Spring AI + Elasticsearch 轻松实现 RAG

引言 在当今人工智能应用快速发展的时代,.

引言

在当今人工智能应用快速发展的时代,检索增强生成(Retrieval-Augmented Generation,简称RAG)已成为构建智能问答系统的核心技术之一。RAG通过将企业私有知识库与大语言模型相结合,能够有效解决模型”幻觉”问题,提供更加准确和上下文相关的回答。

Spring AI作为Spring生态系统中的人工智能框架,最近引入了Elasticsearch作为向量存储的功能支持。Elastic团队也为该项目贡献了重要的优化代码。本文将详细介绍如何使用Spring AI和Elasticsearch构建一个完整的RAG Java应用,帮助开发者快速上手这一强大的技术组合。

通过本教程,你将学习如何配置Maven项目、设置必要的依赖项、将Elasticsearch集成作为向量存储,以及如何读取和分词PDF文档、将其发送到Elasticsearch,并使用AI模型进行智能问答。让我们开始这段技术探索之旅。

技术预览声明

需要特别说明的是,spring-ai-elasticsearch-store目前仍处于技术预览阶段,后续可能会有功能调整。因此,建议读者在正式版本发布之前,不要将本文提供的代码直接应用于生产环境。

前置条件

在开始本教程之前,请确保你的开发环境满足以下要求:

  • Elasticsearch版本:8.14.0或更高版本
  • Java版本:17或更高版本
  • 大语言模型:任何SpringAI支持的LLM模型,完整的支持列表可参考官方文档

实战场景:Runewars游戏规则助手

为了更好地展示RAG的实际应用价值,我们将以一个具体的场景作为切入点。Runewars是一款由Fantasy Flight Games发行的桌游,其规则手册共有40页,内容相当详尽和复杂。对于已经一段时间没有玩这款游戏的玩家来说,重新翻阅规则手册是一件耗时且繁琐的事情。

让我们首先测试一下直接向ChatGPT(GPT-4o版本)询问游戏规则的效果:

ChatGPT回答示例

可以看到,ChatGPT给出的回答不仅泛泛而谈,而且存在事实错误——奖励卡实际上必须对其他玩家保密。这清楚地表明,通用的大语言模型并不了解这款游戏的详细规则。

现在,我们通过RAG技术来增强模型的能力,让它能够基于游戏手册提供准确的答案。

项目目标

本教程的目标是构建一个AI聊天机器人,它能够:

  • 准确回答与Runewars游戏规则相关的问题
  • 在回答中标注信息来源的手册页码

完整项目代码已开源,托管在GitHub上,读者可以参考对照。

项目配置

我们将使用Apache Maven作为构建工具来创建Java项目。首先配置POM文件,从Spring AI的BOM(Bill of Materials)开始导入依赖管理。

Spring Boot自动配置

我们将依赖Spring Boot的自动配置功能来设置所需的Bean对象。这种方式可以大幅简化配置工作,让框架自动完成组件的初始化和注入。

Elasticsearch与模型依赖

接下来添加Elasticsearch向量存储模块和嵌入模型的依赖。以OpenAI为例,我们需要添加相应的依赖项。如果你选择使用其他大语言模型,只需替换对应的依赖配置即可。

PDF文档处理依赖

为了读取和处理游戏手册的PDF文件,我们还需要添加Spring AI提供的PDF解析依赖。这个组件能够将PDF内容转换为可处理的文本格式。

完整的POM文件配置可以从项目仓库获取。

Bean配置详解

在Spring框架中运行本应用所需的Bean对象可以通过自动注入(Autowired)来获取。对于本教程的场景,我们不需要创建自定义的Bean配置,因为Spring Boot的自动配置已经为我们处理了大部分工作。

唯一需要我们手动完成的是在application.properties文件中提供必要的配置信息,包括Elasticsearch的连接地址、API密钥,以及所选大语言模型的认证凭据。

配置参数说明

