embedding.Embed 同时受条数界(8)与字符界(480 字)约束 ⇒ 一次 embedOnce(B 条) 会发 C 个请求(C 由正文长度决定,1..B),因此: 正常批 C 含一条坏正文 ≤ C + B(短正文 9;均长 75 汉字的现实批 10;病理上限 2B=16) 服务整体挂死 首个 chunk 即失败 + 逐条 B ⇒ 1 + B = 9(与字符维无关) 修正 embed_isolation.go 文件头的上界表、approxRequests 的「两者一致」表述、 embedReuseStats.Sent 的注释,以及 embed_resilience_test.go 的上界注释与日志文案。 零行为改动:判据、实现、请求数都未动,测试全绿。
122 lines
5.8 KiB
Go
122 lines
5.8 KiB
Go
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
|
||
}
|