- media: spider 下载图片后按内容 sha1 生成 key,经后端预签名直传 S4,上报只带 key (spider 不持有 AK/SK;单张失败自动回退为上报 URL) - embed: 新增 embed 子命令,从后端领取缺向量的图片→下载→本地 CLIP 编码→回传 (媒体拆分后后端拿不到图片字节,向量只能靠回填补齐) - serve: 服务化常驻模式(领取 crawl 任务 → 抓取 → 回报) - config: 新增 media.upload/concurrency、vision.endpoint/concurrency
256 lines
7.3 KiB
Go
256 lines
7.3 KiB
Go
// Package embed 是「向量回填」的执行端。
|
||
//
|
||
// 为什么需要它:媒体拆分后 spider 直接传图到对象存储,后端拿到的是 key 而不是图片字节,
|
||
// 于是入库时算不出向量(image_embeddings 缺行)。这个 worker 补上这一段:
|
||
//
|
||
// 向后端要「缺向量的图片」 → 从存储下载 → 送本地 CLIP 编码 → 把向量回传给后端
|
||
//
|
||
// 它跑在**有 GPU 的机器**上,所以放在 spider 仓库里(和爬虫同一份部署物),
|
||
// 但逻辑上只在用 GPU,与抓取完全独立:不抓 Vogue、不写业务库。
|
||
package embed
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"log"
|
||
"net/http"
|
||
"sync"
|
||
"time"
|
||
|
||
"my-spiders/internal/ingest"
|
||
)
|
||
|
||
// Worker 向量回填执行端。
|
||
type Worker struct {
|
||
Client *ingest.Client // 取任务 + 回传(HMAC)
|
||
Vision string // 视觉服务地址,如 http://127.0.0.1:8900
|
||
Concurrency int // 下载并发
|
||
Batch int // 单次送编码的张数
|
||
HTTP *http.Client
|
||
}
|
||
|
||
// Stats 一轮处理的统计。
|
||
type Stats struct {
|
||
Fetched int // 后端派发的张数
|
||
Saved int // 后端确认写入的张数
|
||
Failed int // 失败张数(已计入失败计数)
|
||
Remaining int // 后端统计的剩余量(-1 = 未知)
|
||
}
|
||
|
||
// New 创建 worker。并发/批量传 0 时取默认值(4 / 8)。
|
||
func New(client *ingest.Client, vision string, concurrency, batch int) *Worker {
|
||
if concurrency <= 0 {
|
||
concurrency = 4
|
||
}
|
||
if batch <= 0 {
|
||
batch = 8
|
||
}
|
||
return &Worker{
|
||
Client: client,
|
||
Vision: vision,
|
||
Concurrency: concurrency,
|
||
Batch: batch,
|
||
// 图片可能几百 KB~几 MB,60s 足够;向量 POST 走同一个 client。
|
||
HTTP: &http.Client{Timeout: 60 * time.Second},
|
||
}
|
||
}
|
||
|
||
// RunOnce 处理一轮:拉取 → 下载 → 编码 → 回传。
|
||
//
|
||
// 不会因为单张失败而中断:下载失败/编码跳过的图进 failures,由后端累加计数,
|
||
// 连续失败到上限后自然退出队列(坏图不会无限占位)。
|
||
func (w *Worker) RunOnce(ctx context.Context, kind string, limit int) (Stats, error) {
|
||
var st Stats
|
||
pending, remaining, err := w.Client.FetchPendingEmbeddings(ctx, kind, limit)
|
||
if err != nil {
|
||
return st, err
|
||
}
|
||
st.Fetched = len(pending)
|
||
st.Remaining = remaining
|
||
if len(pending) == 0 {
|
||
log.Printf("[embed] %s 无待回填图片(取时剩余 %d)", kind, remaining)
|
||
return st, nil
|
||
}
|
||
|
||
// ① 并发下载:后端给的是可直接访问的 url,worker 不关心存储实现。
|
||
data := make([][]byte, len(pending))
|
||
errs := make([]error, len(pending))
|
||
w.parallel(len(pending), func(i int) {
|
||
body, derr := w.download(ctx, pending[i].URL)
|
||
if derr != nil {
|
||
errs[i] = derr
|
||
return
|
||
}
|
||
data[i] = body
|
||
})
|
||
|
||
// ② 编码:只把下载成功的送进去,按 Batch 分批(视觉服务对单次张数有上限)。
|
||
type job struct {
|
||
pos int
|
||
img []byte
|
||
}
|
||
jobs := make([]job, 0, len(pending))
|
||
for i := range pending {
|
||
if errs[i] == nil {
|
||
jobs = append(jobs, job{pos: i, img: data[i]})
|
||
}
|
||
}
|
||
|
||
items := make([]ingest.EmbeddingVector, 0, len(jobs))
|
||
failures := make([]ingest.EmbeddingFailure, 0, len(pending)-len(jobs))
|
||
|
||
for start := 0; start < len(jobs); start += w.Batch {
|
||
end := start + w.Batch
|
||
if end > len(jobs) {
|
||
end = len(jobs)
|
||
}
|
||
batch := jobs[start:end]
|
||
imgs := make([][]byte, len(batch))
|
||
for i := range batch {
|
||
imgs[i] = batch[i].img
|
||
}
|
||
vecs, idxs, skipped, eerr := w.embed(ctx, imgs)
|
||
if eerr != nil {
|
||
for _, b := range batch {
|
||
p := pending[b.pos]
|
||
failures = append(failures, ingest.EmbeddingFailure{Kind: kind, ImageID: p.ImageID, Error: eerr.Error()})
|
||
}
|
||
continue
|
||
}
|
||
// indexes 缺省时按位置对应;存在时严格按它映射(视觉服务可能跳过坏图)。
|
||
for j := range vecs {
|
||
posInBatch := j
|
||
if len(idxs) == len(vecs) {
|
||
posInBatch = idxs[j]
|
||
}
|
||
if posInBatch < 0 || posInBatch >= len(batch) {
|
||
continue
|
||
}
|
||
p := pending[batch[posInBatch].pos]
|
||
items = append(items, ingest.EmbeddingVector{Kind: kind, ImageID: p.ImageID, Vector: vecs[j]})
|
||
}
|
||
for _, s := range skipped {
|
||
if s < 0 || s >= len(batch) {
|
||
continue
|
||
}
|
||
p := pending[batch[s].pos]
|
||
failures = append(failures, ingest.EmbeddingFailure{Kind: kind, ImageID: p.ImageID, Error: "视觉服务跳过(图片无法解码)"})
|
||
}
|
||
}
|
||
|
||
// ③ 下载失败的也一并上报,让后端计数
|
||
for i := range pending {
|
||
if errs[i] != nil {
|
||
failures = append(failures, ingest.EmbeddingFailure{Kind: kind, ImageID: pending[i].ImageID, Error: errs[i].Error()})
|
||
}
|
||
}
|
||
|
||
if len(items) == 0 && len(failures) == 0 {
|
||
return st, nil
|
||
}
|
||
saved, failed, serr := w.Client.SubmitEmbeddings(ctx, items, failures)
|
||
if serr != nil {
|
||
return st, serr
|
||
}
|
||
st.Saved = saved
|
||
st.Failed = failed
|
||
log.Printf("[embed] %s 本轮:取到 %d 张 → 写入 %d,失败 %d(剩余 %d)",
|
||
kind, st.Fetched, st.Saved, st.Failed, st.Remaining)
|
||
return st, nil
|
||
}
|
||
|
||
// embed 调本地视觉服务编码一批图片。
|
||
// 返回的 idxs 是「vectors 对应入参下标」;skipped 是无法解码的下标。
|
||
func (w *Worker) embed(ctx context.Context, imgs [][]byte) ([][]float32, []int, []int, error) {
|
||
payload := struct {
|
||
Images []string `json:"images"`
|
||
}{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, nil, err
|
||
}
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, w.Vision+"/embed", bytes.NewReader(body))
|
||
if err != nil {
|
||
return nil, nil, nil, err
|
||
}
|
||
req.Header.Set("Content-Type", "application/json")
|
||
resp, err := w.HTTP.Do(req)
|
||
if err != nil {
|
||
return nil, nil, nil, fmt.Errorf("视觉服务不可用: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
raw, _ := io.ReadAll(resp.Body)
|
||
if resp.StatusCode != http.StatusOK {
|
||
return nil, nil, nil, fmt.Errorf("视觉服务返回 %d: %s", resp.StatusCode, truncate(string(raw), 120))
|
||
}
|
||
var out struct {
|
||
Dim int `json:"dim"`
|
||
Vectors [][]float32 `json:"vectors"`
|
||
Indexes []int `json:"indexes"`
|
||
Skipped []int `json:"skipped"`
|
||
}
|
||
if err := json.Unmarshal(raw, &out); err != nil {
|
||
return nil, nil, nil, fmt.Errorf("视觉服务响应解析失败: %w", err)
|
||
}
|
||
return out.Vectors, out.Indexes, out.Skipped, nil
|
||
}
|
||
|
||
func (w *Worker) download(ctx context.Context, url string) ([]byte, error) {
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
resp, err := w.HTTP.Do(req)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode != http.StatusOK {
|
||
return nil, fmt.Errorf("下载失败 status=%d", resp.StatusCode)
|
||
}
|
||
body, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if len(body) == 0 {
|
||
return nil, fmt.Errorf("下载到空文件")
|
||
}
|
||
return body, nil
|
||
}
|
||
|
||
// parallel 以 Concurrency 路并发执行 fn(0..n-1)。
|
||
func (w *Worker) parallel(n int, fn func(i int)) {
|
||
if n == 0 {
|
||
return
|
||
}
|
||
if w.Concurrency <= 0 {
|
||
w.Concurrency = 1
|
||
}
|
||
sem := make(chan struct{}, w.Concurrency)
|
||
var wg sync.WaitGroup
|
||
for i := 0; i < n; i++ {
|
||
wg.Add(1)
|
||
go func(i int) {
|
||
defer wg.Done()
|
||
sem <- struct{}{}
|
||
defer func() { <-sem }()
|
||
fn(i)
|
||
}(i)
|
||
}
|
||
wg.Wait()
|
||
}
|
||
|
||
func truncate(s string, n int) string {
|
||
if len(s) <= n {
|
||
return s
|
||
}
|
||
return s[:n] + "..."
|
||
}
|