mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
feat(knowledge)!: 检索改为纯稠密向量路,去掉三路融合
按「完全改用向量方案」的要求,SearchIn 现在只走多模态稠密路,
不再与稀疏语义路 / 词法路融合。
## 为什么必须去掉融合(实测,不是判断)
生产知识库(192 条)上,10 条带标注查询的命中率:
改前(三路融合) top1= 1/10 top3= 2/10
改后(纯稠密路) top1= 6/10 top3= 7/10
改前连条目**自己的全名**都搜不到:
`plugin_dev_sdk` 搜不到 `plugin_dev_sdk`、`homeagent_identity` 搜不到
`homeagent_identity`,用户看到的是「查什么都返回同样的几个无关条目」。
而稠密路单独打分时**全库排名 1/192**:
the_jet_engine → the_jet_engine_rolls-royce 0.769
plugin_dev_sdk → plugin_dev_sdk 0.852
homeagent_identity → homeagent_identity 0.823
### 根因:逐路归一化把噪声抬到与信号同权
`addPath` 把每路都按**查询内最大值**归一化到 1.0 再加权:
scores[h.id] += weight * h.score / max
于是词法路原始分只有 **0.027** 的弱信号归一化后也变成 1.0,与稠密路的
1.0 等权相加。叠加两个因素:
- jieba 把英文标识符切碎:`the_jet_engine_rolls-royce` →
`[t h e _ j n g i r o l s - y c]`,两条**无关**文档 token 重叠 50%
- `SetMinScore(0)` 导致 186/192 条都进候选,几乎全库打分
余弦分数**本身就可比**(同空间同维度),根本不需要逐路归一化 ——
那一步当初是为「量级不同的三路」服务的,现在只剩一路,它反而成了
破坏排序的东西。
## 历史结论为何在此翻转
旧注释引的实测(仅稠密路 top-1 15% / MRR 0.271;融合后 21% / 0.376)
建立在这两个**已不成立的前提**上:
- 33 条的小规模 KB(现 192 条,含大量英文标识符)
- 稀疏 fastText 词向量(现 chineseclip 512 维多模态空间)
那段结论我没删,而是在 Search 的注释里写明「前提已变、结论翻转」,
免得后来者看到旧数字又把它改回去。
## 退化路径有意保留
未注入多模态空间时(provider 留空)仍走稀疏两路。不保留的话,
那些部署的知识库会直接变成**完全不可用** —— 比排序差更严重。
该路径行为与接入稠密路之前逐字一致(TestNoDenseSpaceKeepsLegacyBehavior)。
## 判据(都可杀变异,逐个实测)
新增 `dense_recall_quality_test.go`:
- `TestDenseRecallQuality`——带标注 10 条查询,门槛 top1≥4 且 top3≥7。
把 denseEnabled 改成恒假 → FAIL「top1 仅 1/10」(正是退化态)
- `TestDensePathIsSoleRecall`——钉形状:稠密路必须在稀疏路之前且
**结果真的参与组装**。第一版只查「有没有调 denseHits」,把结果赋给 `_`
仍能骗过它;补上 `s.items[h.id]` 断言后,变异(结果不参与组装)才被杀
- `TestSearchDocReflectsDenseOnly`——防文档漂移。第一版简单搜「三路」
会误伤(新文档解释「为何不再融合」时正当地提到它),改为检查只在
*声称当前如此* 时才会出现的句子
## 顺带修的
- `TestDenseCrossModalRecall` 的桩把**所有未知文本**映射到同一向量,
导致 `other` 与 `cat-photo` 正文同向、「猫」查询下 `other` 以余弦 1.0
压过 `cat-photo` 的 0.707。那是**桩的假象不是产品缺陷**。给 other
一个真正正交的方向(且 key 必须是 denseFor 实际传入的
`Name + " " + Content`,写成纯 Content 会静默回落到默认向量)。
- 清掉随融合退场的死代码 `denseSpaceWeight` / `hasAnyDense`,
并把 `vec`/`lex` 字段注释改准(它们现在只服务退化路径)。
- `rankdiag_test.go` 现在测的是**退化路径**(它不注入多模态空间),
注释与门槛说明据此改准:实测 top-1 24% / MRR 0.358,
别拿它当知识库的检索水平。
全仓 `go test ./...` → 48 包 ok / 0 FAIL。
This commit is contained in:
250
internal/knowledge/dense_recall_quality_test.go
Normal file
250
internal/knowledge/dense_recall_quality_test.go
Normal file
@ -0,0 +1,250 @@
|
||||
package knowledge
|
||||
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/JianFeeeee/HomeAgent/internal/memory/vector"
|
||||
"github.com/JianFeeeee/HomeAgent/pkg/embedding"
|
||||
_ "github.com/JianFeeeee/HomeAgent/providers/chineseclip"
|
||||
)
|
||||
|
||||
// 知识库检索质量判据(带标注的查询集)。
|
||||
//
|
||||
// # 为什么要有它
|
||||
//
|
||||
// 2026-10-08 之前的检索在真实知识库上**几乎完全失效**:10 条带标注查询里
|
||||
// top-1 只命中 1 条、top-3 命中 2 条。症状是「查什么都返回同样的 cangjie
|
||||
// 无关条目」,连条目自己的全名都搜不到。
|
||||
//
|
||||
// 根因不是模型不行,而是**融合方式**:
|
||||
//
|
||||
// 稠密路(chineseclip 512 维)单独打分时表现极好——
|
||||
// the_jet_engine / plugin_dev_sdk / homeagent_identity
|
||||
// 三条查询的全库排名都是 **1/192**,分数 0.77~0.85。
|
||||
//
|
||||
// 但三路融合时每路都按「查询内最大值」归一化到 1.0 再加权,于是
|
||||
// 词法路原始分数只有 0.02x 的弱信号被抬到 1.0,与稠密路的 1.0 等权相加。
|
||||
// 叠加 jieba 把英文标识符切碎(the_jet_engine → [t h e _ j ...]),
|
||||
// 结果就是噪声压倒信号。
|
||||
//
|
||||
// # 判据设计
|
||||
//
|
||||
// ① 用**可接受集合**而非唯一答案:「更新日志」「插件开发」「鸿蒙」天然多解,
|
||||
//
|
||||
// 只标注唯一答案会过拟合到某次排序。
|
||||
//
|
||||
// ② 阈值取 top-1 ≥ 4/10 且 top-3 ≥ 7/10:能挡住「退回噪声」的退化
|
||||
//
|
||||
// (实测退化态是 1/10 与 2/10),又不至于要求每次都满分。
|
||||
//
|
||||
// ③ 需要真实向量空间才能跑(chineseclip),故默认跳过:
|
||||
//
|
||||
// KB_DENSE_RECALL=1 go test ./internal/knowledge/ -run TestDenseRecallQuality
|
||||
//
|
||||
// ④ 数据根可用 KB_RECALL_ROOT 覆盖,默认 /home/newqqagent/knowledge。
|
||||
var denseRecallCases = []struct {
|
||||
q string
|
||||
want []string
|
||||
}{
|
||||
{"the_jet_engine", []string{"the_jet_engine_rolls-royce"}},
|
||||
{"plugin_dev_sdk", []string{"plugin_dev_sdk"}},
|
||||
{"plugin_dev_patterns", []string{"plugin_dev_patterns"}},
|
||||
{"homeagent_identity", []string{"homeagent_identity"}},
|
||||
{"henan_medical_university", []string{"henan_medical_university", "henan_medical_university_academic_calendar_2026_2027"}},
|
||||
{"changelog_v1.3.0", []string{"changelog_v1.3.0", "changelog_v1.3.x"}},
|
||||
{"仓颉关键字", []string{"cangjie/dev-guide/appendix/keyword", "cangjie_总索引"}},
|
||||
{"插件开发", []string{"plugin_dev_patterns", "plugin_dev_sdk", "plugin_dev_build", "plugin_dev_config"}},
|
||||
{"更新日志", []string{"changelog_v1.3.0", "changelog_v1.3.x", "changelog_v1.2.2", "changelog_v1.2.0"}},
|
||||
{"鸿蒙", []string{"openharmony-docs-local", "openharmony-independent-docs", "openharmony-app-dev-guide"}},
|
||||
}
|
||||
|
||||
func TestDenseRecallQuality(t *testing.T) {
|
||||
if os.Getenv("KB_DENSE_RECALL") == "" {
|
||||
t.Skip("需要 KB_DENSE_RECALL=1(真实向量空间 + 真实知识库)")
|
||||
}
|
||||
root := os.Getenv("KB_RECALL_ROOT")
|
||||
if root == "" {
|
||||
root = "/home/newqqagent/knowledge"
|
||||
}
|
||||
if _, err := os.Stat(root); err != nil {
|
||||
t.Skipf("知识库目录不可用: %v", err)
|
||||
}
|
||||
|
||||
space := openDenseSpaceForRecallTest(t)
|
||||
s := NewStore(root)
|
||||
if err := s.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Stop()
|
||||
s.SetDenseSpace(space)
|
||||
|
||||
hit1, hit3 := 0, 0
|
||||
var misses []string
|
||||
for _, c := range denseRecallCases {
|
||||
acc := func(n string) bool {
|
||||
for _, w := range c.want {
|
||||
if n == w {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
res := s.Search(c.q, 3)
|
||||
ok1, ok3 := false, false
|
||||
for i, r := range res {
|
||||
if acc(r.Name) {
|
||||
ok3 = true
|
||||
if i == 0 {
|
||||
ok1 = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if ok1 {
|
||||
hit1++
|
||||
}
|
||||
if ok3 {
|
||||
hit3++
|
||||
} else {
|
||||
top := "(空)"
|
||||
if len(res) > 0 {
|
||||
top = res[0].Name
|
||||
}
|
||||
misses = append(misses, c.q+" → "+top)
|
||||
}
|
||||
}
|
||||
for _, m := range misses {
|
||||
t.Logf(" 未命中 %s", m)
|
||||
}
|
||||
t.Logf("稠密路召回: top1=%d/%d top3=%d/%d", hit1, len(denseRecallCases), hit3, len(denseRecallCases))
|
||||
|
||||
if hit1 < 4 {
|
||||
t.Errorf("top-1 仅 %d/%d(门槛 4)。\n"+
|
||||
" 稠密路是唯一召回路径,它退化即检索失效。\n"+
|
||||
" 参考:融合退化态是 top1=1/10 top3=2/10。", hit1, len(denseRecallCases))
|
||||
}
|
||||
if hit3 < 7 {
|
||||
t.Errorf("top-3 仅 %d/%d(门槛 7)。\n"+
|
||||
" 稠密路几乎总能命中正确条目(实测全库排名 1/192),\n"+
|
||||
" 掉到 7/10 以下说明向量维度/指纹守卫出了问题。", hit3, len(denseRecallCases))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDensePathIsSoleRecall 钉住「稠密路是唯一召回路径」。
|
||||
//
|
||||
// 为何要钉形状而不只钉效果:效果判据需要真实向量空间(CI 跑不了),
|
||||
// 而这条不需要——它守住的是**结构**,回归时立刻红。
|
||||
//
|
||||
// 退化路径(未注入多模态空间时用稀疏两路)是**有意保留**的:
|
||||
// 留空 core.memory.multimodal_space.provider 的部署仍需可检索,
|
||||
// 否则直接变成「知识库完全不能用」。
|
||||
func TestDensePathIsSoleRecall(t *testing.T) {
|
||||
src := readSourceFile(t, "knowledge.go")
|
||||
start := strings.Index(src, "func (s *Store) SearchIn(")
|
||||
if start < 0 {
|
||||
t.Fatal("找不到 SearchIn")
|
||||
}
|
||||
end := strings.Index(src[start:], "\n}\n")
|
||||
if end < 0 {
|
||||
t.Fatal("SearchIn 提取失败")
|
||||
}
|
||||
body := src[start : start+end]
|
||||
|
||||
// 稠密路分支必须存在且在稀疏两路之前(提前返回)。
|
||||
denseAt := strings.Index(body, "denseHits(qv)")
|
||||
if denseAt < 0 {
|
||||
t.Fatal("SearchIn 必须调用 denseHits(稠密路是唯一召回路径)")
|
||||
}
|
||||
|
||||
// 稠密分支必须真的**以它的结果返回**,不能只是调用完就丢掉。
|
||||
// 光看 source 里有没有 denseHits 不够:把结果赋给 _ 再走融合
|
||||
// 也能骗过纯文本断言(变异测试实测过)。
|
||||
if !regexp.MustCompile(`hits\s*:=\s*s\.denseHits\(qv\)`).MatchString(body) {
|
||||
t.Error("稠密路的结果必须被接住(hits := s.denseHits(qv))并用于排序返回;" +
|
||||
"只调用不使用等于没接——排序仍会落到稀疏融合上")
|
||||
}
|
||||
if !regexp.MustCompile(`if k, ok := s\.items\[h\.id\]`).MatchString(body) {
|
||||
t.Error("稠密路结果必须参与条目组装(s.items[h.id]),不能是摆设")
|
||||
}
|
||||
|
||||
sparseAt := strings.Index(body, "s.vec.SearchScored")
|
||||
if sparseAt < 0 {
|
||||
return // 已彻底移除稀疏路调用(比 fallback 更彻底),通过
|
||||
}
|
||||
if denseAt > sparseAt {
|
||||
t.Error("稠密路必须在稀疏路之前并提前返回:\n" +
|
||||
" 稀疏路的逐路归一化会把 0.02x 的弱信号抬到 1.0,\n" +
|
||||
" 与稠密路的 1.0 等权相加 → 噪声压倒信号(实测 top1 掉到 1/10)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchDocReflectsDenseOnly 防止文档与实现漂移。
|
||||
//
|
||||
// 只钉「文档不能声称三路融合是当前行为」。不能简单搜「三路」——
|
||||
// 新文档在解释「为何不再做三路融合」时会正当地提到它。
|
||||
// 故改为检查那些**只在声称当前这么做时**才会出现的句子。
|
||||
func TestSearchDocReflectsDenseOnly(t *testing.T) {
|
||||
src := readSourceFile(t, "knowledge.go")
|
||||
i := strings.Index(src, "// Search ")
|
||||
if i < 0 {
|
||||
t.Fatal("找不到 Search 的文档注释")
|
||||
}
|
||||
doc := src[i:min(len(src), i+2500)]
|
||||
|
||||
// ① 必须声明稠密路是唯一召回路径。
|
||||
if !strings.Contains(doc, "唯一召回路径") {
|
||||
t.Error("Search 文档必须明写「稠密向量路是唯一召回路径」——" +
|
||||
"这是接口语义,调用方靠它理解为何换了模型就要重建向量")
|
||||
}
|
||||
// ② 不得声称仍在融合(旧文档的原句)。
|
||||
for _, banned := range []string{
|
||||
"三路各自**按查询内最大值归一化**后加权融合,排序才可信",
|
||||
"为何不能只用稠密路",
|
||||
"词法路对专名/术语/\n// 短查询强",
|
||||
} {
|
||||
if strings.Contains(doc, banned) {
|
||||
t.Errorf("Search 文档仍在声称三路融合是当前行为(已与实现不符):\n 命中禁用句: %q", banned)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func firstLines(s string, n int) string {
|
||||
out := ""
|
||||
for i := 0; i < n; i++ {
|
||||
j := strings.Index(s, "\n")
|
||||
if j < 0 {
|
||||
return s
|
||||
}
|
||||
out += s[:j+1]
|
||||
s = s[j+1:]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func readSourceFile(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// openDenseSpaceForRecallTest 打开生产同款多模态空间(chineseclip)。
|
||||
//
|
||||
// 用真 provider 而不是桩:本判据要量的是「真实向量空间下的召回质量」,
|
||||
// 桩给出的向量不具语义,测出来的命中率没有意义。
|
||||
func openDenseSpaceForRecallTest(t *testing.T) vector.MultimodalEmbedder {
|
||||
t.Helper()
|
||||
p, err := embedding.Open("chineseclip", embedding.Config{})
|
||||
if err != nil {
|
||||
t.Skipf("chineseclip provider 不可用(需 onnxruntime 构建标签): %v", err)
|
||||
}
|
||||
ds, err := vector.AdaptProvider(p)
|
||||
if err != nil {
|
||||
t.Fatalf("AdaptProvider: %v", err)
|
||||
}
|
||||
return ds
|
||||
}
|
||||
Reference in New Issue
Block a user