Lesson 3 — Construire le moteur

Un index local persistant : .npy + sqlite

5 min read

This content is not translated yet. Showing the original version.

Un index local persistant : .npy + sqlite

Pourquoi persister ?

Calculer un embedding coûte un appel réseau et un peu d'argent. Une fiche qui n'a pas changé a toujours le même vecteur : le recalculer à chaque démarrage est du gaspillage. On sépare deux choses :

  • les vecteurs : une matrice (60, 768) de nombres, stockée en binaire numpy (vecteurs.npy), chargée en une milliseconde ;
  • les métadonnées : nom, catégorie, prix, description, dans une table sqlite (produits.sqlite), une base de données dans un seul fichier, incluse dans Python.

La colonne ligne fait le lien : la fiche ligne = 12 correspond à vecteurs[12].

flowchart LR
  J["produits.json"] --> H["empreinte = sha256(modèle + dim + texte)"]
  H --> Q{"Déjà dans l'index ?"}
  Q -- "oui" --> R["Réutiliser l'ancien vecteur"]
  Q -- "non" --> E["Appeler gemini-embedding-2"]
  R --> M["Matrice (60, 768)"]
  E --> M
  M --> N[("vecteurs.npy")]
  J --> S[("produits.sqlite : ligne, id, nom, ..., empreinte")]

Le code

files/moteur.py (extrait)
def empreinte(texte: str) -> str:
    return hashlib.sha256(f"{EMBED_MODEL}|{EMBED_DIM}|{texte}".encode()).hexdigest()[:16]


def indexer(produits, embed, dossier=DOSSIER) -> int:
    os.makedirs(dossier, exist_ok=True)
    anciens = {}
    if os.path.exists(os.path.join(dossier, "vecteurs.npy")):
        v, ps = charger_index(dossier)
        anciens = {p["empreinte"]: v[p["ligne"]] for p in ps}
    textes = [texte_document(p) for p in produits]
    empreintes = [empreinte(t) for t in textes]
    a_calculer = [i for i, e in enumerate(empreintes) if e not in anciens]
    nouveaux = dict(zip(a_calculer, embed([textes[i] for i in a_calculer]))) if a_calculer else {}
    vecteurs = np.stack([nouveaux[i] if i in nouveaux else anciens[e]
                         for i, e in enumerate(empreintes)]).astype(np.float32)
    np.save(os.path.join(dossier, "vecteurs.npy"), vecteurs)
    ...  # réécriture de la table produits avec la colonne empreinte
    return len(a_calculer)

Le modèle et la dimension font partie de l'empreinte : si vous passez de 768 à 1536 dimensions, toutes les fiches sont recalculées, ce qui évite de mélanger des vecteurs incompatibles.

ChoixPourquoiLimite
.npy en float32lecture instantanée, 4 octets par nombretout tient en mémoire
sqliterequêtes SQL sur les métadonnées (filtre par prix, catégorie)un seul écrivain à la fois
empreinte sha256ne recalcule que les fiches modifiéesil faut garder le même formatage de texte
réécriture complètecode simple, toujours cohérentlent au-delà de quelques centaines de milliers de fiches

Vous modifiez la description d'une seule fiche puis relancez l'indexation. Combien de textes sont envoyés au modèle ?

Pourquoi inclure le nom du modèle dans l'empreinte ?

Exercice : filtrer par prix

Écrivez une fonction chercher_moins_de(vecteurs, produits, embed, requete, prix_max, k=5) qui ne renvoie que des produits sous un prix donné. Faut-il filtrer avant ou après le calcul des scores ?

Voir la solution

On filtre avant de prendre le top k, sinon on risque de ne garder que 1 ou 2 résultats sur 5 :

def chercher_moins_de(vecteurs, produits, embed, requete, prix_max, k=5):
    q = embed([texte_requete(requete)])[0]
    exclus = [i for i, p in enumerate(produits) if p["prix"] > prix_max]
    return [(produits[i], s) for i, s in top_k(vecteurs, q, k, exclure=exclus)]

top_k accepte déjà une liste exclure, utilisée aussi par similaires. Avec sqlite, on obtiendrait la liste par SELECT ligne FROM produits WHERE prix > ?.

Résultat obtenu

Indexation réelle le 2026-10-15 (gemini-embedding-2, 768 dimensions), lancée deux fois de suite (chemin du dossier raccourci en index/) :

$ python moteur.py indexer
Index à jour dans index/ (60 fiche(s) recalculée(s), modèle gemini-embedding-2, 768 dims)
$ python moteur.py indexer
Index à jour dans index/ (0 fiche(s) recalculée(s), modèle gemini-embedding-2, 768 dims)
$ ls -l index/
-rw-r--r-- 1 ... 24576 produits.sqlite
-rw-rw-r-- 1 ... 184448 vecteurs.npy

184 448 octets = 60 × 768 × 4 octets + 128 octets d'en-tête numpy.

Je sais faire

  • Sauvegarder une matrice d'embeddings en .npy et les métadonnées en sqlite
  • Relier une ligne de la matrice à une fiche par un numéro de ligne
  • Éviter les recalculs grâce à une empreinte du texte, du modèle et de la dimension
  • Rendre l'embedder injectable pour tester sans réseau
Next