fix(knowledge): 修复英文标识符检索不到(分词器 + RRF 融合)

## 故障

真实库 196 条里 154 条是 cangjie 文档(1.53MB / 88.6% 字节)。用户反馈
「语义检索命中偏弱,cangjie 霸榜」。实测三条带精确标识符的查询:

    PruneOldBackups   → top5 全是 cangjie,目标条目一次没出现
    minSceneEvidence  → 同上
    strip_path        → 同上

## 真因不是 cangjie,是分词器

cangjie 只是把问题放大的体量。真正的洞在 `memory.TokenizeWords`(jieba)
对英文标识符的处理 —— 实测:

    PruneOldBackups → [P r u n e O l d B a c k p s]   ← 切成单字符还去重
    strip_path      → [s t r i p _ a h]

于是任意两篇含英文字母的文档 token 重叠率都能到 50%,词法路彻底失效。
而稠密路(chineseclip)对「只在文中出现一次的专名」本就没有分辨力。

这解释了 2026-10-08 那次决策:当时把词法路从融合里去掉,理由是
「词法路对英文标识符失效」—— 观察对,**归因错**:失效的是分词器。

## 改法

1. `identtoken.go`(新):知识库词法路专用分词器
   - 补标识符**整词**:`PruneOldBackups` → `pruneoldbackups`
   - 补**分隔符部件**:`strip_path` → `strip` / `path`(仅当下划线等存在时)
   - **不拆 camelCase**:拆了会产出 `old`/`backups`,而 `old` 在
     `cangjie/tools/cmd-tools/cjcompat_manual` 里是 CLI 参数名 `--old`,
     出现 31 次 —— 实测评测中该文档得分 0.54 > 目标的 0.20,正好把无关文档顶上来
   - 丢掉 jieba 切出来的单字母残渣
   - 中文分词完全沿用 jieba,行为不变
   - 只注入知识库的 veczer,**不动** memory 索引/关键词提取

2. `fusion.go`(新):RRF 倒数排名融合
   - `score = 1.0/(60+rank_dense) + 0.5/(60+rank_lex)`
   - 不用当初那套「逐路按查询内最大值归一化」:那个做法把 0.02 的噪声抬到
     与 1.0 等权,正是 2026-10-08 淘汰融合的真凶。RRF 只看名次,与量纲无关
   - 稠密权重 1.0(语义主线)/ 词法 0.5(精确命名救助)

3. `Search` 文档注释重写:把两段历史都写清(2026-10-08 为何淘汰、2026-10-31
   为何加回但换成 RRF),避免下一个人重复踩。

## 判据

- `TestIdentifierTokenFeedsLex`:钉分词结果(必须产出整词、不得拆出 old)
- `TestIdentifierQueryBeatsVolume`:60 篇英文词密集的噪声文档 + 一条只在
  目标条目出现一次的标识符,top1 必须命中目标 —— 分词器一退化就红
- `TestSeparatorIdentifierNotBuried`:带 `_` 的标识符,整词要压过部件噪声
- `TestDensePathFeedsRanking`(原 `TestDensePathIsSoleRecall`):保留它真正的
  意图 —— 防「调用了 denseHits 却把结果丢掉」这个反模式,形状改为喂给 fuseRRF
- `TestSearchDocReflectsRRF`(原 `..DenseOnly`):钉文档与实现不漂移

## 验证

    go build ./...                        ✓
    go vet ./internal/knowledge/          ✓
    gofmt -l                              ✓
    go test ./internal/knowledge/         ✓(含 -race)
    go test ./... -count=1                全绿

⚠️ 生产实例需重启才能生效(lex 索引在启动时重建,无数据迁移)。
This commit is contained in:
JianFeeeee
2026-10-31 16:42:36 +08:00
parent cc07adb20b
commit 0ca9e96290
5 changed files with 427 additions and 65 deletions

View File

@ -0,0 +1,87 @@
package knowledge
import "sort"
// rrfK 是 RRF(Reciprocal Rank Fusion)的平滑常数。
//
// 60 是 RRF 的通用取值(Cormack et al. 2009):它让「第 1 名」与「第 2 名」
// 的贡献足够接近(1/61 vs 1/62),又让第 100 名的贡献掉到 1/160 —— 既压住
// 长尾噪声,又不至于让名次之间的差异被抹平。调大它更平权,调小更信头部。
const rrfK = 60
// fuseRRF 按倒数排名融合两路命中。
//
// 输入必须是**已按分数降序**的候选(SearchScored / denseHits 都保证这一点),
// 因为 RRF 只用名次、不看分数。
//
// 为什么用名次而不做分数归一化:两路量纲完全不可比(稠密余弦 0~1,TF-IDF 余弦
// 可以低到 0.02)。2026-10-08 淘汰「三路融合」时踩的坑正是逐路按查询内最大值
// 归一到 1.0 —— 那会把 0.02 的噪声抬到与 1.0 的信号等权。RRF 没有这个问题:
// 它只问「你在这路里排第几」,权重由调用方显式给。
//
// 任一路为空时,另一路的贡献不受影响(缺席的路径只是不加分)。
func fuseRRF(dense, lex []scoreHit, wDense, wLex float64, k int) []scoreHit {
if k <= 0 {
k = rrfK
}
scores := make(map[string]float64, len(dense)+len(lex))
addRanks := func(hits []scoreHit, w float64) {
if w <= 0 {
return
}
for i, h := range hits {
// i 从 0 起,名次从 1 起
scores[h.id] += w / float64(k+i+1)
}
}
addRanks(dense, wDense)
addRanks(lex, wLex)
out := make([]scoreHit, 0, len(scores))
for id, s := range scores {
out = append(out, scoreHit{id: id, score: s})
}
sort.Slice(out, func(i, j int) bool {
if out[i].score != out[j].score {
return out[i].score > out[j].score
}
// 同分按 id 稳定排序,避免 map 迭代顺序导致结果不可复现
return out[i].id < out[j].id
})
return out
}
// lexHits 用词法路(TF-IDF 索引)给查询打分,返回**按分数降序**的候选。
//
// 调用方必须已持有 s.mu(读锁即可):这里只读 s.lex 与 s.veczer。
// s.lex 自己另有一把锁,与 s.mu 不同,不存在锁序问题。
func (s *Store) lexHits(query string) []scoreHit {
if s.lex == nil || s.lex.Size() == 0 || s.veczer == nil {
return nil
}
return toHits(s.lex.SearchScored(s.veczer.Vectorize(query), s.lex.Size()))
}
// searchLexOnly 是**只有词法路**时的检索入口。
//
// 用在稠密路向量化为空/失败的场合(空查询、模型未就绪):此时词法路不依赖模型,
// 仍能按字面命中。它不取 s.mu —— 唯一调用方 SearchIn 已持读锁。
func (s *Store) searchLexOnly(query string, inScope func(string) bool, topK int) []*Knowledge {
hits := s.lexHits(query)
if len(hits) == 0 {
return nil
}
out := make([]*Knowledge, 0, topK)
for _, h := range hits {
if !inScope(h.id) {
continue
}
if k, ok := s.items[h.id]; ok {
out = append(out, k)
}
if len(out) >= topK {
break
}
}
return out
}