# Modelo de madurez RAG — Crawl → Walk → Run

Guía progresiva para implementar sistemas RAG con niveles de sofisticación
creciente. Cada nivel resuelve las limitaciones del anterior.

---

## Nivel 1: CRAWL — Funcional mínimo

**Objetivo**: Demostrar que RAG funciona con tu corpus. Priorizar velocidad
de prototipo sobre calidad de respuestas.

### Stack típico

| Componente | Tecnología | Notas |
|-----------|-----------|-------|
| Document loading | PyPDFLoader, TextLoader | Extracción básica sin awareness de estructura |
| Chunking | RecursiveCharacterTextSplitter | chunk_size=1000, overlap=200 |
| Embeddings | OpenAI text-embedding-3-small, Gemini | Modelo general, dimensiones default |
| Vectorstore | ChromaDB (local) | Persistencia en disco, sin schema formal |
| Retrieval | Semantic search top-k | k=5, sin reranking ni filtrado |

### Implementación

```python
from langchain_community.document_loaders import PyPDFLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_community.vectorstores import Chroma

loader = PyPDFLoader("documento.pdf")
documentos = loader.load()

splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)
chunks = splitter.split_documents(documentos)

vectorstore = Chroma.from_documents(
    documents=chunks,
    embedding=embeddings,
    persist_directory="./chroma_db",
)

# Búsqueda simple
resultados = vectorstore.similarity_search("¿Cuál es la política?", k=5)
```

### Limitaciones que motivan avanzar a WALK
- Chunks cortan oraciones y pierden contexto de headings
- Sin reranking: el resultado #4 puede ser más relevante que el #1
- Sin filtrado por score: devuelve basura si no hay match real
- Metadata limitada: no sabes de qué sección viene cada chunk

---

## Nivel 2: WALK — Calidad optimizada

**Objetivo**: Mejorar precisión de retrieval y calidad de chunks sin
cambiar de infraestructura (sigue siendo local).

### Mejoras sobre CRAWL

| Aspecto | CRAWL | WALK |
|---------|-------|------|
| Document processing | PyPDFLoader (texto plano) | Docling (preserva estructura, headings, tablas) |
| Chunking | Por caracteres | HybridChunker (awareness de estructura) |
| Embeddings | General | Especializados con input_type diferenciado |
| Vectorstore | ChromaDB dinámico | LanceDB con schema Pydantic |
| Retrieval | top-k directo | top-k + reranking + threshold adaptativo |
| Metadata | Solo source | source + heading + page_no |

### Reranking con threshold adaptativo

El reranking reordena los resultados de semantic search usando un modelo
especializado. El threshold adaptativo evita devolver resultados irrelevantes
pero garantiza un mínimo de resultados:

```python
import cohere

co = cohere.ClientV2()

def buscar_con_reranking(
    query: str,
    candidatos: list[dict],
    threshold: float = 0.9,
    min_resultados: int = 3,
) -> list[dict]:
    """Semantic search + reranking con threshold adaptativo."""
    # 1. Reranking de candidatos
    reranked = co.rerank(
        query=query,
        documents=[c["text"] for c in candidatos],
        model="rerank-v3.5",
        top_n=10,
    )

    # 2. Filtrar por threshold
    filtrados = [
        {**candidatos[r.index], "relevance_score": r.relevance_score}
        for r in reranked.results
        if r.relevance_score >= threshold
    ]

    # 3. Threshold adaptativo: bajar si hay pocos resultados
    while len(filtrados) < min_resultados and threshold > 0.3:
        threshold -= 0.05
        filtrados = [
            {**candidatos[r.index], "relevance_score": r.relevance_score}
            for r in reranked.results
            if r.relevance_score >= threshold
        ]

    return filtrados
```

### Schema Pydantic para vectorstore

