[控场AI]
· 20 分钟阅读· 10,052 字

PDF转Markdown:修复RAG分块前的阅读顺序与表格

PDF转Markdown:修复RAG分块前的阅读顺序与表格

RAG效果差常因PDF提取丢失结构,转Markdown并按标题分块可解决大部分问题。

文章指出RAG管道回答PDF问题时效果差,根源往往不在嵌入模型或提示词,而在提取阶段:逐行扫描破坏双栏阅读顺序,表格被压平丢失列头,标题沦为普通文本行,导致分块混入无关内容、嵌入向量模糊。作者主张将PDF转为Markdown而非纯文本,因为`#`/`##`标题可作为分块边界与元数据,管道表格能保留表头与数值的对应关系,LangChain的`MarkdownHeaderTextSplitter`等工具可直接利用。文章对比了pypdf、pdfminer.six、pdfplumber、PyMuPDF、Docling、Marker、MinerU、unstructured、MarkItDown等库的方法、擅长场景与许可,并给出选型规则:有文本层的原生PDF适合轻量规则方案,扫描件才需要OCR/ML管道。最后提供一个约90行、基于pdfplumber的递归XY-cut转换脚本,实测将双栏加表格页面正确转为Markdown并按标题分块,同时坦陈其在段落识别、无框线表格、页眉页脚、符号字体和扫描页上的局限。

RAG管道回答PDF问题时效果差,多数人第一反应是调嵌入或改提示词,但问题往往出在提取阶段。双栏页面被逐行扫过、表格变成一串数字、标题沦为普通文本行——你还没开始分块,就得花一下午手工清理。本文分析这些问题的根源,解释为何Markdown(而非纯文本)能解决大部分麻烦,对比主流开源库,并提供一个本地可跑的Python脚本,直观看差异。

PDF to Markdown for RAG

纯文本提取破坏的三件事

双栏布局的阅读顺序。 多数提取器按行从上到下工作。在双栏论文中,左栏第一行后面紧跟右栏第一行,每个分块混入两段无关内容,嵌入向量落在两者中间,检索自然不准。

表格结构。 表格压平成文本后数值还在,结构却丢了。“Q1 Q2 Q3 / 12 15 9”无法告诉模型哪个数字属于哪列。Markdown表格(| Q1 | Q2 | Q3 |)让表头与每行绑定,LLM对这种格式的读取能力很强。

标题层级。 标题是最好的分块边界,也是有用的元数据(比如“该块来自3.2 Limitations”)。但在纯文本里它和其他行毫无区别。Markdown中标题是#/##行,LangChain的MarkdownHeaderTextSplitter或十行小函数就能按标题切分。

CJK文档还有额外问题:两端对齐的中文常在字间插入空格,换行落在句子中间,两者都会干扰关键词搜索和分块大小的计算。

开源库选型:从轻量规则到ML管道

据PyPI和GitHub元数据,以下项目均活跃维护,License需自行核实:

库方法擅长注意License
pypdf纯Python文本提取零依赖、快无布局、表格、标题BSD-3
pdfminer.six布局分析(字符→行→框)细粒度位置、字体、字号结构需自己构建MIT
pdfplumber基于pdfminer.six有框线表格检测、字/字符访问方便无框线表格需调参MIT
PyMuPDF / pymupdf4llmMuPDF引擎,输出Markdown快、Markdown现成可用AGPL-3.0或商业许可AGPL/商业
Docling (IBM)ML布局+表格模型复杂布局、多格式安装重、需下载模型MIT
MarkerML管道,可选LLM困难布局、公式建议GPU;查模型权重条款Apache-2.0(代码)
MinerUML管道、OCR扫描件、中文重;自定义许可MinerU许可
unstructured元素分区多文件类型、元素元数据高分辨率模式引入大量依赖Apache-2.0
MarkItDown (微软)多格式转MarkdownOffice文件、快速转换PDF处理较轻(底层用pdfminer/pdfplumber)MIT

