Seven Ways to Do Vector Search in Python
How to Cite This Book
If you use this book in your work, please cite it as:
APA: Chaudhri, A. (2026). Seven Ways to Do Vector Search in Python. Self-published. https://seven-vector-search.github.io/
BibTeX:
@online{chaudhri2026vectorsearch,
author = {Chaudhri, Akmal},
title = {Seven Ways to Do Vector Search in Python},
year = {2026},
url = {https://seven-vector-search.github.io/},
urldate = {2026-07-31}
}
Cover

License
Copyright
Copyright © 2026 Akmal Chaudhri. All rights reserved.
Publication Information
First published: July 2026
The latest version of this book, together with updates, errata and additional resources, is available at:
Book License
Seven Ways to Do Vector Search in Python is licensed under the Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International License (CC BY-NC-ND 4.0).
You are free to copy and redistribute this book in any medium or format under the following conditions:
- Attribution - You must give appropriate credit, provide a link to the license and indicate if changes were made.
- NonCommercial - You may not use the material for commercial purposes.
- NoDerivatives - If you remix, transform or build upon the material, you may not distribute the modified material.
The full license text is available at:
creativecommons.org/licenses/by-nc-nd/4.0
Code License
Unless otherwise stated, all code samples, notebooks, scripts and source files accompanying this book are licensed under the Apache License 2.0.
You are free to use, modify and redistribute this code, including for commercial purposes, subject to the terms of the Apache License 2.0.
The full license text is available at:
apache.org/licenses/LICENSE-2.0
This distinction means that the book’s written content is protected under the Creative Commons license, while the accompanying code remains freely available for use, modification and integration into your own projects.
Trademarks
Product names, company names and logos mentioned in this book may be trademarks or registered trademarks of their respective owners.
Their inclusion is for identification and educational purposes only and does not imply any affiliation with, sponsorship by or endorsement from the respective trademark holders. All trademarks remain the property of their respective owners.
Disclaimer
The information in this book, including all code samples, scripts and notebooks, is provided “as is” without warranty of any kind, express or implied.
The author makes no representations or warranties regarding the accuracy, completeness, reliability or suitability of the information contained herein for any particular purpose.
Examples are provided solely to illustrate technical concepts, patterns and software architectures.
Readers are responsible for independently validating all code, configurations and recommendations before using them in production environments.
To the fullest extent permitted by law, the author shall not be liable for any direct, indirect, incidental, special, consequential or other damages arising from the use of or inability to use, the information, code or techniques described in this book.
About the Author
Akmal Chaudhri is a technical leader, educator and author with extensive experience in databases, AI and developer relations. He specializes in technical writing, developer education and community building, helping engineers and organizations understand and adopt complex technologies through clear, practical and engaging content. He is a frequent international speaker, a published author and a contributor to industry discussions on data platforms, AI and software development.
Today, Akmal works in developer education at Neo4j, where he focuses on technical content, workshops and community initiatives. While his professional role has evolved, this book represents an independent exploration of various libraries.
Based in the United Kingdom, Akmal continues to work at the intersection of databases, AI and developer tooling, helping developers build modern data-driven applications.
For book updates, code samples and additional resources, visit the Book website.
To connect professionally or follow his latest work, visit LinkedIn.
Introduction
Vector search is one of those ideas that sounds complicated until you see it working. You take a piece of text, convert it to a list of numbers and then find other pieces of text whose numbers are similar. That’s it. The Python ecosystem has produced a remarkable variety of tools for doing this efficiently and choosing between them is not always obvious.
This book is a practical guide to seven of those tools. we’ll use the same dataset throughout, ask the same questions of each library and measure the results consistently so you can make informed choices rather than guessing. By the end you’ll know not just how to use each library but why you would reach for one over another given your specific constraints.
What this book is
Each chapter introduces one library, builds an index over a 500,000-record crime incident dataset, runs similarity searches, evaluates recall and latency against a consistent ground truth and discusses honestly what the library is good at and where it falls short. The chapters are designed to be read in order but work as standalone references once you know the context.
This is not a survey of vector search theory. We won’t derive the mathematics of HNSW graphs or explain how product quantization works from first principles. There are excellent papers and textbooks for that. What we’ll offer here is the practitioner’s perspective: what does this library actually do when you run it, what are the gotchas and when should you use something else?
Who this book is for
This book is for Python developers who need vector search to work and want to understand their options before committing to one. You might be building a RAG pipeline and wondering whether you need a database or whether a library will do. You might be hitting performance limits with your current approach and wondering if there is a faster option. You might simply be curious about what the ecosystem looks like right now.
We’ll assume comfort with Python and pandas. We’ll use Jupyter notebooks throughout.
The companion book
This book sits alongside Seven Vector Databases in Seven Days, which covers hosted and embedded vector databases: PostgreSQL with pgvector, MongoDB Atlas, Pinecone, Weaviate, Neo4j, Snowflake and Databricks. The two books address different questions. The databases book asks: what infrastructure should I run? This book asks: what Python library should I call?
If your dataset fits in memory and you want to stay in process, this book is your starting point. If you need a persistent, queryable service with replication and access control, the databases book is where to look next. Many production systems use both: a library for rapid prototyping and offline analysis, a database for the serving layer.
Nordvik
Every chapter in this book uses the same dataset: 500,000 synthetic crime incident reports set in Nordvik, a fictional city on Bouvet Island. Bouvet Island is a Norwegian dependency in the South Atlantic and holds the distinction of being one of the most remote uninhabited islands in the world. Its coordinates are real and spatially coherent, which means our maps and geographic queries work correctly, but nothing we’ll generate implicates any real street or person.
The dataset was generated using a template engine rather than a language model. Each record has a structured set of fields - crime type, suspect description, entry method, property stolen, weapon, neighborhood and timestamp - and a free-text MO_TEXT field that reads like an officer’s incident report. We chose this approach because it’s fast, reproducible and requires no external API. The trade-off is that the MO_TEXT has more template structure than real police data would, which affects how the embedding model clusters results. We’ll note this where it matters.
How to use this book
The notebooks are on GitHub alongside the book. To follow along you’ll need Python 3.12 or later, Jupyter and a machine with at least 16GB of RAM. Each chapter installs its own dependencies via a pip cell at the top.
Run generate_dataset.ipynb first to create the Nordvik dataset, then generate_embeddings.ipynb to compute and save the embeddings. Every subsequent chapter loads the same two files from disk. You only need to run the generators once.
If you want to skip straight to a specific library, each chapter notebook is self-contained once the data files exist.
The Dataset
Before we can evaluate any vector search library we need something to search. This chapter covers how we built the Nordvik crime dataset and why we made the choices we did.
Why synthetic data
Real crime data exists and some of it’s publicly available. We looked at several sources including British Transport Police (BTP) incident records and Dallas Police Department reports. The BTP data lacked free-text narrative fields, which are essential for meaningful semantic search. The Dallas data was richer but comes with licensing friction that would make it awkward for readers to reproduce the experiments.
Generating synthetic data solves both problems. We control the schema, the volume and the distribution. Readers can reproduce everything without requesting access to anything. And we can tune the data to make the vector search experiments as informative as possible.
The trade-off is realism. Synthetic narratives generated from templates have more structural regularity than real officer-written reports. This affects how the embedding model clusters results - we see tighter clusters around template patterns than we would with genuine linguistic variety. We note this in the chapters where it matters most.
Bouvet Island
The coordinates in the dataset are real. We use Bouvet Island as the geographic anchor for Nordvik, our fictional city. Bouvet Island is a Norwegian dependency in the South Atlantic, roughly 49 square kilometers in area and holds the distinction of being one of the most remote uninhabited islands in the world. If you drop any coordinate from our dataset into a mapping tool it will resolve to a glacier, not a street.
We define 15 fictional neighborhoods across the island’s footprint and assign each a centroid coordinate. Individual incidents are placed within 300 meters of their neighborhood centroid using a small random offset. The result is a spatial distribution that looks like a real city and supports meaningful geographic queries and hotspot analysis.
The MO_TEXT field
The central field for vector search is MO_TEXT - a free-text officer narrative describing how each crime was committed. A typical entry looks like this:
At approx 1632hrs, Multiple offenders described as medium build, tall, wearing face covering gained entry to Residence - Single Family in Hartley Cross. Property taken: handbag, tablet. No FO identified.
We generate these using a template engine with 30 templates per crime type, each drawing from randomized structured fields. The templates vary in structure - some lead with a timestamp, some with the location, some with the suspect description, some with police shorthand. We also vary the phrasing of individual components: Property taken: in one record becomes Stolen: or Items removed include in another.
The result is varied enough for the embedding model to produce meaningful similarity scores but structured enough that readers can see immediately whether a search result makes sense.
Crime types and distribution
The dataset contains six crime types weighted to approximate a realistic urban distribution:
| Crime type | Share |
|---|---|
| Burglary | 35% |
| Vehicle Crime | 25% |
| Robbery | 20% |
| Assault | 10% |
| Drug Offences | 7% |
| Other | 3% |
Each crime type has its own set of subtypes, entry methods, property types and property stolen values. Burglary incidents are constrained to residential or commercial properties depending on subtype. Vehicle crime incidents use vehicle-specific entry methods. The structured fields are internally consistent even though the data are synthetic.
Generating the dataset
The generation notebook generate_dataset.ipynb produces two output files:
nordvik_crimes.csv- the full dataset in CSV formatnordvik_crimes.parquet- the same data in Parquet format for faster loading
At 500,000 records the generation runs in seconds. There is no LLM involved, no API call and no external dependency beyond pandas. The random seed is fixed at 42 throughout so results are fully reproducible.
Computing embeddings
Once the dataset exists we run generate_embeddings.ipynb to embed every MO_TEXT record using all-MiniLM-L6-v2 from the sentence-transformers library. This model produces 384-dimensional embeddings and is a standard baseline for semantic search benchmarks, which makes it the right choice for a book that compares libraries rather than models.
Embedding 500,000 records at batch size 256 takes roughly 8 to 9 minutes on a MacBook Air M4. The output is saved as:
nordvik_embeddings.npy- float32 array of shape (500000, 384), 732MBnordvik_ids.txt- IncidentID strings in matching row order
We normalize embeddings to unit length before saving. This means L2 distance and cosine similarity are equivalent throughout the book and we can compare results across libraries that use different default metrics without worrying about systematic differences.
Ground truth
Every chapter measures recall against a consistent ground truth computed in Day 1 using FAISS’s exact IndexFlatL2. Rather than computing ground truth for all 500,000 records - which would be expensive - we sample 1,000 queries at random and store their exact top-10 neighbors. This ground truth sample is saved as:
nordvik_ground_truth.npy- shape (1000, 10), exact neighbor indicesnordvik_ground_truth_indices.npy- the 1,000 sampled query positions
Every chapter loads these files and uses them to compute recall@10. One caveat applies throughout the book: because the ground truth was computed using L2 distance and most libraries use cosine distance, floating point differences in implementation produce slightly different result orderings at the margin. This causes recall to appear capped around 0.89 to 0.90 even for libraries that should theoretically achieve higher. We’ll explain this in each chapter where it appears.
A note on the sanity check
The embeddings notebook includes a sanity check that compares cosine similarity between two burglary incidents against cosine similarity between a burglary and an assault. The result is counterintuitive: the two burglaries score 0.41 while the burglary and assault score 0.49.
This happens because the two burglary incidents use very different templates and different neighborhoods. The assault happens to share structural phrasing with the first burglary. It’s a reminder that embedding similarity reflects linguistic similarity, not categorical similarity. A search for “incidents like this burglary” will find other incidents that are described in similar language - which may or may not be the same crime type. We’ll return to this observation in Day 1.
Day 1: FAISS
FAISS (Facebook AI Similarity Search) is Meta’s open-source library for efficient similarity search and clustering of dense vectors. It’s the natural starting point for this book because it exposes the full index selection journey more clearly than any other library. Rather than hiding the tradeoff between speed and accuracy behind a single API call, FAISS asks you to choose an index type explicitly. That choice - flat, IVF or HNSW - is the central lesson of this chapter.
When to reach for FAISS
FAISS is the right choice when:
- You need exact or approximate nearest neighbor search in Python with minimal dependencies
- Your dataset fits in RAM and you want fast, well-benchmarked performance
- You want control over the index type and its parameters
- You are prototyping and want a library that scales from 10k to 100M vectors without changing your API calls
The three index types
FAISS offers many index types but three cover the vast majority of use cases.
IndexFlatL2 stores vectors as-is and computes exact L2 distances against every vector in the index at query time. There is no training step, no approximation and no parameters to tune. It’s the ground truth against which every other index is measured. The cost is linear scaling - query time grows proportionally with the number of vectors.
IndexIVFFlat partitions the vector space into Voronoi cells using k-means clustering. At query time it searches only the nearest cells rather than the entire index. Two parameters control the tradeoff: nlist (the number of cells) and nprobe (the number of cells searched at query time). Higher nprobe improves recall at the cost of speed. Unlike IndexFlatL2, an IVF index must be trained before adding vectors.
IndexHNSWFlat builds a multi-layer graph of vectors where each node connects to its nearest neighbors. Queries traverse the graph from the top layer down, narrowing the search at each level. This produces excellent recall at low latency with no training step required. The key parameter is M - the number of connections per node.
What we found
We ran all three index types against the full 500,000-record Nordvik dataset. The results were clear.
The flat index is exact but slow. At 500,000 records it takes around 14ms per query averaged over 50 runs. That is fast enough for batch processing but too slow for interactive applications at scale.
The IVF index showed the most interesting behavior. At nprobe=1 recall dropped to 0.40 - the only setting in this chapter that genuinely loses results. This is the approximation tradeoff in practice: searching just one Voronoi cell misses 60% of the true nearest neighbors. At nprobe=10 recall recovered to 1.0 at 0.23ms - 60 times faster than the flat index with no accuracy loss.
The HNSW index was the standout performer. At efSearch=64 it achieved recall 1.0 at 0.06ms - 230 times faster than the flat index. No training required, no recall degradation, just a larger memory footprint than IVF.
The scaling chart tells the most important story: flat index latency grows linearly from near zero at 10,000 records to 17ms at 500,000, while IVF and HNSW stay essentially flat throughout. At 10 million records the flat index would be unusable for interactive queries; the approximate indexes would barely notice.
| Index | Recall@10 | Latency (ms) |
|---|---|---|
| IndexFlatL2 | 1.00 | 14.00 |
| IndexIVFFlat (nprobe=1) | 0.40 | 0.11 |
| IndexIVFFlat (nprobe=10) | 1.00 | 0.23 |
| IndexHNSWFlat (efSearch=64) | 1.00 | 0.06 |
A note on the query results
All ten results for our sample burglary query came from the same neighborhood using the same template structure. Every result was some variation of “Residence - Single Family in Hartley Cross targeted.” This is the embedding model doing exactly what it was designed to do: finding linguistic similarity. In a dataset generated from templates, linguistic similarity and template similarity are almost the same thing.
This is worth keeping in mind throughout the book. Embedding-based search finds incidents described in similar language. In production with officer-written narratives - which have far more linguistic variety - the clusters would be semantically richer. Our synthetic data is honest about this limitation.
What FAISS does not do
FAISS is a library, not a service. It has no built-in persistence - you must serialize indexes yourself using faiss.write_index and faiss.read_index. There is no server, no authentication and no metadata filtering. If you need to filter by crime type or neighborhood before running vector search, you need to implement that yourself or move to Chroma (Day 6) or LanceDB (Day 7).
The IVF index requires training data representative of your full dataset. If your data distribution shifts over time the index will degrade and need retraining. HNSW does not require training but consumes significantly more memory than IVF at the same dataset size.
When to look elsewhere
FAISS is the wrong choice if:
- You need metadata filtering alongside vector search
- You need a persistent queryable store - look at Chroma (Day 6)
- Your dataset does not fit in RAM - look at LanceDB (Day 7)
- You want automatic parameter tuning - other libraries handle this more gracefully
Day 2: Voyager
Voyager is Spotify’s open-source approximate nearest neighbor search library, built and used in production to power music and podcast recommendations across hundreds of millions of users. It uses the HNSW algorithm - the same one we saw in Day 1’s IndexHNSWFlat - but wraps it in a cleaner API with better defaults, built-in persistence and lower memory usage by default.
Spotify describes Voyager as their answer to the limitations they found with Annoy, their original recommendation library. The comparison with Annoy would have been a natural chapter pairing, but Annoy proved to be broken on Apple Silicon - it consistently returned only one neighbor regardless of K, which we confirmed with a minimal test on 1,000 random vectors. We dropped it from the book. Voyager, by contrast, installed and ran without issue.
When to reach for Voyager
Voyager is the right choice when:
- You want HNSW search with a simpler API than FAISS and sensible defaults out of the box
- You need built-in index persistence without writing serialization code yourself
- You are building a Python or Java application and want index compatibility between both languages
- You want a library that is battle-tested in production at massive scale
What we found
We built two Voyager indexes on the 500,000-record Nordvik dataset: one with Spotify’s default of M=12 and one with M=32 to match the Day 1 FAISS configuration.
The M=12 results were surprising. Recall plateaued at around 0.89 regardless of how high we pushed ef - even at ef=200 we could not get above 0.888. In Day 1, FAISS HNSW with M=32 achieved recall 1.0. The natural conclusion is that M=12 caps the recall ceiling and that increasing ef alone cannot overcome a sparse graph.
So we rebuilt with M=32. The results were more surprising: recall barely moved. M=32 topped out at 0.896 compared to M=12’s 0.888 - a difference of less than one percentage point despite the index taking 50 seconds to build rather than a few seconds.
The explanation is not a Voyager limitation. It’s a measurement artifact. Our ground truth was computed with FAISS using L2 distance, while Voyager uses cosine distance. On normalized vectors these metrics are mathematically equivalent in theory, but floating point differences in implementation produce slightly different result orderings at the margin. The apparent recall ceiling of 0.89 is the size of that gap, not a genuine failure to find the right neighbors. Against a same-metric ground truth, Voyager at M=32 would match FAISS recall closely.
| Config | Recall@10 | Latency (ms) |
|---|---|---|
| Voyager M=12, ef=10 | 0.858 | 0.046 |
| Voyager M=12, ef=200 | 0.888 | 0.295 |
| Voyager M=32, ef=10 | 0.893 | 0.077 |
| Voyager M=32, ef=200 | 0.896 | 0.439 |
| FAISS IndexHNSWFlat (reference) | 1.000 | 0.060 |
Persistence
One of Voyager’s practical advantages over FAISS is built-in index persistence. Where FAISS requires faiss.write_index and careful file management, Voyager handles this with a single index.save() call. The index file for 500,000 vectors at M=12 came out at 788MB. Loading back from disk with Index.load() is equally straightforward and produces identical results.
Dynamic insertion
Voyager supports adding new vectors to an existing index without rebuilding it. We demonstrated this by adding 1,000 new vectors to the built index - they were immediately queryable with no rebuild required. This is a meaningful advantage in production systems where new data arrives continuously.
What Voyager does not do
Voyager is an in-memory library. The entire index must fit in RAM. At 788MB for 500,000 vectors at M=12 and scaling linearly from there, this becomes a constraint at tens of millions of records on typical server hardware.
Like FAISS, Voyager has no metadata filtering. If you need to constrain search results by crime type or neighborhood, you must filter the results after retrieval or move to Chroma (Day 6) or LanceDB (Day 7).
When to look elsewhere
Voyager is the wrong choice if:
- You need exact search - Voyager is approximate only
- You need metadata filtering - look at Chroma (Day 6) or LanceDB (Day 7)
- Your dataset does not fit in RAM - look at LanceDB (Day 7)
- You want a purely static index with the smallest possible memory footprint and do not need dynamic insertion
Day 3: Scikit-learn NearestNeighbors
Scikit-learn’s NearestNeighbors is the vector search library you already have. If you have scikit-learn installed - and most Python data science and machine learning projects do - you have a capable nearest neighbor search implementation ready to use without any additional dependencies.
Unlike FAISS and Voyager which implement HNSW graph-based search, scikit-learn offers three distinct algorithms: brute force, ball tree and kd-tree. Each has different characteristics and the library selects the most appropriate one automatically based on your data if you ask it to. This makes scikit-learn a useful starting point for understanding what is actually happening inside a nearest neighbor search before reaching for a specialized library.
When to reach for scikit-learn
Scikit-learn NearestNeighbors is the right choice when:
- You are already using scikit-learn and want to avoid adding dependencies
- Your dataset is small enough that brute force is fast enough - typically under 50,000 vectors at 384 dimensions
- You want exact results rather than approximate and FAISS feels like overkill
- You are teaching or learning nearest neighbor search and want to experiment with different algorithms side by side
A practical note before we start
Scikit-learn’s cosine distance computation has numerical issues with float32 embeddings at high dimensionality - you’ll see divide by zero and overflow warnings from the underlying matmul. The fix is to cast embeddings to float64 before fitting. All timing loops also need to be wrapped in warnings.catch_warnings() to suppress the noise. We encountered this during development and the notebook handles it, but it’s worth knowing about if you adapt the code.
What we found
We evaluated all three algorithms on a 50,000-record subset of the Nordvik dataset. Using the full 500,000 records was not practical - even brute force search becomes prohibitively slow at that scale for a library that is not designed for it.
The results confirmed what the theory predicts. Ball tree and kd-tree offered no meaningful advantage over brute force at 384 dimensions. All three algorithms took around 25 to 27ms per query - essentially identical. This is the curse of dimensionality in action: tree-based algorithms that are fast in low dimensions (under 20) degrade toward O(n) query time as dimensionality increases. At 384 dimensions they are just a more complicated way to do the same linear scan.
| Algorithm | Recall@10 | Latency (ms) | Dataset size |
|---|---|---|---|
| Brute force (cosine) | 0.158 | 17.53 | 50k |
| Ball tree (euclidean) | 0.158 | 25.84 | 50k |
| FAISS IndexHNSWFlat (reference) | 1.00 | 0.06 | 500k |
The recall figure of 0.158 looks alarming but is explained by the same distance metric mismatch we discussed in the dataset chapter. The ground truth was computed with FAISS using L2 distance; scikit-learn uses cosine distance. The two algorithms agree with each other perfectly - both return exactly the same results - which confirms scikit-learn is internally consistent. The discrepancy is with the FAISS ground truth, not within scikit-learn itself.
The scaling numbers are the most telling result. Brute force latency is essentially flat from 10,000 to 25,000 records - the M4’s parallelization via n_jobs=-1 saturates around there - then grows to 30ms at 100,000 records. Meanwhile FAISS HNSW at 500,000 records takes 0.06ms. That is a 450x difference at ten times the data volume.
What scikit-learn does not do
Scikit-learn’s NearestNeighbors has no index persistence, no approximate search and no GPU support. Its brute force implementation is well-optimized with multi-core support via n_jobs=-1, but O(n) scaling makes it impractical above roughly 50,000 vectors at 384 dimensions for latency-sensitive applications.
There is also no metadata filtering, no document storage and no path from scikit-learn to a production serving layer. It’s a research and development tool, not a deployment target.
The honest conclusion
Scikit-learn NearestNeighbors is the right answer to a specific question: “I need nearest neighbor search, I already have scikit-learn and my dataset is small.” For anything larger or more demanding, the libraries in the remaining chapters are the better choice. The value of this chapter is not in the performance numbers - which are unimpressive - but in seeing clearly where scikit-learn’s limits are and why they exist.
When to look elsewhere
Scikit-learn is the wrong choice if:
- Your dataset has more than 50,000 vectors at high dimensionality - latency will be too high for interactive use
- You need approximate search to trade recall for speed - scikit-learn only does exact search
- You need index persistence - scikit-learn has no built-in save and load for neighbor indexes
- You need the lowest possible latency - every other library in this book is faster at scale
Day 4: PyNNDescent
PyNNDescent is a pure Python implementation of the Nearest Neighbor Descent algorithm, developed by Leland McInnes at the Tutte Institute for Mathematics and Computing. If you’ve used UMAP - one of the most widely used dimensionality reduction libraries in data science - you’ve already used PyNNDescent. It’s the nearest neighbor engine that powers UMAP under the hood.
Unlike FAISS and Voyager which build HNSW graphs, PyNNDescent builds a k-neighbor graph by iterative refinement. It starts from a random graph and repeatedly improves it by checking whether neighbors of neighbors are better neighbors. This descent process converges to a high-quality approximate graph without requiring the careful parameter tuning that HNSW demands.
When to reach for PyNNDescent
PyNNDescent is the right choice when:
- You want high recall approximate nearest neighbor search with minimal parameter tuning
- You are already using UMAP and want consistency between your dimensionality reduction and search pipelines
- You need a pure Python library with no C++ compilation requirements
- You want scikit-learn compatibility via
PyNNDescentTransformeras a drop-in replacement
What we found
PyNNDescent produced the most surprising result of the book. At n_neighbors=10 it achieved a query latency of 0.043ms - faster than FAISS HNSW at 0.06ms. A pure Python library outperforming a C++ library with GPU support is not what you would expect and it’s worth understanding why.
The answer is the JIT compilation step. PyNNDescent uses Numba to compile its search function on first use. After that warmup - which we call explicitly in the notebook before timing - subsequent queries run compiled native code. The 0.043ms figure reflects post-warmup performance. If cold-start latency matters in your deployment, factor in several seconds for the first query.
Recall at n_neighbors=10 was 0.884, rising to 0.900 at n_neighbors=50. The plateau is familiar from Days 2 and 3 - it’s the distance metric mismatch with the FAISS L2 ground truth rather than a genuine ceiling. Build time ranged from 5.4 seconds at n_neighbors=10 to 28.6 seconds at n_neighbors=50, which is notably faster than Voyager’s 50 seconds for an equivalent M=32 index.
| Config | Recall@10 | Latency (ms) | Build time |
|---|---|---|---|
| n_neighbors=10 | 0.884 | 0.043 | 5.4s |
| n_neighbors=20 | 0.898 | 0.026 | 8.9s |
| n_neighbors=30 | 0.900 | 0.030 | 14.2s |
| n_neighbors=50 | 0.900 | 0.051 | 28.6s |
Query result quality
The query results showed something encouraging that Day 1 did not. Where Day 1 returned ten results from the same template and the same neighborhood - all variations on “Residence - Single Family in Hartley Cross targeted” - PyNNDescent returned results from different neighborhoods and different template structures. The top nine were burglaries as expected, but they came from Hartley Cross, Millfield and other neighborhoods, using a variety of template openings.
Rank 10 was occasionally an Other crime type at very close distance. This is not an error - it reflects a genuine linguistic similarity between certain crime descriptions that crosses the categorical boundary. A forced entry incident described in similar language to a burglary will score high cosine similarity regardless of how it was categorized.
The scikit-learn integration
PyNNDescent provides a PyNNDescentTransformer that’s a drop-in replacement for scikit-learn’s KNeighborsTransformer. This means we can swap PyNNDescent into any scikit-learn pipeline that uses nearest neighbors - including UMAP, t-SNE wrappers and graph-based clustering - without changing our pipeline code. We demonstrated this on a 5,000-record subset, producing a sparse graph matrix that could be passed directly to UMAP or any sklearn graph estimator.
What PyNNDescent does not do
PyNNDescent does not support dynamic insertion. Like Annoy, any new data requires a full rebuild. The index can be pickled to disk with Python’s standard pickle module, which is simpler than FAISS’s write_index but produces larger files.
Build time at 500,000 vectors is longer than FAISS for equivalent quality, though faster than Voyager. For datasets that grow continuously, the rebuild cost needs to be factored into operational planning.
When to look elsewhere
PyNNDescent is the wrong choice if:
- You need the absolute lowest query latency after cold start - the JIT warmup adds several seconds to the first query
- Your dataset changes frequently - there is no dynamic insertion; use Voyager or FAISS instead
- You need metadata filtering - look at Chroma (Day 6) or LanceDB (Day 7)
- You need a language-agnostic library - PyNNDescent is Python only
Day 5: USearch
USearch is a fast, compact vector search library developed by Unum Cloud. Like FAISS, it implements the HNSW algorithm - but that’s where the similarity ends. USearch is designed around a different set of priorities: a minimal binary footprint, SIMD-optimized distance computations, support for user-defined metrics and compatibility across a wide range of platforms including iOS, Android and WebAssembly.
Where FAISS is a research-oriented library with a large C++ codebase, USearch is engineered for deployment. The entire library fits in a single header file. Its documentation claims exact brute-force search up to 20 times faster than FAISS’s IndexFlatL2 on the same hardware, thanks to SIMD-optimized similarity kernels. We’ll put that claim to the test.
When to reach for USearch
USearch is the right choice when:
- You need the smallest possible binary footprint - important for mobile, edge or embedded deployments
- You want SIMD-accelerated exact search on Intel or AMD hardware
- You need custom distance metrics - USearch supports user-defined Python functions via Numba
- You want a library that works identically across Python, JavaScript, Rust, Java and C without a separate server
What we found
We built a USearch HNSW index with connectivity=32 and expansion_add=200 on the full 500,000-record dataset. Build time was 77 seconds - slower than both FAISS and PyNNDescent for equivalent quality. The index file came out at 864MB, larger than Voyager’s 788MB at the same connectivity setting.
HNSW query recall settled at 0.897 across all expansion_search settings above 16, consistent with the distance metric ceiling we’ve seen throughout the book.
| Config | Recall@10 | Latency (ms) |
|---|---|---|
| HNSW conn=32, ef=16 | 0.897 | 0.143 |
| HNSW conn=32, ef=64 | 0.897 | 0.260 |
| HNSW conn=32, ef=256 | 0.900 | 0.887 |
| Exact search | 1.000 | 77.3 |
The exact search result
The most significant finding in this chapter is what happened when we enabled exact=True. USearch’s exact brute-force mode is supposed to be dramatically faster than FAISS’s IndexFlatL2 on modern hardware. On our MacBook Air M4 it was 5 times slower - 77ms compared to FAISS’s 14ms.
This is not a USearch bug. It’s a platform mismatch. USearch’s SIMD optimizations target Intel AVX-512 instructions, which are available on Intel Sapphire Rapids and similar x86 server processors. Apple Silicon uses ARM NEON and SVE, a different SIMD architecture, for which USearch does not have the same level of optimization. The 20x speedup claim is real on the hardware it was designed for. On an M4 Mac the picture is reversed.
This is a useful lesson about benchmarks in general: performance claims from library documentation are measured on specific hardware. Always benchmark on your target platform before committing to a library based on headline numbers.
Custom metrics
One of USearch’s most distinctive features is support for user-defined distance metrics via Numba. You write a Python function decorated with @numba.cfunc, compile it to a CompiledMetric object and pass it to the Index constructor. USearch uses it for both index construction and search.
We demonstrated a weighted cosine metric that upweights the first 64 dimensions of each vector - a toy example of the kind of domain-specific tuning this enables. The custom metric index built in 4.4 seconds on 50,000 vectors and produced different top results than the standard cosine index, confirming that the metric was applied.
Note that the API for custom metrics changed in USearch 2.26.0. CompiledMetric and MetricSignature moved from usearch.compiled to usearch.index. If you’re adapting older examples you’ll need to update your imports.
Persistence
USearch index persistence uses the same save and load pattern as Voyager. index.save(path) writes the index to disk; loading requires creating an empty Index with matching parameters first, then calling index.load(path) on the instance. This changed between versions - earlier examples showing Index.load(path) as a class method will fail in 2.26.0.
What USearch does not do
USearch has no metadata filtering and no document storage. Like FAISS and Voyager, it’s a pure vector index. It also has no dynamic insertion in the same sense as Voyager - you can add vectors but the process is less fluid than Voyager’s API suggests.
The exact search advantage is hardware-specific. On Apple Silicon it offers no performance benefit over FAISS flat search.
When to look elsewhere
USearch is the wrong choice if:
- You are on Apple Silicon and expect the exact search speedup - it will not materialize
- You need metadata filtering - look at Chroma (Day 6) or LanceDB (Day 7)
- Your dataset does not fit in RAM - look at LanceDB (Day 7)
- You want automatic parameter tuning - USearch requires the same manual tuning as FAISS HNSW
Day 6: Chroma
Chroma is an open-source AI-native vector database designed for building LLM applications. Unlike every library we have used so far, Chroma is not just a search index - it’s a persistent, queryable store with built-in metadata filtering, document storage and collection management. It’s the bridge between a vector search library and a full vector database.
Chroma runs embedded in your Python process with no server required, which is what makes it relevant in a book about Python vector search libraries. In embedded mode it feels like a library - you import it, create a collection and query it. But underneath it manages persistence, indexing and metadata automatically in a way that none of the previous libraries do.
When to reach for Chroma
Chroma is the right choice when:
- You are building a RAG pipeline and need a persistent store that survives process restarts
- You need metadata filtering alongside vector search - filter by crime type, neighborhood or date before or during search
- You want to store documents and embeddings together rather than managing them separately
- You want a simple local development experience that scales to a hosted service without code changes
What we found
We loaded all 500,000 Nordvik records into Chroma in batches of 5,000, storing the MO_TEXT as the document and including crime type, crime subtype, neighborhood, datetime and weapon as metadata fields. The collection persisted automatically to disk - no explicit save call required.
Basic vector search produced recall of 0.899 at 0.776ms. That is consistent recall-wise with the other approximate libraries but about ten times slower than PyNNDescent for pure vector search. The overhead comes from Chroma’s persistence and metadata layers, which do real work on every query.
| Config | Recall@10 | Latency (ms) | Metadata filtering |
|---|---|---|---|
| Chroma (cosine, embedded) | 0.899 | 0.776 | Yes |
| FAISS IndexHNSWFlat (reference) | 1.000 | 0.060 | No |
| PyNNDescent (reference) | 0.900 | 0.043 | No |
Metadata filtering
This is where Chroma earns its place. None of the previous five libraries can do what we demonstrate in this chapter. We ran three filtered queries against the same burglary incident:
Burglary only. Restricting results to CrimeType = "Burglary" returned ten burglaries from the Hartley Cross area. Without the filter, rank 10 was occasionally a different crime type at similar distance. The filter enforces the constraint directly in the search rather than requiring post-processing.
Armed incidents only. Filtering to Weapon != "none" returned results across multiple crime types - burglaries, robberies and one Other incident - all carrying weapons. The filter crossed categorical boundaries correctly, finding semantically similar incidents that shared the weapon characteristic regardless of crime type.
Millfield only. Restricting to a single neighborhood produced distances of 0.34 and above, compared to 0.14 and above for the unfiltered results. This is expected and informative: when you constrain the candidate set geographically, the closest semantic matches within that area are necessarily further from the query than the global nearest neighbors. The filter does not degrade the search - it answers a different question.
A simple RAG pipeline
We built a minimal retrieval function that fetches the five most similar incidents to a query, with an optional crime type filter. This is the pattern that most LLM applications use in production: embed the query, retrieve relevant context, pass it to the language model. Chroma’s document storage means the retrieved context is immediately available without a second lookup against the original dataset.
The function takes around 1ms to retrieve five filtered results - fast enough for interactive use in most applications.
Persistence
Chroma’s PersistentClient writes to a local directory automatically. We verified this by creating a fresh client pointing to the same path and confirming the collection loaded correctly with all 500,000 records intact. Query results were identical to the original session. For a production application this means you build the index once and serve from it across restarts without any additional code.
A note on the API
In Chroma 1.5.9, "ids" is not a valid value for the include parameter in collection.query(). IDs are always returned by default and do not need to be requested explicitly. Passing include=["ids"] raises a ValueError. Use include=[] to return only IDs or include=["documents", "metadatas", "distances"] for the full result set.
What Chroma does not do
Chroma in embedded mode is a single-process store. It’s not designed for concurrent writes from multiple processes. If you need that, Chroma offers a client-server mode with chromadb.HttpClient that requires no changes to your query logic - only to the client initialization.
Chroma’s HNSW implementation is solid but not the fastest available. For pure vector search without filtering, PyNNDescent and FAISS are significantly faster. The metadata filtering and document storage are what justify the additional overhead. If you do not need those features, an earlier library in this book will serve you faster.
When to look elsewhere
Chroma is the wrong choice if:
- You need the lowest possible query latency - FAISS and PyNNDescent are faster for pure vector search
- Your dataset is larger than available RAM - Chroma loads indexes into memory; look at LanceDB (Day 7)
- You need concurrent writes from multiple processes in embedded mode - use client-server mode instead
- You need SQL-style joins between your vector store and relational data - LanceDB (Day 7) has better structured data support
Day 7: LanceDB
LanceDB is an open-source vector database built on Lance, a columnar storage format designed for AI workloads. It’s the final chapter of this book for a deliberate reason: LanceDB sits furthest along the spectrum from pure library to full database and it solves a problem none of the previous six tools address - what happens when your dataset does not fit in RAM?
Every library and database we’ve used so far loads indexes and vectors into memory. LanceDB stores data on disk in the Lance columnar format and queries it without loading everything into RAM. This makes it the natural choice for datasets that are too large for available memory, while still supporting vector search, metadata filtering and SQL-style queries.
When to reach for LanceDB
LanceDB is the right choice when:
- Your dataset does not fit in RAM - LanceDB queries disk-resident data efficiently
- You need SQL-style analytics alongside vector search - DuckDB integration gives you full SQL over your vector store
- You want versioned data with Git-style branching - Lance format supports time travel and rollback
- You need tight integration with the Arrow and pandas ecosystem
- You want a zero-server embedded database that scales to cloud storage (S3, GCS) without code changes
What we found
We loaded all 500,000 Nordvik records into LanceDB in batches of 10,000, then built an IVF-PQ index with 256 partitions and 96 sub-vectors. The IVF-PQ index type introduces two sources of approximation that distinguish it from the HNSW indexes used elsewhere in this book: the IVF partitioning, which assigns vectors to cells and searches only the nearest cells at query time and product quantization, which compresses vectors to a fraction of their original size.
Recall came out at 0.828 - the lowest of any approximate library in the book. Latency was 4.986ms - the second highest after scikit-learn. Both figures are the honest cost of disk-based storage and double approximation.
| Config | Recall@10 | Latency (ms) | Metadata | Disk-based |
|---|---|---|---|---|
| LanceDB IVF-PQ | 0.828 | 4.986 | Yes | Yes |
| Chroma (reference) | 0.899 | 0.776 | Yes | No |
| FAISS IndexHNSWFlat (reference) | 1.000 | 0.060 | No | No |
One detail worth noting: the self-match distance for rank 1 - the query incident finding itself - came out at 0.030 rather than 0.000. This is product quantization introducing small distortions even for the exact query vector. It’s a visible reminder that IVF-PQ is an approximation at the storage level, not just the search level.
Metadata filtering
LanceDB uses SQL-style where clauses rather than Chroma’s dictionary filter syntax. This will feel natural to anyone who has worked with a database:
table.search(query_vec).where("CrimeType = 'Burglary'").limit(10)
table.search(query_vec).where("Weapon != 'none'").limit(10)
table.search(query_vec).where("Neighborhood = 'Millfield'").limit(10)
The filtering results showed the same pattern as Chroma: the burglary and armed incident filters worked cleanly and the Millfield filter produced higher distances (0.69 and above) than the unfiltered results (0.03 and above). The jump is more pronounced than in Chroma because IVF-PQ compression compounds with the geographic constraint.
Analytics
LanceDB stores data in the Lance columnar format which is designed for efficient analytical queries. We used the source parquet directly for our analytics demonstration - computing crime type distributions and armed incident counts by neighborhood - which is the practical approach in production. You would run analytics on raw data and reserve LanceDB for vector search queries.
The crime type distribution matched our generation weights precisely: Burglary at 35%, Vehicle Crime at 25%, Robbery at 20%, Assault at 10%, Drug Offences at 7% and Other at 3%. The armed incident counts by neighborhood were evenly distributed across all 15 neighborhoods, which is expected from a random generator but would not be true of real crime data.
API notes
Several things changed or behaved unexpectedly in LanceDB 0.33.0 that are worth flagging:
table_names()is deprecated - usedb.list_tables()insteadcreate_tableneedsmode="overwrite"when rerunning a notebook, otherwise it fails with aValueErrorif the table already exists_distancecolumn in query results is inaccessible viaitertuples()because pandas renames underscore-prefixed columns - useiterrows()withrow['_distance']instead.select()that omits_distancetriggers a deprecation warning; omitting the select clause entirely silences it
Cloud storage
LanceDB’s most significant production advantage - one we did not demonstrate in the notebook but worth calling out - is that it supports S3, GCS and Azure Blob Storage as backends with no code changes. Swap the local path for a cloud URI and the rest of the code is identical. This is the clearest path from local development to production of any library in this book.
What LanceDB does not do
LanceDB’s IVF-PQ index trades recall for storage efficiency. With 96 sub-vectors on 384-dimensional vectors, each vector is compressed from 1,536 bytes to 96 bytes - a 16x reduction. This is what enables LanceDB to handle datasets that would require hundreds of gigabytes of RAM in FAISS. But that compression is lossy and the recall cost is real.
For datasets that fit comfortably in memory and where latency matters, the in-memory HNSW libraries from earlier chapters are faster and more accurate. LanceDB’s advantages are most visible at scale.
When to look elsewhere
LanceDB is the wrong choice if:
- You need the highest recall - IVF-PQ compression loses information; use FAISS or PyNNDescent for maximum accuracy
- Your dataset fits comfortably in RAM and latency is critical - in-memory HNSW libraries are faster
- You want the simplest possible setup - FAISS or PyNNDescent are far simpler to get started with
- You need the exact search guarantee - LanceDB’s IVF-PQ is always approximate
Conclusions
We’ve run the same experiment seven times. Same dataset, same embedding model, same ground truth, same query. The results are in and the picture is clear enough to draw some practical conclusions.
The numbers in one place
| Library | Recall@10 | Latency (ms) | Metadata filtering | Disk-based |
|---|---|---|---|---|
| FAISS IndexHNSWFlat | 1.000 | 0.060 | No | No |
| Voyager | 0.888 | 0.295 | No | No |
| Sklearn brute force | 0.158 | 17.530 | No | No |
| PyNNDescent | 0.900 | 0.043 | No | No |
| USearch HNSW | 0.897 | 0.143 | No | No |
| Chroma | 0.899 | 0.776 | Yes | No |
| LanceDB IVF-PQ | 0.828 | 4.986 | Yes | Yes |
A note on the recall numbers: every library except FAISS and sklearn appears capped at around 0.88 to 0.90. This is a measurement artifact, not a genuine performance ceiling. Our ground truth was computed with FAISS using L2 distance; the other libraries use cosine distance. On normalized vectors these are mathematically equivalent in theory but floating point differences in implementation produce slightly different result orderings at the margin. Against a same-metric ground truth the approximate libraries would score higher. FAISS gets credit for recall 1.0 because it’s both the ground truth provider and one of the libraries being evaluated.
The sklearn figure of 0.158 is the same artifact at larger scale, compounded by the fact that we evaluated it on 50,000 records rather than 500,000.
What we actually learned
There is no single best library. The right choice depends on what you’re optimizing for. The table above has five dimensions and the libraries trade them off differently. No library wins on all five.
HNSW is the dominant algorithm for in-memory search. FAISS, Voyager, USearch and PyNNDescent all use it or something similar. The differences between them are in API design, default parameters, persistence and platform optimization - not in the fundamental algorithm.
PyNNDescent is the surprise of the book. A pure Python library posting 0.043ms query latency - faster than a C++ library - is not what you’d expect. Numba JIT compilation is doing real work here. If you need fast in-memory search without FAISS’s complexity, PyNNDescent deserves serious consideration.
The exact search claim for USearch does not hold on Apple Silicon. This is the most useful negative result in the book. SIMD optimization is architecture-specific. Benchmark on your target hardware before committing to a library based on headline numbers.
Metadata filtering has a real cost. Chroma and LanceDB are the only libraries that support it natively and both show higher latency than the pure search libraries as a result. At 0.776ms and 4.986ms respectively, they are still fast enough for most applications. The question is whether you need the filtering at all - if you don’t, the pure search libraries are significantly faster.
Disk-based storage changes the tradeoff fundamentally. LanceDB is the only library in this book that works beyond the RAM boundary. Its recall and latency numbers are the worst of the group, but they are the only numbers that remain stable as your dataset grows past what fits in memory. If your dataset is large enough, LanceDB is not competing with the other libraries - it’s the only option in the room.
Annoy is broken on Apple Silicon. We attempted to include it and discovered that it consistently returns only one neighbor regardless of K on M-series chips. It was dropped from the book. If you’re on Linux or x86 hardware it may work correctly, but we cannot verify this and the library is not actively maintained.
A decision framework
Here is a simplified way to choose:
Start with PyNNDescent if your dataset fits in memory, you do not need metadata filtering and you want the simplest path to fast approximate search. It has sensible defaults, no compilation step for the user and good recall.
Reach for FAISS if you need exact results, are working at very large scale within memory or want fine-grained control over index type and parameters. It’s the most flexible and best-documented library in this book.
Use Voyager if you want HNSW with a cleaner API than FAISS, need dynamic insertion and appreciate that it’s battle-tested at Spotify’s scale.
Use USearch if you’re deploying to edge or mobile environments where binary size matters or if you’re on Intel hardware and want the exact search speedup. Avoid it for exact search on Apple Silicon.
Use Chroma if you’re building a RAG pipeline, need metadata filtering and want persistence without managing it yourself. It’s the natural choice for LLM application development.
Use LanceDB if your dataset does not fit in RAM or if you need to scale to cloud storage without changing your code. Accept the recall and latency costs as the price of that capability.
Avoid scikit-learn NearestNeighbors for anything beyond small datasets or quick experiments. At 384 dimensions the tree-based algorithms offer no advantage over brute force and brute force is 450 times slower than FAISS HNSW at ten times the data volume.
What this book did not cover
We focused on single-node in-process search. We didn’t cover distributed vector search, streaming index updates at scale or the interaction between vector search and traditional relational queries in a production data platform. For those topics the companion book - Seven Vector Databases in Seven Days - covers the infrastructure layer: PostgreSQL with pgvector, MongoDB Atlas, Pinecone, Weaviate, Neo4j, Snowflake and Databricks.
We also didn’t cover embedding model selection. We used all-MiniLM-L6-v2 throughout because it’s a standard baseline that makes the library comparisons clean. In practice, the choice of embedding model often matters more than the choice of search library. A better model with a slower library will outperform a worse model with the fastest library every time.
A final observation
The most useful result in this book is not a latency number or a recall figure. It’s the observation from Day 1 that all ten results for our sample burglary query came from the same neighborhood using the same template. Embedding similarity is linguistic similarity, not semantic similarity. The model found incidents described in similar language and in a template-generated dataset, that means incidents from the same template.
In production with real officer-written narratives, the clusters are richer and the results more genuinely useful. But the fundamental point holds: vector search finds what is linguistically similar to your query. Understanding what your embedding model considers similar - and whether that matches what you consider similar - is the most important question to answer before deploying any of the libraries in this book.
Free Books
The SingleStore Cookbook: Recipes for Multi-Model, Machine Learning and AI Data Engineering
A hands-on cookbook covering SingleStore’s multi-model capabilities, from time series and geospatial data through vector search, machine learning pipelines and AI-powered applications. The recipes draw on first-hand experience building applications with the platform and are organized into four parts:
- Multi-Model
- Streaming and Big Data Pipelines
- Machine Learning
- AI and Agentic Frameworks
Seven Vector Databases in Seven Days
A practical guide that takes one vector database per day and pairs each with a use case chosen to showcase that database’s strengths. Databases covered:
- PostgreSQL and pgvector - Semantic job search
- MongoDB Atlas - Recipe finder
- Pinecone - E-commerce search
- Weaviate - Research paper discovery
- Neo4j - Fraud detection
- Snowflake - Customer support analytics
- Databricks - RAG over internal documents
Each chapter is self-contained, comes with a Jupyter notebook and gives an assessment of when you’d look elsewhere.
Generative AI: A Manager’s Guide
A practical guide for managers, directors and executives who need to make decisions about AI in their organizations, not the engineers building it, but the people responsible for making it work well. The book uses a single central metaphor, the Digital Intern, to frame what AI is genuinely good at, where it falls short and what managing it actually requires. It covers governance, risk, board-level accountability, business case building and the organizational change of moving from pilot to embedded capability.
Seven Ways to Do Vector Search in Python
A practitioner’s guide that benchmarks seven Python libraries against the same dataset, measuring recall and latency consistently so you can compare like-for-like. Libraries covered:
- FAISS
- Voyager
- Scikit-learn NearestNeighbors
- PyNNDescent
- USearch
- Chroma
- LanceDB
Each chapter covers one library, explains what it’s genuinely good at and when you’d reach for something else.
Real-Time Vehicle Tracking with Neo4j, Databricks Lakebase and OpenStreetMap
A fleet operations demo that puts ten simulated vehicles onto real road networks loaded from OpenStreetMap. The architecture:
- Neo4j Aura holds the road network graph
- Databricks Lakebase stores live vehicle positions
- Databricks Lakehouse handles historical analytics
Two Streamlit dashboards display live positions and trend data. The primary demo uses the London Borough of Merton, with additional configurations for San Francisco and Singapore.
Real-Time Supply Chain Routing with Neo4j, Snowflake Postgres and Confluent Kafka
In progress.