基于 Spring AI 的向量检索集成指南
1. 业务场景与目标
在智能客服或知识库问答系统中,用户经常提出关于“某类商品能否通过空运/快递运输”的问题。传统基于关键词(LIKE)的检索方式只能匹配包含相同词语的记录,无法理解语义相似的查询,导致召回率低、用户体验差。
目标:通过向量检索(语义搜索),将用户的问题与知识库中的商品运输规则进行语义匹配,精准返回最相关的记录,并结合大语言模型(LLM)生成自然语言回答。
核心组件:
- Embedding 模型:将文本转换为向量(如 nomic-embed-text)
- 向量数据库:存储并检索向量(如 Elasticsearch)
- 大语言模型:基于检索结果生成回答(如 DeepSeek)
- 业务数据库:存储原始知识条目(如 MySQL)
2. 技术选型与架构
| 组件 | 技术选型 | 说明 |
|---|---|---|
| 应用框架 | Spring Boot 3.x | 项目基础框架 |
| AI 框架 | Spring AI 1.1.5 | 统一集成 LLM 和向量存储 |
| 向量数据库 | Elasticsearch 8.x | 支持向量字段和 HNSW 索引,与 Spring AI 官方集成良好 |
| Embedding 模型 | Ollama + nomic-embed-text | 本地部署,轻量级,768 维,免费 |
| 大语言模型 | DeepSeek API(兼容 OpenAI) | 用于回答生成,可按需替换 |
| 关系数据库 | MySQL | 存储知识原文、用户对话记录等 |
整体架构流程:
- 管理员通过同步任务将 MySQL 中的知识条目(标题、内容、备注)拼接为文本,调用 Embedding 模型生成向量,存入 Elasticsearch。
- 用户提问时,系统将用户问题也转换为向量,在 Elasticsearch 中执行相似度检索,返回 Top-K 条最相似的知识条目。
- 将检索到的知识条目作为上下文,与用户问题一起提供给大语言模型,生成最终回答。
- 记录用户对话日志,用于后续分析和模型迭代。
3. 环境部署
3.1 安装 Elasticsearch(开发环境)
使用 Docker(推荐):
docker run -d --name es-dev \
-p 9200:9200 -p 9300:9300 \
-e "discovery.type=single-node" \
-e "xpack.security.enabled=false" \
docker.elastic.co/elasticsearch/elasticsearch:8.13.3验证:
curl http://localhost:92003.2 安装 Ollama 并下载 Embedding 模型
Linux/macOS:
curl -fsSL https://ollama.com/install.sh | sh
ollama pull nomic-embed-textWindows:从官网下载安装包,运行后执行 ollama pull nomic-embed-text。
验证:
curl http://localhost:11434/api/embed -d '{"model":"nomic-embed-text","input":"test"}'3.3 启动 Spring Boot 应用
确保 MySQL、Elasticsearch、Ollama 均已启动,然后正常启动 Spring Boot 项目。
4. 项目依赖(Maven)
在父 POM 中引入 Spring AI BOM:
<properties>
<spring-ai.version>1.1.5</spring-ai.version>
</properties>
<dependencyManagement>
<dependencies>
<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>在具体模块中添加所需 Starter:
<!-- OpenAI 兼容客户端(用于 DeepSeek) -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
<!-- Ollama 支持 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-ollama</artifactId>
</dependency>
<!-- Elasticsearch 向量存储 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-vector-store-elasticsearch</artifactId>
</dependency>
<!-- Elasticsearch Java 客户端(确保版本兼容) -->
<dependency>
<groupId>co.elastic.clients</groupId>
<artifactId>elasticsearch-java</artifactId>
<version>8.13.3</version>
</dependency>5. 应用配置(application.yml)
spring:
# Elasticsearch 连接
elasticsearch:
uris: http://localhost:9200
# 如未开启安全认证,无需 username/password
# AI 相关配置
ai:
# Ollama 配置(用于 Embedding)
ollama:
base-url: http://localhost:11434
embedding:
model: nomic-embed-text
# Elasticsearch 向量存储配置
vectorstore:
elasticsearch:
dimensions: 768 # 与 nomic-embed-text 维度一致
initialize-schema: true # 自动创建索引映射
similarity: cosine # 相似度算法
index-name: knowledge_vector_index # 自定义索引名
# OpenAI 兼容配置(用于 DeepSeek 对话)
openai:
base-url: https://api.deepseek.com
api-key: your-api-key
chat:
options:
model: deepseek-v4-flash
temperature: 0.1
max-tokens: 4096
top-p: 0.16. 数据模型与同步服务
6.1 知识条目实体(示例)
@Entity
@Table(name = "knowledge_item")
public class KnowledgeItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private String content;
private String remark;
private Integer hitNum; // 被检索命中的次数
// getters/setters...
}6.2 向量同步服务
将 MySQL 中的存量知识条目转换为向量并写入 Elasticsearch。
@Service
@Slf4j
public class VectorIndexService {
@Resource
private KnowledgeItemMapper knowledgeItemMapper;
@Resource
private VectorStore vectorStore;
/**
* 同步所有知识条目到 Elasticsearch 向量索引
*/
public void syncAllItems() {
log.info("开始同步知识条目到向量数据库");
List<KnowledgeItem> items = knowledgeItemMapper.selectList(
new LambdaQueryWrapper<KnowledgeItem>().orderByAsc(KnowledgeItem::getId)
);
if (items == null || items.isEmpty()) {
log.warn("没有知识条目,跳过同步");
return;
}
List<Document> documents = new ArrayList<>();
for (KnowledgeItem item : items) {
String text = buildText(item);
Map<String, Object> metadata = new HashMap<>();
metadata.put("itemId", item.getId());
metadata.put("title", item.getTitle());
Document doc = new Document(text, metadata);
documents.add(doc);
}
// 分批写入,避免内存溢出
int batchSize = 100;
for (int i = 0; i < documents.size(); i += batchSize) {
int end = Math.min(i + batchSize, documents.size());
vectorStore.add(documents.subList(i, end));
log.info("已同步 {}/{} 条", end, documents.size());
}
log.info("✅ 成功同步 {} 条知识条目到 Elasticsearch", documents.size());
}
/**
* 构建向量化的文本:将重要字段拼接,并补充通用上下文
*/
private String buildText(KnowledgeItem item) {
StringBuilder sb = new StringBuilder();
if (StringUtils.isNotBlank(item.getRemark())) {
sb.append("结论:").append(item.getRemark()).append("。");
}
if (StringUtils.isNotBlank(item.getTitle())) {
sb.append("标题:").append(item.getTitle()).append("。");
}
if (StringUtils.isNotBlank(item.getContent())) {
sb.append("内容:").append(item.getContent());
}
String text = sb.toString();
// 如果文本过短,增加通用上下文,增强语义密度
if (text.length() < 20) {
sb.append("这是一条关于物流运输规则的知识记录。");
text = sb.toString();
}
return text;
}
}6.3 触发同步
方式一:通过 @PostConstruct 在启动时执行一次(仅用于初始化):
@PostConstruct
public void init() {
syncAllItems();
}方式二:通过 REST 接口手动触发:
@RestController
@RequestMapping("/admin")
public class AdminController {
@Resource
private VectorIndexService vectorIndexService;
@GetMapping("/sync-knowledge")
public String sync() {
vectorIndexService.syncAllItems();
return "同步完成";
}
}7. 智能问答核心服务
7.1 工具类:知识检索工具
定义 Spring AI Tool(Function Calling)供大模型调用。
@Component
@Slf4j
public class KnowledgeSearchTool {
@Resource
private VectorStore vectorStore;
@Resource
private KnowledgeItemMapper knowledgeItemMapper;
// 每个请求独立标记,防止重复调用
private static final ThreadLocal<Boolean> TOOL_CALLED = new ThreadLocal<>();
@Tool(description = """
根据用户问题,在知识库中检索最相关的运输规则记录。
返回 List<KnowledgeItem>,最多返回 10 条。
请从结果中选择最匹配的一条,并在最终回答中以 '[KNOWLEDGE_ID:xxx]' 开头标记。
此工具每个对话只能调用一次,不要重复调用。
""")
public List<KnowledgeItem> searchKnowledge(@ToolParam(description = "用户查询内容") String query) {
if (Boolean.TRUE.equals(TOOL_CALLED.get())) {
log.info("知识检索工具已调用,本次对话不再重复执行");
return Collections.emptyList();
}
TOOL_CALLED.set(true);
log.info("执行向量检索,查询词:{}", query);
try {
List<Document> results = vectorStore.similaritySearch(
SearchRequest.builder()
.query(query)
.topK(20)
.similarityThreshold(0.3) // 可调整
.build()
);
if (results == null || results.isEmpty()) {
log.info("向量检索未命中任何结果");
return Collections.emptyList();
}
List<Long> ids = results.stream()
.map(doc -> (Long) doc.getMetadata().get("itemId"))
.filter(Objects::nonNull)
.distinct()
.limit(10)
.collect(Collectors.toList());
if (ids.isEmpty()) {
return Collections.emptyList();
}
// 从 MySQL 查出完整记录,并更新命中次数
List<KnowledgeItem> items = knowledgeItemMapper.selectBatchIds(ids);
items.forEach(item -> {
item.setHitNum(item.getHitNum() == null ? 1 : item.getHitNum() + 1);
knowledgeItemMapper.updateById(item);
});
return items;
} catch (Exception e) {
log.error("向量检索异常", e);
return Collections.emptyList();
}
}
public static void clearToolCalled() {
TOOL_CALLED.remove();
}
}7.2 对话服务(流式)
整合工具调用和对话历史,实现流式问答。
@Service
@Slf4j
public class ChatService {
private final ChatClient chatClient;
@Resource
private KnowledgeSearchTool knowledgeSearchTool;
@Resource
private UserConversationMapper conversationMapper;
private final String systemPrompt = """
你是一个智能物流助手,用中文回答用户关于商品运输的问题。
回答必须使用 HTML 格式,仅使用安全标签:<p>、<strong>、<ul>、<li> 等。
如果调用了知识检索工具,请在回答开头标记 [KNOWLEDGE_ID:xxx]。
""";
public ChatService(ChatClient.Builder builder, KnowledgeSearchTool tool) {
this.chatClient = builder
.defaultSystem(systemPrompt)
.defaultTools(tool)
.build();
}
/**
* 流式对话
*/
public Flux<String> chatStream(String userMessage, String userId, String conversationId) {
// 清理工具调用标记
KnowledgeSearchTool.clearToolCalled();
StringBuilder fullReply = new StringBuilder();
List<Message> messages = buildMessages(conversationId, userMessage);
return chatClient.prompt()
.messages(messages)
.stream()
.content()
.doOnNext(fullReply::append)
.doOnComplete(() -> {
String reply = fullReply.toString();
saveConversation(userMessage, reply, userId, conversationId);
KnowledgeSearchTool.clearToolCalled();
})
.doOnError(e -> {
log.error("对话异常", e);
KnowledgeSearchTool.clearToolCalled();
});
}
private List<Message> buildMessages(String conversationId, String userMessage) {
List<Message> messages = new ArrayList<>();
messages.add(new SystemMessage(systemPrompt));
// 加载最近 5 轮历史(如有)
if (StringUtils.isNotBlank(conversationId)) {
List<UserConversation> history = conversationMapper.selectList(
new LambdaQueryWrapper<UserConversation>()
.eq(UserConversation::getConversationId, conversationId)
.orderByAsc(UserConversation::getCreateTime)
.last("LIMIT 10")
);
for (UserConversation record : history) {
messages.add(new UserMessage(record.getUserMessage()));
messages.add(new AssistantMessage(record.getAiReply()));
}
}
messages.add(new UserMessage(userMessage));
return messages;
}
private void saveConversation(String userMsg, String aiReply, String userId, String convId) {
// 保存到数据库(省略实现)
}
}7.3 控制器接口
@RestController
@RequestMapping("/api/chat")
public class ChatController {
@Resource
private ChatService chatService;
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> streamChat(@RequestParam String message,
@RequestParam(required = false) String conversationId,
@RequestParam(required = false) String userId) {
return chatService.chatStream(message, userId, conversationId);
}
}8. 调优与运维
8.1 相似度阈值调整
- similarityThreshold 控制返回结果的最低相似度。值越高,结果越精确,但可能漏掉相关记录;值越低,召回更多,但噪音可能增加。
- 建议初始设为 0.3,根据实际搜索效果上下调整。
8.2 向量化文本优化
- 将业务中的结论、关键标签放在文本最前面(如“结论:可以走”、“结论:禁止空运”),以强化向量特征。
- 对于内容过短的记录,补充通用上下文(如“这是一条物流运输规则”)。
8.3 增量更新
当新增或修改知识条目时,应增量更新向量索引,而不是全量重建。可以监听数据变更事件,调用 vectorStore.add() 或 vectorStore.delete() 进行精细操作。
8.4 监控与日志
- 记录每次向量检索的查询词、命中数量、耗时,便于分析召回效果。
- 监控 Elasticsearch 的索引大小和查询性能,适时调整分片和副本数。
9. 常见问题与解决方案
| 问题 | 可能原因 | 解决方案 |
|---|---|---|
| 向量检索返回结果不相关 | 文本过短 / 语义密度不足 | 优化文本拼接,增加通用上下文 |
| 启动时连接 ES 超时 | ES 未启动或网络问题 | 检查 ES 服务状态,确认 spring.elasticsearch.uris 配置正确 |
| Ollama 调用失败 | Ollama 服务未运行或模型未拉取 | 执行 ollama serve 和 ollama pull nomic-embed-text |
| 重复调用检索工具 | 大模型多次调用工具 | 使用 ThreadLocal 标记,每个对话仅允许调用一次 |
| 索引维度不匹配 | 修改了模型但未重建索引 | 删除旧索引并重新同步数据 |
10. 总结
通过集成向量检索,知识库问答系统能够理解用户提问的语义,而不是依赖关键词拼凑,从而显著提升回答的准确性和用户体验。本指南提供了一套从环境搭建、数据同步到在线服务的完整实施方案,各组件均可根据实际需求替换(如更换 Embedding 模型、切换向量数据库等)。开发者可按此快速落地,并持续优化向量化策略和检索参数,使系统日臻完善。