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`(同名覆盖必须摘掉旧向量,
生产改动当时已提交,测试一直未入库)。
This commit is contained in:
JianFeeeee
2026-09-12 18:13:08 +08:00
parent 5aaae93367
commit 5fbd6514c2
5 changed files with 507 additions and 33 deletions

View File

@ -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

View File

@ -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)
}
}