1. Visión general
En las aplicaciones modernas que se integran con Large Language Models (LLMs), cuando los usuarios envían indicaciones similares o reexpresadas, terminamos haciendo llamadas redundantes al LLM, lo que genera costos innecesarios y mayor latencia.
Semantic caching aborda este desafío al almacenar la consulta del usuario junto con la respuesta del LLM en un vector store. Cuando llega una nueva consulta, primero verificamos el vector store para encontrar preguntas previamente contestadas que sean semánticamente similares. Si se encuentra una coincidencia cercana, devolvemos la respuesta almacenada, evitando por completo la llamada original al LLM.
En este tutorial, construiremos una capa de caché semántico usando Spring AI y Redis.
2. Configuración del proyecto
Antes de comenzar a implementar nuestra capa de caché semántica, necesitaremos incluir las dependencias necesarias y configurar correctamente nuestra aplicación.
2.1. Configuración de un modelo de incrustación
Primero, configuraremos un modelo de incrustación que convierta el texto de lenguaje natural en vectores numéricos. Para nuestra demostración, utilizaremos un modelo de incrustación de OpenAI.
Comencemos añadiendo la dependencia necesaria al archivo pom.xml de nuestro proyecto:
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
<version>1.0.3</version>
</dependency>
Aquí, importamos la dependencia OpenAI starter de Spring AI, que utilizaremos para interactuar con un modelo de incrustación.
A continuación, configuremos la clave API de OpenAI y especifiquemos el modelo de incrustación en nuestro archivo application.yaml:
spring:
ai:
openai:
api-key: ${OPENAI_API_KEY}
embedding:
options:
model: text-embedding-3-small
dimensions: 512
Usamos el marcador de posición de propiedad ${} para cargar el valor de nuestra clave API desde una variable de entorno.
Además, especificamos text-embedding-3-small como nuestro modelo de incrustación con 512 dimensiones. Alternativamente, podemos usar un modelo de incrustación diferente, ya que el modelo o proveedor de IA específico no es relevante para esta demostración.
Al configurar estas propiedades, Spring AI crea automáticamente un bean de tipo EmbeddingModel para nosotros.
2.2. Configuración de Redis como vector store
A continuación, necesitaremos un vector store para guardar nuestras incrustaciones de consultas y sus respuestas correspondientes del LLM. Utilizaremos Redis para este propósito, pero nuevamente, podemos elegir un vector store según nuestros requisitos.
Primero, añadamos la dependencia requerida:
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-vector-store-redis</artifactId>
<version>1.0.3</version>
</dependency>
La dependencia Redis vector store starter nos permite establecer una conexión con Redis e interactuar con ella como un vector store.
Ahora, configuremos la URL de conexión para permitir que nuestra aplicación se conecte a la instancia de Redis provisionada:
spring:
data:
redis:
url: ${REDIS_URL}
Nuevamente usamos un marcador de posición de propiedad para cargar la URL de conexión de Redis desde una variable de entorno. Es importante notar que la URL debe seguir el formato redis://usuario:contraseña@host:puerto.
A continuación, necesitaremos configurar algunas propiedades personalizadas para nuestra implementación de caché semántica. Almacenaremos estas propiedades en el archivo application.yaml de nuestro proyecto y usaremos @ConfigurationProperties para mapear los valores a un registro:
@ConfigurationProperties(prefix = "dev.codeja.semantic.cache")
record SemanticCacheProperties(
Double similarityThreshold,
String contentField,
String embeddingField,
String metadataField
) {}
Aquí, el similarityThreshold determina cuán semánticamente similar debe ser una nueva consulta (en una escala de 0 a 1) a una consulta almacenada para que se considere una coincidencia.
El contentField especifica el nombre del campo donde almacenaremos la consulta original de lenguaje natural dentro de nuestro vector store. El embeddingField almacena la representación vectorial de esta consulta de lenguaje natural, y el metadataField almacena la respuesta correspondiente del LLM.
Ahora definamos los valores de estas propiedades en nuestro application.yaml:
com:
codeja:
semantic:
cache:
similarity-threshold: 0.8
content-field: question
embedding-field: embedding
metadata-field: answer
Establecemos un umbral de similitud de 0.8, garantizando que solo las consultas altamente similares desencadenen aciertos de caché. A continuación, los tres nombres de campo que elegimos indican claramente qué datos contiene cada campo.
Con nuestras propiedades definidas, creemos los beans necesarios para interactuar con nuestro vector store:
@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();
}
}
Primero, creamos un bean JedisPooled que Spring AI usa para comunicarse con Redis. Pasamos la URL de conexión que configuramos en nuestro application.yaml usando el bean RedisProperties auto-configurado.
Luego, definimos nuestro bean RedisVectorStore, pasando el bean JedisPooled y el bean EmbeddingModel auto-configurado. Además, usamos nuestro bean semanticCacheProperties para definir nuestros nombres de campo personalizados. El bean RedisVectorStore es la clase central que usaremos en la sección siguiente para interactuar con nuestro vector store.
3. Implementación de la caché semántica
Con nuestra configuración en su lugar, construyamos el servicio responsable de guardar y buscar en nuestro caché semántico.
3.1. Guardar la respuesta del LLM en la caché
Primero, creemos un método para guardar las respuestas del LLM:
@Service
@EnableConfigurationProperties(SemanticCacheProperties.class)
class SemanticCachingService {
private final VectorStore vectorStore;
private final SemanticCacheProperties semanticCacheProperties;
// constructor estándar
void save(String question, String answer) {
Document document = Document
.builder()
.text(question)
.metadata(semanticCacheProperties.metadataField(), answer)
.build();
vectorStore.add(List.of(document));
}
}
En nuestra clase SemanticCachingService, definimos un método save() que toma una pregunta de lenguaje natural y su respuesta correspondiente como entrada.
Dentro de nuestro método, creamos un objeto Document con la pregunta como el contenido principal de text y almacenamos la respuesta en los metadatos.
Finalmente, usamos el método add() del bean vectorStore inyectado para guardar el documento. El bean genera automáticamente una incrustación para el texto del documento —es decir, la pregunta— y la almacena junto con la pregunta y la respuesta en la caché semántica configurada.
3.2. Realizar una búsqueda semántica en la caché
Ahora, implementemos la funcionalidad de búsqueda para recuperar respuestas almacenadas:
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);
}
En nuestro método search(), primero construimos una instancia de SearchRequest. Pasamos la pregunta como la consulta, establecemos el similarityThreshold de nuestras propiedades y pasamos 1 al método topK() para recuperar solo la mejor coincidencia.
Luego, pasamos nuestra searchRequest al método similaritySearch() del bean vectorStore. Nuevamente, el bean genera automáticamente una incrustación para la pregunta de entrada en segundo plano y busca en nuestra caché semántica la entrada más similar que cumpla con nuestro umbral.
Si no se encontró ninguna entrada similar, simplemente devolvemos un Optional vacío.
Alternativamente, si se encuentra una coincidencia, extraemos el primer Document de los results, extraemos la respuesta de sus metadatos y la devolvemos envuelta en un Optional.
4. Pruebas de nuestra implementación
Finalmente, escribamos una prueba simple para verificar que nuestra implementación de caché semántica funcione correctamente:
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();
Primero, guardamos un par pregunta y respuesta original en el vector store usando nuestro semanticCachingService.
Luego, buscamos usando una versión reexpresada de la pregunta original. A pesar de la diferente redacción, nuestro servicio reconoce la similitud y devuelve la respuesta almacenada.
Finalmente, verificamos que una pregunta no relacionada cuyo significado semántico sea completamente diferente resulte en una pérdida de caché.
5. Conclusión
En este artículo, hemos explorado la implementación de caché semántica usando Spring AI.
Configuramos un modelo de incrustación de OpenAI para convertir texto en representaciones vectoriales y configuramos Redis como un vector store para almacenar y buscar estos vectores. Luego, construimos y probamos un servicio de caché que guarda respuestas del LLM y las recupera para consultas semánticamente similares, reduciendo costos y latencia.
Para nuestra demostración, mantenemos las cosas simples. Podemos encontrar un ejemplo más avanzado que construya la caché semántica sobre un chatbot de Retrieval-Augmented Generation (RAG) aquí.
Newsletter Semanal de Java
Cada viernes recibe lo más nuevo del ecosistema Java: frameworks, herramientas y mejores prácticas.
Sin spam. Cancela cuando quieras.