
先聊一个场景你正坐在工位上刚收到几百份 PDF——可能是技术手册、合同扫描件或者学术论文任务很明确把这些 PDF 变成可检索的文本让它们能被搜索引擎命中、能被知识库引用、能作为语料喂给后续的算法模型。我这些年做文档智能处理项目几乎每隔一段时间就会被人问到PDF 转文本是不是就是个现成的小工具。答案是它是半个工具剩下的半个是战场。文档解析也叫文档结构化解析这个领域表面上是一个提取文本的问题实际上牵扯到 PDF 的内部格式、字体编码、版面还原、OCR 识别、异常兜底、批量调度……每一项都能单独写一章。我们这一章就把从 PDF 到可检索的文本这件事完整地拆开讲清楚先讲为什么 PDF 文本这么难搞再讲工具怎么选、链路怎么搭、坑怎么避最后聊到工程化落地的细节。无论你是刚接触 pdf解析 的新手还是已经在写数据管线的工程师这章的内容应该都能直接抄作业。1. 为什么 PDF 的文本常常是假装存在PDF 是全宇宙最流行的最终格式但它从来不是为了被重新提取而设计的。理解这一点是做好文档解析的第一步也是最重要的一步。1.1 PDF 内部到底存了什么PDF 文件本质上是对象的集合页面、字体、内容流、资源字典。文本并不是以段落或句子为单位存储的而是以文本绘制指令的形式嵌在内容流里。打开一个文本型 PDF 的内容流你会看到类似这样的指令BT /F1 12 Tf 72 700 Td (Hello World) Tj ET意思大概是用 F1 这个字体、12 号大小在坐标72, 700的位置画出 Hello World 这几个字符。PDF 阅读器做的事情就是按顺序执行这些绘制指令把字符画到页面上。这就带来一个关键问题PDF 里存的是画字符的动作和字符在页面上的坐标而不是一段有逻辑顺序的文本。字符之间的顺序、换行、段落归属这些信息 PDF 格式本身并不承诺提供。所以当你用工具提取出来发现文字顺序乱七八糟时不是工具坏了而是 PDF 里本来就没存这个顺序。另一个隐藏很深的问题是字体编码。正常的中文字体文件里字符和字形之间有映射表但很多 PDF 为了压缩体积会嵌入子集字体subset只保留用到的字形。子集字体里字形索引GID和 Unicode 码位之间的对应关系靠一个叫 ToUnicode CMap 的可选表来维护。如果生成 PDF 的工具没写这个表那解析器拿到一串字形 ID却不知道它们对应哪个汉字——这就是中文乱码的根源。后面第 5 节我会专门展开讲。1.2 拿到文件先做活体检测做任何解析之前先花 30 秒判断这个 PDF 是不是活文本。我的习惯是做一个最朴素的三步检查用任意阅读器打开 PDF选中一段文字CtrlC 复制到记事本看粘贴出来是不是正常文字。如果复制出来是乱码、空格或者根本选不中说明这个文件大概率是扫描件或文字转曲字符变成矢量曲线的版本。再用命令行工具跑一下pdftotext或 PDF 库做快速提取统计提取出文本的字符量。这三步能帮你把文件分成三类类型特征解析策略文本型 PDF能复制、粘贴正常直接文本提取扫描型 PDF页面是图片选不中文字必须走 OCR曲线型 PDF文字被转成轮廓能选中但复制乱码或为空只能 OCR或依赖原始文件提示很多打印店导出的 PDF 默认会转曲这种文件的文本层是空的再好的解析工具也救不回来。遇到这类先回去找原文件比在解析上死磕划算得多。我把这个叫活体检测是因为它决定了后面所有技术选型。你在网上搜到那些PDF 转 Word 收费软件本质上也逃不开这三类文本型直接抽扫描型调用 OCR仅此而已。明白了吗文档解析的难点不在工具多不多而在你能不能准确判断文件属于哪一类并为每一类设计正确的处理分支。这也是这一章最想传达的思维。2. 工具选型实测PyMuPDF、pdfplumber、pypdf 谁更顺手市面上能处理 PDF 的库不少但真正值得写进生产环境的我用下来就四个。这一节直接给结论、给代码、给对比表避免你在选型上浪费时间。2.1 四库横向对比先给一张我实测过的对比表环境是 Python 3.10处理 1000 份混合类型文档库底层引擎文本提取质量中文支持版面信息速度典型场景PyMuPDF (fitz)MuPDF (C)高好块/行/词/字符坐标齐全极快生产主力pdfplumberpdfminer.six纯 Python中高好支持 word/rect/table较慢表格提取、精细调试pypdf (原 PyPDF2)纯 Python中一般有限中简单拼接、页级操作pdfminer.six纯 Python中高好提供 layout 对象慢版面研究、教学从我的实践经验看PyMuPDF 应该是你默认的第一选择。理由很实在速度是纯 Python 库的 5~20 倍批量处理时差别是分钟级和小时级的差距。对中文编码的支持足够好绝大多数正常文本型 PDF 都能直接提取。提供text as blocks这种带坐标的结构化输出我们后面做版面还原非常依赖它。pdfplumber 的价值在于它的extract_table和字符级信息。如果你要解析的文档以带表格的报表为主pdfplumber 值得单独跑一条链路。pypdf 更适合做 PDF 的合并、拆分、加密解密这类编辑操作而不是解析。2.2 核心 API 与踩坑提醒PyMuPDF 的基础提取代码极其简单import fitz doc fitz.open(manual.pdf) for page_num, page in enumerate(doc): text page.get_text(text) print(f----- Page {page_num 1} -----) print(text) doc.close()但注意直接get_text()拿到的文本是页面流顺序对多栏版式往往不友好。更推荐用块模式blocks page.get_text(blocks) # 每个 block 是 (x0, y0, x1, y1, text, block_no, block_type)block_type 为 0 表示文本块1 表示图片块。有了坐标和类型就为版面分析打下了基础。pdfplumber 的基础用法import pdfplumber with pdfplumber.open(table_doc.pdf) as pdf: page pdf.pages[0] text page.extract_text() tables page.extract_tables()这里有几个坑pdfplumber 的extract_text()在遇到复杂版式时会返回None所以要做空值防御它的速度慢不适合大批量任务里对每一页都调用它的extract_tables()对没有明显分隔线的表格会把整页当成一个大 cell需要调table_settings参数。2.3 选型决策建议根据你的目标直接对号入座目标是做全文检索、知识库语料 →PyMuPDF 为主输出纯文本或 JSON含坐标。目标是提取报表数据、做财务单据解析 →pdfplumber并针对表头、线条做配置。目标只是批量转一下文本、不追求结构 →pypdf够了但优先还是 PyMuPDF。想彻底搞清楚 PDF 的结构原理、做研究 →pdfminer.six它的 layout 对象值得读源码。工具不在多而在于你熟不熟悉它的脾气。我自己长期保留下来的组合是PyMuPDF 干 80% 的活pdfplumber 处理 15% 的特殊表格剩下 5% 交给 OCR 兜底。这个比例供你参考。3. 从抽文本到保版面可检索文本的完整加工链路很多人以为把 PDF 的文字抽出来就结束了但真正可检索的文本是有要求的段落要连续、标题能识别、正文不乱序、特殊字符不丢。这一节我讲一条经过多次实战打磨的通用加工链路。3.1 坐标是结构化的起点先记住一句话没有坐标的文本提取都是碰运气。我们用 PyMuPDF 拿到的 blocks天然带有边界框坐标。那么版面还原的第一件事就是按坐标重新组织顺序。多栏文档的经典问题就是阅读顺序。通常的阅读顺序规则是先按自上而下的顺序处理但在同一页内还要判断是否存在分栏。一个简单但有效的启发式判断把页面宽度等分为左右两半。统计块的中心点 x 坐标。如果同时存在大量中心点在左侧和右侧的块且左右两侧的 y 区间有重叠判定为双栏。双栏时先按左上块 → 左下块 → 右上块 → 右下块的顺序排序单栏时直接按 y 坐标排序。写成代码可以这样def reorder_blocks(blocks, page_width): blocks: list of (x0, y0, x1, y1, text, block_no, block_type) text_blocks [b for b in blocks if b[6] 0] # 只处理文本块 if not text_blocks: return [] mid_x page_width / 2 left [b for b in text_blocks if (b[0] b[2]) / 2 mid_x] right [b for b in text_blocks if (b[0] b[2]) / 2 mid_x] # 单栏还是双栏 if not left or not right: return sorted(text_blocks, keylambda b: (round(b[1], 1), b[0])) else: left.sort(keylambda b: (b[1], b[0])) right.sort(keylambda b: (b[1], b[0])) result [] # 按 y 范围分带合并左右 bands [] all_blocks left right all_blocks.sort(keylambda b: b[1]) for b in all_blocks: placed False for band in bands: if b[1] band[y_max] and b[3] band[y_min]: band[blocks].append(b) band[y_min] min(band[y_min], b[1]) band[y_max] max(band[y_max], b[3]) placed True break if not placed: bands.append({y_min: b[1], y_max: b[3], blocks: [b]}) for band in bands: band[blocks].sort(keylambda b: b[0]) result.extend(band[blocks]) return result这段代码不复杂但解决了 80% 的版面乱序问题。对于更复杂的版式建议把页面切成多个竖条column strip再做聚类这个后面第 5 节再细化。3.2 段落重组与清洗规则坐标排序只是第一步接下来要做的是把块里的散行拼成段落。PDF 里经常一个视觉段落被拆成多个文本块尤其是跨页的段落。我的做法合并相邻行如果两行的 y 间距小于当前字体行高的 1.4 倍且 x0 起点接近且上一行末尾没有句号就认为属于同一段落。标题识别比较同一块内文本的字体大小。PyMuPDF 可以用page.get_text(dict)拿到每个 span 的size和font。字体明显大于正文的块标记为标题输出时可以变成 Markdown 的#。再往下是清洗规则这里我贴一份常用的清洗 checklist统一换行符把\r\n、\r统一为\n。处理连字符断词英文文档里行尾的soft-要删除连字符并拼接中文没有这个问题别误处理。规整空白多个连续空格缩成单个全角空格转半角。Unicode 归一化用 NFKC 把全角字符转半角把\u00A0不换行空格转普通空格。移除孤立的页眉页脚判断依据是同一文本块在每一页的相同坐标反复出现比如页码、公司名。可以统计前 5 页出现的重复块特征。保留必要标记列表项前的圆点、编号不要丢它们是结构信息。举一个清洗的例子import unicodedata import re def clean_text(text: str) - str: text text.replace(\r\n, \n).replace(\r, \n) # 处理行尾连字符 text re.sub(r(\w)-\n(\w), r\1\2\n, text) # 把不换行空格等特殊空白归一 text text.replace(\u00A0, ).replace(\u2007, ).replace(\u202F, ) text unicodedata.normalize(NFKC, text) text re.sub(r[ \t], , text) text re.sub(r\n{3,}, \n\n, text) return text.strip()注意NFKC 会把全角标点转成半角在中文场景下。会变成.所以对中文语料要酌情关闭标点转换或者只对字母数字做 NFKC。这个细节踩过的人都知道有多痛。做完这些一份规范的纯文本就出来了。如果还想输出 JSON 供下游使用我建议的字段结构是{ page: 1, blocks: [ {bbox: [72.0, 700.0, 500.0, 720.0], kind: heading, text: 3.2 段落重组与清洗规则}, {bbox: [72.0, 725.0, 500.0, 780.0], kind: body, text: 坐标排序只是第一步……} ] }带坐标的 JSON 对后续做高亮定位、RAG 切块、版面检索都非常有用。纯文本和 JSON 两条输出我都建议保留可检索这件事文本是给搜索引擎看的坐标是给定位到原文功能看的。4. 扫描件与图片型 PDFOCR 这条绕不开的路文本型 PDF 处理得再顺遇到扫描件也得低头。现实世界里扫描件占比相当高尤其是合同、古籍、手写单据。这一节的 OCR 方案能覆盖绝大多数图片型 PDF 的需求。4.1 什么时候必须上 OCR一个非常清晰的判断标准页面文本提取的字符数接近 0但页面上有大量图片块这就是扫描件。另一个是复制出来全是乱码或空白。只要命中其中一个就该进入 OCR 流程。我建议在解析主链路里做一次自动检测每页提取文本后统计有效字符数去掉空白后低于某个阈值比如 20 字符就触发 OCR。阈值别设太高有些封面页本身就是图片但正文正常设 20 字符比较稳妥。4.2 选哪个 OCR 引擎OCR 引擎选型取决于你的语言和部署条件。我实际对比过三套引擎中文识别速度部署成本备注Tesseract 5一般慢低老牌开源需要下载 chi_sim 语言包PaddleOCR好快中PaddlePaddle 较重中文场景首选检测识别管线完整云服务 API最好快按量付费有网络和预算限制时再选我的默认推荐是PaddleOCR 的 PP-OCRv4 模型。它在中文识别准确率上明显强于 Tesseract而且支持方向分类、文本检测、文本识别一体调用不需要自己拼流程。纯英文场景 Tesseract 够用但速度依然不如 PaddleOCR。PaddleOCR 的简单用法from paddleocr import PaddleOCR ocr PaddleOCR(use_angle_clsTrue, langch, show_logFalse) def ocr_page(image_path: str) - str: result ocr.ocr(image_path, clsTrue) lines [] if result and result[0]: # result[0] 里每个元素是 [box, (text, conf)] for box, (text, conf) in result[0]: if conf 0.6: lines.append(text) return \n.join(lines)注意PaddleOCR 首次运行会自动下载模型权重生产环境要提前把模型文件准备好避免首次调用时卡在下载。另外 OCR 的置信度过滤很重要低于 0.6 的识别结果往往是噪音宁可丢掉也不让它污染检索索引。4.3 图像预处理决定识别率上限这个观点我在多个项目里反复验证过OCR 识别率的上限在进 OCR 引擎之前就已经被图像质量决定了。这里有三步预处理收益最大DPI 检查与重采样低于 200 DPI 的文字会糊成一团。用 PyMuPDF 渲染扫描页时设置matrix fitz.Matrix(300/72, 300/72)把页面以 300 DPI 渲染出来。渲染分辨率不是越高越好超过 400 DPI 不仅慢还可能放大噪点。灰度化 二值化光照不均的扫描件先转灰度再用自适应阈值如 OpenCV 的adaptiveThreshold做二值化。这能显著提升文字和背景的对比。倾斜矫正扫描时歪个两三度识别率就掉一截。用 OpenCV 检测文本行的倾斜角然后做仿射变换矫正。PaddleOCR 自带的 direction classifier 只能解决旋转 90/180 度的问题微小的倾斜还是要自己处理。一个实用的渲染加预处理的组合import fitz import cv2 import numpy as np def render_page_for_ocr(page, dpi300): scale dpi / 72 pix page.get_pixmap(matrixfitz.Matrix(scale, scale), colorspacefitz.csGRAY) img np.frombuffer(pix.samples, dtypenp.uint8).reshape(pix.height, pix.width) # 简单二值化 _, binary cv2.threshold(img, 0, 255, cv2.THRESH_BINARY cv2.THRESH_OTSU) return binary不夸张地说同样的 PaddleOCR 模型预处理前后的准确率差距能到 10~20 个百分点。这一步是免费午餐一定要做。4.4 OCR 结果要转成带位置的结构OCR 引擎输出的不只是文字还有每个词或行的包围盒。别把这些坐标丢掉——把它们转成和文本型 PDF 相同的 block 结构下游消费逻辑就能完全统一。PaddleOCR 返回的box就是四个角点坐标转成 bbox 再组装成 JSON这一步值得花时间做掉。文档解析的系统设计原则是不管文件是文本型还是扫描型最终都输出同一套结构化表示。这样检索、高亮、切块的代码不分叉整个系统才谈得上可维护。5. 中文、多栏、表格三个最典型的翻车现场与对策做文档解析做久了会发现翻来覆去就那几种问题。这一节把我踩过最深的三个坑摊开讲每个都给出诊断方法和补救方案。5.1 中文乱码的两种形态与对策中文乱码有两种完全不同的形态别搞混。第一种是完全乱码提取出来是锟斤拷、鎴戝氨这类无意义字符或者是一串方块。这通常是字体子集缺少 ToUnicode CMap字形 ID 无法映射回 Unicode。诊断方法检查用不同库提取的结果是否一致如果 pypdf、pdfplumber、PyMuPDF 全乱基本就是映射表缺失。第二种是部分乱码某些生僻字、特殊符号提取不出来其他正常。比如这种扩展 B 区汉字很多老 PDF 字体表里就没有。对策按成本从低到高排列换库试试PyMuPDF 对 GID 映射的处理通常比纯 Python 库好。如果散乱不多用高频字替换 上下文猜词也能糊过去但这是治标。最稳妥的方法还是走 OCR把乱码页面渲染成图片OCR 识别。因为字形是正常显示的OCR 看到的是图形而不是编码。我的建议是生产链路里加一个乱码检测钩子——提取的文本里如果出现\ufffd替换符或者乱码常见字符的比例超过 0.5%就自动降级为该页 OCR。可靠性优先。5.2 多栏版式的重排逻辑学术论文、新闻报纸、产品说明书全是多栏排版。直接按内容流提取会有两个后果左右两栏的文字交错穿插或者第一栏还没读完就跳到第二栏。这在检索场景里尤其致命——一段连贯的文字被打散检索的命中率和片段可读性都会崩。我在第 3 节给出了一个基础的双栏排序算法这里补充更通用的做法X-Y Cut 分割法。思路是递归地把页面切分成区域对页面上所有块的 x 坐标做直方图统计找到纵向的空白带。如果空白带把页面分成了左右两个区域就递归处理每个区域。区域内部再按 y 坐标从上到下排序。递归结束按先左后右、从上到下的顺序输出。这个算法的复杂度不高但对分栏 通栏标题混合的版式很有效。实现时注意通栏标题横跨左右两栏的标题要先单独提取出来否则会被空白带拦腰切断。def x_y_cut(blocks, x0, y0, x1, y1, depth0): if depth 4 or len(blocks) 1: return sorted(blocks, keylambda b: (b[1], b[0])) # 在 [x0, x1] 区间找纵向空白带 xs sorted([b[0] for b in blocks] [b[2] for b in blocks]) gaps find_largest_gap(xs, x0, x1) # 自定义函数 if gaps and gaps[1] - gaps[0] 20: # 空白带足够宽 left [b for b in blocks if b[2] gaps[0]] right [b for b in blocks if b[0] gaps[1]] if left and right: return (x_y_cut(left, x0, y0, gaps[0], y1, depth1) x_y_cut(right, gaps[1], y0, x1, y1, depth1)) ys sorted([b[1] for b in blocks] [b[3] for b in blocks]) gap_y find_largest_gap(ys, y0, y1) if gap_y and gap_y[1] - gap_y[0] 20: top [b for b in blocks if b[3] gap_y[0]] bottom [b for b in blocks if b[1] gap_y[1]] if top and bottom: return (x_y_cut(top, x0, y0, x1, gap_y[0], depth1) x_y_cut(bottom, x0, gap_y[1], x1, y1, depth1)) return sorted(blocks, keylambda b: (b[1], b[0]))这个递归我把深度限制在 4 层防止极端版式导致递归过深。实测对大部分双栏论文、三栏年报都够用。更精细的规则比如按正文行高聚类分栏可以在上面基础上继续加。5.3 表格数据要不要走专用管线表格是文档解析里争议最大的一块。我的结论是先搞清楚需求再决定投入。如果表格只是给人看的比如论文里的对比表那把它按阅读顺序线性化成文本就够了不需要追求表格结构还原。但如果要做财务对账、数据入库就必须用 pdfplumber 的extract_tables()或专门的 Camelot 这类工具。pdfplumber 提取表格的关键在table_settingstable_settings { vertical_strategy: lines, # 按表格线定位 horizontal_strategy: lines, explicit_vertical_lines: [], # 无表格线时手动指定 explicit_horizontal_lines: [], } tables page.extract_tables(table_settings)注意几个实战经验竖线策略用lines适用于有明确表格线的文件没有线的用text策略按文本间距推断但误判率明显上升。合并单元格会造成行列错位提取后要做单元格合并补救。跨页表格要自己拼接表头pdfplumber 不会自动处理。另外如果 PDF 是从 Excel 或 Word 另存的很多情况下原始表格结构已经丢失殆尽这时直接提取文本反而比强行还原表格更好。不要为了看起来结构化而过度加工检索场景里自然语言的表格文本完全够用。6. 批量解析的工程化细节内存、异常、并发当你要解析的不是一份文档而是 1 万份文档时前面说的那些单文件技巧只是起点。真正的体力活在于把这套逻辑放进一个稳定、可观测、可续跑的任务系统里。这一节讲批量处理的工程细节。6.1 内存与异常的防御策略PyMuPDF 虽然快但如果你一次open一个巨大的 PDF 并遍历所有页面内存会线性上涨。正确的姿势是按页处理及时释放大文件分块处理处理完就 close。import gc def process_single_pdf(path: str): doc fitz.open(path) results [] try: for page in doc: text page.get_text(text) results.append(text) page None # 帮助 gc finally: doc.close() gc.collect() return results看起来不起眼但doc.close()之后gc.collect()能明显降低长任务的驻留内存。在任务里维护一个页面级的处理计数器超过一定数量就强制重启进程也是稳内存的土办法。异常防御是另一件大事。我见过的 PDF 丑得千奇百怪损坏的文件、加密但密码为空、页面内容流语法错误、嵌入字体损坏……任何一条都可能让解析进程崩溃。所以每个文件用try/except Exception包住记录失败原因并继续。加密文件用 PyMuPDF 的doc.needs_pass预先判断别等抛异常。超时控制OCR 单页超过 30 秒就放弃记录到失败清单——总比卡死整个任务划算。6.2 并发模型与断点续跑批量解析是典型的 CPU 密集 IO 混合任务。文本提取是 CPU 密集OCR 也吃 CPU但文件加载和图片渲染又有 IO 等待。我的建议是纯文本提取用multiprocessing.Pool进程数取 CPU 核心数减一。OCR 任务本身已经吃满 CPU并行度别超过核心数的一半否则识别反而变慢。混合任务先统计每个文件是文本型还是扫描型分两类并行处理避免慢的 OCR 拖慢文本型任务。一个进程池的框架示例from multiprocessing import Pool def worker(path): try: return {path: path, status: ok, text: extract_or_ocr(path)} except Exception as e: return {path: path, status: error, msg: str(e)} with Pool(processesos.cpu_count() - 1) as pool: results pool.map(worker, pdf_list)断点续跑是批量任务的基本功。做法很简单输出或日志里记录每个文件的处理状态启动时扫描已完成列表跳过已成功的文件。这样即使任务挂了重启也能接着跑不用从头再来。我还强烈建议在工程里加一个失败重试机制对失败文件重试 2 次。很多 PDF 的解析失败是偶发的比如文件锁占用、瞬时内存不足重试一次就过了。重试还失败的再进入人工队列。最后给批量任务加观测点。至少在日志里记录每个文件的解析耗时、提取字符数、是否触发 OCR、失败原因。有了这些你才能回答为什么这周解析速度和上周差了三倍这种问题。可观测性不是可选项是批量解析的保命符。7. 解析完成后还要做的事归一化与检索质量验证文本从 PDF 里抽出来、清洗干净、批量处理完了是不是就结束了还差两步输出归一化和检索验证。这两步直接决定你的可检索文本到底能不能用。7.1 输出归一化给下游一个干净的接口不管源头是文本型 PDF 还是 OCR 结果最终输出的文本应该遵循同一套归一化规范。我踩过不少坑这里直接给一份我认为比较完整的规范编码统一 UTF-8所有文本写入时显式指定encodingutf-8。换行格式统一\n。全角/半角统一策略中文保留全角标点英文和数字转半角。实现上先做 NFKC再把。、等中文标点从半角还原为全角。去除不可见字符零宽空格\u200B、软连字符\u00AD一并删除这些字符最容易混进文本还看不见。行尾空格删除但保留段落间的空行结构。这一套做下来搜索引擎的倒排索引才不会出现同一个词因为全角半角差异被拆成两个 token 的情况。7.2 检索验证用查询词反推解析质量很多团队辛辛苦苦做完解析上线一搜才发现搜不到。我建议在交付前做一个检索质量冒烟测试从源文档里人工挑 20~30 个必须被搜到的词组包括专有名词、数字编号、中文短语。在解析后的文本库里跑这些查询看召回率。召回率低于 90% 的逐个定位失败原因是文本没抽出来乱码还是被清洗规则误删了这个方法屡试不爽。例如我处理过一批设备手册冒烟测试发现型号永远搜不到一查才发现型号文本被 PDF 用了特殊的连字符拆分清洗规则把中间部分删了。没有这一步验证问题会潜伏到生产环境才爆发。另外如果解析结果要喂给 RAG检索增强生成管线还要注意切块不能切断语义单元。基于坐标信息我建议按标题和段落切块每个 H2/H3 标题以下的连续段落作为一个 chunk块与块之间保留 1~2 句重叠避免语义断头。前面我们做的kind: heading和kind: body标记这时候就派上用场了。7.3 留一份黄金测试集最后分享一个小习惯每次做完一批文档解析从里面挑出代表不同类型问题的最小样本集——比如一份双栏论文、一份中文扫描合同、一份带表格的年报、一份乱码 PDF——把它们固定为回归测试用例。以后每次换库版本、改清洗规则、调 OCR 参数都先跑一遍这个黄金测试集。这是我个人在实际项目中收益最大的一件事它保证了你不会在三个月后因为一次顺手优化把原本正常的解析链路搞坏。文档解析这条路没有一劳永逸的方案但只要你把工具选型、版面还原、OCR 兜底、批量工程、质量验证这几块都搭扎实了绝大多数 PDF 都能变成干净、可检索、可复用的文本资产。希望这一章的内容能让你少走我当年走过的弯路。