首页/LangChain 指南/文档加载与切割
LangChain 指南 · 第 06 章

文档加载与切割

本章你将学会 - Document 对象:LangChain 的「统一集装箱」——不管数据来自 txt、PDF、CSV 还是网页,装进箱子后长一个样 - 本地 Loader:TextLoader、PyPDFLoad…

约 53 分钟21,127 字28 个代码示例

本章你将学会

  • Document 对象:LangChain 的「统一集装箱」——不管数据来自 txt、PDF、CSV 还是网页,装进箱子后长一个样
  • 本地 Loader:TextLoader、PyPDFLoader、CSVLoader、DirectoryLoader 各管一摊,以及 UnstructuredMarkdownLoader 的适用思路
  • Web Loader:WebBaseLoader 抓网页、用 bs4 的 SoupStrainer 只取正文;网络依赖有哪些坑
  • 文本切割:RecursiveCharacterTextSplitter 的 chunk_size / chunk_overlap / separators 三大旋钮,中文分隔符怎么配,按字符切 vs 按 token 切
  • 结构化切割:MarkdownHeaderTextSplitter 按标题层级切、标题进 metadata,再和递归切割器打一套「先结构、后长度」的组合拳

上一章(05 · 输出解析器)管的是出口——模型吐出来的字符串,怎么变成代码能用的结构化数据。这一章开始管进口:拾光书屋的 AI 荐书官光靠模型的「出厂记忆」是不够的,店里的宝贝都在本地——店长手写的共读笔记(Markdown)、出版社寄来的书摘手册(PDF)、会员台账(CSV)、官网的夏夜共读活动页(网页)。要把它们变成模型随取随用的知识,第一步就是把各种格式的原始文件,变成模型认识的统一格式;第二步是把大块头切成小块,因为检索和嵌入都吃不动整本书。

这正是 RAG(检索增强生成)的前半程:加载 → 切割 → 嵌入入库。本章拿下前两步,下一章把切好的块送进向量库。

技术基线:Python + LangChain 1.x。本章新增依赖:

pip install langchain langchain-community langchain-text-splitters pypdf beautifulsoup4

说明:langchain-community 是各类 Loader 的聚集地(导入时可能打印一条「维护模式」的提示,属正常现象,文末细说);pypdf 是 PyPDFLoader 的解析引擎;beautifulsoup4 是网页 Loader 的解析引擎。


1. Document 对象:一切数据的统一集装箱

作用:码头上的货千奇百怪——冷冻金枪鱼、成衣、钢琴——但吊机不关心货是什么,它只认集装箱。LangChain 也一样:不管数据来自文本文件、PDF、表格还是网页,Loader 的最终产出都是同一种规格的 Document。后续的切割器、嵌入器、向量库只跟集装箱打交道,完全不关心货是从哪个码头来的。

一个 Document 由三部分构成:

字段 类型 含义
page_content str 正文内容,这段文档的主体
metadata dict 附加信息:来源文件、页码、行号、标题……装在箱侧的标签
id str \| None 可选的唯一编号,默认不填

签名:

from langchain_core.documents import Document

Document(page_content: str, metadata: dict | None = None, id: str | None = None)

最小示例——手动造一个集装箱看看内部:

from langchain_core.documents import Document

doc = Document(
    page_content="拾光书屋今晚八点共读《夜航船》,座位有限,先到先得。",
    metadata={"来源": "门店黑板", "日期": "7月1日"},
)
print(doc.page_content)  # 正文
print(doc.metadata)      # {'来源': '门店黑板', '日期': '7月1日'}

真实项目里很少手动创建,更多是让 Loader 批量产出 list[Document]。那这个列表里什么时候只有一项、什么时候有很多项?

  1. TextLoader 读一个 txt → 1 项(整个文件一份正文)
  2. PyPDFLoader 读一个 PDF → 每页 1 项
  3. CSVLoader 读一张表 → 每行 1 项
  4. DirectoryLoader 读一个目录 → 每个文件至少 1 项
  5. 文档被切割之后 → 每个 chunk 1 项

常见坑:

  • JS 写法的肌肉记忆要改:JS 里是 pageContent(驼峰),Python 里是 page_content(蛇形)。同理 JS 的 metadata 没变,但访问 doc.pageContent 在 Python 里只会拿到 AttributeError。
  • metadata 里有什么,完全取决于谁给你装的箱:TextLoader 会放 source(文件路径),PyPDFLoader 会多放 page(页码)。别假设字段一定存在,取值前先确认(或用 doc.metadata.get(...))。
  • Document 是普通的数据类,可以随时改:doc.metadata["租期"] = "永久" 这样追加字段完全合法,后面讲切割时会大量利用这一点。

2. 本地 Loader:把硬盘上的文件装进集装箱

本地 Loader 主要住在 langchain_community.document_loaders 里,导入方式统一:

from langchain_community.document_loaders import TextLoader, PyPDFLoader, CSVLoader, DirectoryLoader

