b26fe21408
排查"向量路为什么是空的"花了半小时,因为空就是空,没有任何线索。这次把
整条检索链上的静默降级一次清掉。
真 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>
86 lines
2.7 KiB
Go
86 lines
2.7 KiB
Go
package rag
|
||
|
||
import "testing"
|
||
|
||
// TestBleve_ChineseSearch 钉死中文全文检索:CJK 分词器让"星云一号的总设计师"能命中含
|
||
// "星云一号""总设计师"的中文块。修复前默认标准分词器把整段中文当一个 token → 永远 0 命中。
|
||
func TestBleve_ChineseSearch(t *testing.T) {
|
||
t.Setenv("BLEVE_PATH", t.TempDir()+"/bleve") // 隔离落盘索引,测试互不污染
|
||
b := openBleve()
|
||
defer b.close()
|
||
if !b.ready() {
|
||
t.Skip("bleve 不可用")
|
||
}
|
||
const kb = "k1"
|
||
if err := b.index(kb, "d1", []string{
|
||
"星云一号卫星于2023年由长征七号发射,项目总设计师是李明华。",
|
||
"今天天气不错,适合出门散步。",
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
hits, err := b.search(kb, "星云一号的总设计师是谁", 5)
|
||
if err != nil {
|
||
t.Fatalf("全文检索报错: %v", err)
|
||
}
|
||
if len(hits) == 0 {
|
||
t.Fatal("中文全文检索应命中(CJK 分词),got 0 —— 分词器回归了?")
|
||
}
|
||
if !contains(hits[0].Text, "总设计师") {
|
||
t.Fatalf("最相关块应含'总设计师',got %q", hits[0].Text)
|
||
}
|
||
}
|
||
|
||
// TestQueryNgrams 钉死查询 n-gram:含"星云一号"等子串,能与更长实体名"星云一号卫星"双向匹配。
|
||
func TestQueryNgrams(t *testing.T) {
|
||
ng := queryNgrams("星云一号的总设计师是谁")
|
||
if !hasStr(ng, "星云一号") {
|
||
t.Fatalf("应含 2..8 长度子串'星云一号',got %v", ng)
|
||
}
|
||
// 长度边界:最短 2、最长 8。
|
||
for _, s := range ng {
|
||
if r := []rune(s); len(r) < 2 || len(r) > 8 {
|
||
t.Fatalf("n-gram 长度应在 [2,8],got %q(len=%d)", s, len(r))
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestBleve_PersistsAcrossReopen 钉死落盘:写入→关闭→同路径重开,数据仍在。
|
||
// 修复"NewMemOnly 进程重启即丢、三路静默退化两路"的回归——这是 P0 的核心验收。
|
||
func TestBleve_PersistsAcrossReopen(t *testing.T) {
|
||
t.Setenv("BLEVE_PATH", t.TempDir()+"/bleve")
|
||
b1 := openBleve()
|
||
if !b1.ready() {
|
||
t.Skip("bleve 不可用")
|
||
}
|
||
if err := b1.index("kp", "d1", []string{"持久化测试:龙渊号探测器由总师陈思远负责。"}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
b1.close() // 模拟进程退出(落盘刷盘)
|
||
|
||
b2 := openBleve() // 模拟重启:同路径重开
|
||
defer b2.close()
|
||
if hits, err := b2.search("kp", "龙渊号探测器", 5); err != nil || len(hits) == 0 {
|
||
t.Fatal("重开后应仍能检索到(落盘持久),got 0 —— 退回内存了?")
|
||
}
|
||
}
|
||
|
||
func contains(s, sub string) bool {
|
||
return len(sub) == 0 || (len(s) >= len(sub) && indexOf(s, sub) >= 0)
|
||
}
|
||
func indexOf(s, sub string) int {
|
||
for i := 0; i+len(sub) <= len(s); i++ {
|
||
if s[i:i+len(sub)] == sub {
|
||
return i
|
||
}
|
||
}
|
||
return -1
|
||
}
|
||
func hasStr(ss []string, want string) bool {
|
||
for _, s := range ss {
|
||
if s == want {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|