RAG 文档解析实战:从 PDF 表格到高质量切分

RAG 文档解析是检索增强生成链路上最容易被低估的一环。很多团队把向量库、Embedding 模型、重排器都换了一轮,召回率却始终上不去,最后发现根因很朴素:PDF 里的表格被拉成了一行乱码,标题层级全丢,跨页段落被硬生生截断。切分(chunking)质量决定了检索天花板,模型再强也救不回一份被解析坏的文档。这篇文章把文档解析与切分拆成可落地的步骤:工具怎么选、表格怎么救、切分策略怎么定、元数据怎么设计、效果怎么验收。

为什么 RAG 答不准,问题常在解析这一环

一条完整的 RAG 链路是:文档解析 → 切分 → 向量化 → 存储 → 检索 → 重排 → 生成。后面五个环节都有成熟的评测手段,唯独最前面的解析常常是「跑通就算过」。可它偏偏是误差的源头:解析阶段丢掉的信息,后面任何环节都无法凭空补回来。

三类典型症状

  • 数字答错:问「2025 年 Q3 毛利率是多少」,模型给出的是 Q2 的值。往往是表格被解析成扁平文本后,行列对应关系错位。
  • 答案被截断:一段定义横跨两页,切分时正好断在中间,检索只命中前半句,模型只能靠猜补全。
  • 命中无关章节:文档里「适用范围」出现了七次,分属七个不同章节,chunk 里没有标题路径,检索无法区分它们属于谁。

这三类问题的共同点是:向量检索本身没坏,坏的是喂给它的语料。所以在调 top-k、换 Embedding 之前,先花半天时间抽样看看你的 chunk 长什么样,通常收益远高于换模型。关于检索侧的量化方法,可以参考站内的向量检索评测实战:Recall@K 与 RAG 质量度量,把解析改动的效果用指标量出来。

文档解析的三层难度

不要用一套代码硬扛所有文档。先做分层判断,再决定投入多少成本:

  1. 纯文本层:Markdown、TXT、HTML、Word。结构信息本来就在,解析几乎零成本,重点在切分。
  2. 电子版式层:由 Word/LaTeX 导出的 PDF。文字可选中,但版式(分栏、页眉页脚、表格线)会干扰阅读顺序,需要按坐标重排。
  3. 扫描图像层:拍照或扫描的 PDF、图片。没有文本层,必须走 OCR,且要处理倾斜、噪点、印章遮挡。

工程上的做法是写一个探测函数:先尝试抽取文本,如果整页字符数低于阈值(比如 50),就判定为扫描页,自动切到 OCR 分支。这样一份混合文档也能自动分流,不需要人工挑拣。

解析工具选型对照

工具擅长短板适用场景
PyMuPDF (fitz)极快、坐标精准、支持图片抽取表格识别弱大批量正文抽取
pdfplumber表格抽取准、能拿到线框速度慢、内存占用高财报、合同类表格
unstructured格式覆盖广、自带元素分类依赖重、中文调优一般多格式混合入库
MinerU / marker版式还原强、直出 Markdown需要 GPU、部署成本高论文、复杂版式
PaddleOCR中文识别准、开源免费需自建服务扫描件、图片

务实的组合是:PyMuPDF 抽正文 + pdfplumber 抽表格 + PaddleOCR 兜扫描页。三者都是纯 CPU 可跑的开源方案,覆盖 90% 的企业文档,不需要先上 GPU。等到确实遇到大量复杂版式论文,再引入 MinerU 这类重型方案。

实战一:正文与表格分离抽取

核心思路是「先探测、再分流」。同一页里,表格区域交给 pdfplumber,其余文字交给 PyMuPDF 按块(block)读取,避免表格数字混进正文段落。

# pip install pymupdf pdfplumber
import fitz, pdfplumber

MIN_CHARS = 50  # 低于此值判定为扫描页

def extract_page(pdf_path, page_no):
    doc = fitz.open(pdf_path)
    page = doc[page_no]
    text = page.get_text("text")
    if len(text.strip()) < MIN_CHARS:
        return {"type": "scanned", "page": page_no, "text": ""}

    # 按 block 读取,保留阅读顺序(y 优先,再 x)
    blocks = page.get_text("blocks")
    blocks.sort(key=lambda b: (round(b[1], 1), round(b[0], 1)))
    body = "\n".join(b[4].strip() for b in blocks if b[4].strip())

    # 表格单独走 pdfplumber
    tables = []
    with pdfplumber.open(pdf_path) as pdf:
        for t in pdf.pages[page_no].extract_tables():
            if t and len(t) > 1:
                tables.append(t)

    doc.close()
    return {"type": "digital", "page": page_no, "text": body, "tables": tables}

