# 自定义 LangChain 文档分割器:面向业务结构切 Chunk

内置分割器能覆盖大多数通用文本:按字符、递归字符、Markdown 标题、HTML 标题、Token 或语义变化来切分。但真实业务里经常会遇到更强的结构要求:一段合同按条款切,一份工单按字段切,一段客服会话按轮次切,一份报表按章节与指标切。如果只调 chunk_size 和 chunk_overlap,可能会把完整业务单元拆散,导致检索命中后上下文缺少关键字段。

自定义分割器的核心并不复杂:继承 TextSplitter,在构造函数里接收业务参数,实现 split_text() 返回字符串列表。split_documents()、create_documents() 等文档级能力由父类处理,它会把字符串片段重新包装成 Document,并尽量保留 metadata。

# 为什么需要自定义分割器

自定义分割器适合三类场景。

第一类是文本有明确业务边界,例如订单、病历、日志、法规条款、接口说明、客服对话。这些内容的边界通常不是固定长度,而是字段、编号、标题、时间戳、角色标识或模板段落。

第二类是切分目标不是原文片段,而是提取后的结构化信息。例如把每段内容变成关键词,把段落变成摘要,把 HTML 节点变成纯文本块,把日志行变成按事件聚合的片段。

第三类是需要把下游检索策略前置到切分阶段。例如希望每个 chunk 都自带主题词、业务类型、时间范围、实体名,便于后续 metadata filter、rerank 或 Prompt 组织。

# TextSplitter 的最小实现

TextSplitter 的抽象点是 split_text(text: str) -> list[str]。只要这个方法返回字符串列表,父类就可以进一步把它们包装成 Document。

下面的示例按空行切分文本,然后用 jieba.analyse.extract_tags 为每段提取关键词。它不是为了替代常规 chunk,而是演示“输入一段文本,输出更适合检索的文本片段”这个思路。

from typing import List

import jieba.analyse
from langchain_community.document_loaders import UnstructuredFileLoader
from langchain_text_splitters import TextSplitter


class CustomTextSplitter(TextSplitter):
    """自定义文本分割器。"""

    def __init__(self, separator: str, top_k: int = 10, **kwargs):
        """传入分隔符和每段需要提取的关键词数量。"""
        super().__init__(**kwargs)
        self._separator = separator
        self._top_k = top_k

    def split_text(self, text: str) -> List[str]:
        """把文本切成段落,并把每段转换为关键词字符串。"""
        split_texts = text.split(self._separator)

        text_keywords = []
        for split_text in split_texts:
            text_keywords.append(jieba.analyse.extract_tags(split_text, self._top_k))

        return [",".join(keywords) for keywords in text_keywords]


loader = UnstructuredFileLoader("./科幻短篇.txt")
text_splitter = CustomTextSplitter("\n\n", 10)

documents = loader.load()
chunks = text_splitter.split_documents(documents)

for chunk in chunks:
    print(chunk.page_content)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

这里有几个点需要注意。

separator 是业务边界。示例里使用两个换行符,真实业务可以换成 ---、编号正则、HTML 节点边界、Markdown 标题、日志时间戳等。

top_k 是转换强度。关键词越少,chunk 越像主题标签;关键词越多,保留的信息越接近原文。用于召回时,过少会损失细节,过多又可能引入噪声。

split_documents() 不是自己实现的。父类会遍历文档,调用 split_text(),再生成新的 Document 列表。这意味着自定义分割器仍然可以接入 LangChain 的 Loader、VectorStore、Retriever 链路。

# 输出形态

这类分割器的输出不是原段落,而是每段的关键词串,例如:

飞船,星球,引擎,信号,坐标,能源,船员,未知,宇宙,基地
机器人,指令,记忆,核心,实验室,人类,系统,唤醒,协议,异常
城市,天空,光线,屏幕,居民,广播,能源,警报,道路,终端
1
2
3

把关键词作为 chunk 内容有一个明显优点:召回时更容易被主题词命中。缺点也很明显:原文信息被压缩,生成答案时可能缺少细节。因此它更适合做辅助索引、关键词索引或多路召回中的一路,而不是总是替代原文 chunk。

# RAG 分块策略

常见 RAG 分块可以分成四种。

固定大小分块:按字符数或 token 数切。优点是简单稳定,适合格式混乱、没有结构的文本。缺点是容易切断完整语义,比如一个接口说明被拆成请求参数和响应参数两段。

