mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
fix(memory)!: 视觉路被文本块饿死 —— 模态过滤必须在截断之前
## 回归(块 union 化引入,真实数据实测)
CAS 时代 `crossmodal.go` 的视觉路查 `QueryMediaScored`,SQL 是
`FROM media`——**只含媒体**,结构上不可能被文本块挤掉。
86572d13 把它换成通用 `RecallBlocks`(全局 top-K,含数千文本块),
于是视觉路每查必空。
## 定量证据(生产模型 chineseclip + 真实生产库副本)
同一向量空间内,文本查询与两类块的原始余弦:
| 查询 | 图像块 max / p95 | 文本块 max / p95 |
|---|---|---|
| 图片 | 0.3773 / 0.3554 | 1.0000 / 0.8644 |
| 一张图片 | 0.4096 / 0.3707 | 0.9222 / 0.8693 |
| 照片 | 0.3850 / 0.3651 | 0.9349 / 0.8672 |
图片的绝对相似度天然低于「字面相同的文本」。复刻生产调用
(per = max(8, topK*2),再 `continue` 掉文本块):
per= 8 → 5 次查询 40 条,图像块 0 条
per=200 → 5 次查询 1000 条,图像块 0 条 ← 视觉路被彻底饿死
根因:**「取全局 top-K 再过滤掉文本块」——过滤发生在截断之后**,
名额早已被文本块占满。下面的 `continue` 只是防御,不是筛选手段。
## 修法
`BlockRecallQuery` 新增 `Modality` / `ExcludeText`,并在 **SQL 层**
限定(不是取回来再丢):
- 语义正确:让 top-K 在目标模态内部竞争
- 顺带省算力:不必反序列化 3639 个文本块的向量再丢弃
`crossmodal.go` 视觉路传 `ExcludeText: true`。修复后同一诊断:
`per=200 → 560 条全部是图像块,文本块 0 条`。
## 判据(含变异)
- `TestRecallBlocks_模态过滤在截断前生效`(单测,500 满分文本块 + 2 低分媒体块):
① `ExcludeText` TopK=2 仍取到 2 个媒体块;② `Modality=image` 只返回图像块。
**变异**(同时移除 SQL 过滤与循环内 continue)→ 两条断言当场 FAIL。
注:只移除 SQL 过滤而保留循环内 continue **不算变异**——语义等价,
差别仅在性能(这点也实测确认过)。
- `TestE2E_MultimodalBlockRecall`(-tags 'e2e onnxruntime',生产模型 + 真库):
① 12 个真图块可嵌入(512 维);② 12/12 块能被自己的向量召回(纯向量);
③ 跨进程重开后向量/指纹/字节都在;④ `ExcludeText` 视觉路召到 20 个媒体块。
判据②原写成"必须 top1",实测发现同一张图被存成 4 个块(digest 相同)
→ 向量逐位相同 → 并列满分,top1 只取决于排序稳定性。
改为"必须出现在满分并列集,且排在它前面的都必须是同内容"。
回归:memory / agent-core / sdk 全绿;全仓生产编译 GREEN。
This commit is contained in:
@ -167,6 +167,64 @@ func TestRecallBlocks_MinScore过滤(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 模态过滤:ExcludeText / Modality 必须在**截断之前**生效。
|
||||
//
|
||||
// 这是块 union 化引入的真实回归的判据(2026-10-12):
|
||||
// 视觉路从 CAS 时代的 `FROM media`(只含媒体)改成全局 RecallBlocks,
|
||||
// 而实测同一 chineseclip 空间里图像块绝对 cosine(0.36~0.41)天然低于
|
||||
// 文本块(0.65~1.00)——「取全局 top-K 再过滤掉文本块」会让名额先被文本
|
||||
// 块占满,视觉路必空(实测 per=200 返回 1000 条里图像块 0 条)。
|
||||
//
|
||||
// 所以判据不能只查“结果里没有文本块”(那是过滤后的必然),
|
||||
// 而是:**文本块数量远超名额时,媒体块仍能占满名额**。
|
||||
func TestRecallBlocks_模态过滤在截断前生效(t *testing.T) {
|
||||
g := newBlockTestGraph(t)
|
||||
var blocks []MemoryBlock
|
||||
// 500 个文本块,相似度全都**高于**媒体块
|
||||
for i := 0; i < 500; i++ {
|
||||
blocks = append(blocks, MemoryBlock{
|
||||
ID: "txt" + string(rune('a'+i%26)) + string(rune('0'+i/26)),
|
||||
Modality: BlockText, Text: "文本",
|
||||
Vector: vec(1, 0), Fingerprint: "fp", // 与查询完全一致:1.0
|
||||
})
|
||||
}
|
||||
// 2 个媒体块,相似度明显低
|
||||
blocks = append(blocks,
|
||||
MemoryBlock{ID: "img1", Modality: BlockImage, MIME: "image/png", Data: []byte("a"),
|
||||
Vector: vec(0.5, 0.5), Fingerprint: "fp"},
|
||||
MemoryBlock{ID: "img2", Modality: BlockAudio, MIME: "audio/wav", Data: []byte("b"),
|
||||
Vector: vec(0.4, 0.6), Fingerprint: "fp"},
|
||||
)
|
||||
mustPut(t, g, blocks)
|
||||
|
||||
// ① ExcludeText:TopK=2 —— 若过滤在截断后,会被 500 个满分文本块占满 → 0 条
|
||||
hits, err := g.RecallBlocks(BlockRecallQuery{
|
||||
Vector: vec(1, 0), TopK: 2, ExcludeText: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(hits) != 2 {
|
||||
t.Fatalf("ExcludeText 应取到 2 个媒体块,实际 %d 条: %v", len(hits), hitIDs(hits))
|
||||
}
|
||||
for _, h := range hits {
|
||||
if h.Block.Modality == BlockText {
|
||||
t.Errorf("ExcludeText 仍返回文本块: %s", h.Block.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// ② Modality=image:只要图像,不要音频
|
||||
hits, err = g.RecallBlocks(BlockRecallQuery{
|
||||
Vector: vec(1, 0), TopK: 5, Modality: BlockImage,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(hits) != 1 || hits[0].Block.ID != "img1" {
|
||||
t.Fatalf("Modality=image 应只返回 img1,实际 %v", hitIDs(hits))
|
||||
}
|
||||
}
|
||||
|
||||
// 库里没有任何块时返回空切片而非错误("还没回填"是正常状态)。
|
||||
func TestRecallBlocks_空库不报错(t *testing.T) {
|
||||
g := newBlockTestGraph(t)
|
||||
|
||||
Reference in New Issue
Block a user