nano-graphrag 从入门到理解 一份写给初学者的教程

前置要求:会一点 Python · 不需要懂 RAG/图算法
Part 1

为什么需要 nano-graphrag

这一部分不写代码。先把问题讲清楚:大模型缺什么、普通 RAG 补了什么、还差什么,最后才引出 GraphRAG。

01

大模型回答不了的两个问题

入门

假设你手里有一批公司内部文档,你想让 AI 帮你回答问题。会遇到两类麻烦:

问题一 · 资料太长,塞不进去 你问大模型(LLM)一个关于公司制度的问题,但制度文件有 200 页。模型的上下文窗口有限,塞不下,而且直接塞进去又贵又慢。那怎么办?——先帮用户把相关的几段找出来,再让模型只读这几段来回答。这就是下一篇要讲的 RAG。
问题二 · 问题需要"串起来"看 看这个具体例子,本文档从头到尾都会用它:

文档 A:「小李在杭州的茶百道工作,负责新茶饮研发。」
文档 B:「茶百道是一家 2008 年成立于成都的奶茶连锁品牌。」

你问:「小李的公司成立于哪一年?」

答案是 2008 年,但必须经过两步:多跳推理——先由「小李的公司」推出是「茶百道」,再由「茶百道」查到「2008 年」。单独任何一段文字里都没有完整答案。如果你的搜索只返回了文档 A,模型就会答"不知道"。
顺带一提 还有第二种问法:「这些文档里的公司整体发展情况如何?」——这需要通读全部资料做归纳,同样不是"找几段"能解决的。后面我们会看到 GraphRAG 对这两类问题各给了一套答案。
本课要点 LLM 有两个短板:①看不见窗口外的资料;②不擅长跨段落串线索。整篇教程要解决的就是这两个短板。
02

检索增强生成(RAG):先查再答

入门

RAG(Retrieval-Augmented Generation,检索增强生成)的思路一句话就能说完:

类比:开卷考试 闭卷考试 = 让模型靠记忆回答(容易编造,且不知道你的私有资料)。
开卷考试 = 允许翻书,但先把最相关的几页翻出来,再基于翻出来的页作答。
RAG 就是"开卷考试"里的翻书动作(检索)+ 作答动作(生成)。

完整流程分两个阶段:

离线准备把资料切成小块
存进检索库
→
在线提问把问题也转成同样形式
找出最相关的几块
→
拼进提示词问题 + 找到的几块
一起交给 LLM
→
得到回答模型基于资料作答
大幅减少编造

被切成的每一小块叫一个 分块(chunk)。为什么按块存,而不是整篇存?因为检索时"粒度"要小——一篇 5000 字的文章里可能只有 200 字和你的问题相关,返回整篇既浪费又会淹没重点。

那么"找出最相关的几块"具体怎么实现?这就引出下一课的向量检索。

本课要点 RAG = 检索 + 生成。资料先切成 chunk 存起来;提问时找出最相关的几块,连同问题一起给 LLM。它解决了"问题一(资料太长)",但还没解决"问题二(多跳推理)"。
03

向量检索:让机器"读懂"语义

入门

怎么判断"哪几块和问题相关"?最朴素的办法是关键词匹配——搜"奶茶"就找含"奶茶"的块。但"开奶茶店"和"经营饮品连锁"没有共同关键词,语义上却是近义的。

Embedding(向量化)解决这个问题:用一个专门的模型把文字变成一串数字(一个坐标点),规则是——意思越接近的文字,坐标离得越近。

类比:地图上的城市 把每段文字想成地图上的一个城市。
「开奶茶店」和「经营饮品连锁」是两座挨得很近的城市;
「光伏板清洗」是十万八千里外的另一座城市。
所谓检索,就是以"问题"这座城为圆心,找最近的几座城。

存这些坐标的地方叫向量数据库(向量库)。查询时:把问题也转成坐标 → 计算距离 → 返回最近的 top-k 块。距离常用余弦相似度衡量。

对应到本项目 nano-graphrag 默认用一个叫 nano-vectordb 的超轻量向量库(作者自己写的),数据就是一个 JSON 文件,开箱即用——初学者不用装任何数据库服务。
本课要点 Embedding 把文字变坐标,向量库按"距离最近"返回 top-k。这一步让检索从"关键词匹配"升级为"语义匹配"。
04

传统 RAG 的三个短板

入门

普通 RAG(切块 → 向量检索)很好用,但有三个天然短板,正好对应第一课的两个问题:

