📚前言
📒FDE系列内容总纲:
🚄前置课程列表:
见文档结尾附录。
🚀阶段3·Day 63:文档解析 --- 把真实 PDF 手册变成可用文本
FDE 学习系列教程 · 第三阶段 · 第 9 周 · Day 3 预计时长:3 小时 | 难度:★★★★☆ | 前置知识:Day 61-62(RAG 链路、Embedding)、命令行基础 对标大纲课时:3.2.2 文档解析 ------ PDF/Word/PPT/HTML/邮件解析;OCR;表格提取;扫描件处理(MinerU / Docling / Unstructured)
📌 一句话目标:认清 PDF 的五类坑,用 MinerU 和 Docling 把一份真实设备手册解析成干净 Markdown,掌握表格质检与产物清洗。
🧑🤝🧑 开场:你的资料柜,机器打不开
先看看你这两天攒下的东西:
Day61:跑通了 RAG 八步链路(用的 5 条手写资料)
Day62:BGE-M3 把文字变成向量,检索从"字面"升级到"语义"
现在的知识库:
┌────────────────────────────┐
│ DOCS = [ 5 条我手敲的资料 ] │ ← 三百页的手册呢?
└────────────────────────────┘
第 7-8 周冻结的智能工单助手 v0.1 长在第二阶段的设备告警工单闭环系统(FastAPI + MySQL + Docker + 飞书)之上,它会分类、会提取、会写报告------但那些能力都建立在"输入已经是结构化数据"的前提上。而客户真正想让我们回答的"这台设备怎么修",答案在资料柜里,不在数据库里。
现实是:客户的知识根本不是 txt,更不是 MySQL 里的一行记录,而是这些东西:
📄 设备操作与维护手册.pdf (300 页,扫描版,有些页是图片)
📄 注塑车间作业指导书.docx (带表格、带批注)
📄 设备点检标准.xlsx (合并单元格一大堆)
📄 工艺变更通知.pptx (字都在图形框里)
📧 邮件往来:张工说 A3 的那个参数改了 (正文里夹着关键结论)
🖼️ 现场拍的铭牌照片.jpg (型号在图片里)
更要命的是,这些文件里最值钱的内容往往是最难提取的:
· 参数表(一张表 50 个型号的额定值) → 表格提取不行,全变成乱码
· 安全警示框("警告:必须先断电") → 被当成普通段落,重要性丢失
· 双栏排版的小字注释 → 左右两栏被读成"一句话交叉拼接"
在客户现场我见过太多次这样的翻车:RAG 系统上线了,师傅问"液压油的更换周期",系统答"请咨询供应商"------因为手册里那个参数在表格里 ,而解析工具把表格读成了 液压油 更换周期 | | | 500 | 小时 这种散架的字符流,向量检索根本匹配不上。
RAG 的效果上限,是由解析质量决定的。 后面分块再巧、向量模型再强、重排再精,原料是垃圾,出来的还是垃圾(garbage in, garbage out)。
今天就是"把原料准备好"的一天。三件事:
-
认清 PDF 的五类坑(知道坑在哪,才能验收解析结果)
-
上手两个当下最强的开源解析工具:MinerU 和 Docling
-
写出清洗脚本,把解析产物收拾成能直接分块的干净 Markdown
📖 一、PDF 的五类坑
PDF 这个格式,生来就不是为了"被提取内容"设计的------它是为了打印效果固定设计的。一个 PDF 里存的其实是一堆"把字符画在坐标 (x, y)"的绘图指令,不是"段落/表格/标题"这种结构信息。
这就导致解析 PDF 像是在从一张打印好的纸上反推它的排版结构。坑主要有五个:
坑 1:扫描件 ------ 整页就是一张图
┌──────────────────────────┐
│ │
│ (一整页的照片) │ ← PDF 里根本没有文字层
│ │ 只有一个 image 对象
│ │
└──────────────────────────┘
直接提取文本 → 得到空字符串 ""
✅ 解法:OCR(光学字符识别),先把图片里的字认出来
代价:慢(一页几秒)、可能认错(0↔O、1↔l、8↔B)
怎么判断是不是扫描件:用 PDF 阅读器试着选中一段文字,选不中就说明没有文字层。
# 快速判断:用 PyMuPDF 看每页的文字量
import fitz # pip install pymupdf
doc = fitz.open("手册.pdf")
for i, page in enumerate(doc):
text = page.get_text().strip()
flag = "🖼️ 疑似扫描页" if len(text) < 20 else "📝 有文字层"
print(f"第 {i+1:>3} 页:{len(text):>5} 字 {flag}")
坑 2:双栏排版 ------ 左右两栏被读串行
论文/手册常见双栏:
┌───────────────┬───────────────┐
│ 4.1 冷却系统 │ 4.3 电气系统 │
│ 冷却水压力应 │ 主电机功率为 │
│ 保持在 0.3MPa │ 22kW,启动电 │
└───────────────┴───────────────┘
❌ 朴素提取(按字符坐标从上到下扫):
"4.1 冷却系统 4.3 电气系统 冷却水压力应 主电机功率为
保持在 0.3MPa 22kW,启动电......"
↑ 左右两栏被交织成一段,语义完全破坏
✅ 解法:版面分析(Layout Analysis)------
先用模型识别出"这里有两个栏",再按栏分别读取。
MinerU / Docling 都内置了版面分析,这是它们比 PyMuPDF 强的核心原因。
坑 3:表格 ------ 最值钱也最容易散架
PDF 里的表格,在文件层面其实【不存在】。
它只是一堆"画在这几个坐标上的文字" + "画在这几个位置的线条"。
原表:
┌────────┬──────────┬────────┐
│ 设备 │ 冷却水压力 │ 换油周期 │
├────────┼──────────┼────────┤
│ A3 │ 0.3MPa │ 500h │
│ B2 │ 0.35MPa │ 400h │
└────────┴──────────┴────────┘
❌ 朴素提取:
"设备 冷却水压力 换油周期 A3 0.3MPa 500h B2 0.35MPa 400h"
↑ 丢掉了"哪一行属于哪台设备"的对应关系
向量检索后,模型可能把 B2 的 500h 说成 A3 的
✅ 解法:表格结构识别(Table Structure Recognition)
还原成 Markdown 表格或 CSV/DataFrame,行列的对应关系才保得住
坑 4:图片里的字 ------ 流程图、铭牌、曲线图
· 设备铭牌照片(型号/功率/出厂编号在图上)
· 流程图里的文字标注
· 曲线图的坐标轴刻度与图例
❌ 这些字不在文字层里,提取不到
✅ 解法:OCR + 版面分析(把图片区域也纳入识别范围)
进阶:图片描述(用多模态模型给图生成文字说明,第 11 周高级 RAG 会提)
坑 5:页眉页脚 ------ 噪声的主要来源
每一页顶部:"XX型注塑机操作与维护手册 第 47 页"
每一页底部:"公司内部资料 严禁外传 版本 V2.3 2026-03"
❌ 它们会被重复几十上百次地塞进你的知识库
→ 一来浪费存储与 token
→ 二来"XX型注塑机操作与维护手册"这句在每个 chunk 里都出现,
会拉高所有 chunk 之间的相似度,把检索分数搅浑
✅ 解法:解析后统一清洗(今天第六节给脚本)
五坑速查
| 坑 | 表现 | 解法 | 工具支持 |
|---|---|---|---|
| 扫描件 | 提取文本为空 | OCR | MinerU -m ocr、Docling 内置 OCR |
| 双栏 | 左右栏串行 | 版面分析 | MinerU、Docling 都支持 |
| 表格 | 行列关系丢失 | 表格结构识别 | MinerU、Docling(强项) |
| 图内文字 | 内容缺失 | OCR 图片区域 | MinerU、Docling |
| 页眉页脚 | 噪声重复 | 后处理清洗 | 自写脚本(今天给) |
📖 二、工具定位:四个选手该选谁
大纲 3.2.2 点名了三个:MinerU (~79K Star)、Docling (~59K Star)、Unstructured (~28K Star)。再加个你一定会用到的底层库 PyMuPDF。
| 工具 | 定位 | 强项 | 弱项 | 什么时候用 |
|---|---|---|---|---|
| MinerU | 一站式 PDF→Markdown 转换器 | 中文文档效果极佳;自带版面分析+OCR+公式/表格识别;命令行开箱即用;国内模型源友好 | Python API 相对命令行弱一些;依赖较重 | 中文 PDF 首选,批量转换场景 |
| Docling | IBM 出品的文档解析库 | Python API 优雅;表格提取质量顶级(能直接出 DataFrame);格式支持广(PDF/DOCX/PPTX/XLSX/HTML) | 中文 OCR 需额外配模型;体积大 | 表格多、要程序化集成的场景 |
| Unstructured | 通用文档 ETL 框架 | 格式覆盖最广(含邮件 .eml);云 API + 本地两种模式;生态集成多(LangChain 等) | 中文效果与表格质量不如前两个;分区(chunking)逻辑偏英文 | 格式杂、需要接现成生态 |
| PyMuPDF | 底层 PDF 操作库 | 极快、极轻、零模型依赖;能取坐标/画框/切页 | 不做版面分析,双栏会串行;不 OCR | 快速判断文档类型、做预处理、切页范围 |
FDE 现场决策树:
文档是中文 PDF?
├─ 是 → MinerU(命令行一把梭,出 full.md)
└─ 否 → 往下看
│
表格/结构化数据多?
├─ 是 → Docling(export_to_dataframe 直接拿表)
└─ 否 → 往下看
│
要不要写进自己的 Python 服务?
├─ 要 → Docling(API 更好用)或 MinerU 的 mineru-api(Docker)
└─ 只是批量转一次 → MinerU 命令行
需要快速判断某份 PDF 是不是扫描件 / 切几页试试?
└─ 永远是 PyMuPDF(3 秒出结果)
💡 我的建议:两个都装,两个都会用 。现场常见打法是------先用 PyMuPDF 快速摸清文档底细,再用 MinerU 批量转,遇到表格关键页就用 Docling 单独再跑一遍做交叉验证。解析这活,交叉验证比迷信单一工具靠谱。
🖥️ 三、实操:MinerU 命令行一把梭
实操步骤 1:安装 MinerU
cd fde-ai
.\.venv\Scripts\Activate.ps1
pip install -U "mineru[core]"
⚠️ MinerU 依赖较多(torch、paddle 相关组件等),安装可能需要几分钟。如果与 FlagEmbedding 的 torch 版本冲突,建议为 MinerU 单独建一个虚拟环境(解析是离线一次性任务,不需要和在线服务同环境):
python -m venv .venv-parser
.\.venv-parser\Scripts\Activate.ps1
pip install -U "mineru[core]"
顺便装上 PyMuPDF 用于文档体检:
pip install pymupdf
实操步骤 2:准备一份真实 PDF
有真实设备手册最好。没有的话,自己造一份(这个过程本身能让你理解五类坑):
-
打开 Word,写一页《A3 型注塑机冷却系统维护说明》
-
插入一个 3 行 4 列的表格(设备 / 冷却水压力 / 换油周期 / 责任人)
-
加页眉"XX型注塑机操作与维护手册"、页脚"第 X 页"
-
再写一段"警告:维护前必须切断主电源并挂牌上锁"
-
另存为 PDF ,命名
manual_a3.pdf
放好目录:
fde-ai/
└── data/
└── raw/
└── manual_a3.pdf ← 原始 PDF,永远保留,别改
实操步骤 3:先做个体检(30 秒摸清底细)
新建 day63_probe.py:
"""解析前的体检:这份 PDF 到底是什么货色"""
import fitz
from pathlib import Path
pdf = Path("data/raw/manual_a3.pdf")
doc = fitz.open(pdf)
print(f"文件:{pdf.name} 页数:{doc.page_count}")
print("-" * 62)
scan_pages = 0
for i, page in enumerate(doc):
text = page.get_text().strip()
images = page.get_images()
tables = page.find_tables() # PyMuPDF 也有基础表格检测
tag = "🖼️ 扫描页" if len(text) < 20 else "📝 文字层"
if len(text) < 20:
scan_pages += 1
print(f"P{i+1:<3} {tag} 字数={len(text):<6} 图片数={len(images):<3} "
f"表格数={len(tables.tables)}")
print("-" * 62)
print(f"疑似扫描页:{scan_pages}/{doc.page_count}")
print("→ 扫描页占比高就必须开 OCR(-m ocr)")
# 看一眼第 1 页的原始文本,感受"朴素提取"的质量
print("\n【第 1 页朴素文本提取(前 400 字)】")
print(doc[0].get_text()[:400])
python day63_probe.py
看什么:
-
页数对不对?
-
有多少页是扫描页 → 决定要不要
-m ocr -
朴素提取出来的文本是不是双栏串行、表格散架 → 记下来,等会儿跟 MinerU 的产出对比
实操步骤 4:跑 MinerU 命令行
最基础的一条命令:
mineru -p data/raw/manual_a3.pdf -o data/parsed/mineru
国内用户强烈建议加模型源参数(避免下载模型卡住):
mineru -p data/raw/manual_a3.pdf -o data/parsed/mineru --source modelscope
常用参数速查:
# 纯 CPU 后端(没有 GPU 时务必加上,否则可能报 CUDA 相关错)
mineru -p 手册.pdf -o out -b pipeline --source modelscope
# 扫描件/图片型 PDF → 走 OCR
mineru -p 手册.pdf -o out -m ocr -b pipeline
# 只解析前 10 页(第 0 页到第 9 页,页码从 0 开始)------大文件先试跑
mineru -p 手册.pdf -o out -s 0 -e 9
# 指定语言,提升 OCR 与版面识别准确率
mineru -p 手册.pdf -o out -l ch
# 组合拳:CPU + 中文 + 前 20 页 + 国内源
mineru -p 手册.pdf -o out -b pipeline -l ch -s 0 -e 19 --source modelscope
💡
--source modelscope也可以用环境变量方式设置,避免每次都敲:
# PowerShell $env:MINERU_MODEL_SOURCE = "modelscope" # Linux / macOS export MINERU_MODEL_SOURCE=modelscope
实操步骤 5:看懂产物目录
MinerU 会给每个输入文件建一个子目录:
data/parsed/mineru/
└── manual_a3/
├── full.md ★ 主产物:全文 Markdown(后面就用它)
├── content_list.json 结构化内容列表(含每个块的坐标、类型)
├── model.json 版面分析结果(调试用)
├── layout.pdf 版面可视化(框出了标题/表格/图片区域)
├── span.pdf 文本片段可视化
└── images/ 从 PDF 里抠出来的图片
├── 0.jpg
└── 1.jpg
重点看这三样:
① full.md → 你要的干净文本,Day 64 分块的原料就是它
② layout.pdf → 打开看版面分析框得准不准(质检神器)
标题被框成"标题"了吗?表格被框成一整块了吗?
如果框错了,说明这份文档这个工具吃不下,换 Docling 试试
③ content_list.json → 每个块的 type(text/table/image)+ bbox 坐标 + page
想做"只取第 3 章"或"只取表格"这类精细操作,读这个
看一眼 full.md:
# PowerShell
Get-Content data/parsed/mineru/manual_a3/full.md -TotalCount 60 -Encoding UTF8
# Linux / macOS / Git Bash
head -n 60 data/parsed/mineru/manual_a3/full.md
跟第 3 步的朴素提取对比一下,重点看三点:
-
标题有没有变成
#/##(Markdown 结构保住了吗) -
表格是不是变成了
| --- | --- |的 Markdown 表格 -
双栏有没有被正确分开
实操步骤 6:把 MinerU 变成服务(Docker 部署)
批量解析或要给别的系统调用时,MinerU 提供了 FastAPI 服务 mineru-api。平台类工具一律 Docker 部署(第二阶段 Day 43 的老规矩):
# docker-compose.mineru.yml
version: "3.8"
services:
mineru-api:
image: mineru/mineru-api:latest
container_name: mineru-api
ports:
- "8001:8000"
environment:
MINERU_MODEL_SOURCE: modelscope
volumes:
- ./data:/app/data
restart: unless-stopped
docker compose -f docker-compose.mineru.yml up -d
docker logs -f mineru-api
不想写 compose,一条 docker run 也行:
docker run -d --name mineru-api -p 8001:8000 \
-e MINERU_MODEL_SOURCE=modelscope \
-v ${PWD}/data:/app/data \
mineru/mineru-api:latest
# 用 httpx 调用解析服务(Day 47 学过的技能直接用上)
import httpx
r = httpx.post("http://localhost:8001/parse",
json={"pdf_path": "/app/data/raw/manual_a3.pdf"},
timeout=300)
print(r.json()["markdown"][:500])
⚠️ 说明:MinerU 的镜像标签与 API 路径会随版本变化,以上为通用写法。实际使用时先
docker run起来后打开http://localhost:8001/docs看它自动生成的接口文档确认路径------第二阶段的 OpenAPI 技能在这里又派上用场了。
🖥️ 四、实操:Docling 的 Python API
MinerU 胜在命令行一把梭和中文效果,Docling 胜在 Python API 与表格质量。两个都学,现场按需切换。
实操步骤 1:安装
pip install docling
实操步骤 2:命令行快速验证
docling data/raw/manual_a3.pdf
默认会在当前目录输出 Markdown。先跑一次确认安装没问题。
实操步骤 3:Python API 完整用法
新建 day63_docling.py:
"""Day63:Docling Python API ------ 转 Markdown + 提取表格"""
from pathlib import Path
from docling.document_converter import DocumentConverter
SRC = Path("data/raw/manual_a3.pdf")
# ① 转换(一个入口吃 PDF/DOCX/PPTX/XLSX/HTML)
converter = DocumentConverter()
result = converter.convert(str(SRC))
doc = result.document # DoclingDocument 对象
# ② 导出 Markdown
md = doc.export_to_markdown()
out = Path("data/parsed/docling/manual_a3.md")
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(md, encoding="utf-8")
print(f"✅ Markdown 已写入 {out}({len(md)} 字符)")
# ③ 提取表格(Docling 的王牌:直接给你 DataFrame)
tables = [t.export_to_dataframe() for t in doc.tables]
print(f"\n共识别到 {len(tables)} 个表格")
for i, df in enumerate(tables, 1):
print(f"\n------ 表格 {i}({df.shape[0]} 行 × {df.shape[1]} 列)------")
print(df.to_string(index=False))
df.to_csv(f"data/parsed/docling/table_{i}.csv", index=False, encoding="utf-8-sig")
# ④ 看看文档骨架(标题层级是否保住了)
print("\n------ 文档标题结构 ------")
for item in doc.texts:
if getattr(item, "label", None) in ("title", "section_header"):
print(f" [{item.label}] {item.text}")
运行:
python day63_docling.py
输出示例:
✅ Markdown 已写入 data/parsed/docling/manual_a3.md(3821 字符)
共识别到 1 个表格
------ 表格 1(3 行 × 4 列)------
设备 冷却水压力 换油周期 责任人
A3 0.3MPa 500h 张工
B2 0.35MPa 400h 李工
------ 文档标题结构 ------
[title] A3 型注塑机操作与维护手册
[section_header] 4.3 冷却系统维护
[section_header] 4.4 加热系统
实操步骤 4:交叉验证 MinerU 与 Docling
同一份 PDF、两个工具,写个小脚本对比:
"""Day63:MinerU vs Docling 产出对比"""
from pathlib import Path
mineru_md = Path("data/parsed/mineru/manual_a3/full.md").read_text(encoding="utf-8")
docling_md = Path("data/parsed/docling/manual_a3.md").read_text(encoding="utf-8")
for name, md in [("MinerU", mineru_md), ("Docling", docling_md)]:
n_table = md.count("|---") # Markdown 表格分隔行数
n_head = sum(1 for ln in md.splitlines() if ln.startswith("#"))
print(f"{name:<8} 字符数={len(md):<6} 标题数={n_head:<4} 表格分隔行={n_table}")
# 找出只在其中一个里出现的关键词(差异点)
kws = ["0.3MPa", "500h", "挂牌上锁", "警告"]
for kw in kws:
print(f"\n关键词「{kw}」:",
f"MinerU={'✅' if kw in mineru_md else '❌'}",
f"Docling={'✅' if kw in docling_md else '❌'}")
📌 这个"关键词是否出现在产物里"的检查,是最朴素也最有效的解析质检方法。后面做分块、做向量库之后,你还会反复用到它------因为它直接对应"用户问到这个关键词时能不能被召回"。
📖 五、表格提取质量检查清单
表格是工程资料里最容易出事的部分。解析完必须逐项检查,别偷懒。
✅ 七项检查清单
| # | 检查项 | 怎么查 | 不合格的表现 |
|---|---|---|---|
| 1 | 行列数是否正确 | 数 ` | ` 分隔的列数,与原 PDF 对比 |
| 2 | 表头是否被识别为表头 | Markdown 表格第一行是不是列名 | 表头变成了普通数据行 |
| 3 | 合并单元格是否还原 | 看跨行/跨列的值有没有被复制填充 | 合并格只填了第一个,其余空 |
| 4 | 数字与单位是否分离 | 0.3MPa 有没有被拆成 0.3 和 MPa 两列 |
数值列里混进了单位 |
| 5 | 跨页表格是否连续 | 表格被分页截断时,两页是否拼回一个表 | 变成两个残缺的表,第二页没表头 |
| 6 | 是否混入页眉页脚 | 表格里有没有出现"第 X 页" | 表里有噪声行 |
| 7 | 中文是否乱码 | 有没有 \ufffd 或方框符号 |
编码问题 |
检查脚本:自动跑一遍
新建 day63_table_qc.py:
python
"""表格质检:把七项检查自动化"""
import re
from pathlib import Path
md = Path("data/parsed/docling/manual_a3.md").read_text(encoding="utf-8")
lines = md.splitlines()
# ① 找出所有 Markdown 表格块
tables, cur = [], []
for ln in lines:
if ln.strip().startswith("|"):
cur.append(ln.strip())
else:
if len(cur) >= 2:
tables.append(cur)
cur = []
if len(cur) >= 2:
tables.append(cur)
print(f"共发现 {len(tables)} 个表格\n")
for i, t in enumerate(tables, 1):
header = [c.strip() for c in t[0].strip("|").split("|")]
ncol = len(header)
body = t[2:] if len(t) > 2 and set(t[1].replace("|", "").replace("-", "").strip()) == set() else t[1:]
print(f"------ 表格 {i} ------")
print(f" 列数:{ncol} 表头:{header}")
# ② 检查列数一致性
bad = [r for r in body if len([c for c in r.strip("|").split("|")]) != ncol]
print(f" {'✅' if not bad else '❌'} 列数一致:"
f"{len(body) - len(bad)}/{len(body)} 行正常")
# ③ 检查空单元格比例(合并单元格未还原的典型症状)
cells = [c.strip() for r in body for c in r.strip("|").split("|")]
empty = sum(1 for c in cells if not c)
ratio = empty / len(cells) if cells else 0
print(f" {'✅' if ratio < 0.2 else '⚠️'} 空格率:{ratio:.1%}(>20% 可能合并单元格未还原)")
# ④ 检查是否混入页眉页脚噪声
noise = [r for r in body if re.search(r"第\s*\d+\s*页|严禁外传|版本\s*V", r)]
print(f" {'✅' if not noise else '❌'} 页眉页脚噪声:{len(noise)} 行")
# ⑤ 检查乱码
garbled = len(re.findall(r"\ufffd|□", "\n".join(t)))
print(f" {'✅' if garbled == 0 else '❌'} 乱码符号:{garbled} 个")
# ⑥ 检查数字与单位分离(同一格里数字后跟空格再跟单位)
split_unit = [c for r in body for c in r.strip("|").split("|")
if re.search(r"\d\s+[a-zA-Z%℃]+", c.strip())]
print(f" {'✅' if not split_unit else '⚠️'} 数字单位分离:{len(split_unit)} 处 "
f"{split_unit[:3]}\n")
python day63_table_qc.py
💡 表格质检不合格怎么办?按顺序试:① 换工具(MinerU ↔ Docling)② 在 Docling 里调整表格识别模式 ③ 手工补录(关键参数表手工校对一遍是值得的 ,就几十行)④ 实在不行就把表格转成"一句话一条记录"的文本(
"A3 的冷却水压力是 0.3MPa"),牺牲格式换检索可用性------这招在 RAG 场景反而效果更好。
🖥️ 六、解析产物清洗脚本
解析出来的 full.md 通常还带着一堆脏东西。清洗这一步省不得------它直接决定明天分块的质量。
四类必须清洗的噪声
① 页眉页脚 每页都重复的"XX手册 第 47 页"
② 多余空行 连续 3 个以上的空行
③ 断行 PDF 换行导致的句子被腰斩:
"冷却水进水压力应保持在\n0.3MPa 以上" → 应合并
④ 水印/噪声 "严禁外传""样张""DRAFT"
清洗脚本
新建 day63_clean.py:
python
"""Day63:解析产物清洗 ------ 去页眉页脚、去空行、修断行"""
import re
from pathlib import Path
from collections import Counter
SRC = Path("data/parsed/mineru/manual_a3/full.md")
DST = Path("data/cleaned/manual_a3.clean.md")
DST.parent.mkdir(parents=True, exist_ok=True)
raw = SRC.read_text(encoding="utf-8")
lines = raw.splitlines()
# ========== ① 自动识别页眉页脚(出现次数最多的短行)==========
# 思路:真正的页眉页脚会在几十页里重复出现,统计频率就能抓出来
short_lines = [ln.strip() for ln in lines if 0 < len(ln.strip()) <= 30]
freq = Counter(short_lines)
# 出现 ≥5 次且长度短的,判为页眉页脚
threshold = 5
header_footer = {ln for ln, c in freq.items() if c >= threshold}
# 正则兜底:带"第 X 页"、页码、日期戳的行
PAGE_PAT = re.compile(r"^\s*第?\s*\d+\s*页?\s*$|^\s*\d+\s*/\s*\d+\s*$|严禁外传|公司内部资料")
print("识别为页眉页脚的重复行(前 10 条):")
for ln, c in freq.most_common(10):
if c >= threshold:
print(f" ×{c:<4} {ln[:40]}")
# ========== ② 逐行清洗 ==========
cleaned: list[str] = []
for ln in lines:
s = ln.strip()
if s in header_footer: # 命中页眉页脚
continue
if PAGE_PAT.search(s): # 命中页码等正则
continue
cleaned.append(ln.rstrip())
# ========== ③ 压缩多余空行(连续空行压成 1 个)==========
out: list[str] = []
blank = 0
for ln in cleaned:
if ln.strip() == "":
blank += 1
if blank > 1:
continue
else:
blank = 0
out.append(ln)
# ========== ④ 修断行(把被腰斩的中文句子接回去)==========
# 规则:上一行以中文/数字结束(不是句末标点),且下一行以中文/数字开头
# → 说明是 PDF 换行导致的断行,直接合并
merged: list[str] = []
END_OK = "。!?;:)"】》!?;:"
START_SKIP = "#|-|>|*" # 标题、表格、列表、引用不参与合并
for ln in out:
s = ln.strip()
if (merged
and s
and not s.startswith(tuple(START_SKIP))
and not merged[-1].startswith(tuple(START_SKIP))
and merged[-1]
and merged[-1][-1] not in END_OK
and not merged[-1].endswith(" ") # Markdown 硬换行(行尾两空格)
and re.search(r"[\u4e00-\u9fa5]$", merged[-1])
and re.search(r"^[\u4e00-\u9fa5]", s)):
merged[-1] = merged[-1] + s # 接到上一行末尾
else:
merged.append(ln)
text = "\n".join(merged)
text = re.sub(r"\n{3,}", "\n\n", text) # 再压一次空行
DST.write_text(text, encoding="utf-8")
print(f"\n清洗前:{len(raw)} 字符 / {len(lines)} 行")
print(f"清洗后:{len(text)} 字符 / {len(text.splitlines())} 行")
print(f"✅ 已写入 {DST}")
# ========== ⑤ 抽查:打印首尾各 20 行,肉眼过一遍 ==========
print("\n【抽查:前 20 行】")
print("\n".join(text.splitlines()[:20]))
运行:
python day63_clean.py
⚠️ 修断行这一步要小心 :合并逻辑会破坏 Markdown 表格(表格行以
|开头,脚本里已用START_SKIP跳过)、也会破坏列表项。跑完务必抽查 有没有把不该合并的合并了。规则是保守的宁可少合并------断行影响检索一点点,把表格合并坏了影响很大。
清洗前后对比(记进实验记录本)
| 指标 | 清洗前 | 清洗后 | 说明 |
|---|---|---|---|
| 字符数 | 一般减少 10%~25% | ||
| 行数 | |||
| "第 X 页"出现次数 | 应为 0 | ||
| 连续空行数 | 应为 0 | ||
| 表格行是否完好 | 必须抽查 |
📊 文档解析速查表
MinerU 命令速查
| 参数 | 作用 | 示例 |
|---|---|---|
-p |
输入路径(文件或目录) | -p 手册.pdf |
-o |
输出目录 | -o data/parsed |
-b |
后端,pipeline = 纯 CPU |
-b pipeline |
-m |
解析模式,ocr = 走 OCR |
-m ocr |
-s / -e |
起始页 / 结束页(从 0 计) | -s 0 -e 9 |
-l |
语言 | -l ch |
--source |
模型源,国内用 modelscope |
--source modelscope |
环境变量等价写法:MINERU_MODEL_SOURCE=modelscope
Docling 代码速查
from docling.document_converter import DocumentConverter
doc = DocumentConverter().convert("手册.pdf").document
md = doc.export_to_markdown() # 全文 Markdown
tables = [t.export_to_dataframe() for t in doc.tables] # 表格 → DataFrame
texts = [t.text for t in doc.texts] # 全部文本块
产物结构速查
mineru 输出目录/
└── <文件名>/
├── full.md ★ 主产物(清洗与分块的原料)
├── content_list.json 结构化块列表(含 type / bbox / page)
├── layout.pdf 版面可视化(质检用)
└── images/ 抠出的图片
常见翻车与解法
| ❌ 症状 | 原因 | ✅ 解法 |
|---|---|---|
| 解析产物是空的 | 扫描件没开 OCR | 加 -m ocr |
| CUDA / 显卡相关报错 | 无 GPU 却用了默认后端 | 加 -b pipeline |
| 下载模型卡死 | HuggingFace 直连不畅 | --source modelscope 或设 HF_ENDPOINT |
| 中文乱码(\ufffd) | 编码或字体问题 | 换工具;或另存 PDF 时嵌入字体 |
| 表格列数不对 | 表格结构识别失败 | 换 Docling;或手工补录 |
| 双栏串行 | 版面分析没生效 | 确认用了 MinerU/Docling(PyMuPDF 不解决这个) |
| 装依赖冲突 | torch 版本打架 | 为解析单独建虚拟环境 |
| 大文件跑很久 | 300 页 CPU 解析确实慢 | -s/-e 先试跑 10 页;批量走 Docker 服务 |
📝 本课小结
| 知识点 | 一句话记住 |
|---|---|
| PDF 本质 | 为打印设计的绘图指令,不是结构化文档 |
| 坑 1 扫描件 | 只有图片没有文字层 → 必须 OCR |
| 坑 2 双栏 | 朴素提取左右串行 → 要版面分析 |
| 坑 3 表格 | 行列关系会丢失 → 要表格结构识别 |
| 坑 4 图内文字 | 铭牌/流程图里的字提取不到 → OCR 图片区域 |
| 坑 5 页眉页脚 | 重复噪声污染相似度 → 后处理清洗 |
| MinerU | 中文 PDF 首选,命令行一把梭,产物 full.md |
| Docling | 表格质量顶级,Python API 优雅,export_to_dataframe() |
| Unstructured | 格式覆盖最广(含邮件),适合接生态 |
| PyMuPDF | 极快极轻,不做版面分析,用于体检与预处理 |
| 质检方法 | 关键词是否在产物里 + layout.pdf 目视 + 表格七项检查 |
| 清洗四件事 | 去页眉页脚、压空行、修断行、去水印 |
| 交叉验证 | 两个工具跑同一份,比迷信单一工具靠谱 |
🧠 核心认知 :RAG 的效果上限由解析质量决定------分块再巧、向量再强,原料是垃圾出来的还是垃圾。FDE 在现场的真正工作量,往往不在模型上,而在"把客户那堆格式混乱、扫描件与表格夹杂的历史资料,整理成机器能读、能检、能溯源的干净文本"。这一步做扎实,后面所有优化才有地基;这一步偷懒,后面所有调参都是在给垃圾堆喷香水。
📋 课后练习
练习 1:给一份真实 PDF 做体检与解析(约 45 分钟)
-
找一份真实 PDF(设备手册、产品说明书、论文、财报都行,≥10 页),放进
data/raw/。 -
跑
day63_probe.py,记录:总页数、疑似扫描页数、图片数、表格数、朴素提取的乱象(截图或复制一段)。 -
用 MinerU 解析(按体检结果决定要不要加
-m ocr),打开layout.pdf看版面框得准不准,写下 2 处"框对了"和 1 处"框错了"。 -
对比朴素提取与
full.md,写下一句话结论。
练习 2:表格质检实操(约 40 分钟)
-
确认你的 PDF 里至少有 1 个表格(没有就自己造一个带合并单元格的)。
-
用 Docling 解析并跑
day63_table_qc.py,把七项检查结果截图存档。 -
找出至少一项不合格的(列数不一致 / 空格率过高 / 混入页眉页脚),换另一个工具再跑一次,看是否改善。
-
如果两个工具都不行,手工把这张表改写成"一句话一条记录"(如
A3 的冷却水压力是 0.3MPa),思考为什么这种写法在 RAG 里反而更好检索。
练习 3:清洗脚本调优(约 35 分钟)
-
对你的
full.md跑day63_clean.py,对比清洗前后的字符数、行数、"第 X 页"出现次数。 -
故意制造一个破坏 :把
START_SKIP里的|去掉再跑一次,观察表格被合并成什么样,体会为什么必须跳过表格行。 -
给脚本加一条你自己的清洗规则(比如去掉"内部资料 严禁外传"水印、或把全角数字转半角),验证生效。
-
把最终的
manual_a3.clean.md保存好------这是明天 Day 64 分块的唯一原料,别丢了。
🔭 下节预告
今天你把三百页的 PDF 变成了一份干净的 Markdown。但如果现在就把它灌进向量库,你会发现一个尴尬的问题:
整份手册 20 万字,一次性编码成一个 1024 维向量
→ 这个向量是"整本手册的平均意思"
→ 问"A3 冷却水压力多少"时它确实能匹配上(分不低)
→ 但你拿它去生成答案,要么超长塞不下,要么里面 99% 是无关内容
所以必须切块。而且切法大有讲究:
-
按固定字数硬切?会把"步骤 3"和"它的说明"劈成两半
-
切多大?500 字还是 1000 字?重叠多少?
-
按 Markdown 标题切?那遇到一章三千字怎么办?
-
还有更高级的语义分块 和父子分块(小块检索、大块喂模型)
明天(Day 64)就用你今天清洗好的 manual_a3.clean.md,实现四种分块策略,并做一个对比实验:同一份手册、同一组问题,看哪种分块能命中答案、哪种会漏。这是本周最体现"工程判断力"的一天,明天见!
🌍附录:前置课程列表
阶段一:认知启蒙(AI 认知与 FDE 角色)
AI 认知
【FDE系列】阶段1Day 1:AI 层级关系 --- 四个嵌套的圈-CSDN博客
【FDE系列】阶段1Day 2:AI 三阶段发展史 --- 会认 → 会判断 → 会创造-CSDN博客
【FDE系列】阶段1Day 3:符号 AI vs 机器学习 --- 两条路线的本质区别-CSDN博客
【FDE系列】阶段1Day 4:Transformer 的历史意义 --- 2017 年的分水岭-CSDN博客
【FDE系列】阶段1Day 5:本周复习与自测 --- 检验你的 AI 认知地基-CSDN博客
【FDE系列】阶段1Day 6:Transformer 架构 --- 一张图纸盖出千千万万栋楼-CSDN博客
【FDE系列】阶段1Day 7:LLM 本质 --- 文字接龙机器-CSDN博客
【FDE系列】阶段1Day 8:Token --- 模型眼中的最小单位-CSDN博客
【FDE系列】阶段1Day 9:AI 幻觉 --- 为什么会一本正经地胡说八道-CSDN博客
【FDE系列】阶段1Day 10:上下文窗口 --- 模型的记忆力上限 + 本周复习-CSDN博客
【FDE系列】阶段1Day 11:Prompt --- 给模型立规矩-CSDN博客
【FDE系列】阶段1Day 12:Memory --- 让模型记住上下文
【FDE系列】阶段1Day 13:RAG --- 给模型配图书管理员-CSDN博客
【FDE系列】阶段1Day 14:Tool Use --- 让模型动手操作-CSDN博客
【FDE系列】阶段1Day 15:MCP --- 统一的工具接口标准 + 第三周复习-CSDN博客
FDE 基础概念
【FDE系列】阶段1Day 16:什么是 FDE --- 把 AI 变成客户结果的人-CSDN博客
【FDE系列】阶段1Day 17:FDE vs 传统实施 --- 三大本质区别-CSDN博客
【FDE系列】阶段1Day 18:FDE 三重身份 + C6 胜任力模型-CSDN博客
【FDE系列】阶段1Day 19:七阶段行动路径 + 行业经验的价值-CSDN博客
【FDE系列】阶段1Day 20:阶段总结与产出物 --- 第一阶段收官-CSDN博客
阶段二:技术地基(Python + FastAPI + SQL + Docker + API 集成)
Python基础
【FDE系列】阶段2:Day 21:Python 环境搭建 --- 写出你的第一行代码-CSDN博客
【FDE系列】阶段2:Day 22:变量、数据类型、条件判断 --- Python 的"记忆"和"判断"-CSDN博客
【FDE系列】阶段2:Day 23:循环与函数 --- 让代码跑 100 遍、把逻辑打包复用-CSDN博客
【FDE系列】阶段2:Day 24:数据结构 --- 列表、字典、集合、元组-CSDN博客
【FDE系列】阶段2:Day 25:文件读写与 JSON --- 让程序连通外部数据(第一周收官)-CSDN博客
【FDE系列】阶段2:Day 26:模块化编程 --- 把代码拆成"抽屉柜"-CSDN博客
【FDE系列】阶段2:Day 27:异常处理与日志 --- 让程序"摔不烂、查得到"-CSDN博客
FastAPI入门到进阶
【FDE系列】阶段2:Day 28:FastAPI 入门 --- 把你的函数变成 API 服务-CSDN博客
【FDE系列】阶段2:Day 29:FastAPI 进阶 --- Pydantic 模型与完整 CRUD 实战-CSDN博客
【FDE系列】阶段2:Day 30:生产代码规范 --- 测试、类型注解、配置管理(第二周收官)-CSDN博客
SQL基础
【FDE系列】阶段2:Day 31:SQL 基础 --- 增删改查一把梭-CSDN博客
【FDE系列】阶段2:Day 32:多表查询 --- JOIN 与聚合-CSDN博客
【FDE系列】阶段2:Day 33:进阶查询 --- 窗口函数与 CTE-CSDN博客
【FDE系列】阶段2:Day 34:数据清洗 --- 把脏数据捋干净-CSDN博客
【FDE系列】阶段2:Day 35:Python + SQL --- 工单接入 MySQL + 本周收官-CSDN博客
Linux基础
【FDE系列】阶段2:Day 36:Linux 入门与文件操作 --- 扔掉鼠标的第一天-CSDN博客
【FDE系列】阶段2:Day 37:权限、进程与文本三剑客-CSDN博客
【FDE系列】阶段2:Day 38:Shell 脚本 --- 把命令串起来自动跑-CSDN博客
【FDE系列】阶段2:Day 39:Linux 综合实战 --- 让服务无人值守-CSDN博客
【FDE系列】阶段2:Day 40:Shell 进阶 --- 生产级脚本与本周收官-CSDN博客
Docker
【FDE系列】阶段2:Day 41:Docker 入门 --- 把环境装进盒子-CSDN博客
【FDE系列】阶段2:Day 42:Dockerfile 实战 --- 把你的应用打包成镜像-CSDN博客
【FDE系列】阶段2:Day 43:Docker Compose --- 多容器一键编排-CSDN博客
【FDE系列】阶段2:Day 44:Nginx 反向代理 + Git 版本控制-CSDN博客
【FDE系列】阶段2:Day 45:综合实战 --- Docker + Nginx + Git 完整部署与本周收官-CSDN博客
API 集成与系统对接
【FDE系列】阶段2:Day 46:RESTful 设计与认证授权-CSDN博客
【FDE系列】阶段2:Day 47:对接企业系统 --- 飞书 / 钉钉 API-CSDN博客
【FDE系列】阶段2:Day 48:Webhook 处理与数据映射-CSDN博客
【FDE系列】阶段2:Day 49:OpenAPI 文档与接口测试-CSDN博客
【FDE系列】阶段2:Day 50:综合项目 --- 设备告警工单闭环系统 & 第二阶段收官 特殊字符-CSDN博客
阶段三:AI 应用技术(含 SDD 方法论)
AI基础:Prompt Engineering 系统训练
【FDE系列】阶段3:Day 51:从聊天窗口到代码 --- 跟 LLM 的第一次握手-CSDN博客
【FDE系列】阶段3:Day 52:Prompt 三板斧 --- 角色、示例与清晰指令-CSDN博客
【FDE系列】阶段3:Day 53:结构化输出 --- 让模型的回答能进数据库-CSDN博客
【FDE系列】阶段3:Day 54:思维链与推理任务 --- 让模型一步步想清楚-CSDN博客
【FDE系列】阶段3:Day 55:综合实战 --- 巡检报告生成器与本周收官 -CSDN博客
【FDE系列】阶段3:Day 56:评测体系入门 --- 建立你的黄金评测集-CSDN博客
【FDE系列】阶段3:Day 57:Promptfoo 实战 --- A/B 对比让数据说话-CSDN博客
【FDE系列】阶段3:Day 58:Prompt 安全 --- 注入、越狱与防护-CSDN博客
【FDE系列】阶段3:Day 59:模板化与追踪 --- Jinja2 与 Langfuse-CSDN博客
【FDE系列】阶段3:Day 60:综合实战 --- 智能工单助手 v0.1 冻结-CSDN博客
RAG 知识检索系统
【FDE系列】阶段3:Day 61:RAG 全景 --- 给模型配一间资料室-CSDN博客
【FDE系列】阶段3:Day 62:Embedding --- 文字是怎么变成向量的-CSDN博客
待完成教程:
Agent 框架与开发
Tool Calling 与 MCP
LLM 推理与部署
规范驱动开发与 Agent 工程方法论
阶段四:平台与交付(含 Agent 治理)
阶段五:行业实战与认证