← Le blog Klar

Comment BERT a appris à lire votre boîte mail

Premier volet de la série BERT : comment le modèle XLM-RoBERTa de Klar transforme un e-mail en quatre scores, figures des articles et code à l’appui.

Voici le premier volet d’une série en deux parties. Il documente le moteur et le modèle publics qui propulsent Klar aujourd’hui. La suite étudiera comment une pile BERT de production évolue après les mesures et le déploiement.

ChatGPT a rendu célèbre une branche de la famille Transformer : on lui donne une consigne, puis il écrit. BERT a suivi la branche du lecteur. Il voit un texte complet, relie ses différentes parties et résume cette lecture dans un vecteur. Une petite tête utilise ensuite ce vecteur pour choisir une catégorie, par exemple le spam.

BERT est la moitié encodeur d’un Transformer

Le Transformer d’origine comportait un encodeur qui lisait l’entrée et un décodeur qui produisait la sortie. BERT, pour Bidirectional Encoder Representations from Transformers, a conservé la pile d’encodage. Il a été conçu pour comprendre un texte, pas pour le prolonger token après token.

Dans chaque couche, l’auto-attention permet à chaque token de pondérer les informations portées par les autres. Le mot « transfert » ne représente pas la même chose dans un avis bancaire et dans un compte rendu de football, car les mots voisins modifient sa représentation. Bidirectionnel signifie que le contexte de gauche et celui de droite sont disponibles. Un générateur causal ne peut pas consulter les mots qu’il n’a pas encore produits. Un encodeur BERT reçoit toute l’entrée d’un coup.

En haut : chaque mot d’une phrase relié à tous les autres dans les deux sens, produisant une classe unique. En bas : chaque mot relié seulement au précédent, pour prédire le suivant.
La différence essentielle. Un encodeur pèse le contexte des deux côtés d’un mot et aboutit à un seul vecteur pour tout le message ; un décodeur ne voit que ce qui précède et prédit ce qui suit. Schéma Klar, d’après Devlin et al., 2018
ArchitectureCe qu’elle litCe qu’elle produitUsages adaptés
Encodeur de la famille BERTL’entrée complèteDes vecteurs contextuels ou des scores de classeClassification, recherche, extraction
Décodeur causalLes tokens situés à gaucheUne probabilité pour le token suivantRédaction et génération ouverte

Le pré-entraînement apprend la langue. L’entraînement spécialisé lui donne un métier.

Le BERT d’origine a appris à partir de textes non étiquetés grâce au masked language modelling : masquer certains tokens, puis les prédire grâce au contexte situé de part et d’autre. Il utilisait aussi la prédiction de la phrase suivante. Cette première étape coûteuse produisait un encodeur généraliste. Un jeu de données étiqueté bien plus petit suffisait ensuite à l’adapter aux questions-réponses, à l’analyse de sentiment ou à une autre tâche.

« Fine-tuner BERT » ne signifie pas toujours entraîner uniquement la dernière couche. La recette d’origine met à jour l’encodeur pré-entraîné avec la tête spécialisée. Geler l’encodeur et n’entraîner que la tête est un choix d’ingénierie distinct. Il réduit le coût et le risque des petites mises à jour locales, mais ne peut pas remodeler la représentation du langage.

Une idée d’encodeur, quatre étapes utiles

Suivez la famille

BERT désigne désormais autant une famille qu’un modèle précis. Sélectionnez une étape pour voir ce qu’elle a apporté et ce qu’il restait à construire.

L’apport Un encodeur bidirectionnel profond, pré-entraîné avec des tokens masqués puis adapté à de nombreuses tâches linguistiques.
Ce qui manquait encore Les modèles et le vocabulaire d’origine visaient surtout l’anglais, et la méthode d’entraînement n’était qu’une première version.

Le modèle public que Klar exécute aujourd’hui

Le modèle en ligne aujourd’hui est icosha/spam-xlmr-v1, un classifieur de séquence XLM-RoBERTa-large. Son encodeur compte 24 couches, 16 têtes d’attention et une largeur cachée de 1 024. Klar convertit l’encodeur en GGUF quantifié, extrait la tête de classification, puis exécute les deux localement avec llama.cpp et ggml. Le code public est sous AGPLv3 ; le modèle public possède sa propre licence CC-BY-NC-4.0.

Une ligne d’e-mail transformée en tokens XLM-RoBERTa, commençant par le token spécial <s>, puis traversant 24 couches d’encodeur jusqu’à un vecteur de 1 024 valeurs.
L’entrée exacte que Klar construit. Le texte d’encadrement, le tokenizer qui coupe « verifying » en deux morceaux et le <s> initial dont le vecteur représente tout le message sont fixés par le modèle entraîné dessus. Schéma Klar, d’après Devlin et al., 2018
Chaîne de classification de Klar
RFC 822 email
  -> MIME parse and HTML-to-text
  -> normalized subject, sender and body
  -> canonical "User (email)" input
  -> XLM-RoBERTa-large encoder
  -> 1024-value CLS vector
  -> four-class head
  -> gibberish, marketing, regular or spam

