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 }