a4852c3aef
基建拉起后实测发现"三路混合检索"对中文其实只有向量路在干活: `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>
120 lines
3.1 KiB
Go
120 lines
3.1 KiB
Go
package rag
|
||
|
||
import (
|
||
"fmt"
|
||
"hash/fnv"
|
||
"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"
|
||
)
|
||
|
||
// bleveStore 是全文(BM25)检索路。内存索引:随 ingest 写入,进程重启重建。
|
||
// 真实生产应落盘(bleve.New(path,...));此处内存优先求简。
|
||
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(bleveMapping())
|
||
if err != nil {
|
||
log.Printf("[rag] bleve 初始化失败,全文路降级: %v", err)
|
||
return &bleveStore{}
|
||
}
|
||
return &bleveStore{idx: idx}
|
||
}
|
||
|
||
func (b *bleveStore) ready() bool { return b != nil && b.idx != nil }
|
||
|
||
// index 把 (kb, doc, texts) 写入全文索引(id 含 kb+doc+文本哈希,幂等)。
|
||
func (b *bleveStore) index(kb, doc string, texts []string) error {
|
||
if !b.ready() {
|
||
return nil
|
||
}
|
||
batch := b.idx.NewBatch()
|
||
for _, t := range texts {
|
||
id := fmt.Sprintf("%s:%s:%x", kb, doc, fnvHash(t))
|
||
if err := batch.Index(id, map[string]any{"text": t, "kb": kb, "doc": doc}); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return b.idx.Batch(batch)
|
||
}
|
||
|
||
// deleteDoc 删除某 (kb, doc) 的全部全文块(笔记重入库前清旧块)。
|
||
func (b *bleveStore) deleteDoc(kb, doc string) {
|
||
if !b.ready() || doc == "" {
|
||
return
|
||
}
|
||
kq := bleve.NewTermQuery(kb)
|
||
kq.SetField("kb")
|
||
dq := bleve.NewTermQuery(doc)
|
||
dq.SetField("doc")
|
||
req := bleve.NewSearchRequest(bleve.NewConjunctionQuery(kq, dq))
|
||
req.Size = 1000
|
||
res, err := b.idx.Search(req)
|
||
if err != nil {
|
||
return
|
||
}
|
||
batch := b.idx.NewBatch()
|
||
for _, h := range res.Hits {
|
||
batch.Delete(h.ID)
|
||
}
|
||
_ = b.idx.Batch(batch)
|
||
}
|
||
|
||
// search 全文检索(可按 kb 过滤),返回 BM25 排序的命中。
|
||
func (b *bleveStore) search(kb, q string, topK int) []Hit {
|
||
if !b.ready() || q == "" {
|
||
return nil
|
||
}
|
||
mq := bleve.NewMatchQuery(q)
|
||
mq.SetField("text")
|
||
var qy query.Query = mq
|
||
if kb != "" {
|
||
tq := bleve.NewTermQuery(kb)
|
||
tq.SetField("kb")
|
||
qy = bleve.NewConjunctionQuery(mq, tq)
|
||
}
|
||
req := bleve.NewSearchRequest(qy)
|
||
req.Size = topK
|
||
req.Fields = []string{"text"}
|
||
res, err := b.idx.Search(req)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
var hits []Hit
|
||
for _, h := range res.Hits {
|
||
text, _ := h.Fields["text"].(string)
|
||
if text != "" {
|
||
hits = append(hits, Hit{Text: text, Score: float32(h.Score)})
|
||
}
|
||
}
|
||
return hits
|
||
}
|
||
|
||
func fnvHash(s string) uint64 {
|
||
h := fnv.New64a()
|
||
_, _ = h.Write([]byte(s))
|
||
return h.Sum64()
|
||
}
|