LangChain设计与实现-第9章-文档加载与文本分割

第9章 文档加载与文本分割

本书章节导航


RAG(检索增强生成)系统的第一步是将外部知识源中的数据转换为可检索的文档片段。这个过程涉及两个关键环节:文档加载 (从各种数据源读取原始内容)和文本分割(将长文档切分为适合嵌入和检索的小块)。LangChain 为这两个环节构建了精心设计的抽象层,使得开发者可以统一地处理 PDF、网页、数据库、代码仓库等千差万别的数据源。

本章将从 Document 这个核心数据结构开始,逐步展开 BaseLoaderBlob/BlobLoader 的加载体系,深入剖析文本分割器的算法细节,特别是 RecursiveCharacterTextSplitter 的递归分割策略。

:::tip 本章要点

  • 理解 Document 类的设计哲学及其与消息系统的区别
  • 掌握 BaseMedia -> Blob / Document 的继承关系
  • 理解 BaseLoader 的 lazy loading 接口设计
  • 了解 Blob / BlobLoader / BaseBlobParser 的解耦架构
  • 深入理解 TextSplitter 基类的 _merge_splits 合并算法
  • 掌握 RecursiveCharacterTextSplitter 的递归分割策略
  • 了解 BaseDocumentTransformerBaseDocumentCompressor 的角色 :::

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 提供了 idmetadata 两个通用字段。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。虽然两者都承载文本,但它们服务于不同的架构层。

classDiagram class Serializable { +is_lc_serializable() bool +get_lc_namespace() list } class BaseMedia { +id: str | None +metadata: dict } class Blob { +data: bytes | str | None +mimetype: str | None +encoding: str +path: PathLike | None +source: str | None +as_string() str +as_bytes() bytes +as_bytes_io() Generator +from_path(path) Blob$ +from_data(data) Blob$ } class Document { +page_content: str +type: Literal["Document"] } Serializable <|-- BaseMedia BaseMedia <|-- Blob BaseMedia <|-- Document

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 的文档加载体系由三个核心抽象组成:BaseLoaderBlobLoaderBaseBlobParser

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

这段代码有几个值得注意的设计决策:

  1. lazy_load 优先load 方法的默认实现调用 lazy_load 并收集为列表。鼓励子类实现 lazy_load 而非 load,以支持惰性加载
  2. 向后兼容 :如果子类覆盖了 load 但没有覆盖 lazy_load,通过检测 type(self).load != BaseLoader.load 来回退
  3. 异步桥接alazy_load 的默认实现将同步迭代器包装到线程池中,每次调用 next 都在 executor 中执行
flowchart TD subgraph BaseLoader A[load] -->|默认实现| B[list of lazy_load] C[lazy_load] -->|子类实现| D[yield Document] E[alazy_load] -->|默认实现| F[run_in_executor of lazy_load] end subgraph 子类实现 G[TextLoader] --> C H[PDFLoader] --> C I[WebBaseLoader] --> C end C --> J[load_and_split] J --> K[TextSplitter.split_documents]

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))
flowchart LR A[数据源] --> B[BlobLoader] B -->|yield_blobs| C[Blob 流] C --> D[BaseBlobParser] D -->|lazy_parse| E[Document 流] E --> F[TextSplitter] F --> G[分割后的 Document] G --> H[VectorStore]

这种解耦的好处是:

  • 复用 :同一个 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_encoderfrom_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

这个算法的核心逻辑可以用以下图示说明:

flowchart TD A[输入 splits 序列] --> B[初始化 current_doc 和 total] B --> C{遍历每个 split} C --> D{current_doc + split 超过 chunk_size?} D -->|否| E[添加 split 到 current_doc] D -->|是| F[输出 current_doc 为一个 chunk] F --> G[回退 current_doc 保留 overlap 部分] G --> H{total <= chunk_overlap?} H -->|否| I[移除 current_doc 首元素] I --> G H -->|是| E E --> J[更新 total] J --> C C -->|遍历结束| K[输出最后的 current_doc]

