Módulo 7: Production Considerations para RAG

Cápsula 02: Estrategias de Escalado

Descripción de la cápsula

Escalar un sistema RAG no es solo "poner más máquinas". Primero debes entender qué componente limita el rendimiento: ingestion, retrieval o generación. En esta cápsula verás los tres tipos de escalado (vertical, horizontal, sharding), configuraciones Docker Compose listas para usar, código Python para load testing y detección de cuellos de botella, y un proceso claro para decidir cuándo aplicar cada estrategia.


1. Componentes que pueden limitar el rendimiento

Antes de escalar, identifica el cuello de botella:

ComponenteSeñales típicasMétrica clave
IngestionCola de documentos crece, reindexing lentodocs/segundo, memoria
Retrievalp95 de búsqueda sube, latencia variablep50/p95/p99 (ms)
GeneraciónTokens/seg bajo, timeout en LLMtokens/s, timeout rate
RedConexiones bloqueadas, timeoutsconexiones, retries

Si no has medido baseline durante al menos una semana, no escales todavía. La siguiente cápsula cubre monitoring en detalle; aquí asumimos que ya sabes dónde está el cuello.


2. Escalado vertical (más CPU/RAM en el mismo nodo)

Cuándo usarlo

  • Aún no saturas un nodo (CPU < 70%, RAM < 85% sostenido).
  • El índice cabe en una sola máquina.
  • Tu carga es estable y predecible.
  • Estás en fase early-stage o MVP.

Cómo funciona

Aumentas los recursos de la misma instancia: más vCPUs, más RAM, disco más rápido. La ventaja es simplicidad: no introduces coordinación entre nodos, no cambias código, solo subes el tier de la máquina.

Trade-offs

ProsContras
Implementación trivialLímite físico (hard ceiling)
Sin cambios de arquitecturaPuede ser más caro que horizontal
Menor superficie de falloDowntime durante resize (según cloud)
Debugging más simpleNo resuelve cuellos de concurrencia

Cuándo dejar de escalar vertical

  • El tier más grande de tu cloud ya no da más.
  • La concurrencia es el problema (muchas queries simultáneas), no la potencia de un solo request.
  • El costo por nodo se dispara respecto al beneficio.

3. Escalado horizontal (réplicas + load balancer)

Cuándo usarlo

  • Ya tienes cuellos de concurrencia: muchas queries simultáneas saturan un nodo.
  • p95 de retrieval sube en picos de tráfico.
  • Un solo nodo no absorbe la carga aunque tenga recursos libres en promedio.

Arquitectura típica

                    ┌─────────────────┐
                    │  Load Balancer  │
                    │  (Nginx/HAProxy)│
                    └────────┬────────┘
                             │
              ┌──────────────┼──────────────┐
              │              │              │
              ▼              ▼              ▼
        ┌──────────┐  ┌──────────┐  ┌──────────┐
        │  RAG #1  │  │  RAG #2  │  │  RAG #3  │
        │ (Chroma) │  │ (Chroma) │  │ (Chroma) │
        └──────────┘  └──────────┘  └──────────┘
              │              │              │
              └──────────────┼──────────────┘
                             │
                    ┌────────▼────────┐
                    │ Vector DB       │
                    │ (Chroma/Qdrant) │
                    └─────────────────┘

Cada réplica de la API RAG comparte el mismo vector database (o una réplica de lectura). El load balancer reparte las requests entre las réplicas.

Docker Compose: réplicas + Nginx

# docker-compose.horizontal.yaml
version: '3.8'

