Files
AskaEth 589e26c887 fix(memory): 增量向量化 + 单分片逐条隔离,召回请求数降一个数量级(M61)
M15(2026-10-13)登记的两条放大源落在记忆层,本次一并收口:

1. 整理轮/入库审查**只嵌增量**:新加 vector.Index.Vectors(SQLite/PG 实现,
   按 id 取已存向量、维度不符当未命中),Store.embedIncremental 先复用索引里的
   向量、只嵌缺的那些,并把手算出的向量回填索引 ⇒ 同一分区第二轮起零请求。
   判据写明为「向量存在性 + 维度一致」(正文在本仓不可就地改写,故无需哈希)。
   维度与当前 embedder 不一致时禁用复用并 WARN(不混两种表示)。
2. 嵌入链对单个慢分片有韧性:embedIsolated 整批失败后逐条隔离,坏的那几条
   记缺口、其余照常返回;一条都没拿到才报错降级。请求数上界 1+B(B=8 ⇒ 9),
   服务整体挂死时一次即收手,不用「请求放大」换韧性。
3. 缺口/复用/回填条数进日志(缺口走 WARN),review 的被审条目拿不到向量时
   如实返回错误 ⇒ 走既有词项余弦降级(不把「算不出来」说成「没有重复」)。

请求数(单测内假 OpenAI 兼容服务端计数,生产 API embedder + 真 SQLite 索引):
  24 条存量:修复前每轮 3 → 第一轮 3、第二轮 0
  12 条分区整理轮:第一轮 2 → 第二轮 0
  20 条候选的入库审查:26(201÷8)→ 第一条 3、第二条 0
  一条坏正文:保住 7/8 条,代价 9 个请求;整体性故障 9 个请求后收手
2026-10-30 08:30:06 +08:00

