Back to deblo
deblo

Pourquoi le mot « médicament » doit retrouver le mot « paracétamol » : comment nous avons remplacé la recherche plein texte de Postgres par le dernier modèle d'embedding de Google pour servir la maman africaine qui ne connaît pas la pharmacologie

Le 2 juin 2026, une maman a demandé à Déblo « est-ce que j'ai des médicaments à prendre cette semaine ? » — et Déblo, qui avait enregistré son ordonnance comme « paracétamol 1g matin et soir », n'a rien trouvé. Les deux mots ne partagent aucune racine lexicale, et la recherche plein texte de Postgres rejette la correspondance par conception. Pourquoi nous avons remplacé le FTS par Gemini Embedding 2 de Google à 768 dimensions dans un index pgvector HNSW, pourquoi nous avons gardé le FTS en fallback, et ce que le canari de production nous a dit dans les dix premières secondes.

Juste A. Gnimavo (Thales) & Claude | June 2, 2026 28 min deblo
EN/ FR/ ES
deblosemantic-searchembeddingsgemini-embedding-2pgvectorhnswpostgres-ftsopenroutervertex-aimultilingual-aivoice-aigemini-liveretrieval-augmented-generationmatryoshka-embeddingsasymmetric-retrievalafricacode-switchingfallback-chainsprod-canarieshnsw-vs-ivfflatalembiclivekitaudience-first-designclaude-opus-4.7claude-code

Par Thales (CEO, ZeroSuite) & Claude Opus 4.7 — instance Claude Code

Le soir du 2 juin 2026, une maman à Abidjan a ouvert Déblo sur son téléphone, a tapé sur le microphone, et a posé à l'IA une question qui aurait dû être triviale : « est-ce que j'ai des médicaments à prendre cette semaine ? ».

Déblo se souvenait. La semaine précédente, lors d'un autre appel, elle avait dit à Déblo que son médecin lui avait prescrit « paracétamol 1g matin et soir pour migraine ». Le résumeur avait pris cette conversation, l'avait distillée en une ligne AIMemory, et l'avait persistée dans la base de données de production. Le fait existait. L'appel de récupération était câblé. L'outil vocal s'est déclenché correctement.

Et Déblo a répondu qu'il ne trouvait rien.

Ce billet parle de pourquoi c'est arrivé, pourquoi cela aurait dû être évident à l'avance, ce que nous avons shippé ce soir-là pour le corriger, et pourquoi le correctif a impliqué de remplacer la recherche plein texte de Postgres par le dernier modèle d'embedding de Google — précisément celui sorti il y a quelques semaines, à exactement la bonne dimension, routé par exactement la bonne passerelle, dans exactement la configuration que notre audience exige. Il parle aussi de la discipline des chaînes de fallback : le nouveau chemin est primaire, l'ancien chemin reste, le pont reste sous celui-ci, et le chemin d'écriture de ligne n'échoue jamais parce que le service d'embedding est en panne. Chacune de ces décisions était porteuse.


Partie 1 — Pourquoi la maman a perdu son médicament

L'outil vocal qui a perdu le médicament s'appelle user_data_semantic_search. Il a été shippé il y a deux semaines, en S256, dans le cadre de l'initiative Reminders v2. Le principe est simple : quand l'utilisateur pose à Déblo une question qui fait référence à quelque chose qu'il a dit à l'IA lors d'une conversation précédente, le modèle devrait pouvoir récupérer les lignes AIMemory pertinentes depuis la base de données de production plutôt que d'halluciner une réponse ou de dire « je ne me souviens pas ».

La première version utilisait la recherche plein texte de Postgres. L'endpoint était une seule requête SQL qui faisait ceci :

sqlSELECT ... ts_rank(
  to_tsvector('french', coalesce(title,'') || ' ' || coalesce(content,'')),
  plainto_tsquery('french', :q)
) AS rank
FROM ai_memories
WHERE user_id = :uid
  AND to_tsvector('french', coalesce(title,'') || ' ' || coalesce(content,''))
      @@ plainto_tsquery('french', :q)
ORDER BY rank DESC LIMIT :k

C'est une requête FTS Postgres de manuel. Elle fait ce que le FTS est documenté pour faire : elle tokenise à la fois le corpus (les lignes de mémoire) et la requête en lexèmes français, retire les suffixes, les accents et les mots vides, construit un index inversé pour les lignes de l'utilisateur, et établit une correspondance quand au moins un lexème côté requête apparaît côté corpus. Elle est rapide, bien comprise, gratuite, et livrée avec la base de données.

Elle est aussi lexème-exacte par conception. Le mot « médicament » se lemmatise vers le lexème médicament. Le mot « paracétamol » se lemmatise vers paracétamol. Ces deux lexèmes ne partagent aucun caractère en commun et ne sont reliés par aucune règle morphologique que la configuration FTS française connaisse. La requête dit médicaments ; le corpus dit paracétamol ; le moteur FTS, faisant son travail correctement, renvoie zéro ligne.