2.1 TextLoader:一个 txt,一张单

作用:最朴素的 Loader——把整个文本文件原样读进一个 Document 的 page_content。不解析任何语法,Markdown 读进来也只是带 # 号的纯文本。

签名:

TextLoader(file_path: str | Path, encoding: str | None = None, autodetect_encoding: bool = False)

最小示例:

from langchain_community.document_loaders import TextLoader

loader = TextLoader("data/story.txt", encoding="utf-8")
docs = loader.load()          # 返回 list[Document],同步方法,没有 await

print(len(docs))              # 1 —— 一个文件就是一份正文
print(docs[0].metadata)       # {'source': 'data/story.txt'}

常见坑:

  • 中文编码是头号坑。TextLoader 默认按 UTF-8 读;如果文件是 Windows 记事本存的 GBK/GB2312,.load() 会直接抛 RuntimeError(里面裹着一个 UnicodeDecodeError)。两种解法:明确指定 TextLoader(path, encoding="gbk"),或者 autodetect_encoding=True 让它自己猜(猜错率不低,能指定就指定)。
  • JS 的 await loader.load() 在 Python 里是同步的 loader.load()——文件就在本地硬盘上,读完即返,没有异步那回事。
  • 路径既可以是字符串也可以是 pathlib.Path,但 metadata 里的 source 存的是你传进去的样子:传相对路径它就记相对路径。

2.2 PyPDFLoader:一页一张单

作用:读取 PDF 并抽取文字。PDF 是知识密集场景最常见的格式——拾光书屋每本书的「编辑导读」都是出版社发的 PDF。它的默认行为是每一页拆成一个独立的 Document,页码写进 metadata,方便后续检索时标注「出自第几页」。

签名(节选关键参数):

PyPDFLoader(
    file_path: str | PurePath,
    password: str | bytes | None = None,   # 加密 PDF 的密码
    mode: Literal["single", "page"] = "page",  # "page" 每页一个 Document;"single" 整本合一
    extract_images: bool = False,          # 是否尝试提取图片里的文字
)

最小示例:

from langchain_community.document_loaders import PyPDFLoader

loader = PyPDFLoader("data/novel.pdf")   # 换成你自己的 PDF 文件
docs = loader.load()

print(len(docs))                  # 页数
print(docs[0].metadata["page"])   # 0 —— 注意页码从 0 开始计
print(docs[0].metadata["total_pages"])  # 总页数

# 整本合成一个 Document:
loader = PyPDFLoader("data/novel.pdf", mode="single")
docs = loader.load()              # 只有 1 个,正文里页与页之间用 \n\x0c 分隔

常见坑:

  • JS 版的 splitPages: false,Python 版叫 mode="single"——同一个开关,两个名字,迁移时最容易踩的点之一。
  • page 从 0 开始计。跟用户展示「第 3 页」时记得 +1,别把第 0 页说出去。
  • 扫描版 PDF 等于图片:整页是扫描图像、没有文字层的话,PyPDFLoader 只能提出空字符串。要 OCR 才有救(extract_images 配合多模态解析,属于进阶话题)。
  • 页数多的大 PDF,load() 会一次性读入内存;对几百兆的扫描合集考虑 loader.lazy_load() 逐页迭代。

2.3 CSVLoader:一行一张单

作用:把 CSV 表格转成 Document。逻辑是一行记录 = 一个 Document,表头变成「字段名: 值」的键值对文本。拾光书屋的藏书台账是 CSV,加载后每本书都能单独被检索到。

签名(节选关键参数):

CSVLoader(
    file_path: str | Path,
    source_column: str | None = None,       # 用哪一列的值顶替 metadata 里的 source
    content_columns: Sequence[str] = (),    # 只把哪些列拼进正文(默认全部)
    metadata_columns: Sequence[str] = (),   # 额外把哪些列写进 metadata
    encoding: str | None = None,
)

最小示例:

from langchain_community.document_loaders import CSVLoader

# books.csv 内容:
# 书名,作者,评分,标签
# 夜航船,张岱,4.8,古代百科
loader = CSVLoader("data/books.csv", encoding="utf-8")
docs = loader.load()   # 5 行数据 → 5 个 Document

print(docs[0].page_content)
# 书名: 夜航船
# 作者: 张岱
# 评分: 4.8
# 标签: 古代百科
print(docs[0].metadata)  # {'source': 'data/books.csv', 'row': 0}

常见坑:

  • 正文不是原始 CSV 行,而是「字段名: 值」的换行文本。这个格式对嵌入很友好(语义完整),但如果你想要回原始行,得自己拼。
  • row 也是从 0 开始,且表头行不算——第一行数据就是 row: 0。
  • 用 source_column="书名" 可以让 metadata 的 source 变成《夜航船》这种有业务含义的值,比一串文件路径更适合展示「命中了哪本书」。
  • CSV 同样有编码坑(Excel 导出的中文 CSV 常是 GBK),解法同 TextLoader。

2.4 DirectoryLoader:整仓扫货

