Files
sundynix-agentix/sundynix-mcp-go/internal/rag/degrade_test.go
T
Blizzard b26fe21408 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>
2026-07-20 15:10:03 +08:00

109 lines
4.0 KiB
Go
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.
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,得 %qhits=%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 里点明(重启即清零),否则运维看不出这是降级态")
}
}
}