项目概览与设计哲学
nano-graphrag 是微软 GraphRAG 的极简重实现:约 1100 行核心代码(不含测试与 Prompt),保留了图索引 + 社区报告 + 多模式检索的全部核心能力。
小而完整
剔除官方实现的复杂基建(配置系统、流水线框架、CLI),只保留「文档→知识图谱→社区→检索」这条主干,每一行都可读可改。
全组件可插拔
LLM、Embedding、向量库、图存储、KV 存储、分块函数、实体抽取器全部通过 dataclass 字段注入,换组件只改一个参数。
异步优先
全链路 asyncio,LLM 并发抽取、嵌入批量并行,用自研轻量限流器(而非 Semaphore)避免 nest_asyncio 依赖。
零外部服务依赖
默认组合 = JSON 文件 + NetworkX + nano-vectordb,pip install 后无需数据库即可跑通全部功能。
幂等增量写入
文档/分块均以 MD5 作为主键,重复插入自动跳过;LLM 响应落盘缓存,相同请求不再计费。
预算感知
所有上下文组装都以 token 预算为硬约束,逐项按重要性(度数、权重、评分)排序后截断,保证不超模型窗口。
😩 官方 Microsoft GraphRAG
- 多层配置系统 + CLI + 流水线编排
- 代码量数万行,学习曲线陡峭
- 强绑定 Azure OpenAI 生态
- 自定义/魔改成本高
😊 nano-graphrag
- 单文件主流程,数据类即配置
- 核心 ~1100 行,一晚上可通读
- OpenAI / Azure / Bedrock / Ollama 均可
- 改 Prompt、换存储只需动几行
整体架构
整个系统围绕「三类存储 + 两条管线」构建:写入时把文本炼成知识图谱与社区报告,查询时从图与报告中组装上下文交给 LLM 作答。
generate_community_report
local_query · global_query · naive_query
BaseVectorStorage
BaseGraphStorage
NanoVectorDBStorage
NetworkXStorage
Neo4jStorage · Faiss · Milvus …
doc-md5
chunk-md5
best_model
节点/边 + 描述合并
层级社区
JSON 报告 + 评分
local / global / naive
实体 or 分块
边 · 一跳邻居 · 文本回溯
局部引用 / 全局 map-reduce
token 预算内 CSV 上下文
源码地图
| 文件 | 行数 | 职责 |
|---|---|---|
graphrag.py | 382 | 主类 GraphRAG:配置、存储装配、insert/query 编排 |
base.py | 186 | 存储抽象(KV / Vector / Graph)+ QueryParam + Schema 定义 |
_op.py | 1140 | 核心算法层:分块、实体抽取、合并、社区报告、三种查询 |
_llm.py | 294 | OpenAI / Azure / Bedrock 客户端、重试、LLM 缓存、Embedding |
_utils.py | 305 | Tokenizer 包装、token 截断、MD5 主键、JSON 容错解析、并发限流 |
prompt.py | 520 | 全部 LLM Prompt(移植自微软 GraphRAG)+ 分隔符常量 |
_splitter.py | 94 | 基于分隔符 token 的切分器(langchain 风格) |
_storage/ | ~1000 | 5 种存储后端实现(JSON KV / nano-vectordb / hnswlib / networkx / neo4j) |
entity_extraction/ | ~500 | DSPy 类型化抽取器扩展(可选,带自反思精炼) |
主入口:GraphRAG 类
graphrag.py:52 — 一个 @dataclass 承担了「配置中心 + 依赖容器 + 流程编排」三重角色,__post_init__ 完成全部装配。
配置分组一览
| 分组 | 关键字段 | 说明 |
|---|---|---|
| 工作目录 | working_dir | 所有持久化产物的落盘目录,默认带时间戳自动创建 |
| 模式开关 | enable_local / enable_naive_rag | 决定是否创建 entities_vdb / chunks_vdb;global 恒可用 |
| 分块 | chunk_func / chunk_token_size=1200 / chunk_overlap=100 | 分块函数本身也是可注入字段 |
| 抽取 | entity_extract_max_gleaning=1 / entity_summary_to_max_tokens=500 | gleaning 轮数与描述摘要阈值 |
| 聚类 | graph_cluster_algorithm="leiden" / max_graph_cluster_size=10 | Leiden 层级聚类参数 |
| 双模型 | best_model_func / cheap_model_func + 各自 max_token / max_async | 贵模型做抽取与报告;便宜模型做描述摘要——成本控制的关键设计 |
| 嵌入 | embedding_func / embedding_batch_num=32 / query_better_than_threshold=0.2 | 封装为 EmbeddingFunc(自带维度/上限元数据) |
| 存储 | *_storage_cls 三个类字段 | KV / Vector / Graph 后端类,构造时传入 namespace + global_config |
| LLM 缓存 | enable_llm_cache=True | MD5(model, messages) 为键的响应缓存 |
__post_init__ 装配流程
构造 TokenizerWrapper
按 tokenizer_type 选择 tiktoken(gpt-4o)或 HuggingFace tokenizer,统一 encode/decode/decode_batch 接口。
供应商适配
using_azure_openai / using_amazon_bedrock 为真时,仅当用户未自定义时才替换默认的 model_func / embedding_func(尊重用户注入)。
实例化存储
用 asdict(self) 把整个配置快照传给每个存储的 global_config——存储实例反向读取配置的约定由此建立。依次创建 full_docs、text_chunks、llm_response_cache、community_reports(KV)与 chunk_entity_relation_graph(Graph)。
条件创建向量库
enable_local → entities_vdb(meta_fields 只存 entity_name);enable_naive_rag → chunks_vdb。
函数包装
embedding / best_model / cheap_model 全部套上 limit_async_func_call(max_async) 限流;两个 model_func 再 partial 注入 hashing_kv=llm_response_cache 完成缓存接线。
insert() 与 query() 是同步壳:通过 always_get_an_event_loop() 取得(必要时新建)事件循环,再 run_until_complete 调用 ainsert/aquery。这让库既能嵌入 asyncio 应用,也能在普通脚本里直接用。存储抽象层 base.py
整个项目的可扩展性支点:三个抽象基类 + 一套生命周期回调,任何后端只要实现这几个 async 方法即可接入。
4.1 三类存储接口
KV BaseKVStorage[T]
get_by_id / get_by_ids / filter_keys / upsert / drop
承载:原始文档、文本分块、LLM 缓存、社区报告。语义即「带命名空间的字典 + 落盘」。
Vector BaseVectorStorage
upsert(data) 用 value 的 content 字段做嵌入;query(query, top_k)。字段 embedding_func 与 meta_fields 由基类 dataclass 直接提供。
Graph BaseGraphStorage
单/批节点边的 CRUD + 度数查询 + clustering() + community_schema()。批量方法是性能关键:查询期大量并发 gather。
生命周期回调
所有存储继承 StorageNameSpace,带三个钩子:index_start_callback(插入前,Neo4j 用它建索引)、index_done_callback(插入后统一 commit:JSON 落盘、graphml 写出、向量库存盘)、query_done_callback。GraphRAG 用 asyncio.gather 并发驱动所有存储的回调——这就是「内存中改,结束时一次性写盘」的批式持久化模型。
4.2 默认实现与产物文件
| 类 | 后端 | 落盘文件 | 要点 |
|---|---|---|---|
JsonKVStorage | Python dict → JSON | kv_store_<ns>.json | 全量内存字典,done 时整体 dump;简单可靠 |
NanoVectorDBStorage | nano-vectordb(作者同系) | vdb_<ns>.json | upsert 前按 embedding_batch_num 分批并行嵌入;查询带余弦阈值过滤 |
NetworkXStorage | networkx | graph_<ns>.graphml | 默认图后端;内置 Leiden 聚类(graspologic)、稳定最大连通分量预处理 |
HNSWVectorStorage | hnswlib | <ns>_hnsw.index + pkl 元数据 | xxh32 哈希转 uint32 id;参数可经 vector_db_storage_cls_kwargs 注入 |
Neo4jStorage | Neo4j async driver | — | 用 working_dir 哈希出 label 隔离多项目;GDS Leiden;批量 Cypher(UNWIND)优化 |
clusters,随 graphml 一起持久化——图与社区归属天然一体,community_schema() 读取时重建「社区 → 节点/边/chunk_ids/occurrence/sub_communities」的完整视图。写入管线(Insert)
graphrag.py:277 ainsert — 七步流水线,每步幂等、可重入。
文档去重
每篇文档 compute_mdhash_id(content, "doc-") 作主键;filter_keys 剔除已存在文档,全部重复则直接返回。
分块
get_chunks() 先整篇 encode 成 token,再交给 chunk_func 切分;每个分块以内容 MD5(chunk- 前缀)为主键,天然去重。若开启 naive_rag,此时先写入 chunks_vdb。
丢弃旧社区报告
community_reports.drop()——当前不支持社区的增量更新,每次插入都重建全部报告(这是已知的成本权衡点,代码留有 TODO)。
实体关系抽取
extract_entities():LLM 并发抽取每个 chunk → 解析记录 → 按实体名/边对聚合 → 合并进图 → 实体写入向量库。
社区聚类
chunk_entity_relation_graph.clustering("leiden") 在稳定最大连通分量上跑层级 Leiden,把 (level, cluster) 写回每个节点。
生成社区报告
generate_community_report() 按层级从细到粗,为每个社区打包实体/边/子报告 → LLM 产出结构化 JSON 报告。
提交
最后才 upsert full_docs 与 text_chunks——若中途失败,文档不会被标记为已处理,下次 insert 可重试。finally 中所有存储 index_done_callback 统一落盘。
5.1 文本分块:两种策略
chunking_by_token_size(默认):滑动窗口硬切,步长 = max_token_size - overlap_token_size。简单、与语义无关,胜在可预测。
# _op.py:31 — 滑动窗口
for start in range(0, len(tokens), max_token_size - overlap_token_size):
chunk_token.append(tokens[start : start + max_token_size])
lengths.append(min(max_token_size, len(tokens) - start))
chunking_by_seperators:先用 SeparatorSplitter 按多级分隔符(段落 → 换行 → 中英文句号/感叹/问号 → 空白,见 prompt.py 的 default_text_separator)切出原子片段,再贪心合并到 chunk_size,并对相邻 chunk 强制注入 overlap——切点落在自然边界,对下游抽取更友好。分隔符本身也被 encode 成 token 序列在 token 空间匹配。
5.2 实体关系抽取 extract_entities
这是与 LLM 交互最密集的环节,设计上有四个值得注意的机制:
① 约定式文本协议(而非 JSON)
Prompt 要求 LLM 输出用三种分隔符编码的记录流:
("entity"<|>"ALEX"<|>"PERSON"<|>"Alex is a character who...")## ("relationship"<|>"ALEX"<|>"TAYLOR"<|>"Alex is affected by..."<|>7)<|COMPLETE|> # tuple_delimiter=<|> record_delimiter=## completion_delimiter=<|COMPLETE|>
元组首字段区分实体/关系;解析时正则 r"\((.*)\)" 取括号内内容再按 tuple_delimiter 切分。相比 JSON,这种协议对开源模型更鲁棒(不易被截断成非法 JSON)。
② Gleaning(补抽循环)
首轮抽取后,追加发送「MANY entities were missed...」继续提示,让 LLM 补充遗漏;并可选地再问一句「还有遗漏吗?YES/NO」决定是否继续。entity_extract_max_gleaning 控制最大补抽轮数(默认 1)。多轮对话历史通过 pack_user_ass_to_openai_messages 拼接维护。
③ 全 chunk 并发 + 全局聚合
# 所有 chunk 的抽取在限流器约束下并发 results = await asyncio.gather(*[_process_single_content(c) for c in ordered_chunks]) # 跨 chunk 聚合:同名实体收拢;无向图——边键统一 sorted(tuple) maybe_edges[tuple(sorted(k))].extend(v)
④ 记录级校验
_handle_single_entity_extraction 要求至少 4 个字段且首字段为 "entity";关系要求 5 字段,权重字段用 is_float_regex 校验,不合法则回退 1.0。实体名/类型统一 clean_str + upper() 保证图内实体命名一致(这是合并正确性的基石)。
5.3 合并与去重:_merge_nodes / _merge_edges
节点合并 _op.py:182
- entity_type:新旧类型一起做 Counter 投票,取众数
- description:去重后按字典序用
<SEP>拼接 - source_id:并集拼接(记录来自哪些 chunk)
- 拼接后若超过
entity_summary_to_max_tokens(500),触发 cheap_model 摘要压缩——描述永远不会无限膨胀
边合并 _op.py:230
- weight 累加:多次独立提及视为更强关联
- order 取 min:直接关系优先于间接关系
- description / source_id 同节点策略
- 端点缺失时自动补
UNKNOWN类型节点,保证图闭合 - 同样触发超长描述摘要
合并完成后,实体以 ent-md5 写入 entities_vdb,content = entity_name + description——向量检索时语义信号同时来自名称与描述。
5.4 Leiden 社区聚类
NetworkX 路径(gdb_networkx.py:230):
稳定最大连通分量
stable_largest_connected_component:取 LCC 后对节点与边做确定性排序重建图(并把节点名统一 upper+unescape)——保证同一张图每次跑出的聚类可复现(random_seed 固定 0xDEADBEEF)。
层级 Leiden
graspologic 的 hierarchical_leiden,max_cluster_size=10,产出多层社区划分。
回写节点
每个节点得到 clusters = [{"level":0,"cluster":12}, {"level":1,"cluster":45}, ...] 的 JSON 属性。
community_schema() 随后重建社区视图:遍历所有节点的 clusters,聚合每个社区的节点集、边集、chunk_ids;occurrence = 社区 chunk 数 / 全图最大社区 chunk 数(衡量社区覆盖语料的广度);跨层通过节点集包含关系推导 sub_communities。Neo4j 版本则用 GDS 的 gds.leiden.write 把 communityIds 写回节点属性,逻辑同构。
5.5 社区报告生成:层级化 + 预算打包
_op.py:625 generate_community_report 是全文最精巧的函数之一。
按层级从细到粗生成
for level in sorted(set(levels), reverse=True): # level 2 → 1 → 0(细 → 粗) this_level_reports = await asyncio.gather(*[ _form_single_community_report(c, community_datas) for c in this_level]) community_datas.update(...) # 细社区的报告会喂给粗社区当输入
粗社区的描述里可以引用子社区的报告摘要——形成自下而上的信息浓缩层级。
_pack_single_community_describe 的预算分配术
触发子社区打包(启发式)
当节点数或边数 > 100 且存在子社区报告时,优先用子社区报告的 CSV 摘要(按 occurrence 降序截断)替代原始节点边明细;剩余预算继续用于装填未被子社区覆盖的节点边。force_to_use_sub_communities 可强制开启。
去重过滤
已被子社区报告覆盖的节点/边从明细中过滤,避免重复占用 token。
按重要性排序
节点按度数、边按 edge_degree(两端度数和)降序排序——最重要的结构信息排在前面。
动态预算切分
剩余预算扣除 CSV 表头开销后,按节点/边条目数比例 node_ratio 分配各自 token 配额,再用 truncate_list_by_token_size 各自截断。三段(子报告 / 实体 / 关系)拼进固定模板。
报告 Schema
LLM 以 JSON 返回:title / summary / rating(0-10) / rating_explanation / findings(5-10 条 summary+explanation)。_community_report_json_to_str 再把 JSON 渲染成 Markdown 报告字符串,两份都存进 community_reports KV。rating 在 global 查询里充当过滤与排序信号。
查询管线(Query)
QueryParam.mode 决定三种检索路径,共享同一套 token 预算与「CSV 上下文注入」的回答风格。
| 模式 | 入口 | 适用问题 | 核心数据源 |
|---|---|---|---|
| local | local_query | 关于具体实体/事实的细节问题 | 实体向量召回 → 图邻域 → 报告/原文 |
| global | global_query | 宏观主题、全库总结、跨社区问题 | 全部社区报告的 map-reduce |
| naive | naive_query | 传统向量 RAG 基线对照 | 分块向量库 top-k 原文 |
6.1 local 模式:以实体为锚的四源上下文组装
_op.py:844 _build_local_query_context — 经典的 GraphRAG 局部检索:先向量召回 top_k(默认20)个相关实体,再沿图结构扩展出四类证据。
entities_vdb top-20
rank = degree
按命中频次+rating 排序
预算 3200 tokens
rank=度数和+weight 排序
预算 4800 tokens
source_id 回溯 + 一跳邻居关联度
预算 4000 tokens
名称/类型/描述/度数
三个扩展函数各自藏着排序智慧:
- _find_most_related_community_from_entities:统计命中实体指向的社区(level ≤ param.level),按命中频次 → 报告 rating 二级排序截断。
- _find_most_related_edges_from_entities:取全部实体的一跳边去重,按 (edge_degree, weight) 降序截断。
- _find_most_related_text_unit_from_entities:从实体 source_id 回溯原始分块;关键创新是计算每个分块与一跳邻居共享的关联数(relation_counts),排序键
(order, -relation_counts)——既保召回实体的原文优先,也让「多方共同佐证」的分块胜出。
四路内容最终渲染为四段 CSV(Reports / Entities / Relationships / Sources)塞进 local_rag_response 系统提示词。CSV 用 list_of_list_to_csv(逗号+tab 分隔、字段加引号并转义)——token 高效且 LLM 容易理解。
6.2 global 模式:社区报告上的 Map-Reduce
筛选社区
level ≤ param.level 的社区,按 occurrence 降序取前 global_max_consider_community(512) 个,加载报告后按 rating ≥ 阈值过滤,再按 (occurrence, rating) 排序。
Map:分组并行找要点
报告按 token 预算(global_max_token_for_community_report=16384)切分为多个组,每组一次 LLM 调用(global_map_rag_points,强制 JSON 输出),产出带 0-100 重要性评分的要点列表。各组并行,相当于多个「分析师」各自审读一部分社区。
要点聚合
所有组的要点拍平,过滤 score ≤ 0,按 score 降序,再按 token 预算截断保留最重要的要点。
Reduce:合成最终回答
把「按重要性降序排列的分析师要点」交给 global_reduce_rag_response 提示词,LLM 综合去重、按要求格式输出最终回答。
与官方实现的差异:官方是「每个社区一个分析师 + 投票排序」的多分析师框架;nano 简化为「按 token 分组的并行 map + 全局 score 排序 reduce」,调用次数更少、逻辑更直白,效果在多数场景足够。
6.3 naive 模式:最朴素的向量 RAG
chunks_vdb 向量检索 top_k 分块 → 取回原文 → token 截断 → 用 --New Chunk-- 拼接 → naive_rag_response 提示词作答。价值在于提供一条无图基线:同一语料下对比 local/global/naive 的效果,量化知识图谱带来的增益(examples/benchmarks 里即用此思路)。
QueryParam 预算总表
| 字段 | 默认值 | 作用域 |
|---|---|---|
level | 2 | local/global 允许引用的社区层级上限(0 最粗) |
top_k | 20 | local 实体召回数 / naive 分块召回数 |
local_max_token_for_text_unit | 4000 | local 原文分块预算 |
local_max_token_for_local_context | 4800 | local 边描述预算 |
local_max_token_for_community_report | 3200 | local 社区报告预算 |
global_max_consider_community | 512 | global 最多考虑的社区数 |
global_min_community_rating | 0 | global 报告评分过滤线 |
global_max_token_for_community_report | 16384 | global map 分组 / reduce 要点预算 |
设计模式:local 的三路预算 4000+4800+3200 ≈ 12000 tokens,加上实体表与提示词模板正好填满一个 16k 级模型的舒适区——预算是显式工程,不是碰运气。
LLM 调用层 _llm.py
对多家供应商的统一 async 封装,核心是三件事:单例客户端、指数退避重试、基于内容哈希的响应缓存。
统一函数签名
async def openai_complete_if_cache(model, prompt, system_prompt=None, history_messages=[], **kwargs) -> str
所有 complete 函数遵循同一签名:prompt + system_prompt + history_messages → str。kwargs 里弹出 hashing_kv(由 GraphRAG 在装配时 partial 注入)。基于此,create_amazon_bedrock_complete_function(model_id) 用工厂模式为任意 Bedrock 模型动态生成符合签名的函数——这正是「换 LLM 只改一个字段」的实现基础。
LLM 缓存机制
# 1. 组装 messages 后算哈希 args_hash = compute_args_hash(model, messages) # md5(str((model, messages))) if_cache_return = await hashing_kv.get_by_id(args_hash) if if_cache_return is not None: return if_cache_return["return"] # 命中:零成本返回 # 2. 未命中:调 API,写回缓存并立即落盘 response = await openai_async_client.chat.completions.create(...) await hashing_kv.upsert({args_hash: {"return": ..., "model": model}}) await hashing_kv.index_done_callback()
缓存粒度是「完整 messages 列表」——相同的抽取 prompt、相同的社区内容在重复 insert 时完全免费。这也解释了为什么「丢弃旧社区报告后重跑」在语料不变时成本趋近于零。
重试与容错
- tenacity 装饰器:RateLimitError / APIConnectionError 时最多重试 5 次,指数退避 4~10 秒。
- 全局单例客户端(get_openai_async_client_instance 等)避免重复建连。
- Embedding 函数用
wrap_embedding_func_with_attrs附带 embedding_dim / max_token_size 元数据——存储层构造时读取维度初始化索引,无需用户重复声明。
工具库 _utils.py
305 行里藏着六个支撑全局的设计,逐一拆解:
1. TokenizerWrapper
tiktoken 与 HuggingFace tokenizer 的统一适配器,懒加载;decode_batch 对 HF 走 batch_decode 提速。整个系统的 token 预算计算全部经过它,换 tokenizer 不影响任何调用方。
2. truncate_list_by_token_size
按序累加 key(data) 的 token 数,超预算即截断返回前缀。因为输入列表都已按重要性排序,「前缀截断」=「保留最重要的」。每项 +1 的防御性补偿模拟换行拼接开销。全文出现 10+ 次,是预算感知的总开关。
3. compute_mdhash_id / compute_args_hash
内容寻址的根基:doc-/chunk-/ent- 前缀 + MD5。相同内容永远映射到相同 ID,让去重、增量、缓存三件事共享同一套机制。
4. limit_async_func_call
自研并发限流:nonlocal 计数器 + while sleep 自旋,达到 max_size 后新调用排队。作者注释明说:不用 asyncio.Semaphore 是为了避免嵌套事件循环场景下的兼容问题——朴素但稳。
5. convert_response_to_json
JSON 容错三连击:先用栈式扫描提取第一个完整平衡的 {...} 严格解析;失败则退化为正则启发式抽取 key:value(容忍无引号、嵌套对象);再失败记错误返回 None。开源模型输出不稳时的救命稻草,且作为 convert_response_to_json_func 字段可整体替换。
6. list_of_list_to_csv / enclose_string_with_quotes
把二维表渲染成 LLM 友好的 CSV:数字不加引号、字符串加引号并把内部引号翻倍转义,分隔符用「逗号+tab」。所有上下文注入(查询、社区打包)统一走这里,格式高度一致。
Prompt 工程 prompt.py
520 行 Prompt 全部移植自微软 GraphRAG,集中在一个 PROMPTS 字典里,运行时可整体覆盖——魔改行为最经济的入口。
| Key | 用途 | 消费方 |
|---|---|---|
entity_extraction | 实体关系抽取主提示(含 3 个 few-shot 示例) | extract_entities |
entiti_continue_extraction / entiti_if_loop_extraction | gleaning 的续抽与「还有吗」判断 | extract_entities |
summarize_entity_descriptions | 超长实体/边描述合并摘要 | _handle_entity_relation_summary(cheap model) |
community_report | 社区报告生成(严格 JSON schema + grounding 规则) | generate_community_report |
local_rag_response | local 查询的系统提示 | local_query |
global_map_rag_points / global_reduce_rag_response | global 的 map 找要点 / reduce 合成 | global_query |
naive_rag_response | naive 查询的系统提示 | naive_query |
claim_extraction | 声明抽取(未接入管线,covariates 的遗留) | — |
Prompt 设计的三个共性
- Grounding 规则反复强调:「Do not include information where the supporting evidence for it is not provided」「Do not make anything up」在每个回答类提示里至少出现两次(目标段前后各一次)——抗幻觉的提示层手段。
- 目标长度/格式二次注入:{response_type} 在提示头尾各出现一次,强化格式遵循。
- 分隔符即协议:DEFAULT_TUPLE_DELIMITER=<|>、RECORD=##、COMPLETION=<|COMPLETE|>、GRAPH_FIELD_SEP=<SEP>——四个常量贯穿抽取、解析、合并、存储全链路。
DSPy 扩展抽取器
entity_extraction/ — 可选的「类型化抽取」替代路径:用 DSPy Signature + Pydantic 模型取代文本协议解析。
默认路径:Prompt + 文本协议
extract_entities:LLM 输出分隔符记录流 → 正则解析。优点是零额外依赖、对弱模型友好;缺点是解析失败只能丢弃该记录,schema 约束靠 Prompt「祈祷」。
DSPy 路径:Signature + 自反思
extract_entities_dspy:CombinedExtraction 签名声明输入输出类型(list[Entity] / list[Relationship],Pydantic 强约束 importance_score∈[0,1]、order∈[1,3]);可选 self_refine 循环 = Critique(批评)→ Refine(精炼)迭代改进。通过 entity_extraction_func 字段注入。
DSPy 版抽取后仍复用 _merge_nodes_then_upsert / _merge_edges_then_upsert——抽取器可替换、合并语义保持统一,这正是 base 层抽象的价值。另有 generate_dataset 可把抽取结果固化为 dspy.Example 数据集用于后续编译优化。
关键设计决策总结
通读全部源码后,值得带走的八个设计决策:
| # | 决策 | 动机与效果 |
|---|---|---|
| 1 | dataclass 即架构 | 配置、依赖注入、默认值三合一;asdict 快照传给存储层,避免全局配置对象。代价是配置项扁平(40+ 字段),但换来零框架心智负担。 |
| 2 | 三存储抽象 | KV / Vector / Graph 的切分与数据形态严格对应;生命周期回调实现「内存工作 + 终点落盘」,写放大只发生一次。 |
| 3 | 内容寻址(MD5 主键) | 去重、增量、幂等三合一。文档/分块/实体/LLM 缓存四层全部内容寻址,任何一层重放都安全。 |
| 4 | 双模型分工 | best_model 做理解密集任务(抽取/报告),cheap_model 做机械压缩(描述摘要)。成本结构上把大头压到可选的贵调用里。 |
| 5 | token 预算显式化 | truncate_list_by_token_size + 排序优先级,每个上下文组装点都有硬预算。模型窗口变化只需调 QueryParam 数字。 |
| 6 | 文本协议优先于 JSON | 抽取用分隔符元组流而非 JSON:抗截断、抗转义灾难、对弱模型宽容;仅在必须结构化处(社区报告、map 要点)强制 JSON + 容错解析兜底。 |
| 7 | 社区层级复用 | 聚类层级 = 报告生成顺序 = 查询过滤维度;粗社区引用细社区报告,同一层级结构服务索引与检索两端,信息零浪费。 |
| 8 | 自研限流而非 Semaphore | 牺牲一点优雅换取事件循环兼容性(嵌套 loop、Jupyter、子线程场景);与 always_get_an_event_loop 一起构成「到处能跑」的异步策略。 |
已知局限
- 社区报告不支持增量更新,每次 insert 全量重建(有 LLM 缓存兜底,但聚类本身会重算)。
- covariates(claim 追踪)只有 Prompt 残留,功能未实现。
- NetworkX 全内存模型适合中小图;超大图需换 Neo4j 后端。
- JSON KV 存储整读整写,多 GB 语料时建议换嵌入式数据库实现。
- node2vec 节点嵌入是预留接口,local 查询实际依赖实体文本向量而非图结构嵌入。
源码阅读路线图
如果你要亲自读一遍,推荐这条 90 分钟路径:
15 分钟 · 主干
graphrag.py 全文:只看 GraphRAG 类的字段列表、__post_init__、ainsert、aquery。建立「配置→存储→流程」的骨架认知。
10 分钟 · 契约
base.py:三个抽象类的方法签名 + QueryParam + TextChunkSchema/CommunitySchema。此后读任何实现都有地图。
35 分钟 · 算法核心
_op.py 按写入顺序读:chunking_by_token_size → extract_entities → _merge_nodes/edges → generate_community_report(重点看 _pack_single_community_describe);再读 local_query 链路。global/naive 可略读。
15 分钟 · 支撑件
_utils.py 的 TokenizerWrapper、truncate_list_by_token_size、limit_async_func_call、convert_response_to_json;_llm.py 的 openai_complete_if_cache。
10 分钟 · 存储实现
kv_json.py(最简单,46 行)→ vdb_nanovectordb.py → gdb_networkx.py 的 clustering 与 community_schema 两段。
5 分钟 · 跑一遍
用 examples/no_openai_key_at_all.py 或接自己的 LLM 跑通 insert + 三种 query,观察 working_dir 下的产物文件——把代码与磁盘形态对上,理解闭环。