作用:知识库通常不是一个文件,而是一整个目录——notes/ 下混着 .txt、.md、.pdf。DirectoryLoader 负责按模式批量扫货,对命中的每个文件调用你指定的 Loader。

这里有一个 JS → Python 的大差异,必须重点说:JS 版的 DirectoryLoader 接收一个「后缀名 → Loader 工厂」的映射对象,一个实例通吃所有格式;Python 版没有这个映射机制,它是「一个 loader_cls + 一个 glob 模式」——每次只认一种 Loader。要加载多种格式,就多跑几趟再合并结果。

签名(节选关键参数):

DirectoryLoader(
    path: str,
    glob: str = "**/[!.]*",          # 文件匹配模式,** 表示递归子目录
    loader_cls: type = UnstructuredFileLoader,  # 注意默认值!
    loader_kwargs: dict | None = None,  # 传给 loader_cls 构造器的参数
    silent_errors: bool = False,     # 单个文件加载失败时是跳过还是抛错
    show_progress: bool = False,     # 显示进度条(需要 tqdm)
)

最小示例:

from langchain_community.document_loaders import DirectoryLoader, TextLoader

# 第一趟:收所有 .txt(** 递归子目录)
txt_docs = DirectoryLoader(
    "data", glob="**/*.txt",
    loader_cls=TextLoader, loader_kwargs={"encoding": "utf-8"},
).load()

# 第二趟:收所有 .md(Markdown 也先当纯文本读入)
md_docs = DirectoryLoader(
    "data", glob="**/*.md",
    loader_cls=TextLoader, loader_kwargs={"encoding": "utf-8"},
).load()

all_docs = txt_docs + md_docs

常见坑:

  • 默认 loader_cls 是 UnstructuredFileLoader。不显式传 loader_cls=TextLoader,又没安装 unstructured 这个大依赖,加载时直接报错。记住:Python 版必须每次明说用哪个 Loader。
  • glob 决定递归与否:**/*.txt 连子目录一起扫;*.txt 只看当前层。默认值 **/[!.]* 会递归扫所有非隐藏文件,一般要替换成你自己的模式。
  • 目录里混着一个坏文件(编码错、被锁住)会让整趟扫描翻车;批量导入知识库时建议开 silent_errors=True,事后再核对数量。
  • 适合「多类型文件批量导入」「大型知识库统一入库」;单个小文档老老实实用对应的单个 Loader,配置成本更低。

2.5 UnstructuredMarkdownLoader 的思路

作用:UnstructuredMarkdownLoader 属于 unstructured 生态——这个生态用一套统一的解析引擎,把 Markdown、Word、PPT、HTML 等各种格式都拆成结构化元素(标题、段落、列表、表格……),每个元素可以变成一个 Document,metadata 里记录元素类型(category)等信息。

# 思路示意(运行前需安装:pip install "unstructured[md]")
from langchain_community.document_loaders import UnstructuredMarkdownLoader

docs = UnstructuredMarkdownLoader("data/notes.md", mode="elements").load()
# mode="elements":按结构元素拆分,标题、段落各成一个 Document
# mode="single":整篇合成一个 Document(默认)

什么时候选它:

  • 优点:解析能力强,元素类型进了 metadata,适合处理格式混杂、来源复杂的企业文档
  • 代价:unstructured 依赖树庞大(拖家带口一堆系统库),装一次够喝一壶

轻量替代(本章 demo 采用的路线):如果只是想「按 Markdown 的标题结构切分、标题进 metadata」,完全不必请出 unstructured——TextLoader 原样读入 + 5.1 节的 MarkdownHeaderTextSplitter 结构化切割,零额外依赖就能拿到几乎等价的效果。轻量手册的选型原则:能用小工具解决,就不请重型机械进场。


3. Web Loader:把网页装进集装箱

3.1 WebBaseLoader:抓网页、提正文

作用:给一个 URL,抓回 HTML,解析出纯文本,装进 Document。JS 版这一讲的主角 CheerioWebBaseLoader 底层用 cheerio(jQuery 风格的 HTML 解析器);Python 版的 WebBaseLoader 底层用 requests 发请求 + BeautifulSoup(bs4)解析 HTML——两者角色对等,CSS 选择器语法也相通,迁移成本主要在「包名和 API 位置」。

签名(节选关键参数):

WebBaseLoader(
    web_path: str | Sequence[str],      # 一个或多个 URL
    bs_kwargs: dict | None = None,      # 传给 BeautifulSoup 构造器的参数(如 parse_only)
    bs_get_text_kwargs: dict | None = None,  # 传给 soup.get_text() 的参数(如 separator)
    requests_per_second: int = 2,       # 多 URL 时的限速
    verify_ssl: bool = True,
)

最小示例——抓取拾光书屋官网的夏夜共读活动页(URL 换成你自己的目标页):

from langchain_community.document_loaders import WebBaseLoader