粗略规则:若PDF有文本层(原生数字报告、论文、政府文件),基于pdfminer.six/pdfplumber的布局感知规则方案轻量、确定性强、运行成本低。若为扫描件或高度视觉化文档,则需要OCR或ML管道(Docling、Marker、MinerU),代价是算力开销。

文本层(Text Layer) 是判断PDF处理路线的前提。原生数字PDF在生成时会把每个字符的编码、位置、字体、字号写入内容流,形成可被程序读取的文本层;扫描件本质是图像,没有这一层,pdfplumber 等库的 extract_words() 只能返回空列表。快速判断方法:用 pdfplumber 打开页面并调用 page.extract_text(),若返回空字符串或大量 (cid:NN),说明文本层缺失或字体映射异常,应转向 OCR/ML 管道。文本层质量还受字体嵌入方式影响——子集化字体或自定义编码可能让字符映射表丢失,导致提取出乱码,这也是文章中“符号字体”局限的根源。

XY-cut 是一种经典的文档版面分析算法:把页面上的文本块投影到 X 轴(水平方向)和 Y 轴(垂直方向),寻找空白间隙(gutter),沿最宽的间隙递归切分,直到每个区域只含单一阅读单元。对于双栏页面,垂直栏缝通常是全页最宽的空白,因此第一刀就能把左右栏分开,再在每栏内部按水平间隙切分行与段。XY-cut 的优点是确定性强、无需训练模型;缺点是对无框线表格、旋转文本和复杂杂志版式敏感,间隙阈值需要按文档调参。文章示例中的 xy_cut() 正是这一思路的最小实现,并加了“先切栏、再切水平间隙”的优先级规则。

可运行示例:双栏+表格→Markdown→分块

下面生成一个小样本PDF,无需任何外部文件,然后对比extract_text()与约90行转换器的输出。

python -m venv .venv && source .venv/bin/activate
pip install pdfplumber==0.11.10 fpdf2==2.8.9

make_sample.py构建带标题、双栏和有框线表格的页面:


**有框线表格与无框线表格的区别** 决定了 `find_tables()` 能否生效。pdfplumber 的默认策略依赖页面上的直线(矢量线段)来推断单元格边界,因此对带完整边框的表格识别稳定;学术论文中常见的“三线表”只有顶线、底线和表头下横线,缺少列分隔线,默认参数往往检测不到,需要改用 `table_settings` 中的 `vertical_strategy="text"`、`horizontal_strategy="text"`,让算法依据文字对齐位置推断列边界。即便如此,跨行合并单元格、嵌套表头仍会出错,生产中通常需要人工抽检或回退到 ML 表格模型(如 Docling 的 TableFormer)。
# make_sample.py - 构建带标题和有框线表格的双栏PDF
from fpdf import FPDF

LEFT = ("Retrieval quality depends on chunk boundaries. When one chunk mixes "
        "two sections, its embedding blurs both topics. Splitting at headings "
        "keeps each chunk about one thing.")
RIGHT = ("Tables are worse. Flattened into plain text, a row of values "
         "loses its column headers, so the model cannot tell which number "
         "belongs to which metric.")

pdf = FPDF(format="A4")
pdf.add_page()
pdf.set_font("Helvetica", "B", 20)
pdf.cell(0, 12, "Chunking PDFs for RAG", new_x="LMARGIN", new_y="NEXT")
y = pdf.get_y() + 4
for x, title, body in [(10, "Why boundaries matter", LEFT), (110, "Why tables break", RIGHT)]:
    pdf.set_xy(x, y)
    pdf.set_font("Helvetica", "B", 14)
    pdf.multi_cell(90, 8, title)
    pdf.set_x(x)
    pdf.set_font("Helvetica", "", 10)
    pdf.multi_cell(90, 5, body)
