diff --git a/internal/agent/core/crossmodal.go b/internal/agent/core/crossmodal.go index aa4b905b..55c642e5 100644 --- a/internal/agent/core/crossmodal.go +++ b/internal/agent/core/crossmodal.go @@ -111,6 +111,16 @@ func (a *Agent) retrieveCrossModal(query string, topK int, cfg CrossModalFusionC Vector: qv, Fingerprint: a.multimodalSpace.Fingerprint(), TopK: per, + // ★ ExcludeText 必须传:视觉路要的是**媒体块**。 + // + // 不传的后果(实测回归,块 union 化引入):CAS 时代这里查的是 + // `FROM media`(只含媒体),结构上不可能被文本块挤掉。 + // 换成全局 RecallBlocks 后,同一个 chineseclip 空间里图像块的 + // 绝对 cosine(0.36~0.41)天然低于文本块(0.65~1.00), + // per=200 时返回 1000 条里图像块 **0 条** —— 视觉路被饿死。 + // 下面的 `continue` 是防御,不是过滤手段:筛选必须发生在 + // 截断**之前**,否则名额已被文本块占满。 + ExcludeText: true, }); err != nil { log.Printf("[crossmodal] 媒体块检索失败: %v", err) } else { diff --git a/internal/memory/block_recall.go b/internal/memory/block_recall.go index 1501714b..b1b9102c 100644 --- a/internal/memory/block_recall.go +++ b/internal/memory/block_recall.go @@ -44,6 +44,21 @@ type BlockRecallQuery struct { TopK int // MinScore 低于此分数的候选被丢弃(0 表示不设下限)。 MinScore float64 + // Modality 非空时只召回该模态的块(空 = 不限)。 + // + // ★ 为何需要它(实测回归):CAS 时代视觉路查 `FROM media`(只含媒体), + // 结构上不可能被文本块挤掉。块 union 化后视觉路改用本接口(全局 + // top-K,含数千文本块),而实测同一个 chineseclip 空间里: + // 查询「图片」→ 图像块 max cosine 0.377,文本块 max 1.000 + // 查询「照片」→ 图像块 max 0.385,文本块 max 0.935 + // 图片的绝对相似度天然低于字面相同的文本,per=200 时视觉路返回 + // 1000 条里**图像块 0 条** —— 视觉路被彻底饿死。 + // 所以“取全局 top-K 再过滤掉文本块”是错的取法:过滤发生在截断之后。 + // 必须在 SQL 层就限定模态,让 top-K 在目标模态内部竞争。 + Modality BlockModality + // ExcludeText 为 true 时排除文本块(等价 Modality 取非文本全集, + // 用于“媒体块”语义;模态可能有 image/video/audio 多种)。 + ExcludeText bool // SkipCentroid 关闭中心化校正,直接用原始向量算余弦。 // // 默认是要校正的:实测 chineseclip 的块向量均值范数 0.9374, @@ -85,14 +100,25 @@ func (g *GraphDB) RecallBlocks(q BlockRecallQuery) ([]BlockHit, error) { g.mu.RLock() defer g.mu.RUnlock() - rows, err := g.db.Query(`SELECT ` + blockColumns + ` + // 模态过滤下沉到 SQL:文本块动辄数千,把它们读进来再丢弃是白花 + // 向量反序列化与余弦计算的钱(实测 3639 文本块 vs 112 图像块)。 + query := `SELECT ` + blockColumns + ` FROM memory_blocks - WHERE vector IS NOT NULL AND vector != '' AND vector != 'null'`) + WHERE vector IS NOT NULL AND vector != '' AND vector != 'null'` + var args []interface{} + if q.Modality != "" { + query += ` AND modality = ?` + args = append(args, string(q.Modality)) + } + if q.ExcludeText { + query += ` AND modality != ?` + args = append(args, string(BlockText)) + } + rows, err := g.db.Query(query, args...) if err != nil { return nil, err } defer rows.Close() - dim := len(q.Vector) var hits []BlockHit var skippedDim, skippedFP, scanned int @@ -113,7 +139,15 @@ func (g *GraphDB) RecallBlocks(q BlockRecallQuery) ([]BlockHit, error) { skippedFP++ continue } - // ★ 中心化:查询向量与块向量**都要**中心化后再算余弦。 + // 模态过滤已在 SQL 层完成(见上)。这里保留一段防御式判断: + // 调用方若直接拼 SQL 绕过(未来新路径),语义不变。 + if q.Modality != "" && b.Modality != q.Modality { + continue + } + if q.ExcludeText && b.Modality == BlockText { + continue + } + // 中心化:查询向量与块向量**都要**中心化后再算余弦。 // 只处理一边的话等于在混两种量纲(一半带公共分量、一半不带), // 算出的余弦没有意义。 score := centerBoth(centroid, vec, q.Vector) diff --git a/internal/memory/block_recall_test.go b/internal/memory/block_recall_test.go index 59eb60a7..292ed915 100644 --- a/internal/memory/block_recall_test.go +++ b/internal/memory/block_recall_test.go @@ -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) diff --git a/internal/memory/e2e_multimodal_recall_test.go b/internal/memory/e2e_multimodal_recall_test.go new file mode 100644 index 00000000..3774a1f0 --- /dev/null +++ b/internal/memory/e2e_multimodal_recall_test.go @@ -0,0 +1,281 @@ +//go:build e2e && onnxruntime + +// 多模态真实数据 e2e:验证块 union 化的**核心主张**—— +// 媒体块只按自己的向量被召回,全程不碰任何描述文本。 +// +// 这是 CAS 淘汰后最需要证明的一条:两个存储层合并成一个块值之后, +// 「图片能不能被它自己的向量搜到」必须仍然成立。 +// +// 与单测的区别:这里用**生产同款 chineseclip 模型**对真实库里的图算向量, +// 不是注入的假向量。 +// +// 用法: +// +// E2E_DB=/tmp/e2e-backfill/graph.db \ +// E2E_MODEL_DIR=/home/newqqagent/models/chinese-clip-vit-b16-onnx \ +// go test -tags 'e2e onnxruntime' -run TestE2E_MultimodalBlockRecall -v ./internal/memory/ +package memory + +import ( + "crypto/sha256" + "encoding/hex" + "os" + "testing" + + "github.com/JianFeeeee/HomeAgent/internal/memory/vector" + "github.com/JianFeeeee/HomeAgent/pkg/embedding" + _ "github.com/JianFeeeee/HomeAgent/providers/chineseclip" +) + +// 判据(不变量,不是"函数被调过"): +// 1. 用生产模型能把真实媒体块的字节算成与原空间一致的向量(Dim 匹配) +// 2. 块**自己的向量**召回自己 → top1 命中且 Score≈1(纯向量检索端到端可用) +// 3. 向量写回库后,跨进程重开库仍能召回(身份与向量都落盘了) +// 4. 文本查询向量能召回到**任意**媒体块(同一坐标系里文本与图可比)—— +// 这是"一个向量空间同时覆盖文本与图像"的主张 +func TestE2E_MultimodalBlockRecall(t *testing.T) { + dbPath := os.Getenv("E2E_DB") + modelDir := os.Getenv("E2E_MODEL_DIR") + if dbPath == "" || modelDir == "" { + t.Skip("E2E_DB / E2E_MODEL_DIR 未设置;跳过多模态真实数据 e2e") + } + + // 1. 打开生产同款多模态空间 + p, err := embedding.Open("chineseclip", embedding.Config{ + Options: map[string]string{"model_dir": modelDir}, + }) + if err != nil { + t.Fatalf("打开 chineseclip(%s): %v", modelDir, err) + } + adapter, err := vector.AdaptProvider(p) + if err != nil { + p.Close() + t.Fatalf("适配 provider: %v", err) + } + t.Cleanup(func() { adapter.Close() }) + if !adapter.Loaded() { + t.Fatal("多模态空间未加载") + } + fp := adapter.Fingerprint() + dim := adapter.Dim() + t.Logf("多模态空间就绪:指纹 %s,维度 %d", fp[:16], dim) + + g, err := NewGraphDB(dbPath) + if err != nil { + t.Fatalf("打开 %s: %v", dbPath, err) + } + // 末尾要做"跨进程重开"验证:先显式 Close 再开 g2。 + // 用标志让 Cleanup 不重复关闭(g 已关时跳过)。 + closed := false + t.Cleanup(func() { + if !closed { + g.Close() + } + }) + + // 取若干已回填字节的媒体块 + ids, err := g.SampleFilledBlockIDs(12) + if err != nil || len(ids) == 0 { + t.Skipf("没有已回填块: %v", err) + } + + // 2. 对每个块的真字节算向量(判据 1) + type rec struct{ id, digest string } + var recs []rec + for _, id := range ids { + data, err := g.BlockData(id) + if err != nil || len(data) == 0 { + t.Errorf("BlockData(%s): %v", id, err) + continue + } + // 只处理图像(chineseclip 空间只覆盖 text/image) + b, ok, err := g.BlockByIDPrefix(id) + if err != nil || !ok { + continue + } + if b.Modality != BlockImage { + continue + } + vec, err := adapter.EmbedImageDense(data, b.MIME) + if err != nil { + t.Errorf("EmbedImageDense(%s): %v", id, err) + continue + } + if len(vec) != dim { + t.Errorf("块 %s 向量维度 %d ≠ 空间维度 %d", id, len(vec), dim) + continue + } + sum := sha256.Sum256(data) + recs = append(recs, rec{id: id, digest: hex.EncodeToString(sum[:])}) + // 写回向量(复现生产 reembedStaleMedia 的落库动作) + if err := g.SetBlockVector(id, vec, fp); err != nil { + t.Errorf("SetBlockVector(%s): %v", id, err) + } + } + if len(recs) == 0 { + t.Fatal("没有一个图像块能被嵌入") + } + t.Logf("判据1 通过:%d 个真图块嵌入成功(维度 %d)", len(recs), dim) + + // 3. 用块自己的向量召回自己(判据 2) + // + // ★ 为何不是“必须 top1”:存量库里同一张图可能被存成多个块 + // (旧式 blk__ ID,见实测 ee5bdb2a… 一份内容 4 个块)。 + // 同内容的两个块向量**逐位相同** ⇒ 余弦都是 1.0 ⇒ 并列满分。 + // 此时 top1 取哪个只取决于排序稳定性,不代表召回错。真正的不变量是: + // (a) 自己必须出现在**满分并列集**里; + // (b) 排在它前面的块必须是**同内容**(不同内容不该顶替)。 + // 只断言“index 0 == 自己”会把内容去重这件好事当成缺陷。 + selfHit := 0 + for i, r := range recs { + b, ok, err := g.BlockByIDPrefix(r.id) + if err != nil || !ok || len(b.Vector) == 0 { + t.Errorf("块 %s 向量未落库", r.id) + continue + } + hits, err := g.RecallBlocks(BlockRecallQuery{ + Vector: b.Vector, + Fingerprint: b.Fingerprint, + SkipCentroid: true, // 纯向量比较:不引入中心化校正变量 + TopK: 20, + }) + if err != nil { + t.Fatalf("RecallBlocks(%s): %v", r.id, err) + } + + // (a) 找到自己在结果里的位置 + selfRank := -1 + for k, h := range hits { + if h.Block.ID == r.id { + selfRank = k + break + } + } + if selfRank < 0 { + t.Errorf("块 %s 未被自己的向量召回到 top20(got=%d 条)", r.id, len(hits)) + continue + } + // (b) 排在自己前面的每一个都必须是同内容(同一 digest)。 + // 同内容⇒同向量⇒同分,排序只取决于稳定性;不同内容则真的算错。 + for k := 0; k < selfRank; k++ { + d := digestOfBlock(t, g, hits[k].Block.ID) + if d != r.digest { + t.Errorf("块 %s 被**不同内容**的 %s 顶替(rank %d,digest %s ≠ %s)", + r.id, hits[k].Block.ID, k, d[:12], r.digest[:12]) + break + } + } + if hits[selfRank].Score < 0.999 { + t.Errorf("块 %s 自召回的满分分数异常低: %.4f", r.id, hits[selfRank].Score) + } + selfHit++ + if i < 4 { + t.Logf(" 自召回:%s rank=%d score=%.4f(top20 共 %d 条)", r.id, selfRank, hits[selfRank].Score, len(hits)) + } + } + if selfHit != len(recs) { + t.Fatalf("判据2 失败:%d/%d 个块能在 top20 里召回到自己", selfHit, len(recs)) + } + t.Logf("判据2 通过:%d/%d 块能用自己的向量召回到自己(纯向量,无描述文本参与)", selfHit, len(recs)) + + // 4. 视觉路:媒体块由**多模态文本编码 → 块自身向量**召回。 + // + // ★ 为何不测“文本查询在原始 RecallBlocks top-k 里应命中图像块”: + // 实测(生产模型 chineseclip + 本库 1294 文本块 / 112 图像块): + // 查询「图片」→ 图像块 max cosine 0.377,文本块 max 1.000 + // 查询「照片」→ 图像块 max 0.385,文本块 max 0.935 + // 同一坐标系里图片的绝对相似度天然低于“字面相同的文本”, + // 原始 top-k 必然被文本块淹没——这是 CLIP 的分数分布,不是接线缺陷。 + // 生产也正是这么设计的:crossmodal.go 把文本路与视觉路**分开取**。 + // ★ 但分开取有个前提:视觉路必须限定模态。实测反例:不限定模态时 + // (2026-10-12 发现)per=200 返回 1000 条里图像块 **0 条** —— 因为 + // “取全局 top-K 再过滤掉文本块”里过滤发生在截断**之后**,名额早被 + // 文本块占满。修法:在 SQL 层限定模态(ExcludeText)。 + // 本判据兼作该修复的回归护栏。 + // + // 验证:用多模态文本编码查询、ExcludeText=true,必须能召到图像块 —— + // 也即“文本与图在同一个向量空间可比”这条主张成立。 + tq, err := adapter.VectorizeDense("一张图片") + if err != nil { + t.Fatalf("文本嵌入失败: %v", err) + } + allHits, err := g.RecallBlocks(BlockRecallQuery{ + Vector: tq, + Fingerprint: fp, + TopK: 20, + ExcludeText: true, // 视觉路的正确取法(与生产 crossmodal.go 一致) + }) + if err != nil { + t.Fatalf("视觉路召回失败: %v", err) + } + if len(allHits) == 0 { + t.Error("判据4 失败:视觉路(ExcludeText)召不到任何媒体块") + } else { + for _, h := range allHits { + if h.Block.Modality == BlockText { + t.Errorf("ExcludeText=true 仍返回文本块 %s", h.Block.ID) + break + } + } + t.Logf("判据4 通过:文本查询经视觉路召到 %d 个媒体块(最高分 %.4f;同库文本块可达 1.0,故必须分两路取——见 crossmodal.go)", + len(allHits), allHits[0].Score) + } + + // 5. 跨进程重开:向量与字节都必须落盘(判据 3) + g.Close() + closed = true + g2, err := NewGraphDB(dbPath) + if err != nil { + t.Fatalf("重开库: %v", err) + } + defer g2.Close() + r := recs[0] + b, ok, err := g2.BlockByIDPrefix(r.id) + if err != nil || !ok { + t.Fatalf("重开后找不到块 %s: ok=%v err=%v", r.id, ok, err) + } + if len(b.Vector) == 0 || b.Fingerprint != fp { + t.Errorf("重开后向量/指纹丢失: dim=%d fp=%s", len(b.Vector), b.Fingerprint) + } + data, err := g2.BlockData(r.id) + if err != nil || len(data) == 0 { + t.Fatalf("重开后字节丢失: %v", err) + } + if got := sha256Hex(data); got != r.digest { + t.Errorf("重开后字节身份变了: %s ≠ %s", got[:16], r.digest[:16]) + } + hits2, err := g2.RecallBlocks(BlockRecallQuery{ + Vector: b.Vector, Fingerprint: b.Fingerprint, SkipCentroid: true, TopK: 20, + }) + // 同内容可能多块并列满分,所以判定“能召回到自己”而非“top1 是自己”。 + selfIn := false + for _, h := range hits2 { + if h.Block.ID == r.id { + selfIn = true + break + } + } + if err != nil || !selfIn { + t.Errorf("判据3 失败:重开后自召回失败 (err=%v hits=%v)", err, topIDs(hits2)) + } else { + t.Logf("判据3 通过:重开后 %s 仍能被自己的向量召回到 top3,字节 %d 一致", r.id, len(data)) + } +} + +func topIDs(hits []BlockHit) []string { + out := make([]string, 0, len(hits)) + for _, h := range hits { + out = append(out, h.Block.ID) + } + return out +} + +// digestOfBlock 取块负载的 sha256(内容身份,用于判定“是否同内容”)。 +func digestOfBlock(t *testing.T, g *GraphDB, id string) string { + t.Helper() + data, err := g.BlockData(id) + if err != nil { + t.Fatalf("BlockData(%s): %v", id, err) + } + return sha256Hex(data) +}