Files
Cyrene_v2/internal/runtime/memory/tidy_recall.go
T
AskaEth 6e28b38b08
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(memory): 护栏抬高+时间预算闸+试算加性异步 与 D3 入库前审查(耦合两片一起落)
【MT-A:护栏 + 诚实截断 + 试算异步】
- maxTidyCompareItems 400(写死)→2000 并开放为 MEMORY_TIDY_COMPARE_LIMIT;
  MEMORY_TIDY_CANDIDATE_LIMIT 默认 20→200(语义不变,仍每分区)
- 依据是首跑实测:459 条的分区被 400 截断 ⇒ compared(705) < scanned(784),
  候选数(77)由上限而不是规则决定;三处上限的 WARN 都补齐了总数/上限/分区名
- 新增「上限 + 时间预算」双闸(实测 2000 条/分区 ≈234s > 3 分钟墙钟):
  整分区跳过 / 两两比对中途收手,一律如实记 (budget) + WARN
- ?async=1 ⇒ 202 {runId,status};GET …/{runId} 轮询(writeAuth 同档);
  done 的 result 与同步 data 逐字节同形;并发=复用既有 runId、TTL 30min 自完成起算、
  重启即如实 404 tidy_run_not_found;不带 async 时同步路径逐字不变

【MT-B:D3 入库前审查(design/23 §15.1)】
- 新记忆经 Store.Add(仓库里唯一 INSERT INTO memory_items)落库后,由独立记忆管理
  会话(sess-memorytidy)在有界后台队列异步判定 skip/merge/modify/create
- 软合并回写(status + merged_into/merged_from/merge_count,可 revert、绝不改 content)
- 隔离来源(群聊/陌生人)**概不审查**、召回/超时失败**零改写**(fail-safe 结构性成立)
- 写入侧仅 +62.7µs/条(非阻塞入队);默认 ENABLED=true(主人裁定:入库前先审查)

【耦合说明(重要)】两片同包且互相依赖:MT-B 的 preinsert_recall.go 用 MT-A 的
collectTidyPairs 新签名 + tidyBudget ⇒ **各自单独都 build 不过**,故一并落地。
MT-A 为让整包编译,最小幅度适配了 MT-B 的 preinsert_recall.go / preinsert_test.go
(零值预算=行为不变;符号重名改名)。

【我的验收】隔离 worktree(37 文件)build/vet/linux/darwin 全过,切片包全绿;
我亲手打红两个变异:① 去掉 (compare) 截断标记 ⇒ TestTidyCompareLimit_HitIsLoud 红;
② 中和隔离来源守卫 ⇒ TestReviewRestrictedSourceIsNeverTouched 红。
唯一红是 internal/runtime/tool 的两条超时用例(该包无人改)⇒ 主仓库 -count=8 全绿,
判定为负载敏感 flake(当时两个整树验收 + 子代理并发),已记入开发习惯与约定 §7。

.env.example 补 MEMORY_REVIEW_* 四键(MT-B 无该文件权限,由我按其代码语义补写)。
2026-10-06 08:04:12 +08:00

215 lines
8.1 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"
)
// tidyPair 是一条被召回的候选对。
type tidyPair struct {
a, b MemoryItem
score float64
// mode 记录本轮相似度来源(embedding / lexical),进证据与日志。
mode string
}
// recallPairs 在分区内召回候选对。条目少于 2 条时为空操作(无候选、不报错)。
//
// 相似度来源不可用时(embedder 报错、向量条数错位)返回错误 —— 回退决策不在
// 这里:生产轮把它降级成词项余弦,试算轮如实上报(见 tidyRecall 策略)。
//
// 返回的对按「分数降序 + id」确定性排序,使每分区候选上限的截断可复现
// (同一批数据两次运行产出同一批候选 ⇒ 幂等,design §4.1 A)。
func (t *Tidier) recallPairs(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats, budget tidyBudget) ([]tidyPair, error) {
if len(items) < 2 {
return nil, nil
}
if t.deps.Embedder == nil {
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
}
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 报告本轮相似度的来源,便于运维判断阈值是否在预期口径上。
func tidyRecallMode(hasEmbedder bool) string {
if hasEmbedder {
return tidyRecallEmbedding
}
return tidyRecallLexical
}