Chaque flèche compte. Si l’entraînement utilise une représentation et la production une autre, la tête reçoit des vecteurs issus d’une distribution qu’elle n’a jamais apprise. Le moteur public impose donc un seul constructeur de l’entrée calibrée. Il enveloppe le message normalisé dans User (email): ..., puis ajoute les champs d’expéditeur uniquement lorsqu’ils existent. La classification et l’apprentissage local réutilisent exactement cette forme.

engine/spam_engine.cpp, extrait abrégé
for (const auto& exchange : transcript) {
  std::string from_type = exchange.from_type;
  if (!from_type.empty()) {
    from_type[0] = static_cast<char>(std::toupper(from_type[0]));
  }
  input_text += from_type + " (" + exchange.origin + "): "
              + exchange.text + "\n";
}

if (has_any_customer_signal) {
  input_text += "Customer Info:\n";
  if (!customer.name.empty()) input_text += "Name: " + customer.name + "\n";
  if (!customer.email.empty()) input_text += "Email: " + customer.email + "\n";
}

Étape 1 : des tokens à un vecteur pour toute la séquence

Le GgmlEncoder public utilise le tokenizer inclus dans le modèle GGUF. Il limite la longueur de la séquence, conserve le token de fin et vérifie que le token <s> de XLM-RoBERTa occupe la position zéro. Il joue le rôle de classification souvent décrit sous le nom [CLS]. Après le passage dans l’encodeur, le CLS pooling renvoie un vecteur pour l’ensemble du message.

engine/ggml_encoder.h, extrait abrégé
int n = llama_tokenize(vocab_, text.c_str(), text.size(),
                        token_buf_.data(), token_buf_.size(),
                        /* add_special = */ true,
                        /* parse_special = */ false);

if (n > max_tokens_) {
  n = max_tokens_;
  token_buf_[n - 1] = llama_vocab_eos(vocab_);
}

const llama_token bos = llama_vocab_bos(vocab_);
if (token_buf_.empty() || token_buf_.front() != bos) {
  token_buf_.insert(token_buf_.begin(), bos);
}

auto batch = llama_batch_get_one(token_buf_.data(), token_buf_.size());
llama_encode(ctx_, batch);

const float* emb = llama_get_embeddings_seq(ctx_, 0);
return std::vector<float>(emb, emb + n_embd_);

La vérification manuelle du début de séquence n’est pas décorative. Un convertisseur de modèle avait produit des métadonnées GGUF qui désactivaient le token spécial, même quand la tokenisation le demandait. Le pooling en position zéro renvoyait alors le premier mot ordinaire au lieu du résumé de la séquence. Le programme continuait à fonctionner, mais chaque embedding avait changé de sens. La parité de déploiement fait partie du modèle.

Étape 2 : du vecteur aux probabilités de classe

Le vecteur agrégé entre dans une petite tête de classification : une projection dense, tanh, une projection de sortie, puis softmax. Le moteur actuel expose quatre catégories neuronales. C’est cette partie qui transforme une représentation générale du langage en réponse propre à l’e-mail.

engine/spam_engine.cpp, extrait abrégé
const auto logits = impl_->trainable_head->forward(
    embedding, cache_for_training);
const auto probabilities = impl_->trainable_head->softmax(logits);

return ClassScores{
    probabilities[0],  // gibberish
    probabilities[1],  // marketing
    probabilities[2],  // regular
    probabilities[3],  // spam
};

La limite de tokens, l’encodeur et la tête de classification façonnent chacun une partie différente du modèle : modifier l’un apporte ce que les autres ne peuvent pas donner.

ChangementCe que cela apporteCe que cela coûte
Limite de tokensL’encodeur lit une plus grande partie du message.Un contexte plus long consomme mémoire et calcul, et le coût de l’attention augmente vite. La vue d’entraînement doit garder la même limite.
EncodeurChaque message reçoit une nouvelle représentation.La tête doit être entraînée dans ce nouvel espace. Conversion, pooling, quantification et latence sur le matériel cible sont à revalider.
Tête de classificationLa frontière entre les catégories se déplace.Presque rien. Les connaissances linguistiques de l’encodeur restent fixes, et cette petite partie peut s’adapter localement.

Personnaliser ne signifie pas réentraîner tout BERT

L’ajustement complet de l’encodeur appartient à une chaîne d’entraînement hors ligne, avec un corpus vaste et mesuré. Une correction utilisateur est plus petite et plus personnelle. Le chemin local de Klar calcule l’embedding avec l’encodeur gelé, mémorise le passage avant de la tête, rétropropage uniquement dans cette tête, puis applique une étape d’optimisation encadrée. La tête utilise un écrêtage des gradients, un rappel vers ses poids d’origine et une limite dure de dérive afin qu’une série de corrections dans un seul sens ne puisse pas la déplacer sans borne.

