Avancé 18 minOptimisation

Recherche hybride : BM25 + vectoriel

Le RAG vectoriel est génial pour la sémantique, catastrophique pour les identifiants. "Référence RG/2024-117" cherché en pur vectoriel ne retrouve rien. Ajouter un moteur de recherche lexicale (BM25) en parallèle, puis fusionner les résultats, donne un système robuste sur les deux dimensions. Ce guide explique comment en pratique.

Par Mohamed Meguedmi·Màj 2026-02-05·Testé sur Windows, macOS, Linux

#Le problème du pur vectoriel

Noms propres et identifiants
"Dupont contre Martin", "SKU ABC-1234", "RG/2024-117". L'embedding les traite comme du bruit. Pourtant ce sont souvent les termes exacts que vous cherchez.
Vocabulaire de domaine rare
Médical, juridique, technique : les termes spécifiques ont des embeddings fragiles parce que peu représentés dans les corpus d'entraînement.
Requêtes très courtes
"facture 2024" — trop peu de signal pour un embedding. BM25 gère nativement.
i
L'anti-exemple classique
Un RAG pur vectoriel sur une base de tickets cherchera "PROD-4817" et retournera des tickets "similaires" qui n'ont rien à voir. BM25 aurait trouvé l'unique ticket pertinent instantanément.

#BM25, le retour

BM25 est un algorithme de ranking lexical inventé dans les années 80, qui équipe Elasticsearch, Lucene, SQLite FTS5, Postgres via pg_trgm. Il mesure la correspondance exacte entre termes, pondérée par TF-IDF.

Forces
Termes rares (+poids), requêtes courtes, identifiants exacts, vocabulaire de domaine.
Faiblesses
Synonymes ("voiture" ≠ "automobile"), reformulations ("CDD" ≠ "contrat à durée déterminée"), paraphrases.
Pré-requis
Tokenisation + normalisation (minuscules, accents, stemming). Pas de modèle à entraîner.

#L'hybride : le meilleur des deux

On exécute les deux en parallèle — BM25 et embedding — sur la même requête, chacun produit son top-20. On fusionne les deux listes pour obtenir un top-20 hybride, souvent meilleur que chacun séparément.

Gain mesurable
Sur nos benchmarks FR juridique : pur vectoriel = 91 % recall@5, pur BM25 = 78 %, hybride avec RRF = 95 %. L'hybride capte ce que chacun manque.

#RRF : fusionner sans trafiquer

Le piège classique : normaliser les scores de BM25 et du vectoriel pour les additionner. Ça ne marche pas — les échelles sont incomparables. La méthode qui marche : Reciprocal Rank Fusion.

Formule RRF
RRF_score(doc) = Σ  1 / (k + rank_i(doc))
                 i

  k = 60 (constante magique, robuste empiriquement)
  rank_i = rang du doc dans la liste i (1 si premier, 2 si deuxième...)

Le doc n'est dans une liste ? Son apport = 0.
i
Pourquoi ça marche
RRF n'utilise que les rangs, pas les scores. Un doc en top-1 des deux listes gagne beaucoup ; un doc en milieu de peloton des deux ne gagne presque rien. Simple, robuste, pas de tuning.

#Stack pratique

Qdrant 1.10+
Supporte la recherche hybride native avec RRF. Un seul appel API.
Weaviate
Recherche hybride native, pondération alpha réglable entre lexical et vectoriel.
Elasticsearch 8.x
kNN + BM25 combinés via RRF. Plus lourd à déployer mais très complet.
ChromaDB + rank_bm25
Recherche vectorielle via Chroma, BM25 via la lib Python rank_bm25, fusion manuelle en Python. Solution DIY.

#Implémentation custom (ChromaDB + rank_bm25)

hybrid_search.py
from rank_bm25 import BM25Okapi
import chromadb, unicodedata, re

def normalize(txt):
    txt = unicodedata.normalize("NFKD", txt.lower())
    return re.sub(r"[^a-z0-9\s]", " ", txt).split()

# Indexation BM25 (en mémoire)
all_docs = [d["text"] for d in load_docs()]
bm25 = BM25Okapi([normalize(d) for d in all_docs])

# ChromaDB (vectoriel)
client = chromadb.PersistentClient("./chroma_db")
coll = client.get_collection("docs")

def rrf_fuse(rankings, k=60):
    scores = {}
    for ranking in rankings:
        for rank, doc_id in enumerate(ranking, start=1):
            scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank)
    return sorted(scores, key=scores.get, reverse=True)

def hybrid_search(question, top_k=5):
    # BM25
    bm25_scores = bm25.get_scores(normalize(question))
    bm25_top = sorted(range(len(all_docs)), key=lambda i: -bm25_scores[i])[:20]
    bm25_ranking = [str(i) for i in bm25_top]

    # Vectoriel
    vec_res = coll.query(query_texts=[question], n_results=20)
    vec_ranking = vec_res["ids"][0]

    # Fusion
    fused = rrf_fuse([bm25_ranking, vec_ranking])
    return [all_docs[int(i)] for i in fused[:top_k]]

#Régler le ratio BM25 / vectoriel

Par défaut RRF traite les deux listes à égalité. Si votre corpus est très spécialisé (beaucoup d'identifiants), vous pouvez favoriser BM25 en divisant son rank par un facteur < 1 (donc en augmentant sa contribution). À l'inverse, sur corpus libre, favorisez le vectoriel.

RRF pondéré
def rrf_fuse_weighted(rankings, weights, k=60):
    scores = {}
    for ranking, weight in zip(rankings, weights):
        for rank, doc_id in enumerate(ranking, start=1):
            scores[doc_id] = scores.get(doc_id, 0) + weight / (k + rank)
    return sorted(scores, key=scores.get, reverse=True)

# Ex : 40 % BM25, 60 % vectoriel
fused = rrf_fuse_weighted([bm25_ranking, vec_ranking], [0.4, 0.6])
Règle à défaut
Commencez à 50/50. Mesurez recall@5. Si vos requêtes contiennent souvent des identifiants ou références, montez BM25 à 60-70 %. Si elles sont plus naturelles/conversationnelles, laissez 50/50 ou favorisez le vectoriel.
Ce guide vous a aidé ?

Un retour, une erreur, une précision ? Faites-nous signe, ça améliore le guide pour tout le monde.