Ir al contenido
wannacode

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))
Salida
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:

  1. ¿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.
  2. ¿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 pruebas
CASES = [
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 falta
def 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 k
def recall(k):
found = 0
for c in CASES:
r = rank(c)
if r >= 1 and r <= k:
found += 1
return found
print(f"{'tema':<18}{'rango':<7}{'k=1':<5}k=3")
total = 0
for 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}")
Salida
tema rango k=1 k=3
precio 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/7
recall@3 = 6/7

# lugar del fragmento correcto (desde 1), 0 si falta
def 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 k
def recall(k):
found = 0
for c in CASES:
r = rank(c)
if r >= 1 and r <= k:
found += 1
return found
Paso a paso, en palabras
  1. 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.
  2. 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.
  3. Con k = 1 solo entra el primer fragmento en el prompt. El correcto está en 3/7 preguntas: recall@1 = 3/7.
  4. Con k = 3 el fragmento correcto está en 6/7: recall@3 = 6/7. Solo «devolución» sigue fuera.
  5. ¿La respuesta se apoya en los fragmentos? Cada fuente debe estar en el prompt y cada número en la fuente: 7/8.
  6. 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 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 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")
Respuesta

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, correcta
def 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)
Salida
k = 3
tema encontrado respaldada correcta
precio de entrega ✓ ✓ ✓
plazo de entrega ✓ ✓ ✓
devolución ✗ ✗ ✗ el número 30 falta en la fuente
dinero de vuelta ✓ ✓ ✓
contra reembolso ✓ ✓ ✓
tarjeta rechazada ✓ ✓ ✓
garantía ✓ ✓ ✓
domingo — ✓ ✓
recall@3 6/7, respaldada 7/8, correcta 7/8

El ú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)
Salida
k = 1
tema encontrado respaldada correcta
precio de entrega ✓ ✓ ✓
plazo de entrega ✗ ✗ ✓ fuente fuera del prompt: entrega-2
devolución ✗ ✗ ✗ fuente fuera del prompt: devolver-2
dinero de vuelta ✗ ✓ ✗
contra reembolso ✗ ✓ ✗
tarjeta rechazada ✓ ✓ ✓
garantía ✓ ✓ ✓
domingo — ✓ ✓
recall@1 3/7, respaldada 6/8, correcta 5/8

Los 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 ""

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.

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:

Salida
k = 1: recall 3/7, respaldada 6/8, correcta 5/8, 740 tokens
k = 2: recall 5/7, respaldada 6/8, correcta 7/8, 864 tokens
k = 3: recall 6/7, respaldada 7/8, correcta 7/8, 976 tokens
k = 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

1. El fragmento correcto para una pregunta es el tercero por cercanía. ¿Con qué k entra en el prompt?
2. recall@3 es 6/7. ¿Qué significa?
3. La respuesta cita [entrega-2], pero el prompt solo tiene «devolver-1». ¿Qué dice la comprobación?
4. La respuesta se apoya en el fragmento que está en el prompt. ¿Significa que es correcta?
5. Con k = 4 los tres números son completos (7/7, 8/8, 8/8), y los tokens son cerca de un 7 % más que con k = 3. ¿Qué se deduce?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·