LangChain4j学习笔记(四):聊天记忆ChatMemory和检索增强生成RAG

LangChain4j学习笔记(四):聊天记忆ChatMemory和检索增强生成RAG

知识分子没文化
2026-01-24 / 0 评论 / 1,468 阅读 / 9,635 字数 / 正在检测是否收录...

目录

LangChain官网:LangChain4j,官方英文文档:Get Started | LangChain4j,API 文档:Overview (LangChain4j)

非官方中文文档:快速开始 | LangChain4j 中文文档

官方示例代码仓库:langchain4j/langchain4j-examples

LangChain4j版本:1.14.0-beta24

一、聊天记忆ChatMemory

1.1、什么是聊天记忆

大语言模型本身是无状态的,每次调用都是独立的请求,并不记得上一次对话说了什么,如果问“我的订单号是多少”,它回答了;你再问“帮我取消它”,模型会胡言乱语,因为它并不知道“它”指什么。

而给对话加入 聊天记忆(Chat Memory) 就可以解决这个问题,即在每次对话中,把历史消息(SystemMessage、UserMessage、AiMessage、ToolMessage)按顺序存起来,在请求时一起发给模型,这样一来模型知道前面的对话聊了什么,本次回答就能跟着之前对话的进度回答了。

在 LangChain4j 框架中,ChatMemory 就是用来实现聊天记忆的接口,它本质上是一个消息容器,主要负责维护当前会话的历史消息列表,并在每次请求时决定保留哪些、丢弃哪些、如何组装发送给模型。

在目前的大语言模型中,每个模型都有上下文的最大 token 限制,当上下文过长时,给大模型发送的聊天记录就得通过淘汰策略只保留部分数据,根据淘汰策略的不同,ChatMemory 有两个实现类:

实现类 淘汰策略 适用场景 性能消耗
MessageWindowChatMemory 超出时按消息条数淘汰最早的消息,保留最近的 N 条 对话轮数不多,但每轮可能很长(如代码生成) 内存占用低,CPU 消耗低
TokenWindowChatMemory Token 数量淘汰,保留最近的 N 个 Token 的消息,超限则移除最早的消息,直至总 token 数不超过阈值 对话很长,需要按 Token 预算精准控制 内存占用中,CPU 消耗高

关于 TokenWindowChatMemory 的分词方式:不同模型的分词方式不同,用错 TokenCountEstimator 会导致估算偏差。

在 LangChain4j 中,TokenWindowChatMemory 并不是按照消息条数或字符长度来管理上下文,而是按照 Token 数量 来控制历史消息窗口。它会在每次新增消息后,统计当前会话的总 Token 数,如果超过设定的上限,则自动移除较早的整条消息,以确保最终发送给大模型的上下文不会超出模型的上下文窗口限制。

由于不同大模型采用的 Tokenizer(分词器)实现并不相同,因此同一段文本在不同模型下得到的 Token 数量可能存在明显差异。例如,OpenAI GPT 系列使用基于 BPE(Byte Pair Encoding)的分词算法,Qwen 系列、DeepSeek 系列以及部分国产模型则可能采用各自优化的分词策略。对于中文、英文、代码、特殊符号等内容,不同 Tokenizer 的切分结果往往并不一致。

对于许多国产模型,例如 Qwen、DeepSeek、GLM、Moonshot 等,LangChain4j 的社区包中有可能提供对应的 TokenCountEstimator,需要额外导入依赖才能使用。

1.2、实现记忆功能

官方文档:Chat Memory | LangChain4j

1.2.1、编程式

只需要新建一个 ChatMemory 对象实例,然后在 AiServices.builder() 时使用 chatMemory() 方法传入 ChatMemory 对象即可:

// 基于消息条数,保留最近 10 条消息
ChatMemory messageWindowChatMemory = MessageWindowChatMemory.withMaxMessages(10);

// 基于 Token 数量,保留最近 2000 Tokens,需配合 OpenAiTokenCountEstimator 或其他分词器
ChatMemory tokenWindowChatMemory = TokenWindowChatMemory.withMaxTokens(2000, new OpenAiTokenCountEstimator("gpt-4"));

Assistant assistant = AiServices.builder(Assistant.class)
        .chatModel(chatModel)
        .chatMemory(messageWindowChatMemory)          // 注入 ChatMemory 对象
        .build();

// 多轮对话
String answer1 = assistant.chat("我叫张三");      // 模型回复:"你好张三"
String answer2 = assistant.chat("我叫什么名字?"); // 模型回复:"你叫张三"
1.2.2、声明式 + @MemoryId实现会话隔离

