FAISS: Efficient Vector Similarity Search at Scale
FAISS (Facebook AI Similarity Search) is a C++ library with Python bindings for indexing and searching dense vectors. When your application needs to find the nearest neighbours of an embedding among millions or billions of vectors, FAISS gives you fine-grained control over the speed, memory, and recall trade-offs that a managed vector database hides behind an API. This tutorial focuses on the library itself: its index types, how each one works internally, and how to use them in production.
Who this tutorial is for
This is not another generic "build a semantic search engine" walkthrough. We assume you already understand embeddings and cosine similarity at a high level. Instead we go deep on the FAISS index zoo: exact flat indexes, inverted-file (IVF) indexes, product quantization (PQ), HNSW graphs, ID mapping, persistence, and GPU offload. By the end you will be able to choose the right index for your dataset size and latency budget, and tune it deliberately rather than by guesswork.
What FAISS is and what it is not
FAISS solves one problem extremely well: given a query vector, return the k most similar vectors from a collection, using either L2 (Euclidean) distance or inner product. It is a library, not a service. There is no network layer, no authentication, no metadata filtering engine, and no built-in persistence beyond reading and writing a single index file.
That minimalism is the point. FAISS lets you:
- Decide exactly how vectors are stored (full precision, quantized, on disk).
- Trade recall for speed by changing a single parameter at query time.
- Run the same index on CPU or GPU with a one-line move.
- Embed the index directly inside your own process, avoiding network round-trips.
When to use FAISS vs a managed vector database
Reach for FAISS when:
- You want a single library embedded in your service with no extra infrastructure.
- You need precise control over the index structure and memory footprint.
- Your vectors are mostly static, or you rebuild the index on a schedule.
- You are doing research or batch similarity computation.
Reach for a managed vector database (Qdrant, Milvus, Weaviate, pgvector, Pinecone) when:
- You need rich metadata filtering combined with vector search.
- You require frequent inserts, updates, and deletes with durability guarantees.
- You want horizontal scaling, replication, and an HTTP/gRPC API out of the box.
- Operating a stateful service is acceptable and you would rather not build it yourself.
Many of those databases actually use FAISS-like algorithms (IVF, HNSW, PQ) under the hood, so understanding FAISS makes you better at tuning them too.
Installation
FAISS ships as two mutually exclusive packages. Install exactly one.
# CPU-only build (works everywhere, good default)
pip install faiss-cpu
GPU build (requires a CUDA-capable GPU and matching CUDA runtime)
pip install faiss-gpu
For the examples we also use sentence-transformers to produce embeddings and numpy for array handling.
pip install sentence-transformers numpy
A quick sanity check:
import faiss
import numpy as np
print("FAISS version:", faiss.version)
print("Number of GPUs visible to FAISS:", faiss.getnumgpus())
If getnumgpus() returns 0 you are on the CPU build, which is fine for everything except the GPU section near the end.
Creating embeddings
FAISS works with float32 NumPy arrays of shape (nvectors, dimension). It does not generate embeddings itself; you bring your own. Here we use a small sentence-transformer model.
from sentencetransformers import SentenceTransformer
import numpy as np
model = SentenceTransformer("all-MiniLM-L6-v2") # 384-dimensional output
documents = [
"FAISS performs nearest-neighbour search over dense vectors.",
"Product quantization compresses vectors to save memory.",
"An inverted file index partitions the vector space into cells.",
"HNSW builds a navigable small-world graph for fast search.",
"Cosine similarity is inner product on normalized vectors.",
"GPU indexes can accelerate search by an order of magnitude.",
]
embeddings = model.encode(documents, converttonumpy=True)
embeddings = embeddings.astype("float32") # FAISS requires float32
print(embeddings.shape) # (6, 384)
print(embeddings.dtype) # float32
Two rules to internalize early:
float32 arrays. If you pass float64 it will raise an error.d you pass when constructing an index must match the embedding width exactly.d = embeddings.shape[1] # 384
Exact search: IndexFlatL2 and IndexFlatIP
The flat indexes store every vector verbatim and compare the query against all of them. They give 100% recall (they are exhaustive) but scale linearly with the number of vectors. They are the right choice for small collections and the baseline you measure approximate indexes against.
IndexFlatL2 uses squared Euclidean distance. Smaller distance means more similar.
import faiss
index = faiss.IndexFlatL2(d)
print("Trained?", index.istrained) # True - flat indexes need no training
index.add(embeddings) # store all vectors
print("Vectors in index:", index.ntotal) # 6
Searching returns two arrays: D (distances) and I (indices into the index), each of shape (nqueries, k).
query = model.encode(["how to compress vectors"], converttonumpy=True).astype("float32")
k = 3
D, I = index.search(query, k)
print("Distances:", D[0]) # ascending for L2
print("Indices:", I[0])
for rank, idx in enumerate(I[0]):
print(rank, documents[idx])
IndexFlatIP uses the inner (dot) product instead. Larger value means more similar, so results come back in descending order of score.
indexip = faiss.IndexFlatIP(d)
index
ip.add(embeddings)
D, I = indexip.search(query, k)
print(D[0]) # descending: higher is better
Normalizing vectors for cosine similarity
Cosine similarity equals the inner product of L2-normalized vectors. FAISS has no dedicated cosine index, so you normalize both the database and the query vectors and then use IndexFlatIP. The helper faiss.normalizeL2 does this in place.
import numpy as np
emb = embeddings.copy()
faiss.normalizeL2(emb) # in-place L2 normalization
cosindex = faiss.IndexFlatIP(d)
cosindex.add(emb)
q = query.copy()
faiss.normalizeL2(q) # normalize the query the same way
D, I = cosindex.search(q, k)
print("Cosine scores:", D[0]) # now in [-1, 1], higher is more similar
A common mistake is normalizing the database vectors but forgetting the query, which silently produces wrong rankings. Normalize everything that touches an inner-product index.
The index factory
Constructing complex indexes by hand is verbose. faiss.indexfactory builds them from a short string description, which is also how you would store an index recipe in a config file.
# Equivalent to IndexFlatL2(d)
index = faiss.indexfactory(d, "Flat")
IVF with 100 cells, flat storage, L2 metric
index = faiss.indexfactory(d, "IVF100,Flat")
IVF with PQ compression: 100 cells, 16 sub-quantizers of 8 bits each
index = faiss.indexfactory(d, "IVF100,PQ16")
HNSW with 32 neighbours per node
index = faiss.indexfactory(d, "HNSW32")
Cosine similarity variant: pass the metric argument
index = faiss.indexfactory(d, "Flat", faiss.METRICINNERPRODUCT)
The factory string is the most portable way to describe an index, and we will reference both the factory and the explicit class form below.
Approximate search with IndexIVFFlat
For large collections, exhaustive flat search is too slow. The inverted file index (IVF) partitions the vector space into nlist Voronoi cells using k-means clustering. At query time it visits only the nprobe cells closest to the query instead of all of them. This is the core speed/recall lever in FAISS.
IVF needs a quantizer (a flat index used to assign vectors to cells) and must be trained on a representative sample before you add data.
import numpy as np
Build a larger synthetic dataset to make IVF worthwhile
np.random.seed(42)
n = 100000
xb = np.random.random((n, d)).astype("float32")
xq = np.random.random((5, d)).astype("float32")
nlist = 256 # number of Voronoi cells
quantizer = faiss.IndexFlatL2(d) # assigns vectors to cells
index = faiss.IndexIVFFlat(quantizer, d, nlist, faiss.METRICL2)
print("Trained before training?", index.istrained) # False
index.train(xb) # learn the cell centroids (k-means)
print("Trained after training?", index.istrained) # True
index.add(xb)
print("Vectors:", index.ntotal)
By default IVF probes a single cell, which is fast but loses recall because the true neighbour may sit in an adjacent cell. Raise nprobe to search more cells.
index.nprobe = 1
D, I = index.search(xq, k=5) # fast, lower recall
index.nprobe = 16
D, I = index.search(xq, k=5) # slower, higher recall
Choosing nlist and nprobe
nlist: a common starting heuristic is roughlysqrt(n)to4 sqrt(n). More cells means finer partitioning and faster probing per cell, but each cell holds fewer vectors and you usually need a largernprobeto keep recall up.- Training data: aim for at least
30 nlistto256 nlisttraining vectors so k-means produces stable centroids. FAISS warns if you train on too few. nprobe: the single most important query-time knob.nprobe = 1gives maximum speed and minimum recall;nprobe = nlistdegenerates back to exhaustive search. Sweep it against a ground-truth set and pick the smallest value that meets your recall target.
Measuring recall
Recall@k is the fraction of true top-k neighbours that the approximate index returns. Compute ground truth once with a flat index, then compare.
# Ground truth from exact search
flat = faiss.IndexFlatL2(d)
flat.add(xb)
, gt = flat.search(xq, 5)
def recallatk(approxI, truthI, k=5):
hits = 0
for arow, trow in zip(approxI, truthI):
hits += len(set(arow[:k]) & set(trow[:k]))
return hits / (len(truthI) k)
for nprobe in (1, 4, 16, 64):
index.nprobe = nprobe
, I = index.search(xq, 5)
print(f"nprobe={nprobe:3d} recall@5={recallatk(I, gt):.3f}")
This sweep is the workflow you should run on every dataset: it turns the abstract "speed vs recall" trade-off into concrete numbers for your data.
Memory compression with IndexIVFPQ
Flat storage keeps every vector at full precision: n d 4 bytes. One million 384-dim vectors cost about 1.5 GB. Product quantization (PQ) compresses each vector by splitting it into m sub-vectors and encoding each sub-vector with nbits bits, replacing it with the nearest entry in a learned codebook.
A vector then occupies only m * nbits / 8 bytes regardless of d. With m = 16 and nbits = 8 each vector is 16 bytes, roughly a 24x reduction versus float32 for 384 dimensions.
m = 16 # number of sub-quantizers; d must be divisible by m
nbits = 8 # bits per sub-quantizer code (8 => 256-entry codebooks)
nlist = 256
quantizer = faiss.IndexFlatL2(d)
index = faiss.IndexIVFPQ(quantizer, d, nlist, m, nbits)
index.train(xb) # trains both the IVF centroids and the PQ codebooks
index.add(xb)
index.nprobe = 16
D, I = index.search(xq, 5)
The cost of PQ is approximation error: distances are computed against reconstructed (lossy) vectors, so recall drops compared with IVFFlat at the same nprobe. Constraints and tuning:
dmust be divisible bym. Ford = 384, validmvalues include 8, 12, 16, 24, 32, 48, 96.- Larger
mpreserves more detail (higher recall) but uses more memory and is slower. nbits = 8is by far the most common; it keeps codebook lookups cache-friendly.- For an extra accuracy boost you can re-rank PQ candidates with exact distances using
IndexIVFPQRor anIndexRefineFlatwrapper.
Use IVFPQ when the full-precision index does not fit in RAM. If it does fit, IVFFlat will give you better recall for the same query cost.
Graph-based ANN with IndexHNSWFlat
HNSW (Hierarchical Navigable Small World) builds a multi-layer graph where each vector links to its nearest neighbours. Search starts at the top layer and greedily walks toward the query, descending layers as it converges. HNSW typically delivers the best recall-at-low-latency of the CPU indexes and, unlike IVF, needs no separate training step.
M = 32 # neighbours per node in the base layer
index = faiss.IndexHNSWFlat(d, M) # also: indexfactory(d, "HNSW32")
index.hnsw.efConstruction = 80 # build-time search width (quality of graph)
index.add(xb) # building is the expensive part
index.hnsw.efSearch = 64 # query-time search width (recall vs speed)
D, I = index.search(xq, 5)
The three parameters that matter:
M: number of edges per node. HigherMimproves recall and speeds up search but increases memory (each vector storesMlinks) and build time. Typical values are 16 to 64.efConstruction: how thoroughly the graph is explored while inserting. Higher values build a better graph at the cost of indexing time. It only affects build, not query.efSearch: how many candidates the query explores. This is HNSW's runtime recall knob, analogous to IVF'snprobe. Raise it for better recall, lower it for speed.
HNSW trades memory for quality: it stores the full vectors plus the graph, so it uses more RAM than IVFPQ. Its strengths are no training requirement, excellent recall, and stable latency. Its weaknesses are higher memory use and slow, append-only construction (deletions are awkward).
Mapping back to your own IDs with IndexIDMap
By default FAISS returns sequential positions (0, 1, 2, ...) in the I array. Real systems have their own IDs: database primary keys, document UUIDs, and so on. IndexIDMap wraps any index and lets you supply 64-bit integer IDs with addwithids, which are then returned by search.
base = faiss.IndexFlatL2(d)
index = faiss.IndexIDMap(base)
ids = np.array([1001, 1002, 1003, 1004, 1005, 1006], dtype="int64")
small = embeddings[:6]
index.addwithids(small, ids)
D, I = index.search(query, 3)
print("Your IDs:", I[0]) # e.g. [1002, 1004, 1001]
Two variants exist:
IndexIDMapstores the mapping in a side table. It supportsremoveids, which is the standard way to delete vectors in FAISS.IndexIDMap2additionally supportsreconstruct(id)to retrieve the original vector by its ID.
# Removing vectors by ID
selector = faiss.IDSelectorArray(np.array([1002], dtype="int64"))
index.removeids(selector)
print("After removal:", index.ntotal)
Note that IVF indexes accept IDs through addwithids directly without an explicit wrapper, but IndexIDMap is the general mechanism for the flat and HNSW families.
Saving and loading indexes
FAISS serializes an entire index, including trained centroids and codebooks, to a single file. There is no separate "save the model" step; the index is the model.
# Persist
faiss.writeindex(index, "documents.faiss")
Restore in another process or run
index = faiss.readindex("documents.faiss")
print("Reloaded vectors:", index.ntotal)
The file is self-contained but does not store your raw text or metadata. A practical pattern is to keep the FAISS index alongside a separate store (a SQLite table, a Parquet file, or a JSON sidecar) keyed by the same integer IDs you fed into addwithids.
GPU acceleration
If you installed faiss-gpu, you can move a CPU index to the GPU for a large throughput gain on big batches. Build and train on CPU, then transfer.
res = faiss.StandardGpuResources() # manages GPU memory
cpuindex = faiss.IndexFlatL2(d)
cpuindex.add(xb)
gpuindex = faiss.indexcputogpu(res, 0, cpuindex) # device 0
D, I = gpuindex.search(xq, 5)
Bring results back to CPU for saving (writeindex needs a CPU index)
finalcpu = faiss.indexcputoallgpus # for multi-GPU; see below
backoncpu = faiss.indexgputocpu(gpuindex)
faiss.writeindex(backoncpu, "documents.faiss")
To shard an index across every visible GPU, use indexcputoallgpus.
gpuindex = faiss.indexcputoallgpus(cpuindex)
GPU notes worth remembering:
- Not every index type is GPU-supported;
IndexFlat,IndexIVFFlat, andIndexIVFPQare. HNSW is CPU-only. - The GPU shines on large query batches and large datasets. For a handful of queries the CPU/GPU transfer overhead can dominate.
- You must move a GPU index back to CPU with
indexgputocpubefore callingwriteindex.
End-to-end example: a searchable document index
This section ties everything together into a small, reusable component: embed documents, build an IVFFlat index with explicit IDs, persist it, reload it, and query. We keep document text in a parallel dictionary keyed by the same IDs.
import faiss
import numpy as np
from sentencetransformers import SentenceTransformer
class DocumentIndex:
def init(self, modelname="all-MiniLM-L6-v2", nlist=64):
self.model = SentenceTransformer(modelname)
self.d = self.model.getsentenceembeddingdimension()
self.nlist = nlist
self.index = None
self.docs = {} # id -> original text
def embed(self, texts):
emb = self.model.encode(texts, converttonumpy=True).astype("float32")
faiss.normalizeL2(emb) # cosine similarity via inner product
return emb
def build(self, texts, ids):
emb = self.embed(texts)
quantizer = faiss.IndexFlatIP(self.d)
ivf = faiss.IndexIVFFlat(quantizer, self.d, self.nlist,
faiss.METRICINNERPRODUCT)
ivf.train(emb)
base = faiss.IndexIDMap(ivf)
base.addwithids(emb, np.array(ids, dtype="int64"))
self.index = base
self.docs = {int(i): t for i, t in zip(ids, texts)}
def search(self, query, k=5, nprobe=8):
# nprobe lives on the wrapped IVF index
faiss.extractindexivf(self.index).nprobe = nprobe
q = self.embed([query])
D, I = self.index.search(q, k)
return [
{"id": int(i), "score": float(s), "text": self.docs.get(int(i), "")}
for s, i in zip(D[0], I[0]) if i != -1
]
def save(self, path):
faiss.writeindex(self.index, path)
def load(self, path):
self.index = faiss.readindex(path)
if name == "main":
corpus = [
"FAISS partitions vectors into cells with an inverted file index.",
"Product quantization compresses embeddings to cut memory use.",
"HNSW is a graph-based approximate nearest-neighbour algorithm.",
"Normalize vectors to compute cosine similarity with inner product.",
"Move an index to the GPU with indexcputogpu for throughput.",
"Recall is measured against an exact flat-index ground truth.",
]
ids = list(range(1, len(corpus) + 1))
store = DocumentIndex(nlist=4) # tiny nlist for a tiny demo corpus
store.build(corpus, ids)
store.save("documents.faiss")
for hit in store.search("how do I reduce memory of my vectors", k=3):
print(f"{hit['score']:.3f} {hit['text']}")
The extractindexivf helper reaches through the IndexIDMap wrapper to set nprobe on the underlying IVF index, a detail that trips up many first-time users. For a tiny demo corpus we use a small nlist; on real data follow the sqrt(n) heuristic and tune nprobe with the recall sweep shown earlier.
Comparing index types
The table below summarizes the practical trade-offs. "Recall" assumes parameters tuned reasonably; all approximate indexes can reach high recall if you spend enough query time.
| Index | Search type | Speed | Memory | Recall | Training | Best for |
| --------------- | ------------ | ------------ | ----------- | ----------- | -------- | ----------------------------------------- |
| IndexFlatL2/IP | Exact | Slow (O(n)) | High | 100% | No | Small sets, ground truth, baselines |
| IndexIVFFlat | Approximate | Fast | High | High | Yes | Large sets that fit in RAM |
| IndexIVFPQ | Approximate | Fast | Very low | Medium | Yes | Very large sets, memory-constrained |
| IndexHNSWFlat | Approximate | Very fast | Very high | Very high | No | Low-latency queries, static data |
Rules of thumb by dataset size:
- Under ~10k vectors: use
Flat. Exact search is fast enough and the simplicity is worth it. - 10k to a few million, RAM is plentiful:
IVFFlatorHNSW. HNSW for lowest latency, IVFFlat for simpler tuning and GPU support. - Tens of millions and up, or tight memory:
IVFPQ, optionally with a refine step for accuracy.
Best practices
- Always cast embeddings to contiguous
float32before adding them. Mismatched dtypes are the most common error. - Build a ground-truth set with a flat index and measure recall@k whenever you switch index types or change parameters. Do not tune blind.
- Treat
nprobe(IVF) andefSearch(HNSW) as runtime dials. You can change them per query to balance latency and quality without rebuilding. - Train IVF and PQ on data drawn from the same distribution as the vectors you will add. Training on a different distribution quietly hurts recall.
- Keep your text and metadata in a separate store keyed by the same integer IDs you pass to
addwithids. FAISS stores only vectors. - Use
IndexIDMap2if you need to reconstruct original vectors or delete by ID. - For deletions and frequent updates, remember FAISS is happiest as a mostly-static index. If your workload is write-heavy, a managed vector database may serve you better.
- Pin your FAISS version. Index file formats are stable across minor versions, but verify before upgrading in production.
Conclusion and key takeaways
FAISS is a focused, high-performance library for one job: nearest-neighbour search over dense vectors. Its value is the explicit control it gives you over the speed, memory, and recall triangle.
- Flat indexes (
IndexFlatL2,IndexFlatIP) are exact, simple, and the right baseline. Normalize vectors and use inner product for cosine similarity. - IVF (
IndexIVFFlat) accelerates search by probing only the nearest cells;nlistshapes the partition andnprobetrades speed for recall at query time. - PQ (
IndexIVFPQ) compresses vectors dramatically for memory-bound deployments, at the cost of some recall. - HNSW (
IndexHNSWFlat) gives excellent recall at low latency with no training, in exchange for high memory and append-only construction. IndexIDMapmaps results to your own IDs and enables deletion;writeindex/readindexpersist the entire index;indexcputogpuoffloads search to the GPU.
Choose your index from your dataset size and latency budget, measure recall against an exact baseline, and tune the runtime knobs deliberately. That disciplined loop is what separates a FAISS deployment that scales from one that merely runs.