package embedding import "time" // 本文件是**单次 /embeddings 调用的预算推演**:批次上限与单次超时必须由**同一个 // 式子**得出。任一侧单独调整都会让「一次请求的实测耗时 < 单次超时」这条不变式 // 静默失效 —— 而失效的后果不是报错,是**服务端还在算、客户端已经放弃**:被放弃的 // 那一批仍占着本机 llama.cpp 的算力,后面每一个请求(包括写索引时的单条请求)都 // 排在它后面等到自己的超时,于是整条记忆写入路径被拖住、召回全线降级。 // // ## 不变式 // // requestOverhead + 一次请求的字符总量 × perCharBudget ≤ DefaultRequestTimeout / callBudgetShare // // 左半是**推演出来的耗时上界**,右半是该超时下允许用掉的一半(另一半是这台机器的 // 抖动余量)。批次上限由解这个不等式得到(见 DefaultBatchLimit / DefaultBatchChars)。 // // ## 实测依据(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 是**首次**调用(模型加载 / GPU 缓冲分配尚未发生)的墙钟上限。 // 它**只在拿到第一个成功响应之前**生效,之后所有调用立刻回到 // DefaultRequestTimeout —— 所以它不会让「真挂死的请求」长期拖住整轮审查, // 又不让冷启动那一次注定失败(2026-10-13 部署后第一分钟生产日志里 // `vector embed for index failed ... embed 1 texts ... context deadline exceeded` // 就是这一路:模型还没热,5s 不够)。 warmupTimeout = 30 * 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:]) }