在多用户、多会话场景下,不同的用户、不同的对话线程需要有独立的聊天记忆,互不干扰。LangChain4j 通过 @MemoryId 注解和 ChatMemoryProvider 来实现会话隔离功能。

@MemoryId:用于标注 AI Service 接口方法的参数,表示该参数是会话或用户的唯一标识符,其通常是用户 ID 或会话 ID,值可以是 StringLong 等任意类型。框架会根据这个标识符,通过 ChatMemoryProvider 获取或创建对应的 ChatMemory 实例。

ChatMemoryProvider:根据 @MemoryId 动态获取或创建对应的 ChatMemory 实例。

@AiService 声明的接口中,在方法中添加一个 @MemoryId 注解的参数:

@AiService(wiringMode = EXPLICIT, chatModel = "qwenChatModel")
public interface Assistant {

    @SystemMessage("你是一个友好的AI助手")
    String chat(@MemoryId String memoryId, String userMessage);

}

调用时传入不同的 memoryId,框架会自动为每个ID创建和维护独立的聊天记忆:

@RestController
public class ChatController {

    @Autowired
    private Assistant assistant;

    @GetMapping("/chat")
    public String chat(@RequestParam("userId") String userId, 
                       @RequestParam("message") String message) {
        // userId被当作memoryId,不同用户有独立的记忆
        return assistant.chat(userId, message);
    }
}

如果不想提前创建所有用户的 ChatMemory,可以单独在配置类中创建一个 ChatMemoryProvider

@Configuration
public class ChatMemoryConfig {

    @Bean
    public ChatMemoryProvider chatMemoryProvider() {
        return memoryId -> MessageWindowChatMemory.builder()
                .maxMessages(20)
                .id(memoryId)
                .build();
    }

}

ChatMemoryProvider 是一个函数式接口,每次遇到新的 memoryId 时自动创建新的 ChatMemory。与直接注入 chatMemory 的区别在于:chatMemory() 直接绑定一个固定的 ChatMemory 实例,chatMemoryProvider 是按 memoryId 隔离的多例。

注入创建的这个 ChatMemoryProvider 实例也很简单,可以通过 AiServices.builder() 时调用 chatMemoryProvider() 方法:

Assistant assistant = AiServices.builder(Assistant.class)
        .chatModel(chatModel)
        .chatMemoryProvider(provider)  // 注入自定义的chatMemoryProvider
        .build();

或者通过 @AiService 注解的 “chatMemoryProvider” 属性绑定:

@AiService(wiringMode = EXPLICIT
           , chatModel = "qwenChatModel"
           , chatMemoryProvider = "chatMemoryProvider"
)
public interface Assistant {

    @SystemMessage("你是一个能力强大的AI助手")
    String chat(@MemoryId String userId, String userQuestion);

}

1.3、记忆持久化(ChatMemoryStore)

官方文档:chat-memory#persistence | LangChain4j

默认情况下,MessageWindowChatMemoryTokenWindowChatMemory 都使用 InMemoryChatMemoryStore,也就是将所有的聊天记忆存储在内存中,当应用重启后记忆会丢失。

如果要自定义记忆的持久化方式,就需要重写 LangChain4j 提供的 ChatMemoryStore 接口,其默认存在 InMemoryChatMemoryStoreSingleSlotChatMemoryStore 两个基于内存的实现类:

  • InMemoryChatMemoryStore:支持多用户/多会话的数据隔离,内部维护了一个线程安全的哈希表,以 memoryId 作为键(Key),将每个用户的聊天记录列表作为值(Value)进行存储。适用于需要为不同用户或不同会话提供独立记忆空间的场景
  • SingleSlotChatMemoryStore:所有消息共享同一个存储空间,不支持会话隔离,内部仅维护了一个简单的列表,无论传入什么 memoryId,它都会直接操作这唯一的一个集合。所有的增删改查都在这一个列表中完成。适用于单一的全局机器人或测试环境

ChatMemoryStore 接口本身有三个方法,实现这三个方法即可:

public interface ChatMemoryStore {
    List<ChatMessage> getMessages(Object memoryId);                     // 获取某用户的记忆
    void updateMessages(String memoryId, List<ChatMessage> messages);   // 更新记忆
    void deleteMessages(String memoryId);                               // 删除记忆
}

以持久化到 Redis 和 MongoDB 为例来说明。

1.3.1、Redis

引入 相关 Maven 坐标:

<!-- Spring Data Redis -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

