Skip to content

Qdrant — quand tu as 10M+ vecteurs et que tu veux dormir la nuit

TL;DR Qdrant 2026 c'est le champion perf/coût pour ≥ 10M vecteurs. Écrit en Rust, scalabilité ≥ 100M, payload index avancé (filters sur dict/array/range/geo), scalar quantization (RAM/4), binary quantization (RAM/32), on-disk index, hybrid dense+sparse natif, multivector (ColBERT late interaction). Qdrant Cloud dispo en EU (Frankfurt) et offre Hybrid Cloud (control plane chez Qdrant, data chez toi sur Scaleway/OVH). C'est ce que tu vends à un cabinet legal 10M docs, un e-commerce 5M produits avec filtres complexes, ou une agence presse 50M articles. Pricing freelance : 8-15 jours pour un setup prod-grade complet à 1300-1500€/j.

🧠 Mental model

Pourquoi Qdrant et pas pgvector ?

   pgvector                          Qdrant
   ┌───────────────────┐             ┌───────────────────────────┐
   │ Postgres          │             │ Qdrant (Rust)             │
   │  ┌─────────────┐  │             │  ┌─────────────────────┐  │
   │  │ HNSW index  │  │             │  │ HNSW + quantization │  │
   │  │ (mem)       │  │             │  │ + on-disk           │  │
   │  └─────────────┘  │             │  └─────────────────────┘  │
   │  filter = WHERE   │             │  filter = payload index    │
   │   (post-filter    │             │   (pre-filter cardinality- │
   │    surtout)       │             │    aware, sparse-aware)    │
   │                   │             │                            │
   │  ~10M plafond     │             │  100M+, sharding natif     │
   └───────────────────┘             └───────────────────────────┘

Analogie : pgvector = Honda Civic (parfait pour la ville, fiable, partout). Qdrant = Porsche 911 GT3 (te coûte un peu plus mais tu te marres sur circuit à 100M vecteurs avec 200 filtres/req). À 200K vecteurs, le Civic gagne. À 50M vecteurs avec 30 filtres par requête et p99 < 50ms requis, la 911 gagne sans débat.

L'arme secrète : le payload index avec filtering "pré-ANN"

Requête : "robe rouge < 80€ marque ∈ {Sezane, Sandro} taille = M"

   ┌──────────────────────────────────┐
   │  Qdrant query planner            │
   │                                  │
   │  cardinality(brand IN ...) = 12K │
   │  cardinality(price < 80) = 80K   │
   │  cardinality(size = M) = 95K     │
   │  cardinality(color = red) = 6K   │← le plus sélectif
   │                                  │
   │  → pré-filtre sur color = red    │
   │  → ANN HNSW sur les 6K candidats │
   │  → post-filter le reste          │
   └──────────────────────────────────┘


              top-K en < 20ms

C'est le grand différenciateur vs pgvector où le filtre WHERE arrive après le top-K HNSW (ou alors il faut un partial index).

🛠️ Code minimal

python
# pip install qdrant-client[fastembed]
from qdrant_client import QdrantClient
from qdrant_client.models import (
    Distance, VectorParams, PointStruct, Filter, FieldCondition, MatchValue,
    Range, ScalarQuantization, ScalarQuantizationConfig, ScalarType,
)

client = QdrantClient(
    url="https://xxx.eu-central.aws.cloud.qdrant.io:6333",
    api_key="...",
)

client.create_collection(
    collection_name="products",
    vectors_config=VectorParams(
        size=1536,
        distance=Distance.COSINE,
        on_disk=True,
    ),
    quantization_config=ScalarQuantization(
        scalar=ScalarQuantizationConfig(
            type=ScalarType.INT8,
            always_ram=True,  # quantized index en RAM, full vectors sur disque
        )
    ),
)

# Payload index pour filters rapides
client.create_payload_index("products", field_name="brand", field_schema="keyword")
client.create_payload_index("products", field_name="price_cents", field_schema="integer")
client.create_payload_index("products", field_name="tags", field_schema="keyword")

# Upsert
client.upsert(
    collection_name="products",
    points=[
        PointStruct(
            id=42,
            vector=[0.01] * 1536,
            payload={
                "title": "Robe Sezane",
                "brand": "Sezane",
                "price_cents": 7900,
                "tags": ["robe", "été", "bohème"],
            },
        ),
    ],
)

# Search avec filter pre-ANN
hits = client.query_points(
    collection_name="products",
    query=[0.01] * 1536,
    query_filter=Filter(
        must=[
            FieldCondition(key="brand", match=MatchValue(value="Sezane")),
            FieldCondition(key="price_cents", range=Range(lt=10000)),
        ]
    ),
    limit=20,
).points

