
Proyecto Vannevar Bush: Cómo construir tu propio RAG 100% local con FastAPI y LM Studio
En 1945, el científico y visionario Vannevar Bush publicó su histórico ensayo As We May Think, en el que describía el Memex: una máquina conceptual capaz de almacenar bibliotecas enteras de información y recuperarla al instante mediante enlaces asociativos.
Ocho décadas después, en el laboratorio MonteSteam, hemos traído esa visión al presente con el Proyecto Vannevar Bush: un sistema RAG (Retrieval-Augmented Generation) totalmente privado, gratuito y ejecutable en local, diseñado para convertir cualquier documento CSV en una base de conocimientos interactiva para nuestras aulas.
1. ¿Por qué un RAG? El problema de la memoria y las alucinaciones
Cuando le hacemos una pregunta a un Modelo de Lenguaje (LLM) sobre proyectos específicos de nuestro centro o sobre datos privados, ocurren dos cosas:
- Desconocimiento: El modelo fue entrenado con datos públicos de internet y no conoce nuestros archivos internos.
- Alucinación: En lugar de admitir que no lo sabe, la IA suele inventar respuestas que parecen convincentes pero que son totalmente falsas.
Exigirle a un LLM que recuerde cada detalle de un documento es como pedirle a un estudiante que memorice una enciclopedia entera para un examen. La arquitectura RAG resuelve este problema permitiendo que la IA haga el examen a libro abierto: antes de responder, el sistema busca en nuestra base de datos los textos más relevantes y se los entrega al modelo para que redacte la respuesta basándose únicamente en ellos.
2. Cómo funciona la aplicación web (Paso a Paso)
Para que el alumnado e investigadores puedan interactuar con el corpus de manera intuitiva, hemos diseñado una interfaz web completa servida directamente desde Python con FastAPI.
📸 [INSERTAR AQUÍ TU CAPTURA DE PANTALLA DE LA INTERFAZ WEB]
Pie de foto sugerido: Interfaz web de VANNEVAR BUSH (puerto 8041) mostrando los botones de ejemplo, la respuesta generada por el LLM local y las fuentes recuperadas del CSV.
Elementos clave de la pantalla:
- Accesos directos de ejemplo (Botones superiores):
Permiten realizar consultas prediseñadas (Proyecto Laennec, HAIBT, Bermeja, Nightingale) con un solo clic para comprobar el rendimiento inmediato del sistema. - Caja de búsqueda libre:
Cualquier usuario puede redactar sus propias preguntas abiertas sobre el contenido del blog. - Panel 1: Generación Aumentada (Respuesta del LLM):
Muestra la respuesta sintetizada y redactada en tiempo real por el modelo conversacional cargado en LM Studio. - Panel 2: Contexto Recuperado (Top-3 Fuentes):
Muestra los 3 fragmentos extraídos de nuestro archivocorpus.csvcon su correspondiente puntuación de Similitud Coseno. Esto permite auditar exactamente qué información leyó la IA antes de responder. - Barra de Métricas:
Desglosa de forma transparente los tiempos de ejecución: el tiempo de búsqueda vectorial en milisegundos ($ms$) y el tiempo de respuesta del modelo en segundos ($s$).

