ChatMemory + RAG 双剑合璧:让 AI 既记得你,又懂业务

0 阅读9分钟

前 5 篇我们把 ChatMemory 从内存玩到了 MySQL、Redis,又做了性能对比和分布式部署。

但有个评论一直在我心里:"会多轮对话,它就能当客服了吗?"

不能。因为多轮对话只是"记得你刚才说了啥",它并不知道你公司的业务规则——退货几天、运费多少、会员几折。这些信息在知识库里,不在模型脑子里。

所以这一篇,我们把 ChatMemory(记忆)RAG(检索增强) 组合起来。一个管"对话上下文",一个管"业务知识",合体之后,才是真正能用的 AI 助手。


一、ChatMemory 和 RAG,到底是什么关系?

很多人以为这是二选一,其实它们是互补的

维度ChatMemory(对话记忆)RAG(检索增强)
解决的问题"你上一轮说了什么""公司手册里写了什么"
数据来源本次会话的历史消息外部知识库(文档/FAQ/数据库)
生命周期会话级(可持久化)长期静态知识
类比短期记忆长期记忆 / 案头手册
Spring AI 组件MessageChatMemoryAdvisorQuestionAnswerAdvisor + VectorStore

一句话总结:

ChatMemory 让 AI"记得你",RAG 让 AI"懂业务"。 只上 ChatMemory:AI 记得聊天,但一问公司规定就胡说。 只上 RAG:AI 答得准,但每轮都像失忆,追问"那运费呢?"它就懵了。 两个一起上:既准又有连续性。


二、Spring AI 怎么做 RAG?

RAG 的核心链路:文档 → 切片 → 向量化(Embedding) → 存入向量库(VectorStore) → 用户提问时检索相似片段 → 拼进 Prompt

Spring AI 1.0 把"检索 + 拼 Prompt"封装成了一个开箱即用的 Advisor:QuestionAnswerAdvisor。我们只要准备两样东西:

  1. 一个 EmbeddingModel(把文本变成向量)
  2. 一个 VectorStore(存向量、做相似检索)

本项目一直用智谱 GLM 通过 OpenAI 兼容接口接入,所以 Embedding 直接复用同一个 starter,换一个 embedding-3 模型即可。

Step 1:加依赖

pom.xml:

<!-- RAG 向量库:SimpleVectorStore(内存版,仅演示/测试用) -->
<!-- 注意:Spring AI 1.0 没有 spring-ai-starter-vector-store-simple 这个 starter,
     SimpleVectorStore 在核心模块 spring-ai-vector-store 里,版本由 spring-ai-bom 管理 -->
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-vector-store</artifactId>
</dependency>

<!-- RAG Advisor:QuestionAnswerAdvisor 所在模块(独立模块,openai starter 不传递引入) -->
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-advisors-vector-store</artifactId>
</dependency>

⚠️ 重要:Spring AI 1.0 的 SimpleVectorStore纯内存实现,官方明确说明"仅用于测试/演示,不能上生产"。它启动就把知识库全部向量化加载到内存,重启即丢。生产请看第六节换成 pgvector / Redis / Chroma。

Step 2:配 Embedding

application.yml(在已有的 spring.ai.openai 下加一段):

spring:
  ai:
    openai:
      api-key: ${ZHIPU_API_KEY}
      base-url: https://open.bigmodel.cn/api/paas/v4
      # 嵌入模型:复用智谱 OpenAI 兼容接口(embedding-3),供 SimpleVectorStore 向量化知识库
      embedding:
        base-url: https://open.bigmodel.cn/api/paas/v4
        # 关键:Spring AI 默认 embedding 路径是 /v1/embeddings,智谱 endpoint 是 /embeddings(无 /v1 前缀),
        # 不覆盖会被拼成 .../v4/v1/embeddings → 404。chat 靠 completions-path 覆盖,这里同理。
        embeddings-path: /embeddings
        options:
          model: embedding-3
      chat:
        completions-path: /chat/completions
        options:
          model: glm-4.7-flash
          temperature: 0.7

加了这段,Spring AI 会自动配好一个 EmbeddingModel Bean(智谱 embedding-3),后面的 VectorStore 直接注入就能用。


三、先把知识库建起来

新建 RagConfig.java,用 SimpleVectorStore 承载知识库,启动时把几条客服 FAQ 向量化写进内存:

@Configuration
public class RagConfig {

    @Bean
    public VectorStore vectorStore(EmbeddingModel embeddingModel) {
        SimpleVectorStore store = SimpleVectorStore.builder(embeddingModel).build();
        // 启动时把知识库向量化并写入内存(演示用 5 条客服 FAQ)
        store.accept(knowledgeBase());
        return store;
    }

