Módulo 8: Proyecto Integrador RAG con ChromaDB

Cápsula 07: Hardening Final

Descripción de la cápsula

Antes de cerrar el proyecto, aplicarás una pasada de hardening: robustez ante fallos, seguridad básica y mantenibilidad. Un sistema RAG "funciona" cuando retrieval y generation responden; un sistema "production-ready" sigue funcionando cuando ChromaDB se cae, el LLM timeout, o un atacante envía 10.000 requests por segundo.

En esta cápsula implementarás:

  • Manejo de errores con retry logic, fallback responses y degradación controlada
  • Caching (embedding cache + result cache) con métricas de hit rate
  • Seguridad (API keys, rate limiting, validación de entradas)
  • Documentación (README con diagrama, API reference, guía de deployment)

Al final tendrás un sistema que se defiende ante fallos y abusos, y documentación que permite a cualquier developer operarlo.


Por qué Hardening Importa

Escenarios reales de fallo

Día 1: Todo bien
Día 2: ChromaDB reinicia por deploy → /ask devuelve 500 durante 30s
Día 3: OpenAI tiene latencia p99 de 15s → timeouts en cascada
Día 4: Un script mal escrito hace 500 req/seg → servicio caído para todos
Día 5: Alguien descubre que no hay auth → extrae todo el corpus

Sin hardening, cada uno de estos es un incidente. Con hardening: retries, fallbacks, rate limit y auth reducen el impacto.


Áreas de Hardening

ÁreaQué hacerPrioridad
ConfiabilidadRetries, timeouts, fallbacks cuando servicio externo fallaAlta
SeguridadAPI key, rate limiting, validación de inputsAlta
PerformanceCaching de embeddings y resultadosMedia
ErroresFormato uniforme 4xx/5xx, no exponer stack tracesAlta
DocumentaciónREADME, API reference, runbook de operaciónMedia

Error Handling Robusto

Retry Logic para ChromaDB y OpenAI

# app/utils/retry.py
import asyncio
import functools
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type

def with_retry(max_attempts=3, exceptions=(ConnectionError, TimeoutError)):
    def decorator(func):
        @functools.wraps(func)
        async def wrapper(*args, **kwargs):
            last_exc = None
            for attempt in range(max_attempts):
                try:
                    return await func(*args, **kwargs)
                except exceptions as e:
                    last_exc = e
                    if attempt < max_attempts - 1:
                        await asyncio.sleep(2 ** attempt)  # 1s, 2s, 4s
            raise last_exc
        return wrapper
    return decorator

# Uso
@with_retry(max_attempts=3)
async def get_chroma_results(collection, query_embedding, top_k=5):
    return collection.query(query_embeddings=[query_embedding], n_results=top_k)

Con tenacity (opción más completa):

from tenacity import retry, stop_after_attempt, wait_exponential

@retry(
    stop=stop_after_attempt(3),
    wait=wait_exponential(multiplier=1, min=1, max=10),
    retry=retry_if_exception_type((ConnectionError, TimeoutError))
)
async def call_openai(messages):
    # ...

Fallback Responses

Cuando retrieval o generation fallan, devolver una respuesta controlada en lugar de 500 genérico.

# app/api/ask.py
FALLBACK_MESSAGES = {
    "retrieval_failed": "No pude buscar en la base de conocimientos. Por favor intenta de nuevo en unos segundos.",
    "generation_failed": "Encontré información relevante pero tuve un problema al generar la respuesta. Intenta reformular tu pregunta.",
    "no_evidence": "No encontré información suficiente para responder tu pregunta con confianza. ¿Puedes ser más específico?",
}

async def ask_endpoint(payload: dict):
    question = payload.get("question", "").strip()[:500]  # límite
    if not question:
        raise HTTPException(400, "question is required")

    try:
        docs = await retrieve(question, top_k=5)
    except Exception as e:
        logger.warning(f"Retrieval failed: {e}")
        return {
            "answer": FALLBACK_MESSAGES["retrieval_failed"],
            "sources": [],
            "confidence": 0,
            "fallback": True,
        }

    if not docs or not docs.get("documents") or not docs["documents"][0]:
        return {
            "answer": FALLBACK_MESSAGES["no_evidence"],
            "sources": [],
            "confidence": 0,
        }

    try:
        answer = await generate_answer(question, docs["documents"][0])
    except Exception as e:
        logger.warning(f"Generation failed: {e}")
        return {
            "answer": FALLBACK_MESSAGES["generation_failed"],
            "sources": [m for m in docs.get("metadatas", [[]])[0]],
            "confidence": 0.5,
            "fallback": True,
        }

    return {"answer": answer, "sources": docs["metadatas"][0], "confidence": 0.85}

