Qdrant vector search developer on retainer: collection created with on_disk: true for vectors — HNSW traversal fetches every candidate from disk — p99 800ms at 200k vectors — increasing ef doubles latency — 3 weeks — Qdrant qdrant-client developer on monthly retainer

Qdrant Cloud 1.9.x • qdrant-client 1.9.x • Python 3.11 • 200,000 vectors • 1,536 dims • cosine similarity • VectorParams(on_disk=True) set to reduce RAM • HNSW traversal loads 6,144 bytes from disk per candidate • p99 800ms vs 12ms in-memory • developer increases ef 64 → 256 • p99 rises to 1,600ms • checks HNSW params, shard count, Qdrant Cloud tier — 3 weeks • fix: recreate collection with on_disk=False • re-upload 200k vectors • p99 12ms • 1.5h retainer engagement

Northfield Vector Platform Ltd. built their semantic search service on Qdrant Cloud in July 2026. Their 200,000 document embeddings (1,536 dims, OpenAI text-embedding-3-small) fit comfortably in 1.2 GB of RAM, but to stay within their starter Qdrant Cloud cluster's memory limit the developer created the collection with on_disk=True for vectors. Search worked correctly — the right documents came back — but production p99 latency was 800ms, far above the 50ms SLA. The developer spent three weeks tuning HNSW parameters: increasing ef from 64 to 128 to 256, adjusting m, and redistributing shards. Every change either had no effect or made latency worse. At ef=256, p99 rose to 1,600ms. They engaged a Qdrant retainer developer.

The collection configuration and search code at the time of the engagement:

# search_client.py
# Northfield Vector Platform Ltd.
# qdrant-client 1.9.x, Python 3.11, Qdrant Cloud 1.9.x

from qdrant_client import QdrantClient
from qdrant_client.http.models import (
    VectorParams, Distance, HnswConfigDiff,
    PointStruct, SearchParams
)

client = QdrantClient(
    url="https://northfield-qdrant.cloud.qdrant.io:6333",
    api_key="...",
)

COLLECTION_NAME = "documents"
DIMS = 1536

# ---- Collection creation ----
# on_disk=True set to keep Qdrant Cloud RAM usage within starter tier limits

client.recreate_collection(
    collection_name=COLLECTION_NAME,
    vectors_config=VectorParams(
        size=DIMS,
        distance=Distance.COSINE,
        on_disk=True,           # ← vectors stored in memory-mapped disk files
                                #   each HNSW candidate comparison loads 6,144 bytes from disk
                                #   under production query load the OS page cache cannot hold
                                #   all 200,000 vectors (1.2 GB) — most reads hit disk
    ),
    hnsw_config=HnswConfigDiff(
        m=16,
        ef_construct=100,
        on_disk=False,          # ← HNSW graph structure IS in RAM (this is on_disk for the graph)
                                #   note: on_disk here is for the HNSW graph, NOT for vectors
                                #   vectors controlled by VectorParams.on_disk above
    ),
)

# 200,000 vectors uploaded via client.upsert()

# ---- Search function ----

def search(query_embedding: list[float], k: int = 10) -> list:
    results = client.search(
        collection_name=COLLECTION_NAME,
        query_vector=query_embedding,
        limit=k,
        search_params=SearchParams(
            hnsw_ef=64,         # ← developer tried 64, 128, 256 — all resulted in high latency
                                #   increasing hnsw_ef worsens latency because it evaluates
                                #   MORE candidates, each requiring a disk read
        ),
        with_payload=True,
    )
    # Results are correct — right documents returned
    # But p99 latency is 800ms at hnsw_ef=64, 1,600ms at hnsw_ef=256
    # Increasing hnsw_ef makes on_disk latency WORSE, not better
    return results

Why on_disk=True makes ef tuning counterproductive

Qdrant's HNSW implementation builds a multi-layer graph where each node represents a vector in the collection. During a search query, Qdrant starts at an entry point in the top layer, greedily traverses toward the query vector, and descends through layers collecting candidate neighbours. At the bottom layer, it evaluates the ef nearest candidates to select the final top-k results.

The critical performance path is the candidate similarity computation: for each candidate node visited during traversal, Qdrant must load the full vector to compute cosine similarity. With on_disk=False (the default), all vectors are resident in RAM and each comparison is a nanosecond-scale SIMD dot product. With on_disk=True, vectors are stored in memory-mapped files on disk. Under sustained query load, the OS page cache cannot hold all 200,000 vectors (1.2 GB at 1,536 float32 dims), so most candidate comparisons require a physical disk read — typically 100μs to 1ms per read on cloud NVMe storage.

