Accueil / Articles

Au-delà de la similarité Top-K : BM25, recherche hybride, reranking et MMR dans RAG

Au-delà de la similarité top-K dans RAG : BM25 pour les termes exacts, recherche hybride avec RRF, réclassement par cross-encoder et MMR pour la diversité, combinés en un seul pipeline.

Éditeur : REACTAPP.TOP7 min de lecture1698 mots

Sources: Retrieval Strategies in RAG: Beyond Basic Similarity Search

Le premier système RAG que développent la plupart des développeurs suit une recette : intégrer les documents, les placer dans une base de données vectorielle, et lorsque une question arrive, sélectionner les fragments les plus proches pour les transmettre au LLM. Cela fonctionne, jusqu’au moment où cela cesse de le faire. Les utilisateurs commencent à se plaindre que des réponses évidentes sont manquées. Le document contient clairement les informations, mais la recherche ne les met pas en évidence, ou bien le résultat principal ressemble en apparence à la question sans correspondre à ce dont l’utilisateur a besoin. La recherche par similarité simple constitue un bon point de départ, mais avec des limites réelles ; une fois ces limites clairement identifiées, de meilleures stratégies de recherche les corrigent. Voici les raisons de son échec, ce que la recherche hybride et le réclassement permettent de faire, ainsi que la manière de combiner ces éléments en un seul pipeline.

Recherche par similarité, en bref

Dans une configuration de base, la question de l’utilisateur se transforme en un embedding, c’est-à-dire une liste de nombres représentant son sens. Chaque chunk stocké dans le vecteur possède également un embedding. Le système identifie les chunks dont les embeddings sont les plus proches de ceux de la question, mesuré par la similarité cosinus ou le produit scalaire, et retourne les K meilleurs résultats.

# Basic similarity search — what most RAG systems do
results = vector_store.similarity_search(query, k=3)

Il fonctionne bien pour une correspondance sémantique générale. Une question sur le fonctionnement de la photosynthèse affichera des extraits parlant des plantes qui transforment la lumière solaire en énergie, même si la formulation diffère. Cependant, il présente des limites.

Ses points faibles

Les mots-clés exacts échappent à la détection. La recherche sémantique prend en compte le sens plutôt que les mots littéraux, ce qui est généralement un avantage. Cependant, lorsque quelqu’un cherche un terme spécifique, comme le nom d’un produit, un acronyme ou le nom d’une personne, l’encodage utilisé peut ne pas correspondre suffisamment bien. Par exemple, si l’utilisateur demande des informations sur l’architecture du modèle BERT, un extrait indiquant que BERT utilise des transformateurs bidirectionnels existe, mais un extrait plus général sur les architectures des modèles de langage pourrait avoir la priorité car il couvre un champ sémantique plus large.

Un score élevé ne signifie pas nécessairement que c’est la meilleure réponse. La similarité mesure le degré de ressemblance entre deux textes, et non leur utilité pour répondre l’un à l’autre. Un extrait indiquant que les systèmes RAG sont utiles pour répondre aux questions est très proche de la requête « qu’est-ce que RAG ? », tandis que l’extrait contenant la définition réelle est bien plus utile.

Les résultats les plus pertinents peuvent également être redondants. Les documents réels se répètent à travers différents extraits, de sorte que les trois premiers résultats peuvent dire presque la même chose, gaspillant ainsi l’espace de contexte avec du matériel dupliqué.

BM25 pour la récupération de mots-clés

BM25 est un algorithme classique de récupération d’informations, du même type que celui utilisé par les moteurs de recherche traditionnels. Il compare les documents en fonction de mots-clés exacts, en attribuant à chacun un poids selon sa rareté dans l’ensemble du corpus, et il ne fait pas usage d’embeddings, se contentant de texte brut. Une recherche pour "index FAISS" renvoie des extraits contenant littéralement les deux mots, le mot rare ayant plus d’importance que le mot courant.

from langchain_community.retrievers import BM25Retriever
from langchain_community.document_loaders import TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
# Load and split documents
loader = TextLoader("knowledge_base.txt")
documents = loader.load()
splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=50)
chunks = splitter.split_documents(documents)
# Create BM25 retriever
bm25_retriever = BM25Retriever.from_documents(chunks)
bm25_retriever.k = 3
# Search
results = bm25_retriever.invoke("FAISS vector index")
for doc in results:
    print(doc.page_content[:200])

Il fonctionne bien avec des noms de produits spécifiques, des personnes ou des termes techniques, des requêtes courtes et précises, ainsi que là où la formulation exacte prime sur le sens. Il rencontre des difficultés lorsque l’utilisateur exprime les choses différemment du document (synonymes, paraphrases), avec des requêtes longues et conversationnelles, ou lorsque le sens l’emporte sur les mots littéraux.

Recherche hybride

