1. 从零开始:为什么你需要一个“会思考”的智能问答系统?

你好,我是老张,一个在AI和智能硬件领域摸爬滚打了十多年的老码农。今天,我想和你聊聊一个特别有意思的话题:如何用Spring AI、Milvus和RAG技术,亲手搭建一个真正“懂你”的智能问答系统。你可能已经用过不少聊天机器人,但有没有遇到过这样的尴尬:你问了一个问题,它答得挺好;你接着追问一句“还有吗?”,它却一脸懵,完全忘了刚才聊了什么。或者,你提了一个又长又啰嗦的问题,它抓不住重点,给出的答案驴唇不对马嘴。

这些问题,本质上是因为系统缺乏上下文感知查询优化的能力。一个只会“一问一答”的机器人,就像一本不会翻页的字典,用起来总感觉差点意思。而我们今天要做的,就是给它装上“记忆”和“理解力”,让它能记住对话历史,还能把你的大白话自动“翻译”成精准的查询指令。听起来是不是很酷?别担心,整个过程就像搭积木,我会手把手带你走一遍,从环境搭建到代码实现,最后你也能拥有一个能进行多轮流畅对话、还能帮你优化问题的智能助手。

这个系统的核心是 RAG(检索增强生成)。简单来说,它结合了“大海捞针”和“妙笔生花”两个步骤。首先,系统会从你自己的知识库(比如公司文档、产品手册)里,快速找到和你问题最相关的几段信息(检索);然后,把这些信息作为“参考资料”交给大语言模型,让它组织成通顺、准确的回答(生成)。这比让模型凭空瞎编要靠谱得多。而我们要做的升级,就是在“检索”这一步下功夫:让系统能结合之前的聊天记录来理解你当前的问题(上下文感知),还能自动把你的口语化问题提炼成更精准的关键词(查询重写)。这样一来,检索到的资料更准,生成的答案自然也就更好了。

接下来,我们就从准备“施工场地”开始,一步步把这个智能系统搭建起来。我会分享我在搭建过程中踩过的坑和总结的经验,保证你跟着做,一次就能跑通。

2. 环境准备:搭建你的AI开发“工作台”

工欲善其事,必先利其器。在开始写代码之前,我们需要把运行环境准备好。这里我强烈推荐使用Docker,它能帮你把所有依赖的服务(比如数据库)打包成一个个独立的“集装箱”,一键启动,环境干净又隔离,再也不用为“在我机器上好好的”这种问题头疼了。

2.1 Docker与基础服务部署

首先,确保你的电脑上已经安装了Docker Desktop。去Docker官网下载对应你操作系统的版本(Windows、macOS都有),安装过程很简单,一路下一步就行。安装完成后,打开终端(Windows用PowerShell或CMD,macOS/Linux用Terminal),输入 docker --versiondocker compose version,如果能看到版本号,恭喜你,第一步成功了。

我们的系统需要两个核心的“后勤”服务:RedisMilvus

  • Redis: 我们将用它来存储对话历史。想象一下,每次你和机器人聊天,它都需要记住最近几句对话,才能理解“还有吗?”指的是什么。Redis这种内存数据库,读写速度极快,非常适合做这种临时的“记忆中枢”。
  • Milvus: 这是我们的“超级大脑图书馆”。所有你的知识文档(比如我后面会用的医院介绍文本),都会被转换成一种叫“向量”的数字形式,存进Milvus。当你提问时,你的问题也会被转换成向量,Milvus能瞬间从海量向量中找到最相似的那几段文本。它专为向量检索设计,速度比传统数据库快几个数量级。

部署Redis: 我们用一个简单的命令来拉取并运行Redis。在终端里执行:

docker run -d --name my-redis -p 6379:6379 redis:7.4.2

这个命令做了几件事:-d 表示后台运行,--name 给容器起个名字叫my-redis-p 6379:6379 把容器内的6379端口映射到你电脑的6379端口(这样你的Spring Boot程序才能连上它),最后指定使用redis:7.4.2这个镜像。执行后,用 docker ps 命令看看,如果 my-redis 的状态是 Up,就说明Redis已经欢快地跑起来了。

部署Milvus及其全家桶: Milvus本身运行还需要两个小伙伴:etcd(负责协调服务)和minio(负责存储数据)。手动配起来比较麻烦,好在官方提供了“一键安装包”——Docker Compose文件。 在你电脑上找个顺眼的地方,比如 C:\docker\milvus~/docker/milvus,新建一个文件叫 docker-compose.yml。把下面这段配置复制进去:

version: '3.5'
services:
  etcd:
    container_name: milvus-etcd
    image: quay.io/coreos/etcd:v3.5.16
    environment:
      - ETCD_AUTO_COMPACTION_MODE=revision
      - ETCD_AUTO_COMPACTION_RETENTION=1000
      - ETCD_QUOTA_BACKEND_BYTES=4294967296
    volumes:
      - ./volumes/etcd:/etcd
    command: etcd -advertise-client-urls=http://127.0.0.1:2379 -listen-client-urls=http://0.0.0.0:2379 --data-dir /etcd
  minio:
    container_name: milvus-minio
    image: minio/minio:RELEASE.2023-03-20T20-16-18Z
    environment:
      MINIO_ACCESS_KEY: minioadmin
      MINIO_SECRET_KEY: minioadmin
    ports:
      - "9001:9001"
      - "9000:9000"
    volumes:
      - ./volumes/minio:/minio_data
    command: minio server /minio_data --console-address ":9001"
  standalone:
    container_name: milvus-standalone
    image: milvusdb/milvus:v2.5.4
    command: ["milvus", "run", "standalone"]
    environment:
      ETCD_ENDPOINTS: etcd:2379
      MINIO_ADDRESS: minio:9000
    volumes:
      - ./volumes/milvus:/var/lib/milvus
    ports:
      - "19530:19530"
      - "9091:9091"
    depends_on:
      - "etcd"
      - "minio"
  attu:
    image: zilliz/attu:v2.5
    container_name: milvus-dashboard
    environment:
      MILVUS_URL: http://standalone:19530
    ports:
      - "8000:3000"
    depends_on:
      - standalone

保存文件后,在这个目录下打开终端,运行 docker compose up -d。Docker会开始拉取四个镜像并启动容器,第一次运行可能需要几分钟,喝杯咖啡等等。完成后,再用 docker ps 检查,应该能看到四个容器都在运行。

特别提一下最后一个服务 attu,这是Milvus的可视化管理界面。在浏览器里访问 http://localhost:8000,就能看到一个Web界面。第一次进入时,连接地址保持默认的 http://standalone:19530,直接点【Connect】。进去后,建议在用户管理里创建一个新用户,比如用户名 derek,密码 milvus-4321,角色选 admin。后面我们的Spring Boot程序会用到这个账号来连接Milvus。

2.2 Spring Boot项目初始化与核心依赖

环境搭好了,现在来创建我们的Spring Boot项目。我习惯用IntelliJ IDEA,你可以用任何你顺手的IDE。创建一个新的Spring Boot项目,选择Java 17或更高版本,打包方式用Maven。

项目创建好后,打开 pom.xml 文件,这里是管理所有“零件”(依赖)的地方。我们需要引入几个关键的依赖,我把核心部分贴出来,并解释每个是干嘛用的:

<properties>
    <java.version>17</java.version>
    <spring-boot.version>3.4.4</spring-boot.version>
    <spring-ai.version>1.0.0-M6</spring-ai.version>
    <spring-alibaba.version>1.0.0-M6.1</spring-alibaba.version>
</properties>

<dependencies>
    <!-- Spring Boot Web 核心 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
        <version>${spring-boot.version}</version>
    </dependency>
    <!-- Spring Data Redis 用于对话记忆 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
        <version>${spring-boot.version}</version>
    </dependency>
    <!-- Spring AI 与 Milvus 集成的向量存储 -->
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-milvus-store-spring-boot-starter</artifactId>
    </dependency>
    <!-- Spring AI 与阿里云百炼平台集成 -->
    <dependency>
        <groupId>com.alibaba.cloud.ai</groupId>
        <artifactId>spring-ai-alibaba-starter</artifactId>
        <version>${spring-alibaba.version}</version>
    </dependency>
    <!-- 工具类 -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
</dependencies>

