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 个请求后收手
110 lines
4.7 KiB
Go
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
|
|
}
|