三步将 AI(大模型--智谱)RAG-增强检索-- 整合进 Spring Boot项目,
在开始三步集成前,先确保你已准备好以下基础环境:
JDK 17+:Spring Boot 3 和 Spring AI 基于较新的 Java 版本,确保你的 JDK 版本不低于 17。
Maven 或 Gradle:本文以 Maven 为例,它是我们管理项目依赖的得力助手。
一个顺手的 IDE:选择你最熟悉的 IDE 即可,本文以 IntelliJ IDEA 为例。
一个 LLM厂商的 API Key:本文以 DeepSeek 为例,价格便宜,使用起来很方便。
第一步:添加依赖
让我们从创建项目开始。如果你使用 Spring Initializr 会更简单,但为了让大家理解每一步的细节,我们选择从零开始创建一个 Maven 项目。
首先,创建一个新的Maven空项目:
————————————————
<?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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.0</version>
</parent>
<groupId>com.example</groupId>
<artifactId>spring-ai-demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<properties>
<java.version>21</java.version>
<spring-ai.version>1.0.0-M5</spring-ai.version>
</properties>
<dependencies>
<!-- Spring Boot Web Starter -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring AI OpenAI Starter -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
</dependency>
</dependencies>
<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>
</project>
第二步:配置凭证
有了依赖,接下来需要告诉 Spring AI 如何连接到 AI 服务。在 src/main/resources 目录下创建 application.yml 文件(如果不存在的话),添加以下配置:
server:
error:
include-message: always
include-stacktrace: always
spring:
ai:
openai:
api-key: **************************************
base-url: https://open.bigmodel.cn
chat:
completions-path: /api/paas/v4/chat/completions
options:
model: glm-5.1
第三步:编写代码调用
万事俱备,让我们开始编写代码来调用 AI 模型。
package com.ai.controller;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.InMemoryChatMemory;
import org.springframework.ai.image.ImageModel;
import org.springframework.ai.image.ImagePrompt;
import org.springframework.ai.image.ImageResponse;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
@RestController
@RequestMapping("/chat")
public class ChatController {
private final ChatClient chatClient;
private final ChatMemory chatMemory; // 新增:对话记忆
private final ImageModel imageModel;
public ChatController(ChatClient.Builder chatClientBuilder, ImageModel imageModel) {
this.chatClient = chatClientBuilder.build();
this.chatMemory = new InMemoryChatMemory(); // 初始化内存记忆
this.imageModel = imageModel;
}
// 带历史的对话接口
@GetMapping("/with-history")
public String chatWithHistory(@RequestParam String message,
@RequestParam(defaultValue = "default") String sessionId) {
return chatClient.prompt()
.user(message)
.advisors(new MessageChatMemoryAdvisor(chatMemory, sessionId, 10))
.call()
.content();
}
//因为大模型配置的url不支持图片调用。所以需要单独调用图片模型。
@GetMapping("/image")
public String generateImage(@RequestParam String prompt) {
ImageResponse response = imageModel.call(new ImagePrompt(prompt));
return response.getResult().getOutput().getUrl();
}
@PostMapping("/analyze")
public String analyzeFile(@RequestParam("file") MultipartFile file) throws IOException {
String content = new String(file.getBytes());
String prompt = "请分析以下文档内容,并给出摘要:\n" + content;
return chatClient.prompt()
.user(prompt)
.call()
.content();
}
}
package com.ai.controller;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("/chat/*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}
package com.ai.controller;
import org.springframework.ai.document.Document;
import org.springframework.ai.embedding.AbstractEmbeddingModel;
import org.springframework.ai.embedding.Embedding;
import org.springframework.ai.embedding.EmbeddingRequest;
import org.springframework.ai.embedding.EmbeddingResponse;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
/**
* 学习/本地开发用的 EmbeddingModel:
* <ul>
* <li>不依赖外部向量服务或厂商 embedding 接口,避免因网络/网关不兼容导致 RAG 跑不通。</li>
* <li>用一个简单的“hash bag-of-words”把文本映射到固定维度向量(可重复、确定性)。</li>
* </ul>
*
* <p>注意:该实现只用于演示 RAG 流程,不具备真实 embedding 的语义能力;生产环境请替换为正规 embedding 模型。</p>
*/
@Component
@Primary
@ConditionalOnProperty(value = "app.rag.embedding", havingValue = "local", matchIfMissing = true)
public class LocalHashEmbeddingModel extends AbstractEmbeddingModel {
private static final int DIMS = 384;
@Override
public EmbeddingResponse call(EmbeddingRequest request) {
List<String> inputs = request.getInstructions();
List<Embedding> results = new ArrayList<>(inputs.size());
for (int i = 0; i < inputs.size(); i++) {
results.add(new Embedding(embedText(inputs.get(i)), i));
}
return new EmbeddingResponse(results);
}
@Override
public float[] embed(Document document) {
return embedText(document.getText());
}
private float[] embedText(String text) {
float[] v = new float[DIMS];
if (text == null || text.isBlank()) {
return v;
}
// 基于 token 的简易 hashing:把 token hash 到维度桶里累加
// 为了演示稳定性,使用 UTF-8 bytes 做 hash。
String[] tokens = text.toLowerCase().split("\\s+");
for (String token : tokens) {
if (token.isBlank()) continue;
int h = fnv1a32(token.getBytes(StandardCharsets.UTF_8));
int idx = Math.floorMod(h, DIMS);
v[idx] += 1.0f;
}
// L2 normalize,便于余弦相似度比较
double norm = 0.0;
for (float x : v) norm += (double) x * x;
norm = Math.sqrt(norm);
if (norm > 0.0) {
for (int i = 0; i < v.length; i++) {
v[i] = (float) (v[i] / norm);
}
}
return v;
}
private int fnv1a32(byte[] data) {
int hash = 0x811c9dc5;
for (byte b : data) {
hash ^= (b & 0xff);
hash *= 0x01000193;
}
return hash;
}
}
package com.ai.controller;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.vectorstore.SimpleVectorStore;
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;
import java.io.File;
@Configuration
public class RagConfig {
/**
* RAG 向量库(VectorStore)的最简实现:
* <ul>
* <li>使用 {@link SimpleVectorStore}:不依赖外部数据库,适合入门与本地开发。</li>
* <li>依赖 {@link EmbeddingModel} 将文本片段向量化后存储,供相似度检索使用。</li>
* <li>可选落盘:重启应用后仍能继续使用已入库的内容。</li>
* </ul>
*
* <p>注意:生产环境通常会替换为 Milvus/pgvector/Elastic 等持久化向量库。</p>
*/
@Bean
public VectorStore vectorStore(
EmbeddingModel embeddingModel,
@Value("${app.rag.store-path:data/vectorstore.json}") String storePath
) {
var store = new SimpleVectorStore(embeddingModel);
var file = new File(storePath);
if (file.exists() && file.isFile()) {
// 本地开发:启动时自动加载上次保存的向量库快照
store.load(file);
} else {
var parent = file.getParentFile();
if (parent != null) {
// 确保目录存在;如果创建失败,后续 save() 会抛出更明确的异常
parent.mkdirs();
}
}
return store;
}
}
package com.ai.controller;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.document.Document;
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.SimpleVectorStore;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping("/rag")
public class RagController {
/**
* 这个 Controller 演示“进阶版”的核心:RAG(Retrieval-Augmented Generation,检索增强生成)。
*
* <p>RAG 的基本流程:</p>
* <ol>
* <li>Ingest:把文档切分为多个 chunk(片段),每个 chunk 做 embedding 并写入 VectorStore。</li>
* <li>Ask:对问题做 embedding 检索出最相关的 chunk,把这些 chunk 作为“资料”拼进 prompt 让模型回答。</li>
* </ol>
*
* <p>这里用 {@link SimpleVectorStore} 作为最简向量库实现,便于你先跑通思路;后续再替换为真正的向量数据库。</p>
*/
private final ChatClient chatClient;
private final VectorStore vectorStore;
private final TokenTextSplitter textSplitter;
private final String storePath;
public RagController(
ChatClient.Builder chatClientBuilder,
VectorStore vectorStore,
@Value("${app.rag.store-path:data/vectorstore.json}") String storePath
) {
this.chatClient = chatClientBuilder.build();
this.vectorStore = vectorStore;
this.storePath = storePath;
// TokenTextSplitter:按 token 粗略切分文本,避免单次 embedding/检索的文本过长
this.textSplitter = TokenTextSplitter.builder().build();
}
/**
* 文档入库:上传一个文件,把内容切分为多个 chunk 写入向量库。
*
* <p>Postman 测试:</p>
* <ul>
* <li>POST /rag/ingest</li>
* <li>Body=form-data,字段名必须是 file(类型选 File)</li>
* </ul>
*
* <p>返回 docId 方便你做追踪(metadata 里也会带上)。</p>
*/
@PostMapping(value = "/ingest", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public Map<String, Object> ingest(
@RequestParam("file") MultipartFile file,
@RequestParam(value = "source", required = false) String source
) throws IOException {
String text = new String(file.getBytes(), StandardCharsets.UTF_8);
String docId = UUID.randomUUID().toString();
Map<String, Object> baseMetadata = new HashMap<>();
baseMetadata.put("docId", docId);
baseMetadata.put("filename", file.getOriginalFilename());
baseMetadata.put("source", source != null ? source : "upload");
// 先构建一个“整篇文档” Document,再切分成 chunks;每个 chunk 继承 metadata
List<Document> chunks = textSplitter.apply(List.of(new Document(text, baseMetadata)));
vectorStore.add(chunks);
// SimpleVectorStore 支持落盘;便于本地开发重启后继续使用之前入库的内容
persistIfPossible();
return Map.of(
"docId", docId,
"chunks", chunks.size()
);
}
/**
* 带检索的问答:从向量库中检索与问题最相近的 topK 个片段,作为“资料”交给模型回答。
*
* <p>返回里会带 contexts(检索到的片段),方便你观察:</p>
* <ul>
* <li>检索是否命中正确内容</li>
* <li>topK 取值该怎么调</li>
* </ul>
*/
@GetMapping("/ask")
public Map<String, Object> ask(
@RequestParam String question,
@RequestParam(defaultValue = "4") int topK
) {
List<Document> docs = vectorStore.similaritySearch(SearchRequest.query(question).withTopK(topK));
String context = buildContext(docs);
String prompt = """
你是一个严谨的助手。请只基于【资料】回答【问题】。如果资料不足以回答,请明确说“不确定/资料不足”,并说明缺少什么信息。
【资料】
%s
【问题】
%s
""".formatted(context, question);
String answer = chatClient.prompt()
.user(prompt)
.call()
.content();
return Map.of(
"answer", answer,
"topK", topK,
"contexts", docs.stream().map(d -> Map.of(
"id", d.getId(),
"score", d.getScore(),
"metadata", d.getMetadata(),
"content", d.getContent()
)).toList()
);
}
private String buildContext(List<Document> docs) {
if (docs.isEmpty()) {
return "(无检索结果)";
}
StringBuilder sb = new StringBuilder();
for (int i = 0; i < docs.size(); i++) {
Document d = docs.get(i);
sb.append("[").append(i + 1).append("] ");
Object filename = d.getMetadata().getOrDefault("filename", "");
Object docId = d.getMetadata().getOrDefault("docId", "");
sb.append("(docId=").append(docId).append(", file=").append(filename).append(")\n");
sb.append(d.getContent()).append("\n\n");
}
return sb.toString();
}
private void persistIfPossible() {
if (vectorStore instanceof SimpleVectorStore svs) {
// 仅对 SimpleVectorStore 生效:把内存向量库保存为本地文件
svs.save(new File(storePath));
}
}
}
package com.ai.controller;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class SpringAiDemoApplication {
public static void main(String[] args) {
SpringApplication.run(SpringAiDemoApplication.class, args);
}
}
说明:
RAG 是什么(用你项目的话讲)
RAG = 检索增强生成:先把文档切成小段并“向量化”存起来/ingest;提问时先从向量库里检索最相关的几段,再把这些“资料”连同问题一起交给大模型回答/ask。
Postman 怎么测
- 需要分析的文件,入本地库-->>目前用的文件夹
POST http://localhost:8080/rag/ingest- Body →
form-datafile(File) 选一个 txt/md 等文本文件source(Text) 可填manual
- 提问
GET http://localhost:8080/rag/ask?question=你文档里提到的xxx是什么&topK=4
返回会包含:
answer:模型回答
contexts:检索到的片段(含 metadata/score)
你现在项目已经做到:
- Spring Boot 已集成 Spring AI,并且 chat 已对接智谱(
base-url=https://open.bigmodel.cn+chat.completions-path=/api/paas/v4/chat/completions),/chat/with-history、/chat/analyze这类“文本对话/总结”可以走智谱。 - 进阶能力(RAG)代码已经加上了:
/rag/ingest+/rag/ask,并且能跑通“入库→检索→拼资料→回答”的流程。
那如果我要分析项目中的某个类的代码呢,怎么实现?
在你现有项目上加一个“代码分析”接口:传入 Java 文件路径或类全限定名,服务端读取源码内容(限制在 src/main/java 内,防止路径穿越),再调用当前的 chatClient 让大模型做解释/风险点/改进建议。下面我直接把这个功能加到项目里,并给你 Postman 的测试方式。
package com.ai.controller;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.http.MediaType;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Map;
@RestController
@RequestMapping("/code")
public class CodeAnalysisController {
private final ChatClient chatClient;
private final Path projectRoot;
/**
* 代码分析接口(面向学习):
* <ul>
* <li>传入类全限定名(fqcn)或相对路径(path)</li>
* <li>服务端读取源码后交给大模型做讲解/问题点/改进建议</li>
* </ul>
*
* <p>安全约束:仅允许读取 src/main/java 下的 .java 文件。</p>
*/
public CodeAnalysisController(ChatClient.Builder chatClientBuilder) {
this.chatClient = chatClientBuilder.build();
this.projectRoot = Paths.get("").toAbsolutePath().normalize();
}
@GetMapping(value = "/analyze", produces = MediaType.APPLICATION_JSON_VALUE)
public Map<String, Object> analyze(
@RequestParam(required = false) String fqcn,
@RequestParam(required = false) String path
) throws IOException {
Path javaFile = resolveJavaFile(fqcn, path);
String code = Files.readString(javaFile, StandardCharsets.UTF_8);
// 避免一次塞太多 token(学习项目先做个简单上限)
int maxChars = 40_000;
if (code.length() > maxChars) {
code = code.substring(0, maxChars) + "\n\n// ... truncated ...\n";
}
String prompt = """
你是资深 Java/Spring Boot 工程师。请对下面这段源码进行分析,输出要点清晰的学习笔记:
1) 这个类的职责/对外接口/关键依赖
2) 关键逻辑流程(按调用顺序)
3) 潜在问题:异常处理、边界条件、并发/性能、安全风险
4) 可改进建议:可读性、可测试性、可扩展性(给出具体改法)
5) 如果是 Controller,请补充如何用 Postman 测试
【文件路径】
%s
【源码】
```java
%s
```
""".formatted(javaFile.toString(), code);
String result = chatClient.prompt()
.user(prompt)
.call()
.content();
return Map.of(
"file", javaFile.toString(),
"analysis", result
);
}
private Path resolveJavaFile(String fqcn, String path) {
if (!StringUtils.hasText(fqcn) && !StringUtils.hasText(path)) {
throw new IllegalArgumentException("Either 'fqcn' or 'path' must be provided.");
}
if (StringUtils.hasText(fqcn) && StringUtils.hasText(path)) {
throw new IllegalArgumentException("Provide only one: 'fqcn' or 'path'.");
}
Path srcRoot = projectRoot.resolve("src/main/java").normalize();
Path resolved;
if (StringUtils.hasText(fqcn)) {
String rel = fqcn.trim().replace('.', '/') + ".java";
resolved = srcRoot.resolve(rel);
} else {
// 允许传 "com/ai/controller/ChatController.java" 或 "src/main/java/..."
String p = path.trim().replace('\\', '/');
if (p.startsWith("src/main/java/")) {
p = p.substring("src/main/java/".length());
}
resolved = srcRoot.resolve(p);
}
resolved = resolved.toAbsolutePath().normalize();
if (!resolved.startsWith(srcRoot.toAbsolutePath().normalize())) {
throw new IllegalArgumentException("Path is not allowed.");
}
if (!resolved.toString().endsWith(".java")) {
throw new IllegalArgumentException("Only .java files are allowed.");
}
if (!Files.exists(resolved) || !Files.isRegularFile(resolved)) {
throw new IllegalArgumentException("File not found: " + resolved);
}
return resolved;
}
}
已在项目里加好了“分析某个类源码”的实现:新增了 CodeAnalysisController,提供接口 GET /code/analyze,会读取 src/main/java 下的 .java 文件并交给大模型输出学习笔记。
怎么用(Postman)
更多推荐



所有评论(0)