fix(rag): T2.2 把"三路混合"做实 —— 中文全文/图谱两路从 0 贡献修活
基建拉起后实测发现"三路混合检索"对中文其实只有向量路在干活: `hybrid: 向量=1 全文=0 图谱=0` —— 全文、图谱两路恒 0 贡献(典型"广而浅")。两个真因: 1. Bleve 全文:默认标准分词器不切中文 → 整段当一个 token → 中文查询永远 0 命中。 修:text 字段用 cjk 分词器(bigram),kb/doc 用 keyword(保 TermQuery 精确过滤)。 2. 图谱检索:`$q CONTAINS a.name` 要求实体名是查询子串,而 LLM 把实体抽成"星云一号卫星" (带后缀),查询说"星云一号"→ CONTAINS 失败 → 0 命中。 修:加查询字符 n-gram(2..8)双向子串匹配,`星云一号` 即可命中 `星云一号卫星`。 live 验证(同一查询):修复前 `向量=1 全文=0 图谱=0` → 修复后 `向量=1 全文=1 图谱=9`, 三路全贡献。中文混合检索从 1/3 变真 3/3。 测试:Bleve 中文检索命中 + queryNgrams 子串/长度边界(CI 安全,无需基建)。 DEPTH_ROADMAP T2.2:三路做实✅;检索质量度量(离线评测集 recall@k/MRR)待补。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,9 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
|
|
||||||
"github.com/blevesearch/bleve/v2"
|
"github.com/blevesearch/bleve/v2"
|
||||||
|
"github.com/blevesearch/bleve/v2/analysis/analyzer/keyword"
|
||||||
|
"github.com/blevesearch/bleve/v2/analysis/lang/cjk"
|
||||||
|
"github.com/blevesearch/bleve/v2/mapping"
|
||||||
"github.com/blevesearch/bleve/v2/search/query"
|
"github.com/blevesearch/bleve/v2/search/query"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -15,8 +18,24 @@ type bleveStore struct {
|
|||||||
idx bleve.Index
|
idx bleve.Index
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// bleveMapping:text 字段用 cjk 分词器(bigram,能切中文,否则默认标准分词器把整段中文当一个
|
||||||
|
// token → 中文全文检索永远 0 命中);kb/doc 用 keyword(不分词,保 TermQuery 精确过滤)。
|
||||||
|
func bleveMapping() mapping.IndexMapping {
|
||||||
|
text := bleve.NewTextFieldMapping()
|
||||||
|
text.Analyzer = cjk.AnalyzerName
|
||||||
|
kw := bleve.NewTextFieldMapping()
|
||||||
|
kw.Analyzer = keyword.Name
|
||||||
|
doc := bleve.NewDocumentMapping()
|
||||||
|
doc.AddFieldMappingsAt("text", text)
|
||||||
|
doc.AddFieldMappingsAt("kb", kw)
|
||||||
|
doc.AddFieldMappingsAt("doc", kw)
|
||||||
|
im := bleve.NewIndexMapping()
|
||||||
|
im.DefaultMapping = doc
|
||||||
|
return im
|
||||||
|
}
|
||||||
|
|
||||||
func openBleve() *bleveStore {
|
func openBleve() *bleveStore {
|
||||||
idx, err := bleve.NewMemOnly(bleve.NewIndexMapping())
|
idx, err := bleve.NewMemOnly(bleveMapping())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[rag] bleve 初始化失败,全文路降级: %v", err)
|
log.Printf("[rag] bleve 初始化失败,全文路降级: %v", err)
|
||||||
return &bleveStore{}
|
return &bleveStore{}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package rag
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// TestBleve_ChineseSearch 钉死中文全文检索:CJK 分词器让"星云一号的总设计师"能命中含
|
||||||
|
// "星云一号""总设计师"的中文块。修复前默认标准分词器把整段中文当一个 token → 永远 0 命中。
|
||||||
|
func TestBleve_ChineseSearch(t *testing.T) {
|
||||||
|
b := openBleve()
|
||||||
|
if !b.ready() {
|
||||||
|
t.Skip("bleve 不可用")
|
||||||
|
}
|
||||||
|
const kb = "k1"
|
||||||
|
if err := b.index(kb, "d1", []string{
|
||||||
|
"星云一号卫星于2023年由长征七号发射,项目总设计师是李明华。",
|
||||||
|
"今天天气不错,适合出门散步。",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
hits := b.search(kb, "星云一号的总设计师是谁", 5)
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -84,11 +84,14 @@ func (g *graphStore) search(ctx context.Context, kb, query string, limit int) []
|
|||||||
if !g.ready() || query == "" {
|
if !g.ready() || query == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
// 匹配两路:① 查询整体含实体名($q CONTAINS);② 查询的字符 n-gram 与实体名互为子串
|
||||||
|
// —— 解决"查询说'星云一号'、实体名抽成'星云一号卫星'"这类后缀错配(纯 $q CONTAINS 会漏)。
|
||||||
res, err := neo4j.ExecuteQuery(ctx, g.driver,
|
res, err := neo4j.ExecuteQuery(ctx, g.driver,
|
||||||
`MATCH (a:Entity {kb:$kb})-[r:REL]->(b:Entity {kb:$kb})
|
`MATCH (a:Entity {kb:$kb})-[r:REL]->(b:Entity {kb:$kb})
|
||||||
WHERE $q CONTAINS a.name OR $q CONTAINS b.name
|
WHERE $q CONTAINS a.name OR $q CONTAINS b.name
|
||||||
|
OR any(ng IN $ngrams WHERE a.name CONTAINS ng OR b.name CONTAINS ng)
|
||||||
RETURN a.name AS s, r.type AS p, b.name AS o LIMIT $k`,
|
RETURN a.name AS s, r.type AS p, b.name AS o LIMIT $k`,
|
||||||
map[string]any{"kb": kb, "q": query, "k": limit},
|
map[string]any{"kb": kb, "q": query, "ngrams": queryNgrams(query), "k": limit},
|
||||||
neo4j.EagerResultTransformer, neo4j.ExecuteQueryWithDatabase("neo4j"))
|
neo4j.EagerResultTransformer, neo4j.ExecuteQueryWithDatabase("neo4j"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -103,6 +106,28 @@ func (g *graphStore) search(ctx context.Context, kb, query string, limit int) []
|
|||||||
return hits
|
return hits
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// queryNgrams 生成查询的字符 n-gram(长度 2..8,去重并截断)——用于和图谱实体名做双向子串匹配,
|
||||||
|
// 解决实体名比查询里提到的更长/更短的错配(中文无词边界,按字符 n-gram 是务实做法)。
|
||||||
|
func queryNgrams(q string) []string {
|
||||||
|
r := []rune(q)
|
||||||
|
const minLen, maxLen, cap = 2, 8, 80
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
out := make([]string, 0, cap)
|
||||||
|
for i := 0; i < len(r); i++ {
|
||||||
|
for l := minLen; l <= maxLen && i+l <= len(r); l++ {
|
||||||
|
s := string(r[i : i+l])
|
||||||
|
if !seen[s] {
|
||||||
|
seen[s] = true
|
||||||
|
out = append(out, s)
|
||||||
|
if len(out) >= cap {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// triples 返回某 kb 的全部三元组(供 UI 图谱可视化)。
|
// triples 返回某 kb 的全部三元组(供 UI 图谱可视化)。
|
||||||
func (g *graphStore) triples(ctx context.Context, kb string, limit int) []Triple {
|
func (g *graphStore) triples(ctx context.Context, kb string, limit int) []Triple {
|
||||||
if !g.ready() {
|
if !g.ready() {
|
||||||
|
|||||||
Reference in New Issue
Block a user