49 KiB
下线 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.gointernal/repository/embedding_repository.go——image_embeddings的写入与近邻查询internal/service/similar_service.go—— 「疑似同款」业务编排(阈值过滤 + 装配提示数据)internal/service/similar_service_test.godb/migrations/2026-10-12-01-drop-dedup-add-embeddings.sql
修改:
internal/config/config.go—— 新增VisionConfigconfigs/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:
-- 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:在库上执行并验证
运行:
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:删除包
rm -rf internal/pkg/phash
- 步骤 3:删模型字段
在 internal/model/runway_image.go 的 BrandRunwayImage 中删除三个字段及其注释:
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
git add -A internal/pkg internal/model
git commit -m "refactor: 删除 phash 包与图片模型的去重字段"
任务 3:删除入库去重逻辑
文件:
-
修改:
internal/service/ingest_service.go -
步骤 1:删
dedupImage函数
删除整个函数(原 721-747 行)及其上方注释:
// 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 行附近)这两行:
phashStart := time.Now()
res.phashBits = phash.ToVectorBits(phash.Of(data))
res.phash = time.Since(phashStart)
删除后该闭包只剩下载与上传。不要留res.phashBits = "" 之类的占位赋值——字段本身会在下一步被删掉,留赋值会编译不过。任务 10 会在同一位置插入 embed 调用。
- 步骤 3:删
downloadResult的 phash 字段
删除结构体中的:
phashBits string // dHash 向量串,空串表示无法解码
phash time.Duration // 本张指纹计算耗时
并删除其上方关于 phash 的注释行。
- 步骤 4:删
fetchTiming的 phash / dedup 字段与格式化
删除 fetchTiming 结构体中的 phash、dedup 两个字段,并把 formatTiming 简化为只输出 download / upload(embed 字段由任务 10 添加,本步不要引用它,否则编译不过):
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 行):
dupID, isDup := s.dedupImage(ctx, dto.IngestKindRunway, r.phashBits, timing)
改为:
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
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
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 行)中的:
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
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 删除:
{{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
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:删去重专用测试与诊断命令
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:全仓扫描残留
运行:
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
git add -A
git commit -m "test: 清理去重相关测试与诊断命令,更新 README"
阶段一完成。此时系统行为变化:前台不再自动折叠任何重复图,全部平铺,靠人工清理。这是预期结果。
阶段二:SigLIP 集成
任务 8:新增 VisionConfig
文件:
-
修改:
internal/config/config.go -
修改:
configs/config.yml -
步骤 1:加配置结构体
在 config.go 的 S4Config 定义之后追加:
// 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"`` 之后追加:
// Vision 视觉服务(FashionSigLIP)配置,用于「疑似同款」提示。
Vision VisionConfig `yaml:"vision"`
- 步骤 3:加默认值归一
在该文件的 normalize(或同名默认值处理函数)中追加:
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 追加:
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
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:
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:
// 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
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:
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:
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:
- 在
IngestService结构体中新增字段:
vision *vision.Client // 视觉服务客户端(可为 nil:未启用时跳过 embed)
embedRep repository.EmbeddingRepository // 向量写入与检索(可为 nil:跳过)
-
在
NewIngestService签名末尾追加两个参数vis *vision.Client, embedRep repository.EmbeddingRepository,并赋值到字段上。两个参数都允许传 nil。 -
在
downloadResult结构体中新增:
vec []float32 // FashionSigLIP 向量;nil 表示未产出(服务不可用/失败)
embed time.Duration // 本张向量编码耗时
- 在每张图的抓取闭包中(原 phash 计算位置),替换为:
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)
}
}
-
在
fetchTiming结构体中新增embed time.Duration,并在并发段累加timing.embed += r.embed(与既有timing.phash += ...同一位置),formatTiming按任务 3 的新串输出。 -
在串行段组装落库时,收集有向量的图片,落库成功后调用:
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 行)追加两个参数:
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
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:
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:
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 的接口与实现中追加:
// 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 清理重复字段后的同一位置):
- 收集该图集所有图片 id,取回向量:
ids := make([]uint32, 0, len(imgs))
for _, im := range imgs {
ids = append(ids, im.ID)
}
vecByImage, err := h.similar.ListVectors(ctx, kind, ids)
- 查提示并按图片 id 装配进 cells:
hints, _ := h.similar.ForArticle(ctx, kind, vecByImage)
在每个 cell 的 gin.H{...} 里追加:
"similarHints": hints[m.ID],
SimilarService上增加薄封装ListVectors,内部转调embedRep.ListEmbeddings,embedRep 为 nil 时返回 nil。
- 步骤 7:模板与前端展示
在 partials.html 的图集 cell 渲染处,在原「重复」徽章的位置改为渲染同款提示:
{{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
git add internal/service internal/repository internal/handler cmd/server
git commit -m "feat(backstage): 图集页展示「疑似同款」提示"
任务 12:端到端验证
文件: 无代码改动,仅验证。
- 步骤 1:本地起视觉服务(GPU)
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:验证向量入库
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 维向量 - 视觉服务不可用时入库不受影响,日志有明确告警
- 后台图集页展示「疑似同款」角标,不隐藏任何图片
- 前台不再有任何自动折叠