Redis 的配置信息:

spring:
  data:
    redis:
      host: localhost
      port: 6379
      password:
      database: 0

LangChain4j 内置了消息列表的 JSON 序列化/反序列化工具类,使用 StringRedisTemplate 存储 JSON 格式的消息列表:

@Component
public class RedisChatMemoryStore implements ChatMemoryStore {

    @Autowired
    private StringRedisTemplate redisTemplate;

    private static final String KEY_PREFIX = "chat:memory:";

    @Override
    public List<ChatMessage> getMessages(String memoryId) {
        String key = KEY_PREFIX + memoryId;
        String json = redisTemplate.opsForValue().get(key);
        if (json == null || json.isEmpty()) {
            return new ArrayList<>();
        }

        return ChatMessageDeserializer.messagesFromJson(json);

    }

    @Override
    public void updateMessages(String memoryId, List<ChatMessage> messages) {
        String key = KEY_PREFIX + memoryId;
        String json = ChatMessageSerializer.messagesToJson(messages);
        // 设置过期时间,比如 7 天,避免 Redis 无限膨胀
        redisTemplate.opsForValue().set(key, json, Duration.ofDays(7));

    }

    @Override
    public void deleteMessages(String memoryId) {
        String key = KEY_PREFIX + memoryId;
        redisTemplate.delete(key);
    }

}
1.3.2、MongoDB

引入 Spring Data Mongodb相关 Maven 坐标:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>

MongoDB 的配置信息:

spring:
  data:
    mongodb:
      host: localhost
      port: 27017
      database: chat_memory_db         # 当没有此数据库时会自动创建
      username: admin
      password: admin
      authentication-database: admin   # 认证库,默认是admin

      # 或者合并写成:
#      uri: mongodb://admin:admin@localhost:27017/chat_memory_db?authSource=admin&ssl=false

创建一个实体类 ChatMessageBean 用于映射 MongoDB 中的文档集合:

@Data
@NoArgsConstructor
@AllArgsConstructor
// @Document 声明一个 Java 类与 MongoDB 中的集合相对应
@Document("chatMemory")
public class ChatMessageBean {

    /**
     * 消息ID
     */
    @Id
    private ObjectId messageId;

    /**
     * 记忆ID
     */
    private String memoryId;

    /**
     * 存储当前聊天记录列表的 Json 字符串
     */
    private String content;

}

新建一个类 MongoChatMemoryStore 实现三个方法:

@Component
public class MongoChatMemoryStore implements ChatMemoryStore {

    @Autowired
    private MongoTemplate mongoTemplate;

    @Override
    public List<ChatMessage> getMessages(String memoryId) {
        Criteria criteria = Criteria.where("memoryId").is(memoryId);
        Query query = new Query(criteria);
        ChatMessageBean chatMessage = mongoTemplate.findOne(query, ChatMessageBean.class);
        if (chatMessage == null) {
            return new LinkedList<>();
        }
        // 将数据库中的Json聊天信息提取出来
        return ChatMessageDeserializer.messagesFromJson(chatMessage.getContent());
    }

    @Override
    public void updateMessages(String memoryId, List<ChatMessage> chatMessageList) {
        Criteria criteria = Criteria.where("memoryId").is(memoryId);
        Query query = new Query(criteria);
        Update update = new Update();
        update.set("content", ChatMessageSerializer.messagesToJson(chatMessageList));
        mongoTemplate.upsert(query, update, ChatMessageBean.class);

    }

    @Override
    public void deleteMessages(String memoryId) {
        Criteria criteria = Criteria.where("memoryId").is(memoryId);
        Query query = new Query(criteria);
        mongoTemplate.remove(query, ChatMessageBean.class);
    }

}
1.3.3、注入ChatMemoryStore

在实现了自定义的持久化方式之后,通过 MessageWindowChatMemoryRedisChatMemoryStore 注入到 ChatMemoryProvider 中,并在创建 AiService 时注入:

// 在 AI Service 配置中使用
@Bean
public Assistant assistant(ChatModel chatModel, RedisChatMemoryStore redisChatMemoryStore) {
    return AiServices.builder(Assistant.class)
            .chatModel(chatModel)
            .chatMemoryProvider(memoryId -> MessageWindowChatMemory.builder()
                    .maxMessages(10)
                    .chatMemoryStore(redisChatMemoryStore)    // 注入持久化存储
                    .build())
            .build();
}

二、检索增强生成RAG

2.1、RAG介绍

2.1.1、解决什么问题

