mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 17:38:10 +00:00
核心暴露 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.
177 lines
5.3 KiB
Go
177 lines
5.3 KiB
Go
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))
|
||
}
|
||
// 第一个应该是 img2(0.9 vs d1 的 1.0?不,这里算清楚)
|
||
// vec1·vec2 与 vec1·vec1 比较:
|
||
// sim(vec1,vec1) = 1.0(img1 与自身),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 匹配模型 → 非 stale;d2 旧模型 + d3 未嵌入 + d4 无描述图片 = 3 stale;aud1 不算
|
||
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")
|
||
}
|
||
}
|