A propos Compétences Expérience Services Blog Contact

Les embeddings ne savent pas dire non Embeddings cannot say no

Mon détecteur de messages marchait sur mon jeu de test. Sur des messages jamais vus, il ratait un vrai cas sur deux.

TL;DR : j'ai construit un petit détecteur qui repère les messages demandant une action. Il repose sur les embeddings, une technique qui transforme une phrase en nombres. Il marche bien, sauf sur un point : il ne comprend pas la négation. « La panne est réparée, merci » déclenche comme « panne ». J'explique pourquoi c'est un défaut de nature, pas de réglage, chiffres à l'appui. Et pourquoi la bonne réponse est d'accepter ce défaut plutôt que de le corriger.

Cet article est pour les devs qui veulent brancher de l'IA sans payer un gros modèle à chaque message. Aucune connaissance en machine learning n'est requise : je définis tout en route.

Le contexte

Dans un projet perso, une IA surveille un chat entre utilisateurs. Quand un message demande une action, un badge s'affiche. Créer un ticket, envoyer un document.

Appeler un LLM sur chaque message coûte cher. En argent et en temps de réponse. J'ai déjà défendu cette idée ici : mets le LLM en dernier.

Le système a donc deux étages. L'étage 1 est un petit modèle, gratuit à faire tourner, qui trie les messages. L'étage 2 est le LLM, appelé seulement quand l'utilisateur clique sur le badge.

L'étage 1 utilise des embeddings. Un embedding transforme une phrase en une liste de nombres. Deux phrases de sens proche donnent des listes de nombres proches. C'est tout ce qu'il faut comprendre pour la suite.

Décider avec un score de ressemblance

Comment un embedding prend une décision ? En mesurant la ressemblance entre deux phrases. Ce score s'appelle la similarité cosinus. Proche de 1 : les phrases se ressemblent beaucoup. Plus bas : elles n'ont rien à voir.

Mon détecteur compare chaque message aux outils disponibles. « Créer un ticket », « envoyer un document ». Si le message ressemble assez à un outil, il est jugé actionnable.

Toute la question tient dans « assez ». C'est là que ça se complique.

La barre fixe ne marche pas

Premier réflexe : fixer une barre. Au-dessus de 0,85 de ressemblance avec un outil, le message est actionnable.

Ça ne marche pas. Mon modèle donne des scores entre 0,80 et 0,92, pour tout. « Bonjour » et « crée un ticket » obtiennent presque le même score.

Ce modèle voit toutes les phrases comme un peu ressemblantes. Ses scores sont tassés dans un mouchoir de poche. Et une barre fixe ne sépare rien dans un mouchoir de poche. Monte la barre, et tu perds les vrais cas avant de perdre le bruit.

Compare à des phrases neutres, pas à une barre

La solution qui a marché : comparer, au lieu de mesurer dans l'absolu.

J'ai écrit une liste de phrases neutres, sans aucune action dedans. « Bonjour », « merci », « ok super ». Je les appelle les ancres.

La règle devient simple. Un message est actionnable s'il ressemble plus à un outil qu'à la meilleure ancre. S'il ressemble surtout à « merci », c'est du bavardage.

# La règle de décision
tool_score    = similarity(message, closest_tool)
neutral_score = similarity(message, closest_anchor)

actionable = tool_score > neutral_score

Un détail qui compte : je découpe les messages longs à la ponctuation. Une demande noyée dans la politesse ressort mieux morceau par morceau. Revers de la médaille : sans ponctuation, pas de découpage. La dictée vocale en met rarement.

Un faux positif sur quatre

J'ai évalué sur 300 messages générés. Générés, pas réels : le projet n'avait pas encore d'utilisateurs. Garde ça en tête, la réalité fera moins bien.

Deux mesures comptent, et elles sont simples. La première : sur 100 messages qui méritent une action, combien le détecteur en attrape. Chez moi, 92. C'est le rappel, et c'est un bon score.

La seconde : sur 100 messages anodins, combien il laisse tranquilles. Chez moi, 76. Autrement dit, 24 messages anodins sur 100 déclenchent le badge pour rien. C'est la spécificité, et c'est le chiffre qui fâche.

Un badge qui se trompe une fois sur quatre, l'utilisateur arrête d'y croire. Trop de fausses alertes tuent l'alerte. Le premier chiffre brille dans la démo. Le second se paie en production.

Les embeddings ne savent pas dire non

Le pire cas d'échec a un nom : la négation. « Il y a une panne » demande une action. « La panne est réparée, merci » n'en demande aucune.

Pour le détecteur, ces deux phrases sont presque identiques. Même vocabulaire, donc des nombres presque identiques. La seconde déclenche le badge exactement comme la première.

Ce n'est pas un réglage raté. Un embedding résume une phrase par son sujet. Et le sujet des deux phrases est le même : une panne. Le « c'est réparé » ne pèse presque rien dans les nombres.

