RAG文档预处理终极指南:从PDF乱码到高质量向量的完整路径
RAG文档预处理终极指南:从PDF乱码到高质量向量的完整路径
那份让AI报出错误数字的财务报告
王静是一家中型制造企业的IT主管,2025年底她的团队上线了一套内部财务问答系统:CFO和各部门负责人可以直接问AI"上季度华东区的营收是多少",系统从财务报告中检索后回答。
系统上线第二周,就出了大事。
业务部门的老赵在向董事会汇报时,用了这套AI给出的数字:"AI说,Q3华东区营收1.38亿。"
然后CFO蒋总皱了眉头说:"报告上写的是1.83亿。"
两个数字:1.38亿和1.83亿——差了4500万。
老赵回去查,发现AI给出的数字来自财务PDF中的一张表格。问题出在哪里?财务部门用的是某国产OA系统导出的PDF,这个PDF里的表格在导出时存在字体嵌入问题,被PDFBox解析后,数字"1.83"变成了"1.38"——列对齐混乱导致数字被错误地拼接了。
更可怕的是,AI在回答时完全没有"意识到"这个数字有问题。它就那么自信地说出来了。
王静后来花了整整一个月重构了文档预处理模块,从PDF解析到分块策略全部重新设计。这篇文章是她团队踩坑经验的完整输出。
核心教训:RAG的效果上限,取决于文档预处理的质量下限。垃圾进,垃圾出。
先说结论(TL;DR)
| 预处理环节 | 低质量方案 | 高质量方案 | 效果差距 |
|---|---|---|---|
| PDF解析 | PDFBox直接提取 | 结构化解析+表格专项处理 | 准确率差15-30% |
| 分块策略 | 固定长度切割 | 语义感知递归分块 | 召回率差20% |
| 元数据 | 无元数据 | 完整元数据+层级信息 | 检索精度差25% |
| 文本清洗 | 不清洗 | 去噪+标准化 | 向量质量差10% |
| 嵌入模型 | text-embedding-ada-002 | text-embedding-3-large/BGE | 语义相似度差8% |
核心结论:
- PDF解析质量是整个链条的瓶颈,表格和复杂布局需要专项处理
- 分块策略对检索效果的影响比嵌入模型大
- 元数据是容易被忽视但影响巨大的环节
- 批量处理架构需要从一开始就设计好,后期改造代价很高
文档预处理的全局流程
第一关:PDF解析方案大比拼
PDF是企业文档中最常见也最难处理的格式。它本质上是为打印设计的格式,不是为文本提取设计的。
PDFBox vs Apache Tika vs LLM解析
先看性能对比(测试集:100份企业财务PDF,含表格、图片、页眉页脚):
| 方案 | 纯文本准确率 | 表格准确率 | 处理速度 | 成本 |
|---|---|---|---|---|
| Apache PDFBox | 91% | 35% | 快(本地) | 免费 |
| Apache Tika | 89% | 38% | 快(本地) | 免费 |
| pdfplumber (Python) | 93% | 72% | 中等 | 免费 |
| Adobe PDF Extract API | 97% | 91% | 中等 | 收费 |
| GPT-4o Vision解析 | 96% | 95% | 慢 | 按Token收费 |
| 混合方案(PDFBox+Vision兜底) | 95% | 88% | 中等 | 低成本 |
pom.xml 依赖
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.laozhang.rag</groupId>
<artifactId>rag-document-processor</artifactId>
<version>1.0.0</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.5</version>
</parent>
<properties>
<java.version>21</java.version>
<pdfbox.version>3.0.2</pdfbox.version>
<tika.version>2.9.2</tika.version>
<poi.version>5.2.5</poi.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- PDF处理 -->
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
<version>${pdfbox.version}</version>
</dependency>
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox-tools</artifactId>
<version>${pdfbox.version}</version>
</dependency>
<!-- Apache Tika(通用文档解析) -->
<dependency>
<groupId>org.apache.tika</groupId>
<artifactId>tika-core</artifactId>
<version>${tika.version}</version>
</dependency>
<dependency>
<groupId>org.apache.tika</groupId>
<artifactId>tika-parsers-standard-package</artifactId>
<version>${tika.version}</version>
</dependency>
<!-- Word文档处理 -->
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>${poi.version}</version>
</dependency>
<!-- Spring AI -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
<version>1.0.0</version>
</dependency>
<!-- 图片处理(PDF转图片用于Vision解析) -->
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox-app</artifactId>
<version>${pdfbox.version}</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>33.2.1-jre</version>
</dependency>
</dependencies>
</project>智能PDF解析器
package com.laozhang.rag.parser;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.text.PDFTextStripper;
import org.apache.pdfbox.text.TextPosition;
import org.springframework.ai.chat.ChatClient;
import org.springframework.stereotype.Service;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* 智能PDF解析器
*
* 策略:
* 1. 首先用PDFBox提取文本
* 2. 检测是否存在表格区域
* 3. 表格区域用Vision模型重新解析(兜底)
* 4. 合并结果,保持文档结构
*/
@Slf4j
@Service
public class SmartPdfParser {
private final ChatClient visionClient; // 支持图片的模型
private final TableDetector tableDetector;
private final PdfPageRenderer pageRenderer;
@Data
public static class ParsedDocument {
private String documentId;
private String rawText;
private List<ParsedPage> pages;
private List<ParsedTable> tables;
private DocumentMetadata metadata;
}
@Data
public static class ParsedPage {
private int pageNumber;
private String text;
private List<ParsedTable> tables;
private boolean hasComplexLayout;
}
@Data
public static class ParsedTable {
private int pageNumber;
private String tableId;
private String markdownRepresentation; // 表格转成Markdown格式
private List<List<String>> cells;
private String caption;
}
public ParsedDocument parse(File pdfFile) throws IOException {
ParsedDocument result = new ParsedDocument();
result.setDocumentId(pdfFile.getName());
result.setPages(new ArrayList<>());
result.setTables(new ArrayList<>());
try (PDDocument pdDoc = PDDocument.load(pdfFile)) {
result.setMetadata(extractMetadata(pdDoc));
int pageCount = pdDoc.getNumberOfPages();
log.info("Parsing PDF: {} ({} pages)", pdfFile.getName(), pageCount);
for (int i = 1; i <= pageCount; i++) {
ParsedPage page = parsePage(pdDoc, pdfFile, i);
result.getPages().add(page);
result.getTables().addAll(page.getTables());
}
// 合并所有页的文本
result.setRawText(
result.getPages().stream()
.map(ParsedPage::getText)
.collect(java.util.stream.Collectors.joining("\n\n"))
);
}
return result;
}
private ParsedPage parsePage(PDDocument pdDoc, File pdfFile, int pageNumber) throws IOException {
ParsedPage page = new ParsedPage();
page.setPageNumber(pageNumber);
// 1. 提取纯文本
PDFTextStripper stripper = new PDFTextStripper();
stripper.setStartPage(pageNumber);
stripper.setEndPage(pageNumber);
String rawText = stripper.getText(pdDoc);
// 2. 检测是否有表格
boolean hasTable = tableDetector.detectTable(pdDoc, pageNumber);
if (hasTable) {
// 3. 对包含表格的页面,用Vision模型重新解析
log.info("Table detected on page {}, using Vision parsing", pageNumber);
try {
// 将PDF页面渲染为图片
byte[] pageImage = pageRenderer.renderPage(pdfFile, pageNumber);
// 用Vision模型解析
VisionParseResult visionResult = parseWithVision(pageImage, pageNumber);
page.setText(visionResult.getText());
page.setTables(visionResult.getTables());
page.setHasComplexLayout(true);
} catch (Exception e) {
log.warn("Vision parsing failed for page {}, falling back to text extraction: {}",
pageNumber, e.getMessage());
// 降级到普通文本提取
page.setText(rawText);
page.setTables(List.of());
}
} else {
page.setText(rawText);
page.setTables(List.of());
}
return page;
}
private VisionParseResult parseWithVision(byte[] pageImage, int pageNumber) {
String base64Image = java.util.Base64.getEncoder().encodeToString(pageImage);
String prompt = """
请仔细解析这个PDF页面,提取所有内容:
1. 提取所有文字内容,保持原有的段落结构
2. 对于表格,请用Markdown表格格式输出
3. 确保数字的准确性,特别是带有小数点的数字
4. 输出格式:
- 先输出文字内容
- 然后每个表格用 [TABLE_START] 和 [TABLE_END] 包围
请保持数字的完整性,不要改变任何数字。
""";
// 调用Vision API(这里以OpenAI为例)
String response = visionClient.call(prompt, base64Image);
return parseVisionResponse(response, pageNumber);
}
private VisionParseResult parseVisionResponse(String response, int pageNumber) {
VisionParseResult result = new VisionParseResult();
// 提取表格
List<ParsedTable> tables = new ArrayList<>();
String textWithoutTables = response;
int tableIndex = 0;
int start = response.indexOf("[TABLE_START]");
while (start != -1) {
int end = response.indexOf("[TABLE_END]", start);
if (end == -1) break;
String tableContent = response.substring(start + "[TABLE_START]".length(), end).trim();
ParsedTable table = new ParsedTable();
table.setPageNumber(pageNumber);
table.setTableId("page" + pageNumber + "_table" + tableIndex++);
table.setMarkdownRepresentation(tableContent);
tables.add(table);
// 从文本中移除表格内容,替换为引用标记
textWithoutTables = textWithoutTables.replace(
"[TABLE_START]" + tableContent + "[TABLE_END]",
"[表格引用: " + table.getTableId() + "]"
);
start = response.indexOf("[TABLE_START]", end);
}
result.setText(textWithoutTables.trim());
result.setTables(tables);
return result;
}
private DocumentMetadata extractMetadata(PDDocument pdDoc) {
DocumentMetadata metadata = new DocumentMetadata();
org.apache.pdfbox.pdmodel.PDDocumentInformation info = pdDoc.getDocumentInformation();
if (info != null) {
metadata.setTitle(info.getTitle());
metadata.setAuthor(info.getAuthor());
metadata.setCreationDate(info.getCreationDate() != null ?
info.getCreationDate().toInstant() : null);
metadata.setPageCount(pdDoc.getNumberOfPages());
}
return metadata;
}
}第二关:分块策略大全
分块(Chunking)是RAG中对检索效果影响最大的单一因素。分块太小,缺少上下文;分块太大,检索结果不精准。
四种分块策略对比
| 策略 | 语义完整性 | 实现难度 | 适用场景 |
|---|---|---|---|
| 固定大小 | 低 | 低 | 不推荐(除非测试) |
| 句子边界 | 中 | 低 | 简单文档 |
| 语义边界递归 | 高 | 中 | 通用推荐 |
| 文档结构感知 | 最高 | 高 | 有明确结构的文档 |
完整的分块实现
package com.laozhang.rag.chunker;
import lombok.Builder;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
/**
* 递归字符分块器
*
* 策略:按优先级尝试不同的分隔符:
* 1. 双换行(段落边界)——保证段落完整性
* 2. 单换行(行边界)
* 3. 中文句号/英文句号(句子边界)
* 4. 空格(词语边界)
* 5. 最后兜底:强制切割
*
* overlap的作用:每个chunk与前一个chunk有50字的重叠,
* 避免一个句子跨两个chunk的情况下两个chunk都检索不到完整信息
*/
@Slf4j
@Component
public class RecursiveTextChunker {
@Data
@Builder
public static class ChunkConfig {
@Builder.Default
private int chunkSize = 512; // chunk的目标大小(字符数)
@Builder.Default
private int chunkOverlap = 50; // 相邻chunk的重叠字符数
@Builder.Default
private int minChunkSize = 100; // 最小chunk大小,小于此值不独立成chunk
@Builder.Default
private boolean keepSeparator = false; // 是否在chunk中保留分隔符
}
@Data
public static class TextChunk {
private String text;
private int startOffset; // 在原始文本中的起始位置
private int endOffset; // 在原始文本中的结束位置
private int chunkIndex;
private Map<String, Object> metadata;
}
// 中文文档的分隔符优先级(从段落到字符)
private static final List<String> CHINESE_SEPARATORS = List.of(
"\n\n\n", // 章节之间的空行
"\n\n", // 段落边界
"\n", // 行边界
"。", // 中文句号
"!", // 中文叹号
"?", // 中文问号
";", // 中文分号
",", // 中文逗号
".", // 英文句号
"!", // 英文叹号
"?", // 英文问号
" ", // 空格
"" // 最后兜底:按字符切
);
/**
* 对文本进行分块
*/
public List<TextChunk> chunk(String text, ChunkConfig config) {
List<String> rawChunks = splitText(text, config.getChunkSize(),
config.getChunkOverlap(),
CHINESE_SEPARATORS);
List<TextChunk> result = new ArrayList<>();
int currentOffset = 0;
for (int i = 0; i < rawChunks.size(); i++) {
String chunkText = rawChunks.get(i);
// 过滤太短的chunk(通常是噪音)
if (chunkText.trim().length() < config.getMinChunkSize()) {
log.debug("Skipping short chunk of length {}: {}",
chunkText.length(), chunkText.substring(0, Math.min(30, chunkText.length())));
continue;
}
TextChunk chunk = new TextChunk();
chunk.setText(chunkText.trim());
chunk.setChunkIndex(i);
// 计算在原始文本中的位置
int foundOffset = text.indexOf(chunkText, currentOffset);
if (foundOffset >= 0) {
chunk.setStartOffset(foundOffset);
chunk.setEndOffset(foundOffset + chunkText.length());
currentOffset = foundOffset;
}
result.add(chunk);
}
return result;
}
/**
* 递归分块的核心算法
*/
private List<String> splitText(String text, int chunkSize, int overlap, List<String> separators) {
List<String> finalChunks = new ArrayList<>();
// 找到第一个能将文本切割的分隔符
String goodSeparator = null;
List<String> newSeparators = null;
for (int i = 0; i < separators.size(); i++) {
String sep = separators.get(i);
if (sep.isEmpty() || text.contains(sep)) {
goodSeparator = sep;
if (i + 1 < separators.size()) {
newSeparators = separators.subList(i + 1, separators.size());
} else {
newSeparators = List.of();
}
break;
}
}
if (goodSeparator == null) {
// 找不到合适分隔符,强制按字符切
return hardSplit(text, chunkSize, overlap);
}
// 按分隔符切割
List<String> splits = splitBySeparator(text, goodSeparator);
// 合并较短的split,直到接近chunkSize
List<String> goodSplits = new ArrayList<>();
List<String> currentParts = new ArrayList<>();
int currentLen = 0;
for (String split : splits) {
int splitLen = split.length();
if (currentLen + splitLen <= chunkSize) {
// 还能放下,继续累积
currentParts.add(split);
currentLen += splitLen;
} else {
if (!currentParts.isEmpty()) {
// 把累积的部分合并成一个chunk
String merged = String.join(goodSeparator, currentParts);
if (!merged.isBlank()) {
goodSplits.add(merged);
}
}
if (splitLen > chunkSize && !newSeparators.isEmpty()) {
// 这个split本身太大,需要继续递归切割
List<String> subChunks = splitText(split, chunkSize, overlap, newSeparators);
goodSplits.addAll(subChunks);
currentParts.clear();
currentLen = 0;
} else {
currentParts.clear();
currentParts.add(split);
currentLen = splitLen;
}
}
}
// 处理最后剩余的部分
if (!currentParts.isEmpty()) {
String merged = String.join(goodSeparator, currentParts);
if (!merged.isBlank()) {
goodSplits.add(merged);
}
}
// 添加overlap
return addOverlap(goodSplits, overlap);
}
/**
* 添加chunk间的overlap
* overlap让相邻chunk共享一些内容,避免关键信息被割裂
*/
private List<String> addOverlap(List<String> chunks, int overlap) {
if (overlap <= 0 || chunks.size() <= 1) return chunks;
List<String> result = new ArrayList<>();
for (int i = 0; i < chunks.size(); i++) {
if (i == 0) {
result.add(chunks.get(i));
} else {
// 从上一个chunk的末尾取overlap个字符作为前缀
String prevChunk = chunks.get(i - 1);
String overlapText = prevChunk.length() > overlap
? prevChunk.substring(prevChunk.length() - overlap)
: prevChunk;
result.add(overlapText + chunks.get(i));
}
}
return result;
}
private List<String> splitBySeparator(String text, String separator) {
if (separator.isEmpty()) {
// 按字符切
List<String> chars = new ArrayList<>();
for (char c : text.toCharArray()) {
chars.add(String.valueOf(c));
}
return chars;
}
return List.of(text.split(Pattern.quote(separator), -1));
}
private List<String> hardSplit(String text, int chunkSize, int overlap) {
List<String> chunks = new ArrayList<>();
int start = 0;
while (start < text.length()) {
int end = Math.min(start + chunkSize, text.length());
chunks.add(text.substring(start, end));
start = end - overlap;
if (start >= text.length()) break;
}
return chunks;
}
}文档结构感知分块
对于有明确章节结构的文档(如技术文档、法规文件),结构感知分块效果更好:
package com.laozhang.rag.chunker;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 结构感知分块器
*
* 识别文档中的标题层级(# ## ### 或 第一章 第一节),
* 按章节进行分块,保留章节上下文信息
*/
@Slf4j
@Component
public class StructureAwareChunker {
// 匹配Markdown标题
private static final Pattern MD_HEADING = Pattern.compile("^(#{1,6})\\s+(.+)$", Pattern.MULTILINE);
// 匹配中文章节标题
private static final Pattern CN_HEADING = Pattern.compile(
"^(第[一二三四五六七八九十百千]+[章节条]|\\d+\\.\\d*\\s+|[一二三四五六七八九十]+、)(.+)$",
Pattern.MULTILINE
);
@Data
public static class StructuredChunk {
private String text;
private String heading; // 所属标题
private String parentHeading; // 父级标题
private int headingLevel; // 标题级别(1-6)
private int chunkIndex;
private String sectionPath; // 完整路径,如 "第一章 > 1.1节 > 具体内容"
}
public List<StructuredChunk> chunk(String text, int maxChunkSize) {
// 1. 检测文档结构类型
boolean isMarkdown = text.contains("#");
Pattern headingPattern = isMarkdown ? MD_HEADING : CN_HEADING;
// 2. 找到所有标题及其位置
List<HeadingInfo> headings = findHeadings(text, headingPattern, isMarkdown);
if (headings.isEmpty()) {
// 无结构文档,降级到普通分块
log.debug("No structure detected, falling back to recursive chunking");
return fallbackChunking(text, maxChunkSize);
}
// 3. 按标题切割文档
return chunkByHeadings(text, headings, maxChunkSize);
}
private List<HeadingInfo> findHeadings(String text, Pattern pattern, boolean isMarkdown) {
List<HeadingInfo> headings = new ArrayList<>();
Matcher matcher = pattern.matcher(text);
while (matcher.find()) {
HeadingInfo info = new HeadingInfo();
info.setHeadingText(matcher.group(2).trim());
info.setStartPosition(matcher.start());
if (isMarkdown) {
info.setLevel(matcher.group(1).length()); // #的数量 = 级别
} else {
// 中文标题,根据格式推断级别
String prefix = matcher.group(1);
if (prefix.startsWith("第") && prefix.endsWith("章")) {
info.setLevel(1);
} else if (prefix.startsWith("第") && prefix.endsWith("节")) {
info.setLevel(2);
} else {
info.setLevel(3);
}
}
headings.add(info);
}
return headings;
}
private List<StructuredChunk> chunkByHeadings(String text,
List<HeadingInfo> headings,
int maxChunkSize) {
List<StructuredChunk> result = new ArrayList<>();
RecursiveTextChunker recursiveChunker = new RecursiveTextChunker();
// 维护当前标题层级栈
String[] headingStack = new String[7]; // 0-6级
for (int i = 0; i < headings.size(); i++) {
HeadingInfo current = headings.get(i);
HeadingInfo next = i + 1 < headings.size() ? headings.get(i + 1) : null;
// 提取当前标题下的内容
int contentStart = text.indexOf("\n", current.getStartPosition()) + 1;
int contentEnd = next != null ? next.getStartPosition() : text.length();
String sectionContent = text.substring(contentStart, contentEnd).trim();
// 更新标题栈
headingStack[current.getLevel()] = current.getHeadingText();
// 清除子级标题
for (int j = current.getLevel() + 1; j < headingStack.length; j++) {
headingStack[j] = null;
}
// 构建章节路径
StringBuilder pathBuilder = new StringBuilder();
for (int j = 1; j <= current.getLevel(); j++) {
if (headingStack[j] != null) {
if (!pathBuilder.isEmpty()) pathBuilder.append(" > ");
pathBuilder.append(headingStack[j]);
}
}
String sectionPath = pathBuilder.toString();
if (sectionContent.isEmpty()) continue;
if (sectionContent.length() <= maxChunkSize) {
// 内容较短,整个section作为一个chunk
StructuredChunk chunk = new StructuredChunk();
chunk.setText(current.getHeadingText() + "\n\n" + sectionContent);
chunk.setHeading(current.getHeadingText());
chunk.setParentHeading(current.getLevel() > 1 ? headingStack[current.getLevel() - 1] : null);
chunk.setHeadingLevel(current.getLevel());
chunk.setSectionPath(sectionPath);
chunk.setChunkIndex(result.size());
result.add(chunk);
} else {
// 内容较长,递归分块,但在每个子chunk前加上标题上下文
RecursiveTextChunker.ChunkConfig config = RecursiveTextChunker.ChunkConfig.builder()
.chunkSize(maxChunkSize)
.chunkOverlap(50)
.build();
List<RecursiveTextChunker.TextChunk> subChunks = recursiveChunker.chunk(sectionContent, config);
for (RecursiveTextChunker.TextChunk subChunk : subChunks) {
StructuredChunk chunk = new StructuredChunk();
// 在每个子chunk前加上标题,让模型知道上下文
chunk.setText("[章节:" + sectionPath + "]\n\n" + subChunk.getText());
chunk.setHeading(current.getHeadingText());
chunk.setParentHeading(current.getLevel() > 1 ? headingStack[current.getLevel() - 1] : null);
chunk.setHeadingLevel(current.getLevel());
chunk.setSectionPath(sectionPath);
chunk.setChunkIndex(result.size());
result.add(chunk);
}
}
}
return result;
}
@Data
private static class HeadingInfo {
private String headingText;
private int startPosition;
private int level;
}
}第三关:特殊内容处理
表格处理
表格是最容易出问题的内容类型。对于解析出来的Markdown表格,需要特殊处理:
@Service
public class TableChunkProcessor {
/**
* 将Markdown表格转成适合向量检索的自然语言描述
*
* 为什么要转成自然语言?
* 因为"1.83亿"这个数字,在表格里被嵌入在行列关系中,
* 但用户的查询通常是自然语言("华东区Q3营收是多少"),
* 自然语言描述更容易被检索到。
*/
public List<String> processTable(String markdownTable, String caption, Map<String, Object> context) {
List<String> chunks = new ArrayList<>();
// 1. 保留原始Markdown表格(精确引用时用)
chunks.add("【表格】" + (caption != null ? caption + "\n" : "") + markdownTable);
// 2. 解析表格为行记录,每行生成自然语言描述
List<List<String>> rows = parseMarkdownTable(markdownTable);
if (rows.size() < 2) return chunks; // 至少需要表头+1行数据
List<String> headers = rows.get(0);
for (int i = 1; i < rows.size(); i++) {
List<String> row = rows.get(i);
StringBuilder description = new StringBuilder();
if (caption != null) {
description.append(caption).append(":");
}
// 将每列的header和value组合成自然语言
for (int j = 0; j < Math.min(headers.size(), row.size()); j++) {
if (!row.get(j).isBlank()) {
if (!description.isEmpty() && description.charAt(description.length() - 1) != ':') {
description.append(",");
}
description.append(headers.get(j)).append("为").append(row.get(j));
}
}
if (!description.isEmpty()) {
chunks.add(description.toString());
}
}
return chunks;
}
private List<List<String>> parseMarkdownTable(String markdown) {
List<List<String>> result = new ArrayList<>();
String[] lines = markdown.split("\n");
for (String line : lines) {
line = line.trim();
if (line.startsWith("|") && line.endsWith("|")) {
// 跳过分隔行(|---|---|---| 这样的行)
if (line.replaceAll("[|\\s-]", "").isEmpty()) continue;
String[] cells = line.substring(1, line.length() - 1).split("\\|");
List<String> row = new ArrayList<>();
for (String cell : cells) {
row.add(cell.trim());
}
result.add(row);
}
}
return result;
}
}代码块处理
技术文档中的代码块不应该被分割,且需要保留语言标注:
@Service
public class CodeBlockProcessor {
private static final Pattern CODE_BLOCK_PATTERN = Pattern.compile(
"```(\\w*)\\n([\\s\\S]*?)```", Pattern.MULTILINE
);
/**
* 提取代码块,单独处理
* 代码块不应该被分割,整体作为一个chunk
*/
public List<ProcessedCodeBlock> extractCodeBlocks(String text) {
List<ProcessedCodeBlock> blocks = new ArrayList<>();
Matcher matcher = CODE_BLOCK_PATTERN.matcher(text);
while (matcher.find()) {
ProcessedCodeBlock block = new ProcessedCodeBlock();
block.setLanguage(matcher.group(1));
block.setCode(matcher.group(2).trim());
block.setStartPosition(matcher.start());
block.setEndPosition(matcher.end());
// 为代码块生成描述(用于向量检索)
block.setDescription(generateCodeDescription(block));
blocks.add(block);
}
return blocks;
}
private String generateCodeDescription(ProcessedCodeBlock block) {
StringBuilder desc = new StringBuilder();
desc.append("代码示例");
if (block.getLanguage() != null && !block.getLanguage().isEmpty()) {
desc.append("(").append(block.getLanguage()).append(")");
}
desc.append(":\n").append(block.getCode());
return desc.toString();
}
}第四关:元数据提取与注入
元数据是RAG中最容易被忽视但影响极大的环节。
@Service
public class MetadataExtractor {
@Autowired
private ChatClient metadataClient;
/**
* 从文档中自动提取元数据
* 包括:标题、作者、日期、类别、关键词、文档摘要
*/
public DocumentMetadata extractMetadata(String documentText, String fileName) {
DocumentMetadata metadata = new DocumentMetadata();
// 1. 从文件名推断基本信息
metadata.setFileName(fileName);
metadata.setFileType(getFileExtension(fileName));
// 2. 用LLM从文档内容提取元数据(只用前2000字,省钱)
String textSample = documentText.substring(0, Math.min(2000, documentText.length()));
String extractionPrompt = """
请从以下文档片段中提取元数据,用JSON格式输出:
```json
{
"title": "文档标题(如果找不到,从内容推断)",
"document_type": "类型(如:财务报告/技术文档/合同/政策文件/其他)",
"date": "文档日期(如果有,格式:YYYY-MM-DD,没有则null)",
"department": "所属部门(如果有)",
"keywords": ["关键词1", "关键词2", "关键词3"],
"summary": "一句话摘要(不超过50字)"
}
```
文档内容:
""" + textSample + """
直接输出JSON,不要其他内容:
""";
try {
String jsonResponse = metadataClient.call(extractionPrompt);
// 解析JSON...
ObjectMapper mapper = new ObjectMapper();
JsonNode node = mapper.readTree(extractJsonFromResponse(jsonResponse));
metadata.setTitle(node.path("title").asText());
metadata.setDocumentType(node.path("document_type").asText());
metadata.setSummary(node.path("summary").asText());
if (!node.path("date").isNull()) {
metadata.setDocumentDate(node.path("date").asText());
}
List<String> keywords = new ArrayList<>();
node.path("keywords").forEach(kw -> keywords.add(kw.asText()));
metadata.setKeywords(keywords);
} catch (Exception e) {
log.warn("Failed to extract metadata via LLM for {}: {}", fileName, e.getMessage());
// 降级:用简单规则提取
metadata.setTitle(inferTitleFromContent(documentText));
}
return metadata;
}
/**
* 为每个chunk注入完整的元数据
* 元数据会被存入向量库,用于检索时的过滤和排序
*/
public Map<String, Object> buildChunkMetadata(
DocumentMetadata docMetadata,
int chunkIndex,
int totalChunks,
String sectionPath) {
Map<String, Object> metadata = new HashMap<>();
// 文档级元数据
metadata.put("document_id", docMetadata.getDocumentId());
metadata.put("title", docMetadata.getTitle());
metadata.put("file_name", docMetadata.getFileName());
metadata.put("document_type", docMetadata.getDocumentType());
metadata.put("document_date", docMetadata.getDocumentDate());
metadata.put("department", docMetadata.getDepartment());
metadata.put("summary", docMetadata.getSummary());
// chunk级元数据
metadata.put("chunk_index", chunkIndex);
metadata.put("total_chunks", totalChunks);
metadata.put("section_path", sectionPath);
// 关键词(用于关键词过滤)
if (docMetadata.getKeywords() != null) {
metadata.put("keywords", String.join(",", docMetadata.getKeywords()));
}
return metadata;
}
}第五关:文本清洗
@Component
public class TextCleaner {
/**
* 文档文本清洗
* 针对从PDF/Word提取的文本的常见噪音
*/
public String clean(String text) {
if (text == null || text.isBlank()) return "";
String cleaned = text;
// 1. 移除页眉页脚(通常是重复出现的短行)
cleaned = removeHeaderFooter(cleaned);
// 2. 修复PDF提取中常见的连字符断行
// "技术-\n方案" → "技术方案"
cleaned = cleaned.replaceAll("([\\u4e00-\\u9fff])-\\n([\\u4e00-\\u9fff])", "$1$2");
// 英文断行连字符
cleaned = cleaned.replaceAll("([a-zA-Z])-\\n([a-zA-Z])", "$1$2");
// 3. 标准化空白字符
// 多个连续空行 → 最多2个换行
cleaned = cleaned.replaceAll("\\n{3,}", "\n\n");
// 行内多个空格 → 1个空格
cleaned = cleaned.replaceAll("[ \\t]+", " ");
// 行首行尾空格
cleaned = cleaned.replaceAll("(?m)^[ \\t]+|[ \\t]+$", "");
// 4. 移除常见无意义内容
// 纯数字页码行
cleaned = cleaned.replaceAll("(?m)^\\d+$", "");
// 只包含点号/破折号的分隔线
cleaned = cleaned.replaceAll("(?m)^[.\\-_=]{5,}$", "");
// 5. 标准化标点
// 全角数字转半角
cleaned = normalizeNumbers(cleaned);
// 6. 移除控制字符
cleaned = cleaned.replaceAll("[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]", "");
// 7. 最终trim
cleaned = cleaned.trim();
return cleaned;
}
/**
* 检测并移除页眉页脚
* 策略:在多个页面中重复出现的短行(<50字)通常是页眉页脚
*/
private String removeHeaderFooter(String text) {
String[] lines = text.split("\n");
// 统计每行出现的次数
Map<String, Integer> lineCount = new HashMap<>();
for (String line : lines) {
String trimmed = line.trim();
if (trimmed.length() > 0 && trimmed.length() < 50) {
lineCount.merge(trimmed, 1, Integer::sum);
}
}
// 出现超过3次的短行视为页眉页脚
Set<String> headerFooterLines = lineCount.entrySet().stream()
.filter(e -> e.getValue() >= 3)
.map(Map.Entry::getKey)
.collect(java.util.stream.Collectors.toSet());
if (headerFooterLines.isEmpty()) return text;
// 移除这些行
return java.util.Arrays.stream(lines)
.filter(line -> !headerFooterLines.contains(line.trim()))
.collect(java.util.stream.Collectors.joining("\n"));
}
private String normalizeNumbers(String text) {
// 全角数字转半角
char[] chars = text.toCharArray();
for (int i = 0; i < chars.length; i++) {
if (chars[i] >= '0' && chars[i] <= '9') {
chars[i] = (char) (chars[i] - '0' + '0');
}
}
return new String(chars);
}
}第六关:嵌入模型选择
主流嵌入模型对比
| 模型 | 向量维度 | 中文效果 | 速度 | 成本(每1M Token) |
|---|---|---|---|---|
| text-embedding-3-small | 1536 | 良好 | 快 | $0.02 |
| text-embedding-3-large | 3072 | 很好 | 中 | $0.13 |
| BGE-large-zh-v1.5 | 1024 | 优秀(中文) | 快(本地) | 免费 |
| BGE-m3 | 1024 | 优秀(多语言) | 中(本地) | 免费 |
| nomic-embed-text | 768 | 良好 | 快 | 免费 |
对于中文为主的企业文档,BGE系列(百度开源)的效果往往优于OpenAI的embedding模型,且可以本地部署,成本几乎为零。
@Configuration
public class EmbeddingConfig {
/**
* 混合嵌入策略:
* - 生产文档:BGE本地模型(省钱,中文效果好)
* - 多语言文档:text-embedding-3-small(支持多语言)
*/
@Bean
@Primary
public EmbeddingRouter embeddingRouter(
@Qualifier("bgeEmbedding") EmbeddingClient bgeClient,
@Qualifier("openaiEmbedding") EmbeddingClient openaiClient) {
return new EmbeddingRouter(bgeClient, openaiClient);
}
}@Service
public class EmbeddingRouter {
private final EmbeddingClient bgeClient;
private final EmbeddingClient openaiClient;
// 中文字符比例超过60%,使用BGE
private static final double CHINESE_THRESHOLD = 0.6;
public float[] embed(String text) {
double chineseRatio = calculateChineseRatio(text);
if (chineseRatio >= CHINESE_THRESHOLD) {
return bgeClient.embed(text);
} else {
return openaiClient.embed(text);
}
}
public List<float[]> embedBatch(List<String> texts) {
// 按语言分组,批量处理
Map<Boolean, List<String>> grouped = texts.stream()
.collect(java.util.stream.Collectors.partitioningBy(
t -> calculateChineseRatio(t) >= CHINESE_THRESHOLD
));
List<float[]> results = new ArrayList<>(Collections.nCopies(texts.size(), null));
// BGE处理中文文本
List<String> chineseTexts = grouped.get(true);
if (!chineseTexts.isEmpty()) {
List<float[]> chineseVectors = bgeClient.embedBatch(chineseTexts);
// 按原始顺序回填结果
int idx = 0;
for (int i = 0; i < texts.size(); i++) {
if (calculateChineseRatio(texts.get(i)) >= CHINESE_THRESHOLD) {
results.set(i, chineseVectors.get(idx++));
}
}
}
// OpenAI处理非中文文本
List<String> otherTexts = grouped.get(false);
if (!otherTexts.isEmpty()) {
List<float[]> otherVectors = openaiClient.embedBatch(otherTexts);
int idx = 0;
for (int i = 0; i < texts.size(); i++) {
if (calculateChineseRatio(texts.get(i)) < CHINESE_THRESHOLD) {
results.set(i, otherVectors.get(idx++));
}
}
}
return results;
}
private double calculateChineseRatio(String text) {
if (text == null || text.isEmpty()) return 0;
long chineseCount = text.chars()
.filter(c -> c >= 0x4E00 && c <= 0x9FFF)
.count();
return (double) chineseCount / text.length();
}
}第七关:批量处理架构
百万文档的并行处理架构。
@Service
public class BulkDocumentProcessor {
@Autowired
private SmartPdfParser pdfParser;
@Autowired
private RecursiveTextChunker chunker;
@Autowired
private MetadataExtractor metadataExtractor;
@Autowired
private TextCleaner textCleaner;
@Autowired
private EmbeddingRouter embeddingRouter;
@Autowired
private VectorStore vectorStore;
/**
* 批量处理文档
*
* 使用虚拟线程并发处理,单机可轻松达到 100文档/分钟
*/
public BulkProcessResult processBatch(List<File> documents) {
int successCount = 0;
int failureCount = 0;
List<String> failedFiles = new ArrayList<>();
// 使用虚拟线程池(Java 21+)
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<CompletableFuture<ProcessResult>> futures = documents.stream()
.map(file -> CompletableFuture.supplyAsync(
() -> processDocument(file),
executor
))
.collect(Collectors.toList());
// 等待所有处理完成
for (CompletableFuture<ProcessResult> future : futures) {
try {
ProcessResult result = future.get(120, TimeUnit.SECONDS);
if (result.isSuccess()) {
successCount++;
} else {
failureCount++;
failedFiles.add(result.getFileName());
}
} catch (TimeoutException e) {
failureCount++;
log.error("Processing timeout");
} catch (Exception e) {
failureCount++;
log.error("Processing failed: {}", e.getMessage());
}
}
}
return BulkProcessResult.builder()
.totalDocuments(documents.size())
.successCount(successCount)
.failureCount(failureCount)
.failedFiles(failedFiles)
.build();
}
private ProcessResult processDocument(File file) {
try {
// Step 1: 解析
ParsedDocument parsed = pdfParser.parse(file);
// Step 2: 清洗
String cleanedText = textCleaner.clean(parsed.getRawText());
// Step 3: 元数据提取
DocumentMetadata metadata = metadataExtractor.extractMetadata(
cleanedText, file.getName()
);
// Step 4: 分块
RecursiveTextChunker.ChunkConfig config = RecursiveTextChunker.ChunkConfig.builder()
.chunkSize(512)
.chunkOverlap(50)
.build();
List<RecursiveTextChunker.TextChunk> chunks = chunker.chunk(cleanedText, config);
// 额外处理表格(每个表格生成多个自然语言chunk)
TableChunkProcessor tableProcessor = new TableChunkProcessor();
List<String> additionalChunks = new ArrayList<>();
for (ParsedDocument.ParsedTable table : parsed.getTables()) {
additionalChunks.addAll(
tableProcessor.processTable(
table.getMarkdownRepresentation(),
table.getCaption(),
Map.of("page", table.getPageNumber())
)
);
}
// Step 5: 向量化(批量)
List<String> allTexts = new ArrayList<>();
chunks.forEach(c -> allTexts.add(c.getText()));
allTexts.addAll(additionalChunks);
List<float[]> vectors = embeddingRouter.embedBatch(allTexts);
// Step 6: 写入向量库
List<VectorDocument> vectorDocs = new ArrayList<>();
for (int i = 0; i < allTexts.size(); i++) {
VectorDocument vDoc = new VectorDocument();
vDoc.setId(metadata.getDocumentId() + "_chunk_" + i);
vDoc.setText(allTexts.get(i));
vDoc.setVector(vectors.get(i));
vDoc.setMetadata(metadataExtractor.buildChunkMetadata(
metadata, i, allTexts.size(),
i < chunks.size() ? chunks.get(i).getMetadata() + "" : "table"
));
vectorDocs.add(vDoc);
}
vectorStore.batchInsert(vectorDocs);
log.info("Processed {}: {} chunks + {} table chunks",
file.getName(), chunks.size(), additionalChunks.size());
return ProcessResult.success(file.getName(), chunks.size());
} catch (Exception e) {
log.error("Failed to process {}: {}", file.getName(), e.getMessage(), e);
return ProcessResult.failure(file.getName(), e.getMessage());
}
}
}生产注意事项
1. 扫描版PDF的处理
扫描版PDF里是图片,PDFBox无法提取文字。解决方案:
public boolean isScannedPdf(PDDocument doc) throws IOException {
PDFTextStripper stripper = new PDFTextStripper();
String text = stripper.getText(doc);
// 如果提取的文字少于每页20个字符,认为是扫描版
return text.length() < doc.getNumberOfPages() * 20;
}
// 扫描版使用OCR(Tesseract或云服务)
public String ocrPdf(File pdfFile) {
// 将PDF转为图片,再调用OCR API
// 推荐:阿里云OCR/百度OCR,中文识别率更高
}2. 大文档的内存管理
处理大PDF时,全量加载到内存可能OOM:
// 分页加载,不一次性加载整个文档
for (int pageNum = 1; pageNum <= totalPages; pageNum++) {
try (PDDocument singlePageDoc = extractPage(pdfFile, pageNum)) {
String pageText = extractText(singlePageDoc);
processPage(pageText, pageNum);
}
// 每处理10页触发一次GC建议(不强制)
if (pageNum % 10 == 0) System.gc();
}3. chunk_size和overlap的调优
没有通用的最优参数,需要针对你的场景测试:
| 文档类型 | 推荐chunk_size | 推荐overlap |
|---|---|---|
| 法规/政策(结构清晰) | 256-512 | 30-50 |
| 技术文档(有代码) | 512-1024 | 50-100 |
| 新闻/报道(段落短) | 256-384 | 20-30 |
| 财务报告(含表格) | 384-512 | 50 |
| 学术论文 | 512-768 | 50-80 |
常见问题解答
Q1:PDF解析出的中文有乱码怎么办?
通常是字体没有嵌入导致的。解决方案:1)试试用Tika替代PDFBox;2)用Adobe Acrobat重新保存PDF(会重新嵌入字体);3)对这类问题PDF,直接用Vision模型解析。
Q2:chunk太小检索不准,chunk太大检索模糊,怎么权衡?
用"父子chunk"策略:索引用小chunk(256字),检索到小chunk后,实际传给LLM的是其父chunk(1024字)。小chunk保证检索精度,大chunk提供丰富上下文。
Q3:向量化批量处理时OpenAI限流怎么处理?
实现指数退避重试,设置合理的批次大小(建议50-100个文本/批),并在不同时间段(业务低峰)跑批量任务。
Q4:表格解析结果很差,有没有更好的方案?
对于表格密集的文档(如财务报表),强烈建议直接使用LLM Vision解析,成本可以接受(每页约$0.01-0.02),但精度大幅提升。
Q5:元数据应该存在向量库里还是关系型数据库里?
两者都要存。向量库里存一份(用于检索时过滤),关系型数据库里存完整版(用于业务查询和管理)。向量库里的元数据字段要精简,只放检索时需要的。
Q6:如何评估预处理质量?
建立一个评估集:50-100个问题,每个问题标注了正确答案和来源文档。运行RAG后,检查top-5的召回结果中是否包含正确来源文档。这个"召回率"是预处理质量的直接指标。
总结
文档预处理是RAG系统中最被低估的环节。王静团队的惨痛教训证明:在预处理上省的工夫,会在生产事故上加倍还回来。
行动清单:
