告别大模型幻觉!基于 Spring AI Alibaba 的RAG 运维问答实现

前言

随着大语言模型(LLM)的广泛应用,如何让模型"知道"企业内部知识成为一个关键问题。RAG(Retrieval-Augmented Generation)技术通过将检索与生成相结合,让模型能够基于外部知识库回答问题,有效解决了大模型知识滞后和幻觉的问题。

本文将手把手教你使用 Spring AI AlibabaRedis Stack 向量数据库 构建一个智能运维故障解答系统。系统能够根据故障编码,自动检索运维文档并给出对应的故障解释。

技术栈:Spring Boot 3.x + Spring AI Alibaba + DashScope 灵积模型服务 + Redis Stack 向量存储


一、项目背景与需求

假设我们有一份运维故障编码说明文档 ops.txt,内容大致如下:

复制代码
00000 系统正常
C2222 CPU温度过高
A1001 磁盘空间不足
...

我们要实现一个接口,用户输入故障编码(如 C2222),系统能够从文档中检索到对应说明,并通过大模型整理成自然语言回复。

二、环境准备与版本适配

1. Redis Stack 安装

Redis Stack 提供了向量检索能力,可以直接使用 Docker 快速启动:

复制代码
docker run -d --name redis-stack -p 6379:6379 -p 8001:8001 redis/redis-stack:latest

2. 开通阿里云灵积模型服务

阿里云百炼平台 开通 DashScope 服务,获取 API Key,并确保账户有 deepseek-r1deepseek-v3qwen-plustext-embedding-v3 等模型的调用权限。

3. 重要版本适配说明

  • 必须使用 Redis Stack :普通 Redis 不支持向量检索,请确认运行的是 redis-stackredis-stack-server 镜像。
  • 嵌入模型固定使用 text-embedding-v3:保证向量维度与后续检索匹配,不可随意切换,否则会导致维度不一致。
  • 多模型共存时必须指定 Bean 名称 :项目中同时配置了 DeepSeek 和 Qwen 模型,通过 @Bean(name = "xxx") 配合 @Qualifier 明确注入,避免启动时因类型冲突报错。

三、项目依赖与配置

1. Maven 依赖

复制代码
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <!-- Spring AI Alibaba DashScope 支持 -->
    <dependency>
        <groupId>com.alibaba.cloud.ai</groupId>
        <artifactId>spring-ai-alibaba-starter-dashscope</artifactId>
    </dependency>
    <!-- Redis 向量数据库支持 -->
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-starter-vector-store-redis</artifactId>
    </dependency>
</dependencies>

2. application.properties 配置

复制代码
server.port=8012

# 全局编码
server.servlet.encoding.enabled=true
server.servlet.encoding.force=true
server.servlet.encoding.charset=UTF-8

spring.application.name=SAA-12RAG4AiDatabase

# Spring AI Alibaba 配置
spring.ai.dashscope.api-key=${aliQwen-api}
spring.ai.dashscope.chat.options.model=deepseek-r1
spring.ai.dashscope.embedding.options.model=text-embedding-v3

# Redis Stack 配置
spring.data.redis.host=localhost
spring.data.redis.port=6379
spring.data.redis.username=default
spring.data.redis.password=
spring.ai.vectorstore.redis.initialize-schema=true
spring.ai.vectorstore.redis.index-name=atguigu-index
spring.ai.vectorstore.redis.prefix=atguigu-prefix

注意:${aliQwen-api} 需要在环境变量中设置,避免密钥泄漏。

四、核心配置类详解

1. 多模型共存配置 -- SaaLLMConfig

在实际项目中,我们可能需要同时接入多个模型(如 DeepSeek 和 Qwen),Spring AI 支持通过 @Qualifier 区分不同的 ChatModelChatClient

复制代码
@Configuration
public class SaaLLMConfig {
    private final String DEEPSEEK_MODEL = "deepseek-v3";
    private final String QWEN_MODEL = "qwen-plus";

    @Bean(name = "deepseek")
    public ChatModel deepSeek() {
        return DashScopeChatModel.builder()
                .dashScopeApi(DashScopeApi.builder()
                        .apiKey(System.getenv("aliQwen-api"))
                        .build())
                .defaultOptions(DashScopeChatOptions.builder()
                        .withModel(DEEPSEEK_MODEL)
                        .build())
                .build();
    }

    @Bean(name = "qwen")
    public ChatModel qwen() {
        return DashScopeChatModel.builder()
                .dashScopeApi(DashScopeApi.builder()
                        .apiKey(System.getenv("aliQwen-api"))
                        .build())
                .defaultOptions(DashScopeChatOptions.builder()
                        .withModel(QWEN_MODEL)
                        .build())
                .build();
    }

    @Bean(name = "deepseekChatClient")
    public ChatClient deepseekChatClient(@Qualifier("deepseek") ChatModel deepSeek) {
        return ChatClient.builder(deepSeek).build();
    }

    @Bean(name = "qwenChatClient")
    public ChatClient qwenChatClient(@Qualifier("qwen") ChatModel qwen) {
        return ChatClient.builder(qwen).build();
    }
}

2. Redis 序列化配置 -- RedisConfig

为避免 Redis 中出现乱码,我们需要自定义 RedisTemplate 的序列化方式。

复制代码
@Configuration
public class RedisConfig {
    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(factory);
        // key 使用 String 序列化
        template.setKeySerializer(new StringRedisSerializer());
        // value 使用 JSON 序列化
        template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
        template.setHashKeySerializer(new StringRedisSerializer());
        template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
        template.afterPropertiesSet();
        return template;
    }
}

五、文档向量化与初始化存储

为什么会出现向量重复入库?

Spring AI 默认的 VectorStore 并没有幂等机制。如果你直接将文档切片后的 List<Document> 调用 vectorStore.add(),那么每次项目重启时都会重新读取文件、重新生成向量、再次插入数据库,导致:

  • 向量库数据冗余,存储膨胀;
  • 检索时召回多条完全相同或高度重复的片段,干扰大模型判断;
  • 结果重复错乱,回答质量下降。

本文的解决方案

  • 基于文件源 MD5 校验:精准定位同一知识库文件,无论重启多少次,只要源文件未变,就不会重复写入。
  • 利用 Redis 原子 SETNX 命令:在分布式、单机环境下均能保证初始化操作的幂等性。
  • 无需手动清空向量库:支持项目反复重启,自动跳过已导入的数据。
  • 轻量无侵入:不改变原有 RAG 检索逻辑,只在初始化环节增加防重判断。

具体实现如 InitVectorDatabaseConfig 所示:

复制代码
@Configuration
public class InitVectorDatabaseConfig {
    @Autowired
    private VectorStore vectorStore;
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    @Value("classpath:ops.txt")
    private Resource opsFile;

    @PostConstruct
    public void init() {
        // 1. 读取文件
        TextReader textReader = new TextReader(opsFile);
        textReader.setCharset(Charset.defaultCharset());

        // 2. 分割文档为段落并转为 Document 列表
        List<Document> documents = new TokenTextSplitter().transform(textReader.read());

        // 3. 生成唯一标识(基于文件路径的 MD5)
        String source = (String) textReader.getCustomMetadata().get("source");
        String textHash = SecureUtil.md5(source);
        String redisKey = "vector-xxx:" + textHash;

        // 4. 使用 Redis setIfAbsent 实现初始化防重
        Boolean firstTime = redisTemplate.opsForValue().setIfAbsent(redisKey, "1");
        if (Boolean.TRUE.equals(firstTime)) {
            vectorStore.add(documents);
            System.out.println("向量数据初始化完成");
        } else {
            System.out.println("向量数据已存在,跳过初始化");
        }
    }
}

核心防重流程

  1. 使用 TextReader 获取源文件元数据中的路径(source)。
  2. 对 source 进行 MD5 哈希,生成固定长度的唯一标识。
  3. 拼接 Redis Key vector-xxx:md5值
  4. 调用 setIfAbsent 尝试写入,若返回 true 表示键不存在,执行向量写入;返回 false 表示已写入过,直接跳过。

六、RAG 检索增强接口实现与原理解析

6.1 接口实现代码

Controller 层使用 ChatClientRetrievalAugmentationAdvisor 实现 RAG 问答。

复制代码
@RestController
public class RagController {
    @Resource(name = "qwenChatClient")
    private ChatClient chatClient;
    @Resource
    private VectorStore vectorStore;

    @GetMapping("/rag4aiops")
    public Flux<String> rag(String msg) {
        String systemInfo = """
                你是一个运维工程师,按照给出的编码给出对应故障解释,否则回复找不到信息。
                """;

        // 构建检索增强顾问:指定向量数据库检索器
        RetrievalAugmentationAdvisor advisor = RetrievalAugmentationAdvisor.builder()
                .documentRetriever(
                        VectorStoreDocumentRetriever.builder()
                                .vectorStore(vectorStore)
                                .build()
                )
                .build();

        return chatClient.prompt()
                .system(systemInfo)
                .user(msg)
                .advisors(advisor)       // 注入 RAG 顾问
                .stream()
                .content();
    }
}

流程说明

  1. 用户传入故障编码 msg
  2. RetrievalAugmentationAdvisor 调用 VectorStoreDocumentRetriever 从 Redis 向量库中检索与 msg 语义最相似的文档片段。
  3. 将检索到的文档片段作为上下文,与 system prompt 和 user message 一起发送给 Qwen 模型。
  4. 模型根据上下文生成自然语言回答,并以流式方式返回。

6.2 为什么检索不到时会输出"找不到信息"?

在测试接口时,如果输入的知识库中不存在的编码(例如 XYZ),你会发现模型会直接返回"找不到对应信息",而不是胡编乱造。这背后是一整套 严格 RAG 机制在起作用。

