El corchete que tranquiliza
Montas el RAG, añades “cita siempre tus fuentes” al prompt del sistema y empieza a salir esto: “La garantía es de 24 meses [2]”. Todo el mundo se relaja en la demo. El corchete parece una prueba.
No lo es. Ese [2] es un token como cualquier otro. El modelo lo escribe porque
se lo pediste y porque encaja ahí estadísticamente, no porque haya comprobado
nada. Has cambiado una alucinación desnuda por una alucinación con aparato
crítico, que es bastante peor: ahora el lector confía.
La pregunta útil no es si el sistema cita. Es si tú puedes comprobar la cita sin abrir el documento.
Tres fallos distintos con el mismo síntoma
Todos se ven igual —una respuesta con corchetes que está mal— y se arreglan en sitios diferentes.
| Fallo | Qué ocurre | Dónde se arregla |
|---|---|---|
| Cita fantasma | Apunta a un fragmento que no estaba en el contexto: un [7] con k = 5 | Validación de formato |
| Cita descolocada | El fragmento existe, pero no dice lo que la frase afirma | Verificación de soporte |
| Frase huérfana | Afirmación sustantiva sin ninguna cita | Cobertura de atribución |
El primero se detecta con un if y no debería llegar nunca a producción. El
tercero se cuenta. El segundo es el interesante, el que rompe la confianza y el
que casi nadie mide.
Citar documentos no sirve; citar tramos, sí
El error de diseño está antes que el modelo. Si le pides que cite el documento, la unidad de cita es un PDF de 40 páginas: para comprobarla hay que leérselo. Eso no es verificación, es fe con más pasos.
Pídele el tramo literal en el que se apoya. Cambia la naturaleza del problema: la cita deja de ser una etiqueta y pasa a ser una cadena de texto que o está en el fragmento o no está.
from pydantic import BaseModel
class Afirmacion(BaseModel):
texto: str # la frase de la respuesta
chunk_id: str # de qué fragmento sale
tramo: str # cita literal, copiada del fragmento sin reescribir
class Respuesta(BaseModel):
afirmaciones: list[Afirmacion]
# Las salidas estructuradas garantizan la forma, no la verdad:
# el modelo devolverá siempre un 'tramo', y a veces se lo inventará.
# La forma la impone el schema; el contenido lo verificas tú, después.
respuesta = cliente.responder(
pregunta=pregunta,
contexto=fragmentos,
schema=Respuesta,
)
Ese comentario es el nudo del asunto. Las salidas estructuradas resuelven el parsing, y la gente las confunde con una garantía de veracidad. Lo único que has conseguido es que el fallo sea comprobable.
Verificar sin creerle al modelo
Ahora sí: comparar el tramo contra el fragmento del que dice venir. Normalizar un poco —espacios, comillas tipográficas, guiones— y no exigir igualdad absoluta, porque los modelos recortan y reescriben acentos con una alegría notable.
import re, unicodedata
from difflib import SequenceMatcher
def normalizar(s: str) -> str:
s = unicodedata.normalize("NFKC", s).lower()
s = s.replace("“", '"').replace("”", '"').replace("’", "'")
return re.sub(r"\s+", " ", s).strip()
def cita_valida(tramo: str, fragmento: str, umbral: float = 0.92) -> bool:
t, f = normalizar(tramo), normalizar(fragmento)
if not t or len(t) < 20: # tramos de tres palabras casan con todo
return False
if t in f: # camino rápido: coincidencia literal
return True
# Coincidencia parcial: el mejor bloque común contra la longitud del tramo.
m = SequenceMatcher(None, t, f).find_longest_match(0, len(t), 0, len(f))
return (m.size / len(t)) >= umbral
Esto se ejecuta en microsegundos y no cuesta un token. Con eso ya tienes una puerta: si un tramo no valida, la respuesta no sale tal cual. Puedes reintentar, degradar la frase a “según el documento X” o marcarla en la interfaz. Lo que no puedes es mostrarla con un corchete que sugiere una comprobación que no hiciste.
Un detalle que ahorra disgustos: verifica contra el fragmento concreto que
dice el chunk_id, no contra todo el contexto. Si buscas el tramo en los cinco
fragmentos a la vez, una cita descolocada pasa el filtro por casualidad.
Lo que la coincidencia literal no cubre
Que el tramo exista no significa que sostenga la frase. El modelo puede copiar al pie de la letra “el plazo de devolución es de 14 días naturales” y escribir encima “se admiten devoluciones durante dos semanas desde la compra”. El tramo es real, la frase es casi correcta y el matiz —naturales, desde la entrega o desde la compra— se ha perdido por el camino.
Para eso hace falta juzgar la relación entre el tramo y la frase, que es una tarea de inferencia textual: ¿el tramo implica la afirmación, la contradice o simplemente no dice nada al respecto?
| Nivel | Qué detecta | Coste por respuesta | Cuándo |
|---|---|---|---|
Formato (chunk_id válido) | Citas fantasma | Cero | Siempre |
| Coincidencia del tramo | Tramos inventados | Cero | Siempre |
| Modelo NLI pequeño | Falta de soporte semántico | ~10 ms en CPU | En línea si el dominio es sensible |
| LLM como juez | Matices y contradicciones finas | Una llamada extra | Fuera de línea, sobre una muestra |
El orden importa porque es un embudo. Los dos primeros niveles son gratis y
descartan la mayoría de los casos feos, así que solo llega al modelo lo que
merece pagarse. Montar directamente el juez sobre el 100 % del tráfico es la
forma más cara de detectar errores que un in habría cazado.
Un modelo NLI de tamaño pequeño —de la familia DeBERTa, por ejemplo— basta para clasificar el par tramo-afirmación en implica, contradice o neutro. Es una decisión distinta a la del juez: aquí no valoras la calidad de la respuesta, solo si el texto citado la sostiene.
Las tres cifras que conviene tener en un panel
No hace falta un sistema de evaluación elaborado para empezar. Con estas tres sobre una muestra fija de preguntas ya se ve la salud del sistema:
- Citas válidas. Porcentaje de tramos que existen de verdad en su fragmento. Debería estar cerca del 100 %; si baja, el problema es el prompt o el modelo.
- Cobertura. Porcentaje de afirmaciones sustantivas que llevan cita. Aquí se esconde el relleno: frases de transición sin fuente que suenan a conclusión.
- Soporte. De las citas válidas, cuántas implican realmente la afirmación. Es la que más duele y la que mejor correlaciona con la confianza del usuario.
Sepáralas. Un número agregado de “precisión de atribución” mezcla tres causas con tres arreglos distintos y no te dice dónde tocar.
El cambio real no es técnico
La parte difícil de esto no es el código —son 40 líneas— sino aceptar lo que implica: si verificas, algunas respuestas dejarán de salir. El sistema pasa de contestar siempre a contestar menos y mejor, y eso hay que acordarlo antes, no el día que un usuario se encuentre con un “no he encontrado soporte para esta afirmación”.
A cambio, el corchete significa algo. Y esa es toda la diferencia entre un buscador interno que la gente usa y uno que la gente comprueba a mano, que es la forma elegante de decir que lo ha abandonado.
Si hoy tu RAG cita documentos, el primer paso es el más barato: pide el tramo literal y valídalo con la función de arriba. El día que lo despliegues vas a descubrir cuántas de tus citas eran decoración.