实测(2026-10-13,本机空闲,活库 cyrene/user 分区 201 条、均长 34.7 汉字): - 一条入库审查召回链 = 51 个「4 条」请求 = 23.070 / 21.860 s;旧审查预算 20s 比链长还短 ⇒ 每一条候选满额的审查都超时,且超时后 **一行留痕都不写** (7 天里 92.1% 的审查 recall_mode 为空、2274 条超时降级的根因) - 同一条链改成「8 条」= 26 个请求 = 21.435 / 21.597 s:请求数减半,墙钟几乎不变 —— 链路耗时由「条数 × 每条成本」主导,批次只改请求数 - 「16 条」在**真实代码路径**上 3 次里 2 次让单请求越过 5s 单次超时(1.818 / 5.016 超时 / 5.001 超时)⇒ 批上限不越过 8;隔离测量里 12 条 0.996~1.017 s 仍在界内 改动: - embedding: perItemBudget 500ms→300ms(实测摊销 70~107ms/条的约 3 倍) ⇒ DefaultBatchLimit 由 (5s/2−100ms)/300ms = **8**,同一套推演、两维都成立 (条数 100ms+8×300ms=2.5s;字符 100ms+480×5ms=2.5s,均在超时的一半内) - embedding: 新增 maxSafeBatchLimit=12(实测外沿)+ 编译期断言(越界即 uint 溢出、 编译失败),与 tidy_config.go 那条同手法 - memory: defaultReviewTimeout 20s→**90s**(> 实测最坏链:空闲 21.6s⇒4.2 倍余量、 与整理链并发 56.7s + T1 8s ⇒ 1.4 倍;不再大的理由=单 worker 有界队列 256 的 最坏积压 256×90s≈6.4h、服务真挂死时第一片 5s 就降级用不到这条预算) 测试(不留过时断言):budget_test.go 加「钉值 8/480 + 两维不等式 + ≤12 外沿 + 16 条必须仍判越界」四层;api_url_test.go 的 reviewBudgetRef 20s→90s; preinsert_recall_test.go 新增跨包断言「预算 > 实测最坏链 + defaultT1Timeout」。 变异自证:perItemBudget 回 500ms ⇒ 钉值断言红;reviewTimeout 回 20s ⇒ 跨包断言红; perItemBudget 150ms(⇒16)⇒ 编译失败(constant -4 overflows uint)。三者均已逐字还原。 遗留(如实登记,未修):① 整理轮每轮重嵌整个分区(user 一轮仍 250 个请求), 解法方向是复用已入库的 vec_items 或给整理轮一个 embedding 请求预算;② 链路对单点 慢分片没有韧性 —— 一个分片失败即整条 Embed 失败、丢掉其余分片的工作量,且实测 任何批次形状都会偶发 >5s 尖峰(4 条 7.139s / 8 条 9.711s,与批次无关),方向是 重试/分片级容错,调常量解决不了。
116 lines
5.6 KiB
Go
116 lines
5.6 KiB
Go
package memory
|
||
|
||
import (
|
||
"context"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
|
||
)
|
||
|
||
// 本文件是入库前审查的**候选召回**(design/23 §15 D3-3:用既有 recall 路径,
|
||
// 不许另造一套相似度实现)。
|
||
//
|
||
// 复用面(逐条,都是整理侧已经存在的实现):
|
||
// - 阈值:与整理同一个召回门(config.MemoryTidy.RecallThreshold,§13 开放点 2);
|
||
// - 相似度:语义 embedding 的余弦(clampUnit + cosineSim)或零依赖的词项余弦
|
||
// (lexicalSimilarity);
|
||
// - 收集与排序:collectTidyPairs —— 因此「阈值过滤 + 分数降序 + id 兜底」
|
||
// 与整理侧逐字一致,截断也可复现。
|
||
//
|
||
// 与整理侧**唯一**的差异:候选只保留「新条目 vs 既有条目」的对(整理侧是
|
||
// 分区内两两),因为入库前审查永远以刚落库的那一条为中心。
|
||
|
||
// recallCandidates 在同一分区内召回候选(分区键 = persona_id + scope +
|
||
// project_id,design §6 硬约束:分区之间永不比较)。
|
||
//
|
||
// 返回的 degraded 表示本轮相似度是从语义路径**降级**到词项余弦的(embedder
|
||
// 报错或向量条数错位):判定规则一字不改,只把「这一轮的依据不够好」如实
|
||
// 带进留痕(§15 D3-8 的「是否降级」)。
|
||
func (r *PreInsertReviewer) recallCandidates(ctx context.Context, item MemoryItem) ([]ingestCandidate, bool, error) {
|
||
partition := tidyPartition{personaID: item.PersonaID, scope: item.Scope, projectID: item.ProjectID}
|
||
existing, err := r.deps.Store.listReviewCandidates(ctx, partition, item.ID, r.cfg.ScanLimit)
|
||
if err != nil {
|
||
return nil, false, err
|
||
}
|
||
if len(existing) == 0 {
|
||
return nil, false, nil
|
||
}
|
||
// 新条目固定在首位:reviewPairs 只认它为中心。
|
||
items := make([]MemoryItem, 0, len(existing)+1)
|
||
items = append(items, item)
|
||
items = append(items, existing...)
|
||
|
||
if r.deps.Embedder == nil {
|
||
// 未配置语义 embedding:与整理生产轮同一口径 —— 用零依赖词项余弦,
|
||
// 不是降级(这是配置形态,不是故障)。
|
||
return reviewPairs(items, r.cfg.Threshold, lexicalSimFunc(items), tidyRecallLexical), false, nil
|
||
}
|
||
vecs, err := embedReviewTexts(ctx, r.deps.Embedder, items)
|
||
if err != nil {
|
||
// 语义召回不可用 ⇒ 降级到与整理生产轮**同一个**词项余弦,并如实标记
|
||
// degraded(design §4.1 生产轮回退口径)。降级绝不改变判定规则,
|
||
// 也绝不改写任何一行。
|
||
r.log.Warn("memory ingest review embedding recall unavailable, falling back to lexical",
|
||
"item_id", item.ID, "scope", string(item.Scope), "error", err)
|
||
return reviewPairs(items, r.cfg.Threshold, lexicalSimFunc(items), tidyRecallLexical), true, nil
|
||
}
|
||
return reviewPairs(items, r.cfg.Threshold, vectorSimFunc(vecs), tidyRecallEmbedding), false, nil
|
||
}
|
||
|
||
// reviewPairs 用整理的收集内核(collectTidyPairs)产出候选对,再只保留以
|
||
// items[0](新条目)为中心的那些,转成审查候选。首位的 id 为空时无法区分
|
||
// 中心,直接返回空 —— 宁可少审一条,也绝不把两条既有记忆当成候选。
|
||
func reviewPairs(items []MemoryItem, threshold float64, sim func(i, j int) float64, mode string) []ingestCandidate {
|
||
if len(items) < 2 || items[0].ID == "" {
|
||
return nil
|
||
}
|
||
var sink TidyStats
|
||
// 零值预算 = 无时间预算:入库前审查的对数由它自己的 ScanLimit 收口
|
||
// (那是 §15 D3 切片的护栏),本切片不改它的行为。
|
||
pairs, _ := collectTidyPairs(items, threshold, sim, mode, &sink, tidyBudget{})
|
||
out := make([]ingestCandidate, 0, len(pairs))
|
||
for _, p := range pairs {
|
||
switch {
|
||
case p.a.ID == items[0].ID:
|
||
out = append(out, ingestCandidate{item: p.b, score: p.score, mode: p.mode})
|
||
case p.b.ID == items[0].ID:
|
||
out = append(out, ingestCandidate{item: p.a, score: p.score, mode: p.mode})
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// embedReviewTexts 分批把条目向量化(批次上限复用整理侧的 tidyEmbedBatch:
|
||
// 不把整个分区的文本一次性交给远端 embedding API)。单批失败即整体失败,
|
||
// 由调用方决定降级还是上报,绝不半途改用混合表示。
|
||
//
|
||
// 🔴 链长就是**调用方预算的输入**(2026-10-13 实测,见 embedding/budget.go 文件头):
|
||
// 条数是「新条 1 + cfg.ScanLimit(默认 200)」= 201,因此一次召回发出的 HTTP 请求数
|
||
// = ⌈201 / tidyEmbedBatch⌉(tidyEmbedBatch = embedding.DefaultBatchLimit):
|
||
//
|
||
// 批次 4(改前)= 51 个请求 ⇒ 整条链 23.070 / 21.860 s
|
||
// 批次 8(改后)= 26 个请求 ⇒ 整条链 21.435 / 21.597 s
|
||
//
|
||
// 两点必须一起读:① 请求数减半是本改动**唯一**买到的收益 —— 链路墙钟由
|
||
// 「条数 × 每条成本」主导,两个批次形状都是 ~21s;② 所以单条审查的预算
|
||
// (memory.defaultReviewTimeout)必须按**整条链**算,20s 的旧值比链长还短,
|
||
// 会让每一条候选满额的审查都超时(这正是「92% 的审查 recall_mode 为空」的根因)。
|
||
func embedReviewTexts(ctx context.Context, emb embedding.Embedder, 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 := emb.Embed(ctx, texts)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if len(vecs) != len(texts) {
|
||
return nil, ErrTidyEmbedCount
|
||
}
|
||
out = append(out, vecs...)
|
||
}
|
||
return out, nil
|
||
}
|