graphify 增量更新(--update)与仅重聚类(--cluster-only)实战指南
本指南讲解 graphify 的两条高频增量管线:--update 增量重提取,以及 --cluster-only 在既有图上重跑社区发现。读完你将掌握如何只对变更文件做 AST 重提取与合并、正确处理删除文件与语义文件重试,以及如何用 graphify cluster-only . 一步刷新报告与图谱文件。文中全部命令与 API 调用均以仓库内实际实现为准,可直接复制运行。
适用场景定位:参考文档 update.md 源文件 是面向 Agent(如 Claude Code、Kiro、Codex、Gemini CLI 等)的操作手册,仅在用户传入
--update或--cluster-only时才需要加载;首次全量构建不读该文件。它默认已经存在由全量构建产生的graphify-out/(含graph.json与manifest.json),即“上一次运行”的基线。
一、两条命令与它们的定位
graphify 的全量流程通常分为多个步骤(文件扫描 → AST 提取 → 语义提取 → 聚类 → 报告)。在已经产出过一次 graphify-out/ 之后,有两类高频场景:
| 场景 | 命令/触发词 | 做什么 | 与全量流程的关系 |
|---|---|---|---|
| 增量更新 | --update |
只对新增/修改过的文件重做提取,删除的文件被裁剪,随后合并进既有图 | 跳过“未变文件”,仅重跑变更文件相关步骤 |
| 仅重聚类 | --cluster-only(子命令) |
忽略文件内容,在已有 graph.json 上重新聚类、命名社区并重新生成产物 |
跳过提取,只做图谱后处理 |
两种模式都围绕 graphify-out/ 下的中间文件协作:manifest.json(文件指纹清单)、.graphify_detect.json(本次扫描结果)、.graphify_incremental.json(增量差异)、.graphify_extract.json(提取结果)、graph.json(合并后的图)。理解这些文件关系是读懂后文脚本的前提。
二、--update 增量重提取:整体流程
增量更新的核心哲学是“能省则省”:只有内容真的变了,才为它付出提取成本。
- 调用
detect_incremental()计算“相对上次清单”的变更集,写出.graphify_incremental.json; - 把变更集翻译成后续步骤能直接消费的
.graphify_detect.json(files只含变更文件、all_files仍保留全量语料); - 根据“是否只改了代码文件”分流:
- 全部是代码文件 → 只跑 Step 3A(AST),完全跳过 Step 3B(无需 LLM/子代理);
- 存在文档/论文/图片/视频 → 视频先走
references/transcribe.md(Step 2.5),再全量跑 3A–3C; - 只有删除、没有新增 → 生成空提取结果,让合并步骤做裁剪;
- 用
build_merge()把新提取结果与磁盘上的graph.json合并(含删除文件裁剪与变更文件替换),合并结果写回.graphify_extract.json; - 保存 manifest,供下一次
--update作差异基线; - 按常规流程继续执行 Step 4 之后的图谱处理,并在 Step 4 后展示图差异。
三、计算增量差异:detect_incremental 与结果落盘
增量差异的判定实现在 detect.py 的 detect_incremental(root, ...)。它每次都会先做一次完整 detect() 扫描,再把扫描结果与 manifest.json(路径默认 graphify-out/manifest.json)逐文件比对:
- mtime 未变且指纹匹配 → 未变(免费快速路径,仅 stat);
- mtime 变化 → 用 MD5 内容指纹二次确认,避免“只改时间戳不改内容”误报;
- 出现在 manifest、磁盘上已不存在 → 判定为
deleted_files(真正的删除);磁盘上仍在但已不在扫描范围 → 归入excluded_files(被忽略规则排除,不能当删除)。 - 首次运行(无 manifest)会把全部文件当作新增。
关于 mtime 有一个值得注意的细节:源码在 detect.py 处理了“mtime 隐藏同一次重写”的竞态——若 manifest 写入与文件编辑落在同一时间戳刻度内,mtime 无法证明内容未变,此时仍会对该文件补算一次 MD5。manifest 中每行记录的字段为 mtime、ast_hash、semantic_hash 与 seen,分别由 graphify update(AST 通道)与 graphify extract(语义通道)盖戳。
参考文档给出的调用片段如下,结果同时打印并写入 graphify-out/.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.')
"
两点说明:$(cat graphify-out/.graphify_python) 从 graphify 自身输出目录读取本次构建所用的解释器路径,保证与安装 graphify 的 Python 环境一致;INPUT_PATH 是占位符,需替换为实际扫描根目录。
detect_incremental 的返回字典字段(与 detect.py 的写入逻辑对应)如下:
| 字段 | 含义 |
|---|---|
files |
本次全量扫描按类型分组的文件表(code/document/paper/image/video) |
new_files |
仅新增/修改文件,按类型分组 |
unchanged_files |
未变文件,按类型分组 |
new_total |
新增/修改文件总数 |
deleted_files |
磁盘上已删除(需裁剪) |
excluded_files |
仍存活但已移出扫描范围(不算删除) |
skipped_sensitive |
因疑似敏感内容被跳过的文件 |
四、把增量结果翻译给后续步骤:.graphify_detect.json
文档特别强调:后续“Step 3A–Step 6”读取 .graphify_detect.json 时不加条件,因此必须为增量运行写入正确的状态。要点是字段分工:
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')
"
注意 total_files 使用的是 new_total(本次变更数)而非全量数,这是有意为之:让下游“按文件数估算成本”的逻辑只看到本轮真正要做的工作。
五、分流决策:代码-only 与含语义文件
5.1 先判定是否全部为代码文件
$(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)
"
上表所举的扩展名只是示例集合;仓库实际维护了更完整的分类型扩展表(代码、文档、PDF、图片、Office、音视频),见 detect.py 的 CODE_EXTENSIONS / DOC_EXTENSIONS / PAPER_EXTENSIONS / IMAGE_EXTENSIONS / OFFICE_EXTENSIONS / VIDEO_EXTENSIONS。文件类型最终由 classify_file()(detect.py)统一裁决,其中还包含包清单走 AST、.blade.php 复合扩展、shebang 识别扩展名缺失脚本、PDF 位于 Xcode 资源目录内时降级等细节。
5.2 两个分支
分支 A:code_only 为 True(纯代码变更)
这是最省钱的路径:打印提示 [graphify update] Code-only changes detected - skipping semantic extraction (no LLM needed),只对变更文件运行 Step 3A(AST 确定性提取),完全跳过 Step 3B(无子代理、无 LLM 调用),随后直接进入合并与 Step 4–8。由于代码符号提取是确定性的本地 AST 解析,图在秒级内即可完成刷新。
分支 B:code_only 为 False(含文档/论文/图片/视频)
- 若
new_files['video']非空,必须先对这批文件执行references/transcribe.md(Step 2.5)完成转写,再把转写产出的文本路径改写进.graphify_detect.json的files['document']并删除files['video']——否则裸.mp4/.mp3路径会被当成不可读媒体喂给语义子代理; - 随后按正常流程执行完整的 Step 3A–3C。
5.3 只有删除:写空提取结果
若 new_total == 0 且存在删除,.graphify_extract.json 可能尚未创建,而合并步骤需要它才能执行裁剪,因此要补一个空提取:
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
六、合并: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
# Load new extraction and incremental state
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.')
"
(IS_DIRECTED 需要按实际调用替换:传入 --directed 时为 True,否则为 False。)
6.1 prune_sources 只收“真删除”
build_merge 的签名见 build.py。文档给出两条铁律:
prune_sources只能放真正从磁盘删除的文件。变更/重提取文件的旧节点不靠它清理;- 绝不把
changed放进 prune:一旦传入root=,prune 路径会相对化到与刚合并节点相同的基准,会把刚重提取的内容一并删掉。变更文件的“旧节点替换”由build_merge自身的 replace-on-re-extract 机制负责(见下节),而不是去重(dedup)通道。
这背后的源码逻辑位于 build.py:凡在 new_chunks 中出现的 source_file,会先从既有图基准中按“AST 层/语义层”剔除旧节点/边再合入新数据,从而保证变更文件不会累积陈旧节点;真正删除的文件才通过 prune_sources 做不分层级的裁剪,且裁剪同时匹配原始绝对路径与相对化形式。
6.2 为什么用 build_merge 而不是 NetworkX 往返
build_merge 直接读 graph.json,不经 NetworkX 序列化往返,因此边方向(calls / implements / imports)始终被保留(仓库注释 #801)。同时需要传 root=,使来自 detect_incremental 的绝对路径删除项被相对化,与图中相对 source_file 对齐;缺失 root 会导致删除项永远匹配不上、陈旧节点每次更新都累积(#1361)。
directed=IS_DIRECTED 同样关键:--directed --update 若不显式传方向,合并会静默退化为无向图并折叠互反的 A↔B 边。源码对 directed=None 的兜底是继承磁盘上既有图的 directed 标志(见 build.py),但显式传值永远最安全。
6.3 合并结果写回与超边保留
合并得到的图(NetworkX G)被序列化回 .graphify_extract.json,供后续 Step 4 读取完整图:
- 边属性显式取
_src/_tgt并以source/target字段覆盖输出,防止陈旧属性残留在d中; - 超边(hyperedges)取自
G.graph['hyperedges'],因为build_merge已把旧图与新提取两边的超边合并到一起;若回退只取new_extraction,会静默丢掉此前运行的超边(#801)。
6.4 写回 manifest:为下一次 diff 立基线
增量自洽的最后一步是保存 manifest。若不更新基线,下一次 --update 会拿“上一次”的旧状态做差异,产生幽灵节点报告。此段三个要点:
root=必须与build_merge一致:manifest 键以扫描根为基准的相对路径落盘,跨克隆/跨机器可移植(#1417)。这也与 save_manifest 的实现对应——传root时键先 NFC 归一化再转相对路径存储。- 只盖戳真正产出语义输出的文件:
_stamped_manifest_files(cli.py)会筛掉“分块失败/被省略”的语义文件——只有 nodes/hyperedges 中存在其source_file的文件才算有效输出。一个 chunk 失败的文档必须保持未盖戳,否则会被标记为完成、内容永久丢失(#2015/#933)。 clear_semantic清掉陈旧指纹:本次派发但未盖戳的语义文件(chunk 失败或被省略)要清空其历史semantic_hash,强制下次重新排队(#1948);scan_corpus传的是原始全量语料,使自上次运行起新被排除的 in-root 文件被正确丢弃而不是伪装成删除,未触碰的行则完整保留(#1908)。
save_manifest 的行记录由 mtime、seen、ast_hash、semantic_hash 组成(detect.py),并以原子写落盘避免截断(detect.py)。若序列化结果与磁盘一致还会跳过重写,减少无效 IO。
七、合并之后:展示图差异
合并完成后按常规执行 Step 4–8,并在 Step 4 后展示本轮图差异。做法是:合并前先把旧图备份为 .graphify_old.json(cp graphify-out/graph.json graphify-out/.graphify_old.json),合并完成后用 graph_diff 对比新旧两图:
$(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']))
"
graph_diff(analyze.py)返回结构化的新增/删除节点与边,以及一句 summary(如 "3 new nodes, 5 new edges, 1 node removed");边比较时会区分有向与无向(无向图对 (u,v) 归一化),有向关系保留方向。.graphify_old.json 用完后清理:rm -f graphify-out/.graphify_old.json。
八、--cluster-only:在既有图上重聚类
--cluster-only 是图构建完成后的独立后处理。命令形态如下(也可用子命令形态 graphify cluster-only .):
graphify cluster-only .
该命令自包含:它直接基于现有图重新聚类、命名社区,并从既有图重新生成 GRAPH_REPORT.md、graph.json、graph.html。在 CLI 层面对应的实现位于 cli.py(cluster-only 与 label 命令共用入口,区别在于 label 总是重生成社区名),它会基于 .graphify_labels.json 等持久化数据在已有图之上工作(见 cluster.py 附近对“供后续 cluster-only 判读”的说明)。
使用上的关键纪律是:不要在 --cluster-only 后重跑 Step 5–9。这些步骤会读取 .graphify_extract.json、.graphify_detect.json、.graphify_analysis.json 等中间文件,而上次构建的 Step 9 清理早已删除它们,重跑必然抛出 FileNotFoundError。命令完成后,照常向用户展示刷新后的 GRAPH_REPORT.md 摘要即可。
九、何时该用增量管线:决策速查
| 情形 | 推荐做法 | 原因 |
|---|---|---|
| 上次全量构建后改了几个代码文件 | --update(纯代码分支) |
只跑 AST,零 LLM 开销,秒级刷新 |
| 新加入/修改了文档、PDF、图片 | --update(语义分支) |
自动按需触发语义提取,视频先转写 |
| 删除了一批文件 | --update |
空提取 + prune_sources 裁剪幽灵节点 |
| 只改了聚类/命名参数,想重看社区划分 | graphify cluster-only . |
自包含,不触碰提取阶段 |
| 从未构建过 | 全量构建 | 增量依赖既有 manifest.json 与 graph.json 基线 |
一条贯穿始终的提醒:增量管线依赖“上次的 manifest 基线”,而 manifest 必须相对扫描根存储(root=)才具备跨机器可移植性。若基线缺失或格式不兼容(如纯 float mtime 的旧格式),detect_incremental 会按“全部视为新增”处理——宁可多做,不可漏做。
十、源码速查
- update.md 参考文档:本文的权威来源,Kiro 平台技能同款(各平台副本位于 graphify/skills 下的
kiro/、claude/、codex/、opencode/等目录,以及 skill-agents.md 等以.md形式嵌入的技能) - detect.py:文件分类、敏感文件过滤、manifest 读写与
detect_incremental增量判定(save_manifest见第 2112 行附近,detect_incremental见第 2366 行附近) - build.py:
build_merge替换式合并与删除裁剪(第 1626 行附近)、build_from_json(第 798 行附近) - cli.py:
_stamped_manifest_files语义盖戳过滤(第 88 行附近)、cluster-only/label命令入口(第 1843 行附近) - analyze.py:
graph_diff新旧图差异计算(第 556 行附近) - tests:增量行为有专门的回归覆盖,例如
test_incremental.py、test_incremental_mtime_collision.py,以及test_dedup_remaps_hyperedges.py、test_hyperedge_roundtrip.py等对合并/超边保持的验证
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00