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^深度。逐条隔离的代价**恒定且可算**:一批最多 1+B 个请求(B=8 ⇒ 9), // 定位精度与二分相同(精确到单条),没有任何深度参数。 // // ## 三条必须同时成立的约束 // // 1. **定位**:只有整批失败才逐条重试,因此正常路径(绝大多数批)**零额外请求**; // 2. **止损**:一条都没拿到 ⇒ 如实返回错误(调用方降级到词项余弦);有拿到就 // 只损失坏的那几条 —— 绝不把「算不出来」说成「没有相似」; // 3. **预算**:父 ctx 已到期时不再逐条重试(预算花完了,重试只会把 worker 拖住)。 // // 请求数的上界(B = 批上限,生产 = 8): // // 正常批 1 // 含一条坏正文 1 + B = 9(定位到它自己) // 服务整体挂死 1 + B = 9(一次之后 embedMissing 立刻停手,不再发后续批) // 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 }