Files
HomeAgent/internal/memory/media/media_vec_test.go
JianFeeeee 6c2039f5c9 feat(vector): pluggable multimodal vector space
核心暴露 MultimodalEmbedder 接口,两条路径共享同一套 L0/L2/L3
向量缓存、media.Store 坐标、QueryMemoryMediaScored 检索:
  - onnx:内嵌 ONNX 模型(CLIP 等),通过 build tag 编译
  - http:外部向量 API 服务(Jina v5 / OpenAI / 自建)

跨模态融合权重改为 CrossModalFusionConfig 可配置结构体,
移除所有模型特定硬编码(CLIP/Jina),版本切换只需改配置。

模型切换自动迁移:
  - StaleVecDigestsAll 支持全模态(image+audio+video)
  - 启动时并发重算(ONNX 4 workers / API 8 workers)
  - 修复 SQL 运算符优先级导致 kind 过滤失效的 bug

实测对比(492 篇生产文档 + 3 张真实图片):
  - TF-IDF:MRR 0.457(精确匹配快,语义差)
  - fastText:MRR 0.530(语义中等,延迟 8ms)
  - Jina v5-omni:MRR 0.900(全面领先,延迟 40ms)
  - 中文文本→图片:Jina MRR 0.833 vs CLIP 0.611

See docs/embedding-comparison.md for full benchmark.
2026-09-09 17:38:34 +08:00