短板用茶百道例子说明本质原因
① 链条断裂 问「小李的公司成立于哪年」,系统只召回文档 A(提到小李和茶百道),答案所在的文档 B 没被召回,模型答不出 2008。 检索是"一步到位"的,不会顺着线索再查第二步。
② 全局失明 问「这批文档里的公司整体情况如何」,只能返回 top-10 个碎片,谁也代表不了全貌。 top-k 天然只给局部,无法回答"整体怎么样"。
③ 信息孤岛 同一个实体「茶百道」在 5 个文档里各有一段描述,检索时可能只命中其中 1 段,其他 4 段白白浪费。 块与块之间是割裂的,没有"这些块说的是同一件事"的概念。
本课要点 多跳问题答不了、全局问题答不了、同实体信息不聚合——三者的共同根源是"没有建模事物之间的关系"。
05

知识图谱与 GraphRAG 思路

入门

知识图谱(Knowledge Graph)就是上一课说的那张"网":

  • 节点(Node)= 实体:人、公司、地点、事件…… 如「小李」「茶百道」「杭州」
  • 边(Edge)= 关系:两个实体之间有联系。如 小李 —就职于→ 茶百道
  • 每个节点/边上还可以挂一段描述文字和一个权重(关系多强)

用例子画出来是这样:

[小李] ──就职于(权重8)──▶ [茶百道] ──成立于2008──▶ [成都]
  │                         │
  └──位于──[杭州]        ──总部位于──[成都]

问题「小李的公司成立于哪年?」在图上就是走两步:
   小李 ──就职于──▶ 茶百道 ──成立于──▶ 2008   ✔ 两个块的信息被边"串"起来了

GraphRAG 的完整思路(微软 2024 年提出,nano-graphrag 是它的极简实现)分两个阶段:

阶段一 · 建索引读完所有文档
让 LLM 抽出实体关系
拼成知识图谱
+
阶段一 · 写报告把图分成若干"社区"
给每个社区生成
一份摘要报告
=
阶段二 · 查询沿着图找线索(治①③)
读社区报告(治②)

三个短板与解法一一对应:

短板GraphRAG 的解法
链条断裂答案相关的实体和关系被边连着,检索时沿着边走一步、两步,把断开的链条接上
全局失明提前为每个社区写好摘要报告,查询时读报告而不是读碎片
信息孤岛同一实体在图里是同一个节点,所有文档里的描述都合并挂在这个节点上
本课要点 · 第一部分收尾 GraphRAG = 先用 LLM 把语料"读薄"成知识图谱 + 社区报告,再基于图回答问题。nano-graphrag 就是这条路线约 1100 行代码的最小完整版。接下来五课先看全景,再逐站拆解。
Part 2

先看全景:一次写入、一次查询

先不纠结细节,把整个系统的"输入 → 中间产物 → 输出"走一遍,建立地图感。后面第三、四部分再逐站深入。

06

insert:把文章变成知识

入门

使用这个库只有两个动作,这是第一个。你写代码时它长这样:

from nano_graphrag import GraphRAG

graph = GraphRAG(working_dir="./my_kg")
graph.insert("小李在杭州的茶百道工作……")   # 把文档喂进去

这一行 insert 背后发生了 6 件事,每件都在磁盘 ./my_kg/ 留下产物:

① 切块:长文按 1200 token 切成小块,每块算一个 MD5 编号(重复内容自动跳过)产物:kv_store_text_chunks.json
↓
② 抽取:把每块喂给 LLM,让它输出这段里的实体和关系LLM 调用 · 最花钱的一步
↓
③ 合并:多篇文档提到的同一实体合并成一个节点,描述拼接(太长就让 LLM 压缩)产物:graph_chunk_entity_relation.graphml
↓
④ 分组:用聚类算法把图分成一个个"社区"(互相紧密的一簇节点)每个节点记下自己属于哪个社区
↓
⑤ 报告:给每个社区让 LLM 写一份摘要报告(含 0-10 评分)产物:kv_store_community_reports.json
↓
⑥ 索引:把所有实体名+描述向量化,存入向量库产物:vdb_entities.json
提示 你不需要记住这 6 步的细节——但要记住它们的顺序:切块 → 抽取 → 合并 → 分组 → 报告 → 索引。第三部分(第 9~13 课)就是按这个顺序逐个展开的。
本课要点 insert 是离线的"读书"过程:文字 → 实体关系图 → 社区 → 社区报告 → 向量索引。所有产物都落在一个工作目录里,删掉目录 = 全部重来。
07

