Lección 6.3.1 · 70 min

Embeddings y bases vectoriales: FAISS, Chroma, pgvector y Qdrant

Un RAG empieza por encontrar los pasajes que responden a la pregunta. Esta lección convierte informes anuales, folletos de ETF y KID sintéticos en vectores con MiniLM, los busca con FAISS y con Chroma, mide el recall contra los pasajes de referencia y lo compara con BM25 y con una búsqueda híbrida. El resultado no es el que promete el marketing: en estos documentos, la palabra exacta y los filtros por metadatos pesan más que el embedding.

Fig. — Recall@5 de la búsqueda densa (MiniLM), léxica (BM25) e híbrida (RRF) sobre 246 preguntas con pasajes de referencia, sin filtro, con filtro por entidad y año y con la entidad quitada de la consulta
01/03
Módulo
6.3 · RAG financiero
Duración
70 min
Lecciones del nivel 6
  1. 6.1 · NLP financiero clásico y moderno

    1. 6.1.1 Tokenización, embeddings, diccionarios financieros y FinBERT
    2. 6.1.2 Fuentes de texto: EDGAR, CNMV, transcripciones y redes sociales
    3. 6.1.3 Event studies con señales de texto, alineación temporal y topic modeling
  2. 6.2 · LLMs para investigación y trading

    1. 6.2.1 Cómo funciona un LLM: del siguiente token a la API
    2. 6.2.2 Prompting estructurado: JSON que se puede validar
    3. 6.2.3 Guidance, KPIs y tono: qué extrae bien un LLM
    4. 6.2.4 Fine-tuning ligero para tareas financieras: LoRA y QLoRA
    5. 6.2.5 Sesgo de anticipación en LLMs: el modelo que ya conoce el futuro
  3. 6.3 · RAG financiero

    1. 6.3.1 Embeddings y bases vectoriales: FAISS, Chroma, pgvector y Qdrant
    2. 6.3.2 Chunking de documentos financieros: tablas, notas al pie y XBRL
    3. 6.3.3 RAG sobre informes, folletos y KID, y cómo evaluarlo
  4. 6.4 · Agentes de IA para trading

    1. 6.4.1 Agentes con herramientas: tool calling, ReAct y planificación
    2. 6.4.2 Marcos de agentes y MCP
    3. 6.4.3 Arquitecturas multiagente tipo TradingAgents
    4. 6.4.4 Evidencia y límites de los agentes
    5. 6.4.5 Guardarraíles para agentes: el agente propone, el motor decide
    6. 6.4.6 Agentes de investigación: hipótesis, alpha mining y revisión
  5. 6.5 · IA como copiloto del quant

    1. 6.5.1 Asistentes de código con un flujo de tests primero
    2. 6.5.2 Revisión automática de backtests y migraciones de código
    3. 6.5.3 Riesgos del código generado por IA
  6. 6.6 · Big data para trading

    1. 6.6.1 Cuándo los datos son «big»: ticks, libros, opciones y universos
    2. 6.6.2 Parquet, Arrow, Polars en streaming y DuckDB fuera de memoria
    3. 6.6.3 Dask, Spark y Ray: de una máquina a un clúster
    4. 6.6.4 Bases de series temporales: TimescaleDB, QuestDB, ClickHouse, ArcticDB y kdb+
    5. 6.6.5 Almacenamiento en la nube, lakehouse (Delta, Iceberg) y GPU (RAPIDS, cuDF)
  7. 6.7 · Datos alternativos

    1. 6.7.1 Panorama y tamaño del mercado de datos alternativos
    2. 6.7.2 Tipos de datos alternativos: qué mide cada uno
    3. 6.7.3 Web scraping responsable
    4. 6.7.4 Proveedores y due diligence de datos alternativos
    5. 6.7.5 Del dato alternativo a la señal
    6. 6.7.6 Satélite, visión por computador y cadena de suministro

Buscar por significado

Un sistema de preguntas sobre documentos (RAG, lección 6.3.3) tiene dos partes: recuperar los pasajes que contienen la respuesta y redactarla con un LLM a partir de ellos. Si la recuperación falla, el LLM responde sin la información o se la inventa. Esta lección trata la primera parte: cómo convertir texto en vectores, cómo buscarlos rápido y, sobre todo, cómo medir si se encuentra lo que hay que encontrar.

PasajesEmbeddingsÍndice vectorialPreguntaMismo modeloVecinos próximosFiltro metadatosTop-k al LLM
Fuente Mermaid
flowchart TB
  A[Pasajes] --> B[Embeddings]
  B --> C[Índice vectorial]
  D[Pregunta] --> E[Mismo modelo]
  E --> F[Vecinos próximos]
  C --> F
  F --> G[Filtro metadatos]
  G --> H[Top-k al LLM]

Un embedding es un vector de dimensión fija (384 en all-MiniLM-L6-v2) que el modelo asigna a un texto, entrenado para que textos parecidos queden cerca. Con vectores de norma 1, la similitud coseno es el producto interior: cos⁡(u,v)=u⋅v\cos(u, v) = u \cdot v si ∥u∥=∥v∥=1\lVert u \rVert = \lVert v \rVert = 1. La lección 6.1.1 los usó como variables de un clasificador; aquí se usan para buscar. Antes de buscar, conviene ver qué miden:

IN [01]python
import numpy as np
import pandas as pd
from sentence_transformers import SentenceTransformer

st = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2",
                         device="cpu", local_files_only=True)
