Morrigan, our sovereign AI assistant, rests on one bet: zero structural hallucination. Without documentary context judged reliable, the generation model is never called, and the system answers "I don't know". That bet puts the entire weight on retrieval. A mediocre embedder does not degrade answers: it suppresses them (wrongful refusals) or poisons them (bad context presented as reliable).
The use case is demanding: questions in French over technical documentation in English (man pages, MDN, Python docs). Cross-lingual, jargon-heavy, federated corpora.
And we had a thesis constraint: Morrigan generates with RWKV-7, an RNN, not a transformer. Linear cost, constant state, 100% CPU. The uncomfortable question: could retrieval also run on this architecture without sacrificing quality?
Method: a fair 5,000-chunk subset of our production corpus (46,569 in total), relevant passages guaranteed present, the same 56 French queries for every model, comparable hit@k. The eval set is versioned and has never been used for training anything.
| model | params | dims | license | hit@1 | hit@3 | CPU speed¹ |
|---|---|---|---|---|---|---|
| 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 (slow) |
| multilingual-e5-large | 560M | 1024 | MIT | 41 | 50/56 | ~0.9 |
| multilingual-e5-small | 118M | 384 | MIT | 33 | 48/56 | fastest |
| 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 per second on an i5-10210U (laptop); absolute values depend on throttling, relative ones are reliable.
An unambiguous verdict: granite-107m offers the best precision/weight/speed ratio in its class, only 2 hits behind the 278m, which costs 2.6 times its weight and remains unusable on CPU. It went into production. The leader of the 100-150M class is granite.
And the RWKV embedder? 26/56 for the 0.1B (measured with the recommended query instruction, our engine's default; 18/56 without), 22-27 hits below the top of the table. The original paper (EmbeddingRWKV, arXiv:2601.07861) nevertheless reported an English MTEB of 63.06, within 0.8 points of a same-size transformer (gte-base: 63.90). But multilingual was not evaluated there: our bench was, to our knowledge, the first cross-lingual measurement of this model. Provisional conclusion: discarded.
A footnote in our own bench doc read: "RWKV evaluated at a 256-token cap (CPU time concession) vs 512 for the others; if RWKV is close, a fair-512 re-run is recommended". At 26/56, nobody believed in it. We did it anyway.
| EmbeddingRWKV 0.1B | hit@1 | hit@3 | gate² |
|---|---|---|---|
| 256-token cap | 22 | 26/56 | 0.70 |
| 512 cap (fair) | 38 | 48/56 | 0.911 |
² gate = balanced accuracy separating in-corpus / out-of-corpus; our measure of "knows how to say I don't know".
+22 hits by lifting the cap. The explanation is architectural. A mean-pooling transformer averages all tokens: truncating the passage costs it little. A causal RNN pools its final state, built left to right: truncating mid-passage amputates half the content from the embedding. An evaluation lesson that goes beyond our case: a "reasonable" truncation cap can crush a recurrent architecture by 22 points without any error being visible anywhere.
On equal footing, the 144M RNN was in fact tied with e5-small and embeddinggemma-300m, beat e5-base (278M), and stood 5 hits from granite. The campaign became winnable.
Rules set in advance: a single hypothesis per run (never two changes at once), the local bench as sole judge, every checkpoint kept, failures documented. Compute: Kaggle's free tier (T4, ~30 h/week) for training, the laptop for inference and verdicts. Light fine-tuning: retrieval head plus the last 2 blocks (16.3M parameters trained out of 144M).
60,000 pairs from mMARCO's "french" config plus 20,000 CodeSearchNet, in-batch InfoNCE. Result: 47/56 (best), 43/56 (final), a regression below the 48 baseline that worsens with training time (48, then 47, then 43: a clean dose-response curve).
The diagnosis fit in one line: mMARCO's "french" config provides FR→FR pairs. Our bench measures FR→EN. We trained the model to be monolingual, and it dutifully unlearned cross-lingual retrieval. Second flaw: 8 pairs per batch, so 7 negatives per positive; the loss collapsed to ~0.005 within 300 steps, no signal left.
Since mMARCO's translations are aligned by identifiers, zipping the "french" and "english" streams yields exact (FR query, EN passage) pairs. 50,000 FR→EN pairs, 10,000 FR→FR for retention, 10,000 CodeSearchNet, batch of 24 (23 negatives). One epoch.
Result: hit@1 39, parity with granite, hit@3 48, gate 0.920.
Same data, second epoch. The internal validation score was still climbing (0.902 to 0.910), and the bench went backwards: 36 hit@1, 47 hit@3. Over-specialization to mMARCO's style, drift from the target. Campaign law #4: at these volumes, one epoch, no more. A rising validation proxy proves nothing about the targeted skill.
Before the final round, we dissected the 8 remaining hit@3 failures and cross-referenced them with granite's failures on the same queries:
Domain vocabulary cannot be learned from generalist web pairs. So we generated our own data: 1,052 synthetic French queries produced by an open LLM we run ourselves (Qwen2.5-7B, on the free GPU) from the chunks of our own corpus. Around 600 chunks targeted (400 aimed at the domains of the 6 failures, 200 drawn at random), of which 547 produced retained queries, with a strict anti-leak guard: any generated query too close to a query from the eval set is discarded (Jaccard overlap above 0.5 = trash), zero leakage verified.
Final recipe: domain queries ×3, plus 8,000 FR→EN retention pairs, starting from the round 2 champion, a single pass, gentle learning rate.
| 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 = the production metric: Morrigan only injects the top 2 chunks into the generation prompt.
Reading it:
343 queries from the French MIRACL dev set, a pool of 5,000 Wikipedia passages (731 gold passages guaranteed present, distractors sampled uniformly from the corpus's 14.6M). Generalist domain, monolingual French: everything our fine-tune did not target.
| 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% |
Two answers in this table:
At that point, everything said: switch. Two judges out of two, parity in production, retention intact. That is when we demanded a third judge. It changed everything.
All the previous numbers came from pooled evals: 5,000 passages, relevant ones guaranteed, sampled distractors. Before touching production, we imposed a switching gate: the same duel, but on the full stacks: 46,569 code chunks plus the three system corpora, federated retrieval, production gates, zero sampling.
| full stack (46k+) | hit@1 | hit@2 | hit@3 | false refusals | out-of-corpus refusals |
|---|---|---|---|---|---|
| granite-107m | 37 | 44 | 49 | 5/56 | 21/24 |
| RWKV round 3 | 35 | 40 | 44 | 9/56 | 20/24 |
The subset advantage inverted. Everyone drops when going from 5,000 to 46,000 candidates, but not at the same speed: granite loses 2 to 4 hits, the RNN loses 7 to 8. And it wrongly refuses almost twice as often (9 versus 5).
The mechanism, once seen, is limpid. The fine-tune learned attraction (pulling the query toward the right document), and that holds at any scale. It never learned separation: our contrastive batches pitted each pair against 23 random negatives, never against the near-neighbors that swarm in a real corpus (the 40 MDN pages about functions, the 15 nearly identical network man pages). In a pool of 5,000, few lookalikes: attraction is enough. In 46,000, the lookalikes decide the ranking. And there, granite's "sharpened" space, forged by IBM on massive volumes with hard negatives, makes the difference. An aggravating factor: the RNN's cosine distribution is compressed (~0.2-0.6 versus ~0.3-0.9), less dynamic range to settle near-ties.
Decision: no switch. Our rule, written before the campaign, said: the thesis wins ties and small defeats, not clear ones. This one is clear. granite stays in production. The protocol did exactly what it exists for: preventing us from switching on beautiful pool numbers.
This result is also, to our knowledge, a new data point: pooled evaluations systematically overestimate an RNN embedder relative to a transformer. The gap in scale sensitivity only appears in real conditions. If you benchmark different architectures on reduced pools, this bias concerns you.
One card remained, the right one: a round 4 with hard negatives mined at full scale, training the model against its own lookalikes, exactly the technique that forged the reference embedders. But while preparing that round, we first found something else.
An outside hint tipped us off: how do we handle the end-of-sequence token (EOS) at truncation? Once checked, our preprocessing (CPU port and training lib) encoded with EOS then truncated at the cap. Any text longer than the cap therefore lost its EOS at the cut.
Yet EmbeddingRWKV's pooling reads the hidden state only at EOS positions. EOS absent → empty pooling mask → null vector → the head projects the same constant embedding for every text that is too long. No error, no NaN, no warning. Measurement: a cosine of 1.0000 between a long Python code text and a pie recipe. After the fix (truncate to max_tokens − 1 then append EOS): 0.159.
The magnitude, measured on samples of 400 chunks per corpus:
| truncation cap | usage | texts affected (EOS lost) |
|---|---|---|
| 512 tokens | inference / indexing | 0.5% of chunks (~250 out of 46,569 at the constant point) |
| 320 tokens | Kaggle training | 19 to 25% of passages depending on the corpus |
In other words: one training passage in five from rounds 1 to 3 carried no signal, or worse, pulled the query toward the constant point. The fair-512 of §3 is partly explained by this very mechanism: raising the cap from 256 to 512 pulled every 257-512-token chunk out of the constant point. The round 4 mined data was, for its part, clean (0 negatives out of 6,292 beyond the indexing cap, verified).
The fix is two lines; it is published in the CPU port and reported on the model's discussion thread. The lesson goes far beyond our case: an embedder with terminal-state pooling does not degrade gracefully under truncation: it collapses silently. A mean-pooling transformer loses information; an RNN that reads its state at EOS loses everything, at once, without saying so. If you truncate, check that your readout token survives the cut.
Round 4 attacks the §6 mechanism: each query trains against its own lookalikes, the 6 documents that actually trapped it, mined from the champion's top-k errors on the full stack. In numbers: 1,052 domain queries × 6 hard negatives (6,292 in total), each pair seen 3 times with different lookalike draws, plus 8,000 FR→EN retention pairs (7,744 for training, 256 for pure validation). InfoNCE loss with explicit negatives: batches of 12 pairs × (1 positive + 3 sampled hard negatives) = 60 sequences per step, ~908 steps, a single pass, LR 1e-5, still 16.3M trained parameters, with the corrected preprocessing from §7.
A note on method, in all fairness: our original full-scale judge was lost in the meantime (a script left in a temporary directory, one reboot; a judge is code: version it). It was rebuilt stricter: hits counted on raw federated search, the refusal verdict rendered by the real production gate (cosine threshold + cross-language guard + lexical guard), each model's threshold calibrated by balanced accuracy on its own scores. The three contenders were re-judged under strictly identical conditions: full production stacks, 49,795 chunks (46,569 code + 1,988 sysadmin + 626 security + 612 network). The numbers that follow are therefore not comparable line by line with those of §6. Among themselves, they are perfectly comparable.
| full scale, rebuilt judge | hit@1 | hit@2⁴ | hit@3 | false refusals | out-of-corpus refusals | calibrated gate |
|---|---|---|---|---|---|---|
| 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 = the production metric (2 chunks injected into the generation prompt).
Three readings, in order:
Final verdict: granite stays in production. The rule written before the campaign ruled twice in the same direction, and that is exactly as it should be: a protocol that can only say yes is not a protocol. The campaign closes here. The program continues, at a pace of one hypothesis per month: the never-trained full backbone, the never-attempted distillation, the 0.4B never tested under clean conditions, and a generalist multilingual embedder for the community, with exactly this recipe.
This work stands on the shoulders of EmbeddingRWKV (howard-hou) and the RWKV-7 "Goose" architecture (BlinkDL and the RWKV community). Comparisons use the excellent granite-embedding (IBM), multilingual-e5 (Microsoft) and EmbeddingGemma (Google). Data: mMARCO (UNICAMP), CodeSearchNet (GitHub), MIRACL. Synthetic generation: Qwen2.5 (Alibaba). Compute: Kaggle free tier.
Morrigan is part of the ScarletWolf ecosystem: sovereign AI tools, built to run on your machines, under your control. The full detail of the campaign, including everything that did not work, is in our logs. A result without its failures is an advertisement, not a result.