query:三种提问方式

入门

第二个动作是提问,通过 QueryParam(mode=...) 选三种模式之一:

from nano_graphrag import QueryParam

graph.query("小李的公司成立于哪一年?", QueryParam(mode="local"))   # 问细节
graph.query("这批公司整体发展如何?",     QueryParam(mode="global"))  # 问全局
graph.query("茶百道总部在哪?",           QueryParam(mode="naive"))   # 翻原文
模式适用提问直觉类比数据来源
local
本地/局部检索
关于某个实体的具体事实,需要串线索 顺着人脉查:先找到当事人,再问他的同事、他的事迹、相关的会议纪要 向量库找回实体 → 沿图扩展 → 报告+原文
global
全局检索
整体情况、主题归纳、跨社区的大问题 开总结大会:每个小组(社区)先交一份发言稿,再把所有发言稿汇总成最终报告 全部社区报告的 Map-Reduce
naive
朴素 RAG
简单事实、或想对比"不用图"的效果 直接翻书:不查人脉不看报告,向量搜原文照读 分块向量库 top-k 原文
为什么要有 naive? 它是对照组。同一语料下,local/global 比 naive 好多少,就是"建图"带来的价值。做实验、写报告时非常有用——这也是 nano-graphrag 特意保留它的原因。
本课要点 local 问细节(沿图找)、global 问全局(读报告)、naive 兜底对照(翻原文)。第四部分(第 14~16 课)逐一拆解。
08

项目文件地图:每个文件管什么

入门

clone 下来的项目长这样(只看核心目录 nano_graphrag/):

文件用大白话说它干嘛的何时会细读
graphrag.py总指挥:定义所有配置项,串起 insert/query 的每一步第一站(第 6、7 课的流程都在这)
_op.py加工厂:切块、抽取、合并、写报告、三种查询的全部算法第三、四部分的主体
base.py零件接口图:定义"一个存储零件必须有哪些方法"第 19 课
_storage/仓库:接口的具体实现(JSON 文件版、图数据库版……)第 19 课
_llm.py话务员:怎么调 OpenAI/Azure 等接口,怎么重试、怎么缓存第 18 课
_utils.py工具抽屉:数 token、算 MD5、限并发、容错解析 JSON 等小工具第 17、18 课
prompt.py话术本:所有发给 LLM 的提示词都存在这一个字典里第 10、13 课
_splitter.py按标点符号切块的备选切法第 9 课顺带看
本课要点 记住三个主干文件的分工:graphrag.py 指挥、_op.py 干活、_storage/ 存东西。接下来我们按 insert 的顺序走进 _op.py。
Part 3

写入流程逐步拆解

对应 insert 的第 ②~⑥ 步(第 9~13 课)。每课结构固定:做什么 → 为什么 → 例子走一遍 → 对应源码在哪。

09

第一步:切块与去重

基础

做什么

把文档切成小块(chunk)。默认策略是按 token 数滑动窗口硬切:每块最多 1200 个 token,相邻块重叠 100 个 token。

为什么要重叠? 想象把一本书每 20 页装订一份复印件。如果切口正好落在一句话中间,这句话两边的块都"看不全"它。留 100 token 重叠,等于每个切口附近都有两份拷贝,关键信息不至于被切丢。
例子走一遍 文档 A(约 100 token)完整装进一个块,块编号 = chunk-3f2a…(内容的 MD5)。
如果 5 天后你又 insert 了一次完全相同的文档 A → 系统算出的编号一模一样 → 查库发现已存在 → 直接跳过,不花一分钱 LLM 调用。这就是"幂等去重"。

为什么要 MD5 编号?

MD5 是把任意内容压缩成固定长度指纹的函数:内容相同 → 指纹必然相同;内容差一个字 → 指纹完全不同。用指纹当主键,去重、增量更新、"只处理新内容"全都免费获得了。

两种切法

策略做法特点
chunking_by_token_size
默认
无视语义,每 1200 token 一刀简单可预测,什么语料都能用
chunking_by_seperators先按段落→换行→中英文句号→空格的优先级切开,再拼到 1200 以内切口落在自然边界,句子完整,对后续 LLM 抽取更友好

对应源码

_op.py:31 chunking_by_token_size · _op.py:94 get_chunks · _utils.py:186 compute_mdhash_id

