TL;DR
RAG encontró el documento correcto. El LLM todavía lo contradecía. Ése es el fallo que detecta este sistema. Cinco patrones de error: contradicciones numéricas, citas falsas, cambios de negación, deriva de respuestas, respuestas seguras pero infundadas. Tres estrategias de curación solucionan las malas respuestas antes de que los usuarios las vean. Sin API externas, sin jueces de LLM, sin modelos integrados: Python puro en menos de 50 ms. Después de 70 pruebas, cada modo de falla de producción que encontré tiene una afirmación con nombre.
estaba mintiendo (por qué construí esto)
Estoy creando un asistente con tecnología RAG para EmiTechLogic, mi plataforma de educación tecnológica. El objetivo es simple: un alumno hace una pregunta, el sistema extrae mis tutoriales y artículos y responde basándose en ese contenido. El resultado del LLM no debe ser genérico. Debe reflejar mi contenido, mis explicaciones, lo que realmente he escrito.
Antes de presentar esto a estudiantes reales, necesitaba probarlo adecuadamente.
Lo que encontré no fue lo que esperaba. La recuperación estaba funcionando bien. El documento correcto estaba regresando. Pero el LLM estaba generando respuestas que contradecían directamente lo que acababa de obtener. Sin errores, sin fallas. Sólo una respuesta segura y fluida que en los hechos estaba equivocada.
Comencé a investigar qué tan común es esta falla en los sistemas RAG de producción. Cuanto más busqué, más encontré. Este no es un caso extremo raro ni un error que puedas corregir. Es una propiedad estructural de cómo funciona RAG.
El modelo lee el documento correcto y aun así genera algo diferente. Las razones no se comprenden del todo: desviación de la atención, sesgos en el entrenamiento, señales contradictorias en el contexto. Lo que importa en la práctica es que sucede con regularidad, no es predecible y la mayoría de los sistemas no tienen nada para detectarlo antes de que el usuario lo vea.
Esto es lo que la hace más peligrosa que la alucinación LLM estándar. Con un LLM simple, una respuesta incorrecta es al menos plausiblemente incierta. El usuario sabe que el modelo funciona a partir de datos de entrenamiento y podría estar equivocado. Con RAG, el modelo leyó la fuente correcta y aun así la contradijo. El usuario tiene todos los motivos para confiar en la respuesta. Parece que el sistema está haciendo exactamente aquello para lo que fue diseñado.
El modelo no sólo está fallando; está mintiendo con la cara seria. Produce estas respuestas fluidas y autoritarias que se parecen exactamente a la verdad, hasta el momento en que rompen su sistema.
Pasé meses investigando fallas de producción documentadas, reproduciéndolas en código y construyendo un sistema para detectarlas antes de que lleguen a los usuarios. Este artículo es el resultado de ese trabajo.
Todos los resultados provienen de ejecuciones reales del sistema en Python 3.12, solo CPU, sin GPU, excepto cuando se indique explícitamente que se calculan a partir de entradas conocidas.
Código completo: https://github.com/Emmimal/hallucination-detector/
Antes que nada, esto es lo que produce el sistema.
============================= comienza la sesión de prueba =============================== recolectados 70 elementos TestConfidenceScorer 5 aprobados TestFaithfulnessScorer 5 aprobados TestContradictionDetector 7 aprobados TestEntityHallucinationDetector 5 aprobados TestAnswerDriftMonitor 6 pasó TestHallucinationDetector 24 pasó TestQualityScore 18 pasó ============================== 70 pasó ===============================
70 pruebas. Cada modo de falla con nombre que he encontrado tiene una afirmación. Ese número es el objetivo de este artículo, no una nota a pie de página al final.
Donde fallan la mayoría de los sistemas RAG
La mayoría de los tutoriales de RAG se detienen en: recuperar documentos, incluirlos en un mensaje, llamar al modelo.
Eso funciona hasta que deja de funcionar.
Toda la promesa de la generación con recuperación aumentada está fundamentada. Dale al modelo documentos reales y los utilizará. En la práctica, RAG crea un modo de falla que es más peligroso que la alucinación vainilla, no menos.
No se trata de documentos recuperados contradictorios. Ese es un problema aparte que cubrí en Su sistema RAG recupera los datos correctos, pero aún produce respuestas incorrectas. Se trata de un modelo que recuperó exactamente el documento correcto y aun así respondió incorrectamente.
Esto sucede por razones que aún no se comprenden del todo: los mecanismos de atención se desvían hacia tokens irrelevantes, los sesgos de entrenamiento hacia ciertas frases, el modelo promedia señales conflictivas en contexto. Lo que importa para la producción es que esto sucede regularmente, no es predecible y la única forma confiable de detectarlo es verificar la respuesta final antes de que salga de su sistema.
Cuando estaba pasando por fallas de producción documentadas, seguían apareciendo cinco patrones.
La primera es confiar en las respuestas incorrectas. El modelo utiliza palabras como “definitivamente” o “claramente declarado” al tiempo que afirma algo que no tiene base en la fuente recuperada. El lenguaje asertivo es el problema: elimina cualquier señal de que la respuesta pueda ser incorrecta.
El segundo son las contradicciones fácticas. El contexto dice 14 días, la respuesta dice 30. El contexto dice facturación anual, la respuesta dice mensual. La fuente estaba allí. La modelo simplemente lo ignoró.
El tercero son las entidades alucinadas. Nombres de personas, citas en artículos, nombres de organizaciones que no aparecen en ninguna parte de los documentos recuperados. El modelo los inventa y los presenta como hechos.
El cuarto es la deriva de la respuesta. La misma pregunta recibe una respuesta diferente con el tiempo. Este es silencioso: no hay error, no hay bandera, nada. Por lo general, queda atrapado en una auditoría financiera o en una queja de un usuario, no en el sistema en sí.
El quinto es lo que yo llamo confiado pero infiel. El modelo parece seguro en toda la respuesta, pero la mayor parte de lo que dice no se puede rastrear hasta ninguna fuente recuperada. Alta confianza, baja base. Esa combinación es el patrón más peligroso que encontré.
La mayoría de los marcos de detección los marcan y los devuelven al código de su aplicación. Ninguno lo soluciona. Ésa es la brecha que cierra este sistema.
La arquitectura: detectar, puntuar, curar, encaminar
recuperar (consulta) → generar (consulta, fragmentos) → detector.inspect (consulta, fragmentos, respuesta) → QualityScore.compute(informe) → healer.heal(…) → ACEPTAR / HEALED_ACCEPT / FALLBACK / DESCARTAR
Quería que esto se ejecutara dentro de una solicitud FastAPI normal sin agregar dependencias externas ni gastar el presupuesto de latencia. Sin llamadas API, sin modelo integrado, sin juez de LLM. Toda la llamada inspect() se ejecuta en menos de 50 ms con spaCy, menos de 10 ms en el respaldo de expresiones regulares. Esa fue la restricción que diseñé.
Comprobación 1: puntuación de confianza
En un mundo perfecto, usaría logprobs para ver qué tan seguro está el modelo acerca de sus tokens. Pero en producción, la mayoría de las API no facilitan su obtención o agregación.
Necesitaba las herramientas de un pobre. Creé ConfidenceScorer para buscar exceso de confianza lingüística: marcadores de afirmación como "definitivamente" o "garantizado" se comparan con señales de incertidumbre como "podría" o "Creo". Conteo de palabras simple, normalizado por la longitud de la respuesta. Suena demasiado simple para funcionar, pero es sorprendentemente efectivo para atrapar al modelo cuando está faroleando.
def puntuación(self, respuesta: str) -> float: al = respuesta.lower() palabras = len(answer.split()) o 1 alto = suma(len(p.findall(al)) para p en self._HIGH_RE) bajo = suma(len(p.findall(al)) para p en self._LOW_RE) return max(0.0, min(1.0, 0.5 + min(high / (palabras / 10), 1,0) * 0,5 – min(bajo / (palabras / 10), 1,0) * 0,5 ))
Esta verificación se activa cuando la confianza supera 0,75 Y la fidelidad cae por debajo de 0,50. Esa combinación es lo que seguí viendo en los fallos que investigué: el modelo suena completamente seguro, mientras que la mayor parte de lo que dice no se puede rastrear hasta la fuente recuperada. Un alto nivel de confianza enmascara el problema. Eso es lo que hace que sea difícil de atrapar.
Comprobación 2: puntuación de fidelidad
FaithfulnessScorer divide la respuesta en oraciones de afirmación objetiva y luego verifica qué fracción de las palabras de contenido de cada afirmación aparecen en el contexto combinado.
def _claim_grounded(self, Claim: str, context_lower: str) -> bool: kw = _key_words(claim) si no kw: return True devuelve suma(1 para w en kw si w en context_lower) / len(kw) >= self.overlap_threshold
Un reclamo está fundamentado si al menos el 40% de sus palabras clave aparecen en la fuente. Puntuación = reclamaciones fundamentadas / reclamaciones totales.
Me decidí por el 40% porque da suficiente espacio para una paráfrasis natural sin dejar pasar la mentira. Si trabaja en contextos legales o médicos, comience en 0,70; parafrasearse es un riesgo allí. Las preguntas obtienen un pase gratuito en 1.0 ya que no afirman nada.
Comprobación 3: detección de contradicciones
Cuando estaba elaborando este cheque, necesitaba decidir qué es lo que realmente cuenta como una contradicción. Lo reduje a tres patrones que aparecían con mayor frecuencia en los fracasos que observé.
La primera son las contradicciones numéricas. La respuesta utiliza un número que no está en ningún fragmento recuperado y el mismo tema en el contexto tiene un número diferente cerca. Este es el caso más sencillo y el más común.
El segundo son los cambios de negación. El contexto dice "no admite X", la respuesta dice "admite X". Terminé comparando ocho patrones de negación bidireccionalmente (no, no puede, nunca, no, no, no lo hará, no lo hizo) con sus equivalentes positivos. Hacer esto bien requirió más iteraciones de las que esperaba.
La tercera son las contradicciones temporales. Misma unidad, diferente valor, mismo tema. El contexto dice 14 días, la respuesta dice 30 días. Eso es todo.
Un detalle de implementación que me causó problemas desde el principio: el extractor de números tiene que excluir los identificadores con prefijo de letras:
def _extract_numbers(text: str) -> set[str]: # SKU-441 → excluido (el prefijo de letra+guión significa que es una etiqueta) # 5-7 días → preservado (dígito+guión es un rango numérico) # $49.99 → preservado return set(re.findall( r'(?
Sin esta regla, cada código de producto que contenga un número genera un falso positivo. SKU-441 contiene 441: es una etiqueta, no un precio.
Comprobación 4: Detección de alucinaciones de entidades
Esta verificación extrae entidades nombradas de las personas, organizaciones y citas de respuesta y verifica que cada una aparezca en al menos un fragmento de contexto recuperado.
El cambio de expresiones regulares a NER spaCy no fue opcional. Esto es lo que producen los dos enfoques en la misma respuesta:
Respuesta: "El trabajo fundamental fue publicado por el Dr. James Harrison y el Dr. Wei Liu en arXiv:2204.09876, en DeepMind Research Institute". Contexto: "Estudios recientes muestran que los modelos de transformadores logran una precisión del 94% en tareas NER". ───────────────────────────────── ────────────────────────────────── REGEX NER (v1): falsos positivos en frases nominales ───────────────────────────────── ────────────────────────────────── Marcado como alucinado: Dr. James Harrison (persona) ✓ correcto Dr. Wei Liu (persona) ✓ correcto arXiv:2204.09876 (cita) ✓ correcto Escalado nombrado (persona) ✗ FALSO POSITIVO Reconocimiento de entidades (persona) ✗ FALSO POSITIVO NER Tasks (org) ✗ FALSO POSITIVO ───────────────────────────────── ────────────────────────────────── spaCy en_core_web_sm (producción) — salida limpia ───────────────────────────────── ────────────────────────────────── Marcado como alucinado: Dr. James Harrison (persona) ✓ correcto Dr. Wei Liu (persona) ✓ correcto arXiv:2204.09876 (cita) ✓ correcto DeepMind Research Institute (org) ✓ correcto
En v1, mi reserva de expresiones regulares era demasiado agresiva: marcaba frases como "Reconocimiento de entidad nombrada" como nombres de personas solo por las mayúsculas. La actualización al modelo estadístico de spaCy solucionó este problema. De hecho, entiende que 'Scaling Named' es un par sintáctico, no un ser humano. Este único cambio eliminó los falsos positivos que hacían que Check 4 fuera casi inutilizable en mis pruebas anteriores.
Comprobación 5: la parte que nadie supervisa
Este fue el último cheque que hice y el que casi corté.
No estaba seguro de que la detección de deriva valdría la complejidad. Tres meses después de realizar las pruebas, detectó un punto final de fijación de precios que devolvía silenciosamente un valor diferente después de una reconstrucción del índice de recuperación. Ninguno de los otros cuatro cheques se disparó. Eso fue suficiente para conservarlo.
La deriva no se trata de si una sola respuesta es correcta. Se trata de si su sistema se comporta de manera consistente a lo largo del tiempo.
AnswerDriftMonitor almacena huellas digitales ligeras de cada respuesta por pregunta en SQLite:
def _fingerprint(self, respuesta: str) -> dict: números = sorted(set(re.findall(r'bd+(?:.d+)?(?:%|k|m)?b', respuesta))) palabras = respuesta.lower().split() palabras clave = [w para w en palabras si len(w) > 5][:20] pos = suma(1 para w en palabras si w en self._POS) neg = suma(1 para w en palabras si w en self._NEG) return { "números": números, "palabras_clave": palabras_clave, "polaridad": "positiva" si pos > neg más ("negativo" si neg > pos más "neutral"), "length_bucket": len(respuesta) // 100, }
No quería almacenar el texto completo de la respuesta en la base de datos. Eso crece rápidamente y crea una superficie de privacidad que no necesitaba. Por lo tanto, la huella digital almacena solo lo que es necesario para detectar cambios significativos: los números en la respuesta, las 20 palabras principales del contenido, la polaridad general y un grupo de longitud. Eso es suficiente para captar una deriva real sin que la base de datos crezca ilimitadamente.
En cada nueva respuesta, el monitor la compara con las últimas 10 respuestas de esa pregunta. Si la distancia supera 0,35, eso es deriva. Si la similitud promedio cae por debajo de 0,65, eso también es una deriva. Ambos umbrales surgieron de pruebas, no de teoría.
El detalle crítico de producción: esto utiliza SQLite, no un diccionario en memoria. Una estructura en memoria se restablece con cada reinicio del proceso. En una implementación real con reinicios continuos, la detección de deriva nunca se activa. SQLite persiste en todas las implementaciones. Estás observando la degradación a lo largo de días, no de minutos.
# La prueba que detectó un error real durante la preparación def test_persistence_across_instances(self, tmp_path): db_file = str(tmp_path / "drift.db") mon1 = AnswerDriftMonitor(db_path=db_file) for _ in range(5): mon1.record(question, stable_answer) mon2 = AnswerDriftMonitor(db_path=db_file) # instancia nueva, mismo archivo detectado, delta = mon2.record(question, drifted_answer) afirmar detectado # el historial de mon1 todavía está ahí
Esta prueba existe porque mi entorno de preparación se reiniciaba cada 30 minutos y el monitor de deriva había estado ciego todo el tiempo.
La capa de autocuración
HallucinationHealer intenta una de las tres estrategias de solución deterministas y luego vuelve a inspeccionar el resultado. Si la respuesta curada aún no pasa la nueva inspección, sirve para un rechazo seguro en lugar de dar una respuesta incorrecta.
Orden de prioridad de curación:
Estrategia A: parche de contradicción
La estrategia A es una solución directa: si hay un número incorrecto en la respuesta, cámbielo por el correcto según el contexto. Suena simple, pero la normalización del ciclo de facturación fue una pesadilla.
La cuestión era el orden de las operaciones. Si ejecuté los patrones en la secuencia incorrecta, obtendría resultados desordenados como "suscripción anual" porque el sustantivo fue reemplazado antes de que se pudiera ajustar el adjetivo. Para solucionar este problema, hice que el sistema detectara primero la dirección general (anual versus mensual) y luego aplicara un único pase ordenado. Primero maneja patrones específicos antes de recurrir a los adjetivos. Esto resolvió la desviación gramatical que hacía que la parte de "autocuración" del sistema pareciera defectuosa.
Antes: "El plan Pro cuesta $10 por mes y se factura mensualmente. Puedes cancelar tu suscripción mensual en cualquier momento". Contexto: "El plan Pro cuesta $120 por año, facturado anualmente". Después: "El plan Pro cuesta $120 por año, facturado anualmente. Puede cancelar su suscripción anual en cualquier momento". Cambios registrados: — Se reemplazó '$10' → '$120' — Facturación normalizada: 'bpor mesb' → 'por año' — Facturación normalizada: 'bsuscripción mensualb' → 'suscripción anual' — Facturación normalizada: 'bfacturada mensualmenteb' → 'facturada anualmente' — Confianza recalibrada: 0,50 → 0,65 (contradiction_patch)
Estrategia B: limpieza de entidades
La estrategia B es más sencilla. Si la respuesta contiene entidades alucinadas, elimino las frases que las contienen. La parte en la que pensé detenidamente fue qué decirle al usuario. Eliminar oraciones silenciosamente se sentía mal: el usuario obtendría una respuesta más corta sin explicación. Así que agregué una nota de transparencia cada vez que se elimina algo, para que sepan que la respuesta fue recortada y por qué.
para enviado en oraciones: si hay (nombre.inferior() en enviado.inferior() para nombre en nombres_falsos): eliminado.append(enviado) más: clean.append(enviado) si se elimina: resultado += ( " Nota: no se pudieron verificar nombres o referencias " "específicos en los documentos fuente y se han omitido." )
Si cada oración de la respuesta contiene una entidad alucinada, fregar no produce nada. En ese caso, el sanador cae en un declive seguro en lugar de devolver una respuesta vacía.
Estrategia C: reescritura de la base
Cuando la fidelidad es inferior a 0,30, reconstruyo la respuesta desde cero utilizando las oraciones de contexto mejor clasificadas por palabra clave superpuesta con la pregunta. El prefijo importa aquí. No quería usar algo como "Basado en la información disponible" porque eso no le dice nada al usuario sobre de dónde proviene realmente la respuesta. Entonces, el prefijo se elige en función de lo que realmente contiene el contexto:
if re.search(r'$d+|d+%|d+s*(día|mes|año)', combinado_inferior): prefijo = "Según los datos proporcionados:" elif any(w en combinado_inferior para w en ("política", "directriz", "procedimiento", "regla")): prefijo = "Según la documentación fuente:" else: prefijo = "La fuente indica que:"
"Basado en la información disponible" no le dice nada al usuario sobre el origen de la información. Estos tres prefijos sí.
Recalibración de la confianza por estrategia
Después de la curación, la confianza se recalibra en función de lo que se hizo, no se vuelve a ejecutar a ciegas el texto curado:
Resultados curativos en los 5 escenarios
Escenario 1: mentira segura (30 días → 14 días) Inicial: CRÍTICO → parche_contradicción → Final: BAJA Confianza: 1,00 → 0,80 Escenario 2: cita alucinada: Dr. James Harrison, arXiv:2204.09876 El modelo inventó dos investigadores y una cita en artículo. Ninguno de ellos aparece en ninguna parte del contexto recuperado. Resultado esperado: grounding_rewrite reconstruye la respuesta a partir del contexto; la fidelidad de 0,00 activa la primera verificación de prioridad antes de que se consideren las entidades. Escenario 3: Contradicción de facturación ($10/mes → $120/año) Inicial: CRÍTICO → contradicción_patch → Final: BAJA Confianza: 0,50 → 0,65 Escenario 4: Desviación de respuesta (precio de SKU-441 divergido) Inicial: CRÍTICO → grounding_rewrite → Final: BAJA Confianza: 0,50 → 0,50 Escenario 5 — Respuesta limpia Inicial: BAJA → no_healing_needed → Final: BAJA Confianza: 0,62 → 0,62 (sin cambios)
Dos cosas que vale la pena señalar. El escenario 2 desencadena grounding_rewrite, noentity_scrub, porque la fidelidad era 0,00, lo que activa la primera verificación de prioridad antes de que se consideren las entidades. El escenario 4 es CRÍTICO en lugar de MEDIO porque la respuesta desviada también contiene una contradicción numérica ($39,99 frente a $49,99), por lo que la deriva y la contradicción juntas lo empujan a CRÍTICO. Estos son resultados reales de la demostración, no resúmenes ilustrativos.
Ejecutándolo usted mismo
Clona el repositorio y ejecuta los cinco escenarios:
git clone https://github.com/Emmimal/hallucination-detector.git cd detector-de-alucinaciones
Ejecute un único escenario con python demo.py –scenario 3. Esto es lo que el Escenario 3 (la contradicción de facturación) produce de principio a fin:
── DETECTAR ─────────────────────────── ─────────────────────────── Pregunta: ¿Cuánto cuesta el plan Pro? Riesgo: CRÍTICO Confianza: 0,50 Fiel: 0,50 Contradicción: Verdadero — Contradicción numérica: la respuesta usa '10' pero el contexto muestra '120' cerca de 'pro' Nombres falsos:[]Deriva: Falso (delta=0,00) Activado: ['contradicción'] Latencia: 11,9 ms ── PUNTUACIÓN ─────────────────────────── ──────────────────────────── Puntuación: 0,40 → HEALED_ACCEPT Componentes: fiel 0,20 / 0,40 consistente 0,00 / 0,30 confianza 0,10 / 0,20 latencia 0,10 / 0,10 ── HEAL ──────────────────────────── ──────────────────────────── Estrategia: contradiction_patch Riesgo inicial: CRÍTICO → Riesgo final: BAJO Antes: El plan Pro cuesta $10 por mes, facturado mensualmente. Puedes cancelar tu suscripción mensual en cualquier momento. Después: El plan Pro cuesta $120 por año, facturado anualmente. Puedes cancelar tu suscripción anual en cualquier momento. Cambios: — Se reemplazó '$10' → '$120' — Facturación normalizada: 'bpor mesb' → 'por año' — Facturación normalizada: 'bsuscripción mensualb' → 'suscripción anual' — Facturación normalizada: 'bfacturada mensualmenteb' → 'facturada anualmente' — Confianza recalibrada: 0,50 → 0,65 (contradiction_patch)
Riesgo CRÍTICO adentro, riesgo BAJO afuera. La respuesta incorrecta se corrige in situ, el lenguaje del ciclo de facturación se normaliza en todo momento y cada cambio se registra. El usuario obtiene una respuesta corregida. Obtendrá un registro completo de exactamente qué estuvo mal y qué se cambió.
Puntuación de calidad y ruta de entrega
Pasar/fallar no es suficiente para una implementación real. Necesita saber no sólo si una respuesta falló, sino también en qué medida y qué hacer al respecto.
QualityScore calcula un compuesto ponderado que dirige cada respuesta a uno de los cuatro niveles de entrega:
puntuación_final = 0,40 × fidelidad + 0,30 × coherencia (0,0 si se encuentra contradicción) + 0,20 × confianza (calibrada en función del nivel de fidelidad) + 0,10 × puntuación_latencia (curva de penalización no lineal) − 0,20 × penalización_deriva (deducción explícita, aplicada en último lugar)
Registre cada HEALED_ACCEPT por separado de ACCEPT. Son su señal de lo que el modelo siempre hace mal.
Penalización de latencia: puntuación máxima por debajo de 20 ms (Python puro, expresión regular NER), caída lineal de 0,10 a 0,05 en la banda de 20 a 50 ms (ejecución espacial), caída pronunciada hacia 0,00 a 200 ms. La interrupción de 50 ms refleja una restricción de producción real: ahí es donde SpaCy NER comienza a aparecer en un presupuesto de latencia FastAPI típico.
Deducción por deriva: Aplicada en último lugar. La deriva es un centinela del estado de la tubería de recuperación. Una respuesta actualmente basada en un oleoducto degradante aún debería encaminarse hacia un recurso alternativo, porque la inconsistencia pasada predice falta de confiabilidad futura. El -0,20 se aplica después de todos los demás componentes para que pueda llevar cualquier respuesta por debajo del umbral independientemente de la fidelidad actual.
Características de rendimiento
Medido en Python 3.12, solo CPU, sin GPU:
Si necesita menos de 10 ms de un extremo a otro, el respaldo de expresiones regulares NER es un cambio de configuración de una línea. Cambia algo de precisión de detección de entidades por latencia. Para la mayoría de las implementaciones orientadas al cliente, la ruta spaCy de menos de 50 ms no añade ningún retraso perceptible.
Las pruebas: 70 casos, no demostraciones.
Cada fallo de producción nombrado tiene una afirmación. Esto es lo que cubren esas 70 pruebas:
TestConfidenceScorer (5 pruebas) — puntuaciones de respuestas asertivas altas — puntuaciones de respuestas cubiertas bajas — puntuación limitada de 0,0 a 1,0 en todas las entradas — cadena vacía manejada — respuesta Unicode manejada TestFaithfulnessScorer (5 pruebas) — puntuaciones de respuestas fundamentadas ≥ 0,80 — puntuaciones de respuestas fabricadas ≤ 0,50 con lista no fundamentada — la respuesta vacía devuelve una puntuación perfecta — respuesta de solo pregunta excluida de las afirmaciones — configuración de umbral estricta produce puntuaciones más bajas TestContradictionDetector (7 pruebas) — contradicción numérica detectada con razón — números coincidentes pasan limpiamente — inversión de negación detectada — contradicción temporal detectada — respuesta limpia pasa — contexto vacío manejado — respuesta vacía manejada TestEntityHallucinationDetector (5 pruebas) — persona inventada marcada — cita inventada marcada — entidad presente en el contexto no marcada — la respuesta vacía devuelve una lista vacía — palabras comunes del discurso no falsas positivas TestAnswerDriftMonitor (6 pruebas) — sin desvío en la primera respuesta — sin desvío en respuestas consistentes — desvío detectado después de un cambio significativo — diferentes preguntas no interfieren — persistencia entre instancias (SQLite, nuevo objeto, mismo archivo) — historial limpio se restablece a cero TestHallucinationDetector (24 pruebas) — 5 escenarios de producción con niveles de riesgo correctos — riesgo CRÍTICO en contradicción — riesgo BAJO en respuesta limpia — respuesta vacía, contexto vacío, respuesta muy larga, Unicode, palabra única — La excepción HallucinationBlocked lleva el informe completo — estricto config desencadena confident_but_unfaithful – seguimiento y restablecimiento de estadísticas – inspect() concurrente de 20 subprocesos con cero errores – ainspect() devuelve el informe correcto – ainspect() detecta alucinaciones correctamente – asyncio.gather() con 10 llamadas simultáneas de ainspect() – informes de estadísticas NER backend correctamente TestQualityScore (18 pruebas) – ACEPTAR en puntaje alto – FALLBACK en puntaje bajo – HEALED_ACCEPT cuando se aplica la curación – DESCARTAR cuando se cumpla el declive seguro: puntuación máxima de latencia por debajo de 20 ms; latencia reducida a 35 ms, mínimo por encima de 50 ms: penalización pronunciada de latencia a 60 ms, mínimo a cero; la deriva resta exactamente 0,20, mínimo a cero: el parche_contradicción aumenta la confianza, tiene un límite de 0,80; el exfoliante de entidad reduce la confianza en un factor de 0,85; to_dict incluye el campo drift_penalty
Dos de estas pruebas existen debido a errores reales que cometí durante el desarrollo. La prueba de seguridad de subprocesos ejecuta 20 llamadas inspect() simultáneas porque inicialmente tenía una condición de carrera que solo aparecía bajo carga, no en pruebas normales de llamada única. La prueba de persistencia de SQLite crea una nueva instancia de monitor que apunta al mismo archivo de base de datos porque mi entorno de prueba se reiniciaba cada 30 minutos y descubrí que el monitor de deriva había estado completamente ciego todo el tiempo. Un diccionario en memoria se reinicia al reiniciar. SQLite no lo hace. Ambas pruebas están ahí porque los errores ya ocurrieron una vez.
============================= 70 aprobado ==============================
Límites honestos y decisiones de diseño.
Saber qué no capta un sistema es tan importante como saber qué sí lo hace. Cada umbral en DetectorConfig es un punto de partida deliberado, no un número arbitrario.
¿Por qué un umbral de confianza de 0,75? Por debajo de esto, la mayoría de las respuestas contienen suficiente cobertura natural para evitar falsos positivos. Por encima de esto, una alta asertividad combinada con una baja fidelidad es el patrón que vi con más frecuencia en los fracasos que investigué. Bájalo a 0,60 para dominios de alto riesgo donde vale la pena la carga de revisión adicional al marcar antes.
¿Por qué se superpone la fidelidad de 0,40? Este es el mínimo requerido para tolerar la paráfrasis natural sin señalar falsamente respuestas fundamentadas que utilizan una redacción diferente. Los despliegues legales y médicos deberían comenzar en 0,70; en esos ámbitos, la paráfrasis es en sí misma un riesgo, no una tolerancia.
¿Por qué un umbral de deriva de 0,35? Ajustado empíricamente en un pequeño conjunto de consultas. Un umbral más estricto (0,20) se activa demasiado pronto durante la variación rápida normal. Un umbral más flexible (0,50) pasa por alto la degradación real. Su valor correcto depende de cuánta variación natural produzca su LLM para la misma pregunta.
Lo que esto no captará:
Alucinaciones seguras y constantes. Si el modelo siempre dice "30 días" y el contexto también dice "30 días", todas las comprobaciones pasan. Este sistema asume que el contexto recuperado es correcto. No puede detectar una mala recuperación, sólo respuestas que se desvían o contradicen lo recuperado.
Paráfrasis inventiva que cambia de significado. Con una superposición del 40% de palabras clave, una invención cuidadosamente redactada técnicamente puede pasar la prueba de fidelidad. El umbral es un dial: sintonícelo en muestras etiquetadas de su dominio.
Negación con desajustes derivados. El detector de negación busca "puede cancelarse", no "puede cancelarse". Una frase como “puedes cancelar” técnicamente se escapa. La derivación antes de la coincidencia de patrones cierra esta brecha y está en la hoja de ruta para la versión 4.
Deriva como indicador de seguimiento. El monitor de deriva requiere al menos tres respuestas previas antes de activarse. Algunas malas respuestas se darán antes de que las detecten. Te dice cuándo investigar. No previene las primeras fallas después de un cambio de tubería.
Instalación y uso
pip instalar spacy python -m descarga spacy en_core_web_sm
No hay dependencias de pip adicionales más allá de spaCy. SQLite viene con la biblioteca estándar de Python.
Uso básico:
from hallucination_detector import (HalucinationDetector, HallucinationHealer, DetectorConfig, QualityScore) config = DetectorConfig(db_path="drift.db", log_flagged=True) detector = HallucinationDetector(config) healer = HallucinationHealer(detector) # Inspeccionar cada respuesta de LLM antes de la entrega report = detector.inspect(question, context_chunks, llm_answer) puntuación = QualityScore.compute(report) if puntuación.routing == "aceptar": devolver llm_answer # Intentar resultado de curación = healer.heal(question, context_chunks, llm_answer, informe) puntuación = QualityScore.compute(report, Healing_result=result) if puntuación.routing == "healed_accept": devolver resultado.healed_answer devolver respuesta_alternativa
Asíncrono (FastAPI):
informe = espera detector.ainspect(pregunta, context_chunks, llm_answer)
Registro JSON estructurado:
from hallucination_detector import configure_logging import logging configure_logging(level=logging.WARNING) # Cada respuesta marcada emite una ADVERTENCIA JSON estructurada con el informe completo
Bloqueo de riesgo crítico:
if report.is_hallucinating: rise HallucinationBlocked(report) # HallucinationBlocked.report lleva el dict completo para su capa de monitoreo
Modo estricto para contextos legales o médicos:
config = DetectorConfig( Faithness_threshold=0.70, # arriba desde 0.50 Faithness_overlap_threshold=0.70, # arriba desde 0.40trust_threshold=0.60, # abajo desde 0.75 – marcar antes drift_threshold=0.25, # abajo desde 0.35 – más sensible db_path="drift_production.db", log_flagged=True, )
¿Qué sigue?
Hay tres cosas en mi lista para la próxima versión. El primero es mostrar los cambios curativos directamente al usuario; en este momento, las correcciones se realizan de forma silenciosa, lo que parece incorrecto en dominios donde los usuarios necesitan saber que el modelo estaba equivocado. El segundo es agregar señales de deriva entre preguntas en lugar de por pregunta, de modo que pueda detectar cuándo un almacén de documentos completo comienza a degradarse en lugar de detectar una pregunta a la vez. El tercero es un arnés de calibración que genera curvas de precisión/recuperación a partir del tráfico real, por lo que el ajuste del umbral no tiene que realizarse manualmente.
Cierre
Construí esto porque lo necesitaba. Cuando construyes un sistema RAG en el que los estudiantes realmente confiarán, no puedes darte el lujo de enviar respuestas que no hayas inspeccionado. El modelo recuperará el documento correcto y aun así generará algo incorrecto. Ese no es un error que puedas corregir a nivel de modelo. Es una propiedad de cómo funcionan estos sistemas.
Las 70 pruebas no prueban que este sistema sea perfecto. Son una prueba de que entiendo exactamente qué detecta y qué no, y que cada patrón de falla que encontré durante la investigación ahora tiene una afirmación con nombre.
recuperar() → generar() → inspeccionar() → puntuación() → curar() → entregar
La modelo alucinará. La recuperación fallará.
La pregunta es si lo detecta antes que sus usuarios.
El código fuente completo: https://github.com/Emmimal/hallucination-detector/
Referencias
Lewis, P., Pérez, E., Piktus, A., Petroni, F., Karpukhin, V., Goyal, N.,… y Kiela, D. (2020). Generación de recuperación aumentada para tareas de PNL intensivas en conocimiento. Avances en los sistemas de procesamiento de información neuronal, 33, 9459–9474. https://arxiv.org/abs/2005.11401 Honovich, O., Aharoni, R., Herzig, J., Taitelbaum, H., Kukliansy, D., Cohen, V., Scialom, T., Szpektor, I., Hassidim, A. y Matias, Y. (2022). VERDADERO: Reevaluación de la evaluación de la coherencia fáctica. Actas de NAACL 2022, 3905–3920. https://arxiv.org/abs/2204.04991 Min, S., Krishna, K., Lyu, X., Lewis, M., Yih, W., Koh, PW,… y Hajishirzi, H. (2023). FActScore: Evaluación atómica detallada de la precisión fáctica en la generación de texto de formato largo. Actas de EMNLP 2023, 12076–12100. https://arxiv.org/abs/2305.14251 Manakul, P., Liusie, A. y Gales, MJF (2023). SelfCheckGPT: Detección de alucinaciones de caja negra sin recursos para modelos generativos de lenguaje grande. Actas de EMNLP 2023. https://arxiv.org/abs/2303.08896 Honnibal, M., Montani, I., Van Landeghem, S. y Boyd, A. (2020). spaCy: Procesamiento de lenguaje natural de potencia industrial en Python. Zenodo. https://doi.org/10.5281/zenodo.1212303 Gao, Y., Xiong, Y., Gao, X., Jia, K., Pan, J., Bi, Y.,… & Wang, H. (2023). Generación de recuperación aumentada para modelos de lenguaje grandes: una encuesta. Preimpresión de arXiv arXiv:2312.10997. https://arxiv.org/abs/2312.10997 Es, S., James, J., Espinosa-Anke, L. y Schockaert, S. (2023). Ragas: Evaluación automatizada de recuperación de generación aumentada. Preimpresión de arXiv arXiv:2309.15217. https://arxiv.org/abs/2309.15217
Divulgación
Soy un investigador independiente de IA y fundador de EmiTechLogic (emitechlogic.com). Este proyecto se construyó como parte de mi investigación sobre fallas del sistema RAG mientras desarrollaba un asistente impulsado por RAG para EmiTechLogic. Los patrones de falla descritos en este artículo se investigaron a partir de fallas de producción documentadas en el campo y se reprodujeron en código. El código fue escrito y probado localmente en Python 3.12 en Windows usando PyCharm. Todas las bibliotecas utilizadas son de código abierto con licencias permisivas (MIT). El modelo spaCy en_core_web_sm se distribuye bajo la licencia MIT por Explosion AI. No tengo ninguna relación financiera con ninguna biblioteca o herramienta mencionada. Repositorio de GitHub: https://github.com/Emmimal/hallucination-detector. Comparto este trabajo para documentar un patrón que les cuesta tiempo real a los equipos reales, no vender un producto o servicio.