5. Retrieval

Sparse Retrieval und BM25: Wenn die lexikalische Suche überlegen ist

8. März 2026
Équipe Ailog

Entdecken Sie Sparse Retrieval und BM25 für eine präzise lexikalische Suche. Anwendungsfälle, Implementierung und Vergleich mit dem dense retrieval.

Sparse Retrieval et BM25 : Quand la recherche lexicale surpasse

Le sparse retrieval, incarné par l'algorithme BM25, reste une force dominante dans la recherche d'information. Malgré l'engouement pour les embeddings et le dense retrieval, la recherche lexicale surpasse souvent les approches sémantiques dans des cas bien précis. Ce guide explore les mécanismes du sparse retrieval, ses forces, et quand le privilégier.

Qu'est-ce que le sparse retrieval ?

Le sparse retrieval représente les documents par des vecteurs "creux" (sparse) où chaque dimension correspond à un terme du vocabulaire. La plupart des valeurs sont nulles car un document ne contient qu'une fraction du vocabulaire total.

Du TF-IDF au BM25

L'évolution des algorithmes de sparse retrieval :

TF-IDF (1972)           →    BM25 (1994)           →    BM25+ (2011)
Score = TF × IDF             Saturation du TF           Correction bias
                             Normalisation longueur      documents courts

Comment fonctionne BM25

BM25 (Best Matching 25) calcule un score de pertinence basé sur la fréquence des termes de la requête dans les documents, avec des ajustements intelligents.

La formule BM25

Score(D, Q) = Σ IDF(qi) × (f(qi, D) × (k1 + 1)) / (f(qi, D) + k1 × (1 - b + b × |D|/avgdl))

Où :

  • f(qi, D) : fréquence du terme qi dans le document D
  • |D| : longueur du document
  • avgdl : longueur moyenne des documents
  • k1 : paramètre de saturation (typiquement 1.2-2.0)
  • b : paramètre de normalisation de longueur (typiquement 0.75)

Intuition derrière BM25

DEVELOPERpython
def bm25_score_explained(query_terms, document, corpus_stats): """ Intuitive Erklärung der BM25-Bewertung """ score = 0 doc_length = len(document) avg_length = corpus_stats['average_length'] for term in query_terms: # 1. IDF: seltene Begriffe = wichtiger # "algorithme" in 5% der Docs > "le" in 95% idf = compute_idf(term, corpus_stats) # 2. TF mit Sättigung: vermeidet Übergewichtung von Wiederholungen # "machine learning machine learning machine" ≠ 5× besser tf = document.count(term) saturated_tf = (tf * (k1 + 1)) / (tf + k1 * (1 - b + b * doc_length / avg_length)) # 3. Längennormalisierung: kompensiert lange Dokumente # Ein 10-seitiges Dokument mit 5 Erwähnungen ≠ eine 2-zeilige FAQ mit 5 Erwähnungen score += idf * saturated_tf return score

Implémentation pratique

Avec rank_bm25

DEVELOPERpython
from rank_bm25 import BM25Okapi import nltk # Préparation des documents documents = [ "Comment retourner un produit défectueux", "Politique de remboursement sous 30 jours", "Délais de livraison en France métropolitaine", "Frais de retour et conditions", "Service client disponible 24h/24" ] # Tokenisation tokenized_docs = [nltk.word_tokenize(doc.lower()) for doc in documents] # Création de l'index BM25 bm25 = BM25Okapi(tokenized_docs) # Recherche query = "retour produit" tokenized_query = nltk.word_tokenize(query.lower()) scores = bm25.get_scores(tokenized_query) # Résultats triés results = sorted(zip(documents, scores), key=lambda x: x[1], reverse=True) for doc, score in results[:3]: print(f"Score: {score:.3f} - {doc}")

Avec Elasticsearch

DEVELOPERpython
from elasticsearch import Elasticsearch es = Elasticsearch() # Créer un index avec BM25 es.indices.create( index="knowledge_base", body={ "settings": { "similarity": { "custom_bm25": { "type": "BM25", "k1": 1.2, "b": 0.75 } } }, "mappings": { "properties": { "content": { "type": "text", "similarity": "custom_bm25", "analyzer": "french" # Analyseur français }, "category": {"type": "keyword"} } } } ) # Indexer les documents for i, doc in enumerate(documents): es.index(index="knowledge_base", id=i, body={"content": doc}) # Rechercher results = es.search( index="knowledge_base", body={ "query": { "match": { "content": { "query": "retour produit", "operator": "or" } } } } )

Avec Qdrant (sparse vectors)

