← Volver
IA aplicada 16 min de lectura

RAG sin humo: entendé el mecanismo y vas a predecir los fallos

Un embedding colapsa un texto en un punto y tira todo lo demás. De esa sola frase se deduce, antes de tener un usuario, la lista exacta de queries en las que tu sistema va a fallar. Acá está el mecanismo, el lab que lo mide en treinta segundos y el punto donde te conviene un buscador común.

Sala de catálogos de la Biblioteca Mazarina, en París: ficheros de madera con miles de fichas indexadas a mano.
Marie-Lan Nguyen vía Wikimedia Commons — CC BY 2.0 FR

La mayoría de los sistemas RAG que vi fallar no fallaron por falta de herramientas. Fallaron porque los armaron copiando un notebook, sin entender qué hace un embedding.

El mecanismo es simple. Y si lo entendés, podés escribir de antemano la lista de queries en las que tu sistema va a fallar — antes de tener un solo usuario.

Cómo funciona la máquina

Un modelo de embeddings es una función. Toma un texto de largo variable y devuelve un vector de largo fijo: entre 384 y 3072 números, según el modelo. Cuatrocientos tokens de documentación entran, mil veinticuatro floats salen.

Eso es compresión con pérdida. Y la pérdida no es aleatoria: el modelo está entrenado para conservar una sola propiedad — que dos textos con significado parecido queden cerca según coseno. Todo lo demás lo tira. La sintaxis, el orden, la negación, la diferencia entre dos códigos que difieren en un dígito.

Después viene el índice. Un HNSW no te devuelve los k vecinos exactos: te devuelve k vecinos aproximados, con un recall que vos configurás y que casi nadie mide.

El retriever no busca la respuesta a tu pregunta. Busca los textos cuyo resumen numérico se parece al resumen numérico de tu pregunta. No es lo mismo, y toda la ingeniería de RAG vive en esa diferencia.

Qué se sigue del mecanismo

Cuatro consecuencias. Cada una es un modo de falla que podés anticipar sin correr nada.

El chunk es la unidad atómica de recuperación. No podés recuperar media idea. Si el encabezado de la tabla quedó en el chunk 7 y la fila con el dato en el chunk 9, no existe un top-k que traiga la respuesta completa. Chunking no es "partir cada 512 tokens": es decidir qué es una unidad de sentido, y después arreglar el contexto que perdiste al partir. Anthropic midió el arreglo: prependear 50 a 100 tokens de contexto generado a cada chunk bajó la tasa de fallo de recuperación top-20 de 5,7% a 3,7% — un 35% menos.

Similitud semántica no es exact match. E-4032 y E-4023 comparten tokens, comparten contexto y aparecen en oraciones casi idénticas. En el espacio de embeddings están pegados. BM25 los distingue sin esfuerzo, porque cuenta términos en vez de interpretarlos. Esto no es una anécdota: es el hallazgo central de BEIR (Thakur et al., 2021) — BM25 es un baseline durísimo y los modelos densos lo pierden en varios datasets apenas te salís del dominio en el que entrenaron.

El ranking del retriever es barato y malo, por construcción. Un bi-encoder codifica la query y el documento por separado; nunca se ven. Un cross-encoder los mete juntos en el mismo forward pass y hay atención cruzada entre la pregunta y el texto. Ranquea muchísimo mejor y cuesta un forward pass por candidato. De ahí sale la arquitectura entera: recuperás 50 con lo barato, reordenás 50 con lo caro, mandás 5 al modelo.

La posición en el prompt importa. Liu et al. midieron una curva en U en "Lost in the Middle" (arXiv:2307.03172, TACL 2024): con GPT-3.5-Turbo y 20 documentos en contexto, la accuracy cae del orden de 20 puntos cuando el documento relevante queda en el medio en vez de al principio. Con 30 documentos la caída es mayor, y la magnitud exacta depende del modelo — el patrón se repite, el número no. Si armás el prompt ordenando por score descendente y ponés el mejor primero, estás jugando a favor del mecanismo. Si lo ordenás por fecha, no.

