Aller au contenu

RAG hybride BM25 + vectoriel : comment l'implémenter

Votre RAG vectoriel rate des questions que vous ne voyez pas

C'est une remarque que j'entends souvent sur les projets RAG : "Ça marche bien en général, mais parfois il ne trouve rien sur des questions pourtant simples."

Exemple concret : "Quelle est la procédure ISO-27001 pour les accès distants ?" → 0 résultat pertinent.

Le vectoriel encode le sens. Mais quand la question contient un identifiant exact (une norme, un code produit, un acronyme métier), l'encodage sémantique rate complètement.

C'est ce qu'on appelle le vocabulary mismatch. Et c'est le problème que le hybrid search résout.


3 types de requêtes où le vectoriel seul échoue

Un modèle d'embeddings encode le sens, les associations sémantiques, les proximités conceptuelles. C'est puissant. Mais ça rate systématiquement sur 3 catégories de requêtes.

1. Le jargon métier et les normes

"DTU 31.2", "CSRD", "IFRS 9", "ISO-27001 annexe A.9". Ces identifiants n'ont pas de sens au sens sémantique : ce sont des étiquettes. Un modèle d'embeddings généraliste ne sait pas que "DTU 31.2" est la norme sur les ouvrages de maçonnerie et il va encoder ça comme une séquence de caractères sans relation forte avec le contenu du document correspondant.

2. Les noms propres, références produit, codes erreur

"ERROR_CODE_403b", "Produit Ref X-2247-FR", "endpoint /v2/users/batch". L'embedding les encode mais mal : il ne comprend pas que la correspondance exacte est ce qui compte ici, pas la proximité sémantique.

3. Les requêtes mixtes

"Configurer Redis en cluster sur AWS avec authentification SASL". La moitié de la requête est conceptuelle, l'autre moitié est technique et textuelle. Le vectoriel pur gère mal cette asymétrie.

Et inversement, voici les 3 cas où BM25 seul échoue :

  • Questions conceptuelles ("c'est quoi une architecture multi-agents ?")
  • Synonymes et paraphrases ("manière de stocker" ≠ "façon de sauvegarder" pour BM25)
  • Langage naturel multilingue

La conclusion s'impose : les erreurs de BM25 et du vectoriel ne sont pas corrélées. Ce qu'un rate, l'autre trouve souvent. Et c'est exactement pourquoi les combiner fonctionne.


Comment fonctionne BM25 (sans les mathématiques)

BM25 (Best Matching 25) est un algorithme de recherche textuelle. L'idée de base : un mot rare dans un document court est très pertinent.

Deux paramètres font tout le travail :

  • k1 (1.2 par défaut) : la "saturation de fréquence". Après la 5e occurrence d'un mot dans un document, l'apport marginal devient presque nul. Si "Redis" apparaît 20 fois dans un doc, ce n'est pas 20x plus pertinent que s'il apparaît 2 fois.
  • b (0.75 par défaut) : la normalisation par longueur. Un doc de 50 mots qui contient "Redis" est plus pertinent qu'un doc de 5000 mots qui le contient aussi.

Ces valeurs par défaut ont été validées sur des dizaines de benchmarks. Dans la très grande majorité des projets, vous n'avez pas besoin de les toucher.

La vision moderne : BM25 comme vecteur creux

Cette notion est importante pour comprendre les architectures hybrid. BM25 peut se représenter comme un vecteur creux (sparse vector) : un vecteur de dimension égale à la taille du vocabulaire, avec des valeurs non nulles uniquement pour les mots présents dans le document.

C'est cette vision "sparse" qui unifie BM25 et le vectoriel dense dans une même architecture : les deux produisent des vecteurs, mais de nature très différente.

BM25 / Sparse Vectoriel dense
Représentation Vecteur creux (vocab entier) Vecteur dense (768–1536 dims)
Capture Correspondances exactes Sens, contexte, synonymes
Force Jargon, codes, noms propres Questions conceptuelles
Faiblesse Synonymes, paraphrases Identifiants exacts
Coût Très rapide (CPU) Plus lent (GPU recommandé)

