From 0365b08dc9a39059aaab29bf6a39bfbac4f01d3d Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 31 Oct 2026 16:57:29 +0800 Subject: [PATCH] =?UTF-8?q?fix(knowledge):=20=E6=94=B9=E6=88=90=E8=AF=8D?= =?UTF-8?q?=E6=B3=95=E8=B7=AF=E4=B8=BA=E4=B8=BB=EF=BC=8CRRF=20=E6=96=B9?= =?UTF-8?q?=E6=A1=88=E5=AE=9E=E6=B5=8B=E6=97=A0=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 上一版(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 被否的实测原因, 避免下一个人再翻回去 --- .../knowledge/dense_recall_quality_test.go | 33 +++--- internal/knowledge/fusion.go | 109 ++++++++++++------ internal/knowledge/knowledge.go | 68 ++++++----- 3 files changed, 124 insertions(+), 86 deletions(-) diff --git a/internal/knowledge/dense_recall_quality_test.go b/internal/knowledge/dense_recall_quality_test.go index 0923154b..0790bdca 100644 --- a/internal/knowledge/dense_recall_quality_test.go +++ b/internal/knowledge/dense_recall_quality_test.go @@ -162,18 +162,13 @@ func TestDensePathFeedsRanking(t *testing.T) { // 这个反模式实测踩过:把结果赋给 _ 再走别的路径,排序完全落到另一路上, // 而表面上「稠密路接了」看起来是好的。 // - // 2026-10-31 起稠密路不再是唯一路径(改成与词法路 RRF 融合), - // 但「结果必须喂进排序」这条不变,只是喂给 fuseRRF 而不是直接返回。 - if !regexp.MustCompile(`(\w+)\s*:=\s*s\.denseHits\(qv\)`).MatchString(body) { - t.Fatal("稠密路的结果必须被接住(x := s.denseHits(qv))") + // 2026-10-31 起的合并策略是 fuseLexPrimary(词法为主,稠密做同分裁决与补位), + // 但「稠密结果必须喂进排序」这条不变。 + if !regexp.MustCompile(`dense\s*:=\s*s\.denseHits\(qv\)`).MatchString(body) { + t.Fatal("必须写成 dense := 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(`fuseLexPrimary\(\s*lex\s*,\s*dense\s*\)`).MatchString(body) { + t.Error("两路必须经 fuseLexPrimary(lex, dense) 合并;稠密结果不能只接不用") } if !regexp.MustCompile(`if k, ok := s\.items\[h\.id\]`).MatchString(body) { t.Error("融合结果必须参与条目组装(s.items[h.id]),不能是摆设") @@ -229,11 +224,10 @@ func TestIdentifierTokenFeedsLex(t *testing.T) { } } -// TestSearchDocReflectsRRF 防止文档与实现漂移。 +// TestSearchDocReflectsFusion 防止文档与实现漂移。 // -// 2026-10-31 起语义变为「稠密 + 词法 RRF 融合」,本判据随之更新: -// 文档必须声明 RRF,且不得再声称「稠密路是唯一召回路径」。 -func TestSearchDocReflectsRRF(t *testing.T) { +// 2026-10-31 起语义变为「词法为主、稠密为辅」,本判据随之更新。 +func TestSearchDocReflectsFusion(t *testing.T) { src := readSourceFile(t, "knowledge.go") i := strings.Index(src, "// Search ") if i < 0 { @@ -241,9 +235,12 @@ func TestSearchDocReflectsRRF(t *testing.T) { } doc := src[i:min(len(src), i+2500)] - // ① 必须声明 RRF 融合(这是现行接口语义)。 - if !strings.Contains(doc, "RRF") { - t.Error("Search 文档必须写明「稠密路 + 词法路按 RRF 融合」") + // ① 必须声明当前的合并策略(这是接口语义)。 + if !strings.Contains(doc, "fuseLexPrimary") { + t.Error("Search 文档必须写明合并入口 fuseLexPrimary(词法为主、稠密为辅)") + } + if !strings.Contains(doc, "词法路为主") { + t.Error("Search 文档必须写明「词法路为主」——否则读代码的人会以为稠密路是主线") } // ①' 不得再声称稠密路是唯一路径(已与实现不符)。 if strings.Contains(doc, "稠密向量路是**唯一召回路径**") { diff --git a/internal/knowledge/fusion.go b/internal/knowledge/fusion.go index fe316252..244464e7 100644 --- a/internal/knowledge/fusion.go +++ b/internal/knowledge/fusion.go @@ -2,52 +2,95 @@ package knowledge import "sort" -// rrfK 是 RRF(Reciprocal Rank Fusion)的平滑常数。 +// minLexSignal 是「词法路有真实信号」的最低 top1 分数。 // -// 60 是 RRF 的通用取值(Cormack et al. 2009):它让「第 1 名」与「第 2 名」 -// 的贡献足够接近(1/61 vs 1/62),又让第 100 名的贡献掉到 1/160 —— 既压住 -// 长尾噪声,又不至于让名次之间的差异被抹平。调大它更平权,调小更信头部。 -const rrfK = 60 +// 低于它的原因不是命中,而是 jieba 把查询切出了几个泛滥词(如单字、虚词), +// 于是词法路给近 200 条都打上极小的分。这种情况下词法排序没有意义, +// 应当把召回让给稠密路。 +// +// 取值依据(2026-10-31 实测,真实 196 条库 + chineseclip):真命中在 +// 0.056 ~ 0.208;弱命中(`鸿蒙` → openharmony-third-party)0.117。 +// 0.02 比最小真命中低一倍多,足够宽松。 +const minLexSignal = 0.02 -// fuseRRF 按倒数排名融合两路命中。 +// fuseLexPrimary 以**词法路为主、稠密路为辅**合并两路命中。 // -// 输入必须是**已按分数降序**的候选(SearchScored / denseHits 都保证这一点), -// 因为 RRF 只用名次、不看分数。 +// # 为什么不是 RRF(试过,被实测否掉) // -// 为什么用名次而不做分数归一化:两路量纲完全不可比(稠密余弦 0~1,TF-IDF 余弦 -// 可以低到 0.02)。2026-10-08 淘汰「三路融合」时踩的坑正是逐路按查询内最大值 -// 归一到 1.0 —— 那会把 0.02 的噪声抬到与 1.0 的信号等权。RRF 没有这个问题: -// 它只问「你在这路里排第几」,权重由调用方显式给。 +// RRF 只看名次、丢掉分数差距。而这里词法路的名次之间**差距很小**: +// `strip_path` 的 top1 是 0.056、top2 是 0.055 —— 名次差 1,RRF 就当成 +// 几乎等强,稠密路对 top2 的加分一叠就把它超了。实测:RRF(词法 1.0 / +// 稠密 0.5)下 `strip_path` 的目标条目直接掉出前五。 // -// 任一路为空时,另一路的贡献不受影响(缺席的路径只是不加分)。 -func fuseRRF(dense, lex []scoreHit, wDense, wLex float64, k int) []scoreHit { - if k <= 0 { - k = rrfK +// # 为什么词法为主(这与「稠密更先进」的直觉相反,但实测如此) +// +// 真实 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 } - scores := make(map[string]float64, len(dense)+len(lex)) - addRanks := func(hits []scoreHit, w float64) { - if w <= 0 { - return - } - for i, h := range hits { - // i 从 0 起,名次从 1 起 - scores[h.id] += w / float64(k+i+1) - } + if lex[0].score < minLexSignal { + // 词法只是泛滥词的微弱命中,让它接管会把稠密路的语义召回埋掉。 + return dense } - addRanks(dense, wDense) - addRanks(lex, wLex) - out := make([]scoreHit, 0, len(scores)) - for id, s := range scores { - out = append(out, scoreHit{id: id, score: s}) + denseRank := make(map[string]int, len(dense)) + for i, h := range dense { + denseRank[h.id] = i } - sort.Slice(out, func(i, j int) bool { + + 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 } - // 同分按 id 稳定排序,避免 map 迭代顺序导致结果不可复现 - return out[i].id < out[j].id + 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 } diff --git a/internal/knowledge/knowledge.go b/internal/knowledge/knowledge.go index e1350249..0be6072c 100644 --- a/internal/knowledge/knowledge.go +++ b/internal/knowledge/knowledge.go @@ -494,49 +494,49 @@ var sparseSemWeight = 0.5 // 既然不再融合,就不存在「稠密路占多少预算」的问题。 // 保留本注释是因为 rankdiag_test 的历史实测门槛引用过它。 -// Search 检索知识库:**稠密路 + 词法路按 RRF 融合**。 +// Search 检索知识库:**词法路为主、稠密路为辅**(2026-10-31 改)。 // -// # 现行为(2026-10-31 改) +// # 现行为 // -// 两路各自给候选排序,再用倒数排名融合(RRF)合并: +// 两路各自打分,由 fuseLexPrimary 合并(见 fusion.go): // -// score(d) = 1.0/(60+rank_dense) + 0.5/(60+rank_lex) +// · 词法路(标识符感知 TF-IDF)给主序,**保留分数差距** +// · 稠密路用于同分裁决、补位,以及词法无信号时接管 // -// 稠密路是语义主线(权重 1.0),词法路只做**精确命名**的救助(权重 0.5)。 +// 词法路的分词器不是 memory.TokenizeWords,而是 identifierTokenizer +// (见 identtoken.go):jieba 会把 `PruneOldBackups` 切成单字符,词法路 +// 对英文标识符因此完全失效。 // -// # 为什么不回到三路融合(2026-10-08 那次为什么淘汰它) +// # 为什么词法为主(与「稠密更先进」的直觉相反) // -// 当时是「稠密 + 稀疏语义 + 词法」三路,每路按**查询内最大值**归一化到 1.0 -// 再加权相加。那个归一化是致命的: +// 真实 196 条库 + 真实 chineseclip 实测,目标条目在两路的名次: // -// 实测(真实 KB 192 条)词法路对 `the_jet_engine` 的原始分只有 0.027, -// 归一化后变成 1.0,与稠密路的 1.0 **等权相加** —— 弱信号被抬到 -// 与强信号同样的分量,噪声压倒信号。 +// 查询 稠密 词法 +// 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 // -// 于是改成「稠密路唯一」,top1 从 1/10 提到 6/10。 +// 原因是本项目记录过的已知代价:chineseclip 双塔的**纯文本语义弱于 +// MLLM 型嵌入器**。而这里 154/196 条是同一种文档(cangjie),在 512 维里 +// 挤成一团,稠密打分对「哪个才对」几乎没有分辨力。 // -// # 为什么现在要加回词法路(2026-10-31) +// # 历史上两次翻转(别再翻回去) // -// 「稠密路唯一」在**英文标识符**这类查询上留下了洞。实测(真实 196 条库): +// 2026-10-08 「三路归一化融合」→「稠密唯一」:因为逐路按查询内最大值 +// 归一化把 0.02 的噪声抬到与 1.0 等权(top1 1/10)。 +// 2026-10-31 「稠密唯一」→「词法为主」:因为分词器修好后词法路在 +// 标识符查询上唯一命中,而稠密路把它们排到 100 名外。 +// 中间试过 RRF,被实测否掉:词法路名次间分数差太小 +// (strip_path top1=0.056 / top2=0.055),RRF 只看名次 +// 会把它俩当等强,稠密加分一叠就反超。 // -// 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)。 -// -// 回归判据:dense_recall_quality_test.go(带标注的 10 条查询集 + 标识符用例)。 +// 换嵌入器(比如换成语义更强的 MLLM 型)后必须重测:那时稠密路名次可能 +// 反超,权重需要跟着改。判据见 identifier_recall_test.go 与 +// dense_recall_quality_test.go。 func (s *Store) Search(query string, topK int) []*Knowledge { return s.SearchIn(query, "", topK) } @@ -613,9 +613,7 @@ func (s *Store) SearchIn(query, category string, topK int) []*Knowledge { } dense := s.denseHits(qv) lex := s.lexHits(query) - - // 任一路为空都不影响另一路(RRF 对缺席的路径就是不加分)。 - fused := fuseRRF(dense, lex, 1.0, 0.5, rrfK) + fused := fuseLexPrimary(lex, dense) out := make([]*Knowledge, 0, topK) for _, h := range fused { if !inScope(h.id) {