mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
## 回归(块 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。
294 lines
9.1 KiB
Go
294 lines
9.1 KiB
Go
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)
|
||
}
|