数据截至 (上游 commit f791fa4751f6)
入库流水线:文档怎么变成「知识图谱 + 向量库」
本章讲入库主线:一篇文档从原文到「图谱里的点和边」要经过哪几步,以及每步的关键设计。
1. 这一步要解决什么
纯向量 RAG 的入库很简单:切块、算向量、存。LightRAG 多做一件难事——用 LLM 把每个块读成结构化的「实体 + 关系」,再把所有块的结果攒成一张全局图谱。
难点有三个,本章逐个看:
- 怎么让 LLM 稳定地吐出可解析的实体/关系(格式约束 + 二次补抽)。
- 同一个实体在不同块里反复出现,怎么合并成图谱里的一个点(跨块去重)。
- 一个实体被几十个块描述,描述拼起来太长,怎么压缩(map-reduce 摘要)。
2. 入库主线走一遍
SDK 入口 ainsert 固定用「定长 token 分块(F 策略)」,然后入队 + 处理(lightrag/lightrag.py:1774 ainsert):
ainsert(text)
→ apipeline_enqueue_documents(...) # 文档入队,写 doc_status
→ apipeline_process_enqueue_documents() # 真正干活的流水线
ainsert 的 docstring 明确说明它只支持 F 策略;要用 R/V/P 策略或自定义 process_options,得直接调 apipeline_enqueue_documents + apipeline_process_enqueue_documents(lightrag/lightrag.py:1783-1801)。服务器 REST API 走的就是后者那条路。
文档在流水线里经过一个状态机(lightrag/base.py:979,class DocStatus):
PENDING → PARSING → ANALYZING(可选,多模态VLM) → PROCESSING → PROCESSED | FAILED
PARSING:抽内容(parse_native/ MinerU / Docling)。ANALYZING:多模态分析(对图片/表格调 VLM), 非多模态文档跳过。PROCESSING:抽实体/关系,这是图谱构建的核心阶段。
流水线的 worker 方法都在 lightrag/pipeline.py:_parse_worker(:1546)、_analyze_worker(:1869)、_process_worker(:1998)、process_single_document(:2035)。
3. 第一步:分块(chunking)
3.1 四种切块策略
LightRAG 提供 4 种切块器,选择器是单字母(lightrag/chunker/__init__.py:32-42):
| 字母 | 策略 | 怎么切 | 实现 |
|---|---|---|---|
F | 定长 token 窗口(默认) | 固定 chunk_token_size token、相邻块有 chunk_overlap_token_size 重叠 | chunking_by_fixed_token |
R | 递归字符 | 包 LangChain RecursiveCharacterTextSplitter | chunking_by_recursive_character |
V | 语义向量 | 按句子嵌入的相似度断点切 | chunking_by_semantic_vector |
P | 段落语义 | 按段落 + 语义切 | chunking_by_paragraph_semantic |
默认的 F 策略(lightrag/chunker/token_size.py)还会把每个块映射回原文的精确字符跨度(_token_window_source_span,lightrag/chunker/token_size.py:51),这样查询时引用能定位回原文位置。
3.2 块的数据结构
每个块是一个 TextChunkSchema(lightrag/base.py:79-83):tokens(token 数)、content(文本)、full_doc_id(属于哪篇文档)、chunk_order_index(在文档里的第几块)。chunk_order_index 后面在引用排序里会用到。
4. 第二步:抽实体与关系(核心)
这是入库里工程含量最高的一支。入口是 extract_entities(lightrag/operate.py:3827),它对每个 chunk 调一次(或两次)LLM。
4.1 思路:让 LLM 吐「分隔符行」
LightRAG 不直接要 LLM 吐 JSON(虽然有 JSON 模式可选),默认用分隔符格式。每行是一条记录,字段用 <|#|> 分隔(lightrag/prompt.py:12,DEFAULT_TUPLE_DELIMITER):
- 实体行:
entity<|#|>实体名<|#|>实体类型<|#|>描述(4 段) - 关系行:
relation<|#|>源实体<|#|>目标实体<|#|>关系关键词<|#|>关系描述(5 段) - 全部抽完输出
<|COMPLETE|>(DEFAULT_COMPLETION_DELIMITER)
这套格式契约写死在系统提示里(lightrag/prompt.py:80-114,entity_extraction_system_prompt),并反复强调:关系视为无向、源/目标对调不算新关系、只输出源和目标都已在本轮实体里的关系。
为什么用分隔符而非 JSON? 分隔符格式对小模型更友好、更省 token、解析更宽容(漏一个字段不会像 JSON 那样整段崩)。JSON 模式作为可选项存在(entity_extraction_use_json,lightrag/operate.py:3875),用于支持 structured output 的强模型。
4.2 原理演示
# 示意,非源码。演示「一段文本 → 几条分隔符记录」
D = "<|#|>"
text = "OpenAI 由 Sam Altman 创立。"
records = [
f"entity{D}OpenAI{D}organization{D}一家人工智能公司",
f"entity{D}Sam Altman{D}person{D}OpenAI 的联合创始人",
f"relation{D}OpenAI{D}Sam Altman{D}创立,领导{D}Sam Altman 创立了 OpenAI",
"<|COMPLETE|>",
]
# 重点看:实体行 4 段、关系行 5 段;关系两端必须都是上面出现过的实体
解析这些行的是 _handle_single_entity_extraction(lightrag/operate.py:706)和 _handle_single_relationship_extraction(lightrag/operate.py:770),整体解析编排在 _process_extraction_result(lightrag/operate.py:1495)。
4.3 Gleaning:二次补抽
LLM 一次往往抽不全。LightRAG 用 gleaning(拾遗):把第一轮的「用户提问 + LLM 回答」作为对话历史,再追加一句「继续找漏掉的」的指令,让 LLM 补抽(lightrag/operate.py:4095-4109,提示词 entity_continue_extraction_user_prompt 在 lightrag/prompt.py:143)。
轮数由 entity_extract_max_gleaning 控制(lightrag/operate.py:4066,run_gleaning = entity_extract_max_gleaning > 0)。
一个易忽略的护栏: gleaning 会把首轮的长回答塞进历史,可能撑爆模型上下文。所以补抽前先估 token,超过 MAX_EXTRACT_INPUT_TOKENS 就跳过 gleaning 而不是报错(lightrag/operate.py:4079-4093)。
补抽结果与首轮合并时,按描述长度择优:同名实体/关系,谁的描述更长留谁(lightrag/operate.py:4137-4144)。
5. 第三步:跨块合并去重
抽完后,同一个实体名会在很多块里各有一份描述。merge_nodes_and_edges(lightrag/operate.py:3399)负责把它们并成图谱里的一个点。单个实体的合并逻辑在 _merge_nodes_then_upsert(lightrag/operate.py:2356),边的合并在 _merge_edges_then_upsert(lightrag/operate.py:2702)。
合并时要做的事:
- 读已存在的节点:增量入库时图谱里可能已有这个实体,要把旧数据读出来一起合(
lightrag/operate.py:2384-2413)。 - 合并 source_id:实体的「来源块列表」用
<SEP>拼接(GRAPH_FIELD_SEP = "<SEP>",lightrag/constants.py:49),旧的 + 新的并起来(merge_source_ids,lightrag/operate.py:2433)。 - 来源数量上限:一个实体来源块太多会拖慢后续。
apply_source_ids_limit按max_source_ids_per_entity截断,策略有 KEEP / FIFO(lightrag/operate.py:2446-2453)。
6. 第四步:描述摘要(map-reduce)
一个热门实体可能被几十个块描述,描述全拼起来会很长。_handle_entity_relation_summary(lightrag/operate.py:369)决定要不要、怎么压。
它的判定逻辑(lightrag/operate.py:406-461)是一个三档决策:
| 情况 | 处理 | 是否调 LLM |
|---|---|---|
| 只有 1 条描述 | 直接返回(仅做编码净化) | 否 |
条数 < force_llm_summary_on_merge 且 总 token < summary_max_tokens | 直接用分隔符拼接 | 否 |
总 token ≤ summary_context_size(且不满足上一条) | 一次 LLM 摘要 | 是 |
| 总 token 太大 | map-reduce:切成小组各自摘要,再递归摘要,直到收敛 | 是(多次) |
map-reduce 的切组在 lightrag/operate.py:463-491:每组至少保 2 条描述以保证递归一定收敛。真正调 LLM 的是 _summarize_descriptions(lightrag/operate.py:529),用 summarize_entity_descriptions 提示词(lightrag/prompt.py:295),并特别强调「同名但其实是不同实体时,分开各自总结」(lightrag/prompt.py:309-312)。
为什么这么设计? 入库要调海量 LLM,能不调就不调。这套「条数少且短就直接拼、否则才摘要」的阶梯,把 LLM 调用省在了刀刃上。
7. 第五步:写入存储
合并后的实体/关系写进:
- 知识图谱
chunk_entity_relation_graph(点和边)。 - 实体向量库
entities_vdb、关系向量库relationships_vdb(实体名/关系描述算嵌入,供查询时语义检索)。 - 文本块向量库
chunks_vdb+ KVtext_chunks(原文块,供 mix/naive 模式和「找支撑块」用)。
所有句柄在 lightrag/lightrag.py:1410-1479 实例化。内存型后端(JSON/NanoVectorDB/NetworkX)的写入是先缓冲、在 index_done_callback 时落盘(见 BaseVectorStorage.upsert 的 multi-worker 注释,lightrag/base.py:299-308)。
8. 关键细节 / 坑
- 抽取前剥多模态标记: 喂给抽取 LLM 的内容会先剥掉
<cite refid>/<drawing>/<equation>等内部标记(strip_internal_multimodal_markup_for_extraction,lightrag/operate.py:3948),但存储的块内容保持原样,这样查询时引用仍能解析。 - Section Context: 块若带标题面包屑(如
h1 → h2),会作为「背景」注入抽取提示,但明确要求 LLM 不要从标题本身抽实体(lightrag/prompt.py:103)。 - 编码净化: 摘要/描述会过
sanitize_text_for_encoding,因为默认图存储(NetworkX→GraphML/XML)对控制字符敏感,不净化会在落盘时崩(lightrag/operate.py:632、:296-300)。
下一章:这些攒好的点和边,在查询时怎么被「双层检索」用起来 → 02-dual-level-retrieval.md。