首页
/ graphify --update 与 --cluster-only:增量更新与重新聚类的完整工作原理

graphify --update 与 --cluster-only:增量更新与重新聚类的完整工作原理

2026-09-06 12:44:52作者:平淮齐Percy

graphify 的知识图谱(graphify-out/graph.json)一旦构建完成,后续维护几乎都不需要从头重建:--update 只重新抽取新增/修改的文件,--cluster-only 则在已有图上重跑社区发现并刷新全部产物。本文以 graphify 技能包中 update.md 这份增量更新参考文档为主线,完整拆解两个子命令的每一步命令、中间文件状态机,并结合 graphify/detect.pygraphify/build.py 的源码说明"只重抽变更文件"背后可靠的变更检测、替换式合并与清单(manifest)机制。

定位:update.md 是 /graphify 技能的增量参考文档

skill-agents.md 定义的 /graphify 流水线中,首次全量构建依次执行 Step 1(确认解释器)到 Step 9(保存 manifest、清理、汇报),而 --update--cluster-only 被明确定义为非默认子命令

/graphify <path> --update        # incremental - re-extract only new/changed files
/graphify <path> --cluster-only  # rerun clustering on existing graph

主技能文档只保留一句话指引——"See references/update.md for both flows",完整的增量运行手册就是本文拆解的 update.md。该文件开头即声明加载时机:只有用户传了 --update--cluster-only 时才读取它,首次全量构建永远不会读它。两个分支的定位分别是:

  • --update(增量重抽取):上次运行之后有文件新增或修改时使用。只重抽变更文件,节省 token 与时间。
  • --cluster-only(仅重聚类):跳过 Step 1–3 的抽取,直接在现有图上重跑聚类、命名社区并重新生成 GRAPH_REPORT.mdgraph.jsongraph.html

两个分支中所有 bash 块都通过 $(cat graphify-out/.graphify_python) 调用 Python 解释器——这是主技能 Step 1 写入的解释器路径文件(技能包对 --updatequery 等子命令都要求先做这个解释器守卫,缺失时重新解析),保证增量流程使用与首次构建相同的 graphify 包环境。文中 INPUT_PATH 占位符替换为用户实际路径,IS_DIRECTED 在给了 --directed 时替换为 True、否则 False

--update 流程第一步:detect_incremental 检测变更

首次运行增量流程时,先调用 detect_incremental 与上次运行的 manifest 对比,产出变更集并落盘为 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.')
"

