一、项目结构总览
bash
rag-system/
├── pom.xml # 父POM,依赖管理
├── llm-common/ # 公共模块
│ ├── pom.xml
│ └── src/main/java/com/peanutai/llm/common/
│ ├── exception/
│ │ ├── BusinessException.java
│ │ └── ErrorCode.java
│ └── response/
│ └── Result.java
│
├── llm-service/ # 核心业务模块
│ ├── pom.xml
│ └── src/main/java/com/peanutai/llm/service/
│ ├── config/
│ │ └── LangChain4jConfig.java # ChatModel/EmbeddingModel配置
│ ├── controller/
│ │ ├── ChatController.java # 对话接口
│ │ ├── DocumentController.java # 文档上传
│ │ ├── KnowledgeBaseController.java # 知识库管理
│ │ └── RagController.java # RAG问答
│ ├── model/
│ │ ├── dto/ # 请求/响应DTO
│ │ └── enums/ # 枚举
│ ├── protection/
│ │ └── ContentSafetyService.java # 内容安全
│ ├── rag/
│ │ ├── DocumentProcessor.java # 文档解析+分块
│ │ └── PromptTemplate.java # RAG Prompt模板
│ └── service/
│ ├── ChatService.java # @AiService声明式接口
│ ├── EmbeddingService.java # 向量化服务
│ ├── RagService.java # RAG接口
│ ├── RagServiceImpl.java # RAG核心实现
│ └── VectorStoreService.java # 向量存储+检索
│
└── llm-gateway/ # 网关模块
├── pom.xml
└── src/main/java/com/peanutai/llm/gateway/
└── GatewayApplication.java
二、Step 1:环境搭建(5分钟)
2.1 父POM依赖管理
xml
<properties>
<java.version>17</java.version>
<spring-boot.version>3.2.0</spring-boot.version>
<langchain4j.version>0.35.0</langchain4j.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-bom</artifactId>
<version>${langchain4j.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
关键 :用 langchain4j-bom 统一管理所有LangChain4j子模块版本,避免依赖冲突。
2.2 llm-service模块的POM
xml
<dependencies>
<!-- Spring Boot -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- LangChain4j 核心 -->
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j</artifactId>
</dependency>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-open-ai</artifactId>
</dependency>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-spring-boot-starter</artifactId>
</dependency>
<!-- 向量数据库 -->
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-milvus</artifactId>
</dependency>
<!-- 文档解析 -->
<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>
<!-- 监控 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
</dependencies>
2.3 配置文件
yaml
spring:
application:
name: llm-service
llm:
openai:
api-key: ${DASHSCOPE_API_KEY}
base-url: https://dashscope.aliyuncs.com/compatible-mode/v1
model: qwen-plus
embedding:
model: text-embedding-v3
dimensions: 1024
milvus:
host: ${MILVUS_HOST:localhost}
port: ${MILVUS_PORT:19530}
database: default
management:
endpoints:
web:
exposure:
include: health,metrics,prometheus
三、Step 2:配置LangChain4j(ChatModel + EmbeddingModel)
scss
@Configuration
public class LangChain4jConfig {
@Value("${llm.openai.api-key}")
private String apiKey;
@Value("${llm.openai.model:qwen-plus}")
private String modelName;
@Value("${llm.openai.base-url}")
private String baseUrl;
@Value("${llm.embedding.model:text-embedding-v3}")
private String embeddingModelName;
@Value("${llm.embedding.dimensions:1024}")
private Integer embeddingDimensions;
/**
* 同步对话模型
*/
@Bean
public ChatModel chatModel() {
return OpenAiChatModel.builder()
.apiKey(apiKey)
.modelName(modelName)
.baseUrl(baseUrl)
.timeout(Duration.ofSeconds(30))
.temperature(0.7)
.maxTokens(2000)
.logRequests(true)
.logResponses(true)
.build();
}
/**
* 流式对话模型
*/
@Bean
public StreamingChatModel streamingChatModel() {
return OpenAiStreamingChatModel.builder()
.apiKey(apiKey)
.modelName(modelName)
.baseUrl(baseUrl)
.timeout(Duration.ofSeconds(60))
.temperature(0.7)
.maxTokens(2000)
.build();
}
/**
* Embedding模型
*/
@Bean
public EmbeddingModel embeddingModel() {
return OpenAiEmbeddingModel.builder()
.apiKey(apiKey)
.modelName(embeddingModelName)
.baseUrl(baseUrl)
.dimensions(embeddingDimensions)
.timeout(Duration.ofSeconds(30))
.build();
}
}
四、Step 3:文档解析与分块(DocumentProcessor)
这是RAG的第一步,决定后续检索质量。
java
@Slf4j
@Component
public class DocumentProcessor {
private static final int CHUNK_SIZE = 1000; // 每块1000字符
private static final int CHUNK_OVERLAP = 200; // 重叠200字符
private final Tika tika = new Tika();
/**
* 解析文档,提取纯文本
* 支持:PDF、Word、Excel、PPT、HTML、Markdown等30+种格式
*/
public String parse(MultipartFile file) throws IOException {
try (InputStream is = file.getInputStream()) {
String content = tika.parseToString(is);
if (content == null || content.isBlank()) {
throw new IOException("文档内容为空,无法解析: " + file.getOriginalFilename());
}
return content.strip();
} catch (TikaException e) {
throw new IOException("文档解析失败: " + file.getOriginalFilename(), e);
}
}
/**
* 文本分块:固定长度 + 滑动窗口重叠
* 重叠200字符确保语义不被切断
*/
public List<TextSegment> chunk(String content) {
List<TextSegment> chunks = new ArrayList<>();
int length = content.length();
for (int start = 0; start < length; start += (CHUNK_SIZE - CHUNK_OVERLAP)) {
int end = Math.min(start + CHUNK_SIZE, length);
String chunk = content.substring(start, end).strip();
if (!chunk.isBlank()) {
chunks.add(TextSegment.from(chunk));
}
}
log.info("文档分块完成: 共{}个块", chunks.size());
return chunks;
}
}
分块参数调优经验:
| 场景 | chunkSize | overlap | 说明 |
|---|---|---|---|
| 通用文档 | 1000 | 200 | 适合大多数场景 |
| 问答FAQ | 500 | 100 | 短文本,精度优先 |
| 技术文档 | 1500 | 300 | 代码块需要完整 |
| 法律合同 | 2000 | 400 | 条款需要完整上下文 |
五、Step 4:向量化服务(EmbeddingService)
less
@Slf4j
@Service
@RequiredArgsConstructor
public class EmbeddingService {
private final EmbeddingModel embeddingModel;
private static final int BATCH_SIZE = 10;
/**
* 单条向量化(用于用户问题)
*/
public Embedding embed(String text) {
return embeddingModel.embed(text).content();
}
/**
* 批量向量化(用于文档入库)
* DashScope单次最多10条,需分批
*/
public List<Embedding> embedAll(List<String> texts) {
List<TextSegment> segments = texts.stream()
.map(TextSegment::from)
.collect(Collectors.toList());
List<Embedding> allEmbeddings = new ArrayList<>();
for (int i = 0; i < segments.size(); i += BATCH_SIZE) {
List<TextSegment> batch = segments.subList(
i, Math.min(i + BATCH_SIZE, segments.size()));
List<Embedding> batchResult = embeddingModel.embedAll(batch).content();
allEmbeddings.addAll(batchResult);
}
return allEmbeddings;
}
}
六、Step 5:向量存储与检索(VectorStoreService)
这是RAG最核心的环节,包含了多知识库隔离和相似度检索。
scss
@Slf4j
@Service
public class VectorStoreService {
private static final String KB_ID_FIELD = "knowledgeBaseId";
private final EmbeddingStore<TextSegment> embeddingStore;
public VectorStoreService(EmbeddingStore<TextSegment> embeddingStore) {
this.embeddingStore = embeddingStore;
}
/**
* 批量写入向量
* 为每个文档块写入 knowledgeBaseId 元数据,实现多知识库隔离
*/
public void addAll(String knowledgeBaseId,
List<TextSegment> chunks,
List<Embedding> embeddings) {
if (chunks.size() != embeddings.size()) {
throw new IllegalArgumentException("chunks和embeddings数量不一致");
}
for (int i = 0; i < chunks.size(); i++) {
TextSegment segment = chunks.get(i);
segment.metadata().put(KB_ID_FIELD, knowledgeBaseId);
embeddingStore.add(embeddings.get(i), segment);
}
log.info("向量存储完成: kbId={}, count={}", knowledgeBaseId, chunks.size());
}
/**
* 向量相似度检索
* 通过 knowledgeBaseId 过滤,确保只搜指定知识库
* minScore=0.6 过滤低相似度结果
*/
public List<DocumentMatch> search(String knowledgeBaseId,
Embedding queryEmbedding,
int maxResults) {
Filter kbFilter = new IsEqualTo(KB_ID_FIELD, knowledgeBaseId);
return embeddingStore.search(
EmbeddingSearchRequest.builder()
.queryEmbedding(queryEmbedding)
.maxResults(maxResults)
.minScore(0.6)
.filter(kbFilter)
.build())
.matches().stream()
.map(m -> DocumentMatch.builder()
.content(m.embedded().text())
.score(m.score())
.build())
.collect(Collectors.toList());
}
/**
* 按知识库删除
*/
public void deleteCollection(String knowledgeBaseId) {
Filter kbFilter = new IsEqualTo(KB_ID_FIELD, knowledgeBaseId);
embeddingStore.removeAll(kbFilter);
log.info("向量集合已按知识库删除: kbId={}", knowledgeBaseId);
}
}
多知识库隔离原理:
arduino
Milvus向量库
│
├── knowledgeBaseId: "kb-hr"
│ ├── chunk1: "年假政策:入职满1年..."
│ ├── chunk2: "请假流程:OA系统..."
│ └── chunk3: "考勤制度:上班时间..."
│
├── knowledgeBaseId: "kb-tech"
│ ├── chunk1: "Spring Boot配置..."
│ ├── chunk2: "数据库连接池..."
│ └── chunk3: "RESTful API设计..."
│
└── knowledgeBaseId: "kb-product"
├── chunk1: "产品功能说明..."
└── chunk2: "定价策略..."
查询时通过 new IsEqualTo("knowledgeBaseId", "kb-hr") 过滤,只返回人事制度相关内容。
七、Step 6:Prompt模板(PromptTemplate)
typescript
@Component
public class PromptTemplate {
private static final String RAG_SYSTEM_PROMPT = """
你是一个智能知识库助手,请根据以下参考资料回答用户问题。
规则:
1. 仅基于参考资料回答,不要编造信息
2. 如果参考资料不足以回答问题,请明确说明
3. 回答要简洁、准确、有条理
4. 使用中文回答
参考资料:
%s
""";
public String build(String question, String context) {
return String.format(RAG_SYSTEM_PROMPT, context) + "\n\n用户问题:" + question;
}
}
八、Step 7:RAG核心服务(RagServiceImpl)
这是整个系统的核心,串联了所有环节。
java
@Slf4j
@Service
public class RagServiceImpl implements RagService {
private final DocumentProcessor documentProcessor;
private final EmbeddingService embeddingService;
private final VectorStoreService vectorStoreService;
private final ChatModel chatModel;
private final StreamingChatModel streamingChatModel;
private final PromptTemplate promptTemplate;
private final ContentSafetyService contentSafetyService;
public RagServiceImpl(DocumentProcessor documentProcessor,
EmbeddingService embeddingService,
VectorStoreService vectorStoreService,
ChatModel chatModel,
StreamingChatModel streamingChatModel,
PromptTemplate promptTemplate,
ContentSafetyService contentSafetyService) {
this.documentProcessor = documentProcessor;
this.embeddingService = embeddingService;
this.vectorStoreService = vectorStoreService;
this.chatModel = chatModel;
this.streamingChatModel = streamingChatModel;
this.promptTemplate = promptTemplate;
this.contentSafetyService = contentSafetyService;
}
// ==================== 文档入库 ====================
@Override
public void ingestDocument(MultipartFile file, String knowledgeBaseId) {
log.info("开始处理文档入库: file={}, kbId={}",
file.getOriginalFilename(), knowledgeBaseId);
try {
// ① 解析文档
String content = documentProcessor.parse(file);
// ② 文本分块
List<TextSegment> chunks = documentProcessor.chunk(content);
// ③ 批量向量化
List<Embedding> embeddings = embeddingService.embedAll(
chunks.stream().map(TextSegment::text).collect(Collectors.toList()));
// ④ 写入向量库
vectorStoreService.addAll(knowledgeBaseId, chunks, embeddings);
log.info("文档入库完成: file={}", file.getOriginalFilename());
} catch (IOException e) {
log.error("文档解析失败", e);
throw new BusinessException(ErrorCode.DOCUMENT_PARSE_ERROR);
}
}
// ==================== RAG查询 ====================
@Override
public RagResponse query(String question, String knowledgeBaseId) {
long startTime = System.currentTimeMillis();
// [1] 输入安全校验
contentSafetyService.validateInput(question);
// [2] 问题向量化
Embedding questionEmbedding = embeddingService.embed(question);
// [3] 向量检索Top-5
List<DocumentMatch> matches = vectorStoreService.search(
knowledgeBaseId, questionEmbedding, 5);
// [4] 拼接上下文
String context = matches.stream()
.map(DocumentMatch::getContent)
.collect(Collectors.joining("\n\n"));
// [5] 构造Prompt
String prompt = promptTemplate.build(question, context);
// [6] 调用大模型
ChatRequest chatRequest = ChatRequest.builder()
.messages(UserMessage.from(prompt))
.build();
ChatResponse response = chatModel.chat(chatRequest);
// [7] 输出审核
String answer = response.aiMessage().text();
contentSafetyService.auditOutput(answer);
// [8] 构造响应
long latency = System.currentTimeMillis() - startTime;
TokenUsage tokenUsage = response.tokenUsage();
return RagResponse.builder()
.answer(answer)
.sources(convertToSources(matches))
.tokenUsage(com.peanutai.llm.service.model.dto.TokenUsage.builder()
.inputTokens(tokenUsage.inputTokenCount())
.outputTokens(tokenUsage.outputTokenCount())
.totalTokens(tokenUsage.totalTokenCount())
.build())
.latencyMs(latency)
.model("qwen-plus")
.build();
}
// ==================== 流式RAG查询 ====================
@Override
public SseEmitter queryStream(String question, String knowledgeBaseId) {
SseEmitter emitter = new SseEmitter(30000L);
CompletableFuture.runAsync(() -> {
try {
// 同样的RAG流程,但用流式模型
contentSafetyService.validateInput(question);
Embedding questionEmbedding = embeddingService.embed(question);
List<DocumentMatch> matches = vectorStoreService.search(
knowledgeBaseId, questionEmbedding, 5);
String context = matches.stream()
.map(DocumentMatch::getContent)
.collect(Collectors.joining("\n\n"));
String prompt = promptTemplate.build(question, context);
streamingChatModel.chat(prompt, new StreamingChatResponseHandler() {
@Override
public void onPartialResponse(String partialResponse) {
try {
emitter.send(SseEmitter.event()
.name("message")
.data(partialResponse));
} catch (IOException e) {
log.error("SSE发送失败", e);
}
}
@Override
public void onCompleteResponse(ChatResponse completeResponse) {
try {
// 发送来源文档
emitter.send(SseEmitter.event()
.name("sources")
.data(convertToSources(matches)));
emitter.complete();
} catch (IOException e) {
log.error("SSE完成发送失败", e);
}
}
@Override
public void onError(Throwable error) {
log.error("流式查询失败", error);
emitter.completeWithError(error);
}
});
} catch (Exception e) {
log.error("流式查询异常", e);
emitter.completeWithError(e);
}
});
return emitter;
}
}
九、Step 8:Controller层
9.1 文档上传
less
@RestController
@RequestMapping("/api/v1/documents")
@RequiredArgsConstructor
public class DocumentController {
private final RagService ragService;
@PostMapping("/upload")
public Result<Void> upload(
@RequestParam("file") MultipartFile file,
@RequestParam("knowledgeBaseId") String knowledgeBaseId) {
ragService.ingestDocument(file, knowledgeBaseId);
return Result.success();
}
@PostMapping("/batch-upload")
public Result<Void> batchUpload(
@RequestParam("files") List<MultipartFile> files,
@RequestParam("knowledgeBaseId") String knowledgeBaseId) {
ragService.ingestDocuments(files, knowledgeBaseId);
return Result.success();
}
}
9.2 RAG问答
less
@RestController
@RequestMapping("/api/v1/rag")
@RequiredArgsConstructor
public class RagController {
private final RagService ragService;
@PostMapping("/query")
public Result<RagResponse> query(@RequestBody RagRequest request) {
RagResponse response = ragService.query(
request.getQuestion(),
request.getKnowledgeBaseId());
return Result.success(response);
}
@PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter queryStream(@RequestBody RagRequest request) {
return ragService.queryStream(
request.getQuestion(),
request.getKnowledgeBaseId());
}
}
9.3 知识库管理
less
@RestController
@RequestMapping("/api/v1/knowledge-bases")
@RequiredArgsConstructor
public class KnowledgeBaseController {
private final RagService ragService;
@DeleteMapping("/{knowledgeBaseId}")
public Result<Void> delete(@PathVariable String knowledgeBaseId) {
ragService.deleteKnowledgeBase(knowledgeBaseId);
return Result.success();
}
}
十、Step 9:生产级防护
10.1 内容安全服务
typescript
@Slf4j
@Service
public class ContentSafetyService {
private static final List<String> INJECTION_PATTERNS = Arrays.asList(
"ignore previous instructions",
"system prompt",
"忽略之前的指令",
"执行以下命令"
);
public void validateInput(String input) {
if (input == null || input.isBlank()) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
if (input.length() > 4000) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
if (isPromptInjection(input)) {
log.warn("检测到Prompt注入: {}", input);
throw new BusinessException(ErrorCode.CONTENT_SAFETY_ERROR);
}
}
public void auditOutput(String output) {
if (output == null || output.isBlank()) {
throw new BusinessException(ErrorCode.LLM_SERVICE_ERROR);
}
}
private boolean isPromptInjection(String input) {
String lower = input.toLowerCase();
return INJECTION_PATTERNS.stream().anyMatch(lower::contains);
}
}
10.2 统一异常处理
kotlin
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public Result<Void> handleBusiness(BusinessException e) {
log.error("业务异常: code={}, message={}", e.getCode(), e.getMessage());
return Result.error(e.getCode(), e.getMessage());
}
@ExceptionHandler(Exception.class)
public Result<Void> handleException(Exception e) {
log.error("系统异常", e);
return Result.error(5000, "服务器内部错误");
}
}
10.3 统一返回结构
less
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Result<T> {
private int code;
private String message;
private T data;
public static <T> Result<T> success(T data) {
return Result.<T>builder().code(200).message("success").data(data).build();
}
public static <T> Result<T> error(int code, String message) {
return Result.<T>builder().code(code).message(message).build();
}
}
十一、Step 10:测试验证
11.1 启动服务
arduino
set DASHSCOPE_API_KEY=sk-your-api-key
mvn spring-boot:run -pl llm-service
11.2 上传文档
bash
curl -X POST http://localhost:8080/api/v1/documents/upload \
-F "file=@employee-handbook.pdf" \
-F "knowledgeBaseId=kb-hr"
返回:
json
{
"code"
:
200
,
"message"
:
"success"
,
"data"
:
null
}
11.3 RAG问答
ruby
curl -X POST http://localhost:8080/api/v1/rag/query \
-H "Content-Type: application/json" \
-d '{"question":"入职半年能请年假吗?","knowledgeBaseId":"kb-hr"}'
返回:
css
{
"code"
:
200
,
"message"
:
"success"
,
"data"
:
{
"answer"
:
"根据公司员工年假政策,入职满1年才享有年假。您目前入职半年,暂不满足条件。"
,
"sources"
:
[
{
"fileName"
:
"employee-handbook.pdf"
,
"content"
:
"入职满1年享有5天年假..."
,
"similarityScore"
:
0.89
}
]
,
"tokenUsage"
:
{
"inputTokens"
:
520
,
"outputTokens"
:
45
,
"totalTokens"
:
565
}
,
"latencyMs"
:
850
,
"model"
:
"qwen-plus"
}
}
11.4 流式问答
ruby
curl -N -X POST http://localhost:8080/api/v1/rag/stream \
-H "Content-Type: application/json" \
-d '{"question":"年假怎么申请?","knowledgeBaseId":"kb-hr"}'
返回(逐字输出):
vbnet
event:message
data:根据公司
event:message
data:《员工手册》
event:message
data:第3.2条
...
event:sources
data:[{"fileName":"employee-handbook.pdf",...}]
十二、RAG效果调优
12.1 参数调优指南
| 参数 | 默认值 | 调优方向 | 效果 |
|---|---|---|---|
| chunkSize | 1000 | 减小→精度↑ 增大→上下文↑ | 500-2000 |
| chunkOverlap | 200 | 增大→语义连贯↑ token↑ | 100-400 |
| maxResults(Top-K) | 5 | 增大→覆盖↑ 噪声↑ | 3-10 |
| minScore | 0.6 | 增大→精度↑ 召回↓ | 0.5-0.8 |
| temperature | 0.7 | 减小→确定↑ 创意↓ | 0.3-1.0 |
12.2 常见问题与解决
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 检索不到相关文档 | minScore太高 | 降到0.5 |
| 检索到无关文档 | Top-K太大 | 减到3,加minScore |
| 回答不准确 | 上下文太短 | 增大chunkSize |
| 回答断章取义 | 分块切断了语义 | 增大overlap |
| 延迟太高 | 检索+生成慢 | 加缓存、降Top-K |
| Token消耗高 | 上下文太长 | 减chunkSize、减Top-K |
十三、总结
13.1 完整链路回顾
scss
文档上传 → Tika解析 → 文本分块(1000+200) → 批量向量化(1024维)
→ 写入Milvus(带knowledgeBaseId元数据) → 入库完成
用户提问 → 输入安全校验 → 问题向量化 → 向量检索(Top-5, minScore≥0.6)
→ 拼接上下文 → 构造Prompt → 大模型生成 → 输出审核 → 返回答案
13.2 关键数字
| 指标 | 数值 |
|---|---|
| 代码行数 | ~500行(核心逻辑) |
| 文档格式支持 | 30+(Tika) |
| 向量维度 | 1024(通义千问v3) |
| 分块策略 | 1000字符 + 200重叠 |
| 检索Top-K | 5 |
| 相似度阈值 | 0.6 |
| 平均延迟 | 800-1200ms |
| 平均Token | 500-600/次 |
13.3 下一步
这个RAG系统已经可以投入生产使用。下一篇文章将介绍RAG的进阶优化:查询改写、重排序、缓存策略,进一步提升检索效果。