首页
/ CodeGraph 索引漂移(CG-33):增量 sync 与全量重建之间 4.3% 的静默分叉,以及如何让它收敛

CodeGraph 索引漂移(CG-33):增量 sync 与全量重建之间 4.3% 的静默分叉,以及如何让它收敛

2026-09-05 23:34:02作者:傅爽业Veleda

CodeGraph 的核心承诺是"索引永不陈旧":文件一变,图谱自动增量同步,用户无需手动重建。但 CG-33 这次在 codegraph 自家仓库上的测量证明,长期自动同步维护的索引并不会收敛到同一工作树的干净全量重建——4.3% 的独立边是错的,且双向都错,其中绝大多数是 calls 边。本篇基于仓库内的基准文档 docs/benchmarks/index-drift-cg33.md,完整复盘这次调查:漂移为什么难以发现、两个根因(重解析作用域与写入顺序决胜)如何在源码中成立、修复如何通过 definitionDelta 与 rebind 机制实现收敛,以及一个值得所有"增量索引 + 全量重建"系统设计者借鉴的验证方法论。

现象:一个静默的 4.3% 边级分叉

测量对象是 codegraph 自己仓库下的 .codegraph/codegraph.db——一个长期存在、持续增量同步的索引——与用同一构建对同一棵工作树做的全量重建。边以去重后的 (source, target, kind) 三元组比较(2026-08-06 测量):

项目 数量
重建侧独立边三元组 28,809
重建中有、live 索引缺失的边 751
live 索引有、重建中不存在的(陈旧)边 476
总分叉 1,227 — 占 4.3%

缺失边按类型分布:calls=635contains=38references=34instantiates=21imports=13extends=10——调用边是绝对大头。

这件事的严重性在于它是静默的:没有任何警告、没有任何出口暴露它,而 README 明确告诉用户 "The index is never stale, and there is nothing to re-run." 检索质量在不可见地衰减,用户能观察到的症状是 agent 回退到 Read 工具——这看起来像"codegraph 不太行",而不是"这个索引需要重建"。

原始行数会掩盖它

最容易被忽视的一点:live 与重建的边行数分别是 39,845 与 40,122——看起来温和的 +0.7%。因为分叉是双向的,任何基于净数量(net count)的检查都会把它对冲掉,几乎报告不出异常。任何漂移检测器都必须比较边集合,而不是总数。 这一点在仓库自带的只读诊断脚本 scripts/agent-eval/diff-index-drift.mjs 的注释中也被明确重申:"Raw row counts are NOT a drift signal: a bidirectional divergence nets out."

控制实验:索引器本身是确定性的

对同一棵树、同一构建做 rebuild vs rebuild 对照:0 条差异边(两次均为 28,809)。这排除了"索引器非确定性"的假设——live 与 rebuild 之间的差异不是运行间噪声,而是增量同步路径独有的行为。

是解析陈旧,不是残留积累

节点集合在两侧完全一致——files 501 = 501、nodes 10,110 = 10,110、heuristic 边 36 = 36——且每一项完整性检查(重复节点、孤儿边、指向缺失文件行的节点)在两个索引上都是 0

结论是清晰的:没有任何东西在"积累"。陈旧的是跨文件解析(resolution)

根因:ReferenceResolver 把引用绑定到全项目同名定义上

codegraph 的 ReferenceResolver 会把一个引用绑定到全项目范围内同名定义中的一个。由此推出两件事,且漂移必须同时满足两者才能修复:

原因 1:增量重解析的作用域只覆盖"变更文件内部"

