Sparse Retrieval et BM25 : Quand la recherche lexicale surpasse
Découvrez le sparse retrieval et BM25 pour une recherche lexicale précise. Cas d'usage, implémentation et comparaison avec le 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 documentavgdl: longueur moyenne des documentsk1: paramètre de saturation (typiquement 1.2-2.0)b: paramètre de normalisation de longueur (typiquement 0.75)
Intuition derrière BM25
DEVELOPERpythondef bm25_score_explained(query_terms, document, corpus_stats): """ Explication intuitive du score BM25 """ score = 0 doc_length = len(document) avg_length = corpus_stats['average_length'] for term in query_terms: # 1. IDF : termes rares = plus importants # "algorithme" dans 5% des docs > "le" dans 95% idf = compute_idf(term, corpus_stats) # 2. TF avec saturation : évite de sur-pondérer les répétitions # "machine learning machine learning machine" ≠ 5× mieux tf = document.count(term) saturated_tf = (tf * (k1 + 1)) / (tf + k1 * (1 - b + b * doc_length / avg_length)) # 3. Normalisation longueur : compense les longs documents # Un document de 10 pages avec 5 mentions ≠ une FAQ de 2 lignes avec 5 mentions score += idf * saturated_tf return score
Implémentation pratique
Avec rank_bm25
DEVELOPERpythonfrom 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
DEVELOPERpythonfrom 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)
DEVELOPERpythonfrom 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 avec vecteurs sparse client.create_collection( collection_name="bm25_docs", sparse_vectors_config={ "text": SparseVectorParams() } ) def compute_sparse_vector(text: str, idf_dict: dict) -> SparseVector: """Convertit un texte en vecteur sparse BM25-like""" 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'usage | Dense | Sparse | Gagnant |
|---|---|---|---|
| Reformulation sémantique | Excellent | Faible | Dense |
| Correspondance exacte | Faible | Excellent | Sparse |
| Termes techniques | Moyen | Excellent | Sparse |
| Synonymes | Excellent | Faible | Dense |
| Noms propres | Moyen | Excellent | Sparse |
| Requêtes longues | Excellent | Moyen | Dense |
| Requêtes 1-2 mots | Moyen | Excellent | Sparse |
Optimiser BM25
Tuning des paramètres k1 et b
DEVELOPERpythondef grid_search_bm25_params(queries, relevant_docs, corpus): """Trouver les meilleurs paramètres k1 et 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) # Évaluer 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 →
bplus élevé (0.75-1.0) - Documents courts (FAQ) →
bplus bas (0.3-0.5) - Requêtes répétitives →
k1plus bas (0.5-1.0)
Analyse linguistique
La qualité du sparse retrieval dépend fortement de la tokenisation et du preprocessing :
DEVELOPERpythonimport spacy nlp = spacy.load("fr_core_news_md") def preprocess_french(text: str) -> list[str]: """Preprocessing optimisé pour le français""" doc = nlp(text.lower()) tokens = [] for token in doc: # Ignorer la ponctuation et les stopwords 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
DEVELOPERpythondef 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
DEVELOPERpythones.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
DEVELOPERpythonclass 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.
- Fusion hybride - Combiner dense et sparse
- Dense Retrieval - Recherche sémantique avec embeddings
- Fondamentaux du Retrieval - Vue d'ensemble
FAQ
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
Articles connexes
Query Routing : Orienter les requêtes vers la bonne source
Implémentez le query routing pour diriger chaque requête vers la source de données optimale. Classification, routage LLM et stratégies avancées.
Fusion hybride : Combiner dense et sparse retrieval
Maîtrisez la fusion hybride pour combiner recherche sémantique et lexicale. RRF, weighted fusion et stratégies de combinaison optimales.
Dense Retrieval : Recherche sémantique avec embeddings
Maîtrisez le dense retrieval pour une recherche sémantique performante. Embeddings, modèles, indexation vectorielle et optimisations avancées.