🎬 Cas d'usage concrets

Cas 1 — Cabinet international Legaltech, RAG 10M docs

Contexte : Cabinet d'avocats avec bureaux Paris/Londres/Bruxelles. 10M documents (jurisprudence FR + UK case law + EU directives). pgvector avait été essayé, p95 = 1.2s avec filtres juridiction + date + matière. Demande : p95 < 200ms, filtres complexes, souveraineté EU.

Décision : Qdrant Cloud Hybrid (control plane Qdrant Cloud, data nodes sur Scaleway Paris). Scalar quantization INT8 (RAM/4) + on-disk vectors. Payload index sur jurisdiction, matter_code, date_range. Hybrid dense + sparse (SPLADE) pour matcher articles de code exact.

Résultat : p95 = 80ms à 10M docs, 1500 QPS soutenus. Coût infra : ~2 200€/mois (vs ~3 800€ projetés sur Pinecone à ce volume).

TJM facturé : 1500€/j × 14 jours = 21 000€ HT.

Cas 2 — E-commerce 5M produits, filtres complexes

Contexte : Marketplace mode B2C, 5M SKU, requêtes avec en moyenne 6 filtres (marque, taille, couleur, prix, dispo, stock magasin, note). Stack actuelle : Elasticsearch + un proto pgvector → lent (200ms+ p95) et coût Elastic 1 800€/mois.

Décision : Qdrant Cloud EU. Vecteur unique (CLIP-like multi-modal text+image) + payload index sur 8 champs. Geo filter pour stock magasin. Sortie d'Elasticsearch.

Résultat : p95 search = 35ms à 5M produits. Coût Qdrant Cloud : 600€/mois. Économie nette : 1 200€/mois et meilleure expérience.

Cas 3 — Agence presse, archive 50M articles

Contexte : Agence presse française, archive 1945-2026, 50M articles indexés. Besoin : recherche journalistes en quasi-temps réel + clipping client (alertes thématiques). Très long tail de requêtes ("manifestation gilets jaunes Toulouse octobre 2018").

Décision : Qdrant self-hosted sur Scaleway Elements (3 nodes, sharding par décennie). Multivector ColBERT pour la précision sur les requêtes longues (late interaction). Quantization binary pour les vecteurs froids (< 2005). Hybrid dense + BM25 sparse.

Résultat : 50M vecteurs, p99 < 50ms. Coût infra : ~900€/mois (vs > 6 000€/mois sur Pinecone). Journalistes gagnent 30 min/jour de recherche → ROI documenté côté client.

🛠️ Exemple end-to-end

Contexte : agence presse, archive 50M articles. Stack : Qdrant Cloud EU + hybrid dense (BGE-M3 multilingue) + sparse (SPLADE) + multivector ColBERT pour le top-50 rerank. Cible : p99 < 50ms.

python
# infra/qdrant_setup.py
from qdrant_client import QdrantClient
from qdrant_client.models import (
    Distance, VectorParams, SparseVectorParams, SparseIndexParams,
    MultiVectorConfig, MultiVectorComparator,
    ScalarQuantization, ScalarQuantizationConfig, ScalarType,
    OptimizersConfigDiff, HnswConfigDiff,
)

client = QdrantClient(url="https://...:6333", api_key="...")

client.create_collection(
    collection_name="articles",
    vectors_config={
        "dense": VectorParams(
            size=1024,                   # BGE-M3
            distance=Distance.COSINE,
            on_disk=True,
        ),
        "colbert": VectorParams(
            size=128,                    # ColBERT tokens
            distance=Distance.MAX_SIM,
            multivector_config=MultiVectorConfig(
                comparator=MultiVectorComparator.MAX_SIM
            ),
            on_disk=True,
            hnsw_config=HnswConfigDiff(m=0),   # pas d'index HNSW : utilisé en rerank
        ),
    },
    sparse_vectors_config={
        "splade": SparseVectorParams(
            index=SparseIndexParams(on_disk=False)
        ),
    },
    quantization_config=ScalarQuantization(
        scalar=ScalarQuantizationConfig(
            type=ScalarType.INT8, always_ram=True
        )
    ),
    optimizers_config=OptimizersConfigDiff(
        memmap_threshold=20_000,
        default_segment_number=8,
    ),
)

# Payload indexes
for field, schema in [
    ("publication", "keyword"),
    ("date", "datetime"),
    ("topics", "keyword"),
    ("region", "keyword"),
    ("word_count", "integer"),
]:
    client.create_payload_index("articles", field_name=field, field_schema=schema)
