文档处理实战:PDF和Word怎么变成高质量知识库
上篇我们用 PyMuPDF 三行代码解析了 PDF,看起来很简单对不对?
但那是"教科书级"的 PDF------纯文字、单栏、无表格。现实中你收到的 PDF 可能是这样的:扫描件歪七扭八、表格嵌套表格、双栏排版混着图片、甚至第 3 页和第 15 页方向都不一样。
文档处理是 RAG 的第一个大坑,也是最重要的坑。 这篇我把踩过的坑全倒出来。
大家好,我是黒漂技术佬。
一、先给你泼盆冷水:真实文档长什么样
企业知识库的文档来源通常就这几个:
| 来源 | 典型文件 | 质量 |
|---|---|---|
| 产品/技术文档 | PDF(导出自 Word / Markdown) | ⭐⭐⭐⭐ 较好 |
| 制度/合规文件 | PDF(扫描件或打印后扫描) | ⭐⭐ 差 |
| 会议纪要/方案 | Word / 飞书文档 | ⭐⭐⭐ 一般 |
| 培训材料 | PPT 导出 PDF | ⭐⭐ 差 |
| 历史归档 | 各种格式混杂,甚至有 .wps | ⭐ 噩梦级 |
处理这些文档时,你会遇到:
- 扫描件:每一页是一张图,文字是"画上去"的,需要 OCR
- 表格:解析后变成「姓名部门职位张三技术部工程师」,糊成一坨
- 双栏布局:解析器按行读,左边半句右边半句混着来
- 页眉页脚/水印:每页都带"XX公司 · 内部机密 · 2024 V1.0",全被当成正文
- 嵌入图片:架构图、流程图、截图,直接丢弃或变成乱码占位符
二、PDF 解析:三种武器,各有所长
武器 1:PyMuPDF(fitz)------ 通用首选
python
import fitz # PyMuPDF
doc = fitz.open("document.pdf")
for page in doc:
text = page.get_text() # 提取文本
images = page.get_images() # 提取图片引用
tables = page.find_tables() # 检测表格(新版支持)
优点 :速度快,对现代生成的 PDF 效果好,直接支持表格检测。
缺点:遇到扫描件就废,需要配合 OCR。
武器 2:pdfplumber ------ 表格之王
python
import pdfplumber
with pdfplumber.open("document.pdf") as pdf:
for page in pdf.pages:
text = page.extract_text() # 文本提取
tables = page.extract_tables() # 表格提取(比PyMuPDF更稳)
pdfplumber 的表格提取远超 PyMuPDF。它通过分析 PDF 的线条和字符位置来推断表格结构,对于有边框线和无线条的表格都能处理。
这两者不是二选一的关系,而是配合使用:
python
def extract_page(page_fitz, page_plumber):
"""融合两个解析器的结果"""
text = page_fitz.get_text() # 先从 PyMuPDF 拿文本
tables = page_plumber.extract_tables() # 再从 pdfplumber 拿表格
if tables:
for table in tables:
# 把表格转成 Markdown 格式,保留结构
md_table = convert_table_to_markdown(table)
text += "\n\n" + md_table
return text
武器 3:Tesseract OCR ------ 扫描件的救星
对于扫描件,必须上 OCR(光学字符识别,Optical Character Recognition):
python
import fitz
from PIL import Image
import pytesseract
import io
def ocr_scanned_pdf(file_path):
"""处理扫描件 PDF,逐页 OCR"""
doc = fitz.open(file_path)
all_text = []
for page_num, page in enumerate(doc):
# 把 PDF 页面渲染成图片(300 DPI 是 OCR 的最佳分辨率)
pix = page.get_pixmap(dpi=300)
img = Image.open(io.BytesIO(pix.tobytes("png")))
# OCR 识别,中英文混合
text = pytesseract.image_to_string(img, lang='chi_sim+eng')
all_text.append(f"--- 第 {page_num+1} 页 ---\n{text}")
return "\n\n".join(all_text)
关键参数解读:
dpi=300:渲染分辨率。低于 200 的话小字糊成一片,OCR 准确率断崖式下降lang='chi_sim+eng':中英文混合识别。需要提前安装中文语言包
OCR 不是万能药
别对 OCR 抱太高期望。以下场景 OCR 基本歇菜:
- 手写体(尤其是医生的字,懂的都懂)
- 印章遮盖的文字
- 严重倾斜的页面(需要先做透视校正)
- 低分辨率的老旧档案扫描件
实战经验:扫描件先整体做预处理------灰度化、二值化、去噪------能提升 OCR 准确率 10%~20%。
三、Word 文档处理:比 PDF 简单,但也有坑
python
from docx import Document
def parse_docx(file_path):
doc = Document(file_path)
content = []
for para in doc.paragraphs:
# 根据段落样式判断层级
if para.style.name.startswith('Heading'):
level = int(para.style.name.split()[-1]) # Heading 1 → 1
prefix = '#' * level + ' ' # 转成 Markdown 标题
content.append(f"{prefix}{para.text}")
else:
content.append(para.text)
# 处理表格
for table in doc.tables:
md_table = docx_table_to_markdown(table)
content.append(md_table)
return "\n\n".join(content)
Word 的主要坑在嵌入对象:Visio 图、Excel 内嵌表、OLE 对象------这些 python-docx 解不出来,需要额外处理。
四、文本分块:最被低估的技术活
分块看似简单------切一刀就行。但实际上,chunk 的大小和策略直接影响检索质量。
分块的黄金法则
chunk 太大(1000+ 字)→ 语义太杂,检索命中率低
chunk 太小(100 字以下)→ 缺少上下文,LLM 看不懂
chunk 刚好(300~500 字)→ 黄金区间
三种分块策略对比
python
from langchain_text_splitters import (
RecursiveCharacterTextSplitter,
MarkdownHeaderTextSplitter,
SemanticChunker,
)
# 策略1:递归字符分块(最常用,适合绝大多数场景)
splitter_recursive = RecursiveCharacterTextSplitter(
chunk_size=500,
chunk_overlap=100,
separators=["\n\n", "\n", "。", ".", "!", "?", ";", ";", " ", ""]
)
# 策略2:Markdown 按标题分块(适合结构化文档)
headers_to_split_on = [
("#", "h1"),
("##", "h2"),
("###", "h3"),
]
splitter_md = MarkdownHeaderTextSplitter(headers_to_split_on)
# 这样每个 chunk 自动带上 "h1: 第三章", "h2: 3.1 安全要求" 等元数据
# 策略3:语义分块(需要额外的 Embedding 开销,适合高质量要求场景)
splitter_semantic = SemanticChunker(embeddings=my_embeddings)
# 它会用 Embedding 模型计算句子间的语义相似度,
# 在语义"跳变"的地方切分
企业级分块的额外要求
只切块不够,每个 chunk 必须带上元数据(Metadata):
python
def chunk_with_metadata(document, file_name, file_path):
chunks = splitter.split_documents([document])
for i, chunk in enumerate(chunks):
chunk.metadata.update({
"source": file_name, # 文件名:员工手册.pdf
"file_path": file_path, # 完整路径
"chunk_index": i, # 块序号
"page_number": chunk.metadata.get("page", 1),
"doc_type": "policy", # 文档类型:制度/技术/产品
"department": "HR", # 归属部门
"updated_at": "2024-03-15", # 更新时间
})
return chunks
这些元数据在检索时可以用于过滤------比如"只看技术部的文档"、"只看 2024 年更新的内容"。没有元数据,RAG 就是个瞎子。
五、文档清洗流水线:一把梭
把我以上说的串成一个完整的流水线:
python
class DocumentPipeline:
"""企业文档处理流水线"""
def process(self, file_path: str) -> list:
ext = file_path.suffix.lower()
# Step 1: 解析
if ext == '.pdf':
raw_text = self._parse_pdf(file_path)
elif ext == '.docx':
raw_text = self._parse_docx(file_path)
elif ext == '.md':
raw_text = file_path.read_text(encoding='utf-8')
else:
raise ValueError(f"unsupported format: {ext}")
# Step 2: 清洗
clean_text = self._clean(raw_text)
# Step 3: 分块
chunks = self._split(clean_text)
# Step 4: 注入元数据
enriched = self._add_metadata(chunks, file_path)
return enriched
def _clean(self, text: str) -> str:
"""清洗文本噪音"""
import re
# 去掉多余空行(连续 3+ 个换行 → 2 个)
text = re.sub(r'\n{3,}', '\n\n', text)
# 去掉页眉页脚常见的页码标记
text = re.sub(r'^\d+\s*/\s*\d+$', '', text, flags=re.MULTILINE)
# 合并被换行打断的句子(中文段落内不应有单换行)
# 这个需要根据实际情况调整正则
text = re.sub(r'(?<=[\u4e00-\u9fff])\n(?=[\u4e00-\u9fff])', '', text)
return text.strip()
那个中文换行合并的正则是关键
很多 PDF 提取出来的文本长这样:
本系统采用微服务架构设计,
将不同业务模块拆分为独立的
服务单元,通过API网关进行
统一调度。
这在中文里是一个完整段落,但提取出来被硬换行了。正则 (?<=[\u4e00-\u9fff])\n(?=[\u4e00-\u9fff]) 的意思是:如果换行符前后都是中文字符,就把这个换行干掉,连成一句话。
六、实操建议(踩坑总结)
- 先做文档盘点:把你要入库的所有文档列出来,标注格式、页数、质量。扫一眼就知道坑在哪。
- 不要追求 100% 解析完美:表格乱一点、排版歪一点,只要 80% 的信息能提取出来,RAG 就能用。追求 100% 的 ROI 极低。
- 扫描件该放弃就放弃:如果一份扫描件占比不到 5%,且 OCR 质量实在太差,宁可手动录入关键内容到 Markdown,也不要在 OCR 参数上调一整天。
- chunk_size 不是拍脑袋的 :拿你的典型文档做实验,同一组问题,不同 chunk_size 下的检索 mAP 对比。我的数据:中文技术文档,512 字 + 64 字 overlap 综合最优。
- 元数据就是钱:花 10% 的额外时间给每个 chunk 打上完善的元数据,检索效果能提升 30% 以上。投入产出比极高。
💬 你的文档里有什么奇葩格式?遇到过什么解析难题?评论区发张截图(打码敏感信息),我帮你看看怎么解!