作者:大洪讲AI
更新时间:2026年6月
本章目标:完整实现RAG检索增强生成系统,支持文档上传、自动切片、向量存储、相似度检索,通过Prompt工程彻底解决大模型幻觉问题
前置条件:第三章流式聊天100%完成,SSE打字机效果正常


前言

上一章我们实现了流畅的SSE流式聊天,体验已经接近主流AI产品。但这时的AI客服还存在三个致命问题,完全无法用于真实业务:

  1. 胡说八道:大模型会编造不存在的产品参数、价格、售后政策
  2. 知识过时:大模型训练数据有截止日期,不知道你家最新的产品和活动
  3. 没有私有知识:大模型不可能知道你们公司内部的业务规则、客户须知

这一章我们就用RAG检索增强生成技术彻底解决这些问题,让AI只能用你上传的知识库内容回答问题,不知道就明确说不知道,绝不编造。


一、什么是RAG?为什么它是企业AI的标配

1.1 RAG核心概念

RAG全称 Retrieval-Augmented Generation(检索增强生成),简单来说就是:

大模型回答问题之前,先去你的私有知识库中检索最相关的内容,然后把这些内容和问题一起喂给大模型,让它只能基于检索到的内容生成回答。

1.2 RAG完整工作流程

用户提问
    ↓
问题向量化(和文档用同一个嵌入模型)
    ↓
向量数据库相似度检索,找出Top-K最相关的文档片段
    ↓
把「系统规则 + 知识库片段 + 用户问题」组装成增强Prompt
    ↓
大模型基于Prompt生成回答
    ↓
返回给用户

1.3 为什么不用微调?

对比项 RAG检索增强 微调模型
成本 极低,只需要向量数据库和嵌入API 极高,需要大量标注数据和GPU算力
更新速度 实时,上传文档立即生效 慢,每次更新都要重新训练
准确性 高,有明确的知识来源,可溯源 一般,仍然存在幻觉
技术门槛 低,普通后端就能做 高,需要算法工程师

结论:对于企业内部知识库、客服问答这类场景,RAG是性价比最高、落地最快的方案,也是目前行业的标准做法。


二、向量数据库选型与环境准备

2.1 为什么选Chroma

我们选用 Chroma 作为向量数据库,原因非常适合中小项目和快速落地:

  • ✅ 轻量无依赖,Python一行命令就能启动
  • ✅ 本地持久化存储,数据不会丢失
  • ✅ LangChain4j原生支持,对接成本极低
  • ✅ 免费开源,单机支持百万级向量完全够用

2.2 安装Chroma

打开CMD,执行以下命令安装(需要Python 3.10+):

pip install chromadb

⚠️ 踩坑预警:目前最新版Chroma是1.5.9,已经完全移除了v1 API,只支持v2接口。网上很多老教程都没更新,直接用会报410 Gone错误。我们的代码会直接适配v2 API,避开这个坑。

2.3 启动Chroma服务

执行以下命令启动Chroma,数据会持久化到本地磁盘:

chroma run --host 0.0.0.0 --port 8000 --path D:\chroma-data

参数说明:

  • --host 0.0.0.0:允许局域网访问
  • --port 8000:服务端口
  • --path D:\chroma-data:数据持久化路径,不指定默认存在用户目录下

启动成功后,浏览器访问http://localhost:8000/api/v2/collections,返回空数组[]说明服务正常。


三、后端RAG核心代码实现

3.1 第一步:添加Chroma依赖

pom.xmldependencies节点中添加Chroma向量存储依赖:

<!-- Chroma向量存储 -->
<dependency>
    <groupId>dev.langchain4j</groupId>
    <artifactId>langchain4j-chroma</artifactId>
    <version>${langchain4j.version}</version>
</dependency>

刷新Maven依赖,等待下载完成。

3.2 第二步:新增Chroma配置

application.yml中添加Chroma相关配置:

# 向量数据库配置
chroma:
  base-url: http://localhost:8000
  collection-name: ai_kefu_knowledge # 集合名称,相当于MySQL的表

3.3 第三步:配置向量存储Bean

修改src/main/java/org/example/config/AiConfig.java,添加Chroma向量存储的Bean配置:

import dev.langchain4j.store.embedding.EmbeddingStore;
import dev.langchain4j.store.embedding.TextSegment;
import dev.langchain4j.store.embedding.chroma.ChromaEmbeddingStore;
import dev.langchain4j.store.embedding.chroma.ChromaApiVersion;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

// ... 原有其他import保持不变

@Configuration
public class AiConfig {

    @Value("${chroma.base-url}")
    private String chromaBaseUrl;

    @Value("${chroma.collection-name}")
    private String chromaCollectionName;

    // ... 原有三个模型Bean保持不变

