Files
HomeAgent/internal/knowledge/identifier_recall_test.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

102 lines
3.5 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 (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
// TestIdentifierQueryBeatsVolume 复现并钉住 2026-10-31 报的那个故障形态:
//
// 库里 79% 的条目是某一种文档(实测 196 条里 154 条 cangjie),
// 查一个**只在目标条目里出现一次**的英文标识符,结果被那批文档淹没,
// 目标条目一次都进不了前五。
//
// 这里用退化路径(不注入稠密空间)来验,因为它只依赖词法路 —— 而词法路
// 正是当年被误判为"对标识符失效"的那条路。若分词器退回 jieba 单字符切分,
// 本判据会红(噪声文档里全是英文字母,token 重叠率足以盖过目标)。
func TestIdentifierQueryBeatsVolume(t *testing.T) {
root := t.TempDir()
// 目标条目:标识符只出现一次,符合真实文档形态(2872 字节里出现一次)。
target := "# 内核装配\n\n回滚前的清理由 `PruneOldBackups` 负责,保留最近 3 个备份。\n"
writeKB(t, root, "hologramagent_kernel_assembly_upgrade", target)
// 噪声:60 篇英文词密集的文档,模拟 cangjie 那批(含大量 old/path/strip 之类
// 普通英文词,正是误命中来源)。
filler := strings.Repeat("The old path is stripped and the old file path is cleaned. "+
"Use the strip command with the old path argument. ", 8)
for i := 0; i < 60; i++ {
writeKB(t, root, fmt.Sprintf("cangjie/dev-guide/noise_%02d", i), filler)
}
s := NewStore(root)
if err := s.Start(); err != nil {
t.Fatalf("Start: %v", err)
}
defer s.Stop()
for _, q := range []string{"PruneOldBackups", "pruneoldbackups"} {
res := s.Search(q, 5)
if len(res) == 0 {
t.Fatalf("查询 %q 无任何结果", q)
}
if res[0].Name != "hologramagent_kernel_assembly_upgrade" {
var names []string
for _, r := range res {
names = append(names, r.Name)
}
t.Errorf("查询 %q 的 top1 是 %q,应为目标条目。\n 实际前五: %v\n"+
" 常见原因:分词器把标识符切成单字符(见 identtoken.go)",
q, res[0].Name, names)
}
}
}
// TestSeparatorIdentifierNotBuried 钉住带分隔符的标识符(`strip_path`)。
//
// 与上一条分开的原因:带 `_` 的标识符会被拆成部件(strip / path),
// 而这两个都是普通英文词 —— 部件拆分既是命中来源,也是误命中来源。
// 所以这条单独验:整词命中必须压过部件噪声。
func TestSeparatorIdentifierNotBuried(t *testing.T) {
root := t.TempDir()
writeKB(t, root, "changelog_v1.4.0",
"# 更新日志\n\n- 通用反向代理:路径挂载 `strip_path`,按子域反代出去。\n")
filler := strings.Repeat("Path handling: strip the path prefix, then strip again. ", 10)
for i := 0; i < 60; i++ {
writeKB(t, root, fmt.Sprintf("cangjie/deploy/page_%02d", i), filler)
}
s := NewStore(root)
if err := s.Start(); err != nil {
t.Fatalf("Start: %v", err)
}
defer s.Stop()
res := s.Search("strip_path", 5)
if len(res) == 0 {
t.Fatal("无结果")
}
if res[0].Name != "changelog_v1.4.0" {
var names []string
for _, r := range res {
names = append(names, r.Name)
}
t.Errorf("top1 是 %q,应为 changelog_v1.4.0(实际前五 %v)", res[0].Name, names)
}
}
// writeKB 在 root/<name>/content.md 写一条知识。
func writeKB(t *testing.T, root, name, content string) {
t.Helper()
dir := filepath.Join(root, filepath.FromSlash(name))
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "content.md"), []byte(content), 0644); err != nil {
t.Fatal(err)
}
}