文章 · 2025-04-29

使用 Spring AI 构建 RAG 搜索服务设计

系统整体架构采用分层模块化设计,核心包括以下部分:

上述组件通过 Spring Boot 进行配置和组织,最终以 RESTful API 形式提供服务,包括文档入库接口和问答查询接口。下图展示了系统各组件的交互关系:

(架构说明:用户通过 Controller 发出请求,RAG Service 调用 Hybrid Retriever 分别与 Elasticsearch 和 Qdrant 交互检索文档;嵌入模型用于向量生成供 Qdrant 存储与检索;ChatClient 封装 LLM 模型调用(本地或云);最终将检索到的相关文档作为提示上下文,交由 LLM 生成答案并返回给用户。)

核心模块设计

向量存储与混合检索模块

Qdrant 向量存储配置:系统使用 Qdrant 来持久化文档向量及元数据。通过引入依赖 spring-ai-qdrant-store,Spring AI 能够自动配置 Qdrant 相关的 Bean。我们需要提供 Qdrant 客户端以及 VectorStore Bean,例如:

@Bean
public QdrantClient qdrantClient() {
    // 构建连接到 Qdrant 的 gRPC 客户端
    QdrantGrpcClient.Builder grpcClientBuilder = 
        QdrantGrpcClient.newBuilder("qdrant-host", 6334, false);
    grpcClientBuilder.withApiKey("<QDRANT_API_KEY>");
    return new QdrantClient(grpcClientBuilder.build());
}

@Bean
public VectorStore vectorStore(QdrantClient client, EmbeddingModel embeddingModel) {
    return QdrantVectorStore.builder(client, embeddingModel)
            .collectionName("documents")       // 指定集合名称
            .initializeSchema(true)            // 若未预建集合则自动创建
            .build();
}

上述配置创建了 VectorStore 接口的 Qdrant 实现,用于存储向量及执行相似度搜索。其中注入的 EmbeddingModel 用于在存储文档时生成其向量表示。注意:可以提前在 Qdrant 中创建集合并指定向量维度和相似度度量方式,或通过 initializeSchema(true) 由程序自动创建(默认使用 Cosine 距离,维度取决于所用嵌入模型)。确保 Qdrant 实例已启动并可访问,比如通过 Docker 启动 Qdrant 容器。

Elasticsearch 全文检索配置:Elasticsearch 用于存储文档文本索引,实现关键词匹配检索。可采用 Spring Data Elasticsearch 或 Rest API 进行集成。配置上,需要提供 Elasticsearch 的地址(例如 localhost:9200)、索引名称(如 documents)以及索引 mapping(包含文档 ID、内容等字段)。为了简化,实现上可以利用 Spring Data Elasticsearch 定义一个文档实体及对应的 Repository。例如定义 DocumentEntity(id, content, metadata) 并建立全文索引。

文档索引结构:为了方便混合检索结果的合并,我们为每个文档片段分配唯一 ID,并在 Qdrant 和 Elasticsearch 中共享该 ID 作为主键。每份文档在入库时通常会被拆分成若干片段 (chunks),每个片段是一段适合检索和上下文长度的文本。我们将每个片段作为独立的 Document 进行索引和向量存储。这样在检索阶段,无论通过向量还是关键词找到某片段,都可以根据 ID 确定对应的内容。可以将文档内容存储在 Elasticsearch 索引中,而在 Qdrant 中主要存储向量和必要的元信息(如文档 ID、来源等)作为 payload。

Hybrid Retriever 实现:创建一个混合检索器,例如 HybridRetriever implements DocumentRetriever。其 retrieve(Query query) 方法内部执行以下步骤:

 List<Document> vectorDocs = vectorStore.similaritySearch(
       SearchRequest.builder()
           .query(userQuery)
           .topK(5)
           .build()
 );

这将返回与查询最相似的 5 个文档片段。

通过 HybridRetriever,我们将语义匹配和精确匹配结合,最大程度找到相关内容。例如,当用户查询中包含与文档不同的措辞时,向量检索可找到语义相似的内容,而关键词检索可保证若有精确匹配的术语不会被遗漏。

LLM 推理与切换模块

