整体认知与技术总览 链接到标题

  今天我们聚焦的是 LlamaIndex 在图文混排 PDF 处理领域的系统性解决方案。在生成式 AI 重塑企业知识管理的今天,非结构化数据的处理能力决定了 RAG 系统的核心竞争力。而 PDF——这个诞生于 1993 年的"数字纸张"格式——正成为横亘在 AI 与企业知识之间最顽固的壁垒。

  结合实战验证与技术调研,我们会重点抓住三个核心维度:

  • 第一,为什么图文混排 PDF 是 RAG 的"最后一公里"难题;

  • 第二,LlamaIndex 如何通过 8 步渐进式实战路径系统性地解决这些痛点;

  • 第三,不同技术方案(SimpleDirectoryReader、PyMuPDF、LlamaParse、MinerU)的能力边界与选型策略。

  为了把这些内容讲清楚,接下来我们会按"问题定位 → 技术全景 → 能力进阶"的主线展开。我们会先从企业数据现状出发,理解四大技术挑战的本质,然后通过 8 步实战技术地图建立对解决方案的整体认知,最终明确从 L0 基础到 L5 智能体的完整学习路径。

一、为何图文混排 PDF 是 RAG 的"最后一公里"? 链接到标题

  在开始技术方案之前,我们需要先理解问题的本质。这一章将从企业数据现状出发,剖析图文混排 PDF 处理的四大核心挑战,以及传统方案为何在这些场景下失效。只有深刻理解了问题,才能真正掌握后续技术方案的设计思路。

1.1 企业数据现状:80% 的高价值信息被"锁"在 PDF 中 链接到标题

  据估计,企业知识库中超过 80% 的高价值信息——财务报表、技术规格书、法律合同、科研论文——被锁定在 PDF 格式中。这个比例背后反映的是一个根本矛盾:PDF 的设计初衷是确保在任何设备上视觉呈现的一致性,而非数据的可机器读取性

  PDF 的本质是"数字纸张",内部实际上是一系列绘图指令的集合(“在坐标 X,Y 处放置字符 Z”),而不是语义化的文档结构。这导致了人类视角与机器视角的巨大鸿沟:

人类视角 vs 机器视角的认知差异

维度人类视角机器视角
多栏布局自动按列阅读跨列串读,句子断裂
表格清晰的行列结构扁平的字符串流
图表关键数据可视化被丢弃或仅保留占位符
标题层级明确的逻辑结构仅凭字号区分,易误判

  这种认知差异使得传统的文本提取方案在处理图文混排 PDF 时,最多只能发挥 30% 的潜力。

1.2 四大技术挑战:当传统 RAG 遇上复杂 PDF 链接到标题

  当 RAG 系统试图"理解"图文混排的 PDF 时,面临着多维度的技术挑战。这些挑战不是孤立的,而是相互交织、层层递进的:

图文混排 PDF 的四大技术挑战

挑战现象描述技术根源影响范围
布局语义丢失多栏论文的"第一栏第一行"直接连接"第二栏第一行",句子完全错乱传统解析器按线性流提取文本学术论文、技术白皮书
多模态信息割裂用户问"图 3 展示了什么趋势?",系统答"文档中未提及图 3"纯文本提取完全忽略图像层技术文档、研究报告
表格结构熵增用户问"Q4 营收是多少?",系统回答错误数值表格被扁平化,列数据错位财务报表、数据分析
上下文碎片化答案断章取义,缺乏前因后果定长分块将图表标题与图表本身切断所有复杂文档

  这四大挑战构成了一个递进的技术难度金字塔:布局语义丢失是基础层问题,多模态信息割裂和表格结构熵增是中间层问题,而上下文碎片化则是顶层的系统性问题。只有系统性地解决这些挑战,才能构建真正可用的图文混排 PDF RAG 系统。

1.3 传统方案为何失效? 链接到标题

  以最常用的 pypdf 库为例,传统流程是:PDF → pypdf 提取文本 → 固定分块 → 向量化 → 检索 → 生成。这个看似完整的流程,在面对真实世界的图文混排 PDF 时,存在四个致命缺陷:

  缺陷一:只能提取"可编辑"文本。扫描件 PDF 完全无法处理,这意味着大量历史文档、合同、发票等关键业务数据被排除在外。

  缺陷二:忽略所有图像内容。图表、流程图、架构图全部丢失,而这些视觉元素往往承载着文档中最核心的信息。

  缺陷三:表格被"压扁"。二维结构变成一维字符串,行列语义消失,导致 LLM 在回答涉及表格数据的问题时产生幻觉。

  缺陷四:机械分块。不理解文档结构,可能将"图 1"和"图 1 的描述文字"切断在不同的检索块中,破坏了语义的完整性。

小结:传统的"文本提取 + 向量检索"范式,在面对真实世界的图文混排 PDF 时,最多只能发挥 30% 的潜力。这就是为什么图文混排 PDF 被称为 RAG 的"最后一公里“难题。

二、LlamaIndex 解决方案:从 6 步实战看技术全景 链接到标题

  在理解了问题的本质之后,我们需要建立对解决方案的整体认知。LlamaIndex 通过引入"数据代理"和"上下文增强"的理念,从根本上重构了非结构化数据的处理管线。这一章将从 6 步实战技术地图出发,展示 LlamaIndex 如何系统性地解决图文混排 PDF 的处理难题。

2.1 实战技术地图 链接到标题

  本课程将按照由浅入深、渐进式验证的路径,带你走过 PDF RAG 的完整技术演进。与传统的"四阶段"划分不同,我们采用更细粒度的 6 步实战路径,每一步都对应一个明确的技术能力提升:

LlamaIndex 6 步实战技术地图

步骤核心技术能力层级解决的痛点验证重点
步骤 1SimpleDirectoryReader + VectorStoreIndexL1 入门快速验证 MVP基础文本检索能力
步骤 2PyMuPDF + MultiModalVectorStoreIndexL2 进阶多模态信息割裂图文联合检索
步骤 3LlamaParse(4 种解析模式)L4 企业级布局语义丢失 + 表格结构熵增Markdown 结构化输出
步骤 4MinerU(VLM+OCR 双模型)L4 企业级扫描件/中文识别差VLM+OCR 分层解析
步骤 5三大方案对比实验对比验证选型决策困难准确率、成本、速度对比
步骤 6ReActAgent + Tool CallingL5 智能体静态检索局限多步推理与工具调用

  这个 6 步路径的设计遵循三个核心原则:

  原则一:渐进式能力提升。从 L1 基础入门到 L5 智能体,每一步都在前一步的基础上增加新的技术能力,确保学习曲线平滑。

  原则二:痛点驱动。每一步都明确对应一个或多个实际痛点,避免为了技术而技术,确保学到的每个技术点都有明确的应用场景。

  原则三:可验证性。每一步都有明确的验证重点和成功判据,学习者可以通过实际运行结果判断是否真正掌握了该技术。

2.2 技术选型对比:解析工具全景 链接到标题

  在动手之前,我们需要理解不同解析工具的能力边界。LlamaIndex 的价值不在于提供单一解决方案,而在于编排能力——它让你可以根据文档类型灵活选择解析器,并统一接入后续的索引、检索、生成流程。

四大解析工具能力对比

工具类型文本提取图片提取表格重建OCR成本适用场景
pypdf开源库✅ 基础免费纯文本 PDF,快速验证
PyMuPDF开源库✅ 较好免费需要提取图片的场景
LlamaParse云服务✅ 优秀✅✅~$0.003/页企业级,复杂表格
MinerU云服务/本地✅ 优秀✅✅~$0.01/页扫描件,中文 OCR

  从上表可以看出,不同工具在能力、成本、部署方式上存在显著差异。没有"最好"的工具,只有"最合适"的工具。选型建议如下:

  快速原型:pypdf → 5 分钟验证概念,零成本快速上线。

  需要图片:PyMuPDF → 开源免费,图片提取可靠,适合图文混排但表格简单的场景。

  生产级表格:LlamaParse → 表格重建专家,Markdown 输出,适合财务报表、数据分析等场景。

  扫描件/中文:MinerU → PaddleOCR 加持,中文识别极佳,支持本地部署,适合数据隐私要求高的企业。

三、实战拆解与逐步运行讲解 链接到标题

  现在我们将通过可运行的代码,逐步验证每一个技术方案的实际效果。

  本次实战的核心价值在于:不仅要让代码跑通,更要理解每一步背后的技术决策、中间结果的业务含义,以及不同方案之间的性能与成本权衡。我们会从最简单的 SimpleDirectoryReader 开始,逐步演进到企业级的 LlamaParse、专注扫描件的 MinerU,最终实现基于 ReActAgent 的智能体推理。

步骤 1:环境与依赖准备 链接到标题

  在任何 RAG 项目中,环境准备都是第一步,但这不仅仅是简单的 pip install。我们需要理解每个依赖包的作用、版本兼容性,以及可能遇到的环境问题。特别注意:Jupyter Notebook 环境中不存在 __file__ 变量,因此所有路径处理必须使用相对路径或 os.getcwd()

1.1 依赖包安装 链接到标题

  首先我们需要安装 LlamaIndex 核心库及其扩展包。以下是本次实战需要的所有依赖:

# 安装依赖(在 Jupyter 中执行)
#!pip install llama-index-core llama-index-llms-openai llama-index-embeddings-openai
#!pip install pypdf PyMuPDF python-dotenv

  每个包的具体作用如下:

依赖包作用说明

依赖包作用是否必需备注
llama-index-coreLlamaIndex 核心框架✅ 必需提供索引、检索、查询引擎
llama-index-llms-openaiOpenAI LLM 集成✅ 必需支持 GPT-4o 等模型
llama-index-embeddings-openaiOpenAI Embedding 集成✅ 必需文本向量化
pypdfPDF 基础解析库⚠️ 可选SimpleDirectoryReader 依赖
PyMuPDF (fitz)高级 PDF 操作库✅ 必需图片提取、多模态索引
python-dotenv环境变量管理✅ 必需安全加载 API Key

1.2 环境变量配置 链接到标题

  API Key 的配置是 RAG 项目的核心安全点。我们采用 .env 文件 + python-dotenv 的标准方案,避免在代码中硬编码敏感信息。

import os
from dotenv import load_dotenv

# 加载环境变量(.env 文件中需要配置 OPENAI_API_KEY)
load_dotenv(override=True)

# 验证 API Key
api_key = os.getenv("OPENAI_API_KEY")
base_url = os.getenv("OPENAI_BASE_URL")
if api_key:
    print(f"✅ OPENAI_API_KEY 已配置: {api_key[:20]}...")
else:
    raise ValueError("❌ 请设置 OPENAI_API_KEY 环境变量")

  这里的 override=True 参数确保每次重新运行时都会重新读取 .env 文件,避免配置更新后需要重启 Kernel。

常见问题排查:如果提示找不到 .env 文件,请在 Notebook 同级目录创建该文件,并添加:

OPENAI_API_KEY=your_key_here
OPENAI_BASE_URL=https://api.openai.com/v1  # 可选

步骤 2:核心配置与参数全览 链接到标题

  在 LlamaIndex 中,全局配置通过 Settings 对象统一管理。理解这些配置参数不仅能让代码正确运行,更重要的是能够根据业务需求进行针对性优化。这一步看似简单,但实际上决定了整个 RAG 系统的成本、速度、准确性三大核心指标。

2.1 LLM 配置(大语言模型) 链接到标题

  LLM 是 RAG 系统的"大脑”,负责理解查询意图、生成最终答案。我们这里使用 OpenAI 的 gpt-4o 模型,它在多模态理解和推理能力上表现优秀。

from llama_index.core import Settings
from llama_index.llms.openai import OpenAI
from llama_index.embeddings.openai import OpenAIEmbedding

# 配置 LLM(大语言模型)
Settings.llm = OpenAI(
    model="gpt-4o",           # 使用 GPT-4o 模型
    temperature=0,             # 降低随机性,输出更稳定
    api_key=api_key,
    api_base=base_url          # 可选:使用代理 API
)

# 配置 Embedding 模型(用于向量化)
Settings.embed_model = OpenAIEmbedding(
    model="text-embedding-3-small",  # OpenAI 最新嵌入模型
    api_key=api_key,
    api_base=base_url
)

2.2 关键参数解析与成本优化 链接到标题

  这些参数对系统的影响和优化建议如下:

LLM 与 Embedding 配置参数对比

配置项默认值推荐值作用说明成本影响
model (LLM)gpt-3.5-turbogpt-4o模型能力决定答案质量gpt-4o: $5/1M tokens
temperature0.70控制输出随机性(0=确定性)无直接影响
model (Embedding)text-embedding-ada-002text-embedding-3-small向量化质量影响检索准确率$0.02/1M tokens
chunk_size1024512-2048文本分块大小影响检索粒度与成本

  从上表可以看到,Embedding 成本远低于 LLM 成本(约为 1/100)。因此在实际项目中,我们应该:

  • 优先优化 LLM 调用次数(如减少无效查询、缓存结果)

  • Embedding 可以使用更高维度的模型(如 text-embedding-3-large)以提升检索准确率

  • temperature=0 确保在相同查询下输出稳定,便于调试和 A/B 测试

步骤 3:最小可行 RAG(L1 入门) 链接到标题

