Spring AI Java程序员的AI之Spring AI(三)RAG实战
Spring AI之RAG实战与原理分析
- 前言
- RAG
- Document
- DocumentReader
- DocumentTransformer
- DocumentWriter
- VectorStore
- SimpleVectorStore
- RedisVectorStore
- 元数据搜索
- 组装提示词
前言
检索增强生成(RAG)是一种结合信息检索和生成模型的技术,用于将相关数据嵌入到Prompts中,以提高AI模型的响应准确性。该方法包括一个批处理风格的编程模型,读取未结构化的数据,将其转换,然后写入向量数据库。总体上,这是一个ETL(Extract, Transform, Load)管道。向量数据库在RAG技术中用于检索部分。
简单解释就是:
搭建自己的知识库除了文档嵌入到向量数据库之外,就是RAG了。当用户提问的时候先从想来数据库搜索相关的资料,再把相关的资料拼接到用户的提问中,再让模型生成答案。
RAG
Document
Spring AI提供了:
- DocumentReader:用来读取TXT、PDF等文件内容
- DocumentTransformer:用来解析文件内容
- DocumentWriter:用来写入文件内容到向量数据库
DocumentReader
实现类有:
JsonReader:读取JSON格式的文件
TextReader:读取txt文件
PagePdfDocumentReader:使用Apache PdfBox读取PDF文件
TikaDocumentReader:使用Apache Tika来读取PDF, DOC/DOCX, PPT/PPTX, and HTML等文件
比如使用TextReader来读取meituan.txt文件内容:
package com.qjc.demo.service;
import org.springframework.ai.document.Document;
import org.springframework.ai.reader.TextReader;
import org.springframework.ai.transformer.splitter.TextSplitter;
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Component;
import java.util.List;
/***
* @projectName spring-ai-demo
* @packageName com.qjc.demo.service
* @author qjc
* @description TODO
* @Email qjc1024@aliyun.com
* @date 2024-10-17 10:15
**/
@Component
public class DocumentService {
@Value("classpath:meituan-qa.txt") // This is the text document to load
private Resource resource;
public List<Document> loadText() {
TextReader textReader = new TextReader(resource);
textReader.getCustomMetadata().put("filename", "meituan-qa.txt");
List<Document> documents = textReader.get();
return documents;
}
}
@GetMapping("/document")
public List<Document> document() {
return documentService.loadText();
}
得到的结果为:
所以DocumentReader只负责将文件转换为Document对象,如果要对文件进行切分,则需要使用DocumentTransformer。
DocumentTransformer
Spring AI默认提供了一个TokenTextSplitter,我们可以基于Document来进行切分:
public List<Document> loadText() {
TextReader textReader = new TextReader(resource);
textReader.getCustomMetadata().put("filename", "meituan.txt");
List<Document> documents = textReader.get();
TokenTextSplitter tokenTextSplitter = new TokenTextSplitter();
List<Document> list = tokenTextSplitter.apply(documents);
return list;
}
得到的结果如下:
发现它并不是按问题-答案对来进行切分的,TokenTextSplitter的工作原理:
- 先将文本encode为tokens
- 按指定的chunkSize(默认为800)对tokens进行切分,得到一个chunk
- 将chunk进行decode,得到原始文本
- 获取原始文本中最后一个’.‘、’?‘、’!‘、’\n’的位置,该位置表示一段话的结束。
- 如果结束位置超过了minChunkSizeChars,那么则进行切分得到一段话的chunk,否则不切分
- 将切分后的chunk记录到一个List中
- 然后跳转到第二步,处理剩余的tokens
如果要按问题-答案对来进行切分,需要自定义一个TextSplitter:
package com.qjc.demo.utils;
import org.springframework.ai.transformer.splitter.TextSplitter;
import java.util.List;
/***
* @projectName spring-ai-demo
* @packageName com.qjc.demo.utils
* @author qjc
* @description TODO
* @Email qjc1024@aliyun.com
* @date 2024-10-17 10:18
**/
public class QjcTextSplitter extends TextSplitter {
@Override
protected List<String> splitText(String text) {
return List.of(split(text));
}
public String[] split(String text) {
return text.split("\\s*\\R\\s*\\R\\s*");
}
}
然后直接调用就可以了:
package com.qjc.demo.service;
import org.springframework.ai.document.Document;
import org.springframework.ai.reader.TextReader;
import org.springframework.ai.transformer.splitter.TextSplitter;
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Component;
import java.util.List;
/***
* @projectName spring-ai-demo
* @packageName com.qjc.demo.service
* @author qjc
* @description TODO
* @Email qjc1024@aliyun.com
* @date 2024-10-17 10:15
**/
@Component
public class DocumentService {
@Value("classpath:meituan-qa.txt") // This is the text document to load
private Resource resource;
public List<Document> loadText() {
TextReader textReader = new TextReader(resource);
textReader.getCustomMetadata().put("filename", "meituan-qa.txt");
List<Document> documents = textReader.get();
QjcTextSplitter qjcTextSplitter = new QjcTextSplitter ();
List<Document> list = qjcTextSplitter .apply(documents);
return list;
}
}
得到的结果为:
DocumentWriter
得到按指定逻辑切分后的Document之后,就需要把它们做向量化并存入向量数据库了。DocumentWriter有一个子接口VectorStore,就表示向量数据库,而VectorStore有一个默认实现类SimpleVectorStore,可以先尝试使用它来进行Document的向量化和存储。
VectorStore
SimpleVectorStore
SimpleVectorStore只提供了一个构造方法:
public SimpleVectorStore(EmbeddingClient embeddingClient) {
Objects.requireNonNull(embeddingClient, "EmbeddingClient must not be null");
this.embeddingClient = embeddingClient;
}
因此可以直接定义一个SimpleVectorStore的Bean,利用构造注入得到EmbeddingClient:
@Bean
public SimpleVectorStore vectorStore(EmbeddingClient embeddingClient) {
return new SimpleVectorStore(embeddingClient);
}
然后直接使用VectorStore就可以了:
package com.qjc.demo.service;
import org.springframework.ai.document.Document;
import org.springframework.ai.document.DocumentWriter;
import org.springframework.ai.embedding.EmbeddingClient;
import org.springframework.ai.reader.TextReader;
import org.springframework.ai.transformer.splitter.TextSplitter;
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
import org.springframework.ai.vectorstore.SimpleVectorStore;
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-ai-demo
* @packageName com.qjc.demo.service
* @author qjc
* @description TODO
* @Email qjc1024@aliyun.com
* @date 2024-10-17 10:15
**/
@Component
public class DocumentService {
@Value("classpath:meituan-qa.txt") // This is the text document to load
private Resource resource;
@Autowired
private VectorStore vectorStore;
public List<Document> loadText() {
TextReader textReader = new TextReader(resource);
textReader.getCustomMetadata().put("filename", "meituan-qa.txt");
List<Document> documents = textReader.get();
QjcTextSplitter qjcTextSplitter = new QjcTextSplitter ();
List<Document> list = qjcTextSplitter .apply(documents);
// 向量存储
vectorStore.add(list);
return list;
}
}
我们再提供一个Controller用来进行向量查找:
@GetMapping("/documentSearch")
public List<Document> documentSearch(@RequestParam String message) {
return documentService.search(message);
}
public List<Document> search(String message){
List<Document> documents = vectorStore.similaritySearch(message);
return documents;
}
先进行向量存储,从控制台可以发现利用EmbeddingClient进行了多次文本向量化,因为我们把文本拆分成了多个问答对:
然后进行查询:
可以看出确实进行了相似搜索。
RedisVectorStore
SimpleVectorStore是利用了ConcurrentHashMap来进行存储,如果我们想换成Redis,只需要引入相关依赖和定义相关的Bean就可以了。
引入依赖:
<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>
定义Bean:
@Bean
public RedisVectorStore vectorStore(EmbeddingClient embeddingClient) {
RedisVectorStore.RedisVectorStoreConfig config = RedisVectorStore.RedisVectorStoreConfig.builder()
.withURI("redis://localhost:6379")
.withMetadataFields(
RedisVectorStore.MetadataField.text("filename"))
.build();
return new RedisVectorStore(config, embeddingClient);
}
在测试前,请先进入redis-cli执行redis-cli FT.DROPINDEX spring-ai-index DD对现有redis中的数据进行清空,不然会受到之前数据的影响。
测试发现,使用Redis时,meatdata中多了一个vector_score,表示相似度分数,这是RedisVectorStore帮我们设置的,和LangChain4j不一样的时,RedisVectorStore的vector_score是越低表示越相似。
元数据搜索
我们希望把每个问答对的问题存到元数据中,这样就可以使用问题的精准搜索了。我们先定义Redis中的元数据字段:
@Bean
public RedisVectorStore vectorStore(EmbeddingClient embeddingClient) {
RedisVectorStore.RedisVectorStoreConfig config = RedisVectorStore.RedisVectorStoreConfig.builder()
.withURI("redis://localhost:6379")
.withMetadataFields(
RedisVectorStore.MetadataField.text("filename"),
RedisVectorStore.MetadataField.text("question"))
.build();
return new RedisVectorStore(config, embeddingClient);
}
先删除Redis中的Index:
redis-cli FT.DROPINDEX spring-ai-index DD
然后设置每个Document的元数据:
public List<Document> loadText() {
TextReader textReader = new TextReader(resource);
textReader.getCustomMetadata().put("filename", "meituan-qa.txt");
List<Document> documents = textReader.get();
ZhouyuTextSplitter zhouyuTextSplitter = new ZhouyuTextSplitter();
List<Document> list = zhouyuTextSplitter.apply(documents);
// 把问题存到元数据中
list.forEach(document -> document.getMetadata().put("question", document.getContent().split("\\n")[0]));
// 向量存储
vectorStore.add(list);
return list;
}
重新进行向量化以及存储:
重新进行相似度搜索:
定义元数据搜索:
@GetMapping("/documentMetadataSearch")
public List<Document> documentMetadataSearch(@RequestParam String message, @RequestParam String question) {
return documentService.metadataSearch(message, question);
}
public List<Document> metadataSearch(String message, String question) {
return vectorStore.similaritySearch(
SearchRequest
.query(message)
.withTopK(5)
.withSimilarityThreshold(0.1)
.withFilterExpression(String.format("question in ['%s']", question)));
}
搜索结果
组装提示词
通过以上两个步骤,我们可以将自己的知识库进行向量化存储和搜索了,那么接下来,我们只需要将搜索结果和用户问题进行提示词组装送给大模型就可以得到答案了。
@GetMapping("/customerService")
public String customerService(@RequestParam String question) {
// 向量搜索
List<Document> documentList = documentService.search(question);
// 提示词模板
PromptTemplate promptTemplate = new PromptTemplate("{userMessage}\n\n 用以下信息回答问题:\n {contents}");
// 组装提示词
Prompt prompt = promptTemplate.create(Map.of("userMessage", question, "contents", documentList));
// 调用大模型
return chatClient.call(prompt).getResult().getOutput().getContent();
}