# Dépendances : numpy uniquement. Aucun réseau, aucune clé, aucun modèle.
import numpy as np
RNG = np.random.default_rng(7) # seed fixée : tout le notebook est reproductible
DIM = 16 # dimension de l'espace d'embedding (synthétique)
ENVIRONNEMENTS = [
"catalogue_public",
"comite_lecture",
"atelier_interne",
"logistique",
"archive_privee",
"vitrine",
]Séparer les environnements de vecteurs
La séparation par environnement est un contrôle d’accès, pas une commodité d’organisation. Corollaire opérationnel : une réindexation lancée sans préciser l’environnement cible écrase un corpus voisin.
Ces deux lignes viennent du Parcours 2 de livresagites-parcours.md. Elles décrivent deux défaillances observées sur une installation réelle d’AI-Engine, où les vecteurs ne forment pas un corpus unique mais six environnements d’embeddings distincts, séparés par un identifiant en base.
Ce notebook ne reparle pas de l’observation : il rend les deux défaillances mesurables et reproductibles. À partir d’un vector store synthétique partitionné en six régimes d’accès, on mesure deux choses :
- le taux de fuite — une requête de retrieval émise sans filtre d’environnement renvoie des chunks d’un régime d’accès différent ;
- l’accident de réindexation —
reindexer(..., environnement=None)écrit dans un emplacement par défaut qui écrase un corpus voisin, silencieusement.
Les deux sont démontrés déterministiquement, sans réseau, sans clé, sur une fixture synthétique.
La scène : Maison Valmont et ses six régimes d’accès
Maison Valmont indexe ses contenus dans un vector store, mais tous les contenus ne s’adressent pas au même public. Six environnements cohabitent, chacun destiné à un régime d’accès distinct :
| Environnement | Public visé | Régime |
|---|---|---|
catalogue_public |
visiteurs anonymes | ouvert |
vitrine |
page d’accueil, mis en avant | ouvert |
comite_lecture |
comité interne de relecture | réservé |
atelier_interne |
notes d’atelier, brouillons | réservé |
archive_privee |
archives dirigeants | confidentiel |
logistique |
fiches fournisseurs, stocks | interne |
Deux environnements — catalogue_public et comite_lecture — traitent des œuvres comparables (mêmes pièces, mêmes collections). Géométriquement, leurs vecteurs sont donc proches : c’est précisément ce chevauchement qui rendra la fuite possible, et qui montre pourquoi le contrôle d’accès ne peut pas se déduire de la seule distance vectorielle.
Lire la table des six régimes comme une carte de risques, pas une liste
Six environnements, quatre régimes d’accès seulement (ouvert, réservé, confidentiel, interne) : la table ci-dessus n’est pas une nomenclature de rangement, c’est une carte de risques. Le risque de fuite d’un environnement ne dépend pas de son régime seul, mais du produit proximité sémantique × distance de régime avec chacun de ses voisins. vitrine et logistique sont ouvert et interne, mais ils parlent de choses sans aucun rapport (œuvres mises en avant d’un côté, fiches fournisseurs de l’autre) : leur proximité sémantique est faible, donc un retrieval non filtré a peu de chances de ramener l’un quand on cherche l’autre. À l’inverse, catalogue_public et comite_lecture partagent le régime le plus défavorable qui soit — deux publics dont l’un a le droit de voir et l’autre pas (ouvert contre réservé) — autour des mêmes œuvres : proximité sémantique maximale, distance de régime maximale. C’est exactement là que la fuite se concentrera, et ce n’est pas un hasard de fixture : dans une vraie maison d’édition, les fichiers de rélecture commentent les mêmes textes que le catalogue public.
Cette lecture a une conséquence pratique pour l’audit d’un vrai store : on n’audite pas six environnements, on audite les paires. Enumérer les environnements et leurs régimes ne suffit pas ; il faut la matrice des proximités (quels environnements parlent des mêmes sujets ?) croisée avec la matrice des régimes (quels couples ont des droits différents ?). Les cases à haut risque de cette matrice sont les candidats au test de fuite ci-dessous — les autres peuvent attendre. Un audit qui traite les environnements indépendamment les uns des autres rate la structure même du risque, qui vit entre eux.
Reste une différence avec un audit réel : ici, la matrice est connue d’avance — la cellule de construction ci-dessous déclare le couplage catalogue_public / comite_lecture (le décalage scale=0.6). Sur un vrai store, personne ne déclare rien : les proximités doivent être découvertes par la mesure — par exemple en comparant les centroïdes d’environnements ( moyenne des vecteurs de chaque environnement, similarité cosinus entre centroïdes), puis en classant les paires par proximité avant de tester celles qui cumulent proximité sémantique et régimes distincts. La carte des risques d’un store réel est un résultat d’analyse, pas une donnée de configuration — et c’est précisément pour cela qu’elle n’est dressée nulle part : personne ne la demande tant que rien n’a fui. Une dernière précision, sur le sens de la fuite : le risque n’est pas symétrique. Du public vers le réservé, il n’y a pas de fuite d’accès — un membre du comité qui reçoit du contenu public a le droit de le voir ; la seule dégradation est qualitative (du bruit dans ses résultats). C’est le sens inverse qui compte — comite_lecture vers catalogue_public — parce que c’est le seul qui livre du contenu protégé à qui n’y a pas droit. Un test de fuite qui tournerait « dans tous les sens » diluerait son verdict dans du faux positif ; le protocole doit cibler les requêtes émises depuis les régimes les moins privilégiés. La carte des risques gagne donc une flèche : chaque paire à risque porte un sens, du régime supérieur vers le régime inférieur.
Le contrat de dépendances — et une défaillance conçue pour être falsifiable
Trois lignes de configuration ouvrent le notebook : une graine (default_rng(7)), une dimension (DIM = 16), une liste (ENVIRONNEMENTS). Autour d’elles, le contrat de dépendances tient en un commentaire : numpy uniquement, aucun réseau, aucune clé, aucun modèle. Ce n’est pas une économie de moyens, c’est une décision pédagogique doublée d’une garantie épistémologique. Pédagogique : tout lecteur peut exécuter l’intégralité de la démonstration sans compte, sans GPU, sans facture — la barrière d’entrée du contrôle est nulle, alors qu’elle est énorme sur une vraie plateforme (une installation, des clés, un corpus). Épistémologique : sans appel réseau, le résultat ne dépend d’aucun service externe qui aurait changé entre l’écriture et la relecture — les chiffres committés ne périment pas.
DIM = 16 mérite un mot : seize dimensions suffisent à donner aux six nuages une structure stable et lisible (des directions bien séparables), tout en restant petit assez pour que les distances se raisonnent à la main. Une vraie plateforme embarbe sur des milliers de dimensions et une similarité cosinus ; la géométrie diffère en degré, pas en nature — ce qui compte ici est le mécanisme, pas l’échelle.
Le plus important : la défaillance de ce notebook est falsifiable par construction. La cause de la fuite — le couplage des deux nuages — est une ligne de fixture, pas une propriété profonde de la plateforme. Un lecteur sceptique peut ouvrir la cellule suivante, remplacer le décalage du couplage (scale=0.6) par un décalage de l’ordre des centroïdes (scale=3.0), ré-exécuter : les nuages se séparent, la fuite disparaît. La démonstration ne dit pas « la fuite est inévitable » ; elle dit « la fuite apparaît quand des environnements sémantiquement voisins ont des régimes distincts, et disparaît quand on éloigne les contenus ». Une affirmation dont on peut supprimer la cause pour la tester est la seule qui vaille d’être committée.
Construire le vector store partitionné
Chaque environnement est un cluster : un centroïde tiré aléatoirement, autour duquel s’organisent N=40 chunks. Le détail important : on rapproche délibérément catalogue_public et comite_lecture (ils partagent un thème, comme dans la vraie vie), de sorte que leurs clusters se chevauchent partiellement. Les quatre autres environnements restent bien séparés.
N_PAR_ENV = 40
# centroïdes de base, un par environnement
centroids = {env: RNG.normal(scale=3.0, size=DIM) for env in ENVIRONNEMENTS}
# rapprochement catalogue_public <-> comite_lecture (mêmes œuvres => vecteurs proches)
centroids["comite_lecture"] = centroids["catalogue_public"] + RNG.normal(scale=0.6, size=DIM)
# le vector store : dictionnaire environnement -> (N, DIM) chunks
store = {}
for env in ENVIRONNEMENTS:
store[env] = centroids[env] + RNG.normal(scale=1.5, size=(N_PAR_ENV, DIM))
print(f"Vector store Maison Valmont : {len(store)} environnements, "
f"{sum(len(v) for v in store.values())} chunks au total, dimension {DIM}.")
for env in ENVIRONNEMENTS:
print(f" {env:18s} : {len(store[env]):2d} vecteurs")Vector store Maison Valmont : 6 environnements, 240 chunks au total, dimension 16.
catalogue_public : 40 vecteurs
comite_lecture : 40 vecteurs
atelier_interne : 40 vecteurs
logistique : 40 vecteurs
archive_privee : 40 vecteurs
vitrine : 40 vecteurs
Ce que la sortie affirme — et comment la fixture est construite pour exhiber la défaillance
La sortie committée annonce 6 environnements, 240 chunks au total, dimension 16, avec exactement 40 vecteurs par environnement. Deux lectures s’imposent, une sur le nombre, une sur la construction.
Sur le nombre : un vrai store n’est pas équilibré — un catalogue public compte des centaines de chunks quand une archive de direction en compte dix. L’équilibre ici n’est pas du réalisme, c’est un choix de mesure : à effectif égal, le top-5 d’une recherche non filtrée donne à chaque environnement la même chance statistique d’apparaître. La fuite qu’on mesurera plus bas ne pourra donc pas être imputée à un effet de volume (« l’environnement envahissant n’était que plus gros ») ; elle ne pourra venir que de la géométrie — les vecteurs eux-mêmes. C’est la variable qu’on veut isoler, et l’équilibrage est l’instrument qui l’isole. À l’inverse, un lecteur qui voudrait étudier l’effet de volume n’aurait qu’à casser l’équilibre (N_PAR_ENV distinct par environnement) et observer une seconde cause de fuite : un environnement majoritaire envahit mécaniquement les top-k globaux sans aucune proximité sémantique — deux mécanismes de fuite indépendants, un seul visible dans un store donné selon sa distribution.
Sur la construction : les trois échelles numériques du code se lisent comme un programme. Centroïdes tirés à scale=3.0 : six directions bien distinctes dans l’espace. Bruit des chunks à scale=1.5 : des nuages assez compacts pour rester reconnaissables. Couplage comite_lecture = catalogue_public + bruit(scale=0.6) : un décalage cinq fois plus petit que l’écart typique entre environnements — les deux nuages se chevauchent partiellement. La fixture n’essaie pas de simuler une distribution réaliste de contenus : elle fabrique les conditions exactes de la défaillance à démontrer, et l’assume. Un lecteur qui reproduirait l’expérience avec des centroïdes tous éloignés verrait une fuite nulle et conclurait à tort que le filtre est superflu — la réponse serait dans sa fixture, pas dans la plateforme.
Sur la reproductibilité : la graine RNG = default_rng(7) rend le store déterministe — chaque exécution re-dérive exactement les 240 mêmes vecteurs. Chaque affirmation chiffrée de ce notebook (le taux de fuite, le compte après accident) peut donc être re-vérifiée par quiconque relance les cellules : rien ne repose sur un tirage qui aurait bien tourné une fois. La graine a aussi un second rôle, moins visible : elle rend l’accident de réindexation de la deuxième partie reproductible lui aussi — la victime, les comptes avant/après, tout se rejoue à l’identique, ce qui est la condition pour qu’un rapport d’incident démonstratif vaille preuve.
Le geste attendu : filtrer par environnement
Un retrieval correct, émis depuis un chatbot pointant vers catalogue_public, ne doit chercher que parmi les chunks de catalogue_public. C’est ce que fait le paramètre filtre : il restreint la recherche à un environnement avant le top-k. Sans lui, la recherche se fait sur l’union de tous les environnements.
def retrieve(query, store, k=5, filtre=None):
"""Renvoie les k chunks les plus proches de query (distance L2).
Si filtre=None : cherche dans TOUS les environnements (dangereux).
Sinon : ne cherche que dans l'environnement 'filtre' (contrôle d'accès).
"""
envs_cherches = [filtre] if filtre is not None else list(store.keys())
vecs, labels = [], []
for env in envs_cherches:
for v in store[env]:
vecs.append(v); labels.append(env)
vecs = np.array(vecs)
dists = np.linalg.norm(vecs - query, axis=1)
order = np.argsort(dists)[:k]
return [labels[j] for j in order]
def taux_de_fuite(resultats, environnement_attendu):
"""Fraction des resultats hors de l'environnement attendu."""
if not resultats:
return 0.0
hors = sum(1 for r in resultats if r != environnement_attendu)
return hors / len(resultats)Le contrat de retrieve : la sécurité logée dans un paramètre par défaut
Avant de regarder les résultats, arrêtons-nous sur la signature : retrieve(query, store, k=5, filtre=None). Lire une API du point de vue de la sécurité, c’est d’abord regarder ses défauts — ce qui se passe quand l’appelant fait l’effort minimal. Ici, le chemin sans effort est filtre=None, et le code est explicite : ce chemin balaie tous les environnements. Autrement dit, le comportement dangereux n’est pas une erreur de manipulation à contourner : c’est la valeur par défaut, celle que prend l’appel quand on ne réfléchit pas. Un design sûr par défaut exigerait l’inverse — filtre obligatoire, sans valeur par défaut, l’appelant devant nommer l’environnement qu’il revendique. La documentation (# dangereux) est ici la seule barrière, et une docstring ne filtre rien : elle informe celui qui la lit, elle ne contraint personne.
Deuxième lecture, structurelle : le filtre s’applique avant le classement. La fonction construit vecs uniquement à partir des environnements cherchés, puis fait le top-k sur cet espace réduit. Une implémentation naïve ferait l’inverse — chercher les k plus proches globaux puis retirer ceux du mauvais régime — et cette variante aurait deux défauts : elle rendrait moins de k résultats (les intrus retirés ne sont pas remplacés), et surtout elle aurait classé les chunks par proximité globale avant de trier, laissant la distance décider de ce qui reste. Restreindre avant de classer, c’est ce qui fait du filtre un contrôle d’accès et pas un cosmétique de présentation.
Troisième lecture, instrumentale : la liste labels, remplie en parallèle de vecs, est ce qui rendra la fuite mesurable. Sans elle, on recevrait cinq vecteurs sans provenance, et la question « d’où viennent ces résultats ? » resterait sans instrument. L’instrumentation de la défaillance est ici co-localisée avec la fonction qui peut la causer — un choix de conception à imiter quand on audite une vraie API : la provenance des résultats fait partie du contrat, ou rien ne peut être prouvé. Et une quatrième lecture, transversale : les autres paramètres portent aussi du risque. k est un paramètre d’exposition (la section suivante montre l’intrus entrer au rang exact de k). Et l’argument store lui-même : rien n’empêche de passer un autre store que le sien — la fuite cross-environnement et la fuite cross-store sont le même bug un étage plus haut. Une API de retrieval fait confiance à chacun de ses arguments ; la sécurité ne réside dans aucun d’eux, mais dans ce qui les choisit.
Première défaillance — la fuite cross-environnement
Un visiteur public pose une question. Sa requête est embedée, puis on cherche les 5 chunks les plus proches. Si l’environnement cible n’est pas précisé, le retrieve balaie tout le store.
# Une requête issue du regime catalogue_public (un visiteur cherche une œuvre)
requete = store["catalogue_public"][0]
# retrieve SANS filtre : la recherche balaie les six environnements
top5_sans_filtre = retrieve(requete, store, k=5, filtre=None)
fuite = taux_de_fuite(top5_sans_filtre, "catalogue_public")
print("Top-5 sans filtre :", top5_sans_filtre)
print(f"Taux de fuite : {fuite:.0%} des chunks renvoyés viennent d'un autre régime.")
if fuite > 0:
intrus = [r for r in top5_sans_filtre if r != "catalogue_public"]
print(f" -> un visiteur public reçoit du contenu de : {intrus}")Top-5 sans filtre : ['catalogue_public', 'catalogue_public', 'catalogue_public', 'catalogue_public', 'comite_lecture']
Taux de fuite : 20% des chunks renvoyés viennent d'un autre régime.
-> un visiteur public reçoit du contenu de : ['comite_lecture']
Lire le top-5 : l’intrus entre exactement au rang k
La sortie committée mérite une lecture lente, ligne par ligne :
Top-5 sans filtre : ['catalogue_public', 'catalogue_public',
'catalogue_public', 'catalogue_public', 'comite_lecture']
Taux de fuite : 20%
Quatre chunks publics, puis — au cinquième rang, le dernier demandé — un chunk du comité de lecture. L’intrus ne se glisse pas au milieu du classement : il entre à la frontière de k. C’est structurel : la requête est elle-même un chunk de catalogue_public, donc ses plus proches voisins sont d’abord ses pairs du même nuage ; ce n’est qu’une fois les voisins immédiats épuisés que la zone de chevauchement avec le nuage du comité commence à livrer ses chunks. La conséquence pratique est directe : augmenter k, c’est ouvrir la porte. À k=3, cette requête ne fuirait pas ; à k=10, elle fuirait davantage ; à k=240, elle renverrait tout le store. Le paramètre k, présenté comme un réglage de confort de recherche, est un paramètre d’exposition — et c’est précisément ce que l’exercice 1 demande de mesurer sur une batterie de requêtes.
Il faut aussi lire la nature de la requête, car la fixture prend ici une liberté avec la production : requete = store["catalogue_public"][0] — la requête est un chunk du store, donc un point à l’intérieur du nuage public, entouré de ses pairs. Dans un système réel, la requête est le vecteur d’une question posée par un visiteur : elle est proche de ses réponses, mais pas au cœur d’un nuage préexistant — elle se situe quelque part entre les nuages. Une requête réelle a donc toutes les chances de fuir davantage que ce 20 %, car elle est moins protégée par la densité de pairs autour d’elle. Le chiffre committé est une borne favorable, pas un plafond : la fixture minimise la fuite en choisissant le cas le plus facile à raisonner.
Le chiffre de 20 % doit enfin être lu pour ce qu’il est : une requête, un tirage. Cette requête-ci tombe dans une zone du nuage public encore proche de son centre ; d’autres requêtes, tirées plus profondément dans la zone de chevauchement, ont des voisins comité plus proches que leurs voisins publics — leur fuite sera plus forte. Une métrique de production ne retient ni un best case ni un tirage isolé : elle résume une distribution (moyenne et pire cas). Le singleton de cette cellule démontre le mécanisme ; la batterie de l’exercice mesure l’ampleur.
Lecture — la fuite vient des œuvres communes
Le taux de fuite n’est pas un artefact de géométrie trop lâche : il vient de ce que catalogue_public et comite_lecture parlent des même œuvres. Leurs vecteurs sont légitimement proches, donc le plus proche voisin d’un chunk public peut être un chunk du comité. La distance vectorielle, à elle seule, ne sait rien du régime d’accès — elle ne mesure que la proximité sémantique. C’est exactement le cas où un filtre explicite est indispensable.
# Même requête, cette fois AVEC le filtre attendu
top5_avec_filtre = retrieve(requete, store, k=5, filtre="catalogue_public")
fuite_f = taux_de_fuite(top5_avec_filtre, "catalogue_public")
print("Top-5 avec filtre 'catalogue_public' :", top5_avec_filtre)
print(f"Taux de fuite : {fuite_f:.0%}")
print()
print("--- Comparaison ---")
print(f" sans filtre : {fuite:.0%} de fuite (contenu réservé livré au public)")
print(f" avec filtre : {fuite_f:.0%} de fuite (recherche scopée au régime attendu)")Top-5 avec filtre 'catalogue_public' : ['catalogue_public', 'catalogue_public', 'catalogue_public', 'catalogue_public', 'catalogue_public']
Taux de fuite : 0%
--- Comparaison ---
sans filtre : 20% de fuite (contenu réservé livré au public)
avec filtre : 0% de fuite (recherche scopée au régime attendu)
0 % n’est pas une amélioration du retrieve — c’est un autre retrieve
La comparaison finale condense tout en deux lignes : sans filtre : 20% de fuite, avec filtre : 0%. La tentation est de lire « le filtre améliore la recherche ». La lecture exacte est plus forte et plus dérangeante : ce ne sont pas deux qualités d’une même recherche, ce sont deux recherches différentes. Même requête, mais deux espaces : 240 chunks d’un côté, 40 de l’autre. Le top-5 filtré n’est pas le top-5 non filtré débarrassé de son intrus — les cinq chunks retenus sont les meilleurs parmi le régime seul, un classement qui n’existait pas dans la recherche globale. Le filtre ne corrige pas un défaut du retrieve ; il change la question posée (« les chunks les plus proches que ce visiteur a le droit de voir »).
Cela fixe ce que le filtre ne fait pas, et c’est le point le plus important de cette section : filtre est un argument comme un autre. N’importe quel appelant peut écrire retrieve(requete, store, k=5, filtre="comite_lecture") — la fonction fait confiance à l’appelant sur la légitimité du régime demandé. Le contrôle d’accès réel vit donc en amont : dans ce qui décide de la valeur de filtre pour un visiteur donné (la configuration du chatbot, la session, les droits de l’utilisateur authentifié). C’est la distinction entre le persona et l’autorité : un prompt système peut prier le chatbot de rester public, une partition correctement câblée l’y contraint — et réciproquement, une partition correcte câblée sur le mauvais filtre contraint le chatbot… à fuir dans l’autre sens. La bonne question d’audit n’est pas « y a-t-il un filtre ? » mais « qui choisit la valeur du filtre, et au nom de quoi ».
Cette question a un test opérationnel, et il se joue côté serveur : vérifier que la valeur de filtre vient d’une configuration liée à l’identité authentifiée (le chatbot public est câblé sur catalogue_public, point final), et non d’un paramètre que la requête cliente peut porter — car un paramètre que le client choisit n’est pas un contrôle d’accès, c’est une suggestion. Le test négatif est décisif : appeler l’API en tant que visiteur public en demandant filtre="comite_lecture" ; si l’API obéit, le filtre n’est pas une frontière — il est un bouton dans la main de celui qu’il devait arrêter. Un contrôle d’accès qui ne refuse jamais n’est pas silencieux : il est absent.
Lecture — le filtre ramène la fuite à 0 %
Le filtre ramène le taux de fuite à 0% — non pas en rapprochant les vecteurs, mais en restreignant l’espace de recherche avant le top-k. C’est bien un contrôle d’accès : la question n’est pas « ce chunk est-il sémantiquement pertinent ? » mais « ce visiteur a-t-il le droit de le voir ? ». La distance vectorielle répond à la première ; seule la partition par environnement répond à la seconde.
Deuxième défaillance — l’accident de réindexation
On veut réindexer un nouveau corpus. La fonction reindexer prend un environnement cible. Si l’oubli de cet argument fait tomber sur un emplacement par défaut, cet emplacement écrase un environnement existant — sans avertissement.
def reindexer(nouveau_corpus, store, environnement=None):
"""Réindexe nouveau_corpus dans l'environnement cible.
DANGER : si environnement=None, écrit dans le premier environnement
du store (emplacement par défaut) et écrase son contenu.
"""
cible = environnement if environnement is not None else next(iter(store))
store[cible] = nouveau_corpus
return cible
# état avant : catalogue_public contient ses 40 chunks d'origine
print("AVANT accident :")
print(f" catalogue_public : {len(store['catalogue_public'])} vecteurs")
print(f" comite_lecture : {len(store['comite_lecture'])} vecteurs")
# on voulait réindexer la vitrine, mais on a oublié l'environnement cible
nouveau_corpus_vitrine = RNG.normal(scale=3.0, size=(12, DIM))
cible_touchée = reindexer(nouveau_corpus_vitrine, store, environnement=None)
print(f"\nreindexer(..., environnement=None) a écrit dans : {cible_touchée}")
print("\nAPRÈS accident :")
for env in ["catalogue_public", "comite_lecture"]:
print(f" {env:18s} : {len(store[env])} vecteurs")AVANT accident :
catalogue_public : 40 vecteurs
comite_lecture : 40 vecteurs
reindexer(..., environnement=None) a écrit dans : catalogue_public
APRÈS accident :
catalogue_public : 12 vecteurs
comite_lecture : 40 vecteurs
Pourquoi la victime est catalogue_public : un accident d’ordre d’insertion
Regardons la sortie comme un rapport d’incident :
AVANT : catalogue_public : 40 vecteurs | comite_lecture : 40 vecteurs
reindexer(..., environnement=None) a écrit dans : catalogue_public
APRÈS : catalogue_public : 12 vecteurs | comite_lecture : 40 vecteurs
L’intention était de réindexer la vitrine ; la vitrine est intacte. La victime est catalogue_public — un tiers qui n’était ni la cible ni l’auteur du geste. Pourquoi lui ? Parce que reindexer écrit dans next(iter(store)) : le premier environnement du dictionnaire, au sens de l’ordre d’insertion Python. Et le premier inséré était catalogue_public… parce qu’il était premier dans la liste ENVIRONNEMENTS de la cellule de setup. La « destination par défaut » n’est pas un choix — c’est un artefact d’ordre de déclaration. Dans une autre installation, le même oubli aurait détruit un autre environnement : la victime est déterminée par un détail de code sans rapport avec l’intention de l’opérateur.
Le mécanisme de destruction lui-même tient en une ligne : store[cible] = nouveau_corpus — une affectation qui remplace la référence à l’ancien tableau. Pas de fusion, pas de version, pas de corbeille : les 40 chunks d’origine cessent d’être atteignables à l’instant de l’affectation, et 28 d’entre eux (40 - 12) n’existent plus nulle part. Aucune erreur, aucun avertissement — la fonction a fait exactement ce qu’on lui a demandé. C’est la définition même de l’accident silencieux : le système ne peut pas signaler ce qu’il ne considère pas comme une faute, et une affectation n’est jamais une faute pour Python. Le signalement doit donc venir d’ailleurs — du comptage avant/après, seul instrument de la cellule suivante.
Ce schéma — une API d’écriture dont un argument absent déclenche une destruction sans diagnostic — est une classe, pas un cas isolé. Le vrai reindexer d’une plateforme vectorielle en donne d’autres instances : une réindexation sans environnement cible écrit « quelque part » (première collection, collection par défaut, celle du contexte) ; une suppression sans condition vide le namespace entier. La signature commune : l’argument par défaut choisit une cible destructrice plutôt que de refuser. Le remède de conception est toujours le même — faire de l’absence d’argument un refus — et c’est exactement la variante que l’exercice 2 demande d’écrire.
Lecture — l’accident silencieux de la réindexation
L’environnement par défaut était catalogue_public. En voulant réindexer la vitrine, on a silencieusement détruit les 40 chunks d’origine du catalogue public, remplacés par les 12 nouveaux vecteurs. Aucun message, aucune erreur — la fonction a fait exactement ce qu’on lui a dit. La perte est immédiate et invisible tant qu’on ne compte pas.
C’est le corollaire opérationnel du parcours : « une réindexation lancée sans préciser l’environnement cible écrase un corpus voisin ». Le comptage avant/après est le seul instrument qui révèle l’accident.
La leçon, mesurée
Les deux défaillances ont la même racine : la partition par environnement est porteuse d’un invariant de sécurité, pas d’une commodité de rangement. Retirer le filtre ou oublier la cible ne dégrade pas la qualité du retrieval — il viole un contrôle d’accès.
Deux métriques suffisent à le surveiller :
- le taux de fuite d’un retrieval (fraction du top-k hors environnement attendu) — il doit être
0%en production ; - le compte de chunks par environnement avant/après une réindexation — toute chute inattendue signale un écrasement.
Sur l’installation observée, ces deux métriques n’étaient instrumentées nulle part : la fuite était invisible (le chatbot répondait, c’est tout), et l’écrasement silencieux (le store acceptait l’écriture). Les rendre explicites est le premier pas pour qu’elles cessent d’arriver.
Exercices
Les trois exercices suivent le notebook : mesurer, protéger, surveiller. Aucun n’est corrigé — à toi de compléter le geste à partir des fonctions déjà définies (retrieve, taux_de_fuite, reindexer).
Exercice 1 — L’effet du top-k sur la fuite
Le taux de fuite dépend de k : plus on demande de chunks, plus on a de chances d’attraper un voisin hors-régime. Calcule le taux de fuite moyen d’une requête catalogue_public sans filtre, pour k = 5, puis k = 10, puis k = 20. Observe-t-on une dégradation ?
Indice : boucle sur une dizaine de requêtes issues de store["catalogue_public"], moyenne les taux de fuite pour chaque k.
# Exercice 1 -- mesurer l'effet du top-k sur le taux de fuite moyen.
# Étape 1 : choisis plusieurs requêtes issues de catalogue_public.
# Étape 2 : pour k dans [5, 10, 20], calcule le taux de fuite moyen (sans filtre).
# resultats = {} # TODO étudiant
passExercice 2 — Un reindexer qui refuse l’ambiguïté
reindexer(..., environnement=None) est la porte ouverte à l’accident. Écris une variante reindexer_sur qui refuse d’écrire quand l’environnement est None, au lieu d’écrire silencieusement dans l’emplacement par défaut. Elle doit signaler le refus sans lever d’erreur qui casserait l’exécution du notebook.
Indice : retourne une valeur sentinelle (par exemple la chaîne "REFUS : environnement non précisé") quand environnement is None, au lieu de toucher au store.
# Exercice 2 -- écrire reindexer_sur(nouveau, store, env) qui refuse env=None.
# Contrat : si env is None, ne rien écrire et retourner un message explicite.
# def reindexer_sur(nouveau_corpus, store, environnement=None):
# ...
# resultat = None # TODO étudiant
passExercice 3 — Un test de non-régression pour l’écrasement
L’accident de réindexation est silencieux : rien ne l’annonce. Écris un test (une assertion simple) qui détecte qu’un environnement a été écrasé : il compare le compte de chunks d’un environnement avant et après une opération, et signale toute chute. Le test doit passer quand l’environnement est intact, et échouer (message clair) s’il a été écrasé.
Indice : assert len(store[env]) == compte_avant, f"{env} écrasé".
# Exercice 3 -- un test qui détecte l'écrasement silencieux d'un environnement.
# Étape 1 : mémorise le compte de chunks de 'archive_privee' avant l'opération.
# Étape 2 : définis assert_pas_ecrase(env, compte_avant) qui lève une AssertionError
# claire si len(store[env]) != compte_avant.
# test = None # TODO étudiant
passProvenance et pour aller plus loin
Ce notebook matérialise le Parcours 2 de livresagites-parcours.md (RAG sur corpus et piège du multi-environnement). Il est le compagnon exécutable de l’assertion « la séparation par environnement est un contrôle d’accès ».
- Parcours 3 (audit d’un serveur MCP) a son propre compagnon :
auditer-un-serveur-mcp.ipynb. - La fixture est synthétique à 100 % (numpy, seed=7) : aucun appel réseau, aucune clé, aucune donnée client. Les noms d’environnements (
catalogue_public,comite_lecture…) sont des rôles d’accès génériques, pas des noms réels ; les chunks sont des vecteurs, pas de la prose. - Le scénario de chevauchement (
catalogue_public↔︎comite_lecture) est volontaire : il modélise le cas réaliste où deux environnements traitent du contenu sémantiquement proche — précisément celui où un filtre d’environnement devient indispensable.
Comment lire ce notebook
Le notebook a deux parties, chacune démontée en quatre temps : la mise en scène (une fixture synthétique), le geste (l’appel de fonction), la sortie (committée, donc vérifiable), la lecture (une section qui relit la sortie ligne à ligne — jamais l’intention). La première partie montre une fuite de lecture : un visiteur public reçoit du contenu réservé. La deuxième montre une destruction d’écriture : une réindexation mal ciblée écrase un corpus voisin. Les deux défaillances sont les deux faces d’une même cause — la partition par environnement est un contrôle d’accès que rien ne protège par défaut.
Deux conventions de lecture, à connaître avant d’aller plus loin. D’abord, chaque chiffre cité dans les interprétations figure dans une sortie committée au-dessus : rien n’est affirmé qui ne soit imprimé par une cellule, et la graine fixe garantit qu’une ré-exécution reproduit ces sorties à l’identique. Ensuite, les sections « Lecture » pratiquent la relecture adverse : elles cherchent ce que la sortie ne dit pas, ou ce qu’un lecteur pressé y lirait de travers (un 20 % favorable, un 0 % qui n’est pas une amélioration, une victime choisie par accident). La méthode du notebook est exactement son sujet : une affirmation sur un système ne vaut que si elle est ancrée dans une sortie que quiconque peut reproduire. Pour une lecture pressée, trois arrêts suffisent : les deux sections Lecture (après chaque défaillance mesurée) puis La leçon, mesurée en fin de parcours. Le reste — la mise en scène, les protocoles, les frontières — approfondit chaque arrêt. Et si une seule phrase devait rester : les deux défaillances de ce notebook n’ont été visibles nulle part sur l’installation réelle qui les a inspirées, parce que aucune sonde ne les mesurait — tout le reste n’est que la mise en œuvre de cette constatation.