Semantic cache en RAG: el recepcionista con memoria fotográfica

TL;DR

En un RAG con tráfico real, el 30–70% de las queries son semánticamente equivalentes a alguna anterior aunque el texto sea diferente. El semantic cache intercepta esas queries antes del retriever y el LLM, devolviendo la respuesta ya calculada si la similitud coseno con una query previa supera un umbral θ (típicamente 0,92–0,95). Con 10.000 requests/día y un hit rate del 45%, eso equivale a no ejecutar 4.500 generaciones de LLM: aproximadamente 0,62 horas de GPU ahorradas cada día en un cluster con Llama-3.1-70B. El trade-off fundamental es que θ alto da respuestas más precisas pero menor ahorro; θ bajo maximiza el ahorro pero puede devolver respuestas incorrectas para queries sutilmente distintas.


La analogía: el recepcionista con cuaderno

Imagina el mostrador de recepción de un hotel de 400 habitaciones. A lo largo del día, el recepcionista recibe cientos de preguntas. Pero si analizas el libro de registro, verás que el 60% de esas preguntas son variantes de las mismas diez:

  • “¿Dónde está el gimnasio?”
  • “¿A qué hora es el desayuno?”
  • “¿Tienen aparcamiento?”
  • “¿Cómo conecto al WiFi?”

Al tercer día, el recepcionista ha construido mentalmente un cuaderno de respuestas. Cuando alguien pregunta “¿dónde puedo ir a hacer ejercicio?”, no llama al conserje (retrieval) ni consulta el manual interno del hotel de 300 páginas (LLM): mira el cuaderno, identifica que esa pregunta es lo mismo que “¿dónde está el gimnasio?”, y responde en dos segundos.

Pero cuando alguien pregunta “¿a qué hora cierra el gimnasio hoy?”, el recepcionista sabe que no puede fiar del cuaderno: el horario puede haber cambiado por un evento privado. Tiene que llamar al conserje.

Ese es exactamente el mecanismo del semantic cache:

  • El cuaderno es el cache store (Redis con índice vectorial, o una collection de Qdrant).
  • Identificar que “hacer ejercicio” ≈ “gimnasio” es la búsqueda por similitud coseno con umbral θ.
  • Llamar al conserje es el retrieval sobre el corpus.
  • Consultar el manual es la generación del LLM.
  • “Hoy” es la señal de consulta temporal que invalida el cache.

El umbral θ es exactamente lo que distingue “dónde está” (igual semánticamente) de “a qué hora está hoy” (distinto semánticamente). No es magia: es aritmética vectorial sobre representaciones aprendidas.


El problema en producción

Un pipeline RAG típico tiene tres capas de latencia y cómputo: embedding de la query, vector search sobre el corpus, y generación con el LLM. En desarrollo, ese coste es irrelevante. En producción con 50 usuarios concurrentes, cada una de esas capas escala linealmente con el número de requests.

El problema es que los usuarios hacen las mismas preguntas una y otra vez, con formulaciones levemente distintas:

Query originalQuery equivalente
“¿Cómo configuro el agente?”“¿Cuál es el proceso para configurar el agente?”
“error al instalar la dependencia”“falla la instalación de la dependencia”
“¿qué es un embedding?”“explícame qué son los embeddings”

Ejecutar el pipeline completo para cada una de estas variantes es desperdicio puro. Los estudios empíricos en sistemas de soporte técnico y Q&A corporativo reportan que entre el 30% y el 70% de las queries de un día son semánticamente redundantes respecto a queries anteriores de la misma semana.

La distribución de queries en sistemas reales sigue una ley de potencias similar a la distribución de Zipf: los 100 temas más frecuentes concentran aproximadamente el 60% del tráfico total. Un cache bien calibrado captura exactamente esa concentración.


Cómo funciona el semantic cache

El flujo completo se puede ver en el diagrama siguiente. Describámoslo primero en prosa.