DEVELOPERpython
from qdrant_client import QdrantClient from qdrant_client.models import ( SparseVectorParams, PointStruct, SparseVector, NamedSparseVector ) from collections import Counter import math client = QdrantClient("localhost", port=6333) # Collection mit sparse vectors client.create_collection( collection_name="bm25_docs", sparse_vectors_config={ "text": SparseVectorParams() } ) def compute_sparse_vector(text: str, idf_dict: dict) -> SparseVector: """Konvertiert einen Text in einen sparse vector im BM25-Stil""" tokens = text.lower().split() tf = Counter(tokens) indices = [] values = [] for token, freq in tf.items(): if token in idf_dict: token_id = hash(token) % 1000000 # Simple hash # Score TF-IDF simplifié score = freq * idf_dict.get(token, 1.0) indices.append(token_id) values.append(score) return SparseVector(indices=indices, values=values) # Indexer for i, doc in enumerate(documents): sparse_vec = compute_sparse_vector(doc, idf_dict) client.upsert( collection_name="bm25_docs", points=[PointStruct( id=i, payload={"content": doc}, vector={"text": sparse_vec} )] )

Quand le sparse retrieval surpasse le dense

1. Correspondance exacte requise

Requête : "Erreur 503"
Dense retrieval : trouve "Problème de serveur", "Site indisponible"
Sparse retrieval : trouve exactement "Erreur 503 - Service unavailable"

Pour les codes d'erreur, numéros de série, références produit, le sparse est imbattable.

2. Termes techniques ou rares

Requête : "Tenseur de covariance Riemannien"
Dense retrieval : confus, trouve des articles sur les tenseurs en général
Sparse retrieval : match exact sur les documents contenant ces termes précis

Les modèles d'embedding ont rarement vu ces termes spécialisés dans leur entraînement.

3. Recherches combinatoires

Requête : "Python asyncio websocket"
Dense retrieval : comprend le sens global mais peut manquer des combinaisons exactes
Sparse retrieval : trouve les documents contenant les 3 termes

4. Noms propres et entités

Requête : "Jean-Pierre Dupont facture 2024"
Dense retrieval : perd le nom propre dans l'embedding
Sparse retrieval : match exact sur le nom

Tableau comparatif

Cas d'usageDenseSparseGagnant
Reformulation sémantiqueExcellentFaibleDense
Correspondance exacteFaibleExcellentSparse
Termes techniquesMoyenExcellentSparse
SynonymesExcellentFaibleDense
Noms propresMoyenExcellentSparse
Requêtes longuesExcellentMoyenDense
Requêtes 1-2 motsMoyenExcellentSparse

Optimiser BM25

Tuning des paramètres k1 et b

DEVELOPERpython
def grid_search_bm25_params(queries, relevant_docs, corpus): """Finde die besten Parameter k1 und b""" best_score = 0 best_params = {} for k1 in [0.5, 1.0, 1.2, 1.5, 2.0]: for b in [0.25, 0.5, 0.75, 1.0]: bm25 = BM25Okapi(corpus, k1=k1, b=b) # Bewerten total_recall = 0 for query, relevant in zip(queries, relevant_docs): scores = bm25.get_scores(query) top_k = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[:10] hits = len(set(top_k) & set(relevant)) total_recall += hits / len(relevant) avg_recall = total_recall / len(queries) if avg_recall > best_score: best_score = avg_recall best_params = {"k1": k1, "b": b} return best_params

Règles générales :

  • Documents longs → b plus élevé (0.75-1.0)
  • Documents courts (FAQ) → b plus bas (0.3-0.5)
  • Requêtes répétitives → k1 plus bas (0.5-1.0)

Analyse linguistique

La qualité du sparse retrieval dépend fortement de la tokenisation et du preprocessing :

DEVELOPERpython
import spacy nlp = spacy.load("fr_core_news_md") def preprocess_french(text: str) -> list[str]: """Preprocessing optimiert für Französisch""" doc = nlp(text.lower()) tokens = [] for token in doc: # Interpunktion und Stopwörter ignorieren if token.is_punct or token.is_stop: continue # Lemmatisation : "retournés" → "retourner" lemma = token.lemma_ # Filtrer les tokens trop courts if len(lemma) > 2: tokens.append(lemma) return tokens # Exemple text = "Les produits retournés seront remboursés sous 15 jours" tokens = preprocess_french(text) # ['produit', 'retourner', 'rembourser', 'jour']

Expansion de requête

DEVELOPERpython
def expand_query_synonyms(query: str, synonyms_dict: dict) -> str: """Enrichir la requête avec des synonymes""" expanded_terms = [] for term in query.split(): expanded_terms.append(term) if term in synonyms_dict: expanded_terms.extend(synonyms_dict[term]) return " ".join(expanded_terms) synonyms = { "retour": ["remboursement", "renvoi", "échange"], "produit": ["article", "commande", "achat"], "problème": ["souci", "erreur", "bug", "incident"] } query = "retour produit" expanded = expand_query_synonyms(query, synonyms) # "retour remboursement renvoi échange produit article commande achat"

BM25F : pour les documents structurés

BM25F étend BM25 aux documents avec plusieurs champs :

