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

97 lines
3.3 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 (
"regexp"
"strings"
"github.com/JianFeeeee/HologramAgent/internal/memory"
)
// asciiRunRe 匹配一段连续的 ASCII 标识符/单词(允许 . _ - / 作分隔符)。
//
// 为什么用正则扫原文而不是复用 jieba 的结果:jieba 对英文标识符是**按字符切**的
// (实测 `PruneOldBackups` → `[P r u n e O l d B a c k p s]`,还顺带去重),
// 词法路因此对标识符完全失效 —— 任意两篇含英文字母的文档 token 重叠率都能到 50%。
var asciiRunRe = regexp.MustCompile(`[A-Za-z][A-Za-z0-9_.\-/]*`)
// identSepRe 是标识符内的分隔符集合(不含空格:空格是词边界,不是标识符内部分隔)。
var identSepRe = regexp.MustCompile(`[_.\-/]+`)
// identifierTokenizer 是知识库**词法路专用**的分词器。
//
// # 它解决的问题(2026-10-31 实测)
//
// 2026-10-08 把检索改成「稠密路唯一」,理由是词法路对英文标识符失效、而
// 逐路归一化又会把弱信号抬到与稠密路等权。前一半是**分词器的缺陷**,不是
// 词法路本身的属性。实测(真实 196 条库):
//
// query=PruneOldBackups → 目标条目从未进前五,返回的是 cangjie 文档
// query=minSceneEvidence → 同样从未命中
//
// 因为查询被切成单字符,而 `hologramagent_kernel_assembly_upgrade` 里
// 那个唯一的 `PruneOldBackups` 在单字符空间里与其他文档没有区别。
//
// # 做法
//
// 在 jieba 词之外,**额外**补两类 token:
//
// · 标识符整词:`PruneOldBackups` → `pruneoldbackups`、`strip_path` → `strip_path`
// · 分隔符部件:`strip_path` → `strip`、`path`(仅当下划线/点/斜杠存在时)
//
// 刻意**不拆 camelCase**:拆了会把 `PruneOldBackups` 拆出 `old`/`backups`,
// 而 `old` 在 `cangjie/tools/cmd-tools/cjcompat_manual` 里是 CLI 参数名 `--old`,
// 出现 31 次 —— 拆 camelCase 反而让无关文档拿到高分(实测该文档 0.54 > 目标 0.20)。
//
// 同时丢掉**单个 ASCII 字母**的 token:那是 jieba 字符切分的残渣,只制造噪声。
//
// 中文部分完全沿用 jieba,行为不变。
func identifierTokenizer(text string) []string {
base := memory.TokenizeWords(text)
out := make([]string, 0, len(base)+8)
seen := make(map[string]bool, len(base)+8)
add := func(tok string) {
if tok == "" || seen[tok] {
return
}
seen[tok] = true
out = append(out, tok)
}
for _, t := range base {
if isSingleASCIILetter(t) {
continue // jieba 把标识符切碎的残渣
}
add(t)
}
for _, run := range asciiRunRe.FindAllString(text, -1) {
lower := strings.ToLower(run)
// 整词:长度 >= 3 才收(`go`/`io` 这类太泛,收进来只增噪声)。
if len(lower) >= 3 {
add(lower)
}
// 部件:只有真的带分隔符才拆;`PruneOldBackups` 这种无分隔符的整词
// 已经由上一行覆盖,拆 camelCase 反而有害(见函数注释)。
parts := identSepRe.Split(lower, -1)
if len(parts) < 2 {
continue
}
for _, p := range parts {
if len(p) >= 3 {
add(p)
}
}
}
return out
}
// isSingleASCIILetter 报告 token 是不是「单个 ASCII 字母」。
func isSingleASCIILetter(t string) bool {
if len(t) != 1 {
return false
}
c := t[0]
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
}