feat: 实时入库监控 + 向量拆分可视化(异步入库 + 进度 SSE)
入库从同步改为异步流水线 + 进度回流(复用 token 流 NATS streaming)。 UI 实时看到 解析→切块→向量化(分批)→写入 各阶段 + 拆分块预览。 - shared: contract.IngestEvent(stage/done/total/chunks/error) - mcp-go: rag.Ingest 加 onProgress + 分批向量化(10/批)逐批回报;kb_ingest 带 job_id 把进度发到 sundynix.streams.<job_id> + CompleteStream - gateway: 入库异步返回 job_id,后台 runIngest 发进度;GET /kb/ingest/:id/stream SSE - frontend: streamIngest(EventSource);KbView 实时进度面板(阶段徽标+进度条+拆分列表) - 验证: build✓+e2e PASS; 浏览器 12 行→6 阶段点亮+进度条 12/12+拆分 12 块逐条 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -160,14 +160,32 @@ func (g *Gateway) kbSearch(ctx context.Context, call *contract.ToolCall) *contra
|
||||
return &contract.ToolResult{OK: true, Content: string(data)}
|
||||
}
|
||||
|
||||
// kbIngest 把文本入库(切块→embedding→Milvus)。
|
||||
// kbIngest 把文本入库(切块→embedding→Milvus+Bleve)。
|
||||
// 带 job_id 时逐阶段把进度发到 sundynix.streams.<job_id>,供 UI 实时入库监控。
|
||||
func (g *Gateway) kbIngest(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
|
||||
kb, _ := call.Args["kb"].(string)
|
||||
text, _ := call.Args["text"].(string)
|
||||
jobID, _ := call.Args["job_id"].(string)
|
||||
if text == "" {
|
||||
return &contract.ToolResult{OK: false, Error: "kb_ingest: text 必填"}
|
||||
}
|
||||
n, err := g.rag.Ingest(ctx, kb, text)
|
||||
var onProgress func(contract.IngestEvent)
|
||||
if jobID != "" {
|
||||
onProgress = func(ev contract.IngestEvent) {
|
||||
if data, err := json.Marshal(ev); err == nil {
|
||||
_ = g.bus.PublishToken(jobID, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
n, err := g.rag.Ingest(ctx, kb, text, onProgress)
|
||||
if jobID != "" {
|
||||
if err != nil {
|
||||
onProgress(contract.IngestEvent{Stage: "失败", Error: err.Error()})
|
||||
} else {
|
||||
onProgress(contract.IngestEvent{Stage: "完成", Done: n, Total: n, Msg: fmt.Sprintf("已入库 %d 块", n)})
|
||||
}
|
||||
_ = g.bus.CompleteStream(jobID)
|
||||
}
|
||||
if err != nil {
|
||||
return &contract.ToolResult{OK: false, Error: "kb_ingest: " + err.Error()}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,13 @@ import (
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/sundynix/sundynix-shared/contract"
|
||||
)
|
||||
|
||||
// embedBatch 是每批向量化的块数(让大文件的入库进度可观测)。
|
||||
const embedBatch = 10
|
||||
|
||||
// Engine 聚合 embedding + Milvus(向量) + Bleve(全文) + RRF 融合 + 可选 rerank。
|
||||
// embedding 可热更新(控制面下发)。
|
||||
type Engine struct {
|
||||
@@ -65,8 +70,14 @@ func Open(ctx context.Context, milvusAddr, embBase, embKey, embModel, rerankBase
|
||||
// Ready 报告 RAG 是否可用(embedding + Milvus 均就绪)。
|
||||
func (e *Engine) Ready() bool { return e.embed().ready() && e.mv != nil }
|
||||
|
||||
// Ingest 把一段文本切块 → 向量化 → 写入 Milvus,返回块数。
|
||||
func (e *Engine) Ingest(ctx context.Context, kb, text string) (int, error) {
|
||||
// Ingest 把一段文本切块 → 分批向量化 → 写 Milvus + Bleve,返回块数。
|
||||
// onProgress 非空时逐阶段/逐批回调进度(用于实时入库监控)。
|
||||
func (e *Engine) Ingest(ctx context.Context, kb, 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)")
|
||||
}
|
||||
@@ -74,17 +85,58 @@ func (e *Engine) Ingest(ctx context.Context, kb, text string) (int, error) {
|
||||
if len(chunks) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
vecs, err := e.embed().Embed(ctx, chunks)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
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 err := e.mv.insert(ctx, kb, chunks, vecs); err != nil {
|
||||
emit(contract.IngestEvent{Stage: "失败", Error: "写Milvus: " + err.Error()})
|
||||
return 0, err
|
||||
}
|
||||
emit(contract.IngestEvent{Stage: "写Bleve", Msg: "全文索引写入中"})
|
||||
_ = e.bleve.index(kb, chunks) // 同步写全文索引(失败不阻断向量入库)
|
||||
|
||||
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() {
|
||||
|
||||
Reference in New Issue
Block a user