La recherche hybride met en œuvre en parallèle la récupération sémantique et BM25. La recherche sémantique gère le sens et les synonymes, tandis que BM25 s’occupe des mots-clés exacts, de sorte qu’ensemble elles couvrent les cas que chacune traiterait séparément. Les deux listes de résultats sont fusionnées grâce à la Reciprocal Rank Fusion (RRF), une technique qui combine des listes classées en une seule sans nécessiter que leurs scores partagent la même échelle.

from langchain_community.retrievers import BM25Retriever
from langchain_google_genai import GoogleGenerativeAIEmbeddings
from langchain_community.vectorstores import FAISS
from langchain.retrievers import EnsembleRetriever
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_community.document_loaders import TextLoader
#Load and split
loader = TextLoader("knowledge_base.txt")
documents = loader.load()
splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=50)
chunks = splitter.split_documents(documents)
#Retriever 1 — Semantic (FAISS)
embeddings = GoogleGenerativeAIEmbeddings(
    model="models/embedding-001",
    google_api_key="YOUR_KEY"
)
vector_store = FAISS.from_documents(chunks, embeddings)
semantic_retriever = vector_store.as_retriever(search_kwargs={"k": 5})
#Retriever 2 — Keyword (BM25)
bm25_retriever = BM25Retriever.from_documents(chunks)
bm25_retriever.k = 5
#Combine both — Hybrid Search
hybrid_retriever = EnsembleRetriever(
    retrievers=[semantic_retriever, bm25_retriever],
    weights=[0.6, 0.4]  # 60% semantic, 40% keyword
)
#Search
results = hybrid_retriever.invoke("What is FAISS and how does it store vectors?")
print(f"Retrieved {len(results)} chunks")
for i, doc in enumerate(results):
    print(f"\nChunk {i+1}: {doc.page_content[:150]}")

Un paramètre weights définit la contribution de chaque outil de récupération. Une légère inclinaison vers la recherche sémantique convient à la plupart des cas, bien que l’équilibre idéal dépende de vos données. Choisissez la méthode hybride lorsque les documents contiennent des termes techniques, des noms de produits ou des noms propres, lorsque les utilisateurs combinent des questions conversationnelles et des questions basées sur des mots-clés, ou lorsque vous souhaitez utiliser la correspondance de mots-clés comme filet de sécurité derrière la compréhension sémantique.

Réclassement

Le réclassement se déroule en deux étapes. D’abord, on effectue une récupération large en obtenant les 20 ou 30 meilleurs résultats grâce à l’outil de récupération, ce qui est peu coûteux et rapide. Ensuite, ces candidats sont passés par un modèle de réclassement dédié qui évalue chacun selon sa capacité à répondre à la question, ne conservant que les 3 à 5 meilleurs résultats.

La logique est que la similarité vectorielle constitue une approximation rapide de la pertinence, tandis qu’un réclassificateur est plus lent mais bien plus précis. En limitant d’abord le champ des résultats grâce à la similarité, puis en procédant au réclassement, on obtient à la fois rapidité et précision.

from langchain_community.cross_encoders import HuggingFaceCrossEncoder
from langchain.retrievers.document_compressors import CrossEncoderReranker
from langchain.retrievers import ContextualCompressionRetriever
#Base retriever — fetch top 20 candidates
base_retriever = vector_store.as_retriever(search_kwargs={"k": 20})
#Reranker model
reranker_model = HuggingFaceCrossEncoder(
    model_name="BAAI/bge-reranker-base"
)
#Reranker compressor — keeps only top 3 after reranking
reranker = CrossEncoderReranker(model=reranker_model, top_n=3)
#Combine base retriever + reranker
reranking_retriever = ContextualCompressionRetriever(
    base_compressor=reranker,
    base_retriever=base_retriever
)
#Search — fetches 20, reranks, returns top 3
results = reranking_retriever.invoke("How does FAISS perform similarity search?")
print(f"Final chunks after reranking: {len(results)}")
for i, doc in enumerate(results):
    print(f"\nTop {i+1}: {doc.page_content[:200]}")

Un réordonneur à codage croisé lit la question et un fragment ensemble en tant que paire, puis évalue dans quelle mesure ce fragment spécifique est pertinent pour cette question précise. La similarité vectorielle analyse chaque texte indépendamment sans jamais prendre en compte directement leur relation. Recourir au réordonnement est utile lorsque la précision est essentielle et que les réponses incorrectes ont de lourdes conséquences, lorsqu’on répond à des questions à partir de documents spécifiques, ou chaque fois que la qualité de la réponse affecte directement les utilisateurs.

Maximum Marginal Relevance

MMR lutte contre la redondance. Au lieu de choisir simplement les fragments les plus proches, il sélectionne des fragments à la fois pertinents et variés, afin que les mêmes informations ne soient pas renvoyées plusieurs fois.

