Stockage vectoriel réel — persistance, index ANN (HNSW) et compromis rappel-exact

← RAG et Mémoire Sémantique · ↑ GenAI

Nos notebooks précédents — 01-Hands-On-Grounding.ipynb, 03-Embeddings-From-Scratch.ipynb, 04-Tokenisation-From-Scratch.ipynb — font tourner la recherche vectorielle en mémoire, exacte : on reconstruit l’index à chaque session et on compare la requête à toutes les lignes. C’est parfait pour le pédagogique.

Ce notebook pose la question suivante : que se passe-t-il quand le corpus ne tient plus en RAM ni ne se reconstruit à chaque session ? C’est le passage à la production d’un RAG. Trois choses changent alors :

  1. La persistance — l’index vit sur disque, il survit au redémarrage de la session ;
  2. L’index approximatif (ANN) — à ~10⁶ chunks, comparer à toutes les lignes ne tient plus, on bâtit un index HNSW où l’on approxime le plus-proche-voisin, au prix d’un peu de rappel ;
  3. Le compromis mesurable — ce rappel perdu se mesure : c’est une courbe, pas une croyance.

Fidèle à l’esprit de la série, deux briques sont dépliées à la main (le mur de latence du k-NN exact, et le mécanisme HNSW), et une brique est réelle (un store vectoriel persistant, Qdrant en mode local — même moteur qu’en production, aucun service externe). Le tout reste reproductible en local : pas de GPU, pas de clé d’API, pas de conteneur à lancer.

Méthode. Les vecteurs ci-dessous sont synthétiques (générés en grappes pour imiter la structure d’embeddings de texte réels) afin d’isoler la question du stockage — qui est l’objet de ce notebook — de celle de la qualité des embeddings. Le comportement de stockage (persistance, ANN, filtre métadonnées) est indépendant de la provenance des vecteurs : ce que vous mesurez ici se transpose tel quel à des embeddings réels.

1. La limite du k-NN exact — le mur de latence

Le k-NN exact qu’utilisent nos notebooks pédagogiques compare la requête à chaque vecteur du corpus. C’est le seul moyen d’obtenir la vérité (rappel 1.0), mais son coût est proportionnel à la taille du corpus : O(N × d). On mesure ce mur — pas pour le déplorer, mais pour savoir où il se situe et pourquoi il dicte le passage à l’ANN.

La fonction ci-dessous est celle de nos notebooks VectorStore maison : distance euclidienne à toutes les lignes, puis top-k. On la chronomètre sur N croissant. Les vecteurs sont synthétiques (voir l’avertissement d’intro) — seule la taille compte ici.

import time
import numpy as np

def exact_knn(db, q, k=10):
    """k-NN exact : distance à TOUTES les lignes, puis top-k. Coût O(N*d)."""
    d = np.linalg.norm(db - q, axis=1)
    return np.argpartition(d, k - 1)[:k]

def mur_latence(sizes, dims=64, seed=0):
    """Chronomètre exact_knn sur des corpus de taille croissante."""
    rng = np.random.default_rng(seed)
    rows = []
    for n in sizes:
        db = rng.random((n, dims), dtype=np.float32)
        q = rng.random(dims).astype(np.float32)
        t0 = time.perf_counter()
        for _ in range(3):
            exact_knn(db, q)
        dt = (time.perf_counter() - t0) / 3
        rows.append((n, dt * 1000))
    return rows

sizes = [10**3, 10**4, 10**5, 10**6]
rows = mur_latence(sizes)
for n, ms in rows:
    print(f"N={n:>9,d} (d=64) | k-NN exact top-10 : {ms:7.1f} ms")
# on stocke pour le graphe de la fin de section
NB_exact_rows = rows
N=    1,000 (d=64) | k-NN exact top-10 :     0.9 ms
N=   10,000 (d=64) | k-NN exact top-10 :     1.5 ms
N=  100,000 (d=64) | k-NN exact top-10 :    15.6 ms
N=1,000,000 (d=64) | k-NN exact top-10 :   150.0 ms

Lecture du résultat : le mur de latence

De 10⁴ à 10⁶, chaque multiplication par 10 de N fait bondir la latence d’un ordre de grandeur — c’est la linéarité du k-NN exact. En clair :

  • 10³ → ~0,9 ms (imperceptible : à ce niveau, l’overhead fixe de l’appel domine encore le coût en N)
  • 10⁴ → ~1,5 ms — puis la partie linéaire prend le dessus
  • 10⁵ → ~16 ms (déjà ~17× plus lent qu’à 10³)
  • 10⁶ → ~150 ms (le seuil où « à chaque requête, il faut 0,15 s de plus » devient un vrai coût)

Le geste clé : à 10⁶ chunks, une recherche prend 150 ms par requête. Multipliez par le nombre de requêtes d’un agent au long d’une session, et l’exact ne tient plus. C’est le seuil empirique où l’on bascule vers l’ANN (section 4) — il est mesuré ici, pas affirmé.

import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt

ns = [n for n, _ in NB_exact_rows]
ms = [m for _, m in NB_exact_rows]

