RAG(检索增强生成)
LLM 的知识仅限于其训练所用的数据。
如果您想让 LLM 了解特定领域的知识或专有数据,可以:
- 使用 RAG(我们将在本节中介绍)
- 使用您的数据对 LLM 进行微调
- 结合 RAG 和微调
什么是 RAG?
简单来说,RAG 是在将提示发送给 LLM 之前,从您的数据中找到并注入相关信息片段的方法。
通过这种方式,LLM 将获得(希望是)相关信息,并能够利用这些信息进行回复,从而降低幻觉的发生概率。
您可以使用各种信息检索方法来查找相关信息。最流行的方法有:
- 全文(关键词)搜索:此方法使用 TF-IDF 和 BM25 等技术,通过将查询(例如用户的提问)中的关键词与文档数据库进行匹配来搜索文档。它根据关键词在每个文档中的频率和相关性对结果进行排名。
- 向量搜索,也称为“语义搜索”:使用嵌入模型将文本文档转换为数字向量。然后,它根据查询向量和文档向量之间的余弦相似度或其他相似度/距离度量来查找和排名文档,从而捕捉更深层次的语义含义。
- 混合搜索:结合多种搜索方法(例如全文 + 向量)通常可以提高搜索的有效性。
目前,本页面主要关注向量搜索。全文和混合搜索目前仅由 Azure AI Search 集成支持,详情请参见 AzureAiSearchContentRetriever
。我们计划在不久的将来扩展 RAG 工具箱,以包含全文和混合搜索。
RAG 阶段
RAG 过程分为 2 个不同的阶段:索引和检索。LangChain4j 为这两个阶段提供了工具。
索引
在索引阶段,文档经过预处理,以便在检索阶段能够进行高效搜索。
这个过程因所使用的信息检索方法而异。对于向量搜索,这通常包括清理文档、使用额外数据和元数据对其进行丰富、将其分割成更小的片段(又称分块)、嵌入这些片段,最后将它们存储在嵌入存储(又称向量数据库)中。
索引阶段通常在离线进行,这意味着它不需要最终用户等待其完成。例如,可以通过 cron job 在周末每周重新索引一次内部公司文档来实现。负责索引的代码也可以是一个单独的应用程序,只处理索引任务。
然而,在某些情况下,最终用户可能希望上传他们的自定义文档,以使其可供 LLM 访问。在这种情况下,索引应该在在线执行,并作为主应用程序的一部分。
这是索引阶段的简化图:
检索
检索阶段通常在在线进行,当用户提交一个需要使用已索引文档回答的问题时。
这个过程因所使用的信息检索方法而异。对于向量搜索,这通常包括嵌入用户的查询(问题),并在嵌入存储中执行相似度搜索。然后,将相关的片段(原始文档的片段)注入到提示中并发送给 LLM。
这是检索阶段的简化图:
LangChain4j 中的 RAG 风格
LangChain4j 提供三种风格的 RAG:
- Easy RAG:开始使用 RAG 的最简单方法
- Naive RAG:使用向量搜索的 RAG 基本实现
- Advanced RAG:一个模块化的 RAG 框架,允许进行额外的步骤,例如查询转换、从多个来源检索和重新排名
Easy RAG
LangChain4j 有一个 “Easy RAG” 功能,使入门 RAG 尽可能简单。您无需学习嵌入、选择向量存储、找到合适的嵌入模型、弄清楚如何解析和分割文档等。只需指向您的文档,LangChain4j 就会施展它的“魔法”。
如果您需要可定制的 RAG,请跳到下一节。
如果您正在使用 Quarkus,还有一种更简单的方法来做 Easy RAG。请阅读 Quarkus 文档。
注
当然,这种 “Easy RAG” 的质量会低于定制的 RAG 设置。然而,这是开始学习 RAG 和/或制作概念验证的最简单方法。稍后,您将能够平稳地从 Easy RAG 过渡到更高级的 RAG,调整和定制越来越多的方面。
- 导入
langchain4j-easy-rag
依赖项:
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-easy-rag</artifactId>
<version>1.3.0-beta9</version>
</dependency>
- 加载您的文档:
List<Document> documents = FileSystemDocumentLoader.loadDocuments("/home/langchain4j/documentation");
这将加载指定目录中的所有文件。
底层发生了什么?
Apache Tika 库支持多种文档类型,用于检测文档类型并对其进行解析。由于我们没有明确指定要使用的 DocumentParser
,FileSystemDocumentLoader
将加载一个由 langchain4j-easy-rag
依赖项通过 SPI 提供的 ApacheTikaDocumentParser
。
如何自定义加载文档?
如果您想加载所有子目录中的文档,可以使用 loadDocumentsRecursively
方法:
List<Document> documents = FileSystemDocumentLoader.loadDocumentsRecursively("/home/langchain4j/documentation");
此外,您可以使用 glob 或 regex 过滤文档:
PathMatcher pathMatcher = FileSystems.getDefault().getPathMatcher("glob:*.pdf");
List<Document> documents = FileSystemDocumentLoader.loadDocuments("/home/langchain4j/documentation", pathMatcher);
注
使用 loadDocumentsRecursively
方法时,您可能希望在 glob 中使用双星号(而不是单个):glob:**.pdf
。
- 现在,我们需要预处理并将文档存储在专门的嵌入存储中,也称为向量数据库。这对于在用户提问时快速找到相关信息是必要的。我们可以使用我们支持的 15+ 种嵌入存储中的任何一种,但为了简单起见,我们将使用一个内存中的嵌入存储:
InMemoryEmbeddingStore<TextSegment> embeddingStore = new InMemoryEmbeddingStore<>();
EmbeddingStoreIngestor.ingest(documents, embeddingStore);
底层发生了什么?
EmbeddingStoreIngestor
通过 SPI 从langchain4j-easy-rag
依赖项加载一个DocumentSplitter
。每个Document
被分割成更小的片段(TextSegment
s),每个片段最多包含 300 个 token,并有 30 个 token 的重叠。EmbeddingStoreIngestor
通过 SPI 从langchain4j-easy-rag
依赖项加载一个EmbeddingModel
。每个TextSegment
使用EmbeddingModel
转换为一个Embedding
。
注
我们选择 bge-small-en-v1.5 作为 Easy RAG 的默认嵌入模型。它在 MTEB 排行榜上取得了令人印象深刻的成绩,其量化版本仅占用 24 MB 的空间。因此,我们可以使用 ONNX Runtime 轻松地将其加载到内存中并在同一进程中运行。
是的,没错,您可以在同一 JVM 进程中完全离线地将文本转换为嵌入,无需任何外部服务。LangChain4j 开箱即用地提供了 5 个流行的嵌入模型。
- 所有
TextSegment
-Embedding
对都存储在EmbeddingStore
中。
- 最后一步是创建一个 AI Service,它将作为我们与 LLM 的 API:
interface Assistant {
String chat(String userMessage);
}
ChatModel chatModel = OpenAiChatModel.builder()
.apiKey(System.getenv("OPENAI_API_KEY"))
.modelName(GPT_4_O_MINI)
.build();
Assistant assistant = AiServices.builder(Assistant.class)
.chatModel(chatModel)
.chatMemory(MessageWindowChatMemory.withMaxMessages(10))
.contentRetriever(EmbeddingStoreContentRetriever.from(embeddingStore))
.build();
在这里,我们配置 Assistant
使用 OpenAI LLM 来回答用户问题,记住对话中最新的 10 条消息,并从包含我们文档的 EmbeddingStore
中检索相关内容。
- 现在我们就可以和它聊天了!
String answer = assistant.chat("How to do Easy RAG with LangChain4j?");
核心 RAG API
LangChain4j 提供了一套丰富的 API,让您可以轻松构建自定义 RAG 管道,从简单的到高级的。在本节中,我们将介绍主要的领域类和 API。
Document
Document
类表示一个完整的文档,例如单个 PDF 文件或网页。目前,Document
只能表示文本信息,但未来的更新将使其也支持图像和表格。
有用的方法
Document.text()
返回Document
的文本Document.metadata()
返回Document
的Metadata
(参见下面的 “Metadata” 部分)Document.toTextSegment()
将Document
转换为TextSegment
(参见下面的 “TextSegment” 部分)Document.from(String, Metadata)
从文本和Metadata
创建一个Document
Document.from(String)
从文本创建一个带有空Metadata
的Document
Metadata
每个 Document
都包含 Metadata
。它存储关于 Document
的元信息,例如其名称、来源、上次更新日期、所有者或任何其他相关细节。
Metadata
以键值对映射的形式存储,其中键的类型为 String
,值的类型可以是以下之一:String
、Integer
、Long
、Float
、Double
、UUID
。
Metadata
有几个用途:
- 在将
Document
的内容包含在发送给 LLM 的提示中时,也可以包含元数据条目,为 LLM 提供额外的参考信息。例如,提供Document
名称和来源可以帮助 LLM 更好地理解内容。 - 在搜索要包含在提示中的相关内容时,可以按
Metadata
条目进行过滤。例如,您可以将语义搜索范围缩小到仅属于特定所有者的Document
。 - 当
Document
的来源更新时(例如,文档的某个特定页面),可以通过其元数据条目(例如 “id”、“source” 等)轻松找到相应的Document
,并在EmbeddingStore
中进行更新以保持同步。
有用的方法
Metadata.from(Map)
从Map
创建Metadata
Metadata.put(String key, String value)
/put(String, int)
/ 等,向Metadata
添加条目Metadata.putAll(Map)
向Metadata
添加多个条目Metadata.getString(String key)
/getInteger(String key)
/ 等,返回Metadata
条目的值,并将其转换为所需的类型Metadata.containsKey(String key)
检查Metadata
是否包含具有指定键的条目Metadata.remove(String key)
按键从Metadata
中删除条目Metadata.copy()
返回Metadata
的副本Metadata.toMap()
将Metadata
转换为Map
Metadata.merge(Metadata)
将当前Metadata
与另一个Metadata
合并
Document Loader
您可以从 String
创建一个 Document
,但更简单的方法是使用我们库中包含的文档加载器之一:
FileSystemDocumentLoader
来自langchain4j
模块ClassPathDocumentLoader
来自langchain4j
模块UrlDocumentLoader
来自langchain4j
模块AmazonS3DocumentLoader
来自langchain4j-document-loader-amazon-s3
模块AzureBlobStorageDocumentLoader
来自langchain4j-document-loader-azure-storage-blob
模块GitHubDocumentLoader
来自langchain4j-document-loader-github
模块GoogleCloudStorageDocumentLoader
来自langchain4j-document-loader-google-cloud-storage
模块SeleniumDocumentLoader
来自langchain4j-document-loader-selenium
模块PlaywrightDocumentLoader
来自langchain4j-document-loader-playwright
模块TencentCosDocumentLoader
来自langchain4j-document-loader-tencent-cos
模块
Document Parser
Document
可以表示各种格式的文件,例如 PDF、DOC、TXT 等。为了解析这些格式,有一个 DocumentParser
接口,库中包含了一些实现:
TextDocumentParser
来自langchain4j
模块,可以解析纯文本格式的文件(例如 TXT、HTML、MD 等)ApachePdfBoxDocumentParser
来自langchain4j-document-parser-apache-pdfbox
模块,可以解析 PDF 文件ApachePoiDocumentParser
来自langchain4j-document-parser-apache-poi
模块,可以解析 MS Office 文件格式(例如 DOC、DOCX、PPT、PPTX、XLS、XLSX 等)ApacheTikaDocumentParser
来自langchain4j-document-parser-apache-tika
模块,可以自动检测和解析几乎所有现有文件格式
以下是如何从文件系统加载一个或多个 Document
的示例:
// 加载单个文档
Document document = FileSystemDocumentLoader.loadDocument("/home/langchain4j/file.txt", new TextDocumentParser());
// 加载目录中的所有文档
List<Document> documents = FileSystemDocumentLoader.loadDocuments("/home/langchain4j", new TextDocumentParser());
// 加载目录中所有 *.txt 文档
PathMatcher pathMatcher = FileSystems.getDefault().getPathMatcher("glob:*.txt");
List<Document> documents = FileSystemDocumentLoader.loadDocuments("/home/langchain4j", pathMatcher, new TextDocumentParser());
// 加载目录及其子目录中的所有文档
List<Document> documents = FileSystemDocumentLoader.loadDocumentsRecursively("/home/langchain4j", new TextDocumentParser());
您也可以不显式指定 DocumentParser
来加载文档。在这种情况下,将使用默认的 DocumentParser
。默认的解析器通过 SPI 加载(例如从 langchain4j-document-parser-apache-tika
或 langchain4j-easy-rag
,如果其中一个被导入)。如果没有通过 SPI 找到 DocumentParser
,则使用 TextDocumentParser
作为回退。
Document Transformer
DocumentTransformer
的实现可以执行各种文档转换,例如:
- 清理:这涉及从
Document
的文本中删除不必要的噪音,可以节省 token 并减少干扰。 - 过滤:完全排除某些
Document
不进行搜索。 - 丰富:可以向
Document
添加额外信息,以潜在地增强搜索结果。 - 摘要:可以对
Document
进行摘要,并将其简短摘要存储在Metadata
中,以便稍后包含在每个TextSegment
中(我们将在下面介绍),以潜在地改善搜索。 - 等等。
此阶段还可以添加、修改或删除 Metadata
条目。
目前,开箱即用提供的唯一实现是 langchain4j-document-transformer-jsoup
模块中的 HtmlToTextDocumentTransformer
,它可以从原始 HTML 中提取所需的文本内容和元数据条目。
由于没有一刀切的解决方案,我们建议根据您的独特数据实施您自己的 DocumentTransformer
。
Graph Transformer
GraphTransformer
是一个接口,通过提取语义图元素(例如节点和关系),将非结构化的 Document
对象转换为结构化的 GraphDocument
。它非常适合将原始文本转换为结构化语义图。
GraphTransformer
将原始文档转换为 GraphDocument
。这些包括:
- 一组代表文本中实体或概念的节点 (
GraphNode
)。 - 一组代表这些实体如何连接的关系 (
GraphEdge
)。 - 作为
source
的原始Document
。
默认实现是 LLMGraphTransformer
,它使用语言模型(例如 OpenAI)通过提示工程从自然语言中提取图信息。
主要优点
- 实体和关系提取:识别关键概念及其语义连接。
- 图表示:输出已准备好集成到知识图或图数据库中。
- 模型驱动解析:使用大型语言模型从非结构化文本中推断结构。
Maven 依赖项
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-community-llm-graph-transformer</artifactId>
<version>${latest version here}</version>
</dependency>
示例用法
import dev.langchain4j.data.document.Document;
import dev.langchain4j.model.openai.OpenAiChatModel;
import dev.langchain4j.community.data.document.graph.GraphDocument;
import dev.langchain4j.community.data.document.graph.GraphNode;
import dev.langchain4j.community.data.document.graph.GraphEdge;
import dev.langchain4j.community.data.document.transformer.graph.GraphTransformer;
import dev.langchain4j.community.data.document.transformer.graph.llm.LLMGraphTransformer;
import java.time.Duration;
import java.util.Set;
public class GraphTransformerExample {
public static void main(String[] args) {
// 创建一个由 LLM 支持的 GraphTransformer
GraphTransformer transformer = new LLMGraphTransformer(
OpenAiChatModel.builder()
.apiKey(System.getenv("OPENAI_API_KEY"))
.timeout(Duration.ofSeconds(60))
.build()
);
// 输入文档
Document document = Document.from("Barack Obama was born in Hawaii and served as the 44th President of the United States.");
// 转换文档
GraphDocument graphDocument = transformer.transform(document);
// 访问节点和关系
Set<GraphNode> nodes = graphDocument.nodes();
Set<GraphEdge> relationships = graphDocument.relationships();
nodes.forEach(System.out::println);
relationships.forEach(System.out::println);
}
}
输出示例
GraphNode(name=Barack Obama, type=Person)
GraphNode(name=Hawaii, type=Location)
GraphEdge(from=Barack Obama, predicate=was born in, to=Hawaii)
GraphEdge(from=Barack Obama, predicate=served as, to=President of the United States)
Text Segment
一旦您的 Document
被加载,就该将它们分割(分块)成更小的片段了。LangChain4j 的领域模型包含一个 TextSegment
类,它表示 Document
的一个片段。顾名思义,TextSegment
只能表示文本信息。
是否需要分割?
有几个原因解释了为什么您可能只想在提示中包含几个相关的片段,而不是整个知识库:
- LLM 的上下文窗口有限,整个知识库可能无法容纳
- 您在提示中提供的信息越多,LLM 处理和响应所需的时间就越长
- 您在提示中提供的信息越多,您支付的费用就越多
- 提示中的不相关信息可能会分散 LLM 的注意力,增加幻觉的几率
- 您在提示中提供的信息越多,就越难解释 LLM 是基于哪条信息做出回应的
我们可以通过将知识库分割成更小、更易于消化的片段来解决这些问题。这些片段应该有多大?这是一个好问题。和往常一样,这取决于具体情况。
目前有两种广泛使用的方法:
每个文档(例如 PDF 文件、网页等)都是原子且不可分割的。在 RAG 管道的检索过程中,检索到最相关的 N 个文档并注入到提示中。在这种情况下,您很可能需要使用长上下文 LLM,因为文档可能相当长。如果检索完整的文档很重要,例如您不能错过某些细节时,此方法是合适的。
- 优点:不会丢失上下文。
- 缺点:
- 消耗更多的 token。
- 有时,文档可能包含多个部分/主题,并非所有部分都与查询相关。
- 向量搜索质量会受到影响,因为各种大小的完整文档被压缩成单个固定长度的向量。
文档被分割成更小的片段,例如章节、段落,有时甚至是句子。在 RAG 管道的检索过程中,检索到最相关的 N 个片段并注入到提示中。挑战在于确保每个片段为 LLM 提供了足够的上下文/信息以理解它。缺少上下文可能导致 LLM 误解给定的片段并产生幻觉。一个常见的策略是将文档分割成带有重叠的片段,但这并不能完全解决问题。几种高级技术可以提供帮助,例如“句子窗口检索”、“自动合并检索”和“父文档检索”。我们在此不详细介绍,但本质上,这些方法有助于获取检索到的片段周围的更多上下文,为 LLM 提供检索到的片段之前和之后的额外信息。
- 优点:
- 更好的向量搜索质量。
- 减少 token 消耗。
- 缺点:可能仍会丢失部分上下文。
- 优点:
有用的方法
TextSegment.text()
返回TextSegment
的文本TextSegment.metadata()
返回TextSegment
的Metadata
TextSegment.from(String, Metadata)
从文本和Metadata
创建一个TextSegment
TextSegment.from(String)
从文本创建一个带有空Metadata
的TextSegment
Document Splitter
LangChain4j 有一个 DocumentSplitter
接口,并提供了一些开箱即用的实现:
DocumentByParagraphSplitter
DocumentByLineSplitter
DocumentBySentenceSplitter
DocumentByWordSplitter
DocumentByCharacterSplitter
DocumentByRegexSplitter
- 递归:
DocumentSplitters.recursive(...)
它们的工作方式如下:
- 您实例化一个
DocumentSplitter
,指定所需的TextSegment
大小,以及可选的字符或 token 重叠。 - 您调用
DocumentSplitter
的split(Document)
或splitAll(List<Document>)
方法。 DocumentSplitter
将给定的Document
分割成更小的单元,其性质因分割器而异。例如,DocumentByParagraphSplitter
将文档分割成段落(由两个或多个连续的换行符定义),而DocumentBySentenceSplitter
使用 OpenNLP 库的句子检测器将文档分割成句子,依此类推。DocumentSplitter
然后将这些更小的单元(段落、句子、单词等)组合成TextSegment
,尝试在不超过步骤 1 中设置的限制的情况下,将尽可能多的单元包含在一个TextSegment
中。如果某些单元仍然太大而无法放入TextSegment
中,它会调用一个子分割器。这是另一个DocumentSplitter
,能够将不适合的单元分割成更细粒度的单元。所有Metadata
条目都从Document
复制到每个TextSegment
。一个唯一的元数据条目 “index” 被添加到每个文本片段。第一个TextSegment
将包含index=0
,第二个index=1
,依此类推。
Text Segment Transformer
TextSegmentTransformer
与 DocumentTransformer
(如上所述)类似,但它转换的是 TextSegment
。
与 DocumentTransformer
一样,没有一刀切的解决方案,因此我们建议根据您的独特数据实施您自己的 TextSegmentTransformer
。
一种在改进检索方面效果很好的技术是将 Document
标题或简短摘要包含在每个 TextSegment
中。
Embedding
Embedding
类封装了一个数值向量,它代表了已被嵌入的内容(通常是文本,例如 TextSegment
)的“语义含义”。
在此处阅读更多关于向量嵌入的信息:
- https://www.elastic.co/what-is/vector-embedding
- https://www.pinecone.io/learn/vector-embeddings/
- https://cloud.google.com/blog/topics/developers-practitioners/meet-ais-multitool-vector-embeddings
有用的方法
Embedding.dimension()
返回嵌入向量的维度(其长度)CosineSimilarity.between(Embedding, Embedding)
计算两个Embedding
之间的余弦相似度Embedding.normalize()
归一化嵌入向量(原地)
Embedding Model
EmbeddingModel
接口表示一种特殊的模型,可将文本转换为 Embedding
。
目前支持的嵌入模型可以在这里找到。
有用的方法
EmbeddingModel.embed(String)
嵌入给定的文本EmbeddingModel.embed(TextSegment)
嵌入给定的TextSegment
EmbeddingModel.embedAll(List<TextSegment>)
嵌入所有给定的TextSegment
EmbeddingModel.dimension()
返回此模型生成的Embedding
的维度
嵌入存储(Embedding Store)
EmbeddingStore
接口代表一个用于存储 Embedding
(也称为向量数据库)的存储。它能够存储 Embedding
并高效地搜索相似(在嵌入空间中距离相近)的 Embedding
。
目前支持的嵌入存储可以在这里找到。
EmbeddingStore
可以单独存储 Embedding
,也可以将 Embedding
和相应的 TextSegment
一起存储:
- 它可以仅通过 ID 存储
Embedding
。原始嵌入数据可以存储在其他地方,并使用 ID 进行关联。 - 它可以同时存储
Embedding
和原始嵌入数据(通常是TextSegment
)。
常用方法
EmbeddingStore.add(Embedding)
:向存储中添加一个给定的Embedding
并返回一个随机 ID。EmbeddingStore.add(String id, Embedding)
:向存储中添加一个具有指定 ID 的给定Embedding
。EmbeddingStore.add(Embedding, TextSegment)
:向存储中添加一个给定Embedding
及其关联的TextSegment
,并返回一个随机 ID。EmbeddingStore.addAll(List<Embedding>)
:向存储中添加一个给定的Embedding
列表,并返回一个随机 ID 列表。EmbeddingStore.addAll(List<Embedding>, List<TextSegment>)
:向存储中添加一个给定的Embedding
列表及其关联的TextSegment
列表,并返回一个随机 ID 列表。EmbeddingStore.addAll(List<String> ids, List<Embedding>, List<TextSegment>)
:向存储中添加一个给定的Embedding
列表及其关联的 ID 和TextSegment
列表。EmbeddingStore.search(EmbeddingSearchRequest)
:搜索最相似的Embedding
。EmbeddingStore.remove(String id)
:通过 ID 从存储中删除单个Embedding
。EmbeddingStore.removeAll(Collection<String> ids)
:从存储中删除所有 ID 在给定集合中的Embedding
。EmbeddingStore.removeAll(Filter)
:从存储中删除所有与指定Filter
匹配的Embedding
。EmbeddingStore.removeAll()
:从存储中删除所有Embedding
。
嵌入搜索请求(EmbeddingSearchRequest)
EmbeddingSearchRequest
代表一个在 EmbeddingStore
中进行搜索的请求。它有以下属性:
Embedding queryEmbedding
:用作参考的嵌入。int maxResults
:返回的最大结果数。这是一个可选参数。默认值:3。double minScore
:最小分数,范围为 0 到 1(包含)。只有分数 >=minScore
的嵌入才会被返回。这是一个可选参数。默认值:0。Filter filter
:在搜索过程中应用于Metadata
的过滤器。只有Metadata
与Filter
匹配的TextSegment
才会被返回。
过滤器(Filter)
Filter
允许在执行向量搜索时按 Metadata
条目进行过滤。
目前支持以下 Filter
类型/操作:
IsEqualTo
(等于)IsNotEqualTo
(不等于)IsGreaterThan
(大于)IsGreaterThanOrEqualTo
(大于或等于)IsLessThan
(小于)IsLessThanOrEqualTo
(小于或等于)IsIn
(在…中)IsNotIn
(不在…中)ContainsString
(包含字符串)And
(且)Not
(非)Or
(或)
注
并非所有嵌入存储都支持按 Metadata
过滤,请参见此处的“按 Metadata 过滤”列。
一些支持按 Metadata
过滤的存储并不支持所有可能的 Filter
类型/操作。例如,ContainsString
目前仅由 Milvus、PgVector 和 Qdrant 支持。
有关 Filter
的更多详细信息可以在这里找到。
嵌入搜索结果(EmbeddingSearchResult)
EmbeddingSearchResult
代表在 EmbeddingStore
中搜索的结果。它包含一个 EmbeddingMatch
列表。
嵌入匹配(Embedding Match)
EmbeddingMatch
代表一个匹配的 Embedding
,以及其相关性分数、ID 和原始嵌入数据(通常是 TextSegment
)。
嵌入存储摄取器(Embedding Store Ingestor)
EmbeddingStoreIngestor
代表一个摄取管道,负责将 Document
摄取到 EmbeddingStore
中。
在最简单的配置中,EmbeddingStoreIngestor
使用指定的 EmbeddingModel
嵌入提供的 Document
,并将它们连同其 Embedding
一起存储在指定的 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));
EmbeddingStoreIngestor
中的所有 ingest()
方法都返回一个 IngestionResult
。IngestionResult
包含有用的信息,包括 TokenUsage
,它显示了用于嵌入的 token 数量。
EmbeddingStoreIngestor
还可以选择使用指定的 DocumentTransformer
转换 Document
。如果您想在嵌入之前清理、丰富或格式化 Document
,这会很有用。
EmbeddingStoreIngestor
还可以选择使用指定的 DocumentSplitter
将 Document
拆分为 TextSegment
。如果 Document
很大,并且您想将它们拆分为更小的 TextSegment
以提高相似性搜索的质量并减少发送给 LLM 的提示大小和成本,这会很有用。
EmbeddingStoreIngestor
还可以选择使用指定的 TextSegmentTransformer
转换 TextSegment
。如果您想在嵌入之前清理、丰富或格式化 TextSegment
,这会很有用。
一个例子:
EmbeddingStoreIngestor ingestor = EmbeddingStoreIngestor.builder()
// 为每个 Document 添加 userId 元数据条目,以便稍后可以按其过滤
.documentTransformer(document -> {
document.metadata().put("userId", "12345");
return document;
})
// 将每个 Document 拆分为 1000 个 token 的 TextSegment,重叠 200 个 token
.documentSplitter(DocumentSplitters.recursive(1000, 200, new OpenAiTokenCountEstimator("gpt-4o-mini")))
// 将 Document 的名称添加到每个 TextSegment 中以提高搜索质量
.textSegmentTransformer(textSegment -> TextSegment.from(
textSegment.metadata().getString("file_name") + "\n" + textSegment.text(),
textSegment.metadata()
))
.embeddingModel(embeddingModel)
.embeddingStore(embeddingStore)
.build();
朴素 RAG (Naive RAG)
一旦我们的文档被摄取(参见前面的章节),我们就可以创建一个 EmbeddingStoreContentRetriever
来启用朴素 RAG 功能。
当使用 AI 服务时,朴素 RAG 可以按如下方式配置:
ContentRetriever contentRetriever = EmbeddingStoreContentRetriever.builder()
.embeddingStore(embeddingStore)
.embeddingModel(embeddingModel)
.maxResults(5)
.minScore(0.75)
.build();
Assistant assistant = AiServices.builder(Assistant.class)
.chatModel(model)
.contentRetriever(contentRetriever)
.build();
高级 RAG (Advanced RAG)
高级 RAG 可以使用以下核心组件在 LangChain4j 中实现:
QueryTransformer
(查询转换器)QueryRouter
(查询路由器)ContentRetriever
(内容检索器)ContentAggregator
(内容聚合器)ContentInjector
(内容注入器)
下图展示了这些组件如何协同工作:
整个流程如下:
- 用户生成一个
UserMessage
,它被转换为一个Query
。 QueryTransformer
将Query
转换为一个或多个Query
。- 每个
Query
由QueryRouter
路由到一个或多个ContentRetriever
。 - 每个
ContentRetriever
为每个Query
检索相关Content
。 ContentAggregator
将所有检索到的Content
组合成一个最终的排名列表。- 这个
Content
列表被注入到原始的UserMessage
中。 - 最后,包含原始查询和注入的相关内容的
UserMessage
被发送给 LLM。
请参阅每个组件的 Javadoc 以获取更多详细信息。
检索增强器(Retrieval Augmentor)
RetrievalAugmentor
是 RAG 管道的入口点。它负责使用从各种来源检索到的相关 Content
增强 ChatMessage
。
可以在创建 AI 服务时指定 RetrievalAugmentor
的实例:
Assistant assistant = AiServices.builder(Assistant.class)
...
.retrievalAugmentor(retrievalAugmentor)
.build();
每次调用 AI 服务时,都会调用指定的 RetrievalAugmentor
来增强当前的 UserMessage
。
您可以使用 RetrievalAugmentor
的默认实现(如下所述)或实现一个自定义的。
默认检索增强器(Default Retrieval Augmentor)
LangChain4j 提供了 RetrievalAugmentor
接口的开箱即用实现:DefaultRetrievalAugmentor
,它应该适用于大多数 RAG 用例。它的灵感来自这篇文章和这篇论文。建议查阅这些资源以更好地理解其概念。
查询(Query)
Query
代表 RAG 管道中的用户查询。它包含查询的文本和查询元数据。
查询元数据(Query Metadata)
Query
中的 Metadata
包含在 RAG 管道的各个组件中可能很有用的信息,例如:
Metadata.userMessage()
:应该被增强的原始UserMessage
。Metadata.chatMemoryId()
:@MemoryId
注解的方法参数的值。更多详细信息在此处。这可以用于识别用户并在检索期间应用访问限制或过滤器。Metadata.chatMemory()
:所有以前的ChatMessage
。这有助于理解提出Query
的上下文。
查询转换器(Query Transformer)
QueryTransformer
将给定的 Query
转换为一个或多个 Query
。其目标是通过修改或扩展原始 Query
来提高检索质量。
一些已知的提高检索质量的方法包括:
- 查询压缩
- 查询扩展
- 查询重写
- Step-back prompting(回退提示)
- Hypothetical document embeddings (HyDE)(假设文档嵌入)
更多详细信息可以在这里找到。
默认查询转换器(Default Query Transformer)
DefaultQueryTransformer
是 DefaultRetrievalAugmentor
中使用的默认实现。它不对 Query
做任何修改,只是简单地传递它。
压缩查询转换器(Compressing Query Transformer)
CompressingQueryTransformer
使用 LLM 将给定的 Query
和之前的对话压缩为一个独立的 Query
。当用户可能提出后续问题,而这些问题引用了之前问题或答案中的信息时,这会很有用。
例如:
用户:告诉我关于 John Doe 的事
AI:John Doe 是一个...
用户:他住在哪里?
他住在哪里?
这个查询本身无法检索到所需的信息,因为它没有明确提到 John Doe,导致 他
指的是谁不清楚。
当使用 CompressingQueryTransformer
时,LLM 会阅读整个对话并把 他住在哪里?
转换为 John Doe 住在哪里?
。
扩展查询转换器(Expanding Query Transformer)
ExpandingQueryTransformer
使用 LLM 将给定的 Query
扩展为多个 Query
。这很有用,因为 LLM 可以用各种方式重述和重新组织 Query
,这有助于检索更多相关内容。
内容(Content)
Content
代表与用户 Query
相关的内容。目前,它仅限于文本内容(即 TextSegment
),但将来可能支持其他模式(例如图像、音频、视频等)。
内容检索器(Content Retriever)
ContentRetriever
使用给定的 Query
从底层数据源检索 Content
。底层数据源几乎可以是任何东西:
- 嵌入存储
- 全文搜索引擎
- 向量和全文搜索的混合体
- 网络搜索引擎
- 知识图谱
- SQL 数据库
- 等等
ContentRetriever
返回的 Content
列表按相关性从高到低排序。
嵌入存储内容检索器(Embedding Store Content Retriever)
EmbeddingStoreContentRetriever
使用 EmbeddingModel
嵌入 Query
,然后从 EmbeddingStore
中检索相关 Content
。
一个例子:
EmbeddingStore embeddingStore = ...
EmbeddingModel embeddingModel = ...
ContentRetriever contentRetriever = EmbeddingStoreContentRetriever.builder()
.embeddingStore(embeddingStore)
.embeddingModel(embeddingModel)
.maxResults(3)
// maxResults 也可以根据查询动态指定
.dynamicMaxResults(query -> 3)
.minScore(0.75)
// minScore 也可以根据查询动态指定
.dynamicMinScore(query -> 0.75)
.filter(metadataKey("userId").isEqualTo("12345"))
// filter 也可以根据查询动态指定
.dynamicFilter(query -> {
String userId = getUserId(query.metadata().chatMemoryId());
return metadataKey("userId").isEqualTo(userId);
})
.build();
网络搜索内容检索器(Web Search Content Retriever)
WebSearchContentRetriever
使用 WebSearchEngine
从网络检索相关 Content
。
所有支持的 WebSearchEngine
集成可以在此处找到。
一个例子:
WebSearchEngine googleSearchEngine = GoogleCustomWebSearchEngine.builder()
.apiKey(System.getenv("GOOGLE_API_KEY"))
.csi(System.getenv("GOOGLE_SEARCH_ENGINE_ID"))
.build();
ContentRetriever contentRetriever = WebSearchContentRetriever.builder()
.webSearchEngine(googleSearchEngine)
.maxResults(3)
.build();
完整的例子可以在这里找到。
SQL 数据库内容检索器(SQL Database Content Retriever)
SqlDatabaseContentRetriever
是 ContentRetriever
的一个实验性实现,可以在 langchain4j-experimental-sql
模块中找到。它使用 DataSource
和 LLM 来为给定的自然语言 Query
生成并执行 SQL 查询。
请参阅 SqlDatabaseContentRetriever
的 javadoc 以获取更多信息。
一个例子在此处。
Azure AI Search 内容检索器(Azure AI Search Content Retriever)
AzureAiSearchContentRetriever
是与 Azure AI Search 的集成。它支持全文、向量和混合搜索以及重新排名。它可以在 langchain4j-azure-ai-search
模块中找到。请参阅 AzureAiSearchContentRetriever
的 Javadoc 以获取更多信息。
Neo4j 内容检索器(Neo4j Content Retriever)
Neo4jContentRetriever
是与 Neo4j 图数据库的集成。它将自然语言查询转换为 Neo4j Cypher 查询,并通过在 Neo4j 中运行这些查询来检索相关信息。它可以在 langchain4j-community-neo4j-retriever
模块中找到。
查询路由器(Query Router)
QueryRouter
负责将 Query
路由到适当的 ContentRetriever
。
默认查询路由器(Default Query Router)
DefaultQueryRouter
是 DefaultRetrievalAugmentor
中使用的默认实现。它将每个 Query
路由到所有配置的 ContentRetriever
。
语言模型查询路由器(Language Model Query Router)
LanguageModelQueryRouter
使用 LLM 来决定将给定的 Query
路由到哪里。
内容聚合器(Content Aggregator)
ContentAggregator
负责聚合来自以下来源的多个排名列表的 Content
:
- 多个
Query
- 多个
ContentRetriever
- 两者兼有
默认内容聚合器(Default Content Aggregator)
DefaultContentAggregator
是 ContentAggregator
的默认实现,它执行两阶段的倒数排名融合(RRF)。请参阅 DefaultContentAggregator
Javadoc 以获取更多详细信息。
重新排名内容聚合器(Re-Ranking Content Aggregator)
ReRankingContentAggregator
使用 ScoringModel
(如 Cohere)来执行重新排名。支持的评分(重新排名)模型的完整列表可以在这里找到。请参阅 ReRankingContentAggregator
Javadoc 以获取更多详细信息。
内容注入器(Content Injector)
ContentInjector
负责将 ContentAggregator
返回的 Content
注入到 UserMessage
中。
默认内容注入器(Default Content Injector)
DefaultContentInjector
是 ContentInjector
的默认实现,它只是简单地在 UserMessage
的末尾附加 Content
,并带有前缀 Answer using the following information:
(使用以下信息回答:)。
您可以通过 3 种方式自定义如何将 Content
注入 UserMessage
:
- 重写默认的
PromptTemplate
:
RetrievalAugmentor retrievalAugmentor = DefaultRetrievalAugmentor.builder()
.contentInjector(DefaultContentInjector.builder()
.promptTemplate(PromptTemplate.from("{{userMessage}}\n{{contents}}"))
.build())
.build();
请注意,PromptTemplate
必须包含 {{userMessage}}
和 {{contents}}
变量。
- 扩展
DefaultContentInjector
并重写其中一个format
方法。 - 实现一个自定义的
ContentInjector
。
DefaultContentInjector
还支持从检索到的 Content.textSegment()
中注入 Metadata
条目:
DefaultContentInjector.builder()
.metadataKeysToInclude(List.of("source"))
.build()
在这种情况下,TextSegment.text()
将以“content: ”前缀开头,并且来自 Metadata
的每个值都将以其键作为前缀。最终的 UserMessage
将如下所示:
我如何取消预订?
Answer using the following information:
content: To cancel a reservation, go to ...
source: ./cancellation_procedure.html
content: Cancellation is allowed for ...
source: ./cancellation_policy.html
并行化(Parallelization)
当只有一个 Query
和一个 ContentRetriever
时,DefaultRetrievalAugmentor
在同一线程中执行查询路由和内容检索。否则,将使用 Executor
来并行处理。默认情况下,使用修改过的 (keepAliveTime
为 1 秒而不是 60 秒) Executors.newCachedThreadPool()
,但您可以在创建 DefaultRetrievalAugmentor
时提供一个自定义的 Executor
实例:
DefaultRetrievalAugmentor.builder()
...
.executor(executor)
.build;
访问来源(Accessing Sources)
如果您在使用 AI 服务时希望访问来源(用于增强消息的检索到的 Content
),您可以轻松地通过将返回类型包装在 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()
方法指定一个 Consumer<List<Content>>
:
interface Assistant {
TokenStream chat(String userMessage);
}
assistant.chat("How to do Easy RAG with LangChain4j?")
.onRetrieved((List<Content> sources) -> ...)
.onPartialResponse(...)
.onCompleteResponse(...)
.onError(...)
.start();