Email copiado — support@tuurt.com
Cargando experiencia
ia · 04 de octubre de 2026 · 7 min

Cachear embeddings en RAG: por qué recalcularlos en cada request no escala

Recalcular un embedding que no cambió sale caro apenas el tráfico crece. Vemos qué llave de caché usamos y qué se rompió la primera vez que invalidamos mal tras editar el contenido fuente.

Por Equipo Tuurt

Cachear embeddings en RAG: por qué recalcularlos en cada request no escala

Un pipeline de RAG típico convierte cada fragmento de texto en un vector antes de poder compararlo con la pregunta del usuario. Ese paso —llamar a un modelo de embeddings, ya sea una API externa o un modelo local— tiene un costo que se nota apenas el sistema empieza a recibir tráfico real: latencia por cada llamada, facturación por token si el proveedor es externo, y cómputo de GPU o CPU si el modelo corre en casa. El error que cometimos al principio fue tratar ese costo como parte normal de servir una consulta, cuando en realidad la mayoría de las veces que se recalcula un embedding, el contenido que se está vectorizando no cambió desde la última vez.

Un pipeline de ingestión típico parte un documento en fragmentos (chunks) de un tamaño fijo, genera un embedding por fragmento, y guarda ese vector en una base vectorial para búsquedas futuras. Hasta ahí, el embedding se calcula una sola vez por fragmento y el costo es razonable. El problema aparece en dos lugares distintos: cuando el mismo documento se reprocesa —porque el pipeline de ingestión corre de nuevo, porque se corrigió un campo de metadata que dispara todo el flujo, o porque un job de reindexado se ejecuta sin que el contenido real haya cambiado— y cuando varias fuentes de datos comparten fragmentos idénticos o casi idénticos, algo frecuente en catálogos de productos, bases de conocimiento con plantillas repetidas, o documentación que reutiliza los mismos bloques de texto en varios lugares. En ambos casos, sin caché, se paga el mismo costo de cómputo por un resultado que ya se tenía.

Qué guardamos y con qué llave

La primera decisión importante no es dónde guardar el embedding, sino con qué llave identificarlo. La tentación inicial es usar el identificador del documento y el índice del fragmento (document_id + chunk_index) como llave de caché, porque es lo que ya existe en el pipeline de ingestión y no requiere calcular nada adicional. Esa decisión es la que terminó rompiéndose, y volvemos sobre ella más abajo.

La llave que sí funciona es un hash del contenido normalizado del fragmento, combinado con el identificador del modelo de embeddings y su versión. Normalizar antes de hashear importa: dos fragmentos que solo difieren en espacios en blanco al final de una línea, saltos de línea consecutivos o mayúsculas en una palabra no deberían producir dos entradas de caché distintas para, en la práctica, el mismo contenido semántico.

import hashlib

def normalizar(texto: str) -> str:
    return " ".join(texto.strip().split())

def clave_cache(texto: str, modelo: str, version_modelo: str) -> str:
    contenido = normalizar(texto)
    hash_contenido = hashlib.sha256(contenido.encode("utf-8")).hexdigest()
    return f"{modelo}:{version_modelo}:{hash_contenido}"

Incluir el modelo y su versión en la llave no es opcional. Si el embedding se calculó con un modelo y después el equipo migra a otro —o el mismo proveedor publica una nueva versión del modelo con una dimensionalidad distinta o pesos reentrenados—, los vectores viejos no son comparables con los nuevos en el mismo espacio. Sin esa parte de la llave, un cambio de modelo deja embeddings de dos generaciones distintas mezclados en la misma caché, indistinguibles hasta que alguien nota que las búsquedas empiezan a devolver resultados que no tienen sentido.

Dónde vive la caché

Guardamos los embeddings cacheados en una tabla de Postgres, no en un almacén en memoria como Redis, porque la caché necesita sobrevivir reinicios del servicio de ingestión y porque el volumen de fragmentos de un catálogo documental crece de forma constante, no es un conjunto de trabajo acotado que quepa cómodamente en memoria. La tabla es simple: la llave de caché como clave primaria, el vector serializado, y una marca de tiempo de cuándo se calculó.

create table embedding_cache (
    cache_key text primary key,
    embedding vector(1536) not null,
    created_at timestamptz not null default now()
);

La función de ingestión consulta primero esa tabla antes de llamar al modelo:

def obtener_o_calcular_embedding(texto: str, modelo: str, version_modelo: str, conexion):
    clave = clave_cache(texto, modelo, version_modelo)
    fila = conexion.execute(
        "select embedding from embedding_cache where cache_key = %s", (clave,)
    ).fetchone()
    if fila is not None:
        return fila[0]

    vector = llamar_modelo_embeddings(texto, modelo)
    conexion.execute(
        "insert into embedding_cache (cache_key, embedding) values (%s, %s) "
        "on conflict (cache_key) do nothing",
        (clave, vector),
    )
    return vector

El on conflict do nothing existe porque el pipeline de ingestión corre con paralelismo: es perfectamente posible que dos workers procesen el mismo fragmento casi al mismo tiempo antes de que ninguno haya terminado de escribir su resultado. Sin esa cláusula, el segundo worker en llegar fallaría con una violación de llave primaria por una condición de carrera que no tiene nada que ver con un bug real.

Lo que se rompió: invalidar mal tras actualizar el contenido fuente

La primera versión de este sistema, antes de mover la llave al hash del contenido, usaba document_id + chunk_index como clave. Funcionó bien en pruebas porque en pruebas los documentos no cambian. En producción, un documento se actualiza: alguien corrige un párrafo, agrega una sección o reordena el contenido. Cuando el pipeline de ingestión reprocesaba ese documento, el fragmento en la posición chunk_index = 3 ya no era el mismo texto que antes, pero la llave de caché seguía siendo idéntica porque dependía de la posición, no del contenido. El resultado fue que la caché devolvía, con total confianza, el embedding del texto viejo para un fragmento que ahora tenía un texto distinto.

Esto no generó ningún error. No hubo una excepción que alertara a nadie. El pipeline terminaba su corrida, reportaba éxito, y la base vectorial quedaba con un vector que representaba semánticamente el contenido anterior, indexado bajo el contenido nuevo. El síntoma que finalmente lo hizo visible fue que una búsqueda sobre un documento recién actualizado devolvía ese documento como relevante para preguntas que correspondían a su versión vieja, y como poco relevante para preguntas sobre el contenido que efectivamente tenía ahora. Nadie reportó un error del sistema: alguien reportó que "la búsqueda está desactualizada", lo cual, investigado a fondo, resultó ser literalmente cierto en el sentido más directo posible.

La corrección fue el cambio de llave que describimos arriba: al basar la clave de caché en el hash del contenido normalizado, un fragmento editado produce automáticamente una llave distinta, porque su contenido es distinto. No hace falta ninguna lógica de invalidación explícita —ningún DELETE disparado por un evento de actualización, ningún TTL que expire entradas viejas— porque una entrada de caché nunca queda asociada a algo que cambió: si el contenido cambia, la llave cambia, y la entrada vieja simplemente deja de consultarse. Sigue existiendo en la tabla, pero huérfana, y eso se resuelve aparte.

Purgar entradas huérfanas

Mover la llave al contenido resuelve la invalidación, pero introduce un problema distinto: la tabla embedding_cache acumula filas de contenido que ya no es referenciado por ningún fragmento vigente, porque cada edición de un documento deja atrás la entrada del texto anterior. Un job separado, que corre con baja frecuencia, compara las llaves presentes en embedding_cache contra las llaves que efectivamente usa la base vectorial activa, y borra en lotes las que no tienen ninguna referencia vigente. Correrlo con poca frecuencia es intencional: el valor de esas filas huérfanas como caché de un contenido que podría volver a aparecer —un párrafo que se revierte, una sección que se restaura— dura más que el costo de mantenerlas unos días de más.

Qué revisamos antes de dar por buena una caché de embeddings

Antes de considerar lista una implementación de caché de embeddings, verificamos que la llave de caché derive del contenido normalizado del fragmento, nunca de su posición o de un identificador externo que pueda quedar desincronizado del contenido real; que el modelo de embeddings y su versión formen parte de esa llave, para que un cambio de modelo no mezcle vectores de dos generaciones distintas; que la escritura en caché tolere condiciones de carrera entre workers paralelos sin fallar; y que exista un proceso de purgado de entradas huérfanas independiente de la lógica de invalidación, porque mover la invalidación al contenido elimina el riesgo de servir un vector viejo, pero no elimina la necesidad de limpiar lo que ese contenido viejo deja atrás. Cuando esas condiciones se cumplen, cachear embeddings deja de ser un atajo que puede devolver resultados obsoletos sin avisar, y se convierte en lo que debería ser desde el principio: no volver a pagar por un cálculo cuyo resultado ya se conoce.

RAG embeddings IA Postgres
← Volver al blog