- P0-3 持久向量索引**不再空壳**:Store.Add 落库后真做 Embed+Add(失败只记日志、绝不让记忆写入失败); NewRetriever 拿到 WithVectorIndex 时**自动接上写入路径**(无需改组合根);索引无命中 → WARN + **回退内存余弦**; 新增每进程一次的**回填**(索引确实为空时把已落库条目向量化,分批 64、上限 512) - P0-4 工作记忆**按会话接线**:回合落定时写入(owner 会话 + 带会话 id 的窗口,LRU 上限 64), 组装上下文时按会话读取 ⇒ 前导块真的出现;原单窗口 API 语义不变 - P0-17 L4 学习回环**真回写**:新增 capacity.Judge(只跑判定、不施人格覆盖/不发事件/不改状态); loop 在回合落定"只写 owner 会话 + 跳过子代理分支",写 ScopeUser(L4 的读域)+ 结构化溯源 - 顺带红线加固:意图记录是主人**原话**的机器可读形态 ⇒ **绝不注入群聊/陌生人块**(design/03 §5) - 复核:三条各自的包 build/vet/test 绿;「真生效」断言含生产构造路径(vector.New + 真实 SQLite 后端 + n-gram embedder)、跑完一轮窗口非空、L4 回写后能唤醒一个自身无强信号的表达; **5 条变异反证**(其中两条专门打「受限会话零写入」与「群聊不泄露主人原话」)均已还原 - 未做(登记):Index.Search 无 ID 过滤(客户端按候选集过滤,极端情况走回退);每条记忆一次 Embed
221 lines
8.3 KiB
Go
221 lines
8.3 KiB
Go
package memory
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/vector"
|
|
)
|
|
|
|
// countingIndex 包一层真实 vector.Index,数出写入侧与检索侧各用了几次:
|
|
// 它是「持久向量索引真的被喂了数据 / 真的被查询了」的观测点,
|
|
// 而不是替换掉真实后端的桩 —— 命中与得分仍由底层 SQLite 索引算出。
|
|
type countingIndex struct {
|
|
vector.Index
|
|
adds int
|
|
searches int
|
|
}
|
|
|
|
func (c *countingIndex) Add(ctx context.Context, id string, v vector.Vector) error {
|
|
c.adds++
|
|
return c.Index.Add(ctx, id, v)
|
|
}
|
|
|
|
func (c *countingIndex) Search(ctx context.Context, q vector.Vector, topK int) ([]vector.Hit, error) {
|
|
c.searches++
|
|
return c.Index.Search(ctx, q, topK)
|
|
}
|
|
|
|
// newTestVectorIndex 用**生产的构造路径** vector.New 建一个真实向量索引
|
|
// (默认的零额外依赖 SQLite 后端 + n-gram embedder),只是把库路径换成
|
|
// 隔离的内存库,使测试与线上装配走同一条代码路径。
|
|
func newTestVectorIndex(t *testing.T, dim int) vector.Index {
|
|
t.Helper()
|
|
idx, err := vector.New(
|
|
config.DBConfig{SQLitePath: ":memory:"},
|
|
embedding.NewNGram(dim),
|
|
testLogger(),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("vector.New: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = idx.Close() })
|
|
return idx
|
|
}
|
|
|
|
// TestStore_Add_WritesVectorIndex_写入侧真生效 是审计 P0-3 的主断言:
|
|
// 检索器接入索引后,**每条经 Store.Add 落库的记忆都被向量化并写进
|
|
// 索引**,因此对索引自身检索能命中该条目、且自相似度接近 1 ——
|
|
// 索引不再是恒空的空壳(design/03 §3「真正启用向量检索」)。
|
|
func TestStore_Add_WritesVectorIndex_写入侧真生效(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
store := newTestStore(t)
|
|
idx := &countingIndex{Index: newTestVectorIndex(t, 64)}
|
|
|
|
// 装配顺序与 cmd/cyrene 一致:先写 store,再经 NewRetriever 把索引
|
|
// 接到同一个 store 的写入路径。
|
|
r := NewRetriever(store, WithEmbedder(embedding.NewNGram(64)), WithVectorIndex(idx))
|
|
if r == nil {
|
|
t.Fatal("NewRetriever returned nil")
|
|
}
|
|
|
|
stored := addItem(t, store, MemoryItem{
|
|
PersonaID: "persona.a", Content: "她喜欢早上喝手冲咖啡",
|
|
Scope: ScopePersona, Kind: KindRelationship,
|
|
})
|
|
if idx.adds == 0 {
|
|
t.Fatal("Store.Add did not write any vector: the persistent index is still an empty shell")
|
|
}
|
|
|
|
// 索引里确实有这条向量:用同一内容的向量去检索,必须命中它本身。
|
|
qvecs, err := idx.Embed(ctx, []string{stored.Content})
|
|
if err != nil {
|
|
t.Fatalf("index embed: %v", err)
|
|
}
|
|
hits, err := idx.Search(ctx, qvecs[0], 5)
|
|
if err != nil {
|
|
t.Fatalf("index search: %v", err)
|
|
}
|
|
if len(hits) == 0 {
|
|
t.Fatal("index has no vector for the persisted item")
|
|
}
|
|
if hits[0].ID != stored.ID {
|
|
t.Fatalf("top index hit = %q, want the persisted item %q", hits[0].ID, stored.ID)
|
|
}
|
|
if hits[0].Score < 0.99 {
|
|
t.Fatalf("self-similarity = %v, want ~1 (the stored vector is not the item's own)", hits[0].Score)
|
|
}
|
|
}
|
|
|
|
// TestRetriever_Semantic_UsesIndexThenFallsBack_语义路径先索引后回退
|
|
// 覆盖 P0-3 的第二半:索引答不出候选命中时,语义分回退到内存余弦,
|
|
// 而不再因为「装了索引」就交出一片 0 分。
|
|
//
|
|
// 三路对照都在同一个候选集上做:① 索引命中候选 → 只有索引给分的条目
|
|
// 有语义分(索引作答);② 索引只命中候选之外的 id(全局索引里别的
|
|
// 角色的向量)→ 内存余弦接手(装上 embedder 时冷门条目仍有正分);
|
|
// ③ 同样的空候选命中但没有 embedder → 没有任何语义来源,分值为空。
|
|
func TestRetriever_Semantic_UsesIndexThenFallsBack_语义路径先索引后回退(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
store := newTestStore(t)
|
|
// 内容为英文、查询为德语(无共同词元),关键词路径因此贡献 0:
|
|
// 语义分若有,只可能来自向量路径。
|
|
item := addItem(t, store, MemoryItem{
|
|
PersonaID: "persona.a", Content: "she is a morning person",
|
|
Scope: ScopePersona, Kind: KindRelationship,
|
|
})
|
|
const query = "Kaffeegetränk"
|
|
|
|
items, err := store.ListByPersona(ctx, "persona.a")
|
|
if err != nil {
|
|
t.Fatalf("ListByPersona: %v", err)
|
|
}
|
|
|
|
// ① 索引认得候选 → 由索引作答。
|
|
hitIdx := &countingIndex{Index: &mockVectorIndex{
|
|
dim: 4,
|
|
searchFn: func(_ context.Context, _ vector.Vector, _ int) ([]vector.Hit, error) {
|
|
return []vector.Hit{{ID: item.ID, Score: 0.5}}, nil
|
|
},
|
|
}}
|
|
fromIndex := NewRetriever(store, WithEmbedder(fixedEmbedder{dim: 4}), WithVectorIndex(hitIdx))
|
|
got := fromIndex.semanticScores(ctx, items, query)
|
|
if len(got) != 1 || got[item.ID] <= 0 {
|
|
t.Fatalf("semantic scores from the index = %+v, want exactly the indexed item %q", got, item.ID)
|
|
}
|
|
if hitIdx.searches == 0 {
|
|
t.Fatal("the vector index was never queried: the semantic path is not wired")
|
|
}
|
|
|
|
// ② 索引只命中候选之外的 id → 候选集内无命中 → 回退内存余弦。
|
|
foreignIdx := &countingIndex{Index: &mockVectorIndex{
|
|
dim: 4,
|
|
searchFn: func(_ context.Context, _ vector.Vector, _ int) ([]vector.Hit, error) {
|
|
return []vector.Hit{{ID: "someone-else-item", Score: 0.9}}, nil
|
|
},
|
|
}}
|
|
withFallback := NewRetriever(store, WithEmbedder(fixedEmbedder{dim: 4}), WithVectorIndex(foreignIdx))
|
|
fallback := withFallback.semanticScores(ctx, items, query)
|
|
if fallback[item.ID] <= 0 {
|
|
t.Fatalf("semantic scores = %+v, want a positive score for %q from the in-memory cosine fallback",
|
|
fallback, item.ID)
|
|
}
|
|
|
|
// ③ 同样的空候选命中,但没有 embedder 可回退 → 没有语义分。
|
|
bare := NewRetriever(store, WithVectorIndex(foreignIdx))
|
|
if got := bare.semanticScores(ctx, items, query); len(got) != 0 {
|
|
t.Fatalf("semantic scores without an embedder = %+v, want empty", got)
|
|
}
|
|
}
|
|
|
|
// TestRetriever_BackfillsPersistedItems_索引空时一次性回填 证明既有
|
|
// 条目也进入语义路径:写入发生在接入索引**之前**(进程重启后读到的
|
|
// 历史记忆就是这种状态),首次语义检索发现索引为空,于是把已落库
|
|
// 的条目一次性向量化并写进索引 —— 之后它们真的能被索引检索到。
|
|
func TestRetriever_BackfillsPersistedItems_索引空时一次性回填(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
store := newTestStore(t)
|
|
|
|
// 索引接入之前写入的两条记忆(模拟上一次运行留下的历史条目)。
|
|
first := addItem(t, store, MemoryItem{
|
|
PersonaID: "persona.a", Content: "她喜欢早上喝手冲咖啡",
|
|
Scope: ScopePersona, Kind: KindRelationship,
|
|
})
|
|
addItem(t, store, MemoryItem{
|
|
PersonaID: "persona.a", Content: "她周末去山里徒步",
|
|
Scope: ScopePersona, Kind: KindRelationship,
|
|
})
|
|
|
|
idx := &countingIndex{Index: newTestVectorIndex(t, 64)}
|
|
r := NewRetriever(store, WithEmbedder(embedding.NewNGram(64)), WithVectorIndex(idx))
|
|
if _, err := r.Retrieve(ctx, "persona.a", "手冲咖啡", 10); err != nil {
|
|
t.Fatalf("Retrieve: %v", err)
|
|
}
|
|
if idx.adds < 2 {
|
|
t.Fatalf("backfill called Add %d times, want >= 2 (both persisted items)", idx.adds)
|
|
}
|
|
|
|
qvecs, err := idx.Embed(ctx, []string{first.Content})
|
|
if err != nil {
|
|
t.Fatalf("index embed: %v", err)
|
|
}
|
|
hits, err := idx.Search(ctx, qvecs[0], 5)
|
|
if err != nil {
|
|
t.Fatalf("index search: %v", err)
|
|
}
|
|
if len(hits) == 0 || hits[0].ID != first.ID {
|
|
t.Fatalf("index hits = %+v, want the backfilled item %q first", hits, first.ID)
|
|
}
|
|
|
|
// 回填只做一次:再检索一轮不再新增 Add。
|
|
before := idx.adds
|
|
if _, err := r.Retrieve(ctx, "persona.a", "徒步", 10); err != nil {
|
|
t.Fatalf("second Retrieve: %v", err)
|
|
}
|
|
if idx.adds != before {
|
|
t.Fatalf("backfill ran again: adds %d -> %d, want no further embedding", before, idx.adds)
|
|
}
|
|
}
|
|
|
|
// fixedEmbedder 是「任何文本都得到同一个向量」的确定性 embedder:它使
|
|
// 内存余弦路径对任意 query/内容都给出正相似度,从而让测试能判定
|
|
// 「语义分是否由内存回退贡献」。
|
|
type fixedEmbedder struct{ dim int }
|
|
|
|
func (f fixedEmbedder) Embed(_ context.Context, texts []string) ([]embedding.Vector, error) {
|
|
out := make([]embedding.Vector, len(texts))
|
|
for i := range texts {
|
|
v := make(embedding.Vector, f.dim)
|
|
v[0] = 1
|
|
out[i] = v
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (f fixedEmbedder) Dim() int { return f.dim }
|