当这些属性正确配置后,Spring框架会自动选择合适的向量存储实现和嵌入/聊天模型类。如果你使用的LLM与OpenAI不同,可能需要通过spring.ai.vectorstore.elasticsearch.dimensions属性来设置对应的向量维度。例如,OpenAI的向量维度为1536,这也是默认值,因此无需额外配置。

关于Elasticsearch向量存储的所有可配置参数,建议读者参考官方文档获取详细信息。

服务层实现

服务层是应用的核心逻辑所在。我们需要创建一个Service类,在其中注入向量存储和聊天客户端的Bean对象。该服务类将包含两个核心方法:

  • 文档摄入方法:读取指定路径的PDF文件,将其转换为SpringAI的Document格式,然后发送到Elasticsearch向量存储
  • 问答查询方法:先从Elasticsearch检索与问题相关的文档,再将这些文档提供给大语言模型,以获得准确且有上下文的回答

文档摄入与向量化

让我们首先实现文档摄入功能:

@Service
public class DocumentService {
    
    private final VectorStore vectorStore;
    private final EmbeddingModel embeddingModel;
    
    public DocumentService(VectorStore vectorStore, EmbeddingModel embeddingModel) {
        this.vectorStore = vectorStore;
        this.embeddingModel = embeddingModel;
    }
    
    public void loadDocument(String pdfPath) {
        var pdfReader = new PdfPdfReader(pdfPath);
        var document = pdfReader.get();
        
        var textSplitter = new TokenTextSplitter();
        var splitDocuments = textSplitter.apply(document);
        
        vectorStore.add(splitDocuments);
    }
}

细心的读者可能已经注意到,在文档被发送到向量存储之前,经历了一个”分词”(Tokenization)过程。SpringAI提供的TokenTextSplitter类负责将文本切分成更小的片段,每个片段大约包含800个字符。这种处理方式被称为”分块”(Chunking),其目的是将长文档拆分成LLM能够高效处理的小块内容。

这看起来似乎只是在发送字符串到数据库,但实际上,在Spring框架优雅的抽象之下,大量复杂的工作正在后台默默进行:文档首先被发送到嵌入模型进行向量化处理,将文本内容转换为数值向量表示。这些带有向量索引的文档随后被存入Elasticsearch向量数据库,该数据库针对这类数据的摄入和查询进行了专门优化,能够提供卓越的检索性能。

语义检索与问答

接下来实现查询功能:

@Service
public class DocumentService {
    
    private final VectorStore vectorStore;
    private final ChatClient chatClient;
    
    public DocumentService(VectorStore vectorStore, ChatClient.Builder chatClientBuilder) {
        this.vectorStore = vectorStore;
        this.chatClient = chatClientBuilder.build();
    }
    
    public String query(String question) {
        var documents = vectorStore.similaritySearch(question);
        
        String context = documents.stream()
            .map(Document::getContent)
            .collect(Collectors.joining("\n\n"));
            
        var prompt = """
            请根据以下上下文回答问题。如果上下文中没有相关信息,请明确说明。
            
            上下文:
            %s
            
            问题:%s
            """.formatted(context, question);
            
        return chatClient.call(prompt);
    }
}

查询方法的执行流程如下:首先,用户提出的问题被发送到Elasticsearch向量存储,系统执行相似度搜索(更准确地说,是KNN搜索)。

KNN搜索的核心原理是将问题的向量表示与存储在数据库中的文档向量进行比对,找出在向量空间中距离最近的文档返回。这里的”距离”实际上反映了语义相似度——距离越近,表示文档内容与问题的相关性越高。

在代码中,我们设置了withSimilarityThreshold(0.6)参数来控制返回结果的严格程度。这个值的范围从0到1,0表示接受所有结果,1表示只返回完全匹配的内容。设置为0.6是为了在保证答案准确性的同时,避免因过于严格而遗漏相关信息。

此外,考虑到游戏手册的内容特点——每个规则通常只在一个地方详细说明——我们通过withTopK(5)参数限制返回最相关的5个文档片段,这样可以在保证召回率的同时控制上下文长度,避免超出模型的Token限制。

控制器层实现

