mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 14:57:38 +00:00
## 故障
真实库 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 索引在启动时重建,无数据迁移)。
97 lines
3.3 KiB
Go
97 lines
3.3 KiB
Go
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')
|
||
}
|