LangChain RAG 数据预处理:Document Loader 与 Text Splitter 深度解析

59 阅读12分钟

本文聚焦 RAG 链路中最前置、也最容易被忽视的两个组件——Document Loader(文档加载器)与 Text Splitter(文本切分器),并以最常用的 RecursiveCharacterTextSplitter 为例,从原理到代码进行完整拆解。

一、RAG 数据流水线中的两道关卡

在深入组件之前,先用一张全景图定位 Loader 和 Splitter 在整个 RAG 系统中的位置:

img

上图标注绿色的 Document Loader 和蓝色的 Text Splitter,是 RAG 数据流入系统的第一道和第二道关卡:

  • Loader 负责"把东西读进来"——从 PDF、网页、数据库、SaaS 工具等异构数据源读取内容,统一封装为 LangChain 内部的 Document 对象。
  • Splitter 负责"把大块切成小块"——将长文档拆分为语义连贯的片段(chunk),使每个片段既能独立被检索,又不会超出模型的上下文窗口。

这两步做不好,后面再强的 Embedding 模型和 LLM 也救不回来——垃圾进,垃圾出。

二、Document Loader:统一数据入口

2.1 Document 对象——一切的起点

Loader 输出的每个文档都是一个 Document 对象,结构非常简洁:

img

字段类型说明
page_contentstr文档的核心文本内容,后续所有切分、向量化都基于此字段
metadatadict附属信息,如来源 URL、页码、作者等,不参与切分,但会随 chunk 一起传播

metadata 是一个容易被忽略但至关重要的设计。

切分后的每个 chunk 都会继承父文档的 metadata,这意味着你在检索阶段不仅能拿到文本,还能追溯到它来自哪个文件、哪一页。

img

2.2 BaseLoader——所有加载器的统一接口

LangChain 的设计哲学是"接口统一、实现多样"。所有 Loader 都实现 BaseLoader 接口:

img

核心方法只有两个,但它们的区别决定了你能处理多大规模的数据:

方法行为适用场景
load()一次性加载所有文档到内存小数据集、快速原型
lazy_load()逐个 yield 文档,惰性迭代大数据集、流式处理

这个区别看似微妙,但在处理包含数千个文件的目录或爬取大型网站时,load() 可能直接导致内存溢出,而 lazy_load() 则可以平稳地逐条处理

img

2.3 Loader 分类全景

LangChain 官方文档将 Loader 按数据源类型分为几大类:

img

这里有几个值得关注的加载器:

  • Unstructured:生态最广的通用加载器,支持 PDF、HTML、Word、PPT、邮件等数十种格式,底层依赖 unstructured 库。
  • Docling:IBM 开源的新一代文档解析引擎,对复杂排版(表格、多栏、图文混排)的解析质量很高。
  • PyMuPDF4LLM:专为 LLM 场景优化的 PDF 加载器,速度快,对表格和图片的处理较好。
  • Firecrawl / Apify:商用爬虫服务,适合大规模网页采集后直接灌入 RAG。

2.4 Loader 代码案例

下面演示几种最常用的 Loader 用法,你可以自行测试并截图。

案例 1:加载文本文件

from langchain_community.document_loaders import TextLoader


loader = TextLoader("D:\\project\\llms\\langchain\\data\\state_of_the_union.txt",encoding = 'utf-8')
documents = loader.load()

print(f"加载了 {len(documents)} 个文档\n")
print(f"page_content 前 100 字符: {documents[0].page_content[:100]}\n")
print(f"metadata: {documents[0].metadata}")

img

案例 2:加载 PDF(每页一个 Document)

from langchain_community.document_loaders import PyPDFLoader

loader = PyPDFLoader("example.pdf")
pages = loader.load()

print(f"PDF 共 {len(pages)} 页")
for i, page in enumerate(pages):
    print(f"--- 第 {i+1} 页 ---")
    print(f"metadata: {page.metadata}")
    print(f"content 前 80 字符: {page.page_content[:80]}")

PyPDFLoader 会将每页 PDF 封装为一个独立的 Document,metadata 中包含 {'page': N, 'source': 'example.pdf'}。

这个页码信息在后续检索时非常有用——你可以告诉用户答案来自原文第几页。

img

案例 3:加载网页

from langchain_community.document_loaders import WebBaseLoader

