Files
dish-image-toolkit/retrieval/embed_local.py
T
陈世睿 917613c5ce 初始化 dish-image-toolkit:菜品图爬虫 + 图片名称检索平台
- crawler/:豆果(主)/下厨房/Bing 爬虫 + dishes.jsonl(3056 菜→9167 图映射)+ verify_repair 按 URL 重下
- retrieval/:三路检索(BM25 + 本地 BGE-M3 向量 + RRF 融合),FastAPI + 前端;
  写死图片目录(默认 crawler/images,可 IMAGE_DIR 覆盖)、绑 0.0.0.0 局域网访问、
  启动自动建索引、服务器 serve 图片
- 图片(1.7G)与向量模型(2.3G)不进 git

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 10:27:51 +08:00

143 lines
5.9 KiB
Python

# -*- coding: utf-8 -*-
"""本地 BGE-M3 向量(BAAI/bge-m3,1024 维 dense,多语言/中文强,离线无限额)。
与 cloud 版 embed.py 同接口(embed_corpus / matrix / embed_query / last_failed / last_error),
server.py 直接换用。首次会下载约 2.3G 权重(默认走 hf-mirror.com 镜像,国内快);
检测到 CUDA 自动用 GPU。无任何调用配额/限速问题。
"""
from __future__ import annotations
import json
import os
import sys
import threading
import numpy as np
_HERE = os.path.dirname(os.path.abspath(__file__))
# 默认缓存在 C 盘 .cache,但本机 C: 空间不足 → 放到项目所在盘(bge-m3 约 2.3G)。
# 想放别处:启动前设环境变量 HF_HOME 覆盖即可。
os.environ.setdefault("HF_HOME", os.path.join(_HERE, "hf_home"))
# 国内默认走 HF 镜像(已设则不覆盖),下载更稳;并阻止 transformers 误加载 TensorFlow。
os.environ.setdefault("HF_ENDPOINT", "https://hf-mirror.com")
# 关掉 hf-xet 传输:它绕过 HF_ENDPOINT 直连 huggingface.co 的 xet 服务器会超时。
os.environ.setdefault("HF_HUB_DISABLE_XET", "1")
os.environ.setdefault("USE_TF", "0")
os.environ.setdefault("TF_CPP_MIN_LOG_LEVEL", "3")
# 系统代理(Clash 7897)会解压 zstd 并改写 hf-mirror 的 etag,破坏 hf_hub 元数据校验
# (报"couldn't connect to hf-mirror.com")。hf-mirror 是国内站,直连即可 → 清掉本进程代理。
for _k in ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy", "ALL_PROXY", "all_proxy"):
os.environ.pop(_k, None)
os.environ.setdefault("NO_PROXY", "*")
MODEL_ID = os.environ.get("BGE_MODEL", "BAAI/bge-m3")
class LocalEmbedder:
def __init__(self, model: str = MODEL_ID, cache_dir: str = "cache",
device: str | None = None):
self.model_id = model
tag = model.replace("/", "_")
os.makedirs(cache_dir, exist_ok=True)
self.cache_path = os.path.join(cache_dir, f"emb_{tag}.jsonl")
self.cache: dict[str, list[float]] = {}
self.last_failed = 0
self.last_error = ""
self.device = device
self._model = None
self._lock = threading.Lock()
self._load_cache()
# ---------- 缓存(与 cloud 版一致) ----------
def _load_cache(self) -> None:
if not os.path.exists(self.cache_path):
return
with open(self.cache_path, encoding="utf-8") as f:
for line in f:
try:
o = json.loads(line)
self.cache[o["t"]] = o["v"]
except Exception:
pass
def _append_cache(self, items) -> None:
with self._lock:
with open(self.cache_path, "a", encoding="utf-8") as f:
for t, v in items:
f.write(json.dumps({"t": t, "v": v}, ensure_ascii=False) + "\n")
self.cache[t] = v
# ---------- 模型 ----------
def load_model(self) -> str:
"""加载模型(首次会下载权重),返回 device 字符串。"""
if self._model is None:
import torch
# bge-m3 仅发布 pytorch_model.bin(无 safetensors);transformers 5.x 禁止
# torch<2.6 加载 .bin(CVE-2025-32434,防不可信 pickle 执行代码)。本机 torch 2.5.1,
# 模型来自 BAAI 官方(可信)、本地离线加载 → 关掉该检查,免升级 torch / 免下 safetensors。
try:
import transformers.modeling_utils as _mu
_mu.check_torch_load_is_safe = lambda *a, **k: None
except Exception:
pass
from sentence_transformers import SentenceTransformer
dev = self.device or ("cuda" if torch.cuda.is_available() else "cpu")
self._model = SentenceTransformer(self.model_id, device=dev)
self.device = dev
return self.device
def _encode(self, texts: list[str]) -> np.ndarray:
return np.asarray(
self._model.encode(texts, normalize_embeddings=True, batch_size=64,
convert_to_numpy=True, show_progress_bar=False),
dtype=np.float32)
# ---------- 编码 ----------
def embed_corpus(self, names, progress=None) -> None:
"""把 names 全部编码进缓存。progress(done, total) 回调可选。"""
todo = [n for n in dict.fromkeys(names) if n not in self.cache]
total = len(todo)
self.last_failed = 0
self.last_error = ""
if progress:
progress(0, total)
if not total:
return
try:
self.load_model()
except Exception as e: # noqa: BLE001
self.last_failed = total
self.last_error = f"加载模型失败: {type(e).__name__}: {e}"
print(f"[embed_local] {self.last_error}", file=sys.stderr, flush=True)
return
step = 256
done = 0
for i in range(0, total, step):
chunk = todo[i:i + step]
try:
vecs = self._encode(chunk)
self._append_cache([(t, v.tolist()) for t, v in zip(chunk, vecs)])
except Exception as e: # noqa: BLE001
self.last_failed += len(chunk)
self.last_error = str(e)[:200]
print(f"[embed_local] 编码失败: {e}", file=sys.stderr, flush=True)
done += len(chunk)
if progress:
progress(done, total)
# ---------- 取用(与 cloud 版一致) ----------
def matrix(self, names) -> tuple[list[str], np.ndarray]:
keep = [n for n in names if n in self.cache]
if not keep:
return [], np.zeros((0, 0), dtype=np.float32)
m = np.array([self.cache[n] for n in keep], dtype=np.float32)
norm = np.linalg.norm(m, axis=1, keepdims=True)
norm[norm == 0] = 1.0
return keep, m / norm
def embed_query(self, q: str) -> np.ndarray:
self.load_model()
v = self._encode([q])[0]
n = np.linalg.norm(v)
return v / (n if n else 1.0)