一、预处理为什么决定 RAG 的成败
RAG 的回答质量,80% 不取决于模型,而取决于喂给模型的上下文干不干净。检索阶段确实能命中相关文档,但如果进 LLM 的 Chunk 是"目录 + 正文 + 页眉页脚 + 水印"的混合体,模型再强也会答非所问。
本文聚焦 RAG 流程里最容易被低估、却最影响效果的两个环节:
- 文档解析:把 PDF / Word / 扫描件变成干净文本;
- 文本切块:把长文本切成检索友好的 Chunk。
下面从三大解析方案选型讲起,一路落到可直接复用的 Spring Boot 代码与参数经验值。
二、PDF解析三大方案:怎么选
中文RAG场景里,PDF是最棘手的数据源------双栏排版、表格、图表、扫描件、水印......每一项都是坑。
下表是我在实际项目中摸出来的对比:
|------------------------|------------------------|------------------|-------------------------|
| 方案 | 优势 | 劣势 | 适用场景 |
| PDFBox | Apache顶级项目、纯Java、API直观 | 表格识别弱、扫描件需OCR | 标准PDF(双栏/简单表格) |
| Apache Tika | 支持1500+格式、自动识别MIME | 内存占用大、底层仍是PDFBox | 多格式混合(PDF/Word/Excel混存) |
| 商业OCR(百度/腾讯/Azure) | 扫描件识别率高、表格还原好 | 贵、需联网、有数据合规风险 | 含大量扫描件或复杂表格 |
实战选型建议:
- 纯文本型PDF → PDFBox(轻量、免费、可控)
- 多格式仓库(CMS/知识库混合存储)→ Tika统一接口
- 扫描件/影像PDF → 走OCR(不要在Tika里折腾)
2.1 PDFBox实战:从一份合同PDF到结构化文本
XML
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
<version>3.0.3</version>
</dependency>
java
package com.example.rag.preprocess;
import lombok.extern.slf4j.Slf4j;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.text.PDFTextStripper;
import org.apache.pdfbox.text.PDFTextStripperByArea;
import org.springframework.stereotype.Service;
import java.awt.Rectangle;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
/**
* PDF解析服务:解决80%的标准PDF场景
* 版本:PDFBox 3.0.3(注意3.x废弃了PDFTextStripper直接构造,必须用Loader.loadPDF())
*/
@Slf4j
@Service
public class PdfParseService {
/**
* 全文档文本提取(按页返回,保留页码便于回溯引用)
*/
public List<PageContent> extractByPage(File pdfFile) throws Exception {
List<PageContent> result = new ArrayList<>();
try (PDDocument doc = Loader.loadPDF(pdfFile)) {
int totalPages = doc.getNumberOfPages();
log.info("PDF总页数:{}", totalPages);
for (int i = 0; i < totalPages; i++) {
// 关键:startPage/endPage是闭区间
PDFTextStripper stripper = new PDFTextStripper();
stripper.setStartPage(i + 1);
stripper.setEndPage(i + 1);
// 排序参数:PDFBox默认按阅读顺序,复杂双栏建议设为true
stripper.setSortByPosition(true);
String text = stripper.getText(doc);
result.add(new PageContent(i + 1, cleanText(text)));
}
}
return result;
}
/**
* 区域提取:只取页面中部正文,排除页眉页脚水印
* 适用场景:合同、论文等结构化文档
*/
public String extractBodyArea(File pdfFile, int pageNum,
float topMargin, float bottomMargin) throws Exception {
try (PDDocument doc = Loader.loadPDF(pdfFile)) {
PDPage page = doc.getPage(pageNum - 1);
float height = page.getMediaBox().getHeight();
// 关键:定义矩形区域(PDF坐标系原点左下角,Y向上)
Rectangle body = new Rectangle(
50, // left
bottomMargin, // bottom Y
page.getMediaBox().getWidth() - 100, // width
height - topMargin - bottomMargin // height
);
PDFTextStripperByArea stripper = new PDFTextStripperByArea();
stripper.setSortByPosition(true);
stripper.addRegion("body", body);
stripper.extractRegions(page);
return cleanText(stripper.getTextForRegion("body"));
}
}
/**
* 文本清洗:连续空格、换页符、零宽字符
* 这些"幽灵字符"是向量检索命中不准的元凶之一
*/
private String cleanText(String raw) {
if (raw == null) return "";
return raw
.replaceAll("\\u0000", "") // 去除NUL
.replaceAll("[\\s\\u00A0\\u3000]+", " ") // 合并空格(含全角空格)
.replaceAll("(\\r\\n|\\r|\\n)\\s*", "\n") // 多换行合一
.replaceAll("(?m)^\\s*\\d+\\s*$", "") // 移除纯页码行
.trim();
}
public record PageContent(int pageNum, String text) {}
}
生产踩坑提醒:
- PDFBox 3.0+ 移除了
PDFTextStripper()的无参构造,直接new会编译失败,必须用Loader.loadPDF()加载
- 中文PDF默认字体乱码?通常是PDF字体未嵌入,需用
PDFBox 3.0+fontbox配合,或者强制设置stripper.setAddMoreFormatting(true)
- 扫描件(纯图片)
getText会返回空字符串------必须走OCR,别浪费时间
三、Apache Tika:多格式文档的统一入口
如果你面对的是企业知识库(PDF/Word/Excel/PPT/HTML混着来),每个格式写一套解析器是噩梦。Apache Tika用统一的API把这件事办成。
XML
<dependency>
<groupId>org.apache.tika</groupId>
<artifactId>tika-core</artifactId>
<version>2.9.2</version>
</dependency>
<dependency>
<groupId>org.apache.tika</groupId>
<artifactId>tika-parsers-standard-package</artifactId>
<version>2.9.2</version>
</dependency>
java
package com.example.rag.preprocess;
import lombok.extern.slf4j.Slf4j;
import org.apache.tika.Tika;
import org.apache.tika.exception.TikaException;
import org.apache.tika.metadata.Metadata;
import org.apache.tika.metadata.TikaCoreProperties;
import org.apache.tika.parser.AutoDetectParser;
import org.apache.tika.parser.ParseContext;
import org.apache.tika.parser.Parser;
import org.apache.tika.sax.BodyContentHandler;
import org.springframework.stereotype.Service;
import org.xml.sax.SAXException;
import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;
import java.util.HashMap;
import java.util.Map;
/\*\*
\* Tika统一解析器:屏蔽1500+格式差异
\* 关键:底层仍然调用PDFBox/Poi等,但对外暴露统一接口
*/
@Slf4j
@Service
public class TikaUnifiedParser {
/\*\*
\* 解析任意文档为纯文本+元数据
\* @param file 支持 pdf/docx/xlsx/pptx/html/epub/rtf...
*/
public ParsedDocument parse(File file) throws Exception {
// 关键1:BodyContentHandler带-1参数禁用默认字符上限(默认10000会被截断)
BodyContentHandler handler = new BodyContentHandler(-1);
Metadata metadata = new Metadata();
ParseContext context = new ParseContext();
try (InputStream stream = new FileInputStream(file)) {
// 关键2:AutoDetectParser自动按MIME选择解析器
// 永远不要自己new PDFParser(),Tika的自动识别能覆盖95%场景
new AutoDetectParser().parse(stream, handler, metadata, context);
}
// 元数据:标题、作者、创建时间、页数(很关键,用于chunk定位)
Map<String, String> meta = new HashMap<>();
for (String name : metadata.names()) {
meta.put(name, metadata.get(name));
}
log.info("解析完成 [{}] MIME={} 页数={}",
file.getName(),
meta.get("Content-Type"),
meta.get("xmpTPg:NPages"));
return new ParsedDocument(
handler.toString(),
meta.getOrDefault(TikaCoreProperties.TITLE.getName(), file.getName()),
meta
);
}
/\*\*
\* MIME类型嗅探:上传文件后不知道是啥格式?
\* 用Tika.detect()比JDK的Files.probeContentType()准10倍
*/
public String detectMimeType(File file) {
try (InputStream is = new FileInputStream(file)) {
return new Tika().detect(is);
} catch (Exception e) {
log.warn("MIME探测失败", e);
return "application/octet-stream";
}
}
public record ParsedDocument(String content, String title, Map<String, String> metadata) {}
}
注意,Tika有个老坑 :默认 BodyContentHandler(10000) 会把超过1万字符的输出直接丢弃并抛WriteLimitReachedException 。生产环境一定要传 -1 禁用上限,否则长文档会被截断。
四、文本切块:三种策略对比与选型
文本切块(Chunking)是RAG的第二道关。我见过很多团队Embedding模型选得贵、切块策略却拍脑袋------典型的把钱花在刀背上。
4.1 三大切块策略
|--------------|------------------------------------|----------------|-----------------------|
| 策略 | 原理 | 优点 | 缺点 |
| 固定长度 | 按字符数硬切 | 简单、快 | 把句子从中间切断,语义断裂 |
| 句子切块 | 按句号/问号切分 | 保留句子完整性 | 长章节一块Chunk太大,撞Token上限 |
| 递归切块(推荐) | 按 \n\n → \n → 。 → 空格 → 字符 优先级递归 | 自适应文档结构,最长用的方案 | 需要调参 |
| 语义切块 | Embedding相似度断句 | 主题切换更精准 | 慢,对小文档过度设计 |
90%的场景用递归切块就够了。语义切块听起来高大上,但Embedding一次要100ms+,一个100页的文档切完要几十秒------生产环境跑不动。
4.2 Spring AI切块器实战
Spring AI 1.x 内置了两种切块器,覆盖大部分场景:
XML
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-core</artifactId>
<version>1.0.0</version>
</dependency>
java
package com.example.rag.preprocess;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.transformer.splitter.TextSplitter;
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
import org.springframework.ai.transformer.splitter.RecursiveCharacterTextSplitter;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 三种切块策略完整实战
* chunkSize/chunkOverlap 是最关键的两个参数,下面会给出调参经验
*/
@Slf4j
@Service
public class TextChunkingService {
/**
* 方案1:固定Token切块
* 适用:英文/代码场景,Token精准控制
* 中文书:中文1字≈1.5-2个Token,chunkSize=500对应中文250字
*/
public List<String> tokenSplit(String text) {
// 默认:chunkSize=800 tokens, overlap=400(实际偏激进)
TokenTextSplitter splitter = new TokenTextSplitter(
500, // chunkSize:每块500个Token
200, // minChunkSize:小于这个长度的碎片会被合并
10, // minLengthPerChunk
4000, // maxChunkSize:超长段落最大限制
true // keepSeparator:是否在块间保留分隔符
);
return splitter.split(text);
}
/**
* 方案2:递归字符切块(生产首选)
* 关键:按优先级分隔符递归切,超长才退到下一级
* 适合中英文混排、带段落结构的文档
*/
public List<String> recursiveSplit(String text) {
RecursiveCharacterTextSplitter splitter = new RecursiveCharacterTextSplitter(
800, // chunkSize:字符数,对应英文约200词,中文约400字
100, // chunkOverlap:重叠字符数(不是百分比!)
List.of(
"\n\n", // 优先级1:段落分隔
"\n", // 优先级2:换行
"。", // 优先级3:中文句号
"!", // 优先级4:中文感叹号
"?", // 优先级5:中文问号
";", // 优先级6:中文分号
". ", // 优先级7:英文句号+空格
"! ",
"? ",
"; ",
",", // 中文逗号
", ",
" " // 兜底:空格
)
);
return splitter.split(text);
}
/**
* 方案3:自定义"标题感知"切块(推荐用于论文/合同等结构化文档)
* 核心思想:把"标题 + 内容"作为一个Chunk,确保检索命中时上下文完整
*/
public List<DocumentChunk> titleAwareSplit(List<Section> sections) {
return sections.stream()
.map(section -> {
// 标题 + 段落文本合并
String fullText = section.title() + "\n\n" + section.content();
// 单个section超长才递归切
if (fullText.length() <= 1600) {
return List.of(new DocumentChunk(
section.title(),
fullText,
Map.of("section", section.title(),
"level", String.valueOf(section.level()))
));
}
// 超长用递归切
RecursiveCharacterTextSplitter splitter = new RecursiveCharacterTextSplitter(
800, 100, List.of("\n\n", "\n", "。", ";"));
return splitter.split(fullText).stream()
.map(t -> new DocumentChunk(
section.title(),
section.title() + "\n" + t, // 每块带上标题作为前缀
Map.of("section", section.title())))
.toList();
})
.flatMap(List::stream)
.toList();
}
public record Section(String title, String content, int level) {}
public record DocumentChunk(String title, String text, Map<String, String> metadata) {}
}
五、Chunk重叠策略:被忽视的检索质量开关
很多人切完块直接灌库,从不考虑 overlap。这是典型的"跑起来能查"≠"查得准"。
重叠的本质:让一个语义被切断时,下一块的开头能接住它的尾巴。
|--------------------------|------------------------------|----------------------------------|
| 参数 | 推荐值 | 理由 |
| chunkSize | 500~800 Token | Embedding模型的sweet spot,再大切块语义被稀释 |
| chunkOverlap | chunkSize的 10%~20% | 太少→跨块语义丢失;太多→检索重复+成本飙升 |
| 不返回小于minChunkSize的碎片 | minChunkSize = 50~100 Token | 短Chunk基本是噪声,浪费向量库 |
实测经验:
java
chunkSize=500, overlap=100 → 法律/合同场景召回率91%
chunkSize=800, overlap=100 → 技术文档/论文召回率88%(且成本省30%)
chunkSize=1000, overlap=200 → 召回率反而下降到79%(块太大噪声多)
记住:越大不一定越好。中文RAG的甜点区是 chunkSize=500~800 / overlap=80~150 之间。
六、生产级文档预处理Pipeline
把上面说的串起来。一个生产可用的预处理服务,至少包含:MIME探测 → 解析 → 清洗 → 切块 → 元数据绑定。
java
package com.example.rag.preprocess;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.document.Document;
import org.springframework.stereotype.Service;
import java.io.File;
import java.util.List;
/**
* 生产级文档预处理编排器
* 输入:任意格式文件 → 输出:切好的Document列表(含元数据,便于后续追溯)
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class DocumentPreprocessPipeline {
private final TikaUnifiedParser tikaParser;
private final TextChunkingService chunker;
/**
* 一站式处理:解析 → 清洗 → 切块 → 元数据绑定
*/
public List<Document> process(File file) throws Exception {
long start = System.currentTimeMillis();
// 1. MIME探测(防御性编程:用户上传的可能扩展名是假的)
String mime = tikaParser.detectMimeType(file);
log.info("[预处理] 文件={} MIME={}", file.getName(), mime);
// 2. 解析
TikaUnifiedParser.ParsedDocument parsed = tikaParser.parse(file);
// 3. 切块(递归策略,含overlap)
List<String> chunks = chunker.recursiveSplit(parsed.content());
// 4. 构造Document(绑定元数据,未来可挂在向量上做精确过滤)
List<Document> documents = chunks.stream()
.map(text -> {
Document doc = new Document(text);
// 关键:这些metadata会跟着vector一起入库
// 检索时可以用FilterExpression做硬过滤,比如source="contract.pdf"
doc.getMetadata().put("source", file.getName());
doc.getMetadata().put("title", parsed.title());
doc.getMetadata().put("mime", mime);
doc.getMetadata().put("chunk_index", String.valueOf(documents_index++));
return doc;
})
.toList();
log.info("[预处理完成] 文件={} chunks={} 耗时={}ms",
file.getName(), chunks.size(), System.currentTimeMillis() - start);
return documents;
}
private static java.util.concurrent.atomic.AtomicInteger documents_index =
new java.util.concurrent.atomic.AtomicInteger(0);
}
七、实战建议
- 先花50%时间在文档解析上。Embedding再贵的模型,原文是乱码或者没段落,出来都是垃圾。一次把PDF解析做好,能省后面3个月的优化。
- 中文场景 chunkSize 不要超过 1000 Token。中文字符密度高、Embedding模型对中文长上下文衰减更明显,800左右性价比最高。
- 永远在生产加 解析失败告警。PDFBox对加密PDF、损坏PDF会抛IOException,必须 try-catch 单独处理,不能让一个坏文档拖垮整个入库任务。
八、最后
文档预处理是RAG工程的"地基工程"。地基歪了,上面不管用GPT-4还是Claude都救不回来。
模型决定RAG的上限,但文档预处理决定RAG的下限。
下篇预告 :Day 73 我们讲 pgvector------怎么把 PostgreSQL 直接当向量数据库用,少引入一个组件就少一份运维负担。重点:HNSW索引调优、混合检索(向量 + 全文)、以及和专用向量数据库的实测对比。
往期回顾: