文档解析与切片,在 RAG 项目里经常被一句话带过:“就是个预处理嘛。”但这两步才是真正决定检索上限的地方。这篇是这个系列的第二篇,不聊大框架,专门聚焦解析和切片,聊聊为什么说它们是地基,以及怎么才能把地基打直。
先给结论:如果你发现检索效果怎么调都不行——换 embedding、加重排、调 Top-k 都没用——大概率问题不在模型,而在你喂给向量的切片本身就是乱的。解析把 PDF 里的文本和结构还原出来,切片决定每次检索时模型看到的“最小信息单元”是什么。这两步没做好,后面所有环节都是在给错误结果做优化。
1. 一个把检索效果直接归零的解析事故
1.1 事故背景:问题真的出在向量模型身上吗
去年做公司内部制度库时,我遇到过一起非常典型的翻车。文档全部是 PDF,内容涵盖管理办法、操作细则,加起来两百多份。第一版上线后,检索基本是“答非所问”——用户问“报销流程最迟多久提交”,返回的却是“差旅费标准”相关的段落,而且经常把相邻两页的内容搅在一起。
团队第一反应是换 embedding 模型。我们先后试了三个主流向量模型,也加了 rerank,Top-k 从 3 调到 8,问题依旧。后来我从库里随机抽了 30 个切片看了一遍,才发现真相:切片内容长得是这样的——
“……第二十一条 员工应于报销事由发生之日起…… XX集团内部制度 2023修订版 下一页 第二十二条 差旅费报销需附行程单……”
页码、页眉、页脚、正文全混在一起,阅读顺序还是乱的。原因很简单:当时用的 PDF 解析方式是最基础的纯文本提取,对于一个双栏、带页眉页脚的制度文档来说,提取结果基本是“洗过牌的扑克”。向量模型再强,输入是乱序和噪音,输出自然全是噪音。
1.2 检索链路里,解析和切片决定的是“信号上限”
现在回头看,这个事故的根因不在模型,而在数据管线的前两步。
RAG 的检索效果可以拆成这样一个链条:文档解析质量 → 切片质量 → embedding 质量 → 检索排序 → 生成。越靠近左侧,影响越是全局性的。如果把整个链路比作做饭,解析和切片就是洗菜、切菜。菜没洗干净、切得大小不一,大厨(LLM)手艺再好也做不出一桌好菜。
切片有一个特殊属性:它是 embedding 模型的直接输入,也是检索返回的基本单元。检索时,系统只看切片与 query 的相似度;回答时,LLM 只看到 top-k 切片。也就是说,切片一旦切错,信息要么被淹没在无关文字里,要么被截断在边界处,后续所有环节都没有机会“补救”。选择在什么粒度上切、保留哪些上下文,才是检索精度的真正上限。
这也是我把这组动作叫作“地基”的原因:它不产生直接可感知的效果,却决定了后续每一次参数调优能不能生效。地基歪了,上面盖得越高,歪得越厉害。
2. 文档解析的四层境界,以及我最终怎么选
文档解析这件事,最容易被低估。很多人以为“PDF 能复制文字就行”,但 PDF 这个格式本身非常“坑”:它记录的是文本和坐标,而不是语义结构。同一份文档在屏幕上看是规范的体系,提取出来可能完全乱掉。
2.1 第一层:纯文本抽取,只适合“长得规整”的文档
最基础的做法是用 PyMuPDF 的 get_text() 或 pdfplumber 把整页文字直接抽出来。对单栏、无复杂表格、无页眉页脚干扰的电子文档,这一步通常够用。比如电子书型的论文 PDF,纯文本抽取基本能满足需要。
但问题也恰恰出在“规矩”二字上。真实业务文档很少那么听话:双栏排版、表头跨页、脚注、页眉页脚、多级标题,任何一个都会让纯文本提取的结果变得不可用。我在上文提到的事故,用的就是这一层方案,结果大家也看到了。
2.2 第二层:版面分析与阅读顺序恢复
进阶一点,可以在提取文本的同时拿到每个文本块的坐标,然后通过坐标规则把页眉页脚过滤掉、把多栏还原成正确的阅读顺序。unstructured 的 partition_pdf、以及 PyMuPDF 按 block 提取后再做坐标排序,都属于这个级别的思路。
这一层能解决不少问题,但表格依然是难点。坐标只能告诉你表格的位置,不能告诉你表格的列和行是怎么组织的。如果文档里表格多,光靠坐标规则很难还原出“公司名称-成立日期-注册资本”这样的结构。
2.3 第三层:结构化解析,产出 markdown/JSON 树
这是我现在主力使用的层级,代表工具是 marker。
marker 做的事情是“版面识别 + 阅读顺序 + 结构还原”,输出的是 markdown:标题变成 #,表格变成 markdown 表格,列表变成 - 列表,公式、引用也各有对应语法。这意味着解析结果保留了文档最核心的东西——层级结构。
举个例子,一份制度文档经过 marker 解析后,输出大致长这样:
# 第三章 报销管理 ## 3.2 报销流程 ### 3.2.1 时限要求 员工应在费用发生后的 **10 个工作日内** 提交报销申请。 - 必要材料:发票、行程单、审批单 - 提交方式:OA 系统线上提交 | 费用类型 | 最迟时限 | | --- | --- | | 市内交通 | 14 天 | | 差旅报销 | 30 天 |你可能已经发现,这份输出里包含了标题层级、列表、加粗、表格,全都是标准 markdown。这些标记在人的眼里只是排版,在切片程序眼里却是边界和结构。
为什么我对结构化解析这么看重?因为它直接决定了后面切片好不好切。markdown 里的 # 就是天然的切片边界,表格语法就是天然的不可切分单元。你不再需要用 token 数去猜哪里该断,而是让文档自己告诉你:“我是标题,后面这一节是一个完整主题。”
当然,marker 不是万能药。它依赖深度学习模型,在 CPU 上跑大 PDF 很慢,我通常建议用带 GPU 的机器跑批处理;中文长文档偶尔会出幻觉、字号或数字错乱,需要抽检。另外它对复杂扫描件的容忍度有限,扫描件请交给 OCR 那层。
2.4 第四层:OCR 兜底,扫描件绕不开的战场
没有文本层的扫描 PDF,任何解析工具都无能为力,必须走 OCR。我在中文场景用得比较多的是 PaddleOCR 和 PP-Structure,它对中文显示文本的识别率比较高,还能顺带做版面分析。
但 OCR 的实际成本很高:单页耗时是按秒计的,表格结构识别后往往还要再人工修正。所以我有一个很实在的建议:能拿到电子版就绝不收扫描版,从源头控制文档格式,比任何后处理都省钱。如果扫描件实在绕不开,优先只做全文检索兜底用,不要强行进精细结构化流程。
2.5 工具选型实测对比,做一份能拿去开会的表
我整理了一份当前工具的使用感受,供选型时参考:
| 工具 | 强项 | 明显的短板 | 适合场景 |
|---|---|---|---|
| PyMuPDF | 轻量、快、能取坐标 | 基本不还原结构,表格靠后处理 | 文本型 PDF 快速预览、坐标规则 |
| pdfplumber | 表格按矩形框提取,精度高 | 慢,大规模批处理难受 | 少量表格页面,需要精细表格数据 |
| unstructured | 封装多后端,管道化方便 | 依赖重,底层行为黑盒 | 快速搭建可跑的管道 |
| marker | 输出 markdown,标题/表格/公式还原好 | 需要 GPU,中文偶有错字 | 知识库主文档入库 |
| PaddleOCR / PP-Structure | 中文扫描件识别好,有版面分析 | 慢,表格后处理复杂 | 扫描件、老档案数字化 |
我的默认组合是:高产量的正式文档走 marker 批量结构化为 markdown;结构简单的单页文件用 PyMuPDF 省时间;扫描件单独走 OCR,且质检规格会拉得更严。这样既控制了成本,也保证了主链路的解析质量。
3. 切片:别再拿固定窗口“一刀切”了
先澄清一个高频误会:聊到文档切片,很多人第一反应是 Python 的 list slicing——那个是数组切片的索引操作,这里是文档切分。虽然都叫 slice,解决的是完全不同的问题。文档切片是把长文档切成一个个独立的信息单元,后续每个单元单独向量化、单独检索。但两者有一点类似——切得好不好,直接看后面的代码用起来顺不顺。
3.1 chunk size 和 overlap 的经典权衡
每个刚接触 RAG 的人都会面对两个参数:chunk size(块大小)和 overlap(块间重叠)。它们是所有切片策略的基础。
chunk size 本质上是一个“分辨率”选择。块越小,每个向量代表的内容越聚焦,召回精度越高,但独立的单元就越多,检索时可能漏掉跨块的上下文;块越大,上下文越完整,但向量被大量无关词“稀释”,和 query 的相似度会被拉平。embedding 模型通常有最大 token 数限制(512、1024、2048 不等),所以块大小不能超过模型上限。
overlap 是为了缓解“主题正好卡在边界上”的问题。两个相邻块各保留对方的一小段,虽然会增加一些重复存储,但对检索稳定性帮助很大。我内部的默认起点是 chunk size 900 左右、overlap 100 左右,但这不是通用答案。对话型问答可以切小一点,制度条款这种需要引原文的文档可以切大一点。
3.2 固定长度切片的三个翻车现场
我见过很多项目直接按固定 token 数切文本,结果出现三种典型的灾难:
一是表格被拦腰切断。固定长度不会管表格的边界,数到 512 就切。检索结果里经常只有半张表,列名和后面的数据对不上。
二是列表项从中断开。一份操作手册里,“步骤 1”和“步骤 2”被切成两个块,用户问“第二步怎么操作”,返回的块里只有后半段。
三是标题和正文分家。标题在上一块的末尾,正文在下一块的开头。检索命中正文块,但 LLM 不知道这段属于哪个章节,答案很容易张冠李戴。
这三个问题有一个共同本质:固定长度完全无视了文档的内在语义边界,只是在对字符串做“数数”。
3.3 结构感知切片:让文档自己告诉你边界在哪
解决思路并不复杂:先做结构化解析(通常是 marker 的 markdown 输出),然后按标题层级切分。遇到一级标题就开一个新块,把该标题下的内容归到同一个块;如果某个二级标题下的内容太长,再按段落切,并在边界处保留 overlap。
这个策略的价值,不只是切得准,还顺手解决了“上下文从哪来”的问题。切片时把标题路径拼接成元数据,比如“第三章 制度管理 > 3.2 报销流程 > 3.2.1 时限要求”,检索命中后,LLM 能一眼看到这段在整个文档中的位置,回答时有章可循。
你可能会问:现在不是有基于 embedding 的语义切分吗?为什么不用?我试过,效果在部分长文档上确实惊艳,但它有两个现实问题:一是成本高,需要对所有候选边界都算一遍相似度;二是不稳定,同一个小节内部如果存在话题漂移,它可能切成一堆很碎的块,反而不利于后续检索。我的态度是:优先用结构,结构不够用时再用语义补充。结构化解析输出的标题,就是最廉价、最稳定的语义边界。
3.4 实测对比:三种切片方式的差距有多大
我在自己的知识库跑过一组简易测评:100 个真实问题,看 Top-5 里能不能命中标注好的正确段落。样本不算大,但方向很明确:
| 切片方式 | 命中率(Top-5) | 块数 | 可追溯性 |
|---|---|---|---|
| 固定 512 token 切 | 62% | 中 | 标题频繁丢失 |
| 按自然段切 | 78% | 略多 | 段落关联弱 |
| 结构化标题层级切 | 91% | 少 | 标题路径完整 |
结构化切片不仅命中率更高,块数也变少了。原因很简单:它让每个块的语义密度更高,检索时更容易命中,也不需要靠扩大 Top-k 来“碰运气”。
4. 解析、切片之后的集成细节:让地基真正发挥作用
切片切完,事情还没完。如果只是把切片扔进向量库,那只是把地基打直了,还没把钢筋接上。以下四个集成细节,是我认为决定地基最终能不能承重的关键。
4.1 大块检索、小块召回:small-to-big 的取舍
这是我在结构化切片后用得很顺手的模式。原理分两层:检索用小块(比如 300-500 token),保证召回精准;喂给 LLM 时用大块(比如整个小节或整章),保证上下文完整。两个层之间通过 metadata 里的 parent_id 关联。
具体实现:每个检索子块记录 parent_id,检索命中子块后,去库里取对应父块的文本返回给大模型。这样既克服了小切片的上下文不足,也规避了大切片的语义稀释,可以说是“两头都占”。
在这个方案里,结构化解析的价值又被放大了一层:父块边界直接用 markdown 的小节边界,既有语义完整性,又有层级可读性,比固定长度的父子块靠谱得多。
4.2 元数据不是附属品,是检索器的“放大镜”
切片入库时,我会给每个块挂一份元数据,通常长这样:
{ "file": "2024_报销制度.pdf", "chunk_id": "chunk_0042", "parent_id": "section_03_02_01", "heading_path": "第三章 制度管理 > 3.2 报销流程 > 3.2.1 时限要求", "page": 12, "chunk_index": 3, "total_chunks": 5 }这些字段不只是摆设。heading_path 可以直接拼进 prompt,让 LLM 知道引文出处;page 可以支撑前端“点击跳转原文”;parent_id 是 small-to-big 模式的结构基础。检索器在做过滤(比如只看某个章节)或排序时,元数据都能当“放大镜”用。
4.3 入库前的三遍自检清单
切片数量、质量都不能靠感觉,我有一套固定动作:
第一遍,检查粗指标。统计空块数、token 数过小(比如低于 50)或过大(超过 embedding 上限)的块数,看分布是否正常。异常块要么是解析漏字,要么是切分规则有边界 bug。
第二遍,结构抽查。随机抽 20 个切片,逐个看标题路径是否完整、表格有没有被切断、列表连续性是否保得住。这一步用人工但值得,花不了十分钟,却能拦住绝大多数问题。
第三遍,问答实测。准备 20-50 个贴近真实业务的问答,跑一遍检索,看正确段落是否出现在 Top-5 里。这几个问题就是后面迭代效果的“标尺”,建议固定下来,别每次换一批问题。
4.4 评估指标与迭代节奏,别让手感代替数据
我最常看的两个指标,一个是 Top-k 命中率(标注好答案,看能不能被召回),另一个是检索返回块中“有效信息占比”的抽样感受。前者给数据,后者给直觉,两者交叉着看。
迭代的时候记住一条纪律:一次只动一个变量。比如本周只调 overlap,下周只调整标题路径的拼接方式。变量混在一起改,出了问题你根本不知道是哪个改错了。地基工程不怕慢,怕的是你反复装修,却始终没检查承重墙。
5. 一套可以直接抄的 Python 切片流水线
说了这么多理念,最后放一段可以跑通的最小实现。整个流程分三步:用 marker 把文档解析成 markdown,然后用标题层级做切片,最后在入库前做一轮切片体检。
5.1 前置:用 marker 把文档跑成 markdown
先安装 marker(具体依赖看官方 README,不同版本命令会略有差异),然后对单个 PDF 跑:
# marker 的 CLI 命令(不同版本命令名略有差异,以官方 README 为准) marker_single input.pdf --output_format markdown --output_dir ./output跑完会在输出目录里得到一个同名 .md 文件。如果文档比较大,建议先抽几页试跑,确认输出结构正常再全量跑。这里有个非常接地气的经验:marker 在 CPU 上跑大文件非常慢,有条件就上 GPU;没 GPU 时,可以先按页拆分 PDF,再并行跑,能省很多时间。
5.2 基于 markdown 标题层级做切片
拿到 markdown 之后,我通常写一个很小的解析函数,按标题层级把内容切成块,同时拼接标题路径。下面是一个精简版实现:
import re from dataclasses import dataclass @dataclass class Chunk: text: str heading_path: str tokens_estimate: int = 0 def estimate_tokens(text: str) -> int: # 中文按字符估算,英文按词估算,1.5 只是一个经验系数 zh = re.findall(r'[\u4e00-\u9fff]', text) en = re.findall(r'[a-zA-Z0-9_]+', text) return int(len(zh) / 1.5 + len(en)) def split_md_by_heading(md_text: str, max_tokens: int = 900): lines = md_text.splitlines() chunks = [] heading_stack = [] # 当前标题路径 current_text = "" def flush(): nonlocal current_text if current_text.strip(): chunks.append(Chunk( text=current_text.strip(), heading_path=" > ".join(heading_stack), tokens_estimate=estimate_tokens(current_text) )) current_text = "" for line in lines: m = re.match(r'^(#{1,6})\s+(.*)', line) if m: flush() level = len(m.group(1)) title = m.group(2).strip() # 遇到同级或更高级标题时,截断当前分支 while len(heading_stack) >= level: heading_stack.pop() heading_stack.append(title) current_text += line + "\n" continue current_text += line + "\n" if estimate_tokens(current_text) >= max_tokens: flush() flush() return chunks代码里有两个细节值得说明。一是 heading_stack 的维护逻辑:遇到更高级的标题(例如从 H3 跳到 H1),需要弹出所有不小于当前层级的标题,保证标题路径不会越级。二是这个示例为了可读性省略了 overlap 实现,真实环境可以在 flush 时保存最后若干字符,作为下一个块的起始内容拼进去,具体重叠长度按 embedding 模型和业务需要调。
如果某个标题下是一大段连续长文,超过了 max_tokens,这个简化版会直接把它切成一个超大块。实际项目中可以在超限时按空行或句子边界再拆一层,这里保留的是结构切分的主干思路。
5.3 写入向量库之前,先做一次“切片体检”
切片函数写完,别急着入库,跑一轮自检:
from collections import Counter sizes = [c.tokens_estimate for c in chunks] counter = Counter( "empty" if not c.text.strip() else "too_small" if c.tokens_estimate < 50 else "too_large" if c.tokens_estimate > 1500 else "ok" for c in chunks ) print(counter.most_common())再随机打印 5 个 Chunk 的 heading_path 和前 80 个字,人工看一眼内容是否连贯。这一步我每次入库都做,已经养成了习惯。看起来多花了几分钟,实际能省掉后面排查“为什么检索这么差”的几个小时。
5.4 个人经验:别让解析和切片成为“一次搞定”的事
这套流程我跑了小半年,最大的感受是:没有一劳永逸的解析和切片方案。文档源在变,业务问题在变,切片策略就得跟着变。固定的做法是,每次接到一批新类型的文档,先跑通 5-10 个样本,人工看一遍切片质量,再决定要不要调整 chunk size 和 overlap。把解析和切片当作一个需要持续维护的流水线,而不是上线前的一次性工作。
做到这里,你的“地基”才算真正打直了。后面的 embedding 调优、rerank 选型,才是在一个正确的起点上做锦上添花。下一篇我会继续聊向量化之前的最后一道工序——清洗与去重,那又是一坑,到时候再展开。