Graphify 增量更新(`--update`)与仅重聚类(`--cluster-only`):知识图谱二次构建实战指南 Graphify 增量更新--update与仅重聚类--cluster-only知识图谱二次构建实战指南【免费下载链接】graphifyTurn any codebase, with its docs, SQL schemas, configs, and PDFs, into a queryable knowledge graph. A /graphify skill for Claude Code, Cursor, Codex, and Gemini CLI: local deterministic AST parsing, every edge explained, no vector store.项目地址: https://gitcode.com/GitHub_Trending/graph/graphify在 Graphify 项目将任意代码库与文档、SQL 模式、配置、PDF 转化为可查询知识图谱的工具中一次完整构建的“物化”成本可能高达数分钟甚至更多 token。本文基于平台 Skill 的 reference 文档 update.md 展开聚焦两条“不重建全量”的二次构建路径--update增量重提取只对自上次运行以来新增/修改的文件重新提取省 token、省时间与--cluster-only仅重聚类不动抽取只基于已有图重跑社区划分与命名、并重新生成报告产物。读完本文你将掌握增量状态如何通过detect_incremental快照比对得到、何时可以跳过 LLM 语义提取纯代码变更 fast path、合并为何必须用build_merge而不是 NetworkX 往返、哪些经典陷阱剪枝相对化、--directed丢失、失败文档被误盖章会导致陈旧节点或内容永久丢失以及如何安全地展示图 diff。该 reference 文档本身是一份“运行手册runbook”只应在用户传递了--update或--cluster-only参数时加载首次全量构建永不读取本文件见 update.md。它对应各 Agent 平台主 Skill 文件中的## For --update and --cluster-only一节例如 skill-amp.md是主流程 Step 1–9 的“非默认子命令”分支补充。一、两份可复用的“状态先决条件”在展开两条流程前先明确运行手册反复使用的两个运行时约定$(cat graphify-out/.graphify_python)主 Skill 在 Step 1 会把解析出的 Python 解释器路径写入graphify-out/.graphify_python支持 uv tool、pipx、venv、系统安装见 skill-amp.md。后续所有 Python 内联脚本都必须用它而不是裸写python3否则极可能用一个没有安装graphify模块的解释器而失败。INPUT_PATH与IS_DIRECTED是两个占位符执行前必须按实际调用替换——INPUT_PATH替换为用户最初传入的扫描路径与主 Skill Step 1 保存的graphify-out/.graphify_root一致IS_DIRECTED替换为布尔字面量若本次调用带--directed则为True否则为False。此外skill-amp.md 提示在执行--update/--cluster-only/query/path/explain/add任一子命令前都应先确认graphify-out/.graphify_python存在若缺失例如用户删除了graphify-out/需先回到主 Skill Step 1 重新解析解释器。二、--update增量重提取完整流程--update适用于“自上次运行以来新增或修改了文件”的场景。它的总目标是只重提取变化的文件再与磁盘上的旧图graph.json合并从而把增量合并后的完整图回灌给后续 Step 4–8聚类、命名、报告生成。2.1 第一步用detect_incremental求出变化集运行手册要求先调用graphify.detect.detect_incremental(Path(INPUT_PATH))把结果打印并落盘为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, indent2, ensure_asciiFalse)) Path(graphify-out/.graphify_incremental.json).write_text(json.dumps(result, ensure_asciiFalse), 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_incremental定义在 detect.py。它的核心是与上次运行的 manifest 快照做差它先调用一次完整的detect()得到当前语料再读取graphify-out/manifest.json对每个文件走两级判定快路径——mtime未变且哈希一致直接判为 unchanged几乎零磁盘 IO慢路径——mtime变化后再比对一次 MD5 内容哈希才决定是否重提取若没有历史 manifest则把整个语料当作new_files相当于退化为全量incrementalTruemanifest 中“离开语料”的行会被进一步区分为deleted_files文件已从磁盘消失真删除与excluded_files文件还在磁盘但被 ignore 规则/--exclude排除不是删除——这一区分与 watch 侧的语义保持一致源码注释见 detect.py。值得注意的是manifest 行内的seen时间戳还用于一种“同 tick 重写”防护若行被写入的时间与文件 mtime 落在同一文件系统时间片内无法证明 mtime 未变就等于内容未变这一小窗口会强制付一次 MD5 代价_mtime_may_hide_a_rewritedetect.py。该函数返回的 JSON 至少包含如下关键字段后续步骤都会消费字段含义new_files按类型code/document/paper/image/video分组的变化文件子集files本次全量扫描出的全部文件按类型分组total_files/new_total全量文件数 / 变化文件数total_words语料总词数用于成本预估skipped_sensitive因疑似密钥被跳过的文件列表deleted_files真删除磁盘上已不存在的文件excluded_files被忽略规则排除、但还活着的文件2.2 第二步把增量状态“改写”成.graphify_detect.json主流程中的 Step 3AAST到 Step 6分析会无条件读取graphify-out/.graphify_detect.json因此增量运行必须让该文件呈现正确的状态。规则是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_asciiFalse), encoding\utf-8\) 这里用 Python 直接写文件而不是 shell 重定向是为了避免不同主机控制台编码漂移PowerShell 等场景下更稳。2.3 第三步判定是否“纯代码变更”决定要不要 LLM若有新/变化文件先检查所有变化文件是否都是代码文件$(cat graphify-out/.graphify_python) -c import json from pathlib import Path result json.loads(open(graphify-out/.graphify_incremental.json, encodingutf-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/.jsx/.vue/.zig/.ex/.sh/.sql/.tf/.xaml等约 140 种。之所以在手册中独立判定一次是为了做成本路由而不是复刻分类器。code_only为 True纯代码变更打印提示[graphify update] Code-only changes detected - skipping semantic extraction (no LLM needed)然后只对变化文件执行 Step 3AAST 结构提取完全跳过 Step 3B不派发任何语义子代理直接进入合并与 Step 4–8。这正是 Graphify“AST 确定性解析 无需向量库”优势在增量场景的体现——改几行.py不用烧 token。code_only为 False变化中含 doc/paper/image/video先处理媒体。若new_files[video]非空必须先按 transcribe.md即 Step 2.5对视频/音频文件做转录然后重写.graphify_detect.json把转录得到的文本路径移入files[document]并删除files[video]——否则原始.mp4/.mp3路径会被当作不可读媒体直接喂给语义子代理仓库 issue #1392 即此问题。处理完后再按正常流程跑完整的 Step 3A–3C。2.4 第四步若只有删除、没有新增文件如果new_total 0而只有deleted_files需要创建一个空 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}), encodingutf-8) fi若该文件已存在例如 code_only 流程刚写过 AST 结果不要覆盖它。2.5 第五步用build_merge把新 extraction 合并进graph.json核心无论哪种分支最终都要执行同一段“合并 清理 刷新 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_merges replace-on-re-extract (#1344): every source_file in # new_chunks is dropped from the base before merge, so old/stale nodes dont 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 graphs relative source_file values; without it # nothing is pruned and stale nodes accumulate on every update (#1361). # directedIS_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_pathgraphify-out/graph.json, prune_sourcesprune, rootINPUT_PATH, directedIS_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(dataTrue)], 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(dataTrue) ], # 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_asciiFalse), 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 todays state, not the # prior runs 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 runs 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, rootINPUT_PATH, scan_corpus_scan, clear_semantic_cleared or None) print([graphify update] Manifest saved.) 这段脚本浓缩了 Graphify 增量合并的全部关键决策配合源码可以逐条拆解1prune_sources只收“真删除”绝不放“已变更”文件。脚本内注释对应仓库 issue #1344说明变更/被重提取的文件由build_merge的replace-on-re-extract机制处理——new_chunks里出现的每个source_file会先从既有图中按层级AST 层 / semantic 层删除旧贡献再合并因此不会累积陈旧节点。若错误地把changed文件加进prune_sources在传入root的情况下剪枝集会相对化到与新建节点相同的基准反而会误删刚重提取的内容。这在源码 build.py 中有明确警示被重提取的文件正在被 REPLACE 而不是被 DELETE即使调用方同时把它列进prune_sources也绝不能剪。2必须用build_merge而不是 NetworkX 往返。build_merge定义于 build.py它直接读取磁盘graph.json合并避免经过json_graph.node_link_graph的往返导致边的方向calls/implements/imports或超边丢失#801。它本身不写盘由调用方把返回的图序列化回.graphify_extract.json供后续 Step 4 聚类读取。3root必须传且与图内相对source_file对齐。detect_incremental返回的删除路径是绝对路径而图中节点键是相对扫描根的路径build_merge内部会做相对化源码见 build.py还支持从图内记录的扫描根推断。不传root时剪枝不生效陈旧节点会在每次 update 时越积越多#1361。同时build_merge的directed参数显式覆盖磁盘图的自有标记避免一次--directed --update静默把有向图变成无向图、把A-B互反边折叠掉#1392directedNone时则继承磁盘图自身的directed标记#2342。4超边hyperedges必须从合并后的图取。G.graph[hyperedges]同时持有旧graph.json与新 extraction 的超边build_merge会合并二者。若只回退到new_extraction会静默丢弃历史运行产生的超边#801。5manifest 只盖章“真正产出了输出”的语义文件。盖章逻辑复用 CLI 的_stamped_manifest_filescli.py只有nodes/hyperedges里带source_file的实体才算有效语义输出纯 edge-only 结果不算需留给下次重排队。一个被改动且本次 chunk 失败的 doc 必须保持未盖章unstamped否则下次--update会把它当作已处理内容永久丢失#2015。随后脚本计算“本次派发过但未盖章”的语义文件集合通过save_manifest(..., clear_semantic...)清掉它们的陈旧semantic_hash使它们下次被重新排队#1948。save_manifest的实现见 detect.py种子化时以已存在 manifest 为基底、保留未动文件的行防止子集写入擦除整库并原子写盘。6scan_corpus传原始全量语料排除 ≠ 删除。自上次运行后被新增 ignore 规则排出的“活着但在语料外”的文件应被清理为“排除”而非在下次--update中伪装成删除#1908未触及的行原样保留。2.6 第六步展示图 diff可选但推荐在合并、完成 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, directedIS_DIRECTED) if old_data: G_old json_graph.node_link_graph(old_data, edgeslinks) 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通过新旧两图的节点差与边差有向图按(u, v, relation)、无向图按排序后的端点对作为边键返回new_nodes/removed_nodes/new_edges/removed_edges/summary五个字段例如3 new nodes, 5 new edges, 1 node removed。2.7 收尾回到主流程合并并写回.graphify_extract.json之后继续按正常流程运行 Step 4–8聚类 → 社区命名 → 报告/HTML/图导出此时它们看到的已是包含增量合并结果的完整图。至此一次--update在用户侧的观感是只对变化的文件做了 AST/语义提取旧图被无缝替换token 消耗与耗时都远小于全量重建。三、--cluster-only基于已有图仅重聚类--cluster-only适用于“抽取结果没变但希望重新聚类/重新命名/重新生成报告”的场景例如换了聚类参数、想刷新社区标签或图已被其他途径更新。手册明确跳过 Steps 1–3不要重新 detect、不要重新提取直接执行graphify cluster-only .该命令是**自包含self-contained**的它基于现有图重新聚类、命名社区并据此重新生成GRAPH_REPORT.md、graph.json和graph.html见 skill-amp.md 与 CLI 层 cli.py 中cluster-only/label的处理label可视为“总是重新生成社区名字”的cluster-only。graphify cluster-only .中的.指项目根也与graphify extract ./graphify cluster-only .必须先有图的前提一致参考 wiki.py 中“先 extract 或 cluster-only”的提示。关键的“不要”绝对不要在其后重跑主流程 Steps 5–9——这些步骤会读取.graphify_extract.json、.graphify_detect.json、.graphify_analysis.json等中间文件而一次完整构建的 Step 9 清理已经把它们删掉了重跑必然抛FileNotFoundError#1392。正确收尾方式等命令跑完照常向用户展示刷新后的GRAPH_REPORT.md摘要即可。值得补充的是cluster-only之所以能“无中间文件自举”是因为聚类所需的社区成员签名等状态被持久化在graphify-out/.graphify_labels.json旁边见 cluster.py 的注释“Persisted next to.graphify_labels.jsonso a latercluster-onlycan tell...”后续 watch 侧也能据此感知社区是否变化watch.py。四、增量状态机速查manifest、中间文件与产物可以把graphify-out/下的文件理解为一次增量运行的“状态机寄存器”它们各自的角色如下文件谁写谁读作用.graphify_pythonStep 1一切脚本正确的解释器路径.graphify_incremental.json--update第一步--update后续步骤变化集快照new_files/deleted_files/files/skipped_sensitive….graphify_detect.json全量 detect 或增量改写Step 3A–6files变化子集all_files全量语料.graphify_extract.json提取/合并Step 4–6合并后的完整图nodes/edges/hyperedges/token 计数.graphify_old.json合并前的临时备份图 diff 展示供graph_diff比对用完即删manifest.jsonsave_manifest下次detect_incrementalmtime ast_hashsemantic_hash的逐文件快照graph.json/GRAPH_REPORT.md/graph.html聚类/命名/导出用户与 query 侧最终知识图谱产物manifest.json的存在让“跨机器可移植”成为可能以root相对化键名#1417/#777同一个仓库克隆到别处后--update依然能命中缓存文件而不是“全部判为新文件”。同时manifest 写入采用原子写write_json_atomic避免崩溃在磁盘上留下截断的 manifest 导致下次增量解析失败detect.py。五、经典陷阱与规避清单可直接作为排查手册把上述源码注释中的 issue 编号汇总成一张“经验证的真问题”清单便于实际遇到症状时对照幽灵节点越积越多#1361/#1571剪枝路径没相对化。build_merge必须传root或至少让_infer_merge_root能从图上记录扫描根兜底——否则prune_sources的绝对路径永远匹配不到相对化的节点键。--directed静默丢失#1392/#2342合并时把directedIS_DIRECTED写死。否则增量合并可能继承/默认出无向图把互反边折叠。媒体被当作文本喂 LLM#1392有video变化时必须先走 transcribe.mdStep 2.5并把转录路径移进files[document]、删掉files[video]。失败 chunk 被永久盖章#2015/#1948/#933语义文件“产出即盖章”。务必用_stamped_manifest_files过滤并用clear_semantic清空本次派发但未盖章文件的陈旧哈希让它们在下次--update被重新排队。删除与排除混淆#1908scan_corpus必须传全量原始语料让被 ignore 规则排除的文件被清理为“排除”而非伪“删除”。误剪刚重提取的文件#1344/#1178prune_sources严禁包含changed文件——它们由 replace-on-re-extract 处理不是删除。超边静默丢失#801合并回写hyperedges时取G.graph[hyperedges]而不是只取new_extraction。--cluster-only后重跑 Steps 5–9 抛FileNotFoundError#1392中间文件已被 Step 9 清理cluster-only是自包含终点。仓库迁移/克隆后全部重提取#1417manifest 键必须以相对扫描根形式保存并在读取时重新锚定load_manifest(manifest_path, rootroot)。以上每一条都能在 detect.py、build.py、cli.py 的对应实现与注释中溯源仓库的增量语义也由 test_incremental.py 等测试持续守护。六、小结--update与--cluster-only共同构成 Graphify 面向“图已存在、语料小幅演进”的两条低成本路径前者用 manifest 快照差 build_merge的 replace/prune 语义把成本精确限制在“变化文件的重提取 一次合并”上纯代码改动时甚至完全不需要 LLM后者在零抽取的前提下刷新社区划分与全部报告产物。二者都强调对运行手册的精确遵循——占位符替换、状态文件改写、剪枝集边界、有向性保持与 manifest 盖章纪律——任何一步松懈都会以“幽灵节点、内容丢失或静默回退”的形式反噬。掌握了这张运行手册就等于掌握了在真实仓库上安全、省钱地长期维护知识图谱的完整操作模型。本文对应文档tools/skillgen/expected/graphify__skills__amp__references__update.md源头副本为 graphify/skills/amp/references/update.md该参考文件在agents/claude/codex等各平台 Skill 目录下同步分发见 graphify/skills/主 Skill 入口为 graphify/skill.md 及各平台变体例如 graphify/skill-amp.md。【免费下载链接】graphifyTurn any codebase, with its docs, SQL schemas, configs, and PDFs, into a queryable knowledge graph. A /graphify skill for Claude Code, Cursor, Codex, and Gemini CLI: local deterministic AST parsing, every edge explained, no vector store.项目地址: https://gitcode.com/GitHub_Trending/graph/graphify创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考