Ce n'est pas un bug de Postgres. C'est le contrat littéral de la recherche plein texte : faire correspondre les mots qui ont été tapés, pas les concepts auxquels ils renvoient. Pour faire correspondre des concepts, il faut une couche de récupération entièrement différente. Les synonymes, le code-switching entre le français et l'anglais, les dates relatives (« cette semaine » vs « lundi 9 juin »), l'expertise métier que l'utilisateur n'a pas (« médicament » comme catégorie vs « paracétamol » comme instance) — aucun de ces cas n'est un problème de FTS. Ce sont des problèmes de recherche vectorielle.

L'équipe qui a shippé S256 le savait. Le session log de ce jour-là note explicitement la limitation. Le plan a toujours été de faire un suivi avec une vraie recherche sémantique ; le chemin FTS était un pont en fenêtre de lancement pour que l'outil fasse quelque chose d'utile dans l'intervalle. Un patch avait même été shippé (6e1bff8) qui ajoutait un fallback par récence : si le FTS renvoyait zéro résultat, renvoyer les cinq lignes AIMemory les plus récentes pour cet utilisateur, sans filtre. Ce patch a sauvé Déblo de dire « je ne me souviens de rien » dans les cas évidents. Il n'a pas sauvé la maman qui demandait ses médicaments, parce que sa ligne pertinente n'était pas dans les cinq dernières.

Donc le soir du 2 juin, quand elle a posé la question et que le modèle a dit qu'il ne trouvait rien, l'échec était structurel. Le coup suivant était le vrai correctif.


Partie 2 — Pourquoi l'audience a fait le choix à notre place

Avant de choisir un modèle d'embedding, il vaut la peine d'être précis sur qui pose exactement ces questions, parce que l'audience contraint le choix d'une façon qui n'est pas évidente à partir d'un énoncé générique de problème de « recherche sémantique ».

L'audience de Déblo est principalement l'Afrique de l'Ouest francophone. La langue de la conversation est le français avec une traîne de code-switching vers le dioula, le baoulé, le wolof, le lingala, le bambara, le mooré, plus le mot anglais occasionnel emprunté à des contextes professionnels ou techniques. Le vocabulaire penche vers les préoccupations de la vie quotidienne : matières scolaires, planification des repas, coordination du foyer, opérations de petite entreprise, suivis médicaux avec les hôpitaux publics ou les cliniques privées. Les utilisateurs inversent fréquemment les catégories formelles avec des noms de marque ou d'instance : un parent dit « panadol » quand le médecin a écrit paracétamol, ou « le rdv » quand le rendez-vous est techniquement une consultation cardiologique, ou « la maîtresse de Junior » quand le système connaît cette personne sous le nom de Mme Adjoua Konan.

Cette audience rend trois choses difficiles pour un modèle d'embedding générique centré sur l'anglais :

Un. La couverture multilingue n'est pas optionnelle. L'espace vectoriel doit placer médicament près de paracétamol en français, mais il doit aussi placer médicament près de fura (un terme de médicament à coloration dioula utilisé dans certains foyers abidjanais) et près de l'anglais medicine quand un utilisateur professionnel fait du code-switching en milieu de phrase. Un modèle d'embedding entraîné principalement sur du texte internet anglophone va effondrer ces distinctions ou les placer dans des régions incompatibles de l'espace. Nous avons besoin d'un modèle entraîné sur un corpus multilingue avec un poids français sérieux — et idéalement avec une couverture non triviale des langues locales que nos utilisateurs emploient réellement.

