使用 Spring AI 實現語義緩存
1. 概述
在與大型語言模型 (LLM) 整合的現代應用程式中,當使用者提交類似或重新措辭的提示時,我們最終會對 LLM 進行冗餘調用,從而導致不必要的成本和更高的延遲。
語義快取透過將使用者的查詢和 LLM 的回應儲存在向量儲存中來應對這項挑戰。當收到新的查詢時,我們首先檢查向量儲存中是否存在語義相似且已回答過的問題。如果找到高度匹配的問題,則直接傳回快取的回應,完全繞過原始的 LLM 呼叫。
在本教程中,我們將使用 Spring AI 和 Redis 建立語義快取層。
2. 項目設定
在開始實作語義快取層之前,我們需要添加必要的依賴項並正確配置應用程式。
2.1 配置嵌入模型
首先,我們將配置一個嵌入模型,用於將自然語言文字轉換為數值向量。為了演示,我們將使用 OpenAI 的嵌入模型。
首先,讓我們將必要的依賴項新增到專案的pom.xml檔案中:
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
<version>1.0.3</version>
</dependency>
在這裡,我們導入 Spring AI 的OpenAI 啟動器依賴項,我們將使用它來與嵌入模型互動。
接下來,讓我們設定OpenAI API 金鑰,並在application.yaml檔案中指定嵌入模型:
spring:
ai:
openai:
api-key: ${OPENAI_API_KEY}
embedding:
options:
model: text-embedding-3-small
dimensions: 512
我們使用${}屬性佔位符從環境變數載入 API 金鑰的值。
此外,我們指定使用 512 維的text-embedding-3-small作為嵌入模型。或者,我們也可以使用其他嵌入模型,因為具體的 AI 模型或提供者與本次演示無關。
配置這些屬性後, Spring AI 會自動為我們建立一個EmbeddingModel類型的 bean 。
2.2. 將 Redis 配置為 Vector 存儲
接下來,我們需要一個向量儲存來保存查詢嵌入及其對應的LLM回應。我們將使用Redis來實現這一目的,但同樣,我們可以根據需求選擇其他向量儲存。
首先,我們來新增所需的依賴項:
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-vector-store-redis</artifactId>
<version>1.0.3</version>
</dependency>
Redis 向量儲存啟動器依賴項使我們能夠與 Redis 建立連接,並像使用向量儲存一樣與其進行互動。
現在,讓我們設定連接 URL,以便我們的應用程式能夠連接到已配置的 Redis 實例:
spring:
data:
redis:
url: ${REDIS_URL}
我們再次使用屬性佔位符從環境變數載入 Redis 連線 URL。需要注意的是, URL 應遵循redis://username:password@hostname:port的格式。
接下來,我們需要為語義快取實作配置一些自訂屬性。我們將這些屬性儲存在專案的application.yaml檔案中,並使用@ConfigurationProperties將值對應到一筆記錄:
@ConfigurationProperties(prefix = "com.baeldung.semantic.cache")
record SemanticCacheProperties(
Double similarityThreshold,
String contentField,
String embeddingField,
String metadataField
) {}
在這裡, similarityThreshold決定了新查詢與快取查詢在語意上的相似程度(從 0 到 1),只有達到這個程度,新查詢才會被視為匹配項。
contentField指定了向量儲存中用於儲存原始自然語言查詢的欄位名稱metadataField embeddingField對應 LLM 的答案。
現在,讓我們在application.yaml檔案中定義這些屬性的值:
com:
baeldung:
semantic:
cache:
similarity-threshold: 0.8
content-field: question
embedding-field: embedding
metadata-field: answer
我們將相似度閾值設定為0.8 ,以確保只有高度相似的查詢才會觸發快取命中。此外,我們選擇的三個欄位名稱清楚地表明了每個欄位包含的資料內容。
屬性定義完畢後,讓我們建立與向量儲存互動所需的 bean:
@Configuration
@EnableConfigurationProperties(SemanticCacheProperties.class)
class LLMConfiguration {
@Bean
JedisPooled jedisPooled(RedisProperties redisProperties) {
return new JedisPooled(redisProperties.getUrl());
}
@Bean
RedisVectorStore vectorStore(
JedisPooled jedisPooled,
EmbeddingModel embeddingModel,
SemanticCacheProperties semanticCacheProperties
) {
return RedisVectorStore
.builder(jedisPooled, embeddingModel)
.contentFieldName(semanticCacheProperties.contentField())
.embeddingFieldName(semanticCacheProperties.embeddingField())
.metadataFields(
RedisVectorStore.MetadataField.text(semanticCacheProperties.metadataField()))
.build();
}
}
首先,我們建立一個JedisPooled bean,Spring AI 使用它與 Redis 通訊。我們使用自動設定的RedisProperties bean 傳遞在application.yaml檔案中設定的連線 URL。
接下來,我們定義RedisVectorStore bean,並傳入JedisPooled bean 和自動配置的EmbeddingModel bean。此外,我們使用semanticCacheProperties bean 來定義自訂欄位名稱。 RedisVectorStore RedisVectorStore是我們將在下一節中用來與向量儲存互動的核心類別。
3. 實作語意緩存
配置完成後,讓我們建立負責保存和搜尋語義快取的服務。
3.1. 將LLM回應儲存到緩存
我們先建立一個保存LLM回應的方法:
@Service
@EnableConfigurationProperties(SemanticCacheProperties.class)
class SemanticCachingService {
private final VectorStore vectorStore;
private final SemanticCacheProperties semanticCacheProperties;
// standard constructor
void save(String question, String answer) {
Document document = Document
.builder()
.text(question)
.metadata(semanticCacheProperties.metadataField(), answer)
.build();
vectorStore.add(List.of(document));
}
}
在這裡,在我們的SemanticCachingService類別中,我們定義了一個save()方法,該方法接受一個自然語言question及其對應的answer作為輸入。
在我們的方法內部,我們建立一個Document對象,以question作為主要text內容,並將answer儲存在元資料中。
最後,我們使用自動注入的vectorStore bean 的add()方法來保存document 。該 bean 會自動為文件文字(即question )產生嵌入,並將其與question和answer一起儲存在配置的語義快取中。
3.2. 對快取執行語義搜尋
現在,讓我們實作搜尋功能以檢索快取的回應:
Optional<String> search(String question) {
SearchRequest searchRequest = SearchRequest.builder()
.query(question)
.similarityThreshold(semanticCacheProperties.similarityThreshold())
.topK(1)
.build();
List<Document> results = vectorStore.similaritySearch(searchRequest);
if (results.isEmpty()) {
return Optional.empty();
}
Document result = results.getFirst();
return Optional
.ofNullable(result.getMetadata().get(semanticCacheProperties.metadataField()))
.map(String::valueOf);
}
在這裡,在我們的search()方法中,我們首先建立一個SearchRequest實例。我們將question作為查詢傳遞,根據屬性設定similarityThreshold ,並將1傳遞給topK()方法,以便僅檢索單一最佳匹配項。
然後,我們將searchRequest傳遞給vectorStore bean 的similaritySearch()方法。同樣,該 bean 會在後台自動為input問題產生嵌入,並在我們的語義快取中搜尋符合我們閾值的最相似條目。
如果沒有找到類似的條目,我們只需返回一個空的Optional 。
或者,如果找到匹配項,我們從results中提取第一個Document ,從其元資料中提取answer ,並將其包裝在Optional中返回。
4. 測試我們的實施方案
最後,讓我們編寫一個簡單的測試來驗證我們的語義快取實作是否正常運作:
String question = "How many sick leaves can I take?";
String answer = "No leaves allowed! Get back to work!!";
semanticCachingService.save(question, answer);
String rephrasedQuestion = "How many days sick leave can I take?";
assertThat(semanticCachingService.search(rephrasedQuestion))
.isPresent()
.hasValue(answer);
String unrelatedQuestion = "Can I get a raise?";
assertThat(semanticCachingService.search(unrelatedQuestion))
.isEmpty();
首先,我們使用semanticCachingService將原始question和answer對保存到向量儲存中。
然後,我們使用原question的改寫版本進行搜尋。儘管措辭不同,但我們的服務能夠識別出相似之處並返回快取的answer 。
最後,我們驗證了語義意義完全不同的不相關問題會導致快取未命中。
5. 結論
在本文中,我們探討如何使用 Spring AI 實作語意快取。
我們配置了一個來自 OpenAI 的嵌入模型,將文字轉換為向量表示,並設定了 Redis 作為向量儲存來儲存和搜尋這些嵌入。然後,我們建立並測試了一個快取服務,該服務保存 LLM 回應,並在語義相似的查詢中檢索它們,從而降低成本和延遲。
為了演示,我們盡量簡化了操作。您可以在這裡找到一個更高級的範例,該範例在檢索增強生成(RAG)聊天機器人之上建立了語義快取。
與往常一樣,本文中使用的所有程式碼範例都可以在 GitHub 上找到。