docs: 对标CodeGraph增强设计(RWR排序/增量同步/悬空链接补全/explore命令)
1 files added
236 ■■■■■ changed files
docs/superpowers/specs/2026-09-03-kb-cli-codegraph-alignment-design.md 236 ●●●●● patch | view | raw | blame | history
docs/superpowers/specs/2026-09-03-kb-cli-codegraph-alignment-design.md
New file
@@ -0,0 +1,236 @@
# kb-cli 对标 CodeGraph 增强设计
**日期**: 2026-09-03
**状态**: 已批准(Lexi 确认方案 C 全量对标)
**版本**: v1.0.0
## 背景
kb-cli 是 Go 实现的知识库索引与检索 CLI(SQLite + FTS5 + 知识图谱),服务 LPG 知识库(`~/rag-lpg-obsidian`,约 768 个 .md 文件)。
CodeGraph(colbymchenry/codegraph)是代码知识图谱工具,其核心机制经源码分析确认:
1. **双信号排序**:FTS5/bm25 文本信号 + Random-Walk-with-Restart(个性化 PageRank,α=0.25)图结构信号。纯文本命中但调用图不连通的符号自然沉底
2. **悬空引用留痕**:`unresolved_refs` 表记录解析失败的引用(failed + name_tail),下次同步有新符号出现时自动重试
3. **三层新鲜度**:文件事件 + (size, mtime, content-hash) 对账 + 连接时追平;增量同步成本只与改动量成正比
4. **输出预算控制**:explore 一次调用返回相关源码原文(带字节预算)+ 调用路径 + 影响面,明确标注"原文直出,agent 无需再读文件"
5. **边可信度**:边带 `provenance` 字段(静态解析 vs 启发式合成),消费方一眼区分
6. **低价值内容降权**:generated 文件、测试文件在排序中降权垫底
kb-cli 现状痛点(源码分析确认):
| 痛点 | 位置 |
|------|------|
| 排序纯文本加权,无图结构信号 | `internal/search/engine.go` |
| aliases 已解析但搜索完全未用 | `internal/vault/parser.go` / `internal/index/fts.go` |
| 索引新鲜度靠 git commit hash 比对,未 commit 的编辑不可见 | `internal/index/cache.go` |
| 索引变更即全量重建,无增量 | `cmd/index.go` / `cmd/rebuild.go` |
| wikilink 模糊匹配(去编号前缀/标题部分匹配)可能产生假边,无可信度标注 | `internal/graph/builder.go` |
| 悬空 wikilink 直接丢弃,无留痕无自动补全 | `internal/graph/builder.go` |
| 搜索结果全量 dump 内容,无预算控制 | `internal/search/engine.go` WithContent |
## 目标
1. 搜索排序引入图结构信号,售后场景(症状词查询)召回质量显著提升
2. 悬空双链自动补全:新文档入库后历史文档指向它的链接自动解析
3. 增量同步:未 commit 的编辑在下次 search/index 时可见,变更只重解析变更文件
4. `explore` 命令供 Hermes agent 一次调用拿到完整上下文(原文 + 关联 + 预算控制)
5. 边带可信度标注,排序与展示区分精确边和启发式边
## 非目标
- 不引入文件事件 watcher(知识库写入频率低,命令触发式对账足够;codegraph 的 watcher 面向高频编辑的代码库)
- 不引入 embedding / LLM 参与排序(RWR 是确定性算法,零 API 成本,保持可复现)
- 不改变 vault 目录结构和编号规则
- 不改 draft/classify/review 等 LLM 录入链路
## 设计
### 1. 搜索质量层
#### 1.1 RWR 图结构排序
新增 `internal/graph/rwr.go`:
- **种子**:FTS5 命中的节点 ID 集合(去重,上限 20 个)
- **邻接**:无向图。边类型纳入排序:wikilink(全权重)、entity(全权重)、tag(半权重——tag 虚拟节点扇出大,区分度低)
- **算法**:power iteration,restart 概率 α=0.25(种子均匀分布),收敛阈值 1e-6,上限 50 次迭代。768 节点规模下单次计算 <1ms
- **输出**:`map[nodeID]float64`(游走质量),归一化到 [0,1]
最终排序分(`internal/search/engine.go` 修改):
```
finalScore = norm(textScore) * textWeight + rwrMass * (1 - textWeight)
```
- `norm(textScore)`:本批次内 min-max 归一化到 [0,1](textScore 沿用现有位置加权:path/title/tag/content + 通用词/实体词降权;批次只有 1 条结果时 norm=1)
- `rwrMass`:已归一化 [0,1]
- textWeight 可配置(config.yaml `search.text_weight`,默认 0.5),两信号量纲对齐后直接加权
#### 1.2 aliases 进 FTS
- `nodes` 表加列 `aliases TEXT`(JSON 数组),parser 已解析 frontmatter aliases,直接落库
- FTS5 虚拟表改为 external-content 模式(见 §3.2),aliases 列纳入
- bm25 列权重:`aliases 4.0, title 3.0, content 1.0`(aliases 是 FAQ 症状词主战场,最高权重)
- `FTSSearch` 对 aliases 列的命中在 textScore 中按 title 档计权
#### 1.3 status 降权
- `nodes` 表加列 `status TEXT`(parser 从 frontmatter 提取,缺省视为"已解决")
- 排序系数:status ∈ {草稿, 待确认, 跟进中} 或 section = `待审阅` 时 finalScore × 0.6
- 结果展示中标注 status(`--json` 输出含 status 字段)
### 2. 图谱质量层
#### 2.1 边可信度(provenance)
`edges` 表加列 `provenance TEXT`:
| 值 | 含义 |
|----|------|
| `exact` | wikilink 文本 == 目标标题,或 == 文件名(去 .md 和编号前缀)精确匹配 |
| `fuzzy` | 标题包含关系等模糊匹配(现有 `matchesWikilink` 的非精确分支) |
| `tag` / `entity` | tag/entity 虚拟边(非 wikilink,不适用可信度概念,但统一存值) |
消费规则:RWR 邻接中 fuzzy 边权重 ×0.5;`graph query` / `graph related` 输出标注 provenance。
#### 2.2 悬空链接留痕 + 自动补全
新增表:
```sql
CREATE TABLE IF NOT EXISTS unresolved_links (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    from_node INTEGER NOT NULL,
    link_text TEXT NOT NULL,
    name_tail TEXT NOT NULL,       -- link_text 去掉 [[ 内的锚点/标题修饰后的尾部
    status TEXT NOT NULL DEFAULT 'pending',  -- pending | failed
    created_at TEXT DEFAULT (datetime('now')),
    last_attempt TEXT
);
CREATE INDEX idx_unresolved_tail ON unresolved_links(name_tail) WHERE status='failed';
```
- **索引时**:wikilink 未匹配到任何节点 → 插入行(status=pending);匹配到但为 fuzzy → 建边(provenance=fuzzy)
- **重试时机**:每次增量同步(§3.1)后执行 `retryUnresolved`:
  1. 对本批新增/变更节点,以其 title 和文件名(去编号前缀)查 `unresolved_links` 的 name_tail 精确命中
  2. 命中且满足 exact 规则 → 建边(provenance=exact),删行
  3. 命中仅 fuzzy 规则 → 建边(provenance=fuzzy),删行;同时输出提示"建议将双链改为精确标题"
  4. 全量重建(`--force`)时清空表并重新解析
