1. 项目概述
RAG(检索增强生成)技术正在成为企业级AI应用的标准范式。作为一名长期从事Java企业开发的工程师,我发现Spring AI框架为Java开发者提供了一条快速实现RAG的捷径。不同于直接调用大模型API的简单问答系统,RAG通过将外部知识库与生成模型结合,有效解决了以下痛点:
- 知识时效性问题:大语言模型的训练数据存在时间滞后,而RAG可以实时接入最新文档
- 数据隐私顾虑:敏感数据无需上传到第三方模型,可完全在本地知识库中管理
- 回答准确性:基于具体文档片段生成答案,显著减少模型"幻觉"现象
本方案采用Spring Boot + Spring AI的技术栈,配合PGvector向量数据库,构建了一个完整的知识问答系统。相比Python生态的LangChain方案,Spring AI为Java开发者提供了更符合工程习惯的API设计,特别是其统一的存储抽象层,使得切换向量数据库只需修改配置即可完成。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心架构设计
2.1 技术选型决策
在方案设计阶段,我们主要考虑了以下技术组合:
| 组件类型 | 候选方案 | 最终选择理由 |
|---|---|---|
| 向量数据库 | PGvector/Chroma/Redis | PGvector基于PostgreSQL,无需额外维护基础设施,适合已有PG环境的企业 |
| 嵌入模型 | OpenAI/Ollama本地模型 | 教程使用OpenAI演示,生产环境建议切换为Ollama避免API依赖 |
| 文档解析 | Apache Tika | 内置支持PDF/Word/Excel等20+格式,企业文档处理覆盖面广 |
| 文本分割策略 | TokenTextSplitter | 按token计数分割,确保每个片段不超过模型上下文窗口,可配置重叠避免信息割裂 |
实际项目中,如果涉及敏感数据,务必使用本地部署的嵌入模型(如Ollama)。我们团队在金融项目中就曾因临时使用OpenAI嵌入导致安全审计不通过,后来迁移到nomic-embed-text模型才解决问题。
2.2 系统流程分解
RAG系统的核心工作流可分为两个阶段:
-
知识库构建阶段:
- 文档加载:通过Tika解析各类格式的原始文件
- 文本分割:将长文档拆分为语义连贯的片段
- 向量化:使用嵌入模型将文本转换为向量表示
- 存储索引:将向量数据存入PGvector并构建HNSW索引
-
问答服务阶段:
- 问题向量化:将用户查询转换为同维度的向量
- 相似度检索:在向量空间查找最相关的文档片段
- 提示工程:构建包含上下文的Prompt模板
- 答案生成:调用大模型生成最终回复
mermaid复制graph TD
A[原始文档] --> B[文档解析]
B --> C[文本分割]
C --> D[向量化]
D --> E[向量存储]
F[用户问题] --> G[问题向量化]
G --> H[向量相似度搜索]
H --> I[构建Prompt上下文]
I --> J[大模型生成]
J --> K[返回答案]
3. 环境配置详解
3.1 基础设施准备
PostgreSQL with PGvector
推荐使用Docker快速部署:
bash复制docker run --name pgvector \
-e POSTGRES_PASSWORD=yourpassword \
-p 5432:5432 \
-d ankane/pgvector
关键配置参数说明:
shared_buffers:建议设置为物理内存的25%maintenance_work_mem:向量索引构建时需要,建议512MB以上max_parallel_workers_per_gather:并行查询加速,设置为CPU核心数的50%
嵌入模型服务
对于生产环境,我推荐使用Ollama本地部署:
- 安装Ollama服务:
bash复制curl -fsSL https://ollama.com/install.sh | sh
- 下载嵌入模型:
bash复制ollama pull nomic-embed-text
- 启动服务并测试:
bash复制ollama serve &
curl http://localhost:11434/api/embeddings -d '{
"model": "nomic-embed-text",
"prompt": "测试文本"
}'
3.2 Spring Boot项目配置
依赖管理
必须的Maven依赖:
xml复制<dependencies>
<!-- 核心Spring AI依赖 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-pgvector-store-spring-boot-starter</artifactId>
<version>0.8.1</version>
</dependency>
<!-- 文档解析 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-tika-document-reader</artifactId>
<version>0.8.1</version>
</dependency>
<!-- 根据嵌入模型选择 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-ollama-spring-boot-starter</artifactId>
<version>0.8.1</version>
</dependency>
</dependencies>
关键配置项
application.yml的完整配置示例:
yaml复制spring:
datasource:
url: jdbc:postgresql://localhost:5432/postgres
username: postgres
password: yourpassword
hikari:
maximum-pool-size: 10
connection-timeout: 30000
ai:
ollama:
base-url: http://localhost:11434
embedding:
options:
model: nomic-embed-text
vectorstore:
pgvector:
initialize-schema: true
index-type: HNSW
distance-type: COSINE
dimensions: 768 # nomic-embed-text的维度
create-index-concurrently: true # 生产环境建议启用
4. 核心实现解析
4.1 文档处理流水线
文档加载优化
Spring AI的Tika集成虽然方便,但在处理大型PDF时可能遇到内存问题。我们通过自定义Reader实现流式处理:
java复制public class ChunkedPdfReader implements DocumentReader {
private final Resource resource;
private final int chunkSize;
public ChunkedPdfReader(Resource resource, int chunkSize) {
this.resource = resource;
this.chunkSize = chunkSize;
}
@Override
public List<Document> get() {
try (PDDocument pdf = PDDocument.load(resource.getInputStream())) {
PDFTextStripper stripper = new PDFTextStripper();
List<Document> docs = new ArrayList<>();
for (int page = 1; page <= pdf.getNumberOfPages(); page++) {
stripper.setStartPage(page);
stripper.setEndPage(page);
String text = stripper.getText(pdf);
// 按固定大小分块
for (int i = 0; i < text.length(); i += chunkSize) {
int end = Math.min(i + chunkSize, text.length());
docs.add(new Document(text.substring(i, end),
Map.of("source", resource.getFilename(), "page", page)));
}
}
return docs;
} catch (Exception e) {
throw new RuntimeException("PDF解析失败", e);
}
}
}
智能文本分割
默认的TokenTextSplitter可能割裂完整语义,我们改进为基于句子边界的分割:
java复制public class SentenceAwareSplitter extends TextSplitter {
private final int maxTokens;
private final int overlap;
public SentenceAwareSplitter(int maxTokens, int overlap) {
this.maxTokens = maxTokens;
this.overlap = overlap;
}
@Override
public List<String> split(String text) {
// 使用OpenNLP句子检测器
SentenceDetector detector = new SentenceDetectorME(...);
String[] sentences = detector.sentDetect(text);
List<String> chunks = new ArrayList<>();
StringBuilder currentChunk = new StringBuilder();
int tokenCount = 0;
for (String sentence : sentences) {
int sentenceTokens = estimateTokenCount(sentence);
if (tokenCount + sentenceTokens > maxTokens && currentChunk.length() > 0) {
chunks.add(currentChunk.toString());
currentChunk = new StringBuilder(
chunks.isEmpty() ? "" : chunks.get(chunks.size()-1).substring(0, overlap));
tokenCount = estimateTokenCount(currentChunk.toString());
}
currentChunk.append(sentence).append(" ");
tokenCount += sentenceTokens;
}
if (currentChunk.length() > 0) {
chunks.add(currentChunk.toString());
}
return chunks;
}
}
4.2 向量存储优化
索引策略选择
PGvector支持多种索引类型,通过实测对比:
| 索引类型 | 构建速度 | 查询速度 | 内存占用 | 适用场景 |
|---|---|---|---|---|
| IVFFlat | 快 | 中等 | 低 | 快速原型开发 |
| HNSW | 慢 | 快 | 高 | 生产环境高频查询 |
| BRIN | 最快 | 慢 | 最低 | 超大规模向量集合 |
推荐创建索引的SQL示例:
sql复制CREATE INDEX idx_vector_hnsw ON vector_store
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
连接池调优
由于向量搜索是IO密集型操作,需要优化HikariCP配置:
yaml复制spring:
datasource:
hikari:
maximum-pool-size: 20
minimum-idle: 5
idle-timeout: 600000
max-lifetime: 1800000
connection-timeout: 30000
leak-detection-threshold: 60000
4.3 检索增强服务
混合检索策略
单纯依靠向量搜索可能忽略关键词匹配,我们实现混合检索:
java复制public List<Document> hybridSearch(String query, int topK) {
// 向量相似度搜索
List<Document> vectorResults = vectorStore.similaritySearch(
SearchRequest.query(query).withTopK(topK));
// 关键词全文检索
List<Document> keywordResults = jdbcTemplate.query(
"SELECT text, metadata FROM vector_store WHERE text LIKE ? LIMIT ?",
(rs, rowNum) -> new Document(rs.getString(1),
parseMetadata(rs.getString(2))),
"%" + query + "%", topK);
// 结果融合与去重
return Stream.concat(vectorResults.stream(), keywordResults.stream())
.distinct()
.sorted(comparing(this::scoreDocument).reversed())
.limit(topK)
.collect(Collectors.toList());
}
private double scoreDocument(Document doc) {
// 综合向量相似度和关键词匹配度计算最终得分
return doc.getMetadata().getOrDefault("similarity", 0.5)
* (1 + doc.getText().toLowerCase().contains(query.toLowerCase()) ? 0.2 : 0);
}
动态上下文窗口
根据问题复杂度自动调整上下文量:
java复制public String generateAnswer(String question) {
int contextSize = estimateComplexity(question);
List<Document> contexts = retrieveContexts(question, contextSize);
String systemPrompt = """
你是一个专业的知识助手,请严格根据提供的上下文回答问题。
上下文包含{}个片段,请综合分析后给出准确回答。
回答格式:
- 直接回答问题核心
- 引用相关上下文片段编号[1][2]
- 不确定时明确说明""";
Prompt prompt = new Prompt(
new SystemMessage(systemPrompt),
new UserMessage(formatContexts(contexts, question)));
return chatClient.call(prompt).getResult().getOutput().getContent();
}
private int estimateComplexity(String question) {
// 基于问题长度和关键词判断复杂度
int lengthWeight = Math.min(question.length() / 50, 3);
int keywordWeight = containsTechnicalTerms(question) ? 2 : 1;
return lengthWeight * keywordWeight;
}
5. 生产级优化建议
5.1 性能优化方案
- 批量处理文档:
java复制// 批量插入减少数据库往返
vectorStore.add(documents.stream()
.map(this::convertToDocument)
.collect(Collectors.toList()));
- 异步嵌入生成:
java复制@Async
public CompletableFuture<Void> asyncEmbedding(List<Document> docs) {
List<Document> embedded = docs.stream()
.map(doc -> new Document(doc.getText(),
embeddingModel.embed(doc.getText())))
.collect(Collectors.toList());
vectorStore.add(embedded);
return CompletableFuture.completedFuture(null);
}
- 缓存常见查询:
java复制@Cacheable(value = "ragAnswers", key = "#question.hashCode()")
public String cachedAsk(String question) {
return ragChatService.ask(question);
}
5.2 监控与运维
建议监控以下指标:
- 向量生成延迟:
ai_embedding_latency_seconds - 检索耗时:
vector_search_duration_ms - 上下文token使用量:
prompt_token_usage - 回答质量评分:
answer_quality_score
示例Prometheus配置:
yaml复制management:
metrics:
export:
prometheus:
enabled: true
distribution:
percentiles:
ai.embedding.latency: 0.5,0.9,0.99
vector.search.duration: 0.5,0.9,0.99
5.3 安全防护措施
- 输入过滤:
java复制@GetMapping("/ask")
public String safeAsk(@RequestParam @Validated @SafeText String question) {
return ragService.ask(question);
}
- 输出审查:
java复制public String filterSensitiveContent(String answer) {
return sensitiveWordFilter.filter(answer);
}
- 访问控制:
java复制@PreAuthorize("hasRole('KNOWLEDGE_USER')")
@PostMapping("/upload")
public void uploadDocument(@RequestBody Resource resource) {
knowledgeService.load(resource);
}
6. 典型问题排查
6.1 向量搜索不准确
症状:返回的文档片段与问题相关性低
解决方案:
- 检查嵌入模型输出维度是否与PGvector配置匹配
- 确认distance-type配置(COSINE适用于大多数文本场景)
- 调整相似度阈值:
java复制SearchRequest.builder()
.withSimilarityThreshold(0.6) // 提高阈值
.build()
6.2 大模型忽略上下文
症状:生成的答案未引用提供的上下文
调整Prompt设计:
code复制请严格根据以下上下文回答问题:
---
{context}
---
必须遵守:
1. 答案必须基于上述上下文
2. 引用上下文中的具体段落
3. 如果上下文未包含答案,明确回复"根据现有资料无法回答"
6.3 性能瓶颈分析
慢查询排查步骤:
- 开启PG慢查询日志:
sql复制ALTER SYSTEM SET log_min_duration_statement = 1000;
- 检查向量索引使用情况:
sql复制EXPLAIN ANALYZE SELECT * FROM vector_store ORDER BY embedding <-> ? LIMIT 5;
- 调整HNSW参数:
sql复制ALTER INDEX idx_vector_hnsw SET (ef_search = 100);
7. 扩展应用场景
7.1 多模态RAG
结合Spring AI的图片处理能力:
java复制public void storeMultimodal(Resource image, String description) {
byte[] imageEmbedding = visionClient.embed(image);
Document doc = new Document(description);
doc.getMetadata().put("image_embedding", imageEmbedding);
vectorStore.add(List.of(doc));
}
7.2 对话历史管理
实现多轮对话上下文:
java复制public class ConversationManager {
private final Deque<Message> history = new ArrayDeque<>();
public String chat(String userInput) {
history.addLast(new UserMessage(userInput));
String context = buildConversationContext();
List<Document> relevantDocs = retrieveRelevantDocs(context);
Prompt prompt = new Prompt(
new SystemMessage("你正在进行的对话历史:" + context),
new UserMessage(userInput),
new AssistantMessage(relevantDocs));
String answer = chatClient.call(prompt).getContent();
history.addLast(new AssistantMessage(answer));
return answer;
}
}
7.3 自动化知识更新
定时同步最新文档:
java复制@Scheduled(cron = "0 0 2 * * ?") // 每天凌晨2点
public void syncKnowledgeBase() {
List<Resource> newDocuments = documentWatcher.getChanges();
newDocuments.forEach(doc -> {
knowledgeService.load(doc);
log.info("已更新文档:{}", doc.getFilename());
});
}
经过多个项目的实战验证,这套基于Spring AI的RAG方案在保证开发效率的同时,能够满足企业级应用在性能、安全和可维护性方面的要求。特别是在需要频繁更新知识库的场景下,其向量索引的动态更新能力显著优于基于倒排索引的传统搜索方案。
