Files
Cyrene_v2/internal/runtime/memory/tidy_recall.go
T
AskaEth 6c30ca540a fix(memory): 冷启动放宽改为一次性 + 试算轮区分「没候选」与「向量不可用」
review round1 的 p1/p2 收口(commit 5967272 之后)。

p1:旧实现 `warmed` 只在 2xx 之后才翻 ⇒ 注释里写的「只在第一次成功响应之前」
实际是「服务坏着的全部时间」。reviewer 实测「接受连接但永不回包」时**每次**调用
都等满 30s(fail-fast 变 fail-slow):审查 20s 预算被一次吃光、索引写入拖成
8×30s、开机探针阻塞 30s。

- `warmupLeft` 改为**一次性许可**(CompareAndSwap 取走即失效,成败都算花掉)
  ⇒ 挂死时只有第一个请求慢一次,此后一律 DefaultRequestTimeout。
- `warmupTimeout` 30s → 8s:与常态 5s 同一量级(≤2×),且严格小于审查总预算
  20s(一次冷启动不许吃光整轮);两条界都由 TestDefaultRequestTimeout 守住。
- 放宽被花掉且没成功时留一条 WARN(事后可复核「第一个请求」这个判据)。
- 更正两处自述:api.go 的「不会被 30s 拖住」与 budget.go 的不变式说明(批次形状
  永远按稳态超时推导;冷启动只放宽第一个请求的截止时刻)。
- p2:删掉零调用者的 `WithRequestTimeout` —— 单次超时只允许改常量,批次上限与它
  同源并由编译期/常量断言复核。

p2:试算轮在向量源不可用时 `RecallMode` 与「没有候选可比」同为空串。
- `recallLedger` 增 `unavailable` 计数 + `noteUnavailable()`(strictRecall 失败
  那一笔记一笔);试算汇总行新增 `recall_unavailable_partitions`,**成败都记**。
- 不扩 `recallMode` 取值集合:失败那一路不序列化 result(响应是
  500 tidy_recall_unavailable),新取值只会出现在日志里却要动一次契约,不划算。

测试:三种失败形态 × 两次调用的耗时断言(连接被拒 / 500 / 永不回包),
试算轮的「不可用」与「没候选」两态日志断言;两处变异自证(warmupTimeout 调回
30s ⇒ TestDefaultRequestTimeout 红;去掉 noteUnavailable ⇒ 试算汇总行退化成
recall_mode="" + recall_unavailable_partitions=0)。
2026-10-13 20:51:10 +08:00

314 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 分批把分区内条目向量化。单批失败即整体失败(调用方决定回退到
// 词项余弦还是如实上报),不半途改用混合表示 —— 混用会让召回范围不可预测。
func (t *Tidier) embedItems(ctx context.Context, 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 := t.deps.Embedder.Embed(ctx, texts)
if err != nil {
return nil, err
}
if len(vecs) != len(texts) {
return nil, ErrTidyEmbedCount
}
out = append(out, vecs...)
}
return out, 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。