本文将介绍如何使用LangChain4j实现一个简单的RAG应用,对于RAG是啥这里就不多赘述,网上到处都是介绍,如果您想了解可以看下这篇文章:# 落地RAG系列:RAG入门及RAG面临的挑战和解决方案!!
LangChain4j内置EasyRag
LangChain4j 具有“Easy RAG”功能,可以尽可能轻松地开始使用 RAG。您不必学习嵌入、选择向量存储、找到正确的嵌入模型、弄清楚如何解析和拆分文档等。只需指向您的文档,LangChain4j 就会发挥它的魔力。
LangChain4j的“Easy RAG”也是通往高级RAG的必经之路,必须先了解清楚了“Easy RAG”的工作流程以及存在的问题。
Easy RAG 使用
第一步:引入依赖
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-easy-rag</artifactId>
<version>0.32.0</version>
</dependency>
第二步:指定位置加载文档内容
public class LoaderService {
/**
* 加载所有类型的文档内容
*
* @param path where to load documents from
* @return a list of documents
*/
public List<Document> loadFromFileSystem(String path) {
return FileSystemDocumentLoader.loadDocuments(path);
}
}
FileSystemDocumentLoader使用ApacheTikaDocumentParser加载不同类型的文档。我们无需关心的文档的类型。
第三步:将文档向量化处理
public class EmbeddingService {
public void embed(List<Document> documents) {
InMemoryEmbeddingStore<TextSegment> embeddingStore = new InMemoryEmbeddingStore<>();
EmbeddingStoreIngestor.ingest(documents, embeddingStore);
}
}
第四步:Chat Service实现
public interface Assistant {
String chat(String userMessage);
}
第五步:Controller实现
@RestController
@RequiredArgsConstructor
public class EasyRagController {
private final OpenAiChatModel openAiChatModel;
private final EmbeddingStore<TextSegment> embeddingStore;
private final EmbeddingService embeddingService;
private final LoaderService loaderService;
@GetMapping("/chat")
public String chat(String message) {
Assistant assistant = AiServices.builder(Assistant.class)
.chatLanguageModel(openAiChatModel)
.chatMemory(MessageWindowChatMemory.withMaxMessages(10))
.contentRetriever(EmbeddingStoreContentRetriever.from(embeddingStore))
.build();
return assistant.chat(message);
}
@GetMapping("/embedded")
public void embedded() {
// 指定文件路径/或者实现方法上传文档都可以
List<Document> documents = loaderService.loadFromFileSystem("文件路径");
embeddingService.embed(documents);
}
}
测试:
### 将文件向量化到向量数据库,文档内容是一本阿里开发手册
GET http://localhost:8888/embedded
### 测试
GET http://localhost:8888/chat?message=Java 开发手册当前是什么版本?
###
GET http://localhost:8888/chat?message=手册的愿景?
####
GET http://localhost:8888/chat?message=错误码规范都有哪些?
通过测试效果一般,虽然都能回答,但还是不够精确。具体可以参考 GitHub上的示例代码easy-rag
LangChain4j RAG APIs
Document 「文档」
Documnet表示一整篇文档,如一篇PDF文档或者一个网页内容。目前仅能表示文本信息,LangChain4j官方文档说未来Document会支持图片和表格。
Document核心方法
- Document.text():返回文档中的文本信息
- Document.metadata():返回文档的原数据信息
- Document.toTextSegment():将文档转换为文本片段
- Document.from(String, Metadata):根据文本和元数据创建文本对象
- Document.from(String):根据文本创建Document对象
Metadata 「文档元数据」
每个文档都包含元数据,它存储有关文档的元信息。例如其名称,来源,最后更新日期,所有者。
元数据的存储方式:kv键值对方式存储
元数据的作用:
- 根据元数据进行数据的精确过滤,比如搜索文档来源github的数据
- 根据元数据进行权限控制,比如仅能搜索作者为“张三”出的书,其它不允许
- 通过元数据一些信息,可以提高大模型的理解能力。
- 对数据进行更新时,可以使用元数据中设置的唯一标识等等,
作用还是比较强大的,看在实际的业务场景中怎么设计和使用了。
Metadata核心方法:
- Metadata.from(Map) 从指定的
Map创建元数据对象 - Metadata.put(String key, String value):向元数据添加一条数据
- metadata.containsKey(String key):检查
元数据是否包含具有指定键的条目。 - Metadata.copy() 返回
元数据的副本 - Metadata.toMap() 将
元数据转换为 Map 对象
Document Loader 「文档加载器」
文档加载器的作用:就是将指定位置的文档解析转换为文件输入流和元数据。目前支持多种内置加载器。具体可以参考# LangChain4j Document Loaders和 Parsers 详解和应用 中的 Document Lodaers部分。这里就不再赘述。
Document Parser 「文档解析器」
文档解析器的作用:就是将加载文档输入流解析为Document对象。它需要与Documnet Loader配合使用,分工明确!DocumentLoader负载加载文档,则Document Parser负责解析加载过来的文档,并将其转换为Document对象。
具体可以参考# LangChain4j Document Loaders和 Parsers 详解和应用 中的 Document Parser部分。这里就不再赘述。
Document Transfomer 「文档转换器」
文档转换器的作用:实现各种文档的转换,转换的目的是对文档进行清理、过滤、内容增强等,提供高质量的数据内容。「元数据在该阶段进行增删改」
- 清理:将文档中不必要的噪音删除,节省token和减少干扰
- 过滤:去除不必要的文档
- 丰富:可以向文档中添加一些必要信息,增强搜索结果
- 摘要:可以对文档进行摘要,其简短的摘要可以存储在元数据中,潜在的改进搜索。
LangChain4j框架内置转换器:
- HtmlTextExtractor:从原始 HTML 中提取所需的文本内容和元数据条目。
仅提供唯一一个,因为需求、场景多样,需定制DocumentTransformer。
TextSegment 「文档片段」
文档被加载后,就需要将文档进行拆分为文档片段。一个Document包含1~n个TextSegment。
对于文档拆分为TextSegment是一个技术活。拆分不好直接影响搜索效果,进而影响生成效果。
为什么会需要拆分?
- 大模型上下文窗口的限制
- 提示信息过长,导致大模型处理的时间就越长
- 提示信息过多,导致消耗的token越多,成本越大
- 提示信息中不相关的信息过多,分散大模型的注意力,导致产生幻觉
目前有两个比较广泛的做法
- 不拆分,每个文档都是原子的,不可分割的。「这种情况需要长上下文的LLM」
- 场景:检索完整的文档很重要,这种方法很重要
- 优点:不会丢失任何上下文信息
- 缺点:
- 消耗的Token多,成本高
- 提问内容可能仅与部分内容相关
- 向量搜索会受到影响,因为整篇文档被压缩一个固定的向量
- 拆分,文档被拆分更小的部分,如:章节、段落甚至句子 「挑战性高」
- 挑战:
- 如何切分,保证上下文信息不丢失,丢失上下文同样会导致大模型误解或者产生幻觉。
- 解决策略:重叠段/句子窗口检索/自动合并检索/父文档检索
- 优势:
- 提高向量搜索的质量
- 减少token消耗,降低成本
- 缺点:
- 可能会丢失上下文
- 挑战:
TextSegment核心方法
- TextSegment.text():返回
TextSegment中的文本 - TextSegment.metadata():返回
TextSegment中的元数据 - TextSegment.from(String, Metadata)根据文本和
元数据创建TextSegment对象。 - TextSegment.from(String):根据文本创建
TextSegment对象。
Documnet Splitter 「文档拆分器」
介绍TextSegment拆分的一些分析,现在真正干活的该上场了,那就是文档拆分器。LangChain4j提供了7个开箱即用的实现。
- DocumentByParagraphSplitter:根据段落拆分
- DocumentByLineSplitter:根据行拆分
- DocumentBySentenceSplitter:根据句子拆分
- DocumentByWordSplitter:根据词拆分
- DocumentByCharacterSplitter:根据字符拆分
- DocumentByRegexSplitter:根据正则拆分
- DocumentSplitters.recursive(...):递归拆分
这些实现类,我还没有真正的用过,没有实验其每个的拆分效果和搜索效果是什么样子的。但是有一点需要说明,在实际的业务场景中,可能需要根据业务场景选择合适的拆分方式。
TextSegmentTransformer 「文档片段转换器」
TextSegmentTransformer 与 DocumentTransformer功能基本上一致,只是TextSegmentTransformer针对对象是TextSegment。这里不再赘述。
LangChain4j框架也没有内置默认实现,它还是建议根据业务自行定制实现。
Embedding 「嵌入」
通俗的讲嵌入就是将文本、图片、视频等高维度数据转换为低维度的向量数据。该向量并能进行语义表达。
可以看下# Spring AI Embedding模型概念、源码分析和使用示例 和# LangChain4j系列:LangChain4j Embedding模型概念和使用示例 两篇文章。
其它参考: www.elastic.co/what-is/vec…
Embedding核心方法:
- Embedding.dimension() 返回嵌入向量的维度
- CosineSimilarity.between(Embedding, Embedding) 计算 2 个
嵌入之间的余弦相似度。 - Embedding.normalize() 对嵌入向量进行归一化
Embedding Model 「嵌入模型」
有了嵌入的概念,将数据转换为向量,那么谁来干这活呢?嵌入模型就可以。 LangChain4j集成了 15+个嵌入模块,看下# LangChain4j系列:LangChain4j Embedding模型概念和使用示例 这篇文章。这里也不多赘述。
Embedding Model核心方法:
- EmbeddingModel.embed(String) 嵌入给定的文本
- EmbeddingModel.embed(TextSegment) 嵌入给定的TextSegment
- EmbeddingModel.embedAll(List) 嵌入所有给定的 TextSegment。
- EmbeddingModel.dimension() 返回此模型生成的
嵌入的维度
Embedding Store 「向量存储」
通过嵌入模型也将数据转换向量了,转换为向量之后总的有地方存储吧,这个存储还的支持相似度搜索等等,向量存储应运而生。
EmbeddingStore 接口表示 Embedding的存储,也称为向量数据库。它允许存储和有效搜索相似性搜索。
Embedding Store核心接口
- EmbeddingStore.add(Embedding): 将给定的
嵌入对象添加到存储中并返回一个随机 ID。 - EmbeddingStore.add(String id, Embedding):指定 ID 的给定
嵌入添加到向量存储中。 - EmbeddingStore.add(Embedding, TextSegment):将给定的嵌入对象并关联一个TextSegment存储到向量数据库中。
- EmbeddingStore.addAll(List):将一组嵌入对象存储到向量数据库,并返回一组对应的Id
- EmbeddingStore.addAll(List, List)
- EmbeddingStore.search(EmbeddingSearchRequest):相似性搜索
- EmbeddingStore.remove(String id):删除嵌入数据
- EmbeddingStore.removeAll(Collection ids):删除一组嵌入数据
- EmbeddingStore.removeAll(Filter):删除满足条件的嵌入数据
- EmbeddingStore.removeAll():删除所有的嵌入数据
具体使用可以参考# LangChain4j系列:LangChain4j + Milvus向量数据库 +Omalla(qwen:7b) Embedding实战
Embedding Store Ingestor 「向量存储管道」
EmbeddingStoreIngestor 表示一个引入管道,负责将文档引入到 EmbeddingStore 中。下面是其使用示例代码:
代码的作用就是:将文档加入元数据 userId,然后按照一定的规则进行拆分,拆分后的文本片段在加入元数据file_name, 接着使用嵌入大模型实现向量化,最后一步将向量化后的内容存储到向量数据库中。
EmbeddingStoreIngestor ingestor = EmbeddingStoreIngestor.builder()
// adding userId metadata entry to each Document to be able to filter by it later
.documentTransformer(document -> {
document.metadata().put("userId", "12345");
return document;
})
// splitting each Document into TextSegments of 1000 tokens each, with a 200-token overlap
.documentSplitter(DocumentSplitters.recursive(1000, 200, new OpenAiTokenizer()))
// adding a name of the Document to each TextSegment to improve the quality of search
.textSegmentTransformer(textSegment -> TextSegment.from(
textSegment.metadata("file_name") + "\n" + textSegment.text(),
textSegment.metadata()
))
.embeddingModel(embeddingModel)
.embeddingStore(embeddingStore)
.build();
总结
本篇文章主要对LangChain4j框架内置的Easy RAG进行简单的使用,体验一把RAG应用的魅力,虽然有魅力,但是效果不理想。然后讲解RAG APIs的核心方法、作用和常用场景,为后续开发自己的RAG应用打下基础。
对于LangChain4j框架的学习也就到此一段落了,下面我们将进入到高级RAG应用的落地专栏,本专栏还是依赖于LangChain4j去实现。将详细介绍落地RAG应用的技术细节和实现方案。