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 }