2845 字
约 9 分钟
16
S27-X09 · MarkItDown 与文档转换:把一切喂给 LLM

模型只吃文本,而资料是 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. 自测

  1. 为什么 PDF 转文本会出现"句子交错"?
  2. 通用转换工具与视觉模型识别,各自的成本与精度取舍是什么?
  3. 为什么选 Markdown 而不是 HTML 作为中间格式?
  4. 页眉页脚最可靠的识别方式是什么?
  5. 溯源信息为什么必须用注释或元数据,而不能写进正文?
  6. 转换、清洗、分块、嵌入的正确顺序是什么,颠倒会怎样?
S27-X09 · MarkItDown 与文档转换:把一切喂给 LLM
https://www.clxhxhhr.top/posts/5787/.../
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。