夜雨聆风学习资料网

ARTICLE · 1103735

文档预处理与分块

文档预处理与分块

上一章我们把 RAG 的演进史从朴素检索一路捋到了多阶段流水线,最后落在「索引构建」这四个字上。这一章开始进入具体的工程问题。

先设想一个真实的场景。某核电厂要建一个规程问答的知识库,桌面上摊着的原材料是三十年积累下来的运行规程、技术导则、缺陷记录与分析报告:绝大多数是扫描件和双栏排版的 PDF,一部分是从文档系统导出的 Word,还有一批是手写批注过的纸质件归档扫描。第一道要回答的问题很朴素——这些文件,怎么变成机器能检索的东西?

答案是四道工序:解析、清洗、分块、元数据挂载。它们有一个共同特点:位于整条流水线的最上游,而上游的错误几乎无法被下游补救。嵌入模型再强,也读不出被两段文字黏在一起的乱码里藏着哪个限值;重排序模型再准,也救不回一个被页眉、页码、水印糊满的块。工程上有个不太舒服的经验:RAG 项目效果不好,问题绝大多数出在这四道工序上,而不是出在模型上——大家习惯性地怀疑检索算法,却很少回头去看那份被解析得一塌糊涂的原文。

💡

一句话概括本章的核心判断

分块不是把文本切碎,而是替检索做两个决定:检索时允许命中什么,返回时能带出什么上下文。块太大,命中了却不知道该答哪一段;块太小,找得到句子却拼不出结论。所有分块参数的争论,本质上都是在这两个决定之间找平衡。

4.1 文档加载与解析

解析要回答的问题是:一份原始文件里,哪些是正文,哪些是版式噪声,哪些是结构信息。判断错了,后面清洗和分块就会在一个错误的地基上继续盖楼。核电厂的规程文档尤其如此——它们的结构信息密度极高(章、节、条、款、项层层嵌套,条款编号本身就是检索的主键),而版式噪声也极高(每页都有页眉页脚、页码、水印、装订痕迹)。这一节先把解析阶段的常见问题摊开。

4.1.1 四道工序:一张流水线地图

先建立整体视图。解析、清洗、分块、元数据挂载是一条单向流水线,每一步的输出是下一步的输入,任何一步失败都会沿着链路向下传导。

图 4-1 文档预处理的四道工序与三类典型失败点(解析→清洗→分块→元数据挂载)

从图 4-1 可以看出三件事。第一,工序是有依赖方向的:先还原结构,再清洗噪声,最后才能按语义边界切块——顺序反了,先切块再清洗,就没法判断某段文字是页眉还是正文。第二,每道工序都有各自典型的失败形态,而且失败形态互不相同,这意味着不能用一套指标衡量整条流水线。第三,也是最容易被忽略的:每道工序的中间产物都应该落盘保留。出了问题,你要能拿原始文件重跑一遍,清洗后的中间结果和原页并排比对,而不是每次都从头猜。

4.1.2 格式决定解析路径

不同格式的坑完全不同,解析路径也就完全不同。行业里处理这几类文件的常见工具包括:PyMuPDF 与 pdfplumber 负责 PDF 的文本与版面提取,Apache Tika 负责多格式统一入口和 MIME 识别,Unstructured 之类的工具做多格式的分区(partition)处理,OCR 环节常见的是 Tesseract;上层框架里 LangChain 和 LlamaIndex 各自提供了一套文档加载器与文本分割器。下面这张表把常见格式的难点和应对方式摆出来。

格式
主要难点
常见解析路径
核电文档的额外风险
原生 PDF
版面复杂,表格与图片混排
PyMuPDF / pdfplumber 逐页取文本并还原版面
双栏排版被串成一行,表格单元格错位
扫描件 PDF
没有文本层,只有一张张图像
渲染为图像后走 OCR(Tesseract 等)
手写批注、印章遮挡、限值数字被认错
Word 文档
样式、批注、修订痕迹混入正文
按段落与标题样式读取,剥离批注域
修订标记未接受就被入库,出现新旧两套说法
HTML / 网页
导航、广告、脚本噪声
正文抽取加标签清理
正文由脚本动态渲染,抽取结果为空
Markdown / 纯文本
结构已经比较干净
按标题层级直接切
人工整理稿与正式发布版并存,口径不一
数据库 / 记录流
字段值不是自然语言
字段拼成模板句再分块
一条完整记录被拦腰截断,前提条件丢失

表格里最后一行值得多说一句。RAG 的输入不一定是文档,也可以是工单、缺陷记录、设备台账这些结构化数据。它们的字段是「主泵编号 / 报警码 / 发生时间 / 现象描述」,直接拿去嵌入是没有意义的——四个孤立的字段值在向量空间里彼此挨得很近,却回答不了「这台泵当时是什么状态」。行业典型做法是先把一条记录拼成一句完整的模板句(「编号为 XXX 的主泵在 X 年 X 月 X 日出现报警码 YYY,现场现象为……」),让句子本身携带主语和谓语,再送去做分块。这个转换看起来朴素,但它决定了结构化数据能不能被语义检索命中。