大语言模型尽管能力很强,但是也有两个绕不开的问题:

  • 知识边界优先:模型的知识库只截止于训练日期,它不知道训练日期之后产生的知识和训练数据中没有涉及到的知识。
  • 大模型幻觉:当遇到不知道的问题时,大模型可能会自信地编造根本不存在的东西,显出一副“一本正经的胡说八道”的样子

而对此也有一个比较有效的解决方法,就是 RAG(Retrieval-Augmented Generation),即检索增强生成。

RAG(检索增强生成)就是让模型在回答问题之前,先在一个知识库里搜索相关的内容片段,把这些片段作为参考资料拼到提示词里,再让模型根据这些事实来回答。这样既扩展了模型的知识边界,又提高了回答的准确性。

RAG的适用场景:

  • 私有知识库场景,可避免将知识直接纳入模型训练(如 HR、IT 支持、产品文档)
  • 数据处理分析(如根据PDF文档回答相关问题)
  • 法律、医疗等专业领域,用来补充大模型的知识数据
2.1.2、核心流程

主要分成索引(Indexing)检索生成(Retrieval & Generation)两个阶段。

flowchart TB subgraph Indexing["索引阶段(Indexing)"] A["文档加载<br/>加载 PDF、Word、TXT 等文档"] B["文档分割<br/>将长文档切分成合适大小的片段"] C["嵌入模型<br/>文本转换为向量"] D["向量存储<br/>保存向量及原文片段"] A --> B B --> C C --> D end subgraph Retrieval["检索与生成阶段(Retrieval & Generation)"] E["用户提问"] F["问题转换为向量<br/>使用相同嵌入模型"] G["向量检索<br/>检索相似文本片段"] H["提示词构建<br/>将问题+片段组合为提示词"] I["模型生成答案"] E --> F F --> G G --> H H --> I end D -. 提供知识库 .-> G

LangChain4j 对 RAG 流程进行了封装,主要由以下几个组件完成:

  1. 文档加载器DocumentLoader,负责从 PDF、TXT、Word 等文件解析出纯文本
  2. 文档分割器DocumentSplitter,将长文档切分成适合向量化的片段
  3. 嵌入模型EmbeddingModel,将文本片段转换为高维向量
  4. 向量存储EmbeddingStore,将向量存入向量数据库,支持相似度检索
  5. 内容检索器ContentRetriever,根据用户问题负责从知识库中检索相关片段,拼接进提示词,是用户在 AI Service中 最主要交互的组件

2.2、文档处理步骤

2.2.1、文档加载器(Document Loader)

官方文档:RAG (Retrieval-Augmented Generation) #document-loader| LangChain4j

首先要把知识库文件加载成 Document 对象。LangChain4j 提供了多种文档加载器,支持常见的文档格式:

文档加载器 说明 依赖包
TextDocumentParser 解析纯文本文件 langchain4j
ApachePdfBoxDocumentParser 解析PDF文件 langchain4j-document-parser-apache-pdfbox
ApacheTikaDocumentParser 通用格式(Word、PPT 等) langchain4j-document-parser-apache-tika
UrlDocumentLoader 网页 langchain4j

加载 PDF 需要额外解析器,引入依赖:

<!--底层基于Apache PDFBox的PDF文档解析器-->
<dependency>
    <groupId>dev.langchain4j</groupId>
    <artifactId>langchain4j-document-parser-apache-pdfbox</artifactId>
</dependency>

<!--底层基于Apache Tika的通用解析器-->
<dependency>
    <groupId>dev.langchain4j</groupId>
    <artifactId>langchain4j-document-parser-apache-tika</artifactId>
</dependency>

通过 FileSystemDocumentLoader.loadDocument() 对象根据路径加载文件,然后指定文件的加载器:

// 加载单个指定路径的 pdf 文件
String textPath = "./knowledge/testFile.pdf";
Document document = FileSystemDocumentLoader.loadDocument(textPath, new ApachePdfBoxDocumentParser());
System.out.println(document.text());

// 加载单个指定路径的 txt 文件
String textPath = "./knowledge/testFile.txt";
Document textDocument = FileSystemDocumentLoader.loadDocument(textPath, new TextDocumentParser());
System.out.println(document.text());

ApachePdfBoxDocumentParserTextDocumentParser 会把文件都被封装成 Document 对象,后续分割、嵌入都围绕它操作。

对于加载后的文档 Document 对象,一般还需要文档本身的信息,即元数据。LangChain4j 中的元数据被封装为 Metadata 类,Metadata 对外表现为键值存储结构,使用 put()添加元数据,并通过 getString(), getInteger() 等方法按类型安全地提取数据:

// 手动向文档中追加自定义的业务元数据
document.metadata().put("author", "张三");
document.metadata().put("department", "研发部");
document.metadata().put("loadTime", java.time.LocalDateTime.now().toString());

// 验证自定义元数据是否成功写入
System.out.println("作者: " + document.metadata().getString("author"));
System.out.println("部门: " + document.metadata().getString("department"));
2.2.2、文档分割器(Document Splitter)

官方文档:RAG (Retrieval-Augmented Generation) #document-splitter)| LangChain4j

加载进来的文档通常是一整个长文本,不能直接拿去嵌入,因为一方面 LLM 上下文窗口有限,不能塞入长篇大论,另一方面向量搜索需要细粒度的段落,匹配度才高,所以需要将其切分成语义完整、粒度适中的片段。

LangChain4j 提供了多种文本分割器:

分割器 分割依据 适用场景
DocumentByParagraphSplitter 按段落分割 自然语言文档
DocumentBySentenceSplitter 按句子分割 需要保持句子语义完整
DocumentByLineSplitter 按行分割 结构化文档,如日志、CSV
DocumentByWordSplitter 按单词分割 需要控制单词级别的分割
DocumentByCharacterSplitter 按字符(字)分割 对字符数有精确要求的场景
DocumentByRegexSplitter 按正则表达式分割 自定义分割规则
DocumentSplitters.recursive() 按照段落/行/句子/词的顺序从高到低尝试递归分割,每当单次分割的文本超出最大字符数限制便降级为更小层级 长文档,能在不同级别尝试分割

示例代码:

// 按段落分割文档
DocumentSplitter documentByParagraphSplitter = new DocumentByParagraphSplitter(
    300,                  // maxSegmentsPerParagraph: 每个片段最多 300 token
    50,                   // overlapSize: 相邻片段重叠 50 token
    new HuggingFaceTokenCountEstimator()   // 指定分词方式,为空时则按字符分割
);

// 按照段落/行/句子/词的顺序从高到低尝试递归分割
DocumentSplitter documentSplitters = DocumentSplitters.recursive(
    512,                  // maxSegmentSizeInTokens:每个片段最多 512 token
    64,                   // maxOverlapSizeInTokens:相邻片段重叠 64 token
    new HuggingFaceTokenCountEstimator()   // 指定分词方式,为空时则按字符分割
);

// 切割单个文档
List<TextSegment> segmentList = documentByParagraphSplitter.split(pdfDocument);
// 如果是多个文档 document1、document2、document3
List<TextSegment> segmentList = documentSplitters.splitAll(document1, document2, document3);
// 如果是文档集合 documentList
List<TextSegment> segmentList = documentByParagraphSplitter.splitAll(documentList);

TextSegmentDocument 分割后的结果,通常包含:

  • text:分割后的文本内容(字符串)。
  • metadata:内部以键值方式存储,用于携带从原 Document 继承或自定义的元数据(如来源、页码、时间戳等)。同时,分割器还会自动为每个片段添加一个唯一的标识符(如 index=0, index=1)。在检索阶段,这些元数据可用于过滤结果或帮助大模型更好地理解上下文
Token计算

DocumentSplitter 及其子类在分割文本时,为了判断片段大小是否超限,通过两种模式来计算:

  • 字符模式(默认):构造分割器时不提供 TokenCountEstimator 实例,分割器直接以字符数量为单位进行切分
  • Token 模式:如果需要更精确地控制分割粒度,在显式提供一个 TokenCountEstimator 实例,以实现精准计算出文本或聊天消息中的真实 Token 数量。LangChain4j 提供的 TokenCountEstimator 实现中,针对 OpenAI 系列模型,可以使用 OpenAiTokenCountEstimator;而在使用本地嵌入模型时,也可以配合 HuggingFaceTokenCountEstimator 等工具。
为什么需要重叠(overlap)

假设文档有一段:"LangChain4j 是一个 Java 框架。它简化了与大语言模型的交互。开发者可以通过声明式 API 快速构建 AI 应用。"

如果不重叠,按 300 token 硬切,可能把"它简化了与大语言模型的交互"这句话拦腰截断,前半段在一个片段,后半段在另一个片段。设置 50 token 的重叠,可以保证边界处的语义不被破坏,保证语义的完整性。

2.2.3、向量化(Embedding)

官方文档:RAG (Retrieval-Augmented Generation) #embedding| LangChain4j