loader = WebBaseLoader(
    "https://www.shiguang-bookstore.example/events",
    bs_get_text_kwargs={"separator": "\n", "strip": True},  # 防文字粘连,见下方坑
)
docs = loader.load()

print(docs[0].metadata)
# {'source': 'https://...', 'title': '拾光书屋 · 夏夜共读计划',
#  'description': '……', 'language': 'zh-CN'}

metadata 是白送的:WebBaseLoader 会顺手从 HTML 里提取 title、meta description、lang 写进标签——后续检索展示「命中页面的标题」时直接可用。

只取正文(JS selector 参数的 Python 对应物):JS 版有 selector: "h2" 直接筛元素;Python 版没有这个直接参数,标准姿势是把 bs4.SoupStrainer 塞进 bs_kwargs,让 BeautifulSoup 只解析你关心的那部分 DOM:

from bs4 import SoupStrainer
from langchain_community.document_loaders import WebBaseLoader

# 只解析 <article> 标签里的内容,导航栏、页脚全部被挡在门外
loader = WebBaseLoader(
    "https://www.shiguang-bookstore.example/events",
    bs_kwargs={"parse_only": SoupStrainer("article")},
    bs_get_text_kwargs={"separator": "\n", "strip": True},
)
docs = loader.load()   # page_content 里只剩活动正文

更复杂的筛选(比如按 class 取)用 SoupStrainer(class_="post-content");要拿元素列表做后处理,也可以直接 loader.scrape() 拿到 BeautifulSoup 对象自己 .select("h2")——和 cheerio 的 $ 选择器玩法一一对应。

常见坑:

  • 网络依赖是天然的不稳定源:断网、超时、对方 502,都会让 load() 直接抛异常。生产环境记得包一层重试/兜底(04 章的 with_retry / with_fallbacks 在这里正好能用上)。这也是本章 demo 不演示网页抓取的原因——课堂环境网络不可控。
  • get_text() 默认没有分隔符:不同标签的文字会被直接拼在一起——<h2> 里的「夏夜共读计划」和 <p> 里的「晚八点开始」会粘成「夏夜共读计划晚八点开始」,中文还算能忍,英文单词粘连最伤(bookstore 和 keeping 撞成 bookstorekeeping)。务必传 bs_get_text_kwargs={"separator": "\n", "strip": True}。
  • 用了 parse_only 之后,metadata 会缩水:BeautifulSoup 只解析了 <article>,页面里的 <title> 自然找不到了,metadata 只剩 source。要标题就别筛太狠,或自己再抓一次。
  • 动态渲染的页面抓不到:WebBaseLoader 拿到的是服务端返回的原始 HTML。内容靠 JavaScript 执行后才出现的 SPA 页面(大量前端框架站点),抓回来是空壳。这种得换 playwright 之类的无头浏览器方案。
  • 反爬与礼仪:一些站点会拦截无 UA 的请求或频次过高的访问;批量抓取遵守 requests_per_second 限速,别把人家服务器当自家书架翻。

3.2 更多抓取思路:代码仓库与搜索结果

顺着「给模型喂外部知识」这个需求,还有两类常见数据源,思路了解即可:

代码仓库:想让模型理解一个项目的结构和实现(回答「这个函数怎么用」「报错在源码哪里」),可以把整个仓库抓进来。langchain-community 提供了 GitHub 相关 Loader,套路是:指定仓库和分支 → 按 ignorePaths 之类的参数过滤 → 每个代码文件变成一个 Document。抓之前想清楚规模——整仓递归可能拖回几千个文件,先从小目录试起。

搜索 API:模型要回答「今天的科技新闻」这类实时问题时,本地知识库无能为力,得先搜索再抓取。LangChain 里有对接 SearchApi、SerpAPI 等搜索服务的 Loader,套路是两步走:

  1. SearchApiLoader(query=..., api_key=...) 拿到搜索结果——每个结果一个 Document,metadata 里标注结果类型(百科、普通网页、新闻、视频……),按需过滤
  2. 取出结果里的链接,再用 WebBaseLoader 抓正文——和 3.1 节完全一样

常见坑:

  • 搜索类 Loader 都要 API key,注册后有每月免费额度。key 千万别写死在代码里(课件示例里明文写 key 是反面教材),用环境变量 os.environ["SEARCH_API_KEY"] 读取。
  • 第一步拿到的只是「搜索结果摘要」,不是正文;急着把摘要喂给模型,答案会又浅又糙——两步走的第二步不能省。

4. 文本切割:把整头牛切成牛排

4.1 为什么必须切

回忆 RAG 的流程:用户提问 → 从知识库检索相关内容 → 相关内容和问题一起交给模型。如果文档不切割:

  1. 检索粒度太粗:只能整篇为单位去匹配,一篇 2 万字的笔记和一句提问的相似度会被大量无关文字稀释,检索根本瞄不准
  2. 超出模型的 token 限制:长文一次性塞进上下文窗口,要么报错,要么贵到肉疼

