ragpythonevaluacionpgvectorllm

Cuatro bugs que solo encontré cuando medí mi RAG

Tenía un RAG andando sobre un corpus de 271 tesis de la Universidad de Córdoba, FastAPI con pgvector, búsqueda híbrida, un grafo con LangGraph. Probándolo a ojo respondía bien, así que asumí que estaba bien. Después hice lo que no había hecho, medirlo de verdad, y aparecieron cuatro bugs que el código nunca me mostró, porque el código se veía correcto en los cuatro casos.

Esto es lo que encontré, y por qué medir primero cambió todo.

La regla: no optimices lo que no puedes medir

Antes de tocar una sola línea armé un instrumento, un conjunto de preguntas reales sobre el corpus con la respuesta correcta anotada, y unas métricas simples que corren con un comando. Sin ese instrumento, “lo mejoré” es una corazonada. Con él, cada cambio tiene un número antes y un número después. Todo lo que sigue salió de comparar contra ese baseline.

Bug 1: mi búsqueda híbrida no era híbrida

El sistema combinaba búsqueda vectorial (por significado) con búsqueda léxica (por palabras exactas), y las fusionaba. En teoría. Cuando medí cada una por separado, la vectorial sola y la híbrida daban resultados idénticos, hasta el último decimal. La rama léxica no estaba aportando nada.

La causa era de esas que dan risa y dolor a la vez. El corpus está en español, con tildes, “orientación”. Pero las consultas llegan sin tildes, “orientacion”. Y el full-text de Postgres, sin la extensión unaccent, trata esas dos palabras como distintas. Medido: buscar “orientacion” devolvía cero resultados, buscar “orientación” devolvía cuarenta y nueve. Como la gente escribe sin tildes, la mitad léxica de mi búsqueda estaba muerta y yo no lo sabía.

El fix fue una configuración de búsqueda que normaliza acentos en los dos lados. El efecto, medido sobre las preguntas de contenido, el híbrido pasó de recall@3 0.93 a 1.00, y por fin le ganó a la vectorial sola. La búsqueda híbrida solo vale la pena si de verdad es híbrida, y solo lo confirmas midiendo la ablación.

Bug 2: encontraba la tesis, pero no respondía su contenido

Segundo hallazgo, más de fondo. Mi corpus tenía en promedio dos fragmentos por tesis, porque solo había indexado el título y el resumen, nunca el cuerpo del PDF. El sistema encontraba la tesis correcta el 88% de las veces, pero si le preguntabas algo específico del contenido, “qué APIs de inteligencia artificial integró este proyecto”, no tenía con qué responder, porque esa información nunca entró al índice.

Traje el texto completo desde el repositorio (que expone el texto ya extraído, sin tener que parsear PDFs), y pasé de dos a treinta y cinco fragmentos por tesis en promedio. Esa misma pregunta ahora recupera el pasaje exacto del cuerpo, “se integraron las APIs de OpenAI y DeepSeek”.

Dos cosas honestas que solo supe corriéndolo. La primera, el 66% de las tesis no tiene texto extraíble, sus PDFs están escaneados sin OCR, así que el salto de contenido solo aplica a un tercio del corpus. La segunda, el recall a nivel tesis bajó de 0.95 a 0.81, porque con dieciséis veces más contenido entra ruido en el primer resultado. El recall@3 se mantuvo perfecto, que es lo que ve el modelo, así que el cambio valió, pero es un trade-off real y lo dejé escrito, no lo escondí.

Bug 3: mi filtro anti-alucinación era decorativo

El grafo tenía un paso llamado grade, que debía decidir si el contexto recuperado alcanzaba para responder, y si no, rechazar la pregunta en vez de inventar. Usaba un umbral de distancia coseno, si el mejor fragmento no pasaba de 0.35, se rechazaba.

Para probarlo le hice preguntas cuya respuesta no existe en el corpus, la distancia de la Tierra a Marte, la receta de la paella, quién ganó el mundial del 86. Las seis pasaron el umbral con puntajes entre 0.44 y 0.63. Resulta que con embeddings multilingües hasta una pregunta de otro planeta puntúa alto en coseno, así que el umbral de 0.35 nunca discriminaba nada. El filtro no filtraba.

Peor, una pregunta se coló entera, el modelo respondió la distancia a Marte con datos de su propia memoria, no del corpus. Eso es exactamente lo que un RAG no debe hacer. Lo único que salvó a las otras cinco fue una instrucción en el prompt, no la arquitectura, y eso es frágil.

Cambié el umbral de coseno por un juicio del propio modelo, le paso el contexto y la pregunta y le pido que decida si el contexto responde. Medido, el rechazo en las preguntas trampa pasó de 5 de 6 a 6 de 6, la alucinación de Marte desapareció, y no rompí ninguna pregunta válida.

Bug 4: el precio de esa robustez

Ese arreglo no es gratis, y quise saber cuánto cuesta, no suponerlo. Perfilé la latencia por etapa. El cuello no es la base de datos ni la búsqueda, es el modelo, y el nuevo grade por LLM se lleva el 47% del tiempo total de una consulta. Casi la mitad. Es el precio explícito de no alucinar, y ahora lo tengo en un número, así que puedo decidir si lo bajo con un modelo más pequeño solo para ese paso, en vez de discutirlo por intuición.

Lo que me llevo

Los cuatro bugs tenían algo en común, el código se veía bien en todos. La búsqueda híbrida estaba escrita correctamente, el grade tenía su umbral, la ingesta corría sin errores. Ninguno se veía leyendo. Los cuatro aparecieron al medir, y varios recién cuando probé los casos incómodos, las preguntas sin tilde, las preguntas de contenido, las preguntas de otro planeta.

Medir no es un paso de calidad que se hace al final. Es el instrumento que te muestra lo que el código esconde. Y ser honesto con lo que la medición dice, el 66% sin texto, el recall que bajó, el 47% que cuesta el filtro, es lo que separa un número que impresiona de uno en el que se puede confiar.

El código y todas las mediciones están abiertas en el repositorio, con la bitácora completa de cada antes y después.

compartir

X LinkedIn
Volver al blog