mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 17:38:10 +00:00
追「实例看起来没更新」时发现知识库检索本身也不可信,先把病因查清再动手: - **两段式召回不是瓶颈**: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`(同名覆盖必须摘掉旧向量, 生产改动当时已提交,测试一直未入库)。
239 lines
6.2 KiB
Go
239 lines
6.2 KiB
Go
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)
|
||
}
|
||
}
|