文档解析与分块策略
PDF/HTML/Markdown 文档解析与 Chunk 策略详解,构建高质量 RAG 知识库的第一步
引言
在构建 RAG(Retrieval-Augmented Generation)知识库时,文档解析与分块(Chunking)是决定检索质量的基石。原始文档格式多样——PDF 排版复杂、HTML 夹杂标签、Markdown 结构松散——如何从这些格式中准确提取文本,再以合理的粒度切分后存入向量数据库,直接影响后续检索的精确度与召回率。本文从工程实践出发,系统梳理主流文档解析工具与分块策略,并给出可落地的代码示例。
一、文档解析
文档解析的目标是从不同格式的原始文件中提取出干净的文本内容,同时尽可能保留结构信息(标题、段落、表格等)。不同格式有各自成熟的解析方案。
1.1 PDF 解析
PDF 是最复杂的文档格式,其内容以绘图指令而非结构化文本存储,解析难度最高。以下为常用方案对比:
| 工具 | 原理 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|---|
| PyMuPDF (fitz) | 直接解析 PDF 内部对象流 | 一般文档、扫描件以外的 PDF | 速度快,支持范围广,支持提取图片和表格 | 对复杂排版(多栏、旋转)支持一般 |
| pdfplumber | 基于 PDFMiner,逐页解析字符和线条 | 需要精确提取表格和位置信息的场景 | 表格提取能力强,支持坐标定位 | 速度较慢,大文件内存占用高 |
| PyPDF2 / pypdf | 纯 Python PDF 解析 | 简单文本提取、PDF 元数据读取 | 轻量无依赖,适合简单任务 | 对非标准编码支持差,复杂排版效果不佳 |
| marker | 基于深度学习的 PDF 解析(OCR + 版面分析) | 扫描件、数学公式、多栏排版 | 准确率最高,支持 OCR 和版面还原 | 需要 GPU,速度慢,部署成本高 |
选择建议:一般文档优先 PyMuPDF;需要精确表格提取用 pdfplumber;扫描件和老旧文档用 marker(搭配 Tesseract OCR 或 Surya)。
# PyMuPDF 解析示例
import fitz
def extract_text_from_pdf(pdf_path):
doc = fitz.open(pdf_path)
pages = []
for page_num, page in enumerate(doc):
text = page.get_text()
pages.append({"page": page_num + 1, "text": text})
doc.close()
return pages1.2 HTML 解析
HTML 文档包含大量标签、样式和脚本,解析的核心是去除噪声、提取正文。
| 工具 | 定位 | 适用场景 |
|---|---|---|
| BeautifulSoup | 通用 HTML/XML 解析库 | 需要精细控制提取规则,如特定 class 或 id 的容器 |
| readability (python-readability) | 提取文章正文(仿 Readability 算法) | 博客文章、新闻页面等以正文为主的页面 |
| trafilatura | 专注网页文本提取与正文识别 | 批量爬虫场景,自动识别正文、评论、页脚 |
选择建议:单页面分析用 BeautifulSoup 进行精确提取;大规模网页抓取用 trafilatura,它能自动过滤导航、广告等噪声内容。
# trafilatura 解析示例
import trafilatura
def extract_from_html(url_or_html):
downloaded = trafilatura.fetch_url(url_or_html)
text = trafilatura.extract(downloaded, output_format="markdown")
return text1.3 Markdown 解析
Markdown 本身是纯文本,解析重点在于保留标题层级、列表、代码块等结构信息。
import markdown
def md_to_plain_text(md_content):
# 将 Markdown 转为 HTML,再提取纯文本
html = markdown.markdown(md_content)
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, "html.parser")
return soup.get_text()1.4 Office 文档解析
| 工具 | 支持的格式 | 主要用途 |
|---|---|---|
| python-docx | .docx | 读取 Word 文档的段落、表格、样式 |
| openpyxl | .xlsx | 读取 Excel 工作簿的单元格内容与公式 |
# python-docx 解析示例
from docx import Document
def extract_from_docx(docx_path):
doc = Document(docx_path)
paragraphs = [p.text for p in doc.paragraphs if p.text.strip()]
return "\n".join(paragraphs)二、Chunk 分块策略
解析得到原始文本后,需要将其切分为适合检索的片段。分块策略直接影响检索质量:块太大则噪声多、召回精度差;块太小则上下文不完整、语义缺失。
2.1 固定大小分块(Fixed-size Chunking)
最简单的策略,按固定字符数切分,可配合 Overlap 使用。
def fixed_size_chunk(text, chunk_size=512, overlap=64):
chunks = []
start = 0
while start < len(text):
end = start + chunk_size
chunks.append(text[start:end])
start += chunk_size - overlap
return chunks优点:实现简单,计算开销小。缺点:可能切断句子或段落,破坏语义完整性。适用于对语义连续性要求不高的大规模语料预处理。
2.2 语义分块(Semantic Chunking)
基于句子边界检测语义转折点,在语义完整的位置切分。核心思路是:计算相邻句子的嵌入相似度,在相似度骤降的位置断开。
import numpy as np
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("BAAI/bge-small-zh-v1.5")
def semantic_chunk(sentences, threshold=0.7):
embeddings = model.encode(sentences)
chunks, current_chunk = [], []
for i, sent in enumerate(sentences):
current_chunk.append(sent)
if i > 0:
sim = np.dot(embeddings[i-1], embeddings[i]) / (
np.linalg.norm(embeddings[i-1]) * np.linalg.norm(embeddings[i])
)
if sim < threshold:
chunks.append("".join(current_chunk[:-1]))
current_chunk = [sent]
if current_chunk:
chunks.append("".join(current_chunk))
return chunks优点:切分边界与语义边界对齐,检索质量高。缺点:需要调用嵌入模型,计算成本高;阈值需针对不同语料调优。适用于对检索效果要求高的问答系统。
2.3 递归分块(Recursive Chunking)
LangChain 的 RecursiveCharacterTextSplitter 采用递归思想:按优先级列表(["\n\n", "\n", " ", ""])依次尝试分隔符,优先在段落或句子边界切分,若无合适分隔符则降级到字符级别。
from langchain_text_splitters import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=512,
chunk_overlap=64,
separators=["\n\n", "\n", "。", "!", "?", ",", " ", ""],
length_function=len,
)
text = "..." # 原始文本
chunks = splitter.split_text(text)该策略在实际工程中最为常用,它平衡了语义完整性与实现复杂度。通过合理设置 separators 的顺序(将段落分隔符 \n\n 置顶、句子标点次之、空格最后),能够在大多数情况下保持自然边界。
2.4 基于文档结构的分块
利用文档本身的结构标记进行分块,天然保留语义边界。
import re
def split_by_md_headers(md_content):
pattern = r"^(#{1,6}\s+.+)$"
lines = md_content.split("\n")
chunks, current_section, current_header = [], [], None
for line in lines:
if re.match(pattern, line.strip()):
if current_section:
chunks.append({
"header": current_header,
"content": "\n".join(current_section)
})
current_header = line.strip()
current_section = [line]
else:
current_section.append(line)
if current_section:
chunks.append({
"header": current_header,
"content": "\n".join(current_section)
})
return chunks类似地,LaTeX 文档可按 \section{}、\subsection{} 分割;JSON 文档可按顶层 key 分割。这种策略最适合具有明确层级结构的文档。
三、分块参数选择指南
3.1 Chunk Size 选择
| Chunk Size | 适用场景 | 特点 |
|---|---|---|
| 128-256 tokens | 问答对、短语级检索 | 精准但缺乏上下文,适合实体查询 |
| 512 tokens | 一般问答、段落级检索 | 平衡上下文与精准度,最常用 |
| 1024+ tokens | 文档摘要、长文本分析 | 上下文完整,但噪音增加,检索精度下降 |
选择核心原则:Chunk Size 应与下游 LLM 的上下文窗口和目标任务的检索粒度匹配。例如,面向详细技术文档的问答系统,512 tokens 通常是较好的起点。
3.2 Overlap 的作用与最佳实践
Overlap(重叠)让相邻 Chunk 共享部分内容,避免关键信息恰好落在切分边界上而被丢失。
- 作用:补偿硬切分带来的语境断裂,确保边界处的关键信息至少出现在一个完整的 Chunk 中。
- 推荐值:Chunk Size 的 10%-20%。例如 Chunk Size 为 512 时,Overlap 设为 64-100 tokens。
- 注意事项:Overlap 过大会产生大量冗余内容,增加向量存储成本并降低检索区分度。
四、Metadata 标注
每个 Chunk 应附带 Metadata,记录其来源信息,以便检索后进行溯源和引用。常见 Metadata 字段:
| 字段 | 说明 | 示例 |
|---|---|---|
| source | 来源文档路径或 URL | /data/docs/report-2025.pdf |
| page | 页码(PDF 场景) | 3 |
| section | 所属章节标题 | "第三章 实验方法" |
| chunk_index | 该文档内的 Chunk 序号 | 5 |
| timestamp | 文档创建或更新时间 | 2025-06-15 |
| embedding_model | 生成嵌入使用的模型 | bge-small-zh-v1.5 |
metadata = {
"source": "2025-annual-report.pdf",
"page": 12,
"section": "财务概览",
"chunk_index": 3,
"timestamp": "2025-06-15",
"embedding_model": "bge-small-zh-v1.5"
}在 LangChain 中,可将 Metadata 伴随 Chunk 一同存入向量数据库:
from langchain_core.documents import Document
documents = [
Document(page_content=chunk, metadata=metadata)
for chunk, metadata in zip(chunks, metadata_list)
]五、综合实践:完整流水线
将上述内容串联,构建一个从 PDF 解析到 Chunk 输出的完整流水线:
import fitz
from langchain_text_splitters import RecursiveCharacterTextSplitter
def pdf_to_chunks(pdf_path, chunk_size=512, chunk_overlap=64):
# Step 1: 解析 PDF
doc = fitz.open(pdf_path)
full_text = ""
page_map = {}
for page_num, page in enumerate(doc, 1):
text = page.get_text()
full_text += text + "\n"
page_map[len(full_text)] = page_num
doc.close()
# Step 2: 递归分块
splitter = RecursiveCharacterTextSplitter(
chunk_size=chunk_size,
chunk_overlap=chunk_overlap,
separators=["\n\n", "\n", "。", ",", " ", ""],
)
chunks = splitter.split_text(full_text)
# Step 3: 构建带 Metadata 的 Document
result = []
for i, chunk in enumerate(chunks):
# 粗略估算页码
pos = full_text.find(chunk[:50])
page = page_map.get(pos, 1)
result.append(Document(
page_content=chunk,
metadata={
"source": pdf_path,
"page": page,
"chunk_index": i,
}
))
return result六、总结
文档解析与分块是 RAG 系统的第一道关卡,决定了后续检索效果的上限。在实践中,应当根据文档类型选择合适的解析工具(PDF 用 PyMuPDF、网页用 trafilatura、结构化文档用结构分块),再结合任务需求选择分块策略(工程上推荐 LangChain RecursiveCharacterTextSplitter 作为起点)。Metadata 标注同样不可忽视,它是溯源和引用能力的基础。建议在项目初期建立标准化的解析与分块管线,并持续根据检索效果迭代调优参数。