Aller au contenu principal

Module 5 — Recherche dense, lexicale et hybride

L'index vectoriel du module 4 sait trouver les passages proches de sens. C'est une avancée sur la recherche par mots-clés d'il y a vingt ans, mais ce n'est pas la panacée. Ce module explique pourquoi la recherche dense manque encore des cas simples, comment la recherche lexicale les rattrape, et comment fusionner intelligemment les deux.

Ce que la recherche dense fait bien, et pas si bien

Une question courante du fil rouge illustre les deux régimes.

« Comment est traitée l'absence pour maladie de longue durée ? » — la recherche dense trouve sans peine le passage qui commence par « les arrêts de travail supérieurs à un mois font l'objet... ». Aucun mot n'est commun entre la question et la réponse, mais le plongement les rapproche : c'est ce que la recherche dense fait de mieux.

« Que dit le RGPD sur les données RH ? » — la recherche dense retrouve des passages sur la « protection des données » et le « respect de la vie privée » sans jamais mentionner le sigle. Or le document qui répond exactement à la question est celui qui utilise « RGPD » au moins six fois. C'est ce que la recherche dense fait mal : les acronymes et les noms propres sont sous-représentés dans les corpus d'entraînement du modèle de plongement, qui les traite comme des mots rares presque interchangeables.

BM25 : la recherche lexicale robuste

BM25 est la formule héritière du sac de mots pondéré. Elle attribue à chaque passage un score fondé sur les occurrences des mots de la question, corrigé pour la longueur du passage et la rareté du mot dans le corpus.

score(q,p)=tqIDF(t)f(t,p)(k1+1)f(t,p)+k1(1b+bpp)\text{score}(q, p) = \sum_{t \in q} \text{IDF}(t) \cdot \frac{f(t, p) \, (k_1 + 1)}{f(t, p) + k_1 \left(1 - b + b \frac{|p|}{\overline{|p|}}\right)}

En termes simples : un mot rare qui apparaît plusieurs fois dans un passage court remonte ce passage. Les valeurs par défaut (k1=1,5k_1 = 1{,}5 et b=0,75b = 0{,}75) sont robustes ; on les touche rarement.

Contrairement à la recherche dense, BM25 ne comprend pas les synonymes. Une question qui dit « congé maladie » ne trouve pas un passage qui dit « arrêt de travail ». Mais elle trouve toujours un passage qui contient le mot « RGPD ». Les deux méthodes se compensent presque parfaitement.

from rank_bm25 import BM25Okapi
import re

def tokeniser(texte):
return re.findall(r"[a-zA-Zà-ÿ0-9]{2,}", texte.lower())

corpus_tokens = [tokeniser(p["texte"]) for p in passages]
bm25 = BM25Okapi(corpus_tokens)

def chercher_bm25(question, k=10):
scores = bm25.get_scores(tokeniser(question))
tops = scores.argsort()[::-1][:k]
return [(passages[i]["id"], float(scores[i])) for i in tops]

Fusionner les deux classements

On dispose donc de deux classements des passages : le dense et le lexical. La question devient : comment les combiner ? Additionner les scores directement ne marche pas, car ils vivent dans des échelles différentes — les scores BM25 vont typiquement de 0 à 30, les similarités cosinus de 0 à 1.

La bonne solution est la fusion de rangs réciproques (RRF), qui n'utilise que la position dans chaque classement. Le score final d'un passage est :

RRF(p)=c1k+rangc(p)\text{RRF}(p) = \sum_{c} \frac{1}{k + \text{rang}_c(p)}

cc parcourt les classements (dense et lexical) et kk est une constante d'atténuation, souvent 60. La formule est robuste, sans paramètre à régler, et se prête à trois sources ou plus si un jour on ajoute un troisième moteur.

def fusion_rrf(classements, k=60):
scores = {}
for classement in classements:
for rang, (id_p, _) in enumerate(classement, start=1):
scores[id_p] = scores.get(id_p, 0.0) + 1.0 / (k + rang)
return sorted(scores.items(), key=lambda x: -x[1])

Sur le fil rouge, la fusion RRF fait passer le rappel à cinq passages de 0,82 (dense seul) à 0,91 (hybride). C'est le plus gros gain que l'on peut obtenir en modifiant la seule couche de recherche.

Toujours démarrer hybride

On croit souvent qu'il faut d'abord vérifier si la recherche dense suffit avant d'ajouter BM25. Le coût d'ajout de BM25 est négligeable (quelques dizaines de millisecondes sur 300 000 passages), la fusion RRF ne demande aucun réglage, et les gains sur les acronymes, les codes internes et les noms de produits sont systématiques. Autant partir hybride dès le départ.

Le problème des requêtes courtes

Une requête d'un ou deux mots — « stagiaires », « télétravail » — met les deux moteurs à la peine. BM25 remonte tout passage contenant le mot, sans hiérarchie utile. La recherche dense compare un point mal localisé à trois mille voisins ; le voisinage devient bruité.

La réécriture de requête transforme la question courte en une reformulation plus riche, souvent en demandant au modèle de langage lui-même de proposer une paraphrase enrichie.

reecrire = """Reformule cette requete de recherche en une phrase precise et
autonome, adaptee a la recherche documentaire. Ne reponds pas a la question,
reformule-la simplement.

Requete : {question}
Reformulation :"""

def reecrire_question(question, appeler_modele):
if len(question.split()) >= 5:
return question
return appeler_modele(reecrire.format(question=question)).strip()

Le seuil de cinq mots évite de reformuler ce qui est déjà clair. Une variante — HyDE — consiste à demander au modèle d'écrire une réponse hypothétique à la question, puis à indexer cette réponse plutôt que la question. Elle donne parfois des gains, souvent des pertes ; ne pas l'activer sans mesurer.

Un acronyme introuvable en dense est un indice

Quand un utilisateur reçoit une réponse générique alors que la question mentionne « RGPD », « CSE » ou « SNMP », le premier réflexe doit être de vérifier que la recherche est bien hybride. Sur les bancs d'essai internes, plus de la moitié des « le système ne trouve rien » sont dus à un moteur purement dense qui ne connaît pas le sigle. C'est l'un des sept thèmes explicites de la banque d'examen.

Expansion et abréviations

Un dernier levier pratique : maintenir un petit dictionnaire d'expansion des acronymes maison, appliqué à la question avant recherche.

EXPANSIONS = {
"CSE": "Comite Social et Economique",
"OPCO": "Operateur de competences",
"RTT": "Reduction du temps de travail",
}

def enrichir(question):
tokens = question.split()
ajout = " ".join(EXPANSIONS[t] for t in tokens if t in EXPANSIONS)
return f"{question} {ajout}" if ajout else question

Ce n'est pas élégant, mais c'est très efficace, et cela évite d'attendre qu'un modèle de plongement plus gros règle le problème.

En résumé

  • La recherche dense capture les synonymes et le sens ; la recherche lexicale (BM25) reste imbattable sur les acronymes, les codes et les noms propres.
  • On fusionne les deux classements avec la fusion de rangs réciproques, une formule sans paramètre à régler et robuste à l'échelle des scores.
  • La réécriture des questions très courtes (moins de cinq mots) redonne au moteur assez de matière pour trancher entre voisins ; HyDE est optionnel et à mesurer.
  • Un dictionnaire d'expansion des acronymes maison résout d'un coup la classe d'échecs la plus visible pour un utilisateur interne.

Module suivant : reclasser les vingt meilleurs candidats avec un encodeur croisé pour n'en garder que les cinq qui iront dans la consigne finale.