10ac5a5277
Obsidian 化继续:笔记能编辑/新建,文档间 [[双链]] 连成可点关系图。
按 doc 重索引(编辑不重复累积):
- Milvus 加 doc 字段(旧 schema 自动重建);insert 带 doc;deleteDoc(kb,doc) 重入库前清旧块。
- Bleve 索引 id 含 doc + deleteDoc 按 kb+doc 清旧块。
- rag.Ingest(kb, doc, text):写入前按 doc 删旧块再写(Neo4j MERGE 仍幂等,附加式)。
- kb_ingest 工具加 doc 参数;gateway runIngest 把 doc 透传,forceDoc 支持编辑保持笔记名稳定。
编辑/新建:
- gateway POST /kb/note {kb,name,content}:落库 + 以 name 为 doc 重入库(替换旧块,搜索/图谱同步)。
- 前端 VaultPanel:阅读/编辑切换(textarea 预填原文,保存调 saveNote)、新建笔记、乐观更新。
笔记关系图:
- GraphView 加 onNode(节点可点);VaultPanel 阅读/关系图切换,关系图 = 文档间 [[双链]] 三元组
力导向(点节点跳转该笔记)。
验证:curl 编辑 笔记B → 检索只返编辑后内容(旧块已清,不重复)。Preview:关系图渲染
笔记B—链接→项目A概述/模块X 且节点可点;编辑器预填原文可改可存。tsc+vite+后端 build 通过;重建 .app。
注:Milvus 加 doc 字段会触发集合重建(旧向量丢,文库原文在 PG 可重灌);Neo4j 图谱按附加式合并,
编辑删除的实体不会自动消失(图谱倾向增长)。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
271 lines
8.4 KiB
Go
271 lines
8.4 KiB
Go
// Package rag 实现 RAG 核心链:embedding(provider 抽象) + Milvus 向量库 + 入库/检索。
|
||
// 是 LLM Wiki 混合检索的向量路;Bleve/Neo4j 融合为后续扩展。
|
||
package rag
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"log"
|
||
"strings"
|
||
"sync"
|
||
|
||
"github.com/sundynix/sundynix-shared/contract"
|
||
)
|
||
|
||
// embedBatch 是每批向量化的块数(让大文件的入库进度可观测)。
|
||
const embedBatch = 10
|
||
|
||
// 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 配置(控制面变更时调用)。空配置=关闭向量检索。
|
||
func (e *Engine) SetEmbedding(base, key, model string) {
|
||
e.mu.Lock()
|
||
defer e.mu.Unlock()
|
||
if base == "" || model == "" {
|
||
e.emb = nil
|
||
return
|
||
}
|
||
e.emb = newEmbedClient(base, key, model)
|
||
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
|
||
}
|
||
|
||
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 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 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", 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 }
|
||
|
||
// Status 报告各依赖子系统的就绪情况(供 health 工具 → 控制台健康灯)。
|
||
func (e *Engine) Status() map[string]bool {
|
||
return map[string]bool{
|
||
"milvus": e.mv != nil,
|
||
"neo4j": e.graph.ready(),
|
||
"embedding": e.embed().ready(),
|
||
}
|
||
}
|
||
|
||
// Ingest 把一段文本切块 → 分批向量化 → 写 Milvus + Bleve,返回块数。
|
||
// doc 非空表示这是某篇文档/笔记(按 doc 先删旧块再写,支持编辑替换,不重复累积)。
|
||
// onProgress 非空时逐阶段/逐批回调进度(用于实时入库监控)。
|
||
func (e *Engine) Ingest(ctx context.Context, kb, doc, text string, onProgress func(contract.IngestEvent)) (int, error) {
|
||
emit := func(ev contract.IngestEvent) {
|
||
if onProgress != nil {
|
||
onProgress(ev)
|
||
}
|
||
}
|
||
if !e.Ready() {
|
||
return 0, errors.New("rag 未配置(需 embedding + Milvus)")
|
||
}
|
||
chunks := chunk(text)
|
||
if len(chunks) == 0 {
|
||
return 0, nil
|
||
}
|
||
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)})
|
||
}
|
||
|
||
emit(contract.IngestEvent{Stage: "写Milvus", Msg: "向量库写入中"})
|
||
if len(vecs) > 0 {
|
||
e.mv.deleteDoc(ctx, kb, doc, len(vecs[0])) // 编辑/重入库:先清该 doc 旧块
|
||
}
|
||
if err := e.mv.insert(ctx, kb, doc, chunks, vecs); err != nil {
|
||
emit(contract.IngestEvent{Stage: "失败", Error: "写Milvus: " + err.Error()})
|
||
return 0, err
|
||
}
|
||
emit(contract.IngestEvent{Stage: "写Bleve", Msg: "全文索引写入中"})
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
|
||
return len(chunks), nil
|
||
}
|
||
|
||
// previews 取每块的前若干字作为预览(供 UI 展示拆分情况)。
|
||
func previews(chunks []string) []string {
|
||
out := make([]string, len(chunks))
|
||
for i, c := range chunks {
|
||
r := []rune(c)
|
||
if len(r) > 50 {
|
||
out[i] = string(r[:50]) + "…"
|
||
} else {
|
||
out[i] = c
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func itoa(n int) string {
|
||
if n == 0 {
|
||
return "0"
|
||
}
|
||
var b []byte
|
||
for n > 0 {
|
||
b = append([]byte{byte('0' + n%10)}, b...)
|
||
n /= 10
|
||
}
|
||
return string(b)
|
||
}
|
||
|
||
// Search 混合检索:Milvus(向量) + Bleve(全文) → RRF 融合 → 可选 rerank → topK。降级时返回空。
|
||
func (e *Engine) Search(ctx context.Context, kb, query string, topK int) ([]Hit, error) {
|
||
if !e.Ready() {
|
||
return nil, nil
|
||
}
|
||
if topK <= 0 {
|
||
topK = 5
|
||
}
|
||
fanout := topK * 3
|
||
|
||
// 向量路
|
||
vecs, err := e.embed().Embed(ctx, []string{query})
|
||
if err != nil || len(vecs) == 0 {
|
||
return nil, err
|
||
}
|
||
vecHits, _ := e.mv.search(ctx, kb, vecs[0], fanout)
|
||
// 全文路
|
||
ftHits := e.bleve.search(kb, query, fanout)
|
||
// 图谱路(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 {
|
||
if rr, rerr := e.rerank.rerank(ctx, query, cand, topK); rerr == nil {
|
||
return rr, nil
|
||
} else {
|
||
log.Printf("[rag] rerank 降级(用 RRF 结果): %v", rerr)
|
||
}
|
||
}
|
||
if len(cand) > topK {
|
||
cand = cand[:topK]
|
||
}
|
||
return cand, nil
|
||
}
|
||
|
||
func (e *Engine) Close() {
|
||
if e.mv != nil {
|
||
e.mv.close()
|
||
}
|
||
e.graph.close(context.Background())
|
||
}
|
||
|
||
// chunk 朴素切块:按行切,去空白;过长再按长度切。真实系统应做版面/语义切块。
|
||
func chunk(text string) []string {
|
||
var out []string
|
||
for _, line := range strings.Split(text, "\n") {
|
||
s := strings.TrimSpace(line)
|
||
if s == "" {
|
||
continue
|
||
}
|
||
for len(s) > 2000 {
|
||
out = append(out, s[:2000])
|
||
s = s[2000:]
|
||
}
|
||
out = append(out, s)
|
||
}
|
||
return out
|
||
}
|