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="生产链路"
|
||||
|
||||
@@ -548,9 +548,18 @@ func (h *Handler) KbSearch(c *gin.Context) {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": res.Error})
|
||||
return
|
||||
}
|
||||
var hits []map[string]any
|
||||
_ = json.Unmarshal([]byte(res.Content), &hits)
|
||||
c.JSON(http.StatusOK, gin.H{"hits": hits})
|
||||
var out struct {
|
||||
Hits []map[string]any `json:"hits"`
|
||||
Routes []map[string]any `json:"routes"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(res.Content), &out); err != nil {
|
||||
// 兼容老 mcp-go(未部署新版时仍回裸数组),别因为这个把试验台整个打挂。
|
||||
var hits []map[string]any
|
||||
_ = json.Unmarshal([]byte(res.Content), &hits)
|
||||
c.JSON(http.StatusOK, gin.H{"hits": hits})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"hits": out.Hits, "routes": out.Routes})
|
||||
}
|
||||
|
||||
// AdminKbSearch: POST /api/v1/admin/kb/search —— 管理端「检索试验台」:按**完整作用域键**
|
||||
@@ -573,7 +582,10 @@ func (h *Handler) AdminKbSearch(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "kb 与 q 必填"})
|
||||
return
|
||||
}
|
||||
args := map[string]any{"kb": body.KB, "q": body.Q}
|
||||
// diag=true:让 mcp-go 连每一路的诊断一起回(ok/empty/disabled/error)。
|
||||
// 试验台要回答的是"这一路为什么空"——只有命中数回答不了:没配置、报错、
|
||||
// 确实没匹配,三者在结果上都是空数组。
|
||||
args := map[string]any{"kb": body.KB, "q": body.Q, "diag": true}
|
||||
if body.TopK > 0 {
|
||||
args["topK"] = body.TopK
|
||||
}
|
||||
|
||||
@@ -347,11 +347,24 @@ func (g *Gateway) kbSearch(ctx context.Context, call *contract.ToolCall) *contra
|
||||
if v, ok := call.Args["topK"].(float64); ok && v > 0 {
|
||||
topK = int(v)
|
||||
}
|
||||
if !g.rag.Ready() {
|
||||
return &contract.ToolResult{OK: true, Content: "[]"}
|
||||
}
|
||||
// 这里**不能**用 g.rag.Ready() 当总闸。Ready() 要求 embedding 已配置 + Milvus 已连,
|
||||
// 但全文(bleve)与图谱(Neo4j)两路根本不依赖它们;一刀切返回 "[]" 会让"embedding 没配好"
|
||||
// 表现为"整个知识库搜不到东西",且没有任何错误信息。改为让 searchPaths 逐路判定,
|
||||
// 各路自己上报 disabled/error(见 rag.RouteDiag)。
|
||||
// mode 空=生产混合检索(含 rerank);显式 vector/fulltext/graph/hybrid=评测用单路/纯融合(不 rerank)。
|
||||
mode, _ := call.Args["mode"].(string)
|
||||
|
||||
// diag=true(仅检索试验台会传):返回 {hits, routes} 对象,带每一路的
|
||||
// ok/empty/disabled/error 诊断。不传时保持裸数组,生产调用方不受影响。
|
||||
if d, _ := call.Args["diag"].(bool); d {
|
||||
hits, diags := g.rag.SearchByModeDiag(ctx, kb, q, topK, mode)
|
||||
if hits == nil {
|
||||
hits = []rag.Hit{}
|
||||
}
|
||||
data, _ := json.Marshal(map[string]any{"hits": hits, "routes": diags})
|
||||
return &contract.ToolResult{OK: true, Content: string(data)}
|
||||
}
|
||||
|
||||
var hits []rag.Hit
|
||||
var err error
|
||||
if mode == "" {
|
||||
|
||||
@@ -126,9 +126,10 @@ func (b *bleveStore) deleteDoc(kb, doc string) error {
|
||||
}
|
||||
|
||||
// search 全文检索(可按 kb 过滤),返回 BM25 排序的命中。
|
||||
func (b *bleveStore) search(kb, q string, topK int) []Hit {
|
||||
// 错误如实返回:以前吞掉错误只回 nil,全文路挂了看起来就只是"没召回"。
|
||||
func (b *bleveStore) search(kb, q string, topK int) ([]Hit, error) {
|
||||
if !b.ready() || q == "" {
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
mq := bleve.NewMatchQuery(q)
|
||||
mq.SetField("text")
|
||||
@@ -143,7 +144,7 @@ func (b *bleveStore) search(kb, q string, topK int) []Hit {
|
||||
req.Fields = []string{"text"}
|
||||
res, err := b.idx.Search(req)
|
||||
if err != nil {
|
||||
return nil
|
||||
return nil, err
|
||||
}
|
||||
var hits []Hit
|
||||
for _, h := range res.Hits {
|
||||
@@ -152,7 +153,7 @@ func (b *bleveStore) search(kb, q string, topK int) []Hit {
|
||||
hits = append(hits, Hit{Text: text, Score: float32(h.Score)})
|
||||
}
|
||||
}
|
||||
return hits
|
||||
return hits, nil
|
||||
}
|
||||
|
||||
func fnvHash(s string) uint64 {
|
||||
|
||||
@@ -18,7 +18,10 @@ func TestBleve_ChineseSearch(t *testing.T) {
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hits := b.search(kb, "星云一号的总设计师是谁", 5)
|
||||
hits, err := b.search(kb, "星云一号的总设计师是谁", 5)
|
||||
if err != nil {
|
||||
t.Fatalf("全文检索报错: %v", err)
|
||||
}
|
||||
if len(hits) == 0 {
|
||||
t.Fatal("中文全文检索应命中(CJK 分词),got 0 —— 分词器回归了?")
|
||||
}
|
||||
@@ -56,7 +59,7 @@ func TestBleve_PersistsAcrossReopen(t *testing.T) {
|
||||
|
||||
b2 := openBleve() // 模拟重启:同路径重开
|
||||
defer b2.close()
|
||||
if hits := b2.search("kp", "龙渊号探测器", 5); len(hits) == 0 {
|
||||
if hits, err := b2.search("kp", "龙渊号探测器", 5); err != nil || len(hits) == 0 {
|
||||
t.Fatal("重开后应仍能检索到(落盘持久),got 0 —— 退回内存了?")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package rag
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 这组测试钉死「一路挂了不能拖垮全部」以及「三种空必须分得清」。
|
||||
//
|
||||
// 背景:embedding 未配置时,kb_search 与 Search() 都用 Ready() 当总闸直接返回空——
|
||||
// 而全文(bleve)、图谱(Neo4j)两路根本不依赖 embedding/Milvus。结果是"模型配置没下发"
|
||||
// 表现为"整个知识库什么都搜不到",且无任何错误信息,排查时毫无线索。
|
||||
|
||||
// newFulltextOnlyEngine 造一个只有全文路可用的引擎:无 embedding、无 Milvus、无 Neo4j,
|
||||
// 正是 mcp-go 先于 gateway 启动(控制面配置尚未下发)时的真实状态。
|
||||
func newFulltextOnlyEngine(t *testing.T) *Engine {
|
||||
t.Helper()
|
||||
t.Setenv("BLEVE_PATH", t.TempDir()+"/bleve")
|
||||
e := &Engine{bleve: openBleve(), graph: &graphStore{}}
|
||||
t.Cleanup(func() { e.bleve.close() })
|
||||
if !e.bleve.ready() {
|
||||
t.Skip("bleve 不可用")
|
||||
}
|
||||
if err := e.bleve.index("k1", "d1", []string{
|
||||
"星云一号卫星于2023年由长征七号发射,项目总设计师是李明华。",
|
||||
"北斗增强终端解决了星间链路的可靠性难题。",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// 没有 embedding/Milvus 时,全文路仍须照常召回——不能被总闸一刀切。
|
||||
func TestSearch_FulltextSurvivesWithoutEmbedding(t *testing.T) {
|
||||
e := newFulltextOnlyEngine(t)
|
||||
if e.Ready() {
|
||||
t.Fatal("前提不成立:该引擎本应是 not ready(无 embedding/Milvus)")
|
||||
}
|
||||
|
||||
hits, err := e.Search(context.Background(), "k1", "星间链路可靠性", 5)
|
||||
if err != nil {
|
||||
t.Fatalf("检索报错: %v", err)
|
||||
}
|
||||
if len(hits) == 0 {
|
||||
t.Fatal("embedding 未配置时全文路仍应有召回;返回空说明总闸回归了")
|
||||
}
|
||||
}
|
||||
|
||||
// 诊断必须区分三种"空":没配置 / 报错 / 确实没匹配。
|
||||
func TestSearchByModeDiag_DistinguishesEmptyKinds(t *testing.T) {
|
||||
e := newFulltextOnlyEngine(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, diags := e.SearchByModeDiag(ctx, "k1", "星间链路可靠性", 5, "hybrid")
|
||||
byName := map[string]RouteDiag{}
|
||||
for _, d := range diags {
|
||||
byName[d.Name] = d
|
||||
}
|
||||
if len(byName) != 3 {
|
||||
t.Fatalf("应有 vector/fulltext/graph 三路诊断,得 %v", diags)
|
||||
}
|
||||
|
||||
// 向量路:没配置 → disabled(不是 empty,更不是静默消失)
|
||||
if got := byName["vector"].Status; got != "disabled" {
|
||||
t.Fatalf("无 embedding 时向量路应为 disabled,得 %q", got)
|
||||
}
|
||||
if byName["vector"].Note == "" {
|
||||
t.Fatal("disabled 必须给出原因,否则界面上仍是一个无法解释的空")
|
||||
}
|
||||
// 图谱路:Neo4j 未连 → disabled
|
||||
if got := byName["graph"].Status; got != "disabled" {
|
||||
t.Fatalf("无 Neo4j 时图谱路应为 disabled,得 %q", got)
|
||||
}
|
||||
// 全文路:有命中 → ok
|
||||
if got := byName["fulltext"].Status; got != "ok" {
|
||||
t.Fatalf("全文路应为 ok,得 %q(hits=%d err=%q)", got, byName["fulltext"].Hits, byName["fulltext"].Error)
|
||||
}
|
||||
|
||||
// 换一个库里没有的词:全文路应是 empty(确实没匹配),而不是 disabled/error
|
||||
_, diags2 := e.SearchByModeDiag(ctx, "k1", "完全不相关的查询内容xyz", 5, "fulltext")
|
||||
for _, d := range diags2 {
|
||||
if d.Name == "fulltext" && d.Status != "empty" {
|
||||
t.Fatalf("无匹配时全文路应为 empty,得 %q", d.Status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 内存兜底(索引不持久)必须在诊断里说出来:命中数正常但数据随时会没,
|
||||
// 只看 hits 数量完全看不出来。
|
||||
func TestDiag_ReportsNonPersistentIndex(t *testing.T) {
|
||||
e := &Engine{bleve: memBleve(), graph: &graphStore{}}
|
||||
defer e.bleve.close()
|
||||
if err := e.bleve.index("k1", "d1", []string{"星间链路可靠性难题"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, diags := e.SearchByModeDiag(context.Background(), "k1", "星间链路", 5, "fulltext")
|
||||
for _, d := range diags {
|
||||
if d.Name != "fulltext" {
|
||||
continue
|
||||
}
|
||||
if d.Status != "ok" {
|
||||
t.Fatalf("内存索引也应能召回,得 %q", d.Status)
|
||||
}
|
||||
if d.Note == "" {
|
||||
t.Fatal("内存兜底必须在 note 里点明(重启即清零),否则运维看不出这是降级态")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -104,9 +104,10 @@ func (g *graphStore) deleteByFile(ctx context.Context, kb, fileID string) error
|
||||
}
|
||||
|
||||
// search 图谱召回:找查询里提到的实体,返回其相连三元组(文本化)。
|
||||
func (g *graphStore) search(ctx context.Context, kb, query string, limit int) []Hit {
|
||||
// 错误如实返回:Neo4j 不可用/查询失败以前一律回 nil,与"图谱里没有相关实体"无法区分。
|
||||
func (g *graphStore) search(ctx context.Context, kb, query string, limit int) ([]Hit, error) {
|
||||
if !g.ready() || query == "" {
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
// 匹配两路:① 查询整体含实体名($q CONTAINS);② 查询的字符 n-gram 与实体名互为子串
|
||||
// —— 解决"查询说'星云一号'、实体名抽成'星云一号卫星'"这类后缀错配(纯 $q CONTAINS 会漏)。
|
||||
@@ -118,7 +119,7 @@ func (g *graphStore) search(ctx context.Context, kb, query string, limit int) []
|
||||
map[string]any{"kb": kb, "q": query, "ngrams": queryNgrams(query), "k": limit},
|
||||
neo4j.EagerResultTransformer, neo4j.ExecuteQueryWithDatabase("neo4j"))
|
||||
if err != nil {
|
||||
return nil
|
||||
return nil, err
|
||||
}
|
||||
var hits []Hit
|
||||
for _, rec := range res.Records {
|
||||
@@ -127,7 +128,7 @@ func (g *graphStore) search(ctx context.Context, kb, query string, limit int) []
|
||||
o, _ := rec.Get("o")
|
||||
hits = append(hits, Hit{Text: fmt.Sprintf("%v —%v→ %v", s, p, o), Score: 1})
|
||||
}
|
||||
return hits
|
||||
return hits, nil
|
||||
}
|
||||
|
||||
// queryNgrams 生成查询的字符 n-gram(长度 2..8,去重并截断)——用于和图谱实体名做双向子串匹配,
|
||||
|
||||
@@ -221,8 +221,9 @@ func (m *milvusStore) search(ctx context.Context, kb string, qvec []float32, top
|
||||
results, err = do()
|
||||
}
|
||||
if err != nil {
|
||||
// 集合尚未就绪/无法重建 → 降级空结果(不阻断混合检索其它路)。
|
||||
return nil, nil
|
||||
// 如实上抛:调用方负责"不阻断其它路",但必须知道这一路是**失败**而非"没召回"。
|
||||
// 以前这里回 nil,nil,向量路挂掉与检索无果彻底无法区分。
|
||||
return nil, err
|
||||
}
|
||||
var hits []Hit
|
||||
for _, r := range results {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"os"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sundynix/sundynix-shared/contract"
|
||||
)
|
||||
@@ -233,17 +234,17 @@ func itoa(n int) string {
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// Search 混合检索:Milvus(向量) + Bleve(全文) → RRF 融合 → 可选 rerank → topK。降级时返回空。
|
||||
// Search 混合检索:Milvus(向量) + Bleve(全文) + Neo4j(图谱) → RRF 融合 → 可选 rerank → topK。
|
||||
// 注意这里**不再**用 Ready() 当总闸:Ready() 只代表"向量路可用",而全文/图谱两路不依赖
|
||||
// embedding 与 Milvus。以前一刀切返回空,导致 embedding 配置缺失时整个知识库像是"什么都搜不到",
|
||||
// 且无任何错误信息。现在各路独立判定,任一路可用就仍有召回(见 searchPaths 的 RouteDiag)。
|
||||
func (e *Engine) Search(ctx context.Context, kb, query string, topK int) ([]Hit, error) {
|
||||
if !e.Ready() {
|
||||
return nil, nil
|
||||
}
|
||||
if topK <= 0 {
|
||||
topK = 5
|
||||
}
|
||||
fanout := topK * 3
|
||||
|
||||
vecHits, ftHits, graphHits := e.searchPaths(ctx, kb, query, fanout)
|
||||
vecHits, ftHits, graphHits, _ := e.searchPaths(ctx, kb, query, fanout)
|
||||
// RRF 融合(三路,按文本去重)
|
||||
cand := rrf([][]Hit{vecHits, ftHits, graphHits}, fanout)
|
||||
log.Printf("[rag] hybrid: 向量=%d 全文=%d 图谱=%d → 融合=%d", len(vecHits), len(ftHits), len(graphHits), len(cand))
|
||||
@@ -286,26 +287,129 @@ func (e *Engine) DeleteDoc(ctx context.Context, kb, fileID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// searchPaths 跑三路召回,返回各路命中(供混合融合与离线评测按单路对比)。
|
||||
func (e *Engine) searchPaths(ctx context.Context, kb, query string, fanout int) (vec, ft, graph []Hit) {
|
||||
if vecs, err := e.embed().Embed(ctx, []string{query}); err == nil && len(vecs) > 0 {
|
||||
vec, _ = e.mv.search(ctx, kb, vecs[0], fanout)
|
||||
// RouteDiag 是一路召回的诊断。存在的理由:三路里任何一路挂掉都**不会报错**,
|
||||
// 只表现为召回变差——"这一路没配置"、"这一路报错了"、"这一路确实没匹配"
|
||||
// 在结果上完全一样(都是空数组),运维无从分辨。检索试验台据此告诉人是哪一环坏了。
|
||||
type RouteDiag struct {
|
||||
Name string `json:"name"` // vector | fulltext | graph
|
||||
Status string `json:"status"` // ok | empty | disabled | error
|
||||
Hits int `json:"hits"` //
|
||||
MS int64 `json:"ms"` // 该路耗时
|
||||
Error string `json:"error,omitempty"` // status=error 时的原文
|
||||
Note string `json:"note,omitempty"` // 给人看的解释
|
||||
}
|
||||
|
||||
func diagOf(name string, hits []Hit, err error, disabled bool, note string, started time.Time) RouteDiag {
|
||||
d := RouteDiag{Name: name, Hits: len(hits), MS: time.Since(started).Milliseconds(), Note: note}
|
||||
switch {
|
||||
case disabled:
|
||||
d.Status = "disabled"
|
||||
case err != nil:
|
||||
d.Status, d.Error = "error", err.Error()
|
||||
case len(hits) == 0:
|
||||
d.Status = "empty"
|
||||
default:
|
||||
d.Status = "ok"
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// searchPaths 跑三路召回,返回各路命中 + 各路诊断(供混合融合、离线评测与检索试验台)。
|
||||
// 任何一路失败都不阻断其它路,但失败会被如实记录并打日志——绝不静默当成"没召回"。
|
||||
func (e *Engine) searchPaths(ctx context.Context, kb, query string, fanout int) (vec, ft, graph []Hit, diags []RouteDiag) {
|
||||
// ── 向量路:embedding 与 Milvus 任一环节失败都要区分出来 ──
|
||||
t := time.Now()
|
||||
var vErr error
|
||||
var vNote string
|
||||
vDisabled := !e.embed().ready() || e.mv == nil
|
||||
if vDisabled {
|
||||
vNote = "embedding 未配置或 Milvus 未连接"
|
||||
} else {
|
||||
vecs, err := e.embed().Embed(ctx, []string{query})
|
||||
switch {
|
||||
case err != nil:
|
||||
vErr, vNote = fmt.Errorf("embedding: %w", err), "查询向量化失败,向量路本次无贡献"
|
||||
case len(vecs) == 0:
|
||||
vErr, vNote = errors.New("embedding 返回空向量"), "向量化返回空结果"
|
||||
default:
|
||||
vec, vErr = e.mv.search(ctx, kb, vecs[0], fanout)
|
||||
if vErr != nil {
|
||||
vNote = "Milvus 检索失败"
|
||||
} else if len(vec) == 0 {
|
||||
vNote = "该知识库在 Milvus 中没有向量(未入库或集合被重建过)"
|
||||
}
|
||||
}
|
||||
}
|
||||
diags = append(diags, diagOf("vector", vec, vErr, vDisabled, vNote, t))
|
||||
|
||||
// ── 全文路 ──
|
||||
t = time.Now()
|
||||
ftDisabled := !e.bleve.ready()
|
||||
var ftErr error
|
||||
ftNote := ""
|
||||
if ftDisabled {
|
||||
ftNote = "全文索引未就绪"
|
||||
} else {
|
||||
ft, ftErr = e.bleve.search(kb, query, fanout)
|
||||
if !e.bleve.persistent {
|
||||
ftNote = "索引为内存兜底(重启已清零,历史文档需重新入库)"
|
||||
}
|
||||
}
|
||||
diags = append(diags, diagOf("fulltext", ft, ftErr, ftDisabled, ftNote, t))
|
||||
|
||||
// ── 图谱路 ──
|
||||
t = time.Now()
|
||||
gDisabled := !e.graph.ready()
|
||||
var gErr error
|
||||
gNote := ""
|
||||
if gDisabled {
|
||||
gNote = "Neo4j 未连接或未配置"
|
||||
} else {
|
||||
graph, gErr = e.graph.search(ctx, kb, query, fanout)
|
||||
}
|
||||
diags = append(diags, diagOf("graph", graph, gErr, gDisabled, gNote, t))
|
||||
|
||||
for _, d := range diags {
|
||||
if d.Status == "error" {
|
||||
log.Printf("[rag] ⚠️ %s 路检索失败 kb=%s: %s", d.Name, kb, d.Error)
|
||||
}
|
||||
}
|
||||
ft = e.bleve.search(kb, query, fanout)
|
||||
graph = e.graph.search(ctx, kb, query, fanout)
|
||||
return
|
||||
}
|
||||
|
||||
// SearchByModeDiag 与 SearchByMode 同源,额外返回各路诊断(检索试验台用)。
|
||||
// 试验台要回答的是"为什么这一路是空的",光有命中数回答不了。
|
||||
func (e *Engine) SearchByModeDiag(ctx context.Context, kb, query string, topK int, mode string) ([]Hit, []RouteDiag) {
|
||||
if topK <= 0 {
|
||||
topK = 5
|
||||
}
|
||||
fanout := topK * 3
|
||||
vec, ft, graph, diags := e.searchPaths(ctx, kb, query, fanout)
|
||||
var hits []Hit
|
||||
switch mode {
|
||||
case "vector":
|
||||
hits = vec
|
||||
case "fulltext":
|
||||
hits = ft
|
||||
case "graph":
|
||||
hits = graph
|
||||
default:
|
||||
hits = rrf([][]Hit{vec, ft, graph}, fanout)
|
||||
}
|
||||
if len(hits) > topK {
|
||||
hits = hits[:topK]
|
||||
}
|
||||
return hits, diags
|
||||
}
|
||||
|
||||
// SearchByMode 按指定模式返回 topK(评测用,纯检索不 rerank,便于公平对比)。
|
||||
// mode: vector|fulltext|graph|hybrid(RRF)。
|
||||
func (e *Engine) SearchByMode(ctx context.Context, kb, query string, topK int, mode string) []Hit {
|
||||
if !e.Ready() || topK <= 0 {
|
||||
if topK <= 0 {
|
||||
topK = 5
|
||||
}
|
||||
if topK <= 0 {
|
||||
topK = 5
|
||||
}
|
||||
fanout := topK * 3
|
||||
vec, ft, graph := e.searchPaths(ctx, kb, query, fanout)
|
||||
vec, ft, graph, _ := e.searchPaths(ctx, kb, query, fanout)
|
||||
var hits []Hit
|
||||
switch mode {
|
||||
case "vector":
|
||||
|
||||
Reference in New Issue
Block a user