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 个请求后收手
312 lines
13 KiB
Go
312 lines
13 KiB
Go
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。
|