如何使用Spring AI和OpenAI打造文件上传式RAG聊天机器人
在本文中,我们将学习如何利用Spring AI和Spring Web构建一个功能完备且具备检索增强生成(RAG)能力的聊天机器人应用程序,同时还会为其配备用户界面(UI)。通过逐步的详细讲解,从前期准备到具体的代码实现,再到最终的演示,带你深入了解整个开发过程。
一、前置条件
在开始演示前,你需要拥有一个OpenAI API密钥。这个密钥用于在调用OpenAI的大语言模型时进行身份验证,本项目会用到text-embedding-ada-002
模型生成向量嵌入,以及gpt-3.5-turbo
模型提供聊天支持。获取密钥后,将其和模型名称添加到应用程序的属性文件中:
spring.ai.openai.api-key=${OPENAI_API_KEY} spring.ai.openai.model=gpt-3.5-turbo spring.ai.openai.embedding.enabled=true spring.ai.openai.embedding.model=text-embedding-ada-002
二、Maven依赖配置
创建一个新的Spring Boot项目后,需要添加一些依赖。关于如何完整地设置这些依赖,可以参考Spring AI的入门指南。在pom.xml
文件中添加以下依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.thymeleaf.extras</groupId> <artifactId>thymeleaf-extras-springsecurity6</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-openai-spring-boot-starter</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-tika-document-reader</artifactId> </dependency>
三、向量存储配置
Spring AI在检测到相关可用类后,会自动创建一个带有默认值的EmbeddingModel
类型的bean。我们可以使用特定供应商的属性来自定义嵌入选项。以OpenAI嵌入模型为例,可通过修改相关属性进行定制:
spring.ai.openai.embedding.options.model=text-embedding-ada-002 spring.ai.openai.embedding.metadata-mode=EMBED spring.ai.openai.chat.embeddings-path=/v1/embeddings
配置好EmbeddingModel
后,就可以使用向量存储了。Spring支持多种向量存储,在本演示中,我们使用Spring提供的内存型向量存储SimpleVectorStore
。下面是配置向量存储的代码:
import org.springframework.ai.embedding.EmbeddingModel; import org.springframework.ai.vectorstore.SimpleVectorStore; import org.springframework.ai.vectorstore.VectorStore; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class VectorStoreConfig { // 定义一个名为simleVectorStore的Bean,用于创建SimpleVectorStore实例 @Bean VectorStore simleVectorStore(EmbeddingModel embeddingModel) { // 使用传入的EmbeddingModel实例创建SimpleVectorStore对象 return new SimpleVectorStore(embeddingModel); } }
四、文件上传与嵌入向量生成
在基于大语言模型的应用中,检索增强生成(RAG)的核心是在生成回复前获取并处理外部信息,这样能让回复更有内容、更切题。在有用户界面的应用里,最常见的数据获取方式就是文件上传。在后端,我们读取文件内容,用配置好的EmbeddingModel
生成嵌入向量,再通过vectorStore.add()
方法把向量存储到向量数据库中。下面是处理文件上传的控制器代码:
import java.io.IOException; import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardCopyOption; import java.util.List; import org.slf4j.Logger; import org.springframework.ai.document.Document; import org.springframework.ai.reader.tika.TikaDocumentReader; import org.springframework.ai.transformer.splitter.TokenTextSplitter; import org.springframework.ai.vectorstore.VectorStore; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; @RestController public class UploadController { // 定义日志记录器,用于记录UploadController类相关的日志信息 private static final Logger LOG = org.slf4j.LoggerFactory.getLogger(UploadController.class); // 注入向量存储实例,用于存储生成的嵌入向量 private final VectorStore vectorStore; // 构造函数,用于初始化向量存储实例 public UploadController(VectorStore vectorStore) { this.vectorStore = vectorStore; } @PostMapping("/upload") public UploadResponse upload(@RequestParam("file") MultipartFile file) throws IOException { // 将上传的文件保存到临时位置 Path destinationFile = Paths.get("/temp").resolve(Paths.get(file.getOriginalFilename())).normalize().toAbsolutePath(); try (InputStream inputStream = file.getInputStream()) { // 复制文件内容到临时文件 Files.copy(inputStream, destinationFile, StandardCopyOption.REPLACE_EXISTING); } // 使用TikaDocumentReader读取并分割文档内容 TikaDocumentReader documentReader = new TikaDocumentReader(destinationFile.toUri().toString()); List<Document> documents = documentReader.get(); List<Document> splitDocuments = new TokenTextSplitter().apply(documents); // 将分割后的文档添加到向量存储中,同时生成并存储嵌入向量 vectorStore.add(splitDocuments); // 返回上传文件的相关信息 return new UploadResponse(file.getOriginalFilename(), file.getContentType(), file.getSize()); } // 定义一个记录类,用于封装上传文件的名称、类型和大小信息 private static record UploadResponse(String fileName, String fileType, long fileSize) { } }
此外,我们可以通过spring.servlet.multipart.*
属性来控制上传文件的大小:
spring.servlet.multipart.max-file-size=50MB spring.servlet.multipart.max-request-size=50MB
五、聊天控制器
聊天应用通过调用REST API来发送用户查询并展示AI的回复。下面这个REST API接收Question
对象中的用户提示,并以Answer
对象返回回复。该API使用org.springframework.ai.chat.client.ChatClient
将用户问题发送给大语言模型(如GPT模型),同时使用QuestionAnswerAdvisor
通过在额外提示中添加与用户文本相关的上下文信息,来实现RAG模式。
private static final String DEFAULT_USER_TEXT_ADVISE = """ Context information is below. --------------------- {question_answer_context} --------------------- Given the context and provided history information and not prior knowledge, reply to the user comment. If the answer is not in the context, inform the user that you can't answer the question. """;
另外,InMemoryChatMemory
用于在内存中存储聊天对话历史,以便在整个对话过程中进一步优化用户提示的上下文。下面是聊天控制器的完整代码:
import com.howtodoinjava.ai.demo.web.model.Answer; import com.howtodoinjava.ai.demo.web.model.Question; import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.client.advisor.PromptChatMemoryAdvisor; import org.springframework.ai.chat.client.advisor.QuestionAnswerAdvisor; import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor; import org.springframework.ai.chat.memory.InMemoryChatMemory; import org.springframework.ai.vectorstore.SearchRequest; import org.springframework.ai.vectorstore.VectorStore; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/chat") public class ChatController { // 注入ChatClient实例,用于与大语言模型进行交互 private final ChatClient chatClient; // 构造函数,用于初始化ChatClient实例,并添加默认的顾问 public ChatController(ChatClient.Builder chatClientBuilder, VectorStore vectorStore) { this.chatClient = chatClientBuilder .defaultAdvisors( // 添加SimpleLoggerAdvisor,用于记录请求和响应日志 new SimpleLoggerAdvisor(), // 添加QuestionAnswerAdvisor,用于实现RAG模式 new QuestionAnswerAdvisor(vectorStore, SearchRequest.defaults()), // 添加PromptChatMemoryAdvisor,用于管理聊天记忆 new PromptChatMemoryAdvisor(new InMemoryChatMemory())) .build(); } @PostMapping public Answer chat(@RequestBody Question question, Authentication user) { // 使用ChatClient发送用户问题,并获取AI的回复 return chatClient.prompt() .user(question.question()) .advisors( // 设置聊天记忆的会话ID advisorSpec -> advisorSpec.param(CHAT_MEMORY_CONVERSATION_ID_KEY, user.getPrincipal())) .call() .entity(Answer.class); } } // 定义问题数据结构,包含用户提出的问题 public record Question(String question) { } // 定义答案数据结构,包含AI生成的答案 public record Answer(String answer) { }
六、安全配置
在RAG应用中,跟踪用户活动以防止滥用系统资源很重要。为了明确是哪个用户在上传文件和进行聊天,需要构建基于登录的安全机制,这样就能知道当前登录并使用应用的用户是谁。Spring Security可以与Spring Web无缝集成,用来提供基本的登录功能。下面的代码展示了如何进行最小化配置,使用内存用户认证,创建一个用户名是howtodoinjava
,密码是password
的用户:
import java.util.ArrayList; import java.util.Arrays; import java.util.List; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.web.SecurityFilterChain; @Configuration public class SecurityConfig { // 定义一个名为securityFilterChain的Bean,用于配置安全过滤链 @Bean SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { return http // 配置请求授权规则 .authorizeRequests(authorize -> authorize // 允许所有用户访问/login路径 .requestMatchers("/login").permitAll() // 其他请求需要认证 .anyRequest().authenticated() ) // 配置表单登录 .formLogin(formLogin -> formLogin // 设置登录页面路径 .loginPage("/login") // 设置登录成功后的默认跳转页面 .defaultSuccessUrl("/", true) ) // 禁用跨站请求伪造保护 .csrf(csrf -> csrf.disable()) // 配置HTTP头信息 .headers(headers -> headers.frameOptions(frameOptions -> frameOptions.sameOrigin())) .build(); } // 定义一个名为userDetailsService的Bean,用于管理用户详细信息 @Bean UserDetailsService userDetailsService(PasswordEncoder passwordEncoder) { List<UserDetails> usersList = new ArrayList<>(); // 添加一个用户到用户列表中 usersList.add(new User("howtodoinjava", passwordEncoder.encode("password"), Arrays.asList(new SimpleGrantedAuthority("ROLE_USER")))); // 使用内存用户详细信息管理器管理用户列表 return new InMemoryUserDetailsManager(usersList); } // 定义一个名为passwordEncoder的Bean,用于对密码进行编码 @Bean PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }
我们还创建了一个简单的HTML表单,包含两个文本字段和一个提交按钮,用于输入用户名和密码:
<!DOCTYPE html> <html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"> <head> <title>AI Chat Demo</title> </head> <body> <h1>Login</h1> <form method="POST" th:action="@{/login}"> <table> <tr> <td>User:</td> <td><input type="text" name="username"/></td> </tr> <tr> <td>Password:</td> <td><input type="password" name="password"/></td> </tr> <tr> <td colspan="2"><input name="submit" type="submit" value="Submit"/></td> </tr> </table> </form> </body> </html>
七、聊天应用界面
用户登录后,会被重定向到聊天应用页面。在这里,用户可以像在其他聊天应用(如ChatGPT)中一样上传文件并提问。下面是聊天应用界面的HTML代码:
<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/extras/spring-security"> <head> <title>AI Chat Demo</title> <link rel="stylesheet" th:href="@{/style.css}"/> <script th:src="@{/script.js}"></script> <script> var username = "[[${#authentication.principal.username}]]"; </script> </head> <body> <iframe name="hiddenUploadFrame" id="hiddenUploadFrame" style="display:none;"></iframe> <div id="uploadModal" class="modal"> <div class="modal-content"> <span class="closeModalSpan">×</span> <h2>Upload a file</h2> <form id="uploadForm" method="post" th:action="@{/upload}" enctype="multipart/form-data" target="hiddenUploadFrame"> <input type="file" name="file" id="file"/> <input type="submit" value="Upload" name="submit" id="submit"/> </form> <div class="loader" id="loader"> <svg class="circular"> <circle class="path" cx="50" cy="50" r="20" fill="none" stroke-width="5" stroke-miterlimit="10"></circle> </svg> </div> </div> </div> <div id="chatArea"> <div id="header"> <h2>AI Chat Demo</h2> </div> <div id="transcript"> </div> <div id="controls"> <button id="uploadFile">Upload File</button> </div> <textarea id="userInput" type="text" placeholder="How can I help?" rows="1"></textarea> <button id="typedTextSubmit" name="typedTextSubmit">Submit</button> </div> </body> </html>
我们使用简单的JavaScript代码来处理用户问题、AI回复、文件上传表单和状态的显示。每个提示或消息都会先转换为一个新的DIV
元素,然后添加到主聊天框中:
// 将对话内容添加到聊天记录中 const addToTranscript = (who, text) => { let b = document.querySelector('#transcript'); let name = (who === "User") ? username : who; b.innerHTML += createTranscriptEntry(who, name, text); b.scrollTop = b.scrollHeight; console.log(text); }; // 创建对话记录项的HTML代码 const createTranscriptEntry = (who, name, text) => { return ` <div class="${who}Entry"> <div><img src="/${who}.png" width="18" height="18" style="vertical-align: middle;"/> <span style="vertical-align: middle;"><b>${name}:</b> ${text}</span></div> </div>`; }; // 处理AI的回复 const handleResponse = (response) => { addToTranscript("AI", response.answer); }; // 发送用户问题 const postQuestion = (question) => { let data = { question: question }; fetch("/chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) }) .then(res => res.json()) .then(handleResponse); }; // 处理用户输入文本的提交 const submitTypedText = (event) => { let typedTextInput = document.querySelector('#userInput'); let question = typedTextInput.value; if (question.trim()!== '') { addToTranscript("User", question); typedTextInput.value = ''; postQuestion(question); } event.preventDefault(); }; // 处理文件上传 const handleFileUpload = () => { document.getElementById('uploadModal').style.display = 'block'; }; // 关闭文件上传模态框 const closeModal = () => { document.getElementById('uploadModal').style.display = 'none'; }; // 处理文件上传成功 const handleUploadSuccess = () => { closeModal(); document.getElementById('loader').style.display = 'none'; addToTranscript("System", "File uploaded successfully."); }; // 监听文件上传状态 document.getElementById('hiddenUploadFrame').onload = function() { if (this.contentWindow.document.body.textContent.includes('successfully')) { handleUploadSuccess(); } else { addToTranscript("System", "File upload failed."); document.getElementById('loader').style.display = 'none'; } }; // 监听提交按钮点击 document.getElementById('typedTextSubmit').addEventListener('click', submitTypedText); document.getElementById('uploadFile').addEventListener('click', handleFileUpload); document.querySelector('.closeModalSpan').addEventListener('click', closeModal);
八、演示
完成上述代码编写和配置后,启动Spring Boot应用。用户在浏览器中访问应用地址,首先会看到登录页面,输入用户名howtodoinjava
和密码password
登录。登录成功后,进入聊天应用界面。在该界面中,用户可以点击“Upload File”按钮上传文件,文件上传成功后,系统会提示“File uploaded successfully.”。之后,用户在输入框中输入问题,点击“Submit”按钮,应用会将问题发送给大语言模型,并在聊天框中显示AI的回复。
九、总结
通过本文的学习,我们成功基于Spring AI和OpenAI构建了一个具备文件上传功能的RAG聊天机器人。我们了解了如何配置OpenAI相关属性,设置Maven依赖,配置向量存储,实现文件上传与嵌入向量生成、聊天控制器、安全配置以及聊天应用界面。在实际应用中,你可以根据需求进一步优化和扩展这个聊天机器人,比如更换向量存储、优化提示信息以提高回答质量等。