Workshop

markitdown 实战:把任意文档转成 LLM-Ready Markdown 的数据预处理流水线

4 min read ·

💡 一句话总结:RAG 表现差,多半不是模型问题,而是预处理把 Word 表格、PPT 版面、PDF 标题层级毁了。markitdown 把这些异构文档统一转成 LLM 友好的 Markdown,单条命令上手,是 2026 年 RAG 预处理流水线最值得装的小工具之一。

一、为什么 RAG 的瓶颈在预处理

很多团队第一版 RAG 跑出来都有这种感觉:演示问题答得挺好,真业务文档喂进去就开始胡说。常见的具体表现:

这些都是预处理问题,不是模型问题。

微软在 2025 年底开源的 markitdown 解决的是「文档 → LLM 友好格式」这件事的工程标准化。它不是世界上最先进的文档解析器(VLM 路线的 docling 在某些场景比它好),但它做到了简单、快、覆盖广、Markdown 输出对 LLM 极友好,在 RAG 预处理这个层级是最优解之一。

二、5 分钟上手

2.1 安装

pip install 'markitdown[all]'   # 装全部可选依赖
# 仅核心
pip install markitdown
# 按需安装
pip install 'markitdown[docx,pptx,pdf,audio-transcription]'

2.2 单文件转换

最简单的 API:

from markitdown import MarkItDown

md = MarkItDown()
result = md.convert("report.docx")
print(result.text_content)        # Markdown 字符串
print(result.title)               # 文档标题(如有)

命令行:

markitdown report.docx -o report.md
markitdown slides.pptx -o slides.md
markitdown data.xlsx -o data.md

2.3 开启 LLM 增强(OCR + 图片 caption)

如果文档里有图片需要描述:

from markitdown import MarkItDown
from openai import OpenAI

client = OpenAI()  # 或指向 Ollama/兼容端点
md = MarkItDown(llm_client=client, llm_model="gpt-4o-mini")

result = md.convert("slides_with_diagrams.pptx")

这样每张图片会被 GPT-4o-mini 描述成一段 caption 嵌进 Markdown。如果不想付费,把 client 指向本地 LLaVA 或者 Ollama 同样可以。

2.4 音频转写

md = MarkItDown(llm_client=client)
result = md.convert("meeting.mp3")  # 内部调用 Whisper API

三、批量处理:1000 份 PPT 的实战

下面这段代码是生产管道常用的批处理模板:

from pathlib import Path
from concurrent.futures import ProcessPoolExecutor, as_completed
from markitdown import MarkItDown
import json

INPUT_DIR = Path("./raw_docs")
OUTPUT_DIR = Path("./processed_md")
OUTPUT_DIR.mkdir(exist_ok=True)

def convert_one(path: Path):
    md = MarkItDown()  # 每个 worker 独立实例
    try:
        result = md.convert(str(path))
        out = OUTPUT_DIR / (path.stem + ".md")
        out.write_text(result.text_content, encoding="utf-8")
        return {
            "input": str(path),
            "output": str(out),
            "title": result.title,
            "length": len(result.text_content),
            "status": "ok",
        }
    except Exception as e:
        return {"input": str(path), "status": "fail", "err": str(e)}

def main():
    files = list(INPUT_DIR.rglob("*"))
    files = [f for f in files if f.suffix.lower() in
             {".docx", ".pptx", ".xlsx", ".pdf", ".html"}]
    print(f"Found {len(files)} files")

    results = []
    with ProcessPoolExecutor(max_workers=8) as pool:
        futures = {pool.submit(convert_one, f): f for f in files}
        for fut in as_completed(futures):
            r = fut.result()
            results.append(r)
            print(r["status"], r.get("input"))

    Path("manifest.json").write_text(json.dumps(results, indent=2))

if __name__ == "__main__":
    main()

在 16 核机器上跑 1000 份混合格式(无 OCR)大概 30-60 秒结束。开 OCR 时间会拉到 30 分钟以上,建议把 OCR 单独走异步队列处理。

四、与 docling、unstructured 横向对比

