Vector Index Internals: Exact Search, IVF, HNSW, Product Quantization and Filtered Search with FAISS
Hands-on lab · IDE in your browser

Vector Index Internals: Exact Search, IVF, HNSW, Product Quantization and Filtered Search with FAISS

Measure what each vector index trades for speed on 100,000 product embeddings.

Time
55 min
Checked steps
5
Level
Intermediate
Setup
None
Read step 1

Hands-on labs require Pro · $29.99/mo · cancel anytime

Lab cockpit55 min · 5 stepsSession running
4 / 5 steps passingOnly what is in stock · step 5 of 5
vindex.py▶ Run✓ Check
def post_filter(index, queries, allowed, k=K):    """Search for k, then drop results that are not allowed (a boolean array over product ids). Shown for comparison."""    _, ids = index.search(queries, k)    return [[i for i in row if i >= 0 and allowed[i]] for row in ids]  def filtered_search(index, queries, allowed, k=K, nprobe=None):    """Search only among allowed products: pass an IDSelectorBatch of the allowed ids in faiss.SearchParametersIVF    (with nprobe if given) so the index skips everything else. Returns the ids, (len(queries), k)."""     def exact_filtered(db, queries, allowed, k=K):    """The true top k among allowed products only, as ids of the full catalogue."""   
TerminalOutput

The job

Kittiwake Outfitters' product search compares every query with all 100,000 products. The catalogue is growing, so you measure the indexes that avoid that: how fast they are, how much they miss, how much memory they take, and whether they can show only what is in stock.

5 steps, each checked when you finish it

A check runs your work at the end of every step. Hints and the full solution are there if you get stuck.

  1. 1

    Exact search, and what recall means

    Kittiwake Outfitters' search service holds 100,000 product embeddings, 128 numbers each, unit length.

    You writeconfigure()exact_search()recall_at_k()
  2. 2

    IVF: search a few clusters

    An IVF index clusters the catalogue into nlist lists with k-means.

    You writebuild_ivf()sweep()cheapest()
  3. 3

    HNSW: a graph to walk

    HNSW links every product to about M near neighbours, in layers.

    You writebuild_hnsw()index_mb()
  4. 4

    Compress, then re-rank

    Every index so far stores all 128 float32 numbers per product: 512 bytes.

    You writebuild_ivfpq()rerank()
  5. 5

    Only what is in stock

    The shop shows only products in stock: 4% of the catalogue today.

    You writefiltered_search()exact_filtered()

Step 1 as it appears in the lab

The lab’s own text. The hint and the solution stay inside the lab.

Step 1: Exact search, and what recall means

Kittiwake Outfitters' search service holds 100,000 product embeddings, 128 numbers each, unit length. A query's best matches are the products with the highest dot product. Checking all 100,000 is exact search: always right, and the cost grows with the catalogue. Every faster index is judged by recall@10: of the true ten best products, how many did it return?

One trap first. faiss uses one thread per CPU it can see. Your pod sees 48 and may use one, so 48 threads queue for the same core.

Do this

1. Write configure() in vindex.py: faiss.omp_set_num_threads(1), then return faiss.omp_get_max_threads().

2. Write exact_search(db, queries, k, batch) with numpy, one batch of queries at a time:

  • s = queries[start:start + batch] @ db.T, a (batch, 100,000) score matrix;
  • np.argpartition(-s, k, axis=1)[:, :k] finds the top k in any order; sort those k by score, highest first;
  • return (scores, ids), both stacked over all batches, including the last, shorter one.

