Advanced Qdrant

Production Qdrant: the settings, measurements and failure modes that decide whether a large, multi-tenant collection stays fast, correct and recoverable.

Last generated

Lesson 35 of 38 available16 practice questions

SPACED REPETITION · 16 practice questions

Make this lesson stick.

Try 3 questions now. No account needed. Sample answers aren't saved.

Green is not the same as ready

Friday night, your ingestion job loads 1,000,000 new support-ticket chunks into a Qdrant collection. Someone read that turning off indexing speeds up bulk loads, so the job first sets indexing_threshold=0. The load finishes, the collection shows green, and everyone goes home.

Monday morning, search is slower, and it gets slower every time the collection grows. A teammate finds the search parameter indexed_only=True, which promises to skip slow unindexed data, and turns it on. Now every query returns zero results.

We reproduced that weekend on a local Qdrant 1.19.1 server with 1,000,000 random 384-dimensional vectors (random vectors are fine for timing; they are useless for measuring recall). This is what the collection reported after the load:

status=green  points_count=1000000  indexed_vectors_count=0  segments_count=6

And this is the server-side query time (the time field of each response; p50 over 100 queries, three rounds, on a test server shared with other workloads, so read the ratios rather than the digits):

State of the collection p50 server time
Indexing disabled, status green 47–51 ms
Indexing re-enabled, after about 7 minutes of optimizer work 7–17 ms
Same indexed collection, forced brute force (exact=True) 43–60 ms

Nothing was broken. green means "no optimization is failing or pending", not "your vectors are in an index". With indexing disabled there is nothing to optimize, so the collection is green while every query scans every vector. And indexed_only=True did exactly what it says: it searched only indexed segments, and there were none.

That is the theme of this lesson. The Qdrant lesson taught you collections, points, payload indexes, filters and the Query API. Production adds machinery underneath them: segments and an optimizer, memory placement, quantization, tenants, shards and replicas. Each piece reports its state somewhere, and each has a default that is right for a demo and wrong for some production workload. This lesson teaches six moves. Each starts with a symptom you can see.

# Symptom Move You pay with
1 Search slows after a bulk load; status green but little is indexed Read and steer the optimizer Indexing time, or temporarily invisible points
2 The vectors do not fit in RAM Place data by tier, quantize, rescore Some recall, or disk reads per query
3 One big customer slows everyone, or a query leaks another customer's data Partition tenants deliberately Shards, collections or code you must maintain
4 One machine is not enough, or one restart takes search down Shard, replicate, pick consistency Storage × replicas, failed writes when too few replicas answer
5 You need cheap candidates, a precise rerank and business rules in one call Build multi-stage queries Latency per stage, and a multi-shard trap
6 "We lost the collection", "the model upgrade broke search", "users told us first" Snapshot, swap aliases, monitor Storage for backups and a second collection during re-indexing

Everything below was run against Qdrant 1.19.1 with qdrant-client 1.19.1 unless it says otherwise. Features that only exist in a multi-node cluster (shard keys, replication, Raft) cannot run on one local node, so those parts are checked against Qdrant's documentation and marked as such.

💡 Habit worth building: for every setting you change, know where Qdrant reports its effect. A setting you cannot observe is a setting you cannot trust.

Move 1: Read and steer the optimizer

Symptom: search latency depends on when you query (during a load, after a restart, after a config change), and the collection flips between green and yellow.

A collection is a set of small databases

Each shard of a collection stores its points in several segments. A segment holds some of the points with their vectors, payloads and indexes. There are two kinds:

  • Appendable segments accept inserts, updates and deletes. New writes land here.
  • Non-appendable segments are read-only except for deletes. The optimizer builds them, with an HNSW index once they are big enough.

A search runs on every segment and merges the results. So the collection is only as fast as its slowest segment, and a large segment without an index is a brute-force scan hiding inside an otherwise indexed collection.

How many segments should you expect? By default Qdrant derives the target from the number of CPUs (default_segment_number = 0 means "choose from the CPU count"; in the 1.19.1 source that is half the CPUs, at least 2 and at most 8), so the answer depends on the machine. A brand-new empty collection on our 12-core test server reported segments_count = 6. Do not treat a segment count as a health metric unless you know the configured target.

Three optimizers, three triggers

Qdrant rebuilds segments in the background. While it rebuilds one, the old segment stays readable behind a proxy, and new writes go to a copy-on-write segment. Search keeps working throughout; it may just be slower.

Optimizer Starts when… Setting (default on 1.19.1) What it fixes
Vacuum a segment has at least this share of deleted points, and at least this many vectors deleted_threshold (0.2), vacuum_min_vector_number (1000) Deleted points are only marked; they still cost memory and scan time until vacuumed
Merge there are more segments than the target default_segment_number (0 = from CPU count), max_segment_size (unset = from CPU count, in KB) Many tiny segments mean many tiny searches per query
Indexing a segment's vectors exceed the threshold indexing_threshold (10,000 KB; 0 disables vector indexing) Builds the HNSW graph (and quantized copies) for large segments

The indexing threshold is measured in kilobytes of vectors, not in points. Qdrant's docs define 1 KB as one 256-dimensional float32 vector (1,024 bytes). So 10,000 KB is about 6,667 vectors at 384 dimensions and 2,500 vectors at 1,024 dimensions. Smaller segments stay "plain": they are searched by brute force, which at that size is faster than walking a graph.

⚠️ The client's docstring for indexing_threshold still says "Default value is 20,000". The server we ran reported 10000 for a new collection, and the server's configuration docs say the same. Read get_collection(...).config.optimizer_config on your own server instead of trusting either.

Where the optimizer reports its state

Where What it tells you
GET /collections/{name} → status green ready · yellow optimizing · grey optimizations pending but paused (typically after a restart; any update restarts them) · red unrecoverable error
points_count vs indexed_vectors_count A large gap means a lot of data is not in an HNSW index. Both counts are approximate, and small plain segments are legitimately unindexed
GET /collections/{name}/optimizations (since v1.17) Queued optimizations, queued points, the running optimizer and its progress
config.optimizer_config.indexing_threshold 0 means indexing is off, whatever the status color says

Here is our 200,000-point load (384-d random vectors) with indexing disabled, then re-enabled, polled every 5 seconds:

after upload   status=green   points=200000 indexed=0       queued_optimizations=0
t=0s           status=yellow  points=200000 indexed=0       queued_optimizations=4  queued_points=133344
t=10s          status=yellow  points=200000 indexed=66656   queued_optimizations=3
t=20s          status=yellow  points=200000 indexed=133344  queued_optimizations=1
t=35s          status=green   points=200000 indexed=200000  queued_optimizations=0

Predict first: the collection above is green with indexed=0. You run a query with search_params=SearchParams(indexed_only=True). How many results come back?

Check your answer

Zero. indexed_only tells Qdrant to search only indexed segments and segments small enough to scan cheaply. Every segment here was large and unindexed, so nothing was eligible. We ran it: indexed_only results with indexing off: 0. The same flag in normal operation makes freshly written points "blink": they vanish from results until their segment is indexed.

Bulk loads without the trap