所以要把大块头切成大小合适的「牛排」。但切割不能乱剁——把一句话拦腰斩断、把一个定义劈成两半,检索到哪半都不完整。理想的切法像老屠夫:先沿着关节(段落)下刀,块太大再沿肌理(句子)切,实在不行才动用蛮力。这就是 RecursiveCharacterTextSplitter 的「递归」二字的含义。

4.2 两个核心旋钮:chunk_size 与 chunk_overlap

作用:RecursiveCharacterTextSplitter 是最通用的切割器,普通文本(小说、说明、笔记)闭眼选它。

签名:

RecursiveCharacterTextSplitter(
    separators: list[str] | None = None,   # 分隔符层级,默认 ["\n\n", "\n", " ", ""]
    keep_separator: bool | Literal["start", "end"] = True,
    chunk_size: int = 4000,                # 每块的最大长度(单位见 4.5 节)
    chunk_overlap: int = 200,              # 相邻块之间的重叠长度
    length_function: Callable[[str], int] = len,
    add_start_index: bool = False,         # 把每块在原文的起始位置写进 metadata
    is_separator_regex: bool = False,      # separators 是否按正则解释
)
  • chunk_size:每块的最大长度。切糕的模具尺寸。
  • chunk_overlap:相邻两块之间重叠的部分。撕书时每页多留几行「接缝」——关键句正好骑在切割线上时,两块各执一半谁都说不清;留了接缝,至少有一块能拿着完整的前半句,另一块拿着完整的后半句。

最小示例——同一个文本,接缝有无的对比:

from langchain_text_splitters import RecursiveCharacterTextSplitter

text = "夜里灯还亮着。风翻动了书页。少年在找一句话。店长递上热可可。雨点敲着玻璃。灯影落进书里。"

def cut(overlap: int) -> list[str]:
    splitter = RecursiveCharacterTextSplitter(
        chunk_size=20,
        chunk_overlap=overlap,
        separators=["\n\n", "\n", "。", ",", " ", ""],
        keep_separator="end",
    )
    return splitter.split_text(text)

print(cut(0))
# ['夜里灯还亮着。风翻动了书页。', '少年在找一句话。店长递上热可可。', '雨点敲着玻璃。灯影落进书里。']

print(cut(9))
# ['夜里灯还亮着。风翻动了书页。',
#  '风翻动了书页。少年在找一句话。',     ← 开头重复了上一块的最后一句
#  '少年在找一句话。店长递上热可可。',
#  '店长递上热可可。雨点敲着玻璃。',
#  '雨点敲着玻璃。灯影落进书里。']

起步推荐值:chunk_size=1000, chunk_overlap=200,再用官方可视化工具(搜索 chunkviz)对着自己的真实文档调参。找到检索效果好的那组值之前,别迷信任何「最优参数」。

常见坑:

  • chunk_overlap 必须小于 chunk_size,否则构造时直接 ValueError。
  • 接缝的携带单位是「完整的分隔单元」,不是任意字符数。看上面 overlap=9 的例子:接缝恰好是一句 9 字的话。如果某句话本身就有 30 字,而 chunk_overlap=20,这句话没法掰开只带一半——它整个带不过去,接缝在这里就断了。所以中文长句场景,overlap 别设太小(句子平均长度以下基本无效)。
  • 切出来的块可能小于 chunk_size(沿分隔符切,尽量凑满但不硬凑),也可能偶尔超过(一个不可分割的超长单元,比如一整行没有标点的文本)——chunk_size 是「目标上限」,不是硬保证。

4.3 separators:分隔符的层级

作用:递归切割器的「递归」靠它实现。separators 是一张从粗到细的切割清单,切割器拿着文本从第一项开始试:

  1. 先用 "\n\n"(空行,即段落边界)切——切出的块 ≤ chunk_size?收工
  2. 还有超标的块?对超标的块用 "\n"(换行)再切
  3. 还超标?继续往下用更细的分隔符,直到最后的 ""——纯按长度硬切,保底不丢内容

默认值是 ["\n\n", "\n", " ", ""],这是为英文设计的:英文靠空格分词," " 这一级很好用。但中文没有空格——一句话几十个字中间一个空格都没有,默认清单在中文上会一路退到 "" 硬切,结果就是句子被拦腰斩断、句号孤零零漂在块外:

from langchain_text_splitters import RecursiveCharacterTextSplitter

text = "第一句讲夜读。第二句讲晨读。第三句讲午读。"

# 默认分隔符:退到 "" 硬切
RecursiveCharacterTextSplitter(chunk_size=10, chunk_overlap=0).split_text(text)
# ['第一句讲夜读。第二句', '讲晨读。第三句讲午读', '。']   ← 句子被斩断,句号成孤块

# 中文分隔符:句号优先,其次逗号
RecursiveCharacterTextSplitter(
    chunk_size=10, chunk_overlap=0,
    separators=["\n\n", "\n", "。", ",", " ", ""],
    keep_separator="end",   # 句号归入前一句的尾部,切出完整句
).split_text(text)
# ['第一句讲夜读。', '第二句讲晨读。', '第三句讲午读。']   ← 每块都是完整句

