Semantic caching: reduce costo y latencia
Semantic caching: reduce costo y latencia
En el capítulo 7 hicimos que Bifrost fuera resiliente: reintenta, conmuta a otro proveedor y reparte la carga para que ninguna petición se quede sin respuesta. Pero la petición más resiliente, más barata y más rápida es la que nunca llega al proveedor. Si dos usuarios preguntan “¿cómo reseteo mi contraseña?” y “olvidé mi clave, ¿qué hago?”, el LLM va a generar prácticamente la misma respuesta dos veces, cobrándote dos veces y haciéndote esperar dos veces. El semantic caching existe precisamente para evitar eso.
En este capítulo vas a entender por qué cachear por significado (y no solo por texto exacto) es una de las palancas de ahorro más grandes en un gateway de IA, cómo funciona internamente el plugin semantic_cache, cómo conectarlo a un vector store (Redis o Qdrant), y cuándo NO deberías cachear para no devolver datos obsoletos o peligrosos.
¿Qué es el semantic caching?
Un caché tradicional funciona por exact-match: usa el texto exacto de la petición como clave. Si el siguiente request cambia una sola coma, una mayúscula o el orden de una palabra, el caché falla (cache miss) y vuelves a pagar la inferencia. Para lenguaje natural esto es desastroso, porque los humanos casi nunca formulamos la misma idea con las mismas palabras.
El semantic caching cachea por similitud semántica. En lugar de comparar caracteres, calcula el embedding de la petición (un vector que representa su significado) y lo compara contra los embeddings de peticiones anteriores. Si encuentra una suficientemente parecida, devuelve la respuesta ya almacenada sin llamar al modelo.
flowchart LR
A["¿cómo reseteo mi contraseña?"] --> E1["[0.12, -0.88, 0.34, ...]"]
B["olvidé mi clave, ¿qué hago?"] --> E2["[0.13, -0.86, 0.35, ...]"]
E1 -. "similitud coseno 0.96" .-> E2
E2 --> H["Cache HIT: misma respuesta"]
Las dos frases tienen palabras distintas pero su distancia vectorial es mínima, así que comparten respuesta. Las ganancias son directas:
- Costo: un cache hit no consume tokens de generación del proveedor. Solo pagas, como mucho, un embedding barato (
text-embedding-3-smallcuesta una fracción de lo que cuesta ungpt-4o). - Latencia: recuperar un vector y devolver la respuesta almacenada toma milisegundos, frente a los segundos que tarda una generación completa con streaming.
- Resiliencia: si el proveedor está caído o saturado, un hit del caché responde igual, complementando los fallbacks del capítulo anterior.
Bifrost implementa esto con el plugin semantic_cache, que en realidad ofrece dos caminos de lookup complementarios:
- Direct (hash) matching: replay determinista por exact-match. Normaliza la petición y la hashea. Es gratis (no requiere embeddings) y siempre devuelve la respuesta idéntica.
- Semantic (similarity) matching: lookup por embeddings para peticiones semánticamente similares aunque la redacción difiera.
El flujo prioriza el lookup directo antes de caer al semántico: si hay coincidencia exacta, no se gasta ni siquiera el embedding.
Cómo funciona por dentro
Cada petición entrante pasa por el plugin antes de llegar al proveedor. La lógica es la siguiente:
sequenceDiagram
participant C as Cliente
participant B as Bifrost (semantic_cache)
participant V as Vector Store
participant P as Proveedor LLM
C->>B: POST /chat/completions (+ x-bf-cache-key)
B->>B: Normaliza y hashea la peticion
B->>V: Lookup directo (hash)
alt Hit directo
V-->>B: Respuesta cacheada
B-->>C: Respuesta (hit_type: direct)
else Miss directo
B->>P: Genera embedding de la peticion
P-->>B: vector [1536 dims]
B->>V: GetNearest(vector, threshold)
alt Similitud >= threshold
V-->>B: Respuesta cacheada
B-->>C: Respuesta (hit_type: semantic)
else Miss semantico
B->>P: Inferencia completa
P-->>B: Respuesta nueva
B-->>C: Respuesta
B--)V: Escribe respuesta (async)
end
end
Tres detalles importantes que la documentación deja claros:
- La cache key es obligatoria. El caché solo se activa si la petición trae el header
x-bf-cache-keyo si defines undefault_cache_keyen la config. Sin clave de partición, Bifrost no cachea nada. Esto te permite aislar caché por sesión, por tenant o por usuario. - Las escrituras son asíncronas. Cuando hay un miss y se genera una respuesta nueva, escribirla en el vector store ocurre en segundo plano: la primera petición nunca se bloquea esperando a que se guarde el caché.
- Las entradas persisten entre reinicios, porque viven en el vector store y no en memoria del proceso de Bifrost.
Cada respuesta incluye metadatos de depuración en response.ExtraFields.CacheDebug, útiles para verificar que el caché funciona:
{
"cache_hit": true,
"cache_id": "550e8500-e29b-41d4-a725-446655440001",
"hit_type": "direct",
"threshold": 0.8,
"similarity": 0.95,
"provider_used": "openai",
"model_used": "text-embedding-3-small",
"input_tokens": 100
}
El campo hit_type te dice si el acierto vino del camino direct o semantic, y similarity muestra el coseno real cuando fue semántico.
Tipos de petición soportados
El caché no se limita a chat. Cubre chat completions, text completions, la Responses API (incluyendo WebSocket), embeddings, transcriptions, speech e image generation, incluyendo sus variantes en streaming.
Configuración del plugin semantic_cache
La configuración vive en el array plugins de tu config.json (revisamos su estructura general en el capítulo 3). Esta es la configuración completa con todos los campos:
{
"plugins": [
{
"enabled": true,
"name": "semantic_cache",
"config": {
"provider": "openai",
"embedding_model": "text-embedding-3-small",
"dimension": 1536,
"ttl": "5m",
"threshold": 0.8,
"conversation_history_threshold": 3,
"exclude_system_prompt": false,
"cache_by_model": true,
"cache_by_provider": true,
"vector_store_namespace": "BifrostSemanticCachePlugin",
"default_cache_key": ""
}
}
]
}
Cada campo y su rol:
| Campo | Tipo | Default | Para qué sirve |
|---|---|---|---|
provider | string | — | Proveedor del modelo de embeddings. Se omite en modo direct-only. |
embedding_model | string | — | Modelo de embeddings (p. ej. text-embedding-3-small). |
dimension | integer | — | Tamaño del vector. Usa 1 para modo direct-only. |
ttl | duración / segundos | 5m | Expiración de cada entrada. Acepta "30s", "5m", "1h" o un número en segundos. |
threshold | número 0–1 | 0.8 | Similitud coseno mínima para considerar un hit semántico. |
conversation_history_threshold | integer | 3 | No cachea conversaciones con más mensajes que este umbral. |
exclude_system_prompt | boolean | false | Excluye los mensajes de sistema al generar la cache key. |
cache_by_model | boolean | true | Incluye el nombre del modelo en la cache key. |
cache_by_provider | boolean | true | Incluye el nombre del proveedor en la cache key. |
vector_store_namespace | string | BifrostSemanticCachePlugin | Nombre del bucket/índice donde se guardan los vectores. |
default_cache_key | string | "" | Clave de fallback cuando la petición no trae header de cache key. |
El umbral de similitud (threshold)
Es el parámetro más importante de afinar. Va de 0 a 1 y representa la similitud coseno mínima para devolver un hit:
- Más alto (p. ej.
0.95): solo cachea peticiones casi idénticas en significado. Menos hits, pero prácticamente cero riesgo de devolver una respuesta equivocada. Empieza siempre conservador. - Más bajo (p. ej.
0.75): agrupa peticiones más dispares bajo la misma respuesta. Más ahorro, pero riesgo de devolver una respuesta que no encaja del todo con la pregunta.
El default de 0.8 es un punto medio razonable. Sube o baja según midas la tasa de aciertos y la calidad percibida.
El modelo de embeddings
provider + embedding_model definen cómo se vectoriza cada petición. El dimension debe coincidir con el modelo: text-embedding-3-small produce vectores de 1536 dimensiones. Un detalle crítico de operación:
Cambiar
dimension,provideroembedding_modelcontra un namespace existente exige un namespace nuevo o limpiar el índice manualmente. Vectores de distinta dimensión o modelo no son comparables entre sí.
Modo direct-only (sin embeddings)
Si solo quieres exact-match (caché determinista, gratis, sin coste de embeddings), pon dimension: 1 y omite provider y embedding_model:
{
"plugins": [
{
"name": "semantic_cache",
"enabled": true,
"config": {
"dimension": 1,
"ttl": "5m",
"cache_by_model": true,
"cache_by_provider": true
}
}
]
}
Es ideal cuando tus peticiones se repiten literalmente (por ejemplo prompts generados por código, plantillas fijas) y no quieres pagar ni un embedding por miss.
Control por petición: headers
Más allá de la config global, puedes ajustar el comportamiento del caché request a request con headers. Esto es clave para una partición correcta:
| Header | Valor | Efecto |
|---|---|---|
x-bf-cache-key | string | Partición del caché para esta petición. Obligatorio para que se cachee. |
x-bf-cache-ttl | duración / segundos | Sobrescribe el TTL para esta petición. |
x-bf-cache-threshold | float 0–1 | Sobrescribe el umbral de similitud. |
x-bf-cache-type | direct o semantic | Restringe a un solo camino de lookup. |
x-bf-cache-no-store | true | Lee del caché pero no escribe (modo read-only). |
Un ejemplo de petición que usa una clave por sesión, un TTL corto y solo lookup directo:
curl -X POST http://localhost:8080/v1/chat/completions \
-H "Content-Type: application/json" \
-H "x-bf-cache-key: session-123" \
-H "x-bf-cache-ttl: 30s" \
-H "x-bf-cache-type: direct" \
-d '{
"model": "openai/gpt-4o-mini",
"messages": [{"role": "user", "content": "¿como reseteo mi contraseña?"}]
}'
Gestión del caché vía API
Bifrost expone endpoints para administrar el plugin y purgar entradas. Habilitar el plugin en caliente:
curl -X POST http://localhost:8080/api/plugins \
-H "Content-Type: application/json" \
-d '{
"name": "semantic_cache",
"enabled": true,
"config": {
"provider": "openai",
"embedding_model": "text-embedding-3-small",
"dimension": 1536,
"ttl": "5m",
"threshold": 0.8
}
}'
Actualizar la configuración del plugin:
curl -X PUT http://localhost:8080/api/plugins/semantic_cache \
-H "Content-Type: application/json" \
-d '{"enabled": true, "config": {"ttl": "10m"}}'
Invalidar una entrada concreta por su cache_id:
curl -X DELETE http://localhost:8080/api/cache/clear/550e8500-e29b-41d4-a725-446655440001
Limpiar todas las entradas asociadas a una cache key (útil al cerrar una sesión o invalidar un tenant):
curl -X DELETE http://localhost:8080/api/cache/clear-by-key/support-session-456
El vector store dentro del framework de Bifrost
El caché semántico necesita un sitio donde guardar y buscar vectores. Bifrost no reinventa cada base de datos: define un componente unificado llamado VectorStore dentro de su paquete framework, que ofrece una interfaz común sobre varios backends. Así, el plugin semantic_cache trabaja siempre contra la misma API independientemente de si por debajo hay Redis o Qdrant.
flowchart TD
SC["Plugin semantic_cache"] --> VS["VectorStore (framework)"]
VS --> R["Redis / Valkey"]
VS --> Q["Qdrant"]
VS --> P["Pinecone"]
VS --> W["Weaviate"]
La interfaz expone operaciones de alto nivel para gestionar namespaces y vectores. Las principales que documenta Bifrost:
CreateNamespace(ctx, name, dimensions, properties): crea una colección con su esquema.Add(ctx, namespace, id, embedding, metadata): guarda un vector con metadatos.GetNearest(ctx, namespace, queryEmbedding, filters, fields, threshold, limit): búsqueda por similitud.GetChunk(ctx, namespace, id): recupera un item concreto.GetAll(ctx, namespace, filters, fields, cursor, limit): recuperación por lotes con paginación.Delete(ctx, namespace, id): elimina un vector.
Los embeddings se almacenan como arrays de float32 (1536 dimensiones en el ejemplo) y los metadatos como map[string]interface{} junto al vector. El vector_store_namespace de tu plugin corresponde al namespace/colección donde Bifrost organiza las entradas de caché.
Vector stores soportados y cómo elegir
Bifrost soporta cuatro backends. Así los describe la documentación:
| Backend | Característica | Cuándo elegirlo |
|---|---|---|
| Redis / Valkey | Vector store in-memory de alto rendimiento | Empezar rápido, baja latencia, ya usas Redis. Necesita módulo de búsqueda (FT.*). |
| Qdrant | Motor de búsqueda en Rust con filtrado avanzado | Filtrado complejo, escalar a muchos vectores, opción cloud gestionada. |
| Pinecone | Base gestionada con opciones serverless | No querer operar infraestructura, escalado automático. |
| Weaviate | Base lista para producción con soporte gRPC | Casos avanzados de búsqueda y producción. |
Para arrancar y aprender, Redis/Valkey es la opción más simple: un contenedor y a funcionar. Cuando necesites filtrado más potente o escalar a millones de vectores, Qdrant es la siguiente parada natural. Veamos ambos.
Opción A: Redis / Valkey
Redis necesita un módulo de búsqueda que soporte comandos FT.* (creación de índices y búsqueda vectorial). Por eso no sirve un Redis pelado: usa Redis Stack o el bundle de Valkey, que ya lo incluyen.
# Redis Stack (incluye RediSearch)
docker run -d --name redis-stack -p 6379:6379 redis/redis-stack:latest
# Alternativa: bundle de Valkey
docker run -d --name valkey-bundle -p 6379:6379 valkey/valkey-bundle:9.0.0
Configuración local mínima en config.json:
{
"vector_store": {
"enabled": true,
"type": "redis",
"config": {
"addr": "localhost:6379"
}
}
}
Configuración completa con pool de conexiones y timeouts, útil para producción:
{
"vector_store": {
"enabled": true,
"type": "redis",
"config": {
"addr": "localhost:6379",
"username": "",
"password": "",
"db": 0,
"use_tls": false,
"insecure_skip_verify": false,
"cluster_mode": false,
"pool_size": 10,
"max_active_conns": 10,
"min_idle_conns": 5,
"max_idle_conns": 10,
"dial_timeout": "5s",
"read_timeout": "3s",
"write_timeout": "3s",
"context_timeout": "10s"
}
}
}
Para Redis gestionado en la nube con TLS, activa use_tls y aporta credenciales (y opcionalmente el certificado de la CA):
{
"vector_store": {
"enabled": true,
"type": "redis",
"config": {
"addr": "your-redis-host:port",
"username": "your-username",
"password": "your-password",
"db": 0,
"use_tls": true,
"ca_cert_pem": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----",
"cluster_mode": false,
"context_timeout": "10s"
}
}
}
Si despliegas un cluster de Redis, activa cluster_mode: true y deja db en 0 (los clusters solo usan la base 0):
{
"vector_store": {
"enabled": true,
"type": "redis",
"config": {
"addr": "your-cluster-endpoint:6379",
"username": "your-username",
"password": "your-password",
"use_tls": true,
"cluster_mode": true,
"context_timeout": "10s"
}
}
}
Opción B: Qdrant
Qdrant es un motor de búsqueda vectorial escrito en Rust con filtrado avanzado. Bifrost se conecta siempre por gRPC (puerto 6334), no por REST (6333). Levantarlo en local:
docker run -d \
--name qdrant \
-p 6333:6333 \
-p 6334:6334 \
-v $(pwd)/qdrant_storage:/qdrant/storage \
qdrant/qdrant:latest
Configuración local en config.json (fíjate en el puerto gRPC 6334):
{
"vector_store": {
"enabled": true,
"type": "qdrant",
"config": {
"host": "localhost",
"port": 6334
}
}
}
Para Qdrant Cloud necesitas api_key y use_tls: true:
{
"vector_store": {
"enabled": true,
"type": "qdrant",
"config": {
"host": "your-qdrant-cluster.cloud.qdrant.io",
"port": 6334,
"api_key": "your-qdrant-api-key",
"use_tls": true
}
}
}
Campos de configuración de Qdrant:
| Campo | Tipo | Default | Notas |
|---|---|---|---|
host | string | requerido | Hostname del servidor. Soporta sintaxis env.VAR_NAME. |
port | integer | 6334 | Puerto gRPC. Bifrost siempre usa la interfaz gRPC. |
api_key | string | opcional | Soporta sintaxis env.VAR_NAME. |
use_tls | boolean | false | Requerido para Qdrant Cloud. |
max_recv_msg_size_mb | integer | 64 | Súbelo para payloads grandes (p. ej. respuestas en base64). |
Tanto host como api_key admiten la sintaxis env.VAR_NAME para leer el valor de una variable de entorno en vez de incrustarlo en el config.json, evitando secretos en disco (mismo patrón de seguridad que vimos en el capítulo 4).
Buenas prácticas: cuándo NO cachear
El caché es potente, pero servir una respuesta vieja en el momento equivocado es peor que pagar una inferencia. Reglas para decidir qué dejar fuera:
- Respuestas con tools / function calling. Una llamada a herramienta depende del estado del mundo en ese instante (consultar un pedido, crear un ticket, ejecutar una acción). Cachear la respuesta de un tool call devuelve datos congelados o, peor, omite una acción que debía ejecutarse. Para estas peticiones usa
x-bf-cache-no-store: trueo no envíes cache key. - Datos en tiempo real. Precios, clima, stock, saldos, noticias: cualquier respuesta cuya validez caduca en segundos. Si aun así quieres algo de caché, fuerza un TTL muy corto con
x-bf-cache-ttl: 10s. - Respuestas personalizadas o con PII. Si la respuesta depende de la identidad del usuario, asegúrate de particionar con una
x-bf-cache-keypor usuario/tenant para no filtrar la respuesta de uno a otro. Nunca uses una clave global compartida para contenido sensible. - Conversaciones largas. El
conversation_history_threshold(default3) ya evita cachear hilos largos, donde cada turno depende fuertemente del contexto previo y la probabilidad de reúso es mínima. - Salidas creativas con alta temperatura. Si el valor del producto es la variedad (brainstorming, generación creativa), cachear arruina la experiencia devolviendo siempre lo mismo.
La estrategia práctica: activa el caché por defecto solo donde haya reúso real y respuestas estables (FAQs, soporte, clasificación, traducciones de frases recurrentes), y mantenlo apagado o read-only para el resto. Empieza con un threshold alto, mide la tasa de aciertos y la calidad con el campo CacheDebug, y baja el umbral solo cuando estés seguro.
Resumen
- El semantic caching reutiliza respuestas por similitud de significado (embeddings) en vez de por texto exacto, ahorrando tokens de generación (costo) y devolviendo respuestas en milisegundos (latencia).
- El plugin
semantic_cachecombina dos caminos: direct (hash exact-match, gratis) y semantic (búsqueda por embeddings sobre un umbral de similitud), priorizando siempre el directo. - Se configura en el array
pluginsdelconfig.json; los campos clave sonthreshold(similitud),ttl(expiración),provider+embedding_model+dimension(vectorización) yvector_store_namespace. - La cache key es obligatoria (
x-bf-cache-keyodefault_cache_key); además controlas TTL, umbral, tipo y modo read-only por petición con headersx-bf-cache-*. - El VectorStore del framework unifica cuatro backends: Redis/Valkey (rápido de arrancar, requiere módulo
FT.*), Qdrant (gRPC en puerto6334, filtrado avanzado), Pinecone y Weaviate. - No caches respuestas con tools, datos en tiempo real, salidas personalizadas con PII sin particionar, ni conversaciones largas.
Con un gateway que además de resiliente ahorra dinero, el siguiente paso es controlar quién consume y cuánto: claves virtuales, presupuestos y límites.