Cada vez que tu aplicación llama a un LLM, paga dos veces: en dinero y en tiempo de espera. Un modelo como GPT-4o puede tardar entre 1 y 3 segundos en responder, y el coste por millón de tokens se nota cuando tienes tráfico real. La caché semántica ataca exactamente ese problema: si alguien ya preguntó algo parecido, devuelves la respuesta almacenada sin tocar el modelo.
La clave está en "parecido". Una caché tradicional compara cadenas de texto. "¿Qué es Java?" y "Explícame Java" son cadenas distintas, así que la caché no acierta. La caché semántica compara significados: convierte cada pregunta en un vector numérico (un embedding) y busca vectores cercanos. Si la distancia es menor que un umbral, considera que la pregunta es equivalente.
Embeddings: texto convertido en números
Un embedding es una lista de números que representa el significado de un texto. Un modelo de embedding entrenado coloca frases similares en zonas cercanas del espacio vectorial. "¿Cuánto cuesta Java?" y "¿Cuál es el precio de Java?" tendrán vectores muy próximos; "¿Qué es una moto?" estará lejos.
La similitud entre dos vectores se mide habitualmente con similitud coseno, que da un valor entre -1 y 1. Valores por encima de 0.90 suelen indicar preguntas equivalentes, aunque el umbral óptimo depende de tu caso de uso.
pgvector: vectores dentro de PostgreSQL
pgvector es una extensión de PostgreSQL que añade el tipo de dato vector y operadores de búsqueda por similitud. No necesitas una base de datos vectorial separada como Pinecone o Weaviate; tu PostgreSQL de siempre se encarga.
Para habilitar la extensión en tu base de datos:
CREATE EXTENSION IF NOT EXISTS vector;
Con eso ya puedes crear columnas de tipo vector(1536) (dimensión habitual para los embeddings de OpenAI) e indexarlas con HNSW o IVFFlat para búsquedas rápidas sobre millones de filas.
Spring AI: el pegamento entre Java y los LLMs
Spring AI es el módulo oficial de Spring para trabajar con modelos de lenguaje. Abstrae proveedores (OpenAI, Anthropic, Mistral, Ollama
) detrás de interfaces comunes: ChatModel, EmbeddingModel y VectorStore. Cambiar de proveedor de LLM no debería requerir reescribir tu lógica de negocio.
Spring AI incluye soporte nativo para pgvector a través de PgVectorStore. Esto te permite almacenar embeddings y hacer búsquedas de similitud con unas pocas líneas de configuración.
Dependencias Maven
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-pgvector-store-spring-boot-starter</artifactId>
</dependency>
Asegúrate de importar el BOM de Spring AI en tu bloque <dependencyManagement> para que las versiones sean coherentes.
Configuración en application.yml
spring:
datasource:
url: jdbc:postgresql://localhost:5432/miapp
username: usuario
password: secreto
ai:
openai:
api-key: ${OPENAI_API_KEY}
vectorstore:
pgvector:
index-type: HNSW
distance-type: COSINE_DISTANCE
dimensions: 1536
initialize-schema: true
Con initialize-schema: true Spring AI crea la tabla vector_store automáticamente al arrancar. En producción probablemente quieras gestionar el esquema con Flyway o Liquibase y desactivar esta opción.
Flujo de la caché semántica
El proceso tiene dos caminos: acierto de caché (cache hit) y fallo de caché (cache miss).
- El usuario envía una pregunta.
- Generas el embedding de esa pregunta con
EmbeddingModel. - Buscas en pgvector si existe un vector con similitud por encima del umbral (por ejemplo, 0.92).
- Si hay acierto: devuelves la respuesta guardada. Sin llamar al LLM.
- Si hay fallo: llamas al LLM, guardas la pregunta + su embedding + la respuesta, y devuelves el resultado.
Implementación en Spring AI
@Service
public class SemanticCacheService {
private final VectorStore vectorStore;
private final EmbeddingModel embeddingModel;
private final ChatModel chatModel;
private static final double SIMILARITY_THRESHOLD = 0.92;
public SemanticCacheService(VectorStore vectorStore,
EmbeddingModel embeddingModel,
ChatModel chatModel) {
this.vectorStore = vectorStore;
this.embeddingModel = embeddingModel;
this.chatModel = chatModel;
}
public String ask(String question) {
// 1. Buscar en caché
SearchRequest searchRequest = SearchRequest.query(question)
.withTopK(1)
.withSimilarityThreshold(SIMILARITY_THRESHOLD);
List<Document> results = vectorStore.similaritySearch(searchRequest);
if (!results.isEmpty()) {
// Cache hit: devolver respuesta almacenada
return results.get(0).getMetadata().get("answer").toString();
}
// 2. Cache miss: llamar al LLM
String answer = chatModel.call(question);
// 3. Guardar en caché
Document doc = new Document(question,
Map.of("answer", answer));
vectorStore.add(List.of(doc));
return answer;
}
}
El VectorStore.add() calcula el embedding internamente usando el EmbeddingModel configurado, así que no tienes que gestionarlo de forma manual. Spring AI se encarga de convertir el texto en vector antes de persistirlo.
Ajustar el umbral de similitud
El umbral es el parámetro más delicado de la caché semántica. Si lo pones demasiado alto (0.99), casi nunca habrá aciertos y ahorras poco. Si lo bajas mucho (0.80), responderás preguntas distintas con la misma respuesta y perderás precisión.
Un punto de partida razonable es 0.92 para texto en español y dominios técnicos. Para dominar el umbral óptimo, registra aciertos y fallos en producción y analiza los casos límite: qué preguntas se están considerando equivalentes y si esa equivalencia es correcta para tu aplicación.
También puedes segmentar la caché por usuario o por tipo de consulta para evitar que una respuesta de un usuario acabe colándose como respuesta para otro en contextos donde el historial importa.
Métricas que deberías medir
Sin métricas no sabes si la caché funciona. Las más útiles:
- Hit rate: porcentaje de preguntas que aciertan en caché. Con un 40% de aciertos ya reduces la factura a la mitad en ese tramo.
- Latencia por tipo: compara el tiempo de respuesta en hits (decenas de milisegundos) frente a misses (1-3 segundos o más). El impacto en experiencia de usuario es inmediato.
- Tokens ahorrados: multiplica el número de hits por los tokens medios de cada respuesta. Eso te da el ahorro en dólares de forma directa.
Puedes exponer estas métricas con Micrometer y Actuator, que Spring Boot incluye por defecto. Con un par de contadores en el servicio ya tienes un dashboard en Grafana.
Consideraciones para producción
La caché semántica no es gratis. Genera un embedding (que también cuesta tokens, aunque mucho menos que una generación) en cada petición para hacer la búsqueda. Con embeddings de OpenAI el coste es marginal, pero con modelos locales como Ollama es prácticamente cero.
Añade un índice HNSW en pgvector para que las búsquedas escalen bien cuando la caché crezca:
CREATE INDEX ON vector_store USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
Este patrón encaja bien con arquitecturas de microservicios donde varios servicios comparten el mismo proveedor de LLM. Una caché centralizada en pgvector evita que dos servicios distintos hagan la misma llamada al modelo de forma independiente. Para ver cómo organizar la arquitectura completa, los patrones de diseño en Java aplicables a esta arquitectura te dan el contexto estructural necesario.
La caché semántica también funciona bien con Java 25 LTS, la plataforma de referencia para Spring Boot 3, que ya incluye mejoras de rendimiento en la gestión de hilos virtuales y reduce la latencia en las llamadas a servicios externos.
Con esta aproximación, aplicaciones con patrones de preguntas repetitivas pueden reducir el gasto en LLM entre un 30 y un 60% sin tocar la experiencia del usuario.
Imagen: Pexels / panumas nikhomkhai