分割好的文本段需要通过向量模型转换成向量,也就是 Embedding。

向量的特点是:语义相近的文本,向量距离也相近

LangChain4j 中将向量模型封装为 EmbeddingModel,类似于 ChatModel。LangChain4j 支持多种向量模型:

  • 内置轻量模型:langchain4j-embeddings-all-minilm-l6-v2(本地运行,无网络依赖)
  • 调用外部 API:DashScope 的 text-embedding-v2、OpenAI 的 text-embedding-3-small
  • ONNX 模型:langchain4j-embeddings-onnx

更多支持可在官方文档查看:Embedding Models | LangChain4j

导入 DashScope 的依赖包:

<dependency>
    <groupId>dev.langchain4j</groupId>
    <artifactId>langchain4j-community-dashscope-spring-boot-starter</artifactId>
</dependency>
EmbeddingModel textEmbeddingModel = OpenAiEmbeddingModel.builder()
        .baseUrl("https://dashscope.aliyuncs.com/compatible-mode/v1")
        .apiKey(System.getenv("Key_AliBaiLian"))
        .modelName("text-embedding-v3")
        .build();

EmbeddingModel qwenEmbeddingModel = QwenEmbeddingModel.builder()
    .apiKey(System.getenv("Key_AliBaiLian"))
    .modelName("text-embedding-v3")
    .build();

// 对单个片段向量化
Embedding embedding = qwenEmbeddingModel.embed("Hello World").content();
System.out.println("向量维度:" + embedding.vector().length);
System.out.println("向量输出:" + embedding.toString());
2.2.4、向量存储(EmbeddingStore)

官方文档:RAG (Retrieval-Augmented Generation) #embedding-store| LangChain4j

向量存储是 RAG 的核心,负责存储文本向量并支持高效的相似性检索。LangChain4j 将向量存储这部分抽象为了 EmbeddingStore 接口。

LangChain4j 同样支持多种向量数据库:

  • InMemoryEmbeddingStore:纯内存,开发用,重启丢失
  • Redis:需要在 Redis 中安装 RedisStack 或 RediSearch 模块
  • ElasticsearchMilvusPineconeQdrant

更多支持可在官方文档查看:Comparison table of all supported Embedding Stores | LangChain4j

EmbeddingStore 所使用数据库中向量集合的向量维度必须与 EmbeddingModel 输出维度保持一致,否则无法存储。

@Configuration
public class ModelConfig {

    /**
     * 基于pinecone向量数据库的向量存储
     * @return
     */
    @Bean("pineconeEmbeddingStore")
    public EmbeddingStore<TextSegment> pineconeEmbeddingStore(@Qualifier("textEmbeddingModel") EmbeddingModel embeddingModel) {
        // 创建向量存储
        return PineconeEmbeddingStore.builder()
                .apiKey("xxxxxxx")
                // 如果指定的索引不存在,将创建一个新的索引
                .index("xiaozhi-index")
                // 如果指定的命名空间不存在,将创建一个新的命名空间
                .nameSpace("xiaozhi-namespace")
                .createIndex(
                        PineconeServerlessIndexConfig.builder()
                                .cloud("AWS")               // 指定索引部署的云服务商
                                .region("us-east-1")        // 指定索引的所在主机区域
                                .dimension(embeddingModel.dimension())// 指定索引的向量维度,该维度与 embeddedModel 生成的向量维度相同
                                .build())
                .build();
    }

    /**
     * 基于Qdrant向量数据库的向量存储
     * @return
     */
    @Bean("qdrantEmbeddingStore")
    public EmbeddingStore qdrantEmbeddingStore() {
        return QdrantEmbeddingStore.builder()
                .host("127.0.0.1")
                .port(6334)
                .collectionName("xiaozhi_collection")
                .build();
    }

    /**
     * 基于内存的向量存储
     * @return
     */
    @Bean("inMemoryEmbeddingStore")
    public EmbeddingStore inMemoryEmbeddingStore() {
        return new InMemoryEmbeddingStore<>();
    }
}
2.2.5、数据存储(EmbeddingStoreIngestor)

官方文档:RAG (Retrieval-Augmented Generation) #embedding-store-ingestor| LangChain4j

前面已经完成了文档加载(DocumentLoader)、文档切分(DocumentSplitter)和向量模型(EmbeddingModel)的配置,手动将数据存入向量数据库,通常需要以下几个步骤:

// 1. 切分文档
List<TextSegment> segments = documentSplitter.split(document);
// 2. 对每个片段生成向量
for (TextSegment segment : segments) {
    Embedding embedding = embeddingModel.embed(segment.text()).content();
    // 3. 存入向量库
    embeddingStore.add(embedding, segment);
}

