模型只吃文本,而资料是 PDF、Word、PPT、Excel、HTML、扫描件。转换这一步做不好,后面所有环节都在为噪声买单:分块切在页眉中间、检索召回的是一串断行、表格被拉平成一行 "1 2 3 4 5"。
这篇讲转换该怎么选路线、怎么清洗、怎么和分块配合,以及哪些内容本来就转不好。
1. 为什么需要统一转换
| 来源格式 | 难在哪 | 直接入库的后果 |
|---|---|---|
| PDF(文本型) | 阅读顺序≠抽取顺序,双栏会串行 | 句子交错,语义错乱 |
| PDF(扫描件) | 本质是图片,没有文字层 | 抽出来是空字符串 |
| Word | 样式信息多,标题层级藏在样式里 | 结构丢失,全是段落 |
| PPT | 信息在文本框与备注里 | 漏掉备注,只剩标题 |
| Excel | 多 sheet、合并单元格、公式 | 表格语义丢失 |
| HTML | 导航、广告、页脚混在正文 | 噪声占比可能超过正文 |
| 图片/图表 | 文字在像素里 | 要么 OCR,要么视觉模型 |
统一转换的目标不是"变成纯文本",而是变成保留了结构的文本——标题是标题、列表是列表、表格还是表格。
2. 三条路线对照
| 路线 | 代表做法 | 精度 | 成本 | 适合 |
|---|---|---|---|---|
| 专用库转换 | 按格式用对应解析库(PDF/Word/Excel 各一套) | 结构化内容高,扫描件为零 | 低,纯本地 | 格式规整的内部文档 |
| 通用转换工具 | MarkItDown 这类一把梭,统一输出 Markdown | 中等,取决于底层解析器 | 低 | 混合格式、要快速铺量 |
| 视觉模型识别 | 把页面渲染成图,让多模态模型读出结构与内容 | 扫描件、复杂版面最好 | 高(按页计费/算力) | 扫描件、图表、票据 |
实践中通常是分层的:先用通用工具跑一遍,按"抽出来的字数/页数"判断哪些页疑似扫描件,再对这批页面走视觉路线。这样成本花在真正需要的地方。
3. 为什么选 Markdown 作中间格式
| 好处 | 具体表现 |
|---|---|
| 保留结构 | 标题层级、列表、表格都能表达 |
| token 友好 | 比 HTML 少大量标签噪声 |
| 可人工校对 | 纯文本,diff 得出来,改起来快 |
| 模型熟悉 | 训练语料里大量 Markdown,指令跟随更稳 |
| 可版本控制 | 转换产物能进 Git,便于追溯 |
代价也要说清:Markdown 表达不了合并单元格、浮动图注、脚注引用这类结构。这些信息在转换时要么降级成文本描述,要么单独存一份结构化旁路。
4. MarkItDown 的用法与边界
# 安装与命令行用法(包名与参数按当前版本核对)
pip install markitdown
markitdown report.docx > out/report.md
markitdown slides.pptx -o out/slides.md
markitdown data.xlsx > out/data.md
from markitdown import MarkItDown
md = MarkItDown()
result = md.convert("manual.pdf")
text = result.text_content # 统一拿到的 Markdown 文本
# 需要在转换时描述图片,可挂多模态客户端(参数名按当前版本核对)
# md = MarkItDown(llm_client=client, llm_model="MODEL_ID")
它的价值是一个入口吃多种格式,输出同一种 Markdown,省掉为每种格式写一套适配。它的边界同样明确:
| 边界 | 说明 |
|---|---|
| 扫描件 | 没有文字层就抽不出内容,需要 OCR/视觉模型 |
| 复杂版面 | 双栏、多表格并排的 PDF 容易串行 |
| 加密文档 | 有密码就打不开,需要先解密 |
| 超大文件 | 一次读进内存,几百 MB 的 PDF 要拆分处理 |
| 特定语义 | 它不做业务级清洗,页眉页脚仍在文本里 |
5. 转换后的清洗
原始转换产物几乎一定要清洗,否则噪声会直接进入检索库:
| 噪声类型 | 表现 | 处理 |
|---|---|---|
| 页眉页脚 | 每页重复同一行 | 统计高频短行,整行删除 |
| 页码 | 单独一行数字 | 正则匹配行首行尾数字 |
| 断行 | 一句话被切成多行 | 合并"未以标点结尾"的行 |
| 连字符断词 | trans-\nformer |
合并 -\n 后接小写字母 |
| 表格错位 | 单元格顺序混乱 | 表格单独抽出,按行列重建 |
| 图片占位 |  一堆空引用 |
保留占位并记录图注文本 |
| 重复目录 | 目录页与正文标题重复 | 剔除目录段,或用标题去重 |
import re
def clean(text: str) -> str:
# 1) 页码行
text = re.sub(r"(?m)^\s*\d{1,4}\s*$", "", text)
# 2) 连字符断词
text = re.sub(r"(\w)-\n(\w)", r"\1\2", text)
# 3) 段落内硬换行合并(中文场景)
text = re.sub(r"(?<=[^\n。!?;:])\n(?=[^\n#|\-])", "", text)
# 4) 压缩多余空行
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip()
高频短行统计比正则可靠得多:把全文按行统计出现次数,凡是"短且出现次数接近页数"的行,基本就是页眉页脚。
6. 表格与图表:两类转不好的内容
| 内容 | 常见结果 | 建议做法 |
|---|---|---|
| 简单表格 | Markdown 表格,基本可用 | 原样保留,作为独立块 |
| 合并单元格 | 结构塌陷成空列 | 单独抽成 JSON,注明表头继承 |
| 跨页表格 | 被拆成两个表 | 按表头特征合并 |
| 图表(柱状/折线) | 只剩图注或空占位 | 视觉模型转成"数据点 + 结论"描述 |
| 流程图/架构图 | 完全丢失 | 视觉模型出文字描述,人工校 |
| 公式 | 变成乱码字符 | 转 LaTeX,或作为图片保留 |
一条实用原则:表格不要和正文混在同一块里。 表格单独成块、带上表名与单位,检索时更容易被正确召回;混在长段落里的表格,向量表达会被正文稀释。
7. 批量流水线与失败重试
import json, time, pathlib
from markitdown import MarkItDown
md = MarkItDown()
SRC = pathlib.Path("docs")
OUT = pathlib.Path("out"); OUT.mkdir(exist_ok=True)
LOG = pathlib.Path("out/failed.jsonl")
def convert_one(p: pathlib.Path) -> str:
return md.convert(str(p)).text_content
for p in SRC.rglob("*"):
if p.suffix.lower() not in {".pdf", ".docx", ".pptx", ".xlsx", ".html"}:
continue
dst = OUT / (p.stem + ".md")
if dst.exists(): # 断点续跑
continue
for attempt in range(3):
try:
text = convert_one(p)
if not text.strip(): # 空文本 = 疑似扫描件,单独记账
raise ValueError("empty extraction")
dst.write_text(text, encoding="utf-8")
break
except Exception as e:
if attempt == 2:
with LOG.open("a", encoding="utf-8") as f:
f.write(json.dumps({"file": str(p), "error": str(e)},
ensure_ascii=False) + "\n")
else:
time.sleep(2 ** attempt)
流水线上要固定三件事:幂等(已产出就跳过)、可重试(指数退避)、可审计(失败清单落文件)。失败清单比成功数量更重要——它决定了你后面还剩多少工作量。
8. 溯源:让每段话能回到原文
RAG 场景里,"这条答案出自哪份文档第几页"是硬需求。转换阶段就要把位置信息埋进去,事后补是补不回来的。
<!-- source: manual.pdf | page: 17 | section: 3.2 退款时效 -->
## 3.2 退款时效
退款一般在 3–5 个工作日到账。
| 溯源粒度 | 埋点方式 | 适用 |
|---|---|---|
| 文档级 | 文件名 | 只需说"来自哪份" |
| 页级 | 每页前插注释 | PDF 类,引用要精确 |
| 章节级 | 标题层级保留 | 手册、规范类 |
| 块级 | 分块时带上 page/section 元数据 |
最常用,兼顾问答与引用 |
埋点要用注释或结构化元数据,不要写进正文——写进正文会被嵌入模型当成内容,污染语义。
9. 与分块的配合
| 转换产物特征 | 分块策略 | 原因 |
|---|---|---|
| 标题层级清晰 | 按标题切,再按长度二次切 | 语义边界最准 |
| 表格多 | 表格单独成块,不切 | 切开就失去行列关系 |
| 长段落无小标题 | 定长滑动窗口 + 重叠 | 没有天然边界 |
| 代码块 | 按函数/类切 | 切开后语法不完整 |
| 列表 | 整列表一块,条目多再切 | 半截列表语义不全 |
顺序上必须是先清洗、再分块、后嵌入。反过来做,噪声已经被嵌进向量里,清洗只剩心理安慰。
10. 排错:七种转换异常
| 现象 | 原因 | 处理 |
|---|---|---|
| 转换结果是空字符串 | 扫描件,没有文字层 | 走 OCR/视觉模型 |
| 文字串行、句子交错 | 双栏 PDF 阅读顺序判定错 | 按栏切分后分别抽取 |
| 表格变成一行乱序数字 | 版面解析失败 | 表格区单独裁切识别 |
| 中文变乱码 | 编码识别错(GBK/UTF-8) | 显式指定编码,或先探测 |
| 打开就报密码错误 | 文档加密 | 先解密再转换 |
| 处理到一半内存爆 | 超大文件一次性读入 | 按页/按 sheet 分段处理 |
| 入库后检索质量反而下降 | 噪声未清洗,或页眉被反复召回 | 回到清洗环节,别调模型 |
11. 边界:什么时候转换本身不值得
| 场景 | 更合适 | 理由 |
|---|---|---|
| 结构化数据本来就在数据库里 | 直接查库生成文本 | 转换是绕路 |
| 只有少量文档要读 | 人工读一遍写摘要 | 写流水线比读十份文档贵 |
| 内容是纯图表/图纸 | 视觉模型 + 人工描述 | 文本转换拿不到信息 |
| 需要精确数值计算 | 解析原始表格数据 | 文本化后精度丢失 |
| 原始文档会频繁更新 | 建增量索引,而不是全量重转 | 全量重转成本随规模线性上涨 |
一句话定位:**转换的质量上限由原始文档决定,清洗只能去掉噪声、不能补回信息。**扫描件就是图片,这一点任何转换工具都改不了。
12. 自测
- 为什么 PDF 转文本会出现"句子交错"?
- 通用转换工具与视觉模型识别,各自的成本与精度取舍是什么?
- 为什么选 Markdown 而不是 HTML 作为中间格式?
- 页眉页脚最可靠的识别方式是什么?
- 溯源信息为什么必须用注释或元数据,而不能写进正文?
- 转换、清洗、分块、嵌入的正确顺序是什么,颠倒会怎样?