feat(rag): 大文件 RAG 做深 —— 并发入库/图谱窗口化 + Bleve落盘 + file_id治理

几十万字文件从"广而浅"到准生产级:
- 向量化串行→并发分批保序(embedAll);几十万字上千块快数倍
- 图谱整篇喂LLM(爆上下文只抽开头)→窗口化并发抽(extractGraphWindowed),
  全覆盖;窗口/封顶/并发 env 可配;图谱可单配便宜模型(GRAPH_CHAT_*,未配回退主chat)
- Bleve 内存索引(重启即丢、三路退两路)→落盘 scorch(env BLEVE_PATH,失败退内存兜底)
- 下游键改稳定 file_id:Neo4j 关系打 file_id(实体仍 kb+name 共享);
  新增 kb_delete 工具 + Engine.DeleteDoc 级联删 Milvus/Bleve/Neo4j
- 单测:窗口化/去重/env可配/落盘持久/图谱模型回退

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-30 13:37:45 +08:00
parent 6f16062dfc
commit 79e834e8e9
8 changed files with 462 additions and 46 deletions
+76 -40
View File
@@ -6,13 +6,33 @@ import (
"context"
"errors"
"log"
"os"
"strconv"
"sync"
"github.com/sundynix/sundynix-shared/contract"
)
// embedBatch 是每批向量化的块数(让大文件的入库进度可观测)。
const embedBatch = 10
const (
embedBatch = 10 // 每批向量化的块数(兼顾进度可观测 + provider 单批上限)
embedConcurrency = 4 // 并发批数:几十万字上千块串行太慢,限并发跑快数倍且不打爆 provider 速率
)
// 图谱抽取窗口化参数(env 可配,便于按文档体量/成本调):取代"整篇喂 LLM"(几十万字必爆上下文)。
// 把已切的语义块合并成 ~graphWindowRunes 的窗口,逐窗并发抽三元组;实体按 kb+name 在 Neo4j 去重。
func graphWindowRunes() int { return envInt("GRAPH_WINDOW_RUNES", 4000) } // 每窗字数(远小于模型上下文)
func graphMaxWindows() int { return envInt("GRAPH_MAX_WINDOWS", 60) } // 封顶窗口数(控成本;大文档超出略过+告警)
func graphConcurrency() int { return envInt("GRAPH_CONCURRENCY", 3) } // 并发抽取窗口数
// envInt 读正整数 env,缺省/非法时返回 def。
func envInt(key string, def int) int {
if v := os.Getenv(key); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
return n
}
}
return def
}
// Config 是 RAG 引擎的初始化配置。
type Config struct {
@@ -25,13 +45,14 @@ type Config struct {
// 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
mu sync.RWMutex
emb *embedClient
chat *chatClient // 控制面下发的主对话模型
graphChat *chatClient // 可选:图谱抽取专用便宜模型(env GRAPH_CHAT_*);未配则回退主 chat
mv *milvusStore
bleve *bleveStore
rerank *rerankClient
graph *graphStore
}
// SetEmbedding 热更新 embedding 配置(控制面变更时调用)。空配置=关闭向量检索。
@@ -68,6 +89,17 @@ func (e *Engine) chatClient() *chatClient {
return e.chat
}
// graphChatClient 返回图谱抽取用的模型:优先专用便宜模型(env GRAPH_CHAT_*),未配则回退主 chat。
// 让"每篇最多 60 次图谱抽取"走便宜模型,主对话仍用好模型——成本与质量解耦。
func (e *Engine) graphChatClient() *chatClient {
e.mu.RLock()
defer e.mu.RUnlock()
if e.graphChat.ready() {
return e.graphChat
}
return e.chat
}
// Open 建立 RAG 引擎。各路连不上 → 降级(不阻断工具服务)。
func Open(ctx context.Context, cfg Config) *Engine {
e := &Engine{
@@ -75,6 +107,13 @@ func Open(ctx context.Context, cfg Config) *Engine {
rerank: newRerankClient(cfg.RerankBase, cfg.RerankKey, cfg.RerankModel),
graph: openGraph(ctx, cfg.Neo4jURI, cfg.Neo4jUser, cfg.Neo4jPass),
}
// 图谱抽取专用便宜模型(可选,env GRAPH_CHAT_*):未配则图谱抽取回退控制面主 chat。
if gb := os.Getenv("GRAPH_CHAT_BASE"); gb != "" {
e.graphChat = newChatClient(gb, os.Getenv("GRAPH_CHAT_KEY"), os.Getenv("GRAPH_CHAT_MODEL"))
if e.graphChat.ready() {
log.Printf("[rag] 图谱抽取专用便宜模型: %s model=%s", gb, os.Getenv("GRAPH_CHAT_MODEL"))
}
}
if e.rerank.ready() {
log.Printf("[rag] rerank: %s model=%s", cfg.RerankBase, cfg.RerankModel)
}
@@ -130,17 +169,11 @@ func (e *Engine) Ingest(ctx context.Context, kb, doc, text string, onProgress fu
}
emit(contract.IngestEvent{Stage: "切块", Total: len(chunks), Chunks: previews(chunks), Msg: "拆为 " + itoa(len(chunks)) + " 块"})
// 分批向量化(逐批回报进度)。
vecs := make([][]float32, 0, len(chunks))
for i := 0; i < len(chunks); i += embedBatch {
end := min(i+embedBatch, len(chunks))
bv, err := e.embed().Embed(ctx, chunks[i:end])
if err != nil {
emit(contract.IngestEvent{Stage: "失败", Error: "向量化: " + err.Error()})
return 0, err
}
vecs = append(vecs, bv...)
emit(contract.IngestEvent{Stage: "向量化", Done: end, Total: len(chunks)})
// 并发分批向量化(保序,逐批回报进度)—— 几十万字上千块时比串行快数倍
vecs, err := e.embedAll(ctx, chunks, emit)
if err != nil {
emit(contract.IngestEvent{Stage: "失败", Error: "向量化: " + err.Error()})
return 0, err
}
emit(contract.IngestEvent{Stage: "写Milvus", Msg: "向量库写入中"})
@@ -155,26 +188,10 @@ func (e *Engine) Ingest(ctx context.Context, kb, doc, text string, onProgress fu
e.bleve.deleteDoc(kb, doc)
_ = e.bleve.index(kb, doc, 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 {
// 把抽出的三元组实时回流给 UI(边出现边渲染图谱)。
tv := make([]contract.TripleView, len(triples))
for i, t := range triples {
tv[i] = contract.TripleView{S: t.S, P: t.P, O: t.O}
}
emit(contract.IngestEvent{Stage: "抽实体", Total: len(triples), Triples: tv, Msg: "抽出 " + itoa(len(triples)) + " 条知识三元组"})
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)
}
}
// 图谱路:窗口化并发抽实体/关系 → Neo4j(可降级,不阻断向量入库)。
// 几十万字按 ~graphWindowRunes 分窗逐窗抽,全覆盖;实体在 Neo4j 按 kb+name 去重。
if e.graph.ready() && e.graphChatClient().ready() {
e.extractGraphWindowed(ctx, kb, doc, chunks, emit) // doc=file_id:图谱关系按它标源,供级联删
}
return len(chunks), nil
@@ -244,10 +261,29 @@ func (e *Engine) Search(ctx context.Context, kb, query string, topK int) ([]Hit,
return cand, nil
}
// DeleteDoc 按 file_id 级联删某文档在三库的痕迹:Milvus 向量块 + Bleve 全文块 + Neo4j 关系。
// Milvus/Bleve 尽力删(void),Neo4j 返回错误供上层感知;任一失败不阻断其余。
func (e *Engine) DeleteDoc(ctx context.Context, kb, fileID string) error {
if fileID == "" {
return errors.New("file_id 必填")
}
e.mv.deleteByFile(ctx, kb, fileID)
e.bleve.deleteDoc(kb, fileID)
if err := e.graph.deleteByFile(ctx, kb, fileID); err != nil {
log.Printf("[rag] 图谱按 file_id 删除失败: %v", err)
return err
}
log.Printf("[rag] 已删除文档痕迹 kb=%s file_id=%s(向量/全文/图谱)", kb, fileID)
return nil
}
func (e *Engine) Close() {
if e.mv != nil {
e.mv.close()
}
if e.bleve != nil {
e.bleve.close() // 落盘版释放锁并刷盘
}
e.graph.close(context.Background())
}