Votre RAG fonctionne, mais il rate parfois le bon document ? Les techniques de ce guide peuvent réduire le taux d’échec de 50 à 67% selon les benchmarks. Vous apprendrez la recherche hybride, le reranking et le contextual retrieval.
Ce que vous allez apprendre
Section intitulée « Ce que vous allez apprendre »- Combiner recherche dense + BM25 (hybride)
- Utiliser Reciprocal Rank Fusion (RRF) pour fusionner les résultats
- Affiner avec un reranker (cross-encoder)
- Implémenter le Contextual Retrieval d’Anthropic
- Configurer une baseline de production
Pourquoi la recherche dense seule ne suffit pas
Section intitulée « Pourquoi la recherche dense seule ne suffit pas »La recherche dense (embeddings) comprend le sens mais rate parfois les correspondances exactes.
| Requête | Ce que dense trouve | Ce qui manque |
|---|---|---|
| ”Erreur TS-999” | Docs sur “erreurs de déploiement” | Le doc exact avec “TS-999" |
| "kubectl apply -f” | Docs sur “déploiement Kubernetes” | Le runbook avec la commande exacte |
| ”CVE-2024-1234” | Docs sur “vulnérabilités” | Le bulletin de sécurité précis |
Solution : ajouter une recherche lexicale (BM25) et fusionner les résultats.
Recherche hybride : dense + BM25
Section intitulée « Recherche hybride : dense + BM25 »Fonctionnement
Section intitulée « Fonctionnement »| Type | Ce qu’il fait | Forces | Faiblesses |
|---|---|---|---|
| Dense | Recherche sémantique (vecteurs) | Comprend le sens, synonymes | Rate les IDs, codes, acronymes |
| BM25 | Recherche lexicale (mots-clés) | Trouve les termes exacts | Ne comprend pas le sens |
| Hybride | Combine les deux | Le meilleur des deux | Plus complexe |
Reciprocal Rank Fusion (RRF)
Section intitulée « Reciprocal Rank Fusion (RRF) »RRF combine les rankings de plusieurs sources en un score unifié :
def reciprocal_rank_fusion(results_lists, k=60): """ Combine plusieurs listes de résultats avec RRF.
Args: results_lists: liste de listes de (doc_id, score) k: constante de lissage (60 par défaut)
Returns: dict: {doc_id: rrf_score} """ rrf_scores = {}
for results in results_lists: for rank, (doc_id, _) in enumerate(results, start=1): if doc_id not in rrf_scores: rrf_scores[doc_id] = 0 rrf_scores[doc_id] += 1 / (k + rank)
return dict(sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True))
# Usagedense_results = retriever_dense.search(query, top_k=100)bm25_results = retriever_bm25.search(query, top_k=100)
fused = reciprocal_rank_fusion([dense_results, bm25_results])top_candidates = list(fused.keys())[:50] # Top 50 pour le rerankerImplémentation avec des vector stores
Section intitulée « Implémentation avec des vector stores »Certains vector stores supportent la recherche hybride nativement :
| Store | Support hybride | Notes |
|---|---|---|
| Weaviate | ✅ Natif | hybrid_search(query, alpha=0.5) |
| Qdrant | ✅ Natif | search(..., sparse_vector=...) |
| Milvus | ✅ Natif | Depuis v2.4 |
| ChromaDB | ❌ | Ajoutez BM25 manuellement |
| Pinecone | ✅ | Sparse-dense vectors |
Si vous utilisez Weaviate, la recherche hybride ne demande qu’une seule ligne. Le paramètre alpha contrôle l’équilibre entre dense et BM25 :
alpha=1.0→ 100% dense (sémantique pure)alpha=0.0→ 100% BM25 (mots-clés purs)alpha=0.5→ 50/50 (bon point de départ)
# Exemple Weaviate : recherche hybride en une ligneresults = client.query.get("Document", ["content", "source"]) \ .with_hybrid(query="Erreur TS-999", alpha=0.5) \ .with_limit(50) \ .do()
# `alpha=0.5` donne autant de poids au sens qu'aux mots exacts# Ajustez selon votre corpus : plus de technique → augmentez BM25Reranking : affiner les résultats
Section intitulée « Reranking : affiner les résultats »Le retriever retourne 50-150 candidats rapidement. Le reranker les réduit aux 10-20 meilleurs avec plus de précision.
Retriever vs Reranker
Section intitulée « Retriever vs Reranker »| Aspect | Retriever | Reranker |
|---|---|---|
| Vitesse | Millisecondes | Centaines de ms |
| Méthode | Bi-encoder (vecteurs pré-calculés) | Cross-encoder (query + doc ensemble) |
| Précision | Bonne | Excellente |
| Coût | Faible | Modéré |
Le cross-encoder voit la question ET le document ensemble, contrairement au retriever qui compare des vecteurs calculés séparément.
Impact mesuré
Section intitulée « Impact mesuré »Selon les benchmarks d’Anthropic (sur leurs datasets internes) :
| Configuration | Amélioration du recall |
|---|---|
| Embeddings seuls | Baseline |
| + BM25 hybride | Jusqu’à ~50% |
| + Reranking | Jusqu’à ~67% |
Rerankers recommandés
Section intitulée « Rerankers recommandés »| Reranker | Type | Qualité | Coût |
|---|---|---|---|
| Cohere Rerank | API | Excellent | $$ |
| Voyage Rerank | API | Excellent | $$ |
| Jina Reranker | API | Très bon | $ |
| cross-encoder/ms-marco | Local | Bon | Gratuit |
Voici comment utiliser Cohere Rerank. L’idée est simple : vous passez la question et les documents candidats au reranker. Il analyse chaque paire (question, document) et retourne un score de pertinence plus fin que la simple similarité vectorielle.
# Exemple avec Cohere Rerankimport cohere
# 1. Initialiser le client (obtenez votre clé sur dashboard.cohere.com)co = cohere.Client(api_key="votre_cle_api")
# 2. Réordonner les candidats# `candidates` = liste de textes retournés par le retriever# `top_n` = nombre de résultats à garder après rerankingreranked = co.rerank( model="rerank-english-v3.0", # ou rerank-multilingual-v3.0 pour FR query="Comment déployer sur staging ?", documents=candidates, top_n=10)
# 3. Afficher les résultats triés par pertinencefor result in reranked.results: print(f"Score: {result.relevance_score:.3f} - {result.document.text[:50]}...")Ce qui se passe sous le capot :
- Le retriever a retourné 50 candidats rapidement (bi-encoder)
- Le reranker analyse chacun avec la question (cross-encoder)
- Il retourne les 10 meilleurs avec un score de 0 à 1
Contextual Retrieval
Section intitulée « Contextual Retrieval »Technique développée par Anthropic pour résoudre le problème du contexte perdu lors du chunking.
Le problème
Section intitulée « Le problème »Quand vous découpez un document, chaque chunk perd son contexte :
Chunk brut : "Le CA a augmenté de 3% par rapport au trimestre précédent."
❌ Quelle entreprise ?❌ Quel trimestre ?❌ Quel était le CA précédent ?La solution
Section intitulée « La solution »Ajoutez un préfixe de contexte généré par LLM à chaque chunk avant l’embedding :
Chunk contextualisé : "[Contexte : Rapport financier ACME Corp Q2 2023,CA Q1 = 314M€] Le CA a augmenté de 3% par rapport au trimestre précédent."
✅ Entreprise identifiée✅ Période claire✅ Valeur de référenceImplémentation
Section intitulée « Implémentation »-
Pour chaque chunk, envoyez au LLM le document complet + le chunk
-
Demandez un résumé de contexte de 50-100 tokens
-
Préfixez le chunk avec ce contexte
-
Embedez le chunk contextualisé (pas le chunk brut)
-
Indexez dans votre vector store
Voici comment implémenter le contextual retrieval. Le principe : pour chaque chunk, on demande au LLM de générer un court résumé de contexte (50-100 tokens) qui sera préfixé au chunk avant de créer l’embedding.
def generate_context(document: str, chunk: str, client) -> str: """ Génère un contexte pour un chunk.
Pourquoi : le chunk seul perd son contexte (quelle entreprise ? quel trimestre ? quelle section ?). Le LLM lit le document complet et résume les infos manquantes. """ prompt = f"""<document>{document}</document>
<chunk>{chunk}</chunk>
Donne un contexte court (50-100 tokens) pour situer ce chunkdans le document. Inclus : titre du document, section, entités clés.Réponds uniquement avec le contexte, sans préfixe."""
# On utilise Haiku : rapide et économique pour cette tâche simple response = client.messages.create( model="claude-3-haiku-20240307", max_tokens=150, messages=[{"role": "user", "content": prompt}] ) return response.content[0].text
def contextualize_chunks(document: str, chunks: list[str], client) -> list[str]: """ Ajoute du contexte à chaque chunk avant l'embedding.
Exemple de résultat : "[Rapport financier ACME Corp Q2 2023] Le CA a augmenté de 3%..." """ contextualized = [] for chunk in chunks: context = generate_context(document, chunk, client) # Le contexte entre crochets précède le chunk original contextualized.append(f"[{context}] {chunk}") return contextualizedWorkflow complet :
- Découper le document en chunks (voir guide chunking)
- Pour chaque chunk : générer le contexte avec
generate_context() - Préfixer le chunk avec ce contexte
- Encoder le chunk contextualisé (pas le chunk brut)
- Stocker dans le vector store
Ainsi, quand quelqu’un cherche “CA ACME Q2 2023”, le chunk contextualisé sera trouvé même si le chunk original ne mentionnait que “augmentation de 3%”.
Contraintes à connaître
Section intitulée « Contraintes à connaître »| Contrainte | Impact | Mitigation |
|---|---|---|
| Coût d’indexation | Chaque chunk = 1 appel LLM | Budget à prévoir, utiliser Haiku |
| Latence d’indexation | Plus longue | Indexation async/batch |
| Versioning | Contexte invalide si doc change | Stocker doc_version / hash |
| Ré-indexation | Nécessaire si source change | Automatiser avec CI/CD |
Cette technique est rentable sur des corpus stables (docs internes, wikis, runbooks) plus que sur des données très dynamiques.
Baseline de production
Section intitulée « Baseline de production »Configuration de départ recommandée pour un RAG hybride :
| Paramètre | Valeur | Notes |
|---|---|---|
| Dense top_k | 50-100 | Candidats initiaux |
| BM25 top_k | 50-100 | Candidats initiaux |
| Fusion | RRF (k=60) | Combine les deux |
| Rerank top_n | 15-20 | Après fusion |
| Inject final_k | 6-12 | Chunks dans le prompt |
Pipeline complet
Section intitulée « Pipeline complet »Voici comment assembler toutes ces techniques dans un pipeline cohérent. Chaque étape affine progressivement les résultats :
def rag_query(query: str, user_context: dict) -> str: """ Pipeline RAG hybride complet.
Étapes : Dense (100) + BM25 (100) → RRF (50) → Rerank (15) → Inject (8) """
# ÉTAPE 1 : Recherche dense (sémantique) # Trouve les docs au sens proche, même sans mots en commun dense_results = dense_retriever.search(query, top_k=100)
# ÉTAPE 2 : Recherche BM25 (mots-clés) # Trouve les docs avec les termes exacts (codes, IDs, noms) bm25_results = bm25_retriever.search(query, top_k=100)
# ÉTAPE 3 : Fusion avec Reciprocal Rank Fusion # Combine les deux rankings en un seul, équilibré fused = reciprocal_rank_fusion([dense_results, bm25_results], k=60) candidates = list(fused.keys())[:50] # Top 50 pour le reranker
# ÉTAPE 4 : Reranking avec cross-encoder # Analyse chaque paire (query, doc) plus finement reranked = reranker.rerank(query, candidates, top_n=15)
# ÉTAPE 5 : Sélection finale # On ne garde que les 8 meilleurs pour le prompt final_chunks = reranked[:8]
# ÉTAPE 6 : Génération de la réponse # Injecte les chunks dans le prompt et génère context = format_context(final_chunks) response = llm.generate(build_prompt(query, context))
return responsePourquoi ces nombres ?
| Étape | Nombre | Raison |
|---|---|---|
| Dense + BM25 | 100 chacun | Ratisser large, ne pas rater le bon doc |
| Après RRF | 50 | Éliminer les doublons et le bruit |
| Après rerank | 15 | Garder les plus pertinents |
| Dans le prompt | 8 | Éviter de dépasser la fenêtre de contexte |
Erreurs fréquentes
Section intitulée « Erreurs fréquentes »| Erreur | Conséquence | Solution |
|---|---|---|
| Pas de BM25 | Rate les termes exacts (codes, IDs) | Ajouter recherche hybride |
| Pas de reranking | Faux positifs dans le top-K | Ajouter un reranker |
| top_k trop faible | Manque le bon document | Augmenter à 50-100 puis reranker |
| Contextual sans versioning | Contexte obsolète | Stocker hash + doc_version |
| RRF k trop petit | Sur-pondère les premiers résultats | Utiliser k=60 |
À retenir
Section intitulée « À retenir »-
Recherche hybride : dense (sens) + BM25 (termes exacts) > dense seul
-
RRF : fusionne les rankings avec
1/(k+rank), k=60 par défaut -
Reranker : cross-encoder plus précis mais plus lent, utiliser après fusion
-
Contextual Retrieval : préfixer chaque chunk avec son contexte améliore le recall
-
Baseline : dense 100 + BM25 100 → RRF → rerank 15 → inject 6-12