中文场景的推荐配置:

separators=["\n\n", "\n", "。", ";", ",", " ", ""]

段落 → 换行 → 句号 → 分号 → 逗号 → 空格 → 硬切,从粗到细。

常见坑:

  • keep_separator 的三个状态:False 丢弃分隔符(块里没有句号);True / "start" 分隔符归下一块的开头(。第二句…… 开头一个句号很丑);"end" 归上一块的结尾(……第二句。——中文最自然)。切中文记得配 keep_separator="end"。
  • 分隔符只在「块超标」时才逐级降级:一小段 50 字的文本配 chunk_size=1000,第一刀 "\n\n" 切完就收工,后面的分隔符根本轮不上出场。
  • 想用正则当分隔符(比如同时匹配中英文句号 "[。.]"),把 is_separator_regex=True 打开,否则特殊字符会被当普通文本找。

4.4 三个入口:split_text / create_documents / split_documents

作用:切割器有三个长得很像的方法,区别只在「喂什么、吐什么」:

方法 输入 输出 metadata
split_text(text: str) 纯字符串 list[str] 无(字符串没有 metadata)
create_documents(texts, metadatas=None) 字符串列表(可附 metadata) list[Document] 你传入的
split_documents(docs) list[Document] list[Document] 原样继承,还可追加

最小示例:

from langchain_text_splitters import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=80, chunk_overlap=0,
    separators=["\n\n", "\n", "。", ",", " ", ""],
    keep_separator="end",
    add_start_index=True,   # 每块在原文的起始位置写进 metadata
)

# 入口一:手里只有一段字符串(比如接口返回的文本)
chunks = splitter.split_text("很长的正文……")

# 入口二:字符串 + 手工 metadata
docs = splitter.create_documents(
    ["很长的正文……"],
    metadatas=[{"册子": "打烊之后"}],   # 与 texts 一一对应
)

# 入口三:Loader 产出的 Document 直接切(最常用)
# loader_docs = TextLoader("data/story.txt", encoding="utf-8").load()
# docs = splitter.split_documents(loader_docs)
# docs[0].metadata  →  {'source': 'data/story.txt', 'start_index': 0}

metadata 的保留与追加是这三个入口的分水岭:

  • split_documents 把原 Document 的 metadata 原样带给每个切块——文件切成 8 块,8 块都带着 source
  • add_start_index=True 会追加一个 start_index 字段——块在原文的起始字符位置,方便回溯定位原文
  • 后续环节(嵌入、入库、检索)都在 metadata 里做文章:先按 source 过滤、再按 start_index 排序,就能把切块还原成原文

常见坑:

  • JS 版对应方法是 createDocuments(texts, metadatas)(驼峰),Python 是 create_documents(蛇形)——同类迁移的通用规律。
  • create_documents 的 metadatas 是按序对应的外层列表(每个 text 一个 dict),不是「给所有块统一打的标签」。想给所有块打同一个标签,切完自己循环加。
  • split_text 返回的是裸字符串,丢掉了 metadata——只要后面还要走 RAG 流程,就别贪这一步的省事。

4.5 按字符切 vs 按 token 切

作用:chunk_size 的「长度」用什么尺子量?默认用 len——按字符数。但模型的上下文窗口按 token 计费计量,同样的 chunk_size=1000:

  • 中文字符:约 1 字 ≈ 1 token,字符尺子和 token 尺子差不多
  • 英文字符:约 4 字符 ≈ 1 token,字符尺子量出的 1000 字符其实只占约 250 token——块比想象中「小」,token 预算浪费了

token 预算敏感的场景(按 token 计费的嵌入 API、上下文窗口紧张),要换把尺子量。

换尺子的两种方式:

# 方式一:换 length_function——尺子随便定义(本节 demo 用它做离线演示)
splitter = RecursiveCharacterTextSplitter(
    chunk_size=20, chunk_overlap=0,
    length_function=my_token_counter,   # 任何 Callable[[str], int]
)

# 方式二:from_tiktoken_encoder——用 OpenAI 系的 tiktoken 分词器精确计数
# 前提:pip install tiktoken;注意首次使用会联网下载编码表
splitter = RecursiveCharacterTextSplitter.from_tiktoken_encoder(
    encoding_name="cl100k_base",   # 或 model_name="gpt-4o-mini" 自动选对应编码
    chunk_size=512, chunk_overlap=64,
)

实测对比(demo 里的中英混排文本,chunk_size=20 不变,只换尺子):

按字符(len)       → 6 块,每块字符数 [18, 16, 13, 8, 19, 4]
按粗略 token 计数  → 3 块,每块 token 数 [15, 19, 4]