增量 sync 只重解析变更文件里的引用。向仓库中新增或删除一个 pct 的定义,会改变全仓库每一处 pct(...) 引用的正确答案——包括 sync 从未触碰的文件中的引用。而这些引用曾经成功解析过一次,成功解析会删除它们的 unresolved_refs 行,于是没有任何东西会再回头看它们(issue #1240 引入的 retry 只回访被搁置为 status='failed' 的引用)。索引保留了一份"对旧版图谱正确"的答案,而它对当前图谱是错误的。

原因 2:平局决胜由文件写入顺序决定

当没有任何信号能区分候选时,findBestMatch 会保留第一个候选,而当时的 getNodesByName 没有 ORDER BY——胜出者由 rowid 决定,即文件碰巧被写入的顺序。全量索引按扫描顺序写入;sync 则在每个文件变化时追加。同一棵工作树因此会因为索引构建方式不同而解析出不同的边,而且无论怎么重解析都无法收敛——对着同一张图谱重解析,仍会挑出不同的候选。

修复:确定性排序 + definitionDelta + 陈旧边复活

修复由三部分构成,全部可以在当前源码中逐行核对:

  1. getNodesByName(file_path, start_line) 排序——这是代码本身的属性,而不是写入顺序的属性。见 src/db/queries.ts

    SELECT * FROM nodes WHERE name = ? ORDER BY file_path, start_line
    
  2. sync 返回 definitionDelta:本次 sync 改变了定义的名字集合的那批名字。实现上是对 store 阶段前后采样的 file\0name 对做对称差,位于 ExtractionOrchestrator.syncsrc/extraction/index.ts):先删除/插入节点之前采样 pairsBefore(因为 storeExtractionResult 会先删该文件旧节点再插新节点,这是旧定义集最后一个可读时点),store 之后读 pairsAfter,两侧差集即为 delta 名字。SyncResult 类型上对应 definitionDelta?: string[] 字段(src/extraction/index.ts)。

    关键细节:delta 是逐文件比较,而不是整个批次的名字集合。一个提交向新文件添加了 collect,而另一个无关的变更文件恰好也定义了 collect——如果按批级名字集合做对称差,这个变化会被抵消掉而漏报。文档记录了这个漏报正是该修复首次测量时最大的残留类别。

  3. 对每个 delta 名字执行 resurrectStaleResolutionEdges:删除那些"目标是指向该名字符号、且源文件在本次 sync 中未变更"的解析边,并按创建它的引用(metadata.refName 时间戳)重新插回 unresolved_refs。随后既有的孤儿清扫(orphan sweep)会拿同步的图谱重新解析它们——与全量重建解析时的输入完全相同。实现见 src/extraction/index.ts:"先删后插"的顺序有讲究:insertEdgesidx_edges_identityINSERT OR IGNORE,若旧行留在原地而解析改选别的目标,会同时保留两条边,把漂移变成重复。

    主流程中的调用点与开关在 src/index.ts:只有 result.definitionDelta 非空时才走 rebind 分支(body-only 编辑的 delta 为空,所以最常见的 sync 只付一次分支判断的代价),kill switch 为环境变量 CODEGRAPH_NO_REBIND=1

设计上刻意保守

错误的删除是永久性边丢失,而漏掉一次 rebind 只是残留漂移——所以实现按此不对称性保守处理,三条护栏(源码注释与 src/db/queries.ts 的说明一致):

  • 没有时间戳的边永不触碰:合成边或旧引擎构建的边没有 refName 时间戳,无法可靠重建,就不删;
  • 源文件已被本次 sync 重新抽取的边跳过:它们的引用刚刚从零重解析过;
  • 每个名字 500 条边的上限:超过上限的名字被判定为泛化名(getclearpush 之类),直接放弃 rebind,与 getRetryableFailedReferencesperNameCeiling = 500 默认值(src/db/queries.ts)同源。

修复验证:真实提交回放

验证方法不是合成场景,而是把该仓库的真实提交逐个通过 sync 回放,然后与最终树的干净重建做 diff:

