feat: GraphRAG — LLM 抽三元组建 Neo4j 图谱 + 混合检索加图谱第三路

混合检索从 2 路(向量+全文)升级为 3 路(+图谱)。入库时 LLM 抽实体/关系建
Neo4j 图,检索时图谱路(实体关联三元组)融进 RRF;UI 可视化图谱。

- mcp-go rag: chat.go(OpenAI 兼容非流式 chat 客户端,抽取用) + graph.go(neo4j-go-driver
  连接 + LLM 抽三元组 + MERGE 实体/关系 + 图谱召回/全量三元组) + rag.go(Config 结构;
  graph+chat 路;Ingest 加 抽实体/写Neo4j 阶段;Search 三路 RRF 融合;SetChat 热更新)
- mcp-go: Neo4j env(默认 neo4j://localhost:7687, neo4j/sundynix);订阅 chat 控制面配置
  (复用 DeepSeek 做抽取);新工具 kb_graph(返回三元组)
- gateway: GET /api/v1/kb/graph;frontend KbView 知识图谱面板(实体—关系→实体)
- 验证: 全模块 build✓ + e2e PASS; live——入库'sundynix用Milvus...'→DeepSeek 抽 4 三元组
  →Neo4j(8 实体);检索三路融合 向量=4 全文=2 图谱=1;浏览器图谱面板渲染 4 三元组
- 边界: 实体链接用 CONTAINS 朴素匹配(可升级 LLM 查询实体抽取);全文/图谱重启随入库重建

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-11 11:10:22 +08:00
parent 2d5fd2fca5
commit d623b8590e
11 changed files with 399 additions and 22 deletions
+69 -16
View File
@@ -15,14 +15,24 @@ import (
// embedBatch 是每批向量化的块数(让大文件的入库进度可观测)。
const embedBatch = 10
// Engine 聚合 embedding + Milvus(向量) + Bleve(全文) + RRF 融合 + 可选 rerank
// embedding 可热更新(控制面下发)。
// Config 是 RAG 引擎的初始化配置
type Config struct {
MilvusAddr string
EmbedBase, EmbedKey, EmbedModel string
RerankBase, RerankKey, RerankModel string
Neo4jURI, Neo4jUser, Neo4jPass string
}
// Engine 聚合 embedding + Milvus(向量) + Bleve(全文) + Neo4j(图谱) → RRF 融合 + 可选 rerank。
// embedding 与 chat(图谱抽取用)可热更新(控制面下发)。
type Engine struct {
mu sync.RWMutex
emb *embedClient
chat *chatClient
mv *milvusStore
bleve *bleveStore
rerank *rerankClient
graph *graphStore
}
// SetEmbedding 热更新 embedding 配置(控制面变更时调用)。空配置=关闭向量检索。
@@ -37,36 +47,60 @@ func (e *Engine) SetEmbedding(base, key, model string) {
log.Printf("[rag] embedding 配置: %s model=%s", base, model)
}
// SetChat 热更新对话模型配置(图谱实体抽取用,复用控制面 chat 模型)。
func (e *Engine) SetChat(base, key, model string) {
e.mu.Lock()
defer e.mu.Unlock()
e.chat = newChatClient(base, key, model)
if e.chat.ready() {
log.Printf("[rag] 图谱抽取模型: %s model=%s", base, model)
}
}
func (e *Engine) embed() *embedClient {
e.mu.RLock()
defer e.mu.RUnlock()
return e.emb
}
// Open 建立 RAG 引擎。embedding 未配 / Milvus 连不上 → 降级(检索返回空,不阻断工具服务)。
// rerank* 为空则不启用重排(融合结果直接返回)。
func Open(ctx context.Context, milvusAddr, embBase, embKey, embModel, rerankBase, rerankKey, rerankModel string) *Engine {
e := &Engine{bleve: openBleve(), rerank: newRerankClient(rerankBase, rerankKey, rerankModel)}
if e.rerank.ready() {
log.Printf("[rag] rerank: %s model=%s", rerankBase, rerankModel)
func (e *Engine) chatClient() *chatClient {
e.mu.RLock()
defer e.mu.RUnlock()
return e.chat
}
// Open 建立 RAG 引擎。各路连不上 → 降级(不阻断工具服务)。
func Open(ctx context.Context, cfg Config) *Engine {
e := &Engine{
bleve: openBleve(),
rerank: newRerankClient(cfg.RerankBase, cfg.RerankKey, cfg.RerankModel),
graph: openGraph(ctx, cfg.Neo4jURI, cfg.Neo4jUser, cfg.Neo4jPass),
}
if embBase != "" && embModel != "" {
e.SetEmbedding(embBase, embKey, embModel) // env 初值(控制面会覆盖)
if e.rerank.ready() {
log.Printf("[rag] rerank: %s model=%s", cfg.RerankBase, cfg.RerankModel)
}
if cfg.EmbedBase != "" && cfg.EmbedModel != "" {
e.SetEmbedding(cfg.EmbedBase, cfg.EmbedKey, cfg.EmbedModel)
} else {
log.Println("[rag] embedding 未配置(待控制面下发),向量检索暂降级")
}
if milvusAddr != "" {
mv, err := openMilvus(ctx, milvusAddr)
if cfg.MilvusAddr != "" {
mv, err := openMilvus(ctx, cfg.MilvusAddr)
if err != nil {
log.Printf("[rag] Milvus 不可用,向量检索降级: %v", err)
} else {
e.mv = mv
log.Printf("[rag] Milvus connected %s", milvusAddr)
log.Printf("[rag] Milvus connected %s", cfg.MilvusAddr)
}
}
return e
}
// Triples 返回某 kb 的图谱三元组(供 UI 可视化)。
func (e *Engine) Triples(ctx context.Context, kb string, limit int) []Triple {
return e.graph.triples(ctx, kb, limit)
}
// Ready 报告 RAG 是否可用(embedding + Milvus 均就绪)。
func (e *Engine) Ready() bool { return e.embed().ready() && e.mv != nil }
@@ -108,6 +142,22 @@ func (e *Engine) Ingest(ctx context.Context, kb, text string, onProgress func(co
emit(contract.IngestEvent{Stage: "写Bleve", Msg: "全文索引写入中"})
_ = e.bleve.index(kb, chunks) // 同步写全文索引(失败不阻断向量入库)
// 图谱路:LLM 抽实体/关系 → Neo4j(可降级,不阻断向量入库)。
if e.graph.ready() && e.chatClient().ready() {
emit(contract.IngestEvent{Stage: "抽实体", Msg: "LLM 抽取知识三元组"})
triples, terr := extractTriples(ctx, e.chatClient(), text)
if terr != nil {
log.Printf("[rag] 三元组抽取失败(图谱降级): %v", terr)
} else if len(triples) > 0 {
emit(contract.IngestEvent{Stage: "写Neo4j", Total: len(triples), Msg: itoa(len(triples)) + " 条三元组写入图谱"})
if n, gerr := e.graph.store(ctx, kb, triples); gerr != nil {
log.Printf("[rag] 写 Neo4j 失败(图谱降级): %v", gerr)
} else {
log.Printf("[rag] 图谱: 写入 %d 条三元组到 kb=%s", n, kb)
}
}
}
return len(chunks), nil
}
@@ -155,9 +205,11 @@ func (e *Engine) Search(ctx context.Context, kb, query string, topK int) ([]Hit,
vecHits, _ := e.mv.search(ctx, kb, vecs[0], fanout)
// 全文路
ftHits := e.bleve.search(kb, query, fanout)
// RRF 融合(按文本去重
cand := rrf([][]Hit{vecHits, ftHits}, fanout)
log.Printf("[rag] hybrid: 向量=%d 全文=%d → 融合=%d", len(vecHits), len(ftHits), len(cand))
// 图谱路(GraphRAG:查询提到的实体的相连三元组
graphHits := e.graph.search(ctx, kb, query, fanout)
// RRF 融合(三路,按文本去重)
cand := rrf([][]Hit{vecHits, ftHits, graphHits}, fanout)
log.Printf("[rag] hybrid: 向量=%d 全文=%d 图谱=%d → 融合=%d", len(vecHits), len(ftHits), len(graphHits), len(cand))
// 可选 rerank:对融合候选重排取 topK
if e.rerank.ready() && len(cand) > 1 {
@@ -177,6 +229,7 @@ func (e *Engine) Close() {
if e.mv != nil {
e.mv.close()
}
e.graph.close(context.Background())
}
// chunk 朴素切块:按行切,去空白;过长再按长度切。真实系统应做版面/语义切块。