领先一步
VMware 提供培训和认证,以加速您的进步。
了解更多在本博客系列的第一部分中,我们探讨了将Spring AI与大型语言模型集成的基础知识。我们逐步讲解了如何构建自定义ChatClient,利用函数调用进行动态交互,并改进提示以适应Spring Petclinic的用例。最终,我们得到了一个功能齐全的AI助手,能够理解和处理与我们的兽医诊所领域相关的请求。
现在,在第二部分中,我们将更进一步,探索检索增强生成(RAG),这是一种能够让我们处理大型数据集的技术,而这些数据集在典型的函数调用方法的约束下无法处理。让我们看看RAG如何将AI与特定领域的知识无缝集成。
列出兽医本可以是一个简单的实现,但我选择这个机会来展示检索增强生成 (RAG) 的强大功能。
RAG 将大型语言模型与实时数据检索集成,以生成更准确且与上下文相关的文本。虽然这个概念与我们以前的工作一致,但RAG 通常强调从向量存储中检索数据。
向量存储以嵌入的形式存储数据——捕获信息含义的数值表示,例如关于我们兽医的数据。这些嵌入存储为高维向量,基于语义而不是传统的基于文本的搜索,从而促进高效的相似性搜索。
例如,考虑以下兽医及其专长
Alice Brown医生 - **心脏病学**
Bob Smith医生 - **牙科**
Carol White医生 - **皮肤科**
在传统的搜索中,“牙齿清洁”查询不会产生任何精确匹配。但是,使用嵌入驱动的语义搜索,系统会识别出“牙齿清洁”与“牙科”相关。因此,即使查询中从未明确提及Bob Smith医生的专长,他也会作为最佳匹配返回。这说明了嵌入如何捕获底层含义,而不仅仅是依赖于精确的关键词。虽然此过程的实现超出了本文的范围,但您可以通过查看此YouTube视频了解更多信息。
有趣的事实——这个例子是由ChatGPT本身生成的。
本质上,相似性搜索通过识别搜索查询与源数据的数值最接近的值来运行。返回最接近的匹配项。将文本转换为这些数值嵌入的过程也由LLM处理。
在处理大量数据时,使用向量存储最有效。鉴于六位兽医可以轻松地在一个对LLM的调用中处理,我的目标是将数量增加到256。即使256可能仍然相对较小,它也足以说明我们的过程。
此设置中的兽医可以拥有零个、一个或两个专长,这与Spring Petclinic中的原始示例类似。为了避免手动创建所有这些模拟数据的繁琐任务,我使用了ChatGPT的帮助。它生成了一个联合查询,该查询生成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数据库中的相关表导出为硬编码的插入语句。然后,这些语句被添加到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,其中包含一个@EventListener
,它侦听ApplicationStartedEvent
。此方法在应用程序启动并运行后会由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;
}
}
这里有很多内容需要解释,让我们逐步讲解代码。
类似于listOwners
,我们首先从数据库中检索所有兽医。
Spring AI 将类型为Document
的实体嵌入到向量存储中。Document
表示嵌入的数值数据及其原始的可读文本数据。这种双重表示允许我们的代码映射嵌入向量与自然文本之间的关联。
要创建这些Document
实体,我们需要将我们的Vet
实体转换为文本格式。Spring AI为此提供了两个内置的读取器:JsonReader
和TextReader
。由于我们的Vet
实体是结构化数据,因此将它们表示为JSON是有意义的。为了实现这一点,我们使用辅助方法convertListToJsonResource
,该方法利用Jackson解析器将兽医列表转换为内存中的JSON资源。
接下来,我们调用向量存储上的add(documents)
方法。此方法负责通过迭代文档列表(我们以JSON格式表示的兽医)并将每个文档嵌入的同时将其原始元数据与其关联来嵌入数据。
虽然不是严格要求,但我们还生成了一个vectorstore.json
文件,该文件表示我们的SimpleVectorStore
数据库的状态。此文件使我们能够观察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
实体的关系和语义,其方式远远超出简单的文本匹配。
如果我们在每次应用程序重启时运行此嵌入方法,则会导致两个重大缺点。
启动时间长:每个Vet
JSON文档都需要通过再次对LLM进行调用来重新嵌入,从而延迟应用程序的准备。
成本增加:每次应用程序启动时,嵌入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);
}
让我们回顾一下我们在这里做了什么:
我们从请求中的 Vet
实体开始。由于向量存储中的记录表示为 JSON,第一步是将 Vet
实体也转换为 JSON。
接下来,我们创建一个 SearchRequest
,它是传递给向量存储的 similaritySearch
方法的参数。SearchRequest
允许我们根据特定需求微调搜索。在本例中,我们主要使用默认值,除了 topK
参数,它决定返回多少结果。默认情况下,它设置为 4,但在我们的例子中,我们将其增加到 20。这让我们可以处理更广泛的查询,例如“有多少兽医专攻心脏病学?”
如果请求中没有提供过滤器(即 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.
最后一步是调用 similaritySearch
方法。然后,我们映射每个返回结果的 getContent()
,因为这包含实际的 Vet JSON,而不是嵌入式数据。
从这里开始,一切照常进行。LLM 完成函数调用,检索结果,并确定如何在聊天中最好地显示数据。
让我们看看它的实际效果:
看起来我们的系统文本按预期工作,防止了任何过载。现在,让我们尝试提供一些具体的标准:
从 LLM 返回的数据正是我们预期的。让我们尝试一个更广泛的问题:
LLM 成功地识别出至少 20 名专攻心脏病学的兽医,遵守我们定义的 topK(20)上限。但是,如果对结果有任何疑问,LLM 会指出可能还有其他兽医可用,正如我们的系统文本中所指定的那样。
实现聊天机器人UI涉及使用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 的这种无缝融合营造了一种创新不会受到技术障碍阻碍的环境,为开发更智能、更具适应性的应用程序创造了新的机会。