services:
  nginx:
    image: nginx:alpine
    ports:
      - "8000:80"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
    depends_on:
      - rag-api-1
      - rag-api-2
      - rag-api-3

  rag-api-1:
    build: .
    environment:
      - CHROMA_HOST=chroma
      - REPLICA_ID=1
    deploy:
      replicas: 1

  rag-api-2:
    build: .
    environment:
      - CHROMA_HOST=chroma
      - REPLICA_ID=2
    deploy:
      replicas: 1

  rag-api-3:
    build: .
    environment:
      - CHROMA_HOST=chroma
      - REPLICA_ID=3
    deploy:
      replicas: 1

  chroma:
    image: chromadb/chroma:latest
    volumes:
      - chroma_data:/chroma/chroma
    environment:
      - IS_PERSISTENT=TRUE

volumes:
  chroma_data: {}

nginx.conf para round-robin:

events { worker_connections 1024; }

http {
    upstream rag_backend {
        least_conn;  # Opción alternativa: round_robin
        server rag-api-1:8000;
        server rag-api-2:8000;
        server rag-api-3:8000;
    }

    server {
        listen 80;
        location / {
            proxy_pass http://rag_backend;
            proxy_connect_timeout 5s;
            proxy_read_timeout 60s;
        }
    }
}

Autoscaling con Docker Swarm (opcional)

Si usas Swarm, puedes escalar dinámicamente:

docker service scale rag_api=5

Y en docker-compose:

rag-api:
  image: my-rag-api:latest
  deploy:
    replicas: 3
    resources:
      limits:
        cpus: '2'
        memory: 4G

4. Sharding (por tenant, fecha o dominio)

Cuándo usarlo

  • El índice crece tanto que una sola unidad no mantiene latencia estable.
  • Tienes multi-tenant y cada tenant tiene volúmenes muy distintos.
  • Quieres aislar carga por dominio (por ejemplo, por región o producto).

Criterios de sharding

CriterioUso típicoEjemplo
TenantSaaS multi-tenantshard_tenant_1, shard_tenant_2
FechaDocumentos con ventanas temporalesshard_2024_q1, shard_2024_q2
DominioDocumentos por producto/áreashard_producto_a, shard_producto_b
RegiónDatos geográficosshard_eu, shard_us

Regla práctica

No shardees hasta que el índice y el tráfico no se sostengan con una sola unidad razonable. Sharding introduce complejidad: routing de queries, rebalanceo, mantenimiento de múltiples colecciones o clusters.

Routing en código

def get_shard_for_query(tenant_id: str, query_date: date | None) -> str:
    """Determina qué shard usar según tenant y opcionalmente fecha."""
    if tenant_id:
        # Sharding por tenant (ej: hash para distribución)
        h = hash(tenant_id) % 8
        return f"shard_tenant_{h}"
    if query_date:
        # Sharding por trimestre
        quarter = (query_date.month - 1) // 3 + 1
        return f"shard_{query_date.year}_q{quarter}"
    return "shard_default"

Docker Compose: múltiples shards ChromaDB

# docker-compose.sharding.yaml
version: '3.8'

services:
  chroma-shard-0:
    image: chromadb/chroma:latest
    volumes:
      - chroma_shard_0:/chroma/chroma
    environment:
      - IS_PERSISTENT=TRUE

  chroma-shard-1:
    image: chromadb/chroma:latest
    volumes:
      - chroma_shard_1:/chroma/chroma
    environment:
      - IS_PERSISTENT=TRUE

  chroma-shard-2:
    image: chromadb/chroma:latest
    volumes:
      - chroma_shard_2:/chroma/chroma
    environment:
      - IS_PERSISTENT=TRUE

  rag-api:
    build: .
    environment:
      - CHROMA_SHARD_0=chroma-shard-0:8000
      - CHROMA_SHARD_1=chroma-shard-1:8000
      - CHROMA_SHARD_2=chroma-shard-2:8000
    depends_on:
      - chroma-shard-0
      - chroma-shard-1
      - chroma-shard-2

volumes:
  chroma_shard_0: {}
  chroma_shard_1: {}
  chroma_shard_2: {}

5. Load testing con Python

Script básico con Locust o requests