    /**
     * 演示知识库:公司客服 FAQ。
     * 真实项目可改为从数据库 / 文件 / 网页批量加载,并用 metadata 打标签便于过滤。
     */
    private List<Document> knowledgeBase() {
        return List.of(
            new Document("退货政策:商品签收后 7 天内,未拆封、不影响二次销售的可无理由退货。",
                    Map.of("category", "policy", "title", "退货政策")),
            new Document("运费规则:单笔订单满 99 元包邮,不满则收取 10 元运费。",
                    Map.of("category", "policy", "title", "运费规则")),
            new Document("会员等级:分为普通会员、银卡、金卡;金卡会员享全场 9 折优惠。",
                    Map.of("category", "member", "title", "会员等级")),
            new Document("发票说明:支持开具电子普通发票,下单时填写抬头,1 个工作日内开具。",
                    Map.of("category", "policy", "title", "发票说明")),
            new Document("客服时间:人工客服每日 9:00-21:00,智能助手 7x24 小时在线。",
                    Map.of("category", "service", "title", "客服时间"))
        );
    }
}

关键点:

  • Document 第二个参数是 metadata(键值对),后面可以用过滤表达式按 category 精准检索。
  • store.accept(docs) 会调用 EmbeddingModel 把每条文档变成向量存起来。
  • 真实项目里,这一步通常是"定时从数据库/文档站同步知识库",而不是写死 5 条。

四、纯 RAG:先单独跑通

新建 RagController.java,先来一个只有 RAG、没有记忆 的版本,方便对比:

@RestController
@RequestMapping("/rag")
public class RagController {

    private final ChatClient ragOnlyClient;   // 纯 RAG:只检索知识库

    public RagController(ChatClient.Builder builder, VectorStore vectorStore) {
        // 纯 RAG:只有 QuestionAnswerAdvisor,不带记忆
        this.ragOnlyClient = builder
            .defaultSystem("你是公司客服助手,只依据【知识库】内容回答,不要编造;知识库没有就如实说不知道。")
            .defaultAdvisors(QuestionAnswerAdvisor.builder(vectorStore)
                .searchRequest(SearchRequest.builder().topK(4).similarityThreshold(0.5).build())
                .build())
            .build();
    }

    /** 纯 RAG 问答:每轮独立,不知道上一轮聊了什么 */
    @GetMapping("/ask")
    public String ask(@RequestParam String question) {
        return ragOnlyClient.prompt().user(question).call().content();
    }
}

QuestionAnswerAdvisor.builder(vectorStore) 就是 RAG 的全部:

  • 用户提问 → Advisor 用同样的 EmbeddingModel 把问题向量化 → 去 VectorStore 里找最相似的 topK 条文档(相似度 ≥ similarityThreshold)
  • 把检索到的文档拼进 Prompt 的上下文位置 → 模型基于"知识库 + 问题"作答

测一下:

curl "http://localhost:8080/rag/ask?question=你们运费怎么算?"
# => 单笔订单满 99 元包邮,不满则收取 10 元运费。

效果不错!但如果你紧接着问**"那金卡会员呢?"**——它不知道你上一句在聊运费,容易答偏。

070901.png

070902.png

五、重点:ChatMemory + RAG 结合 ⭐

把上一篇的 MessageChatMemoryAdvisor 加进来,两个 Advisor 共存于同一个 ChatClient,记忆和检索一起生效:

@RestController
@RequestMapping("/rag")
public class RagController {

    private final ChatClient ragOnlyClient;   // 纯 RAG
    private final ChatClient ragChatClient;   // RAG + ChatMemory 结合
    private final ChatMemory chatMemory;

    public RagController(ChatClient.Builder builder, VectorStore vectorStore, ChatMemory chatMemory) {
        this.chatMemory = chatMemory;

        // 纯 RAG(同上,省略)
        this.ragOnlyClient = builder
            .defaultSystem("你是公司客服助手,只依据【知识库】内容回答,不要编造;知识库没有就如实说不知道。")
            .defaultAdvisors(QuestionAnswerAdvisor.builder(vectorStore)
                .searchRequest(SearchRequest.builder().topK(4).similarityThreshold(0.5).build())
                .build())
            .build();

        // 结合:RAG 提供外部知识 + ChatMemory 提供对话历史
        // 顺序:MessageChatMemoryAdvisor 在前(先把历史拼进 prompt),QuestionAnswerAdvisor 在后(再注入检索到的知识)
        this.ragChatClient = builder
            .defaultSystem("你是公司客服助手,结合【知识库】与【对话历史】回答用户问题。")
            .defaultAdvisors(
                MessageChatMemoryAdvisor.builder(chatMemory).build(),
                QuestionAnswerAdvisor.builder(vectorStore)
                    .searchRequest(SearchRequest.builder().topK(4).similarityThreshold(0.5).build())
                    .build())
            .build();
    }

    @GetMapping("/ask")
    public String ask(@RequestParam String question) {
        return ragOnlyClient.prompt().user(question).call().content();
    }

    /** RAG + ChatMemory 结合:既有知识库,又有多轮记忆 */
    @GetMapping("/chat")
    public String chat(@RequestParam String sessionId, @RequestParam String question) {
        return ragChatClient.prompt()
            .user(question)
            .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, sessionId))
            .call()
            .content();
    }
}

