文档解析与分块策略:PDF、Markdown、HTML 解析,语义分块、固定大小分块、递归分块实战
文档解析和分块,是 RAG 系统的第一道关卡。说白了,这一步要是没做好,后面检索、生成全是白搭。我见过太多团队花大把时间调模型,结果问题出在最开始的文档读取上——PDF 里文字乱码、Markdown 表格被拆散、HTML 标签没清理干净……这些坑,我基本都踩过一遍。
今天咱们就把这块彻底讲透。从三种主流文档格式的解析,到三种核心分块策略的实战,我会把项目中积累的经验和教训都摊开来说。
3.1 文档解析:三种格式的实战处理
先聊解析。你想想看,知识库里的文档千奇百怪,但最常见的无非三种:PDF、Markdown、HTML。每种格式都有自己的脾气,处理方式完全不同。
3.1.1 PDF 解析:最头疼的格式
PDF 这玩意儿,设计初衷就不是为了给机器读的。它存的是「页面长什么样」,而不是「内容是什么」。所以解析 PDF 时,我建议你根据文档类型选工具:
| 文档类型 | 推荐工具 | 适用场景 | 注意点 |
|---|---|---|---|
| 文字型 PDF | PyMuPDF (fitz) | 论文、报告、合同 | 速度快,保留文本结构 |
| 扫描型 PDF | OCR (pytesseract / PaddleOCR) | 扫描件、图片型文档 | 需要先转图片再识别 |
| 复杂排版 PDF | pdfplumber | 表格、多栏布局 | 能提取表格坐标信息 |
| 混合型 PDF | Unstructured.io | 多种元素混合 | 自动检测元素类型 |
我个人习惯用 PyMuPDF 做第一道处理。它轻量、速度快,而且能保留文本的层级信息。来看个实际例子:
import fitz # PyMuPDF
def extract_pdf_text(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[0:4] 是坐标,block[4] 是文本内容
if block[6] == 0: # 0 表示文本块
page_text.append(block[4])
full_text.append({
"page": page_num + 1,
"content": "\n".join(page_text)
})
return full_text
3.1.2 Markdown 解析:保留结构是关键
Markdown 相对友好,因为它本身就是结构化文本。但问题在于——很多 RAG 系统把 Markdown 当纯文本处理,结果标题、列表、代码块全混在一起,语义全丢了。
我建议的做法是:用 markdown-it 或 mistune 这类解析器,先把 Markdown 转成 AST(抽象语法树),再按节点类型提取内容。
import mistune
def parse_markdown_with_structure(md_text):
# 使用 mistune 解析 Markdown
markdown = mistune.create_markdown(renderer='ast')
tokens = markdown(md_text)
structured_content = []
for token in tokens:
if token['type'] == 'heading':
structured_content.append({
"type": "heading",
"level": token['attrs']['level'],
"content": token['children'][0]['raw']
})
elif token['type'] == 'paragraph':
structured_content.append({
"type": "paragraph",
"content": token['children'][0]['raw']
})
elif token['type'] == 'code':
structured_content.append({
"type": "code",
"language": token.get('info', ''),
"content": token['raw']
})
return structured_content
3.1.3 HTML 解析:清理标签,保留语义
HTML 解析的核心矛盾是:标签太多,噪声太大。你想想看,一个网页里可能有一半是导航、广告、侧边栏,真正的内容就中间那一块。
我的做法分三步:
- 提取主内容:用
readability-lxml或trafilatura这类工具,自动识别文章主体区域 - 清理标签:去掉 script、style、nav、footer 等无关元素
- 保留结构标签:h1-h6、p、ul/ol、table、pre 这些标签保留,其他全部转成纯文本
from bs4 import BeautifulSoup
def clean_html_content(html_text):
soup = BeautifulSoup(html_text, 'html.parser')
# 移除无关元素
for tag in soup(['script', 'style', 'nav', 'footer', 'aside']):
tag.decompose()
# 保留的结构标签
preserved_tags = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'p', 'ul', 'ol', 'li', 'table', 'pre', 'code']
result = []
for element in soup.find_all(preserved_tags):
tag_name = element.name
text = element.get_text(strip=True)
if text:
result.append(f"<{tag_name}>{text}</{tag_name}>")
return "\n".join(result)
3.2 分块策略:三种核心方法实战
文档解析完了,接下来就是分块。分块这事儿,说白了就是「把长文档切成适合检索的小段」。切得太碎,语义不完整;切得太粗,检索精度下降。这里我重点讲三种策略。
3.2.1 固定大小分块:简单但有效
固定大小分块是最直接的方法——按字符数或 token 数切分。优点是实现简单,缺点是容易切断语义。
def fixed_size_chunking(text, chunk_size=500, overlap=50):
"""固定大小分块,带重叠"""
chunks = []
start = 0
while start < len(text):
end = start + chunk_size
chunk = text[start:end]
chunks.append(chunk)
start = end - overlap # 重叠部分
return chunks
- chunk_size:一般 200-1000 token 之间,取决于你的检索模型
- overlap:10%-20% 的重叠,避免关键信息被切在边界上
我在项目中遇到过一个问题:固定分块把一段代码的 for 循环切成了两半,结果检索到的代码片段根本没法用。所以后来我加了个规则——如果切分点在代码块内,就调整到代码块结束位置。
3.2.2 递归分块:LangChain 的经典方案
递归分块是 LangChain 里 RecursiveCharacterTextSplitter 的核心思路。它不像固定分块那样一刀切,而是按「分隔符优先级」递归地切分。
优先级顺序一般是:\n\n > \n > 。 > , > 空格
def recursive_chunking(text, chunk_size=500, chunk_overlap=50):
separators = ["\n\n", "\n", "。", ",", " ", ""]
def _split(text, separators):
if len(text) <= chunk_size:
return [text]
sep = separators[0]
if sep == "":
# 没有分隔符了,直接按字符切
return [text[i:i+chunk_size]
for i in range(0, len(text), chunk_size - chunk_overlap)]
# 按当前分隔符切分
parts = text.split(sep)
chunks = []
current_chunk = ""
for part in parts:
if len(current_chunk) + len(part) + len(sep) <= chunk_size:
current_chunk += (sep if current_chunk else "") + part
else:
if current_chunk:
chunks.append(current_chunk)
current_chunk = part
if current_chunk:
chunks.append(current_chunk)
# 如果切出来的块还是太大,递归用下一个分隔符
result = []
for chunk in chunks:
if len(chunk) > chunk_size:
result.extend(_split(chunk, separators[1:]))
else:
result.append(chunk)
return result
return _split(text, separators)
3.2.3 语义分块:最智能但最复杂
语义分块,说白了就是「让模型自己决定在哪里切」。它不依赖固定的字符数,而是根据内容的语义边界来分。
常见的实现方式有两种:
- 基于嵌入相似度:计算相邻句子的向量相似度,相似度骤降的地方就是分块点
- 基于主题分割:用 TextTiling 算法或 LLM 检测主题变化
from sentence_transformers import SentenceTransformer
import numpy as np
def semantic_chunking(sentences, model_name='all-MiniLM-L6-v2', threshold=0.3):
"""基于嵌入相似度的语义分块"""
model = SentenceTransformer(model_name)
# 计算每个句子的嵌入
embeddings = model.encode(sentences)
# 计算相邻句子的余弦相似度
similarities = []
for i in range(len(embeddings) - 1):
sim = np.dot(embeddings[i], embeddings[i+1]) / (
np.linalg.norm(embeddings[i]) * np.linalg.norm(embeddings[i+1])
)
similarities.append(sim)
# 找到相似度骤降的位置
chunk_boundaries = [0]
for i, sim in enumerate(similarities):
if sim < threshold:
chunk_boundaries.append(i + 1)
chunk_boundaries.append(len(sentences))
# 按边界切分
chunks = []
for i in range(len(chunk_boundaries) - 1):
start = chunk_boundaries[i]
end = chunk_boundaries[i + 1]
chunks.append(" ".join(sentences[start:end]))
return chunks
3.3 三种策略对比与选型建议
三种策略各有优劣,我整理了一张对比表:
| 策略 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 固定大小分块 | 实现简单,速度快 | 容易切断语义 | 日志、代码、结构化数据 |
| 递归分块 | 语义完整性好,通用性强 | 对分隔符质量敏感 | 文章、报告、通用文档 |
| 语义分块 | 最智能,边界最自然 | 计算成本高,依赖模型 | 长文档、学术论文、书籍 |
我个人建议的选型路径是:
- 先试递归分块,它 80% 的场景都够用
- 如果文档结构特别规整(比如 Markdown 有清晰标题),用固定分块 + 标题锚点
- 只有处理长文档(论文、书籍)时,才上语义分块
3.4 本章知识体系总览
下面这张图,把本章的核心逻辑串起来了。从文档输入到分块输出,每一步的输入输出和关键决策点都标清楚了:
3.5 实战建议:从解析到分块的完整流程
最后,我分享一下在实际项目中跑通的完整流程。这个流程经过多次迭代,目前比较稳定:
- 文档预处理:统一编码(UTF-8),去除 BOM 头,修复常见乱码
- 格式识别:根据文件扩展名和 MIME 类型,自动选择解析器
- 内容提取:按 3.1 节的方法提取结构化内容
- 元数据注入:给每个 chunk 打上来源、页码、标题路径等标签
- 分块执行:优先递归分块,特殊场景切换策略
- 质量检查:随机抽 5% 的 chunk 人工检查语义完整性