graphify 增量更新与仅聚类全流程:深入解析 --update 与 --cluster-only 模式
graphify 把一个代码库连同文档、SQL Schema、配置文件与 PDF 等资产转化为可查询的知识图谱(写入 graph.json 并产出 GRAPH_REPORT.md、graph.html)。但全量重建昂贵:一旦语料开始增长,每次小改动都重新跑完整流水线既浪费 token 也浪费时间。本文围绕 Kiro 技能参考文档 update.md 展开,系统讲解两种省成本的增量路径:--update(只对新增/修改过的文件做增量重提取,并对删除文件剪枝)与 --cluster-only(跳过提取、仅对既有图谱重新做社区聚类与命名)。读完你不仅能按 runbook 复现两种模式,还能理解其底层依据:detect_incremental 的变更检测、build_merge 的"重提取即替换"合并语义、graph_diff 的图谱差异报告,以及 manifest 精确盖章避免幽灵节点与坏块永久丢数据的机制。
适用说明:该文档是 Kiro 平台 skill 的按需加载参考,仅在用户显式传入
--update或--cluster-only时读取;首次全量构建永远不读该文件(对应 update.md 的说明)。因此文中所有流程默认你的graphify-out/下已存在一次全量构建产出的graph.json与manifest.json。
一、--update 增量重提取:只处理自上次运行以来变化的文件
适用场景:自上次运行之后你在语料里新增或修改了文件。--update 只重提取变化的部分,显著节省 token 与时间。整个流程由五个阶段组成,下面逐一展开。
1. 运行 detect_incremental 捕获本次变更集
第一步用 graphify.detect.detect_incremental(实现于 detect.py)扫描扫描根目录,把结果写入 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.')
"
其中 INPUT_PATH 需替换为你的语料根目录。从源码看,detect_incremental 的返回值结构完全决定了后续所有阶段的数据来源,关键字段如下:
| 字段 | 含义 |
|---|---|
files |
完整语料:按文件类型分类的全量文件映射(如 code / document / paper / image / video) |
new_files |
本次新增/修改的子集(按同样的类型键分类) |
new_total |
new_files 中文件总数;与 deleted_files 同时为空说明无任何变化 |
total_words / total_files |
全量语料统计(检测层成本/健康检查信息) |
deleted_files |
磁盘上已不存在的 manifest 旧条目(真正的删除) |
excluded_files |
仍存活但已被排除规则移出扫描范围的文件(非删除) |
skipped_sensitive |
因命中文档检测层敏感目录规则而跳过的路径 |
需要指出的是,detect_incremental 并非简单对拍文件名,它在 detect.py 中实现了两段式判定:快速路径——mtime 未变且对应 hash 匹配即视为未变(零额外 IO);慢速路径——mtime 跳变后先做 MD5 内容比对,避免仅因时间戳写回就重提取。同时,同一行代码中还针对 "mtime 落在同一时间片、内容被同长度改写却未推动 mtime" 的竞态(见 _mtime_may_hide_a_rewrite)补充了一次哈希校验。它还按 kind 区分语义增量与 AST 增量:本 runbook 走默认语义路径(semantic_hash),而 CLI 的 graphify update 走 kind="ast"(ast_hash)。若返回的是空 dict(历史上无任何 manifest),全语料被当作"全部新增",deleted_files 为空。
2. 把增量结果映射进 .graphify_detect.json
后续步骤 3A–6 会无条件读取 graphify-out/.graphify_detect.json,所以增量运行必须重写该文件让下游看到正确状态。关键映射:files 携带变更子集(驱动第 3A 步 AST 与第 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\")
"
注意该映射刻意把 needs_graph 置为 True,且把 total_files 覆盖为增量总数——下游若以"文件总数"判断语料规模,此时读到的是"待重提取"规模,避免误判为全量。
3. 判定纯代码变更:决定能否跳过 LLM 语义提取
若存在新文件,先判断变化文件是否全部是代码文件。这决定整个流水线是否还需要语义提取(LLM):
$(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_exts 是判定用的常用代码后缀集合;检测层完整集合见 detect.py 的 CODE_EXTENSIONS(覆盖面更宽,含 .tsx、.csproj、.razor、.pas 等)。随后分支处理:
code_only为 True:打印[graphify update] Code-only changes detected - skipping semantic extraction (no LLM needed),只在变化文件上运行第 3A 步(AST),完全跳过第 3B 步语义提取(不开子代理),直接进入 merge 与步骤 4–8。这正对应 cli.py 中graphify update的提示 "Re-extracting code files ... (no LLM needed)"——确定性 AST 路径不依赖任何模型。code_only为 False(有 doc/paper/image/video 变化):- 若变更文件出现在
new_files['video'],先按 transcribe.md(步骤 2.5)对它们执行转写(引擎见 transcribe.py 的transcribe_all,产物为文本转录),然后重写.graphify_detect.json,把转录出的文本路径移入files['document']并移除files['video']']——否则原始.mp4/.mp3路径会被当不可读媒体直接喂给语义子代理(issue #1392); - 随后按正常流程完整运行步骤 3A–3C 流水线。
- 若变更文件出现在
4. 无新文件、仅有删除时构造空提取
如果没有新文件(纯删除场景),必须创建一个空提取,让后续 merge 步骤有机会按 deleted_files 执行剪枝:
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
剪枝本身不靠"新提取中缺失的文件"推断,而由 merge 时显式传入的 prune_sources(来自 deleted_files)驱动——空提取保证了 merge 阶段有东西可写,同时不引入任何新节点。
5. 用 build_merge 完成增量合并(含删除剪枝)
这是增量流程的核心。build_merge 直接读取磁盘上的 graph.json 与新的提取结果合并,一步完成"替换重提取内容 + 剪除真删除文件",最后把合并结果写回 .graphify_extract.json 以便步骤 4 看到全量图,并落盘更新后的 manifest:
$(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.')
"
这段 runbook 浓缩了几个极易出错、因而必须明确的合并语义(均可与 build.py 的 build_merge 源码相互印证):
prune_sources只填真正删除的文件。build_merge采用 replace-on-re-extract(issue #1344):只要某个source_file出现在新的new_chunks里,它在既有图中的旧节点/边会先被整体丢弃再加入新版本,从而"按文件替换、同文件增量不叠加"。把 changed 文件塞进prune_sources是反模式:当传入root=时,prune 集合会以与"新合并节点"相同基准做相对化,等于把刚重提取的内容当删除再次清掉(issue #1178 已被 replace 语义取代,dedup 不再承担变更文件的调和)。- 方向性必须显式传递:
build_merge直接读graph.json(不经 NetworkX 往返序列化),因此边方向(calls/implements/imports)始终保留(issue #801);directed=IS_DIRECTED中的IS_DIRECTED是占位符——用户传了--directed就写True否则False。不传方向,一次--directed --update会静默地以无向方式重建并把双向 A↔B 边折叠成一条(issue #1392)。源码层面,build.py 的directed参数在None时会继承磁盘图自身的directed标记作为兜底,防止增量 merge 无意翻转图方向。 - 超边必须从
G.graph['hyperedges']取:build_merge会把既有graph.json与新提取的超边合并放进图对象(carry-forward,见 build.py:既未重提取也未删除的文件的超边会被保留,避免每次--update把历史超边集坍缩成"仅本次变更文件"的子集),若退回到只读new_extraction会静默丢掉历次运行产生的超边(issue #801)。 root='INPUT_PATH'必须与detect_incremental的扫描根一致:prune_sources里是绝对路径,必须相对化为与图内source_file(相对路径)一致的形态,否则一次都不会匹配上,删除节点将作为幽灵累积在每个 update 中(issue #1361)。对 manifest 同理:save_manifest(..., root=...)把键相对化存储,跨克隆/换机器可移植(issue #1417)。- Manifest 盖章只针对真正产出的语义文件:通过
cli._stamped_manifest_files(见 cli.py)筛选——只有本轮确实在nodes/hyperedges中产生source_file条目的文档/论文/图片才盖章(仅产出edges不算有效语义输出,issue #2927);chunk 失败或缺漏的文件必须保持"未盖章",否则下一次--update会把它当作已完成而永远丢失其内容(issue #2015)。_cleared = _dispatched - _stamped传给clear_semantic,把"本轮派发但未盖章"的文件的过期semantic_hash清空,强制下轮重派(issue #1948)。scan_corpus传原始全量语料(未按盖章过滤),使"自上次以来被新增排除规则移出扫描范围的文件"被丢弃而不是伪装成删除(issue #1908),未触及的行则原样保留。
6. 展示增量图谱差异
合并完成、步骤 4 执行后,用 graphify.analyze.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
# 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']))
"
配套的备份与清理约定(在 merge 步骤之前执行备份,展示差异后清理):
cp graphify-out/graph.json graphify-out/.graphify_old.json
rm -f graphify-out/.graphify_old.json
从源码看,analyze.py 的 graph_diff 返回 new_nodes、removed_nodes(各含 id/label)、new_edges、removed_edges(各含 source/target/relation/confidence),以及人读摘要 summary(形如 "3 new nodes, 5 new edges, 1 node removed")。值得注意它对无向图做了端序归一化(min(u,v), max(u,v)),因此同一条无向边不会因端点书写顺序不同被误判为新增。更新完成后继续按常规执行步骤 4–8(聚类、社区命名、报告导出等),最终以新的 GRAPH_REPORT.md 摘要收尾。
二、--cluster-only:不重提取,仅对既有图重聚类
当你的目标不是吸收新代码变更,而只是希望刷新图的社区划分、社区命名与可视化产物时,使用 --cluster-only:
- 跳过步骤 1–3(不做任何 detect / AST / 语义提取);
- 直接对现有图重跑聚类:
graphify cluster-only .
该命令完全自包含:它会重新聚类、为社区命名,并基于既有 graph.json 重新生成 GRAPH_REPORT.md、graph.json、graph.html。对应 CLI 入口见 cli.py 附近的 cluster-only/label 分支(label 等价于"总是重新生成社区名的 cluster-only"),聚类与命名算法主体在 cluster.py;命令级交互还支持 graphify cluster-only --help 查看更多选项。
必须遵守的两条纪律(原文档明确强调):
- 不要重跑步骤 5–9。这些步骤读取中间文件(
.graphify_extract.json、.graphify_detect.json、.graphify_analysis.json),而上一次构建的清理(步骤 9)已经删除它们——重跑会直接抛FileNotFoundError(issue #1392)。 - 命令结束后,按惯例呈现刷新后的
GRAPH_REPORT.md摘要即可,无需也不应再执行任何全量重建步骤。
如果你在 --cluster-only 之后确实需要再吸收代码变更,正确路径是先执行第一节的 --update 流程、再回到聚类产物层,而不是对已清理的中间文件反向重建。
三、两种模式的选择速查
| 你的诉求 | 使用模式 | 主要成本 | 跳过内容 |
|---|---|---|---|
| 上次运行后新增/修改了代码文件 | --update(配合本 runbook) |
AST 确定性重提取 | 语义提取 / LLM / 子代理(code_only 分支) |
| 上次运行后新增/修改了 doc/paper/image/video | --update(完整 3A–3C) |
AST + 语义提取(含必要的视频转写) | 未变化的全部文件 |
| 上次运行后删除了文件 | --update(合并阶段剪枝) |
merge + 剪枝 | 除合并外几乎全部提取 |
| 只想重新聚类/命名社区并刷新产物 | cluster-only . |
仅聚类与命名(无提取) | 步骤 1–3 全部 detect/提取 |
| 首次为语料建图 | 全量构建 | 完整流水线 | ——(本文档不适用) |
四、高频踩坑点对照(runbook 注释即防线)
以下每一条都源于原 runbook 注释并以源码佐证,属于增量路径上真实出现过的回归:
- changed 文件混入
prune_sources→ 配合root=会把刚重提取的内容清掉(build.py:同一文件"replace"优先于"delete",且对同时列在new_chunks与prune_sources中的源文件绝不剪枝)。正确做法是让 replace-on-re-extract 处理变更,prune 只管真删除。 - 漏传
directed=IS_DIRECTED→ 有向图增量更新退化为无向重建,双向边被折叠。 - 漏传或错传
root=→ 绝对路径的删除清单匹配不上图内相对source_file,剪枝零生效、幽灵节点累积(issue #1361 / #1571)。 - 超边从
new_extraction而非合并图对象读取 → 历史超边被静默丢弃(issue #801)。 - 对失败 chunk 的文件照常盖章 → 该文件被永久标记为已完成,其内容再也进不了图(issue #2015);正确姿势是用
_stamped_manifest_files+clear_semantic只盖章真实产出的文件。 - raw
.mp4/.mp3直接喂语义子代理 → 不可读媒体导致任务失败(issue #1392);必须先走 transcribe.md 的转写并把转录文本移入document类型。 --cluster-only后重跑步骤 5–9 → 中间文件已在上次构建的清理中被删,直接FileNotFoundError。- 保存 manifest 不传
scan_corpus全量语料 → 自上次以来被新增排除规则移出范围的文件会伪装成删除,且未触碰的 manifest 行可能被误擦除(issue #1908);runbook 中_scan取的是incremental['files'](原始全量),而非盖章过滤后的子集。
五、深入代码库的索引
增量更新全链路的实现与测试均在本仓库内,可继续追踪:
- 变更检测与 manifest:detect_incremental、save_manifest,含
kind(ast/semantic/both)、root相对化、scan_corpus/clear_semantic/clear_ast各参数语义;完整文件类型后缀定义在 detect.py。 - 增量合并:
build_merge(replace-on-re-extract、prune_sources相对化、超边 carry-forward、directed继承)见 build.py。 - 盖章过滤:
_stamped_manifest_files见 cli.py。 - CLI 侧对照:
graphify update分支(纯 AST 快速重建,见 cli.py)与cluster-only/label分支(见 cli.py)。 - 图谱差异:
graph_diff见 analyze.py。 - 配套参考:视频/音频转写走 transcribe.md,其执行入口为 transcribe.py;完整提取流水线的各阶段拆分、目录说明与产物定义可对照 extraction-spec.md 及技能主文件 skill-kiro.md。
掌握以上两条增量路径后,无论代码仓库还是文档语料,长期演进都无需反复全量重建:--update 让每次迭代只花"变化的部分"的钱,--cluster-only 让社区视图可以随时廉价刷新,而 manifest、合并替换语义与差异报告的相互配合保证了图谱始终与磁盘内容保持一致。
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 StartedRust0627
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