Graceful Degradation

Si ChromaDB está caído pero tienes cache de resultados, puedes seguir respondiendo preguntas frecuentes:

async def ask_with_degradation(question: str):
    # 1. Intentar cache de resultado primero
    cached = await result_cache.get(question)
    if cached:
        return {**cached, "from_cache": True}

    # 2. Intentar retrieval normal
    try:
        docs = await retrieve(question)
    except Exception:
        # 3. Degradar: responder solo con modelo (sin RAG) o mensaje de indisponibilidad
        return {"answer": "El servicio de búsqueda no está disponible. Intenta más tarde.", "sources": []}

    # ... flujo normal

Caching

Embedding Cache

Evita re-computar embeddings para las mismas preguntas o textos.

# app/cache/embedding_cache.py
import hashlib
import json
from typing import Optional

# Usando Redis o dict en memoria para desarrollo
class EmbeddingCache:
    def __init__(self, redis_url: Optional[str] = None):
        self._redis = redis.from_url(redis_url) if redis_url else {}
        self._local = {} if not redis_url else None  # fallback in-memory

    def _key(self, text: str, model: str) -> str:
        h = hashlib.sha256(f"{model}:{text}".encode()).hexdigest()
        return f"emb:{h}"

    async def get(self, text: str, model: str = "text-embedding-3-small") -> Optional[list]:
        k = self._key(text, model)
        if self._redis:
            val = await self._redis.get(k)
            return json.loads(val) if val else None
        return self._local.get(k)

    async def set(self, text: str, embedding: list, model: str = "text-embedding-3-small", ttl: int = 86400):
        k = self._key(text, model)
        val = json.dumps(embedding)
        if self._redis:
            await self._redis.setex(k, ttl, val)
        else:
            self._local[k] = embedding

Result Cache

Cachea respuestas completas de /ask para preguntas idénticas.

# app/cache/result_cache.py
class ResultCache:
    def __init__(self, redis_url: Optional[str] = None, ttl: int = 3600):
        self._redis = redis.from_url(redis_url) if redis_url else {}
        self._ttl = ttl

    def _key(self, question: str) -> str:
        return f"ask:{hashlib.sha256(question.strip().lower().encode()).hexdigest()}"

    async def get(self, question: str) -> Optional[dict]:
        k = self._key(question)
        if self._redis:
            val = await self._redis.get(k)
            return json.loads(val) if val else None
        return None

    async def set(self, question: str, result: dict):
        k = self._key(question)
        if self._redis:
            await self._redis.setex(k, self._ttl, json.dumps(result))

Métricas de Hit Rate

# app/metrics.py
from prometheus_client import Counter
cache_hits = Counter("rag_cache_hits_total", "Cache hits", ["cache_type"])  # embedding, result
cache_misses = Counter("rag_cache_misses_total", "Cache misses", ["cache_type"])

# En el flujo
if cached_emb := await embedding_cache.get(text):
    cache_hits.labels(cache_type="embedding").inc()
    return cached_emb
cache_misses.labels(cache_type="embedding").inc()
emb = await get_embedding(text)
await embedding_cache.set(text, emb)

Seguridad

API Key en Headers

# app/auth.py
from fastapi import Security, HTTPException
from fastapi.security import APIKeyHeader

api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)

async def verify_api_key(api_key: str = Security(api_key_header)):
    expected = os.getenv("API_KEY")
    if not expected:
        return None  # Sin API key configurada, permitir (solo para dev)
    if api_key != expected:
        raise HTTPException(403, "Invalid API key")
    return api_key

@app.post("/ask", dependencies=[Depends(verify_api_key)])
async def ask(payload: dict):
    ...

Rate Limiting

# app/middleware/rate_limit.py
from slowapi import Limiter
from slowapi.util import get_remote_address

limiter = Limiter(key_func=get_remote_address)
app.state.limiter = limiter

@app.post("/ask")
@limiter.limit("20/minute")
async def ask(request: Request, payload: dict):
    ...

Alternativa simple sin dependencias externas:

from collections import defaultdict
from time import time