El lab

Corpus mínimo, en español, con las trampas adentro. Instalá rank-bm25, sentence-transformers y numpy.

# retrieval.py
import re
import numpy as np
from rank_bm25 import BM25Okapi
from sentence_transformers import SentenceTransformer, CrossEncoder

DOCS = [
    "Devoluciones: el comercio puede solicitar la devolución total o parcial de un pago acreditado dentro de los 180 días corridos.",
    "Códigos de rechazo: E-4032 significa que la tarjeta no admite pagos en cuotas. E-4023 significa fondos insuficientes.",
    "Conciliación: el archivo se publica todos los días a las 06:00 ART e incluye los movimientos del día anterior.",
    "Webhooks: reintentamos hasta 5 veces con backoff exponencial. Un 2xx del endpoint cierra el reintento.",
    "Límites: el endpoint de creación de pagos acepta 100 requests por minuto por API key.",
    "Devoluciones parciales: se pueden encadenar hasta agotar el monto original del pago.",
]

def tok(s):
    return re.findall(r"\w+", s.lower())

bm25 = BM25Okapi([tok(d) for d in DOCS])
encoder = SentenceTransformer("intfloat/multilingual-e5-small")
M = encoder.encode([f"passage: {d}" for d in DOCS], normalize_embeddings=True)
reranker = CrossEncoder("BAAI/bge-reranker-v2-m3")  # multilingüe a propósito

def dense(q, k=5):
    v = encoder.encode(f"query: {q}", normalize_embeddings=True)
    return np.argsort(-(M @ v))[:k].tolist()

def sparse(q, k=5):
    return np.argsort(-bm25.get_scores(tok(q)))[:k].tolist()

def rrf(*listas, k=60):
    # Reciprocal Rank Fusion, Cormack et al. SIGIR 2009. k=60 es la constante del paper.
    acc = {}
    for lista in listas:
        for pos, doc in enumerate(lista, start=1):
            acc[doc] = acc.get(doc, 0.0) + 1.0 / (k + pos)
    return sorted(acc, key=acc.get, reverse=True)

def hibrido(q, k=5):
    return rrf(dense(q, 20), sparse(q, 20))[:k]

def con_rerank(q, k=3, pool=20):
    cands = rrf(dense(q, pool), sparse(q, pool))[:pool]
    scores = reranker.predict([(q, DOCS[i]) for i in cands])
    return [i for _, i in sorted(zip(scores, cands), key=lambda p: -p[0])][:k]

Y el eval, que es la parte que casi nadie escribe. Mide dos cosas, no una: recall y milisegundos. Sin el segundo número, "reranqueá agresivo" es una recomendación sin el otro lado de la balanza.

# eval.py
import statistics
import time
from retrieval import dense, sparse, hibrido, con_rerank

GOLD = [
    ("¿qué quiere decir el error E-4032?", {1}),
    ("¿hasta cuándo puedo devolver un pago?", {0, 5}),
    ("¿a qué hora sale la conciliación?", {2}),
    ("¿cuántos reintentos hace el webhook?", {3}),
    ("cuántos requests por minuto aguanta crear pagos", {4}),
]

def recall_at_k(fn, k):
    return sum(bool(gold & set(fn(q, k))) for q, gold in GOLD) / len(GOLD)

def ms_por_query(fn, k, repeticiones=5):
    fn(GOLD[0][0], k)  # warm-up: la primera llamada carga pesos y no cuenta
    tiempos = []
    for q, _ in GOLD:
        for _ in range(repeticiones):
            t0 = time.perf_counter()
            fn(q, k)
            tiempos.append((time.perf_counter() - t0) * 1000)
    return statistics.median(tiempos)

for nombre, fn in [("denso", dense), ("bm25", sparse),
                   ("híbrido", hibrido), ("híbrido+rerank", con_rerank)]:
    print(f"{nombre:16} recall@3={recall_at_k(fn, 3):.2f}  "
          f"mediana={ms_por_query(fn, 3):7.1f} ms")

