RAG 实战:PDF 切块的 5 个大坑和解法
做 RAG 的人都知道,效果好不好,八成取决于数据处理。而 PDF 是最常见也最难处理的文档格式——没有之一。
我在给内部运维文档做 RAG 知识库,几百份 PDF、上万页——踩的坑比写的代码还多。
上一篇文章我分享了一套纯本地 RAG 方案(ES + Ollama + Claude Code),有不少朋友问:PDF 切块具体怎么做的?跨页怎么处理?表格怎么办?
这篇就把我踩过的坑、用真实数据复现出来,一个个说清楚——不光说问题,还附上完整的解决方案和代码。
本文用的 PDF 是某云平台的《数据库运维指南》(52 页),所有问题和数据都是真实环境中遇到的。
先说方案:我是怎么切的
我的切块策略很简单——按页切块 + 200 字 overlap:
OVERLAP_CHARS = 200defbuild_chunks(pages, source_file, meta): chunks = []for i, page_text in enumerate(pages):ifnot page_text.strip():continue# 取上一页末尾 200 字作为重叠 overlap = ""if i > 0and pages[i - 1].strip(): overlap = pages[i - 1].strip()[-OVERLAP_CHARS:] content = f"{overlap}\n\n{page_text}"if overlap else page_text chunks.append({"content": content,"source_file": source_file,"page_number": i + 1, ... })return chunksPDF 解析用的 pymupdf4llm,一行代码把 PDF 按页转成 Markdown:
import pymupdf4llmpages = pymupdf4llm.to_markdown("数据库运维指南.pdf", page_chunks=True)# 返回列表,每个元素是一页的 dict,包含 text、toc_items 等
这个方案能跑起来,但跑起来之后你会发现——坑全在数据里。
下面逐个说问题,再说我怎么解决的。
坑 1:页眉页脚噪音
每一页都重复出现相同的页眉页脚,写入 ES 后会严重影响检索质量。
实际数据:
第 7 页首行: **·** 运维指南 产品架构第 7 页末行: 7第 8 页首行: **·** 运维指南 产品架构第 8 页末行: 8第 9 页首行: **·** 运维指南 产品架构第 9 页末行: 9每一页都带着 **·** 运维指南 产品架构 和页码。52 页文档,这段文本重复了几十次。
为什么是个问题?
污染 embedding 向量 — 每个 chunk 都混入了相同的无关文本,向量空间中这些 chunk 会被拉得更近,降低区分度 浪费 token — 每个 chunk 白白占了几十字的无用信息 BM25 误匹配 — 搜索"运维指南"时,所有页都会命中,因为每页页眉都有这个词
解决方案:模式匹配清洗
分析了多个不同产品 PDF 后,发现页眉页脚有非常统一的模式:
页眉第1行: **·** 运维指南 产品架构 ← 固定包含 "**·**" 或 "·"页眉第2行: 数据库服务 ← 紧跟的短行产品名页脚倒数第2行: > 文档版本:20240101 ← 固定 "文档版本:" 前缀页脚最后1行: 12 ← 纯数字页码或罗马数字用正则逐行匹配,从开头删页眉、从末尾删页脚:
_ROMAN = re.compile(r"^[IVXLCDM]+$")_DOC_VERSION = re.compile(r"^[>~\s]*文档版本[::]")_HEADER_BULLET = re.compile(r"\*\*·\*\*|·")defclean_page_text(text: str) -> str: lines = text.split("\n")# --- 删除页眉(从开头往下吃)---while lines: stripped = lines[0].strip()ifnot stripped: lines.pop(0)continueif _HEADER_BULLET.search(stripped): lines.pop(0)# 跳过空行,再吃掉紧跟的短行产品名while lines andnot lines[0].strip(): lines.pop(0)while (lines and lines[0].strip()andnot lines[0].strip().startswith("#")and len(lines[0].strip()) < 30): lines.pop(0)breakbreak# --- 删除页脚(从末尾往上吃)---while lines: stripped = lines[-1].strip()ifnot stripped: lines.pop()continueif stripped.isdigit() or _ROMAN.match(stripped): lines.pop()continueif _DOC_VERSION.search(stripped): lines.pop()continuebreakreturn"\n".join(lines)关键设计:
页眉只从开头吃,不会误删正文中出现的 ·符号页脚只从末尾吃,表格中间的纯数字行不受影响 产品名行用长度阈值(< 30 字符)+ 非标题标记(不以 #开头)双重判断
清洗效果:
数据库运维指南(52 页): 清洗 176 行噪音安全产品文档(572 页): 清洗 1712 行噪音容器文档(13 页): 清洗 32 行噪音平均每页清除约 3-4 行无用内容。

坑 2:封面/标题文字重复
PDF 封面经常用特殊排版(阴影、描边、艺术字),解析出来会出现文字重复。
实际数据:
第 1 页解析结果:**某云 某云** 企业版 企业版 数据库数据库 运维指南运维指南产品版本:v3.x.x文档版本:20240101产品版本:v3.x.x 文档版本:20240101注意看:
产品名出现了 2 次 "企业版" 出现了 2 次 "数据库" 变成了 "数据库数据库" "运维指南" 变成了 "运维指南运维指南" 版本号和文档版本也重复了一遍
原因: PDF 渲染时封面标题用了多层文本叠加(比如底层一个黑色文字 + 上层一个带阴影的文字),pymupdf4llm 把每一层都提取出来了。
更关键的是,前置页不只是封面——还有法律声明、通用约定、目录页,这些页面没有正文内容,全部索引进去就是噪音。
解决方案:用 TOC 定位正文起始页
pymupdf4llm 提取的每页数据包含 toc_items(目录条目),正文第一页一定有 TOC 标记,封面/法律声明/目录页没有。利用这个特征跳过前置页:
def_find_body_start(pages: list[dict]) -> int:"""找到正文起始页索引,跳过封面、法律声明、通用约定、目录等前置页。"""for i, page in enumerate(pages):if page.get("toc_items"):return ireturn0效果:
数据库运维指南: 跳过前 4 页(封面、法律声明、通用约定、目录)安全产品文档: 跳过前 31 页容器文档: 跳过前 4 页简单粗暴但有效——toc_items 是 PDF 自带的结构化信息,不需要猜。
坑 3:表格跨页断裂
这是 PDF 切块最经典的问题。一个表格跨了两页,按页切块后变成两个独立的、不完整的表格。
实际数据(第 8 → 9 页):
第 8 页末尾的表格:
|核心服务|部署服务|提供数据库各节点部署能力。|||自动化运维|数据库自动化运维服务。|||备份服务|全量备份和增量备份服务。|||任务调度|管控任务流调度服务。|||访问控制|提供实例的IP白名单服务。|||集群管理|提供集群管理服务。|第 9 页开头,表格继续:
|||||---|---|---|||名字服务|提供服务发现能力。|||DB初始化|元数据库初始化服务。|||环境初始化|初始化存储、网络等信息。|问题在哪?
第 8 页的表格没有结尾,直接被页面截断 第 9 页的表格没有表头,重新写了 |---|---|---|分隔线存到 ES 里是两个独立 chunk,搜索 "名字服务是什么" 只会命中第 9 页,但你看不到这是"核心服务"分类下的组件
200 字 overlap 能解决吗? 只能缓解。overlap 带来的是上一页末尾的原始文本,但不会把两页的 Markdown 表格合并成一个完整表格。
解决方案:检测并合并跨页表格
核心思路:如果前一页以表格行结尾,后一页以续表数据行(不是表头)开头,就把续表合并到前一页。
_TABLE_ROW = re.compile(r"^\|.*\|$")_TABLE_SEP = re.compile(r"^\|[-|:\s]*-[-|:\s]*\|$")def_merge_cross_page_tables(pages: list[str]) -> list[str]: result = list(pages) changed = Truewhile changed: changed = False non_empty = [i for i in range(len(result)) if result[i].strip()]for pair_idx in range(len(non_empty) - 1): src = non_empty[pair_idx] dst = non_empty[pair_idx + 1] cur_lines = result[src].rstrip().split("\n") next_lines = result[dst].lstrip().split("\n")# 检查:前一页是否以表格行结尾 last_content = ""for line in reversed(cur_lines):if line.strip(): last_content = line.strip()breakifnot _TABLE_ROW.match(last_content):continue# 检查:后一页是否以续表数据行开头(不是 separator) first_nonblank = -1for k, line in enumerate(next_lines):if line.strip(): first_nonblank = kbreakif first_nonblank < 0:continue first_content = next_lines[first_nonblank].strip()ifnot _TABLE_ROW.match(first_content) or _TABLE_SEP.match(first_content):continue# 提取续表的表格行,跳过多余的 separator table_rows = [] rest_start = first_nonblankfor k in range(first_nonblank, len(next_lines)): stripped = next_lines[k].strip()ifnot stripped:if table_rows: rest_start = kbreakcontinueif _TABLE_SEP.match(stripped):continue# 跳过续表的 separatorif _TABLE_ROW.match(stripped): table_rows.append(next_lines[k])else: rest_start = kbreakelse: rest_start = len(next_lines)ifnot table_rows:continue# 合并:续表行追加到前一页,从后一页移除 result[src] = result[src].rstrip() + "\n" + "\n".join(table_rows) result[dst] = "\n".join(next_lines[rest_start:]) changed = Truereturn result关键设计:
**多轮循环 while changed**:处理三页以上的连续跨页表格。比如文档第 7→8→9 页的组件表格,第一轮把 8 合到 7,第二轮把 9 合到 7。**跳过空页 non_empty**:第一轮合并后 page 8 可能变空,需要跳过空页找到真正的下一个有内容的页。区分数据行和表头:续表通常以 ||||(空数据行)+|---|---|---|(separator)开头,我只提取数据行,跳过重复的 separator。
效果:
数据库运维指南: 合并 7 处跨页表格 第 7-9 页(3页链式合并): 56 行表格合并为一个完整表格 第 10-11 页: 12 行表格合并 ...共合并 106 行分散的表格数据坑 4:图片完全丢失
运维文档里大量的操作截图,全部丢失了。
实际数据:
第11页: 丢失 2 张图片 — picture [445 x 423] intentionally omitted第12页: 丢失 1 张图片 — picture [12 x 12] intentionally omitted第13页: 丢失 1 张图片 — picture [109 x 245] intentionally omitted第14页: 丢失 2 张图片 — picture [425 x 191] intentionally omitted第15页: 丢失 3 张图片 — picture [353 x 129] intentionally omitted...(52 页中有 27 页包含图片)pymupdf4llm 默认不提取图片,只留一个占位符 ==> picture [宽 x 高] intentionally omitted <==。
为什么是个大问题?
运维文档的典型写法是:
3. 在浏览器地址栏中输入管控台的访问地址,按回车键。==> picture [445 x 423] intentionally omitted <==说明:您可以单击右上角的当前语言切换其它语言。用户问"怎么登录管控台",RAG 检索到了这个 chunk,但"按回车键"之后的关键截图没了——用户看不到登录页面长什么样、该在哪里输入用户名密码。
对于操作指南类文档来说,丢失截图 = 丢失了关键操作上下文。
解决方案:分级处理图片占位符
完全恢复图片内容需要多模态模型(如 Qwen-VL),成本较高。但我可以做两件事来大幅改善:
删除微型图标(< 50px)— 这些是列表符号、小箭头之类的装饰元素,没有信息量 给有意义的图片加上下文描述 — 用最近的标题或正文作为图片说明
_IMG_PLACEHOLDER = re.compile(r"\*?\*?==> picture \[(\d+) x (\d+)\] intentionally omitted <==\*?\*?")MIN_IMG_DIMENSION = 50defclean_image_placeholders(text: str) -> str: lines = text.split("\n") result = [] last_context = ""for line in lines: m = _IMG_PLACEHOLDER.search(line)ifnot m: stripped = line.strip()if stripped: last_context = stripped result.append(line)continue w, h = int(m.group(1)), int(m.group(2))if w < MIN_IMG_DIMENSION or h < MIN_IMG_DIMENSION:continue# 删除微型图标# 用最近的非空行作为图片描述 heading = re.sub(r"[#*]", "", last_context).strip() if last_context else"" desc = f"[图片: {heading}]"if heading elsef"[图片: {w}x{h}]" result.append(desc)return"\n".join(result)效果:
数据库运维指南: 删除微型图标: 18 个(12x12 之类的小图标) 上下文描述: 26 个(如 "[图片: 运维架构图]"、"[图片: 登录控制台]")原来的占位符 ==> picture [445 x 423] intentionally omitted <== 变成了 [图片: 登录控制台]。虽然图片本身没恢复,但 LLM 至少知道这里有张什么图——回答时可以说"参见操作截图"而不是一片空白。
坑 5:缺少章节感知
当前按"物理页"切块,完全不感知"逻辑章节"。一页里可能有两个章节,或者一个章节横跨五页。
实际数据:
pymupdf4llm 其实已经把 TOC(目录)信息提取出来了:
pages[8]['toc_items']# 输出:# [2, '1.2. 系统架构', 9]# [2, '1.3. 组件及作用', 9]第 9 页同时包含 "1.2 系统架构" 和 "1.3 组件及作用" 两个章节,但在我的实现里被强行塞进了一个 chunk。
反过来,"4.数据库管控平台运维"这个大章节从第 18 页一直到第 52 页,被切成了 34 个 chunk,每个 chunk 都不知道自己属于哪个章节。
为什么是个问题?
搜索"系统架构"时,命中的 chunk 里同时包含了"组件及作用"的内容,返回给 LLM 的上下文会混乱 搜索"管控平台怎么用"时,34 个 chunk 都可能命中,但没有章节层级信息帮助排序 toc_items数据已经有了,但代码里完全没用上——这是白白浪费了 PDF 自带的结构化信息
解决方案:用 TOC 构建章节路径
利用 toc_items 为每个页面计算完整的章节路径,存到 ES 中作为 metadata:
def_build_section_map(pages: list[dict]) -> dict[int, str]:"""为每个页面计算它所属的章节路径。""" section_stack: list[tuple[int, str]] = [] page_sections: dict[int, str] = {}for i, page in enumerate(pages): toc = page.get("toc_items", [])for level, title, _ in toc:# 遇到新章节时,弹出所有同级或更低级别的条目 section_stack = [(lv, t) for lv, t in section_stack if lv < level] section_stack.append((level, title))if section_stack: page_sections[i] = " > ".join(t for _, t in section_stack)# 向后填充:没有 toc_items 的页面继承前一页的章节 last_section = ""for i in range(len(pages)):if i in page_sections: last_section = page_sections[i]else: page_sections[i] = last_sectionreturn page_sections效果示例:
第 5 页 section: "1. 产品架构 > 1.1. 产品概述"第 9 页 section: "1. 产品架构 > 1.3. 组件及作用"第 18 页 section: "4. 管控平台 > 4.1. 概述"第 35 页 section: "4. 管控平台 > 4.2. 实例管理 > 4.2.1. 批量管理"ES 索引也加上 section 字段:
# es_index.py 新增"section": {"type": "text", "analyzer": "ik_smart_analyzer"},这样搜索时可以同时匹配正文内容和章节路径。搜"管控平台 实例管理"时,section 字段精确命中"4. 管控平台 > 4.2. 实例管理"下的 chunk,而不是全文档 34 个 chunk 一起返回。
完整的处理流水线
解决完 5 个坑之后,build_chunks() 变成了一条完整的处理流水线:
defbuild_chunks(pages, source_file, meta):# 1. 跳过封面/前置页(坑 2) body_start = _find_body_start(pages) body_pages = pages[body_start:]# 2. 逐页清洗:页眉页脚(坑 1)+ 图片占位符(坑 4) cleaned = [clean_image_placeholders(clean_page_text(p["text"])) for p in body_pages]# 3. 合并跨页表格(坑 3) cleaned = _merge_cross_page_tables(cleaned)# 4. 构建章节路径(坑 5) section_map = _build_section_map(pages)# 5. 组装 chunk(带 overlap + section metadata) chunks = []for i, page_text in enumerate(cleaned):ifnot page_text.strip():continue overlap = ""if i > 0and cleaned[i - 1].strip(): overlap = cleaned[i - 1].strip()[-OVERLAP_CHARS:] content = f"{overlap}\n\n{page_text}"if overlap else page_text chunks.append({"content": content,"source_file": source_file,"page_number": body_start + i + 1,"total_pages": len(pages),"section": section_map.get(body_start + i, ""), **meta, })return chunks最终效果:
总结
clean_page_text() | |||
_find_body_start() | toc_items 定位正文起始页 | ||
_merge_cross_page_tables() | |||
clean_image_placeholders() | |||
_build_section_map() |

这些问题不是某个库的 bug,而是 PDF 这个格式的本质决定的:PDF 是一种面向渲染的格式,不是面向语义的格式。 它关心的是"这段文字画在纸上的哪个坐标",不关心"这段文字属于哪个章节"。
所以做 RAG,解析 PDF 只是第一步,数据清洗和智能切块才是真正的战场。
完整代码不到 200 行(ingest.py 里的 5 个函数),都是纯 Python + 正则,不依赖额外服务。感兴趣的可以直接拿去用。
你在处理 PDF 时还踩过什么坑?或者有更好的切块方案?欢迎留言区聊聊,踩坑经验共享才是最快的进步方式。
觉得有用的话,点个「在看」或者转发给同样在折腾 RAG 的同事,感谢支持 🍻
往期相关:
毛豆Y,公众号:可乐大数据SREES + Ollama + Claude Code:一套不联网的本地 RAG 方案
夜雨聆风