Cuando llega una nueva query $q$:

  1. Embedding de la query: $q$ se embebe con el mismo modelo que se usó para indexar el corpus. Esto es crítico: si el corpus se indexó con text-embedding-3-large y el cache usa un embedder distinto, los espacios vectoriales no son comparables.

  2. Búsqueda en el cache store: se ejecuta una búsqueda ANN (Approximate Nearest Neighbor) sobre los vectores de queries previamente cacheadas. Se recupera la query más similar $q^*$ y su similitud coseno $s$.

  3. Decisión por umbral: $$ \text{respuesta} = \begin{cases} r^* & \text{si } s(q, q^) \geq \theta \ \text{pipeline}(q) & \text{si } s(q, q^) < \theta \end{cases} $$ donde $r^$ es la respuesta cacheada asociada a $q^$.

  4. En caso de miss: se ejecuta el pipeline completo (retrieval + LLM). La respuesta generada se almacena en el cache con un TTL configurable para futuras queries similares.

El cache store no es una base de datos clave-valor ordinaria. Es un vector index sobre los embeddings de las queries, con los valores siendo las respuestas generadas. Cada entrada tiene la estructura:

{vector: embed(q), response: r, ttl: T, metadata: {...}}

Diagrama del flujo

Flujo del semantic cache en un pipeline RAGDiagrama de flujo mostrando cómo una query pasa primero por el semantic cache y, dependiendo de si hay hit o miss, se devuelve respuesta cacheada o se ejecuta el pipeline completo de retrieval y LLM.Querydel usuarioEmbeddermismo modeloCache checkANN searchsim ≥ θ ?HITRespuestacacheada → usuarioMISSRetrievervector searchLLMgeneraciónRespuestanueva → usuariostore + TTLCache storeRedis / Qdrant— — — HIT pathcache hitcache missstore / retroalimentación
Flujo del semantic cache como middleware entre el gateway y el retriever. Los cache hits evitan por completo el vector search sobre el corpus y la generación del LLM.

El umbral θ y su trade-off

El umbral θ es el parámetro más sensible del sistema. Funciona exactamente como el umbral de reconocimiento del recepcionista: si es demasiado exigente, solo identificará preguntas textualmente idénticas y el cuaderno no servirá de mucho. Si es demasiado laxo, devolverá la respuesta de “¿dónde está el gimnasio?” a alguien que preguntó “¿a qué hora cierra el gimnasio?”.

La similitud coseno entre dos vectores $\mathbf{a}$ y $\mathbf{b}$ es:

$$ s(\mathbf{a}, \mathbf{b}) = \frac{\mathbf{a} \cdot \mathbf{b}}{|\mathbf{a}| \cdot |\mathbf{b}|} $$

Para texto en prosa (español o inglés), los embedders modernos como text-embedding-3-large o nomic-embed-text asignan similitudes coseno en torno a 0,90–0,96 a paráfrasis semánticamente equivalentes y similitudes de 0,75–0,88 a queries relacionadas pero no equivalentes.

La métrica de calidad del cache no es solo el hit rate: es la precision@cache, definida como la fracción de respuestas cacheadas que siguen siendo correctas para la nueva query. Una respuesta cacheada es “correcta” si un evaluador (otro LLM o métricas como BERTScore) la considera equivalente a la que el pipeline completo habría generado para esa query específica.

θHit rate estimadoprecision@cache estimadaAhorro efectivo
0,85~65%~72%~47%
0,90~55%~85%~47%
0,92~48%~91%~44%
0,93~45%~94%~42%
0,95~35%~98%~34%
0,97~18%~99,5%~18%

El ahorro efectivo se define como $\text{hit rate} \times \text{precision@cache}$, ya que un hit con respuesta incorrecta no es un ahorro: es un error que puede costar más en pérdida de confianza que lo que se ahorró en GPU.

La zona óptima empírica para la mayoría de aplicaciones de Q&A corporativo en español o inglés está entre θ = 0,92 y θ = 0,95. En dominios muy especializados donde matices pequeños cambian la respuesta (medicina, derecho, finanzas), conviene θ ≥ 0,95.


Matemáticas del ahorro

Pongamos números concretos sobre un sistema real.

Configuración base:

  • 10.000 requests/día
  • Corpus técnico de 1 millón de chunks en un índice Qdrant
  • LLM: Llama-3.1-70B en 4×H100 SXM (320 GB, NVLink)
  • Respuesta media: 200 tokens de output
  • Throughput del LLM en este hardware: ~400 tokens/s/GPU con batching (continuous batching activo, véase continuous-batching-fundamentos)