# load_test_rag.py
"""
Load test para API RAG. Uso: pip install locust && locust -f load_test_rag.py
O ejecutar directamente: python load_test_rag.py
"""
import asyncio
import time
import statistics
from concurrent.futures import ThreadPoolExecutor
import httpx

BASE_URL = "http://localhost:8000"  # Ajusta según tu API


def single_query(client: httpx.Client, query: str = "¿Qué es RAG?") -> float:
    """Ejecuta una query y devuelve la latencia en segundos."""
    start = time.perf_counter()
    resp = client.post(
        f"{BASE_URL}/v1/query",
        json={"query": query, "top_k": 5},
        timeout=30.0,
    )
    elapsed = time.perf_counter() - start
    resp.raise_for_status()
    return elapsed


def run_load_test(
    num_requests: int = 100,
    concurrency: int = 10,
    query: str = "¿Qué es RAG?",
) -> dict:
    """
    Ejecuta load test con múltiples workers.
    Retorna percentiles de latencia y throughput.
    """
    latencies: list[float] = []

    def worker(_: int) -> float:
        with httpx.Client() as client:
            return single_query(client, query)

    with ThreadPoolExecutor(max_workers=concurrency) as ex:
        futures = [ex.submit(worker, i) for i in range(num_requests)]
        latencies = [f.result() for f in futures]

    latencies_sorted = sorted(latencies)
    n = len(latencies_sorted)

    return {
        "count": n,
        "p50_ms": statistics.median(latencies) * 1000,
        "p95_ms": latencies_sorted[int(n * 0.95)] * 1000 if n else 0,
        "p99_ms": latencies_sorted[int(n * 0.99)] * 1000 if n else 0,
        "mean_ms": statistics.mean(latencies) * 1000,
        "throughput_rps": n / (max(latencies) or 1),
    }


if __name__ == "__main__":
    result = run_load_test(num_requests=200, concurrency=20)
    print("Resultados load test:")
    for k, v in result.items():
        print(f"  {k}: {v}")

Uso

pip install httpx
python load_test_rag.py

Ajusta BASE_URL, num_requests y concurrency según tu entorno. Compara p50, p95 y p99 antes y después de escalar.


6. Detección de cuellos de botella

Script para medir por componente

# bottleneck_detection.py
"""
Mide latencia por componente: retrieval vs generación vs total.
Ayuda a identificar si el cuello está en el vector DB o en el LLM.
"""
import time
import httpx

BASE_URL = "http://localhost:8000"


def measure_retrieval_only(query: str) -> float:
    """Solo retrieval (sin LLM). Tu API debe exponer un endpoint /retrieve."""
    start = time.perf_counter()
    resp = httpx.post(
        f"{BASE_URL}/v1/retrieve",
        json={"query": query, "top_k": 5},
        timeout=30.0,
    )
    resp.raise_for_status()
    return (time.perf_counter() - start) * 1000


def measure_full_rag(query: str) -> tuple[float, float]:
    """
    Query completa. Asume que la API devuelve retrieval_time_ms y generation_time_ms.
    Si no, tendrás que instrumentar tu API para devolver estos campos.
    """
    start = time.perf_counter()
    resp = httpx.post(
        f"{BASE_URL}/v1/query",
        json={"query": query, "top_k": 5},
        timeout=60.0,
    )
    resp.raise_for_status()
    total_ms = (time.perf_counter() - start) * 1000
    data = resp.json()
    retrieval_ms = data.get("retrieval_time_ms", 0)
    generation_ms = data.get("generation_time_ms", 0)
    return total_ms, retrieval_ms, generation_ms


