import { useState } from "react"; import { adminKbSearch, type DatasourceKB, type KbHit, type RouteDiag, type SearchMode } from "../api"; // 检索试验台:对同一个 query 同时跑「生产链路」与「三路 + RRF 融合」,并排看各自召回。 // 用途:线上召回不准时定位是哪一环 —— 向量路空=embedding/切块问题;全文路空=分词/索引问题; // 图谱路空=Neo4j 未起或未抽三元组;融合有而生产为空=rerank 把结果滤掉了。 // // ⚠️ 分数不可跨路比较:向量是余弦相似度(0~1),RRF 是 1/(k+rank) 的融合分(通常 <0.05)。 const ROUTES: Array<{ mode: SearchMode; label: string; hint: string }> = [ { mode: "vector", label: "向量", hint: "Milvus · 语义相似" }, { mode: "fulltext", label: "全文", hint: "Bleve · 关键词倒排" }, { mode: "graph", label: "图谱", hint: "Neo4j · 实体关系" }, { mode: "hybrid", label: "RRF 融合", hint: "三路融合 · 不含 rerank" }, ]; type Results = Partial>; // 每一路的诊断徽章。三种"空"必须分得清 —— 没配置 / 报错 / 确实没匹配, // 以前它们在界面上长得一模一样(都是 0 条),排查时只能靠猜。 const STATUS_TONE: Record = { ok: { label: "正常", cls: "bg-emerald-50 text-emerald-600" }, empty: { label: "无匹配", cls: "bg-gray-100 text-gray-500" }, disabled: { label: "未启用", cls: "bg-amber-50 text-amber-700" }, error: { label: "报错", cls: "bg-rose-50 text-rose-600" }, }; const ROUTE_CN: Record = { vector: "向量", fulltext: "全文", graph: "图谱" }; export function RetrievalBench({ kbs }: { kbs: DatasourceKB[] }) { const withDocs = kbs.filter((k) => k.doc_count > 0); const [kbKey, setKbKey] = useState(""); const [q, setQ] = useState(""); const [topK, setTopK] = useState(5); const [res, setRes] = useState({}); const [running, setRunning] = useState(false); const [ran, setRan] = useState(false); const run = async () => { if (!kbKey || !q.trim()) return; setRunning(true); setRan(true); // 生产链路 + 四路诊断并行跑,各自独立成败(一路挂不影响其它路展示)。 const modes: SearchMode[] = ["", ...ROUTES.map((r) => r.mode)]; const settled = await Promise.all( modes.map(async (m) => { try { return [m, await adminKbSearch(kbKey, q.trim(), topK, m)] as const; } catch (e) { return [m, { hits: [], err: (e as Error).message }] as const; } }), ); setRes(Object.fromEntries(settled)); setRunning(false); }; return (

检索试验台

同一 query 跑通所有检索路,定位召回问题出在哪一环
{/* 查询条件 */}
{withDocs.length === 0 && (

还没有含文档的知识库,先去桌面端建库并入库再来试。

)} {ran && (
{/* 各路健康状况:先回答"哪一路能用",再看"召回了什么" */} {(() => { const routes = res[""]?.routes ?? res["hybrid"]?.routes ?? []; if (!routes.length) return null; return (
各路状态 {routes.map((r) => { const tone = STATUS_TONE[r.status] ?? STATUS_TONE.empty; return ( {ROUTE_CN[r.name] ?? r.name} · {tone.label} {r.hits}命中/{r.ms}ms ); })}
); })()} {/* 非正常路的原因写全,别让人去翻日志 */} {(res[""]?.routes ?? res["hybrid"]?.routes ?? []) .filter((r) => r.status === "disabled" || r.status === "error") .map((r) => (

{ROUTE_CN[r.name] ?? r.name}路{r.status === "error" ? "报错" : "未启用"}:{r.error || r.note}

))} {/* 生产链路:用户实际拿到的结果 */} {/* 诊断四路 */}
分路诊断(分数体系不同,勿跨路比大小)
{ROUTES.map((r) => { // 单路那次请求里,取它自己那一路的诊断(RRF 融合没有单独一路,故为空) const diag = res[r.mode]?.routes?.find((d) => d.name === r.mode); return ; })}
)}
); } function RouteCard({ label, hint, data, diag, highlight, }: { label: string; hint: string; data?: { hits: KbHit[]; routes?: RouteDiag[]; err?: string }; diag?: RouteDiag; highlight?: boolean; }) { const hits = data?.hits ?? []; return (
{label} {hits.length} 命中
{hint}
{data?.err ? (

{data.err}

) : hits.length === 0 ? ( // 空的原因比"空"本身重要:没配置 / 报错 / 确实没匹配,处理方式完全不同

{diag?.status === "disabled" ? `未启用:${diag.note || "该路未配置"}` : diag?.status === "error" ? `报错:${diag.error}` : diag?.note || "这一路没召回(索引里确实没有匹配内容)"}

) : (
    {hits.map((h, i) => (
  1. #{i + 1} {h.score.toFixed(4)}

    {h.text}

  2. ))}
)}
); }