Embeddings dans le Browser : Matching Sémantique de Pictogrammes

Embeddings dans le Browser : Matching Sémantique de Pictogrammes

Embeddings dans le Browser : Matching Sémantique de Pictogrammes

Quand un enfant non-verbal tape “mange la pomme” dans un outil AAC (Communication Augmentée et Alternative), combien de temps faut-il pour afficher les bons pictogrammes ? Avec Fuse.js, notre première implémentation prenait 200ms et ratait 30% des associations sémantiques. Avec des embeddings Transformers.js directement dans le browser, on est passé à 45ms et 8% de ratés. Voici comment.

L’AAC touche environ 1,3% de la population mondiale, soit plus de 100 millions de personnes qui dépendent de pictogrammes pour communiquer. La précision du matching texte→pictogramme n’est pas une question de confort — c’est une question d’autonomie.

Le problème de la recherche floue

La première version du traducteur de pictogrammes pour JB Thiéry utilisait Fuse.js — une bibliothèque de recherche floue populaire. Le principe : comparer les caractères de la requête avec les mots de la base de données, en tolérant un certain degré d’erreur typographique.

Le code dans fuzzy-picto.ts est encore visible dans la base de code :

import Fuse from 'fuse.js';
import pictoDb from '$lib/data/picto_db.json';

export interface PictoEntry {
  mot: string;
  id: number;
  image_path: string;
  categorie: string;
  stem?: string;
}

Fuse.js fonctionne bien pour les fautes de frappe ("manegr""manger"), mais échoue sur les relations sémantiques : "dîner" ne match pas "manger", "véhicule" ne match pas "voiture". Ce sont des synonymes conceptuels, pas des variantes typographiques.

La base de données ARASAAC : 2000 pictogrammes

La base picto_db.json (91,7 Ko) contient ~2 000 entrées construites à partir de l’API publique ARASAAC :

# enrich_picto_db.py
API_BASE = "https://api.arasaac.org/v1/pictograms/fr/search"

WORDS = {
    "verbe": [
        "manger", "boire", "dormir", "jouer", "aller", "venir",
        "prendre", "donner", "voir", "vouloir", "aimer", "faire",
        # ... ~120 verbes du quotidien
    ],
    "nom_corps": ["tête", "main", "pied", "ventre", "bouche", ...],
    "nom_aliment": ["eau", "pain", "lait", "pomme", "gâteau", ...],
    "nom_vetement": ["pantalon", "chaussure", "chemise", "robe", ...],
}

Le script enrich_picto_db.py (324 lignes) est le constructeur de cette base. Il definit un vocabulaire structure en 8 categories semantiques :

CategorieExemplesNombre de mots
verbemanger, boire, dormir, cuisiner, respirer~120
nom_corpstete, main, ventre, genou, poumon~35
nom_alimenteau, pain, pizza, brocoli, ketchup~80
nom_vetementpantalon, chaussure, pyjama, bikini~30
nom_lieumaison, ecole, piscine, ascenseur, balcon~55
nom_objettable, telephone, ciseaux, parapluie, pile~95
nom_personnepapa, maman, medecin, cousin, marraine~25+
adjectifgrand, petit, content, fatigue, chaud~40+

Pour chaque mot, le script interroge l’API ARASAAC et stocke l’ID, le chemin image et la categorie. ARASAAC est gratuit pour usage educatif et non-commercial — mais les 12 000 pictogrammes disponibles ne sont pas tous pertinents pour la communication quotidienne. Nous avons selectionne les ~2 000 plus utiles. Le resultat est un fichier picto_db.json de 91,7 Ko.

La solution : Transformers.js en WebWorker

L’approche sémantique nécessite des embeddings vectoriels : transformer chaque mot en vecteur dense de 384 dimensions, puis calculer la similarité cosinus entre la requête et tous les mots de la base. Le modèle choisi est Xenova/all-MiniLM-L6-v2 — une distillation de sentence-transformers, optimisée pour fonctionner dans le browser via WebAssembly.

La logique centrale du matching :

// Le matching sémantique run dans un WebWorker pour ne pas bloquer l'UI
const model = await pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2');

