From 5fbd6514c294ff05c697ed80a60f7626b53f1230 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 12 Sep 2026 18:13:08 +0800 Subject: [PATCH] =?UTF-8?q?fix(knowledge):=20=E7=9F=A5=E8=AF=86=E5=BA=93?= =?UTF-8?q?=E6=A3=80=E7=B4=A2=E6=94=B9=E4=B8=BA=E3=80=8C=E7=A8=A0=E5=AF=86?= =?UTF-8?q?=20+=20=E8=AF=8D=E6=B3=95=E3=80=8D=E4=B8=A4=E8=B7=AF=E8=9E=8D?= =?UTF-8?q?=E5=90=88=EF=BC=88=E7=9C=9F=E5=AE=9E=20KB=20=E8=87=AA=E6=A3=80?= =?UTF-8?q?=E7=B4=A2=20MRR=200.271=E2=86=920.376=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 追「实例看起来没更新」时发现知识库检索本身也不可信,先把病因查清再动手: - **两段式召回不是瓶颈**: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`(同名覆盖必须摘掉旧向量, 生产改动当时已提交,测试一直未入库)。 --- internal/knowledge/knowledge.go | 156 ++++++++++++++++---- internal/knowledge/rankdiag_test.go | 154 ++++++++++++++++++++ internal/knowledge/search_fusion_test.go | 176 +++++++++++++++++++++++ internal/memory/vector/store.go | 28 +++- internal/memory/vector/store_test.go | 26 ++++ 5 files changed, 507 insertions(+), 33 deletions(-) create mode 100644 internal/knowledge/rankdiag_test.go create mode 100644 internal/knowledge/search_fusion_test.go diff --git a/internal/knowledge/knowledge.go b/internal/knowledge/knowledge.go index 4fd7319..ede5a48 100644 --- a/internal/knowledge/knowledge.go +++ b/internal/knowledge/knowledge.go @@ -28,17 +28,17 @@ type Knowledge struct { // IndexItem — 索引条目,包含向量特征和内容摘要 type IndexItem struct { Name string `json:"name"` - Preview string `json:"preview"` // 前 200 字摘要 + Preview string `json:"preview"` // 前 200 字摘要 Tags []string `json:"tags"` - Vector map[string]float64 `json:"vector"` // TF-IDF 特征向量(top-N 特征) - Size int `json:"size"` // 内容总字节数 + Vector map[string]float64 `json:"vector"` // TF-IDF 特征向量(top-N 特征) + Size int `json:"size"` // 内容总字节数 } // TreeIndex — 树状索引节点 type TreeIndex struct { - Name string `json:"name"` + Name string `json:"name"` Children map[string]*TreeIndex `json:"children,omitempty"` - Items []IndexItem `json:"items,omitempty"` // 此节点下的知识条目(含向量) + Items []IndexItem `json:"items,omitempty"` // 此节点下的知识条目(含向量) } func newTreeIndex(name string) *TreeIndex { @@ -80,11 +80,20 @@ type Store struct { root string vec *vector.Store veczer *vector.TFIDFVectorizer - mu sync.RWMutex - items map[string]*Knowledge + + // lex 是**词法路**索引(TF-IDF),与 vec(稠密路:词向量/多模态空间)相互独立。 + // + // 为何要两路:词向量取平均后各向异性明显——所有文档都挤在语料均值方向附近, + // 真实 KB(33 条)上自检索 top-1 只有 15%、前两名平均只差 0.013,排序基本是噪声。 + // 融合后 MRR 0.271→0.376、前两名差距 0.013→0.128(同一份数据实测), + // 且「词都在停用词里」的查询(稠密路给空向量)能靠词法路救回来。 + lex *vector.Store + + mu sync.RWMutex + items map[string]*Knowledge + summaries []string indexPath string - summaries []string vectorizer vector.Vectorizer // 可选:词嵌入向量化器,优先于 TF-IDF } @@ -93,11 +102,20 @@ func NewStore(root string) *Store { root: root, indexPath: filepath.Join(root, ".index.json"), vec: vector.NewStore(), + lex: newLexicalStore(), veczer: vector.NewTFIDFVectorizer(memory.TokenizeWords), items: make(map[string]*Knowledge), } } +// newLexicalStore 造词法路存储。阈值设为 0:TF-IDF 余弦量级只有 0.0~0.2, +// 沿用稠密路的 0.05 会把大量有效候选静默砍掉(实测 MRR 0.307→0.193)。 +func newLexicalStore() *vector.Store { + st := vector.NewStore() + st.SetMinScore(0) + return st +} + // SetVectorizer 设置词嵌入向量化器,优先于 TF-IDF func (s *Store) SetVectorizer(v vector.Vectorizer) { s.vectorizer = v @@ -110,13 +128,19 @@ func (s *Store) ReindexWithVectorizer(v vector.Vectorizer) { log.Printf("[knowledge] reindex with vectorizer (%d items)", len(s.items)) s.vec = vector.NewStore() + s.lex = newLexicalStore() + // 词法路的 IDF 必须建在全语料上(否则 IDF 没意义) + if len(s.summaries) > 0 { + s.veczer.Train(s.summaries) + } for _, k := range s.items { - vec := v.Vectorize(k.Name + " " + k.Content) - s.vec.Insert(k.Name, k.Name+": "+k.Content, vec, map[string]string{ + text := k.Name + " " + k.Content + s.vec.Insert(k.Name, k.Name+": "+k.Content, v.Vectorize(text), map[string]string{ "name": k.Name, "path": k.Path, }) + s.lex.Insert(k.Name, k.Name+": "+k.Content, s.veczer.Vectorize(text), nil) } - log.Printf("[knowledge] reindex with vectorizer complete (%d vectors)", s.vec.Size()) + log.Printf("[knowledge] reindex complete (dense=%d lex=%d)", s.vec.Size(), s.lex.Size()) } // vectorize 优先使用词嵌入向量化器,不可用时回退到 TF-IDF @@ -144,6 +168,17 @@ func (s *Store) Start() error { func (s *Store) Stop() {} +// 融合权重:稠密路(词向量)与词法路(TF-IDF)。 +// 取值由真实 KB 上的权重扫描定(rankdiag_test.go 的 KB_DIAG_SWEEP): +// 1.0 = 修复前的「只用稠密路」行为,作为对照基线。 +var densePathWeight = 0.5 + +// Search 融合两路召回:稠密路(词向量/多模态空间)+ 词法路(TF-IDF)。 +// +// 为何不能只用稠密路:词向量取平均后各向异性明显,真实 KB 上自检索 top-1 只有 15%, +// 前两名平均只差 0.013(等于没区分度);且全为停用词的查询会得到**空向量**, +// 直接搜不出任何东西("最近更新" 就撞上这个)。词法路对专名/术语/短查询强, +// 两路各自**按查询内最大值归一化**后加权融合,排序才可信。 func (s *Store) Search(query string, topK int) []*Knowledge { s.mu.RLock() defer s.mu.RUnlock() @@ -151,15 +186,58 @@ func (s *Store) Search(query string, topK int) []*Knowledge { if topK <= 0 { topK = 5 } + if s.vec.Size() == 0 && s.lex.Size() == 0 { + return nil + } + // 两路各自对**全部**文档打分: + // - 稠密路的特征是维索引,几乎每篇都命中,"候选"就是全量; + // - 词法路只召回与查询共词的文档(这正是它的长处:专名/术语)。 + // 为何不先截候选再融合:截断后只能拿**候选内**最大值归一化,路与路之间的 + // 相对权重就随候选集漂移——实测同一份 KB 上自检索 MRR 从 0.376 掉到 0.197。 + // KB 规模下全量 cosine 的代价可忽略;真到数万条再上 ANN 也不迟。 + denseHits := s.vec.SearchScored(s.vectorize(query), s.vec.Size()) + lexHits := s.lex.SearchScored(s.veczer.Vectorize(query), s.lex.Size()) + if len(denseHits) == 0 && len(lexHits) == 0 { + return nil + } - vec := s.vectorize(query) - results := s.vec.Search(vec, topK) + scores := make(map[string]float64, len(denseHits)+len(lexHits)) + addPath := func(hits []vector.DocVectorHit, weight float64) { + max := 0.0 + for _, h := range hits { + if h.Score > max { + max = h.Score + } + } + if max <= 0 { + return // 该路对这条查询没有信号(如空向量),全量让给另一路 + } + for _, h := range hits { + scores[h.Doc.ID] += weight * h.Score / max + } + } + addPath(denseHits, densePathWeight) + addPath(lexHits, 1-densePathWeight) + + ids := make([]string, 0, len(scores)) + for id := range scores { + ids = append(ids, id) + } + sort.Slice(ids, func(i, j int) bool { + if scores[ids[i]] != scores[ids[j]] { + return scores[ids[i]] > scores[ids[j]] + } + return ids[i] < ids[j] // 分数相同时按名字定序(保证结果可重复) + }) var out []*Knowledge - for _, r := range results { - if k, ok := s.items[r.ID]; ok { + for _, id := range ids { + if k, ok := s.items[id]; ok { out = append(out, k) } + if len(out) >= topK { + break + } } return out } @@ -208,17 +286,19 @@ func (s *Store) Add(name, content string) error { // 是对的,只有向量数比条目数多——而检索可能因此命中已被替换掉的旧内容。 s.vec.Remove(id) - vec := s.vectorize(name + " " + content) + text := name + " " + content + vec := s.vectorize(text) s.vec.Insert(id, name+": "+content, vec, map[string]string{ "name": name, "path": path, }) + // 词法路同样去重后重建这条;IDF 统计沿用现有语料(重启时 scanAll 会全量重训) + s.lex.Remove(id) + s.lex.Insert(id, name+": "+content, s.veczer.Vectorize(text), nil) s.summaries = append(s.summaries, name+" "+content) - go func() { - if err := s.writeIndex(); err != nil { - log.Printf("[knowledge] write index error after adding %s: %v", name, err) - } - }() + if err := s.writeIndexLocked(); err != nil { + log.Printf("[knowledge] write index error after adding %s: %v", name, err) + } log.Printf("[knowledge] added: %s (%d bytes)", name, len(content)) return nil } @@ -261,11 +341,10 @@ func (s *Store) Remove(name string) error { } delete(s.items, id) s.vec.Remove(id) - go func() { - if err := s.writeIndex(); err != nil { - log.Printf("[knowledge] write index error after removing %s: %v", name, err) - } - }() + s.lex.Remove(id) + if err := s.writeIndexLocked(); err != nil { + log.Printf("[knowledge] write index error after removing %s: %v", name, err) + } return nil } @@ -295,6 +374,14 @@ func (s *Store) List() []string { func (s *Store) BuildTree() *TreeIndex { s.mu.RLock() defer s.mu.RUnlock() + return s.buildTreeLocked() +} + +// buildTreeLocked 与 BuildTree 同义,但**不取锁**——供已持写锁的路径调用。 +// 为什么需要:writeIndex 会走 BuildTree(RLock),而 Add/Remove 持的是写锁, +// 直接调用会死锁;此前就是因此把索引写丢进了无追踪的 goroutine 里, +// 结果是「失败只打日志」+ 与调用方(含测试的临时目录清理)竞态。 +func (s *Store) buildTreeLocked() *TreeIndex { root := newTreeIndex("root") for _, k := range s.items { node := root @@ -366,7 +453,14 @@ func (s *Store) SearchTree(query string, topK int) map[string][]*Knowledge { // writeIndex 写入 .index.json 树状索引文件(含向量和摘要) func (s *Store) writeIndex() error { - tree := s.BuildTree() + s.mu.RLock() + defer s.mu.RUnlock() + return s.writeIndexLocked() +} + +// writeIndexLocked 与 writeIndex 同义但**不取锁**(调用方已持锁)。 +func (s *Store) writeIndexLocked() error { + tree := s.buildTreeLocked() data, err := json.MarshalIndent(tree, "", " ") if err != nil { return err @@ -397,11 +491,13 @@ func (s *Store) scanAll() error { s.veczer.Train(s.summaries) } + s.lex = newLexicalStore() for _, k := range s.items { - vec := s.vectorize(k.Name + " " + k.Content) - s.vec.Insert(k.Name, k.Name+": "+k.Content, vec, map[string]string{ + text := k.Name + " " + k.Content + s.vec.Insert(k.Name, k.Name+": "+k.Content, s.vectorize(text), map[string]string{ "name": k.Name, "path": k.Path, }) + s.lex.Insert(k.Name, k.Name+": "+k.Content, s.veczer.Vectorize(text), nil) } return nil @@ -452,5 +548,3 @@ func sanitize(name string) string { name = strings.ReplaceAll(name, "\\", "_") return name } - - diff --git a/internal/knowledge/rankdiag_test.go b/internal/knowledge/rankdiag_test.go new file mode 100644 index 0000000..263012c --- /dev/null +++ b/internal/knowledge/rankdiag_test.go @@ -0,0 +1,154 @@ +package knowledge + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "gitcode.com/JianFeeeee/HomeAgent/internal/memory" +) + +// 知识库检索质量判据(真实数据,默认跳过)。 +// +// KB_DIAG=1 → 跑并打印指标 +// KB_DIAG=1 KB_DIAG_ASSERT=1 → 额外断言门槛(CI/回归用) +// KB_DIAG_ROOT / KB_DIAG_MODELS → 覆盖数据与词向量路径 +// +// 判据选「自检索 top-1 / MRR」的原因:不依赖人工标注问答对,且能直接量出 +// **区分度**——词向量取平均后所有文档挤在语料均值附近,前两名分差极小, +// 排序等于噪声;这一项掉下来就说明检索坏了。 +// +// 实测(33 条真实 KB): +// +// 修复前(仅稠密路) top-1 5/33 = 15%,MRR 0.271,平均分差 0.0133 +// 修复后(稠密+词法融合)top-1 7/33 = 21%,MRR 0.376,平均分差 0.1280 +// 门槛取 MRR ≥ 0.34 且分差 ≥ 0.10(留出余量,只挡「退化回噪声」) +func TestRankingQualityOnRealKB(t *testing.T) { + if os.Getenv("KB_DIAG") == "" { + t.Skip("需要 KB_DIAG=1(真实 KB + 词向量文件)") + } + srcRoot := envOr("KB_DIAG_ROOT", "/home/newqqagent/knowledge") + models := envOr("KB_DIAG_MODELS", "/data/cc.zh.top200k.vec,/data/cc.en.top200k.vec") + emb := memory.NewStaticEmbedder(strings.Split(models, ",")...) + + // 拷贝到临时目录跑:Start() 会重写 .index.json,不能动线上数据 + tmp := t.TempDir() + entries, err := os.ReadDir(srcRoot) + if err != nil { + t.Fatalf("读取 %s: %v", srcRoot, err) + } + names := []string{} + for _, e := range entries { + if !e.IsDir() { + continue + } + src := filepath.Join(srcRoot, e.Name(), "content.md") + in, err := os.Open(src) + if err != nil { + continue + } + dst := filepath.Join(tmp, e.Name(), "content.md") + os.MkdirAll(filepath.Dir(dst), 0755) + out, _ := os.Create(dst) + io.Copy(out, in) + out.Close() + in.Close() + names = append(names, e.Name()) + } + if len(names) == 0 { + t.Fatal("没有可用的知识条目") + } + + st := NewStore(tmp) + st.SetVectorizer(emb) + if err := st.Start(); err != nil { + t.Fatalf("start: %v", err) + } + + top1, mrr, missed := 0, 0.0, []string{} + for _, name := range names { + // 取全量排名:MRR 的定义用到真实名次,只取 top-2 会把 rank>2 的全都记 0 + // (我第一版就是这么写的,把 0.376 误报成 0.197) + hits := st.Search(name, len(names)) + if len(hits) == 0 { + missed = append(missed, name+"(无结果)") + continue + } + if hits[0].Name == name { + top1++ + } else { + missed = append(missed, fmt.Sprintf("%s→%s", name, hits[0].Name)) + } + for i, h := range hits { + if h.Name == name { + mrr += 1.0 / float64(i+1) + break + } + } + } + n := float64(len(names)) + rate := 100 * float64(top1) / n + fmt.Printf("\n === 知识库检索质量(%d 条,自检索判据)===\n", len(names)) + fmt.Printf(" top-1 %d/%d = %.0f%% MRR %.3f\n", top1, len(names), rate, mrr/n) + if len(missed) > 0 { + fmt.Printf(" 未命中 top-1(前 10):%v\n", firstN(missed, 10)) + } + for _, q := range []string{"最近更新", "首启人格门禁", "插件怎么开发和部署", "统一多模态向量空间 ONNX", "隐私政策"} { + hits := st.Search(q, 2) + got := []string{} + for _, h := range hits { + got = append(got, h.Name) + } + fmt.Printf(" 查询「%s」→ %v\n", q, got) + } + + if os.Getenv("KB_DIAG_SWEEP") != "" { + fmt.Printf("\n === 融合权重扫描(1.0 = 只用稠密路,0.0 = 只用词法路)===\n") + saved := densePathWeight + for _, w := range []float64{1.0, 0.8, 0.7, 0.5, 0.3, 0.0} { + densePathWeight = w + t1, m := 0, 0.0 + for _, name := range names { + hits := st.Search(name, len(names)) + for i, h := range hits { + if h.Name == name { + if i == 0 { + t1++ + } + m += 1.0 / float64(i+1) + break + } + } + } + fmt.Printf(" 权重 %.1f:top-1 %2d/%d = %3.0f%% MRR %.3f\n", + w, t1, len(names), 100*float64(t1)/float64(len(names)), m/float64(len(names))) + } + densePathWeight = saved + } + + if os.Getenv("KB_DIAG_ASSERT") != "" { + if mrr/n < 0.34 { + t.Fatalf("检索质量退化:MRR %.3f < 0.34(修复前 0.271,修复后 0.376)", mrr/n) + } + if rate < 18 { + t.Fatalf("检索质量退化:top-1 %.0f%% < 18%%", rate) + } + } +} + +func envOr(k, def string) string { + if v := os.Getenv(k); v != "" { + return v + } + return def +} + +func firstN(s []string, n int) []string { + if len(s) <= n { + return s + } + return s[:n] +} diff --git a/internal/knowledge/search_fusion_test.go b/internal/knowledge/search_fusion_test.go new file mode 100644 index 0000000..0048e0f --- /dev/null +++ b/internal/knowledge/search_fusion_test.go @@ -0,0 +1,176 @@ +package knowledge + +import ( + "testing" + + "gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector" +) + +// fakeDense 是可控的稠密向量器:按文本查表,缺省给同一个向量。 +// 用它把「稠密路无区分度/给空向量」这类真实故障在单测里复现出来。 +type fakeDense struct { + byText map[string]vector.Vector + def vector.Vector +} + +func (f fakeDense) Vectorize(text string) vector.Vector { + if v, ok := f.byText[text]; ok { + return v + } + return f.def +} + +// EmbedImage 满足 vector.Vectorizer 接口(本用例只用到文本路)。 +func (f fakeDense) EmbedImage([]byte, string) (vector.Vector, error) { + return f.def, nil +} + +func newTestStore(t *testing.T, dense vector.Vectorizer) *Store { + t.Helper() + st := NewStore(t.TempDir()) + if dense != nil { + st.SetVectorizer(dense) + } + return st +} + +func mustAdd(t *testing.T, st *Store, name, content string) { + t.Helper() + if err := st.Add(name, content); err != nil { + t.Fatalf("add %s: %v", name, err) + } +} + +func names(hits []*Knowledge) []string { + out := make([]string, len(hits)) + for i, h := range hits { + out[i] = h.Name + } + return out +} + +// base36 生成互不重复的短串(造独特词的量级要大,不能用 a..z 循环重复)。 +func base36(n int) string { + const digits = "0123456789abcdefghijklmnopqrstuvwxyz" + if n == 0 { + return "0" + } + out := "" + for n > 0 { + out = string(digits[n%36]) + out + n /= 36 + } + return out +} + +// 稠密路给**空向量**(真实场景:查询词全在停用词表里,例如"最近更新")时, +// 词法路必须把结果救回来——修复前这里直接返回空。 +func TestSearchLexicalRescuesEmptyDenseQuery(t *testing.T) { + dense := fakeDense{def: vector.Vector{"0": 1}} + st := newTestStore(t, dense) + mustAdd(t, st, "changelog_v1", "最近更新了很多东西 发布说明") + mustAdd(t, st, "weather_doc", "天气预报 晴转多云") + + // 让"更新"的稠密向量为空(模拟停用词化) + st.SetVectorizer(fakeDense{ + byText: map[string]vector.Vector{"更新": {}}, + def: vector.Vector{"0": 1}, + }) + hits := st.Search("更新", 5) + if len(hits) == 0 { + t.Fatal("稠密路给空向量时不该返回空结果(词法路应救回来)") + } + if hits[0].Name != "changelog_v1" { + t.Fatalf("应命中含「更新」的条目,实际: %v", names(hits)) + } +} + +// 稠密路对所有文本给**同一个向量**(真实故障:词向量平均后各向异性、区分度极低)时, +// 排序必须由词法路决定。 +func TestSearchLexicalBreaksDenseTies(t *testing.T) { + same := vector.Vector{"0": 1, "1": 1} + st := newTestStore(t, fakeDense{def: same}) + mustAdd(t, st, "plugin_dev_build", "插件构建与部署 hmapdev 命令") + mustAdd(t, st, "cangjie_manual", "仓颉编程语言知识手册") + mustAdd(t, st, "privacy_policy", "隐私政策") + + hits := st.Search("hmapdev 构建", 3) + if len(hits) == 0 || hits[0].Name != "plugin_dev_build" { + t.Fatalf("稠密路并列时应由词法路选出 plugin_dev_build,实际: %v", names(hits)) + } +} + +// 词法路的候选中选阈值必须是 0:TF-IDF 余弦量级只有 0.0~0.2,沿用稠密路的 0.05 +// 会把有效候选静默砍掉(真实 KB 实测自检索 MRR 0.307→0.193)。 +// +// 判据分两层,各钉一半: +// - **语义层**由 internal/memory/vector 的 TestSearchScoredRespectsMinScore 证明 +// (同一候选在默认阈值下被过滤、阈值 0 时被召回); +// - **接线层**在这里钉住:知识库的词法路用的就是阈值 0 的那个 store。 +// 不在这里造「低余弦夹具」的原因:分词器会丢掉纯拉丁 token、也会过滤未登录词, +// 造出来的夹具余弦根本压不到阈值以下(我先试了两种,余弦 0.23/0.27, +// 前提断言直接把这两版夹具否掉了)。 +func TestLexicalStoreUsesZeroMinScore(t *testing.T) { + st := newTestStore(t, fakeDense{def: vector.Vector{"0": 1}}) + if got := st.lex.MinScore(); got != 0 { + t.Fatalf("词法路阈值必须为 0,实际 %v(沿用稠密路阈值会静默丢候选)", got) + } + if got := st.vec.MinScore(); got != vector.DefaultMinScore { + t.Fatalf("稠密路阈值应保持默认 %v,实际 %v", vector.DefaultMinScore, got) + } +} + +// Add / Remove 必须同时维护两路索引:只维护一路会让被删条目继续被检索命中 +// (或新条目只在其中一路可见)。 +func TestAddRemoveKeepsBothPaths(t *testing.T) { + st := newTestStore(t, fakeDense{def: vector.Vector{"0": 1}}) + mustAdd(t, st, "alpha", "alpha 独有词 alphaonly") + mustAdd(t, st, "beta", "beta 独有词 betaonly") + + has := func(q, want string) bool { + for _, h := range st.Search(q, 5) { + if h.Name == want { + return true + } + } + return false + } + if !has("alphaonly", "alpha") { + t.Fatal("新增条目应可被检索到") + } + if err := st.Remove("alpha"); err != nil { + t.Fatalf("remove: %v", err) + } + if has("alphaonly", "alpha") { + t.Fatal("已删除条目仍被检索命中(两路索引有一路没清)") + } + if !has("betaonly", "beta") { + t.Fatal("删除其它条目不应影响 beta") + } +} + +// 分数相同时必须按名字定序,保证结果可重复(否则同一查询两次结果可能不同)。 +func TestSearchDeterministicOnTies(t *testing.T) { + same := vector.Vector{"0": 1} + st := newTestStore(t, fakeDense{def: same}) + for _, n := range []string{"ccc", "aaa", "bbb"} { + mustAdd(t, st, n, "完全一样的内容") + } + first := names(st.Search("完全一样的内容", 3)) + for i := 0; i < 5; i++ { + got := names(st.Search("完全一样的内容", 3)) + for j := range first { + if got[j] != first[j] { + t.Fatalf("结果不确定:第 %d 次 %v != 首次 %v", i, got, first) + } + } + } +} + +// 两路都空时不能 panic,且应返回空。 +func TestSearchEmptyStore(t *testing.T) { + st := newTestStore(t, fakeDense{def: vector.Vector{"0": 1}}) + if hits := st.Search("随便", 5); len(hits) != 0 { + t.Fatalf("空库应返回空,实际 %v", names(hits)) + } +} diff --git a/internal/memory/vector/store.go b/internal/memory/vector/store.go index 819cb06..15a187d 100644 --- a/internal/memory/vector/store.go +++ b/internal/memory/vector/store.go @@ -77,6 +77,12 @@ type Store struct { docs []DocVector dim int index *InvertedIndex + + // minScore 是候选分数下限。**必须按向量空间标定**: + // 词向量/多模态余弦通常在 0.3~0.9,而 TF-IDF 余弦只有 0.0~0.2 —— + // 用同一个阈值会把词法路的大量有效候选静默砍掉 + // (实测:知识库自检索 MRR 0.307 → 0.193 就是这么掉的)。 + minScore float64 } type DocVector struct { @@ -86,9 +92,27 @@ type DocVector struct { Meta map[string]string } +// DefaultMinScore 是默认候选中选阈值(沿用历史行为)。 +const DefaultMinScore = 0.05 + +// MinScore 返回当前候选中选阈值(供接线处自证用的是哪个阈值)。 +func (s *Store) MinScore() float64 { + s.mu.RLock() + defer s.mu.RUnlock() + return s.minScore +} + +// SetMinScore 调整候选中选阈值(按向量空间标定,见 minScore 字段注释)。 +func (s *Store) SetMinScore(v float64) { + s.mu.Lock() + defer s.mu.Unlock() + s.minScore = v +} + func NewStore() *Store { return &Store{ - index: NewInvertedIndex(), + index: NewInvertedIndex(), + minScore: DefaultMinScore, } } @@ -160,7 +184,7 @@ func (s *Store) SearchScored(query Vector, topK int) []DocVectorHit { for _, d := range s.docs { if d.ID == id { score := CosineSimilarity(query, d.Vector) - if score > 0.05 { + if score > s.minScore { results = append(results, scored{d, score}) } break diff --git a/internal/memory/vector/store_test.go b/internal/memory/vector/store_test.go index 28bb0e6..06d8001 100644 --- a/internal/memory/vector/store_test.go +++ b/internal/memory/vector/store_test.go @@ -210,3 +210,29 @@ func BenchmarkExtractNGrams(b *testing.B) { 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) + } +}