为了便于测试和调用,我们创建一个基础的REST控制器来暴露服务功能:

@RestController
@RequestMapping("/api/rag")
public class RagController {
    
    private final DocumentService documentService;
    
    public RagController(DocumentService documentService) {
        this.documentService = documentService;
    }
    
    @PostMapping("/load")
    public ResponseEntity loadDocument(@RequestParam String path) {
        documentService.loadDocument(path);
        return ResponseEntity.ok("文档加载成功");
    }
    
    @GetMapping("/query")
    public ResponseEntity query(@RequestParam String question) {
        String answer = documentService.query(question);
        return ResponseEntity.ok(answer);
    }
}

这个控制器提供了两个端点:/api/rag/load用于加载PDF文档,/api/rag/query用于提出问题并获取回答。通过简单的HTTP请求,你就可以与RAG系统进行交互。

运行Elasticsearch

启动Elasticsearch服务是运行本应用的前提条件。有两种便捷的方式可供选择:

Elasticsearch Cloud云服务

最快捷的方式是使用Elasticsearch Cloud云服务。注册后,系统会引导你完成实例创建和配置,整个过程简单直观,无需本地环境配置。

本地Docker运行

如果你更倾向于本地开发,可以使用start-local脚本。该脚本利用Docker容器快速搭建完整的Elasticsearch和Kibana环境:

curl -fsSL https://elastic.co/start-local | sh

执行这条命令后,你将拥有一个完整的本地Elasticsearch实例,可以随时进行开发和测试。

运行应用程序

所有代码准备工作已经完成。现在启动应用程序,默认端口为8080。我们可以使用curl命令进行测试:

# 加载游戏手册文档
curl -X POST "http://localhost:8080/api/rag/load?path=/path/to/runewars_manual.pdf"

# 询问游戏规则问题
curl "http://localhost:8080/api/rag/query?question=奖励卡应该如何处理"
}

需要注意的是,文档的向量化是一个计算密集型操作。如果你使用的是功能较弱的大语言模型,这个过程可能需要一些时间,请耐心等待。

现在,让我们回顾一下最初提出的问题,看看RAG系统的表现:

令人印象深刻,不是吗?一个精通Runewars复杂规则的智能助手已经准备就绪,能够准确回答你关于游戏规则的各种问题。

扩展:使用Ollama本地模型

SpringAI的抽象设计使得切换不同的语言模型变得非常简单。如果你希望使用本地运行的Ollama模型,只需进行以下配置调整。

更新Maven依赖

在POM文件中添加Ollama相关依赖,替换原有的OpenAI依赖。

修改配置文件

更新application.properties中的配置项:

spring.ai.ollama.base-url=http://localhost:11434
spring.ai.embedding.model=mxbai-embed-large
spring.ai.chat.model=llama3.2
spring.ai.ollama.pull-model-strategy=never

pull-model-strategy属性设置为never可以避免自动拉取模型,如果你已经预先配置好所有模型,这可以节省启动时间。另外,请注意检查并设置正确的向量维度——例如,Ollama默认的嵌入模型mxbai-embed-large的向量维度为1024。

完成上述配置更改后,其余代码无需任何修改。需要特别提醒的是,更换嵌入模型意味着你必须使用新的向量维度重新创建Elasticsearch索引,因为不同模型生成的向量是不兼容的。

总结

通过本教程的完整实践,你应该已经掌握了使用Spring AI和Elasticsearch构建RAG应用的核心技能。整个过程的复杂度与构建一个基础的Spring CRUD应用相当,但功能却强大得多。

我们涵盖了以下关键知识点:

  • Spring AI项目的Maven依赖配置和BOM管理
  • Elasticsearch向量存储的集成和自动配置
  • PDF文档的读取、分块和向量化处理
  • 基于相似度检索的语义搜索实现
  • 大语言模型的提示词工程和上下文构建
  • 不同LLM提供商的切换和配置方法

完整项目代码已托管在GitHub,如有任何问题或建议,欢迎在Elastic Discuss论坛与我们交流。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注