embeddings.py
· 21 KiB · Python
原始檔案
#!/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()
| 1 | #!/usr/bin/env python3 |
| 2 | """ |
| 3 | 🚀 Embeddings para todo — Demo completa |
| 4 | ======================================== |
| 5 | Episodio 817: Un solo script que recorre todos los casos de uso. |
| 6 | Búsqueda semántica, clasificación, deduplicación, recomendación y ChromaDB. |
| 7 | |
| 8 | Requisitos: |
| 9 | - Ollama corriendo en localhost:11434 |
| 10 | - Modelo nomic-embed-text descargado: ollama pull nomic-embed-text |
| 11 | - Python 3.10+ |
| 12 | |
| 13 | Uso: |
| 14 | python3 setup_completo.py # Ejecuta todo |
| 15 | python3 setup_completo.py --fast # Solo pasos principales, sin ChromaDB |
| 16 | python3 setup_completo.py --list # Lista los pasos disponibles |
| 17 | python3 setup_completo.py --step 3 # Ejecuta solo el paso 3 |
| 18 | python3 setup_completo.py --step 2,4,6 # Ejecuta pasos específicos |
| 19 | python3 setup_completo.py --interactive # Paso a paso con confirmación |
| 20 | """ |
| 21 | |
| 22 | import os, sys, json, math, glob, time, shutil, subprocess, textwrap, argparse |
| 23 | |
| 24 | # ─── Configuración ──────────────────────────────────────────────────────── |
| 25 | MODEL = "bge-m3" |
| 26 | OLLAMA_URL = "http://localhost:11434" |
| 27 | DIR_DOCS = "./docs_prueba" # Documentos de prueba para búsqueda semántica |
| 28 | DIR_CHROMA = "./chroma_store" # Donde ChromaDB guarda los datos |
| 29 | |
| 30 | # Colores para la terminal |
| 31 | VERDE = "\033[92m" |
| 32 | AZUL = "\033[94m" |
| 33 | AMAR = "\033[93m" |
| 34 | ROJO = "\033[91m" |
| 35 | FIN = "\033[0m" |
| 36 | NEGR = "\033[1m" |
| 37 | |
| 38 | |
| 39 | def ok(msg): |
| 40 | print(f" {VERDE}✅{FIN} {msg}") |
| 41 | |
| 42 | |
| 43 | def info(msg): |
| 44 | print(f" {AZUL}ℹ️{FIN} {msg}") |
| 45 | |
| 46 | |
| 47 | def warn(msg): |
| 48 | print(f" {AMAR}⚠️{FIN} {msg}") |
| 49 | |
| 50 | |
| 51 | def error(msg): |
| 52 | print(f" {ROJO}❌{FIN} {msg}") |
| 53 | |
| 54 | |
| 55 | def titulo(t): |
| 56 | print(f"\n{NEGR}{'=' * 60}{FIN}\n{NEGR} {t}{FIN}\n{'=' * 60}") |
| 57 | |
| 58 | |
| 59 | # ─── Helpers ────────────────────────────────────────────────────────────── |
| 60 | |
| 61 | |
| 62 | def check_ollama(): |
| 63 | """Verifica que Ollama está corriendo y el modelo está disponible.""" |
| 64 | try: |
| 65 | r = subprocess.run( |
| 66 | ["curl", "-s", f"{OLLAMA_URL}/api/tags"], |
| 67 | capture_output=True, |
| 68 | text=True, |
| 69 | timeout=5, |
| 70 | ) |
| 71 | if r.returncode != 0: |
| 72 | error("Ollama no responde. ¿Está corriendo?") |
| 73 | info("Ejecuta: ollama serve") |
| 74 | return False |
| 75 | modelos = json.loads(r.stdout) |
| 76 | disponible = any(MODEL in m["name"] for m in modelos.get("models", [])) |
| 77 | if not disponible: |
| 78 | warn(f"Modelo {MODEL} no encontrado. Descargando...") |
| 79 | subprocess.run(["ollama", "pull", MODEL], check=True) |
| 80 | ok(f"Modelo {MODEL} descargado") |
| 81 | return True |
| 82 | except Exception as e: |
| 83 | error(f"Error conectando con Ollama: {e}") |
| 84 | return False |
| 85 | |
| 86 | |
| 87 | def emb(texto): |
| 88 | """Genera un embedding usando Ollama.""" |
| 89 | payload = json.dumps({"model": MODEL, "prompt": texto}) |
| 90 | r = subprocess.run( |
| 91 | ["curl", "-s", f"{OLLAMA_URL}/api/embeddings", "-d", payload], |
| 92 | capture_output=True, |
| 93 | text=True, |
| 94 | ) |
| 95 | return json.loads(r.stdout)["embedding"] |
| 96 | |
| 97 | |
| 98 | def cos_sim(a, b): |
| 99 | """Similitud coseno entre dos vectores.""" |
| 100 | dot = sum(x * y for x, y in zip(a, b)) |
| 101 | na = math.sqrt(sum(x * x for x in a)) |
| 102 | nb = math.sqrt(sum(y * y for y in b)) |
| 103 | return dot / (na * nb) if na and nb else 0 |
| 104 | |
| 105 | |
| 106 | # ─── 1. Crear documentos de prueba ──────────────────────────────────────── |
| 107 | |
| 108 | |
| 109 | def crear_documentos(): |
| 110 | titulo("1️⃣ CREAR DOCUMENTOS DE PRUEBA") |
| 111 | |
| 112 | if os.path.exists(DIR_DOCS): |
| 113 | shutil.rmtree(DIR_DOCS) |
| 114 | os.makedirs(DIR_DOCS) |
| 115 | |
| 116 | documentos = { |
| 117 | "facturas.txt": """Factura hosting enero 2026: 12.99€ |
| 118 | Factura dominio atareao.es 2026: 18.50€ |
| 119 | Pago servidor VPS febrero: 24.99€ |
| 120 | Total gastos infraestructura Q1: 156.47€ |
| 121 | Próximo pago hosting: 1 de marzo""", |
| 122 | "apuntes-linux.txt": """Cómo montar un servidor web con Nginx en Ubuntu 24.04 |
| 123 | Configurar firewall con nftables: tabla inet filter con policy drop |
| 124 | Instalar Docker: sudo apt install docker.io docker-compose |
| 125 | Los logs del sistema están en /var/log/syslog |
| 126 | systemctl enable --now para activar servicios al arranque""", |
| 127 | "recetas.txt": """Paella valenciana: arroz bomba, pollo, conejo, judía verde, garrofón |
| 128 | Tortilla de patatas: 4 huevos, 2 patatas grandes, cebolla, aceite de oliva |
| 129 | Pan casero: 500g harina de fuerza, 300ml agua, 10g sal, 5g levadura |
| 130 | La clave de un buen arroz es el sofrito y el caldo caliente""", |
| 131 | "compras.txt": """Lista de la compra: leche, huevos, pan, arroz, tomates, lechuga |
| 132 | Comprar regalo cumpleaños: libro de Rust |
| 133 | Recoger paquete en Correos: número de seguimiento 123456789 |
| 134 | Repostar coche: gasolina 95, 40€""", |
| 135 | "ideas-proyectos.txt": """App web para gestionar gastos compartidos con amigos |
| 136 | Script que monitoriza el precio de la luz y avisa cuando baja |
| 137 | Bot de Telegram que resuma noticias de IA cada mañana |
| 138 | Generador de menú semanal con recetas de un archivo de cocina""", |
| 139 | } |
| 140 | |
| 141 | for nombre, contenido in documentos.items(): |
| 142 | with open(os.path.join(DIR_DOCS, nombre), "w") as f: |
| 143 | f.write(contenido.strip()) |
| 144 | ok(f"Documento creado: {nombre}") |
| 145 | |
| 146 | info("5 documentos de prueba listos en ./docs_prueba/") |
| 147 | return list(documentos.keys()) |
| 148 | |
| 149 | |
| 150 | # ─── 2. Búsqueda semántica ──────────────────────────────────────────────── |
| 151 | |
| 152 | |
| 153 | def busqueda_semantica(): |
| 154 | titulo("2️⃣ BÚSQUEDA SEMÁNTICA — buscar por significado") |
| 155 | |
| 156 | # Indexar documentos |
| 157 | info("Generando embeddings de los documentos de prueba...") |
| 158 | archivos = sorted(glob.glob(f"{DIR_DOCS}/*.txt")) |
| 159 | index = [] |
| 160 | |
| 161 | for path in archivos: |
| 162 | with open(path) as f: |
| 163 | texto = f.read() |
| 164 | nombre = os.path.basename(path) |
| 165 | vec = emb(texto) |
| 166 | index.append((nombre, vec, texto)) |
| 167 | print(f" {nombre}: {len(vec)} dimensiones") |
| 168 | |
| 169 | ok(f"{len(index)} documentos indexados") |
| 170 | |
| 171 | # Consultas de prueba |
| 172 | consultas = [ |
| 173 | "¿Cuánto dinero gastamos en servidores?", |
| 174 | "¿Qué hay para cenar esta noche?", |
| 175 | "Alguna idea para un proyecto con IA", |
| 176 | ] |
| 177 | |
| 178 | for consulta in consultas: |
| 179 | print(f'\n {NEGR}🔍 Consulta:{FIN} "{consulta}"') |
| 180 | vec_q = emb(consulta) |
| 181 | |
| 182 | resultados = sorted( |
| 183 | [ |
| 184 | (nombre, cos_sim(vec_q, vec), texto[:100]) |
| 185 | for nombre, vec, texto in index |
| 186 | ], |
| 187 | key=lambda x: -x[1], |
| 188 | ) |
| 189 | |
| 190 | for nombre, sim, preview in resultados[:3]: |
| 191 | barra = "█" * int(sim * 20) + "░" * (20 - int(sim * 20)) |
| 192 | print(f" {barra} {sim:.4f} {nombre}") |
| 193 | |
| 194 | mejor = resultados[0] |
| 195 | print(f" → Mejor resultado: {NEGR}{mejor[0]}{FIN} ({mejor[1]:.1%})") |
| 196 | if mejor[1] > 0.7: |
| 197 | ok("Coincidencia semántica alta") |
| 198 | elif mejor[1] > 0.5: |
| 199 | info("Coincidencia semántica media") |
| 200 | else: |
| 201 | warn("Coincidencia baja — prueba con otra consulta") |
| 202 | |
| 203 | |
| 204 | # ─── 3. Clasificación ───────────────────────────────────────────────────── |
| 205 | |
| 206 | |
| 207 | def clasificacion(): |
| 208 | titulo("3️⃣ CLASIFICACIÓN — sin entrenar modelos") |
| 209 | |
| 210 | categorias = { |
| 211 | "urgente": [ |
| 212 | "se ha caído el servidor de producción", |
| 213 | "necesito esto para mañana sin falta", |
| 214 | "error crítico en la base de datos", |
| 215 | ], |
| 216 | "normal": [ |
| 217 | "te envío el documento actualizado", |
| 218 | "confirma si has recibido el email", |
| 219 | ], |
| 220 | "spam": [ |
| 221 | "gana dinero rápido desde casa", |
| 222 | "has ganado un premio exclusivo", |
| 223 | ], |
| 224 | "cocina": [ |
| 225 | "receta de paella valenciana", |
| 226 | "cómo hacer pan casero", |
| 227 | ], |
| 228 | } |
| 229 | |
| 230 | # Pre-calcular embedding de cada categoría (promedio de ejemplos) |
| 231 | info("Calculando firmas de categorías...") |
| 232 | firmas = {} |
| 233 | for cat, ejemplos in categorias.items(): |
| 234 | vecs = [emb(e) for e in ejemplos] |
| 235 | firmas[cat] = [sum(vals) / len(vals) for vals in zip(*vecs)] |
| 236 | ok(f"Categoría '{cat}' lista ({len(ejemplos)} ejemplos)") |
| 237 | |
| 238 | # Textos a clasificar |
| 239 | textos_prueba = [ |
| 240 | "el servidor web no responde desde las 3 de la mañana", |
| 241 | "te adjunto la factura de este mes para tu revisión", |
| 242 | "FELICIDADES HAS GANADO UN VIAJE AL CARIBE", |
| 243 | "cómo hacer una tortilla de patatas con cebolla caramelizada", |
| 244 | ] |
| 245 | |
| 246 | for texto in textos_prueba: |
| 247 | vec = emb(texto) |
| 248 | resultados = sorted( |
| 249 | [(cat, cos_sim(vec, firma)) for cat, firma in firmas.items()], |
| 250 | key=lambda x: -x[1], |
| 251 | ) |
| 252 | mejor = resultados[0] |
| 253 | barra = "█" * int(mejor[1] * 20) + "░" * (20 - int(mejor[1] * 20)) |
| 254 | print(f'\n 📝 "{texto[:60]}..."') |
| 255 | print(f" → {barra} {NEGR}{mejor[0]}{FIN} ({mejor[1]:.1%})") |
| 256 | |
| 257 | |
| 258 | # ─── 4. Deduplicación ───────────────────────────────────────────────────── |
| 259 | |
| 260 | |
| 261 | def deduplicacion(): |
| 262 | titulo("4️⃣ DEDUPLICACIÓN — encontrar contenido repetido") |
| 263 | |
| 264 | items = [ |
| 265 | {"id": "doc1", "texto": "Cómo instalar Ollama en Ubuntu 24.04 paso a paso"}, |
| 266 | {"id": "doc2", "texto": "Guía completa para instalar Ollama en Ubuntu 24.04"}, |
| 267 | {"id": "doc3", "texto": "Receta de paella valenciana tradicional con marisco"}, |
| 268 | {"id": "doc4", "texto": "Cómo configurar nftables como firewall en Linux"}, |
| 269 | {"id": "doc5", "texto": "Instalación de Ollama en Ubuntu 24.04 - tutorial"}, |
| 270 | ] |
| 271 | |
| 272 | info("Generando embeddings...") |
| 273 | embebidos = [(item, emb(item["texto"])) for item in items] |
| 274 | |
| 275 | print(f"\n {'Item':<10} {'Similitud con':<20} {'Score':<8}") |
| 276 | print(f" {'-' * 40}") |
| 277 | for i, (item_a, emb_a) in enumerate(embebidos): |
| 278 | for j, (item_b, emb_b) in enumerate(embebidos[i + 1 :], i + 1): |
| 279 | sim = cos_sim(emb_a, emb_b) |
| 280 | status = ( |
| 281 | "🔴 DUPLICADO" |
| 282 | if sim > 0.90 |
| 283 | else "🟡 PARECIDO" |
| 284 | if sim > 0.75 |
| 285 | else "🟢 DISTINTO" |
| 286 | ) |
| 287 | print(f" {item_a['id']:<10} {item_b['id']:<20} {sim:.4f} {status}") |
| 288 | |
| 289 | # Detectar duplicados |
| 290 | duplicados = [ |
| 291 | (item_a, item_b, sim) |
| 292 | for (item_a, emb_a), (item_b, emb_b) in [ |
| 293 | ((items[i], embebidos[i][1]), (items[j], embebidos[j][1])) |
| 294 | for i in range(len(items)) |
| 295 | for j in range(i + 1, len(items)) |
| 296 | ] |
| 297 | if (sim := cos_sim(emb_a, emb_b)) > 0.85 |
| 298 | ] |
| 299 | |
| 300 | if duplicados: |
| 301 | print( |
| 302 | f"\n {NEGR}🔴 Se detectaron {len(duplicados)} grupos de duplicados:{FIN}" |
| 303 | ) |
| 304 | for a, b, sim in duplicados: |
| 305 | print(f' • "{a["texto"][:50]}..."') |
| 306 | print(f' "{b["texto"][:50]}..."') |
| 307 | print(f" → Similitud: {sim:.1%}") |
| 308 | else: |
| 309 | info("No se detectaron duplicados significativos") |
| 310 | |
| 311 | |
| 312 | # ─── 5. Recomendación ───────────────────────────────────────────────────── |
| 313 | |
| 314 | |
| 315 | def recomendacion(): |
| 316 | titulo("5️⃣ RECOMENDACIÓN — más como esto") |
| 317 | |
| 318 | articulos = [ |
| 319 | "Linux es el mejor sistema operativo para servidores", |
| 320 | "Cómo instalar Docker en Ubuntu 24.04", |
| 321 | "Introducción a Rust: seguridad de memoria sin recolector de basura", |
| 322 | "PostgreSQL con pgvector para búsqueda semántica", |
| 323 | "Ollama: modelos de lenguaje locales en tu servidor", |
| 324 | "Configurar nftables como firewall en Linux", |
| 325 | "Rust vs Go: comparativa para backend en 2026", |
| 326 | "Embeddings: coordenadas en un mapa de significados", |
| 327 | ] |
| 328 | |
| 329 | info("Generando embeddings de {len(articulos)} artículos...") |
| 330 | index = [(tit, emb(tit)) for tit in articulos] |
| 331 | |
| 332 | # Elegir un artículo de referencia |
| 333 | ref = "Ollama: modelos de lenguaje locales en tu servidor" |
| 334 | print(f'\n 📖 Artículo de referencia: "{ref}"') |
| 335 | print(f"\n {NEGR}📚 Recomendaciones:{FIN}") |
| 336 | |
| 337 | vec_ref = emb(ref) |
| 338 | recomendados = sorted( |
| 339 | [(tit, cos_sim(vec_ref, vec)) for tit, vec in index if tit != ref], |
| 340 | key=lambda x: -x[1], |
| 341 | ) |
| 342 | |
| 343 | for i, (tit, sim) in enumerate(recomendados[:4], 1): |
| 344 | barra = "█" * int(sim * 20) + "░" * (20 - int(sim * 20)) |
| 345 | print(f" {i}. {barra} {sim:.4f} {tit}") |
| 346 | |
| 347 | |
| 348 | # ─── 6. ChromaDB ────────────────────────────────────────────────────────── |
| 349 | |
| 350 | |
| 351 | def chroma_demo(): |
| 352 | titulo("6️⃣ CHROMADB — base de datos vectorial") |
| 353 | |
| 354 | try: |
| 355 | import chromadb |
| 356 | from chromadb.utils import embedding_functions |
| 357 | except ImportError: |
| 358 | warn("chromadb no instalado. Ejecuta: pip install chromadb") |
| 359 | return |
| 360 | |
| 361 | # Limpiar y crear |
| 362 | if os.path.exists(DIR_CHROMA): |
| 363 | shutil.rmtree(DIR_CHROMA) |
| 364 | |
| 365 | info("Creando base de datos vectorial con ChromaDB...") |
| 366 | client = chromadb.PersistentClient(path=DIR_CHROMA) |
| 367 | collection = client.get_or_create_collection( |
| 368 | name="demo_817", |
| 369 | embedding_function=embedding_functions.OllamaEmbeddingFunction( |
| 370 | url=f"{OLLAMA_URL}/api/embeddings", model_name=MODEL |
| 371 | ), |
| 372 | ) |
| 373 | |
| 374 | # Documentos de prueba |
| 375 | docs = [ |
| 376 | "Linux es el mejor sistema operativo para servidores por su estabilidad", |
| 377 | "Para instalar Docker en Ubuntu ejecuta sudo apt install docker.io", |
| 378 | "La paella valenciana lleva arroz, pollo, conejo, judía y garrofón", |
| 379 | "Ollama permite ejecutar modelos de lenguaje localmente en tu hardware", |
| 380 | "Rust es un lenguaje de programación de sistemas seguro y rápido", |
| 381 | "PostgreSQL con pgvector permite búsqueda semántica sobre documentos", |
| 382 | "Los embeddings convierten texto en vectores numéricos", |
| 383 | "El jamón ibérico de bellota se diferencia por la dieta del cerdo", |
| 384 | ] |
| 385 | metadatos = [ |
| 386 | {"tema": "linux"}, |
| 387 | {"tema": "linux"}, |
| 388 | {"tema": "cocina"}, |
| 389 | {"tema": "ia"}, |
| 390 | {"tema": "programacion"}, |
| 391 | {"tema": "ia"}, |
| 392 | {"tema": "ia"}, |
| 393 | {"tema": "cocina"}, |
| 394 | ] |
| 395 | ids = [f"doc_{i}" for i in range(len(docs))] |
| 396 | |
| 397 | collection.add(documents=docs, metadatas=metadatos, ids=ids) |
| 398 | ok(f"{len(docs)} documentos indexados en ChromaDB") |
| 399 | |
| 400 | # Búsqueda |
| 401 | consultas = [ |
| 402 | "¿Qué framework usar para backend?", |
| 403 | "Dime algo de cocina", |
| 404 | ] |
| 405 | |
| 406 | for consulta in consultas: |
| 407 | print(f'\n {NEGR}🔍 Consulta:{FIN} "{consulta}"') |
| 408 | results = collection.query(query_texts=[consulta], n_results=3) |
| 409 | |
| 410 | for doc, meta, dist in zip( |
| 411 | results["documents"][0], results["metadatas"][0], results["distances"][0] |
| 412 | ): |
| 413 | conf = 1 - dist |
| 414 | barra = "█" * int(conf * 20) + "░" * (20 - int(conf * 20)) |
| 415 | print(f" {barra} {conf:.1%} [{meta['tema']}] {doc}") |
| 416 | |
| 417 | # Filtro por metadatos |
| 418 | print(f" 🔽 Con filtro [tema=linux]:") |
| 419 | filtrados = collection.query( |
| 420 | query_texts=[consulta], n_results=2, where={"tema": "linux"} |
| 421 | ) |
| 422 | for doc in filtrados["documents"][0]: |
| 423 | print(f" 🐧 {doc}") |
| 424 | |
| 425 | # Persistencia |
| 426 | if os.path.exists(DIR_CHROMA): |
| 427 | ok(f"Datos persistentes en {DIR_CHROMA}/") |
| 428 | info("Cierra y abre Python — los datos siguen ahí") |
| 429 | |
| 430 | # Limpiar |
| 431 | shutil.rmtree(DIR_CHROMA) |
| 432 | info("ChromaDB limpiada (demo temporal)") |
| 433 | |
| 434 | |
| 435 | # ─── MAIN ────────────────────────────────────────────────────────────────── |
| 436 | |
| 437 | PASOS = { |
| 438 | 1: ("Crear documentos de prueba", crear_documentos, True), |
| 439 | 2: ("Búsqueda semántica", busqueda_semantica, True), |
| 440 | 3: ("Clasificación", clasificacion, True), |
| 441 | 4: ("Deduplicación", deduplicacion, True), |
| 442 | 5: ("Recomendación", recomendacion, True), |
| 443 | 6: ("ChromaDB", chroma_demo, False), |
| 444 | } |
| 445 | |
| 446 | |
| 447 | def listar_pasos(): |
| 448 | print(f"\n{'=' * 60}") |
| 449 | print(f" PASOS DISPONIBLES") |
| 450 | print(f"{'=' * 60}") |
| 451 | for n in sorted(PASOS): |
| 452 | nombre, _, esencial = PASOS[n] |
| 453 | req = " (requiere chromadb)" if not esencial else "" |
| 454 | print(f" {n}. {nombre}{req}") |
| 455 | print(f"\n --fast omite el paso 6 (ChromaDB)") |
| 456 | print(f" --interactive pide confirmación entre pasos\n") |
| 457 | |
| 458 | |
| 459 | def ejecutar_paso(n, limpiar_al_final=False): |
| 460 | if n not in PASOS: |
| 461 | warn(f"Paso {n} no válido. Usa --list para ver los pasos.") |
| 462 | return |
| 463 | nombre, fn, _ = PASOS[n] |
| 464 | info(f"Ejecutando paso {n}: {nombre}...") |
| 465 | fn() |
| 466 | if limpiar_al_final and os.path.exists(DIR_DOCS): |
| 467 | shutil.rmtree(DIR_DOCS) |
| 468 | |
| 469 | |
| 470 | def main(): |
| 471 | parser = argparse.ArgumentParser( |
| 472 | description="Episodio 817 — Embeddings para todo. Demo completa.", |
| 473 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 474 | epilog=textwrap.dedent("""\ |
| 475 | Ejemplos: |
| 476 | python3 setup_completo.py # todo |
| 477 | python3 setup_completo.py --fast # sin ChromaDB |
| 478 | python3 setup_completo.py --list # listar pasos |
| 479 | python3 setup_completo.py --step 3 # solo paso 3 |
| 480 | python3 setup_completo.py --step 1,3,5 # pasos 1,3,5 |
| 481 | python3 setup_completo.py --interactive # paso a paso |
| 482 | """), |
| 483 | ) |
| 484 | parser.add_argument("--fast", action="store_true", help="Omite ChromaDB (paso 6)") |
| 485 | parser.add_argument( |
| 486 | "--list", "-l", action="store_true", help="Lista los pasos disponibles" |
| 487 | ) |
| 488 | parser.add_argument( |
| 489 | "--step", |
| 490 | "-s", |
| 491 | type=str, |
| 492 | help="Ejecuta paso(s) específicos: 3, 1,3,5, 1-4", |
| 493 | ) |
| 494 | parser.add_argument( |
| 495 | "--interactive", |
| 496 | "-i", |
| 497 | action="store_true", |
| 498 | help="Ejecuta paso a paso con confirmación", |
| 499 | ) |
| 500 | args = parser.parse_args() |
| 501 | |
| 502 | if args.list: |
| 503 | listar_pasos() |
| 504 | return |
| 505 | |
| 506 | print(f""" |
| 507 | {NEGR}🚀 EPISODIO 817 — Embeddings para todo{FIN} |
| 508 | {NEGR} Demo completa: búsqueda, clasificación, deduplicación, recomendación, ChromaDB{FIN} |
| 509 | |
| 510 | {NEGR}Requisitos:{FIN} |
| 511 | • Ollama en {OLLAMA_URL} → {VERDE}ollama serve{FIN} |
| 512 | • Modelo: {MODEL} → {VERDE}ollama pull {MODEL}{FIN} |
| 513 | • Python 3.10+ |
| 514 | • pip install chromadb (opcional, para el bloque 6) |
| 515 | """) |
| 516 | |
| 517 | if not check_ollama(): |
| 518 | sys.exit(1) |
| 519 | |
| 520 | # Resolver qué pasos ejecutar |
| 521 | if args.step: |
| 522 | pasos = set() |
| 523 | for parte in args.step.split(","): |
| 524 | parte = parte.strip() |
| 525 | if "-" in parte: |
| 526 | a, b = parte.split("-", 1) |
| 527 | pasos.update(range(int(a), int(b) + 1)) |
| 528 | else: |
| 529 | pasos.add(int(parte)) |
| 530 | pasos = sorted(pasos) |
| 531 | info(f"Ejecutando pasos: {', '.join(str(p) for p in pasos)}") |
| 532 | for p in pasos: |
| 533 | ejecutar_paso(p) |
| 534 | if os.path.exists(DIR_DOCS): |
| 535 | shutil.rmtree(DIR_DOCS) |
| 536 | elif args.interactive: |
| 537 | info( |
| 538 | "Modo interactivo: presiona Enter para avanzar, 's' para saltar, 'q' para salir" |
| 539 | ) |
| 540 | for n in sorted(PASOS): |
| 541 | nombre, fn, esencial = PASOS[n] |
| 542 | if args.fast and n == 6: |
| 543 | info(f"Paso {n} omitido (--fast)") |
| 544 | continue |
| 545 | r = ( |
| 546 | input(f"\n{NEGR}¿Ejecutar paso {n}: {nombre}?{FIN} [Enter/s/q] ") |
| 547 | .strip() |
| 548 | .lower() |
| 549 | ) |
| 550 | if r == "q": |
| 551 | info("Interrumpido por el usuario") |
| 552 | break |
| 553 | if r == "s": |
| 554 | info(f"Paso {n} saltado") |
| 555 | continue |
| 556 | fn() |
| 557 | if os.path.exists(DIR_DOCS): |
| 558 | shutil.rmtree(DIR_DOCS) |
| 559 | else: |
| 560 | crear_documentos() |
| 561 | busqueda_semantica() |
| 562 | clasificacion() |
| 563 | deduplicacion() |
| 564 | recomendacion() |
| 565 | |
| 566 | if not args.fast: |
| 567 | chroma_demo() |
| 568 | else: |
| 569 | info("Modo rápido: omitiendo ChromaDB") |
| 570 | info("Ejecuta sin --fast para incluir ChromaDB") |
| 571 | |
| 572 | if os.path.exists(DIR_DOCS): |
| 573 | shutil.rmtree(DIR_DOCS) |
| 574 | |
| 575 | print(f"\n{NEGR}{'=' * 60}{FIN}") |
| 576 | print(f"\n{VERDE}🎉 Demo completada{FIN}") |
| 577 | print(f" • 6 casos de uso ejecutados") |
| 578 | print(f" • 1 modelo de embeddings ({MODEL})") |
| 579 | print(f" • 0 modelos entrenados, 0 GPUs, 0€") |
| 580 | print( |
| 581 | f"\n {AZUL}📖 Escaleta completa en: episodio-817-escaleta-embeddings.md{FIN}" |
| 582 | ) |
| 583 | print(f" {AZUL}🔗 https://github.com/aejimmi/fail2ban-rs{FIN}") |
| 584 | |
| 585 | |
| 586 | if __name__ == "__main__": |
| 587 | main() |
| 588 |