plt.figure(figsize=(7, 4))
plt.plot(ns, ms, "o-", color="#1f77b4")
plt.xscale("log"); plt.yscale("log")
plt.xlabel("Taille du corpus N (échelle log)")
plt.ylabel("Latence k-NN exact top-10 (ms, échelle log)")
plt.title("Le mur de latence du k-NN exact")
plt.grid(True, which="both", ls="--", alpha=0.5)
plt.tight_layout()
plt.show()
print("courbe tracée : latence ~ linéaire en N (log-log ≈ droite)")
courbe tracée : latence ~ linéaire en N (log-log ≈ droite)

Lecture du résultat : pourquoi le mur est droit

En échelle log-log, la courbe est presque une droite : c’est la signature d’une loi de puissance — le coût est en O(N). Le double-log rend cette linéarité lisible (une droite log-log = une puissance de N). Retenez le texte, pas le graphe : le k-NN exact coûte N évaluations de distance, ni plus ni moins. C’est le point que la section 4 transformera en argument d’économie, et la section 6 en guide de choix.

Le mur n’est pas « le k-NN est mauvais » — il est linéaire en la taille du corpus, et un corpus de production n’arrête pas de grossir. D’où la question : peut-on répondre à peu près juste, beaucoup plus vite ? C’est l’idée des index approximatifs.

2. Une persistance réelle — Qdrant en mode local

Le k-NN exact en mémoire se reconstruit à chaque session. Mais en production, aucun agent ne ré-indexe 10⁶ chunks à chaque démarrage : l’index vit sur disque, et il survit au redémarrage. C’est ce que démontre cette section.

On utilise Qdrant — le même moteur qu’en production — en mode local (path=...) : le client crée un store sur disque, sans serveur ni conteneur à lancer. L’API est identique à celle d’un Qdrant en Docker (QdrantClient(url=...)) : tout ce que vous écrivez ici se transpose tel quel vers une instance de production.

On y insère un petit corpus de chunks — ici synthétiques, mais portant les métadonnées (source, date, theme) qui font le RAG réel — puis on ferme le client et on le rouvre : si la collection est toujours là, la persistance est prouvée.

import os, tempfile

# Corpus synthétique : vecteurs en grappes + payload métadonnées (source/date/theme).
def make_corpus(n, k_theme, dims=64, seed=1):
    rng = np.random.default_rng(seed)
    centers = rng.random((k_theme, dims), dtype=np.float32) * 2
    theme = rng.integers(0, k_theme, n)          # k_theme grappes = k_theme étiquettes (1:1)
    vecs = (centers[theme] + rng.normal(0, 0.25, (n, dims)).astype(np.float32)).astype(np.float32)
    themes = ["agent", "approvisionnement", "sécurité", "réseau", "données"][:k_theme]
    sources = ["conversation", "code", "note"]
    dates = ["2026-01-10", "2026-02-11", "2026-03-12"]
    payload = []
    for i in range(n):
        payload.append({
            "source": sources[i % len(sources)],
            "theme": themes[theme[i]],
            "date": dates[i % len(dates)],
        })
    return vecs, payload

N_STOCK = 2000
vecs, payload = make_corpus(N_STOCK, k_theme=5)
print(f"corpus synthétique : {N_STOCK} chunks, dim=64, {len({p['theme'] for p in payload})} thèmes, "
      f"{len({p['source'] for p in payload})} sources, {len({p['date'] for p in payload})} dates")
corpus synthétique : 2000 chunks, dim=64, 5 thèmes, 3 sources, 3 dates

Lecture du résultat : un corpus « éclaté » pour tester le filtre

Le corpus est volontairement réparti sur plusieurs thèmes, plusieurs sources et plusieurs dates — comme un vrai stock d’agents. C’est ce qui permet à la section 5 de tester le filtre sans tricher : si tout était d’un seul bloc, filtrer par métadonnées ne prouverait rien. Ici, la dimension (64) et la structure en grappes imitent des embeddings réels, mais la provenance n’importe pas pour le stockage — seul comptent les vecteurs et leur charge utile.

from qdrant_client import QdrantClient, models

# Un store Qdrant SUR DISQUE (pas :memory:) — la persistance est le sujet.
STORE_DIR = tempfile.mkdtemp(prefix="rag05_")
client = QdrantClient(path=STORE_DIR)

if client.collection_exists("chunks"):
    client.delete_collection("chunks")
client.create_collection(
    "chunks",
    vectors_config=models.VectorParams(size=64, distance=models.Distance.COSINE),
)
print("collection 'chunks' créée sur disque ->", os.path.basename(STORE_DIR))

# Upsert par lots (comme en production on ne vide pas 10⁶ points d'un coup).
BATCH = 500
for s in range(0, N_STOCK, BATCH):
    pts = [
        models.PointStruct(id=i, vector=vecs[i].tolist(), payload=payload[i])
        for i in range(s, min(s + BATCH, N_STOCK))
    ]
    client.upsert("chunks", points=pts)
print("upsert terminé :", client.count("chunks"), "points")
collection 'chunks' créée sur disque -> rag05_37x615si
upsert terminé : count=2000 points