算法的关键在于回退机制 :当需要开始一个新的 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
flowchart TD A["_split_text(text, separators)"] --> B[找到文本中存在的最粗分隔符] B --> C["用该分隔符分割文本"] C --> D{遍历每个片段} D --> E{片段长度 < chunk_size?} E -->|是| F[加入 good_splits] E -->|否| G{还有更细的分隔符?} G -->|是| H["递归: _split_text(片段, 更细分隔符)"] G -->|否| I[直接作为 chunk 但可能超长] H --> J[合并递归结果到 final_chunks] F --> K{下一个片段超长?} K -->|是| L[先 merge good_splits] L --> G K -->|否| D D -->|遍历结束| M[merge 剩余的 good_splits] M --> N[返回 final_chunks]

让我们用一个具体例子来理解这个算法:

假设 chunk_size=50chunk_overlap=10,输入文本为:

复制代码
这是第一段内容。

这是第二段内容,比较长比较长比较长比较长比较长比较长比较长比较长比较长。

短段。
  1. 首先用 "\n\n" 分割,得到三段
  2. 第一段和第三段长度小于 50,进入 good_splits
  3. 第二段长度超过 50,递归用 "\n" 分割
  4. 如果仍然超长,继续递归用 " " 分割
  5. 最终用 " " 分割后的片段通过 _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."""

BaseDocumentCompressorBaseDocumentTransformer 的关键区别在于它接受一个 query 参数。这使得压缩操作可以是查询感知的 -- 例如,根据查询相关性对检索结果进行重排序(reranking),或者提取文档中与查询最相关的段落。

flowchart TD subgraph 文档处理流水线 A[原始数据源] --> B[BaseLoader.lazy_load] B --> C[Document 流] C --> D[TextSplitter.split_documents] D --> E[分割后的 Document] E --> F[Embeddings.embed_documents] F --> G[VectorStore.add_documents] end subgraph 检索后处理 H[用户查询] --> I[Retriever.invoke] I --> J[检索到的 Document] J --> K[BaseDocumentCompressor.compress_documents] K --> L[压缩/重排后的 Document] L --> M[送入 LLM] end

9.9 设计决策分析

为什么文本分割器是独立包?

LangChain 将文本分割器放在了独立的 langchain-text-splitters 包中。这个决策的原因有二:

  1. 依赖隔离 :某些分割器依赖 tiktokentransformers 等重型包,将其独立可以避免核心包的依赖膨胀
  2. 复用性:文本分割不仅用于 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 的回退机制保持了块间的重叠连续性。语言感知分割则将这种递归策略与编程语言的语法结构相结合,为代码检索提供了优质的分割方案。

BaseDocumentTransformerBaseDocumentCompressor 分别代表了文档处理的两个方向:通用转换和查询感知压缩。它们与加载器、分割器一起,构成了从原始数据到可检索知识库的完整处理链。

相关推荐
杨艺韬2 小时前
langchain设计与实现-前言
langchain·agent
杨艺韬2 小时前
LangChain设计与实现-第2章-架构总揽
langchain·agent
杨艺韬2 小时前
LangChain设计与实现-第3章-Runnable 与 LCEL 表达式语言
langchain·agent
杨艺韬2 小时前
LangChain设计与实现-第4章-消息系统与多模态
langchain·agent
龙侠九重天2 小时前
OpenClaw 多 Agent 隔离机制:工作空间、状态与绑定路由
人工智能·机器学习·ai·agent·openclaw
简简单单做算法2 小时前
基于Qlearning强化学习的RoboCup足球场景下Agent智能进球决策matlab模拟与仿真
matlab·agent·强化学习·qlearning·robocup·智能进球决策
花千树-0103 小时前
Java Agent 集成 MCP 工具协议:让 AI 真正驱动企业系统
java·ai·langchain·ai agent·mcp·harness·j-langchain
Mr.wangh3 小时前
LangChain
langchain
橙子不要熬夜3 小时前
基于 NestJS + LangChain 的 AI 流式对话实战
langchain·openai