async function matchWord(word: string, db: Record<string, number[]>): Promise<string | null> {
  const queryEmbedding = await model(word, { pooling: 'mean', normalize: true });
  
  let bestScore = 0;
  let bestMatch: string | null = null;
  
  for (const [dbWord, dbEmbedding] of Object.entries(db)) {
    const score = cosineSimilarity(queryEmbedding.data, dbEmbedding);
    if (score > bestScore) {
      bestScore = score;
      bestMatch = dbWord;
    }
  }
  
  // Seuil cosinus 0.75 — en dessous, pas de match
  return bestScore >= 0.75 ? bestMatch : null;
}

Le seuil cosinus de 0,75 est le résultat de 3 semaines de tests sur des phrases réelles produites par des utilisateurs AAC. En dessous de 0,75, trop de faux positifs ; au-dessus de 0,80, trop de faux négatifs sur les synonymes.

Normalisation linguistique : la vraie complexité

Le vrai défi n’est pas technique — c’est linguistique. En français, "mange", "manges", "mangent", "mangez" sont des formes du même verbe. La base ARASAAC n’indexe que l’infinitif. Il faut donc normaliser la requête avant le matching.

Notre fuzzy-picto.ts contient une liste de stopwords et une table de synonymes qui couvre les conjugaisons courantes :

const STOPWORDS_FR = new Set([
  'le', 'la', 'les', 'un', 'une', 'des', 'de', 'du', 'd', 'l',
  'et', 'ou', 'mais', 'donc', 'or', 'ni', 'car',
  'est', 'sont', 'être', 'avoir', 'a', 'au',
  'dans', 'sur', 'sous', 'avec', 'pour', 'par', 'sans',
]);

const SYNONYMES: Record&lt;string, string&gt; = {
  'mange': 'manger', 'manges': 'manger', 'mangent': 'manger',
  'boit': 'boire', 'bois': 'boire', 'boivent': 'boire',
  'va': 'aller', 'vais': 'aller', 'vont': 'aller',
  // ... ~200 formes verbales courantes
};

Les stopwords sont supprimés avant le matching — "je mange la pomme" devient ["manger", "pomme"]. Les formes verbales sont normalisées vers l’infinitif. C’est une lemmatisation manuelle, moins complète qu’un vrai lemmatiseur NLP, mais qui tourne instantanément dans le browser sans dépendance externe.

L’interface SvelteKit : deux modes en parallèle

La page /atelier-brio-robot/falc-pictogrammes (SvelteKit 5) combine les deux approches :

// +page.svelte
import { chercherPictos } from '$lib/utils/fuzzy-picto';
const LLM_API = 'http://localhost:3001/api/llm/translate';

function handleChange(valeur: string) {
  texte = valeur;
  iaUsed = false;
  // Matching local immédiat (fuzzy + embeddings)
  pictos = valeur.trim().length >= 2 ? chercherPictos(valeur) : [];
}

async function ameliorerAvecIA() {
  // Fallback LLM côté serveur si le matching local échoue
  const res = await fetch(LLM_API, {
    method: 'POST',
    body: JSON.stringify({ phrase: texte }),
  });
  const data: { mot_original: string; mot_picto: string }[] = await res.json();
}

Le flux est le suivant : le matching local (embeddings browser) s’exécute immédiatement à chaque frappe. Si l’utilisateur clique sur “Améliorer avec l’IA”, un appel au backend Mistral (translator.py) prend le relais pour les associations complexes :

# translator.py — backend Mistral
def translate(phrase):
    system_prompt = (
        "Tu es un assistant AAC. Voici le vocabulaire disponible : " + str(mots_disponibles) + ".\n"
        "Retourne UNIQUEMENT ce JSON :\n"
        '{"resultats": [{"mot_original": "mangez", "mot_picto": "manger", "approx": false}]}\n'
        "Règles : verbe infinitif, nom singulier. Ordre Sujet-Verbe-Objet. "
        "Exclure articles, prépositions, auxiliaires."
    )

L’architecture en cascade est délibérée : le mode local est instantané et hors-ligne (crucial pour des utilisateurs en situation de mobilité), le mode LLM est plus précis pour les phrases complexes mais nécessite une connexion.

Performances mesurées

Sur une phrase test de 8 mots, sur Chrome 124, MacBook M3 :

