package memory import ( "context" "errors" "fmt" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding" ) // 本文件是嵌入链对**单个慢分片**的韧性(design/23 §15.3 登记的遗留,2026-10-13): // `embedding.Embed` 的语义是「任一分片失败 ⇒ 整条调用失败」,于是一条慢分片会把 // 其余已经算好的分片一起丢掉 —— 审查因此整条降级到词项余弦(92% 的 // `recall_mode` 为空的一半成因),整理轮同理。 // // 修法**不在** embedding 包里改语义(那是四个调用方共享的口径,改它等于改所有 // 人的预算推演),而是在调用方这一层做**逐条隔离**:整批先发一次;失败则把这一批 // 拆成单条逐条重试 —— 好的照常拿到向量,坏的那几条记缺口,**整条调用不失败**。 // // ## 为什么是「逐条」而不是递归二分 // // 二分定位一条坏文本在短正文下要 1+2+2+2 = 7 个请求(比逐条的 9 少 2 个),但它 // 需要一套失败预算的簿记(哪一层还允许拆、拆到多深收手),而那套簿记本身就是 // 「韧性变成放大」的风险点:预算给紧了会把好正文一起丢掉,给松了会在服务挂死时 // 把请求数放大成 2^深度。逐条隔离的代价**恒定且可算**:`embedOnce` 调用 ≤ 1+B // (B = 批上限),定位精度与二分相同(精确到单条),没有任何深度参数。 // // ## 三条必须同时成立的约束 // // 1. **定位**:只有整批失败才逐条重试,因此正常路径(绝大多数批)**零额外请求**; // 2. **止损**:一条都没拿到 ⇒ 如实返回错误(调用方降级到词项余弦);有拿到就 // 只损失坏的那几条 —— 绝不把「算不出来」说成「没有相似」; // 3. **预算**:父 ctx 已到期时不再逐条重试(预算花完了,重试只会把 worker 拖住)。 // // ## HTTP 请求数的上界(必须按**两维**切片算,B = 批上限 = 8) // // `embedding.Embed` 不是「一批一个请求」:它按 budget.go 的**条数界** // (DefaultBatchLimit=8)**与字符界**(DefaultBatchChars=480)同时切片 // (chunkTexts),因此一次 `embedOnce(B 条)` 会发出 C 个请求,C 由正文长度决定: // 全批 ≤480 字 ⇒ C=1;均长 75 汉字(budget.go 的实测口径)时 8×75=600 > 480 ⇒ C=2; // 每条自己就超过 480 字 ⇒ 每条独占一片 ⇒ C=B(病理上限)。 // 单条重试**永不切分**(它自己就是一片)⇒ 每条恰好 1 个请求。于是: // // 正常批 C(短正文 C=1;长正文 2 起) // 含一条坏正文 首次失败发生在第 1~C 个 chunk 之一,随后逐条 B 次 // ⇒ ≤ C + B(短正文 9;现实长正文 10;病理上限 2B=16) // 服务整体挂死 首个 chunk 即失败 + 逐条 B 次 ⇒ **1 + B = 9**(与字符维无关) // // 口径与 budget.go 同源:那里的「建模上界 ≠ 延迟保证」是同一类提醒 —— 这里的 // 「≤1+B」是**embedOnce 调用次数**的上界,HTTP 请求数必须再按字符维的 chunk 数算 // (正常批 C、含坏正文 ≤C+B);只有「服务整体挂死 = 1+B」这一条与字符维无关。 // ErrEmbedUnavailable 在「一条向量都没拿到」时返回:调用方据此走既有的词项余弦 // 降级(记忆一条不丢、判定规则一字不改),而不是把「算不出来」当成「没有相似」。 var ErrEmbedUnavailable = errors.New("memory: embedder returned no vector for any input") // ErrEmbedCountMismatch 在 embedder 返回的向量条数与入参条数不一致时返回: // 错位会让相似度与条目错配,因此按失败处理(随后由逐条隔离定位是哪一条出的问题)。 var ErrEmbedCountMismatch = errors.New("memory: embedder returned a mismatched vector count") // embedIsolated 嵌入 texts 并返回与之对齐的向量:失败的位置为 nil,其余照常。 // // 返回错误的**唯一**情形是「一个向量都没拿到」(或父 ctx 已到期);此时调用方按 // 既有语义降级到词项余弦。 func embedIsolated(ctx context.Context, emb embedding.Embedder, texts []string) ([]embedding.Vector, error) { if len(texts) == 0 { return nil, nil } vecs, err := embedOnce(ctx, emb, texts) if err == nil { return vecs, nil } if len(texts) == 1 { // 单条失败:这条慢/坏分片只损失它自己。 return []embedding.Vector{nil}, nil } if cerr := ctx.Err(); cerr != nil { return nil, fmt.Errorf("memory: embed %d texts: %w (%w)", len(texts), err, cerr) } out := make([]embedding.Vector, len(texts)) for i, text := range texts { if ctx.Err() != nil { break } one, oneErr := embedOnce(ctx, emb, []string{text}) if oneErr != nil { // 这一条就是坏的那个分片:留下缺口,别让同一批里的其它条目连坐。 continue } out[i] = one[0] } if nonEmptyVectors(out) == 0 { // 逐条重试也没拿到任何向量 ⇒ 故障是整体性的(服务挂死 / 地址错), // 如实报错让调用方降级,而不是交回一批「没有向量」的空结果。 return nil, err } return out, nil } // embedOnce 发一次 Embed,并要求向量条数与入参一致。它是隔离的「一次尝试」。 func embedOnce(ctx context.Context, emb embedding.Embedder, texts []string) ([]embedding.Vector, error) { vecs, err := emb.Embed(ctx, texts) if err != nil { return nil, err } if len(vecs) != len(texts) { return nil, fmt.Errorf("%w (got %d, want %d)", ErrEmbedCountMismatch, len(vecs), len(texts)) } return vecs, nil } // nonEmptyVectors 数一个对齐切片里真正拿到向量的条数。 func nonEmptyVectors(vecs []embedding.Vector) int { n := 0 for _, v := range vecs { if len(v) > 0 { n++ } } return n }