Files
HomeAgent/internal/knowledge/fusion.go
JianFeeeee 0365b08dc9 fix(knowledge): 改成词法路为主,RRF 方案实测无效
## 上一版(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 被否的实测原因,
  避免下一个人再翻回去
2026-10-31 16:57:29 +08:00

131 lines
4.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}