首页
/ graphify 增量更新与簇重建实战:--update / --cluster-only 参考手册深度解析

graphify 增量更新与簇重建实战:--update / --cluster-only 参考手册深度解析

2026-09-06 13:38:30作者:农烁颖Land

本文围绕 graphify 的 增量更新参考手册 展开,完整覆盖 --update(增量重抽取)与 --cluster-only(仅重新聚类)两条运行路径:从变更检测(detect_incremental)、变更子集检测文件填充、code-only 快速路径、删除修剪与 build_merge 合并,到 manifest 持久化与 graph_diff 图差异展示,并深入 graphify/detect.pygraphify/build.py 等源码,印证每个参数背后的实现动机。读完本文,你可以理解 graphify 如何"只重抽变过的文件、绝不把旧节点留在图里",并掌握在 Agent 技能运行手册中正确执行增量更新全流程的方法。

参考手册的定位:只在增量路径加载

这份 reference 文件是 graphify 面向 Claude Code 等 Agent 平台提供的技能参考文档(graphify/skills/claude/references/update.md),原文明确规定:

Load this only when the user passed --update or --cluster-only. A first-time full build never reads this file.

即:只有当用户传入 --update--cluster-only 时才加载本文件;首次全量构建永远不会读取它。这与同目录下的 transcribe.md(视频转写子步骤)、query.md(查询)等参考文档共同构成技能的按需加载体系。下文按原文两条主线分别展开。

一、--update:增量重抽取的完整流程

增量更新适用于"上一次运行之后新增或修改了文件"的场景,只重抽变更文件,从而节省 token 和时间。整个流程分为四段:变更检测 → 填充检测文件 → 分支判定(code-only / 全量语义)→ 合并、manifest 与图 diff

1. 变更检测:detect_incremental

第一步调用 graphify.detect.detect_incremental 对比上次运行以来的文件变化,并把结果落盘到 .graphify_incremental.json

$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.detect import detect_incremental, save_manifest
from pathlib import Path

result = detect_incremental(Path('INPUT_PATH'))
new_total = result.get('new_total', 0)
print(json.dumps(result, indent=2, ensure_ascii=False))
Path('graphify-out/.graphify_incremental.json').write_text(json.dumps(result, ensure_ascii=False), encoding=\"utf-8\")
deleted = list(result.get('deleted_files', []))
if new_total == 0 and not deleted:
    print('No files changed since last run. Nothing to update.')
    raise SystemExit(0)
if deleted:
    print(f'{len(deleted)} deleted file(s) to prune.')
if new_total > 0:
    print(f'{new_total} new/changed file(s) to re-extract.')
"

INPUT_PATH 是技能执行时由 Agent 填入的实际项目路径占位符。)

结果中 new_total 为新增/变更文件总数,deleted_files 是真正被删除的文件。若两者皆空,直接退出——无事可做。

源码印证:mtime 快速路径 + 内容哈希慢路径

graphify/detect.pydetect_incremental 实现看,变更判定采用两级策略:

  • 快速路径:文件的 mtime 未变且与 manifest 记录的 mtime 一致,则判定为未变更(只做 stat,不做磁盘 IO);
  • 慢速路径:mtime 被刷新后,用 MD5 内容与 manifest 中对应哈希字段比对,内容一致才算未变更。

其中有一个值得注意的细节——mtime 精度窗口防护。源码定义了 _MTIME_COARSE_S = 2.0_MTIME_SUBSECOND_S = 0.05 两个常量(graphify/detect.py):当 manifest 记录时间戳与文件 mtime 落在同一"文件系统刻度"内时(粗粒度文件系统会把 mtime 舍入到整秒),一次同长度编辑可能不会移动 mtime,导致文件被静默跳过。此时 _mtime_may_hide_a_rewrite 会强制走一次内容哈希校验,避免图持续提供旧内容。这个窗口机制与 graphify/cache.py 哈希缓存层的假设保持一致,并有专门的回归测试 tests/test_incremental_mtime_collision.py

另一个实现细节是双哈希 schema:manifest 每个文件行同时保存 ast_hashsemantic_hash,并通过 kind 参数区分语义——kind="ast" 服务于 graphify update(AST-only 重建),kind="semantic" 服务于 graphify extract。缺少 semantic_hash 的文件(比如只跑过 AST 更新的文档)在语义检测中始终视为"需重抽"。此外,删除文件与"被排除文件"被严格区分:磁盘上文件已不存在才计入 deleted_files;文件仍在磁盘但被 .graphifyignore/.gitignore/--exclude 排除的计入 excluded_files,不会误报为删除(见 tests/test_detect.py 中对 test_detect_incremental_* 系列用例的覆盖,包括 mtime 回退、schema 漂移等边界场景)。

