Files
sundynix-agentix/sundynix-mcp-go/internal/rag/milvus.go
T
Blizzard 10ac5a5277 feat(kb): 笔记可编辑(按 doc 替换重索引)+ 笔记关系图([[双链]])
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>
2026-06-13 15:22:03 +08:00

233 lines
7.0 KiB
Go

package rag
import (
"context"
"fmt"
"log"
"strconv"
"strings"
"sync"
"github.com/milvus-io/milvus-sdk-go/v2/client"
"github.com/milvus-io/milvus-sdk-go/v2/entity"
)
const collection = "sundynix_wiki" // Wiki/知识库向量集合
// milvusStore 封装 Milvus 连接与集合管理(集合按首次写入的向量维度懒建)。
type milvusStore struct {
cli client.Client
mu sync.Mutex
dim int // 已建集合的维度(0=未建)
ok bool // 集合是否就绪
}
func openMilvus(ctx context.Context, addr string) (*milvusStore, error) {
cli, err := client.NewClient(ctx, client.Config{Address: addr})
if err != nil {
return nil, err
}
return &milvusStore{cli: cli}, nil
}
func (m *milvusStore) close() {
if m != nil && m.cli != nil {
_ = m.cli.Close()
}
}
// ensure 幂等地按维度 dim 建集合 + 向量索引 + 加载(首次写入时调用)。
func (m *milvusStore) ensure(ctx context.Context, dim int) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.ok && m.dim == dim {
return nil
}
has, err := m.cli.HasCollection(ctx, collection)
if err != nil {
return err
}
// 已存集合维度不一致(切 embedding 模型)或缺 doc 字段(旧 schema)→ 重建。
if has {
if coll, derr := m.cli.DescribeCollection(ctx, collection); derr == nil {
dimBad := vectorDim(coll) != 0 && vectorDim(coll) != dim
if dimBad || !hasField(coll, "doc") {
log.Printf("[rag] 集合需重建(dim 变化或缺 doc 字段):%s", collection)
if err := m.cli.DropCollection(ctx, collection); err != nil {
return fmt.Errorf("drop collection: %w", err)
}
has = false
}
}
}
if !has {
schema := entity.NewSchema().WithName(collection).WithDescription("sundynix wiki vectors").
WithField(entity.NewField().WithName("id").WithDataType(entity.FieldTypeInt64).WithIsPrimaryKey(true).WithIsAutoID(true)).
WithField(entity.NewField().WithName("kb").WithDataType(entity.FieldTypeVarChar).WithMaxLength(64)).
WithField(entity.NewField().WithName("doc").WithDataType(entity.FieldTypeVarChar).WithMaxLength(200)).
WithField(entity.NewField().WithName("text").WithDataType(entity.FieldTypeVarChar).WithMaxLength(8192)).
WithField(entity.NewField().WithName("vector").WithDataType(entity.FieldTypeFloatVector).WithDim(int64(dim)))
if err := m.cli.CreateCollection(ctx, schema, 1); err != nil {
return fmt.Errorf("create collection: %w", err)
}
idx, _ := entity.NewIndexAUTOINDEX(entity.COSINE)
if err := m.cli.CreateIndex(ctx, collection, "vector", idx, false); err != nil {
return fmt.Errorf("create index: %w", err)
}
}
if err := m.cli.LoadCollection(ctx, collection, false); err != nil {
return fmt.Errorf("load collection: %w", err)
}
m.dim, m.ok = dim, true
return nil
}
// invalidate 让集合就绪缓存失效(集合被外部删除/基础设施重启丢失后,强制下次重建)。
func (m *milvusStore) invalidate() {
m.mu.Lock()
m.ok = false
m.mu.Unlock()
}
// isCollectionGone 判断错误是否为"集合不存在"(Milvus 重启丢集合后写/查会报此类)。
func isCollectionGone(err error) bool {
if err == nil {
return false
}
s := strings.ToLower(err.Error())
return strings.Contains(s, "collection not found") ||
strings.Contains(s, "can't find collection") ||
strings.Contains(s, "collection not exist") ||
strings.Contains(s, "collection not loaded")
}
// deleteDoc 删除某 (kb, doc) 的全部块 —— 笔记重新入库前先清旧块,避免重复累积。
func (m *milvusStore) deleteDoc(ctx context.Context, kb, doc string, dim int) {
if doc == "" {
return
}
if err := m.ensure(ctx, dim); err != nil {
return // 集合还没建 → 无旧块可删
}
expr := fmt.Sprintf("kb == %q && doc == %q", kb, doc)
if err := m.cli.Delete(ctx, collection, "", expr); err != nil {
log.Printf("[rag] 按 doc 删除旧块失败(忽略): %v", err)
}
}
// insert 写入若干 (kb, doc, text, vector)。
// 若集合在运行期被丢失(如 Milvus 重启)→ 清缓存、重建集合后重试一次,避免必须重启进程才能恢复。
func (m *milvusStore) insert(ctx context.Context, kb, doc string, texts []string, vecs [][]float32) error {
if len(vecs) == 0 {
return nil
}
dim := len(vecs[0])
kbs := make([]string, len(texts))
docs := make([]string, len(texts))
for i := range kbs {
kbs[i] = kb
docs[i] = doc
}
do := func() error {
if err := m.ensure(ctx, dim); err != nil {
return err
}
if _, err := m.cli.Insert(ctx, collection, "",
entity.NewColumnVarChar("kb", kbs),
entity.NewColumnVarChar("doc", docs),
entity.NewColumnVarChar("text", texts),
entity.NewColumnFloatVector("vector", dim, vecs),
); err != nil {
return fmt.Errorf("insert: %w", err)
}
return m.cli.Flush(ctx, collection, false)
}
err := do()
if err != nil && isCollectionGone(err) {
log.Printf("[rag] 集合不存在(疑似 Milvus 重启丢失),清缓存重建后重试写入")
m.invalidate()
err = do()
}
return err
}
// hasField 判断集合 schema 是否含某字段。
func hasField(coll *entity.Collection, name string) bool {
if coll == nil || coll.Schema == nil {
return false
}
for _, f := range coll.Schema.Fields {
if f.Name == name {
return true
}
}
return false
}
// vectorDim 从集合 schema 读出向量字段维度(用于检测维度变化)。
func vectorDim(coll *entity.Collection) int {
if coll == nil || coll.Schema == nil {
return 0
}
for _, f := range coll.Schema.Fields {
if f.DataType == entity.FieldTypeFloatVector {
if d, ok := f.TypeParams["dim"]; ok {
n, _ := strconv.Atoi(d)
return n
}
}
}
return 0
}
// Hit 是一条检索结果。
type Hit struct {
Text string `json:"text"`
Score float32 `json:"score"`
}
// search 用查询向量做 topK 向量检索(可按 kb 过滤)。
// 集合未建(还没入过库)→ 返回空结果;集合运行期丢失 → 清缓存重建后重试一次。
func (m *milvusStore) search(ctx context.Context, kb string, qvec []float32, topK int) ([]Hit, error) {
expr := ""
if kb != "" {
expr = fmt.Sprintf("kb == \"%s\"", kb)
}
sp, _ := entity.NewIndexAUTOINDEXSearchParam(1)
do := func() ([]client.SearchResult, error) {
if err := m.ensure(ctx, len(qvec)); err != nil {
return nil, err
}
return m.cli.Search(ctx, collection, nil, expr, []string{"text"},
[]entity.Vector{entity.FloatVector(qvec)}, "vector", entity.COSINE, topK, sp)
}
results, err := do()
if err != nil && isCollectionGone(err) {
log.Printf("[rag] 检索时集合不存在,清缓存重建后重试")
m.invalidate()
results, err = do()
}
if err != nil {
// 集合尚未就绪/无法重建 → 降级空结果(不阻断混合检索其它路)。
return nil, nil
}
var hits []Hit
for _, r := range results {
textCol := r.Fields.GetColumn("text")
for i := 0; i < r.ResultCount; i++ {
text := ""
if textCol != nil {
if s, err := textCol.GetAsString(i); err == nil {
text = s
}
}
var score float32
if i < len(r.Scores) {
score = r.Scores[i]
}
hits = append(hits, Hit{Text: text, Score: score})
}
}
return hits, nil
}