Lesson 6.3.1 · 70 min

Embeddings and vector databases: FAISS, Chroma, pgvector and Qdrant

A RAG system starts by finding the passages that answer the question. This lesson turns synthetic annual reports, ETF prospectuses and KIDs into vectors with MiniLM, searches them with FAISS and with Chroma, measures recall against the reference passages and compares it with BM25 and with a hybrid search. The result is not what the marketing promises: in these documents, the exact word and the metadata filters matter more than the embedding.

Fig. — Recall@5 of dense (MiniLM), lexical (BM25) and hybrid (RRF) search over 246 questions with reference passages: no filter, with an entity-and-year filter, and with the entity removed from the query
01/03
Module
6.3 · Financial RAG
Duration
70 min
Updated
Level 6 lessons
  1. 6.1 · Classical and modern financial NLP

    1. 6.1.1 Tokenization, embeddings, financial dictionaries and FinBERT
    2. 6.1.2 Text sources: EDGAR, CNMV, transcripts and social media
    3. 6.1.3 Event studies with text signals, timestamp alignment and topic modeling
  2. 6.2 · LLMs for research and trading

    1. 6.2.1 How an LLM works: from the next token to the API
    2. 6.2.2 Structured prompting: JSON you can validate
    3. 6.2.3 Guidance, KPIs and tone: what an LLM extracts well
    4. 6.2.4 Lightweight fine-tuning for financial tasks: LoRA and QLoRA
    5. 6.2.5 Look-ahead bias in LLMs: the model that already knows the future
  3. 6.3 · Financial RAG

    1. 6.3.1 Embeddings and vector databases: FAISS, Chroma, pgvector and Qdrant
    2. 6.3.2 Chunking financial documents: tables, footnotes and XBRL
    3. 6.3.3 RAG over reports, prospectuses and KIDs, and how to evaluate it
  4. 6.4 · AI agents for trading

    1. 6.4.1 Tool-calling agents: tool calling, ReAct and planning
    2. 6.4.2 Agent frameworks and MCP
    3. 6.4.3 TradingAgents-style multi-agent architectures
    4. 6.4.4 Evidence and limits of agents
    5. 6.4.5 Guardrails for agents: the agent proposes, the engine decides
    6. 6.4.6 Research agents: hypotheses, alpha mining and review
  5. 6.5 · AI as the quant's copilot

    1. 6.5.1 Coding assistants with a test-first workflow
    2. 6.5.2 Automated review of backtests and code migrations
    3. 6.5.3 Risks of AI-generated code
  6. 6.6 · Big data for trading

    1. 6.6.1 When data becomes “big”: ticks, order books, options and universes
    2. 6.6.2 Parquet, Arrow, Polars streaming and out-of-core DuckDB
    3. 6.6.3 Dask, Spark and Ray: from one machine to a cluster
    4. 6.6.4 Time series databases: TimescaleDB, QuestDB, ClickHouse, ArcticDB and kdb+
    5. 6.6.5 Cloud storage, lakehouse (Delta, Iceberg) and GPU (RAPIDS, cuDF)
  7. 6.7 · Alternative data

    1. 6.7.1 Overview and size of the alternative data market
    2. 6.7.2 Types of alternative data: what each one measures
    3. 6.7.3 Responsible web scraping
    4. 6.7.4 Alternative data vendors and due diligence
    5. 6.7.5 From alternative data to a trading signal
    6. 6.7.6 Satellite imagery, computer vision and supply chains

Searching by meaning

A question-answering system over documents (RAG, lesson 6.3.3) has two parts: retrieving the passages that contain the answer and writing it with an LLM from them. If retrieval fails, the LLM answers without the information or makes it up. This lesson covers the first part: how to turn text into vectors, how to search them fast and, above all, how to measure whether what has to be found is found.

PassagesEmbeddingsVector indexQuestionSame modelNearest neighborsMetadata filterTop-k to the LLM
Mermaid source
flowchart TB
  A[Passages] --> B[Embeddings]
  B --> C[Vector index]
  D[Question] --> E[Same model]
  E --> F[Nearest neighbors]
  C --> F
  F --> G[Metadata filter]
  G --> H[Top-k to the LLM]

