
Proyecto IA Monterroso (IX): ordenar 75 documentos para que el RAG crezca sin miedo
En la entrada anterior dejamos MonteIA respondiendo en directo, palabra por palabra. Hoy tocaba algo menos vistoso pero más importante: decidir, de los 75 documentos reales del centro, cuáles puede leer el RAG y cuáles no — y cambiar el código para que esa decisión sea fácil de mantener en el futuro, no una lista escrita a mano que alguien tiene que recordar actualizar.
Qué vamos a hacer
Hasta ahora el RAG solo indexaba 5 documentos, elegidos a mano como primera prueba. El objetivo de hoy era doble: revisar los 70 restantes uno a uno para decidir cuáles son aptos, y cambiar rag.py para que añadir o quitar documentos del RAG no exija tocar código nunca más.
El criterio: “público” no es lo mismo que “apto para una IA”
Antes de clasificar nada, hubo que aclarar una confusión razonable: todos estos documentos están colgados en la web del instituto, así que en principio son “públicos”. Pero eso no significa que sea buena idea dárselos a una IA generativa para que sintetice respuestas a partir de ellos. La pregunta correcta no es “¿puede verlo cualquiera?”, es “¿qué pasa si MonteIA lo cita fuera de contexto o lo mezcla mal con otra pregunta?”.
Con ese criterio, se excluyeron dos tipos de documentos, aunque ambos sean técnicamente públicos:
- Datos personales o de colectivos identificables: el documento del equipo directivo (con nombres reales, que ya habíamos decidido no usar en la versión de blog/demo), las diez fichas de salud del alumnado (asma, diabetes, epilepsia, alergias, convulsiones, insuficiencias…), el manual sobre alumnado con padres separados o divorciados, y los formularios pensados para recoger datos personales (fichas, anexos de solicitud, partes de baja médica).
- Sensibles aunque no tengan ningún nombre: todo el plan de autoprotección y actuación ante emergencias, y la prevención de riesgos laborales. No hay datos de nadie en estos documentos, pero el riesgo no es de privacidad: es que una IA repita mal, fuera de contexto, un protocolo de seguridad física del edificio. Mejor que eso lo explique una persona.
Cuatro documentos más con título ambiguo (órganos de coordinación, designación del equipo de evaluación, vigilancia de recreos) se excluyeron también por precaución, sin llegar a abrirlos, ante la duda de si nombran a personas concretas.
Resultado: 42 documentos aptos, 32 excluidos, repartidos en dos carpetas dentro de documentosmonterroso/: aptorag/ y excluir/. (Un archivo resultó ser un duplicado exacto de otro y desapareció solo durante el proceso — probablemente cosa de la sincronización de OneDrive — sin que se perdiera ningún documento real.)
El cambio de código: la carpeta pasa a ser la lista blanca
La versión anterior de rag.py tenía esto:
DOCUMENTOS_A_INDEXAR = [
"1. Introducción.pdf",
"4.1. Extracto de normas de funcionamiento.pdf",
"4.5. Normas sobre móviles y uso seguro de internet.pdf",
"L. Organización del tiempo escolar.pdf",
"J. Plan de convivencia escolar.pdf",
]
Con 5 documentos, mantener esa lista a mano era razonable. Con 42, y con la idea de seguir ampliándola más adelante, ya no lo es: es fácil olvidarse de añadir un archivo nuevo a la lista, o dejar uno indexado que debería haberse quitado.
La solución más simple: que el código lea directamente el contenido de la carpeta aptorag/, sin ninguna lista intermedia. Así, la propia organización en carpetas — que además hay que hacer igualmente por privacidad — se convierte en la configuración del sistema. Añadir o quitar un documento del RAG es mover un archivo de una carpeta a otra y reiniciar la API. Nada de código de por medio.
Código completo
Solo cambia ia-monterroso-api/rag.py. No hace falta instalar nada nuevo.
"""
rag.py — Recuperación de documentos para MonteIA.
Esta es la capa de "conocimiento" real (frente al bloque de texto fijo de
CONOCIMIENTO_CENTRO en main.py): en vez de un texto siempre igual, busca en
los documentos internos del centro los fragmentos más relacionados con cada
pregunta, y los devuelve para añadirlos al system prompt solo cuando son
relevantes.
Técnica usada: búsqueda léxica con BM25 (librería rank_bm25), NO embeddings.
Es la opción más sencilla que puede funcionar bien en un ordenador sin GPU,
y es más que suficiente para preguntas sobre documentos administrativos,
donde las palabras clave de la pregunta suelen aparecer casi igual en el
documento (por ejemplo: "móviles", "horario", "convivencia"). El día que
haya más potencia de cálculo (GPU) y muchos más documentos, esto se puede
sustituir por una búsqueda semántica con embeddings sin tocar cómo se usa
desde main.py: la función buscar_contexto() seguiría llamándose igual desde
fuera, con la misma firma.
Qué documentos se indexan (cambio importante respecto a la versión anterior):
Ya NO hay una lista de nombres de archivo escrita a mano en este código. Los
75 documentos originales del centro se revisaron uno a uno y se repartieron
en dos carpetas dentro de documentosmonterroso/:
- aptorag/ → documentos de carácter general, sin datos personales de nadie
identificable y sin contenido sensible (protocolos de seguridad, fichas de
salud, situaciones familiares, formularios con datos personales). Todo lo
que hay aquí se indexa.
- excluir/ → todo lo demás. Este código no lo mira siquiera.
Así, la carpeta ES la lista blanca: para añadir o quitar un documento del
RAG basta con moverlo de una carpeta a otra y reiniciar la API, sin tocar
ninguna línea de código. Antes había que editar a mano una lista de nombres
de archivo en este mismo fichero; con solo 5 documentos era manejable, pero
no iba a serlo con 42 o más.
"""
import os
import re
import pypdf
from rank_bm25 import BM25Okapi
# Carpeta de documentos ya curados (revisados uno a uno por privacidad),
# relativa a este archivo: ia-monterroso-api/ y documentosmonterroso/ son
# carpetas hermanas dentro de la carpeta del proyecto. Con una ruta relativa
# no importa en qué unidad o carpeta esté instalado el proyecto en cada
# ordenador.
CARPETA_DOCUMENTOS = os.path.join(
os.path.dirname(__file__), "..", "documentosmonterroso", "aptorag"
)
# Nota sobre los nombres de archivo: algunos contienen literalmente
# "ó", "ñ", etc. en vez de las tildes/eñes reales. Es un
# defecto de cómo se descargaron los archivos originales (entidades HTML
# sin decodificar en el nombre del fichero), no un error de este código.
# Como ahora los nombres se leen directamente de la carpeta (no de una
# lista escrita a mano), esto ya no requiere ningún mantenimiento aquí.
TAMANO_CHUNK = 900 # caracteres aproximados por fragmento
SOLAPE = 150 # caracteres de solapamiento entre fragmentos consecutivos
FRAGMENTOS_POR_RESPUESTA = 4 # cuántos fragmentos se pasan al modelo como máximo
def _tokenizar(texto):
return re.findall(r"\w+", texto.lower())
def _dividir_en_trozos(texto, tamano_chunk=TAMANO_CHUNK, solape=SOLAPE):
"""Trocea un texto largo en fragmentos de tamano_chunk caracteres
aproximados, sin cortar palabras, con solape entre fragmentos
consecutivos para no perder contexto justo en el corte."""
texto = texto.strip()
if not texto:
return []
palabras = texto.split()
trozos = []
actual = []
longitud_actual = 0
for palabra in palabras:
actual.append(palabra)
longitud_actual += len(palabra) + 1
if longitud_actual >= tamano_chunk:
trozos.append(" ".join(actual))
palabras_solape = []
longitud_retroceso = 0
for p in reversed(actual):
longitud_retroceso += len(p) + 1
palabras_solape.insert(0, p)
if longitud_retroceso >= solape:
break
actual = palabras_solape
longitud_actual = sum(len(p) + 1 for p in actual)
if actual:
trozos.append(" ".join(actual))
return trozos
def _listar_documentos():
"""Todos los PDF que haya en la carpeta aptorag/, en el momento de
arrancar la API. Si la carpeta no existe todavía (por ejemplo, en una
instalación nueva antes de copiar los documentos), no falla: simplemente
no hay nada que indexar."""
if not os.path.isdir(CARPETA_DOCUMENTOS):
print(f"[rag] Aviso: no existe la carpeta '{CARPETA_DOCUMENTOS}'.")
return []
return sorted(
nombre for nombre in os.listdir(CARPETA_DOCUMENTOS) if nombre.lower().endswith(".pdf")
)
def _cargar_fragmentos():
fragmentos = []
for nombre_archivo in _listar_documentos():
ruta = os.path.join(CARPETA_DOCUMENTOS, nombre_archivo)
lector = pypdf.PdfReader(ruta)
for num_pagina, pagina in enumerate(lector.pages, start=1):
texto_pagina = pagina.extract_text() or ""
for trozo in _dividir_en_trozos(texto_pagina):
fragmentos.append(
{"texto": trozo, "fuente": nombre_archivo, "pagina": num_pagina}
)
return fragmentos
# Se indexa una sola vez, al arrancar la API. Extraer y trocear texto de
# unas cuantas decenas de PDF es cuestión de segundos en cualquier CPU
# moderna; no hay ningún cálculo de embeddings de por medio, así que el
# arranque sigue siendo rápido aunque la carpeta aptorag/ crezca bastante
# más en el futuro.
_DOCUMENTOS_INDEXADOS = _listar_documentos()
_FRAGMENTOS = _cargar_fragmentos()
_CORPUS_TOKENIZADO = [_tokenizar(f["texto"]) for f in _FRAGMENTOS]
_INDICE_BM25 = BM25Okapi(_CORPUS_TOKENIZADO) if _CORPUS_TOKENIZADO else None
print(
f"[rag] Indexados {len(_FRAGMENTOS)} fragmentos de "
f"{len(_DOCUMENTOS_INDEXADOS)} documentos (carpeta aptorag/)."
)
def buscar_contexto(pregunta, k=FRAGMENTOS_POR_RESPUESTA):
"""Devuelve un texto con los fragmentos más relevantes para la
pregunta, listos para meter en el system prompt, o "" si no hay índice
o ningún fragmento tiene relación léxica real con la pregunta (evita
meter fragmentos irrelevantes "porque toca" cuando no hay nada útil)."""
if _INDICE_BM25 is None or not pregunta.strip():
return ""
consulta = _tokenizar(pregunta)
puntuaciones = _INDICE_BM25.get_scores(consulta)
indices_ordenados = sorted(
range(len(puntuaciones)), key=lambda i: puntuaciones[i], reverse=True
)[:k]
seleccionados = [_FRAGMENTOS[i] for i in indices_ordenados if puntuaciones[i] > 0]
if not seleccionados:
return ""
bloques = [
f"[Fuente: {f['fuente']}, página {f['pagina']}]\n{f['texto']}"
for f in seleccionados
]
return "\n\n---\n\n".join(bloques)
main.py no cambia: sigue llamando a buscar_contexto() exactamente igual que antes. Esa es la ventaja de haber separado el RAG en su propio módulo desde el principio — el cambio se queda dentro de rag.py y no se nota desde fuera.
Validado antes de instalarlo
Antes de subir el cambio a la máquina de pruebas, se comprobó con los 42 documentos reales: 2.183 fragmentos indexados sin errores, y preguntas de prueba sobre temas repartidos por todo el nuevo contenido (móviles, organización del recreo, evaluación de asignaturas, permisos del profesorado, igualdad de género) devuelven contexto relevante en todos los casos. El indexado completo tardó unos 17 segundos en el entorno de pruebas; en ROCKY, sin GPU, es razonable esperar que tarde algo más, pero sigue siendo un coste de arranque único, no algo que afecte a cada pregunta.
Cómo instalarlo (empezar otra vez con el RAG nuevo)
No hace falta instalar ninguna librería nueva. Los pasos son:
- Parar la API si está corriendo (
Ctrl+Cen la terminal donde se lanzóuvicorn). - Comprobar que la carpeta
documentosmonterroso/tiene ahora dentro dos subcarpetas,aptorag/(42 PDFs) yexcluir/(32 PDFs), y que no queda ningún PDF suelto en la raíz. - Sustituir
ia-monterroso-api/rag.pypor la versión de más arriba. - Arrancar de nuevo la API (
uvicorn main:app --reloaddesdeia-monterroso-api/, o el comando que se esté usando). En los mensajes de arranque debe aparecer algo como[rag] Indexados NNNN fragmentos de 42 documentos (carpeta aptorag/).— si el número de documentos no es 42, algo no se ha movido bien.
Cómo comprobarlo
Preguntar algo que solo esté en los documentos nuevos (por ejemplo, sobre permisos del profesorado, evaluación interna, o materias optativas de bachillerato) y comprobar que MonteIA responde citando el documento y la página correctos, como ya hacía con los 5 de antes.
Qué queda pendiente
Confirmar en ROCKY que el arranque con 42 documentos va razonablemente rápido y que las respuestas citan bien las fuentes nuevas. Y, ya con esto resuelto, toca lo siguiente en la lista: un filtro básico de entrada y un límite de peticiones antes de pensar en abrir MonteIA más allá de un solo ordenador — porque un asistente de un centro educativo, con menores de por medio, no debería exponerse a la red sin esas dos cosas mínimas.

Etiqueta:BM25, curación de documentos, fastapi, ia monterroso, ies monterroso, MonteIA, privacidad, protección de datos, RAG