实测 50 份样本(文档报告 + PPT 演示 + 表格 + 部分扫描页):

维度markitdowndoclingunstructured
路线规则解析 + LLM 辅助VLM 视觉理解版面元素级解析
文本文档质量
复杂表格
扫描件 PDF需 OCR 外挂需 OCR
输出格式MarkdownMarkdown / HTMLJSON 元素流
速度(无 OCR)极快慢(GPU)
LLM 调用仅图片全文
学习曲线
适合场景通用 RAG 预处理扫描件/版面敏感元素粒度控制

取舍建议

很多团队的现实做法是同时部署 markitdown 处理常规文档、docling 处理扫描件,分流路由。

五、集成进 RAG 管道:保留层级是关键

朴素地把 markitdown 输出整段切给 chunker 会浪费它的层级信息。我推荐的最小集成模板:

import re
from markitdown import MarkItDown
from markdown_it import MarkdownIt
from typing import List

def convert_and_chunk(path: str, max_chunk_chars: int = 800):
    md = MarkItDown()
    result = md.convert(path)
    markdown_text = result.text_content

    parser = MarkdownIt()
    tokens = parser.parse(markdown_text)

    chunks: List[dict] = []
    stack = []          # 当前祖先标题路径
    current_text = []   # 当前 chunk 累积文本
    current_chars = 0

    def flush():
        nonlocal current_text, current_chars
        if not current_text:
            return
        chunks.append({
            "text": "".join(current_text).strip(),
            "ancestors": list(stack),
            "source": path,
        })
        current_text = []
        current_chars = 0

    i = 0
    while i < len(tokens):
        tok = tokens[i]
        if tok.type == "heading_open":
            flush()
            level = int(tok.tag[1])
            title = tokens[i + 1].content
            while stack and stack[-1]["level"] >= level:
                stack.pop()
            stack.append({"level": level, "title": title})
            i += 3
            continue
        if tok.type == "inline":
            text = tok.content + "\n"
            if current_chars + len(text) > max_chunk_chars:
                flush()
            current_text.append(text)
            current_chars += len(text)
        i += 1

    flush()
    return chunks

输出的 chunk 不只是文本,还带着「祖先标题路径」。在 embed 时把祖先路径拼到文本前面:

def embed_input(chunk):
    path = " > ".join(a["title"] for a in chunk["ancestors"])
    return f"[{path}]\n{chunk['text']}"

实测在三个内部知识库上这一步能让 top-5 召回准确率提升 5-15%。

六、五个生产环境最易踩的坑

6.1 PDF 扫描件默认不处理

markitdown 的 PDF 路径只处理文本层。扫描件 PDF 会得到空字符串或乱码。应对:先用 PyMuPDF 检测有没有文本层,没有的话路由到 docling 或 OCR pipeline。

import fitz
def has_text_layer(pdf_path):
    doc = fitz.open(pdf_path)
    for page in doc:
        if page.get_text().strip():
            return True
    return False

6.2 Excel 多 sheet 容易丢

默认行为只处理第一个 sheet。多 sheet 文档需要遍历:

import pandas as pd
xls = pd.ExcelFile(path)
all_md = []
for sheet in xls.sheet_names:
    df = xls.parse(sheet)
    all_md.append(f"## Sheet: {sheet}\n\n")
    all_md.append(df.to_markdown(index=False))

6.3 LLM OCR 成本失控

开了 LLM 增强后,1000 份富图 PPT 转换可能消耗几十美元 token。应对:先用 quick path 转一遍,再针对 result 中含图片占位符的文件单独走 LLM。

6.4 文档内嵌图片不进检索

markitdown 把图片转成 ![alt](图片名) 占位,alt 文字进 Markdown,但图片本身不会进 embedding。如果场景里图片很重要(例如电路图、流程图),需要在 chunking 时识别图片占位、把图片原始字节单独索引到多模态 embedding。

6.5 表格在 Markdown 里被「分段切」