就这一行差别:.defaultAdvisors(MessageChatMemoryAdvisor..., QuestionAnswerAdvisor...)

Spring AI 的 Advisor 是责任链:

  1. 请求进来,MessageChatMemoryAdvisor 先把该会话的历史消息读出来,拼进 Prompt(让模型"记得前面聊了啥")
  2. 接着 QuestionAnswerAdvisor 检索知识库,把相关文档拼进上下文(让模型"知道业务规定")
  3. 模型拿到历史 + 知识 + 当前问题,给出连贯又准确的回答

测一下多轮:

# 第一轮
curl "http://localhost:8080/rag/chat?sessionId=u1&question=运费怎么算?"
# => 单笔订单满 99 元包邮,不满则收取 10 元运费。

# 第二轮(指代消解:"那金卡呢"依赖上一轮的"运费")
curl "http://localhost:8080/rag/chat?sessionId=u1&question=那金卡会员运费有优惠吗?"
# => 金卡会员享全场 9 折;运费规则本身不变(满 99 包邮,不满收 10 元),
#    但金卡折扣同样适用于运费部分。

看到了吗?第二轮它能正确理解"那金卡呢"指的是运费优惠,这就是 ChatMemory 的功劳;而"9 折""满 99 包邮"这些硬知识来自 RAG。两者缺一不可。

070903.png

070904.png

六、生产落地,这 5 个坑别踩

1. SimpleVectorStore 不能上生产

内存版重启即丢、无法多实例共享。换持久化向量库,代价极小——只要换依赖和 Bean:

// 生产推荐:pgvector(PostgreSQL 插件,SQL 友好、事务一致)
@Bean
public VectorStore vectorStore(EmbeddingModel embeddingModel, JdbcTemplate jdbcTemplate) {
    return PgVectorStore.builder(jdbcTemplate, embeddingModel)
            .dimensions(1024)           // 与 embedding-3 维度一致
            .build();
}

对应依赖:spring-ai-starter-vector-store-pgvector(Redis/Chroma 同理,换成对应 starter 即可)。Advisor 那层代码一行不用改,这就是面向接口编程的好处。

2. Advisor 顺序有讲究

记忆 Advisor 放前面、RAG Advisor 放后面是常规写法。顺序影响最终 Prompt 的拼装结构,建议记忆在前、知识在后,并在 system 里写清"先参考对话历史,再结合知识库"。

3. 注意 Token 预算

ChatMemory 的 maxMessages=20(前几篇配的)+ RAG 的 topK 文档,都会塞进上下文。两者叠加可能撑爆窗口、推高费用。经验值:

  • 对话类场景:maxMessages=10~20,topK=3~5
  • 知识密集场景:maxMessages 调小,topK 调大

4. 相似度阈值要调

similarityThreshold 太高(如 0.85)会漏召回,太低(如 0.3)会召回无关文档。中文 FAQ 场景一般 0.4~0.6 起步,用真实问题跑一遍校准。

5. 用 metadata 过滤,而不是什么都塞进去

QuestionAnswerAdvisor 支持运行时过滤表达式,按业务隔离知识:

// 只检索"政策类"文档
chatClient.prompt()
    .user(question)
    .advisors(a -> a.param(QuestionAnswerAdvisor.FILTER_EXPRESSION, "category == 'policy'"))
    .call()
    .content();

多租户、多业务线时,这个过滤能避免 A 客户的问题召回 B 客户的内部文档。


七、总结

核心结论:

  1. ChatMemory 和 RAG 不是二选一,而是标配组合——一个管"记得你",一个管"懂业务"。
  2. Spring AI 用 Advisor 把两者解耦:MessageChatMemoryAdvisor + QuestionAnswerAdvisor,一个 ChatClient 全搞定。
  3. 组合成本极低:加一个 Advisor 而已,无需改模型、无需改 Prompt 结构。
  4. SimpleVectorStore 仅演示,生产换 pgvector / Redis / Chroma,Advisor 层零改动

整体架构:

用户问题
   │
   ├─[MessageChatMemoryAdvisor]──> 读历史消息 ──┐
   │                                            ├─> 拼成完整 Prompt ─> GLM ─> 回答
   └─[QuestionAnswerAdvisor]──> 检索知识库 ─────┘

本篇代码清单:

  • pom.xml:新增 spring-ai-vector-store(核心模块,提供内存版 SimpleVectorStore)
  • application.yml:新增 spring.ai.openai.embedding(智谱 embedding-3)
  • RagConfig.java:VectorStore + 知识库加载
  • RagController.java:纯 RAG 接口 + RAG/ChatMemory 结合接口

下一篇预告:ChatMemory 监控与排查(怎么知道记忆丢了、RAG 没召回?)

写在最后

我是一名8 年 Java 后端,正在转型 AI 应用开发。Spring AI 系列会持续更新,从 hello world 到 RAG 到 Agent,一路踩坑一路写。

如果你也在转型 AI,关注我,一起走。有问题评论区聊,我会逐条回复。

如果这篇文章帮到了你,点个赞就是对我最大的鼓励 ❤️