python
# pipeline/ingest.py — ingestion batch
import os, uuid
from datetime import datetime
from qdrant_client import QdrantClient
from qdrant_client.models import PointStruct, SparseVector
from FlagEmbedding import BGEM3FlagModel
from pylate import models as colbert_models

client = QdrantClient(url=os.environ["QDRANT_URL"], api_key=os.environ["QDRANT_KEY"])

dense_model = BGEM3FlagModel("BAAI/bge-m3", use_fp16=True)        # dense + sparse
colbert = colbert_models.ColBERT("colbert-ir/colbertv2.0")        # multivector


def embed_batch(texts: list[str]) -> tuple[list[list[float]], list[SparseVector], list[list[list[float]]]]:
    res = dense_model.encode(
        texts,
        return_dense=True, return_sparse=True, return_colbert_vecs=False,
        batch_size=16, max_length=8192,
    )
    dense = res["dense_vecs"].tolist()
    sparse_raw = res["lexical_weights"]
    sparse = [
        SparseVector(indices=list(s.keys()), values=list(s.values()))
        for s in sparse_raw
    ]
    colbert_vecs = colbert.encode(texts, batch_size=8, is_query=False)
    return dense, sparse, [v.tolist() for v in colbert_vecs]


def ingest_articles(articles: list[dict]):
    texts = [a["body"][:8000] for a in articles]
    dense, sparse, colbert_vecs = embed_batch(texts)
    points = []
    for a, d, s, cb in zip(articles, dense, sparse, colbert_vecs):
        points.append(PointStruct(
            id=str(uuid.uuid5(uuid.NAMESPACE_URL, a["url"])),
            vector={"dense": d, "splade": s, "colbert": cb},
            payload={
                "title": a["title"],
                "publication": a["publication"],
                "date": a["date"],
                "topics": a.get("topics", []),
                "region": a.get("region"),
                "word_count": len(a["body"].split()),
                "url": a["url"],
            },
        ))
    client.upsert(collection_name="articles", points=points)
python
# api/search.py — recherche hybride avec rerank ColBERT
from qdrant_client.models import (
    Prefetch, FusionQuery, Fusion, Filter, FieldCondition, MatchAny, DatetimeRange
)
from datetime import datetime

def journalist_search(
    query: str,
    publications: list[str] | None = None,
    date_from: datetime | None = None,
    date_to: datetime | None = None,
    k: int = 20,
):
    res = dense_model.encode(
        [query], return_dense=True, return_sparse=True, return_colbert_vecs=False
    )
    q_dense = res["dense_vecs"][0].tolist()
    q_sparse_raw = res["lexical_weights"][0]
    q_sparse = SparseVector(
        indices=list(q_sparse_raw.keys()),
        values=list(q_sparse_raw.values()),
    )
    q_colbert = colbert.encode([query], is_query=True)[0].tolist()

    filters = []
    if publications:
        filters.append(FieldCondition(key="publication", match=MatchAny(any=publications)))
    if date_from or date_to:
        rng = {}
        if date_from: rng["gte"] = date_from.isoformat()
        if date_to:   rng["lte"] = date_to.isoformat()
        filters.append(FieldCondition(key="date", range=DatetimeRange(**rng)))
    qfilter = Filter(must=filters) if filters else None

    # Stage 1 : RRF dense + sparse → 200 candidats
    # Stage 2 : rerank ColBERT MaxSim sur les 200 → top-k
    hits = client.query_points(
        collection_name="articles",
        prefetch=[
            Prefetch(
                query=q_dense,
                using="dense",
                limit=200,
                filter=qfilter,
            ),
            Prefetch(
                query=q_sparse,
                using="splade",
                limit=200,
                filter=qfilter,
            ),
        ],
        query=q_colbert,
        using="colbert",
        limit=k,
    ).points

    return [
        {
            "id": h.id,
            "score": h.score,
            "title": h.payload["title"],
            "publication": h.payload["publication"],
            "date": h.payload["date"],
            "url": h.payload["url"],
        }
        for h in hits
    ]

Numbers mesurés (cluster Qdrant 3 nodes, n2-standard-8, 50M articles) :

  • ingestion : ~6h pour le full backfill (embedding bottleneck, pas Qdrant)
  • p50 search : 18 ms, p95 : 38 ms, p99 : 47 ms
  • coût mensuel : ~900€ infra + ~200€ pipeline embedding
  • recall@10 hybrid vs dense-only : +18% sur eval set 500 requêtes