回放规模 基线(main 仅 + ORDER BY + rebind 通路(已发布)
16 个提交 48(24 缺失 / 24 陈旧) 20 0 — 收敛
80 个提交 1,634(963 / 671) 890 361(359 / 2)

其中真正有误导性的方向——索引仍然在断言的陈旧边——从 671 降到 80 提交回放下的 2,降幅 99.7%。注意 ORDER BY 单独只能把 80 提交的 1,634 压到 890:它消除了"平局决胜"这一因,但"作用域"这一因必须靠 rebind 通路解决,两个修复缺一不可——这正是文档所说"漂移需要两者同时修掉"的含义。

性能方面:索引与 sync 的墙钟时间在两臂下不变(392 文件的仓库:索引 1.88–2.02s,单文件 sync 0.183s)。ORDER BY 使紧循环中未命中缓存的名字查找慢 18%(10,127 次查找从 237ms 到 280ms),但没有传导到墙钟——因为 ReferenceResolver 按名字对查找做了记忆化。文档还记录了一个被否决的方案:复合 (name, file_path, start_line) 索引能让排序"免费",但它会在写密集的全量索引路径上让每个节点索引条目都携带完整路径字符串——为省下 43ms 不值得。

残留的 361 条:为什么刻意不追

80 提交回放剩余的 361 条边里,357 条属于同一个既有类别:指向极泛化名字(push 260 条、join 97 条)的引用,它们在索引时就解析失败并被搁置,因为 getRetryableFailedReferences 对失败引用超过 500 的名字一律拒绝重试(push 有 1,412 条、join 有 2,346 条失败引用)。这个上限是 #1240/#999 的策略,main 分支上就有,而它拒绝创建的恰恰是跨语言垃圾边——TypeScript 测试文件"调用"名为 push 的 R 方法,或 Rust 中名为 join 的方法。在这个位置上,全量重建才是错的那一个——让 sync 收敛到它,等于教 sync 去制造数千条错误边。文档的结论是刻意保持现状(left as is, deliberately)。

codegraph status 的决定:不上漂移指标

issue 曾问 status 是否应暴露分叉。决定:。理由是:一个漂移数字如果不跑它将要推荐的那次全量重建就无从计算,因此任何便宜到能在 status 里跑的东西都只是估计——而诚实的估计在这里不存在。发布一个代理指标违反"界面不得过度声明"的产品规则,且在修复之后它还会在上述泛化名残留上误报,训练用户去忽略它。(status 出于同样原因已经拒绝为搁置的失败引用报警:任何带外部库导入的仓库都有它们,那样的警告会变成永久噪声。)

精确的、可执行的检查则保留并文档化,见下文复现章节。

漂移为什么劣化检索:RWR 质量是相对量

图谱质量(RWR,random walk with restart)是相对且归一化的:别处缺失的 calls 边会抬升未受影响文件的质量份额。Explore 按这个质量给文件分配预算(src/mcp/tools.tsallocateExploreBudget 以它做权重),所以漂移会静默地抬高本应排低的文件。

文档记录了一个私有应用仓库(重度开发中)的观察案例:一个生成的 ambient-types 文件在漂移索引上的图谱质量为 0.24750,干净重建上为 0.13119(约 1.9 倍),得分 49.0 对 27.0。在漂移索引上,它占掉了 explore 信封的 60.7%,饿死了 agent 明确点名的文件——后者只渲染了 10,970 字符预留量中的 251 字符。全量重索引之后——没有改任何代码——同样的查询给出正确回答。那起事故正是本次测量的起因(见 CG-24,对应 docs/benchmarks/explore-noise-epic-cg24.md)。

严重程度随 churn 与索引年龄放大:codegraph 自家仓库显示 4.3%,一个开发更活跃、churn 更重的仓库可以推断漂移更远。

如何复现

scripts/agent-eval/diff-index-drift.mjs 是只读的,用于 diff 两个索引。先快照 live 索引再重建——本次调查的原始产物就是被覆盖重索引销毁的:

cp .codegraph/codegraph.db /tmp/live.db          # 先快照
node dist/bin/codegraph.js index .               # 全量重建
node scripts/agent-eval/diff-index-drift.mjs /tmp/live.db .codegraph/codegraph.db

退出码 0 表示收敛,1 表示漂移。要重新确认确定性,diff 两个连续重建——那必须报告 0。脚本输出的内容(对应 scripts/agent-eval/diff-index-drift.mjs 的实现)包括:files/nodes/边行数/独立边数/heuristic 边的两侧对照、按 kind 分布的 missing 与 stale、以及区分"解析陈旧"与"残留积累"的三项完整性检查(重复节点、孤儿边、指向缺失文件行的节点)。

要复现回归(而不是测量某个 live 索引),则回放真实提交:克隆仓库、检出 HEAD~N、索引,然后对每个提交按顺序执行 git checkout <sha> && codegraph sync,快照数据库,与最终树的重建做 diff。上表正是这样产生的;其单元规模版本是 tests/sync-rebuild-convergence.test.ts。该测试套件还自带一条回归护栏(CG-35):

CODEGRAPH_NO_REBIND=1 npx vitest run __tests__/sync-rebuild-convergence.test.ts

带 kill switch 运行必须失败、不带必须全绿——因为收敛用例是 rebind 通路仅有的覆盖,"套件在开关下通过"本身就意味着测试没测到东西。

一个探测索引时的教训:sqlite 会创建空库

索引文件是 .codegraph/codegraph.db没有 graph.db。用 sqlite3(以及 Node 的 node:sqlite)指向一个拼错的路径时,行为不是报错,而是创建一个空数据库——后续每条查询都从空 schema 作答,看起来与一个"迁移前的陈旧索引"一模一样。调查过程中正是这一点先制造过一次错误的根因判断。作为防御,scripts/agent-eval/diff-index-drift.mjs 在打开前显式做 existsSync 检查并以退出码 2 拒绝不存在的输入文件,注释原样记录了这条教训:"node:sqlite CREATES a missing file rather than failing, which silently yields an empty schema and a confident, wrong conclusion."

小结:给增量索引设计者的可迁移结论

CG-33 的调查给出四条可以脱离 codegraph 语境成立的结论:

  1. 漂移检测必须比较集合,不能比较计数——双向分叉会在任何 net-count 检查中自我抵消(+0.7% 的行数差掩盖了 4.3% 的边错误);
  2. 先证明确定性再归因——rebuild vs rebuild 为 0 的控制实验,把"增量路径独有"这一结论从推测变成事实;
  3. 当解析是全局作用域时,增量重解析必须重算"答案的定义域变化",而不仅是重解析变更文件——definitionDelta 逐文件对称差 + 删除重插(rebind)是"让增量走一遍与全量相同的解析输入"的实现形式;
  4. 保守性的不对称性要写进设计:错删是永久损失、漏修只是残留,于是"无时间戳不删、已重抽取跳过、泛化名设上限"三条护栏,与"泛化名残留刻意不追、status 刻意不上漂移指标"两个"不做"的决定,共同保证了修复不会把漂移问题换成错误边问题。
登录后查看全文
热门项目推荐
相关项目推荐