如果既无新文件也无删除,流程在此安全终止。返回值的关键字段可以结合 detect.py 中 detect_incremental 的实现 理解:

  • new_files / unchanged_files / new_total:按文件类型(code/document/paper/image/video)分桶的新增或修改文件。
  • deleted_filesexcluded_files 的严格区分(#1908):manifest 中消失的行要按"磁盘上是否还存在"分流——文件从磁盘删掉了才是真删除(其缓存节点成了幽灵),文件仍在磁盘但不在本次扫描结果里只是被排除(ignore 规则或 --exclude 变更),绝不能报成删除。这与 watch.py 中 _reconcile_existing_graph 的 fail-closed 驱逐逻辑 是同一套原则。
  • 快速路径与慢速路径mtime 未变且 hash 匹配则直接判定未变更(只花一次 stat 的代价);mtime 变了才走慢速路径,用 MD5 内容与 manifest 里对应的 ast_hash/semantic_hash 对比后才决定是否重抽(见 detect.py 的 docstring)。用 != 而非 > 比较 mtime,是为了让 git checkout 旧提交、tarball 恢复这类 mtime 回退也能触发重抽(#1859)。
  • kind="ast"kind="semantic" 双 hash:manifest 条目同时记录 ast_hashsemantic_hash。AST 层是确定性解析、免费,语义层要消耗 LLM token,所以两者分开记账;semantic_hash 缺失意味着该文件还没做过语义抽取,下次 extract 必须补上。
  • 无 manifest 的首次运行detect_incremental 把全部文件视为 new_files,等价于全量。

第二步:填充 .graphify_detect.json 供后续步骤消费

--update 复用了全量构建的 Step 3A–6,而这些步骤无条件读取 graphify-out/.graphify_detect.json。因此要把增量结果改写成 detect 的格式: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\")
"

第三步: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)
"

这个分支与主技能 Step 3 的设计一致:代码走确定性 AST 抽取(Part A),完全不需要 LLM;语义抽取(Part B,子代理读文档/论文/图片)才花 token。因此:

  • code_only 为 True:打印 [graphify update] Code-only changes detected - skipping semantic extraction (no LLM needed),只对变更文件跑 Step 3A(AST),完全跳过 Step 3B(不派子代理),然后直接进入合并与 Step 4–8。
  • code_only 为 False(任一变更文件是文档/论文/图片/视频):先检查变更集里是否有 new_files['video']。若有,必须先按 transcribe.md(Step 2.5)把这些视频/音频转写成文本,然后重写 .graphify_detect.json,把转写产物路径挪进 files['document'] 并删掉 files['video']——否则原始 .mp4/.mp3 路径会被当作不可读的媒体直接喂给语义子代理(#1392)。之后正常跑完整的 Step 3A–3C 管线。

只有删除时的空抽取:让合并步骤可以裁剪

若本次没有新增文件、只有删除,要人为造一个空抽取,让后续的 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 的三个关键参数

合并步骤是整个 --update 的心脏,完整代码(原文档原样保留,注意其中的注释解释了三个历史坑位):

$(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.')
"

这段代码值得逐点对照源码理解,它把三个曾经真实发生过的缺陷固化成了调用约定:

1. "替换式重抽"而不是"追加式合并"

build_merge 的 docstring 说明其核心语义:new_chunks 中出现的每个 source_file,其既有贡献会在合并前从旧图里整体丢弃(#1344 的 replace-on-re-extract)。否则变更文件里已经删掉的函数节点、改掉的边会永久残留,"增量"会退化成"只增不减"。实现上这一替换是按层级(tier)作用域的(#2333/#2336):同一文件有两类生产者——确定性的 AST 路径与语义/LLM 路径,二者节点在图中并存;一次只重抽 AST 层时,只替换该文件的 AST 层贡献,语义层完好无损。源码在 build_merge 内部new_chunks 的节点按 _is_ast_tier 分成 new_ast_sources / new_sem_sources,只对同层级的旧条目执行丢弃。

这直接推出注释里那句告诫:prune_sources 只能传真删除的文件,绝不能把 changed 文件塞进去——被重抽的文件是"替换",不是"删除",两者冲突时"替换"必须赢。

2. root= 决定路径能否对上

detect_incremental 给出的删除文件是绝对路径,而图里存的 source_file 是相对路径。build_mergeroot= 后会把 prune 集合同新 chunk 的路径都相对化到同一基准(#1361:不传 root 时什么都剪不掉,陈旧节点每次 update 都累积)。build_merge 中 _eff_root 的注释 还记录了 #1571:技能运行手册曾不传 root,导致绝对路径的删除文件永远匹配不上相对键的节点。若调用方省略 root,实现会回退到从 graph.json 记录的扫描根推断,两种形态都能对上。

3. directed 标志不能丢

directed=IS_DIRECTED 必须显式传:--directed --update 若不传,会静默重建为无向图,把双向 A↔B 边坍缩成一条(#1392)。build_merge 的 directed 参数语义 是:None 时继承磁盘上已有图的 directed 标志(#2342),显式 True/False 则永远覆盖它——运行手册选择显式传,保证与用户原始构建的取向一致。

另外两个细节也来自同一处注释:build_merge 直接读 graph.json 而不做 NetworkX 的 node-link 往返,所以 calls/implements/imports 等边方向始终保真(#801);超边(hyperedges)要从 G.graph["hyperedges"] 取(它已合并了旧图与新抽取),只回退到 new_extraction 会静默丢掉前几轮的超边(同为 #801)。

manifest 保存的四个参数

合并后写回 manifest 时调用了 save_manifest,四个参数各解决一个持久化正确性问题,与 save_manifest 的 docstring 一一对应:

  • root=:manifest 键相对化到扫描根,磁盘格式跨克隆、跨机器可移植,--update 迁移后依然能命中缓存文件而不是全部未命中(#1417)。相对键在 load_manifest 读回时再锚回绝对路径,且做 NFC 归一化以兼容 macOS 的 NFD 文件名(#2221)。
  • 只给"真正产出了输出"的语义文件盖戳_stamped_manifest_filescli.py)按本次新鲜抽取过滤,某个变更文档的 chunk 若失败,它保持未盖章,下次 --update 自动重新排队——否则会被标记完成、内容永久丢失(#2015)。代码文件恒盖章,因为 AST 是确定性的。
  • clear_semantic:本次派发了语义抽取但没盖戳的文件(chunk 失败或被 LLM 遗漏),清掉其陈旧的 semantic_hash,防止 detect_incremental 下次把它读成"未变更"(#1948)。
  • scan_corpus:传入原始全量语料而非过滤后的子集,使"上次运行后新被排除的 in-root 文件"被从 manifest 中丢弃,而不是伪装成删除;未触碰文件的既有行保留(#1908)。

合并之后:Step 4–8 与图谱 diff

合并完成后,对合并后的图正常跑 Step 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

# 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']))
"

配套的备份与清理动作在运行手册中各占一行:合并前 cp graphify-out/graph.json graphify-out/.graphify_old.json 保存旧图,流程结束后 rm -f graphify-out/.graphify_old.json 清理。graph_diff 的实现在 analyze.py,返回包含 summarynew_nodesnew_edges 等键的对比结果,让用户能直观看到这次增量到底改变了什么。

--cluster-only:自包含的重新聚类

--cluster-only 分支极简——跳过 Step 1–3,只跑一条命令:

graphify cluster-only .

graphify cluster-only .自包含的:它基于现有 graph.json 重新聚类、命名社区,并重新生成 GRAPH_REPORT.mdgraph.jsongraph.html。运行手册特别警告:不要重跑 Step 5–9。原因很具体——这些步骤读取的中间文件(.graphify_extract.json.graphify_detect.json.graphify_analysis.json)早在上一次构建的 Step 9 清理阶段就被删掉了,重跑会抛 FileNotFoundError(#1392)。命令跑完后,照常向用户展示刷新后的 GRAPH_REPORT.md 摘要即可。

适用场景是:图本身没变,但你想要新的社区划分(例如换了聚类参数后),或对报告的社区命名不满意,只想重生成报告和可视化,而完全避开昂贵的抽取阶段。

与 --watch / 提交钩子的关系:同一套替换式合并

--update 的手动流程并非孤立存在。watch.py 中的自动重建路径(--watch 监视目录、post-commit 钩子)复用同一套语义:_reconcile_existing_graph 里"重抽文件按层级替换 AST 贡献、语义层保留"、"删除与排除严格区分"、"远程/虚拟 source 永不驱逐"等规则,与本文 --update 手册中 prune_sources/replace-on-re-extract 的原则完全同构。可以推断:手动 --update 与自动 watch 只是触发方式不同,底层的图一致性规则是共享的。watch 路径还额外处理并发安全(fcntl.flock.rebuild.lock 顾问锁)与无法拿锁时的 .pending_changes 排队机制,这些属于 add-watch.md 参考文档的主题,此处不展开。

小结:增量更新的状态机一览

--update 的中间文件串起来,就是一条清晰的状态链:

阶段 输入 输出 关键函数
变更检测 graphify-out/manifest.json + 磁盘 .graphify_incremental.json detect_incrementaldetect.py
状态回填 .graphify_incremental.json .graphify_detect.json(files=变更集,all_files=全量)
分支判断 变更集扩展名 code-only 走纯 AST;有视频先转写
抽取 变更文件 .graphify_extract.json(含空抽取的删除场景) Step 3A/3C
合并 graph.json + 新抽取 合并图写回 .graphify_extract.json build_mergebuild.py
记账 本次新鲜抽取 manifest(相对键、选择性盖章、清除失败 hash) save_manifestdetect.py
报告差异 .graphify_old.json 备份 节点/边增删摘要 graph_diffanalyze.py

理解了这条链,就能回答增量场景下的绝大多数问题:为什么变更文件的旧节点不会残留(替换式重抽,按层级作用域),为什么删除的文件会被正确裁剪(prune_sources + root= 相对化),为什么失败的语义抽取下次会自动重试(选择性盖章 + clear_semantic),以及为什么迁移目录后增量仍然生效(manifest 相对键)。--cluster-only 则是这条链之外的旁路:不碰抽取,只重算社区与报告。

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