文档加载与切割
本章你将学会 - Document 对象:LangChain 的「统一集装箱」——不管数据来自 txt、PDF、CSV 还是网页,装进箱子后长一个样 - 本地 Loader:TextLoader、PyPDFLoad…
本章你将学会
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]。那这个列表里什么时候只有一项、什么时候有很多项?
TextLoader读一个 txt → 1 项(整个文件一份正文)PyPDFLoader读一个 PDF → 每页 1 项CSVLoader读一张表 → 每行 1 项DirectoryLoader读一个目录 → 每个文件至少 1 项- 文档被切割之后 → 每个 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,套路是两步走:
SearchApiLoader(query=..., api_key=...)拿到搜索结果——每个结果一个Document,metadata 里标注结果类型(百科、普通网页、新闻、视频……),按需过滤- 取出结果里的链接,再用
WebBaseLoader抓正文——和 3.1 节完全一样
常见坑:
- 搜索类 Loader 都要 API key,注册后有每月免费额度。key 千万别写死在代码里(课件示例里明文写 key 是反面教材),用环境变量
os.environ["SEARCH_API_KEY"]读取。 - 第一步拿到的只是「搜索结果摘要」,不是正文;急着把摘要喂给模型,答案会又浅又糙——两步走的第二步不能省。
4. 文本切割:把整头牛切成牛排
4.1 为什么必须切
回忆 RAG 的流程:用户提问 → 从知识库检索相关内容 → 相关内容和问题一起交给模型。如果文档不切割:
- 检索粒度太粗:只能整篇为单位去匹配,一篇 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 是一张从粗到细的切割清单,切割器拿着文本从第一项开始试:
- 先用
"\n\n"(空行,即段落边界)切——切出的块 ≤chunk_size?收工 - 还有超标的块?对超标的块用
"\n"(换行)再切 - 还超标?继续往下用更细的分隔符,直到最后的
""——纯按长度硬切,保底不丢内容
默认值是 ["\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 块都带着sourceadd_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 · 嵌入与向量库