<dependencyManagement>
    <dependencies>
        <!-- 统一管理 Spring AI 版本 -->
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-bom</artifactId>
            <version>${spring-ai.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

这里有几个关键点:

  1. spring-ai-milvus-store-spring-boot-starter: 这是Spring AI官方提供的Milvus集成包,有了它,我们就能用几行配置轻松操作Milvus向量数据库,不用自己写复杂的客户端代码。
  2. spring-ai-alibaba-starter: 这是阿里云提供的Spring AI适配器。我们需要一个大语言模型来生成回答,也需要一个嵌入模型来把文本变成向量。阿里云百炼平台提供了稳定、高效的API,这个依赖包就是连接我们代码和百炼API的桥梁。
  3. 版本管理: 注意 spring-ai.versionspring-alibaba.version,Spring AI生态还在快速迭代,用我上面写的这两个版本组合是亲测可用的,能避免很多兼容性坑。

依赖加好后,别忘了配置一下Maven仓库,因为有些包可能在默认的中央仓库没有。在 pom.xml<repositories> 部分,确保添加阿里云仓库和Spring的里程碑仓库,具体配置可以参考我后面给出的完整代码。

3. 核心配置与基础功能实现

环境和服务都就绪了,现在我们来给Spring Boot项目注入“灵魂”——配置文件和基础功能代码。这部分就像给机器人接通电源和设定初始程序。

3.1 应用配置详解

首先,打开 src/main/resources/application.yml 文件,这是整个应用的“控制中心”。我们把所有需要连接的外部服务信息都写在这里:

server:
  port: 8080

spring:
  application:
    name: smart-qa-system
  data:
    redis:
      host: localhost
      port: 6379
      # 如果你之前给Redis设了密码,这里要填上
      # password: yourpassword
      database: 0
  ai:
    dashscope:
      # 这里是关键!去阿里云百炼平台申请一个API-KEY填进来
      api-key: sk-你的真实API-KEY
      # 我们使用DeepSeek-R1模型进行对话生成,它的推理和指令跟随能力很强
      model: deepseek-r1
      embedding:
        options:
          # 使用百炼的text-embedding-v2模型将文本转换为向量
          model: text-embedding-v2
    vectorstore:
      milvus:
        client:
          host: "localhost"
          port: 19530
          # 这里填写你在Attu界面创建的用户名和密码
          username: "derek"
          password: "milvus-4321"
          databaseName: "default"
          # 向量数据存储的集合(类似数据库的表)名称
          collectionName: "hospital_knowledge_base"
          # 嵌入向量的维度,text-embedding-v2模型生成的是1536维向量
          embeddingDimension: 1536
          indexType: IVF_FLAT
          metricType: COSINE
          # 启动时自动创建集合结构,第一次运行设为true
          initialize-schema: true

我来划几个重点:

  • spring.ai.dashscope.api-key: 这是项目的“钥匙”,没有它就无法调用AI模型。你需要注册阿里云账号,开通百炼平台服务,然后创建一个API-KEY。记得保护好它,不要上传到公开的代码仓库。
  • spring.ai.dashscope.model: 这里我选了 deepseek-r1,你也可以根据百炼平台提供的模型列表换成其他,比如 qwq-plus。不同模型在效果和速度上略有差异,deepseek-r1在逻辑推理和长文本理解上表现不错。
  • embeddingDimension: 1536: 这个数字必须和你使用的嵌入模型匹配。text-embedding-v2 模型输出的向量就是1536维,填错了会导致向量存储和检索失败。
  • initialize-schema: true: 第一次运行时务必设为 true,Spring AI会自动在Milvus里创建好集合(Collection)和索引。之后运行可以改为 false 以加快启动速度。

3.2 构建知识库:把文档“喂”给系统

一个空的问答系统是没用的,我们必须先给它“灌输”知识。假设我们有一份关于“不爱学习康复医院”的详细介绍文档(demoHospital.txt),里面包含了医院概况、科室和医生信息。我们需要把这些文本“喂”进Milvus向量数据库。

这个过程分为三步:读取、切分、入库。我写了一个简单的数据加载接口来实现:

@Slf4j
@RestController
@RequestMapping("/api/v1")
public class KnowledgeBaseController {

    @Autowired
    private VectorStore vectorStore; // Spring AI自动注入的向量存储操作接口

    @GetMapping("/load-data")
    public String loadData() throws IOException {
        // 1. 读取文档
        Resource resource = new ClassPathResource("static/demoHospital.txt");
        DocumentReader reader = new TikaDocumentReader(resource.getFile());
        List<Document> documents = reader.get();

        // 2. 文本切分(这是关键!)
        TextSplitter splitter = new TokenTextSplitter(
            500, // 每个文本块的最大token数
            100, // 相邻文本块之间的重叠token数,防止上下文断裂
            20,  // 最大块数限制(可选)
            200, // 每个块的最小token数(可选)
            true // 是否详细记录切分过程
        );
        List<Document> splitDocs = splitter.apply(documents);
        log.info("原始文档已切分为 {} 个文本块", splitDocs.size());

        // 3. 向量化并存入Milvus
        vectorStore.add(splitDocs);
        log.info("知识库数据已成功写入向量数据库。");

        return "数据加载成功!共处理 " + splitDocs.size() + " 个文本块。";
    }
}

这里最核心的是文本切分。你不能把一整篇几十页的文档直接扔给模型,那样效率低,而且检索时容易抓不到重点。TokenTextSplitter 会按语义和长度,把长文档切成一个个小片段(比如每段500个token左右),并且让相邻片段有少量重叠(比如100个token),这样能保证一个完整的句子或概念不会被生硬地切断。切分的好坏,直接决定了后续检索的精度。

启动你的Spring Boot应用,然后在浏览器或Postman里访问 http://localhost:8080/api/v1/load-data。如果看到“数据加载成功”的返回,并且日志没有报错,就可以打开Attu界面(http://localhost:8000),在 Collections 页面应该能看到一个名为 hospital_knowledge_base 的集合,并且里面有数据了。这意味着你的知识库已经准备就绪!

3.3 实现基础对话与记忆功能

有了知识库,我们现在来实现最基础的问答功能,并给它加上“记忆”。我们创建一个 ChatService 来封装核心逻辑:

@Service
public class ChatService {
    @Autowired
    private ChatClient chatClient; // 用于调用大模型
    @Autowired
    private VectorStore vectorStore; // 用于检索知识库
    @Autowired
    private ChatMemory chatMemory; // 用于存储对话历史

    private static final int HISTORY_SIZE = 5; // 记住最近5轮对话

    public String chat(String userId, String userMessage) {
        // 1. 获取该用户最近的对话历史
        List<Message> historyMessages = chatMemory.get(userId, HISTORY_SIZE);

        // 2. 从知识库中检索与当前问题相关的信息
        SearchRequest request = SearchRequest.query(userMessage)
                .withTopK(5) // 返回最相关的5个片段
                .withSimilarityThreshold(0.7); // 相似度阈值,过滤掉太不相关的
        List<Document> relevantDocs = vectorStore.similaritySearch(request);

        // 3. 构建提示词(Prompt),将历史、检索结果和当前问题组合起来
        String context = relevantDocs.stream()
                .map(Doc::getContent)
                .collect(Collectors.joining("\n\n"));

        Prompt prompt = new Prompt(
                new SystemMessage("你是一个专业的医疗咨询助手,请根据以下已知信息回答问题。如果信息不足,请如实告知。\n已知信息:\n" + context),
                historyMessages, // 注入历史对话
                new UserMessage(userMessage) // 当前用户问题
        );

        // 4. 调用大模型生成回答
        ChatResponse response = chatClient.call(prompt);
        String assistantReply = response.getResult().getOutput().getContent();

        // 5. 将本轮对话(用户问题+助手回答)存入记忆
        List<Message> currentTurn = List.of(
                new UserMessage(userMessage),
                new AssistantMessage(assistantReply)
        );
        chatMemory.add(userId, currentTurn);

        return assistantReply;
    }
}

这段代码体现了RAG的核心流程。ChatMemory 是一个接口,我们需要实现它。这里我用Redis来实现:

@Component
public class RedisChatMemory implements ChatMemory {
    private final RedisTemplate<String, Object> redisTemplate;
    private static final String KEY_PREFIX = "chat:history:";

    @Override
    public void add(String conversationId, List<Message> messages) {
        String key = KEY_PREFIX + conversationId;
        // 将Message列表序列化后存入Redis列表
        redisTemplate.opsForList().rightPushAll(key, messages.toArray());
        // 设置过期时间,例如30分钟,避免内存无限增长
        redisTemplate.expire(key, 30, TimeUnit.MINUTES);
    }

    @Override
    public List<Message> get(String conversationId, int lastN) {
        String key = KEY_PREFIX + conversationId;
        Long size = redisTemplate.opsForList().size(key);
        if (size == null || size == 0) {
            return Collections.emptyList();
        }
        // 获取列表最后lastN条记录
        int start = Math.max(0, (int)(size - lastN));
        List<Object> stored = redisTemplate.opsForList().range(key, start, -1);
        // 反序列化回Message对象
        return stored.stream()
                .map(obj -> (Message) obj)
                .collect(Collectors.toList());
    }
}

这样,一个具备基础记忆能力的问答系统就完成了。你可以创建一个简单的REST控制器来暴露 /chat 接口,传入 userIdmessage 进行测试。同一个 userId 的多次对话,系统就能记住上下文了。

4. 高级功能实战:上下文感知与查询重写

基础功能跑通后,我们来点更高级的。这也是让我们的系统从“还行”变得“好用”的关键。

4.1 上下文感知查询:让机器人“记住”刚才聊了什么

在多轮对话中,用户的问题往往是简略的、有上下文的。比如:

  • 用户第一问:“不爱学习康复医院的副主任医生都有谁?”
  • 系统回答:(列出张三、李四等副主任医师)
  • 用户第二问:“还有吗?”

如果没有上下文感知,系统会把“还有吗?”当成一个独立的新问题去知识库检索,很可能返回一些无关信息,或者直接说“我不明白你的问题”。上下文感知查询,就是要让系统能结合历史对话,把“还有吗?”自动补全成“不爱学习康复医院除了刚才提到的张三、李四, 还有哪些副主任医生?”

Spring AI 提供了一个非常方便的工具:CompressionQueryTransformer。看代码:

@GetMapping("/context-aware-query")
public String contextAwareQuery(@RequestParam String userId, @RequestParam String currentQuery) {
    // 1. 获取对话历史
    List<Message> history = chatMemory.get(userId, 5);

    // 2. 构建一个包含历史的查询对象
    Query query = Query.builder()
            .text(currentQuery) // 用户当前的问题,如“还有吗?”
            .history(history)   // 注入历史对话
            .build();

    // 3. 使用上下文感知查询转换器
    CompressionQueryTransformer transformer = CompressionQueryTransformer.builder()
            .chatClientBuilder(chatClient.mutate())
            .build();

    // 4. 转换!得到融合了上下文的新查询
    Query enhancedQuery = transformer.transform(query);
    String finalQueryText = enhancedQuery.text();

    log.info("原始查询:'{}' -> 上下文感知后查询:'{}'", currentQuery, finalQueryText);
    return finalQueryText;
}

这个接口返回的不是最终答案,而是经过“理解”后的新查询语句。你可以把这个新查询语句,再交给之前的基础 chat 方法去知识库检索并生成答案。在实际项目中,你可以把第3、4步集成到 ChatService.chat() 方法内部,对用户输入的问题先做一次上下文增强,再进行检索,实现无缝的体验。

我实测下来,这个功能对提升多轮对话的连贯性帮助巨大。它本质上是用大模型对当前问题和历史记录做了一次“阅读理解”,然后生成一个更完整、更独立的查询语句。

4.2 查询重写:把口语化问题变成“标准提问”

另一个常见问题是,用户的提问方式千奇百怪,非常口语化、冗长。比如:“我想去北京,坐高铁过去,北京有什么景点比较好玩,还得便宜”。直接拿这句话去向量库做相似度匹配,效果可能不好,因为向量匹配对措辞比较敏感。

查询重写(Query Rewrite)就是来解决这个问题的。它的目标是把用户啰嗦、不规范的提问,改写成简洁、清晰、关键词明确的“标准问法”。例如,把上面那句重写成:“北京性价比高的旅游景点推荐”。

Spring AI 同样提供了现成的 RewriteQueryTransformer

@GetMapping("/query-rewrite")
public String rewriteQuery(@RequestParam String userQuery) {
    RewriteQueryTransformer rewriter = RewriteQueryTransformer.builder()
            .chatClientBuilder(chatClient.mutate())
            .build();

    Query originalQuery = new Query(userQuery);
    Query rewrittenQuery = rewriter.transform(originalQuery);

    log.info("查询重写:'{}' -> '{}'", userQuery, rewrittenQuery.text());
    return rewrittenQuery.text();
}

这个转换器内部也是调用大模型,给它一个指令,比如“请将以下用户查询改写为更简洁、更适合信息检索的句子,保留核心意图”,然后让它对原查询进行改写。改写后的查询,再用于向量检索,命中率会显著提升。

把两者结合起来,就是一个强大的查询预处理流水线:

public String smartChat(String userId, String rawUserInput) {
    // 步骤1:查询重写(优化表达)
    String rewrittenQuery = queryRewriteService.rewrite(rawUserInput);

    // 步骤2:上下文感知(补充信息)
    List<Message> history = chatMemory.get(userId, 5);
    String finalQuery = contextAwareService.enhance(rewrittenQuery, history);

    // 步骤3:用优化后的查询进行RAG检索与生成
    return chatService.ragChat(userId, finalQuery);
}

这个流程模拟了人类助理的思考过程:先听清你说什么(重写),再结合之前的聊天背景理解你的真实意图(上下文感知),最后再去资料库(向量库)里找答案并组织语言回复你。

4.3 效果对比与参数调优

理论说再多,不如看实际效果。我们用一个简单的测试来对比:

用户输入处理方式系统内部使用的查询语句可能的结果差异
“还有吗?”基础RAG(无上下文)“还有吗?”检索结果混乱,可能返回无关信息。
“还有吗?”上下文感知RAG“不爱学习康复医院除了已提到的,还有哪些副主任医师?”能准确检索出其他副主任医师信息。
“我想去北京,坐高铁过去,北京有什么景点比较好玩,还得便宜”基础RAG原句直接检索可能因表述冗长,相似度匹配不佳。
“我想去北京,坐高铁过去,北京有什么景点比较好玩,还得便宜”查询重写RAG“北京性价比高的旅游景点推荐”检索更精准,更易找到相关攻略。

参数调优心得

  1. 文本切分参数TokenTextSplitterchunkSize(块大小)和 overlap(重叠长度)需要根据你的文档类型调整。对于技术文档,chunkSize=500 可能不错;对于对话记录,chunkSize=300 可能更合适。overlap 一般设为 chunkSize 的 1/5 到 1/4,确保上下文连贯。
  2. 检索参数SearchRequesttopK(返回数量)和 similarityThreshold(相似度阈值)需要平衡。topK 太小可能漏掉相关信息,太大则会给模型输入过多噪音。我一般从 topK=5 开始调。similarityThreshold 可以过滤掉低质量片段,初期可以设低点(如0.1),观察效果后再调整。
  3. 对话记忆长度HISTORY_SIZE 不是越大越好。太长的历史可能会引入无关信息,干扰当前问题的理解。通常记住最近3-5轮对话已经足够。对于超长对话,可以考虑更复杂的记忆压缩或总结策略。

5. 系统集成、测试与踩坑指南

所有零件都准备好了,现在我们把它们组装起来,并看看实际运行效果。

5.1 完整代码结构与集成

我建议的项目结构如下,清晰易懂:

src/main/java/com/example/smartqa/
├── SmartQaApplication.java          // 启动类
├── config/
│   ├── RedisConfig.java            // Redis配置
│   └── ChatClientConfig.java       // ChatClient与向量存储Bean配置
├── controller/
│   ├── KnowledgeBaseController.java // 数据加载
│   ├── ChatController.java          // 基础对话接口
│   └── AdvancedQueryController.java // 高级查询(上下文、重写)接口
├── service/
│   ├── ChatService.java            // 核心对话服务
│   ├── ContextAwareService.java    // 上下文感知服务
│   └── QueryRewriteService.java    // 查询重写服务
├── memory/
│   └── RedisChatMemory.java        // 基于Redis的对话记忆实现
└── entity/
    └── ChatMessage.java            // 对话消息实体

ChatClientConfig 中,我们将所有Bean组装起来:

@Configuration
public class ChatClientConfig {
    @Autowired
    private VectorStore vectorStore;
    @Autowired
    private ChatModel chatModel; // 由application.yml配置自动注入

    @Bean
    public ChatClient chatClient(ChatMemory chatMemory) {
        return ChatClient.builder(chatModel)
                .defaultSystem("你是一个专业、友好的助手。请根据提供的上下文信息回答问题。如果信息不足,请礼貌地告知用户。回答时请条理清晰。")
                .defaultAdvisors(
                        new QuestionAnswerAdvisor(
                                vectorStore,
                                SearchRequest.builder()
                                        .topK(5)
                                        .similarityThreshold(0.7)
                                        .build()
                        )
                )
                .build();
    }

    @Bean
    public ChatMemory chatMemory(RedisTemplate<String, Object> redisTemplate) {
        return new RedisChatMemory(redisTemplate);
    }
}

这个配置类是整个应用的核心粘合剂。它创建了 ChatClient,并为其装配了两个“顾问”:一个是系统指令,定义了AI的“人设”;另一个是 QuestionAnswerAdvisor,它会在每次对话前,自动使用配置好的 vectorStore 和检索参数去知识库查找资料,并将结果作为上下文提供给大模型。这样,我们在业务代码里只需要关注对话逻辑本身,检索增强的过程被自动完成了。

5.2 全流程测试验证

启动你的Spring Boot应用(确保Redis和Milvus都在运行),然后我们按顺序测试:

  1. 加载知识库:访问 GET http://localhost:8080/api/v1/load-data。看到成功提示后,去Attu检查 hospital_knowledge_base 集合是否有数据。
  2. 测试基础对话:访问 GET http://localhost:8080/api/v1/chat?userId=user123&message=不爱学习康复医院的副主任医生都有谁?。系统应该能从我们“喂”进去的文档中,找到并列出副主任医师的信息。
  3. 测试上下文感知:先调用一次上面的基础对话。然后调用 GET http://localhost:8080/api/v1/context-aware?userId=user123&currentQuery=还有吗?。观察返回的查询语句是否变成了一个包含上下文(比如医院名称、医生职称)的完整问题。
  4. 测试查询重写:访问 GET http://localhost:8080/api/v1/rewrite?query=我想去北京,坐高铁过去,北京有什么景点比较好玩,还得便宜。看看返回的是不是一句简洁的“北京性价比高景点推荐”之类的句子。

你可以把第3步得到的“增强后查询”,直接作为 message 参数,再调用第2步的基础对话接口,看看返回的答案是否更精准、连贯。

5.3 常见问题与排查心得

这条路我踩过不少坑,这里分享几个最常见的:

  • Milvus连接失败:最常见的原因是Attu里创建的用户名密码,和 application.yml 里配置的不一致。或者Milvus服务没完全启动好。多等一会儿,用 docker logs milvus-standalone 看看容器日志。
  • 向量维度不匹配:报错信息可能提到 dimension mismatch。一定要确认 embeddingDimension: 1536 和你使用的嵌入模型(text-embedding-v2)输出维度一致。不同模型维度可能不同。
  • API-KEY无效或额度不足:调用阿里云百炼API失败。去百炼平台控制台检查API-KEY是否启用,以及是否有足够的调用额度。
  • 检索结果不相关:首先检查知识库数据是否成功加载(Attu里看)。然后调整文本切分参数,chunkSize 可能太大了,把完整的语义切碎了。也可以尝试调低 similarityThreshold,或者增加 topK
  • 对话历史不生效:检查Redis是否正常运行,以及 RedisTemplate 的序列化配置是否正确。确保 ChatMemory 接口的 addget 方法被正确调用。

最后,这个项目只是一个起点。你可以在此基础上做很多扩展,比如:

  • 支持多格式文档:使用 TikaDocumentReader 可以支持PDF、Word、PPT等格式,让知识库来源更丰富。
  • 增加来源引用:在返回答案时,附带引用的原文片段,增加可信度。
  • 实现流式输出:对于长答案,使用Spring AI的流式响应,让答案一个字一个字地“打”出来,体验更好。
  • 接入前端界面:用Vue或React写个简单的聊天界面,打造完整的应用。

希望这篇详细的实战指南能帮你打通Spring AI、Milvus和RAG的任督二脉。这套组合拳威力不小,无论是做企业知识库、智能客服还是个人学习助手,都能派上用场。编程最快乐的事,不就是看着自己搭建的系统,真正地“理解”和“回答”问题吗?动手试试吧,遇到问题随时来交流。

更多推荐