Files
Cyrene_v2/internal/runtime/memory/preinsert_recall.go
T
AskaEth f3fb097630 perf(embedding,memory): 批上限 4→8 削掉请求放大,审查预算 20s→90s 覆盖实测链长
实测(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,与批次无关),方向是
重试/分片级容错,调常量解决不了。
2026-10-13 21:59:31 +08:00

116 lines
5.6 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"
"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
}