Coste de una request sin cache:

El embedding de la query tarda ~2 ms en una GPU. El vector search ANN sobre 1 M de chunks en Qdrant tarda ~5 ms (medida empírica con HNSW, ef=128). La generación de 200 tokens a 400 tok/s total (4 GPUs) equivale a:

$$ t_{\text{LLM}} = \frac{200 \text{ tokens}}{400 \text{ tok/s}} = 0{,}5 \text{ s por request} $$

Si 10.000 requests llegan al LLM en un día, el tiempo total de GPU dedicado a generación es:

$$ T_{\text{GPU}} = 10{.}000 \times 0{,}5 \text{ s} = 5{.}000 \text{ s} \approx 1{,}38 \text{ horas de GPU por día} $$

Con semantic cache θ = 0,93, hit rate ~45%:

Solo 5.500 requests (55%) llegan al LLM:

$$ T_{\text{GPU,cache}} = 5{.}500 \times 0{,}5 \text{ s} = 2{.}750 \text{ s} \approx 0{,}76 \text{ horas de GPU por día} $$

Ahorro:

$$ \Delta T_{\text{GPU}} = 1{,}38 - 0{,}76 = 0{,}62 \text{ horas de GPU/día} $$

En cómputo de inferencia, esto equivale aproximadamente a poder atender un 45% más de usuarios sin añadir hardware, o reducir en un 45% los costes de inferencia si se trabaja con APIs externas facturadas por token.

El coste del propio semantic cache (embedding de la query + ANN search sobre el cache store) es de ~7 ms por request, insignificante frente a los 500 ms de generación que se evita en los hits.

Distribución Zipf de los temas:

La razón por la que funciona es la distribución de Zipf del tráfico. Si numeramos los temas por frecuencia (tema 1 = más frecuente), la frecuencia del tema $k$ es proporcional a $1/k$. Con 1.000 temas distintos:

$$ \text{fracción de tráfico cubierta por top-}N = \frac{\sum_{k=1}^{N} 1/k}{\sum_{k=1}^{1000} 1/k} \approx \frac{\ln N}{\ln 1000} = \frac{\ln N}{6{,}9} $$

Para los top-100 temas: $\ln(100)/6{,}9 \approx 4{,}6/6{,}9 \approx 67%$ del tráfico. El cache no necesita cubrir todos los temas: captura el 67% del tráfico cubriendo solo el 10% de los temas.


Stack OSS 2026

GPTCache

GPTCache es la librería de referencia para semantic cache standalone. Su arquitectura es modular:

  • Embedder: ONNX Runtime con modelos convertidos (por defecto onnx/all-MiniLM-L6-v2), sin dependencia de GPU para el cache layer.
  • Vector store: Faiss (local), Milvus, o Qdrant.
  • Scalar store: SQLite (desarrollo) o Redis (producción) para metadata, TTL, y respuestas.
  • Evaluación de similitud: por defecto coseno, configurable.

Configuración mínima en Python:

from gptcache import cache
from gptcache.adapter import openai
from gptcache.embedding import Onnx
from gptcache.manager import CacheBase, VectorBase, get_data_manager
from gptcache.similarity_evaluation.distance import SearchDistanceEvaluation

onnx = Onnx()
data_manager = get_data_manager(
    CacheBase("redis", url="redis://localhost:6379"),
    VectorBase("qdrant", host="localhost", collection_name="query_cache")
)
cache.init(
    embedding_func=onnx.to_embeddings,
    data_manager=data_manager,
    similarity_evaluation=SearchDistanceEvaluation(),
)
cache.set_openai_key()

GPTCache intercepta las llamadas a la API de OpenAI (o a proxies compatibles) de forma transparente. El TTL se configura a nivel del data_manager.

MeanCache

MeanCache (2024) extiende GPTCache para conversaciones multi-turno. El problema con GPTCache estándar es que en diálogos, la query “relevante” no es solo el último mensaje sino toda la ventana de contexto. MeanCache calcula el embedding de la query como la media ponderada de los embeddings de los últimos $k$ turnos:

$$ \mathbf{e}{\text{query}} = \frac{\sum{i=1}^{k} w_i \cdot \mathbf{e}{q_i}}{\sum{i=1}^{k} w_i} $$

