RAG 做不好?可能是你的 PDF 在"捣乱" 😅
------一次把 PDF 解析从"能用"优化到"好用"的实战记录
很多人做 RAG 时都会关注:向量模型、分块策略、Top-K 参数。 但真正影响效果的,往往是最容易被忽视的一步------PDF 解析。
如果解析结构错了,后面再强的模型,也只是在"错误文本"上做优化。
一、RAG 的隐形地基:PDF 解析
RAG 的流程:
- 解析文档
- 切分 chunk
- 向量化
- 检索
- 拼接上下文给 LLM
问题出在第一步。
当 PDF 被错误解析时,常见问题包括:
- 表格被拆成乱序文本
- 多栏内容拼接混乱
- 标题层级丢失
- 页眉页脚混入正文
- 图片中的文字被忽略
这些问题会直接导致检索命中不准、上下文错位。
二、PDF 为什么难?
PDF 本质不是"文本文件",而是"绘图指令"。
它只记录:
- 在某个坐标画一段文字
- 在某个位置画一张图片
- 在某个区域画线条
它没有"段落"、"标题"、"表格"概念。
解析 PDF,其实是在做:
结构重建。
这就是难点。
三、RAG 场景中的真实痛点
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文档结构无法达到要求,特别是针对表格的生成。
-
表格里面的内容,继续被识别成文本与标题,造成内容重复;
通过解析出来的图片既能看到问题的原因,即被识别到表格中,又识别成text
-
由于分页造成一行的信息,被分割成多行。特别对标书这种文件,表格里面某一列的内容特别的多,就被分割成多行;
-
当一个pdf很大,超过150页时,直接卡死;
优化思路
原始做法(容易出问题):
大 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
解析结构json
总体目标
- 将目录或单页的 OCR 结构化结果( *_res.json )合并为有序、干净、可读的 Markdown 表格/段落输出
- 正确处理文本与表格的关系、同表的连续合并、跨页首尾行续接、去重与清理
具体核心代码逻辑有:
- 输入与排序
- 同页文本去重
- 文本并入表格
- 表格连续合并
- 跨页首尾行续接
这边的解析结构通过java代码实现,敬请下回分解!