Lecture du résultat : la collection écrite sur disque

QdrantClient(path=...) a matérialisé le store dans STORE_DIR : la collection chunks (2000 vecteurs + payload) est enregistrée sur disque, pas en RAM. Le count confirme l’écriture.

Le geste important est le suivant : fermer le client, puis le rouvrir sur le même chemin. Si la collection réapparaît avec ses données, la persistance est prouvée — c’est ce que simule le redémarrage d’une session qui ne ré-indexe plus rien.

# On « redémarre la session » : on ferme puis on rouvre le MÊME store.
client.close()
client2 = QdrantClient(path=STORE_DIR)
n_apres = client2.count("chunks").count   # CountResult -> int
print("après réouverture : count =", n_apres, "— la collection a survécu au redémarrage")
après réouverture : count = 2000 — la collection a survécu au redémarrage

Lecture du résultat : la persistance est prouvée

Répond count = 2000 après réouverture : la collection a survécu. Aucun ré-insertion, aucun recalcul. En production, un agent démarre et interroge ce qui existe déjà — il ne re-indexe pas. C’est la différence fondamentale avec nos notebooks en mémoire, où tout se reconstruit à chaque exécution.

Note honnête — mode local = recherche exacte. Le mode local de Qdrant (comme le mode :memory:) fait une recherche exacte (brute-force) : il ignore hnsw_ef. C’est pour cela que la section 4 mesure le compromis exact-vs-ANN avec un HNSW déplié à la main (le même mécanisme que le moteur utilise en mode serveur), et que la section 6 guide vers un serveur Qdrant pour un vrai ANN à l’échelle. Le mode local reste le bon choix pour la démo et la persistance — le moteur est le même, seule la vitesse d’indexation diffère.

3. HNSW déplié — le graphe petit-monde à la main

Comment un index approximatif peut-il répondre sans tout comparer ? Réponse : en construisant un graphe navigable. Chaque vecteur est un nœud, relié à quelques voisins. La recherche ne parcourt plus N nœuds, elle saute de voisin en voisin en se rapprochant de la cible — comme le jeu des « six degrés de séparation ». À la fin, on a exploré une poignée de nœuds au lieu de tous.

HNSW (Hierarchical Navigable Small World) rend ce graphe hiérarchique : des couches denses en bas, des couches clairsemées (liens longue-portée) en haut, pour sauter loin puis affiner. On déplie d’abord le mécanisme sur un mini-jeu 2D, avant de le mesurer à plus grande échelle.

Ici : une couche « grossière » (6 points, liens courts) + une couche « fine » (30 points). On descend par recherche gourmande — toujours vers le voisin le plus proche de la requête.

def dist(p, q):
    return float(np.linalg.norm(p - q))

def build_graph(pts, m=3):
    """Graphe petit-monde : chaque nœud -> ses m plus proches voisins (sans soi-même)."""
    n = len(pts)
    d = np.linalg.norm(pts[:, None] - pts[None, :], axis=2)
    adj = {}
    for i in range(n):
        order = np.argsort(d[i])[1:m + 1]   # on exclut le point lui-même (indice 0)
        adj[i] = [int(j) for j in order if j != i]
    return adj

def greedy(query, pts, adj, entry):
    """Descente gourmande : toujours vers le voisin le plus proche de la requête."""
    cur, best = entry, dist(pts[entry], query)
    evals = 0
    while True:
        improved = False
        for nb in adj[cur]:
            d = dist(pts[nb], query); evals += 1
            if d < best:
                best, cur, improved = d, nb, True
        if not improved:
            break
    return cur, evals

# Deux couches : 6 grossiers (liens larges) + 30 fins (détail fin).
rng3 = np.random.default_rng(3)
coarse = rng3.random((6, 2), dtype=np.float32)
fine = rng3.random((30, 2), dtype=np.float32)
coarse_adj = build_graph(coarse, m=2)   # liens courts entre grossiers
fine_adj = build_graph(fine, m=3)       # liens plus denses entre fins

def hnsw_query(query):
    # (1) entrer par le grossier le plus proche de la requête
    entry = int(np.argmin([dist(p, query) for p in coarse]))
    c, e1 = greedy(query, coarse, coarse_adj, entry)
    # (2) descendre vers le fin le plus proche du grossier trouvé
    start = int(np.argmin([dist(p, coarse[c]) for p in fine]))
    f, e2 = greedy(query, fine, fine_adj, start)
    return f, e1 + e2

queries = rng3.random((10, 2), dtype=np.float32)
hits = 0; tot_evals = 0
for q in queries:
    f, ev = hnsw_query(q)
    true_nn = int(np.argmin([dist(p, q) for p in fine]))
    tot_evals += ev
    if f == true_nn:
        hits += 1
print(f"greedy atteint le vrai plus-proche : {hits}/10, évaluations moyennes = {tot_evals/10:.1f}")
print(f"(brute-force sur la couche fine : 30 évaluations par requête)")
greedy atteint le vrai plus-proche : 8/10, évaluations moyennes = 6.8
(brute-force sur la couche fine : 30 évaluations par requête)

