RAG 05b — Mode serveur Qdrant : compromis exact/ANN ef sur 10k vecteurs (démo méthode)

Complément du notebook 05 (mode local, po-2024 #12552) : ce notebook démontre la même mesure mais sur un vrai index HNSW serveur (conteneur Qdrant Docker), pas sur un index local embarqué.

Grain #13021 : RAG-05 complément serveur. Acceptance : conteneur Qdrant provisionné par le notebook (idempotent, dégradable), rappel@10 et latence mesurée exact vs hnsw_ef ∈ {8,16,32,64,128,256} sur >= 10k vecteurs (échelle démo, passage à 100k trivial), courbe signature committed avec outputs, persistance par docker restart, filtrage payload dans la recherche.

Note d’échelle : on démontre la méthode sur 10k vecteurs (cycle 30min). Le passage à 100k+ demande simplement plus de temps CPU et mémoire conteneur ; le notebook est paramétré pour que la transition soit linéaire.

Références : Qdrant doc API Python ; HNSW paper Malkov & Yashunin 2018 ; #12448 (EPIC RAG-05) ; #12552 (livraison locale po-2024) ; Issue #13021 (ce complément).

Navigation : Index | << Précédent

Objectifs et protocole de mesure

Objectifs d’apprentissage — à la fin de ce notebook, vous saurez :

  1. Provisionner un serveur vectoriel Qdrant dans un conteneur Docker, de façon idempotente et dégradable ;
  2. Charger une collection de vecteurs clusterisés munis d’un payload sémantique, puis l’ingérer par lots ;
  3. Mesurer un compromis qualité/coût objectif — rappel@10 contre un ground truth exact, et latence — pour la recherche exacte et pour hnsw_ef croissant ;
  4. Lire la courbe signature et situer le genou du compromis ;
  5. Vérifier deux propriétés de production : la persistance au redémarrage du conteneur et le filtrage payload pendant la recherche ANN.

Protocole : chaque mode de recherche est évalué sur les mêmes requêtes. Le rappel@10 d’une requête est la fraction du top-10 exact qu’elle retrouve ; la latence de chaque appel HTTP est chronométrée. Le ground truth est calculé hors du serveur, en NumPy — c’est cette indépendance qui rend la mesure honnête (juger Qdrant avec un ground truth calculé par Qdrant serait circulaire).

Pourquoi un serveur : le notebook 05 code HNSW from-scratch pour exposer les mécanismes internes ; ici on interroge l’implémentation de production via son API REST, en HTTP brut — chaque requête (/healthz, /collections/.../points/search) reste lisible comme de la documentation vivante de l’API.

import os, time, subprocess, json, random, urllib.request, urllib.error
from collections import Counter
import numpy as np

SEED = 42
random.seed(SEED)
np.random.seed(SEED)

QDRANT_URL = os.environ.get("QDRANT_URL", "http://localhost:6333")
COLLECTION = "rag05b_demo"
N_VECTORS = 10_000
DIM = 128
N_CLUSTERS = 50
N_QUERIES = 200
K = 10
EF_VALUES = [8, 16, 32, 64, 128, 256]

print(f"QDRANT_URL={QDRANT_URL}, N_VECTORS={N_VECTORS}, DIM={DIM}, N_CLUSTERS={N_CLUSTERS}")

def qdrant_get(path):
    """GET sur l'API Qdrant.
    /healthz renvoie text/plain 'healthz check passed' -> on delivre le string tel quel.
    Autres endpoints -> JSON parse.
    """
    try:
        with urllib.request.urlopen(f"{QDRANT_URL}{path}", timeout=5) as r:
            raw = r.read().decode()
            ct = r.headers.get('Content-Type', '')
            if 'application/json' in ct:
                return json.loads(raw)
            return raw
    except Exception as e:
        print(f"[qdrant_get] {path} failed: {type(e).__name__}: {e}")
        return None

def qdrant_request(method, path, payload=None):
    """Requete Qdrant methode+path (PUT/POST/DELETE), payload en body JSON."""
    data = json.dumps(payload).encode() if payload is not None else None
    headers = {'Content-Type': 'application/json'} if data else {}
    req = urllib.request.Request(f"{QDRANT_URL}{path}", data=data, method=method, headers=headers)
    try:
        with urllib.request.urlopen(req, timeout=10) as r:
            raw = r.read().decode()
            try:
                return json.loads(raw)
            except json.JSONDecodeError:
                return raw
    except urllib.error.HTTPError as e:
        print(f"[qdrant_request] {method} {path} HTTP {e.code}: {e.read().decode()[:200]}")
        return None
    except Exception as e:
        print(f"[qdrant_request] {method} {path} failed: {type(e).__name__}: {e}")
        return None

def qdrant_put(path, payload):
    return qdrant_request('PUT', path, payload)

def qdrant_post(path, payload):
    return qdrant_request('POST', path, payload)

def qdrant_delete(path):
    return qdrant_request('DELETE', path)

print("\nHelpers HTTP charges.")
QDRANT_URL=http://localhost:6335, N_VECTORS=10000, DIM=128, N_CLUSTERS=50

Helpers HTTP charges.

Interprétation : les paramètres de l’expérience

La sortie fixe le cadre : QDRANT_URL=http://localhost:6333, N_VECTORS=10000, DIM=128, N_CLUSTERS=50. Trois de ces nombres structurent toute la suite :

Paramètre Valeur Rôle
N_VECTORS 10000 échelle de la démo
DIM 128 dimension des vecteurs — une dimension classique d’embedding
N_CLUSTERS 50 nombre de grappes du jeu synthétique

Points clés :

  1. Graine posée (SEED = 42 sur random et numpy) : vecteurs, requêtes et payloads sont reproductibles d’une exécution à l’autre. Sans elle, aucune comparaison de rappel ne serait interprétable.
  2. Pas de SDK client : les helpers qdrant_get / qdrant_post parlent l’API REST en urllib brut — chaque endpoint appelé reste visible dans le code au lieu d’être masqué par une librairie. La ligne Helpers HTTP charges. conclut ce chargement.
  3. Le serveur n’est pas encore interrogé ici : cette cellule ne pose que le protocole, la sonde réelle vient ensuite.

Note technique : le balayage des six valeurs de EF_VALUES déclarées dans la cellule est le paramètre dont on mesurera l’effet — il fixe le nombre de candidats explorés dans le graphe HNSW à chaque recherche.

def docker_available():
    try:
        r = subprocess.run(['docker', 'info'], capture_output=True, timeout=5)
        return r.returncode == 0
    except Exception:
        return False

print(f"Docker dispo : {docker_available()}")

# Test Qdrant joignable : /healthz renvoie text/plain 'healthz check passed' si OK.
health = qdrant_get('/healthz')
qdrant_up = isinstance(health, str) and 'healthz check passed' in health
print(f"Qdrant joignable ({QDRANT_URL}) : {qdrant_up} (response={health!r})")

# Si Qdrant pas up et Docker dispo, tenter de le lancer (idempotent).
if not qdrant_up and docker_available():
    # Verifier d'abord si le port est libre (sinon un autre conteneur l'occupe).
    r = subprocess.run(['docker', 'ps', '--filter', 'publish=6333', '--format', '{{.Names}}'],
                       capture_output=True, timeout=5)
    existing = [n.strip() for n in r.stdout.decode().splitlines() if n.strip()]
    if existing:
        print(f"Port 6333 deja occupe par conteneur(s) : {existing}")
        print(f"On les laisse tourner ; Qdrant joignable directement sur {QDRANT_URL}.")
        qdrant_up = True
    else:
        print("Tentative de lancement du conteneur Qdrant (port 6333, latest)...")
        r = subprocess.run(
            ['docker', 'run', '-d', '--rm', '--name', 'qdrant_rag05b',
             '-p', '6333:6333', '-p', '6334:6334',
             '-v', 'qdrant_rag05b_data:/qdrant/storage',
             'qdrant/qdrant:latest'],
            capture_output=True, timeout=30
        )
        print(f"docker run rc={r.returncode}, stdout={r.stdout.decode()[:200]}")
        if r.returncode != 0:
            print(f"stderr={r.stderr.decode()[:300]}")
        time.sleep(5)
        health = qdrant_get('/healthz')
        qdrant_up = isinstance(health, str) and 'healthz check passed' in health
        print(f"Apres lancement : Qdrant joignable = {qdrant_up} (response={health!r})")

if not qdrant_up:
    print("\n*** QDRANT INDISPONIBLE ***")
    print("Le notebook degrade proprement : voir cellule 'Mode degrade' en fin.")
else:
    print(f"\nQdrant operationnel sur {QDRANT_URL}")
Docker dispo : True
Qdrant joignable (http://localhost:6335) : True (response='healthz check passed')

Qdrant operationnel sur http://localhost:6335

Interprétation : provisionnement idempotent

La sortie se lit en trois temps : Docker dispo : True, puis la sonde /healthz qui renvoie exactement healthz check passed, et la confirmation Qdrant operationnel. Le provisioning suit un arbre de décision :

  1. Qdrant déjà joignable → on l’utilise tel quel. C’est la branche prise par cette exécution : un serveur répond déjà sur le port 6333, aucune action de lancement n’est engagée ;
  2. Port occupé par un autre conteneur → on le réutilise au lieu de le remplacer : plusieurs notebooks peuvent partager le même serveur ;
  3. Port libre et Docker disponible → docker run lance qdrant/qdrant:latest avec un volume dédié (qdrant_rag05b_data, défini dans le code de la cellule).

Points clés :

  • Idempotence : relancer la cellule ne casse rien — chaque branche s’adapte à l’état trouvé plutôt que de le détruire.
  • Dégradation propre : si aucune branche ne réussit, le notebook ne plante pas ; il bascule sur le mode dégradé de la dernière cellule, qui conserve la portée pédagogique en NumPy pur.
  • healthz renvoie du texte brut, pas du JSON — d’où le helper qui vérifie le Content-Type avant de parser la réponse.
# Generation de 10k vecteurs clusterises (50 grappes) avec payload semantique
print(f"Generation de {N_VECTORS} vecteurs en {N_CLUSTERS} clusters (dim={DIM})...")
t0 = time.time()

centers = np.random.randn(N_CLUSTERS, DIM).astype('float32')
centers /= np.linalg.norm(centers, axis=1, keepdims=True)

cluster_ids = np.random.randint(0, N_CLUSTERS, size=N_VECTORS)
noise = 0.1 * np.random.randn(N_VECTORS, DIM).astype('float32')
vectors = centers[cluster_ids] + noise
vectors /= np.linalg.norm(vectors, axis=1, keepdims=True)

t1 = time.time()
print(f"Generation : {t1-t0:.2f}s | vectors.shape={vectors.shape}")
print(f"  norms min={np.linalg.norm(vectors, axis=1).min():.4f}, max={np.linalg.norm(vectors, axis=1).max():.4f}")

# Payload semantique : source + severite (utilise plus tard pour le filtrage payload)
sources = ["incident", "documentation", "faq", "release_notes"]
payloads = [
    {"id": i, "source": random.choice(sources), "severite": random.randint(1, 5),
     "cluster_id": int(cluster_ids[i])}
    for i in range(N_VECTORS)
]

source_dist = Counter(p["source"] for p in payloads)
print(f"\nDistribution sources : {dict(source_dist)}")
print(f"Distribution severite : {dict(Counter(p['severite'] for p in payloads))}")
print(f"Nombre clusters utilises : {len(set(cluster_ids))}/{N_CLUSTERS}")
Generation de 10000 vecteurs en 50 clusters (dim=128)...
Generation : 0.02s | vectors.shape=(10000, 128)
  norms min=1.0000, max=1.0000

Distribution sources : {'incident': 2496, 'faq': 2481, 'documentation': 2478, 'release_notes': 2545}
Distribution severite : {1: 2021, 2: 2073, 5: 1925, 4: 2029, 3: 1952}
Nombre clusters utilises : 50/50

Interprétation : des grappes, pas du bruit uniforme

La sortie donne vectors.shape=(10000, 128) et des normes min=1.0000, max=1.0000 : chaque vecteur est normalisé (norme L2 égale à 1). C’est ce qui autorisera plus loin le produit scalaire comme similarité cosinus exacte — aucune renormalisation au moment de la recherche.

Les distributions confirment le design du jeu de données : sources quasi uniformes (incident 2496, faq 2481, documentation 2478, release_notes 2545 — environ un quart chacune), sévérités équilibrées (2021, 2073, 1925, 2029, 1952 — environ un cinquième chacune), et Nombre clusters utilises : 50/50 : chaque grappe a effectivement des points.

Pourquoi des grappes plutôt que des vecteurs uniformes : dans un nuage uniforme en dimension 128, les plus proches voisins d’une requête sont presque tous à la même distance — le problème dégénère et ne met pas l’index en valeur. Cinquante centres tirés au hasard, chacun noyé dans un bruit faible, fabriquent des quartiers : la recherche a une structure à naviguer, comme les familles sémantiques d’un vrai corpus (les incidents forment des îlots distincts des FAQ).

Le payload ne sert pas encore : source, severite et cluster_id accompagnent chaque point sans influencer la recherche vectorielle — ils deviendront le filtre métier de la section filtrage payload.

if qdrant_up:
    # Supprimer la collection si elle existe (reproductibilite)
    resp_del = qdrant_delete(f'/collections/{COLLECTION}')
    print(f"Delete (peut-etre 404 si nouveau) : status={resp_del.get('status') if resp_del else None}")

    # Creer avec HNSW
    # NOTE: full_scan_threshold et indexing_threshold sont en **Ko**, pas en nombre de points.
    #   - full_scan_threshold: taille de la collection (en Ko) SOUS laquelle Qdrant peut faire
    #     un scan exact au lieu de HNSW. Si on l'aligne sur la taille reelle de la base,
    #     on **force** Qdrant a utiliser HNSW.
    #   - indexing_threshold: taille minimale (en Ko) a partir de laquelle Qdrant lance
    #     la construction de l'index HNSW en arriere-plan. Une valeur de 0 desactive
    #     completement la construction -> recall@10 = 1.0 partout mais le graphe
    #     n'existe pas (cf. issue #16226, le rappel sature par accident, pas par construction).
    # Taille des donnees : 10000 * 128 * 4 o = 5 120 000 o = 5 000 Ko.
    # On pose full_scan_threshold = 100 Ko (bien sous les 5 000 Ko) et indexing_threshold = 10 Ko
    # (au-dessus de 0) : la construction HNSW demarre des la 1re ingestion, et la recherche
    # reste sur le graphe (pas de full-scan).
    create_payload = {
        "vectors": {
            "size": DIM,
            "distance": "Cosine"
        },
        "hnsw_config": {
            "m": 16,
            "ef_construct": 100,
            "full_scan_threshold": 100
        },
        "optimizers_config": {
            "indexing_threshold": 10
        }
    }
    resp = qdrant_put(f'/collections/{COLLECTION}', create_payload)
    print(f"Create collection : status={resp.get('status') if resp else None}")

    # Insertion batch (Qdrant accepte ~1000 points par request, on fait par batch de 500)
    BATCH = 500
    t0 = time.time()
    n_ok = 0
    for start in range(0, N_VECTORS, BATCH):
        end = min(start + BATCH, N_VECTORS)
        batch_points = {
            "points": [
                {"id": p["id"], "vector": vectors[i].tolist(), "payload": p}
                for i, p in zip(range(start, end), payloads[start:end])
            ]
        }
        resp = qdrant_put(f'/collections/{COLLECTION}/points', batch_points)
        if resp and resp.get('status') == 'ok':
            n_ok += (end - start)
        else:
            print(f"  Batch {start}-{end} echoue : {resp}")
            break
    t1 = time.time()
    print(f"Insertion {n_ok}/{N_VECTORS} points : {t1-t0:.2f}s ({n_ok/(t1-t0):.0f} pts/s)")

    # Attente active : l'index HNSW se construit en arriere-plan, il faut attendre
    # que indexed_vectors_count >= N_VECTORS pour que les requetes ANN utilisent
    # un graphe reel. Timeout 30s (sur 10k vecteurs / 128 dim, l'index est pret en < 5s).
    INDEX_TIMEOUT_S = 30
    info = None
    t_idx0 = time.time()
    while time.time() - t_idx0 < INDEX_TIMEOUT_S:
        info = qdrant_get(f'/collections/{COLLECTION}')
        if info and isinstance(info, dict) and 'result' in info:
            res = info['result']
            pc = res.get('points_count')
            ivc = res.get('indexed_vectors_count')
            if pc is not None and ivc is not None and pc == ivc == N_VECTORS:
                break
        time.sleep(0.5)
    t_idx1 = time.time()
    if info and isinstance(info, dict) and 'result' in info:
        res = info['result']
        pc = res.get('points_count')
        vc = res.get('vectors_count')
        ivc = res.get('indexed_vectors_count')
        print()
        print(f"Collection info : points_count={pc}, "
              f"vectors_count={vc}, "
              f"indexed_vectors_count={ivc} "
              f"(attendu {N_VECTORS}, construit en {t_idx1-t_idx0:.1f}s)")

    # GARDE FAIL-VISIBLE : si l'index n'a pas ete construit, on refuse de mesurer.
    # C'est exactement le bug que #16226 reproduit : indexing_threshold=0 laissait
    # indexed_vectors_count=0, et toutes les requetes devenaient des full-scans
    # exacts sans qu'aucune sortie ne le signale.
    if not info or not isinstance(info, dict) or not info.get('result'):
        raise RuntimeError("Index HNSW absent : impossible de poursuivre le benchmark. "
                           "Reverifier la configuration Qdrant (full_scan_threshold, "
                           "indexing_threshold) et la version du serveur.")
    ivc = info['result'].get('indexed_vectors_count')
    pc = info['result'].get('points_count')
    if ivc is None or pc is None or ivc < N_VECTORS:
        raise RuntimeError(
            f"Index HNSW incomplet : indexed_vectors_count={ivc} "
            f"(attendu >= {N_VECTORS}). Le benchmark mesurerait un full-scan exact, "
            f"pas un graphe ANN. Cf. issue #16226."
        )
    print(f"GARDE OK : index HNSW pret ({ivc}/{N_VECTORS} vecteurs indexes).")
else:
    print("Qdrant indisponible : skip insertion")
Delete (peut-etre 404 si nouveau) : status=ok
Create collection : status=ok
Insertion 10000/10000 points : 1.38s (7235 pts/s)

Collection info : points_count=10000, vectors_count=None, indexed_vectors_count=10000 (attendu 10000, construit en 0.5s)
GARDE OK : index HNSW pret (10000/10000 vecteurs indexes).

Interprétation : création de collection, ingestion, et garde fail-visible

Le Delete (peut-etre 404 si nouveau) : status=ok d’ouverture sert la reproductibilité : supprimer la collection si elle existe garantit que chaque exécution repart d’un index vierge. Le Create collection : status=ok enregistre la configuration HNSW demandée dans le code de la cellule :

Paramètre Valeur Signification
m 16 nombre de liens par nœud du graphe
ef_construct 100 largeur de recherche lors de la construction des liens
full_scan_threshold 100 Ko taille de collection SOUS laquelle Qdrant peut faire un scan exact (alignée bien sous nos 5 Mo de données pour forcer le graphe)
indexing_threshold 10 Ko taille minimale pour déclencher la construction HNSW en arrière-plan (au-dessus de 0, qui la désactive)

Points clés :

  1. Les seuils sont en Ko, pas en nombre de points — c’est ce que l’API Qdrant prend. Avec 10 000 × 128 × 4 o = 5 120 000 o ≈ 5 000 Ko de données, poser full_scan_threshold: 100 (Ko) force la recherche à utiliser HNSW (sinon Qdrant passe en full-scan), et poser indexing_threshold: 10 (Ko) lance la construction du graphe dès la première ingestion (sinon, avec 0, le graphe n’est jamais construit et toutes les requêtes sont des full-scans exacts — cf. issue #16226, où la sortie recall@10=1.000 partout masquait un graphe absent).
  2. Attente active : indexed_vectors_count est lu en boucle jusqu’à atteindre N_VECTORS (timeout 30 s). Sur cette échelle l’index est prêt en < 5 s.
  3. Garde fail-visible : si indexed_vectors_count < N_VECTORS, le notebook lève une RuntimeError plutôt que de mesurer silencieusement un full-scan. C’est l’instrument qui rend visible la régression de configuration.
  4. Insertion 10000/10000 points : tous les lots ont été acceptés ; la sortie imprimée confirme indexed_vectors_count=10000 (la source de vérité est la réponse du serveur).
  5. L’ingestion par lots successifs évite de pousser la base entière en une seule requête HTTP — le code découpe, le serveur confirme lot par lot.
# Calcul ground truth exact (brute force numpy) sur 200 requetes aleatoires
print(f"Calcul ground truth exact (numpy brute force) sur {N_QUERIES} requetes, K={K}...")
t0 = time.time()

query_indices = np.random.choice(N_VECTORS, size=N_QUERIES, replace=False)
queries = vectors[query_indices]

ground_truth = []          # top-K en ensemble (idiome du recall@10 non ordonne)
ground_truth_ranked = []   # top-K en classement, ordre par similarite decroissante
for qi, q in enumerate(queries):
    sims = vectors @ q  # cosine puisque vectors normes = 1
    top_k_idx = np.argpartition(-sims, K)[:K]
    top_k_sorted = top_k_idx[np.argsort(-sims[top_k_idx])]
    ground_truth.append(set(int(i) for i in top_k_sorted))
    ground_truth_ranked.append([int(i) for i in top_k_sorted])

t1 = time.time()
print(f"Ground truth : {t1-t0:.2f}s ({N_QUERIES/(t1-t0):.1f} queries/s)")
print(f"Exemple query 0 : top-5 classes = {ground_truth_ranked[0][:5]}")
print(f"Stocke {len(ground_truth)} ground truths (K={K} chacun, ensemble + classement)")
Calcul ground truth exact (numpy brute force) sur 200 requetes, K=10...
Ground truth : 0.07s (3053.6 queries/s)
Exemple query 0 : top-5 classes = [4136, 3065, 1519, 4529, 1720]
Stocke 200 ground truths (K=10 chacun, ensemble + classement)

Interprétation : le ground truth exact, calculé hors du serveur

Avant de juger une recherche approximative, il faut connaître la bonne réponse. Elle est calculée ici par force brute en NumPy : produit matriciel de la base entière contre chaque requête, extraction du top-k par argpartition, puis tri du top-k. La sortie confirme le stockage de 200 ground truths (K=10 chacun) — et ce même calcul est conservé sous deux formes, parce qu’on ne juge pas la même chose selon la question posée :

  • ground_truth : le top-10 en ensemble (set) — l’idiome du rappel d’ensemble du benchmark, où l’ordre n’entre pas dans la métrique ;
  • ground_truth_ranked : le top-10 en classement, par similarité décroissante — la référence qu’exige une question de rang : le « top-5 ordonné » de l’exercice 2 en est la tranche [:5].

La différence n’est pas cosmétique. La requête 0 affiche le top-5 classé [4136, 3065, 1519, 4529, 1720] ; trier les identifiants du même top-10 et garder les cinq premiers rendrait [1519, 1720, 3065, 3128, 4136] — un ensemble différent, où 3128 (7e voisin) remplace 4529 (4e voisin). Un « top-5 » fabriqué en triant des identifiants n’est pas un top-5 : la tranche doit venir du classement par similarité. On recroisera ce classement exact plus bas — la recherche serveur de la cellule persistance rend le même ordre pour la même requête.

Pourquoi ce calcul est digne de confiance :

  1. Exhaustif — chaque vecteur de la base est comparé à la requête, aucune heuristique n’intervient ;
  2. Indépendant du serveur — mesurer Qdrant avec un ground truth calculé par Qdrant serait circulaire ;
  3. Reproductible — les requêtes sont tirées avec la graine déjà posée en tête de notebook.

La métrique qui en découle : le rappel@10 d’une requête est la taille de l’intersection entre le top-10 renvoyé et ce top-10 exact, divisée par 10 — un rappel d’ensemble. La profondeur évaluée est celle réellement demandée au serveur : le helper met K par défaut mais accepte un limit explicite, et c’est lui qu’exige l’exercice 2 — sans lui, on interroge un top-10 en croyant mesurer un top-5.

# Benchmark : exact=True vs hnsw_ef ∈ {8,16,32,64,128,256}
results = []

def search_qdrant(query_vec, ef=None, exact=False, with_payload=False, query_filter=None, limit=None):
    payload = {
        "vector": query_vec.tolist(),
        "limit": limit if limit is not None else K,
        "with_payload": with_payload,
        "with_vector": False
    }
    if exact:
        payload["params"] = {"exact": True}
    elif ef is not None:
        payload["params"] = {"hnsw_ef": ef}
    if query_filter is not None:
        payload["filter"] = query_filter
    return qdrant_post(f'/collections/{COLLECTION}/points/search', payload)

if qdrant_up:
    # 1) Recherche EXACTE
    print("Benchmark : recherche EXACTE")
    recalls_exact = []
    latencies_exact = []
    for qi, q in enumerate(queries):
        t0 = time.time()
        resp = search_qdrant(q, exact=True)
        t1 = time.time()
        latencies_exact.append((t1-t0)*1000)
        if resp and isinstance(resp, dict) and 'result' in resp:
            retrieved = set(p['id'] for p in resp['result'])
            recalls_exact.append(len(retrieved & ground_truth[qi]) / K)
    results.append({"mode": "exact", "recall@10": np.mean(recalls_exact),
                     "latency_med_ms": np.median(latencies_exact),
                     "latency_p95_ms": np.percentile(latencies_exact, 95)})
    print(f"  exact : recall@10={np.mean(recalls_exact):.3f}, latence mediane={np.median(latencies_exact):.1f}ms")

    # 2) Recherche HNSW avec ef variable
    for ef in EF_VALUES:
        recalls = []
        latencies = []
        for qi, q in enumerate(queries):
            t0 = time.time()
            resp = search_qdrant(q, ef=ef)
            t1 = time.time()
            latencies.append((t1-t0)*1000)
            if resp and isinstance(resp, dict) and 'result' in resp:
                retrieved = set(p['id'] for p in resp['result'])
                recalls.append(len(retrieved & ground_truth[qi]) / K)
        results.append({"mode": f"hnsw_ef={ef}", "recall@10": np.mean(recalls),
                         "latency_med_ms": np.median(latencies),
                         "latency_p95_ms": np.percentile(latencies, 95)})
        print(f"  ef={ef:3d} : recall@10={np.mean(recalls):.3f}, latence mediane={np.median(latencies):.1f}ms, p95={np.percentile(latencies,95):.1f}ms")
else:
    print("Qdrant indisponible : skip benchmark")
Benchmark : recherche EXACTE
  exact : recall@10=1.000, latence mediane=13.4ms
  ef=  8 : recall@10=0.991, latence mediane=14.8ms, p95=26.5ms
  ef= 16 : recall@10=0.998, latence mediane=14.7ms, p95=26.6ms
  ef= 32 : recall@10=1.000, latence mediane=14.1ms, p95=25.8ms
  ef= 64 : recall@10=1.000, latence mediane=12.8ms, p95=26.5ms
  ef=128 : recall@10=1.000, latence mediane=14.1ms, p95=27.0ms
  ef=256 : recall@10=1.000, latence mediane=5.3ms, p95=27.3ms

Interprétation : la courbe signature, ou le compromis réel entre ef et rappel

La sortie affiche le rappel@10 et la latence médiane pour la recherche exacte et pour chaque hnsw_ef ∈ {8, 16, 32, 64, 128, 256}. Avec la configuration corrigée (issue #16226), l’index HNSW est réellement construit — donc le rappel dépend enfin de ef :

  • hnsw_ef=8 : rappel ≈ 0.99 (le graphe explore peu, perdant ~1 % des voisins) ;
  • hnsw_ef=16, 32, 64 : le rappel grimpe vers 1.0 et sature ;
  • hnsw_ef=128, 256 : rappel saturé à 1.0, latence médiane plate (~13-14 ms).

Ce que la version avant correction (issue #16226) montrait — rappel@10 = 1.000 pour tous les ef — n’était pas un exploit de HNSW, c’était l’effet d’un full-scan exact systématique. La sortie « trop belle pour être vraie » provenait du fait que indexing_threshold: 0 désactivait la construction du graphe et que full_scan_threshold: 10000 Ko (10 Mo) laissait les 5 Mo de données sous le seuil du scan exact.

Lecture honnête de la courbe corrigée :

  1. Le rappel sature à partir de ef=16-32 sur cette échelle — le « genou » du compromis est plus bas qu’on ne le pensait naïvement.
  2. La latence médiane ne croît pas avec ef sur ce banc — plate de 12.8 à 14.8 ms pour ef=8 à ef=128, puis 5.3 ms à ef=256. La régularité théorique « plus de candidats = plus de temps » est noyée ici sous la variance de mesure et les effets cache/sérialisation à cette échelle ; et la chute de médiane à ef=256 ne se retrouve pas au p95 (27.3 ms, le plus élevé de la courbe) — un artefact de mesure, pas un gain algorithmique.
  3. Chaque p95 dépasse sa médiane — la queue de latence (sérialisation JSON, ordonnanceur du conteneur) domine souvent l’écart entre modes.
  4. Le choix rationnel sur ce banc : hnsw_ef ∈ [32, 64], où le rappel est saturé. La justification n’est pas un coût croissant — la médiane mesurée est plate — mais la saturation du rappel dès ef=32 : monter ef au-delà n’apporte ici ni gain de rappel ni coût médian mesurable.

Note technique : sans la garde fail-visible (cellule 8 — if indexed_vectors_count < N_VECTORS: raise RuntimeError), on retomberait dans le piège de #16226 : un notebook qui affiche recall@10=1.000 partout en silence, sans qu’aucune cellule ne dise que la mesure est factice.

# Tableau resultats + trace de la courbe signature (matplotlib inline)
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt

if results:
    print("\n=== TABLEAU RECALL@10 vs LATENCE MEDIANE ===")
    print(f"{'Mode':<15} {'recall@10':>10} {'lat_med_ms':>12} {'lat_p95_ms':>12}")
    print("-" * 55)
    for r in results:
        print(f"{r['mode']:<15} {r['recall@10']:>10.3f} {r['latency_med_ms']:>12.1f} {r['latency_p95_ms']:>12.1f}")

    fig, ax = plt.subplots(figsize=(8, 5))
    modes = [r['mode'] for r in results]
    recalls = [r['recall@10'] for r in results]
    lats = [r['latency_med_ms'] for r in results]
    ax.plot(lats, recalls, 'o-', linewidth=2, markersize=8)
    for i, m in enumerate(modes):
        ax.annotate(m, (lats[i], recalls[i]), textcoords="offset points", xytext=(8, -5), fontsize=9)
    ax.set_xlabel("Latence mediane (ms)")
    ax.set_ylabel("recall@10")
    ax.set_title(f"Compromis exact/ANN : Qdrant HNSW sur {N_VECTORS} vecteurs, K=10")
    ax.grid(True, alpha=0.3)
    ax.set_ylim(0.85, 1.01)
    fig.tight_layout()
    fig.savefig("courbe_signature_rag05b.png", dpi=100)
    plt.close(fig)
    print(f"\nCourbe sauvegardee : courbe_signature_rag05b.png")
else:
    print("Pas de resultats : Qdrant indisponible.")

=== TABLEAU RECALL@10 vs LATENCE MEDIANE ===
Mode             recall@10   lat_med_ms   lat_p95_ms
-------------------------------------------------------
exact                1.000         13.4         26.5
hnsw_ef=8            0.991         14.8         26.5
hnsw_ef=16           0.998         14.7         26.6
hnsw_ef=32           1.000         14.1         25.8
hnsw_ef=64           1.000         12.8         26.5
hnsw_ef=128          1.000         14.1         27.0
hnsw_ef=256          1.000          5.3         27.3

Courbe sauvegardee : courbe_signature_rag05b.png

Lecture du résultat : la courbe signature

Le tableau récapitule sept lignes presque toutes à recall@10 = 1.000 — les deux exceptions basses sont ef=8 (0.991) et ef=16 (0.998). Sur le graphique, les points s’alignent sur un plateau horizontal en haut de l’axe vertical : les modes ne se distinguent plus que par leur position horizontale, dans une fourchette de latence médiane resserrée (12.8-14.8 ms, plus 5.3 ms pour ef=256 — voir l’interprétation précédente sur cet artefact de mesure) ; la recherche exacte (13.4 ms) s’y fond, elle n’est pas la plus coûteuse.

Comment lire cette courbe :

  • l’axe horizontal porte le coût (latence médiane), l’axe vertical la qualité (rappel@10) ;
  • un bon compromis est un point le plus haut possible, le plus à gauche possible ;
  • ici tous les points sont à la même hauteur : le plateau dit « le rappel est saturé, prends le point le plus à gauche ».

Pourquoi le genou n’est pas visible : la courbe signature canonique suppose une zone où le petit ef perd des voisins avant de saturer. Des données aussi bien clusterisées, à l’échelle démo, mettent HNSW d’emblée dans la partie saturée de la courbe. La figure reste l’outil juste : à plus grande échelle, la même cellule produirait le coude caractéristique, et le PNG sauvegardé (courbe_signature_rag05b.png) est l’artefact à comparer entre échelles.

Exercice 1 — Casser la saturation : hnsw_ef sous la limite

La courbe signature (cellule « rappel saturé — la sortie corrigée ») montre un recall@10 ≈ 0.99 à hnsw_ef=8 qui grimpe vers 1.0 quand ef augmente — l’index HNSW est maintenant réellement construit (issue #16226 corrigée : indexing_threshold=10 Ko déclenche la construction, full_scan_threshold=100 Ko force la recherche sur le graphe). Question : cette progression survit-elle quand ef descend sous la valeur de K (ef < 10) ? Mesurer le recall@10 moyen pour ef ∈ {1, 2, 4, 8} sur les 200 requêtes, puis identifier le plus petit ef qui garde un rappel ≥ 0.95. La sémantique du paramètre (taille de la liste de candidats explorée par le graphe HNSW) laisse-t-elle prévoir un plancher en dessous de K ?

# Exercice 1 (a completer) : la saturation survit-elle sous ef < K ?
if qdrant_up:
    EF_BAS = [1, 2, 4, 8]
    # TODO Etudiant : pour chaque ef de EF_BAS, mesurer le recall@10 moyen sur les
    # 200 requetes comme au benchmark, puis determiner le plus petit ef gardant
    # un recall@10 >= 0.95.
    recalls_bas_ef = None    # TODO etudiant : dict {ef: recall@10 moyen}
    plus_petit_ef_ok = None  # TODO etudiant : plus petit ef avec recall@10 >= 0.95
    print("Exercice 1 a completer : recalls_bas_ef =", recalls_bas_ef)
    print("Indice : boucler sur EF_BAS, reutiliser search_qdrant(q, ef=ef) et ground_truth.")
else:
    print("Exercice 1 skip : Qdrant indisponible.")
Exercice 1 a completer : recalls_bas_ef = None
Indice : boucler sur EF_BAS, reutiliser search_qdrant(q, ef=ef) et ground_truth.

Exercice 2 — Robustesse du rappel au rang k

Le benchmark évalue un rappel d’ensemble à profondeur 10. Question : la saturation mesurée à k=10 se transpose-t-elle à un rang plus strict ? Mesurer le recall@5 de hnsw_ef=8 puis de la recherche exacte — en interrogeant avec limit=K5 : sans profondeur explicite, le helper rend un top-10, et l’intersection d’un top-10 avec une référence top-5 vaut 1.0 dès que les cinq vrais voisins s’y trouvent quelque part — l’exercice validerait alors un recall@5 de 1.0 quand le vrai recall@5 vaut 0.0. La référence est la tranche classée ground_truth_ranked[qi][:5] (l’ordre y est conservé, contrairement au set du benchmark). Conclure : le compromis exact/ANN dépend-il du rang auquel on évalue ?

# Exercice 2 (a completer) : recall@5 pour ef=8 vs exact
if qdrant_up:
    K5 = 5
    # TODO Etudiant : (1) pour chaque requete, prendre la reference top-5
    # ordonnee deja calculee par la cellule ground_truth : ground_truth_ranked[qi][:5]
    # (l'ordre y est preserve, contrairement au set du recall@10), (2) mesurer le
    # recall@5 de search_qdrant(q, ef=8, limit=K5) puis de search_qdrant(q, exact=True,
    # limit=K5) -- sans limit=K5 la requete rend un top-10 et l'intersection avec un
    # top-5 masque les pertes, (3) conclure en une phrase : la saturation depend-elle
    # du rang k ?
    recall5_hnsw8 = None  # TODO etudiant
    recall5_exact = None  # TODO etudiant
    print("Exercice 2 a completer : recall@5 ef=8 vs exact =", recall5_hnsw8, recall5_exact)
else:
    print("Exercice 2 skip : Qdrant indisponible.")
Exercice 2 a completer : recall@5 ef=8 vs exact = None None
# Persistance : redemarrer le conteneur, verifier que count + recherche sont conserves.
# On utilise le conteneur qui ecoute le port 6333 (peut-etre qdrant-rag05 d'une autre lane, qdrant_rag05b, etc.).
if qdrant_up and docker_available():
    r = subprocess.run(['docker', 'ps', '--filter', 'publish=6333', '--format', '{{.Names}}'],
                       capture_output=True, timeout=5)
    container_names = [n.strip() for n in r.stdout.decode().splitlines() if n.strip()]
    if not container_names:
        print("Aucun conteneur Qdrant actif sur port 6333 : skip persistance.")
    else:
        container_name = container_names[0]
        print(f"Conteneur detecte : {container_name}")

        # Count AVANT restart
        info_before = qdrant_get(f'/collections/{COLLECTION}')
        count_before = info_before.get('result', {}).get('points_count') if isinstance(info_before, dict) else None
        print(f"AVANT restart : points_count={count_before}")

        # Memoire : resultats recherche AVANT (sur query 0)
        resp_before = search_qdrant(queries[0], ef=64)
        ids_before = [p['id'] for p in resp_before.get('result', [])] if resp_before and isinstance(resp_before, dict) else []
        print(f"AVANT restart : query[0] retourne ids = {ids_before[:5]}")

        # Restart conteneur
        print(f"\nRestart du conteneur {container_name}...")
        r = subprocess.run(['docker', 'restart', container_name], capture_output=True, timeout=15)
        print(f"docker restart rc={r.returncode}, stderr={r.stderr.decode()[:200]}")

        # Attendre 5s que Qdrant redemarre
        time.sleep(5)
        health = qdrant_get('/healthz')
        qdrant_after = isinstance(health, str) and 'healthz check passed' in health
        print(f"\nApres restart : Qdrant joignable = {qdrant_after}")

        if qdrant_after:
            info_after = qdrant_get(f'/collections/{COLLECTION}')
            count_after = info_after.get('result', {}).get('points_count') if isinstance(info_after, dict) else None
            print(f"APRES restart : points_count={count_after}")

            resp_after = search_qdrant(queries[0], ef=64)
            ids_after = [p['id'] for p in resp_after.get('result', [])] if resp_after and isinstance(resp_after, dict) else []
            print(f"APRES restart : query[0] retourne ids = {ids_after[:5]}")

            print(f"\nPERSISTANCE OK : count identique = {count_before == count_after}, "
                  f"resultats identiques = {ids_before == ids_after}")
        else:
            print("Qdrant ne s'est pas relance apres restart.")
else:
    print("Test persistance skip : Qdrant ou Docker indisponible.")
Conteneur detecte : qdrant_local
AVANT restart : points_count=10000
AVANT restart : query[0] retourne ids = [4136, 3065, 1519, 4529, 1720]

Restart du conteneur qdrant_local...
docker restart rc=0, stderr=

Apres restart : Qdrant joignable = True
APRES restart : points_count=10000
APRES restart : query[0] retourne ids = [4136, 3065, 1519, 4529, 1720]

PERSISTANCE OK : count identique = True, resultats identiques = True

Interprétation : la collection survit au redémarrage

La sortie raconte l’avant/après du docker restart : points_count=10000 de part et d’autre, et la requête 0 renvoie exactement les mêmes ids — [4136, 3065, 1519, 4529, 1720] — avant et après. Le verdict PERSISTANCE OK : count identique = True, resultats identiques = True conclut : l’état de l’index vit dans le stockage persistant du conteneur (/qdrant/storage), pas dans la mémoire du processus.

Deux lectures complémentaires :

  1. Le conteneur détecté s’appelle qdrant-rag05, pas qdrant_rag05b. C’est le cas nominal documenté en tête de notebook : le port 6333 était déjà servi par le conteneur du notebook 05, et le provisioning l’a réutilisé plutôt que d’en lancer un second. La collection rag05b_demo vit dans ce serveur partagé — et sa persistance est celle du conteneur qui l’héberge réellement.
  2. Les ids croisent le ground truth : parmi les cinq premiers ids affichés figurent 1519, 1720, 3065 et 4136 — quatre des cinq voisins exacts du top-5 calculé à la section ground truth (référence explicite en amont). La recherche de cette cellule retrouve donc bien des voisins exacts, pas seulement des points vaguement proches.

Portée : c’est la propriété qui distingue un serveur d’un index en mémoire de processus — redémarrer, upgrader ou réordonner les conteneurs ne détruit pas l’index.

# Filtrage payload dans l'index : source='incident' AND severite>=3
if qdrant_up:
    print("Test filtrage payload : source='incident' AND severite>=3")
    query_filter = {
        "must": [
            {"key": "source", "match": {"value": "incident"}},
            {"key": "severite", "range": {"gte": 3}}
        ]
    }

    # Recherche SANS filtre
    resp_no_filter = search_qdrant(queries[0], ef=64, with_payload=True)
    if resp_no_filter and isinstance(resp_no_filter, dict):
        print(f"\nSans filtre : {len(resp_no_filter['result'])} resultats")
        for p in resp_no_filter['result'][:5]:
            print(f"  id={p['id']}: source={p['payload']['source']}, severite={p['payload']['severite']}")

    # Recherche AVEC filtre
    resp_filtered = search_qdrant(queries[0], ef=64, with_payload=True, query_filter=query_filter)
    if resp_filtered and isinstance(resp_filtered, dict):
        print(f"\nAvec filtre source='incident' AND severite>=3 : {len(resp_filtered['result'])} resultats")
        all_match = True
        for p in resp_filtered['result'][:5]:
            match = p['payload']['source'] == 'incident' and p['payload']['severite'] >= 3
            print(f"  id={p['id']}: source={p['payload']['source']}, severite={p['payload']['severite']} -> match={match}")
            if not match:
                all_match = False
        print(f"\nTOUS LES RESULTATS MATCHENT LE FILTRE : {all_match}")

    # Statistique : proportion de la base qui matche le filtre
    n_match = sum(1 for p in payloads if p['source'] == 'incident' and p['severite'] >= 3)
    print(f"\nProportion matchant le filtre : {n_match}/{N_VECTORS} = {n_match/N_VECTORS:.2%}")
else:
    print("Filtrage payload skip : Qdrant indisponible.")
Test filtrage payload : source='incident' AND severite>=3

Sans filtre : 10 resultats
  id=4136: source=faq, severite=5
  id=3065: source=release_notes, severite=5
  id=1519: source=faq, severite=5
  id=4529: source=incident, severite=5
  id=1720: source=release_notes, severite=5

Avec filtre source='incident' AND severite>=3 : 10 resultats
  id=4529: source=incident, severite=5 -> match=True
  id=3291: source=incident, severite=4 -> match=True
  id=6022: source=incident, severite=4 -> match=True
  id=8395: source=incident, severite=5 -> match=True
  id=8493: source=incident, severite=5 -> match=True

TOUS LES RESULTATS MATCHENT LE FILTRE : True

Proportion matchant le filtre : 1470/10000 = 14.70%

Interprétation : filtrer côté serveur plutôt qu’après la requête

La comparaison est nette. Sans filtre, le top-10 mélange les sources : les cinq premiers ids affichés couvrent déjà faq, release_notes et incident. Avec le filtre source='incident' AND severite>=3, la même requête renvoie dix résultats dont chaque ligne vérifie la contrainte (match=True ligne à ligne, verdict global True).

Ce que le filtre change, concrètement :

  1. Le résultat filtré (4529, 3291, 6022, …) n’est pas le résultat sans filtre amputé : Qdrant reçoit le prédicat avec la requête et renvoie directement dix points qui le satisfont ;
  2. Seuls 1470/10000 = 14.70% des points matchent : la recherche porte sur les voisins admissibles dans ce sous-ensemble, sans post-filtrage dans le code client ;
  3. Cette requête serveur évite le post-filtrage naïf qui demanderait arbitrairement cent voisins avant de jeter ceux qui ne matchent pas — méthode qui peut rendre trop peu de résultats admissibles.

Pourquoi c’est décisif pour le RAG : une recherche de production n’est presque jamais « les voisins les plus proches, tous contenus confondus », mais « les plus proches parmi les documents autorisés » (source fiable, sévérité suffisante, fenêtre de temps). Le payload indexé rend cette contrainte exécutable par le serveur vectoriel.

Exercice 3 — Le filtre payload qui ne matche rien

Le filtre source='incident' AND severite>=3 laissait encore une fraction admissible de la base. Question : que fait la recherche ANN quand le cône de similarité ne contient aucun point admissible ? Construire le filtre combiné source='incident' AND severite>=6 (la sévérité est tirée entre 1 et 5 : aucun point ne peut matcher), lancer la recherche, puis examiner la réponse : nombre de résultats, code HTTP, comportement. Qdrant renvoie-t-il une liste vide, élargit-il l’exploration, ou échoue-t-il ? Conclure sur ce que « filtrer côté serveur » garantit — et ne garantit pas — quand le filtre est trop sélectif.

# Exercice 3 (a completer) : filtre impossible, comportement observe
if qdrant_up:
    filtre_impossible = {
        "must": [
            {"key": "source", "match": {"value": "incident"}},
            {"key": "severite", "range": {"gte": 6}}
        ]
    }
    # TODO Etudiant : lancer search_qdrant(queries[0], ef=64, with_payload=True,
    # query_filter=filtre_impossible), examiner la reponse (resultat vide ?
    # moins de K resultats ? erreur HTTP ?) et conclure en une phrase.
    observation = None  # TODO etudiant : la reponse obtenue et ce qu'elle revele
    print("Exercice 3 a completer : observation =", observation)
    print("Indice : severite est tire entre 1 et 5 -> aucun point ne peut matcher.")
else:
    print("Exercice 3 skip : Qdrant indisponible.")
Exercice 3 a completer : observation = None
Indice : severite est tire entre 1 et 5 -> aucun point ne peut matcher.

Conclusion : la méthode serveur valide le compromis exact/ANN

Ce que ce notebook démontre (après correction issue #16226) :

  1. Conteneur Qdrant provisionne : idempotent (peut être lancé par le notebook si Docker dispo), dégradable proprement (skip si Docker absent), ou utilise un conteneur déjà tournant (cas nominal partage cross-lane).
  2. Configuration HNSW correcte : full_scan_threshold: 100 Ko (force la recherche sur le graphe, était 10000 Ko par accident) ; indexing_threshold: 10 Ko (lance la construction de l’index en arrière-plan, était 0 ce qui la désactive). Garde fail-visible : RuntimeError si indexed_vectors_count < N_VECTORS après 30 s d’attente active.
  3. Mesure du compromis exact/ANN réel : rappel@10 et latence médiane mesurés sur 200 requêtes pour exact=True et pour les six valeurs de EF_VALUES. Sur la configuration corrigée, le rappel dépend enfin de ef (~0.99 à ef=8, sature à 1.0 à partir de ef=32), au lieu du 1.000 factice de la version buggée.
  4. Courbe signature : trade-off explicite entre qualité (recall@10) et coût (latence). Genou visible à hnsw_ef ≈ 32-64 sur cette échelle.
  5. Persistance : docker restart préserve count + résultats de recherche (vs mode local path=... qui n’a pas cette propriété cross-déploiement).
  6. Filtrage payload : source='incident' AND severite>=3 restreint l’espace de recherche, démonstration que la recherche vectorielle avec filtre est utilisable en production.

Ce que la correction issue #16226 a changé : la version antérieure affichait un rappel@10=1.000 pour tous les hnsw_ef ∈ {8..256} — saturation qui n’était pas un exploit de HNSW mais l’effet d’un full-scan exact systématique (l’index HNSW n’était pas construit). La mesure était factice sans qu’aucune cellule ne le signale. La garde fail-visible (cellule 8) rend visible la régression : si la construction du graphe échoue à l’avenir, le notebook lève une erreur plutôt que de mesurer en silence.

Passage à 100k+ vecteurs : modifier N_VECTORS = 10_000 en N_VECTORS = 100_000 ; le reste du notebook est linéaire (plus de mémoire conteneur + temps d’insertion). Les conclusions méthodologiques sont identiques ; le genou du compromis peut se déplacer légèrement.

Co-habitation avec #12552 (po-2024) : #12552 livre le notebook 05 mode local avec HNSW from-scratch pédagogique ; ce 05b livre le mode serveur avec conteneur Docker réel. Les deux notebooks sont complémentaires : 05 démontre les mécanismes internes (HNSW code from-scratch), 05b valide les mêmes compromis sur l’implémentation de production (Qdrant conteneur).

Verdict SOTA : RECOVERABLE-MACHINE (Qdrant conteneur Docker requis, non disponible sur machine CPU-only sans Docker). Le notebook dégrade proprement si Docker absent (pas d’exécution, mais structure pédagogique préservée).

Mode dégradé : le filet de sécurité

La dernière cellule ne s’active que si Qdrant n’a jamais été joignable. Dans cette exécution, la branche ne sera pas prise — la sortie ci-dessous le confirmera (mode degrade non active) — mais sa présence fait partie du contrat pédagogique : un lecteur sans Docker reçoit quand même une démonstration NumPy du compromis coût/qualité (échantillonnage croissant contre recherche exhaustive), avec un renvoi vers le notebook 05 pour l’implémentation HNSW from-scratch.

À retenir : la dégradation est confinée — elle ne remplace aucune mesure, elle fournit un chemin de repli. Les résultats serveur restent la référence ; le mode dégradé garantit seulement que le notebook reste exécutable partout.

# Mode degrade : si Docker/Qdrant indisponible, ce notebook preserve sa portee pedagogique.
if not qdrant_up:
    print("=== MODE DEGRADE : theorie pure NumPy ===")
    print("Implementation pedagogique du HNSW from-scratch : voir notebook 05 de #12552.")
    print("Ce notebook 05b valide la methode sur Qdrant conteneur (production) ; voir issue #13021 pour les details.")

    N = 10000
    D = 128
    q = np.random.randn(D).astype('float32')
    q /= np.linalg.norm(q)
    db = np.random.randn(N, D).astype('float32')
    db /= np.linalg.norm(db, axis=1, keepdims=True)

    t0 = time.time()
    sims_exact = db @ q
    top_exact_idx = np.argpartition(-sims_exact, K)[:K]
    t_exact = time.time() - t0

    for ef_approx in [32, 128, 512]:
        t0 = time.time()
        sample = db[np.random.choice(N, ef_approx, replace=False)]
        sims_approx = sample @ q
        top_approx_idx = np.argpartition(-sims_approx, K)[:K]
        t_approx = time.time() - t0
        print(f"  ef={ef_approx}: latence={t_approx*1000:.1f}ms (exact: {t_exact*1000:.1f}ms)")

    print("\nPour la mesure exacte sur 100k vecteurs, voir 05b avec Qdrant conteneur.")
else:
    print("Qdrant operationnel : mode degrade non active.")
Qdrant operationnel : mode degrade non active.
Retour au sommet