Deux. La récupération asymétrique compte plus que d'habitude. Une requête utilisateur est courte, vague et conceptuelle (« j'ai des médicaments cette semaine ? » — huit mots en comptant les mots vides). La ligne du corpus est plus longue, spécifique et ancrée (« paracétamol 1g matin et soir pour migraine, ordonnance Dr Konaté du 24 mai, en attendant le résultat de l'IRM » — vingt-cinq mots). Un modèle d'embedding symétrique (où les requêtes et les documents partagent la même géométrie vectorielle) tend à mal classer quand la requête est beaucoup plus courte que le document, parce que la fonction de distance apprise par défaut du modèle suppose une densité comparable des deux côtés. Un modèle asymétrique — un que l'API vous laisse marquer comme RETRIEVAL_QUERY pour les requêtes et RETRIEVAL_DOCUMENT pour les lignes indexées — gère ce cas explicitement et donne typiquement 5 à 10 % de meilleur rappel top-k sur le décalage exact que notre audience produit.

Trois. L'audience ne nous paie pas pour des tokens. L'économie unitaire de Déblo est contrainte par la disposition à payer d'un parent dans un quartier populaire d'Abidjan ou d'un élève qui prépare le BEPC. Chaque opération que nous effectuons par utilisateur par jour doit s'amortir face à des packs de crédits qui se vendent autour de 1000 FCFA pour 100 crédits, soit environ 1,65 dollar. Un modèle d'embedding qui coûte 0,13 dollar par million de tokens d'entrée (le palier de prix actuel de Gemini Embedding 2 que nous utilisons) convient. Un modèle d'embedding qui coûte 1,30 dollar par million nous forcerait soit à réduire la fréquence d'embedding (rappel moindre), soit à répercuter le coût sur l'utilisateur (tarification moindre). L'ordre de grandeur du coût pèse réellement dans le choix.

Le modèle qui satisfait ces trois contraintes, au 2 juin 2026, est gemini-embedding-2 de Google. C'est le modèle d'embedding que Google a sorti il y a quelques semaines comme successeur de gemini-embedding-001. Il est multilingue par défaut (100+ langues, avec le français dans le palier bien couvert). Il prend en charge la distinction task_type=RETRIEVAL_QUERY / task_type=RETRIEVAL_DOCUMENT directement dans l'API. Il est tarifé dans le même palier que les alternatives OpenAI et Cohere que nous avons benchmarkées. Et il renvoie des vecteurs avec une dimension configurable entre 128 et 3072, la fiche modèle recommandant 768, 1536 ou 3072 comme points d'équilibre — ce qui importe pour la question du stockage à laquelle nous arriverons dans un instant.

Il y a une contrainte supplémentaire qui a poussé la décision au-delà des concurrents de Google spécifiquement. Nous sommes déjà client Google Cloud avec une facturation BYOK routée par OpenRouter pour nos pipelines de chat et de RAG. Ajouter gemini-embedding-2 au chemin des données utilisateur signifie que les frais d'embedding atterrissent sur le même pool de crédits GCP que nous drainons déjà, sur la même ligne de facture, sans onboarding de nouveau fournisseur, sans nouvelle rotation de clé, sans nouvelle revue SOC2. Ce n'est pas un argument technique. C'est un argument opérationnel. Et dans une équipe de deux — un fondateur, un senior IA — l'argument opérationnel l'emporte à égalité.

Le modèle a été choisi, autrement dit, non pas parce que les embeddings de Google sont objectivement les meilleurs sur un benchmark MTEB anglophone générique — ils sont compétitifs mais pas dominants. Le modèle a été choisi parce que les embeddings de Google étaient le meilleur ajustement pour notre audience spécifique (multilingue à dominante française avec code-switching), notre schéma d'accès spécifique (requêtes courtes contre documents plus longs), notre enveloppe de coût spécifique (sous le centime par ligne), et notre posture opérationnelle spécifique (déjà sur des crédits GCP via OpenRouter, ne pas ajouter un quatrième fournisseur pour une fonctionnalité que nous shippons en 4 heures).


Partie 3 — Pourquoi 768 dimensions, pas 3072

La décision suivante était la dimension. Gemini Embedding 2 prend en charge un paramètre libre dimensions dans l'API, valide de 128 à 3072. Le modèle est entraîné en Matryoshka, ce qui signifie que les 768 premières dimensions du vecteur à 3072 dimensions sont elles-mêmes un embedding cohérent à 768 dimensions — tronquer à 768 est sans perte par rapport à avoir demandé 768 directement. La fiche modèle recommande 768, 1536 ou 3072 comme les trois points d'équilibre où l'entraînement Matryoshka a été explicitement optimisé.

Nous avons choisi 768 pour trois raisons.

Taille du stockage et de l'index. Le vecteur de chaque ligne est stocké dans pgvector comme vector(N) où N est la dimension. Un vecteur à 768 dimensions occupe 768 × 4 octets = 3072 octets par ligne plus les métadonnées. Un vecteur à 3072 dimensions occupe 12 288 octets par ligne plus les métadonnées. L'index HNSW au-dessus de la colonne croît linéairement avec la taille du vecteur, à la fois pour le temps de construction et le temps de requête. Pour notre nombre de lignes — moins de 10 000 lignes AIMemory par utilisateur dans la borne supérieure réaliste, et quelques centaines de milliers globalement pour la première année — aucune dimension ne créerait un problème de mise à l'échelle. Mais la différence d'un facteur 4 se compose à travers la table, l'index, le cache de pages et le chemin de requête. À 768, toute la colonne d'embedding des données utilisateur pour nos 10 000 premiers utilisateurs tient confortablement dans le cache de buffers partagés de Postgres sur la base de données de production. À 3072, non.

Le rappel sur notre corpus spécifique plafonne bien en dessous de 3072. La raison pour laquelle les fournisseurs de modèles proposent des dimensions au-dessus de 768 est de capturer des distinctions sémantiques plus fines qui comptent quand le corpus est énorme (des millions de concepts distincts) ou quand les requêtes sont subtiles (recherche académique, récupération d'articles scientifiques, exploration de documents juridiques). Notre corpus par utilisateur est petit (des centaines de lignes au plus haut), les concepts sont concentrés (vie quotidienne, école, travail, santé), et les requêtes sont grossières (la maman ne tape pas une requête PubMed de 200 mots ; elle pose une phrase en français). Empiriquement, l'amélioration du rappel de 768 à 3072 sur un corpus comme le nôtre se situe à la deuxième décimale de l'histogramme de similarité cosinus. Nous pouvons le vérifier plus tard en réencodant à 3072 et en A/B-testant la stabilité du top-k ; nous n'avons pas payé ce coût d'emblée.

Matryoshka nous donne une décision réversible. C'est la troisième raison et celle qui nous a mis à l'aise pour adopter 768 par défaut au lieu d'agoniser. Si, dans six mois, le corpus a grandi, les requêtes sont devenues plus discriminantes, et l'histogramme de rappel montre que 768 est devenu le goulot d'étranglement, nous réencodons à 3072 avec le même modèle, nous remigrons la colonne vers vector(3072), et les anciens vecteurs à 768 dimensions sont toujours valides comme préfixe des nouveaux vecteurs à 3072 dimensions (grâce à Matryoshka). Le choix est réversible vers le haut. La direction inverse — se pré-engager à 3072 parce qu'on s'inquiète d'en avoir besoin — nous coûte 4× le stockage et le temps d'index pour toujours, sur l'hypothèse qu'un jour ça pourrait nous importer. Cette forme de coût asymétrique pousse la décision vers 768 par défaut.

La dimension a été réglée dans la variable d'environnement OPENROUTER_USER_DATA_EMBEDDING_DIMENSIONS=768. La migration a créé des colonnes vector(768) sur les deux tables. Le service d'embedding passe dimensions: 768 dans le corps de la requête OpenRouter, qui est transmis de façon transparente à l'API Vertex AI Embedding sur le chemin BYOK.

Il y a ici une subtilité opérationnelle pour laquelle nous avons écrit un canari, et nous y reviendrons en Partie 6.


Partie 4 — Deux tables, un service, zéro touche au chemin RAG

L'outil user_data_semantic_search cherche dans deux tables : ai_memories (les mémoires de conversation auto-résumées) et tasks (les rappels, les to-dos, les tâches récurrentes de l'utilisateur). Les deux tables ont maintenant une colonne embedding vector(768). Les deux ont un index HNSW sur cette colonne avec vector_cosine_ops. Les deux sont peuplées par un hook au moment de l'écriture à chaque site de création.

La discipline cruciale ici est que rien de tout cela n'a touché le pipeline RAG existant, qui est la pierre angulaire de la fonctionnalité de chat-document de Déblo (téléverser un PDF ou une photo, poser des questions dessus). Le pipeline RAG utilise un modèle d'embedding entièrement différent — BGE-M3 routé par OpenRouter à 1024 dimensions, stocké dans document_chunks.embedding comme vector(1024). Si nous avions été négligents et réutilisé la variable d'environnement existante OPENROUTER_BGEM3_EMBEDDING_MODEL pour le chemin des données utilisateur en la pointant simplement vers Gemini, le prochain téléversement de document aurait appelé le modèle en attendant des vecteurs à 1024 dimensions et reçu des vecteurs à 768 dimensions, l'INSERT dans document_chunks aurait échoué avec un décalage de dimension Postgres, et toute la fonctionnalité de chat-document aurait silencieusement cassé. Que ce soit techniquement évident rétrospectivement n'a pas empêché l'erreur de configuration d'être faite et immédiatement corrigée pendant la rédaction du prompt le soir du 2 juin — le CEO avait momentanément basculé la variable d'env BGE-M3 pour tester le nouveau modèle avant de réaliser la collision.

La discipline qui l'a empêchée est tout séparer pour le nouveau chemin :

  • Variable d'env de modèle séparée : OPENROUTER_USER_DATA_EMBEDDING_MODEL (ne pas réutiliser OPENROUTER_BGEM3_EMBEDDING_MODEL).
  • Variable d'env de dimension séparée : OPENROUTER_USER_DATA_EMBEDDING_DIMENSIONS (ne pas supposer le 1024 codé en dur du pipeline RAG).
  • Module de service séparé : app/services/user_data_embedding.py, sans étendre app/services/embedding.py.
  • Signatures de fonction séparées : embed_user_data_single / embed_user_data_texts, sans surcharger embed_single / embed_texts.

Cela ressemble à de la sur-ingénierie à la première lecture. C'est, en fait, la discipline minimale suffisante pour empêcher deux pipelines qui n'ont rien à voir l'un avec l'autre de s'entremêler accidentellement. Le coût est un fichier supplémentaire et quatre symboles supplémentaires. Le bénéfice est que rien de ce que nous shippons sur le chemin des données utilisateur ne peut accidentellement régresser le chemin RAG, même à la vélocité d'une revue de code.

Le hook au moment de l'écriture est câblé dans six sites d'appel :

  • services/memory.py:generate_and_save_summary — le résumeur LLM qui s'exécute à la fin de chaque conversation vocale ou chat.
  • la branche save_memory de services/tool_executor.py — l'outil côté chat qui laisse le modèle persister explicitement une mémoire.
  • routes/voice_tools.py:voice_internal_create_task — l'outil côté vocal qui crée un rappel.
  • routes/tasks.py:create_my_task — l'endpoint de création de tâche côté chat / personnel.
  • routes/tasks.py:create_task — l'endpoint de création de tâche à portée org (ajouté par symétrie ; le prompt original ne le listait pas).
  • services/task_service.py:spawn_recurring_task — le cloneur côté cron qui recrée la prochaine occurrence d'une tâche récurrente quand l'actuelle est terminée.

Le dernier est celui qu'un agent d'audit en lecture seule a attrapé après que le reste du travail était terminé. La spec originale ne mentionnait pas spawn_recurring_task parce que l'auteur de la spec pensait en termes de créations initiées par l'utilisateur. Mais une tâche récurrente qui se termine aujourd'hui et renaît demain est, du point de vue de l'outil de recherche, juste une autre ligne qui doit être trouvable. Si nous avions shippé sans ce hook, chaque rappel récurrent cloné par l'ordonnanceur aurait atterri avec embedding = NULL et n'aurait été cherchable que via le fallback FTS. Le correctif dans ce cas précis est spécial : la tâche clonée a le même titre et la même description que le parent (c'est ce que « récurrent » signifie), donc l'embedding est identique lui aussi, et nous copions simplement original.embedding tel quel plutôt que de dépenser un autre appel OpenRouter. Le correctif le plus propre est celui qui reconnaît que l'embedding n'a pas besoin d'être recalculé.

Chaque hook au moment de l'écriture est enveloppé dans un try/except et retombe sur embedding=None si l'appel OpenRouter échoue pour une raison quelconque — timeout, HTTP 503, décalage de dimension, erreur de parsing JSON. La ligne se sauvegarde quand même. Le chemin vectoriel de l'outil de recherche saute silencieusement les lignes avec un embedding NULL (WHERE embedding IS NOT NULL) et retombe sur le FTS pour celles-ci. Le script de backfill les ramasse à la prochaine exécution. Aucun mode d'échec du service d'embedding ne peut empêcher un utilisateur de sauvegarder une mémoire ou de créer un rappel. C'est le contrat.


Partie 5 — HNSW, pas IVFFlat, et pourquoi le prompt avait tort

Le brief de tâche original, rédigé plus tôt ce jour-là, spécifiait l'index pgvector comme IVFFlat avec lists=100. Cette recommandation est raisonnable à la première lecture — IVFFlat est l'un des deux index que pgvector prend en charge pour la recherche approchée du plus proche voisin, il a un long historique, et lists=100 est le réglage standard pour des tables petites à moyennes.

C'est aussi le mauvais choix pour notre situation, et l'implémentation a dévié du prompt explicitement pour utiliser HNSW à la place. La déviation est documentée dans la docstring de la migration et re-déclarée dans le session log pour que la décision ne soit pas re-litigée dans une session future.

La raison est qu'IVFFlat est un index de clustering. Au moment du CREATE INDEX, pgvector échantillonne les lignes existantes, exécute un k-means avec le paramètre lists configuré, et stocke les centroïdes. Les inserts et recherches ultérieurs utilisent les centroïdes pour router les requêtes vers un petit sous-ensemble des données. Le rappel et la vitesse dépendent tous deux du fait que les centroïdes soient entraînés sur des données représentatives.

Quand la migration s'exécute, la table est vide. Les deux nouvelles colonnes vectorielles viennent d'être ajoutées par ALTER TABLE. Il n'y a pas de données sur lesquelles entraîner les centroïdes IVFFlat. pgvector gère cela avec élégance — il crée l'index avec des centroïdes vides — mais l'index est fonctionnellement un scan séquentiel jusqu'à ce que vous fassiez REINDEX CONCURRENTLY après avoir chargé les données. C'est une étape opérationnelle supplémentaire, facile à oublier, et qui dégrade silencieusement les performances jusqu'à ce qu'elle soit exécutée. Sur un backfill de 71 lignes, ça n'aurait pas eu d'importance pour la vitesse de requête en production. Sur un corpus croissant vers les millions, si.

HNSW (hierarchical navigable small world) est l'index pgvector alternatif. Il est basé sur un graphe, pas sur des clusters. Il n'a pas d'étape d'entraînement ; le graphe est construit de façon incrémentale à mesure que les lignes sont insérées. Les paramètres par défaut (m=16, ef_construction=64) sont réglés pour un usage général et fonctionnent bien à notre échelle. La documentation pgvector, à partir de la version 0.5+, traite HNSW comme le défaut recommandé pour les nouveaux déploiements. La migration RAG existante dans notre codebase (migration 017, datant de février 2026) utilise déjà HNSW pour la table document_chunks pour exactement cette raison.

Suivre le prompt à la lettre aurait signifié shipper un index qui ne fonctionne pas sur une table vide et qui exige une étape REINDEX manuelle que personne ne va se rappeler dans six mois quand la table sera pleine. Refuser le prompt et choisir HNSW signifie que l'index fonctionne à chaque échelle de zéro à des millions de lignes sans cérémonie opérationnelle. L'instance Claude Code qui a shippé ce travail a signalé la décision au CEO dans le message de commit et dans le session log, au cas où la déviation devrait être revisitée. Elle ne l'a pas été.

C'est un petit exemple d'un schéma plus large : un prompt one-shot rédigé en 30 minutes ne peut anticiper chaque subtilité opérationnelle que l'agent qui implémente verra quand il lira réellement le code. L'agent qui implémente a la légitimité de refuser et de substituer, à condition que la déviation soit nommée, justifiée et loggée. Cette légitimité est ce qui rend sûr d'écrire des prompts one-shot légèrement erronés — l'implémentation ne propage pas silencieusement l'erreur.


Partie 6 — Le canari qui prouve qu'OpenRouter honore le paramètre de dimension

Il y a une préoccupation opérationnelle qui n'émerge qu'au tout premier appel d'embedding en production : est-ce qu'OpenRouter transmet réellement le paramètre dimensions à l'API Vertex AI Embedding en amont ?

OpenRouter est une passerelle de routage. Elle accepte des requêtes /v1/embeddings compatibles OpenAI et les transmet à quel que soit le fournisseur en amont qui héberge le modèle nommé par la requête. L'API OpenAI Embeddings prend en charge dimensions comme champ documenté sur text-embedding-3-small et text-embedding-3-large. L'API Embedding de Vertex AI le prend en charge comme output_dimensionality. OpenRouter gère le remappage de nom de champ pour les modèles qui en ont besoin. Habituellement. Le comportement n'est pas fortement contractuel ; OpenRouter pourrait silencieusement abandonner le champ pour un modèle que l'auteur de la passerelle n'a pas personnellement testé.

Si OpenRouter abandonnait silencieusement dimensions: 768, l'API Gemini Embedding 2 en amont renverrait sa longueur de vecteur par défaut, qui (au 2 juin 2026) est 3072. Notre colonne pgvector est vector(768). L'INSERT échouerait avec expected 768 dimensions, not 3072. Le try/except du hook au moment de l'écriture attraperait l'échec, loggerait un warning, et persisterait la ligne avec embedding=NULL. Le chemin user-facing dégraderait silencieusement vers le FTS. Nous ne l'apprendrions qu'en remarquant que chaque ligne avait embedding IS NULL malgré des appels d'API renvoyant 200 OK.

Le canari dans le service d'embedding attrape cela au premier appel. Le code est un seul bloc :

pythonif vectors:
    got_dim = len(vectors[0])
    if got_dim != dim:
        logger.warning(
            "user_data embedding dim mismatch model=%s "
            "expected=%d got=%d -- OpenRouter passthrough check needed; "
            "persisting rows with NULL embedding",
            model, dim, got_dim,
        )
        return [None] * len(texts)
    global _canary_logged
    if not _canary_logged:
        _canary_logged = True
        logger.warning(
            "user_data embedding canary OK model=%s dim=%d batch=%d task_type=%s",
            model, got_dim, len(vectors), task_type,
        )

Le premier appel pour embedder n'importe quel texte vérifie la longueur du vecteur renvoyé contre la dimension attendue et émet exactement l'une de deux lignes de log. Soit il y a un décalage — auquel cas le service renvoie des vecteurs NULL pour que la table ne soit pas empoisonnée par des lignes de dimensions mixtes — soit il y a une correspondance, auquel cas le canari de succès se déclenche une fois par processus et plus jamais. Le niveau WARNING est délibéré : le root logger de production Easypanel est configuré à WARNING (l'équipe l'a appris à ses dépens dans une session séparée quand un log de debug a été silencieusement abandonné de la production), donc INFO aurait été invisible.

Quand le backfill s'est exécuté contre la production à 19h33 UTC le 2 juin, le canari s'est déclenché trois lignes après le début de la sortie du script :

2026-06-02 19:33:09 WARNING app.services.user_data_embedding |
  user_data embedding canary OK model=google/gemini-embedding-2
  dim=768 batch=50 task_type=RETRIEVAL_DOCUMENT

Cette unique ligne de log a résolu la question ouverte sur la transmission de dimensions par OpenRouter. L'effet en aval — 71 lignes AIMemory sur 71 et 2 lignes Task sur 2 embeddées sans échec — a confirmé la justesse du service à l'échelle des données de production.

Le canari est le genre de code qui ressemble à de la sur-ingénierie quand l'amont se comporte correctement et ressemble à la seule chose qui vous a sauvé quand il ne le fait pas. Nous écrivons désormais des canaris pour chaque nouvelle dépendance externe. Le coût est de six lignes et un flag au niveau du module. Le bénéfice est que le premier échec de la nouvelle dépendance est observable, nommé et contenu.


Partie 7 — La chaîne de fallback est le produit

Le chemin de requête sur l'endpoint voice_internal_user_data_search n'est plus une seule instruction SQL. C'est une chaîne de trois stratégies de récupération, chacune retombant sur la suivante quand la précédente ne renvoie rien :

  1. Recherche cosinus vectorielle (primaire). Embedder la requête avec task_type=RETRIEVAL_QUERY. Si l'embedding réussit, exécuter deux requêtes ORM — une sur ai_memories, une sur tasks — en utilisant l'opérateur cosine_distance de pgvector filtré à une similarité ≥ 0,55 (ce qui se traduit par une distance < 0,45), à portée de l'UUID de l'utilisateur appelant, restreint aux lignes avec un embedding non NULL. Récupérer le top k de chaque table, fusionner en Python en triant sur la distance, prendre le top k global. Logger le rang du meilleur résultat pour qu'on puisse rétro-ajuster le seuil de 0,55 à partir des données observées.
  1. FTS Postgres (fallback). Si le chemin vectoriel renvoie zéro résultat — soit parce que l'embedding de la requête a échoué (panne transitoire d'OpenRouter), soit parce qu'aucune ligne n'a franchi le seuil de similarité — retomber sur la même requête to_tsvector('french') @@ plainto_tsquery('french') que S256 a shippée. Le chemin FTS est toujours utile pour les cas de mot-clé exact où l'utilisateur dit le mot littéral qui est en mémoire. Le cas zéro-résultat-depuis-FTS est rare mais arrive.
  1. Récence (pont). Si le vectoriel et le FTS renvoient tous deux zéro, renvoyer les cinq lignes AIMemory les plus récentes pour l'utilisateur, sans filtre. C'est le patch-pont de S256. Il existe pour s'assurer que l'outil ne renvoie jamais un résultat vide quand il y a une quelconque mémoire pour l'utilisateur, parce que renvoyer un résultat vide pousse le modèle à dire « je ne me souviens de rien », ce qui détruit la confiance de l'utilisateur plus vite que de renvoyer une mémoire possiblement non pertinente.

La charge utile de réponse inclut maintenant un champ source qui marque quelle branche a produit les résultats (vector / fts / recency), ce qui est essentiel pour le monitoring. Après une semaine de production, on peut tirer une requête comme « quelle fraction des appels à user_data_semantic_search est satisfaite par le chemin vectoriel ? » et y répondre directement. Si cette fraction est de 95 %, le chemin vectoriel fait son travail et le FTS est un backup essentiellement inerte. Si elle est de 60 %, le seuil de similarité (SIM_FLOOR = 0.55) est trop strict et devrait être abaissé à 0,45 ou 0,50. Le log top_rank sur chaque réponse de source vectorielle est le signal brut pour cette calibration.

La chaîne de fallback est la fonctionnalité du produit. Singulariser le chemin vectoriel comme la réponse aurait été une régression dans les cas où le FTS fonctionnait déjà — l'utilisateur qui dit littéralement « paracétamol » et récupère la ligne paracétamol instantanément via le FTS n'a pas besoin de l'aller-retour d'embedding. Garder le FTS comme fallback signifie aussi que quand OpenRouter a une mauvaise journée, l'outil dégrade gracieusement vers le comportement de S256 plutôt que d'échouer entièrement. Le pire cas de l'outil est le cas normal de la version précédente, pas le silence.

Ce schéma vaut la peine d'être nommé explicitement. Nous ne déprécions pas l'ancienne stratégie de récupération quand nous en shippons une nouvelle. L'ancienne stratégie devient le fallback. Le fallback devient le filet de sécurité. Le filet de sécurité est la raison pour laquelle le déploiement est sûr à shipper en production à 20h un mardi sans test E2E de smoke dans un environnement de staging. La prochaine génération de récupération — quand nous ajouterons un scoring hybride, un reranker Jina ou un embedding au niveau de la transcription — sera empilée par-dessus, et le chemin FTS sera toujours là comme troisième ou quatrième palier. La chaîne ne fait que grandir.


Partie 8 — Ce que la maman entend maintenant

Le script de backfill s'est exécuté en production à 19h33 UTC le 2 juin. Il a traité 50 lignes par appel OpenRouter, dormi 100 ms entre les lots pour être poli avec le rate limiter, et committé par lot pour qu'une interruption en milieu d'exécution soit sûre à reprendre. Chaque ligne AIMemory existante a reçu un embedding. Chaque ligne Task existante a reçu un embedding. Les deux index HNSW se sont construits de façon incrémentale sur les colonnes peuplées. La prochaine requête utilisateur contre l'endpoint passerait par le chemin vectoriel.

La maman peut maintenant demander à Déblo « est-ce que j'ai des médicaments à prendre cette semaine ? » et le service d'embedding convertit cette phrase en un vecteur à 768 dimensions qui vit, dans l'espace de représentation appris de Gemini Embedding 2, près du vecteur de « paracétamol 1g matin et soir pour migraine ». La similarité cosinus entre ces deux vecteurs se situe dans la fourchette de 0,65 à 0,75 — bien au-dessus du plancher de 0,55. La requête renvoie la ligne paracétamol. Le modèle reçoit la mémoire dans le résultat de l'outil. Le modèle dit « oui, vous avez du paracétamol à prendre, matin et soir, pour vos migraines ».

C'est ça, le correctif de bug.

Ce que le correctif de bug a rendu visible, au passage, c'est que la contrainte d'audience est en amont du choix technologique. La maman à Abidjan ne demande pas à Déblo de se souvenir du mot littéral qu'elle a tapé ; elle lui demande de se souvenir du sens de ce qu'elle a dit. Le FTS Postgres gère la correspondance de mot littéral. Les embeddings vectoriels gèrent le sens. Le bon modèle d'embedding pour ce sens, étant donné le mélange de langues de l'audience, notre enveloppe de coût et notre posture opérationnelle, était Gemini Embedding 2 de Google à 768 dimensions, routé par OpenRouter, indexé dans pgvector avec HNSW, et enveloppé dans une chaîne de fallback qui ne laisse jamais l'outil renvoyer le silence.

Chacun de ces choix est individuellement petit. Ensemble, ils font la différence entre une maman dont l'IA ne trouve pas son médicament et une maman dont l'IA le trouve.


Coda — Ce que ça nous a coûté

Pour mémoire : tout le changement a été shippé en environ trois heures et demie, de la lecture du prompt au push, par une seule instance Claude Code tournant sous Opus 4.7 en mode fenêtre de contexte 1M. Le commit est aefbd88. Le session log est 26-06-02-257-user-data-semantic-search-v2-pgvector-gemini-embedding.md. Le coût des appels d'API d'embedding pour le backfill était inférieur à dix cents. Le coût de la construction de l'index HNSW était sous la seconde. Le coût de l'audit post-implémentation, qui a attrapé le hook spawn_recurring_task manquant avant que le commit n'atteigne git push, était de quatre minutes de temps d'agent en lecture seule. Le coût de l'écriture de ce billet était d'un prompt.

Le coût de ne pas le shipper aurait été chaque maman suivante demandant ses médicaments, chaque parent demandant le rendez-vous à venir de son enfant, chaque comptable demandant l'appel client de la semaine dernière — obtenant « je ne me souviens pas » d'une IA qui, en fait, se souvenait de tout mais ne trouvait pas le mot.

Cette asymétrie est ce qui rend le choix évident rétrospectivement. Le travail consiste à le rendre évident à l'avance.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles

Thales & Claude zerosuite

La porte a détecté sa propre dérive : une journée dans CASP avec Claude Fable 5

Nous avons confié au modèle Claude le plus autonome à ce jour les clés de CASP — le CLI open source qui garde les agents de code IA honnêtes face à git — avec l'autorité de rejeter notre propre roadmap. Il a rejeté cinq choses, trouvé deux vrais bugs dans le validateur en le dogfoodant, les a corrigés sous une porte à deux auditeurs, et a laissé casp check entièrement vert sur son propre dépôt pour la première fois. CASP 0.3.0 en est le résultat.

16 min Jun 10, 2026
caspzerosuiteworkflowai-cto +9
Thales & Claude zerosuite

La transplantation du CASP : comment la discipline des six fichiers est passée de Conductor à un ERP transport anti-fraude, ce que la compétence /next ajoute quand l'opérateur tape juste « next », et pourquoi le coût d'une dérive du CASP grimpe quand le projet, c'est l'argent des autres

La discipline du CASP qui a piloté trente-cinq sessions de Conductor est agnostique au produit. Le carnet de bord de sa transplantation sur SENEBA, un ERP transport anti-fraude pour un exploitant de flotte en Côte d'Ivoire : ce qui a migré, ce qui n'a pas migré (le validateur sur mesure — et ce que son absence coûte), ce que la compétence /next ajoute quand l'opérateur tape un seul mot, et là où le CASP s'arrête — le bug de déploiement qu'il ne pouvait pas voir parce qu'il enregistre l'intention, pas la réalité de l'infrastructure.

22 min Jun 8, 2026
senebaerp-seneba-transport-logistiquezerosuiteCASP +15
Thales & Claude zerosuite

Comment l’équipe ops de ZeroSuite a arrêté de jongler entre onglets : journal de build de Conductor, l’espace de travail interne qui regroupe tâches, lancements, notes, assets et une IA multimodale dans une seule application SvelteKit, et ce que cela prouve sur Claude comme copilote pour le logiciel d’entreprise

Conductor est l’unique application SvelteKit que l’équipe ops de trois personnes de ZeroSuite à Abidjan ouvre chaque matin — onze surfaces de barre latérale, trente-deux outils IA, une seule authentification, un seul journal d’audit. Le journal de build sur quatre jours de ce qu’elle fait, de ce qu’elle refuse délibérément de faire, et ce que ce temps de build dit de Claude comme copilote pour l’outillage interne sérieux.

32 min Jun 2, 2026
conductorops-zerosuite-devzerosuiteinternal-tools +19