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 个请求后收手
This commit is contained in:
AskaEth committed 2026-10-30 08:30:06 +08:00
1 parent 9d28477e3a
commit 589e26c887
15 files changed
+1343 -48

No files matched your search

@@ -0,0 +1,172 @@
package memory
import (
"context"
"testing"
)
// 本文件断言两件事的**请求数**(M61,2026-10-30):
// - 增量:索引里已有向量的行不再重嵌 —— 同一分区第二轮整理轮/审查是 **0** 个请求;
// - 韧性:一条坏分片只损失它自己,整体性故障最多 3 个请求就收手。
//
// 计数来自假服务端(embed_fake_server_test.go),被测对象是生产路径
// (embedding.NewAPI + vector.New + Store/Tidier/PreInsertReviewer),不是替身。
// batchRequests 是「按批上限发出去要几个请求」的算式(⌈n/batch⌉)。
func batchRequests(n, batch int) int {
if n <= 0 || batch <= 0 {
return 0
}
return (n + batch - 1) / batch
}
// TestEmbedIncremental_SecondRoundMakesNoRequests 是「增量后不重嵌」的钉死:
// 第一轮把 24 条存量嵌入并**回填索引**(3 个请求 = ⌈24/8⌉),第二轮**一次都不发**。
//
// 删掉复用实现(或把 embedIncremental 改回「每次全量嵌」)这一条立刻打红:
// 第二轮会重新发出同样的 3 个请求。
func TestEmbedIncremental_SecondRoundMakesNoRequests(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
items := reviewItems(t, store, 24)
srv := newFakeEmbedServer(t)
emb := srv.embedder()
// vector.New 会探测一次 embedder 维度(1 个请求),因此基线在它之后取。
store.attachVectorIndex(fakeIndex(t, emb))
base := srv.count()
first, stats1, err := store.embedIncremental(ctx, emb, items, tidyEmbedBatch)
if err != nil {
t.Fatalf("embedIncremental(first): %v", err)
}
firstReqs := srv.count() - base
if want := batchRequests(len(items), tidyEmbedBatch); firstReqs != want {
t.Fatalf("第一轮请求数 = %d, want %d(⌈%d/%d⌉)", firstReqs, want, len(items), tidyEmbedBatch)
}
if stats1.Reused != 0 || stats1.Sent != len(items) || stats1.Filled != len(items) || stats1.Dropped != 0 {
t.Fatalf("第一轮 stats = %+v, want reused=0 sent=%d filled=%d dropped=0", stats1, len(items), len(items))
}
base = srv.count()
second, stats2, err := store.embedIncremental(ctx, emb, items, tidyEmbedBatch)
if err != nil {
t.Fatalf("embedIncremental(second): %v", err)
}
secondReqs := srv.count() - base
if secondReqs != 0 {
t.Fatalf("第二轮请求数 = %d, want 0(已有向量的行不该重嵌)", secondReqs)
}
if stats2.Reused != len(items) || stats2.Sent != 0 || stats2.Filled != 0 {
t.Fatalf("第二轮 stats = %+v, want reused=%d sent=0 filled=0", stats2, len(items))
}
for i := range items {
if len(second[i]) == 0 {
t.Fatalf("第 %d 条复用失败:向量为空", i)
}
if len(second[i]) != len(first[i]) {
t.Fatalf("第 %d 条维度变了:%d -> %d", i, len(first[i]), len(second[i]))
}
for k := range first[i] {
if second[i][k] != first[i][k] {
t.Fatalf("第 %d 条复用到了别的向量(分量 %d)", i, k)
}
}
}
t.Logf("整理/审查同量记忆的请求数:修复前每轮 %d → 修复后第一轮 %d、第二轮 %d(%d 条,批上限 %d)",
batchRequests(len(items), tidyEmbedBatch), firstReqs, secondReqs, len(items), tidyEmbedBatch)
}
// TestTidyRecall_SecondRoundZeroRequests 是整理轮的生产路径断言:同一分区连续两轮
// recallPairs,第一轮按批上限发请求,第二轮**零请求**(候选召回因此不再每轮重嵌)。
func TestTidyRecall_SecondRoundZeroRequests(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
reviewItems(t, store, 12)
srv := newFakeEmbedServer(t)
emb := srv.embedder()
store.attachVectorIndex(fakeIndex(t, emb))
tidier, err := NewTidier(TidyConfig{}, TidyDeps{Store: store, Embedder: emb, Log: testLogger()})
if err != nil {
t.Fatalf("NewTidier: %v", err)
}
partition := tidyPartition{personaID: "persona.a", scope: ScopePersona}
items, err := store.listTidyPartitionItems(ctx, partition, 500)
if err != nil {
t.Fatalf("listTidyPartitionItems: %v", err)
}
if len(items) != 12 {
t.Fatalf("分区条目数 = %d, want 12", len(items))
}
base := srv.count()
ledger := &recallLedger{}
if _, err := tidier.recallPairs(ctx, partition, items, &TidyStats{}, tidyBudget{}, ledger); err != nil {
t.Fatalf("recallPairs(round 1): %v", err)
}
firstReqs := srv.count() - base
want := batchRequests(len(items), tidyEmbedBatch)
if firstReqs != want {
t.Fatalf("第一轮整理召回请求数 = %d, want %d", firstReqs, want)
}
if ledger.embedding != 1 || ledger.lexical != 0 {
t.Fatalf("ledger = %+v, want 一次 embedding 召回、零次降级", ledger)
}
base = srv.count()
if _, err := tidier.recallPairs(ctx, partition, items, &TidyStats{}, tidyBudget{}, &recallLedger{}); err != nil {
t.Fatalf("recallPairs(round 2): %v", err)
}
secondReqs := srv.count() - base
if secondReqs != 0 {
t.Fatalf("第二轮整理召回请求数 = %d, want 0(增量向量化没生效)", secondReqs)
}
t.Logf("整理轮 recallPairs 请求数:第一轮 %d → 第二轮 %d(%d 条)", firstReqs, secondReqs, len(items))
}
// TestReviewRecall_SecondReviewZeroRequests 是入库审查的生产路径断言:新条由写入
// 侧落库时已建好向量,因此**第二次审查同一分区是零请求**(候选都从索引取)。
//
// 这把 M15 那条「每条审查 26 个请求」压到「第一条审查 ⌈K/批⌉、之后 0」。
func TestReviewRecall_SecondReviewZeroRequests(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
reviewItems(t, store, 20)
srv := newFakeEmbedServer(t)
emb := srv.embedder()
store.attachVectorIndex(fakeIndex(t, emb))
reviewer := newTestReviewer(t, store, emb, 0.80)
first := addItem(t, store, MemoryItem{
PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship,
Content: "新写入记忆 第一条",
})
base := srv.count()
if _, degraded, err := reviewer.recallCandidates(ctx, first); err != nil || degraded {
t.Fatalf("recallCandidates(first) = (degraded=%v, err=%v), want (false, nil)", degraded, err)
}
firstReqs := srv.count() - base
if want := batchRequests(20, tidyEmbedBatch); firstReqs != want {
t.Fatalf("第一条审查的请求数 = %d, want %d(只有 20 条既有候选缺向量)", firstReqs, want)
}
second := addItem(t, store, MemoryItem{
PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship,
Content: "新写入记忆 第二条",
})
base = srv.count()
if _, degraded, err := reviewer.recallCandidates(ctx, second); err != nil || degraded {
t.Fatalf("recallCandidates(second) = (degraded=%v, err=%v), want (false, nil)", degraded, err)
}
secondReqs := srv.count() - base
if secondReqs != 0 {
t.Fatalf("同分区第二次审查的请求数 = %d, want 0(增量为空 ⇒ 一个请求都不该发)", secondReqs)
}
t.Logf("入库审查请求数:修复前 26(201÷8)→ 第一条 %d、第二条 %d", firstReqs, secondReqs)
}