Semantic caching: reduce costo y latencia

Por: Artiko
bifrostsemantic-cachevector-storeredisqdrant

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:

Bifrost implementa esto con el plugin semantic_cache, que en realidad ofrece dos caminos de lookup complementarios:

  1. 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.
  2. 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:

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:

CampoTipoDefaultPara qué sirve
providerstringProveedor del modelo de embeddings. Se omite en modo direct-only.
embedding_modelstringModelo de embeddings (p. ej. text-embedding-3-small).
dimensionintegerTamaño del vector. Usa 1 para modo direct-only.
ttlduración / segundos5mExpiración de cada entrada. Acepta "30s", "5m", "1h" o un número en segundos.
thresholdnúmero 0–10.8Similitud coseno mínima para considerar un hit semántico.
conversation_history_thresholdinteger3No cachea conversaciones con más mensajes que este umbral.
exclude_system_promptbooleanfalseExcluye los mensajes de sistema al generar la cache key.
cache_by_modelbooleantrueIncluye el nombre del modelo en la cache key.
cache_by_providerbooleantrueIncluye el nombre del proveedor en la cache key.
vector_store_namespacestringBifrostSemanticCachePluginNombre del bucket/índice donde se guardan los vectores.
default_cache_keystring""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:

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, provider o embedding_model contra 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:

HeaderValorEfecto
x-bf-cache-keystringPartición del caché para esta petición. Obligatorio para que se cachee.
x-bf-cache-ttlduración / segundosSobrescribe el TTL para esta petición.
x-bf-cache-thresholdfloat 0–1Sobrescribe el umbral de similitud.
x-bf-cache-typedirect o semanticRestringe a un solo camino de lookup.
x-bf-cache-no-storetrueLee 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:

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:

BackendCaracterísticaCuándo elegirlo
Redis / ValkeyVector store in-memory de alto rendimientoEmpezar rápido, baja latencia, ya usas Redis. Necesita módulo de búsqueda (FT.*).
QdrantMotor de búsqueda en Rust con filtrado avanzadoFiltrado complejo, escalar a muchos vectores, opción cloud gestionada.
PineconeBase gestionada con opciones serverlessNo querer operar infraestructura, escalado automático.
WeaviateBase lista para producción con soporte gRPCCasos 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:

CampoTipoDefaultNotas
hoststringrequeridoHostname del servidor. Soporta sintaxis env.VAR_NAME.
portinteger6334Puerto gRPC. Bifrost siempre usa la interfaz gRPC.
api_keystringopcionalSoporta sintaxis env.VAR_NAME.
use_tlsbooleanfalseRequerido para Qdrant Cloud.
max_recv_msg_size_mbinteger64Sú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:

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

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.

Siguiente: Governance: virtual keys, presupuestos y límites