Cambiá E-4032 por E-4023 en la query y mirá qué hace cada retriever. Ese experimento de treinta segundos vale más que tres días de tuning de prompt.

Dónde rompe

El reranker en inglés. El cross-encoder que aparece en todos los tutoriales es ms-marco-MiniLM-L-6-v2, entrenado sobre MS MARCO, que es un corpus en inglés. Sobre documentación en español no te reordena: te ensucia el top-3 que el híbrido ya había traído bien. Es un caso donde agregar una etapa "que mejora todo" empeora el sistema, y no lo vas a ver nunca si no medís. Por eso en el lab uso bge-reranker-v2-m3.

El índice miente. Este es el más caro de todos y casi nadie lo tiene en la lista. Actualizás un documento, el pipeline reindexa e inserta los chunks nuevos — y los viejos siguen vivos en el índice porque nadie los borró. A partir de ahí el sistema responde con confianza total una política derogada. Ningún eval de calidad de respuesta lo detecta, porque la respuesta es coherente, está bien escrita, cita su fuente y está mal. La mitigación es aburrida y funciona: guardá un doc_id y un hash del origen en cada chunk, y antes de insertar borrá por doc_id. Un DELETE ... WHERE doc_id = ? previo a cada upsert compra más confiabilidad que cualquier reranker.

Negación y temporalidad. "¿Qué NO cubre la póliza?" recupera el chunk que dice qué cubre, porque en el espacio de embeddings están casi encima. "¿Cuál es la versión actual del endpoint?" no tiene ninguna señal semántica de recencia: el modelo no sabe qué documento es más nuevo. Eso se arregla con metadata y filtros en el índice, no con más embeddings.

Recall alto, precisión baja. Traer 20 chunks para no perder la respuesta significa meterle al modelo 18 chunks irrelevantes. Eso no es gratis: es exactamente el escenario de "Lost in the Middle", y además le da al modelo material para alucinar con cara de citado.

El experimento que lo hace obvio es reproducible en una tarde: subí el tamaño de chunk y medí las dos puntas. El recall@5 del retriever sube —chunks más grandes contienen más respuestas, es aritmética— y la accuracy de las respuestas baja, porque cada chunk trae más texto irrelevante pegado al fragmento que servía. Recall del retriever y accuracy del sistema se mueven en direcciones opuestas, y si medís una sola vas a optimizar hacia el lugar equivocado con evidencia a favor. Medí las dos, siempre, en el mismo barrido.

La cuenta

Los números públicos, para que la discusión de costos deje de ser religiosa. Precios de lista a agosto de 2026: Voyage cobra USD 0,06 por millón de tokens en voyage-3.5 y USD 0,05 por millón en rerank-2.5. Claude Opus 5 cobra USD 5 por millón de tokens de input. Revalidá antes de citarlos: los precios de inferencia se mueven varias veces por año.

Corpus de 50.000 chunks de 400 tokens: 20 millones de tokens. Indexarlo entero cuesta USD 1,20. Reindexarlo entero cuesta otro USD 1,20. Los embeddings son gratis en términos prácticos — dejá de optimizar ahí.

Ahora el query path. Reranquear 50 candidatos de 400 tokens son 20.000 tokens por query: USD 0,001. Con 10.000 queries por día, USD 10 diarios. Y el prompt final con 8 chunks son unos 3.500 tokens de input: USD 0,0175 por query, USD 175 por día. El generador cuesta diecisiete veces el reranker.

De ahí sale la decisión que importa: es más barato reranquear agresivo y mandar 5 chunks que ahorrarte el reranker y mandar 20. El reranking no es un lujo de calidad, es una optimización de costo. Y agregá esto: los chunks recuperados cambian en cada request, así que esa parte del prompt nunca cachea. El prompt caching te salva el system prompt, no el contexto.

Los cuatro ejes de la decisión

