# 下线 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` →挂「疑似同款」标签,显示「与 #\ 相似 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 其他 - 历史图片向量回填(本次确认不做)。 - 阈值自动调优。