RAG 文档切分不是越细越好:选择 Chunk Size 与 Overlap

RAG 文档切分不是越细越好:选择 Chunk Size 与 Overlap

摘要:切分决定了检索系统能看见什么

RAG 项目中最容易被低估的步骤不是向量数据库选型,而是文档切分。原文进入模型之前先被拆成 Chunk,随后每个 Chunk 生成向量、参与召回并被拼入上下文。切得过大,一个块同时包含多个主题,向量被平均,检索命中后还携带大量无关内容;切得过小,定义、前提、例外和代码解释被分散,模型虽然找到关键词,却无法回答完整问题。

因此不存在一个适用于所有知识库的固定答案,例如"500 字加 50 字重叠"。Chunk Size、Overlap、TopK、Embedding 模型、文档结构和问题类型彼此耦合。合理做法是先保留文档语义结构,再设计少量候选策略,通过离线检索指标和端到端回答质量选择参数。

本文从业务问题、切分方案、父子块架构、代码与表格处理、Python 实现、元数据、权限、安全、评测和性能采集展开,给出一套不依赖虚构"最佳参数"的工程方法。

一、先定义 Chunk Size 到底按什么计算

"每块 500"如果不说明单位,没有比较价值。它可能表示 500 个 Unicode 字符、500 个空格分词、500 个模型 Token,也可能是解析器估算的字节数。中文一个字与一个 Token 并非恒等,代码、URL、数字和英文缩写的比例又不同。最终上下文受模型 Token 窗口约束,所以生产系统应以目标模型或近似 Tokenizer 的 Token 数作为主要预算,字符数只能用于快速预估。

还要区分三种长度:

  • 索引块长度:生成 Embedding 并参与向量召回的文本长度;
  • 返回块长度:检索命中后真正返回给编排层的文本长度;
  • 上下文长度:去重、扩展邻居、重排后送给大模型的总长度。

在最简单的系统中三者相同;在父子块方案中,小块用于索引,父章节用于返回;在窗口扩展方案中,一个小块命中后会补充前后块。讨论参数时必须说明是哪一层,否则团队会出现"索引块明明 300 Token,为何一次召回消耗了 5000 Token"的争论。

二、从文档结构开始,而不是先按长度切

PDF、Word、Markdown 和网页不是纯文本容器。标题层级表示章节边界,列表表示并列关系,表格的表头决定单元格含义,代码围栏保护语法结构,图片标题可能解释后文。若先把解析结果拼成一个长字符串再固定截断,后续再强的检索模型也无法恢复已经丢失的结构。

推荐的摄取流水线是:

  1. 解析原始文件,保留页码、标题、段落、列表、表格、代码块和图片说明;
  2. 清理页眉页脚、重复导航和乱码,但保留业务标识、错误码与单位;
  3. 按章节形成逻辑节点,计算每个节点的 Token 长度;
  4. 小节点在同一父章节内合并,超长节点按句子或语义边界递归拆分;
  5. 生成索引块、父块和相邻关系,并继承访问权限与版本信息;
  6. 生成 Embedding,写入向量索引和关键词索引;
  7. 用固定评测集验证后再发布索引版本。

结构优先并不意味着每个段落都独立成块。一个只有"注意事项"标题和一句"仅管理员可执行"的短段落,若脱离父章节会失去对象。合并时应限制在同一标题路径下,不要跨章节把两个短段落机械拼接。

三、常见切分方案与适用场景

3.1 固定字符或固定 Token 窗口

固定窗口实现简单、吞吐稳定,适合结构极差的日志、转写文本或早期基线。缺点是会从句子、表格或代码中间截断。它最适合用来建立"最低基准",而不是默认最终方案。

3.2 递归结构切分

先按标题、段落、句子等分隔符拆分,仍过长时再按 Token 截断。它能保留大部分人工结构,成本可控,是通用企业文档的推荐起点。不同语言的句末符号和 Markdown 结构必须分别处理,不能只按英文句号。

3.3 语义切分

计算相邻句向量差异,在主题突变处切分。它能发现没有标题的主题边界,但需要额外 Embedding 计算,阈值对文档类型敏感,也可能把"问题---答案"因语义差异较大而拆开。适合访谈、会议记录和长篇自然语言,不宜盲目替代结构解析。

3.4 父子块或 Small-to-Big

小块生成向量以提高定位精度,命中后返回父章节或更大窗口,兼顾召回和上下文。它适合制度手册、技术文档与代码解释,但要控制父块上限,并在多个子块命中同一父块时去重。

3.5 业务对象切分

