ruta ai/agentes / módulo 2

A2 — Retrieval de calidad

Outcome del módulo: diagnosticar por qué un retrieval falla (recall vs precision), y arreglarlo con reranking, hybrid search, chunking bien elegido y metadata filtering — con métricas que prueban la mejora, no intuición.


Concepto 1 — Por qué el retrieval básico falla (recall/precision)

Idea núcleo: similarity search top-k por embeddings mide “parecido semánticamente”, no “relevante para responder la pregunta” — falla en recall (no trae lo que hace falta) y en precision (trae ruido).

Entender (capa 1)

Texto: un bi-encoder codifica la pregunta y cada chunk por separado en vectores y ordena por distancia (coseno). Eso captura similitud temática general, pero dos problemas concretos:

Visual:

flowchart LR
    Q[Pregunta] --> E[Embedding de la pregunta]
    E --> S["Similarity search coseno top-k"]
    S --> R1["Recall bajo: doc relevante no aparece en el top-k"]
    S --> R2["Precision baja: aparecen docs parecidos pero irrelevantes"]

Video: RAG Retrieval Evaluation Metrics: Recall@K, Precision@K, MRR, MAP, NDCG — Praveen Reddy Learnings

Ejemplo:

def recall_at_k(ground_truth_ids: set[str], retrieved_ids: list[str], k: int) -> float:
    top_k = set(retrieved_ids[:k])
    if not ground_truth_ids:
        return 0.0
    return len(ground_truth_ids & top_k) / len(ground_truth_ids)

# retrieval solo-vector, sin ningún ajuste
retrieved = vector_search(query, top_k=5)
print(recall_at_k(ground_truth_ids={"doc_42"}, retrieved_ids=retrieved, k=5))
# baseline típico en corpus técnico con IDs/códigos: bajo, aun con embeddings buenos

Fijar (capa 2)

Nota atómica:

Feynman: “El embedding es un bibliotecario que agrupa libros por ‘de qué tratan’. Si le pedís el libro con el ISBN exacto, te trae uno parecido en tema, no el ISBN. Para eso necesitás otro tipo de búsqueda.”

Aplicar (capa 3)

Con un query set de 10 preguntas + ground truth (doc_id correcto por pregunta) sobre el corpus de A1, calculá recall@5 solo-vector. Anotá el número: es tu baseline a superar con los siguientes conceptos.

Límites


Concepto 2 — Reranking (cross-encoder)

Idea núcleo: el reranking reordena un conjunto de candidatos ya recuperados con un modelo que ve la pregunta y el documento juntos (cross-encoder) — más caro por candidato, mucho más preciso que el bi-encoder que hizo la búsqueda inicial.

Entender (capa 1)

Texto: un bi-encoder codifica query y doc por separado (por eso se puede indexar y buscar rápido). Un cross-encoder recibe el par (query, doc) concatenado y produce un score de relevancia directo — no se puede indexar de antemano (necesitás la query), pero la señal es mucho más fina porque el modelo atiende a ambos textos a la vez. Patrón estándar: recuperar barato con vector search un top-N amplio (ej. 50), y rerankear ese N con cross-encoder para quedarte con el top-k final (ej. 5) que va al prompt.

Visual:

flowchart LR
    Q[Query] --> BE["Bi-encoder: vector search"] --> C50["Top-50 candidatos"]
    C50 --> CE["Cross-encoder: rerank query+doc juntos"]
    CE --> C5["Top-5 final, ordenado por relevancia real"]

Video: Reranking for RAG — LLM, Cross-Encoder & Rule-Based Reranking Explained — bhupen

Ejemplo:

from sentence_transformers import CrossEncoder

reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")  # [verificar] modelo vigente

def rerank(query: str, candidates: list[dict], top_k: int = 5) -> list[dict]:
    pairs = [(query, c["text"]) for c in candidates]
    scores = reranker.predict(pairs)
    for c, s in zip(candidates, scores):
        c["rerank_score"] = float(s)
    return sorted(candidates, key=lambda c: c["rerank_score"], reverse=True)[:top_k]

candidatos = vector_search(query, top_k=50)   # barato, amplio
finales = rerank(query, candidatos, top_k=5)  # caro, preciso, sobre el subset

