Files
HomeAgent/internal/memory/vector/store_test.go
JianFeeeee 5fbd6514c2 fix(knowledge): 知识库检索改为「稠密 + 词法」两路融合(真实 KB 自检索 MRR 0.271→0.376)
追「实例看起来没更新」时发现知识库检索本身也不可信,先把病因查清再动手:

- **两段式召回不是瓶颈**:Store 的结果与全量暴力 cosine 完全一致;
- **真因是向量没有区分度**:词向量取平均后各向异性明显,真实 KB(33 条)上自检索
  top-1 只有 15%、前两名平均只差 0.013,排序基本是噪声;
- 且全为停用词的查询会得到**空向量**("最近更新"),直接搜不出任何东西。

先在真实数据上把候选方案量了一遍(用自检索 top-1 / MRR)再动手:IDF 维度加权零收益、
去均值反而更差,**都不做**;唯一有收益的是与词法路(TF-IDF)融合。

改动:
- `Store` 增设词法路索引,`Search` 融合两路:各自按**查询内最大值**归一化后加权。
  权重 0.5 由权重扫描定:1.0(旧行为)MRR 0.271 / 0.8→0.354 / 0.7→0.358 / **0.5→0.376** /
  0.3→0.336 / 0.0→0.307;语义查询也从"全是 openharmony 噪声"变成命中正确条目
  (「首启人格门禁」→changelog_v1.2.1、「插件怎么开发和部署」→plugin_dev_build);
- `vector.Store` 的候选中选阈值改为**可设**(默认 0.05 保持既有行为):TF-IDF 余弦量级
  只有 0.0~0.2,沿用 0.05 会把词法路有效候选**静默砍掉**——这一条正是 0.376→0.197 的
  差距来源,且当时没有任何报错;
- Add/Remove/scanAll/ReindexWithVectorizer 同步维护两路;分数相同时按名字定序(结果可重复)。

**顺带修一个真实毛病**:Add/Remove 原先用**无追踪的 goroutine** 写索引(因为
writeIndex→BuildTree 会 RLock,而调用方持写锁,同步调用会死锁)→ 失败只打日志,
且与调用方竞态(测试的临时目录清理就撞上了)。改为持锁就地 flush
(buildTreeLocked / writeIndexLocked)。

判据(不依赖人工标注问答对):新增 `internal/knowledge/rankdiag_test.go`,用**自检索
top-1 / MRR** 量区分度,`KB_DIAG=1` 跑、`KB_DIAG_ASSERT=1` 断言(MRR ≥ 0.34)。
另有不依赖真实数据的单测 6 条(空稠密向量靠词法路救回、稠密并列时词法路定序、
词法路阈值接线、Add/Remove 双路一致、并列时确定性、空库不 panic)。

**反向验证**(证明判据真能发现缺陷):权重退回 1.0、词法路阈值改回 0.05、
把阈值写死回 0.05 —— 对应测试逐条变红。另:我第一版夹具余弦 0.365/0.273 远高于阈值,
注入缺陷也不报错(等于没验),故加了「夹具前提」断言并改成两层判据
(语义层由 vector 包测试证明、接线层由知识库测试钉住)。

顺带纳入上一轮漏提交的 `TestAddOverwriteReplacesVector`(同名覆盖必须摘掉旧向量,
生产改动当时已提交,测试一直未入库)。
2026-09-12 18:14:22 +08:00

