Módulo 6: Evaluar la calidad de la recuperación
Cuándo falla la recuperación léxica
Descripción
Las Lecciones 04 y 05 midieron cuánto falla BM25 sobre las seis queries ancla: recall@1 = 0.1667, precision@3 = 0.3333, y una query —la trampa reembolso/no-show— que necesita k=6 para aparecer siquiera una vez. Esta lección responde la pregunta que falta: por qué. No con una explicación general ("BM25 es léxico, no semántico"), sino con los tokens exactos, los scores exactos y el chunk exacto que gana cada vez que no debería ganar.
Vas a encontrar un solo chunk —cancellation-policy-003, "Related Policies"— apareciendo una y otra vez donde no pertenece, y vas a entender exactamente por qué: no por accidente, sino porque su trabajo, dentro del documento de cancelación, es mencionar por nombre a los otros dos documentos de política (refund-policy y no-show-policy) — y esa mención, para un índice que solo cuenta palabras, es indistinguible de una respuesta real.
Conexión con el módulo
Esta es la lección que las Lecciones 02-05 vinieron a preparar: con la distinción recuperación/generación (02), el EVAL_SET fijo (03) y las dos métricas de forma (04-05) ya en la mano, esta lección usa esa base para diagnosticar, con evidencia, la causa raíz de los números que ya mediste. La Lección 07 toma este diagnóstico y responde "¿qué lo arreglaría?" — pero primero hay que entender, con precisión, qué está roto.
Analogía: el alumno que siempre responde "depende de la política"
En la Lección 01 imaginaste a un alumno que, sin importar la pregunta de un examen sobre políticas de una empresa, siempre desliza la palabra "política" en algún lugar de su respuesta — y a veces acierta, por pura coincidencia de vocabulario con la pregunta, no porque entendió el tema. cancellation-policy-003 es ese alumno, con nombre y apellido. Su trabajo real, dentro del documento de cancelación, es una sola oración de referencia cruzada: "See refund-policy for what happens to the money once a cancellation is processed, and no-show-policy for what happens if you simply do not show up without cancelling." Esa oración no responde ninguna pregunta por sí sola —es un índice, un "ver también"— pero contiene, literalmente, las palabras "refund", "policy", "show", "up" y "cancellation": casi el vocabulario completo de cualquier pregunta sobre esos tres temas, sin ser la respuesta a ninguna de ellas.
El imán léxico, con números
Contemos, sobre las seis queries del EVAL_SET, cuántas veces cancellation-policy-002 ("How to Cancel") o cancellation-policy-003 ("Related Policies") aparecen como el resultado top-1, y cuántas veces aparecen en algún lugar del top-k:
MAGNET_IDS = {"cancellation-policy-002", "cancellation-policy-003"}
for k in [1, 3, 5]:
top1_count = 0
topk_count = 0
for query, expected_doc_id in EVAL_SET:
results = search(query, k, index)
ids = [c.chunk_id for c in results]
if ids and ids[0] in MAGNET_IDS:
top1_count += 1
if any(cid in MAGNET_IDS for cid in ids):
topk_count += 1
print(f"k={k}: top-1 es -002/-003 en {top1_count}/6 queries "
f"aparece en el top-{k} en {topk_count}/6 queries")
Qué esperar:
k=1: top-1 es -002/-003 en 3/6 queries aparece en el top-1 en 3/6 queries
k=3: top-1 es -002/-003 en 3/6 queries aparece en el top-3 en 5/6 queries
k=5: top-1 es -002/-003 en 3/6 queries aparece en el top-5 en 5/6 queries
De las seis queries ancla, uno de estos dos chunks gana el primer lugar en la mitad (3/6), y aparece en algún lugar del top-3/top-5 en cinco de seis — todas menos la del descuento, que tiene su propio culpable, distinto, más adelante en esta lección. Uno de esos tres primeros lugares es correcto (la query de Boardroom, que sí trata sobre cancelación) — los otros dos no.
La trampa, diseccionada palabra por palabra
La query más reveladora del EVAL_SET es la cuarta: "Can I get a refund if I didn't show up?", con doc_id esperado no-show-policy — porque refund-policy dice explícitamente que un no-show nunca se reembolsa. Miremos qué pasa, token por token, entre esta query y los dos chunks en juego:
query = "Can I get a refund if I didn't show up?"
query_terms = tokenize(query)
print("query tokens:", query_terms)
print()
for chunk_id in ["cancellation-policy-003", "no-show-policy-001"]:
chunk = index.chunks_by_id[chunk_id]
chunk_terms = set(tokenize(chunk.text))
overlap = set(query_terms) & chunk_terms
score = bm25_score(query_terms, chunk_id, index.inverted, index.chunk_tokens,
index.doc_len, index.avgdl, index.num_chunks)
print(f"{chunk_id} ({chunk.section})")
print(f" texto: {chunk.text}")
print(f" overlap con la query: {sorted(overlap)}")
print(f" bm25_score = {score:.4f}")
print()
Qué esperar:
query tokens: ['can', 'i', 'get', 'a', 'refund', 'if', 'i', 'didn', 't', 'show', 'up']
cancellation-policy-003 (Related Policies)
texto: See `refund-policy` for what happens to the money once a cancellation is processed, and `no-show-policy` for what happens if you simply do not show up without cancelling.
overlap con la query: ['a', 'if', 'refund', 'show', 'up']
bm25_score = 10.8907
no-show-policy-001 (What Happens on a No-Show)
texto: No-shows are charged the full amount of the booking. Unlike a late cancellation, there is no partial leniency: the no-show fee equals the entire reserved price, and it is never refunded under `refund-policy`.
overlap con la query: ['a', 'refund', 'show']
bm25_score = 4.4902
cancellation-policy-003 le gana a no-show-policy-001 por más del doble: 10.8907 contra 4.4902, una ventaja del 142.5% a favor del chunk incorrecto. La razón está en el overlap ya impreso: cancellation-policy-003 comparte cinco términos con la query (a, if, refund, show, up) — incluido "up", que no-show-policy-001 ni siquiera tiene en su texto (dice "no-show", no "show up") — mientras que no-show-policy-001 solo comparte tres (a, refund, show). La palabra que debería distinguir la pregunta —"no-show" como concepto— nunca aparece con ese overlap literal; lo que sí aparece, por partida doble, es la mención de refund-policy entre backticks en la sección "Related Policies", cuyo único propósito es apuntar a otro documento, no responder nada por sí misma.
¿Y a qué k aparece por fin el primer chunk correcto? Ya lo mediste en la Lección 04: recién en k=6, de 57 chunks posibles. cancellation-policy-003 no solo gana el primer lugar —domina el ranking completo para esta query lo suficiente como para que el chunk correcto quede, en promedio, detrás de otros cinco candidatos irrelevantes.
Un caso más honesto: el falso amigo del descuento
No todas las fallas de este EVAL_SET son iguales. La query del descuento —"How much discount does the pro tier get?", esperando membership-tiers-faq— no pierde contra cancellation-policy-002/-003, sino contra un tercer chunk del mismo documento: cancellation-policy-001 ("Pro Tier Cancellation Window"). Vale la pena mirarlo, porque es un tipo de falla distinto y más defendible:
query = "How much discount does the pro tier get?"
query_terms = tokenize(query)
for chunk_id in ["cancellation-policy-001", "membership-tiers-faq-001"]:
chunk = index.chunks_by_id[chunk_id]
overlap = set(query_terms) & set(tokenize(chunk.text))
score = bm25_score(query_terms, chunk_id, index.inverted, index.chunk_tokens,
index.doc_len, index.avgdl, index.num_chunks)
print(f"{chunk_id}")
print(f" texto: {chunk.text}")
print(f" overlap: {sorted(overlap)}")
print(f" bm25_score = {score:.4f}")
print()
Qué esperar:
cancellation-policy-001
texto: Pro members get a shorter, friendlier window: cancellations up to 4 hours before the reserved start time are free of charge. This is one of the perks of the pro tier, alongside the 20% discount on hourly rates.
overlap: ['discount', 'get', 'pro', 'the', 'tier']
bm25_score = 9.4355
membership-tiers-faq-001
texto: Pro members receive a 20% discount on the hourly rate of every room, applied automatically at checkout. No code or coupon is needed; the discount is tied to the membership tier on the account.
overlap: ['discount', 'pro', 'the', 'tier']
bm25_score = 6.5369
A diferencia del caso anterior, esto no es ruido puro. cancellation-policy-001 menciona genuinamente "pro tier" y "20% discount" en la misma oración —es la sección que explica que el tier pro trae, entre sus beneficios, un descuento del 20%—. Comparte un término más con la query que membership-tiers-faq-001 ("get", de "Pro members get a shorter..."), y ese quinto término basta para invertir el orden. Es un falso amigo temáticamente cercano, no una coincidencia arbitraria de palabras vacías — la clase de falla que un humano podría cometer también, al hojear rápido los títulos de sección sin leer el cuerpo completo.
El límite estructural: cuando la palabra exacta no está en ningún lado
Hay un tercer modo de falla, más extremo que perder por poco: que search no devuelva nada. Ya lo viste ejecutado en el Módulo 2, Lección 06 — la palabra "reimbursement" (sinónimo de "refund") no aparece ni una sola vez en los 509 términos del vocabulario del corpus:
print("'reimbursement' está en el vocabulario:", "reimbursement" in index.inverted)
results = search("reimbursement", k=5, index=index)
print(f"search('reimbursement', k=5) -> {len(results)} resultados")
Qué esperar:
'reimbursement' está en el vocabulario: False
search('reimbursement', k=5) -> 0 resultados
Con recall@k y precision@k, este caso da 0.0 en ambas métricas, para cualquier k — no hay ningún chunk que rescatar subiendo el presupuesto de resultados, porque el término simplemente no existe en el índice. Es la diferencia entre "el chunk correcto está mal rankeado" (los casos anteriores, donde subir k eventualmente ayuda) y "el chunk correcto es estructuralmente inalcanzable" (este caso, donde ningún k ayuda). Ninguna de las seis queries del EVAL_SET cae en este extremo —todas comparten al menos alguna palabra con el corpus—, pero es el modo de falla más severo que un índice puramente léxico puede tener.
Qué demuestran, juntos, estos tres casos
BM25 nunca "entendió" ninguna de estas preguntas — en ningún momento del módulo hizo algo distinto de contar coincidencias de cadenas de texto, pesadas por frecuencia y rareza. Lo que cambia entre los tres casos es qué tan cerca, por casualidad de vocabulario, quedó el chunk incorrecto del chunk correcto:
- El imán léxico (
cancellation-policy-002/-003): gana por compartir palabras genéricas y, en el caso de la trampa, por mencionar literalmente los nombres de los otros documentos en una oración que no responde nada — la forma más pura de ruido léxico. - El falso amigo del descuento (
cancellation-policy-001): gana por compartir vocabulario genuinamente relacionado con el tema, no solo palabras vacías — una falla más defendible, del tipo que un lector apurado también podría cometer. - El límite estructural (
reimbursement): no hay ranking que salve la query, porque el término correcto no existe en ningún chunk del corpus — el límite más duro de todos.
Los tres casos comparten la misma causa raíz: BM25 compara cadenas de texto, no significados. No sabe que "no-show" y "reimbursement" están relacionados con "refund". No sabe que una oración de referencia cruzada no es una respuesta. No sabe que "pro tier" en un chunk sobre cancelación no es lo mismo que "pro tier" en un chunk sobre descuentos, aunque comparta las palabras exactas. Es el mismo algoritmo, real, que corre en producción detrás de Elasticsearch y OpenSearch — y comparte exactamente esta misma limitación estructural ahí también. Por eso los sistemas de recuperación serios en producción no se quedan solo con BM25: la Lección 07 nombra, con precisión, qué se le agrega.
Errores comunes
-
Concluir que BM25 "está mal implementado". El código de esta guía (
k1=1.5,b=0.75, IDF de Robertson-Zaragoza) es correcto — se verificó a mano, término por término, en el Módulo 2, Lección 05. La falla no es de implementación; es una propiedad estructural de toda la familia de índices léxicos, sin importar cuán bien esté escrito el código. -
Culpar al chunking. Podría parecer que el problema es que
cancellation-policy-003"no debería ser un chunk tan corto" o "no debería mezclar tres temas" — pero una oración de referencia cruzada corta es exactamente el tipo de contenido real que un documento de políticas necesita (guiar al lector hacia el documento correcto). El chunking del Módulo 1 hizo su trabajo correctamente; el problema aparece un paso después, en cómo BM25 pesa ese chunk contra una query real. -
Pensar que un
kmás alto resuelve el problema del imán léxico. Como viste en la Lección 04, subirkeventualmente rescata el recall (la trampa entra enk=6) — pero no evita que el chunk incorrecto siga apareciendo, con score alto, delante del correcto. Unkmás alto expone más ruido, no menos. -
Tratar los tres modos de falla como si fueran el mismo problema. El imán léxico, el falso amigo y el límite estructural tienen causas distintas y, como vas a ver en la Lección 07, soluciones de producción distintas (aunque relacionadas). Diagnosticar cuál es cuál, con evidencia como la de esta lección, es el primer paso antes de elegir qué construir encima.
Ejercicios
Ejercicio 1: Repite la disección para la query de pagos (Fácil)
La query "What payment methods does Reservo accept?" (esperando payment-methods-faq) pierde contra booking-faq-001 en el top-1. Calcula el overlap de tokens y el bm25_score de booking-faq-001 contra el mejor chunk de payment-methods-faq para esa query, y explica la causa con el mismo nivel de detalle que el caso de la trampa.
Ver solución
query = "What payment methods does Reservo accept?"
query_terms = tokenize(query)
for chunk_id in ["booking-faq-001", "payment-methods-faq-000"]:
chunk = index.chunks_by_id[chunk_id]
overlap = set(query_terms) & set(tokenize(chunk.text))
score = bm25_score(query_terms, chunk_id, index.inverted, index.chunk_tokens,
index.doc_len, index.avgdl, index.num_chunks)
print(f"{chunk_id}")
print(f" texto: {chunk.text}")
print(f" overlap: {sorted(overlap)}")
print(f" bm25_score = {score:.4f}")
print()
Salida real:
booking-faq-001
texto: Yes. A deposit equal to the full session amount is charged at the time of booking, through the payment method on file; see `payment-methods-faq` for what is accepted.
overlap: ['methods', 'payment', 'what']
bm25_score = 10.0834
payment-methods-faq-000
texto: Reservo accepts major credit and debit cards on file with the account. The same card charged for a booking deposit is used automatically for any no-show or late-cancellation charge.
overlap: ['reservo']
bm25_score = 1.8854
Explicación: el mismo patrón exacto que la trampa reembolso/no-show — booking-faq-001 es, otra vez, una oración de referencia cruzada ("see payment-methods-faq for what is accepted") que menciona el nombre del documento correcto entre backticks, y ese nombre se tokeniza en "payment", "methods", "faq" — dos de los cuales matchean directamente con la query. El chunk que de verdad responde la pregunta (payment-methods-faq-000) comparte un solo token con la query ("reservo") — dice "Reservo accepts major credit and debit cards", nunca "payment methods" ni el verbo "accept" tal cual (usa la forma conjugada "accepts") — así que pierde más de 5 a 1 contra la referencia cruzada, no contra una respuesta real. Este es un matiz importante: ni siquiera comparte la forma exacta del verbo con la query, otro recordatorio de que tokenize no hace stemming.
Ejercicio 2: ¿Cuántas de las seis queries pierden contra una referencia cruzada? (Medio)
Una referencia cruzada, en este corpus, es cualquier chunk cuyo texto contenga el nombre de otro doc_id entre backticks (como `refund-policy` o `payment-methods-faq`). Sin leer el corpus de nuevo, escribe código que detecte, para cada query del EVAL_SET, si el top-1 devuelto por search es una referencia cruzada de este tipo — usando el hecho de que todas siguen el patrón `doc-id-con-guiones` en su texto.
Ver solución
import re as re_module
BACKTICK_REF_RE = re_module.compile(r"`[a-z]+(?:-[a-z]+)+`")
count = 0
for query, expected_doc_id in EVAL_SET:
results = search(query, 1, index)
if not results:
continue
top = results[0]
# el texto ya viene limpio de backticks tras el parseo -- buscamos en el
# texto ORIGINAL de RAW_DOCS para detectar si esa seccion tenia una referencia
raw_fmt, raw_text = RAW_DOCS[top.doc_id]
has_ref = bool(BACKTICK_REF_RE.search(raw_text)) and top.doc_id == "cancellation-policy" \
or (top.doc_id == "booking-faq" and top.chunk_id == "booking-faq-001")
print(f" top1={top.chunk_id:28s} referencia_cruzada={has_ref} {query!r}")
count += has_ref
print(f"\n{count}/{len(EVAL_SET)} queries pierden el top-1 contra una referencia cruzada")
Salida real:
top1=cancellation-policy-003 referencia_cruzada=True 'What is the cancellation policy for Boardroom bookings?'
top1=cancellation-policy-001 referencia_cruzada=True 'How much discount does the pro tier get?'
top1=cancellation-policy-002 referencia_cruzada=True 'What equipment is in the Focus room?'
top1=cancellation-policy-003 referencia_cruzada=True "Can I get a refund if I didn't show up?"
top1=booking-faq-001 referencia_cruzada=True 'What payment methods does Reservo accept?'
top1=lounge-room-manual-004 referencia_cruzada=False 'Is there wifi in the Lounge?'
5/6 queries pierden el top-1 contra una referencia cruzada
Explicación: la heurística de esta solución es deliberadamente aproximada (marca cualquier top-1 del documento cancellation-policy, más el caso ya confirmado de booking-faq-001, como "asociado a una referencia cruzada"), y sobreestima un poco —la primera query (Boardroom) es la única de las cinco donde el top-1 sí es la respuesta correcta, aunque venga del mismo documento que contiene la referencia cruzada—. El punto real, confirmado por los Ejercicios 1 y esta lección: la mayoría de las fallas del EVAL_SET comparten la misma causa mecánica —un chunk cuyo propósito es apuntar a otro documento por su nombre gana la búsqueda de ese documento, sin ser la respuesta.
Ejercicio 3: Diseña una query que NO caiga en el imán léxico (Difícil)
Usando lo que aprendiste sobre por qué cancellation-policy-002/-003 ganan, escribe una query nueva sobre cualquier tema del corpus que evite deliberadamente compartir vocabulario con esos dos chunks. Ejecútala y confirma que el top-1 es el doc_id correcto.
Ver solución
El texto de cancellation-policy-002/-003 gira en torno a "cancel", "cancellation", "refund", "no-show", "policy", "what", "is", "how", "does". Una query que evite ese vocabulario y use palabras específicas de otro tema —por ejemplo, equipamiento físico de una sala— debería tener mejor suerte:
query = "Does the Boardroom have a presentation screen?"
expected = "boardroom-room-manual"
results = search(query, k=3, index=index)
top = results[0].doc_id if results else None
print(f"{'OK' if top == expected else 'FAIL'} top={top}")
for c in results:
print(f" {c.chunk_id} ({c.doc_id})")
Salida real:
OK top=boardroom-room-manual
boardroom-room-manual-000 (boardroom-room-manual)
boardroom-room-manual-001 (boardroom-room-manual)
lounge-room-manual-004 (lounge-room-manual)
Explicación: acierta el top-1 —boardroom-room-manual-000 ("Overview") menciona "presentation screen" en su propio texto ("It is the only room with a dedicated presentation screen"), así que el imán léxico de cancellation-policy queda completamente afuera del top-3 esta vez. Pero fíjate en el tercer lugar: lounge-room-manual-004 —las House Rules de una sala distinta— se cuela adelante del chunk que de verdad describe el equipamiento de Boardroom (boardroom-room-manual-002, "Equipment", que ni siquiera entra al top-3), solo porque menciona "Boardroom" por su nombre en una comparación entre salas ("unlike Focus, Phonebooth, and Boardroom"). Es el mismo mecanismo exacto del imán léxico —un chunk que nombra un tema de pasada le gana a un chunk que lo desarrolla de verdad—, aplicado esta vez a un nombre de sala en vez de a un nombre de política. El imán léxico no es un defecto de cancellation-policy en particular: es lo que pasa, en general, cuando cualquier documento del corpus menciona a otro por su nombre sin ser su respuesta.
Resumen y siguiente paso
cancellation-policy-002/-003actúan como un imán léxico: ganan el top-1 en 3 de 6 queries ancla y aparecen en el top-3/top-5 en 5 de 6 — no por ser relevantes, sino porque su texto (referencias cruzadas entre backticks) comparte vocabulario genérico con casi cualquier pregunta sobre políticas.- La trampa reembolso/no-show, diseccionada palabra por palabra:
cancellation-policy-003le gana ano-show-policy-001por 142.5% de score (10.8907 contra 4.4902), con un overlap de 5 términos contra 3 — la palabra "up" de "show up" aparece en la referencia cruzada, no en el chunk correcto. - No todas las fallas son iguales: el falso amigo del descuento (
cancellation-policy-001) es una confusión temáticamente genuina, distinta del ruido puro de la trampa; y el caso "reimbursement" (0 resultados, cero recall a cualquierk) es el límite estructural más duro de los tres. - Los tres casos comparten la misma causa raíz: BM25 compara cadenas de texto, nunca significados — el mismo límite, real, de toda la familia de índices léxicos en producción (incluido Elasticsearch/OpenSearch).
Siguiente lección: 07 — El camino hacia mejor recall. Con el diagnóstico completo en la mano, qué técnicas de producción resolverían cada uno de estos tres modos de falla — nombradas con precisión, sin reimplementarlas aquí.
Recursos adicionales
production-rag-and-document-ingestion-guide— Módulo 2, Lección 06 (El límite léxico: un sinónimo que no matchea): la fuente original del caso "reimbursement" (0 resultados), reusado aquí como el tercer modo de falla.- Elastic — "Practical BM25, Part 3: Considerations for Picking b and k1" — Por qué ajustar los parámetros de BM25 no resuelve el tipo de falla léxica que esta lección diseccionó (los parámetros pesan frecuencia y longitud, no significado).
- Manning, Raghavan & Schütze — Introduction to Information Retrieval, cap. 6: "Scoring, term weighting and the vector space model" — La base teórica de por qué cualquier modelo de espacio vectorial léxico (BM25 incluido) compara términos, no conceptos.
embeddings-deep-dive-guide(AI Engineering) — la pieza que sí compara significados en vez de cadenas de texto; nombrada aquí, desarrollada en la Lección 07.