初始化 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:
@@ -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 的限制。
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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) =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||
@@ -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>
|
||||
@@ -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; } }
|
||||
Reference in New Issue
Block a user