跳到主要内容

入库流水线:文档怎么变成「知识图谱 + 向量库」

本章讲入库主线:一篇文档从原文到「图谱里的点和边」要经过哪几步,以及每步的关键设计。

1. 这一步要解决什么

纯向量 RAG 的入库很简单:切块、算向量、存。LightRAG 多做一件难事——用 LLM 把每个块读成结构化的「实体 + 关系」,再把所有块的结果攒成一张全局图谱

难点有三个,本章逐个看:

  1. 怎么让 LLM 稳定地吐出可解析的实体/关系(格式约束 + 二次补抽)。
  2. 同一个实体在不同块里反复出现,怎么合并成图谱里的一个点(跨块去重)。
  3. 一个实体被几十个块描述,描述拼起来太长,怎么压缩(map-reduce 摘要)。

2. 入库主线走一遍

SDK 入口 ainsert 固定用「定长 token 分块(F 策略)」,然后入队 + 处理(lightrag/lightrag.py:1428 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:1437-1455)。服务器 REST API 走的就是后者那条路。

文档在流水线里经过一个状态机(lightrag/base.py:793-805,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:29-39):

字母策略怎么切实现
F定长 token 窗口(默认)固定 chunk_token_size token、相邻块有 chunk_overlap_token_size 重叠chunking_by_fixed_token
R递归字符包 LangChain RecursiveCharacterTextSplitterchunking_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:72-76):tokens(token 数)、content(文本)、full_doc_id(属于哪篇文档)、chunk_order_index(在文档里的第几块)。chunk_order_index 后面在引用排序里会用到。

4. 第二步:抽实体与关系(核心)

这是入库里工程含量最高的一支。入口是 extract_entities(lightrag/operate.py:3320),它对每个 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:3351),用于支持 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:502)和 _handle_single_relationship_extraction(lightrag/operate.py:589),整体解析编排在 _process_extraction_result(lightrag/operate.py:1285)。

4.3 Gleaning:二次补抽

LLM 一次往往抽不全。LightRAG 用 gleaning(拾遗):把第一轮的「用户提问 + LLM 回答」作为对话历史,再追加一句「继续找漏掉的」的指令,让 LLM 补抽(lightrag/operate.py:3549-3563,提示词 entity_continue_extraction_user_promptlightrag/prompt.py:143)。

轮数由 entity_extract_max_gleaning 控制(lightrag/operate.py:3520,run_gleaning = entity_extract_max_gleaning > 0)。

一个易忽略的护栏: gleaning 会把首轮的长回答塞进历史,可能撑爆模型上下文。所以补抽前先估 token,超过 MAX_EXTRACT_INPUT_TOKENS跳过 gleaning 而不是报错(lightrag/operate.py:3533-3547)。

补抽结果与首轮合并时,按描述长度择优:同名实体/关系,谁的描述更长留谁(lightrag/operate.py:3589-3596)。

5. 第三步:跨块合并去重

抽完后,同一个实体名会在很多块里各有一份描述。merge_nodes_and_edges(lightrag/operate.py:2914)负责把它们并成图谱里的一个点。单个实体的合并逻辑在 _merge_nodes_then_upsert(lightrag/operate.py:2000),边的合并在 _merge_edges_then_upsert(lightrag/operate.py:2329)。

合并时要做的事:

  1. 读已存在的节点:增量入库时图谱里可能已有这个实体,要把旧数据读出来一起合(lightrag/operate.py:2020-2049)。
  2. 合并 source_id:实体的「来源块列表」用 <SEP> 拼接(GRAPH_FIELD_SEP = "<SEP>",lightrag/constants.py:49),旧的 + 新的并起来(merge_source_ids,lightrag/operate.py:2069)。
  3. 来源数量上限:一个实体来源块太多会拖慢后续。apply_source_ids_limitmax_source_ids_per_entity 截断,策略有 KEEP / FIFO(lightrag/operate.py:2082-2089)。

6. 第四步:描述摘要(map-reduce)

一个热门实体可能被几十个块描述,描述全拼起来会很长。_handle_entity_relation_summary(lightrag/operate.py:265)决定要不要、怎么压

它的判定逻辑(lightrag/operate.py:299-347)是一个三档决策:

情况处理是否调 LLM
只有 1 条描述直接返回(仅做编码净化)
条数 < force_llm_summary_on_merge 且 总 token < summary_max_tokens直接用分隔符拼接
总 token ≤ summary_context_size(且不满足上一条)一次 LLM 摘要
总 token 太大map-reduce:切成小组各自摘要,再递归摘要,直到收敛是(多次)

map-reduce 的切组在 lightrag/operate.py:349-374:每组至少保 2 条描述以保证递归一定收敛。真正调 LLM 的是 _summarize_descriptions(lightrag/operate.py:410),用 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 + KV text_chunks(原文块,供 mix/naive 模式和「找支撑块」用)。

所有句柄在 lightrag/lightrag.py:1116-1185 实例化。内存型后端(JSON/NanoVectorDB/NetworkX)的写入是先缓冲、在 index_done_callback 时落盘(见 BaseVectorStorage.upsert 的 multi-worker 注释,lightrag/base.py:289-298)。

8. 关键细节 / 坑

  • 抽取前剥多模态标记: 喂给抽取 LLM 的内容会先剥掉 <cite refid> / <drawing> / <equation> 等内部标记(strip_internal_multimodal_markup_for_extraction,lightrag/operate.py:3422),但存储的块内容保持原样,这样查询时引用仍能解析。
  • Section Context: 块若带标题面包屑(如 h1 → h2),会作为「背景」注入抽取提示,但明确要求 LLM 不要从标题本身抽实体(lightrag/prompt.py:103)。
  • 编码净化: 摘要/描述会过 sanitize_text_for_encoding,因为默认图存储(NetworkX→GraphML/XML)对控制字符敏感,不净化会在落盘时崩(lightrag/operate.py:484:296-300)。

下一章:这些攒好的点和边,在查询时怎么被「双层检索」用起来 → 02-dual-level-retrieval.md