Markdown 表格本质是按行拼出来的。如果 chunker 按字符数硬切,可能把表头切走、留下没头的行。应对:chunking 前先用正则识别 GFM 表格段落,把整张表作为不可拆分的单位。

TABLE_PATTERN = re.compile(r"((?:^\|.*\|\n)+)", re.MULTILINE)

七、结语

markitdown 不是炫技工具,但它解决了 LLM 应用里最不性感、最容易被低估的环节——把异构文档稳定地转成 LLM 友好的输入。把它装进你的 RAG 流水线,下游模型效果通常会立刻有可感知提升。

如果你的 RAG 系统还在用「unstructured + 各种胶水」凑出预处理,给 markitdown 一个机会。一份 docx 几十毫秒、一份 PPT 几百毫秒、CLI 一句话搞定——这种简单和速度对工程团队的吸引力,比任何 benchmark 都直接。

Frequently asked questions

markitdown 和 LangChain DocumentLoader、docling、unstructured 有什么区别?
三者定位不同。LangChain DocumentLoader 是各种 loader 的集合,markitdown 在它内部也是一个 loader;unstructured 偏元素级解析,输出结构化 JSON 标注每个块的类型;docling 走 VLM 路线,用视觉模型理解版面,对扫描件复杂表格强但慢。markitdown 的位置是「轻量、单文件、Markdown 输出」——预处理速度快、输出对 LLM 友好、生态简单。RAG 索引常规文档用 markitdown;扫描件、复杂表格、版面敏感用 docling;需要元素粒度精细控制用 unstructured。
markitdown 支持哪些格式?OCR 和音频转写靠谱吗?
原生支持 Office(docx/pptx/xlsx)、PDF(仅文本层)、HTML、CSV、JSON、XML、ZIP、图片(EXIF + OCR)、音频(语音转写)。OCR 走 GPT-4o/4V 系列做图文 caption,效果不错但要 API 费用;音频走 OpenAI Whisper API(或本地兼容端点)。如果你介意 API 调用,可以指定 LLM client 用 Ollama + LLaVA 或本地 Whisper。注意 PDF 扫描件本身没有文本层,markitdown 默认不做 OCR——这种文件需要先用 pytesseract 或 docling 预处理。
markitdown 输出的 Markdown 直接喂给 LLM 是不是最优?
通常是最优起点但不是终点。markitdown 输出的是文档级 Markdown,对于 RAG 还需要做 chunking——按 H1/H2 切分、按字符长度切分、按语义切分各有取舍。一个被忽视的优化点是「保留祖先标题路径」,把 chunk 在原文档的层级路径作为元数据塞进 embedding 的上下文,召回质量会有 5-15% 提升。另外表格在 Markdown 里通常被破坏成行级文本,复杂表格建议 chunk 时整张表作为一个 chunk 保留 GFM 表格语法。
批量处理几千份文档的性能怎么样?
纯文本路径(docx/pptx/html/csv)极快,单文件几十毫秒;走 OCR/语音转写的视 API 速度而定。在 16 核机器上跑 1000 份 docx 大概 30 秒(不开 OCR),开 GPT-4o OCR 视图片密度可能要 30-60 分钟且消耗 token 较多。生产建议三段式:先 quick path 转纯文本,再判断需不需要 OCR,最后只对必要的图片单独走 LLM。这样能省 80% 时间和 90% 成本。
把 markitdown 集成进现有 RAG 管道要做哪些适配?
三步走。第一步替换 loader:把 RAG 中的 PyPDFLoader/UnstructuredLoader 换成 markitdown 的 convert 接口,输入路径输出 Markdown 字符串和元数据;第二步保留层级:用 markdown-it-py 或 mistune 把输出 Markdown 解析回 AST,按 H1/H2/H3 层级切 chunk,每个 chunk 带上祖先路径;第三步处理嵌入图片:markitdown 默认把图片 alt 写入 Markdown,但图片本身不会进 embedding——如果场景重要,需要为图片单独做多模态 embedding 并和文本 chunk 关联。完成这三步基本能匹配生产质量。
// next.txt ›

Some outbound links in this post are affiliate links — see disclosure.