class SimpleRateLimiter:
    def __init__(self, requests_per_minute=60):
        self.rpm = requests_per_minute
        self.requests = defaultdict(list)

    def is_allowed(self, client_id: str) -> bool:
        now = time()
        self.requests[client_id] = [t for t in self.requests[client_id] if now - t < 60]
        if len(self.requests[client_id]) >= self.rpm:
            return False
        self.requests[client_id].append(now)
        return True

Validación de Entradas

from pydantic import BaseModel, Field, validator

class AskPayload(BaseModel):
    question: str = Field(..., min_length=1, max_length=500)

    @validator("question")
    def sanitize(cls, v):
        v = v.strip()
        if not v:
            raise ValueError("question cannot be empty")
        # Opcional: rechazar patrones peligrosos
        if "<?php" in v.lower() or "<script" in v.lower():
            raise ValueError("Invalid characters in question")
        return v

No Exponer Secretos en Logs

# Mal
logger.info(f"Calling OpenAI with key {api_key[:8]}...")  # Evitar

# Bien
logger.info("Calling OpenAI", extra={"key_prefix": api_key[:4] + "***" if api_key else "not_set"})

Formato Uniforme de Errores

# app/exceptions.py
from fastapi import Request, status
from fastapi.responses import JSONResponse

def error_response(status_code: int, detail: str, trace_id: str = None):
    return JSONResponse(
        status_code=status_code,
        content={
            "error": True,
            "detail": detail,
            "trace_id": trace_id,
        },
    )

@app.exception_handler(422)
async def validation_exception_handler(request: Request, exc):
    return error_response(422, "Invalid input", getattr(request.state, "trace_id", None))

@app.exception_handler(500)
async def server_exception_handler(request: Request, exc):
    logger.exception("Unhandled error")
    return error_response(500, "Internal server error", getattr(request.state, "trace_id", None))

Documentación

README con Arquitectura

# RAG API con ChromaDB

Sistema RAG production-ready: ingestion, retrieval, generation y API REST.

## Arquitectura

