package memory import ( "context" "math" "sort" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding" ) // 本文件是记忆整理的**候选召回**(design/23 §5.1 T0-2):分区内两两相似度 // ≥ recallThreshold 的对进入候选。 // // ⚠️ 口径(不可走样):相似度**只用来召回候选对**,永不直接当合并判据。 // design/23 §1.2 的实据是「必合」的 [12]/[15]=0.8330 与「必不合」的 // [3]/[6]=0.7100 只差 0.12,且全库最高相似度的一对(0.9319)恰是跨作用域 // ⇒ 单阈值双向失效,判定必须靠包含判定 + 规则否决(tidy_rules.go)。 // // 召回来源(embedding / 词项余弦)与「来源不可用时怎么办」被拆成两层: // recallPairs 只报事实(不可用就回错误),**回退还是上报由调用方的 // tidyRecall 策略决定**(tidy.go 的生产轮回退、tidy_dryrun.go 的试算轮上报)。 const ( // tidyRecallEmbedding 是本轮相似度来自语义 embedding。 tidyRecallEmbedding = "embedding" // tidyRecallLexical 是本轮相似度来自零依赖的词项余弦。 tidyRecallLexical = "lexical" // tidyRecallMixed 是多分区的一轮里**部分**分区走了语义、部分降级到词项余弦。 // 它取代「挑一个代表值报上去」的做法:降级一旦发生就必须看得见。 tidyRecallMixed = "mixed" ) // recallLedger 记录**一轮里实际发生**的召回来源(不是配置里那一种)。 // // 为什么需要它(M6 2026-10-13 的 medium finding):整理轮与试算轮的 `recall_mode` // 曾按「有没有配 embedder」写出去(旧实现 tidyRecallMode(hasEmbedder bool)),于是 // **全量降级**在汇总行/面板里被写成 `embedding` —— 汇总与事实相反,运维据此判读会 // 把「向量不可用」读成「向量正常工作」。本类型把口径翻过来:只在**真的用了**那条 // 路的地方记一笔,label() 完全由这些实测记录推出。 type recallLedger struct { // embedding / lexical 是**真正用该来源算过相似度**的分区数。 embedding int lexical int // degraded 是**因相似度来源不可用**而降级到词项余弦的分区数(不是「没配 // embedder」那种配置形态)。它把「没候选」(label 为空串)与「向量不可用」 // (label 为 lexical 且 degraded > 0)分开 —— 这两件事在运维上不是一回事。 degraded int // unavailable 是**尝试过召回但向量源不可用、且本轮不降级**(试算轮的严格口径) // 的分区数。它与 degraded 的区别是「有没有改用词项余弦」:生产轮会降级 // (degraded),试算轮只报错。 // // 🔴 它存在的理由(reviewer round1 的 p2):试算轮在向量源不可用时 // `RecallMode` 与「没有候选可比」**同为空串**,两者在那个字段上分不开。 // 现在 provenance 由 unavailablePartitions() 单独给出,日志行能区分; // 调用方(面板)那条路本来就有明确错误码(500 tidy_recall_unavailable), // 因此**没有**为此扩 recallMode 的取值集合(见 logDryRun 的说明)。 unavailable int } // noteEmbedding 记一笔「这个分区真的用了语义 embedding」。 func (l *recallLedger) noteEmbedding() { if l != nil { l.embedding++ } } // noteLexical 记一笔「这个分区用了词项余弦」;degraded 表示它是**降级**来的 // (embedder 报错 / 向量条数错位),而不是「本来就没配 embedder」。 func (l *recallLedger) noteLexical(degraded bool) { if l == nil { return } l.lexical++ if degraded { l.degraded++ } } // noteUnavailable 记一笔「这个分区尝试召回但向量源不可用,且本轮没有降级」 // (试算轮的严格口径:宁可报错,也不把「没算出来」说成「算过了,没有」)。 func (l *recallLedger) noteUnavailable() { if l != nil { l.unavailable++ } } // label 返回本轮**实际**的召回来源: // - "":没有任何分区真正算过相似度(没有候选可比)—— 「没候选」不是「降级」; // - embedding / lexical:所有算过的分区都走同一条路; // - mixed:两种都有(多分区里有一部分降级)。 // // ⚠️ 「尝试过但不可用」**不改变** label:它没有产出任何相似度,报 embedding / // lexical 都是假的;它的存在由 unavailablePartitions() 单独给出(见 recallLedger // 的 unavailable 字段说明)。 func (l *recallLedger) label() string { if l == nil { return "" } switch { case l.embedding == 0 && l.lexical == 0: return "" case l.embedding == 0: return tidyRecallLexical case l.lexical == 0: return tidyRecallEmbedding default: return tidyRecallMixed } } // degradedPartitions 返回本轮因向量不可用而降级的分区数。 func (l *recallLedger) degradedPartitions() int { if l == nil { return 0 } return l.degraded } // unavailablePartitions 返回本轮「尝试召回但向量源不可用、且没有降级」的分区数。 func (l *recallLedger) unavailablePartitions() int { if l == nil { return 0 } return l.unavailable } // tidyPair 是一条被召回的候选对。 type tidyPair struct { a, b MemoryItem score float64 // mode 记录本轮相似度来源(embedding / lexical),进证据与日志。 mode string } // recallPairs 在分区内召回候选对。条目少于 2 条时为空操作(无候选、不报错), // 且**不往 ledger 记任何一笔** —— 「没有候选可比」不是「走哪条路召回」。 // // 相似度来源不可用时(embedder 报错、向量条数错位)返回错误 —— 回退决策不在 // 这里:生产轮把它降级成词项余弦,试算轮如实上报(见 tidyRecall 策略)。 // // ledger 是**实际发生**的来源记账口(见 recallLedger):它只在真正选定相似度 // 函数的那一行记账,因此谁也无法把它报成「配置里那一种」。 // // 返回的对按「分数降序 + id」确定性排序,使每分区候选上限的截断可复现 // (同一批数据两次运行产出同一批候选 ⇒ 幂等,design §4.1 A)。 func (t *Tidier) recallPairs(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats, budget tidyBudget, ledger *recallLedger) ([]tidyPair, error) { if len(items) < 2 { return nil, nil } if t.deps.Embedder == nil { ledger.noteLexical(false) return t.collectPairs(p, items, t.cfg.RecallThreshold, lexicalSimFunc(items), tidyRecallLexical, stats, budget), nil } vecs, err := t.embedItems(ctx, items) if err != nil { return nil, err } if len(vecs) != len(items) { return nil, ErrTidyEmbedCount } ledger.noteEmbedding() return t.collectPairs(p, items, t.cfg.RecallThreshold, vectorSimFunc(vecs), tidyRecallEmbedding, stats, budget), nil } // limitCompareItems 把参与比对的条目截断到性能护栏并计入 Compared。 // 两两比对是 O(n²·dim):设计稿每分区扫描上限 5000 条(design §9),但真正参与 // 比对的最多 cfg.CompareLimit 条(最新优先,默认 2000,MEMORY_TIDY_COMPARE_LIMIT), // 超限记 WARN 并登记到 stats.Truncated —— 截断绝不静默。 // 空/单条分区不计数(本来就没有对可比)。 func (t *Tidier) limitCompareItems(p tidyPartition, items []MemoryItem, stats *TidyStats) []MemoryItem { if len(items) < 2 { return items } if len(items) > t.cfg.CompareLimit { stats.Truncated = append(stats.Truncated, p.label()+"(compare)") t.log.Warn("memory tidy compare truncated by per-iteration guard", "partition", p.label(), "items", len(items), "limit", t.cfg.CompareLimit) items = items[:t.cfg.CompareLimit] } stats.Compared += len(items) return items } // collectTidyPairs 是召回内核:把相似度 ≥ 阈值的对收集起来,按分数降序 // (再按 id)确定性排序 —— 排序让「每分区候选上限」的截断可复现。 // // 它是自由函数(入库前审查也复用同一个内核,design/23 §15 D3-3「不许另造一套 // 相似度实现」),因此**不认识分区与日志器**:cut=true 表示循环被时间预算提前 // 收手,由调用方决定怎么如实登记(整理侧见 Tidier.collectPairs)。 // budget 为零值(无 deadline)时行为与从前逐字一致。 // // budget 用尽时已找到的对是**真结果**(不是编的),照常返回 —— 少算了多少由 // 调用方如实标记,绝不静默。 func collectTidyPairs(items []MemoryItem, threshold float64, sim func(i, j int) float64, mode string, stats *TidyStats, budget tidyBudget) ([]tidyPair, bool) { pairs := make([]tidyPair, 0) ticks := 0 cut := false pairLoop: for i := 0; i < len(items); i++ { for j := i + 1; j < len(items); j++ { if ticks++; ticks >= tidyBudgetCheckEveryPairs { ticks = 0 if budget.exhausted() { cut = true break pairLoop } } score := sim(i, j) if score < threshold { continue } pairs = append(pairs, tidyPair{a: items[i], b: items[j], score: score, mode: mode}) } } stats.Pairs += len(pairs) sort.Slice(pairs, func(i, j int) bool { if pairs[i].score != pairs[j].score { return pairs[i].score > pairs[j].score } if pairs[i].a.ID != pairs[j].a.ID { return pairs[i].a.ID < pairs[j].a.ID } return pairs[i].b.ID < pairs[j].b.ID }) return pairs, cut } // collectPairs 是整理侧对内核对调用:它在内核之外补上「预算截断」的**诚实登记** // (分区标签进响应的 truncated 数组 + 一条 WARN)。截断绝不静默是这一层的职责, // 因此内核保持纯净(preinsert 审查不需要这套标签)。 func (t *Tidier) collectPairs(p tidyPartition, items []MemoryItem, threshold float64, sim func(i, j int) float64, mode string, stats *TidyStats, budget tidyBudget) []tidyPair { pairs, cut := collectTidyPairs(items, threshold, sim, mode, stats, budget) if cut { stats.Truncated = append(stats.Truncated, budgetTruncationLabel(p)) t.log.Warn("memory tidy pair scan cut by time budget", "partition", p.label(), "items", len(items), "pairs", len(pairs)) } return pairs } // lexicalSimFunc 是零依赖的相似度函数:两条内容词项集合的余弦。 func lexicalSimFunc(items []MemoryItem) func(i, j int) float64 { return func(i, j int) float64 { return lexicalSimilarity(items[i].Content, items[j].Content) } } // vectorSimFunc 是语义相似度函数(与检索侧同一口径:余弦夹到 [0,1])。 func vectorSimFunc(vecs []embedding.Vector) func(i, j int) float64 { return func(i, j int) float64 { return clampUnit(cosineSim(vecs[i], vecs[j])) } } // embedItems 给分区内条目取向量:**索引里已有的直接复用,只嵌缺的** // (判据与回填口径见 embed_incremental.go)。 // // 2026-10-13 M15 登记的两条放大源在这里收口: // - 「整理轮每轮把整个分区重新向量化」(user 分区一轮 ≈250 个请求)⇒ // 第二轮起趋近 0(第一轮把算出来的向量回填索引); // - 「一个分片失败 ⇒ 整条 Embed 失败」⇒ 逐条隔离(embedIsolated), // 一条慢/坏分片只损失它自己,其余条目照常带向量参与比对。 // // 返回切片恒与 items 等长,缺口位置为 nil(相似度按 0 计 ⇒ 那一对不进候选)。 // 一条向量都拿不到时返回错误,调用方按既有语义降级到词项余弦。 func (t *Tidier) embedItems(ctx context.Context, items []MemoryItem) ([]embedding.Vector, error) { vecs, stats, err := t.deps.Store.embedIncremental(ctx, t.deps.Embedder, items, tidyEmbedBatch) if err != nil { return nil, err } logEmbedReuse(t.log, "tidy", stats, tidyEmbedBatch) return vecs, nil } // lexicalSimilarity 是零依赖的相似度回退:两条内容词项集合的余弦。 // 词项来自 extractTidyFacts(CJK 二元组 + 拉丁词 + 数值 + emoji 符号), // 因此「同义不同措辞」的中文近似表述仍能召回,而「红牛 vs 冰美式」 // 这类并列事实只共享极少词项(相似度低或中等),靠包含判定兜住。 func lexicalSimilarity(a, b string) float64 { fa, fb := extractTidyFacts(a), extractTidyFacts(b) if len(fa.terms) == 0 || len(fb.terms) == 0 { return 0 } shared := 0 for term := range fa.terms { if _, ok := fb.terms[term]; ok { shared++ } } return float64(shared) / math.Sqrt(float64(len(fa.terms))*float64(len(fb.terms))) } // clampUnit 把余弦夹到 [0,1]:相似度是「召回门」的输入,负相关对 // (余弦 < 0)与零相关对在召回上等价。 func clampUnit(v float64) float64 { if v < 0 { return 0 } if v > 1 { return 1 } return v } // 旧的 tidyRecallMode(hasEmbedder bool) 已删除(M6 2026-10-13 的 medium finding): // 它按**配置态**报「本轮相似度来源」,把全量降级掩盖成 embedding。现在的口径是 // recallLedger —— 只报**实际发生**的那一种,见本文件顶部的 recallLedger。