第六篇 文档处理与数据清洗:从非结构化到结构化#

在RAG(检索增强生成)系统中,文档处理质量(ETL)直接决定了最终效果的上限。“Garbage In, Garbage Out” 是绝对真理。无论你的模型多么强大,如果喂给它的数据是破碎、混乱或含有噪声的,检索效果一定很差。

本篇不仅介绍工具的使用,更侧重于生产级文档处理方法论,对比 LangChain 和 LlamaIndex 的最佳实践,并涵盖最新的 PDF 解析技术(如 MinerU, LlamaParse)。


学习路径#

graph LR
    A[ETL核心方法论] --> B[Loading<br/>多模态加载]
    B --> C[Chunking<br/>智能切分]
    C --> D[Metadata<br/>元数据增强]
    D --> E[实战<br/>复杂PDF处理]

    style A fill:#e1f5e1
    style B fill:#fff4e1
    style D fill:#ffe1e1
    style E fill:#e1f5fe

Part 1: ETL 核心方法论#

在构建 LLM 应用时,我们遵循标准的 ETL (Extract, Transform, Load) 流程,但在向量数据库语境下,通常描述为:

  1. Load (加载): 将各种非结构化数据(PDF, HTML, MarkDown)统一为标准 Document 对象。
  2. Split (切分): 将长文档切分为适合 Embedding 模型窗口(如 512/1024 tokens)的 Chunks
  3. Embed (向量化): 将文本块转化为向量。
  4. Store (存储): 存入向量数据库。

关键数据结构对比

概念LangChainLlamaIndex说明
原始文档DocumentDocument包含 page_content (text) 和 metadata
切分单元Document (chunk)NodeLlamaIndex 的 Node 结构更丰富,包含关系信息
加载器BaseLoaderBaseReader接口命名不同,功能类似

Part 2: 数据加载 (Loading)#

2.1 LangChain 加载方案#

LangChain 的加载器生态非常丰富,适合处理异构数据源。

2.1.1 网页加载 (WebBaseLoader)#

最常用的网页加载器,基于 BeautifulSoup

from langchain_community.document_loaders import WebBaseLoader

# 1. 加载单个网页
loader = WebBaseLoader("https://python.langchain.com/docs/get_started/introduction")
docs = loader.load()

print(f"Loaded {len(docs)} docs")
print(f"Content preview: {docs[0].page_content[:200]}")
print(f"Metadata: {docs[0].metadata}")

# 2. 并行加载多个网页
loader_multi = WebBaseLoader([
    "https://www.google.com",
    "https://www.baidu.com"
])
loader_multi.requests_per_second = 2  # 限流
docs_multi = loader_multi.aload() # 异步加载

2.1.2 目录加载 (DirectoryLoader)#

适合加载本地知识库文件夹。

from langchain_community.document_loaders import DirectoryLoader
from langchain_community.document_loaders import TextLoader

# 加载指定目录下所有的 .md 文件
loader = DirectoryLoader(
    './knowledge_base',
    glob="**/*.md",
    loader_cls=TextLoader,
    show_progress=True
)
docs = loader.load()

2.2 LlamaIndex 加载方案#

LlamaIndex 的 SimpleDirectoryReader 是目前最强大的全能加载器,一行代码即可处理 PDF, Word, Excel, 图片等多种格式。

2.2.1 SimpleDirectoryReader (全能王)#

from llama_index.core import SimpleDirectoryReader

# 1. 基础用法:加载目录
reader = SimpleDirectoryReader(
    input_dir="./data",
    recursive=True,         # 递归子目录
    required_exts=[".pdf", ".docx"], # 指定后缀
    filename_as_id=True     # 使用文件名做ID
)
documents = reader.load_data()

print(f"Loaded {len(documents)} docs")

# 2. 自定义特定的加载器 (例如对 .pdf 使用特殊的解析逻辑)
from llama_index.readers.file import PDFReader
file_extractor = {".pdf": PDFReader()}

reader_custom = SimpleDirectoryReader(
    "./data",
    file_extractor=file_extractor
)

2.2.2 LlamaHub (加载器生态)#

LlamaIndex 拥有世界上最大的数据加载器社区 LlamaHub

# 例如:加载 Notion 数据
from llama_index.readers.notion import NotionPageReader

# 需要先在 Notion 申请 Integration Token
reader = NotionPageReader(integration_token="secret_xxx")
documents = reader.load_data(page_ids=["page_id_1", "page_id_2"])

Part 3: 智能切分 (Chunking)#

