Nadie sabe responder cuánto tarda
Pregunta en cualquier equipo con un RAG en producción cuánto tarda una respuesta. Te dirán “unos tres segundos”. Pregunta después cuánto de esos tres segundos es el modelo y cuánto es todo lo demás, y la conversación se acaba.
No es desinterés. Es que el pipeline se construyó por capas —primero la búsqueda vectorial, luego el léxico, luego el reranker, luego las citas— y cada capa se midió sola, el día que se añadió, con el portátil de quien la escribió. El total nunca se midió. Cuando alguien se queja de que va lento, el dedo apunta al sitio más caro intuitivamente: el LLM.
El LLM suele ser el que más tiempo consume. Casi nunca es el que explica el p95.
Las etapas y lo que cuesta cada una
Estos son los órdenes de magnitud que veo en pipelines con un corpus de cientos de miles de fragmentos y componentes alojados en servicios de terceros. No son tus números: son el mapa para ir a buscar los tuyos.
| Etapa | Mediana típica | Cola (p95) | Dónde vive |
|---|---|---|---|
| Embebido de la consulta | 40 ms | 400 ms | Llamada de red |
| Búsqueda vectorial (HNSW) | 15 ms | 40 ms | Proceso o base de datos |
| Búsqueda léxica (BM25) | 25 ms | 60 ms | Base de datos |
| Reranking de 50 candidatos | 200 ms | 900 ms | Llamada de red |
| Montaje del prompt | 2 ms | 5 ms | Proceso |
| Primer token del modelo | 600 ms | 2.500 ms | Llamada de red |
| Generación completa | 2.000 ms | 5.000 ms | Llamada de red |
Lee la tabla por columnas y no por filas. En la columna de la mediana, la generación se come el 70 % del tiempo y las dos búsquedas son ruido. En la columna de la cola, el reparto cambia: tres de las cuatro etapas que se disparan son llamadas de red a servicios que no controlas, y dos de ellas —el embebido y el reranker— están antes de que el usuario vea un solo carácter.
Esa es la asimetría que importa. La mediana la domina lo que el usuario ve llegar. La cola la domina lo que ocurre mientras mira un cursor parpadeando.
Instrumentar antes de optimizar
Una traza por etapa, con su presupuesto declarado. Sin esto, cualquier optimización es una apuesta.
import asyncio
import time
from contextlib import asynccontextmanager
# Presupuesto por etapa, en milisegundos. No es documentación: es el timeout
# real que se aplica. Un presupuesto que no corta nada es un comentario.
PRESUPUESTO = {
"embebido": 300,
"busquedas": 200,
"reranking": 800,
"primer_token": 2_000,
}
@asynccontextmanager
async def etapa(nombre: str, traza: dict):
inicio = time.perf_counter()
try:
# El timeout va por etapa, nunca solo en el borde de la petición. Un
# timeout global de 5 s deja que el reranker se coma el presupuesto
# entero y luego mata al modelo, que es la etapa que sí importa.
async with asyncio.timeout(PRESUPUESTO[nombre] / 1000):
yield
finally:
# Se registra también cuando la etapa falla. Las etapas que revientan
# son justamente las que explican el p95, y si solo instrumentas el
# camino feliz tu panel enseña un sistema más rápido del que tienes.
traza[nombre] = round((time.perf_counter() - inicio) * 1000, 1)
async def recuperar(consulta: str, traza: dict):
async with etapa("embebido", traza):
vector = await embeber(consulta)
# Las dos búsquedas van en una sola etapa porque se lanzan juntas: léxico y
# vectorial no se esperan el uno al otro, no comparten entrada más allá de
# la consulta y el presupuesto es el del más lento, no la suma. Medirlas
# como dos etapas secuenciales es el fallo más común aquí, y el más barato
# de arreglar: no cambia ni un resultado.
async with etapa("busquedas", traza):
vec, lex = await asyncio.gather(
buscar_vectorial(vector),
buscar_lexico(consulta),
)
candidatos = fusionar(vec, lex)
async with etapa("reranking", traza):
ordenados = await rerankear(consulta, candidatos)
return ordenados[:8]
Con eso en producción, la pregunta “¿cuánto tarda?” tiene respuesta en una semana. Y la respuesta suele sorprender: en la mitad de los pipelines que he mirado, la etapa más lenta del p95 era el embebido de la consulta, un modelo pequeño que todo el mundo asume gratis porque en la mediana cuesta 40 ms.
Los reintentos doblan la cola que ya tenías
Aquí está el detalle que convierte una cola molesta en un incidente. Si cada llamada de red lleva reintentos con espera exponencial —y debe llevarlos—, el p95 observado no es la latencia del servicio: es la latencia del servicio más el tiempo de los intentos fallidos.
Un reranker con un p99 de dos segundos y dos reintentos de 500 ms de espera te da una cola de más de cinco segundos en el 1 % de las peticiones. Ese 1 % es el que escribe en el canal de soporte.
La regla que uso: el presupuesto de una etapa incluye sus reintentos. Si el reranker tiene 800 ms, eso son 800 ms para todos los intentos juntos, no por intento. Cuando el presupuesto se agota, la etapa se salta y se devuelve el resultado sin rerankear, con una marca en la traza. Una respuesta algo peor en 400 ms vale más que una respuesta óptima en seis segundos, y el usuario no distingue las dos listas pero sí las dos esperas.
Tres recortes que mueven el número
Paralelizar las dos búsquedas. Está en el código de arriba. No cambia ni un resultado, no tiene riesgo y en una búsqueda híbrida elimina del total la más rápida de las dos: con los números de la tabla de arriba, 15 ms de la mediana y 40 ms de la cola. Es el único recorte de esta lista que no tiene contrapartida.
Cachear el embebido de la consulta. Las consultas reales se repiten mucho más de lo que parece: en un asistente interno, el 20 % del tráfico son literalmente las mismas diez preguntas. Un diccionario con la consulta normalizada como clave elimina una llamada de red completa, con su cola y sus reintentos, en una de cada cinco peticiones.
Rerankear menos candidatos. Pasar de 50 a 20 recorta el reranking casi por la mitad, y el impacto en las métricas del retriever es medible: mídelo antes de decidir. A veces el recall@8 no se mueve y te has llevado 100 ms gratis. A veces cae tres puntos y no compensa. Esto no se opina, se mide con el conjunto de evaluación que ya deberías tener.
Lo que no se recorta
El streaming no es una optimización de latencia: es una optimización de latencia percibida, y conviene no confundirlas en un panel. Enviar tokens a medida que salen no hace el pipeline más rápido, hace que el usuario deje de esperar antes. Es la mejora de experiencia más grande por línea de código escrita, y no arregla nada de lo que mide tu p95.
Tampoco se recortan las citas. Verificar que cada afirmación apunta a un fragmento real cuesta tiempo de proceso, y es tiempo que compra lo único que hace útil un RAG frente a un modelo suelto.
Y no se recorta bajando el top-k por debajo de lo que el retriever necesita. Ganar 30 ms para empeorar la respuesta es cambiar un problema que el usuario tolera por uno que no perdona.
Por dónde empezar
El lunes, en este orden. Primero, un diccionario de milisegundos por etapa en la respuesta de tu endpoint, detrás de una cabecera de depuración. Media hora de trabajo y te enseña el reparto real antes de comer.
Segundo, mira el p95 de cada etapa por separado durante una semana, no la media. La media de un pipeline con cinco llamadas de red no describe ninguna petición que haya ocurrido de verdad.
Tercero, ponle presupuesto a cada etapa y haz que se cumpla, con degradación explícita cuando se agote. Un pipeline que se salta el reranker bajo presión y lo deja escrito en la traza es un pipeline que puedes razonar. Uno que espera lo que haga falta solo te da un número distinto cada día y ninguna explicación.