主题
RAG 文档处理:PDF、表格、切分策略这些脏活才是效果上限
本文是 AI 应用系统学习系列的 L2 核心篇。前置:34. RAG 全链路:从文档到答案、47. 向量数据库。 学完可以配合面试题食用:02. RAG 流水线、16. GraphRAG
先问一句:答非所问,真怪模型吗
RAG 上线后答非所问,第一反应往往是换模型或改提示词。先做件更便宜的事:把检索回来的 chunk 原文打印出来看一眼。大概率你会看到这种东西——句子从中间断开、表格变成一串不连贯的数字、或者干脆是乱码。
类比做饭:菜洗不干净、切得乱七八糟,厨艺再好也做不出像样的菜。文档处理就是洗菜切菜,它的质量上限在 Embedding 和模型介入之前就定了。脏文档进去,脏答案出来。
还有一笔时间账。一个企业 RAG 项目里,调提示词、换模型加起来可能占一两周;写解析器、调切分参数、清洗各种格式的文档,占一两个月起步。这是落地最花时间的环节,值得当成一个正式模块来建,而不是个脚本预处理。
PDF 两条路:有文本层走提取,没文本层走 OCR
PDF 是企业文档的主流格式,也是解析的深水区。先判断手里这份 PDF 有没有文本层:pdftotext file.pdf - | head 能出字就有,全是空白或乱码就是扫描件。
两条路:
- 有文本层:用 PyMuPDF 或 pdfplumber 直接提取。PyMuPDF 快(百页文档秒级出结果),适合批量;pdfplumber 的表格抽取更强,适合以表格为主的文档。
- 扫描件:先 OCR。PaddleOCR 对中文效果好,云 OCR 服务省心但要按页付费。OCR 出来的是没有段落结构的纯文本流,标题和正文的层级信息全丢了。
就算有文本层,也有三个典型翻车现场:
- 双栏排版:按坐标从左到右逐行提取,两栏文字会交错成"半句 A 栏接半句 B 栏",embedding 对这种文本基本失效。解法是先按栏的坐标范围切块,分别提取再拼接。
- 页眉页脚:每一页都混进"第 X 页 共 Y 页"和公司名。几十页的文档里这种噪声重复几十遍,检索时白白占坑。解法是按 y 坐标过滤页头页尾区域。
- 跨页表格:表格被分页截断,前半张在上一页、后半张在下一页。不合并的话,两半各自的语义都不完整。
mermaid
flowchart TD
A[PDF 进来] --> B{有文本层?}
B -->|有| C[PyMuPDF / pdfplumber 提取]
B -->|无| D[OCR: PaddleOCR / 云服务]
C --> E{版式检查}
E -->|双栏| F[按栏坐标切块提取]
E -->|单栏| G[直接提取]
F --> H[清洗: 去页眉页脚 / 修跨页表格 / 补段落]
G --> H
D --> H
H --> I[结构化中间格式: Markdown / JSON]建议把所有文档先转成统一的中间格式(Markdown 或带结构的 JSON),后续切分和 Embedding 只面对这一种格式。格式各异的脏活集中收敛在解析层,下游管线保持干净。
表格:转结构化标记,别按字符硬切
表格是 RAG 效果的重灾区。一行"营业收入 1,234 亿元 同比 +12%",转成纯文本后数字和含义还能对上;按固定字符数一切,"营业收入"和"1,234 亿元"进了两个 chunk,这条数据等于废了。
表格处理两条主流路线:
- 转 Markdown 或 HTML 保留结构:表格变成
| 营业收入 | 1,234 亿元 |这样的标记文本。embedding 模型对 Markdown 表格的表征能力参差不齐,投喂前最好拿自己的表格实测一轮召回。 - 表格截图走多模态:把表格整张渲染成图片,用视觉模型(Qwen-VL 这类)识别或直接以图检索。结构保得最完整,成本和链路复杂度都更高。
不管走哪条,有一条铁律:表格不能按固定字符数切。整张表当作一个 chunk;超大表先按行组拆成子表,每块都带上表头,再各自入索引。
切分策略:四种,各有各的适用场景
文档清洗完,切分成 chunk 才能做 embedding。四种策略,从糙到细:
- 固定窗口:每 500 字符一段,重复 50 字符。五行代码搞定,代价是不管语义,句子段落说切就切。对格式规整的短文档勉强能用,正式项目别只用这个。
- 递归字符:LangChain 的
RecursiveCharacterTextSplitter,按\n\n→\n→。→ 空格的优先级逐级降级,尽量在自然边界断开。这是通用场景的默认起点。 - 按文档结构:Markdown 按标题层级切,每章一节、每节一块。只要源文档是 Markdown 或能转成 Markdown,优先用这个——标题天然是语义边界。
- 语义切分:逐句算 embedding,相邻句子相似度出现断崖的位置就是话题切换点,在断崖处切开。效果最贴合内容本身,代价是建库时要对全文档跑一遍 embedding,成本高一个量级。对召回质量有硬要求的场景再上。
选型的实际建议:能转 Markdown 的走结构切分,其余用递归字符,语义切分当质量杀招。别一上来就追最复杂的。
chunk 大小与 overlap:两头挤的平衡术
chunk 大小是个两难:切太小,一条 chunk 只有半句话,缺上下文,单独拿出来谁也看不懂;切太大,一条 chunk 混进多个主题,embedding 向量被稀释,用户问 A 主题时这条"主要讲 B 顺带提了 A"的 chunk 可能排在召回前十开外。经验起点:通用问答 256-512 token,overlap 取 chunk 的 10%-20%。
overlap 的作用要摆正:它只救边界——一句话恰好被切在两块之间时,靠重复区保住完整语义。它救不了大病:切分单元本身选错了(把一张表拦腰切断),overlap 翻倍也没用。
small-to-big:检索用小块,生成喂大块
chunk 大小的矛盾有个工程化解法:拆成两个粒度,各干各的活。小块(一两句话)拿去做 embedding 和检索,语义单一、向量准;命中后喂给模型的是它所属的大块(整节甚至整章),上下文完整。
mermaid
flowchart LR
D[文档] --> P[父块: 章节<br/>1000-2000 token]
D --> C[子块: 段落/句子<br/>100-300 token]
C -->|embedding 入索引| V[向量库]
Q[查询] --> V
V -->|命中子块| M[回查父块 ID]
M --> W[父块全文进上下文]实现要点:子块存向量库,父块存普通数据库,子块的 metadata 里带 parent_id。检索命中的是子块,组装上下文时按 parent_id 回查父块。这个模式叫 small-to-big,也叫父子块检索,LlamaIndex 的 SentenceWindowNodeParser 和 LangChain 的 ParentDocumentRetriever 都是现成实现。两头占便宜:检索精度按小块算,生成质量按大块保。
元数据:溯源和更新全靠它
每个 chunk 入库时必须带上元数据:来源文档、页码、章节路径、更新时间、版本号、业务标签(部门、密级、产品线)。当时看是重复劳动,后面两件事全押在这上面:
- 引用溯源:答案旁边能给出"出自《XX 制度 V3.2》第 12 页"。用户敢用 RAG 的答案,很大程度上因为能点回原文核对。没有页码的溯源会被人质疑。
- 增量更新:文档改版后,按
source + version找到旧版全部 chunk 删掉再写入新版,不用全库重建。没有版本元数据,新旧两版内容同时躺在库里打架,检索时哪边召回看缘分。
再进一步,可以按元数据做权限过滤——不同部门只搜得到自己范围内的文档——以及按章节路径做索引分区。元数据字段在建库第一天就定好,后补要全量重刷。
动手实操:提取、切分、父子块一条龙
用 pdfplumber 提取文本和表格,递归字符切分,最后跑一个父子块检索的最小实现。
python
# pip install pdfplumber langchain langchain-text-splitters
import pdfplumber
from langchain_text_splitters import RecursiveCharacterTextSplitter
# --- 第一步:提取文本和表格(有文本层的 PDF) ---
def extract_pdf(path: str) -> str:
out = []
with pdfplumber.open(path) as pdf:
for i, page in enumerate(pdf.pages):
# 页眉页脚按 y 坐标裁掉:只取页面上 95% 的区域
cropped = page.within_bbox((0, 0, page.width, page.height * 0.95))
lines = (cropped.extract_text() or "").splitlines()
# 表格转 Markdown 行,保住"列名: 值"的对应关系
for table in cropped.extract_tables():
for row in table:
cells = [c.replace("\n", "") if c else "" for c in row]
lines.append("| " + " | ".join(cells) + " |")
# 每行打上页码标记,溯源用
out.extend(f"[p{i+1}] {line}" for line in lines if line.strip())
return "\n".join(out)
# --- 第二步:递归字符切分(父块:喂给模型的大块) ---
splitter = RecursiveCharacterTextSplitter(
chunk_size=400, # 父块 400 字符:上下文完整
chunk_overlap=50, # 50 字符重复区,只救边界断句
separators=["\n\n", "\n", "。", ";", " ", ""], # 优先在自然边界断
)python
# --- 第三步:父子块检索的最小实现 ---
# 依赖:pip install dashscope( embedding 接口任意,换成 OpenAI/SiliconFlow 均可)
import dashscope
from dashscope import TextEmbedding
docs_store: dict[str, dict] = {} # parent_id -> {"text": 父块全文, "source": 来源}
vecs: list[tuple[list, str]] = [] # (子块向量, parent_id)
def embed(text: str) -> list:
resp = TextEmbedding.call(model="text-embedding-v3", input=text)
return resp.output["embeddings"][0]["embedding"]
sub_splitter = RecursiveCharacterTextSplitter( # 子块切分器:120 字符只做检索
chunk_size=120, chunk_overlap=20)
def ingest(doc_text: str, source: str):
parents = splitter.create_documents([doc_text],
metadatas=[{"source": source}])
for idx, p in enumerate(parents):
pid = f"{source}#{idx}"
docs_store[pid] = {"text": p.page_content, "source": source}
# 父块再切成子块,只有子块做 embedding
for chunk_text in sub_splitter.split_text(p.page_content):
vecs.append((embed(chunk_text), pid))
def search(query: str, k: int = 3) -> list[str]:
qv = embed(query)
scored = sorted(vecs, key=lambda v: -dot(qv, v[0])) # 生产请换向量库
seen, hits = set(), []
for _, pid in scored: # 子块命中 -> 回查父块
if pid not in seen:
seen.add(pid)
hits.append(docs_store[pid]["text"])
if len(hits) == k:
break
return hits
def dot(a, b):
return sum(x * y for x, y in zip(a, b))
# --- 使用 ---
text = extract_pdf("2025年报.pdf")
ingest(text, source="2025年报")
for chunk in search("公司去年研发投入多少"):
print(chunk[:100], "...")关键行说明:extract_pdf 里 within_bbox 裁掉页脚,extract_tables 把表格转成 Markdown 行;外层 chunk_size=400 是父块(喂给模型),内层 120 是子块(拿去检索);search 命中子块后按 parent_id 回查父块全文。演示用列表线性扫描,数据过万请换成第 47 篇讲的向量库。
常见误区与小结
- 解析完不人工抽查。双栏 PDF 交错文本、OCR 把"回款率"认成"回款率"以外的字,这类问题不抽样看根本发现不了。上线前固定抽 20 页人工核对,之后每次新文档类型接入再抽。
- 表格按固定字符数切。"列名"和"数值"分家,这条数据就废了;表格必须整块或带表头切。
- 只调 chunk 大小不治本。检索质量差先看解析出的文本对不对,再谈切分参数;把 overlap 从 50 调到 200 救不了选错了切分单元的病。
- 元数据缺页码和版本。溯源给不出页码,文档更新后新旧内容并存打架,都是建库时偷懒欠的债。
- 全库一律语义切分。建库成本翻几倍,对结构清晰的 Markdown 文档收益还不如标题切分;按文档类型选策略。
小结:第 34 篇把 RAG 六步流水线串了一遍,第 47 篇补了检索的底层账,本文把最上游的加载和切分摊开讲——解析、表格、四种切分策略、父子块、元数据。RAG 的效果上限在数据处理阶段就定了大半,这也是企业落地最花时间的环节。下一篇 49. Reflection 模式 转向 Agent 侧:让模型自己检查自己的产出,怎么设计才不是走过场。
参考
- pdfplumber 官方文档:https://github.com/jsvine/pdfplumber
- LangChain Text Splitters:RecursiveCharacterTextSplitter 与 ParentDocumentRetriever 章节(python.langchain.com)
- LlamaIndex SentenceWindowNodeParser:https://docs.llamaindex.ai