An embedding is a fixed-dimension vector (384 in all-MiniLM-L6-v2) that the model assigns to a text, trained so that similar texts end up close together. With unit-norm vectors, cosine similarity is the inner product: cos⁡(u,v)=u⋅v\cos(u, v) = u \cdot v if ∥u∥=∥v∥=1\lVert u \rVert = \lVert v \rVert = 1. Lesson 6.1.1 used them as features for a classifier; here they are used to search. Before searching, it is worth seeing what they measure. The example corpus of this lesson (sentences, reports, prospectuses and questions) is in Spanish and stays as is, because the lesson measures how a model trained on English handles it; the first cell compares four Spanish sentences and one English one:

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, "· norm:", 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]Five sentences, five vectors: cosine similarity
(5, 384) float32 · norm: [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

The labels are the key word of each sentence: “eleva” (the company raises its sales forecast for the year), “mejora” (the company improves its annual revenue outlook), “recorta” (the company cuts its sales forecast), “raises” (the English translation of the first) and “dividendo” (the board approves a dividend payment). The result is instructive. “Eleva” and “recorta” the sales forecast, sentences of opposite meaning with almost the same words, have a similarity of 0.95; “eleva” and its paraphrase “la compañía mejora sus perspectivas de ingresos anuales”, with different words, only 0.53. The English translation sits at 0.17: the model is not multilingual. all-MiniLM-L6-v2 was trained on English text, and with Spanish text it behaves almost like a word-fragment comparator. It is the course's model because of its size (22 million parameters, 91 MB) and license (Apache-2.0); for Spanish there are multilingual models of the same family that are not installed in the environment. The lesson measures what this one delivers, which is the usual situation: a generic model on text that is not its own.

The corpus and its evaluation set

The level 6 RAG material is 21 synthetic Markdown documents: 2023 and 2024 annual reports of four issuers, prospectuses and KIDs of five ETFs and three research notes. rag6_pasajes.parquet splits them into reference passages (lesson 6.3.2 covers how to split documents) and rag6_preguntas.csv brings 263 questions with the passages that have to be cited to answer them. Title passages (section headings, which answer nothing) and the 17 questions with no answer in the documents are discarded.

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), "documents ·", len(pas), "passages ·", len(preg),
      "answerable questions")
print(pas["tipo"].value_counts().to_string())
OUT [02]The corpus: passages from reports, prospectuses, KIDs and research
21 documents · 722 passages · 246 answerable questions
tipo
texto    431
lista    119
tabla    105
nota      67

The metric is recall@k: of each question's gold passages, the fraction that appears among the top kk results, averaged over the questions. It is the one that matters in RAG, because what is not retrieved does not reach the LLM. With k=5k = 5 it measures what would fit in a typical prompt.

FAISS is a library of in-memory vector indexes. IndexFlatIP stores the vectors as they are and compares the query with all of them (exact search by inner product, which with normalized vectors is the cosine). With 722 passages nothing more is needed:

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])   # inner product = cosine
indice.add(P)
print(indice.ntotal, "vectors ·", P.nbytes // 1024, "KB in float32")
ids = pas["id_pasaje"].to_numpy()

def recall(rankings, k):
    """Fraction of the gold passages that are among the top k."""
    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]Passage embeddings and an exact index with FAISS
722 vectors · 1083 KB in float32
{'recall@1': 0.022, 'recall@5': 0.114, 'recall@10': 0.265}

A recall@5 of 11.4% is very low, and the index is not to blame: it is exact. You have to look at what it returns:

IN [04]python
i = 0
print(preg.loc[i, "pregunta"], "· gold:", 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]Why it fails: eight reports with the same template
¿Cuál es el mayor mercado de Busmar Hogar por ventas en 2023 y qué peso tiene? · gold: 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'

The question is about the largest market of Busmar Hogar in 2023, and the top four results are the same passage (“Para 2025, el grupo prevé un crecimiento de las ventas…”, “For 2025, the group expects sales growth…”) in reports from Zaptec Capital, Celra Semiconductores and Busmar itself for the following year. The eight annual reports share a template, like real ones from the same sector, and a loose passage does not say which company or which year it belongs to: that information is in the document's title. No embedding can tell apart passages that say the same thing with other figures.