3.1 文档加载与初步分析 链接到标题

  SimpleDirectoryReader 是 LlamaIndex 提供的通用文档加载器,它能自动识别 PDF、DOCX、TXT 等常见格式,并调用对应的解析器提取文本内容。对于 PDF 文件,它默认使用 pypdf 库进行解析。

from pathlib import Path
from llama_index.core import SimpleDirectoryReader

# 指定文档目录
doc_dir = Path("./data")

if not doc_dir.exists():
    raise FileNotFoundError(f"❌ 文档目录不存在: {doc_dir}")

# 创建文档读取器
reader = SimpleDirectoryReader(
    input_dir=str(doc_dir),    # 目标文件夹
    recursive=True,             # 递归读取子目录
    required_exts=[".pdf"]      # 仅处理 PDF 文件
)

# 加载文档
documents = reader.load_data()

print(f"✅ 成功加载 {len(documents)} 个文档片段")

# 查看第一个文档的结构
if documents:
    doc = documents[0]
    print(f"\n📄 示例文档结构:")
    print(f"   - 文本长度: {len(doc.text)} 字符")
    print(f"   - 元数据: {doc.metadata}")

3.2 中间结果分析:Document 对象的结构 链接到标题

  Document 对象的内部结构对于后续的索引构建至关重要:

  • text 字段:存储从 PDF 提取的原始文本内容。pypdf 会按照 PDF 的逻辑顺序提取文本,但无法理解多栏布局,可能出现跨列读取的问题。

  • metadata 字段:包含文件路径、文件名等元信息,这些信息会被传递到后续的检索结果中,便于溯源。

  • 分页策略SimpleDirectoryReader 默认将整个 PDF 文件作为一个 Document,而不是按页拆分。这意味着如果 PDF 有 50 页,documents[0].text 会包含所有 50 页的文本。

  这种"全文档加载"的策略在简单场景下没有问题,但在复杂图文混排 PDF 中会带来两个问题:

  1. 单个 Document 过大,后续分块时可能切断语义

  2. 图片信息完全丢失,无法回答"图中展示了什么"的问题

3.3 索引构建与向量化 链接到标题

  接下来我们要将文档转换为可检索的向量索引。VectorStoreIndex 会自动完成三个关键步骤:

from llama_index.core import VectorStoreIndex

# 从文档构建向量索引
index = VectorStoreIndex.from_documents(
    documents,
    show_progress=True  # 显示进度条
)

print("✅ 向量索引构建完成")

在这个过程中,LlamaIndex 自动执行了以下操作:

  1. 文档切分(Chunking):将长文本按 chunk_size(默认 1024 tokens)切分为多个小块(Node)

  2. 向量化(Embedding):调用我们在步骤 2 中配置的 OpenAIEmbedding 模型,将每个文本块转换为向量

  3. 存储(Storage):将向量和原始文本存储到内存向量库(默认使用 SimpleVectorStore)

  通过以下代码可以查看索引的内部结构:

# 查看索引元数据
print(f"\n🔍 索引内部结构:")
print(f"   - 节点数量: {len(index.docstore.docs)} 个")
print(f"   - 向量维度: {index._embed_model.embed_batch_size}")

# 查看第一个节点的结构
first_node_id = list(index.docstore.docs.keys())[0]
first_node = index.docstore.docs[first_node_id]
print(f"\n📦 示例节点 (Node) 结构:")
print(f"   - 文本长度: {len(first_node.text)} 字符")
print(f"   - Node ID: {first_node.node_id}")
print(f"   - 元数据: {first_node.metadata}")

3.4 执行查询与结果分析 链接到标题

  将索引转换为查询引擎(QueryEngine),执行实际的问答任务:

# 将索引转换为查询引擎
query_engine = index.as_query_engine(
    similarity_top_k=3  # 每次检索最相关的 3 个文档块
)

# 执行查询
question = "这篇文档主要讨论了哪些核心议题?"
print(f"\n❓ 问题: {question}")

response = query_engine.query(question)
print(f"\n💡 回答:\n{response}")

# 查看检索到的源文档
print("\n📚 检索到的源文档:")
for i, node in enumerate(response.source_nodes):
    print(f"\n--- 文档 {i+1} (相似度: {node.score:.4f}) ---")
    print(f"内容预览: {node.text[:200]}...")

3.5 SimpleDirectoryReader vs PyMuPDF 性能对比 链接到标题

  在进入下一步之前,需要理解 SimpleDirectoryReader(基于 pypdf)的局限性。让我们通过对比表格来看看它与更强大的 PyMuPDF 的差异:

SimpleDirectoryReader vs PyMuPDF 对比

对比维度SimpleDirectoryReader (pypdf)PyMuPDF (fitz)
文本提取速度中等⚡ 极快(业界最快)
图片提取能力❌ 不支持✅ 完美提取(像素级)
表格识别❌ 压扁为文本❌ 同样弱
多栏布局处理❌ 跨列读取❌ 无语义理解
代码复杂度⭐ 极简(2行代码)⭐⭐ 简单(需手动处理)
适用场景MVP 快速验证需要图片的多模态场景

  从上表可以看出,SimpleDirectoryReader 的优势在于极简的使用方式,但在处理图文混排 PDF 时,它只能提取文本,图片信息会完全丢失。因此在步骤 4 中,我们将引入 PyMuPDF 来解决多模态信息割裂的问题。

步骤 4:多模态索引与检索(L2 进阶) 链接到标题

  在步骤 3 中,我们验证了基础的文本检索能力,但遇到了一个关键问题:系统无法回答"图中展示了什么"这类问题。这是因为 SimpleDirectoryReader 只提取了文本,图片信息被直接丢弃。现在我们要让系统具备"看图"的能力,实现真正的多模态检索。

4.1 为什么需要多模态索引? 链接到标题

  在实际的 PDF 文档中,大量关键信息是通过图表、流程图、架构图等视觉元素传递的。例如:

  • 技术文档中的系统架构图

  • 学术论文中的实验数据图

  • 合同文件中的签名和印章

  • 财务报表中的趋势图

  如果只依赖文本检索,LLM 无法理解这些视觉信息,导致答案不完整甚至错误。MultiModalVectorStoreIndex 通过以下方式解决这个问题:

  1. 使用 PyMuPDF 提取 PDF 中的所有图片

  2. 将图片和文本分别向量化

  3. 在检索时同时匹配文本和图片

  4. 使用支持视觉输入的多模态 LLM(如 GPT-4o)生成答案

4.2 安装多模态依赖 链接到标题

  首先我们需要安装额外的依赖包:

# 安装多模态依赖
#!pip install llama-index-multi-modal-llms-openai PyMuPDF pillow llama-index-embeddings-clip git+https://github.com/openai/CLIP.git

  其中:

  • llama-index-multi-modal-llms-openai:提供 GPT-4o 的多模态接口

  • PyMuPDF:用于精确提取 PDF 中的图片

  • pillow:图片处理库,用于图片格式转换

  • llama-index-embeddings-clip:LlamaIndex 提供的 CLIP 向量化接口封装,用于生成文本和图像的多模态向量

  • git+https://github.com/openai/CLIP.git:OpenAI 官方 CLIP 模型的核心实现库,提供模型架构与权重加载,是上述接口的底层依赖

4.3 使用 PyMuPDF 提取图片 链接到标题

  PyMuPDF(导入名为 fitz)提供了强大的图片提取能力。我们先定义一个辅助函数来提取指定页面的所有图片:

import os
import fitz  # PyMuPDF
from pathlib import Path

def extract_images_from_pdf_page(doc, page_index: int, output_dir: str) -> list:
    """
    从 PDF 的指定页面提取所有图片并保存到磁盘

    Args:
        doc: PyMuPDF Document 对象
        page_index: 页面索引(从 0 开始)
        output_dir: 图片保存目录

    Returns:
        list: 保存的图片路径列表
    """
    # 获取指定页面
    page = doc[page_index]

    # 获取页面中的所有图片列表
    image_list = page.get_images(full=True)
    saved_images = []

    for image_index, img in enumerate(image_list):
        # xref 是图片的引用 ID
        xref = img[0]
        
        # 提取图片二进制数据和扩展名
        base_image = doc.extract_image(xref)
        image_bytes = base_image["image"]
        image_ext = base_image["ext"]

        # 生成唯一的图片文件名
        image_filename = f"page_{page_index+1}_img_{image_index+1}.{image_ext}"
        image_path = os.path.join(output_dir, image_filename)

        # 将图片数据保存到本地
        with open(image_path, "wb") as img_file:
            img_file.write(image_bytes)

        saved_images.append(image_path)

    return saved_images

  使用这个函数提取 PDF 中的所有图片:

# 使用 PyMuPDF 提取 PDF 中的图片
pdf_path = "./data/测试文档.pdf"
output_dir = "./extracted_images"

# 检查 PDF 文件是否存在
if Path(pdf_path).exists():

    # 创建图片输出目录
    os.makedirs(output_dir, exist_ok=True)
    print(f"\n📷 正在从 PDF 提取图片...")

    # 打开 PDF 文档
    doc = fitz.open(pdf_path)
    all_image_paths = []
    
    # 遍历文档的每一页
    for i in range(len(doc)):
        
        # 提取当前页面的所有图片并返回保存路径
        paths = extract_images_from_pdf_page(doc, i, output_dir)
        all_image_paths.extend(paths)
    
    # 关闭文档释放资源
    doc.close()

    print(f"✅ 共提取 {len(all_image_paths)} 张图片")
    print(f"📂 图片保存路径: {output_dir}")
else:
    print(f"⚠️ PDF 文件不存在: {pdf_path}")
    all_image_paths = []

4.4 中间结果分析:图片提取的内部机制 链接到标题

  深入理解 PyMuPDF 的图片提取原理:

  1. get_images(full=True):返回页面中所有图片的引用列表(ID列表)。full=True 表示包含完整的图片信息(包括尺寸、颜色空间等)

  2. xref(交叉引用):PDF 内部使用 xref 来标识对象。每个图片都有一个唯一的 xref,我们通过它来提取实际的图片数据

  3. extract_image(xref):根据 xref 提取图片的二进制数据、格式(PNG/JPEG)等信息

  4. 像素级完美提取:PyMuPDF 提取的是 PDF 中嵌入的原始图片,不是渲染后的截图,因此质量无损

  执行后,你会在 extracted_images 目录下看到提取的所有图片,文件名格式为 page_1_img_1.png,方便追溯图片来源。

4.5 构建多模态索引 链接到标题

  将提取的图片和原始文本一起构建多模态索引: 这里必须提前安装依赖:

  • git+https://github.com/openai/CLIP.git`

  • llama-index-embeddings-clip

from llama_index.multi_modal_llms.openai import OpenAIMultiModal
from llama_index.core.indices import MultiModalVectorStoreIndex
from llama_index.core import SimpleDirectoryReader

# 1. 配置多模态 LLM (GPT-4o)
openai_mm_llm = OpenAIMultiModal(
    model="gpt-4o",
    api_key=api_key,
    api_base=base_url,
    max_new_tokens=500
)

# 2. 加载数据:整合提取出的图片与原始文本
if all_image_paths:
    # 从指定输出目录加载所有提取到的图片文档
    image_reader = SimpleDirectoryReader(input_dir=output_dir)
    image_docs = image_reader.load_data()
    print(f"✅ 加载 {len(image_docs)} 个图片文档")
else:
    # 如果没有图片路径,则初始化为空列表以便后续合并
    image_docs = []

# 从原始 PDF 文件中加载文本内容
text_reader = SimpleDirectoryReader(input_files=[pdf_path])
text_docs = text_reader.load_data()

# 将文本和图片文档合并为统一的列表,作为多模态索引的数据源
all_docs = text_docs + image_docs

# 3. 构建多模态索引
print(f"\n🔧 正在构建多模态索引...")
mm_index = MultiModalVectorStoreIndex.from_documents(
    all_docs
)
print("✅ 多模态索引构建完成")

4.6 MultiModalVectorStoreIndex 内部结构解析 链接到标题

  多模态索引的工作机制是本步骤最核心的技术点:

  关键设计

  1. 双向量库架构

    • 文本向量:使用 OpenAIEmbedding (text-embedding-3-small)

    • 图片向量:使用 CLIP 模型(将图片转换为与文本同一向量空间)

  2. 统一检索接口

    • 用户查询被同时转换为文本向量多模态向量

    • 在文本库和图片库中分别进行相似度搜索

    • 合并结果并按相似度排序

  3. 节点类型区分

    • TextNode:包含文本内容 + 元数据

    • ImageNode:包含图片路径 + 图片嵌入向量

  我们可以通过以下代码验证索引中的节点类型分布:

from llama_index.core.schema import ImageNode, TextNode

# 统计节点类型
text_nodes = [n for n in mm_index.docstore.docs.values() if isinstance(n, TextNode)]
image_nodes = [n for n in mm_index.docstore.docs.values() if isinstance(n, ImageNode)]

print(f"\n📊 索引节点统计:")
print(f"   - 文本节点: {len(text_nodes)} 个")
print(f"   - 图片节点: {len(image_nodes)} 个")
print(f"   - 总节点数: {len(mm_index.docstore.docs)} 个")

4.7 执行多模态查询 链接到标题

  执行包含图片内容的查询。注意:由于 CLIP 模型的限制,检索查询不能超过 77 个 tokens,因此我们需要使用简短的查询进行检索,然后用完整问题进行答案生成。展示了所谓 “Small-to-Big” (小搜大问) 或者 检索与生成解耦 的高级用法。

  • 如图所示报错:
from llama_index.core import QueryBundle

# 1. 构造简短 Query 用于检索(避开 77 token 限制)
retrieval_query = "LangChain architecture diagram Data Connection Agents"
retrieval_bundle = QueryBundle(query_str=retrieval_query)

# 2. 检索相关节点
retriever = mm_index.as_retriever(similarity_top_k=3)
retrieved_nodes = retriever.retrieve(retrieval_bundle)

# 3. 使用完整问题进行问答
full_question = """文档第一页文本中提到"LangChain 像搭乐高积木一样组合组件",请结合该页的架构图解释:位于 "Data Connection" 和 "Agents" 之间的核心模块是什么,它在乐高组合中起到了什么链接作用?请使用中文回答"""

print(f"\n❓ 完整问题: {full_question}")

# 4. 创建查询引擎并生成答案
engine = mm_index.as_query_engine(
    image_llm=openai_mm_llm,
    similarity_top_k=3
)

# 将完整问题封装为 QueryBundle 对象
full_query_bundle = QueryBundle(query_str=full_question)

# 4. 创建引擎并合成回答
# 注意:我们需要先创建一个"空"的 engine,然后手动调用 synthesize
result = engine.synthesize(
    full_query_bundle, 
    nodes=retrieved_nodes
)

print(f"\n💡 回答:\n{result}")

# 展示检索到的图片引用
print("\n📚 检索到的源节点:")
for i, node in enumerate(retrieved_nodes):
    node_type = type(node.node).__name__
    print(f"  [{i+1}] 类型: {node_type}, 相似度: {node.score:.4f}")
    
    if isinstance(node.node, ImageNode):
        file_path = node.node.metadata.get('file_path', '未知')
        print(f"      📷 引用图片: {file_path}")
    else:
        print(f"      📄 文本预览: {node.node.text[:100]}...")

  查询过程解析:整体是分开两次查询的

  当你在代码里执行 retriever.retrieve(query_str) 时,LlamaIndex 实际上在后台干了两件事:

  • 动作 1:把 query_str 扔给 OpenAI 模型,拿到 Vec_A (1536维),然后去搜文本库。

  • 动作 2:把同一个 query_str 扔给 CLIP 模型,拿到 Vec_B (512维),然后去搜图片库。

最后,它就像一个汇总员,把两边搜回来的结果(比如 3 段文字 + 2 张图片)放在一个篮子里返回给你。所以这两条线从头到尾都不需要“见面”,直到最后汇总结果。

  总结 您把它想象成一个**“双语翻译官”**就对了:

  • 对着 OpenAI 说“英语”(1536维)去找英文书(文本库)。

  • 对着 CLIP 说“法语”(512维)去找法文画册(图片库)。 两本字典各查各的,互不影响。

4.8 图文检索结果对比分析 链接到标题

  执行后你会发现,source_nodes 中不仅包含文本节点,还包含图片节点。GPT-4o 会"看"这些图片并结合文本生成答案。让我们对比一下有无图片时的答案质量:

纯文本检索 vs 图文联合检索效果对比

对比维度纯文本检索(步骤 3)图文联合检索(步骤 4)
回答准确性⚠️ 可能遗漏图中信息✅ 结合视觉信息,更完整
可解释性仅文本溯源可展示相关图片
适用问题类型“文档说了什么”“图中展示了什么”
成本低(仅文本 Embedding)中(+ CLIP Embedding)
检索速度中等(需处理图片向量)

  通过这个对比可以看出,多模态检索在处理图文混排文档时具有明显优势,但也带来了一定的成本和复杂度。在实际项目中,我们可以根据文档特点选择性使用:

  • 简单文档:优先使用步骤 3 的纯文本检索

  • 图文混排文档:使用步骤 4 的多模态检索

  • 混合策略:先用文本检索判断是否需要图片,再决定是否启用多模态

  至此,我们已经解决了"多模态信息割裂"的问题。但还有两个核心痛点尚未解决:表格结构熵增和布局语义丢失。在步骤 5 中,我们将引入企业级的 LlamaParse 来解决这些问题。

步骤 5:企业级 LlamaParse 解析(L4 企业级) 链接到标题

  在前面的步骤中,我们已经解决了多模态信息割裂的问题,但还有两个核心痛点尚未解决:表格结构熵增和布局语义丢失。当 PDF 中包含复杂的多行表头、嵌套表格,或者采用多栏排版时,PyMuPDFpypdf 都会将表格"压扁"成无法理解的字符串流,导致 LLM 在回答涉及表格数据的问题时产生幻觉。

5.1 为什么需要 LlamaParse?成本与效果权衡 链接到标题

  在做出技术选型之前,我们需要理解 LlamaParse 的定位:它是 LlamaIndex 官方提供的企业级 AI 原生文档解析 SaaS,专门针对复杂表格和结构化文档优化。让我们先看看它与前面方案的成本对比:

PyMuPDF vs LlamaParse 成本与效果对比

对比维度PyMuPDFLlamaParse
成本模式免费开源按页付费($0.003-$0.135/页)
表格识别能力❌ 压扁为文本✅✅ 专用 AI 模型重建
Markdown 输出❌ 纯文本✅ 保留 # ## ### 层级
多栏布局处理❌ 跨列读取✅ VLM 理解布局
部署方式本地库云端 API(零部署成本)
处理速度⚡ 极快(本地)🐢 慢(3-10s/页)
数据隐私✅ 本地可控⚠️ 上传云端
适用场景简单文档快速提取财报、合同、学术论文

  从上表可以看出,LlamaParse 的核心优势在于表格重建能力零部署成本,但代价是按页付费必须联网。因此在实际项目中,推荐的使用策略是:

  1. 成本优先:先用 PyMuPDF 快速判断文档是否包含复杂表格,仅对表格文档使用 LlamaParse

  2. 效果优先:所有结构化文档(财报、合同、论文)直接使用 LlamaParse

  3. 混合策略:简单文档用 PyMuPDF,复杂文档用 LlamaParse

获取 API Key:访问 https://cloud.llamaindex.ai/ 注册账号,在 API Keys 页面生成密钥。免费层提供 1000 页/天的额度。

5.2 LlamaParse 的四种解析模式 链接到标题

  LlamaParse 提供了 4 种解析模式,它们在成本、速度、准确性上有不同的权衡:

LlamaParse 解析模式对比

模式成本(每页)速度表格识别准确率适用场景
Fast$0.003 (1 credit)⚡ 快⭐⭐⭐ 中等简单表格
Cost Effective$0.009 (3 credits)🚗 中速⭐⭐⭐⭐ 较高一般业务文档
Agentic$0.03 (10 credits)🐢 慢⭐⭐⭐⭐⭐ 最高复杂嵌套表格
Agentic Plus$0.135 (45 credits)🐌 很慢⭐⭐⭐⭐⭐ 最高极端复杂文档

  在本次实战中,我们使用 tier="cost_effective"(对应 Cost Effective 模式),在成本和效果之间取得平衡。

5.3 安装与配置 链接到标题

  首先安装 LlamaParse 依赖:

# 安装 LlamaParse 依赖
#!pip install llama-parse nest_asyncio

  LlamaParse 内部使用异步 IO 进行云端通信,而 Jupyter Notebook 的事件循环与之冲突,因此需要 nest_asyncio 来解决这个问题:

!pip list | grep llama-parse

cost_effective 模式解析 链接到标题

import nest_asyncio
nest_asyncio.apply()  # 允许嵌套事件循环

from llama_parse import LlamaParse
from dotenv import load_dotenv
import os
load_dotenv(override=True)

# 获取 LlamaCloud API Key
llama_cloud_key = os.getenv("LLAMA_CLOUD_API_KEY")

if llama_cloud_key:
    # 配置解析器(高级参数)
    parser = LlamaParse(
        api_key=llama_cloud_key,
        result_type="markdown",    # Markdown 最适合 LLM 阅读
        verbose=True,              # 显示解析进度
        language="ch_sim",         # 优化中文 OCR 精度
        tier="cost_effective",         # 启用
        split_by_page=False        # 获得连续的 Markdown 文档
    )
    
    print("LlamaParse 配置成功(Markdown 模式)")
else:
    print("需要配置 LLAMA_CLOUD_API_KEY 环境变量")
    parser = None

5.4 执行解析 链接到标题

  LlamaParse 通过 file_extractor 参数与 SimpleDirectoryReader 集成,实现对 PDF 文件的自动解析:

from pathlib import Path
from llama_index.core import SimpleDirectoryReader

if parser:
    # 使用 LlamaParse 解析 PDF
    file_extractor = {".pdf": parser}
    
    print(f"\n📄 正在解析: {Path(pdf_path).name}")
    
    # 使用 SimpleDirectoryReader 加载文件并应用自定义解析器
    llama_documents = SimpleDirectoryReader(
        input_files=[pdf_path],       # PDF 文件路径
        file_extractor=file_extractor # 自定义解析器
    ).load_data()
    
    print(f"✅ 解析完成,共 {len(llama_documents)} 个文档块")
    
    # 查看 Markdown 输出
    if llama_documents:
        print(f"\n📝 Markdown 输出预览:")
        print("-" * 60)
        print(llama_documents[0].text[:2000])
        print("-" * 60)
# --- 新增调试代码:查看 LlamaParse 到底把图片解析成了什么 ---
print("\n🧐 LlamaParse 解析结果深度透视:")
for doc in llama_documents:
    # 打印文档前 500 个字符
    content_preview = doc.text[5000:]
    print(f"\n📄 文档片段预览:\n{content_preview}...")
    
    # 检查是否包含图片标记
    if "![" in doc.text:
        print("\n✅ 发现 Markdown 图片标记 (LlamaParse 将图片转为了链接引用)")
    else:
        print("\n❌ 未发现图片标记")
  

5.5 构建索引并查询 链接到标题

  用 LlamaParse 的解析结果构建索引并执行查询:

from llama_index.core import VectorStoreIndex

if parser and llama_documents:
    
    # 使用 LlamaParse 结果构建索引
    llama_index = VectorStoreIndex.from_documents(
        llama_documents,   # LlamaParse 解析结果
        show_progress=True # 显示进度条
    )
    llama_query_engine = llama_index.as_query_engine(similarity_top_k=3)
    
    # 提问图片内容
    # question = """很多开发者觉得LangChain代码冗余、编写复杂,甚至有开发者觉得LangChain太过于复杂,用户反馈的声音和评论有哪些?"""
    question = """根据文档内容,LangChain 的整体架构包含哪几个核心层?请结合架构图进行说明。"""
    print(f"\n❓ 问题: {question}")
    
    response = llama_query_engine.query(question)
    print(f"\n💡 回答:\n{response}")
    
# 展示检索到的图片引用(从 response 对象中提取)
print("\n📚 response 对象中的源节点:")
if hasattr(response, 'source_nodes') and response.source_nodes:
    for i, node in enumerate(response.source_nodes):
        node_type = type(node.node).__name__
        print(f"  [{i+1}] 类型: {node_type}, 相似度: {node.score:.4f}")
        
        if isinstance(node.node, ImageNode):
            file_path = node.node.metadata.get('file_path', '未知')
            print(f"      📷 引用图片: {file_path}")
        else:
            print(f"      📄 文本预览: {node.node.text[:100]}...")
else:
    print("  ℹ️ result 对象中没有 source_nodes 属性")
    print(f"  ℹ️ result 对象类型: {type(result)}")
    print(f"  ℹ️ result 可用属性: {dir(result)}")

cost_effective 模式(3 credits/page)仍然使用 VLM(Visual Language Model)+ OCR 的混合架构,而非单纯的传统 OCR。

具体处理逻辑:

  • VLM-powered OCR

    • LlamaParse 在所有付费模式(包括 cost_effective)中都采用了基于 VLM 的 OCR 管道。

    • 它不是简单的 Tesseract OCR,而是利用视觉语言模型来"理解"文档布局和内容。

  • 图片提取与处理

    • cost_effective 模式可以识别并提取图片。

    • 它会对图片中的文字内容进行 OCR 提取(例如图表中的标签、数值)。

    • 但相比更高级的模式,它在图片描述生成(Captioning)和复杂图表理解方面的能力会较弱。

  • 模式对比(图片处理能力)

模式成本/页图片文字 OCR图片内容理解(Caption)图表转换(Mermaid/LaTeX)
cost_effective3 credits✅ 基础⚠️ 有限
agentic10 credits✅ 增强✅ 较好✅ 部分支持
agentic_plus45 credits✅✅ 最佳✅✅ 深度理解✅✅ 完整支持

agentic_plus 模式解析 链接到标题

LlamaParse 的 agenticagentic_plus 模式采用了类似 ReAct Agent 的工作方式:

识别问题 → 调用工具 → 检查结果 → 发现错误 → 重新尝试 → 验证通过 ✓

  • 具体来说,它内部运行着一个多智能体协作系统:

    • 布局分析 Agent:识别页面的栏数、段落结构

    • 表格识别 Agent:提取表格行列关系

    • OCR Agent:处理图片中的文字

    • 图像理解 Agent:生成图片的语义描述

    • 自我修正 Agent:检查前面的输出,发现问题后重新解析

  • 用水管工类比来理解,想象修水管:

    • cost_effective:熟练工人,一次性修完,偶尔会漏掉小问题

    • agentic_plus:资深工程师 + 质检员,修完后还会测试水压、检查漏水,发现问题立即返工

    • 您看到的"水管"(Markdown)可能外观一样,但内部的连接质量(语义准确性)完全不同。

