[RAG在LangChain中的实现]根据数据格式选择文档加载器和文本分割器

在"让LLM在指定的上下文范围内回答问题"提供的例子中,我们介绍了RAG涉及的几个标准的组件,后续内容将系统地介绍它们,这篇文章主要关注文档加载器和文本分割器。在RAG(检索增强生成)系统中,文档加载器和文本分割器是数据预处理阶段的两个核心组件,决定了系统最终能检索到什么质量的内容。文档加载器的主要任务是"数据摄取",将各种格式的外部非结构化数据导入RAG管道,并转换为统一的文档格式。由于大语言模型有上下文窗口限制,且过长的文本会降低检索精度,分割器负责将加载好的长文档"切片"成更小的块。

1. 文档(Document)

在LangChain的RAG流程中,Document类是承载数据的核心原子单位。我们可以把它想象成一个带有"标签"的碎片化文本。它的结构由两部分组成:

  • page_content: 这是实际的文本内容。在RAG流程中,这段文字会被送入Embedding模型转化成向量,或者在检索后直接提供给LLM作为上下文;
  • metadata(继承自BaseMedia): 它用于存储元数据,比如来源(文件名、URL)、页码、行号、作者、创建时间和自定义分类标签等;

Document类定义如下,它具有专属的类型document,同时定义了__str__方法返回metadata(如果有)和page_content格式化后的文本。它还从基类BaseMedia继承了表示唯一标识的id字段。由于SerializableBaseMedia的基类,所以Document是一个可序列化类型。

python 复制代码
class Document(BaseMedia):
    page_content: str
    type: Literal["Document"] = "Document"

    def __init__(self, page_content: str, **kwargs: Any) -> None:
        super().__init__(page_content=page_content, **kwargs) 

    def __str__(self) -> str:
        if self.metadata:
            return f"page_content='{self.page_content}' metadata={self.metadata}"
        return f"page_content='{self.page_content}'"

class BaseMedia(Serializable):
    id: str | None = Field(default=None, coerce_numbers_to_str=True)
    metadata: dict = Field(default_factory=dict)

2. 文档加载器(BaseLoader)

在RAG的数据流中,文档加载器是数据进入系统的门户。它的任务只有一个,那就是把各种格式(PDF、Notion和数据库等)的原始内容转换成Document对象。不同的格式具有对应的文档加载器类型,BaseLoader是它们的基类。

BaseLoader作为一个抽象基类,其子类必须重写其lazy_load或者load方法。lazy_load方法利用返回的迭代器(Iterator[Document])实现"惰性加载",默认实现的load方法会返回根据Iterator[Document]创建的列表。如果需要提供异步加载的实现,可以重写alazy_load或者aload方法。load_and_split将调用load方法得到到文档传入指定的text_splitter进行切片,一步到位完成"加载+分块"的任务。