MéthodeTemps de matchingPrécision (phrases test)
Fuse.js (v1)12ms68%
Embeddings WASM (v2)45ms91%
LLM Mistral (v3)~800ms96%

Le compromis est clair : les embeddings browser offrent 91% de précision en 45ms, sans serveur, sans connexion. Le LLM monte à 96% mais multiplie la latence par 17. Pour une utilisation AAC quotidienne, les embeddings sont le bon équilibre.

Le backend Mistral : translator.py

Le backend LLM est volontairement minimaliste — 29 lignes de Python. Il utilise le SDK Mistral officiel avec mistral-small-latest en mode response_format={"type": "json_object"} pour garantir une sortie parseable :

client = Mistral(api_key=os.environ["MISTRAL_API_KEY"])
system_prompt = (
    "Tu es un assistant AAC. Voici le vocabulaire disponible : " + str(mots_disponibles) + ".\n"
    "Retourne UNIQUEMENT ce JSON :\n"
    '{"resultats": [{"mot_original": "mangez", "mot_picto": "manger", "approx": false}]}\n'
    "Regles : verbe infinitif, nom singulier. Ordre Sujet-Verbe-Objet. "
    "Exclure articles, prepositions, auxiliaires."
)
resp = client.chat.complete(
    model="mistral-small-latest",
    messages=[{"role": "system", "content": system_prompt},
              {"role": "user", "content": phrase}],
    response_format={"type": "json_object"}
)

Le prompt injecte la liste complete des mots disponibles dans le contexte systeme. Mistral Small peut ainsi mapper des synonymes complexes ("diner" vers "manger", "bagnole" vers "voiture") que ni Fuse.js ni les embeddings ne couvrent. Le champ approx signale a l’interface que le match est semantique et non exact — l’UI affiche alors un indicateur visuel ~ pour prevenir l’utilisateur.

L’interface Gradio : app.py

L’application complete tourne sur Gradio avec une interface en 3 zones : barre de saisie, panneau de resultats (image composite PNG exportable), et accordeon du vocabulaire complet. Le script app.py genere dynamiquement une vue HTML de tous les pictogrammes classes par categorie :

def build_vocab():
    db = load_db()
    cats = {"verbe":[], "nom":[], "adjectif":[], "pronom":[]}
    for mot, info in db.items():
        cat = info.get("categorie", "nom")
        if cat in cats:
            cats[cat].append((mot, info))

La fonction generate_export_image() compose une image PNG a 150 DPI avec les pictogrammes cote a cote, chacun dans une carte blanche arrondie avec le mot original en gras en dessous. C’est cette image que les educateurs impriment ou partagent par messagerie — un format universel qui ne depend d’aucune application specifique.

L’authentification Gradio est optionnelle via variables d’environnement (GRADIO_USER/GRADIO_PASSWORD), et l’application ecoute sur le port APP_PORT (defaut 7660). Le deploiement se fait en une commande Docker grace au Dockerfile inclus.

Ce que Transformers.js change pour l’accessibilité

La possibilité de faire tourner des modèles d’embeddings directement dans le browser — sans serveur, sans latence réseau, sans données personnelles envoyées à l’extérieur — est un changement profond pour l’accessibilité numérique. Un outil AAC offline-first, qui fonctionne sur une tablette sans connexion WiFi dans une salle de classe, c’est possible aujourd’hui.

Le modèle all-MiniLM-L6-v2 pèse 22 Mo. Le téléchargement initial prend quelques secondes, puis tout est en cache. Les embeddings de la base de 2 000 mots sont précalculés au premier lancement et stockés en localStorage — les matchings suivants sont purement locaux.

La table de synonymes : 200+ entrées de lemmatisation manuelle

La table SYNONYMES dans fuzzy-picto.ts n’est pas un simple dictionnaire de conjugaisons — c’est une décision d’architecture. En AAC, les utilisateurs produisent des formes verbales conjuguées (ils tapent “mange” pas “manger”), des formes abrégées (“choc” pour “chocolat”), et des raccourcis personnels. Un vrai lemmatiseur NLP (spaCy, stanza) traiterait tout ça — mais il pèse 50-200 Mo, incompatible avec un outil web offline-first.

La table couvre trois niveaux de normalisation :