完整 RAG 流程回顾

整个 RAG(检索增强生成)链路如下:

  1. 用户提问:例如问"XYZ 是什么故障"。
  2. 向量检索:把问题文本向量化,去 Redis 向量库做相似度搜索。
  3. 上下文组装:将检索回来的文档片段与原始问题一起拼入 Prompt。
  4. 大模型回答:模型只被允许基于 Prompt 中的文档片段作答。
产生"找不到信息"的三大原因
① Prompt 强约束(最核心)

RAG 系统会在 System Prompt 中明确限制模型的行为,典型示例:

复制代码
你只能使用【参考文档】里面提供的内容回答用户问题。
如果参考文档为空,或者没有和问题相关的信息,请直接回复:找不到对应信息,不要编造、不要使用自己的固有知识。

【参考文档】
{retrieve_context}

用户问题:{user_query}

当向量库检索不到任何内容时,{retrieve_context} 就会被替换为空字符串。大模型读到"参考文档为空",就会严格遵守指令,输出"找不到对应信息",禁止调用模型自身的训练知识

很多新手会困惑:大模型明明知道 "XYZ" 可能代表什么,为什么不回答?------就是因为 Prompt 锁死了,不允许使用内部知识,只能基于检索回来的上下文。

② 向量库确实没有匹配片段

如果知识库文件中根本没有与用户提问相关的主题(比如根本没有 "XYZ" 这个编码),那么:

  • 文档切片、向量化入库后,Redis 里就不存在与 "XYZ" 语义相近的向量;
  • 用户提问向量化后去 Redis 做近似搜索,返回的文档列表为空;
  • 组装 Prompt 时,参考文档部分就是空的,最终触发"找不到信息"。
③ 相似度阈值过滤

向量检索通常会设置一个相似度阈值(例如 0.7)。即使向量库里有其他文档,但如果它们与用户问题的向量相似度全部低于阈值,这些文档也会被直接过滤掉,最终交给模型的上下文仍然是空。

Spring AI Alibaba 的 VectorStoreDocumentRetriever 支持配置 similarityThreshold,如果设置了该值,那么只有相似度高于阈值的文档才会被采用。

两种模式对比
模式 行为
严格 RAG(生产常用) 检索不到内容 → 返回 "找不到信息",禁止幻觉,不瞎编
宽松 RAG 检索不到内容,允许大模型使用自身知识回答问题

本文的示例就是典型的 严格 RAG,通过 System Prompt 做了强限制。这样做的好处是:

  • 杜绝大模型幻觉,回答可控、可追溯;
  • 所有答案都基于企业真实文档,适合生产环境。

七、测试与验证

1. 启动服务

确保 Redis Stack 已启动,环境变量 aliQwen-api 已设置,然后运行 Spring Boot 应用。启动日志中应看到"向量数据初始化完成"。

2. 接口调用

重启应用后再次调用接口,观察日志应输出"向量数据已存在,跳过初始化",且检索结果依旧准确,不会出现冗余重复。

八、总结

本文通过一个简单的运维问答场景,展示了如何利用 Spring AI Alibaba + Redis Stack 实现本地知识库的 RAG 应用。核心步骤包括:

  1. 文档预处理:读取、分割、向量化。
  2. 向量存储:利用 Redis Stack 存储向量索引。
  3. 检索增强生成 :通过 RetrievalAugmentationAdvisor 自动完成检索与上下文增强。
  4. 防重初始化:使用 Redis 分布式锁思想,保证数据只加载一次。
  5. 严格 RAG 策略:通过 Prompt 约束和阈值控制,确保模型只基于知识库回答,杜绝幻觉。
相关推荐
中间件XL2 天前
ai-agent框架spring ai alibaba (三)外部调用II-2 分布式MCP
spring ai·springaialibaba·分布式mcp·nacos3
Java成神之路-3 天前
RAG 文本向量化:文本转向量、向量入库、相似度查询全过程
springaialibaba
Java成神之路-5 天前
Spring AI Alibaba 实现多轮对话记忆:ChatMemory 与 Redis 持久化实战
java·springaialibaba
Java成神之路-6 天前
Spring AI 统一结构化返回:ChatModel / ChatClient 两种实现方式
java·springaialibaba
MinggeQingchun11 天前
AI - Java之Spring AI Alibaba
ai·springai·springaialibaba
中间件XL1 个月前
ai-agent框架spring ai/alibaba 原理源码分析(五)graph III 图执行
graph·ai agent·spring ai·springaialibaba
中间件XL3 个月前
ai-agent框架spring ai/alibaba原理源码分析(三) 外部调用III-skills
ai agent·calling·spring ai·springaialibaba·skills
dddaidai1235 个月前
SpringAI Alibaba(一)
ai·springai·springaialibaba
大傻^5 个月前
Spring AI Alibaba 快速入门:基于通义千问的AI应用开发环境搭建
java·人工智能·后端·spring·springai·springaialibaba