搶先一步
VMware 提供培訓和認證,助力您加速進步。
瞭解更多在本部落格系列的第一部分中,我們探討了 Spring AI 與大型語言模型整合的基礎知識。我們逐步介紹瞭如何構建自定義 ChatClient,利用 Function Calling 進行動態互動,並最佳化提示以適應 Spring Petclinic 的用例。到最後,我們擁有了一個能夠理解和處理與我們的獸醫診所領域相關的請求的功能性 AI 助手。
現在,在第二部分中,我們將透過探索檢索增強生成 (RAG) 更進一步,RAG 是一種使我們能夠處理典型 Function Calling 方法無法容納的大資料集的技術。讓我們看看 RAG 如何將 AI 與特定領域知識無縫整合。
雖然列出獸醫可以直接實現,但我選擇以此為例來展示檢索增強生成 (RAG) 的強大功能。
RAG 將大型語言模型與即時資料檢索相結合,以生成更準確且更具上下文相關性的文字。儘管此概念與我們之前的工作一致,但 RAG 通常側重於從向量儲存中檢索資料。
向量儲存以嵌入的形式儲存資料——這些是捕捉資訊含義的數字表示,例如我們獸醫的資料。這些嵌入儲存為高維向量,有助於基於語義而非傳統文字搜尋的高效相似度搜索。
例如,考慮以下獸醫及其專長
Alice Brown 醫生 - 心臟病學
Bob Smith 醫生 - 牙科
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;
}
}
這裡有很多內容需要解讀,讓我們一起瀏覽程式碼
與 listOwners
類似,我們首先從資料庫中檢索所有獸醫。
Spring AI 將 Document
型別的實體嵌入到向量儲存中。Document
表示嵌入的數值資料及其原始的、人類可讀的文字資料。這種雙重表示使得我們的程式碼能夠對映嵌入向量與自然文字之間的關聯。
要建立這些 Document
實體,我們需要將我們的 Vet
實體轉換為文字格式。Spring AI 為此提供了兩個內建讀取器:JsonReader
和 TextReader
。由於我們的 Vet
實體是結構化資料,將它們表示為 JSON 是合理的。為了實現這一點,我們使用輔助方法 convertListToJsonResource
,它利用 Jackson 解析器將獸醫列表轉換為記憶體中的 JSON 資源。
接下來,我們在向量儲存上呼叫 add(documents)
方法。此方法負責透過遍歷文件列表(我們的獸醫,採用 JSON 格式)並嵌入每個文件,同時將原始元資料與之關聯,來嵌入資料。
雖然不是嚴格必需的,但我們也生成了一個 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
實體的關係和語義,其方式遠超簡單的文字匹配。
如果我們在每次應用程式重新啟動時都執行此嵌入方法,將導致兩個顯著缺點
啟動時間長:每個 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 會像我們的系統文字中指定的那樣,指出可能還有其他獸醫可用。
實現聊天機器人使用者介面涉及使用 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 的這種無縫融合營造了一個創新不受技術壁壘阻礙的環境,為開發更智慧、更自適應的應用程式創造了新的機會。