518cddbd1c
此前四张卡片一律写"这一路没召回",而人的视线落在卡片上、不在顶部状态行。 现在按该路诊断分别显示:未启用给出缺什么配置(琥珀)、报错给出错误原文(红)、 真没匹配才说没召回。 真环境验证(截图为证):向量="该知识库在 Milvus 中没有向量(未入库或集合被 重建过)"、图谱="未启用:Neo4j 未连接或未配置"、全文="索引里确实没有匹配内容"。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
231 lines
10 KiB
TypeScript
231 lines
10 KiB
TypeScript
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<Record<string, { hits: KbHit[]; routes?: RouteDiag[]; err?: string }>>;
|
||
|
||
// 每一路的诊断徽章。三种"空"必须分得清 —— 没配置 / 报错 / 确实没匹配,
|
||
// 以前它们在界面上长得一模一样(都是 0 条),排查时只能靠猜。
|
||
const STATUS_TONE: Record<string, { label: string; cls: string }> = {
|
||
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<string, string> = { 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<Results>({});
|
||
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 (
|
||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||
<div className="mb-1 flex items-center gap-2">
|
||
<h4 className="text-sm font-semibold text-gray-700">检索试验台</h4>
|
||
<span className="text-[11px] text-gray-400">同一 query 跑通所有检索路,定位召回问题出在哪一环</span>
|
||
</div>
|
||
|
||
{/* 查询条件 */}
|
||
<div className="mt-4 flex flex-wrap items-end gap-2">
|
||
<label className="flex flex-col gap-1">
|
||
<span className="text-[11px] text-gray-400">知识库</span>
|
||
<select
|
||
value={kbKey}
|
||
onChange={(e) => setKbKey(e.target.value)}
|
||
className="w-64 rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-sm text-gray-700 focus:border-violet-400 focus:outline-none"
|
||
>
|
||
<option value="">选择一个知识库…</option>
|
||
{withDocs.map((k) => (
|
||
<option key={k.id} value={`${k.space_id}/${k.name}`}>
|
||
{k.name} · {k.tenant_name || k.tenant_id} ({k.doc_count} 篇)
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
|
||
<label className="flex flex-1 flex-col gap-1" style={{ minWidth: 220 }}>
|
||
<span className="text-[11px] text-gray-400">查询</span>
|
||
<input
|
||
value={q}
|
||
onChange={(e) => setQ(e.target.value)}
|
||
onKeyDown={(e) => e.key === "Enter" && void run()}
|
||
placeholder="输入一个真实用户会问的问题…"
|
||
className="rounded-lg border border-gray-200 px-3 py-1.5 text-sm text-gray-700 focus:border-violet-400 focus:outline-none"
|
||
/>
|
||
</label>
|
||
|
||
<label className="flex flex-col gap-1">
|
||
<span className="text-[11px] text-gray-400">topK</span>
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
max={20}
|
||
value={topK}
|
||
onChange={(e) => setTopK(Math.max(1, Math.min(20, Number(e.target.value) || 5)))}
|
||
className="w-20 rounded-lg border border-gray-200 px-3 py-1.5 text-sm text-gray-700 focus:border-violet-400 focus:outline-none"
|
||
/>
|
||
</label>
|
||
|
||
<button
|
||
onClick={() => void run()}
|
||
disabled={running || !kbKey || !q.trim()}
|
||
className="rounded-lg bg-violet-600 px-4 py-1.5 text-xs font-medium text-white hover:bg-violet-700 disabled:opacity-40"
|
||
>
|
||
{running ? "检索中…" : "跑一遍"}
|
||
</button>
|
||
</div>
|
||
|
||
{withDocs.length === 0 && (
|
||
<p className="mt-3 text-xs text-amber-600">还没有含文档的知识库,先去桌面端建库并入库再来试。</p>
|
||
)}
|
||
|
||
{ran && (
|
||
<div className="mt-5 space-y-4">
|
||
{/* 各路健康状况:先回答"哪一路能用",再看"召回了什么" */}
|
||
{(() => {
|
||
const routes = res[""]?.routes ?? res["hybrid"]?.routes ?? [];
|
||
if (!routes.length) return null;
|
||
return (
|
||
<div className="flex flex-wrap items-center gap-2 rounded-lg bg-gray-50 px-3 py-2">
|
||
<span className="text-[10px] font-semibold uppercase tracking-wider text-gray-400">各路状态</span>
|
||
{routes.map((r) => {
|
||
const tone = STATUS_TONE[r.status] ?? STATUS_TONE.empty;
|
||
return (
|
||
<span
|
||
key={r.name}
|
||
title={r.error || r.note || ""}
|
||
className={`inline-flex items-center gap-1 rounded px-2 py-0.5 text-[11px] ${tone.cls}`}
|
||
>
|
||
{ROUTE_CN[r.name] ?? r.name} · {tone.label}
|
||
<span className="tabular-nums opacity-60">{r.hits}命中/{r.ms}ms</span>
|
||
</span>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
})()}
|
||
{/* 非正常路的原因写全,别让人去翻日志 */}
|
||
{(res[""]?.routes ?? res["hybrid"]?.routes ?? [])
|
||
.filter((r) => r.status === "disabled" || r.status === "error")
|
||
.map((r) => (
|
||
<p key={r.name} className={`text-[11px] ${r.status === "error" ? "text-rose-600" : "text-amber-700"}`}>
|
||
{ROUTE_CN[r.name] ?? r.name}路{r.status === "error" ? "报错" : "未启用"}:{r.error || r.note}
|
||
</p>
|
||
))}
|
||
{/* 生产链路:用户实际拿到的结果 */}
|
||
<RouteCard
|
||
label="生产链路"
|
||
hint="混合检索 + rerank —— Agent 实际拿到的就是这个"
|
||
data={res[""]}
|
||
highlight
|
||
/>
|
||
|
||
{/* 诊断四路 */}
|
||
<div>
|
||
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-gray-300">
|
||
分路诊断(分数体系不同,勿跨路比大小)
|
||
</div>
|
||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||
{ROUTES.map((r) => {
|
||
// 单路那次请求里,取它自己那一路的诊断(RRF 融合没有单独一路,故为空)
|
||
const diag = res[r.mode]?.routes?.find((d) => d.name === r.mode);
|
||
return <RouteCard key={r.mode} label={r.label} hint={r.hint} data={res[r.mode]} diag={diag} />;
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className={`rounded-lg border p-3 ${highlight ? "border-violet-200 bg-violet-50/40" : "border-gray-100 bg-gray-50/40"}`}>
|
||
<div className="flex items-baseline justify-between gap-2">
|
||
<span className="text-xs font-semibold text-gray-700">{label}</span>
|
||
<span className={`text-[11px] tabular-nums ${hits.length ? "text-emerald-600" : "text-gray-400"}`}>
|
||
{hits.length} 命中
|
||
</span>
|
||
</div>
|
||
<div className="mt-0.5 text-[10px] text-gray-400">{hint}</div>
|
||
|
||
{data?.err ? (
|
||
<p className="mt-2 text-[11px] text-rose-500">{data.err}</p>
|
||
) : hits.length === 0 ? (
|
||
// 空的原因比"空"本身重要:没配置 / 报错 / 确实没匹配,处理方式完全不同
|
||
<p className={`mt-2 text-[11px] leading-relaxed ${diag?.status === "error" ? "text-rose-500" : diag?.status === "disabled" ? "text-amber-600" : "text-gray-400"}`}>
|
||
{diag?.status === "disabled"
|
||
? `未启用:${diag.note || "该路未配置"}`
|
||
: diag?.status === "error"
|
||
? `报错:${diag.error}`
|
||
: diag?.note || "这一路没召回(索引里确实没有匹配内容)"}
|
||
</p>
|
||
) : (
|
||
<ol className="mt-2 space-y-1.5">
|
||
{hits.map((h, i) => (
|
||
<li key={i} className="text-[11px] leading-relaxed">
|
||
<div className="flex items-baseline gap-1.5">
|
||
<span className="shrink-0 text-gray-300">#{i + 1}</span>
|
||
<span className="shrink-0 tabular-nums text-violet-600">{h.score.toFixed(4)}</span>
|
||
</div>
|
||
<p className="mt-0.5 line-clamp-3 text-gray-600" title={h.text}>
|
||
{h.text}
|
||
</p>
|
||
</li>
|
||
))}
|
||
</ol>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|