Files
Cyrene_v2/internal/runtime/memory/embed_isolation.go
T
AskaEth 589e26c887 fix(memory): 增量向量化 + 单分片逐条隔离,召回请求数降一个数量级(M61)
M15(2026-10-13)登记的两条放大源落在记忆层,本次一并收口:

1. 整理轮/入库审查**只嵌增量**:新加 vector.Index.Vectors(SQLite/PG 实现,
   按 id 取已存向量、维度不符当未命中),Store.embedIncremental 先复用索引里的
   向量、只嵌缺的那些,并把手算出的向量回填索引 ⇒ 同一分区第二轮起零请求。
   判据写明为「向量存在性 + 维度一致」(正文在本仓不可就地改写,故无需哈希)。
   维度与当前 embedder 不一致时禁用复用并 WARN(不混两种表示)。
2. 嵌入链对单个慢分片有韧性:embedIsolated 整批失败后逐条隔离,坏的那几条
   记缺口、其余照常返回;一条都没拿到才报错降级。请求数上界 1+B(B=8 ⇒ 9),
   服务整体挂死时一次即收手,不用「请求放大」换韧性。
3. 缺口/复用/回填条数进日志(缺口走 WARN),review 的被审条目拿不到向量时
   如实返回错误 ⇒ 走既有词项余弦降级(不把「算不出来」说成「没有重复」)。

请求数(单测内假 OpenAI 兼容服务端计数,生产 API embedder + 真 SQLite 索引):
  24 条存量:修复前每轮 3 → 第一轮 3、第二轮 0
  12 条分区整理轮:第一轮 2 → 第二轮 0
  20 条候选的入库审查:26(201÷8)→ 第一条 3、第二条 0
  一条坏正文:保住 7/8 条,代价 9 个请求;整体性故障 9 个请求后收手
2026-10-30 08:30:06 +08:00

110 lines
4.7 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^深度。逐条隔离的代价**恒定且可算**:一批最多 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
}