切分策略直接影响检索的准确性。切分太碎会丢失上下文,切分太大则包含噪声。

LangChain vs LlamaIndex 切分器对比#

特性LangChain: RecursiveCharacterTextSplitterLlamaIndex: SentenceSplitter
默认行为递归尝试分隔符 ["\n\n", "\n", " ", ""]优先按句子完整性切分,窗口滑动
优势通用性强,适合代码、Markdown语义保留更好,适合纯文本
元数据需手动维护自动保留前后文关系 (Relationships)

3.1 LangChain: 递归字符切分#

from langchain_text_splitters import RecursiveCharacterTextSplitter

text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=1000,      # 块大小
    chunk_overlap=200,    # 重叠部分,防止上下文丢失
    separators=["\n\n", "\n", " ", ""] # 优先级从左到右
)

splits = text_splitter.split_documents(docs)

3.2 LlamaIndex: 句子窗口切分#

LlamaIndex 的切分器通常称为 NodeParser

from llama_index.core.node_parser import SentenceSplitter

# 默认分块器
splitter = SentenceSplitter(
    chunk_size=1024,
    chunk_overlap=20

)

nodes = splitter.get_nodes_from_documents(documents)

# 查看生成的 Node
print(f"生成了 {len(nodes)} 个节点")
print(nodes[0].get_content()) # 内容
print(nodes[0].relationships) # 关系(上一块、下一块的ID)

3.3 高级策略:分层切分 (Hierarchical Chunking)#

对于长文档,父子索引 (Parent-Child Indexing) 是一种非常有效的策略:

  • 父块: 大块(如 2048 tokens),用于给 LLM 生成答案,保留完整上下文。
  • 子块: 小块(如 256 tokens),用于向量检索,提高精准度。

LlamaIndex 实现:

from llama_index.core.node_parser import HierarchicalNodeParser

node_parser = HierarchicalNodeParser.from_defaults(
    chunk_sizes=[2048, 512, 128] # 三层切分
)
nodes = node_parser.get_nodes_from_documents(documents)
# 检索时,如果命中 128 的块,可以自动回溯到 2048 的父块内容

Part 4: 元数据提取 (Metadata Extraction)#

单纯靠文本内容检索往往不够,我们需要结构化元数据(如标题、作者、摘要、关键词)来进行过滤(Pre-filtering)。

4.1 使用 LLM 提取元数据#

这是目前最灵活的方法。我们可以定义一个 Pydantic 模型,利用 LLM 的 Function Calling 能力提取元数据。

from langchain_core.pydantic_v1 import BaseModel, Field
from langchain_openai import ChatOpenAI

# 1. 定义元数据结构
class DocumentMetadata(BaseModel):
    title: str = Field(..., description="The title of the document")
    summary: str = Field(..., description="A one-sentence summary")
    tags: list[str] = Field(..., description="Keywords for categorization")
    sentiment: str = Field(..., description="Sentiment: positive, negative, or neutral")

# 2. 配置 LLM
llm = ChatOpenAI(model="gpt-4", temperature=0)
structured_llm = llm.with_structured_output(DocumentMetadata)

# 3. 提取函数
def extract_metadata(text_chunk):
    return structured_llm.invoke(text_chunk)

# 测试
sample_text = """
LlamaIndex v0.10 发布了!这次更新带来了重大的架构变革,
将库拆分为 llama-index-core 和各种插件包。用户现在可以按需安装依赖,
大大减小了包体积。社区对此反应热烈。
"""

metadata = extract_metadata(sample_text)
print(metadata.json(indent=2))
# 输出:
# {
#   "title": "LlamaIndex v0.10 Release",
#   "summary": "LlamaIndex v0.10 introduces architectural changes splitting the core library from plugins for optimized dependency management.",
#   "tags": ["LlamaIndex", "Release", "Python", "Architecture"],
#   "sentiment": "positive"
# }

4.2 LlamaIndex 自动化 Pipeline#

LlamaIndex 提供了 IngestionPipeline 来串联提取过程。

from llama_index.core.ingestion import IngestionPipeline
from llama_index.core.extractors import TitleExtractor, SummaryExtractor

pipeline = IngestionPipeline(
    transformations=[
        SentenceSplitter(chunk_size=1024, chunk_overlap=200),
        TitleExtractor(nodes=5),    #利用前5个节点生成标题
        SummaryExtractor(summaries=["prev", "self", "next"]), # 生成摘要
    ]
)