177 lines
5.3 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 media
import (
"math"
"testing"
)
func TestQueryMedia_BasicSimilarity(t *testing.T) {
s := newTestStore(t, 0)
defer s.Close()
// 入库三张带向量的媒体:两张图、一段音频
d1, _ := s.Put([]byte("img1"), Item{MIME: "image/png", Description: "紫蓝红三色带"})
d2, _ := s.Put([]byte("img2"), Item{MIME: "image/jpeg", Description: "蓝紫红渐变"})
d3, _ := s.Put([]byte("aud1"), Item{MIME: "audio/wav", Description: "一段语音"})
// 模拟视觉嵌入img1 和 img2 向量接近aud1 远离
vec1 := []float64{0.9, 0.1, 0.0, 0.0}
vec2 := []float64{0.8, 0.2, 0.0, 0.0} // 与 vec1 相似
vec3 := []float64{0.0, 0.0, 0.9, 0.1} // 与前两个完全不同
s.SetVec(d1, vec1, "test-clip")
s.SetVec(d2, vec2, "test-clip")
s.SetVec(d3, vec3, "test-clip")
// 用 vec1 作为查询vec2 最相似vec3 与 vec1 正交(相似度 0被阈值过滤
results, err := s.QueryMedia(vec1, "test-clip", 10)
if err != nil {
t.Fatal(err)
}
// vec3 与 vec1 正交(余弦相似度 0被 0.05 阈值正确剔除 → 只召回 2 个
if len(results) != 2 {
t.Fatalf("expected 2 results (正交的 aud1 被阈值过滤), got %d", len(results))
}
// 第一个应该是 img20.9 vs d1 的 1.0?不,这里算清楚)
// vec1·vec2 与 vec1·vec1 比较:
// sim(vec1,vec1) = 1.0img1 与自身sim(vec1,vec2) = 0.9*0.8+0.1*0.2 = 0.74
// 所以 img1自相似 1.0排第一img2 排第二
if results[0].Digest != d1 {
t.Errorf("expected d1 (自相似 1.0) as first, got %s", results[0].Digest)
}
if results[1].Digest != d2 {
t.Errorf("expected d2 as second, got %s", results[1].Digest)
}
// 验证分数img1 与自身是 1.0
selfScore := cosineSimilaritySlice(vec1, vec1)
if math.Abs(selfScore-1.0) > 1e-10 {
t.Errorf("self-similarity should be 1.0, got %f", selfScore)
}
// img1 与 aud1 的相似度应该很低
crossScore := cosineSimilaritySlice(vec1, vec3)
if crossScore > 0.1 {
t.Errorf("cross-modality similarity should be low, got %f", crossScore)
}
}
func TestQueryMedia_EmptyVecSkipped(t *testing.T) {
s := newTestStore(t, 0)
defer s.Close()
d1, _ := s.Put([]byte("img1"), Item{MIME: "image/png"})
_, _ = s.Put([]byte("img2"), Item{MIME: "image/png"})
// d1 有向量d2 没有
s.SetVec(d1, []float64{0.5, 0.5}, "test")
// d2 留空
results, err := s.QueryMedia([]float64{0.5, 0.5}, "test", 10)
if err != nil {
t.Fatal(err)
}
if len(results) != 1 {
t.Fatalf("expected 1 result (d2 has no vec), got %d", len(results))
}
if results[0].Digest != d1 {
t.Errorf("expected d1, got %s", results[0].Digest)
}
}
func TestQueryMedia_DimensionMismatchSkipped(t *testing.T) {
s := newTestStore(t, 0)
defer s.Close()
d1, _ := s.Put([]byte("img1"), Item{MIME: "image/png"})
s.SetVec(d1, []float64{0.5, 0.5}, "model-A") // 2 维
// 查询用 3 维向量:维度不匹配,应该返回空
results, err := s.QueryMedia([]float64{0.3, 0.3, 0.3}, "model-A", 10)
if err != nil {
t.Fatal(err)
}
if len(results) != 0 {
t.Fatalf("expected 0 results (dim mismatch), got %d", len(results))
}
}
func TestQueryMedia_EmptyQueryReturnsNil(t *testing.T) {
s := newTestStore(t, 0)
defer s.Close()
results, err := s.QueryMedia(nil, "", 10)
if err != nil {
t.Fatal(err)
}
if results != nil {
t.Fatalf("expected nil, got %d results", len(results))
}
}
func TestSetVec_PersistsCorrectly(t *testing.T) {
s := newTestStore(t, 0)
defer s.Close()
d, _ := s.Put([]byte("hello"), Item{MIME: "image/png"})
vec := []float64{0.1, 0.2, 0.3, 0.4}
s.SetVec(d, vec, "clip-vit-b32")
it, err := s.Stat(d)
if err != nil {
t.Fatal(err)
}
if it.VecModel != "clip-vit-b32" {
t.Errorf("VecModel = %q, want clip-vit-b32", it.VecModel)
}
if len(it.Vec) != 4 {
t.Fatalf("Vec len = %d, want 4", len(it.Vec))
}
for i, v := range vec {
if math.Abs(it.Vec[i]-v) > 1e-10 {
t.Errorf("Vec[%d] = %f, want %f", i, it.Vec[i], v)
}
}
}
func TestStaleVecDigests(t *testing.T) {
s := newTestStore(t, 0)
defer s.Close()
// 有描述且 vec_model 匹配 → 非 stale
d1, _ := s.Put([]byte("img1"), Item{MIME: "image/png", Description: "图一"})
s.SetVec(d1, []float64{0.1}, "clip-vit-b32")
// 有描述但 vec_model 旧 → stale
d2, _ := s.Put([]byte("img2"), Item{MIME: "image/png", Description: "图二"})
s.SetVec(d2, []float64{0.2}, "clip-vit-b14")
// 有描述但从未嵌入vec_model 空)→ stale
d3, _ := s.Put([]byte("img3"), Item{MIME: "image/png", Description: "图三"})
// 无描述但有图片 → 也应被迁移(描述是可选语义通道,图片应独立于描述参与向量空间)
d4, _ := s.Put([]byte("img4"), Item{MIME: "image/png"})
// 音频不参与图片迁移StaleVecDigests 只查 kind='image'
s.Put([]byte("aud1"), Item{MIME: "audio/wav", Description: "语音"})
stale, err := s.StaleVecDigests("clip-vit-b32")
if err != nil {
t.Fatal(err)
}
// d1 匹配模型 → 非 staled2 旧模型 + d3 未嵌入 + d4 无描述图片 = 3 staleaud1 不算
if len(stale) != 3 {
t.Fatalf("expected 3 stale digests (d2 旧模型 + d3 未嵌入 + d4 无描述), got %d: %v", len(stale), stale)
}
got := map[string]bool{}
for _, d := range stale {
got[d] = true
}
if !got[d2] || !got[d3] || !got[d4] {
t.Errorf("expected d2, d3, d4 stale, got %v", stale)
}
if got[d1] {
t.Errorf("d1 (匹配模型) 不应 stale")
}
}