跳到主要内容

摄入主线:从一个 PDF 到可被问答的知识图谱

本章讲写路径:process_document_complete 被调用后,一份文档怎么一步步变成知识图谱里的节点和边。

这一节讲什么

先看整条主线的骨架,再逐阶段拆。核心难点只有一个:纯文本可以直接交给 LightRAG,但图表公式不行——它们得先被「翻译」成文字,而且翻译完还要小心地接回 LightRAG 的内部存储,不能破坏 LightRAG 自己维护的文档状态。

3.1 入口与主线骨架

process_document_complete(processor.py:1660,ProcessorMixin)是端到端入口。剥掉错误处理和回调,主线只有四步:

# 示意,浓缩自 processor.py:1708-1791
content_list, doc_id = await self.parse_document(file_path, ...) # ① 解析
text_content, multimodal_items = separate_content(content_list) # ② 分离
if text_content.strip():
await insert_text_content(self.lightrag, input=text_content, ids=doc_id, ...) # ③ 文本入库
if multimodal_items:
await self._process_multimodal_content(multimodal_items, file_name, doc_id) # ④ 模态处理

为什么文本和模态分两条路? 文本是 LightRAG 的「母语」,直接 ainsert 即可,LightRAG 会自己切块、抽实体、建图、写 doc_status。模态块没有这个待遇——它们要先被处理器翻译,再由 RAG-Anything 手动塞进 LightRAG 的各个存储。所以模态是一条平行的、自己搭的流水线。

3.2 阶段①:解析 → content_list

parse_document(processor.py:388)按文件扩展名分派到解析器的不同方法:PDF 走 parse_pdf、图片走 parse_image、Office/HTML 走 parse_office_doc,其余走通用 parse_document。解析跑在 asyncio.to_thread 里(解析是同步重活,不能阻塞事件循环)。

产物是 content_list —— 这是贯穿全系统的核心数据结构,一个保留文档顺序的字典数组。每项形如:

type关键字段说明
texttext, text_level, page_idx正文;text_level>0 表示是标题
imageimg_path, image_caption, image_footnote, page_idx图片(绝对路径)
tabletable_body, table_caption, table_footnote表格(markdown 或行列)
equationlatex / text, text_format公式

解析结果会被缓存(基于「文件路径 + mtime + 解析配置」的 MD5),见 _generate_cache_key(processor.py:50)。同一文件没改、配置没变,第二次直接命中缓存跳过解析。

doc_id 来自内容而非文件名。 _generate_content_based_doc_id(processor.py:202)把每个块的关键内容拼成一个签名串再做 hash,前缀 doc-。好处:同一份内容无论叫什么文件名都得到同一个 id,天然去重。

3.3 阶段②:separate_content 拆流

separate_content(utils.py:172)遍历 content_list,做两件事:

  • type=="text" 的块:把 text 拼进一个大字符串(用 \n\n 连接)。
  • 其余块:原样收进 multimodal_items,并埋两个隐藏字段给后续用:
    • _content_list_index:它在原数组里的位置(后面排序 chunk 用)。
    • 对图片额外算 _section_path(它所在的章节路径,如 Introduction > Method)和 _neighbor_text(前后几个文字块),帮 VLM 理解上下文。

这两个隐藏字段是个巧妙处:在分离阶段就顺手把「这张图在文档里的位置感」固化下来,因为此刻还能看到完整有序的 content_list,后面分块并发处理时就看不到全局了。_section_path 的算法是经典的「标题栈」——见 extract_section_path_from_content_list(utils.py:91)。

3.4 阶段④:模态批处理的 7 个子阶段

这是全章重点。_process_multimodal_content_batch_type_aware(processor.py:884)把模态块变成图谱内容,分 7 步:

怎么读这张图

从上到下是先后顺序。Stage 1 是并发的(每个模态块一个协程),其余是串行批处理。

Stage 1 并发生成描述 每个块 → 选对处理器 → 调 VLM/LLM → (描述, 实体信息)
│ (Semaphore 限流,默认 max_parallel_insert=2)

Stage 2 套 chunk 模板 描述 + 原始数据 → 格式化成一段标准文本块


Stage 3 写 chunk 存储 text_chunks(给抽取用) + chunks_vdb(给检索用)


Stage 3.5 写「模态主实体」 把 "实验结果图 (image)" 这种实体写进 entities_vdb + 图谱 + full_entities


Stage 4 抽实体关系 复用 LightRAG 的 extract_entities 从 chunk 里挖实体/关系


Stage 5 加 belongs_to 关系 把抽出的子实体都「归属」到模态主实体名下(权重 10.0)


Stage 6 合并入图谱 复用 LightRAG 的 merge_nodes_and_edges 落库


Stage 7 更新 doc_status 把模态 chunk 的 id 并进文档的 chunks_list

Stage 1:为什么先只生成描述