ChatClient 与 ChatModel 抽象:Spring AI 提供了 ChatModel 接口表示具体的大语言模型(如 OpenAI ChatGPT、Anthropic Claude、本地 Llama 等),ChatClient 则封装了与这些模型交互的通用客户端。本服务通过注入一个 ChatClient 实例来调用 LLM 进行回答生成。得益于 ChatClient 的抽象设计,我们可以灵活地更换背后的 ChatModel,而业务代码保持不变。例如,可以在配置中切换使用 OpenAI 的 GPT-4 模型或本地的 Llama2 模型。

1. OpenAI 模型集成:若使用 OpenAI 云服务,添加依赖 spring-ai-openai,并在应用配置中提供 OpenAI 的 API 密钥和所选模型名称。例如在 application.yml 中配置:

spring:
  ai:
    openai:
      api-key: YOUR_OPENAI_API_KEY
      chat:
        model: gpt-4

这将启用 Spring AI 对 OpenAI Chat API 的自动配置。注入的 ChatClient 将默认使用 OpenAI 指定的模型。通过 ChatClient 的 fluent API,可以很方便地发送对话消息并获取回复。例如:

ChatResponse response = chatClient.prompt()
    .user("请解释RAG的作用")
    .call()
    .chatResponse();
String answer = response.content();

上述调用会将用户消息发送到配置的 OpenAI 模型并获得回答。

2. 本地 Ollama 模型集成:若采用本地 LLM 模型,需安装并运行 Ollama 服务。Ollama 可以管理和提供本地模型的推理服务,并通过 REST 接口与应用交互。引入依赖 spring-ai-ollama 后,Spring AI 提供了 OllamaChatModel 和 OllamaApi 来对接本地 Ollama 实例。配置上,可在 application.yml 中指定 Ollama 服务地址及所用模型,例如:

spring:
  ai:
    ollama:
      base-url: http://localhost:11434
      default-model: llama2

假设已通过"ollama pull"命令下载了名称为"llama2"的模型,Ollama 将以该模型提供推理。应用启动时,会自动创建连接到 Ollama 的 API 客户端 (OllamaApi) 和对应的 ChatModel 实现。ChatClient 随即可使用本地模型进行对话。

使用 Ollama 时,调用方式与 OpenAI 类似,只是由本地服务产生结果。例如:

ChatResponse response = chatClient.prompt()
    .system("你是资深Java助理")   // 可以设定初始系统提示
    .user("给出RAG搜索服务的优势")
    .call()
    .chatResponse();

ChatClient 将向本地 Ollama 发送请求并获取模型回复。

3. 推理方式切换:为了在本地模型和云 API 之间切换,设计上可以使用 Spring Profiles 或配置开关。例如定义 llm.mode 配置项,可取值 local 或 openai。通过条件配置,在 local 模式下注入 Ollama 的 ChatClient Bean,在 openai 模式下注入 OpenAI 的 ChatClient Bean。借助 Spring AI,对不同 ChatModel 的支持是开箱即用的,开发者只需更改配置即可无缝切换 LLM 提供方,大大减少切换带来的代码改动。

文本嵌入生成模块

1. HuggingFace 本地嵌入:为了确保数据安全和降低依赖,本方案支持使用本地 Embedding 模型。利用 Spring AI 的 Transformer ONNX 支持,我们可以在 Java 中直接加载 HuggingFace 提供的 embedding 模型(转换为 ONNX 格式)并生成向量。例如选择 sentence-transformers/all-MiniLM-L6-v2 模型,该模型输出 768 维句向量。首先将 ONNX 模型文件和 tokenizer 文件准备好(可通过 Spring AI 自动下载缓存)。然后配置 EmbeddingModel Bean:

@Bean
public EmbeddingModel embeddingModel() {
    TransformersEmbeddingModel model = new TransformersEmbeddingModel();
    // 可选:指定模型和分词器资源路径或URL(否则使用默认的all-MiniLM-L6-v2)
    model.setModelResource("classpath:/onnx/all-MiniLM-L6-v2/model.onnx");
    model.setTokenizerResource("classpath:/onnx/all-MiniLM-L6-v2/tokenizer.json");
    return model;
}