239 lines
6.2 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 vector
import (
"math"
"testing"
)
func TestExtractNGrams(t *testing.T) {
tests := []struct {
text string
maxN int
check []string // 应包含
}{
{"hello", 2, []string{"h", "e", "l", "o", "he", "el", "ll", "lo"}},
{"中文测试", 2, []string{"中", "文", "测", "试", "中文", "文测", "测试"}},
{"a b", 1, []string{"a", "b"}},
}
for _, tt := range tests {
got := extractNGrams(tt.text, tt.maxN)
for _, want := range tt.check {
found := false
for _, g := range got {
if g == want {
found = true
break
}
}
if !found {
t.Errorf("extractNGrams(%q, %d) missing %q; got %v", tt.text, tt.maxN, want, got)
}
}
}
}
func TestExtractNGramsNoDups(t *testing.T) {
got := extractNGrams("aaaa", 2)
seen := make(map[string]bool)
for _, g := range got {
if seen[g] {
t.Errorf("duplicate ngram: %q", g)
}
seen[g] = true
}
}
func TestCosineSimilarity(t *testing.T) {
a := Vector{"a": 1, "b": 2}
b := Vector{"a": 2, "b": 4}
sim := CosineSimilarity(a, b)
if math.Abs(sim-1.0) > 1e-6 {
t.Errorf("identical direction vectors should have cos=1, got %f", sim)
}
c := Vector{"a": 1, "b": 0}
d := Vector{"a": 0, "b": 1}
sim = CosineSimilarity(c, d)
if math.Abs(sim) > 1e-6 {
t.Errorf("orthogonal vectors should have cos=0, got %f", sim)
}
sim = CosineSimilarity(Vector{}, Vector{"a": 1})
if sim != 0 {
t.Errorf("zero vector should return 0, got %f", sim)
}
}
func TestTFIDFVectorizer(t *testing.T) {
v := NewTFIDFVectorizer(NGramTokenizer(2))
docs := []string{"今天天气很好", "今天心情不错", "明天要下雨"}
v.Train(docs)
vec := v.Vectorize("今天")
if len(vec) == 0 {
t.Fatal("vector should not be empty")
}
if _, ok := vec["今天"]; !ok {
t.Errorf("expected feature '今天' in vector")
}
// 两个文档都有"今天"idf 应该较低
idfToday := vec["今天"]
vecSun := v.Vectorize("下雨")
idfRain := vecSun["下雨"]
if idfRain <= idfToday {
t.Errorf("expected rare '下雨' to have higher idf than common '今天', got today=%f rain=%f", idfToday, idfRain)
}
}
func TestTFIDFVectorizerEmpty(t *testing.T) {
v := NewTFIDFVectorizer(NGramTokenizer(2))
v.Train(nil)
vec := v.Vectorize("test")
if len(vec) == 0 {
t.Error("should produce features even without training")
}
// 未训练时所有 idf=1, 仅有 tf 归一化
for _, w := range vec {
if w < 0 {
t.Errorf("weight should be non-negative, got %f", w)
}
}
}
func TestInvertedIndex(t *testing.T) {
idx := NewInvertedIndex()
idx.Add("doc1", Vector{"a": 1, "b": 2})
idx.Add("doc2", Vector{"b": 1, "c": 3})
results := idx.Search(Vector{"a": 1}, 10)
if len(results) != 1 || results[0] != "doc1" {
t.Errorf("search 'a' should return doc1 only, got %v", results)
}
results = idx.Search(Vector{"b": 1}, 10)
if len(results) != 2 {
t.Errorf("search 'b' should return 2 docs, got %v", results)
}
idx.Remove("doc1")
results = idx.Search(Vector{"a": 1}, 10)
if len(results) != 0 {
t.Errorf("after remove, search 'a' should return empty, got %v", results)
}
}
func TestStoreInsertAndSearch(t *testing.T) {
s := NewStore()
v := NewTFIDFVectorizer(NGramTokenizer(2))
v.Train([]string{"hello world", "goodbye world"})
s.Insert("1", "hello world", v.Vectorize("hello world"), nil)
s.Insert("2", "goodbye world", v.Vectorize("goodbye world"), nil)
if s.Size() != 2 {
t.Errorf("expected size 2, got %d", s.Size())
}
results := s.Search(v.Vectorize("hello"), 5)
if len(results) == 0 {
t.Fatal("expected results")
}
if results[0].ID != "1" {
t.Errorf("expected doc1 as top result for 'hello', got %s", results[0].ID)
}
}
func TestStoreRemove(t *testing.T) {
s := NewStore()
v := NewTFIDFVectorizer(NGramTokenizer(1))
v.Train([]string{"hello world", "hello a", "foo bar", "baz qux", "test doc"})
s.Insert("1", "a", v.Vectorize("a"), nil)
s.Insert("2", "a", v.Vectorize("a"), nil)
s.Remove("1")
if s.Size() != 1 {
t.Errorf("after remove, size should be 1, got %d", s.Size())
}
results := s.Search(v.Vectorize("a"), 5)
if len(results) != 1 || results[0].ID != "2" {
t.Errorf("only doc2 should remain, got %v", results)
}
}
func TestStoreEmpty(t *testing.T) {
s := NewStore()
results := s.Search(Vector{"a": 1}, 5)
if results != nil {
t.Errorf("empty store should return nil, got %v", results)
}
}
func TestStoreAll(t *testing.T) {
s := NewStore()
v := NewTFIDFVectorizer(NGramTokenizer(1))
v.Train([]string{"a", "b"})
s.Insert("1", "a", v.Vectorize("a"), map[string]string{"k": "v"})
s.Insert("2", "b", v.Vectorize("b"), nil)
all := s.All()
if len(all) != 2 {
t.Errorf("All() should return 2 docs, got %d", len(all))
}
if all[0].Meta["k"] != "v" {
t.Errorf("meta should be preserved")
}
}
func BenchmarkCosineSimilarity(b *testing.B) {
va := Vector{}
vb := Vector{}
for i := 0; i < 100; i++ {
f := string(rune('a' + i%26))
va[f] = float64(i)
vb[f] = float64(100 - i)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
CosineSimilarity(va, vb)
}
}
func BenchmarkExtractNGrams(b *testing.B) {
text := "今天天气很好,适合出去散步。明天可能下雨,记得带伞。"
for i := 0; i < b.N; i++ {
extractNGrams(text, 2)
}
}
// 候选中选阈值必须**按向量空间标定**:词向量/多模态余弦通常在 0.3~0.9
// 而 TF-IDF 余弦只有 0.0~0.2。用同一个阈值会把词法路的有效候选静默砍掉
// (知识库自检索 MRR 0.307→0.193 就是这么掉的,且当时看不出任何报错)。
func TestSearchScoredRespectsMinScore(t *testing.T) {
// 构造一个低余弦候选:共享特征 "a",但两个向量几乎正交 → cosine ≈ 0.02
st := NewStore()
st.Insert("doc", "", Vector{"a": 1, "b": 1}, nil) // |doc| = √2
query := Vector{"a": 0.02, "c": 100} // 与 doc 的点积 0.02
hits := st.SearchScored(query, 10)
for _, h := range hits {
if h.Score < DefaultMinScore {
t.Fatalf("默认阈值 %.2f 不该返回 %.5f 的候选", DefaultMinScore, h.Score)
}
}
if len(hits) != 0 {
t.Fatalf("该查询在默认阈值下应被过滤,实际返回 %d 条", len(hits))
}
st.SetMinScore(0)
hits = st.SearchScored(query, 10)
if len(hits) != 1 || hits[0].Doc.ID != "doc" {
t.Fatalf("阈值设为 0 后应召回低余弦候选,实际 %+v", hits)
}
}