79e834e8e9
几十万字文件从"广而浅"到准生产级: - 向量化串行→并发分批保序(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>
164 lines
4.9 KiB
Go
164 lines
4.9 KiB
Go
package rag
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"log"
|
||
"strings"
|
||
"sync"
|
||
|
||
"github.com/sundynix/sundynix-shared/contract"
|
||
)
|
||
|
||
// embedAll 并发分批把 chunks 向量化,保序返回(vecs[i] 对应 chunks[i])。
|
||
// 串行版几十万字上千块 = 上百次顺序 HTTP,慢且阻塞;这里限并发 embedConcurrency 跑,
|
||
// 各批写入预分配切片的对应区间保持顺序,任一批失败即整体失败(上层会清理重入)。
|
||
func (e *Engine) embedAll(ctx context.Context, chunks []string, emit func(contract.IngestEvent)) ([][]float32, error) {
|
||
vecs := make([][]float32, len(chunks))
|
||
sem := make(chan struct{}, embedConcurrency)
|
||
var wg sync.WaitGroup
|
||
var mu sync.Mutex
|
||
var firstErr error
|
||
done := 0
|
||
|
||
for start := 0; start < len(chunks); start += embedBatch {
|
||
end := min(start+embedBatch, len(chunks))
|
||
wg.Add(1)
|
||
sem <- struct{}{}
|
||
go func(start, end int) {
|
||
defer wg.Done()
|
||
defer func() { <-sem }()
|
||
mu.Lock()
|
||
stop := firstErr != nil
|
||
mu.Unlock()
|
||
if stop {
|
||
return // 已有批失败,不再发起新请求
|
||
}
|
||
bv, err := e.embed().Embed(ctx, chunks[start:end])
|
||
mu.Lock()
|
||
defer mu.Unlock()
|
||
if err != nil {
|
||
if firstErr == nil {
|
||
firstErr = err
|
||
}
|
||
return
|
||
}
|
||
copy(vecs[start:end], bv)
|
||
done += end - start
|
||
emit(contract.IngestEvent{Stage: "向量化", Done: done, Total: len(chunks)})
|
||
}(start, end)
|
||
}
|
||
wg.Wait()
|
||
if firstErr != nil {
|
||
return nil, firstErr
|
||
}
|
||
return vecs, nil
|
||
}
|
||
|
||
// packGraphWindows 把已切好的语义块合并成抽取窗口(窗内拼接,每窗 ~target 字)。
|
||
// 复用语义块边界,避免再切一遍;超出 maxWindows 截断并回报 truncated=true(调用方告警)。
|
||
func packGraphWindows(chunks []string, target, maxWindows int) (windows []string, truncated bool) {
|
||
var cur strings.Builder
|
||
curLen := 0
|
||
flush := func() {
|
||
if curLen > 0 {
|
||
windows = append(windows, cur.String())
|
||
cur.Reset()
|
||
curLen = 0
|
||
}
|
||
}
|
||
for _, c := range chunks {
|
||
cl := runeLen(c)
|
||
if curLen > 0 && curLen+cl > target {
|
||
flush()
|
||
if len(windows) >= maxWindows {
|
||
return windows, true
|
||
}
|
||
}
|
||
if curLen > 0 {
|
||
cur.WriteByte('\n')
|
||
curLen++
|
||
}
|
||
cur.WriteString(c)
|
||
curLen += cl
|
||
}
|
||
flush()
|
||
return windows, false
|
||
}
|
||
|
||
// extractGraphWindowed 把全文按窗口并发抽三元组并 MERGE 进 Neo4j。
|
||
// 取代"整篇喂 LLM"——几十万字也能全覆盖;实体按 kb+name 在 Neo4j 天然去重,
|
||
// 这里再做一次内存去重减少重复 MERGE。单窗失败只丢该窗,不影响其余。
|
||
func (e *Engine) extractGraphWindowed(ctx context.Context, kb, fileID string, chunks []string, emit func(contract.IngestEvent)) {
|
||
windows, truncated := packGraphWindows(chunks, graphWindowRunes(), graphMaxWindows())
|
||
if len(windows) == 0 {
|
||
return
|
||
}
|
||
if truncated {
|
||
log.Printf("[rag] 文档过大,图谱仅抽取前 %d 窗(其余略过)kb=%s", len(windows), kb)
|
||
emit(contract.IngestEvent{Stage: "抽实体", Msg: fmt.Sprintf("文档较大,图谱抽取前 %d 段", len(windows))})
|
||
}
|
||
emit(contract.IngestEvent{Stage: "抽实体", Done: 0, Total: len(windows), Msg: fmt.Sprintf("LLM 分 %d 段抽取知识三元组…", len(windows))})
|
||
|
||
chat := e.graphChatClient()
|
||
sem := make(chan struct{}, graphConcurrency())
|
||
var wg sync.WaitGroup
|
||
var mu sync.Mutex
|
||
var all []Triple
|
||
doneWin := 0
|
||
|
||
for _, w := range windows {
|
||
wg.Add(1)
|
||
sem <- struct{}{}
|
||
go func(w string) {
|
||
defer wg.Done()
|
||
defer func() { <-sem }()
|
||
ts, err := extractTriples(ctx, chat, w)
|
||
mu.Lock()
|
||
defer mu.Unlock()
|
||
doneWin++
|
||
if err != nil {
|
||
log.Printf("[rag] 窗口三元组抽取失败(忽略该窗): %v", err)
|
||
} else {
|
||
all = append(all, ts...)
|
||
}
|
||
emit(contract.IngestEvent{Stage: "抽实体", Done: doneWin, Total: len(windows), Msg: fmt.Sprintf("图谱抽取 %d/%d 段", doneWin, len(windows))})
|
||
}(w)
|
||
}
|
||
wg.Wait()
|
||
|
||
all = dedupeTriples(all)
|
||
if len(all) == 0 {
|
||
return
|
||
}
|
||
// 实时回流给 UI(边出现边渲染图谱)。
|
||
tv := make([]contract.TripleView, len(all))
|
||
for i, t := range all {
|
||
tv[i] = contract.TripleView{S: t.S, P: t.P, O: t.O}
|
||
}
|
||
emit(contract.IngestEvent{Stage: "抽实体", Total: len(all), Triples: tv, Msg: fmt.Sprintf("抽出 %d 条知识三元组", len(all))})
|
||
emit(contract.IngestEvent{Stage: "写Neo4j", Total: len(all), Msg: fmt.Sprintf("%d 条三元组写入图谱", len(all))})
|
||
if n, gerr := e.graph.store(ctx, kb, fileID, all); gerr != nil {
|
||
log.Printf("[rag] 写 Neo4j 失败(图谱降级): %v", gerr)
|
||
} else {
|
||
log.Printf("[rag] 图谱: 写入 %d 条三元组到 kb=%s(%d 窗)", n, kb, len(windows))
|
||
}
|
||
}
|
||
|
||
// dedupeTriples 去掉空项与重复的 (s,p,o),减少跨窗重复 MERGE。
|
||
func dedupeTriples(ts []Triple) []Triple {
|
||
seen := make(map[string]bool, len(ts))
|
||
out := ts[:0]
|
||
for _, t := range ts {
|
||
if t.S == "" || t.P == "" || t.O == "" {
|
||
continue
|
||
}
|
||
k := t.S + "\x00" + t.P + "\x00" + t.O
|
||
if !seen[k] {
|
||
seen[k] = true
|
||
out = append(out, t)
|
||
}
|
||
}
|
||
return out
|
||
}
|