论文说根层摘要只要原文的 2.6%,我实测是 126%——彻底搞懂GraphRAG第六篇

3 阅读16分钟

30 秒判断:本篇基于 graphrag 3.2.0,自己跑了三组语料(另对照论文公布的两组)。GraphRAG 全局问答能省 token,靠的是"索引期把语料压成一批社区报告"。论文里这个压缩比是 2.3–2.6% ,而我自己那三组语料里有两组超过 100%(126% / 129% )——报告比原文还长。往下拆,膨胀来自三个乘数:同一片原文被很多社区各写了一遍、每份报告有一个长度地板、社区切得太碎。而那个地板,是提示词里一句"至少 5 条 findings"造出来的。

一句话结论:压缩比不是方法自带的属性,它是"每根社区能分到多少源材料"与"一份报告至少要写多长"这两个量的比值。前者由你的语料和抽取粒度决定,后者由提示词决定——只有前者远大于后者时,这套东西才省 token。

适合谁读:打算用(或正在用)GraphRAG 做全局问答、想知道"这套配置在我的语料上划不划算"的同学。不需要读过原论文。

结构速览:1 节是先摊账;2–3 节拆膨胀的机制;4–5 节是两条容易被忽略的发现;6 节给出"什么语料能喝下默认配置"的判据。

证据标记:✅ 实测(本文三组语料、多层统计)· 📄 源码/提示词原文 · 📎 论文 · 🤔 推断。


0. 一张对不上的账

GraphRAG 的卖点之一,是全局问答便宜。它的路径是:索引期把语料压成一批社区报告,查询期不再读原文,而是对这批准摘要做 map-reduce。论文里这条路的成本是这样写的:根层社区摘要只需要"直接对源文本做摘要"(TS)那条基线的 2–3% 。

我按论文的方法在自己的语料上建了一遍索引,然后量同一件事:

语料源 token根社区数每根社区覆盖根层报告 ÷ 原文
论文 · 播客1,014,6113429,8422.6%
论文 · 新闻1,707,694—31,0492.3%
我们 · 同源新闻子集(51 篇)122,387373,30838.0%
我们 · 14 篇技术笔记108,786472,315126%
我们 · 7 篇小语料32,363171,904129%

先说清那份英文新闻的来路:它不是随手找的新闻,而是论文那份 News 语料的同源子集。论文 §4 写明它的 News 数据集取自 MultiHop-RAG(Tang & Yang, 2024),类别是娱乐 / 商业 / 体育 / 技术 / 健康 / 科学,时间跨 2013-09 到 2023-12;我在这份语料上做固定种子 42 的分层抽样,抽到 51 篇 / 12.2 万 token,约为全量(按本文 tokenizer 计 138 万 token;论文报约 170 万,差在分块与 tokenizer 口径)的 9% 。所以"论文 · 新闻 2.3% → 我们 38.0%"这组对照,是同一批源材料缩小一个数量级,不是换成另一种语料。

同一件事,这几组语料给出 2.3% 到 129% ——差了将近 50 倍,而且方向是反的:不是"压得没那么狠",是"压完反而更大"。

这篇不讨论"谁的语料有问题",只拆机制:这个 126% 是怎么产生的。 拆完你会发现,它其实是三个乘数的必然结果,而其中两个能被你直接调。


1. 先把账摊开:不是一层的问题

很多人(包括我)一开始只看根层。但社区报告是分层生成的,深层的量和根层完全不是一个量级。同一份 10.9 万 token 的技术笔记语料:

层社区数报告合计 token÷ 原文平均每份
L0(最粗 / 根)47136,909125.9%2,913
L1383709,980652.6%1,854
L2(最细)7471,096,8511008.3%1,468
L3813,00312.0%1,625
全层合计1,1851,956,7431798.7%—

L2 这一层的报告总量,是原文的 10 倍。 而 L2 的社区有 747 个——比根层多了 16 倍。

所以第一个要问的问题不是"报告为什么长",而是"为什么会有这么多社区"。

1.1 社区数是按实体数硬拆出来的(但还有第二个终止条件)

GraphRAG 的社区发现是两段:先做一次 Leiden 划分(得到根层),然后把超过阈值的社区继续拆,阈值 max_cluster_size = 10(📄 config/defaults.py、graphs/hierarchical_leiden.py)。

但终止条件不止"拆到 ≤10"这一条。论文的原话是"recursively detecting sub-communities within each detected community until reaching leaf communities that can no longer be partitioned"(📎 论文 §3.1.4)——子图再也拆不出更细的划分时就停。所以 max_cluster_size 是目标,不是保证。

