• 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_id 和 end_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 语言,如 ch、en

    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 解析完成”这四个字。

  • 相关阅读:
    Python案例4
    关于:在 Windows 10/11 中共享文件和打印机
    金仓数据库 KingbaseGIS 使用手册(8.9. 栅格波段统计和分析函数、8.10. 栅格输出)
    Redisson分布式锁实战
    【MySQL】存储引擎
    CSS文本超限后使用省略号代替
    flink中配置Rockdb的重要配置项
    html中图片、音乐、视频标签及选择器、背景
    高速串行信号串接电容放在发送端还是接收端
    vscode登录租的新服务器
  • 原文地址:https://www.cnblogs.com/goodhacker/p/23008551