You have three sane ways to load a lot of data. All of them start the same way: create the payload indexes you will filter on before uploading. Qdrant adds filter-aware links to a segment's HNSW graph only for payload fields that are indexed when that graph is built. In our test, creating a payload index after the graphs existed started no optimization at all: the existing segments simply lack those links, and the collection stays green. That is not a small slowdown. In the Qdrant lesson's experiment, filtered recall@10 fell to near zero and many filtered pages came back short. Qdrant's bulk upload guide says the fix is rebuilding the graph, which is expensive on a large collection. Until then, ACORN (Qdrant 1.16+, enabled per query) can restore filtered recall, at the cost of slower filtered queries.

  1. Initial load of a new collection: defer indexing, then prove it finished. Set indexing_threshold=0, upload, restore the threshold, then wait until /optimizations shows nothing queued and indexed_vectors_count is close to points_count. Do not route traffic to it before that.
  2. Continuous heavy writes to a live collection: keep indexing on and hide unindexed data. prevent_unoptimized=True (since v1.17.1, marked experimental) stores points written into a large unindexed segment as deferred: accepted and logged, but invisible to search until the optimizer has indexed that segment. Searches stay fast; new data appears late.
  3. Re-embedding or re-chunking everything: build a second collection and swap an alias. This is Move 6.
from qdrant_client import QdrantClient, models

client = QdrantClient(url="http://localhost:6333")
NAME = "tickets"
if client.collection_exists(NAME):
    client.delete_collection(NAME)

# Option 1: defer indexing during an initial load
client.create_collection(
    NAME,
    vectors_config=models.VectorParams(size=384, distance=models.Distance.COSINE),
    optimizers_config=models.OptimizersConfigDiff(indexing_threshold=0),
)
client.create_payload_index(NAME, "tenant_id", models.KeywordIndexParams(
    type=models.KeywordIndexType.KEYWORD, is_tenant=True))   # before the upload
# ... upload in batches (Qdrant suggests 64-256 points per request) ...
client.update_collection(NAME, optimizers_config=models.OptimizersConfigDiff(indexing_threshold=10000))

# Option 2: a live collection under heavy writes
client.update_collection(NAME, optimizers_config=models.OptimizersConfigDiff(prevent_unoptimized=True))
client.upsert(NAME, points=[models.PointStruct(id=1, vector=[0.1] * 384, payload={"tenant_id": "acme"})],
              wait=False)   # the Python client waits by default; with prevent_unoptimized, don't

info = client.get_collection(NAME)
print(info.status, info.config.optimizer_config.indexing_threshold, info.config.optimizer_config.prevent_unoptimized)
# green 10000 True
client.delete_collection(NAME)

Why wait=False in option 2? With prevent_unoptimized, a wait=True write returns only after every deferred point, including earlier ones in the queue, has been indexed. Qdrant's optimizer docs warn that this can block the whole update queue and time out clients. The Python client defaults to wait=True; the REST API defaults to false. Watch the backlog in update_queue.deferred_points of the collection info, or the metric collection_update_queue_deferred_points.

wait=False has a durability price too. Qdrant acknowledges such a write once it is in the memory-mapped write-ahead log. That survives a crash of the Qdrant process, but the log reaches the disk only at the next flush (every 5 seconds by default), so a power cut can lose the last few seconds of acknowledged writes. A wait=True write syncs the log first. For a sync job that can re-send from its source with the same point ids, the risk is acceptable; for writes you cannot replay, keep your own retry log (the Vector Database Architecture lesson walks through the write-ahead log).

Batch size has a hard ceiling as well as an optimum. The REST API rejects request bodies over max_request_size_mb (default 32, meaning 32 MiB = 33,554,432 bytes). In our test, 5,000 points of 768-d random vectors made a 75.6 MB JSON body, and the server answered 400: JSON payload (75635487 bytes) is larger than allowed (limit: 33554432 bytes). The same batch went through over gRPC, but Qdrant's guide suggests 64–256 points per request anyway, with parallel streams (one per shard is a good start) for throughput.

⚠️ upload_collection(..., parallel=2) starts worker processes. On macOS, running that at the top level of a script fails with a multiprocessing RuntimeError unless the call sits under if __name__ == "__main__":.

How to know it helped: record p50/p95 of the server-side query time during and after a load, the gap between points_count and indexed_vectors_count, and the time until /optimizations is empty. A good bulk-load change shortens the window in which search is slow or incomplete, without leaving it open.

Your turn: a colleague proposes "set indexing_threshold=0 at 01:00 before the nightly sync of 3M changed chunks and restore it at 07:00; users are asleep anyway". The sync writes into the live collection that also serves a 24/7 API for partners. What goes wrong, and what would you do instead?

Check your answer

Two things. First, from 01:00 until the optimizer has indexed the new data, every changed chunk sits in large unindexed segments, so partner queries scan them by brute force; the collection stays green the whole time, so no alert fires. Second, restoring the threshold at 07:00 does not make the data indexed at 07:00; indexing 3M vectors takes time, which lands on the morning peak. Better: keep indexing on and enable prevent_unoptimized with wait=False writes, accepting that changed chunks become visible when their segment is indexed; or, if the sync rewrites most of the collection, load a new collection and swap an alias when /optimizations is empty. Measure the partner API's p95 and the deferred-point backlog during the sync either way.

Move 2: Place data by tier, quantize, rescore

Symptom: the capacity plan says the vectors need more RAM than your nodes have, or latency jumps whenever the operating system starts evicting pages.

The bill

Take a helpdesk SaaS with 30,000,000 chunks embedded at 1,024 dimensions. The arithmetic (float32 = 4 bytes per dimension, GB = 10⁹ bytes):

What you store per vector Bytes per vector 30M vectors
float32 originals 4,096 122.88 GB
int8 scalar quantization 1,024 30.72 GB
4-bit (TurboQuant bits4) 512 15.36 GB
2-bit (binary two_bits or TurboQuant bits2) 256 7.68 GB
1.5-bit (binary one_and_half_bits or TurboQuant bits1_5) 192 5.76 GB
1-bit (binary or TurboQuant bits1) 128 3.84 GB
HNSW links on the base layer, m=16 (estimate: 2·m ids × 4 bytes) ≈ 128 ≈ 3.84 GB

(TurboQuant also stores a few bytes of correction data per vector, negligible at 1,024 dimensions.)

Qdrant's capacity-planning guide sizes each structure separately and then adds about 20% headroom for the page cache, runtime overhead and optimizer work. Keeping this collection entirely in RAM means 122.88 GB of vectors, plus 4.61 GB of graph (the guide multiplies the base-layer links by 1.2 for upper layers and bookkeeping), plus 1.56 GB of ID tracker at 52 bytes per point. With the headroom, that is 129.05 GB × 1.2 ≈ 155 GB, before payloads and payload indexes. Nobody wants to pay for that if a 15–31 GB quantized copy can carry the search.

Memory tiers: decide what lives where (1.19)

Qdrant persists everything to disk. Since v1.19, a single memory parameter on each structure decides how much of it is kept in RAM:

  • pinned: loaded onto the heap and never evicted. Fast, but it must always fit.
  • cached: a memory-mapped file that Qdrant warms into the OS page cache at startup. Under memory pressure the OS can evict it.
  • cold: a memory-mapped file that is not warmed. The first access to each page is a disk read.

cached and cold differ only in the warm-up; the OS evicts both by the same rules. Defaults, from Qdrant's memory tiers docs:

Structure Default Allowed
Dense vectors (originals) cached cached, cold
HNSW graph cached all three
Quantized vectors pinned if originals are cached, cold if originals are cold all three
Sparse vector index pinned all three
Payloads cold cached, cold
Payload indexes pinned all three

We checked the "Allowed" column on our 1.19.1 server: it rejects pinned only for dense originals and payloads ("pinned memory placement is not supported…"), and accepts every tier for the sparse index and for payload indexes.

