GraphRAG 增量索引实测:删掉文档它连跑都不跑,那它到底"增"了什么?——彻底搞懂GraphRAG第二篇

5 阅读32分钟

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. 1 个读增量步骤:load_update_documents
  2. 9 个标准链步骤:切块 → 抽图 → 聚类 → 写报告 → 算向量(就是标准索引链的那十步,去掉读全量输入那一步)
  3. 8 个合并步骤:update_* 系列

关键在第 2 段和第 3 段是分开的两次执行。运行时它把目录切成三份(📄 index/run/run_pipeline.py):

output/                        ← 当前索引(最后会被合并结果覆盖)
update_output/
  └─ 20260926-120449/
       ├─ previous/            ← 把上一轮的 output/ 全表复制一份备份
       └─ delta/               ← 这一轮新内容的"小索引",独立一份

于是流程变成:

  1. 把当前的 output/ 全部复制到 previous/(📄 _copy_previous_output,逐表读出来再写一份);
  2. 用新文档在 delta/ 里跑一整套标准链——切块、抽实体、聚类、写报告,一个不少;
  3. 用 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 / 合并后):

表previousdelta合并后合并后 = 相加?
documents112是
text_units729是
entities10040136否(100+40=140,少了 4)
relationships12053173是
communities14620是
community_reports8412是

唯一的"否"就在 entities——它是这张清单里唯一真正发生记录级合并的表。合并规则如下(📄 index/update/*.py):

表合并键规则实测观察
documents行号新表行号从旧表最大值往后顺延,然后直接拼接2 行,doc.txt / doc2.txt
entitiestitle 精确匹配匹配上的新实体映射回旧实体 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 与边表实数不符
基线索引(完整链跑出来的)1000
delta 小索引400
合并后1363

那 3 个刚好就是跨批出现、又在增量里拿到新边的实体:

实体旧值合并后应为差
AI-STUDY-HELPER411−7
RAG 评测工程15−4
FORMAT12−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)13696364
关系(键 = source, target)173120530

所以 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. 那它为什么不把这堵墙修掉

到这儿很容易得出一个结论:"不就是没做吗,把受影响的社区重算一遍不就行了?"——这是个自然的反应。但翻完算法性质和这条线的文献之后,我的判断是:这是算法层面的取舍,不是工程欠账:

  1. Leiden 是全局优化。 它优化的是整张图的模块度(一个全局求和)。加、删、改任何一处结构,最优划分都可能在任意位置变化,而不只是在你动过的那几个节点周围——这正是 5.2 里"删不掉"那条的根因。
  2. 解空间里有大量近似最优划分。 不同划分的分数可能只差一点点,成员却差很多(near-degenerate)。加上算法本身带随机性——GraphRAG 为此专门固定了随机种子(代码注释写着 to guarantee clustering stability)。需要锁种子,本身就是"输出对细节敏感"的自证。
  3. "只重算受影响的社区"会卡在接缝上。 重算区和冻结区的边界不对应任何一致的最优划分,得到的是"既不是旧最优、也不是新最优"的混合体——而且没有目标函数能判它合不合格。
  4. 想保质量就只能全量重算,而重算的代价主要不在聚类,而在报告。 聚类本身是本地计算、很快;真正贵的是它引发的连锁——社区一变,所有受影响的社区报告都要重写,而每份报告是一次 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,欢迎在评论区补充两件事:一次增量更到多少量级才觉得贵,以及你怎么处理删除——删除这条我实测到的行为比较极端,想看看有没有人踩过更深的坑。