1. 概述

AI 技术已成为现代开发的核心技能。本文将构建一个 RAG Wiki 应用,它能基于存储的文档回答用户问题

我们将使用 Spring AI 集成 MongoDB 向量数据库 和 LLM。

2. RAG 应用

当自然语言生成需要依赖上下文数据时,我们使用 检索增强生成(RAG) 应用。RAG 应用的核心组件是 向量数据库,它在高效管理和检索数据中扮演关键角色

RAG 应用中的文档填充和提示流程

我们使用 嵌入模型 处理源文档。该模型将文档文本转换为高维向量。这些向量捕获内容的语义含义,使我们能基于上下文而非仅关键词匹配来比较和检索相似内容。然后将文档存储在向量存储中。

保存文档后,我们可以通过以下方式基于它们发送提示:

  1. 首先用嵌入模型处理问题,将其转换为捕获语义含义的向量
  2. 接着执行相似性搜索,将问题向量与向量存储中的文档向量比较
  3. 从最相关文档中构建问题的上下文
  4. 最后将问题和上下文发送给 LLM,生成与查询相关且被上下文丰富化的回答

3. MongoDB Atlas 向量搜索

本教程使用 MongoDB Atlas Search 作为向量存储。它提供的 向量搜索 功能能满足项目需求。为测试环境设置本地 MongoDB Atlas Search 实例,我们使用 mongodb-atlas-local Docker 容器。创建 docker-compose.yml 文件:

version: '3.1'

services:
  my-mongodb:
    image: mongodb/mongodb-atlas-local:7.0.9
    container_name: my-mongodb
    environment:
      - MONGODB_INITDB_ROOT_USERNAME=wikiuser
      - MONGODB_INITDB_ROOT_PASSWORD=password
    ports:
      - 27017:27017

4. 依赖与配置

首先添加必要依赖。由于应用提供 HTTP API,引入 spring-boot-starter-web 依赖

<dependency>
    <groupId>org.springframework.boot</groupId> 
    <artifactId>spring-boot-starter-web</artifactId>
    <version>LATEST_VERSION</version>
</dependency>

为连接 LLM 添加 OpenAI API 客户端 依赖

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-openai-spring-boot-starter</artifactId>
    <version>LATEST_VERSION</version>
</dependency>

最后添加 MongoDB Atlas Store 依赖

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-mongodb-atlas-store-spring-boot-starter</artifactId>
    <version>LATEST_VERSION</version>
</dependency>

现在添加应用配置属性:

spring:
  data:
    mongodb:
      uri: mongodb://wikiuser:password@localhost:27017/admin
      database: wiki
  ai:
    vectorstore:
      mongodb:
        collection-name: vector_store
        initialize-schema: true
        path-name: embedding
        indexName: vector_index
    openai:
      api-key: ${OPENAI_API_KEY}
      chat:
        options:
          model: gpt-3.5-turbo

我们指定了 MongoDB URL 和数据库名,并通过设置集合名称、嵌入字段名和向量索引名配置向量存储。得益于 initialize-schema 属性,Spring AI 框架会自动创建这些资源

最后添加 OpenAI API 密钥模型版本

5. 将文档保存到向量存储

现在添加数据保存流程。应用将基于现有文档回答用户问题——本质上充当 Wiki。

添加存储文件内容和路径的模型:

public class WikiDocument {
    private String filePath;
    private String content;

    // 标准 getter/setter
}

下一步添加 WikiDocumentsRepository。该仓库封装所有持久化逻辑:

import org.springframework.ai.document.Document;
import org.springframework.ai.transformer.splitter.TokenTextSplitter;

@Component
public class WikiDocumentsRepository {
    private final VectorStore vectorStore;

    public WikiDocumentsRepository(VectorStore vectorStore) {
        this.vectorStore = vectorStore;
    }

    public void saveWikiDocument(WikiDocument wikiDocument) {

        Map<String, Object> metadata = new HashMap<>();
        metadata.put("filePath", wikiDocument.getFilePath());
        Document document = new Document(wikiDocument.getContent(), metadata);
        List<Document> documents = new TokenTextSplitter().apply(List.of(document));

        vectorStore.add(documents);
    }
}

