初始化 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
+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; } }