作者:JAVA老刘 | 15年Java老兵,目前专注AI+行业落地
先说为什么写这个
上周有个客户甩给我一个需求:"能不能做个AI,用户输入汽车故障现象,系统直接告诉他什么原因、怎么修?"
我说能。
三天后,我用Spring AI + Chroma搭了一套汽车故障问答系统,扔给他一个链接让他自己测。他测了10分钟,沉默了,然后问我:
"这套东西,你们卖多少钱?"
这就是AI落地和真实需求相遇的样子。
1. 先搞清楚什么是RAG
在开始写代码之前,先把一个概念说清楚:RAG(检索增强生成) 。
传统的LLM(比如直接调ChatGPT)有两个问题:
- 知识有截止日期,它不知道你公司的内部知识
- 容易一本正经胡说八道,在专业领域这是致命的
RAG的思路是:
用户问题 → 检索本地知识库 → 把相关知识片段捞出来 → 拼进Prompt → LLM生成回答
这样回答有据可查,不会胡编。在汽车客服场景里,这个太重要了——你不能容忍AI把保养周期说错、把故障码解释错。
2. 技术选型
| 组件 | 我选什么 | 为什么 |
|---|---|---|
| LLM | MiniMax 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提效——无论是客服机器人、销售助手还是知识管理——欢迎评论区聊聊,我不卖课,只做实在的事。
有技术问题也可以问,我尽量回。