注意两个细节:blocks.sort 里对坐标做了取整,是为了避免同一行内因 0.1pt 的浮动导致顺序抖动;len(t) > 1 用来过滤只有表头的假表格,pdfplumber 经常把带下划线的标题误判成一行表格。

实战二:扫描页走 OCR 兜底

被标记为 scanned 的页面,用 PyMuPDF 渲染成高分辨率位图,再交给 OCR。分辨率是关键:默认 72dpi 识别率很差,提到 200~300dpi 后中文准确率会有明显提升。

# pip install paddleocr
import fitz
from paddleocr import PaddleOCR

ocr = PaddleOCR(use_angle_cls=True, lang="ch")

def ocr_page(pdf_path, page_no, dpi=220):
    doc = fitz.open(pdf_path)
    page = doc[page_no]
    zoom = dpi / 72
    pix = page.get_pixmap(matrix=fitz.Matrix(zoom, zoom))
    img_path = f"/tmp/p{page_no}.png"
    pix.save(img_path)
    doc.close()

    result = ocr.ocr(img_path, cls=True)
    lines = []
    for block in result:
        for line in block or []:
            txt, conf = line[1][0], line[1][1]
            if conf >= 0.6:          # 低置信度直接丢,避免污染语料
                lines.append(txt)
    return "\n".join(lines)

置信度阈值建议保守一些。OCR 产出的低置信度文本往往是印章、水印、装订线的碎片,留在语料里会造成大量语义噪声,宁可漏也不要错。OCR 是 CPU 密集型任务,批量入库时建议起独立 worker 队列,别和在线检索抢资源。

表格必须转成 Markdown,而不是纯文本

这是提升问答准确率性价比最高的一步。表格如果用空格拼接,模型无法判断哪个数字属于哪一列;转成 Markdown 管道表后,行列关系被显式保留,大模型对这种格式的理解能力很强。

def table_to_markdown(table, caption=""):
    """table: List[List[str]],第一行视为表头"""
    rows = [[(c or "").replace("\n", " ").strip() for c in r] for r in table]
    rows = [r for r in rows if any(r)]          # 去空行
    if not rows:
        return ""
    width = max(len(r) for r in rows)
    rows = [r + [""] * (width - len(r)) for r in rows]  # 补齐列数

    head = "| " + " | ".join(rows[0]) + " |"
    sep  = "| " + " | ".join(["---"] * width) + " |"
    body = "\n".join("| " + " | ".join(r) + " |" for r in rows[1:])
    md = "\n".join([head, sep, body])
    return (f"**{caption}**\n\n" + md) if caption else md

再补一条经验:表格不要参与常规切分。一张表就是一个语义完整的 chunk,哪怕它超过了长度上限也尽量别切;真的超长,就按行分批并在每一批都重复表头,保证每个 chunk 都能独立读懂。

切分策略:四种方案怎么选

策略原理优点风险
固定长度按字符数硬切实现最简单、长度可控语义断裂严重
递归字符按段落→句号→逗号逐级降级通用性好,工程首选忽略文档层级
结构感知按标题层级切块语义完整、可带标题路径依赖解析质量
语义切分按相邻句向量相似度断点边界最自然成本高、需调阈值

推荐做法:结构感知优先,递归字符兜底

先按标题层级把文档切成「章节块」,块内如果还超长,再用递归字符二次切分,并在每个子块前面拼上标题路径。这样既保住了语义完整性,也让每个 chunk 都自带上下文坐标。

import re

HEADING = re.compile(r"^(#{1,4})\s+(.*)$")

def split_by_heading(md_text, max_chars=800, overlap=80):
    chunks, path, buf = [], [], []

    def flush():
        text = "\n".join(buf).strip()
        if not text:
            return
        prefix = " > ".join(path)
        for i in range(0, len(text), max_chars - overlap):
            piece = text[i:i + max_chars]
            chunks.append({"path": prefix, "text": f"[{prefix}]\n{piece}"})

    for line in md_text.splitlines():
        m = HEADING.match(line)
        if m:
            flush(); buf = []
            level = len(m.group(1))
            path = path[:level - 1] + [m.group(2).strip()]
        else:
            buf.append(line)
    flush()
    return chunks

