AI 遇上 Spring Petclinic:使用 Spring AI 實作 AI 助理 (第二部分)

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

第一部分回顧

在本部落格系列的第一部分中,我們探討了將 Spring AI 與大型語言模型整合的基本知識。 我們逐步建立了自訂的 ChatClient、利用 Function Calling 進行動態互動,並完善了我們的提示,使其適合 Spring Petclinic 的用例。 最後,我們擁有了一個功能完善的 AI 助理,能夠理解和處理與我們獸醫診所領域相關的請求。

現在,在第二部分中,我們將更進一步,探索檢索增強生成 (Retrieval-Augmented Generation, RAG),這是一種使我們能夠處理大型資料集的技術,這些資料集無法適應典型 Function Calling 方法的限制。 讓我們看看 RAG 如何將 AI 與特定領域的知識無縫整合。

檢索增強生成

雖然列出獸醫可能是一個簡單明瞭的實作,但我選擇以此為契機,展示檢索增強生成 (RAG) 的強大功能。

RAG 將大型語言模型與即時資料檢索整合,以產生更準確且與情境相關的文字。 儘管這個概念與我們之前的工作一致,但 RAG 通常強調從向量儲存庫中檢索資料。

向量儲存庫包含嵌入 (embeddings) 形式的資料,這些嵌入是捕捉資訊意義的數值表示,例如關於我們獸醫的資料。 這些嵌入以高維向量的形式儲存,以便於基於語意 (semantics) 而非傳統的基於文字的搜尋,進行有效的相似度搜尋 (similarity searches)

例如,考慮以下獸醫及其專業:

  1. Alice Brown 醫生 - 心臟科

  2. Bob Smith 醫生 - 牙科

  3. 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;
		}
	}

這裡有很多需要拆解的地方,所以讓我們逐步查看程式碼

  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 會注意到可能還有其他獸醫可用,如我們的系統文字中所述。

實作 UI

實作聊天機器人 UI 涉及使用 Thymeleaf、JavaScript、CSS 和 SCSS 預處理器。

在審閱程式碼後,我決定將聊天機器人放置在可從任何標籤頁訪問的位置,使 layout.html 成為理想的選擇。

在與 Dr. 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 社群中所有即將舉辦的活動。

查看全部