🎯 Patterns courants

  1. Scalar quantization INT8 par défaut dès > 1M vecteurs. RAM/4, perte recall < 1.5%.
  2. Binary quantization pour les data froides (archive > 5 ans). RAM/32, à rerank avec full vectors top-100.
  3. on_disk=True + quantization_config(always_ram=True) = combo gagnant : vectors complets sur disque, index quantized en RAM.
  4. Payload index obligatoire avant prod — sinon Qdrant scan le payload, perf s'effondre à grande échelle.
  5. Multi-vectors (dense + sparse + colbert) dans une seule collection, sélectionnés par using=. Pas besoin de 3 collections.
  6. prefetch + RRF fusion côté serveur, pas dans ton code Python. Moins de round-trips, plus rapide.
  7. Snapshots avant migrations : client.create_snapshot("collection") + S3 push automatique.
  8. Sharding par dimension métier (décennie, tenant, géographie) plutôt que par hash random. Permet d'aller plus vite quand le filtre exclut des shards entiers.

🔄 Versions & écosystème 2026

ComposantVersion 2026Notes
Qdrant1.13+Mature, multivector stable, async io complet
Client Python1.13+query_points + fusion server-side
Qdrant CloudEU (Frankfurt, Paris en preview), US, APACFree tier 1 GB
Qdrant Hybrid CloudGAControl plane Qdrant, data dans ton compte (AWS/GCP/Azure/Scaleway)
FastEmbed0.4+Inférence locale ONNX (BGE, E5, multilingual)
pylate / RAGatouille0.5+ColBERT v2 / PLAID en Python
Qdrant + LangChain/LlamaIndexnatifConnecteurs maintenus

Nouveauté 2026 : Qdrant Federated Search (alpha) — fan-out vers plusieurs collections (multi-tenant strict ou multi-zone) avec fusion.

⚠️ Pitfalls

  1. Pas de payload index → filtre = full scan payload. À 10M points, latence > 1s. Toujours create_payload_index au schema time.
  2. Distance MAX_SIM sans multivector_config → erreur ou résultats absurdes. ColBERT exige MultiVectorConfig.
  3. always_ram=False + grosse collection → I/O disque à chaque requête, p99 explose. Mettre l'index quantized en RAM.
  4. Mix de vector sizes dans la même collection → impossible. Re-créer la collection ou utiliser named vectors.
  5. Upsert un par un → 100 req/s max. Batch de 100-500 points pour passer à 10K points/s.
  6. Filter trop laxiste (should au lieu de must) → résultats incohérents. should = OR, must = AND.
  7. Snapshot manuel et oubli de S3 push → backup local perdu au reboot. Configurer S3 destination.
  8. HnswConfigDiff(m=0) sur la collection principale par erreur → pas d'index, queries lentes. m=0 réservé aux multivector "rerank-only".
  9. Pagination par offset à fort volume → coût quadratique. Utiliser scroll (cursor pagination) pour les exports.
  10. Cluster sous-dimensionné en CPU pour SPLADE/ColBERT → ingestion devient le bottleneck, pas Qdrant. Sizing CPU embedding ≠ sizing Qdrant.

🩺 Observabilité prod

Qdrant expose un endpoint Prometheus /metrics (port 6333). Métriques à dashboarder :

qdrant_collection_vector_count{collection="articles"}        # taille
qdrant_grpc_responses_avg_duration_seconds{endpoint="search"} # latence
qdrant_grpc_responses_total{status!="OK"}                    # erreurs
qdrant_memory_usage_bytes                                    # RAM
qdrant_cluster_pending_operations                            # backlog

Dashboard Grafana minimal : 6 panels — count, latence p50/p95/p99, error rate, RAM, disk usage, batch upsert throughput. Coût : 0€ (Grafana OSS + Prometheus self-hosted ou Grafana Cloud free tier).

💰 Pricing / ROI client

Qdrant Cloud EU (Frankfurt)

SetupRAMDiskPrix mensuel
4 GB (dev)4 GB32 GB~70 €
16 GB (1-5M vec)16 GB128 GB~290 €
32 GB HA cluster (10-30M)32 GB256 GB ×3~900 €
64 GB HA cluster (50-100M)64 GB512 GB ×3~2 100 €

Self-hosted (Scaleway Elements 3 nodes Pro2-S)

  • ~400€/mois pour 10M vecteurs INT8 quantized
  • ~900€/mois pour 50M vecteurs avec sharding

Pricing freelance type

MissionJoursTJMPrix HT
POC Qdrant Cloud + eval vs pgvector41300€5 200€
Migration pgvector → Qdrant (5M vec)71400€9 800€
Setup Qdrant Cluster prod + hybrid + monitoring101500€15 000€
Optimisation perf (quantization, sharding, payload index)51500€7 500€