The baseline of document search is BM25, a lexical model: it scores each passage by the query words it contains, with more weight on words that are rare in the corpus and a length correction. It knows nothing about synonyms, but it does not confuse “recorta” (cuts) with “eleva” (raises). Hybrid search combines the two rankings; the simplest and most robust way is reciprocal rank fusion (RRF, Cormack, Clarke and Büttcher, 2009), which adds 1/(k0+p)1/(k_0 + p) for each passage in each list, where pp is the passage's position in that list, with k0=60k_0 = 60, the value they fixed in their pilot study. It does not need the scores of the two systems to be comparable, only their positions.

QuestionEmbeddings: top 20BM25: top 20RRFFused top-k
Mermaid source
flowchart TB
  P[Question] --> D[Embeddings: top 20]
  P --> L[BM25: top 20]
  D --> R[RRF]
  L --> R
  R --> K[Fused top-k]
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: sum of 1 / (k0 + position)."""
    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 [("dense", denso), ("BM25", lexico),
                                   ("hybrid", hibrido)]},
                     index=["recall@1", "recall@5", "recall@10"])
print(tabla.round(3).to_string())
OUT [05]BM25 and hybrid search with rank fusion (RRF)
           dense   BM25  hybrid
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 retrieves 42.3% in the top 5, almost four times more than the embeddings: the questions share terms with their passages (“provisiones para litigios”, litigation provisions; “pasivos por arrendamiento”, lease liabilities; “unidad de creación”, creation unit) and the lexical model exploits them. The fusion ends up between the two (30.1%). But neither solves the underlying problem: knowing which document each passage comes from.

Chroma: metadata and filters

A vector database is more than an index: it stores the vectors together with the text and its metadata (document, issuer, date, type) and lets you filter by them in the query. Chroma is an embedded database, inside the process itself, with where-style filters. Following the README of the ia environment, the collection is created with embedding_function=None and given the embeddings already computed (its default function would download a model, and the course works without a network), with the cosine space in the configuration (by default it uses L2, according to its documentation):

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):
    """Entity named in the question and, if any, the most recent year."""
    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 with your own embeddings and a metadata filter
['R01-p076', 'R01-p068', 'R01-p004'] [0.374 0.383 0.397]

The filter comes from the question itself: the entity it names and, if it cites a year, the report for that year. With the filter, the top three results for the Busmar question are already from its 2023 report (R01), although still not the gold passage. Chroma returns cosine distances (1−cos⁡1 - \cos); they are the same FAISS would give on that subset. The same filter, as a mask, is applied to 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 [("dense", denso_f), ("BM25", lexico_f),
             ("hybrid", hibrido_f)]:
    print(f"{m:8s}", {f"@{k}": recall(r, k) for k in (1, 5, 10)})
OUT [07]The same filter in the three searches
dense    {'@1': 0.091, '@5': 0.464, '@10': 0.633}
BM25     {'@1': 0.302, '@5': 0.752, '@10': 0.887}
hybrid   {'@1': 0.151, '@5': 0.656, '@10': 0.804}

The filter multiplies recall: with k=5k = 5, from 11.4% to 46.4% with embeddings and from 42.3% to 75.2% with BM25. One detail remains: once the document is filtered, the company's name in the query no longer tells anything and pulls toward the passages that repeat it (“Busmar Hogar opera en el sector…”, “Busmar Hogar operates in the … sector”). It is removed:

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 [("dense", denso_l), ("BM25", lexico_l),
             ("hybrid", 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"],
                      "dense": porpas(denso_l),
                      "BM25": porpas(lexico_l),
                      "hybrid": porpas(hibrido_l)})
        .groupby("tipo")
        .agg(n=("dense", "size"), dense=("dense", "mean"),
             BM25=("BM25", "mean"), hybrid=("hybrid", "mean")))
print(tipo.round(2).sort_values("n", ascending=False).to_string())
OUT [08]With the filter applied, the entity is redundant in the query
dense    {'@1': 0.307, '@5': 0.545, '@10': 0.698}
BM25     {'@1': 0.546, '@5': 0.866, '@10': 0.899}
hybrid   {'@1': 0.468, '@5': 0.731, '@10': 0.82}
                n  dense  BM25  hybrid
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

The figure at the top summarizes the path. With the filter and the clean query, BM25 reaches 86.6% in the top 5, the embeddings 54.5% and the fusion 73.1%. By question type, BM25 retrieves every footnote and every table with a note; the embeddings fail mostly on tables (42%), where the text is figures and vertical bars. Calculation questions (“¿cuánto crecieron las ventas?”, how much did sales grow?) are found by nobody: their gold passage is the table of key figures, which does not contain the word “crecieron” (grew). Cross-document comparison questions (7) need five passages from five documents and no method gets to half.

At scale: approximate indexes

With 722 vectors, comparing against all of them is instantaneous. With millions (all the news of a decade, every EDGAR filing chunked), exact search costs one distance per vector and per query. Approximate indexes compare against a fraction:

  • IVF (inverted file): groups the vectors with k-means into nlist cells and, for each query, only scans the nprobe nearest cells.
  • HNSW (Malkov and Yashunin): a layered neighbor graph; the search descends the graph from an entry point and efSearch sets how many candidates it keeps.
  • PQ (product quantization): splits each vector into chunks and stores each chunk as the index of a centroid; with IVF, it saves memory as well as time.

The cell builds 50,000 synthetic 384-dimensional vectors grouped into 5,000 topics (a corpus with structure, like a real one; with real embeddings the figures change, the pattern does not) and measures, for each index and parameter, what fraction of the 10 exact neighbors it retrieves and how many distances it computes per query. The number of distances is the deterministic cost measure; time depends on the machine and its load, and behaves proportionally. FAISS is pinned to one thread so that the HNSW graph is always the same.

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 = [("exact", "-", 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=["index", "parameter", "recall@10",
                                   "distances per query"])
ann["distances per query"] = ann["distances per query"].round()
print(ann.round(3).astype({"distances per query": int})
      .to_string(index=False))
print("bytes per vector: exact", dim * 4, "· IVF-PQ", pq.code_size)
OUT [09]At scale: exact, IVF, HNSW and IVF-PQ with 50,000 vectors
 index    parameter  recall@10  distances per query
 exact            -      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 per vector: exact 1536 · IVF-PQ 48
Fig. — Recall@10 against exact search and distances computed per query for FAISS IVF and HNSW indexes on 50,000 synthetic 384-dimensional vectors

HNSW dominates IVF on this data: with about 3,000 distances per query, HNSW retrieves 90.4% of the exact top 10 and IVF 71.2%; IVF needs about 12,800 to reach 89.4%. Neither reaches 100% without approaching the exact cost. The price of HNSW lies elsewhere: building the graph is the slowest part of the cell and the graph takes memory on top of the vectors. IVF-PQ scans the same cells as IVF (the same distances, but each one is a sum of 48 values from a table instead of a 384-dimensional product) and stores 48 bytes per vector against the 1,536 of a float32 vector (32 times less), at the cost of recall: 79.2% with 64 cells, against 89.4% for uncompressed IVF. The FAISS guide to choosing an index sums up the criterion: IndexFlat is the only one that guarantees exact results and is enough with few searches; if exact results are not needed and memory is not tight, HNSW (about d⋅4+M⋅2⋅4d \cdot 4 + M \cdot 2 \cdot 4 bytes per vector: 1,664 here, with M=16M = 16); if it is tight, product quantization.

pgvector and Qdrant

FAISS and Chroma run inside the process. In production, vectors usually live in a service:

  • pgvector adds to PostgreSQL a vector type (up to 2,000 indexable dimensions), the cosine distance <=> and HNSW indexes (by default m = 16, ef_construction = 64 and hnsw.ef_search = 40) and IVFFlat. Its advantage is that the passages, the metadata and the permissions are in the same database and are filtered with SQL. Its documentation warns that with approximate indexes the filter is applied after the index scan, so a selective filter can return fewer results than requested; since version 0.8.0 there are iterative scans (hnsw.iterative_scan) that keep searching until the list is complete.
  • Qdrant is a dedicated server (REST on port 6333 and gRPC on 6334, with Docker locally) with payload filters inside the search; the Python client creates collections with their distance and queries with query_points.

Neither of the two is run in the course (they need a server). The code, according to their documentation (accessed 2026-10-23):

IN [10]python
# pgvector: PostgreSQL with the vector extension (not run)
# SQL, once:
#   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: self-hosted or managed server (not run in the course)
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
What it isIndex libraryEmbedded database (also client-server)PostgreSQL extensionDedicated server
Data and metadataOutside (managed by the user)In the collectionIn SQL tablesIn the payload
FiltersOn your ownwhere on metadataSQL (WHERE)Filter in the query
Approximate indexesIVF, HNSW, PQ and moreHNSWHNSW, IVFFlatHNSW with filters
WhenResearch, batches, maximum in-memory speedPrototypes and local RAGThe data is already in PostgresSearch service with filters and scale

Exercises

  1. Prepend to each passage the title of its document (for example, “Busmar Hogar, S.A. — Informe anual 2023”, annual report 2023) before computing its embedding and repeat the dense search without filters. How much does recall@5 rise?
Solution
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 with the document title in front
{'recall@1': 0.199, 'recall@5': 0.515, 'recall@10': 0.583}

Dense recall@5 goes from 11.4% to 51.5% without any filter: more than the metadata filter with the original query (46.4%) and somewhat less than the filter with the clean query (54.5%). Putting the document's context inside the passage is the idea of contextual chunking from lesson 6.3.2: the embedding already “knows” which company and which year each passage belongs to, and it also helps when the question does not name the entity literally and an exact filter cannot be built. The two techniques combine.

  1. Weight the fusion: give BM25 twice the weight of the embeddings in RRF (adding 2/(k0+p)2/(k_0 + p) for BM25) and measure recall@5 with the filter and the clean query.
Solution
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"BM25 weight {wb}: recall@5 = "
          f"{recall(rrf_pesos(denso_l, lexico_l, wb=wb), 5)}")
OUT [13]Weighted RRF
BM25 weight 1: recall@5 = 0.731
BM25 weight 2: recall@5 = 0.81
BM25 weight 4: recall@5 = 0.813

With twice the weight for BM25, the fusion rises from 73.1% to 81.0%; with four times, to 81.3%, still below the 86.6% of BM25 alone. With one retriever clearly better than the other, weighted fusion only pays off if the weak one brings passages the strong one does not find (questions phrased with other words); here that does not happen enough. The weight, moreover, is chosen on one evaluation set and checked on another: choosing it and measuring it on the same 246 questions overestimates the result.

  1. An asset manager wants a search engine over 15 years of research notes (about 2 million passages), with per-team permissions and filters by date and issuer, and already stores everything in PostgreSQL. Which option would you choose and what would you measure first?
Solution

pgvector is the natural candidate: the passages, the permissions and the metadata are already in PostgreSQL and the filters by date, issuer and team are SQL. Before deciding I would measure, with my own set of questions and their reference passages, the recall@k of dense search, of lexical search (PostgreSQL has full-text search) and of hybrid search; the recall of the HNSW index against exact search with the real filters, because a very selective filter after an approximate index can return few results (hence the iterative scans); the latency under the expected load; and the index memory with 2 million vectors of the chosen dimension. Qdrant or a separate FAISS index would make sense if vector search had to scale independently of the database.

Each answer is folded under its question

Self-assessment

  1. With MiniLM, “La empresa eleva su previsión de ventas para el año” (“The company raises its sales forecast for the year”) and “La empresa recorta su previsión de ventas para el año” (“The company cuts its sales forecast for the year”) have a cosine similarity of 0.95, and the first one and its paraphrase “La compañía mejora sus perspectivas de ingresos anuales” (“The company improves its annual revenue outlook”), 0.53. What does it indicate?

    • AThat the model is broken
    • BThat cosine similarity is useless for text
    • CThat, in Spanish, this embedding mostly measures shared words and topic, not the meaning of the sentence: two opposite sentences with the same words end up closer than two equivalent sentences with different words
    • DThat the opposite sentences are synonyms in finance
    Show answer

    Answer C. That, in Spanish, this embedding mostly measures shared words and topic, not the meaning of the sentence: two opposite sentences with the same words end up closer than two equivalent sentences with different words

    all-MiniLM-L6-v2 was trained on English text. In Spanish it does little more than compare word fragments. That is why searching by meaning in Spanish requires a multilingual model or, as in this lesson, combining it with lexical search.

  2. Without filters, dense search retrieves 11.4% of the reference passages in the top 5. For a question about Busmar Hogar in 2023, the top results are nearly identical passages from other issuers. What is the main cause?

    • AThe eight annual reports share a template: many passages differ only by company and year, which do not appear in the passage
    • BThe FAISS index is approximate
    • CThe model has too few dimensions
    • DThe questions are too long
    Show answer

    Answer A. The eight annual reports share a template: many passages differ only by company and year, which do not appear in the passage

    IndexFlatIP is exact. The problem is the content: “Para 2024, el grupo prevé un crecimiento de las ventas…” (“For 2024, the group expects sales growth…”) appears in four reports with other figures. Filtering by entity and year (metadata) raises dense recall@5 to 46.4% and BM25's to 75.2%.

  3. After filtering and cleaning the query, recall@5 is 54.5% with embeddings, 86.6% with BM25 and 73.1% with the RRF fusion of the two. Which conclusion is correct?

    • AHybrid search is always the best
    • BEmbeddings are always worse than BM25
    • CRRF is implemented wrong
    • DFusion with equal weights inherits the errors of the weak retriever; here it is better to use BM25 alone, weight the fusion or switch to an embedding model that understands Spanish, and to decide it with the evaluation set
    Show answer

    Answer D. Fusion with equal weights inherits the errors of the weak retriever; here it is better to use BM25 alone, weight the fusion or switch to an embedding model that understands Spanish, and to decide it with the evaluation set

    RRF averages rankings: if one of the two is clearly worse, the mix ends up between both. With a multilingual embedding or questions that do not share words with the passage, the result could be reversed. The decision comes from measuring recall, not from the fashionable architecture.

  4. With 50,000 vectors, HNSW with efSearch = 128 retrieves 90.4% of the 10 exact neighbors computing about 3,067 distances per query, and IVF with nprobe = 64 retrieves 89.4% with about 12,763. What does an approximate index gain?

    • AMore precision than exact search
    • BSpeed: far fewer distances are computed than the 50,000 of the exact index, at the cost of losing some neighbors; the search parameter controls that trade-off
    • CAlways less memory
    • DNothing with fewer than a million vectors
    Show answer

    Answer B. Speed: far fewer distances are computed than the 50,000 of the exact index, at the cost of losing some neighbors; the search parameter controls that trade-off

    HNSW and IVF store the full vectors (HNSW also stores the graph), so they do not save memory. Product quantization is what saves it: IVF-PQ stores 48 bytes per vector instead of 1,536, with less recall.

  5. When does pgvector make sense compared with FAISS?

    • AWhen the passages and their metadata already live in PostgreSQL and you need to filter with SQL, transactions and database permissions
    • BWhen the fastest possible in-memory search is needed
    • CNever: pgvector only does exact search
    • DOnly with more than a billion vectors
    Show answer

    Answer A. When the passages and their metadata already live in PostgreSQL and you need to filter with SQL, transactions and database permissions

    FAISS is a library: indexes in the process's memory, with no storage or metadata filters of its own. pgvector adds a vector type and HNSW and IVFFlat indexes to PostgreSQL; its documentation warns that with approximate indexes the filter is applied after the index scan, and since version 0.8.0 it offers iterative scans.

Key points

  • A normalized embedding turns a text into a unit-norm vector, and cosine similarity is their inner product. What it captures depends on the model: with all-MiniLM-L6-v2 in Spanish, “eleva” (raises) and “recorta” (cuts) the forecast end up at 0.95.
  • Without filters, dense search retrieves 11.4% of the reference passages in the top 5 and BM25 42.3%: with template documents, the company and the year (which are not in the passage) decide.
  • Metadata is half of a vector database: filtering by entity and year in Chroma and removing the entity from the query brings recall@5 to 54.5% (dense), 86.6% (BM25) and 73.1% (hybrid RRF).
  • Approximate indexes trade recall for cost: with 50,000 vectors, HNSW reaches 90.4% of the exact top 10 with 3,067 distances per query, IVF needs 12,763 for 89.4% and exact search, 50,000. IVF-PQ shrinks each vector from 1,536 to 48 bytes.
  • FAISS is an index library; Chroma, an embedded database with metadata; pgvector brings vectors to PostgreSQL and Qdrant is a dedicated server with payload filters. The choice depends on where the data lives and which filters are needed.

Resources

Data