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

190 lines
7.1 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 service
import (
"context"
"fmt"
"log"
"sort"
"fashionapi/internal/repository"
)
// SimilarHint 是一条「疑似同款」提示:某张图片的一个近邻。
//
// 带上近邻的图片 key / 名称 / 所属图集标题,后台才能直接显示「和哪张图像」。
type SimilarHint struct {
ImageID uint32 // 近邻图片 id
OwnerID uint32 // 近邻所属走秀 / 街拍专辑 id(供前端跳转)
Similar float64 // 余弦相似度 0~1
Image string // 近邻图片存储 key(前端显示缩略图)
Name string // 近邻图片名(如 "Look 12")
OwnerTitle string // 近邻所属图集标题
}
// DupStat 是一个图集的「跨图集重复」概况,供文章列表直接标出哪些图集有重复。
type DupStat struct {
Total int // 该图集存活图片总数(实时统计,不用过期的 image_count 缓存列)
Images int // 其中有跨图集重复的图片数
FirstImageID uint32 // 其中 id 最小的一张,列表点进去可直接锚点定位
}
// SimilarService 组装后台「疑似同款」提示。
//
// 定位(对应规格 §8/§9):**只提示、不隐藏、不计入重复率、不参与任何去重**。
//
// 分工:
// - PrecomputeForGallery 在**入库时**(或 dbtool similarbackfill 回填时)算好近邻并落库;
// - HintsForImages 只读结果表,供页面渲染。
//
// 为什么必须预计算:原实现在**打开图集页时**对每张图实时检索,一个 116 张图的图集要发
// 116 次查询、每次约 20KB SQL(768 维向量文本)。本机 PostgreSQL 跑在 WSL2 里、Windows 经
// wslrelay 访问,该链路大 payload 吞吐实测约 0.47MB/s —— 服务端执行单条只要 0.45ms,
// 客户端往返却要 45ms,合计 116×45ms ≈ 5 秒。挪到入库时后页面只读一次小查询。
type SimilarService struct {
embedRep repository.EmbeddingRepository
threshold float64
}
// NewSimilarService 构造 SimilarService。threshold 为余弦相似度阈值。
// embedRep 可为 nil(视觉服务未启用),此时所有查询返回空结果。
func NewSimilarService(embedRep repository.EmbeddingRepository, threshold float64) *SimilarService {
return &SimilarService{embedRep: embedRep, threshold: threshold}
}
// Enabled 报告是否可用(仓储已注入)。
func (s *SimilarService) Enabled() bool {
return s != nil && s.embedRep != nil
}
// Threshold 返回当前的「疑似同款」相似度阈值,供页面展示。
func (s *SimilarService) Threshold() float64 {
if s == nil {
return 0
}
return s.threshold
}
// HintsForImages 读取一个图集全部图片的「疑似同款」提示(只读结果表,不计算)。
//
// 返回 map[图片 id] -> 近邻提示列表(按相似度降序、每张最多 similarNeighborLimit 条)。
// **不返回 error**:提示是增值信息,读取失败只记日志并返回空,
// 绝不能让「查不到同款」影响后台页面渲染。
func (s *SimilarService) HintsForImages(ctx context.Context, kind string, imageIDs []uint32) map[uint32][]SimilarHint {
if !s.Enabled() || len(imageIDs) == 0 {
return nil
}
pairs, err := s.embedRep.ListSimilarPairs(ctx, kind, imageIDs)
if err != nil {
log.Printf("[similar] 读近邻失败 kind=%s 数量=%d err=%v(页面不显示同款提示)", kind, len(imageIDs), err)
return nil
}
if len(pairs) == 0 {
return nil
}
out := make(map[uint32][]SimilarHint, len(pairs))
for selfID, ns := range pairs {
if len(ns) == 0 {
continue
}
sort.Slice(ns, func(i, j int) bool { return ns[i].Similar > ns[j].Similar })
if len(ns) > similarNeighborLimit {
ns = ns[:similarNeighborLimit]
}
hints := make([]SimilarHint, 0, len(ns))
for _, n := range ns {
hints = append(hints, SimilarHint{
ImageID: n.OtherID,
OwnerID: n.OwnerID,
Similar: n.Similar,
Image: n.Image,
Name: n.Name,
OwnerTitle: n.OwnerTitle,
})
}
out[selfID] = hints
}
return out
}
// DupStats 汇总 runway / street 两类图集的跨图集重复情况(kind -> 图集 id -> 概况)。
//
// 供文章列表直接标出「哪些图集有重复」——否则只能一个个点进图集才能发现。
//
// **不返回 error**:与提示同理,汇总失败只记日志并跳过该类,
// 绝不能让列表页因为查不到重复而渲染失败。
func (s *SimilarService) DupStats(ctx context.Context) map[string]map[uint32]DupStat {
if !s.Enabled() {
return nil
}
out := make(map[string]map[uint32]DupStat, 2)
for _, kind := range []string{"runway", "street"} {
raw, err := s.embedRep.DupStatsByOwner(ctx, kind)
if err != nil {
log.Printf("[similar] 汇总跨图集重复失败 kind=%s err=%v(列表不显示重复列)", kind, err)
continue
}
if len(raw) == 0 {
continue
}
m := make(map[uint32]DupStat, len(raw))
for ownerID, st := range raw {
m[ownerID] = DupStat{Total: st.Total, Images: st.Images, FirstImageID: st.FirstImageID}
}
out[kind] = m
}
return out
}
// PrecomputeForGallery 计算并落库一个图集全部图片的跨图集近邻。
//
// imageIDs 须为该图集的全部图片 id:它同时充当「同图集排除集」——需求是
// 「本图集的图与其他图集有没有重复」,同图集内所有照片共享背景/灯光/模特/造型,
// 相似度天然偏高(实测同场中位 0.69、最高 0.91),参与比对只会淹没真正的跨图集重复。
//
// 结果**双向**写入(A→B 与 B→A):这样打开任一侧所在的图集都能看到提示。
//
// 说明:单张查询失败只记日志并跳过,整体不中断——预计算是增值数据,
// 绝不能让「算不出同款」影响入库或回填。
func (s *SimilarService) PrecomputeForGallery(ctx context.Context, kind string, imageIDs []uint32) error {
if !s.Enabled() || len(imageIDs) == 0 {
return nil
}
vecByImage, err := s.embedRep.ListEmbeddings(ctx, kind, imageIDs)
if err != nil {
return fmt.Errorf("取向量: %w", err)
}
if len(vecByImage) == 0 {
return nil // 这批图还没有向量(视觉服务未启用),无需预计算
}
rows := make([]repository.SimilarPair, 0, len(vecByImage)*similarNeighborLimit*2)
done := 0
for imageID, vec := range vecByImage {
if len(vec) == 0 {
continue
}
neighbors, ferr := s.embedRep.FindSimilarImages(
ctx, kind, imageID, imageIDs, vec, s.threshold, similarNeighborLimit)
if ferr != nil {
log.Printf("[similar] 预计算近邻失败 kind=%s 图片=%d err=%v", kind, imageID, ferr)
continue
}
done++
for _, n := range neighbors {
rows = append(rows,
repository.SimilarPair{SelfID: imageID, OtherID: n.ImageID, Similar: n.Similar},
repository.SimilarPair{SelfID: n.ImageID, OtherID: imageID, Similar: n.Similar},
)
}
}
if err := s.embedRep.ReplaceSimilarPairs(ctx, kind, imageIDs, rows); err != nil {
return fmt.Errorf("写入近邻: %w", err)
}
log.Printf("[similar] 预计算完成 kind=%s 图=%d 已算=%d 对=%d 阈值=%.2f",
kind, len(imageIDs), done, len(rows), s.threshold)
return nil
}
// similarNeighborLimit 每张图最多展示的「疑似同款」条数:只作人工参考,多了是噪音。
const similarNeighborLimit = 5