FAQ 可以一问一答为一个对象,API 文档可以一个 endpoint 为单位,产品目录可以一个 SKU 为单位,法规可以一条款为单位。业务对象边界往往比通用算法更可靠。代价是需要领域解析器,文档格式变化时要维护。

工程上常用的不是单选,而是组合:先识别业务对象,再按标题递归拆分;超长自然语言节点用语义边界辅助;索引采用小块,返回采用父块。

四、Chunk Size、Overlap 与 TopK 的联动

小块通常主题更集中,精确问题更容易命中,但完整答案可能分布在多个块中,需要更大 TopK 或邻居扩展。大块上下文更完整,却会降低向量区分度,占用更多输入 Token,并让重排器处理更长文本。Overlap 能保留边界附近的语义,却会增加索引量和重复召回。

假设原文长度为 L,块大小为 S,重叠为 O,粗略块数可按 ceil((L - O) / (S - O)) 估算。当 O 接近 S 时,块数快速增加。重叠 20% 不只是增加约 20% 存储;它还可能让 TopK 被相似副本占满,最终上下文重复率明显升高。

参数调整需要成组进行。例如,把索引块从 600 Token 降到 250 Token,却保持 TopK=3,很可能召不全跨段答案;把 TopK 提到 12,又可能引入大量相邻重复。因此实验应至少记录:

变量 候选示例 观察重点
索引块 Token 200、400、700 Recall、主题纯度、块数量
Overlap 0、10%、20% 边界问题、重复率、索引成本
TopK 3、6、10 召回增益与上下文膨胀
返回策略 原块、邻居窗口、父块 完整性、噪声与延迟
Rerank 数量 0、20、50 排名质量和精排成本

表中的数字只是实验网格示例,不是推荐结论。最终值必须由自己的文档分布、模型和评测问题决定。

五、代码、表格和列表需要专门规则

代码块应尽量保持完整,并附带文件语言、类名、方法名和所属标题。一个方法超过上限时,可以按类、方法或语法树切分,而不是从任意行截断。索引文本可以在代码前加入短描述,例如"OrderService.cancel:取消未支付订单",提高自然语言查询的命中率;但描述必须可追溯,自动生成内容要标记来源,避免把模型幻觉当成代码事实。

表格不能只抽取单元格。每一行都必须继承表头和必要的多级表头,例如将"高级版 | 100 | 20GB"转成"套餐=高级版;月费=100 元;存储=20GB"。大表可以按行组切分,但每个块重复表头是有意义的结构冗余。涉及跨行合计的问题,返回时需要扩展到完整表或通过结构化查询解决。

列表要区分并列项与步骤。步骤列表被拆开后应保留序号和父标题,否则"执行第三步"没有上下文。定义列表应尽量把术语和解释放在同一块。OCR 文档还要保留页码和版面置信度;低置信度文本可进入人工校验队列,不能与高质量正文同等参与答案生成。

六、父子块数据模型与检索数据流

一个可用的 Chunk 至少应保存:

json 复制代码
{
  "chunkId": "doc-42:v7:c018",
  "documentId": "doc-42",
  "documentVersion": 7,
  "parentId": "doc-42:v7:s004",
  "sectionPath": ["部署手册", "网络配置", "代理设置"],
  "ordinal": 18,
  "tokenCount": 326,
  "contentHash": "sha256...",
  "sourcePage": 12,
  "acl": ["tenant:1001", "role:ops"],
  "validFrom": "2026-07-01T00:00:00Z"
}

查询时先用小块做向量与关键词召回,应用权限和文档版本过滤,再融合排名。若选择父块返回,则按 parentId 聚合子块分数,每个父块只保留一次;若采用邻居扩展,则按 ordinal 获取前后窗口。之后进行去重、精排和 Token 预算裁剪,最后把来源信息一起交给生成模型。

权限过滤应尽量发生在召回阶段,而不是把无权内容取出后再删除。后过滤可能导致 TopK 全被无权结果占满,同时向应用暴露了敏感文本。向量库不支持复杂 ACL 时,可以按租户分索引、预过滤候选 ID,或者扩大候选后严格后过滤并补召回,但必须评估隔离风险。

七、一个可测试的 Python 递归切分实现

下面示例体现三个原则:只在同一章节内合并;长度按注入的 Tokenizer 计算;Overlap 从上一块尾部选取完整句子。它不是针对所有格式的解析器,而是一个便于单元测试的核心切分器。

python 复制代码
from dataclasses import dataclass
from typing import Callable, Iterable


