edit | blame | history | raw

kb-cli 索引边扩展设计:tag/entity 虚拟节点落库

日期:2026-08-21
状态:草案(待用户审阅)

背景与问题

当前 kb-cli index build 后:814 个文件节点,仅 138 条边(全部是 wikilink)。

根因(已在源码中确认):

  1. tag/entity 边被丢弃internal/graph/builder.go 为每个文件的 tags/entities 生成了指向虚拟节点(ID 从 1000000 起)的边,但虚拟节点从不插入 SQLite。cmd/rebuild.go 写边时遇到 idMap 中不存在的节点直接跳过,导致全库 1100+ 个文件 frontmatter 中的 tags 一条边都没落库。
  2. wikilink 只认精确标题匹配matchesWikilink 要求链接文本等于文件标题或去编号后的文件名。库内 3334 个 wikilink 中大量是实体名([[电子秤]] 401 次、[[运营管理平台]] 315 次等),无同名文件,匹配失败。

实际影响:
- graph query / graph related 的关联遍历基本失效(每节点平均 0.17 条边)
- search 排序不依赖边(FTS + 板块匹配),日常搜索未受影响
- "相关文档推荐"(共同标签/共同实体)能力缺失

目标

  • 边数从 138 提升到 2500~3500(tag + entity + wikilink)
  • graph related 能通过共同标签/实体找到真正相关的文档
  • 不破坏现有 search 行为:tag/entity 节点不能污染 FTS 搜索结果和关键词匹配

非目标(YAGNI)

  • 不做 wikilink 别名模糊匹配(方案B)——精确匹配语义正确,模糊匹配易引入误连
  • 不改 search 的排序算法(共同标签加分可作为后续独立优化)
  • 不做增量索引(现有全量重建已够用,814 文件重建秒级完成)

设计

1. Schema 变更:nodes 表增加 node_type

CREATE TABLE nodes (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    path       TEXT NOT NULL UNIQUE,
    node_type  TEXT NOT NULL DEFAULT 'file',   -- 'file' | 'tag' | 'entity'
    title      TEXT,
    section    TEXT,
    tags       TEXT,
    entities   TEXT,
    wikilinks  TEXT,
    content_fts TEXT,
    created_at TEXT DEFAULT (datetime('now')),
    updated_at TEXT DEFAULT (datetime('now'))
);

迁移策略:initTables 建表时包含新列;对已存在的旧库执行
ALTER TABLE nodes ADD COLUMN node_type TEXT NOT NULL DEFAULT 'file'
(SQLite 支持 ADD COLUMN,幂等处理:先 PRAGMA table_info 检查列是否存在)。
由于 index build 本来就是全量 ClearData + 重建,旧数据会被清掉,
迁移只影响"旧库直接跑非 build 命令"的场景,DEFAULT 'file' 已兜底。

2. 虚拟节点落库

builder.go:虚拟节点不再用内存 ID,改为生成带命名空间的合成 path:

  • tag 节点:path = "tag:<标签名>"node_type = "tag"title = 标签名
  • entity 节点:path = "entity:<实体名>"node_type = "entity"title = 实体名
  • 文件节点:node_type = "file"

Graph 结构增加 NodeType 字段。虚拟节点也进入 g.Nodes 切片(保证 idMap 完整),
边生成逻辑不变(file → 虚拟节点的 tag/entity 边 + wikilink 边)。

rebuild.go:写入逻辑不变(先节点后边,idMap 映射),虚拟节点现在有真实 ID,
tag/entity 边不再被跳过。

path 命名空间安全性:vault 内实际文件路径均不含 tag:/entity: 前缀
(已验证),UNIQUE 约束不会冲突。

3. 查询过滤:防止虚拟节点污染检索

以下查询增加 WHERE node_type = 'file'(或等效过滤):

函数 位置 过滤方式
CreateFTS / PopulateFTS fts.go PopulateFTS 的 INSERT...SELECT 增加 WHERE node_type='file',tag 节点不进 FTS
FTSSearch fts.go FTS 表只含 file 节点,天然隔离,无需改动
FindNodesByKeyword sqlite.go SQL 增加 AND node_type='file'
FindRelatedNodes sqlite.go 种子节点查询增加 AND node_type='file';共同标签/实体遍历逻辑不变(tag 边落库后此逻辑才真正生效)
GetAllNodes(gc 用) sqlite.go gc 的 os.Stat 检查只对 node_type='file' 的节点执行,tag/entity 节点跳过(它们本就不是磁盘文件)

GetNodeEdges 图遍历不过滤 node_type——tag 节点正是遍历要经过的中间节点。

4. stats 展示

graph stats 的关系分布自然包含 tag/entity/wikilink 三类,无需改动;
index status 的边数会如实反映新数量。

数据流(rebuild 后)

vault 扫描(814 files)
  → 构建图: 814 文件节点 + N 个 tag 节点 + M 个 entity 节点
  → 边: file→tag, file→entity, file→file(wikilink)
  → SQLite: 全部节点落库(node_type 区分), 全部边落库
  → FTS: 仅 file 节点
  → search: FTS+板块(行为不变)
  → graph related: 共同 tag/entity 关联真正可用

错误处理

  • ALTER TABLE 迁移失败 → 报错退出(build 前保证 schema 就绪)
  • 虚拟节点 path 与真实文件冲突(理论上不会)→ UNIQUE 冲突报错,build 失败并提示
  • 边 UNIQUE 约束(from, to, relation, label):同一文件重复 tag 由 builder 去重(现有逻辑)

测试

  1. go test ./internal/graph/
  • BuildGraph 生成的节点包含 tag/entity 节点,node_type 正确
  • 合成 path 命名空间正确
  1. go test ./internal/index/
  • PopulateFTS 后 FTS 表不含 tag/entity 节点
  • FindNodesByKeyword 不返回 tag 节点
  • FindRelatedNodes 能通过共同 tag 找到相关文档
  • gc 不误删 tag/entity 节点
  1. 端到端:kb-cli index buildindex status 边数 ≥ 2000;
    graph related "电磁阀" 返回结果包含共享 tag 的文档;
    search 结果与改动前一致(不出现 tag 节点)。

工作量估计

  • builder.go:~30 行(虚拟节点入 Nodes + NodeType 字段)
  • sqlite.go schema + 迁移:~40 行
  • fts.go / 各查询过滤:~20 行
  • 测试:~150 行
  • 总计约 1 小时实现 + 验证