30 秒判断:你打算把 GraphRAG 用在会持续更新的语料上,或者已经在用、想知道"能不能只更新新内容"。这篇把它的增量索引(
graphrag update)真跑了一遍:新内容怎么进来、四张表怎么合、哪一部分它明确拒绝重算,以及它离"和全量重建等价"还差哪五处。一句话结论:它不是一个"增量维护"的机制,而是 append-only 的追加——新文档单独跑一遍完整流水线,再把几张表拼到旧索引上。社区不重算、实体度不重算;而删除文档这件事,它连跑都不跑(实测:删掉一份文档再执行
update,6 秒后以退出码 0 结束,索引里那份文档还在)。适合谁读:在为"知识库会持续更新"做选型的同学;或者已经在用 GraphRAG、准备上第二次索引的人。
阅读成本:约 20 分钟。1–3 节是机制与实测对账,4 节是它买到的好处,5 节先立一把尺子、再逐条对缺口,6 节解释它为什么不修那堵墙,7 节是原始数据与成本,8 节给了一套判断"某个能力是不是还停在半成品"的方法。
证据标记:✅ 实测(自己跑出来的)/ 📄 源码(读 graphrag 3.2.0)/ 📎 官方文档 / 🤔 推断。凡是推断的地方我都会写明。
0. 为什么增量值得单独看一篇
这个系列的第一篇写的是 GraphRAG 的索引本身——十步流水线、产出哪六张表、哪里会静默出错——讲的是第一次跑会发生什么。这一篇接着往下走。
真正决定一个索引方案能不能长期用的,往往是第二次跑:语料变了以后怎么办。
这件事在 GraphRAG 里尤其要紧,因为它建的是一张全局的图——社区划分、社区报告,本质上都是"看整个语料"得出的结论。而"全局"和"增量"天生矛盾:它怎么处理这个矛盾,基本就决定了它能用在什么场景。
一句话概括这篇的发现:它没有试图解决这个矛盾,而是绕开了——新的归新的、旧的归旧的,两套结构并排放着。
1. 它跑的是什么:一条 18 步的流水线
入口是一条 CLI 命令:
graphrag update --root .
它做的事,是把方法名从 standard 换成 standard-update,然后执行三段拼起来的流水线。跑一次,日志会把这 18 步原样打出来(✅ 实测):
Starting pipeline with workflows: load_update_documents, create_base_text_units,
create_final_documents, extract_graph, finalize_graph, extract_covariates,
create_communities, create_final_text_units, create_community_reports,
generate_text_embeddings, update_final_documents, update_entities_relationships,
update_text_units, update_covariates, update_communities,
update_community_reports, update_text_embeddings, update_clean_state
数一下就是 1 + 9 + 8:
- 1 个读增量步骤:
load_update_documents - 9 个标准链步骤:切块 → 抽图 → 聚类 → 写报告 → 算向量(就是标准索引链的那十步,去掉读全量输入那一步)
- 8 个合并步骤:
update_*系列
关键在第 2 段和第 3 段是分开的两次执行。运行时它把目录切成三份(📄 index/run/run_pipeline.py):
output/ ← 当前索引(最后会被合并结果覆盖)
update_output/
└─ 20260926-120449/
├─ previous/ ← 把上一轮的 output/ 全表复制一份备份
└─ delta/ ← 这一轮新内容的"小索引",独立一份
于是流程变成:
- 把当前的
output/全部复制到previous/(📄_copy_previous_output,逐表读出来再写一份); - 用新文档在
delta/里跑一整套标准链——切块、抽实体、聚类、写报告,一个不少; - 用 8 个
update_*步骤,把previous/和delta/的几张表合起来,写回output/。
所以"增量"省下的是对存量语料的重复抽取,但流程一步没少:新内容仍然要走完整的十步。
顺手记一个部署上的坑:这个"备份"是全表复制,每次增量都会在
update_output/下留一份完整快照。更新频繁的话,磁盘是按"每次一份索引"的量级长的。(✅ 实测:我跑了 4 次update,update_output/下就躺了 4 份快照。)
1.1 一个前提:上一轮的索引必须是"完整标准链"产出的
这一条是踩出来的:如果拿一份被裁剪过的索引当"上一轮索引"(下面 1.2 会解释为什么会有人去裁剪流水线),更新会跑到第四步就挂:
Starting workflow: update_text_units
Pipeline error: 'human_readable_id'
原因是这份索引跳过了一个叫 create_final_text_units 的收尾步骤,它的 text_units 表里就没有 human_readable_id / entity_ids 这几列,而更新链要用(✅ 实测)。换句话说:增量索引的前提是上一轮索引"长得齐全" ;你自己裁剪过流水线,就得先把缺的列补齐。
1.2 一个配置陷阱:workflows 会盖掉 --method,而且静默失败
这个坑值得单独讲,因为它同时踩中"看不懂配置"和"看起来成功了"两件事。先把两个名字说清楚:
--method是命令行上的一个选项(standard/fast/standard-update…),用来挑官方内置的那一套流水线。graphrag update会自动把方法名换成standard-update——上面那 18 步就是这么被选出来的。workflows是settings.yaml里的一个配置项,值是一串步骤名,意思是"这次只跑我列的这几步"。它是给"我想裁剪流水线"用的——比如不想花钱算向量,就把嵌入那两步从清单里去掉。
麻烦在于两者的优先级(📄 PipelineFactory.create_pipeline,原话就一行):
workflows = config.workflows or cls.pipelines.get(method, [])
配置优先。 所以只要 settings.yaml 里列了 workflows,--method 选出来的那套流水线就完全不起作用——graphrag update 也会乖乖去跑你列的那条链。
我把这个坑也实测了一遍:把一份"六步裁剪清单"写回 settings.yaml,然后照常执行 graphrag update。
| 观察 | 结果 |
|---|---|
| 日志第一行 | Starting pipeline with workflows: load_input_documents, create_base_text_units, extract_graph, finalize_graph, create_communities, create_community_reports —— 走的是你列的那条链,update_* 那 8 步一步没跑(在这条链里它们本来也不该跑) |
| 它实际干了什么 | 把 input/ 里的文档全部重新索引了一遍(那一刻是 2 篇:原始那篇 + 上次新增的那篇)——产出 126 实体 / 166 关系 / 29 社区 / 26 报告,全写进 delta/。该轮 delta 的 documents 是 2 行、text_units 是 9 块(7 + 2),跟"两篇都被索引了"对得上 |
| 我的索引 | 一个字节都没变(还是 136 / 173 / 20 / 12) |
| 退出码 | 0 |
| 耗时 | 92 秒——一次完整索引的钱,白花 |
所以这个坑有两层:你以为在更新,其实跑的是完整索引(这笔钱白花);更糟的是,这份完整索引被写进了 delta/ 暂存目录,没有任何一步把它提升为正式索引——你的 output/ 一个字节都没变,退出码还是 0。
这里容易绕一个弯: "没跑合并"并不是第二个 bug。
那次 delta/ 里装的是一份包含全部输入文档的完整索引——拿它去跟 previous/ 合并,反而会把自己重复一遍。真正缺的不是"合并",而是 "把 delta/ 提升为正式索引"这个动作:正常更新链的前提是"delta/ 只装新增内容",所以确实要靠 8 个 update_* 把它并进旧索引;可一旦换成全量链,delta/ 装的是全部内容,而仍然没有代码负责把它提交上去。
结果就是:钱花了、结果算了、索引没变。
想避开它只有两条路:要么别在配置里写 workflows(让 --method 生效),要么把它写成完整的更新链(load_update_documents + 9 个标准链步骤 + 8 个 update_*,也就是本文开头那 18 个名字)。
2. 差异怎么算:按文档标题比对
哪些文档算"新"?判据非常朴素——按文档标题比对(📄 get_delta_docs):
- 新输入的标题集合 − 上轮
documents表的标题集合 ⇒ 新增 - 上轮标题集合 − 新输入的标题集合 ⇒ 已删除
标题取自文件名(或 front matter)。我拿两个场景各跑了一次真实更新,结果如下(✅ 实测,两次都是 6 秒、退出码 0):
| 场景 | 操作 | 日志里实际做了什么 | 索引状态 |
|---|---|---|---|
| 删掉一份文档 | 从 input/ 移除 doc2.txt,执行 update | 只跑了 load_update_documents 就结束(后面 17 步一步没执行) | documents 表里 doc2.txt 还在 |
| 改一份文档的内容 | 保留文件名,往 doc.txt 追加一段文字 | 同样只跑了第一步就结束 | 索引仍是最初那份内容和 7 个文本块 |
这就是整条链路里我觉得最值得说的一处。get_delta_docs 返回的 InputDelta 里明明有 deleted_inputs,但唯一的调用方只取了 new_inputs:
return delta_documents.new_inputs
📄 我全包搜了一遍 deleted_inputs:除了 incremental_index.py 里的定义与赋值,整个 graphrag 包里没有任何地方读它。
实测的后果比"删除不落地"更干脆:删除这件事根本不会被处理。你把文档从 input/ 里删掉,update 会以成功状态退出,而索引里那份文档的 chunk、实体、关系、社区报告全都原样留着,仍然会被检索到、仍然会出现在全局综述里。用户看到的是"一份已经不存在的文档被当成当前知识在回答"。
同名改内容也一样:判据是标题不是内容,所以"文件改了但名字没变"等于没变化。
(顺带记一条:3.0.3 的 CHANGELOG 里有一条 "Add … remove … operations to vector store API"——向量库 API 有删除能力了,但增量链里没有任何删除调用。📎)
3. 四张表分别怎么合
delta/ 和 previous/ 都有同样的几张表,合并规则却是每张一套。先看规模对账(✅ 实测,这三列是 previous / delta / 合并后):
| 表 | previous | delta | 合并后 | 合并后 = 相加? |
|---|---|---|---|---|
documents | 1 | 1 | 2 | 是 |
text_units | 7 | 2 | 9 | 是 |
entities | 100 | 40 | 136 | 否(100+40=140,少了 4) |
relationships | 120 | 53 | 173 | 是 |
communities | 14 | 6 | 20 | 是 |
community_reports | 8 | 4 | 12 | 是 |
唯一的"否"就在 entities——它是这张清单里唯一真正发生记录级合并的表。合并规则如下(📄 index/update/*.py):
| 表 | 合并键 | 规则 | 实测观察 |
|---|---|---|---|
documents | 行号 | 新表行号从旧表最大值往后顺延,然后直接拼接 | 2 行,doc.txt / doc2.txt |
entities | title 精确匹配 | 匹配上的新实体映射回旧实体 id;text_unit_ids 取并集;description 收成列表;frequency 按证据块数重算 | 4 个实体被合并;证据块确实取并集(如某实体 1 块 + 1 块 → 合并后 2 块) |
relationships | (source, target) | 编号顺延后按两端分组;description 收成列表;weight 取平均;combined_degree 重算;最后过滤悬空边 | 两批没有任何一对边重合(120+53 直接相加),所以这一轮没发生关系级合并 |
communities | 社区编号 | 新批次编号整体偏移(旧最大值 + 1),父指针一起重映射,然后直接拼接 | 编号从 0–5 偏移成 14–19;-1(不属于任何社区)原样保留 |
community_reports | 同一套编号映射 | 直接拼接 | 新批次的 4 份报告落到编号 14 / 15 / 18 / 19;旧 8 份报告的 id 与标题 8/8 全部保留 |
3.1 度数:实测 3 个实体停在了旧值
entities.degree 不重算,源码里就是 "degree": "first",旁边还留着一行 TODO(we could probably re-compute this with the entire new graph)📄。
实测比这句话更有信息量。按源码定义(把边归一成无向对再去重,每个端点 +1)逐个实体核一遍:
| 产物 | 实体数 | degree 与边表实数不符 |
|---|---|---|
| 基线索引(完整链跑出来的) | 100 | 0 |
| delta 小索引 | 40 | 0 |
| 合并后 | 136 | 3 |
那 3 个刚好就是跨批出现、又在增量里拿到新边的实体:
| 实体 | 旧值 | 合并后应为 | 差 |
|---|---|---|---|
AI-STUDY-HELPER | 4 | 11 | −7 |
RAG 评测工程 | 1 | 5 | −4 |
FORMAT | 1 | 2 | −1 |
两个附带结论:只在新批次里的实体,度数是对的(它们本来就只有新边);而边的 combined_degree 反而重算了——所以"度数完全不管"是不对的,脏的只有实体这一侧。而实体 degree 在查询侧被当作实体排序键(📄 rank_col="degree"),所以这份脏账不是纯摆设。
3.2 合并之后还会再调一次 LLM(但比看上去少得多)
合并把同一实体的多条描述收成了列表,随后更新流程会调用摘要步骤把这些描述"汇总成一段"。而汇总器只对描述条数 ≥ 2 的条目真的调模型(📄 SummarizeExtractor:0 条返回空、1 条原样返回、多于 1 条才调)。
那"条数 ≥ 2"的到底是哪些条目?这里有个关键前提:每条描述在它自己那一批的索引里,就已经被汇总成一条了。所以一个条目有几条描述,等于它出现在几批里——
- 只在旧批次 ⇒ 1 条
- 只在新批次 ⇒ 1 条
- 两批都出现 ⇒ 2 条 ⇒ 才会调模型
也就是只跟"是否跨批重合"有关,跟新旧无关。这里有两点容易说错:① 这一步不只针对实体,关系也一样要走一遍;② "只有 1 条描述"的不只是旧条目,这次新增、但没在旧批次出现过的条目同样只有 1 条。真实构成是(✅ 实测,按合并键逐条数出来的):
| 类别 | 合并后条目数 | 只在旧批次(1 条) | 只在新批次(1 条) | 两批重合(2 条 ⇒ 调模型) |
|---|---|---|---|---|
实体(键 = title) | 136 | 96 | 36 | 4 |
关系(键 = source, target) | 173 | 120 | 53 | 0 |
所以 update_entities_relationships 实际只调了 4 次:进度条要走 309 个条目(136 实体 + 173 关系),其中 305 个只有 1 条描述、原样返回、不花模型的钱,真正进模型的只有那 4 个跨批重合的实体。
这句话可以记成一条规律:这一步的开销正比于"跨批重合的条目数",而不是正比于实体/关系的总量。
3.3 社区标题那行"重写"其实是个空操作
合并代码里有一句"把所有社区的 title 统一改写成 Community N",看着像是要抹掉旧标题。实际不用担心:communities.title 在完整链产出的索引里本来就是 Community N(✅ 实测:基线、六步链产物、合并后三份都是),有意义的标题存在另一张表 community_reports.title 里,而它被完整保留了(合并后 12 份报告里,旧 8 份的 id 和标题一个没变)。所以那行重写在当前数据上是个 no-op。
顺带记一条读源码的经验:读代码能看出"做了什么动作",但看不出"这个动作在当前数据上有没有效果" 。像这种"看着危险、其实空转"的行,只有把产物摊开对着看才能确认。
3.4 于是它到底"增"了什么
一句话:新内容独立成图 + 表级拼接。
只有"实体/关系的记录级合并"算真正的合并。而结构级的东西(社区划分、实体度数)不做重算:旧社区树原封不动留着,新批次的社区树编号偏移之后并排放进去。
4. 好处:它买到了什么(按用户视角)
公平起见,先把好处说足。按用户实际会怎么问排,它买到的是两条腿——一条无条件,一条有条件:
| 用户会怎么问 | 它给到的 | 依据 |
|---|---|---|
| "这篇里关于 X 是怎么说的"(找片段) | 和全量重建一样:加进去就能查到 | 块向量是 upsert 语义,这条链不经过全局结构 📄 |
| "X 和 Y 是什么关系 / 跟 X 相关的都有什么"(找关联) | 不受更新次数影响,而且跨批实体的证据集会越攒越全 | 这条链只吃实体 / 关系 / 证据块 📄;证据块取并集 ✅ 实测:1 块 → 2 块、1 块 → 3 块 |
| "这批资料整体讲了什么 / 两篇的共同点"(整体看) | 在增量轮数少时,覆盖与综合基本等同于一次全量重建 | 报告数 8 → 12 ✅ 实测;查询侧会把该档的报告全扫一遍 📄 |
| "每次更新贵不贵" | 只付"新内容"那部分的钱——约等于只索引那篇新文档 | ✅ 实测:这一轮 24 次 LLM 调用;而存量那篇单独重建一次就要 62 次,两篇一起重建只会更多 |
一句话概括:
用三条必然代价(5.2 那三条),换到了"有界轮数内的全局问答质量 + 无条件的局部检索质量"。
这句话有两个地方容易被读歪,补两笔:
- "较小"体现在哪:那三条代价全都落在成本与粒度控制上,没有一条落在"答案对不对" ——这正是 5.3 的结论。纯新增、轮数少的时候你几乎感觉不到它们(这一轮增量 42 秒,就是这种情形)。
- "无条件"要扣掉两处欠账:5.1 的第 4、5 条(社区引用悬空、
degree停在旧值)会碰到局部检索——但它们与"结构能不能增量维护"无关,是没做完,可修。(顺带说,记录级合并本身是有效的:4 个同 title 实体合成了一行,没有在表里出现两份。)
所以底下那五处缺口,没有一处是"查不到东西"——它们全部落在 "语料变了" 和 "长期用下去" 这两件事上。下一节先把这两件事量化成一把尺子。
5. 一把尺子:它离"等价于全量重建"差多远
增量的合格线只有一句话:把语料分次喂进去,用户应该感觉不出和"一次全量重建"的区别。
为什么以"全量重建"为基准?因为这套系统里的社区、层级、报告、度数,全都是以整个语料为对象定义出来的(第 3 节);分批只是实现手段,不该改变语义。
一个精确性说明:等价不等于逐字节相同——抽取阶段本身带随机性,同一批语料跑两次也不会逐字一致。所以这把尺子量的是"在查询层面不可区分":同样的问法,答案的覆盖与结构不因分批而系统性变差,成本也不因分批而系统性变高。
拆开看,它要在四个面上成立(前两个都归"答案质量"这一个总要求,分开列只是为了可检查):
| 面 | 用户会说 | 合格的样子 |
|---|---|---|
| ① 内容 | "同一堆资料,我一次给它和分三次给它,问同样的整体问题,答案应该一样全" | 整体问题的覆盖与综合,不因分批下降 |
| ② 可控性 | "我问'整体有哪些主题'和问'某个机制怎么运作',应该稳定地拿到两档粗细不同的东西" | 同一档粒度下,内容的详略不因批次而变 |
| ③ 语料事实 | "我删掉的不该再出现;我新加的、改过的,它得看到" | 索引 = 当前 input/ 的全部且仅有 |
| ④ 长期成本 | "我每周加几篇,不能让同一个问题的回答越来越贵、越来越慢" | 同一个问题的代价不随更新次数上涨 |
5.1 五处缺口(按"用户多痛"排)
排序用的是"用户多痛":能不能绕开 > 影不影响正确性 > 影响面有多广。每条都写清"用户会怎么撞上"——说不出这一点的,就不该进这份清单。
| # | 缺口 | 面 | 用户会怎么撞上 | 性质 |
|---|---|---|---|---|
| 1 | 删除被忽略 / 改名改内容不可见 | ③ | 从资料里删掉的文章,它还会引用、还会写进综述;改了内容的同一篇,它说的还是旧的 | 必然代价 |
| 2 | 报告总量随批次增长(压缩比下降 + 查询变贵 + 覆盖受预算封顶) | ④ | 同一个问题,用得越久越贵越慢;想要细一档时,能进回答的内容还被固定预算卡着 | 必然代价 |
| 3 | 层级粒度不可比 | ② | 我要细一档的答案,新旧内容的颗粒度对不上——同一个档位,两批指的不是同一种粒度 | 必然代价 |
| 4 | 社区表里的实体引用会失效 | ① | 问某个实体相关的东西,取它所在社区的背景时缺一块 | 实现欠账 |
| 5 | 实体 degree 停在旧值 | ① | 局部检索的排序失真,本该进上下文的背景没进来 | 实现欠账 |
对应的实测证据(细节见第 2、3 节):
- 删除:删掉一份文档后执行
update,只跑第一步就结束、退出码 0,索引里那份文档还在;同名改内容同样空跑(🤔 改名的后果是推断:会被算成"删一份 + 加一份",而删除不落地 ⇒ 同一篇内容出现两份)。 - 成本:报告数 8 → 12(其中 4 份来自新批次);每来一批都会多出一批自己的根(+6)。
- 粒度:旧索引有 L0/L1 两层(9 + 5),新批次只有 L0(6 个) 。
- 第 4、5 条要"两张表对着看"才露出来:单看
communities正常、单看entities也正常,把社区里的entity_ids拿去实体表对一遍才发现 3 条指空(AI-STUDY-HELPER合并后不属于任何社区);degree那条同理——3 个跨批实体停在旧值,最大那个是 4、按合并后的边表应为 11。
5.2 前三条其实是同一堵墙:模块性跨批次用不上
上表里那三条"必然代价",看起来是三件事(删不掉、压不缩、粒度不可比),其实是同一个原因的三个面——图的模块性没法跨批次利用。
论文把这条前提写得很直白(📎 原文):
GraphRAG contrasts with these approaches by focusing on a previously unexplored quality of graphs in this context: their inherent modularity … and the ability to partition graphs into nested modular communities of closely related nodes.
整条价值链建在这上面:因为图是模块化的,社区才能切分整个语料;因为社区切分整个语料,一份社区摘要才"代表语料的一部分";于是全局问答可以只读这些摘要、不必读原文。
而增量做的事,是把"整个语料的一层划分"换成"每批各自一份划分的拼盘"。实测:
第一批(1 篇文档) L0 根节点 9 个
第二批(+1 篇文档) L0 根节点 6 个(这一批自己的划分,全部停在 L0)
合并后 L0 根节点 15 个 = 9 + 6
每来一批就多长出一批自己的"树",树的条数由那批内容自己决定,跟语料整体无关。三处后果由此展开:
| 从模块性上掉下来的那一块 | 机制 | 结果 |
|---|---|---|
| 删不掉(缺口 1) | 删掉实体与边会改变度数和连通性(use_lcc 那条闸门首当其冲:图被切成几片后,留下的可能就不再是原来那片),而聚类是全局算法,输入图一变,划分很可能整体重排 | "落地删除"不是在表里减几行,而是等于一部分全量重建 |
| 压不缩(缺口 2) | 每批各自成树,根节点数按批次累加 | 报告总量线性增长, "压缩语料"这份优势一路失效 |
| 粒度不可比(缺口 3) | 层号是各自划分的产物 | 旧索引的"第 1 层"指旧语料的细分,新批次的"根"是新文档内部的话题簇——两者不在同一个坐标里 |
那"压缩语料"为什么这么关键?论文自己的评测正好回答了这个问题(📎)。它比了三个条件:GraphRAG 的社区摘要(C0–C3 四档)、TS(把同一套 map-reduce 直接作用在源文本上,不用图)、以及向量检索。结论是:对向量检索大幅赢;但对 TS,在全面性与多样性上没有统计显著差异;真正赢 TS 的是上下文 token——根层省 97% 以上。
论文 Table 2 把这笔账量化成一个百分比(占最大 token 数的比例):C0 只占 2.3–2.6%,C3 已经占到 67–74% (TS = 100%)。也就是说——它相对 TS 的确定性优势,基本就是"社区摘要压缩了语料"这一件事,而这正是模块性给的。
覆盖面这边还有个上限:最终能进回答的内容被封在固定的 token 预算里(📎 论文原话:中间答案按 helpfulness 排序、逐个塞进新窗口,until the token limit is reached)。候选变多、预算不变,覆盖就不会跟着涨。
还有两个必须说清的限定:
- 差距取决于语料之间的主题重叠度。重叠越多,全量重建越能把多篇收进同一棵子树,差距越大;完全不重叠的语料,两种做法本来就一样。所以它不是个固定倍数,而是一个随语料变化的量。
- 跨批主题"理论上"会失去结构保证,但按论文的证据量级很小(连完全不做聚合的 TS 都不差),所以这条不计入质量缺口——它可确认的代价就是上面那个压缩比。
一句话收口:它本质上是 append-only——能接受"只往里加",不能接受"删、改、重划粒度"。
5.3 两类缺口:必然代价 3 条、实现欠账 2 条
-
必然代价(表里第 1、2、3 条) :都属于上面那堵墙。想修就得动结构,而动结构就等于(部分)全量重算——这正是下一节要讲的。
-
实现欠账(表里第 4、5 条) :和那堵墙无关,是没做完。它们有个共同特征:零件都在,就是没装上。
degree:源码里明明白白留着一行 TODO(we could probably re-compute this with the entire new graph),而重算度数只要遍历一遍合并后的边表,很便宜;- 社区引用:id 映射已经建好了、
text_units那张表也用它改过了,只有communities没跟着改。
6. 那它为什么不把这堵墙修掉
到这儿很容易得出一个结论:"不就是没做吗,把受影响的社区重算一遍不就行了?"——这是个自然的反应。但翻完算法性质和这条线的文献之后,我的判断是:这是算法层面的取舍,不是工程欠账:
- Leiden 是全局优化。 它优化的是整张图的模块度(一个全局求和)。加、删、改任何一处结构,最优划分都可能在任意位置变化,而不只是在你动过的那几个节点周围——这正是 5.2 里"删不掉"那条的根因。
- 解空间里有大量近似最优划分。 不同划分的分数可能只差一点点,成员却差很多(near-degenerate)。加上算法本身带随机性——GraphRAG 为此专门固定了随机种子(代码注释写着 to guarantee clustering stability)。需要锁种子,本身就是"输出对细节敏感"的自证。
- "只重算受影响的社区"会卡在接缝上。 重算区和冻结区的边界不对应任何一致的最优划分,得到的是"既不是旧最优、也不是新最优"的混合体——而且没有目标函数能判它合不合格。
- 想保质量就只能全量重算,而重算的代价主要不在聚类,而在报告。 聚类本身是本地计算、很快;真正贵的是它引发的连锁——社区一变,所有受影响的社区报告都要重写,而每份报告是一次 LLM 调用,这笔开销与社区数成正比。这也是为什么"结构漂移"的代价这么高:它漂到哪儿,就得在哪儿重写一遍摘要。
层级会放大这个影响:层级是递归切出来的,一次小插入可能改变分支的深度(同一个主题上一轮落在第 2 层、这一轮落在第 3 层),而全局检索是"选一层、扫全量"——于是一次小小的增量,会整体挪动全局问答的取材范围。
这不是没人想过。动态社区检测是一条活跃的研究线(DynaMo 2017、DyPerm 2018、Constrained Spectral Clustering 2019、Flow stability 2021,以及今年还在出的新工作),研究了近十年,仍然没有成为默认做法——因为"增量带来的结构漂移"必须和"结构可比性"做交换,而 GraphRAG 的价值恰恰建立在"结构是全局的"之上。
所以这是一次用语义换成本的取舍:换到的是第 4 节那两件东西(有界轮数内的全局质量 + 无条件的局部检索),付出的就是 5.2 里那三条必然代价。它们不是三处 bug,而是这一个决定的三张账单。
7. 实测数据与成本
7.1 实验设置
- 上一轮索引:1 篇中文技术文章(约 2.7 万字符 / 8138 token / 7 个文本块),用完整标准链跑出来的:100 实体 / 120 关系 / 14 社区 / 8 报告
- 增量内容:新加一篇同系列的中文文章(约 7300 字符 / 2290 token / 2 个文本块),所以后续几次运行的
input/里是 2 篇(原始那篇 + 新增那篇) - 模型:DeepSeek
deepseek-chat;本轮不涉及 embedding(把嵌入名单设为空,避免在没有 embedding key 的情况下卡住)
提醒一句以免对不上账: "2 篇"指的是
input/目录里的文档数。索引侧还有一份"上一轮索引的备份目录",里面也会有这些文档——但它只是快照,不参与新一轮的输入判断。
7.2 这次增量花了多少
| 指标 | 数值 |
|---|---|
| 墙钟耗时 | 42 秒(存量那篇单独重建一次是 64 秒) |
| 退出码 | 0 |
| LLM 调用总数 | 24 次 |
| 其中:抽图(新文档) | 4 次(2 个块 × (1 + 1 次 gleaning)) |
| 其中:描述汇总 | 14 次(合并那一步占 4 次,其余是 delta 自身的汇总) |
| 其中:社区报告 | 6 次(新批次 6 个社区各一次) |
(调用数是我数缓存文件得来的——缓存一条调用一个文件,所以这个数字是准的;但具体花了多少钱我没计量,因为本地没接计费接口。上面这几档调用数,足够你按自己模型的价格估。)
值得单说一句:报告的 6 次调用只产出了 4 份报告。这不是增量特有的毛病——社区报告本来就有个"LLM 返回了、但解析不进 schema 就被静默丢掉"的老问题,在这里同样是退出码 0、指标上看不出来。
这笔账怎么读:这次增量的 24 次调用,基本就是 "只索引那篇新文档" 的成本(抽图 4 + 新社区报告 6 + 描述汇总 14)。而存量那篇单独重建一次就要 62 次调用——省下来的正是这部分"存量不重跑";把新旧两篇一起全量重建,只会比 62 更多。另外,增量里也有一块省不掉的固定开销:新批次的社区报告(每个新社区一次)和跨批条目的重汇总(第 3.2 节)。新文档越小,这两块固定开销占比越高。
8. 顺带一套方法:怎么判断一个能力是不是"还停在半成品"
这次我最想推荐的不是某个结论,而是这套判断方式。读开源项目时,文档怎么写、issue 怎么回,都不如把这三样排在一起看:
① 看文档覆盖。 一个从 0.4.0 就进代码的能力,如果官方文档里没有介绍页,基本可以判断它还没被当作"对外能力"。
这个案例我实际查了一遍(📎):
docs/index/下 8 个页面(含methods.md、default_dataflow.md、outputs.md)里,没有任何一处介绍增量索引;方法文档只讲了 standard 和 fast 两种;- 配置文档里只有一句:
update_output_storage——"a secondary storage location for running incremental indexing"; - 唯一真正"提到它"的地方是
outputs.md的字段表:period(ingest 日期)和size(社区实体数)的说明都写着 "used for incremental update merges" ; - 开始指南(
get_started.md)里完全没有update这个命令。
② 看 CHANGELOG 有没有后续推进。 只有"引入"那一次、之后全是外围修补,就是搁置(📎):
0.3.3 Add entrypoints for incremental indexing
0.4.0 Add Incremental Indexing(+ 配置 + text units 更新
+ Calculate new inputs and deleted inputs on update)
0.4.1 Add update cli entrypoint(+ 修可选 covariates 更新)
2.0.0 Rework the update output storage structure
+ Properly increment text unit IDs during updates
3.0.2 load_update_documents 支持 async iterator
3.2.0 仅依赖升级
从 2024 年初引入到现在,全是存储重构、ID 修复、异步改造这类外围动作,核心的"合并语义"没有动过。
③ 看那个"算出来/写下来却没人用"的东西有多久了。 这是最强的信号,这个案例里有两个:
Calculate new inputs **and deleted inputs** on update是 0.4.0 那一批进来的—— "检出被删除的文档"从第一天就算出来了,两年后依然没有被任何地方使用;实测证明:删除场景下update干脆什么都不做。outputs.md明确写着period和size是 "used for incremental update merges",但period在查询配置和查询代码里零出现(📄)——字段留了、用途也写了,就是没人读。这也正好解释了 5.2 里那个"批次不可辨"的现象:它不是一个"没提供能力"的问题,是"提供了但没接上" 。
一个能算出删除却不落地、留了字段却没人读的系统,说明设计者当初想到了这些路,但没有把它们走完。
9. 小结:一句话结论 + 三条使用建议
一句话结论:它用"只往里加"换来了"有界轮数内的全局问答质量 + 无条件的局部检索质量",代价记在成本、粒度控制、和"语料变了" 这三本账上——答案质量这一面它不欠(5.3)。所以它不是"不好用",而是只在特定用法下好用。
三条建议,按决策顺序排:
① 先分清你问的是哪一类问题。
- 局部(找片段、看某个实体相关的东西)⇒ 无脑可用:不受更新次数影响,证据还会越攒越全。
- 全局(这批资料整体讲了什么、几篇的共同点)⇒ 只在增量轮数少的时候成立;轮数一多,成本先涨、粒度跟着乱(5.2)。
② 再看你的语料会不会"变"。
- 只会往里加(一次性收进来的报告集、论文集、尽调材料)⇒ 它很合适,前面那些账基本不用还。
- 会删、会改(真实知识库的常态)⇒ 把删除当成硬约束:
update完全不管这件事(第 2 节那个测试)。你能自己做的是文本/向量层清理(让块检索里不再出现),而社区报告里的残留只能靠定期整体重建清掉。
③ 最后定刷新策略:刷新周期要和语料的演化速度同量级。
全局结构是"慢变量",需要时间窗才能收敛出主题;语料变得比刷新还快,"全局主题"还没成形就过时了。按这条分档:
| 语料更新频率 | 怎么用 |
|---|---|
| 基本不动(书、法规、一次性研究集) | 增量够用;语料小的话直接全量重建更省心 |
| 每月补几次 | 用增量做"临时可见",再定期整体重建 |
| 每天新增 | 别指望它维护全局结构——要么分域、要么接受滞后并明说 |
顺带一句关于"省"的实话:语料小的时候,增量的收益本来就小。 本文这个量级,一轮增量花 24 次调用(约等于只索引那篇新文档),而存量那篇单独重建一次要 62 次——省下的是几十次调用、折成钱是几美分;而你要接手的是删除、粒度、批次这一整套账。增量划不划算,要看"新内容占存量的比例",不是看它叫不叫增量。
附:实验设置与核对方式
- 版本:
graphrag 3.2.0(Python 3.11) - 四次真实运行:① 1 次带新文档的增量(成功,42 秒);② 删除文档后再增量(6 秒,空跑);③ 同名改内容后再增量(6 秒,空跑);④ 把
workflows写回配置后再增量(92 秒,跑成了完整索引、索引本体没变,见 1.2) - 产物位置:
data/tmp/graphrag-exp-update/(含update_output/<时间戳>/{previous,delta}/,可以直接对比三份产物)
主要核对位置(📄):
index/update/incremental_index.py(InputDelta/get_delta_docs/concat_dataframes)index/update/{entities,relationships,communities}.py(四张表的记录级合并)index/workflows/update_*.py、load_update_documents.py(只返回新增)index/workflows/factory.py(workflows覆盖--method)、index/run/run_pipeline.py(快照与 delta 目录)index/operations/finalize_entities.py/finalize_relationships.py(度数语义:无向去重、combined_degree= 两端度数之和)query/indexer_adapters.py(实体degree被当作 rank)
官方文档与变更记录(📎):docs/index/methods.md、docs/index/outputs.md、docs/config/yaml.md、docs/get_started.md、仓库根目录 CHANGELOG.md。
本文的核对脚本与笔记都在开源仓库里:github.com/lipeidonggz…
如果你在生产里跑过 graphrag update,欢迎在评论区补充两件事:一次增量更到多少量级才觉得贵,以及你怎么处理删除——删除这条我实测到的行为比较极端,想看看有没有人踩过更深的坑。