La factura de un LLM en producción no crece por las preguntas difíciles: crece por las repetidas. En cualquier producto con un asistente, una parte enorme del tráfico son variaciones de las mismas consultas — "¿cómo cambio mi contraseña?", "cómo restablezco mi clave", "olvidé mi password". Un caché tradicional por clave exacta no captura nada de eso, porque el texto nunca es idéntico. Un caché semántico sí: convierte la pregunta en un embedding, busca si ya respondiste algo suficientemente parecido, y si el vecino más cercano supera un umbral de similitud, devuelve la respuesta guardada sin tocar el modelo. Elegí este patrón en un producto real porque el costo por request importaba más que la frescura absoluta de cada respuesta; a cambio, acepté la complejidad de gestionar umbrales y de invalidar entradas. Este artículo es lo que aprendí.
La mecánica: pgvector es suficiente
No necesitas infraestructura nueva. Si ya usas PostgreSQL con pgvector para búsqueda semántica — que es mi caso habitual con Prisma —, el caché es una tabla más:
CREATE TABLE llm_cache (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
query_text TEXT NOT NULL,
query_embedding vector(1536) NOT NULL,
response TEXT NOT NULL,
model TEXT NOT NULL,
hit_count INT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX ON llm_cache
USING hnsw (query_embedding vector_cosine_ops);
El flujo de lectura es un query de vecino más cercano con umbral:
const [hit] = await prisma.$queryRaw<CacheHit[]>`
SELECT response, 1 - (query_embedding <=> ${embedding}::vector) AS similarity
FROM llm_cache
WHERE expires_at > now()
ORDER BY query_embedding <=> ${embedding}::vector
LIMIT 1
`;
if (hit && hit.similarity >= 0.92) {
return hit.response; // cache hit: cero tokens gastados
}
Si no hay hit, llamas al modelo, guardas la respuesta con su embedding, y el siguiente usuario que pregunte lo mismo con otras palabras ya no paga.
El umbral es la decisión de producto, no un detalle técnico
Todo el patrón vive o muere en ese 0.92. Demasiado bajo, y sirves respuestas equivocadas a preguntas que solo se parecen superficialmente — "¿cómo cancelo mi suscripción?" y "¿cómo cancelo un pago?" tienen embeddings incómodamente cercanos. Demasiado alto, y tu hit rate se desploma y el caché no paga su propia complejidad.
Mi enfoque: empezar en 0.95, loguear cada hit con la pregunta original y la cacheada, y revisar una muestra a mano cada semana. Bajar el umbral solo cuando los falsos positivos de la franja siguiente sean aceptables. En dominios con vocabulario cerrado (soporte de un producto concreto) he llegado a 0.90; en dominios abiertos no bajaría de 0.93.
💡 El umbral de similitud no es un hiperparámetro que se ajusta una vez: es una política de producto que define cuánta imprecisión toleras a cambio de costo. Trátalo como tal — con logging, revisión periódica y un owner.
Invalidación: el precio oculto
El caché semántico hereda el problema clásico de los cachés y le añade uno propio. El clásico: las respuestas caducan. Si tu producto cambia el flujo de facturación, todas las respuestas cacheadas sobre facturación son ahora mentiras bien escritas. Por eso cada entrada lleva expires_at y, más importante, un mecanismo de invalidación por categoría: etiqueto las entradas con el área funcional que tocan y cuando despliego un cambio en esa área, borro la categoría entera. Es brusco, pero es predecible.
El problema propio: las respuestas personalizadas. Si la respuesta del modelo incluye datos del usuario ("tu plan actual es Pro"), cachearla y servírsela a otro usuario es una fuga de datos, no una optimización. Mi regla es binaria: solo entra al caché lo que pasa por un clasificador previo de "pregunta genérica". Todo lo que dependa del contexto del usuario se queda fuera, sin excepciones ni casos especiales.
Cuándo no hacerlo
Los trade-offs mandan. Un caché semántico compensa cuando el tráfico tiene alta redundancia semántica (soporte, FAQs, onboarding), el costo por llamada es relevante y la latencia importa — un hit responde en ~50ms contra los 2-4 segundos de un modelo grande. No compensa cuando cada pregunta es única (herramientas creativas, análisis de documentos del usuario), cuando las respuestas dependen de contexto personal, o cuando el volumen es tan bajo que la factura del LLM es ruido.
En mi caso, la señal para construirlo fue mirar los logs: cuando ves la misma intención expresada de veinte formas distintas un día tras otro, el caché se justifica solo. Si no has mirado tus logs todavía, empieza por ahí — es posible que no necesites nada de esto, y esa también es una buena noticia.