上述 TransformersEmbeddingModel 会加载本地 ONNX 模型,在调用 embed() 时对输入文本列表输出对应的向量表示。作为 Spring Bean 时,Spring AI 会自动调用其 afterPropertiesSet() 完成初始化。第一次使用时模型文件可能从指定位置加载或下载并缓存。之后即可通过 embeddingModel.embed(List texts) 获取嵌入向量。

如果使用 Ollama 作为本地引擎,另一种方法是利用 Ollama 的 Embedding API。Spring AI 提供了 OllamaEmbeddingModel 封装 Ollama 的向量生成接口。通过 pull 相应的嵌入模型(Ollama 支持直接 pull HuggingFace 上的 embedding 模型,如 ollama pull hf.co/intfloat/e5-small-v2),然后:

@Bean
public EmbeddingModel embeddingModel(OllamaApi ollamaApi) {
    // 假设使用一个名为'e5-small-v2'的embedding模型
    var options = OllamaOptions.builder().model("e5-small-v2").build();
    return new OllamaEmbeddingModel(ollamaApi, options);
}

此方式下,所有文本向量将由 Ollama 本地服务生成,效果等同于直接使用 HuggingFace 模型。

2. OpenAI Embedding API:如需借助 OpenAI 的预训练模型快速获取嵌入,可以使用 OpenAI 提供的 Embedding 接口(例如 text-embedding-ada-002 模型)。Spring AI 的 OpenAiEmbeddingModel 封装了调用逻辑,只需提供 API 密钥和可选的模型名称即可使用。例如:

@Bean
public EmbeddingModel embeddingModel() {
    return new OpenAiEmbeddingModel(new OpenAiApi(System.getenv("OPENAI_API_KEY")));
}

默认情况下,将使用 Ada 模型生成 1536 维的嵌入向量。这种方式适合对接 OpenAI 强大的向量质量,但需考虑网络延迟和调用成本。

3. 嵌入模式切换:和 LLM 类似,可通过配置切换嵌入模型来源。比如配置 embedding.mode 为 local 或 openai。当为 local 时,启用 TransformersEmbeddingModel 或 OllamaEmbeddingModel;为 openai 时,则使用 OpenAiEmbeddingModel。开发时可以提供默认实现,并允许通过配置文件修改。例如默认采用本地模型,若用户提供了 OpenAI 的 apiKey 则自动改用 OpenAI 嵌入服务。

无论哪种实现,我们的 VectorStore 在初始化时都会绑定一个 EmbeddingModel,用于在向量数据库中存储和检索时转换文本。例如,上述配置的 embeddingModel 将被注入到 QdrantVectorStore 中,当调用 vectorStore.add(documents) 时,每个 Document 的文本内容会经由 EmbeddingModel 转为向量并存入 Qdrant。

数据流程说明

文档索引流程 (ETL 离线入库)

  1. 文档获取与预处理:通过管理后台或批处理任务,获取需要纳入知识库的原始文档。文档格式可以是纯文本、PDF、Markdown 等。使用 Spring AI 的文档读取器将文档转换为文本内容,并按段落或固定长度对文本进行分块,生成文档片段列表。每个片段可以封装为 Spring AI 的 Document 对象,其中包含内容文本和元数据(如文档 ID、标题、来源等)。

  2. 嵌入向量生成:针对每个文档片段,调用 EmbeddingModel 生成其语义向量表示。例如利用本地模型将每个片段转换为 768 维向量。向量通常存于 Document 对象的 embedding 字段,或直接在插入向量数据库时计算。这展示了从文档到向量的转换过程:首先将文档分割为小块,然后使用嵌入模型将每个块映射为高维向量表示,捕捉语义信息。

  3. 向量存储入库:调用 vectorStore.add(documents) 方法,将文档片段批量插入 Qdrant 向量数据库。Spring AI 的 VectorStore 接口对底层数据库执行插入操作,并自动处理向量数据和元信息存储。例如对于 Qdrant,每个 Document 的 embedding 向量和附加 payload 会存入指定 collection 中。如果 collection 尚不存在且 initializeSchema=true,VectorStore 实现会自动创建集合(相应的维度和索引参数)。插入成功后,文档的语义表示就持久化在 Qdrant 中了,可用于相似检索。

  4. 全文索引入库:将文档片段索引到 Elasticsearch。对于每个 Document 对象,从中提取必要字段(如 id、content、metadata),调用 Elasticsearch 的索引 API 存储。若使用 Spring Data Elasticsearch,可以调用 Repository 的 save() 方法批量保存片段实体。如果直接使用 Elasticsearch REST API,则构造批量索引请求。每个片段将成为 Elasticsearch 中一条文档记录,内容字段做全文检索索引处理。可以考虑在 content 字段上应用 Elasticsearch 自带的中文分词(如果内容为中文)以提高检索效果。

  5. 索引完成:经过以上过程,知识库文档被有效地存储为向量库+全文索引的混合形式:Qdrant 持有每个片段的向量及元数据,Elasticsearch 持有片段的可检索文本。后续查询即可利用这两种存储提供的能力。

