graphify 增量更新与图谱再聚类:`--update` 精准重提取与 `--cluster-only` 自包含重跑实战指南
导读:本指南围绕 graphify 的增量维护能力展开,讲解当知识图谱已经构建完成后,如何通过 --update(仅对新增/变更文件做增量重提取)与 --cluster-only(在既有图谱上重跑社区聚类并重新生成报告与可视化)两种模式低成本地持续更新图谱,避免每次全量重建造成的 token 与时间浪费。读完本文,你将掌握完整的中间状态文件读写协议、纯代码变更跳过 LLM 语义提取的优化分支、视频/文档变更的正确处理顺序、以 build_merge 为核心的合并与剪枝原理,以及如何展示两次构建间的图差异。
该操作手册是 graphify 面向各 AI 编码助手(Claude Code、Cursor、Codex、Gemini CLI 等)下发的技能参考文档之一,主流程定义在 技能总入口 与平台化版本 skill-windows.md,本文所讲的两条维护路径以 update.md 为蓝图,并在文末补充了仓库中对应的源码证据与测试位置,方便读者对照源码深入。
什么时候需要读取这份参考文档
这份参考文档与其余技能参考一样遵循“按需加载”原则:只有当用户显式传入 --update 或 --cluster-only 时才加载它。首次全量构建、或用户只是对代码库提出自然语言问题时,都不会读取本文件。
--update:自上轮运行后新增或修改过文件时使用,只对发生变化的文件做重提取,从而节省 token 与时间;--cluster-only:跳过全部提取阶段,仅对既有graph.json重新运行社区发现、社区命名,并重新生成GRAPH_REPORT.md、graph.json、graph.html。
两者都隐含一个前提:仓库内已存在一份由此前完整构建产出的图谱输出目录(默认 graphify-out/),其中至少包含可作为变更比对基线的 manifest.json 与既有 graph.json。增量模式的自动判定与 --update/--cluster-only 的分工,在设计文档 增量更新设计 中有更宏观的描述:只要 graphify-out/manifest.json 与 graphify-out/graph.json 同时存在即进入增量模式,首次运行永远是全量。
变更检测的底层机制:manifest 快照
增量重提取的第一性原理,是把“上次构建时每个文件的 mtime 与内容哈希”记录下来,本轮扫描时逐一比对,只有内容真正变化的文件才会进入重提取队列。
核心实现位于 detect.py:
detect_incremental(root, ...)(detect.py)内部先调用一次全量detect()获取当轮扫描语料,再与graphify-out/manifest.json中的记录比对,输出new_files(按文件类型分组的变更清单)、unchanged_files、deleted_files、excluded_files与new_total;- 判定策略是双轨的:mtime 未变且哈希匹配 → 视为未变更(纯
stat,近乎零开销);mtime 变化 → 再以内容 MD5 与对应哈希字段比对后才决定是否重提取; kind参数区分两套哈希:kind="semantic"(graphify extract默认)比对semantic_hash,kind="ast"(graphify update)比对ast_hash。这保证了被 AST-only 更新触碰过的文件,在后续语义提取时不会被误判为“已完成”;- manifest 行记录
mtime、seen、ast_hash、semantic_hash,并且当传入root时以相对路径(posix 风格)落盘,保证 manifest 在克隆/换机器后依然可移植; save_manifest(...)(detect.py)通过原子写入(write_json_atomic)避免中途崩溃产生截断 manifest;同时支持scan_corpus(整轮真实语料)与clear_semantic(本轮派发但未产出结果的语义文件)两个可选参数,用于剔除“被 ignore 规则移出扫描范围”的行,以及清空失败文件的语义哈希以便下轮重试。
一个值得注意的边界处理:对 manifest 中 mtime 落在同一文件系统时间片内的记录,detect_incremental 会额外做一次内容哈希校验,防止“同长度编辑落回同一时间戳刻度内、mtime 不变而内容已变”的漏检——这一点与缓存层对 mtime 粒度的保守假设保持一致。
文件类型分类与纯代码判断
detect_incremental 返回的 new_files 是按文件类型分组的结构:code、document、paper、image、video 等。类型划分由 classify_file 完成,扩展名集合定义在 detect.py:
CODE_EXTENSIONS:.py、.ts、.js、.go、.rs、.java、.cpp/.c/.cc/.cxx及头文件、.rb、.swift、.kt/.kts、.cs、.scala、.php、Fortran 系列(.f/.F/.f90/.F90/.f95/.F95/.f03/.F03/.f08/.F08)、.lua、.toc等;- 文档类(
.md、.txt、.html、.yaml等)属于语义(LLM)提取路径,需要被送到 LLM 子代理; - 图片、PDF、音视频则分别属于
image、paper、video类型。
**纯代码判断(code_only)**是增量流程的第一个关键分支:它决定这次更新是否需要调用任何 LLM。如果所有变更文件的后缀(小写化后)都落在代码扩展名集合内,说明本轮只动了可确定性解析的源码,完全可以走“纯 AST 通道”而把语义提取整体跳过。
--update 增量重提取完整流程
下面按参考文档的编排,逐步展开 --update 分支的每个环节。
第 1 步:调用 detect_incremental 并落盘增量结果
首先调用变更检测,把结果写入 graphify-out/.graphify_incremental.json,并根据 new_total 与 deleted_files 打印概要,决定是否提前退出:
$(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)是技能脚本的运行环境技巧——该文件记录了此前解析出的 Python 解释器路径,保证后续所有内联 Python 片段与构建时使用同一个解释器。INPUT_PATH需要替换为实际扫描根目录(相对当前工作目录)。若既没有新增/变更文件、也没有删除文件,脚本直接以退出码 0 结束,整轮--update无任何重提取发生。
第 2 步:重写 .graphify_detect.json 供后续阶段读取
Steps 3A–6 会无条件读取 graphify-out/.graphify_detect.json,因此必须在增量语义下把它改写为正确的状态。关键约束在于两个字段的分工:
files:只携带变更子集(即增量结果的new_files),驱动 Step 3A 的 AST 提取与 Step 3B0 的缓存检查只针对“真正变了的东西”;all_files:携带全量语料(result['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\")
"
第 3 步:按变更内容走三条不同分支
这是 --update 的核心分叉点:变更文件的组成决定了重提取的深度。
分支 A —— 纯代码变更(code_only 为 True):只对变更文件运行 Step 3A(AST 确定性解析),整体跳过 Step 3B(语义提取,不启动任何子代理、不调用 LLM),随后直接进入合并与 Steps 4–8,并打印提示:
[graphify update] Code-only changes detected - skipping semantic extraction (no LLM needed)
分支 B —— 存在文档/图片/视频等非代码变更:若 code_only 为 False,需要先处理视频类文件:如果 new_files['video'] 中有变更文件,先对它们执行 transcribe.md(即 Step 2.5 转写),随后重写 .graphify_detect.json,把转写产物路径并入 files['document'] 并删除 files['video']。这样做的原因在注释中写得很清楚:否则原始 .mp4/.mp3 路径会被直接喂给语义子代理,而它们无法阅读这类二进制媒体。之后按正常流程完整运行 Steps 3A–3C。
分支 C —— 只有删除、没有新增:此时创建一份空的提取结果,供随后的合并阶段执行剪枝(删除节点需要以“新提取为空 + 删除清单”为输入):
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
第 4 步:用 build_merge 把新结果并入既有图
合并是整个增量更新正确性的命门。参考文档明确要求调用 build_merge() 而非走 NetworkX 往返转换,理由与代码实现一致(build.py):
- 直接读
graph.json合并,不做 NetworkX 往返,因此边方向(calls、implements、imports)永远被保留; - replace-on-re-extract(#1344):
new_chunks中出现的每个source_file,其旧节点/边在合并前先从基线中剔除,因此变更文件不会累积“陈旧节点”——这也是为什么prune_sources只应放入真正被删除的文件,绝不能把changed一并塞进去;否则root=传入时 prune 集合与新建节点被相对化到同一基线,会把刚重提取的内容也删掉; prune_sources需要root=:detect_incremental返回的是绝对路径,而图中节点以相对source_file存储,传入root后两者才对齐,否则每次更新都会“剪了个寂寞”,陈旧节点不断累积;directed=必须显式给定:--directed传 True、否则传 False。不传则--directed --update会把有向图静默重建为无向图,并折叠互为反向的 A↔B 边。
脚本如下:
$(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_sources is ONLY for genuinely DELETED files. Changed/re-extracted files are
# handled by build_merge's replace-on-re-extract (#1344): every source_file in
# new_chunks is dropped from the base before merge, so old/stale nodes don't survive.
# Do NOT add 'changed' here: with root= passed, prune_set relativizes to the same base
# as the freshly merged nodes and would DELETE the re-extracted content (#1178 is moot
# now that replace — not the dedup pass — reconciles changed files).
prune = list(deleted) or None
# Use build_merge() — reads graph.json directly without NetworkX round-trip
# so edge direction (calls, implements, imports) is always preserved (#801).
# Pass root= so prune_sources (absolute paths from detect_incremental) are
# relativized to match the graph's relative source_file values; without it
# nothing is pruned and stale nodes accumulate on every update (#1361).
# directed=IS_DIRECTED: replace IS_DIRECTED with True if --directed was given, else
# False. Without it a --directed --update silently rebuilds undirected and collapses
# reciprocal A<->B edges (#1392).
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')
# Write merged result back to .graphify_extract.json so Step 4 sees the full graph
merged_out = {
'nodes': [{'id': n, **d} for n, d in G.nodes(data=True)],
'edges': [
# Explicit source/target last so they win over any stale attrs in 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'] holds hyperedges from both existing graph.json
# and new_extraction (build_merge combines them). Falling back to
# new_extraction only would silently drop prior-run hyperedges (#801).
'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)')
# Save manifest so next --update diffs against today's state, not the
# prior run's baseline (prevents ghost-node reports on subsequent updates).
# root= matches the build_merge call above so the manifest keys stay relative to
# the scan root — portable across clones/machines, so --update keeps matching
# cached files instead of missing every one after a move (#1417).
#
# Only stamp semantic files (docs/papers/images) that ACTUALLY produced output
# THIS run (new_extraction is this run's fresh extraction, read above before the
# merge overwrote the file): a changed doc whose chunk failed must stay unstamped
# so the next --update re-queues it, otherwise it is marked done and its content
# is lost forever (#2015). Mirrors the library extract path
# (cli._stamped_manifest_files + clear_semantic + scan_corpus).
from graphify.cli import _stamped_manifest_files
_manifest_files = _stamped_manifest_files(incremental['files'], new_extraction, Path('INPUT_PATH'))
# Changed semantic files dispatched this run but NOT stamped had their chunk fail
# or be omitted; clear any stale semantic_hash so they are re-queued (#1948).
_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_corpus = the RAW full corpus so in-root files newly excluded since last run
# are dropped rather than masquerading as deletions; untouched rows preserved (#1908).
_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.')
"
这段脚本集中体现了增量更新中的多个反直觉陷阱,逐一拆解:
- 超边(hyperedge)必须从合并结果里读,而不是从新提取读:
G.graph['hyperedges']中同时包含既有graph.json与新提取的超边(build_merge会合并两者)。若回退到只用new_extraction的超边,此前几轮产生的超边会在每次更新中被静默丢弃。对应的底层实现里,build_merge会把既未重提取也未删除的文件的超边“携带”进合并,且新 chunks 重新发出的同 id 超边以新版为准; - manifest 仅在成功后按“实际产出”盖章:这里复用了 CLI 层工具函数
_stamped_manifest_files(cli.py),它只对确实产生了节点或超边输出的语义文件盖章;一个 chunk 提取失败、仅产出孤立边、或部分截断的文档会保持未盖章状态,下次--update会重新排队,否则该内容就永远丢失了; - 清空“被派发但未盖章”文件的旧哈希:
_cleared = _dispatched - _stamped通过clear_semantic强制把这类文件的semantic_hash置空,避免继承陈旧哈希后被判定为“未变更”; scan_corpus传完整语料:使“本轮被 ignore 规则/--exclude排除但仍在磁盘上”的文件被正确地从 manifest 中清除行,而不是在下轮被误报为删除;未触碰的行则被原样保留。
合并完成后,后续的 Steps 4–8(评分、god-nodes、意外连接、聚类、写回等)就在这份“完整图”上照常执行。
第 5 步:向用户展示图差异
Step 4 之后,参考文档要求在合并前后各取一次图快照做 diff 展示。注意在此之前需要先备份旧图(见下一步),然后用 graph_diff(实现见 analyze.py,返回 new_nodes、removed_nodes、new_edges、removed_edges 与一段人类可读的 summary)对比新旧图:
$(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
# Load old graph (before update) from backup written before merge
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']))
"
第 6 步:备份与清理旧图
在合并步骤执行之前,先备份既有图:
cp graphify-out/graph.json graphify-out/.graphify_old.json
展示完图差异后清理备份:
rm -f graphify-out/.graphify_old.json
--cluster-only:纯再聚类模式
与 --update 不同,--cluster-only 的目的是不重提取任何文件,只针对既有 graph.json 重跑社区发现并刷新全部产出物。参考文档给出的执行方式极为简单:
graphify cluster-only .
关键约束:graphify cluster-only . 是完全自包含(self-contained)的——它会基于既有图谱自动完成重新聚类、社区命名,并重新生成 GRAPH_REPORT.md、graph.json 与 graph.html 三个产出物。因此在它执行完毕后:
- 不要再重跑 Steps 5–9。这些步骤读取的是中间文件(
.graphify_extract.json、.graphify_detect.json、.graphify_analysis.json),而此前一次完整构建的收尾(Step 9 清理)已经把它们删除,重跑只会触发FileNotFoundError; - 正确做法是像往常一样,把刷新后的
GRAPH_REPORT.md摘要呈现给用户。
在 CLI 层,cluster-only 与 label 子命令同源(label 是总会重新生成社区名的 cluster-only),其实现还会把社区标签写入磁盘以供后续 cluster-only 识别,从源码结构看(cli.py、cluster.py),该命令被设计为可对既有图反复调用。
适用前提与边界条件
基于本仓库当前实现,以下限制需要在使用时留意:
--update的正确性依赖graphify-out/manifest.json与graph.json同时存在;若用户删除了graphify-out/,需要先重新解析解释器并做一次全量构建,而非直接跑增量;--update中的 code_only 分支只覆盖纯代码变更;文档/图片/PDF/音视频变更必然要走语义(LLM)提取,若只想获得 AST 级别的快速更新,文档变更并不适用;- 视频类文件必须先经 transcribe.md 转写为文本后再进入语义提取,否则裸媒体路径会被语义子代理视为不可读内容;
- 合并阶段的有向性必须显式声明,且删除剪枝只应针对“真正被删除”的文件,二者是防止陈旧节点累积、防止误删重提取内容的两个支柱。
源码与测试对照
本文描述的两条流程均可与仓库源码、测试互证:
- 变更检测:
detect_incremental与save_manifest见 detect.py;类型分类与扩展名集合见 detect.py 与 detect.py; - 合并与剪枝:
build_merge的 replace-on-re-extract、prune 语义、超边携带与有向性继承逻辑见 build.py;build_from_json见 build.py; - 图差异:
graph_diff见 analyze.py; - 盖章与清理:
_stamped_manifest_files见 cli.py; - 测试覆盖:仓库中 tests/test_incremental.py 与 tests/test_incremental_mtime_collision.py 针对增量重提取与 mtime 同刻度撞车场景做了专门验证,tests/test_stale_prune.py 聚焦陈旧节点的剪枝行为,可结合这些用例验证你在接入
--update/--cluster-only时遇到的具体行为边界。
若需要理解增量更新的宏观定位(自动进入增量模式的条件、与语义缓存的配合、输出摘要格式等),可进一步阅读 增量更新设计文档。
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 StartedRust0626
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