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:
JianFeeeee
2026-10-12 17:18:04 +08:00
parent 937164e515
commit fdfbcea479
4 changed files with 387 additions and 4 deletions

View File

@ -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)