```python
from lancedb.pydantic import LanceModel, Vector

class DocumentChunk(LanceModel):
    vector: Vector(384)      # Dimensión del embedding
    text: str                # Contenido del chunk
    source: str              # Archivo origen
    heading: str = ""        # Heading más cercano
    page_no: int = 0         # Página del documento
```

### Embeddings con input_type diferenciado

```python
# Al indexar: input_type="search_document"
embeddings_doc = co.embed(
    texts=textos_chunks,
    model="embed-english-light-v3.0",
    input_type="search_document",
    output_dimension=384,
)

# Al buscar: input_type="search_query"
embedding_query = co.embed(
    texts=[query],
    model="embed-english-light-v3.0",
    input_type="search_query",
    output_dimension=384,
)
```

### Limitaciones que motivan avanzar a RUN
- Vectorstore local no escala a millones de documentos
- Sin guardrails de seguridad (prompt injection, PII)
- Sin observabilidad de producción (latency, error rate)
- Mantenimiento manual del índice

---

## Nivel 3: RUN — Producción

**Objetivo**: Escalar, asegurar y operar el sistema RAG en producción
con SLOs definidos.

### Mejoras sobre WALK

| Aspecto | WALK | RUN |
|---------|------|-----|
| Vectorstore | Local (LanceDB) | Managed (Vertex AI, Pinecone, Weaviate Cloud) |
| Guardrails | Ninguno | Input sanitization + output validation |
| Observabilidad | Logs locales | OpenTelemetry + dashboards |
| Document processing | Local | Pipeline async con cola de procesamiento |
| Scaling | Máquina única | Horizontal (embedding service separado) |

### Guardrails de seguridad (pre-LLM)

```python
def guardrail_pre_llm(query: str) -> tuple[bool, str]:
    """Filtrar queries peligrosos antes de llegar al LLM."""
    # Categorías de filtrado (taxonomía Model Armor):
    # - RAI: Contenido dañino, bias, desinformación
    # - PI: Prompt injection, jailbreak
    # - SDP: Exposición de datos sensibles (PII)
    # - URI: URLs maliciosas en el input

    import re
    patrones_pi = [
        r"ignore\s+.*instructions",
        r"you\s+are\s+now",
        r"system\s*:\s*you",
    ]
    for patron in patrones_pi:
        if re.search(patron, query, re.IGNORECASE):
            return False, "Query bloqueado: posible prompt injection"

    return True, "OK"
```

### Observabilidad mínima para RAG en producción

| Métrica | Qué mide | Alerta si |
|---------|----------|-----------|
| retrieval_latency_p95 | Tiempo de búsqueda vectorial | > 500ms |
| rerank_latency_p95 | Tiempo de reranking | > 200ms |
| generation_latency_p95 | Tiempo de generación LLM | > 3s |
| relevance_score_avg | Calidad promedio de resultados | < 0.5 |
| empty_results_rate | % de queries sin resultados | > 20% |
| error_rate | Fallos de API/vectorstore | > 1% |

---

## Criterios para avanzar de nivel

| De → A | Avanzar cuando | NO avanzar si |
|--------|---------------|---------------|
| CRAWL → WALK | La precisión del retrieval es insuficiente; queries relevantes devuelven resultados irrelevantes | Estás en fase de prototipo y la precisión importa menos que la velocidad |
| WALK → RUN | Necesitas escala (>100K docs), seguridad (datos sensibles), o SLOs formales | El sistema es interno, bajo tráfico, y la calidad de WALK es suficiente |

---

## Evaluación de calidad RAG por nivel

| Nivel | Evaluación mínima |
|-------|------------------|
| CRAWL | Manual: 10 queries conocidas, verificar si los chunks devueltos son relevantes |
| WALK | Semi-auto: relevance_score promedio > 0.7, queries fuera de dominio filtradas |
| RUN | RAGAS automatizado: faithfulness, context_relevancy, answer_correctness con dataset de evaluación |

Para métricas RAGAS detalladas, ver [evaluacion-rag.md](evaluacion-rag.md).