ROI client : un cabinet legal qui passe de 1.2s p95 → 80ms p95 = utilisateurs qui ne refusent plus le produit interne, adoption × 3.

🧪 Testing / Eval

python
# tests/test_qdrant.py
import pytest
from api.search import journalist_search

GOLDEN = [
    {"q": "manifestation gilets jaunes Toulouse 2018",
     "must_contain_url": "lemonde.fr/.../2018/12/01/..."},
    # ... 200 queries curated
]

@pytest.mark.parametrize("case", GOLDEN)
def test_top10_contains_expected(case):
    hits = journalist_search(case["q"], k=10)
    urls = [h["url"] for h in hits]
    assert any(case["must_contain_url"] in u for u in urls)


def test_p99_below_50ms(benchmark):
    res = benchmark(journalist_search, "élection présidentielle 2022", k=10)
    stats = benchmark.stats
    assert stats["max"] < 0.050

À monitorer :

  • nDCG@10 sur eval set
  • p50/p95/p99 par endpoint
  • RAM usage par segment (Qdrant metrics)
  • Index build time après ingest large
  • Recall hybrid (dense+sparse+colbert) vs dense-only

📋 Runbook prod Qdrant

markdown
# Runbook Qdrant — collection `articles`

## Configuration cluster
- 3 nodes, 32GB RAM, n2-standard-8
- Sharding : 6 shards, replication factor 2
- Quantization : scalar INT8 always_ram=true
- On-disk vectors : oui

## Procédures
### Scale-up (ajout shard)
1. Snapshot complet : `POST /collections/articles/snapshots`
2. Push snapshot S3
3. `PATCH /collections/articles` : augmenter `shard_number`
4. Qdrant rebalance automatique (peut prendre 1-3h)

### Recovery node down
1. Qdrant détecte automatiquement, repli sur replicas
2. Si node ne revient pas en 15 min : provisionner replacement
3. Restore depuis snapshot S3 si volume disque corrompu

### Migration de model d'embedding
1. Créer collection `articles_v2` avec nouvelle dim/distance
2. Job backfill par scroll (curseur)
3. Test A/B sur 5% du trafic
4. Bascule traffic via alias Qdrant
5. Drop `articles_v1` après 14 jours

### Investiguer p99 > SLA
1. Check `qdrant_memory_usage_bytes` : approche limite ?
2. Check `qdrant_collection_optimizer_status` : OK ?
3. Check payload indexes activés sur les filters utilisés
4. Si OK : monter `ef_search`, ou ajouter shard

## Contacts
- DRI Qdrant : ...
- Freelance : ...

🔁 Quand utiliser / éviter

Utiliser Qdrant quand :

  • ≥ 10M vecteurs (ou pgvector trop lent à 5M sur ton hardware)
  • Filtering complexe pre-ANN (cardinality-aware)
  • Multi-vectors (dense + sparse + colbert) nécessaires
  • Multi-tenancy stricte avec performance par tenant
  • Souveraineté EU (Qdrant Cloud Frankfurt ou Hybrid Cloud sur Scaleway)
  • Budget infra mensuel à optimiser (vs Pinecone serverless qui décolle vite)
  • Latence p99 < 50ms requise

Éviter / rester sur pgvector quand :

  • < 5M vecteurs et Postgres déjà en prod
  • Pas d'équipe pour gérer un second système
  • ACL fine via RLS Postgres absolument nécessaire
  • Budget infra serré ET volume modeste

🧰 Annexes — sizing, quantization, sharding

Sizing rapide (RAM = budget critique)

   RAM nécessaire ≈ N × dim × bytes_per_value × overhead_hnsw
                    + N × payload_size_avg

   Pour 10M vecteurs, dim=1024, float32, m=16 :
     vectors  = 10M × 1024 × 4   = 40 GB
     hnsw     = 10M × 16 × 8     = 1.3 GB (links)
     payload  = 10M × 500 bytes  = 5 GB
     ───────────────────────────
     total    ≈ 46 GB RAM

   Avec scalar INT8 (always_ram + on_disk vectors) :
     quantized_index = 10M × 1024 × 1 = 10 GB en RAM
     full vectors    = 40 GB sur disque (paginé)
     ───────────────────────────
     total RAM       ≈ 15 GB (× 3 économie)

Toujours dimensionner sur le scénario à 18 mois, pas sur le volume actuel.

Quantization cheat sheet

TypeRAM ratioRecall typiqueUse case
float32 (default)1.0référence< 1M vecteurs, mémoire abondante
float16 (halfvec-like)0.5-0.5%tier intermédiaire
scalar INT80.25-1.5%défaut prod 2026, > 1M vec
binary0.03125-10 à -15%data froide, rerank obligatoire top-100
product quantization (PQ)variable-3 à -8%très gros datasets > 50M

