初始化 dish-image:菜品图爬虫 + 图片名称检索平台

- 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>
This commit is contained in:
陈世睿
2026-07-01 10:27:51 +08:00
commit 74caa9d112
20 changed files with 5205 additions and 0 deletions
+35
View File
@@ -0,0 +1,35 @@
# retrieval — 图片名称检索平台
基于**图片名称**的三路检索并排对比,给对接业务做检索选型测试。
- **① BM25** —— jieba 分词 + rank_bm25(认字面/关键词)
- **② 向量** —— 本地 `BAAI/bge-m3`(1024 维,认语义;有 CUDA 走 GPU)
- **③ RRF 融合** —— 按排名融合两路:`Σ 1/(k + rank)`,k 默认 60(只看名次,绕开分数量纲不可比)
> 语义查询是向量的强项:查"碳酸饮料"能召回"可乐/雪碧"(字面零重叠),BM25 则无结果。
## 跑
```
run.bat # 或 pip install -r requirements.txt && python server.py
```
- **图片目录**:默认读 **`<仓库>/crawler/images`**;想指别处 → 启动前 `set IMAGE_DIR=<绝对路径>`
- 启动即扫描该目录、自动建索引;**首次**下 BGE-M3 ~2.3G(走 hf-mirror 镜像),之后约 25s 加载。
-`0.0.0.0`,控制台打印**局域网地址**;同事同网打开直接搜,无需选文件夹。防火墙放行 8799 入站。
## 接口
- `GET /api/status` — 建索引进度(`phase`: bm25/loadmodel/embedding、`device`、图片/名称数)
- `GET /api/search?q=&topk=&rrf_k=&w_bm25=&w_vec=` — 返回 `{bm25[], vector[], fusion[]}`,每条含图片文件名 + 在另两路的命中名次
- `GET /images/<文件名>` — 服务器直出图片(局域网里显示图靠它)
## 文件
| 文件 | 作用 |
| --- | --- |
| `server.py` | FastAPI:扫描目录 / 启动自动建索引 / status / search + serve 图片与前端 |
| `embed_local.py` | **本地 BGE-M3 向量**(GPU 自适应、落盘缓存;内含 HF 镜像/代理/torch 版本等环境坑处理) |
| `embed.py` | (备用)千问 DashScope 云端向量,含直连/自适应限速 |
| `bm25.py` / `fuse.py` | 中文 BM25(jieba + 补单字) / RRF 融合 |
| `static/` | 前端单页(纯搜索页,轮询 status 显示建索引进度) |
## 依赖 / 环境
`sentence-transformers`(+ torch)。检测到 CUDA 自动用 GPU;向量缓存落 `cache/`,模型落 `hf_home/`(均不进 git)。
`embed_local.py` 已处理:走 hf-mirror 镜像、绕系统代理直连、`HF_HUB_DISABLE_XET=1`、绕过 transformers 对 torch<2.6 加载 .bin 的限制。
+37
View File
@@ -0,0 +1,37 @@
# -*- coding: utf-8 -*-
"""中文 BM25 检索(jieba 分词 + rank_bm25)。文档 = 菜名。
菜名通常很短(2~6 字),除 jieba 词以外再补单字,提升短词召回。
"""
from __future__ import annotations
import re
import jieba
import numpy as np
from rank_bm25 import BM25Okapi
jieba.initialize()
def tokenize(text: str) -> list[str]:
text = text.replace("_", " ").strip()
words = [t for t in jieba.lcut_for_search(text) if t.strip()]
chars = [c for c in re.sub(r"\s+", "", text) if "" <= c <= "鿿"]
toks = words + chars
return toks if toks else [t for t in re.sub(r"\s+", "", text)] or ["_"]
class BM25Index:
def __init__(self, names: list[str]):
self.names = list(names)
self.tokenized = [tokenize(n) for n in self.names]
self.bm25 = BM25Okapi(self.tokenized)
def search(self, query: str, topk: int = 30) -> list[tuple[str, float]]:
q = tokenize(query)
if not q:
return []
scores = self.bm25.get_scores(q)
order = np.argsort(scores)[::-1][:topk]
return [(self.names[i], float(scores[i])) for i in order if scores[i] > 0]
+231
View File
@@ -0,0 +1,231 @@
# -*- coding: utf-8 -*-
"""通义千问 DashScope 文本向量客户端(text-embedding-v3,1024 维)。
要点:
- Key 复用 PriceBot 的 QWEN_API_KEY;可用环境变量 DASHSCOPE_API_KEY / QWEN_API_KEY 覆盖。
- 接口走 OpenAI 兼容端点;单次批量上限 10(硬限),用线程池并行多批。
- name -> 向量 落盘缓存(cache/emb_<model>.jsonl):重复加载同一批菜名零成本、零计费。
"""
from __future__ import annotations
import json
import os
import sys
import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
import numpy as np
import requests
DASHSCOPE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1/embeddings"
# 默认复用 PriceBot 的通义千问 Key(本工具在 dish-images 下、不进 git);优先用环境变量覆盖。
DEFAULT_KEY = "sk-f66e689a9b0c43239e299137f68c453c"
BATCH = 10 # text-embedding-v3 单次最多 10 条
# 免费额度对"并发齐发"很敏感(冷却后多线程同时重试会互撞、永远挤不进窗口),默认单线程顺序发,
# 由下方自适应间隔 _delay 控速最稳。Key 额度充足时可设环境变量 EMBED_WORKERS=4 之类提速。
MAX_WORKERS = max(1, int(os.environ.get("EMBED_WORKERS", "1")))
def get_key(override: str | None = None) -> str:
return (override or os.environ.get("DASHSCOPE_API_KEY")
or os.environ.get("QWEN_API_KEY") or DEFAULT_KEY)
_tls = threading.local()
def _sess() -> requests.Session:
"""线程本地 session,trust_env=False:忽略系统代理(HTTP(S)_PROXY),直连 dashscope。
Clash 等代理偶尔把请求甩到海外节点,会被阿里云按来源风控拒(HTTP 400 Access denied);
dashscope.aliyuncs.com 是大陆公网端点,直连最稳(与 PriceBot 调千问一致)。"""
s = getattr(_tls, "s", None)
if s is None:
s = _tls.s = requests.Session()
s.trust_env = False
return s
# ---------- 全局自适应限速:稳态间隔 + 冷却闸,收敛到免费额度可持续的速率 ----------
# dashscope 免费额度是「滚动每分钟预算」式:瞬时小量能过,持续高频会返回 400,且文案写成
# Arrearage/overdue(其实是"免费窗口用尽且无余额续费",约 1 分钟自动回血)。故这是【可重试】
# 信号:命中就放大间隔(降速)+ 拉长全员冷却(等回血),成功就缓慢提速,自动逼近可持续速率。
_pace_lock = threading.Lock()
_next_at = 0.0
_resume_at = 0.0
_delay = 0.6 # 当前稳态请求间隔(秒),单线程顺序发
_cool = 5.0 # 当前冷却时长(秒),命中放大、成功回落
DELAY_MIN, DELAY_MAX = 0.3, 4.0
COOL_MIN, COOL_MAX = 5.0, 45.0
def _pace() -> None:
"""按当前稳态间隔 _delay 给每个请求排队发牌(有效速率 ≈ 1/_delay)。"""
global _next_at
with _pace_lock:
now = time.time()
slot = max(now, _next_at)
_next_at = slot + _delay
wait = slot - now
if wait > 0:
time.sleep(wait)
def _gate_wait() -> None:
"""命中限速后的全员冷却,期间所有线程一起等(等额度回血)。"""
while True:
with _pace_lock:
wait = _resume_at - time.time()
if wait <= 0:
return
time.sleep(min(wait, 2.0) + 0.05)
def _rate_hit() -> None:
"""命中免费额度限速:降速 + 拉长冷却等回血。"""
global _delay, _cool, _resume_at
with _pace_lock:
_delay = min(_delay * 1.5, DELAY_MAX)
_cool = min(_cool * 1.4, COOL_MAX)
_resume_at = max(_resume_at, time.time() + _cool)
def _ok() -> None:
"""成功:缓慢提速、缩短冷却,收敛到刚好不撞墙。"""
global _delay, _cool
with _pace_lock:
_delay = max(_delay * 0.9, DELAY_MIN)
_cool = max(_cool * 0.9, COOL_MIN)
class ArrearsError(RuntimeError):
"""重试多轮仍被免费额度拒(疑似额度耗尽/欠费),供上层提示"稍后重试/换 Key""""
def _is_rate_signal(status: int, text: str) -> bool:
"""免费额度限速信号(可重试):含真限速(429/503/throttling)与"伪欠费"(Arrearage/overdue,
实为免费窗口用尽、约 1 分钟回血)。两者都靠退避+等待解决。"""
if status in (429, 503):
return True
low = text.lower()
return ("throttling" in low or "rate limit" in low or "requests rate" in low
or "arrearage" in low or "overdue" in low or "good standing" in low
or "欠费" in text or "余额不足" in text)
class Embedder:
def __init__(self, model: str = "text-embedding-v4", cache_dir: str = "cache",
key: str | None = None):
self.model = model
self.key = get_key(key)
os.makedirs(cache_dir, exist_ok=True)
self.cache_path = os.path.join(cache_dir, f"emb_{model}.jsonl")
self.cache: dict[str, list[float]] = {}
self.last_failed = 0
self.last_error = ""
self._lock = threading.Lock()
self._load_cache()
# ---------- 缓存 ----------
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: list[tuple[str, list[float]]]) -> 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 _embed_batch(self, texts: list[str]) -> list[list[float]]:
body = {"model": self.model, "input": texts, "encoding_format": "float"}
last = None
rate_hits = 0
for _ in range(7): # 配合冷却升级,总等待可超 1 分钟,足以熬过免费窗口回血
_gate_wait()
_pace()
try:
r = _sess().post(
DASHSCOPE_URL,
headers={"Authorization": f"Bearer {self.key}",
"Content-Type": "application/json"},
json=body, timeout=30)
if r.status_code == 200:
_ok()
data = sorted(r.json()["data"], key=lambda d: d["index"])
return [d["embedding"] for d in data]
txt = r.text[:200]
last = f"HTTP {r.status_code}: {txt}"
if _is_rate_signal(r.status_code, txt):
rate_hits += 1
_rate_hit() # 降速 + 拉长冷却等回血后重试
else:
time.sleep(1.0) # 其它错误轻退避后重试
except requests.RequestException as e:
last = f"{type(e).__name__}: {e}"
time.sleep(2)
if rate_hits >= 4: # 多轮仍被额度拒 → 让上层提示换Key/稍后重试
raise ArrearsError(f"模型 {self.model} 免费额度反复受限(可能已用尽);"
f"可稍后重试,或在 embed.py 换 DASHSCOPE_API_KEY")
raise RuntimeError(f"embed batch failed: {last}")
def embed_corpus(self, names: list[str], 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
batches = [todo[i:i + BATCH] for i in range(0, total, BATCH)]
done = 0
ok_count = 0
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as ex:
futs = {ex.submit(self._embed_batch, b): b for b in batches}
for fut in as_completed(futs):
b = futs[fut]
try:
self._append_cache(list(zip(b, fut.result())))
ok_count += len(b)
except Exception as e: # noqa: BLE001
self.last_failed += len(b)
self.last_error = str(e)[:200]
if self.last_failed <= len(b) * 2: # 只打前一两次,避免刷屏
print(f"[embed] 批失败({self.last_failed}): {e}", file=sys.stderr, flush=True)
# 一次都没成功 + 已连续失败 ≥2 批 → 判定 Key 不可用,中止省额度;
# 若前面已有成功(只是滚动额度临时受限),则继续磨,失败的批留待下次续抓。
if ok_count == 0 and self.last_failed >= 20:
print("[embed] 持续失败且零成功,中止向量编码(BM25 仍可用)",
file=sys.stderr, flush=True)
ex.shutdown(wait=False, cancel_futures=True)
break
done += len(b)
if progress:
progress(done, total)
# ---------- 取用 ----------
def matrix(self, names: list[str]) -> tuple[list[str], np.ndarray]:
"""返回 (有向量的 name 列表, L2 归一化矩阵 N×dim)。"""
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:
v = np.array(self._embed_batch([q])[0], dtype=np.float32)
n = np.linalg.norm(v)
return v / (n if n else 1.0)
+142
View File
@@ -0,0 +1,142 @@
# -*- 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)
+19
View File
@@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
"""RRF(Reciprocal Rank Fusion)融合多路排名。
RRF 只看「排名」不看「分数量纲」,天然解决 BM25 分数与余弦相似度不可比的问题:
fused(d) = Σ_method w_method * 1 / (k + rank_method(d))
k 越大,头部名次的权重差异越平缓(经验默认 60)。
"""
from __future__ import annotations
def rrf(rankings: dict[str, list[str]], weights: dict[str, float],
k: int = 60) -> list[tuple[str, float]]:
"""rankings: {method: [name 按相关性降序]};返回 [(name, fused_score)] 降序。"""
score: dict[str, float] = {}
for method, names in rankings.items():
w = weights.get(method, 1.0)
for rank, name in enumerate(names, start=1):
score[name] = score.get(name, 0.0) + w * (1.0 / (k + rank))
return sorted(score.items(), key=lambda kv: kv[1], reverse=True)
+9
View File
@@ -0,0 +1,9 @@
fastapi>=0.110
uvicorn[standard]>=0.27
jieba>=0.42.1
rank-bm25>=0.2.2
numpy>=1.24
# 本地向量 BGE-M3。依赖 torch:本机已装 CUDA 版(2.5.1+cu121),勿被覆盖。
# 全新机器装 GPU 版 torch 见 https://pytorch.org(CPU 版也能跑,只是慢些)。
sentence-transformers>=2.7
+8
View File
@@ -0,0 +1,8 @@
@echo off
chcp 65001 >nul
cd /d %~dp0
set PYTHONUTF8=1
echo [1/2] 安装依赖(已装会秒过)...
python -m pip install -q -r requirements.txt
echo [2/2] 启动服务,浏览器打开 http://127.0.0.1:8799
python server.py
+262
View File
@@ -0,0 +1,262 @@
# -*- coding: utf-8 -*-
"""图片名称检索测试台 —— 后端(FastAPI)。
三路检索,全部基于「图片名称」:
1) BM25 —— 关键词匹配(jieba 分词)
2) 向量 —— 本地 BAAI/bge-m3 余弦相似度
3) 融合 —— RRF 合并上面两路排名
【写死目录 + 局域网模式】图片目录写死在 IMAGE_DIR,服务器启动即扫描并自动建索引、
自己通过 /images 把图片发出去。绑 0.0.0.0,mentor 用局域网 IP 打开即可直接检索,
无需选文件夹、无需建索引。
跑法:python server.py → 控制台会打印可发给 mentor 的局域网地址。
"""
from __future__ import annotations
import os
import re
import socket
import sys
import threading
import numpy as np
from fastapi import Body, FastAPI, Query
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from bm25 import BM25Index
from embed_local import LocalEmbedder
from fuse import rrf
for _s in (sys.stdout, sys.stderr):
try:
_s.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
HERE = os.path.dirname(os.path.abspath(__file__))
# 图片目录:默认 = 本仓库的 crawler/images(爬虫默认就把图下到那里)。
# 想指向别处已爬好的图,启动前设环境变量 IMAGE_DIR=<绝对路径> 覆盖即可,无需改代码。
IMAGE_DIR = os.environ.get("IMAGE_DIR") or os.path.abspath(
os.path.join(HERE, os.pardir, "crawler", "images"))
IMG_EXT = (".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp")
PORT = 8799
app = FastAPI(title="图片名称检索测试台")
def scan_images(img_dir: str) -> dict[str, list[str]]:
"""扫描图片目录 → {菜名: [文件名,...]}。去掉尾部 _NN 把同名多图归并为一条。"""
name2files: dict[str, list[str]] = {}
if not os.path.isdir(img_dir):
return name2files
for fn in os.listdir(img_dir):
if not fn.lower().endswith(IMG_EXT):
continue
stem = os.path.splitext(fn)[0]
name = re.sub(r"_\d+$", "", stem) or stem
name2files.setdefault(name, []).append(fn)
for files in name2files.values():
files.sort()
return name2files
class State:
"""全局索引状态(单实例工具,够用)。"""
def __init__(self):
self.lock = threading.Lock()
self.names: list[str] = []
self.name2files: dict[str, list[str]] = {}
self.bm25: BM25Index | None = None
self.embedder: LocalEmbedder | None = None
self.model = "BAAI/bge-m3"
self.vec_names: list[str] = []
self.vec_matrix: np.ndarray | None = None
self.state = "idle" # idle | building | ready | error
self.phase = "" # bm25 | loadmodel | embedding | done
self.emb_done = 0
self.emb_total = 0
self.failed = 0
self.message = ""
self.device = ""
ST = State()
def build_index(names: list[str], model: str) -> None:
try:
with ST.lock:
ST.state, ST.phase, ST.message = "building", "bm25", ""
ST.names, ST.model = names, model
ST.bm25 = None
ST.vec_matrix, ST.vec_names = None, []
ST.emb_done = ST.emb_total = ST.failed = 0
# 1) BM25(秒级)
bm = BM25Index(names)
with ST.lock:
ST.bm25, ST.phase = bm, "loadmodel"
# 2) 本地 BGE-M3:先加载模型(首次会下载约 2.3G),再编码(有 CUDA 走 GPU)
emb = LocalEmbedder(model=model, cache_dir=os.path.join(HERE, "cache"))
dev = emb.load_model()
with ST.lock:
ST.device, ST.phase = dev, "embedding"
def prog(done: int, total: int) -> None:
with ST.lock:
ST.emb_done, ST.emb_total = done, total
emb.embed_corpus(names, progress=prog)
vnames, mat = emb.matrix(names)
with ST.lock:
ST.embedder, ST.vec_names, ST.vec_matrix = emb, vnames, mat
ST.failed = emb.last_failed
ST.message = "" if vnames else ("向量未生成:" + emb.last_error if emb.last_error else "")
ST.state, ST.phase = "ready", "done"
except Exception as e: # noqa: BLE001
with ST.lock:
ST.state, ST.message = "error", f"{type(e).__name__}: {e}"
@app.on_event("startup")
def _startup_autobuild() -> None:
"""启动即扫描写死目录并后台建索引,mentor 打开就能搜。"""
n2f = scan_images(IMAGE_DIR)
with ST.lock:
ST.name2files = n2f
names = sorted(n2f.keys())
if not names:
with ST.lock:
ST.state = "error"
ST.message = f"图片目录为空或不存在:{IMAGE_DIR}"
return
threading.Thread(target=build_index, args=(names, "BAAI/bge-m3"), daemon=True).start()
@app.post("/api/index")
def api_index(payload: dict = Body(...)):
"""(保留)手动指定名称重建索引;写死目录模式下一般用不到。"""
names = [str(n).strip() for n in payload.get("names", []) if str(n).strip()]
names = list(dict.fromkeys(names))
model = (payload.get("model") or "BAAI/bge-m3").strip()
if not names:
return JSONResponse({"ok": False, "error": "没有菜名"}, status_code=400)
if ST.state == "building":
return JSONResponse({"ok": False, "error": "正在建索引,请稍候"}, status_code=409)
threading.Thread(target=build_index, args=(names, model), daemon=True).start()
return {"ok": True, "total": len(names), "model": model}
@app.get("/api/status")
def api_status():
with ST.lock:
return {
"state": ST.state,
"phase": ST.phase,
"bm25_ready": ST.bm25 is not None,
"vec_ready": ST.vec_matrix is not None and len(ST.vec_names) > 0,
"total": len(ST.names),
"n_images": sum(len(v) for v in ST.name2files.values()),
"emb_done": ST.emb_done,
"emb_total": ST.emb_total,
"vec_count": len(ST.vec_names),
"failed": ST.failed,
"model": ST.model,
"device": ST.device,
"message": ST.message,
}
def _bm25_search(q: str, topk: int) -> list[tuple[str, float]]:
return ST.bm25.search(q, topk=topk) if ST.bm25 else []
def _vec_search(q: str, topk: int) -> list[tuple[str, float]]:
mat, names, emb = ST.vec_matrix, ST.vec_names, ST.embedder
if mat is None or len(names) == 0 or emb is None:
return []
qv = emb.embed_query(q)
sims = mat @ qv
order = np.argsort(sims)[::-1][:topk]
return [(names[i], float(sims[i])) for i in order]
@app.get("/api/search")
def api_search(q: str = Query(...), topk: int = 30, rrf_k: int = 60,
w_bm25: float = 1.0, w_vec: float = 1.0):
q = q.strip()
if not q:
return {"q": q, "bm25": [], "vector": [], "fusion": []}
bm = _bm25_search(q, topk)
ve = _vec_search(q, topk)
bm_rank = [n for n, _ in bm]
ve_rank = [n for n, _ in ve]
fused = rrf({"bm25": bm_rank, "vector": ve_rank},
{"bm25": w_bm25, "vector": w_vec}, k=rrf_k)[:topk]
bm_s, ve_s = dict(bm), dict(ve)
bm_pos = {n: i + 1 for i, n in enumerate(bm_rank)}
ve_pos = {n: i + 1 for i, n in enumerate(ve_rank)}
def card(name: str, score: float) -> dict:
return {
"name": name,
"score": round(score, 4),
"files": ST.name2files.get(name, []), # 服务器 serve 的图片文件名
"bm25": round(bm_s[name], 3) if name in bm_s else None,
"bm25_rank": bm_pos.get(name),
"vec": round(ve_s[name], 4) if name in ve_s else None,
"vec_rank": ve_pos.get(name),
}
return {
"q": q,
"bm25": [card(n, s) for n, s in bm],
"vector": [card(n, s) for n, s in ve],
"fusion": [card(n, s) for n, s in fused],
}
# 图片由服务器 serve(局域网里 mentor 的浏览器据此显示);必须在 "/" 之前挂载。
if os.path.isdir(IMAGE_DIR):
app.mount("/images", StaticFiles(directory=IMAGE_DIR), name="images")
# 静态前端(放在所有 /api 与 /images 之后挂载)
app.mount("/", StaticFiles(directory=os.path.join(HERE, "static"), html=True), name="static")
def _lan_ips() -> list[str]:
ips: list[str] = []
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
ips.append(s.getsockname()[0])
s.close()
except Exception:
pass
try:
for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
ip = info[4][0]
if ip not in ips:
ips.append(ip)
except Exception:
pass
return [i for i in ips if not i.startswith("127.")]
if __name__ == "__main__":
import uvicorn
print("=" * 56, flush=True)
print(f"图片目录:{IMAGE_DIR}", flush=True)
print("发给 mentor 的局域网地址:", flush=True)
for ip in _lan_ips() or ["<查不到,用 ipconfig 看本机 IPv4>"]:
print(f" http://{ip}:{PORT}", flush=True)
print(f"本机自测:http://127.0.0.1:{PORT}", flush=True)
print("(若 mentor 连不上:Windows 防火墙放行该端口入站)", flush=True)
print("=" * 56, flush=True)
uvicorn.run(app, host="0.0.0.0", port=PORT, log_level="info")
+120
View File
@@ -0,0 +1,120 @@
'use strict';
const $ = (id) => document.getElementById(id);
let pollTimer = null;
// ---------- 启动:轮询索引状态(服务器开机自动建索引) ----------
window.addEventListener('DOMContentLoaded', pollStatus);
function pollStatus() {
clearInterval(pollTimer);
tick();
pollTimer = setInterval(tick, 800);
}
async function tick() {
let s;
try { s = await (await fetch('/api/status')).json(); }
catch { $('status').textContent = '连接服务失败,确认服务器已启动。'; return; }
// BM25 就绪即可搜(向量还在编码也能先看关键词路)
if (s.bm25_ready) { $('q').disabled = false; $('search').disabled = false; }
const bar = $('buildBar');
if (s.state === 'building') {
bar.classList.remove('hidden');
if (s.phase === 'loadmodel') {
$('status').textContent = `图片 ${s.n_images} 张 / 名称 ${s.total} 个 · 正在加载向量模型…`;
setBar('加载本地向量模型 BGE-M3(首次需下载约 2.3G)…', 8);
} else if (s.phase === 'embedding') {
const pct = s.emb_total ? Math.round((s.emb_done / s.emb_total) * 100) : 0;
const dev = s.device ? ` · ${s.device}` : '';
$('status').textContent = `图片 ${s.n_images} 张 / 名称 ${s.total} 个 · 向量编码中(BM25 已就绪,可先搜)`;
setBar(`向量编码中${dev} ${s.emb_done}/${s.emb_total}`, pct);
} else {
$('status').textContent = 'BM25 分词建索引中…';
setBar('BM25 分词建索引中…', 4);
}
} else if (s.state === 'ready') {
clearInterval(pollTimer);
const dev = s.device ? ` (${s.device})` : '';
const note = s.failed ? `,${s.failed} 条失败` : '';
$('status').innerHTML = `✓ 就绪:<b>${s.total}</b> 个名称 / <b>${s.n_images}</b> 张图${dev}${note} —— 直接搜索吧。`;
bar.classList.add('hidden');
} else if (s.state === 'error') {
clearInterval(pollTimer);
$('status').textContent = '❌ ' + (s.message || '建索引失败');
bar.classList.add('hidden');
}
}
function setBar(t, pct) {
$('buildText').textContent = t;
$('barFill').style.width = (pct || 0) + '%';
}
// ---------- 搜索 ----------
$('search').addEventListener('click', doSearch);
$('q').addEventListener('keydown', (e) => { if (e.key === 'Enter') doSearch(); });
async function doSearch() {
const q = $('q').value.trim();
if (!q) return;
const params = new URLSearchParams({
q, topk: $('topk').value || 20, rrf_k: $('rrfk').value || 60,
w_bm25: $('wbm').value || 1, w_vec: $('wvec').value || 1,
});
let d;
try { d = await (await fetch('/api/search?' + params)).json(); }
catch (err) { alert('搜索失败:' + err.message); return; }
renderCol('bm25', d.bm25);
renderCol('vector', d.vector);
renderCol('fusion', d.fusion);
}
function renderCol(kind, items) {
$('cnt-' + kind).textContent = items.length ? `${items.length}` : '';
const box = $('col-' + kind);
box.innerHTML = '';
if (!items.length) { box.innerHTML = '<div class="empty">无结果</div>'; return; }
items.forEach((it, i) => box.appendChild(card(kind, it, i + 1)));
}
function card(kind, it, rank) {
const el = document.createElement('div');
el.className = 'card';
const primary = kind === 'bm25' ? `BM25 ${fmt(it.bm25)}`
: kind === 'vector' ? `余弦 ${fmt(it.vec)}`
: `RRF ${fmt(it.score)}`;
const badges = [];
if (kind !== 'bm25') badges.push(rankBadge('BM25', it.bm25_rank));
if (kind !== 'vector') badges.push(rankBadge('向量', it.vec_rank));
const src = imgURL(it.files && it.files[0]);
el.innerHTML = `
<div class="rk">${rank}</div>
${src ? `<img loading="lazy" src="${src}" title="${esc(it.name)}">` : '<div class="card-img"></div>'}
<div class="meta">
<div class="nm" title="${esc(it.name)}">${esc(it.name)}</div>
<div class="sc">${primary}</div>
<div class="badges">${badges.join('')}</div>
</div>`;
return el;
}
function rankBadge(label, rank) {
return rank
? `<span class="badge">${label}#${rank}</span>`
: `<span class="badge miss">${label}未命中</span>`;
}
function imgURL(file) {
return file ? '/images/' + encodeURIComponent(file) : '';
}
const fmt = (v) => (v === null || v === undefined ? '—' : v);
const esc = (s) => String(s).replace(/[&<>"]/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
+59
View File
@@ -0,0 +1,59 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>图片名称检索测试台</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<header>
<h1>图片名称检索测试台</h1>
<p class="sub">基于<b>图片名称</b>的三路检索对比:BM25 关键词 / 向量 BGE-M3 / RRF 融合。图片目录已固定,直接搜即可。</p>
</header>
<!-- 索引状态 -->
<section class="panel">
<div id="status" class="info muted">连接服务中…</div>
<div id="buildBar" class="buildbar hidden">
<div class="bar"><div id="barFill" class="fill"></div></div>
<span id="buildText"></span>
</div>
</section>
<!-- 检索 -->
<section class="panel">
<div class="row">
<input id="q" class="query" type="text" placeholder="输入查询词,例如:红烧肉 / 碳酸饮料 / 甜点 / 辣的川菜" disabled>
<label class="sel">返回<input id="topk" type="number" value="20" min="1" max="100" style="width:56px"></label>
<button id="search" class="btn primary" disabled>搜索</button>
</div>
<details class="adv">
<summary>高级:融合(RRF)参数</summary>
<div class="row">
<label class="sel">RRF k <input id="rrfk" type="number" value="60" min="1" max="500" style="width:64px"></label>
<label class="sel">BM25 权重 <input id="wbm" type="number" value="1.0" step="0.1" min="0" style="width:64px"></label>
<label class="sel">向量 权重 <input id="wvec" type="number" value="1.0" step="0.1" min="0" style="width:64px"></label>
</div>
</details>
</section>
<!-- 结果:三列 -->
<section id="results" class="results">
<div class="column" data-kind="bm25">
<h2>① BM25 关键词 <span class="cnt" id="cnt-bm25"></span></h2>
<div class="cards" id="col-bm25"></div>
</div>
<div class="column" data-kind="vector">
<h2>② 向量 BGE-M3 <span class="cnt" id="cnt-vector"></span></h2>
<div class="cards" id="col-vector"></div>
</div>
<div class="column" data-kind="fusion">
<h2>③ RRF 融合 <span class="cnt" id="cnt-fusion"></span></h2>
<div class="cards" id="col-fusion"></div>
</div>
</section>
<script src="/app.js"></script>
</body>
</html>
+68
View File
@@ -0,0 +1,68 @@
* { box-sizing: border-box; }
body {
margin: 0; padding: 0 24px 48px;
font-family: -apple-system, "Segoe UI", "Microsoft YaHei", Roboto, sans-serif;
color: #1f2328; background: #f6f7f9;
}
header { padding: 20px 0 8px; }
h1 { margin: 0; font-size: 22px; }
.sub { margin: 6px 0 0; color: #57606a; font-size: 14px; }
.sub b { color: #1f2328; }
.panel {
background: #fff; border: 1px solid #e6e8eb; border-radius: 10px;
padding: 16px; margin: 14px 0;
}
.row { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; }
.btn {
display: inline-flex; align-items: center; gap: 6px;
padding: 8px 14px; border-radius: 8px; border: 1px solid #d0d7de;
background: #fff; cursor: pointer; font-size: 14px; color: #1f2328;
}
.btn:hover { background: #f3f4f6; }
.btn.primary { background: #1f6feb; border-color: #1f6feb; color: #fff; }
.btn.primary:hover { background: #1a60d0; }
.btn.primary:disabled, .btn:disabled { opacity: .5; cursor: not-allowed; }
.file-btn { position: relative; }
.chk, .sel { font-size: 14px; color: #424a53; display: inline-flex; align-items: center; gap: 6px; }
.sel select, .sel input, .query {
font-size: 14px; padding: 7px 9px; border: 1px solid #d0d7de; border-radius: 8px; background: #fff;
}
.query { flex: 1; min-width: 240px; }
code { background: #eef0f2; padding: 1px 5px; border-radius: 4px; font-size: 12px; }
.info { margin-top: 12px; font-size: 13px; }
.muted { color: #6b7280; }
.buildbar { margin-top: 12px; display: flex; align-items: center; gap: 10px; font-size: 13px; color: #424a53; }
.bar { flex: 1; height: 8px; background: #eaecef; border-radius: 6px; overflow: hidden; }
.fill { height: 100%; width: 0; background: #2da44e; transition: width .25s; }
.preview { margin-top: 12px; display: flex; flex-wrap: wrap; gap: 6px; }
.preview img { width: 64px; height: 64px; object-fit: cover; border-radius: 6px; border: 1px solid #e6e8eb; }
.adv { margin-top: 10px; font-size: 13px; }
.adv summary { cursor: pointer; color: #57606a; }
.adv .row { margin-top: 10px; }
.results { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; margin-top: 4px; }
.column { background: #fff; border: 1px solid #e6e8eb; border-radius: 10px; padding: 12px; min-height: 80px; }
.column h2 { margin: 0 0 10px; font-size: 15px; display: flex; align-items: center; gap: 8px; }
.cnt { color: #6b7280; font-size: 12px; font-weight: 400; }
.cards { display: flex; flex-direction: column; gap: 8px; }
.card { display: flex; gap: 10px; padding: 8px; border: 1px solid #eef0f2; border-radius: 8px; align-items: center; }
.card:hover { border-color: #c8d1da; background: #fafbfc; }
.card .rk { width: 22px; text-align: center; font-size: 13px; color: #8b949e; flex: none; }
.card img { width: 56px; height: 56px; object-fit: cover; border-radius: 6px; flex: none; background: #eef0f2; }
.card .meta { min-width: 0; }
.card .nm { font-size: 14px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.card .sc { font-size: 12px; color: #1f6feb; margin-top: 2px; }
.card .badges { margin-top: 3px; display: flex; flex-wrap: wrap; gap: 4px; }
.badge { font-size: 11px; padding: 1px 6px; border-radius: 999px; background: #eef0f2; color: #57606a; }
.badge.miss { background: #fff1f0; color: #cf222e; }
.empty { color: #8b949e; font-size: 13px; padding: 8px 4px; }
@media (max-width: 900px) { .results { grid-template-columns: 1fr; } }