PDF 解析为什么难:一份面向 RAG 的问题清单与解决路线

核心观点:很多教程把 PDF 进 RAG 简化成 PDF -> 纯文本 -> 切块 -> 向量库。这只在单栏电子 PDF 上成立。
真实语料中,同一个 .pdf 背后可能是扫描件、乱码字体、双栏论文、跨页表格或公式教材。
跳过结构恢复的代价:阅读顺序错乱、表格列错位、公式消失、无法回溯原页。

本文按「问题 → 原则 → 可运行实现」展开,带你理清 PDF 解析的完整链路。


🎯 一、先定目标:重建结构,而非抽取文本

一份 PDF 要进入 RAG,必须先具备以下能力。对应的目标形态是:
page -> block(标题/正文/表格/图片) -> line -> span(文字/公式/资源)

能力维度 解决的问题
文件识别与规范化 扩展名不可信、图片输入
文本层判断 文本 PDF、扫描 PDF、乱码 PDF
版面识别 标题、正文、表格、图片、公式分别在哪里
内容识别 OCR 文字、公式 LaTeX、表格 HTML
阅读顺序恢复 双栏、侧栏、脚注、竖排文字
结构恢复 span、line、block、段落、列表与标题层级
证据保留 页码、bbox、图片资源、解析版本

🔗 二、处理链:九个问题,一条固定顺序

一份 PDF 进入 RAG 前,要依次解决这九个问题。

1. 核心流程图

输入 -> 文本/OCR 判断 -> 版面识别 -> 专项识别 -> 阅读顺序 -> 段落/表格结构 -> Content List -> RAG chunk

2. 关键问题与处理阶段

# 问题 处理阶段 解决后得到
1 文件类型不可信、需要裁剪页码 第四节:统一输入 可控的页面输入
2 没有文本层,或文本层已是乱码 第三节:文本/OCR 判断 选择原生字符或 OCR
3 标题、正文、表格、页眉混在一起 第五节:版面识别 按类型划分的内容区域
4 普通文字、公式、表格无法用同一种识别方法 第七、八、九节:专项识别 带置信度的文字、HTML 表格、LaTeX 公式
5 双栏、竖排、侧栏、脚注不能只按坐标排序 第十节:阅读顺序 可读的段落顺序
6 字符要恢复成行、段落、列表和标题层级 第十节:结构恢复 段落与标题路径
7 表格行列、表头、合并单元格、跨页关系 第八节:表格 保留列关系的 HTML
8 图片、公式截图、表格要与原页对应 第九、十一节:资源与中间结构 可定位的资源与证据
9 切块必须保留标题路径、页码、bbox 和来源 第十二节:RAG 入库 可引用的知识块

💡 注意:表格从第一步就被当作独立内容类型。它不该先变成普通文本再“猜列”,而要直接走表格专用流程。


🔍 三、判断文本 PDF、扫描 PDF 和乱码 PDF

现象与原因

  • 现象:提取结果为空;部分页面有文字;整篇结果充满 (cid:123)
  • 原因:扫描页只有像素;隐藏文本层为空;字体 ToUnicode 映射损坏;混合体(电子页+扫描页)。

一种可行的判断实现

utils/pdf_classify.py::classify 随机抽取最多 10 页,依次检查有效字符数、乱码比例和图像覆盖率:

if (get_avg_cleaned_chars_per_page(pdf, pages_to_check) < 50 or detect_invalid_chars(sample_pdf_bytes)):
    return 'ocr'
if get_high_image_coverage_ratio(sample_pdf_bytes, pages_to_check) >= 0.8:
    return 'ocr'
return 'txt'
  • 有效字符数:去掉空白再统计,避免“排版空格多”被误判。
  • 乱码检测:统计 (cid:数字) 的字符占比。
    cid_pattern = re.compile(r'$cid:\d+$')
    cid_chars_ratio = cid_count / (cid_count + text_len - cid_len)
    return cid_chars_ratio > 0.05
    

参数背后的取舍:异常时默认走 OCR,是刻意的“召回优先”。OCR 慢,但把整页当成空文档,RAG 将永远检索不到它。生产系统应当记录抽样指标,并支持逐页分类


⚙️ 四、统一输入,并在推理前裁剪页码

1. 图片为什么先转成 PDF?

后续所有算法都依赖 PDF 的页数、页面尺寸、页对象和渲染接口。
cli/common.py::read_fn文件字节判断类型,图片统一转成 PDF:

def read_fn(path):
    path = Path(path)
    file_bytes = path.read_bytes()
    file_suffix = guess_suffix_by_bytes(file_bytes, path)
    if file_suffix in image_suffixes:
        return images_bytes_to_pdf_bytes(file_bytes)
    if file_suffix in pdf_suffixes:
        return file_bytes
    raise Exception(f"Unknown file suffix: {file_suffix}")

2. 页码为什么在推理前裁剪?

start_page_idend_page_id 从 0 开始,裁剪发生在渲染和模型调用之前:

def _prepare_pdf_bytes(pdf_bytes_list, start_page_id, end_page_id):
    return [
        convert_pdf_bytes_to_bytes_by_pypdfium2(
            pdf_bytes, start_page_id, end_page_id
        ) for pdf_bytes in pdf_bytes_list
    ]

收益

  1. 不处理无关页面(省算力)。
  2. 不覆盖源文件(可回溯)。
  3. 子任务的页索引保持连续(页码不错位)。

🧩 五、先做版面识别,再做内容识别

整页 OCR 是常见错误:它会把正文、页眉、页脚、表格线、图片中的文字、公式和旁注全部混进一个文本流。

正确的顺序
整页图像 -> 版面区域 -> 按类型调用模型 -> 合并回页面坐标

  • 版面检测:找区域。
  • OCR:负责普通文字。
  • 公式检测:定位公式框,输出 LaTeX。
  • 表格:先做方向校正与有线/无线分类,再选对应的结构模型。

实现上,Pipeline 首先跑版面模型:

images_layout_res = self.model.layout_model.batch_predict(
    pil_images, YOLO_LAYOUT_BASE_BATCH_SIZE
)

images_layout_res 只是每页的区域候选,不是最终文本。


🚀 六、批处理到底在控制什么

这一段有三个名字最容易被误解:一个环境变量、一条任务记录、一个倍率。

1. MINERU_MIN_BATCH_INFERENCE_SIZE:页面任务的分批上限

  • 含义:Pipeline 外层一次交给 batch_image_analyze() 处理的“页面任务数上限”。
  • 默认值:384。
  • 逻辑
    • 设为 384:1000 页输入 -> 分成 384 + 384 + 232 三批。
    • 设为 100:1000 页输入 -> 分成 10 批。
  • 作用:控制页面如何分批进入 Pipeline。调大减少调度开销但抬高内存;调小省内存但吞吐下降。

2. (pdf_idx, page_idx, image, ocr_enable, lang):一条页面任务

这是任务的记账结构,不是 PDF 内容:

字段 含义
pdf_idx 该页属于输入列表中的第几个 PDF
page_idx 该页在这个 PDF 内的页号,从 0 开始
image 页面渲染出的 PIL 图像
ocr_enable 该页是否启用 OCR
lang OCR 语言,如 chen

3. batch_ratio:显存自适应倍率

它是倍率,用来放大某些子模型的基础 batch,不是质量分数:

if gpu_memory >= 16: batch_ratio = 16
elif gpu_memory >= 12: batch_ratio = 8
elif gpu_memory >= 8: batch_ratio = 4
elif gpu_memory >= 6: batch_ratio = 2
else: batch_ratio = 1

例如公式识别的基础 batch 是 16,实际使用值可能是 batch_ratio * 16


🛡️ 七、OCR 如何避免截断和误识别

1. 扩边:避免把字符裁掉

布局框往往贴着文字边缘,直接裁剪会截断首尾字符。稳妥的做法是裁剪时向外扩边:

new_image, useful_list = crop_img(
    res, np_img, crop_paste_x=50, crop_paste_y=50
)
adjusted_mfdetrec_res = get_adjusted_mfdetrec_res(
    single_page_mfdetrec_res, useful_list
)
  • 50:上下左右预留的安全边界像素。
  • useful_list:记录裁剪区域在原页中的坐标。

2. 分组识别与低置信度处理

  • 分组识别:OCR 识别应按语言分组,中英文混排时尤其重要。
  • 低置信度:宁可标注,不要入库。
    if ocr_score > OcrConfidence.min_confidence:
        span['content'] = ocr_text
        span['score'] = float(f"{ocr_score:.3f}")
    else:
        span['content'] = ''
        span['score'] = 0.0
    
    原则:低质量区域标记为待复核,而不是当作正常文本 embedding。

📊 八、表格:为什么必须独立解析

把表格压成一句话,列关系就没了:

错误示例产品 2023 2024 增长率 A 10 12 20%
问题:“10 是 2023 年的,还是 2024 年的?”——这句话已经无法回答。

正确流程
表格框 -> 方向旋转 -> 有线/无线分类 -> 单元格 OCR -> 网格匹配 -> HTML

  • 兜底策略:先用无线表格模型处理全部表格,再把“被判为有线”或“无线模型自身置信度偏低”的结果交给有线表格模型兜底。
  • HTML 转义:单元格文本写入 HTML 之前必须转义 html.escape(ocr_res[0]),否则 <> 会破坏结构。
  • 跨页表格:判定原则是宁可少合并,也不能把两个相邻表格拼错。即使最终没有合并,RAG 侧也要把表头复制到后页数据块的 chunk 里。

🧮 九、公式、图片和方向文字

公式需要两个动作:检测在哪里,再识别写了什么

images_mfd_res = self.model.mfd_model.batch_predict(
    np_images, MFD_BASE_BATCH_SIZE
)
images_formula_list = self.model.mfr_model.batch_predict(
    images_mfd_res, np_images,
    batch_size=self.batch_ratio * MFR_BASE_BATCH_SIZE,
)
  • MFD:公式检测,只输出框。
  • MFR:公式识别,输出 LaTeX。