\`\`\`
Documentos → Ingestion → ChromaDB → Retrieval → Generation → API
                ↑              ↑           ↑
            chunking      vector store    LLM (OpenAI)
\`\`\`

## Requisitos

- Python 3.11+
- Docker y Docker Compose
- OpenAI API Key

## Uso Local

\`\`\`bash
cp .env.example .env
docker-compose up -d
curl -X POST http://localhost:8000/ask -H "Content-Type: application/json" -d '{"question":"¿Qué es RAG?"}'
\`\`\`

## API Reference

| Endpoint | Método | Descripción |
|----------|--------|-------------|
| /health | GET | Health check |
| /search | GET | Búsqueda semántica (q, top_k) |
| /ask | POST | Pregunta RAG (question) |
| /ingest | POST | Ingestion de documentos |

## Deployment

Ver [DEPLOYMENT.md](./DEPLOYMENT.md).

API Reference Automática

FastAPI genera Swagger en /docs y ReDoc en /redoc. Asegúrate de documentar parámetros:

@app.post("/ask", summary="Pregunta RAG")
async def ask(
    payload: AskPayload,
    request: Request,
    api_key: str = Depends(verify_api_key)
):
    """
    Recibe una pregunta, recupera contexto de ChromaDB y genera respuesta con fuentes.
    Requiere X-API-Key en header si está configurado.
    """
    ...

Checklist de Hardening

  • No se exponen secretos en logs
  • Hay fallbacks cuando ChromaDB o OpenAI fallan
  • Errores 4xx/5xx tienen formato uniforme
  • Retry logic en llamadas a ChromaDB y OpenAI
  • Rate limiting en endpoints públicos
  • API key opcional para protección
  • Validación de inputs con Pydantic
  • Documentación de endpoints actualizada
  • README con arquitectura, setup y troubleshooting
  • Plan de rollback documentado
  • Caching de embeddings y resultados (opcional pero recomendado)
  • Métricas de cache hit rate

Priorización de Hardening

OrdenÁreaEsfuerzoImpacto
1Confiabilidad: retries, fallbacksMedioAlto
2Seguridad: auth, rate limit, validaciónMedioAlto
3Errores: formato uniforme, no stack tracesBajoMedio
4CachingMedioAlto (performance)
5DocumentaciónMedioMedio (mantenibilidad)
6Plan de rollbackBajoAlto (operación)

Ejercicios con Soluciones Detalladas

Ejercicio 1: Retry con backoff exponencial para OpenAI

Objetivo: Implementar retry solo para errores 429 y 5xx de OpenAI.

Solución:

@retry(
    stop=stop_after_attempt(3),
    wait=wait_exponential(multiplier=1, min=2, max=60),
    retry=retry_if_exception(lambda e: "429" in str(e) or "5" in str(e)[:1])
)
async def call_openai_completion(messages):
    # ...

Ejercicio 2: Fallback a respuesta corta cuando LLM timeout

Objetivo: Si OpenAI tarda más de 30s, devolver "Consulta muy compleja, intenta reformular".

Solución:

try:
    answer = await asyncio.wait_for(generate_answer(question, docs), timeout=30.0)
except asyncio.TimeoutError:
    answer = "Consulta muy compleja. Intenta reformular o simplificar tu pregunta."

Ejercicio 3: Rate limit por API key en lugar de IP

Objetivo: Limitar 100 req/min por API key para multi-tenant.

Solución:

def get_client_id(request: Request) -> str:
    return request.headers.get("X-API-Key", get_remote_address(request))

limiter = Limiter(key_func=get_client_id)

Ejercicio 4: Cache hit rate en endpoint /metrics

Objetivo: Exponer gauge rag_cache_hit_rate calculado como hits/(hits+misses).

Solución:

from prometheus_client import Gauge
hit_rate = Gauge("rag_cache_hit_rate", "Cache hit rate", ["cache_type"])

# Actualizar en cada hit/miss
def update_hit_rate(cache_type):
    h, m = cache_hits.labels(cache_type=cache_type)._value.get(), cache_misses.labels(cache_type=cache_type)._value.get()
    hit_rate.labels(cache_type=cache_type).set(h / (h + m) if (h + m) > 0 else 0)

Ejercicio 5: README con diagrama Mermaid

Objetivo: Incluir diagrama de flujo en README.

Solución:

## Arquitectura

\`\`\`mermaid
flowchart LR
    A[Documentos] --> B[Chunking]
    B --> C[Embeddings]
    C --> D[ChromaDB]
    D --> E[Retrieval]
    E --> F[Generation]
    F --> G[API Response]
\`\`\`

Ejercicio 6: Plan de rollback en 3 pasos

Objetivo: Documentar rollback en DEPLOYMENT.md.

Solución:

## Rollback

1. Revertir a imagen anterior: `docker pull rag-api:v1.2.2 && docker-compose up -d rag-api`
2. Si hay migración de ChromaDB: restaurar backup de volumen
3. Verificar health y métricas antes de cerrar incidente

Ejercicio de Hardening en 60 Minutos

Reparte el tiempo así:

TiempoFoco
20 minErrores: retries, fallbacks, formato uniforme
20 minSeguridad: API key, rate limit, validación
20 minDocs: README, runbook, rollback

Entrega: Lista de hallazgos y acciones con prioridad (crítico / importante / nice-to-have).


Troubleshooting Final

"Todo parece bien, pero no confiamos en release"

Haz smoke tests + canary interno antes de entregar. Despliega en staging, corre los integration tests, simula fallo de ChromaDB y verifica que los fallbacks funcionen.

"Tenemos hardening técnico pero docs pobres"

Sin runbook ni README operativo, el proyecto no está listo. Cualquier developer debe poder levantar el sistema y entender qué hace cada endpoint. Invierte 1-2 horas en documentación.

"Rollback no está probado"

Define y valida rollback mínimo en staging: qué comando ejecutar, cómo restaurar datos si hace falta, cómo verificar que todo está bien.

"Cache está causando respuestas obsoletas"

Ajusta TTL. Para embedding cache 24h suele estar bien; para result cache 1h o menos si el corpus se actualiza con frecuencia. Añade forma de invalidar cache por patrón o flush total.

"Rate limit está bloqueando usuarios legítimos"

Aumenta límites o implementa límites por tenant. Monitorea metricas de rechazos y ajusta.


Resumen

  • Implementaste manejo de errores robusto con retries, fallbacks y degradación controlada.
  • Añadiste caching de embeddings y resultados con métricas de hit rate.
  • Aplicaste seguridad básica: API key, rate limiting, validación de inputs.
  • Estandarizaste errores en formato uniforme sin exponer stack traces.
  • Documentaste con README (arquitectura, setup, API reference) y guía de deployment.
  • Definiste plan de rollback para operación.

El sistema queda preparado para entrega técnica seria. La siguiente cápsula consolida todo como proyecto final.


Recursos Adicionales


Tiempo estimado: 55-65 minutos
Siguiente: 08-proyecto-rag-production-ready.md