
Llevo años usando grep, como todo linuxero que se precie. Y oye, grep es fantástico, pero tiene una limitación que cada vez me frustraba más: busca coincidencias literales. Le pides factura y te devuelve líneas con esa palabra, pero nunca recibo, pago, abono o cuenta. Hasta que descubrí los embeddings. Y no, no hace falta ser un máster en inteligencia artificial ni tener un clúster de GPUs. Con un equipo normal y una CPU moderna, puedes convertir cualquier texto en coordenadas de 1024 dimensiones y hacer búsquedas inteligentes. Justo de eso va este episodio.
La cuestión es que los embeddings no son un concepto nuevo. Llevan años usándose en procesamiento de lenguaje natural, pero hasta hace poco requerían infraestructura especializada: GPUs potentes, datasets enormes, conocimientos de deep learning. Sin embargo, con la llegada de modelos como BGE-M3 y herramientas como Ollama, todo esto ha cambiado. Ahora cualquier linuxero con un equipo medio puede generar embeddings de calidad en cuestión de segundos, directamente desde la terminal o con un script de Python. Y no solo eso, sino que los resultados son sorprendentemente buenos.
Pero antes de meternos en faena, un poco de contexto. En los últimos meses hemos estado construyendo los cimientos de lo que será nuestro cerebro digital, especialmente en el episodio 809 sobre RAG casero. Sin embargo, pensé que antes de montar toda la infraestructura, merecía la pena mostrar un camino intermedio, una solución que no requiere montar todo el tinglado pero que ya resuelve problemas muy concretos.
¿Qué son los embeddings?
Vamos a empezar por el principio. Un embedding no es ni más ni menos que la representación numérica de una palabra, una frase o un párrafo completo en forma de coordenadas. Imagina que pudiéramos colocar cada palabra en un mapa, de manera que las palabras con significados similares quedaran cerca y las diferentes, lejos.
La palabra gato estaría cerca de perro y de mascota, pero lejos de nevera y de coche. Esto es exactamente lo que consiguen los embeddings, capturar el significado semántico en coordenadas numéricas.
Ahora bien, aquí viene lo alucinante. No estamos hablando de dos o tres dimensiones como las del plano cartesiano. Los embeddings modernos trabajan con espacios de 384, 768 o incluso 1024 dimensiones. Ostras Lorenzo, si ya me cuesta visualizar tres dimensiones, ¿cómo voy a entender 1024?, estarás pensando. Pues no te preocupes, porque no tienes que visualizarlas. El ordenador hace el trabajo pesado.
Cada una de esas 1024 dimensiones captura una característica semántica diferente. No sabemos exactamente qué representa cada una, podría ser una combinación de aspectos gramaticales, contextuales y temáticos, pero el modelo es capaz de organizar esa información de manera que las palabras con significados parecidos acaben teniendo vectores parecidos.
Y lo mejor es que con los embeddings podemos hacer operaciones matemáticas con palabras. Sí, como lo oyes. Si al vector de rey le restas el vector de hombre y le sumas el vector de mujer, obtienes el vector de reina. Esto no es un truco de magia, es que los embeddings capturan relaciones analógicas. El modelo ha aprendido que rey es a hombre lo que reina es a mujer, y la aritmética vectorial lo refleja perfectamente.
La primera vez que vi esto en acción me quedé con la boca abierta. Es una de esas cosas que parecen imposibles hasta que las pruebas tú mismo.
Modelos de embeddings disponibles
En el ecosistema actual hay varios modelos de embeddings, cada uno con sus características. El que yo estoy utilizando ahora mismo es BGE-M3, desarrollado por la Academia de Inteligencia Artificial de Pekín, BAAI. Es multilenguaje, funciona especialmente bien con español, y genera vectores de 1024 dimensiones. Esto le da una granularidad muy buena para capturar matices semánticos.
Pero no es la única opción. También tienes modelos como all-MiniLM-L6-v2, que genera vectores de 384 dimensiones. Es más ligero y entre tres y cuatro veces más rápido que BGE-M3. Para muchos casos de uso, especialmente cuando trabajas con volúmenes grandes de documentos, puede ser suficiente.
La elección del modelo depende de tus necesidades. Si trabajas con textos en español y necesitas la máxima precisión semántica, BGE-M3 es una excelente opción. Si priorizas la velocidad y trabajas con textos más simples o en inglés, all-MiniLM-L6-v2 te va de perlas. Y ojo, que entre medias tienes modelos como gte-small o instructor-xl, cada uno con sus puntos fuertes. Mi recomendación, que pruebes un par de ellos con tus propios textos y veas cuál te da mejores resultados antes de decidirte.
Cómo generar embeddings con Ollama
Para generar embeddings, lo primero que necesitas es tener Ollama funcionando en tu equipo. Si has seguido episodios anteriores, seguro que ya lo tienes instalado. Si no, es tan sencillo como ir a ollama.ai y seguir las instrucciones.
Una vez que tienes Ollama funcionando, descargas un modelo de embeddings. Yo uso BGE-M3:
ollama pull bge-m3
Y ya está. Con esto, puedes generar embeddings de dos formas. La primera, directamente desde la terminal con curl:
curl -X POST http://localhost:11434/api/embed \
-H "Content-Type: application/json" \
-d '{
"model": "bge-m3",
"input": "Hola mundo"
}'
Lo que obtienes es un vector de 1024 coordenadas. Con jq puedes procesar la salida:
curl -s -X POST http://localhost:11434/api/embed \
-H "Content-Type: application/json" \
-d '{"model": "bge-m3", "input": "Hola mundo"}' | jq '.embeddings[0] | length'
Y te devolverá 1024. Así de sencillo es convertir cualquier texto en un punto de 1024 dimensiones.
La segunda forma, mucho más práctica, es con Python:
import ollama
resultado = ollama.embed(
model='bge-m3',
input='Hola mundo'
)
print(len(resultado['embeddings'][0])) # 1024
Búsqueda semántica, más allá de grep
Vale, ya sabemos generar embeddings. ¿Y ahora qué? Pues a usarlos para hacer cosas útiles.
La búsqueda semántica es el caso de uso más inmediato. Como te decía, grep busca coincidencias literales. Si buscas factura, te encuentra factura pero no recibo, pago, cuenta o abono. La búsqueda semántica entiende que todas esas palabras están relacionadas.
El mecanismo es simple, conviertes tu consulta a un embedding, lo comparas con los embeddings de tus documentos, y devuelves aquellos que estén más cerca en el espacio vectorial. La comparación se hace con la similitud del coseno o la distancia euclídea. No te asustes, que esto no es más que una fórmula matemática que mide lo parecidos que son dos vectores.
Te pongo un ejemplo concreto. Imagina que tienes documentos de facturas, apuntes de Linux, recetas de cocina, compras e ideas para proyectos. Si preguntas cuánto dinero gastamos en servidores, la búsqueda semántica te dice que el documento más cercano es el de facturas. Si preguntas qué hay de cenar, te dice el de recetas. Si preguntas alguna idea para un proyecto de IA, te señala el de ideas para proyectos.
Con 10 documentos puedes hacerlo a mano, pero ¿y si tienes 100, 1000 o 10000? Ahí es donde este enfoque demuestra su potencia.
Lo mejor es que para empezar no necesitas ni base de datos. Con Python y NumPy tienes suficiente:
import numpy as np
import ollama
def generar_embedding(texto):
respuesta = ollama.embed(model='bge-m3', input=texto)
return np.array(respuesta['embeddings'][0])
def buscar(documentos, consulta):
emb_consulta = generar_embedding(consulta)
resultados = []
for i, doc in enumerate(documentos):
emb_doc = generar_embedding(doc)
similitud = np.dot(emb_consulta, emb_doc) / (
np.linalg.norm(emb_consulta) * np.linalg.norm(emb_doc)
)
resultados.append((similitud, i, doc))
resultados.sort(reverse=True)
return resultados
Este script calcula la similitud del coseno entre tu consulta y cada documento. Es alucinante lo poco que se necesita para tener un sistema de búsqueda semántica funcional.
Bases de datos vectoriales para escalar
Cuando el número de documentos crece, comparar uno a uno se vuelve ineficiente. Para 10000 documentos, necesitarías 10000 llamadas al modelo y 10000 comparaciones por consulta. Esto no escala.
Aquí entran las bases de datos vectoriales. Son sistemas especializados en almacenar vectores y hacer búsquedas por similitud de forma eficiente, usando índices como HNSW que permiten encontrar los vecinos más cercanos en tiempo logarítmico.
La cuestión es que no estamos hablando de magia, sino de estructuras de datos bien conocidas. HNSW, por ejemplo, organiza los vectores en un grafo multinivel que permite saltar directamente a la región del espacio donde probablemente están los vecinos más cercanos. Para que te hagas una idea, con 100000 vectores y HNSW, una consulta puede resolverse en unos pocos milisegundos. Sin índice, la misma consulta tardaría segundos enteros. Esto es justo el corazón del RAG que empezamos a construir en el episodio 809 y que continuaremos en los 819 y 821. La idea es montar nuestro propio sistema de Retrieval Augmented Generation, pero antes de llegar ahí, quería mostraros todo lo que se puede hacer solo con embeddings, sin montar toda la infraestructura.
Existen varias opciones de bases de datos vectoriales, cada una con sus particularidades. ChromaDB es la más sencilla, ideal para empezar. pgvector te permite usar PostgreSQL como base de datos vectorial, lo que evita tener que gestionar un sistema adicional. Qdrant es otra alternativa con muy buen rendimiento. Y si necesitas escalar a millones de vectores, Milvus o Weaviate son opciones más que contrastadas. La buena noticia es que el concepto es el mismo en todas, cambia la implementación pero no la forma de usarlas.
Clasificación sin entrenar el modelo
Este es, para mi, uno de los usos más prácticos. Imagina que cada día recibes cien correos y quieres clasificarlos en urgentes, normales y spam. Con un enfoque tradicional, tendrías que entrenar un modelo de clasificación, con dataset etiquetado, entrenamiento, validación…
Con embeddings, el enfoque es radicalmente distinto. No entrenas nada. Simplemente defines unos pocos ejemplos para cada categoría y dejas que la similitud semántica haga el trabajo.
Por ejemplo, para la categoría urgente pones tres o cuatro ejemplos:
- Se ha caído el servidor
- Mañana vence el plazo
- Error crítico en producción
Para normal:
- Te envío el documento, confirma la recepción
- Quedamos para la reunión semanal
Para spam:
- Gana dinero rápido con este método
- Tu tía Gertrudis te ha dejado un millón de euros
Por cada categoría defines varios ejemplos, calculas sus embeddings, y cuando llega un correo nuevo, calculas su embedding y lo comparas con todos. La categoría del ejemplo más cercano es la categoría del correo.
¿Y qué tal funciona? Pues con tres o cuatro ejemplos por categoría ya tienes alrededor de un 80% de acierto. Si subes a diez ejemplos, alcanzas el 90% fácilmente. Sin GPU, sin dataset, sin entrenamiento. Solo Ollama corriendo en tu CPU.
Pero no solo puedes clasificar correos. Puedes clasificar sentimientos, tipos de documento, prioridades en tareas, preguntas técnicas versus quejas de usuario… cualquier cosa que se te ocurra.
En el ejemplo interactivo que he preparado para el episodio, el clasificador recibe mensajes como el servidor web no responde desde las 3 de la mañana y lo clasifica como urgente con un 69% de confianza. Te adjunto la factura para su revisión va a normal. Felicidades, has ganado un viaje al Caribe lo detecta como spam con un 60%. No está nada mal para un clasificador que no ha visto ni un solo ejemplo de entrenamiento. Y si quisieras mejorarlo, basta con añadir más ejemplos a cada categoría. En serio, con diez ejemplos bien escogidos por categoría, el acierto se dispara por encima del 90%. Y todo corriendo en tu CPU, sin GPU, sin enviar datos a ninguna nube.
Deduplicación de documentos con embeddings
Otro caso de uso que me encanta es la detección de duplicados. Hasta ahora usábamos herramientas como fdupes o jdupes, que comparan el hash del contenido. El problema es que si cambias una coma, el hash cambia por completo y el duplicado pasa desapercibido.
Con embeddings, la cosa cambia. Calculas el embedding de cada documento y comparas las similitudes. Cuando la similitud es superior al 95%, los documentos son prácticamente iguales. Cuando es superior al 85%, están muy relacionados. Cuando supera el 70%, están relacionados pero son distintos.
Esto viene muy bien para:
- Enlaces repetidos, si gestionas una colección de bookmarks y has guardado el mismo enlace dos veces con distinto título
- Artículos casi idénticos en un feed RSS, donde recibes el mismo artículo con distinto titular
- Versiones del mismo documento, el mismo email reenviado tres veces con distinto asunto
- Notas personales en Obsidian, donde puedes encontrar las que hablan del mismo tema y fusionarlas
Te pongo un ejemplo real: Cómo instalar Ollama en Ubuntu 24.04 paso a paso y Guía completa para instalar Ollama en Ubuntu 24.04 dan una similitud del 95%. Son el mismo contenido con distinto título. La deduplicación semántica los marca al instante.
Sistema de recomendaciones con similitud semántica
Llegamos a la guinda del pastel. El principio es el mismo, pero aplicado de forma distinta.
Imagina que tienes un blog con cien artículos. Alguien te pregunta ¿Tienes algo sobre cómo montar un modelo de inteligencia artificial en mi equipo? Es muy probable que ningún artículo contenga exactamente esas palabras. Pero con una búsqueda semántica, el sistema encuentra los artículos que hablan sobre Ollama, modelos locales, instalación de herramientas de IA, y te los recomienda.
En mi ejemplo interactivo, si pregunto modelos de lenguaje locales en tu servidor, el sistema me recomienda artículos relacionados. Además, puedo agrupar todos los artículos por similitud temática, crear clusters, y ofrecer recomendaciones contextuales.
Las aplicaciones son infinitas:
- Recomendación de recetas basada en ingredientes que tienes
- Agrupación de música por estilo
- Organización de bookmarks por temas
- Descubrimiento de patrones en correos
- Recomendación de artículos relacionados en tu blog
Lo mejor de todo es que el mecanismo es siempre el mismo. Da igual si buscas, clasificas, deduplicas o recomiendas, coges el texto, generas el embedding, y comparas. La magia está en los umbrales que elijas y en cómo interpretes los resultados.
ChromaDB, base de datos vectorial persistente
Para terminar, hablemos de ChromaDB. Hasta ahora hemos trabajado con scripts que generan embeddings sobre la marcha y los comparan en memoria. Esto funciona para volúmenes pequeños, pero cuando tienes miles de documentos, necesitas algo más robusto.
ChromaDB es una base de datos vectorial open source diseñada para ser sencilla. Se instala con un simple pip install chromadb y ya está lista. No necesitas montar un servidor ni configurar nada complejo.
Sus ventajas principales son:
- Persistencia en disco, los embeddings se guardan y no tienes que recalcularlos
- Filtros por metadatos, puedes añadir autor, fecha o categoría y filtrar por ellos
- Modo cliente-servidor, puedes tener ChromaDB en un servidor y consultarlo desde cualquier equipo
- Búsqueda eficiente con índices HNSW para encontrar vecinos en milisegundos
El flujo de trabajo es muy sencillo. Creas una colección, añades tus documentos con sus embeddings y metadatos, y luego haces consultas:
import chromadb
cliente = chromadb.PersistentClient(path="./mi_bd_vectorial")
coleccion = cliente.get_or_create_collection("mis_documentos")
coleccion.add(
ids=["doc1", "doc2", "doc3"],
documents=["Factura del servidor de marzo", "Receta de tortilla de patatas", "Cómo instalar Ollama"],
metadatas=[{"tipo": "factura"}, {"tipo": "cocina"}, {"tipo": "tutorial"}]
)
resultado = coleccion.query(
query_texts=["¿Cuánto pagamos de hosting?"],
n_results=2
)
print(resultado["documents"])
Así de simple. ChromaDB se encarga de generar los embeddings, almacenarlos y hacer las búsquedas de forma eficiente. Y puedes escalar desde un script de prueba hasta un sistema de producción sin cambiar de herramienta.
Y si quieres el flujo completo,
#!/usr/bin/env python3
"""
🚀 Embeddings para todo — Demo completa
========================================
Episodio 817: Un solo script que recorre todos los casos de uso.
Búsqueda semántica, clasificación, deduplicación, recomendación y ChromaDB.
Requisitos:
- Ollama corriendo en localhost:11434
- Modelo nomic-embed-text descargado: ollama pull nomic-embed-text
- Python 3.10+
Uso:
python3 setup_completo.py # Ejecuta todo
python3 setup_completo.py --fast # Solo pasos principales, sin ChromaDB
python3 setup_completo.py --list # Lista los pasos disponibles
python3 setup_completo.py --step 3 # Ejecuta solo el paso 3
python3 setup_completo.py --step 2,4,6 # Ejecuta pasos específicos
python3 setup_completo.py --interactive # Paso a paso con confirmación
"""
import os, sys, json, math, glob, time, shutil, subprocess, textwrap, argparse
# ─── Configuración ────────────────────────────────────────────────────────
MODEL = "bge-m3"
OLLAMA_URL = "http://localhost:11434"
DIR_DOCS = "./docs_prueba" # Documentos de prueba para búsqueda semántica
DIR_CHROMA = "./chroma_store" # Donde ChromaDB guarda los datos
# Colores para la terminal
VERDE = "\033[92m"
AZUL = "\033[94m"
AMAR = "\033[93m"
ROJO = "\033[91m"
FIN = "\033[0m"
NEGR = "\033[1m"
def ok(msg):
print(f" {VERDE}✅{FIN} {msg}")
def info(msg):
print(f" {AZUL}ℹ️{FIN} {msg}")
def warn(msg):
print(f" {AMAR}⚠️{FIN} {msg}")
def error(msg):
print(f" {ROJO}❌{FIN} {msg}")
def titulo(t):
print(f"\n{NEGR}{'=' * 60}{FIN}\n{NEGR} {t}{FIN}\n{'=' * 60}")
# ─── Helpers ──────────────────────────────────────────────────────────────
def check_ollama():
"""Verifica que Ollama está corriendo y el modelo está disponible."""
try:
r = subprocess.run(
["curl", "-s", f"{OLLAMA_URL}/api/tags"],
capture_output=True,
text=True,
timeout=5,
)
if r.returncode != 0:
error("Ollama no responde. ¿Está corriendo?")
info("Ejecuta: ollama serve")
return False
modelos = json.loads(r.stdout)
disponible = any(MODEL in m["name"] for m in modelos.get("models", []))
if not disponible:
warn(f"Modelo {MODEL} no encontrado. Descargando...")
subprocess.run(["ollama", "pull", MODEL], check=True)
ok(f"Modelo {MODEL} descargado")
return True
except Exception as e:
error(f"Error conectando con Ollama: {e}")
return False
def emb(texto):
"""Genera un embedding usando Ollama."""
payload = json.dumps({"model": MODEL, "prompt": texto})
r = subprocess.run(
["curl", "-s", f"{OLLAMA_URL}/api/embeddings", "-d", payload],
capture_output=True,
text=True,
)
return json.loads(r.stdout)["embedding"]
def cos_sim(a, b):
"""Similitud coseno entre dos vectores."""
dot = sum(x * y for x, y in zip(a, b))
na = math.sqrt(sum(x * x for x in a))
nb = math.sqrt(sum(y * y for y in b))
return dot / (na * nb) if na and nb else 0
# ─── 1. Crear documentos de prueba ────────────────────────────────────────
def crear_documentos():
titulo("1️⃣ CREAR DOCUMENTOS DE PRUEBA")
if os.path.exists(DIR_DOCS):
shutil.rmtree(DIR_DOCS)
os.makedirs(DIR_DOCS)
documentos = {
"facturas.txt": """Factura hosting enero 2026: 12.99€
Factura dominio atareao.es 2026: 18.50€
Pago servidor VPS febrero: 24.99€
Total gastos infraestructura Q1: 156.47€
Próximo pago hosting: 1 de marzo""",
"apuntes-linux.txt": """Cómo montar un servidor web con Nginx en Ubuntu 24.04
Configurar firewall con nftables: tabla inet filter con policy drop
Instalar Docker: sudo apt install docker.io docker-compose
Los logs del sistema están en /var/log/syslog
systemctl enable --now para activar servicios al arranque""",
"recetas.txt": """Paella valenciana: arroz bomba, pollo, conejo, judía verde, garrofón
Tortilla de patatas: 4 huevos, 2 patatas grandes, cebolla, aceite de oliva
Pan casero: 500g harina de fuerza, 300ml agua, 10g sal, 5g levadura
La clave de un buen arroz es el sofrito y el caldo caliente""",
"compras.txt": """Lista de la compra: leche, huevos, pan, arroz, tomates, lechuga
Comprar regalo cumpleaños: libro de Rust
Recoger paquete en Correos: número de seguimiento 123456789
Repostar coche: gasolina 95, 40€""",
"ideas-proyectos.txt": """App web para gestionar gastos compartidos con amigos
Script que monitoriza el precio de la luz y avisa cuando baja
Bot de Telegram que resuma noticias de IA cada mañana
Generador de menú semanal con recetas de un archivo de cocina""",
}
for nombre, contenido in documentos.items():
with open(os.path.join(DIR_DOCS, nombre), "w") as f:
f.write(contenido.strip())
ok(f"Documento creado: {nombre}")
info("5 documentos de prueba listos en ./docs_prueba/")
return list(documentos.keys())
# ─── 2. Búsqueda semántica ────────────────────────────────────────────────
def busqueda_semantica():
titulo("2️⃣ BÚSQUEDA SEMÁNTICA — buscar por significado")
# Indexar documentos
info("Generando embeddings de los documentos de prueba...")
archivos = sorted(glob.glob(f"{DIR_DOCS}/*.txt"))
index = []
for path in archivos:
with open(path) as f:
texto = f.read()
nombre = os.path.basename(path)
vec = emb(texto)
index.append((nombre, vec, texto))
print(f" {nombre}: {len(vec)} dimensiones")
ok(f"{len(index)} documentos indexados")
# Consultas de prueba
consultas = [
"¿Cuánto dinero gastamos en servidores?",
"¿Qué hay para cenar esta noche?",
"Alguna idea para un proyecto con IA",
]
for consulta in consultas:
print(f'\n {NEGR}🔍 Consulta:{FIN} "{consulta}"')
vec_q = emb(consulta)
resultados = sorted(
[
(nombre, cos_sim(vec_q, vec), texto[:100])
for nombre, vec, texto in index
],
key=lambda x: -x[1],
)
for nombre, sim, preview in resultados[:3]:
barra = "█" * int(sim * 20) + "░" * (20 - int(sim * 20))
print(f" {barra} {sim:.4f} {nombre}")
mejor = resultados[0]
print(f" → Mejor resultado: {NEGR}{mejor[0]}{FIN} ({mejor[1]:.1%})")
if mejor[1] > 0.7:
ok("Coincidencia semántica alta")
elif mejor[1] > 0.5:
info("Coincidencia semántica media")
else:
warn("Coincidencia baja — prueba con otra consulta")
# ─── 3. Clasificación ─────────────────────────────────────────────────────
def clasificacion():
titulo("3️⃣ CLASIFICACIÓN — sin entrenar modelos")
categorias = {
"urgente": [
"se ha caído el servidor de producción",
"necesito esto para mañana sin falta",
"error crítico en la base de datos",
],
"normal": [
"te envío el documento actualizado",
"confirma si has recibido el email",
],
"spam": [
"gana dinero rápido desde casa",
"has ganado un premio exclusivo",
],
"cocina": [
"receta de paella valenciana",
"cómo hacer pan casero",
],
}
# Pre-calcular embedding de cada categoría (promedio de ejemplos)
info("Calculando firmas de categorías...")
firmas = {}
for cat, ejemplos in categorias.items():
vecs = [emb(e) for e in ejemplos]
firmas[cat] = [sum(vals) / len(vals) for vals in zip(*vecs)]
ok(f"Categoría '{cat}' lista ({len(ejemplos)} ejemplos)")
# Textos a clasificar
textos_prueba = [
"el servidor web no responde desde las 3 de la mañana",
"te adjunto la factura de este mes para tu revisión",
"FELICIDADES HAS GANADO UN VIAJE AL CARIBE",
"cómo hacer una tortilla de patatas con cebolla caramelizada",
]
for texto in textos_prueba:
vec = emb(texto)
resultados = sorted(
[(cat, cos_sim(vec, firma)) for cat, firma in firmas.items()],
key=lambda x: -x[1],
)
mejor = resultados[0]
barra = "█" * int(mejor[1] * 20) + "░" * (20 - int(mejor[1] * 20))
print(f'\n 📝 "{texto[:60]}..."')
print(f" → {barra} {NEGR}{mejor[0]}{FIN} ({mejor[1]:.1%})")
# ─── 4. Deduplicación ─────────────────────────────────────────────────────
def deduplicacion():
titulo("4️⃣ DEDUPLICACIÓN — encontrar contenido repetido")
items = [
{"id": "doc1", "texto": "Cómo instalar Ollama en Ubuntu 24.04 paso a paso"},
{"id": "doc2", "texto": "Guía completa para instalar Ollama en Ubuntu 24.04"},
{"id": "doc3", "texto": "Receta de paella valenciana tradicional con marisco"},
{"id": "doc4", "texto": "Cómo configurar nftables como firewall en Linux"},
{"id": "doc5", "texto": "Instalación de Ollama en Ubuntu 24.04 - tutorial"},
]
info("Generando embeddings...")
embebidos = [(item, emb(item["texto"])) for item in items]
print(f"\n {'Item':<10} {'Similitud con':<20} {'Score':<8}")
print(f" {'-' * 40}")
for i, (item_a, emb_a) in enumerate(embebidos):
for j, (item_b, emb_b) in enumerate(embebidos[i + 1 :], i + 1):
sim = cos_sim(emb_a, emb_b)
status = (
"🔴 DUPLICADO"
if sim > 0.90
else "🟡 PARECIDO"
if sim > 0.75
else "🟢 DISTINTO"
)
print(f" {item_a['id']:<10} {item_b['id']:<20} {sim:.4f} {status}")
# Detectar duplicados
duplicados = [
(item_a, item_b, sim)
for (item_a, emb_a), (item_b, emb_b) in [
((items[i], embebidos[i][1]), (items[j], embebidos[j][1]))
for i in range(len(items))
for j in range(i + 1, len(items))
]
if (sim := cos_sim(emb_a, emb_b)) > 0.85
]
if duplicados:
print(
f"\n {NEGR}🔴 Se detectaron {len(duplicados)} grupos de duplicados:{FIN}"
)
for a, b, sim in duplicados:
print(f' • "{a["texto"][:50]}..."')
print(f' "{b["texto"][:50]}..."')
print(f" → Similitud: {sim:.1%}")
else:
info("No se detectaron duplicados significativos")
# ─── 5. Recomendación ─────────────────────────────────────────────────────
def recomendacion():
titulo("5️⃣ RECOMENDACIÓN — más como esto")
articulos = [
"Linux es el mejor sistema operativo para servidores",
"Cómo instalar Docker en Ubuntu 24.04",
"Introducción a Rust: seguridad de memoria sin recolector de basura",
"PostgreSQL con pgvector para búsqueda semántica",
"Ollama: modelos de lenguaje locales en tu servidor",
"Configurar nftables como firewall en Linux",
"Rust vs Go: comparativa para backend en 2026",
"Embeddings: coordenadas en un mapa de significados",
]
info("Generando embeddings de {len(articulos)} artículos...")
index = [(tit, emb(tit)) for tit in articulos]
# Elegir un artículo de referencia
ref = "Ollama: modelos de lenguaje locales en tu servidor"
print(f'\n 📖 Artículo de referencia: "{ref}"')
print(f"\n {NEGR}📚 Recomendaciones:{FIN}")
vec_ref = emb(ref)
recomendados = sorted(
[(tit, cos_sim(vec_ref, vec)) for tit, vec in index if tit != ref],
key=lambda x: -x[1],
)
for i, (tit, sim) in enumerate(recomendados[:4], 1):
barra = "█" * int(sim * 20) + "░" * (20 - int(sim * 20))
print(f" {i}. {barra} {sim:.4f} {tit}")
# ─── 6. ChromaDB ──────────────────────────────────────────────────────────
def chroma_demo():
titulo("6️⃣ CHROMADB — base de datos vectorial")
try:
import chromadb
from chromadb.utils import embedding_functions
except ImportError:
warn("chromadb no instalado. Ejecuta: pip install chromadb")
return
# Limpiar y crear
if os.path.exists(DIR_CHROMA):
shutil.rmtree(DIR_CHROMA)
info("Creando base de datos vectorial con ChromaDB...")
client = chromadb.PersistentClient(path=DIR_CHROMA)
collection = client.get_or_create_collection(
name="demo_817",
embedding_function=embedding_functions.OllamaEmbeddingFunction(
url=f"{OLLAMA_URL}/api/embeddings", model_name=MODEL
),
)
# Documentos de prueba
docs = [
"Linux es el mejor sistema operativo para servidores por su estabilidad",
"Para instalar Docker en Ubuntu ejecuta sudo apt install docker.io",
"La paella valenciana lleva arroz, pollo, conejo, judía y garrofón",
"Ollama permite ejecutar modelos de lenguaje localmente en tu hardware",
"Rust es un lenguaje de programación de sistemas seguro y rápido",
"PostgreSQL con pgvector permite búsqueda semántica sobre documentos",
"Los embeddings convierten texto en vectores numéricos",
"El jamón ibérico de bellota se diferencia por la dieta del cerdo",
]
metadatos = [
{"tema": "linux"},
{"tema": "linux"},
{"tema": "cocina"},
{"tema": "ia"},
{"tema": "programacion"},
{"tema": "ia"},
{"tema": "ia"},
{"tema": "cocina"},
]
ids = [f"doc_{i}" for i in range(len(docs))]
collection.add(documents=docs, metadatas=metadatos, ids=ids)
ok(f"{len(docs)} documentos indexados en ChromaDB")
# Búsqueda
consultas = [
"¿Qué framework usar para backend?",
"Dime algo de cocina",
]
for consulta in consultas:
print(f'\n {NEGR}🔍 Consulta:{FIN} "{consulta}"')
results = collection.query(query_texts=[consulta], n_results=3)
for doc, meta, dist in zip(
results["documents"][0], results["metadatas"][0], results["distances"][0]
):
conf = 1 - dist
barra = "█" * int(conf * 20) + "░" * (20 - int(conf * 20))
print(f" {barra} {conf:.1%} [{meta['tema']}] {doc}")
# Filtro por metadatos
print(f" 🔽 Con filtro [tema=linux]:")
filtrados = collection.query(
query_texts=[consulta], n_results=2, where={"tema": "linux"}
)
for doc in filtrados["documents"][0]:
print(f" 🐧 {doc}")
# Persistencia
if os.path.exists(DIR_CHROMA):
ok(f"Datos persistentes en {DIR_CHROMA}/")
info("Cierra y abre Python — los datos siguen ahí")
# Limpiar
shutil.rmtree(DIR_CHROMA)
info("ChromaDB limpiada (demo temporal)")
# ─── MAIN ──────────────────────────────────────────────────────────────────
PASOS = {
1: ("Crear documentos de prueba", crear_documentos, True),
2: ("Búsqueda semántica", busqueda_semantica, True),
3: ("Clasificación", clasificacion, True),
4: ("Deduplicación", deduplicacion, True),
5: ("Recomendación", recomendacion, True),
6: ("ChromaDB", chroma_demo, False),
}
def listar_pasos():
print(f"\n{'=' * 60}")
print(f" PASOS DISPONIBLES")
print(f"{'=' * 60}")
for n in sorted(PASOS):
nombre, _, esencial = PASOS[n]
req = " (requiere chromadb)" if not esencial else ""
print(f" {n}. {nombre}{req}")
print(f"\n --fast omite el paso 6 (ChromaDB)")
print(f" --interactive pide confirmación entre pasos\n")
def ejecutar_paso(n, limpiar_al_final=False):
if n not in PASOS:
warn(f"Paso {n} no válido. Usa --list para ver los pasos.")
return
nombre, fn, _ = PASOS[n]
info(f"Ejecutando paso {n}: {nombre}...")
fn()
if limpiar_al_final and os.path.exists(DIR_DOCS):
shutil.rmtree(DIR_DOCS)
def main():
parser = argparse.ArgumentParser(
description="Episodio 817 — Embeddings para todo. Demo completa.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=textwrap.dedent("""\
Ejemplos:
python3 setup_completo.py # todo
python3 setup_completo.py --fast # sin ChromaDB
python3 setup_completo.py --list # listar pasos
python3 setup_completo.py --step 3 # solo paso 3
python3 setup_completo.py --step 1,3,5 # pasos 1,3,5
python3 setup_completo.py --interactive # paso a paso
"""),
)
parser.add_argument("--fast", action="store_true", help="Omite ChromaDB (paso 6)")
parser.add_argument(
"--list", "-l", action="store_true", help="Lista los pasos disponibles"
)
parser.add_argument(
"--step",
"-s",
type=str,
help="Ejecuta paso(s) específicos: 3, 1,3,5, 1-4",
)
parser.add_argument(
"--interactive",
"-i",
action="store_true",
help="Ejecuta paso a paso con confirmación",
)
args = parser.parse_args()
if args.list:
listar_pasos()
return
print(f"""
{NEGR}🚀 EPISODIO 817 — Embeddings para todo{FIN}
{NEGR} Demo completa: búsqueda, clasificación, deduplicación, recomendación, ChromaDB{FIN}
{NEGR}Requisitos:{FIN}
• Ollama en {OLLAMA_URL} → {VERDE}ollama serve{FIN}
• Modelo: {MODEL} → {VERDE}ollama pull {MODEL}{FIN}
• Python 3.10+
• pip install chromadb (opcional, para el bloque 6)
""")
if not check_ollama():
sys.exit(1)
# Resolver qué pasos ejecutar
if args.step:
pasos = set()
for parte in args.step.split(","):
parte = parte.strip()
if "-" in parte:
a, b = parte.split("-", 1)
pasos.update(range(int(a), int(b) + 1))
else:
pasos.add(int(parte))
pasos = sorted(pasos)
info(f"Ejecutando pasos: {', '.join(str(p) for p in pasos)}")
for p in pasos:
ejecutar_paso(p)
if os.path.exists(DIR_DOCS):
shutil.rmtree(DIR_DOCS)
elif args.interactive:
info(
"Modo interactivo: presiona Enter para avanzar, 's' para saltar, 'q' para salir"
)
for n in sorted(PASOS):
nombre, fn, esencial = PASOS[n]
if args.fast and n == 6:
info(f"Paso {n} omitido (--fast)")
continue
r = (
input(f"\n{NEGR}¿Ejecutar paso {n}: {nombre}?{FIN} [Enter/s/q] ")
.strip()
.lower()
)
if r == "q":
info("Interrumpido por el usuario")
break
if r == "s":
info(f"Paso {n} saltado")
continue
fn()
if os.path.exists(DIR_DOCS):
shutil.rmtree(DIR_DOCS)
else:
crear_documentos()
busqueda_semantica()
clasificacion()
deduplicacion()
recomendacion()
if not args.fast:
chroma_demo()
else:
info("Modo rápido: omitiendo ChromaDB")
info("Ejecuta sin --fast para incluir ChromaDB")
if os.path.exists(DIR_DOCS):
shutil.rmtree(DIR_DOCS)
print(f"\n{NEGR}{'=' * 60}{FIN}")
print(f"\n{VERDE}🎉 Demo completada{FIN}")
print(f" • 6 casos de uso ejecutados")
print(f" • 1 modelo de embeddings ({MODEL})")
print(f" • 0 modelos entrenados, 0 GPUs, 0€")
print(
f"\n {AZUL}📖 Escaleta completa en: episodio-817-escaleta-embeddings.md{FIN}"
)
print(f" {AZUL}🔗 https://github.com/aejimmi/fail2ban-rs{FIN}")
if __name__ == "__main__":
main()
Casos de uso y próximos pasos
Como has visto, los embeddings son una herramienta que te va a dar mucho juego. Con Ollama, BGE-M3 y unas pocas líneas de código, puedes implementar:
- Búsqueda semántica, Ollama + NumPy + 20 líneas de código
- Clasificación, Ollama + ejemplos de categoría
- Deduplicación, Ollama + umbrales de similitud
- Recomendaciones, Ollama + similitud del coseno
- Clustering, Ollama + algoritmos de agrupamiento
- Base de datos vectorial, Ollama + ChromaDB
Cada uno de estos casos de uso comparte el mismo mecanismo de base, convertir texto en vectores y comparar distancias. Lo único que cambia es cómo interpretas los resultados y qué umbrales aplicas.
Y esto es solo el principio. En los episodios 819 y 821 vamos a dar el salto al RAG completo, montaremos un sistema de Retrieval Augmented Generation que combine la búsqueda semántica con un modelo de lenguaje para responder preguntas sobre tus documentos.
La gran ventaja de todo esto es que puedes empezar poco a poco. Primero implementas una búsqueda semántica básica con un script de Python. Luego añades clasificación, luego deduplicación. Y cuando veas que necesitas escalar, das el salto a ChromaDB o a PostgreSQL.
Mi recomendación es que empieces por el caso de uso que más te duela. ¿Tienes mil correos sin clasificar? Empieza por el clasificador. ¿Tienes cientos de notas en Obsidian y no encuentras nada? Monta una búsqueda semántica. ¿Te llegan noticias repetidas en tus feeds RSS? Implementa la deduplicación. Elige el problema que más te frustre y aplícale embeddings. Verás como en una tarde tienes algo funcionando.
Y recuerda, todo esto corre en tu equipo, con tu CPU, sin depender de servicios externos ni de conexión a internet. Tus datos son tuyos y se quedan en tu casa.
Imagínate por un momento que tienes un asistente que conoce todo lo que has escrito, leído o guardado. Que cuando le preguntas algo, no solo busca palabras exactas, sino que entiende lo que quieres decir. Eso es exactamente lo que consigues con los embeddings bien organizados. Con un script de treinta líneas y Ollama corriendo en segundo plano, ya tienes un buscador semántico para tus documentos.
Yo mismo empecé con un script de veinte líneas para buscar en mis notas de Obsidian, y ahora tengo un sistema que clasifica automáticamente los correos que me llegan, detecta artículos duplicados en mis feeds RSS y recomienda contenido relacionado en el blog. Y todo sin salir de mi servidor, sin depender de APIs externas, sin enviar mis datos a nadie.
Y lo mejor de todo es que cuanto más los uses, más te das cuenta de sus posibilidades. Lo que empieza como un experimento para buscar en tus notas acaba convirtiéndose en el clasificador automático de tu correo, en el detector de artículos repetidos y en el sistema de recomendaciones de tu web. Todo con la misma pieza fundamental, convertir texto en vectores y comparar distancias.
Más información
- Ollama – Página oficial — Plataforma para ejecutar modelos de lenguaje en local
- Ollama API de embeddings — Documentación oficial de la API de embeddings
- BGE-M3 en Hugging Face — Modelo de embeddings multilenguaje de BAAI
- FlagEmbedding de BAAI — Documentación oficial del modelo BGE
- ChromaDB — Base de datos vectorial open source
- Cookbook de ChromaDB — Guías y ejemplos de uso de ChromaDB
- NumPy — Biblioteca fundamental para cómputo vectorial en Python
- Episodio 809 – RAG Casero — Episodio anterior sobre RAG