def run_bottleneck_check(num_samples: int = 20, query: str = "¿Qué es RAG?"):
    """Ejecuta mediciones y reporta dónde está el cuello."""
    retrieval_times = [measure_retrieval_only(query) for _ in range(num_samples)]
    retrieval_avg = sum(retrieval_times) / len(retrieval_times)

    full_times = [measure_full_rag(query) for _ in range(num_samples)]
    total_avg = sum(t[0] for t in full_times) / len(full_times)
    ret_avg = sum(t[1] for t in full_times) / len(full_times)
    gen_avg = sum(t[2] for t in full_times) / len(full_times)

    print("=== Análisis de cuello de botella ===\n")
    print(f"Retrieval puro (avg): {retrieval_avg:.0f} ms")
    print(f"Total RAG (avg):     {total_avg:.0f} ms")
    print(f"Retrieval en RAG:    {ret_avg:.0f} ms")
    print(f"Generación en RAG:   {gen_avg:.0f} ms")
    print()

    retrieval_pct = (ret_avg / total_avg * 100) if total_avg else 0
    generation_pct = (gen_avg / total_avg * 100) if total_avg else 0

    if retrieval_pct > 50:
        print("→ El cuello está en RETRIEVAL. Considera: más réplicas, sharding, o tuning del índice.")
    elif generation_pct > 50:
        print("→ El cuello está en GENERACIÓN (LLM). Considera: modelo más rápido, cache, o más workers.")
    else:
        print("→ La latencia está repartida. Revisa red, I/O o otros componentes.")

Endpoint de ejemplo para instrumentar

Tu API RAG debería devolver tiempos por etapa. Ejemplo conceptual:

# En tu endpoint /v1/query
start_retrieval = time.perf_counter()
docs = vector_store.similarity_search(query, k=5)
retrieval_time_ms = (time.perf_counter() - start_retrieval) * 1000

start_generation = time.perf_counter()
response = llm.generate(context=docs, query=query)
generation_time_ms = (time.perf_counter() - start_generation) * 1000

return {
    "answer": response,
    "retrieval_time_ms": retrieval_time_ms,
    "generation_time_ms": generation_time_ms,
}

7. Regla práctica de decisión

  1. Si aún no saturas un nodo: escala vertical primero.
  2. Si ya tienes cuellos de concurrencia: pasa a horizontal (réplicas + LB).
  3. Si crece mucho el índice y la latencia se degrada: aplica sharding con criterios claros (tenant, fecha, dominio).
  4. Siempre: mide baseline → interviene una sola vez → vuelve a medir.

8. Señales de que necesitas escalar

SeñalAcción sugerida
p95 de retrieval sube de forma sostenidaRevisar retrieval: horizontal o sharding
Uso de memoria cercano al límiteEscalar vertical o añadir nodos
Cola de consultas aumenta en picosHorizontal (más réplicas)
Reindexing tarda horasParalelizar ingestion o sharding
Timeouts en LLMNo es vector DB: optimizar LLM

9. Proceso sugerido de escalado

  1. Mide baseline por al menos una semana (p50, p95, p99, memoria, CPU).
  2. Identifica el cuello con el script de bottleneck detection o métricas de APM.
  3. Ejecuta una sola intervención (solo vertical O solo horizontal O solo sharding).
  4. Repite medición y compara contra baseline.
  5. Si no hay mejora suficiente, repite el ciclo con otro tipo de escalado o con otro componente.

10. Ejercicios prácticos

Ejercicio 1: Decisión vertical vs horizontal

Caso: p95 pasó de 220 ms a 480 ms en dos semanas; memoria al 85 % en horas pico.

Preguntas:

  • ¿Escalarías primero vertical u horizontal?
  • ¿Qué métrica usarías para validar éxito?
  • ¿Qué condición dispararía una segunda intervención?
Ver solución
  • Vertical primero: La memoria al 85 % sugiere que el nodo está cerca del límite. Escalar vertical (más RAM) puede dar margen sin cambiar arquitectura. Si la CPU también está alta, más CPU ayuda.
  • Horizontal si: La concurrencia es el problema (muchas requests simultáneas) y un nodo más grande no absorbe bien los picos. En ese caso, añadir réplicas.
  • Métrica de validación: p95 de retrieval (o de la request completa) vuelve a ~220 ms o menos, con memoria estable por debajo del 80 %.
  • Segunda intervención: Si después de escalar vertical el p95 sigue alto o la memoria sigue al límite, entonces pasar a horizontal o revisar sharding si el índice creció mucho.