pdf.set_y(150)
pdf.set_font("Helvetica", "B", 14)
pdf.cell(0, 8, "Formats compared", new_x="LMARGIN", new_y="NEXT")
pdf.set_font("Helvetica", "", 10)
with pdf.table(col_widths=(60, 40, 40)) as table:  # fpdf2绘制框线
    for row in [("Output", "Headings", "Tables"), ("Plain text", "lost", "flattened"), ("Markdown", "# / ##", "pipe table")]:
        r = table.row()
        for cell in row:
            r.cell(cell)
pdf.output("sample.pdf")
print("wrote sample.pdf")

pdf_to_md.py做四件事:将单词按行分组(在栏间空隙处分割),用递归XY-cut排序(有垂直栏缝则先切栏,否则切最宽水平间隙),按字号相对页面中位数标记标题,用pdfplumber的find_tables()把有框线表格替换为Markdown表格。

# pdf_to_md.py - 最小文本层PDF -> Markdown -> 标题感知分块
import statistics, sys
import pdfplumber

def lines_of(words, gap=12):
    """将单词分组为行片段;宽水平间隙(栏缝)触发新片段。"""
    rows = {}
    for w in words:
        rows.setdefault(round(w["top"]), []).append(w)
    frags = []
    for ws in rows.values():
        ws.sort(key=lambda w: w["x0"])
        cur = [ws[0]]
        for w in ws[1:]:
            if w["x0"] - cur[-1]["x1"] > gap:
                frags.append(cur); cur = []
            cur.append(w)
        frags.append(cur)
    return [{"x0": f[0]["x0"], "x1": f[-1]["x1"], "top": min(w["top"] for w in f),
             "bottom": max(w["bottom"] for w in f), "size": f[0]["size"],
             "text": " ".join(w["text"] for w in f)} for f in frags]

def gaps(intervals):
    intervals = sorted(intervals); out = []; end = intervals[0][1]
    for a, b in intervals[1:]:
        if a > end:
            out.append((end, a))
        end = max(end, b)
    return out

def xy_cut(items, min_gutter=7):
    """递归XY-cut:有栏缝先切栏,否则切最宽水平间隙。"""
    if len(items) <= 1:
        return items
    width = max(i["x1"] for i in items) - min(i["x0"] for i in items)
    cols = [g for g in gaps([(i["x0"], i["x1"]) for i in items]) if g[1] - g[0] >= min_gutter]
    for a, b in sorted(cols, key=lambda g: g[0] - g[1]):          # 最宽栏缝优先
        left = [i for i in items if i["x1"] <= a]; right = [i for i in items if i["x0"] >= b]
        if min(max(i["x1"] for i in left) - min(i["x0"] for i in left),
               max(i["x1"] for i in right) - min(i["x0"] for i in right)) > 0.2 * width:
            return xy_cut(left) + xy_cut(right)
    rows = gaps([(i["top"], i["bottom"]) for i in items])
    if not rows:
        return sorted(items, key=lambda i: (i["top"], i["x0"]))
    a, b = max(rows, key=lambda g: g[1] - g[0])
    return xy_cut([i for i in items if i["bottom"] <= a]) + xy_cut([i for i in items if i["top"] >= b])

def page_to_markdown(page):
    tables = [t for t in page.find_tables() if len(t.extract()) >= 2]   # 仅有框线表格
    def in_table(w):
        return any(t.bbox[0] <= w["x0"] and w["x1"] <= t.bbox[2] and
                   t.bbox[1] <= w["top"] and w["bottom"] <= t.bbox[3] for t in tables)
    words = [w for w in page.extract_words(extra_attrs=["size"]) if not in_table(w)]
    body = statistics.median(w["size"] for w in words)
    items = lines_of(words)
    for t in tables:
        rows = [[(c or "").replace("\n", " ").strip() for c in r] for r in t.extract()]
        md = ["| " + " | ".join(rows[0]) + " |", "|" + "---|" * len(rows[0])]
        md += ["| " + " | ".join(r) + " |" for r in rows[1:]]
        x0, top, x1, bottom = t.bbox
        items.append({"x0": x0, "x1": x1, "top": top, "bottom": bottom, "size": body,
                      "text": "\n".join(md), "table": True})
    out = []
    for it in xy_cut(items):
        if it.get("table"):
            out.append(it["text"])
        elif it["size"] >= body * 1.8:
            out.append("# " + it["text"])
        elif it["size"] >= body * 1.3:
            out.append("## " + it["text"])
        elif out and not out[-1].startswith(("#", "|")):
            out[-1] += " " + it["text"]                  # 合并换行段落
        else:
            out.append(it["text"])
    return "\n\n".join(out)