Sharding stratégies

   Critère sharding   | Quand l'utiliser
   ───────────────────┼─────────────────────────────────────
   hash random        | Trafic uniforme, pas de partitionnement métier
   par tenant         | Multi-tenant strict (mais Weaviate est mieux ici)
   par date / décennie| Archive presse, logs (filtre fréquent par date)
   par géographie     | Multi-région, latence locale critique
   par langue         | Corpus multilingue, modèles d'embedding par langue

Le pattern "rerank with full vectors"

Quand tu utilises binary quantization, tu perds 10-15% de recall. Solution :

python
# 1. Recherche grossière sur l'index binary (rapide, peu précis)
candidates = client.query_points(
    "docs", query=q_vec, limit=200,
    search_params={"quantization": {"ignore": False, "rescore": False}},
).points

# 2. Re-rank avec les full vectors (lent, précis) sur les 200 candidats
final = client.query_points(
    "docs", query=q_vec, limit=10,
    search_params={"quantization": {"ignore": True}},  # full precision
    filter=Filter(must=[HasIdCondition(has_id=[c.id for c in candidates])]),
).points

Combo gagnant : 95% du recall full-precision pour 5% de la RAM.

Backup & DR

bash
# Snapshot manuel
curl -X POST "$QDRANT_URL/collections/articles/snapshots" -H "api-key: $KEY"

# Configuration S3 (Scaleway Object Storage)
# qdrant config:
#   storage.snapshots_path: /qdrant/snapshots
#   service.enable_static_content: false
# Sidecar S3 sync : aws s3 sync /qdrant/snapshots s3://qdrant-backups-fr/

Plan DR typique : snapshot quotidien S3, RPO 24h. Pour RPO < 1h, configurer la réplication cross-cluster (Qdrant Cloud Enterprise) ou un script qui fait des snapshots toutes les heures.

🧠 Comment un staff engineer raisonne sur HNSW, recall et latence

Tout ce dossier tient sur trois leviers couplés : m / ef_construct (build-time), ef_search (query-time), et la quantization. Un dev junior tune au pif. Un senior sait que ces trois leviers échangent les mêmes trois ressources : RAM, latence, recall. Tu ne peux pas maximiser les trois — tu choisis lesquels deux tu sacrifies en partie.

Le graphe HNSW, vraiment

HNSW = un graphe multi-couches "small-world". Chaque point a m voisins par couche. La recherche descend depuis une couche éparse (peu de nœuds, gros sauts) vers la couche dense (tous les nœuds, petits sauts), en gardant une liste de candidats de taille ef_search.

   ef_search = taille de la frontière exploratoire à la query
   m         = degré du graphe (connectivité)
   ef_construct = qualité du graphe à l'insertion

   recall ↑  quand ef_search ↑, m ↑, ef_construct ↑
   latence ↑ quand ef_search ↑  (linéaire), m ↑ (sous-linéaire)
   RAM ↑     quand m ↑           (m × 8 bytes × N pour les liens)
   build ↑   quand ef_construct ↑, m ↑

Le piège mental classique : croire que m plus grand = toujours mieux. Faux. m trop élevé sur un dataset à faible dimension intrinsèque sature le recall (gain marginal nul) mais coûte la RAM et le build linéairement. Sur du 1536-dim dense, m=16 est le défaut sain ; tu montes à m=32-48 seulement si tu mesures un recall plafonné < cible après avoir poussé ef_search.

L'ordre d'optimisation que défend un senior

Tu ne tunes pas tout en même temps. Ordre :

  1. ef_search d'abord (query-time, gratuit à changer, pas de rebuild). Tu montes jusqu'à atteindre la cible recall sur ton eval set. C'est le seul levier que tu peux ajuster par requête (Qdrant l'accepte dans search_params), donc tu peux faire du recall adaptatif : ef_search=64 pour le tail latency-sensible, ef_search=256 pour les requêtes "deep research" tolérantes.
  2. Si ef_search plafonne le recall → le graphe lui-même est pauvre : monte m / ef_construct et rebuild. Coûteux, donc en dernier.
  3. Quantization en parallèle : la perte de recall de l'INT8 (~1.5%) se récupère avec le rescoring (rescore=true relit les full vectors sur le top candidates). Donc le combo réel en prod = INT8 always_ram + on_disk full vectors + rescore. Tu paies un I/O disque sur le top-K seulement, pas sur tout le scan.

Le piège du recall mesuré sur le mauvais ground truth

