ruta rag en profundidad / módulo 6

M6 — Latencia, costo y operación

Outcome del módulo: medir dónde se va el tiempo y el dinero en cada query de scholar-rag, atacar el cuello con datos, y dejar el sistema observable en producción. Aquí cierras el ciclo: de “funciona” a “sé operarlo”.


Concepto 1 — Percentiles: por qué el promedio miente

Idea núcleo: el tiempo de respuesta se mide en percentiles (p50, p95, p99), no en promedio. El usuario siente el p99, y el promedio lo esconde.

Entender (capa 1)

Texto: si mides latencia con el promedio, un sistema que responde en 200ms casi siempre pero se va a 5s el 2% de las veces te da un promedio bonito que oculta el desastre. Los percentiles cuentan la verdad: p50 (mediana) es “la mitad responde más rápido que esto”; p95 es “el 5% peor tarda más que esto”; p99 es “el 1% peor”. Un p99 alto significa que uno de cada cien usuarios sufre, y en un servicio con volumen eso es mucha gente, todo el tiempo.

Por qué importa el p99 y no el promedio: las latencias no se distribuyen parejo, tienen cola larga (una query que cayó en un pico de GC, un cold start, un lock de BD). Esa cola es la experiencia real de los momentos malos, y es donde vive la percepción de “el sistema a veces se traba”. Optimizar el promedio puede dejar el p99 intacto; optimizar el p99 es lo que hace que el sistema se sienta sólido.

Visual:

flowchart LR
    D[distribucion de latencias] --> P50["p50 = 180ms: la experiencia tipica"]
    D --> P95["p95 = 600ms: dias regulares"]
    D --> P99["p99 = 4200ms: la cola que el usuario recuerda"]
    AVG["promedio = 240ms: esconde la cola"] -.engaña.-> D

Ejemplo:

import numpy as np
lat = np.array(latencies_ms)
print("p50", np.percentile(lat, 50),
      "p95", np.percentile(lat, 95),
      "p99", np.percentile(lat, 99))
# el promedio (lat.mean()) casi nunca es la métrica que reportas

Fijar (capa 2)

Nota atómica:

Feynman: “Si meto la cabeza en el horno y los pies en el congelador, en promedio estoy tibio. El promedio de latencia es igual de útil. Los percentiles dicen qué tan mal la pasa el que peor la pasa, que es lo que la gente recuerda.”

Aplicar (capa 3)

Instrumenta scholar-rag para registrar la latencia de cada query y calcula p50/p95/p99 sobre unas cientos. Anótalos. Ese p99 es el número que vas a bajar, y el antes/después es la respuesta a la pregunta 3 de las cinco.


Concepto 2 — Profiling: mide dónde se va el tiempo, no adivines

Idea núcleo: el cuello casi nunca está donde crees. Antes de optimizar, mides la latencia por etapa (embed, retrieve, grade, generate) y atacas la que domina.

Entender (capa 1)

Texto: la regla de oro del rendimiento: mide, no adivines. Es tentador “optimizar” el retrieval porque suena lento, cuando el 80% del tiempo se va en la llamada al LLM. Perfilar es medir cuánto tarda cada etapa del grafo por separado, para saber cuál domina el p99. En un RAG el reparto típico es: embedding (chico si está bien), retrieval (chico con índice, enorme sin él — ver M2), grade (una llamada extra al LLM), generate (casi siempre la más grande, porque generar tokens es lento). Pero “típico” no es “el tuyo”: lo mides.

Con el desglose en mano, la optimización se vuelve obvia: atacas la etapa que más pesa. Si generate domina, quizá streamear la respuesta o usar un modelo más rápido; si retrieve domina, ya sabes (índice, M2); si grade cuesta demasiado para lo que aporta (M4), lo quitas. Optimizar sin perfilar es apostar; con perfil, es cirugía.

Visual:

flowchart LR
    Q[query] --> E["embed: 15ms"]
    E --> R["retrieve: 40ms"]
    R --> G["grade: 300ms - llamada LLM"]
    G --> GE["generate: 900ms - domina"]
    GE --> T[total p99]
    GE -.ataca esto primero.-> O[optimizar]

Ejemplo:

import time
async def timed(name, coro):
    t0 = time.perf_counter()
    r = await coro
    stage_ms[name] = (time.perf_counter() - t0) * 1000
    return r

