package embedding import "time" // 本文件是**单次 /embeddings 调用的预算推演**:批次上限与单次超时必须由**同一个 // 式子**得出。任一侧单独调整都会让「一次请求的实测耗时 < 单次超时」这条不变式 // 静默失效 —— 而失效的后果不是报错,是**服务端还在算、客户端已经放弃**:被放弃的 // 那一批仍占着本机 llama.cpp 的算力,后面每一个请求(包括写索引时的单条请求)都 // 排在它后面等到自己的超时,于是整条记忆写入路径被拖住、召回全线降级。 // // ## 不变式 // // requestOverhead + 一次请求的字符总量 × perCharBudget ≤ DefaultRequestTimeout / callBudgetShare // // 左半是**推演出来的耗时上界**,右半是该超时下允许用掉的一半(另一半是这台机器的 // 抖动余量)。批次上限由解这个不等式得到(见 DefaultBatchLimit / DefaultBatchChars)。 // // ⚠️ 这条不等式**只按稳态超时(DefaultRequestTimeout)推导,且只约束批次形状**; // 冷启动那次放宽(warmupTimeout)**不改批形状**,只放宽进程第一个请求的**截止 // 时刻**,而且只发生一次(见 warmupTimeout 的说明)。批形状与超时仍然同源: // 无论第一个请求的截止多宽,它带的条数/字符数永远按稳态超时算出来。 // // ## 实测依据(2026-10-13,本机 = AMD RX 6600 XT + llama.cpp b11371 + // Qwen3-Embedding-0.6B Q8_0,端点 127.0.0.1:11434,**服务器空闲**,正文取自活库 // data/cyrene.db:短正文约 33 汉字、长正文约 168 汉字;每档 5~6 次取最坏) // // n=1 短 0.066~0.185s 长 0.740s // n=8 短 0.86~1.11s // n=12 短 1.27s 长 5.47s // n=16 短 2.20~3.31s // n=64 短 7.49~8.49s(改前的批次上限) // // 拟合:单次请求耗时 ≈ 固定开销 + 字符总量 × 每字符成本;固定开销实测约 50ms, // 每字符成本实测 2.7~3.9ms。**同一批 n≥32 起出现过数倍放大**(8.97s / 26.6s), // 因此大批次的耗时不可预测 —— 这也是「余量取一半」而不是「卡着边界」的依据。 // // 复跑命令(读活库正文、只连本机 embedding 服务、不写任何库): // // python - <<'PY' // import json,sqlite3,time,urllib.request // URL="http://127.0.0.1:11434/v1/embeddings"; MODEL="" // t=[r[0] for r in sqlite3.connect("data/cyrene.db").execute( // "select content from memory_items order by id limit 64")] // for b in (t[:1],t[:8],t[:12],t[:16],t[:64]): // body=json.dumps({"model":MODEL,"input":b}).encode() // r=urllib.request.Request(URL,data=body,headers={"Content-Type":"application/json"}) // s=time.perf_counter(); urllib.request.urlopen(r,timeout=300).read() // print(len(b), round(time.perf_counter()-s,3)) // PY const ( // requestOverhead 是单次 HTTP 调用的固定开销上界(实测 ~50ms,向上取整)。 requestOverhead = 100 * time.Millisecond // perCharBudget 是**每字符**的墙钟预算上界(实测 2.7~3.9 ms/字符,取 5ms)。 perCharBudget = 5 * time.Millisecond // perItemBudget 是**每条文本**的墙钟预算上界:按活库里最长的正文(168 汉字, // 最坏摊销 0.456 s/条)向上取整到 500ms。 perItemBudget = 500 * time.Millisecond // callBudgetShare 表示一次请求最多用掉超时的 1/2:另一半是抖动余量 // (见上面 n≥32 的数倍放大)。调大它 = 余量变小,必须连带复核实测。 callBudgetShare = 2 // DefaultBatchLimit 是单次请求的**条数**上限,由上面三个常量推出来(唯一推演): // (5s/2 − 100ms) / 500ms = 4。 DefaultBatchLimit = (int(DefaultRequestTimeout/callBudgetShare) - int(requestOverhead)) / int(perItemBudget) // DefaultBatchChars 是单次请求的**字符总量**上限,同一个不等式换成每字符预算: // (5s/2 − 100ms) / 5ms = 480。它防的是「条数很少但每条很长」: // 4 条 × 168 汉字 = 672 字符已越过它,会被再切一刀。 DefaultBatchChars = (int(DefaultRequestTimeout/callBudgetShare) - int(requestOverhead)) / int(perCharBudget) // warmupTimeout 是**一次性的**冷启动放宽:本 embedder 生命周期里的**第一个** // 请求可以拿到这个截止(模型加载 / GPU 缓冲分配还没发生过),此后**每一个** // 请求(含刚失败的那个之后的下一个)一律回到 DefaultRequestTimeout。 // // 🔴 它**不是**「只要还没成功过就一直放宽」—— 那个口径会在服务挂死时把 // fail-fast 变成 fail-slow(每次都等 warmupTimeout),并把审查的 20s 预算、 // 索引写入的 5s 预算整段吃掉。一次性 + 8s 两条一起才是有界的: // - 一次性:放宽最多花掉**一个**请求的时长,最坏情形之后立刻回到 5s; // - 8s:与常态 5s **同一量级**(1.6×),且严格小于审查单条总预算 20s // (memory.defaultReviewTimeout)⇒ 一次冷启动不可能吃光整轮审查, // 后面的 T1 灰区判定(8s)仍有预算。这条不等式由测试守住 // (api_url_test.go 的 TestDefaultRequestTimeout)。 // // 依据:2026-10-13 部署后第一分钟的生产日志里有一条 // `vector embed for index failed ... embed 1 texts ... context deadline exceeded` // —— 模型还没热时 5s 不够;而稳态实测单条只要 0.03~0.19s(见上面的曲线), // 所以放宽只需要覆盖「进程的第一个请求」这一刻。 warmupTimeout = 8 * time.Second ) // callBudget 返回在 timeout 这个单次超时下,一次请求允许用掉的墙钟预算。 func callBudget(timeout time.Duration) time.Duration { if timeout <= 0 { timeout = DefaultRequestTimeout } return timeout/callBudgetShare - requestOverhead } // batchLimitsFor 由**同一个超时**推出一对批次上限(条数 / 字符总量)。 // 它保证:按 (条数 ≤ items 且 字符总量 ≤ chars) 切出来的任何一段,其耗时上界 // 都不超过该超时的一半。两者都至少为 1 —— 单条文本本身越界时独占一段, // 正文**绝不截断**(截断会让向量描述的不是这条记忆)。 func batchLimitsFor(timeout time.Duration) (items, chars int) { budget := callBudget(timeout) items = int(budget / perItemBudget) if items < 1 { items = 1 } chars = int(budget / perCharBudget) if chars < 1 { chars = 1 } return items, chars } // chunkTexts 按「条数 ≤ maxItems 且 字符总量 ≤ maxChars」把 texts 切成若干段。 // 单条文本的 rune 数超过 maxChars 时它独占一段(见 batchLimitsFor 的口径)。 // 空输入返回 nil。 func chunkTexts(texts []string, maxItems, maxChars int) [][]string { if len(texts) == 0 { return nil } if maxItems < 1 { maxItems = 1 } if maxChars < 1 { maxChars = 1 } var out [][]string start := 0 chars := 0 for i, text := range texts { n := len([]rune(text)) // 段内已有内容,再加这一条就越界 ⇒ 先收口,本段从这一条重新开始。 if i > start && (i-start >= maxItems || chars+n > maxChars) { out = append(out, texts[start:i]) start, chars = i, 0 } chars += n } return append(out, texts[start:]) }