文档处理实战: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)—— 通用首选
importfitz# PyMuPDFdoc=fitz.open("document.pdf")forpageindoc:text=page.get_text()# 提取文本images=page.get_images()# 提取图片引用tables=page.find_tables()# 检测表格(新版支持)优点:速度快,对现代生成的 PDF 效果好,直接支持表格检测。
缺点:遇到扫描件就废,需要配合 OCR。
武器 2:pdfplumber —— 表格之王
importpdfplumberwithpdfplumber.open("document.pdf")aspdf:forpageinpdf.pages:text=page.extract_text()# 文本提取tables=page.extract_tables()# 表格提取(比PyMuPDF更稳)pdfplumber 的表格提取远超 PyMuPDF。它通过分析 PDF 的线条和字符位置来推断表格结构,对于有边框线和无线条的表格都能处理。
这两者不是二选一的关系,而是配合使用:
defextract_page(page_fitz,page_plumber):"""融合两个解析器的结果"""text=page_fitz.get_text()# 先从 PyMuPDF 拿文本tables=page_plumber.extract_tables()# 再从 pdfplumber 拿表格iftables:fortableintables:# 把表格转成 Markdown 格式,保留结构md_table=convert_table_to_markdown(table)text+="\n\n"+md_tablereturntext武器 3:Tesseract OCR —— 扫描件的救星
对于扫描件,必须上 OCR(光学字符识别,Optical Character Recognition):
importfitzfromPILimportImageimportpytesseractimportiodefocr_scanned_pdf(file_path):"""处理扫描件 PDF,逐页 OCR"""doc=fitz.open(file_path)all_text=[]forpage_num,pageinenumerate(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 简单,但也有坑
fromdocximportDocumentdefparse_docx(file_path):doc=Document(file_path)content=[]forparaindoc.paragraphs:# 根据段落样式判断层级ifpara.style.name.startswith('Heading'):level=int(para.style.name.split()[-1])# Heading 1 → 1prefix='#'*level+' '# 转成 Markdown 标题content.append(f"{prefix}{para.text}")else:content.append(para.text)# 处理表格fortableindoc.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 字)→ 黄金区间三种分块策略对比
fromlangchain_text_splittersimport(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):
defchunk_with_metadata(document,file_name,file_path):chunks=splitter.split_documents([document])fori,chunkinenumerate(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",# 更新时间})returnchunks这些元数据在检索时可以用于过滤——比如"只看技术部的文档"、“只看 2024 年更新的内容”。没有元数据,RAG 就是个瞎子。
五、文档清洗流水线:一把梭
把我以上说的串成一个完整的流水线:
classDocumentPipeline:"""企业文档处理流水线"""defprocess(self,file_path:str)->list:ext=file_path.suffix.lower()# Step 1: 解析ifext=='.pdf':raw_text=self._parse_pdf(file_path)elifext=='.docx':raw_text=self._parse_docx(file_path)elifext=='.md':raw_text=file_path.read_text(encoding='utf-8')else:raiseValueError(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)returnenricheddef_clean(self,text:str)->str:"""清洗文本噪音"""importre# 去掉多余空行(连续 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)returntext.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% 以上。投入产出比极高。
💬 你的文档里有什么奇葩格式?遇到过什么解析难题?评论区发张截图(打码敏感信息),我帮你看看怎么解!