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 的上界注释与日志文案。 零行为改动:判据、实现、请求数都未动,测试全绿。
171 lines
6.2 KiB
Go
171 lines
6.2 KiB
Go
package memory
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"testing"
|
||
)
|
||
|
||
// 本文件是「单分片韧性」的断言(M61,2026-10-30;design/23 §15.3 已登记口径):
|
||
// 一条慢/坏分片**不丢整条**——同一批里其它条目的向量照常返回,只损失那一条;
|
||
// 而整体性故障(服务挂死)**不放大**:整批在第一个 chunk 即失败 + 逐条 B 次
|
||
// ⇒ 1+B 个请求就收手(B = 批上限 8)。
|
||
//
|
||
// ⚠️ 请求数口径是**两维**的(见 embed_isolation.go 文件头):本文件的正文都很短
|
||
// (8 条合计远小于 DefaultBatchChars=480)⇒ chunkTexts 只按条数界切、C=1,因此
|
||
// 「含一条坏正文 = 1+B」在这些用例里是精确值;长正文批的真实上界是 C+B。
|
||
|
||
// 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 个 chunk(本例正文短 ⇒ C=1)+ 逐条重试 B 次
|
||
// ⇒ 1+B = 9。长正文批的真实上界是 C+B(C 由 480 字字符界决定,见文件头)。
|
||
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 封顶(整批在第一个 chunk 就失败 ⇒ 1 个请求 + 逐条 B;
|
||
// 这一条与字符维的 chunk 数无关,本例正文短、C 本来就是 1)。
|
||
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("被审条目没有向量却把自己报成未降级:那会把「算不出来」说成「没有重复」")
|
||
}
|
||
}
|