顺带说一句路线差异:知识图谱路线处理的是同一批原始材料,但目标不是切块而是抽取实体与关系。想了解那条路线怎么从规程里把「设备—参数—限值」这种结构化出来,可以对照看 [第4章 知识获取](/knowledge-engineering/04-知识获取)。

4.1.3 扫描件:没有文本层的 PDF

核电厂三十年积累的规程里,相当一部分是扫描件。扫描件 PDF 表面上和普通 PDF 一样,解析器打开也能读,但 get_text() 拿回来的是空的——因为文件里根本没有文字,只有图像。这时候必须走 OCR:把每页渲染成图片,识别成文字,再往下走。

关键在于要先判断「这份文件有没有文本层」,而不是对所有文件一股脑上 OCR。OCR 贵,而且会引入新的错误源。

def has_text_layer(path: str, sample_pages: int = 10) -> bool:     """抽样判断 PDF 是原生文档还是扫描件,决定要不要走 OCR。"""     with fitz.open(path) as doc:         pages = list(range(0, min(sample_pages, doc.page_count)))         for i in pages:             page = doc.load_page(i)             if page.get_text("text").strip():                 return True     return False  # 返回 False 只用于路由,不用于下结论:加密件、纯图纸页、 # 字体未嵌入的文档都可能落到这一支,仍需人工抽查确认。

⚠️

OCR 的错误会一路传到答案里,而且是那种看不出来的错误

文字识别错一个汉字,模型照样能把它向量化、检索出来、生成一段流畅的答案——错误因此变得极难发现。但限值不一样。限值里的小数点被识别错位,数值就可能差一个数量级;「报警」错成「报井」,是设备术语的彻底失真。这两类错误在核电厂的规程里都可能直接导致现场按错误数据操作。行业里通行的兜底做法是:对含数字和小数点的参数表页、限值表页做人工抽检,抽检比例宁可高一些。

OCR 的另一个坑是版面还原。Tesseract 之类的识别器输出的是「文本块 + 坐标」,你要自己决定怎么把这些块拼回阅读顺序。双栏排版的文档如果按先左后右拼,整段文字会变成左右两栏文字交替出现的乱句——而这种乱句在肉眼看的时候几乎察觉不到,只有当你抽出来读才会发现。解析器(如 PyMuPDF、pdfplumber)提供的坐标信息在这里很有用:可以根据块的横坐标聚类成两列,再按列依次拼接。

4.1.4 表格与图注:最容易被丢掉的高价值内容

在核电厂的规程文档里,表格的信息密度往往高于正文。一张限值表、参数表、检查表,几十行数字,条条都是现场要用的判据。但绝大多数朴素解析路径在处理表格时会做同一件事:把表格压平成一串没有结构的文字。压平之后,「参数名 / 数值 / 单位 / 适用工况」这四列之间的对应关系就彻底消失了,变成一句「主泵振动 限值 单位 稳态 额定功率」这样读起来莫名其妙的话。

💬

让参数表重新可检索的三种做法

