AI 遇见 Spring Petclinic:使用 Spring AI 实现 AI 助手(第二部分)

工程 | Oded Shopen | 2024 年 9 月 27 日 | ...

第一部分回顾

在本博客系列的第一部分中,我们探讨了 Spring AI 与大型语言模型集成的基础知识。我们逐步介绍了如何构建自定义 ChatClient,利用 Function Calling 进行动态交互,并优化提示以适应 Spring Petclinic 的用例。到最后,我们拥有了一个能够理解和处理与我们的兽医诊所领域相关的请求的功能性 AI 助手。

现在,在第二部分中,我们将通过探索检索增强生成 (RAG) 更进一步,RAG 是一种使我们能够处理典型 Function Calling 方法无法容纳的大数据集的技术。让我们看看 RAG 如何将 AI 与特定领域知识无缝集成。

检索增强生成

虽然列出兽医可以直接实现,但我选择以此为例来展示检索增强生成 (RAG) 的强大功能。

RAG 将大型语言模型与实时数据检索相结合,以生成更准确且更具上下文相关性的文本。尽管此概念与我们之前的工作一致,但 RAG 通常侧重于从向量存储中检索数据。

向量存储以嵌入的形式存储数据——这些是捕捉信息含义的数字表示,例如我们兽医的数据。这些嵌入存储为高维向量,有助于基于语义而非传统文本搜索的高效相似度搜索

例如,考虑以下兽医及其专长

  1. Alice Brown 医生 - 心脏病学

  2. Bob Smith 医生 - 牙科

  3. Carol White 医生 - 皮肤病学

在传统搜索中,查询“洁牙”不会产生精确匹配。然而,借助由嵌入驱动的语义搜索,系统认识到“洁牙”与“牙科”相关。因此,Bob Smith 医生将作为最佳匹配返回,即使查询中从未明确提及他的专长。这说明了嵌入如何捕捉潜在的含义,而不仅仅依赖于精确的关键词。虽然此过程的实现超出了本文的范围,但您可以通过观看此YouTube 视频了解更多信息。

有趣的事实 - 这个例子是由 ChatGPT 自己生成的。

本质上,相似度搜索通过识别搜索查询的数值与源数据数值最接近的值来操作。返回最接近的匹配项。将文本转换为这些数字嵌入的过程也由 LLM 处理。

生成测试数据

处理大量数据时,使用向量存储最为有效。考虑到六位兽医可以在一次 LLM 调用中轻松处理,我打算将数量增加到 256 位。虽然 256 位可能仍然相对较少,但它非常适合说明我们的过程。

在此设置中,兽医可以拥有零个、一个或两个专长,这与 Spring Petclinic 中的原始示例类似。为了避免手动创建所有这些模拟数据的繁琐任务,我请 ChatGPT 协助。它生成了一个 union 查询,产生 250 位兽医,并为其中 80% 的兽医分配了专长

-- Create a list of first names and last names
WITH first_names AS (
    SELECT 'James' AS name UNION ALL
    SELECT 'Mary' UNION ALL
    SELECT 'John' UNION ALL
    ...
),
last_names AS (
    SELECT 'Smith' AS name UNION ALL
    SELECT 'Johnson' UNION ALL
    SELECT 'Williams' UNION ALL
    ...
),
random_names AS (
    SELECT
        first_names.name AS first_name,
        last_names.name AS last_name
    FROM
        first_names
    CROSS JOIN
        last_names
    ORDER BY
        RAND()
    LIMIT 250
)
INSERT INTO vets (first_name, last_name)
SELECT first_name, last_name FROM random_names;

-- Add specialties for 80% of the vets
WITH vet_ids AS (
    SELECT id
    FROM vets
    ORDER BY RAND()
    LIMIT 200  -- 80% of 250
),
specialties AS (
    SELECT id
    FROM specialties
),
random_specialties AS (
    SELECT 
        vet_ids.id AS vet_id,
        specialties.id AS specialty_id
    FROM 
        vet_ids
    CROSS JOIN 
        specialties
    ORDER BY 
        RAND()
    LIMIT 300  -- 2 specialties per vet on average
)
INSERT INTO vet_specialties (vet_id, specialty_id)
SELECT 
    vet_id,
    specialty_id