@dataclass(frozen=True)
class Segment:
    """
    最小文本片段单元(不可变数据类)
    代表原始文档经过初步切分后的基础文本单元,归属同一个章节路径
    :param section: 章节层级路径,例 ("第一章", "1.1"),用于区分不同章节,跨章节不能合并
    :param text: 当前片段文本内容
    :param ordinal: 全局序号,用于追踪Chunk由哪些原始Segment组成
    """
    section: tuple[str, ...]
    text: str
    ordinal: int


@dataclass(frozen=True)
class Chunk:
    """
    最终输出的文本块(RAG检索用chunk,不可变)
    由若干连续Segment拼接而成,携带溯源信息
    :param section: 所属章节路径,同一个Chunk内所有Segment章节必须一致
    :param text: 拼接完成后的chunk完整文本
    :param source_ordinals: 构成该chunk的所有Segment序号元组,用于溯源
    :param token_count: 当前chunk文本token数量
    """
    section: tuple[str, ...]
    text: str
    source_ordinals: tuple[int, ...]
    token_count: int


class RecursiveChunker:
    """
    支持章节隔离、滑动窗口重叠、超长片段递归拆分的文本分块器
    核心特性:
    1. 不同section(章节)的Segment不会合并到同一个Chunk
    2. 达到max_tokens时自动切分,并保留尾部overlap_tokens长度文本作为下一块重叠上下文
    3. 单个Segment文本超过max_tokens时,按句子二次拆分
    """
    def __init__(
        self,
        count_tokens: Callable[[str], int],
        max_tokens: int,
        overlap_tokens: int,
    ) -> None:
        """
        :param count_tokens: token计数函数,输入文本返回token数量(外部传入模型tokenizer)
        :param max_tokens: 单个Chunk允许的最大token上限
        :param overlap_tokens: 相邻Chunk之间保留的重叠上下文token数量
        """
        if max_tokens <= 0:
            raise ValueError("max_tokens must be positive")
        if overlap_tokens < 0 or overlap_tokens >= max_tokens:
            raise ValueError("overlap_tokens must be in [0, max_tokens)")
        self.count_tokens = count_tokens
        self.max_tokens = max_tokens
        self.overlap_tokens = overlap_tokens

    def split(self, segments: Iterable[Segment]) -> list[Chunk]:
        """
        入口方法:批量处理Segment,生成Chunk列表
        逻辑:按章节分组,同一章节Segment送入_flush执行滑动窗口分块;跨章节立即刷新组
        :param segments: 原始Segment迭代器
        :return: 切分完成的Chunk列表
        """
        chunks: list[Chunk] = []
        group: list[Segment] = []  # 存放当前同一章节的Segment组
        for segment in segments:
            # 章节发生变化:先把上一组所有Segment切分为Chunk,清空分组
            if group and group[-1].section != segment.section:
                chunks.extend(self._flush(group))
                group = []
            group.append(segment)
        # 处理最后一组剩余Segment
        chunks.extend(self._flush(group))
        return chunks

    def _flush(self, segments: list[Segment]) -> list[Chunk]:
        """
        对**同一章节**的一组Segment执行滑动窗口重叠分块主逻辑
        :param segments: 同章节连续Segment列表
        :return: 当前章节生成的Chunk列表
        """
        result: list[Chunk] = []
        current: list[Segment] = []  # 正在累积、待组成Chunk的片段

        for segment in segments:
            # 如果单个Segment超长,先拆成多个小片段
            pieces = self._split_oversized(segment)
            for piece in pieces:
                candidate = current + [piece]
                text = "\n".join(item.text for item in candidate)
                # 加入新片段后超出token上限 → 需要生成一块,并且构造重叠窗口
                if current and self.count_tokens(text) > self.max_tokens:
                    result.append(self._to_chunk(current))
                    # 截取尾部片段作为重叠上下文
                    current = self._tail_for_overlap(current)

                    # 保证重叠片段 + 当前piece依然不超限;超限则不断丢弃头部片段
                    while current:
                        overlapped = current + [piece]
                        overlapped_text = "\n".join(item.text for item in overlapped)
                        if self.count_tokens(overlapped_text) <= self.max_tokens:
                            break
                        current = current[1:]
                # 将当前片段加入累积队列
                current.append(piece)
        # 循环结束,把剩余未输出的片段打包成最后一个Chunk
        if current:
            result.append(self._to_chunk(current))
        return result

    def _split_oversized(self, segment: Segment) -> list[Segment]:
        """
        处理超长Segment:单段文本token超过max_tokens时,按句子拆分成多个小Segment
        :param segment: 原始超长片段
        :return: 拆分后的若干新Segment,继承原section、ordinal序号
        """
        if self.count_tokens(segment.text) <= self.max_tokens:
            return [segment]
        # 外部函数:文本切分为句子列表
        sentences = split_sentences(segment.text)
        # 外部函数:句子打包,保证每组token不超过上限
        return [
            Segment(segment.section, text, segment.ordinal)
            for text in pack_sentences(
                sentences, self.count_tokens, self.max_tokens
            )
        ]

    def _tail_for_overlap(self, items: list[Segment]) -> list[Segment]:
        """
        从当前Segment列表尾部截取文本,总token不超过overlap_tokens,用作下一块重叠上下文
        :param items: 当前组成Chunk的所有Segment
        :return: 尾部重叠片段列表
        """
        tail: list[Segment] = []
        # 逆序从末尾向前收集片段
        for item in reversed(items):
            candidate = [item] + tail
            text = "\n".join(value.text for value in candidate)
            # 叠加后超过重叠token上限,停止收集
            if self.count_tokens(text) > self.overlap_tokens:
                break
            tail = candidate
        return tail

    def _to_chunk(self, items: list[Segment]) -> Chunk:
        """
        将一组连续Segment组装成最终Chunk对象
        :param items: 构成一个Chunk的连续Segment
        :return: Chunk实例
        """
        text = "\n".join(item.text for item in items)
        return Chunk(
            section=items[0].section,
            text=text,
            source_ordinals=tuple(item.ordinal for item in items),
            token_count=self.count_tokens(text),
        )

