Files
HomeAgent/internal/memory/block_recall.go
JianFeeeee 86572d134d refactor(memory)!: 块 union 化,彻底淘汰 CAS 第一阶段(生产代码)
块自带负载(MemoryBlock.Data),删 PayloadDigest;检索纯向量,媒体随块
同生灭。生产代码全绿;CAS 专属测试将在下一提交随判据重写。

- memory: Data BLOB + migration9;blockColumns 不含 data(召回只比向量);
  MediaBlockID 内容派生 ID;BlockByIDPrefix/BlockData 按需加载
- sdk: memory_impl 去 media.Store;MemoryBlockView PayloadDigest→HasData
- core: mediaref/graphmedia/crossmodal/medialoop 全部改块语义;
  captureBlockMedia 直接产块;resolveMediaRefs 按块 ID 解析
- knowledge: 媒体字节随条目目录自带(File+Data),删 mediaGet/mediaPut
- webui: graphBlockView/pulse payload_digest→has_data
- bootstrap/registry: 去 MediaStore 接线(SetMediaStore 空实现,待⑥删包)
2026-10-12 10:13:36 +08:00

285 lines
9.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
// 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()
rows, err := g.db.Query(`SELECT ` + blockColumns + `
FROM memory_blocks
WHERE vector IS NOT NULL AND vector != '' AND vector != 'null'`)
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
}
// ★ 中心化:查询向量与块向量**都要**中心化后再算余弦。
// 只处理一边的话等于在混两种量纲(一半带公共分量、一半不带),
// 算出的余弦没有意义。
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
}