How Qdrant Works Internally: HNSW Indexing, Segment Architecture, and the Rust-Based Vector Engine Behind Production Similarity Search
A deep-dive into Qdrant's internals for senior engineers. Covers the segment lifecycle with immutable segments and WAL-backed mutable storage, HNSW graph construction with ef_construct and m parameters, scalar/product/binary quantization tradeoffs, the query planner's pre-filter vs post-filter decision logic, distributed mode with Raft-based shard management, and production tuning for memory-mapped vs in-memory storage.
If you already know what approximate nearest neighbor (ANN) search is and why vector databases exist, this article skips past the motivation. The focus here is on what Qdrant actually does inside its Rust runtime: how it stores vectors, how it builds and searches its HNSW graph, how it handles filtering without destroying recall, and what happens when you run it as a distributed cluster.
The Storage Model: Segments All the Way Down
Qdrant organizes all data inside a collection into segments. A segment is a self-contained unit holding a subset of the collection’s points, their payload (arbitrary JSON), and an optional vector index. Understanding the segment lifecycle is the foundation of understanding everything else.
Collection
├── Shard 0
│ ├── Segment A (immutable, indexed)
│ ├── Segment B (immutable, indexed)
│ └── Segment C (mutable, WAL-backed, unindexed or partially indexed)
└── Shard 1
├── Segment D (immutable, indexed)
└── Segment E (mutable, WAL-backed)
Mutable Segment and the WAL
Every write (insert, update, delete) goes to the mutable segment for a shard. Before the mutable segment accepts a write, Qdrant appends the operation to a write-ahead log stored on disk. The WAL is a plain append-only file; recovery after a crash replays it to rebuild the mutable segment’s in-memory state.
The mutable segment does not have a full HNSW index. It has a flat structure: vectors are stored sequentially, and searches against it are brute-force. This is intentional. Building an HNSW graph requires knowing the neighborhood structure of all points, which is not possible while the segment is actively receiving writes.
Immutable Segments and the Optimizer
When the mutable segment grows past a threshold (configurable via optimizer_config.indexing_threshold, default 20,000 vectors), the optimizer kicks in. The optimizer runs as a background task and does the following:
- Seals the current mutable segment, making it immutable.
- Constructs the HNSW index over the now-frozen set of vectors.
- Optionally applies quantization to compress the vector data.
- Replaces the old unindexed segment with the new indexed one via an atomic file rename.
- Opens a new mutable segment for incoming writes.
The optimizer also runs segment merging: small immutable segments are combined into larger ones to reduce the overhead of searching across many segment files. This mirrors the compaction logic in LSM-tree storage engines like RocksDB, though the motivation is search fan-out reduction rather than read amplification from overlapping sorted runs.
Deletions in Qdrant are soft-deletes. A deleted point ID is recorded in a bitset (the deleted vector mask) attached to the segment. The point’s storage slot is not immediately reclaimed. Reclamation happens during the next optimizer merge that covers that segment, at which point the optimizer writes a new segment excluding the deleted points.
HNSW: Parameters That Actually Matter
Qdrant uses HNSW (Hierarchical Navigable Small World) as its primary index type. The implementation is written in Rust and lives in the hnsw_efs module. Two parameters dominate index quality and build time:
m (default: 16): The number of bidirectional connections each node maintains per layer. Higher m increases recall and search accuracy at the cost of more memory and longer index build time. For high-dimensional embeddings (1536+), values between 16 and 64 are typical. Below 8, recall degrades sharply on hard queries.
ef_construct (default: 100): The size of the dynamic candidate list during index construction. Each new node inserted into the graph runs a beam search of width ef_construct to find its m best neighbors. Higher values produce a denser, more accurate graph but slow down indexing. For production ingestion pipelines where indexing throughput matters, values between 64 and 200 are a reasonable range to benchmark.
At search time, there is a third parameter:
ef (search ef, default: follows collection config): Controls beam search width during query execution. A higher ef increases recall at the cost of latency. Unlike ef_construct, this can be set per request, letting callers trade recall for speed on a query-by-query basis.
import { QdrantClient } from "@qdrant/js-client-rest";
const client = new QdrantClient({ url: "http://localhost:6333" });
// Create a collection with explicit HNSW parameters
await client.createCollection("embeddings", {
vectors: {
size: 1536,
distance: "Cosine",
},
hnsw_config: {
m: 32,
ef_construct: 128,
full_scan_threshold: 10000, // below this count, skip index, use brute force
},
optimizers_config: {
indexing_threshold: 20000,
memmap_threshold: 50000,
},
});
// Per-query ef override
const results = await client.search("embeddings", {
vector: queryEmbedding,
limit: 10,
params: {
hnsw_ef: 256, // higher recall for this query
exact: false,
},
});
One frequently misunderstood field is full_scan_threshold. When a segment has fewer vectors than this value, Qdrant skips the HNSW graph entirely and does an exact brute-force scan. For small segments (newly sealed, not yet merged) this is faster than graph traversal because HNSW’s constant overhead dominates at small N. This means freshly ingested data is always searched exactly, which is a useful property for recall correctness during warm-up.
Quantization: Three Options, Three Tradeoff Profiles
Qdrant supports three quantization modes, each operating on a different axis of the precision-memory-speed tradeoff.
Scalar Quantization (SQ)
Compresses each float32 component to int8, achieving 4x memory reduction with minimal recall loss (typically under 1% on standard benchmarks at ef=128). The quantized vectors are stored in the segment file alongside a per-dimension calibration table (min and max values observed during quantization) used to reconstruct approximate float values for scoring.
await client.updateCollection("embeddings", {
quantization_config: {
scalar: {
type: "int8",
quantile: 0.99, // use 99th-percentile range for calibration
always_ram: true, // keep quantized vectors in RAM, full vecs on disk
},
},
});
always_ram: true is the most important production setting for scalar quantization. It keeps the compressed int8 vectors in RAM for fast HNSW graph traversal while leaving the full float32 vectors in memory-mapped files on disk. The search path becomes: traverse HNSW using int8 vectors (fast, low memory), then re-score the top-K candidates using full float32 vectors from disk (accurate, bounded IO). This is called rescoring and is enabled by default.
Product Quantization (PQ)
Splits each vector into M sub-vectors and quantizes each independently using learned codebooks. PQ can achieve 8x to 32x compression ratios but requires a training phase over a sample of the data to learn the codebooks. Recall loss is higher than scalar quantization: 2-5% is typical at the same search ef.
PQ is the right choice when memory pressure is severe and you can tolerate a training step during index build. It is not suitable for collections that need to be indexed immediately or where the vector distribution shifts frequently (codebooks would need retraining).
Binary Quantization (BQ)
Converts each float32 component to a single bit (positive/negative), achieving 32x compression. This sounds extreme, but for embeddings from models that produce representations with strong sign structure (OpenAI text-embedding-3-large, Cohere embed-v3, and similar production models), BQ preserves enough geometric information for practical recall at high ef values.
The tradeoff: you need significantly higher hnsw_ef at query time to compensate for the quantization noise, typically 512 or above. And rescoring against full float32 vectors is mandatory rather than optional. But the throughput improvement is dramatic: binary XOR operations over int64 words replace float multiply-accumulate, which translates directly to faster graph traversal on commodity CPUs.
Compression comparison (1536-dim float32 vector):
float32 raw: 6,144 bytes
int8 scalar: 1,536 bytes (4x)
product quant: 192-768 bytes (8-32x, depends on M param)
binary: 192 bytes (32x)
Payload Indexes and the Query Planner
The feature that separates Qdrant from pure ANN engines is filtered vector search. You can attach arbitrary JSON payloads to each point and search with conditions like {"must": [{"key": "tenant_id", "match": {"value": "acme"}}, {"key": "created_at", "range": {"gte": 1717200000}}]}.
Filtered search is deceptively hard to implement correctly. Naive post-filtering (run ANN, then filter results) fails when the filter is selective: if only 1% of vectors match the filter, a top-10 ANN query needs to over-fetch by 100x to guarantee 10 results, destroying latency.
Qdrant handles this with a query planner that estimates filter selectivity and chooses between two strategies:
Post-filtering: Traverse the HNSW graph normally, collect candidates, then apply filters. Efficient when filters eliminate few points (high selectivity ratio, most vectors pass).
Pre-filtering (indexed filtering): Build a filtered candidate set from payload indexes first, then run ANN search restricted to that candidate set. The HNSW traversal only considers nodes in the candidate set. This is efficient when the filter is selective (few vectors pass) because the graph traversal space is small.
The planner estimates selectivity using cardinality statistics maintained in the payload indexes. For a keyword field with a known cardinality, the planner can compute matching_count / total_count and switch to pre-filtering when that ratio drops below a threshold (around 0.1 by default, tunable).
// Create payload indexes to enable query planner optimization
await client.createPayloadIndex("embeddings", {
field_name: "tenant_id",
field_schema: "keyword",
});
await client.createPayloadIndex("embeddings", {
field_name: "created_at",
field_schema: "integer",
});
// Filtered search - the planner will choose pre or post filter
const results = await client.search("embeddings", {
vector: queryEmbedding,
limit: 10,
filter: {
must: [
{ key: "tenant_id", match: { value: "acme" } },
{ key: "created_at", range: { gte: 1717200000 } },
],
},
with_payload: true,
});
Without payload indexes, Qdrant falls back to scanning all payloads for filter evaluation, which works but kills performance at scale. Creating an index is an async operation; Qdrant builds it in the background without blocking reads or writes.
ASCII Architecture: Single Node
Client (REST / gRPC)
│
▼
┌───────────────────────────────┐
│ API Layer │
│ actix-web (REST :6333) │
│ tonic (gRPC :6334) │
└───────────┬───────────────────┘
│
▼
┌───────────────────────────────┐
│ Collection Manager │
│ - Collection config │
│ - Shard routing │
│ - Alias management │
└───────────┬───────────────────┘
│
┌──────┴──────┐
▼ ▼
┌─────────┐ ┌─────────┐
│ Shard 0 │ │ Shard 1 │ (local shards)
│ │ │ │
│ WAL │ │ WAL │
│ Mutable │ │ Mutable │
│ Segment │ │ Segment │
│ │ │ │
│ Imm Seg │ │ Imm Seg │
│ (HNSW + │ │ (HNSW + │
│ index) │ │ index) │
└────┬────┘ └────┬────┘
│ │
▼ ▼
┌───────────────────────────────┐
│ Storage Backend │
│ mmap files (vectors) │
│ RocksDB (payload + IDs) │
└───────────────────────────────┘
▲
│
┌────────┴──────────────────────┐
│ Optimizer Thread Pool │
│ - Segment sealing │
│ - HNSW index construction │
│ - Quantization │
│ - Segment merging │
└───────────────────────────────┘
Payload and point ID mappings are stored in RocksDB (embedded, not a separate process). Vectors themselves live in flat binary files accessed via memory-mapped IO. This split lets Qdrant use RocksDB’s sorted structure for efficient payload lookups and range scans while avoiding its overhead for raw vector data, where sequential access patterns favor simple mmap files.
Distributed Mode: Raft and Sharding
When you run a Qdrant cluster, the architecture adds two new concerns: distributing data across nodes and maintaining consistent collection metadata.
Collection metadata (schema, shard placement, replication factor) is managed by a Raft consensus group across all nodes. Every node participates in the Raft group for cluster-wide metadata. This means collection creation, deletion, and configuration changes are strongly consistent: they go through a Raft log commit before being applied. With a 3-node cluster, the cluster tolerates one node failure for metadata operations.
Data (vectors and payloads) is distributed via shards. When you create a collection with shard_number: 6 and a 3-node cluster, each node holds 2 shards. Shard assignment is deterministic: point IDs are hashed to a shard using a consistent hashing scheme. This means a given point ID always maps to the same shard regardless of how many nodes are in the cluster, which simplifies routing.
3-node cluster, 6 shards, replication factor 2:
Node 0: Shard 0 (leader), Shard 1 (leader), Shard 4 (replica), Shard 5 (replica)
Node 1: Shard 2 (leader), Shard 3 (leader), Shard 0 (replica), Shard 1 (replica)
Node 2: Shard 4 (leader), Shard 5 (leader), Shard 2 (replica), Shard 3 (replica)
Writes go to the shard leader, which replicates to followers synchronously (by default) before acknowledging. You can relax this to async replication per-request:
await client.upsert("embeddings", {
wait: false, // async write, higher throughput, weaker durability guarantee
ordering: "weak", // only shard leader needs to acknowledge
points: batch,
});
Search requests are fan-out: the coordinating node sends the query to all shard leaders (or one replica per shard, load balanced), collects top-K results from each, and merges them into a final result set. The merge is a simple top-K selection by score. For a 10-result query across 6 shards, the coordinator fetches the top-10 from each shard (60 candidates total) and returns the global top-10.
The Raft metadata plane and the shard replication plane are independent. Raft handles only collection-level operations; shard replication uses a direct peer-to-peer protocol between shard leader and followers.
Production Configuration Decisions
Memory-Mapped vs In-Memory Storage
Qdrant offers three storage modes for vector data, controlled by on_disk at the vector config level and memmap_threshold at the optimizer level:
In-memory: All vectors loaded into heap memory at startup. Fastest reads, highest memory cost. Use for collections under 10M vectors where you can afford the RAM.
Memory-mapped (mmap): Vectors live in mmap files. The OS page cache handles what fits in RAM; the rest is served from disk. This is the default for large segments (past memmap_threshold). Access latency is a function of your page cache hit rate. On a cold node, expect high-latency first searches.
On-disk with quantization: Quantized vectors in RAM (via always_ram: true), full vectors on disk. Combines fast graph traversal with bounded memory usage. This is the recommended production configuration for large collections where recall requirements are high but RAM is constrained.
Snapshots and Backup
Qdrant snapshots are the primary backup mechanism. A snapshot captures a consistent point-in-time view of a collection:
# Create snapshot via REST
curl -X POST "http://localhost:6333/collections/embeddings/snapshots"
# Response includes snapshot name and creation timestamp
# Snapshots are stored at /qdrant/snapshots/<collection>/<snapshot>.snapshot
Snapshots are implemented using hard-link copies of segment files. Because segments are immutable once indexed, the hard-link approach is safe: the snapshot and the live collection share physical disk blocks until either is modified. The mutable segment is checkpointed (WAL flushed, segment state serialized) before the snapshot is finalized.
For distributed clusters, you must snapshot each node independently. There is no cluster-level atomic snapshot; coordinating consistent cross-shard snapshots requires stopping writes or accepting a fuzzy snapshot window.
Observability
Qdrant exposes a /metrics endpoint in Prometheus text format. Key metrics to watch:
qdrant_collections_total_points: total points across all collectionsapp_info: version and build metadataqdrant_rest_responses_duration_seconds: request latency by endpointqdrant_grpc_responses_duration_seconds: gRPC latency histogram
The telemetry endpoint (/telemetry) exposes detailed per-collection stats including optimizer runs, segment counts, and indexing progress, useful during initial data loading to track when the optimizer has finished building HNSW indexes.
Tradeoffs vs Alternatives
| Dimension | Qdrant | Pinecone | Weaviate | Milvus | pgvector |
|---|---|---|---|---|---|
| Runtime | Rust (single binary) | Proprietary managed | Go + Java | Go + C++ (Knowhere) | C extension for Postgres |
| Index type | HNSW (built-in) | Proprietary ANN | HNSW + flat | HNSW, IVF, DiskANN | HNSW, IVF (via ivfflat) |
| Quantization | SQ, PQ, BQ built-in | Managed internally | SQ (PQ in beta) | SQ, PQ, BQ | None (raw float32/16) |
| Filtered search | Pre/post filter query planner | Managed internally | Pre-filter + HNSW | Pre-filter | Sequential scan or partial index |
| Self-hosted | Yes, Docker/K8s | No (managed only) | Yes | Yes | Yes (it’s Postgres) |
| Distributed | Raft metadata + shard replication | Fully managed | Raft-based | etcd + shard replication | Postgres replication (not ANN-aware) |
| Storage flexibility | RAM, mmap, disk+quant | Managed (no control) | In-memory + mmap | tiered (S3-backed) | Postgres storage (tablespace) |
| Payload filtering | Native payload indexes | Metadata filters | GraphQL + filters | Scalar index | SQL WHERE clauses |
| Operational complexity | Low (single binary) | Zero (fully managed) | Medium | High (many components) | Low (Postgres ops) |
| Best fit | Self-hosted, full control, production ANN | Zero-ops managed, fast start | Semantic knowledge graphs, multimodal | Billion-scale self-hosted | Already on Postgres, moderate scale |
Qdrant’s storage architecture gives you more knobs than Pinecone (where you control nothing) and fewer moving parts than Milvus (which requires etcd, a message queue, and separate coordinator, data, and index nodes). For teams that need self-hosted ANN search with production-grade filtering and quantization, and who want a single-binary operational model, Qdrant sits in a reasonable middle ground. For teams already on Postgres at moderate scale (under 10M vectors, pgvector with HNSW handles more than most people expect), the additional operational surface area may not be worth it.
The segment architecture, the query planner’s filter/index interplay, and the quantization pipeline are what make Qdrant’s performance numbers reproducible in production rather than just on benchmarks. Understanding those three pieces is what lets you tune it correctly rather than copying defaults from a blog post.
More in System Design
How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL
A deep dive into Aurora's storage-compute separation, the log-is-the-database design that pushes redo log processing to storage nodes, quorum-based replication across 6 copies in 3 AZs, protection group architecture, fast cloning, Serverless v2 scaling mechanics, and honest tradeoffs versus RDS, self-managed PostgreSQL, CockroachDB, and Cloud Spanner.
How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale
A deep dive into CockroachDB's internals: the 512MB range-based data model with automatic splitting, per-range Raft replication, MVCC timestamp ordering with hybrid logical clocks, DistSQL physical planning, serializable transactions with timestamp refreshes, closed timestamps for follower reads, online schema changes using multi-version schema descriptors, and production considerations for hotspots and write amplification.
How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation
Containers are not virtual machines and they are not magic. They are a thin composition of Linux kernel primitives: namespaces, cgroups, and a layered filesystem. This article traces exactly what happens between docker run and a running process, covering the OCI spec, containerd, runc, overlay filesystems, and container networking at the kernel level.
How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally
A deep dive into YugabyteDB internals covering the DocDB storage engine built on RocksDB LSM trees, per-tablet Raft consensus, YSQL and YCQL dual query layers, distributed MVCC with hybrid logical clocks, automatic tablet splitting and rebalancing, xCluster multi-region replication, and production considerations for hotspots, connection pooling, and schema design.