loader = WebBaseLoader("https://python.langchain.com/docs/get_started/introduction")
docs = loader.load()

print(f"加载了 {len(docs)} 个文档")
print(f"metadata: {docs[0].metadata}")
print(f"content 前 200 字符: {docs[0].page_content[:200]}")

img

案例 4:惰性加载大型目录

from langchain_community.document_loaders import DirectoryLoader

# 批量加载目录下所有 .txt 文件
loader = DirectoryLoader(
    "./documents",
    glob="**/*.txt",
    loader_cls=TextLoader,
    show_progress=True,
)

# 大目录场景:使用 lazy_load 逐条处理
for doc in loader.lazy_load():
    print(f"处理: {doc.metadata['source']}, 长度: {len(doc.page_content)}")

img

三、Text Splitter:切分策略与选型

3.1 为什么必须切分

Loader 产出的是完整文档,但直接将一篇万字长文喂给 Embedding 模型或 LLM 有两个致命问题:

img

切分的目标是在语义完整性和chunk 大小约束之间找到平衡:每个 chunk 足够小以适配模型窗口,又足够大以保持语义自洽。

3.2 三大切分策略概览

LangChain 官方文档将切分策略分为三类:

img

策略切分依据适用场景代表 Splitter
文本结构型段落、句子、词的层次通用文本(推荐首选)RecursiveCharacterTextSplitter
长度型字符数或 token 数需精确控制 chunk 大小CharacterTextSplitter、TokenTextSplitter
文档结构型HTML 标签、Markdown 标题、JSON 结构有结构标记的文档MarkdownHeaderTextSplitter 等

官方文档明确建议:对于大多数场景,直接从 RecursiveCharacterTextSplitter 开始,它在保持上下文完整和管理 chunk 大小之间提供了良好的平衡[1]。

四、RecursiveCharacterTextSplitter 深度解析

这是整个 Splitter 体系中最核心、使用率最高的组件。理解它的原理,等于掌握了 LangChain 文本切分的核心思想。

4.1 核心思想:递归降级切分

RecursiveCharacterTextSplitter 的设计哲学是:尽最大努力保持语义单元的完整性。

它维护一个有序的分隔符列表,默认值为:

img

这个列表的顺序不是随意的——它代表了从大到小的语义层次:

img

切分算法的核心逻辑可以用以下伪代码描述:

function split_text(text, separators, chunk_size):
    separator = separators[0]  # 先用最大的分隔符
    
    # 用 separator 把文本分成多段
    splits = text.split(separator)
    
    chunks = []
    current_chunk = ""
    
    for split in splits:
        # 如果这段本身就已经超出了 chunk_size
        if length(split) > chunk_size and len(separators) > 1:
            # 递归:用下一级分隔符继续切
            sub_chunks = split_text(split, separators[1:], chunk_size)
            chunks.extend(sub_chunks)
        elif length(current_chunk + separator + split) <= chunk_size:
            # 当前段还能放下,拼进去
            current_chunk += separator + split
        else:
            # 放不下了,当前 chunk 收尾,开始新 chunk
            chunks.append(current_chunk)
            current_chunk = split
    
    if current_chunk:
        chunks.append(current_chunk)
    
    return chunks

对应的流程图:

img

关键直觉:这个算法就像剥洋葱——先尝试沿段落边界撕开(代价最小、语义保持最好),撕不开再沿行边界撕,还不行再沿词边界撕,最差情况才会把一个词从中间劈开。

4.2 一个完整的切分示例

用一个具体例子来走一遍流程。假设有如下文本,chunk_size=50,chunk_overlap=0:

这是第一段,它比较长,超过了五十个字符的限制。\n\n这是第二段,它比较短。

切分过程如下:

img

现在改一下条件:chunk_size=30:

img

如果段落本身就超过了 chunk_size,递归降级就启动了:

img

4.3 chunk_overlap:为何要让 chunk"重叠"

chunk_overlap 参数控制相邻 chunk 之间的重叠区域大小。为什么要重叠?看下图:

img

重叠区的本质是用冗余换鲁棒性——在 chunk 边界处牺牲一点存储空间,换取检索时上下文不会断裂。典型的经验值是 chunk_overlap = chunk_size * 10%~20%。

4.4 参数全景

img