python 复制代码
class BaseLoader(ABC): 
    def load(self) -> list[Document]:
        return list(self.lazy_load())

    async def aload(self) -> list[Document]:
        return [document async for document in self.alazy_load()]

    def load_and_split(
        self, text_splitter: TextSplitter | None = None
    ) -> list[Document]:
        if text_splitter is None:
            if not _HAS_TEXT_SPLITTERS:
                msg = (
                    "Unable to import from langchain_text_splitters. Please specify "
                    "text_splitter or install langchain_text_splitters with "
                    "`pip install -U langchain-text-splitters`."
                )
                raise ImportError(msg)

            text_splitter_: TextSplitter = RecursiveCharacterTextSplitter()
        else:
            text_splitter_ = text_splitter
        docs = self.load()
        return text_splitter_.split_documents(docs)

    def lazy_load(self) -> Iterator[Document]:
        if type(self).load != BaseLoader.load:
            return iter(self.load())
        msg = f"{self.__class__.__name__} does not implement lazy_load()"
        raise NotImplementedError(msg)

    async def alazy_load(self) -> AsyncIterator[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

LangChain社区提供了数百个具体的文档加载器类型,涵盖了从本地文件到云端服务的各种场景。为了方便记忆,可以将它们分为以下几大类:

  • 通用文件类:处理最常见的本地数据格式

    • TextLoader: 加载纯文本文件;
    • PyPDFLoader: 使用pypdf库加载PDF,能够按页拆分并提取页码;
    • CSVLoader: 为CSV的每一行创建一个Document,支持通过source_column指定元数据来源;
    • JSONLoader: 通过jq语法解析JSON文件中的特定字段;
    • UnstructuredMarkdownLoader: 处理Markdown文件,保留标题等结构信息;
  • 网络与网页类: 从互联网抓取内容

    • WebBaseLoader: 最基础的网页加载器,基于BeautifulSoup抓取HTML内容;
    • SeleniumURLLoader: 适用于需要JavaScript渲染的动态网页;
    • YoutubeLoader: 自动获取YouTube视频的字幕并转化为文档;
  • 目录与批量处理:

    • DirectoryLoader: 扫描整个文件夹,并可以根据文件后缀自动选择对应的加载器类型(如.pdf用PyPDFLoader,.txt用TextLoader);
  • 协同工具与SaaS平台:

    • NotionDirectoryLoader: 处理从Notion导出的Markdown或通过 API 接入;
    • GithubFileLoader: 从指定的GitHub仓库抓取文件;
    • SlackDirectoryLoader: 加载Slack的聊天记录备份;
    • ArxivLoader: 搜索并下载 Arxiv 上的科研论文;
  • 数据库与云存储:

    • BigQueryLoader: 将 SQL 查询结果转化为文档;
    • AzureBlobStorageFileLoader: 从云端对象存储直接读取;

3. 文本分割器

在RAG流程中,文本分割是决定系统性能的关键环节。其必要性主要体现在以下四个核心维度:

  • 突破LLM的上下文窗口限制: 大模型能处理的Token数量是有上限的。如果有一本500页的PDF,直接作为提示词会导致溢出报错,所以必须将长文档切成LLM 能够"吞下"的小块;
  • 提升检索的"信噪比" :这是RAG效果好坏的核心。向量搜索是基于语义相似度的,如果一个Document包含10个不同的主题,它的向量表示会变得模糊、泛化,导致检索不准。分割后的"小块"主题更集中;
  • 节省成本与提升响应速度:LLM按Token计费。如果检索出3个500字的片段就能解决问题,就没必要塞入3 个1万字的完整文档。而且处理的Token越少,LLM生成回复的首字延迟(TTFT)就越短;
  • 优化向量嵌入的质量 :主流Embedding模型Token的输入限制。超过部分的文本会被直接截断。优秀的分割器(如 RecursiveCharacterTextSplitter)会利用 Overlap(重叠区)防止语义在切割处断层。

3.1 TextSplitter

作为很多文本分割器(并非所有)基类,TextSplitter继承自如下这个BaseDocumentTransformer抽象类。作为"文档转换器"的BaseDocumentTransformer旨在利用它的方法transform_documentsatransform_documents将指定的文档序列最相应转换,并返回转换后的文档序列。transform_documents为必须实现的抽象方法。TextSplitter也是一个抽象类,具体的文本分割体现在它的抽象方法split_text中。

python 复制代码
class BaseDocumentTransformer(ABC):
    @abstractmethod
    def transform_documents(
        self, documents: Sequence[Document], **kwargs: Any
    ) -> Sequence[Document]:

    async def atransform_documents(
        self, documents: Sequence[Document], **kwargs: Any
    ) -> Sequence[Document]

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

    @abstractmethod
    def split_text(self, text: str) -> list[str]

TextSplitter的构造函数提供了如下的参数:

  • chunk_size:每个文本块的最大长度。太大容易混入杂质,太小则语义不全,默认为4000;
  • chunk_overlap:相邻两个块之间的重复内容长度。它像胶水一样连接上下文,能防止语义在切分点被生硬斩断,默认200。chunk_overlap不能大于chunk_size
  • length_function: 计算长度的方法,默认按字符数计算。在RAG中,建议传入tiktoken的编码函数,按Token数量来切分。因为LLM的限制是基于Token的,100个汉字和100个英文单词占用的Token差异可能会很大;
  • keep_separator: 是否或者如何保留分隔符,具有如下三个选项。
    • False: 丢弃
    • "start": 放在块开头
    • "end" :(放在块结尾)
  • add_start_index:如果设为True,切分后的Document元数据中会多出一个start_index字段,记录该块在原原始文档中的字符偏移位置。对于精准溯源非常有用,能帮你定位到原文的具体坐标;
  • strip_whitespace:自动去掉每个块前后的空格和换行。可以节省Token,让提供给LLM的文本更干净;

TextSplitter还提供了create_documentssplit_documents方法进行基于Document的切割和创建。create_documents方法会遍历texts参数提供的每条文本,并调用split_text方法对其切割,最后为每个切片文本创建一个Documenttransform_documents方法的实现与之类似,差异就是被切割的原始文本为提供Documentpage_content字段,返回的Document会保留原始的元数据。它针对基类抽象方法transform_documents的实现正是调用了create_documents方法。

python 复制代码
class TextSplitter(BaseDocumentTransformer, ABC):
    def create_documents(
        self, texts: list[str], metadatas: list[dict[Any, Any]] | None = None
    ) -> list[Document]

    def split_documents(self, documents: Iterable[Document]) -> list[Document]:
    @override
    def transform_documents(
        self, documents: Sequence[Document], **kwargs: Any
    ) -> Sequence[Document]

TextSplitter还定义了如下两个类方法作为创建具体文本分割器的工厂方法。它们的核心目的是让文本分割与大模型Token限制完全对齐,因为它们直接用大模型的分词器来计算chunk_size

python 复制代码
class TextSplitter(BaseDocumentTransformer, ABC):
    @classmethod
    def from_huggingface_tokenizer(
        cls, tokenizer: PreTrainedTokenizerBase, **kwargs: Any
    ) -> TextSplitter:

    @classmethod
    def from_tiktoken_encoder(
        cls,
        encoding_name: str = "gpt2",
        model_name: str | None = None,
        allowed_special: Literal["all"] | AbstractSet[str] = set(),
        disallowed_special: Literal["all"] | Collection[str] = "all",
        **kwargs: Any,
    ) -> Self:

我们可以根据需要选择选择这两个工厂方法:

  • from_tiktoken_encoder:适用于OpenAI模型。如果使用GPT-3.5、GPT-4等OpenAI的模型,它们内部使用了tiktoken库;
  • from_huggingface_tokenizer:适用于开源模型。如果你使用的是Llama-3、ChatGLM、Qwen或BERT等开源模型,通常会从Hugging Face下载其分词器;

3.2 CharacterTextSplitter

CharacterTextSplitter是最直观、最简单的分割器。它直接根据一个指定的字符进行硬切分,然后再尝试合并成符合长度限制的块。如果参数is_separator_regex被设置为True,最终会采用正则表达式实施分割。

python 复制代码
class CharacterTextSplitter(TextSplitter):
    def __init__(
        self,
        separator: str = "\n\n",
        is_separator_regex: bool = False, 
        **kwargs: Any,
    ) -> None
    def split_text(self, text: str) -> list[str]

CharacterTextSplitter针对文本分割风行如下三条铁律:

分隔符(Separator)是唯一准则

它只在我们指定的分隔符(默认\n\n)处动刀。如果一段文字里没有这个分隔符,即使长度超过了chunk_size的一万倍,它也绝不会强行切开,而是会完整输出并抛出一个警告。如下的演示程序体现了这一点。

python 复制代码
from langchain_text_splitters import  CharacterTextSplitter

text ="""Lorem ipsum dolor sit amet, consectetur adipiscing elit.

Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."""

splitter = CharacterTextSplitter(chunk_size=20, chunk_overlap=5, strip_whitespace=False)
chunks = splitter.split_text(text)
for i, chunk in enumerate(chunks):
    print(f"Chunk {i+1} (length = {len(chunk)}):\n{chunk}\n")

输出:

复制代码
Created a chunk of size 56, which is longer than the specified 20
Chunk 1 (length = 56):
Lorem ipsum dolor sit amet, consectetur adipiscing elit.

Chunk 2 (length = 66):
Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
块大小(Chunk Size)是合并上限

它不是在"切割"长文本,而是在"堆叠"小碎片。它先把全文按分隔符炸成碎片,然后像搭积木一样,把碎片往一个块里塞。一旦加入下一个碎片会超过chunk_size就立即停止当前块,开启新块。如下的演示程序体现了这一点。

python 复制代码
from langchain_text_splitters import  CharacterTextSplitter

text ="""Lorem ipsum dolor sit amet.

consectetur adipiscing elit.

Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."""

splitter = CharacterTextSplitter(chunk_size=100, chunk_overlap=5, strip_whitespace=False)
chunks = splitter.split_text(text)
for i, chunk in enumerate(chunks):
    print(f"Chunk {i+1} (length = {len(chunk)}):\n{chunk}\n")

输出

复制代码
Chunk 1 (length = 57):
Lorem ipsum dolor sit amet.

consectetur adipiscing elit.

Chunk 2 (length = 66):
Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
重叠(Overlap)是回退检索

只有发生块切换时,重叠才会触发。开启新块时,它会从前一个块的末尾往回数chunk_overlap个字符。如果在回退的这段距离里,能抓到一个或多个完整的"碎片",这些碎片就会被重复放入新块的开头。如果回退的距离内连一个分隔符都遇不到,重叠就彻底失效。由于上面两个例子中chunk_overlap过小(5),导致第二个chunk回退5个字符遇不到分隔符,所以不会有重叠。我们在上面这个例子基础上,将chunk_overlap设置为50(超过第二个段落的长度),第二个段落将会成为两个Chunk之间的重叠部分。

python 复制代码
from langchain_text_splitters import  CharacterTextSplitter

text ="""Lorem ipsum dolor sit amet.

consectetur adipiscing elit.

Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."""

splitter = CharacterTextSplitter(chunk_size=100, chunk_overlap=50, strip_whitespace=False)
chunks = splitter.split_text(text)
for i, chunk in enumerate(chunks):
    print(f"Chunk {i+1} (length = {len(chunk)}):\n{chunk}\n")

输出:

复制代码
Chunk 1 (length = 57):
Lorem ipsum dolor sit amet.

consectetur adipiscing elit.

Chunk 2 (length = 96):
consectetur adipiscing elit.

Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.

这三条规则是文本分割器的底层通用逻辑,但在不同类型的分割器中,它们的"执行力度"和"表现形式"会有所差异:

  • 分隔符是唯一准则:

    • RecursiveCharacterTextSplitter具有多个具有不同优先级的分隔符。如果高优先级的切不开,它会逐级下放,直到最后的""(字符级)保底;
    • TokenTextSplitter完全不看字符,它的"分隔符"就是Token的边界。它不关心标点符号,只关心Token计数;
  • 块大小是合并上限: 几乎通用,所有基于长度的分割器都遵守这个逻辑------即"尽可能多地堆叠碎片,直到塞不下下一个为止"。

    • MarkdownHeaderTextSplitter是个特例,它只看标题(#),如果一个标题下有一万字,它就给出一个一万字的块,完全无视chunk_size
  • 重叠是回退检索: 线性分割器的核心。

    • RecursiveCharacterTextSplitter表现最完美,它在切断处往回数chunk_overlap个字符。由于它有""保底,它能精准地回退到那个位置;
    • TokenTextSplitter表现最硬核,它在Token序列里往回数N个数字。这种回退是物理级的,重叠部分的大小永远恒定;
    • MarkdownHeaderTextSplitter采用标题分割,不适用。它们不回退文字,而是通过"元数据注入"来实现逻辑上的重叠(即把上一级的标题信息带到下一级的每一个块里);

3.3 RecursiveCharacterTextSplitter

如果说CharacterTextSplitter是拿着一把菜刀"一刀切",那么RecursiveCharacterTextSplitter就是一位精细的"外科医生"。它是LangChain最推荐、也是默认的文本分割器。它会按照优先级选择分隔符递归地进行分割,直到块的大小满足要求。

python 复制代码
class RecursiveCharacterTextSplitter(TextSplitter):
    def __init__(
        self,
        separators: list[str] | None = None,
        keep_separator: bool | Literal["start", "end"] = True,  
        is_separator_regex: bool = False, 
        **kwargs: Any,
    ) -> None
    def split_text(self, text: str) -> list[str]

这组具有优先级的分隔符通过separators参数定义,如果is_separator_regex被设置为True,分隔符将被视为正则表达式。如果没有对分隔符作显式设置,会使用如下所示的四种默认分隔符(按照优先级排序):

  • "\n\n": 段落
  • "\n": 换行
  • " ": 空格
  • "": 单个字符(最后的保底手段)

它的执行流程可以理解为一个循环降级的过程。这样做的好处是尽可能地保持语义的完整性。它优先保证段落在一起,其次是句子在一起,最后才是单词在一起。

  • 尝试最高级:首先看能不能用"\n\n"把文档切开;
  • 检查长度:切开后的每一个碎片,是否小于你设定的chunk_size
    • 是:保留这个碎片;
    • 否:对这个"超标"的碎片,进入下一级分隔符("\n")重复上述过程;
  • 继续降级:如果换行符还切不小,就用空格;如果空格还不行,就强制按字符一个个切;

如下的演示程序提供了两段文字,第一段有换行。我们使用RecursiveCharacterTextSplitterchunk_size=50, chunk_overlap=30。整段文本被分为四个切片,重叠出现在最后两个切片。

python 复制代码
from langchain_text_splitters import  RecursiveCharacterTextSplitter

text ="""Lorem ipsum dolor sit amet.
consectetur adipiscing elit.

Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."""

splitter = RecursiveCharacterTextSplitter(chunk_size=50, chunk_overlap=30)
chunks = splitter.split_text(text)
for i, chunk in enumerate(chunks):
    print(f"Chunk {i+1} (length = {len(chunk)}):\n{chunk}\n")

输出:

复制代码
Lorem ipsum dolor sit amet.

Chunk 2 (length = 28):
consectetur adipiscing elit.

Chunk 3 (length = 45):
Sed do eiusmod tempor incididunt ut labore et

Chunk 4 (length = 44):
incididunt ut labore et dolore magna aliqua.

鉴于"编程"已经成为AI应用一个重要的分支,RecursiveCharacterTextSplitter为"代码文本"的切割进行了针对性设计。我们不仅可以利用类方法get_separators_for_language得到指定某种编程语言的分隔符,还可以直接调用from_language类方法创建与执行编程语言相匹配的RecursiveCharacterTextSplitter对象。

python 复制代码
class RecursiveCharacterTextSplitter(TextSplitter):
    @classmethod
    def from_language(
        cls, language: Language, **kwargs: Any
    ) -> RecursiveCharacterTextSplitter

    @staticmethod
    def get_separators_for_language(language: Language) -> list[str]

class Language(str, Enum):
    CPP = "cpp"
    GO = "go"
    JAVA = "java"
    KOTLIN = "kotlin"
    JS = "js"
    TS = "ts"
    PHP = "php"
    PROTO = "proto"
    PYTHON = "python"
    R = "r"
    RST = "rst"
    RUBY = "ruby"
    RUST = "rust"
    SCALA = "scala"
    SWIFT = "swift"
    MARKDOWN = "markdown"
    LATEX = "latex"
    HTML = "html"
    SOL = "sol"
    CSHARP = "csharp"
    COBOL = "cobol"
    C = "c"
    LUA = "lua"
    PERL = "perl"
    HASKELL = "haskell"
    ELIXIR = "elixir"
    POWERSHELL = "powershell"
    VISUALBASIC6 = "visualbasic6"

下面的程序演示针对一段C#代码的切割,可以看出每个切片基本是一段相对完整的代码片段:

python 复制代码
from langchain_text_splitters import  RecursiveCharacterTextSplitter,Language

text = """
var app = WebApplication.Create(args);
IApplicationBuilder appBuilder = app;
appBuilder
    .Use(middleware: HelloMiddleware)
    .Use(middleware: WorldMiddleware);
app.Run();

static RequestDelegate HelloMiddleware(RequestDelegate next)
    => async httpContext => {
    await httpContext.Response.WriteAsync("Hello, ");
    await next(httpContext);
};

static RequestDelegate WorldMiddleware(RequestDelegate next)=> httpContext => httpContext.Response.WriteAsync("World!");
"""

splitter = RecursiveCharacterTextSplitter.from_language(language=Language.CSHARP, chunk_size=200)
chunks = splitter.split_text(text)
for i, chunk in enumerate(chunks):
    print(f"Chunk {i+1}:\n{chunk}\n")

输出:

复制代码
Chunk 1:
var app = WebApplication.Create(args);
IApplicationBuilder appBuilder = app;
appBuilder
    .Use(middleware: HelloMiddleware)
    .Use(middleware: WorldMiddleware);
app.Run();

Chunk 2:
static RequestDelegate HelloMiddleware(RequestDelegate next)
    => async httpContext => {
    await httpContext.Response.WriteAsync("Hello, ");
    await next(httpContext);
};

Chunk 3:
static RequestDelegate WorldMiddleware(RequestDelegate next)=> httpContext => httpContext.Response.WriteAsync("World!");

3.4 HTMLHeaderTextSplitter

HTMLHeaderTextSplitter利用HTML的标题标签(h1-h6)来切分文档,并将层级结构转化为元数据。它按照如下的方式通过HTML的语义结构来切分:

  • 保持上下文: 如果一段话在 <h2>Foobar</h2> 下面,切分后的片段会自动带上 {"Sub Topic": "Foobar"} 的标签;
  • 结构化检索: 搜索时,模型不仅能看到正文,还能知道这段话属于哪个章节;
python 复制代码
class HTMLHeaderTextSplitter:
    def __init__(
        self,
        headers_to_split_on: list[tuple[str, str]],
        return_each_element: bool = False,
    ) -> None

    def split_text(self, text: str) -> list[Document]
    def split_text_from_url(
        self, url: str, timeout: int = 10, **kwargs: Any
    ) -> list[Document]

    def split_text_from_file(self, file: str | IO[str]) -> list[Document]

HTMLHeaderTextSplitter类型的构造具有如下两个参数,它们决定了切分器"在哪里切"以及"切成多细"。我们可以把它们想象成手术刀的"刻度"和"切法"。

  • headers_to_split_on:定义切分刻度,是一个由(header_tag, header_name)二元组组成的列表。header_tag指HTML中的标题标签,如h1、h2和h3等;header_name是希望在返回的Document元数据中显示的Key;

  • return_each_element:定义切分力度(合并还是独立)。这是一个布尔值,决定了最终生成的Document对象是如何封装HTML元素:

    • True: 采用原子化切割。每一个HTML标签(包括标题本身和每一个段落)都会被封装成一个独立的Document。如果有一个 <h1>后面跟着三个 <p>,我们会得到四个Document,每个Document都会携带相同的标题元数据;
    • False: 按标题聚合。它会把同一个标题下的所有非标题元素(如多个 <p>, <li>, <span>)全部"揉"在一起,合并成一个大的Document。它保证检索出来的片段是完整的段落或章节。这种方式可以减少向量数据库中的Document数量,保持内容的连贯性

HTMLHeaderTextSplitter定义了split_text方法对针对指定HTML文本的切割。我们也可以调用split_text_from_urlsplit_text_from_file提供HTML页面的URL或者HTML文件。

python 复制代码
from langchain_text_splitters import  HTMLHeaderTextSplitter
html = """
<html>
    <head>
        <title></title>
    </head>
    <body>
        <h1>Header 1</h1>
        <p>Lorem ipsum dolor sit amet.</p>
        <p>consectetur adipiscing elit.</p>
        <h2>Header 2</h2>
        <p>Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p>
    </body>
</html>
"""

def split(return_each_element:bool):
    splitter = HTMLHeaderTextSplitter(
        headers_to_split_on=[("h1","heading1"),("h2","heading 2")],
        return_each_element= return_each_element)
    documents = splitter.split_text(html)
    for index, document in enumerate(documents):
        print(f"Document {index + 1}:")
        print(f"\tcontent: {document.page_content}")
        print(f"\tmetadata: {document.metadata}")

split(return_each_element= False)
split(return_each_element= True)

上面的演示程序中,我们构建了一个HTML片段,然后构建了一个针对两级标题(headers_to_split_on=[("h1","heading1"),("h2","heading 2")])的HTMLHeaderTextSplitter对象。然如下现实的是return_each_element分别被设置为False和True时,切割HTML文本生成的文本。

复制代码
Document 1:
        content: Header 1
        metadata: {'heading1': 'Header 1'}
Document 2:
        content: Lorem ipsum dolor sit amet.
consectetur adipiscing elit.
        metadata: {'heading1': 'Header 1'}
Document 3:
        content: Header 2
        metadata: {'heading1': 'Header 1', 'heading 2': 'Header 2'}
Document 4:
        content: Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
        metadata: {'heading1': 'Header 1', 'heading 2': 'Header 2'}

Document 1:
        content: Header 1
        metadata: {'heading1': 'Header 1'}
Document 2:
        content: Lorem ipsum dolor sit amet.
        metadata: {'heading1': 'Header 1'}
Document 3:
        content: consectetur adipiscing elit.
        metadata: {'heading1': 'Header 1'}
Document 4:
        content: Header 2
        metadata: {'heading1': 'Header 1', 'heading 2': 'Header 2'}
Document 5:
        content: Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
        metadata: {'heading1': 'Header 1', 'heading 2': 'Header 2'}

3.4 HTMLSectionSplitter

相比HTMLHeaderTextSplitterHTMLSectionSplitter是一个增强版且两阶段的切分器。它的核心逻辑可以概括为:先强制规范化(通过 XSLT),再按标题切分,最后进行字符级二次切分。

引入XSLT转换是它最特别的地方。很多网页不规范,比如用 <p style="font-size:30px"> 代替 <h1>HTMLSectionSplitter内置了一个.xslt文件(converting_to_header.xslt)。在切分前,它会调用convert_possible_tags_to_header方法,利用lxml库强行把那些"看起来像标题"的标签转换成标准的<h1>-<h6>。它比HTMLHeaderTextSplitter更抗干扰,能处理结构不那么标准的HTML。

python 复制代码
class HTMLSectionSplitter:
    def __init__(
        self,
        headers_to_split_on: list[tuple[str, str]],
        **kwargs: Any,
    ) -> None

    def split_documents(self, documents: Iterable[Document]) -> list[Document]
    def split_text(self, text: str) -> list[Document]

    def create_documents(
        self, texts: list[str], metadatas: list[dict[Any, Any]] | None = None
    ) -> list[Document]
    def split_html_by_headers(self, html_doc: str) -> list[dict[str, str | None]]
    def convert_possible_tags_to_header(self, html_content: str) -> str
    def split_text_from_file(self, file: StringIO) -> list[Document]

在进行切割的时候,HTMLHeaderTextSplitter会为<h1>-<h6>标签中的内容生成一个独立的DocumentHTMLSectionSplitter则会将它与后续内容进行合并,所以如果将上面演示实例中的分割器替换成HTMLSectionSplitter,只会生成如下两个Document

复制代码
Document 1:
        content: Header 1 
 Lorem ipsum dolor sit amet.
 consectetur adipiscing elit.
        metadata: {'heading1': 'Header 1'}
Document 2:
        content: Header 2
 Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
        metadata: {'heading 2': 'Header 2'}

3.5 RecursiveJsonSplitter

RecursiveJsonSplitter能把一个巨大的JSON变成若干个小的JSON片段,同时保证每个片段都保留原有的层级结构。这在处理复杂的API响应或大型配置文件并将其提供给LLM时非常有用。RecursiveJsonSplittermax_chunk_size字段是一个硬性指标,控制单个JSON字符串的最大长度。min_chunk_size字段则是一个软性指标。如果当前切片还没达到这个数,它会倾向于继续往里塞东西,而不是新开一个切片,从而避免产生过多零碎的JSON。

python 复制代码
class RecursiveJsonSplitter:
    max_chunk_size: int = 2000
    min_chunk_size: int = 1800
    def __init__(
        self, max_chunk_size: int = 2000, min_chunk_size: int | None = None
    ) -> None

    def split_json(
        self,
        json_data: dict[str, Any],
        convert_lists: bool = False, 
    ) -> list[dict[str, Any]]:

    def split_text(
        self,
        json_data: dict[str, Any],
        convert_lists: bool = False, 
        ensure_ascii: bool = True,
    ) -> list[str]:

    def create_documents(
        self,
        texts: list[dict[str, Any]],
        convert_lists: bool = False,  
        ensure_ascii: bool = True,
        metadatas: list[dict[Any, Any]] | None = None,
    ) -> list[Document]

文本分割的核心操作实现在它的split_json方法中,待分割的JSON数据体现为json_data参数对应的字典。关键参数convert_lists如果设为True,方法会先把数组或者列表转成带索引的字典(如 {"0": "item1"}),这样数组里的每个元素都能被当作独立的键值对来精确切分。它不是简单地切断字符串,而是递归地遍历JSON树,并在此基础上提供如下的保障:

  • 结构保留:如果一个嵌套很深的字段被切分出去,它生成的每个切片都会带有从根节点到该字段的全路径键名;
  • 大小控制:它通过json.dumps方法计算序列化后的字符长度,确保每个分片大小在min_chunk_sizemax_chunk_size之间;

split_json方法返回的是一个列表,作为列表元素的字典代表作为切片的JSON。split_text方法在此基础上将对象序列化成字符串,create_documents方法又在后者基础上利用附加的元数据将其转换成Document列表。如下的程序演示了split_jsoncreate_documents方法的用法。

python 复制代码
from langchain_text_splitters import  RecursiveJsonSplitter
import json
json_data = {
    "foo": "Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua",
    "bar": "Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. ",
}

splitter = RecursiveJsonSplitter(max_chunk_size=50, min_chunk_size=30)
list = splitter.split_json(json_data)
for index, split in enumerate(list):
    print(f"split {index + 1}:")
    print(f"\tjson: {json.dumps(split)}")

documents = splitter.create_documents(
    texts = list, 
    metadatas=[{"source": f"split_{i + 1}"} for i in range(len(list))])
for index, document in enumerate(documents):
    print(f"document {index + 1}:")
    print(f"\tpage_content: {document.page_content}")
    print(f"\tmetadata: {document.metadata}")

输出:

复制代码
split 1:
        json: {"foo": "Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua"}
split 2:
        json: {"bar": "Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. "}
document 1:
        page_content: {"foo": "Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua"}
        metadata: {'source': 'split_1'}
document 2:
        page_content: {"bar": "Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. "}
        metadata: {'source': 'split_2'}

3.6 MarkdownHeaderTextSplitter

网上有一句话:"AI的语言是Markdown",所以基于Markdown标题对文本进行分割的MarkdownHeaderTextSplitter的重要性就不言而喻了。MarkdownHeaderTextSplitter是最符合人类逻辑的分割器之一。它不只是"切碎"文本,而是理解文档的层级结构。普通的分割器(如RecursiveCharacterTextSplitter)是基于字符长度硬切,而这个类是基于Markdown标题(#、##、###)来切分。

MarkdownHeaderTextSplitter并没有继承TextSplitter,用于切割的split_text方法返回的不是字符串列表,而是Document列表,Document可以将标题内容存入元数据。它定义的另一个方法aggregate_lines_to_chunks的作用是将具有相同"身份标签"(Metadata)的连续行(通过LineType表示)重新粘合在一起。

python 复制代码
class MarkdownHeaderTextSplitter:
    def __init__(
        self,
        headers_to_split_on: list[tuple[str, str]],
        return_each_line: bool = False,
        strip_headers: bool = True,
        custom_header_patterns: dict[str, int] | None = None,
    ) -> None
    def aggregate_lines_to_chunks(self, lines: list[LineType]) -> list[Document]
    def split_text(self, text: str) -> list[Document]

class LineType(TypedDict):
    metadata: dict[str, str]
    content: str

构造一个MarkdownHeaderTextSplitter对象需要提供如下参数:

  • headers_to_split_on:定义哪些级别的标题需要触发"切断"操作,并指定存储在Metadata中的键名(比如[("#", "Header 1"), ("##", "Header 2")])。它会根据符号长度自动排序(从###到#),确保优先匹配深层标题。
  • return_each_line:控制输出粒度,通常保持False。只有当你需要对文档进行极细粒度的行级分析时才开启。
    • False (默认):将属于同一组标题的所有行聚合成一个较大的Document对象。
    • True:将每一行文本都作为一个独立的Document返回(每行都带有该行所属的标题元数据)。
  • strip_headers:决定是否从正文(page_content)中删掉标题行本身。如果RAG系统需要LLM感知标题在正文中的原始位置,设为False;若追求简洁,保持True。
    • True (默认):标题行被剔除,只保留其下的内容。标题信息仅存在于 metadata中。这能节省Token,避免冗余。
    • False:标题行既出现在元数据里,也保留在正文文本中。
  • custom_header_patterns:支持非标准的Markdown标题识别。默认情况下,它只认#。通过这个参数,可以定义特定的正则模式作为标题。

如下的程序演示了MarkdownHeaderTextSplitter针对一段Markdown文本的分割,以及针对一组携带文本内容和元数据的LineType列表的聚合。

python 复制代码
from langchain_text_splitters import  MarkdownHeaderTextSplitter,LineType

text ="""
# 动物百科
## 猫科
猫是可爱的。
它们喜欢睡觉。
## 犬科
狗是忠诚的。
"""
splitter = MarkdownHeaderTextSplitter([("#", "Header 1"), ("##", "Header 2")])
documents = splitter.split_text(text)
for i, document in enumerate(documents):
    print(f"Document {i+1}:\n{document}\n")

lines:list[LineType] = [
    {"content": "猫是可爱的。", "metadata": {"Header 1": "动物百科", "Header 2": "猫科"}},
    {"content": "它们喜欢睡觉。", "metadata": {"Header 1": "动物百科", "Header 2": "猫科"}}, 
    {"content": "狗是忠诚的。", "metadata": {"Header 1": "动物百科", "Header 2": "犬科"}}, 
]

print('-'*80)
documents = splitter.aggregate_lines_to_chunks(lines) 
for i, document in enumerate(documents):
    print(f"Document {i+1}:\n{document}\n")

输出:

复制代码
Document 1:
page_content='猫是可爱的。
它们喜欢睡觉。' metadata={'Header 1': '动物百科', 'Header 2': '猫科'}

Document 2:
page_content='狗是忠诚的。' metadata={'Header 1': '动物百科', 'Header 2': '犬科'}

--------------------------------------------------------------------------------
Document 1:
page_content='猫是可爱的。
它们喜欢睡觉。' metadata={'Header 1': '动物百科', 'Header 2': '猫科'}

Document 2:
page_content='狗是忠诚的。' metadata={'Header 1': '动物百科', 'Header 2': '犬科'}

3.7 TokenTextSplitter

TokenTextSplitter是为了精准控制成本和对齐上下文窗口而设计的。由于LLM处理的不是字符数,而是Token,这个分割器通过调用tiktoken库,直接站在模型的视角来测量和切割文本。

python 复制代码
class TokenTextSplitter(TextSplitter):
    def __init__(
        self,
        encoding_name: str = "gpt2",
        model_name: str | None = None,
        allowed_special: Literal["all"] | AbstractSet[str] = set(),
        disallowed_special: Literal["all"] | Collection[str] = "all",
        **kwargs: Any,
    ) -> None
    def split_text(self, text: str) -> list[str]

def split_text_on_tokens(*, text: str, tokenizer: Tokenizer) -> list[str]:

@dataclass(frozen=True)
class Tokenizer:
    chunk_overlap: int
    tokens_per_chunk: int
    decode: Callable[[list[int]], str]
    encode: Callable[[str], list[int]]

TokenTextSplitter的实现的split_text方法最终会调用split_text_on_tokens函数根据指定的Tokenizer对象实施分割。构造TokenTextSplitter对象提供的参数用于创建这个Tokenizer对象。如果通过model_name参数指定了采用模型,那么它将决定Tokenizer中用来进行编码和解码的encodedecode字段(内部会调用tiktoken.encoding_for_model函数)。如果没有执行模型,这两个字段则由encoding_name参数表示的编码名称决定。allowed_specialdisallowed_special参数用于控制特殊Token的使用。

下面的程序演示了利用TokenTextSplitter以每15个Token对输入文本进行切割,为了验证每个切片对应的Token数,我们利用TokenTextSplitter_tokenzier字段返回的分词器。

python 复制代码
from langchain_text_splitters import  TokenTextSplitter

text = "Sed ut perspiciatis unde omnis iste.Natus error sit voluptatem accusantium doloremque laudantium."
splitter = TokenTextSplitter(chunk_size=15, chunk_overlap=5)
chunks = splitter.split_text(text)

for i, chunk in enumerate(chunks):
    token_count = len(splitter._tokenizer.encode(chunk))
    print(f"""
Chunk {i + 1}
    content: {chunk}
    tokens: {token_count}""")

输出:

复制代码
Chunk 1
    content: Sed ut perspiciatis unde omnis iste.Natus
    tokens: 15

Chunk 2
    content:  iste.Natus error sit voluptatem accusantium dol
    tokens: 15

Chunk 3
    content:  accusantium doloremque laudantium.
    tokens: 11

3.8 SentenceTransformersTokenTextSplitter

SentenceTransformersTokenTextSplitter是一个专门为开源Embedding模型(如 BERT、RoBERTa、MPNet)设计的切分器。它会调用特定的本地模型分词器,确保切分出的每一个切片都精准符合该模型的输入窗口限制(无需手动设置配片长度)。它默认采用的模型为sentence-transformers/all-mpnet-base-v2,除了用于切割的split_text,它还提供了count_tokens方法来计算指定文本具有的Token数量。

python 复制代码
class SentenceTransformersTokenTextSplitter(TextSplitter):
    def __init__(
        self,
        chunk_overlap: int = 50,
        model_name: str = "sentence-transformers/all-mpnet-base-v2",
        tokens_per_chunk: int | None = None,
        **kwargs: Any,
    ) -> None
    def split_text(self, text: str) -> list[str]
    def count_tokens(self, *, text: str) -> int

下面的程序采用默认的模型(sentence-transformers/all-mpnet-base-v2)创建SentenceTransformersTokenTextSplitter对象,并利用它对一段长文本进行切割。最后输出每个切片的内容和Token数量。默认模型采用的切片长度为386个Tokens,输出证实了这一点:

python 复制代码
from langchain_text_splitters import  SentenceTransformersTokenTextSplitter

text = "Sed ut perspiciatis unde omnis iste.Natus error sit voluptatem accusantium doloremque laudantium." * 20
splitter = SentenceTransformersTokenTextSplitter()
chunks = splitter.split_text(text)

for i, chunk in enumerate(chunks):
    token_count = splitter.count_tokens(text = chunk)
    print(f"""
Chunk {i + 1}
    content: {chunk}
    tokens: {token_count}""")

输出:

复制代码
Chunk 1
    content: sed ut perspiciatis unde omnis...
    tokens: 386

Chunk 2
    content: ##mque laudantium. sed ut perspiciatis unde ...
    tokens: 350
相关推荐
KIHU快狐2 小时前
KIHU快狐|49寸户外液晶显示器2500亮度智能调光加油站业务办理屏
python·计算机外设
2401_873544922 小时前
持续集成/持续部署(CI/CD) for Python
jvm·数据库·python
Fang fan2 小时前
Netty入门
java·开发语言·redis·分布式·python·哈希算法
2301_814590252 小时前
使用Python进行图像识别:CNN卷积神经网络实战
jvm·数据库·python
第一程序员2 小时前
GitHub Actions:Python项目的CI/CD实践
python·ci/cd·github
matlabgoodboy2 小时前
Python代做java代码编写C++大数据R语言Hadoop/spark/flink/C语言
java·大数据·python
清水白石0082 小时前
《Python 编程全景解析:透视性能瓶颈——从基础测速到线上热点诊断的高阶实战》
开发语言·python
清水白石0082 小时前
Python 服务优雅停机实战:信号处理、资源收尾与 Kubernetes 滚动发布避坑指南
python·kubernetes·信号处理
gc_22992 小时前
学习python使用Ultralytics的YOLO26进行目标检测的基本用法
python·目标检测·yolo26