feat(memory): 记忆整理「试算(dry-run)」入口 + 面板卡(契约 v0.75)
主人要「现在就想看到在 736 条上它会建议合并哪些」,而不是等 23:00 的低峰首跑。
- 后端:Tidier.DryRun + TidyService.DryRun,**复用同一条扫描→召回→判定→构造候选路径**(抽出 tidyRoundPolicy,生产轮与试算轮只差「候选出口」与「召回降级策略」两处;禁止第二套实现)
- 🔴 零副作用:不写 memory_tidy_candidates / memory_tidy_runs,绝不改 memory_items 任何列;机械证明 = 跑前跑后 cyrene.db 与 -wal 的 SHA256 一致(测试断言「表行数不变」)
- 失败码诚实:500 tidy_unavailable / 409 tidy_disabled / 500 tidy_recall_unavailable(**embedding 不可用不降级成 0 条**)/ 503 tidy_timeout / 500 tidy_dry_run_failed;**空数组唯一含义 = 算过了没建议**
- 端点 POST /api/v1/admin/memories/tidy/dry-run(writeAuth);面板「记忆」页加试算卡(点了才跑、无轮询、三态分开、明写「仅试算,不会改动任何记忆」)
- 真机 e2e(741 条活库副本):76 ms / 15 分区 / 53 候选 / 41 skipped / 1 vetoed(low_confidence);样例 merge reason=fact_subset score=1.0000、link reason=similar_but_not_contained score=0.9248
- 变异:dry-run 顺手写库 ⇒ 两条测试红(行数 0→2);自造阈值 ⇒ 红;失败回 200 空数组 ⇒ 红
- 如实登记:Compared=688 被 400/分区护栏截断、候选上限 20/分区被打满 ⇒ 护栏值需在 741 条量级重定(非 bug)
This commit is contained in:
1 parent
3d8c30dc99
commit
d6ed9cfa2b
18 files changed
+1631
-79
No files matched your search
@@ -15,6 +15,17 @@ import (
|
||||
// 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 {
|
||||
@@ -26,15 +37,35 @@ type tidyPair struct {
|
||||
|
||||
// recallPairs 在分区内召回候选对。条目少于 2 条时为空操作(无候选、不报错)。
|
||||
//
|
||||
// 两两比对是 O(n²·dim):设计稿每分区扫描上限 5000 条(design §9),但真正
|
||||
// 参与比对的最多 maxTidyCompareItems 条(最新优先),超限记 WARN 并登记到
|
||||
// stats.Truncated —— 截断绝不静默。
|
||||
// 相似度来源不可用时(embedder 报错、向量条数错位)返回错误 —— 回退决策不在
|
||||
// 这里:生产轮把它降级成词项余弦,试算轮如实上报(见 tidyRecall 策略)。
|
||||
//
|
||||
// 返回的对按「分数降序 + id」确定性排序,使每分区候选上限的截断可复现
|
||||
// (同一批数据两次运行产出同一批候选 ⇒ 幂等,design §4.1 A)。
|
||||
func (t *Tidier) recallPairs(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats) []tidyPair {
|
||||
func (t *Tidier) recallPairs(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats) ([]tidyPair, error) {
|
||||
if len(items) < 2 {
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
if t.deps.Embedder == nil {
|
||||
return collectTidyPairs(items, t.cfg.RecallThreshold, lexicalSimFunc(items), tidyRecallLexical, stats), nil
|
||||
}
|
||||
vecs, err := t.embedItems(ctx, items)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(vecs) != len(items) {
|
||||
return nil, ErrTidyEmbedCount
|
||||
}
|
||||
return collectTidyPairs(items, t.cfg.RecallThreshold, vectorSimFunc(vecs), tidyRecallEmbedding, stats), nil
|
||||
}
|
||||
|
||||
// limitCompareItems 把参与比对的条目截断到性能护栏并计入 Compared。
|
||||
// 两两比对是 O(n²·dim):设计稿每分区扫描上限 5000 条(design §9),但真正参与
|
||||
// 比对的最多 maxTidyCompareItems 条(最新优先),超限记 WARN 并登记到
|
||||
// stats.Truncated —— 截断绝不静默。空/单条分区不计数(本来就没有对可比)。
|
||||
func (t *Tidier) limitCompareItems(p tidyPartition, items []MemoryItem, stats *TidyStats) []MemoryItem {
|
||||
if len(items) < 2 {
|
||||
return items
|
||||
}
|
||||
if len(items) > maxTidyCompareItems {
|
||||
stats.Truncated = append(stats.Truncated, p.label()+"(compare)")
|
||||
@@ -43,13 +74,17 @@ func (t *Tidier) recallPairs(ctx context.Context, p tidyPartition, items []Memor
|
||||
items = items[:maxTidyCompareItems]
|
||||
}
|
||||
stats.Compared += len(items)
|
||||
return items
|
||||
}
|
||||
|
||||
sims, mode := t.similarityFunc(ctx, p, items)
|
||||
// collectTidyPairs 是召回内核:把相似度 ≥ 阈值的对收集起来,按分数降序
|
||||
// (再按 id)确定性排序 —— 排序让「每分区候选上限」的截断可复现。
|
||||
func collectTidyPairs(items []MemoryItem, threshold float64, sim func(i, j int) float64, mode string, stats *TidyStats) []tidyPair {
|
||||
pairs := make([]tidyPair, 0)
|
||||
for i := 0; i < len(items); i++ {
|
||||
for j := i + 1; j < len(items); j++ {
|
||||
score := sims(i, j)
|
||||
if score < t.cfg.RecallThreshold {
|
||||
score := sim(i, j)
|
||||
if score < threshold {
|
||||
continue
|
||||
}
|
||||
pairs = append(pairs, tidyPair{a: items[i], b: items[j], score: score, mode: mode})
|
||||
@@ -68,31 +103,22 @@ func (t *Tidier) recallPairs(ctx context.Context, p tidyPartition, items []Memor
|
||||
return pairs
|
||||
}
|
||||
|
||||
// similarityFunc 返回分区内的两两相似度函数与来源标签。
|
||||
//
|
||||
// 接入 embedder 时用它批量向量化后取余弦(与检索侧同一口径:余弦夹到
|
||||
// [0,1])。embedder 缺席或整批 Embed 失败时回退到零依赖的词项余弦
|
||||
// —— 该回退保证整理在离线/无 embedding 配置时仍可跑(宁可比语义路径更保守),
|
||||
// 且让测试无需网络即可确定性运行。两个来源的相似度**不可混用阈值**:
|
||||
// 阈值只有一个(design §13 开放点 2 的 0.80),回退路径命中更少 ⇒ 更保守。
|
||||
func (t *Tidier) similarityFunc(ctx context.Context, p tidyPartition, items []MemoryItem) (func(i, j int) float64, string) {
|
||||
if t.deps.Embedder != nil {
|
||||
vecs, err := t.embedItems(ctx, items)
|
||||
if err == nil && len(vecs) == len(items) {
|
||||
return func(i, j int) float64 {
|
||||
return clampUnit(cosineSim(vecs[i], vecs[j]))
|
||||
}, "embedding"
|
||||
}
|
||||
t.log.Warn("memory tidy embedding recall unavailable, falling back to lexical",
|
||||
"partition", p.label(), "error", err)
|
||||
}
|
||||
// lexicalSimFunc 是零依赖的相似度函数:两条内容词项集合的余弦。
|
||||
func lexicalSimFunc(items []MemoryItem) func(i, j int) float64 {
|
||||
return func(i, j int) float64 {
|
||||
return lexicalSimilarity(items[i].Content, items[j].Content)
|
||||
}, "lexical"
|
||||
}
|
||||
}
|
||||
|
||||
// embedItems 分批把分区内条目向量化。单批失败即整体失败(调用方回退到
|
||||
// 词项余弦),不半途改用混合表示 —— 混用会让召回范围不可预测。
|
||||
// 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 {
|
||||
@@ -142,3 +168,11 @@ func clampUnit(v float64) float64 {
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// tidyRecallMode 报告本轮相似度的来源,便于运维判断阈值是否在预期口径上。
|
||||
func tidyRecallMode(hasEmbedder bool) string {
|
||||
if hasEmbedder {
|
||||
return tidyRecallEmbedding
|
||||
}
|
||||
return tidyRecallLexical
|
||||
}
|
||||
Reference in new issue
Block a user