Spring AI 源码解析(七):RAG 管道设计理念
概述:
RAG(Retrieval-Augmented Generation)是 Spring AI 中最核心的工程化模式——将私有知识库与 LLM 结合,让大模型能够基于企业内部文档回答问题。Spring AI 把 RAG 抽象成了四个标准阶段:
- 文档加载(Document Loading):从文件系统、数据库等来源读取原始文档
- 文档转换(Document Transformation):切分、清洗、元数据提取
- 向量入库(Vector Ingestion):Embedding 向量化 + 写入 VectorStore
- 检索增强(Retrieval Augmentation):相似度搜索 + 上下文注入 Prompt → LLM 生成
RAG 的本质是把"检索"变成 LLM 可理解的"上下文",让模型从"凭记忆回答"变成"照着材料回答"。
一、 文档加载:DocumentReader 体系结构
整个 RAG 管道的入口就是 DocumentReader 接口,设计极简:
public interface DocumentReader {
List<Document> get();
}
一个 get() 方法,返回 Document 列表。Document 包含两部分核心数据:
content(String):文档的文本内容metadata(Map):文档的元数据(文件名、页码、作者等)
Spring AI 内置了六种 Reader 实现:
| 实现类 | 适用格式 | 底层引擎 | 典型场景 |
|---|---|---|---|
TextReader |
纯文本 .txt | Java NIO | 日志、配置文件 |
JsonReader |
JSON 文件 | Jackson | API 文档、结构化数据 |
PagePdfDocumentReader |
PDF 文本型 | Apache PDFBox | 产品手册、技术白皮书 |
TikaDocumentReader |
几乎所有格式 | Apache Tika | 多格式混合的文档库 |
TokenizingDocumentReader |
任意文本 | 内置 Tokenizer | 需要按 token 粒度读取 |
MarkdownDocumentReader |
Markdown 文件 | 内置解析器 | 技术文档、README |
TikaDocumentReader:万能格式解析器
TikaDocumentReader 是最常用的 Reader,它利用 Apache Tika 的自动格式检测能力,一个类搞定 PDF、Word、HTML、Excel 等数十种格式:
public class TikaDocumentReader implements DocumentReader {
private final Resource resource;
@Override
public List<Document> get() {
try (InputStream inputStream = resource.getInputStream()) {
BodyContentHandler handler = new BodyContentHandler(-1); // -1 禁用大小限制
AutoDetectParser parser = new AutoDetectParser();
Metadata metadata = new Metadata();
parser.parse(inputStream, handler, metadata);
Map<String, Object> docMetadata = extractMetadata(metadata);
return List.of(new Document(handler.toString(), docMetadata));
} catch (Exception e) {
throw new DocumentReadException("Failed to read document: "
+ resource.getFilename(), e);
}
}
}
关键细节:BodyContentHandler(-1) 中的 -1 参数禁用了 Tika 默认的内容大小限制(默认 100KB)。如果不设置这个,大文档会被静默截断——这个坑排查了很久才定位到。
实践踩坑:PDF 扫描件(图片型 PDF)Tika 解析出来是空字符串。这是因为 Tika 只能提取文本层,无法 OCR 识别图片中的文字。解决方案有两个:一是预处理时用 OCR 工具(如 Tesseract)将扫描件转为可搜索 PDF;二是在 Reader 层做二次处理,检测到空内容时自动调用 OCR。
自定义 Reader 实践
实际项目中,通常封装一层,根据文件扩展名选择最合适的 Reader:
public DocumentReader createReader(Resource resource) {
String filename = resource.getFilename().toLowerCase();
if (filename.endsWith(".pdf")) {
return new PagePdfDocumentReader(resource);
}
if (filename.endsWith(".json")) {
return new JsonReader(resource);
}
return new TikaDocumentReader(resource); // 兜底方案
}
文档切分:DocumentSplitter 与智能断点算法
为什么需要切分
Embedding 模型的输入有严格限制——主流的 text-embedding-3-small 最大 8192 token,bge-large-zh 仅 512 token。一篇 50 页的 PDF 可能包含数万 token,直接 Embedding 会被截断。因此必须在 Embedding 之前将文档切分为大小合适的 “chunk”。
public interface DocumentSplitter {
List<Document> split(List<Document> documents);
}
TokenTextSplitter 核心实现
public class TokenTextSplitter implements DocumentSplitter {
public static final int DEFAULT_CHUNK_SIZE = 800;
public static final int DEFAULT_CHUNK_OVERLAP = 200;
private final int chunkSize;
private final int chunkOverlap;
@Override
public List<Document> split(List<Document> documents) {
List<Document> result = new ArrayList<>();
for (Document doc : documents) {
List<String> chunks = splitText(doc.getContent());
for (int i = 0; i < chunks.size(); i++) {
Map<String, Object> chunkMetadata = new HashMap<>(doc.getMetadata());
chunkMetadata.put("chunk_index", i);
chunkMetadata.put("chunk_total", chunks.size());
chunkMetadata.put("source_doc", doc.getId());
result.add(new Document(chunks.get(i), chunkMetadata));
}
}
return result;
}
}
关键元数据:每个 chunk 都保留了 chunk_index、chunk_total 和 source_doc。这在调试检索效果时至关重要——如果某段内容检索不出来,可以快速定位到它在原始文档中的位置。
智能断点算法
简单按字符数切割会在句子中间截断,导致语义碎片化。TokenTextSplitter 实现了自然断点查找:
private List<String> splitText(String text) {
List<String> chunks = new ArrayList<>();
int start = 0;
while (start < text.length()) {
int end = Math.min(start + chunkSize, text.length());
if (end < text.length()) {
// 在 chunk 边界 ±50 字符范围内寻找自然断点
int breakPoint = findNaturalBreak(text, Math.max(start, end - 50),
Math.min(text.length(), end + 50));
if (breakPoint > 0) {
end = breakPoint;
}
}
chunks.add(text.substring(start, end).trim());
start = end - chunkOverlap; // 重叠区域保证跨 chunk 语义连贯
}
return chunks;
}
private int findNaturalBreak(String text, int from, int to) {
// 优先级:段落分隔(双换行)> 句号/问号/感叹号 > 换行 > 逗号
for (int i = to; i >= from; i--) {
char c = text.charAt(i);
if (c == '\n' && i > 0 && text.charAt(i - 1) == '\n') return i;
}
for (int i = to; i >= from; i--) {
char c = text.charAt(i);
if (c == '。' || c == '?' || c == '!' || c == '.') return i + 1;
}
return -1; // 找不到自然断点,就用硬截断
}
断点优先级:段落分隔 > 句子结尾标点 > 换行 > 逗号。这个优先级设计保证了每个 chunk 的语义完整性。
Chunk 参数调优经验
| chunkSize | chunkOverlap | 适用场景 | 效果评价 |
|---|---|---|---|
| 500 | 100 | 短文本、FAQ | chunk 太小,语义不完整,检索精度偏低 |
| 800 | 200 | 技术文档(默认) | 大多数场景的最优解 |
| 1000 | 250 | 法律文书、学术论文 | 单句较长时保证完整性 |
| 1500 | 300 | 综述类长文 | 可能超出 bge-small 等模型的输入限制 |
Embedding 转换:从文本到向量
切分后的 chunk 需要经过 EmbeddingModel 转为向量才能存入向量数据库:
public interface EmbeddingModel {
EmbeddingResponse embed(EmbeddingRequest request);
List<float[]> embed(List<Document> documents);
default float[] embed(String text) {
return embed(List.of(new Document(text))).get(0);
}
}
Embedding 的本质是将文本映射到一个高维空间中的点——语义相似的文本在空间中距离相近。Spring AI 支持 OpenAI、Azure、HuggingFace、Ollama 等十余种 Embedding 模型接入。
// 实际使用:批量 Embedding 提升吞吐
List<Document> chunks = splitter.split(reader.get());
List<float[]> vectors = embeddingModel.embed(chunks);
// 向量维度取决于模型:OpenAI text-embedding-3-small → 1536维
// bge-large-zh → 1024维
性能注意:embed(List<Document>) 批量方法远比循环调用 embed(String) 高效,因为 Embedding API 通常支持批量输入,网络往返次数从 N 次降为 1 次。
二、向量入库:VectorStore 写入与检索
public interface VectorStore {
void add(List<Document> documents);
List<Document> similaritySearch(SearchRequest request);
List<Document> similaritySearch(String query);
}
Spring AI 内置了 20+ 种 VectorStore 实现,覆盖主流向量数据库:
| 实现类 | 向量数据库 | 适用场景 |
|---|---|---|
PgVectorStore |
PostgreSQL + pgvector | 已有 PG 基础设施的团队 |
RedisVectorStore |
Redis Stack | 低延迟、高并发检索 |
ChromaVectorStore |
Chroma | 轻量级、快速原型开发 |
MilvusVectorStore |
Milvus | 十亿级向量规模 |
SimpleVectorStore |
内存 Map | 开发测试、小数据集 Demo |
相似度检索的内部流程
// SearchRequest 精细化控制
SearchRequest request = SearchRequest.builder()
.query("Spring AI 支持哪些模型?")
.topK(5) // 返回最相似的 5 个文档
.similarityThreshold(0.75) // 相似度阈值,低于此值的结果丢弃
.filterExpression("type == 'manual'") // 元数据过滤
.build();
List<Document> results = vectorStore.similaritySearch(request);
similarityThreshold 的重要性:不设阈值时,即使知识库中没有相关内容,向量数据库也会返回"最相似"的结果——相似的噪声。设 0.75 阈值后,相关性不足的结果直接被过滤,避免 LLM 被不相关内容误导。
检索增强:QuestionAnswerAdvisor 核心机制
这是整个 RAG 管道中最关键的一环——将检索到的文档注入 LLM 的 System Prompt,实现"带证据的回答"。
public class QuestionAnswerAdvisor implements CallAroundAdvisor {
private final VectorStore vectorStore;
private final SearchRequest searchRequest;
private static final int DEFAULT_ORDER = 0;
private static final String DEFAULT_CONTEXT_TEMPLATE = """
以下是可能对回答问题有帮助的上下文信息:
---------------------
{context}
---------------------
请基于以上上下文信息回答用户的问题。
如果上下文信息不足以回答问题,请如实告知。
""";
@Override
public ChatResponse aroundCall(
AdvisorSupport advisorSupport,
CallAroundAdvisorChain chain) {
// 1. 提取用户原始问题
String userText = extractUserText(advisorSupport.getMessages());
// 2. 构建搜索请求并执行相似度检索
SearchRequest request = searchRequest.query(userText);
List<Document> documents = documentRetriever.retrieve(request);
// 3. 拼接检索到的文档内容作为上下文
String context = documents.stream()
.map(Document::getContent)
.collect(Collectors.joining("\n---\n"));
// 4. 将上下文注入 System Prompt
String systemPrompt = DEFAULT_CONTEXT_TEMPLATE
.replace("{context}", context);
// 5. 构建增强后的消息列表
List<Message> enhancedMessages = new ArrayList<>();
enhancedMessages.add(new SystemMessage(systemPrompt));
advisorSupport.getMessages().stream()
.filter(m -> m.getMessageType() != MessageType.SYSTEM)
.forEach(enhancedMessages::add);
advisorSupport.setMessages(enhancedMessages);
return chain.next(advisorSupport);
}
}
三个关键设计决策
1. 上下文注入位置:为什么是 System 消息?
RAG Advisor 将检索结果放在 System 消息而非 User 消息中。这是有意的设计——LLM 对 System 消息的遵从度远高于 User 消息。放在 System 消息里,模型会把上下文当作"必须遵守的事实依据";放在 User 消息里,模型则可能将其视为"仅供参考的信息"。
2. "如实告知"为何不是摆设?
"如果上下文信息不足以回答问题,请如实告知。"
这行 Prompt 是防幻觉的关键。实测去掉这行后,LLM 在检索结果不充分时会"强行编造"——用自己的训练数据补充答案,造成看似正确实则虚假的回复。加上后,模型更倾向于说"抱歉,当前文档中没有相关信息"。
3. 已有 System 消息的覆盖策略
filter(m -> m.getMessageType() != MessageType.SYSTEM) 将原始 System 消息过滤掉,用 RAG 上下文消息替代。如果你的应用需要同时保留原始 System 指令(如角色设定)和 RAG 上下文,需要自定义 Advisor,将两者合并而不是直接覆盖。
DocumentRetriever:检索策略抽象
Spring AI 还抽象出了 DocumentRetriever 接口,将"从哪检索"与"如何检索"解耦:
public interface DocumentRetriever {
List<Document> retrieve(SearchRequest request);
}
VectorStore 本身实现了这个接口,但你也可以实现自定义的 Retriever——比如先查缓存、没命中再查向量数据库,或者同时从多个 VectorStore 聚合结果。
三、Advisor 责任链:多 Advisor 协作模式
Spring AI 的 Advisor 机制基于经典的责任链模式(Chain of Responsibility),多个 Advisor 可以按顺序叠加:
public interface CallAroundAdvisor extends Ordered {
ChatResponse aroundCall(
AdvisorSupport advisorSupport,
CallAroundAdvisorChain chain);
}
public class CallAroundAdvisorChain {
private final List<CallAroundAdvisor> advisors;
private final ChatModel chatModel;
private int currentIndex = 0;
public ChatResponse next(AdvisorSupport advisorSupport) {
if (currentIndex < advisors.size()) {
return advisors.get(currentIndex++).aroundCall(advisorSupport, this);
}
// 所有 Advisor 执行完毕,最终调用 ChatModel
return chatModel.call(advisorSupport.getPrompt());
}
}
多 Advisor 调用链示意:
RAGAdvisor → SafetyAdvisor → HistoryAdvisor → LoggingAdvisor → ChatModel.call()
(注入上下文) (安全检查) (对话历史) (日志记录) (最终调用)
自定义 Advisor 实践:可以项目中实现了一个 PermissionAdvisor,在 System 消息中注入当前用户的权限范围:
public class PermissionAdvisor implements CallAroundAdvisor {
@Override
public ChatResponse aroundCall(
AdvisorSupport support, CallAroundAdvisorChain chain) {
String userPermissions = SecurityContextHolder.getContext()
.getAuthentication().getAuthorities().toString();
String permissionHint = "当前用户只能访问以下权限范围内的信息:" + userPermissions;
support.getMessages().add(0, new SystemMessage(permissionHint));
return chain.next(support);
}
}
这样 LLM 就能基于用户权限来过滤回答内容,避免越权访问。
四、实践建议与调优经验
基于项目实战经验,总结 RAG 调优关键点:
- Chunk 大小是检索质量的基石:800 token 是技术文档的甜点值。chunk 太大检索不准,太小语义不完整。建议根据实际文档类型做 A/B 测试,而不是盲从默认值。
- 相似度阈值不可省略:
similarityThreshold(0.75)能有效过滤不相关内容。没有阈值时,即使知识库中不存在答案,向量数据库也会返回"相似度最高"的噪声,LLM 会基于这些噪声强行编造。 - Embedding 模型与 Chunk 大小要匹配:
bge-large-zh最大支持 512 token,给它 1000 token 的 chunk 会被静默截断。先用模型文档确认输入限制,再设定 chunkSize。 - 重叠区域(overlap)防止边界信息丢失:文档在 chunk 边界处的内容容易"断裂"。200 token 的 overlap 保证了边界信息的跨 chunk 覆盖。
- 扫描件 PDF 需要预处理:Tika 无法提取图片型 PDF 的文字。建议在入库前用 OCR 工具预处理,或在管道中增加扫描件检测 + OCR 环节。
- 批量 Embedding 而非逐条调用:
embeddingModel.embed(List<Document>)比循环调用效率高数倍,减少网络往返。 - 元数据是调试利器:每个 chunk 保留
chunk_index、source_doc等元数据。当检索效果不理想时,能快速定位问题出在哪个文档的哪一段。
总结
RAG 管道的核心是将"检索"与"生成"解耦,通过标准化的四阶段流水线(加载 → 切分 → 入库 → 检索)实现可扩展的知识库问答系统。
RAG 看起来流程清晰,但实际调优中大部分问题不在代码层,而在数据层——文档切得太碎语义不连贯,切得太大搜索命中率低,扫描件未做 OCR 导致内容丢失。框架提供工具,真正的效果取决于对业务文档的理解和参数调优。
更多推荐

所有评论(0)