Creación de un sistema de preguntas y respuestas con citas a partir de PDFs de bancos centrales utilizando RAG
Una implementación práctica de RAG basada en informes trimestrales de los bancos centrales: ingestión de datos, recuperación sensible a la recencia, respuestas citadas y los errores prácticos encontrados en el proceso.
La mayoría de los tutoriales sobre RAG se basan en datos organizados y sintéticos. Los documentos reales se comportan de manera diferente, y la brecha entre comprender la generación mejorada por recuperación de información y poder implementarla realmente solo se hace evidente cuando alguien desarrolla todo el sistema a partir de un corpus desorganizado. Esa fue la motivación detrás de bspQA, un sistema de respuesta a preguntas basado en los informes trimestrales de política monetaria del Bangko Sentral ng Pilipinas, el banco central de Filipinas.
El corpus constituye un buen objetivo de aprendizaje. Cada informe abarca de 60 a 70 páginas de análisis económico detallado, y diecisiete de ellos datan de varios años atrás; todos son de acceso público. El lenguaje es específico del campo, hay muchos archivos, y el problema de recuperación es real, ya que nadie puede pegar diecisiete informes en una ventana de contexto. El objetivo era modesto: hacer una pregunta como “¿Cuál era la perspectiva de inflación para 2026?” y recibir una respuesta fundamentada y citada en cuestión de segundos, en lugar de tener que buscar con Ctrl+F entre montones de PDFs.
El patrón en un párrafo
RAG vincula las respuestas de un LLM a un conjunto específico de documentos, en lugar de a todo lo que absorbió durante su entrenamiento. Los documentos se convierten en embeddings vectoriales con antelación. Cuando llega una pregunta, se recuperan los fragmentos más cercanos a ella, se les pasa al modelo como contexto y se le indica al modelo que responda únicamente a partir de ese contexto. La ventaja es un sistema que se niega a inventar información que los documentos no contienen.
Dos procesos, separados
El diseño separa deliberadamente dos tareas.
- La ingestión se realiza de forma offline, una vez por cada nuevo documento. Descarga los PDF, los convierte en texto plano, divide el texto en fragmentos, crea embeddings y almacena todo en Qdrant.
- La consulta se ejecuta en tiempo real con cada solicitud. Se convierte la pregunta en un embedding, se recuperan los fragmentos relevantes y se genera la respuesta. En esta etapa nunca se manipulan los PDF originales.
Desarrollo de la ingestión
Paso de descarga
Un archivo manifest.json es la fuente de verdad. Cada informe cuenta con una entrada que contiene su URL, una etiqueta con un punto y su fecha de publicación. Las URLs se pegaron a mano, lo cual es tan poco impresionante como suena.
{
"doc_id": "bsp-mpr-2023-q3",
"title": "Monetary Policy Report Q3 2023",
"period_label": "Q3 2023",
"publication_date": "2023-08-17",
"url": "https://www.bsp.gov.ph/..."
}
El recopilador omite los archivos que ya se encuentran en el disco, por lo que ejecutarlo nuevamente no causa problemas, y agregar un informe significa incluir un elemento más en la lista de documentos.
Paso de análisis
MarkItDown de Microsoft convierte los PDF en texto. Para los informes de políticas con mucho texto, realiza un trabajo decente: los encabezados se mantienen, los saltos de párrafo aparecen limpios, y las tablas solo se conservan de forma parcial.
MarkItDown también es una dependencia pesada que implica la inclusión de muchos paquetes. Dado que el análisis solo ocurre durante la ingestión y nunca al responder consultas, se encuentra en un grupo de dependencias opcionales, por lo que el backend alojado en Render no carga con ese peso. Vale la pena adoptar desde temprano la costumbre de decidir qué paquetes pertenecen al entorno de ingestión y cuáles al de servicio, ya que los dos no necesitan ser idénticos. También existe un paso de limpieza entre el análisis y la división en fragmentos, y la razón de ello se explica a continuación.
Fragmentación, incrustación e inserción
Las decisiones reales comienzan aquí. Los fragmentos provienen de una ventana deslizante de 512 tokens con 64 tokens de solapamiento, contados con tiktoken.
def chunk_text_by_tokens(text, enc, chunk_size=512, overlap=64):
tokens = enc.encode(text)
stride = chunk_size - overlap
chunks = []
start = 0
while start < len(tokens):
window = tokens[start : start + chunk_size]
chunks.append(enc.decode(window))
if start + chunk_size >= len(tokens):
break
start += stride
return chunks
La superposición garantiza que una oración cortada en un límite de bloque siga apareciendo completa en al menos un bloque.
Los embeddings provienen de OpenAI’s text-embedding-3-small, que es económico y eficaz para textos densos relacionados con políticas. Todo el proyecto costó unos diez dólares en créditos, aunque el recargo mínimo en ese momento era de cinco, y eso es suficiente para un proyecto pequeño.
Cada vector recibe un identificador UUID5 derivado del nombre del archivo de origen y el índice del bloque. Eso hace que la reingestión sea segura, ya que una segunda ejecución sobrescribe los registros existentes en lugar de acumular duplicados.
Cada carga almacenada incluye el archivo de origen, el índice del fragmento, el texto, el modelo de incrustación, la etiqueta del período, una clave de período normalizada como q3_2023, y la fecha de publicación en formato ISO 8601. Qdrant almacena índices de palabras clave tanto para el archivo de origen como para la clave del período, además de un índice de fecha y hora para la fecha de publicación, lo que permite que el filtrado de metadatos sea rápido al realizar consultas.
Recuperación de datos y la trampa de la recencia
Una configuración simple devuelve los K resultados principales según la similitud coseno. Esto funciona hasta que el corpus abarca varios años y alguien solicita la perspectiva actual sobre la inflación. Si el banco utilizó un lenguaje casi idéntico al hablar de inflación en el primer trimestre de 2022 y en el tercer trimestre de 2024, el fragmento de 2022 podría obtener una puntuación más alta gracias a una redacción ligeramente mejor, aunque lo más probable es que el lector quiera la información más reciente.
La solución es obtener más datos de los solicitados y volver a clasificarlos. El sistema obtiene tres veces la cantidad de candidatos solicitada y luego vuelve a calificar a cada uno combinando la relevancia coseno con un valor de recencia normalizado.
RECENCY_WEIGHT = 0.85
def combined_score(hit):
relevance = hit.score / max_score
recency = (pub_date - min_date).days / date_span_days # 0.0 to 1.0
return (1 - RECENCY_WEIGHT) * relevance + RECENCY_WEIGHT * recency
Un peso de recencia de 0.85 significa que se da una gran preferencia a los documentos más recientes, a menos que uno más antiguo sea mucho más relevante. En un corpus de políticas donde cada informe sustituye efectivamente al anterior, ese equilibrio parece adecuado.
Los filtros opcionales permiten al usuario limitar la búsqueda a un período específico o a un rango de fechas:
# scope to Q1 2023 only
retrieve_chunks(query, period_label_key="q1_2023")
# scope to a date range
retrieve_chunks(query, publication_date_from="2023-01-01", publication_date_to="2023-12-31")
Sin filtros, el sistema funciona como un asistente de “última vista”. Con ellos, se convierte en una herramienta de investigación histórica.
Generación
El generador está diseñado de forma sencilla. Los fragmentos obtenidos se formatean como fuentes numeradas y se envían a gpt-4o-mini mediante una instrucción del sistema que, en resumen, dice: utilizar solo las fuentes proporcionadas, no incorporar conocimientos externos, citar cada afirmación factual directamente en el texto e indicarlo cuando las fuentes no contengan información suficiente.
Esa última cláusula merece una atención especial. Una instrucción estrictamente limitada es la defensa más práctica contra las alucinaciones. Si la búsqueda no devuelve nada relevante, el sistema responde con un mensaje predefinido en lugar de improvisar.
Las respuestas de la API contienen el texto de la respuesta además de una lista estructurada de fuentes, donde cada una indica el período al que pertenece, cuándo fue publicada y dónde se encuentra el PDF de la fuente. En la interfaz frontal, estas se convierten en citas clickeables, lo que permite rastrear cada afirmación.
Problemas que surgieron en la práctica
Elementos de la página que contaminan los fragmentos
Cada página de cada informe tiene un encabezado que dice “Clasificación: GENERAL” y un pie de página con el formato “Informe de Política Monetaria - T3 2023 | 47”. Después del análisis, estas líneas aparecen como texto ordinario y se incluyen en los fragmentos, desperdiciando recursos en información irrelevante. De hecho, el número de página en ese pie de página podría permitir citas más precisas, una idea que solo se tuvo en consideración tarde.
La solución es realizar una pasada de limpieza antes de dividir el contenido en fragmentos:
def _clean_parsed_text(text):
text = text.replace("\x0c", "\n\n")
text = re.sub(r"Classification:\s*GENERAL\s*\n", "", text)
text = re.sub(r"Monetary Policy Report\s*[--][^\n]+\|\s*\d+\s*\n", "", text)
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip()
Es obvio con el tiempo y fácil pasar por alto si el texto analizado nunca se inspecciona antes de incrustarlo. El síntoma era extraño: durante las pruebas de respuestas, el sistema parecía utilizar otros fragmentos en lugar del de mayor calificación. Los pies de página dividían los párrafos a mitad de frase, lo que alteraba silenciosamente el contexto que representaba cada fragmento.
Borrado accidental
Qdrant ofrece una bandera “recreate-collection” que elimina y reconstruye toda la colección. Útil en el desarrollo, pero catastrófico en producción. Actualmente, una protección ignora esta bandera siempre que el entorno parece ser de producción:
def _should_recreate_qdrant_collection():
if _is_production_environment():
return False
explicit = os.environ.get("QDRANT_RECREATE_COLLECTION", "").lower()
if explicit in ("1", "true", "yes"):
return True
app_env = (os.environ.get("ENVIRONMENT") or "").lower()
return app_env in ("development", "dev", "local")
En un proyecto con un único usuario, la protección implementada podría considerarse sobrediseñada, pero cuesta casi nada.
Duplicados después de la reingestión
Después de editar una entrada del manifiesto y volver a ejecutar la ingestión, existían dos conjuntos de fragmentos para el mismo PDF. La solución es eliminar los fragmentos existentes del archivo de origen antes de insertar los nuevos:
client.delete(
collection_name=collection,
points_selector=models.Filter(must=[
models.FieldCondition(
key="source_file",
match=models.MatchValue(value=source_file)
)
]),
)
Junto con los identificadores UUID5 estables, la reingestión ahora es idempotente.
Arranques en frío en la versión gratuita
El backend se encuentra en la versión gratuita de Render, la cual se apaga cuando no hay actividad. La primera solicitud después de un arranque en frío puede tardar de 30 a 60 segundos, tiempo suficiente como para que un visitante concluya que la aplicación está rota. Un endpoint /health sencillo resuelve gran parte de este problema: el frontend lo consulta al cargar la página, activando el servidor antes de que se envíe la primera consulta. Eso supone una solicitud HTTP adicional por sesión. La interfaz de usuario también incluye una breve nota que explica que la primera consulta puede tardar de medio minuto a un minuto mientras el backend se activa.
Elegir el tamaño del bloque
El valor de 512 tokens se determinó mediante experimentos. Los bloques de 128 a 256 tokens eran más precisos, pero a menudo perdían el contexto necesario para ser interpretados por sí solos. Los bloques de 1024 tokens o más incluían texto adyacente no relacionado. La superposición de 64 tokens suaviza los cortes bruscos en los límites y justifica el espacio adicional de almacenamiento.
Qué cambiar la próxima vez
- Primero, crear un conjunto de evaluación. Existe una carpeta denominada evals, pero no se elaboró ningún conjunto de preguntas de referencia antes de ajustar el peso de la recencia o el tamaño de los bloques, por lo que ambas decisiones son suposiciones basadas en información limitada. Un verdadero conjunto de evaluación serviría como base para tomar decisiones más fundamentadas.
- Agregar un reclasificador. Incorporar la recencia en la puntuación es solo una heurística. Un codificador cruzado como Cohere Rerank, o un modelo local, podría calificar tanto la consulta como los bloques juntos y determinar su relevancia de manera más fiable.
- Pruebe la búsqueda híbrida. Los vectores densos pueden omitir términos exactos. Una frase como una tasa de recompra inversa del 7.00% es difícil de recuperar si las palabras que la componen se formulan de manera diferente. Combinar la coincidencia de palabras clave BM25 con la búsqueda vectorial cerraría esa brecha, y Qdrant ofrece esta funcionalidad de forma predeterminada.
- Transmita las respuestas de forma continua. gpt-4o-mini tarda entre dos y cinco segundos en responder. Transmitir los tokens a medida que llegan hace que la interfaz parezca más rápida, incluso cuando el tiempo total permanece igual.
Reutilización del diseño
La arquitectura es genérica. Basta con reemplazar el manifiesto por otro corpus de documentos, actualizar la descripción del dominio en el prompt del sistema, y se obtiene un sistema de preguntas y respuestas funcional con cualquier conjunto de PDFs. Las partes más complejas, como el segmentado preciso, la recuperación basada en la recencia, el filtrado de metadatos y la reingestión segura para producción, ya están gestionadas.
Leer más
- Notas prácticas: Construyendo un pipeline local RAG de calidad producción — 100% gratuito, sin — Guía paso a paso de las Notas prácticas: Construyendo un pipeline local RAG de calidad producción — 100% gratuito, sin: contratos, verificaciones y espacios para código listo para usar para los equipos que implementan este patrón.
- Construyendo sistemas RAG que resisten en producción: desde la ingesta hasta la observabilidad — Desde el procesamiento en bloques y la recuperación híbrida hasta el reclasificado, el control de acceso, los conjuntos de evaluación y el rastreo: las capas que diferencian una demostración RAG de un sistema de producción.