Files
sundynix-agentix/sundynix-admin/src/components/RetrievalBench.tsx
T
Blizzard 518cddbd1c feat(admin): 检索试验台分路卡片直接说明"为什么空"
此前四张卡片一律写"这一路没召回",而人的视线落在卡片上、不在顶部状态行。
现在按该路诊断分别显示:未启用给出缺什么配置(琥珀)、报错给出错误原文(红)、
真没匹配才说没召回。

真环境验证(截图为证):向量="该知识库在 Milvus 中没有向量(未入库或集合被
重建过)"、图谱="未启用:Neo4j 未连接或未配置"、全文="索引里确实没有匹配内容"。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 15:29:04 +08:00

231 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}