mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-07 01:37:34 +00:00
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:
@ -140,7 +140,7 @@ func TestDenseRecallQuality(t *testing.T) {
|
||||
// 退化路径(未注入多模态空间时用稀疏两路)是**有意保留**的:
|
||||
// 留空 core.memory.multimodal_space.provider 的部署仍需可检索,
|
||||
// 否则直接变成「知识库完全不能用」。
|
||||
func TestDensePathIsSoleRecall(t *testing.T) {
|
||||
func TestDensePathFeedsRanking(t *testing.T) {
|
||||
src := readSourceFile(t, "knowledge.go")
|
||||
start := strings.Index(src, "func (s *Store) SearchIn(")
|
||||
if start < 0 {
|
||||
@ -152,40 +152,88 @@ func TestDensePathIsSoleRecall(t *testing.T) {
|
||||
}
|
||||
body := src[start : start+end]
|
||||
|
||||
// 稠密路分支必须存在且在稀疏两路之前(提前返回)。
|
||||
// 稠密路分支必须存在且在两路收集之前。
|
||||
denseAt := strings.Index(body, "denseHits(qv)")
|
||||
if denseAt < 0 {
|
||||
t.Fatal("SearchIn 必须调用 denseHits(稠密路是唯一召回路径)")
|
||||
t.Fatal("SearchIn 必须调用 denseHits(稠密路是语义主线)")
|
||||
}
|
||||
|
||||
// 稠密分支必须真的**以它的结果返回**,不能只是调用完就丢掉。
|
||||
// 光看 source 里有没有 denseHits 不够:把结果赋给 _ 再走融合
|
||||
// 也能骗过纯文本断言(变异测试实测过)。
|
||||
if !regexp.MustCompile(`hits\s*:=\s*s\.denseHits\(qv\)`).MatchString(body) {
|
||||
t.Error("稠密路的结果必须被接住(hits := s.denseHits(qv))并用于排序返回;" +
|
||||
"只调用不使用等于没接——排序仍会落到稀疏融合上")
|
||||
// ★ 本条判据真正要防的是「调用了 denseHits 却把结果丢掉」——
|
||||
// 这个反模式实测踩过:把结果赋给 _ 再走别的路径,排序完全落到另一路上,
|
||||
// 而表面上「稠密路接了」看起来是好的。
|
||||
//
|
||||
// 2026-10-31 起稠密路不再是唯一路径(改成与词法路 RRF 融合),
|
||||
// 但「结果必须喂进排序」这条不变,只是喂给 fuseRRF 而不是直接返回。
|
||||
if !regexp.MustCompile(`(\w+)\s*:=\s*s\.denseHits\(qv\)`).MatchString(body) {
|
||||
t.Fatal("稠密路的结果必须被接住(x := s.denseHits(qv))")
|
||||
}
|
||||
if !regexp.MustCompile(`fuseRRF\(\s*(\w+)`).MatchString(body) {
|
||||
t.Error("稠密路与词法路必须经 fuseRRF 融合;稠密结果不能只接不用")
|
||||
}
|
||||
// 稠密路产物必须真的作为融合输入之一(而不是另外造一份空的)。
|
||||
if !regexp.MustCompile(`dense\s*:=\s*s\.denseHits\(qv\)`).MatchString(body) ||
|
||||
!regexp.MustCompile(`fuseRRF\(\s*dense\s*,`).MatchString(body) {
|
||||
t.Error("必须写成 dense := s.denseHits(qv) 然后 fuseRRF(dense, ...)")
|
||||
}
|
||||
if !regexp.MustCompile(`if k, ok := s\.items\[h\.id\]`).MatchString(body) {
|
||||
t.Error("稠密路结果必须参与条目组装(s.items[h.id]),不能是摆设")
|
||||
t.Error("融合结果必须参与条目组装(s.items[h.id]),不能是摆设")
|
||||
}
|
||||
|
||||
sparseAt := strings.Index(body, "s.vec.SearchScored")
|
||||
if sparseAt < 0 {
|
||||
return // 已彻底移除稀疏路调用(比 fallback 更彻底),通过
|
||||
// 词法路也必须真的接上,「稀疏语义路」仍然不该回来。
|
||||
if !regexp.MustCompile(`lex\s*:=\s*s\.lexHits\(query\)`).MatchString(body) {
|
||||
t.Error("词法路必须接上(lex := s.lexHits(query))——" +
|
||||
"它是 PruneOldBackups / strip_path 这类标识符查询的唯一命中路径")
|
||||
}
|
||||
if denseAt > sparseAt {
|
||||
t.Error("稠密路必须在稀疏路之前并提前返回:\n" +
|
||||
" 稀疏路的逐路归一化会把 0.02x 的弱信号抬到 1.0,\n" +
|
||||
" 与稠密路的 1.0 等权相加 → 噪声压倒信号(实测 top1 掉到 1/10)")
|
||||
// 稀疏语义路不得出现在**稠密分支**里(退化路径用它是对的)。
|
||||
if deg := strings.Index(body, "退化路径"); deg > 0 {
|
||||
if strings.Contains(body[:deg], "s.vec.SearchScored") {
|
||||
t.Error("稀疏语义路不该回到稠密分支:它与稠密路高度冗余," +
|
||||
"而逐路归一化的坑见 Search 的文档注释")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchDocReflectsDenseOnly 防止文档与实现漂移。
|
||||
// TestIdentifierTokenFeedsLex 钉住「分词器修好了」这个前提。
|
||||
//
|
||||
// 只钉「文档不能声称三路融合是当前行为」。不能简单搜「三路」——
|
||||
// 新文档在解释「为何不再做三路融合」时会正当地提到它。
|
||||
// 故改为检查那些**只在声称当前这么做时**才会出现的句子。
|
||||
func TestSearchDocReflectsDenseOnly(t *testing.T) {
|
||||
// 若 identtoken.go 退化回 jieba 单字符切分,上面那条 RRF 判据仍会绿,
|
||||
// 但 PruneOldBackups 又搜不到了 —— 所以必须单独钉分词结果。
|
||||
func TestIdentifierTokenFeedsLex(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"PruneOldBackups", "pruneoldbackups"}, // 整词(不拆 camelCase)
|
||||
{"strip_path", "strip_path"}, // 整词保留分隔符
|
||||
{"strip_path", "strip"}, // 部件
|
||||
{"coldcontext_query", "coldcontext"}, // 部件
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := identifierTokenizer(c.in)
|
||||
found := false
|
||||
for _, g := range got {
|
||||
if g == c.want {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("identifierTokenizer(%q) 必须产出 %q,实际 %v", c.in, c.want, got)
|
||||
}
|
||||
}
|
||||
// 反向:不能拆出 old —— 那会命中 cjcompat 的 --old(31 次)把无关文档顶上来
|
||||
for _, g := range identifierTokenizer("PruneOldBackups") {
|
||||
if g == "old" || g == "backups" {
|
||||
t.Errorf("不该拆 camelCase:产出了 %q(会把 --old 密集的无关文档顶上来)", g)
|
||||
}
|
||||
}
|
||||
// 中文行为不变
|
||||
if toks := identifierTokenizer("知识库检索"); len(toks) == 0 {
|
||||
t.Error("中文分词不该被影响")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchDocReflectsRRF 防止文档与实现漂移。
|
||||
//
|
||||
// 2026-10-31 起语义变为「稠密 + 词法 RRF 融合」,本判据随之更新:
|
||||
// 文档必须声明 RRF,且不得再声称「稠密路是唯一召回路径」。
|
||||
func TestSearchDocReflectsRRF(t *testing.T) {
|
||||
src := readSourceFile(t, "knowledge.go")
|
||||
i := strings.Index(src, "// Search ")
|
||||
if i < 0 {
|
||||
@ -193,10 +241,13 @@ func TestSearchDocReflectsDenseOnly(t *testing.T) {
|
||||
}
|
||||
doc := src[i:min(len(src), i+2500)]
|
||||
|
||||
// ① 必须声明稠密路是唯一召回路径。
|
||||
if !strings.Contains(doc, "唯一召回路径") {
|
||||
t.Error("Search 文档必须明写「稠密向量路是唯一召回路径」——" +
|
||||
"这是接口语义,调用方靠它理解为何换了模型就要重建向量")
|
||||
// ① 必须声明 RRF 融合(这是现行接口语义)。
|
||||
if !strings.Contains(doc, "RRF") {
|
||||
t.Error("Search 文档必须写明「稠密路 + 词法路按 RRF 融合」")
|
||||
}
|
||||
// ①' 不得再声称稠密路是唯一路径(已与实现不符)。
|
||||
if strings.Contains(doc, "稠密向量路是**唯一召回路径**") {
|
||||
t.Error("Search 文档仍在声称「稠密向量路是唯一召回路径」,与实现不符")
|
||||
}
|
||||
// ② 不得声称仍在融合(旧文档的原句)。
|
||||
for _, banned := range []string{
|
||||
|
||||
Reference in New Issue
Block a user