Spring AI + Chroma 如何搭建汽车客服知识库问答系统

8 阅读5分钟

作者:JAVA老刘 | 15年Java老兵,目前专注AI+行业落地


先说为什么写这个

上周有个客户甩给我一个需求:"能不能做个AI,用户输入汽车故障现象,系统直接告诉他什么原因、怎么修?"

我说能。

三天后,我用Spring AI + Chroma搭了一套汽车故障问答系统,扔给他一个链接让他自己测。他测了10分钟,沉默了,然后问我:

"这套东西,你们卖多少钱?"

这就是AI落地和真实需求相遇的样子。


1. 先搞清楚什么是RAG

在开始写代码之前,先把一个概念说清楚:RAG(检索增强生成)

传统的LLM(比如直接调ChatGPT)有两个问题:

  1. 知识有截止日期,它不知道你公司的内部知识
  2. 容易一本正经胡说八道,在专业领域这是致命的

RAG的思路是:

用户问题 → 检索本地知识库 → 把相关知识片段捞出来 → 拼进Prompt → LLM生成回答

这样回答有据可查,不会胡编。在汽车客服场景里,这个太重要了——你不能容忍AI把保养周期说错、把故障码解释错。


2. 技术选型

组件我选什么为什么
LLMMiniMax M2.7国产合规,长上下文,便宜,够用
向量数据库Chroma轻量,一行命令启动,不用搭Milvus那套
框架Spring AI我做了15年Java,这个最顺手
知识库格式Markdown整理成本低,汽车知识直接可以写成md

3. 先启动 Chroma(2分钟)

docker run -d --name chroma \
  -p 8000:8000 \
  chromadb/chroma:latest

验证:

curl http://localhost:8000/api/v1/heartbeat
# 返回 {"success":true} 就OK

就这一行,不用配集群,不用调参数,Chroma够用了。


4. 知识库准备

我把汽车知识整理成三个目录:

knowledge/
├── FAQ/
│   ├── 故障码大全.md         # OBD-II故障码+处理方案
│   ├── 保养周期表.md         # 各类保养项目+周期
│   └── 常见问题.md           # 售前售后高频问题
├── 车型资料/
│   ├── 紧凑型SUV.md         # 主流SUV对比+话术
│   └── 新能源车.md           # 纯电/混动车型资料
└── 报价话术/
    └── 售前咨询话术.md       # 报价/议价/促成签单

做知识库的核心原则:按业务场景分类,不是按文档格式分类。

每个md文件要有来源标识,方便后续回答时引用,比如:

# 故障码P0171

【来源】故障码大全.md  
【分类】发动机系统  
【严重程度】⚠️ 中

## 含义
混合气过稀

## 处理方案
检查进气漏气、喷油嘴...

5. 完整代码

5.1 依赖(pom.xml)

<dependencies>
    <!-- Spring AI Core -->
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-core</artifactId>
        <version>1.0.0-M6</version>
    </dependency>
    
    <!-- MiniMax适配器 -->
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-minimaxi</artifactId>
        <version>1.0.0-M6</version>
    </dependency>
    
    <!-- Chroma向量数据库 -->
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-chroma</artifactId>
        <version>1.0.0-M6</version>
    </dependency>
</dependencies>

5.2 配置(application.yml)

spring:
  ai:
    minimax:
      api-key: ${MINIMAX_API_KEY}
      chat:
        options:
          model: MiniMax-M2.7
          temperature: 0.7

chroma:
  host: localhost
  port: 8000
  collection-name: car-knowledge

5.3 文档加载器

@Component
@Slf4j
public class CarKnowledgeLoader {

    private final ClassPathResource resource = new ClassPathResource("knowledge/");

    /**
     * 加载指定目录下的所有Markdown文档
     */
    public List<Document> loadDocuments(String subDir) {
        List<Document> documents = new ArrayList<>();
        
        try {
            Path dirPath = resource.getFile().toPath().resolve(subDir);
            if (!Files.exists(dirPath)) {
                log.warn("目录不存在: {}", dirPath);
                return documents;
            }
            
            try (Stream<Path> paths = Files.walk(dirPath)) {
                paths.filter(p -> p.toString().endsWith(".md"))
                     .forEach(path -> {
                         try {
                             String content = Files.readString(path);
                             String source = path.getFileName().toString();
                             
                             Document doc = new Document(content, 
                                 Map.of("source", source, "category", subDir));
                             documents.add(doc);
                         } catch (IOException e) {
                             log.error("读取文件失败: {}", path, e);
                         }
                     });
            }
        } catch (IOException e) {
            log.error("加载文档目录失败: {}", subDir, e);
        }
        
        return documents;
    }
}

5.4 文档切分器

@Component
public class CarTextSplitter {

    private static final int MAX_TOKENS = 500; // 每个chunk约500字

    /**
     * 按##标题切分,保持语义完整
     */
    public List<Document> splitByHeaders(List<Document> documents) {
        List<Document> chunks = new ArrayList<>();

        for (Document doc : documents) {
            String content = doc.getContent();
            String source = doc.getMetadata().getOrDefault("source", "unknown");
            String category = doc.getMetadata().getOrDefault("category", "general");

            // 按 ## 标题切分
            String[] sections = content.split("(?=## )");
            StringBuilder currentChunk = new StringBuilder();
            int tokenCount = 0;

            for (String section : sections) {
                if (section.trim().isEmpty()) continue;

                // 简单估算token(中文字符约1.5token)
                int sectionTokens = section.length() / 2;

                if (tokenCount + sectionTokens > MAX_TOKENS && currentChunk.length() > 0) {
                    chunks.add(new Document(currentChunk.toString(),
                        Map.of("source", source, "category", category)));
                    currentChunk = new StringBuilder();
                    tokenCount = 0;
                }

                currentChunk.append(section);
                tokenCount += sectionTokens;
            }

            if (currentChunk.length() > 0) {
                chunks.add(new Document(currentChunk.toString(),
                    Map.of("source", source, "category", category)));
            }
        }
        return chunks;
    }
}