The HNSW graph itself remains in RAM (controlled by the separate hnsw_config.on_disk parameter, which defaults to False). The developer saw that the graph was in RAM and reasonably concluded that graph traversal should be fast — but the graph traversal is not the bottleneck. The bottleneck is the vector data fetches that happen at every node visited during traversal. Increasing ef increases the number of nodes visited and therefore the number of disk reads — making the problem proportionally worse.

# Diagnosing the on_disk=True issue — qdrant-client inspection

# Step 1: inspect collection configuration
info = client.get_collection(collection_name=COLLECTION_NAME)
vectors_config = info.config.params.vectors
print(f"on_disk: {vectors_config.on_disk}")
# on_disk: True   ← root cause found

# Step 2: confirm vectors are fully indexed before blaming latency
print(f"vectors_count:         {info.vectors_count}")
print(f"indexed_vectors_count: {info.indexed_vectors_count}")
# vectors_count:         200000
# indexed_vectors_count: 200000   ← HNSW fully built, not the issue

# Step 3: latency comparison — measure search time at different ef values
import time

test_vec = [0.0] * DIMS
test_vec[0] = 1.0

def bench(ef: int, n: int = 50) -> float:
    times = []
    for _ in range(n):
        t0 = time.perf_counter()
        client.search(
            collection_name=COLLECTION_NAME,
            query_vector=test_vec,
            limit=10,
            search_params=SearchParams(hnsw_ef=ef),
        )
        times.append(time.perf_counter() - t0)
    times.sort()
    return times[int(n * 0.99)] * 1000  # p99 in ms

print(f"p99 at ef=64:  {bench(64):.0f}ms")
print(f"p99 at ef=128: {bench(128):.0f}ms")
print(f"p99 at ef=256: {bench(256):.0f}ms")
# p99 at ef=64:   800ms   ← performance with on_disk=True
# p99 at ef=128: 1100ms   ← increases with ef (more disk reads)
# p99 at ef=256: 1600ms   ← 2x at ef=64, confirming disk I/O is the bottleneck
# If latency were CPU-bound, p99 would plateau at high ef values
# The linear relationship between ef and latency proves the bottleneck is I/O, not compute

The fix: recreate collection with on_disk=False

The correct solution is to delete the existing collection and recreate it with on_disk=False (the default), then re-upload all 200,000 vectors. There is no way to change the on_disk storage setting for an existing collection without recreating it — Qdrant's vector storage layout is fixed at collection creation. If reducing RAM usage is still a requirement, the right approach is scalar quantization with ScalarQuantization, which compresses float32 vectors to int8 — reducing vector RAM from 1.2 GB to 300 MB while keeping vectors in memory and maintaining sub-20ms p99 latency.

# search_client.py — FIXED
# qdrant-client 1.9.x, Python 3.11, Qdrant Cloud 1.9.x

from qdrant_client import QdrantClient
from qdrant_client.http.models import (
    VectorParams, Distance, HnswConfigDiff,
    PointStruct, SearchParams, ScalarQuantizationConfig,
    ScalarType, QuantizationConfig
)

client = QdrantClient(
    url="https://northfield-qdrant.cloud.qdrant.io:6333",
    api_key="...",
)

COLLECTION_NAME = "documents"
DIMS = 1536

def recreate_collection_in_memory() -> None:
    """
    Recreate the collection with vectors in RAM and scalar quantization
    to reduce memory footprint without the latency penalty of on_disk=True.
    - float32 vectors in RAM: 1.2 GB for 200k vectors at 1536 dims
    - int8 scalar quantization: ~300 MB for 200k vectors (4x compression)
    - p99 latency with quantization: ~15ms (vs 800ms on_disk)
    """
    client.recreate_collection(
        collection_name=COLLECTION_NAME,
        vectors_config=VectorParams(
            size=DIMS,
            distance=Distance.COSINE,
            on_disk=False,          # ← default; vectors in RAM
        ),
        hnsw_config=HnswConfigDiff(
            m=16,
            ef_construct=100,
        ),
        quantization_config=QuantizationConfig(
            scalar=ScalarQuantizationConfig(
                type=ScalarType.INT8,
                quantile=0.99,      # ← compress all but the top 1% of values
                always_ram=True,    # ← keep quantized vectors in RAM
            )
        ),
    )