注意处理器有两个方法:generate_description_only(只翻译,不入库)和 process_multimodal_content(翻译 + 入库)。批处理走的是前者——先把所有块的「翻译」并发跑完(这一步最慢,要等 LLM/VLM),拿到 (description, entity_info) 列表,再统一走后面的入库阶段。这样 LLM 调用能并发吃满,而对存储的写入保持有序批量。Stage 1 的并发协程见 process_single_item_with_correct_processor(processor.py:921)。

Stage 2:chunk 模板——模态块长什么样

模态块不是裸描述,而是套了模板的结构化文本。以图片为例(prompt.py:334,PROMPTS["image_chunk"]):

Image Content Analysis:
- Section Path: Introduction > Method
- Neighbor Text: 前后正文……
Image Path: /abs/path/fig3.jpg
Captions: Figure 3: 实验曲线
Footnotes: None

Visual Analysis: <VLM 生成的详细描述>

关键:Image Path: 这一行被原样保留进 chunk 文本。这不是冗余——查询阶段的 VLM 增强正是靠正则匹配这一行,才能把图片路径找回来(见 03-query.md)。模板套用逻辑在 _apply_chunk_template(processor.py:1109)。

Stage 5:belongs_to 是怎么回事

这是 RAG-Anything 把模态接进图谱的核心嫁接术

问题:LightRAG 的 extract_entities 从图片描述里能挖出一堆普通实体(比如「准确率」「测试集」),但这些实体和「这张图本身」是断开的。RAG-Anything 想表达「这些实体都出自图 3」。

做法:为每个抽出的子实体,补一条指向模态主实体(如 实验结果图 (image))的 belongs_to 边,权重设成显眼的 10.0:

# 示意,见 processor.py:1437-1451 _batch_add_belongs_to_relations_type_aware
belongs_to_relation = {
"src_id": entity_name, # 子实体,如「准确率」
"tgt_id": modal_entity_name, # 模态主实体,如「实验结果图 (image)」
"keywords": "belongs_to,part_of,contained_in",
"weight": 10.0, # 高权重:检索时优先把同图实体聚到一起
"source_id": chunk_id,
}

效果:检索命中「准确率」时,顺着这条高权重边能把整张图的相关实体都拉出来,保持「同一张图的内容在检索结果里抱团」。这就是 README 说的 "Hierarchical Structure Preservation"。

Stage 4 / 6:为什么直接复用 LightRAG 的函数

Stage 4 调 extract_entities、Stage 6 调 merge_nodes_and_edges,都是从 lightrag.operate 直接 import 的(modalprocessors.py:27)。RAG-Anything 不重写实体抽取和图谱合并——它把翻译好的模态文本伪装成普通 chunk,然后调 LightRAG 的标准管线处理。这是整个设计的精髓:模态块在 Stage 2 之后就「变成了文本」,后面完全复用文本 RAG 的机器。

3.5 失败兜底:批处理挂了怎么办

_process_multimodal_content(processor.py:609)在批处理(_process_multimodal_content_batch_type_aware)抛异常时,会退回到逐个处理的 _process_multimodal_content_individual(processor.py:727)。逐个版用处理器的 process_multimodal_content(翻译 + 入库一体),牺牲并发换稳健。两条路最后都会调 _mark_multimodal_processing_complete 标记完成。

3.6 doc_status:两条进度要分别记

一个文档有两份处理进度:文本处理(LightRAG 管)和模态处理(RAG-Anything 管)。LightRAG 可能在文本处理完就把状态标成 PROCESSED,但此时模态还没处理。所以 RAG-Anything 额外维护一个 multimodal_processed 布尔标记:

  • 优先写进 doc_status 记录里(_mark_multimodal_processing_complete,processor.py:1539)。
  • 老版本 LightRAG 的 doc_status schema 不认识 multimodal_processed 这个字段会报错——于是有兜底:退回只更新 status,把模态标记写进一个独立的 KV 命名空间 multimodal_status(_set_multimodal_status_record,processor.py:171)。

「文本 + 模态都完成」才算真正处理完,见 is_document_fully_processed(processor.py:1580)。这种「主存储不收就降级到旁路 KV」的兼容写法,是本库为了适配不同版本 LightRAG 反复出现的模式。

关键细节 / 坑

  • 模态块的 chunk_order_index 接在文本 chunk 之后(existing_chunks_count + index,processor.py:977),保证模态内容在文档里的排序位置合理。
  • 纯模态文档(没有文本) 需要 RAG-Anything 提前手动建 doc_status 记录(processor.py:1724),因为不会调 ainsert,LightRAG 不会替它建。有文本时则不能提前建,否则 LightRAG 会把这次插入当成重复文档。
  • chunk_id 由内容 hash 而来(compute_mdhash_id(formatted_chunk_content, prefix="chunk-")),所以 Stage 2/3.5/5 里反复重算同一段模板内容能得到同一个 chunk_id——这是它们能对上号的前提。