    /**
     * Chroma向量存储(适配v2 API,兼容Chroma 1.5+)
     */
    @Bean
    public EmbeddingStore<TextSegment> embeddingStore() {
        return ChromaEmbeddingStore.builder()
                .baseUrl(chromaBaseUrl)
                .collectionName(chromaCollectionName)
                .apiVersion(ChromaApiVersion.V2) // 关键:指定v2 API,解决410报错
                .build();
    }
}

⚠️ 重点提醒.apiVersion(ChromaApiVersion.V2)是适配高版本Chroma的核心配置,90%的人遇到410错误都是因为漏了这行。

3.4 第四步:实现文档切片工具类

长文档不能直接向量化,必须切成固定大小的片段,不然检索精度会非常差。我们实现一个带重叠的切片工具,保证上下文连贯。

新建文件:src/main/java/org/example/util/DocSplitter.java

package org.example.util;

import java.util.ArrayList;
import java.util.List;

public class DocSplitter {

    // 每个片段的字符数
    private static final int CHUNK_SIZE = 300;
    // 相邻片段重叠字符数,避免上下文断裂
    private static final int CHUNK_OVERLAP = 50;

    /**
     * 把长文本切成固定大小的片段,带重叠
     */
    public static List<String> split(String content) {
        List<String> chunks = new ArrayList<>();
        if (content == null || content.isEmpty()) {
            return chunks;
        }

        int length = content.length();
        int start = 0;

        while (start < length) {
            int end = Math.min(start + CHUNK_SIZE, length);
            chunks.add(content.substring(start, end));
            
            // 移动起始位置,减去重叠部分
            start += CHUNK_SIZE - CHUNK_OVERLAP;
            
            // 防止死循环
            if (start >= length) {
                break;
            }
        }

        return chunks;
    }
}

3.5 第五步:实现RAG核心服务

新建文件:src/main/java/org/example/service/RagService.java,这是整个知识库的核心:

package org.example.service;

import dev.langchain4j.data.document.Document;
import dev.langchain4j.data.segment.TextSegment;
import dev.langchain4j.model.embedding.EmbeddingModel;
import dev.langchain4j.store.embedding.EmbeddingMatch;
import dev.langchain4j.store.embedding.EmbeddingStore;
import dev.langchain4j.store.embedding.EmbeddingStoreIngestor;
import org.example.util.DocSplitter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class RagService {

    @Autowired
    private EmbeddingStore<TextSegment> embeddingStore;

    @Autowired
    @Qualifier("doubaoEmbeddingModel")
    private EmbeddingModel embeddingModel;

    // 检索最相关的Top 3片段
    private static final int TOP_K = 3;
    // 相似度阈值,低于这个分数的结果直接丢弃
    private static final double SCORE_THRESHOLD = 0.6;

    /**
     * 把文档内容存入知识库
     */
    public void addDocument(String content) {
        // 1. 切片
        List<String> chunks = DocSplitter.split(content);
        
        // 2. 批量向量化并存入向量库
        for (String chunk : chunks) {
            TextSegment segment = TextSegment.from(chunk);
            EmbeddingStoreIngestor.ingest(Document.from(segment.text()), embeddingModel, embeddingStore);
        }
    }

    /**
     * 检索与问题最相关的知识库内容
     */
    public String retrieveRelevantContent(String question) {
        // 1. 相似度检索
        List<EmbeddingMatch<TextSegment>> matches = embeddingStore.findRelevant(
                embeddingModel.embed(question).content(),
                TOP_K,
                SCORE_THRESHOLD
        );

        // 2. 拼接检索到的内容
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < matches.size(); i++) {
            sb.append("【资料").append(i + 1).append("】\n");
            sb.append(matches.get(i).embedded().text()).append("\n\n");
        }

        return sb.toString().trim();
    }

    /**
     * 构建RAG增强Prompt,加防编造规则
     */
    public String buildRagPrompt(String question, String relevantContent) {
        StringBuilder prompt = new StringBuilder();
        prompt.append("你是公司的专业AI客服,必须严格遵守以下规则:\n");
        prompt.append("1. 只能使用下面【知识库内容】中的信息回答问题,绝对不能编造任何知识库中没有的内容\n");
        prompt.append("2. 如果知识库中没有相关内容,直接回答:\"抱歉,我没有找到相关信息,请您咨询人工客服。\"\n");
        prompt.append("3. 回答简洁、专业、有条理,使用\"您\"称呼用户\n");
        prompt.append("4. 不要添加任何知识库中没有的解释和延伸\n\n");
        prompt.append("【知识库内容】\n");
        prompt.append(relevantContent).append("\n\n");
        prompt.append("用户问题:").append(question);
        
        return prompt.toString();
    }
}

