917613c5ce
- 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>
121 lines
4.3 KiB
JavaScript
121 lines
4.3 KiB
JavaScript
'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]));
|