Un embedder de 107M qui bat des modèles cinq fois plus gros.

10 juillet 2026

Morrigan est notre assistant IA post-LLM : il tourne en entier sur une machine modeste, sans GPU et sans appel à un service externe. La génération repose sur un modèle RWKV-7 World 2.9B (un RNN à attention linéaire, pas un transformer) quantifié en q4_k_m, et les réponses sont ancrées sur un corpus documentaire par du RAG strict : sans contexte fiable retrouvé, le modèle n'est jamais appelé et Morrigan répond « Je ne sais pas ». Le retrieval n'est donc pas un détail d'implémentation, c'est le plafond de qualité de tout l'assistant.

Cette semaine, on a refait ce moteur. En une phrase : embedder choisi au banc d'essai (un modèle de 107M l'emporte sur des modèles bien plus gros), portage CPU d'un embedder RWKV publié en open source, deux résultats négatifs mesurés, et une architecture multi-corpus. Voici la méthode et les chiffres, échecs compris, parce que sur ce terrain un résultat négatif propre vaut souvent autant qu'un positif.

Le cas : retrieval cross-lingue sur du code

Notre contrainte est inhabituelle. Les questions arrivent en français, la documentation technique est en anglais. Il faut donc du retrieval cross-lingue (une requête FR doit retrouver un passage EN) sur un domaine à vocabulaire pointu : noms de fonctions, messages d'erreur, signatures d'API. Le classement d'un embedder sur un tableau généraliste anglophone ne dit rien de fiable sur cette tâche. On a donc benché sur nos requêtes.

Le banc d'essai

La métrique est le hit@k : la proportion de requêtes dont le bon document apparaît dans les k premiers résultats. Le jeu d'évaluation compte 56 requêtes FR ancrées sur un corpus de documentation code (Python, JavaScript, CSS, HTML, SQL/PostgreSQL, et des pages man shell et git), plus 24 requêtes hors-corpus pour mesurer le refus, la garde « 0 hallucination ».

Une précision méthodologique qu'on assume : pour comparer huit modèles sur une machine faible en une nuit, on ré-encode un sous-ensemble équitable de 5 000 chunks (les documents cibles y sont garantis, plus des distracteurs), identique pour tous les modèles. Le hit@k relatif entre modèles est donc fiable ; l'absolu est optimiste, puisque le sous-ensemble garantit la présence des cibles. On le dit plutôt que de le cacher. Détail de repro pour qui voudrait relancer le harnais : le shootout se bloque sur un check réseau du Hub Hugging Face même hors-ligne, il faut forcer HF_HUB_OFFLINE=1.

Résultats, hit@3 sur 56, retrieval FR→EN code :

modèletailledimshit@3licence
granite-embedding-278m-multilingual278M76855/56Apache-2.0
granite-embedding-107m-multilingual107M38453/56Apache-2.0
multilingual-e5-large560M102450/56MIT
multilingual-e5-small118M38448/56MIT
embeddinggemma-300m300M76848/56Gemma
multilingual-e5-base278M76846/56MIT
EmbeddingRWKV-0.4B389M76830/56Apache-2.0
EmbeddingRWKV-0.1B144M76826/56Apache-2.0

Le verdict est net : granite-embedding-107m (IBM, Apache-2.0). À 107M de paramètres et 384 dimensions, il bat des modèles deux à cinq fois plus lourds, ses poids sont locaux et sa licence permissive, et sa vitesse d'encodage sur CPU vaut celle d'e5-base. Le 278m gagne deux points de plus, mais pour 2,6 fois le poids, des vecteurs deux fois plus lourds, et un encodage nettement plus lent sur CPU (mesuré sous 0,7 chunk/s contre environ 2,7 pour le 107m). Deux hits sur 56 ne valent pas ce prix. On a retenu le 107m, et on a ré-indexé le corpus code avec.

Un mot sur les deux lignes RWKV, parce que le chiffre mérite une note honnête. Le fichier de résultats du harnais enregistre le 0.1B à 18/56 ; les 26 affichés ci-dessus sont la même mesure avec l'instruction de requête asymétrique (le préfixe Instruct:/Query:), désormais activée par défaut dans notre moteur. L'écart 18→26 dit à quel point ce préfixe compte pour ces modèles. Le 0.4B à 30/56 vient d'un run ponctuel, non re-versé dans le fichier de résultats : mesuré, mais moins reproductible que le reste du tableau. On y revient tout de suite, parce que c'est le cœur de l'affaire.

La partie RWKV : une contribution, et un résultat négatif

Notre pari de fond, c'est la cohérence d'architecture : si la génération tourne sur un RNN linéaire, le retrieval devrait pouvoir en faire autant. On voulait donc évaluer sérieusement EmbeddingRWKV, un modèle d'embedding bâti sur l'architecture RWKV-7 « Goose ».

La contribution : le faire tourner sur CPU

