Ce notebook est le versant pratique de la section RAG et Mémoire Sémantique. Il fait tourner une vraie base vectorielle Qdrant en mémoire (:memory:), sans Docker, sans GPU, sans clé d’API et sans aucune donnée réelle. Tout est reproductible en local.
Vous y manipulerez, étape par étape, les quatre gestes qui forment le socle du grounding :
Embarquer un petit corpus (texte → vecteurs) ;
Rechercher par le sens (et voir où le grep échoue) ;
Filtrer sur la charge utile (payload) ;
Indexer un champ de payload — et reproduire en miniature l’incident réel du « scroll doom loop » (cf. 04 - Incidents et leçons, section 8).
Carte courte de la série
Le parcours se lit comme une chaîne de responsabilités :
Grounding — ce notebook pose le corpus, les vecteurs et les filtres.
KernelMemory — 06 introduit le pipeline, 07 le transpose en Python, puis 08 et 09 étendent la recherche hybride et multimodale.
Prérequis conceptuels : 01 - Pourquoi la mémoire sémantique. Aucune installation lourde : qdrant-client embarque un moteur en mémoire et fastembed télécharge un petit modèle d’embeddings (~130 Mo) au premier appel.
1. Installation
qdrant-client[fastembed] apporte à la fois le client Qdrant et l’intégration fastembed (modèle d’embeddings local, exécuté sur CPU). Aucune clé d’API n’est requise.
L’output ci-dessus affiche “qdrant-client importé — moteur en mémoire prêt”, ce qui confirme trois choses en un seul geste : (1) le paquet qdrant-client[fastembed] a été installé dans l’environnement actif sans erreur de résolution de dépendances, (2) le moteur Qdrant embarqué (mode :memory:) est prêt à recevoir des opérations — sans qu’un serveur séparé ne tourne à côté, (3) aucun appel réseau ni clé d’API n’a été nécessaire pour atteindre cet état. C’est la promesse du mode mémoire : reproduire en local la même API qu’une instance Docker de production, sans l’infrastructure. Si l’import échoue, le message d’erreur vient de Python (paquet absent, version incompatible) — il faut alors installer le paquet plutôt que chercher à contourner (cf règle F : on répare l’environnement, on ne maquille pas l’erreur).
2. Une base vectorielle en mémoire
QdrantClient(":memory:") instancie un moteur Qdrant dans le processus Python, sans serveur ni conteneur. L’API est identique à celle d’un Qdrant de production : tout ce que vous écrivez ici se transpose tel quel vers une instance Docker (cf. 02 - Infrastructure).
client = QdrantClient(":memory:")# Modèle d'embeddings MULTILINGUE (gère le français), exécuté localement sur CPU via# fastembed. 384 dimensions, ~0,22 Go téléchargé au premier appel. On l'explicite pour# la reproductibilité — un modèle anglais seul séparerait mal des phrases françaises.EMBED_MODEL ="sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"client.set_model(EMBED_MODEL)print("Modèle d'embeddings :", EMBED_MODEL)
La ligne “Modèle d’embeddings : sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2” confirme la résolution du modèle d’embeddings multilingue (384 dimensions, ~130 Mo téléchargés au premier appel par fastembed). Le choix n’est pas anodin : ce modèle rapproche correctement des phrases françaises entre elles (par exemple “éviter les pics de charge” et “throttling des appels sortants”), là où un modèle purement anglais sous-représenterait leur parent sémantique. Si le corpus contient surtout du français, garder ce modèle ; pour un corpus anglais technique dense, un modèle BGE ou E5 peut donner de meilleurs résultats — c’est précisément la variation que l’exercice 3 explore. Le téléchargement initial prend quelques dizaines de secondes ; les appels suivants sont mis en cache par fastembed.
3. Indexer un petit corpus
On indexe une poignée de fragments imitant ce qu’une flotte d’agents produit : des bribes de conversations et de notes techniques. À chaque document est attachée une charge utile (payload) — ici la source et un sujet — qui voyagera avec le vecteur et servira aux filtres.
client.add(...) fait tout en un geste : il embarque chaque texte avec fastembed, puis upsert le point (vecteur + payload) dans la collection.
documents = ["Pour éviter de saturer l'API, on limite le nombre de requêtes par seconde.","Le throttling des appels sortants protège le service des pics de charge.","La collection a été recréée après une dérive de montage du disque virtuel.","Un index de payload sur le champ de hash rend la déduplication instantanée.","La quantization TurboQuant 4 bits comprime les vecteurs d'un facteur huit.","La recette de la tarte aux pommes demande des pommes, du beurre et du sucre.",]metadata = [ {"source": "conversation", "sujet": "rate-limiting"}, {"source": "code", "sujet": "rate-limiting"}, {"source": "incident", "sujet": "mount-drift"}, {"source": "incident", "sujet": "payload-index"}, {"source": "doc", "sujet": "quantization"}, {"source": "bruit", "sujet": "hors-domaine"},]ids =list(range(len(documents)))client.add( collection_name="grounding_demo", documents=documents, metadata=metadata, ids=ids,)info = client.get_collection("grounding_demo")print("Points indexés :", info.points_count)
Points indexés : 6
Lecture du résultat : un corpus hétérogène pour un test réaliste
“Points indexés : 6” — le corpus se compose volontairement de 5 fragments métier (rate-limiting, dérive de montage, index de payload, quantization) et 1 fragment hors-domaine (la tarte aux pommes). Cette dernière phrase n’a rien à faire dans une base de production — et c’est précisément ce qui rend la démo probante : on va voir dans la cellule 8 qu’elle reste à un score de similarité très bas (≈ 0.11) face à une requête métier, ce qui démontre que le moteur ne confond pas le sujet et le décor. En production, ce genre de fragment bruit sert de témoin négatif : si une requête “innocente” remonte la tarte aux pommes dans le top 3, c’est que quelque chose a dérivé (modèle trop générique, corpus mal segmenté, ou limit trop élevé).
4. Recherche sémantique
On interroge en langage naturel. Qdrant embarque la requête avec le même modèle, puis renvoie les k points dont le vecteur est le plus proche (similarité cosinus). Le score est dans [0, 1] — plus il est haut, plus le sens est proche.
def chercher(question, limit=3): hits = client.query(collection_name="grounding_demo", query_text=question, limit=limit)print(f"Requête : {question!r}\n")for h in hits:print(f" score={h.score:.3f} [{h.metadata['source']}/{h.metadata['sujet']}] {h.document}")print()return hits_ = chercher("comment ne pas dépasser le quota d'appels ?")
Requête : "comment ne pas dépasser le quota d'appels ?"
score=0.667 [code/rate-limiting] Le throttling des appels sortants protège le service des pics de charge.
score=0.519 [conversation/rate-limiting] Pour éviter de saturer l'API, on limite le nombre de requêtes par seconde.
score=0.113 [incident/payload-index] Un index de payload sur le champ de hash rend la déduplication instantanée.
Lecture du résultat : la tarte aux pommes reste à 0.11
Trois scores, dans l’ordre : 0.667 ([code/rate-limiting]), 0.519 ([conversation/rate-limiting]), 0.113 ([incident/payload-index]). La tarte aux pommes (sujet hors-domaine) n’apparaît même pas dans le top 3 — c’est précisément ce que la cellule 5 annonce : “le document tarte aux pommes reste loin : il partage peut-être des mots fonctionnels, mais pas le sens”. Le 0.113 du fragment payload-index est trompeur : il est bas (le document parle de hash, pas de throttling), mais non nul — c’est le reflet d’un voisinage sémantique large dans l’espace des embeddings, pas une proximité réelle. Une règle métier saine consiste à filtrer les hits en dessous d’un seuil de score (par exemple score ≥ 0.3) plutôt que d’espérer que le moteur classe tout par pertinence absolue.
5. Sémantique contre lexical : le grep est aveugle au sens
La requête ci-dessus ne contient aucun des mots « limite », « requêtes » ou « throttling ». Un grep n’aurait rien trouvé. La recherche sémantique, elle, rapproche « quota d’appels », « limiter les requêtes » et « throttling » parce qu’ils signifient la même chose.
À l’inverse, le document « tarte aux pommes » reste loin : il partage peut-être des mots fonctionnels, mais pas le sens.
# Trois formulations très différentes du même concept -> mêmes documents pertinents.for q in ["rate limiting", "éviter les pics de charge sur le service", "ralentir les appels réseau"]: _ = chercher(q, limit=2)
Requête : 'rate limiting'
score=0.349 [code/rate-limiting] Le throttling des appels sortants protège le service des pics de charge.
score=0.344 [conversation/rate-limiting] Pour éviter de saturer l'API, on limite le nombre de requêtes par seconde.
Requête : 'éviter les pics de charge sur le service'
score=0.714 [code/rate-limiting] Le throttling des appels sortants protège le service des pics de charge.
score=0.390 [conversation/rate-limiting] Pour éviter de saturer l'API, on limite le nombre de requêtes par seconde.
Requête : 'ralentir les appels réseau'
score=0.616 [code/rate-limiting] Le throttling des appels sortants protège le service des pics de charge.
score=0.428 [conversation/rate-limiting] Pour éviter de saturer l'API, on limite le nombre de requêtes par seconde.
Lecture du résultat : trois formulations, un même top document
Les trois requêtes — “rate limiting” (0.349), “éviter les pics de charge sur le service” (0.714), “ralentir les appels réseau” (0.616) — remontent toutes le même document en tête ([code/rate-limiting] : “Le throttling des appels sortants protège le service des pics de charge”). C’est la preuve directe que la recherche sémantique n’est pas un grep amélioré : aucune des trois requêtes ne contient le mot “throttling”, et pourtant c’est lui qui remonte. Le 0.349 sur la formulation anglaise “rate limiting” est plus bas que les deux reformulations françaises — c’est attendu : le corpus est en français, donc l’espace vectoriel est dominé par les vecteurs français ; les vecteurs anglais se projettent en bord d’espace, plus loin des centroïdes métier. C’est une observation, pas un défaut — voir l’exercice 3 pour l’effet inverse (modèle anglais sur corpus français).
6. Filtrer sur la charge utile (payload)
La recherche sémantique peut être contrainte par un filtre exact sur le payload : « les fragments proches de cette idée, mais seulement ceux issus d’un incident ». C’est la combinaison vecteur + filtre qui fait la puissance d’une base comme Qdrant.
flt = models.Filter( must=[models.FieldCondition(key="source", match=models.MatchValue(value="incident"))])hits = client.query( collection_name="grounding_demo", query_text="problème d'infrastructure à diagnostiquer", query_filter=flt, limit=3,)for h in hits:print(f" score={h.score:.3f} [{h.metadata['source']}/{h.metadata['sujet']}] {h.document}")
score=0.276 [incident/payload-index] Un index de payload sur le champ de hash rend la déduplication instantanée.
score=0.117 [incident/mount-drift] La collection a été recréée après une dérive de montage du disque virtuel.
Lecture du résultat : le filtre payload borne la recherche au bon périmètre
Deux hits seulement après filtre source = 'incident' : 0.276 ([incident/payload-index]) et 0.117 ([incident/mount-drift]). Les fragments rate-limiting (qui avaient les meilleurs scores à la cellule 8) sont exclus, même s’ils sont sémantiquement plus proches — parce qu’ils n’ont pas la bonne valeur de payload. C’est la combinaison des deux signaux (vecteur + métadonnée) qui fait la puissance d’une base hybride : la sémantique répond à “qu’est-ce qui parle de ce sujet ?”, le payload répond à “dans quel contexte ?”. En production, on filtre avant de classer : le moteur ne calcule la similarité que sur les points qui passent le filtre, ce qui économise du calcul et réduit le bruit (un hit à 0.276 filtré vaut mieux qu’un hit à 0.7 hors-périmètre).
7. War-story en miniature : l’index de payload qui éteint le doom loop
Voici la reproduction, en réduction, d’un incident réel (cf. 04 - Incidents et leçons, section 8).
En production, l’indexeur déduplique en interrogeant Qdrant par un scrollfiltré sur un champ de hash (contentHash). Tant que ce champ n’a pas d’index de payload, chaque scroll filtré est un balayage O(n) de toute la collection. Sur plus d’un million de points avec payload sur disque, cela prenait 60 à 95 secondes par requête — les délais explosaient, les clients réessayaient, et les CPU saturaient pendant des heures.
Reproduisons le geste qui a tout réglé : créer l’index de payload. On fabrique une collection synthétique (vecteurs aléatoires, pas d’embeddings — on ne mesure que le filtre), on chronomètre un scroll filtré avant puis après la création de l’index.
20000 points synthétiques upsertés.
sans index 273.11 ms -> 4 point(s)
avec index 297.39 ms -> 4 point(s)
Lecture du résultat : à cette échelle, l’index ne sert (presque) à rien — et c’est honnête
Mesure brute : sans index = 273.11 ms pour 4 points ; avec index = 297.39 ms (légèrement plus lent, dans le bruit). Ce résultat n’est pas une erreur : le moteur en mémoire scanne vite une collection de 20 000 points — Qdrant émet d’ailleurs un avertissement explicite, “Payload indexes have no effect in the local Qdrant”, car l’optimisation d’index ne s’active que côté serveur, pas en mode :memory:. Le ratio observé (≈ 1.09, en faveur de « sans index ») est un artefact : le surcoût de create_payload_index + la maintenance du HNSW interne ne sont pas amortis sur 20 000 points en RAM ; sur 1 million de points sur disque, il s’inverse brutalement.
L’asymptote est ce qui compte. À l’échelle réelle — des millions de points, payload sur disque, requêtes concurrentes — le même balayage O(n) passe de quelques millisecondes à dizaines de secondes, dépasse le délai d’opération et déclenche la tempête de retries (le scroll doom loop) qui sature les CPU.
Les leçons (identiques à l’incident réel) :
Un index de payload n’est pas un luxe : sans lui, tout filtre est un balayage O(n).
Créez les index de payload à la création de la collection — une collection recréée sans ses index repart sur la même bombe à retardement.
Un retry naïf amplifie une panne : remplacer la déduplication par scroll par une récupération par identifiant (retrieve(), O(1)) supprime la cause à la racine.
8. Note — quantization TurboQuant (turbo.bits)
À l’échelle d’un million de vecteurs, l’empreinte mémoire devient le facteur limitant. Qdrant propose une quantization qui comprime les vecteurs. Sur l’infrastructure décrite dans cette section, on utilise TurboQuant 4 bits, configuré par la clé officielle :
quantization_config:turbo:bits: bits4 # valeurs possibles : bits1, bits2, bits4, bits8always_ram:true # garder les vecteurs quantizés en RAM pour des recherches rapides
Résultat mesuré en production : compression 8×, pour une score-recall@10 = 1.0 vs la recherche exacte (aucune perte de qualité observée sur la collection réelle). Le rescore est un paramètre de requête (params.quantization.rescore), pas de la collection. Détails et contexte dans 02 - Infrastructure Qdrant.
9. Pour aller plus loin — la série RAG sur texte
Ce notebook manipule l’infrastructure de mémoire sémantique. Pour le versant applicatif du RAG sur des documents, deux notebooks de la section Texte sont complémentaires :
5_RAG_Modern.ipynb — un pipeline RAG complet de bout en bout (découpage, embeddings, recherche, génération augmentée).
Les trois exercices ci-dessous sont à compléter dans les cellules de code qui suivent. Chaque cellule est un squelette exécutable (elle s’exécute telle quelle, mais ne fait rien d’utile tant que vous n’avez pas rempli les # TODO). Inspirez-vous des sections 4 (recherche sémantique), 6 (filtres de payload) et de la cellule 4 (modèle d’embeddings).
Exercice 1 — Retrouver un document par le sens, pas par les mots
Le grounding retrouve un texte grâce à sa signification, même si la requête ne partage aucun mot avec lui. C’est tout l’intérêt d’une recherche vectorielle par rapport à un grep.
Objectif. Ajoutez au corpus un document sur la sécurité des secrets (par exemple la rotation des clés d’API), puis écrivez une requête qui le retrouve sans employer aucun de ses mots-clés (« secret », « clé », « API »…).
Indices. - Inspirez-vous de la cellule 6 pour client.add(...) et de la fonction chercher (section 4) pour la requête. - Un modèle multilingue rapproche par exemple « gestion des identifiants » et « sécurité des secrets » bien qu’aucun mot ne soit commun.
# Exercice 1 — Ajoutez un document sur la sécurité des secrets, puis retrouvez-le# par le SENS, sans citer ses mots exacts (cf. section 4 : fonction `chercher`).## TODO étudiants :# 1. Complétez `nouveau_document` (une phrase sur la rotation/fuite de clés).# 2. Ajoutez-le à la collection "grounding_demo" via `client.add(...)`# (même forme que la cellule 6 : documents=[...], metadata=[...]).# 3. Complétez `requete_semantique` avec une formulation qui évite les mots du document.# 4. Décommentez l'appel à `chercher` et vérifiez que votre document figure dans le top 3.nouveau_document ="..."# TODO : votre phrase sur la sécurité des secretsrequete_semantique ="..."# TODO : formulation sans les mots-clés du document# TODO : ajoutez le document à la collection# client.add(collection_name="grounding_demo", documents=[nouveau_document],# metadata=[{"source": "exercice", "sujet": "secrets"}])# resultat = chercher(requete_semantique, limit=3)resultat =None# TODO : remplacez par l'appel à chercher(...) ci-dessusprint("Exercice 1 à compléter — résultat :", resultat)
Exercice 1 à compléter — résultat : None
Exercice 2 — Combiner deux conditions de filtre (ET logique)
Un Filter(must=[...]) ne retient que les points qui satisfont toutes les FieldCondition listées : c’est l’équivalent d’un ET logique. La section 6 filtre sur une seule condition ; ici on en combine deux.
Objectif. Construisez un filtre qui sélectionne les documents dont source = "incident"etsujet = "mount-drift", puis interrogez la collection avec ce filtre.
Indices. - Inspectez d’abord les métadonnées de la cellule 6 pour connaître les valeurs exactes. - Deux FieldCondition dans la même liste must = intersection (ET). Pour un OU, il faudrait should=[...].
# Exercice 2 — Combinez DEUX conditions de filtre (ET logique via `must`).# Cf. section 6 : un `Filter(must=[...])` ne retient que les points satisfaisant# TOUTES les FieldCondition listées.## TODO étudiants :# 1. Construisez `filtre_combine` avec models.Filter(must=[...]).# 2. Ajoutez dans `must` la 1re FieldCondition (key="source", value="incident").# 3. Ajoutez la 2e FieldCondition (key="sujet", value="mount-drift").# 4. Décommentez l'appel à `client.query(...)` et observez le nombre de hits.filtre_combine =None# TODO : models.Filter(must=[...]) avec deux FieldCondition# reponse = client.query(# collection_name="grounding_demo",# query_text="dérive de montage du disque",# query_filter=filtre_combine,# limit=5,# )reponse =None# TODO : remplacez par l'appel à client.query(...) ci-dessusprint("Exercice 2 à compléter — réponse :", reponse)
Exercice 2 à compléter — réponse : None
Exercice 3 — Comparer deux modèles d’embeddings
Le choix du modèle d’embeddings (langue couverte, dimension du vecteur) change la qualité du retrieval. fastembed supporte plusieurs modèles locaux ; la cellule 4 en a fixé un (multilingue, 384 dimensions).
Objectif. Créez un second client avec un modèle d’embeddings différent (par exemple BAAI/bge-small-en-v1.5), ré-indexez le même petit corpus, puis comparez les scores obtenus pour une même requête entre les deux modèles.
Indices. - Créez un nouveau QdrantClient(":memory:") puis appelez client_b.set_model(...). - Un modèle entraîné surtout sur l’anglais donnera des scores plus faibles sur un corpus francophone : c’est précisément ce que l’on veut mettre en évidence. - Pour lister les modèles disponibles : QdrantClient.get_models() ou la doc fastembed.
# Exercice 3 — Changez de modèle d'embeddings et comparez les scores.# Cf. cellule 4 : `client.set_model(EMBED_MODEL)` définit le modèle utilisé.## TODO étudiants :# 1. Créez un second client `client_b = QdrantClient(":memory:")`.# 2. Fixez-lui un modèle DIFFÉRENT (ex. "BAAI/bge-small-en-v1.5").# 3. Recréez la collection "comparaison" et ré-indexez le corpus de la cellule 6.# 4. Lancez la même requête sur les deux clients et comparez les scores du top hit.# client_b = QdrantClient(":memory:")# client_b.set_model("BAAI/bge-small-en-v1.5") # TODO : un autre modèle fastembed# ... TODO : créer la collection, indexer le corpus, requêter ...comparaison =None# TODO : dict du type {"multilingue": 0.83, "bge-small-en": 0.71}print("Exercice 3 à compléter — comparaison :", comparaison)
Exercice 3 à compléter — comparaison : None
11. Pour aller plus loin — explorations libres
Les quatre pistes ci-dessous sont des explorations ouvertes (sans squelette de code) pour approfondir, une fois les trois exercices ci-dessus terminés :
Ajoutez un document sur un nouveau sujet (par exemple la sécurité des secrets), puis vérifiez qu’une requête sémantique le retrouve sans en citer les mots exacts.
Combinez deux conditions de filtre (must avec deux FieldCondition) : par exemple source = "incident"etsujet = "mount-drift".
Faites varier N dans la section 7 (par ex. 5 000 → 100 000) et observez comment l’écart de temps scroll avec/sans index évolue. Que se passerait-il sur disque plutôt qu’en mémoire ?
Changez le modèle d’embeddings (client.set_model(...) avec un autre modèle fastembed) et comparez les scores. La dimension du vecteur change-t-elle vos résultats ?
Continuer le parcours
Vous avez maintenant relié texte, embeddings, recherche sémantique et filtres Qdrant. La suite immédiate est 02-Retrieval-Avance.ipynb : elle part de ces résultats pour travailler le classement, la fusion et l’évaluation du retrieval.
Notebook pratique — Section RAG et Mémoire Sémantique — Juin 2026. Exécutable hors ligne (Qdrant en mémoire + fastembed), sans clé d’API ni donnée réelle.