nodes = pipeline.run(documents=documents)
# nodes[0].metadata 现在会自动包含 'document_title', 'section_summary' 等字段

Part 5: 复杂文档实战 (PDF & Tables)#

真实世界的文档(尤其是 PDF)充满了挑战:多栏布局、表格、公式、图片。简单的 PyPDF 往往无法胜任。

5.1 解决方案对比矩阵#

方案核心技术优势劣势推荐场景
LlamaParseLLM VisionSOTA 表格/布局识别,Markdown 输出付费,云端处理复杂表格、图文混排商业文档
MinerUDeep Learning开源免费,公式识别极强 (LaTeX)需 GPU,部署较重学术论文、课本、公式密集型
PyMuPDFRule-based极快,免费复杂布局/表格无法处理纯文本 PDF,简单发票
UnstructuredHybrid格式支持最全速度较慢,依赖系统库多格式杂乱的文档堆

5.2 LlamaParse 实战 (推荐)#

LlamaParse 是专为 RAG 设计的解析器,它直接将 PDF 转换为 Markdown,完美保留标题层级和表格结构。

# pip install llama-parse
import os
from llama_parse import LlamaParse
from llama_index.core import SimpleDirectoryReader

# 配置 API Key (https://cloud.llamaindex.ai/)
os.environ["LLAMA_CLOUD_API_KEY"] = "llx-..."

# 1. 配置解析器
parser = LlamaParse(
    result_type="markdown",  # 输出 Markdown
    verbose=True,
    language="zh",           # 支持中文
    gpt4o_mode=True          # 开启高级多模态识别 (消耗更多 credit)
)

# 2. 结合 SimpleDirectoryReader 使用
file_extractor = {".pdf": parser}
reader = SimpleDirectoryReader(
    "./complex_docs",
    file_extractor=file_extractor
)
documents = reader.load_data()

# 3. 检查结果
print(documents[0].text[:500])
# 你会发现表格被转换为了 Markdown Table 格式:
# | 季度 | 营收 | 利润 |
# |-----|------|-----|
# | Q1  | 100  | 20  |

5.3 MinerU 实战 (学术/公式场景)#

如果你处理的是包含大量数学公式的论文,MinerU (Magic-PDF) 是目前开源界的 SOTA。

注:MinerU 需要独立部署,以下为 Python 调用示例。

# 假设已在本地安装 magic-pdf
# pip install magic-pdf[full]

import os

# MinerU 通常通过命令行使用,但也可以封装为 Python 函数
def process_pdf_with_mineru(pdf_path):
    output_dir = os.path.dirname(pdf_path)
    # 调用 CLI (实际生产环境建议使用 API 服务模式)
    os.system(f"magic-pdf -p {pdf_path} -o {output_dir}")

    # MinerU 会生成 .md 文件
    md_path = pdf_path.replace(".pdf", ".md")
    if os.path.exists(md_path):
        with open(md_path, "r") as f:
            return f.read()
    return None

# 读取后的 Markdown 内容可以直接喂给 LangChain/LlamaIndex
markdown_content = process_process_pdf_with_mineru("./paper.pdf")

5.4 图片与图表处理 (Multimodal RAG)#

对于 PDF 中的图片,通常有两种策略:

  1. OCR 转文本: 使用 GPT-4o-vision 描述图片内容,存为文本。
  2. 多模态索引: 将图片直接作为 Embedding 存入(如 CLIP Embedding),支持文搜图。

策略 1 代码示例 (使用 GPT-4o 生成图片描述):

# 这是一个概念性示例
def process_image(image_bytes):
    from langchain_openai import ChatOpenAI
    from langchain_core.messages import HumanMessage

    chat = ChatOpenAI(model="gpt-4o")

    msg = HumanMessage(content=[
        {"type": "text", "text": "请详细描述这张图片中的图表数据或关键信息。"},
        {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{base64_image}"}}
    ])

    response = chat.invoke([msg])
    return response.content

总结#

文档处理是 RAG 系统中最 “脏” 但最 “重要” 的环节。

  • Loading: 首选 LlamaIndex SimpleDirectoryReader,简单强大。
  • Parsing: 复杂 PDF 首选 LlamaParse (商业) 或 MinerU (开源)。
  • Chunking: 文本用 SentenceSplitter,代码用 RecursiveCharacterTextSplitter
  • Metadata: 务必提取 标题摘要,这对检索排序至关重要。

做好 ETL,你的 RAG 系统就已经成功了一半。

[统计组件仅在生产环境显示]