Files
HomeAgent/internal/knowledge/hierarchy_e2e_test.go
JianFeeeee ce694bc1a1 test(knowledge): 端到端验证多模态与分层链路 + 修稠密路同分次序不确定
一、修缺陷:denseHits 同分次序随机
  稠密路用 map 遍历 + 只按分数排序,**没有 tie-break**:同分条目的相对
  次序随每次调用变化 ⇒ 同样的查询两次可能给出不同首位(用户看到结果在跳,
  测试偶发变红)。Search 的主排序早就有「分数相同时按名字定序」,这条漏了。
  补上同分按 id 定序,并加 TestDenseHitsTieIsDeterministic 反向守住
  (撤掉 tie-break 后该测试在 5 次运行里稳定报出首位跳变)。

二、端到端验证(两个新文件)
- TestMultimodalEndToEndWithRealProvider:走**真实 embedding provider**
  (内置 http provider + 一个符合内核契约的最小服务),覆盖
  embedding.Open → AdaptProvider → media CAS → SetDenseSpace/SetMediaGetter
  → AddWithMedia(文本⊕图片融合)→ 以图搜知识 → .dense.json 落盘 →
  重启命中缓存(ReindexDense built=0)。
  不用 ONNX provider 是因为真模型 200MB 权重 + 3 分钟加载,进不了 CI;
  该链路是 provider 无关的(AdaptProvider 之后内核只认 MultimodalEmbedder)。

- TestHierarchicalIndexEndToEnd:多层分类(tech/go/两段、tech/rust/两段)
  的 Category 推导、树导出结构与挂载点、三级前缀过滤检索、范围外排除、
  索引落盘、重启后不漂移、树在重启后仍可用。
  反向验证:把 inScope 改成恒真后该测试稳定变红(报出范围外条目混入),
  确认它真能抓到「分层不参与召回」这一退化。

三、额外实测(本机,非 CI)
带 onnxruntime tag(生产构建形态)下用真实 Qwen3-VL 模型跑通全链路:
  provider dim=2048 modalities=[text image] fp=e43381246264...
  photo.Dense = 文本⊕图片融合结果
  以图搜知识 top1=photo score=0.757   ← 真正的跨模态召回
  重启后 ReindexDense built=0(命中缓存)
另确认默认构建(无 tag)下 qwen3vl 是 stub、Open 明确报错,不会静默降级成
"看似可用"。Makefile 的 HOMED_TAGS 默认即 onnxruntime,故发行版默认启用。
2026-09-26 14:20:19 +08:00

138 lines
4.0 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 (
"strings"
"testing"
)
// 分层索引端到端:多层分类 → Category 推导 → 树导出 → 分类过滤检索 →
// 索引落盘 → 重启后仍然自洽。
//
// 这是「分层索引是否真的工作」的直接证据。此前分层只是**存储布局**
// (Category 有值、树能导出),但对召回零影响 —— SearchTree/SearchCategories
// 是死代码,Search 全库平铺。现在 SearchIn 让分层参与召回。
func TestHierarchicalIndexEndToEnd(t *testing.T) {
dir := t.TempDir()
s := NewStore(dir)
if err := s.Start(); err != nil {
t.Fatal(err)
}
defer s.Stop()
for _, e := range []struct{ name, body string }{
{"tech/go/并发", "goroutine 调度 GMP 抢占 通道"},
{"tech/go/context", "context 取消 超时 传播"},
{"tech/rust/所有权", "borrow checker move 语义"},
{"life/sleep", "作息 褪黑素 深睡"},
{"cook/coffee", "手冲 烘焙 水温 粉水比"},
} {
if err := s.Add(e.name, e.body); err != nil {
t.Fatal(err)
}
}
// 1) 多层分类:Category 是**父路径**,不是最后一段
for _, want := range []struct{ name, cat string }{
{"tech/go/并发", "tech/go"},
{"tech/go/context", "tech/go"},
{"tech/rust/所有权", "tech/rust"},
{"life/sleep", "life"},
{"cook/coffee", "cook"},
} {
k := s.items[want.name]
if k == nil {
t.Errorf("条目 %s 未载入", want.name)
continue
}
if k.Category != want.cat {
t.Errorf("%s 的 Category 应为 %q,实为 %q", want.name, want.cat, k.Category)
}
}
// 2) 树导出:层级结构 + 挂载点 + 条目带向量/预览
ti := s.BuildTree()
tech := ti.Children["tech"]
if tech == nil || tech.Children["go"] == nil {
t.Fatal("树中缺少 tech/go 节点")
}
var inGo []string
for _, it := range tech.Children["go"].Items {
inGo = append(inGo, it.Name)
if len(it.Vector) == 0 {
t.Errorf("%s 在树里没有向量", it.Name)
}
if it.Preview == "" {
t.Errorf("%s 在树里没有预览", it.Name)
}
}
if len(inGo) != 2 {
t.Errorf("tech/go 下应挂 2 条,实为 %v", inGo)
}
// 顶层无分类条目挂在 root.Items
if len(ti.Items) != 0 {
t.Errorf("本用例所有条目都有分类,root.Items 应为空,实为 %d", len(ti.Items))
}
// 3) 分类过滤检索:前缀匹配整棵子树
q := "调度 取消 borrow 作息 手冲"
if r := s.SearchIn(q, "tech/go", 10); len(r) != 2 {
t.Errorf("限定 tech/go 应命中 2 条,实为 %v", namesOf(r))
}
if r := s.SearchIn(q, "tech", 10); len(r) != 3 {
t.Errorf("限定 tech 应命中 3 条,实为 %v", namesOf(r))
}
for _, r := range s.SearchIn(q, "tech", 10) {
if !strings.HasPrefix(r.Name, "tech/") {
t.Errorf("范围外条目 %s 混入", r.Name)
}
}
// 不存在的分类:空结果而非报错
if r := s.SearchIn(q, "no/such", 10); len(r) != 0 {
t.Errorf("不存在的分类应返回空,实为 %v", namesOf(r))
}
// 4) 索引落盘(含分类结构)
if err := s.Flush(); err != nil {
t.Fatal(err)
}
data, err := readFileString(dir + "/.index.json")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(data, "tech") || !strings.Contains(data, "go") {
t.Error("索引文件未包含分类结构")
}
// 5) 重启后分层仍自洽(不漂移)
s.Stop()
s2 := NewStore(dir)
if err := s2.Start(); err != nil {
t.Fatal(err)
}
defer s2.Stop()
if len(s2.List()) != 5 {
t.Errorf("重启后条目数应为 5,实为 %d", len(s2.List()))
}
for _, want := range []struct{ name, cat string }{
{"tech/go/并发", "tech/go"},
{"tech/rust/所有权", "tech/rust"},
{"life/sleep", "life"},
} {
k := s2.items[want.name]
if k == nil {
t.Errorf("重启后 %s 丢失", want.name)
continue
}
if k.Category != want.cat {
t.Errorf("重启后 %s 的 Category 漂移: %q → %q", want.name, want.cat, k.Category)
}
}
if r := s2.SearchIn(q, "tech/go", 10); len(r) != 2 {
t.Errorf("重启后分类过滤失效,实为 %v", namesOf(r))
}
// 树在重启后仍可用
if s2.BuildTree().Children["tech"] == nil {
t.Error("重启后树结构丢失")
}
}