Antes de armar nada, la pregunta no es qué vector store elegís. Son cuatro ejes, y tres de ellos te pueden sacar del problema entero.

Tamaño y cardinalidad del corpus. Menos de unos 100 documentos estables no van en un índice: van en el prompt. Un modelo con contexto de un millón de tokens se traga tu manual de políticas completo, sin retriever, sin chunking, sin eval de recuperación y sin una superficie de falla que no controlás. RAG empieza a pagar cuando el corpus no entra o cuando meterlo entero en cada request es más caro que buscarlo.

Volatilidad. Un corpus que cambia cada seis meses y uno que cambia cada hora son dos sistemas distintos. El segundo necesita el borrado por doc_id, un reindexado incremental y una alarma cuando el lag de indexación crece. Si no vas a construir eso, no armes RAG sobre un corpus vivo: vas a servir respuestas viejas con cara de nuevas.

Tolerancia al error. Una respuesta rara en un chat interno se corrige con un mensaje. Una respuesta rara sobre una condición contractual tiene un costo que no se corrige con un mensaje. Ese eje define cuánta evidencia comprás antes de lanzar, y define si el sistema puede responder solo o tiene que mostrar el documento y dejar decidir a una persona.

Brecha de vocabulario. Es el eje que decide si necesitás la parte densa. Si tus usuarios escriben con las mismas palabras que están en los documentos —términos técnicos, códigos, nombres propios—, BM25 solo te alcanza y los embeddings agregan latencia y una fuente nueva de errores. Si preguntan "¿me pueden devolver la plata?" y el documento dice "reversión de acreditación", ahí la parte densa gana algo real. Medilo, no lo asumas: es exactamente lo que compara el eval de arriba.

Cuándo un buscador alcanza

No necesitás RAG. Necesitás que la pregunta lo justifique.

Si tus usuarios saben lo que buscan y buscan por término — un SKU, un número de expediente, un nombre de cliente — un tsvector en Postgres con un índice GIN les da mejor resultado, en 5 milisegundos, sin costo por query y con filtros que ellos controlan. Poner un LLM en el medio ahí es agregar latencia, costo y una superficie de alucinación para resolver algo que ya estaba resuelto.

RAG paga cuando la respuesta no está en ningún documento, sino que hay que sintetizarla a partir de tres. Ese es el criterio. Si podés señalar un documento y decir "la respuesta es esta", andá a buscar ese documento y mostralo.

Lo que yo haría

Primero el eval: 30 a 50 preguntas reales con los chunk_id correctos anotados a mano. Sin eso no tenés un sistema, tenés una demo.

Después, en este orden, y cada paso con su condición de corte escrita antes de correrlo:

  • BM25 solo, como piso. Si te da 0,90 de recall@5 en tu golden set, terminá ahí. No metas embeddings, no montes un vector store, no agregues una dependencia con pesos de 500 MB para ganar dos puntos que tu generador no va a aprovechar.
  • Híbrido con RRF. Son diez líneas de código. Corte: si no le gana a BM25 por más que el ruido del golden set —con 40 preguntas, dos o tres puntos son ruido—, quedate con BM25 y ahorrate la mitad de la latencia.
  • Reranking, en el idioma que corresponde. Corte: mirá las dos columnas del eval juntas. Si sube el recall pero te agrega 300 ms por query y tu producto es un chat sincrónico, la decisión es de producto, no de retrieval.
  • Chunking contextual. Último, porque es el más caro de operar: te obliga a una pasada de LLM sobre el corpus entero y a rehacerla en cada reindexado. Corte: sólo si el análisis de errores muestra que las fallas son de contexto perdido al partir, no de vocabulario ni de ranking.

Cada paso te dice si el siguiente vale la pena, y el mecanismo te dice de antemano qué esperar de cada uno.

Para seguir

Lecturas

Videos

Siguiente · DevOps · 6 min No necesitás Gitflow. Necesitás integración continua de verdad. Leer siguiente →