DEVELOPERpython
# Configuration Elasticsearch avec boost par champ es.search( index="products", body={ "query": { "multi_match": { "query": "smartphone samsung", "fields": [ "title^3", # Titre : poids x3 "description^1", # Description : poids x1 "category^2" # Catégorie : poids x2 ], "type": "best_fields" } } } )
DEVELOPERpython
# Implémentation manuelle BM25F def bm25f_score(query, document_fields, field_weights): """ BM25F : scoring multi-champs document_fields = { "title": "Samsung Galaxy S24", "description": "Smartphone haut de gamme avec écran AMOLED...", "category": "Téléphones" } field_weights = {"title": 3, "description": 1, "category": 2} """ total_score = 0 for field_name, content in document_fields.items(): weight = field_weights.get(field_name, 1) field_score = bm25_score(query, content) total_score += weight * field_score return total_score

Limites du sparse retrieval

1. Vocabulary mismatch

Document : "Véhicule électrique à batterie lithium"
Requête : "voiture écologique"
→ Score BM25 = 0 (aucun terme commun)

2. Sensibilité aux typos

Requête : "remboursment" (typo)
→ Ne trouve pas "remboursement"

Solution : Fuzzy matching

DEVELOPERpython
es.search( index="knowledge_base", body={ "query": { "match": { "content": { "query": "remboursment", "fuzziness": "AUTO" # Tolère 1-2 erreurs } } } } )

3. Pas de compréhension du contexte

Requête : "Apple"
→ Trouve autant la marque que les recettes de pommes

Le sparse ne désambiguïse pas sans contexte additionnel.

Intégration dans un pipeline RAG

DEVELOPERpython
class SparseRetriever: def __init__(self, documents: list[str]): self.documents = documents self.tokenized_docs = [self._preprocess(d) for d in documents] self.bm25 = BM25Okapi(self.tokenized_docs) def _preprocess(self, text: str) -> list[str]: # Tokenisation et normalisation return preprocess_french(text) def search(self, query: str, top_k: int = 5) -> list[dict]: tokenized_query = self._preprocess(query) scores = self.bm25.get_scores(tokenized_query) # Indices des top_k documents top_indices = sorted( range(len(scores)), key=lambda i: scores[i], reverse=True )[:top_k] return [ { "content": self.documents[i], "score": scores[i], "method": "bm25" } for i in top_indices ]

Prochaines étapes

Le sparse retrieval excelle sur les correspondances exactes, mais manque de compréhension sémantique. La solution ? Combiner les deux approches.

FAQ

BM25 surpasse les embeddings pour les correspondances exactes (codes d'erreur, références produit), les termes techniques rares absents du vocabulaire des modèles, les noms propres, et les requêtes très courtes (1-2 mots). Il est aussi plus rapide, sans GPU requis, et totalement transparent dans son fonctionnement. Pour un RAG production, combinez les deux approches.
Le paramètre k1 (typiquement 1.2-2.0) contrôle la saturation de la fréquence des termes : une valeur basse pénalise la répétition excessive d'un même mot. Le paramètre b (typiquement 0.75) ajuste la normalisation par longueur de document : plus b est élevé, plus les longs documents sont pénalisés. Pour des FAQs courtes, réduisez b (0.3-0.5). Pour des articles longs, gardez b élevé.
BM25 natif échoue sur les typos car il compare des termes exacts. Trois solutions : le fuzzy matching (Elasticsearch fuzziness="AUTO" tolère 1-2 erreurs), la correction orthographique en prétraitement, ou l'expansion de requête avec des variantes générées. Pour un système robuste, combinez BM25 avec des embeddings qui sont naturellement tolérants aux variations orthographiques.
Oui, significativement. La lemmatisation ("retournés" devient "retourner") permet de matcher toutes les formes conjuguées et accordées d'un mot. Utilisez SpaCy avec le modèle français pour une lemmatisation de qualité. Ajoutez aussi la suppression des stopwords ("le", "de", "et") qui n'apportent pas de valeur discriminante. Ces prétraitements peuvent améliorer le recall de 10-15%.
Oui, avec BM25F (Field-weighted BM25). Cette variante permet d'attribuer des poids différents aux champs : titre (poids x3), description (poids x1), catégorie (poids x2). Une correspondance dans le titre compte plus qu'une correspondance dans la description. Elasticsearch et Lucene supportent nativement ce multi-field boosting. C'est essentiel pour les catalogues produits et les bases documentaires structurées. ---

Sparse retrieval optimisé avec Ailog

Ailog combine automatiquement sparse et dense retrieval pour des résultats optimaux :

  • BM25 français natif avec lemmatisation et analyse linguistique
  • Fusion hybride automatique adaptée à vos contenus
  • Tuning des paramètres basé sur vos retours utilisateurs
  • Zéro configuration - tout fonctionne dès l'import

Essayez gratuitement et bénéficiez du meilleur des deux mondes.

Tags

ragretrievalbm25sparse retrievalrecherche lexicale

Verwandte Artikel

Ailog Assistant

Ici pour vous aider

Salut ! Pose-moi des questions sur Ailog et comment intégrer votre RAG dans vos projets !