118 lines
6.1 KiB
Go
Raw Permalink 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 (
"context"
"errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
)
// ErrReviewSelfUnembedded 在「被审的那一条自己拿不到向量」时返回:语义召回对它
// 无从谈起(相似度恒 0),因此如实报错让调用方走既有的词项余弦降级,而不是交出
// 一片看起来「没有重复」的空候选(那会把「算不出来」说成「没有」)。
var ErrReviewSelfUnembedded = errors.New("memory/review: the item under review could not be embedded")
// 本文件是入库前审查的**候选召回**(design/23 §15 D3-3:用既有 recall 路径,
// 不许另造一套相似度实现)。
//
// 复用面(逐条,都是整理侧已经存在的实现):
// - 阈值:与整理同一个召回门(config.MemoryTidy.RecallThreshold,§13 开放点 2);
// - 相似度:语义 embedding 的余弦(clampUnit + cosineSim)或零依赖的词项余弦
// (lexicalSimilarity);
// - 收集与排序:collectTidyPairs —— 因此「阈值过滤 + 分数降序 + id 兜底」
// 与整理侧逐字一致,截断也可复现。
//
// 与整理侧**唯一**的差异:候选只保留「新条目 vs 既有条目」的对(整理侧是
// 分区内两两),因为入库前审查永远以刚落库的那一条为中心。
// recallCandidates 在同一分区内召回候选(分区键 = persona_id + scope +
// project_id,design §6 硬约束:分区之间永不比较)。
//
// 返回的 degraded 表示本轮相似度是从语义路径**降级**到词项余弦的(embedder
// 报错或向量条数错位):判定规则一字不改,只把「这一轮的依据不够好」如实
// 带进留痕(§15 D3-8 的「是否降级」)。
func (r *PreInsertReviewer) recallCandidates(ctx context.Context, item MemoryItem) ([]ingestCandidate, bool, error) {
partition := tidyPartition{personaID: item.PersonaID, scope: item.Scope, projectID: item.ProjectID}
existing, err := r.deps.Store.listReviewCandidates(ctx, partition, item.ID, r.cfg.ScanLimit)
if err != nil {
return nil, false, err
}
if len(existing) == 0 {
return nil, false, nil
}
// 新条目固定在首位:reviewPairs 只认它为中心。
items := make([]MemoryItem, 0, len(existing)+1)
items = append(items, item)
items = append(items, existing...)
if r.deps.Embedder == nil {
// 未配置语义 embedding:与整理生产轮同一口径 —— 用零依赖词项余弦,
// 不是降级(这是配置形态,不是故障)。
return reviewPairs(items, r.cfg.Threshold, lexicalSimFunc(items), tidyRecallLexical), false, nil
}
vecs, err := r.embedReviewTexts(ctx, items)
if err != nil {
// 语义召回不可用 ⇒ 降级到与整理生产轮**同一个**词项余弦,并如实标记
// degraded(design §4.1 生产轮回退口径)。降级绝不改变判定规则,
// 也绝不改写任何一行。
r.log.Warn("memory ingest review embedding recall unavailable, falling back to lexical",
"item_id", item.ID, "scope", string(item.Scope), "error", err)
return reviewPairs(items, r.cfg.Threshold, lexicalSimFunc(items), tidyRecallLexical), true, nil
}
return reviewPairs(items, r.cfg.Threshold, vectorSimFunc(vecs), tidyRecallEmbedding), false, nil
}
// reviewPairs 用整理的收集内核(collectTidyPairs)产出候选对,再只保留以
// items[0](新条目)为中心的那些,转成审查候选。首位的 id 为空时无法区分
// 中心,直接返回空 —— 宁可少审一条,也绝不把两条既有记忆当成候选。
func reviewPairs(items []MemoryItem, threshold float64, sim func(i, j int) float64, mode string) []ingestCandidate {
if len(items) < 2 || items[0].ID == "" {
return nil
}
var sink TidyStats
// 零值预算 = 无时间预算:入库前审查的对数由它自己的 ScanLimit 收口
// (那是 §15 D3 切片的护栏),本切片不改它的行为。
pairs, _ := collectTidyPairs(items, threshold, sim, mode, &sink, tidyBudget{})
out := make([]ingestCandidate, 0, len(pairs))
for _, p := range pairs {
switch {
case p.a.ID == items[0].ID:
out = append(out, ingestCandidate{item: p.b, score: p.score, mode: p.mode})
case p.b.ID == items[0].ID:
out = append(out, ingestCandidate{item: p.a, score: p.score, mode: p.mode})
}
}
return out
}
// embedReviewTexts 给「新条 1 + 既有候选 K」取向量:**索引里已有的直接复用,
// 只嵌缺的**(判据与回填口径见 embed_incremental.go)。
//
// 🔴 链长就是**调用方预算的输入**(2026-10-13 实测,见 embedding/budget.go 文件头):
// 条数是「新条 1 + cfg.ScanLimit(默认 200)」= 201,因此一次召回发出的 HTTP 请求数
// 曾是 ⌈201 / tidyEmbedBatch⌉(tidyEmbedBatch = embedding.DefaultBatchLimit):
//
// 批次 4(改前)= 51 个请求 ⇒ 整条链 23.070 / 21.860 s
// 批次 8(M15)= 26 个请求 ⇒ 整条链 21.435 / 21.597 s
// 增量(本切片)= **1 个请求**(只有刚落库的新条没向量;既有候选从索引取),
// 同一分区第二次审查起 = **0 个请求**(新条落库时写入侧已建好向量)
//
// ⇒ 单条审查的预算(memory.defaultReviewTimeout = 90s)因此从「按链长算、勉强够」
// 变成「按链长算、余量极大」。预算本身**不下调**:慢分片与并发仍会拉长单请求,
// 余量是韧性而不是浪费(见 preinsert_config.go 的推导)。
//
// 返回切片恒与 items 等长(缺口为 nil)。被审的那一条(items[0])拿不到向量时
// 返回错误:语义召回对它无从谈起(相似度恒 0),交给调用方走既有的词项余弦降级,
// 而不是交出一片空候选。
func (r *PreInsertReviewer) embedReviewTexts(ctx context.Context, items []MemoryItem) ([]embedding.Vector, error) {
vecs, stats, err := r.deps.Store.embedIncremental(ctx, r.deps.Embedder, items, tidyEmbedBatch)
if err != nil {
return nil, err
}
logEmbedReuse(r.log, "review", stats, tidyEmbedBatch)
if len(vecs) == 0 || len(vecs[0]) == 0 {
return nil, ErrReviewSelfUnembedded
}
return vecs, nil
}