首页
/ graphify 增量更新与重聚类全指南:掌握 `--update` 与 `--cluster-only` 的底层原理与实战

graphify 增量更新与重聚类全指南:掌握 `--update` 与 `--cluster-only` 的底层原理与实战

2026-09-06 19:16:51作者:牧宁李

导读

graphify 把整个代码库(含文档、SQL Schema、配置与 PDF)构造成可查询的知识图谱。面对大型代码库,每次全量重建既烧 token 又耗时,为此 graphify 提供两种非默认子命令:--update(增量重提取,只处理自上次构建以来新增或修改的文件)与 --cluster-only(跳过提取、直接在既有图谱上重跑聚类)。本文将基于 graphify 技能参考文档 update.md(仓库根目录等价副本见 tools/skillgen/expected/graphify__skills__agents__references__update.md),逐段拆解其完整操作流程,并结合 detect.pybuild.pycli.py 等源码揭示每个步骤背后"为什么这么做"。读完你将掌握:如何安全地增量刷新图谱、如何区分纯代码变更与语义文件变更、如何正确合并并裁剪删除文件、以及如何在不重建提取的前提下重新聚类。

说明:该参考文档是写给 Claude Code / Cursor / Codex 等 Agent 的运行手册(runbook),仅当用户显式传入 --update--cluster-only 时才需要加载;首次全量构建永远不会读取本文件。

一、何时使用 --update--cluster-only

graphify 的默认入口(/graphify <path>graphify extract .)是首次全量构建:发现文件 → AST 解析 → 语义提取 → 合并 → 聚类。其技能文档(如 skill-agents.md)将完整构建流程拆成多个带编号的步骤。两种非默认子命令都跳过或缩短这个流程:

子命令 触发条件 行为 典型场景
--update 用户在上次构建后新增/修改/删除了文件 仅增量重提取变更文件并合并进旧图 代码提交后刷新图谱,省 token 与时间
--cluster-only 图谱已存在、文件未变 跳过提取(Steps 1–3),仅在既有 graph.json 上重新聚类并再生成产物 调整了聚类参数或社区命名后想重出报告

从 CLI 侧看,graphify cluster-only . 是自包含命令,cli.py 中把 cluster-onlylabel 归为同一执行族(两者都会在既有图上重聚类,区别在于 label 总是重新生成社区命名)。而 --update 在 Agent 侧通过本参考文档的分步脚本来驱动,是本文重点。

二、--update 增量重提取:端到端流程

2.1 Step 0:用 detect_incremental() 探测变更集

增量更新的第一步永远是"算出哪些文件变了"。参考文档给出的探针脚本如下(可直接在项目内运行):

