文档加载:把 PDF/DOCX 喂给 RAG 之前要先看清坑(二)
本文章结合仓库:https://github.com/yaoweizhang/learn-ragflow。本篇是 s02,讲清文档加载最容易翻车的几类问题。看完就知道,为什么 RAG 项目经常”模型没换、效果变差”,问题大多出在这一步。
1. Demo能跑,真实样本失败
pypdf 几行就能读 PDF,python-docx 几行就能读 DOCX,看起来不值得单独成章。
所谓文档加载(document loading),就是把 PDF / DOCX / TXT 这些乱七八糟的二进制格式,转成”一段一段的纯文本”喂给后面的 chunking 和 embedding。负责这事的脚本就叫做 loader(本仓库 s02 目录里的 basic_load.py 就是 loader 的具体实现:输入一个 PDF 或 DOCX 路径,输出一个 [{text, source, page}, ...] 的列表)。扔进真实样本才会发现事情不对。
实际项目里常见的一种情况:文档包含双栏排版, loader 提取的文本就会顺序错乱。embedding(文本变成数字向量)拿到这种”被切碎又乱拼”的字符串,retrieval top-k(按相似度从向量库得到前 k 个最像的段落)召回的是碎段,最后的问答肯定效果不好。另一个例子是 文档中有表格,但是loader无法识别,loader 返回多段文本,表格没提取到——后面用户问”全年营收”,系统答不上来,因为这些数字根本没进向量库。
下面整理成 4 类,方便对号入座。
编码 / 乱码: Windows GBK 控制台打印 DOCX 时报 UnicodeEncodeError: 'gbk' codec can't encode character '\xa0';老 PDF 用非 UTF-8 字符映射(GBK / Big5)出乱码;CJK 字体的 PDF 可能因字体子集化丢字符。loader 本身没问题,死在控制台层。
扫描件: pypdf 对纯图像页(扫描件、手机拍的合同)extract_text() 返回空字符串,被 if text.strip() 静默过滤。用户传 10 页扫描件,loader 给 0 段。s11介绍新的解决方法OCR(用视觉模型把图里的字读成文字)。
多栏错位: 双栏 PDF 按字符位置扫,左栏底部接到右栏顶部。学术论文尤其严重。loader 不知道哪段属于哪栏,出来的段落是碎段混拼。
表格 / 图片: DOCX 的表格存在 Document.tables,按段读(Document.paragraphs)把整张表吞了。PDF 的表格被拍成”列1 列2″一行的碎片,没有 row boundary。图片里的 OCR 文本直接消失。财报 / 合同 / 规格表这类文档,表格丢了核心信息就蒸发了。
把这四类合起来看,loader 的脆弱性藏在真实样本里,demo 跑通不代表能用。在 s02 把这些坑看清,后面的 chunking、embedding、retrieval才能做得更好,他们全建立在这个基础上。
2. 两个 demo 的关系
bash
┌──────────────────┐
│ 代码 1 │
│ basic_load.py │
│ load_pdf / │
│ load_docx │
│ ▼ │
│ {text,page, │
│ source} 列表 │
└──────────────────┘
│
▼ (跑在真实样本上)
┌───────────────────────┐
│ 代码 2 │
│ failure_modes.py │
│ 复用 load_pdf/docx │
│ + 量化 PDF 错位 │
│ + DOCX 丢表字符数 │
│ │
│ 输出损失量化 (不修) │
└───────────────────────┘
toy 上跑通 真实样本上暴露坑
代码 1 把 PDF / DOCX 归一到统一 {text, page, source} schema,下游 chunker / embedder 不用为每种格式重写。留下的坑:DOCX 表格被吞、PDF 多栏错位、扫描件返回空串。
代码 2 复用代码 1 的 loader,跑在真实样本上量化损失——DOCX 丢 572 字符、PDF 4 段长度 861/562/413/744。它不修任何东西,只展示。修的事交给 s03 / s11。
两脚本的关系:代码 1 跑通 demo 暴露”真实样本上不行”的局限,代码 2 把这局限量化成具体数字。后面章节负责填坑——s03 chunking 用切片粒度重排缓解 PDF 多栏错位,s11 多模态补全表格 / OCR。
3. 代码 1:把 PDF / DOCX 读成统一格式
python
def load_pdf(path: Path) -> list[dict]:
"""逐页抽 text, 空页过滤, page 从 1 起编."""
out = []
for i, page in enumerate(PdfReader(path).pages, start=1):
text = page.extract_text() or ""
if text.strip():
out.append({"text": text, "page": i, "source": path.name})
return out
def load_docx(path: Path) -> list[dict]:
"""按 Word paragraph 抽, 空段过滤, page 强制 None(DOCX 无页概念)."""
out = []
for p in Document(path).paragraphs:
if p.text.strip():
out.append({"text": p.text, "page": None, "source": path.name})
return out
page=None 是因为 DOCX 没有”页”这个概念。塞 0 或 -1 会污染下游过滤逻辑(if page >= 0 这种判断会把 -1 当真实页号处理),None 最干净。下游用 if page is None 判断比 if page 稳。
跑一下:
bash
python3 s02_doc_loading/basic_load.py
bash
PDF 段落数: 4, DOCX 段落数: 27
PDF 第 1 段前 100 字: 紫光恒越 R3630 G5 双路机架式服 务器 产品白皮书 · v1.0 · 仅用于 RAG 教程测试 一、产品概述...
DOCX 第 1 段前 100 字: 青蓝科技股份有限公司
两格式返回同一形状的 list[{text, page, source}],page 值不同 (PDF: 1/2/3/4, DOCX: None),下游不需要 if source.endswith('.pdf') 之类的分支判断。
4. 代码 2:展示丢失信息
bash
python3 s02_doc_loading/failure_modes.py
bash
[PDF] 4 页抽出的段落 (page, len, first 60 字):
page= 1 len= 861 | 紫光恒越 R3630 G5 双路机架式服 务器 产品白皮书...
page= 2 len= 562 | 三、整机规格 组件 规格 说明 处理器 2 × 第三代 Intel Xeon 可 扩展处理器...
page= 3 len= 413 | 四、应用场景 云数据中心:作为通用计算节点支撑私有云与混合云平台...
page= 4 len= 744 | 五、可靠性与可维护性 冗余设计:电源、风扇、Boot 盘、PCIe 控制器均支持 N+1 冗余...
[DOCX] paragraphs(非空)=27, tables=3, 表格内总字符=572
→ basic_load.py 的 load_docx 只读 paragraphs,丢失 572 字符(3 张表)
PDF 第 2 段最有意思:三、整机规格 组件 规格 说明 处理器 2 × 第三代 Intel Xeon 可 扩展处理器...,这里有一张 13 行 × 3 列的规格表(处理器、内存、存储、网络、电源…),但 extract_text() 把它拍成了”组件 规格 说明”一行的碎片。RAG 拿到这段根本不知道”40 核/80 线程”对应的是”处理器”。
DOCX 更直白:27 段文字 + 3 张表,loader 只读了 27 段文字,3 张表 572 字符蒸发。财报类文档丢这 572 字符,基本等于丢整份文档。
PDF 4 页长度看着正常(861/562/413/744 是页面内容密度差异),但多栏错位藏在字符串内部,长度量不出来,只有 embedding 拿到的时候才发现语义混乱。代码 2 不生产新数据,只量化损失。
5. 工业方案:RAGFlow 怎么搞
RAGFlow 把 PDF 解析拆成”pdfplumber 拿页面对象 → 栅格化成图 → 视觉模型读文字与坐标”三步,扫描件 / 复杂版式 / 表格都能兜底。MVP 的 pypdf.extract_text 一条路,这几类情况全废。
python
class VisionParser(RAGFlowPdfParser):
def __images__(self, fnm, zoomin=3, page_from=0, page_to=MAXIMUM_PAGE):
try:
with sys.modules[LOCK_KEY_pdfplumber]:
self.pdf = pdfplumber.open(fnm)
self.page_images = [
p.to_image(resolution=72 * zoomin).annotated
for i, p in enumerate(self.pdf.pages[page_from:page_to])
]
except Exception:
self.page_images = None
VisionParser 用 pdfplumber.open 拿页面对象,再调 p.to_image(resolution=72 * zoomin).annotated 把整页渲染成高分辨率 PNG(栅格化)。
跟 MVP 相比,设计上几处明显不同。
后端多路 + 自动 fallback。RAGFlowPdfParser 是父类,子类有 PlainParser / VisionParser / TxtParser 等多个后端;任务执行器按文档类型 / OCR 探测结果挑一个。__images__ 用 LOCK_KEY_pdfplumber 当全局信号量加锁,允许多进程并发而不爆 ONNX runtime 显存;try/except 把渲染失败降级成 page_images=None,单页炸了不会让整篇文档挂掉——这种”局部失败不传染整体”的设计,在多进程爬虫场景下很关键。
MVP 的 basic_load.py 只有 PdfReader(path).pages[i].extract_text() 一条路,对扫描件 / 表格 / 页眉页脚毫无办法。RAGFlow 的三阶段:
(1) __images__ 把页面变成 72 × zoomin DPI 的图;
(2) 视觉模型(OCR + 布局识别 + 表格结构识别)从图里读出文字与坐标;
(3) 按 bbox(每个文字 / 块的矩形坐标)排序回填成段落。代价是要装 ONNX 模型(微软开源的推理引擎)+ CPU/GPU 推理 + 几十 MB 依赖,换来”扫描件 / 复杂版式 / 表格”全场景能跑。
6. 怎么解决
每类问题都有对应的修复路径,但该不该在这一章做,要权衡一下:
编码 / 乱码: 控制台层一行修——sys.stdout.reconfigure(encoding='utf-8')。PDF 字符映射问题需要 OCR,s11 多模态那章专门讲。
扫描件: s11 多模态专题。跑 failure_modes.py 看到扫描件率高于 30%,直接跳 s11,别在 basic_load.py 上硬塞 OCR。OCR 模型 50MB~1GB+,压在入门骨架里不划算。
多栏错位: s03 chunking 缓解(切片粒度重排让错位段被切碎成更小单位)。根因修复在 s11 视觉层(版面分析 + 目标检测)。
表格 / 图片: s11 表格结构识别 + OCR。
7. 思考题
• 代码 1 的 page=None 为什么不写成 0 或 -1?如果下游按 page >= 0 过滤会怎样?
• 把代码 1 喂给扫描件 PDF,会拿到什么?RAG 流水线在空文本上会发生什么?
• 表格丢了 572 字符,对 RAG 召回率影响有多大?如果表格里是关键数字(比如”全年营收 28.74 亿”),embedding 检索能召回吗?
8. 思考题答案
Q1. 为什么 page=None 而不是 0 或 -1?
DOCX 没有”页”这个结构概念(段落才是结构边界),硬塞 0 或 -1 当 sentinel(哨兵值,代表”没值”的标记)不优雅。下游写 if page >= 0 判断会把 -1 当真实页号处理(过滤逻辑反掉);用 if page 会把 0 判为 False 跳过。None 是 Python 里”这个字段不适用”的标准表达,下游用 if page is None 判断最稳。
Q2. 扫描件 PDF 跑代码 1,会拿到什么?
pypdf.extract_text() 对纯图像页返回空字符串,被 if text.strip() 静默过滤掉。你传了 10 页扫描件,实际拿到 0 段。后面 embedding 步骤在空字符串上跑(直接抛异常或返回 NaN 向量);retrieval 拿空向量算相似度,top-k 全是无意义结果。报错不会发生在 loader 层,而是发生在 embedding 层——栈轨迹指向 embedding 代码,你以为是 embedding 的 bug,实际根因在 s02。
Q3. 表格丢 572 字符,召回率影响有多大?
按字符比例看不算大(27 段文本 + 572 字符表格,占比 ~10%);但对结构化查询影响几乎是 100%。表格里如果是”全年营收 28.74 亿””处理器 40 核”这类数字,embedding 召回几乎不可能命中,因为这些字符根本没进向量库。表格对结构化查询来说是必须的:丢了表格,这类问题就永远答不上来。
9. 下一篇:s03 文本分块
s03 文本分块,把代码 1 输出的 {text, page, source} schema 上的 text 单元按”句界 + token cap”切块。顺手暴露几类失败:父子块(小块命中但答案跨块)、跨段引用(引用上下文被切断)、表格切片(表格被强行切到两 chunk 里)。切片粒度的重排能吸收一部分 PDF 多栏错位的影响(错位段被切碎成 token-cap 级小块,embedding 拿到的局部稠密度比整页稀疏长文本更稳),但根因修复在 s11 版面分析。
仓库地址:https://github.com/yaoweizhang/learn-ragflow
夜雨聆风