RAG 做不好?可能是你的 PDF 在"捣乱" 😅

0 阅读6分钟

RAG 做不好?可能是你的 PDF 在"捣乱" 😅

------一次把 PDF 解析从"能用"优化到"好用"的实战记录

很多人做 RAG 时都会关注:向量模型、分块策略、Top-K 参数。 但真正影响效果的,往往是最容易被忽视的一步------PDF 解析

如果解析结构错了,后面再强的模型,也只是在"错误文本"上做优化。


一、RAG 的隐形地基:PDF 解析

RAG 的流程:

  1. 解析文档
  2. 切分 chunk
  3. 向量化
  4. 检索
  5. 拼接上下文给 LLM

问题出在第一步。

当 PDF 被错误解析时,常见问题包括:

  • 表格被拆成乱序文本
  • 多栏内容拼接混乱
  • 标题层级丢失
  • 页眉页脚混入正文
  • 图片中的文字被忽略

这些问题会直接导致检索命中不准、上下文错位。


二、PDF 为什么难?

PDF 本质不是"文本文件",而是"绘图指令"。

它只记录:

  • 在某个坐标画一段文字
  • 在某个位置画一张图片
  • 在某个区域画线条

它没有"段落"、"标题"、"表格"概念。

解析 PDF,其实是在做:

结构重建。

这就是难点。


三、RAG 场景中的真实痛点

1. 标题结构丢失

原文:

  1. 项目背景
    1.1 技术路线

错误解析后变成一整段文本,导致 chunk 边界失效。


2. 表格被拍扁

原本结构化表格被识别成:

产品 价格 数量 A 100 2 B 200 5

Embedding 失去列语义。 在合同、标书、财务文档场景里,这会严重影响检索质量。


3. 多栏排版顺序错乱

两栏论文如果按扫描顺序拼接,会导致语义错位。

看起来"有点相关",但始终不精准。


四、工程痛点:显存爆炸

大 PDF(200+ 页)直接解析时常见问题:

  • GPU OOM
  • 模型重复加载
  • 未使用 no_grad
  • 中间张量未清理

解决方案:

  • 模型只加载一次
  • 分段解析 PDF
  • 使用 no_grad
  • 主动清理显存

示例:

with paddle.no_grad():
    result = model(image)
del obj
gc.collect()
paddle.device.cuda.empty_cache()

五、为什么选择 PaddleOCR

优势包括:

  • 支持文本识别 + 表格识别 + 版面分析
  • 可输出结构化表格
  • 支持本地部署
  • 可控性强,适合工程优化
  • 在开源的OCR中,准确率高

在企业级 RAG 场景下,这些能力非常关键。


六、工程优化过程

PaddleOCR-VL 是一款先进、高效的文档解析模型,专为文档中的元素识别设计。其核心组件为 PaddleOCR-VL-0.9B,这是一种紧凑而强大的视觉语言模型(VLM),它由 NaViT 风格的动态分辨率视觉编码器与 ERNIE-4.5-0.3B 语言模型组成,能够实现精准的元素识别。

使用比较简单

#生成md文件
paddleocr doc_parser \
  -i keep.pdf \
  --save_path ./output1

但是直接使用有很多问题,生成的md文档结构无法达到要求,特别是针对表格的生成。

  • 表格里面的内容,继续被识别成文本与标题,造成内容重复;

    image-20260222101501662

    通过解析出来的图片既能看到问题的原因,即被识别到表格中,又识别成text

    image-20260222101632172

  • 由于分页造成一行的信息,被分割成多行。特别对标书这种文件,表格里面某一列的内容特别的多,就被分割成多行;

  • 当一个pdf很大,超过150页时,直接卡死;

    image-20260222113433209

优化思路

原始做法(容易出问题):

大 PDF → PaddleOCR → 直接输出 Markdown

优化后改为:

PDF
  ↓
① 切分(10页一段)
  ↓
② PaddleOCR 输出结构化 JSON
  ↓
③ 自己解析 JSON → 生成 Markdown

这一步是工程思维的转变

分段解析大 PDF

每 5~10 页切一段,分段后优点:

  • 可控
  • 可恢复
  • 可并行
  • 不容易 OOM

源码示例:

def split_pdf(pdf_path, output_dir, chunk_size):
    """将大 PDF 按页切分为多段。

    参数:
        pdf_path (str): 输入 PDF 文件路径。
        output_dir (str): 切分后的临时输出目录。
        chunk_size (int): 每个分段包含的页数。

    返回:
        list[str]: 切分得到的分段 PDF 路径列表。
    """
    if not os.path.exists(output_dir):
        os.makedirs(output_dir)
    doc = fitz.open(pdf_path)
    total_pages = len(doc)
    logger.info(f"📦 [文件读取] 总页数: {total_pages}")
    split_files = []
    for i in range(0, total_pages, chunk_size):
        start = i
        end = min(i + chunk_size, total_pages)
        chunk_name = f"chunk_{start+1}_{end}.pdf"
        chunk_path = os.path.join(output_dir, chunk_name)
        new_doc = fitz.open()
        new_doc.insert_pdf(doc, from_page=start, to_page=end-1)
        new_doc.save(chunk_path)
        new_doc.close()
        split_files.append(chunk_path)
    doc.close()
    return split_files

