Módulo 6: Evaluar la calidad de la recuperación

Recall@k

Descripción

La lección anterior dejó un dato suelto, contado a mano en el Ejercicio 2: de las seis queries ancla, cuatro tienen el doc_id correcto en algún lugar del top-3, aunque solo una lo tiene en el primer lugar. Esta lección le pone nombre y fórmula a esa pregunta —recall@k— y la calcula para varios valores de k, sobre las seis queries del EVAL_SET, con el índice construido en la lección anterior.

Vas a implementar recall_at_k en menos de cinco líneas, correrla para k entre 1 y 7, y ver un patrón muy claro en los números reales: el recall sube con k, pero no de forma pareja entre queries — una de las seis se resiste hasta el final.

Conexión con el módulo

Esta lección no toca el índice ni el EVAL_SET — trabaja completamente sobre lo que la Lección 03 ya construyó. Es la primera de las dos métricas de forma del módulo; la Lección 05 agrega la segunda (precision@k) sobre el mismo material, y juntas alimentan el diagnóstico de la Lección 06.


Analogía: ¿la respuesta correcta apareció entre las primeras k que revisaste?

Retomando el examen con la hoja de respuestas: imagina que, en vez de un examen de opción múltiple estándar, cada pregunta viene con una lista ordenada de posibles respuestas, y el alumno puede marcar hasta k de ellas por pregunta —no solo la primera—. Recall@k pregunta, para cada pregunta del examen: ¿la respuesta correcta está en algún lugar de las primeras k que el alumno marcó? No importa si la marcó primera o última de esas k —solo importa si está adentro de esa ventana o no.

Aplicado a search: para una query dada, search(query, k, index) te da una lista ordenada de hasta k chunks. Recall@k pregunta si, en algún lugar de esa lista, aparece al menos un chunk cuyo doc_id sea el esperado. Es una pregunta de todo o nada por query —1.0 si está, 0.0 si no—, y el "recall@k" del set completo es el promedio de esos unos y ceros sobre las seis queries.


La fórmula, y por qué es binaria en este caso

En recuperación de información, la definición general de recall es:

recall@k = |relevantes ∩ recuperados en el top-k| / |total de relevantes|

En un corpus donde "relevante" puede significar muchos documentos por query, el denominador (|total de relevantes|) puede ser mayor a uno. En el EVAL_SET de esta guía, el ground truth es un solo doc_id por query —la tabla-ancla nunca marca dos documentos como correctos para la misma pregunta—, así que el denominador siempre vale 1, y la fórmula se simplifica a una pregunta binaria: ¿al menos un chunk del doc_id esperado apareció en el top-k?

def recall_at_k(results: list[Chunk], expected_doc_id: str) -> float:
    """1.0 si algun chunk devuelto pertenece a expected_doc_id, si no 0.0.
    El ground truth de EVAL_SET es un unico doc_id por query, asi que
    recall@k aqui responde: 'aparecio el documento correcto en el top-k?'"""
    return 1.0 if any(c.doc_id == expected_doc_id for c in results) else 0.0

results ya viene truncado a k porque es exactamente lo que search(query, k, index) devuelve — recall_at_k no necesita saber qué valor de k se usó, solo mirar la lista que recibió. El "@k" vive en cómo se llamó a search, no en la fórmula de recall_at_k misma.


Ejemplo trabajado: recall@k para las seis queries, k de 1 a 7

Usando el index y el EVAL_SET de la Lección 03:

def evaluate_recall(eval_set, index, k):
    scores = []
    for query, expected_doc_id in eval_set:
        results = search(query, k, index)
        scores.append(recall_at_k(results, expected_doc_id))
    return scores


for k in [1, 2, 3, 4, 5, 6, 7]:
    scores = evaluate_recall(EVAL_SET, index, k)
    mean_recall = sum(scores) / len(scores)
    hits = int(sum(scores))
    print(f"recall@{k} = {mean_recall:.4f}  ({hits}/{len(EVAL_SET)})")

Qué esperar:

recall@1 = 0.1667  (1/6)
recall@2 = 0.5000  (3/6)
recall@3 = 0.6667  (4/6)
recall@4 = 0.8333  (5/6)
recall@5 = 0.8333  (5/6)
recall@6 = 1.0000  (6/6)
recall@7 = 1.0000  (6/6)

El recall sube de forma monótona con k —nunca baja, porque agregar un resultado más nunca puede hacer que un doc_id que ya estaba adentro deje de estarlo— y llega a 1.0000 recién en k=6. Entre k=4 y k=5 el número no cambia (0.8333 en ambos): agregar un quinto resultado no metió a ninguna query nueva. Vale la pena mirar, query por query, en qué k exacto entra cada una:

for query, expected_doc_id in EVAL_SET:
    for k in range(1, 8):
        results = search(query, k, index)
        if recall_at_k(results, expected_doc_id) == 1.0:
            print(f"  entra en k={k}  {query!r}")
            break
    else:
        print(f"  NUNCA entra (k<=7)  {query!r}")

Qué esperar:

  entra en k=1  'What is the cancellation policy for Boardroom bookings?'
  entra en k=2  'How much discount does the pro tier get?'
  entra en k=2  'What equipment is in the Focus room?'
  entra en k=6  "Can I get a refund if I didn't show up?"
  entra en k=4  'What payment methods does Reservo accept?'
  entra en k=3  'Is there wifi in the Lounge?'

Cinco de las seis queries entran a un k razonable (entre 1 y 4). La query de la trampa —reembolso/no-show— es la única que necesita k=6 para aparecer: de los 57 chunks del corpus, el primer chunk de no-show-policy recién aparece en el sexto lugar del ranking BM25 para esa query. La Lección 06 va a diseccionar exactamente por qué, palabra por palabra.


Lo que recall@k NO te dice

Un número de recall alto es una buena noticia, pero es una noticia incompleta. Compara k=4 (recall=0.8333, 5/6) con k=7 (recall=1.0000, 6/6): para llegar del primero al segundo, tuviste que subir el presupuesto de resultados de 4 a 7 —casi el doble— solo para rescatar una query más (la trampa). Recall@k no penaliza en absoluto cuánto ruido viene mezclado con el resultado correcto — un k=7 con recall perfecto podría estar devolviendo, junto al chunk correcto, seis chunks completamente irrelevantes, y recall@7 seguiría marcando 1.0000 sin inmutarse. Esa es exactamente la pregunta que precision@k, en la próxima lección, sí responde.


Errores comunes

  1. Pensar que recall@k puede bajar al subir k. Es matemáticamente imposible con esta definición: search(query, k+1, index) siempre incluye todo lo que search(query, k, index) ya tenía, más un elemento más (o el mismo si no hay más candidatos con score positivo). Si recall@k baja al subir k en tu código, hay un error en cómo estás llamando a search, no en los datos.

  2. Calcular recall sobre el doc_id de un solo chunk, en vez de "algún chunk del documento". recall_at_k usa any(...) deliberadamente — si search devuelve dos chunks distintos de no-show-policy en el top-k, eso sigue siendo recall=1.0, no 2.0. La métrica es binaria por query, no un conteo de cuántos chunks del documento correcto aparecieron.

  3. Confundir "recall llega a 1.0 en algún k" con "el sistema funciona bien". Como viste arriba, la trampa reembolso/no-show necesita k=6 de 57 chunks posibles para aparecer siquiera una vez — eso es un recall técnicamente perfecto en k=6, pero es una señal de un sistema que casi falla por completo en esa query específica. El número por sí solo, sin mirar en qué k se logra, puede ocultar justo el problema que se supone que debía exponer.

  4. Usar recall@k con un k distinto al que realmente usaría search_docs en producción. Si el Módulo 3 acotó k a un máximo razonable (por ejemplo, k=5) para no devolver texto de más al modelo, medir recall@20 no dice nada sobre lo que el agente real va a ver — mide un escenario que nunca ocurre en producción.


Ejercicios

Ejercicio 1: Calcula recall@1 a mano (Fácil)

Sin ejecutar código, usando la tabla de "en qué k entra cada query" de esta lección, calcula recall@1 a mano: ¿cuántas de las seis queries entran exactamente en k=1? Confirma con evaluate_recall.

Ver solución

Mirando la tabla, solo una query tiene "entra en k=1": "What is the cancellation policy for Boardroom bookings?". Las otras cinco entran en k mayor a 1, así que a k=1 ninguna de ellas cuenta. Recall@1 = 1/6 = 0.1667.

scores = evaluate_recall(EVAL_SET, index, k=1)
print(scores, sum(scores) / len(scores))

Salida real:

[1.0, 0.0, 0.0, 0.0, 0.0, 0.0] 0.16666666666666666

Explicación: coincide con el cálculo a mano y con el número ya visto en el Módulo 2 —recall@1 es matemáticamente idéntico a "acertó el top-1", porque con k=1 solo hay un chunk para revisar, y any(...) sobre una lista de un elemento es simplemente preguntar por ese elemento.

Ejercicio 2: Encuentra el k mínimo para recall perfecto (Medio)