# _op.py 滑动窗口,就 3 行核心逻辑:
for start in range(0, len(tokens), max_token_size - overlap_token_size):
    chunk_token.append(tokens[start : start + max_token_size])   # 每次前进 1100,取 1200
本课要点 切块 = 定检索粒度;重叠 = 防止信息被切丢;MD5 编号 = 内容寻址去重。这三件事是后面一切"增量、省钱"的地基。
10

第二步:让 LLM 圈出实体

基础

做什么

把每个块发给 LLM,让它按照固定话术,输出这段文字里的实体(是什么)和关系(谁和谁怎么样)。这是整个系统最花钱的一步,所有块并发进行(同时发很多请求)。

发给 LLM 的提示词长什么样

提示词存在 prompt.py,简化后核心是这样:

识别文中所有实体,格式:("entity" <|> 实体名 <|> 类型 <|> 描述)
识别实体间关系,格式:("relationship" <|> 源 <|> 目标 <|> 描述 <|> 强度)
用 ## 分隔每条记录,全部完成后输出 <|COMPLETE|>

--- 示例(few-shot)---
("entity" <|> "Alex" <|> "person" <|> "Alex 是个观察力敏锐的角色……")##
("relationship" <|> "Alex" <|> "Taylor" <|> "Alex 受 Taylor 影响" <|> 7)

--- 真实数据 ---
Text: {你切好的那个块}
例子:块内容与返回 输入块:「小李在杭州的茶百道工作,负责新茶饮研发。」

LLM 返回:
("entity" <|> "小李" <|> "person" <|> "小李是茶百道的研发人员")
("entity" <|> "茶百道" <|> "organization" <|> "茶百道是小李就职的公司")
("entity" <|> "杭州" <|> "geo" <|> "杭州是小李工作的城市")
("relationship" <|> "小李" <|> "茶百道" <|> "小李就职于茶百道" <|> 8)
为什么不用 JSON?用这套奇怪的 <|> 分隔符? JSON 对"手写"它很不友好:模型写到一半被截断,或漏一个逗号,整个结构就废了,只能整段丢弃。
而这种分隔符协议非常皮实:## 切成一条条记录,<|> 切开一条记录的字段——某一条坏了只丢那一条,解析用一个正则就够。对能力较弱的开源模型尤其友好。

Gleaning:追问"还有漏的吗?"

第一次抽取后,系统会再发一句 "MANY entities were missed in the last extraction. Add them below…"(上次漏了很多,补上),让模型补充;再问一句"还有吗?YES/NO"决定是否继续。这叫 gleaning(打谷扬场式的复查),就像写完作文后再检查一遍错别字。entity_extract_max_gleaning 控制最多复查几轮(默认 1 轮)。

本课要点 抽取 = LLM 按固定格式吐出实体/关系记录;分隔符协议比 JSON 抗造;gleaning 是复查补漏;此步并发执行、由 best_model(较强的模型)承担。
11

第三步:合并成一张图

基础

做什么

每个块各自抽出了实体,现在要把全库结果汇成一张图。核心问题是:10 个块都提到了"茶百道",图里应该有 10 个节点还是 1 个?——1 个。同名实体合并为同一节点。

合并时三个字段怎么处理

字段合并策略为什么这么设计
类型 entity_type投票:谁票多算谁
(5 次说 organization,1 次说 company → 取 organization)
不同块对同一实体的判断可能不一致,众数最稳
描述 description去重后字典序拼接,用 <SEP> 连起来每块贡献的信息都要保留,字典序保证结果确定
来源 source_id并集拼接:记录"这个节点被哪些块提到"后面查询时靠它反查原文(第 14 课会用到)

边(关系)合并同理,两点差异:

  • 权重累加:两个实体在不同块里反复共同出现 → weight 相加,说明关系越铁
  • 图是无向的:(A,B) 和 (B,A) 是同一条边,聚合前统一按字母序排好

合并完成后,每个实体(名称+描述)会被向量化存进实体向量库——这就是第 6 课流程里的最后一步⑥,local 查询正是靠它找回相关实体的。

例子走完后的图 文档 A+B 都处理完,图里是:
[小李] —就职于(8)→ [茶百道] —成立于(?)→ [成都],[茶百道] 旁边挂"总部:成都",[小李] 旁边挂"工作地:杭州"。
跨文档的链条已经连上了——这正是第 4 课病历里"链条断裂"的治愈现场。

_op.py:182 _merge_nodes_then_upsert · _op.py:230 _merge_edges_then_upsert · _op.py:111 描述超长摘要

12