frases = [
    "La empresa eleva su previsión de ventas para el año",
    "La compañía mejora sus perspectivas de ingresos anuales",
    "La empresa recorta su previsión de ventas para el año",
    "The company raises its full-year sales guidance",
    "El consejo aprueba el pago de un dividendo",
]
V = st.encode(frases, normalize_embeddings=True,
              show_progress_bar=False)
print(V.shape, V.dtype, "· norma:", np.linalg.norm(V, axis=1).round(3))
sim = pd.DataFrame(V @ V.T, index=["eleva", "mejora", "recorta",
                                   "raises", "dividendo"],
                   columns=["eleva", "mejora", "recorta", "raises",
                            "dividendo"])
print(sim.round(2).to_string())
OUT [01]Cinco frases, cinco vectores: similitud coseno
(5, 384) float32 · norma: [1. 1. 1. 1. 1.]
           eleva  mejora  recorta  raises  dividendo
eleva       1.00    0.53     0.95    0.17       0.40
mejora      0.53    1.00     0.55    0.18       0.38
recorta     0.95    0.55     1.00    0.18       0.40
raises      0.17    0.18     0.18    1.00       0.04
dividendo   0.40    0.38     0.40    0.04       1.00

El resultado es instructivo. «Eleva» y «recorta» la previsión de ventas, frases de sentido opuesto con casi las mismas palabras, tienen una similitud de 0,95; «eleva» y su paráfrasis «la compañía mejora sus perspectivas de ingresos anuales», con otras palabras, solo 0,53. La traducción inglesa queda a 0,17: el modelo no es multilingüe. all-MiniLM-L6-v2 se entrenó con texto inglés, y con texto español se comporta casi como un comparador de fragmentos de palabras. Es el modelo del curso por tamaño (22 millones de parámetros, 91 MB) y licencia (Apache-2.0); para español existen modelos multilingües de la misma familia que no están instalados en el entorno. La lección mide lo que da este, que es la situación habitual: un modelo genérico sobre texto que no es el suyo.

El corpus y su conjunto de evaluación

El material de RAG del nivel 6 son 21 documentos sintéticos en Markdown: informes anuales de 2023 y 2024 de cuatro emisores, folletos y KID de cinco ETF y tres notas de research. rag6_pasajes.parquet los divide en pasajes de referencia (la lección 6.3.2 trata cómo partir documentos) y rag6_preguntas.csv trae 263 preguntas con los pasajes que hay que citar para responderlas. Se descartan los pasajes de título (encabezados de sección, que no responden a nada) y las 17 preguntas sin respuesta en los documentos.

IN [02]python
pas = pd.read_parquet("rag6_pasajes.parquet")
docs = pd.read_parquet("rag6_documentos.parquet")
preg = pd.read_csv("rag6_preguntas.csv")
docs["corto"] = (docs["entidad"].str.replace(", S.A.", "")
                 .str.replace(" UCITS ETF", ""))
docs["anio"] = docs["titulo"].str.extract(r"(20\d\d)$")[0].fillna("")
pas = pas.merge(docs[["id_doc", "entidad", "anio"]], on="id_doc")
pas = pas[pas["tipo"] != "titulo"].reset_index(drop=True)
preg = preg[preg["respondible"]].reset_index(drop=True)
oro = [set(x.split(";")) for x in preg["pasajes_oro"]]
print(len(docs), "documentos ·", len(pas), "pasajes ·", len(preg),
      "preguntas respondibles")
print(pas["tipo"].value_counts().to_string())
OUT [02]El corpus: pasajes de informes, folletos, KID y research
21 documentos · 722 pasajes · 246 preguntas respondibles
tipo
texto    431
lista    119
tabla    105
nota      67

La métrica es el recall@k: de los pasajes de oro de cada pregunta, qué fracción aparece entre los kk primeros resultados, promediado sobre las preguntas. Es la que importa en RAG, porque lo que no se recupera no llega al LLM. Con k=5k = 5 se mide lo que cabría en un prompt típico.

FAISS: búsqueda exacta

FAISS es una biblioteca de índices de vectores en memoria. IndexFlatIP guarda los vectores tal cual y compara la consulta con todos (búsqueda exacta por producto interior, que con vectores normalizados es el coseno). Con 722 pasajes no hace falta nada más:

IN [03]python
import faiss

faiss.omp_set_num_threads(1)
P = st.encode(list(pas["texto"]), batch_size=32,
              normalize_embeddings=True, show_progress_bar=False)
Q = st.encode(list(preg["pregunta"]), batch_size=64,
              normalize_embeddings=True, show_progress_bar=False)