"拆不动"真的会发生吗? 会,而且很好造:喂一张 20 个节点的完全图进去,得到的是一个 20 个实体的叶子——任何切分都会让模块度变差,算法就不切了。我拿官方那条 native 调用试了三张合成图(脚本 _probe_unsplittable.py):

合成图结果
20 个节点的完全图1 个 20 实体的叶子(拆不动)
两个 10 节点完全图 + 一条桥2 个簇,各 10 个实体(拆得动)
8 / 12 两簇、簇间稀疏2 个簇(12 与 8)——12 那个没继续拆(它自己是个完全图)

真实语料里也确实留下过这种"胖叶子"(脚本 _check_unsplittable.py;这里"叶子" = 没有子社区的社区):14 篇技术笔记 931 个叶子里有 2 个 >10 实体(最大 15);7 篇小语料 109 个里 2 个(最大 13);51 篇英文新闻 571 个里 18 个(最大 65,是阈值的 6 倍多) 。

不过在我们这份技术笔记语料上,"按实体数硬拆"仍然是主导机制,实测分布吻合得很好:

层社区数实体数中位≤10 实体的占比
L047870%
L13831050%
L27474100%

所以准确说法是:层级主要由"超过 10 个实体就继续拆"决定;拆不动的子图会停在超阈值那一步,变成一颗"胖叶子" (这份语料里不到叶子的 1%,但新闻那份最大的一颗有 65 个实体)。

这条规则给出的下界是有前提的:

只要都拆得动,社区数 ≥ 实体数 ÷ 10;而每个叶子社区能分到的材料只剩一两块。

我的语料抽出了 4,919 个实体 ⇒ 拆得动的话至少 492 个叶社区(实测最深两层 755 个,量级吻合)。社区一多,报告就多——这是第一个乘数。


2. 另外两个乘数

2.1 同一片原文被写了很多遍

社区之间共享原文块:一个文本块里往往有多个分属不同社区的实体,于是这个块会同时出现在多个社区的材料里,而每个社区都要为它写一份完整报告。

层社区数块被引用的总次数去重后的块数平均每个块被几个社区引用
L0474131043.97
L13838621038.37
L27471,08110310.50

103 个块在 L2 被引用了 1,081 次——平均每块 10.5 个社区。所以"10 倍"不是"报告啰嗦",而是"同一片原文被写了 10 遍"。

2.2 每份报告有一个长度地板

按"社区自己有多少块材料"分桶,问题更清楚(L2,747 个社区):

材料社区数报告 token 合计平均每份占 L2 报告量
1 块(约 1,200 token)545776,1931,42470.8%
2 块132202,4741,53418.5%
3–4 块5386,0481,6247.8%
5+ 块1732,1361,8902.9%

73% 的 L2 社区只有 1 块材料,它们贡献了 70.8% 的报告量。 而对照根层:只有 2 个"1 块社区"(2.5%),31 个"5+ 块社区"(71.2%)。

看最后两列:材料从 1 块涨到 5+ 块(5 倍),平均报告只从 1,424 涨到 1,890( +33% )。报告长度几乎不跟着材料走——它有一个下限。

换个口径看同一件事(每个社区自己的材料 token vs 它产出的报告 token):

层平均材料平均报告报告 ÷ 材料
L010,0852,9130.29
L12,6081,8540.71
L21,6831,4680.87
L32,1001,6250.77

材料从 L2 到 L0 涨了 6.0 倍,报告只涨 2.0 倍(幂律指数约 0.38)。报告长度和材料量是高度次线性的——只有根层在真正压缩(0.29),L2 几乎是在"复述"(0.87)。


3. 那条地板,是提示词里一句"至少 5 条"造出来的

官方默认的社区报告提示词里有这么一句(📄 prompts/index/community_report.py,我这轮的产物与它一字不差):

DETAILED FINDINGS: A list of 5-10 key insights about the community. Each insight should have a short summary followed by multiple paragraphs of explanatory text grounded according to the grounding rules below. Be comprehensive.

要求里同时有下限(至少 5 条)、条数上限(最多 10 条)、以及"每条要多段、要全面"。

于是当材料只有 2 个实体、1 块原文时,模型也必须凑出 5 条"多段"的发现。它凑的方式我在真实样例里看得一清二楚。举一个 L2 社区的例子(材料 1 块 / 1,200 token、只有 2 个实体):

  • 报告 951 token、5 条 findings,其中一条是"社区结构以草案框架为中心,关系明确"——也就是把邻接表念了一遍;
  • 另一个社区的报告里有一条是"输入侧与输出侧通过关系 2166 和 2167 形成双向约束"——把图结构本身描述了一遍。

