---
title: "6.3.1 Embeddings y bases vectoriales"
description: "Embeddings con FAISS y Chroma frente a BM25 y búsqueda híbrida en documentos financieros: recall medido, filtros por metadatos y coste de IVF y HNSW."
url: "https://pythonparatrading.com/curso-python-trading/nivel-6/embeddings-y-bases-vectoriales/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-6/embeddings-y-bases-vectoriales.png"
---

# 6.3.1 · 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.

- Curso: [Nivel 6: IA generativa, LLMs, agentes, big data y datos alternativos](https://pythonparatrading.com/curso-python-trading/nivel-6/)
- Módulo: 6.3 · RAG financiero
- Duración: 70 min
- Requisitos: [6.1.1 · Tokenización, embeddings, diccionarios financieros y FinBERT](https://pythonparatrading.com/curso-python-trading/nivel-6/tokenizacion-embeddings-loughran-mcdonald-y-finbert/)

## Al terminar esta lección

- Calcular embeddings normalizados con sentence-transformers, compararlos por similitud coseno y saber qué captan y qué no (polaridad, idioma).
- Indexar pasajes con FAISS y con Chroma (con embeddings propios y filtros por metadatos) y medir el recall@k contra un conjunto de evaluación.
- Construir una línea base BM25 y una búsqueda híbrida con fusión de rankings (RRF), y decidir con datos cuál usar.
- Medir el compromiso entre recall y coste de los índices aproximados (IVF, HNSW, IVF-PQ) frente a la búsqueda exacta.
- Situar pgvector y Qdrant frente a FAISS y Chroma según su documentación: dónde viven los datos, filtros e índices.

## 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.

```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 \cdot v$ si $\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:

```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())
```
```output
(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.

```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())
```
```output
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 $k$ primeros
resultados, promediado sobre las preguntas. Es la que importa en RAG, porque lo que no se recupera no llega al LLM. Con
$k = 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:

```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)})
```
```output
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:

```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}")
```
```output
¿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/(k_0 + p)$ en cada lista, donde $p$ es la posición del pasaje en esa lista, con $k_0 = 60$, el valor que fijaron en su estudio piloto. No necesita que las
puntuaciones de los dos sistemas sean comparables, solo sus posiciones.

```mermaid
flowchart TB
  P[Pregunta] --> D[Embeddings: top 20]
  P --> L[BM25: top 20]
  D --> R[RRF]
  L --> R
  R --> K[Top-k fusionado]
```

```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())
```
```output
           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):

```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))
```
```output
['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$); son las mismas que daría FAISS sobre ese subconjunto. El mismo
filtro, como máscara, se aplica a BM25:

```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)})
```
```output
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 = 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:

```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())
```
```output
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.

<aside class="callout" data-kind="aviso">

La búsqueda híbrida no es mejor por definición. Con pesos iguales, RRF hereda los errores del recuperador débil: aquí el
denso. Las opciones son ponderar la fusión, cambiar a un modelo de embeddings que entienda español o quedarse con BM25,
y la decisión sale de medir el recall en un conjunto de evaluación propio, nunca de una comparativa genérica.

</aside>

## 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.

```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)
```
```output
í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.* Dos curvas en escala logarítmica de coste. HNSW, destacada, va del 64,6 % con 582 distancias al 94,1 % con 5 679. IVF va del 42,7 % con 453 distancias al 89,4 % con 12 763. La búsqueda exacta recupera el 100 % con 50 000 distancias.

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 \cdot 4 + M \cdot 2 \cdot 4$ bytes por vector: 1 664 aquí, con $M = 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):

```python skip
# 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()
```

```python skip
# 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
```

| | FAISS | Chroma | pgvector | Qdrant |
|---|---|---|---|---|
| Qué es | Biblioteca de índices | Base embebida (también cliente-servidor) | Extensión de PostgreSQL | Servidor dedicado |
| Datos y metadatos | Fuera (los gestiona el usuario) | En la colección | En tablas SQL | En el *payload* |
| Filtros | Por cuenta propia | `where` sobre metadatos | SQL (`WHERE`) | `Filter` en la consulta |
| Índices aproximados | IVF, HNSW, PQ y más | HNSW | HNSW, IVFFlat | HNSW con filtros |
| Cuándo | Investigación, lotes, máxima velocidad en memoria | Prototipos y RAG local | Los datos ya están en Postgres | Servicio 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.**

```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)})
```
```output
{'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.

2. Pondera la fusión: da a BM25 el doble de peso que a los embeddings en RRF (sumando $2/(k_0 + p)$ para BM25) y
   mide el recall@5 con el filtro y la consulta limpia.

**Solución.**

```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)}")
```
```output
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.

3. 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.

## 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?

   - A. Que el modelo está roto
   - B. Que la similitud coseno no sirve para texto
   - 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
   - D. Que las frases opuestas son sinónimos en finanzas

   *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?

   - 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
   - B. El índice de FAISS es aproximado
   - C. El modelo tiene pocas dimensiones
   - D. Las preguntas son demasiado largas

   *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?

   - A. La búsqueda híbrida siempre es la mejor
   - B. Los embeddings siempre son peores que BM25
   - C. RRF está mal implementado
   - 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

   *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?

   - A. Más precisión que la búsqueda exacta
   - 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
   - C. Menos memoria siempre
   - D. Nada con menos de un millón de vectores

   *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?

   - 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
   - B. Cuando se necesita la búsqueda más rápida posible en memoria
   - C. Nunca: pgvector solo hace búsqueda exacta
   - D. Solo con más de mil millones de vectores

   *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

### Documentación

- [FAISS: guía para elegir un índice (wiki del proyecto)](https://github.com/facebookresearch/faiss/wiki/Guidelines-to-choose-an-index) · github.com
- [Chroma: filtros por metadatos](https://docs.trychroma.com/docs/querying-collections/metadata-filtering) · docs.trychroma.com
- [pgvector (GitHub): índices HNSW e IVFFlat, filtros y recorridos iterativos](https://github.com/pgvector/pgvector) · github.com
- [Qdrant: filtros por payload](https://qdrant.tech/documentation/concepts/filtering/) · qdrant.tech

### Libros

- [Malkov, Y. y Yashunin, D. (2016): Efficient and robust approximate nearest neighbor search using HNSW graphs. arXiv:1603.09320](https://arxiv.org/abs/1603.09320) · arxiv.org
- [Cormack, G. V., Clarke, C. L. A. y Büttcher, S. (2009): Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods. SIGIR](https://plg.uwaterloo.ca/~gvcormac/cormacksigir09-rrf.pdf) · plg.uwaterloo.ca

### Datos

- [Datos del nivel 6: pasajes de referencia para RAG (Parquet)](https://pythonparatrading.com/curso/datos/nivel-6/rag6_pasajes.parquet) · Descarga · rag6_pasajes.parquet

Lección anterior: [6.2.5 · Sesgo de anticipación en LLMs: el modelo que ya conoce el futuro](https://pythonparatrading.com/curso-python-trading/nivel-6/sesgo-de-anticipacion-en-llms/)

Lección siguiente: [6.3.2 · Chunking de documentos financieros: tablas, notas al pie y XBRL](https://pythonparatrading.com/curso-python-trading/nivel-6/chunking-de-documentos-financieros/)
