fix(memory): embedding 批上限与超时同源 —— 不再「客户端先放弃、服务端继续算」
M9(EMBRECALL)。故障机制(M6 + 本次复测):客户端 5s 单次超时先放弃,服务端仍在 算那一批 ⇒ 本机 llama.cpp 被自己顶住 ⇒ 后续每个请求(含写向量索引的单条请求) 排队到自己的超时 ⇒ 召回全线降级。 - embedding/budget.go(新):把「一次请求的耗时上界 ≤ 单次超时的一半」写成不变式, 批上限(条数 4 / 字符 480)与 5s 超时由**同一个不等式**解出;实测依据与复跑命令 写进注释(空闲态摊销曲线 1→0.19s / 8→1.1s / 12→1.3s / 64→8.5s,约 50ms 固定 + 2.7~3.9ms/字符,n≥32 起有数倍放大 ⇒ 余量取一半)。 - embedding/api.go:Embed 按同一对上限**自己切片**(不变式与调用方无关);冷启动 首次调用单独给 warmupTimeout,拿到第一个成功响应后立刻回常态超时。 - memory/tidy_config.go:tidyEmbedBatch = embedding.DefaultBatchLimit(同源), 并加编译期断言 —— 调回 64 会直接编译失败(constant -60 overflows uint)。 - memory/tidy.go | tidy_recall.go | tidy_dryrun.go:recall_mode 改报**实际发生** 的来源(""=本轮没候选可比 / embedding / lexical / mixed),新增 recall_degraded_partitions;试算轮 RecallMode 同口径(M6 medium finding)。 - 测试:embedding 侧用「实测曲线 + 会睡够时间的假客户端」钉住两态 (批内 < 超时 ⇒ 不降级;旧配对 64 条 ⇒ 必然超时),并断言 abandoned == 0; memory 侧钉住降级/健康/没候选三态与试算轮的如实上报。 不改 preinsert*、不改 .env、不动判定规则与任何阈值。
This commit is contained in:
1 parent
68ba85389b
commit
59672720bd
9 files changed
+836
-45
No files matched your search
@@ -25,8 +25,75 @@ const (
|
||||
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
|
||||
}
|
||||
|
||||
// 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++
|
||||
}
|
||||
}
|
||||
|
||||
// label 返回本轮**实际**的召回来源:
|
||||
// - "":没有任何分区真正算过相似度(没有候选可比)—— 「没候选」不是「降级」;
|
||||
// - embedding / lexical:所有算过的分区都走同一条路;
|
||||
// - mixed:两种都有(多分区里有一部分降级)。
|
||||
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
|
||||
}
|
||||
|
||||
// tidyPair 是一条被召回的候选对。
|
||||
type tidyPair struct {
|
||||
a, b MemoryItem
|
||||
@@ -35,18 +102,23 @@ type tidyPair struct {
|
||||
mode string
|
||||
}
|
||||
|
||||
// recallPairs 在分区内召回候选对。条目少于 2 条时为空操作(无候选、不报错)。
|
||||
// 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) ([]tidyPair, error) {
|
||||
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
|
||||
}
|
||||
@@ -57,6 +129,7 @@ func (t *Tidier) recallPairs(ctx context.Context, p tidyPartition, items []Memor
|
||||
if len(vecs) != len(items) {
|
||||
return nil, ErrTidyEmbedCount
|
||||
}
|
||||
ledger.noteEmbedding()
|
||||
return t.collectPairs(p, items, t.cfg.RecallThreshold,
|
||||
vectorSimFunc(vecs), tidyRecallEmbedding, stats, budget), nil
|
||||
}
|
||||
@@ -205,10 +278,6 @@ func clampUnit(v float64) float64 {
|
||||
return v
|
||||
}
|
||||
|
||||
// tidyRecallMode 报告本轮相似度的来源,便于运维判断阈值是否在预期口径上。
|
||||
func tidyRecallMode(hasEmbedder bool) string {
|
||||
if hasEmbedder {
|
||||
return tidyRecallEmbedding
|
||||
}
|
||||
return tidyRecallLexical
|
||||
}
|
||||
// 旧的 tidyRecallMode(hasEmbedder bool) 已删除(M6 2026-10-13 的 medium finding):
|
||||
// 它按**配置态**报「本轮相似度来源」,把全量降级掩盖成 embedding。现在的口径是
|
||||
// recallLedger —— 只报**实际发生**的那一种,见本文件顶部的 recallLedger。
|
||||
Reference in new issue
Block a user