Et si BM25 ne suffit pas (je l'explique à la fin), il existe des alternatives sparse plus intelligentes : SPLADE et BGE-M3.


Reciprocal Rank Fusion : l'algorithme de fusion qui marche vraiment

Quand on a deux listes de résultats (une de BM25, une du vectoriel), comment les combiner ?

La première idée naïve : additionner les scores. Ça ne marche pas. Les scores de BM25 et les scores de similarité cosinus n'ont pas les mêmes plages ni la même distribution. Additionner un score BM25 de 15.3 avec une similarité cosinus de 0.82 n'a aucun sens mathématique.

La solution : Reciprocal Rank Fusion (RRF)

L'idée est élégante : on oublie les scores, on ne garde que les rangs.

La formule : score_RRF(doc) = 1 / (k + rang_BM25) + 1 / (k + rang_vectoriel)

Avec k = 60 (valeur par défaut dans Elasticsearch et Azure AI Search).

Un exemple pas à pas :

Document Rang BM25 Rang vectoriel Score RRF
Doc A 1er 2e 1/(60+1) + 1/(60+2) = 0.0325
Doc B 3e 1er 1/(60+3) + 1/(60+1) = 0.0323
Doc C 2e absent 1/(60+2) + 0 = 0.0161

Doc A sort en tête parce qu'il est bien classé dans les deux systèmes. Doc C est absent du vectoriel : il perd face aux docs présents dans les deux listes, même s'il était 2e en BM25.

C'est la robustesse de RRF : un document fort dans une seule liste peut quand même ressortir, mais un document modérément bon dans les deux le battra presque toujours.

graph LR
    Q["💬 Question"] --> B["🔤 BM25\n(sparse)"]
    Q --> V["🧠 Embedding\n(dense)"]
    B --> BL["📋 Liste ranked\nBM25"]
    V --> VL["📋 Liste ranked\nvectoriel"]
    BL --> RRF["⚖️ Reciprocal\nRank Fusion"]
    VL --> RRF
    RRF --> R["📊 Top-K\nfusionnés"]
    R --> LLM["💡 LLM → Réponse"]

    style Q fill:#fef3c7,stroke:#d97706,color:#000
    style B fill:#fce7f3,stroke:#db2777,color:#000
    style V fill:#e0e7ff,stroke:#4338ca,color:#000
    style BL fill:#fce7f3,stroke:#db2777,color:#000
    style VL fill:#e0e7ff,stroke:#4338ca,color:#000
    style RRF fill:#f3f4f6,stroke:#6b7280,color:#000
    style R fill:#dbeafe,stroke:#2563eb,color:#000
    style LLM fill:#bbf7d0,stroke:#16a34a,color:#000

k = 60 vs k = 2 : quelle différence ?

  • k élevé (60) → les rangs élevés pèsent moins. La liste finale est plus équilibrée entre les deux signaux.
  • k faible (2) → les rangs élevés dominent fortement. Qdrant utilise k=2 par défaut, ce qui favorise les très bons résultats d'une seule liste.

Alternative : RelativeScoreFusion (Weaviate)

Weaviate propose une autre approche : normaliser les scores de chaque méthode entre 0 et 1, puis les additionner. Leur benchmark sur FIQA (dataset Q&A financier) montre +6% de recall vs RRF. Ça vaut la peine de tester les deux sur vos données avant de choisir.


Les benchmarks qui justifient de l'implémenter

Je ne vous demande pas de me croire sur parole. Voici les chiffres.

Microsoft Azure AI Search (BEIR + données clients)

Méthode NDCG@3 Gain vs vectoriel seul
BM25 seul 40.6 −7%
Vectoriel seul 43.8
Hybrid RRF 48.4 +10%
Hybrid + reranker 60.1 +37%

Elasticsearch (BEIR benchmark)

Hybrid : +1.4% vs vectoriel seul, +18% vs BM25 seul.

LlamaIndex (Hit Rate sur benchmark Q&A)

La combinaison hybrid + reranker atteint un Hit Rate de 0.938. C'est le meilleur score de leur benchmark, devant le vectoriel seul (0.891) et BM25 seul (0.869).

La leçon à retenir : le hybrid améliore systématiquement le vectoriel pur, surtout sur les données avec du jargon. Et si vous ajoutez un reranker derrière, vous multipliez encore l'effet.


Implémentation : 3 stacks en pratique

Stack 1 — LangChain + BM25 + FAISS (open-source, minimal)

C'est le point d'entrée. Parfait pour un POC ou une stack entièrement locale sans base de données externe.

from langchain.retrievers import BM25Retriever, EnsembleRetriever
from langchain.vectorstores import FAISS
from langchain_openai import OpenAIEmbeddings

# Vos documents splittés au préalable
documents = [...]

# Retriever BM25
bm25_retriever = BM25Retriever.from_documents(documents)
bm25_retriever.k = 5

# Retriever vectoriel FAISS
embeddings = OpenAIEmbeddings()
faiss_store = FAISS.from_documents(documents, embeddings)
vector_retriever = faiss_store.as_retriever(search_kwargs={"k": 5})

# Fusion avec RRF (c=60 par défaut dans LangChain)
ensemble_retriever = EnsembleRetriever(
    retrievers=[bm25_retriever, vector_retriever],
    weights=[0.5, 0.5]  # 50/50 BM25 / vectoriel
)

results = ensemble_retriever.invoke("votre question")

C'est 15 lignes de code. RRF est géré nativement par EnsembleRetriever.

Limite principale : BM25 est recalculé en mémoire à chaque redémarrage. Pour de la prod, il faut persister l'index BM25 séparément (avec pickle ou une solution externe).


Stack 2 — LlamaIndex + QueryFusionRetriever

LlamaIndex a une implémentation native avec support async, utile si vous voulez paralléliser les deux recherches pour réduire la latence.

from llama_index.retrievers.bm25 import BM25Retriever
from llama_index.core.retrievers import VectorIndexRetriever, QueryFusionRetriever

# Vos index existants
vector_retriever = VectorIndexRetriever(index=vector_index, similarity_top_k=5)
bm25_retriever = BM25Retriever.from_defaults(
    docstore=vector_index.docstore,
    similarity_top_k=5
)

# Fusion
hybrid_retriever = QueryFusionRetriever(
    retrievers=[vector_retriever, bm25_retriever],
    similarity_top_k=5,
    num_queries=1,      # pas de multi-query ici, juste fusion
    use_async=True,     # BM25 et vectoriel en parallèle
    mode="reciprocal_rerank",
)

results = await hybrid_retriever.aretrieve("votre question")

Le use_async=True est important : les deux retrievers tournent en parallèle, ce qui réduit la latence de moitié en pratique.


Stack 3 — Weaviate (production, tout-en-un)

Weaviate gère BM25, vectoriel et fusion nativement. C'est la stack la plus propre pour de la production à grande échelle : tout est dans la base de données, pas de logique de fusion côté applicatif.

import weaviate
from weaviate.classes.query import MetadataQuery, HybridFusion

client = weaviate.connect_to_local()
collection = client.collections.get("Documents")

# Hybrid query avec RelativeScoreFusion
results = collection.query.hybrid(
    query="votre question",
    alpha=0.5,           # 0 = BM25 pur, 1 = vectoriel pur, 0.5 = équilibré
    fusion_type=HybridFusion.RELATIVE_SCORE,
    query_properties=["content", "title^2"],  # boost du titre
    limit=5,
    return_metadata=MetadataQuery(score=True, explain_score=True)
)

for obj in results.objects:
    print(obj.properties["content"])
    print(f"Score hybrid : {obj.metadata.score}")

Le paramètre alpha est votre levier principal en production : - Augmentez alpha si vos questions sont conceptuelles (plus vectoriel) - Diminuez alpha si vos requêtes contiennent beaucoup de jargon ou d'identifiants (plus BM25)

Quand l'utiliser : volumes importants, filtres par métadonnées combinés au hybrid, multi-tenant, ou si vous voulez éviter de gérer BM25 séparément dans votre code.


Quand utiliser hybrid vs vectoriel pur

Situation Recommandation
Jargon métier, acronymes, normes Hybrid (BM25 essentiel)
Questions conceptuelles pures Vectoriel pur
Noms propres, codes produit, références Hybrid
Requêtes avec fautes d'orthographe Vectoriel (plus robuste aux typos)
Patterns de code, regex, logs BM25 pur
Corpus multilingue Hybrid + BGE-M3
Production généraliste sans profiling Hybrid par défaut

Ma règle personnelle : en production généraliste, je pars toujours sur du hybrid. Le coût additionnel est marginal (BM25 est quasi-gratuit comparé à l'embedding) et le gain sur les cas limites vaut toujours l'effort.


Pour aller plus loin : SPLADE et BGE-M3

BM25 est une excellente baseline, mais il a une limite évidente : il ne comprend pas les synonymes. "Envoyer" et "transmettre" sont deux tokens différents pour BM25 : aucun score de proximité entre eux.

Deux alternatives modernes valent d'être connues.

SPLADE (Sparse Lexical and Expansion)

SPLADE est un modèle entraîné pour produire des vecteurs creux "intelligents". Contrairement à BM25, il fait de l'expansion de termes : si le document parle de "voiture", SPLADE peut ajouter du poids à "automobile", "véhicule", "auto". Il garde la structure sparse (rapide, filtrable) tout en capturant une partie de la sémantique.

À considérer quand : votre corpus a beaucoup de synonymes sectoriels, ou quand BM25 rate trop souvent des variantes terminologiques.

BGE-M3 (BAAI/bge-m3)

C'est le modèle le plus polyvalent sorti en 2024. Un seul modèle produit simultanément : - Des vecteurs denses (embedding classique) - Des vecteurs creux (type SPLADE) - Des scores ColBERT (pour le reranking)

100 langues. Contexte de 8192 tokens. Vous l'utilisez à la fois comme retriever dense, retriever sparse, et reranker, avec un seul modèle à gérer.

À considérer quand : corpus multilingue, ou quand vous voulez simplifier votre stack en n'ayant qu'un seul modèle à déployer et maintenir.

BM25 SPLADE BGE-M3 sparse
Expansion de termes Non Oui Oui
Entraînement nécessaire Non Oui (modèle pré-entraîné) Oui (modèle pré-entraîné)
Vitesse Très rapide Rapide Modéré
Multilingue Dépend du tokenizer Limité Oui (100 langues)
Cas d'usage idéal Baseline, jargon exact Synonymes sectoriels Production multilingue

FAQ

Le hybrid search ralentit-il les requêtes ?

La partie BM25 est quasi-instantanée (calcul CPU sur index inversé). Le vectoriel est le goulot d'étranglement, comme dans un RAG classique. En pratique, le hybrid bien implémenté ajoute 5 à 15ms de latence vs le vectoriel seul, négligeable dans la majorité des architectures RAG.

Quelle base de données vectorielle choisir pour du hybrid search ?

Weaviate et Qdrant ont le meilleur support natif aujourd'hui. Weaviate a alpha, RelativeScoreFusion et BM25F (BM25 avec boost par champ). Qdrant a son propre RRF avec k=2. Elasticsearch reste la référence si vous avez déjà une infrastructure ELK. FAISS et Chroma nécessitent de gérer BM25 séparément côté application (ce que fait LangChain avec EnsembleRetriever).

Faut-il fine-tuner BM25 pour mon domaine ?

Rarement nécessaire. Les paramètres k1 et b par défaut ont été validés sur des corpus très variés. Ce qui compte plus : la qualité du tokenizer (qu'il gère bien les accents, tirets, et la casse de vos données) et le preprocessing (stop words pertinents dans votre domaine). Pour du jargon très spécialisé, BM25 n'a d'ailleurs pas besoin de stemming : les tokens exacts sont précisément ce qu'on cherche.

BM25 gère-t-il correctement le français ?

Oui, avec un tokenizer adapté. LangChain utilise rank_bm25 avec le stemmer Snowball qui couvre bien le français standard. Pour du jargon technique ou des acronymes, le stemming est inutile voire contre-productif : désactivez-le ou excluez ces termes de la normalisation.


Pour aller plus loin


Si mes articles vous intéressent et que vous avez des questions ou simplement envie de discuter de vos propres défis, n'hésitez pas à m'écrire à anas0rabhi@gmail.com, j'aime échanger sur ces sujets !

Vous pouvez aussi réserver un créneau d'échange ou vous abonner à ma newsletter :)


À propos de moi

Je suis Anas Rabhi, consultant Data Scientist freelance. J'accompagne les entreprises dans leur stratégie et mise en œuvre de solutions d'IA (RAG, Agents, NLP).

Découvrez mes services sur tensoria.fr ou testez notre solution d'agents IA heeya.fr.