一、引言
LLM 大语言模型虽然强大,但有一个致命缺陷:不知道我们系统的内部设计。
当你问"我们系统的订单流程是怎样的?"或者"用户登录接口的限流策略是什么?",LLM 只能回答通用知识,无法给出准确答案。
RAG(Retrieval-Augmented Generation,检索增强生成)正是解决这个问题的最佳方案。它的核心思想是:先从知识库中检索相关文档,再将这些文档作为上下文喂给 LLM,让它基于这些信息生成答案。
二、RAG 架构原理
2.1 RAG 工作流程
┌─────────────────────────────────────────────────────────────────────────┐
│ RAG 完整工作流程 │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ 【构建阶段】 │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 原始文档 │───▶│ 文档切片 │───▶│ 生成向量 │ │
│ │ (PDF/Markdown)│ │ (Chunking) │ │ (Embedding) │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ 向量数据库 │ │
│ │ (pgvector) │ │
│ └──────────────┘ │
│ │
│ 【查询阶段】 │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 用户提问 │───▶│ 生成查询向量 │───▶│ 向量检索 │ │
│ │ │ │ │ │ (相似度匹配) │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ 构建 Prompt │ │
│ │ (注入上下文) │ │
│ └──────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ LLM 生成答案 │ │
│ │ │ │
│ └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
2.2 核心组件
| 组件 | 作用 | 技术选型 |
|---|---|---|
| 文档切片器 | 将长文档切分成适合 LLM 的小块 | Spring AI TokenTextSplitter |
| 嵌入模型 | 将文本转换为向量表示 | OpenAI text-embedding-3-small |
| 向量数据库 | 存储和检索向量 | PostgreSQL + pgvector |
| 检索器 | 根据查询向量找到最相关的文档 | pgvector 余弦相似度 |
| LLM | 根据上下文生成答案 | OpenAI GPT-4o |
三、环境准备
3.1 技术栈
| 技术 | 版本 | 说明 |
|---|---|---|
| Java | 21 | Spring AI 3.x 要求 Java 17+ |
| Spring Boot | 3.2.x | 最新稳定版 |
| Spring AI | 1.0.x | RAG 功能依赖 |
| PostgreSQL | 16 | 配合 pgvector 扩展 |
| Docker | 24.x | 快速启动 PostgreSQL |
3.2 Docker Compose 配置
创建 docker-compose.yml:
version: '3.8'
services:
postgres:
image: pgvector/pgvector:pg16
container_name: rag-postgres
environment:
POSTGRES_DB: rag_db
POSTGRES_USER: rag_user
POSTGRES_PASSWORD: rag_password
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U rag_user -d rag_db"]
interval: 10s
timeout: 5s
retries: 5
volumes:
postgres_data:
创建 init.sql:
-- 启用 pgvector 扩展
CREATE EXTENSION IF NOT EXISTS vector;
-- 创建知识库表
CREATE TABLE IF NOT EXISTS document_embeddings (
id SERIAL PRIMARY KEY,
content TEXT NOT NULL,
metadata JSONB,
embedding vector(1536),
token_count INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 创建向量索引(加速相似性搜索)
CREATE INDEX IF NOT EXISTS idx_embedding ON document_embeddings USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
-- 创建全文索引(用于混合检索)
CREATE INDEX IF NOT EXISTS idx_content_fts ON document_embeddings USING GIN (to_tsvector('english', content));
启动服务:
docker-compose up -d
四、项目初始化
4.1 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.example</groupId>
<artifactId>spring-ai-rag-demo</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.5</version>
<relativePath/>
</parent>
<properties>
<java.version>21</java.version>
<spring-ai.version>1.0.0</spring-ai.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring AI OpenAI -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
<!-- Spring AI pgvector -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-vector-store-pgvector</artifactId>
</dependency>
<!-- Spring Boot Data JPA -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- PostgreSQL Driver -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
4.2 application.yml 配置
server:
port: 8080
spring:
application:
name: spring-ai-rag-demo
# PostgreSQL 数据源配置
datasource:
url: jdbc:postgresql://localhost:5432/rag_db
username: rag_user
password: rag_password
driver-class-name: org.postgresql.Driver
# JPA 配置
jpa:
hibernate:
ddl-auto: none
show-sql: true
properties:
hibernate:
format_sql: true
# Spring AI 配置
ai:
openai:
api-key: ${OPENAI_API_KEY}
chat:
options:
model: gpt-4o
temperature: 0.1
embedding:
options:
model: text-embedding-3-small
dimensions: 1536
# pgvector 配置
vectorstore:
pgvector:
index-type: IVFFLAT
distance-type: COSINE
五、核心代码实现
5.1 文档切片策略
策略一:按 Token 分割(推荐)
package com.example.rag.service;
import org.springframework.ai.document.Document;
import org.springframework.ai.reader.TextReader;
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
@Service
public class DocumentSplitterService {
private final TokenTextSplitter tokenTextSplitter;
public DocumentSplitterService(
@Value("${app.rag.chunk-size:512}") int chunkSize,
@Value("${app.rag.chunk-overlap:100}") int chunkOverlap) {
this.tokenTextSplitter = new TokenTextSplitter(chunkSize, chunkOverlap);
}
public List<Document> splitByToken(Resource resource) {
TextReader textReader = new TextReader(resource);
List<Document> documents = textReader.get();
return tokenTextSplitter.apply(documents);
}
public List<Document> splitByToken(String content, String source) {
Document document = new Document(content, Map.of("source", source));
return tokenTextSplitter.apply(List.of(document));
}
}
策略二:按段落分割
package com.example.rag.service;
import org.springframework.ai.document.Document;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@Service
public class ParagraphSplitterService {
@Value("${app.rag.max-paragraph-length:2000}")
private int maxParagraphLength;
public List<Document> splitByParagraph(String content, String source) {
List<Document> documents = new ArrayList<>();
String[] paragraphs = content.split("\\n\\s*\\n");
for (int i = 0; i < paragraphs.length; i++) {
String paragraph = paragraphs[i].trim();
if (paragraph.isEmpty()) continue;
// 如果段落太长,进一步分割
if (paragraph.length() > maxParagraphLength) {
List<String> chunks = splitLongParagraph(paragraph);
for (int j = 0; j < chunks.size(); j++) {
documents.add(new Document(
chunks.get(j),
Map.of(
"source", source,
"paragraph", i + 1,
"chunk", j + 1
)
));
}
} else {
documents.add(new Document(
paragraph,
Map.of(
"source", source,
"paragraph", i + 1
)
));
}
}
return documents;
}
private List<String> splitLongParagraph(String paragraph) {
List<String> chunks = new ArrayList<>();
int start = 0;
int end = maxParagraphLength;
while (start < paragraph.length()) {
if (end >= paragraph.length()) {
chunks.add(paragraph.substring(start));
break;
}
int lastPeriod = paragraph.lastIndexOf('.', end);
int lastNewline = paragraph.lastIndexOf('\n', end);
int splitPoint = Math.max(lastPeriod, lastNewline);
if (splitPoint <= start) {
splitPoint = end;
} else {
splitPoint++;
}
chunks.add(paragraph.substring(start, splitPoint));
start = splitPoint;
end = start + maxParagraphLength;
}
return chunks;
}
}
5.2 向量存储服务
package com.example.rag.service;
import org.springframework.ai.document.Document;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.vectorstore.PgVectorStore;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class VectorStoreService {
private final VectorStore vectorStore;
private final EmbeddingModel embeddingClient;
@Autowired
public VectorStoreService(PgVectorStore vectorStore, EmbeddingModel embeddingClient) {
this.vectorStore = vectorStore;
this.embeddingClient = embeddingClient;
}
public void addDocuments(List<Document> documents) {
vectorStore.add(documents);
}
public List<Document> search(String query, int topK, double similarityThreshold) {
SearchRequest searchRequest = SearchRequest
.query(query)
.topK(topK)
.similarityThreshold(similarityThreshold);
return vectorStore.similaritySearch(searchRequest);
}
public void deleteAll() {
vectorStore.delete(List.of());
}
}
5.3 RAG 问答服务
package com.example.rag.service;
import org.springframework.ai.chat.ChatClient;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.document.Document;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
@Service
public class RagService {
private final ChatClient chatClient;
private final VectorStoreService vectorStoreService;
@Value("${app.rag.top-k:5}")
private int topK;
@Value("${app.rag.similarity-threshold:0.7}")
private double similarityThreshold;
@Autowired
public RagService(ChatClient chatClient, VectorStoreService vectorStoreService) {
this.chatClient = chatClient;
this.vectorStoreService = vectorStoreService;
}
public String query(String question) {
// 1. 从向量数据库检索相关文档
List<Document> relevantDocs = vectorStoreService.search(
question, topK, similarityThreshold
);
if (relevantDocs.isEmpty()) {
return "抱歉,知识库中没有找到相关信息。";
}
// 2. 构建上下文
String context = buildContext(relevantDocs);
// 3. 构建 Prompt
Prompt prompt = buildPrompt(question, context);
// 4. 调用 LLM 生成答案
return chatClient.call(prompt).getResult().getOutput().getContent();
}
private String buildContext(List<Document> documents) {
StringBuilder context = new StringBuilder();
for (int i = 0; i < documents.size(); i++) {
Document doc = documents.get(i);
context.append(String.format("【文档 %d】\n", i + 1));
context.append(doc.getContent());
context.append("\n\n");
}
return context.toString();
}
private Prompt buildPrompt(String question, String context) {
String systemPrompt = """
你是一个专业的技术助手,基于提供的上下文信息回答用户问题。
规则:
1. 只能基于提供的上下文信息回答,不要编造信息
2. 如果上下文没有相关信息,明确说明"无法从知识库中找到相关答案"
3. 回答要简洁明了,直接针对问题
4. 如果有多个相关文档,综合所有信息给出完整答案
""";
String userPrompt = String.format("""
上下文信息:
%s
用户问题:%s
""", context, question);
List<Message> messages = new ArrayList<>();
messages.add(new SystemMessage(systemPrompt));
messages.add(new UserMessage(userPrompt));
return new Prompt(messages);
}
}
5.4 使用 QuestionAnswerAdvisor 简化 RAG(推荐)
Spring AI 提供了 QuestionAnswerAdvisor,可以一键实现 RAG 问答,无需手动构建 Prompt 和检索上下文。
5.4.1 配置 ChatClient
package com.example.rag.config;
import org.springframework.ai.chat.ChatClient;
import org.springframework.ai.chat.advisor.QuestionAnswerAdvisor;
import org.springframework.ai.openai.OpenAiChatClient;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class ChatClientConfig {
@Value("${spring.ai.openai.api-key}")
private String apiKey;
@Value("${spring.ai.openai.chat.options.model}")
private String model;
@Value("${spring.ai.openai.chat.options.temperature}")
private float temperature;
@Bean
public ChatClient ragChatClient(VectorStore vectorStore) {
OpenAiChatOptions options = OpenAiChatOptions.builder()
.withApiKey(apiKey)
.withModel(model)
.withTemperature(temperature)
.build();
return new OpenAiChatClient(options)
.advisors(new QuestionAnswerAdvisor(vectorStore));
}
}
5.4.2 简化版 RAG 服务
package com.example.rag.service;
import org.springframework.ai.chat.ChatClient;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
@Service
public class RagAdvisorService {
private final ChatClient ragChatClient;
public RagAdvisorService(@Qualifier("ragChatClient") ChatClient ragChatClient) {
this.ragChatClient = ragChatClient;
}
public String query(String question) {
return ragChatClient.call(question).getResult().getOutput().getContent();
}
}
5.4.3 两种方式对比
| 维度 | 手动构建方式 | QuestionAnswerAdvisor |
|---|---|---|
| 代码量 | 较多(需手动检索和构建 Prompt) | 极简(一行代码) |
| 灵活性 | 高(完全控制 Prompt 和检索逻辑) | 中等(默认行为,可自定义) |
| 适用场景 | 需要定制化的复杂场景 | 快速开发、标准 RAG 场景 |
| 学习成本 | 较高 | 较低 |
💡 推荐策略:使用
QuestionAnswerAdvisor进行快速开发,当需要更精细的控制时(如自定义 Prompt 模板、混合检索等),再切换到手动构建方式。
5.5 混合检索(向量 + 关键词)
package com.example.rag.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import com.example.rag.entity.DocumentEmbedding;
import java.util.List;
@Repository
public interface DocumentEmbeddingRepository extends JpaRepository<DocumentEmbedding, Long> {
@Query(value = """
SELECT *,
1 - (embedding <=> :queryVector) as similarity,
ts_rank(to_tsvector('english', content), plainto_tsquery('english', :query)) as text_rank
FROM document_embeddings
WHERE embedding <=> :queryVector < :maxDistance
OR to_tsvector('english', content) @@ plainto_tsquery('english', :query)
ORDER BY (similarity * 0.7) + (text_rank * 0.3) DESC
LIMIT :limit
""", nativeQuery = true)
List<DocumentEmbedding> hybridSearch(
@Param("queryVector") float[] queryVector,
@Param("query") String query,
@Param("maxDistance") double maxDistance,
@Param("limit") int limit
);
}
5.5 文档实体类
package com.example.rag.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Entity
@Table(name = "document_embeddings")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class DocumentEmbedding {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(columnDefinition = "TEXT")
private String content;
@Column(columnDefinition = "JSONB")
private String metadata;
@Column(columnDefinition = "vector(1536)")
private float[] embedding;
@Column(name = "token_count")
private Integer tokenCount;
@Column(name = "created_at")
private LocalDateTime createdAt;
}
5.6 REST API 控制器
package com.example.rag.controller;
import org.springframework.ai.document.Document;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import com.example.rag.service.DocumentSplitterService;
import com.example.rag.service.RagAdvisorService;
import com.example.rag.service.RagService;
import com.example.rag.service.VectorStoreService;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/rag")
public class RagController {
private final RagService ragService;
private final RagAdvisorService ragAdvisorService;
private final VectorStoreService vectorStoreService;
private final DocumentSplitterService documentSplitterService;
public RagController(RagService ragService,
RagAdvisorService ragAdvisorService,
VectorStoreService vectorStoreService,
DocumentSplitterService documentSplitterService) {
this.ragService = ragService;
this.ragAdvisorService = ragAdvisorService;
this.vectorStoreService = vectorStoreService;
this.documentSplitterService = documentSplitterService;
}
@PostMapping("/upload")
public ResponseEntity<Map<String, Object>> uploadDocument(
@RequestParam("file") MultipartFile file) throws IOException {
String content = new String(file.getBytes(), StandardCharsets.UTF_8);
String filename = file.getOriginalFilename();
List<Document> documents = documentSplitterService.splitByToken(content, filename);
vectorStoreService.addDocuments(documents);
Map<String, Object> response = new HashMap<>();
response.put("status", "success");
response.put("filename", filename);
response.put("chunks", documents.size());
return ResponseEntity.ok(response);
}
@PostMapping("/query")
public ResponseEntity<Map<String, Object>> query(@RequestBody Map<String, String> request) {
String question = request.get("question");
String method = request.getOrDefault("method", "advisor");
String answer;
if ("manual".equals(method)) {
answer = ragService.query(question);
} else {
answer = ragAdvisorService.query(question);
}
Map<String, Object> response = new HashMap<>();
response.put("question", question);
response.put("answer", answer);
response.put("method", method);
return ResponseEntity.ok(response);
}
@DeleteMapping("/documents")
public ResponseEntity<Map<String, Object>> clearDocuments() {
vectorStoreService.deleteAll();
Map<String, Object> response = new HashMap<>();
response.put("status", "success");
response.put("message", "所有文档已删除");
return ResponseEntity.ok(response);
}
}
六、文档切片策略对比
6.1 策略对比
| 维度 | Token 分割 | 段落分割 |
|---|---|---|
| 粒度控制 | 精确(按 Token 数) | 粗略(按段落) |
| 语义完整性 | 可能切断语义 | 保持段落语义完整 |
| 实现复杂度 | 简单(Spring AI 内置) | 中等(需要自定义) |
| 适用场景 | 技术文档、代码 | 博客文章、报告 |
| Token 浪费 | 较少 | 可能较多 |
6.2 切片效果对比
原始文档:
"Spring Boot 是一个用于构建生产级应用的框架。它简化了 Spring 应用的初始搭建和开发过程。
Spring Boot 自动配置了大量的 Spring 组件,让开发者可以快速上手。"
【Token 分割(chunkSize=30)】
Chunk 1: "Spring Boot 是一个用于构建生产级应用的框架。它简化了 Spring 应用的"
Chunk 2: "应用的初始搭建和开发过程。Spring Boot 自动配置了大量的 Spring 组件,让"
Chunk 3: "让开发者可以快速上手。"
【段落分割】
Chunk 1: "Spring Boot 是一个用于构建生产级应用的框架。它简化了 Spring 应用的初始搭建和开发过程。"
Chunk 2: "Spring Boot 自动配置了大量的 Spring 组件,让开发者可以快速上手。"
6.3 推荐配置
// TokenTextSplitter 推荐配置
// chunkSize: 512-1024(根据 embedding 模型和 LLM 上下文窗口调整)
// chunkOverlap: 100-200(确保上下文连贯性)
TokenTextSplitter splitter = new TokenTextSplitter(512, 100);
七、相似度阈值调优
7.1 阈值影响
| 阈值 | 效果 | 适用场景 |
|---|---|---|
| 0.9+ | 仅返回高度相似的文档 | 精确匹配,如 API 文档查询 |
| 0.8-0.9 | 返回较相似的文档 | 一般问答场景 |
| 0.7-0.8 | 返回相似文档(推荐) | 综合问答,平衡召回率和准确率 |
| 0.6-0.7 | 返回较多相关文档 | 探索性查询,需要更多上下文 |
| < 0.6 | 返回大量文档,包含不相关内容 | 不推荐使用 |
7.2 调优方法
// 逐步调整阈值,观察效果
public class ThresholdTuner {
public static void tuneThreshold(VectorStoreService vectorStoreService,
List<String> testQueries,
List<String> expectedAnswers) {
double[] thresholds = {0.9, 0.85, 0.8, 0.75, 0.7, 0.65};
for (double threshold : thresholds) {
int correct = 0;
for (int i = 0; i < testQueries.size(); i++) {
List<Document> results = vectorStoreService.search(
testQueries.get(i), 5, threshold
);
// 检查检索结果是否包含预期答案
if (containsAnswer(results, expectedAnswers.get(i))) {
correct++;
}
}
System.out.printf("Threshold %.2f: %d/%d correct%n",
threshold, correct, testQueries.size());
}
}
private static boolean containsAnswer(List<Document> documents, String expected) {
return documents.stream()
.anyMatch(doc -> doc.getContent().toLowerCase()
.contains(expected.toLowerCase()));
}
}
八、混合检索实现
8.1 混合检索策略
package com.example.rag.service;
import org.springframework.ai.document.Document;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.example.rag.entity.DocumentEmbedding;
import com.example.rag.repository.DocumentEmbeddingRepository;
import java.util.ArrayList;
import java.util.List;
@Service
public class HybridSearchService {
private final DocumentEmbeddingRepository repository;
private final EmbeddingModel embeddingClient;
@Autowired
public HybridSearchService(DocumentEmbeddingRepository repository,
EmbeddingModel embeddingClient) {
this.repository = repository;
this.embeddingClient = embeddingClient;
}
public List<Document> hybridSearch(String query, int limit, double maxDistance) {
// 生成查询向量
float[] queryVector = embeddingClient.embed(query).toArray(new float[0]);
// 混合检索
List<DocumentEmbedding> results = repository.hybridSearch(
queryVector, query, maxDistance, limit
);
// 转换为 Document 对象
List<Document> documents = new ArrayList<>();
for (DocumentEmbedding entity : results) {
documents.add(new Document(
entity.getContent(),
Map.of(
"source", entity.getMetadata(),
"similarity", 1 - cosineDistance(entity.getEmbedding(), queryVector)
)
));
}
return documents;
}
private double cosineDistance(float[] a, float[] b) {
double dotProduct = 0;
double normA = 0;
double normB = 0;
for (int i = 0; i < a.length; i++) {
dotProduct += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
return 1 - (dotProduct / (Math.sqrt(normA) * Math.sqrt(normB)));
}
}
8.2 混合检索效果对比
查询:"订单流程设计"
【纯向量检索】
返回:"用户登录后可以查看订单列表..."
"订单状态包括:待付款、待发货、已完成..."
【混合检索(向量 + 关键词)】
返回:"订单创建流程:用户选择商品 → 确认订单 → 支付 → 发货..."
"订单状态机设计:待付款 → 已付款 → 已发货 → 已完成..."
"订单表结构设计:order_id, user_id, total_amount..."
"订单消息通知机制:支付成功后发送短信通知..."
九、完整工作流程测试
9.1 上传文档
curl -X POST http://localhost:8080/api/rag/upload \
-F "file=@system-design.md"
响应:
{
"status": "success",
"filename": "system-design.md",
"chunks": 15
}
9.2 提问
curl -X POST http://localhost:8080/api/rag/query \
-H "Content-Type: application/json" \
-d '{"question": "我们系统的订单流程是怎样设计的?"}'
响应:
{
"question": "我们系统的订单流程是怎样设计的?",
"answer": "根据知识库,我们系统的订单流程设计如下:\n\n1. 用户选择商品加入购物车\n2. 点击结算,进入订单确认页面\n3. 确认订单信息后提交订单\n4. 系统调用支付服务完成支付\n5. 支付成功后扣减库存\n6. 通知仓库进行发货\n7. 用户可以查看订单状态\n\n关键设计点:\n- 使用分布式锁保证库存扣减的原子性\n- 支付超时时间设置为 30 分钟\n- 订单状态变更通过消息队列异步通知"
}
十、生产环境注意事项
10.1 性能优化
性能优化策略:
┌─────────────────────────────────────────────────────┐
│ 1. 向量索引优化 │
│ ├─ 使用 IVFFLAT 索引(适合大规模数据) │
│ └─ 根据数据量调整 lists 参数(通常为数据量的平方根)│
│ │
│ 2. 缓存优化 │
│ ├─ 缓存频繁查询的向量 │
│ └─ 使用 Redis 缓存检索结果 │
│ │
│ 3. 异步处理 │
│ ├─ 文档上传异步处理 │
│ └─ 使用消息队列解耦 │
│ │
│ 4. 批量操作 │
│ ├─ 批量生成向量 │
│ └─ 批量插入数据库 │
└─────────────────────────────────────────────────────┘
10.2 数据安全
安全注意事项:
┌─────────────────────────────────────────────────────┐
│ 1. API Key 管理 │
│ ├─ 使用环境变量存储 API Key │
│ └─ 不要硬编码到代码中 │
│ │
│ 2. 输入验证 │
│ ├─ 验证上传文件的类型和大小 │
│ └─ 过滤敏感内容 │
│ │
│ 3. 访问控制 │
│ ├─ 添加 API 认证(JWT/OAuth) │
│ └─ 限制上传和查询权限 │
│ │
│ 4. 数据脱敏 │
│ ├─ 对敏感信息进行脱敏处理 │
│ └─ 不要在上下文中包含密码、密钥等敏感信息 │
└─────────────────────────────────────────────────────┘
10.3 监控与日志
监控指标:
┌─────────────────────────────────────────────────────┐
│ - 向量检索耗时 │
│ - LLM 调用耗时 │
│ - API 调用成功率 │
│ - 知识库文档数量 │
│ - 检索命中率 │
│ - Token 使用量 │
│ - 错误率 │
└─────────────────────────────────────────────────────┘
十一、总结
11.1 RAG 落地关键步骤
落地步骤:
1. 准备环境:PostgreSQL + pgvector + Docker
2. 项目初始化:Spring Boot 3.x + Spring AI 依赖
3. 文档处理:选择合适的切片策略(Token 分割推荐)
4. 向量生成:使用 EmbeddingModel 生成向量
5. 向量存储:使用 PgVectorStore 存储到数据库
6. 检索配置:调优相似度阈值和 topK 参数
7. RAG 问答:构建 Prompt,调用 LLM 生成答案
8. 混合检索:结合向量和关键词检索提升召回率
9. 监控优化:添加监控和日志,持续优化
11.2 常见问题与解决方案
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 检索结果不相关 | 相似度阈值太高 | 降低阈值到 0.7-0.8 |
| 答案不准确 | 上下文不足 | 增加 topK 值或调整切片策略 |
| 响应慢 | 向量检索耗时 | 添加索引或使用缓存 |
| Token 消耗大 | 上下文太长 | 优化切片大小或减少 topK |
| 知识库过期 | 文档没有更新 | 建立文档更新机制 |
11.3 下一步建议
- 添加文档管理功能:支持文档版本管理、增量更新
- 优化 Prompt 工程:根据不同场景设计专用 Prompt
- 集成更多数据源:支持 PDF、Word、Confluence 等
- 添加反馈机制:让用户评价答案质量,持续优化
- 考虑私有化部署:使用开源模型(如 Llama 3)替代 OpenAI
💡 互动话题:你在实践 RAG 的过程中遇到过哪些挑战?有什么优化经验可以分享?欢迎在评论区讨论!