当知识库文件较多时,每次都手动编写这套流程会比较繁琐。

因此 LangChain4j 提供了 EmbeddingStoreIngestor,用于将文档加载、切分、向量模型、向量存储这一整套流程封装起来,实现文档的数据导入。比如:

EmbeddingStoreIngestor ingestor = EmbeddingStoreIngestor.builder()
    .embeddingModel(embeddingModel)      // 向量模型
    .embeddingStore(embeddingStore)      // 向量存储
    .documentSplitter(recursiveSplitter) // 文档分割器
    .textSegmentTransformer(textSegment -> {
        // 自定义转换逻辑
        textSegment.metadata().put("source", "knowledge-base");
        return textSegment;
     })
    .build();

// 加载知识库文档
List<Document> documentList = FileSystemDocumentLoader.loadDocuments("/path/to/docs");

ingestor.ingest(documentList);              // 分割 → 向量化 → 入库
// 如果是单个文件
ingestor.ingest(document);
// 多个文件
ingestor.ingest(document1, document2, document3);

执行 ingest() 方法时,LangChain4j 内部会自动完成以下操作:

  1. 使用 DocumentSplitter 将文档切分成多个 TextSegment
  2. 调用 EmbeddingModel 为每个片段生成向量
  3. 调用 EmbeddingStore 将向量与对应的文本片段写入向量数据库

后续用户提问时,通过 ContentRetriever 根据问题向量进行相似度检索,并将检索到的相关片段返回给大模型。

2.2.6、检索查询(ContentRetriever)

对于最后的检索查询,由 ContentRetriever 完成,ContentRetriever 是 LangChain4j 中负责知识检索的核心组件,用于根据用户问题从知识库中检索相关内容。最常用的实现类是 EmbeddingStoreContentRetriever

ContentRetriever contentRetriever = EmbeddingStoreContentRetriever.builder()
        .embeddingModel(embeddingModel)  // 向量模型
        .embeddingStore(embeddingStore)  // 向量存储
        .maxResults(3)                   // 最多返回 3 个相关片段
        .minScore(0.6)                   // 相似度低于 0.6 的不要
        .build();

手动调用 ContentRetriever

Query query = Query.from("我的名字是什么?");
List<Content> contentList = contentRetriever.retrieve(query);
contentList.forEach(System.out::println);

当用户提问时,框架会先调用 contentRetriever.retrieve(query) 获取相关文档片段,然后将 ContentRetriever 检索到的内容通过 RAG 组件包装后加入 Prompt(通常放在 System Message 或 User Message 之前)。大模型就可以基于这些“外挂知识”生成回答。

但通常不会手动调用 ContentRetriever,更多时候直接注入到 AI Service 中:

@AiService(chatModel = "chatModel", contentRetriever = "contentRetriever", ...)
public interface Assistant {
    String chat(String message);
}

或者通过 AiServices.builder() 注入:

Assistant assistant = AiServices.builder(Assistant.class)
    .chatModel(chatModel)
    .contentRetriever(contentRetriever)
    .build();

三、ChatMemory和RAG整合

实现聊天记忆与记忆持久化的部分代码参考 1.2 与 1.3,不再赘述。

3.1、Maven 依赖

<dependency>
    <groupId>dev.langchain4j</groupId>
    <artifactId>langchain4j-easy-rag</artifactId>
</dependency>

<!-- Pinecone 向量数据库 -->
<dependency>
    <groupId>dev.langchain4j</groupId>
    <artifactId>langchain4j-pinecone</artifactId>
</dependency>

<!-- Qdrant 向量数据库 -->
<dependency>
    <groupId>dev.langchain4j</groupId>
    <artifactId>langchain4j-qdrant</artifactId>
</dependency>

3.2、配置类

在配置类 ModelConfig 中添加 ChatModelEmbeddingModelEmbeddingStoreEmbeddingStoreContentRetriever

@Configuration
public class ModelConfig {
    // ================================== ChatModel Bean ==================================
    @Bean("qwenChatModel")
    public QwenChatModel qwenChatModel() {
        return QwenChatModel.builder()
                .baseUrl("https://dashscope.aliyuncs.com/compatible-mode/v1")
                .apiKey(System.getenv("Key_AliBaiLian"))
                .modelName("qwen-max")
                .build();
    }

    // ================================== Memory Bean ==================================