3. Write recall_at_k(found, truth): per query, len(set(found_row) & set(truth_row)) / k, leaving out -1 (faiss's "no result"); return the mean as a float.

4. Run. Compare faiss's speed before and after configure(), and your numpy search against faiss's.

vindex.py, the file you edit110 lines
"""Vector search for Kittiwake Outfitters. You write the functions marked TODO, one step at a time."""
import time

import faiss
import numpy as np

K = 10


# ---------- Step 1: exact search, and what "recall" means ----------

def configure():
    """Make faiss use one thread (the pod has one CPU but sees 48) and return faiss.omp_get_max_threads()."""
    # TODO (Step 1): faiss.omp_set_num_threads(1), then return faiss.omp_get_max_threads().
    raise NotImplementedError("Step 1: write configure()")


def exact_search(db, queries, k=K, batch=64):
    """(scores, ids), each (len(queries), k): the k products with the highest dot product per query, best first,
    computed with numpy in batches of `batch` queries (a full queries x products matrix would not fit in memory)."""
    # TODO (Step 1): for each batch of queries: s = batch @ db.T, np.argpartition(-s, k, axis=1)[:, :k],
    # then sort those k by score, descending; stack the batches.
    raise NotImplementedError("Step 1: write exact_search()")


def recall_at_k(found, truth):
    """The mean over queries of |found row & truth row| / k: the share of the true top k that the search returned."""
    # TODO (Step 1): per query, len(set(found without -1) & set(truth)) / k; the mean, as a float.
    raise NotImplementedError("Step 1: write recall_at_k()")


def ms_per_query(index, queries, k=K):
    """Milliseconds per query for index.search(queries, k), the faster of two runs."""
    best = None
    for _ in range(2):
        t = time.perf_counter()
        index.search(queries, k)
        dt = (time.perf_counter() - t) * 1000 / len(queries)
        best = dt if best is None else min(best, dt)
    return best


# ---------- Step 2: IVF, search a few clusters ----------

def build_ivf(db, nlist, train_size=20_000, seed=0):
    """An IndexIVFFlat with inner-product metric and nlist lists, trained on train_size random rows of db
    (np.random.default_rng(seed), without replacement), with every row of db added."""
    raise NotImplementedError("build_ivf() arrives in Step 2")


def set_param(index, name, value):
    """Set a search-time knob: "nprobe" on an IVF index, "efSearch" on an HNSW index."""
    if name == "efSearch":
        index.hnsw.efSearch = value
    else:
        setattr(faiss.extract_index_ivf(index) if name == "nprobe" else index, name, value)


def sweep(index, queries, truth, name, values, k=K):
    """For each value of the knob: {"value", "recall", "ms"} (recall_at_k against truth, ms_per_query)."""
    raise NotImplementedError("sweep() arrives in Step 2")


def cheapest(results, target):
    """The first result (in the order swept) whose recall reaches target, or None."""
    raise NotImplementedError("cheapest() arrives in Step 2")


# ---------- Step 3: HNSW, a graph to walk ----------

def build_hnsw(db, M, ef_construction):
    """An IndexHNSWFlat with inner-product metric, M links per node and efConstruction set, with db added."""
    raise NotImplementedError("build_hnsw() arrives in Step 3")


def index_mb(index):
    """The index's size in megabytes (1e6 bytes), as faiss.serialize_index() writes it."""
    raise NotImplementedError("index_mb() arrives in Step 3")


# ---------- Step 4: compress, then re-rank ----------

def build_ivfpq(db, nlist, m, train_size=20_000, seed=0):
    """An IndexIVFPQ (inner product, nlist lists, m sub-quantizers of 8 bits) trained like build_ivf(), db added."""
    raise NotImplementedError("build_ivfpq() arrives in Step 4")


def rerank(vectors, queries, candidates, k=K):
    """Exact re-scoring: for each query, the k candidate ids (ignore -1) with the highest dot product against the
    full vectors (which may be a memory-mapped array on disk), best first."""
    raise NotImplementedError("rerank() arrives in Step 4")


# ---------- Step 5: only what is in stock ----------

def post_filter(index, queries, allowed, k=K):
    """Search for k, then drop results that are not allowed (a boolean array over product ids). Shown for comparison."""
    _, ids = index.search(queries, k)
    return [[i for i in row if i >= 0 and allowed[i]] for row in ids]


def filtered_search(index, queries, allowed, k=K, nprobe=None):
    """Search only among allowed products: pass an IDSelectorBatch of the allowed ids in faiss.SearchParametersIVF
    (with nprobe if given) so the index skips everything else. Returns the ids, (len(queries), k)."""
    raise NotImplementedError("filtered_search() arrives in Step 5")


def exact_filtered(db, queries, allowed, k=K):
    """The true top k among allowed products only, as ids of the full catalogue."""
    raise NotImplementedError("exact_filtered() arrives in Step 5")
Provided for you:data.pytry_it.py

Frequently asked questions

What is recall@10 for a vector index?

The share of the true ten nearest neighbours, found by exact search, that the index returns. It measures what an approximate index misses in exchange for its speed.

What is the difference between IVF and HNSW?

IVF clusters vectors into lists and scans only the nprobe lists closest to the query. HNSW builds a layered graph of neighbours and walks it, keeping efSearch candidates. HNSW is usually faster at high recall and uses more memory and build time.

Why re-rank product quantization results?

PQ stores each vector as a few bytes, so its scores are approximate and recall drops. Fetching the exact vectors of a larger candidate set and re-scoring them recovers most of the recall at little cost.

Why does post-filtering fail for selective filters?

If only 4% of items qualify, searching for 10 and dropping the rest leaves fewer than one result per query on average. The filter has to be applied inside the search so it keeps looking until it finds k allowed items.

How vector indexes work

Approximate nearest neighbour indexes trade a little recall for a large speed-up. Which trade is right depends on numbers you can measure on your own data. You build exact search as ground truth, then IVF, HNSW and IVF-PQ indexes with FAISS, sweep their search-time parameters for recall@10 and latency, compare memory, re-rank compressed results with exact vectors and run filtered search with an ID selector.