donde $w_i$ decrece con la antigüedad del turno. Esto reduce los false positives en diálogos donde el tema va cambiando.

Qdrant como cache store dual

Si el corpus del RAG ya está en Qdrant, se puede usar la misma instancia con una collection separada para el cache. Las ventajas son operacionales: un solo servicio a gestionar, misma infraestructura de backup y monitoreo.

La collection del cache usa payload filters para implementar TTL:

from qdrant_client import QdrantClient
from qdrant_client.models import Filter, FieldCondition, Range
import time

client = QdrantClient("localhost", port=6333)

# Buscar en cache con filtro de TTL
hits = client.search(
    collection_name="semantic_cache",
    query_vector=query_embedding,
    query_filter=Filter(
        must=[FieldCondition(
            key="expires_at",
            range=Range(gt=time.time())
        )]
    ),
    limit=1,
    score_threshold=0.93
)

Hay que ejecutar periódicamente una limpieza de entradas expiradas, ya que Qdrant no tiene TTL nativo (a diferencia de Redis).

Langfuse para trazabilidad

Langfuse es el estándar OSS para observabilidad de pipelines LLM (véase tracing-llm-otel-genai). Cada request debe marcarse con si fue cache hit o miss:

from langfuse import Langfuse
from langfuse.decorators import observe

langfuse = Langfuse()

@observe()
def process_query(query: str) -> dict:
    cache_result = semantic_cache.lookup(query)
    if cache_result:
        langfuse.update_current_observation(
            metadata={"cache_hit": True, "cache_score": cache_result.score}
        )
        return cache_result.response
    # pipeline completo...
    langfuse.update_current_observation(
        metadata={"cache_hit": False}
    )

Con estos metadatos, Langfuse permite calcular el hit rate real, la distribución de scores de similitud, y detectar si el umbral θ necesita ajuste.


Hardware on-premise: configuración de referencia

Para un despliegue on-premise con este stack, una configuración adecuada para RAG con semantic cache es:

Nodo de inferencia: 4×H100 SXM (320 GB NVLink total) para Llama-3.1-70B en FP8. Throughput ~400 tok/s en generación con continuous batching (vLLM o TGI).

Nodo de servicios vectoriales: CPU con 256 GB RAM. Qdrant para el corpus (1–10 M chunks) y para el cache store (hasta 500K entradas en memoria). Redis 7.x para metadata y exact-match cache como primera capa.

Nodo de embedding: CPU o GPU de gama media (A10G). El embedder del cache puede correr en ONNX Runtime en CPU sin impacto perceptible en latencia (~2 ms por embedding).

La separación del cache store del corpus es importante: el corpus tiene millones de chunks con índices HNSW grandes; el cache store tiene como máximo decenas de miles de queries con un índice mucho más pequeño y tiempos de búsqueda de 1–2 ms.


Casos donde el cache falla

El recepcionista con cuaderno falla en tres escenarios bien definidos:

1. Queries con contexto temporal

“¿Cuál es el estado actual del incidente?” o “¿Qué cambió en la última versión?” son preguntas cuya respuesta correcta cambia con el tiempo. Un cache con TTL de 24 horas podría devolver información obsoleta.

La solución es detectar marcadores temporales en la query (expresiones regulares sobre “hoy”, “ahora”, “actual”, “último”, “ayer”, y sus equivalentes en inglés) y forzar un cache miss para estas queries, independientemente del score de similitud.

2. Queries personalizadas con datos privados

Si el RAG tiene acceso a datos del usuario (historial de cuenta, documentos privados), dos usuarios distintos haciendo la misma pregunta deben recibir respuestas diferentes. Un cache compartido que ignora el contexto del usuario es un riesgo de privacidad.

La solución es un cache particionado por user_id o tenant_id. Esto reduce el hit rate (el cache de cada usuario es más pequeño) pero es la única opción segura en arquitecturas multi-tenant.

3. TTL y corpus stale

Cuando el corpus se actualiza (se ingieren nuevos documentos, se corrigen errores), las respuestas cacheadas pueden quedar desactualizadas. Un TTL fijo (24–48 horas) mitiga el problema pero no lo elimina.

