Files
backend_v2/internal/repository/embedding_integration_test.go
toom1996 1de1ec46a6 update
2026-10-18 17:42:10 +08:00

181 lines
6.8 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.

//go:build integration
// 集成测试:在真实 PostgreSQL + pgvector 上验证 image_embeddings 的读写与余弦近邻检索。
//
// 单测(embedding_repository_test.go)只覆盖向量串的格式化/解析等纯逻辑;
// 本测试覆盖真正跑 SQL 的部分——`?::vector` 转换、`<=>` 余弦算子、
// 以及与图片表 join 过滤软删行的行为。这些在 fake 上无法验证。
//
// 运行:go test -tags integration ./internal/repository/ -run TestEmbeddingRepo -v
package repository
import (
"context"
"testing"
"fashionapi/internal/model"
)
// TestEmbeddingRepoUpsertListAndSimilar 覆盖写入 → 批量读回 → 余弦近邻检索这一整条链路。
func TestEmbeddingRepoUpsertListAndSimilar(t *testing.T) {
db := testDB(t)
ctx := context.Background()
repo := NewEmbeddingRepository(db)
// 1) 造两条走秀图片(近邻检索要 join 图片表过滤软删,因此图片必须真实存在且 is_deleted=0)。
now := uint32(1)
runway := model.BrandRunway{BrandID: 1, TitleEn: "embedding-it", Status: model.StatusPending, CreatedAt: now, UpdatedAt: now}
if err := db.Create(&runway).Error; err != nil {
t.Fatalf("建走秀失败: %v", err)
}
imgs := []model.BrandRunwayImage{
{RunwayID: runway.ID, BrandID: 1, Image: "it/emb-a.jpg", Name: "a", SortOrder: 1, CreatedAt: now, UpdatedAt: now},
{RunwayID: runway.ID, BrandID: 1, Image: "it/emb-b.jpg", Name: "b", SortOrder: 2, CreatedAt: now, UpdatedAt: now},
}
if err := db.Create(&imgs).Error; err != nil {
t.Fatalf("建图片失败: %v", err)
}
t.Cleanup(func() {
db.Exec("DELETE FROM image_embeddings WHERE kind = 'runway' AND image_id IN (?, ?)", imgs[0].ID, imgs[1].ID)
db.Delete(&model.BrandRunwayImage{}, imgs[0].ID)
db.Delete(&model.BrandRunwayImage{}, imgs[1].ID)
db.Delete(&model.BrandRunway{}, runway.ID)
})
// 2) 两个几乎相同的 768 维向量(第二个与第一个只差一个分量),相似度应远高于阈值。
va := make([]float32, 768)
for i := range va {
va[i] = 0.1
}
va[0] = 0.5
vb := make([]float32, 768)
copy(vb, va)
vb[0] = 0.499
if err := repo.UpsertEmbedding(ctx, "runway", imgs[0].ID, va); err != nil {
t.Fatalf("写向量 A 失败: %v", err)
}
if err := repo.UpsertEmbedding(ctx, "runway", imgs[1].ID, vb); err != nil {
t.Fatalf("写向量 B 失败: %v", err)
}
// 3) 批量读回:维度必须保持 768,且值能精确往返。
got, err := repo.ListEmbeddings(ctx, "runway", []uint32{imgs[0].ID, imgs[1].ID})
if err != nil {
t.Fatalf("ListEmbeddings 失败: %v", err)
}
if len(got) != 2 {
t.Fatalf("应读回 2 个向量,得到 %d", len(got))
}
if len(got[imgs[0].ID]) != 768 {
t.Fatalf("向量维度应为 768,得到 %d", len(got[imgs[0].ID]))
}
if got[imgs[0].ID][0] != 0.5 {
t.Fatalf("向量分量往返后不一致:%v", got[imgs[0].ID][0])
}
// 4) 近邻检索:不排除任何图时,A 应找到同图集的 B(高相似度),且结果里不含自身。
neighbors, err := repo.FindSimilarImages(ctx, "runway", imgs[0].ID, nil, got[imgs[0].ID], 0.9, 5)
if err != nil {
t.Fatalf("FindSimilarImages 失败: %v", err)
}
var found *SimilarImage
for i := range neighbors {
if neighbors[i].ImageID == imgs[0].ID {
t.Fatalf("近邻结果不应包含自身 imageID=%d", imgs[0].ID)
}
if neighbors[i].ImageID == imgs[1].ID {
found = &neighbors[i]
}
}
if found == nil {
t.Fatalf("应找到近邻 B(imageID=%d),实际结果 %+v", imgs[1].ID, neighbors)
}
if found.Similar < 0.99 {
t.Fatalf("两向量几乎相同,相似度应接近 1,实际 %f", found.Similar)
}
if found.OwnerID != runway.ID {
t.Fatalf("近邻所属记录应为 %d,实际 %d", runway.ID, found.OwnerID)
}
// 近邻的描述字段必须带回,后台靠它们显示「和哪张图像」。
if found.Image != "it/emb-b.jpg" || found.Name != "b" || found.OwnerTitle != "embedding-it" {
t.Fatalf("近邻描述字段不完整: %+v", found)
}
// 5) 传入同图集排除集后,B 必须被排除——需求是只看「本图集 vs 其他图集」,
// 同图集内所有照片共享背景/灯光/造型,相似度天然偏高,参与比对只会是噪音。
excluded, err := repo.FindSimilarImages(ctx, "runway", imgs[0].ID,
[]uint32{imgs[0].ID, imgs[1].ID}, got[imgs[0].ID], 0.9, 5)
if err != nil {
t.Fatalf("排除同图集时 FindSimilarImages 失败: %v", err)
}
for _, n := range excluded {
if n.ImageID == imgs[1].ID {
t.Fatalf("同图集的图应被排除,但仍返回了 imageID=%d", imgs[1].ID)
}
}
// 6) 重复写入同一 (kind, image_id) 应走 UPSERT 更新而非插入新行。
if err := repo.UpsertEmbedding(ctx, "runway", imgs[0].ID, vb); err != nil {
t.Fatalf("重复写向量失败: %v", err)
}
again, err := repo.ListEmbeddings(ctx, "runway", []uint32{imgs[0].ID})
if err != nil {
t.Fatalf("重复写后 ListEmbeddings 失败: %v", err)
}
if len(again) != 1 {
t.Fatalf("同一 (kind,image_id) 应只有一行,得到 %d", len(again))
}
if again[imgs[0].ID][0] != 0.499 {
t.Fatalf("UPSERT 应更新为新值,实际 %v", again[imgs[0].ID][0])
}
}
// TestEmbeddingRepoSkipsSoftDeletedImages 软删的图片不得作为「疑似同款」参照
// (image_embeddings 无 is_deleted 列,靠 join 图片表过滤)。
func TestEmbeddingRepoSkipsSoftDeletedImages(t *testing.T) {
db := testDB(t)
ctx := context.Background()
repo := NewEmbeddingRepository(db)
now := uint32(1)
runway := model.BrandRunway{BrandID: 1, TitleEn: "embedding-it-softdel", Status: model.StatusPending, CreatedAt: now, UpdatedAt: now}
if err := db.Create(&runway).Error; err != nil {
t.Fatalf("建走秀失败: %v", err)
}
imgs := []model.BrandRunwayImage{
{RunwayID: runway.ID, BrandID: 1, Image: "it/sd-a.jpg", Name: "a", SortOrder: 1, CreatedAt: now, UpdatedAt: now},
{RunwayID: runway.ID, BrandID: 1, Image: "it/sd-b.jpg", Name: "b", SortOrder: 2, IsDeleted: 1, CreatedAt: now, UpdatedAt: now},
}
if err := db.Create(&imgs).Error; err != nil {
t.Fatalf("建图片失败: %v", err)
}
t.Cleanup(func() {
db.Exec("DELETE FROM image_embeddings WHERE kind = 'runway' AND image_id IN (?, ?)", imgs[0].ID, imgs[1].ID)
db.Delete(&model.BrandRunwayImage{}, imgs[0].ID)
db.Delete(&model.BrandRunwayImage{}, imgs[1].ID)
db.Delete(&model.BrandRunway{}, runway.ID)
})
vec := make([]float32, 768)
for i := range vec {
vec[i] = 0.1
}
if err := repo.UpsertEmbedding(ctx, "runway", imgs[0].ID, vec); err != nil {
t.Fatalf("写向量 A 失败: %v", err)
}
if err := repo.UpsertEmbedding(ctx, "runway", imgs[1].ID, vec); err != nil {
t.Fatalf("写向量 B(软删图)失败: %v", err)
}
neighbors, err := repo.FindSimilarImages(ctx, "runway", imgs[0].ID, nil, vec, 0.0, 50)
if err != nil {
t.Fatalf("FindSimilarImages 失败: %v", err)
}
for _, n := range neighbors {
if n.ImageID == imgs[1].ID {
t.Fatalf("已软删的图片(imageID=%d)不应作为同款参照", imgs[1].ID)
}
}
}