参数经验值:中文场景 max_chars 取 500~800、overlap 取 10% 左右比较稳。切太碎会丢上下文,切太大则会稀释向量语义、拉低相似度区分度。Embedding 模型的窗口上限也要一并考虑,具体选型可参考Embedding 模型选型与文本向量化实战

chunk 元数据:让每一段都能自证来源

只存文本的 chunk 是「哑数据」,无法过滤、无法溯源、无法增量更新。入库时至少带上这几个字段:

import hashlib

def build_record(chunk, doc_meta, page_no, idx):
    text = chunk["text"]
    return {
        "id": hashlib.md5(f'{doc_meta["doc_id"]}:{page_no}:{idx}'.encode()).hexdigest(),
        "text": text,
        "metadata": {
            "doc_id":     doc_meta["doc_id"],
            "doc_title":  doc_meta["title"],
            "heading":    chunk["path"],      # 标题路径,可用于过滤
            "page":       page_no,            # 溯源用,前端可跳页
            "chunk_type": doc_meta.get("type", "text"),  # text | table | ocr
            "version":    doc_meta["version"],           # 增量更新按版本淘汰
            "hash":       hashlib.md5(text.encode()).hexdigest(),
        },
    }

hash 字段的作用是幂等:文档重新入库时先比对哈希,内容没变就跳过向量化,能省下大量 Embedding 调用成本。version 则用于安全下线旧版本——先写新版本,全部就绪后再按版本号批量删旧,避免中间态出现检索空窗。存储层的具体落地可以参考Chroma 向量数据库实战:RAG 知识库存储选型

效果验收:别靠感觉,靠抽样和指标

解析改动是否有效,必须能被度量。建立一套最小验收流程,成本不高但收益明显:

  1. 人工抽样:随机抽 30 个 chunk 肉眼过一遍,检查是否断句合理、表格是否成形、有没有页眉页脚残留。
  2. 构造问答集:从文档里挑 50 个能明确定位答案的问题,记录标准答案所在页码。
  3. 跑召回指标:统计 Recall@5 与命中 chunk 的页码是否正确,解析策略调整前后对比数值。
  4. 观察生成端:同一批问题下,答案里的数字错误率是否下降。
# 极简召回率评测
def recall_at_k(retriever, qa_set, k=5):
    hit = 0
    for q in qa_set:
        docs = retriever.search(q["question"], top_k=k)
        pages = {d["metadata"]["page"] for d in docs}
        if q["gold_page"] in pages:
            hit += 1
    return round(hit / len(qa_set), 3)

实践中最常见的结果是:把表格转成 Markdown、给 chunk 加上标题路径这两个动作,往往就能把 Recall@5 拉高十几个百分点——比换一个更贵的 Embedding 模型有效得多。若解析已经做到位而检索仍不理想,再去做混合检索与重排,参考大模型 RAG 进阶:混合检索与重排序优化实战;知识关联性强的场景可以进一步看GraphRAG 实战

踩坑清单

表现处理
页眉页脚混入每个 chunk 都带公司名和页码统计高频重复行,出现率超 60% 的直接剔除
双栏 PDF 串行左右栏文字交替混排按 x 坐标聚类分栏,先左栏后右栏
跨页段落断裂句子在页尾突然中止页尾无句末标点时与下页首段合并
合并单元格错位表格列数忽多忽少补齐列数并向下填充空值
重复入库同一内容检索出多条按文本哈希去重,配合 version 淘汰
OCR 噪声出现无意义乱码短句置信度过滤 + 长度小于 4 字丢弃

小结

RAG 文档解析没有银弹,但有明确的优先级:先做好分层探测与工具分流,再把表格转成 Markdown,接着用结构感知切分配合标题路径,最后补齐元数据与幂等哈希。这四步做完,绝大多数「答不准」的问题会自然消失。把解析当成一个可评测、可迭代的工程环节,而不是一次性脚本,你的知识库才真正具备长期维护的能力。想快速搭一套端到端环境验证效果,可以从Dify + Ollama 本地部署教程起步。

上一篇 Prometheus 告警规则实战:从 Recording Rule 到 Alertmanager 路由
下一篇 多智能体协作实战:反思与辩论提升 LLM 可靠性