第9章 文档加载与文本分割
本书章节导航
- 前言
- 第1章 为什么需要理解 LangChain
- 第2章 架构总览
- 第3章 Runnable 与 LCEL 表达式语言
- 第4章 消息系统与多模态
- 第5章 语言模型抽象层
- 第6章 提示词模板引擎
- 第7章 输出解析与结构化输出
- 第8章 工具系统
- 第9章 文档加载与文本分割 (当前)
- 第10章 向量存储与检索器
- 第11章 Chain 组合模式
- 第12章 回调与可观测性
- 第13章 记忆与会话管理
- 第14章 Agent 架构与执行循环
- 第15章 工具调用与 Agent 模式
- 第16章 序列化与配置系统
- 第17章 Partner 集成架构
- 第18章 设计模式与架构决策
RAG(检索增强生成)系统的第一步是将外部知识源中的数据转换为可检索的文档片段。这个过程涉及两个关键环节:文档加载 (从各种数据源读取原始内容)和文本分割(将长文档切分为适合嵌入和检索的小块)。LangChain 为这两个环节构建了精心设计的抽象层,使得开发者可以统一地处理 PDF、网页、数据库、代码仓库等千差万别的数据源。
本章将从 Document 这个核心数据结构开始,逐步展开 BaseLoader、Blob/BlobLoader 的加载体系,深入剖析文本分割器的算法细节,特别是 RecursiveCharacterTextSplitter 的递归分割策略。
:::tip 本章要点
- 理解
Document类的设计哲学及其与消息系统的区别 - 掌握
BaseMedia->Blob/Document的继承关系 - 理解
BaseLoader的 lazy loading 接口设计 - 了解
Blob/BlobLoader/BaseBlobParser的解耦架构 - 深入理解
TextSplitter基类的_merge_splits合并算法 - 掌握
RecursiveCharacterTextSplitter的递归分割策略 - 了解
BaseDocumentTransformer和BaseDocumentCompressor的角色 :::
9.1 Document:检索流水线的核心数据结构
Document 是 LangChain 中最基础的数据结构之一,它代表一个可检索的文本单元。其设计刻意简洁:
python
# langchain_core/documents/base.py
class Document(BaseMedia):
page_content: str
"""String text."""
type: Literal["Document"] = "Document"
def __init__(self, page_content: str, **kwargs: Any) -> None:
super().__init__(page_content=page_content, **kwargs)
Document 只有两个核心字段:page_content(文本内容)和从 BaseMedia 继承的 metadata(元数据字典)。这种极简设计是有意为之的 -- 它使得 Document 可以作为整个检索流水线的通用数据载体,从文档加载到文本分割,再到向量存储和检索,所有组件都以 Document 为中心。
9.1.1 BaseMedia:基础媒体抽象
python
class BaseMedia(Serializable):
id: str | None = Field(default=None, coerce_numbers_to_str=True)
metadata: dict = Field(default_factory=dict)
BaseMedia 提供了 id 和 metadata 两个通用字段。id 是可选的文档标识符,设计上建议使用 UUID 格式。coerce_numbers_to_str=True 的设定允许传入数字类型的 ID 并自动转为字符串,提升了 API 的容错性。
9.1.2 Document 与消息的区别
源码中的注释特别强调了一点:
python
class Document(BaseMedia):
"""Class for storing a piece of text and associated metadata.
Note:
`Document` is for **retrieval workflows**, not chat I/O.
For sending text to an LLM in a conversation,
use message types from `langchain.messages`.
"""
这个区分非常重要。Document 用于数据处理流水线(加载、分割、嵌入、检索),而 BaseMessage 用于对话 I/O。虽然两者都承载文本,但它们服务于不同的架构层。
9.1.3 str 方法的考量
Document 重写了 __str__ 方法:
python
def __str__(self) -> str:
if self.metadata:
return f"page_content='{self.page_content}' metadata={self.metadata}"
return f"page_content='{self.page_content}'"
这个重写排除了 id 字段和其他可能在未来添加的字段。注释解释了原因:确保将 Document 直接嵌入 Prompt 的用户代码不会因为新增字段而中断。这是一个向后兼容性的务实考量。
9.2 Blob:原始数据的抽象
Blob 是文档加载过程中的中间表示,它代表一段原始数据 -- 可以是内存中的字节流,也可以是文件系统中的路径引用。
python
class Blob(BaseMedia):
data: bytes | str | None = None
mimetype: str | None = None
encoding: str = "utf-8"
path: PathLike | None = None
model_config = ConfigDict(
arbitrary_types_allowed=True,
frozen=True, # Blob 是不可变的
)
Blob 的 frozen=True 配置使其成为不可变对象,这是一个重要的设计选择 -- 它确保了 Blob 在流水线中传递时不会被意外修改。
9.2.1 延迟加载策略
Blob 采用延迟加载模式。当通过 from_path 创建时,它不会立即读取文件内容:
python
@classmethod
def from_path(cls, path, *, encoding="utf-8", mime_type=None, guess_type=True, metadata=None):
if mime_type is None and guess_type:
mimetype = mimetypes.guess_type(path)[0]
else:
mimetype = mime_type
# 不加载数据,只保存路径引用
return cls(data=None, mimetype=mimetype, encoding=encoding, path=path, metadata=metadata or {})
数据只在实际需要时才被读取:
python
def as_string(self) -> str:
if self.data is None and self.path:
return Path(self.path).read_text(encoding=self.encoding)
if isinstance(self.data, bytes):
return self.data.decode(self.encoding)
if isinstance(self.data, str):
return self.data
这种延迟加载模式在处理大量文件时非常重要 -- 它避免了将所有文件内容一次性加载到内存中。
9.2.2 多种数据访问方式
Blob 提供了三种数据访问接口,适应不同的使用场景:
| 方法 | 返回类型 | 适用场景 |
|---|---|---|
as_string() |
str |
文本文件处理 |
as_bytes() |
bytes |
二进制文件处理 |
as_bytes_io() |
BytesIO / BufferedReader |
流式处理、传递给第三方库 |
as_bytes_io() 是一个上下文管理器,当数据来自文件路径时,它直接返回文件句柄而不是将整个文件加载到内存:
python
@contextlib.contextmanager
def as_bytes_io(self) -> Generator[BytesIO | BufferedReader, None, None]:
if isinstance(self.data, bytes):
yield BytesIO(self.data)
elif self.data is None and self.path:
with Path(self.path).open("rb") as f:
yield f # 直接 yield 文件句柄
9.3 文档加载体系
LangChain 的文档加载体系由三个核心抽象组成:BaseLoader、BlobLoader 和 BaseBlobParser。
9.3.1 BaseLoader:统一加载接口
python
# langchain_core/document_loaders/base.py
class BaseLoader(ABC):
def load(self) -> list[Document]:
"""Load data into Document objects."""
return list(self.lazy_load())
def lazy_load(self) -> Iterator[Document]:
"""A lazy loader for Document."""
if type(self).load != BaseLoader.load:
return iter(self.load())
raise NotImplementedError(...)
async def alazy_load(self) -> AsyncIterator[Document]:
"""A lazy loader for Document."""
iterator = await run_in_executor(None, self.lazy_load)
done = object()
while True:
doc = await run_in_executor(None, next, iterator, done)
if doc is done:
break
yield doc
这段代码有几个值得注意的设计决策:
- lazy_load 优先 :
load方法的默认实现调用lazy_load并收集为列表。鼓励子类实现lazy_load而非load,以支持惰性加载 - 向后兼容 :如果子类覆盖了
load但没有覆盖lazy_load,通过检测type(self).load != BaseLoader.load来回退 - 异步桥接 :
alazy_load的默认实现将同步迭代器包装到线程池中,每次调用next都在 executor 中执行
9.3.2 load_and_split:便捷但不推荐
BaseLoader 提供了一个 load_and_split 方法,将加载和分割合并为一步操作:
python
def load_and_split(self, text_splitter: TextSplitter | None = None) -> list[Document]:
if text_splitter is None:
text_splitter_ = RecursiveCharacterTextSplitter()
else:
text_splitter_ = text_splitter
docs = self.load()
return text_splitter_.split_documents(docs)
注释中标记了 Do not override this method. It should be considered to be deprecated!。这暗示了 LangChain 更倾向于让开发者显式地分开调用加载和分割步骤,以获得更好的控制粒度。
9.3.3 BlobLoader 与 BaseBlobParser:解耦加载与解析
Blob 加载体系将文档加载分解为两个独立的步骤:
python
# langchain_core/document_loaders/blob_loaders.py
class BlobLoader(ABC):
@abstractmethod
def yield_blobs(self) -> Iterator[Blob]:
"""A lazy loader for raw data represented by Blob."""
# langchain_core/document_loaders/base.py
class BaseBlobParser(ABC):
@abstractmethod
def lazy_parse(self, blob: Blob) -> Iterator[Document]:
"""Lazy parsing interface."""
def parse(self, blob: Blob) -> list[Document]:
"""Eagerly parse the blob."""
return list(self.lazy_parse(blob))
这种解耦的好处是:
- 复用 :同一个
BlobLoader(如文件系统 loader)可以搭配不同的BaseBlobParser(如 PDF parser、CSV parser) - 组合灵活性:可以构建管道,如先过滤特定 MIME 类型的 Blob,再传递给对应的 parser
- 测试友好:可以独立测试加载和解析逻辑
9.4 文本分割器体系
文本分割是 RAG 系统中最关键也最容易被低估的环节。分割策略直接影响检索质量:块太大,嵌入向量可能丢失精确语义;块太小,又可能丢失上下文。LangChain 的文本分割器位于独立的 langchain-text-splitters 包中,以 TextSplitter 为基类构建了丰富的分割器体系。
9.4.1 TextSplitter 基类
python
# langchain_text_splitters/base.py
class TextSplitter(BaseDocumentTransformer, ABC):
def __init__(
self,
chunk_size: int = 4000,
chunk_overlap: int = 200,
length_function: Callable[[str], int] = len,
keep_separator: bool | Literal["start", "end"] = False,
add_start_index: bool = False,
strip_whitespace: bool = True,
) -> None:
if chunk_size <= 0:
raise ValueError(f"chunk_size must be > 0, got {chunk_size}")
if chunk_overlap > chunk_size:
raise ValueError("chunk_overlap must be <= chunk_size")
self._chunk_size = chunk_size
self._chunk_overlap = chunk_overlap
self._length_function = length_function
...
核心参数解析:
| 参数 | 默认值 | 含义 |
|---|---|---|
chunk_size |
4000 | 每个块的最大长度 |
chunk_overlap |
200 | 相邻块之间的重叠长度 |
length_function |
len |
计算文本长度的函数 |
keep_separator |
False |
是否保留分隔符及其位置 |
add_start_index |
False |
是否在 metadata 中记录起始位置 |
strip_whitespace |
True |
是否去除块首尾空白 |
length_function 的可替换性是一个关键设计点。默认使用 Python 的 len(按字符数计算),但在实际应用中,通常需要按 token 数计算长度(因为模型的上下文窗口以 token 为单位)。from_tiktoken_encoder 和 from_huggingface_tokenizer 类方法就是为此提供的便捷构造器。
9.4.2 核心方法链
python
@abstractmethod
def split_text(self, text: str) -> list[str]:
"""Split text into multiple components."""
def create_documents(self, texts, metadatas=None) -> list[Document]:
"""Create Document objects from texts."""
documents = []
for i, text in enumerate(texts):
index = 0
previous_chunk_len = 0
for chunk in self.split_text(text):
metadata = copy.deepcopy(metadatas_[i])
if self._add_start_index:
offset = index + previous_chunk_len - self._chunk_overlap
index = text.find(chunk, max(0, offset))
metadata["start_index"] = index
previous_chunk_len = len(chunk)
documents.append(Document(page_content=chunk, metadata=metadata))
return documents
def split_documents(self, documents: Iterable[Document]) -> list[Document]:
texts, metadatas = [], []
for doc in documents:
texts.append(doc.page_content)
metadatas.append(doc.metadata)
return self.create_documents(texts, metadatas=metadatas)
add_start_index 的实现值得仔细看:它使用 text.find(chunk, max(0, offset)) 来定位每个 chunk 在原文中的位置。offset 的计算考虑了 chunk 重叠,确保搜索起点不会跳过目标位置。
9.4.3 _merge_splits:块合并的核心算法
_merge_splits 是所有文本分割器共享的核心方法,它将初始的小段文本合并为目标大小的块:
python
def _merge_splits(self, splits: Iterable[str], separator: str) -> list[str]:
separator_len = self._length_function(separator)
docs = []
current_doc: list[str] = []
total = 0
for d in splits:
len_ = self._length_function(d)
if (total + len_ + (separator_len if len(current_doc) > 0 else 0) > self._chunk_size):
if len(current_doc) > 0:
doc = self._join_docs(current_doc, separator)
if doc is not None:
docs.append(doc)
# 回退以保持重叠
while total > self._chunk_overlap or (
total + len_ + (separator_len if len(current_doc) > 0 else 0)
> self._chunk_size and total > 0
):
total -= self._length_function(current_doc[0]) + (
separator_len if len(current_doc) > 1 else 0
)
current_doc = current_doc[1:]
current_doc.append(d)
total += len_ + (separator_len if len(current_doc) > 1 else 0)
# 处理最后一个块
doc = self._join_docs(current_doc, separator)
if doc is not None:
docs.append(doc)
return docs
这个算法的核心逻辑可以用以下图示说明:
算法的关键在于回退机制 :当需要开始一个新的 chunk 时,不是完全清空 current_doc,而是从头部逐步移除元素,直到剩余部分的长度不超过 chunk_overlap。这确保了相邻 chunk 之间有适当的重叠,避免语义断裂。
9.4.4 keep_separator 的三种模式
keep_separator 控制分隔符的处理方式:
python
def _split_text_with_regex(text, separator, *, keep_separator):
if separator:
if keep_separator:
splits_ = re.split(f"({separator})", text)
if keep_separator == "end":
# 分隔符追加到前一个块的末尾
splits = [splits_[i] + splits_[i + 1] for i in range(0, len(splits_) - 1, 2)]
else:
# 分隔符追加到后一个块的开头 (keep_separator == "start" 或 True)
splits = [splits_[i] + splits_[i + 1] for i in range(1, len(splits_), 2)]
else:
splits = re.split(separator, text)
else:
splits = list(text) # 逐字符分割
return [s for s in splits if s]
三种模式的效果:
swift
原文: "Hello\n\nWorld\n\nFoo"
分隔符: "\n\n"
keep_separator=False: ["Hello", "World", "Foo"]
keep_separator="start": ["\n\nHello", "\n\nWorld", "\n\nFoo"] # 首段前无分隔符
keep_separator="end": ["Hello\n\n", "World\n\n", "Foo"]
9.5 RecursiveCharacterTextSplitter:递归分割的艺术
RecursiveCharacterTextSplitter 是 LangChain 中最重要也最常用的文本分割器。它的核心思想是:尝试用最大粒度的分隔符分割文本,如果某个片段仍然超过 chunk_size,则递归使用更细粒度的分隔符继续分割。
9.5.1 默认分隔符层级
python
class RecursiveCharacterTextSplitter(TextSplitter):
def __init__(self, separators=None, keep_separator=True, is_separator_regex=False, **kwargs):
super().__init__(keep_separator=keep_separator, **kwargs)
self._separators = separators or ["\n\n", "\n", " ", ""]
self._is_separator_regex = is_separator_regex
默认的分隔符序列 ["\n\n", "\n", " ", ""] 代表了从粗到细的四个粒度层级:
| 层级 | 分隔符 | 含义 |
|---|---|---|
| 1 | "\n\n" |
段落边界 |
| 2 | "\n" |
行边界 |
| 3 | " " |
单词边界 |
| 4 | "" |
字符边界(逐字分割) |
9.5.2 递归分割算法
python
def _split_text(self, text: str, separators: list[str]) -> list[str]:
final_chunks = []
# 找到第一个能匹配到的分隔符
separator = separators[-1]
new_separators = []
for i, s_ in enumerate(separators):
separator_ = s_ if self._is_separator_regex else re.escape(s_)
if not s_:
separator = s_
break
if re.search(separator_, text):
separator = s_
new_separators = separators[i + 1:]
break
# 使用选定的分隔符分割
separator_ = separator if self._is_separator_regex else re.escape(separator)
splits = _split_text_with_regex(text, separator_, keep_separator=self._keep_separator)
# 合并小段,递归处理大段
good_splits = []
separator_ = "" if self._keep_separator else separator
for s in splits:
if self._length_function(s) < self._chunk_size:
good_splits.append(s)
else:
if good_splits:
merged_text = self._merge_splits(good_splits, separator_)
final_chunks.extend(merged_text)
good_splits = []
if not new_separators:
final_chunks.append(s) # 无法继续分割,直接添加
else:
# 递归:使用更细粒度的分隔符继续分割
other_info = self._split_text(s, new_separators)
final_chunks.extend(other_info)
if good_splits:
merged_text = self._merge_splits(good_splits, separator_)
final_chunks.extend(merged_text)
return final_chunks
让我们用一个具体例子来理解这个算法:
假设 chunk_size=50,chunk_overlap=10,输入文本为:
这是第一段内容。
这是第二段内容,比较长比较长比较长比较长比较长比较长比较长比较长比较长。
短段。
- 首先用
"\n\n"分割,得到三段 - 第一段和第三段长度小于 50,进入
good_splits - 第二段长度超过 50,递归用
"\n"分割 - 如果仍然超长,继续递归用
" "分割 - 最终用
" "分割后的片段通过_merge_splits合并为不超过 50 的 chunk
9.5.3 语言感知分割
RecursiveCharacterTextSplitter 提供了 from_language 类方法,为不同编程语言预定义了分隔符序列:
python
@classmethod
def from_language(cls, language: Language, **kwargs):
separators = cls.get_separators_for_language(language)
return cls(separators=separators, is_separator_regex=True, **kwargs)
以 Python 为例:
python
if language == Language.PYTHON:
return [
"\nclass ", # 类定义边界
"\ndef ", # 函数定义边界
"\n\tdef ", # 方法定义边界
"\n\n", # 段落边界
"\n", # 行边界
" ", # 单词边界
"", # 字符边界
]
这种语言感知的分隔符设计确保代码在语义边界处被分割,而不是在函数或类的中间断开。LangChain 目前支持 Python、JavaScript、TypeScript、Java、Go、Rust、C/C++、Markdown、HTML、LaTeX 等近 30 种语言。
9.6 CharacterTextSplitter:简单字符分割
与递归分割器相比,CharacterTextSplitter 使用单一分隔符进行分割:
python
class CharacterTextSplitter(TextSplitter):
def __init__(self, separator="\n\n", is_separator_regex=False, **kwargs):
super().__init__(**kwargs)
self._separator = separator
self._is_separator_regex = is_separator_regex
def split_text(self, text: str) -> list[str]:
sep_pattern = self._separator if self._is_separator_regex else re.escape(self._separator)
splits = _split_text_with_regex(text, sep_pattern, keep_separator=self._keep_separator)
# 检测零宽断言,避免重复插入分隔符
lookaround_prefixes = ("(?=", "(?<!", "(?<=", "(?!")
is_lookaround = self._is_separator_regex and any(
self._separator.startswith(p) for p in lookaround_prefixes
)
merge_sep = ""
if not (self._keep_separator or is_lookaround):
merge_sep = self._separator
return self._merge_splits(splits, merge_sep)
值得注意的是对正则零宽断言(lookaround)的特殊处理。当分隔符是零宽断言时(如 (?<=\.),在句号后分割),分隔符本身不消耗字符,因此合并时不应再次插入分隔符。
9.7 TokenTextSplitter:基于 Token 的分割
当需要精确控制每个 chunk 的 token 数量时,TokenTextSplitter 直接在 token 层面进行分割:
python
class TokenTextSplitter(TextSplitter):
def split_text(self, text: str) -> list[str]:
def _encode(_text):
return self._tokenizer.encode(_text, allowed_special=..., disallowed_special=...)
tokenizer = Tokenizer(
chunk_overlap=self._chunk_overlap,
tokens_per_chunk=self._chunk_size,
decode=self._tokenizer.decode,
encode=_encode,
)
return split_text_on_tokens(text=text, tokenizer=tokenizer)
split_text_on_tokens 的实现是直接的滑动窗口:
python
def split_text_on_tokens(*, text, tokenizer):
splits = []
input_ids = tokenizer.encode(text)
start_idx = 0
while start_idx < len(input_ids):
cur_idx = min(start_idx + tokenizer.tokens_per_chunk, len(input_ids))
chunk_ids = input_ids[start_idx:cur_idx]
decoded = tokenizer.decode(chunk_ids)
if decoded:
splits.append(decoded)
if cur_idx == len(input_ids):
break
start_idx += tokenizer.tokens_per_chunk - tokenizer.chunk_overlap
return splits
这种方法保证了每个 chunk 的 token 数精确符合要求,但可能在单词或语义边界的中间断开文本。在实践中,通常推荐使用 RecursiveCharacterTextSplitter.from_tiktoken_encoder,它结合了语义感知分割和 token 计数。
9.8 BaseDocumentTransformer 与 BaseDocumentCompressor
除了文本分割,LangChain 还定义了两个文档处理抽象。
9.8.1 BaseDocumentTransformer
python
class BaseDocumentTransformer(ABC):
@abstractmethod
def transform_documents(self, documents: Sequence[Document], **kwargs) -> Sequence[Document]:
"""Transform a list of documents."""
async def atransform_documents(self, documents: Sequence[Document], **kwargs) -> Sequence[Document]:
return await run_in_executor(None, self.transform_documents, documents, **kwargs)
TextSplitter 继承了这个接口,其 transform_documents 的实现就是调用 split_documents。但这个接口的设计更加通用 -- 它可以表示任何文档转换操作,如去重、翻译、摘要等。
9.8.2 BaseDocumentCompressor
python
class BaseDocumentCompressor(BaseModel, ABC):
@abstractmethod
def compress_documents(
self,
documents: Sequence[Document],
query: str,
callbacks: Callbacks | None = None,
) -> Sequence[Document]:
"""Compress retrieved documents given the query context."""
BaseDocumentCompressor 与 BaseDocumentTransformer 的关键区别在于它接受一个 query 参数。这使得压缩操作可以是查询感知的 -- 例如,根据查询相关性对检索结果进行重排序(reranking),或者提取文档中与查询最相关的段落。
9.9 设计决策分析
为什么文本分割器是独立包?
LangChain 将文本分割器放在了独立的 langchain-text-splitters 包中。这个决策的原因有二:
- 依赖隔离 :某些分割器依赖
tiktoken、transformers等重型包,将其独立可以避免核心包的依赖膨胀 - 复用性:文本分割不仅用于 RAG,还用于长文档处理、代码分析等场景,独立包便于在非 LangChain 项目中使用
Blob 的不可变设计
Blob 被设计为 frozen=True 的不可变对象。这个选择在并发场景中尤其重要 -- 当多个 parser 同时处理同一个 Blob 时,不可变性确保了线程安全。同时,不可变对象可以安全地被缓存。
chunk_overlap 的作用
chunk_overlap 是 RAG 质量的一个关键杠杆。重叠区域确保了跨 chunk 边界的信息不会完全丢失。但过大的重叠会增加存储和计算成本。经验上,重叠比例通常设为 chunk_size 的 5%-20%。
为什么 load_and_split 被标记为弃用?
将加载和分割耦合在一个方法中违反了单一职责原则。分开调用的好处是:
- 可以在分割前对文档进行过滤或预处理
- 可以选择不同的分割器处理不同类型的文档
- 更容易调试和测试
9.10 小结
LangChain 的文档加载与文本分割系统是 RAG 流水线的基石。Document 以其极简的 page_content + metadata 结构,成为整个流水线的通用数据载体。Blob 通过延迟加载和不可变设计,优雅地处理了原始数据的多样性。BaseLoader 的 lazy loading 接口确保了处理大规模数据时的内存效率。
文本分割器体系以 TextSplitter 为基类,提供了从简单字符分割到递归语义分割的完整方案。RecursiveCharacterTextSplitter 的递归算法是这个体系的精华 -- 它通过多层级分隔符序列,尽可能在语义边界处分割文本,同时通过 _merge_splits 的回退机制保持了块间的重叠连续性。语言感知分割则将这种递归策略与编程语言的语法结构相结合,为代码检索提供了优质的分割方案。
BaseDocumentTransformer 和 BaseDocumentCompressor 分别代表了文档处理的两个方向:通用转换和查询感知压缩。它们与加载器、分割器一起,构成了从原始数据到可检索知识库的完整处理链。