1456 lines
49 KiB
Markdown
1456 lines
49 KiB
Markdown
# 下线 pHash 去重 + FashionSigLIP「疑似同款」实现计划
|
||
|
||
> **面向 AI 代理的工作者:** 必需子技能:使用 subagent-driven-development(推荐)或 executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
|
||
|
||
**目标:** 删除现有基于 dHash 的自动去重整条链路,并接入独立 Python 视觉服务产出的 768 维 FashionSigLIP 向量,在后台图集页展示「疑似同款」提示。
|
||
|
||
**架构:** Python 服务(`D:\project\fashion-siglip`,独立仓库)常驻加载 `marqo-fashionSigLIP`,暴露 `POST /embed` 与 `GET /health`。Go worker 在入库的并发抓取阶段并行调用 `/embed`,把归一化向量写入既有空表 `image_embeddings`(`vector(768)` + HNSW 余弦索引)。后台查看图集时按需做 HNSW top-k 近邻查询,现算现展示,不持久化任何「同款关系」。
|
||
|
||
**技术栈:** Go(gin / pgx / pgvector)、PostgreSQL + pgvector、Python 3.11 + FastAPI + uvicorn + torch/open_clip。
|
||
|
||
**规格:** `docs/superpowers/specs/2026-10-12-fashionclip-siglip-vision-service-design.md` —— 执行前必须完整阅读,计划的论证依据来自规格。
|
||
|
||
---
|
||
|
||
## 全局约束
|
||
|
||
- 向量维度固定为 **768**(M0 实测,非 512)。
|
||
- `kind` 取值必须对齐 `dto.IngestKind`:`"runway"` / `"street"`。
|
||
- `image_embeddings.created_at` 是 `integer`(Unix 秒),写入时传 `time.Now().Unix()`。
|
||
- 视觉服务不可用 / 超时 / 维度不符时,**一律不写向量且绝不阻断入库**;不得因此让入库失败。
|
||
- 视觉服务连续失败达 `fail_threshold` 次后熔断冷却 5 分钟,期间直接跳过 embed(不逐张等待超时)。
|
||
- 生产无 GPU,CPU 推理;本地 `.venv` 有 CUDA 版 torch。两者算出的向量数值一致,不需按设备分支。
|
||
- **`--workers 1`**:模型常驻进程内,多 worker 会各加载一份模型导致内存翻倍。
|
||
- 迁移编号续接现有序列:`2026-10-12-01-*.sql`。
|
||
- 本计划中每个任务的 commit 步骤可由用户统一执行或合并,不强制逐个提交。
|
||
|
||
---
|
||
|
||
## 文件结构
|
||
|
||
**新建:**
|
||
- `internal/vision/client.go` —— 视觉服务 HTTP 客户端 + 熔断器(纯逻辑,可单测)
|
||
- `internal/vision/client_test.go`
|
||
- `internal/repository/embedding_repository.go` —— `image_embeddings` 的写入与近邻查询
|
||
- `internal/service/similar_service.go` —— 「疑似同款」业务编排(阈值过滤 + 装配提示数据)
|
||
- `internal/service/similar_service_test.go`
|
||
- `db/migrations/2026-10-12-01-drop-dedup-add-embeddings.sql`
|
||
|
||
**修改:**
|
||
- `internal/config/config.go` —— 新增 `VisionConfig`
|
||
- `configs/config.yml` —— 新增 `vision:` 段
|
||
- `internal/service/ingest_service.go` —— 删去重、加 embed 调用
|
||
- `internal/service/article_service.go` —— 删 `dup_of` 重算
|
||
- `internal/service/street_snap_service.go` —— 删 `dup_of` 重算
|
||
- `internal/model/runway_image.go`、`internal/model/street_snap.go` —— 删字段
|
||
- `internal/repository/ingest_repository.go` —— 删 `FindNearDuplicateImage` 与三列写入
|
||
- `internal/repository/article_repository.go` —— 删 `phash <->` 查询
|
||
- `internal/repository/street_snap_repository.go` —— 删 `phash <->` 查询
|
||
- `internal/repository/admin_repository.go` —— 删重复率统计
|
||
- `internal/handler/backstage_handler.go` —— 删重复徽章/统计,加「疑似同款」
|
||
- `internal/handler/templates/partials.html` —— 删「重复」徽章
|
||
- `internal/handler/assets/admin.js` —— 去掉重复徽章前端逻辑
|
||
- `cmd/server/main.go` —— 装配 vision 客户端与 embedding 仓储
|
||
- `cmd/dbtool/main.go` —— 删相关引用
|
||
- `README.md` —— 更新去重描述
|
||
|
||
**删除:**
|
||
- `internal/pkg/phash/`(整包)
|
||
- `cmd/diagdup/`(整目录)
|
||
- `internal/repository/dedup_integration_test.go`
|
||
|
||
---
|
||
|
||
## 阶段一:下线去重
|
||
|
||
### 任务 1:Schema 迁移
|
||
|
||
**文件:**
|
||
- 创建:`db/migrations/2026-10-12-01-drop-dedup-add-embeddings.sql`
|
||
- 修改:`db/migrations/README.md`
|
||
|
||
- [ ] **步骤 1:确认备份存在**
|
||
|
||
运行:`ls db/backups/`
|
||
预期:存在 `db_dump.sql`。**若不存在,先跑 `./bin/dbtool dump -clean -out db/backups/db_dump.sql` 再继续**(本迁移不可逆)。
|
||
|
||
- [ ] **步骤 2:写迁移 SQL**
|
||
|
||
创建 `db/migrations/2026-10-12-01-drop-dedup-add-embeddings.sql`:
|
||
|
||
```sql
|
||
-- 2026-10-12-01 下线 pHash 去重 +启用 image_embeddings 存 FashionSigLIP 向量
|
||
--
|
||
-- 对应规格:docs/superpowers/specs/2026-10-12-fashionclip-siglip-vision-service-design.md §5.2 / §8
|
||
--
|
||
-- 不可逆:DROP COLUMN / DROP TABLE 无回滚手段,执行前务必确认 db/backups/ 有备份。
|
||
|
||
-- ---------------------------------------------------------------------------
|
||
-- ① 下线自动去重:DROP 三列 + 两个 HNSW 索引
|
||
-- ---------------------------------------------------------------------------
|
||
DROP INDEX IF EXISTS brand_runway_images_phash_hnsw;
|
||
DROP INDEX IF EXISTS street_snap_images_phash_hnsw;
|
||
|
||
ALTER TABLE brand_runway_images
|
||
DROP COLUMN IF EXISTS phash,
|
||
DROP COLUMN IF EXISTS is_duplicate,
|
||
DROP COLUMN IF EXISTS dup_of;
|
||
|
||
ALTER TABLE street_snap_images
|
||
DROP COLUMN IF EXISTS phash,
|
||
DROP COLUMN IF EXISTS is_duplicate,
|
||
DROP COLUMN IF EXISTS dup_of;
|
||
|
||
-- ---------------------------------------------------------------------------
|
||
-- ② 删死表 image_duplicates(2026-09-21 建表后全仓零引用)
|
||
-- ---------------------------------------------------------------------------
|
||
DROP TABLE IF EXISTS image_duplicates;
|
||
|
||
-- ---------------------------------------------------------------------------
|
||
-- ③ image_embeddings 改造为可用状态
|
||
-- 该表 2026-09-21 建表后一直零引用、零数据,此处原地改造:
|
||
-- - kind: smallint -> varchar(8),对齐 dto.IngestKind("runway" / "street")
|
||
-- - embedding: vector(512) -> vector(768),M0 实测模型输出 768 维
|
||
-- - 补 (kind, image_id) 唯一索引:原表只有自增主键,
|
||
-- 不加唯一约束会导致同一张图重复写入多行
|
||
-- ---------------------------------------------------------------------------
|
||
ALTER TABLE image_embeddings
|
||
ALTER COLUMN kind TYPE varchar(8) USING kind::varchar(8),
|
||
ALTER COLUMN embedding TYPE vector(768);
|
||
|
||
-- 旧向量维度与新维度不同,必须重建 HNSW 索引(pgvector 不允许改列类型后沿用旧索引)
|
||
DROP INDEX IF EXISTS image_embeddings_vec_idx;
|
||
|
||
CREATE UNIQUE INDEX IF NOT EXISTS uq_image_embeddings_kind_image
|
||
ON image_embeddings (kind, image_id);
|
||
|
||
CREATE INDEX IF NOT EXISTS image_embeddings_vec_idx
|
||
ON image_embeddings USING hnsw (embedding vector_cosine_ops);
|
||
```
|
||
|
||
- [ ] **步骤 3:在库上执行并验证**
|
||
|
||
运行:
|
||
```bash
|
||
psql "$DATABASE_URL" -f db/migrations/2026-10-12-01-drop-dedup-add-embeddings.sql
|
||
psql "$DATABASE_URL" -c "\d image_embeddings"
|
||
```
|
||
预期:`image_embeddings` 的 `kind` 为 `character varying(8)`、`embedding` 为 `vector(768)`,且存在 `uq_image_embeddings_kind_image` 与 `image_embeddings_vec_idx`。
|
||
|
||
- [ ] **步骤 4:更新 migrations/README.md**
|
||
|
||
在表格中追加一行:
|
||
```
|
||
| 6 | `2026-10-12-01-drop-dedup-add-embeddings.sql` | 下线 pHash 去重(DROP `phash`/`is_duplicate`/`dup_of` + HNSW 索引 + 死表 `image_duplicates`);`image_embeddings` 的 `kind` 改 `varchar(8)`、`embedding` 改 `vector(768)`,补唯一索引并重建 HNSW。**不可逆**。 |
|
||
|
||
- [ ] **步骤 5:Commit**
|
||
|
||
```bash
|
||
git add db/migrations/2026-10-12-01-drop-dedup-add-embeddings.sql db/migrations/README.md
|
||
git commit -m "feat(db): 下线 pHash 去重并改造 image_embeddings 存SigLIP 向量"
|
||
```
|
||
|
||
---
|
||
|
||
### 任务 2:删除 phash 包与模型字段
|
||
|
||
**文件:**
|
||
- 删除:`internal/pkg/phash/phash.go`、`internal/pkg/phash/phash_test.go`
|
||
- 修改:`internal/model/runway_image.go`、`internal/model/street_snap.go`
|
||
|
||
- [ ] **步骤 1:删除前确认无其他引用**
|
||
|
||
运行:`grep -rn "pkg/phash" --include=*.go .`
|
||
预期:仅 `internal/service/ingest_service.go`、`internal/repository/{ingest,street_snap,article}_repository.go` 引用(任务 3、5 会清掉)。`model` 包**不应**出现——若出现说明模型层直接依赖了 phash 包,停下来报告。
|
||
|
||
- [ ] **步骤 2:删除包**
|
||
|
||
```bash
|
||
rm -rf internal/pkg/phash
|
||
```
|
||
|
||
- [ ] **步骤 3:删模型字段**
|
||
|
||
在 `internal/model/runway_image.go` 的 `BrandRunwayImage` 中删除三个字段及其注释:
|
||
```go
|
||
Phash sql.NullString // dHash 指纹(pgvector 二进制向量串),NULL 表示无法解码
|
||
IsDuplicate uint8
|
||
DupOf string // 指向近重复的源图 id(空串表示非重复)
|
||
```
|
||
|
||
在 `internal/model/street_snap.go` 的街拍图片结构体中删除同名的 `Phash` / `IsDuplicate` / `DupOf` 三个字段。
|
||
|
||
- [ ] **步骤 4:确认模型层已无 phash 引用**
|
||
|
||
运行:`grep -rn "Phash\|IsDuplicate\|DupOf" internal/model/`
|
||
预期:无输出。
|
||
|
||
- [ ] **步骤 5:Commit**
|
||
|
||
```bash
|
||
git add -A internal/pkg internal/model
|
||
git commit -m "refactor: 删除 phash 包与图片模型的去重字段"
|
||
```
|
||
|
||
---
|
||
|
||
### 任务 3:删除入库去重逻辑
|
||
|
||
**文件:**
|
||
- 修改:`internal/service/ingest_service.go`
|
||
|
||
- [ ] **步骤 1:删 `dedupImage` 函数**
|
||
|
||
删除整个函数(原 721-747 行)及其上方注释:
|
||
```go
|
||
// dedupImage 判断单张图是否近重复(dHash 汉明距离 ≤ 阈值):命中则仍入库,但标记 is_duplicate + dup_of。
|
||
func (s *IngestService) dedupImage(ctx context.Context, kind, phashBits string, timing *fetchTiming) (dupID uint32, isDup uint8) {
|
||
```
|
||
该函数整体删除,含内部对 `s.repo.FindNearDuplicateImage` 的调用与 `dto.IngestKind` 的 tables 分支。
|
||
|
||
- [ ] **步骤 2:删并发段的 phash 计算**
|
||
|
||
在每张图的抓取闭包中删除(原841 行附近)这两行:
|
||
```go
|
||
phashStart := time.Now()
|
||
res.phashBits = phash.ToVectorBits(phash.Of(data))
|
||
res.phash = time.Since(phashStart)
|
||
```
|
||
删除后该闭包只剩下载与上传。**不要留`res.phashBits = ""` 之类的占位赋值**——字段本身会在下一步被删掉,留赋值会编译不过。任务 10 会在同一位置插入 embed 调用。
|
||
|
||
- [ ] **步骤 3:删 `downloadResult` 的 phash 字段**
|
||
|
||
删除结构体中的:
|
||
```go
|
||
phashBits string // dHash 向量串,空串表示无法解码
|
||
phash time.Duration // 本张指纹计算耗时
|
||
```
|
||
并删除其上方关于 phash 的注释行。
|
||
|
||
- [ ] **步骤 4:删 `fetchTiming` 的 phash / dedup 字段与格式化**
|
||
|
||
删除 `fetchTiming` 结构体中的 `phash`、`dedup` 两个字段,并把 `formatTiming` 简化为只输出 download / upload(`embed` 字段由任务 10 添加,本步**不要**引用它,否则编译不过):
|
||
|
||
```go
|
||
func (t *fetchTiming) String() string {
|
||
// 注意:下载 / 上传是「各张图耗时之和」,并发执行下其总和会大于墙钟总耗时,
|
||
// 这是预期行为,不代表超时。
|
||
return fmt.Sprintf("图片=%d 并发=%d 下载=%v 上传=%v(下载/上传为各张累计值)",
|
||
t.total, t.concurrency,
|
||
t.download.Round(time.Millisecond),
|
||
t.upload.Round(time.Millisecond))
|
||
}
|
||
```
|
||
|
||
- [ ] **步骤 5:删串行段的去重调用与落库赋值**
|
||
|
||
删除两处 `dedupImage` 调用(走秀约 467 行、街拍约 626 行):
|
||
```go
|
||
dupID, isDup := s.dedupImage(ctx, dto.IngestKindRunway, r.phashBits, timing)
|
||
```
|
||
改为:
|
||
```go
|
||
dupID, isDup := uint32(0), uint8(0)
|
||
```
|
||
|
||
删除所有落库处的 `Phash:` 与 `IsDuplicate:` 赋值(约 343/344、483/484、570/571 行)。
|
||
|
||
- [ ] **步骤 6:编译验证**
|
||
|
||
运行:`go build ./...`
|
||
预期:**此时会报错**,因为仓储层 `FindNearDuplicateImage` 还在但已无调用方(这属于预期,任务 5 清理);`ingest_service.go` 自身不应有「未使用」错误。若报 `phash` 未使用等错误,就地清理残留 import。
|
||
|
||
- [ ] **步骤 7:Commit**
|
||
|
||
```bash
|
||
git add internal/service/ingest_service.go
|
||
git commit -m "refactor(ingest): 移除入库去重判定与 dHash 计算"
|
||
```
|
||
|
||
---
|
||
|
||
### 任务 4:删除删图/删文章时的 dup_of 重算逻辑
|
||
|
||
**文件:**
|
||
- 修改:`internal/service/article_service.go`(约 297-354 行)
|
||
- 修改:`internal/service/street_snap_service.go`(约 398-449 行)
|
||
|
||
- [ ] **步骤 1:删走秀侧重算**
|
||
|
||
删除 `article_service.go` 中三处带「删图后重算本画廊去重」「跨文章清理」「下架前先取存活图片 id」注释的代码块。这些块的目的仅是维护 `dup_of` 标记,去重下线后不再需要。
|
||
|
||
删除后,删图流程应只剩下:取存活图片 id → 软删 / 物理删 → 返回。
|
||
|
||
- [ ] **步骤 2:删街拍侧重算**
|
||
|
||
同样删除 `street_snap_service.go` 中对应的三处重算代码块。
|
||
|
||
- [ ] **步骤 3:确认残留清理函数已无调用**
|
||
|
||
运行:`grep -rn "dup_of\|DupOf\|IsDuplicate" internal/service/`
|
||
预期:无输出。
|
||
|
||
- [ ] **步骤 4:编译验证**
|
||
|
||
运行:`go build ./...`
|
||
预期:通过(仓储层残留调用在任务 5 清理;若 `article_service.go`/`street_snap_service.go` 内有查询返回 `dup_of` 的语句被删漏,编译会报错,据此补齐)。
|
||
|
||
- [ ] **步骤 5:Commit**
|
||
|
||
```bash
|
||
git add internal/service/article_service.go internal/service/street_snap_service.go
|
||
git commit -m "refactor: 移除删图/删文章时的 dup_of 重算逻辑"
|
||
```
|
||
|
||
---
|
||
|
||
### 任务 5:删除仓储层去重查询与三列写入
|
||
|
||
**文件:**
|
||
- 修改:`internal/repository/ingest_repository.go`
|
||
- 修改:`internal/repository/article_repository.go`(约 430 行)
|
||
- 修改:`internal/repository/street_snap_repository.go`(约 372 行)
|
||
- 修改:`internal/repository/admin_repository.go`
|
||
|
||
- [ ] **步骤 1:删 `FindNearDuplicateImage`**
|
||
|
||
删除 `ingest_repository.go` 中整个 `FindNearDuplicateImage` 方法(约 575-600 行)及其在 `IngestRepository` 接口中的声明(约 26 行起的接口体内),连同上方解释 `phashBits 为 vector(64) 二进制向量串` 的注释。
|
||
|
||
- [ ] **步骤 2:删写入语句中的三列**
|
||
|
||
在 `CreateRunwayWithImages`、`ReuseRejectedRunway`、`CreateStreetSnapWithImages` 等所有 INSERT 语句中删除 `phash`、`is_duplicate`、`dup_of` 三列及其占位符与参数绑定。
|
||
|
||
- [ ] **步骤 3:删 `phash <->` 近邻查询**
|
||
|
||
删除 `article_repository.go`(约 430 行)与 `street_snap_repository.go`(约 372 行)中的:
|
||
```go
|
||
Select("id, (phash <-> ?::vector) AS dist", phashBits).
|
||
Where("(phash <-> ?::vector) <= ?", phashBits, l2Limit).
|
||
```
|
||
连同其所属方法(若该方法只服务于去重,整体删除)及相关 `l2Limit` 计算。
|
||
|
||
- [ ] **步骤 4:删重复率统计**
|
||
|
||
删除 `admin_repository.go` 中基于 `is_duplicate` 的重复张数统计方法及其在接口中的声明。
|
||
|
||
- [ ] **步骤 5:编译验证**
|
||
|
||
运行:`go build ./...`
|
||
预期:通过。若 `phash` 包 import 变成未使用,删除该 import。
|
||
|
||
- [ ] **步骤 6:Commit**
|
||
|
||
```bash
|
||
git add internal/repository
|
||
git commit -m "refactor(repo): 移除 phash 近邻查询、去重三列写入与重复率统计"
|
||
```
|
||
|
||
---
|
||
|
||
### 任务 6:删除后台重复徽章与重复率展示
|
||
|
||
**文件:**
|
||
- 修改:`internal/handler/backstage_handler.go`
|
||
- 修改:`internal/handler/templates/partials.html`(约 62 行)
|
||
- 修改:`internal/handler/assets/admin.js`
|
||
|
||
- [ ] **步骤 1:删图集装配中的重复字段**
|
||
|
||
在 `backstage_handler.go` 约 677-735 行的图集 cells 装配处,删除所有 `isDuplicate` 与 `dupOf` 键,并把 `var dupCount int` 及其 `if im.IsDuplicate == 1 { dupCount++ }` 一并删除。
|
||
|
||
- [ ] **步骤 2:删重复率统计与 `buildDupSources`**
|
||
|
||
删除约 333 行、388 行的批量重复率统计调用,以及约 961 行起的整个 `buildDupSources` 函数(含其上方注释)。
|
||
|
||
- [ ] **步骤 3:删模板徽章**
|
||
|
||
在 `partials.html` 删除:
|
||
```html
|
||
{{if eq .isDuplicate 1}}<span class="badge dup">重复</span>
|
||
```
|
||
以及与之配套的折叠/隐藏 CSS 类(`.badge.dup`)与包裹它的条件渲染结构。
|
||
|
||
- [ ] **步骤 4:删前端逻辑**
|
||
|
||
在 `admin.js` 中删除读取 `isDuplicate` / `dupOf` 并渲染重复徽章、隐藏重复图的代码段;同时删除模板中对应的折叠样式引用。
|
||
|
||
- [ ] **步骤 5:跑测试**
|
||
|
||
运行:`go test ./... 2>&1 | Select-String -Pattern "FAIL"`
|
||
预期:无 FAIL(既有与去重相关的集成测试会在任务 7 统一处理;若此处出现因本任务改动导致的失败,就地修复)。
|
||
|
||
- [ ] **步骤 6:Commit**
|
||
|
||
```bash
|
||
git add internal/handler
|
||
git commit -m "refactor(backstage): 移除重复徽章、折叠与重复率展示"
|
||
```
|
||
|
||
---
|
||
|
||
### 任务 7:清理测试、命令与文档
|
||
|
||
**文件:**
|
||
- 删除:`internal/repository/dedup_integration_test.go`
|
||
- 删除:`cmd/diagdup/`(整目录)
|
||
- 修改:`internal/repository/editor_image_delete_integration_test.go`
|
||
- 修改:`internal/repository/ingest_single_table_integration_test.go`
|
||
- 修改:`internal/service/ingest_image_count_test.go`
|
||
- 修改:`cmd/dbtool/main.go`
|
||
- 修改:`README.md`
|
||
|
||
- [ ] **步骤 1:删去重专用测试与诊断命令**
|
||
|
||
```bash
|
||
rm internal/repository/dedup_integration_test.go
|
||
rm -rf cmd/diagdup
|
||
```
|
||
|
||
- [ ] **步骤 2:清理仍引用 `phash.ToVectorBits` 的测试**
|
||
|
||
在 `editor_image_delete_integration_test.go` 与 `ingest_single_table_integration_test.go` 中,删除所有 `phash.ToVectorBits(...)` 相关的测试数据构造与去重断言(含 `allOne` / `bVec` / `cVec` 之类的指纹变量)。
|
||
|
||
- [ ] **步骤 3:清理 fake 仓储方法**
|
||
|
||
在 `ingest_image_count_test.go` 的 fake 仓储中删除 `FindNearDuplicateImage` 方法定义(约 38 行)。
|
||
|
||
- [ ] **步骤 4:清理 dbtool 引用**
|
||
|
||
在 `cmd/dbtool/main.go` 中删除对 `phash` 包或去重相关逻辑的引用;若无直接引用则无需改动本文件。
|
||
|
||
- [ ] **步骤 5:全仓扫描残留**
|
||
|
||
运行:
|
||
```bash
|
||
grep -rn "phash\|Phash\|is_duplicate\|IsDuplicate\|dup_of\|DupOf" --include=*.go --include=*.html --include=*.js .
|
||
```
|
||
预期:**零输出**。有残留就逐个清理干净。
|
||
|
||
- [ ] **步骤 6:更新 README**
|
||
|
||
在 `README.md` 中删除 pHash / 自动去重的描述,改为说明入库时产出768 维 FashionSigLIP 向量写入 `image_embeddings`,供后台「疑似同款」提示使用。
|
||
|
||
- [ ] **步骤 7:跑全量测试**
|
||
|
||
运行:`go test ./...`
|
||
预期:全部通过。
|
||
|
||
- [ ] **步骤 8:Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "test: 清理去重相关测试与诊断命令,更新 README"
|
||
```
|
||
|
||
**阶段一完成。此时系统行为变化:前台不再自动折叠任何重复图,全部平铺,靠人工清理。这是预期结果。**
|
||
|
||
---
|
||
|
||
## 阶段二:SigLIP 集成
|
||
|
||
### 任务 8:新增 VisionConfig
|
||
|
||
**文件:**
|
||
- 修改:`internal/config/config.go`
|
||
- 修改:`configs/config.yml`
|
||
|
||
- [ ] **步骤 1:加配置结构体**
|
||
|
||
在 `config.go` 的 `S4Config` 定义之后追加:
|
||
```go
|
||
// VisionConfig 视觉服务(FashionSigLIP)配置。
|
||
//
|
||
// 入库时把每张图编码成 768 维归一化向量写入 image_embeddings,
|
||
// 后台图集页据此提示「疑似同款」。服务不可用时自动降级为「不产出向量」,
|
||
// 绝不阻断入库。
|
||
type VisionConfig struct {
|
||
// Enabled 总开关;关闭则完全跳过 embed 调用。
|
||
Enabled bool `yaml:"enabled"`
|
||
// Endpoint 服务地址,仅监听内网。env: VISION_ENDPOINT
|
||
Endpoint string `yaml:"endpoint"`
|
||
// TimeoutSeconds 单批 embed 超时(秒),默认 5。
|
||
TimeoutSeconds int `yaml:"timeout_seconds"`
|
||
// Dim 向量维度,必须与模型一致;M0 实测为 768。
|
||
Dim int `yaml:"dim"`
|
||
// SimilarThreshold 「疑似同款」余弦相似度阈值,须经样本校准。
|
||
SimilarThreshold float64 `yaml:"similar_threshold"`
|
||
// FailThreshold 连续失败多少次后熔断冷却。
|
||
FailThreshold int `yaml:"fail_threshold"`
|
||
}
|
||
```
|
||
|
||
- [ ] **步骤 2:挂到 Config**
|
||
|
||
在 `Config` 结构体中 `S4 S4Config \`yaml:"s4"\`` 之后追加:
|
||
```go
|
||
// Vision 视觉服务(FashionSigLIP)配置,用于「疑似同款」提示。
|
||
Vision VisionConfig `yaml:"vision"`
|
||
```
|
||
|
||
- [ ] **步骤 3:加默认值归一**
|
||
|
||
在该文件的 `normalize`(或同名默认值处理函数)中追加:
|
||
```go
|
||
if c.Vision.Endpoint == "" {
|
||
c.Vision.Endpoint = "http://127.0.0.1:8900"
|
||
}
|
||
if c.Vision.TimeoutSeconds <= 0 {
|
||
c.Vision.TimeoutSeconds = 5
|
||
}
|
||
if c.Vision.Dim <= 0 {
|
||
c.Vision.Dim = 768
|
||
}
|
||
if c.Vision.FailThreshold <= 0 {
|
||
c.Vision.FailThreshold = 3
|
||
}
|
||
if c.Vision.SimilarThreshold <= 0 {
|
||
c.Vision.SimilarThreshold = 0.68
|
||
}
|
||
```
|
||
|
||
> `SimilarThreshold` 默认值取 0.68:实测同套连拍(`c1`/`c2`)相似度仅 0.6965,初稿的 0.85 会漏掉最该命中的样本。任务 11 后需用真实样本校准。
|
||
|
||
- [ ] **步骤 4:写 yml**
|
||
|
||
在 `configs/config.yml` 追加:
|
||
```yaml
|
||
vision:
|
||
enabled: false
|
||
endpoint: "http://127.0.0.1:8900"
|
||
timeout_seconds: 5
|
||
dim: 768
|
||
similar_threshold: 0.68
|
||
fail_threshold: 3
|
||
```
|
||
|
||
- [ ] **步骤 5:编译验证**
|
||
|
||
运行:`go build ./...`
|
||
预期:通过。
|
||
|
||
- [ ] **步骤 6:Commit**
|
||
|
||
```bash
|
||
git add internal/config configs
|
||
git commit -m "feat(config): 新增 VisionConfig(视觉服务 / 疑似同款阈值)"
|
||
```
|
||
|
||
---
|
||
|
||
### 任务 9:视觉服务客户端
|
||
|
||
**文件:**
|
||
- 创建:`internal/vision/client.go`
|
||
- 创建:`internal/vision/client_test.go`
|
||
|
||
- [ ] **步骤 1:编写失败的测试**
|
||
|
||
创建 `internal/vision/client_test.go`:
|
||
```go
|
||
package vision
|
||
|
||
import (
|
||
"context"
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// 1x1 纯色 PNG,作为最小合法图片字节。
|
||
const tinyPNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
|
||
|
||
func TestEmbedReturnsVectorsAndSkipsBroken(t *testing.T) {
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
if r.URL.Path != "/embed" {
|
||
t.Errorf("路径不对: %s", r.URL.Path)
|
||
}
|
||
json.NewEncoder(w).Encode(map[string]any{
|
||
"dim": 768,
|
||
"vectors": [][]float32{make([]float32, 768), make([]float32, 768)},
|
||
"indexes": []int{0, 2},
|
||
"skipped": []int{1},
|
||
"device": "cpu",
|
||
})
|
||
}))
|
||
defer srv.Close()
|
||
|
||
c := New(srv.URL, 768, 2*time.Second, 3)
|
||
vecs, skipped, err := c.Embed(context.Background(), [][]byte{[]byte("a"), []byte("b"), []byte("c")})
|
||
if err != nil {
|
||
t.Fatalf("Embed 返回错误: %v", err)
|
||
}
|
||
if len(vecs) != 2 {
|
||
t.Fatalf("应有 2 个向量,得到 %d", len(vecs))
|
||
}
|
||
if len(skipped) != 1 || skipped[0] != 1 {
|
||
t.Fatalf("skipped 应为 [1],得到 %v", skipped)
|
||
}
|
||
if len(vecs[0]) != 768 {
|
||
t.Fatalf("向量维度应为 768,得到 %d", len(vecs[0]))
|
||
}
|
||
}
|
||
|
||
func TestEmbedDisabledSkipsCall(t *testing.T) {
|
||
called := false
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
called = true
|
||
}))
|
||
defer srv.Close()
|
||
|
||
c := New(srv.URL, 768, time.Second, 3)
|
||
c.Disable()
|
||
vecs, _, err := c.Embed(context.Background(), [][]byte{[]byte("x")})
|
||
if err != nil || vecs != nil {
|
||
t.Fatalf("禁用时应返回 nil,nil,实际 %v %v", vecs, err)
|
||
}
|
||
if called {
|
||
t.Fatal("禁用时不应发起 HTTP 请求")
|
||
}
|
||
}
|
||
|
||
func TestEmbedDimensionMismatchIsError(t *testing.T) {
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
json.NewEncoder(w).Encode(map[string]any{
|
||
"dim": 512, "vectors": [][]float32{make([]float32, 512)}, "indexes": []int{0}, "skipped": []int{},
|
||
})
|
||
}))
|
||
defer srv.Close()
|
||
|
||
c := New(srv.URL, 768, time.Second, 3)
|
||
if _, _, err := c.Embed(context.Background(), [][]byte{[]byte("x")}); err == nil {
|
||
t.Fatal("维度不符应返回错误")
|
||
}
|
||
}
|
||
|
||
func TestEmbedTripBreakerThenCooldownSkips(t *testing.T) {
|
||
// 服务总是 500,验证连续失败达到阈值后熔断、不再发起请求。
|
||
hits := 0
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
hits++
|
||
w.WriteHeader(http.StatusInternalServerError)
|
||
}))
|
||
defer srv.Close()
|
||
|
||
c := New(srv.URL, 768, time.Second, 3)
|
||
for i := 0; i < 3; i++ {
|
||
if _, _, err := c.Embed(context.Background(), [][]byte{[]byte("x")}); err == nil {
|
||
t.Fatalf("第 %d 次调用应报错", i+1)
|
||
}
|
||
}
|
||
if c.Open() {
|
||
t.Fatal("熔断后 Open 应为 false")
|
||
}
|
||
before := hits
|
||
if _, _, err := c.Embed(context.Background(), [][]byte{[]byte("x")}); err != nil {
|
||
t.Fatalf("熔断期应直接返回错误而非请求: %v", err)
|
||
}
|
||
if hits != before {
|
||
t.Fatalf("熔断期间不应再发起请求,hits 从 %d 变为 %d", before, hits)
|
||
}
|
||
}
|
||
|
||
func TestEmbedSendsBase64Payload(t *testing.T) {
|
||
var got struct {
|
||
Images []string `json:"images"`
|
||
}
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
json.NewDecoder(r.Body).Decode(&got)
|
||
json.NewEncoder(w).Encode(map[string]any{
|
||
"dim": 768, "vectors": [][]float32{make([]float32, 768)}, "indexes": []int{0}, "skipped": []int{},
|
||
})
|
||
}))
|
||
defer srv.Close()
|
||
|
||
c := New(srv.URL, 768, time.Second, 3)
|
||
if _, _, err := c.Embed(context.Background(), [][]byte{[]byte("hello")}); err != nil {
|
||
t.Fatalf("Embed 返回错误: %v", err)
|
||
}
|
||
want := base64.StdEncoding.EncodeToString([]byte("hello"))
|
||
if len(got.Images) != 1 || got.Images[0] != want {
|
||
t.Fatalf("请求体 base64 不正确: %v", got.Images)
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **步骤 2:运行测试验证失败**
|
||
|
||
运行:`go test ./internal/vision/ -v`
|
||
预期:FAIL,报错 `undefined: New`、`undefined: Client` 之类(包内尚无实现)。
|
||
|
||
- [ ] **步骤 3:实现客户端**
|
||
|
||
创建 `internal/vision/client.go`:
|
||
```go
|
||
// Package vision 调用独立部署的 FashionSigLIP 视觉服务,把图片编码成归一化向量。
|
||
//
|
||
// 设计要点:
|
||
// - 服务不可用、超时、维度不符一律返回错误,调用方据此「不写向量」而非中断入库;
|
||
// - 连续失败达到阈值后熔断冷却,避免每张图都卡在超时上;
|
||
// - 熔断期间不发起任何 HTTP 请求。
|
||
package vision
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"net/http"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
// MaxBatch 单次请求的最大图片数,与服务端限制保持一致。
|
||
const MaxBatch = 32
|
||
|
||
// ErrUnavailable 表示服务不可用(未启用、熔断中、网络错误、超时、维度不符)。
|
||
var ErrUnavailable = errors.New("vision service unavailable")
|
||
|
||
// ErrBatchTooLarge 表示单批图片数超过 MaxBatch。
|
||
var ErrBatchTooLarge = errors.New("vision batch too large")
|
||
|
||
const cooldown = 5 * time.Minute
|
||
|
||
// Client 是视觉服务客户端。并发安全。
|
||
type Client struct {
|
||
endpoint string
|
||
dim int
|
||
failLimit int
|
||
http *http.Client
|
||
mu sync.Mutex
|
||
fails int
|
||
cooldownEnd time.Time
|
||
disabled bool
|
||
}
|
||
|
||
// embedResponse 是服务端 POST /embed 的响应结构。
|
||
type embedResponse struct {
|
||
Dim int `json:"dim"`
|
||
Vectors [][]float32 `json:"vectors"`
|
||
Indexes []int `json:"indexes"`
|
||
Skipped []int `json:"skipped"`
|
||
Device string `json:"device"`
|
||
}
|
||
|
||
type embedRequest struct {
|
||
Images []string `json:"images"`
|
||
}
|
||
|
||
// New 构造客户端。
|
||
func New(endpoint string, dim int, timeout time.Duration, failLimit int) *Client {
|
||
if timeout <= 0 {
|
||
timeout = 5 * time.Second
|
||
}
|
||
if failLimit <= 0 {
|
||
failLimit = 3
|
||
}
|
||
return &Client{
|
||
endpoint: endpoint,
|
||
dim: dim,
|
||
failLimit: failLimit,
|
||
http: &http.Client{Timeout: timeout},
|
||
}
|
||
}
|
||
|
||
// Disable 关闭客户端,此后 Embed 直接返回 ErrUnavailable 且不发起请求。
|
||
func (c *Client) Disable() {
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
c.disabled = true
|
||
}
|
||
|
||
// Enabled 报告客户端是否可用(未禁用且不在熔断期)。
|
||
func (c *Client) Enabled() bool { return c.Open() }
|
||
|
||
// Open 报告是否可以发起请求(未禁用且不在熔断冷却期)。
|
||
func (c *Client) Open() bool {
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
return c.canRequestLocked()
|
||
}
|
||
|
||
func (c *Client) canRequestLocked() bool {
|
||
if c.disabled {
|
||
return false
|
||
}
|
||
return time.Now().After(c.cooldownEnd)
|
||
}
|
||
|
||
// fail 记录一次失败;达到阈值则进入熔断冷却。
|
||
func (c *Client) fail() {
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
c.fails++
|
||
if c.fails >= c.failLimit {
|
||
c.cooldownEnd = time.Now().Add(cooldown)
|
||
c.fails = 0
|
||
}
|
||
}
|
||
|
||
// ok 记录一次成功,清零连续失败计数。
|
||
func (c *Client) ok() {
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
c.fails = 0
|
||
}
|
||
|
||
// Embed 把图片字节批量编码为归一化向量。返回的 skipped 是无法解码的图片下标。
|
||
//
|
||
// 任何故障(未启用 / 熔断 / 网络 / 超时 / 维度不符)都返回 ErrUnavailable,
|
||
// 调用方应据此跳过写向量,而不是让入库失败。
|
||
func (c *Client) Embed(ctx context.Context, imgs [][]byte) ([][]float32, []int, error) {
|
||
if len(imgs) == 0 {
|
||
return nil, nil, nil
|
||
}
|
||
if len(imgs) > MaxBatch {
|
||
return nil, nil, ErrBatchTooLarge
|
||
}
|
||
if !c.Open() {
|
||
return nil, nil, ErrUnavailable
|
||
}
|
||
|
||
payload := embedRequest{Images: make([]string, len(imgs))}
|
||
for i, b := range imgs {
|
||
payload.Images[i] = base64.StdEncoding.EncodeToString(b)
|
||
}
|
||
body, err := json.Marshal(payload)
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint+"/embed", bytes.NewReader(body))
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
req.Header.Set("Content-Type", "application/json")
|
||
|
||
resp, err := c.http.Do(req)
|
||
if err != nil {
|
||
c.fail()
|
||
return nil, nil, ErrUnavailable
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode != http.StatusOK {
|
||
c.fail()
|
||
return nil, nil, ErrUnavailable
|
||
}
|
||
var out embedResponse
|
||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||
c.fail()
|
||
return nil, nil, ErrUnavailable
|
||
}
|
||
if out.Dim != c.dim {
|
||
// 维度不符属于配置与模型不一致,必须告警并拒写,防止污染向量列。
|
||
c.fail()
|
||
return nil, nil, fmt.Errorf("%w: dim=%d want=%d", ErrUnavailable, out.Dim, c.dim)
|
||
}
|
||
|
||
c.ok()
|
||
return out.Vectors, out.Skipped, nil
|
||
}
|
||
```
|
||
|
||
- [ ] **步骤 4:运行测试验证通过**
|
||
|
||
运行:`go test ./internal/vision/ -v`
|
||
预期:全部 PASS。
|
||
|
||
- [ ] **步骤 5:Commit**
|
||
|
||
```bash
|
||
git add internal/vision
|
||
git commit -m "feat(vision): 视觉服务客户端(批量编码 + 熔断降级)"
|
||
```
|
||
|
||
---
|
||
|
||
### 任务 10:embedding 仓储与入库集成
|
||
|
||
**文件:**
|
||
- 创建:`internal/repository/embedding_repository.go`
|
||
- 修改:`internal/service/ingest_service.go`
|
||
- 修改:`cmd/server/main.go`
|
||
- 创建:`internal/repository/embedding_repository_test.go`
|
||
|
||
- [ ] **步骤 1:写仓储层失败测试**
|
||
|
||
创建 `internal/repository/embedding_repository_test.go`:
|
||
```go
|
||
package repository
|
||
|
||
import (
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// 向量串格式化是写入正确性的关键:维度必须精确、float32 精度不能丢。
|
||
func TestFormatEmbeddingVector(t *testing.T) {
|
||
v := make([]float32, 768)
|
||
v[0] = 0.5
|
||
v[767] = -0.25
|
||
|
||
s := FormatEmbeddingVector(v)
|
||
if !strings.HasPrefix(s, "[") || !strings.HasSuffix(s, "]") {
|
||
t.Fatalf("应输出 [..] 形式的向量串,得到 %q", trunc(s))
|
||
}
|
||
if got := strings.Count(s, ",") + 1; got != 768 {
|
||
t.Fatalf("维度应为 768,得到 %d", got)
|
||
}
|
||
if !strings.Contains(s, "0.5") || !strings.Contains(s, "-0.25") {
|
||
t.Fatalf("首尾元素未按 float32 精度输出: %s", trunc(s))
|
||
}
|
||
}
|
||
|
||
// 空向量必须能安全格式化,不能 panic。
|
||
func TestFormatEmbeddingVectorEmpty(t *testing.T) {
|
||
if got := FormatEmbeddingVector(nil); got != "[]" {
|
||
t.Fatalf("nil 应格式化为 [],得到 %q", got)
|
||
}
|
||
}
|
||
|
||
func trunc(s string) string {
|
||
if len(s) > 80 {
|
||
return s[:80] + "..."
|
||
}
|
||
return s
|
||
}
|
||
```
|
||
|
||
(该测试故意写得直白,只验证「维度正确、带方括号、float32 精度不丢」这三件事。若 `splitComma`/`countDims` 与仓库内既有 helper 重名,改为方法内联实现。)
|
||
|
||
- [ ] **步骤 2:运行测试验证失败**
|
||
|
||
运行:`go test ./internal/repository/ -run TestFormatEmbeddingVector -v`
|
||
预期:FAIL,报错 `undefined: FormatEmbeddingVector`。
|
||
|
||
- [ ] **步骤 3:实现仓储层**
|
||
|
||
创建 `internal/repository/embedding_repository.go`:
|
||
```go
|
||
package repository
|
||
|
||
import (
|
||
"context"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"github.com/jackc/pgx/v5"
|
||
)
|
||
|
||
// SimilarImage 是一张「疑似同款」的近邻结果。
|
||
type SimilarImage struct {
|
||
ImageID uint32 // 近邻图片 id
|
||
OwnerID uint32 // 近邻所属走秀 / 街拍专辑 id(供前端跳转)
|
||
Similar float64 // 余弦相似度 0~1
|
||
}
|
||
|
||
// EmbeddingRepository 读写 image_embeddings 并做近邻检索。
|
||
type EmbeddingRepository interface {
|
||
// UpsertEmbedding 写入或更新一张图的向量(kind + image_id 唯一)。
|
||
UpsertEmbedding(ctx context.Context, kind string, imageID uint32, vec []float32) error
|
||
// BulkUpsertEmbedding 批量写入,单事务提交;vecs[i] 对应 ids[i](vecs[i] 为 nil 则跳过)。
|
||
BulkUpsertEmbedding(ctx context.Context, kind string, ids []uint32, vecs [][]float32) error
|
||
// FindSimilarImages 返回该图的近邻(已排除自身、已过滤软删图片)。
|
||
FindSimilarImages(ctx context.Context, kind string, imageID uint32, vec []float32, minSim float64, limit int) ([]SimilarImage, error)
|
||
}
|
||
|
||
// FormatEmbeddingVector 把向量格式化为 pgvector 的文本字面量([x,y,...])。
|
||
//
|
||
// 用 'g' + -1 位精度:float32 的每个值都能精确往返,避免向量被截断后相似度漂移。
|
||
func FormatEmbeddingVector(vec []float32) string {
|
||
var b strings.Builder
|
||
b.Grow(len(vec) * 12)
|
||
b.WriteByte('[')
|
||
for i, v := range vec {
|
||
if i > 0 {
|
||
b.WriteByte(',')
|
||
}
|
||
b.WriteString(strconv.FormatFloat(float64(v), 'g', -1, 32))
|
||
}
|
||
b.WriteByte(']')
|
||
return b.String()
|
||
}
|
||
|
||
type embeddingRepository struct {
|
||
pool *pgxpool.Pool
|
||
}
|
||
|
||
// NewEmbeddingRepository 构造 EmbeddingRepository。
|
||
func NewEmbeddingRepository(pool *pgxpool.Pool) EmbeddingRepository {
|
||
return &embeddingRepository{pool: pool}
|
||
}
|
||
|
||
func (r *embeddingRepository) UpsertEmbedding(ctx context.Context, kind string, imageID uint32, vec []float32) error {
|
||
return r.BulkUpsertEmbedding(ctx, kind, []uint32{imageID}, [][]float32{vec})
|
||
}
|
||
|
||
func (r *embeddingRepository) BulkUpsertEmbedding(ctx context.Context, kind string, ids []uint32, vecs [][]float32) error {
|
||
if len(ids) == 0 {
|
||
return nil
|
||
}
|
||
// 逐行写入而非 unnest 批量:日入库仅几百张,量级极小,
|
||
// 换来的是可读性与低出错概率。ON CONFLICT 保证同一图重复入库不会写出多行。
|
||
now := nowUnix()
|
||
for i, id := range ids {
|
||
if i >= len(vecs) || vecs[i] == nil {
|
||
continue
|
||
}
|
||
if _, err := r.pool.Exec(ctx, `
|
||
INSERT INTO image_embeddings (kind, image_id, embedding, created_at)
|
||
VALUES ($1, $2, $3::vector, $4)
|
||
ON CONFLICT (kind, image_id) DO UPDATE
|
||
SET embedding = EXCLUDED.embedding, created_at = EXCLUDED.created_at`,
|
||
kind, id, FormatEmbeddingVector(vecs[i]), now); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
`FindSimilarImages` 的实现分两步(先走 HNSW 取候选,再 join 过滤软删,保证能用上索引):
|
||
|
||
```go
|
||
func (r *embeddingRepository) FindSimilarImages(ctx context.Context, kind string, imageID uint32, vec []float32, minSim float64, limit int) ([]SimilarImage, error) {
|
||
if len(vec) == 0 || limit <= 0 {
|
||
return nil, nil
|
||
}
|
||
// 放大候选倍数:部分候选会因图片已软删被过滤掉。
|
||
candidate := limit * 5
|
||
if candidate < 20 {
|
||
candidate = 20
|
||
}
|
||
rows, err := r.pool.Query(ctx, `
|
||
SELECT e.image_id,
|
||
1 - (e.embedding <=> $1::vector) AS sim
|
||
FROM image_embeddings e
|
||
WHERE e.kind = $2
|
||
AND e.image_id <> $3
|
||
ORDER BY e.embedding <=> $1::vector
|
||
LIMIT $4`, FormatEmbeddingVector(vec), kind, imageID, candidate)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
type cand struct {
|
||
imageID uint32
|
||
sim float64
|
||
}
|
||
var cands []cand
|
||
for rows.Next() {
|
||
var c cand
|
||
if err := rows.Scan(&c.imageID, &c.sim); err != nil {
|
||
return nil, err
|
||
}
|
||
cands = append(cands, c)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, err
|
||
}
|
||
if len(cands) == 0 {
|
||
return nil, nil
|
||
}
|
||
|
||
// 第二步:按 kind join 图片表,过滤软删并取所属文章 id。
|
||
ids := make([]int32, len(cands))
|
||
for i, c := range cands {
|
||
ids[i] = int32(c.imageID)
|
||
}
|
||
var ownerTable string
|
||
switch kind {
|
||
case "runway":
|
||
ownerTable = "brand_runway_images"
|
||
case "street":
|
||
ownerTable = "street_snap_images"
|
||
default:
|
||
return nil, nil
|
||
}
|
||
ownerCol := "runway_id"
|
||
if kind == "street" {
|
||
ownerCol = "snap_id"
|
||
}
|
||
|
||
q := `SELECT i.id, i.` + ownerCol + ` FROM ` + ownerTable + ` i
|
||
WHERE i.id = ANY($1) AND i.is_deleted = 0`
|
||
out, err := r.pool.Query(ctx, q, ids)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer out.Close()
|
||
owners := map[int32]uint32{}
|
||
for out.Next() {
|
||
var id int32
|
||
var owner uint32
|
||
if err := out.Scan(&id, &owner); err != nil {
|
||
return nil, err
|
||
}
|
||
owners[id] = owner
|
||
}
|
||
if err := out.Err(); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
res := make([]SimilarImage, 0, limit)
|
||
for _, c := range cands {
|
||
owner, ok := owners[c.imageID]
|
||
if !ok {
|
||
continue // 图片已软删
|
||
}
|
||
if c.sim < minSim {
|
||
continue
|
||
}
|
||
res = append(res, SimilarImage{ImageID: c.imageID, OwnerID: owner, Similar: c.sim})
|
||
if len(res) >= limit {
|
||
break
|
||
}
|
||
}
|
||
return res, nil
|
||
}
|
||
```
|
||
|
||
> **实现提示**:`street_snap_images` 的所属列名与 `brand_runway_images` 不同(很可能一个是 `snap_id`、一个是 `runway_id`,或街拍用 `owner_id`)。**动手前先读 `internal/model/street_snap.go` 与 `internal/repository/street_snap_repository.go` 确认真实列名与表结构**,按实际情况替换上面的 `ownerTable` / `ownerCol`。不要凭猜测写死。
|
||
|
||
- [ ] **步骤 4:运行测试验证通过**
|
||
|
||
运行:`go test ./internal/repository/ -run TestFormatEmbeddingVector -v`
|
||
预期:PASS。
|
||
|
||
- [ ] **步骤 5:接入入库流程**
|
||
|
||
修改 `internal/service/ingest_service.go`:
|
||
|
||
1. 在 `IngestService` 结构体中新增字段:
|
||
```go
|
||
vision *vision.Client // 视觉服务客户端(可为 nil:未启用时跳过 embed)
|
||
embedRep repository.EmbeddingRepository // 向量写入与检索(可为 nil:跳过)
|
||
```
|
||
|
||
2. 在 `NewIngestService` 签名末尾追加两个参数 `vis *vision.Client, embedRep repository.EmbeddingRepository`,并赋值到字段上。两个参数都允许传 nil。
|
||
|
||
3. 在 `downloadResult` 结构体中新增:
|
||
```go
|
||
vec []float32 // FashionSigLIP 向量;nil 表示未产出(服务不可用/失败)
|
||
embed time.Duration // 本张向量编码耗时
|
||
```
|
||
|
||
4. 在每张图的抓取闭包中(原 phash 计算位置),替换为:
|
||
```go
|
||
if s.vision != nil && s.vision.Enabled() {
|
||
embedStart := time.Now()
|
||
vecs, _, err := s.vision.Embed(ctx, [][]byte{data})
|
||
res.embed = time.Since(embedStart)
|
||
if err == nil && len(vecs) > 0 {
|
||
res.vec = vecs[0]
|
||
} else if err != nil {
|
||
log.Printf("[ingest] embed 失败 url=%s err=%v(不写向量,继续入库)", url, err)
|
||
}
|
||
}
|
||
```
|
||
|
||
5. 在 `fetchTiming` 结构体中新增 `embed time.Duration`,并在并发段累加 `timing.embed += r.embed`(与既有 `timing.phash += ...` 同一位置),`formatTiming` 按任务 3 的新串输出。
|
||
|
||
6. 在串行段组装落库时,收集有向量的图片,落库成功后调用:
|
||
```go
|
||
if s.embedRep != nil {
|
||
var ids []uint32
|
||
var vecs [][]float32
|
||
for _, r := range results {
|
||
if r.vec != nil {
|
||
ids = append(ids, r.imageID)
|
||
veccs = append(vecs, r.vec)
|
||
}
|
||
}
|
||
if len(ids) > 0 {
|
||
if err := s.embedRep.BulkUpsertEmbedding(ctx, kind, ids, vecs); err != nil {
|
||
log.Printf("[ingest] 写向量失败 kind=%s count=%d err=%v(不影响入库)", kind, len(ids), err)
|
||
}
|
||
}
|
||
}
|
||
```
|
||
> 图片 id 需要从 `CreateRunwayWithImages` / `CreateStreetSnapWithImages` 的返回或回填中取得。**若这两个方法当前不返回新建图片的 id,需先给它们补返回值(或在插入后按 sort_order 回查 id)**,这是本步的前置改动。
|
||
|
||
- [ ] **步骤 6:在 main.go 装配**
|
||
|
||
在 `cmd/server/main.go` 中,`NewIngestService` 调用处(约 174 行)追加两个参数:
|
||
```go
|
||
var embedRep repository.EmbeddingRepository
|
||
var vis *vision.Client
|
||
if cfg.Vision.Enabled {
|
||
vis = vision.New(cfg.Vision.Endpoint, cfg.Vision.Dim, time.Duration(cfg.Vision.TimeoutSeconds)*time.Second, cfg.Vision.FailThreshold)
|
||
embedRep = repository.NewEmbeddingRepository(pool)
|
||
}
|
||
ingestSvc := service.NewIngestService(ingestRepo, brandRepo, mediaRepo, imgUp, imgDel, localUp, cfg.Ingest.DownloadProxy, translateSvc, vis, embedRep)
|
||
```
|
||
并补上 `internal/vision` 的 import。
|
||
|
||
- [ ] **步骤 7:编译并跑测试**
|
||
|
||
运行:`go build ./... && go test ./internal/service/ ./internal/repository/ ./internal/vision/`
|
||
预期:全部通过。
|
||
|
||
- [ ] **步骤 8:Commit**
|
||
|
||
```bash
|
||
git add internal/repository internal/service cmd/server
|
||
git commit -m "feat(ingest): 入库时产出并存储 FashionSigLIP 向量"
|
||
```
|
||
|
||
---
|
||
|
||
### 任务 11:后台「疑似同款」提示
|
||
|
||
**文件:**
|
||
- 创建:`internal/service/similar_service.go`
|
||
- 创建:`internal/service/similar_service_test.go`
|
||
- 修改:`internal/handler/backstage_handler.go`
|
||
- 修改:`internal/handler/templates/partials.html`
|
||
- 修改:`internal/handler/assets/admin.js`
|
||
- 修改:`cmd/server/main.go`
|
||
|
||
- [ ] **步骤 1:编写失败的测试**
|
||
|
||
创建 `internal/service/similar_service_test.go`:
|
||
```go
|
||
package service
|
||
|
||
import (
|
||
"context"
|
||
"testing"
|
||
|
||
"fashionapi/internal/repository"
|
||
)
|
||
|
||
// fakeEmbeddingRepo 模拟仓储:固定返回预置近邻。
|
||
type fakeEmbeddingRepo struct {
|
||
res []repository.SimilarImage
|
||
}
|
||
|
||
func (f *fakeEmbeddingRepo) UpsertEmbedding(ctx context.Context, kind string, imageID uint32, vec []float32) error {
|
||
return nil
|
||
}
|
||
|
||
func (f *fakeEmbeddingRepo) BulkUpsertEmbedding(ctx context.Context, kind string, ids []uint32, vecs [][]float32) error {
|
||
return nil
|
||
}
|
||
|
||
func (f *fakeEmbeddingRepo) FindSimilarImages(ctx context.Context, kind string, imageID uint32, vec []float32, minSim float64, limit int) ([]repository.SimilarImage, error) {
|
||
return f.res, nil
|
||
}
|
||
|
||
// SimilarService 需要能从仓储取到该图的向量才能查近邻,故接口需提供取向量能力。
|
||
func TestSimilarBelowThresholdIsDropped(t *testing.T) {
|
||
// 阈值过滤应在仓储查询侧完成(已传 minSim),此处验证空结果安全返回。
|
||
svc := NewSimilarService(&fakeEmbeddingRepo{}, 0.9)
|
||
got, err := svc.ForArticle(context.Background(), "runway", nil)
|
||
if err != nil {
|
||
t.Fatalf("ForArticle 返回错误: %v", err)
|
||
}
|
||
if len(got) != 0 {
|
||
t.Fatalf("无向量时应返回空结果,得到 %d", len(got))
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **步骤 2:运行测试验证失败**
|
||
|
||
运行:`go test ./internal/service/ -run TestSimilar -v`
|
||
预期:FAIL,报错 `undefined: NewSimilarService`。
|
||
|
||
- [ ] **步骤 3:实现服务**
|
||
|
||
创建 `internal/service/similar_service.go`:
|
||
```go
|
||
package service
|
||
|
||
import (
|
||
"context"
|
||
|
||
"fashionapi/internal/repository"
|
||
)
|
||
|
||
// SimilarHint 是一张图的「疑似同款」提示集合。
|
||
type SimilarHint struct {
|
||
ImageID uint32
|
||
OwnerID uint32
|
||
Similar float64
|
||
}
|
||
|
||
// SimilarService 组装「疑似同款」提示。
|
||
type SimilarService struct {
|
||
embedRep repository.EmbeddingRepository
|
||
threshold float64
|
||
}
|
||
|
||
// NewSimilarService 构造 SimilarService。threshold 为余弦相似度阈值。
|
||
func NewSimilarService(embedRep repository.EmbeddingRepository, threshold float64) *SimilarService {
|
||
return &SimilarService{embedRep: embedRep, threshold: threshold}
|
||
}
|
||
|
||
// ListVectors 取一批图片的已存向量,供 ForArticle 查询近邻使用。
|
||
// embedRep 为 nil(视觉服务未启用)时返回 nil map,调用方据此跳过。
|
||
func (s *SimilarService) ListVectors(ctx context.Context, kind string, imageIDs []uint32) (map[uint32][]float32, error) {
|
||
if s == nil || s.embedRep == nil || len(imageIDs) == 0 {
|
||
return nil, nil
|
||
}
|
||
return s.embedRep.ListEmbeddings(ctx, kind, imageIDs)
|
||
}
|
||
|
||
// ForArticle 为一个图集的所有图片查询「疑似同款」提示。
|
||
//
|
||
// vecByImage 提供每张图的已存向量;为 nil 的图片不参与查询。
|
||
// 结果按图片 id 归并,便于前端按图挂标签。
|
||
func (s *SimilarService) ForArticle(ctx context.Context, kind string, vecByImage map[uint32][]float32) (map[uint32][]SimilarHint, error) {
|
||
out := map[uint32][]SimilarHint{}
|
||
if s == nil || s.embedRep == nil || len(vecByImage) == 0 {
|
||
return out, nil
|
||
}
|
||
for imageID, vec := range vecByImage {
|
||
if len(vec) == 0 {
|
||
continue
|
||
}
|
||
similar, err := s.embedRep.FindSimilarImages(ctx, kind, imageID, vec, s.threshold, 5)
|
||
if err != nil {
|
||
// 单张查询失败不影响其它图片,也不影响页面渲染。
|
||
continue
|
||
}
|
||
if len(similar) == 0 {
|
||
continue
|
||
}
|
||
hints := make([]SimilarHint, 0, len(similar))
|
||
for _, it := range similar {
|
||
hints = append(hints, SimilarHint{ImageID: it.ImageID, OwnerID: it.OwnerID, Similar: it.Similar})
|
||
}
|
||
out[imageID] = hints
|
||
}
|
||
return out, nil
|
||
}
|
||
```
|
||
|
||
> **实现提示**:`FindSimilarImages` 只查近邻,但**没有取回查询图自身向量**的能力。后台要跑这个查询,必须先把该图集所有图片的向量取出来(新增 `EmbeddingRepository.ListEmbeddings(ctx, kind, imageIDs) (map[uint32][]float32, error)`,一条 SQL 批量取)。**动手前先补这个仓储方法**,否则本任务无法完成。过滤 `is_deleted = 0` 在仓储层做。
|
||
|
||
- [ ] **步骤 4:运行测试验证通过**
|
||
|
||
运行:`go test ./internal/service/ -run TestSimilar -v`
|
||
预期:PASS。
|
||
|
||
- [ ] **步骤 5:补仓储取向量方法**
|
||
|
||
在 `internal/repository/embedding_repository.go` 的接口与实现中追加:
|
||
```go
|
||
// ListEmbeddings 批量取一个图集图片的已存向量;无向量的图片不出现在结果中。
|
||
ListEmbeddings(ctx context.Context, kind string, imageIDs []uint32) (map[uint32][]float32, error)
|
||
```
|
||
实现用 `WHERE kind = $1 AND image_id = ANY($2) SELECT image_id, embedding`,pgx 能把 `vector` 直接扫成字符串再解析为 `[]float32`。
|
||
|
||
- [ ] **步骤 6:在图集 handler 挂提示**
|
||
|
||
修改 `backstage_handler.go` 的图集装配处(任务 6 清理重复字段后的同一位置):
|
||
|
||
1. 收集该图集所有图片 id,取回向量:
|
||
```go
|
||
ids := make([]uint32, 0, len(imgs))
|
||
for _, im := range imgs {
|
||
ids = append(ids, im.ID)
|
||
}
|
||
vecByImage, err := h.similar.ListVectors(ctx, kind, ids)
|
||
```
|
||
2. 查提示并按图片 id 装配进 cells:
|
||
```go
|
||
hints, _ := h.similar.ForArticle(ctx, kind, vecByImage)
|
||
```
|
||
在每个 cell 的 `gin.H{...}` 里追加:
|
||
```go
|
||
"similarHints": hints[m.ID],
|
||
```
|
||
3. `SimilarService` 上增加薄封装 `ListVectors`,内部转调 `embedRep.ListEmbeddings`,embedRep 为 nil 时返回 nil。
|
||
|
||
- [ ] **步骤 7:模板与前端展示**
|
||
|
||
在 `partials.html` 的图集 cell 渲染处,在原「重复」徽章的位置改为渲染同款提示:
|
||
```html
|
||
{{range .similarHints}}
|
||
<a class="badge similar" href="/backstage/image/{{.ImageID}}" title="疑似同款(相似度 {{printf "%.2f" .Similar}})">疑似同款 {{printf "%.2f" .Similar}}</a>
|
||
{{end}}
|
||
```
|
||
并在 CSS 中新增 `.badge.similar` 样式(建议琥珀色系,与红色「重复」区分开,避免误认为已折叠)。
|
||
|
||
前端 `admin.js` 无需折叠逻辑——同款提示**不做隐藏**,仅展示角标。
|
||
|
||
- [ ] **步骤 8:装配并验证**
|
||
|
||
在 `cmd/server/main.go` 中构造 `similarSvc` 并注入 `BackstageHandler`(该 handler 构造函数需新增一个参数)。
|
||
|
||
运行:`go build ./... && go test ./...`
|
||
预期:全部通过。
|
||
|
||
- [ ] **步骤 9:Commit**
|
||
|
||
```bash
|
||
git add internal/service internal/repository internal/handler cmd/server
|
||
git commit -m "feat(backstage): 图集页展示「疑似同款」提示"
|
||
```
|
||
|
||
---
|
||
|
||
## 任务 12:端到端验证
|
||
|
||
**文件:** 无代码改动,仅验证。
|
||
|
||
- [ ] **步骤 1:本地起视觉服务(GPU)**
|
||
|
||
```powershell
|
||
cd D:\project\fashion-siglip
|
||
.\.venv\Scripts\python.exe -m uvicorn app:app --host 127.0.0.1 --port 8900 --workers 1
|
||
```
|
||
预期日志:`模型就绪:device=cuda dim=768`。
|
||
|
||
- [ ] **步骤 2:打开视觉开关**
|
||
|
||
把 `configs/config.yml` 中 `vision.enabled` 改为 `true`。
|
||
|
||
- [ ] **步骤 3:触发一次入库**
|
||
|
||
用 spider 提交一条走秀或街拍数据入库。
|
||
|
||
预期日志出现 `[ingest] embed 失败` **或** 不出现该行(取决于服务可达);入库本身必须成功。
|
||
|
||
- [ ] **步骤 4:验证向量入库**
|
||
|
||
```sql
|
||
SELECT kind, count(*), min(vector_dims(embedding)) FROM image_embeddings GROUP BY kind;
|
||
```
|
||
预期:有行数,`vector_dims` 为 768。
|
||
|
||
- [ ] **步骤 5:验证后台提示**
|
||
|
||
打开后台某图集页,确认出现「疑似同款」角标,且该图**没有被隐藏**(验证「只提示不折叠」这一关键决策)。
|
||
|
||
- [ ] **步骤 6:验证降级**
|
||
|
||
停掉视觉服务,再提交一次入库。
|
||
预期:入库成功,日志出现 `[ingest] embed 失败 ...(不写向量,继续入库)`,`image_embeddings` 无新增行,后台无同款提示但页面正常。
|
||
|
||
- [ ] **步骤 7:回归确认去重已下线**
|
||
|
||
打开任一含重复图的前台页面。
|
||
预期:**所有图片平铺展示,无任何折叠、无「重复」徽章**。
|
||
|
||
- [ ] **步骤 8:Commit(仅文档)**
|
||
|
||
把实测结论补进 `README.md`(向量维度、开关配置、视觉服务启动方式),提交。
|
||
|
||
---
|
||
|
||
## 验收标准
|
||
|
||
- [ ] `go build ./...` 与 `go test ./...` 全绿
|
||
- [ ] 全仓无 `phash` / `is_duplicate` / `dup_of` 残留(`grep -rn` 零输出)
|
||
- [ ] 入库时 `image_embeddings` 写入 768 维向量
|
||
- [ ] 视觉服务不可用时入库不受影响,日志有明确告警
|
||
- [ ] 后台图集页展示「疑似同款」角标,**不隐藏任何图片**
|
||
- [ ] 前台不再有任何自动折叠 |