ai_xiaopei
2026-08-21 17d11e5e15d2b3545e163a7ebcc794a740189edd
spec: tag/entity 虚拟节点落库设计(索引边扩展)
1 files added
132 ■■■■■ changed files
docs/superpowers/specs/2026-08-21-kb-cli-tag-entity-edges-design.md 132 ●●●●● patch | view | raw | blame | history
docs/superpowers/specs/2026-08-21-kb-cli-tag-entity-edges-design.md
New file
@@ -0,0 +1,132 @@
# 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 小时实现 + 验证