update
This commit is contained in:
189
internal/service/similar_service.go
Normal file
189
internal/service/similar_service.go
Normal file
@ -0,0 +1,189 @@
|
||||
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
|
||||
Reference in New Issue
Block a user