Building and Searching 100 Million Vectors#
Authored by Lyon Lay
Authored by Lyon Lay
TL;DR: We built an open-source vector index that indexes 100 million vectors in 1 hour and searches them at 23 ms p99 latency with 96% recall@100. We found no published build on comparable data and hardware that is faster.1 It has many favorable scaling properties, suggesting it could scale to 100B vectors. The purpose of this post is to make this work available to the industry.2 The code, benchmarks, and results are all open source.
Vector search is a core component of many AI systems; it enables search for similar documents, images, or text over vast corpora of data. Brute force gives exact answers but is untenable at scale.3 An index makes search practical by reducing the work needed; it finds most of the nearest neighbors while only reading a small fraction of the data. Prequisite knowledge and terminology are in the appendix.
Hierarchical SPANN, our index architecture, is a tree that divides the collection into progressively smaller regions, allowing a query to traverse to the most promising regions. This structure scales well horizontally as subtrees can be divided across machines.
ANN (Approximate nearest neighbor search) relies on the fact that nearby vectors cluster together, so searching a few well-chosen groups can recover most neighbors. Each group forms a posting list, represented by a center vector, or centroid. Comparing a query with centers identifies promising lists without scanning their contents. SPANN 4 keeps the lists on disk and their centers in memory.
Finding those centers is itself a nearest-neighbor search. The index groups nearby centers, gives each group its own center, and repeats up to one root. Each level searches a smaller collection.
A leaf holds a posting list; an internal node holds its children's centers. With a target of about a hundred children per internal node, the measured 100M tree has four levels and the 100B-shaped tree has six. The tree stays shallow, though preserving recall may require exploring more branches.
Compression makes comparisons cheap. The query routes with 4-bit-per-dimension centroid codes, scans posting-list codes, 1-bit-per-dimension compressions of each stored vector (codes for short), and checks its shortlist at full precision. Both kinds of code use RaBitQ 5.
Subtrees also provide natural boundaries between machines. Splitting a proximity graph such as HNSW can leave search paths crossing machines and paying network round trips. A subtree owns a set of leaves that can be served together.
Choose the lists. Starting at the root, traverse down the tree: the query scores the children of retained nodes and keeps the most promising. The number retained is the beam width. At the bottom, it has selected a few hundred to a few thousand leaves. The completed traversal looks like a subtree
Scan the lists. The query scores every code in the selected lists and keeps an approximate shortlist larger than the requested result count, k.
Rerank the shortlist. It fetches the candidates' original vectors, computes exact distances, and returns the nearest k.
Searching more leaves or reranking more candidates can improve recall, the fraction of true nearest neighbors returned. Codes scanned measures posting-list work; latency includes routing, scanning, and reranking.
The writer alternates insertion with balancing: moving vectors, creating or removing nodes, and adding levels. It publishes snapshots for readers, allowing construction and querying to use different settings, representations, and machines.
The build time includes keeping the tree balanced and searchable after each batch. As the collection grows, insertion navigates a larger tree and balancing operates over more leaves.6
Storing each vector in one list simplifies maintenance. SPANN can replicate boundary vectors across neighboring lists to improve recall 4. In my experiments, the modest recall gain came with much longer builds: moving a vector requires removing its outdated copies. One copy also avoids cross-machine replica bookkeeping.
In Chroma's production architecture, writes enter our durable wal3 log, background workers publish index versions, and queries combine a committed index with recent writes. Integrating this prototype and measuring sustained-write performance are work for a later post.
When opening a snapshot, the reader orders leaves depth first to keep subtrees together, then divides them into equally sized parts. A coordinator routes the query; each machine scans its selected leaves and returns a shortlist. Full vectors come from a block store 7 on demand, and each machine indexes only its own leaves' blocks.
Each machine caches the full routing tree, about 0.75 bytes per stored vector at 100M, to avoiding distributed routing overhead: potentially a network round trip at every level.
Splitting preserves the selected lists and candidates. Each part reranks its own shortlist, however, so more parts check more candidates at full precision. Recall rises slightly, along with reranking work.
As the collection grows so does the index. For an index to scale, we want to see that the work done for each query (# computations performed, the cost per computation, # of MB loaded) scales sub-linearly with collection size, or even converges on a cieling. This is indeed what we observe.
I built one FineWeb 8 index, saving checkpoints from 1M to 100M. The query set, dataset source, dimensions, construction settings, and hardware stay fixed; exact answers are computed for each size.
Moving from 10M to 100M vectors, recovering the same fraction of neighbors requires roughly two to three and a half times the scan, depending on the recall target. Brute force would require ten times the work.
Latency grows faster than codes scanned because navigating the larger tree adds work. To understand that cost, separate how well lists group neighbors from how well queries find those lists.
Grouping holds up over the directly measured range: from 1M to 10M, nearly all true neighbors remain concentrated in a few dozen lists.
Finding those lists gets harder as more centers compete for similar ranks. A center summarizes a list; it need not be among the closest centers for its list to contain a true neighbor. Relevant lists with far-away centers slip further down the ranking, requiring a wider search.
Internal routing faces the same choice. At small sizes, a query can score every internal node. Around 50M, retaining only approximately the best tenth of the second level saves enough work to search more leaves, reaching higher recall in less time.
The hierarchy still skips most vectors, but preserving recall requires more work to choose the right lists. That routing cost also limits the gains from distributing scans.
Sharing leaves across machines increases capacity and throughput more than it improves single-query latency. I measured the difference by serving the same 100M index from one, two, four, and eight identical machines.
Once scans are distributed, routing dominates latency. Parallelizing it across the coordinator's cores helps, but selecting the next beam remains sequential. Queries also wait for the slowest scan worker, and selected lists rarely divide evenly.
At 100M, queries touch nearly every machine despite subtree placement. Communication would fall only if each machine owned a much larger region than a query typically explores. The deeper-tree experiment below tests that possibility.
The coordinator also caps throughput: it routes, scans its part, and merges every query. Total CPU work per query rises with machine count because scanning the same codes in smaller pieces adds overhead.
Extending the 10M–100M trend three more factors of ten gives too wide a range for a useful prediction. Instead, I test a real routing tree at the depth of a 100B index and do some napkin math to derive the rest. A narrower 1B prediction is in the appendix.
Routing compares queries only with centers, so it can be tested without building every posting list. I use 70M real vectors as stand-in leaf centers in a tree with the depth and level widths expected at 100B. This measures routing work and retention of nearest centers, not end-to-end recall over 100B vectors.
A beam that retains nearly all nearest centers at the 100M shape loses recall as the tree deepens. Retaining 95% at the 100B shape requires an internal beam of about 8,192. Shallow navigation still takes substantial work.
The deeper tree offers more locality: selected lists concentrate in fewer subtrees, contacting a smaller fraction of machines. Distribution has room to grow if routing can keep up.
The budget separates measured results, estimates derived from current rates, and extrapolated trends. The scan estimate is an order of magnitude, not a promised operating point.
At the measured scoring rate, routing alone would take roughly 100–175 ms per query, before scanning any lists. This is derived, not directly timed on the 100B-shaped tree. Distributing more leaf data does not remove that bottleneck.
The hierarchy makes large collections practical by skipping most of their vectors. The next gains depend on choosing what to skip more efficiently.
These are the systems and papers closest to this work. Each takes a different position on where vectors live, how queries are routed and how vectors are compressed.
The prototype builds on one machine with bounded balancing work, publishes immutable snapshots, and serves them across machines with full-vector retrieval on demand. The next improvements target routing and storage costs:
The figure extends the fitted 10M–100M trend one more factor of ten, recording a prediction before the 1B experiment runs.
Four assumptions define the prediction:
The post assumes some familiarity with the topics below. If one is new, the linked article is a good starting point.
The implementation, full results, and benchmark code are in chroma-core/chroma#7960 (as of commit 4de458a), for reproducing the measurements, tuning the index, and testing the forecast on other datasets.
This is my assessment of the build result, not a matched benchmark against every index. Published results use different datasets, dimensions, hardware, and build procedures. Meta reports about 168 seconds to build IVF-PQ over 100M 96-dimensional vectors from Deep1B on one H100 GPU (Accelerating GPU indexes in Faiss with NVIDIA cuVS, 2025). InSituANN reports DiskANN builds of 26.6, 38.4, and 40.3 hours on SIFT-1B, DEEP-1B, and SPACEV-1B using 64 threads (InSituANN: Revisiting IVF for PCIe-Efficient Billion-Scale Vector Search, 2026). This build uses CPUs, handles vectors eight times wider than the GPU example, and maintains a balanced, searchable index after each batch of a million vectors rather than building only once over a fixed dataset. ↩
This work builds on my personal vector database project and Chroma's next generation of SPANN. I am sharing it for others to extend; query-recall performance remains an area for improvement. ↩
For example, 100M vectors would cost us about 300 GB of reads and 200 GFLOPs of arithmetic. ↩
Chen et al., SPANN: Highly-efficient Billion-scale Approximate Nearest Neighbor Search, NeurIPS 2021. ↩ ↩2
Gao and Long, RaBitQ: Quantizing High-Dimensional Vectors with a Theoretical Error Bound for Approximate Nearest Neighbor Search, SIGMOD 2024. ↩
Insertion and balancing of 100M 768-dimensional FineWeb vectors on one r6i.32xlarge, with 16 insertion and 16 balancing workers. Excludes input loading and checkpoint saves. The tree is balanced and searchable after each batch. ↩
Chroma's blockfile stores large immutable blocks in S3 and caches them on local SSD. See Chroma's distributed architecture. ↩
Penedo et al., The FineWeb Datasets: Decanting the Web for the Finest Text Data at Scale, 2024. The vectors come from Qdrant-FineWeb-10B, which embeds FineWeb documents with Alibaba-NLP/gte-multilingual-base into 768-dimensional unit-norm vectors. ↩