Premier obstacle : le code de référence d'EmbeddingRWKV est CUDA uniquement. Il compile un kernel .cu au chargement et impose device="cuda" en demi-précision. Sur une machine sans GPU, impossible à exécuter.

On a donc porté le chemin d'inférence texte en PyTorch pur, sur CPU, sans aucune dépendance CUDA. Le cœur du travail était de réimplémenter l'opérateur récurrent WKV-7 et de vérifier qu'il reste numériquement identique au kernel d'origine, pas une approximation. En posant u = w0 + tanh(xw·w1)·w2, notre chemin pur-torch calcule la décroissance ainsi :

# u, puis -softplus(-u) - 0.5
w = -F.softplus(-(self.w0 + torch.tanh(xw @ self.w1) @ self.w2)) - 0.5
# décroissance appliquée en exponentielle imbriquée
w = torch.exp(-torch.exp(w))          # = exp(-exp(-softplus(-u) - 0.5))

Le kernel CUDA, lui, applique w = exp(-0.6065 · sigmoid(u)), avec 0.6065 = exp(-0.5). Les deux expressions sont égales, par l'identité exp(-softplus(-u) - 0.5) = exp(-0.5) · sigmoid(u). Le port est donc fidèle par construction. Honnêteté d'ingénierie : cette équivalence est une identité algébrique, démontrée sur le papier et vérifiée par un sanity-check de pertinence, mais pas encore verrouillée par un test automatisé dans le dépôt.

On a publié ce portage en open source, sous licence Apache-2.0, comme dépôt autonome, pour que n'importe qui puisse faire tourner ce modèle d'embedding sur un CPU. C'est une contribution concrète à l'écosystème RWKV : ce qui n'existait qu'en version GPU tourne maintenant partout. Il est aussi annoncé et ouvert à la discussion côté Hugging Face, sur la page du modèle d'origine.

Une note de perf utile à d'autres. La récurrence RWKV est bornée par la bande passante mémoire sur son état interne [B, H, N, N], pas par le calcul. La compiler en JIT n'aide quasiment pas. Le seul levier qui paie, c'est un petit batch (4) qui garde l'état résident en cache ; un batch plus gros le fait déborder en RAM et ralentit.

Le résultat négatif : il ne rattrape pas les transformers

Une fois le modèle utilisable, la mesure est sans appel : 26/56 pour le 0.1B, 30/56 pour le 0.4B, contre 53 pour granite-107m. Et surtout, la taille n'explique pas l'écart. Le 0.4B, plus gros que les meilleurs transformers du banc, ne gagne que quatre points sur le 0.1B et reste à vingt-trois points derrière granite. Ce n'est pas un problème de capacité, c'est un problème de domaine : ce modèle n'a pas vu de retrieval multilingue sur du code à l'entraînement. Le score qu'il obtient sur les classements anglophones ne se transfère pas à notre cas cross-lingue.

Un symptôme concret : la distribution des similarités. Un match correct sort à un cosinus de 0,25 à 0,41 sur le RWKV, contre 0,75 à 0,95 sur granite ou e5. Le signal est là mais tassé, ce qui oblige à un seuil de gate propre au modèle (on est passé de 0,84 avec e5 à 0,64 avec granite ; le RWKV en réclamerait un autre encore).

Conclusion assumée : comme bi-encodeur de retrieval, on l'écarte. La cohérence d'architecture ne rachète pas une chute de vingt à trente points par réponse. Le portage CPU, lui, reste : c'est un artefact souverain réutilisable, et la piste d'un fine-tune sur notre domaine reste ouverte pour plus tard.

Deuxième résultat négatif : l'hybride ne sert à rien ici

On a aussi testé la recherche hybride, qui combine le sémantique (embeddings denses) et le lexical (BM25, un score de correspondance exacte de mots). C'est une technique éprouvée, souvent gagnante. On voulait la vérifier, pas la supposer. Ce bench-là tourne sur l'index code complet, environ 46 000 chunks, pas sur le sous-ensemble de 5 000 ; granite y sort donc à 50/56 plutôt que 53, cohérent avec l'écart optimiste du sous-ensemble.

méthodehit@1hit@3
dense (granite seul)43/5650/56
BM25 lexical seul12/5619/56
fusion RRF (dense + lexical)40/5650/56

Le lexical seul s'effondre (19/56), et le fusionner au dense n'apporte rien : le hit@3 reste identique et le hit@1 baisse. La raison tient au cas cross-lingue : une requête française ne partage pas ses mots avec un document anglais, sauf sur les identifiants de code communs. BM25 n'a presque rien à mordre. En monolingue la conclusion serait sans doute inverse ; ici, on ne construit pas d'hybride, et on l'a décidé par la mesure plutôt que par principe.

L'architecture : des corpus séparés, fusionnés à la requête

Reste l'organisation. Morrigan a plusieurs corpus : documentation code, connaissances générales, et désormais réseau informatique. La solution paresseuse serait de tout fondre dans un index unique. On a choisi l'inverse : garder chaque corpus dans son propre index et les fusionner au moment de la requête.