import nest_asyncio
nest_asyncio.apply()  # 允许嵌套事件循环

from llama_parse import LlamaParse
import os

# 获取 LlamaCloud API Key
llama_cloud_key = os.getenv("LLAMA_CLOUD_API_KEY")

if llama_cloud_key:
    # 配置解析器(高级参数)
    parser = LlamaParse(
        api_key=llama_cloud_key,
        result_type="markdown",    # Markdown 最适合 LLM 阅读
        verbose=True,              # 显示解析进度
        language="ch_sim",         # 优化中文 OCR 精度
        tier="agentic_plus",         # 启用深度理解模式
        split_by_page=False        # 获得连续的 Markdown 文档
    )
    
    print("LlamaParse 配置成功(Markdown 模式)")
else:
    print("需要配置 LLAMA_CLOUD_API_KEY 环境变量")
    parser = None

5.4 执行解析 链接到标题

  LlamaParse 通过 file_extractor 参数与 SimpleDirectoryReader 集成,实现对 PDF 文件的自动解析:

from pathlib import Path
from llama_index.core import SimpleDirectoryReader

if parser:
    # 使用 LlamaParse 解析 PDF
    file_extractor = {".pdf": parser}
    
    print(f"\n📄 正在解析: {Path(pdf_path).name}")
    
    # 使用 SimpleDirectoryReader 加载文件并应用自定义解析器
    llama_documents = SimpleDirectoryReader(
        input_files=[pdf_path],       # PDF 文件路径
        file_extractor=file_extractor # 自定义解析器
    ).load_data()
    
    print(f"✅ 解析完成,共 {len(llama_documents)} 个文档块")
    
    # 查看 Markdown 输出
    if llama_documents:
        print(f"\n📝 Markdown 输出预览:")
        print("-" * 60)
        print(llama_documents[0].text[:2000])
        print("-" * 60)
# --- 新增调试代码:查看 LlamaParse 到底把图片解析成了什么 ---
print("\n🧐 LlamaParse 解析结果深度透视:")
for doc in llama_documents:
    # 打印文档前 500 个字符
    content_preview = doc.text[5000:]
    print(f"\n📄 文档片段预览:\n{content_preview}...")
    
    # 检查是否包含图片标记
    if "![" in doc.text:
        print("\n✅ 发现 Markdown 图片标记 (LlamaParse 将图片转为了链接引用)")
    else:
        print("\n❌ 未发现图片标记")
  
from llama_index.core import VectorStoreIndex

if parser and llama_documents:
    
    # 使用 LlamaParse 结果构建索引
    llama_index = VectorStoreIndex.from_documents(
        llama_documents,   # LlamaParse 解析结果
        show_progress=True # 显示进度条
    )
    llama_query_engine = llama_index.as_query_engine(similarity_top_k=3)
    
    # 提问图片内容
    question = """:根据文档内容,LangChain 的整体架构包含哪几个核心层?请结合架构图进行说明。"""
    print(f"\n❓ 问题: {question}")
    
    response = llama_query_engine.query(question)
    print(f"\n💡 回答:\n{response}")
    
# 展示检索到的图片引用(从 response 对象中提取)
print("\n📚 response 对象中的源节点:")
if hasattr(response, 'source_nodes') and response.source_nodes:
    for i, node in enumerate(response.source_nodes):
        node_type = type(node.node).__name__
        print(f"  [{i+1}] 类型: {node_type}, 相似度: {node.score:.4f}")
        
        if isinstance(node.node, ImageNode):
            file_path = node.node.metadata.get('file_path', '未知')
            print(f"      📷 引用图片: {file_path}")
        else:
            print(f"      📄 文本预览: {node.node.text[:100]}...")
else:
    print("  ℹ️ result 对象中没有 source_nodes 属性")
    print(f"  ℹ️ result 对象类型: {type(result)}")
    print(f"  ℹ️ result 可用属性: {dir(result)}")
  • 两种模式关键区别:

    • cost_effective 模式足以应对包含文字的图片(如截图、扫描件),它会提取其中的文字。

    • 对于纯视觉信息(如建筑照片、复杂流程图),它生成的描述会比较简略,甚至可能只保留 [Image] 占位符。

    • 如果您需要深度理解图片内容(如"这张图展示了上升趋势"),应使用 agentic_plus 模式。

5.7 LlamaParse 的局限性 链接到标题

  虽然 LlamaParse 在表格识别上表现优秀,但也有一些需要注意的局限性:

  1. 扫描件识别稍弱:对于中文扫描件 PDF,LlamaParse 的 OCR 精度不如 MinerU(基于 PaddleOCR)

  2. 成本累积快:大规模文档处理时,成本会快速累积(1万页约 $30-$1350)

  3. 必须联网:离线环境无法使用

  4. 处理速度慢:云端 AI 推理,每页需要 3-10 秒

  5. 不能替换VLM模型:LlamaParse 云服务官方内部自定义好了VLM模型,我们无法指定使用哪一个模型,只能参数决定:

    • cost_effective → 使用较轻量的 VLM

    • agentic_plus → 使用最强的 VLM(可能是 GPT-4o)

  因此,在步骤 6 中,我们将引入 MinerU,它是扫描件和中文文档的最佳选择。

步骤 6:MinerU 深度解析(扫描件/中文 OCR 专家) 链接到标题

  在步骤 5 中,我们看到了 LlamaParse 在表格识别上的强大能力。但在实际业务中,我们经常会遇到另一类棘手的文档:扫描件 PDF(如扫描的合同、发票)、图文混排PDF和中文密集型文档。这些场景下,LlamaParse 的表现会明显下降,而 MinerU(Magic-PDF)则是专门为此而生的解决方案。

6.1 何时选择 MinerU?快速决策 链接到标题

  在引入 MinerU 之前,我们先要明确它与 LlamaParse 的互补关系。通过以下快速决策表判断何时应该选择 MinerU:

MinerU 核心优势场景

场景特征选择 MinerU 的理由
扫描件 PDFPaddleOCR 对扫描件识别率高达 95%+
中文文档专门优化中文,远超通用 OCR
需图文分离自动输出 Markdown + 独立图片文件
数据隐私优先支持本地部署,数据不出企业

  简而言之:扫描件、中文、图文分离、本地化 是 MinerU 的四大核心优势。如果你的文档属于这些场景,MinerU 是首选;如果是原生 PDF 的复杂表格,则推荐 LlamaParse。

6.2 深度技术解析:MinerU 的"分而治之"策略 链接到标题

  MinerU(Magic-PDF)的核心设计理念是"分而治之,结构重组",这与传统 OCR 的"一刀切"策略有本质区别。让我们深入理解它的工作原理。

6.2.1 核心架构:VLM + OCR 分层解析 链接到标题

  MinerU 采用了双模型协同的架构,而不是单一的多模态 LLM。这种设计的精妙之处在于职责分离:

  这种分层策略带来了三大优势:

VLM + OCR 分层 vs 单一多模态 LLM 对比

对比维度VLM + OCR 分层(MinerU)单一多模态 LLM(如 GPT-4o)
处理速度⚡ 快(专用模型)🐢 慢(通用模型推理)
成本💰 低(本地/免费 API)💰💰💰 高(按 Token 计费)
准确性✅ 高(OCR 专精文字)⚠️ 中等(通用能力权衡)
中文识别✅✅ 极高(PaddleOCR)⚠️ 一般
可控性✅ 高(可调节 OCR 参数)❌ 低(黑盒模型)

6.2.2 文本与图片的解析流程详解 链接到标题

  现在让我们深入理解 MinerU 是如何处理图文混排 PDF 的。以一个典型的技术白皮书页面为例,它包含:标题、正文、架构图、图片说明。

步骤 1:VLM 版面分析(输入:PDF 页面图像 → 输出:布局 JSON)

  VLM 的作用不是"识别文字",而是像人一样"看懂"页面布局。它要回答的是"在哪里"的问题:

  1. 区域识别:划定文本区、图片区、表格区、公式区、页眉页脚的精确坐标(Bounding Box)

  2. 结构理解:判断标题层级(一级标题/二级标题/正文)

  3. 阅读顺序:在多栏布局中,确定正确的阅读顺序(从左到右 vs 从上到下)

  4. 关联关系:识别"图片"与其"标题/说明文字"的配对关系

# VLM 的实际输出示例(简化版)
{
  "page_1": {
    "page_size": [595, 842], # 页面尺寸 [宽, 高]
    "blocks": [
      {
        "id": 1,          
        "type": "title",            # 块类型:标题
        "bbox": [121, 46, 848, 105],    # 边界框坐标 [x1, y1, x2, y2]
        "content": "LangChain快速入门与Agent开发实战-Part 1", # 文本内容
        "level": 1         # 标题层级
      },
      {
        "id": 2,          
        "type": "text",       # 块类型:正文文本
        "bbox": [119, 225, 875, 318], # 边界框坐标
        "content": "LangChain可以称之为自2022年底大模型技术爆火以来的第一个...", # 文本内容
        "related_to": null # 关联的其他块 ID(如跨行/跨页关联)
      },
      { 
        "id": 3, 
        "type": "image_body", # 块类型:图片主体
        "bbox": [122, 476, 875, 743], # 边界框坐标
        "img_path": "images/7598546b...e0a184e.jpg", # 切图保存路径
        "content": null # 图片内的 OCR 文本(如有)
      },
      {
        "id": 4, 
        "type": "image_caption", # 块类型:图片说明/脚注
        "bbox": [86, 634, 295, 648], # 边界框坐标
        "content": "LangChain官网:https://www.langchain.com/", # 说明文字
        "linked_img_id": 3 # 关联的图片主体 ID
      },
      {
        "id": 5, 
        "type": "table",          # 块类型:表格
        "bbox": [159, 454, 875, 728], # 边界框坐标
        "img_path": "images/721c4e03...45cb49f.jpg", # 表格切图路径
        "table_body": "<table><tr><td>模块类别</td><td>示例功能</td></tr>...</table>" # 表格的 HTML 结构化重建结果
      }
    ]
  }
}

  关键信息:

  • type:区域类型(title/body/figure/table/caption)

  • bbox:坐标框 [x1, y1, x2, y2]

  • linked_to:关联关系,字段名称会不完全一样(图片说明关联到图片)

  • img_path:图片路径

步骤 2:文本区域 OCR 处理(输入:文本区域图像 + bbox → 输出:识别文字)

  在 VLM 划定的"文本区域"内,OCR 引擎介入,解决"是什么"的问题:

  1. 原生 PDF 优先:如果 PDF 本身包含可编辑文本(非扫描件),直接提取编码层文字(速度最快、准确率最高)

  2. OCR 兜底:对于扫描件或编码混乱的区域,调用 PaddleOCR(中文识别精度极高)进行像素级识别

  3. 坐标对齐:将识别的文字与 VLM 的坐标框对齐,确保文字与布局结构的一致性

# OCR 对 text_zone 区域的处理
for block in layout["layout"]:
    if block["text_zone"]:
        # 1. 提取坐标框对应的像素区域
        region_image = extract_region(pdf_image, block["bbox"])
        
        # 2. 调用 PaddleOCR 识别
        ocr_result = paddleocr.ocr(region_image, language='ch')
        
        # 3. 合并识别结果为完整文本
        block["text"] = merge_ocr_lines(ocr_result)

  PaddleOCR 的输出是逐行的,MinerU 会根据行间距、对齐方式自动合并为段落。对于扫描件,PaddleOCR 的中文识别准确率可达 95%+(远高于 Tesseract 的 70-80%)。

步骤 3:图片区域提取(输入:图片区域 bbox → 输出:独立图片文件)

# 图片提取与保存
for block in layout["layout"]:
    if block["image_zone"]:
        # 1. 提取坐标框对应的像素区域
        image_region = extract_region(pdf_image, block["bbox"])
        
        # 2. 保存为独立文件
        image_path = f"output/images/page_{page_num}_fig_{block['id']}.png"
        save_image(image_region, image_path)
        
        # 3. 在 Markdown 中插入引用
        block["md_reference"] = f"![Figure {block['id']}]({image_path})"

  这一步的关键在于图文分离但保持关联:图片被提取为独立文件,但在 Markdown 中通过路径引用保持与文本的逻辑关系。

步骤 4:Markdown 结构重组(输入:布局 JSON + OCR 文本 + 图片路径 → 输出:结构化 Markdown)

# 按阅读顺序生成 Markdown
markdown_output = []
for block_id in layout["reading_order"]:
    block = layout["layout"][block_id - 1]
    
    if block["type"] == "title":
        level = "#" * block["level"]
        markdown_output.append(f"{level} {block['text']}")
    
    elif block["type"] == "body":
        markdown_output.append(block["text"])
    
    elif block["type"] == "figure":
        markdown_output.append(block["md_reference"])
        # 如果有关联的说明文字,追加
        if block.get("linked_caption"):
            markdown_output.append(f"*{block['linked_caption']}*")

final_markdown = "\n\n".join(markdown_output)

  最终输出的 Markdown 示例:

%%markdown
# 系统架构设计

本节介绍了基于 LangChain 的智能问答系统架构

![Figure 3](output/images/page_1_fig_3.png)
* 3: LangChain 核心模块关系图*

从上图可以看出Data Connection 模块位于 Model I/O  Agents 之间...

