Files
HomeAgent/internal/knowledge/fusion.go
JianFeeeee 0ca9e96290 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 索引在启动时重建,无数据迁移)。
2026-10-31 16:42:36 +08:00

88 lines
2.9 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 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
}