Saltar al contenido

← RAG con Ollama

Comparar modelos de embeddings: dimensión, latencia y recall

Preview gratis Sección 2 · Embeddings locales y búsqueda vectorial

En este video

  • Cargas las 60 preguntas de preguntas.jsonl desde su URL pública y dejas fuera las 6 sin_respuesta: quedan 54 evaluables
  • Defines el acierto a nivel sección y una función mrr; hit@k sale de que el rango recíproco sea al menos 1/k
  • Indexas el corpus con nomic-embed-text, mxbai-embed-large y bge-m3, cada uno en su colección de ChromaDB, y mides calidad y tiempos
  • bge-m3 gana con MRR 0.81 y hit@1 0.74 contra 0.52 de nomic y 0.46 de mxbai; en parafraseadas, 0.62 contra 0.14 y 0.12
  • Calculas el costo de más dimensiones (160 chunks por 1024 por 4 bytes son 640 KB) y concluyes que conviene medir en tu idioma y tu corpus
preparacion.py
# Datos y preparación que el video da por hechos: ejecuta esto antes del resto del código.
import json
from collections import Counter
from urllib.request import urlopen
BASE = "https://pypro-assets.sfo3.digitaloceanspaces.com/cursos/rag-con-ollama/datos"
preguntas.py
url = f"{BASE}/preguntas.jsonl"
texto = urlopen(url).read().decode()
preguntas = [json.loads(l) for l in texto.splitlines()]
evaluables = [p for p in preguntas
              if p["tipo"] != "sin_respuesta"]
print(len(preguntas), len(evaluables))
tipos = Counter(p["tipo"] for p in evaluables)
for tipo, n in tipos.items():
    print(tipo, n)
metricas.py
# Paso que el video ejecuta sin mostrarlo en pantalla:
q22 = {"pregunta": "¿Cuánto tarda un envío internacional a Canadá?",
       "doc": "envios-internacionales.md", "secciones": ["Países con envío"]}
nomic = [{"doc": "envios-internacionales.md", "seccion": "Introducción"}, {"doc": "envios-internacionales.md", "seccion": "Costos, impuestos y aduanas"}, {"doc": "envios-internacionales.md", "seccion": "Países con envío"}, {"doc": "envios-internacionales.md", "seccion": "Devoluciones internacionales"}, {"doc": "envios-nacionales.md", "seccion": "Introducción"}]
bge = [{"doc": "envios-internacionales.md", "seccion": "Países con envío"}, {"doc": "faq-clientes.md", "seccion": "Pedidos y envíos"}, {"doc": "envios-internacionales.md", "seccion": "Tiempos de preparación"}, {"doc": "envios-nacionales.md", "seccion": "Tiempos de entrega"}, {"doc": "envios-internacionales.md", "seccion": "Devoluciones internacionales"}]

def acierto(c, p):
    return (c["doc"] == p["doc"]
            and c["seccion"] in p["secciones"])
def mrr(ranking, p):
    for i, c in enumerate(ranking, start=1):
        if acierto(c, p):
            return 1 / i
    return 0.0
print(mrr(nomic, q22), mrr(bge, q22))
benchmark.py
# Paso que el video ejecuta sin mostrarlo en pantalla:
import io, re, time, zipfile
import chromadb
import ollama

def cargar_corpus(url=f"{BASE}/pyshop-kb.zip"):
    docs = []
    with zipfile.ZipFile(io.BytesIO(urlopen(url).read())) as z:
        for nombre in sorted(n for n in z.namelist() if n.endswith(".md")):
            crudo = z.read(nombre).decode("utf-8")
            meta, texto = re.match(r"---\n(.*?)\n---\n(.*)", crudo, re.S).groups()
            campos = dict(linea.split(": ", 1) for linea in meta.splitlines())
            docs.append({"doc": nombre.split("/")[-1], "titulo": campos["titulo"],
                         "tipo": campos["tipo"], "area": campos["area"], "texto": texto.strip()})
    return docs