Tu peux le vérifier chez toi en deux minutes. Prends tes phrases positives, ajoute « c'est réparé, merci » à la fin de chacune. Puis regarde les scores : ils bougent à peine.

L'examen blanc m'a rattrapé

J'ai d'abord cru pouvoir corriger la négation par le réglage. Douze ancres de plus, du type « c'est réparé », « problème réglé ». Et une barre de décision plus haute.

Sur mon jeu de test, superbe. Alors j'ai fait passer un examen blanc au détecteur. En jargon : un holdout. Un jeu de messages neufs, jamais utilisés pendant le réglage. Des sujets inédits, pas les annales.

Verdict. Les fausses alertes du type « c'est réparé » ont bien disparu. Mais le détecteur est passé de 68 à 48 vrais cas attrapés sur 100. Ma correction ratait plus d'un vrai cas sur deux.

Ça s'appelle le surapprentissage. Mon réglage avait appris mes exemples par cœur, au lieu d'apprendre le problème. Comme un élève qui récite les annales et coule sur un sujet neuf.

Pire : sur l'examen blanc, aucune barre ne donne à la fois un bon rappel et peu de fausses alertes. Le bouton que je tournais n'a plus rien à donner. Le plafond est structurel.

Toute la précision appartient au LLM

Les chiffres m'ont imposé la conclusion. Ce détecteur ne sera jamais précis. En revanche, il peut être exhaustif.

Je lui ai donc choisi un rôle : une porte, pas un juge. Barre au plus bas, pour attraper large. Sur l'examen blanc : 88 vrais cas sur 100 attrapés, et 31 messages anodins sur 100 qui passent pour rien.

Ce bruit est accepté, et écrit noir sur blanc dans le design. Parce que l'étage 2 juge derrière : le LLM, appelé au clic, sait lire « c'est réparé ». Lui filtre la négation et choisit le bon outil.

# La config shippée : une porte, pas un juge
model     : multilingual-e5-small   # petit, sur CPU, zéro entraînement
threshold : lowest                  # attraper large, assumé
anchors   : smalltalk + resolution  # « merci », « c'est réparé »...
result    : 88 vrais cas sur 100 · 31 fausses alertes sur 100

Dernière décision, la plus contre-intuitive : ne pas entraîner le modèle. Je n'ai pas de vraies conversations. Et entraîner sur des messages générés, c'est apprendre les tics du générateur. L'examen blanc venait de me montrer ce piège. J'entraînerai quand j'aurai quelques centaines de vrais messages anonymisés.

La checklist avant de shipper un détecteur à embeddings

Avant de mettre un score de ressemblance en production, passe la liste.

  • Teste sur des messages jamais vus, pas sur ceux qui ont servi au réglage
  • Compte les fausses alertes, pas seulement les bonnes détections
  • Essaie des phrases de négation : « c'est réparé », « plus besoin, merci »
  • Compare à des phrases neutres plutôt que de fixer une barre absolue
  • Donne à l'étage embeddings un rôle de porte : attraper large, laisser juger plus loin
  • Sache combien de fausses alertes l'étage suivant peut absorber
  • N'entraîne jamais sur des données générées

Ce qu'il faut retenir

Un score de ressemblance ne comprend pas « non ». Ce n'est pas un défaut de réglage, c'est la nature de l'outil.

Alors mesure sur du jamais-vu, publie tes chiffres moches, et place chaque étage là où il est bon. L'embedding attrape. Le LLM comprend.

Tu construis une détection d'intention ou un routage IA, et les chiffres ne tiennent pas en production ? Parlons-en.

My message detector worked on my test set. On messages it had never seen, it missed one real case out of two.

TL;DR: I built a small detector that spots messages asking for an action. It relies on embeddings, a technique that turns a sentence into numbers. It works well, except for one thing: it does not understand negation. "The outage is fixed, thanks" fires exactly like "outage". I explain why this is a flaw of nature, not of tuning, with numbers to back it. And why the right answer is to accept the flaw rather than fix it.

This article is for developers who want to plug in AI without paying a large model on every message. No machine learning background needed: I define everything along the way.

The setup

In a personal project, an AI watches a chat between users. When a message asks for an action, a badge shows up. Create a ticket, send a document.

Calling an LLM on every message is expensive. In money and in response time. I already made that case here: put the LLM last.

So the system has two stages. Stage 1 is a small model, free to run, that sorts messages. Stage 2 is the LLM, called only when the user clicks the badge.

Stage 1 uses embeddings. An embedding turns a sentence into a list of numbers. Two sentences with close meanings give close lists of numbers. That is all you need to understand for the rest.

Deciding with a similarity score

How does an embedding make a decision? By measuring how much two sentences look alike. That score is called cosine similarity. Close to 1: the sentences are very similar. Lower: they have nothing in common.

