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.

1.3 billion vectors CA / US / MX / CL Deep technical 15 interview drills Qdrant 1.19 / TurboQuant Reading time ~90 min

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.

The system we are designing
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.

One query against 800M title vectors, exhaustivelytext
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:

SurfaceTargetReasoning
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.

The main ANN index families
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.

An IVF index partitioning vector space into Voronoi cells around centroids A two-dimensional map of vector space divided into five irregular cells, each owned by a centroid. Two cells nearest the query are highlighted and searched; the other three are skipped entirely. A red point marks the genuine nearest neighbour to the query, which lies just across a cell boundary in an unsearched cell and is therefore missed, illustrating the recall loss at cell edges. IVF: partition the space, then search only the nearest few cells Every vector belongs to exactly one cell, owned by the nearest centroid. c1 c2 c3 c4 c5 query true nearest — just across the boundary, never examined nprobe = 2 Only cells c4 and c5 are opened — 5 of the 16 vectors here, about a third of the work. The edge problem The genuine nearest vector sits 26 units away, but in unprobed c2. The closest vector actually examined is 94 units away — a badly wrong answer. Raising nprobe fixes it, at proportional cost in latency. Build cost One k-means pass over a sample. Minutes, not hours — far cheaper than building a graph index.
Figure 4.1 — IVF (inverted file index). k-means carves the space into cells; a query compares itself against the centroids, then scans only the nprobe nearest cells — here 5 of the 16 vectors rather than all of them. The weakness is structural rather than incidental: a hard partition has boundaries, and a true neighbour sitting just across one is invisible. In this layout the genuine nearest vector is 26 units from the query but falls in unprobed c2, so the best answer returned is 94 units away.

