# 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 ```sql 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 命名空间正确 2. `go test ./internal/index/`: - PopulateFTS 后 FTS 表不含 tag/entity 节点 - FindNodesByKeyword 不返回 tag 节点 - FindRelatedNodes 能通过共同 tag 找到相关文档 - gc 不误删 tag/entity 节点 3. 端到端:`kb-cli index build` 后 `index status` 边数 ≥ 2000; `graph related "电磁阀"` 返回结果包含共享 tag 的文档; `search` 结果与改动前一致(不出现 tag 节点)。 ## 工作量估计 - builder.go:~30 行(虚拟节点入 Nodes + NodeType 字段) - sqlite.go schema + 迁移:~40 行 - fts.go / 各查询过滤:~20 行 - 测试:~150 行 - 总计约 1 小时实现 + 验证