LLM的知识仅限于它所接受到的训练数据。如果我们希望让它了解特定领域的专有知识,则可以使用下面的方式操作:
- 使用RAG
- 使用专有数据对LLM进行微调
- RAG与数据微调方式结合使用
什么是RAG
简单地说,RAG就是把数据发送给LLM之前从数据中查找相关信息片段并把它注入到提示符的方法。这样的话LLM获得相关信息,并可以使用这些信息进行回复。
那么最为重要的是我们要可以检索到信息,可以使用下面的一些方法:
- 全文(关键字)检索,这咱方法使用IF-IDF和BM25之类的技术,通过在文档数据库中匹配查询关键字(用户询问的内容)来检索文档。它会根据这些关键字在每个文档中的频率和相关性对结果进行排序。
- 向量搜索,有称为“语义搜索”。使用嵌入模型把长文本文档转换为数字向量。然后,根据查询向量和文档向量之间的余弦相似度或其他相似度,来查找和排序文档,从而获得更深层次的语义。
- 混合检索,结合多种搜索方法(如:全文 + 向量),通常这样子可以提高检索的效率。
RAG的两阶段处理
RAG的过程是分为两个阶段的:索引和检索
索引
索引阶段就是为了后一阶段检索而进行的对文档的预处理
在这个阶段会根据所使用的信息检索方法而有变化。对于向量检索,通常包含清理文档、用额外的数据和元数据丰富它们、把它们分割为更小的段/块,最后把它们存储到存储区(向量数据库)中。
索引阶段通常是脱机进行的,这意味着它不需要最终用户等待它的操作完成。这样的话我们就可以使用定时任务在固定时间进行重新索引知识库文档,负责进行索引的代码也可以是一个单独的代码也可以是一个单独的应用程序,专门只用来处理索引任务。
在有些情况下,最终用户可能希望上传他们自己的文档,便于LLM访问到它们。在这种情况下索引阶段应该是在线执行的,并且是主应用程序的一部分。
关于索引阶段官方示意图如下:
检索
检索阶段通常是在线进行的,它处于用户提交应该使用索引文档回答问题时。
在这个过程中会根据所使用的信息检索方法而变化。对于向量搜索,通常来说涉及到用户的查询并在嵌入存储中地相似性搜索。然后把这个片段(原始文档的片段)注入提示并发送给LLM。
关于检索的官方示意图如下:
三种不同的RGA
- Easy RAG:使用RAG最简单的方法
- Native RAG:一个使用矢量搜索的RAG基本实现
- Advanced RAG:一个模块化的RAG框架,它允许额的步骤,如查询转换、多个源检索和重新排序
Easy RAG
LangChain4j有一个简单的RAG实现,使用这个不需要额外去了解嵌入、矢量存储、嵌入模型以及了解如何解析和分割文档等等,我们只需要指向指定的文档,LangChain4j就会去处理。
注意:这种简单的RAG在质量上肯定是会低于定制RAG的
使用步骤
第一步:我们要使用Easy RAG功能,必须要添加相对应的依赖
<dependency> <groupId>dev.langchain4j</groupId><artifactId>langchain4j-easy-rag</artifactId>
</dependency>
第二步:加载指定文档
List<Document> documents = FileSystemDocumentLoader.loadDocuments("E://myDocument");
第二步会加载指定目录下的所有文档,它的实现依赖于Apache Tika库支持,Tika库支持多种类型文档的解析,由于这里没有指定具体的DocumentParser,那么FileSystemDocumentLoader会加载一个ApacheTikaDocumentParser,这是由langchain4j-easy-rag依赖项通过SPI提供的。
我们了可以自定义需要解析哪些个文档,可以给定一个匹配规则,然后解析目录下满足规则的文档
PathMatcher pathMatcher = FileSystems.getDefault().getPathMatcher("glob:**" + ".txt");
FileSystemDocumentLoader.loadDocuments("E://myDocument", pathMatcher);
上面的话给定了一个规则,则只会解.txt的文档
第三步:我们需要一个专门的嵌入存储(矢量数据库)对文档进行预处理和存储
InMemoryEmbeddingStore<TextSegment> embeddingStore = new InMemoryEmbeddingStore<>();
EmbeddingStoreIngestor.ingest(documents, embeddingStore);
EmbeddingStoreIngestor通过SPI从langchain4j-easy-rag依赖项加载一个DocumentSplitter。每个文档被分割为更小的部分,每个部分由不超过300个令牌组成,并有30个令牌的重叠。
EmbeddingStoreIngestor通过SPI从langchain4j-easy-rag依赖项加载一个EmbeddingModel,使用这个EmbeddingModel把每个TextSegment转为嵌入
最终所有的TextSegment-Embedding对都会存储到EmbeddingStore
第四步:定义和创建AiService
具体示例
1、pom依赖项添加
<dependency><groupId>dev.langchain4j</groupId><artifactId>langchain4j-easy-rag</artifactId>
</dependency>
2、创建一个Document的工具类用来封装加载文档的方法
public class DocumentUtils {/*** 根据指定的目录加载目录下所有文档* @param dir 目录* @return 文档列表*/public static List<Document> loadDocuments(String dir) {return FileSystemDocumentLoader.loadDocuments(dir);}/*** 根据指定的目录加载目录下指定类型的文件* @param dir 指定要加载的目录* @param filter 文件类型* @return 文档列表*/public static List<Document> loadDocuments(String dir, String filter) {PathMatcher pathMatcher = FileSystems.getDefault().getPathMatcher("glob:**" + filter);return FileSystemDocumentLoader.loadDocuments(dir, pathMatcher);}/*** 根据指定的目录加载目录下所有文档* @param dir 目录* @param containsSubDir 是否包含子目录* @return 文档列表*/public static List<Document> loadDocuments(String dir,Boolean containsSubDir) {if (containsSubDir) {return FileSystemDocumentLoader.loadDocumentsRecursively(dir);}return FileSystemDocumentLoader.loadDocuments(dir);}/*** 根据指定的目录加载目录下指定类型的文件* @param dir 指定要加载的目录* @param filter 文件类型* @param containsSubDir 是否包含子目录* @return 文档列表*/public static List<Document> loadDocuments(String dir,String filter,Boolean containsSubDir) {// 多级目录时写**PathMatcher pathMatcher = FileSystems.getDefault().getPathMatcher("glob:**" + filter);if (containsSubDir) {return FileSystemDocumentLoader.loadDocumentsRecursively(dir, pathMatcher);}return FileSystemDocumentLoader.loadDocuments(dir, pathMatcher);}
}
3、创建AiService接口
public interface Assistant {Flux<String> chat(@MemoryId String memoryId, @UserMessage String message);
}
4、创建AiService实例Bean
@Configuration
public class AssistantConfig {@Resourceprivate StreamingChatLanguageModel streamingChatLanguageModel;@Beanpublic Assistant assistant() {InMemoryEmbeddingStore<TextSegment> embeddingStore = getInMemoryEmbeddingStore("E:\\project\\IdeaProjects\\AI\\LangChain4j\\rag\\src\\main\\resources\\documents", ".txt");return AiServices.builder(Assistant.class).chatMemoryProvider(memoryId -> MessageWindowChatMemory.withMaxMessages(10)).contentRetriever(EmbeddingStoreContentRetriever.from(embeddingStore)).streamingChatLanguageModel(streamingChatLanguageModel).build();}private InMemoryEmbeddingStore<TextSegment> getInMemoryEmbeddingStore(String path, String filter) {InMemoryEmbeddingStore<TextSegment> embeddingStore = new InMemoryEmbeddingStore<>();List<Document> documents = DocumentUtils.loadDocuments(path, filter);if (documents.isEmpty()) {throw new RuntimeException("没有找到文件");}EmbeddingStoreIngestor.ingest(documents, embeddingStore);return embeddingStore;}}
5、controller中进行调用
public interface Assistant {Flux<String> chat(@MemoryId String memoryId, @UserMessage String message);
}
RAG核心API介绍
Document
Document代表了整个文档,比如单个PDF文件或网页。注意:Document现在只能表示文本信息
Document.text(); // 返回文档的文本Document.metadata(); // 返回文档的元数据MetadataDocument.toTextSegment(); // 把文档转为TextSegmentDocument.from(String, Metadata); // 使用文本+Metadata来创建文档Docunent.from(String); // 通过文本来创建文档,此时Metadata为null
Metadata
每个文档都包含了Metadata。它存储了文档的一些元信息,如:文档名称、来源、上次更新日期、文档所有者以及其它的一些信息。
元数据以键值对的形式进行存储,其中键为String类型,值可以是String,Integer,Long,Float,Double。
基于以下几点来看使用元数据是非常有用的:
- 在向LLM提供提示符时包含Document的内容时,可以包含元数据条目,从而为LLM提供需要考虑的附加信息,如:提供文档名称和来源可以帮助提高LLM对内容的理解
- 我们在搜索包含在提示符中的相关内容时,可以通过Metadata条目进行过滤。这样可以把语义搜索范围缩小到特定的文档
- 当文档的源被更新时,可以通过元数据条目(如:id,resource...)找到相应文档,并在EmbeddingStore中更新它以保持同步
Metadata.from(Map); // 通过指定的map来创建MetadataMetadata.put(String key, String value); // 添加指定的条目到元数据Metadata.getString(String key);
Metadata.getInteger(String key); // 通过指定的key来获取value值,并且这个值是按调的方法不同直接专为对应的类型Metadata.containsKey(String key); // 检查Metadata是否包含某个指定的keyMetadata.remove(String key); // 根据指定的key来移除一个条目Metadata.copy(); // 返回Metadata的一个副本Metadata.toMap(); // 把Metadata转为一个map
DocumentLoader
我们可以使用文本来创建一个Document,但是更为常见的是使用一个文档加载器来实现
下面列举了langchang4j中的文档加载器
- FileSystemDocumentLoader:在langchain4j模块中
- ClassPathDocumentLoader:在langchain4j模块中
- UrlDocumentLoader:在langchain4j模块中
- AmazonS3DocumentLoader:在langchain4j-docunent-loader-amazon-s3模块中
- AzureBlobStorageDocumentLoader:在langchain4j-document-loader-azure-storage-blob模块中
- GitHubDocumentLoader:在langchain4j-document-loader-azure-github
- GoogleCloudStorageDocumentLoader:在langchain4j-docuement-loader-google-cloud-storage模块中
- SeleniumDocumentLoader:在langchain4j-document-loader-selenium模块中
- TencentCosDocumentLoader:在langchain4j-document-loader-tencent-cos模块中
DocumentParser
文档可以表示为各种格式的文件,如:PDF,DOC,TXT,MD...,为了解析它们,有一个DocumentParser接口,其中包含了以下一些实现:
- TextDocumentParser:来自langchain4j模块,可以解析普通文本文件
- ApachePdfBoxDocumentParser:来自langchain4j-document-parser-apache-poi模块,可以解析office文件
- ApacheTikaDocumentParser:来自langchain4j-document-parser-apache-tika模块,可以自动检测文档的类型并且几乎可以解析所有现有文件格式
String dir = "E:\\project\\IdeaProjects\\AI\\LangChain4j\\rag\\src\\main\\resources\\documents";
// 解析单个指定的文档
Document document = FileSystemDocumentLoader.loadDocument(dir + "\\my.txt", new TextDocumentParser());
log.info("document: {}", document.text());
System.out.println("========================================================");
Metadata metadata = document.metadata();
for (String key : metadata.toMap().keySet()) {System.out.println(key + ":" + metadata.getString(key));
}// 解析指定目录下的所有文档
System.out.println("================ 解析目录下所有文档 =================");
List<Document> documentList = FileSystemDocumentLoader.loadDocuments(dir, new TextDocumentParser());
if (!documentList.isEmpty()) {for (Document doc : documentList) {Metadata docMetadata = doc.metadata();for (String key : docMetadata.toMap().keySet()) {System.out.println(key + ":" + docMetadata.getString(key));if (key.equals("file_name") && docMetadata.getString(key).equals("my.txt")) {System.out.println(doc.text());}}System.out.println("=================================================");}
}// 解析指定目录下的指定类型文档
System.out.println("================ 解析目录下指定类型文档 =================");
PathMatcher pathMatcher = FileSystems.getDefault().getPathMatcher("glob:*.txt"); // 目录下txt文件
List<Document> documentList2 = FileSystemDocumentLoader.loadDocuments(dir, pathMatcher, new TextDocumentParser());
if (!documentList2.isEmpty()) {for (Document document1 : documentList2) {System.out.println(document1.text());System.out.println("=========================================================");}
}// 加载目录及其子目录下的所有文档
System.out.println("============= 解析目录及子目录下所有文档 =================");
List<Document> documents = FileSystemDocumentLoader.loadDocumentsRecursively(dir, new TextDocumentParser());
System.out.println("加载到的文档数:" + documents.size());
在我们解析文档时,也可以不明确指定DocumentParser,这个时候会使用默认DocumentParser。它默认是通过SPI加载的(比如从langchain4j-document-parser-apache-tika或者langchain4j-easy-rag,如果其中有一个被导入的话),如果没有通过SPI找到DocumentParser则会使用TextDocumentParser。
DocumentTransformer
这个用来实现各种文档的转换,有如下的一些动作
- 清理:把文档中的不必要的噪音删除,节省token及减少干扰
- 过滤:从搜索中完全排除特定的文档
- 增强:可以向文本中添加其它的信息,从而潜在地增强搜索结果
- 总结:可以对文档进行简短的总结,这个简短的总结可以存在元数据中,可以改进搜索
- ...
在转换时还可以增加、删除、修改metadata条目
当前提供的唯一开箱即用的实现是:langchain4j-document-transformer-jsoup模块中的HtmlToTextDocumentTransformer
TextSegment
一旦文档加载完成,这个时候它们会被分割为更小的部分,langchain4j中有一个TextSegment类,它表示文档的一个片段。它只能表示文本信息。
我们为什么要进行分割呢?
- LLM有一个有限的上下文窗口,如果一次性放入整个知识库不合适
- 在提示中提供更多的信息,LLM在处理和响应的时候就越慢
- 在提示中提供更多的信息,我们调用LLM的成本越高
- 在提示中给出不相关的信息这可能增加产生幻觉的机会,这个时候有过多的不相关信息干扰
- 在提示中给出信息越多,就越难理解LLM是根据哪些信息做出的响应
我们可以通过把知识库分成更小、更容易理解的部分来解决上面的问题,这个要分多细?这个还得要视情况而定。
有两种常用的使用方法:
方法一:每个文档(pdf,网页...)都是原子的,不可分割。在RAG检索期间,检索N个最相关的文档并把它注入提示符,在这种情况下需要使用长上下文LLM,因为文档可能是相当长的,如果对于检索完整的文档很重要,这种方法是可取的。
对于方法一的优缺点如下:
优点:没有上下文丢失
缺点:消耗token会多,长文档可能包含多个主题,并非所有主题都与查询有关,各种大小的完整文档被压缩到一个固定长度的矢量中,矢量搜索的质量会受到影响
方法二:文档被分为更小的部分,RAG检索期间,检索N个最相关的段并把它注入提示符。这里要保证每个部分提供足够的上下文/信息以便于LLM理解它。为了减少LLM误解给定的片段并产生幻觉,一种常见的做法就是把文档分成有重叠的部分,注意,这种做法并不是万能的
对于方法二的优缺点如下:
优点:矢量搜索质量好,减少token的消耗
缺点:一些上下文可能丢失
TextSegment.text(); // 返回TextSegment的文本信息TextSegment.metadata(); // 返回TextSegment的metadataTextSegment.from(String, Metadata); // 根据文本与metadata来创建TextSegmentTextSegment.from(String); // 根据文本创建TextSegment,此时metadata为null
DocumentSplitter
LangChain4j有一个DocumentSplitter接口,它有几个开箱即用的实现
- DocumentByParagraphSplitter
- DocumentByLineSplitter
- DocumentBySentenceSplitter
- DocumentByWordSplitter
- DocumentByCharacterSplitter
- DocumentByRegexSplitter
- HierarchicalDocumentSplitter
工作原理:
- 实例化一个DocumentSplitter,指定TextSegment所需的大小,并可选择地指定字符或令牌的重叠部分
- 调用DocumentSplitter的split(Document),splitAll(List<Document>)
- DocumentSplitter把给定的文档拆分为更小的单元,其性质会随着拆分器而产生变化。如:DocumentByParagraphSplitter会把文档分成段落(由两个或多个连接的换行符定义),而DocumentBySentenceSplitter使用OpenNPL库的句子检测器把文档分成句子...
- DocumentSplitter然后把这些较小的单元(段落、句子...)组合到TextSegment中,在不超过1中所说的限定大小情况下在单个TextSegment中包含尽可能多的单元。如果某些单元还是过大,则调用子分割器,把不适合理细粒度单元拆分,所有元数据条目从Document复制到TextSegment。有一个唯一的元数据条目“索引”会添加到每个文本段,第一个TextSegment会包含index=0,第二个index=1...
TextSegmentTransformer
它类似于DocumentTransformer,只是说这里转换的是TextSegment
转换是没有万能的方案的,需要根据具体的情况进行实现,一般有效的改进检索技术是在每一个TextSegment当中包含文档标题或简短摘要。
Embedding
它封装了一个数字向量,表示嵌入内容的“语义”(通常是文本,如:TextSegment)
Embedding.dimension(); // 它返回嵌入向量的维度(长度)CosineSimilarity.between(Embedding, Embedding); // 计算两者之间的余弦相似度Embedding.nomalize(); // 规范化嵌入向量
EmbeddingStore
EmbeddingStore接口表示Embedding的存储,也称为矢量数据库。
EmbeddingStore可以单独或与相应的TextSegment一起存储Embedding
EmbeddingStore.add(Embedding); // 把给定嵌入添加到存储中并返回一个随机idEmbeddingStore.add(String id, Embedding); // 指定id的给定嵌入添加到存储区EmbeddingStore.add(Embedding, TextSegment); // 把给定嵌入与关联的TextSegment添加到存储中,返回一个随机的idEmbeddingStore.addAll(List<Embedding>); // 把给定嵌入列表添加到存储区,并返回一个随机id列表EmbeddingStore.addAll(List<Embedding>,List<TextSegment>); // 向存储中添加带有关联TextSegment的给定Embedding列表,返回随机id列表EmbeddingStore.addAll(List<String> ids, List<Embedding>, List<TextSegment>); // 把带有关联id和textSegment的给定嵌入列表添加到存储中EmbeddingStore.search(EmbeddingSearchRequest); // 搜索最为相似的嵌入EmbeddingStore.remove(String id); // 根据id从存储区删除指定的嵌入EmbeddingStore.removeAll(Collection<String> ids); // 根据列表中的id来批量删除存储区中的嵌入EmbeddingStore.removeAll(Filter); // 根据指定的筛选器来删除所有匹配的嵌入EmbeddingStore.removeAll(); // 删除所有的嵌入
EmbeddingSearchRequest
这个代表了在EmbeddingStore中搜索的请求,其具有如下属性:
- Embedding queryEmbedding 作为参数的嵌入
- int maxResult 要返回的最大结果数,这是一个可选参数,默认值是3
- double minScore 最低分数,范围是0~1。只有分数>= minScore的嵌入才会返回,可选参数,默认值是0
- Filter filter 搜索过程中应用于元数据的过滤器。只有元数据匹配过滤器的TextSegment才会返回
Filter
Filter允许在执行矢量搜索时按元数据项进行过滤
目前支持的操作如下:
- IsEqualTo
- IsNotEqualTo
- IsGreaterThan
- IsGreaterThanOrEqualTo
- IsLessThan
- IsLessThanOrEqualTo
- IsIn
- IsNotIn
- ContainsString
- And
- Not
- Or
注意:不是所有的嵌入存储都支持按元数据过滤的,支持的话也不一定是所有的筛选类型或操作都支持!
EmbeddingSearchResult
这个代表了EmbeddingStore中搜索结果,它包含了EmbeddingMatch的列表
EmbeddingMatch
表示匹配的嵌入及相关性评分、ID和原始嵌入数据(一般是TextSegment)
EmbeddingStoreIngestor
EmbeddingStroeIngestor表示一个管道,负责把文档流到EmbeddingStore当中去
最简单的配置中,EmbeddingStoreIngestor使用指定的EmbeddingModel嵌入提供的文档,并把它们的嵌入一起存储在指定的EmbeddingStore当中。
EmbeddingStoreIngestor ingestor = EmbeddingStoreIngestor.builder().embeddingModel(embeddingModel).embeddingStore(embeddingStore).build();
ingestor.ingest(document1);
ingestor.ingest(document2,document3);
IngestionResult ingestionResult = ingestor.ingest(List.of(document4,document5,document6));
上面所有的ingest()方法都会返回一个IngestionResut对象,在它当中有一个TokenUsage,显示了用于嵌入的令牌数量。
EmbeddingStoreIngestor可以选择使用指定的DocumentTransformer转换文档。如果希望在嵌入之前清理、丰富或格式化文档,是非常有用的。
EmbeddingStoreIngestor可以使用指定的DocumentSplitter把文档拆分为TextSegment。如果当前文档比较大并希望把它们分割为更小的textSegment,以提高相似性搜索的质量,并减少发送给LLM的提示的大小和成本是非常有用的。
EmbeddingStoreIngestor可以使用指定的TextSegmentTransformer转换TextSegment,如果希望在嵌入TextSegment之前进行清理、丰富或格式化它们,这是非常有用的。
看下面的代码:
private InMemoryEmbeddingStore<TextSegment> getInMemoryEmbeddingStoreTransformer(String path, String filter) {InMemoryEmbeddingStore<TextSegment> embeddingStore = new InMemoryEmbeddingStore<>();List<Document> documents = DocumentUtils.loadDocuments(path, filter);if (documents.isEmpty()) {throw new RuntimeException("没有找到文件");}EmbeddingStoreIngestor ingestor = EmbeddingStoreIngestor.builder()// 向每个document添加一个userId元数据条目,后面可能的话可以通过它来进行过滤.documentTransformer(document -> {document.metadata().put("userId", "xiaoxie");return document;})// 把每个文档拆分为每个文本段,每个文本段有200个令牌,其中有10个令牌重叠.documentSplitter(DocumentSplitters.recursive(200, 10))//.textSegmentTransformer(textSegment -> TextSegment.from(textSegment.metadata().getString("file_name") + "\n" + textSegment.text(),textSegment.metadata())).embeddingModel(embeddingModel).embeddingStore(embeddingStore).build();ingestor.ingest(documents);return embeddingStore;
}
注意,我们存储使用的是:InMemoryEmbeddingStore,它的向量维度是384,如我们此时使用的是百炼平台的 text-embedding-v3,它的向量维度是1024,这两者会对不上最终请求时会出错的。
所以我们不要使用百炼的这个embeddingModel,也就是直接都不使用.embedding(embeddingModel),最终改为如下:
在config类中我们创建一个Bean
@Bean("transformerAssistant")
public Assistant assistant2() {InMemoryEmbeddingStore<TextSegment> embeddingStore = getInMemoryEmbeddingStoreTransformer("E:\\project\\IdeaProjects\\AI\\LangChain4j\\rag\\src\\main\\resources\\documents", ".md");return AiServices.builder(Assistant.class).chatMemoryProvider(memoryId -> MessageWindowChatMemory.withMaxMessages(10)).contentRetriever(EmbeddingStoreContentRetriever.from(embeddingStore)).streamingChatLanguageModel(streamingChatLanguageModel).build();
}private InMemoryEmbeddingStore<TextSegment> getInMemoryEmbeddingStoreTransformer(String path, String filter) {InMemoryEmbeddingStore<TextSegment> embeddingStore = new InMemoryEmbeddingStore<>();List<Document> documents = DocumentUtils.loadDocuments(path, filter);if (documents.isEmpty()) {throw new RuntimeException("没有找到文件");}EmbeddingStoreIngestor ingestor = EmbeddingStoreIngestor.builder()// 向每个document添加一个userId元数据条目,后面可能的话可以通过它来进行过滤.documentTransformer(document -> {document.metadata().put("userId", "xiaoxie");return document;})// 把每个文档拆分为每个文本段,每个文本段有200个令牌,其中有10个令牌重叠.documentSplitter(DocumentSplitters.recursive(200, 10))//.textSegmentTransformer(textSegment -> TextSegment.from(textSegment.metadata().getString("file_name") + "\n" + textSegment.text(),textSegment.metadata()))// 这里不要加上embeddingModel,因为InMemoryEmbeddingStore向量维度是384。现在百炼平台引入embeddingModel向量维度无法对应// .embeddingModel(embeddingModel).embeddingStore(embeddingStore).build();ingestor.ingest(documents);return embeddingStore;
}
controller中进行请求如下:
@Resource(name = "transformerAssistant")
private Assistant assistant2;@GetMapping(value = "/chat2",produces = "text/plain;charset=UTF-8")
public Flux<String> chat2(@RequestParam("message") String message, @RequestParam("memoryId") String memoryId) {return assistant2.chat(memoryId, message);
}
Native RAG
一旦我们对文档进行ingested,我们就可以创建一个EmbeddingStoreContentRetriever,在其中可以指定EmbeddingStroe,EmbeddingModel
config类中创建Bean的代码改为如下:
@Bean("transformerAssistant")
public Assistant assistant2() {InMemoryEmbeddingStore<TextSegment> embeddingStore = getInMemoryEmbeddingStoreTransformer("E:\\project\\IdeaProjects\\AI\\LangChain4j\\rag\\src\\main\\resources\\documents", ".md");EmbeddingStoreContentRetriever embeddingStoreContentRetriever = EmbeddingStoreContentRetriever.builder().embeddingModel(embeddingModel).embeddingStore(embeddingStore).maxResults(5).minScore(0.75).build();return AiServices.builder(Assistant.class).chatMemoryProvider(memoryId -> MessageWindowChatMemory.withMaxMessages(10))// .contentRetriever(EmbeddingStoreContentRetriever.from(embeddingStore)).contentRetriever(embeddingStoreContentRetriever).streamingChatLanguageModel(streamingChatLanguageModel).build();
}private InMemoryEmbeddingStore<TextSegment> getInMemoryEmbeddingStoreTransformer(String path, String filter) {InMemoryEmbeddingStore<TextSegment> embeddingStore = new InMemoryEmbeddingStore<>();List<Document> documents = DocumentUtils.loadDocuments(path, filter);if (documents.isEmpty()) {throw new RuntimeException("没有找到文件");}EmbeddingStoreIngestor ingestor = EmbeddingStoreIngestor.builder()// 向每个document添加一个userId元数据条目,后面可能的话可以通过它来进行过滤.documentTransformer(document -> {document.metadata().put("userId", "xiaoxie");return document;})// 把每个文档拆分为每个文本段,每个文本段有200个令牌,其中有10个令牌重叠.documentSplitter(DocumentSplitters.recursive(200, 10))//.textSegmentTransformer(textSegment -> TextSegment.from(textSegment.metadata().getString("file_name") + "\n" + textSegment.text(),textSegment.metadata())).embeddingModel(embeddingModel).embeddingStore(embeddingStore).build();ingestor.ingest(documents);return embeddingStore;
}
注意:这里使用的还是内存嵌入存储,为什么此时又可以使用百炼的EmbeddingModel了,这个是因在创建EmbeddingStoreIngestor时指定了EmbeddingModel,在创建AiService时,构造EmbeddingStoreContentRetriever时也指写了EmbeddingModel,这两个EmbeddingModel是一样的,所以它们的向量维度也一定是一样的!!
Advanced RAG
对于高级RAG可以使用LangChain4j中如下核心组件实现
- QueryTransformer
- QueryRouter
- ContentRetriever
- ContentAggregator
- ContentInjector
官方提供的下图展示了各个组件的协同工作过程
从这个图上可以看到这个过程大致如下:
- 用户生成一个UserMessage,它会被转换为一个Query
- QueryTransformer把查询转换为一个或多个查询
- 每个查询由QueryRouter路由到一个或多个ContentRetriever
- 每一个ContentRetriever为每个查询检索相关的内容
- ContentAggregator把所有检索到的内容组合成一个最终排名列表
- Content列表会被注入到原始UserMessage中
- 最终把包含原始查询和注入的相关内容的UserMessage发送到LLM
RetrievalAugmentor
它是进入RAG管道的入口,负责用各种来源检索到相关内容扩充ChatMessage
每次调用AiService时,会调用指定的RetrievalAugmentor扩展当前UserMessage,有一个RetrievalAugmentor的默认实现,也可以自定义实现。
DefaultRetrievalAugmentor
LangChain4j提供了一个实现DefaultRetrievalAugmentor,它适用于大多数RAG。
Query
它表示RAG管道中的用户查询,包含查询文本和查询元数据
QueryMetadata
查询中的元数据包含可能在RAG管道的各个组件中的有用信息,如:
Metadata.userMessage() 应该被增强的原始UserMessage
Metadata.chatMemoryId() @MemoryId注解指定的参数值,用来识别用户,并在检索期间应用于访问限制和过滤器
Metadata.chatMemory() 所有之前的聊天信息,有助于理解查询所处的上下文
QueryTransformer
把给定的查询转换为一个或多个查询。目标是通过修改或扩展原始查询来提高检索质量
一些可以改进检索的方法有:
- 查询压缩:Query compression
- 查询扩展:Query expansion
- 查询重写:Query re-writing
- 退一步提示:Step-back prompting
- 文档嵌入:Hypothetical document embeddings
DefaultQueryTransformer
它是DefaultRetrievalAugmentor中使用的默认实现,它不会对Query进行任何修改,而只是把它传递出去。
CompressingQueryTransformer
把给定查询和之前的对话压缩为一个独立的查询。当用户可能会提出后续问题,涉及到之前的问题或答案中的信息时会非常有用。
ExpandingQueryTransformer
把给定查询扩展为多个查询。LLM可以以各种方式重新表述和重新制定查询,这有助于检索更相关的内容。
Content
Content表示与用户查询相关的内容,当前只支持文本内容
ContentRetriever
使用给定的查询从底层数据源检索内容,底层数据源可以是任何内容:
- EmbeddingStore
- 全文检索引擎
- 矢量与全文检索的混合
- 网络搜索引擎
- 知识图谱
- SQL数据库
- ...
由ContentRetriever返回的列表内容会按相关性从高到低排序
EmbeddingStoreContentRetriever
它用EmbeddingModel嵌入查询,从EmbeddingStore检索相关内容,如下所示代码:
@Bean("transformerAssistant")public Assistant assistant2() {InMemoryEmbeddingStore<TextSegment> embeddingStore = getInMemoryEmbeddingStoreTransformer("E:\\project\\IdeaProjects\\AI\\LangChain4j\\rag\\src\\main\\resources\\documents", ".md");EmbeddingStoreContentRetriever embeddingStoreContentRetriever = EmbeddingStoreContentRetriever.builder().embeddingModel(embeddingModel).embeddingStore(embeddingStore).maxResults(5)// maxResults是动态的.dynamicMaxResults(query -> 3).minScore(0.75)// minScore是动态的.dynamicMinScore(query -> 0.75)// .filter(metadataKey("userId").isEqualTo("xiaoxie"))// 动态filter.dynamicFilter(query -> {String chatMemoryId = (String) query.metadata().chatMemoryId();return metadataKey("userId").isEqualTo(chatMemoryId);}).build();return AiServices.builder(Assistant.class).chatMemoryProvider(memoryId -> MessageWindowChatMemory.withMaxMessages(10))// .contentRetriever(EmbeddingStoreContentRetriever.from(embeddingStore))// .contentRetriever(embeddingStoreContentRetriever).retrievalAugmentor(DefaultRetrievalAugmentor.builder().contentRetriever(embeddingStoreContentRetriever).build()).streamingChatLanguageModel(streamingChatLanguageModel).build();}
QueryRouter
QueryRouter负责把Query路由到适当的ContentRetriever
DefaultQueryRouter
它是DefaultRetrievalAugmentor中使用的默认实现。把每个查询路由到所有配置的Conentretiever
LanguageModelQueryRouter
用LLM来决定把给定的查询路由到哪里
ContentAggregator
负责聚合来自以下内容的多个排名列表
- 多个Query
- 多个ContentRetriever
DefaultContentAggregator
DefaultContentAggregator是ContentAggregator是ContentAggregator的默认实现
ReRankingContentAggregator
ReRankingContentAggregator用一个ScoringModel,像Cohere,执行重新排名。
ContentInjector
负责把ContentAggregator返回的内容注入到UserMessage中
DefaultContentInjector
它是ContentInjector的默认实现,它简单地把Contents附加到UserMessage的末尾
我们可以重写default PromptTemplate
@Bean("transformerAssistant")public Assistant assistant2() {InMemoryEmbeddingStore<TextSegment> embeddingStore = getInMemoryEmbeddingStoreTransformer("E:\\project\\IdeaProjects\\AI\\LangChain4j\\rag\\src\\main\\resources\\documents", ".md");EmbeddingStoreContentRetriever embeddingStoreContentRetriever = EmbeddingStoreContentRetriever.builder().embeddingModel(embeddingModel).embeddingStore(embeddingStore).maxResults(5)// maxResults是动态的.dynamicMaxResults(query -> 3).minScore(0.75)// minScore是动态的.dynamicMinScore(query -> 0.75)// .filter(metadataKey("userId").isEqualTo("xiaoxie"))// 动态filter.dynamicFilter(query -> {String chatMemoryId = (String) query.metadata().chatMemoryId();return metadataKey("userId").isEqualTo(chatMemoryId);}).build();return AiServices.builder(Assistant.class).chatMemoryProvider(memoryId -> MessageWindowChatMemory.withMaxMessages(10))// .contentRetriever(EmbeddingStoreContentRetriever.from(embeddingStore))// .contentRetriever(embeddingStoreContentRetriever).retrievalAugmentor(DefaultRetrievalAugmentor.builder().contentRetriever(embeddingStoreContentRetriever).contentInjector(DefaultContentInjector.builder().promptTemplate(PromptTemplate.from("{{userMessage}}\n,基于以下回答:{{contents}}")).build()).build()).streamingChatLanguageModel(streamingChatLanguageModel).build();}
看上面在构造AiService的Bean的时候在retrievalAugmentor()中有一个.contentInjector()
注意:PromptTemplate必须包含{{userMessage}}和{{contents}}变量。
并行化
当只有一个查询和一个ContentRetriever,DefaultRetrievalAugmentor在同一个线程中执行查询路由和内容检索,否则会使用Executor来进行并行处理。
默认情况下,使用修改后的(keepAliveTime是1秒而不是60秒)Executors.newCachedThreadPool(),但是你可以在创建DefaultRetrievalAugmentor时提供一个自定义Executor实例
DefaultRetrievalAugmentor.builder()....executor(executor).build;
Accessing Soruces
如查希望在使用AIService时访问Sources,可以通过Result类获取
interface Assistant {Result<String> chat(String userMessage);
}Result<String> result = assistant.chat("How to do Easy RAG with LangChain4j?");String answer = result.content();
List<Content> sources = result.sources();
如果是流式的,可以使用onRetrieved来指定
interface Assistant {TokenStream chat(String userMessage);
}assistant.chat("How to do Easy RAG with LangChain4j?").onRetrieved((List<Content> sources) -> ...).onPartialResponse(...).onCompleteResponse(...).onError(...).start();