1. 前言
这是 SpringAI 系列的第二篇文章,这篇文章将介绍如何基于 RAG 技术,使用 SpringAI + Vue3 + ElementPlus 实现一个 Q&A 系统。本文使用 deepseek 的 DeepSeek-V3 作为聊天模型,使用阿里百炼的 text-embedding-v3 作为向量模型,使用 redis 作为向量库。(PS:近期阿里百炼也上架了DeepSeek-V3 和 DeepSeek-R1 模型供开发者调用,如果觉得 DeepSeek 官方 AP I比较慢的话,可以去试试)。
什么是 RAG?
RAG(Retrieval-Augmented Generation,检索增强生成)技术是一种结合了检索(Retrieval)和生成(Generation)的自然语言处理方法。它通过检索相关的文档片段来增强语言模型的生成能力,从而提高生成文本的质量和相关性。RAG技术的核心思想是利用检索系统从大规模文档集合中找到与输入问题最相关的文档片段,然后将这些片段作为上下文信息输入到生成模型中,生成更加准确和详细的回答。
注:以下几行内容节选自 spring ai alibaba RAG 部分说明文档。
RAG 的工作流程分为两个阶段:Indexing pipeline 和 RAG 。
-
Indexing pipeline 阶段主要是将结构化或者非结构化的数据或文档进行加载和解析、chunk切分、文本向量化并保存到向量数据库。
-
RAG 阶段主要包括将 promp t文本内容转为向量、从向量数据库检索内容、对检索后的文档 chunk 进行重排和 prompt 重写、最后调用大模型进行结果的生成。
下面是大致流程:
2. 效果展示
让聊天模型基于我上传的文件,进行回答。
3. 设计思路
整个 Q&A 功能的时序图:
4. 前提条件
-
已有自己的向量库,如 Redis、Faiss。想了解更多参考我的博客:
【LLM】Redis 作为向量库入门指南_redis 向量数据库-CSDN博客文章浏览阅读1k次,点赞25次,收藏12次。这篇文章将介绍基于 RedisSearch 的Redis向量库实现。通过阅读本篇文章,你将学习到如何创建向量索引,如何存储和更新向量,如何进行向量搜索,如何使用阿里百炼 Embedding Model 文本向量化,如何集成到 spring boot 中并实现向量的存储和搜索等。_redis 向量数据库https://blog.csdn.net/u013176571/article/details/145122380
-
已安装 18.3 或更高版本的 Node.js ,未安装进入 官网下载 ,LTS 为长期支持版,Current 为最新功能版。
-
使用过Spring AI,可参考我的这篇博客:
【Spring AI】Spring AI Alibaba的简单使用_spring-ai-alibaba-starter-CSDN博客文章浏览阅读696次,点赞7次,收藏6次。项目中引入Spring AI、Spring AI Alibaba踩坑笔记,并实现简单的几种聊天模式。_spring-ai-alibaba-starterhttps://blog.csdn.net/u013176571/article/details/144488475
需要注意:写这篇文章的时候,我对 spring-ai-core 版本进行了升级,版本为:1.0.0-M5。并且舍弃了 spring-ai-alibaba-starter 包,改为引入使用更广泛的 spring-ai-openai 包,这些修改意味着之前写的文章中的部分代码要做相应的修改,下文将会介绍。
5. 引入 Spring AI
5.1 pom 文件配置
spring-ai 版本为 1.0.0-M5。
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-core</artifactId>
<version>${spring-ai-core.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai</artifactId>
<version>${spring-ai-core.version}</version>
</dependency>
由于 spring-ai 相关依赖包还没有发布到中央仓库,需要在项目的 pom.xml 依赖中加入如下仓库配置。
<repositories>
<repository>
<id>maven2</id>
<name>maven2</name>
<url>https://repo1.maven.org/maven2/</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
<repository>
<id>spring-milestones</id>
<name>Spring Milestones</name>
<url>https://repo.spring.io/milestone</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
</repositories>
maven 的 setting.xml 中做出如下更改
<mirror>
<id>alimaven</id>
<name>aliyun maven</name>
<url>https://maven.aliyun.com/repository/public</url>
<!-- 表示除了spring-milestones、maven2其它都走阿里云镜像 -->
<mirrorOf>*,!spring-milestones,!maven2</mirrorOf>
</mirror>
5.2 application.yml 配置
聊天模型基于 deepseek,‘your-api-key’ 为 deepseek 上申请的api-key,base-url 设置为 deepseek 的接口 url。
spring:
ai:
openai:
api-key: your-api-key
base-url: https://api.deepseek.com
chat:
model: deepseek-chat
chat:
client:
enabled: false
6. 接口开发
由于我们使用的是 spring-ai-openai 依赖,依赖中的 ChatModel 、 EmbeddingModel 等 Bean 默认配置都是基于 OpenAI 的模型,所以我们都需要根据自己使用的模型重新注册这些 Bean。
6.1 注册 EmbeddingModel
从上文的 RAG 流程中我们知道,实现 Q&A 系统的第一步是将知识库的资源向量化(也就是 Embedding)。
我这里使用阿里百炼的嵌入模型,下图是其主要文本向量模型对比:
这篇文章使用 text-embedding-v3,需要新注册一个 EmbeddingModel Bean。
@Bean
public EmbeddingModel embeddingModel() {
return new OpenAiEmbeddingModel(
// embeddingBaseUrl 为阿里百炼的接口 url ,值为:https://dashscope.aliyuncs.com/compatible-mode
// embeddingApiKey 为阿里百炼上申请的 api-key 。
new OpenAiApi(embeddingBaseUrl, embeddingApiKey),
MetadataMode.EMBED,
OpenAiEmbeddingOptions.builder()
.model("text-embedding-v3") // 选用的嵌入模型
.dimensions(1024) // 向量维度
.build(),
RetryUtils.DEFAULT_RETRY_TEMPLATE);
}
6.2 注册 ChatModel
我使用 DeepSeek 的 DeepSeek-V3 作为聊天模型,这里需要重新注入 ChatModel Bean。
@Bean
public ChatModel chatModel() {
// baseUrl 为 DeepSeek 接口 url,值为:https://api.deepseek.com
// apiKey 为申请的秘钥,defaultChatModel 为聊天模型。
return new OpenAiChatModel(new OpenAiApi(baseUrl, apiKey), OpenAiChatOptions.builder()
.model("deepseek-chat") // 选用的聊天模型
.temperature(0.7d) //
.build());
}
6.3 注册 VectorStore
我的这篇文章
做过详细介绍,这里增加两个元数据配置 docId(文档Id) 和 docName(文档名),用于在回答的时候进行过滤。
@Bean
public VectorStore vectorStore() {
return RedisVectorStore.builder(jedisPooled, embeddingModel)
.indexName(indexName)
.prefix(prefix)
.embeddingFieldName("embedding") // 向量字段名,默认 embedding
.contentFieldName("content") // 存储的原始文本内容
.initializeSchema(initializeSchema) // 是否初始化索引配置信息
.batchingStrategy(batchingStrategy)
.metadataFields(
RedisVectorStore.MetadataField.text(SpringAIConst.VectorStore.METADATA_DOC_ID), // 存储的元数据信息
RedisVectorStore.MetadataField.text(SpringAIConst.VectorStore.METADATA_DOC_NAME) // 存储的元数据信息
).build();
}
6.4 文档 Embedding
文本文档 Embedding 之前要先对文本文档进行分割,以满足大模型接口的输入字符数或者 Token 限制。
6.4.1 文档分割
文本分割的算法有:字符分割、递归字符文本分割、语义文档分割等等,如果有需要我将在后续文章中介绍这些算法。
本文使用较为常见的:递归字符文本分割。在递归字符文本分割中,通常会按照分隔符的优先级逐步分割文本。以英文常用的分隔符为例 ["\n\n", "\n", " "] ,其表示先用 "\n\n" 分割成段落,然后在每个段落中用 "\n" 分割成行,最后在每行中用 " " 分割成单词,这种逐层分割的方式可以将文本逐步细化为更小的单元。
public class RecursiveCharacterTextSplitter implements TextSplitterIntf {
/**
* 分隔符列表,用于拆分文本
* 中文:"\n\n", "。", "!", "?", ",", ";"
* 英文:"\n\n", "\n", " "
*/
private List<String> separators = Arrays.asList("\n\n", "。", "!", "?", ",", ";");
/**
* 每个块的最大大小
*/
private int chunkSize = 250;
/**
* 块之间的重叠大小,用来维持上下文关系
*/
private int chunkOverlap = 30;
public RecursiveCharacterTextSplitter() {
}
public RecursiveCharacterTextSplitter(int chunkSize, int chunkOverlap) {
this.chunkSize = chunkSize;
this.chunkOverlap = chunkOverlap;
if (chunkOverlap >= chunkSize) {
throw new IllegalArgumentException("chunkOverlap must be smaller than chunkSize");
}
}
public RecursiveCharacterTextSplitter(List<String> separators, int chunkSize, int chunkOverlap) {
this.separators = separators;
this.chunkSize = chunkSize;
this.chunkOverlap = chunkOverlap;
if (chunkOverlap >= chunkSize) {
throw new IllegalArgumentException("chunkOverlap must be smaller than chunkSize");
}
}
/**
* Spring ai Document 拆分
*
* @param documents
* @param metaData 文档元数据信息
* @return
*/
@Override
public List<Document> split(List<Document> documents, List<Map<String, Object>> metaData) {
List<JSONObject> docList = this.beforeSplit(documents, metaData);
List<Document> chunkedDocuments = new ArrayList<>();
docList.forEach(item -> {
Document document = item.getObject("document", Document.class);
Map<String, Object> singleMetaData = item.getObject("metaData", Map.class);
List<String> chunks = new ArrayList<>();
splitRecursive(document.getContent(), chunks);
chunks.forEach(chunk -> {
chunkedDocuments.add(new Document(chunk, singleMetaData));
});
});
return chunkedDocuments;
}
/**
* 递归地拆分文本
*
* @param text
* @param chunks
*/
private void splitRecursive(String text, List<String> chunks) {
if (text.length() <= chunkSize) {
chunks.add(text);
return;
}
String separator = findBestSeparator(text);
if (separator == null) {
chunks.add(text);
return;
}
String[] parts = text.split(separator, -1);
StringBuilder currentChunk = new StringBuilder();
int currentLength = 0;
for (String part : parts) {
if (currentLength + part.length() > chunkSize) {
if (currentChunk.length() > 0) {
chunks.add(currentChunk.toString());
// 通过回退chunkOverlap的距离创建来重叠部分
int overlapStart = Math.max(0, currentChunk.length() - chunkOverlap);
currentChunk = new StringBuilder(currentChunk.substring(overlapStart));
currentLength = currentChunk.length();
}
}
currentChunk.append(part).append(separator);
currentLength += part.length() + separator.length();
}
if (currentChunk.length() > 0) {
chunks.add(currentChunk.toString());
}
}
/**
* 查找文本中第一个出现的分隔符
*
* @param text
* @return
*/
private String findBestSeparator(String text) {
for (String separator : separators) {
if (text.contains(separator)) {
return separator;
}
}
return null;
}
}
6.4.2 BatchingStrategy 介绍
每个 Embedding 模型都有最大处理 token 数限制,对于一个大文档,我们不能够一次性发送给模型处理,为了解决这个问题,Spring AI 实现了一种批处理策略,这个方法将大量文档分解成更小的批次,不仅能够适配 Embedding Model 的最大上下文,还能够提高性能并更有效地利用API速率限制。
Spring AI 通过 BatchingStrategy 接口提供此功能,该接口允许根据文档的 token 数量将文档处理为子批次,这个接口有一个默认实现 TokenCountBatchingStrategy ,这里我们需要根据自己选择的模型重新注册该 Bean。
@Bean
public BatchingStrategy tokenCountBatchingStrategy() {
return new TokenCountBatchingStrategy(
EncodingType.CL100K_BASE, // 指定用于分词的编码类型
8000, // 最大 Token 数量
0.1 // 设置预留百分比(默认 10%)
);
}
参数解释:
-
EncodingType.CL100K_BASE:指定用于分词的编码类型。这种编码类型被 JTokkitTokenCountEstimator 使用,以准确估计token数量。
-
8000:最大 Token 数量,默认使用 OpenAI 的最大输入token数(8191)。
-
0.1:设置预留百分比(默认 10%),从最大输入token数中预留的token百分比,这为处理过程中可能的token数量增加创建了一个缓冲区。
6.5 保存文档到向量库
6.5.1 分割后的文本入向量库
为了防止同一文档重复入库,在文档入库前先计算文档的 Hash 值,并在文档保存到向量库后,将文档的信息(文档名,文档大小,分割文本大小等)保存到 Redis 中(key 为文档 Hash 值),方便后续操作中的重复判断。
/**
* 文档入库
*
* @param inputStream 文档输入流
* @param docHash 文档Hash
* @param docName 文档名
* @throws Exception
*/
public void addDocument(InputStream inputStream, String docHash, String docName) throws Exception {
// 校验文档是否已经存在
if (redisService.hasKey(CommonConst.RAG_KEY_PREFIX + docHash)) {
return;
}
int docSize = inputStream.available();
// 文件流转为 Document
TextReader textReader = new TextReader(new InputStreamResource(inputStream));
List<Document> documents = textReader.get();
// 文档拆分
TextSplitterIntf textSplitter = new RecursiveCharacterTextSplitter(250, 30);
List<Document> newDocuments = textSplitter.split(documents, List.of(Map.of(SpringAIConst.VectorStore.METADATA_DOC_ID, docHash,
SpringAIConst.VectorStore.METADATA_DOC_NAME, docName)));
// 向量入库
vectorStore.add(newDocuments);
// 将文档信息存入 Redis
JSONObject docJson = new JSONObject();
docJson.put(SpringAIConst.VectorStore.METADATA_DOC_NAME, docName);
docJson.put("docSize", docSize);
docJson.put("docChunks", newDocuments.size());
redisService.setObject(CommonConst.RAG_KEY_PREFIX + docHash, docJson);
}
6.5.2 计算文档的 Hash 值
支持的算法:MD5、SHA-1、SHA-256
/**
* 计算文件 Hash 值
*
* @param inputStream 输入流
* @param algorithm 算法,可选,MD5、SHA-1、SHA-256
* @return
* @throws Exception
*/
public static String calculateFileHash(InputStream inputStream, String algorithm) throws Exception {
// 创建MessageDigest实例
algorithm = StringUtils.isEmpty(algorithm) ? CommonConst.SHA_256 : algorithm;
MessageDigest digest = MessageDigest.getInstance(algorithm);
// 打开文件输入流
try (inputStream) {
byte[] buffer = new byte[1024];
int numRead;
// 读取文件内容并更新摘要
while ((numRead = inputStream.read(buffer)) != -1) {
digest.update(buffer, 0, numRead);
}
// 完成哈希计算
byte[] hashBytes = digest.digest();
// 将哈希值转换为十六进制字符串
StringBuilder sb = new StringBuilder();
for (byte b : hashBytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
}
6.6 聊天接口
话不多说,直接上代码。
/**
* 带聊天记忆流式返回
*
* @param accessKey 聊天访问key,每次打开聊天页面重置
* @param inputInfo 聊天输入内容
* @param docIds 参考文档ID,多个用英文','号隔开
* @return
*/
@GetMapping("/memoryStreamWithApi")
public Flux<ServerSentEvent<String>> memoryStreamWithApi(String accessKey, String inputInfo, String docIds) {
// 接收并校验参数
CommonUtil.checkAndThrow(accessKey, "您没有该功能访问权限!");
CommonUtil.checkAndThrow(inputInfo, "请输入内容!");
// 调用模型查询
ChatClient.ChatClientRequestSpec chatClientRequestSpec = ChatClient.builder(chatModel)
.defaultAdvisors(
new MessageChatMemoryAdvisor(new InMemoryChatMemory()))
.build()
.prompt()
.user(inputInfo)
.advisors(advisor -> advisor.param(CHAT_MEMORY_CONVERSATION_ID_KEY, accessKey).param(CHAT_MEMORY_RETRIEVE_SIZE_KEY, 5));
// 先从向量库查询近似数据
if (StringUtils.isNotEmpty(docIds)) {
// 组装过滤条件,根据文档ID过滤,等价于 SQL 的 WHERE docId IN (docId1,docId2...)
List<String> docIdList = Arrays.asList(docIds.split(","));
Filter.Expression expression = new Filter.Expression(Filter.ExpressionType.IN,
new Filter.Key(SpringAIConst.VectorStore.METADATA_DOC_ID), new Filter.Value(docIdList));
Advisor advisor = new QuestionAnswerAdvisor(vectorStore,
SearchRequest.builder().query(inputInfo)
.filterExpression(expression)
.topK(6)
.similarityThreshold(0.8d).build());
chatClientRequestSpec.advisors(advisor);
}
return chatClientRequestSpec.stream()
.chatResponse()
.map(response -> ServerSentEvent.<String>builder()
.data(response.getResult().getOutput().getContent())
.build());
}
7. 快速搭建 Elemenet-Plus 项目
写到这发现需要整理的文档太多,前端实现放到下一篇文章。
8. 参考文档
- Spring AI RAG 文档
- Spring AI Alibaba RAG 文档
- Vue3 文档
- Element Plus 文档
- markdown-it 文档
9. 拓展
9.1 如何计算 Token ?
9.1.1 阿里通义
Token 是模型用来表示自然语言文本的基本单位,可以直观地理解为“字”或“词”。
-
对于中文文本,1个 Token 通常对应一个汉字或词语。例如,“你好,我是通义千问”会被转换成['你好', ',', '我是', '通', '义', '千', '问']。
-
对于英文文本,1个 Token 通常对应3至4个字母或1个单词。例如,"Nice to meet you."会被转换成['Nice', ' to', ' meet', ' you', '.']。
不同的大模型切分Token 的方法可能不同。可以本地运行 tokenizer 来估计文本的 Token 量。
9.1.2 DeepSeek
一般情况下模型中 token 和字数的换算比例大致如下:
-
1 个英文字符 ≈ 0.3 个 token。
-
1 个中文字符 ≈ 0.6 个 token。