From fdfbcea4790fb353846a6cd1e5bf1516690ec3dc Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 12 Oct 2026 17:18:04 +0800 Subject: [PATCH] =?UTF-8?q?fix(memory)!:=20=E8=A7=86=E8=A7=89=E8=B7=AF?= =?UTF-8?q?=E8=A2=AB=E6=96=87=E6=9C=AC=E5=9D=97=E9=A5=BF=E6=AD=BB=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E6=A8=A1=E6=80=81=E8=BF=87=E6=BB=A4?= =?UTF-8?q?=E5=BF=85=E9=A1=BB=E5=9C=A8=E6=88=AA=E6=96=AD=E4=B9=8B=E5=89=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 回归(块 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。 --- internal/agent/core/crossmodal.go | 10 + internal/memory/block_recall.go | 42 ++- internal/memory/block_recall_test.go | 58 ++++ internal/memory/e2e_multimodal_recall_test.go | 281 ++++++++++++++++++ 4 files changed, 387 insertions(+), 4 deletions(-) create mode 100644 internal/memory/e2e_multimodal_recall_test.go 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) +}