普通 OCR 处理不了上下标、矩阵和希腊字母,所以公式必须独立走一条链路。


📖 十、恢复字符顺序、段落和标题结构

1. 概念定义

  • span:文字、公式、图片、表格等最小内容单元。
  • line:视觉上属于同一行的一组 span。
  • block:标题、正文、表格主体等区域。
  • page:包含页面尺寸和多个 block 的页面对象。

2. 排序逻辑

span 依靠“覆盖率 + 类型兼容性”放进 block:

if calculate_overlap_area_in_bbox1_area_ratio(span_bbox, block_bbox) > temp_radio \
   and span_block_type_compatible(span['type'], block_type):
    block_spans.append(span)
  • 横排 vs 竖排
    if vertical_ratio > 0.8:
        # 竖排:按 x2 从右到左成列、再按 y 从上到下
        block_lines = merge_spans_to_vertical_line(block['spans'])
        lines = vertical_line_sort_spans_from_top_to_bottom(block_lines)
    else:
        # 横排:按 y 聚合、再按 x 从左到右
        block_lines = merge_spans_to_line(block['spans'])
        lines = line_sort_spans_by_left_to_right(block_lines)
    
  • 双栏页面:需要 block 级的阅读顺序排序,不能简单按 (y0, x0) 排序。

🗂️ 十一、为什么要保留 middle JSON

解析流水线通常产出四种结果,各有分工:

输出 用途
model.json 模型原始输出,用于定位“检测/识别”错误
middle.json 清洗、归类、排序后的结构,支持回放
content_list.json RAG 切块与入库
Markdown 人工阅读、预览、导出

Content List 中的 bbox 会归一化到 0~1000,从而与页面尺寸解耦:

[
    int(x0 * 1000 / page_width), int(y0 * 1000 / page_height),
    int(x1 * 1000 / page_width), int(y1 * 1000 / page_height),
]

除了 bbox,还要保存 source、文档 hash、page_idx、标题路径、内容类型、解析器和版本号。


📥 十二、RAG 入库方法

切块顺序:先按标题和段落切,再对过长段落按句子或 token 做二次切分。不要直接从整篇 Markdown 按固定字符数切割

各类内容的保存方式也不同:表格同时保存 HTML 和行级检索文本,公式保存 LaTeX 与截图,图片保存 caption 与资源路径。

def to_chunks(items, source, title_path=()):
    chunks = []
    for index, item in enumerate(items):
        if item.get('type') != 'text':
            continue
        body = item.get('text', '').strip()
        if not body:
            continue
        prefix = ' / '.join(title_path)
        chunks.append({
            'chunk_id': f"{source}-p{item['page_idx']}-b{index}",
            'text': f'{prefix}\n{body}'.strip(),
            'source': source,
            'page_idx': item['page_idx'],
            'bbox_1000': item.get('bbox'),
            'content_type': item.get('type'),
        })
    return chunks

🐞 十三、解析失败怎么定位

按“从上游到下游”的顺序查,一次只怀疑一层:

  1. 分类指标:平均有效字符、乱码比例、图像覆盖率——先确认是不是走错了 OCR。
  2. layout bbox:标题、正文、表格、图片框是否覆盖正确。
  3. 识别结果:OCR score、LaTeX、表格 HTML——判断是识别错了,还是裁剪错了。
  4. span / line / block:检查双栏、竖排、脚注和重复内容。
  5. 跨页表格:检查表头、列数和 rowspan / colspan
  6. 最终 chunk:确认标题路径、页码、bbox 和来源都在。

提醒:不要只看 Markdown 是否“能读”。应该同时验收原 PDF、bbox 可视化、middle.json、表格 HTML 和最终 chunk。


🏁 十四、后端选择与总结

1. Pipeline vs VLM

模式 流程 适用场景
Pipeline 版面检测 -> OCR/公式/表格专用模型 -> 确定性后处理 格式稳定、文档量大、需要可控表格 HTML 和公式 LaTeX
VLM images_list -> predictor.batch_two_step_extract -> middle_json 复杂版面、代码、引用等难以用固定规则描述的页面
  • 生产环境建议路由模式。正常文本 PDF 走 Pipeline,扫描、乱码、复杂页由 VLM 兜底,低置信度页进入重试或人工复核。

2. 总结:PDF RAG 的可靠链路

输入规范化 -> 文本/OCR 分类 -> 页面渲染和版面定位 -> OCR / 公式 / 表格 / 图片专门解析 -> 阅读顺序和段落恢复 -> Content List + 资源 + provenance -> 语义切块与向量索引

这条链上每一环都在回答一个具体问题:

  • 分类:有没有可用的文本层?
  • 版面检测:内容在哪里?
  • 阅读顺序:先读哪一栏?
  • 表格模型:这个数字属于哪一列?
  • 公式识别:这个符号是什么?
  • 页码和 bbox:答案能否被验证?

把这些都做对,才配得上“PDF 解析完成”这四个字。

posted @ 2026-09-17 10:57  bamb00  阅读(123)  评论(0)    收藏  举报