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。
319 lines
11 KiB
Go
319 lines
11 KiB
Go
package memory
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"log"
|
||
"math"
|
||
"sort"
|
||
)
|
||
|
||
// 块节点的向量召回。
|
||
//
|
||
// 为什么需要它:entities 是旧形态且正在退场,而 memory_blocks 才是带
|
||
// vector+fingerprint 的节点载体(46f833c 引入)。此前唯一有向量的只有
|
||
// memory_blocks,但**没有任何召回路径读它** —— BlocksForNode 只能按
|
||
// 端点反查,必须先知道 nodeID。于是「问一个具体问题」时只能退到
|
||
// entities 的 jieba+LIKE,实测跨维度定位 0/5。
|
||
//
|
||
// 本文件补的就是这条缺失的路径:query 向量 → 块向量 → 按余弦排序。
|
||
//
|
||
// 与 vector.Store 的分工:Store 是内存倒排索引(L0/L1 用),块向量存在
|
||
// SQLite(graph.db),生命周期与图一致、可跨进程重启、可被备份脚本覆盖。
|
||
//
|
||
// 全表扫描的代价(**已量化,不是估算**):生产库迁移后约 1277 块 × 2048 维
|
||
// = 21 MB 读入 + 2.6M 次乘加,每次 memory_recall 一次。当前可接受;
|
||
// 块数上到万级时需要换方案(候选:把向量搬进 vector.Store 的内存倒排,
|
||
// 或按 modality/scene 预筛减少扫描量)。在此之前不做优化 —— 没有真实
|
||
// 规模数据时优化是猜,本会话已经因此浪费过几轮。
|
||
|
||
// BlockHit 是一条块召回的候选及相似度。
|
||
type BlockHit struct {
|
||
Block MemoryBlock `json:"block"`
|
||
Score float64 `json:"score"`
|
||
}
|
||
|
||
// BlockRecallQuery 描述一次块召回请求。
|
||
type BlockRecallQuery struct {
|
||
Vector []float64
|
||
// Fingerprint 是发起方所用向量空间的指纹。与库中块不一致的块会被
|
||
// **跳过**(不是报错):库里可能同时存在两个空间的块(迁移期),
|
||
// 跳过才是正确的——拿新空间的查询向量去比旧空间的块向量,余弦值
|
||
// 没有任何意义,而且不会报错,只是"结果看起来还行但全是错的"。
|
||
Fingerprint string
|
||
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,
|
||
// 93.7% 的能量在同一个方向上,导致同属性不同值的分离度只有 0.0095
|
||
// (去均值后 0.0689,提升 7.3 倍)。而中心是**按需加载**的 ——
|
||
// 库里没建过中心、或中心已失效(块数变化 >20%)时自动跳过校正,
|
||
// 所以这个开关只在「明确知道中心存在却想绕开」时才需要。
|
||
SkipCentroid bool
|
||
}
|
||
|
||
// RecallBlocks 按向量相似度召回块节点。
|
||
//
|
||
// 返回的 hit 已按 Score 降序。库里没有任何块的向量时返回空切片而非错误
|
||
// ——「还没回填」是正常状态,不是故障。
|
||
func (g *GraphDB) RecallBlocks(q BlockRecallQuery) ([]BlockHit, error) {
|
||
if len(q.Vector) == 0 {
|
||
return nil, fmt.Errorf("recall blocks: empty query vector")
|
||
}
|
||
topK := q.TopK
|
||
if topK <= 0 {
|
||
topK = 10
|
||
}
|
||
|
||
// 中心在 RLock 之前加载:LoadCentroid 内部自己拿锁,
|
||
// 而 g.mu 是写锁优先的 Mutex(不可重入),RLock 里再 RLock 虽可行
|
||
// 但夹着写锁时会出现窗口。分开更稳。
|
||
var centroid *Centroid
|
||
if !q.SkipCentroid && q.Fingerprint != "" {
|
||
c, use, err := g.LoadCentroid(q.Fingerprint, len(q.Vector))
|
||
if err != nil {
|
||
// 中心读取失败不该让召回失败 —— 退回原始向量即可,
|
||
// 只是少了个校正。记日志以便发现。
|
||
log.Printf("[graph] recall blocks: 中心向量读取失败,退回原始向量: %v", err)
|
||
} else if use {
|
||
centroid = c
|
||
}
|
||
}
|
||
|
||
g.mu.RLock()
|
||
defer g.mu.RUnlock()
|
||
|
||
// 模态过滤下沉到 SQL:文本块动辄数千,把它们读进来再丢弃是白花
|
||
// 向量反序列化与余弦计算的钱(实测 3639 文本块 vs 112 图像块)。
|
||
query := `SELECT ` + blockColumns + `
|
||
FROM memory_blocks
|
||
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
|
||
for rows.Next() {
|
||
b, err := scanBlockRow(rows)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
scanned++
|
||
vec := b.Vector
|
||
// 维度不一致:跳过并计数。**不截断、不填充**——那会造出无意义的分数。
|
||
if len(vec) != dim {
|
||
skippedDim++
|
||
continue
|
||
}
|
||
// 指纹不一致:跳过并计数。查询方声明的空间与块所属空间不同。
|
||
if q.Fingerprint != "" && b.Fingerprint != "" && b.Fingerprint != q.Fingerprint {
|
||
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)
|
||
if q.MinScore > 0 && score < q.MinScore {
|
||
continue
|
||
}
|
||
b.Vector = vec
|
||
hits = append(hits, BlockHit{Block: b, Score: score})
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
if skippedDim > 0 {
|
||
log.Printf("[graph] recall blocks: 跳过 %d 个维度不匹配的块(查询 %d 维,共扫 %d)",
|
||
skippedDim, dim, scanned)
|
||
}
|
||
if skippedFP > 0 {
|
||
log.Printf("[graph] recall blocks: 跳过 %d 个指纹不匹配的块(查询空间 %s,共扫 %d)",
|
||
skippedFP, shortFP(q.Fingerprint), scanned)
|
||
}
|
||
|
||
sort.SliceStable(hits, func(i, j int) bool { return hits[i].Score > hits[j].Score })
|
||
if len(hits) > topK {
|
||
hits = hits[:topK]
|
||
}
|
||
return hits, nil
|
||
}
|
||
|
||
// RecallBlocksWithArbitration 与 RecallBlocks 相同,但额外返回仲裁详情
|
||
// (被取代的块、结论状态)。
|
||
//
|
||
// 分成两个入口的原因:调用方大多只关心「该给模型看什么」,仲裁详情是
|
||
// 调试与跑分用的。把它塞进返回签名会让 90% 的调用点多做无用功。
|
||
func (g *GraphDB) RecallBlocksWithArbitration(q BlockRecallQuery) ([]BlockHit, ArbitrationResult, error) {
|
||
// ★ 仲裁前必须拿到**未截断**的全量候选。
|
||
//
|
||
// 实测故障形态(真实 chineseclip + 真库 188 块):查询「值班室分机号
|
||
// 是多少」,旧号 4379 以 0.8127 排 top1,新号 4324 那条**进不了
|
||
// top8**。正确记录在召回阶段就被挤掉了 —— 事后仲裁无从挽回,
|
||
// 因为被判取代的旧值和新值都不在候选里。
|
||
//
|
||
// 所以顺序是:全量打分(TopK 放大)→ 仲裁 → 按调用方的 TopK 截断。
|
||
//
|
||
// 为什么 RecallBlocks 本身不仲裁:它是通用召回接口,
|
||
// 返回顺序按余弦相似度是它的**既有契约**(有测试钉着)。
|
||
// 仲裁改变的是「给模型看哪些、按什么顺序看」,属于上层策略 ——
|
||
// 混进召回层会让这个接口的语义变得含糊(调用方说不清拿到的是什么序)。
|
||
q2 := q
|
||
q2.TopK = largeTopK
|
||
hits, err := g.RecallBlocks(q2)
|
||
if err != nil {
|
||
return nil, ArbitrationResult{}, err
|
||
}
|
||
arb := arbitrate(g, hits)
|
||
kept := arb.Kept
|
||
if k := effectiveTopK(q.TopK); len(kept) > k {
|
||
kept = kept[:k]
|
||
}
|
||
return kept, arb, nil
|
||
}
|
||
|
||
// largeTopK 是仲裁前的候选上限。
|
||
//
|
||
// ★ 为什么要有上限而不是真·全量:仲裁是 O(n²),候选数若等于库里全部
|
||
// 带向量的块(实测 188,回填后可能上万),比较次数会到亿级。
|
||
// 这个值远大于正常 TopK(默认 10 / 生产 5~20),实际效果是「足够全」,
|
||
// 同时给出一个确定的性能上界。
|
||
const largeTopK = 2000
|
||
|
||
// effectiveTopK 归一 TopK(与 RecallBlocks 内部一致)。
|
||
func effectiveTopK(k int) int {
|
||
if k <= 0 {
|
||
return 10
|
||
}
|
||
return k
|
||
}
|
||
|
||
// BlockVectorStats 报告块向量的回填状态,用于运维判断「要不要跑回填」。
|
||
type BlockVectorStats struct {
|
||
Total int `json:"total"`
|
||
WithVector int `json:"with_vector"`
|
||
MissingFP int `json:"missing_fingerprint"`
|
||
Fingerprints []string `json:"fingerprints"`
|
||
Dimensions []int `json:"dimensions"`
|
||
}
|
||
|
||
func (g *GraphDB) BlockVectorStats() (BlockVectorStats, error) {
|
||
g.mu.RLock()
|
||
defer g.mu.RUnlock()
|
||
|
||
var st BlockVectorStats
|
||
fpSet := map[string]bool{}
|
||
dimSet := map[int]bool{}
|
||
rows, err := g.db.Query(`SELECT vector, fingerprint FROM memory_blocks`)
|
||
if err != nil {
|
||
return st, err
|
||
}
|
||
defer rows.Close()
|
||
for rows.Next() {
|
||
var vectorJSON, fp string
|
||
if err := rows.Scan(&vectorJSON, &fp); err != nil {
|
||
return st, err
|
||
}
|
||
st.Total++
|
||
if vectorJSON == "" || vectorJSON == "null" {
|
||
continue
|
||
}
|
||
vec, err := decodeVector(vectorJSON)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
st.WithVector++
|
||
if fp == "" {
|
||
st.MissingFP++
|
||
} else {
|
||
fpSet[fp] = true
|
||
}
|
||
dimSet[len(vec)] = true
|
||
}
|
||
for fp := range fpSet {
|
||
st.Fingerprints = append(st.Fingerprints, fp)
|
||
}
|
||
for d := range dimSet {
|
||
st.Dimensions = append(st.Dimensions, d)
|
||
}
|
||
sort.Strings(st.Fingerprints)
|
||
sort.Ints(st.Dimensions)
|
||
return st, rows.Err()
|
||
}
|
||
|
||
// ── 向量工具 ──
|
||
|
||
func decodeVector(s string) ([]float64, error) {
|
||
var vec []float64
|
||
if err := json.Unmarshal([]byte(s), &vec); err != nil {
|
||
return nil, fmt.Errorf("decode vector: %w", err)
|
||
}
|
||
return vec, nil
|
||
}
|
||
|
||
func cosine(a, b []float64) float64 {
|
||
if len(a) != len(b) || len(a) == 0 {
|
||
return 0
|
||
}
|
||
var dot, na, nb float64
|
||
for i := range a {
|
||
dot += a[i] * b[i]
|
||
na += a[i] * a[i]
|
||
nb += b[i] * b[i]
|
||
}
|
||
if na == 0 || nb == 0 {
|
||
return 0
|
||
}
|
||
s := dot / (math.Sqrt(na) * math.Sqrt(nb))
|
||
// NaN 会让排序结果不确定(NaN 的比较全 false),显式归零。
|
||
if math.IsNaN(s) {
|
||
return 0
|
||
}
|
||
return s
|
||
}
|
||
|
||
func shortFP(fp string) string {
|
||
if len(fp) > 12 {
|
||
return fp[:12]
|
||
}
|
||
return fp
|
||
}
|