def assert_collection_ready(dims: int) -> None:
    """Verify collection is queryable and HNSW is fully built."""
    info = client.get_collection(collection_name=COLLECTION_NAME)

    if info.config.params.vectors.on_disk:
        raise RuntimeError(
            "Collection created with on_disk=True for vectors. "
            "This causes disk I/O per HNSW candidate comparison and high latency. "
            "Recreate the collection with on_disk=False and re-upload all vectors."
        )

    if info.vectors_count != info.indexed_vectors_count:
        raise RuntimeError(
            f"HNSW index not fully built: "
            f"{info.indexed_vectors_count} of {info.vectors_count} vectors indexed. "
            "Wait for indexing to complete before serving queries."
        )

    canary_vec = [0.0] * dims
    canary_vec[0] = 1.0
    client.upsert(
        collection_name=COLLECTION_NAME,
        points=[PointStruct(id=999999999, vector=canary_vec, payload={"_canary": True})],
    )
    results = client.search(
        collection_name=COLLECTION_NAME,
        query_vector=canary_vec,
        limit=1,
        search_params=SearchParams(hnsw_ef=64),
    )
    top_id = results[0].id if results else None
    client.delete(collection_name=COLLECTION_NAME, points_selector=[999999999])

    if top_id != 999999999:
        raise RuntimeError(
            f"Canary search failed: expected id=999999999, got id={top_id}. "
            "HNSW index may be incomplete."
        )
    print(f"[OK] Collection '{COLLECTION_NAME}' ready: on_disk=False, HNSW complete.")

def search(query_embedding: list[float], k: int = 10) -> list:
    results = client.search(
        collection_name=COLLECTION_NAME,
        query_vector=query_embedding,
        limit=k,
        search_params=SearchParams(hnsw_ef=128),
        with_payload=True,
    )
    # After fix: p99 12ms at ef=128 with scalar quantization (vs 800ms with on_disk=True)
    return results

# Call at application startup
assert_collection_ready(dims=DIMS)

Re-uploading vectors after collection recreation

# Re-upload 200,000 vectors after collection recreation

from qdrant_client.http.models import PointStruct
import itertools

def chunked(iterable, n):
    it = iter(iterable)
    while True:
        chunk = list(itertools.islice(it, n))
        if not chunk:
            break
        yield chunk

def upload_all_vectors(vectors_with_payload: list[dict]) -> None:
    """
    Upload all vectors in batches of 500.
    vectors_with_payload: list of {"id": int, "vector": list[float], "payload": dict}
    """
    total = len(vectors_with_payload)
    uploaded = 0

    for batch in chunked(vectors_with_payload, 500):
        client.upsert(
            collection_name=COLLECTION_NAME,
            points=[
                PointStruct(id=item["id"], vector=item["vector"], payload=item["payload"])
                for item in batch
            ],
        )
        uploaded += len(batch)
        if uploaded % 10000 == 0:
            print(f"Uploaded {uploaded}/{total} vectors...")

    # Wait for HNSW indexing to complete
    import time
    while True:
        info = client.get_collection(collection_name=COLLECTION_NAME)
        if info.indexed_vectors_count >= info.vectors_count:
            break
        print(f"Indexing: {info.indexed_vectors_count}/{info.vectors_count} vectors indexed...")
        time.sleep(2)

    print(f"All {total} vectors uploaded and indexed.")

# Execution
recreate_collection_in_memory()
upload_all_vectors(all_vectors_with_payload)  # load from your source
assert_collection_ready(dims=DIMS)

Validation

# Validation log — October 31 2026
# qdrant-client 1.9.x, Python 3.11, Qdrant Cloud 1.9.x

# ---- Before fix ----
# Collection: on_disk=True for vectors
# Total vectors: 200,000; indexed: 200,000 (HNSW complete — not the issue)
# p99 at ef=64:  800ms   — disk I/O per candidate comparison
# p99 at ef=256: 1600ms  — ef increase made latency worse (more disk reads)
# recall@10: 94% (recall was not the problem — latency was)

# ---- After fix: recreate with on_disk=False + scalar quantization ----
# Collection: on_disk=False; ScalarQuantization INT8
# Total vectors: 200,000; indexed: 200,000 ✓
# RAM usage: ~320 MB (int8 quantized, vs 1.2 GB float32)
# p99 at ef=64:  8ms
# p99 at ef=128: 12ms    ← using ef=128 for better recall at negligible latency cost
# p99 at ef=256: 22ms    ← ef=256 now costs only 10ms more (pure compute, no disk I/O)
# recall@10: 93% at ef=128 with int8 quantization (vs 94% without quantization — acceptable)
# assert_collection_ready(): [OK] Collection 'documents' ready

# ---- Hour breakdown ----
# Triage:     0.5h — p99 800ms; ef increase worsens latency; identify on_disk=True
# Root cause: 0.25h — confirm disk I/O per candidate via latency-vs-ef linear relationship
# Fix:        0.5h — recreate with on_disk=False + ScalarQuantization; re-upload 200k vectors
# Validation: 0.25h — p99 12ms at ef=128; recall 93%; assert_collection_ready() passes
# Total:      1.5h

# Retainer engagement cost: 1.5h (estimated $113-180 at mid-level rates)

Related retainer posts


Track retainer hours without a client portal

HourTab turns your time-tracker CSV into a public URL your client bookmarks. No login. No portal. CSV in, URL out.

See pricing →