ctx = await timed("retrieve", retrieval.hybrid_search(vec))
ans = await timed("generate", llm.generate(q, ctx))
# suma stage_ms sobre muchas queries: la etapa dominante es tu objetivo

Fijar (capa 2)

Nota atómica:

Feynman: “Perfilar es medir el tráfico antes de ensanchar una calle. Ensanchar la calle vacía porque ‘se ve angosta’ no sirve; el trancón estaba tres cuadras más allá. El perfil te muestra dónde está el trancón real.”

Aplicar (capa 3)

Agrega timers por etapa en scholar-rag y corre 100 queries. Ordena las etapas por tiempo. Identifica la dominante: esa, y solo esa, es la que optimizas primero. Guarda el desglose para el design doc.


Concepto 3 — El LLM como dependencia hostil: el wrapper robusto

Idea núcleo: toda llamada al LLM puede tardar de más, fallar o devolver basura. Un wrapper con timeout + retry con backoff + validación convierte esos fallos en algo manejable. Y es reusable en tus otros productos.

Entender (capa 1)

Texto: este es el gap técnico que atraviesa todo tu portafolio: el LLM se trata como servicio confiable, y no lo es. Un cliente LLM listo para producción envuelve cada llamada con: timeout (no colgar indefinido, M5), retry con backoff exponencial (un 429 o 5xx se reintenta esperando cada vez más, para no golpear un servicio caído), validación de output (parsear contra schema antes de usar, M4), y fallback (qué responder cuando, tras los reintentos, no hay respuesta buena). Sin esto, un hipo del proveedor se propaga como un crash a tu usuario.

Lo valioso: este wrapper no es solo para scholar-rag. Es un módulo que importas en market-discovery, internal-console, outlier-miner, cualquier producto tuyo que llame a un LLM. Construirlo bien una vez, con tests, es a la vez una mejora de scholar-rag y una pieza de infraestructura de tu portafolio, la que demuestra que sabes operar LLMs en serio.

Visual:

flowchart TB
    C[llamada al LLM] --> T{timeout}
    T -->|ok| V{valida schema?}
    T -->|tarda de mas| RT[retry con backoff]
    RT -->|reintentos agotados| FB[fallback: respuesta degradada]
    V -->|si| U[usar]
    V -->|no| RT

Ejemplo:

async def robust_complete(prompt, *, retries=3, timeout_s=20):
    for attempt in range(retries):
        try:
            async with asyncio.timeout(timeout_s):
                raw = await client.complete(prompt)
            return RagAnswer.model_validate(raw)         # valida (M4)
        except (TimeoutError, RateLimitError, ValidationError) as e:
            await asyncio.sleep(2 ** attempt)            # backoff: 1s, 2s, 4s
            last = e
    return RagAnswer(answer="No disponible ahora.", grounded=False)  # fallback

Fijar (capa 2)

Nota atómica:

Feynman: “Llamar al LLM sin wrapper es marcar un teléfono que a veces no contesta, a veces contesta en chino, a veces tarda una hora, y creer que siempre responde claro y rápido. El wrapper es el protocolo: cuelgo si tarda, vuelvo a marcar con paciencia, y si nada, tengo un plan B.”

Aplicar (capa 3)

Extrae las llamadas al LLM de scholar-rag a un robust_complete con timeout, retry+backoff, validación y fallback. Escríbele tests (simulando 429 y salida off-schema). Ese módulo, con tests, es candidato a vivir por separado y ser importado por tus otros productos.


Concepto 4 — Costo por query: la métrica que casi nadie mide

Idea núcleo: cada query tiene un costo en tokens (dinero real). Medirlo por etapa te deja decidir el trade-off calidad/costo con números, no con miedo.

Entender (capa 1)

Texto: un RAG hace varias llamadas al LLM por query (el grade de cada chunk, la generación final), y cada una consume tokens que cuestan. Si no lo mides, no sabes cuánto te cuesta atender a un usuario, ni cuál etapa es la cara. Medir el costo por query —tokens de entrada y salida por llamada, multiplicados por el precio del modelo— abre decisiones concretas: quizá grade cuesta la mitad del presupuesto para lo poco que aporta (M4), o un modelo más barato en la generación baja el costo 5x con una caída mínima de faithfulness (que ya sabes medir, M1). El eje calidad/costo/latencia es un triángulo: mover uno mueve los otros, y solo lo navegas con los tres medidos.

