← Back to DevBytes

How to Index Millions of Documents in Qdrant

How to Index Millions of Documents in Qdrant

Qdrant is an open-source vector search engine built in Rust, designed for high-performance similarity search at scale. When your dataset grows from thousands to millions of vectors, naive indexing strategies quickly break down — memory consumption explodes, query latency creeps up, and ingestion throughput stalls. This tutorial walks through the architecture, configuration, and operational practices required to index millions of documents in Qdrant efficiently.

What Is Vector Indexing in Qdrant?

At its core, Qdrant stores vectors alongside payloads (arbitrary JSON metadata) and builds an index structure — typically an HNSW (Hierarchical Navigable Small World) graph — to enable approximate nearest neighbor (ANN) search. Indexing is the process of inserting vectors, constructing the graph, and optionally building payload indexes for fast filtering. At million-scale, every choice you make about quantization, shard sizing, batching, and filter indexing compounds into either a smooth production system or a slow, memory-hungry mess.

Why It Matters

Indexing a few thousand documents is trivial — almost any configuration works. But at millions of documents, you face three real constraints:

Getting these right means the difference between sub-50ms queries and multi-second timeouts.

Setting Up the Environment

For this tutorial, we'll use the Qdrant Python client. Install it along with a few helpers:

pip install qdrant-client sentence-transformers tqdm

You can run Qdrant locally via Docker:

docker run -p 6333:6333 -p 6334:6334 \
  -v $(pwd)/qdrant_storage:/qdrant/storage \
  qdrant/qdrant

For million-scale workloads, prefer the gRPC endpoint (port 6334) — it has significantly lower overhead than REST for bulk operations.

Creating an Optimized Collection

The collection configuration is where most scaling decisions are locked in. Let's create a collection tuned for millions of 768-dimensional vectors using scalar quantization to cut memory by 4x.

from qdrant_client import QdrantClient
from qdrant_client.http.models import (
    Distance, VectorParams, CollectionConfig,
    ScalarQuantization, ScalarQuantizationConfig,
    ScalarType, QuantizationConfig, HnswConfigDiff,
    OptimizersConfigDiff, OnDiskPayload
)

client = QdrantClient(host="localhost", port=6334, prefer_grpc=True)

COLLECTION_NAME = "documents"
VECTOR_SIZE = 768

client.recreate_collection(
    collection_name=COLLECTION_NAME,
    vectors_config=VectorParams(
        size=VECTOR_SIZE,
        distance=Distance.COSINE,
        on_disk=True,  # store raw vectors on disk, keep only quantized in RAM
    ),
    hnsw_config=HnswConfigDiff(
        m=16,              # connectivity, 16 is a good default
        ef_construct=200,  # higher = better recall, slower build
        full_scan_threshold=10000,
        max_indexing_threads=4,
        on_disk=True,      # graph on disk for large collections
    ),
    optimizers_config=OptimizersConfigDiff(
        indexing_threshold=50000,  # build HNSW after 50k points
        flush_interval_sec=30,
        max_optimization_threads=4,
    ),
    quantization_config=ScalarQuantization(
        scalar=ScalarQuantizationConfig(
            type=ScalarType.INT8,
            quantile=0.99,
            always_ram=True,  # keep quantized vectors in RAM for speed
        ),
    ),
    on_disk_payload=True,  # store payloads on disk, index hot fields
)

print("Collection created.")

Key decisions here:

Generating and Batching the Data

For demonstration, we'll generate synthetic documents. In production, replace this with your embedding pipeline. The critical pattern is batched upserts — never insert one point at a time.

import uuid
import random
from sentence_transformers import SentenceTransformer
from qdrant_client.http.models import PointStruct, Batch

model = SentenceTransformer("all-MiniLM-L6-v2")  # 384-dim; swap for 768-dim model
TOTAL_DOCS = 1_000_000
BATCH_SIZE = 512