- 整表一块 + 表头前置:把表名和表头复制到表格文本之前,让每一行都带着列名。这样「主泵振动限值是多少」这种问法能命中表格块。 

    • 行展开
      :把表格拆成「一行一句话」,每句重复列名。代价是块数量变多、索引变大,收益是任意一行都能被单独检索到。
    • 表结构序列化
    • :用 Markdown 管道表或 HTML 表格原样保留,前提是下游的嵌入模型对结构化文本处理得好——这一点需要在自己的语料上实测,不能想当然。

    同一节里还有一个容易被忽略的对象:图注与图内文字。流程图、系统图、逻辑框图里的文字,恰恰是「判断链条」最密集的地方。某核电厂的规程里有大量「异常处置流程图」,图上每个方框里都是动作词——确认、隔离、汇报、禁止手动复位。这些词散落在图里,纯文本抽取时经常被整个丢掉。但如果把图注文字和它所属的条款正文拼在一起保留,它就变成了极高价值的检索素材:用户问「主泵联锁跳闸后能不能手动复位」,答案就藏在那张流程图里。

    还有一处:跨页断字。装订版和双面印刷的扫描件里,一个词可能被分页切成两半,两半之间还夹着页码。不做处理的话,块里会留下半个词,下游做关键词检索时这个词永远匹配不上。

    4.1.5 版次与作废标记:必须在解析阶段抓住

    页眉页脚既是噪声,也是信息源。这一点在核电文档里体现得特别明显:一本规程的页眉通常形如「某核电厂运行规程 A/1 第 128 页」,里面同时包含了文档编号、版次和页码。这行字每页重复,如果按第 4.2 节的思路直接删掉,版次信息也就跟着没了。

    正确的顺序是:先从页眉里抽出文档级元数据(文档编号、版次、生效日期、页码),存下来;再把页眉从正文中删除。这两件事的顺序不能颠倒。

    🚨

    核电红线:作废版本必须隔离,不能只靠排序

    一份规程通常有多个版次,初版、修订版、再修订版并存的情况很常见。如果把所有版次都灌进同一个索引、而不做版次过滤,后果不是「答得差一点」,而是答出一个看起来完全合理的错误答案:现场问「主泵振动限值是多少」,系统从初版里召回一个早已作废的数值,然后自信地给出答案——格式规整、语气笃定、标注了条款号,人工复核时很容易一眼扫过去。这是本章唯一一个需要用红线标注的问题。 

    防线有三道:入库前按版次和状态字段做物理隔离或强制元数据过滤;答案输出时必须标注所引用的版次;作废版本单独存放,默认不参与检索。真要连版次一起做结构化,那是 [第6章 知识库组织与管理](/rag/06-知识库组织与管理) 的话题,但过滤字段必须在分块这一阶段就挂上去,事后补是补不上的。

    这里要说清楚一个诚实的边界:不是所有文档系统都能给出「现行/作废」的状态字段。拿不到状态字段时,只能靠页眉版次规则和人工确认来推断版本有效性。这个推断过程本身要被记录下来并由运行部门确认,不能默默地在代码里写一条 if version == latest 就了事。

    4.1.6 解析质量要能被验证

    解析质量不能靠感觉。很多团队的做法是:解析完直接入库,测了问答效果不好,然后一头扎进检索算法和模型调优里。问题是,如果解析阶段就把两栏正文串成了一行、或者把一半的表格丢掉了,那么后面所有的调优都是在垃圾上做优化。

    可落地的验证手段有五种,成本都不高:

    • 抽样人审
      :随机抽若干页,把解析结果和原页并排放,逐段核对。这是唯一能发现「语义乱序」的方法,统计指标发现不了。
    • 每页字符数分布
      :明显偏低可能意味着整页没解析出来(纯图纸页、扫描页漏走 OCR);明显偏高可能意味着双栏串行或者重复抽取。画出直方图,异常页挑出来看。
    • 条款号连续性检查
      :把解析结果里所有条款号抽出来排序,看能不能拼回一条连续的编号序列。缺号就是漏解析,多号或者顺序错乱就是串行。这是核电厂规程文档最有效的一条自动检查。
    • 表格行列一致性
      :记录每张表解析出的行数,与原表人工核对的行数比对,偏差过大的表单独标记。
    • 解析器版本留档
      :换解析器、调参数之后,要能回答「哪些块需要重算」。这依赖元数据里记录解析配置,细节在 [4.4.3 节](#443-元数据让块可过滤可引用可审计)。

    到这里,解析阶段该解决的问题都摆出来了。但解析出来的文本还是「带着版式噪声的原始文本」——页眉残留在正文里,空白乱成一团,跨页断字还在。下一步要做的是文本清洗。清洗这一步的判断难度不比解析低,因为它要求你区分「看起来像噪声的正文」和「看起来像正文的噪声」。

    4.2 文本清洗

    清洗的目标只有一句话:让下游的分块和检索看到干净的正文,同时不丢失任何有语义的内容。听起来简单,做起来处处是取舍。核电厂的规程文本尤其敏感——清理掉的每一个字都可能是某条判据的一部分,清理错一个字就是判据错了。

    4.2.1 噪声清单:哪些该删,哪些不敢删

    先做一张清单,把「可以放心删」和「删之前要三思」分开。左边这一列是行业里公认可以自动清理的,右边这一列看着像噪声、实际可能是内容。

    放心清理
    谨慎处理
    连续空格与空行、缩进残留
    页眉页脚(可能带版次与页码信息)
    孤立重复标点、乱码控制字符
    短行(可能是标题、条款号或图注)
    HTML 标签、脚本、样式块
    空行分隔的短段落(可能是独立的条款或表格行)
    Word 批注域、修订标记残留
    「见表 6-3」「如图 4-1 所示」这类引用(指向别处的关键锚点)
    OCR 产生的孤立噪点字符
    缩进与空格(可能编码了表格的列对齐关系)
    装订线附近的边缘字符
    公式中的下标、上下标(清洗可能破坏其语义)

    右边这一列的共同点是:它们在统计上很像噪声,但在语义上有价值。所以清洗规则不能只写「删掉看起来重复的行」,而要带上明确的判定条件。

    4.2.2 页眉页脚:为什么不能一刀切

    页眉页脚是最典型的「不能一刀切」。最直觉的做法是「删除每一页的第一行和最后一行」,问题是有些页的第一行是真标题(章节首页经常这样排版),这一删就把标题删了,而且删得毫无痕迹。

    更稳的做法是基于文档整体统计来判定:

    • 收集每一页的首行和末行,统计出现频次。
    • 出现在超过半数页面上的相同文本,才判为页眉/页脚。
    • 判定为页眉后,先抽取其中的文档编号、版次、页码存为元数据,再从正文中删除(见 [4.1.5 节](#415-版次与作废标记必须在解析阶段抓住))。
    • 章节首页往往没有页眉,这类页要靠「首行是否为已知页眉文本」来排除,而不是靠位置。

    这个方法的额外好处是:它天然地适配了混合文档。一份扫描装订的规程,前面有手动加的封面、没有页眉,后面有标准页眉;统计规则能自动区分,不需要为每份文档写特例。

    4.2.3 清洗必须幂等而且可回滚

    🔑

    清洗是最容易出事的一步,因为它的错误不可见

    删除操作的特点是:做对了看不出来,做错了当场也看不出来。页眉被当成正文删掉,块里的内容少了三行,检索照样能跑,答案照样生成,只有对照原文才发现少了东西。所以清洗规则必须满足三个条件: 

      • 幂等
        :同样的输入跑两次,结果必须完全一样。否则「重跑一遍补数据」这种常规操作会引入无法预测的差异。
      • 可回滚
        :原始解析结果必须完整保留,清洗只产出新副本,绝不原地覆盖。出了问题能立刻退回上一版对比。
      • 可审计
      • :每条规则写进配置而不是硬编码在代码里,规则改了什么、什么时候改的、谁批的,都要有记录。这在核电厂是有实际意义的——知识库的内容变更需要有据可查。

      4.2.4 清洗顺序与边界

      清洗的顺序会改变结果,这一点很少被写进文档但必须知道。按下面的顺序走,前面步骤依赖的结构信息才不会在后面被破坏:

      1

      先抽结构再洗文本

      表格、条款号、章节标题先识别出来并标成结构对象。这一步做完之前,不要做任何字符级的替换,否则会把结构标记一起洗掉。

      2

      页眉页脚按统计规则剔除

      基于全文档出现频次判定,并在删除前把版次、页码等信息抽成元数据。注意:这一条必须排在「按位置删首末行」之前。

      3

      跨页断字与连字符修复

      处理被分页切断的词、扫描造成的字间多余空格。这一步依赖页码信息,所以要在页码抽取之后做。

      4

      空白与缩进归一

      合并连续空格、压缩空行、统一缩进。表格已经结构化,缩进的语义价值被提取走了,这一步才可以放心做。

      5

      术语与符号的最后统一

      全半角标点统一、同义词归一。这一步最危险,因为核电厂术语不能想当然地替换,放到最后是为了让前面的错误更容易被发现。

      📌

      把清洗和检索分开调

      一个常见的团队误区是:检索效果不好,就去加强清洗——删更多「噪声」、合并更多行、把文本变得更「干净」。方向反了。清洗解决的是「文本本身是否正确」,检索效果解决的是「怎么找到正确的块」。如果分块策略不对,删掉再多页眉也救不回来。正确的排查顺序是:先看块对不对(分块),再看块干不干净(清洗),最后才轮到检索算法。

      清洗做完,文本就干净了。但干净只是入场券——真正决定检索质量的一步,是把这些段落切成块。块切得好不好,直接决定了用户问一句话时系统能捞回什么;块切错了,后面再强的嵌入模型、再准的重排序,也组装不出正确答案。

      4.3 分块策略

      分块是本章最核心、也是最容易被低估的一步。检索的召回、重排的精排、生成的组装,全都作用在「块」这个单位上。块是检索的最小货币单位,一块切得不好,误差会在这条链路上被逐级放大。

      4.3.1 策略全景与选型

      常见做法可以归成四大类。固定长度按字符或 token 硬切;固定长度加重叠在固定长度基础上让相邻块共享一段文字;语义分块在句间相似度的低谷处切;结构分块按标题、条款号、表格边界切。工程上还常在结构分块之上叠一层层次(父子)分块,用小块建索引、用大块做返回单元。

      策略
      切分依据
      主要优点
      主要缺点
      适用场景
      固定长度
      按字符或 token 硬切
      实现最简单,速度快,结果完全可复现
      可能拦腰截断句子与条款,边界生硬
      快速原型、语料规整的说明文
      固定长度 + 重叠
      滑动窗口,保留上一块尾部
      缓解边界截断,实现同样简单
      索引冗余上升,重复内容会挤占 Top-K 名额
      绝大多数无结构语料的默认起点
      语义分块
      句间相似度的低谷处切开
      块内话题一致,命中更聚焦
      每篇文档多一次向量化计算,阈值需按语料调
      论述性长文、访谈纪要、报告正文
      结构分块
      按标题、条款号、表格边界切
      与文档逻辑对齐,引用能标注条款号
      强依赖解析质量,扫描件必须先过 OCR
      规程、标准、合同、技术报告
      父子 / 层次分块
      小块建索引,父块做返回单元
      命中精准且上下文完整
      索引体积与去重逻辑都更复杂
      长章节为主的正式知识库
      表格专用块
      整表一块,表头前置或行展开
      限值表、参数表能被整体召回
      块内容偏大,向量被数字稀释
      限值表、参数表、检查表

      🔑

      选型不是选「最好的那个」,是选「你的语料能撑住的那个」

      结构分块听起来最理想,但它是六种策略里对解析质量依赖最重的一种——解析不出条款号,结构分块就退化成固定长度分块;解析错位,结构分块还会把错误的边界忠实地保留下来。语义分块听起来最聪明,但它的阈值没有通用值,而且每篇文档都要额外做一次句级向量化,批量入库时这笔成本要算清楚。行业里最稳的起步方式是:先上固定长度加重叠把链路跑通,建立起可回归的评测集;然后按评测结果,一段一段地升级到结构分块。 直接从结构分块起步,很容易在解析还没稳定的时候把锅算到分块头上。

      4.3.2 块大小的双向失效

      块大小是第一个要调的参数,也是最容易调错的一个。它往两边都会坏。

      图 4-2 块大小的双向失效(大块丢定位 / 小块丢上下文)

      图 4-2 里的三行值得逐条展开。

      块过大的真正代价是「定位退化」。它不表现为检索不到,而表现为检索到一堆不对的东西。向量模型把一个块编码成一个方向,一个块里混了三件事,这三件事在这个方向上被平均掉了。用户问第三件事,模型照样能召回这个块——因为它确实包含第三件事——但返回给生成模型的上下文里,混着另外两件无关内容。生成模型很可能顺着无关内容编出一个逻辑自洽但事实错误的答案。这类错误比召回到无关文档更难被发现,因为表面上「有引用」。块过小的代价是「组装失败」。这个反而好发现一些:答案总是缺关键信息,或者需要靠模型自己脑补。核电厂的规程文本里这种情况特别明显——「应满足下列条件之一:」「其取值不得低于前一版本规定的限值」这类句子,条件项和结论项被切到不同块里,模型只能猜。  中间那段「合适」不是一条可以照抄的数值。它是「块内只讲一件事、句子完整、上下文要素齐备」这个性质。落到具体数字上,核电厂的常见工程起点是几百字符量级的块,英文文档则是几百 token 量级。这个数字一定会随语料变化:条款平均长度短(大量短条款),块就该跟着小;文档满是长段落论述,块就得跟着大。  

      4.3.3 固定长度加重叠:多数项目的默认起点

      固定长度是所有策略里最容易实现、最容易复现、最好回归的。它的问题只有一个:会在任意位置切断句子。补救办法是回退到句末标点,再加上重叠。

      def fixed_chunk(text: str, size: int = 400, overlap: int = 60) -> list[str]:     """按字符滑动窗口切块;size 与 overlap 按字符计,中文友好。"""     if overlap >= size:         raise ValueError("重叠长度必须小于块长度,否则无法前进")     chunks, start = [], 0     while start < len(text):         end = min(start + size, len(text))         piece = text[start:end]         # 回退到最近的句末标点,避免把句子拦腰截断         if end < len(text):             for mark in ("。", ";", "!", "?", "\n"):                 cut = piece.rfind(mark)                 if cut > size * 0.5:                     end = start + cut + 1                     break         chunks.append(text[start:end].strip())         start = end - overlap if end < len(text) else end     return chunks

      这段代码里有三个细节值得注意,它们都是踩过坑之后加上的:

      • 回退到句末标点的那一步有个下限
        (cut > size * 0.5)。如果不加这个下限,遇到一段几百字没有标点的长句或者表格串,回退会一路退到块开头,等于没切分。宁可切在奇怪的位置,也不要产生一个超长块。
      • 重叠长度必须小于块长度
        ,否则 start = end - overlap 不前进,死循环。这类边界条件在测试里一定要覆盖。
      • 按字符而不是按 token 计长度
        ,对中文语料更直观。代价是它不知道实际 token 数,块的实际 token 长度会有波动。如果你的嵌入模型有明确的上下文上限,需要额外做一次 token 计数校验。

      size 和 overlap 这两个数字怎么定?[4.3.6 节](#436-块大小怎么定别信经验值) 会专门讲。先记住一件事:这两个参数是评测集调出来的,不是抄来的。

      4.3.4 语义分块:在相似度低谷处切

      语义分块的思路很直白:把文档切成句子,逐句算向量,算相邻两句的相似度;相似度骤降,说明话题切换了,就在那里切。实现上是同一条流水线,只是把「按字符数切」换成「按相似度低谷切」。

      图 4-3 语义分块:按句间相似度的低谷切分(话题切换处即块边界)

      对应的实现骨架是这样的,embed 和 cosine 复用第 5 章要讲的嵌入模型与相似度度量:

      def semantic_chunk(sentences: list[str], threshold: float = 0.45,                    min_len: int = 120) -> list[str]:     """在句间相似度的低谷处切块。threshold 必须按语料实测,不是通用常数。"""     emb = embed(sentences)                       # 句级向量化,复用第 5 章的嵌入模型     sims = [float(cosine(emb[i - 1], emb[i])) for i in range(1, len(emb))]     chunks, buf = [], [sentences[0]]     for i, sim in enumerate(sims):         buf.append(sentences[i + 1])         # 相似度跌破阈值,或当前块已经够长,就收束         if sim < threshold or len("".join(buf)) >= min_len * 2:             chunks.append("".join(buf))             buf = [sentences[i + 1]]     if buf:         chunks.append("".join(buf))     return chunks

      语义分块有两个必须知道的限制。

      第一个限制是阈值不可迁移。上面 0.45 这个数字只是骨架里的占位值。不同的嵌入模型、不同的相似度度量、不同的语料,相似度的分布完全不同:一个在中文通用语料上训练好的模型,面对全是设备编号和参数的规程文本,相似度可能整体偏低,阈值就得跟着下调。正确做法是先把一份文档的相似度分布画出来(就是图 4-3 那条曲线),再决定阈值落在哪里。

      第二个限制是它对文档结构视而不见。如果一段话里恰好有两个相邻句子在讲同一件事,相似度就高,切点被推迟,块就变长了;如果一段话里每一句都换了说法但其实在讲同一条判据,相似度就低,块被切碎。语义分块和结构分块解决的是不同维度的问题,最好的结果往往是两者结合:先用结构把文档分成大段,再在大段内部用语义分块细分。

      顺带说一句,同一件事在长上下文路线里叫「文档理解」——让模型直接读整篇文档并抽取结构化信息,绕过切块这个环节。那条路线的思路和取舍,值得对照看 [第15章 文档分析与理解](/long-context/15-文档分析与理解)。

      4.3.5 结构分块:条款号就是天然的边界

      对于核电厂规程、标准、合同这类文档,结构分块几乎是最优解。理由很简单:这类文档本来就有明确的、由编制人精心设计过的语义边界,任务只是把它们识别出来,而不是凭空创造。

      一条典型的规程条款长这样:

      6.3.2 主泵机械密封应按下列周期进行状态监测: (1)启堆前,应对密封进行目视检查,确认无泄漏痕迹; (2)机组功率超过额定功率百分之八十后,每两小时记录一次密封泄漏量; (3)任一记录值超过本规程附录 B 规定的限值时,应按 6.3.5 的规定处置。

      这一条有三个天然边界:条款号 6.3.2 是起点,(3) 的结束是终点,而 附录 B 和 6.3.5 是需要跟着一起走、不能被切掉的引用。结构分块要做的事情,就是按条款号正则起切、把续行并入本条款、并且把被引用的附录和条款号记进元数据(后面查询时好去展开)。

      CLAUSE = re.compile(r"^(\d+(?:\.\d+)+)\s+(\S.*)$")   # 形如 6.3.2 主泵机械密封应…… HEAD = re.compile(r"^第[一二三四五六七八九十百]+[章节]")  def clause_chunk(lines: list[str]) -> list[dict]:     """按条款号切块,同时把章节路径与条款编号挂到每个块的元数据上。"""     out, cur, path = [], None, []     for raw in lines:         line = raw.rstrip()         if not line.strip():             continue         m = CLAUSE.match(line)         if m:                                   # 遇到新条款,收束上一块             if cur:                 out.append(cur)             cur = {"no": m.group(1), "heading_path": list(path), "text": line}         elif HEAD.match(line):             path = [line.strip()] + path[:2]     # 章节路径只保留有限层级         elif cur:             cur["text"] += "\n" + line          # 续行、表格、图注并入本条款     if cur:         out.append(cur)     return out

      这段代码里有两个工程上容易忽略的取舍。

      第一,章节路径只保留有限层级。 全路径「第 6 章 设备管理 / 6.3 泵组 / 6.3.2 主泵机械密封状态监测」当然最完整,但它会出现在每个块的元数据里,索引体积会随之膨胀。实际做法是保留两到三级——足以让检索结果按章节分组展示就行,完整路径可以从文档结构表里回溯。  第二,续行并入本条款。 这一行 cur["text"] += "\n" + line 看起来朴素,却是结构分块区别于固定长度分块的核心:固定长度分块在字符数到顶时一定会切走,结构分块则在遇到下一个条款号时才切。一条 200 字的条款和一条 2000 字的条款在这里会被区别对待——这正是我们想要的。  

      结构分块也有它的坑。条款号格式不统一是常态:有的用 6.3.2,有的用 6.3.2.1,有的用中文数字 六点三,有的把编号放在一个小方框里或者用制表符对齐。这些都要靠额外的规则去归一,而每加一条规则就多一个出错的地方。所以正则规则必须配一套测试用例,把文档里真实出现过的编号格式都覆盖进去,改规则时跑一遍。

      4.3.6 块大小怎么定:别信经验值

      ⚠️

      关于「最佳块大小」,唯一诚实的回答是:取决于你的语料

      任何声称给出了通用最优块大小的说法,都应当被怀疑。块大小同时受四个变量牵制:条款的平均长度(短条款多的文档,块自然要小)、嵌入模型的上下文上限(块不能大到被截断)、生成侧的上下文预算(块大了,返回时塞不进提示词)、以及表格密度(表格一多,纯文本块会被数字淹没,语义和数字的关系就断了)。 

      可落地的做法只有一条:准备几十条来自真实岗位的提问作为固定评测集,固定住嵌入模型和检索参数,只改块大小和重叠比例,每改一版就跑一次完整评测,看召回和最终答案质量。参数是测出来的,不是抄来的。 抄来的那组数字在别人的语料上可能是最优的,在你的规程上可能让一半的条款被从中间切断。

      补一个容易被忽略的校验项:块的实际 token 长度要在入库前统计一遍。按字符切的块,token 长度波动可能很大(数字和英文缩写密集的段落,token 数远高于同等字符数的中文散文)。如果有块超过了嵌入模型或生成模型的上下文上限,它在后续环节会被静默截断——表现为「答案说到一半没了」,而排查方向往往会跑偏到提示词上。

      到这里,块切出来了。但「切出来」只是第一步:块与块之间有缝隙,块与文档之间有层级,块与来源之间有身份。这三件事,就是分块优化要解决的。

      4.4 分块优化

      前三节解决的是「切得对不对」,这一节解决的是「切完之后还能不能补救」。分块优化的本质,是承认第一次切分不可能完美,然后用结构化的手段把漏掉的东西补回来。

      4.4.1 重叠分块:给边界留一条缝

      固定长度切分有一个隐藏的失败模式:一条完整的信息横跨两个块,而且是在语义上被拆开的位置上拆的。比如「主泵振动限值由附录 B 规定」这句话,如果「限值为某个具体数值」正好在块边界上,那么块一里只有半句,块二里只有后半句。两个块都检索不到完整的答案——因为没有哪个块包含完整语义,而用户的问法更接近完整的那句话。

      重叠就是为这个缝隙设计的:让块 N 保留块 N-1 结尾的一段文字。这样,跨边界的信息在两个块里都完整出现,至少有一个能被检索到。

      图 4-4 两种补边界漏洞的手段:重叠分块(时间维)与父子块(结构维)

      图 4-4 上半部分是重叠的机制,下半部分讲的是层次分块,两者解决的是同一个问题的不同侧面。

      重叠的代价同样真实,而且是必须主动管理的:

      • 索引体积上升
        。重叠比例是块大小的百分之十到二十时,索引会相应膨胀。这个代价好算,也容易接受。
      • 重复内容挤占 Top-K 名额
        。这一条更麻烦,也更常被忽略。如果一段文字同时出现在块三和块四里,而这两块的向量高度相似,那么检索返回 Top-5 时,这一段会占掉两个位置,真正有区分度的其他文档被挤出去。重叠比例越高,这个问题越严重。
      • 重排序阶段会放大这个问题
        。重排序模型看到两个几乎一样的块,很难把它们排开,排序结果的不稳定性会直接反映到答案里。

      可落地的缓解办法有两个:一是入库时对重叠区做标记,让重排序或者去重阶段能把「同一段内容的两个块」识别出来;二是把重叠比例控制在块大小的百分之十到二十这个量级,并且在评测集上确认它带来的召回收益是真的,而不是自己想象出来的。

      4.4.2 层次分块:父子块与摘要-详情

      重叠是在时间轴上补缝,层次分块是在结构上补缝。思路是:用小块提高检索的精度,用大块保证返回的上下文完整。

      具体做法是建立父子关系。父块是一个完整的语义单元(核电厂文档里通常是一个条款,或者一个条款下的一组子项),子块是把父块再切细后的片段。只有子块进向量库做检索;一旦命中,返回给生成模型的是它所属的整个父块。 这样,检索时是「小而准」,返回时是「大而全」。

      举个具体例子。假设 6.3.2 这个条款讲主泵机械密封的状态监测,底下有三段,分别讲限值、监测周期和超标处置。把这三段做成三个子块分别建索引,索引精度会很高——用户问「主泵振动限值是多少」,命中的一定是讲限值那个子块,不会被另外两段干扰。但如果只返回这个子块,模型可能答不全,因为它不知道这个限值属于哪个工况、依据是哪个版本。所以返回时把整个 6.3.2 条款都带上,模型就有了完整的语境。

      除了父子块,还有一种摘要-详情的层次:对每一章额外生成一个摘要块,摘要块负责宏观检索(「主泵的监测要求有哪些」这类问题),详情块负责返回细节。摘要可以用抽取式方法做(把每节首句拼起来),也可以用模型生成,但要记住一点:摘要块一旦入库,它的内容就成了知识库的一部分,出错要按正文的标准去审。

      层次分块不是免费的。它把「切块」变成了「切块加维护父子关系加回溯」三件事,块 id 要稳定,父子关系要能被索引查到,更新时还要考虑父块的重建时机。

      4.4.3 元数据:让块可过滤、可引用、可审计

      💡

      元数据不是备注,是块的身份

      没有元数据的块是一段孤立的文字:它不知道自己来自哪份文件、哪个版次、哪个条款,因此无法被过滤、无法被引用、也无法被追责。检索结果里那句「根据运行规程,振动限值为……」,这句话的每一个字都来自元数据——没有版次和条款号,这句话在核电厂是不可用的,因为它无法被现场人员核对。

      一个块上该挂哪些字段?分三个层次看。

      文档级字段(同一文档的所有块共享):文档编号、文档标题、版次、发布部门、生效日期、现行或作废状态。其中版次和状态是 [4.1.5 节](#415-版次与作废标记必须在解析阶段抓住) 那条红线的执行载体,必须在入库时就写死在块上。  块级字段(每个块独有):条款编号、章节路径、页码、在文档中的起止位置、父块 id、块序号、重叠来源块 id(如果做了重叠标记)。  解析溯源字段(用于回溯和增量):解析器名称与版本、清洗规则版本、分块策略与参数、嵌入模型版本、嵌入时间戳。这一组字段平时用不上,但它是回答「上周那批块是怎么算出来的」的唯一途径。  

      一个块的完整元数据大致长这样:

      {   "chunk_id": "HDF-2024-A0-6.3.2-004",   "text": "6.3.2 主泵机械密封应按下列周期进行状态监测……",   "metadata": {     "doc_id": "HDF-2024-A0",     "doc_title": "核电厂运行规程",     "revision": "A/0",     "status": "现行",     "clause_no": "6.3.2",     "heading_path": ["设备管理", "泵组", "主泵机械密封状态监测"],     "page": 128,     "content_type": "clause",     "issued_by": "运行部门",     "effective_date": "2024-03-01",     "parent_id": "HDF-2024-A0-6.3.2",     "parser_version": "custom-layout-2026-03",     "chunk_strategy": "clause",     "embedded_at": "2026-03-18T09:20:00Z"   } }

      一个关键取舍:哪些字段进正文,哪些只做元数据。 章节标题、文档标题这类字段,很多实现会选择拼到块文本的前面(例如以「设备管理 泵组 主泵机械密封状态监测」开头),这样语义检索时能用上标题的上下文;代价是每个块都变长了,向量里混进了不属于本块正文的词,而且重复内容会拉高索引体积。行业里的常见做法是:参与语义检索的字段才拼进正文,纯过滤和纯展示的字段只做元数据。 具体拼不拼,用评测集测,不要凭感觉。  

      元数据本身有一套成熟的治理方法论,字段怎么定义、怎么管生命周期、怎么保证唯一性和一致性,可以对照看 [第9章 元数据管理](/data-governance/09-元数据管理)。本章只负责一件事:在分块这一阶段,就把块的元数据模型定下来。

      4.4.4 代价与增量:改一条条款要重算什么

      💬

      重叠和层次都会产生重复内容,去重要提前设计

      同一段文字在一个索引里出现多次,听起来是浪费,实际上是索引设计里绕不开的一部分。问题在于:如果你没有提前想好怎么处理,这些重复就会变成难以排查的排序异常。行业里常见的三种处理方式是:入库时按内容哈希做一次跨块去重;检索时在重排序之后按内容哈希再折叠一次;或者在提示词组装阶段只保留相似度最高的那一份。选哪种取决于重复率有多高,而重复率是重叠比例和层次深度的直接函数——先量出重复率,再选策略。

      分层设计还有一个附带好处,就是增量更新终于变得可行了。这一条值得单独说,因为它是分块优化在工程上最实在的收益。

      前三种分块策略(固定、语义、结构)都默认「文档是一整块,检索是整块进整块出」,所以文档改一个字就意味着重新解析、重新清洗、重新切分、重新向量化、重新入库。一份 300 页的规程改一个限值,整条链路重跑一遍——这在批量入库阶段可以接受,在日常维护阶段无法接受。

      分层的结构改变了这件事:有了稳定的块 id 和可查询的父子关系之后,「6.3.2 条款改版」这个变更的影响面就是可以精确计算的——重新解析这一个条款、重新切它的子块、重新嵌入这几个子块、重新索引这些 id。文档里其余几百个块一根指头都不用动。判断影响面需要三个信息:块 id 的构造规则是否稳定(能不能从文档编号加条款号直接算出 id)、父子关系是否落在索引里(能不能反查「哪些子块属于 6.3.2」)、以及块 id 会不会因为插入新条款而整体漂移(如果用行号做 id,插一段就会让后面全部失效)。

      这三个信息是分块阶段就要设计的,等到文档开始频繁修订了再回头补,代价很大。 索引怎么保持可用、更新怎么不打架,是 [第7章 索引优化与维护](/rag/07-索引优化与维护) 的话题,但地基打在这一章。  

      到这里,文档从一份原始文件走到了一个有身份、有层级、有边界的块。接下来的问题就自然了:块是文字做的,检索是按「意思」找的——怎么让「意思」变成机器能比的东西? 这就是下一章的主题。

      4.5 小结

      本章把一份原始文档送进知识库之前的四道工序讲完了。

      • 解析
        :格式决定路径,扫描件要靠文本层检测分流到 OCR;表格和图注是最容易被丢掉的高价值内容;版次与作废状态必须在解析阶段就抓出来,事后补不上。
      • 清洗
        :噪声清单要区分「能删」和「不敢删」;页眉页脚按全文档出现频次判定,删除前先抽元数据;清洗必须幂等、可回滚、可审计。
      • 分块
        :固定长度、语义分块、结构分块各有失效模式,结构分块对解析质量依赖最重;块大小是双向失效,没有通用最优值,只能用固定评测集测出来。
      • 优化
        :重叠在时间轴上补缝,父子块在结构上补缝,两者都换来索引冗余;元数据是块的身份,决定了它能否被过滤、引用和追责;稳定的块 id 让增量更新从「整本重算」变成「只算改动的那几条」。

      四道工序的共同纪律只有一条:每一步的中间产物都要留下,每一步的输出都要能被验证。 上游的错误下游救不回来,而上游的错误往往是不显形的——一段被页眉污染的文本、一个被截断的限值数字、一条来自作废版次的条款,表面上都能跑通全链路,只有对照原文才看得出来。

      下一章讲嵌入与向量化:把切好的文本块变成高维向量,让「意思相近」变成「距离相近」,这才是语义检索真正开始的地方。

      相关学习资料