
Proyecto Karen Spärck Jones — Sesión 1: buscar por palabras exactas vs. buscar por significado
Hasta ahora hemos entrenado modelos para clasificar datos o hemos dibujado gráficos para convencer a decisiones políticas reales. Hoy abrimos una puerta totalmente nueva en la familia de proyectos: convertir el texto en un espacio matemático de significado.
No vamos a entrenar un modelo desde cero. Vamos a comparar dos técnicas fundamentales de recuperación de información sobre las entradas de nuestro propio blog de AIDARAC:
- BM25 (Búsqueda literal): Basada en las ideas de la científica británica Karen Spärck Jones (pionera de la búsqueda de información). Cuenta palabras y mide su rareza.
- Embeddings (Búsqueda semántica): Traduce frases a vectores numéricos en un espacio conceptual y mide qué tan cerca están en significado usando la similitud coseno.
🛠️ Guía de instalación y arranque (de principio a fin)
Sigue estos cuatro pasos para tener la aplicación corriendo en tu equipo.
Paso 1: Preparar LM Studio (Servidor de Embeddings local)
- Abre LM Studio.
- En el buscador de modelos, descarga un modelo específico de embeddings (son muy pequeños y rápidos en CPU). Modelos recomendados:
text-embedding-nomic-embed-text-v1.5bge-small-en-v1.5all-MiniLM-L6-v2
- Ve a la pestaña Developer / Server (icono de la red en la barra izquierda).
- Carga el modelo de embeddings descargado y pulsa en Start Server (por defecto escuchará en
http://localhost:1234). - Copia el nombre exacto con el que aparece cargado el modelo en LM Studio (lo necesitarás en la configuración del código si difiere).
Paso 2: Instalar las librerías de Python
Abre una terminal o símbolo del sistema en tu ordenador y ejecuta:
Bash
pip install fastapi uvicorn numpy requests
Paso 3: Crear los archivos en una carpeta
Crea una carpeta de proyecto (por ejemplo sparck_jones/) e introduce en ella estos tres archivos (tienes sus códigos completos al final de esta entrada):
corpus.csv— Las entradas del blog de AIDARAC.bm25_vs_embeddings.py— Versión interactiva para terminal/consola.app.py— Aplicación web interactiva en FastAPI.
Paso 4: Ejecutar el proyecto
Opción A: Modo Consola (Terminal)
Si quieres probarlo rápidamente desde la línea de comandos:
Bash
python3 bm25_vs_embeddings.py
(Escribe tus preguntas directamente en la consola para ver la comparativa).
Opción B: Modo Web (Interfaz Junta de Andalucía en puerto 8040)
Para arrancar el servidor web interactivo:
Bash
uvicorn app:app --reload --port 8040
Abre tu navegador web e ingresa en: http://localhost:8040
🧠 Explicación: ¿Qué está pasando bajo el capó?
1. BM25 (Columna izquierda)
- ¿Cómo funciona? Descompone la frase en palabras sueltas. Cuenta cuántas veces aparece cada palabra en la entrada, pero aplica la fórmula IDF(Inverse Document Frequency) de Karen Spärck Jones: si una palabra aparece en casi todos los documentos (como “de”, “el”, “modelo”), puntúa muy poco; si es una palabra rara y específica (como “extrapola” o “espectrograma”), otorga muchísima puntuación.
- Puntuación: Un número de $0$ a $+\infty$. No tiene límite superior.
2. Embeddings + Similitud Coseno (Columna derecha)
- ¿Cómo funciona? Pide a LM Studio que convierta la pregunta y cada entrada del blog en una lista de números (un vector de alta dimensión). Luego calcula el ángulo entre ambos vectores mediante la similitud coseno:
$$\text{Similitud} = \frac{A \cdot B}{\Vert{}A\Vert{} \Vert{}B\Vert{}}$$ - Puntuación: Varía entre $0$ (ninguna relación) y $1$ (significado idéntico).
🔬 Experimento en clase: Comparación práctica
Prueba a realizar estas búsquedas en la web y observa cómo se comportan ambos algoritmos:
| Pregunta de prueba | BM25 (Palabras) | Embeddings (Significado) | Observación / Lección |
| “¿cómo se sabe si un ajuste extrapola mal?” | Gana METIS 9 | Gana METIS 9 | Ambos aciertan porque la pregunta contiene las palabras clave exactas. |
| “riesgo de fuego en el monte” | Falla o puntuación baja | Gana BERMEJA | Embeddings entiende que “fuego en el monte” significa “incendio forestal”. BM25 busca la palabra literal “fuego”. |
| “METIS 9” | Gana METIS 9 ($100\%$) | Resultado impreciso | BM25 es imbatible con códigos, nombres propios o identificadores exactos. |
[COMPLETAR EN CLASE: Escribe la pregunta "sesgo de género en IA" y anota qué entrada ocupa el Top-1 en cada columna y con qué puntuación.]

📁 Códigos completos del proyecto
1. Archivo: corpus.csv
Crea este archivo en la carpeta con el conjunto de datos de prueba:
Fragmento de código
titulo,resumen
METIS 9 — Ajuste de rectas y extrapolación,Ajuste de recta con mínimos cuadrados y cómo comprobar si un modelo extrapola mal fuera del rango de datos.
BERMEJA — Incendios forestales y prevención,Análisis del riesgo de fuego en el monte prevención de incendios forestales y variables meteorológicas.
TATMAN — Sesgo de género en modelos de IA,Estudio sobre el sesgo de género en reconocimiento de voz y modelos de lenguaje de inteligencia artificial.
LAENNEC — Estetoscopio y clasificación de audio,Clasificación de sonidos cardíacos y murmullos usando coeficientes MFCC y clasificadores automáticos.
KATY PAYNE — Sesión 1: Introducción a audio,Visualización y procesamiento de audios de gatos y perros comparando frecuencias e infrasonidos.
KATY PAYNE — Sesión 2: Coeficientes MFCC,Extracción de caracteristicas MFCC con librosa y análisis de boxplots antes de entrenar un modelo.
KATY PAYNE — Sesión 3: Entrenamiento Random Forest,Clasificación binaria de maullidos y ladridos con Random Forest y evaluación con matriz de confusión.
FLORENCE NIGHTINGALE — Sesión 1: Datos de Crimea,Carga de datos reales de mortalidad de la guerra de Crimea y gráfico de barras por causas de muerte.
FLORENCE NIGHTINGALE — Sesión 2: Diagrama de la Rosa,Evolución mensual de muertes reconstrucción del diagrama coxcomb polar y el impacto de la comisión sanitaria.
WALD — Sesgo del superviviente,Análisis de los blindajes de aviones en la Segunda Guerra Mundial y cómo evitar el sesgo del superviviente.
BIOTOPO — Cálculo y simulación de ecosistemas,Simulación de poblaciones animales y análisis de estabilidad mediante ecuaciones matemáticas.
CROWSON — Generación de texto e historias,Modelos generativos para creación de narrativa interactiva y síntesis de lenguaje natural.
VAUCANSON — Automátas y agentes de decisión,Agentes autónomos que toman decisiones en entornos simulados basados en reglas.
LABERINTO — Algoritmos de búsqueda de caminos,Resolución de laberintos mediante exploración en anchura profundidad y algoritmo A estrella.
2. Archivo: bm25_vs_embeddings.py (Versión Consola)
Python
"""
BM25 vs Embeddings — Laboratorio IA Local MonteSteam
======================================================
Compara dos formas distintas de buscar sobre el mismo corpus de texto:
- BM25: cuenta PALABRAS (con ajustes de frecuencia y longitud).
Implementado a mano, sin librerías, para que se vea la fórmula.
- Embeddings: compara SIGNIFICADO. El vector lo calcula un modelo de
embeddings servido por LM Studio (no el LLM conversacional).
Uso:
1. Abre LM Studio, carga un modelo de EMBEDDINGS (no un chat normal).
Modelos típicos que corren bien en CPU: nomic-embed-text-v1.5,
bge-small-en-v1.5, all-MiniLM-L6-v2.
2. Actívalo como servidor local.
3. Comprueba el nombre exacto del modelo cargado y ponlo en
EMBEDDING_MODEL más abajo.
4. python3 bm25_vs_embeddings.py
"""
import csv
import math
import re
import sys
from collections import Counter
import numpy as np
import requests
# ---------------------------------------------------------------------
# CONFIGURACIÓN — ajusta esto a tu LM Studio local
# ---------------------------------------------------------------------
LM_STUDIO_URL = "http://localhost:1234/v1/embeddings"
EMBEDDING_MODEL = "text-embedding-nomic-embed-text-v1.5"
CORPUS_PATH = "corpus.csv"
TOP_N = 5
def tokenizar(texto):
"""Minúsculas y solo palabras, sin puntuación."""
return re.findall(r"[a-záéíóúñ0-9]+", texto.lower())
class BM25:
def __init__(self, documentos_tokenizados, k1=1.5, b=0.75):
self.docs = documentos_tokenizados
self.k1 = k1
self.b = b
self.n_docs = len(documentos_tokenizados)
self.doc_lens = [len(d) for d in documentos_tokenizados]
self.avg_len = sum(self.doc_lens) / self.n_docs
self.doc_freqs = [Counter(d) for d in documentos_tokenizados]
df = Counter()
for d in documentos_tokenizados:
for palabra in set(d):
df[palabra] += 1
self.df = df
def _idf(self, palabra):
n = self.df.get(palabra, 0)
return math.log((self.n_docs - n + 0.5) / (n + 0.5) + 1)
def puntuar(self, query_tokens):
puntuaciones = [0.0] * self.n_docs
for i in range(self.n_docs):
freqs = self.doc_freqs[i]
doc_len = self.doc_lens[i]
for palabra in query_tokens:
if palabra not in freqs:
continue
f = freqs[palabra]
idf = self._idf(palabra)
numerador = f * (self.k1 + 1)
denominador = f + self.k1 * (
1 - self.b + self.b * doc_len / self.avg_len
)
puntuaciones[i] += idf * numerador / denominador
return puntuaciones
def pedir_embedding(texto):
resp = requests.post(
LM_STUDIO_URL,
json={"model": EMBEDDING_MODEL, "input": texto},
timeout=30,
)
resp.raise_for_status()
data = resp.json()
return np.array(data["data"][0]["embedding"], dtype=np.float32)
def pedir_embeddings_corpus(textos):
vectores = []
for i, t in enumerate(textos, start=1):
print(f" Calculando embedding {i}/{len(textos)}...", end="\r")
vectores.append(pedir_embedding(t))
print()
return np.vstack(vectores)
def similitud_coseno(vector_query, matriz_docs):
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 cargar_corpus(path):
filas = []
with open(path, encoding="utf-8") as f:
lector = csv.DictReader(f)
for fila in lector:
filas.append(fila)
return filas
def mostrar_ranking(titulo, filas, puntuaciones, top_n=TOP_N):
orden = np.argsort(puntuaciones)[::-1][:top_n]
print(f"\n--- {titulo} ---")
for rank, idx in enumerate(orden, start=1):
print(f"{rank}. [{puntuaciones[idx]:.3f}] {filas[idx]['titulo']}")
def main():
print("Cargando corpus...")
filas = cargar_corpus(CORPUS_PATH)
textos = [f"{f['titulo']} {f['resumen']}" for f in filas]
docs_tokenizados = [tokenizar(t) for t in textos]
bm25 = BM25(docs_tokenizados)
print("Pidiendo embeddings del corpus a LM Studio...")
try:
matriz_embeddings = pedir_embeddings_corpus(textos)
except requests.exceptions.RequestException as e:
print(f"\nERROR al conectar con LM Studio: {e}")
sys.exit(1)
print(f"Corpus cargado: {len(filas)} entradas.\n")
while True:
pregunta = input("Pregunta> ").strip()
if pregunta.lower() in ("salir", "exit", "quit"):
break
if not pregunta:
continue
query_tokens = tokenizar(pregunta)
puntos_bm25 = bm25.puntuar(query_tokens)
vector_pregunta = pedir_embedding(pregunta)
puntos_emb = similitud_coseno(vector_pregunta, matriz_embeddings)
mostrar_ranking("BM25 (palabras)", filas, puntos_bm25)
mostrar_ranking("Embeddings (significado)", filas, puntos_emb)
print()
if __name__ == "__main__":
main()
3. Archivo: app.py (Aplicación Web en FastAPI)
Python
"""
SPARCK JONES — BM25 vs Embeddings (Web)
=========================================
FastAPI + LM Studio.
Arranque:
uvicorn app:app --reload --port 8040
"""
import csv
import math
import re
from collections import Counter
import numpy as np
import requests
from fastapi import FastAPI
from fastapi.responses import HTMLResponse, JSONResponse
LM_STUDIO_URL = "http://localhost:1234/v1/embeddings"
EMBEDDING_MODEL = "text-embedding-nomic-embed-text-v1.5"
CORPUS_PATH = "corpus.csv"
TOP_N = 5
def tokenizar(texto):
return re.findall(r"[a-záéíóúñ0-9]+", texto.lower())
class BM25:
def __init__(self, documentos_tokenizados, k1=1.5, b=0.75):
self.docs = documentos_tokenizados
self.k1 = k1
self.b = b
self.n_docs = len(documentos_tokenizados)
self.doc_lens = [len(d) for d in documentos_tokenizados]
self.avg_len = sum(self.doc_lens) / self.n_docs
self.doc_freqs = [Counter(d) for d in documentos_tokenizados]
df = Counter()
for d in documentos_tokenizados:
for palabra in set(d):
df[palabra] += 1
self.df = df
def _idf(self, palabra):
n = self.df.get(palabra, 0)
return math.log((self.n_docs - n + 0.5) / (n + 0.5) + 1)
def puntuar(self, query_tokens):
puntuaciones = [0.0] * self.n_docs
for i in range(self.n_docs):
freqs = self.doc_freqs[i]
doc_len = self.doc_lens[i]
for palabra in query_tokens:
if palabra not in freqs:
continue
f = freqs[palabra]
idf = self._idf(palabra)
numerador = f * (self.k1 + 1)
denominador = f + self.k1 * (
1 - self.b + self.b * doc_len / self.avg_len
)
puntuaciones[i] += idf * numerador / denominator
return puntuaciones
def similitud_coseno(vector_query, matriz_docs):
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_embedding(texto):
resp = requests.post(
LM_STUDIO_URL,
json={"model": EMBEDDING_MODEL, "input": texto},
timeout=30,
)
resp.raise_for_status()
return np.array(resp.json()["data"][0]["embedding"], dtype=np.float32)
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 = [f"{f['titulo']} {f['resumen']}" for f in FILAS]
print("Preparando BM25...")
BM25_INDEX = BM25([tokenizar(t) for t in TEXTOS])
print("Pidiendo embeddings del corpus a LM Studio...")
try:
MATRIZ_EMBEDDINGS = np.vstack([pedir_embedding(t) for t in TEXTOS])
print(f"Listo: {len(FILAS)} entradas indexadas.")
except requests.exceptions.RequestException as e:
print(f"AVISO: no se pudo conectar con LM Studio al arrancar ({e}).")
MATRIZ_EMBEDDINGS = None
app = FastAPI(title="SPARCK JONES — BM25 vs Embeddings")
@app.get("/", response_class=HTMLResponse)
def home():
return PAGINA
@app.get("/api/buscar")
def buscar(q: str):
query_tokens = tokenizar(q)
puntos_bm25 = BM25_INDEX.puntuar(query_tokens)
orden_bm25 = np.argsort(puntos_bm25)[::-1][:TOP_N]
resultado_bm25 = [
{"titulo": FILAS[i]["titulo"], "score": round(float(puntos_bm25[i]), 3)}
for i in orden_bm25
]
if MATRIZ_EMBEDDINGS is None:
return JSONResponse(
status_code=503,
content={"error": "LM Studio no responde. Enciende el servidor y reinicia esta app."},
)
try:
vector_q = pedir_embedding(q)
except requests.exceptions.RequestException as e:
return JSONResponse(status_code=503, content={"error": f"Error llamando a LM Studio: {e}"})
puntos_emb = similitud_coseno(vector_q, MATRIZ_EMBEDDINGS)
orden_emb = np.argsort(puntos_emb)[::-1][:TOP_N]
resultado_emb = [
{"titulo": FILAS[i]["titulo"], "score": round(float(puntos_emb[i]), 3)}
for i in orden_emb
]
return {"bm25": resultado_bm25, "embeddings": resultado_emb}
PAGINA = """
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>SPARCK JONES — BM25 vs Embeddings</title>
<style>
:root {
--verde: #00863E;
--verde-oscuro: #006633;
--verde-claro: #E8F5EC;
--verde-borde: #BFE3CC;
}
body { font-family: system-ui, sans-serif; max-width: 900px; margin: 40px auto; padding: 0 20px; background: #fafafa; color: #222; }
h1 { font-size: 1.5rem; color: var(--verde-oscuro); margin-bottom: 4px; }
.subtitulo { color: #666; margin-bottom: 30px; }
form { display: flex; gap: 10px; margin-bottom: 30px; }
input[type=text] { flex: 1; padding: 10px 14px; 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 { padding: 10px 20px; font-size: 1rem; border: none; border-radius: 6px; background: var(--verde); color: white; cursor: pointer; }
button:hover:not(:disabled) { background: var(--verde-oscuro); }
button:disabled { background: #a8a8a8; }
.columnas { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; margin-bottom: 40px; }
.columna { background: white; border: 1px solid var(--verde-borde); border-radius: 8px; padding: 16px 20px; }
.columna h2 { font-size: 1rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--verde-oscuro); border-bottom: 2px solid var(--verde-claro); padding-bottom: 8px; margin-top: 0; }
.resultado { padding: 10px 0; border-bottom: 1px solid var(--verde-claro); }
.resultado:last-child { border-bottom: none; }
.resultado .score { color: var(--verde); font-weight: 600; font-size: 0.85rem; }
.estado { color: #888; font-style: italic; }
.explicacion { background: var(--verde-claro); border: 1px solid var(--verde-borde); border-radius: 8px; padding: 24px 28px; margin-top: 10px; }
.explicacion h2 { color: var(--verde-oscuro); font-size: 1.1rem; margin-top: 0; }
.explicacion h3 { color: var(--verde-oscuro); font-size: 0.95rem; margin-bottom: 4px; }
.explicacion p { line-height: 1.5; color: #333; }
.explicacion ul { line-height: 1.6; color: #333; }
footer { text-align: center; color: #999; font-size: 0.8rem; margin-top: 30px; }
</style>
</head>
<body>
<h1>SPARCK JONES</h1>
<p class="subtitulo">BM25 (palabras) vs Embeddings (significado) sobre el corpus de AIDARAC</p>
<form id="form-busqueda">
<input type="text" id="pregunta" placeholder="Escribe una pregunta..." autofocus>
<button type="submit" id="boton">Buscar</button>
</form>
<div class="columnas">
<div class="columna">
<h2>BM25 — palabras</h2>
<div id="resultados-bm25"><p class="estado">Escribe una pregunta arriba.</p></div>
</div>
<div class="columna">
<h2>Embeddings — significado</h2>
<div id="resultados-emb"><p class="estado">Escribe una pregunta arriba.</p></div>
</div>
</div>
<div class="explicacion">
<h2>¿Qué está pasando aquí?</h2>
<p>
Esta página compara <strong>dos formas distintas de buscar</strong> sobre las mismas
entradas del blog de AIDARAC. Las dos reciben la misma pregunta, pero la
resuelven de maneras completamente diferentes.
</p>
<h3>BM25 (columna izquierda)</h3>
<p>
Cuenta <strong>palabras</strong>. Compara literalmente qué palabras de tu pregunta
aparecen en cada entrada, dando más peso a las palabras raras.
</p>
<h3>Embeddings (columna derecha)</h3>
<p>
Compara <strong>significado</strong>. Un modelo de IA convierte tu pregunta y cada entrada
en un vector numérico en un espacio conceptual.
</p>
<h3>¿Qué significan los números?</h3>
<ul>
<li><strong>BM25:</strong> Puntuación libre desde 0 en adelante.</li>
<li><strong>Embeddings:</strong> Similitud coseno entre -1 y 1 (cercano a 1 indica alta similitud).</li>
</ul>
</div>
<footer>SPARCK JONES · Laboratorio IA Local MonteSteam</footer>
<script>
const form = document.getElementById('form-busqueda');
const boton = document.getElementById('boton');
function pintar(contenedor, resultados) {
contenedor.innerHTML = resultados.map(r =>
`<div class="resultado">${r.titulo}<br><span class="score">${r.score}</span></div>`
).join('');
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
const pregunta = document.getElementById('pregunta').value.trim();
if (!pregunta) return;
boton.disabled = true;
boton.textContent = 'Buscando...';
try {
const resp = await fetch('/api/buscar?q=' + encodeURIComponent(pregunta));
const data = await resp.json();
if (data.error) {
document.getElementById('resultados-emb').innerHTML = `<p class="estado">${data.error}</p>`;
document.getElementById('resultados-bm25').innerHTML = `<p class="estado">${data.error}</p>`;
} else {
pintar(document.getElementById('resultados-bm25'), data.bm25);
pintar(document.getElementById('resultados-emb'), data.embeddings);
}
} catch (err) {
document.getElementById('resultados-emb').innerHTML = `<p class="estado">Error de conexión: ${err}</p>`;
} finally {
boton.disabled = false;
boton.textContent = 'Buscar';
}
});
</script>
</body>
</html>
"""



