LangChain4j实战-检索增强生成RAG (Retrieval-Augmented Generation)

LangChain4j实战-检索增强生成RAG (Retrieval-Augmented Generation)

引言

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

  • 使用RAG,后续我会对其进行介绍
  • 使用你的数据微调LLM
  • 结合RAG和微调

什么是RAG

简单来说,RAG是这样一种方法:在将提示词发送给大语言模型(LLM)之前,先从你自己的数据中检索出相关的信息片段,并将它们注入到提示词中。这样一来,LLM获得这些相关信息,并能够基于它们来生成回答,这应该会降低产生"幻觉"的概率

我们可以通过多种信息检索方法来查找相关信息片段,其中最主流的有:

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

目前LangChain4j主要聚焦于向量搜索。全文搜索和混合搜索仅通过Azure AI Search集成提供支持。

RAG的过程

RAG过程分为两个不同的阶段:索引(Indexing)和检索(Retrieval)。针对这两个阶段,LangChain4j都提供了相应的工具支持

Indexing-索引阶段

在索引阶段,文档会经过预处理,以便在检索阶段能够进行高效搜索。

这个过程会根据所使用的信息检索方法而有所不同。对于向量搜索,这个过程通常包括:清洗文档,用额外的数据和元数据丰富其内容,将文档拆分成更小的片段(也称为"分块"),对这些片段进行嵌入,最后将它们存储到嵌入存储(也称为向量数据库)中。

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

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

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

1

Retrieval-检索阶段

检索阶段通常在线执行,即当用户提交一个需要利用已索引的文档来回答问题时,该阶段便会启动。

这个过程会根据所使用的信息检索方法而有所不同。对于向量搜索,这通常涉及对用户的查询(问题)进行嵌入,并在嵌入存储中执行相似性搜索。相关的片段(原始文档的组成部分)随后被注入到提示词中,并发送给LLM。

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

2

LangChain4j中RAG的实现方式

LangChain4j提供了三种RAG实现方式,后面会以Easy RAG的实现方式来进行代码示例演示

  • Easy RAG(简易RAG): 入门RAG的最简单的方式
  • Naive RAG(朴素RAG): 一种基于向量搜索的基础RAG实现
  • Advanced RAG(高阶RAG): 一种模块化RAG框架,支持查询转换,多源检索和重排序等额外步骤

LangChain4j中RAG的核心API

LangChain4j提供了一套丰富的API,让你能够轻松构建从简单到复杂的自定义RAG流水线

Doucument 文档类

一个Document类代表整个文档,例如单个PDF文件或网页。目前Document只能表示文本信息,但未来的更新将使其能够支持图像和表格,以下是常用的方法

3

常用的方法:

Document.text()
返回Document的文本内容

Document.metadata()
返回Document的Metadata(元数据)

Document,toTextSegment()
将Document转换为TextSegment(文本片段)

Document.from(String,Metadata)
从文本和Metadata创建一个Document

Document.from(String)
从文本创建一个Document,其中Metadata为空

Metadata 元数据类

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

该Metadata以key-value键值对形式存储,其中key为String类型,值可以是以下类型之一:String、Integer、Long、Float、Double、UUID。

Metadata(元数据)之所以有用,有以下几个原因:

  • 在提示中提供额外信息:在将文档内容提供个LLM时,可以附上元数据,为模型提供更多可供参考的附加信息。比如,提供文档的名称和来源,就能帮助LLM更好地理解内容。
  • 在搜索时进行筛选:在搜索相关内容以构建提示时,可以利用元数据进行过滤。例如,你可以将语义搜索的范围限定在仅属于特定所有者的文档中。
  • 同步更新内容:当文档来源更新时(比如某个文档页面发送了变更),我们可以通过其元数据(如"id","source"等)快速找到对应的稳定,并同步更新EmbeddingStore中的内容,确保数据的一致性。

4

常用的方法:

Metadata.from(Map)
从一个Map创造出Metadata

Metadata.put(String key, String value)
向Metadata中添加一个条目

Metadata.putAll(Map)
向Metadata添加多个条目

Metadata.getString(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-selenium
GoogleCloudStorageDocumentLoader langchain4j-document-loader-selenium
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 能够自动检测并解析几乎所有现有的文件格式
MarkdownDocumentParser langchain4j-document-parser-markdown 能够解析 Markdown 格式的文件
YamlDocumentParser langchain4j-document-parser-yaml 能够解析 YAML 格式的文件

从一个文件系统加载一个或多个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。

Document Transformer 文档转换器

各种实现方式能够执行多种文档转换,如:

  • Cleaning(清理):这包括从Document的文本中去除不必要的噪音,这样可以节省标记并减少干扰。
  • Filtering(筛选):将特定的Document完成排除在搜索之外。
  • Enriching(丰富):可以向Document中添加更多信息,以增强搜索结果的潜在效果。
  • Summarizing(总结):可以对Document进行总结,并将简短的摘要存储在Metadata中,以便后续将其包含在每个TextSegment中,从而有可能提高搜索效果。
  • ...等等
在此阶段也可以添加、修改或删除Metadata条目。

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

由于没有一种通用的解决方案,我们建议你根据自身独特的数据来实施自己的DocumentTransformer。

TextSegment 文本块类

一旦文档(Document)加载完成后,就是时候将它们分割(split)成更小的片段(segments)了。LangChain4j的领域模型包含一个TextSegment类,它表示一个Document的片段。顾名思义,TextSegment只能表示文本信息

拆分还是不拆分

在提示词中只包含少数几个相关片段,而不是整个知识库,这样做有几个原因:

  • 大语言模型的上下文窗口有限,整个知识库可能放不下。
  • 提示词中提供的信息越多,大语言模型处理和响应的时间就越长。
  • 提示中提供的信息越多,你支付的费用就越多。
  • 提示中的无关信息可能会干扰大语言模型,增加其产生幻觉的几率。
  • 提示中提供的信息越多,就越难解释大语言模型是基于哪些信息做出响应的。

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

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

1.每个文档(例如PDF文件、网页等)被视为一个原子性的、不可分割的整体

在RAG流水线的检索阶段,会检索出N个最相关的文档,并将其注入到提示词中。由于文档可能很长,在这种情况下,你很可能需要使用长上下文的语言模型。如果获取完整的文档很重要,例如当你不能错过任何细节时,这种方法就很适用。

优点:

  • 不会丢失任何上下文。

缺点:

  • 消耗更多Token。
  • 有时,一个文档可能包含多个章节或主题,而非所有内容都与查询相关。
  • 向量搜索质量会下降,因为不同大小的完整文档被压缩成一个单一的、固定长度的向量。

2.文档被拆分成更小的片段,例如章节、段落,有时甚至是句子

在RAG流水线的检索阶段,会检索出N个最相关的片段,并将其注入到提示词中。挑战在于要确保每个片段都能为大语言模型提供足够的上下文/信息来理解其内容。上下文的缺失可能导致大语言模型误解给定的片段并产生幻觉。一个常见的策略是将文档拆分成带有重叠的片段,但并不能完全理解问题。有几种高级技术可以提供帮助,例如“句子窗口检索”、“自动合并检索”、“父文档检索”。在此不展开讲述,但本质上,这些方法有助于获取检索片段周围的更多上下文,为大语言模型提供检索片段前后的额外信息。

优点:

  • 向量搜索质量更高。
  • 减少了Token消耗。

缺点:

  • 仍可能丢失部分上下文。

TextSegment类的常用方法

5

TextSegment.text()
返回TextSegment的文本内容

TextSegment.metadata()
返回TextSegment的Metadata

TextSegment.from(String, Metadata)
从文本和Metadata创建一个TextSegment

TextSegment.from(String)
从文本创建一个TextSegment,其中Metadata为空

Document Splitter 文档分割器

LangChain4j具有一个DocumentSplitter接口,其中包含多个开箱即用的实现:

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

它们都遵循以下工作流程:

  1. 实例化与配置:实例化一个DocumentSplitter,并指定TextSegment的期望大小,以及可选的、以字符或Token为单位的重叠大小。
  2. 调用方法:调用DocumentSplitter的split(Document)或DocumentSplitter(List)方法。
  3. 拆分单元:DocumentSplitter会将给定的Document拆分成更小的单元,这些单元的具体类型取决于所使用的分割器。例如,DocumentByParagraphSplitter会将文档按段落(由两个或更多连续换行符定义)进行拆分,而DocumentBySentenceSplitter则使用OpenNLP库的句子检测器来拆分句子,以此类推。
  4. 组合成片段:DocumentSplitter会将这些更小的单元(如段落、句子、单词等)组合成TextSegment,并会尽可能多地将单元合并到单个TextSegment中,只要不超过步骤1所设定的大小限制即可。如果某些单元仍然过大,无法放入一个TextSegment,它就会调用一个子分割器。这个子分割器是另一个DocumentSplitter,它能将那些过大的单元进一步拆分成更细粒度的单元。
  5. 处理元数据:所有的元数据条目都会从Document复制到每个TextSegment中。同时,每个TextSegment都会被添加一个名为"index"的唯一元数据条目。第一个TextSegment的index为0,第二个为1,以此类推。

Text Segment Transformer 文本块转换器

TextSegmentTransformer与DocumentTransformer类似,但它转换的时TextSegment。

和DocumentTransformer一样,并不存在一刀切的解决方案,因此建议实现自己的TextSegmentTransformer,为你独特的数据进行量身定制。

一种能有效提升检索效果的技术,就是在每个TextSegment中包含文档(Document)标题和简短摘要。

LangChain构建RAG的一般步骤

  1. 加载文档:使用DocumentLoader和DocumentParser加载文档
  2. 转换文档:使用DocumentTransformer清理或增强文档(可选)
  3. 拆分文档:使用DocumentSplitter将文档拆分为更小的片段(可选)
  4. 嵌入文档:使用EmbeddingModel将文档片段转换为嵌入向量
  5. 存储嵌入:使用EmbeddingStoreIngestor存储嵌入向量
  6. 检索相关内容:根据用户查询,从EmbeddingStore检索最相关的文档片段
  7. 生成响应:将检索到的相关内容与用户查询一起提供给语言模型,生成最终响应

基于LangChain的Easy RAG方法实现RAG的实例

bom物料清单

    <properties>
        <maven.compiler.source>21</maven.compiler.source>
        <maven.compiler.target>21</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <java.version>21</java.version>
        <!--Spring Boot-->
        <spring-boot.version>3.5.3</spring-boot.version>
        <!--LangChain4J-->
        <langchain4j.version>1.7.1</langchain4j.version>
        <!--LangChain4J community-->
        <langchain4j-community.version>1.7.1-beta14</langchain4j-community.version>
    </properties>
    <!--    <dependencyManagement> 是一个声明和集中管理依赖版本和配置的机制(物料清单)。它本身并不引入实际的依赖,而是为依赖提供一个“模板”或“蓝图”-->
    <dependencyManagement>
        <dependencies>
            <!--Spring Boot-->
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-dependencies</artifactId>
                <version>${spring-boot.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
            <!--LangChain4J-->
            <dependency>
                <groupId>dev.langchain4j</groupId>
                <artifactId>langchain4j-bom</artifactId>
                <version>${langchain4j.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
            <!--langchain4j-community-->
            <dependency>
                <groupId>dev.langchain4j</groupId>
                <artifactId>langchain4j-community-bom</artifactId>
                <version>${langchain4j-community.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

pom依赖

    <dependencies>
        <!--快速构建一个基于 Spring MVC 的 Web 应用程序而预置的一组依赖项的集合-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!--一个通过注解在编译时自动生成 Java 样板代码的库-->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>
        <!--LangChain4J openAI集成依赖(低级API依赖)-->
        <dependency>
            <groupId>dev.langchain4j</groupId>
            <artifactId>langchain4j-open-ai</artifactId>
        </dependency>
        <!--LangChain4J 高级AI服务API依赖-->
        <dependency>
            <groupId>dev.langchain4j</groupId>
            <artifactId>langchain4j</artifactId>
        </dependency>
        <!--LangChain4J 响应式编程依赖(AI服务使用Flux)-->
        <dependency>
            <groupId>dev.langchain4j</groupId>
            <artifactId>langchain4j-reactor</artifactId>
        </dependency>
        <!--hutool Java工具库-->
        <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>5.8.40</version>
        </dependency>
        <!--langchain4j 依赖qdrant向量数据库的依赖-->
        <dependency>
            <groupId>dev.langchain4j</groupId>
            <artifactId>langchain4j-qdrant</artifactId>
        </dependency>
        <!--LangChain4J easy-rag依赖-->
        <dependency>
            <groupId>dev.langchain4j</groupId>
            <artifactId>langchain4j-easy-rag</artifactId>
        </dependency>
    </dependencies>

声明式AI服务接口

public interface ChatAssistant {
    String chat(String text);
}

大模型配置类

/**
 * 大模型配置类
 */
@Configuration
public class LLMConfig {
    @Bean
    public ChatModel chatModel() {
        return OpenAiChatModel.builder()
                .apiKey(System.getenv("aliyunQwen-apiKey"))
                .modelName("qwen3-next-80b-a3b-instruct")
                .baseUrl("https://dashscope.aliyuncs.com/compatible-mode/v1")
                .build();
    }

    @Bean
    public EmbeddingModel embeddingModel() {
        return OpenAiEmbeddingModel.builder()
                .apiKey(System.getenv("aliyunQwen-apiKey"))
                .modelName("text-embedding-v4")
                .baseUrl("https://dashscope.aliyuncs.com/compatible-mode/v1")
                .build();
    }

    /**
     * 基于内存向量数据库的向量存储
     *
     * @return 嵌入存储
     */
    @Bean("memoryEmbeddingStroe")
    public InMemoryEmbeddingStore<TextSegment> inMemoryEmbeddingStore() {
        return new InMemoryEmbeddingStore<>();
    }

    /**
     * 构建带聊天记忆,RAG功能的AI服务实例
     *
     * @param chatModel              大模型实例
     * @param inMemoryEmbeddingStore 内存向量数据库嵌入存储
     * @return AI服务实例
     */
    @Bean
    public ChatAssistant chatAssistant(ChatModel chatModel, @Qualifier("memoryEmbeddingStroe") EmbeddingStore<TextSegment> inMemoryEmbeddingStore) {
        return AiServices.builder(ChatAssistant.class)
                .chatModel(chatModel)
                .chatMemory(MessageWindowChatMemory.withMaxMessages(100))
                .contentRetriever(EmbeddingStoreContentRetriever.from(inMemoryEmbeddingStore))
                .build();
    }
}

提供大模型调用的控制层接口

@Slf4j
@RestController
@RequestMapping("easyRag")
public class ChatEasyRagController {
    @Autowired
    private InMemoryEmbeddingStore<TextSegment> inMemoryEmbeddingStore;
    @Autowired
    private ChatAssistant chatAssistant;

    @GetMapping("/chatWithRag")
    public String chatWithRag() throws FileNotFoundException {
        FileInputStream fileInputStream = new FileInputStream("F:\\test\\通义千问大模型异常状态码说明.docx");
        Document document = new ApacheTikaDocumentParser().parse(fileInputStream);
        EmbeddingStoreIngestor.ingest(document, inMemoryEmbeddingStore);
        String answer = chatAssistant.chat("错误代码为duplicate_custom_id是什么含义");
        log.info("answer: {}", answer);
        return answer;
    }
}

参考资料

https://docs.langchain4j.dev/tutorials/rag

posted @ 2025-12-03 00:08  柯南。道尔  阅读(44)  评论(0)    收藏  举报