    // MongoChatMemoryStore是之前自定义记忆持久化到 MongoDB 的实现类
    @Bean("chatMemoryMongoProvider")
    public ChatMemoryProvider chatMemoryMongoProvider(MongoChatMemoryStore mongoChatMemoryStore) {
        return memoryId -> MessageWindowChatMemory.builder()
                .id(memoryId)        // 在 ChatMemoryStore 中标识不同的会话
                .maxMessages(20)
                .chatMemoryStore(mongoChatMemoryStore)  // 聊天记忆的持久化方式实现类
                .build();
    }

    // ================================== EmbeddingModel Bean ==================================

    @Bean("textEmbeddingModel")
    public EmbeddingModel textEmbeddingModel() {
        return QwenEmbeddingModel.builder()
                .apiKey(System.getenv("Key_AliBaiLian"))
                .modelName("text-embedding-v3")
                .build();
    }

    // ================================== EmbeddingStore Bean ==================================

    /**
     * 基于Qdrant向量数据库的向量存储
     */
    @Bean("qdrantEmbeddingStore")
    public EmbeddingStore qdrantEmbeddingStore() {
        return QdrantEmbeddingStore.builder()
                .host("127.0.0.1")
                .port(6334)
                .collectionName("xiaozhi_collection")
                .build();
    }

    /**
     * 配置 ContentRetriever,使用 qdrantEmbeddingStore
     */
    @Bean("contentRetriever")
    ContentRetriever contentRetriever(@Qualifier("textEmbeddingModel") EmbeddingModel embeddingModel,
                                      @Qualifier("qdrantEmbeddingStore") EmbeddingStore embeddingStore) {
        return EmbeddingStoreContentRetriever.builder()
                .embeddingStore(embeddingStore)           // 指定要使用的嵌入存储
                .embeddingModel(embeddingModel)           // 设置用于生成嵌入向量的嵌入模型
                .maxResults(1)                            // 设置返回的最大数量为1
                .minScore(0.8)                            // 设置最小相似度
                .dynamicTopK(5)                           // 可添加动态TopK
                .dynamicMinScore(0.6)                     // 可添加动态阈值
                .build();
    }


}

3.3、将数据存入向量数据库

使用测试类手动加载文件中的数据,并分割、嵌入到向量数据库中(以 Qdrant 为例,使用其他向量数据库只需要更换注入的 EmbeddingStore 实例即可):

@Autowired
private EmbeddingModel textEmbeddingModel;

@Autowired
@Qualifier("qdrantEmbeddingStore")
private EmbeddingStore qdrantEmbeddingStore;

@Test
public void UploadKnowledgeLibraryTest() {
    // 使用FileSystemDocumentLoader读取指定目录下的文档,并且使用默认的文档解析器对文档进行解析
    String path1 = ".\path\resources\file1.md";
    Document document1 = FileSystemDocumentLoader.loadDocument(path1);
    String path2 = ".path\resources\file2.md";
    Document document2 = FileSystemDocumentLoader.loadDocument(path2);
    String path3 = ".\path\resources\file3.md";
    Document document3 = FileSystemDocumentLoader.loadDocument(path3);
    List<Document> documentList = Arrays.asList(document1, document2, document3);

    // 将文本向量存入向量数据库
    EmbeddingStoreIngestor.builder()
            .embeddingModel(textEmbeddingModel)
            .embeddingStore(qdrantEmbeddingStore)
            // 按照段落切分
            .documentSplitter(new DocumentByParagraphSplitter(300, 50, new HuggingFaceTokenCountEstimator()))
            .build()
            .ingest(documentList);
}

3.4、AI Service

配置好 ContentRetriever、向量数据库中嵌入数据之后,只需在 AI Service 中声明即可自动启用 RAG 能力。

方式一:@AiService 注解

@AiService(wiringMode = EXPLICIT, 
           chatModel = "qwenChatModel", 
           chatMemoryProvider = "chatMemoryMongoProvider",
           contentRetriever = "contentRetriever") // 注入检索器
public interface Assistant {
    String chat(@MemoryId String memoryId, String userMessage);
}

方式二:编程式构建

Assistant assistant = AiServices.builder(Assistant.class)
        .chatModel(qwenChatModel)
        .chatMemoryProvider(chatMemoryMongoProvider)
        .contentRetriever(contentRetriever)      // 绑定检索器
        .build();

参考资料:

官方英文文档:Get Started | LangChain4j,API 文档:Overview (LangChain4j)

非官方中文文档:快速开始 | LangChain4j 中文文档

1

评论 (0)

取消