$(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 环境(含依赖)。若该文件缺失(如用户误删 graphify-out/),需先重新解析解释器再执行子命令。
  • detect_incremental(root) 定义于 detect.py:它先调用全量 detect() 扫描,再与持久化的 manifest.json(记录每个文件的 mtime 与内容哈希)比对。默认 kind="semantic" 用于 extract;graphify update(AST-only)应使用 kind="ast"——只有当 ast_hash 缺失或内容变化时才判为 changed。
  • 快速路径:mtime 未变且哈希匹配 → 直接判定 unchanged,零磁盘 IO;慢速路径:mtime 变化后先用 MD5 与存储哈希比对再决定是否重提取。
  • 返回值结构包含 new_files(按文件类型分桶的新增/变更文件,类型键如 code/document/paper/image/video)、new_totaldeleted_filesunchanged_filesexcluded_files
  • deleted_filesexcluded_files 的重要区分:仅在磁盘上消失的行才是真正的删除(其缓存节点是"幽灵节点");文件仍存在但被新 ignore 规则排除的行归入 excluded_files,绝不能当作删除上报(detect.py 源码依据 manifest 行与当前语料差集、再经磁盘存在性探测来区分)。
  • 没有任何变更时直接 raise SystemExit(0) 提前退出,连合并步骤都跳过。

2.2 补给 .graphify_detect.json:让下游步骤看到正确的增量状态

后续 Steps 3A–6 会无条件读取 .graphify_detect.json。增量模式下必须手工重写它,使其 files 携带"变更子集"、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\")
"

字段语义(从源码与参考文档归纳):

  • files = 变更子集,驱动 Step 3A(AST 解析)与 Step 3B0(缓存检查)只处理变更的文件,省去对未变更文件的重复解析;
  • all_files = 全量语料,供需要语料级上下文的步骤使用;
  • total_files / total_words 等让下游保留与全量构建一致的统计口径。

2.3 分流决策:纯代码变更 vs 含语义文件变更

在真正派发子代理前,先判断变更是否全部是代码文件,这决定了能否跳过最昂贵的语义提取(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_only 的结果三分支:

分支 A——code_only 为 True(纯代码变更):打印 [graphify update] Code-only changes detected - skipping semantic extraction (no LLM needed),只对变更文件运行 Step 3A(AST),完全跳过 Step 3B(语义子代理,0 次 LLM 调用),随后直接进入合并与 Steps 4–8。这是最省 token 的路径。注意这里使用的代码扩展名集合是参考文档自带的精简判定集,实际 graphify 的 CODE_EXTENSIONS 全集要大得多(见 detect.py),Agent 侧判定采用保守子集即可。

分支 B——code_only 为 False(变更里含 doc/paper/image/video):若 new_files['video'] 非空,必须对视频文件运行 transcribe.md(Step 2.5 转写),然后重写 .graphify_detect.json:把生成的转写文本路径并入 files['document']、同时移除 files['video']。否则原始 .mp4/.mp3 路径会被当作不可读媒体直接喂给语义子代理(#1392)。之后按正常全量 Steps 3A–3C 流水线执行(即 AST + 语义提取 + 必要缓存校验)。

分支 C——没有新增文件、只有删除(纯删除场景):此时没有新的 extraction 可合并,但合并步骤需要空壳输入才能触发裁剪逻辑:

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

2.4 合并:build_merge()、replace-on-re-extract 与删除裁剪

合并是整个增量更新最微妙的一步。参考文档给出的核心脚本如下:

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

结合 build.py 的源码,这段脚本蕴含三个极易踩坑的机制:

机制一:replace-on-re-extract 取代"先删后加"的朴素思路。 build_merge() 在加载既有 graph.json 后,会把"新 extraction 中出现过的 source_file"在旧图里的全部节点/边先行移除,再合并新块。源码注释明确:文件被重新提取就代表替换(per-tier 语义,AST 层与语义层各自独立替换),绝不能把 changed 文件同时塞进 prune_sources。因为当传入 root= 时,prune 集合会按同一基准做相对化,若把"变更文件"误当删除文件裁剪,会直接删掉刚提取好的新内容(源码 #1796/#2012 正是针对这个数据丢失问题做的防御)。所以参考文档的注释强调:prune = list(deleted) or None——只有磁盘上真正消失的文件才进 prune 集合。

机制二:不传 root= 会导致删除裁剪完全失效。 detect_incremental 返回的删除路径是绝对路径,而 graph.json 里节点的 source_file 是相对扫描根的相对路径(首建于 #932 之后即是相对键)。不传 root=,绝对删除路径永远无法匹配相对节点键,于是被删文件的旧节点残留在图上,每次更新都累积"幽灵节点"(#1361)。build_merge 内部还会以图上记录的扫描根为兜底推断合并根(源码 #1571),但 Agent 手册要求显式传 root='INPUT_PATH' 以获得确定性。

机制三:directed=IS_DIRECTED 保护图的有向性。 --directed 构建的有向图若在合并时不显式传入 directed=True,会静默按无向图重建,导致 A→B 与 B→A 的互反边坍缩(#1392)。源码里 directed=None 时以磁盘图的 directed 标志为默认值(#2342),显式布尔值始终覆盖磁盘标志;Agent 手册因此要求把用户是否传过 --directed 如实翻译成该参数。

合并后把结果写回 .graphify_extract.json,使 Step 4(后续分析/查询步骤)看到的是全量图而不是"仅本次新增"的局部图。回写时 edges 的构造特意把 source/target 放在最后,确保它们覆盖任何残留在属性里的过期 _src/_tgt

hyperedges 必须取自 G.graph['hyperedges'] build_merge 会把既有图与新块两边的超边合并进图的元数据(#801)。若只回写 new_extraction 里的超边,会导致前几轮构建产生的超边被静默丢弃。

2.5 Manifest 落盘的三重保险

脚本最后调用 save_manifest(定义见 detect.py),其三个参数的语义在源码 docstring 中解释得非常清楚:

  • root=:让 manifest 的键相对扫描根落盘,实现跨机器/跨克隆位置的移植性(#1417)——clone 到新目录后 --update 仍能命中缓存,而不是因绝对路径漂移把每个文件都误判为新文件。
  • 仅盖章真正产出结果的语义文件:借助 cli.py_stamped_manifest_files(),只把本次运行确实生成了 nodes/hyperedges 的 doc/paper/image 标记为已提取。一个 chunk 提取失败的文档必须保持未盖章,否则下次 --update 会认为它已完成而永不重试,其内容将永久丢失(#2015、#933)。
  • clear_semantic=_cleared:本次派发过但未盖章的语义文件(即 _dispatched - _stamped 差集)需要清空其旧 semantic_hash,否则种子循环会把上一轮的哈希原样继承,掩盖本次失败并让检测误报"未变更"(#1948)。
  • scan_corpus=_scan:传原始全量语料(而非盖章过滤后的子集),使"仍在磁盘上但本次已不在扫描范围"(被新 ignore 规则排除)的文件行被正确丢弃而不是永远伪装成删除(#1908);未被提及的旧行则完整保留。

参考文档中 save_manifest 的调用还刻意把 kind 留给默认——但要注意源码签名区分 kind="ast"graphify update 用,只盖 ast_hash、在内容不变时保留语义哈希)与 kind="semantic"graphify extract 用),理解这点能帮你判断一次更新后哪些文件会在下一次语义提取时被重新排队。

2.6 合并前备份旧图,合并后展示图差异

为了向用户汇报"这次更新改变了什么",参考文档要求:在合并步骤之前先把旧图备份,之后用 graph_diff() 计算新旧快照差异:

cp graphify-out/graph.json graphify-out/.graphify_old.json

合并完成、进入 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']))
"

graph_diff 的实现细节值得注意:它对有向图用 (u, v, relation) 三元组作边键、对无向图用 (min(u,v), max(u,v), relation),因此差异统计天然与 directed= 保持一致;返回结构含 new_nodes / removed_nodes / new_edges / removed_edges / summary,其中 summary 形如 "3 new nodes, 5 new edges, 1 node removed",直接可读、可直接向用户播报。

收尾清理: 汇报完差异后删除备份,避免残留文件干扰下次运行:

rm -f graphify-out/.graphify_old.json

(文中备份/清理命令属于 Agent 运行手册中的标准清理步骤,仓库内 docs/superpowers/specs/ 下另有增量更新去重设计文档可供深入参考。)

三、--cluster-only:在既有图谱上重跑聚类

当图谱文件没变、只是需要重新聚类(例如改了聚类参数或想刷新社区命名)时,跳过 Steps 1–3 的整条提取链,直接执行:

graphify cluster-only .

参考文档特别强调该命令自包含:它会基于既有 graph.json 重新聚类、命名社区,并一次性再生成 GRAPH_REPORT.mdgraph.jsongraph.html。从源码侧印证:聚类相关的社区标签与成员签名会被持久化(见 cluster.py 中"持久化到 .graphify_labels.json 旁、供后续 cluster-only 判断社区是否漂移"的注释),而 cli.pycluster-only/label 分支会在既有图上重聚类;对于纯 cluster-only 模式,代码内容未变、文件统计不可用的场景也有专门处理。

最关键的纪律是:cluster-only 之后绝不重跑 Steps 5–9。 那些步骤依赖的中间文件(.graphify_extract.json.graphify_detect.json.graphify_analysis.json)在上一次完整构建的收尾(Step 9 清理)时已被删除,硬跑会直接 FileNotFoundError(#1392)。正确收尾方式只有一个:命令结束后,把刷新后的 GRAPH_REPORT.md 摘要呈现给用户即可。

四、常见陷阱速查表

陷阱 后果 正确做法
把 changed 文件混入 prune_sources 刚重提取的新内容被误删(#1796/#2012) prune 集合只放 deleted_files
合并时不传 root='INPUT_PATH' 删除裁剪失效,幽灵节点累积(#1361) 显式传 root,且与首次构建扫描根一致
--directed 更新时不传 directed=True 有向图静默降级为无向图、互反边坍缩(#1392) 按是否传过 --directed 如实传参
语义文档提取失败仍盖章 manifest 下次更新不再重试该文件,内容永久丢失(#2015) _stamped_manifest_files() 只盖真正产出者
视频文件不先转写就进语义子代理 原始媒体路径不可读(#1392) 先跑 transcribe(Step 2.5)并改写 detect 状态
hyperedges 只回写新 extraction 前几轮超边被静默丢弃(#801) G.graph.get('hyperedges')
cluster-only 后再跑 Steps 5–9 中间文件已删,FileNotFoundError(#1392) 直接呈现新 GRAPH_REPORT.md
增量后不刷新 manifest 下次以旧基线 diff,产生"幽灵节点"误报 每次合并后调 save_manifest 落盘

五、源码地图:进一步阅读

本文所述机制均可追溯到仓库源码,建议按需深入:

  • 变更探测与差异状态:detect.py —— detect_incrementalL2366)、save_manifestL2112);文件类型全集与敏感文件排除逻辑也在本文件前部。
  • 合并、替换与裁剪语义:build.py —— build_mergeL1626)的 replace-on-re-extract 与 prune 机制、build_from_jsonL798)。
  • 差异报告:analyze.py —— graph_diffL556)。
  • 盖章逻辑与 cluster 执行族:cli.py —— _stamped_manifest_filesL88);cluster.py 承载聚类与标签持久化。
  • 技能主文档与相邻参考:skill-agents.md 定义了全量流程的步骤编号、transcribe.md 是视频转写(Step 2.5)的配套手册,两者与本参考文档互为上下文。

掌握增量更新与重聚类这两条路径,意味着你的 graphify 图谱可以在代码持续演进的同时保持新鲜,而无需为每次小改动支付全量重建的 token 成本——这正是大型代码库上保持"图谱可用、查询可信"的关键操作素养。

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