Ejercicio 2: Interpretar resultados de load test

Tienes estos resultados antes y después de añadir 2 réplicas:

MétricaAntesDespués
p50 (ms)18095
p95 (ms)420210
p99 (ms)890380
RPS1228

Pregunta: ¿El escalado horizontal fue efectivo? ¿Qué siguiente paso considerarías?

Ver solución

Sí, fue efectivo. Los percentiles mejoraron (p95 pasó de 420 ms a 210 ms) y el throughput casi se duplicó (12 → 28 RPS). El siguiente paso dependería de los objetivos: si 28 RPS es suficiente, mantener. Si se espera más tráfico, seguir añadiendo réplicas o introducir caché para queries repetidas. Si p99 (380 ms) sigue siendo alto para el producto, investigar outliers (queries pesadas, cold start, etc.).


Ejercicio 3: Elegir criterio de sharding

Tienes un sistema con 50 tenants. 3 de ellos representan el 70 % del volumen de documentos y del tráfico.

Pregunta: ¿Shardearías por tenant? Si sí, ¿cómo distribuirías los shards?

Ver solución

Shardear por tenant puede tener sentido si quieres aislar carga. Para 50 tenants con 3 muy dominantes, una opción es:

  • Shards dedicados para los 3 grandes: shard_tenant_A, shard_tenant_B, shard_tenant_C
  • Un shard compartido para el resto: shard_tenants_resto (con hash por tenant_id para distribución interna)

O bien, shardear por hash de tenant_id en N shards (ej: 8), aceptando que los 3 grandes pueden vivir en shards diferentes. La elección depende de si necesitas aislar costos/latencia por tenant o solo repartir carga.


Ejercicio 4: Docker Compose para 5 réplicas

Tarea: Modifica el docker-compose.horizontal.yaml de esta cápsula para usar un solo servicio rag-api con 5 réplicas en lugar de 3 servicios separados, manteniendo Nginx como load balancer.

Ver solución
version: '3.8'

services:
  nginx:
    image: nginx:alpine
    ports:
      - "8000:80"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
    depends_on:
      - rag-api

  rag-api:
    build: .
    environment:
      - CHROMA_HOST=chroma
    deploy:
      replicas: 5

  chroma:
    image: chromadb/chroma:latest
    volumes:
      - chroma_data:/chroma/chroma
    environment:
      - IS_PERSISTENT=TRUE

volumes:
  chroma_data: {}

En nginx.conf, el upstream debe resolver el nombre del servicio (en Docker/Swarm cada réplica tiene su IP):

upstream rag_backend {
    least_conn;
    server rag-api:8000;  # Docker resuelve a todas las réplicas
}

Nota: con docker compose sin Swarm, replicas no se usa directamente; necesitas docker compose up --scale rag-api=5 o migrar a Swarm/Kubernetes para replicas nativas.


Ejercicio 5: Script de bottleneck con timeout

Tarea: Modifica measure_full_rag en el script de bottleneck para que, si la request supera 45 segundos, registre un timeout y devuelva (45000, 0, 0) en lugar de fallar. Añade un contador de timeouts al final del reporte.

Ver solución
def measure_full_rag_with_timeout(
    query: str, timeout_sec: float = 45.0
) -> tuple[float, float, float, bool]:
    """
    Query completa con timeout. Retorna (total_ms, retrieval_ms, generation_ms, timed_out).
    """
    start = time.perf_counter()
    try:
        resp = httpx.post(
            f"{BASE_URL}/v1/query",
            json={"query": query, "top_k": 5},
            timeout=timeout_sec,
        )
        resp.raise_for_status()
        total_ms = (time.perf_counter() - start) * 1000
        data = resp.json()
        return (
            total_ms,
            data.get("retrieval_time_ms", 0),
            data.get("generation_time_ms", 0),
            False,
        )
    except (httpx.TimeoutException, httpx.ConnectTimeout):
        return (timeout_sec * 1000, 0, 0, True)