Para corpus con actualizaciones frecuentes, la solución es un mecanismo de invalidación activa: cuando se actualiza el corpus en Qdrant, se lanza un job que identifica qué entradas del cache podrían estar afectadas (por overlap semántico con los chunks actualizados) y las elimina. Esta es la “cache invalidation selectiva” mencionada en el apartado de temas no cubiertos.


Integración en el pipeline como middleware

El semantic cache se implementa como middleware entre el API gateway y el retriever. No modifica el contrato de la API: el cliente sigue enviando queries y recibiendo respuestas en el mismo formato.

class SemanticCacheMiddleware:
    def __init__(self, cache_store, retriever, llm, threshold=0.93):
        self.cache = cache_store
        self.retriever = retriever
        self.llm = llm
        self.threshold = threshold

    async def process(self, query: str, context: dict) -> dict:
        # Primera capa: exact-match cache (Redis GET, O(1))
        exact = await self.cache.exact_lookup(query)
        if exact:
            return {**exact, "cache_type": "exact"}

        # Segunda capa: semantic cache (ANN search)
        query_embedding = await self.embed(query)
        semantic = await self.cache.semantic_lookup(
            query_embedding, threshold=self.threshold
        )
        if semantic:
            return {**semantic, "cache_type": "semantic"}

        # Miss: pipeline completo
        chunks = await self.retriever.retrieve(query_embedding)
        response = await self.llm.generate(query, chunks)

        # Store para futuras queries
        await self.cache.store(
            embedding=query_embedding,
            query=query,
            response=response,
            ttl=context.get("ttl", 86400)
        )
        return {**response, "cache_type": "miss"}

La primera capa de exact-match (Redis GET) es una optimización adicional: para queries textualmente idénticas, ni siquiera se calcula el embedding. El coste es una operación Redis de microsegundos. Solo si no hay exact match se pasa al semantic lookup.


Lo que no hemos cubierto

  • Cache invalidation selectiva: cuando se actualiza un subconjunto del corpus (por ejemplo, se reindexan los documentos de un producto específico), habría que identificar qué entradas del cache están semánticamente solapadas con los chunks actualizados y marcarlas como stale. El mecanismo implica calcular similitud entre los embeddings de los chunks actualizados y los embeddings de las queries cacheadas, lo cual es costoso a escala.

  • Multi-tenant cache: isolación vs. compartición: en un SaaS con múltiples clientes, el cache compartido maximiza el hit rate pero puede exponer respuestas de un tenant a otro si no se filtra correctamente. El cache particionado por tenant es seguro pero tiene hit rates mucho más bajos. El punto medio es un cache compartido con filtrado por ACL aplicado sobre los payload filters de Qdrant.

  • Semantic cache para streaming responses: cuando el LLM emite tokens en streaming (SSE), el cache no puede interceptar fácilmente la respuesta completa. Las opciones son: cachear en el primer miss y devolver la respuesta completa de golpe en los hits (rompiendo la experiencia de streaming), o implementar un “fake streaming” que emite los tokens de la respuesta cacheada a velocidad controlada.

  • Exact-match cache como primera capa: antes del semantic cache, un lookup de O(1) en Redis con la query como clave puede capturar queries textualmente idénticas a costo ínfimo. El código del apartado anterior ya muestra esta arquitectura en dos capas.


Ver también


Referencias

  1. Bang, J. et al. (2024). MeanCache: User-Centric Semantic Cache for Large Language Model Based Web Applications. arXiv:2403.02694.
  2. Zilliz. (2023). GPTCache: A Library for Creating Semantic Cache for LLM Queries. GitHub: zilliztech/GPTCache.
  3. Qdrant Team. (2024). Qdrant Documentation: Filtering with payload. qdrant.tech/documentation.
  4. Manning, C. D., Raghavan, P., Schütze, H. (2008). Introduction to Information Retrieval. Cambridge University Press. Cap. 19: Web search (distribución Zipf).
  5. Langfuse. (2024). Observability for LLM Applications. langfuse.com/docs.
  6. Meta AI. (2024). Llama 3.1 Model Card. ai.meta.com.
  7. Guo, Y. et al. (2023). Evaluating the Factual Consistency of Large Language Models Through Summarization. Referencia para BERTScore como métrica de evaluación de respuestas cacheadas.