注意:文档入库流程可以离线批处理,也可以通过提供 API 让用户动态上传文档。在本设计中,可以实现一个 /documents/load 接口(或使用 Stackademic 博文中的 /load 概念),接受文件上传请求,服务接收文件后执行上述步骤完成索引。同时返回状态或结果给客户端。为简化,此接口细节在此不展开。

在线检索问答流程

  1. 用户查询输入:用户通过前端或 API 调用发送查询请求给 RAG 服务(例如 HTTP GET/POST /ask?question=...)。查询内容为自然语言问题,例如:"这个系统如何支持本地模型?"。Controller 层接收请求后,将查询字符串封装为 Query 对象或直接传给服务层。

  2. 混合检索:RAG 服务调用 HybridRetriever 来从知识库中检索相关信息:

    • Elasticsearch 关键词检索:对 Elasticsearch 执行查询,可使用 match 或 multi_match 在 content 字段搜索用户问题中的关键词。如果有匹配,返回相关度最高的前 N 个片段,例如 N=5。
    • Qdrant 向量检索:将用户问题字符串通过 EmbeddingModel 编码为向量查询,调用 vectorStore.similaritySearch() 获取相似度最高的前 M 个片段,例如 M=5。可以设置一定的相似度阈值,忽略低于阈值的结果。
    • 结果融合:将上两步获得的片段集合并集成。假定我们获取了最多 10 个相关片段。可对这些片段按重要性简单排序(比如根据是否包含关键词将 Elasticsearch 结果靠前),或不排序直接传递给 LLM。在本设计中,我们将片段文本直接作为上下文传递,因此顺序影响不大,但可以将更相关的放在前面以提高回答质量。
  3. 构造提示 (Prompt):将用户问题和检索到的文档片段组装成提示内容传给 LLM。通常采用一个预定义的 Prompt 模板,例如:

请根据以下文档内容回答问题。如果无法从中找到答案,请回复“未找到相关信息”。

文档内容:
{{{context}}}

问题: {{{question}}}

答案:

其中 {{{context}}} 占位符由检索到的多个文档片段文本拼接填充,{{{question}}} 为用户原始问题。这样生成的完整提示提供了问题所需的参考信息。此步骤可以使用 Spring AI 的 PromptTemplate 辅助完成,也可手动拼接字符串。这提到在查询阶段可使用 PromptTemplate 插入检索到的内容并渲染最终提示,然后发送给模型。

Spring AI 也支持直接使用 QuestionAnswerAdvisor 简化这一过程。如果使用 ChatClient.prompt().advisors(new QuestionAnswerAdvisor(vectorStore)),它会自动在调用模型前查询向量库并附加结果。但由于我们实现了自定义的 HybridRetriever(包含 Elasticsearch),我们将自行构造 prompt,以便加入两种检索结果。

  1. LLM 生成回答:调用 LLM 模型生成答案。通过前述配置好的 ChatClient 来执行:
String promptText = promptTemplate.render(Map.of("context", combinedDocsText, "question", userQuestion));
ChatResponse response = chatClient.prompt(promptText).call().chatResponse();
String answer = response.content();