Older settings still work but are deprecated: on_disk: true on vectors or the HNSW config maps to cold, always_ram: true on quantization maps to pinned, and on_disk_payload maps to the payload's memory. memmap_threshold in the optimizer config has been deprecated since v1.15. If you read a tutorial that tunes those, translate it.

The layout Qdrant recommends when the originals do not fit: quantized copy pinned, originals cold, HNSW graph in RAM (cached or pinned). Search runs on the compressed copy and the graph; the originals are read only to rescore the final candidates. Qdrant's docs advise against a cold HNSW graph. If you must, look at inline_storage (since v1.16, requires quantization): it stores each point's vector and its neighbours' quantized codes next to the links, so a graph walk makes fewer random reads. The price is disk space: to keep the graph file down to roughly 3–6 times the size of the float32 vectors, Qdrant's docs say to use a quantization of 4 bits or fewer. They also advise against inline storage while the graph is in RAM.

Quantization: compress, oversample, rescore

Quantized search in Qdrant works in two steps, inside every segment, before the segments' results are merged:

 in each segment:
   compressed copy (RAM)                       originals (RAM, page cache or disk)
   find limit × oversampling candidates   →    rescore them exactly    →    keep the best `limit`
 then: merge the segments' lists into the final top `limit`

rescore and oversampling are per-query settings. By default Qdrant rescores for binary quantization and TurboQuant at 1, 1.5 and 2 bits, and does not rescore for scalar int8, TurboQuant 4-bit or product quantization. The methods available in 1.19, from Qdrant's quantization docs:

Method (since) Compression vs float32 Notes
Scalar int8 (v1.1) 4× quantile (e.g. 0.99) clips outliers
TurboQuant (v1.18) bits4 8× (default), bits2 16×, bits1_5 ≈21×, bits1 32× Rotates vectors first; queries stay full precision
Binary (v1.5; 1.5- and 2-bit since v1.15) 32×, ≈21×, 16× Fastest; 1-bit loses most on small or uncentered vectors. query_encoding can keep the query at 4 or 8 bits (asymmetric)
Product (v1.2) up to 64× Slowest and least accurate; for when memory dominates

Qdrant's quantization guide says 24× for the 1.5-bit encodings. Qdrant 1.19.1's source stores 1.5 bits per dimension for both, and 32 / 1.5 ≈ 21.3, so plan with about 21×.

Predict first: all-MiniLM-L6-v2 produces 384-d vectors. With 1-bit binary quantization, 32× smaller, what share of the true top 10 do you expect back without rescoring? About 90%? About 50%?

Check your answer

Closer to half: we measured 0.563 (table below). Binary quantization keeps only the sign of each dimension, and Qdrant's docs note it works best for high-dimensional, centered vectors; 384 dimensions is small. Rescoring with oversampling recovers most of the loss.

We measured it on SciFact (5,183 scientific abstracts from the BEIR benchmark, also used in Move 5) embedded with all-MiniLM-L6-v2, with its 300 test claims as queries. The number is ANN recall@10: the share of the exact top 10 (brute force in numpy) that each configuration returned. We built every configuration twice, once with all points in one segment (Qdrant also kept an empty appendable segment for new writes) and once spread over 8–10 segments, because the segment count turned out to change what rescoring does.

Each cell shows 1 segment / 8–10 segments. The float32 HNSW baseline was 0.998 / 0.999. Each method's default rescoring is in parentheses.

Quantized copy Rescoring off On, oversampling 1 On, oversampling 2 On, oversampling 4
int8 scalar (default: off) 0.981 / 0.981 0.981 / 1.000 0.997 / 1.000 —
TurboQuant bits4 (default: off) 0.952 / 0.952 0.952 / 1.000 0.998 / 1.000 —
binary 2-bit (default: on, 1) 0.653 / 0.652 0.653 / 0.976 0.837 / 0.995 —
binary 1-bit, 8-bit query (default: on, 1) 0.697 / 0.697 0.697 / 0.986 0.870 / 0.999 —
binary 1-bit (default: on, 1) 0.563 / 0.565 0.563 / 0.910 0.737 / 0.963 0.860 / 0.988
TurboQuant bits1 (default: on, 1) 0.702 / 0.697 0.702 / 0.983 0.874 / 0.997 0.964 / 1.000

Three lessons in one table:

  • Bits matter more than the method name. Without rescoring, int8 (8 bits per dimension) lost 2% of the true top 10, 4-bit TurboQuant about 5%, and the 1–2-bit encodings 30–44%. At 1 bit, TurboQuant (0.702) and an 8-bit query encoding (0.697) both beat plain binary (0.563).
  • Rescoring works per segment. Qdrant searches each segment separately. With rescoring on, each segment picks its limit × oversampling best candidates by compressed score and rescores them with the originals before the segments' results are merged (you can see it in Qdrant's source, postprocess_search_result). With one segment and oversampling 1, rescoring only re-sorts the same ten candidates, so recall does not move. With 8–10 segments, 80–100 candidates get exact scores even at oversampling 1, which took int8 and 4-bit TurboQuant to 1.000 and 1-bit binary from 0.565 to 0.910. Your production segment count depends on CPU cores and shards, so measure on the deployment, not on a laptop.
  • Oversampling buys recall with reads. Every extra candidate, in every segment, is one more original vector to fetch, which is nearly free in RAM and not free on disk (next section).

⚠️ One small corpus and one small model: this is a method, not a result. Run the same comparison on a sample of your own queries before you choose. exact=True is a convenient reference inside Qdrant: in our runs it ignored the quantized copy and returned the exact top 10 (recall 1.000 even for 1-bit binary with rescoring off).

When rescoring reads the disk

Rescoring is cheap while the originals sit in RAM and expensive when they don't. Qdrant measured this in When Your Collection Outgrows RAM (August 2026): 4.6 million DBpedia entities, all-MiniLM-L6-v2, Qdrant 1.19.0 in Docker on a laptop, with the container's memory limited. With a 4 GiB limit, p50 latency was 4.3 ms with rescoring off and 43.4 ms with it on, because the originals had to be re-read from disk; with a 12 GiB limit, rescoring added only 0.3 ms. The same article reports 1-bit TurboQuant at recall@10 0.605 without rescoring, 0.951 with rescoring at oversampling 1, and 0.988 at oversampling 4.

So size the disk side too, and remember the per-segment multiplier. For the 30M × 1,024-d example with limit=10 and oversampling=3, each segment rescores 30 originals of 4,096 bytes. With 8 segments, one query can read up to 240 originals, about 0.98 MB. At 200 queries per second that is up to 48,000 random reads and about 197 MB per second before the page cache absorbs any of it. Compare that with the random-read rate your disk actually sustains: local NVMe may cope, network block storage limited to a few thousand IOPS will not. Lower oversampling, rescoring only the queries that need it, or keeping the originals cached cuts the load. On Linux, Qdrant 1.19 can issue those reads asynchronously (storage.performance.io_uring: auto).

The configuration for that layout:

from qdrant_client import QdrantClient, models

client = QdrantClient(url="http://localhost:6333")
NAME = "kb_tiered"
if client.collection_exists(NAME):
    client.delete_collection(NAME)
client.create_collection(
    NAME,
    vectors_config=models.VectorParams(size=1024, distance=models.Distance.COSINE,
                                       memory=models.Memory.COLD),            # originals: disk
    hnsw_config=models.HnswConfigDiff(memory=models.Memory.CACHED),           # graph: page cache
    quantization_config=models.TurboQuantization(
        turbo=models.TurboQuantQuantizationConfig(bits=models.TurboQuantBitSize.BITS4,
                                                  memory=models.Memory.PINNED)),  # compressed copy: heap
)
params = client.get_collection(NAME).config
print(params.params.vectors.memory, params.hnsw_config.memory, params.quantization_config.turbo.memory)
# cold cached pinned

hits = client.query_points(NAME, query=[0.01] * 1024, limit=10,
    search_params=models.SearchParams(quantization=models.QuantizationSearchParams(rescore=True, oversampling=2.0)))
client.delete_collection(NAME)

⚠️ Qdrant 1.19 also accepts datatype=turbo4 for the originals themselves. That shrinks the stored vectors, but then no full-precision copy exists and rescoring against originals is impossible. It is a different trade from a quantized copy.

Your turn: 12M chunks at 768 dimensions on one node with 32 GB RAM and local NVMe. Recall@10 must stay at or above 0.95 against exact search, and you expect 150 queries per second. Which layout would you try first, and what would you measure?

Check your answer

The originals are 12M × 768 × 4 = 36.86 GB, more than the node's RAM, so they go cold. An int8 copy is 9.22 GB and a 4-bit copy 4.61 GB; either fits pinned with room for the graph (about 12M × 128 B ≈ 1.5 GB at m=16) and payload indexes. Start with int8 or bits4 pinned, originals cold, HNSW cached, and measure recall@10 against exact=True on a few hundred real queries, first without rescoring and then with rescoring at oversampling 2. If rescoring is needed, each segment reads 20 originals of 3,072 bytes; with 8 segments that is up to 160 reads per query and 24,000 per second at 150 QPS, about 74 MB/s. Local NVMe can usually take that, but measure it: p95 under load, disk read rate and process_major_page_faults_total.

Move 3: Partition tenants deliberately

Symptom: one customer's re-import makes everyone's search slow, or a security review asks "what stops customer A's query from returning customer B's chunks?"

General tenant isolation (deny by default, ACLs as server-side filters, testing for leaks) belongs to Metadata & Filtering. This move is about how Qdrant lays tenants out. Qdrant's multitenancy guide offers three layouts inside one collection, plus the option of separate collections.

Layout 1: partition by payload, with a tenant index

Every point carries tenant_id; every query filters on it. Two settings turn this from "a filter" into a tenant layout:

  • is_tenant=True on the keyword index (since v1.11). Qdrant then stores each tenant's vectors together, so a tenant-filtered search reads one contiguous region instead of seeking all over the segment. It changes the storage layout; it does not build a separate graph.
  • Per-tenant HNSW graphs: set payload_m=16 and m=0 in the collection's hnsw_config. m=0 switches off the global graph; payload_m builds graph links within each value of the indexed tenant field instead. Qdrant recommends it when indexing many tenants together becomes the bottleneck.

The cost of m=0 is easy to underestimate. Qdrant's guide says queries without a tenant filter become slower, because there is no global graph. On 1.19.1 we saw something worse. We loaded 60,000 real questions (Quora duplicates data, all-MiniLM-L6-v2, 384-d) as 20 tenants of 3,000 points each, once per layout, with default thresholds and the tenant index created before the upload, and compared each layout's top 10 with the exact top 10 from numpy (300 held-out questions as queries):

Query Global graph (m=16) Per-tenant graphs (m=0, payload_m=16)
Filtered to one tenant recall@10 1.000 recall@10 1.000
No tenant filter (admin, analytics) recall@10 0.990 recall@10 0.087

The unfiltered query was not slow; it was wrong, and nothing raised an error. With no global links, the search can only follow tenant-local links and settles on poor neighbours. (The filtered rows are exact because 3,000-point tenants are small enough for the planner to scan through the payload index; a larger tenant would use its own graph.) Two rules follow. On an m=0 collection every query must carry a tenant filter, which the wrapper below enforces anyway. The rare cross-tenant query should use exact=True, which in our test returned the exact top 10 by scanning everything, or should run against a collection that keeps a global graph.

Keep the filter out of the caller's hands

In a payload-partitioned collection, isolation is exactly as strong as the code that adds the filter. Two rules:

  1. Build the tenant filter in one function in your retrieval service, from the tenant id in the authenticated session. Never accept tenant_id from the request body.
  2. Make the function refuse to run without it.
from qdrant_client import QdrantClient, models

def tenant_search(client: QdrantClient, collection: str, tenant_id: str, vector, limit=10, extra: models.Filter | None = None):
    if not tenant_id:
        raise PermissionError("no tenant in session")          # fail closed
    must = [models.FieldCondition(key="tenant_id", match=models.MatchValue(value=tenant_id))]
    if extra is not None:
        must.append(extra)                                     # caller filters can only narrow
    return client.query_points(collection, query=vector, limit=limit,
                               query_filter=models.Filter(must=must)).points

client = QdrantClient(url="http://localhost:6333")
NAME = "tenant_demo"
if client.collection_exists(NAME):
    client.delete_collection(NAME)
client.create_collection(NAME, vectors_config=models.VectorParams(size=4, distance=models.Distance.COSINE))
client.create_payload_index(NAME, "tenant_id", models.KeywordIndexParams(
    type=models.KeywordIndexType.KEYWORD, is_tenant=True))
client.upsert(NAME, points=[
    models.PointStruct(id=1, vector=[1, 0, 0, 0], payload={"tenant_id": "acme"}),
    models.PointStruct(id=2, vector=[1, 0.1, 0, 0], payload={"tenant_id": "globex"}),
])
print([p.id for p in tenant_search(client, NAME, "acme", [1, 0, 0, 0])])   # [1]
try:
    tenant_search(client, NAME, "", [1, 0, 0, 0])
except PermissionError as e:
    print("refused:", e)                                                  # refused: no tenant in session
client.delete_collection(NAME)

Can Qdrant enforce this for you? Only at collection level. Qdrant's JWT-based access control (since v1.9, security docs) grants read or read-write per collection; a token cannot be limited to points whose tenant_id has a given value. If a requirement says "the database itself must refuse cross-tenant reads", you need a collection per tenant (with a token scoped to it) or a separate deployment. A payload partition cannot give that guarantee on its own.

Strict mode is a useful guardrail in the same spirit. With unindexed_filtering_retrieve=False, a query that filters on a field without a payload index is rejected instead of silently scanning:

strict, unindexed filter -> 400 Bad request: Index required but not found for "tenant" of one of the following types: [keyword]. Help: Create an index for this key or use a different filter.

(We enabled it with update_collection(..., strict_mode_config=models.StrictModeConfig(enabled=True, unindexed_filtering_retrieve=False)). Qdrant Cloud enables strict mode by default.)

Layout 2: shard keys for a few big tenants

User-defined sharding (since v1.7) creates the collection with sharding_method=CUSTOM, then one named shard per key with create_shard_key. Writes name their key with shard_key_selector; a query that names a key touches only that key's shards, and a query without one runs on all shards. A tenant's traffic then touches only its own shard, which you can place and replicate independently (create_shard_key takes shards_number, replication_factor and placement). A shard key routes; it does not isolate. The caller chooses the key, and a request that leaves it out reads every shard, so keep the tenant filter (or a collection with its own token) for isolation.

Two facts decide whether this scales:

  • shard_number changes meaning: it becomes shards per shard key. Physical shards = shard_number × number of keys × replication_factor. With 10 keys, shard_number=1 and replication_factor=2, that is 20 physical shards.
  • Each physical shard has its own storage and indexes. Qdrant's docs say to keep shard keys low-cardinality and to use payload partitioning for large numbers of tenants.

This layout needs a cluster. On our standalone server, creating a CUSTOM collection worked, but create_shard_key answered 405 … "Qdrant is running in standalone mode". The behaviour below is from the distributed deployment docs, not from our run.

Layout 3: tiered multitenancy

Real SaaS tenants are skewed: thousands of small ones and a few giants, and you often do not know in advance which small one will grow. Tiered multitenancy (since v1.16) combines the first two layouts in one collection:

                        shard_key_selector = {target: "acme", fallback: "default"}
                                      |
             +------------------------+------------------------+
             | shard "acme" exists and is active?              |
             | yes: route to "acme"      no: route to "default" |
             +-------------------------------------------------+
  shard "default": thousands of small tenants, partitioned by payload (is_tenant index)
  shard "acme", "globex", ...: one dedicated shard per promoted tenant

Queries send the same selector and the tenant filter. To promote a tenant, you create its shard key in the Partial state and copy its points with the replicate_points API; when the transfer completes the shard becomes Active, requests route there automatically, and you delete the tenant's points from the fallback shard. Limits from the docs: the fallback and dedicated shards must have shard_number 1 (a new dedicated shard starts with replication_factor 1 and gets replicas afterwards), so all small tenants together must fit in one shard, and thus on one node, and so must each dedicated tenant. Qdrant advises no more than about a thousand dedicated shards per cluster and suggests promoting a tenant once it reaches roughly 20,000 points.

Layout 4: a collection per tenant

It gives the hardest boundary (per-collection tokens, per-tenant configuration, even a per-tenant embedding model) and the simplest deletion. It is also the most expensive at scale: each collection carries its own overhead, and Qdrant Cloud limits a cluster to 1,000 collections by default. Qdrant's guide recommends it only for "a limited number of tenants that need strict isolation".

Your tenants Layout What you give up
Many, small, similar Payload partition + is_tenant (+ payload_m/m=0 if indexing is the bottleneck) Isolation lives in your code; with m=0, unfiltered queries lose recall
A few large ones needing their own resources Shard keys Physical shards multiply; cluster required
Thousands of small plus a few large, growth unknown Tiered: fallback shard + promotion Fallback shard must fit on one node; promotion is an operation to run
Few tenants, contractual isolation enforced by the database Collection per tenant Per-collection overhead, schema changes × tenants

💡 If tenants use sparse BM25-style vectors with Qdrant's IDF modifier, a payload partition mixes every tenant's vocabulary into one set of document frequencies. Qdrant 1.19 added an idf search parameter to compute those statistics over one tenant's points.

Your turn: 9,000 tenants, 384-d vectors, a self-hosted 6-node cluster with 64 GB RAM and 2 TB NVMe per node. 8,990 small tenants have under 20,000 chunks each, 45 million chunks together; 10 large ones have 2–6 million each. Two of the large ones pay for "dedicated capacity". Pick a layout, check that it fits, and name the first two things you would monitor.

Check your answer

Tiered multitenancy: one collection with custom sharding, a default fallback shard for the 8,990 small tenants (partitioned by payload, is_tenant index created before the upload), and a dedicated shard for each of the 10 large tenants. All ten are far past the roughly 20,000 points at which Qdrant suggests promoting, and ten dedicated shards are far below the thousand it advises against. Place the two paying tenants' shards and their replicas on nodes that do not hold the fallback shard. Collection-per-tenant would mean 9,000 collections, a shard key per tenant 9,000+ physical shards, and pure payload partitioning gives nobody dedicated capacity.

Now the fit check, because the fallback shard lives on one node. Its float32 originals are 45M × 384 × 4 B = 69.12 GB, plus about 5.8 GB of graph links (45M × 128 B) and 2.3 GB of ID tracker (52 B per point): about 77 GB before any headroom, more than the node's 64 GB of RAM. It fits on disk, so keep the originals cold and search a quantized copy: int8 pinned is 17.28 GB, about 25 GB in RAM with the graph and tracker, about 30 GB with 20% headroom. Measure recall@10 against exact=True with and without rescoring. The largest dedicated shard (6M × 1,536 B = 9.2 GB of originals) is easy. Replicating the fallback shard spreads its read load, at the price of one more full copy on another node. Monitor the fallback shard's size and RAM use, because it cannot grow past one node; if it gets close, promote the largest small tenants or split the small tenants over two tiered collections. Also monitor per-tenant point counts and p95, so you promote a tenant before it hurts its neighbours.

Move 4: Shard, replicate, pick consistency

Symptom: the data no longer fits one machine, or restarting one node takes search down.

Everything in this move needs a multi-node cluster, which our single local server cannot run. The facts come from Qdrant's distributed deployment, horizontal scaling, consistency and resilience docs as of v1.19. The concepts (why shard, why replicate) are in Vector Database Architecture; here are Qdrant's knobs.

Shards: choose the number once

A collection is split into shard_number shards; with automatic sharding, Qdrant assigns points to shards by consistent hashing. If you do not set it, shard_number equals the number of nodes when the collection is created (1 on a standalone server). Three rules from the docs:

  • Self-hosted, you cannot change it without creating a new collection. Qdrant Cloud has supported resharding up and down since v1.13.
  • For even load, the shard count should be a multiple of the node count, and at least 2 per node so you can add nodes later.
  • If you expect growth, 12 is a common start: it divides evenly over 1, 2, 3, 4, 6 and 12 nodes. More than 12 in a small cluster mostly adds overhead.

Moving shards between nodes (for example after adding a node) is a supported online operation; in Qdrant Cloud it happens automatically, self-hosted you trigger it.

Replicas: no primary, parallel writes

replication_factor (default 1) is the number of copies of each shard. By default there is no primary replica: a write goes to all active replicas of its shard in parallel, and any replica can serve reads. A replica is Active, Dead (failed and excluded until it recovers) or Partial (catching up). A replication factor above 1 on a single node is meaningless; Qdrant will not put two copies of a shard on one node.

Three settings trade throughput for consistency:

Setting Scope Default Effect Cost
write_consistency_factor collection 1 A write succeeds only after this many replicas applied it Writes fail while fewer replicas than this are active; the client must retry
Read consistency per request 1 majority, quorum, all or a number: query several replicas and keep only the points they agree on More replicas queried per read
Write ordering per request weak medium (a dynamically elected leader) or strong (a fixed leader) serializes writes, so every replica applies them in the same order strong writes are unavailable while that leader is down

Qdrant 1.19 added a lighter option for results that "blink" between replicas while writes propagate: send the same X-Qdrant-Route-Affinity header value (a user or session id) and Qdrant pins those reads to one replica, on a best-effort basis.

Raft: only the metadata

Qdrant runs the Raft consensus protocol for cluster metadata: which nodes exist, where shards live, collection configuration. Creating, updating or deleting a collection needs a majority of nodes. Point writes and searches do not go through Raft; they go straight to the replicas. That split explains Qdrant's node-count advice:

Cluster One node goes down
1 node Everything fails until it restarts
2 nodes, shards replicated on both Reads and writes continue; collection operations fail (1 of 2 is not a majority)
3+ nodes, replication factor ≥ 2 Everything continues

Majority means more than half, so 4 nodes survive one failure for collection operations, the same as 3. Self-hosted Qdrant does not replace a permanently lost node for you; Qdrant Cloud does.

Predict first. Three nodes A, B, C. A collection has 6 shards and replication_factor=2, placed like this:

shard      0      1      2      3      4      5
replicas   A,B    B,C    C,A    A,B    B,C    C,A

Node A dies. With write_consistency_factor=1, which operations still succeed? And with write_consistency_factor=2?

Check your answer

With factor 1, everything: every shard still has one active replica for reads and writes, and B and C are 2 of 3 nodes, a majority, so collection operations work too. With factor 2, searches and collection operations still work, and writes to shards 1 and 4 (replicas on B and C) succeed, but writes that hash to shards 0, 2, 3 or 5 fail, because those shards now have one active replica and the factor asks for two. Setting the factor equal to the replication factor buys "no write is acknowledged unless every copy has it" and pays with write availability during every node restart. When A returns, its replicas catch up and become active again.

Move 5: Build multi-stage queries

Symptom: the ranking you want needs something the first-stage index cannot do cheaply: a model too expensive to run on the whole collection, or a business rule such as "newer policies first".

The Qdrant lesson used prefetch to fuse a dense and a sparse retriever. The same mechanism chains stages: each prefetch produces candidates, and the outer query re-scores only those candidates. Prefetches can nest, so one request can run a cheap stage, a precise stage and a business-rule stage.

 stage 1: prefetch  (cheap, wide)      dense HNSW, or a small / quantized vector    limit 100
            |  candidate ids + scores
 stage 2: query     (precise, narrow)  multivector MaxSim over those 100 only        limit 10

Two rules that cause real bugs:

  • A stage can only re-rank what the stage below returned. If the relevant chunk is not in the prefetch, no reranker can bring it back; that is a recall problem, fixed with a larger prefetch limit or a better first stage.
  • Prefetch limit must be at least the outer limit + offset. We ran a prefetch with limit=5 under an outer limit=10: 5 points came back, not 10, and no error was raised.

A late-interaction rerank inside Qdrant, measured

Late-interaction models such as ColBERT keep one vector per token and score with MaxSim (see Reranking Models). Qdrant stores them as a multivector and computes MaxSim itself. Because the rerank vector is only used on prefetched candidates, it does not need an HNSW graph: Qdrant's docs recommend m=0 for vectors used only for re-scoring, which saves the memory of the graph.

We measured this on SciFact (5,183 scientific abstracts, 300 test claims with relevance labels, from the BEIR benchmark): all-MiniLM-L6-v2 (384-d) as the first stage and answerdotai/answerai-colbert-small-v1 (96-d per token, about 286 tokens per abstract on average) as the reranker, both stored in one collection.

from qdrant_client import QdrantClient, models

client = QdrantClient(url="http://localhost:6333")
NAME = "rerank_demo"
if client.collection_exists(NAME):
    client.delete_collection(NAME)
client.create_collection(NAME, vectors_config={
    "dense": models.VectorParams(size=384, distance=models.Distance.COSINE),
    "colbert": models.VectorParams(
        size=96, distance=models.Distance.COSINE,
        multivector_config=models.MultiVectorConfig(comparator=models.MultiVectorComparator.MAX_SIM),
        datatype=models.Datatype.FLOAT16,          # halves the storage of the token vectors
        hnsw_config=models.HnswConfigDiff(m=0),    # rerank-only: no graph needed
    ),
})
def e(i, dim=96):                   # unit "token vector" along axis i
    v = [0.0] * dim
    v[i] = 1.0
    return v

# toy points: one dense vector and a few token vectors each
client.upsert(NAME, points=[
    models.PointStruct(id=1, vector={"dense": [0.1] * 384, "colbert": [e(0), e(1)]}),
    models.PointStruct(id=2, vector={"dense": [0.2] * 384, "colbert": [e(0), e(2), e(3)]}),
])
hits = client.query_points(
    NAME,
    prefetch=models.Prefetch(query=[0.1] * 384, using="dense", limit=100),   # stage 1
    query=[e(0), e(1)],                                                      # stage 2: query token vectors
    using="colbert",
    limit=10,
).points
print([(h.id, round(h.score, 3)) for h in hits])   # [(1, 2.0), (2, 1.0)]: MaxSim sums each query token's best match
client.delete_collection(NAME)

On SciFact, with nDCG@10 computed against the test labels:

Pipeline nDCG@10 Relevant docs inside the candidate set Mean request time
MiniLM dense, top 10 0.648 recall@10 = 0.788 16 ms
dense top 20 → ColBERT MaxSim 0.724 recall@20 = 0.844 24 ms
dense top 100 → ColBERT MaxSim 0.742 recall@100 = 0.925 40 ms
dense top 300 → ColBERT MaxSim 0.745 recall@300 = 0.970 62 ms

The times are client-side means over the 300 queries on our shared test server, so compare the rows rather than trusting the digits.

Reranking the top 100 lifted nDCG@10 from 0.648 to 0.742 on this data. Going from 100 to 300 candidates added almost nothing (0.745), although the pool then held 4.5 percentage points more of the relevant documents, and every extra candidate costs MaxSim work: the mean request time rose from 16 ms (dense only) to 24, 40 and 62 ms for 20, 100 and 300 candidates. The recall column is the ceiling. With 20 candidates, 16% of the relevant documents were never in the pool, so no reranker could have ranked them. Watch that number when you shrink the pool to save latency. This is one dataset and one pair of models; measure yours.

Business rules in the score: formula queries

Since v1.14, the outer query can be a formula over the prefetch score, payload values and conditions. Typical uses: prefer recent documents, prefer a document type, decay by distance. We scored four chunks for the query "refund policy" with cosine similarity plus a recency bonus and a policy bonus:

from qdrant_client import QdrantClient, models

client = QdrantClient(url="http://localhost:6333")
NAME = "boost_demo"
if client.collection_exists(NAME):
    client.delete_collection(NAME)
client.create_collection(NAME, vectors_config=models.VectorParams(size=4, distance=models.Distance.COSINE))
client.create_payload_index(NAME, "updated_at", models.PayloadSchemaType.DATETIME)
client.create_payload_index(NAME, "doc_type", models.PayloadSchemaType.KEYWORD)
client.upsert(NAME, points=[
    models.PointStruct(id=1, vector=[1.0, 0.1, 0.0, 0.0], payload={"title": "Refund policy 2024", "updated_at": "2024-03-01T00:00:00Z", "doc_type": "policy"}),
    models.PointStruct(id=2, vector=[1.0, 0.2, 0.0, 0.0], payload={"title": "Refund policy 2026", "updated_at": "2026-09-01T00:00:00Z", "doc_type": "policy"}),
    models.PointStruct(id=3, vector=[1.0, 0.0, 0.05, 0.0], payload={"title": "Forum: refunds?", "updated_at": "2026-09-20T00:00:00Z", "doc_type": "forum"}),
    models.PointStruct(id=4, vector=[1.0, 0.3, 0.1, 0.0], payload={"title": "Refund FAQ (no date)", "doc_type": "faq"}),
])
query = [1.0, 0.0, 0.0, 0.0]
recency = models.ExpDecayExpression(exp_decay=models.DecayParamsExpression(
    x=models.DatetimeKeyExpression(datetime_key="updated_at"),
    target=models.DatetimeExpression(datetime="2026-09-25T00:00:00Z"),
    scale=86400 * 365, midpoint=0.5))           # a one-year-old document gets 0.5
formula = models.FormulaQuery(
    formula=models.SumExpression(sum=[
        "$score",                                                   # cosine from the prefetch
        models.MultExpression(mult=[0.05, recency]),                # up to +0.05 for recency
        models.MultExpression(mult=[0.03, models.FieldCondition(    # +0.03 if it is a policy
            key="doc_type", match=models.MatchValue(value="policy"))]),
    ]),
    defaults={"updated_at": "2020-01-01T00:00:00Z"},                # undated = old
)
plain = client.query_points(NAME, query=query, limit=4, with_payload=True).points
boosted = client.query_points(NAME, prefetch=models.Prefetch(query=query, limit=50),
                              query=formula, limit=4, with_payload=True).points
