初始化 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,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