Chez Wazo, nous faisons tourner un RAG entièrement local. La souveraineté sur les données n’est pas un argument de marketing : la base mélange de la documentation produit, des documentations techniques mises à disposition de nos partenaires, du code open source et propriétaire, et un volume important de notes privées. Tout cela reste sur nos serveurs. Aucune donnée ne part vers un tiers cloud.

Ce volume impose un travail d’amélioration continue. Le RAG alimente à la fois l’avant-vente et le support, deux usages où une réponse fausse ou trop lente coûte cher. Récemment, un changement d’embedding a fait passer le rappel de notre recherche de 0,45 à 0,95 et divisé la latence par douze.

Le problème

La base de connaissances est 100 % locale (Ollama + Mistral + ChromaDB). Elle mélange deux mondes linguistiques :

  • la documentation publique de Wazo Platform est en anglais ;
  • les PDF produit et l’expertise terrain sont en français ;
  • et les utilisateurs posent leurs questions en français.

nomic-embed-text est un modèle anglophone — sa model card le tagge English, son rapport technique (arXiv 2402.01613) décrit un entraînement sur des paires anglophones (StackExchange, Quora, Amazon, requêtes web). Aucun pré-entraînement multilingue. L’utiliser sur un corpus bilingue FR/EN revenait à le faire fonctionner hors de son domaine d’entraînement.

Notre contournement : avant chaque recherche, traduire la question FR→EN avec Mistral, puis lancer la recherche dans les deux langues. En pratique, c’était le seul moyen de ramener nomic dans son langage d’entraînement. Ça marchait… au prix d’un appel LLM supplémentaire par requête (~2 s en CPU).

L’intuition : un embedding nativement bilingue rendrait ce contournement inutile. bge-m3 (1024 dimensions, contexte 8192 tokens, backbone XLM-RoBERTa, 100+ langues) est disponible en un ollama pull. Mais il y a une contrepartie : le modèle est bien plus gros. XLM-RoBERTa-large (~560M params) contre un BERT de 137M pour nomic, soit ~2× de RAM résidente (1,14 Go vs 0,57 Go, mesuré via ollama ps) et un embedding de requête ~2,7× plus lent (0,16 s vs 0,06 s). Il restait à prouver que le gain valait ce surcoût.

La méthode : isoler une seule variable

Le piège d’une comparaison d’embeddings, c’est de changer dix choses à la fois. Nous avons donc construit un protocole où la seule variable est le modèle.

1. Un jeu d’évaluation labellisé. 20 questions en français, chacune associée à la (ou les) source(s) qui y répond vraiment. La répartition reflète la difficulté réelle :

  • 10 questions sur la documentation anglaise (cœur du gap cross-langue) ;
  • 9 questions sur les PDF produit français ;
  • 1 question d’expertise française (le cas facile).

2. Deux collections ingérées avec le même code. Une collection en 768 dimensions (nomic) et une en 1024 dimensions (bge-m3) sont produites par le même chunking, le même préfixe contextuel, le même corpus (858 fichiers, 2923 chunks — identiques au chunk près). Seul l’appel d’embedding diffère.

3. Un harnais qui mesure quatre configurations :

EmbeddingTraduction runtimeCe qu’on isole
Anomicouila baseline en production
Bnomicnonl’effet réel de la traduction
Cbge-m3nonl’hypothèse cible
Dbge-m3ouila traduction reste-t-elle utile ?

Métriques : Recall@8 (la bonne source est-elle dans le top 8, notre TOP_K de prod ?), MRR (à quel point est-elle haute ?), position moyenne de la bonne source, et latence par requête.

Les résultats

Vue d’ensemble

ConfigRecall@8MRRPosition moy.MissesLatence/reqEmbed requête
A — nomic + traduction0,450,336#6,45/202,37 s0,06 s
B — nomic seul0,100,073#7,815/200,10 s0,06 s
C — bge-m3 seul0,950,790#1,71/200,20 s0,16 s
D — bge-m3 + traduction0,900,747#1,81/202,04 s0,16 s

Là où ça faisait mal : la ventilation par catégorie (Recall@8)

Configdoc ENPDF produit FRexpertise FR
A — nomic + traduction0,600,221,00
B — nomic seul0,000,111,00
C — bge-m3 seul1,000,891,00
D — bge-m3 + traduction0,900,891,00

La ventilation par catégorie montre l’essentiel. bge-m3 fait 1,00 sur tout le contenu anglais (la traduction plafonnait à 0,60) et 0,89 sur les PDF produit français, contre 0,22 pour nomic avec traduction — un contenu que nomic ratait même avec sa béquille.

Ce que les chiffres nous apprennent

1. bge-m3 écrase le gap cross-langue. Question française, réponse anglaise : 0,60 → 1,00. C’est exactement ce pour quoi un embedding bilingue existe.

2. La traduction était une béquille — et elle est devenue nuisible. Comparez A et B : sans traduction, nomic s’effondre (0,45 → 0,10). La traduction faisait donc un vrai travail avec nomic. Mais avec bge-m3, l’ajouter dégrade le rappel (C 0,95 → D 0,90). Pourquoi ? Traduire une question française en anglais pour aller chercher un PDF français, c’est brouiller le signal. La bonne réponse n’est pas « traduire mieux », c’est « ne plus traduire ».

3. Le mystère du Poste Opérateur POPC est résolu. Lors d’un précédent nettoyage des PDF, une question butait obstinément : « Qu’est-ce que le POPC ? » ramenait une réponse hallucinée, et le bon document restait introuvable. Nous avions diagnostiqué un problème de retrieval, pas d’extraction. Verdict des mesures :

ConfigRang du bon document (POPC)
A — nomic + traduction#19
B — nomic seulabsent (top-20)
C — bge-m3 seul#1

Bout-en-bout, la réponse est désormais ancrée sur le vrai document (supervision, transferts, annuaires unifiés — le contenu réel du PDF), fini le « Post Office Private Connection » inventé.

4. Plus rapide, en prime. Supprimer la traduction retire un appel Mistral : la latence de recherche passe de 2,37 s à 0,20 s (~12×). bge-m3 embed une requête en 0,16 s contre 0,06 s pour nomic — négligeable face aux 2 s économisées.

5. Un résidu, dit honnêtement. Une question résiste dans toutes les configs : une fiche Souscription. Ni nomic ni bge-m3 ne la remontent. C’est un cas pour le re-ranking ou l’enrichissement de la source, pas un échec de l’embedding.

Le prix à payer

Rien n’est gratuit :

  • Ré-ingestion complète obligatoire. La dimension d’une collection ChromaDB est figée : on ne passe pas de 768 à 1024 par un flag. Il faut vider et réingérer.
  • RAM ~2×. bge-m3 occupe 1,14 Go résident contre 0,57 Go pour nomic (mesuré via ollama ps). Sur la VM 8 Go, à surveiller aux côtés des 4,1 Go de Mistral.
  • Ingestion ~2,8× plus lente en CPU : 2434 s contre 860 s pour les 2923 chunks. Sans effet sur la latence de requête, mais à prévoir lors du ré-index.

Le compromis est vite tranché : doubler le rappel et diviser la latence par douze valent bien 0,6 Go de RAM et une ré-ingestion ponctuelle.

La décision

Nous adoptons la config C : bge-m3 comme embedding, traduction désactivée par défaut. La fonction de traduction reste dans le code, au cas où nous reviendrions un jour à un embedding non bilingue.

Pourquoi mesurer plutôt que sentir

Le jeu de questions est devenu un livrable partagé en interne : toute future optimisation du retrieval se mesure contre lui. On ne « sent » plus si une recherche s’améliore — on le chiffre.

C’est la partie la plus utile de ce travail, plus encore que le choix de bge-m3 lui-même. Un embedding qui gagne aujourd’hui peut perdre demain sur un autre corpus. Le protocole, lui, reste. Il suffit de relancer les quatre configurations pour le savoir.