Files
HomeAgent/internal/memory/block_recall_test.go
JianFeeeee fdfbcea479 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。
2026-10-12 17:18:04 +08:00

294 lines
9.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package memory
import (
"math"
"os"
"testing"
)
func newBlockTestGraph(t *testing.T) *GraphDB {
t.Helper()
g := newTestGraph(t)
t.Cleanup(func() {
g.Close()
os.Remove(g.dbPath)
})
return g
}
func vec(vals ...float64) []float64 { return vals }
// ★ 基本召回:按余弦排序,TopK 截断。
func TestRecallBlocks_按余弦排序(t *testing.T) {
g := newBlockTestGraph(t)
mustPut(t, g, []MemoryBlock{
{ID: "b_far", Modality: BlockText, Text: "无关内容",
Vector: vec(0, 1, 0), Fingerprint: "fp1"},
{ID: "b_near", Modality: BlockText, Text: "最相关",
Vector: vec(1, 0, 0), Fingerprint: "fp1"},
{ID: "b_mid", Modality: BlockText, Text: "中等相关",
Vector: vec(0.7, 0.7, 0), Fingerprint: "fp1"},
})
hits, err := g.RecallBlocks(BlockRecallQuery{
Vector: vec(1, 0, 0), Fingerprint: "fp1", TopK: 3,
})
if err != nil {
t.Fatalf("RecallBlocks: %v", err)
}
if len(hits) != 3 {
t.Fatalf("期望 3 条,实际 %d", len(hits))
}
want := []string{"b_near", "b_mid", "b_far"}
for i, id := range want {
if hits[i].Block.ID != id {
t.Errorf("第 %d 位应为 %s,实际 %s(score=%.4f)",
i, id, hits[i].Block.ID, hits[i].Score)
}
}
if math.Abs(hits[0].Score-1.0) > 1e-9 {
t.Errorf("正交向量自身相似度应为 1,实际 %.9f", hits[0].Score)
}
}
// ★ TopK 必须真的截断(否则召回会灌爆上下文——这是本会话实测过的症状)。
func TestRecallBlocks_TopK截断(t *testing.T) {
g := newBlockTestGraph(t)
var blocks []MemoryBlock
for i := 0; i < 20; i++ {
blocks = append(blocks, MemoryBlock{
ID: "b" + itoa(i), Modality: BlockText, Text: "t",
Vector: vec(float64(i+1), 1, 0), Fingerprint: "fp1",
})
}
mustPut(t, g, blocks)
hits, err := g.RecallBlocks(BlockRecallQuery{Vector: vec(1, 0, 0), TopK: 5})
if err != nil {
t.Fatal(err)
}
if len(hits) != 5 {
t.Fatalf("TopK=5 应返回 5 条,实际 %d", len(hits))
}
}
// ★★ 指纹防护:换向量空间后,旧空间的块必须被**跳过**。
//
// 这是本文件最重要的判据。拿新空间的查询向量去比旧空间的块向量,
// 余弦值没有任何意义,而且**不报错**——只是"结果看起来还行但全是错的"。
func TestRecallBlocks_指纹不匹配则跳过(t *testing.T) {
g := newBlockTestGraph(t)
mustPut(t, g, []MemoryBlock{
{ID: "old", Modality: BlockText, Text: "旧空间",
Vector: vec(1, 0, 0), Fingerprint: "old-space"},
{ID: "new", Modality: BlockText, Text: "新空间",
Vector: vec(1, 0, 0), Fingerprint: "new-space"},
})
hits, err := g.RecallBlocks(BlockRecallQuery{
Vector: vec(1, 0, 0), Fingerprint: "new-space", TopK: 10,
})
if err != nil {
t.Fatal(err)
}
if len(hits) != 1 || hits[0].Block.ID != "new" {
t.Fatalf("只应召回新空间的块,实际 %+v", hitIDs(hits))
}
// 旧空间的块若被召回,就是静默污染
for _, h := range hits {
if h.Block.Fingerprint != "new-space" {
t.Errorf("召回了指纹不匹配的块: %s (%s)", h.Block.ID, h.Block.Fingerprint)
}
}
}
// ★★ 维度防护:维度不同必须跳过,**不截断不填充**。
func TestRecallBlocks_维度不匹配则跳过(t *testing.T) {
g := newBlockTestGraph(t)
mustPut(t, g, []MemoryBlock{
{ID: "d512", Modality: BlockText, Text: "旧 512 维",
Vector: vec(1, 0, 0), Fingerprint: "fp"},
{ID: "d2048", Modality: BlockText, Text: "新 2048 维",
Vector: append(vec(1, 0, 0), make([]float64, 2045)...), Fingerprint: "fp"},
})
hits, err := g.RecallBlocks(BlockRecallQuery{
Vector: append(vec(1, 0, 0), make([]float64, 2045)...), Fingerprint: "fp", TopK: 10,
})
if err != nil {
t.Fatal(err)
}
if len(hits) != 1 || hits[0].Block.ID != "d2048" {
t.Fatalf("只应召回同维度的块,实际 %+v", hitIDs(hits))
}
}
// 没有向量的块不参与向量召回(但它们仍可被 BlocksForNode 按边查到)。
func TestRecallBlocks_无向量的块不参与(t *testing.T) {
g := newBlockTestGraph(t)
mustPut(t, g, []MemoryBlock{
{ID: "no_vec", Modality: BlockText, Text: "没向量"},
{ID: "has_vec", Modality: BlockText, Text: "有向量",
Vector: vec(1, 0), Fingerprint: "fp"},
})
hits, err := g.RecallBlocks(BlockRecallQuery{Vector: vec(1, 0), TopK: 10})
if err != nil {
t.Fatal(err)
}
if len(hits) != 1 || hits[0].Block.ID != "has_vec" {
t.Fatalf("无向量的块不该参与召回,实际 %+v", hitIDs(hits))
}
}
// 空查询向量必须报错(而不是静默返回全零分的垃圾结果)。
func TestRecallBlocks_空向量报错(t *testing.T) {
g := newBlockTestGraph(t)
if _, err := g.RecallBlocks(BlockRecallQuery{}); err == nil {
t.Fatal("空查询向量应报错")
}
}
// MinScore 过滤。
func TestRecallBlocks_MinScore过滤(t *testing.T) {
g := newBlockTestGraph(t)
mustPut(t, g, []MemoryBlock{
{ID: "hi", Modality: BlockText, Text: "高", Vector: vec(1, 0), Fingerprint: "fp"},
{ID: "lo", Modality: BlockText, Text: "低", Vector: vec(0, 1), Fingerprint: "fp"},
})
hits, err := g.RecallBlocks(BlockRecallQuery{
Vector: vec(1, 0), TopK: 10, MinScore: 0.5,
})
if err != nil {
t.Fatal(err)
}
if len(hits) != 1 || hits[0].Block.ID != "hi" {
t.Fatalf("MinScore 应滤掉低分候选,实际 %+v", hitIDs(hits))
}
}
// 模态过滤: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)
hits, err := g.RecallBlocks(BlockRecallQuery{Vector: vec(1, 0), TopK: 5})
if err != nil {
t.Fatalf("空库不该报错: %v", err)
}
if len(hits) != 0 {
t.Fatalf("空库应返回 0 条,实际 %d", len(hits))
}
}
// BlockVectorStats 报告回填状态(运维据此判断要不要跑回填)。
func TestBlockVectorStats(t *testing.T) {
g := newBlockTestGraph(t)
mustPut(t, g, []MemoryBlock{
{ID: "a", Modality: BlockText, Text: "有向量有指纹",
Vector: vec(1, 0), Fingerprint: "fpX"},
{ID: "b", Modality: BlockText, Text: "有向量无指纹",
Vector: vec(1, 0)},
{ID: "c", Modality: BlockText, Text: "无向量"},
})
st, err := g.BlockVectorStats()
if err != nil {
t.Fatal(err)
}
if st.Total != 3 || st.WithVector != 2 || st.MissingFP != 1 {
t.Fatalf("统计错误: %+v", st)
}
if len(st.Fingerprints) != 1 || st.Fingerprints[0] != "fpX" {
t.Errorf("指纹集错误: %v", st.Fingerprints)
}
if len(st.Dimensions) != 1 || st.Dimensions[0] != 2 {
t.Errorf("维度集错误: %v", st.Dimensions)
}
}
// ── 辅助 ──
func mustPut(t *testing.T, g *GraphDB, blocks []MemoryBlock) {
t.Helper()
if err := g.PutMemoryBlocks(blocks); err != nil {
t.Fatalf("PutMemoryBlocks: %v", err)
}
}
func hitIDs(hits []BlockHit) []string {
out := make([]string, len(hits))
for i, h := range hits {
out[i] = h.Block.ID
}
return out
}
func itoa(i int) string {
if i == 0 {
return "0"
}
var b []byte
for i > 0 {
b = append([]byte{byte('0' + i%10)}, b...)
i /= 10
}
return string(b)
}