Le notebook 07 a branché le service Kernel Memory sur Qdrant et conduit le cycle ingestion → recherche → citations. Mais sa recherche, comme celle de tout le pan dense de la série (01, 02, 05), repose sur un seul mécanisme : la similarité entre vecteurs denses. Or une question d’ingénierie contient souvent des tokens exacts — un code d’erreur ECONNREFUSED, un port 6333, une clé de configuration KernelMemory__Services__Qdrant__Endpoint — que l’embedding lisse en une direction sémantique approximative.
La réponse canonique de l’état de l’art est la recherche hybride : faire courir la question sur deux jambes — BM25 (lexicale, épars) et dense (sémantique) — puis fusionner les deux classements (RRF, Reciprocal Rank Fusion). Ce notebook mesure ce que cette seconde jambe apporte réellement, sur un corpus français de documentation technique ingéré par Kernel Memory :
Ingestion KM du corpus (pipeline extract → partition → gen_embeddings → save_records, identique au 07) ;
Constat empirique de deux verrous : le service KM n’expose pas de recherche hybride, et Qdrant refuse d’ajouter une jambe épars à une collection existante ;
Projection de l’index KM vers une collection hybride (mêmes points, mêmes vecteurs denses, + vecteurs épars BM25 calculés par fastembed / Qdrant/bm25) ;
Mesure contrôlée : recall@3 / recall@5 / MRR@10 pour dense seul, BM25 seul et hybride RRF, sur un gold qui sépare deux classes de questions — paraphrase sémantique (le terrain du dense) et terme exact rare (le terrain de BM25) ;
Lecture honnête : où l’hybride gagne, où il ne gagne pas, ce que ça coûte.
Dépendances : pip install qdrant-client fastembed requests python-dotenv (+ Docker pour les deux conteneurs). Le .env de la série (MyIA.AI.Notebooks/GenAI/.env, gitignored) fournit l’endpoint OpenAI-compatible des embeddings — même configuration que le 07.
Plan
Infrastructure : Qdrant + service Kernel Memory
Corpus : deux terrains de chasse et leurs leurres
Ingestion KM, lecture Qdrant, et les deux verrous
Projection vers une collection hybride
Protocole de mesure (attendus écrits avant)
Résultats et lecture
Coûts, exercices, nettoyage
import jsonimport osimport subprocessimport tempfileimport timefrom pathlib import Pathimport requestsfrom dotenv import load_dotenv# --- Resolution du .env de la serie GenAI (gitignored) -------------ENV_CANDIDATES = [ Path(os.environ.get("GENAI_ENV_FILE", "")) if os.environ.get("GENAI_ENV_FILE") elseNone, Path.cwd() /"MyIA.AI.Notebooks"/"GenAI"/".env", Path.cwd().parent /"MyIA.AI.Notebooks"/"GenAI"/".env", Path.cwd().parents[1] /"MyIA.AI.Notebooks"/"GenAI"/".env"iflen(Path.cwd().parents) >1elseNone,]ENV_PATH =next((p for p in ENV_CANDIDATES if p and p.is_file()), None)if ENV_PATH isNone:# Degradation propre (regle C.1) : sans .env, la mesure sera sautee via INFRA_OKprint("AVERTISSEMENT : .env de la serie GenAI introuvable ""(attendu : MyIA.AI.Notebooks/GenAI/.env, cf. .env.example)")else: load_dotenv(ENV_PATH)# --- Configuration --------------------------------------------------INDEX ="km13421-hybrid"# index KM -> collection Qdrant homonymePROJ_COLLECTION ="km13421-hybrid-proj"# collection hybride projeteeKM_PORT =9001KM_URL =f"http://localhost:{KM_PORT}"QDRANT_LOCAL ="http://localhost:6333"QDRANT_CONTAINER ="qdrant_rag08"KM_CONTAINER ="km_service_rag08"KM_FILES_VOLUME ="km_rag08_files"QDRANT_VOLUME ="qdrant_rag08_data"EMBED_MODEL ="text-embedding-3-small"# 1536 dimensions (meme espace que le 07)SPARSE_NAME ="bm25"# nom de la jambe epars dans la collection projeteeOR_BASE = os.getenv("OPENROUTER_BASE_URL", "").rstrip("/")OR_KEY = os.getenv("OPENROUTER_API_KEY", "")corpus_dir = Path(tempfile.mkdtemp(prefix="km08_corpus_"))def mask(url: str) ->str:"""Host seul d'une URL -- jamais d'eventuels credentials."""try:return url.split("//", 1)[1].split("/", 1)[0]exceptException:return"(non configure)"env_desc =f"{ENV_PATH.name} ({ENV_PATH.parent.parent.name}/{ENV_PATH.parent.name})"if ENV_PATH else"INTROUVABLE"print(f".env charge : {env_desc}")print(f"Endpoint embeddings : {mask(OR_BASE) or'(non configure)'}")print(f"Cle embeddings : {'presente ('+str(len(OR_KEY)) +' caracteres)'if OR_KEY else'ABSENTE'}")print(f"Modele embeddings : {EMBED_MODEL}")print(f"Corpus provisoire : {corpus_dir.name}/ (repertoire temporaire)")
1a. Ce que la resolution d’environnement etablit — et ce qu’elle tait
La sortie ci-dessus est le premier contraste du notebook : elle nomme ce qui est configure et tait ce qui est secret. Les trois informations publiees le sont deliberement.
L’endpoint d’embeddings (openrouter.ai) est un fait d’architecture : il determine l’espace vectoriel dans lequel tout le notebook raisonne. Le modele (text-embedding-3-small) fixe la dimension 1536 que la collection hybride devra declarer a la creation, sous peine d’un rejet a l’insertion. Enfin la cle n’apparait que par sa presence et sa longueur (73 caracteres) : ce triplet suffit a diagnostiquer un .env mal rempli sans jamais imprimer la valeur. Un notebook qui afficherait key[:8] pour « prouver » qu’il detient une cle fuirait huit caracteres pour zero information supplementaire.
La cascade de resolution (GENAI_ENV_FILE, puis trois positions relatives au cwd) explique le seul point fragile de la cellule : le notebook s’execute depuis la racine du depot ou depuis son propre dossier. Le corpus provisoire part dans un repertoire temporaire — les 22 documents sont fabriques par le notebook, jamais versionnes. Le livrable est le protocole de mesure, pas le corpus. Et la degradation reste propre (regle C.1) : sans .env, rien ne leve, le notebook bascule en mode degrade et l’annonce, la mesure etant simplement impossible.
1. Infrastructure : Qdrant + service Kernel Memory
Même patron que le 07 : un Qdrant local jetable (comme le 05b) et le conteneur kernelmemory/service configuré par variables d’environnement (mapping appsettings par doubles underscores), backend d’embeddings = endpoint OpenAI-compatible du .env. Sans Docker ou sans clé, les cellules d’infrastructure passent en mode dégradé (INFRA_OK = False) et la mesure est sautée proprement — le notebook reste exécutable de bout en bout (règle C.1).
def docker_available() ->bool:try: r = subprocess.run(["docker", "info"], capture_output=True, timeout=10)return r.returncode ==0exceptException:returnFalsedef qdrant_up() ->bool:try: r = requests.get(f"{QDRANT_LOCAL}/healthz", timeout=5)return"healthz check passed"in r.textexceptException:returnFalsedef km_up() ->bool:try: r = requests.get(f"{KM_URL}/", timeout=5)return r.status_code ==200and"Ingestion service"in r.textexceptException:returnFalseDOCKER_OK = docker_available()print(f"Docker dispo : {DOCKER_OK}")qdrant_ready = qdrant_up()ifnot qdrant_ready and DOCKER_OK: subprocess.run(["docker", "rm", "-f", QDRANT_CONTAINER], capture_output=True) r = subprocess.run( ["docker", "run", "-d", "--rm", "--name", QDRANT_CONTAINER,"-p", "6333:6333", "-p", "6334:6334","-v", f"{QDRANT_VOLUME}:/qdrant/storage","qdrant/qdrant:latest"], capture_output=True, timeout=180)print(f"Conteneur Qdrant lance (rc={r.returncode})")for _ inrange(30): time.sleep(2)if qdrant_up():breakif DOCKER_OK and qdrant_up() andnot km_up(): subprocess.run(["docker", "rm", "-f", KM_CONTAINER], capture_output=True) cmd = ["docker", "run", "-d", "--rm", "--user", "root","--name", KM_CONTAINER,"-p", f"{KM_PORT}:9001","-v", f"{KM_FILES_VOLUME}:/km-files",# Backend embeddings (endpoint OpenAI-compatible) -- cles jamais affichees"-e", f"KernelMemory__Services__OpenAI__Endpoint={OR_BASE}","-e", f"KernelMemory__Services__OpenAI__APIKey={OR_KEY}","-e", f"KernelMemory__Services__OpenAI__EmbeddingModel={EMBED_MODEL}","-e", "KernelMemory__Services__OpenAI__EmbeddingModelMaxTokenTotal=8191","-e", "KernelMemory__Services__OpenAI__TextModel=google/gemini-3.7-flash","-e", "KernelMemory__Services__OpenAI__TextModelMaxTokenTotal=60000","-e", "KernelMemory__Services__OpenAI__TextGenerationType=Chat","-e", "KernelMemory__TextGeneratorType=OpenAI","-e", "KernelMemory__Retrieval__EmbeddingGeneratorType=OpenAI","-e", "KernelMemory__DataIngestion__EmbeddingGeneratorTypes__0=OpenAI","-e", "KernelMemory__Retrieval__MemoryDbType=Qdrant","-e", "KernelMemory__DataIngestion__MemoryDbTypes__0=Qdrant","-e", "KernelMemory__Services__Qdrant__Endpoint=http://host.docker.internal:6333","-e", "KernelMemory__Services__SimpleFileStorage__StorageType=Disk","-e", "KernelMemory__Services__SimpleFileStorage__Directory=/km-files","-e", "KernelMemory__DataIngestion__DefaultSteps=extract,partition,gen_embeddings,save_records","kernelmemory/service:latest", ] r = subprocess.run(cmd, capture_output=True, timeout=300)print(f"Conteneur service KM lance (rc={r.returncode})")for _ inrange(45): time.sleep(2)if km_up():breakqdrant_ready = qdrant_up()km_ready = km_up()INFRA_OK =bool(qdrant_ready and km_ready and OR_KEY)print(f"Qdrant pret : {qdrant_ready} | service KM pret : {km_ready}")print(f"INFRA_OK (mesure possible) : {INFRA_OK}")
Docker dispo : True
Conteneur Qdrant lance (rc=0)
Conteneur service KM lance (rc=0)
Qdrant pret : True | service KM pret : True
INFRA_OK (mesure possible) : True
1b. Deux sondes, un interrupteur : INFRA_OK
Ces trois fonctions ne servent pas a « faire joli » : elles forment l’interrupteur de mesure du notebook. docker_available() interroge le demon (docker info), qdrant_up() ne se contente pas d’un code HTTP mais exige la chaine healthz check passed dans la reponse, et km_up() exige a la fois un 200 et le marqueur Ingestion service dans le corps. Trois verifications de fond, pas trois tests de connectivite.
C’est cette exigence qui donne son sens a la ligne finale : INFRA_OK (mesure possible) : True. Toutes les cellules de mesure du notebook sont gardees par if INFRA_OK: et se replient sur un message de mode degrade sinon. Consequence a garder en tete pour lire la suite : aucune metrique de ce notebook n’est fabriquee. Si l’infrastructure n’etait pas montee a la main — conteneurs Qdrant et kernelmemory/service lances ici avec rc=0 — le notebook ne produirait pas de chiffres plausibles, il produirait un aveu de non-mesure.
2. Corpus : 22 documents français, deux terrains de chasse et leurs leurres
Le corpus est rédigé sur les thèmes réels de la série (incidents Qdrant, HNSW, chunking, grounding, persistance, coûts), mais polarisé volontairement pour rendre la mesure discriminante :
six documents (d1–d6) contiennent des termes exacts rares qu’aucune paraphrase ne produit naturellement : ECONNREFUSED, port 6333, ef_construct, INT4/AWQ, la clé de configuration KernelMemory__Services__Qdrant__Endpoint, prefetch/fusion rrf/dbsf, exit code 137 ;
huit documents (d7–d14) portent des concepts (graphe approximatif, fenêtre glissante, citations, abstention, persistance, vecteurs épars, taux d’échec, latence) formulés en français naturel — le terrain où l’embedding dense excelle ; parmi eux, d13 et d14 ne sont cibles par aucune question du gold : ils restent dans l’index comme distracteurs de leur propre classe, que le moteur doit classer sous la cible ;
huit distracteurs (x1–x8) partagent le vocabulaire des questions sans être la réponse : le glossaire qui recense ECONNREFUSED sans le diagnostiquer, les codes de sortie Unix qui citent exit code sans le 137, la fiche des verbes d’API sans prefetch/rrf… Sans eux, retrouver le bon document parmi 14 serait trivial pour les trois moteurs et la mesure ne discriminerait rien.
Un corpus uniforme ne montrerait rien : BM25 y serait partout inférieur ou égal au dense. C’est l’hétérogénéité des questions réelles que le gold de la section 5 reproduit.
DOCS = {# --- terrain 1 : termes exacts rares (jambe BM25 attendue dominante) ---"d1": "Le client de recherche echoue avec l'erreur ECONNREFUSED sur le port 6333 : le ""socket TCP est refuse parce que le daemon Qdrant n'ecoute pas encore au moment de ""la premiere requete apres redemarrage. Le bon reflexe n'est pas de relancer le ""conteneur mais d'attendre le healthz : une sonde GET /healthz qui repond avant la ""premiere recherche elimine toute la classe des erreurs ECONNREFUSED au demarrage.","d2": "Reglages du graphe hierarchique : le parametre m fixe le nombre d'aretes par noeud ""(16 par defaut), ef_construct gouverne la largeur de recherche a l'insertion (100 ""par defaut) et full_scan_threshold bascule en dessous de 10000 points vers un ""parcours exact. Monter ef_construct ameliore le rappel au prix du temps ""d'indexation ; c'est le premier parametre a toucher quand une recherche approchee ""manque des voisins.","d3": "La quantification INT4 reduit les poids a 4 bits : la memoire tombe d'un facteur 8 ""mais la perplexite remonte de 49 pourcents sur le jeu de test AWQ. Le compromis ""utile se situe autour de INT8 ; INT4 reste reserve aux modeles trop grands pour la ""carte. Toujours mesurer la perplexite avant et apres : une petite perte est ""acceptable, une grosse veut dire que la version compresse ne raconte plus la meme ""langue.","d4": "La configuration du service se mappe par variables d'environnement avec doubles ""underscores : KernelMemory__Services__Qdrant__Endpoint pointe vers l'instance ""Qdrant, KernelMemory__Retrieval__MemoryDbType=Qdrant active le connecteur, ""KernelMemory__Services__OpenAI__EmbeddingModel choisit le modele d'embeddings. ""Oublier un seul segment et le service demarre silencieusement sur sa configuration ""par defaut : verifier la page d'accueil du service apres chaque changement.","d5": "L'API /points/query accepte des sous-requetes prefetch et une fusion finale : deux ""prefetch (dense puis epars) suivis d'une fusion rrf ou dbsf constituent la requete ""hybride canonique. RRF classe par rangs reciproques sans dependre des echelles de ""score ; DBSF normalise les scores avant somme. Le prefetch dense se nomme par son ""nom de vecteur, l'epars par le sien : les deux jambes vivent dans la meme ""collection.","d6": "Le conteneur s'arrete avec exit code 137 : c'est le signal SIGKILL du noyau, ""presque toujours un manque de memoire. La memoire residente du service depassait ""la limite du conteneur au moment d'indexer un gros corpus ; le tueur memoire a ""tronque le processus sans trace dans les logs applicatifs. La limite doit couvrir ""le pic d'indexation, pas le regime stationnaire, sinon chaque grosse ingestion se ""termine par le meme arret brutal.",# --- terrain 2 : concepts en francais naturel (jambe dense attendue dominante) ---"d7": "Le graphe hierarchique navigable des petits mondes permet de trouver les voisins ""les plus proches sans comparer la question a tous les vecteurs : une couche ""superieure saute de loin en loin vers la bonne region, les couches inferieures ""affinent. On echange un peu d'exactitude contre un temps de recherche ""logarithmique, et c'est cet echange qui rend la recherche vectorielle utilisable ""a grande echelle.","d8": "Decouper un long document en morceaux qui se chevauchent : la fenetre glissante ""avance d'une longueur fixe en conservant un recouvrement, pour qu'une phrase ""coupee en deux garde son contexte des deux cotes. Trop petit, le fragment perd le ""sens ; trop grand, il dilue la question. Le recouvrement est l'assurance contre la ""frontiere mal placee.","d9": "Tracer d'ou vient chaque information restituee : chaque passage retrouve porte son ""document, sa partition et sa position, si bien que la reponse cite sa source au ""niveau du fragment plutot que du fichier. C'est la difference entre une reponse ""qui affirme et une reponse qui montre, et la confiance nait de la possibilite de ""verifier.","d10": "Empecher le modele de repondre en dehors des sources : si le systeme ne retrouve ""rien de pertinent, la bonne reponse est de ne pas repondre. L'abstention est un ""echec sain ; un systeme qui invente quand il ne sait pas est pire qu'un systeme ""qui avoue. La couverture documentaire passe avant l'optimisation du classement.","d11": "Conserver l'index apres un arret : tant que le stockage vit dans un volume dedie, ""redemarrer le service ne detruit rien, les collections et les vecteurs sont relus ""au demarrage. Le danger est le conteneur anonyme sans volume : supprime, il ""emporte l'index avec lui. Un volume nomme par service est la regle minimale ""d'hygiene.","d12": "Deux familles de vecteurs pour la recherche : les denses compactent le sens en ""quelques centaines de dimensions continues et excellent sur les reformulations ; ""les epars gardent un axe par mot du vocabulaire et captent l'identite lexicale ""exacte. Une question d'ingenierie melange les deux regimes, d'ou l'interet de ""faire courir les deux representations puis de fusionner.","d13": "Surveiller le taux d'echec d'indexation : chaque document accepte n'est pas encore ""un document rechercheable. Entre l'acceptation et l'indexation il y a un pipeline ""asynchrone dont chaque etape peut echouer sans bruit. Comparer le nombre de ""documents declares au nombre de points reellement presents reste le controle le ""moins couteux et le plus revelateur.","d14": "Chaque jambe de recherche a son cout : la jambe dense exige un embeddage de la ""question a chaque requete, la jambe lexicale ne coute qu'une tokenisation ; la ""fusion ajoute une union de classements a trier. Sur un corpus petit le cout est ""invisible, mais il se paie en latence et en infrastructure d'embeddings des que ""le debit monte. Mesurer avant d'adopter.",# --- distracteurs lexicaux : vocabulaire partage, reponse absente ---"x1": "Glossaire des erreurs reseau courantes : ECONNREFUSED, ETIMEDOUT, EHOSTUNREACH, ""ECONNRESET. Le dictionnaire recense chaque code avec sa traduction et un exemple ""de message systeme, sans diagnostic ni procede de resolution.","x2": "Reglages par defaut du client : timeout de 30 secondes, 3 tentatives, temporisation ""exponentielle entre essais, journalisation verbeuse activee. La fiche recense les ""parametres generaux du client HTTP et leurs valeurs d'usine, sans lien avec un ""moteur de recherche particulier.","x3": "Modes de compression des modeles : distillation, elagage, factorisation de ""matrices. Chaque technique reduit l'empreinte memoire et le cout de calcul d'un ""modele, avec des pertes de qualite variables selon la tache et la taille de ""depart.","x4": "Heritage des variables d'environnement systeme : PATH, HOME, LANG, TERM. Un ""processus enfant recopie l'environnement de son parent ; les variables ""systeme suivent cette chaine, contrairement aux variables de session de ""l'interpreteur.","x5": "Semantique des verbes d'une API : GET lit sans effet de bord, POST cree, PUT ""remplace de facon idempotente, DELETE supprime. La fiche decrit chaque verbe, ""les codes de retour attendus et l'usage des en-tetes de condition.","x6": "Codes de sortie des programmes Unix : 0 pour le succes, 1 pour une erreur ""generique, 2 pour un usage incorrect de la ligne de commande, 126 pour un ""fichier non executable, 127 pour une commande introuvable.","x7": "Pagination des resultats d'une recherche : decalage numerique simple, curseur ""opaque, ou cle de tri stable. Chaque strategie repond a la question de ""parcourir de grands resultats sans sauter ni doubler de lignes.","x8": "Telescopage de requetes concurrentes vers la meme collection : verrouillage ""optimiste, versions de documents, arbitrage du dernier ecrit. Les systemes de ""stockage distribues exposent ces mecanismes pour reconcilier des ecritures ""simultanees.",}uploaded = []for doc_id, text in DOCS.items(): p = corpus_dir /f"{doc_id}.txt" p.write_text(text, encoding="utf-8") uploaded.append((doc_id, p))print(f"{len(uploaded)} documents francais prets dans {corpus_dir.name}/")print("Documents a terme exact :", ", ".join(f"d{i}"for i inrange(1, 7)))print("Documents conceptuels :", ", ".join(f"d{i}"for i inrange(7, 15)))print("Distracteurs lexicaux :", ", ".join(f"x{i}"for i inrange(1, 9)))
Le tirage affiche n’est pas un echantillon neutre : il est polarise pour rendre la mesure discriminante, et les trois groupes ont chacun un role.
Les six documents a terme exact (d1–d6) portent des jetons rares — ECONNREFUSED, ef_construct, INT4 AWQ, KernelMemory__Services__Qdrant__Endpoint, rrf, exit code 137 — que seule la jambe BM25 sait appariee litteralement. Les huit documents conceptuels (d7–d14) developpent les memes notions sans le mot rare : c’est le terrain ou la jambe dense devrait gagner. Les huit distracteurs lexicaux (x1–x8) partagent le vocabulaire des questions sans en etre la reponse — leur unique fonction est de peupler le top-k et de faire baisser une metrique si une jambe se laisse berner par la forme plutot que par le sens.
Sans le troisieme groupe, un corpus de 14 documents ne pourrait jamais departager deux moteurs : tout serait en rang 1 et toutes les moyennes vaudraient 1,0. Les distracteurs sont donc l’ingredient qui empeche la mesure d’etre un tapis rouge.
3. Ingestion KM, lecture Qdrant, et les deux verrous
3a. Cycle d’ingestion (patron 07)
Suppression propre d’un éventuel index résiduel — en attendant la suppression réelle dans Qdrant, pour ne pas créer de course avec la recréation — puis upload des 22 documents et polling du statut jusqu’à completed pour chacun. Le pipeline (extract → partition → gen_embeddings → save_records) tourne dans le service : le notebook ne voit que les codes HTTP et, à la fin, les points dans Qdrant.
def km_delete_index() ->bool:try: r = requests.delete(f"{KM_URL}/indexes", params={"index": INDEX}, timeout=30)return r.status_code in (200, 202)exceptException:returnFalsedef qdrant_collection_exists(name: str) ->bool:try: r = requests.get(f"{QDRANT_LOCAL}/collections/{name}", timeout=10)return r.status_code ==200exceptException:returnFalsedef km_status(doc_id: str) ->dict: r = requests.get(f"{KM_URL}/upload-status", params={"index": INDEX, "documentId": doc_id}, timeout=30) r.raise_for_status()return r.json()codes = []if INFRA_OK:if qdrant_collection_exists(INDEX):print(f"Index {INDEX} existant -> suppression demandee : {km_delete_index()}")for _ inrange(30): # attendre la suppression REELLE (anti-course) time.sleep(2)ifnot qdrant_collection_exists(INDEX):breakprint(f"Collection Qdrant supprimee : {not qdrant_collection_exists(INDEX)}")else:print("Depart propre : pas d'index preexistant")def km_upload(doc_id: str, path: Path) ->int: data = {"documentId": doc_id, "index": INDEX} files = {"files": (path.name, path.read_bytes(), None)} r = requests.post(f"{KM_URL}/upload", data=data, files=files, timeout=120)return r.status_code codes = [(d, km_upload(d, p)) for d, p in uploaded] n_ok =sum(1for _, c in codes if c ==202)print(f"Uploads acceptes (HTTP 202) : {n_ok}/{len(codes)}") refused = [(d, c) for d, c in codes if c !=202]if refused:print(f"Refuses : {refused}")# Polling jusqu'a completion de chaque document (patron 07) pending = {doc_id for doc_id, c in codes if c ==202} deadline = time.time() +300while pending and time.time() < deadline:for doc_id inlist(pending):try:if km_status(doc_id).get("completed", False): pending.discard(doc_id)exceptException:passif pending: time.sleep(4) n_done =len(codes) -len(pending) -len(refused)print(f"Documents indexes : {n_done}/{len(uploaded)}")if pending:print(f"Toujours en attente : {sorted(pending)}")else:print("Infrastructure indisponible : ingestion sautee (mode degrade).")
Deux lignes disent deux choses differentes, et les confondre est l’erreur classique :
Uploads acceptes (HTTP 202) : 22/22 — le service Kernel Memory a accuse reception. Un 202 Accepted signifie « pris en compte », pas « traite ». A cet instant, rien ne garantit qu’un seul document soit cherchable.
Documents indexes : 22/22 — cette ligne est le resultat d’un polling de upload-status document par document, jusqu’a l’etat completed. C’est cette seconde ligne, et elle seule, qui autorise la suite du notebook.
La premiere instruction de la cellule — supprimer un index residuel et attendre sa disparition reelle dans Qdrant — repond au meme souci : une suppression demandee n’est pas une suppression effective, et recreer l’index trop tot creerait une course entre l’ecriture et la suppression. D’ou Depart propre : pas d'index preexistant, verifie et non suppose. Cette discipline est reprise telle quelle du notebook 07.
3b. Ce que KM a écrit dans Qdrant
Scroll direct dans la collection — le pont avec les notebooks 01 et 05 : la couche d’abstraction produit du Qdrant standard. Le payload de chaque point porte le texte du fragment (sous forme de chaîne JSON interne), les tags d’upload et les tags réservés de provenance (__document_id, __file_part…) — les métadonnées qui alimentent les citations. C’est ce texte que la jambe BM25 devra voir.
def parse_km_tags(tag_list: list) ->dict:"""Les tags KM dans Qdrant : liste plate de chaines 'cle:valeur'.""" parsed = {}for t in tag_list or []: k, _, v = t.partition(":") parsed[k] = vreturn parsedpoints_km = []if INFRA_OK: r = requests.post(f"{QDRANT_LOCAL}/collections/{INDEX}/points/scroll", json={"limit": 100, "with_payload": True, "with_vector": False}, timeout=30) points_km = r.json()["result"]["points"]print(f"Scroll de {len(points_km)} points dans '{INDEX}'\n")from collections import Counter par_doc = Counter()for p in points_km: parsed = parse_km_tags((p.get("payload") or {}).get("tags")) par_doc[parsed.get("__document_id", "?")] +=1print("Points par document :", dict(sorted(par_doc.items()))) ex = points_km[0].get("payload", {}) or {} inner = json.loads(ex.get("payload", "{}")) parsed = parse_km_tags(ex.get("tags"))print(f"\nExemple de point : document={parsed.get('__document_id')} "f"| fichier={inner.get('file')} | texte={len(inner.get('text', ''))} caracteres")print(f"Tags reserves (provenance KM) : "f"{sorted(k for k in parsed if k.startswith('__'))}")else:print("Mode degrade : pas de scroll Qdrant.")
3b-bis. Ce que le payload revele de la couche d’abstraction
Le scroll donne trois enseignements que la documentation de Kernel Memory n’annonce pas aussi directement.
D’abord Points par document a 1 partout : pour des documents de cette taille, KM n’a pas decoupe en fragments — chaque document correspond a un point. Le chunking reste donc invisible ici, ce qui simplifie la lecture des metriques : un point gagne = un document gagne.
Ensuite les tags reserves — __document_id, __file_id, __file_part, __file_type, __part_n, __sect_n — sont la provenance ecrite par le service. Ce sont eux que la projection de la section 4 doit conserver : detruire le lien vers __document_id rendrait toute recherche infiltrable par document impossible (c’est precisement le sujet de l’exercice 3).
Enfin le format de payload : le texte du fragment est stocke sous forme de chaine JSON interne, et parse_km_tags montre que les tags arrivent en liste plate de cle:valeur. La couche d’abstraction produit donc du Qdrant parfaitement standard — le pont avec les notebooks 01 et 05 est literalement visible dans le payload.
3c. Les deux verrous, constatés à la main
Verrou 1 — le service KM est dense-only. Son endpoint /search prend un query texte, l’embedde, et compare : une seule jambe. Pas d’option hybride dans l’API du service (constat de la page swagger au moment de l’écriture).
Verrou 2 — Qdrant ne laisse pas ajouter une jambe épars à une collection existante. La route dédiée à l’enregistrement d’un vecteur épars après création n’existe pas sur ce build (404 ci-dessous), et la mise à jour générale de la collection renvoie 400 Not existing vector name (constaté à la préparation du notebook). Autrement dit : on ne peut pas « enrichir » l’index KM en place ; il faut projeter ses points vers une collection créée avec les deux jambes dès le départ.
if INFRA_OK:try: r = requests.patch(f"{QDRANT_LOCAL}/collections/{INDEX}/sparse_vectors/{SPARSE_NAME}", json={"modifier": "idf"}, timeout=15)if r.status_code <400: verdict =f"HTTP {r.status_code} -- la jambe epars aurait ete ajoutee (inattendu)"else:try: detail = r.json().get("status", {}).get("error", "") or r.text[:120]exceptException: detail = r.text[:120] detail = detail or"(route inexistante sur ce build)" verdict =f"HTTP {r.status_code} : {detail}"exceptExceptionas exc: # noqa: BLE001 verdict =f"echec de requete : {exc}"print("Tentative d'ajout d'un vecteur epars a la collection KM existante :")print(f" -> {verdict}")else:print("Mode degrade : verrou non demontre en direct.")
Tentative d'ajout d'un vecteur epars a la collection KM existante :
-> HTTP 404 : (route inexistante sur ce build)
3c-bis. Le 404 est le resultat, pas l’echec
Cette cellule est ecrite pour echouer, et c’est voulu : HTTP 404 : (route inexistante sur ce build) est la preuve experimentale du verrou 2, pas un incident.
La distinction compte. Ici Qdrant ne refuse pas la modification parce qu’elle serait interdite par une politique : la routePATCH /collections/{name}/sparse_vectors/{sparse} est absente du build interroge. Autrement dit, le service ne dit pas « non », il dit « je ne connais pas cette operation ». Une jambe epars ne peut donc pas etre ajoutee apres coup a une collection existante : la seule voie est de declarer les deux jambes a la creation, ce que fera la section 4.
Retenez la forme du raisonnement, il est reutilisable : pour etablir une limite, une tentative reelle suivie de la citation exacte de la reponse vaut mieux qu’une affirmation de documentation. Le code distingue d’ailleurs explicitement les deux cas — un code < 400 aurait imprime « aurait ete ajoutee (inattendu) ». Le notebook declare donc aussi ce qui l’aurait contredit.
4. Projection : l’index KM devient une collection hybride
La projection est mécanique et fidèle : on scroll les points de la collection KM avec leurs vecteurs denses, on re-crée une collection dotée des deux jambes (dense 1536 + épars BM25 avec modificateur idf — le re-pondération par fréquence inverse de document se fait côté serveur à la requête), et on ré-inscrit chaque point : même identifiant, même payload simplifié (texte, document, fichier), même vecteur dense, + le vecteur épars BM25 du texte calculé par fastembed (Qdrant/bm25, l’encodeur BM25 de référence de l’écosystème Qdrant).
Rien n’est ré-embeddé côté dense : la jambe dense de la collection projetée est exactement celle que le pipeline KM a écrite — la mesure comparera donc bien deux stratégies de recherche, pas deux pipelines d’ingestion.
from fastembed import SparseTextEmbeddingfrom qdrant_client import QdrantClient, modelsqc = QdrantClient(url=QDRANT_LOCAL, timeout=60)bm25_model = SparseTextEmbedding(model_name="Qdrant/bm25")n_proj =0if INFRA_OK:# 4a. collection hybride neuve (les DEUX jambes declarees a la creation)if qc.collection_exists(PROJ_COLLECTION): qc.delete_collection(PROJ_COLLECTION) qc.create_collection( collection_name=PROJ_COLLECTION, vectors_config=models.VectorParams(size=1536, distance=models.Distance.COSINE), sparse_vectors_config={ SPARSE_NAME: models.SparseVectorParams(modifier=models.Modifier.IDF)}, )# 4b. scroll KM AVEC vecteurs, projection point a point offset =None rows = []whileTrue: res = qc.scroll(collection_name=INDEX, limit=32, offset=offset, with_payload=True, with_vectors=True) batch, offset = resfor pt in batch: inner = json.loads((pt.payload or {}).get("payload", "{}")) text = inner.get("text", "") tags = parse_km_tags((pt.payload or {}).get("tags")) doc = tags.get("__document_id", "?") emb =list(bm25_model.embed([text]))[0] rows.append(models.PointStruct(id=pt.id, vector={"": pt.vector.get("") ifisinstance(pt.vector, dict) else pt.vector, SPARSE_NAME: models.SparseVector( indices=emb.indices.tolist(), values=emb.values.tolist()), }, payload={"texte": text, "document": doc,"fichier": inner.get("file", "?")}, ))if offset isNone:break qc.upsert(collection_name=PROJ_COLLECTION, points=rows, wait=True) n_proj =len(rows) info = qc.get_collection(PROJ_COLLECTION)print(f"Collection hybride '{PROJ_COLLECTION}' : {n_proj} points projetes")print(f" dense : dim={info.config.params.vectors.size} "f"distance={info.config.params.vectors.distance.value}")print(f" epars : {list((info.config.params.sparse_vectors or {}).keys())} "f"(modifier idf)")assert n_proj ==len(points_km), "projection incomplete"print(" projection complete : autant de points que la collection KM source")else:print("Mode degrade : pas de projection.")
Collection hybride 'km13421-hybrid-proj' : 22 points projetes
dense : dim=1536 distance=Cosine
epars : ['bm25'] (modifier idf)
projection complete : autant de points que la collection KM source
4a. Projeter, c’est reconstruire — pas etendre
La sortie resume la manoeuvre : 22 points projetes, dense dim=1536 distance=Cosine, epars ['bm25'] avec modifier idf, et autant de points que la collection KM source.
Le point a comprendre est que ce n’est pas une extension de l’index KM. L’index KM ne peut pas recevoir de seconde jambe (verrou 2), donc le notebook relit ses 22 points avec leurs vecteurs denses et les reinsere dans une collection neuve ou les deux jambes sont declarees des la naissance. La projection est fidele dans les deux sens : meme nombre de points, memes vecteurs denses — seuls les payloads sont reconduits, ce qui preserve le lien vers __document_id et rend l’exercice 3 possible.
Le modifier "idf" de la jambe epars merite un mot : il fait re-pondere chaque terme par la frequence inverse de document au moment de la requete, cote serveur. C’est ce qui distingue un vrai BM25 d’une simple ponderation binaire presence/absence — et c’est pourquoi la meme question peut remonter d1 en BM25 pur alors que le dense, lui, se laisse attirer par les distracteurs (voir le controle de la section 5).
5. Protocole de mesure — attendus écrits AVANT de lancer
Gold : 12 questions, 6 par classe. Classe exact : la question contient le token rare du document cible. Classe paraphrase : la question reformule le concept sans aucun mot rare du document cible.
Métriques : recall@3, recall@5 (fraction des documents pertinents présents dans le top-k) et MRR@10 (rang réciproque du premier pertinent), agrégées par classe puis sur l’ensemble. Les trois modes voient le même corpus projeté, les mêmes questions, les mêmes budgets top_k.
Attendus qualitatifs (écrits avant la mesure) :
Classe
dense attendu
BM25 attendu
hybride RRF attendu
exact
médiocre (l’embedding lisse les tokens rares)
fort
proche de BM25, idéalement ≥ max(dense, BM25)
paraphrase
fort
médiocre (aucun mot partagé)
proche de dense — le risque mesurable est que la jambe BM25 dégrade le classement (interférence de rangs)
La question de recherche n’est donc pas « l’hybride est-il bon » mais : le gain sur la classe exact se paie-t-il une perte sur la classe paraphrase ? C’est le verdict que la section 6 mesure.
GOLD = [# classe exact : la question porte le token rare du document cible {"q": "erreur ECONNREFUSED sur le port 6333", "relevant": {"d1"}, "classe": "exact"}, {"q": "regler ef_construct et m du graphe", "relevant": {"d2"}, "classe": "exact"}, {"q": "quantification INT4 AWQ perplexite", "relevant": {"d3"}, "classe": "exact"}, {"q": "variable KernelMemory__Services__Qdrant__Endpoint", "relevant": {"d4"}, "classe": "exact"}, {"q": "prefetch avec fusion rrf ou dbsf", "relevant": {"d5"}, "classe": "exact"}, {"q": "le conteneur s arrete avec exit code 137", "relevant": {"d6"}, "classe": "exact"},# classe paraphrase : reformulation conceptuelle, aucun mot rare du cible {"q": "comment trouver les voisins sans tout comparer ?", "relevant": {"d7"}, "classe": "paraphrase"}, {"q": "comment decouper un long texte en fragments qui gardent leur contexte ?", "relevant": {"d8"}, "classe": "paraphrase"}, {"q": "comment savoir de quel document vient chaque bout de reponse ?", "relevant": {"d9"}, "classe": "paraphrase"}, {"q": "que faire quand aucune source ne correspond a la question ?", "relevant": {"d10"}, "classe": "paraphrase"}, {"q": "comment garder l index quand le service redemarre ?", "relevant": {"d11"}, "classe": "paraphrase"}, {"q": "pourquoi garder une representation par mot en plus du vecteur dense ?", "relevant": {"d12"}, "classe": "paraphrase"},]print(f"Gold : {len(GOLD)} questions "f"({sum(1for g in GOLD if g['classe']=='exact')} exact, "f"{sum(1for g in GOLD if g['classe']=='paraphrase')} paraphrase)")
Gold : 12 questions (6 exact, 6 paraphrase)
5-pre. Un gold ecrit AVANT de mesurer
Douze questions, six par classe, et un seul document pertinent par question. Ce dernier choix est deliberé : avec un unique document de reference, recall@k se lit directement comme « le bon document est-il dans le top-k ? » et la moyenne sur la classe reste interpretable sans ponderation.
La discipline importe plus que le contenu : ce gold est ecrit avant la premiere requete. Les attendus de la section 5 (le dense doit lisser les termes rares, BM25 doit dominer sur la classe exact) sont donc des previsions enregistrees, pas des rationalisations d’apres resultats. C’est ce qui rend la suite du notebook interessante : la section 6 pourra les contredire, et la contradiction sera un resultat au meme titre qu’une confirmation.
Un point d’honnetete sur la taille : 12 questions sur un corpus de 22 documents, c’est petit. Les moyennes qui suivent sont donc a lire comme un ordre de grandeur sur ce corpus precis, pas comme une borne de performance des trois moteurs.
def embed_query(text: str) ->list:"""Embedding dense de la question via l'endpoint OpenAI-compatible (meme espace que KM).""" r = requests.post(f"{OR_BASE}/embeddings", headers={"Authorization": f"Bearer {OR_KEY}"}, json={"model": EMBED_MODEL, "input": text}, timeout=60) r.raise_for_status()return r.json()["data"][0]["embedding"]def search_dense(question: str, k: int=10) ->list:"""Jambe dense seule : recherche par similarite cosinus (ce que fait le /search de KM).""" res = qc.query_points(collection_name=PROJ_COLLECTION, query=embed_query(question), limit=k, with_payload=True)return [p.payload["document"] for p in res.points]def search_bm25(question: str, k: int=10) ->list:"""Jambe BM25 seule : vecteurs epars, modificateur idf cote serveur.""" res = qc.query_points(collection_name=PROJ_COLLECTION, query=models.Document(text=question, model="Qdrant/bm25"), using=SPARSE_NAME, limit=k, with_payload=True)return [p.payload["document"] for p in res.points]def search_hybrid(question: str, k: int=10) ->list:"""Hybride : deux prefetch (dense + BM25) fusionnes en RRF -- l'API Query de Qdrant.""" res = qc.query_points(collection_name=PROJ_COLLECTION, prefetch=[ models.Prefetch(query=embed_query(question), limit=k), models.Prefetch(query=models.Document(text=question, model="Qdrant/bm25"), using=SPARSE_NAME, limit=k), ], query=models.FusionQuery(fusion=models.Fusion.RRF), limit=k, with_payload=True)return [p.payload["document"] for p in res.points]if INFRA_OK: demo = search_hybrid("erreur ECONNREFUSED sur le port 6333", k=3)print(f"Verification hybride (question exacte) : top-3 documents = {demo}")else:print("Mode degrade : moteurs definis mais non testables.")
5-bis. Le controle positif — et un signal qui ne trompe pas
Cette cellule est le controle positif du protocole : avant de lancer les douze questions, on verifie sur une question exacte que la chaine complete repond. Le resultat merite qu’on s’y arrete : top-3 documents = ['d1', 'x1', 'x2'].
Le document pertinent d1 est bien en rang 1 — la chaine fonctionne. Mais les deux places suivantes sont occupees par x1 et x2, deux distracteurs lexicaux : des documents qui partagent le vocabulaire de la question sans en etre la reponse. La jambe dense remonte donc du bruit de forme en position 2 et 3.
C’est exactement ce qu’une moyenne efface. Retenez ce que cette ligne annonce deja : si les distracteurs peuplent le top-3 sans deposseder le bon document de sa premiere place, alors recall@3 vaudra 1,0 mecaniquement et ne distinguera plus rien. La mesure de la section 6 ne sera donc pas un juge du gain de l’hybride : elle sera un juge de la difficulte du corpus. Le protocole avait ecrit ses attendus ; ce controle, lui, ecrit la limite de ce qui peut etre mesure ici.
6. Résultats et lecture
6a. La mesure
Chaque question passe par les trois modes ; on calcule recall@3, recall@5 et MRR@10 par question, puis la moyenne par classe et globale.
classe dense R@3 dense R@5 dense MRR@10 bm25 R@3 bm25 R@5 bm25 MRR@10 hybride RRF R@3 hybride RRF R@5 hybride RRF MRR@10
question
erreur ECONNREFUSED sur le port 6333 exact 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0
regler ef_construct et m du graphe exact 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0
quantification INT4 AWQ perplexite exact 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0
variable KernelMemory__Services__Qdrant__End exact 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0
prefetch avec fusion rrf ou dbsf exact 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0
le conteneur s arrete avec exit code 137 exact 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0
comment trouver les voisins sans tout compar paraphrase 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0
comment decouper un long texte en fragments paraphrase 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0
comment savoir de quel document vient chaque paraphrase 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0
que faire quand aucune source ne correspond paraphrase 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0
comment garder l index quand le service rede paraphrase 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0
pourquoi garder une representation par mot e paraphrase 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0
6a-bis. Lire le tableau par question avant d’en faire la moyenne
Le tableau ci-dessus est le materiel brut : douze lignes, une par question, et pour chacune trois moteurs croises avec trois metriques. La synthese de la cellule suivante n’en gardera que des moyennes — c’est donc ici, et seulement ici, que l’on peut voir si ces moyennes avaient la possibilite de varier.
Deux verifications a faire de l’oeil avant de descendre :
Chaque ligne porte-t-elle la meme valeur ? Si dense R@3, bm25 R@3 et hybride RRF R@3 affichent la meme note sur toutes les lignes, alors la moyenne par classe ne fera que recopier cette valeur, et le tableau de synthese sera un trompe-l’oeil : il aura l’air de comparer trois moteurs alors qu’il constate une seule chose douze fois.
Les deux classes se comportent-elles differemment ? Le protocole de la section 5 a predit dense faible sur la classe exact et BM25 faible sur la classe paraphrase. Si les deux classes rendent le meme resultat, ce n’est pas un moteur qui a gagne : c’est la question posee qui n’a pas discrimine.
Ces deux verifications ne demandent aucun calcul — elles demandent de regarder le tableau avant de le resumer. Une moyenne calculee sans ce coup d’oeil peut masquer exactement le phenomene qu’on cherchait a mesurer.
if INFRA_OK:import pandas as pd df = pd.DataFrame(per_q) metric_cols = [c for c in df.columns if c notin ("classe", "question")] summary = df.groupby("classe")[metric_cols].mean().round(3) order = ["exact", "paraphrase"] summary = summary.loc[order] summary.loc["(global)"] = df[metric_cols].mean().round(3)print("TABLEAU SYNTHESE — moyenne par classe")print(summary.to_string())else:print("Mode degrade.")
Le verdict est sans appel, mais pas celui qu’on attendait : les trois moteurs atteignent le plafond — recall@3 = recall@5 = MRR@10 = 1,0 sur les douze questions, y compris la classe exact où le dense était attendu médiocre.
Deux lectures, toutes deux instructives :
L’attendu « le dense lisse les tokens rares » est démenti sur ce corpus.text-embedding-3-small place le document cible au rang 1 même pour ECONNREFUSED, ef_construct ou KernelMemory__Services__Qdrant__Endpoint. Un embedding moderne encode suffisamment les identifiants techniques pour qu’un corpus de 22 documents courts ne le prenne pas en défaut.
Le gold ne discrimine pas à cette échelle — et la mesure honnête le dit. Avec un point par document et des questions directement formulées à partir des contenus, retrouver le bon document au rang 1 reste un problème facile pour tout moteur compétent. Les huit leurres (x1–x8) partagent du vocabulaire avec les questions, mais aucun n’assez proche pour détrôner la cible. C’est la leçon de méthodologie la plus transférable du notebook : un benchmark doit être calibré contre le plafond — une métrique à 1,0 partout ne mesure plus rien, et le réflexe correct est de rapporter « mesure non discriminante » plutôt que de chercher un écart qui n’existe pas dans les données.
Où est le gain de l’hybride, alors ? Pas ici — et c’est documenté. L’écart dense/BM25 se creuse quand (a) le corpus compte des milliers de fragments aux contenus qui se recouvrent, (b) plusieurs fragments d’un même document se disputent les premiers rangs, (c) les questions sont ambigües ou mal formulées, (d) des identifiants exacts voisins coexistent (versions, codes proches). Ce notebook pose l’infrastructure hybride complète — projection, prefetch, fusion RRF — prête à être confrontée à un corpus plus vaste (piste : exercice 2). Le coût de la deuxième jambe étant faible (§7a), la conclusion opérationnelle sur ce type de corpus est : l’hybride ne coûte presque rien, n’apporte rien de mesurable ici, et s’impose quand la couverture lexicale devient critique.
if INFRA_OK:import pandas as pd df = pd.DataFrame(per_q)print("Verdict par classe (R@5) :")for classe in ["exact", "paraphrase"]: sub = df[df["classe"] == classe] vals = {m: sub[f"{m} R@5"].mean() for m in MODES} best =max(vals, key=vals.get) hyb_vs_dense = vals["hybride RRF"] - vals["dense"] hyb_vs_bm25 = vals["hybride RRF"] - vals["bm25"]print(f" {classe:11s} : dense={vals['dense']:.2f} bm25={vals['bm25']:.2f} "f"hybride={vals['hybride RRF']:.2f} -> meilleur : {best}")print(f" hybride - dense = {hyb_vs_dense:+.2f} | "f"hybride - bm25 = {hyb_vs_bm25:+.2f}") g = df[metric_cols].mean()print(f"\nGLOBAL R@5 : dense={g['dense R@5']:.2f} bm25={g['bm25 R@5']:.2f} "f"hybride={g['hybride RRF R@5']:.2f}") perdantes =int((df["hybride RRF R@5"] < df["dense R@5"]).sum())print(f"Questions ou l'hybride fait PIRE que le dense seul (R@5) : {perdantes}/{len(df)}")else:print("Mode degrade.")
Verdict par classe (R@5) :
exact : dense=1.00 bm25=1.00 hybride=1.00 -> meilleur : dense
hybride - dense = +0.00 | hybride - bm25 = +0.00
paraphrase : dense=1.00 bm25=1.00 hybride=1.00 -> meilleur : dense
hybride - dense = +0.00 | hybride - bm25 = +0.00
GLOBAL R@5 : dense=1.00 bm25=1.00 hybride=1.00
Questions ou l'hybride fait PIRE que le dense seul (R@5) : 0/12
6c. Ce que +0.00 signifie vraiment
Le verdict est net et va contre les deux attendus : exact dense=1.00 bm25=1.00 hybride=1.00 et paraphrase identique, GLOBAL R@5 a 1,00 partout, et surtout Questions ou l'hybride fait PIRE que le dense seul (R@5) : 0/12.
Deux lectures, et la seconde est la plus utile.
Premierement, l’hybride n’est jamais puni : combiner les deux jambes n’a degrade aucune des douze questions. C’est le resultat rassurant — la fusion RRF ne fait pas perdre d’information.
Deuxiemement, et c’est le vrai enseignement, hybride - dense = +0.00 ne signifie pas que l’hybride soit inutile : cela signifie que cette metrique, sur ce corpus, ne peut pas le montrer. Le plafond est atteint des le mode dense seul, donc aucun gain n’est mesurable. La question « quel moteur gagne ? » est mal posee ici ; la question correcte est « quelle metrique discerne encore, une fois que recall@3 et MRR@10 valent 1,0 ? » — precision@k, nDCG, ou un corpus ou les distracteurs passent devant le bon document. C’est l’objet de l’exercice 2.
7. Coûts, décisions, exercices, nettoyage
7a. Ce que la deuxième jambe coûte
Poste
dense seul
dense + BM25 (hybride)
Embeddings à la requête
1 appel d’API par question
1 appel d’API par question (inchangé)
Indexation
1 vecteur dense par fragment
+ 1 vecteur épars par fragment (calcul local, tokenizer BM25)
Stockage Qdrant
1536 float32 ≈ 6 Ko/fragment
+ entrées éparses (quelques dizaines d’entiers non nuls)
Le coût marginal est faible ; le vrai sujet est la couverture documentaire : BM25 ne retrouve que ce qui est écrit mot pour mot. C’est un instrument pour les questions d’ingénierie (codes d’erreur, clés de config, identifiants), pas un remplacement du dense.
7b. Exercice 1 — la fusion DBSF au lieu de RRF
L’API Query propose une seconde fusion : DBSF (Distribution-Based Score Fusion), qui normalise les scores des deux jambes avant de les sommer — au lieu de ne considérer que les rangs. Implémentez search_hybrid_dbsf en remplaçant models.Fusion.RRF par models.Fusion.DBSF, mesurez le même gold, et comparez : sur ce corpus, laquelle des deux fusions préserve le mieux la classe paraphrase ?
Indices : la fonction query_points accepte models.FusionQuery(fusion=...) ;réutilisez recall_at_k et le tableau par classe de la section 6.
# Exercice 1 — a completerdef search_hybrid_dbsf(question: str, k: int=10) ->list:"""Hybride avec fusion DBSF (normalisation des scores) au lieu de RRF (rangs)."""# Etape 1 : construire les deux prefetch comme dans search_hybrid# Etape 2 : remplacer la fusion RRF par models.Fusion.DBSF# Etape 3 : retourner la liste des documents du top-kpass# TODO etudiantreturnNoneresultat_ex1 =None# TODO etudiant : tableau comparatif RRF vs DBSF par classeprint("Exercice 1 a completer : fusion DBSF vs RRF (meme gold, meme protocole)")
Exercice 1 a completer : fusion DBSF vs RRF (meme gold, meme protocole)
7c. Exercice 2 — la courbe recall@k
La section 6 fixe k à 3 et 5. Implémentez la courbe complète : pour k de 1 à 10, recall@k moyen (global et par classe) des trois modes. Question directrice : existe-t-il un k pour lequel le dense seul rattrape l’hybride sur la classe exact ?
Indices : appelez les moteurs avec k=10 une seule fois par question et tronquez laliste classée à chaque k — ne relancez pas 10 fois les recherches.
# Exercice 2 — a completerdef courbe_recall_k(mode_fn, gold, ks=range(1, 11)) ->dict:"""Recall@k moyen pour chaque k, sur tout le gold. Retourne {k: recall moyen}."""# Etape 1 : pour chaque question, obtenir le classement top-10 une seule fois# Etape 2 : pour chaque k, tronquer puis moyenner recall_at_kpass# TODO etudiantreturnNoneresultat_ex2 =None# TODO etudiant : {mode: {k: recall}} pour les 3 modesprint("Exercice 2 a completer : courbe recall@k (k=1..10) par mode")
Exercice 2 a completer : courbe recall@k (k=1..10) par mode
7d. Exercice 3 — hybride filtré : restreindre la recherche à un document
KM indexe chaque fragment avec son __document_id — la projection a gardé cette information dans le payload document. Qdrant permet de filtrer pendant la requête hybride (le filtre s’applique aux deux jambes). Implémentez search_hybrid_filtered qui ne cherche que dans les fragments d’un document donné, et vérifiez sur la question exacte de d1 que la réponse ne contient que d1.
Indices : models.Filter(must=[models.FieldCondition(key='document',* *match=models.MatchValue(value=...))]) se passe à query_points(..., query_filter=...).
# Exercice 3 — a completerdef search_hybrid_filtered(question: str, doc_id: str, k: int=5) ->list:"""Hybride RRF restreint aux fragments d'un seul document (payload 'document')."""# Etape 1 : construire models.Filter sur le champ payload "document"# Etape 2 : le passer a query_points en query_filterpass# TODO etudiantreturnNoneresultat_ex3 =None# TODO etudiant : demonstration sur la question exacte de d1print("Exercice 3 a completer : hybride filtre par document")
Exercice 3 a completer : hybride filtre par document
7e. Nettoyage
Le notebook laisse la place propre : suppression de l’index KM (et attente de sa disparition réelle dans Qdrant), de la collection projetée, des conteneurs et des volumes — même discipline que le 07.
if INFRA_OK: ok_del = km_delete_index()for _ inrange(30): time.sleep(2)ifnot qdrant_collection_exists(INDEX):breakif qc.collection_exists(PROJ_COLLECTION): qc.delete_collection(PROJ_COLLECTION)print(f"Index KM supprime : {ok_del} | collection projetee supprimee : True")if DOCKER_OK:for name in (KM_CONTAINER, QDRANT_CONTAINER): subprocess.run(["docker", "rm", "-f", name], capture_output=True)for vol in (KM_FILES_VOLUME, QDRANT_VOLUME): subprocess.run(["docker", "volume", "rm", "-f", vol], capture_output=True)print("Conteneurs et volumes supprimes (km_service_rag08, qdrant_rag08, volumes associes)")else:print("Pas de nettoyage Docker necessaire (jamais demarres).")
Index KM supprime : True | collection projetee supprimee : True
Conteneurs et volumes supprimes (km_service_rag08, qdrant_rag08, volumes associes)
7e-bis. Le demontage est en deux temps
Index KM supprime : True | collection projetee supprimee : True, puis la disparition des conteneurs km_service_rag08 et qdrant_rag08 avec leurs volumes.
Le premier temps est le plus instructif : supprimer l’index KM ne suffit pas, et l’ordre importe. Le code boucle — jusqu’a trente fois, deux secondes d’intervalle — en attendant que la collection ait reellement disparu de Qdrant avant de poursuivre. Sans cette attente, un redemarrage du notebook retomberait sur l’index en cours de suppression, c’est-a-dire exactement la course que la section 3 cherchait a eviter.
Le second temps — docker rm -f des conteneurs, puis docker volume rm des volumes — remet la machine dans l’etat ou le notebook l’a trouvee. La lecon de fond : un notebook qui monte une infrastructure doit la demonter, et le demontage est une operation ordonnee, pas un grand menage final.
Conclusion
Ce que le notebook a construit et mesuré :
la chaîne hybride complète au-dessus de Kernel Memory : ingestion par le service (22 documents français), constat des deux verrous (KM dense-only ; Qdrant refuse d’étendre une collection existante — 404 constaté en direct), projection fidèle vers une collection à deux jambes (dense 1536 + BM25 Qdrant/bm25, modificateur idf), requête prefetch + fusion RRF — chaque étape exécutée réellement, aucune sortie fabriquée ;
un protocole de mesure honnête : attendus écrits avant, gold à deux classes, leurres lexicaux, recall@k et MRR par classe — dont le résultat est un plafond partagé à 1,0 : sur un corpus de cette taille, le dense seul ne cède rien et l’hybride n’ajoute rien de mesurable. Le gain attendu de la deuxième jambe se joue à une échelle que ce notebook ne prétend pas couvrir ;
la leçon de méthodologie : la valeur d’une mesure contrôlée n’est pas de confirmer ce qu’on espère, mais de révéler quand le benchmark lui-même est le facteur limitant. Métriques plates = gold non discriminant — un signal à rapporter tel quel, pas à forcer.
Le prolongement naturel : le corpus hétérogène du 07 (PDF réel, code source, textes longs — plusieurs fragments par document) projeté dans la collection hybride, là où les rangs commenceront à différer.
# Cellule finale : etat d'execution (patron 05b/07)if INFRA_OK:print("Toutes les sorties de ce notebook proviennent d'une execution reelle :")print(" - service Kernel Memory (kernelmemory/service, conteneur Docker local, port 9001)")print(" - Qdrant local (conteneur Docker, port 6333, patron du notebook 05b)")print(" - embeddings via endpoint OpenAI-compatible (cle chargee depuis .env, jamais affichee)")print(f" - {len(uploaded)} documents ingeres par KM, projetes vers la collection hybride "f"'{PROJ_COLLECTION}'")print(f" - gold de {len(GOLD)} questions mesure sur 3 moteurs (dense, BM25, hybride RRF)")print(" - infrastructure demontee en fin de notebook (conteneurs et volumes supprimes)")else:print("MODE DEGRADE : infra incomplete (Docker indisponible, service KM non lance ou cles absentes).")print("Les cellules ont ete sautees proprement (garde INFRA_OK), aucune sortie n'a ete fabriquee.")print("Pour l'execution complete : Docker Desktop actif + .env de la serie GenAI (cf. .env.example).")
Toutes les sorties de ce notebook proviennent d'une execution reelle :
- service Kernel Memory (kernelmemory/service, conteneur Docker local, port 9001)
- Qdrant local (conteneur Docker, port 6333, patron du notebook 05b)
- embeddings via endpoint OpenAI-compatible (cle chargee depuis .env, jamais affichee)
- 22 documents ingeres par KM, projetes vers la collection hybride 'km13421-hybrid-proj'
- gold de 12 questions mesure sur 3 moteurs (dense, BM25, hybride RRF)
- infrastructure demontee en fin de notebook (conteneurs et volumes supprimes)