ChatClient 会将我们提供的完整提示发送给底层 ChatModel(OpenAI 或 Ollama)。模型接收到含有参考内容的提问后,基于提供的上下文进行作答,从而减小幻觉和出错的概率。生成的答案通过 ChatResponse 返回,提取其内容字符串,即为最终解答。

  1. 结果返回:将 LLM 的回答字符串封装为响应,通过 Controller 返回给调用方。可以只返回答案文本,或者包含一些元信息(如引用的文档来源列表等)。本设计聚焦核心功能,假定仅返回答案文本。在前端界面上,用户即可看到根据其提问生成的回答。

  2. 对话记忆(可选):如果需要多轮对话支持,可结合 Spring AI 的 ChatMemory 功能,将之前的问题和答案作为对话历史提供给 ChatClient,以实现上下文记忆。但基本的 RAG 问答场景可不考虑对话历史,每次独立进行。

整个查询流程在用户看来只是提供问题、获得答案,但背后经过了关键词和语义双通道检索,再由大模型综合分析文档给出结果,从而实现"以知识库为基础的问答"。这种 RAG 技术有效缓解了 LLM 上下文长度限制、知识截止和幻觉问题。

关键组件与接口定义

配置与组件摘要

主要依赖:项目引入以下关键依赖:

应用配置:通过 application.yml 管理上述模式切换参数,例如:

app:
  llm: mode: local        # 本地(local)或openai
  embedding: mode: local  # 本地(local)或openai
spring:
  ai:
    # OpenAI 配置(仅当使用openai模式)
    openai:
      api-key: xxxx
      chat.model: gpt-4
    # Ollama 配置(仅当使用local模式)
    ollama:
      base-url: http://ollama:11434   # 假设Docker容器内服务名
      default-model: llama2-7b
    # Qdrant 向量库配置
    vectorstore:
      qdrant:
        collection-name: documents
        initialize-schema: true

上例展示了自定义的 app 配置段用于控制模式,以及 Spring AI 自带的一些配置项。通过 profiles 或条件注入,可以根据 app.llm.mode 选择不同的 ChatModel 配置。

关键 Bean:汇总本设计涉及的重要 Spring Bean 及其配置方式:

@Bean
@ConditionalOnProperty(name="app.llm.mode", havingValue="openai")
ChatClient chatClientOpenAI(OpenAiChatModel model) {
    return ChatClient.builder(model).build();
}

@Bean
@ConditionalOnProperty(name="app.llm.mode", havingValue="local")
ChatClient chatClientOllama(OllamaChatModel model) {
    return ChatClient.builder(model).build();
}

上述伪代码表明根据配置选择不同的 ChatModel 创建 ChatClient。实际上若使用 Spring Boot Starter 和配置文件,大部分情况直接 @Autowired ChatClient 即可(底层已根据配置选好了模型)。

RagService 接口定义

RagService 可以定义如下接口以供 Controller 调用:

public interface RagService {
    /** 根据用户问题返回答案 */
    String getAnswer(String question);
}

其实现类 RagServiceImpl 负责按照在线检索问答流程的步骤完成工作:

@Service
public class RagServiceImpl implements RagService {
    @Autowired private HybridRetriever retriever;
    @Autowired private ChatClient chatClient;
    @Value("${app.maxDocs:6}") private int maxDocs;

    @Override
    public String getAnswer(String question) {
        // 1. 调用混合检索获取相关文档片段列表
        List<Document> docs = retriever.retrieve(new Query(question));
        if (docs.isEmpty()) {
            return "抱歉,未能找到相关信息。";
        }
        // 截取最多maxDocs篇,以防过长
        List<Document> topDocs = docs.size() > maxDocs ? docs.subList(0, maxDocs) : docs;
        // 2. 构造提示上下文文本
        StringBuilder context = new StringBuilder();
        for (Document doc : topDocs) {
            context.append(doc.getContent()).append("\n");
        }
        String prompt = String.format("基于以下内容回答问题:\n%s\n问题:%s\n回答:", context, question);
        // 3. 调用LLM生成回答
        ChatResponse response = chatClient.prompt(prompt).call().chatResponse();
        return response.content();
    }
}

上述代码中:

通过这种实现,RagService 对外提供了一个高层接口,隐藏了内部复杂性,Controller 只需传入问题字符串即可获得答案。

示例 Controller