这里注入了 VectorStore 接口 Bean,由 spring-ai-mongodb-atlas-store-spring-boot-starter 提供的 MongoDBAtlasVectorStore 实现。在 saveWikiDocument 方法中,创建 Document 实例并填充内容和元数据。

然后使用 TokenTextSplitter 将文档分割为小块并保存到向量存储。现在创建 WikiDocumentsServiceImpl

@Service
public class WikiDocumentsServiceImpl {
    private final WikiDocumentsRepository wikiDocumentsRepository;

    // 构造函数

    public void saveWikiDocument(String filePath) {
        try {
            String content = Files.readString(Path.of(filePath));
            WikiDocument wikiDocument = new WikiDocument();
            wikiDocument.setFilePath(filePath);
            wikiDocument.setContent(content);

            wikiDocumentsRepository.saveWikiDocument(wikiDocument);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
}

在服务层读取文件内容,创建 WikiDocument 实例并交由仓库持久化。

控制器中简单传递文件路径到服务层,成功保存时返回 201 状态码:

@RestController
@RequestMapping("wiki")
public class WikiDocumentsController {
    private final WikiDocumentsServiceImpl wikiDocumentsService;

    // 构造函数

    @PostMapping
    public ResponseEntity<Void> saveDocument(@RequestParam String filePath) {
        wikiDocumentsService.saveDocument(filePath);

        return ResponseEntity.status(201).build();
    }
}

⚠️ 注意该接口的安全问题:用户可能通过此接口上传配置或系统文件等意外文件。解决方案是限制可上传文件的目录范围。现在启动应用测试流程。添加 Spring Boot 测试依赖以建立测试 Web 上下文:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <version>LATEST_VERSION</version>
</dependency>

启动测试应用实例并调用两个文档的 POST 接口:

@AutoConfigureMockMvc
@ExtendWith(SpringExtension.class)
@SpringBootTest
class RAGMongoDBApplicationManualTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void givenMongoDBVectorStore_whenCallingPostDocumentEndpoint_thenExpectedResponseCodeShouldBeReturned() throws Exception {
        mockMvc.perform(post("/wiki?filePath={filePath}",
          "src/test/resources/documentation/owl-documentation.md"))
          .andExpect(status().isCreated());

        mockMvc.perform(post("/wiki?filePath={filePath}",
          "src/test/resources/documentation/rag-documentation.md"))
          .andExpect(status().isCreated());
    }
}

两次调用均应返回 201 状态码,表示文档已添加。使用 MongoDB Compass 确认文档成功保存到向量存储:
MongoDB Compass 中存储的文档视图

如你所见——两个文档均已保存。可以看到原始内容和嵌入数组

6. 相似性搜索

添加相似性搜索功能。在仓库中添加 findSimilarDocuments 方法:

@Component
public class WikiDocumentsRepository {
    private final VectorStore vectorStore;

    public List<WikiDocument> findSimilarDocuments(String searchText) {

        return vectorStore
          .similaritySearch(SearchRequest
            .query(searchText)
            .withSimilarityThreshold(0.87)
            .withTopK(10))
          .stream()
          .map(document -> {
              WikiDocument wikiDocument = new WikiDocument();
              wikiDocument.setFilePath((String) document.getMetadata().get("filePath"));
              wikiDocument.setContent(document.getContent());

              return wikiDocument;
          })
          .toList();
    }
}

调用了 VectorStoresimilaritySearch 方法。除搜索文本外,还指定了结果限制和相似性阈值。相似性阈值参数用于控制文档内容与搜索文本的匹配程度

服务层代理仓库调用:

public List<WikiDocument> findSimilarDocuments(String searchText) {
    return wikiDocumentsRepository.findSimilarDocuments(searchText);
}

控制器添加 GET 接口接收搜索文本参数:

@RestController
@RequestMapping("/wiki")
public class WikiDocumentsController {
    @GetMapping
    public List<WikiDocument> get(@RequestParam("searchText") String searchText) {
        return wikiDocumentsService.findSimilarDocuments(searchText);
    }
}

调用新接口测试相似性搜索:

@Test
void givenMongoDBVectorStoreWithDocuments_whenMakingSimilaritySearch_thenExpectedDocumentShouldBePresent() throws Exception {
    String responseContent = mockMvc.perform(get("/wiki?searchText={searchText}", "RAG Application"))
      .andExpect(status().isOk())
      .andReturn()
      .getResponse()
      .getContentAsString();

    assertThat(responseContent)
      .contains("RAG AI Application is responsible for storing the documentation");
}

使用非完全匹配的搜索文本调用接口。但仍检索到相似内容的文档,并确认其包含 rag-documentation.md 文件中的文本

7. 提示接口

开始构建提示流程——应用的核心功能。首先创建 AdvisorConfiguration

@Configuration
public class AdvisorConfiguration {