def chunk_by_heading(md):
    chunks, current = [], []
    for block in md.split("\n\n"):
        if block.startswith("#") and current:
            chunks.append("\n\n".join(current)); current = []
        current.append(block)
    return chunks + ["\n\n".join(current)]

if __name__ == "__main__":
    with pdfplumber.open(sys.argv[1] if len(sys.argv) > 1 else "sample.pdf") as pdf:
        page = pdf.pages[0]
        print("=== naive extract_text() ===")
        print(page.extract_text()[:240], "...\n")
        md = page_to_markdown(page)
        print("=== markdown ===")
        print(md, "\n")
        for i, c in enumerate(chunk_by_heading(md)):
            print(f"--- chunk {i}: {c.splitlines()[0]!r} ({len(c)} chars)")

运行:

python make_sample.py && python pdf_to_md.py

extract_text()的输出中,第3-5行全部横跨两栏,内容交错:

Chunking PDFs for RAG
Why boundaries matter Why tables break
Retrieval quality depends on chunk boundaries. When Tables are worse. Flattened into plain text, a row of
one chunk mixes two sections, its embedding blurs both values loses its column headers, so the model cannot
topics. Splitting at headings keeps each chunk about tell which number belongs to which metric.
one thing.
Formats compared
Output Headings Tables
Plain text lost flattened
Markdown # / ## pipe table

转换器输出(Python 3.13、pdfplumber 0.11.10、fpdf2 2.8.9测试):

# Chunking PDFs for RAG

## Why boundaries matter

Retrieval quality depends on chunk boundaries. When one chunk mixes two sections, its embedding blurs both topics. Splitting at headings keeps each chunk about one thing.

## Why tables break

Tables are worse. Flattened into plain text, a row of values loses its column headers, so the model cannot tell which number belongs to which metric.

## Formats compared

| Output | Headings | Tables |
|---|---|---|
| Plain text | lost | flattened |
| Markdown | # / ## | pipe table |

--- chunk 0: '# Chunking PDFs for RAG' (23 chars)
--- chunk 1: '## Why boundaries matter' (196 chars)
--- chunk 2: '## Why tables break' (170 chars)
--- chunk 3: '## Formats compared' (134 chars)

每栏各自成为标题下的独立段落,表格保留表头,分块器按标题切分。实际管道中,你会把过小的块(如只有标题的chunk 0)合并到下一块,并把标题路径加入元数据。

玩具版本的局限

这个版本只在这一页有效,真实文档会迅速暴露问题:

  • 段落识别:它把所有非标题行一直拼到下一个标题。真实代码需要检测段落分隔(缩进、垂直间距)和连字符断词。
  • 无框线学术表格:find_tables()默认设置检测不到“三线表”。
  • 页眉页脚:旋转边距文本(arXiv侧边印章)、页眉页脚和页码会混入正文。
  • 符号字体:字形映射到Unicode私有使用区,部分字体产生(cid:NN)垃圾。这类页面应被标记,而非嵌入。
  • 扫描页:完全没有文本层,应标记而不是索引空字符串。

要点总结

对于原生数字PDF,提取阶段丢失的RAG质量,在保留阅读顺序、标题和表格后大部分可以找回。先从本文这样的小型规则转换器起步,观察分块结果,只有当文档确实需要时才升级到更重的ML管道。(本文由AI辅助撰写,代码已实际运行,输出为运行结果。)

#PDF转Markdown#RAG分块#PDF文本提取#pdfplumber#MarkdownHeaderTextSplitter#Docling#阅读顺序#表格提取
分享:

相关推荐