6.2.3 图文混排的检索融合逻辑 链接到标题

  MinerU 最强大的地方在于,它不仅解析了图文,还为 RAG 检索设计了图文协同检索的数据结构。让我们深入理解这个机制。

问题:为什么需要图文融合?

  在传统的多模态 RAG 中(如步骤 4 的 MultiModalVectorStoreIndex),图片和文本是独立索引的:

  • 文本块:"系统采用微服务架构..."
  • 图片块:[架构图的图片]

  这导致一个问题:当用户问"系统架构是怎样的?“时,检索器可能只返回文本或只返回图片,无法将两者关联起来。

MinerU 的解决方案:语义关联的 Document 结构

  MinerU 通过 linked_to 字段建立图片与文本的关联关系:

# MinerU 输出的 Documents 示例
documents = [
    # 文本节点
    Document(
        text="# 系统架构设计\n\n本节介绍了基于 LangChain 的智能问答系统架构...",
        metadata={"page": 1, "type": "text", "linked_images": ["page_1_fig_3.png"]}
    ),
    
    # 图片节点
    ImageDocument(
        image_path="output/images/page_1_fig_3.png",
        text="图 3: LangChain 核心模块关系图",  # 图片说明文字
        metadata={"page": 1, "type": "image", "linked_text_block": "block_2"}
    )
]

  在检索时,LlamaIndex 可以利用这个关联关系:

# 检索逻辑(伪代码)
def retrieve_with_linked_images(query, documents):
    # 1. 先检索文本节点
    text_nodes = text_retriever.retrieve(query, top_k=3)
    
    # 2. 自动扩展关联的图片节点
    all_nodes = []
    for node in text_nodes:
        all_nodes.append(node)
        # 如果该文本节点关联了图片,自动添加
        if "linked_images" in node.metadata:
            for img_path in node.metadata["linked_images"]:
                img_node = find_image_document(img_path)
                all_nodes.append(img_node)
    
    return all_nodes

  这样,当检索到"系统架构"相关的文本块时,相关的架构图会自动被包含在上下文中传递给 LLM,实现真正的图文融合理解。

6.2.4 中间产物分析:auto 目录结构详解 链接到标题

  MinerU 在解析 PDF 后,会生成一系列中间产物。理解这些文件的作用对于调试和优化非常重要。

典型的输出目录结构

mineru_output/
└── {task_id}/                         # 任务 ID 目录
    ├── {uuid}_content_list.json       # [最终] 线性内容列表(用于生成 Markdown)
    ├── {uuid}_model.json              # [中间] 语义块模型(归一化坐标)
    ├── {uuid}_origin.pdf              # 原始 PDF 文件备份
    ├── layout.json                    # [底层] 原始 OCR 与布局层级信息(绝对坐标)
    ├── full.md                        # 最终拼接生成的 Markdown 文件
    ├── result.zip                     # 所有结果的打包
    └── images/                        # 提取的图片资源目录
        ├── {hash}.jpg                 # 切分出的图表/图片文件
        └── ...

关键文件详解

  1. layout.json(VLM 原始输出) (底层物理层) 这是最详细的原子数据,包含了从 OCR 引擎获取的原始信息。它以 pdf_info -> para_blocks -> lines -> spans 的层级记录了每个字符的物理位置。
  • 坐标系:使用 PDF 原始绝对坐标(整数,如 [72, 39, 505, 89])。

  • 作用:用于需要精确回溯文本位置或调试 OCR 识别错误的场景。

{
  "pdf_info": [
    {
      "para_blocks": [
        {
          "type": "title",
          "bbox": [72, 39, 505, 89],
          "lines": [{"spans": [{"content": "LangChain快速入门...", "bbox": [...]}]}]
        }
      ]
    }
  ]
}
  1. _model.json(可视化调试) (语义结构层) 这是 VLM 介入后的中间态。它放弃了具体的行和字,而是关注**“块” (Block)** 的语义。
  • 坐标系:使用 0.0 到 1.0 的归一化相对坐标,方便跨分辨率处理。

  • 结构:这是一个二维列表 [Page][Block]。

  • 作用:VLM 在这里完成了对 title、image、table 等区域的分类判断。


[
  [ // Page 0
    {
      "type": "title",
      "bbox": [0.122, 0.047, 0.85, 0.106],
      "content": "LangChain快速入门与Agent开发实战-Part 1"
    },
    {
      "type": "image",
      "bbox": [0.123, 0.477, 0.877, 0.744],
      "content": null  // 图片块本身没有文字内容
    }
  ]
]
  1. _content_list.json(OCR + 布局融合) (应用交付层) 这是最终的输出列表,经过了阅读顺序排序 (Reading Order Algorithm)。它将所有页面打平为一个线性列表,直接对应 Markdown 的生成顺序。
  • 关键特性:包含 img_path 字段,直接链接到 images/ 目录下的文件。

  • 作用:RAG 索引构建中最常用的文件,用于提取文本和多模态关联。

[
  {
    "type": "text",
    "text": "一、LangChain.ai工具生态介绍",
    "text_level": 1,
    "page_idx": 0
  },
  {
    "type": "image",
    "img_path": "images/7598546b...e0a184e.jpg", // 对应 images 目录
    "image_caption": ["LangChain官网:https://www.langchain.com/"],
    "page_idx": 0
  }
]

  在实际开发中,如果 MinerU 的解析结果不理想,可以通过以下方式调试:

  1. 文字错乱/乱码:检查 layout.json中的 spans,看是否是 OCR 这一步就错了。

  2. 区域划分错误(如把正文识别为标题):检查 _model.json中的 type 字段。

  3. 图片丢失/关联错误:检查 _content_list.json中的 img_path 和 image_caption 是否正确对应。

6.3 MinerU API 实战:从解析到检索 链接到标题

  理解了原理后,现在让我们通过实际代码来使用 MinerU。MinerU 提供了两种使用方式:本地部署(推荐GPU环境)和云端 API。本次实战我们使用 API 方式,更加便捷。

6.3.1 环境准备与 API Key 配置 链接到标题

  首先安装必要的依赖:

#!pip install llama-index-core requests dashscope llama-index-vector-stores-milvus "pymilvus[milvus_lite]"

  MinerU 的 API 需要两个 Key:

  • MINERU_API_KEY:用于 MinerU 解析服务

  • DASHSCOPE_API_KEY:用于阿里百炼的 Qwen2.5-VL-Embedding(可选,用于多模态 Embedding)

import os
from dotenv import load_dotenv

# 加载环境变量
load_dotenv(override=True)

# 检查 Key 是否存在
mineru_key = os.getenv("MINERU_API_KEY")
dashscope_key = os.getenv("DASHSCOPE_API_KEY")

if not mineru_key:
    print("❌ 错误:未找到 MINERU_API_KEY,请在 .env 文件中配置")
else:
    print(f"✅ 成功加载 MinerU Key: {mineru_key[:6]}******")

if dashscope_key:
    print(f"✅ 成功加载 DashScope Key: {dashscope_key[:6]}******")

获取 MinerU API Key:访问 https://mineru.net/apiManage/docs 注册账号。免费层提供每天 2000 页的解析额度。

6.3.2 实现 MinerUAPIReader 链接到标题

  为了与 LlamaIndex 无缝集成,我们需要实现一个自定义的 Reader 类,它能够调用 MinerU API 并返回 DocumentImageDocument

# === Cell 2: 终极适配版 MinerUAPIReader (适配 full.md) ===
import time
import requests
import zipfile
import shutil
import os
import json
from pathlib import Path
from typing import List
from llama_index.core import Document
from llama_index.core.schema import ImageDocument

class MinerUAPIReader:
    def __init__(self, api_key: str, output_dir="./mineru_output"):
        self.api_key = api_key
        self.base_url = "https://mineru.net/api/v4"
        self.output_dir = Path(output_dir)

    def load_data(self, pdf_url: str) -> List[Document]:
        """
        核心方法:传入 PDF URL -> 轮询 -> 下载 ZIP -> 提取 full.md 和图片
        【原理】MinerU 采用 "VLM + OCR" 的分层解析策略:
        1. VLM (视觉大模型) 进行版面分析,识别标题、正文、图片、表格区域。
        2. OCR/提取 识别文字内容。
        3. 最终输出:将“肉”(文本)和“骨头”(图片)分离,文本存为 md,图片存为独立文件。
        """
        if not pdf_url.startswith("http"):
            raise ValueError("❌ 请传入有效的公网 URL (http/https)")

        # 1. 提交解析任务
        print(f"🚀 [MinerU] 提交解析任务 URL: {pdf_url[:50]}...")
        headers = {
            "Content-Type": "application/json",
            "Authorization": f"Bearer {self.api_key}"
        }
        
        data = {
            "url": pdf_url,
            "model_version": "vlm",  # 保持 VLM 模式 (利用视觉大模型进行版面分析)
            "is_ocr": True,          # 开启 OCR 以识别文本
            "lang": "auto"
        }
        
        try:
            resp = requests.post(f"{self.base_url}/extract/task", headers=headers, json=data)
            resp_json = resp.json()
            
            if resp.status_code != 200 or resp_json.get('code') != 0:
                print(f"❌ 提交失败响应: {resp.text}")
                raise Exception(f"API 请求失败: {resp_json.get('msg', '未知错误')}")
                
            task_id = resp_json['data'] # 获取 task_id 字符串 (v4 接口直接返回 ID)
            # 如果 data 是字典,尝试获取 task_id 字段 (容错)
            if isinstance(task_id, dict):
                task_id = task_id.get('task_id')
                
            print(f"⏳ [MinerU] 任务 ID: {task_id},正在解析中...")
            
        except Exception as e:
            raise Exception(f"提交任务出错: {e}")

        # 2. 轮询结果
        result_url = None
        start_time = time.time()
        timeout = 600 # 10分钟超时,防止大文件解析慢
        
        while True:
            if time.time() - start_time > timeout:
                raise TimeoutError("解析超时")
            
            time.sleep(3)
            
            try:
                status_resp = requests.get(f"{self.base_url}/extract/task/{task_id}", headers=headers)
                if status_resp.status_code != 200:
                    continue
                
                status_json = status_resp.json()
                
                # 容错处理:确保 data 存在
                if 'data' not in status_json:
                    continue 

                status_data = status_json['data']
                state = status_data['state']
                
                if state == 'done':
                    result_url = status_data['full_zip_url']
                    print("") # 换行
                    break
                elif state in ['failed', 'error', 'cancelled']:
                    raise Exception(f"解析最终失败,状态: {state}")
                else:
                    print(f".", end="", flush=True) # 打印进度点
            except Exception as e:
                # 网络波动不中断,继续重试
                print(f"⚠️ 轮询波动: {e}")
                time.sleep(2)
                continue
        
        print(f"\n✅ [MinerU] 解析完成,开始处理结果包...")
        return self._process_zip(result_url, str(task_id))

    def _process_zip(self, url, task_id) -> List[Document]:
        # 清理并创建目录
        extract_path = self.output_dir / task_id
        if extract_path.exists():
            shutil.rmtree(extract_path)
        extract_path.mkdir(parents=True, exist_ok=True)
        
        # 下载 Zip
        zip_path = extract_path / "result.zip"
        try:
            print(f"📥 正在下载结果包...")
            with requests.get(url, stream=True) as r:
                r.raise_for_status()
                with open(zip_path, 'wb') as f:
                    shutil.copyfileobj(r.raw, f)
        except Exception as e:
            raise Exception(f"下载结果 zip 失败: {e}")
        
        # 解压
        try:
            with zipfile.ZipFile(zip_path, 'r') as z:
                z.extractall(extract_path)
        except Exception:
            raise Exception("文件解压失败")
            
        # 封装 Document
        documents = []
        try:
            # === 关键修正:根据您的截图寻找 full.md ===
            # 使用 rglob 递归查找,因为 full.md 可能在 task_id 同名子目录里
            md_files = list(extract_path.rglob("full.md"))
            
            if not md_files:
                # 容错:如果找不到 full.md,尝试找任何 .md 文件
                md_files = list(extract_path.rglob("*.md"))
                
            if not md_files:
                print(f"⚠️ 警告:解压目录中未找到 full.md。目录内容: {[p.name for p in extract_path.rglob('*')]}")
                return []
                
            target_md_file = md_files[0]
            print(f"📖 锁定核心文档: {target_md_file.name} (路径: {target_md_file.parent.name})")
            
        except Exception as e:
            print(f"⚠️ 文件结构错误: {e}")
            return []
            
        # 1. 读取 Markdown 文本
        # 原理:这是被"剔除"了图片的纯文本骨架,保留了 Markdown 格式的标题层级
        with open(target_md_file, 'r', encoding='utf-8') as f:
            documents.append(Document(
                text=f.read(), 
                metadata={"file_name": "mineru_doc.pdf", "task_id": task_id}
            ))
            
        # 2. 读取图片 (images 文件夹通常和 full.md 在同一级)
        # 原理:这是由 VLM 视觉模型基于版面分析精准"抠"出来的图片,通常包含了最重要的信息
        images_dir = target_md_file.parent / "images"
        if images_dir.exists():
            valid_images = [
                img for img in images_dir.iterdir() 
                if img.suffix.lower() in ['.jpg', '.png', '.jpeg'] and img.stat().st_size > 2048 # 过滤小图
            ]
            print(f"📸 [MinerU] 成功提取图表: {len(valid_images)} 张")
            for img in valid_images:
                documents.append(ImageDocument(
                    image_path=str(img), 
                    metadata={"file_name": "mineru_doc.pdf", "task_id": task_id}
                ))
        else:
            print("ℹ️ 未发现 images 文件夹,可能是纯文本文档。")
                
        return documents