- **效果**:新文档入库 → 历史文档中指向它的悬空双链自动补全,无需人工回改
### 3. 增量同步层
#### 3.1 对账机制(替代 commit hash 全量重建)
`internal/index/cache.go` 重写为 `reconcile.go`:
1. 扫描 vault 得到文件清单 `{path: (size, mtime, sha256)}`(sha256 仅对 size/mtime 变化的文件计算,避免全库哈希开销)
2. 与 nodes 表记录比对,分四类:
   - **新增**:vault 有、库中没有 → 解析、建节点、建边
   - **修改**:size/mtime 变化且 sha256 不同 → 重解析该文件,更新节点行,删该文件相关边后重建(tag/entity 边 + 该文件作为 from 的 wikilink 边);该文件作为 to 的边不受影响
   - **删除**:库中有、vault 没有 → 删节点,ON DELETE CASCADE 删相关边;unresolved_links 中 from_node 指向它的行一并删除
   - **未变**:跳过
3. 执行 `retryUnresolved`(§2.2)
4. `meta.git_commit` 仍记录(供 status 展示),但**不再作为重建判据**
`index build` 默认走增量对账路径;`index build --force` 保留全量重建(ClearData + 重建 + 清空 unresolved_links)。
`NeedsRebuild` 逻辑废弃:`search` 命令的 pre-flight 改为"快速对账"——只比对 size/mtime(不读内容不哈希),有差异才触发完整对账。768 文件 stat 开销 <50ms。
#### 3.2 FTS external-content + 触发器
```sql
CREATE VIRTUAL TABLE nodes_fts USING fts5(
    title, content, tags, aliases,
    content='nodes', content_rowid='id',
    tokenize='unicode61'
);
-- 触发器:nodes 的 INSERT/DELETE/UPDATE 同步维护 nodes_fts
```
- 删除 `PopulateFTS` 全量重建路径,增量维护
- `--force` 全量重建时执行 `INSERT INTO nodes_fts(nodes_fts) VALUES('rebuild')` 一次性重灌
#### 3.3 schema 迁移
新增 `schema_versions` 表(同 codegraph)。`Open` 时按版本顺序应用迁移:
- v1 → 现有 schema(老库兼容)
- v2:nodes 加 aliases/status 列;edges 加 provenance 列;unresolved_links 建表;FTS 改 external-content(rebuild 重灌)
迁移幂等(列已存在则跳过),老库自动升级,无需手动重建。
### 4. explore 命令
`cmd/explore.go`(新):
```
kb-cli explore <问题> [--budget 16000] [--top 5] [--json]
```
流程:
1. 查询词按空白切分为关键词,走 §1 的 FTS + RWR 双信号排序
2. 取 top N(默认 5)文档,按 `--budget`(默认 16000 字节,硬上限 32000)分配预算:按 finalScore 降序依次分配,每文档至少 800 字节,超预算的文档截断到整段边界
3. **段落级截取**:文档按 markdown 标题(`#`~`####`)切段,只输出命中查询词的段落(整段不截半句);文档总长 < 分配预算时整篇输出
4. 关联清单:每个入选文档的 wikilink 目标(exact 边优先,fuzzy 边标注)
5. 悬空链接提示:入选文档中未解析的 wikilink 列表
6. 尾部标注:`以上为文档原文直出(含行号),agent 无需再读文件`;`--json` 输出结构化
预算诊断:`KB_EXPLORE_DEBUG=1` 时 stderr 输出每文档分配/实际/占比表(对标 codegraph 的 explore 诊断,默认关闭且不影响输出字节)。
### 5. 配置
config.yaml 新增:
```yaml
search:
  text_weight: 0.5      # 文本分权重(图分权重 = 1 - text_weight)
explore:
  default_budget: 16000 # 默认字节预算
  hard_budget: 32000    # 硬上限
  top_n: 5
```
## 数据流
```
vault 扫描(stat对账) → 变更文件解析(parser) → 节点/边增量写入(SQLite+FTS触发器)
                                        ↘ 悬空链接入 unresolved_links
查询: 关键词 → FTS5(bm25) → 种子 → RWR(图质量) → 双信号排序 → [search 列表 | explore 原文+关联]
```
## 测试计划
**单元测试**(Go test):
- `rwr_test.go`:收敛性、种子外孤立节点质量≈0、tag 半权重生效
- `reconcile_test.go`:新增/修改/删除/未变四场景,边重建正确性
- `unresolved_test.go`:悬空入表 → 新节点入库 → 自动解析建边删行
- `migration_test.go`:v1 老库 → v2 迁移幂等,FTS 重灌后查询正确
- `explore_test.go`:预算分配、段落截取不截半句、硬上限
**端到端验证**(`~/rag-lpg-obsidian` 768 文件实库):
1. 旧版索引 vs 新版索引,同一组售后查询(含症状词,如"红绿闪""充气失败")排序对比:目标文档 rank 提升
2. 增量同步耗时 vs 全量重建耗时
3. 未 commit 编辑:修改一个文档后直接 `kb-cli search`,新内容可见
4. 悬空补全:新建一个文档标题匹配既有悬空链接,确认双链自动建边
5. `explore` 输出在预算内且原文与文件一致(diff 校验)
## 实施顺序
1. schema v2 迁移 + FTS external-content(基础设施,其他全部依赖它)
2. 增量对账(reconcile)
3. RWR + 双信号排序 + aliases + status 降权
4. provenance + unresolved_links 自动补全
5. explore 命令
6. 端到端验证 + 文档(README 命令更新)
## 风险与缓解
| 风险 | 缓解 |
|------|------|
| RWR 权重调不当导致排序退化 | text_weight 可配置;端到端用固定查询集回归对比,退化即回调 |
| 增量对账边界 case(git mv、文件重命名) | 重命名 = 删+增,正确性不受影响;mtime 未变但内容变的极端 case 由 sha256 二次确认兜底 |
| 老库迁移破坏现有索引 | 迁移前自动备份 .db 文件(cp 到 .db.bak);迁移失败可回滚 |
| explore 段落截取对无标题结构的文档失效 | 无标题文档整篇输出(受预算约束) |