3.6 第六步:Chroma管理工具类(适配v2 API)

因为LangChain4j没有提供统计数量、清空集合的方法,我们自己封装一个工具类调用Chroma的v2 HTTP接口。

新建文件:src/main/java/org/example/util/ChromaUtil.java

package org.example.util;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;

@Component
public class ChromaUtil {

    @Value("${chroma.base-url}")
    private String chromaBaseUrl;

    @Value("${chroma.collection-name}")
    private String chromaCollectionName;

    private final RestTemplate restTemplate = new RestTemplate();
    private final ObjectMapper objectMapper = new ObjectMapper();

    /**
     * 获取知识库文档片段总数
     */
    public String getStats() {
        try {
            String collectionId = getCollectionIdByName(chromaCollectionName);
            if (collectionId == null) {
                return "知识库当前共有0个文档片段";
            }

            String url = chromaBaseUrl + "/api/v2/collections/" + collectionId + "/count";
            Integer count = restTemplate.getForObject(url, Integer.class);
            return "知识库当前共有" + count + "个文档片段";
        } catch (Exception e) {
            return "获取统计信息失败:" + e.getMessage();
        }
    }

    /**
     * 清空知识库
     */
    public String clearKnowledgeBase() {
        try {
            String collectionId = getCollectionIdByName(chromaCollectionName);
            if (collectionId == null) {
                return "知识库不存在,无需清空";
            }

            String url = chromaBaseUrl + "/api/v2/collections/" + collectionId;
            restTemplate.delete(url);
            return "知识库已清空";
        } catch (Exception e) {
            return "清空失败:" + e.getMessage();
        }
    }

    /**
     * v2 API必须:通过集合名称获取集合ID
     */
    private String getCollectionIdByName(String collectionName) {
        try {
            String url = chromaBaseUrl + "/api/v2/collections";
            String response = restTemplate.getForObject(url, String.class);
            JsonNode root = objectMapper.readTree(response);

            for (JsonNode collection : root) {
                if (collectionName.equals(collection.get("name").asText())) {
                    return collection.get("id").asText();
                }
            }
            return null;
        } catch (Exception e) {
            return null;
        }
    }
}

3.7 第七步:知识库管理接口

新建文件:src/main/java/org/example/controller/KnowledgeBaseController.java

package org.example.controller;

import org.example.model.vo.AjaxResult;
import org.example.service.RagService;
import org.example.util.ChromaUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;

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

    @Autowired
    private RagService ragService;

    @Autowired
    private ChromaUtil chromaUtil;

    /**
     * 上传文档到知识库(支持txt/md文件)
     */
    @PostMapping("/upload")
    public AjaxResult<String> uploadDocument(@RequestParam("file") MultipartFile file) {
        try {
            // 读取文件内容(UTF-8编码)
            StringBuilder content = new StringBuilder();
            try (BufferedReader reader = new BufferedReader(
                    new InputStreamReader(file.getInputStream(), StandardCharsets.UTF_8))) {
                String line;
                while ((line = reader.readLine()) != null) {
                    content.append(line).append("\n");
                }
            }

            // 存入知识库
            ragService.addDocument(content.toString());

            return AjaxResult.success("文档上传成功,已添加到知识库");
        } catch (Exception e) {
            return AjaxResult.error("上传失败:" + e.getMessage());
        }
    }

    /**
     * 获取知识库统计信息
     */
    @GetMapping("/stats")
    public AjaxResult<String> getStats() {
        return AjaxResult.success(chromaUtil.getStats());
    }

    /**
     * 清空知识库
     */
    @DeleteMapping("/clear")
    public AjaxResult<String> clear() {
        return AjaxResult.success(chromaUtil.clearKnowledgeBase());
    }
}

3.8 第八步:改造流式聊天接口,接入RAG

修改ChatController.java的流式接口,让回答基于知识库内容:

@PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter streamChat(@RequestBody ChatRequest request) {
    SseEmitter emitter = new SseEmitter(60000L);

    // 1. 检索知识库相关内容
    String relevantContent = ragService.retrieveRelevantContent(request.getMessage());

    // 2. 构建增强Prompt
    String prompt;
    if (relevantContent.isEmpty()) {
        // 没有相关内容,直接返回固定话术
        prompt = "抱歉,我没有找到相关信息,请您咨询人工客服。";
        // 这里可以直接返回,不走大模型,节省费用
        try {
            emitter.send(prompt, MediaType.TEXT_PLAIN);
            emitter.send("[DONE]", MediaType.TEXT_PLAIN);
            emitter.complete();
            return emitter;
        } catch (IOException e) {
            emitter.completeWithError(e);
            return emitter;
        }
    } else {
        // 有相关内容,用RAG增强Prompt
        prompt = ragService.buildRagPrompt(request.getMessage(), relevantContent);
    }

    // 3. 调用流式大模型生成回答
    streamingChatModel.generate(prompt, new StreamingResponseHandler<AiMessage>() {
        @Override
        public void onNext(String token) {
            try {
                emitter.send(token, MediaType.TEXT_PLAIN);
            } catch (IOException e) {
                emitter.completeWithError(e);
            }
        }

        @Override
        public void onComplete(Response<AiMessage> response) {
            try {
                emitter.send("[DONE]", MediaType.TEXT_PLAIN);
                emitter.complete();
            } catch (IOException e) {
                emitter.completeWithError(e);
            }
        }

        @Override
        public void onError(Throwable error) {
            emitter.completeWithError(error);
        }
    });

    return emitter;
}

