Second Brain documentation

ADR 0097 – Plongements locaux : « Ressemble à », projet par le sens, doublons

Accepté (2026-10-17, fil B du jalon 4, cadre : ADR 0086 « étage 2 »). Livré en deux temps le même jour : le circuit complet et son interface, testé avec un faux modèle ; puis l'adaptateur ONNX Runtime + `multilingual-e5-small`, vérifié de bout en bout avec le vrai modèle (téléchargement accepté par Aurélien).

← Toutes les décisions

ADR 0097 – Plongements locaux : « Ressemble à », projet par le sens, doublons

Statut : Accepté (2026-10-17, fil B du jalon 4, cadre : ADR 0086 « étage 2 »). Livré en deux temps le même jour : le circuit complet et son interface, testé avec un faux modèle ; puis l’adaptateur ONNX Runtime + multilingual-e5-small, vérifié de bout en bout avec le vrai modèle (téléchargement accepté par Aurélien).

Numéro hors de la plage 0090 à 0093 réservée au fil B par l’ADR 0086 (les quatre premières lignes ont pris les quatre numéros) et de la plage 0094 à 0096 du fil C (0094 et 0095 y sont pris) : le premier numéro libre après les deux plages.

Décision

Inactif par défaut. app.assistant.embeddings.enabled=false (AssistantProperties). Sans modèle l’application se comporte comme avant : aucun calcul, GET /api/nodes/{id}/similar rend [], CapturePreview.similar est vide, GET /api/assist/capabilities dit embeddings: false. Réglages : model (dossier du modèle, /data/models/multilingual-e5-small), similar (0,87, texte cherché contre un nœud), similar-node (0,91, deux nœuds), project (0,86, projet par le sens), duplicate (0,95, doublon probable), max-similar (5). Un modèle absent ou illisible n’empêche pas de démarrer : l’erreur est journalisée et les plongements restent inactifs.

Seuils calibrés sur le vrai modèle. Les similarités de e5 sont comprimées : des textes sans rapport restent à 0,83 à 0,85 (requête contre passage) et 0,86 à 0,90 (deux passages) ; des textes de même sujet dépassent 0,87 et 0,91 (« Acheter de la peinture pour la chambre » contre « Rénovation chambre » : 0,869 ; « Réviser l’auto » contre « Entretien des véhicules » : 0,903) ; un quasi-doublon (« Révision de la voiture » contre « Révision voiture ») atteint 0,955 et 0,988. Les seuils par défaut en découlent ; un autre modèle impose de les recalibrer.

Stockage. Migration V18__node_embedding.sql : node_embedding(node_id, model, text_hash, vector real[], updated_at), clé (node_id, model), suppression en cascade. Pas de pgvector (image PostgreSQL, sauvegarde et restauration inchangées) ; la similarité cosinus se calcule en mémoire sur des vecteurs normalisés (produit scalaire), suffisant au volume personnel. Donnée dérivée : ni événement, ni export (recalculable).

Calcul. Texte embarqué = titre + 1 000 premiers caractères du corps. NodeService publie NodeTextChanged à la création et à la modification ; EmbeddingService calcule après validation de la transaction (jamais en échec de l’écriture : l’erreur est journalisée) et seulement si l’empreinte SHA-256 du texte (avec l’identifiant du modèle) a changé. Rattrapage au démarrage dans un fil virtuel, après l’ouverture du port (contrairement à BodyIndexReconciler, car un modèle de cette taille calcule en quelques dizaines de millisecondes par nœud) ; les vecteurs d’un autre modèle sont supprimés.

Usages.

  • GET /api/nodes/{id}/similar : nœuds proches avec score et duplicate (score ≥ duplicate).
  • Aperçu de capture : similar (nœuds proches du titre) pour signaler un doublon ou un sujet voisin avant de créer.
  • Projet par le sens : quand aucun mot ne désigne de projet (ADR 0090) et qu’aucun champ n’est saisi, le projet le plus proche parmi les candidats du champ référence devient une devinette d’origine similar ; un ex æquo n’en désigne aucun.

Abstraction. TextEmbedder (modelId, embedPassage, embedQuery) : un bean suffit à brancher un modèle. Les tests utilisent un faux modèle (sac de mots hachés avec synonymes).

Modèle et adaptateur

  • Modèle : multilingual-e5-small (384 dimensions, 12 couches), version ONNX quantifiée de Xenova (model_quantized.onnx, environ 118 Mo) et tokenizer.json, posés par scripts/fetch-embedding-model.sh (à lancer à la main ; empreintes SHA-256 vérifiées ; jamais de téléchargement par l’application). Dans l’image, le dossier est /data/models/multilingual-e5-small (volume /data) ; en développement, data/models/… (ignoré par git).
  • OnnxTextEmbedder : ONNX Runtime 1.22 dans la JVM (la dépendance ajoute environ 140 Mo au jar, bibliothèques natives de toutes les plateformes ; l’image eclipse-temurin est en glibc, ce qui convient), préfixes query: et passage: , moyenne des vecteurs de jetons puis norme 1 (le masque est plein : un texte par appel).
  • UnigramTokenizer : le tokenizer SentencePiece de XLM-RoBERTa en Java pur, lu dans tokenizer.json (Viterbi sur les scores, ▁ devant chaque mot, <s> et </s>, 512 jetons au plus). Une bibliothèque de tokenizers (DJL) aurait téléchargé du code natif à l’exécution. Normalisation : NFKC de Java à la place de la table nmt_nfkc de SentencePiece. Vérifié : les identifiants de jetons de 9 phrases (accents, ligatures, ß, 東京, apostrophes, espaces multiples, tabulation) sont identiques à ceux de la bibliothèque de référence de HuggingFace (OnnxTextEmbedderTest, empreintes dans src/test/resources/assist/e5-reference-ids.json).
  • Tests : OnnxTextEmbedderTest (jetons, norme 1, sens) et EmbeddingOnnxIT (démarrage, calcul à l’écriture, projet par le sens, proches, doublon) s’ignorent tant que le modèle n’est pas installé : la CI sans modèle reste verte.

Reste à faire

  • Interface : section « Ressemble à » de la fiche (hors des fichiers du fil B).
  • Mesurer sur un vrai jeu de notes (quelques milliers) le temps de rattrapage au démarrage et la mémoire du cache de vecteurs.

Alternatives écartées

  • pgvector : change l’image et la procédure de sauvegarde pour un gain nul à ce volume.
  • Recalcul synchrone dans la transaction d’écriture : un modèle lent ou en panne bloquerait la saisie.
  • Rattrapage bloquant au démarrage : un premier lancement avec des milliers de nœuds retarderait l’ouverture du port.

Limites connues

  • Le cache des vecteurs est chargé au premier usage et mis à jour à chaque calcul ; un nœud mis à la corbeille y reste jusqu’au redémarrage mais n’est jamais rendu (son titre n’est plus lisible).
  • Comparaison exhaustive : O(nombre de nœuds) par requête, à revoir (index approché) au-delà de quelques dizaines de milliers de nœuds.