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

513 lines
20 KiB
Go
Raw Permalink 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 repository
import (
"context"
"strconv"
"strings"
"time"
"gorm.io/gorm"
)
// SimilarImage 是一张「疑似同款」的近邻结果。
//
// 除 id 与分数外,还带回对方的图片 key、名称与所属图集标题:
// 后台必须能直接看出「和哪张图像」,只给一个分数等于让人在全库里翻找。
type SimilarImage struct {
ImageID uint32 // 近邻图片 id
OwnerID uint32 // 近邻所属走秀 / 街拍专辑 id(供前端跳转)
Similar float64 // 余弦相似度 0~1
Image string // 近邻图片存储 key(前端据此显示缩略图)
Name string // 近邻图片名(如 "Look 12")
OwnerTitle string // 近邻所属图集标题(跨图集命中时用来指明在哪个图集)
}
// SimilarPair 是 image_similar_pairs 的写入行:只带 id 与分数,不带描述。
//
// 描述靠读侧 join 图片表/图集表取得,这样图片改名、换图集后不会留下陈旧文案。
type SimilarPair struct {
SelfID uint32
OtherID uint32
Similar float64
}
// SimilarNeighbor 是读出来的近邻:在 SimilarImage 基础上补齐对方的描述字段。
type SimilarNeighbor struct {
OtherID uint32 // 近邻图片 id
OwnerID uint32 // 近邻所属图集 id(供前端跳转)
Similar float64 // 余弦相似度 0~1
Image string // 近邻图片存储 key(前端显示缩略图)
Name string // 近邻图片名(如 "Look 12")
OwnerTitle string // 近邻所属图集标题
}
// DupStat 是一个图集的「跨图集重复」概况,供文章列表直接标出哪些图集有重复。
//
// Total 由图片表实时统计得出,**刻意不用 brand_runways.image_count** ——
// 那一列是过期缓存(实测 runway 124 实际 240 张、该列却写 87),拿它当分母会把重复率算错。
type DupStat struct {
Total int // 该图集存活图片总数(实时统计)
Images int // 其中有跨图集重复的图片数
FirstImageID uint32 // 其中 id 最小的一张,列表点进去可直接锚点定位
}
// EmbeddingRepository 读写 image_embeddings 并做近邻检索。
//
// 该表存 FashionSigLIP 产出的 768 维归一化向量,仅用于后台「疑似同款」提示,
// **不参与任何自动去重**。表无 is_deleted 列,因此读侧一律 join 图片表过滤软删行。
//
// 近邻结果走 image_similar_pairs 预计算表:入库时(或 dbtool similarbackfill 回填时)
// 算一次存表,后台页面只读结果。原因见 ReplaceSimilarPairs 注释。
type EmbeddingRepository interface {
// UpsertEmbedding 写入或更新一张图的向量((kind, image_id) 唯一)。
UpsertEmbedding(ctx context.Context, kind string, imageID uint32, vec []float32) error
// BulkUpsertEmbedding 批量写入;vecs[i] 对应 ids[i],vecs[i] 为 nil 则跳过该行。
BulkUpsertEmbedding(ctx context.Context, kind string, ids []uint32, vecs [][]float32) error
// ListEmbeddings 批量取一批图片的已存向量;无向量的图片不出现在结果中。
ListEmbeddings(ctx context.Context, kind string, imageIDs []uint32) (map[uint32][]float32, error)
// FindSimilarImages 返回该图的近邻(已排除自身与 excludeIDs、已过滤软删图片,按相似度降序)。
//
// excludeIDs 用于排除「同图集的图」:当前需求只看「本图集的图与其他图集有没有重复」,
// 同图集内所有照片共享背景/灯光/模特/造型,相似度天然偏高(实测同场中位 0.69、最高 0.91),
// 参与比对只会产生噪音。
FindSimilarImages(ctx context.Context, kind string, imageID uint32, excludeIDs []uint32, vec []float32, minSim float64, limit int) ([]SimilarImage, error)
// ReplaceSimilarPairs 用 rows 整体替换 selfIDs 这些图的近邻对(先删后插,同一事务)。
//
// rows 必须**双向**(A→B 与 B→A 各一行):这样打开任一侧所在的图集都能看到提示,
// 读侧无需 UNION。入库写完向量后调用。
ReplaceSimilarPairs(ctx context.Context, kind string, selfIDs []uint32, rows []SimilarPair) error
// ListSimilarPairs 读一批图的近邻(含对方的图片 key / 名称 / 所属图集标题,已过滤软删)。
ListSimilarPairs(ctx context.Context, kind string, imageIDs []uint32) (map[uint32][]SimilarNeighbor, error)
// DupStatsByOwner 按图集汇总跨图集重复情况(含每组存活图片总数),供文章列表标注。
//
// 返回「图集 id -> 概况」;没有任何图片的图集不会出现(调用方按缺失处理为 0)。
DupStatsByOwner(ctx context.Context, kind string) (map[uint32]DupStat, error)
}
// FormatEmbeddingVector 把向量格式化为 pgvector 的文本字面量(`[x,y,...]`)。
//
// 用 'g' + -1 位精度:float32 的每个值都能精确往返,避免向量被截断后相似度漂移。
func FormatEmbeddingVector(vec []float32) string {
var b strings.Builder
b.Grow(len(vec)*12 + 2)
b.WriteByte('[')
for i, v := range vec {
if i > 0 {
b.WriteByte(',')
}
b.WriteString(strconv.FormatFloat(float64(v), 'g', -1, 32))
}
b.WriteByte(']')
return b.String()
}
// ParseEmbeddingVector 把 pgvector 的文本表示(`[x,y,...]`)解析回 float32 切片。
// 解析失败返回 nil(调用方据此跳过该行,不阻断渲染)。
func ParseEmbeddingVector(s string) []float32 {
s = strings.TrimSpace(s)
if len(s) < 2 || s[0] != '[' || s[len(s)-1] != ']' {
return nil
}
s = s[1 : len(s)-1]
if s == "" {
return nil
}
parts := strings.Split(s, ",")
out := make([]float32, 0, len(parts))
for _, p := range parts {
f, err := strconv.ParseFloat(strings.TrimSpace(p), 32)
if err != nil {
return nil
}
out = append(out, float32(f))
}
return out
}
// imageOwner 描述一种图片所属关系的表名、外键列名,以及主表用于展示的标题列。
//
// 上述表名与列名均为代码内常量,不接受外部输入,因此拼接进 SQL 无注入面。
type imageOwner struct {
table string // 图片表
fk string // 图片表里指向主表的外键列
titleTable string // 主表(图集)
titleCol string // 主表的标题列
}
// OwnerTable 返回某类图片的「图片表名 / 指向图集的外键列名」,供 dbtool 按图集遍历(回填近邻)使用。
func OwnerTable(kind string) (table, fk string, ok bool) {
o, ok := ownerOf(kind)
if !ok {
return "", "", false
}
return o.table, o.fk, true
}
// ownerOf 按 kind 返回对应图片表的所属关系;未知 kind 返回 ok=false。
func ownerOf(kind string) (imageOwner, bool) {
switch kind {
case "runway":
// 走秀主表的英文标题列是 title_en(原通用 title 列已由 i18n 迁移删除)。
return imageOwner{
table: "brand_runway_images", fk: "runway_id",
titleTable: "brand_runways", titleCol: "title_en",
}, true
case "street":
return imageOwner{
table: "street_snap_images", fk: "snap_id",
titleTable: "street_snaps", titleCol: "title",
}, true
default:
return imageOwner{}, false
}
}
type embeddingRepository struct {
db *gorm.DB
}
// NewEmbeddingRepository 创建向量仓储。
func NewEmbeddingRepository(db *gorm.DB) EmbeddingRepository {
return &embeddingRepository{db: db}
}
func (r *embeddingRepository) UpsertEmbedding(ctx context.Context, kind string, imageID uint32, vec []float32) error {
return r.BulkUpsertEmbedding(ctx, kind, []uint32{imageID}, [][]float32{vec})
}
// BulkUpsertEmbedding 逐行写入而非 unnest 批量:日入库仅几百张,量级极小,
// 换来的是可读性与低出错概率。ON CONFLICT 保证同一图重复入库不会写出多行。
func (r *embeddingRepository) BulkUpsertEmbedding(ctx context.Context, kind string, ids []uint32, vecs [][]float32) error {
if len(ids) == 0 {
return nil
}
now := uint32(time.Now().Unix())
for i, id := range ids {
if i >= len(vecs) || vecs[i] == nil {
continue
}
if err := r.db.WithContext(ctx).Exec(`
INSERT INTO image_embeddings (kind, image_id, embedding, created_at)
VALUES (?, ?, ?::vector, ?)
ON CONFLICT (kind, image_id) DO UPDATE
SET embedding = EXCLUDED.embedding, created_at = EXCLUDED.created_at`,
kind, id, FormatEmbeddingVector(vecs[i]), now).Error; err != nil {
return err
}
}
return nil
}
func (r *embeddingRepository) ListEmbeddings(ctx context.Context, kind string, imageIDs []uint32) (map[uint32][]float32, error) {
if len(imageIDs) == 0 {
return nil, nil
}
var rows []struct {
ImageID uint32 `gorm:"column:image_id"`
Embedding string `gorm:"column:embedding"`
}
if err := r.db.WithContext(ctx).
Table("image_embeddings").
Select("image_id, embedding").
Where("kind = ? AND image_id IN ?", kind, imageIDs).
Scan(&rows).Error; err != nil {
return nil, err
}
out := make(map[uint32][]float32, len(rows))
for _, row := range rows {
if vec := ParseEmbeddingVector(row.Embedding); vec != nil {
out[row.ImageID] = vec
}
}
return out, nil
}
// FindSimilarImages 两步走:先走 HNSW 取候选(只查 image_embeddings,索引用得上),
// 再按 kind join 图片表过滤软删行并取所属文章 id(这一步不走索引,但候选集很小)。
//
// ⚠️ 第一步必须用 Raw 写死参数顺序,不能用 GORM 的 Table/Select/Where/Order 链式组装:
// 链式写法里 `Select(..., vecStr)` 与 `Order(gorm.Expr("embedding <=> ?::vector", vecStr))`
// 各带一个同名参数,实测 GORM 会把 ORDER BY 的那个参数绑到错误位置,
// 结果 `ORDER BY embedding <=> ...` 退化为按垃圾值排序 —— 候选集变成随机若干行,
// 真正的近邻被大量漏掉(相似度阈值那一步仍用正确的向量,因此现象是「提示的并不是最像的那张」)。
func (r *embeddingRepository) FindSimilarImages(ctx context.Context, kind string, imageID uint32, excludeIDs []uint32, vec []float32, minSim float64, limit int) ([]SimilarImage, error) {
if len(vec) == 0 || limit <= 0 {
return nil, nil
}
owner, ok := ownerOf(kind)
if !ok {
return nil, nil
}
// 放大候选倍数:部分候选会因图片已软删被第二步过滤掉。
candidate := limit * 5
if candidate < 20 {
candidate = 20
}
vecStr := FormatEmbeddingVector(vec)
// 排除同图集的图必须写在候选查询里、而不是留到第二步过滤:
// 若留到第二步,同图集的图会先占满候选名额(同场相似度天然偏高),
// 真正要看的跨图集近邻反而被 LIMIT 截断在外面。
//
// ⚠️ 已知性能瓶颈(2026-10-13 实测):本函数每张图调用一次,一条 116 张图的图集要跑
// 116 次,而每次往返要传约 20KB(768 维向量文本在 SELECT 与 ORDER BY 各出现一次)。
// 本机 PostgreSQL 跑在 WSL2 里、Windows 经 wslrelay 访问,该链路大 payload 吞吐极差
// (实测约 0.47MB/s):服务端执行这条查询只要 0.45ms,客户端往返却要 ~45ms,
// 合计 116×45ms ≈ 5 秒。**这是页面慢的真正原因**,且与 GORM/参数化/PG 日志/连接池均无关
// (已逐项实测排除:原生 database/sql 同样 45ms、SELECT 1 只要 1ms、池等待为 0)。
// 后续方向见 docs 里的讨论:把向量改为二进制参数、或整图集一条 LATERAL 查询、或用内容哈希替代。
where := "kind = ? AND image_id <> ?"
args := []any{kind, imageID}
if len(excludeIDs) > 0 {
ids := make([]string, 0, len(excludeIDs))
for _, id := range excludeIDs {
ids = append(ids, strconv.FormatUint(uint64(id), 10))
}
where += " AND image_id NOT IN (" + strings.Join(ids, ",") + ")"
}
args = append(args, candidate)
sqlText := "SELECT image_id, 1 - (embedding <=> '" + vecStr + "'::vector) AS sim " +
"FROM image_embeddings " +
"WHERE " + where + " " +
"ORDER BY embedding <=> '" + vecStr + "'::vector LIMIT ?"
type cand struct {
ImageID uint32 `gorm:"column:image_id"`
Sim float64 `gorm:"column:sim"`
}
var cands []cand
if err := r.db.WithContext(ctx).Raw(sqlText, args...).Scan(&cands).Error; err != nil {
return nil, err
}
if len(cands) == 0 {
return nil, nil
}
ids := make([]uint32, 0, len(cands))
for _, c := range cands {
ids = append(ids, c.ImageID)
}
// 第二步:join 图集主表,一次性取回「对方的图片 key / 名称 / 所属图集标题」。
// 有了这三项,后台角标就能显示缩略图并指明是哪张图、在哪个图集,
// 而不是只给一个相似度分数让人自己去找。
var neighbors []struct {
ID uint32 `gorm:"column:id"`
Image string `gorm:"column:image"`
Name string `gorm:"column:name"`
Owner uint32 `gorm:"column:owner"`
OwnerTitle string `gorm:"column:owner_title"`
}
// 显式展开占位符,不写 `IN ?`:Raw 路径下切片展开依赖 GORM 内部实现,
// 显式 `IN (?,?,…)` 才能确定逐值绑定。占位符是常量、表名列名来自 ownerOf 常量,
// 值一律走参数绑定,无注入面。
placeholders := make([]string, len(ids))
joinArgs := make([]any, len(ids))
for i, id := range ids {
placeholders[i] = "?"
joinArgs[i] = id
}
if err := r.db.WithContext(ctx).Raw(
"SELECT i.id AS id, i.image AS image, i.name AS name, "+
"i."+owner.fk+" AS owner, t."+owner.titleCol+" AS owner_title "+
"FROM "+owner.table+" AS i "+
"JOIN "+owner.titleTable+" AS t ON t.id = i."+owner.fk+" "+
"WHERE i.id IN ("+strings.Join(placeholders, ",")+") AND i.is_deleted = 0",
joinArgs...).Scan(&neighbors).Error; err != nil {
return nil, err
}
type neighborMeta struct {
image string
name string
owner uint32
title string
}
alive := make(map[uint32]neighborMeta, len(neighbors))
for _, n := range neighbors {
alive[n.ID] = neighborMeta{image: n.Image, name: n.Name, owner: n.Owner, title: n.OwnerTitle}
}
res := make([]SimilarImage, 0, limit)
for _, c := range cands {
meta, ok := alive[c.ImageID]
if !ok {
continue // 图片已软删,不作为「疑似同款」参照
}
if c.Sim < minSim {
continue
}
res = append(res, SimilarImage{
ImageID: c.ImageID,
OwnerID: meta.owner,
Similar: c.Sim,
Image: meta.image,
Name: meta.name,
OwnerTitle: meta.title,
})
if len(res) >= limit {
break
}
}
return res, nil
}
// ReplaceSimilarPairs 用 rows 整体替换 selfIDs 这些图的近邻对。
//
// 为什么要有这张预计算表:原实现是在**打开图集页时**对每张图实时做一次向量近邻检索,
// 一个 116 张图的图集要发 116 次查询、每次约 20KB SQL。本机 PostgreSQL 跑在 WSL2 里、
// Windows 经 wslrelay 访问,该链路大 payload 吞吐实测约 0.47MB/s,合计要 5 秒。
// 改成入库时算一次存表后,页面只读结果(一次几百字节的小查询)。
//
// 排除 id 用内联字面量而非参数:这些 id 是 uint32,格式化为十进制串后字符集受限,无注入面。
func (r *embeddingRepository) ReplaceSimilarPairs(ctx context.Context, kind string, selfIDs []uint32, rows []SimilarPair) error {
if len(selfIDs) == 0 {
return nil
}
ids := make([]string, 0, len(selfIDs))
for _, id := range selfIDs {
ids = append(ids, strconv.FormatUint(uint64(id), 10))
}
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 先删掉这批图的旧近邻:图集重爬/重算时避免残留过期对。
if err := tx.Exec(
"DELETE FROM image_similar_pairs WHERE kind = ? AND self_id IN ("+strings.Join(ids, ",")+")",
kind).Error; err != nil {
return err
}
if len(rows) == 0 {
return nil
}
now := uint32(time.Now().Unix())
vals := make([]string, 0, len(rows))
args := make([]any, 0, len(rows)*5)
for _, p := range rows {
vals = append(vals, "(?, ?, ?, ?, ?)")
args = append(args, kind, p.SelfID, p.OtherID, p.Similar, now)
}
return tx.Exec(
"INSERT INTO image_similar_pairs (kind, self_id, other_id, sim, created_at) VALUES "+
strings.Join(vals, ",")+
" ON CONFLICT (kind, self_id, other_id) DO UPDATE SET sim = EXCLUDED.sim, created_at = EXCLUDED.created_at",
args...).Error
})
}
// ListSimilarPairs 批量读近邻,并 join 图片表与图集主表补齐对方描述。
//
// 两条查询、payload 极小(这是页面变快的关键):先按 (kind, self_id) 主键前缀取对,
// 再按对方 id 一次性取描述。软删图片在此被过滤掉——预计算表里的行可能残留,
// 不级联清理,靠这里过滤即可。
func (r *embeddingRepository) ListSimilarPairs(ctx context.Context, kind string, imageIDs []uint32) (map[uint32][]SimilarNeighbor, error) {
if len(imageIDs) == 0 {
return nil, nil
}
owner, ok := ownerOf(kind)
if !ok {
return nil, nil
}
var pairs []struct {
SelfID uint32 `gorm:"column:self_id"`
OtherID uint32 `gorm:"column:other_id"`
Sim float64 `gorm:"column:sim"`
}
if err := r.db.WithContext(ctx).
Table("image_similar_pairs").
Select("self_id, other_id, sim").
Where("kind = ? AND self_id IN ?", kind, imageIDs).
Scan(&pairs).Error; err != nil {
return nil, err
}
if len(pairs) == 0 {
return nil, nil
}
otherIDs := make([]uint32, 0, len(pairs))
seen := make(map[uint32]bool, len(pairs))
for _, p := range pairs {
if !seen[p.OtherID] {
seen[p.OtherID] = true
otherIDs = append(otherIDs, p.OtherID)
}
}
ph := make([]string, len(otherIDs))
args := make([]any, len(otherIDs))
for i, id := range otherIDs {
ph[i] = "?"
args[i] = id
}
var metas []struct {
ID uint32 `gorm:"column:id"`
Image string `gorm:"column:image"`
Name string `gorm:"column:name"`
Owner uint32 `gorm:"column:owner"`
OwnerTitle string `gorm:"column:owner_title"`
}
if err := r.db.WithContext(ctx).Raw(
"SELECT i.id AS id, i.image AS image, i.name AS name, "+
"i."+owner.fk+" AS owner, t."+owner.titleCol+" AS owner_title "+
"FROM "+owner.table+" AS i "+
"JOIN "+owner.titleTable+" AS t ON t.id = i."+owner.fk+" "+
"WHERE i.id IN ("+strings.Join(ph, ",")+") AND i.is_deleted = 0",
args...).Scan(&metas).Error; err != nil {
return nil, err
}
metaByID := make(map[uint32]SimilarNeighbor, len(metas))
for _, m := range metas {
metaByID[m.ID] = SimilarNeighbor{
OtherID: m.ID, OwnerID: m.Owner,
Image: m.Image, Name: m.Name, OwnerTitle: m.OwnerTitle,
}
}
out := make(map[uint32][]SimilarNeighbor, len(pairs))
for _, p := range pairs {
m, ok := metaByID[p.OtherID]
if !ok {
continue // 对方已软删或不存在,不作为提示
}
m.Similar = p.Sim
out[p.SelfID] = append(out[p.SelfID], m)
}
return out, nil
}
// DupStatsByOwner 按图集汇总跨图集重复情况,一次查完供列表页使用。
//
// 用 LEFT JOIN 同时拿到两件事:每组存活图片总数(做分母)与其中有重复的图片数。
// 分母刻意实时统计而非读 brand_runways.image_count —— 那列是**只记主图**的口径(见 AdminRecordStats)。
//
// 必须同时校验「对端那张图还活着」:image_similar_pairs 是**预计算表**,删除图集只会软删
// 图片行、不会删近邻对,于是对端已删的陈旧近邻会继续留在表里。编辑页的提示路径(ListSimilarPairs)
// 会过滤掉这些死链,列表汇总若不过滤就会长期显示已不存在的重复(实测:图集 391 的 88 个近邻
// 对端全部已删,列表仍显示「88 张 · 37%」)。恢复图集(is_deleted 置回 0)后会自动重新计入。
//
// 表名/列名来自 ownerOf 常量,kind 走参数绑定,无注入面。
func (r *embeddingRepository) DupStatsByOwner(ctx context.Context, kind string) (map[uint32]DupStat, error) {
owner, ok := ownerOf(kind)
if !ok {
return nil, nil
}
var rows []struct {
Owner uint32 `gorm:"column:owner"`
Total int `gorm:"column:total"`
Images int `gorm:"column:images"`
FirstImg uint32 `gorm:"column:first_img"`
}
if err := r.db.WithContext(ctx).Raw(
"SELECT i."+owner.fk+" AS owner, "+
"count(DISTINCT i.id) AS total, "+
"count(DISTINCT p.self_id) AS images, "+
"coalesce(min(p.self_id), 0) AS first_img "+
"FROM "+owner.table+" AS i "+
"LEFT JOIN image_similar_pairs AS p ON p.kind = ? AND p.self_id = i.id "+
" AND EXISTS (SELECT 1 FROM "+owner.table+" AS o WHERE o.id = p.other_id AND o.is_deleted = 0) "+
"WHERE i.is_deleted = 0 "+
"GROUP BY i."+owner.fk,
kind).Scan(&rows).Error; err != nil {
return nil, err
}
out := make(map[uint32]DupStat, len(rows))
for _, r := range rows {
out[r.Owner] = DupStat{Total: r.Total, Images: r.Images, FirstImageID: r.FirstImg}
}
return out, nil
}