💡 优化点:如果检索不到相关内容,直接返回固定话术,不调用大模型,既节省API费用,又保证不会编造答案。


四、完整功能验证

4.1 准备测试文档

新建一个product.txt文件,写入以下测试内容:

【产品介绍】
AI智能客服系统V2.0是基于豆包大模型开发的企业级客服系统。
产品价格:基础版999元/年,专业版2999元/年,企业版定制报价。
产品功能:支持SSE流式聊天、RAG知识库、意图识别、多轮对话、数据统计。
售后服务:7天无理由退款,1年免费技术支持,工作日9:00-18:00在线客服。

4.2 验证步骤

  1. 启动Chroma服务:确保8000端口正常运行
  2. 重启后端项目:控制台无报错
  3. 上传文档:用Postman调用POST http://localhost:8080/api/kb/upload,选择刚才的product.txt文件上传
  4. 查看统计:调用GET http://localhost:8080/api/kb/stats,返回「知识库当前共有X个文档片段」
  5. 测试知识库内问题:前端发送「你们产品多少钱?」,AI应该准确回答三个版本的价格
  6. 测试知识库外问题:前端发送「今天天气怎么样?」,AI应该返回「抱歉,我没有找到相关信息,请您咨询人工客服。」
  7. 测试防编造:前端发送「你们的产品支持视频会议吗?」,AI应该说找不到相关信息,不会编造

✅ 全部验证通过,说明RAG知识库系统正常工作,AI已经不会胡说八道了。


五、RAG效果调优指南

效果不好的时候,从这几个维度调整,90%的问题都能解决:

问题 调整方向 建议值
检索不到相关内容 减小切片大小,增加Top-K 切片200-300字符,Top-K 3-5
回答上下文断裂 增加切片重叠大小 重叠占切片的15%-20%
回答有无关内容 提高相似度阈值,减少Top-K 阈值0.6-0.8,Top-K 2-3
还是会编造内容 强化Prompt规则,加重惩罚语气 明确说明「编造内容会造成严重后果」
长文档效果差 按章节/段落手动切分,再上传 避免把不相关的内容切到同一个片段

六、常见问题排查

问题1:启动报错Chroma连接失败

  • 检查Chroma服务是否正常启动
  • 检查chroma.base-url地址和端口是否正确
  • 检查防火墙是否拦截了8000端口

问题2:调用接口返回410 Gone

  • 99%是因为没加.apiVersion(ChromaApiVersion.V2)配置
  • 检查Chroma版本是否在1.0以上

问题3:上传文档成功但检索不到

  • 检查嵌入模型是否和上传时用的是同一个
  • 调大TOP_K或者降低SCORE_THRESHOLD
  • 检查文档内容是否和问题语义相关

问题4:AI还是会编造内容

  • 检查Prompt中的规则是否足够明确
  • 检查检索到的内容是否真的包含答案
  • 把模型的temperature调到0.1-0.3,降低创造性

七、本章总结

本章我们完成了RAG知识库系统的完整实现:

  1. ✅ 理解了RAG检索增强生成的核心原理和优势
  2. ✅ 完成了Chroma向量数据库的安装启动,适配v2 API
  3. ✅ 实现了文档自动切片、批量向量化、存储入库
  4. ✅ 实现了相似度检索和RAG增强Prompt构建
  5. ✅ 开发了完整的知识库管理接口(上传、统计、清空)
  6. ✅ 改造了流式聊天接口,回答完全基于知识库
  7. ✅ 通过Prompt工程彻底解决了大模型幻觉问题

现在我们的AI客服已经具备了私有知识库问答能力,可以准确回答业务相关的问题,不知道的就明确说不知道,完全满足基础的企业客服需求。


下章预告

下一章我们将实现多轮对话与上下文管理,包括会话持久化、MySQL数据库建表、滑动窗口上下文策略、对话历史管理,让AI能够记住之前的对话内容,支持自然的多轮交流。

关注我,第一时间收到更新通知!

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