recall@10 n'a de sens que contre un ground truth exact (brute-force kNN). Beaucoup d'équipes mesurent "recall" contre les résultats d'un autre système ANN — c'est mesurer l'accord entre deux approximations, pas le recall. La bonne méthode : sur un sous-échantillon (1-5K vecteurs), calcule le kNN exact (numpy argsort sur la distance), et compare. C'est le seul chiffre que tu peux défendre en revue d'archi.

Pré-filtre, cardinalité, et le mode "le filtre tue HNSW"

Le filtering pre-ANN cardinality-aware est l'arme de Qdrant, mais il a un mode de défaillance vicieux : quand le filtre est très sélectif ET corrélé négativement avec la proximité vectorielle. Exemple : tu filtres brand=X (0.1% des points) mais les vecteurs de brand=X sont diffus dans l'espace. HNSW, en pré-filtrant, se retrouve à parcourir un sous-graphe quasi-déconnecté — le graphe n'a pas d'arêtes entre ces points. Qdrant bascule alors sur un payload-index scan + brute-force sur les candidats (via le seuil full_scan_threshold). C'est voulu et correct, mais si tu ne le sais pas, tu vois une latence qui explose sur certaines requêtes sans comprendre. Le senior connaît ce seuil et l'ajuste selon la distribution de cardinalité de ses filtres.

🏋️ Exercices

Exercice 1 — Mesurer le recall pour de vrai (warm-up)

Objectif : prouver, chiffre à l'appui, le recall réel de ta config INT8 vs float32 sur un eval set.

Charge 50K vecteurs (dim 1024) dans deux collections : une float32, une INT8 quantized + rescore. Calcule le ground truth exact (brute-force kNN avec numpy/faiss flat) pour 500 requêtes, puis mesure recall@10 de chaque collection contre ce ground truth. Trace recall vs ef_search ∈ {32, 64, 128, 256}.

Indice/Solution : recall@10 = |top10_qdrant ∩ top10_exact| / 10, moyenné sur les 500 requêtes. Tu dois observer (a) INT8 sans rescore perd ~1-2%, (b) INT8 avec rescore récupère quasi tout, (c) le recall sature : au-delà d'un certain ef_search, gain nul mais latence linéaire. Le livrable c'est la courbe — c'est elle que tu montres en entretien.

Exercice 2 — Le filtre qui fait exploser la p99

Objectif : reproduire le mode de défaillance "filtre sélectif anti-corrélé", puis le diagnostiquer et le mitiger.

Génère 1M vecteurs. Attache un payload tenant_id avec une distribution power-law (un tenant = 0.05% des points, vecteurs volontairement dispersés). Mesure p50/p99 search sans filtre, puis avec tenant_id=<rare>. Tu dois voir la p99 du cas filtré déraper. Puis : (a) identifie via les métriques que Qdrant fait du full-scan, (b) joue sur full_scan_threshold et sur le sharding par tenant, (c) compare.

Indice/Solution : le pré-filtre cardinality-aware déclenche le brute-force quand cardinality(filtre) < full_scan_threshold. Si la cardinalité est élevée mais le sous-graphe déconnecté, HNSW erre. La vraie fix en multi-tenant strict : shard par tenant (shard_key) — le filtre devient un routing de shard, plus un scan. Défends le chiffre : combien de RAM/latence économisée vs collection unique.

Exercice 3 — Sizing à 18 mois et arbitrage quantization

Objectif : produire un plan de capacité défendable pour 50M → 200M vecteurs avec budget RAM contraint.

Tu as 64 GB de RAM par node, 3 nodes, replication 2. Vecteurs dim 1024. Calcule : combien de vecteurs tu loges en float32 ? en INT8 always_ram + on_disk ? en binary + rescore ? Pour chaque tier, estime recall attendu et p99. Choisis une stratégie par température de donnée (hot/warm/cold) et justifie le seuil de bascule.

Indice/Solution : reprends la formule de la section sizing. INT8 = index 1 byte/dim en RAM, full vectors sur disque ; binary = 1 bit/dim (RAM/32) mais rescore obligatoire top-100. Le piège : la replication ×2 double la RAM effective — souvent oublié. Réponse type : hot (< 12 mois) en INT8, cold (archive) en binary, avec un eval set qui prouve que le recall cold reste acceptable après rescore.

Exercice 4 — Migration zero-downtime d'embedding model

Objectif : passer de BGE-M3 (1024) à un nouveau modèle (dim différente) sur une collection prod de 50M points, sans coupure ni régression de qualité.