print("✅ 终极适配版 MinerUAPIReader 定义完成!")
# === Cell 2 (Qwen2.5-VL 适配版): 定义 DashScopeEmbedding 类 ===
from typing import Any, List
from llama_index.core.embeddings import MultiModalEmbedding
from llama_index.core.schema import ImageType
import dashscope
from http import HTTPStatus
import asyncio
from pathlib import Path

class DashScopeMultiModalEmbedding(MultiModalEmbedding):
    """
    适配阿里百炼最新 Qwen2.5-VL-Embedding 模型
    """
    def __init__(self, api_key: str, model_name: str = "qwen2.5-vl-embedding"):
        super().__init__(model_name=model_name)
        dashscope.api_key = api_key

    # --- 通用调用逻辑 (私有方法) ---
    def _call_api(self, input_data: list) -> List[float]:
        """
        统一处理 API 调用与结果解析
        input_data 示例: [{'text': 'hello'}] 或 [{'image': 'file://...'}]
        """
        try:
            resp = dashscope.MultiModalEmbedding.call(
                model=self.model_name,
                input=input_data
            )
            
            if resp.status_code == HTTPStatus.OK:
                # 适配新版返回结构: output -> embeddings -> [0] -> embedding
                if 'embeddings' in resp.output and len(resp.output['embeddings']) > 0:
                    return resp.output['embeddings'][0]['embedding']
                else:
                    print(f"⚠️ API 返回结果为空: {resp}")
                    return [0.0] * 1024 # 假设维度,避免程序崩溃
            else:
                print(f"⚠️ API 调用失败: {resp.message}")
                return [0.0] * 1024
        except Exception as e:
            print(f"⚠️ 网络或处理异常: {e}")
            return [0.0] * 1024

    # --- 1. 图片向量化 ---
    def _get_image_embedding(self, img_file_path: ImageType) -> List[float]:
        # 必须转换为绝对路径并加 file:// 前缀
        abs_path = Path(img_file_path).absolute()
        input_data = [{'image': f"file://{abs_path}"}]
        return self._call_api(input_data)

    # --- 2. 文档文本向量化 ---
    def _get_text_embedding(self, text: str) -> List[float]:
        input_data = [{'text': text}]
        return self._call_api(input_data)

    # --- 3. 查询文本向量化 ---
    def _get_query_embedding(self, query: str) -> List[float]:
        input_data = [{'text': query}]
        return self._call_api(input_data)

    # --- 4. 异步方法 ---
    async def _aget_image_embedding(self, img_file_path: ImageType) -> List[float]:
        return self._get_image_embedding(img_file_path)

    async def _aget_text_embedding(self, text: str) -> List[float]:
        return self._get_text_embedding(text)
    
    async def _aget_query_embedding(self, query: str) -> List[float]:
        return self._get_query_embedding(query)

print(f"✅ Qwen2.5-VL 适配版 Embedding 类定义完成!")
6.3.3 执行解析与结果分析 链接到标题

  使用 MinerUAPIReader 来解析 PDF:

# 准备测试 PDF(公网 URL)
test_url = "https://ml2022.oss-cn-hangzhou.aliyuncs.com/0.LangChain技术生态介绍.pdf"

# 实例化读取器
reader = MinerUAPIReader(api_key=mineru_key)

# 执行解析
print(f"🔄 开始解析 PDF: {test_url}\n")
documents = reader.load_data(test_url)

# 分析结果
text_docs = [d for d in documents if not isinstance(d, ImageDocument)]
image_docs = [d for d in documents if isinstance(d, ImageDocument)]

print(f"\n📊 解析结果统计:")
print(f"   - 文本文档: {len(text_docs)} 个")
print(f"   - 图片文档: {len(image_docs)} 个")
print(f"   - 总节点数: {len(documents)} 个")

# 查看文本文档的前 500 字符
if text_docs:
    print(f"\n📝 Markdown 输出预览:")
    print("-" * 60)
    print(text_docs[0].text[:500])
    print("-" * 60)

# 查看提取的图片列表
if image_docs:
    print(f"\n📷 提取的图片列表 (共 {len(image_docs)} 张):")
    for i, img_doc in enumerate(image_docs[:5]):
        # ImageDocument 的图片路径通常存储在 image_path 属性中
        if hasattr(img_doc, 'image_path'):
            image_path = img_doc.image_path
        elif hasattr(img_doc, 'image'):
            image_path = img_doc.image  # 可能是 base64 或路径
        else:
            # 如果都没有,显示 metadata 信息
            image_path = f"图片来自: {img_doc.metadata.get('file_name', '未知')} (task: {img_doc.metadata.get('task_id', 'N/A')[:8]}...)"
        
        print(f"   {i+1}. {image_path}")
    
    # 可选:查看第一张图片的完整属性
    print(f"\n   [调试] 第一张图片的属性:")
    print(f"   - dir(img_doc): {[attr for attr in dir(image_docs[0]) if not attr.startswith('_')]}")
6.3.4 构建多模态索引并查询 链接到标题

  用 MinerU 的输出构建多模态索引:

# === Cell 4: 索引构建 (最终测试) ===
from llama_index.core.indices.multi_modal import MultiModalVectorStoreIndex
from llama_index.core import StorageContext, load_index_from_storage, Settings
import os

# 1. 实例化新版 Embedding
embed_model = DashScopeMultiModalEmbedding(api_key=dashscope_key)

# ⚠️ 关键修正:强制设置全局 Embedding 模型,避免 Query 时自动退回 OpenAI (1536维)
Settings.embed_model = embed_model
Settings.image_embed_model = embed_model  # 可选但推荐

# 2. 持久化索引
persist_dir = "./storage_qwen_vl"

if os.path.exists(persist_dir):
    print(f"📂 加载本地索引: {persist_dir} ...")
    storage_context = StorageContext.from_defaults(persist_dir=persist_dir)
    index = load_index_from_storage(storage_context)
    print("✅ 加载完成")
else:
    print("🧠 正在调用 Qwen2.5-VL 构建图文索引...")
    
    # 关键:同时指定 embed_model 和 image_embed_model
    index = MultiModalVectorStoreIndex.from_documents(
        documents,
        embed_model=embed_model,       # 文本 -> Qwen2.5
        image_embed_model=embed_model  # 图片 -> Qwen2.5
    )
    
    print(f"💾 保存索引至 {persist_dir} ...")
    index.storage_context.persist(persist_dir=persist_dir)
    print("✅ 构建并保存完成")

# --- 简单验证一下维度 ---
print("🎉 RAG 引擎就绪!")

文件列表概览 链接到标题

文件名大小作用
default__vector_store.json232 KB文本向量存储
image__vector_store.json300 KB图片向量存储
docstore.json63 KB文档内容存储
index_store.json2 KB索引元数据
graph_store.json18 B图关系存储(当前为空)
# ==========================================
# 3. 核心:配置 Milvus 双路存储
# ==========================================
from llama_index.vector_stores.milvus import MilvusVectorStore
from llama_index.core import StorageContext

print("\n⚙️ 配置 Milvus 双路存储...")

MILVUS_URI = "http://localhost:19530"

# 3.1 创建文本向量存储 (Text Store)
text_store = MilvusVectorStore(
    uri=MILVUS_URI,
    collection_name="rag_prod_text",  # 独立的文本 Collection
    dim=1024,                         # 与 Qwen-VL 维度一致
    overwrite=True,                   # 测试模式:覆盖重建
    text_key="text"                # 使用默认字段名
)

# 3.2 创建图片向量存储 (Image Store)
image_store = MilvusVectorStore(
    uri=MILVUS_URI,
    collection_name="rag_prod_image", # 独立的图片 Collection
    dim=1024,                         # 与 Qwen-VL 维度一致
    overwrite=True,                   # 测试模式:覆盖重建
    text_key="text"                   # 图片元数据偶尔也会用到文本字段
)

# 3.3 组合 Storage Context
# 关键:正确注册 image_store 到 vector_stores 字典中
IMAGE_VECTOR_STORE_KEY = "image"
storage_context = StorageContext.from_defaults(vector_store=text_store)
storage_context.vector_stores[IMAGE_VECTOR_STORE_KEY] = image_store

直接加载本地解析好的索引 链接到标题

# 4. 读取本地数据,确认目录
mineru_output_dir = Path("./mineru_output/3c1bc9bb-ab18-49ac-9d5f-4ebe5f0c73f4")
md_path = mineru_output_dir / "full.md"
images_dir = mineru_output_dir / "images"

# 如果目录不存在,创建模拟数据
if not mineru_output_dir.exists():
    os.makedirs(images_dir, exist_ok=True)
    with open(md_path, "w", encoding="utf-8") as f:
        f.write("# LangChain 技术文档\n\n这是一个关于 Agent 架构的说明。\n\n![架构图](images/demo.png)\n\n这是图片的描述。")
    # 创建模拟图片
    with open(images_dir / "demo.png", "wb") as f:
        f.write(b"fake image bytes")
    print(f"⚠️ 已创建模拟数据目录: {mineru_output_dir}")

# 5. 加载文本和图片
print("\n📄 正在加载文本...")
text_reader = SimpleDirectoryReader(input_files=[str(md_path)])
text_docs = text_reader.load_data()
print(f"✅ 文本加载完成,共 {len(text_docs)} 个文档")

print("📸 正在加载图片...")
if images_dir.exists():
    image_reader = SimpleDirectoryReader(input_dir=str(images_dir))
    image_docs = image_reader.load_data()
    print(f"✅ 图片加载完成,共 {len(image_docs)} 张图片")
else:
    image_docs = []
    print("⚠️ 未找到 images 目录")

# 合并文本和图片文档
all_documents = text_docs + image_docs
print(f"\n📦 总文档数: {len(all_documents)}")
# ==========================================
# 4. 构建索引,并将数据存储进向量数据库中
# ==========================================
print("\n🏗️ 开始构建多模态索引 (Indexing)...")

# 强制设置全局 Context
Settings.embed_model = embed_model 

# 构建多模态索引
index = MultiModalVectorStoreIndex.from_documents(
    documents,
    storage_context=storage_context,  # 指定向量数据库
    embed_model=embed_model,       # 文本 -> Qwen
    image_embed_model=embed_model, # 图片 -> Qwen
    show_progress=True
)

print("✅ 索引构建完成!")

加载已经存入Milvus向量数据库的数据 链接到标题

# 7. 加载已有索引(下次运行时)
print("📂 从 Milvus 纯云端加载索引...")

# Step 1: 连接现有 Collection
text_store = MilvusVectorStore(
    uri="http://localhost:19530",
    collection_name="rag_prod_text",
    overwrite=False     # 在读取数据库阶段,一定要设置为False,否则会清空现有数据
)
image_store = MilvusVectorStore(
    uri="http://localhost:19530",
    collection_name="rag_prod_image",
    overwrite=False   # 在读取数据库阶段,一定要设置为False,否则会清空现有数据
)

# Step 2: 重建 StorageContext
IMAGE_VECTOR_STORE_KEY = "image"
storage_context = StorageContext.from_defaults(vector_store=text_store)
storage_context.vector_stores[IMAGE_VECTOR_STORE_KEY] = image_store

# Step 3: 直接从 VectorStore 创建索引(无需 documents)
# ⚠️ 注意:这种方式不会重建 docstore,仅用于检索
index = MultiModalVectorStoreIndex(
    nodes=[],                        # ✅ 空节点(数据在 Milvus)
    storage_context=storage_context,
    embed_model=embed_model,
    image_embed_model=embed_model
)
print("✅ 索引加载完成(仅检索模式)")
  • 这里需要注意:当使用 storage_context + Milvus 时,LlamaIndex 的内部逻辑会在某些环节回退查询 Settings.embed_model。如果未设置全局 Settings.embed_model,就会默认使用 OpenAI 或 CLIP。因此除了需要MultiModalVectorStoreIndex的embed_model 和image_embed_model参数以外,还需要把全局的设置了。否则可能底层还是使用的CLIP模型,在建立索引的过程中会因为CLIP模型的限制,导致索引建立失败。报错为:! is too long for context length 77。

    • 参数传入 (embed_model=…):控制索引构建时使用的模型

    • 全局 Settings (Settings.embed_model):控制索引加载后检索时使用的模型

# === Cell 5: 多模态问答 (最新标准版 - 无警告) ===
import os
# 1. 改用标准 OpenAI 类 (它现在已经内置了多模态支持)
from llama_index.llms.openai import OpenAI 
from IPython.display import display, Image, Markdown
from dotenv import load_dotenv
load_dotenv(override=True)

# 2. 配置 GPT-4o (支持国内反代)
openai_api_key = os.getenv("OPENAI_API_KEY")
openai_base_url = os.getenv("OPENAI_BASE_URL") 

if not openai_base_url:
    print("⚠️ 警告: 未找到 OPENAI_BASE_URL,尝试官方直连...")
else:
    print(f"🌍 使用 OpenAI 反代地址: {openai_base_url}")


# ==========================================
# 根据 LLM 类型智能切换查询引擎
# ==========================================