第四步:社区——给图分组

进阶

做什么

图建好后,可能有成千上万个节点。把互相连接紧密的一簇节点划成一组,一个组就叫一个社区(community)。这一步由聚类算法完成,nano-graphrag 用的是 Leiden 算法。

类比:社交圈 你的微信好友几千人,但自然分成一个个圈子:大学室友圈、公司同事圈、家人圈。圈子内部联系密,圈子之间联系疏——这正是社区发现算法在图上做的事。Leiden 就是"自动画圈"的算法,且质量高、速度快(它也是微软 GraphRAG 用的算法,保持了方法论一致)。

为什么要"层级"?

圈子可以套圈子:「茶百道-研发圈」内部又分「小李-小王-小张」小组。Leiden 输出的就是多层结果(level 0、1、2……):层数字越小圈越大(level 0 ≈ 全公司大部门,level 2 ≈ 项目小组)。同一节点可以同时属于多个层的社区。

level 0:  [====== 一个大社区(100 人)======]
level 1:     [== 社区A(30人)==]   [== 社区B(40人)==]
level 2:        [小组A1] [小组A2]      [小组B1] [小组B2]

代码里每个节点存一份归属记录:
"clusters": [{"level":0,"cluster":5}, {"level":1,"cluster":12}, ...]

occurrence(出现度)是什么?

每个社区会算一个 0~1 的值:该社区覆盖的文本块数 ÷ 全库最大社区的文本块数。直觉上,覆盖原文越多的社区,越"有料"——后面查询排序时会用到它。

本课要点 社区 = 图上的"圈子";Leiden 自动画圈;圈分层级(大圈套小圈);occurrence 衡量圈子覆盖了多少原文。为什么要画圈?——为了下一课给每个圈子写一份报告,这是解决"全局失明"的关键。

_storage/gdb_networkx.py:230 _leiden_clustering · _storage/gdb_networkx.py:170 community_schema

13

第五步:给社区写报告

进阶

做什么 & 为什么

对每个社区,把它内部的实体、关系打包,让 LLM 写一份结构化摘要报告。这一步是在为"全局问题"预存答案:

类比:公司的年报 老板不可能亲自读完全公司 1 万封邮件(=全局问题没法靠翻碎片解决)。于是每个部门写一份部门年报,老板只读几十份年报就掌握全局。社区报告就是"部门年报",写年报的成本花在建索引时,查询时免费复用。

报告里有什么

LLM 被要求返回一个 JSON(提示词在 prompt.py 的 community_report):

{
  "title": "茶百道研发与成都总部社区",
  "summary": "该社区围绕茶百道展开,包含其成都总部、杭州研发中心及人员……",
  "rating": 6.5,                    // 0~10 分:这个圈子的重要程度
  "rating_explanation": "涉及核心业务实体,影响中等偏上",
  "findings": [                       // 5~10 条要点,每条=标题+展开
    {"summary":"茶百道的双城布局", "explanation":"成都总部与杭州研发……"},
    ...
  ]
}

报告会存成两份:JSON(保留 rating 等结构化字段,供排序过滤用)和 Markdown 文本(供直接阅读/拼进提示词)。

怎么保证不超模型窗口:打包预算

一个社区可能有几百个节点,全塞进提示词会爆窗口。系统有一套打包策略(_pack_single_community_describe):

① 大社区优先用子报告圈太大时,不列明细, 而是引用已写好的子社区报告摘要
→
② 按重要性排序节点按度数(朋友数)排, 关系按两端度数和排——重要的排前面
→
③ 按 token 预算截断剩下的空间按比例分给 实体表和关系表,装不下的丢掉
"排序后截断"这个技巧后面到处都是 先把最重要的排到前面,再"从头开始装,装满为止"——被丢掉的永远是最不重要的尾部。这是整个项目治理上下文长度的核心手法,第 17 课会再总结。

_op.py:625 generate_community_report · _op.py:464 打包预算逻辑

Part 4

查询流程逐步拆解

insert 之后知识就备好了。这一部分回答:三种模式各自怎么把问题变成答案?

14

local:刨根问底式提问

基础

它怎么回答"小李的公司成立于哪一年?"

五步走,全部发生在 _build_local_query_context 里:

① 用问题去实体向量库检索 → 找回最相关的 20 个实体「小李」「茶百道」…被召回,同时拿到每个节点的度数(朋友数)
↓
② 顺着图扩展,收集三类证据· 相关社区的报告 · 这些实体之间的边 · 它们提到过的原文分块
↓
③ 各类证据按重要性排序 + 按 token 预算截断报告:命中次数多的优先;边:度数和大的优先;原文:被多方共同佐证的优先
↓
④ 拼成 4 段 CSV,塞进系统提示词-----Reports----- / -----Entities----- / -----Relationships----- / -----Sources-----
↓
⑤ LLM 读着上下文作答回答被约束"不许编造,只依据提供的资料"
例子:链条怎么接上的 ①向量召回「小李」「茶百道」 → ②图上看到 小李—就职于→茶百道,且茶百道节点挂的 source_id 指向文档 B 的分块 → ③文档 B 原文「成立于2008年」进入 Sources 表 → ⑤模型答:2008 年。

注意第②步:召回的实体可能只在文档 A 出现,但顺着一条边就把文档 B 拽出来了——这就是"沿图扩展"对第 4 课病历①的治愈。
三个证据来源的排序诀窍(记住直觉即可)
  • 报告:被越多召回实体指向的社区越相关,评分高的优先
  • 边:两端都是"社交达人"(度数高)且 weight 大的边更重要
  • 原文:不只看"是不是召回实体写的",还看"有多少邻居共同提到它"——被多方佐证的原文更可信

_op.py:844 _build_local_query_context · _op.py:935 local_query

本课要点 local = 向量找实体 → 沿图扩边/报告/原文 → 排序截断拼 CSV → LLM 作答。四路证据各有一种"重要性"排序,都受 token 预算约束。
15

global:我要的是全局

进阶

它完全不走实体向量,只读"年报"

问「这批文档里的公司整体发展如何?」——没有任何单个实体能回答这个问题。global 的策略是把第 13 课写好的所有社区报告拿来,走一轮 Map-Reduce:

① 筛选:挑出要读的报告层级 ≤ level、按 occurrence 排序取前 512 个、评分低于阈值的丢掉
↓
② Map:报告按 token 预算分成若干组,每组一次 LLM 调用每组像一位"分析师",产出带 0~100 重要性评分的要点列表
↓
③ 汇总所有要点:过滤 0 分,按分数从高到低,按预算截断留下的都是各组认为最重要的
↓
④ Reduce:最后一次 LLM 调用,把要点融成最终回答"分析师报告已按重要性排序,请综合成一段回答"
类比:开总结大会 报告太多,老板一个人读不完 → 分组:每人发一摞报告,各自提炼 10 条要点并打分(=Map)→ 把所有人交上来的要点汇总、按分数排序(=汇总)→ 最后一个人把精华写成总报告(=Reduce)。
注意:发一摞报告给"人"而不是发一张清单,是因为一摞报告能装下更多 token——分组本身就是控制长度的手段。

对比 local,二者的分工非常清晰:

localglobal
读什么实体 + 边 + 原文 + 少量报告只读社区报告
LLM 调用1 次分组数 + 1 次
擅长"X 的 Y 是多少"(细节、多跳)"整体怎么样"(归纳、主题)

_op.py:1017 global_query · _op.py:970 _map_global_communities

16

naive:不看图直接翻书

入门

最简单的一个模式,四步:

问题向量化去分块向量库检索
→
取回 top-k 分块原文按 token 预算截断
→
拼进朴素提示词“以下是已知信息……”
→
LLM 回答等于普通 RAG

它与 local 的区别只有一个开关:enable_naive_rag=True 时才会额外建一个分块向量库(local 用的是实体向量库,两者是不同的库)。

本课要点 · 第四部分收尾 local 沿图查细节(1 次 LLM)、global 读报告做归纳(多次 LLM)、naive 翻原文当基线。选模式的口诀:问具体的人和事用 local,问整体用 global,做对比实验用 naive。
Part 5

底层支撑机制

前几课反复出现的几个"配角"——token 预算、缓存、限流、存储接口——单独拎出来讲透。懂了这四课,你就理解了这个项目 80% 的代码细节。

17

token 与预算:为什么到处截断

基础

先说 token

token 是 LLM 的"字数单位":模型有上下文窗口上限(如 128k token),API 按 token 计费。项目里所有"1200"、"500"、"16384"之类的数字都是 token 数。项目用一个叫 TokenizerWrapper 的工具把文字数成 token(底层是 tiktoken),全项目数 token 只走这一个口,换成别的分词器也不影响别处。

核心矛盾

全项目的统一工具:truncate_list_by_token_size