indice = faiss.IndexFlatIP(P.shape[1])   # producto interior = coseno
indice.add(P)
print(indice.ntotal, "vectores ·", P.nbytes // 1024, "KB en float32")
ids = pas["id_pasaje"].to_numpy()

def recall(rankings, k):
    """Fracción de los pasajes de oro que están entre los k primeros."""
    return round(float(np.mean([len(set(ids[r[:k]]) & g) / len(g)
                                for r, g in zip(rankings, oro)])), 3)

_, denso = indice.search(Q, 20)
print({f"recall@{k}": recall(denso, k) for k in (1, 5, 10)})
OUT [03]Embeddings de los pasajes e índice exacto con FAISS
722 vectores · 1083 KB en float32
{'recall@1': 0.022, 'recall@5': 0.114, 'recall@10': 0.265}

Un recall@5 del 11,4 % es muy bajo, y el índice no tiene la culpa: es exacto. Hay que mirar qué devuelve:

IN [04]python
i = 0
print(preg.loc[i, "pregunta"], "· oro:", preg.loc[i, "pasajes_oro"])
for j in denso[i][:4]:
    print(f"  {ids[j]}  {pas.loc[j, 'entidad'][:22]:22s} "
          f"{pas.loc[j, 'texto'][:52]!r}")
OUT [04]Por qué falla: ocho informes con la misma plantilla
¿Cuál es el mayor mercado de Busmar Hogar por ventas en 2023 y qué peso tiene? · oro: R01-p031
  R08-p076  Zaptec Capital, S.A.   'Para 2025, el grupo prevé un crecimiento de las vent'
  R07-p076  Zaptec Capital, S.A.   'Para 2024, el grupo prevé un crecimiento de las vent'
  R03-p076  Celra Semiconductores, 'Para 2024, el grupo prevé un crecimiento de las vent'
  R02-p076  Busmar Hogar, S.A.     'Para 2025, el grupo prevé un crecimiento de las vent'

La pregunta es sobre el mayor mercado de Busmar Hogar en 2023, y los cuatro primeros resultados son el mismo pasaje («Para 2025, el grupo prevé un crecimiento de las ventas…») en informes de Zaptec Capital, Celra Semiconductores y la propia Busmar del año siguiente. Los ocho informes anuales comparten plantilla, como los reales de un mismo sector, y un pasaje suelto no dice de qué empresa ni de qué año es: esa información está en el título del documento. Ningún embedding puede distinguir pasajes que dicen lo mismo con otras cifras.

BM25 y búsqueda híbrida

La línea base de la búsqueda de documentos es BM25, un modelo léxico: puntúa cada pasaje por las palabras de la consulta que contiene, con más peso a las palabras raras en el corpus y una corrección por longitud. No sabe de sinónimos, pero no confunde «recorta» con «eleva». La búsqueda híbrida combina los dos rankings; la forma más simple y robusta es la fusión por rango recíproco (RRF, Cormack, Clarke y Büttcher, 2009), que suma para cada pasaje 1/(k0+p)1/(k_0 + p) en cada lista, donde pp es la posición del pasaje en esa lista, con k0=60k_0 = 60, el valor que fijaron en su estudio piloto. No necesita que las puntuaciones de los dos sistemas sean comparables, solo sus posiciones.

PreguntaEmbeddings: top 20BM25: top 20RRFTop-k fusionado
Fuente Mermaid
flowchart TB
  P[Pregunta] --> D[Embeddings: top 20]
  P --> L[BM25: top 20]
  D --> R[RRF]
  L --> R
  R --> K[Top-k fusionado]
IN [05]python
import re
import unicodedata
from nltk.corpus import stopwords
from rank_bm25 import BM25Okapi

VACIAS = set(stopwords.words("spanish"))

def tokens(t):
    t = unicodedata.normalize("NFKD", t.lower())
    t = "".join(c for c in t if not unicodedata.combining(c))
    return [w for w in re.findall(r"\w+", t) if w not in VACIAS]

bm25 = BM25Okapi([tokens(t) for t in pas["texto"]])

def rank_bm25(consultas, mascaras=None):
    S = np.array([bm25.get_scores(tokens(c)) for c in consultas])
    if mascaras is not None:
        S[~mascaras] = -np.inf
    return np.argsort(-S, axis=1, kind="stable")[:, :20]

def rrf(*rankings, k0=60, n=20):
    """Reciprocal rank fusion: suma de 1 / (k0 + posición)."""
    salida = []
    for rs in zip(*rankings):
        p = {}
        for r in rs:
            for pos, j in enumerate(r):
                p[j] = p.get(j, 0) + 1 / (k0 + pos + 1)
        salida.append(sorted(p, key=lambda j: (-p[j], j))[:n])
    return np.array(salida)

lexico = rank_bm25(preg["pregunta"])
hibrido = rrf(denso, lexico)
tabla = pd.DataFrame({m: [recall(r, k) for k in (1, 5, 10)]
                      for m, r in [("denso", denso), ("BM25", lexico),
                                   ("híbrido", hibrido)]},
                     index=["recall@1", "recall@5", "recall@10"])
print(tabla.round(3).to_string())
OUT [05]BM25 y búsqueda híbrida con fusión de rankings (RRF)
           denso   BM25  híbrido
recall@1   0.022  0.073    0.042
recall@5   0.114  0.423    0.301
recall@10  0.265  0.655    0.532

BM25 recupera el 42,3 % en los 5 primeros, casi cuatro veces más que los embeddings: las preguntas comparten términos con sus pasajes («provisiones para litigios», «pasivos por arrendamiento», «unidad de creación») y el modelo léxico los explota. La fusión queda entre los dos (30,1 %). Pero ninguno resuelve el problema de fondo: saber de qué documento es cada pasaje.

Chroma: metadatos y filtros

Una base vectorial es más que un índice: guarda los vectores junto con el texto y sus metadatos (documento, emisor, fecha, tipo) y permite filtrar por ellos en la consulta. Chroma es una base embebida, en el propio proceso, con filtros tipo where. Siguiendo el README del entorno ia, se crea la colección con embedding_function=None y se le pasan los embeddings ya calculados (su función por defecto descargaría un modelo, y el curso funciona sin red), con el espacio coseno en la configuración (por defecto usa L2, según su documentación):

IN [06]python
import chromadb

cliente = chromadb.EphemeralClient()
col = cliente.create_collection(
    "pasajes", embedding_function=None,
    configuration={"hnsw": {"space": "cosine"}})
col.add(ids=list(ids), embeddings=P, documents=list(pas["texto"]),
        metadatas=[{"id_doc": a, "entidad": b, "anio": c}
                   for a, b, c in pas[["id_doc", "entidad", "anio"]]
                   .itertuples(index=False)])

def filtro(pregunta):
    """Entidad citada en la pregunta y, si hay, el año más reciente."""
    m = docs[[c in pregunta for c in docs["corto"]]]
    anios = re.findall(r"20\d\d", pregunta)
    if anios and (m["anio"] == max(anios)).any():
        m = m[m["anio"] == max(anios)]
    return m

def where(m):
    if m.empty:
        return None
    return {"id_doc": {"$in": list(m["id_doc"])}}

r = col.query(query_embeddings=Q[:1], n_results=3,
              where=where(filtro(preg.loc[0, "pregunta"])),
              include=["distances"])
print(r["ids"][0], np.round(r["distances"][0], 3))
OUT [06]Chroma con embeddings propios y filtro por metadatos
['R01-p076', 'R01-p068', 'R01-p004'] [0.374 0.383 0.397]

El filtro sale de la propia pregunta: la entidad que nombra y, si cita un año, el informe de ese año. Con el filtro, los tres primeros resultados de la pregunta de Busmar ya son de su informe de 2023 (R01), aunque todavía no el pasaje de oro. Chroma devuelve distancias coseno (1−cos⁡1 - \cos); son las mismas que daría FAISS sobre ese subconjunto. El mismo filtro, como máscara, se aplica a BM25:

IN [07]python
pos = {p: i for i, p in enumerate(ids)}

def rank_chroma(E, consultas, n=20):
    salida = []
    for e, c in zip(E, consultas):
        w = where(filtro(c))
        k = n if w is None else min(n, int(pas["id_doc"].isin(
            w["id_doc"]["$in"]).sum()))
        r = col.query(query_embeddings=e[None], n_results=k, where=w,
                      include=[])
        fila = [pos[x] for x in r["ids"][0]]
        salida.append(fila + [fila[-1]] * (n - len(fila)))
    return np.array(salida)

mascaras = np.array([
    pas["id_doc"].isin(filtro(c)["id_doc"]).to_numpy()
    if not filtro(c).empty else np.ones(len(pas), bool)
    for c in preg["pregunta"]])
denso_f = rank_chroma(Q, preg["pregunta"])
lexico_f = rank_bm25(preg["pregunta"], mascaras)
hibrido_f = rrf(denso_f, lexico_f)
for m, r in [("denso", denso_f), ("BM25", lexico_f),
             ("híbrido", hibrido_f)]:
    print(f"{m:8s}", {f"@{k}": recall(r, k) for k in (1, 5, 10)})
OUT [07]El mismo filtro en las tres búsquedas
denso    {'@1': 0.091, '@5': 0.464, '@10': 0.633}
BM25     {'@1': 0.302, '@5': 0.752, '@10': 0.887}
híbrido  {'@1': 0.151, '@5': 0.656, '@10': 0.804}

El filtro multiplica el recall: con k=5k = 5, del 11,4 % al 46,4 % con embeddings y del 42,3 % al 75,2 % con BM25. Queda un detalle: una vez filtrado el documento, el nombre de la empresa en la consulta ya no informa de nada y atrae hacia los pasajes que lo repiten («Busmar Hogar opera en el sector…»). Se quita:

IN [08]python
def limpia(p):
    for c in docs["corto"].unique():
        p = p.replace(c, "")
    return p

limpias = [limpia(p) for p in preg["pregunta"]]
Q2 = st.encode(limpias, batch_size=64, normalize_embeddings=True,
               show_progress_bar=False)
denso_l = rank_chroma(Q2, preg["pregunta"])
lexico_l = rank_bm25(limpias, mascaras)
hibrido_l = rrf(denso_l, lexico_l)
for m, r in [("denso", denso_l), ("BM25", lexico_l),
             ("híbrido", hibrido_l)]:
    print(f"{m:8s}", {f"@{k}": recall(r, k) for k in (1, 5, 10)})
porpas = lambda r: [len(set(ids[x[:5]]) & g) / len(g)
                    for x, g in zip(r, oro)]
tipo = (pd.DataFrame({"tipo": preg["tipo_pregunta"],
                      "denso": porpas(denso_l),
                      "BM25": porpas(lexico_l),
                      "híbrido": porpas(hibrido_l)})
        .groupby("tipo")
        .agg(n=("denso", "size"), denso=("denso", "mean"),
             BM25=("BM25", "mean"), híbrido=("híbrido", "mean")))
print(tipo.round(2).sort_values("n", ascending=False).to_string())
OUT [08]Con el filtro aplicado, la entidad sobra en la consulta
denso    {'@1': 0.307, '@5': 0.545, '@10': 0.698}
BM25     {'@1': 0.546, '@5': 0.866, '@10': 0.899}
híbrido  {'@1': 0.468, '@5': 0.731, '@10': 0.82}
                n  denso  BM25  híbrido
tipo
texto         122   0.71  0.90     0.72
tabla          64   0.42  0.88     0.75
nota_pie       16   0.50  1.00     0.88
texto_y_nota   16   0.50  1.00     0.84
tabla_y_nota   13   0.19  1.00     1.00
calculo         8   0.00  0.00     0.00
comparacion     7   0.23  0.29     0.47

La figura de la cabecera resume el recorrido. Con filtro y consulta limpia, BM25 llega al 86,6 % en los 5 primeros, los embeddings al 54,5 % y la fusión al 73,1 %. Por tipo de pregunta, BM25 recupera todas las notas al pie y las tablas con nota; los embeddings fallan sobre todo en tablas (42 %), donde el texto son cifras y barras verticales. Las preguntas de cálculo («¿cuánto crecieron las ventas?») no las encuentra nadie: su pasaje de oro es la tabla de magnitudes, que no contiene la palabra «crecieron». Las de comparación entre documentos (7) necesitan cinco pasajes de cinco documentos y ningún método llega a la mitad.

A escala: índices aproximados

Con 722 vectores, comparar con todos es instantáneo. Con millones (todas las noticias de una década, todos los filings de EDGAR troceados), la búsqueda exacta cuesta una distancia por vector y por consulta. Los índices aproximados comparan con una fracción:

  • IVF (inverted file): agrupa los vectores con k-medias en nlist celdas y, en cada consulta, solo recorre las nprobe celdas más cercanas.
  • HNSW (Malkov y Yashunin): un grafo de vecinos por capas; la búsqueda baja por el grafo desde un punto de entrada y efSearch fija cuántos candidatos mantiene.
  • PQ (cuantización de producto): parte cada vector en trozos y guarda cada trozo como el índice de un centroide; con IVF, ahorra memoria además de tiempo.

La celda construye 50 000 vectores sintéticos de 384 dimensiones agrupados en 5 000 temas (un corpus con estructura, como uno real; con embeddings reales las cifras cambian, el patrón no) y mide, para cada índice y parámetro, qué fracción de los 10 vecinos exactos recupera y cuántas distancias calcula por consulta. El número de distancias es la medida de coste determinista; el tiempo depende de la máquina y de su carga, y se comporta en proporción. FAISS se fija a un hilo para que el grafo HNSW sea siempre el mismo.

IN [09]python
rng = np.random.default_rng(0)
N, dim, grupos = 50_000, 384, 5_000
centros = rng.standard_normal((grupos, dim)).astype("float32")
X = centros[rng.integers(0, grupos, N)] + rng.standard_normal(
    (N, dim)).astype("float32")
X /= np.linalg.norm(X, axis=1, keepdims=True)
Xq = centros[rng.integers(0, grupos, 500)] + rng.standard_normal(
    (500, dim)).astype("float32")
Xq /= np.linalg.norm(Xq, axis=1, keepdims=True)
exacto = faiss.IndexFlatIP(dim)
exacto.add(X)
_, verdad = exacto.search(Xq, 10)

def r10(I):
    return np.mean([len(set(a) & set(b)) / 10
                    for a, b in zip(I, verdad)])

cuantif = faiss.IndexFlatIP(dim)
ivf = faiss.IndexIVFFlat(cuantif, dim, 256, faiss.METRIC_INNER_PRODUCT)
ivf.cp.seed = 0
ivf.train(X[:20_000])
ivf.add(X)
tam = np.array([ivf.invlists.list_size(i) for i in range(256)])
filas = [("exacto", "-", 1.0, N)]
for nprobe in (1, 4, 16, 64):
    ivf.nprobe = nprobe
    _, I = ivf.search(Xq, 10)
    _, listas = cuantif.search(Xq, nprobe)
    filas.append(("IVF", f"nprobe={nprobe}", r10(I),
                  256 + tam[listas].sum(1).mean()))
hnsw = faiss.IndexHNSWFlat(dim, 16, faiss.METRIC_INNER_PRODUCT)
hnsw.hnsw.efConstruction = 40
hnsw.add(X)
for ef in (16, 32, 64, 128, 256):
    hnsw.hnsw.efSearch = ef
    faiss.cvar.hnsw_stats.reset()
    _, I = hnsw.search(Xq, 10)
    filas.append(("HNSW", f"efSearch={ef}", r10(I),
                  faiss.cvar.hnsw_stats.ndis / len(Xq)))
pq = faiss.IndexIVFPQ(faiss.IndexFlatIP(dim), dim, 256, 48, 8,
                      faiss.METRIC_INNER_PRODUCT)
pq.cp.seed = 0
pq.train(X[:20_000])
pq.add(X)
tam_pq = np.array([pq.invlists.list_size(i) for i in range(256)])
for nprobe in (16, 64):
    pq.nprobe = nprobe
    _, I = pq.search(Xq, 10)
    _, listas = pq.quantizer.search(Xq, nprobe)
    filas.append(("IVF-PQ", f"nprobe={nprobe}", r10(I),
                  256 + tam_pq[listas].sum(1).mean()))
ann = pd.DataFrame(filas, columns=["índice", "parámetro", "recall@10",
                                   "distancias por consulta"])
ann["distancias por consulta"] = ann["distancias por consulta"].round()
print(ann.round(3).astype({"distancias por consulta": int})
      .to_string(index=False))
print("bytes por vector: exacto", dim * 4, "· IVF-PQ", pq.code_size)
OUT [09]A escala: exacto, IVF, HNSW e IVF-PQ con 50 000 vectores
índice    parámetro  recall@10  distancias por consulta
exacto            -      1.000                    50000
   IVF     nprobe=1      0.427                      453
   IVF     nprobe=4      0.556                     1041
   IVF    nprobe=16      0.712                     3386
   IVF    nprobe=64      0.894                    12763
  HNSW  efSearch=16      0.646                      582
  HNSW  efSearch=32      0.796                      993
  HNSW  efSearch=64      0.860                     1707
  HNSW efSearch=128      0.904                     3067
  HNSW efSearch=256      0.941                     5679
IVF-PQ    nprobe=16      0.658                     3386
IVF-PQ    nprobe=64      0.792                    12763
bytes por vector: exacto 1536 · IVF-PQ 48
Fig. — Recall@10 frente a la búsqueda exacta y distancias calculadas por consulta de los índices IVF y HNSW de FAISS con 50 000 vectores sintéticos de 384 dimensiones

HNSW domina a IVF en estos datos: con unas 3 000 distancias por consulta, HNSW recupera el 90,4 % del top-10 exacto e IVF el 71,2 %; IVF necesita unas 12 800 para llegar al 89,4 %. Ninguno llega al 100 % sin acercarse al coste exacto. El precio de HNSW está en otra parte: construir el grafo es lo más lento de la celda y el grafo ocupa memoria además de los vectores. IVF-PQ recorre las mismas celdas que IVF (las mismas distancias, pero cada una es una suma de 48 valores de una tabla en lugar de un producto de 384 dimensiones) y guarda 48 bytes por vector frente a los 1 536 de un vector float32 (32 veces menos), a cambio de recall: 79,2 % con 64 celdas, frente al 89,4 % de IVF sin comprimir. La guía de FAISS para elegir índice resume el criterio: IndexFlat es el único que garantiza resultados exactos y basta con pocas búsquedas; si no hacen falta exactos y la memoria no aprieta, HNSW (unos d⋅4+M⋅2⋅4d \cdot 4 + M \cdot 2 \cdot 4 bytes por vector: 1 664 aquí, con M=16M = 16); si aprieta, cuantización de producto.

pgvector y Qdrant

FAISS y Chroma corren dentro del proceso. En producción, los vectores suelen vivir en un servicio:

  • pgvector añade a PostgreSQL un tipo vector (hasta 2 000 dimensiones indexables), la distancia coseno <=> e índices HNSW (por defecto m = 16, ef_construction = 64 y hnsw.ef_search = 40) e IVFFlat. Su ventaja es que los pasajes, los metadatos y los permisos están en la misma base y se filtran con SQL. Su documentación avisa de que con índices aproximados el filtro se aplica después de recorrer el índice, así que un filtro selectivo puede devolver menos resultados de los pedidos; desde la versión 0.8.0 hay recorridos iterativos (hnsw.iterative_scan) que siguen buscando hasta completar la lista.
  • Qdrant es un servidor dedicado (REST en el puerto 6333 y gRPC en el 6334, con Docker en local) con filtros por payload dentro de la búsqueda; el cliente de Python crea colecciones con su distancia y consulta con query_points.

Ninguno de los dos se ejecuta en el curso (necesitan un servidor). El código, según su documentación (consultada el 2026-10-23):

IN [10]python
# pgvector: PostgreSQL con la extensión vector (no se ejecuta)
# SQL, una vez:
#   CREATE EXTENSION vector;
#   CREATE TABLE pasajes (id text PRIMARY KEY, id_doc text,
#                         texto text, embedding vector(384));
#   CREATE INDEX ON pasajes USING hnsw (embedding vector_cosine_ops);
import psycopg
from pgvector.psycopg import register_vector

with psycopg.connect("dbname=research") as conn:
    register_vector(conn)
    for (i, doc, txt), e in zip(pas[["id_pasaje", "id_doc", "texto"]]
                                .itertuples(index=False), P):
        conn.execute("INSERT INTO pasajes VALUES (%s, %s, %s, %s)",
                     (i, doc, txt, e))
    conn.execute("SET hnsw.ef_search = 100")
    conn.execute("SET hnsw.iterative_scan = relaxed_order")
    filas = conn.execute(
        "SELECT id, texto FROM pasajes WHERE id_doc = %s "
        "ORDER BY embedding <=> %s LIMIT 5", ("R01", Q[0])).fetchall()
IN [11]python
# Qdrant: servidor propio o gestionado (no se ejecuta en el curso)
from qdrant_client import QdrantClient
from qdrant_client.models import (Distance, FieldCondition, Filter,
                                  MatchValue, PointStruct, VectorParams)

qd = QdrantClient(url="http://localhost:6333")
qd.create_collection(
    collection_name="pasajes",
    vectors_config=VectorParams(size=384, distance=Distance.COSINE))
qd.upsert(collection_name="pasajes", wait=True, points=[
    PointStruct(id=k, vector=e.tolist(),
                payload={"id_pasaje": i, "id_doc": doc})
    for k, (i, doc, e) in enumerate(zip(ids, pas["id_doc"], P))])
res = qd.query_points(
    collection_name="pasajes", query=Q[0].tolist(), limit=5,
    query_filter=Filter(must=[FieldCondition(
        key="id_doc", match=MatchValue(value="R01"))]),
    with_payload=True).points
FAISSChromapgvectorQdrant
Qué esBiblioteca de índicesBase embebida (también cliente-servidor)Extensión de PostgreSQLServidor dedicado
Datos y metadatosFuera (los gestiona el usuario)En la colecciónEn tablas SQLEn el payload
FiltrosPor cuenta propiawhere sobre metadatosSQL (WHERE)Filter en la consulta
Índices aproximadosIVF, HNSW, PQ y másHNSWHNSW, IVFFlatHNSW con filtros
CuándoInvestigación, lotes, máxima velocidad en memoriaPrototipos y RAG localLos datos ya están en PostgresServicio de búsqueda con filtros y escala

Ejercicios

  1. Antepón a cada pasaje el título de su documento (por ejemplo, «Busmar Hogar, S.A. — Informe anual 2023») antes de calcular su embedding y repite la búsqueda densa sin filtros. ¿Cuánto sube el recall@5?
Solución
IN [12]python
titulos = pas["id_doc"].map(docs.set_index("id_doc")["titulo"])
P2 = st.encode(list(titulos + ". " + pas["texto"]), batch_size=32,
               normalize_embeddings=True, show_progress_bar=False)
idx2 = faiss.IndexFlatIP(P2.shape[1])
idx2.add(P2)
_, denso2 = idx2.search(Q, 20)
print({f"recall@{k}": recall(denso2, k) for k in (1, 5, 10)})
OUT [12]Embeddings con el título del documento delante
{'recall@1': 0.199, 'recall@5': 0.515, 'recall@10': 0.583}

El recall@5 denso pasa del 11,4 % al 51,5 % sin ningún filtro: más que el filtro por metadatos con la consulta original (46,4 %) y algo menos que el filtro con la consulta limpia (54,5 %). Poner el contexto del documento dentro del pasaje es la idea del chunking con contexto de la lección 6.3.2: el embedding ya «sabe» de qué empresa y de qué año es cada pasaje, y sirve también cuando la pregunta no nombra la entidad de forma literal y un filtro exacto no se puede construir. Las dos técnicas se combinan.

  1. Pondera la fusión: da a BM25 el doble de peso que a los embeddings en RRF (sumando 2/(k0+p)2/(k_0 + p) para BM25) y mide el recall@5 con el filtro y la consulta limpia.
Solución
IN [13]python
def rrf_pesos(A, B, wa=1.0, wb=2.0, k0=60, n=20):
    salida = []
    for ra, rb in zip(A, B):
        p = {}
        for r, w in ((ra, wa), (rb, wb)):
            for pos_, j in enumerate(r):
                p[j] = p.get(j, 0) + w / (k0 + pos_ + 1)
        salida.append(sorted(p, key=lambda j: (-p[j], j))[:n])
    return np.array(salida)

for wb in (1, 2, 4):
    print(f"peso BM25 {wb}: recall@5 = "
          f"{recall(rrf_pesos(denso_l, lexico_l, wb=wb), 5)}")
OUT [13]RRF ponderado
peso BM25 1: recall@5 = 0.731
peso BM25 2: recall@5 = 0.81
peso BM25 4: recall@5 = 0.813

Con el doble de peso para BM25, la fusión sube del 73,1 % al 81,0 %; con cuatro veces, al 81,3 %, todavía por debajo del 86,6 % de BM25 solo. Con un recuperador claramente mejor que el otro, la fusión ponderada solo compensa si el débil aporta pasajes que el fuerte no encuentra (preguntas formuladas con otras palabras); aquí no ocurre lo bastante. El peso, además, se elige en un conjunto de evaluación y se comprueba en otro: elegirlo y medirlo en las mismas 246 preguntas sobreestima el resultado.

  1. Una gestora quiere un buscador sobre 15 años de notas de research (unos 2 millones de pasajes), con permisos por equipo y filtros por fecha y emisor, y ya guarda todo en PostgreSQL. ¿Qué opción elegirías y qué medirías antes?
Solución

pgvector es el candidato natural: los pasajes, los permisos y los metadatos ya están en PostgreSQL y los filtros por fecha, emisor y equipo son SQL. Antes de decidir mediría, con un conjunto de preguntas propio y sus pasajes de referencia, el recall@k de la búsqueda densa, de la léxica (PostgreSQL tiene búsqueda de texto completo) y de la híbrida; el recall del índice HNSW frente a la búsqueda exacta con los filtros reales, porque un filtro muy selectivo después de un índice aproximado puede devolver pocos resultados (de ahí los recorridos iterativos); la latencia con la carga esperada; y la memoria del índice con 2 millones de vectores de la dimensión elegida. Qdrant o un índice FAISS aparte tendrían sentido si la búsqueda vectorial tuviera que escalar de forma independiente de la base de datos.

Respuesta plegada en cada pregunta

Autoevaluación

  1. Con MiniLM, «La empresa eleva su previsión de ventas para el año» y «La empresa recorta su previsión de ventas para el año» tienen similitud coseno 0,95, y la primera y su paráfrasis «La compañía mejora sus perspectivas de ingresos anuales», 0,53. ¿Qué indica?

    • AQue el modelo está roto
    • BQue la similitud coseno no sirve para texto
    • CQue, en español, este embedding mide sobre todo palabras compartidas y tema, no el sentido de la frase: dos frases opuestas con las mismas palabras quedan más cerca que dos frases equivalentes con palabras distintas
    • DQue las frases opuestas son sinónimos en finanzas
    Ver respuesta

    Respuesta C. Que, en español, este embedding mide sobre todo palabras compartidas y tema, no el sentido de la frase: dos frases opuestas con las mismas palabras quedan más cerca que dos frases equivalentes con palabras distintas

    all-MiniLM-L6-v2 se entrenó con texto inglés. En español apenas pasa de comparar fragmentos de palabras. Por eso, para buscar por significado en español, hace falta un modelo multilingüe o, como en esta lección, combinarlo con búsqueda léxica.

  2. Sin filtros, la búsqueda densa recupera el 11,4 % de los pasajes de referencia entre los 5 primeros. Para una pregunta sobre Busmar Hogar en 2023, los primeros resultados son pasajes casi idénticos de otros emisores. ¿Cuál es la causa principal?

    • ALos ocho informes anuales comparten plantilla: muchos pasajes solo se distinguen por la empresa y el año, que no aparecen en el pasaje
    • BEl índice de FAISS es aproximado
    • CEl modelo tiene pocas dimensiones
    • DLas preguntas son demasiado largas
    Ver respuesta

    Respuesta A. Los ocho informes anuales comparten plantilla: muchos pasajes solo se distinguen por la empresa y el año, que no aparecen en el pasaje

    IndexFlatIP es exacto. El problema es de contenido: «Para 2024, el grupo prevé un crecimiento de las ventas…» aparece en cuatro informes con otras cifras. Filtrar por la entidad y el año (metadatos) sube el recall@5 denso al 46,4 % y el de BM25 al 75,2 %.

  3. Tras filtrar y limpiar la consulta, el recall@5 es del 54,5 % con embeddings, del 86,6 % con BM25 y del 73,1 % con la fusión RRF de los dos. ¿Qué conclusión es correcta?

    • ALa búsqueda híbrida siempre es la mejor
    • BLos embeddings siempre son peores que BM25
    • CRRF está mal implementado
    • DLa fusión con pesos iguales hereda los errores del recuperador débil; aquí conviene BM25 solo, ponderar la fusión o cambiar a un modelo de embeddings que entienda español, y decidirlo con el conjunto de evaluación
    Ver respuesta

    Respuesta D. La fusión con pesos iguales hereda los errores del recuperador débil; aquí conviene BM25 solo, ponderar la fusión o cambiar a un modelo de embeddings que entienda español, y decidirlo con el conjunto de evaluación

    RRF promedia rankings: si uno de los dos es claramente peor, la mezcla queda entre ambos. Con un embedding multilingüe o preguntas que no comparten palabras con el pasaje, el resultado podría invertirse. La decisión sale de medir recall, no de la arquitectura de moda.

  4. Con 50 000 vectores, HNSW con efSearch = 128 recupera el 90,4 % de los 10 vecinos exactos calculando unas 3 067 distancias por consulta, e IVF con nprobe = 64 recupera el 89,4 % con unas 12 763. ¿Qué se gana con un índice aproximado?

    • AMás precisión que la búsqueda exacta
    • BVelocidad: se calculan muchas menos distancias que las 50 000 del índice exacto, a cambio de perder algunos vecinos; el parámetro de búsqueda regula ese compromiso
    • CMenos memoria siempre
    • DNada con menos de un millón de vectores
    Ver respuesta

    Respuesta B. Velocidad: se calculan muchas menos distancias que las 50 000 del índice exacto, a cambio de perder algunos vecinos; el parámetro de búsqueda regula ese compromiso

    HNSW e IVF guardan los vectores completos (HNSW, además, el grafo), así que no ahorran memoria. La que ahorra es la cuantización de producto: IVF-PQ guarda 48 bytes por vector en lugar de 1 536, con menos recall.

  5. ¿Cuándo tiene sentido pgvector frente a FAISS?

    • ACuando los pasajes y sus metadatos ya viven en PostgreSQL y hace falta filtrar con SQL, transacciones y permisos de la base de datos
    • BCuando se necesita la búsqueda más rápida posible en memoria
    • CNunca: pgvector solo hace búsqueda exacta
    • DSolo con más de mil millones de vectores
    Ver respuesta

    Respuesta A. Cuando los pasajes y sus metadatos ya viven en PostgreSQL y hace falta filtrar con SQL, transacciones y permisos de la base de datos

    FAISS es una biblioteca: índices en memoria del proceso, sin almacenamiento ni filtros por metadatos propios. pgvector añade a PostgreSQL un tipo vector e índices HNSW e IVFFlat; su documentación avisa de que con índices aproximados el filtro se aplica después del recorrido del índice, y desde la versión 0.8.0 ofrece recorridos iterativos.

Ideas clave

  • Un embedding normalizado convierte un texto en un vector de norma 1 y la similitud coseno es su producto interior. Qué capta depende del modelo: con all-MiniLM-L6-v2 en español, «eleva» y «recorta» la previsión quedan a 0,95.
  • Sin filtros, la búsqueda densa recupera el 11,4 % de los pasajes de referencia en los 5 primeros y BM25 el 42,3 %: con documentos de plantilla, la empresa y el año (que no están en el pasaje) deciden.
  • Los metadatos son la mitad de una base vectorial: filtrar por entidad y año en Chroma y quitar la entidad de la consulta lleva el recall@5 al 54,5 % (denso), 86,6 % (BM25) y 73,1 % (híbrido RRF).
  • Los índices aproximados cambian recall por coste: con 50 000 vectores, HNSW llega al 90,4 % del top-10 exacto con 3 067 distancias por consulta, IVF necesita 12 763 para el 89,4 % y el exacto, 50 000. IVF-PQ reduce cada vector de 1 536 a 48 bytes.
  • FAISS es una biblioteca de índices; Chroma, una base embebida con metadatos; pgvector lleva los vectores a PostgreSQL y Qdrant es un servidor dedicado con filtros por payload. Se elige por dónde viven los datos y qué filtros hacen falta.

Recursos