Pourquoi la recherche hybride
Le cas d'usage : la base de connaissances technique
Une équipe support reçoit des questions sur un logiciel. Deux styles de questions coexistent :
| Style | Exemple | Ce qui compte |
|---|---|---|
| Identifiant | « J'obtiens HEL-4040, que dois-je faire ? » | le code exact, à la lettre près |
| Paraphrase | « Comment vider la mémoire temporaire d'une source qui renvoie des infos dépassées ? » | le sens : le document parle de « purger le cache des connecteurs » |
| Mixte | « L'import CSV refuse mon jeton, quel code d'erreur ? » | un peu des deux |
Aucune méthode de recherche seule ne gère bien les trois.
Deux moteurs, deux angles morts
BM25 (Best Matching 25) est l'algorithme de recherche par mots-clés des moteurs classiques (Elasticsearch, Lucene). Il donne un score à chaque document selon les mots de la question qu'il contient, en favorisant les mots rares dans le corpus (un code hel-4040 présent dans un seul document pèse lourd) et en pénalisant les documents très longs.
La recherche vectorielle transforme chaque texte en un embedding, un vecteur de quelques centaines de nombres qui résume son sens. Deux textes de sens proche ont des vecteurs proches (similarité cosinus élevée), même s'ils ne partagent aucun mot.
flowchart TB
subgraph BM["BM25 : compare des mots"]
B1["« HEL-4040 »"] -->|"mot rare, trouvé"| B2["err-HEL-4040 ✓"]
B3["« vider la mémoire temporaire »"] -->|"aucun mot commun"| B4["proc-purger-cache ✗"]
end
subgraph VE["Vecteurs : comparent des sens"]
V1["« HEL-4040 »"] -->|"20 erreurs presque identiques"| V2["err-HEL-4030 ? ✗"]
V3["« vider la mémoire temporaire »"] -->|"sens proche de « purger le cache »"| V4["proc-purger-cache ✓"]
end
Un utilisateur cherche « paramètre sync.batch_size ». Quel moteur a le plus de chances de placer le bon document en tête, et pourquoi ?
Un identifiant exact et rare est le cas idéal pour BM25 : son poids IDF (inverse de la fréquence documentaire) est maximal. Un embedding le place près de tous les autres paramètres de batch.
L'architecture du lab
sequenceDiagram participant U as Utilisateur participant H as HybridIndex participant G as Gemini U->>H: question H->>H: BM25 → top 20 H->>G: embedding de la question H->>H: cosinus → top 20 H->>H: fusion RRF → top 20 H->>G: reranking (sortie JSON) H->>G: réponse à partir du top 5 G-->>U: réponse + [identifiants cités]
Coût par question : 1 appel d'embedding, 1 appel de reranking, 1 appel de génération. BM25 et la fusion sont du calcul local, quasi gratuit.
Un corpus conçu pour piéger
Le script make_corpus.py génère les 80 documents de façon déterministe (pas d'appel au LLM, donc le même corpus pour tout le monde) :
| Famille | Nombre | Piège visé |
|---|---|---|
Codes d'erreur HEL-4010 à HEL-7050 | 20 | textes quasi identiques, seul le code et le module changent |
Paramètres export.timeout_s, sync.batch_size… | 20 | même structure, valeurs par défaut différentes |
| Procédures | 20 | vocabulaire métier (« purger », « révoquer ») absent des questions |
| Notes de version, incidents, FAQ | 20 | informations dispersées, parfois 2 documents pertinents |
for mi, (m, label) in enumerate(MODULES):
for suffix, symptom, cause, fix in SYMPTOMS:
code = f"HEL-{4 + mi}0{suffix}0" # ex. HEL-4010, HEL-5020…
docs.append({"id": f"err-{code}", "title": f"Erreur {code}",
"text": f"Erreur {code} pendant {label} : {symptom}. Cause : {cause}. ..."})Les 25 questions de référence sont réparties en 10 « identifiant », 10 « paraphrase » et 5 « mixte », chacune avec la liste de ses documents pertinents :
cd files
python make_corpus.py # → corpus.jsonl et questions.jsonPourquoi le corpus est-il généré par un script déterministe plutôt que par un LLM ?
Une évaluation n'a de valeur que si l'on compare les méthodes sur exactement les mêmes données. Un corpus regénéré à chaque fois changerait les documents pertinents.
Résultat obtenu
Exécution du 2026-10-15 (le script vérifie lui-même qu'il y a 80 identifiants uniques et que chaque document attendu existe) :
$ python make_corpus.py
80 documents, 25 questions
$ head -c 230 corpus.jsonl
{"id": "err-HEL-4010", "title": "Erreur HEL-4010", "text": "Erreur HEL-4010 pendant l'export des rapports : le délai d'attente est dépassé. Cause : le serveur distant ne répond pas avant la limite configurée. ...Exercice : classer les questions
Sans lancer de code, prédisez pour chaque question quel moteur (BM25 ou vecteurs) trouvera le bon document :
- « Quelle est la valeur par défaut de archive.parallelism ? »
- « Est-ce que nos informations peuvent partir aux États-Unis ? » (document : faq-regions, « Les données ne quittent pas la région choisie »)
- « HEL-6010 apparaît chaque nuit, pourquoi ? »
Voir la solution
- Les deux :
archive.parallelismest un jeton rare (BM25), et la phrase est assez explicite pour les vecteurs. Mesuré : rang 1 pour les deux. - Vecteurs : aucun mot commun utile (« États-Unis » contre « Amérique du Nord »). Mesuré : BM25 ne le trouve pas, les vecteurs le placent en tête.
- BM25 : le code exact. Le mot « nuit » attire même les vecteurs vers l'archivage nocturne (
HEL-7010), un piège sémantique. Mesuré : BM25 rang 1, vecteurs rang 6.
Je sais faire
- Expliquer en une phrase ce que mesure BM25 et ce que mesure un embedding
- Donner un exemple de question où chaque moteur échoue
- Décrire les 5 étapes du pipeline hybride et leur coût en appels LLM
- Générer le corpus et les questions de référence avec
make_corpus.py