mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 14:57:38 +00:00
## 上一版(RRF)在生产上零收益
73bc3f9 用「稠密 1.0 / 词法 0.5 的 RRF」重启部署后,生产上 7 条查询
结果基本没变,目标条目一次都没进前三。原因是我在**本地退化路径**上验证,
而生产走稠密路径——两个分支的融合逻辑根本不同。
在真实 196 条库 + 真实 chineseclip 上测出两路的名次,问题一目了然:
查询 稠密名次 词法名次
PruneOldBackups 103 1
strip_path 164 1
minSceneEvidence 130 1
coldcontext_query 149 1
上下文裁剪的保护条数受什么约束 157 1
冷上下文取回为什么不能先删后灌 158 1
词法路全中,稠密路全在 100 名外。稠密权重 1.0 时它的 `1.0/61` 永远压过
词法的 `0.5/61`,所以词法路精确命中也没用。
## 为什么连 RRF 本身也不行
试过把权重翻成词法 1.0 / 稠密 0.5,仍然错。因为 RRF **丢掉分数差距**:
`strip_path` 的 top1 是 0.056、top2 是 0.055 —— 名次差 1,RRF 当它们等强,
稠密对 top2 的加分一叠就反超。
## 现在的做法:fuseLexPrimary
- 词法路给主序,**保留分数差距**(这是关键词)
- 稠密路用于:同分裁决、补位、以及词法无信号时接管
(`minLexSignal = 0.02`,低于此视为 jieba 泛滥词的微弱命中,让稠密接)
- 稠密结果仍然被使用,不是死代码
## 实测(真实库 + 真实模型)
top1 = 10/13 top3 = 11/13
改前生产表现:7/7 全错。剩下的 3 条未命中(仓颉关键字 rank2、鸿蒙、更新日志)
是既有的弱点,与本次改动无关。
## 判据
- `TestDensePathFeedsRanking`:钉「稠密结果必须喂进排序」(改了形状,意图不变)
- `TestSearchDocReflectsFusion`:钉文档写明 fuseLexPrimary + 词法为主
- 文档注释里记下两次翻转(2026-10-08 → 10-31)与 RRF 被否的实测原因,
避免下一个人再翻回去
131 lines
4.7 KiB
Go
131 lines
4.7 KiB
Go
package knowledge
|
||
|
||
import "sort"
|
||
|
||
// minLexSignal 是「词法路有真实信号」的最低 top1 分数。
|
||
//
|
||
// 低于它的原因不是命中,而是 jieba 把查询切出了几个泛滥词(如单字、虚词),
|
||
// 于是词法路给近 200 条都打上极小的分。这种情况下词法排序没有意义,
|
||
// 应当把召回让给稠密路。
|
||
//
|
||
// 取值依据(2026-10-31 实测,真实 196 条库 + chineseclip):真命中在
|
||
// 0.056 ~ 0.208;弱命中(`鸿蒙` → openharmony-third-party)0.117。
|
||
// 0.02 比最小真命中低一倍多,足够宽松。
|
||
const minLexSignal = 0.02
|
||
|
||
// fuseLexPrimary 以**词法路为主、稠密路为辅**合并两路命中。
|
||
//
|
||
// # 为什么不是 RRF(试过,被实测否掉)
|
||
//
|
||
// RRF 只看名次、丢掉分数差距。而这里词法路的名次之间**差距很小**:
|
||
// `strip_path` 的 top1 是 0.056、top2 是 0.055 —— 名次差 1,RRF 就当成
|
||
// 几乎等强,稠密路对 top2 的加分一叠就把它超了。实测:RRF(词法 1.0 /
|
||
// 稠密 0.5)下 `strip_path` 的目标条目直接掉出前五。
|
||
//
|
||
// # 为什么词法为主(这与「稠密更先进」的直觉相反,但实测如此)
|
||
//
|
||
// 真实 196 条库 + 真实 chineseclip,同一批查询里两路的名次:
|
||
//
|
||
// 查询 稠密名次 词法名次
|
||
// PruneOldBackups 103 1
|
||
// strip_path 164 1
|
||
// minSceneEvidence 130 1
|
||
// changelog_v1.3.0 9 1
|
||
// henan_medical_university 4 1
|
||
// 上下文裁剪的保护条数受什么约束 157 1
|
||
// 更新日志 86 8
|
||
//
|
||
// 原因是本项目自己记录过的已知代价:chineseclip 双塔的**纯文本语义弱于
|
||
// MLLM 型嵌入器**(见 multimodal-space 文档)。而这里 154/196 条是同一种
|
||
// 文档(cangjie),在 512 维里挤成一团,稠密打分对「哪个才对」几乎没有
|
||
// 分辨力 —— 目标条目普遍落在 100 名以外。
|
||
//
|
||
// # 稠密路仍然要接(不是死代码)
|
||
//
|
||
// 1. **同分裁决**:词法分数相同时按稠密名次排(分数是浮点,同分确实出现)
|
||
// 2. **补位**:词法路命中不足 topK 时,按稠密名次追加
|
||
// 3. **无信号时接管**:词法 top1 低于 minLexSignal(见该常量注释)
|
||
// 4. **词法全空时接管**:查询里没有任何可命中的词
|
||
//
|
||
// 换嵌入器(比如换成语义更强的 MLLM 型)后本策略应当重测 —— 那时稠密路
|
||
// 的名次可能反超,权重需要跟着改。判据见 identifier_recall_test.go 与
|
||
// dense_recall_quality_test.go。
|
||
func fuseLexPrimary(lex, dense []scoreHit) []scoreHit {
|
||
if len(lex) == 0 {
|
||
return dense
|
||
}
|
||
if lex[0].score < minLexSignal {
|
||
// 词法只是泛滥词的微弱命中,让它接管会把稠密路的语义召回埋掉。
|
||
return dense
|
||
}
|
||
|
||
denseRank := make(map[string]int, len(dense))
|
||
for i, h := range dense {
|
||
denseRank[h.id] = i
|
||
}
|
||
|
||
out := make([]scoreHit, len(lex))
|
||
copy(out, lex)
|
||
sort.SliceStable(out, func(i, j int) bool {
|
||
if out[i].score != out[j].score {
|
||
return out[i].score > out[j].score
|
||
}
|
||
di, iok := denseRank[out[i].id]
|
||
dj, jok := denseRank[out[j].id]
|
||
if iok != jok {
|
||
return iok // 稠密路认识的排前面
|
||
}
|
||
if iok && jok && di != dj {
|
||
return di < dj
|
||
}
|
||
return out[i].id < out[j].id // 兜底:可复现
|
||
})
|
||
|
||
// 补位:词法路没覆盖的候选按稠密名次接在尾部。
|
||
seen := make(map[string]bool, len(out))
|
||
for _, h := range out {
|
||
seen[h.id] = true
|
||
}
|
||
for _, h := range dense {
|
||
if !seen[h.id] {
|
||
out = append(out, h)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// lexHits 用词法路(TF-IDF 索引)给查询打分,返回**按分数降序**的候选。
|
||
//
|
||
// 调用方必须已持有 s.mu(读锁即可):这里只读 s.lex 与 s.veczer。
|
||
// s.lex 自己另有一把锁,与 s.mu 不同,不存在锁序问题。
|
||
func (s *Store) lexHits(query string) []scoreHit {
|
||
if s.lex == nil || s.lex.Size() == 0 || s.veczer == nil {
|
||
return nil
|
||
}
|
||
return toHits(s.lex.SearchScored(s.veczer.Vectorize(query), s.lex.Size()))
|
||
}
|
||
|
||
// searchLexOnly 是**只有词法路**时的检索入口。
|
||
//
|
||
// 用在稠密路向量化为空/失败的场合(空查询、模型未就绪):此时词法路不依赖模型,
|
||
// 仍能按字面命中。它不取 s.mu —— 唯一调用方 SearchIn 已持读锁。
|
||
func (s *Store) searchLexOnly(query string, inScope func(string) bool, topK int) []*Knowledge {
|
||
hits := s.lexHits(query)
|
||
if len(hits) == 0 {
|
||
return nil
|
||
}
|
||
out := make([]*Knowledge, 0, topK)
|
||
for _, h := range hits {
|
||
if !inScope(h.id) {
|
||
continue
|
||
}
|
||
if k, ok := s.items[h.id]; ok {
|
||
out = append(out, k)
|
||
}
|
||
if len(out) >= topK {
|
||
break
|
||
}
|
||
}
|
||
return out
|
||
}
|