文档解析与预处理:PDF/HTML/Word解析、文本清洗、段落分割策略

文档解析这步,说白了就是给RAG系统「喂饭」。饭没切好,后面的检索和生成全得跟着遭殃。我见过太多团队在模型调优上花了大把时间,结果召回率上不去,一查才发现——原始文档里的文字都是乱码或者被截断的。

今天咱们就把这顿饭怎么做透,聊清楚。

2.1 多格式文档解析实战

现实世界里的文档格式五花八门。PDF、Word、HTML是最常见的三种。每种格式的解析策略完全不同,我一个个说。

2.1.1 PDF解析:最头疼的格式

PDF这玩意儿,说白了就是一张「打印出来的纸」。它里面没有段落、没有标题层级,只有坐标和字体。所以解析PDF时,我习惯分场景选工具:

场景 推荐工具 优点 缺点
文字型PDF(可复制) PyMuPDF (fitz) 速度快,保留字体和位置信息 对复杂表格支持一般
扫描型PDF(图片) OCR(PaddleOCR / Tesseract) 能提取图片中的文字 速度慢,有识别错误
复杂排版PDF pdfplumber 表格提取能力强 大文件内存占用高
混合型PDF Unstructured.io 自动检测类型,一站式 依赖较多,部署稍重
我的经验: 遇到扫描型PDF,别直接用Tesseract。我曾在项目中处理过一批清晰度很差的合同,Tesseract的准确率只有60%。后来换成PaddleOCR,配合版面分析模型,准确率提到了92%。嗯,选对工具真的很关键。

下面是我常用的PDF解析代码片段:

import fitz  # PyMuPDF

def extract_text_from_pdf(pdf_path):
    doc = fitz.open(pdf_path)
    full_text = []
    for page_num, page in enumerate(doc):
        # 提取文本块,保留位置信息
        blocks = page.get_text("blocks")
        page_text = []
        for block in blocks:
            # block结构: (x0, y0, x1, y1, text, block_type)
            if block[5] == 0:  # 0表示文本块
                page_text.append(block[4])
        full_text.append("\n".join(page_text))
    doc.close()
    return "\n".join(full_text)

2.1.2 Word文档解析

Word文档比PDF友好得多。它本身就有段落、标题、列表这些结构信息。我个人习惯用 python-docx 这个库。

这里有个坑:很多人直接遍历所有段落,忽略了表格里的内容。Word里的表格经常藏着关键数据。

from docx import Document

def extract_from_docx(docx_path):
    doc = Document(docx_path)
    content = []
    
    # 提取段落
    for para in doc.paragraphs:
        if para.text.strip():
            content.append({
                "text": para.text,
                "style": para.style.name  # 保留样式信息
            })
    
    # 提取表格内容
    for table in doc.tables:
        for row in table.rows:
            row_text = [cell.text for cell in row.cells]
            content.append({
                "text": " | ".join(row_text),
                "style": "Table"
            })
    
    return content
注意: Word文档里的页眉页脚、批注、修订痕迹,这些内容通常不应该进入RAG索引。我曾经因为没过滤批注,导致检索出来的内容里夹杂着「这个数据不对,需要修改」之类的内部备注,差点闹出笑话。

2.1.3 HTML解析

HTML解析的核心是「去噪」。网页里充斥着导航栏、广告、侧边栏、版权声明……这些东西对RAG来说全是噪音。

我推荐用 BeautifulSoup 配合 readability-lxml 来做:

from bs4 import BeautifulSoup
import readability

def extract_from_html(html_content):
    # 第一步:用readability提取正文
    doc = readability.Document(html_content)
    main_content = doc.summary()
    
    # 第二步:用BeautifulSoup清理残留标签
    soup = BeautifulSoup(main_content, 'html.parser')
    
    # 移除脚本、样式、图片
    for tag in soup(['script', 'style', 'img', 'iframe']):
        tag.decompose()
    
    # 提取标题层级
    title = soup.find('h1')
    title_text = title.get_text() if title else ""
    
    # 提取所有文本
    text = soup.get_text(separator='\n', strip=True)
    
    return {
        "title": title_text,
        "text": text
    }

2.2 文本清洗:别让脏数据毁了你的RAG

解析完文档,你会发现文本里全是「垃圾」——乱码字符、多余的空格、换行符、Unicode控制字符……这些必须清理干净。

