74caa9d112
- 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>
143 lines
5.9 KiB
Python
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)
|