Conjugaisons fréquentes : les 20 verbes AAC les plus courants (manger, boire, aller, vouloir, pouvoir, faire, aimer, jouer, dormir, prendre…) avec leurs 6-8 formes conjuguées. Soit ~160 entrées pour les verbes seuls.

Pluriels irréguliers : "yeux""oeil", "chevaux""cheval", "genoux""genou" — les formes que la règle +s ne capture pas.

Synonymes conceptuels proches : "dîner""manger", "boisson""boire", "chaussure""soulier". Ces mappings couvrent les cas où l’embedding cosinus serait inférieur à 0.75 par manque de représentation dans le modèle multilingue.

const SYNONYMES: Record<string, string> = {
  // Conjugaisons
  'mange': 'manger', 'manges': 'manger', 'mangent': 'manger', 'mangez': 'manger',
  'boit': 'boire', 'bois': 'boire', 'boivent': 'boire',
  // Pluriels irréguliers
  'yeux': 'oeil', 'chevaux': 'cheval', 'genoux': 'genou',
  // Synonymes conceptuels
  'dîner': 'manger', 'déjeuner': 'manger', 'boisson': 'boire',
  // ~200 entrées au total
};

Benchmark Fuse vs Embeddings vs LLM : les résultats complets

Le tableau de performances de l’article résume les résultats — voici les détails du protocole de test :

Corpus de test : 150 phrases réelles collectées auprès d’orthophonistes partenaires de JB Thiéry. Phrases de 2 à 12 mots, incluant des erreurs typographiques courantes, des constructions syntaxiques simplifiées (“moi vouloir eau”), et des registres mixtes (noms propres + verbes courants).

Métrique de précision : un pictogramme est considéré “correct” si l’orthophoniste juge qu’il correspond à l’intention de l’utilisateur — pas nécessairement au mot exact. "véhicule" → pictogramme "voiture" est compté comme correct.

MéthodeTemps medianP95PrécisionFaux positifsHors-ligne
Fuse.js12ms28ms68%18%Oui
Embeddings WASM45ms89ms91%6%Oui
LLM Mistral800ms1400ms96%2%Non

Le P95 des embeddings (89ms) est la métrique critique pour l’AAC : sur des tablettes Android milieu de gamme (le matériel dominant dans les établissements spécialisés), le P95 monte à ~140ms — encore en dessous du seuil de 200ms perçu comme “lent” par les utilisateurs AAC.

Les ambiguïtés sémantiques résiduelles

Même à 91% de précision, certaines catégories de mots résistent à l’embedding seul :

Polysémie contextuelle : "main" peut être le corps humain ou l’outil de saisie d’un jeu. "pont" peut être la structure ou le congé. Sans contexte phrastique, le matching retourne systématiquement le sens le plus fréquent dans le corpus d’entraînement du modèle — qui n’est pas forcément le sens AAC.

Absence de pictogramme exact : certains concepts AAC courants (émotions nuancées comme “frustré”, “anxieux”) n’ont pas de pictogramme ARASAAC dédié. Le matching retourne le plus proche ("colère" pour "frustré") — ce qui est parfois acceptable, parfois trompeur.

Faux amis morphologiques : "banc" (siège) et "banque" ont un embedding cosinus de 0.71 — en dessous du seuil 0.75, donc pas de match automatique. Mais si on descend le seuil à 0.65, "banque""banc" devient un faux positif fréquent.

Ces cas résiduels sont précisément ceux que le fallback LLM résout — Mistral reçoit la phrase complète avec contexte et peut désambiguïser.

Export PNG/PDF : l’usage en séance d’orthophonie

L’interface JB Thiéry expose deux modes d’export que l’article de base n’aborde pas : PNG strip (une bande horizontale de pictogrammes, imprimable directement) et PDF A4 (mise en page avec le texte original en dessous, pour les cahiers de communication).

Ces formats sont générés côté client via canvas (PNG) et jsPDF (PDF), sans serveur. L’enseignant ou l’orthophoniste peut construire une séquence de communication en quelques secondes, l’imprimer, et l’utiliser en séance — même sans connexion internet.

Vous développez un outil d’accessibilité numérique ou un projet nécessitant du matching sémantique côté client ? Contactez-nous — nous combinons expertise AAC, NLP et développement web pour des solutions inclusives.