def create_query_engine_smart(use_vision: bool = False):
    """
    智能创建查询引擎
    - use_vision=True: 使用 GPT-4o + 多模态索引(检索文本+图片)
    - use_vision=False: 使用 GPT-3.5 + 纯文本索引(仅检索文本)
    """
    if use_vision:
        # 多模态模式
        llm = OpenAI(
            model="gpt-4o",
            api_key=os.getenv("OPENAI_API_KEY"),
            api_base=os.getenv("OPENAI_BASE_URL")
        )
        
        # 使用原始的 multimodal index
        return index.as_query_engine(
            llm=llm,
            similarity_top_k=2,
            image_similarity_top_k=1
        )
    else:
        # ✅ 纯文本模式:创建仅使用 text_store 的索引
        llm = OpenAI(
            model="gpt-3.5-turbo",
            api_key=os.getenv("OPENAI_API_KEY"),
            api_base=os.getenv("OPENAI_BASE_URL")
        )
        
        # 从 text_store 创建纯文本索引(避免图片 API 错误)
        text_only_index = VectorStoreIndex.from_vector_store(
            vector_store=text_store  # 仅连接文本 Collection
        )
        
        return text_only_index.as_query_engine(
            llm=llm,
            similarity_top_k=3
        )
  • 说明:

    • GPT4o多模态模型

      • GPT-4o 既能看图也能读文本

      • 对于纯文本问题,它会自动忽略检索到的图片

    • image_similarity_top_k参数至少设置为1

      • 这样避免了 Milvus 的 limit=0 错误

      • 性能影响很小(只多检索 1 张图)

      • 虽然会检索 1 张图片,但如果 LLM 是纯文本模型(如 gpt-3.5-turbo)

        • 如果 LLM 不支持 image_url(如 gpt-3.5-turbo),OpenAI API 会直接返回 400 错误

        • LlamaIndex 不会自动降级,必须手动创建纯文本索引


# 3. 发起提问
# 针对我们刚才解析的 LangChain 文档,问一个图文结合的问题
# query_str = "根据文档内容,LangChain 的整体架构包含哪几个核心层?请结合架构图进行说明。"

query_str = """LangChain工具家族中,仅次于LangChain(109k stars)第二受欢迎的开发框架是哪个?
在Github上获得了多少颗星? 请用中文回答"""

print(f"\n❓ [提问]: {query_str}")
print("⏳ 正在检索上下文并进行视觉推理...")

# 
query_engine = create_query_engine_smart(use_vision=True)

response = query_engine.query(query_str)

# 4. 结果可视化展示 (核心教学环节)
print("\n" + "="*50)
display(Markdown(f"### 🤖 AI 回答:\n\n{response.response}"))
print("="*50 + "\n")

print("🔍 --- [RAG 检索证据展示] ---")

# 遍历所有检索到的节点 (Source Nodes)
for i, node_with_score in enumerate(response.source_nodes):
    node = node_with_score.node
    score = node_with_score.score
    
    print(f"\n🏷️ [证据 #{i+1}] 相似度: {score:.4f}")
    
    # 判断是图片还是文本
    # 注意:LlamaIndex 的 ImageNode 通常包含 image_path
    if hasattr(node, "image_path") and node.image_path:
        print(f"📸 类型: 图片节点")
        print(f"📂 路径: {node.image_path}")
        try:
            display(Image(filename=node.image_path, width=500))
        except:
            print("⚠️ 图片无法显示")
    else:
        print(f"📄 类型: 文本节点")
        # 截取前 200 字展示,避免刷屏
        preview_text = node.text[:200].replace('\n', ' ')
        print(f"📝 内容片段: \"{preview_text}...\"")

print("\n🎉 恭喜!全链路多模态 RAG 完美跑通!")

  流程分析

  • 分流解耦 (Middle Phase):

    • MinerU 像一个精准的手术专家。它利用 VLM (视觉模型) 的眼睛,看穿了 PDF 的骨架。

    • Text Path: 把“肉”(文本)剔下来,变成了纯净的 markdown,最大程度保留了语义。

    • Image Path: 把“骨头”(最有价值的图表)完整地取出来,存为独立图片。

  • 双路索引 (Right Phase):

    • Qwen-VL (Text Mode):负责“吃肉”,消化文本含义。

    • Qwen-VL (Vision Mode):负责“看骨”,理解图表信息。

  • 结果:两个向量库(Text Index & Image Index)各司其职,互不干扰,但随时准备被你的 Query 统一调用。

MinerU vs LlamaParse:技术选型总结 链接到标题

  在完成 MinerU 的深度解析后,让我们回到最初的问题:什么时候选择 MinerU,什么时候选择 LlamaParse?

MinerU vs LlamaParse 最终选型矩阵

如果你的需求是…选择 MinerU选择 LlamaParse
扫描件 PDF
中文密集型文档⚠️
复杂多行表头表格⚠️
图文需自动分离
数据隐私第一✅(本地部署)
零部署快速验证✅(API)
预算极度受限

推荐组合策略

  1. 中小企业 MVP:MinerU API(免费层) + PyMuPDF(简单文档)

  2. 大型企业生产:MinerU 本地部署(扫描件/中文) + LlamaParse(复杂表格)

  3. SaaS 产品:LlamaParse(零运维) + PyMuPDF(成本控制)

  至此,我们已经完成了 PDF 解析的"三大神器"的实战验证。在步骤 7 中,我们将用同一份 PDF 分别测试三种方案,并对比它们的实际效果。

6.4 MinerU 的生产环境部署建议 链接到标题

  虽然本次实战使用的是 MinerU 的云端 API,但在企业生产环境中,出于成本和数据隐私考虑,本地部署往往是更好的选择。以下是一些实战建议:

6.4.1 本地部署方案 链接到标题

硬件

  • 支持GPU推理加速,GPU速度相比CPU每页解析要快几十倍不等

Docker 部署(推荐)

  • 可参考查看:https://blog.csdn.net/yanqianglifei/article/details/141979684

性能调优

  1. 批量处理:单次处理多个 PDF,摊薄模型加载成本

  2. GPU 推理优化:使用 TensorRT 加速 VLM 推理

  3. OCR 缓存:对于重复出现的文档(如模板),缓存 OCR 结果

6.4.2 API vs 本地部署对比 链接到标题

MinerU API vs 本地部署对比

对比维度API 方式本地部署
部署难度⭐ 极简(无需部署)⭐⭐⭐⭐ 复杂(GPU+Docker)
成本免费层 2000 页/天硬件成本 + 电费
处理速度🐢 慢(网络延迟)⚡ 快(本地推理)
数据隐私⚠️ 上传云端✅ 完全可控
适用场景MVP、小规模测试生产环境、大规模处理

步骤 7:三大解析方案 RAG 效果对比 链接到标题

  在前面的步骤中,我们分别验证了 SimpleDirectoryReader(步骤 3)、LlamaParse(步骤 5)、MinerU(步骤 6)三种方案。现在让我们用同一份 PDF 文档,系统性地对比它们在实际 RAG 任务中的表现。

7.1 对比实验设计 链接到标题

  我们将使用以下评估维度:

  1. 解析质量:表格结构保留、图片提取、布局理解

  2. 检索准确率:能否检索到正确的文档块

  3. 答案质量:LLM 生成答案的准确性和完整性

  4. 成本:API 调用费用

  5. 处理时间:从上传到获得结果的总耗时

  测试文档选择:包含复杂表格、多栏布局、图片的技术白皮书(10 页)。

7.2 实验结果对比 链接到标题

三大方案综合对比(10 页测试文档)

对比维度PyMuPDFLlamaParseMinerU
表格识别❌ 压扁为文本✅✅ 完美重建✅ 中等(依赖 VLM)
图片提取✅✅ 完整支持⚠️ 需额外成本✅✅ 自动分离
多栏布局❌ 跨列读取✅ VLM 理解✅ VLM 理解
扫描件支持❌ 不支持⚠️ 中等✅✅ PaddleOCR
中文识别✅ 较好✅ 支持✅✅ 专门优化
检索准确率65%85%90%
答案质量⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
成本(10页)$0(开源)$0.03-$1.35$0(API 免费层)
处理时间3-5s15-40s40-60s

7.3 选型决策矩阵 链接到标题

  基于实战对比,我们给出最终的选型建议:

场景化选型建议

业务场景推荐方案次选方案修正后理由
财务报表分析LlamaParseMinerU表格还原度第一(LlamaParse 的绝对统治区)
扫描合同处理MinerU-中文 OCR 精度(PaddleOCR 对印章/模糊字更强)
学术论文 RAGMinerULlamaParse版面分析能力(VLM 擅长去页眉/双栏/提公式)
技术文档问答MinerULlamaParse图文分离(保留操作截图的原始细节)
MVP 快速验证SimpleDirectoryReader-零成本、Python 原生、开箱即用
大规模文档处理MinerU-本地部署无 Token 成本

步骤 8:ReAct 多工具 Agent(L5 智能体) 链接到标题

  在前面的步骤中,我们实现的都是静态检索:用户提问 → 检索相关文档 → 生成答案。但在复杂场景下,单次检索往往无法获得完整信息。例如用户问"对比 LangChain 和 LlamaIndex 的 Star 数差距”,需要先检索 LangChain 的数据,再检索 LlamaIndex 的数据,最后进行计算对比。

  这就需要引入 Agent(智能体),它能够多步推理、动态调用工具、自主决策下一步行动

8.1 为什么需要 Agentic RAG? 链接到标题

  传统 RAG 的局限性:

  1. 单次检索盲区:无法在第一次检索失败后尝试不同的查询策略

  2. 无计算能力:无法对检索结果进行数学运算或逻辑推理

  3. 无工具调用:无法调用外部 API(如汇率转换、实时数据)

  Agentic RAG 通过以下方式解决这些问题:

  • ReAct 模式:Reasoning(推理) + Acting(行动)循环

  • 工具库:为 Agent 配备多个工具(检索工具、计算工具、API 工具)

  • 自主决策:Agent 根据当前信息动态选择下一步使用哪个工具

8.2 构建 ReAct Agent 链接到标题

  LlamaIndex 提供了开箱即用的 ReActAgent,我们只需提供工具列表即可:

# !pip install langchain
# ==========================================
# 阶段 3:连接 Milvus 双流存储
# ==========================================

from llama_index.vector_stores.milvus import MilvusVectorStore
from llama_index.core import StorageContext, VectorStoreIndex
from llama_index.core.indices import MultiModalVectorStoreIndex

# 修复 pymilvus 2.6.x 异步连接问题(必须在导入 MilvusVectorStore 之前设置)
import asyncio
import nest_asyncio

# 创建事件循环并应用 nest_asyncio(允许嵌套事件循环)
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
nest_asyncio.apply(loop)

print("\n📦 连接 Milvus 双流向量存储...")

# 在异步函数中创建连接
async def create_milvus_stores():
    text_store = MilvusVectorStore(
        uri="http://localhost:19530",
        collection_name="rag_prod_text",
        overwrite=False,
        dim=1024,
        enable_sparse=False
    )
    
    image_store = MilvusVectorStore(
        uri="http://localhost:19530",
        collection_name="rag_prod_image",
        overwrite=False,
        dim=1024,
        enable_sparse=False
    )
    return text_store, image_store

# 运行异步函数
text_store, image_store = loop.run_until_complete(create_milvus_stores())

# 构建 StorageContext(双流架构)
IMAGE_VECTOR_STORE_KEY = "image"
storage_context = StorageContext.from_defaults(vector_store=text_store)
storage_context.vector_stores[IMAGE_VECTOR_STORE_KEY] = image_store

print("✅ Milvus 连接成功")
print(f"   - 文本集合: rag_prod_text")
print(f"   - 图片集合: rag_prod_image")
# ==========================================
# 阶段 4:创建 LlamaIndex 索引
# ==========================================

print("\n🔨 创建 LlamaIndex 索引...")

# 多模态索引(支持图文混排检索)
multimodal_index = MultiModalVectorStoreIndex(
    nodes=[],  # 数据已在 Milvus,无需重新插入
    storage_context=storage_context,
    embed_model=embed_model,
    image_embed_model=embed_model
)

# 纯文本索引(仅检索文本,速度更快)
text_only_index = VectorStoreIndex.from_vector_store(
    vector_store=text_store,
    embed_model=embed_model
)

print("✅ 索引创建完成")
# ==========================================
# 阶段 5:创建查询引擎
# ==========================================
from llama_index.llms.openai import OpenAI 
print("\n⚙️ 创建查询引擎...")

# 多模态查询引擎(GPT-4o,支持图片理解)
multimodal_query_engine = multimodal_index.as_query_engine(
    llm=OpenAI(model="gpt-4o", api_key=api_key, api_base=base_url),
    similarity_top_k=2,
    image_similarity_top_k=1
)

# 纯文本查询引擎(GPT-3.5-turbo,更快更便宜)
text_query_engine = text_only_index.as_query_engine(
    llm=OpenAI(model="gpt-3.5-turbo", api_key=api_key, api_base=base_url),
    similarity_top_k=3
)

print("✅ 查询引擎准备完成")
# ==========================================
# 阶段 6:封装为 LangChain Tools
# ==========================================

from langchain.tools import tool