生成pdf结构json

优化点:

  • 模型只加载一次

    def create_pipeline(device: Optional[str] = None) -> PaddleOCRVL:
        """创建并返回 `PaddleOCRVL` 管线。
    
        参数:
            device (Optional[str]): 设备标识,如 `gpu:0` 或 `cpu`;为 None 时按默认配置。
    
        返回:
            PaddleOCRVL: OCR/结构化管线实例。
        """
        return PaddleOCRVL(device=device) if device else PaddleOCRVL()
    
  • 推理不建图

    with paddle.no_grad():
    
  • 主动清理显存

    del obj
    gc.collect()
    paddle.device.cuda.empty_cache()
    

完整示例:

def parse_one_file(pipeline: PaddleOCRVL, file_path: Path, output_path: Path):
    """解析单个文件并输出结构化结果。

    参数:
        pipeline (PaddleOCRVL): 已初始化的 OCR/结构化管线实例。
        file_path (Path): 待解析的文件路径(PDF 或图片)。
        output_path (Path): 结果输出目录,包含 JSON/图片/Markdown。

    返回:
        None
    """
    logger.info(f"开始解析 {file_path}")
    with paddle.no_grad():
        out = pipeline.predict(input=str(file_path))
    pages_res = list(out)
    structured = pipeline.restructure_pages(pages_res,merge_tables=False,relevel_titles=False)
    for res in structured:
        res.print()
        res.save_to_json(save_path=output_path)
        res.save_to_img(save_path=output_path)
        res.save_to_markdown(save_path=output_path)
    logger.info(f"完成解析 {file_path}")
    try:
        del pages_res
        del structured
    except Exception:
        pass
    gc.collect()
    try:
        if paddle.device.is_compiled_with_cuda():
            paddle.device.cuda.empty_cache()
    except Exception:
        pass

def create_pipeline(device: Optional[str] = None) -> PaddleOCRVL:
    """创建并返回 `PaddleOCRVL` 管线。

    参数:
        device (Optional[str]): 设备标识,如 `gpu:0` 或 `cpu`;为 None 时按默认配置。

    返回:
        PaddleOCRVL: OCR/结构化管线实例。
    """
    return PaddleOCRVL(device=device) if device else PaddleOCRVL()

def run_pdf2md(input_path: str, output_dir: str, pipeline: Optional[PaddleOCRVL] = None):
    """运行解析流程,输入为单文件或目录。

    参数:
        input_path (str): 输入路径,可以是文件或目录。
        output_dir (str): 输出目录,将保存 JSON/图片/Markdown。
        pipeline (Optional[PaddleOCRVL]): 可选外部管线;提供则复用,不提供则本地创建。

    返回:
        None
    """
    setup_logger()
    ip = Path(input_path)
    op = Path(output_dir)
    op.mkdir(parents=True, exist_ok=True)
    local_pipeline = False
    if pipeline is None:
        local_pipeline = True
        dev = os.environ.get("PADDLE_DEVICE")
        pipeline = create_pipeline(dev)
    if ip.is_dir():
        for fp in sorted(ip.rglob("*")):
            if fp.is_file() and fp.suffix.lower() in {".pdf", ".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff"}:
                try:
                    parse_one_file(pipeline, fp, op)
                except Exception as e:
                    logger.warning(f"文件处理失败 {fp}: {e}")
    else:
        try:
            parse_one_file(pipeline, ip, op)
        except Exception as e:
            logger.warning(f"文件处理失败 {ip}: {e}")
    if local_pipeline:
        try:
            del pipeline
        except Exception:
            pass
    gc.collect()
    try:
        if paddle.device.is_compiled_with_cuda():
            paddle.device.cuda.empty_cache()
    except Exception:
        pass

image-20260222115105244

解析结构json

总体目标

  • 将目录或单页的 OCR 结构化结果( *_res.json )合并为有序、干净、可读的 Markdown 表格/段落输出
  • 正确处理文本与表格的关系、同表的连续合并、跨页首尾行续接、去重与清理

具体核心代码逻辑有:

  • 输入与排序
  • 同页文本去重
  • 文本并入表格
  • 表格连续合并
  • 跨页首尾行续接

这边的解析结构通过java代码实现,敬请下回分解!