RAG 文档解析是检索增强生成链路上最容易被低估的一环。很多团队把向量库、Embedding 模型、重排器都换了一轮,召回率却始终上不去,最后发现根因很朴素:PDF 里的表格被拉成了一行乱码,标题层级全丢,跨页段落被硬生生截断。切分(chunking)质量决定了检索天花板,模型再强也救不回一份被解析坏的文档。这篇文章把文档解析与切分拆成可落地的步骤:工具怎么选、表格怎么救、切分策略怎么定、元数据怎么设计、效果怎么验收。
为什么 RAG 答不准,问题常在解析这一环
一条完整的 RAG 链路是:文档解析 → 切分 → 向量化 → 存储 → 检索 → 重排 → 生成。后面五个环节都有成熟的评测手段,唯独最前面的解析常常是「跑通就算过」。可它偏偏是误差的源头:解析阶段丢掉的信息,后面任何环节都无法凭空补回来。
三类典型症状
- 数字答错:问「2025 年 Q3 毛利率是多少」,模型给出的是 Q2 的值。往往是表格被解析成扁平文本后,行列对应关系错位。
- 答案被截断:一段定义横跨两页,切分时正好断在中间,检索只命中前半句,模型只能靠猜补全。
- 命中无关章节:文档里「适用范围」出现了七次,分属七个不同章节,chunk 里没有标题路径,检索无法区分它们属于谁。
这三类问题的共同点是:向量检索本身没坏,坏的是喂给它的语料。所以在调 top-k、换 Embedding 之前,先花半天时间抽样看看你的 chunk 长什么样,通常收益远高于换模型。关于检索侧的量化方法,可以参考站内的向量检索评测实战:Recall@K 与 RAG 质量度量,把解析改动的效果用指标量出来。
文档解析的三层难度
不要用一套代码硬扛所有文档。先做分层判断,再决定投入多少成本:
- 纯文本层:Markdown、TXT、HTML、Word。结构信息本来就在,解析几乎零成本,重点在切分。
- 电子版式层:由 Word/LaTeX 导出的 PDF。文字可选中,但版式(分栏、页眉页脚、表格线)会干扰阅读顺序,需要按坐标重排。
- 扫描图像层:拍照或扫描的 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 知识库存储选型。
效果验收:别靠感觉,靠抽样和指标
解析改动是否有效,必须能被度量。建立一套最小验收流程,成本不高但收益明显:
- 人工抽样:随机抽 30 个 chunk 肉眼过一遍,检查是否断句合理、表格是否成形、有没有页眉页脚残留。
- 构造问答集:从文档里挑 50 个能明确定位答案的问题,记录标准答案所在页码。
- 跑召回指标:统计 Recall@5 与命中 chunk 的页码是否正确,解析策略调整前后对比数值。
- 观察生成端:同一批问题下,答案里的数字错误率是否下降。
# 极简召回率评测
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 本地部署教程起步。




