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 个请求后收手
This commit is contained in:
AskaEth committed 2026-10-30 08:30:06 +08:00
1 parent 9d28477e3a
commit 589e26c887
15 files changed
+1343 -48

No files matched your search

+29 -27
View File
@@ -2,10 +2,16 @@ 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 路径,
// 不许另造一套相似度实现)。
//
@@ -44,7 +50,7 @@ func (r *PreInsertReviewer) recallCandidates(ctx context.Context, item MemoryIte
// 不是降级(这是配置形态,不是故障)。
return reviewPairs(items, r.cfg.Threshold, lexicalSimFunc(items), tidyRecallLexical), false, nil
}
vecs, err := embedReviewTexts(ctx, r.deps.Embedder, items)
vecs, err := r.embedReviewTexts(ctx, items)
if err != nil {
// 语义召回不可用 ⇒ 降级到与整理生产轮**同一个**词项余弦,并如实标记
// degraded(design §4.1 生产轮回退口径)。降级绝不改变判定规则,
@@ -79,37 +85,33 @@ func reviewPairs(items []MemoryItem, threshold float64, sim func(i, j int) float
return out
}
// embedReviewTexts 分批把条目向量化(批次上限复用整理侧的 tidyEmbedBatch:
// 不把整个分区的文本一次性交给远端 embedding API)。单批失败即整体失败,
// 由调用方决定降级还是上报,绝不半途改用混合表示。
// embedReviewTexts 给「新条 1 + 既有候选 K」取向量:**索引里已有的直接复用,
// 只嵌缺的**(判据与回填口径见 embed_incremental.go)。
//
// 🔴 链长就是**调用方预算的输入**(2026-10-13 实测,见 embedding/budget.go 文件头):
// 条数是「新条 1 + cfg.ScanLimit(默认 200)」= 201,因此一次召回发出的 HTTP 请求数
// = ⌈201 / tidyEmbedBatch⌉(tidyEmbedBatch = embedding.DefaultBatchLimit):
// 曾是 ⌈201 / tidyEmbedBatch⌉(tidyEmbedBatch = embedding.DefaultBatchLimit):
//
// 批次 4(改前)= 51 个请求 ⇒ 整条链 23.070 / 21.860 s
// 批次 8(改后)= 26 个请求 ⇒ 整条链 21.435 / 21.597 s
// 批次 8(M15)= 26 个请求 ⇒ 整条链 21.435 / 21.597 s
// 增量(本切片)= **1 个请求**(只有刚落库的新条没向量;既有候选从索引取),
// 同一分区第二次审查起 = **0 个请求**(新条落库时写入侧已建好向量)
//
// 两点必须一起读:① 请求数减半是本改动**唯一**买到的收益 —— 链路墙钟由
// 「条数 × 每条成本」主导,两个批次形状都是 ~21s;② 所以单条审查的预算
// (memory.defaultReviewTimeout)必须按**整条链**算,20s 的旧值比链长还短,
// 会让每一条候选满额的审查都超时(这正是「92% 的审查 recall_mode 为空」的根因)。
func embedReviewTexts(ctx context.Context, emb embedding.Embedder, items []MemoryItem) ([]embedding.Vector, error) {
out := make([]embedding.Vector, 0, len(items))
for start := 0; start < len(items); start += tidyEmbedBatch {
end := min(start+tidyEmbedBatch, len(items))
texts := make([]string, 0, end-start)
for _, it := range items[start:end] {
texts = append(texts, it.Content)
}
vecs, err := emb.Embed(ctx, texts)
if err != nil {
return nil, err
}
if len(vecs) != len(texts) {
return nil, ErrTidyEmbedCount
}
out = append(out, vecs...)
// ⇒ 单条审查的预算(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
}
return out, nil
logEmbedReuse(r.log, "review", stats, tidyEmbedBatch)
if len(vecs) == 0 || len(vecs[0]) == 0 {
return nil, ErrReviewSelfUnembedded
}
return vecs, nil
}