def run_bottleneck_check(num_samples: int = 20, query: str = "¿Qué es RAG?"):
    # ... setup ...
    timeouts = 0
    for _ in range(num_samples):
        total_ms, ret_ms, gen_ms, timed_out = measure_full_rag_with_timeout(query)
        if timed_out:
            timeouts += 1
        # ... acumular en listas ...
    print(f"\nTimeouts: {timeouts}/{num_samples}")

Ejercicio 6: Cuándo NO shardear

Pregunta: Da dos situaciones concretas en las que shardear sería prematuro o contraproducente.

Ver solución
  1. Índice pequeño y estable: Si tienes < 100 K vectores y la latencia es estable, shardear añade complejidad operativa (múltiples colecciones, routing, backups) sin beneficio medible. Primero escala vertical u horizontal.
  2. Queries que cruzan shards: Si muchas consultas necesitan resultados de varios tenants o rangos de fecha a la vez, shardear obliga a consultar N shards y fusionar, lo que puede empeorar latencia y código. En esos casos, un único índice con metadata filtering suele ser más simple.

11. Troubleshooting de escalado

"Escalé y no mejoró"

Probablemente no atacaste el cuello correcto. Si el problema está en retrieval y escalaste solo la API (o al revés), no verás mejora. Usa el script de bottleneck detection para confirmar si el tiempo se va en retrieval, generación o red. Asegúrate de tener métricas por componente antes de escalar.


"La latencia mejora pero el costo se dispara"

Introduce límites de autoscaling (min/max réplicas) y revisa cachés. Muchas consultas repetidas pueden servirse desde cache en lugar de ir al vector DB y al LLM. También revisa si estás sobredimensionando: reducir el tier vertical o el número de réplicas en horas valle puede bajar costos sin afectar picos.


"No sé si shardear todavía"

Shardea cuando el índice y el tráfico ya no se sostengan con una sola unidad razonable: p95 de retrieval crece aunque tengas recursos libres, o el índice es tan grande que una sola máquina no lo mantiene en RAM de forma eficiente. Si aún puedes escalar vertical u horizontal con buen ROI, espera.


"Las réplicas no reparten bien la carga"

Revisa la política del load balancer. round_robin trata todas las réplicas igual; least_conn envía más tráfico a las menos ocupadas. Si algunas requests son mucho más pesadas que otras, least_conn suele funcionar mejor. Verifica también que no haya un único punto de contenido (por ejemplo, un solo ChromaDB) que sature todas las réplicas.


"Después de shardear, algunas queries son lentas"

Puede que estés consultando varios shards y fusionando resultados. Si el routing no es preciso (por ejemplo, una query necesita datos de 3 shards), la latencia será la suma de las 3 consultas. Optimiza el routing para que la mayoría de queries golpeen un solo shard, o considera si el criterio de sharding es el adecuado.


12. Resumen

  • Escalar bien empieza por observación (baseline, métricas por componente), no por intuición.
  • Vertical: cuando no saturas un nodo; simple pero con techo.
  • Horizontal: cuando la concurrencia es el cuello; réplicas + load balancer.
  • Sharding: cuando el índice crece demasiado; por tenant, fecha o dominio.
  • Ejecuta una sola intervención a la vez y mide de nuevo antes de la siguiente.
  • Usa load testing y scripts de detección de cuellos antes de decidir qué escalar.
  • La siguiente cápsula te da las métricas y herramientas para observar correctamente en producción.

13. Recursos adicionales


Tiempo estimado: 25–35 minutos
Siguiente: 03-monitoring-observabilidad.md