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,与批次无关),方向是 重试/分片级容错,调常量解决不了。
This commit is contained in:
1 parent
20051f0168
commit
f3fb097630
8 files changed
+280
-53
No files matched your search
@@ -42,16 +42,24 @@ const (
|
||||
// budget.go 里**同一个不等式**推出 —— 单次请求的耗时上界不得超过超时的一半。
|
||||
// 改这一行就等于改了批次上限,反之亦然;两侧永远是配套的。
|
||||
//
|
||||
// 为什么每次调用必须有自己的上限:上层「入库前审查」的单条总预算是 20s
|
||||
// (memory.defaultReviewTimeout),embedding 只是它内部的第一步,后面还有 T1 灰区
|
||||
// 判定(8s)。一次卡死的 HTTP 不能把整条审查的 20s 都赌掉。真慢就**快速失败**,
|
||||
// 调用方按**既有语义**降级(审查降级到词项余弦并如实留痕 degraded),
|
||||
// **绝不静默丢记忆** —— 本常量只砍等待时间,不改任何判定规则。
|
||||
// 为什么每次调用必须有自己的上限:上层「入库前审查」的单条总预算是 90s
|
||||
// (memory.defaultReviewTimeout),而**一条召回链是几十个请求**(201 条 ÷ 8 条/片
|
||||
// = 26 个,见 budget.go 文件头的链路实测)—— 一次卡死的 HTTP 不能把整条审查的预算
|
||||
// 都赌掉:把它压在 5s,最坏也只是这一片失败,调用方按**既有语义**降级(审查降级到
|
||||
// 词项余弦并如实留痕 degraded),**绝不静默丢记忆**。本常量只砍等待时间,不改任何
|
||||
// 判定规则。
|
||||
//
|
||||
// 5s 保持不变的依据:本次故障(2026-10-13)的成因不是这个数太小,而是批次太大 ——
|
||||
// 64 条的实测耗时 7.5~8.5s 已经越过它,客户端放弃、服务端还在算,把整台本机
|
||||
// embedding 服务顶住,连带单条请求(写向量索引)也一起超时。所以修法是**把批次
|
||||
// 收进预算内**,而不是把预算放大到迁就批次(后者会让真挂死的请求也拖住整轮)。
|
||||
// 5s 保持不变的依据:本次故障(2026-10-13)的成因**不是**这个数太小(链条是
|
||||
// 「N 条 → ⌈N/批次⌉ 个请求」,链长超的是**调用方**预算),因此修法是把链条收短
|
||||
// (budget.go: 批上限 4 → 8)并**单独**抬高调用方预算(memory.defaultReviewTimeout
|
||||
// 20s → 90s),而不是把单次超时放大到迁就链条 —— 后者会让真挂死的请求也一起
|
||||
// 拖住整轮审查。
|
||||
//
|
||||
// ⚠️ 已知残余(见 budget.go 文件头 ④):空闲窗口实测单请求最大 1.782s,但**任何**
|
||||
// 批次形状都偶发越过本超时的尖峰(实测 4 条 7.139s、8 条 9.711s —— 明确与批次无关)。
|
||||
// 越过它的后果被设计成**有界的**:那一次调用失败 ⇒ 调用方按既有语义降级
|
||||
// (召回回落词项余弦 + 留痕 degraded),**不是**整轮丢失。真正要治的是链路对单点
|
||||
// 慢分片的韧性(重试/分片级容错),不在这里。
|
||||
//
|
||||
// 冷启动另有 warmupTimeout(见 budget.go),但它**只放宽本进程的第一个请求**、
|
||||
// 且一次性花掉:失败不会让它续期,因此「服务一直挂死」这件事**不会**变成
|
||||
@@ -161,6 +169,12 @@ func (e *APIEmbedder) Dim() int {
|
||||
// **自己切片**,分多次请求顺序发出,因此「一次请求的耗时 < 单次超时」这条不变式
|
||||
// 对任何调用方都成立 —— 调用方给多大的切片都不会把服务端顶住。中途任一段失败即
|
||||
// 整体失败(返回已发请求的错误),绝不半途改用混合表示。
|
||||
//
|
||||
// ⚠️ 代价在调用方那一侧:**一次 Embed(N) 是 ⌈N/条数上限⌉ 个请求**(改前 201 条 =
|
||||
// 51 个;改后 8 条/片 = 26 个),整条链的墙钟实测 ~21s(见 budget.go 文件头)。
|
||||
// 因此调用方的**总**预算必须按链长算,而不是按一次请求算 ——
|
||||
// memory.defaultReviewTimeout = 90s 就是这条口径(20s 的旧值比链长还短,
|
||||
// 让每一条候选数满额的审查都必然超时,且失败时不留痕)。
|
||||
func (e *APIEmbedder) Embed(ctx context.Context, texts []string) ([]Vector, error) {
|
||||
if len(texts) == 0 {
|
||||
return []Vector{}, nil
|
||||
|
||||
Reference in new issue
Block a user