常见坑:

  • tiktoken 是「联网刺客」:首次调用要从网络下载编码表,离线环境直接挂。内网部署的项目要么提前缓存,要么用自定义 length_function 兜底。
  • tiktoken 的 token 数是 OpenAI 系模型的口径;换其他模型(本地 Ollama 跑 qwen),tokenizer 不同,token 数会有偏差。预算卡得很死时,留 10–20% 余量。
  • 还有更硬的 TokenTextSplitter:纯按 token 数切,完全不考虑句子边界——可能出现一句话被切成两半、一半在上一块的情况。检索质量优先就用递归切割器,token 精度优先才用它。

4.6 代码切割:from_language

作用:代码有语法结构,按普通文本切很容易把一个函数从中间切开——前半块有 def 没有函数体,后半块凭空多出缩进,模型看了直摇头。from_language 给切割器换一套为编程语言定制的分隔符层级:优先在函数/类定义边界下刀,其次按语句结束符,再次按换行、空格兜底。

签名:

RecursiveCharacterTextSplitter.from_language(language: Language, **kwargs)

最小示例:

from langchain_text_splitters import Language, RecursiveCharacterTextSplitter

# JS 版:RecursiveCharacterTextSplitter.fromLanguage("js", {...})
# Python 版:推荐传 Language 枚举(小写字符串也能识别,见下方坑)
splitter = RecursiveCharacterTextSplitter.from_language(
    Language.PYTHON,
    chunk_size=320, chunk_overlap=0,
)

code_docs = splitter.split_text(source_code_text)

# 看看都支持哪些语言:
print([lang for lang in Language])  # CPP、GO、JAVA、JS、PYTHON、RUST、TS、MARKDOWN……

Python 版为 Python 定制的层级大致是:先按类/函数定义切,再按语句切,再按缩进块、换行兜底——尽量保住语法单元的完整性。

常见坑:

  • JS 传字符串 "js",Python 传枚举 Language.JS——Language 是字符串枚举,传 "js" 这类小写字符串也能跑(实测行为一致),但枚举有代码补全和拼写保护,手滑写个 "JAVASCRIPT" 不会静默给出错误分隔符表。
  • from_language 只是换了分隔符清单,chunk_size / chunk_overlap 等参数照旧要自己传;它不保证每个块都是「完整可运行的语法单元」,只是尽力沿着结构边界下刀。
  • Markdown 也在 Language 枚举里(Language.MARKDOWN),但按标题结构化切割有更专业的 5.1 节工具,别用错了场合。

5. Markdown 结构化切割:MarkdownHeaderTextSplitter

5.1 按标题切,标题进 metadata

作用:拾光书屋的共读笔记是标准 Markdown:# 一级标题(笔记册名)、## 二级(每本书)、### 三级(金句摘录/讨论记录)。这种文档天然自带章节骨架,按标题切割比按长度切割语义完整得多——而且标题路径会写进每个块的 metadata。检索命中某个块时,顺着 metadata 就知道它来自「拾光书屋共读笔记 → 《山月记》共读夜 → 金句摘录」,展示和过滤都有了抓手。

签名:

MarkdownHeaderTextSplitter(
    headers_to_split_on: list[tuple[str, str]],  # [(标题符号, metadata 字段名), ...]
    strip_headers: bool = True,   # 标题本身是否从正文里剔除(默认剔除)
)

最小示例:

from langchain_text_splitters import MarkdownHeaderTextSplitter

md_text = """# 拾光书屋共读笔记

馆主的话。

## 夜读指南

关灯前读诗。

## 晨读指南

早起读散文。
"""

splitter = MarkdownHeaderTextSplitter(
    headers_to_split_on=[("#", "h1"), ("##", "h2")],
    strip_headers=True,
)
docs = splitter.split_text(md_text)   # 注意:吃字符串,吐 list[Document]

for doc in docs:
    print(doc.metadata, "←", doc.page_content)
# {'h1': '拾光书屋共读笔记'}                          ← 馆主的话。
# {'h1': '拾光书屋共读笔记', 'h2': '夜读指南'}          ← 关灯前读诗。
# {'h1': '拾光书屋共读笔记', 'h2': '晨读指南'}          ← 早起读散文。

常见坑:

  • headers_to_split_on 是元组列表:("#", "h1") 的意思是「# 开头的标题,值存进 metadata 的 h1 字段」。字段名随便起("chapter"、"section" 都行),但一旦定了就是检索时的过滤键,团队要统一。
  • metadata 是「路径式」累积的:三级标题的块会同时带 h1、h2、h3 三个字段——它的祖先标题都在。做 metadata 过滤时这个特性非常好用。
  • 它没有 chunk_size 参数——只按标题切,不管块多大。标题下动辄几千字的「巨型小节」它会整块吐出来。块太大怎么办?看 5.2。
  • JS 版教程里用的 MarkdownTextSplitter(按 Markdown 语法加分隔符的普通切割)Python 里也有,但更推荐 MarkdownHeaderTextSplitter——前者只是切得 smarter 一点,后者把标题变成了可检索的 metadata,是质的提升。
  • split_text 返回的 Document 没有经过 Loader,不带 source;源头信息要自己补,或者走 5.2 的组合拳让 Loader 的 metadata 流转下来。

