30 秒判断:你正打算把 GraphRAG 用到自己的语料上,或者已经在跑、却总觉得"结果怎么跟预期不太一样"——这篇把索引这一侧从头到尾讲清楚,附 8 轮小语料实测数据。
一句话结论:索引是一条十步流水线,但真正决定质量和成本的只有三步(切块、抽图、写报告)。它最坑的地方不是算得差,是大部分失败根本不报错——报告少了几份、一批实体被悄悄踢出社区,指标却全是绿的。
适合谁读:在搭 RAG / 文档问答 / 知识图谱的同学。不用读过 GraphRAG 论文,知道 embedding 和文本分块就够了。
阅读成本:约 20 分钟。1–2 节是地图,3–4 节可以照着直接跑起来和验收,5 节信息密度最高(参数挂在哪一步、哪些闸门不打招呼),7 节是原始数据。
数字都标了出处:✅ 我自己跑出来的 / 📄 读 graphrag 3.2.0 源码 / 🤔 我的判断。
0. 为什么值得只盯着索引看
平时聊 RAG,注意力基本都在检索和生成上。但 GraphRAG 这条路线有点特别——分量几乎全在索引:它不像普通 RAG 那样只把文档切块、算向量,而是先让 LLM 把整批语料读成一张实体关系图,再把图切成社区、给每个社区写一份报告,最后才轮到检索去用。
代价是索引很贵(要调很多次 LLM),周期也长。所以真正值得先弄清楚的是三件事:
- 它到底产出什么,这些东西后面谁在用;
- 这条流水线上有哪几个环节可以配、参数挂在哪;
- 它会在哪里悄悄出错——这一条尤其重要,因为它不太会报错。
所以我拿两篇万把 token 的文章、8 轮不同的提示词配置,把它从头跑到社区报告,每一步都对着 graphrag 3.2.0 的源码核了一遍。下面说的,都是这一轮里真跑出来或真读到的。
先交代下语料(后面提到就用括号里的简称):
- Anthropic 篇:How we contain Claude across products,英文,约 2.9 万字符,讲 Claude 三个产品怎么用沙箱、虚拟机、出网管控把 agent 的"爆炸半径"框住。
- 判官篇:判官不稳定时,先别换判官——一次 LLM-as-Judge 的完整排查实录,中文,约 2.7 万字符,就是我自己之前那篇。
两篇都很小。小语料足以看清"产出形状"和"流程机制",但代表不了真实知识库的规模——凡是跟规模强相关的数字(成本、耗时、社区粒度),请只当量级参考。
1. 地图:一次索引到底产出什么
输入很简单:一个 input/ 目录,丢几个 .txt 进去就能跑(文档标题默认取文件名)。一个文件也能跑。
产出是几张 parquet 表,角色差别挺大。先把这几张表分清,后面大半的困惑都不会出现:
| 表 | 一行是什么 | 在系统里的角色 | 被谁消费 |
|---|---|---|---|
text_units | 一个文本块(chunk) | 事实源——所有引用最终落到这里 | 向量检索、局部检索、报告生成 |
entities | 一个实体 | 结构:图的节点 | 社区聚类、检索、报告生成 |
relationships | 两个实体间的一条关系 | 结构:图的边 | 同上 |
communities | 一个社区(含层级、父子、成员) | 索引层的组织方式(不是事实) | 报告生成、查询时选社区 |
community_reports | 一个社区一份 LLM 写的报告 | 给 LLM 读的材料(不是给机器查的) | 全局检索的 map-reduce |
documents | 一篇文档 | 辅助(标题 / 全文 / 时间) | 切块与溯源 |
一句话记住分工:chunk 是"事实",图和社区是"导航",报告是"检索时给模型看的摘要"。
1.1 这里的"图",和 RDF 那种知识图谱不是一回事
名字里都有"图谱",容易让人以为它跟 RDF / 本体式知识图谱是一类东西。实际差得挺远,差别集中体现在"一条边怎么表示" :
| RDF / 本体式知识图谱 | GraphRAG 的 relationships 表 | |
|---|---|---|
| 一条边长什么样 | 三元组(主语,谓词,宾语),如 Claude foaf:knows X | (source,target,description)——根本没有谓词列 |
| 谓词从哪来 | 受控词表 / 本体,可枚举、可推理、可约束方向 | 没有词表。"这是哪种关系"只体现在那句自然语言描述里 |
| 同一对节点多条关系 | 可以(不同谓词各一条) | 合并成一条:按 (source, target) 分组,描述接成一段、weight 相加 📄 |
| 边上额外的字段 | 一般只有谓词 | description、weight(LLM 打的关系强度)、combined_degree(两端度数之和)、text_unit_ids(证据块) |
| 方向 | 由本体定义 | 由抽取时的措辞决定,没有约束保证(方向错了,这条边就指向了错的 chunk) |
| 性质 | 断言(说它是事实) | 索引——事实在 chunk 里,边靠 text_unit_ids 指回去 |
拿我自己跑出来的两条边看最清楚:
source: 判官 target: LLM-AS-JUDGE
description: “判官”(LLM-AS-JUDGE)是 LLM-as-Judge 评测方法的核心角色,其名称即该方法的简称……
weight: 18.0 combined_degree: 32 text_unit_ids: 2 个块
source: FLIP-RATE target: JUDGE CONSISTENCY
description: flip-rate 是 judge consistency 的操作化定义
weight: 7.0 combined_degree: 7 text_unit_ids: 1 个块
这两条边的语义完全不同——一条说"是同义简称",一条说"是操作化定义"——但这个差别只存在于那句话里,表里没有任何字段能区分它们。在 RDF 里,"把某类关系全查出来"是一条 SPARQL;在这里你只能对描述文本做模糊匹配。
还有一个反直觉的实测结果:这一轮 120 条边里,没有任何一对 (source, target) 出现两次。多块文本提到同一对实体,是被合并成一条、强度相加的(有 14 条边的证据块不止一个)。也就是说, "同一对实体之间存在多种关系"这件事,在这个数据模型里表达不出来。
所以如果你要的是"可枚举、可推理、按关系类型查"的图,GraphRAG 的产物不能直接当知识图谱用——它更像「带证据锚点的话题导航结构」。
2. 完整流水线:十步,各自在决定什么
官方 --method standard 就是下面这十步。其中真正写向量库的只有第十步(这一点很容易看错,见 2.3):
| # | 步骤 | 它在决定什么 | 贵不贵 / 会不会静默出错 |
|---|---|---|---|
| 1 | load_input_documents | 读 input/ 下的文档 → documents 表(标题默认=文件名) | 不花钱;多文件源只取第一个文件是已知限制 📄 |
| 2 | create_base_text_units | 切块 → text_units 表。chunk 是后续一切引用的落点 | 不花钱(纯本地) |
| 3 | create_final_documents | 整理出对外版 documents 表 | 不花钱 |
| 4 | extract_graph | 抽实体与关系——范围由实体类型表(靶子)+ 提示词决定 | 最贵(每块 1+gleanings 次调用,默认 2 次) |
| 5 | finalize_graph | 合并与裁剪:丢掉"引用了不存在实体"的边 | 不花钱;会静默丢(只打 warning) |
| 6 | extract_covariates | 抽 claim(关于某个实体的、可能带时间限定的断言) | 默认关闭 📄 |
| 7 | create_communities | 层次聚类成一片社区森林 → communities 表 | 不花钱;use_lcc 会静默排除一部分实体(见 5.1) |
| 8 | create_final_text_units | 给每个 chunk 挂上反向引用(这块里出了哪些实体/关系/claim),就地重写 text_units 表 | 不花钱;与 embedding 无关(见 2.3) |
| 9 | create_community_reports | 为每个社区写一份报告 | 较贵(每社区一次);失败静默(见第 4 节) |
| 10 | generate_text_embeddings | 算向量并写向量库——唯一的 embedding 步骤 | 按 token 计费 |
2.1 "社区树"其实是森林
一个容易讲错的地方:官方文档和很多介绍里说"把图切成社区树",听起来像一棵树。实际是一片森林——算法(层次 Leiden)在整张图上一次划出若干互不相交的顶层社区,每个顶层社区再各自往下分层。
证据就是 level = 0 的行不止一个:它们是各棵树的根(parent 为 −1)。✅ 实测根节点数:判官篇 8 个、Anthropic 篇 9 个、两篇混跑 14 个。
顺带一个更有意思的观察:这些根的"深浅"并不一样。判官篇 8 个根里,4 个一个子社区都没有(它的最细粒度就是顶层),另 4 个往下分了 3–4 个子社区。
这个不均匀对查询体验的影响挺直接:按"层级"去指定粒度时,不同顶层社区在同一个层号上代表的东西,大小能差很多。这也是我后来觉得"让用户手填一个 community level"不是个好设计的原因。
2.2 官方三种套餐,以及全部 22 个可配环节
流水线不是写死的程序,而是一份"只跑我要的步骤"的清单(配置项 workflows)。官方内置三种组合:
| 套餐 | 组成 | 什么时候用 |
|---|---|---|
--method standard | 上面那十步(LLM 抽图 + 图上下文写报告) | 默认;质量优先 |
--method fast | 把"LLM 抽图"换成句法抽图(extract_graph_nlp + prune_graph),报告也换成用文本块原文做上下文 | 不调 LLM 抽图,便宜快;用质量换成本 |
--method standard-update / fast-update | 上面两种 + 八个 update_* 步骤 | 增量索引 |
自己拼的话,可选单步一共 22 个(📄 index/workflows/,graphrag 3.2.0):
| 环节 | 它做什么 |
|---|---|
load_input_documents / load_update_documents | 读全量输入 / 读增量输入 |
create_base_text_units / create_final_text_units | 切块 / 给块补反向引用 |
create_final_documents | 整理对外版文档表 |
extract_graph / extract_graph_nlp | LLM 抽实体与关系 / 句法抽图(不调 LLM) |
prune_graph / finalize_graph | 剪掉低频节点 / 合并与裁剪 |
extract_covariates | 抽 claim |
create_communities | 层次聚类成社区森林 |
create_community_reports / create_community_reports_text | 写社区报告(图上下文 / 文本块原文上下文) |
generate_text_embeddings | 算向量、写向量库 |
update_*(8 个) | 增量链:文档 / 实体关系 / 文本块 / claim / 社区 / 报告 / 向量 / 状态清理 |
2.3 embedding 只发生在第十步(这里最容易看错)
我一开始以为"第八步 create_final_text_units 是为 embedding 做准备的",名字实在太像了。读了源码才发现它跟 embedding 一点关系都没有:
- 它只处理 chunk:流式读
text_units,给每一行补上entity_ids/relationship_ids/covariate_ids三个反向引用列,然后就地重写同一张text_units表。被嵌入的text字段一个字符都没动 📄。 - 它排在报告生成(第 9 步)之后,逻辑上也不可能成为前面的前置。
- 它的真实用途在查询与溯源:拿到一个块,能直接知道"这块里出了哪些实体、哪些关系"。
写向量库的只有第十步 generate_text_embeddings,而且它不只嵌 chunk,默认嵌三样(配置项 embed_text.names):
| 向量 | 取自哪张表 / 哪个字段 | 查询时谁在用它 |
|---|---|---|
text_unit_text | text_units.text——每个 chunk 的原文 | 基础检索的"块向量 top-k" |
entity_description | entities 的标题拼描述(title:description) | 局部检索找入口实体 |
community_full_content | community_reports.full_content——每份报告的全文 | 查询时定位相关社区 |
几个实操细节:
- 默认嵌入模型
text-embedding-3-large(3072 维),默认向量库 LanceDB 📄; - 报告全文那一份通常最贵——报告本身就不短(我测到的中位数在 2–3 千字符量级);
- 有一条护栏:某个字段要嵌、但它的源表不在产出里(比如没跑报告那步),它就 warning 一句然后跳过,不会崩 📄。
所以"跳过 embedding"只在一种场景下成立:这次只想看索引产出、不打算查。跳过之后产出里没有向量库,将来想查还得补跑。
2.4 最小可跑集合:六步,以及为什么少不了谁
从十步里摘掉四个(第十步 embedding、第八步反向引用、第三步对外版文档、第六步 claim——后者默认本来就不跑),剩下的就是能一路跑出社区报告的最小链,六步:
load_input_documents → create_base_text_units → extract_graph
→ finalize_graph → create_communities → create_community_reports
为什么是这六步——不是随手删的,是按"下一环读什么"倒推出来的(📄 看每个 workflow 打开哪张表就明白了):
| 步骤 | 它读什么 | 它写什么 | 少了它会怎样 |
|---|---|---|---|
load_input_documents | input/ | documents | 没有文档,切块没有原料 |
create_base_text_units | documents | text_units | 抽图没有输入 |
extract_graph | text_units | entities + relationships | 没有图,聚类无从谈起 |
finalize_graph | entities + relationships | 清洗后的同一对表 | 后面会拿到"指向不存在实体"的边 |
create_communities | entities + relationships | communities | 没有社区,报告无从谈起 |
create_community_reports | communities + entities + relationships | community_reports | ——(终点) |
砍得掉的三类,都是收尾或只服务查询期的步骤,不在主线上:
- 第十步 embedding——只服务查询期;
- 第八步
create_final_text_units——只是给 chunk 补反向引用,embedding 不依赖它; - 第三步
create_final_documents——报告生成并不读 documents(create_community_reports只打开 communities / entities / relationships 三张表 📄)。
反过来说:这六步里没有一步是"可选"的,每一环的输出正好是下一环的输入。
3. 跑起来:一份最小配置 + 两个必知坑
3.1 最小配置
completion_models:
default_completion_model:
model_provider: <provider> # LiteLLM 前缀,如 deepseek / openai
model: <model>
auth_method: api_key
api_key: ${YOUR_API_KEY} # 从环境变量注入
call_args: { timeout: 180, num_retries: 2 } # 不设超时会卡死(我卡过 8 分钟)
concurrent_requests: 4 # 默认 25 容易把 API 打急
workflows: [ load_input_documents, create_base_text_units, extract_graph,
finalize_graph, create_communities, create_community_reports ]
cache: { type: json, storage: { type: file, base_dir: cache } }
reporting: { type: file, base_dir: logs }
注意这里故意没有配 embedding_models,理由见下一条。
3.2 --skip-validation 跳过的是什么(它不是跳过步骤)
graphrag index --root . --skip-validation
--skip-validation 这个参数它跳的是预检,跳的不是任何工作流步骤(📄 对应 validate_config_names)。预检做的事是——给配置里每一个模型发一条测试消息:每个对话模型试一次完成调用,每个嵌入模型试一次嵌入调用。
于是就出现了看起来自相矛盾的情况:
- 你没配
embedding_models、也没跑 embedding 步骤 ⇒ 预检确实没得可试,安全; - 你配了
embedding_models但填的是占位值(我早期就是这么干的)⇒ 预检会真去调它,直接 401 失败 ✅; - 所以口径是二选一:要么"不配 embedding + 加
--skip-validation",要么"把 embedding 配真 + 不加这个开关" 。
上面那份最小配置属于第一种,配它的命令就带上这个开关——这不矛盾。
3.3 settings.yaml 里任何字面 $ 都会炸
它用 string.Template 做环境变量替换,连注释里的 $ 也算。我踩了三次:一次是给文件匹配规则写正则的结尾锚,两次是在注释里写出了占位符的完整写法。报错还挺隐晦(Invalid placeholder in string / Environment variable not found),但原因都在这一个字符上。
3.4 先别动提示词和参数
先用默认跑一轮,拿到"默认长什么样"的基线,再改。不然你没法判断改动到底带来了什么——这一点在下面第 7 节的数据里体现得很明显。默认那版的效果确实不怎么样,但没有它,你就不知道差在哪。
4. 跑完先验收:它的失败大多是静默的
这一节是全文我最想让你看的。
跑完之后,别只看有没有报错。我遇到过三种"不报错但结果不对"的情况:
| 静默行为 | 实测表现 | 为什么指标看不见 |
|---|---|---|
| 社区报告没生成 | 判官篇 prompt-tune 那版:64 个社区只出了 52 份(缺 12);Anthropic 篇默认:23 → 20(缺 3);两篇混跑:85 → 74(缺 11) | 失败发生在"LLM 返回 → 解析成 schema"这一步,HTTP 调用是成功的 ⇒ 不进 failed_response_count。指标全绿与丢掉 19% 的报告可以同时存在 ✅ |
| 关系被丢弃 | 日志里一行 Dropped N relationship(s) referencing non-existent entities | 只打 warning,不进任何计数字段 |
| 实体根本没进社区 | 判官篇那一轮:抽出 101 个实体,最终只有 74 个能被任何社区报告覆盖 | 不是失败,是 use_lcc 的设计行为——但没有任何提示(见 5.1) |
验收动作其实很简单:拿集合对账,别看指标。
社区集合 − 报告集合 = 没写出报告的社区
一行代码的事。我把它写成了脚本,每次跑完先看这个差是不是空。
这里的教训值得单独说一句:指标的边界由埋点决定,不由问题决定。 你问的是"有没有丢东西",而指标的埋点在"调用层",失败却发生在"产出层"——两者不是一回事。想判断"有没有漏",就得拿应有集合减实有集合。
5. 调优:先看参数挂在哪一步
5.1 一张表:参数的角色,决定你该测什么
| 属于哪一步 | 参数 | 它的角色 | 因此该测什么 |
|---|---|---|---|
| 切块 | chunking.size(默认 1200 token)/ overlap(默认 100) | 决定"事实源"的粒度与块数 | 抽取精度 × 成本。推荐值会随 LLM 代际过期,不必照搬 🤔 |
| 抽图 | extract_graph.entity_types(默认 organization / person / geo / event)+ 抽取提示词 | 抽取范围的靶子;查询期根本不读 type | 范围与成本,不是分类正确率 |
| 抽图 | max_gleanings(默认 1) | 召回开关:先断言"你漏了很多" ,再让模型补一轮 | 召回收益 vs 硬凑噪音(两者同源) |
| 聚类 | cluster_graph.max_cluster_size(默认 10)/ use_lcc(默认 true) | 决定社区层级;use_lcc 决定哪些实体根本不进社区 | 覆盖面(见 5.2) |
| 写报告 | community_reports.graph_prompt / max_length / max_input_length | 报告的语言与口径、输出长度、上下文预算 | 可读性 / 信息量 vs token |
| 嵌入 | embed_text.names / embedding_models / vector_store | 嵌哪三样、用哪个模型、存哪个库 | 查询召回 vs 索引成本 |
| 全流程 | workflows | 决定跑哪些步骤 | 成本 / 耗时最直接的开关 |
调参方法上,我只有三条朴素的经验:
- 固定其他旋钮,只扫一个——chunk 大小、gleanings、类型表,最后全都落到"抽取范围"上,一起动就说不清是谁的功劳;
- 用一份"抽取范围"小金标准对账:必须抽到的命中了几条、明显不该进来的进来了几条。不需要逐条完美,否则维护成本会失控;
- 别照抄别人的推荐值——这些参数跟所用的 LLM 强绑定,换模型就得重标 🤔。
顺便说一个关于 entity_types 的判断:它在 GraphRAG 里的角色只是抽取提示词的参数,查询期完全不读(我核过:66 个 .py 文件里 entity.type 零命中)。所以分类框得粗、偶尔归错,都不算缺陷;但如果哪天你要拿它做过滤或路由,归错就变成漏召回,分类正确率立刻升级成硬要求。
这条我吃过一次亏:我一度把"类型桶的分布"当成质量指标去分析,还觉得自己发现了问题。其实那个字段在系统里的职责就不是"分类",用分类正确率去要求它,是用错了尺子。
5.2 use_lcc:决定"哪些实体根本不进社区"的静默闸门
这个配置我建议你专门看一眼:它不报错,但会安静地改变你的覆盖面。
use_lcc 是 largest connected component(最大连通片)的开关,默认 true。它的行为写在 graphs/stable_lcc.py 里,一共做三件事 📄:
- 只保留最大连通片上的边——只在关系图上留下那个节点数最多的连通片(连通片 = 在这个无向视角下能互相走到的节点集合),连不到它的节点与边全部丢掉;
- 归一化实体名(HTML 反转义、转大写、去空格),让同一实体的不同写法不至于把图撕开;
- 把每条边的方向按字典序摆平(名字小的当 source)——也就是说,社区划分实际上是在无向图上做的。这一条其实在
cluster_graph里已经无条件先做过一遍,源码注释直接写着Normalize edge direction and deduplicate (undirected graph)📄。
一个容易混的细节:这三件里只有前两件受 use_lcc 控制。关掉它,连通片过滤和实体名归一就都不做了(后者不做,意味着大小写/空格不同的同一实体可能被当成两个,把图撕成更多小片);方向摆平无论如何都会发生。
于是 use_lcc: true(默认)意味着:不在最大连通片上的实体,在聚类之前就被排除了,永远不会属于任何社区,也就永远不会被任何社区报告覆盖。
实测后果(判官篇那一轮):
抽取到实体 101 个
其中出现在关系里的 99 个 (另 2 个一条边都没有)
最大连通片上的节点 74 个 ⇒ 25 个节点在聚类前被丢掉
最终能被社区覆盖的实体 74 个 ⇒ 27 个实体一个社区都进不去
多出来的 27 个实体,就这么无声无息地消失了。 它们是谁?看名字就知道为什么连不上主片:一串虚构地名和人名(白丘国、碧石监狱、苏小满、林柏舟……),加上仓库路径这类"语料里只出现过一次"的字符串——各自三两相连、自成小片。
所以这个闸门是双面的:好处是自动去噪,代价是它不区分"噪声"和"稀但真实的旁支"。
怎么选:
- 想保住覆盖面、宁可多留噪声 ⇒ 设
use_lcc: false,代价是那些孤立小片各自成社区,报告数量和成本一起上涨; - 想要干净、能接受"稀有的东西看不见" ⇒ 保持默认。
关键是知道这道闸门存在,并且它不打招呼。
附带一个自查方法:跑完对一下"实体表行数"和"被任何社区收录的实体数",差多少就是被
use_lcc挡掉的量。
5.3 prompt-tune 应该是调优的第一步,不是可选项
官方默认提示词是给英文通用语料写的。换一篇别的语言、别的领域的语料,产出会明显变形——我第一次跑判官篇(中文),图和报告全变英文,实体类型也还是英文通用那四类。
所以 prompt-tune 不该被当成"可选工具",它是调优的起点:
graphrag prompt-tune --root . --selection-method top --limit 7 --output prompts-tuned
它用你自己的语料反推一份适配的提示词,我这两篇原料一次跑的成本大约 $0.04 ✅。产出六样东西(都落成文件):领域推断、语言检测、persona(角色设定)、实体类型表、few-shot 示例、评分标准(带锚点) ,写成三份提示词:
prompts-tuned/extract_graph.txt # 抽图提示词(含实体类型表与示例)
prompts-tuned/summarize_descriptions.txt # 实体描述摘要提示词
prompts-tuned/community_report_graph.txt # 社区报告提示词(含评分口径)
采样逻辑是:先全量切块(本地、零 token),再按选法抽块(📄 prompt_tune/loader/input.py):
--selection-method | 实现 | 性质 |
|---|---|---|
top | 取前 --limit 块(默认 15) | 系统性偏向文档开头 / 排在前面的那一篇(实测两篇混跑时,示例全取自第一篇) |
random(默认) | 随机抽 --limit 块 | 无偏但样本小,且没有固定种子、不可复现 |
auto | 随机抽 ≤--n-subset-max(默认 300)块去嵌入 → 取离质心最近的 --k(默认 15)块 | 意图是"选最主流 / 最有代表性";实现有缺陷(见下) ;也是唯一会真调 embedding 的选法 |
all | 全部块 | 样本最大、也最贵 |
两处已知实现缺陷(📄 同文件):
auto行号错位:抽样后的块存进了另一个变量,但最后取"离质心最近"时用的仍是原始顺序的整张表、拿位置索引去取 ⇒ 取到的不是离质心最近的那些,实际退化成文档开头那几块;- 块数少于 15 会直接崩:越界时它先把
limit回落成 15,再无条件sample(15)。所以语料很小的时候,默认的random选法反而是最危险的那个。
产出能信到什么程度(✅ 实测):
- 类型与 persona 能跨领域融合:Anthropic 篇(英文·安全)+ 判官篇(中文·评测)一起跑,得到一份 13 类、覆盖两边的类型表;
- 语言只能挑一个:检测到中文就把三份文件都写成中文 ⇒ 英文那一半的描述与报告会被译成中文。对"报告服务于大模型阅读但是不作为可信数据源"的定位来说可以接受,但换语料必须重跑;
- 示例取自语料自身 ⇒ 它调的是"这一篇"的适配,不是"整个库"的适配;
- 评分口径不保证有区分度:见 6.3。
6. 我撞到的几条边界
6.1 实体类型不参与检索
entity.type 在查询期完全不被读取(📄 66 个 .py 文件零命中)。别指望靠它做过滤或路由,它就是个抽取靶子。
6.2 社区报告会缺,而且每次缺的不一样
同一批语料里丢哪几个社区,每次都不一样:判官篇第三版丢 #4,第四版丢 #9 / #18,prompt-tune 那版丢了 12 个。这是调用级的抖动,不是某个社区本身有问题。所以,某次少了一份报告,不用去怀疑某个社区有问题;但也别指望它会主动告诉你。
6.3 报告里的 rank 不保证有区分度
rank 是社区报告里的重要性评分,口径由报告提示词决定。prompt-tune 生成的那版把口径改成"与领域的相关性"之后,判官篇 48/52 份报告的 rank 都 ≥ 8(0–10 量表)✅——评分标准变得"更专业",反而把排序能力吃掉了。
所以想拿 rank 排序或过滤的话,口径得选一个在你这批语料里有方差的维度。挑口径的时候先看一眼分数分布——全挤在一起,就说明这个口径对你没用。
6.4 提示词不能跨语料移植
语言、领域、类型、示例全都被烧进提示词文本里。我把为判官篇调好的提示词原样套到 Anthropic 篇上,结果是:报告 100% 变中文(英文原文被翻了),标题里出现"判官 / 金标准"这类另一篇的主题词,还长出了新的垃圾桶——44% 的实体被归进 TECHNICAL TERM ✅。
6.5 多文件源只处理第一个文件
这是我踩到的另一个静默行为:input/ 里放多文件时,只取第一个文件 📄。不是配置问题,是这一版工作流的既有行为。多文件语料要自己先合并,或按文件拆成多次跑。
7. 八轮实测数据
下面是全部 8 轮的原始数据(语料就前面那两篇,每轮只改提示词或语言设置,其余参数不动)。
规模列=实体 / 关系 / 社区 / 报告数:
| 轮次 | 语料 | 提示词 / 设置 | 规模 | 得到什么 |
|---|---|---|---|---|
| 1 | Anthropic 篇 | 官方默认提示词(英文、四类实体) | 128 / 185 / 23 / 20 | 看清产出形状;发现示例串味;发现静默失败;撞上 provider 的 schema 卡点 |
| 2 | 判官篇 | 官方默认提示词 | 95 / 109 / 16 / 15 | 报告语言:无约束时会回落到英文 |
| 3 | 判官篇 | 只改语言那一句("用英文输出"→"跟随原文语言") | 133 / 153 / 33 / 31 | 语言回来了,但实体名大小写与类型标签还不服帖 |
| 4 | 判官篇 | 再改三处:中文示例 + 去掉"首字母大写"要求 + 加"不要翻译" | 101 / 120 / 21 / 20 | 抽取侧终于服帖:实体描述含中文比例 11%→91% 、关系描述 3%→98% (换成更严的"纯中文、不含拉丁字母"口径则是 52% / 46%);但报告语言仍未稳定(标题 12/20、正文 16/20 含中文) |
| 5 | 判官篇 | 再把报告提示词整篇译成中文(不改语义) | 101 / 120 / 21 / 19 | 报告标题 19/19、正文 19/19 全是中文(此前有整篇英文的报告);报告长度中位 2,749→1,946 字符(顺带省 token)。抽取侧与第 4 轮完全相同,说明这轮只动了报告 |
| 6 | 判官篇 | prompt-tune 自动生成的三份提示词(13 类实体) | 273 / 431 / 64 / 52 | 类型表贴合语料:EVENT 这类垃圾桶从 61% 降到最多 14%;成本 0.178;同时暴露 12/64 的静默丢报告 |
| 7 | Anthropic 篇 | 把第 6 轮为判官篇调好的提示词原样移植过来 | 277 / 381 / 63 / 54 | 报告 100% 中文 + 主题串味 + 新垃圾桶(TECHNICAL TERM 占 44%)⇒ 提示词不能跨语料移植 |
| 8 | 两篇一起 | 两篇混跑 prompt-tune | 368 / 581 / 85 / 74 | 类型 / persona 能跨领域融合;语言仍只能挑一个;示例偏偏全取自第一篇 |
成本量级(供估算,✅ 实测,用的是 DeepSeek 的对话模型;换别家价格与质量都要重估):
| 动作 | 成本 |
|---|---|
| 单篇索引一轮(只到社区报告) | $0.05–0.18 |
prompt-tune 一次 | ≈ $0.04 |
| 两篇混跑一轮(含 74 份报告) | ≈ $0.3 |
读表前先说几点:
- 第 1 轮 vs 第 6 轮:规模从 128/185 涨到 273/431,不是模型变强了,是类型表变贴合了——EVENT 这个垃圾桶被拆开以后,原本被吞掉的实体都出来了;
- 第 6 轮 vs 第 7 轮:同一份提示词换语料,规模不掉,但内容质量掉得很明显(串味 + 新垃圾桶)——"能跑通"和"跑得对"是两件事;
- 第 4 轮 vs 第 5 轮是最能说明问题的一组对照:第 4 轮改的是抽取侧提示词(图变了:描述含中文率 91% / 98%),报告语言并没有跟着变好(标题 12/20、正文 16/20);第 5 轮只改报告侧提示词,图一模一样(规模、中文率都相同),报告立刻变成 19/19 全中文。结论是两条链路的语言各自管自己的,互不代劳;
- 还有一条反过来的观察:报告即使"全中文",正文里仍会保留必要的英文术语——第 4/5 轮报告正文的字符级中文占比中位数约 60% 。这通常不是问题,因为剩下那些英文基本是原文本来就在用的专有名词。
8. 小结:什么时候值得上 GraphRAG
回到最开始那个问题——值不值得。
它的强项是"全局性、跨文档的问题":把一片语料里散落的主题收拢起来做综述、找跨源连接,这类问题向量检索确实给不出(向量检索必然要套 top-k,输出恒为"点集")。
它的代价也很实在,有两条我建议提前想清楚:
- 索引贵(要调很多次 LLM)而且慢。本文这两篇小文章只是"看清形状",真实规模的语料要按千次调用、小时级去估。
- 社区检测是全局优化,增量更新很难做干净。这一版的增量索引本质是"把新内容单独成图、再和旧表拼接"——不重算社区、不重算度数。对一个内容持续更新的知识库来说,这意味着主题会被切成互不相连的两半、同一个主题反复出现、"全面性"看起来变好其实是重复计入。
反过来,如果你手上的语料是一段时间内相对稳定、隔一段时间整体重建一次的(比如一批报告、一批论文、一次尽调),GraphRAG 的这套产出就很合适;如果你的知识库是每天有新内容进来的,那在现有版本下要做的是把"索引刷新周期"和"语料演化速度"对齐,而不是期待它能增量维护。
最后再说一遍那个最实用的动作:跑完先做集合对账,别信指标。 报告少了几份、27 个实体没进社区、关系被丢了一批——这些都不会报错。
附:实验环境与复现
- GraphRAG 版本:
graphrag 3.2.0(Python 3.11) - 对话模型:DeepSeek
deepseek-chat;嵌入模型:默认text-embedding-3-large - 语料:前面那两篇文章,各约 2.7–2.9 万字符
- 完整脚本、每一轮的提示词与产出、以及本文引用的所有审计脚本,都在开源仓库里:github.com/lipeidonggz…(实验数据在
data/tmp/graphrag-exp*下)
如果你也在跑 GraphRAG,欢迎在评论区说说你遇到的静默失败——我很想看看这份清单还缺哪些。