3. Arquitectura del Sistema y Código Completo (app_rag.py)
El proyecto no requiere dependencias pesadas de orquestación ni servicios en la nube. Todo el motor funciona mediante un único archivo en Python que conecta el servidor FastAPI con las API locales de LM Studio (http://localhost:1234).
Flujo de Datos:
$$\text{Pregunta del Usuario} \longrightarrow \text{Embedding Vectorial} \longrightarrow \text{Similitud Coseno (CSV)} \longrightarrow \text{Inyección de Contexto} \longrightarrow \text{LLM Local}$$
Código completo del servidor y la interfaz (app_rag.py):
Python
"""
VANNEVAR BUSH — Primer RAG Local MonteSteam (Web)
==================================================
FastAPI + LM Studio (Embeddings + LLM Chat Completions)
Arranque:
uvicorn app_rag:app --reload --port 8041
"""
import csv
import time
import numpy as np
import requests
from fastapi import FastAPI
from fastapi.responses import HTMLResponse, JSONResponse
# ---------------------------------------------------------------------
# CONFIGURACIÓN — Endpoints de LM Studio
# ---------------------------------------------------------------------
LM_STUDIO_EMBED_URL = "http://localhost:1234/v1/embeddings"
LM_STUDIO_CHAT_URL = "http://localhost:1234/v1/chat/completions"
EMBEDDING_MODEL = "text-embedding-nomic-embed-text-v1.5"
CHAT_MODEL = "local-model"
CORPUS_PATH = "corpus.csv"
TOP_K = 3
# =======================================================================
# PARTE 1: Funciones Adaptativas para el CSV y Búsqueda Vectorial
# =======================================================================
def extraer_texto_completo(fila):
"""Junta todos los campos de la fila del CSV independientemente de los nombres de columna."""
return " ".join([str(val).strip() for val in fila.values() if val]).strip()
def extraer_titulo(fila, indice):
"""Extrae la columna de título si existe o la sintetiza con las primeras palabras."""
for clave in ['titulo', 'Titulo', 'title', 'Title', 'nombre', 'Nombre']:
if clave in fila and fila[clave]:
return fila[clave].strip()
texto = extraer_texto_completo(fila)
palabras = texto.split()
if palabras:
return " ".join(palabras[:6]).capitalize() + "..."
return f"Entrada #{indice + 1}"
def similitud_coseno(vector_query, matriz_docs):
"""Calcula el producto escalar entre vectores normalizados."""
query_norm = vector_query / (np.linalg.norm(vector_query) + 1e-9)
docs_norm = matriz_docs / (
np.linalg.norm(matriz_docs, axis=1, keepdims=True) + 1e-9
)
return docs_norm @ query_norm
def pedir_embeddings_batch(lista_textos):
"""Solicita los embeddings de todos los textos en una sola petición lote rápida."""
resp = requests.post(
LM_STUDIO_EMBED_URL,
json={"model": EMBEDDING_MODEL, "input": lista_textos},
timeout=30,
)
resp.raise_for_status()
data = resp.json()["data"]
data_ordenada = sorted(data, key=lambda x: x["index"])
return np.array([item["embedding"] for item in data_ordenada], dtype=np.float32)
def pedir_embedding_query(texto):
"""Solicita el embedding de una sola pregunta."""
resp = requests.post(
LM_STUDIO_EMBED_URL,
json={"model": EMBEDDING_MODEL, "input": texto},
timeout=15,
)
resp.raise_for_status()
return np.array(resp.json()["data"][0]["embedding"], dtype=np.float32)
# =======================================================================
# PARTE 2: Generación Aumentada (LLM Chat Completions con Prompt Adaptativo)
# =======================================================================
def pedir_respuesta_rag(pregunta, documentos_recuperados):
contexto_texto = ""
for idx, doc in enumerate(documentos_recuperados, start=1):
contexto_texto += f"--- FUENTE {idx}: {doc['titulo']} ---\n{doc['resumen']}\n\n"
prompt_sistema = (
"Eres el asistente científico del laboratorio IA Local MonteSteam.\n"
"Tu misión es responder a la pregunta utilizando la información y proyectos descritos "
"en el CONTEXTO RECUPERADO sobre el blog de AIDARAC.\n\n"
"INSTRUCCIONES:\n"
"1. Identifica los algoritmos, tecnologías, herramientas y conceptos mencionados en los textos "
" y conéctalos directamente con la pregunta planteada.\n"
"2. Sé claro, sintético y preciso en tu respuesta.\n"
"3. Solo si las fuentes recuperadas no mencionan en absoluto el tema o proyecto consultado, responde:\n"
" 'No dispongo de información suficiente en el corpus de AIDARAC para responder a esta pregunta.'"
)
prompt_usuario = (
f"CONTEXTO RECUPERADO DEL BLOG:\n{contexto_texto}\n"
f"PREGUNTA DEL USUARIO:\n{pregunta}"
)
payload = {
"model": CHAT_MODEL,
"messages": [
{"role": "system", "content": prompt_sistema},
{"role": "user", "content": prompt_usuario},
],
"temperature": 0.2,
"max_tokens": 600,
}
resp = requests.post(LM_STUDIO_CHAT_URL, json=payload, timeout=90)
resp.raise_for_status()
data = resp.json()
return data["choices"][0]["message"]["content"]
# =======================================================================
# PARTE 3: Carga del Corpus e Indexación Adaptativa
# =======================================================================
def cargar_corpus(path):
with open(path, encoding="utf-8") as f:
return list(csv.DictReader(f))
print("Cargando corpus...")
FILAS = cargar_corpus(CORPUS_PATH)
TEXTOS = [extraer_texto_completo(f) for f in FILAS]
print("Indexando embeddings del corpus en LM Studio...")
try:
MATRIZ_EMBEDDINGS = pedir_embeddings_batch(TEXTOS)
print(f"✅ Listo: {len(FILAS)} entradas indexadas con éxito.")
except Exception as e:
print(f"⚠️ AVISO: No se pudieron obtener los embeddings desde LM Studio ({e}).")
MATRIZ_EMBEDDINGS = None
# =======================================================================
# PARTE 4: Servidor FastAPI
# =======================================================================
app = FastAPI(title="VANNEVAR BUSH — Primer RAG Local MonteSteam")
@app.get("/", response_class=HTMLResponse)
def home():
return PAGINA
@app.get("/api/rag")
def endpoint_rag(q: str):
if MATRIZ_EMBEDDINGS is None:
return JSONResponse(
status_code=503,
content={"error": "El servidor de embeddings de LM Studio no está listo."},
)
t_inicio_retrieval = time.time()
# 1. RETRIEVAL
try:
vector_q = pedir_embedding_query(q)
except Exception as e:
return JSONResponse(
status_code=503,
content={"error": f"Error recuperando embeddings de la consulta: {e}"},
)
puntos_emb = similitud_coseno(vector_q, MATRIZ_EMBEDDINGS)
orden = np.argsort(puntos_emb)[::-1][:TOP_K]
fuentes_recuperadas = [
{
"titulo": extraer_titulo(FILAS[i], i),
"resumen": extraer_texto_completo(FILAS[i]),
"score": round(float(puntos_emb[i]), 3),
}
for i in orden
]
t_retrieval_ms = round((time.time() - t_inicio_retrieval) * 1000, 1)
# 2. GENERATION
t_inicio_llm = time.time()
try:
respuesta_llm = pedir_respuesta_rag(q, fuentes_recuperadas)
except Exception as e:
return JSONResponse(
status_code=503,
content={
"error": f"Error conectando con el LLM en LM Studio ({e}). ¿Tienes cargado un modelo conversacional?"
},
)
t_llm_s = round(time.time() - t_inicio_llm, 2)
return {
"pregunta": q,
"respuesta": respuesta_llm,
"fuentes": fuentes_recuperadas,
"tiempo_retrieval_ms": t_retrieval_ms,
"tiempo_llm_s": t_llm_s,
}
# =======================================================================
# PARTE 5: Interfaz Web Integrada (HTML + JS)
# =======================================================================
PAGINA = """
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VANNEVAR BUSH — Primer RAG Local MonteSteam</title>
<style>
:root { --verde: #00863E; --verde-oscuro: #006633; --verde-claro: #E8F5EC; --verde-borde: #BFE3CC; }
body { font-family: system-ui, -apple-system, sans-serif; max-width: 960px; margin: 40px auto; padding: 0 20px; background: #fafafa; color: #222; }
h1 { font-size: 1.8rem; color: var(--verde-oscuro); margin-bottom: 4px; }
.subtitulo { color: #555; margin-bottom: 25px; font-size: 0.95rem; }
.ejemplos-contenedor { background: white; border: 1px solid var(--verde-borde); padding: 16px 20px; border-radius: 8px; margin-bottom: 20px; box-shadow: 0 2px 4px rgba(0,0,0,0.02); }
.ejemplos-titulo { font-size: 0.85rem; font-weight: bold; color: var(--verde-oscuro); text-transform: uppercase; letter-spacing: 0.05em; display: block; margin-bottom: 10px; }
.ejemplos-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.btn-ejemplo { font-size: 0.88rem; text-align: left; padding: 10px 12px; border-radius: 6px; border: 1px solid #e0e0e0; background: #f9f9f9; cursor: pointer; color: #333; transition: all 0.2s; line-height: 1.4; border-left: 4px solid var(--verde); }
.btn-ejemplo:hover { border-color: var(--verde); background: var(--verde-claro); }
.tag { font-weight: bold; font-size: 0.75rem; text-transform: uppercase; display: inline-block; margin-bottom: 2px; color: var(--verde-oscuro); }
form { display: flex; gap: 10px; margin-bottom: 25px; }
input[type=text] { flex: 1; padding: 12px 16px; font-size: 1rem; border: 1px solid var(--verde-borde); border-radius: 6px; }
input[type=text]:focus { outline: none; border-color: var(--verde); box-shadow: 0 0 0 3px var(--verde-claro); }
button[type=submit] { padding: 12px 24px; font-size: 1rem; font-weight: 600; border: none; border-radius: 6px; background: var(--verde); color: white; cursor: pointer; }
button[type=submit]:hover:not(:disabled) { background: var(--verde-oscuro); }
button[type=submit]:disabled { background: #a8a8a8; }
.panel-rag { display: grid; grid-template-columns: 1.2fr 0.8fr; gap: 20px; margin-bottom: 30px; }
.bloque { background: white; border: 1px solid var(--verde-borde); border-radius: 8px; padding: 20px; box-shadow: 0 2px 4px rgba(0,0,0,0.02); }
.bloque h2 { font-size: 0.95rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--verde-oscuro); border-bottom: 2px solid var(--verde-claro); padding-bottom: 8px; margin-top: 0; }
.respuesta-contenido { line-height: 1.6; font-size: 0.98rem; color: #111; white-space: pre-wrap; }
.fuente-item { background: var(--verde-claro); border-left: 3px solid var(--verde); padding: 10px 12px; margin-bottom: 12px; border-radius: 0 6px 6px 0; }
.fuente-titulo { font-weight: bold; color: var(--verde-oscuro); font-size: 0.9rem; }
.fuente-resumen { font-size: 0.85rem; color: #444; margin-top: 4px; }
.fuente-score { font-family: monospace; font-size: 0.8rem; color: var(--verde); font-weight: bold; }
.metricas { font-size: 0.8rem; color: #777; margin-top: 15px; border-top: 1px dashed var(--verde-borde); padding-top: 8px; }
.estado { color: #888; font-style: italic; }
footer { text-align: center; color: #888; font-size: 0.82rem; margin-top: 40px; }
</style>
</head>
<body>
<h1>VANNEVAR BUSH</h1>
<p class="subtitulo">Proyecto RAG Local (Retrieval-Augmented Generation) · Laboratorio IA Local MonteSteam</p>
<div class="ejemplos-contenedor">
<span class="ejemplos-titulo">Ejemplos recomendados para probar en clase (haz clic):</span>
<div class="ejemplos-grid">
<button type="button" class="btn-ejemplo" onclick="probarEjemplo('¿Qué modelo de IA se utiliza en el Proyecto Laennec para clasificar los sonidos del corazón?')">
<span class="tag">📌 Proyecto Laennec</span><br>
¿Qué algoritmo analiza los sonidos cardíacos?
</button>
<button type="button" class="btn-ejemplo" onclick="probarEjemplo('¿Por qué una sola neurona no puede aprender la función XOR según el proyecto HAIBT?')">
<span class="tag">📌 Proyecto HAIBT</span><br>
¿Por qué una sola neurona no aprende XOR?
</button>
<button type="button" class="btn-ejemplo" onclick="probarEjemplo('¿Qué regla y datos se utilizan en el proyecto Bermeja para medir el riesgo de incendio?')">
<span class="tag">📌 Proyecto Bermeja</span><br>
¿Cómo se mide el riesgo de incendio en Bermeja?
</button>
<button type="button" class="btn-ejemplo" onclick="probarEjemplo('¿Qué representa el Diagrama de la Rosa de Florence Nightingale?')">
<span class="tag">📌 Proyecto Nightingale</span><br>
¿Qué representa el Diagrama de la Rosa?
</button>
</div>
</div>
<form id="form-rag">
<input type="text" id="pregunta" placeholder="Escribe tu propia pregunta sobre el blog de AIDARAC..." autofocus>
<button type="submit" id="boton">Preguntar a Vannevar</button>
</form>
<div class="panel-rag">
<div class="bloque">
<h2>1. Generación Aumentada (Respuesta del LLM)</h2>
<div id="contenedor-respuesta">
<p class="estado">Elige un ejemplo arriba o escribe una nueva pregunta para probar el RAG.</p>
</div>
<div id="metricas" class="metricas" style="display:none;"></div>
</div>
<div class="bloque">
<h2>2. Contexto Recuperado (Top-3 Fuentes)</h2>
<div id="contenedor-fuentes">
<p class="estado">Aquí aparecerán los fragmentos del CSV inyectados como contexto al LLM.</p>
</div>
</div>
</div>
<footer>VANNEVAR BUSH · Puerto 8041 · Laboratorio IA Local MonteSteam</footer>
<script>
const form = document.getElementById('form-rag');
const boton = document.getElementById('boton');
const divRespuesta = document.getElementById('contenedor-respuesta');
const divFuentes = document.getElementById('contenedor-fuentes');
const divMetricas = document.getElementById('metricas');
async function ejecutarRAG(textoPregunta) {
if (!textoPregunta) return;
boton.disabled = true;
boton.textContent = 'Procesando RAG...';
divRespuesta.innerHTML = '<p class="estado">🔍 Recuperando fuentes e inyectando contexto en el LLM...</p>';
divFuentes.innerHTML = '<p class="estado">Buscando documentos similares...</p>';
divMetricas.style.display = 'none';
try {
const resp = await fetch('/api/rag?q=' + encodeURIComponent(textoPregunta));
const data = await resp.json();
if (data.error) {
divRespuesta.innerHTML = `<p class="estado" style="color: #c00;">${data.error}</p>`;
divFuentes.innerHTML = '<p class="estado">No se pudieron recuperar fuentes.</p>';
} else {
divRespuesta.innerHTML = `<div class="respuesta-contenido">${data.respuesta}</div>`;
divFuentes.innerHTML = data.fuentes.map(f => `
<div class="fuente-item">
<div class="fuente-titulo">${f.titulo}</div>
<div class="fuente-resumen">${f.resumen}</div>
<div class="fuente-score">Similitud Coseno: ${f.score}</div>
</div>
`).join('');
divMetricas.style.display = 'block';
divMetricas.innerHTML = `⏱️ Búsqueda de fuentes: <strong>${data.tiempo_retrieval_ms} ms</strong> | Generación LLM: <strong>${data.tiempo_llm_s} s</strong>`;
}
} catch (err) {
divRespuesta.innerHTML = `<p class="estado" style="color: #c00;">Error de conexión: ${err}</p>`;
} finally {
boton.disabled = false;
boton.textContent = 'Preguntar a Vannevar';
}
}
function probarEjemplo(texto) {
document.getElementById('pregunta').value = texto;
ejecutarRAG(texto);
}
form.addEventListener('submit', (e) => {
e.preventDefault();
const q = document.getElementById('pregunta').value.trim();
ejecutarRAG(q);
});
</script>
</body>
</html>
"""
4. Caso práctico en el aula: La lección del “Proyecto Laennec”
Durante la puesta a prueba en clase nos encontramos con un caso de estudio real sobre el diseño de prompts.
Al preguntar: ¿Qué modelo de IA se utiliza en el Proyecto Laennec para clasificar los sonidos del corazón?, el buscador por embeddings recuperó correctamente la fuente con un nivel de similitud altísimo (0.72). Sin embargo, el LLM se negaba a responder alegando que no tenía datos explícitos.
¿Por qué ocurrió esto?
El texto recuperado decía: “escucha sonidos reales y predice a oído antes de preguntarle al Random Forest“.
Como nuestro System Prompt inicial exigía: “No supongas nada que no esté escrito de forma explícita”, el modelo razonó de forma ultra-conservadora: sabia que Random Forest era un algoritmo, pero como el texto no decía literalmente las palabras “modelo de IA de clasificación”, prefirió no responder para no infringir la orden anti-alucinación.
La solución mediante Prompt Engineering
Para solucionar este comportamiento sin volver al modelo vulnerable a alucinaciones, ajustamos la orden del sistema para permitirle conectar conceptos y algoritmos de forma lógica:
Python
prompt_sistema = (
"Eres el asistente científico del laboratorio IA Local MonteSteam.\n"
"Tu misión es responder a la pregunta utilizando la información y proyectos descritos "
"en el CONTEXTO RECUPERADO sobre el blog de AIDARAC.\n\n"
"INSTRUCCIONES:\n"
"1. Identifica los algoritmos, tecnologías, herramientas y conceptos mencionados en los textos "
" y conéctalos directamente con la pregunta planteada.\n"
"2. Sé claro, sintético y preciso en tu respuesta.\n"
"3. Solo si las fuentes recuperadas no mencionan en absoluto el tema o proyecto consultado, responde:\n"
" 'No dispongo de información suficiente en el corpus de AIDARAC para responder a esta pregunta.'"
)
Tras este ajuste, la IA asoció de inmediato Random Forest como el algoritmo de clasificación del proyecto, respondiendo a la perfección.
5. Conclusiones: Soberanía Tecnológica en el Aula
El Proyecto Vannevar Bush demuestra que no hace falta pagar suscripciones a servicios externos ni enviar datos privados a servidores de terceros para contar con un motor de inteligencia artificial de última generación.
Gracias a la combinación de modelos abiertos, computación local y Python, el laboratorio MonteSteam dispone de un entorno seguro, rápido y transparente donde los estudiantes pueden aprender las tripas de la IA moderna explorando directamente cómo interactúan los vectores, las matrices de similitud y los modelos de lenguaje.