FROM (
    SELECT 
        vet_id,
        specialty_id,
        ROW_NUMBER() OVER (PARTITION BY vet_id ORDER BY RAND()) AS rn
    FROM 
        random_specialties
) tmp
WHERE 
    rn <= 2;  -- Assign at most 2 specialties per vet

-- The remaining 20% of vets will have no specialties, so no need for additional insertion commands

为了确保我的数据在多次运行中保持静态和一致,我将 H2 数据库中的相关表导出为硬编码的 insert 语句。然后将这些语句添加到 data.sql 文件中

INSERT INTO vets VALUES (default, 'James', 'Carter');
INSERT INTO vets VALUES (default, 'Helen', 'Leary');
INSERT INTO vets VALUES (default, 'Linda', 'Douglas');
INSERT INTO vets VALUES (default, 'Rafael', 'Ortega');
INSERT INTO vets VALUES (default, 'Henry', 'Stevens');
INSERT INTO vets VALUES (default, 'Sharon', 'Jenkins');
INSERT INTO vets VALUES (default, 'Matthew', 'Alexander');
INSERT INTO vets VALUES (default, 'Alice', 'Anderson');
INSERT INTO vets VALUES (default, 'James', 'Rogers');
INSERT INTO vets VALUES (default, 'Lauren', 'Butler');
INSERT INTO vets VALUES (default, 'Cheryl', 'Rodriguez');
...
...
-- Total of 256 vets

-- First, let's make sure we have 5 specialties
INSERT INTO specialties (name) VALUES ('radiology');
INSERT INTO specialties (name) VALUES ('surgery');
INSERT INTO specialties (name) VALUES ('dentistry');
INSERT INTO specialties (name) VALUES ('cardiology');
INSERT INTO specialties (name) VALUES ('anesthesia');

INSERT INTO vet_specialties VALUES ('220', '2');
INSERT INTO vet_specialties VALUES ('131', '1');
INSERT INTO vet_specialties VALUES ('58', '3');
INSERT INTO vet_specialties VALUES ('43', '4');
INSERT INTO vet_specialties VALUES ('110', '3');
INSERT INTO vet_specialties VALUES ('63', '5');
INSERT INTO vet_specialties VALUES ('206', '4');
INSERT INTO vet_specialties VALUES ('29', '3');
INSERT INTO vet_specialties VALUES ('189', '3');
...
...

嵌入测试数据

对于向量存储本身,我们有多种选择。带 pgVector 扩展的 Postgres 可能是最受欢迎的选择。Greenplum——一个大规模并行 Postgres 数据库——也支持 pgVector。Spring AI 参考文档列出了当前支持的向量存储。

对于我们的简单用例,我选择了使用 Spring AI 提供的 SimpleVectorStore。此类使用一个简单的 Java ConcurrentHashMap 实现向量存储,这对于我们包含 256 位兽医的小数据集来说绰绰有余。此向量存储的配置以及聊天记忆的实现,在带有 @Configuration 注解的 AIBeanConfiguration 类中定义

@Configuration
@Profile("openai")
public class AIBeanConfiguration {

	@Bean
	public ChatMemory chatMemory() {
		return new InMemoryChatMemory();
	}

	@Bean
	VectorStore vectorStore(EmbeddingModel embeddingModel) {
		return new SimpleVectorStore(embeddingModel);
	}

}

向量存储需要在应用程序启动时立即嵌入兽医数据。为此,我添加了一个 VectorStoreController bean,它包含一个监听 ApplicationStartedEvent@EventListener。当应用程序启动并运行时,Spring 会自动调用此方法,确保在适当的时间将兽医数据嵌入到向量存储中

	@EventListener
	public void loadVetDataToVectorStoreOnStartup(ApplicationStartedEvent event) throws IOException {
		// Fetches all Vet entites and creates a document per vet
		Pageable pageable = PageRequest.of(0, Integer.MAX_VALUE);
		Page<Vet> vetsPage = vetRepository.findAll(pageable);

		Resource vetsAsJson = convertListToJsonResource(vetsPage.getContent());
		DocumentReader reader = new JsonReader(vetsAsJson);

		List<Document> documents = reader.get();
		// add the documents to the vector store
		this.vectorStore.add(documents);

		if (vectorStore instanceof SimpleVectorStore) {
			var file = File.createTempFile("vectorstore", ".json");
			((SimpleVectorStore) this.vectorStore).save(file);
			logger.info("vector store contents written to {}", file.getAbsolutePath());
		}

		logger.info("vector store loaded with {} documents", documents.size());
	}

	public Resource convertListToJsonResource(List<Vet> vets) {
		ObjectMapper objectMapper = new ObjectMapper();
		try {
			// Convert List<Vet> to JSON string
			String json = objectMapper.writeValueAsString(vets);

			// Convert JSON string to byte array
			byte[] jsonBytes = json.getBytes();

			// Create a ByteArrayResource from the byte array
			return new ByteArrayResource(jsonBytes);
		}
		catch (JsonProcessingException e) {
			e.printStackTrace();
			return null;
		}
	}