split_sentencespack_sentences 应针对语言实现,并对单个超长句、URL、代码块设置兜底。若一个没有句号的日志行超过上限,最终仍要按 Token 硬切,否则算法可能生成超大块。刷新旧块后还必须重新检查"Overlap 尾部 + 新 piece",示例会从最旧的重叠句开始丢弃,直到组合重新落入 max_tokens;不能因为 Overlap 本身未超上限,就默认与下一块拼接后也未超限。单元测试需验证空输入、恰好上限、单段超长、章节切换、Overlap 上限、Overlap 与大 piece 组合以及 Unicode 文本。

生产实现还应生成稳定 Chunk ID。推荐使用 documentId + documentVersion + ordinal + contentHash,不要只用数据库自增 ID。这样索引重建时能识别内容是否真的变化,也能准确删除旧版本。

八、离线评测:先测检索,再测最终回答

评测集不能只由开发者随手写十个问题。应从搜索日志、客服问题、文档 FAQ 和领域专家中采集,并按类型分层:

  • 精确标识问题,如错误码、型号、接口名;
  • 同义改写问题,不直接出现文档关键词;
  • 需要同段多句才能回答的问题;
  • 跨相邻段或跨章节组合问题;
  • 表格、代码和列表问题;
  • 文档中不存在答案、应该拒答的问题;
  • 不同租户权限下结果应不同的问题。

每个问题标注相关文档与可接受 Chunk 集合。检索层可计算 Recall@K、MRR、NDCG、命中父章节率和无权结果率。生成层观察答案正确性、证据支持率、引用准确率、拒答准确率和上下文利用率。性能层记录 Embedding 数量、索引体积、检索 P95、Rerank P95、上下文 Token 与单次成本。

Recall@K 高不代表回答一定好:相关块可能排在最后,被 Token 裁剪;也可能多个重复块挤掉互补证据。最终回答正确率高也不能掩盖权限泄漏。因此要将检索、生成、安全和成本分层看待。

评测结果必须保存实验配置,包括解析器版本、Chunk 参数、Embedding 模型、索引版本、融合策略、Reranker、TopK 和 Prompt 版本。否则下周"同样参数"无法复现。没有真实测试环境时,可以给出指标定义和采集脚本,但不能编造某参数提升了多少百分点。

九、线上观测与性能优化

线上重点关注零结果率、低分结果率、查询改写次数、候选重复率、父块膨胀率、最终上下文 Token、引用点击或人工纠错,以及按文档类型拆分的延迟。不要把 query 原文直接作为指标标签,它既高基数又可能含敏感信息;原文进入受控日志或样本库前应脱敏。

切分越细,Embedding 数量、索引存储和更新成本越高。文档更新时可按 contentHash 增量重建,只对变化章节生成向量;发布新索引采用别名或版本指针原子切换,确认新版本健康后再回收旧索引。删除文档不仅要删向量,还要删关键词索引、父块存储和缓存。