Implémente le pattern alias : collection v2 avec nouvelle dim, backfill par scroll (cursor), A/B sur 5% du trafic via deux clients, mesure du delta nDCG@10 entre v1 et v2, bascule par alias, garde v1 14 jours. Gère le cas où le backfill plante à mi-chemin (idempotence).

Indice/Solution : scroll (pas offset — coût quadratique) avec un curseur persisté pour reprendre. IDs déterministes (uuid5(url)) pour rendre l'upsert idempotent. L'A/B se mesure sur un eval set figé, pas sur le trafic brut (sinon tu mesures du bruit). La bascule par alias est atomique côté Qdrant. Le piège : ne jamais drop v1 avant d'avoir 14 jours de métriques qui prouvent la non-régression.

Exercice 5 — Casse-le, puis répare (chaos)

Objectif : provoquer une dégradation prod réaliste et écrire le runbook de remédiation.

Sur un cluster 3 nodes : (a) tue un node pendant l'ingestion, observe le comportement (replicas, rebalance), (b) sature la RAM en désactivant always_ram sous charge, observe la p99, (c) supprime un payload index utilisé par un filtre chaud, observe l'effondrement. Pour chaque incident : détecte via les métriques Prometheus, explique la cause, remédie, et écris l'entrée de runbook correspondante.

Indice/Solution : (a) avec replication ≥ 2 la lecture continue, mais l'ingestion peut bloquer si le write consistency factor n'est pas atteignable — c'est le tradeoff CAP. (b) always_ram=False = I/O disque par requête → qdrant_grpc_responses_avg_duration explose, RAM stable mais inutile. (c) sans payload index, filtre = full scan payload, latence > 1s à 10M. La leçon staff : chaque incident doit produire une alerte Prometheus qui l'aurait détecté avant l'utilisateur.

Exercice 6 — Défends le "Qdrant vs pgvector" en comité d'archi

Objectif : construire le dossier de décision chiffré qui justifie (ou réfute) la migration pour un cas donné.

Cas : e-commerce, 5M produits, 6 filtres/requête, p95 actuel pgvector = 200ms, budget infra serré, équipe de 2. Produis : un benchmark reproductible pgvector vs Qdrant (même hardware, même eval set), un TCO sur 18 mois (infra + jours de mise en place + maintenance), et une recommandation argumentée — y compris le scénario où tu recommandes de rester sur pgvector.

Indice/Solution : le piège est de vendre Qdrant par réflexe. À 5M avec une équipe de 2 et Postgres déjà en prod, le coût d'exploitation d'un second système peut dominer le gain de latence. Le bon dossier chiffre les deux et conclut selon le contexte. Un staff engineer qui recommande "ça dépend, voici le seuil" gagne plus de crédibilité que celui qui pousse toujours l'outil le plus sexy.

🎤 En entretien

Q : Pourquoi Qdrant plutôt que pgvector, et où est le point de bascule ? R : pgvector post-filtre (le WHERE arrive après le top-K HNSW) et plafonne en pratique vers ~5-10M vecteurs ; Qdrant fait du pré-filtre cardinality-aware et shard nativement à 100M+. Bascule quand : volume > ~10M, filtres complexes corrélés, ou p99 < 50ms requis sur du multi-filtre — sinon pgvector gagne sur le coût d'exploitation.

Q : Comment tu garantis le recall en prod avec de la quantization ? R : INT8 always_ram pour l'index + full vectors on_disk + rescore sur le top candidates : 95-98% du recall full-precision pour ~25% de la RAM. Et je mesure le recall contre un ground truth brute-force exact, pas contre un autre système ANN — sinon je mesure l'accord entre deux approximations.

Q : Un filtre très sélectif fait exploser ta latence. Que se passe-t-il ? R : HNSW pré-filtré sur un sous-graphe trop épars ou déconnecté erre sans converger ; Qdrant bascule sur payload-scan + brute-force selon full_scan_threshold. Fix selon le cas : ajuster ce seuil, ou en multi-tenant strict, shard par tenant pour transformer le filtre en routing de shard.

Q : Tu dois migrer le modèle d'embedding sur 50M points en prod, sans coupure. Comment ? R : nouvelle collection v2, backfill par scroll avec curseur reprenable et IDs déterministes (uuid5) pour l'idempotence, A/B 5% sur eval set figé avec delta nDCG@10, bascule atomique par alias, drop de v1 après 14 jours de métriques propres. Jamais de drop avant la preuve de non-régression.

🔗 Liens

→ Voir aussi : 01-pgvector.md (alternative low-volume), 03-weaviate.md (concurrent modulaire), 05-hybrid-search.md, 04-pinecone-self-hosted.md.

Bibliothèque tech perso — Achref