    @Bean
    public QuestionAnswerAdvisor questionAnswerAdvisor(VectorStore vectorStore) {
        return new QuestionAnswerAdvisor(vectorStore, SearchRequest.defaults());
    }
}

创建 QuestionAnswerAdvisor Bean,负责构建提示请求(包括初始问题)。此外它会将向量存储的相似性搜索结果作为问题上下文附加。现在向 API 添加搜索接口:

@RestController
@RequestMapping("/wiki")
public class WikiDocumentsController {
    private final WikiDocumentsServiceImpl wikiDocumentsService;
    private final ChatClient chatClient;
    private final QuestionAnswerAdvisor questionAnswerAdvisor;

    public WikiDocumentsController(WikiDocumentsServiceImpl wikiDocumentsService,
                                   @Qualifier("openAiChatModel") ChatModel chatModel,
                                   QuestionAnswerAdvisor questionAnswerAdvisor) {
        this.wikiDocumentsService = wikiDocumentsService;
        this.questionAnswerAdvisor = questionAnswerAdvisor;
        this.chatClient = ChatClient.builder(chatModel).build();
    }

    @GetMapping("/search")
    public String getWikiAnswer(@RequestParam("question") String question) {
        return chatClient.prompt()
          .user(question)
          .advisors(questionAnswerAdvisor)
          .call()
          .content();
    }
}

通过向提示添加用户输入并附加 QuestionAnswerAdvisor 构建提示请求。

最后调用接口查看关于 RAG 应用的回答:

@Test
void givenMongoDBVectorStoreWithDocumentsAndLLMClient_whenAskQuestionAboutRAG_thenExpectedResponseShouldBeReturned() throws Exception {
    String responseContent = mockMvc.perform(get("/wiki/search?question={question}", "Explain the RAG Applications"))
      .andExpect(status().isOk())
      .andReturn()
      .getResponse()
      .getContentAsString();

    logger.atInfo().log(responseContent);

    assertThat(responseContent).isNotEmpty();
}

发送问题 "Explain the RAG applications" 并记录 API 响应:

b.s.r.m.RAGMongoDBApplicationManualTest : Based on the context provided, the RAG AI Application is a tool 
used for storing documentation and enabling users to search for specific information efficiently...

接口返回了基于向量数据库中存储的文档的 RAG 应用信息

现在尝试询问知识库中肯定不存在的内容:

@Test
void givenMongoDBVectorStoreWithDocumentsAndLLMClient_whenAskUnknownQuestion_thenExpectedResponseShouldBeReturned() throws Exception {
    String responseContent = mockMvc.perform(get("/wiki/search?question={question}", "Explain the Economic theory"))
      .andExpect(status().isOk())
      .andReturn()
      .getResponse()
      .getContentAsString();

    logger.atInfo().log(responseContent);

    assertThat(responseContent).isNotEmpty();
}

询问经济理论后得到响应:

b.s.r.m.RAGMongoDBApplicationManualTest : I'm sorry, but the economic theory is not directly related to the information provided about owls and the RAG AI Application.
If you have a specific question about economic theory, please feel free to ask.

这次应用未找到相关文档,也未使用其他来源提供答案

8. 总结

本文使用 Spring AI 框架成功实现了 RAG 应用,该框架是集成各种 AI 技术的绝佳工具。同时 MongoDB 被证明是处理向量存储的强大选择。

凭借这种强大组合,我们可以构建基于现代 AI 的各类应用,包括聊天机器人、自动化 Wiki 系统和搜索引擎

代码已发布在 GitHub


原始标题:Building a RAG App Using MongoDB and Spring AI | Baeldung