def truncate_list_by_token_size(list_data, key, max_token_size, tokenizer):
    tokens = 0
    for i, data in enumerate(list_data):        # 按顺序累加
        tokens += len(tokenizer.encode(key(data))) + 1
        if tokens > max_token_size:
            return list_data[:i]                  # 装满就停,返回前缀
    return list_data

它在源码里出现了十几次,每次调用前列表都已经排好序,所以"返回前缀"="保留最重要的"。这就是前面各课反复看到的"预算 4000 tokens""按评分截断"背后的同一行代码。

预算从哪来? 写入侧:预算 = 模型窗口 − 提示词模板开销(best_model_max_token_size 减去模板长度)。
查询侧:预算写死在 QueryParam 里(local 三路 4000+4800+3200,global 16384),你可以按自己模型的窗口改这几个数。
本课要点 token 是窗口与计费单位;窗口有限所以必须截断;截断前必须排序;排序+截断由 truncate_list_by_token_size 一处实现,全局复用。
18

调用 LLM 的三件保险

基础

LLM API 会限流、会抖动、会重复计费。_llm.py 给每次调用上了三道保险:

保险一 · 重试(摔倒了爬起来)

遇到限流错误(RateLimitError)或连接错误时,最多再试 5 次,间隔 4→10 秒指数变长。用的是 tenacity 库的 @retry 装饰器,套在每个调用函数外面,调用方无感知。

保险二 · 缓存(同一道题不答两次)

工作方式 ① 把「模型名 + 完整的消息列表」做 MD5,得到一个指纹;
② 先查缓存:这个指纹见过吗?见过 → 直接返回上次的答案,一分钱不花;
③ 没见过 → 调 API → 把答案连同指纹存进 kv_store_llm_response_cache.json,并立刻落盘。

实际效果:重建社区报告时,只要社区内容没变,提示词就一样,第二次全部命中缓存。中途失败重跑 insert,也不用为已完成的部分再付一次钱。

_llm.py:50 openai_complete_if_cache · _utils.py:220 compute_args_hash

保险三 · 限流(进闸门先排队)

抽取几百个块时会同时发几百个请求,API 必然报错。系统给每个 LLM/Embedding 函数外面套了一层并发闸门(limit_async_func_call):同时最多 16 个在飞(可配),第 17 个请求必须等前面有人完成才放行。

类比:景区闸机 闸机同时只放 16 人,第 17 人在黄线外等——不是拒绝,是排队。这样把"同时几百个请求"变成"稳定的每秒十几个请求",限流错误自然就少了。

_utils.py:276 limit_async_func_call

本课要点 重试治"网络抖动",缓存治"重复花钱",限流治"并发过猛"。三道保险让大规模文档的抽取既稳又省。
19

三类存储与"可插拔"

基础

系统里的数据其实分三种形态

形态例子对应接口默认实现
键值对
id → 记录
文档、分块、LLM 缓存、社区报告 BaseKVStorage JsonKVStorage
一个 JSON 文件
向量
文字 → 坐标
实体库(local 用)、分块库(naive 用) BaseVectorStorage NanoVectorDBStorage
JSON + 暴力算距离
图
节点 + 边
实体关系图 + 社区归属 BaseGraphStorage NetworkXStorage
内存图 → graphml 文件

"可插拔"是怎么实现的?

base.py 只定义接口(一堆 raise NotImplementedError 的方法声明),不关心后面是谁。具体实现放在 _storage/ 里。想换零件?改一个字段:

from nano_graphrag._storage.gdb_neo4j import Neo4jStorage

graph = GraphRAG(
    graph_storage_cls=Neo4jStorage,          # 图存储从 NetworkX 换成 Neo4j,就这一行
    vector_db_storage_cls=HNSWVectorStorage, # 向量库同理
)
类比:换电池 接口像"5 号电池仓":仓的规格(正负极、直径)是固定的,电池品牌随便换。GraphRAG 主流程只认"仓",不知道里面装的是谁——所以它才敢在 __post_init__ 里写 self.graph_storage_cls(...) 而不关心具体类。

还有个容易忽略的设计:统一的落盘时机

所有存储都继承同一个基类,带三个钩子函数:index_start_callback(开始写入前调用)、index_done_callback(全部写完后调用)。默认实现是"整个 insert 过程都在内存里改,最后一次性把 JSON/graphml/向量库写到磁盘"——避免写一半崩了留下脏数据。

base.py:59-186 接口定义 · graphrag.py:360 _insert_done 统一落盘