我总结了一套清洗流程,按优先级排序:

  1. 去除控制字符:\x00-\x1f 这些不可见字符,直接干掉
  2. 统一换行符:\r\n 统一转成 \n
  3. 合并多余空白:连续空格、制表符,合并成一个空格
  4. 修复乱码:比如「’」这种UTF-8解码错误,转成正确的单引号
  5. 去除特殊符号:保留中文、英文、数字、常用标点,其他酌情处理

核心原则: 清洗要「保守」。宁可少洗,不要多洗。我见过有人把代码里的下划线全删了,结果检索「RAG_system」时永远匹配不到「RAGsystem」——因为下划线被洗掉了。

下面是我常用的清洗函数:

import re
import unicodedata

def clean_text(text):
    # 1. 去除控制字符
    text = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]', '', text)
    
    # 2. 统一换行符
    text = text.replace('\r\n', '\n').replace('\r', '\n')
    
    # 3. 合并多余空白(保留单个换行)
    text = re.sub(r'[ \t]+', ' ', text)
    
    # 4. 修复常见乱码
    text = text.replace('’', "'").replace('â€"', '—')
    text = text.replace('“', '"').replace('â€', '"')
    
    # 5. 去除零宽字符
    text = text.replace('\u200b', '').replace('\ufeff', '')
    
    # 6. 规范化Unicode(NFKC)
    text = unicodedata.normalize('NFKC', text)
    
    return text.strip()

2.3 段落分割策略:切得好,才能找得准

文档清洗干净了,接下来就是「切段」。这一步直接决定了检索的颗粒度。

切得太粗,一个段落包含多个主题,检索时语义模糊。切得太细,上下文丢失,检索结果支离破碎。

我常用的分割策略有三种,适用场景不同:

策略 原理 适用场景 推荐参数
固定长度分割 按字符数或数切分 通用场景,简单粗暴 512-1024 tokens
语义分割 按段落、标题、换行符切 结构化文档(、报告) 保留自然段落
递归分割 先按大块切,再递归细分 长文档、混合内容 chunk_size=1000, overlap=200

我个人最常用的是「递归分割 + 重叠窗口」。为什么?因为固定长度分割经常把一句话从中间砍断,导致语义不完整。而递归分割会优先按段落边界切,实在超长了才按句子切。

from langchain.text_splitter import RecursiveCharacterTextSplitter

def split_document(text, chunk_size=1000, chunk_overlap=200):
    splitter = RecursiveCharacterTextSplitter(
        separators=["\n\n", "\n", "。", ".", " ", ""],
        chunk_size=chunk_size,
        chunk_overlap=chunk_overlap,
        length_function=len,
    )
    chunks = splitter.split_text(text)
    return chunks
避坑指南: 我曾经在切分中文文档时,直接用英文的separator列表,结果「。句号」没有被识别为分隔符,导致很多段落被硬生生从中间切开。后来我把separator改成了 ["\n\n", "\n", "。", "!", "?", ".", " ", ""],效果好了很多。

2.4 整体流程与SVG框架图

把上面这些步骤串起来,就是一套完整的文档预处理流水线。我画了张图,方便你理解整体脉络:

文档预处理流水线 原始文档 格式识别 PDF解析 Word解析 HTML解析 文本清洗 段落分割 结构化Chunks 分割策略:固定长度 / 语义 / 递归 推荐:递归分割 + 重叠窗口

你看,整个流程其实不复杂。但每个环节都有细节要注意。格式识别这步,我建议用文件扩展名 + MIME类型双重判断,别只信扩展名——我遇到过把PDF重命名为.docx的奇葩情况。

2.5 实战中的几个坑

最后分享几个我踩过的坑,希望能帮你省点时间:

  • PDF里的表格:很多PDF解析库会把表格内容打乱。我建议先用 pdfplumber 单独提取表格,再和正文合并。
  • HTML里的动态内容:有些网页内容是通过JavaScript渲染的。直接用requests拿不到。这种情况得用 selenium 或 playwright 先渲染再解析。
  • 编码问题:Word文档可能是GBK编码,HTML可能是UTF-8。统一转成UTF-8再处理,否则清洗时容易出乱码。
  • 分割后的重叠:重叠窗口不是越大越好。我试过overlap=500,结果相邻两个chunk几乎一模一样,检索时重复内容太多。一般overlap控制在chunk_size的10%-20%比较合理。

文档预处理这块,说白了就是「七分工具,三分经验」。工具选对了,流程搭好了,剩下的就是根据你的数据特点慢慢调参。别指望一次搞定,多试几次,你会找到最适合自己场景的那套方案。