最后,提供一个示例控制器来演示接口定义和调用流程:

@RestController
@RequestMapping("/api")
public class QaController {
    @Autowired
    private RagService ragService;

    // 提问接口
    @GetMapping("/ask")
    public ResponseEntity<String> askQuestion(@RequestParam("q") String question) {
        String answer = ragService.getAnswer(question);
        return ResponseEntity.ok(answer);
    }

    // 文档上传接口(可选,实现文档入库)
    @PostMapping(value="/documents", consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
    public ResponseEntity<String> uploadDocument(@RequestPart("file") MultipartFile file) {
        // 调用文档处理服务将文件内容存入ES和Qdrant
        // DocumentLoader.load(file);
        return ResponseEntity.ok("文档已上传并索引完成");
    }
}

通过上述 Controller,前端或用户可以通过 HTTP 请求与 RAG 服务交互,实现动态问答。

Docker Compose 环境部署方案

为方便开发和部署,提供 Docker Compose 配置同时启动所需的外部服务(Qdrant、Elasticsearch、Ollama)和本 Spring Boot 应用。本项目的 docker-compose.yml 示例如下:

version: '3.9'
services:
  elasticsearch:
    image: docker.elastic.co/elasticsearch/elasticsearch:8.9.0
    container_name: elasticsearch
    environment:
      - discovery.type=single-node
      - xpack.security.enabled=false       # 禁用安全认证
      - ES_JAVA_OPTS=-Xms1g -Xmx1g         # 内存配置,可根据需要调整
    ports:
      - "9200:9200"
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:9200"] 
      interval: 30s
      retries: 3

  qdrant:
    image: qdrant/qdrant:v1.3.5
    container_name: qdrant
    ports:
      - "6333:6333"   # HTTP API
      - "6334:6334"   # gRPC API
    volumes:
      - qdrant_data:/qdrant/storage

  ollama:
    image: ollama/ollama:0.1.9
    container_name: ollama
    ports:
      - "11434:11434"  # Ollama REST API 默认端口
    volumes:
      - ollama_data:/root/.ollama

  rag-service:
    build: .
    container_name: rag-service
    environment:
      SPRING_PROFILES_ACTIVE: "local"          # 激活本地LLM配置。如切换OpenAI则设为cloud等。
      OPENAI_API_KEY: "${OPENAI_API_KEY:-}"    # 可选,提供OpenAI密钥
      QDRANT_GRPC_HOST: "qdrant"               # 若应用读取环境配置连接Qdrant
      QDRANT_GRPC_PORT: 6334
    depends_on:
      - elasticsearch
      - qdrant
      - ollama
    ports:
      - "8080:8080"

说明:

网络配置:Compose 默认会将这些服务置于同一网络下,容器名称就是主机名。因此应用在连接 Qdrant 和 Elasticsearch 时,可以使用 qdrant:6334、elasticsearch:9200 作为地址。Spring AI Docker Compose 集成模块甚至可以根据容器名自动发现服务并配置连接,例如名字包含"ollama/ollama"的容器会被识别为 Ollama 服务。

启动:运行 docker-compose up -d 将后台启动所有容器。待 Elasticsearch 和 Qdrant 完成启动(可通过健康检查日志或 API 验证),Spring Boot 应用会自动连接它们。此时:

通过 Docker Compose,一键部署整个 RAG 系统的依赖,方便在本地或服务器上运行测试。

总结

本设计文档详细描述了一个基于 Java Spring 生态的 RAG 搜索服务方案。通过 Spring AI 框架提供的抽象和集成能力,我们实现了 Retriever-VectorStore-RAG Service 的模块化架构,支持混合检索、灵活的 LLM/Embedding 切换以及容器化部署。核心技术选型包括 Spring Boot、Spring AI、Elasticsearch、Qdrant 和 Ollama 等,均为当下流行且有良好支持的组件。文档提供了整体架构和数据流程解析,给出了关键配置和代码示例,确保实现细节清晰可行。开发者可以据此搭建起一个可复现的 RAG 问答系统,在保证回答准确性的同时兼顾部署灵活性和数据私有性,为企业知识库问答、智能搜索等应用场景提供有力支持。

© 2026 Yuxu Ge ·