本课要点 三类数据 → 三个接口 → 各自的实现类;换后端只改一个字段;改数据和写磁盘分离,结束时统一提交。这就是"小而可扩展"的实现方式。
20

动手路线与下一步

实践

建议的上手顺序(由浅入深,每步都有反馈)

第 1 步 · 跑通最小例子(30 分钟) 装好依赖后,用 examples/no_openai_key_at_all.py 或接自己的 LLM(OpenAI/DeepSeek/Ollama 都行),对一段几百字文本执行一次 insert + 三种 query。目标:见到真实回答。
第 2 步 · 打开工作目录看产物(20 分钟) 看 working_dir 下的 5 个文件:kv_store_*.json 里翻几条 chunk 和社区报告;graphml 文件用文本编辑器搜 clusters。把"第 6 课的 6 步流程"和磁盘文件对上号。
第 3 步 · 打印中间上下文(30 分钟) 查询时加 QueryParam(mode="local", only_need_context=True)——直接输出检索到的 CSV 上下文,不经过 LLM。你会亲眼看到四路证据长什么样、排序截断的效果,这是理解检索质量最快的途径。
第 4 步 · 改一个东西试试(1 小时) 任选其一:① 改 prompt.py 里的 DEFAULT_ENTITY_TYPES,让抽取适配你的领域(如法律/医疗);② 调 QueryParam 里的预算数字;③ 把 chunk_token_size 从 1200 改成 600 观察差异。改完跑对比,才算真学会。
第 5 步 · 带着问题读源码 按第 8 课地图顺序:graphrag.py(ainsert 全流程)→ _op.py 的 extract_entities → 两个 merge → local_query → generate_community_report。每读一个函数,问自己"这一步在六步流程的第几步?"

可以继续学的概念(按性价比排序)

方向要学什么解决什么
GraphRAG 方法论本身微软 GraphRAG 原版论文(GraphRAG: From Local to Global)nano 删掉的 covariates、原版 map-reduce 设计
图算法社区发现(Leiden/Louvain)、节点度数、连通分量第 12 课背后的数学在做什么
检索质量召回率/精确率评测、rerank 重排如何科学判断"检索得好不好"
工程扩展把 JsonKV/NanoVectorDB 换成 SQLite/Qdrant 等生产级存储大规模语料下的性能与并发
同系项目LightRAG(HKUDS)、微软 GraphRAG 原版对照阅读,理解不同取舍
全教程一句话总结 nano-graphrag 做的事:用 LLM 把一堆文档读成一张"实体关系图"和一摞"社区报告"(insert),再沿图找线索、读报告答问题(query)——中间所有工程细节(MD5 去重、token 预算、缓存、限流、可插拔存储)都是为了让这个过程省钱、稳定、可换零件。
词

附录 · 术语速查表

随查随用
术语一句话解释首见于
LLM大语言模型,如 GPT、Claude、DeepSeek、通义等01
上下文窗口模型一次能"看见"的最大文字量01
多跳推理需要连续串起 2+ 条线索才能得出的答案01
RAG检索增强生成:先查资料再让模型作答02
chunk(分块)被切出来的文字片段,检索的基本单位02
embedding(向量化)把文字变成坐标,语义近则坐标近03
向量库存坐标、按"谁离我最近"检索的数据库03
余弦相似度衡量两个向量方向接近程度的指标,1 为最像03
知识图谱节点=实体、边=关系的知识组织方式05
GraphRAG先建知识图谱再检索回答的 RAG 范式05
实体 / 关系图中的节点 / 连接节点的边05
社区(community)图上互相连接紧密的一簇节点,即"圈子"12
Leiden一种自动给图分圈的聚类算法,支持层级12
层级(level)社区的嵌套深度,数字越小圈越大12
occurrence社区覆盖的原文块占比,衡量"有料程度"12
社区报告让 LLM 给每个社区写的摘要,含评分13
Map-Reduce先分头处理再汇总的两阶段模式15
token模型的计字/计费单位17
预算截断排序后按 token 上限保留前缀的通用手法17
LLM 缓存相同提问直接返回上次答案,避免重复计费18
并发限流同时最多 N 个请求在飞,多的排队18
可插拔定义接口+注入实现,换零件不动主流程19
幂等 / 去重重复执行不产生副作用,靠内容 MD5 实现09
gleaning抽取后再追问"还有漏的吗"的补漏机制10
best/cheap 双模型重要任务用贵模型、体力活用便宜模型的分工11