Chapter 4 · Production engineering
Vector Databases at Billion Scale
A working design for 800 million product-title embeddings and 500 million image embeddings across four markets, in Qdrant. Capacity arithmetic, index tuning, multitenancy, bootstrapping, batch upserts and re-indexing — then a runbook putting every optimisation in the order you should actually try them, with the numbers worked out rather than asserted.
4.1 The workload
This chapter is different from the previous three. Those built concepts; this one builds a system. Everything here is anchored to one concrete workload, and every number is derived rather than asserted.
| Property | Product titles | Product images |
|---|---|---|
| Count | 800,000,000 | 500,000,000 |
| Model | EmbeddingGemma-300M | DINOv3, ~300M parameter variant |
| Output dimensions | 768, truncatable to 512 / 256 / 128 | 1024, fixed |
| Markets | Canada, United States, Mexico, Chile | |
| Modality | Text, multilingual | Image, self-supervised features |
[!] Confirm your own dimensions before trusting any sizing
The arithmetic in this chapter uses 768 for text and 1024 for image vectors. DINOv3 ships in several sizes and the ~300M-parameter ViT-L variant emits 1024 dimensions, but check the exact checkpoint you deploy — a ViT-B at 768 or a ViT-H at 1280 changes every RAM figure proportionally. Run len(model.encode("test")) once and put the result in your capacity spreadsheet. Do not inherit a number from a blog post, including this one.
Why this scale changes the engineering
A million vectors is a laptop exercise. Any library works, defaults are fine, and mistakes are cheap because rebuilding takes minutes. At 1.3 billion, four things become true at once:
- Memory is the binding constraint, not CPU. The decisive question is how many bytes each vector occupies, because that sets your machine count and therefore most of your bill.
- Full rebuilds stop being routine. An operation taking 40 seconds per million takes about 14 hours at this scale. "Just re-index" ceases to be a casual suggestion and becomes a planned migration.
- Defaults are actively wrong. Qdrant's out-of-the-box settings target datasets thousands of times smaller. Shipping them here produces an expensive, slow cluster.
- Architecture decisions become expensive to reverse. The collection layout in section 4.10 is the clearest example: choosing wrong means re-ingesting everything later.
[+] The single most important number
Almost every decision in this chapter reduces to one quantity: bytes of RAM per vector. Multiply it by 1.3 billion and you have your cluster. The techniques in sections 4.7 and 4.8 move that number from about 3,600 bytes down to under 250, which is the difference between roughly 19 machines and 2. Everything else is detail hanging off that.
4.2 Why not a normal database
A reasonable first question: you already run Postgres. Why add another system?
Because relational indexes answer a fundamentally different question. A B-tree index makes equality and range queries fast: find the row where id = 5, or price between 20 and 40. It works by keeping values in sorted order, which requires that "sorted order" exists.
[!] There is no sorted order in 768 dimensions
Nearest-neighbour search asks "which of these 800 million vectors points in most nearly the same direction as my query". There is no way to sort vectors such that similar ones are adjacent, because similarity is defined across hundreds of dimensions simultaneously. A B-tree cannot help. Without a specialised index the only correct answer is to compare the query against every single vector.
What brute force actually costs here
Worth computing, because it establishes why approximation is mandatory rather than merely convenient.
comparisons 800,000,000
multiply-adds 800,000,000 x 768 = 614 billion operations
memory read 800,000,000 x 3 KB = 2.46 TB streamed per query
Even at a very optimistic 50 GB/s of memory bandwidth, reading the data
alone takes ~49 seconds. Per query. For one user.
That is the entire justification for this chapter. Exact search does not scale, so we trade a small amount of accuracy for several orders of magnitude of speed.
4.3 Exact versus approximate search
[def] Approximate nearest neighbour, and recall
ANN search finds vectors that are almost certainly among the closest, without checking every candidate. Its accuracy is measured as recall@k: of the k genuinely nearest vectors, what fraction did the index actually return?
Recall@10 of 0.95 means that on average you retrieved 9.5 of the true top 10. The missing item is usually rank 8 or 9 rather than rank 1, which matters enormously for whether users notice.
[retail] How much recall do you actually need?
Teams reflexively demand 0.99 and then pay for it in RAM. Interrogate the requirement against the actual product surface:
| Surface | Target | Reasoning |
|---|---|---|
| Search results page | 0.90 – 0.95 | The user sees 24 products. Missing the true rank-9 item when rank-11 takes its place is invisible — both are plausible. |
| "More like this" carousel | 0.85 – 0.90 | Six slots of loosely similar items. Nobody can perceive the difference. |
| Duplicate detection at ingest | 0.99+ | A missed duplicate becomes a permanent catalogue defect that costs money to clean later. |
| Recall for a downstream reranker | 0.95+ at k=100 | The reranker can only reorder what it receives. Anything missed here is lost permanently. |
Dropping the search target from 0.99 to 0.95 can cut your index memory substantially. At 1.3 billion vectors that is real money, and the change is imperceptible to users.
[+] Measure recall, do not assume it
Recall is measurable. Sample 1,000 representative production queries, run each with exact search over a subset to establish ground truth, then compare against what the index returns. Do this before launch, and again after any parameter change. Teams that skip this step invariably discover their real recall months later, usually via a complaint about search quality that takes weeks to diagnose.
4.4 Index families, and why Qdrant uses HNSW
Every ANN index makes the same bargain: examine a small fraction of the vectors, and accept that you will occasionally miss a true neighbour. What separates the families is how they decide which fraction to examine — and each strategy fails in its own characteristic way. Knowing all of them matters, because the reason HNSW wins for this workload is also the reason it is expensive.
| Family | Idea | Trade-off |
|---|---|---|
| Partition IVF, k-means based |
Cluster vectors, search only the nearest few clusters. | Cheap memory, fast build. Recall suffers near cluster boundaries. Needs periodic retraining as data shifts. |
| Hashing LSH |
Hash so that similar vectors collide. | Simple and fast, but poor recall per unit memory at high dimensions. Largely superseded. |
| Graph HNSW, Vamana |
Link each vector to near neighbours, then walk the graph greedily. | Best recall-versus-speed available. Costs RAM for the graph and is slower to build. |
| Compression PQ, and IVF-PQ |
Shrink each vector so more of them fit in memory. | Not an index by itself — a multiplier layered on one of the above. Distances become approximate. |
| Disk-resident graph DiskANN |
Compressed vectors in RAM to steer, full vectors on SSD. | Breaks the RAM ceiling entirely. Adds SSD latency and requires fast NVMe. |
[!] These are not five competing choices
The first three are genuine alternatives — you pick one. The last two are composable: PQ is a compression scheme that gets bolted onto a partition or a graph index, and DiskANN is a graph index that uses PQ to escape the memory ceiling. In practice the real production systems are combinations: IVF-PQ (FAISS at billion scale), HNSW + scalar quantization (what this chapter builds), and DiskANN (graph + PQ + SSD). Treating them as a flat menu of five options is a common interview mistake.
Partition indexes: IVF
The oldest and most intuitive approach. Run k-means over a sample of the data to find cluster centroids, assign each vector to its nearest one, and store an inverted list per cluster. At query time, compare against the centroids — a few thousand comparisons — then scan only the vectors inside the closest nprobe clusters.
[def] The knob, and what it trades
nprobe is IVF's equivalent of HNSW's ef: a pure runtime dial trading latency for recall. The relationship is close to linear — double nprobe and you roughly double both the vectors scanned and the time taken. That linearity is IVF's real weakness. HNSW's cost grows logarithmically with the search effort required, so as recall targets climb, the gap between the two widens sharply. IVF at recall 0.99 is often slower than HNSW at the same recall despite doing conceptually simpler work.
[+] Where IVF still wins
Build time and memory. An IVF index over 800 million vectors trains in minutes on a sample and stores nothing per vector beyond a cluster assignment; the equivalent HNSW graph takes many hours and permanently costs tens of gigabytes of neighbour lists. If your index is rebuilt nightly from scratch, or if it must fit in memory alongside a much larger raw dataset, IVF's economics can beat HNSW's outright — which is exactly why IVF-PQ remains the default for billion-scale FAISS deployments.
Hashing: LSH
LSH takes a different angle: rather than partitioning the space by proximity to learned centroids, it uses random projections. Generate a set of random hyperplanes through the origin, and record which side of each plane a vector falls on. Vectors pointing in similar directions almost always agree on most of those bits, so a vector's bit string works as a bucket key.
[hist] Why LSH lost
LSH dominated the 2000s and has strong theoretical guarantees — you can prove collision probabilities as a function of angle, which is more than can be said for HNSW. But the guarantees are probabilistic per table, so reaching high recall demands many independent hash tables, and the memory for those tables buys far less recall than the same memory spent on a graph. By the mid-2010s benchmarks were consistently showing graph indexes achieving better recall at lower latency and lower memory. LSH survives today mainly in near-duplicate detection, where its cheap bit codes and provable properties still earn their place, rather than in general-purpose vector search.
Graph indexes: HNSW
Graph indexes discard partitioning entirely. Instead, every vector stores links to some of its nearest neighbours, and search becomes navigation: start somewhere, repeatedly step to whichever neighbour is closer to the query, and stop when no neighbour improves. HNSW adds a hierarchy on top of that idea.
[hist] 2016: HNSW
Malkov and Yashunin published Hierarchical Navigable Small World graphs in 2016. It became the default index in essentially every vector database — Qdrant, Milvus, Weaviate, pgvector, Elasticsearch — because it dominates the recall-versus-latency curve for in-memory workloads. Qdrant implements HNSW exclusively, which simplifies tuning: there is no index-type decision, only parameter choices.
How the graph is searched
The idea borrows from how you would navigate a country without a map: take long-haul flights to get near the right region, then progressively shorter hops.
Compression: product quantization
PQ is not an index. It is a way of making vectors smaller so that whichever index you chose can hold more of them, and it appears here because "which index family" and "how are the vectors stored" are decisions people routinely conflate in interviews.
[!] PQ error is structural, unlike scalar quantization
Scalar quantization (4.7) rounds each number to a coarser grid — the vector you compare against is still your vector, just less precisely described. PQ replaces each slice with the nearest entry from a shared codebook, so the vector you compare against is a different vector: the centroid, which stands in for thousands of others. Two distinct products can quantize to an identical code and become genuinely indistinguishable to the index. That is why PQ recall figures in 4.7 look worse than the compression ratio alone suggests, and why PQ is almost always paired with a rescoring pass over full-precision vectors.
Breaking the memory ceiling: DiskANN
Every option so far assumes the index lives in RAM. At 1.3 billion vectors that assumption is what drives the entire capacity plan in 4.9 — and DiskANN is the family that rejects it.
[+] When to reach for DiskANN
The deciding question is whether your RAM budget or your latency budget binds first. DiskANN typically lands in the 5–20 ms range against roughly 2–10 ms for an in-memory HNSW index — slower, but not by the order of magnitude people expect, because only a handful of SSD reads sit on the critical path. If the alternative is sharding across several machines purely to buy RAM, one DiskANN node with fast NVMe is often cheaper and simpler to operate. Qdrant does not implement DiskANN; its memory-mapped storage combined with quantization occupies a similar niche.
Choosing between them
| If the constraint that binds is… | Reach for | Because |
|---|---|---|
| Query latency at high recall | HNSW | Dominates the recall-versus-latency curve. This is the workload in this chapter. |
| Index build time, or frequent full rebuilds | IVF | Trains in minutes on a sample; HNSW takes hours to days at this scale. |
| RAM, with vectors that must stay in memory | IVF-PQ | Coarse partition plus 64× compression is how FAISS fits a billion vectors on one box. |
| RAM, with fast NVMe available | DiskANN | Keeps graph quality while moving the bulk of the bytes off RAM entirely. |
| Near-duplicate detection specifically | LSH | Cheap bit codes and provable collision bounds still earn their place here. |
[retail] Why this chapter uses HNSW plus scalar quantization
Applying the table to our own constraints: the search surface needs recall around 0.95 at interactive latency, which rules out IVF at acceptable cost and rules out LSH entirely. The index is rebuilt rarely rather than nightly, so HNSW's punishing build time is a one-off rather than a recurring tax. That leaves the memory bill, which is handled by quantization rather than by changing index family — scalar int8 first (4.7), because it preserves per-vector identity and needs no codebook training, with binary plus rescoring as the lever if the budget tightens further. DiskANN would be the next move if RAM cost eventually dominates, and Qdrant's memory-mapped storage is the closest available equivalent.
[≡] The question interviewers actually ask
Not "what is HNSW" — that is recitable. The discriminating question is "when would you not use it?". A strong answer names a specific binding constraint and follows it through: rebuild frequency (IVF trains in minutes, HNSW takes days at a billion vectors), a RAM ceiling that quantization alone cannot close (DiskANN, or IVF-PQ), or extreme write churn, where HNSW's incremental insert cost and graph degradation over time make a periodically retrained partition index the calmer operational choice.
4.5 The HNSW parameters that matter
Qdrant exposes a handful of numbers that govern the recall, speed and memory of your index. Getting these right is most of the tuning work, and the defaults are wrong at our scale. Before the table of what to set, it is worth understanding what the graph builder actually does with each one — because every parameter here is a direct lever on a specific step of the construction or search algorithm, and knowing which step is what turns tuning from guesswork into reasoning.
What Qdrant does when you insert a vector
Qdrant builds the graph incrementally. Every point that arrives goes through the same five steps, and m and ef_construct are the two dials on that procedure.
[→] Inserting one vector into the graph
- Roll a random level. Qdrant draws level = round(−ln(U) / ln m) for a uniform random U. The point is then inserted into every layer from 0 up to that level. Nothing about the vector's content influences this — it is pure chance, which is what keeps the layer sizes predictable.
- Descend from the top entry point. Starting at the graph's single entry point in the highest layer, greedily walk to the nearest neighbour, drop a layer, repeat. Above the point's own level this is a cheap one-best-candidate walk whose only job is to arrive somewhere sensible.
- At each layer at or below its level, run a real search. Here the walk widens to a beam of ef_construct candidates, producing a pool of good potential neighbours rather than just one.
- Prune that pool to at most m links using the selection heuristic below — the step that decides the graph's quality.
- Add backlinks. Each chosen neighbour gets a link back. If that pushes a neighbour over its own limit, its link list is re-pruned by the same heuristic. This is why inserting one point can quietly modify a dozen others, and why builds are expensive.
[def] Why the levels decay exactly like that
Dividing by ln m is what makes each layer hold 1/m of the layer below it. That is not a tuning choice; it is the property that makes search logarithmic. If each layer is m times smaller than the one beneath, then the number of layers is logm N, and a greedy walk that crosses each layer in a roughly constant number of hops visits O(log N) nodes in total. Raising m therefore does two things at once: more links per node, and a shallower pyramid.
[≡] A detail specific to Qdrant
The original HNSW paper takes the floor of that expression; Qdrant rounds it. The consequence is that Qdrant's upper layers are denser than the textbook version — layer 1 holds about 25 per cent of all points rather than 1/16th — because rounding promotes everything above the halfway mark instead of discarding it. It changes no tuning advice, but if you ever compare a Qdrant graph against published HNSW layer statistics and find them disagreeing by a factor of four, this is why.
The neighbour selection heuristic
Step 4 is where graph quality is won or lost, and it is the part most descriptions of HNSW skip. The obvious implementation — keep the m closest candidates — produces a measurably worse graph than the one Qdrant builds.
[+] Two consequences worth being able to state
Nodes often have fewer than m links. The heuristic rejects redundant candidates rather than padding the list to m, so real link counts sit below the maximum. This is healthy, not a defect — and it means the memory formula in 4.6 is an upper bound. The links that survive are disproportionately long. Those long links are the “small world” part of the name: they are what let a greedy walk cross the dataset in a few hops instead of crawling neighbour to neighbour.
What Qdrant does when you search
Search reuses the same machinery in reverse, and ef is its only dial. The graph is fixed by this point; nothing you pass at query time changes it.
[→] Answering one query
- Enter at the top layer and greedily walk toward the query, keeping only the single best candidate. Cheap: the upper layers hold very few points.
- Drop a layer and repeat until layer 0 is reached. Each descent lands the search closer to the right region, which is why the entry point's position barely matters.
- At layer 0, widen to a beam of ef candidates. Maintain a priority queue of the ef best points seen, expanding the most promising unvisited one, until no unvisited candidate is closer than the worst entry in the queue.
- Return the top k from that queue.
[!] ef is silently raised to your limit
Qdrant runs ef = max(ef, limit). Requesting 500 results with ef=128 does not search with a beam of 128 — it searches with 500, and costs accordingly. This surprises teams who tune ef against limit=10 benchmarks and then deploy a limit=500 candidate-generation call for a reranker, wondering why latency does not match the benchmark. If you need many candidates, measure at the limit you will actually use.
full_scan_threshold: the parameter whose units are a trap
This one is different in kind from the other three. It does not tune the graph at all — it decides whether the graph is used in the first place. Below the threshold, Qdrant abandons HNSW and brute-forces the candidate set, which is both faster and exact for small enough sets.
[!] It is measured in kilobytes, not in points
This is the single most common misconfiguration of the four parameters, because the name suggests a row count and the value looks like one. From Qdrant's own definition: “Minimal size threshold (in KiloBytes) below which full-scan is preferred over HNSW search… Note: 1Kb = 1 vector of size 256”. The threshold measures the total size of the vectors being scanned, so the number of points it corresponds to depends entirely on your dimensionality and quantization.
| Vector | Size each | Default 10,000 KB means… | For ~20,000 points, set |
|---|---|---|---|
| Titles, 768d float32 | 3.00 KB | 3,333 points | 60000 |
| Titles, 768d int8 | 0.75 KB | 13,333 points | 15000 |
| Images, 1024d float32 | 4.00 KB | 2,500 points | 80000 |
| Images, 1024d int8 | 1.00 KB | 10,000 points | 20000 |
Two consequences. First, the same threshold value means different things for your two collections — 20,000 KB brute-forces 26,667 title vectors but only 20,000 image vectors. Second, quantizing changes the behaviour of a threshold you did not touch: switching titles from float32 to int8 quadruples the number of points that fall under the same KB limit, silently moving the brute-force boundary.
[def] Do not confuse it with indexing_threshold
Two similarly named settings, both in kilobytes, doing unrelated jobs. full_scan_threshold (an HNSW config field, default 10000) is a per-query planner decision: is this filtered subset small enough to scan directly? indexing_threshold (an optimizer config field, default 20000) is a per-segment build decision: is this segment large enough to be worth building an HNSW graph for at all? Segments below it stay unindexed and are always scanned. A common bootstrapping trick in 4.14 is setting indexing_threshold=0 to disable graph building during a bulk load, then restoring it — a completely different operation from touching full_scan_threshold.
The parameters, side by side
| Parameter | Step it controls | Set at | Cost of raising it |
|---|---|---|---|
| m | Max links kept per node after pruning. Also sets layer decay (1/m) and m0 = 2m at layer 0. | Index build | Permanent RAM, every vector. The expensive one. |
| ef_construct | Beam width while searching for candidates during insertion. | Index build | Build time only. No runtime memory cost. |
| ef (hnsw_ef) | Beam width during the layer-0 walk at query time. | Per query | Latency only. Tunable live, per request. |
| full_scan_threshold | Whether to use the graph at all, or brute-force the subset. In KB. | Per query, from config | Raising it means more brute-force: exact, but linear in subset size. |
[+] The relationship between ef_construct and ef
They are the same mechanism — a beam width — applied at two different times, which is why they behave so differently in cost. ef_construct is paid once per vector at build time and its benefit is baked into the graph permanently: a better candidate pool means better links, which every future query benefits from for free. ef is paid on every single query forever. That asymmetry is the entire tuning strategy: be generous with ef_construct, because you pay once; be careful with ef, because you pay always. A well-built graph (high ef_construct) reaches a given recall at a lower ef, so build effort directly buys query speed.
Recommended values for this workload
| Setting | Qdrant default | Titles | Images | Why |
|---|---|---|---|---|
| m | 16 | 16 | 16 | The default is genuinely right here. Each step up costs about 55 GB of RAM per billion vectors and buys progressively less recall. |
| ef_construct | 100 | 200 | 256 | Higher build effort produces a better graph at zero runtime cost. You build once and query forever, so spend here. |
| ef | 128 | 64 – 128 | 128 – 256 | Tune per surface against measured recall. Image vectors are less separable and generally need more. |
| full_scan_threshold | 10000 KB | 15000 KB | 20000 KB | Both values target roughly 20,000 points once quantized to int8 — the unit is kilobytes, so the two collections need different numbers to mean the same thing. See 4.5 and 4.12. |
[!] Why raising m is the trap
m looks harmless in a config file and is the most common cause of an unexpectedly expensive cluster. Going from m=16 to m=32 adds roughly 180 GB of RAM across 1.3 billion vectors and typically improves recall by one or two points — which you could often have obtained by raising ef for free. Remember it is m0 = 2m links at layer 0, where every vector lives, so the memory cost scales with double the number you typed. Treat any proposal to raise m as a capacity change requiring the arithmetic in section 4.6, not as a tuning tweak.
[retail] Per-surface ef, one index
Because ef is a per-query parameter, a single collection can serve several quality tiers. Set ef=48 for the "more like this" carousel where speed matters and nobody notices a swapped rank-9 result, and ef=200 for the duplicate-detection job that runs overnight and must not miss anything. Same data, same index, different cost per call. Teams frequently build two collections to achieve this, and then pay twice for storage they did not need.
4.6 Sizing from first principles
This is the section to internalise. Everything about cost, machine count and feasibility follows from one formula, and it is simple enough to do on a whiteboard in an interview.
[def] RAM per vector
bytes_per_vector = (dimensions x bytes_per_dimension) # the vector
+ (2 x m x 4 x 1.08) # the HNSW graph
total_RAM = bytes_per_vector x vector_count
The second term deserves explanation because it is the part people forget. Layer 0 of the graph stores up to 2 x m neighbour identifiers per vector — twice m, because the base layer is built with doubled connectivity. Each identifier is a 4-byte integer. The upper layers add roughly 8% on top. With m=16 that is 138 bytes per vector, and critically this cost does not shrink when you quantize. The graph is made of pointers, not of vector data.
| m | Bytes per vector | 800M titles | 500M images | Total |
|---|---|---|---|---|
| 8 | 69 B | 55 GB | 35 GB | 90 GB |
| 16 | 138 B | 111 GB | 69 GB | 180 GB |
| 32 | 276 B | 221 GB | 138 GB | 359 GB |
| 64 | 553 B | 442 GB | 276 GB | 719 GB |
[!] The floor nobody plans for
At m=16 you are committed to 180 GB of RAM before storing any vector data at all. If you quantize aggressively down to binary, the graph becomes the majority of your memory footprint. This is why "just use binary quantization" does not reduce cost by 32 times — it reduces the vector half, while the graph half stays put.
The unquantized baseline
Start with float32, which is what you get if you change nothing.
TITLES 768 dims x 4 bytes = 3,072 B + 138 B graph = 3,210 B/vector
x 800,000,000 = 2,568 GB
IMAGES 1024 dims x 4 bytes = 4,096 B + 138 B graph = 4,234 B/vector
x 500,000,000 = 2,117 GB
TOTAL = 4,685 GB (~4.7 TB)
Nearly five terabytes of RAM. On 256 GB nodes that is 19 machines holding one copy of the data, before replication, before payload storage, before any headroom. Add a single replica for availability and you are at 38.
[+] This is why the next two sections exist
Nearly 5 TB of RAM is not a serious proposal. Sections 4.7 and 4.8 bring this to under 300 GB — a factor of roughly 15 — with a recall cost you can measure and mostly recover. That reduction is the engineering. Everything before it was setup.
4.7 Quantization
A float32 number uses 4 bytes to store roughly 7 decimal digits of precision. Embedding values are almost all small numbers between about -1 and 1, and nearest-neighbour ranking only needs to know which vectors are closer, not their exact distances. That precision is largely wasted.
[def] Quantization
Quantization stores each dimension with fewer bits. Qdrant offers three forms: scalar (float32 to int8, 4x smaller), binary (each dimension to a single bit, 32x smaller), and product quantization (compresses groups of dimensions, up to 64x). All three trade precision for memory.
| Mode | Size | Total RAM | Recall before rescoring | Verdict |
|---|---|---|---|---|
| None (float32) | 1x | 4,685 GB | 1.00 | Unaffordable at this scale. |
| Scalar int8 | 4x smaller | 1,306 GB | 0.97 – 0.99 | Safe default. Minimal quality risk. |
| Binary | 32x smaller | 321 GB | 0.70 – 0.90 | Excellent with rescoring. Needs validation per model. |
| Product | up to 64x | ~250 GB | 0.60 – 0.85 | Slow to encode, CPU-heavy at query. Rarely worth it over binary. |
Rescoring: why binary is not as lossy as it looks
Binary quantization keeps one bit per dimension — effectively just the sign. That sounds catastrophic, and on its own it is mediocre. The trick is a two-stage search.
- Search the binary index, but ask for more results than you need Request the top 100 when you want 10. This is oversampling. Binary distance is computed with bitwise XOR and a popcount instruction, which is extraordinarily fast — often 10 to 40 times faster than float comparison.
- Fetch the full-precision vectors for those 100 candidates Read them from disk. This is 100 random reads, which on NVMe is a few milliseconds and is the reason this technique needs fast storage.
- Re-rank the 100 by exact distance and return the top 10 The expensive precise comparison now runs against 100 vectors rather than 800 million.
[+] Why this recovers most of the loss
Binary search is poor at precise ranking but decent at coarse filtering. The true top 10 are almost always somewhere in the binary top 100 — they are just in the wrong order. Rescoring fixes the order using exact arithmetic. Typical result: recall rises from around 0.80 back to 0.95 or higher, while memory stays 32 times smaller. You pay a few milliseconds of disk latency for a very large saving.
[!] Binary quantization is model-dependent
Whether binary works well depends on how the embedding model distributes its values. Models trained with binary or Matryoshka objectives degrade gracefully; others fall apart. Test it on your own data before committing. Take 100,000 vectors, establish exact ground truth, and measure recall at several oversampling factors. Expect text and image embeddings to behave differently — in this workload DINOv3 image vectors generally need higher oversampling than EmbeddingGemma text vectors.
TurboQuant and the turbo4 datatype
The three modes above were the whole picture until recently. Qdrant has since added TurboQuant, and it changes the default advice, so it is worth understanding what it does differently rather than treating it as a fourth row in the table.
[def] TurboQuant, and why the rotation matters
TurboQuant is a quantization method developed by Google. Before compressing, it applies a fast random rotation to the vector, which spreads each dimension's information evenly across all coordinates. Because no single dimension carries a disproportionate share of the signal after rotation, one pre-computed quantization mapping works well across the whole dataset.
That is precisely the limitation binary quantization has. Binary needs a centred distribution and falls apart on models that don't provide one — which is why section 4.7's warning about model-dependence exists. The rotation removes that dependency, so TurboQuant works acceptably on any vector distribution.
[+] Asymmetric scoring, for free
TurboQuant compresses only the stored vectors; the incoming query is scored at full precision. You get the memory saving on 1.3 billion stored vectors while the one vector that could most damage recall — the query — loses nothing. This is automatic and needs no configuration.
| Setting | Compression | RAM (vectors + graph) | Where it fits |
|---|---|---|---|
| 4-bit TurboQuant | 8x | ~743 GB | Twice the compression of scalar int8 at comparable recall and speed. The best default. |
| 2-bit TurboQuant | 16x | ~460 GB | Comparable to 2-bit binary. Slower than binary, better recall. |
| 1.5-bit TurboQuant | 24x | ~390 GB | Intermediate point. Worth testing only if 1-bit misses your recall target. |
| 1-bit TurboQuant | 32x | ~321 GB | Same footprint as binary, better recall, slightly slower. Rescoring on by default. |
[!] A datatype is not the same thing as quantization
These are two separate mechanisms that are easy to confuse, and the distinction matters for the two-stage memory tiering in section 4.18.
- A datatype changes how the original vectors are stored. Setting datatype="turbo4" means there is no float32 copy anywhere — the original is the 4-bit representation, at one-eighth the size.
- Quantization builds a second, smaller representation alongside the originals. The compact copy drives the initial search; the originals remain available for rescoring.
[+] The combination that makes this interesting
You can use both at once: store originals as turbo4 and add 1-bit TurboQuant on top. The 1-bit index does the fast candidate search, then rescoring happens against 4-bit vectors rather than float32 ones. Rescoring is slightly less precise than it would be against full precision, but the thing being read from disk is eight times smaller — which, when rescoring is disk-bound, makes it faster as well as cheaper. Section 4.18 works through when that trade is worth making.
[retail] One caveat for the image collection
TurboQuant is SIMD-accelerated for Cosine, Dot and Euclidean distance. Manhattan (L1) is supported but requires reconstructing each vector to compare it, which is dramatically slower. Both collections here use cosine, so this is a non-issue — but it is worth knowing before choosing a distance metric on a future collection, because the metric choice quietly constrains the compression options available later.
What you actually have to deploy
A reasonable worry at this point, especially for anyone who has operated product quantization: if TurboQuant is a Google method with a rotation and a codebook, where does that codebook come from? Is there a model to install next to Qdrant, or a training job to run over the catalogue before anything can be written? Neither. There is nothing to install and nothing to fit.
[+] It is an algorithm in the binary, not a model beside it
TurboQuant ships inside Qdrant. You keep sending ordinary float32 vectors exactly as before and set a bit depth in the collection config; the server compresses on ingest. The entire surface you configure is bits and where the quantized copy lives. There is no artifact to version, no fit step to schedule, and nothing to keep in sync between your pipeline and the database.
[def] Why there is nothing to train
Both halves of the method are fixed in advance, and each is fixed for a different reason.
- The rotation is seeded by constants. It is generated from three hardcoded seeds and depends only on the vector's dimensionality, so every Qdrant instance produces byte-identical output for the same input. That is what makes quantized vectors portable between nodes at all — if the rotation were random per collection, it would have to be persisted and shipped alongside the data.
- The codebook is analytic. The rotation leaves the coordinates approximately Gaussian, so the right codebook is the one that is optimal for a standard normal — and that has a closed form. The 1-bit centroid is ±√(2/π), or about 0.7979, which is simply the mean of a standard normal's positive half. The 2-bit and 4-bit tables are the Lloyd-Max solutions for the same distribution. None of it depends on your data, all of it can be a constant.
This is the substantive operational difference between TurboQuant and product quantization, and it is easy to miss because both are described as “codebook” methods. PQ's codebook is learned from your vectors: at the 48-slice configuration from 4.4, it is roughly 200,000 floats derived by k-means on a sample of your catalogue. TurboQuant's is sixteen numbers that are the same for every collection in the world.
[!] What a learned codebook costs you operationally
The difference is not really the memory the codebook occupies — 200,000 floats is under a megabyte. It is that a learned codebook is state derived from a snapshot of your data. It has to be trained before the first vector can be stored, persisted with the index, and kept consistent with it forever; lose it and every stored code becomes meaningless. It also drifts: a codebook fitted on last year's catalogue slowly stops describing this year's, so recall decays quietly and the fix is a retrain plus a full re-encode. A constant codebook has none of these properties, which is worth more in production than the compression difference.
[retail] What this means for the load pipeline
Concretely, for the bootstrapping sequence in 4.14: there is no training stage to slot in. You create the collection with the quantization config already set, then start writing float32 vectors, and they are compressed as they land. Compare this with an IVF-PQ deployment, which needs a representative sample loaded and a k-means pass completed before the first production write — a genuine ordering constraint on the whole migration. Changing bit depth later still requires re-encoding the collection, because the stored codes change; but that is a re-encode, not a retrain.
4.8 Matryoshka truncation
Quantization reduces the bits per dimension. Matryoshka representation learning reduces the number of dimensions, and it applies to your text embeddings specifically.
[hist] 2022: Matryoshka representation learning
Normally, truncating an embedding destroys it — information is spread evenly across all dimensions, so cutting half discards half the meaning. MRL, published in 2022, changes the training objective so the model packs the most important information into the earliest dimensions. The result nests like the Russian dolls it is named after: the first 128 numbers are a usable embedding, the first 256 are a better one, and all 768 are best. You can truncate after the fact with no retraining and no re-encoding.
[+] EmbeddingGemma supports this; DINOv3 does not
This is an asymmetry worth exploiting. EmbeddingGemma-300M was trained with MRL and officially supports truncation to 512, 256 and 128 dimensions. DINOv3 was not — its 1024 dimensions are indivisible, and truncating them will degrade quality badly. So your titles collection can shrink in a way your images collection cannot. Plan them separately rather than applying one policy to both.
| Dimensions | Vector bytes | Plus graph | Titles RAM | Typical quality retained |
|---|---|---|---|---|
| 768 (full) | 768 B | 906 B | 725 GB | 100% |
| 512 | 512 B | 650 B | 520 GB | ~99% |
| 256 | 256 B | 394 B | 315 GB | ~97% |
| 128 | 128 B | 266 B | 213 GB | ~93% |
[retail] Product titles are short, which helps
A product title is typically 5 to 15 words: "Nike Air Zoom Pegasus 41 Men's Running Shoe, Black, UK 9". That carries far less semantic complexity than a paragraph of prose, so it survives dimensional reduction unusually well. Truncating titles to 256 dimensions is a very different proposition from truncating long product descriptions or customer reviews. Measure on your own data, but expect short text to tolerate this better than the general benchmarks suggest.
[!] Renormalise after truncating
Cutting dimensions changes a vector's length. If you use cosine distance, you must re-normalise the truncated vector to unit length or your distances will be subtly wrong — and wrong in a way that produces plausible-looking but degraded results, which is the hardest kind of bug to notice. Do this once at ingest, not per query.
4.9 The full capacity plan
Combining sections 4.6 to 4.8, here is the recommended configuration and the reasoning behind each choice.
| Decision | Titles (800M) | Images (500M) |
|---|---|---|
| Stored dimensions | 256 (MRL truncated from 768) | 1024 (no truncation possible) |
| Quantization in RAM | Binary, with rescoring | Binary, with rescoring |
| Full-precision originals | On NVMe, for rescoring | On NVMe, for rescoring |
| m | 16 | 16 |
| ef_construct | 200 | 256 |
| Oversampling | 3x | 4x |
TITLES 256 dims / 8 = 32 B binary + 138 B graph = 170 B/vector
x 800,000,000 = 136 GB
IMAGES 1024 dims / 8 = 128 B binary + 138 B graph = 266 B/vector
x 500,000,000 = 133 GB
TOTAL RAM = 269 GB
NVMe (float32 originals for rescoring) = 4.5 TB
[+] From 4,685 GB to 269 GB
A 17-fold reduction, taking the cluster from roughly 19 memory-bound nodes to 2 or 3. Note what happened to the composition: the HNSW graph is now 180 of those 269 GB — two thirds of your memory is pointers, not vector data. Further quantization would barely help. If you needed to shrink again, the only remaining lever is reducing m, and that costs recall directly.
[!] What this plan does not include
Add these before sizing hardware, or you will under-provision:
- Payload storage. Market, category, price, availability per point. Budget 100 to 300 bytes per vector, and keep it on disk rather than RAM unless you filter on it constantly.
- Replication. A replication factor of 2 doubles everything. Non-negotiable for a customer-facing service.
- Operating headroom. Segment merges temporarily need extra space. Target no more than 70% steady-state memory use.
- Growth. Size for 18 months of catalogue expansion, not today's count.
index + graph 269 GB
payload (~200 B/vector) 260 GB (disk-backed, partially cached)
-------
working set 529 GB
x2 replication 1,058 GB
/0.70 headroom 1,512 GB
=> 6 nodes at 256 GB RAM, each with ~2 TB NVMe
4.10 Four markets: the core architecture decision
You have Canada, the United States, Mexico and Chile. Do you build four collections, or one collection with a market field in the payload? This is the decision that is most expensive to reverse, so it deserves the most careful thought.
There is a third option most teams do not know about, and it is usually the right one. Take the two obvious options first.
Option A: four separate collections
titles_ca, titles_us, titles_mx, titles_cl
- Complete isolation between markets
- Each HNSW graph contains only that market's vectors
- No filtering needed at query time
- Drop a market by deleting one collection
- Different configuration per market is possible
Option B: one collection, market in payload
titles with payload.market
- One collection to operate and monitor
- Cross-market search is trivial
- Shared products stored once
- Simpler client code
- Requires a filter on every single query
The problem with Option B that is not obvious
Filtering in a vector database is not like filtering in SQL. In Postgres, a WHERE market = 'MX' narrows the search and makes it faster. In an HNSW index, filtering can make it dramatically slower, and the reason is worth understanding properly.
[!] Why filtering degrades graph search
The HNSW graph was built by connecting each vector to its nearest neighbours regardless of market. When you search with a Mexico filter, the traversal keeps landing on US and Canadian vectors, which fail the filter and must be discarded — but the walk still had to visit them to discover their neighbours.
If Mexico is 8% of your catalogue, roughly 92% of the graph traversal is wasted work. Worse, the filtered subgraph may be disconnected: the walk can reach a region where every neighbour is filtered out, dead-end, and return poor results. Recall drops silently. This is the single most common performance surprise in production vector search.
[+] Qdrant's answer: filterable index with tenant optimisation
Qdrant handles this specifically. Declare a payload field as a tenant key and Qdrant will physically group points by that value on disk and add extra HNSW links between same-tenant vectors, on top of the global graph it builds anyway. You get one logical collection with much of the isolation benefit of separate ones, and cross-market search still works. This is the third option, and for four markets it is usually the correct answer.
How to choose, concretely
| Consideration | Separate collections | Single + tenant key |
|---|---|---|
| Query latency, single market | Best. No filter at all. | Near-equal once tenant-optimised. |
| Cross-market search | Four queries, merge client-side, scores not directly comparable. | One query, drop the filter. |
| Small markets (Chile) | A tiny graph gets poor recall and wastes a shard's overhead. | Benefits from sharing the larger structure. |
| Operational surface | 4 collections x 2 modalities = 8 things to monitor, migrate, back up. | 2 collections. |
| Per-market configuration | Different m or dimensions per market if genuinely needed. | One configuration for all. |
| Data residency / legal isolation | Physically separable, deletable, auditable. | Possible via shard placement, but harder to prove to an auditor. |
| Adding a fifth market | New collection, new pipeline, new dashboards. | Just start writing points with the new tenant value. |
| Duplicate products across markets | Stored and indexed once per market. | Can be stored once with a list of markets. |
[retail] Recommendation for this workload
One collection per modality, with market as a tenant key. So two collections total: product_titles and product_images, each partitioned internally by market.
The reasoning specific to your situation:
- Your markets are wildly uneven. The US will dominate; Chile might be 2% of the catalogue. A separate Chilean collection would hold a small graph with measurably worse recall, and you would still pay per-shard overhead for it.
- Products genuinely overlap. The same global brands appear in all four markets. Separate collections means embedding and storing them four times — paying for the same vector repeatedly.
- Cross-market queries are a real requirement. Catalogue teams want "is this product already listed anywhere?" for deduplication and supplier matching. Under Option A that is four queries with scores you cannot directly compare.
- Eight collections is a genuine operational burden. Every re-index, backup, schema change and alert rule multiplies by eight.
[!] When to overrule this
Choose separate collections if data residency law requires it — if Mexican data must demonstrably never leave a specific region, physical separation is far easier to defend in an audit than shard-placement rules. Also separate if one market needs a different embedding model, since vectors from different models cannot share a collection or be meaningfully compared. Both are real situations; neither is the default.
[i] The general principle
Separate collections when tenants need different treatment — different models, configurations or legal handling. Use a tenant key when tenants need different data but identical treatment. Four markets running the same models with the same settings is squarely the second case. The instinct to separate usually comes from thinking about isolation rather than measuring it.
Creating the collection
The configuration that implements everything decided so far: two collections, market as a tenant key, binary quantization with originals on disk for rescoring.
from qdrant_client import QdrantClient, models
client = QdrantClient(url="http://qdrant:6333", timeout=120)
# --- Titles: 800M vectors, MRL-truncated 768 -> 256 -------------------
client.create_collection(
collection_name="product_titles",
vectors_config=models.VectorParams(
size=256,
distance=models.Distance.COSINE,
# Keep float32 originals on disk. RAM holds only the binary
# codes; these are read back for rescoring the candidates.
on_disk=True,
),
quantization_config=models.BinaryQuantization(
binary=models.BinaryQuantizationConfig(always_ram=True),
),
hnsw_config=models.HnswConfigDiff(
m=16,
ef_construct=200,
# Also build per-tenant graph links, so a market-filtered
# search traverses mostly-relevant neighbours. The global
# graph stays (m=16): with only four markets, unscoped
# queries still need a path. See 4.19 for when to drop it.
payload_m=16,
),
optimizers_config=models.OptimizersConfigDiff(
# 1-2 GB segments. Too small = many files; too large = slow merges.
max_segment_size=2_000_000,
default_segment_number=0,
memmap_threshold=100_000,
),
shard_number=12,
replication_factor=2,
on_disk_payload=True,
)
# --- The tenant index. This is the important line. -------------------
#
# is_tenant=True tells Qdrant to physically co-locate points that
# share a market value, so a filtered search walks a graph that is
# mostly relevant instead of discarding 90% of what it visits.
# Without this, single-collection multitenancy performs badly.
client.create_payload_index(
collection_name="product_titles",
field_name="market",
field_schema=models.KeywordIndexParams(
type="keyword",
is_tenant=True,
),
)
# Ordinary filter indexes for the other fields we query on.
for field, schema in [
("category_id", "keyword"),
("in_stock", "bool"),
("price_cents", "integer"),
]:
client.create_payload_index(
collection_name="product_titles",
field_name=field,
field_schema=schema,
)
# --- Images: identical shape, but 1024 dims and no truncation --------
client.create_collection(
collection_name="product_images",
vectors_config=models.VectorParams(
size=1024, # DINOv3 has no MRL: cannot truncate
distance=models.Distance.COSINE,
on_disk=True,
),
quantization_config=models.BinaryQuantization(
binary=models.BinaryQuantizationConfig(always_ram=True),
),
hnsw_config=models.HnswConfigDiff(
m=16, ef_construct=256, payload_m=16,
),
shard_number=8,
replication_factor=2,
on_disk_payload=True,
)
[!] Two settings that are easy to get wrong
- payload_m=16 alongside m=16. This adds per-tenant graph links while keeping the global graph. Setting m=0 would remove the global graph entirely and build only per-tenant sub-graphs — a bigger win, but it breaks any query that doesn't filter by market, so section 4.19 treats it as a separate decision. Note the field is m: there is no m0 on HnswConfigDiff, and an unknown keyword here fails rather than silently doing nothing.
- always_ram=True on the quantized vectors, on_disk=True on the originals. These work as a pair. Invert them by accident and you will hold 4.5 TB in RAM and read binary codes from disk — precisely backwards, and the cluster will either refuse to start or thrash.
So is the index global, or per market?
A question worth stopping on, because the answer decides whether cross-market search still works. If the tenant key makes Qdrant build a separate graph for each market, then a query that spans markets has four graphs to deal with and no obvious way to combine them. If instead the index stays global, it is not clear what the tenant key bought you. Neither answer is quite right.
[+] It is one graph, with extra edges
Qdrant builds the ordinary global HNSW graph first, exactly as it would without a tenant key. It then makes a second pass over each market, connecting that market's points to each other, and merges those links into the same graph. Every point ends up with one link list holding both kinds of edge: its global nearest neighbours, whatever market they belong to, plus extra links to nearby points in its own market. Nothing is removed and nothing is partitioned away.
That single design answers both questions at once. A market-filtered search follows the same-market edges and rarely wastes a hop, because at every node there is a neighbour that will survive the filter. An unfiltered, cross-market search ignores those extra edges entirely and walks the global ones, behaving exactly as it would in a collection with no tenant key at all.
[def] Why cross-market search costs nothing extra
There is no fan-out and no merge step. The engine does not search four graphs and combine four result sets — there is only one graph, so a cross-market query is an ordinary HNSW traversal. The tenant edges are simply additional entries in the link lists it walks past. The only cost is memory: those extra links are real links, and they are counted by the same 2 × m × 4 bytes arithmetic from 4.6. You are buying filtered-search performance with RAM, not with query complexity.
[≡] A useful mental model
Think of one road network carrying two classes of road. The global links are the international highways; the tenant links are the domestic roads inside each country. A domestic journey stays on domestic roads and never leaves the country. An international journey uses the highways. Same map, two overlaid layers, and no need to decide up front which kind of journey you are making.
[!] The one setting that does remove the global graph
All of the above assumes m > 0, which is the default and the right choice here. There is a configuration that makes the index genuinely per-tenant — setting m=0 so the global pass never runs, leaving only the per-market links. At that point cross-market search really does break: there are no global edges to traverse, and an unfiltered query has nothing to walk. That trade is occasionally worth making, but only for collections with hundreds or thousands of tenants where no query ever spans them. Section 4.19 covers it, and why four markets is not that case.
[retail] One detail that reveals the intent
For an ordinary indexed payload field, Qdrant checks whether the main graph is already well enough connected for that field's values and skips adding extra links if it is — they would not earn their memory. For a field declared with is_tenant=True, it never skips. Marking a field as a tenant key is a statement that essentially every query will filter on it, and Qdrant takes that promise literally. Which is the real reason to reserve the flag for market and not to sprinkle it across every field you happen to filter on.
4.11 Sharding and replication
A shard is a horizontal slice of a collection that lives on one node and holds its own independent HNSW graph. Sharding is how you spread 800 million vectors across machines.
[def] How a sharded query works
A search is sent to every shard in parallel. Each returns its local top k, and the coordinating node merges those into a global top k. This means query latency is governed by your slowest shard, not the average — a single overloaded node degrades every query in the collection.
Choosing a shard count
| Too few shards | Too many shards |
|---|---|
| Each shard is huge; graph build takes days and a rebuild is painful. | Fixed per-shard overhead multiplies; more network round-trips per query. |
| Cannot spread load evenly across nodes. | Each shard's graph is small, which slightly reduces recall. |
| A single node failure loses a large fraction of the data. | Merge and optimisation work happens more often. |
[+] A workable rule
Target 50 to 100 million vectors per shard. That keeps each graph buildable in hours rather than days and each shard small enough to move between nodes when rebalancing. For 800M titles that gives 8 to 16 shards; the configuration above uses 12. For 500M images, 8 shards. Also make the count divisible by your likely node count so shards distribute evenly.
[!] Shard count is fixed at creation
You cannot reshard a Qdrant collection in place without migrating data. Choose with 18 months of growth in mind. Over-provisioning shards slightly is much cheaper than the migration you will otherwise run — erring toward 16 rather than 8 costs a little overhead and buys you room.
Shard keys, and the trap in using market as one
Qdrant lets you route points to specific shards by a payload value, called a shard key. It is tempting to shard by market. Resist it.
[retail] Why market is a bad shard key
Your markets are very uneven. Suppose the split is US 60%, MX 20%, CA 15%, CL 5%:
shard_us 480M vectors <- overloaded, slow, the bottleneck
shard_mx 160M vectors
shard_ca 120M vectors
shard_cl 40M vectors <- nearly idle, wasting a node
Because query latency follows the slowest shard, US queries drag on while the Chilean node sits idle. You cannot scale them independently, and a US traffic spike cannot borrow the spare capacity. Shard by a hash of the point ID for even distribution, and use the tenant key from section 4.10 for market isolation. Sharding and tenancy are different mechanisms solving different problems — conflating them is a common and costly mistake.
[i] Replication
A replication factor of 2 keeps two copies of every shard on different nodes. It doubles memory but buys survival of a node failure and lets reads spread across replicas. For anything customer-facing, treat it as mandatory. Set write_consistency_factor=1 for ingest throughput, accepting that a replica may briefly lag — for a product catalogue that is almost always the right trade.
4.12 Metadata filtering
Section 4.10 introduced why filtering hurts graph search. This section covers what to do about it, because in a real storefront almost every query is filtered: in stock, ships to this region, within this category, above this rating.
The three filtering strategies
| Strategy | Method | Fails when |
|---|---|---|
| Post-filter | Search first, discard non-matching results afterwards. | The filter is selective. Ask for 10, get 200 back, 3 survive. You silently return fewer results than requested. |
| Pre-filter | Find all matching IDs first, then brute-force search only those. | The filter is broad. Matching 400M points means comparing against 400M vectors. |
| Filtered traversal | Apply the filter during graph walk, skipping non-matching neighbours. | Very selective filters fragment the graph and recall degrades. |
[+] What Qdrant actually does
Qdrant estimates the filter's selectivity from its payload index and picks a strategy per query. If the estimated size of the matching subset falls below full_scan_threshold, it brute-forces that subset, which is both faster and exact. Above that, it does filtered graph traversal. This is why setting that threshold correctly matters so much — it is the switch between two very different execution plans. Note the threshold is measured in kilobytes of vector data, not in points (4.5), so the point count it corresponds to changes whenever you change dimensionality or quantization.
[!] The cardinality trap
The dangerous zone is a filter matching roughly 0.1% to 5% of the collection: too many points to brute-force, too few for the graph to stay well-connected.
A concrete example: market=CL AND category=garden_furniture AND in_stock=true might match 40,000 points out of 800 million. Graph traversal will wander through vast regions where nothing matches, and recall can collapse from 0.95 to below 0.5 with no error raised. The query succeeds. It just quietly returns worse results.
[retail] Practical rules for filter design
- Always index fields you filter on. Without a payload index Qdrant cannot estimate cardinality and will choose badly. This is the most common cause of mysteriously slow filtered queries.
- Make the tenant key do the heavy lifting. Market is handled structurally, so your remaining filters operate within one market's subgraph and are far less selective relative to it.
- Raise full_scan_threshold so it covers roughly 20,000 points. Because the unit is kilobytes, that is 15000 for int8 titles and 20000 for int8 images (4.5). Brute-forcing 20,000 quantized vectors takes single-digit milliseconds and gives exact results — better than a degraded graph walk.
- Prefer booleans over ranges where you can. An indexed in_stock boolean is cheaper to evaluate than stock_count > 0.
- Measure recall with your real filters. Recall measured on unfiltered queries tells you almost nothing about production, where nearly every query carries a filter.
4.13 Two modalities, two collections
You have text vectors from EmbeddingGemma and image vectors from DINOv3. A recurring question is whether these can share a collection. They cannot, and the reason is worth stating precisely.
[!] Vectors from different models are not comparable
Each model learns its own space during its own training. Dimension 42 of EmbeddingGemma and dimension 42 of DINOv3 have no relationship whatsoever. A cosine distance between them is arithmetically computable and semantically meaningless. They are also different lengths — 256 after truncation versus 1024 — so Qdrant would reject them in a single named vector anyway. This is not a limitation of the database; it is a property of embeddings.
[+] Named vectors: one point, several embeddings
Qdrant does support multiple named vectors on the same point, each with its own size, distance metric and index. That is genuinely useful when one entity has several representations — but each named vector still gets its own HNSW graph, so you pay the same memory as separate collections. The benefit is a shared payload and a single point ID, not a saving.
| Consideration | Two collections | One collection, named vectors |
|---|---|---|
| Counts differ (800M vs 500M) | Natural. Not every product has an image. | Awkward. 300M points carry a null image vector. |
| Independent re-indexing | Re-embed images without touching titles. | Coupled. Touching one risks the other. |
| Different update rates | Titles change often, images rarely. | Same point rewritten for either change. |
| Payload duplication | Market and category stored twice. | Stored once. |
| Combined text-and-image ranking | Two queries, fuse in the application. | Server-side fusion available. |
[retail] Recommendation: two collections
The decisive argument is the count mismatch and the update-rate mismatch. Only about 62% of your products have image embeddings, and images are re-embedded on a completely different schedule from titles — typically when the vision model is upgraded, which is rare, versus titles changing whenever merchandising edits a product name. Coupling them into one collection means every title correction rewrites a point carrying a 1024-dimension image vector that did not change. Keep them separate and join on product_id in the application.
4.14 Bootstrapping 1.3 billion vectors
Initial load is the operation most likely to go badly, because the naive approach is both slow and produces a worse index than necessary.
[!] The mistake almost everyone makes first
Creating the collection with indexing enabled and then streaming points in. Qdrant dutifully builds and rebuilds the HNSW graph as data arrives, merging segments continuously. You end up paying graph-construction cost many times over, and the ingest slows down as the collection grows. At 1.3 billion vectors this is the difference between roughly two days and over a week.
The correct sequence
- Create the collection with indexing disabled Set indexing_threshold=0. Qdrant now accepts points as plain storage without touching the graph. Ingest runs at full speed and stays at full speed.
- Upload in parallel, in batches Many workers, each sending batches of a few hundred points. Details in section 4.15.
- Verify the count before indexing Check that the point count matches your source. Discovering a gap after a 14-hour index build is a bad day.
- Enable indexing and let it build once Set indexing_threshold back to 20000. Qdrant builds each segment's graph once, in parallel across segments and shards. One pass, correct result.
- Wait for green, then measure recall Poll until collection status is green. Then run your recall benchmark before sending production traffic.
[+] Why this is dramatically faster
Building an HNSW graph over a complete dataset is far more efficient than incrementally maintaining one during ingest, and it produces a better-quality graph as well, because each insertion sees the full data distribution rather than only what arrived before it. You get a faster load and higher recall from the same hardware.
from qdrant_client import QdrantClient, models
client = QdrantClient(url="http://qdrant:6333", prefer_grpc=True)
COLLECTION = "product_titles"
def set_indexing(enabled: bool) -> None:
"""Toggle HNSW construction.
threshold=0 means "never index", which is what makes bulk load fast.
Restoring it to 20000 triggers a single clean build per segment.
"""
client.update_collection(
collection_name=COLLECTION,
optimizer_config=models.OptimizersConfigDiff(
indexing_threshold=20_000 if enabled else 0,
),
)
# 1. Turn indexing off before the first point arrives.
set_indexing(False)
# 2. ... run the parallel loaders from section 4.15 here ...
# 3. Verify before paying for the build.
expected = 800_000_000
actual = client.count(COLLECTION, exact=True).count
if actual != expected:
raise SystemExit(f"aborting: {actual:,} loaded, expected {expected:,}")
# 4. One build pass.
set_indexing(True)
import time
def wait_until_green(poll_seconds: int = 60) -> None:
"""Block until Qdrant reports the collection fully indexed.
Expect hours at this scale. Log progress rather than waiting
silently -- an operator watching a blank terminal for 14 hours
will eventually restart something they should not.
"""
while True:
info = client.get_collection(COLLECTION)
if info.status == models.CollectionStatus.GREEN:
print("indexing complete")
return
print(
f"status={info.status} "
f"indexed={info.indexed_vectors_count:,} "
f"of {info.points_count:,}"
)
time.sleep(poll_seconds)
wait_until_green()
[retail] Realistic bootstrap timings
For 800M titles on a 6-node cluster, expect roughly 6 to 10 hours to upload with well-tuned parallelism, then 10 to 16 hours for the index build. Plan a two-day window and run it against a staging cluster first with 1% of the data — 8 million vectors — to validate the whole pipeline end to end. Extrapolating timings from that 1% sample is accurate enough for planning and costs an hour instead of two days.
4.15 Batch upserts and incremental updates
After bootstrap, the system lives in a steady state of change: new products arrive, titles get corrected, items go out of stock, whole categories get re-merchandised. How you batch those writes determines both throughput and whether search stays available while they land.
[def] Upsert
An upsert inserts a point if its ID is new and replaces it if the ID exists. Qdrant has no separate insert and update — there is only upsert. This makes writes idempotent: re-sending the same batch after a timeout is safe, which matters enormously when a pipeline retries.
Choosing a batch size
The most common tuning question, and the answer is less obvious than "bigger is better".
| Batch size | Payload per request | Behaviour |
|---|---|---|
| 1 – 10 | < 10 KB | Round-trip overhead dominates. Throughput collapses. |
| 64 – 256 | ~64 – 256 KB | The sweet spot for most clusters. |
| 1,000 – 5,000 | 1 – 5 MB | Fine for bulk load with few workers. Long lock times. |
| > 10,000 | > 10 MB | Timeouts, memory spikes, and a failed batch loses a lot of work. |
[+] The rule that actually matters
Parallelism beats batch size. Sixteen workers each sending batches of 256 will comfortably outperform one worker sending batches of 4,096, because Qdrant parallelises across shards and a single writer cannot saturate the cluster. Tune worker count first, batch size second. Aim for a request payload of roughly 1 to 4 MB and set worker count to about two per CPU core on the client.
[!] Never use wait=True in a bulk pipeline
wait=True blocks until the write is committed to disk on all replicas. It is correct for a single interactive write where the user must see the result immediately, and catastrophic for bulk throughput — it can be 10 to 50 times slower. Use wait=False for pipelines and verify with a count afterwards.
from concurrent.futures import ThreadPoolExecutor, as_completed
from itertools import islice
import time
import uuid
from qdrant_client import QdrantClient, models
BATCH_SIZE = 256 # ~1 MB per request at 256 dims plus payload
WORKERS = 16 # roughly 2 per client CPU core
MAX_RETRIES = 5
client = QdrantClient(
url="http://qdrant:6333",
prefer_grpc=True, # notably faster than HTTP for bulk work
timeout=120,
)
def chunked(iterable, size):
"""Yield lists of `size` items without materialising the source."""
it = iter(iterable)
while batch := list(islice(it, size)):
yield batch
def stable_id(product_id: str, market: str) -> str:
"""Derive a deterministic point ID from the business keys.
This is what makes the pipeline idempotent: a replayed batch
overwrites the same points instead of duplicating them. Never use
uuid4() here -- a retry after a timeout would double-insert.
"""
return str(uuid.uuid5(uuid.NAMESPACE_URL, f"{market}/{product_id}"))
def upsert_batch(rows: list[dict]) -> int:
"""Upsert one batch, retrying with exponential backoff.
Raises only after MAX_RETRIES, so a transient node restart or a
brief GC pause does not kill a multi-hour job.
"""
points = [
models.PointStruct(
id=stable_id(row["product_id"], row["market"]),
vector=row["vector"],
payload={
"product_id": row["product_id"],
"market": row["market"], # the tenant key
"category_id": row["category_id"],
"in_stock": row["in_stock"],
},
)
for row in rows
]
for attempt in range(MAX_RETRIES):
try:
client.upsert(
collection_name="product_titles",
points=points,
wait=False, # never block in a bulk pipeline
)
return len(points)
except Exception:
if attempt == MAX_RETRIES - 1:
raise
# Back off so a struggling cluster gets room to recover
# instead of being hammered by every worker at once.
time.sleep(2 ** attempt)
return 0
def load_all(rows) -> int:
"""Drive batches across a bounded thread pool.
Parallelism matters more than batch size: a single writer cannot
saturate a sharded cluster no matter how large its batches are.
"""
written = 0
with ThreadPoolExecutor(max_workers=WORKERS) as pool:
futures = [
pool.submit(upsert_batch, batch)
for batch in chunked(rows, BATCH_SIZE)
]
for future in as_completed(futures):
written += future.result()
if written % 1_000_000 < BATCH_SIZE:
print(f"{written:,} points written")
return written
Update patterns: pick the cheapest one that works
Not every change requires rewriting a vector. Recognising which kind of update you have saves an enormous amount of work.
| What changed | Operation | Cost |
|---|---|---|
| Stock status, price, ranking boost | set_payload | Very cheap. The vector and graph are untouched. |
| Product title edited | Re-embed, then upsert | Embedding cost plus a graph update for that point. |
| Product delisted | set_payload a soft-delete flag | Cheap. Filter it out at query time. |
| Product permanently removed | delete by filter | Leaves tombstones until the next segment merge. |
| Embedding model upgraded | Full re-index (section 4.16) | Days. Plan it as a migration. |
[+] The highest-value optimisation in this section
Only re-embed when the embedded text actually changed. Hash the title at ingest and store the hash in the payload. On the next run, compare hashes and skip anything unchanged. In a typical retail catalogue, a daily feed touches millions of rows but genuinely changes the title on only a small fraction — most edits are price, stock or imagery. Teams that skip this check re-embed the entire catalogue nightly and pay for GPU time they did not need.
[!] Deletes are not free
Qdrant marks deleted points as tombstones rather than removing them immediately. They keep consuming memory and are still traversed during search until an optimiser merges the segment. A large seasonal delisting can leave a collection bloated and slow. Watch the deleted-point count, and schedule merges after any bulk delete rather than assuming space returns on its own.
[retail] A realistic daily pipeline
02:00 pull the day's catalogue delta ~4M rows changed
02:20 compare title hashes ~180K genuinely new text
02:30 embed those 180K on GPU ~12 min
02:45 upsert vectors (16 workers, batch 256) ~3 min
02:50 set_payload for the other 3.8M rows ~8 min, no re-embedding
03:00 verify counts, sample recall
03:15 done
4.16 Re-indexing without downtime
Sooner or later you will need to rebuild everything: a better embedding model ships, you decide to change dimensions, or you need different HNSW parameters. At 1.3 billion vectors this is a migration, and it must happen while the current system keeps serving.
[!] What you cannot change in place
- Vector dimensions. Fixed at creation.
- Distance metric. Fixed at creation.
- Shard count. Requires data migration.
- m. Changing it only affects newly written points, leaving a collection with two different graph densities — worse than either.
The alias swap
The standard technique, and the reason to use aliases from day one rather than pointing your application at collection names directly.
- Your application never names a collection directly It queries the alias titles_live, which currently points at titles_v1. If you did not do this at the start, do it now — creating an alias for an existing collection is instant.
- Build the replacement alongside Create titles_v2 with the new configuration and bootstrap it using the procedure from 4.14. The live collection continues serving throughout. You need capacity for both simultaneously, which is the main cost of this approach.
- Catch up on the delta The build took hours, and the catalogue changed during it. Replay every update since the build started. This is why your pipeline should be idempotent and why points need an updated_at field.
- Validate before switching Run your recall benchmark against v2. Compare results for real production queries side by side. A model upgrade that improves benchmark scores can still rank your catalogue worse.
- Swap the alias atomically One API call repoints titles_live to titles_v2. No downtime, no deployment.
- Keep v1 for a while Storage is cheap relative to an incident. Retain it for a week so rollback is another single alias call, then delete.
[+] Why the alias indirection is worth it
Without aliases, switching collections means a code change and a deployment, and rolling back means another deployment while the site is degraded. With aliases, rollback is one API call taking milliseconds. This costs nothing to set up on day one and is painful to retrofit during an incident.
[retail] Run the shadow comparison
Before swapping, mirror a sample of live traffic to v2 and log both result sets without showing v2 to anyone. Compare overlap in the top 10, and have merchandising review the queries where they diverge most. A new embedding model frequently improves general benchmarks while regressing on your specific vocabulary — brand names, local terminology, category jargon. Two days of shadow traffic costs far less than discovering a search regression from a revenue dashboard.
4.17 Monitoring and failure modes
Vector databases fail in ways that do not raise errors. A degraded index returns results happily; they are simply worse. That makes monitoring unusually important, because your users will notice before your dashboards do unless you watch the right things.
| Signal | Healthy | What a bad value indicates |
|---|---|---|
| Measured recall@10 | At or above target | The one metric that catches silent quality loss. Run a fixed query set hourly against known ground truth. |
| p99 search latency | Stable | A rising p99 with a flat p50 usually means one slow shard, not general load. |
| Resident memory per node | Below 70% | Above 85% and segment merges will fail or the process gets killed. |
| Collection status | green | yellow means indexing is behind; red means a shard is unavailable. |
| Deleted point count | Low and falling | Growing tombstones bloat memory and slow traversal. Trigger a merge. |
| Unindexed vector count | Near zero | Points present but not in the graph are invisible to search. They exist and cannot be found. |
[!] The four failures that will actually happen
- Silent recall collapse after a filter change. Someone adds a selective filter, cardinality lands in the danger zone from 4.12, and recall halves with no error. Only a recall monitor catches this.
- Memory exhaustion during a merge. Merging needs temporary headroom. A node at 85% will fail mid-merge and may not recover cleanly. This is why the 70% target exists.
- Dimension mismatch after a model change. Someone upgrades the embedding model in the pipeline but not the collection. Every write fails, or worse, set succeeds with vectors from a different space.
- The unindexed backlog. Ingest outruns indexing, the queue grows, and recently added products silently do not appear in search. Merchandising reports "the new range is missing" and nothing is technically broken.
[+] The single alert worth having
If you build only one monitor, make it continuous recall measurement. Keep a fixed set of 1,000 queries with pre-computed exact ground truth, run them hourly, and alert when recall drops more than two points below target. Latency and memory alerts are standard infrastructure practice and you probably have them already. Recall is the one that is specific to this system, and it is the one that catches the failures nothing else will.
4.18 The optimisation runbook, in order
Everything up to here explained mechanisms one at a time. This section is the sequence: what to do first, what to measure, and how to stop — ending at the smallest, cheapest configuration that still meets a recall target you set deliberately rather than discovered by accident.
[!] Why order matters more than the individual techniques
Every optimisation in this chapter costs recall. Applied in the wrong order they interact: truncate dimensions and quantize aggressively at the same time, watch recall fall to 0.71, and you have no idea which change caused it or whether reverting one would have been enough. Change one variable at a time against a fixed benchmark, and each step gives you an answer you can act on.
Step 0: build the benchmark before touching anything
You cannot optimise against a number you don't have. This step is unglamorous, takes about a day, and is the reason the rest of the process works at all.
[def] What a usable recall benchmark actually needs
- A representative sample, not a random one
- Take 100,000 to 1 million vectors, sampled to match production: all four markets in roughly their real proportions, both head and long-tail products. A uniform random sample from one market's catalogue will tell you the wrong thing about the others.
- Real queries, not synthetic ones
- Pull 1,000 to 10,000 actual search strings from production logs. Real queries are short, misspelled, and full of brand names — embeddings behave differently on those than on the well-formed sentences you would invent. Include the filters those queries carried, because a recall number measured without filters is close to meaningless (4.12).
- Exact ground truth, computed once
- For each query, brute-force the true top-k against the sample with full-precision vectors and no index. This is slow — hours on CPU for a large sample — and that is fine, because you do it once and reuse it for every subsequent experiment. Store it as a file, not a database table, so it is trivially reproducible.
- A recall target you set before you start
- Decide the number in advance: "recall@10 ≥ 0.95 at p99 latency ≤ 50 ms." Choosing it afterwards means rationalising whatever you happened to get.
[+] Measure recall@k, and be precise about which k
recall@10 is the fraction of the true top 10 that your configuration actually returned, averaged across queries. It is not the same as recall@100, and the difference matters: a two-stage setup (4.7) can have poor recall@10 in its first stage yet excellent final recall@10 after rescoring, because the first stage only needs to get candidates into the top 100, not order them correctly. Measure the stage you care about, and measure the end-to-end pipeline separately.
[retail] Measure the business metric too, not just recall
Recall against brute-force ground truth measures fidelity to the embedding model's opinion — not whether customers found what they wanted. Keep a smaller set of human-judged query/product pairs alongside it. A configuration change that drops recall from 0.97 to 0.95 while leaving judged relevance flat is one you should probably ship; the reverse is one to revert regardless of what recall says.
The order to try things in
Each step below is cheaper or safer than the one after it. Run the benchmark after every step, and stop as soon as you are under your RAM budget with recall still above target — there is no prize for using every technique in the chapter.
| # | Step | Typical saving | Recall risk |
|---|---|---|---|
| 1 | Baseline: float32, no quantization, default HNSW — establish the ceiling and the true cost | — | None. This is the reference. |
| 2 | Tune ef at query time (4.5) — free, reversible, no re-index | Latency, not RAM | None — it is a per-query knob. |
| 3 | MRL dimension truncation (4.8, and the study below) — shrinks vectors and graph together | 2–3x | Low on short text. Measurable, monotonic, easy to reason about. |
| 4 | Moderate quantization: scalar int8 or 4-bit TurboQuant | 4–8x | Under 1% typically. The safe default. |
| 5 | Aggressive quantization with rescoring: 1-bit TurboQuant or binary | 32x | Large before rescoring, small after. Needs fast storage. |
| 6 | Two-stage memory tiering (below) — move the originals off RAM entirely | Most of the remainder | None to recall; costs latency, bounded by disk speed. |
| 7 | Structural: tenant indexing, per-tenant graphs (4.19) | Graph RAM, plus large latency wins | None if every query is tenant-scoped. Severe if any query is not. |
[!] Why dimension truncation comes before quantization
Truncation shrinks the HNSW graph as well as the vectors, and its effect on recall is smooth and predictable — each halving costs a little, and you can see exactly where the curve bends. Quantization error, by contrast, interacts with the embedding model's value distribution in ways that are hard to predict and vary by model. Do the predictable thing first so that when the unpredictable thing misbehaves, you know the dimension count wasn't the cause.
Worked example: the MRL truncation study
Section 4.8 gave the memory arithmetic for truncating EmbeddingGemma's 768 dimensions. This is how to actually run the study rather than trusting the table — and the shape of the answer, which is the part that generalises.
# Sweep MRL truncation dimensions against fixed ground truth.
# One variable changes: the dimension count. Everything else is held.
import numpy as np
def truncate(vectors, dim):
# MRL packs importance into the leading dimensions, so a prefix
# is a valid embedding. Renormalise: cosine assumes unit length.
cut = vectors[:, :dim]
return cut / np.linalg.norm(cut, axis=1, keepdims=True)
def recall_at_k(got, truth, k=10):
return np.mean([
len(set(g[:k]) & set(t[:k])) / k
for g, t in zip(got, truth)
])
for dim in [768, 512, 256, 128, 64]:
# Rebuild the index at this dimension, query, compare to the
# SAME float32 full-dimension ground truth every time.
got = search_all(truncate(corpus, dim), truncate(queries, dim))
print(dim, round(recall_at_k(got, ground_truth), 4))
[+] Reading the curve: look for the knee, not the best number
The output of a study like this is not a single winner — it is a curve, and the interesting feature is where it bends. For short text like product titles it typically stays almost flat from 768 down to 256, then falls away below that:
- 768 → 512: a fraction of a percent. Free.
- 512 → 256: a small, acceptable drop. Still comfortably above target.
- 256 → 128: the curve bends. Noticeably worse, and the RAM saved is now much smaller in absolute terms than the earlier halvings.
[!] Three ways this study goes wrong
- Forgetting to renormalise. Truncation changes vector length; cosine distance assumes unit vectors. Skip the renormalise and you measure a normalisation bug, not a truncation effect (4.8).
- Regenerating ground truth per dimension. Ground truth must stay fixed at full-precision, full-dimension. Recomputing it at each dimension measures self-consistency, which stays near 1.0 and tells you nothing.
- Testing one collection and applying the answer to both. DINOv3 was not trained with MRL, so this entire study is invalid for the image vectors — truncating them degrades quality sharply and no amount of measurement makes that acceptable.
Two-stage memory tiering: the last big RAM saving
By this point the vectors are as small as they are going to get. The remaining question is not how to compress further but which copy lives in RAM — and Qdrant lets you answer that separately for the compact representation and the originals.
[def] Memory tiers
Qdrant always stores vectors on disk; the memory setting controls what is additionally held in RAM. pinned keeps a copy resident, cached lets the OS page it in and out, and cold reads from disk on demand. Setting this separately for the original vectors and the quantized ones is what makes the two-stage arrangement possible.
| Originals | Quantized | RAM | When to use it |
|---|---|---|---|
| cached | pinned | Highest — both copies | The default. Fastest, and unaffordable at this scale. |
| cold | pinned | Compact copy only | The two-stage sweet spot. Search runs entirely in RAM; only the rescoring candidates touch disk. |
| cold | cold | Lowest | Very large collections on NVMe where latency matters less than cost. |
# Originals cold and stored as 4-bit; 1-bit index pinned in RAM.
# Stage 1 searches the pinned 1-bit index -- no disk, no float math.
# Stage 2 rescores candidates against the 4-bit originals from disk.
client.create_collection(
collection_name="product_titles",
vectors_config=models.VectorParams(
size=256,
distance=models.Distance.COSINE,
memory=models.Memory.COLD,
datatype=models.Datatype.TURBO4,
),
quantization_config=models.TurboQuantization(
turbo=models.TurboQuantQuantizationConfig(
bits=models.TurboQuantBitSize.BITS1,
memory=models.Memory.PINNED,
),
),
)
# Oversampling and rescoring are query-time knobs: tune them against
# the benchmark without rebuilding the index.
client.query_points(
collection_name="product_titles",
query=vector,
limit=10,
search_params=models.SearchParams(
quantization=models.QuantizationSearchParams(
rescore=True,
oversampling=3.0, # fetch 30 candidates, return 10
),
),
)
[!] Rescoring against turbo4 originals is a real trade, not a free win
Storing originals as turbo4 shrinks them eightfold, but they are no longer full precision — so the rescoring step, whose entire job is to correct the first stage's ranking with better arithmetic, is now doing so with 4-bit numbers. Usually that is still enough to recover most of the loss. Sometimes it isn't. This is exactly the configuration to A/B against your benchmark rather than assume, and the reason to keep float32 originals if your recall target is tight and disk is cheap.
[+] If rescoring becomes the bottleneck
With originals cold, every rescore is a disk read, and on high-latency storage the rescoring step can dominate the query. Three levers, in order: lower oversampling (fewer candidates to fetch), raise the bit depth of the quantized index so the first stage ranks better and needs less correction, or disable rescoring entirely and accept the first stage's ordering. The third is reasonable at 4-bit; it rarely is at 1-bit.
[retail] Where this workload lands
Following the sequence: MRL truncation to 256 dimensions, then 1-bit TurboQuant pinned in RAM with turbo4 originals cold on NVMe, with oversampling tuned to hit recall@10 ≥ 0.95. That is a two-order-of-magnitude reduction from the naive 4.7 TB in section 4.6 — and note that the two structural decisions doing most of the work, truncation and tiering, cost almost no recall. The aggressive quantization is doing less than it appears to.
4.19 Bootstrapping order and per-tenant indexing
Section 4.14 covered the load sequence and 4.10 covered tenant keys. This section is the part that only becomes visible once both are true at once: the interaction between them, and the switch that turns global indexing off entirely.
Why indexing order dominates the load
[def] The one-line version of 4.14, restated as a rule
indexing_threshold=0 during ingest, restore it afterwards. Building the graph once over a complete dataset is both faster and produces a better graph than maintaining one incrementally, because each insertion sees the full data distribution rather than only the points that happened to arrive earlier. At 1.3 billion vectors this is days, not hours, of difference.
[!] Payload indexes are the exception: create those first
Vector indexing should be deferred; payload indexing should not. Create payload indexes — especially the tenant index — before loading points, not after. Qdrant uses the tenant declaration to decide where points physically live on disk, so declaring it after the data is already written means the co-location benefit only arrives later, when the optimizer eventually rewrites those segments. Declaring it first means points land in the right place the first time.
Turning off global indexing
The tenant key in 4.10 makes filtered search cheaper by adding same-tenant links on top of the global graph. There is a stronger version of the same idea: stop building the global graph at all, and keep only the per-tenant links.
[def] m=0 plus payload_m
Setting m=0 in the HNSW config disables the global vector index for the collection — the main build pass is skipped entirely. Setting payload_m alongside it keeps the per-tenant pass, so each tenant's points are linked to each other and nothing else. With the global pass gone there is no longer a shared graph for every tenant's vectors to contend over, which removes the single biggest bottleneck in a many-tenant collection. Note the difference from 4.10: there the tenant links were added to a global graph, and here they are all that remains.
# Per-tenant graphs, no global index.
# Only correct if EVERY query filters on the tenant field.
client.create_collection(
collection_name="product_titles",
vectors_config=models.VectorParams(
size=256, distance=models.Distance.COSINE,
),
hnsw_config=models.HnswConfigDiff(
m=0, # no global graph
payload_m=16, # one graph per tenant instead
),
)
# Declare the tenant field BEFORE loading any points, so Qdrant
# co-locates each tenant's data on disk as it is written.
client.create_payload_index(
collection_name="product_titles",
field_name="market",
field_schema=models.KeywordIndexParams(
type=models.KeywordIndexType.KEYWORD,
is_tenant=True,
),
)
[+] Two separate wins, often confused
- is_tenant=True is about disk layout. It groups a tenant's points together physically, turning what would be many random seeks into sequential reads. This matters most when vectors are cold (4.18) — it is a disk optimisation.
- m=0 with payload_m is about graph structure. It removes the global graph and its RAM, and it makes indexing parallel across tenants rather than contended. This is an index optimisation.
[!] This breaks any query that isn't tenant-scoped
With no global graph, a query that omits the tenant filter has no efficient path through the data — the engine has nothing to traverse, and cross-tenant search degrades to a brute-force scan. This is precisely the capability that 4.10's default configuration preserves and that m=0 gives up. Before setting it, be certain that every query in the system filters on the tenant field, including the ones nobody thinks about: admin tools, analytics jobs, deduplication passes, the internal script someone wrote to spot-check embeddings. One unscoped query path is enough to make this the wrong configuration.
[retail] Four markets is not the case this was built for
Honest scoping: m=0 is designed for collections with hundreds or thousands of tenants, where per-tenant indexing turns one enormous serial graph build into many small parallel ones. With four large markets the calculus is different — the shared graph isn't the bottleneck, and each market is big enough that its sub-graph is substantial anyway.
For this workload: use is_tenant=True for the disk co-location, keep the global graph, and revisit m=0 only if the catalogue is later partitioned into something genuinely many-tenanted — per seller, say, or per fulfilment region. The general rule: the more tenants and the smaller each one, the more m=0 pays off.
The full sequence, start to finish
- Create the collection with vector indexing disabled indexing_threshold=0, with the quantization and memory tiers from 4.18 already configured. These are structural — changing them later means rebuilding.
- Create payload indexes, tenant field first Before any points are written, so co-location happens on the way in rather than during a later rewrite.
- Load in parallel batches Many workers, a few hundred points each (4.15). Ingest stays at full speed because nothing is building a graph yet.
- Verify counts against the source Before indexing, not after. Discovering a gap once the build has run costs the whole build.
- Restore indexing_threshold and let it build once Per segment, per shard, in parallel — and per tenant if m=0 is set.
- Wait for green, then run the 4.18 benchmark Against the real recall target, with real filters, before production traffic arrives. This is the last point at which a bad compression decision is cheap to undo.
4.20 Key takeaways
- Exact search does not scale. One brute-force query over 800M vectors streams 2.5 TB. Approximation is mandatory, not a shortcut.
- Recall is a dial, not a constant. Decide the target per surface. Dropping a search page from 0.99 to 0.95 is invisible to users and saves substantial memory.
- Bytes per vector determines your cluster. Multiply by 1.3 billion and you have your machine count. Everything else is detail.
- The HNSW graph costs 138 bytes per vector at m=16 and quantization does not shrink it. That is a 180 GB floor before any vector data.
- ef is free to change, m is not. Tune ef per query at runtime; changing m means a full rebuild.
- Binary quantization plus rescoring is the big lever. 32x less memory, with recall recovered by re-ranking a few hundred candidates read from NVMe.
- TurboQuant rotates before it compresses, which removes binary quantization's dependence on a centred value distribution. 4-bit is the sensible modern default; 1-bit matches binary's footprint with better recall. Because the rotation makes the coordinates Gaussian, its codebook is a set of constants rather than something learned — so unlike PQ there is nothing to train, install or keep in sync with the index.
- A datatype and a quantization are different things. datatype="turbo4" shrinks the originals themselves; quantization adds a second, smaller copy alongside them. The two-stage setup uses both.
- Matryoshka truncation applies to your text vectors only. EmbeddingGemma truncates to 256 cleanly; DINOv3 cannot be truncated at all.
- Together they take 4,685 GB down to 269 GB — about 19 nodes to 6 including replication and headroom.
- For four markets, use one collection with a tenant key. Uneven market sizes, shared products and cross-market queries all argue against separate collections.
- Separate only for legal residency or a different model per market. Those are the two genuine reasons.
- Never shard by market. Uneven shards mean the US node becomes the bottleneck while Chile idles. Shard by ID hash; isolate by tenant key.
- Filtering can silently destroy recall when a filter matches 0.1% to 5% of the collection. Index every filtered field and raise full_scan_threshold.
- Text and image vectors need separate collections. Different models produce incomparable spaces, and the counts and update rates differ anyway.
- Disable indexing during bulk load, then build once. Days versus weeks, and a better graph.
- Parallelism beats batch size. 16 workers at batch 256 outperforms one worker at 4,096. Never use wait=True in a pipeline.
- Hash the text and skip unchanged rows. Most catalogue updates change price or stock, not the title. Re-embedding everything nightly is pure waste.
- Use aliases from day one so re-indexing is an atomic swap and rollback is one API call.
- Monitor recall continuously. This system degrades without erroring, and recall is the only signal that catches it.
- Build the benchmark before the first optimisation, not after the third. Real queries, real filters, exact ground truth computed once at full precision, and a recall target chosen in advance.
- Change one variable at a time, in order: baseline, then ef, then MRL truncation, then moderate quantization, then aggressive quantization with rescoring, then memory tiering. Stop as soon as you are inside budget.
- Truncation before quantization, because its effect on recall is smooth and predictable while quantization error depends on the model's value distribution.
- Two-stage tiering is the last big saving: compact index pinned in RAM, originals cold on disk. It costs latency, not recall — a different currency from every other lever here.
- m=0 disables the global graph entirely and builds per-tenant sub-graphs instead. A large win for hundreds of small tenants, and the wrong choice for four large ones — and it breaks any query that isn't tenant-scoped.
[i] Vocabulary check
You should be able to explain: ANN, recall@k, HNSW, m, ef, ef_construct, scalar and binary quantization, TurboQuant, datatype versus quantization, rescoring, oversampling, memory tiers (pinned, cached, cold), Matryoshka truncation, ground truth, shard, replica, shard key, tenant key, payload index, cardinality, full_scan_threshold, segment, tombstone, upsert, idempotency, alias swap.
4.21 Interview drills
These are the questions asked of people who claim billion-scale vector experience. The distinguishing feature of a strong answer here is arithmetic — being able to size something on a whiteboard rather than gesturing at concepts.
1. Size a cluster for 800M 768-dim vectors. Talk me through it.
Two components per vector: the data and the graph. Float32 at 768 dimensions is 3,072 bytes. The HNSW graph at m=16 stores 2m neighbour IDs at 4 bytes, plus about 8% for upper layers, so roughly 138 bytes. Call it 3,210 bytes per vector, times 800 million, which is about 2.6 TB just for titles.
That is unaffordable, so I would quantize. Binary takes the vector part to 96 bytes, giving 234 per vector and about 187 GB. If the model supports Matryoshka I would truncate to 256 dimensions first, taking it to roughly 136 GB.
Then I would add payload, double it for replication and target 70% utilisation. In practice that lands around 6 nodes at 256 GB rather than the 19 the naive configuration needs.
What is being tested: whether you know the graph cost exists. Most candidates size the vectors and forget the 138 bytes, which is a 180 GB error at this scale.
2. Four markets. Separate collections, or one with a market field?
One collection per modality, with market as a tenant key. Not four collections, and not a plain payload filter either — the tenant key is the important detail.
A plain filter on a shared HNSW graph is slow, because the traversal keeps landing on points from other markets that fail the filter. Declaring the field as a tenant key makes Qdrant co-locate points by market and add same-market links on top of the global graph, so you get most of the isolation benefit without separate collections — and because those links are added rather than substituted, cross-market search still works normally.
I would go this way because the markets are very uneven — a separate Chilean collection would be a small graph with worse recall — products overlap across markets so separate collections would store them repeatedly, and cross-market deduplication queries are a real requirement.
Follow-up to expect: "When would you separate?" Data residency law, or a different embedding model per market. Both are genuine; neither is the default.
3. Why not shard by market? It seems natural.
Because a sharded query fans out to every shard and waits for the slowest one. If the US is 60% of the catalogue and Chile is 5%, the US shard becomes the bottleneck on every query while the Chilean node sits nearly idle.
You also cannot scale them independently, and a US traffic spike cannot use the spare capacity elsewhere. Shard on a hash of the point ID for even distribution, and handle market isolation with a tenant key. Sharding is about spreading load; tenancy is about data isolation. They are different problems and conflating them is a common mistake.
4. How does binary quantization keep acceptable recall?
On its own it does not — one bit per dimension typically gives recall around 0.80. It works because of two-stage retrieval.
Stage one searches the binary index but oversamples, asking for 100 candidates when you want 10. Binary distance is XOR plus popcount, so this is extremely fast. Stage two fetches the full-precision vectors for just those 100 from NVMe and re-ranks them exactly.
The insight is that binary is bad at ranking but adequate at coarse filtering. The true top 10 are almost always somewhere in the binary top 100, just misordered. Rescoring fixes the order and recall comes back to 0.95 or so, while memory stays 32 times smaller.
Add the caveat: it is model-dependent, so you validate on your own data. Image embeddings usually need higher oversampling than text.
5. Your filtered searches got slow after adding a category filter. Why?
Almost certainly filter cardinality landing in the bad zone. The graph was built connecting nearest neighbours regardless of category, so a selective filter means the traversal spends most of its time visiting points that get discarded.
Worse, the filtered subgraph can be disconnected: the walk hits a region where every neighbour fails the filter, dead-ends, and returns poor results with no error raised.
I would check whether the field has a payload index first — without one Qdrant cannot estimate cardinality and picks a bad plan. Then I would raise full_scan_threshold so that small result sets get brute-forced exactly instead of walking a fragmented graph. And I would measure recall with the filter applied, because unfiltered recall numbers tell you nothing here.
6. What is the difference between ef and ef_construct?
Both control how wide the search beam is, but at different times. ef_construct applies while building the graph and determines how thoroughly each new point looks for good neighbours. ef applies at query time and determines how thoroughly you search the finished graph.
The practical distinction is reversibility. ef is a runtime parameter you can set per query — high for a batch analytics job, low for an autocomplete dropdown, in the same collection. ef_construct is baked into the graph, so changing it means rebuilding. That is why I would rather set ef_construct generously at build time and tune ef afterwards.
7. Load 1.3 billion vectors as fast as possible. What do you do?
The main thing is to set indexing_threshold to 0 before loading anything, so Qdrant stores points without touching the HNSW graph. Then load with high parallelism, then re-enable indexing and let it build each segment once.
Without that, Qdrant continuously builds and rebuilds the graph as data arrives, and ingest slows down as the collection grows. At this scale that is roughly two days versus over a week — and the single-pass build actually produces a better graph, because each insertion sees the full data distribution.
For the upload itself: gRPC rather than HTTP, around 16 workers, batches of a few hundred, wait=False, and deterministic point IDs derived from business keys so retries are idempotent. I would verify the count before triggering the index build, because finding a gap after fourteen hours of indexing is expensive.
8. The embedding model is being upgraded. How do you migrate with no downtime?
Alias swap. The application queries an alias, never a collection name directly. I build the new collection alongside the old one, replay the updates that happened during the build, validate, then repoint the alias in a single atomic call.
Before switching I would shadow a sample of live traffic against the new collection and compare top-10 overlap, with merchandising reviewing the biggest divergences. A new model can improve public benchmarks while regressing on your own brand names and category vocabulary.
Then keep the old collection for a week so rollback is one API call rather than a deployment. The cost of this approach is needing capacity for both collections simultaneously.
9. Can text and image embeddings share a collection?
Not meaningfully. Each model learns its own space, so dimension 42 of the text model and dimension 42 of the vision model are unrelated. A distance between them computes fine and means nothing. They are also different lengths, so a single vector field would reject them anyway.
Qdrant does support multiple named vectors on one point, which is legitimate when an entity has several representations. But each named vector gets its own HNSW graph, so there is no memory saving — just a shared payload. In this case I would use two collections, because only about 62% of products have images and the two are re-embedded on completely different schedules. Coupling them means every title correction rewrites an image vector that did not change.
10. Recall dropped from 0.95 to 0.72 overnight. Nothing was deployed. Debug it.
First I would establish what "recall dropped" is measured against — if the ground-truth set is stale, the metric may be wrong rather than the system.
Then, in order of likelihood:
- Indexing fell behind. Check collection status and the unindexed vector count. Points that exist but are not in the graph cannot be found. A large overnight ingest is the usual cause.
- A bulk delete left tombstones. Deleted points still occupy the graph until a merge runs, degrading traversal.
- Filter cardinality shifted. If a seasonal range went out of stock, an in_stock filter that used to match 80% now matches 15%, and the filtered traversal degrades.
- Memory pressure. A node above 85% may have failed a merge and be serving from a fragmented state.
The unifying point is that none of these raise an error. This is exactly why continuous recall monitoring against a fixed query set is the alert worth building.
11. Why does quantization not reduce memory by the full 32x?
Because the HNSW graph is not vector data. At m=16 each point stores about 138 bytes of neighbour pointers, and pointers do not compress when you quantize the vectors they point between.
Concretely: 768-dim float32 is 3,072 bytes of vector plus 138 of graph. Binary takes the vector to 96 bytes, so you go from 3,210 to 234 — about 14x, not 32x. And after truncating to 256 dimensions the vector is only 32 bytes against 138 of graph, so the graph is now four fifths of the footprint. Past that point further quantization is nearly pointless; the only remaining lever is lowering m, which costs recall directly.
Why this gets asked: it separates people who have actually measured a cluster from people who have read a blog post about compression ratios.
12. When would you not use a vector database at all?
More often than the hype suggests:
- Under a million vectors. A pgvector extension on the Postgres you already run is simpler, transactional, and fast enough. Do not add a distributed system to avoid a sequential scan of 200,000 rows.
- The query is genuinely lexical. Part numbers, SKUs, exact model codes. BM25 beats embeddings here and always will — nobody wants fuzzy semantic neighbours of "SKU-88421".
- The corpus fits in memory in one process. A FAISS index in your application avoids a network hop entirely.
- You need strict transactional guarantees across vectors and business data in one commit.
A dedicated vector database earns its operational cost at scale, with high write rates, or when you need distributed replication. At 1.3 billion vectors that is clearly justified. At 100,000 it is resume-driven development.
13. Search p99 latency is bad but p50 is fine. Where do you look?
That specific shape — healthy median, bad tail — usually means the problem is not general load. A query fans out to every shard and completes only when the slowest replies, so the tail is dominated by whichever shard is struggling.
My order of investigation:
- Per-shard latency. If one shard is consistently slow, check whether it is on an overloaded node or holds more data than the others.
- Uneven shard sizes. Classic symptom of sharding on a skewed key such as market instead of an ID hash.
- Segment merges. An optimiser running on one node will slow it while it works.
- Filter variance. If only some queries carry a selective filter, those are the ones in your tail.
- Rescoring disk reads. If originals are on disk and page cache is cold, the tail reflects storage latency rather than search.
Median latency tells you about the common case; the tail tells you about your worst shard. They are different questions.
14. A merchandiser says a new product cannot be found, but it is in the database. Explain.
The likeliest cause is that the point exists in storage but is not yet in the HNSW graph. Qdrant will happily accept and store a point before indexing it, and an unindexed point is invisible to vector search while being perfectly retrievable by ID. Check the unindexed vector count and collection status — yellow means indexing is behind.
Other candidates worth ruling out:
- A filter is excluding it. If in_stock is false or the market value is wrong, it is filtered out before ranking.
- It is genuinely ranked below the cutoff. Present, findable, just not in the top 10 for that query — a relevance problem, not an infrastructure one.
- Written to the wrong collection or the old side of an alias swap.
The diagnostic that separates these quickly: retrieve the point by ID. If that works but search does not find it, it is indexing or filtering. If ID retrieval also fails, it went somewhere you did not expect.
15. Design the whole system. Ninety seconds.
Two collections, one for titles and one for images, because the models produce incomparable spaces and the two have different counts and update rates.
Market as a tenant key, not separate collections and not separate shards. The markets are uneven, products overlap, and cross-market queries are a real requirement.
Titles truncated to 256 dimensions using Matryoshka, which EmbeddingGemma supports. Images stay at 1024 because DINOv3 does not.
Binary quantization with rescoring: binary codes in RAM, float32 originals on NVMe, oversample 3 to 4x and re-rank. That is 269 GB instead of 4.7 TB.
Twelve shards by ID hash, replication factor 2, roughly six nodes at 256 GB with 2 TB NVMe each.
Bootstrap with indexing disabled, then one build pass. Aliases from day one so a model upgrade is an atomic swap.
And continuous recall monitoring, because everything here degrades silently rather than erroring, and recall is the only metric that catches it.
The structure that works: lead with the two or three decisions that are expensive to reverse, then the numbers, then operations. Do not start with HNSW parameters — they are the least consequential thing on the list.
Where this leaves you
You can now size a billion-scale vector cluster from first principles, justify every parameter you set, and answer the four-market question with reasoning rather than instinct. That is the infrastructure layer of a semantic search system.
What it does not give you is quality. A perfectly sized index running a mediocre retrieval strategy still returns mediocre results, and no amount of tuning m and ef will fix a bad ranking approach. Chapter 5 covers what sits on top: hybrid search combining vectors with keyword matching, cross-encoder reranking, and how to evaluate whether any of it is genuinely working.