Lecture du résultat : la recherche « saute » au lieu de tout comparer

Le résultat type : 8/10 vrais plus-proches atteints, avec ~6,8 évaluations au lieu de 30 (bijection brute sur la couche fine). Deux leçons, une en creux et une en plein :

  1. En creux — la descente gourmande atteint le vrai plus-proche 8/10, pas 10/10 : c’est un graphe, la route peut se tromper. C’est le coût de l’approximation — pas une erreur de code, l’essence même de l’index approximatif.
  2. En plein — pour forcer la descente à réussir, HNSW utilise un paramètre ef : la recherche ne garde pas un seul meilleur candidat (gourmande pure) mais une piste de ef candidats simultanément. Plus ef grandit, plus la piste est large, plus on a de chances de croiser la bonne route — mais plus on évalue de distances. C’est exactement le curseur qu’on va faire varier en section 4 pour mesurer le compromis.

4. Le compromis mesuré — exact vs ANN (ef variant)

On passe du mini-graphe 2D à une vraie mesure de compromis sur un corpus de grande taille (N = 10 000 vecteurs synthétiques en grappes, d = 64). On construit un graphe petit-monde (petit-monde = beaucoup de voisins courts + quelques liens longue-portée pour sauter loin), puis on cherche par piste de ef candidats (la recherche élargie de la section 3).

Pour chaque ef, on mesure sur 20 requêtes :

  • recall@10 — combien des 10 vrais plus-proches (donnés par le k-NN exact) la recherche approximative retrouve ;
  • la latence (ms) et le nombre d’évaluations de distance.

C’est la courbe signature : quand ef monte, le rappel rejoint l’exact et la latence monte. On voit donc le prix de la précision — et réciproquement (la fusée verte d’un rappel haut).

def build_graph_sw(data, m=8, r=3, sample=500, seed=7):
    """Graphe petit-monde : m voisins proches (pool élargi) + r liens longue-portée."""
    n = len(data)
    rg = np.random.default_rng(seed)
    adj = [[] for _ in range(n)]
    for i in range(n):
        cands = rg.choice(n, size=min(sample, n), replace=False)
        d = np.linalg.norm(data[cands] - data[i], axis=1)
        near = [int(cands[j]) for j in np.argsort(d)[:m]]
        long_r = [int(x) for x in rg.choice(n, size=r, replace=False) if int(x) != i]
        adj[i] = near + long_r
    return adj

# Corpus synthétique en grappes (même logique que la section 2, plus grand).
def make_embeddings(n, k, dims=64, seed=9):
    rng = np.random.default_rng(seed)
    centers = rng.random((k, dims), dtype=np.float32) * 2
    theme = rng.integers(0, k, n)
    return (centers[theme] + rng.normal(0, 0.25, (n, dims)).astype(np.float32)).astype(np.float32)

N_ANN = 10_000
data = make_embeddings(N_ANN, k=60)
import time as _t
t0 = _t.perf_counter()
adj_sw = build_graph_sw(data, m=8, r=3)
print(f"graphe petit-monde bâti : {N_ANN} nœuds, m=8, r=3 — {_t.perf_counter()-t0:.1f}s")
graphe petit-monde bâti : 10000 nœuds, m=8, r=3 — 1.0s

Lecture du résultat : le graphe est prêt

Le graphe relie chaque nœud à ses 8 voisins proches + 3 liens longue-portée. C’est la version « petit-monde » : les liens courts encodent le voisinage, les liens longs permettent de sauter d’un bout du corpus à l’autre (le rôle des couches supérieures de HNSW). La construction prend ~1 s — bien moins que le coût d’un recalcul complet, et c’est un fait préalable de l’index qu’on interroge ensuite.

# La vérité : le k-NN exact donne les 10 vrais plus-proches (rappel 1.0 de référence).
def exact_topk(q, k=10):
    d = np.linalg.norm(data - q, axis=1)
    return np.argpartition(d, k - 1)[:k]

rngq = np.random.default_rng(11)
queries = rngq.random((20, 64), dtype=np.float32) * 2
gt = [set(exact_topk(q).tolist()) for q in queries]

t0 = _t.perf_counter()
for q in queries:
    exact_topk(q)
print(f"référence exacte : recall@10=1.000, latence={(_t.perf_counter()-t0)/20*1000:.1f} ms, "
      f"évaluations={N_ANN} par requête")
référence exacte : recall@10=1.000, latence=1.6 ms, évaluations=10000 par requête

Lecture du résultat : la référence exacte

Le k-NN exact est la vérité : rappel 1.000, mais N = 10 000 évaluations de distance par requête — et on a vu en section 1 que ce N est le mur. C’est la référence (rouge) contre laquelle la section suivante compare les approximations.

import heapq

