本章内容(This chapter covers)
- 检索增强生成(RAG,Retrieval-augmented generation)
- 启用向量存储(vector store)
- 创建文档加载流水线(document loading pipeline)
回想学生时代,你是否碰到过开卷考试?无论你掌握了多少内容、临考能“抱佛脚”到何种程度,只要知道考试时可以查阅资料,往往就更有把握答对问题。
现在再想象一下:不仅允许你翻书找答案,还明确告诉你答案在哪些页。知道“去哪里找”的信息几乎就确保了成功。
LLM 的训练涵盖了海量信息,但总会有一些问题超出其训练覆盖范围。若能把问题与包含答案的文档(更好的是一个小片段)配对,就不仅能帮助 LLM 更准确地作答,还能在面对超出训练边界的问题时,几乎消除幻觉(hallucinations) 。
本章将介绍检索增强生成(RAG) :在你发问的同时,动态给 LLM 提供相关信息。先来了解 RAG 的工作原理。
4.1 理解 RAG(Understanding RAG)
在上一章里,你通过把 Burger Battle 的规则“塞进提示(stuff the prompt)”,为 LLM 提供了可查阅的上下文(类似开卷)。
Burger Battle 的规则书相对较小(约 1000+ tokens),放进提示不会吃掉太多上下文窗口。但有些游戏的规则更冗长,会占用更多上下文窗口;而你又希望能回答更多游戏的问题。把若干游戏的整本规则书都塞进一次提示是不现实的——而且大多数问题的答案只出现在极小的文档片段里。
RAG 的思路就是:把文档切成较小块,并且只把与提问相似(因而推测为相关)的少量块放进提示。图 4.1 展示了这个过程。
典型 RAG 系统包含三部分:
- 文档加载器(document loader) :负责把文档加载进向量存储。
- 向量存储(vector store) :存储文档,之后用于检索。
- 支持 RAG 的应用:向向量存储提交查询,找出与问题相似(推测相关)的文档片段。
用图 4.1 中编号的台球作引导,流程如下:
- 把任意大小的文档加载并切分为更小的片段。切分策略由你决定,常见做法是确保每个片段不超过一定的 token 数。
- 为每个片段内容计算在多维空间中的一组坐标(称为向量嵌入 / embeddings)。
- 将这些文档片段写入向量存储——这是一类可根据嵌入进行相似性搜索的数据库。
- 当提出问题时,为问题本身计算嵌入,并用它作为查询向量去向量存储检索,与问题在多维空间中距离最近的文档片段。
- 取回的前若干文档片段将作为上下文塞入提示。
图 4.1 RAG 的核心是找到与问题相关的文档并把它们作为上下文加入提示。
听起来确实有点复杂。但好消息是:你不必关心如何计算嵌入,或如何在多维空间衡量问题与文档间的“距离”。这些都由嵌入模型与向量存储负责。Spring AI 进一步抽象了与嵌入模型和向量存储的交互,使得使用 RAG 十分省心。
接下来要给 Board Game Buddy 应用加入 RAG 能力,让它能回答任何已加载规则的游戏问题。在此之前,你需要一个向量存储来写入这些规则。
4.2 配置向量存储(Setting up a vector store)
向量检索是指:把查询与数据存储中的内容都表示为向量,通过比较其多维坐标来搜索相似内容。常用的度量是余弦相似度(cosine similarity) (en.wikipedia.org/wiki/Cosine…),通常取 1 减去相似度得到余弦距离(0 到 2)。简单说,查询向量与文档向量的夹角越小,越相似。
虽然从数学角度看很有意思,但使用时无需深入原理。查询与文档的向量由 API 或库计算;余弦距离的计算由向量存储内部完成。你只需要挑一个向量存储并用它就行。
Spring AI 支持的常见向量存储包括:
- Azure AI Search
- Apache Cassandra
- Chroma
- Elasticsearch
- GemFire
- SAP HANA
- Milvus
- MongoDB
- Neo4j
- Pinecone
- PostgreSQL(带 pgvector 扩展)
- Qdrant
- Redis(带 RediSearch 模块)
- Weaviate
最终选择取决于能力、性能、价格等权衡;无论选择哪个,对你基于 Spring AI 的应用开发影响都很小。
虽然其中许多都有云托管服务,但本书示例选用一个可以通过 Docker Compose 运行的向量存储。多个受 Spring AI 支持的选项都符合要求,我们选择 Qdrant。
在用 Docker Compose 跑 Qdrant 前,请先确保你的机器已安装 Docker(安装说明见 docs.docker.com/engine/inst…)。
安装好 Docker 后,在 Board Game Buddy 项目的根目录创建一个 compose.yaml,内容如下:
services:
qdrant:
image: 'qdrant/qdrant:latest'
ports:
- '6334:6334'
- '6333:6333'
这段 YAML 告诉 Docker Compose 启动一个 Qdrant 服务。对我们最重要的是 ports:把容器端口映射到宿主机,其中 6334 将对外暴露——这正合适,因为稍后使用 Spring AI 的 Qdrant 客户端时,默认假设 Qdrant 监听在 localhost:6334。
同时也暴露了 6333。这是可选的,但对调试非常方便:它提供了一个 REST API,可用 curl 或 HTTPie 与 Qdrant 交互。
有两种方式启动 Qdrant:
- 手动使用
docker compose命令 - 使用 Spring Boot 的 Docker Compose 支持
若用命令行手动启动,执行:
$ docker compose --file compose.yaml up
更简单的是让 Spring Boot 在应用启动时自动拉起 Qdrant。为此,在构建中加入以下依赖:
implementation 'org.springframework.boot:spring-boot-docker-compose'
implementation 'org.springframework.ai:spring-ai-spring-boot-docker-compose'
第一项为 Spring Boot 基于 compose.yaml 自动启动容器的能力;应用关闭时,Spring Boot 会停止 Qdrant 容器。第二项启用服务连接(service connection) ,使 Spring AI 能正确连接 Qdrant。同一个依赖还会为以下容器(若存在)创建服务连接:
- AWS OpenSearch
- Chroma
- MongoDB
- Ollama
- OpenSearch
- TypeSense
- Weaviate
Spring Boot 还对更多容器提供服务连接支持,详见官方文档:mng.bz/eBNG。
无论你用 docker compose 命令手动启动,还是用 Spring Boot 的 Docker Compose 支持,至此 Qdrant 都已就绪,可以满足你的文档存储与向量检索需求。现在向量存储已经跑起来了,可以把它用于 Board Game Buddy 的 RAG 交互。但在应用能回答“游戏规则”相关问题之前,还需要一种方式把规则加载进向量存储。下一节我们来创建一个文档加载流水线来填充规则数据。
4.3 加载文档(Loading documents)
从本质上讲,把文档加入向量存储非常简单:读取文件、把它们切分为更小的块,然后将这些块保存到向量存储中。这个过程涉及 Spring AI 提供的三个关键组件:文档读取器(document reader) 、文本切分器(text splitter)以及向量存储客户端(vector store client) 。下面的代码片段展示了将文档加载进向量存储的最基础方式:
@Value("file://${HOME}/documents/my-document.txt")
private Resource documentResource;
public void loadDocument(VectorStore vectorStore) {
DocumentReader reader = new TextReader(documentResource);
TextSplitter splitter = TokenTextSplitter.builder().build();
vectorStore.accept(splitter.apply(reader.get()));
}
在这个简单示例里,TextReader 将 my-document.txt 读入为一个只含单个元素的 List<Document>,然后交给 TokenTextSplitter,把该 Document 拆分为一个或多个 Document,每个都承载原文档的一个片段。随后,这些子文档列表被交给指定的 VectorStore 保存。
对于只会围绕一份文档发问的简单 RAG 应用,这样做没问题。但在 Board Game Buddy 应用里,你可能会就任意数量的游戏发问,并会随着游戏库的增长不断把新的规则文档加入向量存储。把单个文档声明为一个在应用启动时加载的 Resource 显然不合适。
因此,你将创建一个文档加载流水线,它既能把多份文档加载到向量存储,也能在无需重启应用的情况下随时加入新文档。该流水线的流程如图 4.2 所示。
图 4.2 将文档加载到向量存储的流水线
4.3.1 初始化加载器项目(Initializing the loader project)
为了构建这条流水线,你将使用一组特殊的 Spring 库组合,包括:
- Spring AI OpenAI——主要用于访问 OpenAI 的嵌入(embedding)API。你也可以选用其他 AI 服务;若这样做,要理解不同的嵌入模型不跨兼容。因此,你需要确保游戏规则应用与加载器使用相同或兼容的嵌入 API。
- Spring MVC——Spring AI 需要它。
- Spring AI Qdrant——用来连接并向所选向量存储(Qdrant)写入文档的客户端库。
- Spring AI Tika Document Reader——基于 Apache Tika 的文档读取器,能读取多种文件类型。
- Spring Function Catalog——具体用到其
fileSupplier函数(mng.bz/pZaR),用于监听目录的新文件,并把它们发送给一个自定义消费者,由后者将文档写入向量存储。图 4.2 左侧的盒子就是该文件供应器;右侧盒子是你将在本节构建的自定义组件。 - Spring Cloud Function(spring.io/projects/sp…)——用于协调文件供应器与自定义消费者之间的交互。
选用 Spring Cloud Function 与 Spring Function Catalog,可以很方便地定义如图 4.2 的流水线。但这不是唯一方式,在使用 Spring AI 时也非必须;另一选择是 Spring Batch(spring.io/projects/sp…)。
首先创建一个新的 Spring Boot 项目。若使用 Spring Boot Initializr(start.spring.io),按图 4.3 所示选择依赖并生成项目。
图 4.3 初始化“向量存储加载器(Vector Store Loader)”项目
另外你还需要一个非 starter 依赖,因此 Initializr 上选不到。具体来说,需要加入 Spring Function Catalog 的**文件供应器(file supplier)**依赖。创建项目后,编辑 build.gradle,在 dependencies 块中添加:
implementation 'org.springframework.cloud.fn:spring-file-supplier'
该依赖属于更大的 Spring Functions Catalog 项目的一部分,需要你为其引入 BOM(mng.bz/Ownj),以便正确解析版本。将下述条目加入 dependencyManagement(与 Spring AI、Spring Cloud 的 BOM 条目并列):
mavenBom "org.springframework.cloud.fn:" +
"spring-functions-catalog-bom:" +
"$springFunctionsCatalogVersion"
最后,定义 springFunctionsCatalogVersion,指定所用的 Functions Catalog 版本:
springFunctionsCatalogVersion = '5.1.0'
完成后,build.gradle 大致如下:
plugins {
id 'java'
id 'org.springframework.boot' version '3.5.5'
id 'io.spring.dependency-management' version '1.1.7'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
java {
sourceCompatibility = '24'
}
repositories {
mavenCentral()
}
ext {
springCloudVersion = '2025.0.0'
springFunctionsCatalogVersion = '5.1.0'
springAiVersion = "1.0.3"
}
dependencyManagement {
imports {
mavenBom "org.springframework.cloud:" +
"spring-cloud-dependencies:" +
"$springCloudVersion"
mavenBom "org.springframework.cloud.fn:" +
"spring-functions-catalog-bom:" +
"$springFunctionsCatalogVersion"
mavenBom "org.springframework.ai:" +
"spring-ai-bom:" +
"$springAiVersion"
}
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.cloud:spring-cloud-function-context'
implementation 'org.springframework.cloud.fn:spring-file-supplier'
implementation 'org.springframework.ai:spring-ai-tika-document-reader'
implementation 'org.springframework.ai:spring-ai-starter-model-openai'
implementation(
'org.springframework.ai:spring-ai-starter-vector-store-qdrant')
implementation 'org.springframework.ai:spring-ai-advisors-vector-store'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
tasks.named('test') {
useJUnitPlatform()
}
接着,需要在 src/main/resources/application.properties 设置几个关键属性。首先,因为此应用为使 OpenAI 客户端正常工作而引入了 Spring MVC,你要确保它不要占用 8080 端口,以免与“游戏规则”应用冲突。由于本应用不对外暴露 API,把端口设为 0 可让它在启动时选择一个可用的高位端口:
server.port=0
默认情况下,Spring AI 假定 Qdrant 中的集合已存在,并不会替你创建。为了开发时方便,设置 spring.ai.vectorstore.qdrant.initialize-schema 为 true 可确保自动创建集合,并准备好接收文档:
spring.ai.vectorstore.qdrant.initialize-schema=true
集合名默认为 SpringAiCollection;若需要,你可以通过 spring.ai.vectorstore.qdrant.collection-name 指定自定义集合名:
spring.ai.vectorstore.qdrant.collection-name=GameRules
设置集合名是可选的。但如果你在加载器应用里自定义了集合名,请确保在 Board Game Buddy 中也设置相同的属性,这样两个应用才会操作同一个文档集合。
此外,你将使用 OpenAI 的嵌入 API 来为文档生成向量并写入向量存储,因此需在如下属性中提供你的 OpenAI API Key:
spring.ai.openai.api-key="${OPENAI_API_KEY}"
与 Board Game Buddy 应用相同,API Key 通过环境变量 OPENAI_API_KEY 引用实际密钥。
准备就绪后,就可以声明高层级的流水线定义了。
4.3.2 定义加载器流水线(Defining the loader pipeline)
Spring Cloud Function 允许通过设置 spring.cloud.function.definition 来定义一个由一个或多个函数组成的组合函数(使用竖线 | 作为分隔符)。对于向量存储加载器,在 src/main/resources/application.properties 中加入如下配置即可:
spring.cloud.function.definition=\
fileSupplier|\
documentReader|\
splitter|\
titleDeterminer|\
vectorStoreConsumer
这里,组合函数以 fileSupplier 开头,它是 Spring Function Catalog 提供的 java.util.function.Function 实现。文件供应器负责监听一个目录的新文件,拾取它们,并把文件交给下一个函数。监听的目录通过 application.properties 中的 file.supplier.directory 指定:
file.supplier.directory="/var/dropoff"
本例会监听 /var/dropoff 目录的任何文件。当然,你也可以让它更“挑剔”一些:通过设置 file.supplier.filename-regex,只处理感兴趣的文件类型:
file.supplier.filename-regex=.*.(pdf|docx|txt)
上面的正则表达式让文件供应器仅加载 PDF、Microsoft Word 或纯文本文件,其他类型忽略。
下一个函数是 documentReader,这是 Spring AI 的 DocumentReader 接口实现:接收文件并将其读入为一个 Document 对象。随后该 Document 会被传给组合管线中的下一个函数。
splitter 函数是 Spring AI 的 TextSplitter 实现:接收 Document,把它切分为更小的片段,并以 List<Document> 的形式返回。
该 List<Document> 随后被 vectorStoreConsumer 函数接收,后者通过 Spring AI 的 VectorStore 接口将这些文档片段写入向量存储。spring.cloud.function.definition 这一行既直观又简洁地定义了文档加载流水线。那么,这些函数究竟怎样定义?
这些名称分别对应 Spring 应用上下文中的Bean。如前所述,fileSupplier Bean 由 Spring Function Catalog 自动配置提供;其余的 Bean 则需要你自己声明。接下来我们看看如何定义它们。
4.3.3 创建流水线组件(Creating the pipeline components)
文档加载流水线中的每个组件,除了 vectorStoreConsumer 以外,都是 java.util.function 包里的 Function;vectorStoreConsumer 则是同一包里的 Consumer。它们都会接收某种输入,而所有的 Function 组件都会产出一些输出,随后被送到下一个组件。
作为函数,这些组件可以用 lambda 来实现,并在一个 @Bean 方法中定义,方法名需要与组合函数定义中的组件名匹配。先从把 documentReader 函数定义为一个 @Bean 方法开始。
读取文档(Reading documents)
Spring AI 内置了若干基于 DocumentReader 接口的文档读取器可选。开箱即用的包括:
- TextReader——最简单的读取器,能读取纯文本文件
- JsonReader——适合读取 JSON 文件中的文档
这些读取器来自 Spring AI 的 commons 模块。但我们要加载的是桌游规则,而多数桌游规则既不是纯文本也不是 JSON,因此需要考虑 Spring AI 的其他选项。
桌游规则书通常由发行方官网或 Board Game Geek(boardgamegeek.com/)提供,格式多为 PDF。因此你也许会考虑使用 Spring AI 的 PDF 文档读取器:
- PagePdfDocumentReader——按分页把 PDF 拆成多个文档
- ParagraphPdfDocumentReader——按段落(宽泛地基于目录结构定义)把 PDF 拆成多个文档
它们不在 commons 模块里,如需使用,请在项目中添加依赖:
implementation 'org.springframework.ai:spring-ai-pdf-document-reader'
上述两个读取器都会把文档拆分成更小的块。顾名思义,PagePdfDocumentReader 按页拆分;而 ParagraphPdfDocumentReader 的行为没那么直观,它依赖 PDF 的目录元数据,并按 PDF 的章节拆分(这些章节未必等同于“段落”大小)。
除了“段落”定义的特殊性,ParagraphPdfDocumentReader 并不适用于所有 PDF。如果 PDF 没有目录元数据(很多桌游规则书都没有),它会抛出异常,无法加载。
你可能会想,PagePdfDocumentReader 应该是 Board Game Buddy 文档加载器的首选。但在决定之前还要注意:有时规则会以 非 PDF 形式提供,比如纯文本或 Microsoft Word。而在企业内落地 Spring AI 时,文档来源类型可能更多:PDF、纯文本、Word、Excel、PowerPoint,以及其他 PagePdfDocumentReader 无法读取的格式。
在需要对文档类型保持灵活时,可以使用 Spring AI 的 TikaDocumentReader。它基于 Apache Tika(tika.apache.org/),能读取非常广泛的文档类型,包括上述所有格式以及更多。
鉴于其灵活性,我们在 Board Game Buddy 的规则加载器中采用 TikaDocumentReader。确保它在类路径中可用,请(而不是添加上面的 PDF 读取器依赖)加入:
implementation 'org.springframework.ai:spring-ai-tika-document-reader'
现在可以通过如下 @Bean 方法定义 documentReader 函数:
@Bean
Function<Flux<byte[]>, Flux<Document>> documentReader() {
return resourceFlux -> resourceFlux
.map(fileBytes ->
new TikaDocumentReader(
new ByteArrayResource(fileBytes))
.get()
.getFirst()).subscribeOn(Schedulers.boundedElastic());
}
该函数接受 Flux<byte[]> 作为输入,产出 Flux<List<Document>> 作为输出。之所以要接收 Flux<byte[]>,是因为 Spring Function Catalog 提供的 fileSupplier 组件输出的正是 Flux<byte[]>。
回忆 3.4.3 节,Flux 是 Project Reactor 的响应式类型,代表随着可用而交付的数据流。在这里,fileSupplier 读取文件后把文件字节放入 Flux 流,送往下游函数;稍后处理另一份文件时,同样把其字节送入同一个 Flux。
由于整个流水线的流程代表一个长时间运行的流,我们需要以 Flux 作为输入并在整个文档加载流水线中延续这条流。这也是函数返回 Flux(但携带的是 List<Document>)的原因。
在 documentReader 内部,通过对输入的 Flux<byte[]> 调用 map() 完成到 Flux<Document> 的映射。传给 map() 的 lambda 收到的是 byte[],先包装为 ByteArrayResource,交给 TikaDocumentReader。随后调用其 get() 返回 List<Document>;由于这里应始终只有单个文档,便取第一个元素返回,使其作为 Flux<Document> 传给下一个函数。
切分文档(Splitting documents)
由于单个文档可能很大,通常需要把它拆成更小的块,这样你就不用把整份文档作为上下文塞进提示词里。Spring AI 的 PDF 文档读取器已经会拆分,但我们此处使用的是 TikaDocumentReader,它不会自动切分,因此需要在规则加载流水线中定义一个文档切分器。splitter 函数定义如下:
@Bean
Function<Flux<Document>, Flux<List<Document>>> splitter() {
var splitter = new TokenTextSplitter();
return documentFlux ->
documentFlux
.map(incoming -> splitter
.apply(List.of(incoming)))
.subscribeOn(Schedulers.boundedElastic());
}
与 documentReader 类似,splitter 也是以 lambda 定义。它使用 Spring AI 的 TokenTextSplitter 将传入的 Document 拆分,得到包含原文档若干块的 List<Document>。
TokenTextSplitter 会将文本拆分为不超过指定 token 数的块(默认 800)。为了尽量避免在句中切断,块的大小可能小于该目标值。你可以通过构建器设置各种属性来调整它的行为。表 4.1 列举了 TokenTextSplitter 构建器用于微调的常用方法。
表 4.1 TokenTextSplitter 的构建器方法(微调行为)
| 方法 | 说明 | 默认值 |
|---|---|---|
withChunkSize() | 每个文本块的目标 token 数 | 800 |
withKeepSeparator() | 是否在块中保留换行分隔符 | true |
withMaxNumChunks | 从一段文本最多生成多少块 | 10000 |
withMinChunkLengthToEmbed() | 丢弃长度小于该值的块 | 5 |
withMinChunkSizeChars() | 每个文本块的最小字符数 | 350 |
例如,你希望把目标块大小设为 500 tokens,丢弃少于 10 的块,并不保留换行分隔符,可这样构造:
TextSplitter splitter = TokenTextSplitter.builder()
.withChunkSize(500)
.withKeepSeparator(false)
.withMinChunkLengthToEmbed(10)
.build();
无论如何配置拆分器,完成后得到的 List<Document> 都会通过 Flux<List<Document>> 传递给下一个函数。
确定游戏标题(Determining the game’s title)
在把文档块写入向量存储之前,必须把游戏标题作为元数据写在这些文档块上。这样 Board Game Buddy 在做相似度检索时,才能按特定游戏检索相关规则。否则,相似度搜索可能会返回错游戏的相似规则。
一种办法是要求文档遵循命名约定,使标题可从文件名推导。但这对提供文档的人执行命名约定的能力与意愿依赖过大。
有个“狂想”:与其依赖命名规范,不如直接用生成式 AI 根据文档内容推断游戏标题?这正是 titleDeterminer 函数要做的事。
代码清单 4.1 使用生成式 AI 从规则文档中判断游戏标题
private static final Logger LOGGER =
LoggerFactory.getLogger(GameRulesLoaderApplication.class);
@Value("classpath:/promptTemplates/nameOfTheGame.st")
Resource nameOfTheGameTemplateResource;
@Bean
Function<Flux<List<Document>>, Flux<List<Document>>>
titleDeterminer(ChatClient.Builder chatClientBuilder) {
var chatClient = chatClientBuilder.build();
return documentListFlux -> documentListFlux
.map(documents -> {
if (!documents.isEmpty()) {
var firstDocument = documents.getFirst();
var gameTitle = chatClient.prompt()
.user(userSpec -> userSpec
.text(nameOfTheGameTemplateResource)
.param("document", firstDocument.getText()))
.call()
.entity(GameTitle.class);
if (Objects.requireNonNull(gameTitle).title().equals("UNKNOWN")) {
LOGGER.warn("Unable to determine the name of a game; " +
"not adding to vector store.");
documents = Collections.emptyList();
return documents;
}
LOGGER.info("Determined game title to be {}", gameTitle.title());
documents = documents.stream().peek(document -> {
document.getMetadata()
.put("gameTitle", gameTitle.getNormalizedTitle());
}).toList();
}
return documents;
});
}
如你所见,titleDeterminer 比前面的流水线函数更有意思。它从 splitter 接收到文档块列表后,拿第一块作为上下文发起提示,请 LLM 判断游戏标题。用于判断标题的提示模板如下:
Your job is to determine the name of a game based on the rules given in the
document (in the DOCUMENT section). The document will be a short excerpt
from the rules of the game. The title of the game may or may not be explicitly
stated in the document. If the title is not explicitly stated, set the title
to "UNKNOWN".
If the title is explicitly stated in the rules, then it should be given in
title case.
DOCUMENT:
{document}
该提示清晰指示:请根据传入的规则片段判断游戏标题;若无法判断,给出回退值 UNKNOWN。否则,就以 GameTitle 对象返回标题,并把它作为元数据写入这批文档块。随后以携带修改后列表的 Flux 返回,供流水线下一步继续处理。
GameTitle 是个简单的 Java record,携带游戏标题,并包含一个 getNormalizedTitle() 方法,将标题转小写并用下划线替换空格:
package com.example.gamerulesloader;
public record GameTitle(String title) {
public String getNormalizedTitle() {
return title.toLowerCase().replace(" ", "_");
}
}
对标题做规范化,便于后续在向量存储中按特定游戏检索相关文档。
将文档写入向量存储(Writing documents to the vector store)
现在,文档已经被读取、切分为块,且每个块都带上了游戏标题元数据。流水线的最后一步就是把这些块写入向量存储。vectorStoreConsumer 是流水线的终点组件,定义如下:
@Bean
Consumer<Flux<List<Document>>> vectorStoreConsumer(VectorStore vectorStore) {
return documentFlux -> documentFlux
.doOnNext(documents -> {
if (!documents.isEmpty()) {
var docCount = documents.size();
LOGGER.info("Writing {} documents to vector store.", docCount);
vectorStore.accept(documents);
LOGGER.info(
"{} documents have been written to vector store.", docCount);
}
})
.subscribe();
}
可以看到,vectorStoreConsumer 实现的是 Consumer,因此没有输出;但它会接收一个 Flux<List<Document>> 作为输入。由于 VectorStore.accept() 并不接收 Flux,所以必须先从携带它的 Flux 中取出 List<Document> 再传给 accept()。这正是 doOnNext() 的作用:当 List<Document> 到达 Flux 中,它就执行记录日志(将要写入多少文档),然后调用 accept() 写入,最后再记录已写入的日志。
在 vectorStoreConsumer 定义中的最后一步,对该 Flux 调用了 subscribe()。这一步至关重要:如果不订阅(subscribe),这条数据流就不会流动,什么也不会发生。可以把整个 Flux 流水线想象成一根花园水管,subscribe() 就是打开水龙头。
至此,流水线已经定义完毕,所有组件也都实现。接下来就可以运行它,观察实际效果了。
4.3.4 运行流水线(Running the pipeline)
在 application.properties 中由 spring.cloud.function.definition 属性定义的流水线,本质上就是由你创建的各个组件组合而成的一个函数。要让数据开始在流水线中流动,你需要运行这个组合函数。
下面的 go() 方法定义了一个 ApplicationRunner bean,通过调用组合函数的 run() 来启动流水线:
@Bean
ApplicationRunner go(FunctionCatalog catalog) {
Runnable composedFunction = catalog.lookup(null);
return args -> {
composedFunction.run();
};
}
该 ApplicationRunner bean 会被注入一个 FunctionCatalog,可用来查找函数。由于我们的应用只有一个组合函数,向 lookup() 传入 null 就会返回这个流水线函数。拿到函数后,调用其 run() 方法即可启动流水线。
现在可以启动应用让一切运转起来了。在向量存储加载器项目的根目录下运行:
$ ./gradlew bootRun
应用启动后,试着把某个桌游的 PDF 规则复制到 /tmp/dropoff 目录。如果手头没有 PDF 规则书,可以在发行商官网或 Board Game Geek 上找到很多游戏的规则书。
当你把一个或多个规则文档复制到投递目录后,你应当能在日志里看到文档被拾取、通过流水线处理、并写入向量存储的证据。文档越大,加载时间越长;如果是很多页的规则书,请耐心等待。
例如,如果你把 Burger Battle 的规则复制到了 /tmp/dropoff,日志中可能会看到如下信息(为适配书页边距略作修改):
TextSplitter : Splitting up document into 2 chunks.
GameRulesLoaderApplication : Determined game title to be Burger Battle
GameRulesLoaderApplication : Writing 2 documents to vector store.
GameRulesLoaderApplication : 2 documents have been written to vector store.
你可以用 HTTPie 验证文档块已被写入 Qdrant。首先通过 Qdrant 提供的 /collections 端点获取集合列表,以便知道集合 ID:
$ http :6333/collections -b
{
"result": {
"collections": [
{
"name": "board-game-buddy"
}
]
},
"status": "ok",
"time": 2.1375e-05
}
该响应给出了 Qdrant 向量存储中的集合列表。此处只有 board-game-buddy 一个集合,说明集合已存在。接下来再调一个 API 获取该集合中的文档块数量。
在 Qdrant 术语中,文档块称为 points(意为每个块在多维空间中的一个点)。要获取 point 的数量,可以向 API 发送一个 POST 请求:
$ http POST :6333/collections/board-game-buddy/points/count exact:=true -b
{
"result": {
"count": 2
},
"status": "ok",
"time": 0.000239875
}
该端点(路径中包含集合名)接收一个 POST 请求,请求体可包含用于细化过滤的属性。上述示例把 exact 设为 true,以获取集合中 point 的精确计数。也可以把 exact 设为 false 获取估算计数,可能响应更快。但就本例少量条目而言,获取精确值也足够快。计数结果显示加入 Burger Battle 规则后,Qdrant 中有 2 条记录,与日志一致。
如果愿意,你也可以使用 Qdrant 的 API 来做相似查询。但那会比较繁琐,因为需要在发起查询请求前自己计算 embeddings。如果你想尝试,可以参考 Qdrant 的 API 文档:api.qdrant.tech/api-referen… 。
不过,更有趣也更实用的方式是通过 Spring AI 在 Board Game Buddy API 中实现 RAG 来完成这类查询。下面就把 RAG 加到 Board Game Buddy 应用中。
4.4 实现 RAG(Implementing RAG)
在任何应用中加入 RAG,通常要先到向量存储查询与问题相似的文档,然后把这些文档作为上下文加入提示词中。第一步是给 Board Game Buddy 应用添加向量存储的 starter 依赖。既然加载器使用了 Qdrant,这里也加入同样的 Qdrant 依赖:
implementation 'org.springframework.ai:spring-ai-starter-vector-store-qdrant'
接下来,实现 RAG 功能以搜索与所提问题相似的文档。
4.4.1 搜索相似文档(Searching for similar documents)
虽然 GameRulesService 目前还没有实现 RAG,但它负责把某个游戏的规则加载到提示中。现在它是从固定位置加载规则。若要让 Board Game Buddy 回答许多不同游戏的问题——其中很多规则书可能很长(即 token 很多)——那么 GameRulesService 需要改为:从向量存储中按与问题的相似度检索文档块。
下面的代码清单展示了如何修改 GameRulesService 来执行 RAG 搜索。
代码清单 4.2 在 GameRulesService 中实现 RAG
package com.example.boardgamebuddy;
import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.ai.vectorstore.filter.FilterExpressionBuilder;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.stream.Collectors;
@Service
public class GameRulesService {
private final VectorStore vectorStore;
public GameRulesService(VectorStore vectorStore) {
this.vectorStore = vectorStore;
}
public String getRulesFor(String gameName, String question) {
var searchRequest = SearchRequest
.builder()
.query(question)
.filterExpression(
new FilterExpressionBuilder()
.eq("gameTitle", normalizeGameTitle(gameName)).build())
.build(); #1
System.err.println("Search request: " + searchRequest);
var similarDocs =
vectorStore.similaritySearch(searchRequest); #2
if (similarDocs.isEmpty()) {
return "The rules for " + gameName + " are not available.";
}
return similarDocs.stream()
.map(Document::getText)
.collect(Collectors.joining(System.lineSeparator())); #3
}
private String normalizeGameTitle(String gameTitle) { #4
return gameTitle.toLowerCase().replace(" ", "_");
}
}
这个新版本的 GameRulesService 通过构造器注入了一个 VectorStore(此处是 QdrantVectorStore)。它会在 getRulesFor() 中使用 VectorStore 来搜索与问题相似的文档。
不过,在此之前,getRulesFor() 会先构造一个 SearchRequest 来定义搜索参数。查询词本身就是用户提出的问题。如果你只想按问题查询,到这里就够了,SearchRequest 可写成:
var searchRequest = SearchRequest.builder().query(question).build();
但由于向量存储里可能有很多个游戏的规则文档,getRulesFor() 进一步定义了一个过滤条件:基于元数据中 gameTitle 的值等于规范化(即 snakecase)后的游戏名,仅检索该游戏的规则块。
该过滤条件由 FilterExpressionBuilder 定义,通过流式接口简化了构造过程。你也可以用 String 指定同样的表达式,例如:
.filterExpression("gameTitle == '" + normalizeGameTitle(gameName) + "'");
默认情况下,相似度搜索会返回最多四个与问题最相似的文档。但你可以通过设置 Top-K 来调整返回条数。Top-K 指定“返回最相似的前 K 条文档”。例如下面把 Top-K 设为 6:
var searchRequest = SearchRequest.builder()
.query(question)
.topK(6)
.filterExpression(
new FilterExpressionBuilder()
.eq("gameTitle", normalizeGameTitle(gameName)).build())
.build();
这样搜索将返回最多 6 条相似文档。
请注意:往提示上下文中加入的文档块越多,token 消耗越大——这不仅提高费用,还可能触及 token 上限。反过来,如果把 Top-K 设得太低,LLM 可能拿不到足够的上下文,影响答案的准确性。
在微调搜索请求时,你也可以设定一个相似度阈值。相似度范围是 0.0 到 1.0。默认阈值为 0.0,意味着只要有点像就会被返回。如果你发现搜索返回的结果不够相似而难以作为有效上下文,可以通过 withSimilarityThreshold() 调整阈值。比如把阈值设为 0.5:
SearchRequest searchRequest = SearchRequest.builder()
.query(question)
.topK(6)
.similarityThreshold(0.5)
.filterExpression(
new FilterExpressionBuilder()
.eq("gameTitle", normalizeGameTitle(gameName)).build())
.build();
相似度阈值有助于剔除不太相关的结果,但不要设得太高。阈值越高,返回结果越少(甚至可能为 0),从而导致 LLM 缺乏足够上下文来作答。
准备好 SearchRequest 后,调用 VectorStore 的 similaritySearch 即可查询相似文档。如果没有结果,就返回一段字符串提示指定游戏没有规则可用;若找到了规则,则把这些文档的文本内容提取出来,用换行符连接成一个字符串。
最终,getRulesFor() 返回的字符串会包含所有相似文档的文本。它将被 SpringAiBoardGameService 用来填充系统提示模板中的 {rules} 占位符。为了让它生效,还需要对 SpringAiBoardGameService 和系统提示模板做几处小改动。
4.4.2 更新服务(Updating the service)
在大多数情况下,SpringAiBoardGameService 为支持 RAG 交互并不需要做太多改动。因为 RAG 的主要工作已经在 GameRulesService 类中完成。但由于 GameRulesService 的 getRulesFor() 现在需要同时接收游戏名和问题两个参数,你需要修改 SpringAiBoardGameService 的 askQuestion() 方法,更新对 getRulesFor() 的调用方式:
@Override
public Answer askQuestion(Question question) {
var gameRules = gameRulesService.getRulesFor(
question.gameTitle(), question.question());
var answer = chatClient.prompt()
.system(systemSpec -> systemSpec
.text(promptTemplate)
.param("gameTitle", question.gameTitle())
.param("rules", gameRules))
.user(question.question())
.call()
.content();
return new Answer(question.gameTitle(), answer);
}
除此之外,SpringAiBoardGameService 不需要其他改动,因为它本来就会把 getRulesFor() 返回的 String 注入到提示词的 {rules} 占位符中。
不过,提示模板(systemPromptTemplate.st)需要稍作调整。之前的版本比较宽松:如果规则文本里找不到答案,就允许模型从自己的训练中作答。但这会导致较多错误答案(即“幻觉”)。下面这个新版 systemPromptTemplate.st 更严格:
You are a helpful assistant, answering questions about a tabletop
game named {gameTitle}.
Given the context in the DOCUMENTS section and no prior
knowledge, answer the user's question about the game.
If the answer is not in the DOCUMENTS section, then
reply with "I don't know".
DOCUMENTS:
----------
{rules}
----------
根据该模板对 LLM 的要求,如果在给定的规则中找不到用户问题的答案,就应该回答 I don't know。
现在来试一下。确保向量存储仍在运行,并且已经写入了若干游戏规则。然后启动应用,通过 /ask 端点提问。例如,如果你已经加载了 Burger Battle 的规则,可以用 HTTPie 询问 Grave Digger 这张牌:
$ http :8080/ask question="What is the Grave Digger card?" \
gameTitle="Burger Battle" -b
{
"answer": "The Grave Digger card allows a player to dig through the
Graveyard for any needed ingredient and add it to their
Burger in the game Burger Battle.",
"gameTitle": "Burger Battle"
}
另一方面,假设你询问 Carcassonne(卡卡颂) ,但尚未将它的规则加载进向量存储,那么会得到如下结果:
$ http :8080/ask question="How do you score a monastery?" \
gameTitle="Carcassonne" -b
{
"answer": "I don't know.",
"gameTitle": "Carcassonne"
}
如果之后把 Carcassonne 的规则加载进来并再次提问,可能得到如下回答:
$ http :8080/ask question="How do you score a monastery?" \
gameTitle="Carcassonne" -b
{
"answer": "To score a monastery in Carcassonne, it must be completed
when it is surrounded by tiles. Each of the monastery’s tiles
(including the one with the monastery itself and the 8
surrounding tiles) is worth 1 point.",
"gameTitle": "Carcassonne"
}
具体结果会因使用的模型而异。大多数模型会按模板要求回答 I don't know。有时你可能会得到更长的一段文字,本质上也是“我不知道”。也有一些模型可能会完全忽略指令,尝试依据其训练数据作答。
在 GameRulesService 中实现 RAG 相对直接,也清楚展示了相似度搜索在问答流程中的位置。但 Spring AI 还提供了一种更简洁的方式来应用 RAG,能减少你需要编写的代码。下面看看如何使用 advisor 来实现 RAG——这是一种由 Spring AI 提供的组件,能为你封装大部分通用的 RAG 功能。
4.5 使用 advisor 实现 RAG(Implementing RAG with an advisor)
如你所见,RAG 的大量工作发生在真正把提示词发给 LLM 之前。实际上,在上一节的实现中,RAG 的主体都在 GameRulesService 中完成,而 askQuestion() 做的第一件事就是调用 getRulesFor()。askQuestion() 中与 RAG 相关的另一件事,只是把 {rules} 参数在设置提示词时带进去。
不难想象,我们可以把这些 RAG 工作抽取到某种拦截器里,让它在调用 LLM 之前做:先执行相似度搜索找出与问题相似的文档,然后把这些文档注入到提示模板中,再把提示词发出去。
这正是 Spring AI 的 QuestionAnswerAdvisor 要做的事。Spring AI 的 advisors 会在与 ChatClient 交互的前后被调用,既可以为提示词添加上下文,也可以从响应中提取信息以便后续使用。QuestionAnswerAdvisor 的主要工作是:向用户提示模板追加一些文本,并在向量存储中搜索文档加入为上下文。
注:
QuestionAnswerAdvisor只是 Spring AI 提供的多个 advisor 之一。还有一些在处理聊天记忆时很有用的 advisor,比如RetrievalAugmentationAdvisor,我们会在本章稍后看到它。到第 5 章加入对话记忆时,我们会继续使用它们。
来看一个使用 QuestionAnswerAdvisor 的 askQuestion() 实现:
@Override
public Answer askQuestion(Question question) {
String gameNameMatch = String.format("gameTitle ==
'%s'", question.gameTitle());
return chatClient.prompt()
.system(systemSpec -> systemSpec
.text(promptTemplate)
.param("gameTitle", question.gameTitle()))
.user(question.question())
.advisors(
QuestionAnswerAdvisor.builder(vectorStore).build())
.call()
.entity(Answer.class);
}
在这个版本的 askQuestion() 中,虽然仍然提供了系统提示,但它不再用于放入从向量存储检索来的文档块。因此,系统提示模板可以简化为:
You are a helpful assistant, answering questions about the tabletop
game named {gameTitle}.
不再通过该模板提供规则,而是调用 advisors(),让 QuestionAnswerAdvisor 去执行搜索相似文档的工作。它甚至会把包含相似文档及“如何使用这些文档”的文本追加到系统提示中。本质上,上节你手动完成的大多数步骤,都由 QuestionAnswerAdvisor 在幕后代劳了。
默认情况下,QuestionAnswerAdvisor 追加到提示中的文本如下:
Context information is below.
---------------------
{question_answer_context}
---------------------
Given the context and provided history information and no prior knowledge,
reply to the user comment. If the answer is not in the context, inform
the user that you can't answer the question.
其中 {question_answer_context} 会被相似度搜索得到的文档替换。
在 ChatClient 提交请求之前,它会调用 QuestionAnswerAdvisor 来处理 RAG 相关工作。该 advisor 在创建时需要一个 VectorStore 引用来执行相似搜索。它还会基于一个默认的 SearchRequest,再把问题作为查询条件加入。
但这些都在 ChatClient 与 QuestionAnswerAdvisor 的内部完成,无需你在代码中实现。使用 QuestionAnswerAdvisor 时,你也不再需要 GameRulesService 或类似的组件。
如上在 askQuestion() 中的用法,并没有任何与某次请求强绑定的配置。因此,你可以把它提取出来,作为 ChatClient 的默认 advisor——让所有 ChatClient 请求都使用它。做法是显式声明一个 ChatClient bean,并通过 defaultAdvisors() 设置该 advisor。下面的配置类演示了如何实现:
代码清单 4.3 配置带默认 advisor 的 ChatClient bean
package com.example.boardgamebuddy;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.vectorstore
.QuestionAnswerAdvisor;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class AiConfig {
@Bean
ChatClient chatClient(
ChatClient.Builder chatClientBuilder, VectorStore vectorStore) {
return chatClientBuilder
.defaultAdvisors(
QuestionAnswerAdvisor.builder(vectorStore).build())
.build();
}
}
这只是第一步;此时你在处理请求时就不必在 askQuestion() 中再显式提供 QuestionAnswerAdvisor 了。但还缺了一个关键信息。下面的新版本 SpringAiBoardGameService 展示如何在创建 QuestionAnswerAdvisor 的 SearchRequest 中加入游戏名过滤条件。
代码清单 4.4 在 SearchRequest 中指定过滤条件以创建 QuestionAnswerAdvisor
import static org.springframework.ai.chat.client.advisor
.vectorstore.QuestionAnswerAdvisor.FILTER_EXPRESSION;
@Override
public Answer askQuestion(Question question) {
var gameNameMatch = String.format(
"gameTitle == '%s'",
normalizeGameTitle(question.gameTitle()));
return chatClient.prompt()
.system(systemSpec -> systemSpec
.text(promptTemplate)
.param("gameTitle", question.gameTitle()))
.user(question.question())
.advisors(advisorSpec ->
advisorSpec.param(FILTER_EXPRESSION, gameNameMatch))
.call()
.entity(Answer.class);
}
askQuestion() 不再创建 QuestionAnswerAdvisor,因为它已在创建 ChatClient bean 时统一配置。但当时并不知道具体的游戏名,所以这里由 askQuestion() 负责提供该细节。
首先,askQuestion() 使用给定的游戏名构造一个过滤表达式,用于按游戏标题筛选向量存储中的文档。调用 advisors() 时,通过 FILTER_EXPRESSION 这个常量对应的参数键传入该表达式。这样一来,QuestionAnswerAdvisor(在创建 ChatClient bean 时配置的那个)就拥有了过滤所需的一切信息,能把结果聚焦到指定的游戏上。
虽然使用 QuestionAnswerAdvisor 比自己实现 RAG 简单很多,但它在处理过程中的灵活性相对有限。Spring AI 还提供了另一个 advisor:RetrievalAugmentationAdvisor,它能让你对 RAG 流程有更多可控点,同时依然保持相对易用。下面我们就来看 模块化 RAG advisor —— RetrievalAugmentationAdvisor。
4.6 应用模块化 RAG(Applying modular RAG)
假设你想确保用户问题的语言与向量存储中被检索文档的语言一致;或者你想通过聚焦用户查询来提升向量检索到的文档准确度;又或者你希望从非向量存储的其他位置检索相关文档。
虽然 QuestionAnswerAdvisor 无法帮助你完成这些事,但 Spring AI 的 RetrievalAugmentationAdvisor 可以。RetrievalAugmentationAdvisor 是一个模块化的 RAG advisor,你可以通过插拔式的配套组件定制其行为,处理不同任务。
为了理解 RetrievalAugmentationAdvisor 的工作方式,我们先来“复刻”一版与 QuestionAnswerAdvisor 类似的 RAG 流程;随后再通过插入组件来改变这个 advisor 的行为。
首先,你需要在构建中加入 Spring AI 的 RAG 依赖,使 RetrievalAugmentationAdvisor 可用于应用:
implementation 'org.springframework.ai:spring-ai-rag'
接着,创建一个 RetrievalAugmentationAdvisor 实例,并像上一节使用 QuestionAnswerAdvisor 一样把它接入 ChatClient。下面的清单展示了更新后的 chatClient() bean 方法,将默认 advisor 切换为 RetrievalAugmentationAdvisor。
清单 4.5 配置一个默认的 RetrievalAugmentationAdvisor
package com.example.boardgamebuddy;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.rag.advisor.RetrievalAugmentationAdvisor;
import org.springframework.ai.rag.preretrieval.query.expansion
.MultiQueryExpander;
import org.springframework.ai.rag.preretrieval.query.transformation
.RewriteQueryTransformer;
import org.springframework.ai.rag.preretrieval.query.transformation
.TranslationQueryTransformer;
import org.springframework.ai.rag.retrieval.search.
VectorStoreDocumentRetriever;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class AiConfig {
@Bean
ChatClient chatClient(
ChatClient.Builder chatClientBuilder, VectorStore vectorStore) {
var advisor = RetrievalAugmentationAdvisor.builder() #1
.documentRetriever(
VectorStoreDocumentRetriever.builder() #2
.vectorStore(vectorStore)
.build())
.build();
return chatClientBuilder
.defaultAdvisors(advisor) #3
.build();
}
}
这里在创建 ChatClient bean 时把该 advisor 设为默认 advisor;当然你也可以在构建 prompt 时,通过 advisors() 方法临时指定。
你会注意到:创建 RetrievalAugmentationAdvisor 的代码比 QuestionAnswerAdvisor 多几行。原因在于你必须通过 documentRetriever() 指定一个文档检索器。与 QuestionAnswerAdvisor 默认假设“要查询向量存储”不同,RetrievalAugmentationAdvisor 不做任何假设——你可以插入任何实现了 Spring AI DocumentRetriever 接口的检索器,例如一个面向知识库服务的检索器。而在此处,我们选择了 VectorStoreDocumentRetriever,它从向量存储检索文档。
只能有一种 DocumentRetriever?
RetrievalAugmentationAdvisor 的文档检索器是可插拔的,可以配合任何 DocumentRetriever 实现。但是在 Spring AI 1.0.0 中,官方仅提供了 VectorStoreDocumentRetriever 这一实现。
当然,你完全可以自定义一个实现。DocumentRetriever 很简单,只需实现一个方法:
public interface DocumentRetriever extends Function<Query,
List<Document>> {
List<Document> retrieve(Query query);
default List<Document> apply(Query query) {
return retrieve(query);
}
}
当把 RetrievalAugmentationAdvisor 设为默认 advisor 后,你仍需要像清单 4.4 那样在 prompt 构建阶段提供过滤条件(例如按游戏名过滤)。但需要注意:此处的 FILTER_EXPRESSION 常量不是 QuestionAnswerAdvisor 的那个,而是来自 VectorStoreDocumentRetriever,因此需要静态导入:
import static org.springframework.ai.rag.retrieval.search
.VectorStoreDocumentRetriever.FILTER_EXPRESSION;
然后在构建 prompt 时,通过 advisors() 传入该过滤表达式:
return chatClient.prompt()
// ...
.advisors(advisorSpec ->
advisorSpec.param(FILTER_EXPRESSION, gameNameMatch))
.call()
.entity(Answer.class);
除了 FILTER_EXPRESSION 的导入不同,这段与清单 4.4(QuestionAnswerAdvisor 的版本)几乎一致。
RetrievalAugmentationAdvisor 的灵活性不仅体现在“从何处检索文档”,它还允许你插入改写查询的组件,从而让检索更聚焦。下面我们看看如何插入一个重写用户查询的组件,以获得更聚焦的结果。
4.6.1 重写用户查询(Rewriting the user’s query)
美版《办公室》(The Office)里的 Kevin Malone 说过一句话:“Why waste time say lot word when few word do trick? ”(能少说就别多说)。虽然这话听起来傻乎乎,但也有道理:更精简的问题,往往更聚焦,也更容易得到更相关的检索结果。
设想用户问:“What is the Burger Force Field and how can I play it in the game Burger Battle? ” 这句并不算啰嗦,但如果进一步收敛到核心,可能更有效;问题更短,向量检索时也更容易匹配到更相关的文档。
Spring AI 提供了一个查询变换器:RewriteQueryTransformer,它会把用户查询改写得更简洁明确。其内部通过 ChatClient 调用 LLM 来完成改写。
使用 RewriteQueryTransformer 的方式是:先创建其实例,再在构建 RetrievalAugmentationAdvisor 时把它挂入 advisor 的 queryTransformers:
var advisor = RetrievalAugmentationAdvisor.builder()
.documentRetriever(
VectorStoreDocumentRetriever.builder()
.vectorStore(vectorStore)
.build())
.queryTransformers(
RewriteQueryTransformer.builder()
.chatClientBuilder(chatClientBuilder)
.build())
.build();
与许多 Spring AI 组件类似,RewriteQueryTransformer 通过 builder 创建;你必须通过 chatClientBuilder() 提供一个 ChatClient.Builder。它会据此创建一个 ChatClient,用来请求 LLM 改写用户查询。创建好 RewriteQueryTransformer 后,通过 queryTransformers() 交给 RetrievalAugmentationAdvisor 的 builder 即可。
在 RewriteQueryTransformer 加持下,冗长的问题会被改写得更聚焦。例如前面的 Burger Battle 问题,可能被改写成:“What is the Burger Force Field in Burger Battle? ”——然后再用这个更精炼的问题去查询向量存储。
接下来,我们再看看另一种查询变换器:确保查询语言与向量存储中文档的语言匹配。
4.6.2 翻译用户查询(Translating user queries)
到目前为止,我们一直假设加载到 Board Game Buddy 向量存储中的规则是英文,且用户也会用英文提交查询。但桌游爱好者遍布全球,他们很可能希望用自己的母语提问。如果让用户在与 Board Game Buddy 交互时必须使用英文并不体贴;同时,把每个游戏规则的多种语言版本都加载进向量存储也不现实。
如果用户使用与向量存储中文档不同的语言提问,他们也许碰巧能得到尚可的结果。但可以很容易想见:如果查询语言与文档语言一致,往往能得到更好的结果。使用 TranslationQueryTransformer(翻译查询变换器)就能解决这个问题。
顾名思义,TranslationQueryTransformer 会把用户的查询翻译为目标语言,理想情况下与文档语言一致。这样,即便用户的语言与文档语言不同,向量检索仍更有可能返回更相关的结果。
来看如何使用 TranslationQueryTransformer。假设 Board Game Buddy 的向量存储里全部是英文规则。此时,我们希望无论用户用什么语言提问,都先把问题转换成英文再去查询向量存储。下面是在创建 RetrievalAugmentationAdvisor 时提供 TranslationQueryTransformer 的方式:
var advisor = RetrievalAugmentationAdvisor.builder()
.documentRetriever(
VectorStoreDocumentRetriever.builder()
.vectorStore(vectorStore)
.build())
.queryTransformers(
TranslationQueryTransformer.builder()
.chatClientBuilder(chatClientBuilder)
.targetLanguage("English")
.build(),
RewriteQueryTransformer.builder()
.chatClientBuilder(chatClientBuilder)
.build())
.build();
如你所见,queryTransformers() 方法可以接收多个查询变换器。这里同时加入了 RewriteQueryTransformer(改写查询)和 TranslationQueryTransformer(翻译查询)。
与 RewriteQueryTransformer 类似,TranslationQueryTransformer 也依赖 ChatClient 向 LLM 请求翻译,因此需要通过 chatClientBuilder() 提供一个 ChatClient.Builder。它还需要知道目标语言,这里通过 targetLanguage("English") 指定为英文。
有了这个变换器后,你就能确保用于向量检索的最终问题是英文。例如,德国用户问:“Was ist das Burger Force Field und wie kann ich es im Spiel Burger Battle spielen? ”,TranslationQueryTransformer 会先把它翻译为英文 “What is the Burger Force Field and how can I play it in the game Burger Battle? ”。然后再交给 RewriteQueryTransformer 改写得更聚焦,例如 “What is the Burger Force Field in Burger Battle? ”。
查询变换器并不是改善向量检索结果的唯一方式。把一个查询扩展为多个不同表述也常常能带来更好的效果。下面看看如何把查询扩展器接入 RetrievalAugmentationAdvisor。
4.6.3 扩展用户查询(Expanding user queries)
RewriteQueryTransformer 的目标是在提交到向量存储之前让用户问题更聚焦;而 MultiQueryExpander(多查询扩展器)走的是相反路线:它不是简化,而是扩展——基于原始问题生成多个不同表述的查询。
例如,用户问:“Does Burger Force Field protect against Burgerpocalypse? ”(Burger Force Field 是否能防御 Burgerpocalypse?),MultiQueryExpander 可能生成如下额外查询:
- “Is the Burger Force Field effective in preventing Burgerpocalypse? ”
- “How does the Burger Force Field mitigate the effects of a Burgerpocalypse? ”
- “What measures does the Burger Force Field implement to safeguard against a Burgerpocalypse? ”
其思路是:同一个问题用多种方式提问,向量检索也许能命中原问法未覆盖但仍然相关的文档。
使用 MultiQueryExpander 与使用查询变换器非常相似:构建它时需要提供一个 ChatClient.Builder,以便让 LLM 生成扩展后的查询列表。但不同点在于,构建 RetrievalAugmentationAdvisor 时应将它传给 queryExpander(),而不是 queryTransformers():
var advisor = RetrievalAugmentationAdvisor.builder()
.documentRetriever(
VectorStoreDocumentRetriever.builder()
.vectorStore(vectorStore)
.build())
.queryExpander(
MultiQueryExpander.builder()
.chatClientBuilder(chatClientBuilder)
.build())
.build();
默认情况下,MultiQueryExpander 会产出4 个查询(原始查询 + 3 个新查询)。你可以通过 numberOfQueries() 指定更多数量,例如:
var advisor = RetrievalAugmentationAdvisor.builder()
.documentRetriever(
VectorStoreDocumentRetriever.builder()
.vectorStore(vectorStore)
.build())
.queryExpander(
MultiQueryExpander.builder()
.chatClientBuilder(chatClientBuilder)
.numberOfQueries(5)
.build())
.build();
传入 5 表示会生成6 个查询(5 个新查询 + 原始查询)。如果你不想包含原始查询,可以通过 includeOriginal(false) 排除它:
var advisor = RetrievalAugmentationAdvisor.builder()
.documentRetriever(
VectorStoreDocumentRetriever.builder()
.vectorStore(vectorStore)
.build())
.queryExpander(
MultiQueryExpander.builder()
.chatClientBuilder(chatClientBuilder)
.numberOfQueries(5)
.includeOriginal(false)
.build())
.build();
这将得到5 个全新的查询,不含原始问法。
无论是像 GameRulesService 那样显式实现 RAG,还是把细节交给 QuestionAnswerAdvisor 或 RetrievalAugmentationAdvisor,选择主要取决于你希望掌控多少细节,以及希望框架代劳多少。使用这两个 advisor 能简化代码;而显式实现 RAG 则给予你更强的可定制性,以适配你的业务需求。
接下来的下一章,我们将看看 Spring AI 在会话记忆方面提供的更多 advisor,使应用能与 LLM 进行多轮对话。
总结(Summary)
- RAG(检索增强生成) 让应用可以就模型未训练的信息发起提问并获得回答。
- RAG 还能大幅减少当模型未受训(或过度受训而无法识别上下文)时出现的所谓幻觉(hallucinations)。
- RAG 的核心是:把提示(prompt)上下文聚焦到与问题最相似的少量文档块上。
- Spring AI 集成了十余种向量存储,可用于存放与查询与问题相似的文档。
- 通过 Spring AI 的
ChatClient,你既可以显式实现 RAG,也可以使用QuestionAnswerAdvisor或RetrievalAugmentationAdvisor来为你处理 RAG 细节。