<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Redis on lo0 — Blog Técnico</title><link>https://blog.lo0.es/tags/redis/</link><description>Recent content in Redis on lo0 — Blog Técnico</description><generator>Hugo -- gohugo.io</generator><language>es</language><lastBuildDate>Thu, 04 Jun 2026 08:00:00 +0200</lastBuildDate><atom:link href="https://blog.lo0.es/tags/redis/index.xml" rel="self" type="application/rss+xml"/><item><title>Semantic cache en RAG: el recepcionista con memoria fotográfica</title><link>https://blog.lo0.es/posts/semantic-cache-rag/</link><pubDate>Thu, 04 Jun 2026 08:00:00 +0200</pubDate><guid>https://blog.lo0.es/posts/semantic-cache-rag/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>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.&lt;/p>
&lt;hr>
&lt;h2 id="la-analogía-el-recepcionista-con-cuaderno">La analogía: el recepcionista con cuaderno&lt;/h2>
&lt;p>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:&lt;/p>
&lt;ul>
&lt;li>&amp;ldquo;¿Dónde está el gimnasio?&amp;rdquo;&lt;/li>
&lt;li>&amp;ldquo;¿A qué hora es el desayuno?&amp;rdquo;&lt;/li>
&lt;li>&amp;ldquo;¿Tienen aparcamiento?&amp;rdquo;&lt;/li>
&lt;li>&amp;ldquo;¿Cómo conecto al WiFi?&amp;rdquo;&lt;/li>
&lt;/ul>
&lt;p>Al tercer día, el recepcionista ha construido mentalmente un cuaderno de respuestas. Cuando alguien pregunta &amp;ldquo;¿dónde puedo ir a hacer ejercicio?&amp;rdquo;, 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 &amp;ldquo;¿dónde está el gimnasio?&amp;rdquo;, y responde en dos segundos.&lt;/p>
&lt;p>Pero cuando alguien pregunta &amp;ldquo;¿a qué hora cierra el gimnasio &lt;strong>hoy&lt;/strong>?&amp;rdquo;, el recepcionista sabe que no puede fiar del cuaderno: el horario puede haber cambiado por un evento privado. Tiene que llamar al conserje.&lt;/p>
&lt;p>Ese es exactamente el mecanismo del semantic cache:&lt;/p>
&lt;ul>
&lt;li>El &lt;strong>cuaderno&lt;/strong> es el cache store (Redis con índice vectorial, o una collection de Qdrant).&lt;/li>
&lt;li>&lt;strong>Identificar que &amp;ldquo;hacer ejercicio&amp;rdquo; ≈ &amp;ldquo;gimnasio&amp;rdquo;&lt;/strong> es la búsqueda por similitud coseno con umbral θ.&lt;/li>
&lt;li>&lt;strong>Llamar al conserje&lt;/strong> es el retrieval sobre el corpus.&lt;/li>
&lt;li>&lt;strong>Consultar el manual&lt;/strong> es la generación del LLM.&lt;/li>
&lt;li>&lt;strong>&amp;ldquo;Hoy&amp;rdquo;&lt;/strong> es la señal de consulta temporal que invalida el cache.&lt;/li>
&lt;/ul>
&lt;p>El umbral θ es exactamente lo que distingue &amp;ldquo;dónde está&amp;rdquo; (igual semánticamente) de &amp;ldquo;a qué hora está hoy&amp;rdquo; (distinto semánticamente). No es magia: es aritmética vectorial sobre representaciones aprendidas.&lt;/p>
&lt;hr>
&lt;h2 id="el-problema-en-producción">El problema en producción&lt;/h2>
&lt;p>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.&lt;/p>
&lt;p>El problema es que los usuarios hacen las mismas preguntas una y otra vez, con formulaciones levemente distintas:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Query original&lt;/th>
&lt;th>Query equivalente&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&amp;ldquo;¿Cómo configuro el agente?&amp;rdquo;&lt;/td>
&lt;td>&amp;ldquo;¿Cuál es el proceso para configurar el agente?&amp;rdquo;&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&amp;ldquo;error al instalar la dependencia&amp;rdquo;&lt;/td>
&lt;td>&amp;ldquo;falla la instalación de la dependencia&amp;rdquo;&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&amp;ldquo;¿qué es un embedding?&amp;rdquo;&lt;/td>
&lt;td>&amp;ldquo;explícame qué son los embeddings&amp;rdquo;&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>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&amp;amp;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.&lt;/p>
&lt;p>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.&lt;/p>
&lt;hr>
&lt;h2 id="cómo-funciona-el-semantic-cache">Cómo funciona el semantic cache&lt;/h2>
&lt;p>El flujo completo se puede ver en el diagrama siguiente. Describámoslo primero en prosa.&lt;/p>
&lt;p>Cuando llega una nueva query $q$:&lt;/p>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>Embedding de la query&lt;/strong>: $q$ se embebe con el mismo modelo que se usó para indexar el corpus. Esto es crítico: si el corpus se indexó con &lt;code>text-embedding-3-large&lt;/code> y el cache usa un embedder distinto, los espacios vectoriales no son comparables.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Búsqueda en el cache store&lt;/strong>: 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$.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Decisión por umbral&lt;/strong>:
$$
\text{respuesta} = \begin{cases} r^* &amp;amp; \text{si } s(q, q^&lt;em>) \geq \theta \ \text{pipeline}(q) &amp;amp; \text{si } s(q, q^&lt;/em>) &amp;lt; \theta \end{cases}
$$
donde $r^&lt;em>$ es la respuesta cacheada asociada a $q^&lt;/em>$.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>En caso de miss&lt;/strong>: se ejecuta el pipeline completo (retrieval + LLM). La respuesta generada se almacena en el cache con un TTL configurable para futuras queries similares.&lt;/p>
&lt;/li>
&lt;/ol>
&lt;p>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:&lt;/p>
&lt;pre tabindex="0">&lt;code>{vector: embed(q), response: r, ttl: T, metadata: {...}}
&lt;/code>&lt;/pre>&lt;h3 id="diagrama-del-flujo">Diagrama del flujo&lt;/h3>
&lt;figure>
&lt;svg viewBox="0 0 760 420" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="sc-title sc-desc" style="width:100%;max-width:760px;font-family:system-ui,sans-serif;">
&lt;title id="sc-title">Flujo del semantic cache en un pipeline RAG&lt;/title>
&lt;desc id="sc-desc">Diagrama 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.&lt;/desc>
&lt;!-- Fondo -->
&lt;rect width="760" height="420" fill="#f8f9fa" rx="8"/>
&lt;!-- Query entrada -->
&lt;rect x="20" y="180" width="110" height="52" rx="8" fill="#e3f2fd" stroke="#1565c0" stroke-width="1.5"/>
&lt;text x="75" y="202" text-anchor="middle" font-size="12" font-weight="600" fill="#0d47a1">Query&lt;/text>
&lt;text x="75" y="220" text-anchor="middle" font-size="11" fill="#1565c0">del usuario&lt;/text>
&lt;!-- Flecha query → embedder -->
&lt;line x1="130" y1="206" x2="168" y2="206" stroke="#555" stroke-width="1.5" marker-end="url(#arr)"/>
&lt;!-- Embedder -->
&lt;rect x="168" y="180" width="100" height="52" rx="8" fill="#f3e5f5" stroke="#6a1b9a" stroke-width="1.5"/>
&lt;text x="218" y="202" text-anchor="middle" font-size="12" font-weight="600" fill="#4a148c">Embedder&lt;/text>
&lt;text x="218" y="220" text-anchor="middle" font-size="10" fill="#6a1b9a">mismo modelo&lt;/text>
&lt;!-- Flecha embedder → cache check -->
&lt;line x1="268" y1="206" x2="306" y2="206" stroke="#555" stroke-width="1.5" marker-end="url(#arr)"/>
&lt;!-- Cache check -->
&lt;rect x="306" y="172" width="120" height="68" rx="8" fill="#fff3e0" stroke="#e65100" stroke-width="1.5"/>
&lt;text x="366" y="196" text-anchor="middle" font-size="12" font-weight="600" fill="#bf360c">Cache check&lt;/text>
&lt;text x="366" y="212" text-anchor="middle" font-size="10" fill="#e65100">ANN search&lt;/text>
&lt;text x="366" y="226" text-anchor="middle" font-size="10" fill="#e65100">sim ≥ θ ?&lt;/text>
&lt;!-- Rama HIT (arriba) -->
&lt;line x1="366" y1="172" x2="366" y2="120" stroke="#2e7d32" stroke-width="1.5" stroke-dasharray="5,3" marker-end="url(#arr-green)"/>
&lt;text x="380" y="148" font-size="10" fill="#2e7d32" font-weight="600">HIT&lt;/text>
&lt;rect x="296" y="68" width="140" height="48" rx="8" fill="#e8f5e9" stroke="#2e7d32" stroke-width="1.5"/>
&lt;text x="366" y="90" text-anchor="middle" font-size="12" font-weight="600" fill="#1b5e20">Respuesta&lt;/text>
&lt;text x="366" y="106" text-anchor="middle" font-size="11" fill="#2e7d32">cacheada → usuario&lt;/text>
&lt;!-- Rama MISS (derecha) -->
&lt;line x1="426" y1="206" x2="466" y2="206" stroke="#c62828" stroke-width="1.5" stroke-dasharray="5,3" marker-end="url(#arr-red)"/>
&lt;text x="435" y="196" font-size="10" fill="#c62828" font-weight="600">MISS&lt;/text>
&lt;!-- Retriever -->
&lt;rect x="466" y="180" width="100" height="52" rx="8" fill="#e8eaf6" stroke="#283593" stroke-width="1.5"/>
&lt;text x="516" y="202" text-anchor="middle" font-size="12" font-weight="600" fill="#1a237e">Retriever&lt;/text>
&lt;text x="516" y="220" text-anchor="middle" font-size="10" fill="#283593">vector search&lt;/text>
&lt;!-- Flecha retriever → LLM -->
&lt;line x1="566" y1="206" x2="606" y2="206" stroke="#555" stroke-width="1.5" marker-end="url(#arr)"/>
&lt;!-- LLM -->
&lt;rect x="606" y="180" width="100" height="52" rx="8" fill="#fce4ec" stroke="#880e4f" stroke-width="1.5"/>
&lt;text x="656" y="202" text-anchor="middle" font-size="12" font-weight="600" fill="#880e4f">LLM&lt;/text>
&lt;text x="656" y="220" text-anchor="middle" font-size="10" fill="#ad1457">generación&lt;/text>
&lt;!-- Flecha LLM → respuesta nueva -->
&lt;line x1="656" y1="232" x2="656" y2="290" stroke="#555" stroke-width="1.5" marker-end="url(#arr)"/>
&lt;!-- Respuesta nueva -->
&lt;rect x="596" y="290" width="120" height="48" rx="8" fill="#e8f5e9" stroke="#2e7d32" stroke-width="1.5"/>
&lt;text x="656" y="312" text-anchor="middle" font-size="12" font-weight="600" fill="#1b5e20">Respuesta&lt;/text>
&lt;text x="656" y="328" text-anchor="middle" font-size="11" fill="#2e7d32">nueva → usuario&lt;/text>
&lt;!-- Flecha respuesta nueva → store en cache -->
&lt;line x1="596" y1="314" x2="430" y2="314" stroke="#e65100" stroke-width="1.5" stroke-dasharray="4,3" marker-end="url(#arr-orange)"/>
&lt;text x="510" y="308" text-anchor="middle" font-size="10" fill="#e65100">store + TTL&lt;/text>
&lt;!-- Flecha de store al cache check (retroalimentación) -->
&lt;line x1="366" y1="314" x2="366" y2="240" stroke="#e65100" stroke-width="1.5" stroke-dasharray="4,3" marker-end="url(#arr-orange)"/>
&lt;!-- Cache store label -->
&lt;rect x="296" y="340" width="140" height="48" rx="8" fill="#fff8e1" stroke="#f57f17" stroke-width="1.5"/>
&lt;text x="366" y="361" text-anchor="middle" font-size="11" font-weight="600" fill="#e65100">Cache store&lt;/text>
&lt;text x="366" y="377" text-anchor="middle" font-size="10" fill="#f57f17">Redis / Qdrant&lt;/text>
&lt;line x1="366" y1="340" x2="366" y2="314" stroke="#e65100" stroke-width="1.5" stroke-dasharray="4,3" marker-end="url(#arr-orange)"/>
&lt;!-- Leyenda -->
&lt;text x="20" y="390" font-size="10" fill="#555">— — — HIT path&lt;/text>
&lt;rect x="20" y="397" width="30" height="2" fill="#2e7d32"/>
&lt;text x="60" y="410" font-size="10" fill="#2e7d32">cache hit&lt;/text>
&lt;rect x="110" y="397" width="30" height="2" fill="#c62828"/>
&lt;text x="150" y="410" font-size="10" fill="#c62828">cache miss&lt;/text>
&lt;rect x="200" y="397" width="30" height="2" fill="#e65100"/>
&lt;text x="240" y="410" font-size="10" fill="#e65100">store / retroalimentación&lt;/text>
&lt;!-- Marcadores de flecha -->
&lt;defs>
&lt;marker id="arr" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
&lt;path d="M0,0 L0,6 L8,3 z" fill="#555"/>
&lt;/marker>
&lt;marker id="arr-green" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
&lt;path d="M0,0 L0,6 L8,3 z" fill="#2e7d32"/>
&lt;/marker>
&lt;marker id="arr-red" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
&lt;path d="M0,0 L0,6 L8,3 z" fill="#c62828"/>
&lt;/marker>
&lt;marker id="arr-orange" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
&lt;path d="M0,0 L0,6 L8,3 z" fill="#e65100"/>
&lt;/marker>
&lt;/defs>
&lt;/svg>
&lt;figcaption>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.&lt;/figcaption>
&lt;/figure>
&lt;hr>
&lt;h2 id="el-umbral-θ-y-su-trade-off">El umbral θ y su trade-off&lt;/h2>
&lt;p>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 &amp;ldquo;¿dónde está el gimnasio?&amp;rdquo; a alguien que preguntó &amp;ldquo;¿a qué hora cierra el gimnasio?&amp;rdquo;.&lt;/p>
&lt;p>La similitud coseno entre dos vectores $\mathbf{a}$ y $\mathbf{b}$ es:&lt;/p>
&lt;p>$$
s(\mathbf{a}, \mathbf{b}) = \frac{\mathbf{a} \cdot \mathbf{b}}{|\mathbf{a}| \cdot |\mathbf{b}|}
$$&lt;/p>
&lt;p>Para texto en prosa (español o inglés), los embedders modernos como &lt;code>text-embedding-3-large&lt;/code> o &lt;code>nomic-embed-text&lt;/code> 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.&lt;/p>
&lt;p>La métrica de calidad del cache no es solo el hit rate: es la &lt;strong>precision@cache&lt;/strong>, definida como la fracción de respuestas cacheadas que siguen siendo correctas para la nueva query. Una respuesta cacheada es &amp;ldquo;correcta&amp;rdquo; 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.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>θ&lt;/th>
&lt;th>Hit rate estimado&lt;/th>
&lt;th>precision@cache estimada&lt;/th>
&lt;th>Ahorro efectivo&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>0,85&lt;/td>
&lt;td>~65%&lt;/td>
&lt;td>~72%&lt;/td>
&lt;td>~47%&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>0,90&lt;/td>
&lt;td>~55%&lt;/td>
&lt;td>~85%&lt;/td>
&lt;td>~47%&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>0,92&lt;/td>
&lt;td>~48%&lt;/td>
&lt;td>~91%&lt;/td>
&lt;td>~44%&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>0,93&lt;/strong>&lt;/td>
&lt;td>&lt;strong>~45%&lt;/strong>&lt;/td>
&lt;td>&lt;strong>~94%&lt;/strong>&lt;/td>
&lt;td>&lt;strong>~42%&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>0,95&lt;/td>
&lt;td>~35%&lt;/td>
&lt;td>~98%&lt;/td>
&lt;td>~34%&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>0,97&lt;/td>
&lt;td>~18%&lt;/td>
&lt;td>~99,5%&lt;/td>
&lt;td>~18%&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>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.&lt;/p>
&lt;p>La &lt;strong>zona óptima empírica&lt;/strong> para la mayoría de aplicaciones de Q&amp;amp;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.&lt;/p>
&lt;hr>
&lt;h2 id="matemáticas-del-ahorro">Matemáticas del ahorro&lt;/h2>
&lt;p>Pongamos números concretos sobre un sistema real.&lt;/p>
&lt;p>&lt;strong>Configuración base:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>10.000 requests/día&lt;/li>
&lt;li>Corpus técnico de 1 millón de chunks en un índice Qdrant&lt;/li>
&lt;li>LLM: Llama-3.1-70B en 4×H100 SXM (320 GB, NVLink)&lt;/li>
&lt;li>Respuesta media: 200 tokens de output&lt;/li>
&lt;li>Throughput del LLM en este hardware: ~400 tokens/s/GPU con batching (continuous batching activo, véase &lt;a href="https://blog.lo0.es/posts/continuous-batching-fundamentos/">continuous-batching-fundamentos&lt;/a>)&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Coste de una request sin cache:&lt;/strong>&lt;/p>
&lt;p>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:&lt;/p>
&lt;p>$$
t_{\text{LLM}} = \frac{200 \text{ tokens}}{400 \text{ tok/s}} = 0{,}5 \text{ s por request}
$$&lt;/p>
&lt;p>Si 10.000 requests llegan al LLM en un día, el tiempo total de GPU dedicado a generación es:&lt;/p>
&lt;p>$$
T_{\text{GPU}} = 10{.}000 \times 0{,}5 \text{ s} = 5{.}000 \text{ s} \approx 1{,}38 \text{ horas de GPU por día}
$$&lt;/p>
&lt;p>&lt;strong>Con semantic cache θ = 0,93, hit rate ~45%:&lt;/strong>&lt;/p>
&lt;p>Solo 5.500 requests (55%) llegan al LLM:&lt;/p>
&lt;p>$$
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}
$$&lt;/p>
&lt;p>&lt;strong>Ahorro:&lt;/strong>&lt;/p>
&lt;p>$$
\Delta T_{\text{GPU}} = 1{,}38 - 0{,}76 = 0{,}62 \text{ horas de GPU/día}
$$&lt;/p>
&lt;p>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.&lt;/p>
&lt;p>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.&lt;/p>
&lt;p>&lt;strong>Distribución Zipf de los temas:&lt;/strong>&lt;/p>
&lt;p>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:&lt;/p>
&lt;p>$$
\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}
$$&lt;/p>
&lt;p>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.&lt;/p>
&lt;hr>
&lt;h2 id="stack-oss-2026">Stack OSS 2026&lt;/h2>
&lt;h3 id="gptcache">GPTCache&lt;/h3>
&lt;p>&lt;a href="https://github.com/zilliztech/GPTCache">GPTCache&lt;/a> es la librería de referencia para semantic cache standalone. Su arquitectura es modular:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Embedder&lt;/strong>: ONNX Runtime con modelos convertidos (por defecto &lt;code>onnx/all-MiniLM-L6-v2&lt;/code>), sin dependencia de GPU para el cache layer.&lt;/li>
&lt;li>&lt;strong>Vector store&lt;/strong>: Faiss (local), Milvus, o Qdrant.&lt;/li>
&lt;li>&lt;strong>Scalar store&lt;/strong>: SQLite (desarrollo) o Redis (producción) para metadata, TTL, y respuestas.&lt;/li>
&lt;li>&lt;strong>Evaluación de similitud&lt;/strong>: por defecto coseno, configurable.&lt;/li>
&lt;/ul>
&lt;p>Configuración mínima en Python:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">gptcache&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">cache&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">gptcache.adapter&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">openai&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">gptcache.embedding&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Onnx&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">gptcache.manager&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">CacheBase&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">VectorBase&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">get_data_manager&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">gptcache.similarity_evaluation.distance&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">SearchDistanceEvaluation&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">onnx&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Onnx&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">data_manager&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">get_data_manager&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">CacheBase&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;redis&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">url&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;redis://localhost:6379&amp;#34;&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">VectorBase&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;qdrant&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">host&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;localhost&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">collection_name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;query_cache&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">cache&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">init&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">embedding_func&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">onnx&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">to_embeddings&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">data_manager&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">data_manager&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">similarity_evaluation&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">SearchDistanceEvaluation&lt;/span>&lt;span class="p">(),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">cache&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">set_openai_key&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>GPTCache intercepta las llamadas a la API de OpenAI (o a proxies compatibles) de forma transparente. El TTL se configura a nivel del &lt;code>data_manager&lt;/code>.&lt;/p>
&lt;h3 id="meancache">MeanCache&lt;/h3>
&lt;p>&lt;a href="https://arxiv.org/abs/2403.02694">MeanCache&lt;/a> (2024) extiende GPTCache para conversaciones multi-turno. El problema con GPTCache estándar es que en diálogos, la query &amp;ldquo;relevante&amp;rdquo; 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:&lt;/p>
&lt;p>$$
\mathbf{e}&lt;em>{\text{query}} = \frac{\sum&lt;/em>{i=1}^{k} w_i \cdot \mathbf{e}&lt;em>{q_i}}{\sum&lt;/em>{i=1}^{k} w_i}
$$&lt;/p>
&lt;p>donde $w_i$ decrece con la antigüedad del turno. Esto reduce los false positives en diálogos donde el tema va cambiando.&lt;/p>
&lt;h3 id="qdrant-como-cache-store-dual">Qdrant como cache store dual&lt;/h3>
&lt;p>Si el corpus del RAG ya está en Qdrant, se puede usar la misma instancia con una &lt;strong>collection separada&lt;/strong> para el cache. Las ventajas son operacionales: un solo servicio a gestionar, misma infraestructura de backup y monitoreo.&lt;/p>
&lt;p>La collection del cache usa &lt;code>payload filters&lt;/code> para implementar TTL:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">qdrant_client&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">QdrantClient&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">qdrant_client.models&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Filter&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">FieldCondition&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">Range&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">time&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">client&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">QdrantClient&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;localhost&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">port&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">6333&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Buscar en cache con filtro de TTL&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">hits&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">client&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">search&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">collection_name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;semantic_cache&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">query_vector&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">query_embedding&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">query_filter&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">Filter&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">must&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">FieldCondition&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">key&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;expires_at&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nb">range&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">Range&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">gt&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">time&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">time&lt;/span>&lt;span class="p">())&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">limit&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">score_threshold&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mf">0.93&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Hay que ejecutar periódicamente una limpieza de entradas expiradas, ya que Qdrant no tiene TTL nativo (a diferencia de Redis).&lt;/p>
&lt;h3 id="langfuse-para-trazabilidad">Langfuse para trazabilidad&lt;/h3>
&lt;p>&lt;a href="https://langfuse.com">Langfuse&lt;/a> es el estándar OSS para observabilidad de pipelines LLM (véase &lt;a href="https://blog.lo0.es/posts/tracing-llm-otel-genai/">tracing-llm-otel-genai&lt;/a>). Cada request debe marcarse con si fue cache hit o miss:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langfuse&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Langfuse&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langfuse.decorators&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">observe&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">langfuse&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Langfuse&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nd">@observe&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">process_query&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">query&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">dict&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">cache_result&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">semantic_cache&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">lookup&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">query&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">cache_result&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">langfuse&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">update_current_observation&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">metadata&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;cache_hit&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;cache_score&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">cache_result&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">score&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">cache_result&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">response&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># pipeline completo...&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">langfuse&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">update_current_observation&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">metadata&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;cache_hit&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">False&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Con estos metadatos, Langfuse permite calcular el hit rate real, la distribución de scores de similitud, y detectar si el umbral θ necesita ajuste.&lt;/p>
&lt;hr>
&lt;h2 id="hardware-on-premise-configuración-de-referencia">Hardware on-premise: configuración de referencia&lt;/h2>
&lt;p>Para un despliegue on-premise con este stack, una configuración adecuada para RAG con semantic cache es:&lt;/p>
&lt;p>&lt;strong>Nodo de inferencia:&lt;/strong> 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).&lt;/p>
&lt;p>&lt;strong>Nodo de servicios vectoriales:&lt;/strong> 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.&lt;/p>
&lt;p>&lt;strong>Nodo de embedding:&lt;/strong> 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).&lt;/p>
&lt;p>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.&lt;/p>
&lt;hr>
&lt;h2 id="casos-donde-el-cache-falla">Casos donde el cache falla&lt;/h2>
&lt;p>El recepcionista con cuaderno falla en tres escenarios bien definidos:&lt;/p>
&lt;h3 id="1-queries-con-contexto-temporal">1. Queries con contexto temporal&lt;/h3>
&lt;p>&amp;ldquo;¿Cuál es el estado actual del incidente?&amp;rdquo; o &amp;ldquo;¿Qué cambió en la última versión?&amp;rdquo; son preguntas cuya respuesta correcta cambia con el tiempo. Un cache con TTL de 24 horas podría devolver información obsoleta.&lt;/p>
&lt;p>La solución es detectar marcadores temporales en la query (expresiones regulares sobre &amp;ldquo;hoy&amp;rdquo;, &amp;ldquo;ahora&amp;rdquo;, &amp;ldquo;actual&amp;rdquo;, &amp;ldquo;último&amp;rdquo;, &amp;ldquo;ayer&amp;rdquo;, y sus equivalentes en inglés) y forzar un cache miss para estas queries, independientemente del score de similitud.&lt;/p>
&lt;h3 id="2-queries-personalizadas-con-datos-privados">2. Queries personalizadas con datos privados&lt;/h3>
&lt;p>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.&lt;/p>
&lt;p>La solución es un cache particionado por &lt;code>user_id&lt;/code> o &lt;code>tenant_id&lt;/code>. 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.&lt;/p>
&lt;h3 id="3-ttl-y-corpus-stale">3. TTL y corpus stale&lt;/h3>
&lt;p>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.&lt;/p>
&lt;p>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 &amp;ldquo;cache invalidation selectiva&amp;rdquo; mencionada en el apartado de temas no cubiertos.&lt;/p>
&lt;hr>
&lt;h2 id="integración-en-el-pipeline-como-middleware">Integración en el pipeline como middleware&lt;/h2>
&lt;p>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.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">class&lt;/span> &lt;span class="nc">SemanticCacheMiddleware&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">def&lt;/span> &lt;span class="fm">__init__&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">cache_store&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">retriever&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">llm&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">threshold&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mf">0.93&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">cache&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">cache_store&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">retriever&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">retriever&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">llm&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">llm&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">threshold&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">threshold&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">async&lt;/span> &lt;span class="k">def&lt;/span> &lt;span class="nf">process&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">query&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">context&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">dict&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">dict&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># Primera capa: exact-match cache (Redis GET, O(1))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">exact&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">cache&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">exact_lookup&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">query&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">exact&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="o">**&lt;/span>&lt;span class="n">exact&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;cache_type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;exact&amp;#34;&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># Segunda capa: semantic cache (ANN search)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">query_embedding&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">embed&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">query&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">semantic&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">cache&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">semantic_lookup&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">query_embedding&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">threshold&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">threshold&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">semantic&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="o">**&lt;/span>&lt;span class="n">semantic&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;cache_type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;semantic&amp;#34;&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># Miss: pipeline completo&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">chunks&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">retriever&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">retrieve&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">query_embedding&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">response&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">llm&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">generate&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">query&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">chunks&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># Store para futuras queries&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">await&lt;/span> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">cache&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">store&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">embedding&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">query_embedding&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">query&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">query&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">response&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">response&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">ttl&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">context&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;ttl&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">86400&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="o">**&lt;/span>&lt;span class="n">response&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;cache_type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;miss&amp;#34;&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>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.&lt;/p>
&lt;hr>
&lt;h2 id="lo-que-no-hemos-cubierto">Lo que no hemos cubierto&lt;/h2>
&lt;ul>
&lt;li>
&lt;p>&lt;strong>Cache invalidation selectiva&lt;/strong>: 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.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Multi-tenant cache: isolación vs. compartición&lt;/strong>: 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.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Semantic cache para streaming responses&lt;/strong>: 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 &amp;ldquo;fake streaming&amp;rdquo; que emite los tokens de la respuesta cacheada a velocidad controlada.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Exact-match cache como primera capa&lt;/strong>: 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.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="ver-también">Ver también&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/posts/rag-reranker-hybrid-retrieval-fundamentos/">RAG con reranker y hybrid retrieval&lt;/a> — el retrieval que el semantic cache evita ejecutar en los hits; entender cómo funciona el vector search que se ahorra&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/postgresql-qdrant-ingestion-microservicios/">PostgreSQL + Qdrant: ingestion de documentos en microservicios&lt;/a> — el vector store que puede hacer doble función como cache store, con la misma instancia de Qdrant para corpus y cache&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/kv-cache-fundamentos/">KV cache en transformers&lt;/a> — cache a nivel de atención del transformer; diferente al semantic cache a nivel de query del sistema RAG pero complementario&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/continuous-batching-fundamentos/">Continuous batching en inferencia LLM&lt;/a> — el batching que procesa los cache misses; el semantic cache reduce la presión de requests que llegan al motor de inferencia&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/tracing-llm-otel-genai/">Tracing de LLMs con OTel y GenAI&lt;/a> — cómo instrumentar cache hits vs misses con OpenTelemetry para medir el ahorro real en producción&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="referencias">Referencias&lt;/h2>
&lt;ol>
&lt;li>Bang, J. et al. (2024). &lt;em>MeanCache: User-Centric Semantic Cache for Large Language Model Based Web Applications&lt;/em>. arXiv:2403.02694.&lt;/li>
&lt;li>Zilliz. (2023). &lt;em>GPTCache: A Library for Creating Semantic Cache for LLM Queries&lt;/em>. GitHub: &lt;a href="https://github.com/zilliztech/GPTCache">zilliztech/GPTCache&lt;/a>.&lt;/li>
&lt;li>Qdrant Team. (2024). &lt;em>Qdrant Documentation: Filtering with payload&lt;/em>. &lt;a href="https://qdrant.tech/documentation/concepts/filtering/">qdrant.tech/documentation&lt;/a>.&lt;/li>
&lt;li>Manning, C. D., Raghavan, P., Schütze, H. (2008). &lt;em>Introduction to Information Retrieval&lt;/em>. Cambridge University Press. Cap. 19: Web search (distribución Zipf).&lt;/li>
&lt;li>Langfuse. (2024). &lt;em>Observability for LLM Applications&lt;/em>. &lt;a href="https://langfuse.com/docs">langfuse.com/docs&lt;/a>.&lt;/li>
&lt;li>Meta AI. (2024). &lt;em>Llama 3.1 Model Card&lt;/em>. &lt;a href="https://ai.meta.com/blog/meta-llama-3-1/">ai.meta.com&lt;/a>.&lt;/li>
&lt;li>Guo, Y. et al. (2023). &lt;em>Evaluating the Factual Consistency of Large Language Models Through Summarization&lt;/em>. Referencia para BERTScore como métrica de evaluación de respuestas cacheadas.&lt;/li>
&lt;/ol></description></item></channel></rss>