5.2 组合拳:先按结构粗切,再按长度细切

作用:两把刀各管一段——MarkdownHeaderTextSplitter 管结构(在哪切:标题边界),RecursiveCharacterTextSplitter 管大小(切多大:chunk_size)。先用结构刀把文档切成语义完整的小节,再用长度刀把超标的小节进一步细分。关键是:第二刀用 split_documents 切,第一刀产出的标题 metadata 会原样流转到每个细块上——这就是 4.4 节「metadata 保留」特性的高光时刻。

最小示例:

from langchain_community.document_loaders import TextLoader
from langchain_text_splitters import (
    MarkdownHeaderTextSplitter,
    RecursiveCharacterTextSplitter,
)

# 第 0 步:Loader 读入(此时 metadata 只有 source)
docs = TextLoader("data/notes.md", encoding="utf-8").load()

# 第一刀:按标题结构切(标题路径进 metadata)
header_splitter = MarkdownHeaderTextSplitter(
    headers_to_split_on=[("#", "h1"), ("##", "h2"), ("###", "h3")],
)
sections = header_splitter.split_text(docs[0].page_content)

# 第二刀:按长度细切(标题 metadata 自动继承,start_index 是追加的)
size_splitter = RecursiveCharacterTextSplitter(
    chunk_size=60, chunk_overlap=10,
    separators=["\n\n", "\n", "。", ",", " ", ""],
    keep_separator="end",
    add_start_index=True,
)
final_docs = size_splitter.split_documents(sections)

# 同一小节被切成两块时,标题路径完全相同,只有 start_index 不同:
# {'h1': '拾光书屋共读笔记', 'h2': '《夜航船》共读夜', 'h3': '金句摘录', 'start_index': 0}
# {'h1': '拾光书屋共读笔记', 'h2': '《夜航船》共读夜', 'h3': '金句摘录', 'start_index': 40}

这套「结构刀 + 长度刀」的思路可以推广:PDF 可以先按页切(Loader 已经做了)、再按长度细切;HTML 可以先按标题结构切、再细切。先粗后细、metadata 层层流转,是文本切割的通用心法。

常见坑:

  • 第二刀必须用 split_documents(喂 Document),用 split_text(喂字符串)会把第一刀攒下的标题 metadata 全部丢掉。
  • 顺序不能反:先按长度切再按标题切没有意义——长度刀不认识标题,切完的碎块里标题文本和正文混在一起,结构信息已经没了。
  • 每个小节独立计量 start_index(块在自己小节里的位置),不是在全文里的位置。要全局定位,给每个小节手动追加一个「小节序号」字段。

6. 本章小结

一张表带走本章所有工具:

你的需求 用什么
统一各种数据的格式 Document(page_content + metadata)
读 txt TextLoader(中文记得 encoding="utf-8")
读 PDF,每页一块 PyPDFLoader(整本合一用 mode="single")
读 CSV,每行一块 CSVLoader(source_column 让 source 有业务含义)
批量扫一个目录 DirectoryLoader(glob + loader_cls,一种格式一趟)
抓静态网页 WebBaseLoader(SoupStrainer 只取正文)
通用文本切割 RecursiveCharacterTextSplitter
中文切得体面 separators 加 。;, + keep_separator="end"
块之间留接缝 chunk_overlap(单位是完整分隔单元,别小于句子平均长度)
按 token 预算切 from_tiktoken_encoder / 自定义 length_function
切代码 from_language(Language.PYTHON, ...)
Markdown 按标题切 MarkdownHeaderTextSplitter(标题路径进 metadata)
结构 + 大小都要管 先 MarkdownHeaderTextSplitter 后 RecursiveCharacterTextSplitter.split_documents

配套 demo(都在本章 demo/ 目录):

  • demo_loader.py:手动创建 Document、TextLoader / CSVLoader / DirectoryLoader 逐个上手、notes.md 按标题结构化切割——不需要模型、不需要网络,直接跑。
  • demo_splitter.py:chunk_overlap 接缝对比、默认分隔符 vs 中文分隔符、字符尺子 vs token 尺子、三个切割入口、Markdown 组合拳——同样离线可跑。
  • demo/data/:story.txt(自创短文)、notes.md(共读笔记)、books.csv(藏书台账),都是演示用的小文件。

关于那条 DeprecationWarning:导入 langchain_community 时会看到「sunset / 迁移到独立集成包」的提示。这是官方在把 Loader 们逐步拆到各自独立的包里(如 langchain-unstructured)。目前 langchain-community 里的这些经典 Loader 依然可用,教学和原型开发照常写;等某个 Loader 有了独立包,再按提示逐个迁移即可——写的代码不用变思路,只换 import 来源。

到这里,RAG 前半程的「加载 → 切割」已经打通:任何格式的知识都能变成带 metadata 的小块。下一章给这些小块「办入住」——把它们变成向量、存进向量库。下一章:07 · 嵌入与向量库