跳转至

文档解析与分块

一句话:文档解析与分块是 RAG 的"数据入口",决定了"喂给向量库的内容质量"——分块过大召回噪声高,过小语义割裂,百万级文档还要靠并行+增量解决吞吐。

概念

RAG 的效果遵循一个朴素定律:Garbage In, Garbage Out。检索再精妙,喂进去的文档是乱的,召回也救不回来。 ingestion(数据摄入)阶段的两件事直接决定召回上限:

  1. 文档解析:把 PDF / Word / PPT / Excel / HTML / Markdown 等异构文件,转成结构化的纯文本(保留标题层级、表格、列表)。
  2. 分块(Chunking):把长文档切成大小适中的片段(chunk),每块作为一个独立的检索单元和 Embedding 单位。

为什么要分块?因为大模型的上下文窗口有限,更关键的是 检索粒度——用户问一个具体问题,你只需要召回"最相关的那两三段",而不是把整本手册塞进 Prompt。分块就是"把大文档切成可被精准命中的小单元"。

原理

文档解析:按类型对症下药

文档类型 解析难点 主流方案
PDF 多栏排版、表格、扫描件(图片)、公式 PyMuPDF / pdfplumber 提取文本与表格;扫描件走 OCR(PaddleOCR / Tesseract);复杂版面用 Layout 解析(unstructured、Marker)
Word / DOCX 标题层级、批注、图片 python-docx / Apache POI(Java),按 Heading 样式还原结构
Excel / 表格 一行就是一条知识,不能整表一块 转成 Markdown 表或逐行序列化为"表头 + 该行"的自然语言,或直接按行/按 Sheet 分块
HTML 噪声多(导航/广告/footer) BeautifulSoup 去标签,按 DOM 标题切分
Markdown 天然带层级 直接按 #/## 标题树切分,最省事

表格是高频踩坑点:把一张表整块 Embedding,向量往往糊成一片,召回极差。更好的做法是"行级语义化"——把 [姓名, 张三, 年龄, 28] 转成"姓名是张三,年龄是 28"这样的句子,每行(或每几行)一块。

分块策略:四种主流切法

策略 做法 优点 缺点
固定长度(fixed-size) 按字符数/token 数硬切(如 500 token 一块) 实现最简单、可控 易从句子中间切断,语义割裂
语义分块(semantic) 按句子/段落边界切,或用 Embedding 相似度判断"话题转折点" 语义完整 实现复杂、块大小不均
递归分块(recursive) [\n\n, \n, 。, 空格] 分隔符层级递归切,先按大单位,超长再按小单位 平衡语义与长度,最常用 需调分隔符顺序
结构化分块 按 Markdown 标题 / 文档章节 / 知识点切 语义边界最清晰 依赖文档结构质量

实际工程里递归分块(Recursive Character Splitter)是默认首选,因为它既尊重段落边界(\n\n 优先),又在超长段落上兜底切分,鲁棒性最好。

Overlap:相邻块的重叠区

切块时让相邻块重叠一段(如 50 token),目的是防止关键信息被切在两块的边界处、导致任一单块都不完整。Overlap 的取舍:

  • 太小:边界信息丢失,召回的块语义残缺。
  • 太大:存储与检索冗余上升,相似块过多反而稀释排序。

经验值:chunk size 256~512 token 时,Overlap 取 chunk 的 10%~20%(如 512 token 块配 50~100 token Overlap)。

三者关系:chunk_size × Overlap × 检索 top_k

召回质量是这三者联动的结果:

  • chunk_size 大:单块信息全,但噪声也多(命中块里夹了大量无关内容),且总块数少、检索粒度粗。
  • chunk_size 小:检索粒度细、命中准,但单块语义可能不完整,需要更大 top_k + Rerank 拼回完整答案。
  • 因此现代 RAG 普遍采用"小块检索 + 大块返回"策略:用小 chunk 做向量匹配(精准定位),命中的块再向上下文扩展(如取父段落 / 相邻块)拼成上下文喂给 LLM。
// LangChain4j 递归分块示例:DocumentSplitter 自带 recursive 分块器
import dev.langchain4j.data.document.DocumentSplitter;
import dev.langchain4j.data.document.splitter.DocumentSplitters;

// chunk 512 token,overlap 64 token,中文友好按句号/换行递归
DocumentSplitter splitter = DocumentSplitters.recursive(512, 64);

// 把解析后的文档切成 TextSegment 列表,每段带 metadata(来源文件/页码/标题)
List<TextSegment> segments = splitter.split(document);

// 表格行级语义化:把一行 Excel 数据转成自然语言再 Embedding
String rowToText(Map<String, String> row) {
    // {"姓名":"张三","年龄":"28"} -> "姓名是张三,年龄是 28。"
    return row.entrySet().stream()
        .map(e -> e.getKey() + "是" + e.getValue())
        .collect(Collectors.joining(",")) + "。";
}
# LangChain 递归分块示例:RecursiveCharacterTextSplitter 是默认主力
from langchain_text_splitters import RecursiveCharacterTextSplitter

# 分隔符按优先级递归:先段落、再换行、再句号,最后兜底字符
splitter = RecursiveCharacterTextSplitter(
    chunk_size=512,
    chunk_overlap=64,
    separators=["\n\n", "\n", "。", ",", " ", ""],
)

chunks = splitter.split_text(document_text)

# LlamaIndex 同样提供 SentenceSplitter / SemanticSplitter,按节点(Node)组织
# 表格行级语义化示例
def row_to_text(row: dict) -> str:
    return ",".join(f"{k}{v}" for k, v in row.items()) + "。"

实战要点

  1. 分块大小不是拍脑袋,要按评测集调:固定一批问答对,扫一遍 chunk_size ∈ {256, 384, 512, 768} × overlap ∈ {0, 64, 128},看 Hit Rate@5 拐点。一般 384~512 是甜点区,太大会把不相关段落一起命中拉低精度。

  2. 表格务必行级语义化,别整表一块:把 Excel 每行转成自然语言("字段A是X,字段B是Y")再分块,召回比整表 Embedding 高一个数量级——这是 B 端 RAG 最常见的召回翻车点。

  3. 保留结构化 metadata:每个 chunk 带上来源文件、页码、标题层级,检索后能做过滤("只在合同附件里搜")和引用回溯(答案标注出处),对幻觉治理和可信度至关重要。

  4. 百万级文档靠并行 + 增量 + 去重

  5. 用 Spark / 多机并行跑解析与 Embedding,单机串行几百万文档是天文数字级延迟。
  6. 维护文档哈希(如 MD5),重新入库时只对变更文档增量 Embedding,避免全量重算。
  7. 同一文档更新时按 doc_id 删旧块、插新块,保证向量库一致。

  8. 扫描件 PDF 走 OCR 管线:纯文本提取对扫描件返回空,必须先 OCR(PaddleOCR 中文效果好)再分块;否则这部分知识在向量库里根本不存在,是隐性召回盲区。

本节相关题目

难度 题目 链接
基础 常见文档分块策略有哪些、各自适用什么 → 题库
进阶 分块大小与 Overlap 如何影响召回与噪声 → 题库
深度 设计百万级文档企业级 RAG 的完整链路与优化点 → 题库