2. 填充 .graphify_detect.json:让后续步骤看到正确的状态

检测到变更子集后,需要重写 .graphify_detect.json,使无条件读取该文件的 Step 3A–6 看到增量运行应有的状态。原文的关键说明是:

  • files 携带变更子集——驱动 Step 3A 的 AST 抽取,以及只对变更文件做 Step 3B0 缓存检查;
  • all_files 携带全量语料——供任何需要全库上下文的步骤使用。
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
r = json.loads(Path('graphify-out/.graphify_incremental.json').read_text(encoding=\"utf-8\"))
Path('graphify-out/.graphify_detect.json').write_text(json.dumps({
    'files': r.get('new_files', {}),
    'all_files': r.get('files', {}),
    'total_files': r.get('new_total', 0),
    'total_words': r.get('total_words', 0),
    'skipped_sensitive': r.get('skipped_sensitive', []),
    'needs_graph': True,
}, ensure_ascii=False), encoding=\"utf-8\")
"

字段对应关系值得注意:new_files(变更子集)映射为 files,而 detect_incremental 返回的完整扫描结果 files(全量语料)映射为 all_files

3. 分支判定:code-only 快速路径 vs 完整语义管线

若存在新文件,先判断所有变更文件是否都是代码文件:

$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path

result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {}
code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'}
new_files = result.get('new_files', {})
all_changed = [f for files in new_files.values() for f in files]
code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed)
print('code_only:', code_only)
"

原文据此给出两条分支:

  • code_only == True:打印 [graphify update] Code-only changes detected - skipping semantic extraction (no LLM needed),只对变更文件执行 Step 3A(AST 确定性解析),完全跳过 Step 3B(LLM 语义子代理),直接进入合并与 Steps 4–8。这与项目"本地确定性 AST 解析、无向量库"的核心设计一脉相承——代码结构图不消耗 LLM token。
  • code_only == False(任一变更文件是文档/论文/图片/视频):若变更文件中有 new_files['video'],先按 transcribe.md(Step 2.5)对视频转写,然后重写 .graphify_detect.json,把转写产出路径移入 files['document']、删除 files['video']——否则裸的 .mp4/.mp3 路径会被当作文本喂给语义子代理,导致不可读媒体进入抽取(原文标注对应 issue #1392)。之后按正常流程执行完整 Steps 3A–3C。

与 detect.py 扩展名分类的对照

这份 code_exts 集合是运行手册内的自包含判定。对照 graphify/detect.py 的官方分类,CODE_EXTENSIONS 覆盖面更广(还包括 .vue.svelte.zig.sql.terraform 系、PowerShell、C# 工程文件等),并额外区分 DOC_EXTENSIONS.md/.mdx/.rst 等)、PAPER_EXTENSIONS.pdf)、IMAGE_EXTENSIONSVIDEO_EXTENSIONS(含 .mp3/.wav 等音频)。可以推断,参考手册中的精简集合是刻意为 Agent 内联脚本保持轻量——FileType 枚举(code/document/paper/image/video)才是分类权威,内联集合只服务于"是否需要 LLM"的二元判定。

4. 仅有删除:构造空抽取以供合并修剪

若没有新/变更文件、只有删除,需要创建空抽取文件,让合并步骤据此做节点修剪:

if [ ! -f graphify-out/.graphify_extract.json ]; then
    echo '[graphify update] Only deletions -- creating empty extraction for merge.'
    $(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
Path('graphify-out/.graphify_extract.json').write_text(json.dumps({'nodes':[],'edges':[],'hyperedges':[],'input_tokens':0,'output_tokens':0}), encoding='utf-8')
"
fi

5. 合并:build_merge 的四个关键参数

合并阶段是整个增量流程的核心,原文以详尽注释给出了调用与理由:

$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
from graphify.build import build_merge
from graphify.detect import save_manifest

new_extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
incremental = json.loads(Path('graphify-out/.graphify_incremental.json').read_text(encoding=\"utf-8\"))
deleted = list(incremental.get('deleted_files', []))
prune = list(deleted) or None

G = build_merge(
    [new_extraction],
    graph_path='graphify-out/graph.json',
    prune_sources=prune,
    root='INPUT_PATH',
    directed=IS_DIRECTED,
)
print(f'[graphify update] Merged: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges')

merged_out = {
    'nodes': [{'id': n, **d} for n, d in G.nodes(data=True)],
    'edges': [
        {**{k: val for k, val in d.items() if k not in ('_src', '_tgt', 'source', 'target')},
         'source': d.get('_src', u), 'target': d.get('_tgt', v)}
        for u, v, d in G.edges(data=True)
    ],
    'hyperedges': list(G.graph.get('hyperedges', [])),
    'input_tokens': new_extraction.get('input_tokens', 0),
    'output_tokens': new_extraction.get('output_tokens', 0),
}
Path('graphify-out/.graphify_extract.json').write_text(json.dumps(merged_out, ensure_ascii=False), encoding=\"utf-8\")
print(f'[graphify update] Merged extraction written ({len(merged_out[\"nodes\"])} nodes, {len(merged_out[\"edges\"])} edges)')

from graphify.cli import _stamped_manifest_files
_manifest_files = _stamped_manifest_files(incremental['files'], new_extraction, Path('INPUT_PATH'))
_sem_types = ('document', 'paper', 'image')
_dispatched = {f for t, fl in incremental.get('new_files', {}).items() if t in _sem_types for f in fl}
_stamped = {f for fl in _manifest_files.values() for f in fl}
_cleared = _dispatched - _stamped
_scan = {f for fl in incremental['files'].values() for f in fl}
save_manifest(_manifest_files, root='INPUT_PATH', scan_corpus=_scan, clear_semantic=_cleared or None)
print('[graphify update] Manifest saved.')
"

原文为 build_merge 给出的每条参数理由(IS_DIRECTED 同样为 Agent 按 --directed 开关替换的占位符):

参数 原文理由(含 issue 溯源) 源码印证
prune_sources=prune 只用于真正被删除的文件。变更/重抽文件由 build_merge 的 replace-on-re-extract 机制处理(#1344):new_chunks 中每个 source_file 在合并前从基图中剔除,旧节点不会残留。切勿changed 加入 prune:当传了 root= 时,prune 集合会与刚合并节点相对化到同一基准,反而删掉刚重抽的内容 graphify/build.py 注释明确"Re-extracted files REPLACE their prior contribution",且替换是**按层(tier-scoped)**的:AST 层与语义层各自独立替换,重抽一层不会抹掉另一层的节点
root='INPUT_PATH' detect_incremental 返回的绝对路径 prune 集合相对化,与图中相对 source_file 匹配;缺省则什么都剪不掉,每次更新都累积幽灵节点(#1361/#1571) build_merge 内部 _eff_root 逻辑:未传 root 时会回退推断扫描根,避免绝对 win32 路径与相对 posix 键不匹配(#1007)
directed=IS_DIRECTED 必须显式传入:否则 --directed --update 会静默重建为无向图,把互反的 A↔B 边坍缩(#1392) directed=Nonebuild_merge 继承磁盘上图自身的 directed 标记(#2342),显式值总是覆盖
graph_path(只读) 直接读 graph.json,无 NetworkX 往返,边方向(calls/implements/imports)恒保真(#801) docstring 明确 "Does NOT write to disk — the caller persists the result"

合并后 G.graph["hyperedges"] 同时包含旧图与新抽取的超边,原文特别警告:只回退到 new_extraction 会静默丢弃前次运行的超边(#801)——上表代码中 'hyperedges': list(G.graph.get('hyperedges', [])) 正是对应处理。边序列化时显式把 source/target 放在最后,使其优先于 d 中任何陈旧属性。

6. manifest 持久化:让下一次 --update 对比今天的状态

合并后必须保存 manifest,否则下次 --update 仍会对比上次的基线,产生"幽灵节点"报告。原文还解释了三个精细参数(对应 graphify/detect.pysave_manifest 的 docstring):

  • root='INPUT_PATH':manifest 键相对化到扫描根,跨 clone/机器可移植。不传的话,目录一移动,--update 就会对每个缓存文件全部 miss(#1417/#777)。
  • 只盖章真正产出输出的语义文件_stamped_manifest_filesgraphify/cli.py)只对本次实际产出了节点/超边的文档盖章;某个 chunk 抽取失败的文件保持未盖章,下次 --update 自动重新排队——否则它会被标记完成、内容永久丢失(#2015/#933)。
  • clear_semantic:本次被派发但未盖章的文件(chunk 失败或被 LLM 遗漏)清除其陈旧 semantic_hash,强制重排(#1948)。
  • scan_corpus:传入原始全量语料,使自上次运行以来新被排除的根内文件被正常丢弃,而不是伪装成删除;未触碰的行保留(#1908)。

7. 图差异展示:graph_diff

Steps 4–8 在合并后的完整图上照常执行。Step 4 之后,用更新前的备份图做差异对比:

$(cat graphify-out/.graphify_python) -c "
import json
from graphify.analyze import graph_diff
from graphify.build import build_from_json
from networkx.readwrite import json_graph
import networkx as nx
from pathlib import Path

old_data = json.loads(Path('graphify-out/.graphify_old.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_old.json').exists() else None
new_extract = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
G_new = build_from_json(new_extract, directed=IS_DIRECTED)

if old_data:
    G_old = json_graph.node_link_graph(old_data, edges='links')
    diff = graph_diff(G_old, G_new)
    print(diff['summary'])
    if diff['new_nodes']:
        print('New nodes:', ', '.join(n['label'] for n in diff['new_nodes'][:5]))
    if diff['new_edges']:
        print('New edges:', len(diff['new_edges']))
"

配套操作(原文原样保留):

  • 合并步骤之前:cp graphify-out/graph.json graphify-out/.graphify_old.json
  • 结束后清理:rm -f graphify-out/.graphify_old.json

graph_diffgraphify/analyze.py)返回 new_nodesremoved_nodesnew_edgesremoved_edges 与一行 summary(形如 "3 new nodes, 5 new edges, 1 node removed")。从源码看,无向图的边按 (min(u,v), max(u,v), relation) 归一化,有向图按 (u, v, relation) 区分,保证互反边不会被误判为增删——这与 --directed 必须显式传参的要求形成闭环。

二、--cluster-only:对现有图单独重跑聚类

原文对该模式的说明极其明确:

Skip Steps 1–3. Re-run clustering on the existing graph:

graphify cluster-only .

graphify cluster-only .自包含的:重新聚类、命名社区,并从现有图重新生成 GRAPH_REPORT.mdgraph.jsongraph.html切勿重跑 Steps 5–9——这些步骤读取的中间文件(.graphify_extract.json.graphify_detect.json.graphify_analysis.json)已在此前构建的清理步骤(Step 9)中被删除,重跑会抛 FileNotFoundError(#1392)。完成后照常呈现刷新后的 GRAPH_REPORT.md 摘要。

graphify/cli.pycluster-only 分支可印证该命令的实际能力面:

  • label 子命令共用代码路径(label 是"总是重新生成社区名"的 cluster-only);
  • 支持 --no-viz--no-label--missing-only--timing--backend=--model=--batch-size=--min-community-size=(默认 3)等标志;
  • 重聚类后会用 remap_communities_to_previous 将新社区 ID 映射回上一次标注(#1027),避免标签因原始 cid 索引变化而错位;
  • token 用量来自真实的标注 LLM 调用而非硬编码零值(#1694);--no-label 的纯占位标签不会被持久化复用(#2073)。

适用场景:调整了社区命名后端/模型、想刷新报告与可视化但不想重新支付抽取成本时,cluster-only 是最小代价路径。

三、设计要点小结

综合参考手册与源码,graphify 增量更新的设计可以归纳为五条不变量,每条都有对应的源码与测试证据:

  1. 只重抽变更文件:mtime 快速路径 + 内容哈希慢路径 + 双哈希(ast_hash/semantic_hash)区分 AST 与语义两个抽取层(graphify/detect.py,测试见 tests/test_detect.pytests/test_incremental_mtime_collision.py);
  2. 变更文件"替换"而非"追加"build_merge 按层替换重抽文件的既有贡献,删除文件走 prune_sources,二者机制严格分离(graphify/build.py,测试见 tests/test_build_merge_shrink_guard.pytests/test_prune_sweeps_orphans.py);
  3. 失败不盖章:未产出输出的文件永远不写入 manifest 哈希,下次自动重排队(graphify/cli.py);
  4. 路径可移植:manifest 与图键均相对化到扫描根,跨机器/跨 clone 依然命中缓存(#777/#1417);
  5. 方向性不静默丢失directed 必须显式传入合并调用,图自身的 directed 标记只作兜底(#1392/#2342)。

此外,仓库根目录下的 docs/superpowers/specs/2026-05-04-incremental-updates-dedup-design.mddocs/superpowers/plans/2026-05-04-incremental-updates-dedup.md 记录了增量更新与去重合并的设计演进,可作为进一步深入阅读的材料。需要说明的是,文中所有 $(cat graphify-out/.graphify_python) 调用都假定 graphify-out/ 中已由首次构建写入解释器路径标记,且 INPUT_PATHIS_DIRECTED 等占位符由 Agent 在执行时按实际上下文替换——脱离首次构建直接运行这些片段不会成立,这是使用本参考手册的前提限制。

登录后查看全文
热门项目推荐
相关项目推荐