跳到主要内容

RAG(检索增强生成)

LLM 的知识仅限于其训练数据。 如果你想让 LLM 了解特定领域的知识或专有数据,你可以:

  • 使用 RAG,我们将在本节中介绍
  • 使用你的数据对 LLM 进行微调
  • 结合 RAG 和微调

什么是 RAG?

简单来说,RAG 是在将提示发送给 LLM 之前,从你的数据中查找并注入相关信息片段的方法。 这样 LLM 将获得(希望是)相关的信息,并能够利用这些信息进行回复, 从而降低产生幻觉的概率。

相关信息片段可以通过各种 信息检索 方法找到。 最流行的有:

  • 全文(关键词)搜索。该方法使用 TF-IDF 和 BM25 等技术, 通过将查询(例如,用户提出的问题)中的关键词与文档数据库进行匹配来搜索文档。 它根据每个文档中这些关键词的频率和相关性对结果进行排序。
  • 向量搜索,也称为“语义搜索”。 使用嵌入模型将文本文档转换为数字向量。 然后根据查询向量与文档向量之间的余弦相似度 或其他相似度/距离度量来查找和排序文档, 从而捕捉更深层的语义含义。
  • 混合搜索。结合多种搜索方法(例如,全文 + 向量)通常可以提高搜索的有效性。

目前,本页主要关注向量搜索。 全文和混合搜索目前仅由 Azure AI Search 集成和 Elasticsearch 支持, 有关更多详细信息,请参阅 AzureAiSearchContentRetrieverElasticsearchContentRetriever。 我们计划在不久的将来扩展 RAG 工具箱,以包含全文和混合搜索。

RAG 阶段

RAG 过程分为两个不同的阶段:索引和检索。 LangChain4j 为这两个阶段都提供了工具。

索引

在索引阶段,文档以能够在检索阶段实现高效搜索的方式进行预处理。

此过程可能因所使用的信息检索方法而异。 对于向量搜索,这通常涉及清理文档、用额外的数据和元数据丰富文档、 将文档拆分为更小的片段(也称为分块)、嵌入这些片段,最后将它们存储在嵌入存储(也称为向量数据库)中。

索引阶段通常离线进行,这意味着不需要最终用户等待其完成。 例如,可以通过一个 cron 作业来实现,该作业在周末每周重新索引一次内部公司文档。 负责索引的代码也可以是一个仅处理索引任务的独立应用程序。

然而,在某些场景中,最终用户可能希望上传他们的自定义文档,以使其可供 LLM 访问。 在这种情况下,索引应该在线执行,并成为主应用程序的一部分。

以下是索引阶段的简化示意图:

检索

检索阶段通常在线进行,当用户提交一个应使用索引文档来回答的问题时。

此过程可能因所使用的信息检索方法而异。 对于向量搜索,这通常涉及嵌入用户的查询(问题) 并在嵌入存储中执行相似性搜索。 然后将相关的片段(原始文档的片段)注入提示并发送给 LLM。

以下是检索阶段的简化示意图:

LangChain4j 中的 RAG 风格

LangChain4j 提供三种 RAG 风格:

  • Easy RAG:开始使用 RAG 的最简单方式
  • Naive RAG:使用向量搜索的基本 RAG 实现
  • Advanced RAG:一个模块化的 RAG 框架,允许进行额外的步骤,例如 查询转换、从多个来源检索以及重新排序

Easy RAG

LangChain4j 具有“简易 RAG”功能,使开始使用 RAG 变得尽可能简单。 你不必了解嵌入、选择向量存储、找到合适的嵌入模型、 弄清楚如何解析和拆分文档等。 只需指向你的文档,LangChain4j 就会施展它的魔法。

如果你需要可定制的 RAG,请跳到下一节

如果你使用 Quarkus,有一种更简单的方法来实现简易 RAG。 请阅读 Quarkus 文档

备注

这种“简易 RAG”的质量当然会低于定制 RAG 设置的质量。 然而,这是开始学习 RAG 和/或制作概念验证的最简单方式。 之后,你将能够顺利地从简易 RAG 过渡到更高级的 RAG, 逐步调整和定制更多方面。

  1. 导入 langchain4j-easy-rag 依赖:
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-easy-rag</artifactId>
<version>1.18.1-beta28</version>
</dependency>
  1. 让我们加载你的文档:
List<Document> documents = FileSystemDocumentLoader.loadDocuments("/home/langchain4j/documentation");

这将从指定目录加载所有文件。

底层发生了什么?

Apache Tika 库支持多种文档类型, 用于检测文档类型并解析它们。 由于我们没有明确指定使用哪个 DocumentParserFileSystemDocumentLoader 将通过 SPI 加载由 langchain4j-easy-rag 依赖提供的 ApacheTikaDocumentParser

如何自定义文档加载?

如果你想从所有子目录加载文档,可以使用 loadDocumentsRecursively 方法:

List<Document> documents = FileSystemDocumentLoader.loadDocumentsRecursively("/home/langchain4j/documentation");

此外,你还可以使用通配符或正则表达式来筛选文档:

PathMatcher pathMatcher = FileSystems.getDefault().getPathMatcher("glob:*.pdf");
List<Document> documents = FileSystemDocumentLoader.loadDocuments("/home/langchain4j/documentation", pathMatcher);
备注

使用 loadDocumentsRecursively 方法时,你可能希望在 glob 中使用双星号(而不是单星号):glob:**.pdf

  1. 现在,我们需要对文档进行预处理,并将其存储在专门的嵌入存储(也称为向量数据库)中。 这是为了在用户提问时能够快速找到相关的信息片段。 我们可以使用 30 多种受支持的嵌入存储中的任何一种, 但为了简单起见,我们将使用内存中的一种:
InMemoryEmbeddingStore<TextSegment> embeddingStore = new InMemoryEmbeddingStore<>();
EmbeddingStoreIngestor.ingest(documents, embeddingStore);
底层发生了什么?
  1. EmbeddingStoreIngestor 通过 SPI 从 langchain4j-easy-rag 依赖中加载一个 DocumentSplitter。 每个 Document 被拆分成更小的片段(TextSegment),每个片段最多包含 300 个 token, 并且有 30 个 token 的重叠。

  2. EmbeddingStoreIngestor 通过 SPI 从 langchain4j-easy-rag 依赖中加载一个 EmbeddingModel。 每个 TextSegment 使用 EmbeddingModel 转换为一个 Embedding

备注

我们选择了 bge-small-en-v1.5 作为 Easy RAG 的默认嵌入模型。 它在 MTEB 排行榜 上取得了令人瞩目的成绩, 其量化版本仅占用 24 兆字节的空间。 因此,我们可以轻松地将其加载到内存中,并使用 ONNX Runtime 在同一进程中运行。

是的,你没看错,你可以完全离线地将文本转换为嵌入向量,无需任何外部服务, 就在同一个 JVM 进程中。 LangChain4j 提供了一些流行的嵌入模型 开箱即用

  1. 所有 TextSegment-Embedding 对都存储在 EmbeddingStore 中。
  1. 最后一步是创建一个 AI 服务,它将作为我们与 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中检索相关内容。

  1. 现在,我们可以开始与它对话了!
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() 返回 DocumentMetadata(参见下方“Metadata”部分)
  • Document.toTextSegment()Document 转换为 TextSegment(参见下方“TextSegment”部分)
  • Document.from(String, Metadata) 从文本和 Metadata 创建 Document
  • Document.from(String) 从文本创建 DocumentMetadata 为空

Metadata

每个 Document 都包含 Metadata。 它存储有关 Document 的元信息,例如其名称、来源、最后更新日期、所有者, 或任何其他相关详细信息。

Metadata 以键值映射的形式存储,其中键为 String 类型, 值可以是以下类型之一:StringIntegerLongFloatDoubleUUID

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 合并

文档加载器

你可以从 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 可以表示各种格式的文件,例如 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 模块, 可以自动检测并解析几乎所有现有的文件格式
  • DoclingDocumentParser,来自 langchain4j-document-parser-docling 模块, 使用 Docling JavaDocling 来处理文档。
  • MarkdownDocumentParser,来自 langchain4j-document-parser-markdown 模块, 可以解析 markdown 格式的文件
  • YamlDocumentParser,来自 langchain4j-document-parser-yaml 模块, 可以解析 yaml 格式的文件

以下是如何从文件系统加载一个或多个 Document 的示例:

// Load a single document
Document document = FileSystemDocumentLoader.loadDocument("/home/langchain4j/file.txt", new TextDocumentParser());

// Load all documents from a directory
List<Document> documents = FileSystemDocumentLoader.loadDocuments("/home/langchain4j", new TextDocumentParser());

// Load all *.txt documents from a directory
PathMatcher pathMatcher = FileSystems.getDefault().getPathMatcher("glob:*.txt");
List<Document> documents = FileSystemDocumentLoader.loadDocuments("/home/langchain4j", pathMatcher, new TextDocumentParser());

// Load all documents from a directory and its subdirectories
List<Document> documents = FileSystemDocumentLoader.loadDocumentsRecursively("/home/langchain4j", new TextDocumentParser());

你还可以在不显式指定DocumentParser的情况下加载文档。 在这种情况下,将使用默认的DocumentParser

默认的解析器通过SPI加载(例如,从langchain4j-document-parser-apache-tikalangchain4j-easy-rag中,如果导入了其中之一)。 如果通过SPI未找到任何DocumentParser,则使用TextDocumentParser作为回退方案。

文档转换器

DocumentTransformer实现可以执行多种文档转换,例如:

  • 清洗:这涉及从Document的文本中去除不必要的噪声,可以节省令牌并减少干扰。
  • 过滤:完全排除特定的Document,使其不参与搜索。
  • 丰富:可以向Document添加附加信息,以潜在地改善搜索结果。
  • 摘要:可以对Document进行摘要,并将其简短摘要存储在Metadata中, 以便稍后包含在每个TextSegment(我们将在下面介绍)中,从而潜在地改善搜索。
  • 等等。

在此阶段,还可以添加、修改或删除Metadata条目。

目前,开箱即用的唯一实现是langchain4j-document-transformer-jsoup模块中的HtmlToTextDocumentTransformer, 它可以从原始HTML中提取所需的文本内容和元数据条目。

由于没有一刀切的解决方案,我们建议你根据自己的独特数据实现自己的DocumentTransformer

图转换器

GraphTransformer是一个接口,通过提取语义图元素(如节点和关系),将非结构化的Document对象转换为结构化的GraphDocument。 它非常适合将原始文本转换为结构化的语义图。

GraphTransformer将原始文档转换为GraphDocument。这些包括:

  • 一组节点GraphNode),表示文本中的实体或概念。
  • 一组关系GraphEdge),表示这些实体之间的连接方式。
  • 原始Document作为source

默认实现是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) {
// Create a GraphTransformer backed by an LLM
GraphTransformer transformer = new LLMGraphTransformer(
OpenAiChatModel.builder()
.apiKey(System.getenv("OPENAI_API_KEY"))
.timeout(Duration.ofSeconds(60))
.build()
);

// Input document
Document document = Document.from("Barack Obama was born in Hawaii and served as the 44th President of the United States.");

// Transform the document
GraphDocument graphDocument = transformer.transform(document);

// Access nodes and relationships
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)

文本片段

当你的Document被加载后,就该将其拆分(分块)为更小的片段了。 LangChain4j的领域模型包含一个TextSegment类,它表示Document的一个片段。 顾名思义,TextSegment只能表示文本信息。

拆分还是不拆分?

你可能希望在提示词中只包含几个相关的片段,而不是整个知识库,原因如下:

  • LLM的上下文窗口有限,整个知识库可能无法容纳
  • 提示词中提供的信息越多,LLM处理和响应所需的时间就越长
  • 提示词中提供的信息越多,你需要支付的费用就越高
  • 提示词中不相关的信息可能会分散LLM的注意力,增加产生幻觉的可能性
  • 提示词中提供的信息越多,就越难解释LLM是基于哪些信息做出响应的

我们可以通过将知识库拆分为更小、更易于理解的片段来解决这些问题。 这些片段应该有多大?这是个好问题。和往常一样,这取决于具体情况。

目前有两种广泛使用的方法:

  1. 每个文档(例如PDF文件、网页等)是原子的、不可分割的。 在RAG管道的检索过程中,会检索出最相关的N个文档并注入到提示词中。 在这种情况下,你很可能需要使用长上下文LLM,因为文档可能相当长。 如果检索完整文档很重要,这种方法很合适, 例如当你无法承受遗漏某些细节时。
  • 优点:不会丢失上下文。
  • 缺点:
    • 消耗更多的token。
    • 有时,文档可能包含多个章节/主题,并非所有内容都与查询相关。
    • 向量搜索质量会受到影响,因为不同大小的完整文档被压缩成单个固定长度的向量。
  1. 将文档拆分为更小的片段,例如章节、段落,有时甚至是句子。 在RAG管道的检索过程中,会检索出最相关的N个片段并注入到提示词中。 挑战在于确保每个片段都提供足够的上下文/信息,以便LLM能够理解。 上下文缺失可能导致LLM误解给定的片段并产生幻觉。 一种常见的策略是将文档拆分为有重叠的片段,但这并不能完全解决问题。 有几种高级技术可以提供帮助,例如“句子窗口检索”、“自动合并检索” 和“父文档检索”。 我们这里不深入讨论细节,但本质上,这些方法有助于获取检索片段周围的更多上下文, 为LLM提供检索片段之前和之后的额外信息。
  • 优点:
    • 更好的向量搜索质量。
    • 减少token消耗。
  • 缺点:仍可能丢失一些上下文。
常用方法
  • TextSegment.text() 返回TextSegment的文本
  • TextSegment.metadata() 返回TextSegmentMetadata
  • TextSegment.from(String, Metadata) 从文本和Metadata创建TextSegment
  • TextSegment.from(String) 从文本创建TextSegmentMetadata为空

文档拆分器

LangChain4j有一个DocumentSplitter接口,提供了多种开箱即用的实现:

  • DocumentByParagraphSplitter
  • DocumentByLineSplitter
  • DocumentBySentenceSplitter
  • DocumentByWordSplitter
  • DocumentByCharacterSplitter
  • DocumentByRegexSplitter
  • 递归式:DocumentSplitters.recursive(...)

它们的工作方式如下:

  1. 你实例化一个DocumentSplitter,指定所需的TextSegment大小, 以及可选的字符或token重叠量。
  2. 你调用DocumentSplittersplit(Document)splitAll(List<Document>)方法。
  3. DocumentSplitter将给定的Document拆分为更小的单元, 这些单元的性质因拆分器而异。例如,DocumentByParagraphSplitter将文档 划分为段落(由两个或更多连续换行符定义), 而DocumentBySentenceSplitter使用OpenNLP库的句子检测器将文档 拆分为句子,依此类推。
  4. DocumentSplitter然后将这些更小的单元(段落、句子、单词等)组合成TextSegment, 尝试在单个TextSegment中尽可能多地包含单元,而不超过步骤1中设置的限制。 如果某些单元仍然太大而无法放入TextSegment,它会调用子拆分器。 这是另一个DocumentSplitter,能够将无法容纳的单元拆分为更细粒度的单元。 所有Metadata条目都会从Document复制到每个TextSegment。 每个文本片段都会添加一个唯一的元数据条目“index”。 第一个TextSegment将包含index=0,第二个包含index=1,依此类推。

文本片段转换器

TextSegmentTransformerDocumentTransformer(上文所述)类似,但它转换的是TextSegment

DocumentTransformer一样,没有放之四海而皆准的解决方案, 因此我们建议你根据自己的独特数据实现自定义的TextSegmentTransformer

一种对改善检索效果相当有效的技术是在每个TextSegment中包含Document的标题或简短摘要。

嵌入

Embedding类封装了一个数值向量,它表示被嵌入内容(通常是文本,例如TextSegment)的“语义含义”。

在此处了解更多关于向量嵌入的信息:

常用方法
  • Embedding.dimension() 返回嵌入向量的维度(其长度)
  • CosineSimilarity.between(Embedding, Embedding) 计算两个Embedding之间的余弦相似度
  • Embedding.normalize() 规范化嵌入向量(原地操作)

嵌入模型

EmbeddingModel接口表示一种特殊类型的模型,它将文本转换为Embedding

当前支持的嵌入模型可以在此处找到。

常用方法
  • EmbeddingModel.embed(String) 嵌入给定的文本
  • EmbeddingModel.embed(TextSegment) 嵌入给定的TextSegment
  • EmbeddingModel.embedAll(List<TextSegment>) 嵌入所有给定的TextSegment
  • EmbeddingModel.dimension() 返回此模型生成的Embedding的维度

请求/响应API和每次调用参数

除了上述便捷方法外,EmbeddingModel还接受EmbeddingRequest并返回 EmbeddingResponse,这允许你传递每次调用的参数

EmbeddingResponse response = embeddingModel.embed(EmbeddingRequest.builder()
.input("What is the capital of France?")
.inputType(EmbeddingInputType.QUERY) // query vs document, see the section below
.dimensions(256) // reduce output dimensionality (on models that support it)
.build());

List<Embedding> embeddings = response.embeddings();

每次调用的参数都是严格可选的:每个提供商通过 supportedParameters() 声明其支持的参数。 如果请求使用了模型不支持的参数,会快速失败并抛出 UnsupportedFeatureException, 而不是静默忽略。另请参阅 查询与文档嵌入

多模态嵌入

某些模型将图像(以及交错的文本+图像)嵌入到同一向量空间中。从 Content 部件构建输入;在支持此功能的模型上(Cohere Embed v4、Voyage 多模态、Google Gemini Embedding 2、Amazon Titan Multimodal、Jina CLIP 等),这些部件会被融合为单个嵌入:

EmbeddingResponse response = embeddingModel.embed(EmbeddingRequest.builder()
.input(TextContent.from("a photo of a cat"), ImageContent.from("https://example.com/cat.png"))
.build());

模型通过 supportedContentTypes() 声明其支持的内容类型;向纯文本模型传入图片会快速失败并抛出 UnsupportedFeatureExceptionEmbeddingModel 的可观测性(监听器)在 可观测性 教程中有详细描述。

嵌入存储

EmbeddingStore 接口表示 Embedding 的存储,也称为向量数据库。 它支持存储和高效搜索相似的(在嵌入空间中距离较近的)Embedding

目前支持的嵌入存储可参见此处

EmbeddingStore 可以单独存储 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>) 将带有指定 ID 和 TextSegment 的给定 Embedding 列表添加到存储中
  • EmbeddingStore.search(EmbeddingSearchRequest) 搜索最相似的 Embedding
  • EmbeddingStore.remove(String id) 按 ID 从存储中移除单个 Embedding
  • EmbeddingStore.removeAll(Collection<String> ids) 从存储中移除所有 ID 存在于给定集合中的 Embedding
  • EmbeddingStore.removeAll(Filter) 从存储中移除所有匹配指定 FilterEmbedding
  • EmbeddingStore.removeAll() 从存储中移除所有 Embedding

EmbeddingSearchRequest

EmbeddingSearchRequest 表示在 EmbeddingStore 中执行搜索的请求。 它具有以下属性:

  • Embedding queryEmbedding:用作参考的嵌入向量。
  • int maxResults:返回结果的最大数量。这是一个可选参数。默认值:3。
  • double minScore:最低分数,范围从 0 到 1(含)。只有分数 >= minScore 的嵌入向量才会被返回。这是一个可选参数。默认值:0。
  • Filter filter:搜索时应用于 Metadata 的过滤器。只有 Metadata 匹配 FilterTextSegment 才会被返回。

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)。

嵌入存储摄取器

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() 方法均返回 IngestionResultIngestionResult 包含有用的信息,包括 TokenUsage, 它显示了嵌入所使用的 token 数量。

可选地,EmbeddingStoreIngestor 可以使用指定的 DocumentTransformer 转换 Document。 如果你希望在嵌入之前清理、丰富或格式化 Document,这会很有用。

可选地,EmbeddingStoreIngestor 可以使用指定的 DocumentSplitterDocument 拆分为 TextSegment。 如果 Document 很大,并且你希望将其拆分为更小的 TextSegment 以提高相似性搜索的质量 并减少发送给 LLM 的提示的大小和成本,这会很有用。

可选地,EmbeddingStoreIngestor 可以使用指定的 TextSegmentTransformer 转换 TextSegment。 如果你希望在嵌入之前清理、丰富或格式化 TextSegment,这会很有用。

示例:

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 OpenAiTokenCountEstimator("gpt-4o-mini")))

// adding a name of the Document to each TextSegment to improve the quality of search
.textSegmentTransformer(textSegment -> TextSegment.from(
textSegment.metadata().getString("file_name") + "\n" + textSegment.text(),
textSegment.metadata()
))

.embeddingModel(embeddingModel)
.embeddingStore(embeddingStore)
.build();

查询与文档嵌入(可选)

某些嵌入模型(例如 Cohere Embed v4、Voyage、Google)在文档和查询采用不同嵌入方式时,能获得更好的检索质量。你可以通过声明输入类型来选择启用此功能:在 EmbeddingStoreIngestor 上声明 DOCUMENT(用于索引片段),在 EmbeddingStoreContentRetriever 上声明 QUERY(用于查询,参见 嵌入存储内容检索器)。

EmbeddingStoreIngestor ingestor = EmbeddingStoreIngestor.builder()
.embeddingModel(embeddingModel)
.embeddingStore(embeddingStore)
.embeddingInputType(EmbeddingInputType.DOCUMENT)
.build();

当未设置embeddingInputType时,不会发送输入类型。当设置时,所选的EmbeddingModel必须支持输入类型参数(参见其supportedParameters()),否则嵌入操作会快速失败并抛出UnsupportedFeatureException

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

下图展示了这些组件如何协同工作:

其流程如下:

  1. 用户产生一条 UserMessage,该消息会被转换为一个 Query
  2. QueryTransformerQuery 转换为一个或多个 Query
  3. 每个 QueryQueryRouter 路由到一个或多个 ContentRetriever
  4. 每个 ContentRetriever 为每个 Query 检索相关的 Content
  5. ContentAggregator 将所有检索到的 Content 合并为单个最终的排序列表
  6. Content 列表被注入到原始的 UserMessage
  7. 最后,包含原始查询以及注入的相关内容的 UserMessage 被发送给 LLM

更多细节请参阅每个组件的 Javadoc。

检索增强器

RetrievalAugmentor 是 RAG 管道的入口点。 它负责使用从各种来源检索到的相关 Content 来增强 ChatMessage

在创建 AI 服务 时,可以指定一个 RetrievalAugmentor 实例:

Assistant assistant = AiServices.builder(Assistant.class)
...
.retrievalAugmentor(retrievalAugmentor)
.build();

每次调用 AI Service 时,指定的 RetrievalAugmentor 都会被调用来增强当前的 UserMessage

你可以使用 RetrievalAugmentor 的默认实现 (如下所述),也可以实现自定义实现。

默认检索增强器

LangChain4j 提供了 RetrievalAugmentor 接口的开箱即用实现: DefaultRetrievalAugmentor,它应该适用于大多数 RAG 用例。 其设计灵感来源于这篇文章这篇论文。 建议阅读这些资源,以便更好地理解相关概念。

Query

Query 表示 RAG 流水线中的用户查询。 它包含查询文本和查询元数据。

查询元数据

Query 中的 Metadata 包含可能在 RAG 流水线 各个组件中有用的信息,例如:

  • Metadata.userMessage() - 需要被增强的原始 UserMessage
  • Metadata.chatMemoryId() - 带有 @MemoryId 注解的方法参数的值。更多详情请参阅此处。这可用于识别用户,并在检索过程中应用访问限制或过滤器。
  • Metadata.chatMemory() - 所有之前的 ChatMessage。这有助于理解 Query 被提出时的上下文。
  • Metadata.invocationParameters() - 包含调用 AI Service 时可以指定的 InvocationParameters
interface Assistant {
String chat(@UserMessage String userMessage, InvocationParameters parameters);
}

InvocationParameters parameters = InvocationParameters.from(Map.of("userId", "12345"));
String response = assistant.chat("Hello", parameters);

InvocationParameters 也可以在 AI Service 的其他组件中访问,例如:

参数存储在一个可变、线程安全的 Map 中。

在 AI Service 的单次调用期间,数据可以通过 InvocationParameters 在 AI Service 组件之间传递(例如,从一个 RAG 组件传递到另一个 RAG 组件,或从 RAG 组件传递到工具)。

查询转换器

QueryTransformer 将给定的 Query 转换为一个或多个 Query。 其目标是通过修改或扩展原始 Query 来提高检索质量。

一些已知的改进检索方法包括:

  • 查询压缩
  • 查询扩展
  • 查询重写
  • 退一步提示(Step-back prompting)
  • 假设性文档嵌入(HyDE)

更多详情请参见此处

LangChain4j 还提供了一个可选的社区 Prompt Repetition 模块,该模块提供了 RepeatingQueryTransformer。它会在内容检索之前重复检索查询,并且应该用于转换查询本身,而不是发送给模型的最终增强提示。

默认查询转换器

DefaultQueryTransformerDefaultRetrievalAugmentor 中使用的默认实现。 它不会对 Query 做任何修改,只是将其直接传递。

压缩查询转换器

CompressingQueryTransformer 使用 LLM 将给定的 Query 和之前的对话压缩成一个独立的 Query。 当用户可能提出引用之前问题或答案中的信息的后续问题时,这非常有用。

以下是一个示例:

User: Tell me about John Doe
AI: John Doe was a ...
User: Where did he live?

查询 Where did he live? 本身无法检索到所需信息, 因为其中没有明确提及 John Doe,导致不清楚 he 指的是谁。

使用 CompressingQueryTransformer 时,LLM 会阅读整个对话, 并将 Where did he live? 转换为 Where did John Doe live?

扩展查询转换器

ExpandingQueryTransformer 使用 LLM 将给定的 Query 扩展为多个 Query。 这很有用,因为 LLM 可以以多种方式改写和重新表述 Query, 这将有助于检索更相关的内容。

内容

Content 表示与用户 Query 相关的内容。 目前,它仅限于文本内容(即 TextSegment), 但未来可能支持其他模态(例如,图像、音频、视频等)。

内容检索器

ContentRetriever 使用给定的 Query 从底层数据源检索 Content。 底层数据源几乎可以是任何形式:

  • 嵌入存储
  • 全文检索引擎
  • 向量与全文检索的混合
  • 网络搜索引擎
  • 知识图谱
  • SQL 数据库
  • 等等

ContentRetriever 返回的 Content 列表按相关性从高到低排序。

嵌入存储内容检索器

EmbeddingStoreContentRetriever 使用 EmbeddingModelQuery 进行嵌入, 从而从 EmbeddingStore 中检索相关的 Content

以下是一个示例:

EmbeddingStore embeddingStore = ...
EmbeddingModel embeddingModel = ...

ContentRetriever contentRetriever = EmbeddingStoreContentRetriever.builder()
.embeddingStore(embeddingStore)
.embeddingModel(embeddingModel)
.maxResults(3)
// maxResults can also be specified dynamically depending on the query
.dynamicMaxResults(query -> 3)
.minScore(0.75)
// minScore can also be specified dynamically depending on the query
.dynamicMinScore(query -> 0.75)
.filter(metadataKey("userId").isEqualTo("12345"))
// filter can also be specified dynamically depending on the query
.dynamicFilter(query -> {
String userId = query.metadata().invocationParameters().get("userId");
return metadataKey("userId").isEqualTo(userId);
})
.build();

interface Assistant {
String chat(@UserMessage String userMessage, InvocationParameters parameters);
}

InvocationParameters parameters = InvocationParameters.from(Map.of("userId", "12345"));
String response = assistant.chat("Hello", parameters);

要将查询与input_type=query嵌入(与摄取器的DOCUMENT配对,参见 查询嵌入与文档嵌入),请在检索器上设置输入类型:

ContentRetriever contentRetriever = EmbeddingStoreContentRetriever.builder()
.embeddingStore(embeddingStore)
.embeddingModel(embeddingModel)
.embeddingInputType(EmbeddingInputType.QUERY)
.build();

默认情况下保持不变(不发送输入类型)。EmbeddingModel 必须支持 input_type 参数。

Web 搜索内容检索器

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 数据库内容检索器

SqlDatabaseContentRetrieverContentRetriever 的一个实验性实现, 可在 langchain4j-experimental-sql 模块中找到。

它使用 DataSource 和 LLM 来为给定的自然语言 Query 生成并执行 SQL 查询。

更多信息请参阅 SqlDatabaseContentRetriever 的 javadoc。

这里有一个示例

Azure AI Search 内容检索器

AzureAiSearchContentRetriever 是与 Azure AI Search 的集成。 它支持全文搜索、向量搜索和混合搜索,以及重排序。 它可以在 langchain4j-azure-ai-search 模块中找到。 更多信息请参阅 AzureAiSearchContentRetriever 的 Javadoc。

Neo4j 内容检索器

Neo4jContentRetriever 是与 Neo4j 图数据库的集成。 它将自然语言查询转换为 Neo4j Cypher 查询, 并通过在 Neo4j 中运行这些查询来检索相关信息。 它可以在 langchain4j-community-neo4j-retriever 模块中找到。

Elasticsearch 内容检索器

ElasticsearchContentRetriever 是与 Elasticsearch 的集成。 它支持全文搜索、向量搜索和混合搜索。 它可以在 langchain4j-elasticsearch 模块中找到。 更多信息请参阅 ElasticsearchContentRetriever 的 Javadoc。

查询路由器

QueryRouter 负责将 Query 路由到适当的 ContentRetriever

默认查询路由器

DefaultQueryRouterDefaultRetrievalAugmentor 中使用的默认实现。 它将每个 Query 路由到所有已配置的 ContentRetriever

语言模型查询路由器

LanguageModelQueryRouter 使用 LLM 来决定将给定的 Query 路由到何处。

内容聚合器

ContentAggregator 负责聚合来自以下来源的多个 Content 排名列表:

  • 多个 Query
  • 多个 ContentRetriever
  • 两者兼有

默认内容聚合器

DefaultContentAggregatorContentAggregator 的默认实现, 它执行两阶段倒数排名融合(RRF)。 更多详情请参阅 DefaultContentAggregator Javadoc

重排序内容聚合器

ReRankingContentAggregator 使用 ScoringModel(如 Cohere)来执行重排序。 支持的评分(重排序)模型的完整列表可在 此处 找到。 更多详情请参阅 ReRankingContentAggregator Javadoc

内容注入器

ContentInjector 负责将由 ContentAggregator 返回的 Content 注入到 UserMessage 中。

默认内容注入器

DefaultContentInjectorContentInjector 的默认实现,它简单地将 Content 追加到 UserMessage 的末尾,并带有前缀 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 将如下所示:

How can I cancel my reservation?

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

并行化

当只有一个 Query 和一个 ContentRetriever 时, DefaultRetrievalAugmentor 会在同一线程中执行查询路由和内容检索。 否则,会使用 Executor 来并行化处理过程。 默认情况下,使用的是经过修改的(keepAliveTime 为 1 秒而非 60 秒)Executors.newCachedThreadPool(), 但你也可以在创建 DefaultRetrievalAugmentor 时提供自定义的 Executor 实例:

DefaultRetrievalAugmentor.builder()
...
.executor(executor)
.build;

访问来源

如果你希望在使用 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();

控制聊天记忆中存储的内容

当将 RetrievalAugmentorAI 服务 一起使用时, 你可以控制聊天记忆中存储的是增强后的用户消息(注入了检索到的 Content) 还是原始的用户消息。

此行为通过 AiServices 构建器上的 storeRetrievedContentInChatMemory 选项进行配置。

配置

  • true(默认值)
    在聊天记忆中存储增强后的 UserMessage(原始查询加上检索到的内容)。
    相同的增强消息也会发送给 LLM。

  • false
    在聊天记忆中仅存储原始的 UserMessage(不含检索到的内容)。
    推理期间仍会将增强消息发送给 LLM。

仅存储原始用户消息在你希望保持聊天历史简洁且与用户实际输入一致时非常有用, 同时仍能为 LLM 提供检索到的上下文以生成答案。

示例

interface Assistant {

String chat(String userMessage);
}

ChatModel chatModel = OpenAiChatModel.builder()
.apiKey(System.getenv("OPENAI_API_KEY"))
.modelName(GPT_4_O_MINI)
.build();

MessageWindowChatMemory chatMemory =
MessageWindowChatMemory.withMaxMessages(10);

RetrievalAugmentor retrievalAugmentor =
DefaultRetrievalAugmentor.builder()
.contentRetriever(
EmbeddingStoreContentRetriever.from(embeddingStore, embeddingModel))
.build();

Assistant assistant = AiServices.builder(Assistant.class)
.chatModel(chatModel)
.chatMemory(chatMemory)
.retrievalAugmentor(retrievalAugmentor)
// Store only the original user message in chat memory
.storeRetrievedContentInChatMemory(false)
.build();

示例