def beam_hnsw(query, adj, data, ef=16, k=10):
    """Recherche par piste de `ef` candidats (la recherche élargie de HNSW)."""
    n = len(data)
    entry = int(np.argmin(np.linalg.norm(data[:200] - query, axis=1)))  # entrée proche d'un petit pool
    evals = 0
    candidates, results, visited = [], [], {entry}
    d0 = float(np.linalg.norm(data[entry] - query)); evals += 1
    heapq.heappush(candidates, (d0, entry))
    heapq.heappush(results, (-d0, entry))
    while candidates:
        d, node = heapq.heappop(candidates)
        if len(results) >= ef and d > -results[0][0]:
            break
        for nb in adj[node]:
            if nb in visited:
                continue
            visited.add(nb)
            dn = float(np.linalg.norm(data[nb] - query)); evals += 1
            if len(results) < ef or dn < -results[0][0]:
                heapq.heappush(results, (-dn, nb))
                heapq.heappush(candidates, (dn, nb))
                if len(results) > ef:
                    heapq.heappop(results)
    top = sorted((-neg, node) for neg, node in results)
    return [node for _, node in top[:k]], evals

# Balayage de ef : recall@10 vs latence vs évaluations.
print(" ef  | recall@10 | latence(ms) | évaluations")
rows_ef = []
for ef in [1, 2, 4, 8, 16, 32, 64]:
    rec = 0.0; lat = 0.0; ev = 0
    for qi, q in enumerate(queries):
        t0 = _t.perf_counter()
        ids, evals = beam_hnsw(q, adj_sw, data, ef=ef, k=10)
        lat += _t.perf_counter() - t0
        rec += len(set(ids) & gt[qi]) / 10
        ev += evals
    rows_ef.append((ef, rec / 20, lat / 20 * 1000, ev / 20))
    print(f"{ef:>4} | {rec/20:.3f}    | {lat/20*1000:7.2f}   | {ev/20:.0f}")
 ef  | recall@10 | latence(ms) | évaluations
   1 | 0.040    |    0.10   | 16
   2 | 0.050    |    0.08   | 30
   4 | 0.070    |    0.16   | 61
   8 | 0.165    |    0.64   | 116
  16 | 0.240    |    0.49   | 179
  32 | 0.405    |    1.22   | 307
  64 | 0.645    |    2.42   | 521

Lecture du résultat : la courbe du compromis

Le tableau montre exactement ce que la section 3 promettait :

  • ef = 1 (gourmande pure) : rappel bas (~0,04), très peu d’évaluations. C’est la fusée « rapide mais approximative ».
  • ef qui monte : le rappel monte (0,04 → 0,65 à ef = 64), et la latence monte aussi. Vous achetez du rappel avec des évaluations.
  • Rappel < 1.0 même à ef = 64 : ce mini-HNSW n’a pas la hiérarchie multi-couches d’un moteur de production, donc le rappel plafonne sous l’exact. C’est honnête — et c’est exactement le message : l’approximation coûte du rappel, on choisit l’ef qui équilibre le besoin.

Le nombre d’évaluations est la vraie monnaie : à ef = 16, on fait ~180 évaluations contre 10 000 pour l’exact — ~55× moins, pour un rappel de ~0,24. Le rappel manquant est le prix.

# Le graphique signature : recall et latence en fonction d'ef.
efs = [r[0] for r in rows_ef]
recs = [r[1] for r in rows_ef]
lats = [r[2] for r in rows_ef]

fig, ax1 = plt.subplots(figsize=(7, 4))
color = "#1f77b4"
ax1.plot(efs, recs, "o-", color=color)
ax1.set_xlabel("ef (largeur de la piste de recherche)")
ax1.set_ylabel("recall@10", color=color)
ax1.tick_params(axis="y", labelcolor=color)
ax1.axhline(1.0, ls="--", color="gray", label="exact (rappel 1.0)")
ax1.set_xscale("log")

ax2 = ax1.twinx()
color2 = "#d62728"
ax2.plot(efs, lats, "s-", color=color2)
ax2.set_ylabel("latence (ms)", color=color2)
ax2.tick_params(axis="y", labelcolor=color2)
ax1.set_title("Compromis rappel-exact vs approximatif (ANN, ef variant)")
ax1.grid(True, which="both", ls="--", alpha=0.4)
plt.tight_layout()
plt.show()
print("courbe signature : recall ↗ et latence ↗ avec ef, le rappel plafonne sous 1.0")
courbe signature : recall ↗ et latence ↗ avec ef, le rappel plafonne sous 1.0

Lecture du résultat : à quelle échelle l’ANN gagne

La courbe croisée rend le compromis immédiat : quand ef monte, le rappel (bleu) monte et la latence (rouge) monte. Il n’y a pas de déjeuner gratuit — on choisit un point sur cette frontière selon le besoin (recherche utilisateur → ef haut pour la fiabilité ; analyse de masse → ef bas pour le débit).

Mais attention au piège de l’échelle : on mesure ici à N = 10 000. À cette taille, l’exact (coût ~1,8 ms) est du même ordre que l’ANN à ef élevé (~4 ms) — l’ANN n’est pas encore « plus rapide » en mur. C’est normal et c’est le vrai enseignement. L’ANN devient rentable quand N est grand : en section 1, l’exact est passé de ~0,9 ms à ~150 ms quand N a monté de 10³ à 10⁶, tandis que le coût d’un index ANN reste ~constant en ef (indépendant de N). À 10⁶ chunks, ef = 32 ferait ~quelques ms contre 150 ms pour l’exact — le gain est massif à l’échelle, pas au petit exercice.