def secciones_de(doc):
    salida = []
    for parte in re.split(r"^(?=## )", doc["texto"], flags=re.M):
        if parte.strip():
            m = re.match(r"## (.+)", parte)
            salida.append((m.group(1).strip() if m else "Introducción", parte.strip()))
    return salida

def chunk_markdown(doc, tam=800, solape=100):
    chunks = []
    for seccion, texto in secciones_de(doc):
        trozos = [texto] if len(texto) <= tam else [
            texto[i:i + tam] for i in range(0, max(len(texto) - solape, 1), tam - solape)]
        for trozo in trozos:
            chunks.append({"id": f"{doc['doc']}#{len(chunks)}", "texto": trozo, "doc": doc["doc"],
                           "titulo": doc["titulo"], "tipo": doc["tipo"], "area": doc["area"],
                           "seccion": seccion})
    return chunks

def embed(textos, modelo, tipo="documento", lote=64):
    if isinstance(textos, str):
        textos = [textos]
    if modelo.startswith("nomic-embed-text"):
        prefijo = "search_document: " if tipo == "documento" else "search_query: "
        textos = [prefijo + t for t in textos]
    vectores = []
    for i in range(0, len(textos), lote):
        vectores += ollama.embed(model=modelo, input=textos[i:i + lote]).embeddings
    return vectores

cliente = chromadb.PersistentClient(path="pyshop_chroma_744")
METADATOS = ("doc", "titulo", "tipo", "area", "seccion")

def indexar(chunks, modelo, nombre):
    """Una colección por modelo (coseno), con los embeddings calculados por Ollama."""
    if nombre in [c.name for c in cliente.list_collections()]:
        cliente.delete_collection(nombre)
    col = cliente.create_collection(nombre, metadata={"hnsw:space": "cosine"})
    col.add(ids=[c["id"] for c in chunks], documents=[c["texto"] for c in chunks],
            metadatas=[{k: c[k] for k in METADATOS} for c in chunks],
            embeddings=embed([c["texto"] for c in chunks], modelo, "documento"))
    return col

def buscar(col, pregunta, modelo, k=10):
    r = col.query(query_embeddings=embed(pregunta, modelo, "consulta"), n_results=k)
    return [{"id": i, "distancia": d, **m}
            for i, d, m in zip(r["ids"][0], r["distances"][0], r["metadatas"][0])]

MODELOS = ["nomic-embed-text", "mxbai-embed-large", "bge-m3"]
docs = cargar_corpus()
chunks = [c for doc in docs for c in chunk_markdown(doc)]

filas = []
for m in MODELOS:
    col = indexar(chunks, m, f"kb_{m}")
    for p in evaluables:
        top10 = buscar(col, p["pregunta"], m, k=10)
        filas.append({"modelo": m, "tipo": p["tipo"],
                      "mrr": mrr(top10, p)})
print(len(filas), "filas")

# Paso que el video ejecuta sin mostrarlo en pantalla:
import pandas as pd

df = pd.DataFrame(filas)
df["hit@1"] = df["mrr"] == 1
df["hit@5"] = df["mrr"] >= 0.2
cols = ["hit@1", "hit@5", "mrr"]
tabla = df.groupby("modelo")[cols].mean()
print(tabla.round(2))

t = df.pivot_table("mrr", index="tipo",
                   columns="modelo")
t.columns = ["bge-m3", "mxbai", "nomic"]
print(t.round(2))
tiempos.py
for m in MODELOS:
    embed(["hola"], m)  # calentar
    t0 = time.perf_counter()
    col = indexar(chunks, m, f"kb_{m}")
    t1 = time.perf_counter()
    for p in evaluables:
        buscar(col, p["pregunta"], m)
    ms = (time.perf_counter() - t1) / 54 * 1000
    print(m, f"{t1 - t0:.1f} s", f"{ms:.0f} ms")
costo.py
for m, dim in [("nomic", 768), ("bge-m3", 1024)]:
    kb = 160 * dim * 4 / 1024
    gb = 1_000_000 * dim * 4 / 1e9
    print(m, f"{kb:.0f} KB", f"{gb:.1f} GB/millón")

Recursos