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..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 @@ -5,24 +5,26 @@ "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": [] }, "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", - "**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", + "**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", - "**References** : Qdrant doc API Python ; HNSW paper Malkov & Yashunin 2018 ; #12448 (EPIC RAG-05) ; #12552 (livraison locale po-2024) ; Issue #13021 (ce complement)." + "**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", + "**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)." ] }, { @@ -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", + "### 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 **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-ê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", "| Paramètre | Valeur | Signification |\n", "|-----------|--------|---------------|\n", - "| `m` | 16 | nombre de liens par nœud du graphe |\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` | 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 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", - "**Points clés** :\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", - "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 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." ] }, { @@ -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", + "### Interprétation : le rappel dépend 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 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", - "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`** : à `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 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 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 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 à 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." ] }, { @@ -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,10 +891,10 @@ "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": [] @@ -874,15 +902,15 @@ "source": [ "### Lecture du résultat : 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 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 coût (latence médiane), l'axe vertical la qualité (rappel@10) ;\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", - "- ici tous les points sont à la même hauteur : le plateau dit « le rappel est saturé, prends le point le plus à gauche ».\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 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 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." ] }, { @@ -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 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` ?" ] }, { @@ -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,10 +1151,10 @@ "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": [] @@ -1134,7 +1166,7 @@ "\n", "Deux lectures complémentaires :\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", + "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", "**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." @@ -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,30 +1356,32 @@ "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 méthode serveur mesure un compromis réel — à condition de prouver l'index\n", + "\n", + "**Ce que ce notebook démontre** :\n", "\n", - "**Ce que ce notebook demontre** :\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", - "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", - "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", + "**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` ; 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 à 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 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 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)." ] }, { @@ -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 0904ed4462..4f10754a06 100644 Binary files a/MyIA.AI.Notebooks/GenAI/RAG-et-Memoire-Semantique/courbe_signature_rag05b.png and b/MyIA.AI.Notebooks/GenAI/RAG-et-Memoire-Semantique/courbe_signature_rag05b.png differ