mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-06 17:27:31 +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:
@ -196,8 +196,11 @@ func NewStore(root string) *Store {
|
||||
denseCachePath: filepath.Join(root, ".dense.json"),
|
||||
vec: vector.NewStore(),
|
||||
lex: newLexicalStore(),
|
||||
veczer: vector.NewTFIDFVectorizer(memory.TokenizeWords),
|
||||
items: make(map[string]*Knowledge),
|
||||
// 词法路用**标识符感知**分词器(见 identtoken.go),不是 memory.TokenizeWords:
|
||||
// jieba 会把 `PruneOldBackups` 切成单字符,词法路对标识符因此完全失效。
|
||||
// 只改知识库这一条路,内存索引/关键词提取仍用原分词器(行为不变)。
|
||||
veczer: vector.NewTFIDFVectorizer(identifierTokenizer),
|
||||
items: make(map[string]*Knowledge),
|
||||
}
|
||||
}
|
||||
|
||||
@ -491,48 +494,49 @@ var sparseSemWeight = 0.5
|
||||
// 既然不再融合,就不存在「稠密路占多少预算」的问题。
|
||||
// 保留本注释是因为 rankdiag_test 的历史实测门槛引用过它。
|
||||
|
||||
// Search 检索知识库:**稠密向量路是唯一召回路径**。
|
||||
// Search 检索知识库:**稠密路 + 词法路按 RRF 融合**。
|
||||
//
|
||||
// # 为何不再做三路融合(2026-10-08 改)
|
||||
// # 现行为(2026-10-31 改)
|
||||
//
|
||||
// 此前是「稠密 + 稀疏语义(词向量/TF-IDF)+ 词法(TF-IDF)」三路融合,
|
||||
// 每路按**查询内最大值**归一化到 1.0 再加权。这个归一化是致命的:
|
||||
// 两路各自给候选排序,再用倒数排名融合(RRF)合并:
|
||||
//
|
||||
// score(d) = 1.0/(60+rank_dense) + 0.5/(60+rank_lex)
|
||||
//
|
||||
// 稠密路是语义主线(权重 1.0),词法路只做**精确命名**的救助(权重 0.5)。
|
||||
//
|
||||
// # 为什么不回到三路融合(2026-10-08 那次为什么淘汰它)
|
||||
//
|
||||
// 当时是「稠密 + 稀疏语义 + 词法」三路,每路按**查询内最大值**归一化到 1.0
|
||||
// 再加权相加。那个归一化是致命的:
|
||||
//
|
||||
// 实测(真实 KB 192 条)词法路对 `the_jet_engine` 的原始分只有 0.027,
|
||||
// 归一化后变成 1.0,与稠密路的 1.0 **等权相加** —— 弱信号被抬到
|
||||
// 与强信号同样的分量,噪声压倒信号。
|
||||
//
|
||||
// 后果是检索几乎完全失效:10 条带标注查询里 top-1 只命中 1 条,
|
||||
// 连条目自己的全名都搜不到(`plugin_dev_sdk` 搜不到 `plugin_dev_sdk`),
|
||||
// 用户看到的是「查什么都返回同样的几个无关条目」。
|
||||
// 于是改成「稠密路唯一」,top1 从 1/10 提到 6/10。
|
||||
//
|
||||
// 而**稠密路单独打分时表现极好** —— 同一批查询全库排名 1/192:
|
||||
// # 为什么现在要加回词法路(2026-10-31)
|
||||
//
|
||||
// the_jet_engine → the_jet_engine_rolls-royce 0.769
|
||||
// plugin_dev_sdk → plugin_dev_sdk 0.852
|
||||
// homeagent_identity → homeagent_identity 0.823
|
||||
// 「稠密路唯一」在**英文标识符**这类查询上留下了洞。实测(真实 196 条库):
|
||||
//
|
||||
// 改后实测:top1 1/10 → 6/10,top3 2/10 → 6/10。
|
||||
// query=PruneOldBackups → 目标条目从未进前五
|
||||
// query=minSceneEvidence → 同上
|
||||
// query=strip_path → 同上
|
||||
//
|
||||
// # 为何能去掉归一化
|
||||
// 返回的全是 154 条 cangjie 文档里反复霸榜的那几篇。原因是词法路当年被淘汰
|
||||
// 时,把**分词器的缺陷**误判成了词法路的属性:jieba 对英文标识符是
|
||||
// **按字符切**的(`PruneOldBackups` → `[P r u n e O l d B a c k p s]`),
|
||||
// 任意两篇含英文字母的文档 token 重叠率都能到 50%。
|
||||
//
|
||||
// 余弦分数**本身就是可比的**(同一空间、同一维度下),不需要逐路归一化。
|
||||
// 归一化当初是为了把「量级不同的三路」拉到同一尺度,现在只剩一路,
|
||||
// 它反而成了破坏排序的东西。
|
||||
// 修掉分词器(identtoken.go:标识符整词 + 分隔符部件,且不拆 camelCase)后,
|
||||
// 同三条查询词法路**唯一命中**目标条目。而稠密路对「只在文中出现一次专名」
|
||||
// 本就无能为力(chineseclip 的纯文本语义弱,见 multimodal-space 文档的已知代价)。
|
||||
//
|
||||
// # 历史结论为何在此时翻转
|
||||
// 用 RRF 而不是当初那套归一化:RRF 只看**名次**、不看分数,两路量纲不可比
|
||||
// 的问题自动消失,也不会把 0.02 的弱信号抬到与 1.0 等权(第 100 名只贡献
|
||||
// 1/160,第 1 名贡献 1/61)。
|
||||
//
|
||||
// 旧注释引的实测(仅稠密路 top-1 15%、MRR 0.271;融合后 21% / 0.376)
|
||||
// 做在两处**已不再成立的前提**上:
|
||||
// - 33 条的小规模 KB(现在是 192 条,且含大量英文标识符)
|
||||
// - 稀疏 fastText 词向量(现在是 chineseclip 512 维多模态空间)
|
||||
//
|
||||
// 换成真正的多模态向量空间后,稠密路的语义能力与当时不可同日而语,
|
||||
// 而稀疏路的「对专名/术语强」在**英文标识符**上反而因 jieba 把
|
||||
// the_jet_engine 切成 [t h e _ j n g ...] 而完全失效——两条无关文档
|
||||
// 的 token 重叠率高达 50%。
|
||||
//
|
||||
// 回归判据:dense_recall_quality_test.go(带标注的 10 条查询集)。
|
||||
// 回归判据:dense_recall_quality_test.go(带标注的 10 条查询集 + 标识符用例)。
|
||||
func (s *Store) Search(query string, topK int) []*Knowledge {
|
||||
return s.SearchIn(query, "", topK)
|
||||
}
|
||||
@ -575,22 +579,45 @@ func (s *Store) SearchIn(query, category string, topK int) []*Knowledge {
|
||||
return nil // 该分类下没有任何条目,省掉全库打分
|
||||
}
|
||||
|
||||
// ★ 稠密向量路是**唯一召回路径**(2026-10-08)。
|
||||
// ★ 稠密路 + 词法路按 **RRF(倒数排名融合)** 合并(2026-10-31 改)。
|
||||
//
|
||||
// 详见 Search 的文档注释:三路融合的逐路归一化把 0.02x 的弱信号
|
||||
// 抬到与稠密路的 1.0 等权,导致检索几乎完全失效(top1 1/10)。
|
||||
// 这里直接返回稠密路结果,不经任何融合。
|
||||
// ## 为什么不再是「稠密路唯一」
|
||||
//
|
||||
// 2026-10-08 去掉词法路的理由是「词法路对英文标识符失效」。那个观察是对的,
|
||||
// 但归因错了 —— 失效的是**分词器**(jieba 把 `PruneOldBackups` 切成
|
||||
// `[P r u n e O l d B a c k p s]`),不是词法路本身。修掉分词器
|
||||
// (见 identtoken.go)后,同一批查询词法路能精确命中:
|
||||
//
|
||||
// PruneOldBackups → hologramagent_kernel_assembly_upgrade(唯一命中)
|
||||
// minSceneEvidence → hologramagent_kernel_facts
|
||||
// strip_path → changelog_v1.4.0
|
||||
//
|
||||
// 而稠密路对这些**只在文中出现一次的专名**无能为力(chineseclip 纯文本语义弱),
|
||||
// 实测这三条查询的目标条目一次都进不了前五,返回的是 154 条 cangjie 文档里
|
||||
// 反复霸榜的那几篇。
|
||||
//
|
||||
// ## 为什么用 RRF 而不是 2026-10-08 淘汰的那套
|
||||
//
|
||||
// 被淘汰的是「逐路按查询内最大值归一化再加权相加」。那个做法把词法路 0.027 的
|
||||
// 原始分抬成 1.0,与稠密路的 1.0 等权 —— 是**归一化**破坏了排序,不是词法路没用。
|
||||
// RRF 只用**名次**不用分数,天然不受两路量纲差异影响,也不会放大弱信号:
|
||||
// 第 100 名贡献 1/(60+100),与第 1 名的 1/61 差 2.6 倍,不会等权。
|
||||
//
|
||||
// 两路权重:稠密 1.0、词法 0.5 —— 语义仍是主线,词法只做精确命名的救助。
|
||||
if s.denseEnabled() {
|
||||
qv, err := s.dense.VectorizeDense(query)
|
||||
if err != nil || len(qv) == 0 {
|
||||
// 向量化失败或得到空向量(空/纯空白查询)。
|
||||
// 稠密路是唯一路径,故返回空而不是退回稀疏路——
|
||||
// 退回就等于把已验证的退化行为又放回来。
|
||||
return nil
|
||||
// 退回词法路:它不依赖模型,至少还能按字面命中。
|
||||
return s.searchLexOnly(query, inScope, topK)
|
||||
}
|
||||
hits := s.denseHits(qv)
|
||||
dense := s.denseHits(qv)
|
||||
lex := s.lexHits(query)
|
||||
|
||||
// 任一路为空都不影响另一路(RRF 对缺席的路径就是不加分)。
|
||||
fused := fuseRRF(dense, lex, 1.0, 0.5, rrfK)
|
||||
out := make([]*Knowledge, 0, topK)
|
||||
for _, h := range hits {
|
||||
for _, h := range fused {
|
||||
if !inScope(h.id) {
|
||||
continue
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user