Fijar (capa 2)

Nota atómica:

Feynman: “El bi-encoder es un buscador rápido que te trae 50 candidatos con una mirada superficial. El cross-encoder es un experto que lee de verdad la pregunta contra cada uno de esos 50 y los reordena en serio. No le pedís al experto que lea toda la biblioteca.”

Aplicar (capa 3)

Sobre el mismo query set del Concepto 1, agregá reranking a top-50 → top-5 y recalculá recall@5. Compará contra el baseline.

Límites


Concepto 3 — Hybrid search (BM25 + vector)

Idea núcleo: combinar búsqueda léxica (BM25, exacta con keywords/IDs) con búsqueda vectorial (semántica) cubre los huecos de cada una por separado; se fusionan los rankings, típicamente con Reciprocal Rank Fusion (RRF).

Entender (capa 1)

Texto: BM25 es TF-IDF mejorado — puntúa por coincidencia de términos, exacto y explicable, cero comprensión de sinónimos o paráfrasis. Vector search es lo opuesto: fuerte en semántica, débil en coincidencias literales (SKU, código de error, nombre propio raro, número de versión). Hybrid search corre ambas búsquedas en paralelo sobre el mismo corpus y fusiona los dos rankings en uno solo, normalmente con RRF: cada doc suma 1 / (k + rank) en cada lista donde aparece, y se ordena por la suma.

Visual:

flowchart TD
    Q[Query] --> BM25["BM25: búsqueda léxica exacta"]
    Q --> VEC["Vector search: búsqueda semántica"]
    BM25 --> RRF["Reciprocal Rank Fusion"]
    VEC --> RRF
    RRF --> TOP["Ranking final combinado"]

Video: Top 3 RAG Retrieval Strategies: Sparse, Dense & Hybrid Explained — IBM Technology

Ejemplo:

def reciprocal_rank_fusion(rankings: list[list[str]], k: int = 60) -> list[str]:
    scores: dict[str, float] = {}
    for ranking in rankings:
        for rank, doc_id in enumerate(ranking):
            scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank + 1)
    return sorted(scores, key=scores.get, reverse=True)

bm25_ids = bm25_search(query, top_k=20)      # postgres tsvector, o rank_bm25 en memoria
vector_ids = vector_search(query, top_k=20)  # pgvector <=>
fusion = reciprocal_rank_fusion([bm25_ids, vector_ids])[:10]
-- Postgres: BM25-ish con tsvector, vector con pgvector, en la misma tabla
SELECT id, ts_rank(search_vector, plainto_tsquery('spanish', :query)) AS bm25_score
FROM chunks
ORDER BY bm25_score DESC
LIMIT 20;

Fijar (capa 2)

Nota atómica:

Feynman: “BM25 busca la palabra exacta como un Ctrl+F inteligente. El vector busca ‘algo que signifique parecido’. Ninguno solo alcanza siempre; hybrid corre los dos y se queda con lo mejor de cada lista.”

Aplicar (capa 3)

Implementá RRF combinando tus resultados BM25 y vector para el mismo query set, y compará recall@5 contra el baseline y contra reranking solo.

Límites


Concepto 4 — Estrategias de chunking (fijo vs semántico, overlap)

Idea núcleo: cómo cortás el corpus define el techo de tu retrieval; chunking fijo es simple y predecible, chunking semántico respeta los límites de significado, y el overlap evita perder contexto justo en el borde de cada corte.

Entender (capa 1)

Texto:

Visual:

flowchart TD
    subgraph Fixed["Chunking fijo"]
        F1["chunk 1: 500 tokens"] --> F2["chunk 2: 500 tokens, overlap 50"] --> F3["riesgo: corta ideas a la mitad"]
    end
    subgraph Semantic["Chunking semántico"]
        S1["chunk = párrafo/sección completa"] --> S2["respeta límites de significado"] --> S3["más caro de calcular"]
    end

Video: RAG Chunking Strategies Explained: Optimize Document Chunking for Recall & Precision — SystemDR

Ejemplo:

from langchain_text_splitters import RecursiveCharacterTextSplitter, MarkdownHeaderTextSplitter