def generate_documents(n):
    """Yield synthetic documents with metadata."""
    categories = ["finance", "health", "tech", "legal", "education"]
    for i in range(n):
        text = f"Document number {i} discusses topic {random.choice(categories)} "
        text += f"with reference code {uuid.uuid4().hex[:8]}."
        yield {
            "id": i,
            "text": text,
            "category": random.choice(categories),
            "score": round(random.uniform(0, 1), 2),
            "created_at": 1700000000 + i,
        }

def embed_batch(texts):
    return model.encode(texts, batch_size=64, show_progress_bar=False).tolist()

Uploading Millions of Points Efficiently

The upload loop is where throughput lives. Use client.upsert() with batches, and let Qdrant handle async indexing in the background. Avoid calling client.create_collection() or schema changes during upload.

from tqdm import tqdm

def upload_documents():
    doc_iter = generate_documents(TOTAL_DOCS)
    pbar = tqdm(total=TOTAL_DOCS, desc="Uploading")

    batch_docs = []
    for doc in doc_iter:
        batch_docs.append(doc)
        if len(batch_docs) >= BATCH_SIZE:
            texts = [d["text"] for d in batch_docs]
            vectors = embed_batch(texts)

            points = [
                PointStruct(
                    id=d["id"],
                    vector=v,
                    payload={
                        "text": d["text"],
                        "category": d["category"],
                        "score": d["score"],
                        "created_at": d["created_at"],
                    },
                )
                for d, v in zip(batch_docs, vectors)
            ]

            client.upsert(
                collection_name=COLLECTION_NAME,
                points=points,
                wait=False,  # async — don't block on indexing
            )

            pbar.update(len(batch_docs))
            batch_docs = []

    # flush remaining
    if batch_docs:
        vectors = embed_batch([d["text"] for d in batch_docs])
        points = [
            PointStruct(id=d["id"], vector=v, payload=d)
            for d, v in zip(batch_docs, vectors)
        ]
        client.upsert(collection_name=COLLECTION_NAME, points=points, wait=False)
        pbar.update(len(batch_docs))

    pbar.close()
    print("Upload complete. Waiting for indexing to settle...")

upload_documents()

The wait=False flag is essential at scale. It tells Qdrant to acknowledge receipt and index asynchronously. You can monitor indexing progress:

import time

while True:
    info = client.get_collection(COLLECTION_NAME)
    status = info.status
    indexed = info.indexed_vectors_count
    total = info.points_count
    print(f"Status: {status} | Indexed: {indexed}/{total}")
    if status == "green" and indexed == total:
        break
    time.sleep(10)

print("Indexing complete!")

Adding Payload Indexes for Fast Filtering

Without payload indexes, any filtered query must scan all matching points sequentially. At million-scale, this is catastrophic. Create indexes on fields you filter by before or early during ingestion:

from qdrant_client.http.models import (
    PayloadSchemaType, KeywordIndexParams, IntegerIndexParams,
    FloatIndexParams, GeoIndexParams
)

# Keyword field — exact match filtering
client.create_payload_index(
    collection_name=COLLECTION_NAME,
    field_name="category",
    field_schema=KeywordIndexParams(
        type=PayloadSchemaType.KEYWORD,
        on_disk=False,  # keep in RAM if frequently filtered
    ),
)

# Integer field — range queries
client.create_payload_index(
    collection_name=COLLECTION_NAME,
    field_name="created_at",
    field_schema=IntegerIndexParams(
        type=PayloadSchemaType.INTEGER,
        range=True,
        on_disk=True,
    ),
)

# Float field — range queries
client.create_payload_index(
    collection_name=COLLECTION_NAME,
    field_name="score",
    field_schema=FloatIndexParams(
        type=PayloadSchemaType.FLOAT,
        range=True,
        on_disk=True,
    ),
)

print("Payload indexes created.")

With these indexes, a filtered query like "category == 'tech' AND score > 0.8" will use the index to prune candidates before the HNSW search, keeping latency low even with millions of points.

Querying at Scale

Once indexed, queries should be fast. Here's how to perform a filtered similarity search:

from qdrant_client.http.models import Filter, FieldCondition, MatchValue, Range

query_text = "machine learning in healthcare"
query_vector = model.encode(query_text).tolist()

results = client.search(
    collection_name=COLLECTION_NAME,
    query_vector=query_vector,
    query_filter=Filter(
        must=[
            FieldCondition(key="category", match=MatchValue(value="health")),
            FieldCondition(key="score", range=Range(gte=0.5)),
        ]
    ),
    limit=10,
    with_payload=True,
    with_vectors=False,
    search_params={"hnsw_ef": 128, "exact": False},
)

for hit in results:
    print(f"ID: {hit.id} | Score: {hit.score:.4f} | Category: {hit.payload['category']}")

The hnsw_ef parameter controls search-time accuracy. Higher values improve recall at the cost of latency. For million-scale collections, 128–256 is a good starting point. Use exact=True only for benchmarking or when you need perfect recall on small result sets.

Best Practices for Million-Scale Indexing

1. Use Quantization Aggressively

Scalar quantization (INT8) reduces memory by 4x with minimal recall loss. For extreme scale, consider binary quantization, which reduces memory by 32x and enables SIMD-accelerated Hamming distance computation. Always keep quantized vectors in RAM (always_ram=True) while pushing raw vectors to disk.

2. Batch Everything

Never upsert single points. Aim for batch sizes between 256 and 1024. Larger batches improve throughput but increase memory pressure on the client. If your embedding model is the bottleneck, parallelize embedding across multiple GPU workers and feed batches to Qdrant from a queue.

3. Use gRPC, Not REST

For bulk operations, the gRPC interface is 2–5x faster than REST due to smaller serialization overhead and connection multiplexing. Always set prefer_grpc=True when creating the client.

4. Let Indexing Happen Asynchronously

Use wait=False during upserts. Qdrant's optimizer will build and merge segments in the background. Monitor collection status until it turns green before running production queries or benchmarks.

5. Index Payload Fields Proactively

Create payload indexes before or early in ingestion. Adding indexes to an already-large collection triggers a full rescan, which can take hours at million-scale.

6. Shard for Horizontal Scale

A single Qdrant node handles tens of millions of vectors comfortably with quantization. Beyond that, use Qdrant's distributed mode with multiple shards. Distribute by shard_key to co-locate related data and reduce cross-shard fan-out:

from qdrant_client.http.models import ShardingMethod

client.recreate_collection(
    collection_name="sharded_documents",
    vectors_config=VectorParams(size=768, distance=Distance.COSINE),
    sharding_method=ShardingMethod.CUSTOM,
)

# Route points to specific shards
client.upsert(
    collection_name="sharded_documents",
    points=points,
    shard_key="shard_1",
)

7. Monitor and Tune Optimizers

Qdrant continuously optimizes segment structure. If ingestion is slow, increase max_optimization_threads. If query latency is high during ingestion, lower indexing_threshold so smaller segments get indexed sooner, reducing the number of unindexed segments scanned during search.

8. Snapshot for Backup and Recovery

At million-scale, re-ingesting from scratch is expensive. Take periodic snapshots:

client.create_snapshot(collection_name=COLLECTION_NAME)
# Snapshots are stored in the storage directory and can be restored on a new node.

Conclusion

Indexing millions of documents in Qdrant is fundamentally about making smart trade-offs between memory, latency, and recall. By combining scalar quantization with on-disk vector storage, batching upserts over gRPC, creating payload indexes proactively, and letting Qdrant's async optimizer do its work, you can build a vector search system that handles million-scale datasets with sub-100ms query latency on modest hardware. Start with the configuration patterns shown here, benchmark against your own data distribution, and tune hnsw_ef, m, and batch sizes until you hit your target recall-latency balance. As your dataset grows beyond what a single node can serve, Qdrant's sharding and replication features let you scale horizontally without changing your application code.

— Ad —

Google AdSense will appear here after approval

← Back to all articles