mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-02 15:27:33 +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 索引在启动时重建,无数据迁移)。
102 lines
3.5 KiB
Go
102 lines
3.5 KiB
Go
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)
|
||
}
|
||
}
|