My detector compares each message to the available tools. "Create a ticket", "send a document". If the message looks enough like a tool, it is deemed actionable.

The whole question sits inside "enough". That is where things get hard.

A fixed bar does not work

First instinct: set a bar. Above 0.85 similarity with a tool, the message is actionable.

It does not work. My model gives scores between 0.80 and 0.92, for everything. "Hello" and "create a ticket" get almost the same score.

This model sees every sentence as somewhat similar. Its scores are packed into a tiny range. And a fixed bar separates nothing inside a tiny range. Raise the bar, and you lose real cases before you lose the noise.

Compare against neutral phrases, not a bar

The solution that worked: compare, instead of measuring in the absolute.

I wrote a list of neutral phrases, with no action in them. "Hello", "thanks", "ok great". I call them anchors.

The rule becomes simple. A message is actionable if it looks more like a tool than like the best anchor. If it mostly looks like "thanks", it is chit-chat.

# The decision rule
tool_score    = similarity(message, closest_tool)
neutral_score = similarity(message, closest_anchor)

actionable = tool_score > neutral_score

One detail that matters: I split long messages on punctuation. A request drowned in politeness stands out better piece by piece. The flip side: no punctuation, no splitting. Voice dictation rarely adds any.

One false alarm out of four

I evaluated on 300 generated messages. Generated, not real: the project had no users yet. Keep that in mind, reality will do worse.

Two measures matter, and they are simple. First: out of 100 messages that deserve an action, how many the detector catches. Mine catches 92. That is called recall, and it is a good score.

Second: out of 100 harmless messages, how many it leaves alone. Mine leaves 76. In other words, 24 harmless messages out of 100 trigger the badge for nothing. That is called specificity, and it is the number that hurts.

A badge that is wrong one time out of four stops being believed. Too many false alarms kill the alarm. The first number shines in the demo. The second one is paid in production.

Embeddings cannot say no

The worst failure mode has a name: negation. "There is an outage" asks for an action. "The outage is fixed, thanks" asks for none.

To the detector, these two sentences are almost identical. Same vocabulary, so almost the same numbers. The second one triggers the badge exactly like the first.

This is not a botched setting. An embedding summarizes a sentence by its topic. And both sentences have the same topic: an outage. The "it's fixed" part weighs almost nothing in the numbers.

You can check this yourself in two minutes. Take your positive sentences, append "it's fixed, thanks" to each one. Then look at the scores: they barely move.

The mock exam caught up with me

At first I believed I could fix negation through tuning. Twelve more anchors, like "it's fixed" and "problem solved". And a higher decision bar.

On my test set, beautiful. So I gave the detector a mock exam. In jargon: a holdout. A set of fresh messages, never used during tuning. New exam questions, not the past papers.

The verdict. The "it's fixed" false alarms did disappear. But the detector dropped from catching 68 real cases out of 100 to just 48. My fix missed more than one real case out of two.

That is called overfitting. My tuning had memorized my examples instead of learning the problem. Like a student who recites past papers and sinks on a fresh question.

Worse: on the mock exam, no bar gives both a good catch rate and few false alarms. The knob I was turning has nothing left to give. The ceiling is structural.

All the precision belongs to the LLM

The numbers forced the conclusion on me. This detector will never be precise. It can, however, be exhaustive.

So I picked its role: a gate, not a judge. Bar at the lowest, to catch wide. On the mock exam: 88 real cases out of 100 caught, and 31 harmless messages out of 100 let through for nothing.

That noise is accepted, and written into the design. Because stage 2 judges behind it: the LLM, called on click, can read "it's fixed". It filters the negation and picks the right tool.

# The shipped config: a gate, not a judge
model     : multilingual-e5-small   # small, on CPU, zero training
threshold : lowest                  # catch wide, on purpose
anchors   : smalltalk + resolution  # "thanks", "it's fixed"...
result    : 88 real cases out of 100 · 31 false alarms out of 100

Last decision, the most counter-intuitive one: do not train the model. I have no real conversations. And training on generated messages means learning the generator's quirks. The mock exam had just shown me that trap. I will train once I have a few hundred real, anonymized messages.

The checklist before you ship an embedding detector

Before a similarity score goes to production, run the list.

  • Test on messages never seen before, not on the ones used for tuning
  • Count the false alarms, not just the good catches
  • Try negation phrases: "it's fixed", "no need anymore, thanks"
  • Compare against neutral phrases rather than setting an absolute bar
  • Give the embedding stage a gate's role: catch wide, let something else judge
  • Know how many false alarms the next stage can absorb
  • Never train on generated data

What to remember

A similarity score does not understand "no". That is not a tuning flaw, it is the nature of the tool.

So measure on never-seen data, publish your ugly numbers, and put each stage where it is good. The embedding catches. The LLM understands.

Building intent detection or AI routing, and the numbers do not hold in production? Let's talk.