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:
- Recall bajo: el chunk que responde la pregunta usa otras palabras (sinónimos, paráfrasis) y queda fuera del top-k, o el corpus tiene la respuesta partida en dos chunks y ninguno solo alcanza el umbral.
- Precision baja: chunks temáticamente parecidos pero que no responden la pregunta específica entran en el top-k y compiten por espacio de contexto con los que sí sirven.
- Términos exactos (IDs, códigos de error, nombres propios, números de versión) son el peor caso: el embedding los generaliza y pierde la coincidencia literal que un buscador léxico atraparía sin esfuerzo.
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:
- Front: ¿por qué un embedding “bueno” puede fallar en encontrar el chunk correcto?
- Back: porque mide similitud semántica general, no relevancia respecto a una pregunta puntual; términos exactos (IDs, nombres, números) se pierden en la generalización del embedding, y la respuesta puede estar fraseada muy distinto a la pregunta.
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
- El retrieval básico alcanza en corpus chicos, homogéneos, con queries genéricas donde no hay términos exactos críticos.
- No optimices reranking/hybrid antes de tener un baseline medido — sin número de referencia no sabés si mejoraste algo.
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:
- Front: ¿por qué no usar cross-encoder para buscar en todo el corpus directamente?
- Back: porque no es indexable (necesita la query en el momento de scorear cada doc); correrlo contra todo el corpus es carísimo. Se usa como segunda pasada sobre un top-N ya reducido por vector search.
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
- Latencia y costo extra por query (una llamada/inferencia adicional por candidato).
- No reemplaza un mal retrieval inicial: si el chunk correcto no entró en el top-50, el reranker nunca lo va a ver.
- No rerankees miles de candidatos — el costo crece linealmente con N.
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:
- Front: ¿qué gana hybrid search que vector-only no tiene?
- Back: coincidencia exacta de términos (IDs, códigos, nombres propios) vía BM25, que el embedding tiende a diluir; RRF fusiona sin necesitar normalizar scores de escalas distintas (coseno vs BM25 no son comparables directamente).
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
- Más infra: mantener un índice de texto completo además del índice vectorial.
- En corpus muy chicos o sin vocabulario técnico exacto (todo lenguaje natural genérico), el aporte de BM25 es marginal — medí antes de justificar la complejidad.
- RRF es un método entre varios; hay alternativas (weighted sum con normalización) que pueden rendir distinto según el corpus [verificar caso a caso].
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:
- Fixed-size chunking: cortar por tamaño fijo con un overlap. Ojo con la unidad:
RecursiveCharacterTextSplitter(chunk_size=500)mide en caracteres por defecto (500 chars ≈ 125 tokens); para medir en tokens usáRecursiveCharacterTextSplitter.from_tiktoken_encoder(chunk_size=500). Simple, rápido, barato — pero puede cortar una idea a la mitad si el corte cae en medio de una oración. - Semantic/structural chunking: cortar respetando límites de significado — párrafos, secciones, headers de markdown, o usando similitud de embeddings entre oraciones consecutivas para detectar dónde cambia el tema. Chunks más coherentes, pero más caro de computar (si es por embeddings) o depende de que el doc tenga estructura clara (si es por headers).
- Overlap: repetir los últimos N caracteres/tokens del chunk anterior al principio del siguiente, para que una idea que cruza el borde no quede partida sin contexto en ninguno de los dos lados.
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:
- Front: ¿para qué sirve el overlap entre chunks?
- Back: evita que una idea que cruza el punto de corte quede sin contexto en ninguno de los dos chunks resultantes; repite los últimos tokens del chunk anterior al inicio del siguiente.
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
- Semantic chunking por embeddings cuesta más (llamadas extra para decidir cortes); si el doc ya tiene estructura clara (markdown, HTML con headers), chunking estructural por headers da resultados comparables mucho más barato.
- Chunks demasiado chicos pierden contexto; demasiado grandes diluyen la relevancia del retrieval y desperdician ventana de contexto — no hay tamaño universal, se calibra por corpus.
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:
- Front: ¿por qué filtrar por metadata en vez de confiar solo en el ranking semántico?
- Back: porque dos chunks pueden ser semánticamente parecidos pero pertenecer a contextos distintos (secciones, fechas, fuentes) que el embedding no distingue; el filtro estructural elimina esos falsos positivos antes de rankear.
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
- Requiere que la metadata exista y sea confiable desde la ingesta — si no se capturó bien, no hay filtro que la invente después.
- Over-filtering (filtros muy estrictos) puede dejar el candidate set vacío y el recall en cero; el filtro reduce ruido, no reemplaza un buen retrieval.
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:
- Por qué falla el retrieval básico (recall vs precision)
- Reranking con cross-encoder (bi-encoder + cross-encoder, cuándo cada uno)
- Hybrid search (BM25 + vector, RRF)
- Estrategias de chunking (fijo vs semántico, overlap)
- Metadata filtering
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.