engine/spam_engine.cpp
float SpamEngine::train_embedding(
    const std::vector<float>& embedding,
    int correct_label) {
  (void)classify_embedding(embedding, true);
  const float loss = impl_->trainable_head->backward(correct_label);
  impl_->trainable_head->step(1);
  return loss;
}

Cette séparation donne un rôle clair à chaque partie. Le grand encodeur gelé fournit une compréhension générale dans de nombreuses langues. La petite tête apprend où le propriétaire de la boîte place la frontière. Les deux fonctionnent sur l’appareil, mais seule la tête change après une correction locale.

Ce que BERT sait bien faire, et ce qu’il ne sait pas faire

  • Adapté : classification, recherche et extraction quand toute l’entrée est disponible avant la réponse.
  • Adapté : sorties compactes et fixes qui ne nécessitent ni modèle génératif ni aller-retour vers le cloud.
  • Inadapté : rédaction ouverte. Un encodeur ne possède pas le décodeur autorégressif nécessaire à la production d’une réponse.
  • Insuffisant seul : identité, authentification, réputation et préférences personnelles. Ces éléments exigent des preuves extérieures au texte.

BERT reste important parce que tous les problèmes d’IA ne sont pas des conversations. Certains des modèles les plus utiles lisent une fois, renvoient une réponse bornée, puis disparaissent dans le logiciel qui les entoure. C’est son rôle dans Klar. Le moteur AGPLv3 complet, avec tous les extraits ci-dessus, est consultable sur github.com/klar-im/engine.

La deuxième partie reviendra sur les effets d’un changement d’encodeur, de représentation d’entrée et de contraintes de déploiement.

Une banque a suivi le même plan sur 26 millions de clients

Addendum · août 2026

Quatre mois avant la publication de cet article, Revolut Research et NVIDIA ont publié PRAGMA, un modèle de fondation pour les historiques d’événements bancaires. Ce n’est pas un modèle d’e-mail, et il ne lit pas le texte comme Klar. Il mérite quand même une lecture : une équipe disposant de 26 millions de dossiers clients et de 32 H100 a fait les mêmes choix d’architecture que ceux décrits plus haut, et a buté sur la même limite à la fin.

PRAGMA: Revolut Foundation Model Ostroukhov et al. · arXiv · 9 avril 2026

PRAGMA Revolut Research et NVIDIA, avril 2026

Encodeur seul et bidirectionnel, parce que l’objectif est d’obtenir des représentations transférables pour des tâches financières discriminantes, et non de générer du texte libre.

Klar Le moteur décrit plus haut, sur votre Mac

Un encodeur XLM-RoBERTa qui s’arrête à quatre scores. Rien dans le modèle ne sait rédiger une phrase, et rien n’en a besoin.

Les différences sont aussi instructives que les points communs. PRAGMA est un modèle d’un milliard de paramètres sur 32 H100, qui lit des années de transactions pour évaluer un risque de crédit ; Klar est un modèle de 550 millions de paramètres qui lit un seul message sur votre portable, sans que rien ne quitte la machine. Ce qui survit à cet écart d’échelle, c’est la forme : lire toute l’entrée d’un coup, apprendre la langue en comblant des trous, geler le résultat et y accrocher une petite tête, puis accepter que ce qui vit en dehors des mots reste en dehors du modèle.

Consulter les sources originales

Ces articles scientifiques et dépôts de code tracent le chemin utile entre le Transformer d’origine et le moteur présenté ici.

  1. Attention Is All You Need Vaswani et al. · 2017
  2. BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding Devlin et al. · 2018
  3. RoBERTa: A Robustly Optimized BERT Pretraining Approach Liu et al. · 2019
  4. Unsupervised Cross-lingual Representation Learning at Scale Conneau et al. · 2020
  5. PRAGMA: Revolut Foundation Model Ostroukhov et al. · 2026
  6. Modèle anti-spam XLM-RoBERTa public de Klar icosha · CC-BY-NC-4.0
  7. Moteur anti-spam Klar et milter Postfix Klar · AGPLv3

Note de septembre 2026 : cet article décrit le premier modèle public de Klar, un transformeur XLM-RoBERTa d’environ 550 millions de paramètres. Le modèle publié de Klar est désormais un encodeur plus petit et plus précis de la même famille, multilingual-e5-base, environ 280 millions de paramètres, 12 couches et des vecteurs de 768 valeurs, ajusté sur le corpus de mails de Klar ; l’application Mac l’embarque à partir de la mise à jour qui l’inclut. Tout ce que l’article dit sur l’articulation entre le lecteur, la tête et l’exécution locale reste vrai ; seuls les chiffres de taille ont changé. Il tourne toujours sur votre Mac.

Tous les articles

Reprenez le contrôle de vos mails

Anti-spam pour Apple Mail. Gratuit sur l’App Store.

Télécharger dans l’ App Store