Files
backend_v2/docs/superpowers/specs/2026-10-12-fashionclip-siglip-vision-service-design.md
toom1996 1de1ec46a6 update
2026-10-18 17:42:10 +08:00

17 KiB
Raw Blame History

下线 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. 目标

  1. 下线 pHash 去重能力(代码、schema、模板、统计、测试一并清理)。
  2. 新增独立 Python 视觉服务,常驻加载 FashionSigLIP,提供图片向量(embed)能力。
  3. 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_hnsw
  • street_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
)

需要三处改造(同样在迁移中):

  1. embedding 由 vector(512) 改为 vector(768):M0 spike 实测模型输出 768 维。表中无数据,类型变更即时完成。
  2. kind 由 smallint 改为 varchar(8),取值对齐既有的 dto.IngestKind("runway" / "street"),与 image_duplicates 当年用 varchar 的做法一致。表中无数据、无代码引用,改动安全。
  3. 新增唯一索引 (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,点积即余弦相似度

结论:

  1. CPU 单图约 72ms,日入库几百张的量级完全无压力,生产无 GPU 的部署假设成立。
  2. 本地 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 其他

  • 历史图片向量回填(本次确认不做)。
  • 阈值自动调优。