From e377031091ddf56ff26a453af41ee24d848f1cd2 Mon Sep 17 00:00:00 2001 From: jsboige Date: Tue, 15 Sep 2026 02:50:50 +0200 Subject: [PATCH 1/2] fix(rag,#16226): construire et prouver le vrai index HNSW du 05b indexing_threshold: 0 ne construisait jamais le graphe et full_scan_threshold (10000 Ko) depassait la taille des donnees (5000 Ko) : hnsw_ef etait ignore, le compromis exact/ANN mesurait un artefact. Seuils 100/10 Ko, temoin indexed_vectors_count=10000/10000 en sortie, garde fail-visible avant benchmark, re-execution complete (ef=8 -> recall 0.989), interpretations realignees sur les mesures reelles. Co-Authored-By: Claude Sonnet 5 --- .../05b-Stockage-Vectoriel-Serveur.ipynb | 532 ++++++++++-------- .../courbe_signature_rag05b.png | Bin 27605 -> 30197 bytes 2 files changed, 283 insertions(+), 249 deletions(-) diff --git a/MyIA.AI.Notebooks/GenAI/RAG-et-Memoire-Semantique/05b-Stockage-Vectoriel-Serveur.ipynb b/MyIA.AI.Notebooks/GenAI/RAG-et-Memoire-Semantique/05b-Stockage-Vectoriel-Serveur.ipynb index 65a3c10ace..557ce40e38 100644 --- a/MyIA.AI.Notebooks/GenAI/RAG-et-Memoire-Semantique/05b-Stockage-Vectoriel-Serveur.ipynb +++ b/MyIA.AI.Notebooks/GenAI/RAG-et-Memoire-Semantique/05b-Stockage-Vectoriel-Serveur.ipynb @@ -5,10 +5,10 @@ "id": "2bac5024", "metadata": { "papermill": { - "duration": 0.002449, - "end_time": "2026-09-12T08:45:42.612257", + "duration": 0.003297, + "end_time": "2026-09-15T00:45:17.845277+00:00", "exception": false, - "start_time": "2026-09-12T08:45:42.609808", + "start_time": "2026-09-15T00:45:17.841980+00:00", "status": "completed" }, "tags": [] @@ -20,9 +20,11 @@ "\n", "**Grain #13021** : RAG-05 complement serveur. **Acceptance** : conteneur Qdrant provisionne par le notebook (idempotent, degradable), rappel@10 et latence mesures exact vs `hnsw_ef ∈ {8,16,32,64,128,256}` sur >= 10k vecteurs (echelle demo, passage a 100k trivial), courbe signature committed avec outputs, persistance par `docker restart`, filtrage payload dans la recherche.\n", "\n", + "**Revision 2026-09-15 (suivi #16226)** : la configuration initiale (`full_scan_threshold: 10000` Ko, `indexing_threshold: 0`) laissait l'index HNSW **non construit** et toutes les requetes en scan exact -- le parametre `hnsw_ef` etait ignore et la courbe signature mesurait un compromis fantome (rappel 1.000 partout). Les deux seuils passent desormais sous la taille des donnees (5 000 Ko), la construction de l'index est **prouvee** en sortie (`indexed_vectors_count=10000/10000`), et une garde fail-visible arrete le benchmark si l'index manque.\n", + "\n", "**Note d'echelle** : on demontre la methode sur 10k vecteurs (cycle 30min). Le passage a 100k+ demande simplement plus de temps CPU et memoire conteneur ; le notebook est parametre pour que la transition soit lineaire (1 ligne a changer).\n", "\n", - "**References** : Qdrant doc API Python ; HNSW paper Malkov & Yashunin 2018 ; #12448 (EPIC RAG-05) ; #12552 (livraison locale po-2024) ; Issue #13021 (ce complement)." + "**References** : Qdrant doc API Python ; HNSW paper Malkov & Yashunin 2018 ; #12448 (EPIC RAG-05) ; #12552 (livraison locale po-2024) ; Issue #13021 (ce complement) ; Issue #16226 (revision vra index)." ] }, { @@ -30,10 +32,10 @@ "id": "95fdc23c", "metadata": { "papermill": { - "duration": 0.002003, - "end_time": "2026-09-12T08:45:42.616527", + "duration": 0.002423, + "end_time": "2026-09-15T00:45:17.850245+00:00", "exception": false, - "start_time": "2026-09-12T08:45:42.614524", + "start_time": "2026-09-15T00:45:17.847822+00:00", "status": "completed" }, "tags": [] @@ -62,16 +64,16 @@ "id": "d7e7ca7e", "metadata": { "execution": { - "iopub.execute_input": "2026-09-12T08:45:42.621443Z", - "iopub.status.busy": "2026-09-12T08:45:42.621249Z", - "iopub.status.idle": "2026-09-12T08:45:42.688520Z", - "shell.execute_reply": "2026-09-12T08:45:42.688058Z" + "iopub.execute_input": "2026-09-15T00:45:17.856390Z", + "iopub.status.busy": "2026-09-15T00:45:17.856116Z", + "iopub.status.idle": "2026-09-15T00:45:17.929764Z", + "shell.execute_reply": "2026-09-15T00:45:17.929107Z" }, "papermill": { - "duration": 0.070712, - "end_time": "2026-09-12T08:45:42.689290", + "duration": 0.077827, + "end_time": "2026-09-15T00:45:17.930401+00:00", "exception": false, - "start_time": "2026-09-12T08:45:42.618578", + "start_time": "2026-09-15T00:45:17.852574+00:00", "status": "completed" }, "tags": [] @@ -159,10 +161,10 @@ "id": "7f2ce18c", "metadata": { "papermill": { - "duration": 0.002302, - "end_time": "2026-09-12T08:45:42.693841", + "duration": 0.002689, + "end_time": "2026-09-15T00:45:17.935694+00:00", "exception": false, - "start_time": "2026-09-12T08:45:42.691539", + "start_time": "2026-09-15T00:45:17.933005+00:00", "status": "completed" }, "tags": [] @@ -193,16 +195,16 @@ "id": "fe4f5fb5", "metadata": { "execution": { - "iopub.execute_input": "2026-09-12T08:45:42.698729Z", - "iopub.status.busy": "2026-09-12T08:45:42.698493Z", - "iopub.status.idle": "2026-09-12T08:45:53.093505Z", - "shell.execute_reply": "2026-09-12T08:45:53.092990Z" + "iopub.execute_input": "2026-09-15T00:45:17.941751Z", + "iopub.status.busy": "2026-09-15T00:45:17.941480Z", + "iopub.status.idle": "2026-09-15T00:45:18.684868Z", + "shell.execute_reply": "2026-09-15T00:45:18.684293Z" }, "papermill": { - "duration": 10.39873, - "end_time": "2026-09-12T08:45:53.094575", + "duration": 0.747271, + "end_time": "2026-09-15T00:45:18.685509+00:00", "exception": false, - "start_time": "2026-09-12T08:45:42.695845", + "start_time": "2026-09-15T00:45:17.938238+00:00", "status": "completed" }, "tags": [] @@ -212,37 +214,8 @@ "name": "stdout", "output_type": "stream", "text": [ - "Docker dispo : True\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "[qdrant_get] /healthz failed: URLError: \n", - "Qdrant joignable (http://localhost:6333) : False (response=None)\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Tentative de lancement du conteneur Qdrant (port 6333, latest)...\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "docker run rc=0, stdout=fe7133194334cb0210366a969be4005cbeb2399abf894f25d53a90f9ffc9a24b\n", - "\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Apres lancement : Qdrant joignable = True (response='healthz check passed')\n", + "Docker dispo : True\n", + "Qdrant joignable (http://localhost:6333) : True (response='healthz check passed')\n", "\n", "Qdrant operationnel sur http://localhost:6333\n" ] @@ -302,10 +275,10 @@ "id": "651b72d4", "metadata": { "papermill": { - "duration": 0.00257, - "end_time": "2026-09-12T08:45:53.100955", + "duration": 0.002584, + "end_time": "2026-09-15T00:45:18.691754+00:00", "exception": false, - "start_time": "2026-09-12T08:45:53.098385", + "start_time": "2026-09-15T00:45:18.689170+00:00", "status": "completed" }, "tags": [] @@ -332,16 +305,16 @@ "id": "6f8aec44", "metadata": { "execution": { - "iopub.execute_input": "2026-09-12T08:45:53.108624Z", - "iopub.status.busy": "2026-09-12T08:45:53.108247Z", - "iopub.status.idle": "2026-09-12T08:45:53.155384Z", - "shell.execute_reply": "2026-09-12T08:45:53.154918Z" + "iopub.execute_input": "2026-09-15T00:45:18.698104Z", + "iopub.status.busy": "2026-09-15T00:45:18.697860Z", + "iopub.status.idle": "2026-09-15T00:45:18.742112Z", + "shell.execute_reply": "2026-09-15T00:45:18.741512Z" }, "papermill": { - "duration": 0.050916, - "end_time": "2026-09-12T08:45:53.156184", + "duration": 0.048281, + "end_time": "2026-09-15T00:45:18.742641+00:00", "exception": false, - "start_time": "2026-09-12T08:45:53.105268", + "start_time": "2026-09-15T00:45:18.694360+00:00", "status": "completed" }, "tags": [] @@ -397,10 +370,10 @@ "id": "af451f0b", "metadata": { "papermill": { - "duration": 0.002121, - "end_time": "2026-09-12T08:45:53.160849", + "duration": 0.002576, + "end_time": "2026-09-15T00:45:18.747917+00:00", "exception": false, - "start_time": "2026-09-12T08:45:53.158728", + "start_time": "2026-09-15T00:45:18.745341+00:00", "status": "completed" }, "tags": [] @@ -423,16 +396,16 @@ "id": "698f2350", "metadata": { "execution": { - "iopub.execute_input": "2026-09-12T08:45:53.167910Z", - "iopub.status.busy": "2026-09-12T08:45:53.167719Z", - "iopub.status.idle": "2026-09-12T08:45:54.790436Z", - "shell.execute_reply": "2026-09-12T08:45:54.789631Z" + "iopub.execute_input": "2026-09-15T00:45:18.754438Z", + "iopub.status.busy": "2026-09-15T00:45:18.754126Z", + "iopub.status.idle": "2026-09-15T00:45:22.855780Z", + "shell.execute_reply": "2026-09-15T00:45:22.854752Z" }, "papermill": { - "duration": 1.628468, - "end_time": "2026-09-12T08:45:54.791403", + "duration": 4.106042, + "end_time": "2026-09-15T00:45:22.856527+00:00", "exception": false, - "start_time": "2026-09-12T08:45:53.162935", + "start_time": "2026-09-15T00:45:18.750485+00:00", "status": "completed" }, "tags": [] @@ -456,9 +429,15 @@ "name": "stdout", "output_type": "stream", "text": [ - "Insertion 10000/10000 points : 1.20s (8348 pts/s)\n", + "Insertion 10000/10000 points : 1.75s (5716 pts/s)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ "\n", - "Collection info : points_count=10000, vectors_count=None\n" + "Attente optimiseur : 2.0s | status=green, points_count=10000, indexed_vectors_count=10000/10000\n" ] } ], @@ -468,7 +447,13 @@ " resp_del = qdrant_delete(f'/collections/{COLLECTION}')\n", " print(f\"Delete (peut-etre 404 si nouveau) : status={resp_del.get('status') if resp_del else None}\")\n", "\n", - " # Creer avec HNSW\n", + " # Creer avec HNSW. Les deux seuils s'expriment en Ko de donnees vectorielles :\n", + " # - full_scan_threshold : en dessous, Qdrant repond par scan exact (l'index ne sert a rien) ;\n", + " # - indexing_threshold : l'optimiseur ne construit l'index qu'au-dela (0 = construction jamais lancee).\n", + " # Ici 10 000 vecteurs x 128 dim x 4 octets = 5 000 Ko : pour que le graphe HNSW soit\n", + " # construit PUIS reellement emprunte par les requetes, les deux seuils passent sous cette taille.\n", + " # (Une version anterieure de ce notebook posait 10 000 / 0 : l'index n'existait jamais,\n", + " # et le parametre hnsw_ef etait silencieusement ignore -- voir l'issue de suivi.)\n", " create_payload = {\n", " \"vectors\": {\n", " \"size\": DIM,\n", @@ -477,10 +462,10 @@ " \"hnsw_config\": {\n", " \"m\": 16,\n", " \"ef_construct\": 100,\n", - " \"full_scan_threshold\": 10000\n", + " \"full_scan_threshold\": 100\n", " },\n", " \"optimizers_config\": {\n", - " \"indexing_threshold\": 0\n", + " \"indexing_threshold\": 10\n", " }\n", " }\n", " resp = qdrant_put(f'/collections/{COLLECTION}', create_payload)\n", @@ -507,15 +492,25 @@ " t1 = time.time()\n", " print(f\"Insertion {n_ok}/{N_VECTORS} points : {t1-t0:.2f}s ({n_ok/(t1-t0):.0f} pts/s)\")\n", "\n", - " # Verifier count\n", - " info = qdrant_get(f'/collections/{COLLECTION}')\n", - " if info and isinstance(info, dict) and 'result' in info:\n", - " print(f\"\\nCollection info : points_count={info['result'].get('points_count')}, \"\n", - " f\"vectors_count={info['result'].get('vectors_count')}\")\n", - " else:\n", - " print(f\"\\nInfo collection : {info}\")\n", + " # Attendre que l'optimiseur construise l'index HNSW -- et le PROUVER :\n", + " # indexed_vectors_count est le temoin que le graphe existe reellement.\n", + " # Sans ce temoin, un benchmark \"exact vs hnsw_ef\" peut mesurer deux scans\n", + " # exacts sans que rien ne le signale.\n", + " t0 = time.time()\n", + " res = {}\n", + " while time.time() - t0 < 120:\n", + " info = qdrant_get(f'/collections/{COLLECTION}')\n", + " res = info.get('result', {}) if isinstance(info, dict) else {}\n", + " if res.get('status') == 'green' and (res.get('indexed_vectors_count') or 0) >= N_VECTORS:\n", + " break\n", + " time.sleep(2)\n", + " print(f\"\\nAttente optimiseur : {time.time()-t0:.1f}s | status={res.get('status')}, \"\n", + " f\"points_count={res.get('points_count')}, \"\n", + " f\"indexed_vectors_count={res.get('indexed_vectors_count')}/{N_VECTORS}\")\n", + " if (res.get('indexed_vectors_count') or 0) < N_VECTORS:\n", + " print(\"*** INDEX NON CONSTRUIT : la garde du benchmark le signalera (cellule suivante). ***\")\n", "else:\n", - " print(\"Qdrant indisponible : skip insertion\")\n" + " print(\"Qdrant indisponible : skip insertion\")" ] }, { @@ -523,31 +518,29 @@ "id": "8867007f", "metadata": { "papermill": { - "duration": 0.002513, - "end_time": "2026-09-12T08:45:54.796776", + "duration": 0.002601, + "end_time": "2026-09-15T00:45:22.862066+00:00", "exception": false, - "start_time": "2026-09-12T08:45:54.794263", + "start_time": "2026-09-15T00:45:22.859465+00:00", "status": "completed" }, "tags": [] }, "source": [ - "### Interprétation : création de collection et ingestion par lots\n", + "### Interpretation : creation, ingestion -- et preuve que l'index existe\n", "\n", - "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 :\n", + "Le `Delete (peut-etre 404 si nouveau) : status=ok` d'ouverture sert la **reproductibilite** : supprimer la collection si elle existe garantit que chaque execution repart d'un index vierge. Le `Create collection : status=ok` enregistre la configuration demandee par la cellule :\n", "\n", - "| Paramètre | Valeur | Signification |\n", + "| Parametre | Valeur | Signification |\n", "|-----------|--------|---------------|\n", - "| `m` | 16 | nombre de liens par nœud du graphe |\n", + "| `m` | 16 | nombre de liens par noeud du graphe HNSW |\n", "| `ef_construct` | 100 | largeur de recherche lors de la construction des liens |\n", - "| `full_scan_threshold` | 10000 | seuil configuré pour l'arbitrage entre scan exact et HNSW |\n", - "| `indexing_threshold` | 0 | demander l'indexation sans seuil d'attente |\n", + "| `full_scan_threshold` | 100 Ko | en dessous de ce seuil, Qdrant repond par scan exact sans emprunter l'index |\n", + "| `indexing_threshold` | 10 Ko | l'optimiseur ne construit l'index qu'au-dela de ce seuil (0 = jamais construit) |\n", "\n", - "**Points clés** :\n", + "**Pourquoi ces deux seuils, et pourquoi si bas** : les unites sont des **Ko de donnees vectorielles**, pas un nombre de points. La collection pese 10 000 x 128 x 4 octets = **5 000 Ko**. La configuration anterieure posait `10000/0` : l'optimiseur ne construisait jamais le graphe (`indexing_threshold: 0`), et meme un graphe construit n'aurait pas ete emprunte (5 000 Ko < `full_scan_threshold`) -- toutes les requetes, `hnsw_ef` compris, etaient des scans exacts. Les seuils actuels (100 Ko, 10 Ko) passent sous la taille reelle : le graphe est construit **et** sert.\n", "\n", - "1. `Insertion 10000/10000 points` : tous les lots ont été acceptés ; le compteur serveur `points_count=10000` confirme la prise en compte — la source de vérité est la réponse du serveur, pas le compte local.\n", - "2. `vectors_count=None` est une absence de champ pour cette version de l'API, sans incidence sur la suite.\n", - "3. 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." + "**La preuve, en une ligne de sortie** : `Attente optimiseur : 2.0s | status=green, points_count=10000, indexed_vectors_count=10000/10000`. Le champ `indexed_vectors_count` est le temoin que les 10 000 vecteurs vivent **dans le graphe HNSW**. Sans lui, rien ne distingue un serveur indexe d'un serveur en scan integral : les deux repondent, tous deux affichent `status=green`. C'est la garantie minimale avant de mesurer quoi que ce soit sur `hnsw_ef` -- la cellule benchmark la refuse d'ailleurs sans ce temoin." ] }, { @@ -556,16 +549,16 @@ "id": "58b1033c", "metadata": { "execution": { - "iopub.execute_input": "2026-09-12T08:45:54.802797Z", - "iopub.status.busy": "2026-09-12T08:45:54.802454Z", - "iopub.status.idle": "2026-09-12T08:45:54.854337Z", - "shell.execute_reply": "2026-09-12T08:45:54.853932Z" + "iopub.execute_input": "2026-09-15T00:45:22.872192Z", + "iopub.status.busy": "2026-09-15T00:45:22.871982Z", + "iopub.status.idle": "2026-09-15T00:45:22.936710Z", + "shell.execute_reply": "2026-09-15T00:45:22.935778Z" }, "papermill": { - "duration": 0.055789, - "end_time": "2026-09-12T08:45:54.855044", + "duration": 0.070781, + "end_time": "2026-09-15T00:45:22.937642+00:00", "exception": false, - "start_time": "2026-09-12T08:45:54.799255", + "start_time": "2026-09-15T00:45:22.866861+00:00", "status": "completed" }, "tags": [] @@ -576,7 +569,7 @@ "output_type": "stream", "text": [ "Calcul ground truth exact (numpy brute force) sur 200 requetes, K=10...\n", - "Ground truth : 0.05s (4247.1 queries/s)\n", + "Ground truth : 0.06s (3520.8 queries/s)\n", "Exemple query 0 : top-5 classes = [4136, 3065, 1519, 4529, 1720]\n", "Stocke 200 ground truths (K=10 chacun, ensemble + classement)\n" ] @@ -610,15 +603,32 @@ "id": "2387c89b", "metadata": { "papermill": { - "duration": 0.002467, - "end_time": "2026-09-12T08:45:54.860154", + "duration": 0.002613, + "end_time": "2026-09-15T00:45:22.943050+00:00", "exception": false, - "start_time": "2026-09-12T08:45:54.857687", + "start_time": "2026-09-15T00:45:22.940437+00:00", "status": "completed" }, "tags": [] }, - "source": "### Interprétation : le ground truth exact, calculé hors du serveur\n\nAvant 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 :\n\n- `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 ;\n- `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]`.\n\nLa 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.\n\n**Pourquoi ce calcul est digne de confiance** :\n\n1. **Exhaustif** — chaque vecteur de la base est comparé à la requête, aucune heuristique n'intervient ;\n2. **Indépendant du serveur** — mesurer Qdrant avec un ground truth calculé par Qdrant serait circulaire ;\n3. **Reproductible** — les requêtes sont tirées avec la graine déjà posée en tête de notebook.\n\n**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." + "source": [ + "### Interprétation : le ground truth exact, calculé hors du serveur\n", + "\n", + "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 :\n", + "\n", + "- `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 ;\n", + "- `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]`.\n", + "\n", + "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.\n", + "\n", + "**Pourquoi ce calcul est digne de confiance** :\n", + "\n", + "1. **Exhaustif** — chaque vecteur de la base est comparé à la requête, aucune heuristique n'intervient ;\n", + "2. **Indépendant du serveur** — mesurer Qdrant avec un ground truth calculé par Qdrant serait circulaire ;\n", + "3. **Reproductible** — les requêtes sont tirées avec la graine déjà posée en tête de notebook.\n", + "\n", + "**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." + ] }, { "cell_type": "code", @@ -626,16 +636,16 @@ "id": "35cc30ea", "metadata": { "execution": { - "iopub.execute_input": "2026-09-12T08:45:54.865801Z", - "iopub.status.busy": "2026-09-12T08:45:54.865563Z", - "iopub.status.idle": "2026-09-12T08:46:15.940221Z", - "shell.execute_reply": "2026-09-12T08:46:15.939326Z" + "iopub.execute_input": "2026-09-15T00:45:22.950754Z", + "iopub.status.busy": "2026-09-15T00:45:22.950382Z", + "iopub.status.idle": "2026-09-15T00:45:39.249658Z", + "shell.execute_reply": "2026-09-15T00:45:39.248948Z" }, "papermill": { - "duration": 21.079325, - "end_time": "2026-09-12T08:46:15.941675", + "duration": 16.304362, + "end_time": "2026-09-15T00:45:39.250362+00:00", "exception": false, - "start_time": "2026-09-12T08:45:54.862350", + "start_time": "2026-09-15T00:45:22.946000+00:00", "status": "completed" }, "tags": [] @@ -652,49 +662,49 @@ "name": "stdout", "output_type": "stream", "text": [ - " exact : recall@10=1.000, latence mediane=16.0ms\n" + " exact : recall@10=1.000, latence mediane=15.1ms\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ - " ef= 8 : recall@10=1.000, latence mediane=15.1ms, p95=32.4ms\n" + " ef= 8 : recall@10=0.989, latence mediane=14.3ms, p95=27.0ms\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ - " ef= 16 : recall@10=1.000, latence mediane=15.5ms, p95=32.2ms\n" + " ef= 16 : recall@10=1.000, latence mediane=12.9ms, p95=27.1ms\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ - " ef= 32 : recall@10=1.000, latence mediane=15.6ms, p95=31.0ms\n" + " ef= 32 : recall@10=1.000, latence mediane=13.3ms, p95=27.4ms\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ - " ef= 64 : recall@10=1.000, latence mediane=15.3ms, p95=28.8ms\n" + " ef= 64 : recall@10=1.000, latence mediane=14.7ms, p95=26.9ms\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ - " ef=128 : recall@10=1.000, latence mediane=16.2ms, p95=33.6ms\n" + " ef=128 : recall@10=1.000, latence mediane=5.4ms, p95=27.3ms\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ - " ef=256 : recall@10=1.000, latence mediane=13.9ms, p95=30.9ms\n" + " ef=256 : recall@10=1.000, latence mediane=4.3ms, p95=25.7ms\n" ] } ], @@ -717,7 +727,19 @@ " payload[\"filter\"] = query_filter\n", " return qdrant_post(f'/collections/{COLLECTION}/points/search', payload)\n", "\n", + "# Garde fail-visible : si l'index HNSW n'est pas construit, Qdrant sert des scans\n", + "# exacts et IGNORE hnsw_ef -- mesurer quand meme produirait un \"compromis\" fantome\n", + "# (rappel 1.000 partout, parametre sans effet) sans aucun signal d'erreur.\n", + "index_pret = False\n", "if qdrant_up:\n", + " info = qdrant_get(f'/collections/{COLLECTION}')\n", + " res = info.get('result', {}) if isinstance(info, dict) else {}\n", + " index_pret = (res.get('indexed_vectors_count') or 0) >= N_VECTORS\n", + " if not index_pret:\n", + " print(f\"*** INDEX ABSENT (indexed_vectors_count={res.get('indexed_vectors_count')}) : \"\n", + " \"benchmark SKIP -- mesurer des scans exacts en croyant mesurer l'ANN serait un faux temoin. ***\")\n", + "\n", + "if qdrant_up and index_pret:\n", " # 1) Recherche EXACTE\n", " print(\"Benchmark : recherche EXACTE\")\n", " recalls_exact = []\n", @@ -752,7 +774,7 @@ " \"latency_p95_ms\": np.percentile(latencies, 95)})\n", " 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\")\n", "else:\n", - " print(\"Qdrant indisponible : skip benchmark\")" + " print(\"Qdrant indisponible ou index absent : skip benchmark\")" ] }, { @@ -760,28 +782,28 @@ "id": "58696280", "metadata": { "papermill": { - "duration": 0.002472, - "end_time": "2026-09-12T08:46:15.947741", + "duration": 0.003919, + "end_time": "2026-09-15T00:45:39.257959+00:00", "exception": false, - "start_time": "2026-09-12T08:46:15.945269", + "start_time": "2026-09-15T00:45:39.254040+00:00", "status": "completed" }, "tags": [] }, "source": [ - "### Interprétation : rappel saturé — le signal est dans le coût\n", + "### Interpretation : le rappel depend enfin de ef -- ef=8 perd 1,1 %\n", "\n", - "C'est le résultat central du notebook, et il est contre-intuitif : le rappel@10 vaut **1.000 pour chaque mode**. La recherche exacte comme chaque `hnsw_ef` de 8 à 256 retrouve l'intégralité du top-10 exact sur les 200 requêtes. À cette échelle, sur des grappes aussi serrées, le graphe HNSW est suffisamment connecté pour que même le plus petit `ef` ne perde aucun voisin.\n", + "Le resultat central : **`ef=8` termine a `recall@10 = 0.989`**, tous les autres modes a `1.000`. Une seule mesure separe maintenant le mode exact du mode approche -- c'est peu, et c'est tout : sur la version fantome de ce benchmark (index jamais construit, `hnsw_ef` ignore), les sept lignes affichaient `1.000` sans exception et rien ne le signalait. Le parametre pilote desormais reellement la recherche : c'est la difference entre mesurer un compromis et photographier un artefact.\n", "\n", - "Le signal discriminant est donc la **latence**, et la sortie en livre trois lectures :\n", + "Trois lectures de la sortie :\n", "\n", - "1. **La recherche exacte est la plus coûteuse** : plus de trois fois la latence médiane de `hnsw_ef=16` (les deux mesures proviennent de la même cellule) ;\n", - "2. **La latence médiane croît avec `ef`** : plus de deux fois plus élevée à `ef=256` qu'à `ef=16` — explorer plus de candidats coûte, même quand le rappel est déjà saturé ;\n", - "3. **Chaque p95 dépasse sa médiane** : la queue de latence (sérialisation JSON, ordonnanceur du conteneur) domine souvent l'écart entre modes.\n", + "1. **La perte d'approximation se concentre sous `K`** : a `ef=8 < K=10`, la largeur d'exploration descend sous la taille du top demande ; des `ef=16`, la saturation revient. Le bord de la courbe signature vit donc entre 8 et 16 -- exactement la zone que l'exercice 1 ira sonder (`ef` dans {1, 2, 4, 8}) ;\n", + "2. **La latence ne se lit pas comme un effet de `ef`** : les medianes s'etalent de 4,3 a 15,1 ms **sans ordre monotone** (14,3 -> 12,9 -> 13,3 -> 14,7 -> 5,4 -> 4,3 ms). La passe execute `exact` d'abord puis `ef` croissant : a cette echelle le cout fixe HTTP/noyau domine, et les dernieres mesures beneficient des caches rechauffes par les premieres -- la baisse finale dit plus le rechauffement que la largeur de recherche ;\n", + "3. **Chaque p95 (25,7 a 27,4 ms) ecrase sa mediane** : la queue de latence (serialisation JSON, ordonnanceur du conteneur) domine l'ecart entre modes.\n", "\n", - "**Lecture honnête** : à l'échelle démo, le compromis est plat côté qualité — le choix rationnel est un petit `ef`. Le genou (le point où augmenter `ef` commence à acheter du rappel) n'apparaît qu'à plus grande échelle ou sur des données plus difficiles ; le paramétrage du notebook est précisément fait pour aller le mesurer en changeant une constante.\n", + "**Lecture honnete** : a l'echelle demo, le compromis qualite est quasi plat au-dessus de `ef=8` et le signal de cout est noye dans le bruit. La conclusion operationnelle est justement celle-ci : un petit `ef` (des 16 ici) est gratuit en rappel, et la mesure qui le prouve exige un index dont l'existence est attestee au prealable.\n", "\n", - "> **Note technique** : un benchmark qui ne montre qu'un rappel de 1.000 partout ne dit rien tout seul — c'est son croisement avec la latence qui décide." + "> **Note technique** : un benchmark de rappel ne dit rien tout seul -- ni un plateau a 1.000 (qui peut etre un artefact de scan exact), ni un trou a 0.989 (qui peut etre un plancher de largeur). C'est le croisement avec le temoin d'index et la structure en `ef` qui decide." ] }, { @@ -790,16 +812,16 @@ "id": "de01bd37", "metadata": { "execution": { - "iopub.execute_input": "2026-09-12T08:46:15.953207Z", - "iopub.status.busy": "2026-09-12T08:46:15.953047Z", - "iopub.status.idle": "2026-09-12T08:46:16.950784Z", - "shell.execute_reply": "2026-09-12T08:46:16.950284Z" + "iopub.execute_input": "2026-09-15T00:45:39.268301Z", + "iopub.status.busy": "2026-09-15T00:45:39.268020Z", + "iopub.status.idle": "2026-09-15T00:45:41.418239Z", + "shell.execute_reply": "2026-09-15T00:45:41.416977Z" }, "papermill": { - "duration": 1.001397, - "end_time": "2026-09-12T08:46:16.951524", + "duration": 2.156758, + "end_time": "2026-09-15T00:45:41.419081+00:00", "exception": false, - "start_time": "2026-09-12T08:46:15.950127", + "start_time": "2026-09-15T00:45:39.262323+00:00", "status": "completed" }, "tags": [] @@ -813,13 +835,19 @@ "=== TABLEAU RECALL@10 vs LATENCE MEDIANE ===\n", "Mode recall@10 lat_med_ms lat_p95_ms\n", "-------------------------------------------------------\n", - "exact 1.000 16.0 30.2\n", - "hnsw_ef=8 1.000 15.1 32.4\n", - "hnsw_ef=16 1.000 15.5 32.2\n", - "hnsw_ef=32 1.000 15.6 31.0\n", - "hnsw_ef=64 1.000 15.3 28.8\n", - "hnsw_ef=128 1.000 16.2 33.6\n", - "hnsw_ef=256 1.000 13.9 30.9\n", + "exact 1.000 15.1 26.8\n", + "hnsw_ef=8 0.989 14.3 27.0\n", + "hnsw_ef=16 1.000 12.9 27.1\n", + "hnsw_ef=32 1.000 13.3 27.4\n", + "hnsw_ef=64 1.000 14.7 26.9\n", + "hnsw_ef=128 1.000 5.4 27.3\n", + "hnsw_ef=256 1.000 4.3 25.7\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ "\n", "Courbe sauvegardee : courbe_signature_rag05b.png\n" ] @@ -863,26 +891,26 @@ "id": "164c9d18", "metadata": { "papermill": { - "duration": 0.002629, - "end_time": "2026-09-12T08:46:16.957040", + "duration": 0.003, + "end_time": "2026-09-15T00:45:41.425399+00:00", "exception": false, - "start_time": "2026-09-12T08:46:16.954411", + "start_time": "2026-09-15T00:45:41.422399+00:00", "status": "completed" }, "tags": [] }, "source": [ - "### Lecture du résultat : la courbe signature\n", + "### Lecture du resultat : la courbe signature\n", "\n", - "Le tableau récapitule sept lignes, toutes à `recall@10 = 1.000`. 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. La recherche exacte est le point le plus à droite (la plus coûteuse) ; les modes `hnsw_ef` s'étalent à sa gauche.\n", + "Le tableau resume sept lignes : six a `recall@10 = 1.000` et **une a `0.989` -- `hnsw_ef=8`**, le point bas du graphique. Le plateau de saturation reste dominant, mais il a desormais un bord : c'est ce bord que la courbe signature existe pour rendre visible.\n", "\n", "**Comment lire cette courbe** :\n", "\n", - "- l'axe horizontal porte le coût (latence médiane), l'axe vertical la qualité (rappel@10) ;\n", - "- un bon compromis est un point **le plus haut possible, le plus à gauche possible** ;\n", - "- ici tous les points sont à la même hauteur : le plateau dit « le rappel est saturé, prends le point le plus à gauche ».\n", + "- l'axe horizontal porte le cout (latence mediane), l'axe vertical la qualite (recall@10) ;\n", + "- un bon compromis est un point **le plus haut possible, le plus a gauche possible** ;\n", + "- le point `ef=8` legerement sous le plateau materialise la perte d'approximation -- mesuree (1,1 %), pas spectaculaire, et sa position horizontale (14,3 ms) n'achete rien par rapport a `ef=16` (12,9 ms, rappel plein).\n", "\n", - "**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." + "**Pourquoi le genou est ici si discret** : des donnees aussi bien clusterisees, a l'echelle demo, mettent HNSW d'emblee pres de la saturation ; le vrai coude -- la zone ou croitre `ef` achete du rappel -- vit sous `K`, et c'est l'objet de l'exercice 1. La figure reste l'outil juste : a plus grande echelle ou sur des donnees plus dispersees, la meme cellule produit le coude caracteristique, et le PNG sauvegarde (`courbe_signature_rag05b.png`) est l'artefact a comparer entre echelles." ] }, { @@ -890,10 +918,10 @@ "id": "35388fb7", "metadata": { "papermill": { - "duration": 0.002664, - "end_time": "2026-09-12T08:46:16.962353", + "duration": 0.00286, + "end_time": "2026-09-15T00:45:41.432345+00:00", "exception": false, - "start_time": "2026-09-12T08:46:16.959689", + "start_time": "2026-09-15T00:45:41.429485+00:00", "status": "completed" }, "tags": [] @@ -901,7 +929,7 @@ "source": [ "## Exercice 1 — Casser la saturation : hnsw_ef sous la limite\n", "\n", - "La courbe signature montre un `recall@10` saturé à 1.000 pour tous les `hnsw_ef` testés (8 à 256) : sur ce jeu clusterisé, l'index HNSW retrouve toujours le top-10 exact. **Question** : cette saturation 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` ?\n" + "Le benchmark place le bord de saturation entre `ef=8` (recall@10 = 0.989) et `ef=16` (1.000) : l'index est reel, la perte d'approximation vient d'apparaitre. **Question** : comment evolue le rappel quand `ef` descend **sous** la valeur de `K` (ef < 10) ? Mesurer le `recall@10` moyen pour `ef` ∈ {1, 2, 4, 8} sur les 200 requetes, puis identifier le plus petit `ef` qui garde un rappel ≥ 0.95. La semantique du parametre (largeur de la liste de candidats exploree par le graphe HNSW) laisse-t-elle prevoir un plancher en dessous de `K` ?" ] }, { @@ -910,16 +938,16 @@ "id": "aff0bc7d", "metadata": { "execution": { - "iopub.execute_input": "2026-09-12T08:46:16.968009Z", - "iopub.status.busy": "2026-09-12T08:46:16.967806Z", - "iopub.status.idle": "2026-09-12T08:46:16.970830Z", - "shell.execute_reply": "2026-09-12T08:46:16.970450Z" + "iopub.execute_input": "2026-09-15T00:45:41.440004Z", + "iopub.status.busy": "2026-09-15T00:45:41.439500Z", + "iopub.status.idle": "2026-09-15T00:45:41.445186Z", + "shell.execute_reply": "2026-09-15T00:45:41.444312Z" }, "papermill": { - "duration": 0.006627, - "end_time": "2026-09-12T08:46:16.971432", + "duration": 0.01041, + "end_time": "2026-09-15T00:45:41.445760+00:00", "exception": false, - "start_time": "2026-09-12T08:46:16.964805", + "start_time": "2026-09-15T00:45:41.435350+00:00", "status": "completed" }, "tags": [] @@ -954,15 +982,19 @@ "id": "e96461f3", "metadata": { "papermill": { - "duration": 0.002693, - "end_time": "2026-09-12T08:46:16.976786", + "duration": 0.002992, + "end_time": "2026-09-15T00:45:41.451706+00:00", "exception": false, - "start_time": "2026-09-12T08:46:16.974093", + "start_time": "2026-09-15T00:45:41.448714+00:00", "status": "completed" }, "tags": [] }, - "source": "## Exercice 2 — Robustesse du rappel au rang k\n\nLe 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 ?" + "source": [ + "## Exercice 2 — Robustesse du rappel au rang k\n", + "\n", + "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 ?" + ] }, { "cell_type": "code", @@ -970,16 +1002,16 @@ "id": "47c169b4", "metadata": { "execution": { - "iopub.execute_input": "2026-09-12T08:46:16.982246Z", - "iopub.status.busy": "2026-09-12T08:46:16.982099Z", - "iopub.status.idle": "2026-09-12T08:46:16.985159Z", - "shell.execute_reply": "2026-09-12T08:46:16.984831Z" + "iopub.execute_input": "2026-09-15T00:45:41.460046Z", + "iopub.status.busy": "2026-09-15T00:45:41.459872Z", + "iopub.status.idle": "2026-09-15T00:45:41.464414Z", + "shell.execute_reply": "2026-09-15T00:45:41.463607Z" }, "papermill": { - "duration": 0.006493, - "end_time": "2026-09-12T08:46:16.985775", + "duration": 0.010346, + "end_time": "2026-09-15T00:45:41.465399+00:00", "exception": false, - "start_time": "2026-09-12T08:46:16.979282", + "start_time": "2026-09-15T00:45:41.455053+00:00", "status": "completed" }, "tags": [] @@ -1017,16 +1049,16 @@ "id": "56f46174", "metadata": { "execution": { - "iopub.execute_input": "2026-09-12T08:46:16.991914Z", - "iopub.status.busy": "2026-09-12T08:46:16.991736Z", - "iopub.status.idle": "2026-09-12T08:46:23.115352Z", - "shell.execute_reply": "2026-09-12T08:46:23.114783Z" + "iopub.execute_input": "2026-09-15T00:45:41.475533Z", + "iopub.status.busy": "2026-09-15T00:45:41.475123Z", + "iopub.status.idle": "2026-09-15T00:45:47.437432Z", + "shell.execute_reply": "2026-09-15T00:45:47.436565Z" }, "papermill": { - "duration": 6.127866, - "end_time": "2026-09-12T08:46:23.116298", + "duration": 5.9695, + "end_time": "2026-09-15T00:45:47.438137+00:00", "exception": false, - "start_time": "2026-09-12T08:46:16.988432", + "start_time": "2026-09-15T00:45:41.468637+00:00", "status": "completed" }, "tags": [] @@ -1036,11 +1068,11 @@ "name": "stdout", "output_type": "stream", "text": [ - "Conteneur detecte : qdrant_rag05b\n", + "Conteneur detecte : qdrant_proto_05b\n", "AVANT restart : points_count=10000\n", "AVANT restart : query[0] retourne ids = [4136, 3065, 1519, 4529, 1720]\n", "\n", - "Restart du conteneur qdrant_rag05b...\n" + "Restart du conteneur qdrant_proto_05b...\n" ] }, { @@ -1119,25 +1151,25 @@ "id": "3e9495fd", "metadata": { "papermill": { - "duration": 0.002916, - "end_time": "2026-09-12T08:46:23.123418", + "duration": 0.003166, + "end_time": "2026-09-15T00:45:47.444647+00:00", "exception": false, - "start_time": "2026-09-12T08:46:23.120502", + "start_time": "2026-09-15T00:45:47.441481+00:00", "status": "completed" }, "tags": [] }, "source": [ - "### Interprétation : la collection survit au redémarrage\n", + "### Interpretation : la collection survit au redemarrage\n", "\n", - "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.\n", + "La sortie raconte l'avant/apres du `docker restart` : `points_count=10000` de part et d'autre, et la requete 0 renvoie exactement les memes ids — `[4136, 3065, 1519, 4529, 1720]` — avant et apres. Le verdict `PERSISTANCE OK : count identique = True, resultats identiques = True` conclut : l'etat de l'index vit dans le **stockage persistant du conteneur** (`/qdrant/storage`), pas dans la memoire du processus.\n", "\n", - "Deux lectures complémentaires :\n", + "Deux lectures complementaires :\n", "\n", - "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.\n", - "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.\n", + "1. **Le conteneur detecte s'appelle `qdrant_proto_05b`** -- un conteneur de developpement deja en ecoute sur le port 6333, distinct du nom prevu par le provisioning (`qdrant_rag05b`). C'est le cas nominal documente en tete de notebook : le port etait deja servi, et le provisioning reutilise le serveur existant plutot que d'en lancer un second. La collection `rag05b_demo` vit dans ce serveur partage -- et sa persistance est celle du conteneur qui l'heberge reellement.\n", + "2. **Les ids croisent le ground truth** : parmi les cinq premiers ids affiches figurent `1519`, `1720`, `3065` et `4136` — quatre des cinq voisins exacts du top-5 calcule a la section ground truth (reference explicite en amont). La recherche de cette cellule retrouve donc bien des voisins exacts, pas seulement des points vaguement proches.\n", "\n", - "**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." + "**Portee** : c'est la propriete qui distingue un serveur d'un index en memoire de processus — redemarrer, upgrader ou reordonner les conteneurs ne detruit pas l'index." ] }, { @@ -1146,16 +1178,16 @@ "id": "3f32334c", "metadata": { "execution": { - "iopub.execute_input": "2026-09-12T08:46:23.130012Z", - "iopub.status.busy": "2026-09-12T08:46:23.129646Z", - "iopub.status.idle": "2026-09-12T08:46:23.194286Z", - "shell.execute_reply": "2026-09-12T08:46:23.193229Z" + "iopub.execute_input": "2026-09-15T00:45:47.452318Z", + "iopub.status.busy": "2026-09-15T00:45:47.451963Z", + "iopub.status.idle": "2026-09-15T00:45:47.475596Z", + "shell.execute_reply": "2026-09-15T00:45:47.474546Z" }, "papermill": { - "duration": 0.069403, - "end_time": "2026-09-12T08:46:23.195753", + "duration": 0.028511, + "end_time": "2026-09-15T00:45:47.476226+00:00", "exception": false, - "start_time": "2026-09-12T08:46:23.126350", + "start_time": "2026-09-15T00:45:47.447715+00:00", "status": "completed" }, "tags": [] @@ -1229,10 +1261,10 @@ "id": "5a8b18f3", "metadata": { "papermill": { - "duration": 0.003653, - "end_time": "2026-09-12T08:46:23.204009", + "duration": 0.004678, + "end_time": "2026-09-15T00:45:47.485449+00:00", "exception": false, - "start_time": "2026-09-12T08:46:23.200356", + "start_time": "2026-09-15T00:45:47.480771+00:00", "status": "completed" }, "tags": [] @@ -1256,10 +1288,10 @@ "id": "a9d44084", "metadata": { "papermill": { - "duration": 0.002536, - "end_time": "2026-09-12T08:46:23.209198", + "duration": 0.003965, + "end_time": "2026-09-15T00:45:47.494038+00:00", "exception": false, - "start_time": "2026-09-12T08:46:23.206662", + "start_time": "2026-09-15T00:45:47.490073+00:00", "status": "completed" }, "tags": [] @@ -1276,16 +1308,16 @@ "id": "c3b8529d", "metadata": { "execution": { - "iopub.execute_input": "2026-09-12T08:46:23.215156Z", - "iopub.status.busy": "2026-09-12T08:46:23.214974Z", - "iopub.status.idle": "2026-09-12T08:46:23.218344Z", - "shell.execute_reply": "2026-09-12T08:46:23.217866Z" + "iopub.execute_input": "2026-09-15T00:45:47.503983Z", + "iopub.status.busy": "2026-09-15T00:45:47.503781Z", + "iopub.status.idle": "2026-09-15T00:45:47.509044Z", + "shell.execute_reply": "2026-09-15T00:45:47.507884Z" }, "papermill": { - "duration": 0.007529, - "end_time": "2026-09-12T08:46:23.219305", + "duration": 0.011689, + "end_time": "2026-09-15T00:45:47.509965+00:00", "exception": false, - "start_time": "2026-09-12T08:46:23.211776", + "start_time": "2026-09-15T00:45:47.498276+00:00", "status": "completed" }, "tags": [] @@ -1324,28 +1356,30 @@ "id": "69ca19c3", "metadata": { "papermill": { - "duration": 0.002647, - "end_time": "2026-09-12T08:46:23.224707", + "duration": 0.004043, + "end_time": "2026-09-15T00:45:47.518096+00:00", "exception": false, - "start_time": "2026-09-12T08:46:23.222060", + "start_time": "2026-09-15T00:45:47.514053+00:00", "status": "completed" }, "tags": [] }, "source": [ - "## Conclusion : la methode serveur valide le compromis exact/ANN\n", + "## Conclusion : la methode serveur mesure un compromis reel — a condition de prouver l'index\n", "\n", "**Ce que ce notebook demontre** :\n", "\n", - "1. **Conteneur Qdrant provisionne** : idempotent (peut etre lance par le notebook si Docker dispo), degradeable proprement (skip si Docker absent), ou utilise un conteneur deja tournant (cas nominal partage cross-lane).\n", - "2. **Mesure du compromis exact/ANN** : rappel@10 et latence mediane mesures sur 200 requetes pour `exact=True` et pour les six valeurs de `EF_VALUES`.\n", - "3. **Courbe signature** : trade-off explicite entre qualite (recall@10) et cout (latence). Genou visible = valeur recommandee de `ef`.\n", + "1. **Conteneur Qdrant provisionne** : idempotent (peut etre lance par le notebook si Docker dispo), degradable proprement (skip si Docker absent), ou reutilisation d'un conteneur deja en ecoute (cas nominal partage).\n", + "2. **L'index est prouve avant d'etre mesure** : `indexed_vectors_count=10000/10000` apres ingestion. Sans ce temoin, un benchmark `hnsw_ef` peut photographier des scans exacts en croyant mesurer l'ANN — c'est precisement l'artefact que cette revision corrige : `indexing_threshold: 0` ne construit jamais le graphe, et un `full_scan_threshold` superieur a la taille des donnees ne l'emprunte jamais (#16226).\n", + "3. **Compromis exact/ANN mesure** : `ef=8` perd 1,1 % de rappel (`0.989`), saturation au-dessus ; a cette echelle la latence est dominee par le cout fixe HTTP et le rechauffement des caches — le genou qualite vit sous `K` (exercice 1).\n", "4. **Persistance** : `docker restart` preserve count + resultats de recherche (vs mode local `path=...` qui n'a pas cette propriete cross-deploiement).\n", - "5. **Filtrage payload** : `source='incident' AND severite>=3` restreint l'espace de recherche, demonstration que la recherche vectorielle avec filtre est utilisable en production.\n", + "5. **Filtrage payload** : `source='incident' AND severite>=3` restreint l'espace de recherche cote serveur.\n", + "\n", + "**La lecon de configuration, en une phrase** : les seuils HNSW de Qdrant s'expriment en **Ko de donnees vectorielles** — `indexing_threshold: 0` ne veut pas dire « indexer tout de suite » mais « ne jamais indexer », et un `full_scan_threshold` au-dessus de la taille de la collection rend le graphe inutilise ; les deux seuils doivent passer sous la taille reelle (5 000 Ko ici) pour que l'index existe et serve.\n", "\n", - "**Passage a 100k+ vecteurs** : modifier `N_VECTORS = 10_000` en `N_VECTORS = 100_000` ; le reste du notebook est lineaire (plus de memoire conteneur + temps d'insertion). Les conclusions methodologiques sont identiques ; le genou du compromis peut se deplacer legerement.\n", + "**Passage a 100k+ vecteurs** : modifier `N_VECTORS = 10_000` en `N_VECTORS = 100_000` ; a mesure que la collection grossit, sa taille en Ko depasse naturellement les seuils, et l'index se construit et s'emprunte sans intervention. Les conclusions methodologiques sont identiques ; le genou du compromis peut se deplacer.\n", "\n", - "**Co-habitation avec #12552 (po-2024)** : #12552 livre le notebook 05 mode local avec HNSW from-scratch pedagogique ; ce 05b livre le mode serveur avec conteneur Docker reel. Les deux notebooks sont complementaires : 05 demontre les mecanismes internes (HNSW code from-scratch), 05b valide les memes compromis sur l'implementation de production (Qdrant conteneur).\n", + "**Co-habitation avec #12552 (po-2024)** : #12552 livre le notebook 05 mode local avec HNSW from-scratch pedagogique ; ce 05b valide les memes compromis sur l'implementation de production (Qdrant conteneur) — a condition de verifier, chaque fois, que l'index mesure est l'index construit.\n", "\n", "**Verdict SOTA** : RECOVERABLE-MACHINE (Qdrant conteneur Docker requis, non disponible sur machine CPU-only sans Docker). Le notebook degrade proprement si Docker absent (pas d'execution, mais structure pedagogique preservee)." ] @@ -1355,10 +1389,10 @@ "id": "f03ac1d4", "metadata": { "papermill": { - "duration": 0.002694, - "end_time": "2026-09-12T08:46:23.230106", + "duration": 0.003607, + "end_time": "2026-09-15T00:45:47.525737+00:00", "exception": false, - "start_time": "2026-09-12T08:46:23.227412", + "start_time": "2026-09-15T00:45:47.522130+00:00", "status": "completed" }, "tags": [] @@ -1377,16 +1411,16 @@ "id": "1e6ded9f", "metadata": { "execution": { - "iopub.execute_input": "2026-09-12T08:46:23.238158Z", - "iopub.status.busy": "2026-09-12T08:46:23.237977Z", - "iopub.status.idle": "2026-09-12T08:46:23.242028Z", - "shell.execute_reply": "2026-09-12T08:46:23.241433Z" + "iopub.execute_input": "2026-09-15T00:45:47.535295Z", + "iopub.status.busy": "2026-09-15T00:45:47.534955Z", + "iopub.status.idle": "2026-09-15T00:45:47.542281Z", + "shell.execute_reply": "2026-09-15T00:45:47.541150Z" }, "papermill": { - "duration": 0.008396, - "end_time": "2026-09-12T08:46:23.242738", + "duration": 0.01346, + "end_time": "2026-09-15T00:45:47.543161+00:00", "exception": false, - "start_time": "2026-09-12T08:46:23.234342", + "start_time": "2026-09-15T00:45:47.529701+00:00", "status": "completed" }, "tags": [] @@ -1449,21 +1483,21 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.13.3" + "version": "3.13.14" }, "papermill": { "default_parameters": {}, - "duration": 42.812273, - "end_time": "2026-09-12T08:46:23.502450", + "duration": 31.748951, + "end_time": "2026-09-15T00:45:47.902587+00:00", "environment_variables": {}, "exception": null, - "input_path": "D:\\Dev\\CoursIA-15670\\MyIA.AI.Notebooks\\GenAI\\RAG-et-Memoire-Semantique\\05b-Stockage-Vectoriel-Serveur.ipynb", - "output_path": "D:\\Dev\\CoursIA-15670\\MyIA.AI.Notebooks\\GenAI\\RAG-et-Memoire-Semantique\\05b-Stockage-Vectoriel-Serveur_output.ipynb", + "input_path": "05b-Stockage-Vectoriel-Serveur.ipynb", + "output_path": "05b-Stockage-Vectoriel-Serveur.ipynb", "parameters": {}, - "start_time": "2026-09-12T08:45:40.690177", + "start_time": "2026-09-15T00:45:16.153636+00:00", "version": "2.7.0" } }, "nbformat": 4, "nbformat_minor": 5 -} \ No newline at end of file +} diff --git a/MyIA.AI.Notebooks/GenAI/RAG-et-Memoire-Semantique/courbe_signature_rag05b.png b/MyIA.AI.Notebooks/GenAI/RAG-et-Memoire-Semantique/courbe_signature_rag05b.png index 0904ed446266b7e71b5f1012df8b694d3fceaa24..4f10754a063e6323098eca005853f1426c7315c9 100644 GIT binary patch literal 30197 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 literal 27605 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

Js-7&5rAh_d>VC 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 From cd3e63565242ff78cb39c6dc43bb3d7ac6f0cf21 Mon Sep 17 00:00:00 2001 From: jsboige Date: Tue, 15 Sep 2026 03:14:00 +0200 Subject: [PATCH 2/2] fix(rag,#16226): re-accentuer la prose francaise des cellules markdown du 05b La passe 2 avait reecrit 7 cellules markdown sans accents francais (Interpretation, demonstre, compromis fantome...) alors que la convention du depot exige une prose accentuee -- le gate enrich-quality le signalait (3 lignes survivantes de-accentuees, 18 caracteres accentues perdus). Markdown-only (C.2 preservee : aucune cellule code touchee, outputs de la passe papermill inchanges). enrich_quality_ci.py --base origin/main : exit 0. Co-Authored-By: Claude Sonnet 5 --- .../05b-Stockage-Vectoriel-Serveur.ipynb | 90 +++++++++---------- 1 file changed, 45 insertions(+), 45 deletions(-) diff --git a/MyIA.AI.Notebooks/GenAI/RAG-et-Memoire-Semantique/05b-Stockage-Vectoriel-Serveur.ipynb b/MyIA.AI.Notebooks/GenAI/RAG-et-Memoire-Semantique/05b-Stockage-Vectoriel-Serveur.ipynb index 557ce40e38..0fdfb29ee5 100644 --- a/MyIA.AI.Notebooks/GenAI/RAG-et-Memoire-Semantique/05b-Stockage-Vectoriel-Serveur.ipynb +++ b/MyIA.AI.Notebooks/GenAI/RAG-et-Memoire-Semantique/05b-Stockage-Vectoriel-Serveur.ipynb @@ -14,17 +14,17 @@ "tags": [] }, "source": [ - "# RAG 05b — Mode serveur Qdrant : compromis exact/ANN ef sur 10k vecteurs (demo methode)\n", + "# RAG 05b — Mode serveur Qdrant : compromis exact/ANN ef sur 10k vecteurs (démo méthode)\n", "\n", - "> **Complement du notebook 05 (mode local, po-2024 #12552)** : ce notebook demontre la **meme mesure** mais sur un **vrai index HNSW serveur** (conteneur Qdrant Docker), pas sur un index local embarque.\n", + "> **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é.\n", "\n", - "**Grain #13021** : RAG-05 complement serveur. **Acceptance** : conteneur Qdrant provisionne par le notebook (idempotent, degradable), rappel@10 et latence mesures exact vs `hnsw_ef ∈ {8,16,32,64,128,256}` sur >= 10k vecteurs (echelle demo, passage a 100k trivial), courbe signature committed avec outputs, persistance par `docker restart`, filtrage payload dans la recherche.\n", + "**Grain #13021** : RAG-05 complément serveur. **Acceptance** : conteneur Qdrant provisionné par le notebook (idempotent, dégradable), rappel@10 et latence mesurés exact vs `hnsw_ef ∈ {8,16,32,64,128,256}` sur >= 10k vecteurs (échelle démo, passage à 100k trivial), courbe signature committée avec outputs, persistance par `docker restart`, filtrage payload dans la recherche.\n", "\n", - "**Revision 2026-09-15 (suivi #16226)** : la configuration initiale (`full_scan_threshold: 10000` Ko, `indexing_threshold: 0`) laissait l'index HNSW **non construit** et toutes les requetes en scan exact -- le parametre `hnsw_ef` etait ignore et la courbe signature mesurait un compromis fantome (rappel 1.000 partout). Les deux seuils passent desormais sous la taille des donnees (5 000 Ko), la construction de l'index est **prouvee** en sortie (`indexed_vectors_count=10000/10000`), et une garde fail-visible arrete le benchmark si l'index manque.\n", + "**Révision 2026-09-15 (suivi #16226)** : la configuration initiale (`full_scan_threshold: 10000` Ko, `indexing_threshold: 0`) laissait l'index HNSW **non construit** et toutes les requêtes en scan exact — le paramètre `hnsw_ef` était ignoré et la courbe signature mesurait un compromis fantôme (rappel 1.000 partout). Les deux seuils passent désormais sous la taille des données (5 000 Ko), la construction de l'index est **prouvée** en sortie (`indexed_vectors_count=10000/10000`), et une garde fail-visible arrête le benchmark si l'index manque.\n", "\n", - "**Note d'echelle** : on demontre la methode sur 10k vecteurs (cycle 30min). Le passage a 100k+ demande simplement plus de temps CPU et memoire conteneur ; le notebook est parametre pour que la transition soit lineaire (1 ligne a changer).\n", + "**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 (1 ligne à changer).\n", "\n", - "**References** : Qdrant doc API Python ; HNSW paper Malkov & Yashunin 2018 ; #12448 (EPIC RAG-05) ; #12552 (livraison locale po-2024) ; Issue #13021 (ce complement) ; Issue #16226 (revision vra index)." + "**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) ; Issue #16226 (révision vrai index)." ] }, { @@ -527,20 +527,20 @@ "tags": [] }, "source": [ - "### Interpretation : creation, ingestion -- et preuve que l'index existe\n", + "### Interprétation : création, ingestion — et preuve que l'index existe\n", "\n", - "Le `Delete (peut-etre 404 si nouveau) : status=ok` d'ouverture sert la **reproductibilite** : supprimer la collection si elle existe garantit que chaque execution repart d'un index vierge. Le `Create collection : status=ok` enregistre la configuration demandee par la cellule :\n", + "Le `Delete (peut-être 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 demandée par la cellule :\n", "\n", - "| Parametre | Valeur | Signification |\n", + "| Paramètre | Valeur | Signification |\n", "|-----------|--------|---------------|\n", - "| `m` | 16 | nombre de liens par noeud du graphe HNSW |\n", + "| `m` | 16 | nombre de liens par nœud du graphe HNSW |\n", "| `ef_construct` | 100 | largeur de recherche lors de la construction des liens |\n", - "| `full_scan_threshold` | 100 Ko | en dessous de ce seuil, Qdrant repond par scan exact sans emprunter l'index |\n", - "| `indexing_threshold` | 10 Ko | l'optimiseur ne construit l'index qu'au-dela de ce seuil (0 = jamais construit) |\n", + "| `full_scan_threshold` | 100 Ko | en dessous de ce seuil, Qdrant répond par scan exact sans emprunter l'index |\n", + "| `indexing_threshold` | 10 Ko | l'optimiseur ne construit l'index qu'au-delà de ce seuil (0 = jamais construit) |\n", "\n", - "**Pourquoi ces deux seuils, et pourquoi si bas** : les unites sont des **Ko de donnees vectorielles**, pas un nombre de points. La collection pese 10 000 x 128 x 4 octets = **5 000 Ko**. La configuration anterieure posait `10000/0` : l'optimiseur ne construisait jamais le graphe (`indexing_threshold: 0`), et meme un graphe construit n'aurait pas ete emprunte (5 000 Ko < `full_scan_threshold`) -- toutes les requetes, `hnsw_ef` compris, etaient des scans exacts. Les seuils actuels (100 Ko, 10 Ko) passent sous la taille reelle : le graphe est construit **et** sert.\n", + "**Pourquoi ces deux seuils, et pourquoi si bas** : les unités sont des **Ko de données vectorielles**, pas un nombre de points. La collection pèse 10 000 x 128 x 4 octets = **5 000 Ko**. La configuration antérieure posait `10000/0` : l'optimiseur ne construisait jamais le graphe (`indexing_threshold: 0`), et même un graphe construit n'aurait pas été emprunté (5 000 Ko < `full_scan_threshold`) — toutes les requêtes, `hnsw_ef` compris, étaient des scans exacts. Les seuils actuels (100 Ko, 10 Ko) passent sous la taille réelle : le graphe est construit **et** sert.\n", "\n", - "**La preuve, en une ligne de sortie** : `Attente optimiseur : 2.0s | status=green, points_count=10000, indexed_vectors_count=10000/10000`. Le champ `indexed_vectors_count` est le temoin que les 10 000 vecteurs vivent **dans le graphe HNSW**. Sans lui, rien ne distingue un serveur indexe d'un serveur en scan integral : les deux repondent, tous deux affichent `status=green`. C'est la garantie minimale avant de mesurer quoi que ce soit sur `hnsw_ef` -- la cellule benchmark la refuse d'ailleurs sans ce temoin." + "**La preuve, en une ligne de sortie** : `Attente optimiseur : 2.0s | status=green, points_count=10000, indexed_vectors_count=10000/10000`. Le champ `indexed_vectors_count` est le témoin que les 10 000 vecteurs vivent **dans le graphe HNSW**. Sans lui, rien ne distingue un serveur indexé d'un serveur en scan intégral : les deux répondent, tous deux affichent `status=green`. C'est la garantie minimale avant de mesurer quoi que ce soit sur `hnsw_ef` — la cellule benchmark la refuse d'ailleurs sans ce témoin." ] }, { @@ -791,19 +791,19 @@ "tags": [] }, "source": [ - "### Interpretation : le rappel depend enfin de ef -- ef=8 perd 1,1 %\n", + "### Interprétation : le rappel dépend enfin de ef — ef=8 perd 1,1 %\n", "\n", - "Le resultat central : **`ef=8` termine a `recall@10 = 0.989`**, tous les autres modes a `1.000`. Une seule mesure separe maintenant le mode exact du mode approche -- c'est peu, et c'est tout : sur la version fantome de ce benchmark (index jamais construit, `hnsw_ef` ignore), les sept lignes affichaient `1.000` sans exception et rien ne le signalait. Le parametre pilote desormais reellement la recherche : c'est la difference entre mesurer un compromis et photographier un artefact.\n", + "Le résultat central : **`ef=8` termine à `recall@10 = 0.989`**, tous les autres modes à `1.000`. Une seule mesure sépare maintenant le mode exact du mode approché — c'est peu, et c'est tout : sur la version fantôme de ce benchmark (index jamais construit, `hnsw_ef` ignoré), les sept lignes affichaient `1.000` sans exception et rien ne le signalait. Le paramètre pilote désormais réellement la recherche : c'est la différence entre mesurer un compromis et photographier un artefact.\n", "\n", "Trois lectures de la sortie :\n", "\n", - "1. **La perte d'approximation se concentre sous `K`** : a `ef=8 < K=10`, la largeur d'exploration descend sous la taille du top demande ; des `ef=16`, la saturation revient. Le bord de la courbe signature vit donc entre 8 et 16 -- exactement la zone que l'exercice 1 ira sonder (`ef` dans {1, 2, 4, 8}) ;\n", - "2. **La latence ne se lit pas comme un effet de `ef`** : les medianes s'etalent de 4,3 a 15,1 ms **sans ordre monotone** (14,3 -> 12,9 -> 13,3 -> 14,7 -> 5,4 -> 4,3 ms). La passe execute `exact` d'abord puis `ef` croissant : a cette echelle le cout fixe HTTP/noyau domine, et les dernieres mesures beneficient des caches rechauffes par les premieres -- la baisse finale dit plus le rechauffement que la largeur de recherche ;\n", - "3. **Chaque p95 (25,7 a 27,4 ms) ecrase sa mediane** : la queue de latence (serialisation JSON, ordonnanceur du conteneur) domine l'ecart entre modes.\n", + "1. **La perte d'approximation se concentre sous `K`** : à `ef=8 < K=10`, la largeur d'exploration descend sous la taille du top demandé ; dès `ef=16`, la saturation revient. Le bord de la courbe signature vit donc entre 8 et 16 — exactement la zone que l'exercice 1 ira sonder (`ef` dans {1, 2, 4, 8}) ;\n", + "2. **La latence ne se lit pas comme un effet de `ef`** : les médianes s'étalent de 4,3 à 15,1 ms **sans ordre monotone** (14,3 → 12,9 → 13,3 → 14,7 → 5,4 → 4,3 ms). La passe exécute `exact` d'abord puis `ef` croissant : à cette échelle le coût fixe HTTP/noyau domine, et les dernières mesures bénéficient des caches réchauffés par les premières — la baisse finale dit plus le réchauffement que la largeur de recherche ;\n", + "3. **Chaque p95 (25,7 à 27,4 ms) écrase sa médiane** : la queue de latence (sérialisation JSON, ordonnanceur du conteneur) domine l'écart entre modes.\n", "\n", - "**Lecture honnete** : a l'echelle demo, le compromis qualite est quasi plat au-dessus de `ef=8` et le signal de cout est noye dans le bruit. La conclusion operationnelle est justement celle-ci : un petit `ef` (des 16 ici) est gratuit en rappel, et la mesure qui le prouve exige un index dont l'existence est attestee au prealable.\n", + "**Lecture honnête** : à l'échelle démo, le compromis qualité est quasi plat au-dessus de `ef=8` et le signal de coût est noyé dans le bruit. La conclusion opérationnelle est justement celle-ci : un petit `ef` (dès 16 ici) est gratuit en rappel, et la mesure qui le prouve exige un index dont l'existence est attestée au préalable.\n", "\n", - "> **Note technique** : un benchmark de rappel ne dit rien tout seul -- ni un plateau a 1.000 (qui peut etre un artefact de scan exact), ni un trou a 0.989 (qui peut etre un plancher de largeur). C'est le croisement avec le temoin d'index et la structure en `ef` qui decide." + "> **Note technique** : un benchmark de rappel ne dit rien tout seul — ni un plateau à 1.000 (qui peut être un artefact de scan exact), ni un trou à 0.989 (qui peut être un plancher de largeur). C'est le croisement avec le témoin d'index et la structure en `ef` qui décide." ] }, { @@ -900,17 +900,17 @@ "tags": [] }, "source": [ - "### Lecture du resultat : la courbe signature\n", + "### Lecture du résultat : la courbe signature\n", "\n", - "Le tableau resume sept lignes : six a `recall@10 = 1.000` et **une a `0.989` -- `hnsw_ef=8`**, le point bas du graphique. Le plateau de saturation reste dominant, mais il a desormais un bord : c'est ce bord que la courbe signature existe pour rendre visible.\n", + "Le tableau résume sept lignes : six à `recall@10 = 1.000` et **une à `0.989` — `hnsw_ef=8`**, le point bas du graphique. Le plateau de saturation reste dominant, mais il a désormais un bord : c'est ce bord que la courbe signature existe pour rendre visible.\n", "\n", "**Comment lire cette courbe** :\n", "\n", - "- l'axe horizontal porte le cout (latence mediane), l'axe vertical la qualite (recall@10) ;\n", - "- un bon compromis est un point **le plus haut possible, le plus a gauche possible** ;\n", - "- le point `ef=8` legerement sous le plateau materialise la perte d'approximation -- mesuree (1,1 %), pas spectaculaire, et sa position horizontale (14,3 ms) n'achete rien par rapport a `ef=16` (12,9 ms, rappel plein).\n", + "- l'axe horizontal porte le coût (latence médiane), l'axe vertical la qualité (recall@10) ;\n", + "- un bon compromis est un point **le plus haut possible, le plus à gauche possible** ;\n", + "- le point `ef=8` légèrement sous le plateau matérialise la perte d'approximation — mesurée (1,1 %), pas spectaculaire, et sa position horizontale (14,3 ms) n'achète rien par rapport à `ef=16` (12,9 ms, rappel plein).\n", "\n", - "**Pourquoi le genou est ici si discret** : des donnees aussi bien clusterisees, a l'echelle demo, mettent HNSW d'emblee pres de la saturation ; le vrai coude -- la zone ou croitre `ef` achete du rappel -- vit sous `K`, et c'est l'objet de l'exercice 1. La figure reste l'outil juste : a plus grande echelle ou sur des donnees plus dispersees, la meme cellule produit le coude caracteristique, et le PNG sauvegarde (`courbe_signature_rag05b.png`) est l'artefact a comparer entre echelles." + "**Pourquoi le genou est ici si discret** : des données aussi bien clusterisées, à l'échelle démo, mettent HNSW d'emblée près de la saturation ; le vrai coude — la zone où croître `ef` achète du rappel — vit sous `K`, et c'est l'objet de l'exercice 1. La figure reste l'outil juste : à plus grande échelle ou sur des données plus dispersées, la même cellule produit le coude caractéristique, et le PNG sauvegardé (`courbe_signature_rag05b.png`) est l'artefact à comparer entre échelles." ] }, { @@ -929,7 +929,7 @@ "source": [ "## Exercice 1 — Casser la saturation : hnsw_ef sous la limite\n", "\n", - "Le benchmark place le bord de saturation entre `ef=8` (recall@10 = 0.989) et `ef=16` (1.000) : l'index est reel, la perte d'approximation vient d'apparaitre. **Question** : comment evolue le rappel quand `ef` descend **sous** la valeur de `K` (ef < 10) ? Mesurer le `recall@10` moyen pour `ef` ∈ {1, 2, 4, 8} sur les 200 requetes, puis identifier le plus petit `ef` qui garde un rappel ≥ 0.95. La semantique du parametre (largeur de la liste de candidats exploree par le graphe HNSW) laisse-t-elle prevoir un plancher en dessous de `K` ?" + "Le benchmark place le bord de saturation entre `ef=8` (recall@10 = 0.989) et `ef=16` (1.000) : l'index est réel, la perte d'approximation vient d'apparaître. **Question** : comment évolue le rappel 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 (largeur de la liste de candidats explorée par le graphe HNSW) laisse-t-elle prévoir un plancher en dessous de `K` ?" ] }, { @@ -1160,16 +1160,16 @@ "tags": [] }, "source": [ - "### Interpretation : la collection survit au redemarrage\n", + "### Interprétation : la collection survit au redémarrage\n", "\n", - "La sortie raconte l'avant/apres du `docker restart` : `points_count=10000` de part et d'autre, et la requete 0 renvoie exactement les memes ids — `[4136, 3065, 1519, 4529, 1720]` — avant et apres. Le verdict `PERSISTANCE OK : count identique = True, resultats identiques = True` conclut : l'etat de l'index vit dans le **stockage persistant du conteneur** (`/qdrant/storage`), pas dans la memoire du processus.\n", + "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.\n", "\n", - "Deux lectures complementaires :\n", + "Deux lectures complémentaires :\n", "\n", - "1. **Le conteneur detecte s'appelle `qdrant_proto_05b`** -- un conteneur de developpement deja en ecoute sur le port 6333, distinct du nom prevu par le provisioning (`qdrant_rag05b`). C'est le cas nominal documente en tete de notebook : le port etait deja servi, et le provisioning reutilise le serveur existant plutot que d'en lancer un second. La collection `rag05b_demo` vit dans ce serveur partage -- et sa persistance est celle du conteneur qui l'heberge reellement.\n", - "2. **Les ids croisent le ground truth** : parmi les cinq premiers ids affiches figurent `1519`, `1720`, `3065` et `4136` — quatre des cinq voisins exacts du top-5 calcule a la section ground truth (reference explicite en amont). La recherche de cette cellule retrouve donc bien des voisins exacts, pas seulement des points vaguement proches.\n", + "1. **Le conteneur détecté s'appelle `qdrant_proto_05b`** — un conteneur de développement déjà en écoute sur le port 6333, distinct du nom prévu par le provisioning (`qdrant_rag05b`). C'est le cas nominal documenté en tête de notebook : le port était déjà servi, et le provisioning réutilise le serveur existant 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.\n", + "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.\n", "\n", - "**Portee** : c'est la propriete qui distingue un serveur d'un index en memoire de processus — redemarrer, upgrader ou reordonner les conteneurs ne detruit pas l'index." + "**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." ] }, { @@ -1365,23 +1365,23 @@ "tags": [] }, "source": [ - "## Conclusion : la methode serveur mesure un compromis reel — a condition de prouver l'index\n", + "## Conclusion : la méthode serveur mesure un compromis réel — à condition de prouver l'index\n", "\n", - "**Ce que ce notebook demontre** :\n", + "**Ce que ce notebook démontre** :\n", "\n", - "1. **Conteneur Qdrant provisionne** : idempotent (peut etre lance par le notebook si Docker dispo), degradable proprement (skip si Docker absent), ou reutilisation d'un conteneur deja en ecoute (cas nominal partage).\n", - "2. **L'index est prouve avant d'etre mesure** : `indexed_vectors_count=10000/10000` apres ingestion. Sans ce temoin, un benchmark `hnsw_ef` peut photographier des scans exacts en croyant mesurer l'ANN — c'est precisement l'artefact que cette revision corrige : `indexing_threshold: 0` ne construit jamais le graphe, et un `full_scan_threshold` superieur a la taille des donnees ne l'emprunte jamais (#16226).\n", - "3. **Compromis exact/ANN mesure** : `ef=8` perd 1,1 % de rappel (`0.989`), saturation au-dessus ; a cette echelle la latence est dominee par le cout fixe HTTP et le rechauffement des caches — le genou qualite vit sous `K` (exercice 1).\n", - "4. **Persistance** : `docker restart` preserve count + resultats de recherche (vs mode local `path=...` qui n'a pas cette propriete cross-deploiement).\n", - "5. **Filtrage payload** : `source='incident' AND severite>=3` restreint l'espace de recherche cote serveur.\n", + "1. **Conteneur Qdrant provisionné** : idempotent (peut être lancé par le notebook si Docker dispo), dégradable proprement (skip si Docker absent), ou réutilisation d'un conteneur déjà en écoute (cas nominal partagé).\n", + "2. **L'index est prouvé avant d'être mesuré** : `indexed_vectors_count=10000/10000` après ingestion. Sans ce témoin, un benchmark `hnsw_ef` peut photographier des scans exacts en croyant mesurer l'ANN — c'est précisément l'artefact que cette révision corrige : `indexing_threshold: 0` ne construit jamais le graphe, et un `full_scan_threshold` supérieur à la taille des données ne l'emprunte jamais (#16226).\n", + "3. **Compromis exact/ANN mesuré** : `ef=8` perd 1,1 % de rappel (`0.989`), saturation au-dessus ; à cette échelle la latence est dominée par le coût fixe HTTP et le réchauffement des caches — le genou qualité vit sous `K` (exercice 1).\n", + "4. **Persistance** : `docker restart` préserve count + résultats de recherche (vs mode local `path=...` qui n'a pas cette propriété cross-déploiement).\n", + "5. **Filtrage payload** : `source='incident' AND severite>=3` restreint l'espace de recherche côté serveur.\n", "\n", - "**La lecon de configuration, en une phrase** : les seuils HNSW de Qdrant s'expriment en **Ko de donnees vectorielles** — `indexing_threshold: 0` ne veut pas dire « indexer tout de suite » mais « ne jamais indexer », et un `full_scan_threshold` au-dessus de la taille de la collection rend le graphe inutilise ; les deux seuils doivent passer sous la taille reelle (5 000 Ko ici) pour que l'index existe et serve.\n", + "**La leçon de configuration, en une phrase** : les seuils HNSW de Qdrant s'expriment en **Ko de données vectorielles** — `indexing_threshold: 0` ne veut pas dire « indexer tout de suite » mais « ne jamais indexer », et un `full_scan_threshold` au-dessus de la taille de la collection rend le graphe inutilisé ; les deux seuils doivent passer sous la taille réelle (5 000 Ko ici) pour que l'index existe et serve.\n", "\n", - "**Passage a 100k+ vecteurs** : modifier `N_VECTORS = 10_000` en `N_VECTORS = 100_000` ; a mesure que la collection grossit, sa taille en Ko depasse naturellement les seuils, et l'index se construit et s'emprunte sans intervention. Les conclusions methodologiques sont identiques ; le genou du compromis peut se deplacer.\n", + "**Passage à 100k+ vecteurs** : modifier `N_VECTORS = 10_000` en `N_VECTORS = 100_000` ; à mesure que la collection grossit, sa taille en Ko dépasse naturellement les seuils, et l'index se construit et s'emprunte sans intervention. Les conclusions méthodologiques sont identiques ; le genou du compromis peut se déplacer.\n", "\n", - "**Co-habitation avec #12552 (po-2024)** : #12552 livre le notebook 05 mode local avec HNSW from-scratch pedagogique ; ce 05b valide les memes compromis sur l'implementation de production (Qdrant conteneur) — a condition de verifier, chaque fois, que l'index mesure est l'index construit.\n", + "**Co-habitation avec #12552 (po-2024)** : #12552 livre le notebook 05 mode local avec HNSW from-scratch pédagogique ; ce 05b valide les mêmes compromis sur l'implémentation de production (Qdrant conteneur) — à condition de vérifier, chaque fois, que l'index mesuré est l'index construit.\n", "\n", - "**Verdict SOTA** : RECOVERABLE-MACHINE (Qdrant conteneur Docker requis, non disponible sur machine CPU-only sans Docker). Le notebook degrade proprement si Docker absent (pas d'execution, mais structure pedagogique preservee)." + "**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)." ] }, {