5.5 Chroma 配置

@Configuration
public class ChromaConfig {

    @Value("${chroma.host:localhost}")
    private String host;

    @Value("${chroma.port:8000}")
    private int port;

    @Value("${chroma.collection-name:car-knowledge}")
    private String collectionName;

    @Bean
    public VectorStore vectorStore(ChatModel chatModel) {
        ChromaApi chromaApi = new ChromaApi("http://" + host + ":" + port);
        Collection collection = chromaApi.getOrCreateCollection(collectionName);
        return new ChromaVectorStore(collection, chatModel);
    }
}

5.6 知识库初始化(启动时自动加载)

@Component
public class KnowledgeBaseInitializer implements ApplicationRunner {

    private final CarKnowledgeLoader knowledgeLoader;
    private final CarTextSplitter textSplitter;
    private final VectorStore vectorStore;

    public KnowledgeBaseInitializer(
            CarKnowledgeLoader knowledgeLoader,
            CarTextSplitter textSplitter,
            VectorStore vectorStore) {
        this.knowledgeLoader = knowledgeLoader;
        this.textSplitter = textSplitter;
        this.vectorStore = vectorStore;
    }

    @Override
    public void run(ApplicationArguments args) {
        System.out.println("📚 开始初始化汽车知识库...");

        List<Document> faqDocs = knowledgeLoader.loadDocuments("FAQ");
        List<Document> carDocs = knowledgeLoader.loadDocuments("车型资料");
        List<Document> priceDocs = knowledgeLoader.loadDocuments("报价话术");

        List<Document> allDocs = new ArrayList<>();
        allDocs.addAll(faqDocs);
        allDocs.addAll(carDocs);
        allDocs.addAll(priceDocs);

        List<Document> chunks = textSplitter.splitByHeaders(allDocs);
        System.out.println("📄 共加载文档块: " + chunks.size() + " 个");

        vectorStore.add(chunks);
        System.out.println("✅ 知识库初始化完成!");
    }
}

5.7 核心问答服务

@Service
@Slf4j
public class CarChatService {

    private final ChatClient chatClient;
    private final VectorStore vectorStore;
    private static final int TOP_K = 3;

    private static final String SYSTEM_PROMPT = """
        你是一个专业的汽车客服助手。请基于以下知识库内容回答用户问题。
        
        【知识库】
        {context}
        
        回答要求:
        1. 基于知识库内容准确回答,不要编造
        2. 如果知识库没有相关信息,坦诚说明
        3. 涉及专业维修建议引导到店检查
        4. 回答时引用知识库来源,增加可信度
        """;

    public CarChatService(ChatClient.Builder chatClientBuilder, VectorStore vectorStore) {
        this.chatClient = chatClientBuilder.build();
        this.vectorStore = vectorStore;
    }

    public String answer(String question) {
        log.info("收到问题: {}", question);

        // 1. 语义检索Top-K相关文档
        List<Document> relevantDocs = vectorStore.similaritySearch(
            SearchRequest.builder()
                .query(question)
                .topK(TOP_K)
                .build()
        );

        // 2. 组装上下文
        String context = relevantDocs.stream()
            .map(doc -> {
                String source = doc.getMetadata().getOrDefault("source", "未知来源");
                String category = doc.getMetadata().getOrDefault("category", "通用");
                return String.format("【来源】%s (%s)\n%s", source, category, doc.getContent());
            })
            .collect(Collectors.joining("\n\n---\n\n"));

        if (relevantDocs.isEmpty()) {
            return "抱歉,知识库中暂无相关信息,建议您咨询在线客服。";
        }

        // 3. LLM生成回答
        String answer = chatClient.prompt()
            .system(SYSTEM_PROMPT.replace("{context}", context))
            .user(question)
            .call()
            .content();

        return answer;
    }
}

5.8 Controller

@RestController
@RequestMapping("/api/chat")
public class ChatController {

    private final CarChatService chatService;

    public ChatController(CarChatService chatService) {
        this.chatService = chatService;
    }

    @PostMapping("/answer")
    public Map<String, Object> answer(@RequestBody Map<String, String> request) {
        String question = request.get("question");
        if (question == null || question.trim().isEmpty()) {
            return Map.of("error", "问题不能为空");
        }
        return Map.of("answer", chatService.answer(question), "question", question);
    }
}

6. 效果演示

用户: "发动机灯亮了怎么办?"

系统检索到:
- 【来源】故障码大全.md → P0171/P0172等故障码含义+处理方案
- 【来源】常见问题.md → 发动机灯亮的应对流程

LLM回答:
"发动机灯亮通常表示发动机管理系统检测到异常。根据知识库,
常见原因包括...(综合检索结果生成)

---

用户: "途岳和CR-V怎么选?"

系统检索到:
- 【来源】紧凑型SUV.md → 大众途岳vs本田CR-V详细对比

LLM回答:
"两款车核心差异在于...(带数据支撑的专业对比)

7. 我的感悟

搭完这套系统,我最大的感受是:

AI落地不需要大模型军备竞赛,需要的是行业Know-How + 工程实现。

懂汽车CRM的人 + 懂AI的人,如果能串起来,胜过十个通用聊天机器人。

我现在做的事,就是把这两个东西接上。


8. 写在最后

如果你也有汽车相关业务,想用AI提效——无论是客服机器人、销售助手还是知识管理——欢迎评论区聊聊,我不卖课,只做实在的事。

有技术问题也可以问,我尽量回。

你们公司有什么场景想用AI改造?