mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 14:57:38 +00:00
merge: 知识库标识符检索修复
This commit is contained in:
@ -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{
|
||||
|
||||
87
internal/knowledge/fusion.go
Normal file
87
internal/knowledge/fusion.go
Normal 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
|
||||
}
|
||||
101
internal/knowledge/identifier_recall_test.go
Normal file
101
internal/knowledge/identifier_recall_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
96
internal/knowledge/identtoken.go
Normal file
96
internal/knowledge/identtoken.go
Normal 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')
|
||||
}
|
||||
@ -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