Files
Cyrene_v2/internal/runtime/memory/embed_resilience_test.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

163 lines
5.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package memory
import (
"context"
"fmt"
"testing"
)
// 本文件是「单分片韧性」的断言(M61,2026-10-30;design/23 §15.3 已登记口径):
// 一条慢/坏分片**不丢整条**——同一批里其它条目的向量照常返回,只损失那一条;
// 而整体性故障(服务挂死)**不放大**:最多 3 个请求就收手。
// TestEmbedIsolation_OneBadTextKeepsTheRest 是韧性的主断言:批里有一条坏正文时,
// 其余 7 条照常拿到向量,坏的那条记为缺口,请求数被二分定位的成本封顶。
//
// 删掉 embedIsolated(改回「一次 Embed 定生死」)这一条会打红:整批返回错误、
// 7 条好正文的向量一起丢掉。
func TestEmbedIsolation_OneBadTextKeepsTheRest(t *testing.T) {
t.Parallel()
ctx := context.Background()
srv := newFakeEmbedServer(t)
srv.failIf = "毒"
emb := srv.embedder()
texts := make([]string, 0, tidyEmbedBatch)
bad := -1
for i := 0; i < tidyEmbedBatch; i++ {
text := fmt.Sprintf("正常正文 第%d条", i)
if i == tidyEmbedBatch-1 {
text = "毒 这一条会让整批请求失败"
bad = i
}
texts = append(texts, text)
}
base := srv.count()
vecs, err := embedIsolated(ctx, emb, texts)
reqs := srv.count() - base
if err != nil {
t.Fatalf("embedIsolated: %v(一条坏分片不该丢掉整条调用)", err)
}
if len(vecs) != len(texts) {
t.Fatalf("向量条数 = %d, want %d(必须与入参对齐)", len(vecs), len(texts))
}
if len(vecs[bad]) != 0 {
t.Fatalf("坏正文居然拿到了向量:%v", vecs[bad])
}
kept := 0
for i := range vecs {
if i == bad {
continue
}
if len(vecs[i]) == 0 {
t.Fatalf("第 %d 条好正文被坏分片连坐了(向量为空)", i)
}
kept++
}
if kept != len(texts)-1 {
t.Fatalf("保住的条数 = %d, want %d", kept, len(texts)-1)
}
// 逐条隔离的成本上界:整批 1 次 + 逐条重试 B 次(B = 批上限 8)⇒ 9。
if limit := 1 + tidyEmbedBatch; reqs > limit {
t.Fatalf("定位一条坏分片花了 %d 个请求,超过逐条上界 %d(韧性变成了放大)", reqs, limit)
}
t.Logf("一条坏分片:保住 %d/%d 条,代价 %d 个请求(上界 1+B = %d)", kept, len(texts), reqs, 1+tidyEmbedBatch)
}
// TestEmbedIsolation_OutageStopsFast 是**反放大**的断言:服务整体挂死时不许把
// 「用请求换韧性」变成请求风暴 —— 逐条重试一条都没拿到即判定整体性故障,当场
// 返回错误,且请求数被 1+B 封顶。
func TestEmbedIsolation_OutageStopsFast(t *testing.T) {
t.Parallel()
ctx := context.Background()
srv := newFakeEmbedServer(t)
srv.failAll = true
emb := srv.embedder()
texts := make([]string, 0, tidyEmbedBatch)
for i := 0; i < tidyEmbedBatch; i++ {
texts = append(texts, fmt.Sprintf("正文 %d", i))
}
base := srv.count()
vecs, err := embedIsolated(ctx, emb, texts)
reqs := srv.count() - base
if err == nil {
t.Fatalf("服务整体不可用却返回成功:%v", vecs)
}
if limit := 1 + tidyEmbedBatch; reqs > limit {
t.Fatalf("整体性故障发了 %d 个请求,want ≤ %d(整批 1 + 逐条 B)", reqs, limit)
}
t.Logf("整体性故障:%d 个请求后收手(无放大),错误 = %v", reqs, err)
}
// TestEmbedIncremental_PartialGapKeepsGoing 是端到端的韧性:24 条里 1 条坏正文,
// 其余 23 条照常带向量返回,统计里如实记 1 个缺口(绝不静默)。
func TestEmbedIncremental_PartialGapKeepsGoing(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
items := reviewItems(t, store, 24)
srv := newFakeEmbedServer(t)
srv.failIf = "毒"
emb := srv.embedder()
store.attachVectorIndex(fakeIndex(t, emb))
// 把第 9 条换成坏正文(用一条新条目顶掉,保证它也在 missing 里)。
items[8] = addItem(t, store, MemoryItem{
PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship,
Content: "毒 这条正文每次请求都失败",
})
vecs, stats, err := store.embedIncremental(ctx, emb, items, tidyEmbedBatch)
if err != nil {
t.Fatalf("embedIncremental: %v(一条坏正文不该丢掉整轮召回)", err)
}
if stats.Dropped != 1 {
t.Fatalf("缺口数 = %d, want 1(缺口必须如实计数)", stats.Dropped)
}
if stats.Filled != len(items)-1 {
t.Fatalf("回填数 = %d, want %d", stats.Filled, len(items)-1)
}
if len(vecs[8]) != 0 {
t.Fatalf("坏正文居然拿到了向量")
}
for i := range vecs {
if i == 8 {
continue
}
if len(vecs[i]) == 0 {
t.Fatalf("第 %d 条被坏分片连坐了", i)
}
}
}
// TestReviewRecall_SelfGapDegradesToLexical 是「被审的那一条自己没向量」的口径:
// 此时语义召回对它无从谈起(相似度恒 0),必须**如实降级**到词项余弦并标记
// degraded,而不是交出一片看起来「没有重复」的空候选。
func TestReviewRecall_SelfGapDegradesToLexical(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
reviewItems(t, store, 3)
srv := newFakeEmbedServer(t)
srv.failIf = "毒"
emb := srv.embedder()
store.attachVectorIndex(fakeIndex(t, emb))
reviewer := newTestReviewer(t, store, emb, 0.80)
item := addItem(t, store, MemoryItem{
PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship,
Content: "毒 被审的这条没有向量",
})
_, degraded, err := reviewer.recallCandidates(ctx, item)
if err != nil {
t.Fatalf("recallCandidates: %v(缺口该走降级,不该整条失败)", err)
}
if !degraded {
t.Fatal("被审条目没有向量却把自己报成未降级:那会把「算不出来」说成「没有重复」")
}
}