17 KiB
下线 pHash 去重 + FashionSigLIP「疑似同款」提示 — 设计文档
- 日期:2026-10-12
- 状态:待实现
- 相关代码:
internal/pkg/phash、internal/service/ingest_service.go、internal/repository/ingest_repository.go、internal/handler/backstage_handler.go
1. 背景
现有图片去重基于 internal/pkg/phash 的 dHash(64 位),存入图片表的 phash vector(64) 列,配HNSW 索引(brand_runway_images_phash_hnsw / street_snap_images_phash_hnsw,L2 距离),在 IngestService.dedupImage 中判定,命中则写 is_duplicate + dup_of,前台折叠、后台显示「重复」徽章与重复率统计。
dHash 只看像素,只能识别「同一文件被重复上传 / 裁剪 / 缩放 / 重压缩」,识别不了「同一件衣服被不同角度重新拍摄」。且这套机制带来持续的维护成本:is_duplicate / dup_of 是持久化关系,删图、删文章时都必须重算(article_service.go、street_snap_service.go 各有一段专门的重算逻辑)。
决策:下线整套自动去重,不再有任何自动折叠,重复图全部平铺,靠人工清理。
新项目 D:\project\fashion-siglip 已用 marqo-fashionSigLIP(open_clip,ViT-B/16 SigLIP)验证语义向量可行:图片编码为归一化向量后,余弦相似度可反映语义层面的相似性。SigLIP 只用于后台提示「疑似同款」,不参与任何去重判定。
2. 目标
- 下线 pHash 去重能力(代码、schema、模板、统计、测试一并清理)。
- 新增独立 Python 视觉服务,常驻加载 FashionSigLIP,提供图片向量(embed)能力。
- Go worker 入库时获取向量落库;后台查看图集时展示「疑似同款」提示。
3. 非目标(YAGNI)
- 不做自动去重、不做自动折叠(本次明确下线)。
- 不做文本搜图 / 图文跨模态检索。
- 不实现
/classify分类接口(仅在服务边界预留)。 - 不回填历史图片向量。
- 不做阈值自动调优。
4. 架构总览
┌──────────────────────────────┐
spider ──HTTP──▶ │ Go server (worker) │
│ IngestService │
│ 下载 → embed ──上传 │
└───┬──────────────────┬───────┘
│ 写/查向量 │ POST /embed (批量)
▼ ▼
┌───────────────────┐ ┌───────────────────────────┐
│ PostgreSQL │ │ Python 视觉服务 (CPU) │
│ image_embeddings │◀─│ FashionSigLIP 常驻 │
│ (vector(768)+HNSW)│ │ /embed /health │
└───────────────────┘ └───────────────────────────┘
▲
│ 后台看图集时按需 HNSW 查询
│
BackstageHandler
进程边界理由:FashionSigLIP 是 PyTorch 模型,Go 无法直接执行。独立服务做到模型常驻一次、与 Go 发布解耦,并为将来分类能力复用同一进程。
5. 下线现有去重能力(删除清单)
5.1 代码
| 位置 | 删除内容 |
|---|---|
internal/pkg/phash/ |
整个包(phash.go、phash_test.go) |
internal/model/runway_image.go、internal/model/street_snap.go |
Phash / IsDuplicate / DupOf 字段 |
internal/service/ingest_service.go |
dedupImage() 整个函数;concurrentFetch 中 res.phashBits 计算;timing 的 phash / dedup 字段;落库处 Phash / IsDuplicate 赋值;formatTiming 中「phash / 近重复」两段 |
internal/service/article_service.go:297-354 |
删图 / 下架时的 dup_of 重算逻辑 |
internal/service/street_snap_service.go:398-449 |
同上 |
internal/repository/ingest_repository.go |
FindNearDuplicateImage;写入处 phash / is_duplicate / dup_of 三列 |
internal/repository/article_repository.go:430 |
phash <-> 近邻查询 |
internal/repository/street_snap_repository.go:372 |
同上 |
internal/repository/admin_repository.go |
重复率统计查询 |
internal/handler/backstage_handler.go |
重复率统计(约 333/388 行)、徽章与折叠数据装配(约 683/702/713/732 等)、buildDupSources(约 961 行起) |
internal/handler/templates/partials.html:62 |
「重复」徽章与折叠模板 |
cmd/diagdup/ |
整个诊断命令 |
5.2 Schema(新增迁移)
brand_runway_images:DROP 列phash/is_duplicate/dup_of,DROP 索引brand_runway_images_phash_hnswstreet_snap_images:DROP 列同上,DROP 索引street_snap_images_phash_hnsw- DROP 死表
image_duplicates(2026-09-21 建表后全仓零引用,已确认的死物)
该迁移不可逆(已确认接受),执行前确认
db/backups/里有备份。
5.3 测试
- 删除
internal/repository/dedup_integration_test.go - 清理
editor_image_delete_integration_test.go、ingest_single_table_integration_test.go中依赖phash.ToVectorBits/ 重复标记的用例 - 清理
internal/service/ingest_image_count_test.go中 fake 仓储的FindNearDuplicateImage方法 - 清理
cmd/dbtool/main.go中相关引用 - 更新
README.md中 pHash / 去重的描述
5.4 保留
sha1 批次内内容去重保留——它是内容哈希(同一 URL / 同一文件在一次入库批次内只处理一次),与 pHash 无关,且几乎零成本。
6. Python 视觉服务
由 D:\project\fashion-siglip 改造而来(现有 embed_images.py / compare_images.py 是每次重新加载模型的脚本原型,不可直接用于生产)。
6.1 接口契约
| 接口 | 入参 | 出参 | 说明 |
|---|---|---|---|
POST /embed |
{"images": ["<图片字节的 base64 数组>"]} |
{"dim": 768, "vectors": [[...], ...]} |
批量编码;向量已 L2 归一化 |
GET /health |
— | {"ok": true, "dim": 768, "device": "cpu"} |
探活与维度自检 |
- 单批上限由服务侧限制(建议 ≤ 32 张)。
- 损坏 / 无法解码的图片跳过,不导致整批失败。
- 维度已由 M0 spike 实测确认为 768(
marqo-fashionSigLIP输出 768 维,非 512)。Go 侧vision.dim默认值与image_embeddings.embedding列类型均按 768 处理。
6.2 部署
- 生产无 GPU,CPU 推理。日入库几百张的量级,CPU 足够。
- 依赖本地模型缓存(沿用现有脚本的
HF_HUB_OFFLINE=1策略),避免运行时网络请求。 - 独立进程 / 容器,仅监听内网。
6.3 启动方式与端口
- 框架:FastAPI + uvicorn(异步、批量接口顺手、自带
/docs便于排查)。 - 端口:
8900,与 Go 侧vision.endpoint默认值一致;生产仅监听127.0.0.1,启动前确认端口未被占用。 - 模型常驻单进程:在应用 lifespan 启动阶段加载一次;加载完成前
/health返回ready=false,避免首个请求承担加载延迟并被 Go 侧超时打断。 --workers 1:模型常驻进程内,多 worker 会各加载一份模型导致内存翻倍;吞吐靠批量(单次/embed传多张)提升,不靠多进程。- 生产离线:模型权重烤进镜像或挂载至
HF_HOME,并设HF_HUB_OFFLINE=1,避免运行时访问 HuggingFace。 - 配置项(环境变量):
VISION_HOST/VISION_PORT/VISION_MODEL/HF_HOME。 - 部署:独立 Dockerfile(
python:3.11-slim+ CPU 版 torch + open_clip + Pillow),健康检查打/health。
启动命令:
# 本地开发:用项目自带的 .venv(torch 2.14.1+cu132,走GPU)
.venv/Scripts/python.exe -m uvicorn app:app --host 127.0.0.1 --port 8900 --workers 1
# 生产:CPU 版 torch 的容器
uvicorn app:app --host 127.0.0.1 --port 8900 --workers 1
本地
.venv已具备 CUDA 版 torch,无需重复安装;fastapi/uvicorn已装入该venv。 服务会自动探测cuda可用性并使用GPU,不可用时回退 CPU,无需配置区分。
7. Go 侧集成
在现有 concurrentFetch 的并发段中插入 embed 调用:
并发段(每张图):下载 → embed → 上传
串行段(按下标):组装 → 落库(同时写入图片记录与 image_embeddings)
- 向量随图片记录一起写入
image_embeddings。 - embed 失败 / 超时 → 不写 embedding 行,不阻断入库。
timing结构新增embed字段,沿用既有各阶段的统计方式。
8. 数据模型——复用 image_embeddings
该表已存在(当前列类型 vector(512) + 已建好的 HNSW 余弦索引 image_embeddings_vec_idx),但全仓零代码引用、零数据,是死表。直接复用,不在两张图片表新增列。
-- 目标结构
image_embeddings (
id bigint identity primary key,
image_id integer not null,
kind varchar(8) not null, -- runway | street,对齐 dto.IngestKind
embedding vector(768), -- 实测维度,由 512 扩到 768
created_at integer not null default 0
)
需要三处改造(同样在迁移中):
embedding由vector(512)改为vector(768):M0 spike 实测模型输出 768 维。表中无数据,类型变更即时完成。kind由smallint改为varchar(8),取值对齐既有的dto.IngestKind("runway"/"street"),与image_duplicates当年用varchar的做法一致。表中无数据、无代码引用,改动安全。- 新增唯一索引
(kind, image_id):现表只有id主键、没有业务唯一约束,不加会导致同一张图重复写入多行。
HNSW 索引
image_embeddings_vec_idx建在embedding上,列类型变更后需重建索引。
软删一致性:图片表有 is_deleted,image_embeddings 没有。删图后向量会残留,因此读侧必须 join 图片表过滤 is_deleted = 0(沿用 image_duplicates 当年的处理约定,不做主动级联清理)。
不存持久化的「同款关系」——同款提示在后台查看时现算现展示,避免新增/删图导致的陈旧标记。
9. 「疑似同款」判定与展示
- 后台查看图集时,对每张有向量的图做一次 HNSW top-k 近邻查询(k 建议 5)。
- 余弦相似度 ≥
vision.similar_threshold→挂「疑似同款」标签,显示「与 #<id> 相似 0.70」,可跳转对比。 - 阈值必须实测校准,不可拍脑袋定(见 §12)。初稿假设的 0.85 已被实测证伪。
- 只排除图片自身;同文章内的近邻同样参与提示——这能发现同一图集里的连拍糊片。
- 仅后台可见,不影响前台展示、不写入任何「重复」语义。
10. 配置项
| 配置 | 默认值 | 说明 |
|---|---|---|
vision.enabled |
false |
总开关,关闭则完全跳过 embed |
vision.endpoint |
http://127.0.0.1:8900 |
服务地址 |
vision.timeout |
5s |
单批超时 |
vision.dim |
768 |
向量维度(须与模型一致,已实测确认) |
vision.similar_threshold |
0.85 |
「疑似同款」相似度阈值 |
vision.fail_threshold |
3 |
连续失败多少次后熔断冷却 |
11. 降级与容错
核心原则:视觉服务不可用绝不能影响入库。
| 故障 | 行为 |
|---|---|
| 服务未启用 | 跳过 embed,不写 embedding 行 |
| 连接失败 / 超时 | 该批向量为空,记日志,继续入库 |
连续失败 ≥ fail_threshold |
熔断冷却 5 分钟,期间直接跳过 embed(避免每张图都卡超时) |
| 返回维度与配置不符 | 拒绝写入并告警,防止写入脏数据 |
冷却结束后半开重试一次,成功则恢复。
12. 阈值校准(必须做,不可跳过)
已实测到的关键反例:c1.jpg 与 c2.jpg 是同一个人、同一地点、同一套穿搭(麂皮短外套 + 格纹百褶裙 + 麂皮短靴 + 托特包)的连拍,仅姿势不同——属于最典型的「同款不同拍」,但余弦相似度只有 0.6965。
初稿假设的 0.85 因此被证伪:按该阈值,这两张最该被认出来的图反而认不出来,会大量漏判。CLIP 类模型的归一化余弦相似度整体偏低,「同款不同拍」通常落在 0.6~0.8,「不同图」落在 0.30.5,真实分界线约在 **0.650.70**。
校准方法:取一批已知「同款不同拍」与「不同款」样本,统计相似度分布,确认两个分布的可分性,再定 similar_threshold。至少需要覆盖:同套连拍、同款不同场次、不同款同风格三类样本。
因该阈值只影响一个后台提示标签、不影响前台展示与任何去重逻辑,可以偏宽松(宁可多提示)。
13. 测试策略
Go 侧
- 入库主流程回归:图片正常入库,删除 phash 后
go build ./...与全部现有测试通过。 - 降级路径:视觉服务返回错误 / 超时 / 维度不符时,入库正常完成且不产生 embedding 行。
vision.enabled=false时不产生任何 embed 请求。- 同款查询:读侧正确过滤
is_deleted = 1的图片。
Python 侧
/embed返回维度正确、向量已 L2 归一化(模长≈1)。- 损坏图片被跳过而非整批失败。
/health可用。
14. 里程碑
已确认:下线去重与 SigLIP 集成合成一个大改动(同分支、同一次交付),内部按下列顺序推进,每步保持可编译、可回归。
M0 — spike(已完成)
把 fashion-siglip 包成常驻 /embed 服务(app.py + smoke_test.py),实测结论:
| 项 | 实测值 |
|---|---|
| 向量维度 | 768(非设计初稿假设的 512,schema 与配置已按 768 修正) |
| 模型加载 | CPU 1.9s / GPU 2.5s(启动时一次) |
| CPU 推理 | 3 张图 217ms(约 72ms/张,torch 2.14.0+cpu) |
| GPU 推理 | 3 张图 57ms(约 19ms/张,torch 2.14.1+cu132,RTX 5060 Ti) |
| GPU/CPU 一致性 | 同一批图相似度 0.6965(GPU) vs 0.6964(CPU),数值一致 |
| 向量归一化 | 模长 1.0000,点积即余弦相似度 |
结论:
- CPU 单图约 72ms,日入库几百张的量级完全无压力,生产无 GPU 的部署假设成立。
- 本地 GPU 比 CPU 快约 3.8 倍,且两种设备算出的向量数值一致——本地 GPU 调试、生产 CPU 部署不会产生数据不一致。
M1 — 主改动(一个分支,一次交付),内部顺序:
| 步骤 | 内容 |
|---|---|
| ① Schema 迁移 | DROP 三列 + 两个 phash HNSW 索引 + 死表 image_duplicates;image_embeddings 的 kind 改 varchar(8) + 补 (kind, image_id) 唯一索引 |
| ② 下线去重 | 按 §5 清单删除 phash / is_duplicate / dup_of 的全部代码、模板、统计、测试、文档 |
| ③ 服务化 | Python 服务正式化(配置、批处理、错误处理、/health) |
| ④ Go 集成 | image_embeddings 仓储读写 + concurrentFetch 挂钩 + 降级熔断 + 配置项 |
| ⑤ 后台展示 | 图集页「疑似同款」标签与对比入口 |
每步结束时 go build ./... 与现有测试须通过;步骤②完成后系统进入「无自动去重」状态(前台不再折叠),需在交付说明中显式提示这一行为变化。
M2 — 阈值校准
dry-run 诊断,定 vision.similar_threshold。
15. 后续(不在本次范围)
15.1 zero-shot 分类:已存向量可直接复用
FashionSigLIP 是图文对齐模型(CLIP 架构),文本与图像编码到同一向量空间,点积即类别分数。D:\project\fashion-siglip\test_similarity_debug.py 已用 8 组分类验证过该能力:Gender / Scene、Clothing、Color、Material、Pattern、Brand、Style、Shoes / Accessories。
关键性质:文本向量是固定的,可在服务启动时算一次并缓存;分类因此退化为「已存图像向量 · 预计算文本向量」的纯点积运算。于是:
- ✅ 性别 / 品类 / 颜色 / 品牌 / 风格 / 材质 / 图案 / 鞋包等 zero-shot 分类,可直接复用已存向量,读时计算,无需重新编码任何图片、无需再调 GPU。
- ❌ 若要超越 zero-shot 的准确率(微调专用分类器或更换模型),则必须重新编码全部图片——不同模型的向量空间不通用,旧向量作废。
这条性质使「入库时存向量」成为一次投入、两处收益的选择:既买下同款检索,也买下可无限扩展的 zero-shot 分类能力。
注意:zero-shot 判定的是造型风格而非人物生理性别(模型主要依据服装)。中性 / oversize 穿搭、多人合影、背影、无人物图易误判,建议输出分数而非硬标签,允许人工覆盖。
15.2 其他
- 历史图片向量回填(本次确认不做)。
- 阈值自动调优。