diff --git a/sundynix-mcp-go/internal/rag/bleve.go b/sundynix-mcp-go/internal/rag/bleve.go index 11cf3fc..c41a778 100644 --- a/sundynix-mcp-go/internal/rag/bleve.go +++ b/sundynix-mcp-go/internal/rag/bleve.go @@ -6,6 +6,9 @@ import ( "log" "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" ) @@ -15,8 +18,24 @@ type bleveStore struct { 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 { - idx, err := bleve.NewMemOnly(bleve.NewIndexMapping()) + idx, err := bleve.NewMemOnly(bleveMapping()) if err != nil { log.Printf("[rag] bleve 初始化失败,全文路降级: %v", err) return &bleveStore{} diff --git a/sundynix-mcp-go/internal/rag/cjk_test.go b/sundynix-mcp-go/internal/rag/cjk_test.go new file mode 100644 index 0000000..650709f --- /dev/null +++ b/sundynix-mcp-go/internal/rag/cjk_test.go @@ -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 +} diff --git a/sundynix-mcp-go/internal/rag/graph.go b/sundynix-mcp-go/internal/rag/graph.go index 6589c05..8306b6d 100644 --- a/sundynix-mcp-go/internal/rag/graph.go +++ b/sundynix-mcp-go/internal/rag/graph.go @@ -84,11 +84,14 @@ func (g *graphStore) search(ctx context.Context, kb, query string, limit int) [] if !g.ready() || query == "" { return nil } + // 匹配两路:① 查询整体含实体名($q CONTAINS);② 查询的字符 n-gram 与实体名互为子串 + // —— 解决"查询说'星云一号'、实体名抽成'星云一号卫星'"这类后缀错配(纯 $q CONTAINS 会漏)。 res, err := neo4j.ExecuteQuery(ctx, g.driver, `MATCH (a:Entity {kb:$kb})-[r:REL]->(b:Entity {kb:$kb}) 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`, - 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")) if err != nil { return nil @@ -103,6 +106,28 @@ func (g *graphStore) search(ctx context.Context, kb, query string, limit int) [] 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 图谱可视化)。 func (g *graphStore) triples(ctx context.Context, kb string, limit int) []Triple { if !g.ready() {