# fijo, con overlap
fixed_splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=50)
chunks_fixed = fixed_splitter.split_text(documento)

# estructural: corta por headers de markdown, no por tamaño ciego
header_splitter = MarkdownHeaderTextSplitter(
    headers_to_split_on=[("#", "h1"), ("##", "h2")]
)
chunks_semantic = header_splitter.split_text(documento_markdown)
# ojo: MarkdownHeaderTextSplitter devuelve list[Document], no list[str];
# el texto está en cada .page_content (a diferencia de RecursiveCharacterTextSplitter que da strings)

Fijar (capa 2)

Nota atómica:

Feynman: “Chunking fijo es cortar una soga cada metro exacto, sin mirar los nudos. Chunking semántico es cortar respetando los nudos (párrafos, secciones). El overlap es dejar un pedacito de cada lado pegado al corte, por si el nudo quedó justo ahí.”

Aplicar (capa 3)

Re-chunkeá el corpus de A1 con las dos estrategias (fijo con overlap vs estructural por headers) y compará recall@5 de cada una sobre el mismo query set.

Límites


Concepto 5 — Metadata filtering

Idea núcleo: adjuntar metadata estructurada a cada chunk (fuente, fecha, sección, tipo) y filtrar antes o durante la búsqueda vectorial reduce el espacio de búsqueda y elimina falsos positivos que son semánticamente parecidos pero de otro contexto.

Entender (capa 1)

Texto: metadata filtering combina un filtro exacto (WHERE estructurado) con la búsqueda vectorial (ORDER BY distancia), en vez de buscar sobre todo el índice y esperar que el ranking semántico resuelva la relevancia contextual solo. Con pgvector se hace en la misma query SQL: filtro por columnas/JSON de metadata, y sobre ese subconjunto ya reducido se ordena por distancia de embedding.

Visual:

flowchart LR
    Q["Query + filtro metadata (ej section=pricing, year=2026)"] --> F["Reduce el candidate set"]
    F --> V["Vector search sobre el subset filtrado"]
    V --> R["Resultados sin ruido de otras secciones/fechas"]

Video: Metadata Filtering for Vector Search + Latest Filter Tech — James Briggs

Ejemplo:

SELECT id, text, metadata
FROM chunks
WHERE metadata->>'section' = 'pricing'
  AND (metadata->>'year')::int >= 2025
ORDER BY embedding <=> :query_embedding
LIMIT 10;
def ingest_chunk(text: str, source: str, section: str, year: int) -> None:
    embedding = embed(text)
    db.execute(
        "INSERT INTO chunks (text, embedding, metadata) VALUES (%s, %s, %s)",
        (text, embedding, {"source": source, "section": section, "year": year}),
        # dict→JSONB directo funciona en psycopg3; en psycopg2 envolver con psycopg2.extras.Json(...).
        # embedding (list[float]) requiere register_vector(conn) o pasar '[...]'::vector
    )

Fijar (capa 2)

Nota atómica:

Feynman: “Es como buscar ‘precio’ en un archivo con 5 años de documentos: si primero filtrás por ‘sección pricing, año 2026’ y recién ahí buscás semánticamente, no te va a traer un precio de hace 3 años solo porque suena parecido.”

Aplicar (capa 3)

Agregá metadata (fuente, fecha, sección) al pipeline de ingesta de A1 y escribí una query que combine filtro + vector search para una pregunta específica.

Límites


Build step

Sumá reranking (Concepto 2) y hybrid search (Concepto 3) al pipeline de retrieval de A1. Con el mismo query set y ground truth del Concepto 1, medí recall@5 en al menos tres configuraciones: baseline vector-only, vector + rerank, hybrid + rerank. Documentá los tres números — es la evidencia de que el retrieval mejoró, no una afirmación.

Checklist de dominio

Marcás cuando podés explicar (Feynman) + aplicar cada uno:

Salida verificable del módulo: script de evaluación que corre el mismo query set contra las tres configuraciones (baseline / rerank / hybrid+rerank) y reporta recall@5 de cada una en una tabla, más la capacidad de explicar en voz alta por qué cada mejora subió el número.