merge: 知识库标识符检索修复

This commit is contained in:
JianFeeeee
2026-10-31 16:42:38 +08:00
5 changed files with 427 additions and 65 deletions

View File

@ -140,7 +140,7 @@ func TestDenseRecallQuality(t *testing.T) {
// 退化路径(未注入多模态空间时用稀疏两路)是**有意保留**的:
// 留空 core.memory.multimodal_space.provider 的部署仍需可检索,
// 否则直接变成「知识库完全不能用」。
func TestDensePathIsSoleRecall(t *testing.T) {
func TestDensePathFeedsRanking(t *testing.T) {
src := readSourceFile(t, "knowledge.go")
start := strings.Index(src, "func (s *Store) SearchIn(")
if start < 0 {
@ -152,40 +152,88 @@ func TestDensePathIsSoleRecall(t *testing.T) {
}
body := src[start : start+end]
// 稠密路分支必须存在且在稀疏两路之前(提前返回)。
// 稠密路分支必须存在且在两路收集之前。
denseAt := strings.Index(body, "denseHits(qv)")
if denseAt < 0 {
t.Fatal("SearchIn 必须调用 denseHits(稠密路是唯一召回路径)")
t.Fatal("SearchIn 必须调用 denseHits(稠密路是语义主线)")
}
// 稠密分支必须真的**以它的结果返回**,不能只是调用完就丢掉。
// 光看 source 里有没有 denseHits 不够:把结果赋给 _ 再走融合
// 也能骗过纯文本断言(变异测试实测过)。
if !regexp.MustCompile(`hits\s*:=\s*s\.denseHits\(qv\)`).MatchString(body) {
t.Error("稠密路的结果必须被接住(hits := s.denseHits(qv))并用于排序返回;" +
"只调用不使用等于没接——排序仍会落到稀疏融合上")
// ★ 本条判据真正要防的是「调用了 denseHits 却把结果丢掉」——
// 这个反模式实测踩过:把结果赋给 _ 再走别的路径,排序完全落到另一路上,
// 而表面上「稠密路接了」看起来是好的。
//
// 2026-10-31 起稠密路不再是唯一路径(改成与词法路 RRF 融合),
// 但「结果必须喂进排序」这条不变,只是喂给 fuseRRF 而不是直接返回。
if !regexp.MustCompile(`(\w+)\s*:=\s*s\.denseHits\(qv\)`).MatchString(body) {
t.Fatal("稠密路的结果必须被接住(x := 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(`if k, ok := s\.items\[h\.id\]`).MatchString(body) {
t.Error("稠密路结果必须参与条目组装(s.items[h.id]),不能是摆设")
t.Error("融合结果必须参与条目组装(s.items[h.id]),不能是摆设")
}
sparseAt := strings.Index(body, "s.vec.SearchScored")
if sparseAt < 0 {
return // 已彻底移除稀疏路调用(比 fallback 更彻底),通过
// 词法路也必须真的接上,「稀疏语义路」仍然不该回来。
if !regexp.MustCompile(`lex\s*:=\s*s\.lexHits\(query\)`).MatchString(body) {
t.Error("词法路必须接上(lex := s.lexHits(query))——" +
"它是 PruneOldBackups / strip_path 这类标识符查询的唯一命中路径")
}
if denseAt > sparseAt {
t.Error("稠密路必须在稀疏路之前并提前返回:\n" +
" 稀疏路的逐路归一化会把 0.02x 的弱信号抬到 1.0,\n" +
" 与稠密路的 1.0 等权相加 → 噪声压倒信号(实测 top1 掉到 1/10)")
// 稀疏语义路不得出现在**稠密分支**里(退化路径用它是对的)。
if deg := strings.Index(body, "退化路径"); deg > 0 {
if strings.Contains(body[:deg], "s.vec.SearchScored") {
t.Error("稀疏语义路不该回到稠密分支:它与稠密路高度冗余," +
"而逐路归一化的坑见 Search 的文档注释")
}
}
}
// TestSearchDocReflectsDenseOnly 防止文档与实现漂移。
// TestIdentifierTokenFeedsLex 钉住「分词器修好了」这个前提。
//
// 只钉「文档不能声称三路融合是当前行为」。不能简单搜「三路」——
// 新文档在解释「为何不再做三路融合」时会正当地提到它。
// 故改为检查那些**只在声称当前这么做时**才会出现的句子。
func TestSearchDocReflectsDenseOnly(t *testing.T) {
// 若 identtoken.go 退化回 jieba 单字符切分,上面那条 RRF 判据仍会绿,
// 但 PruneOldBackups 又搜不到了 —— 所以必须单独钉分词结果。
func TestIdentifierTokenFeedsLex(t *testing.T) {
cases := []struct{ in, want string }{
{"PruneOldBackups", "pruneoldbackups"}, // 整词(不拆 camelCase)
{"strip_path", "strip_path"}, // 整词保留分隔符
{"strip_path", "strip"}, // 部件
{"coldcontext_query", "coldcontext"}, // 部件
}
for _, c := range cases {
got := identifierTokenizer(c.in)
found := false
for _, g := range got {
if g == c.want {
found = true
break
}
}
if !found {
t.Errorf("identifierTokenizer(%q) 必须产出 %q,实际 %v", c.in, c.want, got)
}
}
// 反向:不能拆出 old —— 那会命中 cjcompat 的 --old(31 次)把无关文档顶上来
for _, g := range identifierTokenizer("PruneOldBackups") {
if g == "old" || g == "backups" {
t.Errorf("不该拆 camelCase:产出了 %q(会把 --old 密集的无关文档顶上来)", g)
}
}
// 中文行为不变
if toks := identifierTokenizer("知识库检索"); len(toks) == 0 {
t.Error("中文分词不该被影响")
}
}
// TestSearchDocReflectsRRF 防止文档与实现漂移。
//
// 2026-10-31 起语义变为「稠密 + 词法 RRF 融合」,本判据随之更新:
// 文档必须声明 RRF,且不得再声称「稠密路是唯一召回路径」。
func TestSearchDocReflectsRRF(t *testing.T) {
src := readSourceFile(t, "knowledge.go")
i := strings.Index(src, "// Search ")
if i < 0 {
@ -193,10 +241,13 @@ func TestSearchDocReflectsDenseOnly(t *testing.T) {
}
doc := src[i:min(len(src), i+2500)]
// ① 必须声明稠密路是唯一召回路径。
if !strings.Contains(doc, "唯一召回路径") {
t.Error("Search 文档必须明写「稠密向量路是唯一召回路径」——" +
"这是接口语义,调用方靠它理解为何换了模型就要重建向量")
// ① 必须声明 RRF 融合(这是现行接口语义)。
if !strings.Contains(doc, "RRF") {
t.Error("Search 文档必须写明「稠密路 + 词法路按 RRF 融合」")
}
// ①' 不得再声称稠密路是唯一路径(已与实现不符)。
if strings.Contains(doc, "稠密向量路是**唯一召回路径**") {
t.Error("Search 文档仍在声称「稠密向量路是唯一召回路径」,与实现不符")
}
// ② 不得声称仍在融合(旧文档的原句)。
for _, banned := range []string{

View File

@ -0,0 +1,87 @@
package knowledge
import "sort"
// rrfK 是 RRF(Reciprocal Rank Fusion)的平滑常数。
//
// 60 是 RRF 的通用取值(Cormack et al. 2009):它让「第 1 名」与「第 2 名」
// 的贡献足够接近(1/61 vs 1/62),又让第 100 名的贡献掉到 1/160 —— 既压住
// 长尾噪声,又不至于让名次之间的差异被抹平。调大它更平权,调小更信头部。
const rrfK = 60
// fuseRRF 按倒数排名融合两路命中。
//
// 输入必须是**已按分数降序**的候选(SearchScored / denseHits 都保证这一点),
// 因为 RRF 只用名次、不看分数。
//
// 为什么用名次而不做分数归一化:两路量纲完全不可比(稠密余弦 0~1,TF-IDF 余弦
// 可以低到 0.02)。2026-10-08 淘汰「三路融合」时踩的坑正是逐路按查询内最大值
// 归一到 1.0 —— 那会把 0.02 的噪声抬到与 1.0 的信号等权。RRF 没有这个问题:
// 它只问「你在这路里排第几」,权重由调用方显式给。
//
// 任一路为空时,另一路的贡献不受影响(缺席的路径只是不加分)。
func fuseRRF(dense, lex []scoreHit, wDense, wLex float64, k int) []scoreHit {
if k <= 0 {
k = rrfK
}
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)
}
}
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})
}
sort.Slice(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
})
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
}

View File

@ -0,0 +1,101 @@
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)
}
}

View File

@ -0,0 +1,96 @@
package knowledge
import (
"regexp"
"strings"
"github.com/JianFeeeee/HologramAgent/internal/memory"
)
// asciiRunRe 匹配一段连续的 ASCII 标识符/单词(允许 . _ - / 作分隔符)。
//
// 为什么用正则扫原文而不是复用 jieba 的结果:jieba 对英文标识符是**按字符切**的
// (实测 `PruneOldBackups` → `[P r u n e O l d B a c k p s]`,还顺带去重),
// 词法路因此对标识符完全失效 —— 任意两篇含英文字母的文档 token 重叠率都能到 50%。
var asciiRunRe = regexp.MustCompile(`[A-Za-z][A-Za-z0-9_.\-/]*`)
// identSepRe 是标识符内的分隔符集合(不含空格:空格是词边界,不是标识符内部分隔)。
var identSepRe = regexp.MustCompile(`[_.\-/]+`)
// identifierTokenizer 是知识库**词法路专用**的分词器。
//
// # 它解决的问题(2026-10-31 实测)
//
// 2026-10-08 把检索改成「稠密路唯一」,理由是词法路对英文标识符失效、而
// 逐路归一化又会把弱信号抬到与稠密路等权。前一半是**分词器的缺陷**,不是
// 词法路本身的属性。实测(真实 196 条库):
//
// query=PruneOldBackups → 目标条目从未进前五,返回的是 cangjie 文档
// query=minSceneEvidence → 同样从未命中
//
// 因为查询被切成单字符,而 `hologramagent_kernel_assembly_upgrade` 里
// 那个唯一的 `PruneOldBackups` 在单字符空间里与其他文档没有区别。
//
// # 做法
//
// 在 jieba 词之外,**额外**补两类 token:
//
// · 标识符整词:`PruneOldBackups` → `pruneoldbackups`、`strip_path` → `strip_path`
// · 分隔符部件:`strip_path` → `strip`、`path`(仅当下划线/点/斜杠存在时)
//
// 刻意**不拆 camelCase**:拆了会把 `PruneOldBackups` 拆出 `old`/`backups`,
// 而 `old` 在 `cangjie/tools/cmd-tools/cjcompat_manual` 里是 CLI 参数名 `--old`,
// 出现 31 次 —— 拆 camelCase 反而让无关文档拿到高分(实测该文档 0.54 > 目标 0.20)。
//
// 同时丢掉**单个 ASCII 字母**的 token:那是 jieba 字符切分的残渣,只制造噪声。
//
// 中文部分完全沿用 jieba,行为不变。
func identifierTokenizer(text string) []string {
base := memory.TokenizeWords(text)
out := make([]string, 0, len(base)+8)
seen := make(map[string]bool, len(base)+8)
add := func(tok string) {
if tok == "" || seen[tok] {
return
}
seen[tok] = true
out = append(out, tok)
}
for _, t := range base {
if isSingleASCIILetter(t) {
continue // jieba 把标识符切碎的残渣
}
add(t)
}
for _, run := range asciiRunRe.FindAllString(text, -1) {
lower := strings.ToLower(run)
// 整词:长度 >= 3 才收(`go`/`io` 这类太泛,收进来只增噪声)。
if len(lower) >= 3 {
add(lower)
}
// 部件:只有真的带分隔符才拆;`PruneOldBackups` 这种无分隔符的整词
// 已经由上一行覆盖,拆 camelCase 反而有害(见函数注释)。
parts := identSepRe.Split(lower, -1)
if len(parts) < 2 {
continue
}
for _, p := range parts {
if len(p) >= 3 {
add(p)
}
}
}
return out
}
// isSingleASCIILetter 报告 token 是不是「单个 ASCII 字母」。
func isSingleASCIILetter(t string) bool {
if len(t) != 1 {
return false
}
c := t[0]
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
}

View File

@ -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
}