print([(p.payload["title"], round(p.score, 4)) for p in plain])
print([(p.payload["title"], round(p.score, 4)) for p in boosted])
client.delete_collection(NAME)
[('Forum: refunds?', 0.9988), ('Refund policy 2024', 0.995), ('Refund policy 2026', 0.9806), ('Refund FAQ (no date)', 0.9535)]
[('Refund policy 2026', 1.0584), ('Forum: refunds?', 1.0483), ('Refund policy 2024', 1.0335), ('Refund FAQ (no date)', 0.9539)]

The 2026 policy moves from third to first. Check one row by hand: its cosine is 0.9806; it is 24 days old, so the decay is exp(ln(0.5) / 365 days × 24 days) = 0.9554, worth 0.05 × 0.9554 = 0.0478; plus 0.03 for being a policy gives 1.0584. Three details matter in practice:

  • Missing values. Without the defaults entry, the undated FAQ does not score 0; the whole request fails: 400 Bad Request: Wrong input: Expected datetime value for updated_at in the payload and/or in the formula defaults. Error: No value found in a payload nor defaults.
  • Scale. The bonus weights (0.05, 0.03) only make sense relative to the score they are added to. Cosine scores here sit near 1; an RRF score from Qdrant's fusion is a sum of 1/(2 + rank) terms (Qdrant's default k is 2 with zero-based ranks), so the same weights would swamp it. Tune them on labeled queries.
  • Formulas re-score only prefetched points. The outer limit cannot reach a document the prefetch did not return.

The multi-shard fusion trap

Fusion (RRF or DBSF) can sit in the outer query or inside a prefetch. Qdrant's docs note that in a collection with several shards, a fusion inside a prefetch is computed per shard, on each shard's local ranks. We ran the same data (2,000 points, two named vectors) with 1 and with 4 shards on our single node, once with RRF as the outer query and once with RRF inside a prefetch wrapped by a formula that just returns $score:

shards=1  RRF as outer query  : [(1126, 0.5), (657, 0.5), (1218, 0.3333), (1966, 0.3333), (549, 0.25)]
          RRF inside prefetch : [(1126, 0.5), (657, 0.5), (1218, 0.3333), (1966, 0.3333), (683, 0.25)]
shards=4  RRF as outer query  : [(1126, 0.5), (657, 0.5), (1966, 0.3333), (1218, 0.3333), (549, 0.25)]
          RRF inside prefetch : [(1966, 0.5769), (1641, 0.5), (1126, 0.5), (741, 0.5), (683, 0.5)]

With one shard the two forms agree on the top four (the fifth slot is a tie at 0.25). With four shards, the nested form gives several documents the score 0.5, the RRF score of a first-place hit in one list, because each shard ranked its own best matches first. To fuse globally, fusion must be the outer query; a formula over fused results is only globally correct on a single shard.

Your turn: product asks you to halve the time the rerank stage adds on the SciFact setup above. Using only the measured table, what would you try first, what would it cost, and what would you check before shipping?

Check your answer

Shrink the candidate pool from 100 to 20. In the table, the time the rerank adds (request time minus the dense-only 16 ms) falls from about 24 ms (40 − 16) to about 8 ms (24 − 16), while nDCG@10 falls only from 0.742 to 0.724. The cost hides in the recall column: the pool's recall drops from 0.925 to 0.844, so about 8 percentage points more of the relevant documents can no longer be reranked into view. Before shipping, look at the labeled queries whose relevant document sat between ranks 21 and 100 of the dense stage, and check p95, not just the mean. If recall matters more, keep 100 and make the first stage better instead (for example hybrid retrieval), so a smaller pool still holds the relevant chunks.

Move 6: Snapshot, swap aliases, monitor

Symptom: someone deletes the wrong collection; an embedding-model upgrade must go live without a broken hour; or your users notice slow search before your dashboards do.

Snapshots: what they contain and what they don't

A collection snapshot is a tar file with the collection's configuration, points and payloads on one node at one moment. Facts from Qdrant's snapshot docs that decide whether your backup actually restores:

  • Per node. In a cluster, each node's snapshot contains only the shards stored on that node; you snapshot every node.
  • No aliases. Aliases are not in a collection snapshot; recreate them after a restore.
  • Version window. A snapshot restores only on the same minor version or the next one (a v1.18.1 snapshot restores on v1.18.x with x ≥ 1, or on v1.19.x). Test restores after every upgrade.
  • Priority. When restoring, priority picks the source of truth when snapshot and existing data differ: replica (default) prefers existing data, snapshot prefers the snapshot, no_sync skips synchronization and is for manual shard surgery. Qdrant's docs recommend snapshot when restoring into a new collection or cluster.
  • Storage. Snapshots land in the node's snapshots_path (/qdrant/snapshots in the Docker image) or, since v1.10, in S3-compatible storage. A snapshot on the same disk as the data is not a backup.
  • Whole-storage snapshots (all collections plus aliases) exist too, but only for single-node deployments. Qdrant Cloud also offers disk-level backups.
from qdrant_client import QdrantClient, models
import numpy as np

client = QdrantClient(url="http://localhost:6333")
SRC, DST = "snap_src", "snap_restored"
for name in (SRC, DST):
    if client.collection_exists(name):
        client.delete_collection(name)
client.create_collection(SRC, vectors_config=models.VectorParams(size=8, distance=models.Distance.COSINE))
client.upload_collection(SRC, vectors=np.random.default_rng(7).standard_normal((500, 8)).astype(np.float32), ids=range(500))

snap = client.create_snapshot(SRC)                      # synchronous; returns name and size
client.recover_snapshot(DST, location=f"file:///qdrant/snapshots/{SRC}/{snap.name}",
                        priority=models.SnapshotPriority.SNAPSHOT)   # the path is on the Qdrant node
print(client.count(DST).count)                          # 500

client.delete_snapshot(SRC, snap.name)
for s in client.list_snapshots(DST):
    client.delete_snapshot(DST, s.name)
for name in (SRC, DST):
    client.delete_collection(name)

The file:// location is read by the server, so it works here because the snapshot was created on the same node. To move a snapshot to another cluster, upload the file to POST /collections/{name}/snapshots/upload?priority=snapshot, or give the server a URL it can reach.

Re-embedding without a broken hour: aliases

A new embedding model, a new chunking strategy or a new distance means re-embedding everything (for a chunking change alone the space stays the same, so an in-place sync also works; the second collection lets you evaluate before users see it); see Vector Embeddings for why a same-dimension model swap fails silently rather than loudly. Re-embedding in place mixes old-model and new-model vectors in one collection for the whole migration, and every query compares against a mixture. Build a second collection instead and point an alias at whichever one is live. The application only ever queries the alias; alias changes are atomic, so no request sees a half-switched state.

from qdrant_client import QdrantClient, models

client = QdrantClient(url="http://localhost:6333")
for name, size in (("docs_v1", 384), ("docs_v2", 1024)):     # old model, new model
    if client.collection_exists(name):
        client.delete_collection(name)
    client.create_collection(name, vectors_config=models.VectorParams(size=size, distance=models.Distance.COSINE))

client.update_collection_aliases(change_aliases_operations=[
    models.CreateAliasOperation(create_alias=models.CreateAlias(collection_name="docs_v1", alias_name="docs"))])
