graphify 增量更新机制解析:`--update` 与 `cluster-only` 的实操流程与底层实现
本文以 graphify 的 Kilo 技能参考文档 update.md 为主线,完整拆解 --update(增量再抽取)与 --cluster-only(仅重聚类)两条路径:从变更检测、.graphify_detect.json 状态填充、code-only 短路、build_merge 合并与 manifest 回写,到图 diff 展示,并结合 graphify/detect.py、graphify/build.py、graphify/analyze.py 与 graphify/cli.py 的源码,解释每个参数的存在原因。读完本文,你能独立执行一次安全的增量更新、理解“为什么已修改文件不能加入 prune 集合”这类关键设计决策,并正确使用 cluster-only 重新生成图谱报告。
一、文档定位:什么时候读这份参考
update.md 的开篇就明确了使用边界:只有当用户传入了 --update 或 --cluster-only 时才加载本参考,首次全量构建永远不会读取它。它的适用场景是“上次运行之后新增或修改过文件”——此时只需重新抽取变更文件,节省 token 与时间。
理解这一点有助于把两条路径与全量构建区分开:
| 路径 | 触发条件 | 读取的中间状态 | 产物 |
|---|---|---|---|
| 全量构建 | 首次运行 /graphify |
无 manifest 基线 | 完整图谱 + 报告 |
--update |
上次运行后有增/改/删 | graphify-out/manifest.json |
合并后的 graph.json、更新后的 manifest |
cluster-only |
只想重聚类/重命名社区 | 仅已有 graph.json |
刷新后的 GRAPH_REPORT.md、graph.json、graph.html |
所有增量操作都发生在 graphify-out/ 目录内,Python 解释器统一通过 $(cat graphify-out/.graphify_python) 这一行获取,避免依赖系统 Python 环境。
二、第一阶段:用 detect_incremental 做变更检测
增量更新的第一步是调用 detect_incremental 并与上次 manifest 对比:
$(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.')
"
结果写入 graphify-out/.graphify_incremental.json;当 new_total == 0 且没有删除文件时直接以退出码 0 结束,不做任何事。
源码视角:detect_incremental 如何判定“变更”
实现位于 graphify/detect.py#L2366-L2504,其文档字符串解释了两种判定模式:
kind="semantic"(graphify extract默认):文件的semantic_hash缺失或内容变化即视为“已变更”,确保只被graphify update(纯 AST 路径)触碰过的文件仍会被语义抽取重新覆盖;kind="ast"(graphify update用):以ast_hash为基准。
变更检测采用快路径 + 慢路径两级策略:mtime 未变且哈希匹配 → 判定不变(几乎零 IO);mtime 被更新 → 用 MD5 与 manifest 中对应哈希字段比对后才决定是否重新抽取。源码中还有两个值得注意的健壮性设计:
- mtime 回退也触发重抽:对比用
!=而非>,使 git checkout 旧提交、tarball 恢复等导致的 mtime 倒退仍能触发重新抽取,避免图谱与磁盘漂移(源码注释 #1859); - 秒级文件系统同刻窗口:文件写入与被记录落在同一“文件系统刻度”时,后续同长度编辑不会移动 mtime,因此仅在这一窄窗口内才支付一次内容哈希的代价(#1163 相关的 schema 漂移保护与
_mtime_may_hide_a_rewrite检查)。
返回值除 new_files / unchanged_files 外,还区分 deleted_files 与 excluded_files(graphify/detect.py#L2479-L2495):manifest 中残留的行如果文件已从磁盘消失,才算真正的删除(其缓存节点是“幽灵”);如果文件仍存在但不在当前扫描范围内(被 .graphifyignore / .gitignore / --exclude 排除),则只是被排除,不能当作删除处理——这正是后文 merge 阶段 prune 集合只能包含删除文件的原因。
首次运行(无 manifest)时,detect_incremental 会把整个语料视为新增:new_files = files、new_total = total_files、deleted_files = []。
三、第二阶段:填充 .graphify_detect.json,让后续步骤看到正确的增量状态
检测完成后,必须重写 graphify-out/.graphify_detect.json,因为 Step 3A–6 无条件地读取它。参考文档解释了两个字段的语义分工:
files:只装变更子集,驱动 Step 3A 的 AST 抽取与 Step 3B0 的缓存检查“只针对变化部分”;all_files:装全量语料,供需要 corpus-wide 上下文的步骤使用。
$(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(本轮待处理数量),而 all_files 取的是全量 files——这种“处理集合”与“上下文集合”分离的状态设计,是增量流水线能与全量构建共用 Step 3A–6 代码的关键。
四、第三阶段:code-only 分支与媒体文件转写
若存在新增/变更文件,先判断它们是否全部是代码文件:
$(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 子代理),直接进入 merge 与 Step 4–8。这与项目“本地确定性 AST 解析、每条边可解释”的核心理念一致——代码文件的符号与调用关系不需要 LLM 参与;code_only == False(任一变更文件是文档/论文/图片/视频):先检查new_files['video']。若存在视频/音频,必须先执行 transcribe.md 的 Step 2.5 对它们转写,然后重写.graphify_detect.json,把转写稿路径移入files['document']、删除files['video']——否则原始.mp4/.mp3路径会被直接喂给语义子代理,变成不可读的媒体(源码与文档均以 #1392 标记该问题)。之后按常规跑完整 Step 3A–3C 流水线。
值得对照的是,graphify/detect.py#L44-L49 中库级 CODE_EXTENSIONS / VIDEO_EXTENSIONS 等集合比参考文档里的 code_exts 白名单更宽(还包含 .sql、.vue、.svelte、.zig 等)。参考文档中的清单是“是否跳过语义抽取”的保守判定子集:扩展名不在其中就按“非纯代码”处理,宁可多走一次 LLM 流程也不漏掉文档内容。
仅有删除时:构造空抽取供 merge 剪枝
如果没有任何新增/变更文件、只有删除,则生成一个空抽取文件,让 merge 步骤有东西可读、从而执行剪枝:
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 合并——增量更新的真正核心
合并前先备份旧图:cp graphify-out/graph.json graphify-out/.graphify_old.json(供第六阶段的 diff 使用),合并后清理:rm -f graphify-out/.graphify_old.json。
合并脚本(原文档 Step 4 入口):
$(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, # 若给了 --directed 则为 True,否则 False
)
# ... 将合并结果写回 .graphify_extract.json(见下文)...
"
源码视角:build_merge 的四个关键设计
实现位于 graphify/build.py#L1626,签名与语义如下:
- replace-on-re-extract(#1344):
new_chunks中出现的每个source_file,其在基础图中的既有节点/边会被先行丢弃再合并,因此“已修改文件”绝不需要进入 prune 集合。参考文档中的注释特别强调这一点:“prune_sources 只给真正被删除的文件用”——因为当传入root=时,prune 集合会被相对化到与刚合并出的节点相同的基准,若把changed也塞进去,会连新内容一起删掉。此外替换是分层的(#2333/#2336):AST 层与语义层两个 producer 的节点在图中共存,某一层的重抽取只会替换该层的旧贡献,不会误删另一层。 root=不可省略(#1361):detect_incremental给出的删除路径是绝对路径,而图中存储的source_file是相对值。不传root=就发生不了相对化匹配——结果是一次 prune 都匹配不上,陈旧节点在每次更新中不断累积。源码中甚至有_infer_merge_root兜底(#1571),当调用方省略root时尝试从图谱记录的扫描根推断。directed=IS_DIRECTED必须显式传(#1392):若用户以--directed运行,则传True。不传的话,--directed --update会静默地以无向方式重建,把互指的 A↔B 边折叠掉。库实现上,directed=None会继承磁盘图谱自身的标志(#2342),显式 True/False 总是覆盖。- 直接读
graph.json,不做 NetworkX 往返(#801):保证calls、implements、imports等边方向不被序列化/反序列化过程破坏。
合并结果回写:超边与 token 计数的完整继承
脚本把合并后的图写回 graphify-out/.graphify_extract.json,让 Step 4 看到完整图:
merged_out = {
'nodes': [{'id': n, **d} for n, d in G.nodes(data=True)],
'edges': [
# 显式的 source/target 放最后,压过 d 中任何陈旧属性
{**{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)
],
# G.graph["hyperedges"] 同时包含旧 graph.json 与新抽取的超边
# (build_merge 会合并两者)。只回退到 new_extraction 会静默丢掉
# 上一次运行的超边(#801)。
'hyperedges': list(G.graph.get('hyperedges', [])),
'input_tokens': new_extraction.get('input_tokens', 0),
'output_tokens': new_extraction.get('output_tokens', 0),
}
这里有两个容易踩坑的细节都被文档注释钉死了:边数据中若残留 _src/_tgt/source/target 等旧属性,必须用显式字段压过它们;超边只能从 G.graph["hyperedges"] 取,因为 build_merge 已经把新旧两侧合并进去,只看本次抽取会丢失历史超边。
六、第五阶段:manifest 回写与“盖章”机制
合并完成后必须保存 manifest,使下一次 --update 与今天的状态做 diff,而不是上一次的基线(否则会报告幽灵节点)。参考文档给出的完整逻辑:
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)
三个参数各有明确职责,都能在源码中找到对应:
_stamped_manifest_files(graphify/cli.py#L88-L157):只给本轮真正产出的语义文件“盖章”(ast_hash/semantic_hash)。某文档的抽取分块失败了就不会出现在new_extraction中,保持未盖章状态,下次--update自动重新排队——否则它会被标记为“已完成”,内容永久丢失(#2015)。实现上只有nodes与hyperedges算有效语义产出:仅有边而没有实体节点/超边的结果同样不盖章(#933/#1666/#2927)。clear_semantic=_cleared(#1948):本轮被派发但没盖章的文件,其陈旧的semantic_hash必须被清空(置为""),否则 seed 循环会原样继承旧哈希,detect_incremental(kind="semantic")会误报其“无变化”。这对应 graphify/detect.py#L2148-L2154 中clear_semantic参数的文档说明。scan_corpus=_scan(#1908):必须传原始全量语料。这样那些“仍在磁盘上、但被新加入 ignore 规则排除”的文件行会被丢弃,而不是伪装成删除;未触碰的行则原样保留。源码注释明确:保存文件子集的调用方(如 changed-paths hook)应留空以保留未触碰行,而全量扫描方必须传完整 detect 输出。root='INPUT_PATH'(#1417):与上方build_merge一致地传入,使 manifest 键保持相对扫描根(posix 风格、前向斜杠)。这样 manifest 跨克隆/跨机器可移植——项目搬家后--update仍能命中缓存文件,而不是全部 miss。
save_manifest 的实现见 graphify/detect.py#L2112:kind 区分 ast(graphify update 写)/semantic(graphify extract 写)/both,保证两条流水线各盖各的哈希、互不覆盖。
七、第六阶段:展示图 diff 并收尾
合并完成后按常规运行 Step 4–8。Step 4 之后用 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 位于 graphify/analyze.py#L556-L637,对两个快照返回 new_nodes / removed_nodes / new_edges / removed_edges 及一句人类可读的 summary(如 “3 new nodes, 5 new edges, 1 node removed”)。注意其边比较是按 (u, v, relation) 三元组归一化的:无向图会先对端点排序,因此“关系类型不变但端点交换”不会误报为增删;有向图则保留方向。这与本文第五节强调的边方向保真(#801)形成闭环——合并时保方向、diff 时也按方向比较。
收尾动作:删除备份 rm -f graphify-out/.graphify_old.json。
八、--cluster-only:对已有图谱做自包含的重聚类
参考文档的后半部分非常简短但约束性强,这里结合 CLI 实现补充参数细节。
graphify cluster-only .
- 跳过 Step 1–3(检测与抽取),直接对现有
graph.json重新聚类:重聚类、命名社区、并重新生成GRAPH_REPORT.md、graph.json、graph.html; - 不要重跑 Step 5–9:那些步骤读取的中间文件(
.graphify_extract.json、.graphify_detect.json、.graphify_analysis.json)已在上一轮构建的 Step 9 清理中被删除,重跑会抛FileNotFoundError(#1392)。完成后照常呈现刷新后的GRAPH_REPORT.md摘要即可。
从 CLI 源码 graphify/cli.py#L1843-L1917 看,cluster-only 的完整能力还包括一组可选参数(均支持 --flag value 与 --flag=value 两种写法,且与位置参数顺序无关,#724):
| 参数 | 默认值 | 作用 |
|---|---|---|
--graph |
<path>/graphify-out/graph.json |
显式指定要重聚类的图谱文件 |
--resolution |
1.0 |
聚类分辨率 |
--exclude-hubs |
关闭 | 排除 hub 节点参与社区检测 |
--min-community-size= |
3 |
最小社区规模 |
--backend / --backend= |
配置默认 | 社区命名使用的 LLM 后端 |
--model / --model= |
配置默认 | 社区命名使用的模型 |
--max-concurrency / --batch-size |
4 / 100 |
命名调用的并发与批大小 |
--no-viz / --no-label / --missing-only / --timing |
关闭 | 跳过可视化 / 跳过命名 / 只命名缺失社区 / 打印分阶段计时 |
两个边界行为值得记住:
- 找不到图谱文件时直接报错退出,提示先运行
/graphify(即全量抽取):error: no graph found at <path> — run /graphify first; - 与
cluster-only同入口的还有label子命令——它是“总是重新生成社区名”的变体(force_relabel),即使.graphify_labels.json已存在也会用配置的后端重新命名。
九、常见陷阱速查
把参考文档与源码注释中反复出现的坑整理成一张表,便于日常维护时对照:
| 陷阱 | 后果 | 正确做法(对应源码/文档依据) |
|---|---|---|
把 changed 文件加入 prune_sources |
与 root= 相对化基准一致后,会连刚合并的新内容一起删掉 |
prune 只传 deleted_files;修改文件靠 replace-on-re-extract 对账(build.py#L1685-L1749) |
省略 build_merge(root=) |
绝对删除路径匹配不上相对 source_file,一次都不剪枝,陈旧节点持续累积 |
始终传 root='INPUT_PATH'(#1361/#1571) |
--directed 时省略 directed=True |
静默按无向重建,互指边被折叠 | 显式传 directed=IS_DIRECTED(#1392) |
| 视频/音频直接进语义子代理 | 原始 .mp4/.mp3 不可读,抽取失败 |
先走 transcribe.md Step 2.5,再改 .graphify_detect.json 的路由 |
| 失败分块的文档被盖章为完成 | 内容永久丢失,不再重抽 | _stamped_manifest_files 只盖实际产出;clear_semantic 清空未盖章文件的旧哈希(#2015/#1948) |
cluster-only 后重跑 Step 5–9 |
FileNotFoundError(中间文件已被上轮 Step 9 删除) |
cluster-only 自包含,只呈现 GRAPH_REPORT.md(#1392) |
| 超边只从本次抽取取 | 静默丢失上一轮的超边 | 取 G.graph["hyperedges"](#801) |
十、小结
update.md 虽然篇幅不长,但它浓缩了 graphify 增量流水线的全部关键决策:detect_incremental 用 mtime + 内容哈希的两级判定圈出最小变更集;.graphify_detect.json 中 files/all_files 的分工让抽取步骤“只处理变化、却看得到全貌”;code-only 白名单让纯代码变更绕开 LLM;build_merge 以“重抽取即替换、删除才 prune、root 相对化、方向显式化”四条规则保证图谱长期收敛而不漂移;manifest 的盖章/清章机制则确保失败的工作下次必然重试;cluster-only 作为自包含命令把“重新理解社区结构”与“重新抽取”彻底解耦。配合 graphify/skills/kilo/ 下的其余参考文档(query.md、transcribe.md、hooks.md 等),这套增量机制构成了 graphify 面向持续演进的代码库保持知识图谱新鲜且每条边可解释的完整闭环。
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
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00