Morrigan, notre assistant IA souverain, repose sur un pari : zéro hallucination structurelle. Sans contexte documentaire jugé fiable, le modèle de génération n'est jamais appelé, et le système répond « je ne sais pas ». Ce pari fait porter tout le poids sur le retrieval. Un embedder médiocre ne dégrade pas les réponses : il les supprime (refus à tort) ou les empoisonne (mauvais contexte présenté comme fiable).
Le cas d'usage est exigeant : des questions en français sur de la documentation technique en anglais (man pages, MDN, doc Python). Du cross-lingue, du jargon, des corpus fédérés.
Et nous avions une contrainte de thèse : Morrigan génère avec RWKV-7, un RNN, pas un transformer. Coût linéaire, état constant, 100 % CPU. La question qui fâche : le retrieval pouvait-il, lui aussi, tourner sur cette architecture sans sacrifier la qualité ?
Méthode : sous-ensemble équitable de 5 000 chunks de notre corpus de production (46 569 au total), passages pertinents garantis présents, mêmes 56 requêtes FR pour tous les modèles, hit@k comparable. Le jeu d'éval est versionné et n'a jamais servi à entraîner quoi que ce soit.
| modèle | params | dims | licence | hit@1 | hit@3 | vitesse CPU¹ |
|---|---|---|---|---|---|---|
| granite-107m | 107M | 384 | Apache-2.0 | 39 | 53/56 | ~2,7 ch/s |
| granite-278m | 278M | 768 | Apache-2.0 | 41 | 55/56 | <0,7 (lent) |
| multilingual-e5-large | 560M | 1024 | MIT | 41 | 50/56 | ~0,9 |
| multilingual-e5-small | 118M | 384 | MIT | 33 | 48/56 | la + rapide |
| embeddinggemma-300m | 300M | 768 | Gemma | 37 | 48/56 | ~2,4 |
| multilingual-e5-base | 278M | 768 | MIT | 32 | 46/56 | ~2,2 |
| EmbeddingRWKV 0.4B | 389M | 768 | Apache-2.0 | 23 | 30/56 | ~1,4 |
| EmbeddingRWKV 0.1B | 144M | 768 | Apache-2.0 | 22 | 26/56 | ~4 |
¹ chunks/seconde sur i5-10210U (laptop) ; l'absolu dépend du throttling, le relatif est fiable.
Verdict sans appel : granite-107m offre le meilleur rapport précision/poids/vitesse de sa catégorie, à 2 hits seulement du 278m qui coûte 2,6 fois son poids et reste inutilisable en CPU. Il est passé en production. Le leader de la classe 100-150M, c'est lui.
Et l'embedder RWKV ? 26/56 pour le 0.1B (mesuré avec l'instruction de requête recommandée, le défaut de notre moteur ; 18/56 sans), à 22-27 hits du haut du tableau. Le papier d'origine (EmbeddingRWKV, arXiv:2601.07861) annonçait pourtant un MTEB anglais de 63.06, à 0,8 point d'un transformer de même taille (gte-base : 63.90). Mais le multilingue n'y était pas évalué : notre bench était, à notre connaissance, la première mesure cross-lingue de ce modèle. Conclusion provisoire : écarté.
Une ligne de bas de page de notre propre doc de bench disait : « RWKV évalué à cap 256 tokens (concession de temps CPU) vs 512 pour les autres ; si RWKV est proche, re-run fair-512 recommandé ». À 26/56, personne n'y croyait. On l'a fait quand même.
| EmbeddingRWKV 0.1B | hit@1 | hit@3 | gate² |
|---|---|---|---|
| cap 256 tokens | 22 | 26/56 | 0,70 |
| cap 512 (équitable) | 38 | 48/56 | 0,911 |
² gate = balanced accuracy de séparation in-corpus / hors-corpus ; notre mesure du « sait dire je ne sais pas ».
+22 hits en levant le cap. L'explication tient à l'architecture. Un transformer en mean-pooling moyenne tous les tokens : tronquer le passage lui coûte peu. Un RNN causal poole son état final, construit de gauche à droite : tronquer à mi-passage ampute l'embedding de la moitié du contenu. Leçon d'évaluation qui dépasse notre cas : un cap de troncature « raisonnable » peut écraser une architecture récurrente de 22 points sans qu'aucune erreur ne soit visible nulle part.
À armes égales, le RNN de 144M faisait donc jeu égal avec e5-small et embeddinggemma-300m, battait e5-base (278M), et se tenait à 5 hits de granite. La campagne devenait jouable.
Règles fixées à l'avance : une seule hypothèse par run (jamais deux changements à la fois), le bench local comme juge unique, tous les checkpoints conservés, les échecs documentés. Compute : le tier gratuit de Kaggle (T4, ~30 h/semaine) pour l'entraînement, le laptop pour l'inférence et les verdicts. Fine-tune léger : tête retrieval plus les 2 derniers blocs (16,3M paramètres entraînés sur 144M).
60 000 paires du config « french » de mMARCO plus 20 000 CodeSearchNet, InfoNCE in-batch. Résultat : 47/56 (best), 43/56 (final), une régression sous la baseline de 48, qui s'aggrave avec la durée d'entraînement (48, puis 47, puis 43 : une courbe dose-réponse propre).
Le diagnostic tenait en une ligne : le config « french » de mMARCO fournit des paires FR→FR. Notre bench mesure du FR→EN. Nous avons entraîné le modèle à être monolingue, et il a consciencieusement désappris le cross-lingue. Deuxième faille : 8 paires par batch, soit 7 négatifs par positif ; la perte s'effondrait à ~0,005 en 300 pas, plus aucun signal.
Les traductions mMARCO étant alignées par identifiants, zipper le flux « french » et le flux « english » donne des paires (requête FR, passage EN) exactes. 50 000 paires FR→EN, 10 000 FR→FR de rétention, 10 000 CodeSearchNet, batch de 24 (23 négatifs). Une époque.
Résultat : hit@1 39, parité avec granite, hit@3 48, gate 0,920.
Mêmes données, deuxième époque. Le score interne de validation montait encore (0,902 à 0,910), et le bench a reculé : 36 hit@1, 47 hit@3. Sur-spécialisation au style mMARCO, dérive de la cible. Loi de campagne n° 4 : sur ces volumes, une époque, pas davantage. Un proxy de validation qui monte ne prouve rien sur la compétence visée.
Avant le round final, nous avons disséqué les 8 échecs restants du hit@3 et croisé avec les échecs de granite sur les mêmes requêtes :
Le vocabulaire de domaine ne s'apprend pas dans des paires web généralistes. Nous avons donc généré nos données : 1 052 requêtes françaises synthétiques produites par un LLM ouvert que nous exécutons nous-mêmes (Qwen2.5-7B, sur le GPU gratuit) à partir des chunks de notre propre corpus. Environ 600 chunks visés (400 ciblés sur les domaines des 6 échecs, 200 tirés au hasard), dont 547 ont produit des requêtes retenues, avec une garde anti-fuite stricte : toute requête générée trop proche d'une requête du jeu d'éval est jetée (recouvrement Jaccard supérieur à 0,5 = poubelle), zéro fuite vérifiée.
Recette finale : requêtes domaine ×3, plus 8 000 paires FR→EN de rétention, départ depuis le champion du round 2, une seule passe, learning rate doux.
| params | hit@1 | hit@2³ | hit@3 | gate | |
|---|---|---|---|---|---|
| RWKV baseline (fair-512) | 144M | 38 | — | 48 | 0,911 |
| RWKV round 2 | 144M | 39 | — | 48 | 0,920 |
| RWKV round 3 (final) | 144M | 43 | 47 | 51 | 0,938 |
| granite-107m | 107M | 39 | 47 | 53 | 0,932 |
| granite-278m | 278M | 41 | — | 55 | 0,940 |
| e5-large | 560M | 41 | — | 50 | 0,92 |
³ hit@2 = la métrique de production : Morrigan n'injecte que les 2 premiers chunks dans le prompt de génération.
Lecture :
343 requêtes du dev set MIRACL français, pool de 5 000 passages Wikipédia (731 passages-or garantis, distracteurs échantillonnés uniformément sur les 14,6M du corpus). Domaine généraliste, monolingue FR : tout ce que notre fine-tune n'a pas visé.
| hit@1 | hit@2 | hit@3 | hit@10 | |
|---|---|---|---|---|
| granite-107m | 97,7 % | 99,1 % | 99,4 % | 99,7 % |
| RWKV round 3 | 95,6 % | 98,3 % | 98,8 % | 99,7 % |
| RWKV baseline | 95,9 % | 99,1 % | 99,4 % | 99,7 % |
Deux réponses dans ce tableau :
À ce stade, tout disait : basculez. Deux juges sur deux, la parité en production, la rétention intacte. C'est là qu'on a exigé un troisième juge. Il a tout changé.
Tous les chiffres précédents venaient d'évals en pool : 5 000 passages, pertinents garantis, distracteurs échantillonnés. Avant de toucher à la production, nous avons imposé une porte de bascule : le même duel, mais sur les stacks complètes : 46 569 chunks de code plus les trois corpus système, retrieval fédéré, gates de production, zéro échantillonnage.
| stack complète (46k+) | hit@1 | hit@2 | hit@3 | faux refus | refus hors-corpus |
|---|---|---|---|---|---|
| granite-107m | 37 | 44 | 49 | 5/56 | 21/24 |
| RWKV round 3 | 35 | 40 | 44 | 9/56 | 20/24 |
L'avantage du subset s'est inversé. Tout le monde descend en passant de 5 000 à 46 000 candidats, mais pas à la même vitesse : granite perd 2 à 4 hits, le RNN en perd 7 à 8. Et il refuse à tort presque deux fois plus (9 contre 5).
Le mécanisme, une fois vu, est limpide. Le fine-tune a appris l'attraction (rapprocher la requête du bon document), et ça tient à toute échelle. Il n'a jamais appris la séparation : nos batchs contrastifs opposaient chaque paire à 23 négatifs aléatoires, jamais aux quasi-voisins qui pullulent dans un vrai corpus (les 40 pages MDN sur les fonctions, les 15 man pages réseau presque identiques). Dans un pool de 5 000, peu de sosies : l'attraction suffit. Dans 46 000, les sosies décident du classement. Et là, l'espace « affûté » de granite, forgé par IBM sur des volumes massifs avec négatifs durs, fait la différence. Un facteur aggravant : la distribution de cosinus du RNN est resserrée (~0,2-0,6 contre ~0,3-0,9), moins de plage dynamique pour départager les quasi-égalités.
Décision : pas de bascule. Notre règle, écrite avant la campagne, disait : la thèse gagne les égalités et les petites défaites, pas les nettes. Celle-ci est nette. granite reste en production. Le protocole a fait exactement ce pour quoi il existait : nous empêcher de basculer sur de beaux chiffres de pool.
Ce résultat est aussi, à notre connaissance, une donnée nouvelle : les évaluations en pool surestiment systématiquement un embedder RNN relativement à un transformer. L'écart de sensibilité à l'échelle n'apparaît qu'en conditions réelles. Si vous benchez des architectures différentes sur des pools réduits, ce biais vous concerne.
Restait une carte à jouer, la bonne : un round 4 aux négatifs durs minés à pleine échelle, entraîner le modèle contre ses propres sosies, exactement la technique qui a forgé les embedders de référence. Mais en préparant ce round, on a d'abord trouvé autre chose.
Une piste extérieure nous met la puce à l'oreille : comment gérons-nous le token de fin de séquence (EOS) à la troncature ? Vérification faite, notre préprocessing (portage CPU et lib d'entraînement) encodait avec EOS puis tronquait au cap. Tout texte plus long que le cap perdait donc son EOS à la coupe.
Or le pooling d'EmbeddingRWKV ne lit l'état caché qu'aux positions EOS. EOS absent → masque de pooling vide → vecteur nul → la tête projette le même embedding constant pour tous les textes trop longs. Aucune erreur, aucun NaN, aucun avertissement. Mesure : cosinus de 1,0000 entre un texte long de code Python et une recette de tarte. Après correction (tronquer à max_tokens − 1 puis ajouter l'EOS) : 0,159.
L'ampleur, mesurée sur des échantillons de 400 chunks par corpus :
| cap de troncature | usage | textes touchés (EOS perdu) |
|---|---|---|
| 512 tokens | inférence / indexation | 0,5 % des chunks (~250 sur 46 569 au point constant) |
| 320 tokens | entraînement Kaggle | 19 à 25 % des passages selon le corpus |
Autrement dit : un passage d'entraînement sur cinq des rounds 1 à 3 n'apportait aucun signal, ou pire, attirait la requête vers le point constant. Le fair-512 du §3 s'explique d'ailleurs en partie par ce mécanisme : monter le cap de 256 à 512 sortait du point constant tous les chunks de 257 à 512 tokens. Les données minées du round 4 étaient, elles, saines (0 négatif sur 6 292 au-delà du cap d'indexation, vérifié).
Le fix fait deux lignes ; il est publié dans le portage CPU et signalé sur le fil de discussion du modèle. La leçon dépasse largement notre cas : un embedder à pooling d'état terminal ne dégrade pas gracieusement à la troncature : il s'effondre en silence. Un transformer mean-pooling perd de l'information ; un RNN qui lit son état à l'EOS perd tout, d'un coup, sans le dire. Si vous tronquez, vérifiez que votre token de lecture survit à la coupe.
Le round 4 attaque le mécanisme du §6 : chaque requête s'entraîne contre ses propres sosies, les 6 documents qui la piégeaient réellement, minés parmi les erreurs top-k du champion sur la stack complète. En chiffres : 1 052 requêtes domaine × 6 négatifs durs (6 292 au total), chaque paire vue 3 fois avec des tirages de sosies différents, plus 8 000 paires FR→EN de rétention (7 744 à l'entraînement, 256 en validation pure). Perte InfoNCE à négatifs explicites : batchs de 12 paires × (1 positif + 3 négatifs durs échantillonnés) = 60 séquences par pas, ~908 pas, une seule passe, LR 1e-5, toujours 16,3M paramètres entraînés, avec le préprocessing corrigé du §7.
Note de méthode, à la loyale : notre juge pleine échelle d'origine a été perdu entre-temps (un script resté dans un répertoire temporaire, un redémarrage ; un juge est du code : versionnez-le). Il a été reconstruit en plus strict : les hits comptés sur la recherche fédérée brute, le verdict de refus rendu par le vrai gate de production (seuil cosinus + garde cross-langage + garde lexicale), le seuil de chaque modèle calibré par balanced accuracy sur ses propres scores. Les trois prétendants ont été re-jugés dans des conditions strictement identiques : stacks complètes de production, 49 795 chunks (46 569 code + 1 988 sysadmin + 626 sécurité + 612 réseau). Les chiffres qui suivent ne sont donc pas comparables ligne à ligne avec ceux du §6. Entre eux, ils le sont parfaitement.
| pleine échelle, juge reconstruit | hit@1 | hit@2⁴ | hit@3 | faux refus | refus hors-corpus | gate calibré |
|---|---|---|---|---|---|---|
| granite-107m (production) | 41/56 | 47/56 | 48/56 | 2/56 | 17/24 | 0,64 |
| RWKV round 3 | 26/56 | 38/56 | 42/56 | 7/56 | 22/24 | 0,498 (bacc 0,887) |
| RWKV round 4 | 30/56 | 38/56 | 40/56 | 5/56 | 22/24 | 0,404 (bacc 0,869) |
⁴ hit@2 = la métrique de production (2 chunks injectés dans le prompt de génération).
Trois lectures, dans l'ordre :
Verdict final : granite reste en production. La règle écrite avant la campagne a tranché deux fois dans le même sens, et c'est très bien ainsi : un protocole qui ne peut dire que oui n'est pas un protocole. La campagne se clôt ici. Le programme, lui, continue, à raison d'une hypothèse par mois : le backbone entier jamais entraîné, la distillation jamais tentée, le 0.4B jamais testé dans des conditions propres, et un embedder multilingue généraliste pour la communauté, avec exactement cette recette.
Ce travail se tient sur les épaules d'EmbeddingRWKV (howard-hou) et de l'architecture RWKV-7 « Goose » (BlinkDL et la communauté RWKV). Les comparaisons utilisent les excellents granite-embedding (IBM), multilingual-e5 (Microsoft) et EmbeddingGemma (Google). Données : mMARCO (UNICAMP), CodeSearchNet (GitHub), MIRACL. Génération synthétique : Qwen2.5 (Alibaba). Compute : tier gratuit Kaggle.
Morrigan fait partie de l'écosystème ScarletWolf : des outils IA souverains, conçus pour tourner chez vous, sous votre contrôle. Le détail de la campagne, y compris tout ce qui n'a pas marché, est dans nos logs. Un résultat sans ses échecs est une publicité, pas un résultat.