Spring AI之Java经典面试题智能小助手
前言
通过Ollama在本地部署了Llama3大模型,这篇来基于Llama3和Spring AI,以及ChatGPT Web来实现一个Java经典面试题智能小助手。
私有化部署大模型最佳解决方案 Ollama
一、准备面试题
建议优先使用marddown或txt等文本格式,因为经过尝试,如果导出为PDF格式,会出现格式错乱。
ThreadLocal和InheritableThreadLocal的区别
ThreadLocal和InheritableThreadLocal都可以用通过线程来共享数据,区别在于当前线程在InheritableThreadLocal中设置的值可以被子线程继承,并且是复制(也就是子线程和父线程一开始InheritableThreadLocal中的值时一致的,但是后续的修改互不影响),而当前线程在ThreadLocal中设置的值不会被子线程所继承。
如何理解Java中的装箱与拆箱
装箱,就是int类型包装为Integer类型,拆箱,就是反过来,因为Java中支持8种基本数据类型,每种基本类型都有对应的包装类型,装箱会调用valueOf()方法,传入基本类型,返回包装类型,这个方法中通常会有一个缓存,比如用来缓存数字1对应的Integer对象,拆箱会调用intValue()方法,返回基本类型,不要过多的进行装箱和拆箱,毕竟是在调方法,是消耗性能的。
Java中为什么要有基础类型
Java是面向对象的,一切都是对象,但是像字符、数字这些常用类型,每次用的时候也去new对象,就会比较费性能和内存了,所以Java设计了8种基础类型,在使用基础类型时,对应的内存空间是直接分配在栈上的,而不是分配在堆上,这样性能也更好。
说说进程和线程的区别
一个操作系统上会运行很多个程序,这些程序都有自己的代码,以及都要用内存来存代码,和代码运行过程中产生的数据,进程就是用来隔离各个程序的内存空间的,使得程序之间互不干扰,还是这多个程序,为了让它们能同时运行,CPU就需要先执行这个程序的几条指令,然后切换到另外一个程序去执行,然后再切回来,就像同时在运行多条指令流水线,而这个流水线就是线程,是CPU调度的最小单位
为什么Java不支持多继承?
首先,思考这么一种场景,假如现在A类继承了B类和C类,并且B类和C类中,都存在test()方法,那么当A类对象调用test()方法时,该调用B类的test()呢?还是C类的test()呢?是没有答案的,所以Java中不允许多继承。
String、StringBuffer、StringBuilder的区别
- String是不可变的,如果尝试去修改,会新生成一个字符串对象,StringBuffer和StringBuilder是可变的
- StringBuffer是线程安全的,StringBuilder是线程不安全的,所以在单线程环境下StringBuilder效率会更高
二、搭建工程
引入SpringBoot:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.1</version>
</parent>
引入Spring AI
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>0.8.1-SNAPSHOT</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
引入spring web
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
引入Ollama
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-ollama-spring-boot-starter</artifactId>
</dependency>
引入Markdown解析器
<dependency>
<groupId>com.vladsch.flexmark</groupId>
<artifactId>flexmark</artifactId>
<version>0.42.14</version>
</dependency>
引入Redis向量数据库相关
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-redis</artifactId>
</dependency>
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>5.1.0</version>
</dependency>
指定仓库
<repositories>
<repository>
<id>spring-milestones</id>
<name>Spring Milestones</name>
<url>https://repo.spring.io/milestone</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
<repository>
<id>spring-snapshots</id>
<name>Spring Snapshots</name>
<url>https://repo.spring.io/snapshot</url>
<releases>
<enabled>false</enabled>
</releases>
</repository>
</repositories>
三、文件读取与解析
新建InterviewService,提供向量存储、向量搜索功能:
@Bean
public RedisVectorStore vectorStore(EmbeddingClient embeddingClient) {
RedisVectorStore.RedisVectorStoreConfig config = RedisVectorStore.RedisVectorStoreConfig.builder()
.withURI("redis://localhost:6379")
.withIndexName("interview-assistant-index")
.withMetadataFields(
RedisVectorStore.MetadataField.text("filename"))
.build();
return new RedisVectorStore(config, embeddingClient);
}
package com.qjc.demo.service;
import org.springframework.ai.document.Document;
import org.springframework.ai.reader.TextReader;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Component;
import java.util.List;
/***
* @projectName spring-ollama-demo
* @packageName com.qjc.demo.service
* @author qjc
* @description TODO
* @Email [email protected]
* @date 2024-10-18 10:23
**/
@Component
public class InterviewService {
@Value("classpath:Java基础面试题.md")
private Resource resource;
@Autowired
private VectorStore vectorStore;
public List<Document> loadText() {
// 读取文件内容
TextReader textReader = new TextReader(resource);
List<Document> documents = textReader.get();
// 解析文件内容
MarkdownSplitter textSplitter = new MarkdownSplitter();
List<Document> list = textSplitter.apply(documents);
// 将问题提取出来存入Metadata
list.forEach(document -> {
String title = document.getContent().split("==title==")[0];
String replace = title.replace("##", "");
document.getMetadata().put("question", replace.trim());
});
// 向量化以及向量存储
vectorStore.add(list);
return list;
}
public List<Document> search(String message){
// ...
}
}
四、Markdown文件解析
思路是:通过解析文件中的二级标题和标题下的内容,得到一个Document,标题和内容直接用"title"分割。
package com.com.qjc.demo.utils;
import com.vladsch.flexmark.ast.Heading;
import com.vladsch.flexmark.parser.Parser;
import com.vladsch.flexmark.util.ast.Document;
import com.vladsch.flexmark.util.ast.Node;
import com.vladsch.flexmark.util.collection.iteration.ReversiblePeekingIterator;
import org.springframework.ai.transformer.splitter.TextSplitter;
import java.util.ArrayList;
import java.util.List;
/***
* @projectName spring-ollama-demo
* @packageName com.qjc.demo.utils
* @author qjc
* @description TODO
* @Email [email protected]
* @date 2024-10-18 10:23
**/
public class MarkdownSplitter extends TextSplitter {
@Override
protected List<String> splitText(String text) {
Parser parser = Parser.builder().build();
Document markdownDocument = parser.parse(text);
List<String> result = new ArrayList<>();
ReversiblePeekingIterator<Node> iterator = markdownDocument.getChildren().iterator();
StringBuilder builder = new StringBuilder();
while (iterator.hasNext()) {
Node node = iterator.next();
// 如果是二级标题
if (node instanceof Heading && ((Heading) node).getLevel() == 2) {
if (!builder.isEmpty()) {
result.add(builder.toString());
}
builder.delete(0, builder.length());
builder.append(node.getChars());
builder.append("==title==");
} else {
builder.append(node.getChars());
}
}
if (!builder.isEmpty()) {
result.add(builder.toString());
}
return result;
}
}
五、问题搜索
public List<Document> search(String question){
// 先查元数据
SearchRequest metaSearchRequest = SearchRequest
.query(question)
.withTopK(3)
.withSimilarityThreshold(0.9)
.withFilterExpression(String.format("question in ['%s']", question));
List<Document> metaDocuments = vectorStore.similaritySearch(metaSearchRequest);
if (!CollectionUtils.isEmpty(metaDocuments)) {
return metaDocuments;
}
// 元数据没查到在相似搜索
SearchRequest searchRequest = SearchRequest
.query(question)
.withTopK(3)
.withSimilarityThreshold(0.9);
return vectorStore.similaritySearch(searchRequest);
}
六、自定义EmbeddingClient
默认情况下是对问题和答案同时进行向量化,如果只想对问题进行向量化,则需要自定义EmbeddingClient:
package com.qjc.demo.config;
import org.springframework.ai.document.Document;
import org.springframework.ai.ollama.OllamaEmbeddingClient;
import org.springframework.ai.ollama.api.OllamaApi;
import java.util.List;
/***
* @projectName spring-ollama-demo
* @packageName com.qjc.demo.config
* @author qjc
* @description TODO
* @Email [email protected]
* @date 2024-10-18 10:23
**/
public class QjcOllamaEmbeddingClient extends OllamaEmbeddingClient {
public QjcOllamaEmbeddingClient (OllamaApi ollamaApi) {
super(ollamaApi);
}
@Override
public List<Double> embed(Document document)
// 单独对问题进行向量化
String question = (String) document.getMetadata().get("question");
return this.embed(question);
}
}
@Bean
public QjcOllamaEmbeddingClient ollamaEmbeddingClient(OllamaApi ollamaApi, OllamaEmbeddingProperties properties) {
QjcOllamaEmbeddingClient qjcOllamaEmbeddingClient = new QjcOllamaEmbeddingClient (ollamaApi);
qjcOllamaEmbeddingClient.withModel("nomic-embed-text:v1.5");
return qjcOllamaEmbeddingClient;
}
七、定义请求Controller
package com.qjc.demo.controller;
import org.springframework.ai.chat.ChatClient;
import org.springframework.ai.chat.ChatResponse;
import org.springframework.ai.chat.StreamingChatClient;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.chat.prompt.PromptTemplate;
import org.springframework.ai.document.Document;
import org.springframework.ai.openai.api.OpenAiApi;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import reactor.core.publisher.Flux;
import java.io.IOException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/***
* @projectName spring-ollama-demo
* @packageName com.qjc.demo.controller
* @author qjc
* @description TODO
* @Email [email protected]
* @date 2024-10-18 10:23
**/
@RestController
public class ChatController {
@Autowired
private StreamingChatClient chatClient;
@Autowired
private InterviewService interviewService;
@GetMapping("/document")
public List<Document> document() {
return interviewService.loadText();
}
@GetMapping("/documentSearch")
public List<Document> documentSearch(@RequestParam String message) {
return interviewService.search(message);
}
@PostMapping(value = "/v1/chat/completions", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<OpenAiApi.ChatCompletionChunk> interview(@RequestBody OpenAiApi.ChatCompletionRequest request) {
String question = request.messages().get(1).content();
// 向量搜索
List<Document> documentList = interviewService.search(question);
// 提示词模板
PromptTemplate promptTemplate = new PromptTemplate("{userMessage}\n\n 用中文,并根据以下信息回答问题:\n {contents}");
// 组装提示词
Prompt prompt = promptTemplate.create(Map.of("userMessage", question, "contents", documentList));
// 调用大模型
Flux<ChatResponse> stream = chatClient.stream(prompt);
return stream.map(chatResponse -> {
String content = chatResponse.getResult().getOutput().getContent();
// 需要优化
OpenAiApi.ChatCompletionChunk chatCompletionChunk = new OpenAiApi.ChatCompletionChunk("1",
List.of(new OpenAiApi.ChatCompletionChunk.ChunkChoice(
OpenAiApi.ChatCompletionFinishReason.STOP,
1,
new OpenAiApi.ChatCompletionMessage(
content,
OpenAiApi.ChatCompletionMessage.Role.ASSISTANT)
, new OpenAiApi.LogProbs(null))),
null, null, null, null);
return chatCompletionChunk;
});
}
}