# ... fill docs_v2 with new-model vectors, wait for indexing, run your eval set against it ...
client.update_collection_aliases(change_aliases_operations=[   # one atomic switch
    models.DeleteAliasOperation(delete_alias=models.DeleteAlias(alias_name="docs")),
    models.CreateAliasOperation(create_alias=models.CreateAlias(collection_name="docs_v2", alias_name="docs")),
])
print(client.get_collection("docs").config.params.vectors.size)   # 1024: the alias now resolves to v2

client.update_collection_aliases(change_aliases_operations=[
    models.DeleteAliasOperation(delete_alias=models.DeleteAlias(alias_name="docs"))])
for name in ("docs_v1", "docs_v2"):
    client.delete_collection(name)

The cost is temporary: two collections' worth of storage and indexing during the migration. The benefit is a rollback that is one more alias switch, as long as you keep v1 until the new model has proven itself on your evaluation set (see Retrieval Metrics). Remember that the query side must switch models at the same moment as the alias.

What to watch

Our local server's /metrics endpoint (Prometheus format) exposes names such as these. No qdrant_ prefix appears unless you configure service.metrics_prefix.

Question Where to look
Is search getting slower? rest_responses_duration_seconds (histogram; grpc_responses_duration_seconds for gRPC). Add ?per_collection=true for a per-collection breakdown
Is data unindexed or backlogged? indexed_vectors_count vs points_count from GET /collections/{name}; GET /collections/{name}/optimizations; metrics collection_running_optimizations, collection_update_queue_length, collection_update_queue_deferred_points, collection_indexed_only_excluded_points
Are replicas healthy? (cluster) collection_dead_replicas, collection_active_replicas_min; GET /collections/{name}/cluster for per-shard states
Is consensus healthy? (cluster) cluster_peers_total, cluster_term, cluster_pending_operations_total; GET /cluster
Memory pressure? memory_resident_bytes, memory_allocated_bytes, process_major_page_faults_total (a rising count means cached/cold data is being re-read from disk)
Can the node take traffic? /healthz, /livez, /readyz (our server answered readyz with "all shards are ready")

Two operational details: /metrics reports only the node you ask, so scrape every node, even behind a load balancer; and since v1.17, GET /cluster/telemetry aggregates telemetry across peers. Qdrant's own docs describe the per-collection indexed_vectors_count as approximate, so alert on a large, persistent gap together with a non-empty optimization queue, not on exact equality.

Final round: no label on the incident

Real incidents don't say which move they need. For each one, name the most likely cause, the first thing you would check, and the fix with its cost.

Incident 1. After a model upgrade the team re-embedded the corpus in place, 200,000 chunks an hour, into the live collection (same 768 dimensions as before). For six hours answer quality dropped, with no errors anywhere, then recovered on its own.

Check your answer

During the migration the collection held a mixture of old-model and new-model vectors, and the query encoder matched only one of them. Because the dimensions were equal nothing errored. Similarities between vectors from two different models are unreliable: some queries still look right, many do not, which is exactly the quiet degradation no error or alert catches. Check: the migration log against the incident window. Fix: next time, fill a new collection and swap an alias once your evaluation set passes; the cost is double storage for the duration.

Incident 2. Tenant admins can now "search all workspaces". Normal users get excellent results; the all-workspaces results look random, and there are no errors. Last month you switched the collection to payload_m=16, m=0 to speed up indexing.

Check your answer

"All workspaces" is a query without a tenant filter, and with m=0 there is no global graph for it to walk. The search follows tenant-local links only and returns poor neighbours without complaint (we measured recall@10 of 0.087 against exact search in this situation). Check: the request filter and the collection's hnsw_config. Fix options: run cross-workspace search as one filtered query per workspace the admin can see and merge the results in your service; use exact=True for these rare queries (correct, but a full scan); or build the global graph again (m=16) and give up the indexing speed-up.

Incident 3. You restored last night's snapshot of support_v7 onto a fresh server after a disk failure. The collection is there with the right point count, but every query from the app fails with "Not found: Collection support doesn't exist!"

Check your answer

The app queries the alias support, and collection snapshots do not contain aliases. Recreate the alias (support → support_v7), then add alias creation to your restore runbook, or use a whole-storage snapshot on a single-node deployment, which does include aliases.

Incident 4. Every afternoon during the CRM sync, p95 search latency triples. The collection turns yellow, /optimizations shows the indexing optimizer running, and CPU is saturated. Search results are complete.

Check your answer

Read-write contention: the sync fills large unindexed segments, queries brute-force them, and indexing competes with search for CPU. Options with their costs: prevent_unoptimized=True with wait=False writes (fast searches; synced changes appear only after indexing); fewer optimization threads (max_optimization_threads) or a node-level optimizer_cpu_budget (less contention, but the backlog drains more slowly); more replicas on additional nodes so reads spread out (more nodes and storage, and every replica repeats the same writes and indexing work, so replicas added to the same busy nodes would make the contention worse). Measure p95 and the deferred-point count during the next sync.

Cheat sheet: symptom → move → cost

When you see… Reach for You pay with
Green collection, slow search, indexed_vectors_count far below points_count indexing_threshold back on; wait for /optimizations to empty Indexing time before the data is fast
Heavy writes make search slow prevent_unoptimized + wait=False; optimizer thread/CPU limits New points invisible until indexed; unsynced writes for up to 5 s; slower backlog
Vectors don't fit in RAM Quantized copy pinned, originals cached or cold, HNSW cached Recall (fix with rescoring + oversampling) or disk reads per query
Binary quantization loses recall on a small model Rescore + oversampling, 2-bit or 1.5-bit encoding, asymmetric query encoding, or TurboQuant Less compression or more rescoring I/O
Thousands of small tenants Payload partition + is_tenant (+ payload_m/m=0) Isolation in your code; with m=0, never query without a tenant filter
A few huge tenants among many small ones Tiered multitenancy (fallback shard, promote) A cluster, promotion operations
One node can't hold it or serve it Shards (≥ 2 per node, 12 for growth) + replication_factor ≥ 2 on 3+ nodes Storage × replicas; shard count fixed self-hosted
Writes must be on several copies before ack write_consistency_factor Writes fail while too few replicas are active
Need precision on top of recall Prefetch chain; rerank vector with m=0 Latency per stage; can't rerank what wasn't fetched
Business rules in ranking Formula query with defaults Weights to tune per score scale; per-shard fusion trap
Model upgrade New collection + alias swap Double storage during migration
Disaster recovery Snapshots per node, stored off the node; aliases recreated Storage; restore tests after each upgrade

How would you measure it on your own data?

  • Latency: p50/p95 of the server-side time of real queries, before and after, during load and at rest.
  • ANN and quantization fidelity: 200 sample queries, compare the top 10 against exact=True (and quantization ignore=True) to get ANN recall@10 for every configuration you consider.
  • Retrieval quality: nDCG@10 or recall@k on a labeled query set whenever a stage changes (quantization, reranker, formula weights). The Evaluation & Quality Metrics lessons go deeper.
  • Freshness: time from write to "visible in search" under your real write rate, especially with prevent_unoptimized.
  • Resilience: in staging, stop one node during load and watch write errors, collection_dead_replicas and recovery time.

Before moving on, take one production collection you know and answer from memory: how many segments and shards it has, whether its data is fully indexed, where its quantized and original vectors live, how it would behave if one node died, and how you would restore it on a new server tomorrow. Each answer you cannot give is a place to look.

Next: Hybrid Retrieval Systems for fusing lexical and dense retrieval, Metadata & Filtering for access control and filter design, and Production Deployment & Optimization for the rest of the serving stack.