跳到主要内容

第 2 章 · 图抽取:从文本抽出实体和关系

这章讲什么: 这是整条流水线里"含金量"最高的一步——把一段自然语言变成结构化的"实体 + 关系"。本章讲 LLM 抽取的模板与解析、"gleaning"多轮补抽的技巧、同名实体的描述合并,以及不花 LLM 的 NLP 快速路径。对应 workflow:extract_graph(standard)与 extract_graph_nlp(fast)。


2.1 要解决的小问题

给一段文本(一个 text_unit),要抽出两样东西:

  • 实体:文本里提到的"东西"——人、组织、地点…(entity_name, entity_type, entity_description)。
  • 关系:实体两两之间的联系(source, target, description, strength/weight)。

难点有二:(a) LLM 输出是自由文本,得可靠地解析成结构化记录;(b) LLM 一遍常常抽不全(漏实体、漏关系)。GraphRAG 用"带分隔符的模板" + "gleaning 多轮补抽"分别对付这两点。


2.2 思路:让 LLM 输出"带自定义分隔符"的记录

GraphRAG 不依赖 JSON,而是让 LLM 按一套自定义分隔符吐记录,再自己切。分隔符定义在 graph_extractor.py 顶部:

常量作用
TUPLE_DELIMITER`<>`
RECORD_DELIMITER##记录之间的分隔
COMPLETION_DELIMITER`<COMPLETE

prompt(packages/graphrag/graphrag/prompts/index/extract_graph.py(GRAPH_EXTRACTION_PROMPT))要求 LLM 把每条实体写成 ("entity"<|>名字<|>类型<|>描述)、每条关系写成 ("relationship"<|>源<|>目标<|>描述<|>强度),用 ## 连成一串,末尾输出 <|COMPLETE|>。prompt 里还内置了几个 few-shot 例子帮助模型对齐格式。

为什么不用 JSON(精华): 抽取常常是长列表、还要多轮追加(见下),用轻量分隔符比反复拼合法 JSON 更省 token、也更抗"半截输出"——切分时按 ## split、按 <|> split 即可,坏记录直接跳过。


2.3 gleaning:让 LLM 再抽一遍、再问"还有吗"

单次抽取容易漏。GraphRAG 的对策叫 gleaning(拾遗):抽完第一遍后,在同一段对话里追加"你漏了很多,接着补"的消息,让模型基于已看过的上下文再抽一轮;每轮之间再问一句"还有没有?回答 Y/N",答 N 就提前停。

怎么读下面这张流程图:从上到下是一次抽取的生命周期,右侧是两个退出闸门(到达上限 / 模型说没了)。

首轮抽取 (extraction_prompt)
│ results += 首轮输出

┌───────────── gleaning 循环 (i < max_gleanings) ─────────────┐
│ 加 CONTINUE_PROMPT("漏了很多,接着补") → 调 LLM → results += │
│ │ │
│ ├─ i 已到最后一轮 ────────────────────────▶ 退出 │
│ │ │
│ 加 LOOP_PROMPT("还有吗? 只答 Y/N") → 调 LLM │
│ └─ 回答 != "Y" ─────────────────────────▶ 退出 │
└─────────────────────────────────────────────────────────────┘
│ 最终把整串 results 交给解析器切成实体/关系

_process_result → (entities_df, relationships_df)

真实实现是 GraphExtractor._process_documentpackages/graphrag/graphrag/index/operations/extract_graph/graph_extractor.py),关键在它用 CompletionMessagesBuilder 把每轮 assistant 回复也加回消息历史,所以补抽是"带记忆"的。CONTINUE_PROMPTLOOP_PROMPT 两句提示词在 prompts/index/extract_graph.py:128-129

解析在 GraphExtractor._process_result:按 RECORD_DELIMITER 切记录、按 TUPLE_DELIMITER 切字段,判断首字段是 "entity" 还是 "relationship",字段数不够就跳过;关系强度用 float(record_attributes[-1]),转不动就默认 1.0。实体名统一 .upper() 归一化——这让"同一个实体的不同大小写写法"能在后面被合并。


2.4 合并:同名实体/同一对关系的多条描述→一段

不同文本块可能都提到"MARTIN SMITH",各给一句描述。抽完后 extract_graph(workflow 层,packages/graphrag/graphrag/index/workflows/extract_graph.py)会调 summarize_descriptions同名实体、同一对 (source,target) 关系的多条描述交给 LLM 合并成一段连贯描述。

串起来看 workflow 里的 get_summarized_entities_relationships

# 示意,非源码。对应 extract_graph.py get_summarized_entities_relationships
# 每个实体/关系原本有一列 description(可能来自多个块,已聚合成多条)
entity_summaries, relationship_summaries = await summarize_descriptions(...)
# 丢掉原始 description,换上 LLM 合并后的摘要
entities = extracted_entities.drop(columns=["description"]).merge(entity_summaries, on="title")
relationships = extracted_relationships.drop(columns=["description"]).merge(
relationship_summaries, on=["source", "target"])

注意抽取用的模型和合并用的模型是分开配置的(config.extract_graph.completion_model_id vs config.summarize_descriptions.completion_model_id),都各自带缓存(context.cache.child(...))。如果一遍下来一个实体或关系都没抽到,extract_graph(workflow 层)会直接抛错,因为空图后面没法聚类。

索引配置里开了 snapshots.raw_graph 的话,合并前的"原始"实体/关系也会另存一份(raw_entities/raw_relationships),方便调试对比。


2.5 定稿:算度数(finalize_graph)

抽+合并完,finalize_graphpackages/graphrag/graphrag/index/workflows/finalize_graph.py)给每个实体补上"度数"(degree,连了多少条边)。它的巧处是流式算度数、不建大 DataFrame:

# 示意,非源码。对应 finalize_graph.py _build_degree_map
seen = set(); degree = Counter()
async for row in relationships_table:
lo, hi = sorted((row["source"], row["target"])) # 无向边归一化
if (lo, hi) not in seen: # 去重反向重复边
seen.add((lo, hi)); degree[lo] += 1; degree[hi] += 1

度数很重要:查询期 local search 会用它给实体排序("关系越多越可能是核心实体"),聚类也用边权。度数写回 entities.degree(列名见 data_model/schemas.py(ENTITIES_FINAL_COLUMNS) 的 NODE_DEGREE)。


2.6 快速路径:不花 LLM 的 NLP 抽图

fast 方法用 extract_graph_nlppackages/graphrag/graphrag/index/workflows/extract_graph_nlp.py)代替 LLM 抽取。思路完全不同:

  • 实体 = 名词短语:用名词短语抽取器(build_noun_graph + np_extractors,支持正则/句法/CFG 三种,见 NounPhraseExtractorType 枚举)从每块里抽名词短语当"实体"。
  • 关系 = 共现:两个名词短语出现在同一块里就连一条边,边权按共现次数(可选 normalize_edge_weights 归一化)。

这样一分钱 LLM 都不花就得到一张图,代价是没有"类型"和"描述"、噪声更多——所以 fast 线随后要跑 prune_graphpackages/graphrag/graphrag/index/workflows/prune_graph.py)按度数/频次剪掉噪声节点,社区报告也改用基于文本块的 create_community_reports_text

何时用哪条: 语料大、预算紧、能接受质量下降 → fast;要高质量的带类型带描述的图 → standard


2.7 prompt 调优(顺带一提)

抽取质量高度依赖 prompt 里的实体类型清单和 few-shot 例子。GraphRAG 提供 graphrag prompt-tune 命令(cli/main.py@app.command("prompt-tune"),逻辑在 packages/graphrag/graphrag/prompt_tune/api/prompt_tune.py):它拿你自己的语料采样,自动生成贴合领域的抽取 prompt、实体类型和例子。README 明确建议:开箱 prompt 效果一般,上生产前先 prompt-tune


2.8 代码地图

主题文件路径符号名
抽取 workflow(编排抽+合并)packages/graphrag/graphrag/index/workflows/extract_graph.pyextract_graph,get_summarized_entities_relationships
LLM 抽取器 + gleaning + 解析packages/graphrag/graphrag/index/operations/extract_graph/graph_extractor.pyGraphExtractor,_process_document,_process_result
分隔符常量同上TUPLE_DELIMITER,RECORD_DELIMITER,COMPLETION_DELIMITER
抽取 prompt + 补抽提示packages/graphrag/graphrag/prompts/index/extract_graph.pyGRAPH_EXTRACTION_PROMPT,CONTINUE_PROMPT,LOOP_PROMPT
描述合并packages/graphrag/graphrag/index/operations/summarize_descriptions/summarize_descriptions.pysummarize_descriptions
度数定稿packages/graphrag/graphrag/index/workflows/finalize_graph.pyfinalize_graph,_build_degree_map
NLP 快速抽图packages/graphrag/graphrag/index/workflows/extract_graph_nlp.pyextract_graph_nlp
名词短语抽取器packages/graphrag/graphrag/index/operations/build_noun_graph/build_noun_graph,create_noun_phrase_extractor
prompt 调优packages/graphrag/graphrag/prompt_tune/,api/prompt_tune.py