Sin mirar la tabla de esta lección, escribe código que encuentre, para el EVAL_SET completo, el valor mínimo de k tal que recall@k = 1.0 (todas las queries encuentran su doc_id esperado). Ejecuta y reporta ese k.

Ver solución
k = 1
while True:
    scores = evaluate_recall(EVAL_SET, index, k)
    if sum(scores) == len(EVAL_SET):
        break
    k += 1
print(f"recall@k = 1.0 se alcanza por primera vez en k={k}")

Salida real:

recall@k = 1.0 se alcanza por primera vez en k=6

Explicación: confirma, con código en vez de con la tabla ya publicada, que hace falta k=6 para que las seis queries tengan su documento correcto en algún lugar del ranking — impulsado enteramente por la query de la trampa, que es la última en entrar. Este es un patrón útil en producción: en vez de mirar el recall a un k fijo, preguntar "¿cuál es el k mínimo que garantiza recall perfecto sobre mi set de evaluación?" da una idea directa de cuánto presupuesto de resultados hace falta pedirle al índice.

Ejercicio 3: Compara recall@k con y sin la query de la trampa (Difícil)

Construye un EVAL_SET reducido, sin la cuarta query (la del reembolso/no-show), y calcula recall@1 y recall@3 sobre ese subconjunto de cinco queries. Compara contra los mismos k sobre el EVAL_SET completo de seis, y explica cuánto cambia el número al quitar una sola query difícil.

Ver solución
eval_set_sin_trampa = [pair for pair in EVAL_SET if pair[1] != "no-show-policy"]
print(f"queries en el subconjunto: {len(eval_set_sin_trampa)}")

for k in [1, 3]:
    full = sum(evaluate_recall(EVAL_SET, index, k)) / len(EVAL_SET)
    reduced = sum(evaluate_recall(eval_set_sin_trampa, index, k)) / len(eval_set_sin_trampa)
    print(f"recall@{k}  completo (6 queries)={full:.4f}   sin trampa (5 queries)={reduced:.4f}")

Salida real:

queries en el subconjunto: 5
recall@1  completo (6 queries)=0.1667   sin trampa (5 queries)=0.2000
recall@3  completo (6 queries)=0.6667   sin trampa (5 queries)=0.8000

Explicación: quitar una sola query cambia el promedio de forma notable —recall@3 sube de 0.6667 a 0.8000 (de 4/6 a 4/5)— porque con solo seis queries en el set, cada una vale un sexto (o un quinto) del score total. Esto es una lección aparte, importante para cualquier evaluación con un EVAL_SET chico: un set de seis queries es suficiente para demostrar un patrón de falla con claridad (como hace esta guía), pero un promedio calculado sobre tan pocos casos es sensible a cada query individual — en un sistema de evaluación de producción real, el EVAL_SET normalmente tiene decenas o cientos de queries anotadas, precisamente para que ninguna query aislada mueva tanto el promedio.


Resumen y siguiente paso

  • Recall@k responde: de las queries del EVAL_SET, ¿en qué fracción el doc_id esperado apareció en algún lugar del top-k de search? Es binaria por query (1.0 o 0.0) porque el ground truth de esta guía es un solo documento correcto por pregunta.
  • Ejecutado sobre las seis queries ancla: recall@1 = 0.1667, recall@3 = 0.6667, recall@6 = 1.0000 — sube de forma monótona con k, pero la query de la trampa (reembolso/no-show) recién entra en k=6, de 57 chunks posibles.
  • Recall@k no penaliza el ruido mezclado en el resultado — un recall perfecto en k=7 no dice nada sobre cuántos de esos 7 chunks son realmente del documento correcto.
  • Un EVAL_SET chico (seis queries) es sensible a cada query individual — el Ejercicio 3 lo confirmó con números: quitar una sola query cambió el recall en varios puntos porcentuales.

Siguiente lección: 05 — Precision@k. La métrica que sí mide cuánto ruido viene mezclado con el resultado correcto — la mitad que recall@k deja sin responder.


Recursos adicionales

  1. Manning, Raghavan & Schütze — Introduction to Information Retrieval, cap. 8.3: "Evaluation of ranked retrieval results" — La definición formal de recall en un contexto de ranking, la que esta lección simplifica para un solo documento relevante por query.
  2. production-rag-and-document-ingestion-guide — Módulo 6, Lección 03 (El set de evaluación fijo): la fuente del index y el EVAL_SET que esta lección usa sin cambios.
  3. Python — funciones integradas any() y sum() — Las dos funciones sobre las que está construida toda la lógica de recall_at_k y evaluate_recall.
  4. Python 3.14 — What's New — La versión con la que se ejecuta todo el código de este módulo.