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.
- Módulo
- 6.3 · RAG financiero
- Duración
- 70 min
Lecciones del nivel 6
6.1 · NLP financiero clásico y moderno
6.2 · LLMs para investigación y trading
6.3 · RAG financiero
6.4 · Agentes de IA para trading
- 6.4.1 Agentes con herramientas: tool calling, ReAct y planificación
- 6.4.2 Marcos de agentes y MCP
- 6.4.3 Arquitecturas multiagente tipo TradingAgents
- 6.4.4 Evidencia y límites de los agentes
- 6.4.5 Guardarraíles para agentes: el agente propone, el motor decide
- 6.4.6 Agentes de investigación: hipótesis, alpha mining y revisión
6.5 · IA como copiloto del quant
6.6 · Big data para trading
- 6.6.1 Cuándo los datos son «big»: ticks, libros, opciones y universos
- 6.6.2 Parquet, Arrow, Polars en streaming y DuckDB fuera de memoria
- 6.6.3 Dask, Spark y Ray: de una máquina a un clúster
- 6.6.4 Bases de series temporales: TimescaleDB, QuestDB, ClickHouse, ArcticDB y kdb+
- 6.6.5 Almacenamiento en la nube, lakehouse (Delta, Iceberg) y GPU (RAPIDS, cuDF)
6.7 · Datos alternativos
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.
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: si . 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:
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())(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.00El 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.
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())21 documentos · 722 pasajes · 246 preguntas respondibles
tipo
texto 431
lista 119
tabla 105
nota 67La métrica es el recall@k: de los pasajes de oro de cada pregunta, qué fracción aparece entre los primeros resultados, promediado sobre las preguntas. Es la que importa en RAG, porque lo que no se recupera no llega al LLM. Con 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:
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)})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:
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}")¿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 en cada lista, donde es la posición del pasaje en esa lista, con , el valor que fijaron en su estudio piloto. No necesita que las puntuaciones de los dos sistemas sean comparables, solo sus posiciones.
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]
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()) 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.532BM25 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):
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))['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 (); son las mismas que daría FAISS sobre ese subconjunto. El mismo
filtro, como máscara, se aplica a BM25:
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)})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 , 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:
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())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.47La 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
nlistceldas y, en cada consulta, solo recorre lasnprobeceldas 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
efSearchfija 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.
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)í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 48HNSW 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 bytes por vector: 1 664 aquí, con ); 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 defectom = 16,ef_construction = 64yhnsw.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):
# 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()# 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
- 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
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)}){'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.
- Pondera la fusión: da a BM25 el doble de peso que a los embeddings en RRF (sumando para BM25) y mide el recall@5 con el filtro y la consulta limpia.
Solución
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)}")peso BM25 1: recall@5 = 0.731
peso BM25 2: recall@5 = 0.81
peso BM25 4: recall@5 = 0.813Con 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.
- 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
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.
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 %.
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.
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.
¿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.