fix(rag): 检索三路不再静默吞错 —— 逐路诊断 + 一路挂不拖垮全部
排查"向量路为什么是空的"花了半小时,因为空就是空,没有任何线索。这次把
整条检索链上的静默降级一次清掉。
真 bug(不只是可观测性):
- kb_search 与 Search() 都拿 rag.Ready() 当总闸,而 Ready() 只代表"向量路
可用"(embedding + Milvus)。全文(bleve)与图谱(Neo4j)根本不依赖它们,却
被一并毙掉 → "模型配置没下发"表现为"整个知识库什么都搜不到",还不报错。
改为逐路判定,任一路可用就仍有召回。
不再吞错:
- milvus.search 原先把 error 转成 nil,nil —— 检索失败与无召回彻底无法区分;
- bleve.search / graph.search 出错直接回 nil,连日志都没有;
- searchPaths 丢掉 embedding 的 error。
三处改为如实返回,错误统一打日志。
逐路诊断(RouteDiag):每路上报 ok/empty/disabled/error + 耗时 + 原因,经
kb_search 的 diag 参数(仅试验台传,生产调用返回值不变)→ gateway → 检索
试验台。界面上现在能直接看出"这一路没配置/报错了/确实没匹配",不必翻日志。
内存兜底索引也会在 note 里点明"重启即清零"。
测试:3 组,覆盖"无 embedding 时全文仍可召回"、三种空的区分、内存索引提示。
把总闸加回去验证过第一条确实会红——测试能抓到这个回归,不是摆设。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -513,9 +513,24 @@ export interface KbHit {
|
||||
/** 检索模式:单路用于逐路定位是哪一路没召回;hybrid=纯 RRF 融合(不 rerank);""=生产链路(混合+rerank)。 */
|
||||
export type SearchMode = "" | "vector" | "fulltext" | "graph" | "hybrid";
|
||||
|
||||
/** RouteDiag 一路召回的诊断。三种"空"必须能分辨:没配置(disabled)、报错(error)、确实没匹配(empty)。 */
|
||||
export interface RouteDiag {
|
||||
name: "vector" | "fulltext" | "graph";
|
||||
status: "ok" | "empty" | "disabled" | "error";
|
||||
hits: number;
|
||||
ms: number;
|
||||
error?: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
// adminKbSearch 检索试验台:按完整作用域键跨租户检索任意知识库。
|
||||
// kb 传 `${space_id}/${name}`(来自 adminDatasources)。
|
||||
export async function adminKbSearch(kb: string, q: string, topK = 5, mode: SearchMode = ""): Promise<KbHit[]> {
|
||||
// kb 传 `${space_id}/${name}`(来自 adminDatasources)。返回命中 + 每一路的诊断。
|
||||
export async function adminKbSearch(
|
||||
kb: string,
|
||||
q: string,
|
||||
topK = 5,
|
||||
mode: SearchMode = "",
|
||||
): Promise<{ hits: KbHit[]; routes: RouteDiag[] }> {
|
||||
const res = guard(
|
||||
await fetch(`${ADMIN}/kb/search`, {
|
||||
method: "POST",
|
||||
@@ -523,9 +538,9 @@ export async function adminKbSearch(kb: string, q: string, topK = 5, mode: Searc
|
||||
body: JSON.stringify({ kb, q, topK, mode }),
|
||||
}),
|
||||
);
|
||||
const d = (await res.json().catch(() => ({}))) as { hits?: KbHit[]; error?: string };
|
||||
const d = (await res.json().catch(() => ({}))) as { hits?: KbHit[]; routes?: RouteDiag[]; error?: string };
|
||||
if (!res.ok) throw new Error(d.error ?? `search failed: ${res.status}`);
|
||||
return d.hits ?? [];
|
||||
return { hits: d.hits ?? [], routes: d.routes ?? [] };
|
||||
}
|
||||
|
||||
export async function adminDatasources(): Promise<{ counts: { users: number; kbs: number; docs: number }; datasources: DatasourceKB[] }> {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { adminKbSearch, type DatasourceKB, type KbHit, type SearchMode } from "../api";
|
||||
import { adminKbSearch, type DatasourceKB, type KbHit, type RouteDiag, type SearchMode } from "../api";
|
||||
|
||||
// 检索试验台:对同一个 query 同时跑「生产链路」与「三路 + RRF 融合」,并排看各自召回。
|
||||
// 用途:线上召回不准时定位是哪一环 —— 向量路空=embedding/切块问题;全文路空=分词/索引问题;
|
||||
@@ -14,7 +14,17 @@ const ROUTES: Array<{ mode: SearchMode; label: string; hint: string }> = [
|
||||
{ mode: "hybrid", label: "RRF 融合", hint: "三路融合 · 不含 rerank" },
|
||||
];
|
||||
|
||||
type Results = Partial<Record<string, { hits: KbHit[]; err?: string }>>;
|
||||
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);
|
||||
@@ -34,7 +44,7 @@ export function RetrievalBench({ kbs }: { kbs: DatasourceKB[] }) {
|
||||
const settled = await Promise.all(
|
||||
modes.map(async (m) => {
|
||||
try {
|
||||
return [m, { hits: await adminKbSearch(kbKey, q.trim(), topK, m) }] as const;
|
||||
return [m, await adminKbSearch(kbKey, q.trim(), topK, m)] as const;
|
||||
} catch (e) {
|
||||
return [m, { hits: [], err: (e as Error).message }] as const;
|
||||
}
|
||||
@@ -107,6 +117,37 @@ export function RetrievalBench({ kbs }: { kbs: DatasourceKB[] }) {
|
||||
|
||||
{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="生产链路"
|
||||
|
||||
Reference in New Issue
Block a user