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.
Objectifs d’apprentissage — à la fin de ce notebook, vous saurez :
Provisionner un serveur vectoriel Qdrant dans un conteneur Docker, de façon idempotente et dégradable ;
Charger une collection de vecteurs clusterisés munis d’un payload sémantique, puis l’ingérer par lots ;
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 ;
Lire la courbe signature et situer le genou du compromis ;
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.errorfrom collections import Counterimport numpy as npSEED =42random.seed(SEED)np.random.seed(SEED)QDRANT_URL = os.environ.get("QDRANT_URL", "http://localhost:6333")COLLECTION ="rag05b_demo"N_VECTORS =10_000DIM =128N_CLUSTERS =50N_QUERIES =200K =10EF_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 rawexceptExceptionas e:print(f"[qdrant_get] {path} failed: {type(e).__name__}: {e}")returnNonedef qdrant_request(method, path, payload=None):"""Requete Qdrant methode+path (PUT/POST/DELETE), payload en body JSON.""" data = json.dumps(payload).encode() if payload isnotNoneelseNone 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 rawexcept urllib.error.HTTPError as e:print(f"[qdrant_request] {method}{path} HTTP {e.code}: {e.read().decode()[:200]}")returnNoneexceptExceptionas e:print(f"[qdrant_request] {method}{path} failed: {type(e).__name__}: {e}")returnNonedef 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.")
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 :
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.
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.
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 ==0exceptException:returnFalseprint(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 healthprint(f"Qdrant joignable ({QDRANT_URL}) : {qdrant_up} (response={health!r})")# Si Qdrant pas up et Docker dispo, tenter de le lancer (idempotent).ifnot 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 =Trueelse: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 healthprint(f"Apres lancement : Qdrant joignable = {qdrant_up} (response={health!r})")ifnot 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}")
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 :
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 ;
Port occupé par un autre conteneur → on le réutilise au lieu de le remplacer : plusieurs notebooks peuvent partager le même serveur ;
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 semantiqueprint(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] + noisevectors /= 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 inrange(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 elseNone}")# 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 elseNone}")# Insertion batch (Qdrant accepte ~1000 points par request, on fait par batch de 500) BATCH =500 t0 = time.time() n_ok =0for start inrange(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 inzip(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 andisinstance(info, dict) and'result'in info: res = info['result'] pc = res.get('points_count') ivc = res.get('indexed_vectors_count')if pc isnotNoneand ivc isnotNoneand pc == ivc == N_VECTORS:break time.sleep(0.5) t_idx1 = time.time()if info andisinstance(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.ifnot info ornotisinstance(info, dict) ornot info.get('result'):raiseRuntimeError("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 isNoneor pc isNoneor ivc < N_VECTORS:raiseRuntimeError(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 :
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).
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.
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.
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).
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 aleatoiresprint(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 decroissantefor qi, q inenumerate(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 :
Exhaustif — chaque vecteur de la base est comparé à la requête, aucune heuristique n’intervient ;
Indépendant du serveur — mesurer Qdrant avec un ground truth calculé par Qdrant serait circulaire ;
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.
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 :
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 :
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.
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.
Chaque p95 dépasse sa médiane — la queue de latence (sérialisation JSON, ordonnanceur du conteneur) domine souvent l’écart entre modes.
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 matplotlibmatplotlib.use('Agg')import matplotlib.pyplot as pltif 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 inenumerate(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.")
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.95print("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 exactif 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 etudiantprint("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()]ifnot 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') ifisinstance(info_before, dict) elseNoneprint(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 andisinstance(resp_before, dict) else []print(f"AVANT restart : query[0] retourne ids = {ids_before[:5]}")# Restart conteneurprint(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 healthprint(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') ifisinstance(info_after, dict) elseNoneprint(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 andisinstance(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.")
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 :
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.
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>=3if 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 andisinstance(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 andisinstance(resp_filtered, dict):print(f"\nAvec filtre source='incident' AND severite>=3 : {len(resp_filtered['result'])} resultats") all_match =Truefor p in resp_filtered['result'][:5]: match = p['payload']['source'] =='incident'and p['payload']['severite'] >=3print(f" id={p['id']}: source={p['payload']['source']}, severite={p['payload']['severite']} -> match={match}")ifnot match: all_match =Falseprint(f"\nTOUS LES RESULTATS MATCHENT LE FILTRE : {all_match}")# Statistique : proportion de la base qui matche le filtre n_match =sum(1for 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 filtresource='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 :
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 ;
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 ;
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 observeif 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 reveleprint("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) :
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).
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.
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.
Courbe signature : trade-off explicite entre qualité (recall@10) et coût (latence). Genou visible à hnsw_ef ≈ 32-64 sur cette échelle.
Persistance : docker restart préserve count + résultats de recherche (vs mode local path=... qui n’a pas cette propriété cross-déploiement).
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.ifnot 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() - t0for 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() - t0print(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.")