这里有很多内容需要解读,让我们一起浏览代码

  1. listOwners 类似,我们首先从数据库中检索所有兽医。

  2. Spring AI 将 Document 类型的实体嵌入到向量存储中。Document 表示嵌入的数值数据及其原始的、人类可读的文本数据。这种双重表示使得我们的代码能够映射嵌入向量与自然文本之间的关联。

  3. 要创建这些 Document 实体,我们需要将我们的 Vet 实体转换为文本格式。Spring AI 为此提供了两个内置读取器:JsonReaderTextReader。由于我们的 Vet 实体是结构化数据,将它们表示为 JSON 是合理的。为了实现这一点,我们使用辅助方法 convertListToJsonResource,它利用 Jackson 解析器将兽医列表转换为内存中的 JSON 资源。

  4. 接下来,我们在向量存储上调用 add(documents) 方法。此方法负责通过遍历文档列表(我们的兽医,采用 JSON 格式)并嵌入每个文档,同时将原始元数据与之关联,来嵌入数据。

  5. 虽然不是严格必需的,但我们也生成了一个 vectorstore.json 文件,它代表了我们的 SimpleVectorStore 数据库的状态。此文件使我们能够观察 Spring AI 如何在幕后解释存储的数据。让我们看看生成的文件,以了解 Spring AI 看到了什么。