三类"凑长度"的成分:① 复述实体描述(把实体表里的 description 换个说法);② 对图结构的元描述("X 是社区的核心枢纽"——零信息量);③ 引用标记——实测约 15% 的字符是 [Data: Entities (…)] 这类标记(根层更夸张:26.5% )。

再叠加一句"至少 5 条",报告长度的下限就被钉死了。

一条能直接搬走的判据:

只有当每个社区的平均材料明显大于这个地板(约 1.5k token)时,报告才不膨胀。 实测:根层 8.79 块 ✓ / L1 2.25 块(临界)/ L2 1.45 块 ✗。

3.1 一个反例,证明这不是"提示词写不好"

同一份提示词下,材料有 9 块 / 10,208 token 的那个社区,只写了 1,390 token 的报告——压缩到 0.14 倍,是真正的好摘要。

也就是说:提示词没问题,是"材料太少"这种情形它没被设计来应对。


4. 一个我没想到的发现:高层报告从来没读过子报告

按直觉,分层摘要应该是"上层看下层的摘要,越往上越粗"。论文摘要里确实有这么一句: "summaries at higher levels of the hierarchy recursively incorporating lower-level summaries" 。

但论文正文(§3.1.5)给的实际规则是条件式的:

如果该社区的全部元素摘要装得下上下文窗口,就按叶子社区的做法办——直接总结元素。装不下时,才按子社区 token 体量降序,用子社区摘要替换对应的元素摘要,直到装得下。

也就是说,"递归纳入下层摘要"只在超限那条路径上成立。

而我们这份语料走的是哪条路?我把 1,185 个社区的上下文构建过程全部离线复算了一遍:context_exceed_limit = True 的数量是 0。

全程没有发生一次"用子社区摘要替换",高层报告从来没有读过低层报告。

这意味着两层之间的粗细差异,不是靠"递归抽象"实现的,而只靠两件事:① 高层的材料更多;② 输出带宽固定(5–10 条 findings,加上那个本该存在的长度上限)。而这两条都是经验值——下节说第二个为什么半残了。


5. 两个方向都被扭曲,而"上限"那半还被弄丢了

5.1 上下限都在扭

统计三层报告的 findings 条数(提示词规格是 5–10 条):

层份数中位恰好 10 条恰好 5 条低于 5 条最高
L0(根)47919 份 = 40.4%2 份011
L1383828 份 = 7.3%31 份6 份10
L2(最碎)743710 份172 份 = 23.1%20 份10

根层完整分布:{5: 2, 7: 2, 8: 10, 9: 13, 10: 19, 11: 1}——大头挤在 8–10,40% 正好顶到 10,还有一个写到 11(越出提示词给的范围)。

两头都不是"材料需要多少就写多少" :材料最多的根层被截断在上限(想说的更多但写不完),材料最少的 L2 被撑到下限(没东西可说也得凑 5 条)。而且上下限都是软约束——L2 有 20 份连 5 条都没写够。

5.2 长度上限被 prompt-tune 丢掉了

官方默认的报告提示词里有这么一句(出现 2 处):

Limit the total report length to {max_report_length} words.

而 prompt-tune 会整篇重写提示词。我实测了它产出的三份报告提示词:max_report_length 出现 0 次——占位符只剩 {input_text}。运行时 .format() 不会报错(多余的关键字参数被忽略),所以既没有长度上限,也没有任何提示。

实测结果:最长的一份根层报告 4,169 token,远超"2000 词"的原意。

所以这批报告的实际形态是:下限由"至少 5 条"撑着,上限无人管。


6. 那什么语料能喝下这套默认配置

把上面所有东西合成一条判据:

根层报告总量 < 原文 ⟺ 根社区数 × 平均报告长度 < 源 token

而"平均报告长度"有个地板(在我这份语料上约 1.4k token),所以真正的主控量是"每根社区能分到多少源材料" 。它又可以被拆成两个因子的乘积:

每根社区材料 = (源 token ÷ 实体数) × (每根社区平均实体数) = 抽取粒度 × 聚类粒度

拿实测数据验一下(两组都对得上,误差约 12%,来自块之间的重叠):

语料token/实体每根社区实体数相乘实测每根材料
51 篇英文新闻39.574.22,9313,308
14 篇技术笔记22.193.32,0622,315

于是这几组语料的对照就有了解释:

语料每根社区覆盖根层报告 ÷ 原文
论文 · 播客29,8422.6% ✓
论文 · 新闻31,0492.3% ✓
我们 · 同源新闻子集(51 篇 / 12.2 万 token,论文那份的 9%)3,30838.0%
我们 · 14 篇技术笔记(10.9 万 token)2,315126%
我们 · 7 篇小语料(3.2 万 token)1,904129%

两个可以立刻用的结论:

  1. 适合默认配置的语料是"长、松散、叙事型" (新闻 / 播客 / 会议记录)——每个社区能分到的材料多;不适合的是"精炼、概念密度高"的(技术笔记 / 论文 / 规格文档)——实体密、社区多、每社区材料少,必然撞地板。
  2. 换语料类型能改善,但改变不了量级。 我把语料换成同规模的英文新闻(就是上面那份论文同源子集)后,每根社区材料从 2,315 涨到 3,308(+43%),根层压比从 126% 降到 38%(改善 3.3 倍)——但离论文的 2.6% 还差 15 倍。原因是根社区数几乎不随语料量涨(论文 34 个根 / 101 万 token,我们 37 个根 / 12.2 万 token),要让每根社区材料涨到 3 万 token,得把语料喂到约 110 万 token。

⚠️ 一个陷阱:这条判据里没有"语料总量"这一项。它看起来像主控变量,其实只是背景——真正起作用的是上面那个"抽取粒度 × 聚类粒度"。12 万 token 这个量级,换什么语料类型都进不了论文那个区间。


7. 小结:三句话

  1. 报告比原文长,不是因为模型啰嗦,而是三个乘数叠出来的:社区被硬拆到碎(>10 实体就继续拆)+ 同一片原文被 10 个社区各写一遍 + 每份报告有约 1.4k token 的长度地板。
  2. 地板是提示词里"至少 5 条 findings、每条多段、要全面"造出来的,而本该平衡它的长度上限被 prompt-tune 静默丢掉了。所以现在的形态是"下限有人管、上限没人管"。
  3. 能不能省 token,取决于"每根社区分到的材料 ÷ 报告地板"这个比值——而它 = 抽取粒度 × 聚类粒度 ÷ 地板。先算这个比值,再决定要不要用这套方案,比跑完一遍再发现报告比原文还长要便宜得多。

下一篇:那 2.6% 到底是在什么条件下成立的

这篇量的是"报告有多长"。但还有一个更靠前的问题:论文那个 2.6%,是在什么前提下测出来的? 我做了个对照实验——同一份语料,只把抽取提示词从"6 个域的通用新闻"换成"技术 + 体育"两个域,根层压比就从 38% 变到 27% 。也就是说,这个数字更像是"你目标的函数",而不是"你的语料的函数"。下一篇把这条链讲完,包括它对"图到底值不值得用"意味着什么。


附:核对方式

  • 版本:graphrag 3.2.0(Python 3.11),DeepSeek deepseek-chat 作补全模型,索引分块用默认配置(1200 token / 100 重叠)
  • 语料:① 14 篇中文技术笔记(108,786 token);② 7 篇小语料(32,363 token);③ 论文 News 语料的同源子集:51 篇英文新闻(122,387 token,固定种子 42 分层抽样;全量按本文 tokenizer 计 1,381,153 token,取到约 9%)
  • 论文与数据集(📎):原论文 From Local to Global §4 数据集说明(其 News 数据集取自 Tang & Yang, 2024);MultiHop-RAG(Tang, Y. & Yang, Y., 2024)
  • 主要代码/提示词位置(📄):prompts/index/community_report.py(findings 规格与长度上限)、index/operations/summarize_communities/graph_context/context_builder.py(上下文构建与超限判断)、config/defaults.py(ClusterGraphDefaults.max_cluster_size = 10、CommunityReportDefaults)
  • 本地统计脚本(都在开源仓库):_explain_l2_blowup.py(块级复用与分桶)、_l2_size_mix.py(按材料块数分桶)、_level_cost_table.py(分层成本)、_l2_findings.py(findings 分布)、_diag_reports.py(上下文超限复算)、_compare_roots.py(跨语料根社区对照);1.1 的两个:_probe_unsplittable.py(合成图验证"拆不动")、_check_unsplittable.py(三份语料里数"胖叶子")
  • 本文的完整实验记录与代码:github.com/lipeidonggz…

如果你的语料上也算过"根层报告 ÷ 原文"这个比值,欢迎贴出来——我手上三组都在 100% 以上,很想看看有没有人跑进论文那个区间,以及需要多长的语料。