基于结构的分块:按 Markdown 标题、HTML 标题、JSON 字段、表格行列、章节编号等切。优点是保留文档本身的组织方式,适合规范文档、接口文档、手册和合同。缺点是遇到结构不规范的内容时,需要额外容错。

基于语义的分块:通过 embedding 或断点检测找主题变化。优点是更接近自然语义边界,适合长文章、报告、描述性文本。缺点是成本更高,并且稳定性依赖 embedding 模型。

递归分块:给出一组分隔符,先用高优先级边界切,超过大小再逐级下探。优点是在简单与语义完整之间取得平衡,所以工程上使用很广。缺点是参数需要结合具体语料调试。

如果这四类方法都无法得到稳定效果,就要重新判断:当前任务是否真的适合 RAG。比如高度结构化、规则固定、答案模板固定的场景,可能更适合结构化抽取、规则检索、数据库查询或微调,而不是继续无限调 chunk。

# 最新版 LangChain 用法提示

当前 Python 生态里,TextSplitter 位于 langchain_text_splitters 包。自定义分割器仍然建议继承 TextSplitter 并实现 split_text(),再复用父类的 split_documents()。

文档加载器通常来自 langchain_community.document_loaders 或具体集成包。Embedding 建议使用独立包,例如 langchain_openai.OpenAIEmbeddings。不要继续把所有组件都默认从旧的 langchain 顶层包导入。

如果业务逻辑已经不是“切文本”,而是“解析业务对象并生成多个 Document”,优先考虑自定义 Loader 或独立预处理函数。分割器应该专注于把一段文本拆成可检索片段,不要承担数据库读取、权限校验、远程 API 聚合等职责。

# 拓展

自定义分割器可以和 metadata 设计一起使用。比如每个 chunk 的 metadata 里保留 source、section、start_index、business_type、tenant_id、version。检索时先做 metadata filter,再做向量相似度,可以明显减少无关召回。

还可以构建双索引:一份索引存原文 chunk,用于答案生成;另一份索引存关键词、摘要或实体,用于扩大召回。查询时先在辅助索引找候选,再回到原文索引取完整上下文。

对中文文本,分词器、标点、标题层级和实体识别都很重要。固定字符数经常会切断中文业务术语,自定义分割器可以把中文句号、顿号、冒号、编号、括号标题纳入规则。

# 常见问题

自定义分割器一定比内置分割器好吗?

不是。内置分割器更稳定,维护成本更低。只有当业务边界明确、内置策略会破坏语义,或者需要输出特殊 chunk 形态时,才值得自定义。

关键词 chunk 能不能直接用于回答?

可以召回,但不适合单独生成答案。关键词缺少上下文和论证过程,最好把它当辅助召回信号,再关联回原文片段。

chunk 越小越好吗?

不是。chunk 太小会丢上下文,导致答案看起来相关但依据不足;chunk 太大又会召回噪声,增加成本。一般要结合命中率、答案准确率、上下文利用率一起评估。

# 面试题

为什么 RAG 系统里 chunk 策略会影响最终答案?

因为 Retriever 返回的是 chunk,不是完整文档。chunk 边界决定了证据是否完整、噪声是否过多、Prompt 是否能拿到必要上下文。

如何实现一个 LangChain 自定义 TextSplitter?

继承 TextSplitter,在构造函数接收业务参数,实现 split_text() 返回字符串列表,然后通过 split_documents() 处理 Document 列表。

固定分块、结构分块、语义分块和递归分块怎么选?

无结构文本先用递归或固定分块;规范文档优先结构分块;长篇叙述或主题变化明显的文本可以考虑语义分块;业务对象强结构场景优先自定义。

# 生产问题排查

问题 常见原因 处理方式
召回片段主题正确但无法回答 chunk 只有关键词或摘要,缺少原文细节 建立原文索引,关键词索引用于候选扩展
相邻字段被拆散 分隔符优先级不合理,或 chunk_size 过小 按业务结构切分,必要时把字段组合成完整业务单元
召回结果重复 overlap 过大或同一段被多路索引重复写入 控制 overlap,写入前加文档 hash 和 chunk hash
召回混入其他租户数据 metadata 过滤缺失 强制 tenant_id、dataset_id 过滤,并做权限测试
更新后仍命中旧内容 索引未重建,缓存未按版本隔离 使用文档版本、更新时间、hash 做增量更新