[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.

LSH assigning bit codes to vectors using random hyperplanes A circular region of vector space crossed by three dashed random hyperplanes. Four vectors are plotted. Two nearby vectors, a and b, fall on the same side of all three planes and so receive an identical three-bit code, landing in the same bucket. A distant vector c receives a different code. A fourth vector d lies close to a but is separated from it by the third hyperplane, so it receives a different code and is missed despite being a genuine neighbour, at 52 units from a against 192 units for the distant vector c. LSH: random hyperplanes turn a vector into a short bit code Each plane asks one yes/no question: which side are you on? h1 h2 h3 a b c d Resulting buckets a → 101 b → 101 d → 111 c → 010 a and b collide Same code, same bucket. Only bucket members get compared, so the scan is tiny. d is the problem d sits close to a — 52 units, against 192 for c — but h2 happens to slice between them, so their codes differ and d is never even considered. More tables reduce the odds; nothing eliminates them.
Figure 4.2 — LSH (locality-sensitive hashing). Each random hyperplane contributes one bit: which side of the plane the vector falls on. Vectors pointing in similar directions tend to agree on every bit, so they hash to the same bucket and only bucket-mates are compared. The failure mode is visible at d: it sits 52 units from a against 192 for the genuinely distant c, yet a single unlucky plane (h2) splits it into a different code, so it is never considered. The standard fix is many independent hash tables, which multiplies the memory — and this is precisely why LSH lost to graph indexes at high dimensions.

[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.

How a hierarchical navigable small world graph is searched Three stacked layers. The top layer is sparse with long links, the middle layer denser with medium links, and the bottom layer holds every vector with short links. A path enters at the top, takes large jumps, then descends layer by layer taking smaller steps until it reaches the nearest vector. LAYER 2 — sparse, long-range links roughly 1 vector in 40 entry LAYER 1 — medium density roughly 1 vector in 6 LAYER 0 — every vector, short links all 800M titles live here. This layer dominates the memory budget. nearest
Figure 4.3 — HNSW search. The sparse top layer covers distance cheaply and each descent narrows the region, so roughly 200 to 600 distance comparisons replace 800 million. Layer 0 holds every vector, which is why its neighbour lists dominate the memory budget — the arithmetic for that appears in section 4.6.
In plain English: instead of checking every vector, the index keeps a network of "who is near whom" and walks it, starting with big jumps and finishing with small ones. You reach a very good answer after a few hundred steps instead of 800 million.

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.

Product quantization splitting a vector into sub-vectors encoded by codebook ids A 768-dimensional float32 vector, 3072 bytes, shown as a horizontal strip divided into 48 sub-vectors of 16 dimensions each. Each sub-vector is matched against its own codebook of 256 k-means centroids, and replaced by the one-byte id of its nearest centroid. The resulting code is 48 bytes, 64 times smaller than the original vector. Product quantization: split the vector, replace each piece with a codebook id One 768-dim float32 vector — 3,072 bytes dims 1–16 dims 17–32 dims 33–48 … up to 768 48 sub-vectors codebook 1 256 centroids learned by k-means nearest → id codebook 2 256 centroids learned by k-means nearest → id codebook 3 256 centroids learned by k-means nearest → id codebook 48 256 centroids learned by k-means nearest → id id 37 id 212 id 8 48 bytes The stored code — one byte per sub-vector. 3,072 bytes → 48 bytes = 64× smaller. Distances are computed against the centroids, never the originals — so the error is baked in and rescoring matters. 48 sub-vectors × 16 dims = 768 dims. Each id indexes 256 centroids, which is exactly one byte.
Figure 4.4 — Product quantization. Rather than compressing each number, PQ compresses groups of dimensions: every 16-dim slice is replaced by the id of the nearest entry in a codebook learned for that slice. Storage falls from 3,072 bytes to 48 — but every distance is now measured to a centroid rather than to the real vector, so the ranking is approximate in a way scalar quantization is not. PQ is rarely used alone; it is combined with a coarse index as IVF-PQ, which is what makes billion-scale FAISS deployments fit in memory.

[!] 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.

DiskANN splitting an index between compressed vectors in RAM and full data on SSD Two stacked boxes on the left. The upper box, labelled RAM and described as small, holds product-quantized copies of every vector at 48 bytes each, used to choose which direction to walk. The lower box, labelled SSD and described as large, holds the full precision vectors together with each node's neighbour list, so a single random read fetches both. On the right, a three-step query flow: walk the graph using in-memory compressed distances, read a shortlist of full vectors from SSD, then re-rank exactly. DiskANN: keep a compressed map in RAM, the real data on SSD RAM — small PQ-compressed copy of every vector (48 bytes each, not 3,072) Used only to decide where to walk next. Approximate distances are good enough to choose a direction. SSD — large Full float32 vectors + adjacency lists Each node's neighbour list is stored beside its vector, so one random read fetches both. Roughly 3–5 reads per query, a few hundred microseconds each. Query flow 1 Walk the graph using the RAM-resident PQ distances — no disk touched. 2 Collect a candidate shortlist, then read those full vectors from SSD. 3 Re-rank the shortlist with exact distances and return the top k. Result: a billion vectors on one machine with ~62 GB of RAM instead of ~4 TB.
Figure 4.5 — DiskANN (Microsoft's Vamana graph). The insight is that graph traversal only needs to know which way to go, and a compressed vector is accurate enough for that. Only the final shortlist needs exact distances, and that is a handful of SSD reads. This is the standard answer to "we cannot afford to keep 1.3 billion vectors in RAM" — at 48 bytes per vector the compressed copy is about 62 GB against roughly 4 TB for the raw float32 data, a 64× reduction bought with a few hundred microseconds of disk latency.

[+] 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

Which family fits which binding constraint
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

  1. 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.
  2. 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.
  3. 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.
  4. Prune that pool to at most m links using the selection heuristic below — the step that decides the graph's quality.
  5. 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.
Layer populations in a Qdrant HNSW graph with m equal to 16 Five horizontal bars, one per layer, drawn widest at the bottom for layer 0 and shrinking sharply upward. Layer 0 holds 100 percent of points, about 800 million. Layer 1 holds 25 percent, about 200 million. Layer 2 holds 1.56 percent, about 12.5 million. Layer 3 holds 0.098 percent, about 781 thousand. Layer 4 holds 0.006 percent, about 49 thousand. Each layer contains one sixteenth of the layer below it. How Qdrant assigns a vector to layers, and how big each layer ends up level = round( −ln(U) / ln m ), U ~ Uniform(0,1) — a point at level L is inserted into every layer 0…L layer 0 100.00% of all points ≈ 800,000,000 at 800M layer 1 25.00% of all points ≈ 200,000,000 at 800M layer 2 1.56% of all points ≈ 12,500,000 at 800M layer 3 0.0977% of all points ≈ 781,250 at 800M layer 4 0.0061% of all points ≈ 48,828 at 800M Each layer holds 1/m — one sixteenth — of the layer beneath it. So the graph is ~8 layers deep at 800M vectors, and layer 0 alone accounts for essentially all of the memory. Note Qdrant rounds where the original paper floors, which makes its upper layers denser than the textbook version.
Figure 4.6 — Layer assignment is random but not arbitrary: the exponential decay is what makes the top of the graph a coarse, cheap map and the bottom an exhaustive one. Because each layer is 1/m the size of the one below, raising m makes the pyramid shallower as well as wider — more links per node, fewer hops to cross.

[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.

Naive nearest neighbour selection compared with the HNSW pruning heuristic Two panels showing the same arrangement: a new point, a tight cluster of eight points to one side, and a single distant point in the opposite direction. In the left panel, naive selection links the new point to its four nearest neighbours, all of which are inside the dense cluster. In the right panel, the heuristic links to one cluster member and to the distant point, preserving a route to the far region of the graph. Neighbour selection: why HNSW does not simply keep the m closest Naive: keep the 4 nearest All four links land in the same dense cluster. far region new point Qdrant: the heuristic Keeps a link toward the far region instead. far region new point Rule (links_container.rs): accept a candidate only if it is closer to the new point than to every already-accepted neighbour. The naive graph is locally perfect and globally disconnected — a search arriving in that cluster can never leave it. This is also why a node can end up with fewer than m links: the heuristic rejects redundant candidates rather than padding the list.
Figure 4.7 — The single most important detail of HNSW construction, and the one most often omitted. Keeping the m nearest candidates produces a graph where every link is short and the search cannot escape a local cluster. Qdrant instead accepts a candidate only if it is closer to the new point than to any neighbour already chosen — deliberately keeping a longer link that points somewhere new. Those surviving long links are what make greedy descent converge at all.

[+] 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

  1. 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.
  2. 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.
  3. 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.
  4. Return the top k from that queue.
The effect of the ef parameter on the width of the search beam Three rows showing search beams of increasing width. At ef equal to 16 the beam is narrow and greedy and may settle on a local minimum. At ef equal to 64 the beam is wider with more escape routes, the usual production range. At ef equal to 256 the beam is very wide, approaching exhaustive search at a cost in latency. A note records that Qdrant raises ef to at least the requested result limit. ef at query time: the size of the beam carried through the walk ef is not “how many results” — it is how many candidates stay in play while searching layer 0. ef = 16 16 candidates narrow beam, greedy, cheap may settle on a local minimum ef = 64 64 candidates wider beam, more escape routes the usual production range ef = 256 256 candidates very wide beam, ticks every 16 candidates approaches exhaustive, costs latency graph_layers.rs: let ef = max(ef, top) — Qdrant silently widens the beam to at least your requested limit, so asking for 200 results never searches with a beam of 128.
Figure 4.8ef is the number of candidates kept alive during the layer-0 walk. A wider beam means more chances to notice that a seemingly worse direction eventually leads somewhere better, which is exactly how a greedy search escapes a local minimum — and it is why recall rises with ef while the graph itself never changes.

[!] 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

What each parameter touches, and when it can be changed
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

Starting points for 800M titles and 500M images
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.

Graph memory alone, before storing a single vector
m Bytes per vector 800M titles 500M images Total
869 B55 GB35 GB90 GB
16138 B111 GB69 GB180 GB
32276 B221 GB138 GB359 GB
64553 B442 GB276 GB719 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.

Default configuration, float32, m=16text
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.

Quantization options for this workload
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.

  1. 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.
  2. 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.
  3. 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.

Two-stage search with binary quantization and rescoring Stage one searches a small binary index held in RAM and returns 100 oversampled candidates very quickly. Stage two fetches the full precision vectors for only those 100 candidates from NVMe disk, re-ranks them by exact distance, and returns the final top 10. Memory holds 321 gigabytes instead of 4685, and recall recovers from about 0.80 to about 0.96. STAGE 1 — binary index, in RAM 1.3B vectors at 1 bit per dimension distance = XOR + popcount RAM = 321 GB time = ~4 ms recall alone: ~0.80 STAGE 2 — rescore, from NVMe only the 100 candidates, full float32 100 random reads, not 800M disk = 4.5 TB originals time = ~3 ms recall after: ~0.96 100 The oversampling dial ask for 2x results = faster, lower recall | 4x = the usual sweet spot | 8x = higher recall, more disk reads Net effect 14.6x less RAM than float32, ~7 ms total, recall within 4 points of exact. RAM is the scarce resource; disk is not.
Figure 4.9 — Binary search plus rescoring. The insight is that the two stages have different jobs: the cheap stage decides which hundred vectors are worth looking at, and the expensive stage decides their order. Only the first stage has to fit in RAM.

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.

TurboQuant bit depths against the earlier options, for this workload
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.

Product quantization and TurboQuant compared as pipelines Two horizontal pipelines. The product quantization lane has four stages: sampling vectors, running k-means as a training pass, storing a codebook of 196,608 floats, and finally encoding. The first three are the user's responsibility. The TurboQuant lane also has four stages: pushing float32 vectors, a rotation using fixed seeds, a lookup into a codebook of 16 constants, and encoding. Only the first stage involves the user; the rest happen inside Qdrant with no fitting step. What has to happen before a vector can be stored compressed Both paths end with a compact code in the index. Only one of them needs your data first. Product quantization Sample vectors you provide Run k-means training pass Store codebook 196,608 floats Encode + store Qdrant The codebook is derived from your data, so it must be trained, persisted, versioned and kept with the index. TurboQuant Push float32 you provide Rotate fixed seeds Look up codebook 16 constants Encode + store Qdrant Nothing is fitted. The rotation depends only on dimensionality and the codebook only on bit depth. Why a constant codebook is good enough: the rotation makes any input distribution look approximately Gaussian, so the optimal codebook for a standard normal is already close to optimal for your vectors. That codebook can therefore be computed once, in advance, by whoever wrote the library.
Figure 4.10 — The operational difference between the two. Product quantization's codebook is an artifact derived from your vectors, so it has to be trained before anything can be stored and then kept alongside the index forever. TurboQuant's is a set of constants compiled into Qdrant. You send ordinary float32 vectors and set a bit depth; there is no model to load and no training pass to schedule.

[+] 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.

Truncating the 800M title vectors, int8 quantized, m=16
Dimensions Vector bytes Plus graph Titles RAM Typical quality retained
768 (full)768 B906 B725 GB100%
512512 B650 B520 GB~99%
256256 B394 B315 GB~97%
128128 B266 B213 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.

Recommended production configuration
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
Resulting memory footprinttext
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.
Realistic provisioningtext
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
In plain English: the naive configuration needs about 19 machines. Truncating titles, quantizing to binary and rescoring from disk brings that to about 6 including replication and headroom. Same recall, roughly a third of the hardware.

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

Decision matrix for the four-market question
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.

create_collections.pypython
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,
)
create_collections.py (continued)python
# --- 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.
Global HNSW graph versus tenant-optimised graph On the left, one global graph mixes all four markets, so a search filtered to Mexico keeps visiting Canadian, US and Chilean points that fail the filter and are discarded, wasting most of the traversal. On the right, the tenant key has added extra same-market links on top of that same global graph, so the equivalent filtered search walks Mexican points directly and visits far fewer nodes for the same result. The other markets remain present and reachable; they are simply not needed for this query. PLAIN PAYLOAD FILTER one global graph, markets interleaved US MX CA US CL MX US CA MX US visited 6 nodes, only 3 usable the grey ones were fetched, then discarded TENANT KEY (is_tenant=True) extra same-market links added on top of the global graph US / CA / CL still reachable, just not needed here MX partition MX MX MX same 3 results visited 3 nodes, all 3 usable no wasted fetches, no dead ends Same collection, same filter, same results — the difference is purely how the graph was built.
Figure 4.11 — Why a tenant key matters. Both sides answer "nearest Mexican products", but the left walks a graph built without regard to market and discards most of what it visits. With four markets the waste is moderate; with thousands of tenants the left-hand approach becomes unusable. Note the right-hand graph still contains the global links — the tenant key adds same-market edges rather than replacing anything, which is what keeps unfiltered search working.

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.

How tenant links are merged into the global HNSW graph Three panels showing the same six points, three from Mexico and one each from the United States, Canada and Chile. The first panel shows the main graph, whose edges connect Mexican points to foreign ones because market was ignored during construction. The second shows the tenant pass, which connects the three Mexican points to each other. The third shows the merged result containing both sets of edges together. Below, the link list of one Mexican point is shown before the merge holding a US and a Chilean neighbour, and after the merge holding those same two plus two Mexican neighbours, with nothing removed. One graph, two kinds of edge — what a tenant key actually adds Qdrant builds the global graph, then the same-market links, then merges the second into the first. 1. Main graph m = 16, market ignored 2. Tenant links payload_m, same market only 3. Merged both sets, one link list MX MX MX US CA CL MX MX MX US CA CL MX MX MX US CA CL The link list stored for the top-left MX point: before merge US · CL after merge US · CL + MX · MX — one list, holding both. Nothing was removed. hnsw/build.rs: graph_layers_builder.merge_from_other(additional_graph) — the tenant subgraph is folded in, not kept apart.
Figure 4.12 — The tenant key is additive. Qdrant builds the ordinary global graph, builds a second set of links restricted to each market, and then merges the two into a single link list per point. A Mexican product ends up knowing both its global neighbours and its Mexican ones — which is why a market-filtered search finds somewhere to walk, and an unfiltered search still works exactly as before.

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

Trade-offs in 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

How a vector database can apply a filter
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.

Separate collections versus named vectors on one point
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

  1. 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.
  2. Upload in parallel, in batches Many workers, each sending batches of a few hundred points. Details in section 4.15.
  3. 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.
  4. 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.
  5. 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.

bootstrap.py — disable indexing, load, then build oncepython
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)
bootstrap.py (continued) — wait for the build to finishpython
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 trade-offs at 256 dimensions
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.

batch_upsert.py — parallel loader with idempotent IDspython
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}"))
batch_upsert.py (continued) — retry, backoff, thread poolpython
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.

Update types and their correct operation
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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. Swap the alias atomically One API call repoints titles_live to titles_v2. No downtime, no deployment.
  6. 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.

What to watch, and what it means when it moves
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.

Optimisation order, and what each step costs
# 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.

mrl_study.pypython
# 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.

The three tier combinations, on the 800M title collection
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.
two_stage.pypython
# 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_index.pypython
# 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

  1. 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.
  2. 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.
  3. 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.
  4. Verify counts against the source Before indexing, not after. Discovering a gap once the build has run costs the whole build.
  5. Restore indexing_threshold and let it build once Per segment, per shard, in parallel — and per tenant if m=0 is set.
  6. 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

  1. Exact search does not scale. One brute-force query over 800M vectors streams 2.5 TB. Approximation is mandatory, not a shortcut.
  2. 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.
  3. Bytes per vector determines your cluster. Multiply by 1.3 billion and you have your machine count. Everything else is detail.
  4. 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.
  5. ef is free to change, m is not. Tune ef per query at runtime; changing m means a full rebuild.
  6. Binary quantization plus rescoring is the big lever. 32x less memory, with recall recovered by re-ranking a few hundred candidates read from NVMe.
  7. 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.
  8. 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.
  9. Matryoshka truncation applies to your text vectors only. EmbeddingGemma truncates to 256 cleanly; DINOv3 cannot be truncated at all.
  10. Together they take 4,685 GB down to 269 GB — about 19 nodes to 6 including replication and headroom.
  11. For four markets, use one collection with a tenant key. Uneven market sizes, shared products and cross-market queries all argue against separate collections.
  12. Separate only for legal residency or a different model per market. Those are the two genuine reasons.
  13. Never shard by market. Uneven shards mean the US node becomes the bottleneck while Chile idles. Shard by ID hash; isolate by tenant key.
  14. 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.
  15. Text and image vectors need separate collections. Different models produce incomparable spaces, and the counts and update rates differ anyway.
  16. Disable indexing during bulk load, then build once. Days versus weeks, and a better graph.
  17. Parallelism beats batch size. 16 workers at batch 256 outperforms one worker at 4,096. Never use wait=True in a pipeline.
  18. Hash the text and skip unchanged rows. Most catalogue updates change price or stock, not the title. Re-embedding everything nightly is pure waste.
  19. Use aliases from day one so re-indexing is an atomic swap and rollback is one API call.
  20. Monitor recall continuously. This system degrades without erroring, and recall is the only signal that catches it.
  21. 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.
  22. 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.
  23. Truncation before quantization, because its effect on recall is smooth and predictable while quantization error depends on the model's value distribution.
  24. 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.
  25. 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.