{
  "dd919c71-06bb-4777-b974-120dfee8b9f9" : {
    "embedding" : [ 0.013877872, 0.03598228, 0.008212427, 0.00917901, -0.036433823, 0.03253927, -0.018089917, -0.0030867155, -0.0017038669, -0.048145704, 0.008974405, 0.017624263, 0.017539598, -4.7888185E-4, 0.013842596, -0.0028221398, 0.033414137, -0.02847539, -0.0066955267, -0.021885695, -0.0072387885, 0.01673529, -0.007386951, 0.014661016, -0.015380662, 0.016184973, 0.00787377, -0.019881975, -0.0028785826, -0.023875304, 0.024778388, -0.02357898, -0.023748307, -0.043094076, -0.029322032, ... ],
    "content" : "{id=31, firstName=Samantha, lastName=Walker, new=false, specialties=[{id=2, name=surgery, new=false}]}",
    "id" : "dd919c71-06bb-4777-b974-120dfee8b9f9",
    "metadata" : { },
    "media" : [ ]
  },
  "4f9aabed-c15c-43f6-9dbc-46ed9a18e176" : {
    "embedding" : [ 0.01051745, 0.032714732, 0.007800559, -0.0020621764, -0.03240663, 0.025530376, 0.0037602335, -0.0023702774, -0.004978633, -0.037364256, 0.0012831709, 0.032742742, 0.005430281, 0.00847278, -0.004285406, 0.01146276, 0.03036196, -0.029941821, 0.013220336, -0.03207052, -7.518716E-4, 0.016665466, -0.0052062077, 0.010678503, 0.0026591222, 0.0091940155, ... ],
    "content" : "{id=195, firstName=Shirley, lastName=Martinez, new=false, specialties=[{id=1, name=radiology, new=false}, {id=2, name=surgery, new=false}]}",
    "id" : "4f9aabed-c15c-43f6-9dbc-46ed9a18e176",
    "metadata" : { },
    "media" : [ ]
  },
  "55b13970-cd55-476b-b7c9-62337855ae0a" : {
    "embedding" : [ -0.0031563698, 0.03546827, 0.018778138, -0.01324492, -0.020253662, 0.027756566, 0.007182742, -0.008637386, -0.0075725033, -0.025543278, 5.850768E-4, 0.02568248, 0.0140383635, -0.017330453, 0.003935892, ... ],
    "content" : "{id=19, firstName=Jacqueline, lastName=Ross, new=false, specialties=[{id=4, name=cardiology, new=false}]}",
    "id" : "55b13970-cd55-476b-b7c9-62337855ae0a",
    "metadata" : { },
    "media" : [ ]
  },
...
...
...

太酷了!我们有一个 JSON 格式的 Vet,旁边是一组数字,这些数字对我们来说可能没什么意义,但对 LLM 却非常有意义。这些数字代表嵌入的向量数据,模型使用这些数据来理解 Vet 实体的关系和语义,其方式远超简单的文本匹配。

优化成本和快速启动

如果我们在每次应用程序重新启动时都运行此嵌入方法,将导致两个显著缺点

  1. 启动时间长:每个 Vet JSON 文档都需要通过再次调用 LLM 进行重新嵌入,这会延迟应用程序就绪时间。

  2. 成本增加:每次应用启动时嵌入 256 个文档都会向 LLM 发送 256 个请求,导致不必要的 LLM 费用使用。

嵌入更适合 ETL(抽取、转换、加载)或流处理过程,这些过程独立于主 Web 应用程序运行。这些过程可以在后台处理嵌入,而不会影响用户体验或产生不必要的成本。

为了简化 Spring Petclinic 中的操作,我决定在启动时加载预嵌入的向量存储。这种方法可以实现即时加载,并避免任何额外的 LLM 成本。以下是实现此目的对方法的补充

	@EventListener
	public void loadVetDataToVectorStoreOnStartup(ApplicationStartedEvent event) throws IOException {
		Resource resource = new ClassPathResource("vectorstore.json");

		// Check if file exists
		if (resource.exists()) {
			// In order to save on AI credits, use a pre-embedded database that was saved
			// to disk based on the current data in the h2 data.sql file
			File file = resource.getFile();
			((SimpleVectorStore) this.vectorStore).load(file);
			logger.info("vector store loaded from existing vectorstore.json file in the classpath");
			return;
		}
        // Rest of the method as before
        ...
        ...
}

vectorstore.json 文件位于 src/main/resources 下,确保应用程序在启动时总是加载预嵌入的向量存储,而不是从头开始重新嵌入数据。如果我们需要重新生成向量存储,只需删除现有的 vectorstore.json 文件并重新启动应用程序即可。新的向量存储生成后,我们可以将新的 vectorstore.json 文件放回 src/main/resources。这种方法既提供了灵活性,又避免了常规重启期间不必要的重新嵌入过程。

向量存储准备就绪后,实现 listVets 函数变得简单直接。函数定义如下

	@Bean
	@Description("List the veterinarians that the pet clinic has")
	public Function<VetRequest, VetResponse> listVets(AIDataProvider petclinicAiProvider) {
		return request -> {
			try {
				return petclinicAiProvider.getVets(request);
			}
			catch (JsonProcessingException e) {
				e.printStackTrace();
				return null;
			}
		};
	}
    record VetResponse(List<String> vet) {
    };

    record VetRequest(Vet vet) {
    }

以下是 AIDataProvider 中的实现

	public VetResponse getVets(VetRequest request) throws JsonProcessingException {
		ObjectMapper objectMapper = new ObjectMapper();
		String vetAsJson = objectMapper.writeValueAsString(request.vet());

		SearchRequest sr = SearchRequest.from(SearchRequest.defaults()).withQuery(vetAsJson).withTopK(20);
		if (request.vet() == null) {
			// Provide a limit of 50 results when zero parameters are sent
			sr = sr.withTopK(50);
		}

		List<Document> topMatches = this.vectorStore.similaritySearch(sr);
		List<String> results = topMatches.stream().map(document -> document.getContent()).toList();
		return new VetResponse(results);
	}

让我们回顾一下我们在这里做了什么

  1. 我们从请求中的一个 Vet 实体开始。由于向量存储中的记录表示为 JSON,第一步是将 Vet 实体也转换为 JSON。

  2. 接下来,我们创建一个 SearchRequest,它是传递给向量存储的 similaritySearch 方法的参数。SearchRequest 允许我们根据特定需求微调搜索。在此情况下,我们大部分使用默认值,但 topK 参数除外,它决定返回多少结果。默认情况下,此参数设置为 4,但在我们的例子中,我们将其增加到 20。这使我们能够处理更广泛的查询,例如“有多少兽医专长是心脏病学?”

  3. 如果在请求中未提供任何过滤器(即,Vet 实体为空),我们将 topK 值增加到 50。这使我们能够为“列出诊所中的兽医”等查询返回多达 50 位兽医。当然,这不会是整个列表,因为我们希望避免使用过多数据来压倒 LLM。然而,我们应该没有问题,因为我们仔细微调了系统文本来管理这些情况

    When dealing with vets, if the user is unsure about the returned results, 
    explain that there may be additional data that was not returned.
    Only if the user is asking about the total number of all vets, 
    answer that there are a lot and ask for some additional criteria. 
    For owners, pets or visits - answer the correct data.
    
  4. 最后一步是调用 similaritySearch 方法。然后我们将每个返回结果的 getContent() 进行映射,因为这包含实际的 Vet JSON,而不是嵌入数据。

从这里开始,一切照常。LLM 完成函数调用,检索结果,并确定如何在聊天中最佳地显示数据。

让我们看看实际效果

5e16fa8b-4073-4d4f-ab1a-ca1324a83616

看来我们的系统文本正常运行,避免了任何过载。现在,让我们尝试提供一些特定条件

76dcf069-0393-4749-b730-1f59a5cba3bd

LLM 返回的数据完全符合我们的预期。让我们尝试一个更广泛的问题

8ec546df-dcb4-4e1d-9f34-891dd39ff9e5

LLM 成功识别出至少 20 位专长是心脏病学的兽医,符合我们定义的 topK 上限 (20)。但是,如果对结果有任何不确定性,LLM 会像我们的系统文本中指定的那样,指出可能还有其他兽医可用。

实现用户界面

实现聊天机器人用户界面涉及使用 Thymeleaf、JavaScript、CSS 和 SCSS 预处理器。

在查看代码后,我决定将聊天机器人放置在可以从任何标签页访问的位置,这使得 layout.html 成为理想选择。

在与 Dave Syer 博士讨论 PR 期间,我意识到我不应直接修改 petclinic.css,因为 Spring Petclinic 使用 SCSS 预处理器生成 CSS 文件。

我承认——我主要是一名后端 Spring 开发者,我的职业生涯专注于 Spring、云架构、Kubernetes 和 Cloud Foundry。虽然我有一些 Angular 经验,但我不是前端开发专家。我可能能弄出点东西,但它可能看起来不够精良。

幸运的是,我有一个很棒的结对编程伙伴——ChatGPT。如果你对我是如何开发 UI 代码感兴趣,可以看看这个ChatGPT 会话。通过与大型语言模型协作进行编码练习,你能学到很多东西,这非常了不起。只是记得要仔细审查建议,而不是盲目地复制粘贴。

结论

在试用了几个月的 Spring AI 后,我深刻体会到这个项目背后的思考和努力。Spring AI 确实独一无二,因为它允许开发人员探索 AI 世界,而无需培训数百名团队成员学习像 Python 这样的新语言。更重要的是,这一经验凸显了一个更大的优势:你的 AI 代码可以与现有的业务逻辑共存于同一个代码库中。只需添加几个额外的类,你就可以轻松地为遗留代码库增强 AI 能力。无需在新的 AI 特有应用程序中从头开始重建所有数据,这极大地提高了生产力。即使是像 IDE 中现有 JPA 实体的自动代码补全这样的简单功能,也能带来巨大的变化。

Spring AI 通过简化 AI 能力的集成,有可能显著增强基于 Spring 的应用程序。它使开发人员能够利用机器学习模型和 AI 驱动的服务,而无需深厚的数据科学专业知识。通过抽象复杂的 AI 操作并将其直接嵌入熟悉的 Spring 框架中,开发人员可以专注于快速构建智能的、数据驱动的功能。AI 与 Spring 的这种无缝融合营造了一个创新不受技术壁垒阻碍的环境,为开发更智能、更自适应的应用程序创造了新的机会。

订阅 Spring 新闻通讯

保持联系,订阅 Spring 新闻通讯

订阅

抢先一步

VMware 提供培训和认证,助力您加速进步。

了解更多

获得支持

Tanzu Spring 通过一个简单的订阅提供 OpenJDK™、Spring 和 Apache Tomcat® 的支持和二进制文件。

了解更多

即将举行的活动

查看 Spring 社区所有即将举行的活动。

查看全部