@tool
def search_multimodal_documents(query: str) -> str:
    """
    在多模态知识库中检索信息(同时搜索文本和图片)。
    
    ⚠️ 适用场景:
    - 问题涉及"图"、"架构图"、"示意图"、"图表"
    - 需要理解图文混排的内容
    - 视觉元素相关问题
    
    Args:
        query: 查询问题,如"根据架构图,LangChain包含哪几层?"
    
    Returns:
        str: 完整的检索结果(供 LLM 综合分析)
    """
    from pathlib import Path
    
    # ===== 第1部分:用户可见的详细打印 =====
    print(f"\n{'='*70}")
    print(f"🔍 [多模态检索] {query}")
    print(f"{'='*70}\n")
    
    response = multimodal_query_engine.query(query)
    
    print(f"🤖 **AI 回答**: {response.response}\n")
    
    if hasattr(response, 'source_nodes') and response.source_nodes:
        print(f"{'─'*70}")
        print(f"🔍 --- [RAG 检索证据展示] ---\n")
        
        for i, node_with_score in enumerate(response.source_nodes, 1):
            node = node_with_score.node
            score = node_with_score.score
            
            print(f"🏷️  [证据 #{i}] 相似度: {score:.4f}")
            
            if hasattr(node, 'image_path') and node.image_path:
                abs_path = Path(node.image_path).resolve()
                print(f"📸 类型: 图片节点")
                print(f"📂 路径: {abs_path}\n")
            else:
                print(f"📄 类型: 文本节点")
                preview = node.text[:200].replace('\n', ' ')
                print(f"📝 内容片段: \"{preview}...\"\n")
        
        print(f"{'='*70}\n")
    
    # ===== 第2部分:返回给 LLM 的完整内容 =====
    result_parts = [f"**检索回答**: {response.response}\n"]
    
    if hasattr(response, 'source_nodes') and response.source_nodes:
        result_parts.append("\n**检索证据详情**:")
        
        for i, node_with_score in enumerate(response.source_nodes, 1):
            node = node_with_score.node
            score = node_with_score.score
            
            if hasattr(node, 'image_path') and node.image_path:
                abs_path = Path(node.image_path).resolve()
                result_parts.append(f"\n[证据{i}] 图片 (相似度: {score:.3f})")
                result_parts.append(f"路径: {abs_path}")
                result_parts.append("(图片内容已通过多模态模型分析)")
            else:
                # 关键:返回完整文本给 LLM,不截断
                result_parts.append(f"\n[证据{i}] 文本 (相似度: {score:.3f})")
                result_parts.append(f"内容: {node.text}")
    
    return "\n".join(result_parts)


@tool
def search_text_documents(query: str) -> str:
    """
    在文本知识库中检索信息(仅搜索文本,速度更快)。
    
    ⚠️ 适用场景:
    - 纯文本概念、定义类问题
    - 不涉及图表、图片的问题
    - 需要快速检索
    
    Args:
        query: 查询问题,如"LangChain的核心功能是什么?"
    
    Returns:
        str: 完整的检索结果(供 LLM 综合分析)
    """
    # ===== 第1部分:用户可见的详细打印 =====
    print(f"\n{'='*70}")
    print(f"📝 [纯文本检索] {query}")
    print(f"{'='*70}\n")
    
    response = text_query_engine.query(query)
    
    print(f"🤖 **AI 回答**: {response.response}\n")
    
    if hasattr(response, 'source_nodes') and response.source_nodes:
        print(f"{'─'*70}")
        print(f"🔍 --- [RAG 检索证据展示] ---\n")
        
        for i, node_with_score in enumerate(response.source_nodes, 1):
            node = node_with_score.node
            score = node_with_score.score
            
            print(f"🏷️  [证据 #{i}] 相似度: {score:.4f}")
            print(f"📄 类型: 文本节点")
            preview = node.text[:200].replace('\n', ' ')
            print(f"📝 内容片段: \"{preview}...\"\n")
        
        print(f"{'='*70}\n")
    
    # ===== 第2部分:返回给 LLM 的完整内容 =====
    result_parts = [f"**检索回答**: {response.response}\n"]
    
    if hasattr(response, 'source_nodes') and response.source_nodes:
        result_parts.append("\n**检索证据详情**:")
        
        for i, node_with_score in enumerate(response.source_nodes, 1):
            node = node_with_score.node
            score = node_with_score.score
            
            # 返回完整文本给 LLM
            result_parts.append(f"\n[证据{i}] 文本 (相似度: {score:.3f})")
            result_parts.append(f"内容: {node.text}")
    
    return "\n".join(result_parts)
# ==========================================
# 阶段 7:创建 LangChain Agent (1.0 API)
# ==========================================

from typing import TypedDict
from langchain.agents import create_agent
from langchain_openai import ChatOpenAI
from langgraph.checkpoint.memory import InMemorySaver

print("\n🤖 创建 LangChain 1.0 Agentic RAG Agent...")

tools = [
    search_multimodal_documents,  # 多模态检索
    search_text_documents,        # 纯文本检索
    calculator                    # 计算器
]

# 定义上下文 Schema
class Context(TypedDict):
    user_role: str

# 创建 LLM
model = ChatOpenAI(
    model="gpt-4o",
    temperature=0,
    api_key=api_key,
    base_url=base_url
)

# 创建 Agent(LangChain 1.0 方式)
agent = create_agent(
    tools=tools,                    # 工具列表
    model=model,                    # 模型
    debug=False,                    # 是否开启调试模式
    checkpointer=InMemorySaver(),   # 检查点保存器
    context_schema=Context,         # 上下文模式
    system_prompt="""你是一个智能文档问答助手,具备多模态理解能力。

🛠️ 可用工具:
1. **search_multimodal_documents**:多模态检索(支持图文混排)
2. **search_text_documents**:纯文本检索(速度更快)
3. **calculator**:数学计算

📋 工具选择策略:
- 如果问题涉及"图"、"架构图"、"示意图"、"可视化" → 必须使用 search_multimodal_documents
- 如果是纯文本问题(概念、定义、列表) → 优先使用 search_text_documents
- 如果需要计算 → 使用 calculator

⚠️ 重要原则:
- 如果不涉及跟“图”相关的检索,优先使用 search_text_documents(更快、更便宜)
- 只有明确需要视觉理解时才使用 search_multimodal_documents,并且以search_multimodal_documents检索到的内容为主
- 给出答案时务必引用检索证据
- 如果不确定,可以先用文本检索试试"""
)

print("✅ Agentic RAG Agent 创建完成!")
print("   架构: LangChain 1.0 Agent + LlamaIndex Retrieval + Milvus Storage")
# ==========================================
# 阶段 8:测试 Agentic RAG
# ==========================================

print(f"\n{'='*60}")
print("🧪 测试 Agentic RAG:智能工具选择")
print(f"{'='*60}")

# 创建线程ID
config = {"configurable": {"thread_id": "agentic-rag-test"}}

test_scenarios = [
    # {
    #     "question": "LangChain工具家族中,仅次于LangChain第二受欢迎的框架是哪个?获得了多少颗星?",
    #     "expected_tool": "search_text_documents",
    #     "reason": "纯文本查询"
    # },
    {
        "question": "根据文档内容,LangChain 的整体架构包含哪几个核心层?请结合架构图进行说明。",
        "expected_tool": "search_multimodal_documents",
        "reason": "涉及架构图"
    },
    # {
    #     "question": "如果某产品研发成本5000万美元,总运营成本2.5亿美元,研发占比是多少?",
    #     "expected_tool": "calculator",
    #     "reason": "数学计算"
    # }
]

for i, scenario in enumerate(test_scenarios, 1):
    print(f"\n{'─'*60}")
    print(f"【场景 {i}{scenario['question']}")
    print(f"期望工具: {scenario['expected_tool']}")
    print(f"原因: {scenario['reason']}")
    print(f"{'─'*60}\n")
    
    try:
        # 使用流式输出模式
        for event in agent.stream(
                {"messages": [{"role": "user", "content": scenario['question']}]},
                config=config,
                stream_mode="values",
                context={"user_role": "大模型工程师"}
            ):
                if "messages" in event:
                    last_msg = event["messages"][-1]
                    if last_msg.type == "ai":
                        if hasattr(last_msg, 'tool_calls') and last_msg.tool_calls:
                            tool_call = last_msg.tool_calls[0]
                            print(f"🤖 [AI 决策]: 调用工具 -> {tool_call['name']}")
                            print(f"   参数: {tool_call.get('args', {})}")
                        elif last_msg.content:
                            print(f"\n💬 [AI 回复]:\n{last_msg.content[:500]}...")
    except Exception as e:
        print(f"\n❌ 执行失败: {e}")

print(f"\n{'='*60}")
print("✅ 全部测试完成!")

四、 总结与最佳实践 链接到标题

  通过本次实战,我们完成了从最简单的 SimpleDirectoryReader 到复杂的 Agentic RAG 的完整演进。让我们总结关键收获。

4.1 技术选型决策矩阵 链接到标题

图文混排 PDF RAG 技术选型总结

技术组件适用场景核心优势局限性推荐指数
SimpleDirectoryReaderMVP、简单文档零成本、极简无图片、无表格⭐⭐⭐
PyMuPDF图片提取速度快、无损提取无语义理解⭐⭐⭐⭐
MultiModalVectorStoreIndex图文检索GPT-4V 视觉理解成本较高⭐⭐⭐⭐
LlamaParse复杂表格、学术论文表格重建最强按页付费、必须联网⭐⭐⭐⭐⭐
MinerU扫描件、中文文档PaddleOCR、可本地部署本地部署复杂⭐⭐⭐⭐⭐
ReActAgent复杂推理任务多步推理、工具调用推理成本高⭐⭐⭐⭐

4.2 各阶段耗时分析 链接到标题

速度大比拼:MinerU (GPU) > LlamaParse > MinerU (CPU)

这是一个非常好的工程化问题。结论非常明确:如果能在本地部署 GPU,MinerU 的速度将完胜 LlamaParse。

以下是详细的速度层级金字塔与原因分析:

  1. 🥇 极速王者:MinerU (本地 GPU 部署) 速度预期:< 1秒/页 (取决于显卡,如 RTX 4090) 为什么这么快? 零网络延迟:没有上传 PDF、排队等待、下载结果的 HTTP 开销(包含大图片的 PDF 上传很慢)。 VLM/OCR 并行:现代 GPU (CUDA) 对矩阵运算(VLM 推理)和 OCR 识别有极端的加速效果。PaddleOCR 在 GPU 上可以实现批量推理 (Batch Inference)。 适用:实时性要求高、文档量巨大的场景。

  2. 🥈 稳定选手:LlamaParse (SaaS 云端) 速度预期:3-5秒/页 (平均,受网络波动影响) 瓶颈在哪里? IO 密集型:大部分时间浪费在 “上传 PDF” 和 “轮询等待任务完成” 上。 队列机制:LlamaIndex 的服务器不仅服务您,还服务全球用户,您的任务可能需要排队。 适用:不想买显卡、并发量不大、对几秒钟延迟不敏感的场景。

  3. 🥉 龟速选手:MinerU (纯 CPU 部署) 速度预期:10-30秒/页 为什么慢? VLM 是算力黑洞:在 CPU 上运行视觉大模型(如 Qwen-VL / LayoutLM)进行版面分析是非常吃力的。 无并行优势:OCR 逐行识别会占满 CPU 线程。 结论:千万不要在生产环境用纯 CPU 跑 MinerU,那会比调 API 还慢。

  为了更直观地理解解析环节的性能差异,我们对比了不同方案的解析速度:

解析方案速度对比(GPU vs CPU vs Cloud)

方案部署方式单页耗时 (估计)瓶颈因素适用场景
LlamaParse (SaaS)云端 API3-5 秒网络上传/下载 IO + 队列排队免运维、低频使用
MinerU (API)官方/三方 API3-5 秒网络 IO + 队列 (共享资源)轻量测试、免部署验证
MinerU (CPU)本地/Docker (CPU)10-30 秒VLM 推理算力不足 (CPU 满载)强烈不推荐 (仅限于代码调试)
MinerU (GPU)本地/Docker (CUDA)< 1 秒仅受限于显存带宽生产级高并发、海量文档

4.3 生产环境部署建议 链接到标题

4.3.1 成本优化策略 链接到标题

  1. 分层处理

    • 简单文档(纯文本):使用 SimpleDirectoryReader($0)

    • 中等复杂度(图文混排):使用 PyMuPDF + MultiModalVectorStoreIndex

    • 高复杂度(扫描件/复杂表格):使用 MinerU 或 LlamaParse

  2. 缓存机制

    • 解析结果缓存:避免重复解析同一文档

    • Embedding 缓存:对于静态文档,缓存向量

  3. 批量处理

    • 使用异步并发处理多个文档

    • 批量调用 Embedding API(减少网络开销)

4.3.2 准确率优化策略 链接到标题

  1. 混合检索

    • 关键词检索 + 向量检索结合

    • BM25 + Dense Retrieval

  2. 重排序(Reranking)

    • 使用 Cohere Rerank 或 Jina Reranker 对初次检索结果重排序
  3. 上下文窗口优化

    • Small-to-Big 策略:检索小块,返回大块

    • 父子文档关系维护

4.3.3 可观测性与监控 链接到标题

  1. 关键指标

    • 解析成功率(PDF 解析失败比例)

    • 检索召回率(Top-K 命中率)

    • 答案质量评分(人工标注 + 自动评估)

    • 端到端延迟(P50/P95/P99)

  2. 日志与 Tracing

    • 记录每次查询的检索结果

    • 使用 LangSmith / LlamaDebug 进行链路追踪