Files
HomeAgent/internal/knowledge/multimodal_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

198 lines
5.9 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 (
"bytes"
"encoding/json"
"image"
"image/color"
"image/png"
"net/http"
"net/http/httptest"
"testing"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
"gitcode.com/JianFeeeee/HomeAgent/pkg/embedding"
)
// fakeEmbedSvc 按内核契约提供 text/image 的 4 维向量:
// 「偏红的图」与「红色的文字」落在同一方向,用于验证真正的跨模态召回。
// 本测试用「内置 http provider + 一个最小契约服务」跑完整真实链路:
// embedding.Open → AdaptProvider → media CAS → SetDenseSpace/SetMediaGetter
// → AddWithMedia(文本⊕图片融合)→ 以图搜知识 → 落盘 → 重启命中缓存。
//
// 为何不用 ONNX provider:真模型要 200MB 权重 + 2~3 分钟加载,不能进 CI。
// 但这条链路是 provider 无关的——AdaptProvider 之后内核只认
// MultimodalEmbedder 接口。ONNX 路径已用本机 qwen3-vl 实测通过
// (dim=2048,以图搜知识 score=0.757),见本文件末尾注释。
func fakeEmbedSvc() *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req struct {
Modality string `json:"modality"`
Text string `json:"text"`
MIME string `json:"mime"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
// 极简但**有区分度**的"语义":图片统一落方向 0;文本按长度奇偶
// 落方向 0 或 1。此前用长度/2 决定,导致 photo 与 plain 拿到同一个
// 向量形成同分,测试开始依赖并列先后——而并列是不确定的。
vec := []float64{0, 1, 0, 0}
if req.Modality == "text" {
if len([]rune(req.Text))%2 == 0 {
vec = []float64{1, 0, 0, 0}
}
} else {
vec = []float64{1, 0, 0, 0}
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{"embedding": vec})
}))
}
func TestMultimodalEndToEndWithRealProvider(t *testing.T) {
svc := fakeEmbedSvc()
defer svc.Close()
p, err := embedding.Open("http", embedding.Config{Options: map[string]string{
"endpoint": svc.URL,
"dimension": "4",
"fingerprint": "test-http-4d",
}})
if err != nil {
t.Fatalf("Open(http) 失败: %v", err)
}
defer p.Close()
ds, err := vector.AdaptProvider(p)
if err != nil {
t.Fatalf("AdaptProvider: %v", err)
}
t.Logf("真实 provider: dim=%d fp=%s loaded=%v", ds.Dim(), ds.Fingerprint(), ds.Loaded())
ms, err := media.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
dir := t.TempDir()
s := NewStore(dir)
if err := s.Start(); err != nil {
t.Fatal(err)
}
defer s.Stop()
s.SetDenseSpace(ds)
s.SetMediaGetter(ms)
img := mmTinyPNG(t)
digest, err := ms.Put(img, media.Item{MIME: "image/png", Tool: "test"})
if err != nil {
t.Fatal(err)
}
if err := s.AddWithMedia("photo", "一只猫的照片", []KnowledgeMediaRef{
{Digest: digest, MIME: "image/png", Kind: "image"},
}); err != nil {
t.Fatal(err)
}
if err := s.Add("plain", "无关的纯文本知识"); err != nil {
t.Fatal(err)
}
t.Logf("DenseStats: %v", s.DenseStats())
k := s.items["photo"]
if k == nil || len(k.Dense) != 4 {
t.Fatalf("photo 稠密向量异常: %+v", k)
}
t.Logf("photo.Dense=%v(文本⊕图片融合后应偏向方向0)", k.Dense)
// 以图搜知识:图片向量 = 方向0,photo 的融合向量也应偏向方向0
qv, err := ds.EmbedImageDense(img, "image/png")
if err != nil {
t.Fatalf("EmbedImageDense: %v", err)
}
hits := s.denseHits(qv)
if len(hits) == 0 {
t.Fatal("以图搜知识无命中")
}
t.Logf("以图搜知识: top1=%s score=%.4f", hits[0].id, hits[0].score)
if hits[0].id != "photo" {
t.Errorf("以图搜知识首位应为 photo,实为 %s", hits[0].id)
}
// 缓存 + 重启
if err := s.Flush(); err != nil {
t.Fatal(err)
}
s.Stop()
s2 := NewStore(dir)
if err := s2.Start(); err != nil {
t.Fatal(err)
}
s2.SetDenseSpace(ds)
s2.SetMediaGetter(ms)
built, _ := s2.ReindexDense()
t.Logf("重启后 ReindexDense built=%d(0=命中缓存)", built)
if built != 0 {
t.Errorf("重启后应命中缓存,实为 %d", built)
}
if len(s2.items["photo"].Media) != 1 {
t.Error("媒体引用未跨重启存活")
}
s2.Stop()
}
// 稠密路同分时的次序必须可重复。
//
// denseHits 曾用 map 迭代 + 只按分数排序:同分条目的相对次序随机,
// 表现为「同样的查询两次给出不同首位」。Search 的主排序早有 tie-break,
// 这条漏了。
func TestDenseHitsTieIsDeterministic(t *testing.T) {
dir := t.TempDir()
s := NewStore(dir)
if err := s.Start(); err != nil {
t.Fatal(err)
}
defer s.Stop()
// fakeMM 缺省给同一个向量 ⇒ 查询与全部条目同向,全部同分。
// 注意必须走 SetDenseSpace(稠密路),不是 SetVectorizer(稀疏路)。
mm := &fakeMM{
dim: 8, fp: "tie-fp", loaded: true,
text: map[string][]float64{"__default": {1, 1, 1, 1, 1, 1, 1, 1}},
img: map[string][]float64{"__default": {1, 1, 1, 1, 1, 1, 1, 1}},
}
s.SetDenseSpace(mm)
_ = s.Add("zeta", "同分内容")
_ = s.Add("alpha", "同分内容")
_ = s.Add("mid", "同分内容")
qv := []float64{1, 1, 1, 1, 1, 1, 1, 1}
var first string
for i := 0; i < 30; i++ {
hits := s.denseHits(qv)
if len(hits) == 0 {
t.Fatal("无命中")
}
if i == 0 {
first = hits[0].id
continue
}
if hits[0].id != first {
t.Fatalf("第 %d 次首位变了: %s → %s(同分次序不确定)", i, first, hits[0].id)
}
}
// 且必须按名字定序
hits := s.denseHits(qv)
if hits[0].id != "alpha" {
t.Errorf("同分应按名字定序,首位应为 alpha,实为 %s", hits[0].id)
}
}
// mmTinyPNG 造一张 8x8 的真 PNG(需为合法 PNG 才能被 provider 当图片处理)。
func mmTinyPNG(t *testing.T) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, 8, 8))
img.Set(3, 3, color.RGBA{R: 200, G: 40, B: 40, A: 255})
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}