Evaluar RAG: ¿encuentra el fragmento y la respuesta se apoya en él?
En resumen: Evaluar RAG comprueba dos cosas: si la búsqueda encuentra el fragmento correcto (recall@k) y si la respuesta se apoya solo en lo encontrado.
Problema
Más barato, y «funciona»
El asistente de la lección anterior pone hasta 3 fragmentos en el prompt. Un compañero nota que los fragmentos son la mayor parte de los tokens. Baja k a 1 y prueba dos preguntas:
for question in ["cuánto cuesta la entrega a una oficina", "el banco rechazó mi tarjeta"]: print(question + "\n → " + answer(question, 1))cuánto cuesta la entrega a una oficina → La entrega a una oficina de mensajería cuesta 3 €. [entrega-1]el banco rechazó mi tarjeta → Prueba el pago con otra tarjeta. [pago-2]Las dos respuestas son correctas, con fuente. El compañero publica el cambio.
Al día siguiente el asistente le dice a un cliente que el dinero vuelve en 14 días. Los documentos dicen cinco. Las dos preguntas probadas a mano no podían detectarlo: en las dos el fragmento correcto es el primero por cercanía.
La idea
Dos preguntas separadas
RAG puede fallar en dos lugares, y se miden por separado:
- ¿Lo encuentra? La búsqueda tiene que traer el fragmento correcto entre los primeros k. Su lugar entre los fragmentos encontrados es el rango. La proporción de preguntas en que el rango está entre 1 y k es recall@k. Es el mismo recall (exhaustividad) de la lección sobre evaluar un modelo: de todo lo que debías encontrar, cuánto encontraste.
- ¿Se apoya en él? La respuesta tiene que salir de los fragmentos que el modelo vio: toda fuente debe ser del prompt, y todo número debe estar en el fragmento citado.
La tercera, «es correcta», es la comprobación «debe contener» de la lección sobre evaluación. Las tres juntas te dicen dónde está el error: en la búsqueda, en el modelo o en los documentos.
Para esto necesitas un conjunto de pruebas: preguntas de clientes y, para cada una, el fragmento que la responde. El conjunto lo escribe una sola vez, a mano, alguien que conoce los documentos.
Míralo
El conjunto de pruebas y el rango
# un caso del conjunto de pruebas: tema, pregunta, el fragmento correcto ("" = ninguno) y lo que debe tener la respuesta@dataclass(frozen=True)class Case: topic: str question: str expect: str has: str# el conjunto de pruebasCASES = [ Case("precio de entrega", "cuánto cuesta la entrega a una oficina", "entrega-1", "3 €"), Case("plazo de entrega", "cuándo llega mi producto", "entrega-2", "dos días"), Case("devolución", "no quiero el producto", "devolver-1", "14 días"), Case("dinero de vuelta", "en cuántos días me devuelven el dinero", "devolver-2", "cinco días"), Case("contra reembolso", "puedo pagar contra reembolso", "pago-1", "contra reembolso"), Case("tarjeta rechazada", "el banco rechazó mi tarjeta", "pago-2", "otra tarjeta"), Case("garantía", "cuál es la garantía del producto", "garantía-1", "2 años"), Case("domingo", "abren los domingos", "", "No lo sé"),]
# un rechazo honesto empieza asíDECLINE = "no lo sé"La última pregunta no tiene fragmento: la tienda no ha escrito nada sobre el domingo. La respuesta correcta es «No lo sé».
El rango es el lugar del fragmento correcto entre los encontrados, y recall@k cuenta las preguntas en que está entre los primeros k:
# lugar del fragmento correcto (desde 1), 0 si faltadef rank(c): hits = search(c.question, len(PIECES)) for i, h in enumerate(hits): if h.piece.id == c.expect: return i + 1 return 0
# recall@k: en cuántas preguntas está entre los primeros kdef recall(k): found = 0 for c in CASES: r = rank(c) if r >= 1 and r <= k: found += 1 return foundprint(f"{'tema':<18}{'rango':<7}{'k=1':<5}k=3")total = 0for c in CASES: if not c.expect: continue total += 1 r = rank(c) print(f"{c.topic:<18}{r:<7}{mark(1 <= r <= 1):<5}{mark(1 <= r <= 3)}")for k in [1, 3]: print(f"recall@{k} = {recall(k)}/{total}")tema rango k=1 k=3precio de entrega 1 ✓ ✓plazo de entrega 3 ✗ ✓devolución 4 ✗ ✗dinero de vuelta 2 ✗ ✓contra reembolso 2 ✗ ✓tarjeta rechazada 1 ✓ ✓garantía 1 ✓ ✓recall@1 = 3/7recall@3 = 6/7# lugar del fragmento correcto (desde 1), 0 si faltadef rank(c): hits = search(c.question, len(PIECES)) for i, h in enumerate(hits): if h.piece.id == c.expect: return i + 1 return 0
# recall@k: en cuántas preguntas está entre los primeros kdef recall(k): found = 0 for c in CASES: r = rank(c) if r >= 1 and r <= k: found += 1 return found# si la respuesta se apoya en los fragmentos: "" o motivodef unsupported(answer, hits): if answer.lower().startswith(DECLINE): return "" ids = sources(answer) if not ids: return "sin fuente" words = set() for source in ids: hit = next( (h for h in hits if h.piece.id == source), None) if hit is None: return "fuente fuera del prompt: " + source words.update(hit.piece.text.split(" ")) for word in answer.split(" "): if word.startswith("["): continue n = "".join(ch for ch in word if ch.isdigit()) if n and n not in words: return f"el número {n} falta en la fuente" return ""Paso a paso, en palabras
- El conjunto de pruebas: 8 preguntas de clientes y, para cada una, el fragmento que la responde. La última no tiene fragmento en los documentos.
- Rango: en qué lugar está el fragmento correcto entre los encontrados. Para «devolución» es solo el 4.º: el pequeño modelo de vectores se equivoca.
- Con k = 1 solo entra el primer fragmento en el prompt. El correcto está en 3/7 preguntas: recall@1 = 3/7.
- Con k = 3 el fragmento correcto está en 6/7: recall@3 = 6/7. Solo «devolución» sigue fuera.
- ¿La respuesta se apoya en los fragmentos? Cada fuente debe estar en el prompt y cada número en la fuente: 7/8.
- Dos fallos distintos: la búsqueda no encontró el fragmento (recall) y el modelo escribió un número que no está en su fuente (respaldada).
Con k = 1 el fragmento correcto está en el prompt solo en 3 de 7 preguntas. Con k = 3 está en 6. «devolución» tiene rango 4: el modelo vectorial pequeño encuentra «no quiero el producto» más cercano a la garantía (coseno 1,00) que al fragmento correcto (0,54), porque la pregunta solo tiene la palabra «producto», y de eso trata también la garantía.
Míralo
¿Se apoya en él?
La comprobación toma la respuesta y los fragmentos que el modelo vio. Un rechazo honesto la pasa. Toda fuente tiene que estar entre los fragmentos, y todo número de la respuesta tiene que ser una palabra de los fragmentos citados:
# si la respuesta se apoya en los fragmentos: "" o motivodef unsupported(answer, hits): if answer.lower().startswith(DECLINE): return "" ids = sources(answer) if not ids: return "sin fuente" words = set() for source in ids: hit = next( (h for h in hits if h.piece.id == source), None) if hit is None: return "fuente fuera del prompt: " + source words.update(hit.piece.text.split(" ")) for word in answer.split(" "): if word.startswith("["): continue n = "".join(ch for ch in word if ch.isdigit()) if n and n not in words: return f"el número {n} falta en la fuente" return ""Devuelve una cadena vacía cuando la respuesta está respaldada, y el motivo en caso contrario. El motivo es más útil que ✗: te dice dónde mirar.
Pruébalo
Predice la salida
Con k = 1, para «en cuántos días me devuelven el dinero» solo «devolver-1» entra en el prompt: «puedes devolver tu compra en 14 días». ¿Qué imprime la comprobación para esta respuesta?
claim = "El dinero vuelve en 14 días. [devolver-1]"why = unsupported(claim, search("en cuántos días me devuelven el dinero", 1))print(why or "respaldada")«respaldada». La fuente está en el prompt, y 14 es una palabra del fragmento. La respuesta se apoya en lo encontrado, pero es incorrecta: el dinero llega en cinco días, y eso está en «devolver-2», que no se encontró. Por eso «respaldada» y «correcta» son dos comprobaciones.
Míralo
Todo el conjunto
report pasa cada pregunta por RAG e imprime las tres marcas: encontrado, respaldada, correcta. Con k = 3:
# todo el conjunto con k fragmentos: encontrado, respaldada, correctadef report(k): found = total = stands = right = 0 print(f"k = {k}") print("tema encontrado respaldada correcta") for c in CASES: hits = search(c.question, k) text = answer(c.question, k) why = unsupported(text, hits) f = "—" if c.expect: total += 1 in_top = any(h.piece.id == c.expect for h in hits) found += in_top f = mark(in_top) stands += why == "" right += c.has in text row = f"{c.topic:<18}{f:<12}{mark(why == ''):<12}{mark(c.has in text)}" print(row if why == "" else row + " " + why) n = len(CASES) print(f"recall@{k} {found}/{total}, respaldada {stands}/{n}, correcta {right}/{n}")report(3)k = 3tema encontrado respaldada correctaprecio de entrega ✓ ✓ ✓plazo de entrega ✓ ✓ ✓devolución ✗ ✗ ✗ el número 30 falta en la fuentedinero de vuelta ✓ ✓ ✓contra reembolso ✓ ✓ ✓tarjeta rechazada ✓ ✓ ✓garantía ✓ ✓ ✓domingo — ✓ ✓recall@3 6/7, respaldada 7/8, correcta 7/8El único fallo es «devolución»: no se encontró el fragmento correcto, el modelo citó el fragmento sobre el dinero y escribió 30 días, mientras que el fragmento dice cinco. La comprobación detecta el número.
Ahora lo mismo con k = 1, el cambio del compañero:
report(1)k = 1tema encontrado respaldada correctaprecio de entrega ✓ ✓ ✓plazo de entrega ✗ ✗ ✓ fuente fuera del prompt: entrega-2devolución ✗ ✗ ✗ fuente fuera del prompt: devolver-2dinero de vuelta ✗ ✓ ✗contra reembolso ✗ ✓ ✗tarjeta rechazada ✓ ✓ ✓garantía ✓ ✓ ✓domingo — ✓ ✓recall@1 3/7, respaldada 6/8, correcta 5/8Los tres números bajan. Los motivos son distintos: en «plazo de entrega» el modelo cita un fragmento que no vio (y acierta por casualidad), en «devolución» también, pero con el número equivocado, en «dinero de vuelta» se apoya en el fragmento equivocado, y en «contra reembolso» dice con honestidad «No lo sé.», porque el fragmento no se encontró. Las dos preguntas probadas a mano estaban entre las tres que pasan todo.
Pruébalo
Completa
¿Entre qué fragmentos se busca cada fuente de la respuesta? ¿Qué va en lugar de ___?
# si la respuesta se apoya en los fragmentos: "" o motivo
def unsupported(answer, hits):
if answer.lower().startswith(DECLINE):
return ""
ids = sources(answer)
if not ids:
return "sin fuente"
words = set()
for source in ids:
hit = next(
(h for h in if h.piece.id == source), None)
if hit is None:
return "fuente fuera del prompt: " + source
words.update(hit.piece.text.split(" "))
for word in answer.split(" "):
if word.startswith("["):
continue
n = "".join(ch for ch in word if ch.isdigit())
if n and n not in words:
return f"el número {n} falta en la fuente"
return ""hits: los fragmentos que entraron en el prompt. No todos los documentos: una fuente que existe pero que el modelo no vio significa una respuesta de memoria.
Pruébalo
Encuentra el error
Un compañero reescribe recall. Ahora recall@1 es 0/7 y recall@3 es 5/7. ¿Qué línea tiene el error?
Elige la línea con el error.
r < k deja fuera el fragmento justo en el lugar k. Con k = 1 no pasa nada, porque el rango es al menos 1. La condición correcta es 1 ≤ r ≤ k.
La idea
Cuándo duele
- El conjunto es pequeño. Aquí una pregunta es el 14 % de recall. Un conjunto real tiene cientos de preguntas sacadas de conversaciones reales, y crece con cada error que encuentra un cliente.
- Las etiquetas envejecen. El fragmento correcto se anota por su fuente. Cuando los documentos se dividen de nuevo, las fuentes cambian, y el conjunto hay que actualizarlo con ellas.
- Respaldada no significa correcta. La respuesta «14 días» se apoya en el fragmento equivocado. Por eso las tres comprobaciones van juntas: encontrado, respaldada, correcta.
- La comprobación es tosca. Mira solo los dígitos y solo si la palabra está. «cinco días» en letras no se comprueba, y el «5» de «5 €» pasa aunque la respuesta diga 5 días. Un segundo modelo comprueba con más precisión (ver «Más»), pero también se equivoca.
- Puede haber más de un fragmento correcto. La respuesta puede estar en dos fragmentos. Entonces el conjunto los guarda todos, y «encontrado» significa al menos uno, o todos si hacen falta los dos.
- El conjunto corre en cada cambio. Un k nuevo, un umbral nuevo, un modelo vectorial nuevo, un chunking nuevo, un prompt nuevo: cada uno cambia los números. Por eso el conjunto es código y corre como las pruebas, con una barra por debajo de la cual el cambio no se publica, como en la lección sobre evaluación.
Más precisamente, para curiosos
Un segundo modelo como juez. En lugar de buscar dígitos, puedes preguntar a un segundo modelo: «¿Esta respuesta se deduce de estos fragmentos? Sí o no.» Detecta más que la comprobación tosca, pero cuesta una llamada más, y hay que comprobarlo a él mismo con un conjunto de notas conocidas.
Otras medidas para la búsqueda. recall@k solo dice si el fragmento está. MRR (la media de 1/rango) dice también qué tan arriba está: el rango 1 da 1, el rango 3 da 0,33. precision@k dice cuántos de los k fragmentos tratan de la pregunta, es decir, cuántos tokens se desperdician.
El conjunto en CI. Cómo el conjunto corre automáticamente en cada cambio y frena una versión mala antes de que llegue a los clientes es tarea de ZeroToOps.
set() guarda las palabras de los fragmentos citados. next((h for h in hits if ...), None) devuelve la primera coincidencia o None.Ejercicio
Qué tan grande debe ser k
Resuélvelo en tu editor.
Escribe una función que corra todo el conjunto para un k dado e imprima recall, respaldada, correcta y cuántos tokens entraron en los prompts (los tokens del prompt y de la pregunta, solo cuando se llama al modelo). Córrela para k de 1 a 4:
k = 1: recall 3/7, respaldada 6/8, correcta 5/8, 740 tokensk = 2: recall 5/7, respaldada 6/8, correcta 7/8, 864 tokensk = 3: recall 6/7, respaldada 7/8, correcta 7/8, 976 tokensk = 4: recall 7/7, respaldada 8/8, correcta 8/8, 1042 tokens¿Qué k elegirías, y por qué? k = 4 arregla «devolución», y cuesta cerca de un 7 % más de tokens que k = 3. Piensa también cuánto de estos números se debe solo a este conjunto pequeño.
Más tareas
- Umbral. Corre el conjunto con un umbral de 0,6 y de 0,7 con k = 3. ¿Qué pasa con recall y con «domingo»?
- MRR. Calcula la media de 1/rango para las siete preguntas con fragmento (el rango 0 da 0).
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Costo y latencia: presupuesto, caché, un modelo más pequeño · Búsqueda semántica: los k más cercanos · Dividir documentos en fragmentos (chunking) · RAG: respuestas a partir de los documentos
- Continúa en: El asistente de soporte
- Ejercicios sobre el tema: Búsqueda semántica y RAG