# Standard similarity search — might return redundant chunks
standard_results = vector_store.similarity_search(query, k=3)
# MMR search — returns relevant AND diverse chunks
mmr_results = vector_store.max_marginal_relevance_search(
    query,
    k=3,           # final number of chunks to return
    fetch_k=20,    # candidates to consider before selecting diverse ones
    lambda_mult=0.5  # 0 = max diversity, 1 = max relevance
)

Le paramètre lambda_mult définit l’équilibre à trouver. Une valeur de 1,0 signifie une pertinence pure, identique à une recherche de similarité ordinaire. Une valeur de 0,0 signifie une diversité pure, avec des segments aussi différents les uns des autres que possible. Une valeur de 0,5 équilibre les deux et constitue généralement une bonne valeur de départ. Le MMR s’avère utile lorsque les documents sont répétitifs, lorsque l’on souhaite couvrir plusieurs aspects d’un sujet, ou lorsque la fenêtre de contexte est trop restreinte pour les segments dupliqués.

Un pipeline complet

En combinant ces éléments, la récupération hybride élargit la portée des recherches, le réclassement sélectionne les segments les plus pertinents, et une chaîne RAG complète génère la réponse.

from langchain_google_genai import ChatGoogleGenerativeAI, GoogleGenerativeAIEmbeddings
from langchain_community.vectorstores import FAISS
from langchain_community.retrievers import BM25Retriever
from langchain_community.cross_encoders import HuggingFaceCrossEncoder
from langchain.retrievers import EnsembleRetriever, ContextualCompressionRetriever
from langchain.retrievers.document_compressors import CrossEncoderReranker
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_community.document_loaders import TextLoader
# Setup
llm = ChatGoogleGenerativeAI(model="gemini-1.5-flash", google_api_key="YOUR_KEY")
embeddings = GoogleGenerativeAIEmbeddings(model="models/embedding-001", google_api_key="YOUR_KEY")
# Load and split documents
loader = TextLoader("your_knowledge_base.txt")
documents = loader.load()
splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=50)
chunks = splitter.split_documents(documents)
# Build retrievers
vector_store = FAISS.from_documents(chunks, embeddings)
semantic_retriever = vector_store.as_retriever(search_kwargs={"k": 10})
bm25_retriever = BM25Retriever.from_documents(chunks)
bm25_retriever.k = 10
# Hybrid retriever
hybrid_retriever = EnsembleRetriever(
    retrievers=[semantic_retriever, bm25_retriever],
    weights=[0.6, 0.4]
)
# Add reranking on top
reranker = CrossEncoderReranker(
    model=HuggingFaceCrossEncoder(model_name="BAAI/bge-reranker-base"),
    top_n=3
)
final_retriever = ContextualCompressionRetriever(
    base_compressor=reranker,
    base_retriever=hybrid_retriever
)
# RAG prompt
prompt = ChatPromptTemplate.from_messages([
    ("system", """Answer the question using only the context below.
    If the answer is not in the context, say "I don't have that information."

    Context: {context}"""),
    ("human", "{question}")
])
def format_docs(docs):
    return "\n\n".join(doc.page_content for doc in docs)
# Complete chain
rag_chain = (
    {"context": final_retriever | format_docs, "question": RunnablePassthrough()}
    | prompt
    | llm
    | StrOutputParser()
)
# Use it
answer = rag_chain.invoke("Your question here")
print(answer)

La qualité des résultats obtenus est nettement supérieure à celle d’une simple recherche par similarité de base.

Choix d’une stratégie

Un bref guide de décision associe les situations aux stratégies appropriées :

Just starting out / simple use case?
    → Basic similarity search is fine
Documents have specific technical terms or product names?
    → Add BM25 → use Hybrid Search
Answer quality is critical, wrong answers are costly?
    → Add Reranking on top of any retriever
Documents have lots of repeated content?
    → Use MMR instead of standard similarity search
Production system with high quality requirements?
    → Hybrid Search + Reranking together

Ne sur-engineer pas dès le premier jour. Commencez par une recherche de similarité de base, ajoutez une recherche hybride dès que vous constatez des manques de mots-clés, et ajoutez un réclassement lorsque la qualité des réponses doit s’améliorer. Chaque ajout entraîne des coûts en termes de complexité, de latence et de calcul. Pour un RAG en production, la recherche hybride associée à un réclassement constitue la norme à viser, et la complexité ne doit apparaître que lorsque un problème spécifique l’exige.

Une remarque pratique sur le réclassement : il nécessite pip install sentence-transformers, et le modèle BAAI/bge-reranker-base se télécharge automatiquement lors de la première utilisation, pour environ 1 GB. Pour la production, les API de réclassement hébergées par Cohere ou Jina AI permettent d’éviter les contraintes liées à l’exécution du modèle localement.

Lectures complémentaires

  • Au-delà de la recherche par similarité : filtrage des métadonnées pour RAG avec S3 Vectors — D’où proviennent les valeurs de filtrage, quand un classificateur est utile, et comment définir les métadonnées filtrables et non filtrables dans Amazon S3 Vectors.