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>
This commit is contained in:
Blizzard
2026-06-13 15:22:03 +08:00
parent 55c85302b6
commit 10ac5a5277
9 changed files with 242 additions and 29 deletions
+26 -4
View File
@@ -26,21 +26,43 @@ func openBleve() *bleveStore {
func (b *bleveStore) ready() bool { return b != nil && b.idx != nil }
// index 把 (kb, texts) 写入全文索引(按 kb+文本哈希幂等 ID)。
func (b *bleveStore) index(kb string, texts []string) error {
// index 把 (kb, doc, texts) 写入全文索引(id 含 kb+doc+文本哈希幂等)。
func (b *bleveStore) index(kb, doc string, texts []string) error {
if !b.ready() {
return nil
}
batch := b.idx.NewBatch()
for _, t := range texts {
id := fmt.Sprintf("%s:%x", kb, fnvHash(t))
if err := batch.Index(id, map[string]any{"text": t, "kb": kb}); err != nil {
id := fmt.Sprintf("%s:%s:%x", kb, doc, fnvHash(t))
if err := batch.Index(id, map[string]any{"text": t, "kb": kb, "doc": doc}); err != nil {
return err
}
}
return b.idx.Batch(batch)
}
// deleteDoc 删除某 (kb, doc) 的全部全文块(笔记重入库前清旧块)。
func (b *bleveStore) deleteDoc(kb, doc string) {
if !b.ready() || doc == "" {
return
}
kq := bleve.NewTermQuery(kb)
kq.SetField("kb")
dq := bleve.NewTermQuery(doc)
dq.SetField("doc")
req := bleve.NewSearchRequest(bleve.NewConjunctionQuery(kq, dq))
req.Size = 1000
res, err := b.idx.Search(req)
if err != nil {
return
}
batch := b.idx.NewBatch()
for _, h := range res.Hits {
batch.Delete(h.ID)
}
_ = b.idx.Batch(batch)
}
// search 全文检索(可按 kb 过滤),返回 BM25 排序的命中。
func (b *bleveStore) search(kb, q string, topK int) []Hit {
if !b.ready() || q == "" {
+37 -5
View File
@@ -47,11 +47,12 @@ func (m *milvusStore) ensure(ctx context.Context, dim int) error {
if err != nil {
return err
}
// 已存集合维度不一致(如切换 embedding 模型)→ 重建。
// 已存集合维度不一致( embedding 模型)或缺 doc 字段(旧 schema→ 重建。
if has {
if coll, derr := m.cli.DescribeCollection(ctx, collection); derr == nil {
if existing := vectorDim(coll); existing != 0 && existing != dim {
log.Printf("[rag] 集合维度 %d≠%d,重建 %s", existing, dim, collection)
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)
}
@@ -63,6 +64,7 @@ func (m *milvusStore) ensure(ctx context.Context, dim int) error {
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 {
@@ -99,16 +101,32 @@ func isCollectionGone(err error) bool {
strings.Contains(s, "collection not loaded")
}
// insert 写入若干 (kb, text, vector)
// 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 string, texts []string, vecs [][]float32) error {
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 {
@@ -116,6 +134,7 @@ func (m *milvusStore) insert(ctx context.Context, kb string, texts []string, vec
}
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 {
@@ -132,6 +151,19 @@ func (m *milvusStore) insert(ctx context.Context, kb string, texts []string, vec
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 {
+8 -3
View File
@@ -114,8 +114,9 @@ func (e *Engine) Status() map[string]bool {
}
// Ingest 把一段文本切块 → 分批向量化 → 写 Milvus + Bleve,返回块数。
// doc 非空表示这是某篇文档/笔记(按 doc 先删旧块再写,支持编辑替换,不重复累积)。
// onProgress 非空时逐阶段/逐批回调进度(用于实时入库监控)。
func (e *Engine) Ingest(ctx context.Context, kb, text string, onProgress func(contract.IngestEvent)) (int, error) {
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)
@@ -144,12 +145,16 @@ func (e *Engine) Ingest(ctx context.Context, kb, text string, onProgress func(co
}
emit(contract.IngestEvent{Stage: "写Milvus", Msg: "向量库写入中"})
if err := e.mv.insert(ctx, kb, chunks, vecs); err != nil {
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.index(kb, chunks) // 同步写全文索引(失败不阻断向量入库)
e.bleve.deleteDoc(kb, doc)
_ = e.bleve.index(kb, doc, chunks) // 同步写全文索引(失败不阻断向量入库)
// 图谱路:LLM 抽实体/关系 → Neo4j(可降级,不阻断向量入库)。
if e.graph.ready() && e.chatClient().ready() {