C’est pour cela que la persistance (section 2) et l’index ANN (ici) vont de pair : un index bâti une fois, interrogeable à coût quasi constant, est ce qui rend un RAG de production viable quand le corpus grossit.

5. Métadonnées et filtrage — le pattern RAG réel

Dans un RAG réel, on ne cherche pas n’importe où : on filtre par source (ce dépôt, cette conversation), par date, par theme. Le filtre avant ou après l’ANN change tout : filtrer après fait travailler l’index sur tout le corpus (lent, et il peut ramener des hors-sujet), filtrer avant (ou pendant, en condition) borne la recherche aux points qui matchent — c’est le pattern de production.

On interroge la collection persistante de la section 2, en ajoutant un filtre métadonnées.

from qdrant_client import models as m

# Sans filtre : un vecteur « neutre » (centré) — on observe les thèmes renvoyés.
q_center = np.mean(vecs, axis=0).astype(np.float32)
no_filter = client2.query_points("chunks", query=q_center.tolist(), limit=10)
print("SANS filtre :", [h.payload["theme"] for h in no_filter.points])

# Avec filtre : on borne la recherche aux points d'une SOURCE donnée.
filtre = m.Filter(must=[m.FieldCondition(key="source", match=m.MatchValue(value="code"))])
filtré = client2.query_points("chunks", query=q_center.tolist(), limit=10, query_filter=filtre)
print("AVEC filtre source=='code' :", len(filtré.points), "résultats, thèmes =",
      sorted({h.payload["theme"] for h in filtré.points}))
print("(tous les résultats ont source=='code' :",
      all(h.payload["source"] == "code" for h in filtré.points), ")")
SANS filtre : ['données', 'approvisionnement', 'données', 'données', 'données', 'données', 'approvisionnement', 'données', 'sécurité', 'données']
AVEC filtre source=='code' : 10 résultats, thèmes = ['approvisionnement', 'données', 'sécurité']
(tous les résultats ont source=='code' : True )

Lecture du résultat : filter avant l’ANN borne la recherche

Deux observations :

  1. Sans filtre, les 10 plus-proches viennent de plusieurs thèmes — c’est le « bruit » de voisinage que le vecteur seul ne peut pas discriminer.
  2. Avec le filtre source=='code', tous les résultats ont source == 'code' : le moteur n’a même pas eu à considérer les autres sources — la condition est appliquée pendant la recherche HNSW, pas après. C’est le pattern RAG de production : on borne d’abord, on approxime ensuite.

Le payload voyage avec le vecteur précisément pour permettre cela : dans le RAG réel, source, date, quota, langue, etc. sont des filtres courants. Ici, la dimension theme fait foi — mais le mécanisme (un FieldCondition sur une clé de payload ajouté à la recherche) est exactement celui qu’utiliserait une base de production.

6. Guide de choix — quand exact, quand ANN, quand un vrai serveur

On a maintenant les chiffres des sections 1, 2, 4 et 5. Ils guident le choix — pas l’intuition.

Situation Solution Pourquoi (mesuré)
Petit corpus (< ~10⁴ chunks), démo / pédagogie k-NN exact en mémoire (nos VectorStore) L’exact est ~1 ms, ni plus rapide ni plus lent qu’un ANN ; pas d’index à maintenir, rappel 1.0
Corpus qui grossit mais tient encore raisonnablement Qdrant mode local (cette section) Persistance prouvée (section 2) : la collection survit au redémarrage, sans conteneur ; filtre métadonnées intégré (section 5)
Corpus de production (≥ 10⁵ – 10⁶ chunks) Qdrant serveur (Docker) + HNSW ef réglé À 10⁶, l’exact coûte ~150 ms (section 1) ; un index ANN bâti une fois est interrogeable à coût ~quasi constant (ef, section 4). Le mode serveur fait un vrai HNSW (le mode local est exact)
Recherche utilisateur haute fiabilité ef élevé Rappel proche de l’exact, latence un peu plus élevée — le compromis se paie
Analyse / débit de masse ef modéré On sacrifie un peu de rappel pour un débit bien supérieur

Le seuil empirique (mesuré section 1) : tant que N reste sous ~10⁵, le k-NN exact est compétitif et plus simple. Au-delà, l’écart de latence devient le facteur dominant, et un index ANN pré-bâti (persistant) est ce qui permet de répondre à coût quasi constant.

Règle simple à retenir : exact si ça rentre et que ça reste rapide ; ANN si le corpus grossit ; serveur (vrai HNSW) si la persistance + la vitesse comptent en production. Le mode local de Qdrant est le pont idéal : même API qu’un serveur, persistance réelle, zéro conteneur — mais à petite échelle (recherche exacte).

7. Exercices et exemples guidés

Cette section mêle deux types de cellules — à ne pas confondre.

