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:
@ -111,6 +111,16 @@ func (a *Agent) retrieveCrossModal(query string, topK int, cfg CrossModalFusionC
|
|||||||
Vector: qv,
|
Vector: qv,
|
||||||
Fingerprint: a.multimodalSpace.Fingerprint(),
|
Fingerprint: a.multimodalSpace.Fingerprint(),
|
||||||
TopK: per,
|
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 {
|
}); err != nil {
|
||||||
log.Printf("[crossmodal] 媒体块检索失败: %v", err)
|
log.Printf("[crossmodal] 媒体块检索失败: %v", err)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -44,6 +44,21 @@ type BlockRecallQuery struct {
|
|||||||
TopK int
|
TopK int
|
||||||
// MinScore 低于此分数的候选被丢弃(0 表示不设下限)。
|
// MinScore 低于此分数的候选被丢弃(0 表示不设下限)。
|
||||||
MinScore float64
|
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 关闭中心化校正,直接用原始向量算余弦。
|
// SkipCentroid 关闭中心化校正,直接用原始向量算余弦。
|
||||||
//
|
//
|
||||||
// 默认是要校正的:实测 chineseclip 的块向量均值范数 0.9374,
|
// 默认是要校正的:实测 chineseclip 的块向量均值范数 0.9374,
|
||||||
@ -85,14 +100,25 @@ func (g *GraphDB) RecallBlocks(q BlockRecallQuery) ([]BlockHit, error) {
|
|||||||
g.mu.RLock()
|
g.mu.RLock()
|
||||||
defer g.mu.RUnlock()
|
defer g.mu.RUnlock()
|
||||||
|
|
||||||
rows, err := g.db.Query(`SELECT ` + blockColumns + `
|
// 模态过滤下沉到 SQL:文本块动辄数千,把它们读进来再丢弃是白花
|
||||||
|
// 向量反序列化与余弦计算的钱(实测 3639 文本块 vs 112 图像块)。
|
||||||
|
query := `SELECT ` + blockColumns + `
|
||||||
FROM memory_blocks
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
dim := len(q.Vector)
|
dim := len(q.Vector)
|
||||||
var hits []BlockHit
|
var hits []BlockHit
|
||||||
var skippedDim, skippedFP, scanned int
|
var skippedDim, skippedFP, scanned int
|
||||||
@ -113,7 +139,15 @@ func (g *GraphDB) RecallBlocks(q BlockRecallQuery) ([]BlockHit, error) {
|
|||||||
skippedFP++
|
skippedFP++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// ★ 中心化:查询向量与块向量**都要**中心化后再算余弦。
|
// 模态过滤已在 SQL 层完成(见上)。这里保留一段防御式判断:
|
||||||
|
// 调用方若直接拼 SQL 绕过(未来新路径),语义不变。
|
||||||
|
if q.Modality != "" && b.Modality != q.Modality {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if q.ExcludeText && b.Modality == BlockText {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 中心化:查询向量与块向量**都要**中心化后再算余弦。
|
||||||
// 只处理一边的话等于在混两种量纲(一半带公共分量、一半不带),
|
// 只处理一边的话等于在混两种量纲(一半带公共分量、一半不带),
|
||||||
// 算出的余弦没有意义。
|
// 算出的余弦没有意义。
|
||||||
score := centerBoth(centroid, vec, q.Vector)
|
score := centerBoth(centroid, vec, q.Vector)
|
||||||
|
|||||||
@ -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) {
|
func TestRecallBlocks_空库不报错(t *testing.T) {
|
||||||
g := newBlockTestGraph(t)
|
g := newBlockTestGraph(t)
|
||||||
|
|||||||
281
internal/memory/e2e_multimodal_recall_test.go
Normal file
281
internal/memory/e2e_multimodal_recall_test.go
Normal file
@ -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_<ts>_<n> 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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user