
Proyecto IA Monterroso (VII): el primer RAG, sin tarjeta gráfica
Hasta la entrada anterior, MonteIA sabía un puñado de datos del centro metidos a mano en un bloque de texto: oferta educativa, calendario, y poco más. Funciona, pero no escala: no se puede meter un reglamento entero de cien páginas dentro de un system prompt sin que la respuesta se vuelva lentísima o el modelo se pierda. Hoy le damos a MonteIA su primer sistema de verdad para consultar documentos: RAG (Retrieval-Augmented Generation, generación aumentada por recuperación). En vez de saberlo todo de memoria, MonteIA busca en los documentos el trozo relevante para cada pregunta, y solo ese trozo se le pasa al modelo.
Qué es el RAG, en corto
La idea es sencilla: cuando alguien pregunta algo, antes de mandarle la pregunta al modelo de IA, se busca primero en una colección de documentos qué fragmentos tienen relación con esa pregunta. Esos fragmentos (normalmente 2-4, no el documento entero) se meten en el system prompt, junto a la pregunta. El modelo responde entonces con esa información delante, en vez de tener que “saberlo” de antemano.
Esto tiene una ventaja enorme para un proyecto como este: el modelo (Llama 3.2 3B) sigue siendo el mismo, pequeño y limitado, pero ya no depende de lo que se aprendió durante su entrenamiento (que es cero sobre el IES Monterroso) sino de documentos reales que le pasamos nosotros en el momento.
La decisión importante: BM25, no embeddings
Cuando se habla de RAG, la mayoría de tutoriales usan embeddings: se convierte cada fragmento de texto en un vector numérico que representa su significado, y se buscan los fragmentos con vectores más parecidos al de la pregunta. Es una técnica potente, pero tiene un coste: calcular esos vectores necesita un modelo de embeddings corriendo, y hacerlo rápido normalmente pide GPU.
ROCKY no tiene GPU. Así que en vez de embeddings, MonteIA usa BM25: una técnica de búsqueda por palabras clave, de las de toda la vida (es la misma familia de algoritmos que llevan usando los motores de búsqueda de texto durante décadas), muy ligera de calcular, que no necesita ningún modelo adicional. Funciona bien cuando la pregunta comparte vocabulario literal con el documento — y en documentos administrativos de un instituto, eso pasa muy a menudo: si alguien pregunta por “los móviles”, es muy probable que el documento relevante también hable de “móviles” con esas mismas palabras.
Antes de darle esto por bueno, hice la prueba con las cinco preguntas típicas que se le podrían hacer, sobre los cinco documentos elegidos. Con preguntas directas (“¿qué normas hay sobre los móviles?”, “¿cuál es el horario del centro?”) acierta bien el documento y la página. Con preguntas más indirectas (“¿qué se hace en caso de conflicto entre alumnos?”) el acierto es más flojo, porque BM25 no entiende sinónimos ni relaciones de significado, solo coincidencia de palabras. Es una limitación real, y queda anotada como tal: el día que haya GPU y muchos más documentos, se puede pasar a embeddings sin tocar cómo se usa el sistema desde el resto del código, porque toda la búsqueda vive en un único sitio.
Por qué solo 5 documentos de 75
José Luis tiene una carpeta con 75 documentos reales del centro: el Plan de Centro completo, normativa, protocolos, formularios… Meterlos todos de golpe en una máquina sin GPU, sin haber probado antes si el sistema funciona, no tenía sentido: si algo iba mal, sería muy difícil saber si el problema era el volumen de documentos o el diseño del sistema. Así que se eligieron 5 para esta primera versión:
- Introducción (al Reglamento de Organización y Funcionamiento).
- Extracto de normas de funcionamiento.
- Normas sobre móviles y uso seguro de internet.
- Organización del tiempo escolar.
- Plan de convivencia escolar.
Se eligieron por dos motivos a la vez: son de los que más se puede esperar que alumnado o profesorado pregunten, y no contienen ningún dato personal. El resto se añadirá cuando haya mejor hardware, documento por documento, revisando antes cada uno.
Un aparte sobre privacidad, porque hay que decirlo
De los 75 documentos de la carpeta, varios contienen (o podrían contener) información sensible: fichas de enfermedades crónicas del alumnado, un manual de actuación para alumnado con padres separados o divorciados, formularios de recursos humanos con datos de personal. Ninguno de esos se ha tocado para este RAG.
Para comprobar que la extracción de texto de un PDF funcionaba bien antes de escribir el sistema, se abrió uno de esos documentos sensibles —el de fichas de asma— como prueba técnica. Resultó ser una plantilla en blanco: sin nombre, sin datos de ningún alumno real, solo el formulario vacío que rellenaría cada familia. Aun así, no se ha indexado ni se va a indexar en este RAG: no aporta nada útil a un asistente general, y no hay motivo para arriesgarse con ese tipo de documentos sin una revisión mucho más cuidadosa que la de “elegir 5 rápido para probar el concepto”.
El principio que se sigue de aquí en adelante: antes de añadir cualquier documento nuevo al RAG, se revisa si puede contener datos personales de alumnado, familias o personal. Si puede, no entra sin una decisión explícita y consciente sobre cómo tratarlo.
Código completo
Tres archivos cambian: main.py, uno nuevo llamado rag.py, y requirements.txt.
ia-monterroso-api/rag.py (nuevo)
python
"""
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.
"""
import os
import re
import pypdf
from rank_bm25 import BM25Okapi
# Carpeta de documentos, 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")
# Solo estos documentos se indexan por ahora, de los 75 que hay en la
# carpeta. Es un primer lote pequeño y sin datos personales, elegido a
# propósito para probar el sistema en un ordenador sin GPU (ROCKY). El
# resto se añadirá más adelante, con mejor hardware, simplemente añadiendo
# su nombre de archivo a esta lista.
#
# Nota sobre los nombres: 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. Si se renombran
# los archivos con los caracteres correctos, hay que actualizar esta lista.
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",
]
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))
# Retrocedemos unas palabras para que el siguiente fragmento
# solape con el final de este, en vez de empezar de cero.
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 _cargar_fragmentos():
fragmentos = []
for nombre_archivo in DOCUMENTOS_A_INDEXAR:
ruta = os.path.join(CARPETA_DOCUMENTOS, nombre_archivo)
if not os.path.exists(ruta):
print(f"[rag] Aviso: no se encuentra '{nombre_archivo}', se ignora.")
continue
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. Con 5 documentos tarda muy
# poco (extraer texto de un PDF es rápido); si en el futuro son muchos más,
# esto seguirá siendo razonablemente rápido porque solo se extrae y trocea
# texto, no se calcula ningún embedding.
_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_A_INDEXAR)} documentos."
)
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)
ia-monterroso-api/main.py (actualizado)
python
from fastapi import FastAPI, HTTPException
from fastapi.responses import HTMLResponse
from pydantic import BaseModel
import requests
from rag import buscar_contexto
# --- Configuración ---
LM_STUDIO_URL = "http://localhost:1234/v1/chat/completions"
MODEL_NAME = "llama-3.2-3b-instruct"
TIMEOUT_SEGUNDOS = 120
# Cuántos mensajes recientes del historial se envían al modelo en cada
# petición. No enviamos la conversación entera: en un modelo pequeño (3B)
# ejecutado en CPU sin GPU, cuanto más historial se manda, más lenta es la
# respuesta y antes se llena la ventana de contexto del modelo. Con esto la
# IA "recuerda" lo último dicho sin que la conversación se vuelva eterna
# de procesar.
MAX_MENSAJES_CONTEXTO = 12
# Personalidades de MonteIA. Viven aquí, en la capa de la API, y no en el
# cliente: así cualquier aplicación futura que hable con esta API (la web,
# una app de alumnado, lo que sea) hereda las mismas personalidades sin
# tener que repetirlas en cada sitio. Si cambia el modelo de IA, este texto
# no cambia.
SYSTEM_PROMPT_FORMAL = """Eres MonteIA, el asistente de inteligencia artificial del IES Monterroso, un instituto público de Estepona (Andalucía, España).
Cómo debes comportarte:
- Habla en español de España, de forma cercana, clara y respetuosa.
- Tu público es alumnado y profesorado de un instituto: sé apropiado para un entorno educativo.
- Más abajo tienes información real sobre el centro (oferta educativa, calendario, contacto) y, cuando venga al caso, fragmentos reales de documentos internos del IES Monterroso relacionados con la pregunta. Básate en ellos cuando estén disponibles. Si te preguntan algo que no está en esa información ni en esos fragmentos, dilo claramente en vez de inventarte una respuesta que suene plausible: no sabes nada del centro que no se te haya dado explícitamente.
- Si no sabes algo con certeza, dilo abiertamente en vez de inventarte datos. Eres un modelo pequeño ejecutado en un ordenador sin GPU, así que puedes equivocarte, y es mejor admitirlo que dar una respuesta inventada con seguridad.
- Sé conciso salvo que te pidan una explicación larga.
"""
SYSTEM_PROMPT_INFORMAL = """Eres MonteIA, el asistente de inteligencia artificial del IES Monterroso, un instituto público de Estepona (Andalucía, España). Aquí tienes una personalidad cercana e informal, como la de un profesor muy activo que siempre tiene mil cosas entre manos.
Cómo debes comportarte:
- Eres animado, dinámico y con sentido del humor. Puedes hacer bromas de vez en cuando, pero SOLO sobre ti mismo (por ejemplo, sobre lo liado que andas o lo que tardas en pensar la respuesta). Nunca bromees sobre otras personas, sobre el alumnado, el profesorado, ni sobre temas sensibles.
- Sigues estando en un centro educativo público: por debajo del tono gracioso, mantén siempre el respeto y un contenido apropiado para cualquier edad del instituto.
- De vez en cuando, sin repetirlo en cada respuesta, recuerda con simpatía que estás para ayudar a entender y aprender, no para que te copien un examen o una tarea tal cual.
- Más abajo tienes información real sobre el centro (oferta educativa, calendario, contacto) y, cuando venga al caso, fragmentos reales de documentos internos del IES Monterroso relacionados con la pregunta. Básate en ellos cuando estén disponibles. Si te preguntan algo que no está en esa información ni en esos fragmentos, dilo claramente (con humor si quieres, pero sin inventar datos): no sabes nada del centro que no se te haya dado explícitamente.
- Si no sabes algo con certeza, dilo abiertamente en vez de inventarte datos, con la misma naturalidad con la que bromeas. Eres un modelo pequeño ejecutado en un ordenador sin GPU, así que puedes equivocarte.
- Habla en español de España. Sé conciso salvo que te pidan una explicación larga.
"""
PERSONALIDADES = {
"formal": SYSTEM_PROMPT_FORMAL,
"informal": SYSTEM_PROMPT_INFORMAL,
}
PERSONALIDAD_POR_DEFECTO = "formal"
# Conocimiento propio del centro. Es deliberadamente un bloque de texto
# aparte de la personalidad (capa de "conocimiento" separada de la capa de
# "orquestación/personalidad", como marca la arquitectura del proyecto), así
# que el día que esto crezca y haya que pasar a un sistema de recuperación
# de documentos (RAG), solo se sustituye este bloque por una búsqueda en los
# documentos reales, sin tocar la personalidad.
#
# IMPORTANTE: el equipo directivo, el teléfono, el correo y la dirección
# postal de aquí abajo son DATOS FICTICIOS, inventados a propósito para esta
# versión pública/de blog. La oferta educativa y el calendario escolar sí
# son reales, tomados de la web oficial del centro. En un despliegue real
# dentro del IES Monterroso, esos datos ficticios se sustituyen por los
# verdaderos.
CONOCIMIENTO_CENTRO = """Información del IES Monterroso (Estepona, Málaga, España):
Contacto (datos de ejemplo, ficticios):
- Dirección postal: Avda. de las Palmeras, 12, 29680 Estepona (Málaga).
- Teléfono: 951 00 11 22.
- Correo: contacto@ejemplo-instituto.es.
Equipo directivo (nombres de ejemplo, ficticios):
- Dirección: Elena Cabrera Ruiz.
- Vicedirección: Antonio Reyes Molina.
- Jefatura de Estudios: Marta Domínguez Vega.
- Secretaría: Carlos Medina Ortiz.
Oferta educativa (real):
- ESO: 4 cursos (12-16 años). En 1º-3º hay optativas como Ajedrez, Robótica y Oratoria, y segundas lenguas (alemán, francés). En 4º se elige entre opciones como Economía, Latín, Tecnología u otra segunda lengua. Existen Programas de Diversificación Curricular y Ciclos Formativos de Grado Básico desde los 15 años. La evaluación usa la escala IN/SU/BI/NT/SB.
- Bachillerato: dos modalidades, Ciencias y Tecnología, y Humanidades y Ciencias Sociales.
- Formación Profesional: Informática y Comunicaciones; Atención a Personas en Situación de Dependencia; Guía, Información y Asistencias Turísticas; Acondicionamiento Físico.
Calendario escolar 2025/26 (real):
- Curso lectivo: del 15 de septiembre de 2025 al 14 de junio de 2026.
- Vacaciones de Navidad: del 22 de diciembre de 2025 al 6 de enero de 2026.
- Semana Blanca: del 23 al 27 de febrero de 2026.
- Semana Santa: del 30 de marzo al 3 de abril de 2026.
- No hay información pública de horarios de secretaría ni de fechas exactas de evaluaciones: si preguntan por eso, dilo así, no lo inventes.
"""
app = FastAPI(title="MonteIA - API")
class Mensaje(BaseModel):
role: str # "user" o "assistant"
content: str
class PeticionChat(BaseModel):
mensajes: list[Mensaje]
personalidad: str = PERSONALIDAD_POR_DEFECTO
class Respuesta(BaseModel):
respuesta: str
@app.post("/preguntar", response_model=Respuesta)
def preguntar(datos: PeticionChat):
if not datos.mensajes:
raise HTTPException(status_code=400, detail="No se ha enviado ningún mensaje.")
personalidad = PERSONALIDADES.get(
datos.personalidad, PERSONALIDADES[PERSONALIDAD_POR_DEFECTO]
)
# Recortamos el historial de turnos ANTES de añadir el system prompt,
# para que el recorte nunca se lleve por delante la personalidad.
historial_recortado = datos.mensajes[-MAX_MENSAJES_CONTEXTO:]
# RAG: buscamos en los documentos internos indexados (ver rag.py) los
# fragmentos más relacionados con la ÚLTIMA pregunta (no con toda la
# conversación, para no arrastrar temas antiguos). Solo se añaden al
# system prompt si hay alguno realmente relacionado; si no, no se
# menciona ningún documento y el modelo sigue las reglas normales de
# admitir lo que no sabe.
ultima_pregunta = historial_recortado[-1].content if historial_recortado else ""
contexto_documentos = buscar_contexto(ultima_pregunta)
partes_system_prompt = [personalidad, CONOCIMIENTO_CENTRO]
if contexto_documentos:
partes_system_prompt.append(
"Fragmentos de documentos internos del IES Monterroso relacionados "
"con la última pregunta (puede que no todos sean útiles: usa solo lo "
"que responda de verdad a la pregunta, y si te basas en uno, puedes "
"citar el documento entre paréntesis):\n\n" + contexto_documentos
)
system_prompt = "\n\n".join(partes_system_prompt)
mensajes_para_el_modelo = (
[{"role": "system", "content": system_prompt}]
+ [m.model_dump() for m in historial_recortado]
)
cuerpo_peticion = {
"model": MODEL_NAME,
"messages": mensajes_para_el_modelo,
"temperature": 0.7,
}
try:
respuesta_lm_studio = requests.post(
LM_STUDIO_URL, json=cuerpo_peticion, timeout=TIMEOUT_SEGUNDOS
)
respuesta_lm_studio.raise_for_status()
except requests.exceptions.ConnectionError:
raise HTTPException(
status_code=503,
detail="No se puede conectar con LM Studio. ¿Está abierto y con el servidor activado?",
)
except requests.exceptions.Timeout:
raise HTTPException(status_code=504, detail="El modelo ha tardado demasiado en responder.")
datos_respuesta = respuesta_lm_studio.json()
texto_generado = datos_respuesta["choices"][0]["message"]["content"]
return Respuesta(respuesta=texto_generado)
@app.get("/salud")
def salud():
return {"estado": "ok"}
PAGINA_HTML = """
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>MonteIA</title>
<style>
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body { font-family: system-ui, sans-serif; background: #ffffff; color: #1f1f1f;
display: flex; flex-direction: column; }
.cabecera { background: #1e7d3c; color: white; padding: 14px 24px; }
.cabecera h1 { margin: 0; font-size: 1.35rem; letter-spacing: 0.2px; }
.cabecera p { margin: 2px 0 0; font-size: 0.8rem; color: #dff3e4; }
.franja { display: flex; height: 8px; }
.franja div { flex: 1; }
.franja .verde { background: #1e7d3c; }
.franja .blanca { background: #ffffff; }
.contenido { flex: 1; display: flex; flex-direction: column; width: 100%;
max-width: 860px; margin: 0 auto; padding: 16px 20px 20px;
min-height: 0; }
.aviso { font-size: 0.8rem; color: #6b6b6b; margin: 0 0 10px; }
#chat { flex: 1; overflow-y: auto; padding: 10px 4px; display: flex;
flex-direction: column; gap: 12px; }
.burbuja { max-width: 78%; padding: 11px 14px; border-radius: 14px;
line-height: 1.5; white-space: pre-wrap; font-size: 0.97rem; }
.usuario { align-self: flex-end; background: #1e7d3c; color: white;
border-bottom-right-radius: 4px; }
.asistente { align-self: flex-start; background: #eaf7ee; color: #1f1f1f;
border: 1px solid #cfead7; border-bottom-left-radius: 4px; }
.asistente.pensando { color: #6b6b6b; font-style: italic; }
.error { align-self: flex-start; background: #fde8e8; color: #9b1c1c;
border: 1px solid #f6c6c6; }
.zona-entrada { display: flex; gap: 8px; margin-top: 12px; align-items: flex-end;
border-top: 1px solid #e6e6e6; padding-top: 12px; }
textarea { flex: 1; font-size: 1rem; padding: 10px 12px; border-radius: 10px;
border: 1px solid #cfd4cf; resize: none; max-height: 120px; font-family: inherit; }
textarea:focus { outline: none; border-color: #1e7d3c; }
button { padding: 10px 18px; font-size: 1rem; border: none; border-radius: 10px;
background: #1e7d3c; color: white; cursor: pointer; white-space: nowrap; }
button:disabled { background: #a8c6b0; cursor: not-allowed; }
#boton_nueva { background: none; color: #1e7d3c; text-decoration: underline;
font-size: 0.8rem; padding: 0; margin: 0 0 10px; align-self: flex-start; }
.barra_superior { display: flex; justify-content: space-between; align-items: center;
flex-wrap: wrap; gap: 8px; margin-bottom: 4px; }
.selector_personalidad { font-size: 0.8rem; color: #444; display: flex;
align-items: center; gap: 6px; }
.selector_personalidad select { font-size: 0.8rem; padding: 3px 6px; border-radius: 6px;
border: 1px solid #cfd4cf; background: white; }
</style>
</head>
<body>
<div class="cabecera">
<h1>MonteIA</h1>
<p>Asistente del IES Monterroso</p>
</div>
<div class="franja"><div class="verde"></div><div class="blanca"></div><div class="verde"></div></div>
<div class="contenido">
<p class="aviso">Puede tardar unos segundos en pensar la respuesta y alguna vez
equivocarse en datos concretos. La conversación no se guarda: si recargas
la página, empieza de cero.</p>
<div class="barra_superior">
<button id="boton_nueva" onclick="nuevaConversacion()">Empezar conversación nueva</button>
<label class="selector_personalidad">
Personalidad:
<select id="personalidad" onchange="nuevaConversacion()">
<option value="formal">Formal</option>
<option value="informal">Cercana (informal)</option>
</select>
</label>
</div>
<div id="chat"></div>
<div class="zona-entrada">
<textarea id="pregunta" placeholder="Escribe tu pregunta... (Enter para enviar, Mayús+Enter para salto de línea)" rows="1"></textarea>
<button id="boton" onclick="enviar()">Enviar</button>
</div>
</div>
<script>
let historial = [];
const chatDiv = document.getElementById('chat');
const textarea = document.getElementById('pregunta');
const boton = document.getElementById('boton');
const selectorPersonalidad = document.getElementById('personalidad');
function pintarBurbuja(role, texto, extraClase) {
const div = document.createElement('div');
div.className = 'burbuja ' + (role === 'user' ? 'usuario' : 'asistente') + (extraClase ? ' ' + extraClase : '');
div.textContent = texto;
chatDiv.appendChild(div);
chatDiv.scrollTop = chatDiv.scrollHeight;
return div;
}
function nuevaConversacion() {
historial = [];
chatDiv.innerHTML = '';
}
async function enviar() {
const texto = textarea.value.trim();
if (!texto) return;
historial.push({ role: 'user', content: texto });
pintarBurbuja('user', texto);
textarea.value = '';
textarea.style.height = 'auto';
boton.disabled = true;
boton.textContent = '...';
const burbujaPensando = pintarBurbuja('assistant', 'Pensando...', 'pensando');
try {
const res = await fetch('/preguntar', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mensajes: historial, personalidad: selectorPersonalidad.value })
});
if (!res.ok) {
const detalle = await res.json().catch(() => null);
throw new Error(detalle && detalle.detail ? detalle.detail : 'Error del servidor');
}
const datos = await res.json();
burbujaPensando.remove();
historial.push({ role: 'assistant', content: datos.respuesta });
pintarBurbuja('assistant', datos.respuesta);
} catch (e) {
burbujaPensando.remove();
pintarBurbuja('assistant', e.message || 'Ha ocurrido un error.', 'error');
historial.pop(); // no dejamos en el historial la pregunta si no hubo respuesta
} finally {
boton.disabled = false;
boton.textContent = 'Enviar';
textarea.focus();
}
}
textarea.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
enviar();
}
});
textarea.addEventListener('input', () => {
textarea.style.height = 'auto';
textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px';
});
</script>
</body>
</html>
"""
@app.get("/", response_class=HTMLResponse)
def interfaz():
return PAGINA_HTML
ia-monterroso-api/requirements.txt (actualizado)
fastapi
uvicorn[standard]
requests
pypdf
rank-bm25
Cómo instalarlo y probarlo, paso a paso
Esta vez hay un paso más que en las entregas anteriores: hay librerías nuevas que instalar, y hay que reiniciar la API para que las cargue.
- Crea la carpeta
documentosmonterroso/al mismo nivel queia-monterroso-api/(es decir, como carpeta hermana, no dentro), y pon ahí dentro, como mínimo, los 5 PDF mencionados arriba, con sus nombres de archivo exactos. - Guarda una copia de tu
main.pyactual como respaldo. - Sustituye
ia-monterroso-api/main.pypor el código de arriba, y crea el archivo nuevoia-monterroso-api/rag.pycon su contenido, y actualizarequirements.txt. - Si la API ya estaba en marcha, deténla (Ctrl+C en la terminal donde corre
uvicorn). - Instala las dependencias nuevas:
cd ia-monterroso-api
pip install -r requirements.txt --break-system-packages
- Vuelve a arrancar la API:
uvicorn main:app --reload --port 8000
- En la consola donde arranca, busca una línea como
[rag] Indexados 462 fragmentos de 5 documentos.(el número exacto puede variar un poco). Si no aparece o aparece un aviso de “no se encuentra” algún archivo, revisa que los PDF estén en la carpeta correcta con el nombre exacto. - Abre
http://localhost:8000/y prueba:- “¿Qué normas hay sobre el uso de móviles en el instituto?”
- “¿Cuál es el horario del centro?”
- Algo que sepas que no está en ninguno de los 5 documentos ni en el bloque de conocimiento básico (por ejemplo, algo de otra asignatura o departamento) → debería seguir admitiendo que no lo sabe, en vez de inventárselo.
Qué queda pendiente
Este RAG cubre 5 documentos de 75. Cuando José Luis tenga un ordenador con GPU, el plan es ir revisando el resto uno a uno —separando los que se pueden indexar de los que tienen implicaciones de privacidad— y, según cuántos acaben entrando, valorar si compensa ya pasar de BM25 a una búsqueda semántica con embeddings. Hasta entonces, MonteIA sigue funcionando con lo que tiene: un poco de conocimiento fijo, cinco documentos reales, y la honestidad de admitir lo que no sabe.


Etiqueta:BM25, documentos administrativos, fastapi, ia local, ia monterroso, llama 3.2, lm studio, MonteIA, privacidad, pypdf, RAG, rank_bm25

