edit | blame | history | raw

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 悬空链接留痕 + 自动补全

新增表:

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 指向它的行一并删除
  • 未变:跳过
  1. 执行 retryUnresolved(§2.2)
  2. 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 + 触发器

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 新增:

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 段落截取对无标题结构的文档失效 无标题文档整篇输出(受预算约束)