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

288 lines
17 KiB
Markdown
Raw 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.

# 下线 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`。
启动命令:
```bash
# 本地开发:用项目自带的 .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`),但全仓零代码引用、零数据,是死表。直接复用,不在两张图片表新增列。
```sql
-- 目标结构
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.3~0.5,真实分界线约在 **0.65~0.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 其他
- 历史图片向量回填(本次确认不做)。
- 阈值自动调优。