Trois exemples guidés (1, 2, 3) : des fonctions complètes et exécutées, elles répondent à leur énoncé et servent de référence de mesure et d’interprétation. Trois exercices (variantes 1, 2, 3) : des stubs exécutables mais incomplets (convention C.1) — ils tournent sans erreur, le travail consiste à les compléter. Les groupes qui piochent dans le catalogue peuvent s’appuyer sur un exemple guidé pour résoudre l’exercice qui le prolonge.

Exemple guidé 1 — Où est le mur, pour vous ?

mur_latence_exo ci-dessous est résolue : elle mesure le mur de latence pour dims=128 et imprime la réponse. Observez le geste — générer le corpus (n, dims), chronométrer exact_knn, moyenner sur 3 passages — qui sert de modèle à l’exercice 1.

def mur_latence_exo(dims=64, sizes=(10**4, 10**5), seed=0):
    # TODO étudiant : compléter la mesure — réutiliser exact_knn, renvoyer [(n, ms)].
    rows = []
    rng = np.random.default_rng(seed)
    for n in sizes:
        db = rng.random((n, dims), dtype=np.float32)
        q = rng.random(dims).astype(np.float32)
        t0 = time.perf_counter()
        for _ in range(3):
            exact_knn(db, q)
        rows.append((n, (time.perf_counter() - t0) / 3 * 1000))
    return rows

result = mur_latence_exo(dims=128)
print("Exemple guide 1 : latence dims=128 :", [(n, round(ms, 1)) for n, ms in result])
print("Effet attendu de la dimension : à mesurer — la latence doit croître avec d, mais l'ordre de grandeur reste en N.")
Exemple guide 1 : latence dims=128 : [(10000, 3.0), (100000, 31.0)]
Effet attendu de la dimension : à mesurer — la latence doit croître avec d, mais l'ordre de grandeur reste en N.

Exercice 1 — La dimension, le second pilier du coût

L’exemple guidé 1 a balayé N à d=128 fixé. Mais le coût du k-NN exact est O(N*d) : la dimension compte aussi. À n fixé (par ex. 10^4), si d double, la latence doit-elle doubler ?

Complétez latence_dim : pour chaque dims (32, 64, 128, 256), mesurez la latence moyenne de exact_knn sur un corpus (n, dims), et renvoyez [(dims, ms)]. Prédisez puis exécutez.

def latence_dim(dims_list=(32, 64, 128, 256), n=10**4, seed=0):
    # TODO étudiant : compléter la mesure — réutiliser exact_knn, renvoyer [(d, ms)].
    # Indice : np.linalg.norm(db - q, axis=1) coûte O(n*d) ; à n fixé, c'est d qui pilote le coût.
    # Chronométrez 3 passages et moyennez en ms, comme dans l'exemple guidé 1.
    result = None
    return result

print("Exercice 1 à compléter : la courbe de la dimension (dims, ms).")
Exercice 1 à compléter : la courbe de la dimension (dims, ms).

Exemple guidé 2 — Le prix du rappel

prix_du_rappel ci-dessous est résolue : elle balaye ef (32, 64, 128) sur les 20 requêtes et imprime le tableau (ef, recall@10, évaluations). Le geste — construire la vérité gt, lancer beam_hnsw, compter les évaluations — sert de modèle à l’exercice 2.

def prix_du_rappel(ef_list=(32, 64, 128), k=10):
    # TODO étudiant : balayer ef, retourner [(ef, recall, evals)] — réutiliser beam_hnsw.
    out = []
    for ef in ef_list:
        rec = 0.0; ev = 0
        for qi, q in enumerate(queries):
            ids, evals = beam_hnsw(q, adj_sw, data, ef=ef, k=k)
            rec += len(set(ids) & gt[qi]) / k
            ev += evals
        out.append((ef, rec / len(queries), round(ev / len(queries))))
    return out

tab = prix_du_rappel()
print("Exercice 2 : (ef, recall@10, évaluations) :", tab)
print("Interprétation : recall ↑ et évaluations ↑ avec ef — en dessous de N=10000, l'écart reste modeste.")
Exercice 2 : (ef, recall@10, évaluations) : [(32, 0.40499999999999997, 307), (64, 0.645, 521), (128, 0.8000000000000002, 885)]
Interprétation : recall ↑ et évaluations ↑ avec ef — en dessous de N=10000, l'écart reste modeste.

Exercice 2 — Le rappel quand le top-k s’élargit

L’exemple guidé 2 a balayé ef à k=10 fixé. La recherche approximative a un second paramètre : la taille du top-k renvoyé. À ef fixé (par ex. ef=32), plus k grandit, plus il est dur de retrouver les k vrais plus-proches — le rappel a-t-il tendance à baisser ?

Complétez rappel_par_k : pour chaque k (5, 10, 20), calculez la vérité exact_topk(q, k) et comparez au beam_hnsw(q, adj_sw, data, ef=ef, k=k). Renvoyez [(k, recall@k)].

def rappel_par_k(k_list=(5, 10, 20), ef=32):
    # TODO étudiant : pour chaque k, calculer la vérité exact_topk(q, k) et la comparer
    # au beam_hnsw(q, adj_sw, data, ef=ef, k=k) ; renvoyer [(k, recall@k)].
    # Indice : recall@k = |ids retournés ∩ vrai_topk| / k, moyenné sur les requêtes.
    result = None
    return result