Esta es una métrica que separa al que “montó un RAG” del que piensa como dueño de un sistema en producción: sabe cuánto cuesta cada respuesta y por qué.

Visual:

flowchart TB
    Q[una query] --> G["grade: N llamadas x tokens"]
    Q --> GE["generate: tokens in + out"]
    G --> C["costo = tokens x precio del modelo"]
    GE --> C
    C --> D{decision: calidad vs costo vs latencia}

Ejemplo:

def query_cost(usage, price_in, price_out) -> float:
    return usage.input_tokens * price_in + usage.output_tokens * price_out
# registra costo por etapa; suma sobre queries; compáralo con faithfulness (M1)
# para elegir modelo: el barato que no baje faithfulness es el correcto

Fijar (capa 2)

Nota atómica:

Feynman: “No medir el costo por query es manejar un negocio sin saber cuánto cuesta hacer cada plato. Igual vendes, pero no sabes si ganas o pierdes en cada uno, ni cuál ingrediente te está fundiendo.”

Aplicar (capa 3)

Registra tokens de entrada/salida por llamada en scholar-rag y calcula el costo por query. Identifica la etapa más cara. Prueba un modelo más barato en la generación y mide costo y faithfulness a la vez: si la faithfulness aguanta, acabas de bajar el costo con evidencia.


Concepto 5 — Observabilidad: operar, no solo desplegar

Idea núcleo: un sistema en producción necesita que puedas ver qué hace sin adivinar: trazas por request, logs estructurados, y objetivos claros (SLOs). Es la diferencia entre “lo deployé” y “lo opero”.

Entender (capa 1)

Texto: desplegar es que el código corra en un servidor. Operar es saber, en cualquier momento, si está sano y por qué. Tres piezas: logging estructurado (JSON con trace_id, tenant_id, etapa, latencia, costo — no print() sueltos ni except: pass que se tragan el error, los dos vicios que la auditoría encontró en scholar-rag); tracing por request (seguir una query a través de embed → retrieve → grade → generate para ver dónde falló o se demoró); y SLOs (objetivos explícitos: “p99 < 2s”, “faithfulness > 0.85”), que convierten “va bien” en algo verificable y alertable.

Con esto, cuando algo se rompe a las 3am no adivinas: abres la traza de las peticiones lentas, ves la etapa culpable, revisas el log estructurado con su contexto, y sabes. Esa capacidad —diagnosticar producción con datos— es lo que un ingeniero senior internacional da por sentado y lo que cierra la brecha entre demo y sistema.

Visual:

flowchart LR
    R[request] --> TR[trace: id unico por toda la cadena]
    TR --> LG[logs estructurados: json con contexto]
    LG --> SLO{cumple SLO? p99<2s, faith>0.85}
    SLO -->|no| AL[alerta: sabes que y donde]
    SLO -->|si| OK[sano, verificable]

Ejemplo:

# structlog: contexto en cada log, no print sueltos
log.info("rag_query", trace_id=tid, stage="generate",
         latency_ms=stage_ms["generate"], cost_usd=cost, faithful=score)
# nunca:  print("done")   ni   except Exception: pass

Fijar (capa 2)

Nota atómica:

Feynman: “Desplegar es prender el carro y soltarlo. Operar es tener tablero: velocímetro, temperatura, luz de aceite. Sin tablero, te enteras de que algo anda mal cuando el motor ya se fundió en la autopista.”

Aplicar (capa 3)

Reemplaza los print() de scholar-rag por logging estructurado con trace_id, etapa, latencia y costo. Elimina los except: pass (loguea el error con contexto). Define dos SLOs (uno de latencia, uno de faithfulness). Con eso, scholar-rag pasa de desplegado a operable.


Cierre del módulo y del curso

Cerraste el ciclo: sabes dónde se va el tiempo (perfilado) y el dinero (costo por query), envolviste el LLM como el tercero hostil que es, y dejaste el sistema observable. scholar-rag ya no es un RAG que hiciste, es un sistema que sabes operar.

Lo que puedes responder ahora: “¿cuánto cuesta y tarda una query, dónde está el cuello y cómo lo bajaste?” — con el antes/después medido.

Ya tienes lo necesario para las cinco preguntas del Capstone. Ahí conviertes todo esto en los tres artefactos públicos que hacen visible tu profundidad.


El mapa completo (fases, orden de excavación) vive en tu vault: Personal/Carrera/Mapa-Profundidad-scholar-rag.md.