Stocker dans SQLite et rappeler par similarité
Un embedding, c'est quoi déjà ?
Un embedding est une liste de nombres (un vecteur) qui représente le sens d'un texte : deux textes de sens proche ont des vecteurs proches. On mesure cette proximité avec la similarité cosinus (entre -1 et 1). Si les vecteurs sont normalisés (longueur 1), le cosinus est un simple produit scalaire.
flowchart LR
Q["Question : Dans quelle équipe suis-je ?"] --> EQ["embed(query)"]
EQ --> DOT["Produit scalaire avec tous les faits de l'utilisateur"]
T[("Table facts : id, user_id, text, vec BLOB")] --> DOT
DOT --> TOP["Top-k (k=4)"]
TOP --> CTX["Contexte du modèle"]
Une table suffit
self.db.execute("""CREATE TABLE IF NOT EXISTS facts (
id INTEGER PRIMARY KEY, user_id TEXT NOT NULL, text TEXT NOT NULL,
vec BLOB NOT NULL, updated_at REAL NOT NULL)""")
def recall(self, user, query, k=4):
rows = self.db.execute("SELECT id, text, vec FROM facts WHERE user_id=?", (user,)).fetchall()
if not rows:
return []
m = np.frombuffer(b"".join(r[2] for r in rows), dtype=np.float32).reshape(len(rows), -1)
scores = m @ self.embed(query, "query")
best = np.argsort(-scores)[:k]
return [(float(scores[i]), rows[i][0], rows[i][1]) for i in best]Le vecteur est stocké en binaire (np.float32(...).tobytes(), 768 × 4 = 3 072 octets par fait). Le filtre WHERE user_id=? est fait avant le calcul : les faits de Bob ne sont même pas lus quand Camille pose une question.
Les pièges de gemini-embedding-2
Trois comportements réels, vérifiés le 2026-10-15, qui changent le code :
| Constat | Conséquence dans le code |
|---|---|
| Une liste de textes envoyée en un appel renvoie un seul vecteur agrégé (API Gemini) | un texte par appel, ou une types.Content par texte |
task_type="RETRIEVAL_QUERY" ne change rien aux vecteurs | on préfixe le texte, comme le recommande la documentation : task: question answering | query: ... pour la question, title: none | text: ... pour le fait |
Sur Vertex AI, 404 en europe-west1 et un seul contenu par requête | EMBED_MODEL=gemini-embedding-001 sur Vertex en Europe |
prompt = f"task: question answering | query: {text}" if kind == "query" else f"title: none | text: {text}"
r = with_retry(lambda: c.models.embed_content(
model=EMBED_MODEL, contents=prompt, config=types.EmbedContentConfig(output_dimensionality=DIM)))output_dimensionality=768 réduit le vecteur (3 072 dimensions par défaut) : quatre fois moins de stockage, pour une qualité de classement qui reste suffisante ici.
Vous envoyez 6 faits dans une liste à embed_content avec gemini-embedding-2 sur l'API Gemini. Combien de vecteurs recevez-vous ?
gemini-embedding-2 agrège une liste en un seul embedding. Pour 6 vecteurs, il faut 6 contenus séparés (types.Content) ou 6 appels. Notre premier essai a renvoyé un seul vecteur et cassé l'indexation.
Pourquoi pas de seuil fixe ?
Tentation : « on ne rappelle que les faits avec une similarité supérieure à 0,7 ». Regardez les scores réels de la section suivante (lignes = questions, colonnes = faits, préfixes de tâche appliqués) avant de choisir un seuil.
Résultat obtenu
Mesure réelle du 2026-10-15, gemini-embedding-2, 768 dimensions :
Camille arachides Go Nantes Paiements Logistique
Comment je m'appelle ? 0.74 0.67 0.66 0.67 0.65 0.64
Dans quelle équipe suis-je ? 0.67 0.63 0.64 0.64 0.68 0.70
Quelle est la couleur de ma voiture ? 0.64 0.60 0.62 0.61 0.58 0.59
Dans quel langage écrire l'exemple ? 0.65 0.58 0.72 0.60 0.61 0.58Le classement est juste à chaque ligne (le bon fait est premier). Mais les valeurs sont serrées : la question sans réponse (« couleur de ma voiture ») obtient 0,64 sur son meilleur fait, à peine moins que les bonnes réponses aux questions « équipe » (0,70) et « langage » (0,72). Aucun seuil ne sépare proprement « pertinent » de « hors sujet ».
D'où le choix du projet : top-k sans seuil, et c'est le modèle, guidé par la consigne « si l'information n'est pas dans ta mémoire, dis-le », qui décide d'utiliser ou non un fait. L'évaluation finale le confirme : sur la question de la voiture, l'assistant a répondu « Je ne la connais pas » malgré 4 faits rappelés.
Pourquoi le projet rappelle-t-il toujours les k meilleurs faits au lieu d'appliquer un seuil de similarité ?
Les mesures montrent des scores entre 0,58 et 0,74 pour tous les couples. Le classement est fiable, les valeurs absolues ne le sont pas. Avec un k petit (4), le coût reste faible et le modèle trie.
Exercice : la dimension
Proposez une expérience pour choisir entre 768 et 3 072 dimensions, sans vous fier à l'intuition.
Voir la solution
- Reprenez les 8 questions de scenarios.json qui ont un
faitattendu. - Pour chaque dimension (
EMBED_DIM=768puis3072), reconstruisez la mémoire et mesurez le taux de bon fait rappelé dans le top-1 et le top-4 (fact_recalleddans eval_rappel.py). - Notez aussi la taille de la base (4 octets × dimensions × nombre de faits) et le temps de
recall.
Si 768 donne le même rappel top-4, gardez 768 : quatre fois moins de stockage. Sur 8 questions, l'écart ne sera pas significatif : pour décider en production, il faut des dizaines ou des centaines de questions réelles.
Je sais faire
- Stocker un embedding en BLOB dans SQLite et le relire avec numpy
- Rappeler les k faits les plus proches, filtrés par utilisateur
- Contourner les pièges de gemini-embedding-2 (liste agrégée, préfixes, Vertex)
- Expliquer pourquoi top-k bat un seuil fixe sur nos mesures