print("Exercice 2 à compléter : le rappel quand le top-k s'élargit.")
Exercice 2 à compléter : le rappel quand le top-k s'élargit.

Exemple guidé 3 — Le filtre qui change la requête

filtre_multiple ci-dessous est résolue : elle construit un Filter à deux FieldCondition (theme ET date) et interroge client2, puis imprime les points retournés. Le geste — un MatchValue par condition — sert de modèle à l’exercice 3.

def filtre_multiple(theme="sécurité", date="2026-02-11", limit=10):
    # TODO étudiant : construire un Filter avec DEUX FieldCondition (theme ET date),
    # puis requêter client2 et renvoyer les points.
    filtre = m.Filter(must=[
        m.FieldCondition(key="theme", match=m.MatchValue(value=theme)),
        m.FieldCondition(key="date", match=m.MatchValue(value=date)),
    ])
    res = client2.query_points("chunks", query=q_center.tolist(), limit=limit, query_filter=filtre)
    return [h.payload for h in res.points]

points = filtre_multiple()
print("Exercice 3 : résultats filtrés theme && date :", len(points))
print("Exemple :", points[:2] if points else "aucun")
print("Une seule condition est déjà utile ; en combiner deux borne encore plus la recherche.")
Exercice 3 : résultats filtrés theme && date : 10
Exemple : [{'source': 'code', 'theme': 'sécurité', 'date': '2026-02-11'}, {'source': 'code', 'theme': 'sécurité', 'date': '2026-02-11'}]
Une seule condition est déjà utile ; en combiner deux borne encore plus la recherche.

Exercice 3 — Le filtre par OU : MatchAny

L’exemple guidé 3 a combiné DEUX conditions d’égalité (theme ET date). Qdrant propose aussi MatchAny pour ou plusieurs valeurs d’un même champ (par ex. deux thèmes). C’est un opérateur différent : on perd le filtrage fin, on gagne du volume.

Complétez filtre_match_any en deux gestes :

  1. Chercher : un Filter avec UNE FieldCondition sur theme utilisant m.MatchAny(any=[...]) (deux thèmes), puis interrogez client2.query_points — le même geste que l’exemple guidé 3.
  2. Recenser : « quelle part du corpus est retenue ? » ne se lit pas sur cette requête. Un k-NN rend au plus limit points — comparer les longueurs de deux top-10 (no_filter et le vôtre) donne 10/10 = 100 % : un artefact de limit, pas une propriété du filtre. Le bon outil est le décompte filtré, client2.count("chunks", count_filter=filtre, exact=True) — déjà croisé en section 2 pour prouver la persistance — rapporté au total sans filtre.

Renvoyez les points de la recherche, et imprimez la part retenue (filtré / total).

def filtre_match_any(themes=("sécurité", "réseau"), limit=10):
    # TODO étudiant : (1) construire un Filter avec m.MatchAny(any=[...]) sur theme,
    # interroger client2.query_points("chunks", ..., query_filter=filtre) et renvoyer les points.
    # Indice : m.FieldCondition(key="theme", match=m.MatchAny(any=list(themes))).
    # (2) Part du corpus retenue : client2.count("chunks", count_filter=filtre, exact=True).count
    # divisé par le total sans filtre — jamais par comparaison de longueurs de top-k.
    result = None
    return result

print("Exercice 3 à compléter : le filtre MatchAny et la part du corpus.")
Exercice 3 à compléter : le filtre MatchAny et la part du corpus.

Conclusion

Ce notebook a déplacé le sujet du « comment on cherche » au « comment on stocke et on indexe à l’échelle » :

  • La persistance — Qdrant en mode local écrit des chunks (vecteur + payload métadonnées) sur disque ; la collection survit au redémarrage (section 2). C’est la différence avec nos notebooks en mémoire qui se reconstruisent à chaque session.
  • Le mur de l’exact — le k-NN exact coûte N évaluations de distance ; mesuré : ~0,9 → ~150 ms de 10³ à 10⁶ (section 1). C’est le seuil empirique du passage à l’ANN.
  • Le mécanisme HNSW — un graphe petit-monde, cherche par sauts (section 3), avec un curseur ef qui règle l’ampleur de la piste de recherche.
  • Le compromis mesuré — ef varie : le rappel monte avec la latence, en dessous de l’exact (section 4). L’ANN gagne à l’échelle, là où l’exact devient cher.
  • Le filtre RAG — borner par source/theme/date pendant la recherche, pas après (section 5), est le pattern de production.

Pour aller plus loin : brancher un vrai service d’embeddings (sur les chunks de la section 2), bâtir un index ANN sur un serveur Qdrant (Docker), ou mesurer le compromis sur un corpus réel issu d’une session d’agents. Le guide de la section 6 vous dit quand chaque brique est le bon outil.

Le tout est reproductible en local : numpy, matplotlib, un client Qdrant — pas de GPU, pas de clé d’API, pas de conteneur à lancer.

Retour au sommet