说实话,今年做 RAG 项目,最让我头疼的不是向量模型选型,不是 rerank 调参,而是最不起眼的文档预处理。尤其是 Windows 本地环境下的 PDF 解析:合同是扫描件、报告是双栏、论文全是公式,用 pypdf 提出来的文本连分段都是乱的。直到我把 MinerU 4.0 部署到本地的 Windows 工作站,离线把 PDF 批量解析成结构化 Markdown,RAG 的文档预处理才算真正打通。这篇文章记录了从环境准备、命令行跑通、Python 批处理到接入 RAG 的完整实战过程,踩过的坑和最终方案都会一步步展开。
1. 为什么偏偏是 MinerU:RAG 文档预处理的痛点与选型逻辑
1.1 RAG 质量的上限,在文档解析这一步就被钉死了
RAG 社区里流传一句话:Garbage in, garbage out。以前我不太当回事,直到自己亲手把一个烂解析结果送进切分器,才彻底明白。检索链路大致是:文档解析 → 清洗 → 切分 → embedding → 召回 → 重排 → 生成。大多数团队的优化重心放在 embedding 模型和 reranker 上,但没有人想过,如果第一步文档解析就把结构丢了,后面所有环节都是在"垃圾"里做文章。
举个真实例子。我处理过一份双栏排版的行业白皮书,用 pdfplumber 按坐标提取文本,得到的顺序大概是"左栏上半部、右栏上半部、左栏下半部、右栏下半部",甚至伴随着页眉页脚来回穿插。这样的文本切出的 chunk,语义完全是跳跃的,检索命中率再调也上不去。PDF 本身就是一种"排版格式",它保存的是每段文字在页面上的坐标,而不是阅读顺序。解析工具必须通过版面分析把坐标还原成阅读顺序,这就是 MinerU 这类工具的价值。
1.2 开源方案选了一圈,为什么留下 MinerU
我当时在 Windows 工作站上横向对比过几类方案,简单列一下:
| 方案 | 原理 | 扫描件 | 结构还原 | 中文/公式 | 适合场景 |
|---|---|---|---|---|---|
| pypdf / pdfplumber | 直接抽取文本与坐标 | 不支持 | 弱 | 一般 | 简单纯文本 PDF |
| OCRmyPDF | OCR 转文字层 | 支持 | 弱 | 一般 | 扫描件转可检索 PDF |
| Marker | 深度学习版面还原 | 支持 | 中 | 中 | 英文为主 |
| MinerU | 版面检测 + OCR + 公式 + 表格一体化 | 支持 | 强 | 中文友好 | 中文文档/论文/扫描件/复杂表格 |
关键差异在哪里?MinerU 不是"把文字抠出来",而是把整个页面还原成段落、标题、图片、表格、公式这些结构化块,最后输出一份 Markdown 和一份 JSON。Markdown 留给人工阅读和直接喂给 RAG,JSON 里则保存着每个 block 的坐标、类型、内容——这些是精细切分和清洗的原材料。
其实对 RAG 来说,表格才是最伤脑筋的东西。纯文本提取会把表格糊成一团,而 MinerU 会把表格还原成 HTML 结构,这样切分后仍然能保留行、列对应关系。这一条就足够让我选它。
1.3 坚持本地离线部署的三点理由
第一,数据安全。我处理的很多是内部合同、技术文档,不可能传到别人的 OCR API 上。本地部署意味着文件从头到尾不出机器,模型权重也只是推理时加载在内存和显存里。
第二,成本可控。批量文档解析按页收费的 API,处理几千页 PDF 不是小数目;本地跑虽然要电费,但机器是现成的。
第三,可编排。本地命令行和 Python API 支持批处理、断点续跑、失败重试,这些能力在 Web 管理后台里不一定给你。
"离线"要澄清一下:MinerU 的模型权重第一次使用需要从模型仓库下载,一旦下载完成并缓存到本地,后续整个解析过程不再依赖网络。真正全内网的机器也完全可玩,把模型目录整体拷贝过去即可。
2. 环境准备阶段:Windows 上这些坑提前踩完
2.1 Python 虚拟环境:先隔离再动手
MinerU 依赖一堆深度学习相关的包,直接装到系统 Python 里大概率会把别的环境搞乱。我建议用 conda 建个独立环境,Python 版本选 3.10,整体兼容性最稳。
conda create -n mineru python=3.10 -y conda activate mineruWindows 用户很容易忽略一个点:项目路径、工作路径里不要带中文和空格。MinerU 底层有一堆 C++ 扩展和推理框架,对 Unicode 路径的支持时好时坏,我曾经因为放在C:\Users\张三\Desktop\知识库\下反复报错,换到纯英文路径后一切正常。这不是调侃,是真实踩出来的。
2.2 先想清楚用 CPU 还是 GPU:直接决定你的耗时预期
如果你机器有 NVIDIA 显卡,优先用 GPU。MinerU 的管线里最吃资源的是版面检测模型和公式识别模型,测试下来:
| 运行设备 | 耗时参考(一页普通文字 PDF) | 显存占用参考 |
|---|---|---|
| NVIDIA GPU(6GB 以上) | 1~3 秒 | 3~6GB |
| CPU 多核 | 20~60 秒 | 内存为主,约 4~8GB |
注意这是普通文档的参考值,扫描版走 OCR 还要再加时间。如果是纯 CPU 机器,不建议一次跑太多文档,可以配合第 4 章的批处理脚本逐份处理,别把系统直接卡死。有 6GB 显存以上的显卡体验会好很多,我手头的 RTX 2060 6G 都能跑,只是偶尔在大表格页面会紧张。
2.3 安装 MinerU 和依赖:准备好 C++ 编译环境
用 pip 直接装:
pip install "mineru[full]"这里有两个常见问题。第一,Windows 上部分依赖需要 C++ 编译器,最典型的是一些视觉模型组件,安装时报Microsoft Visual C++ 14.0 is required,先装 Visual Studio 2022 build tools,勾选"使用 C++ 的桌面开发"组件。第二,如果某个包在 PyPI 上没有对应 Windows 的预编译 wheel,而系统又去源码编译,多半会失败,解决办法是更换当前 Python 小版本(例如 3.10.11 换到 3.10.0),或者找匹配的预编译 wheel 手动安装后再装 MinerU。
装完之后确认一下版本:
mineru --version2.4 模型下载:网络就绪后一劳永逸
首次运行 MinerU 会自动下载模型,国内网络如果不稳定,可以先把模型源切到 ModelScope:
set MINERU_MODEL_SOURCE=ModelScope模型默认会缓存到用户目录下的.cache(具体路径取决于下载源,HuggingFace 默认在%USERPROFILE%\.cache\huggingface,ModelScope 默认在%USERPROFILE%\.cache\modelscope,也可以设置MINERU_MODEL_CACHE统一指定缓存位置)。下载成功后做一次全量解析验证,之后日常使用就是完完全全的离线状态。内网机器想离线部署,只需要在一台有网络的机器上下好模型,把整个模型目录拷贝进去,再设置环境变量指向它即可,这个操作我在公司的隔离网段验证过,可行。
3. 命令行快速跑通:第一份 PDF 转 Markdown
3.1 最简命令
环境准备好后,执行:
cd D:\workspace mineru -p D:\docs\report.pdf -o D:\docs\output命令结束之后,输出目录里会多一个以原 PDF 文件名命名的文件夹,结构类似:
output/report/ ├── report.md ├── report.json ├── report_layout.pdf └── images/ ├── 1_0.png ├── 1_1.png └── ...其中report.md就是我最关心的结构化结果。如果这份 PDF 是纯文字版,速度很快;如果是扫描版,MinerU 会自动判断并走 OCR 管线,时间会长一些。
3.2 常用参数解析
不同小版本之间 CLI 参数可能有微调,建议先跑mineru --help看一遍。以我当前版本实践到的参数为例:
| 参数 | 作用 | 我的用法 |
|---|---|---|
-p | 指定输入 PDF 路径 | -p D:\docs\report.pdf |
-o | 指定输出根目录 | -o D:\docs\output |
-m | 解析模式:auto/ocr/txt | 默认 auto 即可 |
--lang | 指定主要语言 | 中文文档用--lang ch |
--device | 指定推理设备 | 有显卡--device cuda,无显卡--device cpu |
-s/-e | 指定起始页/结束页 | 定位失败页时非常有用 |
-m auto是我最常用的模式。它会逐页判断页面里有没有可复制的文字层:有文字层就按文本抽取,没有就自动切换到 OCR。绝大多数场景下什么都不用改。如果你确定整个 PDF 都是扫描件,可以直接给-m ocr,省掉自动判断的开销。
3.3 从产物反推解析质量
我第一次跑完就盯着report_layout.pdf看。这页可视化文件把检测到的版面块用不同颜色的框标出来:标题、正文、表格、图片、公式都一目了然。框的位置如果对得上,Markdown 基本不会出大问题;如果某块位置错乱,再去翻 JSON 里的对应内容定位原因。
report.json是另一个宝藏。它把页面上每个 block 的类型、坐标、文本、嵌套关系都记录下来。举个例子,RAG 切分时如果只用 Markdown,遇到没有标题的纯文本段落,切分器只能盲切;但用 JSON 里的 block 类型,可以按"段落边界 + 表格完整性 + 坐标纵坐标"来做更精细的切分点选择。这是后面进阶玩法的基础。
4. Python API 批量处理:把解析变成管线的一环
命令行只是探路,真正做 RAG 文档预处理,手里往往是几十个上百个 PDF,必须脚本化。
4.1 MinerU 的 Python API 入口
4.0 提供了比较干净的 Python API,核心就是MinerU类和配置对象MinerUConfig:
from pathlib import Path from mineru import MinerU from mineru.core.config import MinerUConfig def parse_single_pdf(pdf_path: Path, output_root: Path) -> None: config = MinerUConfig( pdf_filename=str(pdf_path), output_dir=str(output_root), method="auto", device="cuda", lang="ch", ) mineru = MinerU(config) mineru.run()需要注意的一点是:不同小版本MinerUConfig里能传的字段名可能略有差异。比如有些版本参数叫device,有些版本在配置里用devices也有过调整。最稳妥的办法是安装后先用python -c "import inspect; from mineru import MinerUConfig; print(inspect.signature(MinerUConfig))"查看实际字段。如果只是想跑批处理,不折腾配置,也可以直接调用命令行:
import subprocess def parse_cli(pdf_path: str, out_dir: str) -> None: subprocess.run( ["mineru", "-p", pdf_path, "-o", out_dir, "--device", "cuda"], check=True, )这样依赖的是已经验证过的 CLI,反而更不容易出错。
4.2 批量任务编排:并发别太贪
我写批处理脚本时踩过一次并行度的坑。最初用concurrent.futures.ThreadPoolExecutor开 4 个线程同时跑,直接把显存撑爆,日志里刷出一片 CUDA out of memory。后来改成两个思路:GPU 机器上一次只跑一个 PDF 任务,但可以用异步方式预载多个文件路径,减少 IO 空闲;CPU 机器上开 2 个进程分别跑不同文件,只要内存没爆就没问题。
一个比较实用的批处理脚本骨架:
import logging from pathlib import Path logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") logger = logging.getLogger(__name__) INPUT_DIR = Path(r"D:\docs\source") OUTPUT_DIR = Path(r"D:\docs\parsed") def run_batch(): pdf_files = sorted(INPUT_DIR.glob("*.pdf")) logger.info("共发现 %d 个 PDF", len(pdf_files)) for pdf in pdf_files: try: parse_single_pdf(pdf, OUTPUT_DIR) logger.info("成功: %s", pdf.name) except Exception as exc: logger.error("失败 %s: %s", pdf.name, exc) # 记录失败列表,最后统一重跑这里我特意不做并发,优先保证稳定性。对大多数团队来说,晚上挂机批量跑,第二天收结果,比白天抢显存更实际。
4.3 断点续跑:失败是常态,设计上要认
批量跑几十份 PDF,总有几份解析到一半崩掉。不想每次重头再来,就需要在脚本里加"已完成跳过"逻辑。判断依据可以这样设计:
def is_done(pdf_path: Path, output_root: Path) -> bool: md_file = output_root / pdf_path.stem / f"{pdf_path.stem}.md" if not md_file.exists(): return False if md_file.stat().st_size < 1024: return False # 产物过小,大概率没有解析成功 return True主循环里在parse_single_pdf之前先判断is_done,已经成功过的文件直接跳过。如果某份文档几十页,其中只有一页出了问题,可以用-s / -e把那一页单独拎出来重跑,再把 md 文件拼接回去。这个方法我用了很多次,省下大量重跑时间。
5. 扫描版、复杂版式与表格:实战调优记录
5.1 一眼判断 PDF 是文字版还是扫描版
在投入 MinerU 解析前,先快速判断文档类型能帮你规划解析策略。用 pypdf 做个简单检测:
from pypdf import PdfReader def has_text_layer(pdf_path: str) -> bool: reader = PdfReader(pdf_path) for page in reader.pages: text = page.extract_text() if text and text.strip(): return True return False纯扫描件没有任何可选中的文字,extract_text()返回空或纯粹乱码,这类文档直接全部走 OCR。文字版但是夹杂扫描插图,-m auto会自动按页选择策略,不需要你操心。不过要注意一种特殊情况:部分政府公文、老式印刷 PDF,文字层确实存在,但字体编码是乱码,提取出来是"锟斤拷"这类,这种情况在auto模式下 MinerU 可能也会先走文本抽取路径。我遇到时会手动指定该文档为-m ocr,强制整体 OCR,效果立刻正常。
5.2 表格识别:效果好,但检查别偷懒
MinerU 对规则表格的识别非常强,输出为 HTML<table>。这给 RAG 切分带来一个好处:表格在切片里是完整结构,而不是一串扁平文本。但复杂表格仍然有翻车的时候,最典型的是合并单元格错位、跨页表格被切分成两个表。应对办法有两个:
第一,解析后用report_layout.pdf抽查有表格的页面,确认框范围是否准确。第二,跨页表格在 RAG 场景里可以接受拆成两个块,但最好在清洗阶段给两个表加相同的标题标识,例如[表 3(续)],否则检索时只命中断头表会缺失上下文。
5.3 公式识别:按需开关,别让它拖慢整个批次
MinerU 能把公式识别成 LaTeX,这对论文类 RAG 是刚需。但公式识别模型是管线里最重的一环,CPU 上跑一篇公式密集的论文,耗时能翻好几倍。如果你处理的文档主要是合同、报告、行政文书,根本不需要公式,建议在配置里关闭公式相关的识别项。具体参数在不同版本中不一致,有的版本通过 pipeline 配置控制,有的版本通过模型选择切换,建议翻一下对应版本文档里的"公式识别(Formula)"开关说明。简单说:不是所有文档都需要全套模型,按需裁剪是 RAG 预处理调优里最划算的一步。
5.4 内存与显存压力大的应对
真正的大文档(几百页、图片密集)容易把内存和显存都顶满。我目前的稳定方案是:预处理时按页范围切片跑。先用脚本把大 PDF 按 50 页一组拆成多个小 PDF(拆页用 pypdf 几分钟就能写完),每组单独跑 MinerU,最后合并 md。这么做的还有一个额外好处:某组失败重跑时,只重跑 50 页,不连累其他组。Windows 上次序执行多组任务,用个简单的循环脚本就能控制好内存峰值。
6. 解析结果如何接入 RAG:切分、清洗与验证闭环
6.1 为什么 Markdown 结构对 RAG 这么重要
很多人问我,RAG 切分直接用文本不就行了吗?真正做了才知道差在哪。MinerU 输出的 Markdown 保留了标题层级#、##、###,这让切分器可以直接按标题边界切,而不是固定长度盲切。双栏 PDF 被还原成连续阅读顺序,切出来的 chunk 天然就是逻辑完整的段落。表格以 HTML 形式存在,embedding 模型能根据 HTML 标签理解表格语义。这些都是一行命令背后的直接价值。
配合 LangChain 或 LlamaIndex 的 Markdown 切分器,或者直接按需要自定义按#标题切分,效果远好于固定chunk_size=512。
6.2 清洗:删除页眉页脚与 OCR 噪声
MinerU 不是神,它也会把页眉页脚、期刊名称这些重复内容带进 Markdown。这些内容在切分后会造成大量重复 chunk,污染检索结果。我的清洗策略是先跑一个规则脚本:
- 用正则匹配常见页眉页脚模式(如连续多页相同的短段落),直接过滤;
- 把 HTML 表格中的多余空白单元格压缩;
- 删除长破折号、全角空格等 OCR 常见噪声;
- 如果在 JSON 里能看到 block 类型,优先保留正文、表格、公式块,页眉页脚如果被识别出来就丢弃。
import re def clean_markdown(md_text: str) -> str: # 示例:过滤连续重复出现的页眉 lines = md_text.splitlines() seen = {} result = [] for line in lines: key = line.strip() if key and len(key) < 60: seen[key] = seen.get(key, 0) + 1 if seen.get(key, 0) > 3: continue # 超过 3 页重复,判定为页眉 result.append(line) return "\n".join(result)这个脚本非常粗糙,但足以说明思路:把预处理阶段和"MinerU 解析"两个环节拆开,清洗逻辑保持独立,便于迭代升级。
6.3 质检:用 5 类样本文档验证效果
在正式跑全量之前,建议先建立一个小型质检集。我通常选这 5 类:
| 类型 | 检查重点 |
|---|---|
| 纯文字 PDF | 标题层级、段落顺序 |
| 扫描版合同/单据 | OCR 错字率、数字是否完整 |
| 复杂表格报表 | 表格行列对应、合并单元格 |
| 学术论文 | 公式 LaTeX、双栏顺序 |
| 图文混排手册 | 图片是否保留、说明文字顺序 |
每类文档跑完后人工过一眼 Markdown 和版面可视化,记录耗时、成功页数、明显错漏页。如果解析结果不理想,返回去看对应页的布局框,判断是检测框偏移还是 OCR 引擎的问题。这套质检流程只需要多花半小时,却能避免全量跑完才发现方案不可用,强烈建议不要跳过。
最后,我还有一个小技巧:把 MinerU 输出的 JSON 作为切分元数据存下来,与 Markdown 一一对应。这样在 RAG 上线后,如果某个查询命中了一个 chunk,可以直接定位回原始 PDF 的页码和坐标,做引用溯源。这个功能对于企业内部知识库非常实用,因为用户永远会追问:"你这个回答依据的是文档里的哪一段?"。