L'intérêt est double. La maintenance reste indépendante : on ré-encode ou on remplace un corpus sans toucher aux autres, et on peut évaluer chacun séparément. Et la précision est préservée : une question de code ne se retrouve pas diluée par un article encyclopédique.

Le FederatedRetriever interroge chaque corpus, applique un seuil de pertinence par corpus (gate = 0.64, la garde « 0 hallucination »), puis désigne le corpus primaire par son meilleur score. Un autre corpus n'est convoqué que si son score reste dans une marge du primaire :

tops[c] = max(tops.get(c, 0.0), s)   # meilleur score par corpus
primary = max(tops.values())         # primaire = le meilleur score, tous corpus confondus
elig = [x for x in cand if tops[x[2]] >= primary - self.margin]   # marge = 0.10

C'est là qu'a lieu la complétion croisée, quand une information vit dans deux corpus. Chaque chunk porte des étiquettes (corpus, doc_type, language, topic) qui alimentent un léger boost de routage (0.05) : une question qui mentionne « en python » ou nomme un outil (tcpdump, iptables) fait remonter les documents correspondants. Détail qui compte pour la cohérence : ce boost ne sert qu'à réordonner et à sélectionner ; le score renvoyé reste le cosinus brut, pour que le gate en aval garde son sens.

On a réglé la marge par balayage. Résultat mesuré, et un peu contre-intuitif : ouvrir la marge ne dégrade jamais la précision code (elle reste stable autour de 50/56 sur tout le balayage), parce que ce sont les scores qui protègent le routage, pas la marge. Et la complétion croisée reste un effet mineur (deux à trois requêtes sur douze en jeu mixte), simplement parce que nos corpus se recouvrent peu. On le dit franchement : ici, la vraie valeur de la fédération, c'est « répondre aux deux domaines, bien routé », pas une complétion riche qu'on aurait fantasmée.

Détail souverain sur le corpus réseau : ses 612 chunks (seize outils, de ip à tcpdump) sont construits à partir des pages man locales de la machine. Aucune dépendance cloud, aucun scraping.

Reproduire et aller plus loin

Pour qui veut refaire ou réutiliser ce travail :

Si vous travaillez sur du retrieval cross-lingue, deux points de ce travail vous feront gagner du temps : le lexical n'aide pas quand la requête et le document sont dans des langues différentes, et un modèle d'embedding fort sur les classements anglophones peut s'effondrer sur un domaine qu'il n'a pas vu à l'entraînement. Mesurez sur votre tâche.

Ce qu'il faut retenir

Le fil qui relie tout ça, c'est la même exigence que sur le reste de nos outils souverains : on mesure avant de choisir, on assume ce qui échoue, et on rend ce qu'on prend à l'écosystème.

Questions fréquentes

Pourquoi ne pas avoir pris le plus gros modèle du banc ?

Parce que le gain ne justifiait pas le coût. Le granite-278m marque 55/56 contre 53/56 pour le 107m, soit deux hits sur cinquante-six, au prix de 2,6 fois le poids, de vecteurs deux fois plus lourds, et d'un encodage CPU nettement plus lent. Sur une machine sans GPU, la vitesse d'indexation et l'empreinte mémoire comptent autant que le score. Le 107m était le meilleur compromis mesuré.

Le portage CPU du RWKV est-il vraiment fidèle au kernel CUDA ?

Oui, par construction. La décroissance de l'opérateur WKV-7 s'écrit exp(-exp(-softplus(-u) - 0.5)) dans notre chemin pur-torch, ce qui est algébriquement égal au exp(-0.6065 · sigmoid(u)) du kernel d'origine (avec 0.6065 = exp(-0.5)). C'est une identité exacte, pas une approximation numérique. La réserve honnête : cette équivalence est démontrée et vérifiée par sanity-check, mais pas encore verrouillée par un test automatisé dans le dépôt.

La recherche hybride dense+lexical est-elle toujours inutile ?

Non, la conclusion est scopée à notre cas. En cross-lingue (requête FR, document EN), BM25 n'a presque aucun mot commun à exploiter, donc la fusion n'aide pas et peut même dégrader le hit@1. En monolingue, où la requête et le document partagent le vocabulaire, l'hybride redevient très probablement gagnant. On n'a pas construit d'hybride ici parce que notre tâche est cross-lingue.

Pourquoi garder les corpus séparés plutôt qu'un seul index ?

Pour deux raisons concrètes : la maintenance et la précision. Des index séparés se ré-encodent, se remplacent et s'évaluent indépendamment, sans rien casser ailleurs. Et le routage par score évite qu'une question de code soit diluée par un article encyclopédique. La fusion à la requête récupère le meilleur des deux mondes : chaque corpus reste prouvable seul, et une question multi-domaine reçoit quand même une réponse bien routée.

Une question, un désaccord, une envie d'essayer ? Écrivez-moi, c'est le fondateur qui répond.