Un multiplicateur ne repêche jamais un zéro
Note, septembre 2026. Le 28/09/2026, un audit extérieur a trouvé un verdict faux dans les chiffres ci-dessous. L’erratum corrige le résultat de bout en bout à 35/48 (72,9 %) au juge strict et 37/48 (77,1 %) au juge souple. Le résultat actuel est une relance dont le protocole a été publié avant qu’elle tourne : 77,1 % (37/48) sur des questions que le moteur n’avait jamais vues, juge officiel de LongMemEval (la page benchmark). Ce billet reste en ligne tel qu’il a été écrit.
Demandez à un système de mémoire « Quel service avait Admon dimanche ? ». Une seule chose compte : sait-il retrouver l’unique session qui contient Admon ?
Le nôtre n’y arrivait pas. Le raté venait de la conception, donc il se produisait à chaque fois.
L’angle mort
Mnemosyne OS classe la mémoire par similarité vectorielle dense : embeddings e5, cosinus, un seul classement. C’est le bon outil pour le sens. « La fois où je me suis plaint de l’hôtel » retrouve la plainte sans partager un mot avec elle. Un jeton littéral rare est une autre affaire. Un nom propre, un identifiant ou un numéro de version est précisément ce qu’un embedder projette près du bruit. Il n’a jamais vu cette chaîne. Il n’a aucun sens à y accrocher. La session qu’il vous faut peut dormir au rang #800.
Nous l’avons mesuré sur LongMemEval full-haystack, avec le même protocole que la campagne des 72,9 % : environ 480 sessions de distraction par question, sur un échantillon de 48 questions. Pour 6 questions sur 48, pas une seule session de preuve ne remontait. Sur 35 questions, nous pouvions suivre le chunk exact porteur de la réponse. Il manquait au contexte servi 10 fois.
Un modèle ne peut pas répondre avec un chunk que la recherche ne lui a jamais tendu.
Le correctif tentant, mesuré à zéro
Le code contenait déjà la réponse évidente : un boost de termes. Extraire les jetons distinctifs de la question. Quand un chunk de mémoire en contient un mot pour mot, multiplier son cosinus par 2.
Il s’est passé deux choses. Chacune nous a appris quelque chose.
D’abord, branché naïvement, ce fut une catastrophe. Une question en prose met une majuscule à son premier mot. Donc « What shift was Admon… » boostait « what », un jeton présent dans à peu près chaque chunk conversationnel de l’univers. Le rappel des preuves s’est effondré de 38/48 à 17/48. Une règle a survécu : une majuscule n’atteste un nom propre que si le mot n’est pas en tête de phrase.
Ensuite, une fois dompté, il a mesuré neutre de bout en bout. L’arithmétique dit pourquoi. Les sessions qui nous manquaient avaient un cosinus proche de zéro, et 0,30 doublé reste sous 0,80. Un multiplicateur amplifie un signal qui existe. Il n’en crée pas. Les chunks que cet angle mort perd n’ont aucun rang, donc il n’y a rien à multiplier.
Un second classement plutôt qu’un plus gros multiplicateur
Le correctif qui a marché ne consulte jamais les vecteurs.
Classer le coffre deux fois. Une fois par cosinus, plus profond que le budget
(200 au lieu de 32). Une fois lexicalement, avec Okapi BM25. BM25 est le
schéma de pondération de termes, vieux de quarante ans, que les produits de
« recherche hybride » managés font tourner sous le capot. Puis fusionner les
deux classements par Reciprocal Rank Fusion : chaque document marque la somme
des 1/(k + rang) sur les canaux.
Des rangs, jamais des scores. Un cosinus vit dans [0,1]. Une somme BM25 est non bornée. La recherche hybride rate en général quand quelqu’un normalise l’un contre l’autre. RRF ne les compare jamais.
La fusion par rangs porte aussi une propriété de sûreté. Un document présent dans un seul classement peut au mieux égaler le #1 de l’autre canal. Il ne dépassera jamais un document sur lequel les deux canaux s’accordent. Le canal lexical ne peut donc pas s’emparer du budget. Il ne peut que déloger des résultats vectoriels classés si profond que leur propre réciproque s’est éteinte. Et BM25 ne rend rien pour un document qui ne partage aucun terme avec la question. Les canaux ne se recouvrent donc que partiellement, et c’est exactement là que la fusion a quelque chose à apporter.
Le budget de contexte n’a pas bougé : toujours 32 sources. Le canal change quels chunks sont servis, jamais combien.
Les chiffres
| Mesure (échantillon full-haystack de 48 questions) | Vecteurs seuls | + canal lexical |
|---|---|---|
| Questions dont toutes les sessions de preuve remontent | 38/48 | 41/48 (+6/−3) |
| Chunk porteur de la réponse dans le contexte servi (35 suivies) | 25/35 | 30/35 (+6/−1) |
| Bout en bout, juge strict | 29/48 | 37/48 = 77,1 % (p apparié = 0,0215) |
La recherche se mesure de façon déterministe : entrées identiques à l’octet, aucun LLM dans la boucle, reproduction gratuite. Le chiffre de bout en bout passe par un juge LLM, et ce juge est bruité. Rejouer des passes identiques à l’octet dans ce montage fait basculer environ 2,6 verdicts sur 48. Ce banc ne peut donc pas trancher un écart sous ~5 questions. C’est pourquoi la recherche est l’instrument, et le taux de réponse la confirmation. Le +8 passe le plancher avec de la marge. Il s’est reproduit sur deux passes indépendantes, d’accord verdict par verdict.
Auditez-le. Chaque chiffre ci-dessus se recalcule depuis des lignes par
question publiées. Les passes brutes et les registres extraits mécaniquement
vivent sur la
page de provenance des benchmarks.
Les passes brutes sont les deux passes strictes, les fichiers de rappel
déterministe et les 48 questions nouvelles. La clé de lecture de la campagne
est dans
lexical-2026-08/.
Une commande, node verify.js, redérive chaque cellule et échoue au moindre
écart.
Deux façons de fabriquer un résultat
Il existe deux façons classiques de fabriquer un résultat pareil. Régler les hyper-paramètres sur son échantillon. Ne publier que l’échantillon sur lequel on a développé.
Nous n’avons fait ni l’un ni l’autre.
Les paramètres sont les défauts de la littérature : BM25 k1=1,5, b=0,75,
et RRF k=60, la constante du papier d’origine. Nous les avons laissés non
réglés exprès. Notre échantillon de 48 questions tourne environ 13 points plus
facile que le benchmark dont il est tiré. S’ajuster à un échantillon facile
fabrique des gains qui ne transfèrent pas.
Puis les 48 questions nouvelles : le canal ne les avait jamais vues, et nous les avons tirées après le gel de la conception. Le gain a transféré : +4 questions avec preuves complètes, +2 chunks porteurs de réponse, zéro régression. Pas une question n’est ressortie moins bien servie. La catégorie dominante de gains était la même que sur l’échantillon de développement : le raisonnement temporel.
Ce que nous n’affirmons pas
Plus tôt en août, nous avions mesuré notre propre montage de recherche augmenté d’une API de recherche hybride managée (Google Vertex AI Search). Ce montage augmenté par Google marquait 37/48 au juge strict. Le canal local atteint désormais le même 37/48, sans API, sans compte, sans que rien ne quitte votre machine.
Cette phrase ne dit pas que nous avons battu qui que ce soit. Nous avons comparé deux de nos propres montages, sous un protocole que nous contrôlons. L’un appelait un service externe, l’autre pas. Le produit managé n’a jamais été noté d’une façon qui autoriserait une comparaison de produits.
Les autres réserves restent imprimées à côté du chiffre. Un seul benchmark. Un échantillon de 48 questions plus mince et plus facile que son ensemble parent. Un juge côté réponse au plancher de bruit connu. Les 48 questions nouvelles ont levé l’objection que nous craignions le plus : avoir discrètement ajusté notre propre jeu de développement. Une seconde famille de benchmarks reste due.
La plomberie
- L’index inversé est persistant : de simples tables SQLite dans la base du coffre, avec un dictionnaire de termes à clés entières. Une construction à froid sur un coffre de 2 470 chunks prend 19,5 s en naïf (un fsync par posting) et moins d’une seconde en une seule transaction. Ensuite il est incrémental : un ingest n’indexe que ce qu’il ajoute.
- Pas FTS5, délibérément. Le harnais de benchmark tourne sur
node:sqlite, qui n’a pas le module FTS5. Le levier y aurait été immesurable, et un levier non mesuré est indistinguable d’un levier qui n’a jamais tourné. Les défauts de FTS5 (k1=1,2, tokenizerunicode61) auraient de surcroît changé en silence le classement même que nous mesurions. - Un index incomplet ne rend rien, et le pipeline retombe sur le classement vectoriel, le comportement d’hier. Un index à moitié construit a des fréquences documentaires fausses et produit un classement confiant, silencieusement faux. Une fonctionnalité absente vaut mieux qu’une réponse fausse.
- L’isolation mémoire par application est appliquée dans le canal lui-même, avec ses propres tests. Rien n’est délégué à qui voudra bien l’appeler.
Le canal part avec la v1.3.8, actif par défaut. MNEMO_LEXICAL_FUSION=0
restaure le chemin vecteurs seuls à l’octet près. Nos propres réserves
exigent cette issue de secours.
Un nom propre que votre mémoire n’entendait pas retrouve désormais sa session. Le gain est local, mesuré, reproduit deux fois, et confirmé sur 48 questions que le canal n’avait jamais vues.