Loading...

基于 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存储知识原文、用户对话记录等



整体架构流程

  1. 管理员通过同步任务将 MySQL 中的知识条目(标题、内容、备注)拼接为文本,调用 Embedding 模型生成向量,存入 Elasticsearch。
  2. 用户提问时,系统将用户问题也转换为向量,在 Elasticsearch 中执行相似度检索,返回 Top-K 条最相似的知识条目。
  3. 将检索到的知识条目作为上下文,与用户问题一起提供给大语言模型,生成最终回答。
  4. 记录用户对话日志,用于后续分析和模型迭代。

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:9200

3.2 安装 Ollama 并下载 Embedding 模型

Linux/macOS

curl -fsSL https://ollama.com/install.sh | sh
ollama pull nomic-embed-text

Windows:从官网下载安装包,运行后执行 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.1

6. 数据模型与同步服务

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 模型、切换向量数据库等)。开发者可按此快速落地,并持续优化向量化策略和检索参数,使系统日臻完善。

0

回到顶部