Rerank 是常见延迟热点。可以并行执行向量与 BM25,融合后只对前几十个候选精排;设置严格超时,精排超时时退化到融合排名。父块读取适合批量查询,避免每个候选单独访问对象存储产生 N+1 请求。

十、异常、安全与数据治理边界

检索到的文档属于不可信数据,文档中可能包含"忽略系统指令"一类间接提示词注入。切分时保留来源和信任等级,生成阶段明确把内容作为参考资料,工具执行权限仍由确定性代码控制。不能因为内容来自企业知识库就默认可信,上传者账号也可能被盗。

文档解析失败要区分可重试和永久错误。对象存储超时可以重试;加密 PDF 缺少密码、格式损坏或无权限属于待人工处理。部分页面失败时不要悄悄标记整份文档"索引成功",应记录页级状态并阻止未完整版本成为正式索引,或明确展示"部分可用"。

个人信息、商业机密和保留期限必须从原文继承到 Chunk。日志、离线评测集和向量本身都可能泄露信息,不能只保护原文件。租户删除请求要能通过 documentId 定位所有派生数据并证明已清理。

十一、常见误区

第一个误区是块越小检索越准。小块可能让关键词命中更准,却破坏答案完整性,并增加 TopK 与索引成本。第二个误区是 Overlap 越大越安全。重叠过大会制造近似副本,让候选缺少多样性。第三个误区是只按字符切中文,却用 Token 控制模型上下文。

第四个误区是只评测最终回答。生成模型可能凭常识答对,掩盖检索失败;也可能检索正确却因 Prompt 或输出限制答错。第五个误区是把每个 PDF 页作为 Chunk。页面是排版单位,不一定是语义单位,段落还可能跨页。

第六个误区是只保存文本和向量。没有标题路径、版本、权限、页码和相邻关系,就无法做父子检索、引用、删除、调试和审计。第七个误区是一次调参后永久不变;文档类型、Embedding 模型与用户问题分布变化后,应重新跑固定评测集。

十二、延伸

如果被问"Chunk Size 怎么选",有说服力的回答不是背一个数字,而是说明:按 Token 计算,结构优先;设计 2 到 4 组候选;与 Overlap、TopK 和返回策略联动;分别评估检索 Recall、端到端质量、上下文 Token 和延迟;按代码、表格和跨章节问题分桶观察。

如果被问"父子块为什么有效",可以解释小块向量主题集中、便于命中,父块返回完整语境;代价是父块可能带来噪声和 Token 膨胀,需要聚合去重、长度上限和 Rerank。若问"如何处理更新",回答文档版本化、内容哈希增量构建、索引别名切换和派生数据删除链路。

进一步的问题是"语义切分一定优于递归切分吗"。答案是否定的:语义切分增加计算成本,阈值难统一,可能拆散业务对象;结构良好的文档通常优先使用标题和段落。它应由评测证明增益,而不是因算法听起来高级就默认采用。

十三、总结

RAG 切分的本质是为检索建立信息单元,而不是把长文本塞进固定长度容器。可靠方案从解析结构开始,区分索引块、返回块和最终上下文,针对代码与表格使用领域规则,并让 Chunk Size、Overlap、TopK、父子扩展和 Rerank 一起接受实验。

没有通用最佳参数,只有可复现的选择过程。把元数据、权限、版本、安全、离线评测和线上指标纳入同一条数据流,团队才能知道一次效果变化究竟来自文档、切分、检索还是生成,也才能在知识库持续增长时稳定演进。

相关推荐
互联网中的一颗神经元2 小时前
小白python入门 - 23. Python 正则表达式的应用
python·正则表达式
雪碧透心凉_2 小时前
while 循环与循环嵌套
开发语言·python
商业模式源码开发2 小时前
2026年GEO优化全解析:生成式引擎优化如何重构企业流量与品牌护城河
大数据·人工智能·ai
初学者,亦行者2 小时前
利用pyecharts自动化绘制漏斗图保姆级教程
python·信息可视化·数据分析
对讲机数码科普2 小时前
黑龙江应急救援单工通信保障方案设计与实战应用
大数据·网络·人工智能
这就是佬们吗2 小时前
Python入门⑤-异常处理、文件操作与实战项目
开发语言·数据库·python·算法·pycharm
SuperHeroWu72 小时前
【HarmonyOS AI】DevEco CLI、Skills、知识库运用AI Coding提效详解
人工智能·华为·harmonyos
Old Uncle Tom2 小时前
银行用户画像 -- 金融目标与需求意图
前端·人工智能·金融
码农学院2 小时前
AIO与GEO融合趋势:从内容生成到智能搜索优化的技术演进
人工智能