参数类型默认值说明
chunk_sizeint4000chunk 的最大尺寸,尺寸由 length_function 定义
chunk_overlapint0相邻 chunk 之间的重叠字符数
length_functionCallablelen度量文本长度的函数,默认按字符数,也可用 tiktoken 按 token 数
separatorsList[str]["\n\n", "\n", " ", ""]有序分隔符列表,从大到小递归降级
is_separator_regexboolFalse是否将 separators 视为正则表达式

关于 chunk_size 的选择,没有一个放之四海皆准的值,但有一些经验区间:

img

4.5 中文与无词边界语言的处理

默认分隔符列表 ["\n\n", "\n", " ", ""] 对英文很合适,因为英文以空格分词。

但中文、日文、泰文等书写系统没有词边界,空格分隔符几乎无效,直接降级到逐字符切分,可能把一个词从中间切断。

官方文档给出了针对中文的解决方案——在分隔符列表中加入中文标点:

img

4.6 完整代码案例

案例 1:基础用法——split_text vs create_documents

from langchain_text_splitters import RecursiveCharacterTextSplitter

# 示例文本
text = """人工智能(AI)是计算机科学的一个分支,致力于开发能够模拟人类智能的系统。

机器学习是AI的核心子领域,通过数据训练模型使其具备预测和决策能力。

深度学习是机器学习的子集,使用多层神经网络处理复杂数据。

自然语言处理(NLP)是AI的另一个重要分支,专注于让计算机理解和生成人类语言。"""

# 创建 splitter
text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=100,
    chunk_overlap=20,
    length_function=len,
    is_separator_regex=False,
)

# 方式1: split_text — 返回字符串列表
texts = text_splitter.split_text(text)
print(f"切分为 {len(texts)} 个 chunk:")
for i, t in enumerate(texts):
    print(f"\n--- chunk {i+1} ({len(t)}字符) ---")
    print(t)

# 方式2: create_documents — 返回 Document 对象列表(带 metadata)
documents = text_splitter.create_documents([text])
print(f"\ncreate_documents 返回 {len(documents)} 个 Document:")
for i, doc in enumerate(documents):
    print(f"\n--- Document {i+1} ---")
    print(f"page_content ({len(doc.page_content)}字符): {doc.page_content[:80]}...")
    print(f"metadata: {doc.metadata}")

img

split_text 和 create_documents 的核心区别:

img

案例 2:中文优化分隔符

from langchain_text_splitters import RecursiveCharacterTextSplitter

chinese_text = """
大语言模型(LLM)是通过海量文本数据训练的深度学习模型,具备强大的语言理解和生成能力。RAG(检索增强生成)是一种结合检索和生成的技术架构,通过从外部知识库检索相关文档来增强LLM的回答质量。这种架构有效缓解了LLM的幻觉问题,使回答更加准确可靠。

RAG系统通常包含三个核心组件:文档加载器负责从各种数据源读取文档,文本切分器将长文档切分为适合检索的小片段,向量数据库存储文档片段的向量表示用于相似度检索。此外,Embedding模型将文本转换为向量,检索器根据查询向量从数据库中找到最相关的片段,最后将检索结果作为上下文传给LLM生成最终回答。
"""

# 默认分隔符——对中文可能不太友好
splitter_default = RecursiveCharacterTextSplitter(
    chunk_size=100,
    chunk_overlap=10,
)
texts_default = splitter_default.split_text(chinese_text)
print("=== 默认分隔符 ===")
for i, t in enumerate(texts_default):
    print(f"chunk {i+1}: {t[:60]}...")

# 中文优化分隔符
splitter_cn = RecursiveCharacterTextSplitter(
    chunk_size=100,
    chunk_overlap=10,
    separators=[
        "\n\n",
        "\n",
        "。",
        ",",
        "、",
        "\uff0c",  # 全角逗号 ,
        "\u3001",  # 顿号 、
        "\uff0e",  # 全角句号 .
        "\u3002",  # 句号 。
        "\u200b",  # 零宽空格(泰语/日语)
        " ",
        "",
    ],
)
texts_cn = splitter_cn.split_text(chinese_text)
print("\n=== 中文优化分隔符 ===")
for i, t in enumerate(texts_cn):
    print(f"chunk {i+1}: {t[:60]}...")

img

案例 3:从 Document 切分——load_and_split

当你从 Loader 拿到 Document 后,可以直接用 split_documents 方法切分,metadata 会自动传播:

from langchain_community.document_loaders import TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter

# 1. 用 Loader 加载
loader = TextLoader("state_of_the_union.txt")
documents = loader.load()
print(f"加载了 {len(documents)} 个文档, 总字符数: {sum(len(d.page_content) for d in documents)}")

# 2. 用 Splitter 切分
splitter = RecursiveCharacterTextSplitter(
    chunk_size=500,
    chunk_overlap=50,
    separators=["\n\n", "\n", "。", "!", "?", ",", " ", ""],
)

# split_documents 直接处理 Document 列表
chunks = splitter.split_documents(documents)
print(f"\n切分为 {len(chunks)} 个 chunk")
print(f"\n前 3 个 chunk 的 metadata:")
for i, chunk in enumerate(chunks[:3]):
    print(f"  chunk {i+1}: source={chunk.metadata['source']}, "
          f"长度={len(chunk.page_content)}")

img

metadata 被原封不动地复制到每个 chunk——这就是追溯能力的来源。

img

五、端到端实战:Loader + Splitter 串联

将两个组件串联起来,完成从原始文件到可入库 chunk 的完整流程:

from langchain_community.document_loaders import PyPDFLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter

# ---- Step 1: Loader 加载 ----
loader = PyPDFLoader("research_paper.pdf")
pages = loader.load()
print(f"[Loader] 加载了 {len(pages)} 页")

# ---- Step 2: Splitter 切分 ----
text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=800,
    chunk_overlap=100,
    separators=["\n\n", "\n", "。", "!", "?", ",", "、", " ", ""],
)

chunks = text_splitter.split_documents(pages)
print(f"[Splitter] 切分为 {len(chunks)} 个 chunk")
print(f"[Splitter] 平均长度: {sum(len(c.page_content) for c in chunks) / len(chunks):.0f} 字符")

# ---- Step 3: 检查结果 ----
print("\n--- 抽样检查 ---")
for i in [0, len(chunks)//2, -1]:
    chunk = chunks[i]
    print(f"\nchunk[{i}]:")
    print(f"  source: {chunk.metadata['source']}")
    print(f"  page:   {chunk.metadata.get('page', 'N/A')}")
    print(f"  length: {len(chunk.page_content)}")
    print(f"  preview: {chunk.page_content[:100]}...")

# ---- Step 4: 传入向量数据库(示意) ----
# from langchain_openai import OpenAIEmbeddings
# from langchain_community.vectorstores import Chroma
# embedding = OpenAIEmbeddings()
# vectorstore = Chroma.from_documents(chunks, embedding)

完整的端到端数据流:

img

img

六、参数调优建议

不同场景下的推荐参数配置:

场景chunk_sizechunk_overlap分隔符策略理由
精确问答(客服/FAQ)300~50050~100中文标点优化小 chunk 检索精度高,重叠避免边界丢信息
知识库检索(通用)800~1200100~200中文标点优化平衡精度与 context 丰富度
长文档摘要(论文/报告)1500~2000200~400段落优先大 chunk 保留更多上下文,适合摘要任务
代码文档500~80050~100代码专用分隔符按函数/类边界切分

调优时的核心思路可以用一张图概括:

img

七、结语

Document Loader 和 Text Splitter 是 RAG 系统中"做对了看不出价值,做错了全链路遭殃"的基础组件。

Loader 的核心价值在于接口统一——BaseLoader 的 load() / lazy_load() 双方法设计,让你可以无缝切换数据源而不改动下游代码。选 Loader 时,关注它对你目标文件格式的解析质量(尤其是表格、多栏、图文混排),而不仅仅是"能不能加载"。

RecursiveCharacterTextSplitter 的核心智慧在于递归降级——不是一刀切,而是先尝试沿最强语义边界(段落)切,切不动再逐级降级。这个设计让它在绝大多数场景下都能产出语义连贯的 chunk,这也是官方推荐它作为首选的原因。

两个容易踩的坑:一是对中文文本忘记加入中文标点分隔符,导致词被从中间切断;二是把 chunk_overlap 设为 0,在 chunk 边界处丢失上下文。记住这两点,你的 RAG 数据预处理质量就能超过多数匆忙上手的实现。

参考来源

  1. LangChain 官方文档 — Text splitter integrations
  2. LangChain 官方文档 — RecursiveCharacterTextSplitter 详细指南
  3. LangChain 官方文档 — Document loader integrations