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 个请求后收手
118 lines
6.1 KiB
Go
118 lines
6.1 KiB
Go
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
|
||
}
|