Aller au contenu
Développement medium

RAG avancé : améliorer la qualité du retrieval

17 min de lecture

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.

  • 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

La recherche dense (embeddings) comprend le sens mais rate parfois les correspondances exactes.

RequêteCe que dense trouveCe qui manque
”Erreur TS-999”Docs sur “erreurs de déploiement”Le doc exact avec “TS-999"
"kubectl apply -f”Docs sur “déploiement KubernetesLe 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 + reranking

TypeCe qu’il faitForcesFaiblesses
DenseRecherche sémantique (vecteurs)Comprend le sens, synonymesRate les IDs, codes, acronymes
BM25Recherche lexicale (mots-clés)Trouve les termes exactsNe comprend pas le sens
HybrideCombine les deuxLe meilleur des deuxPlus complexe

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))
# Usage
dense_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 reranker

Certains vector stores supportent la recherche hybride nativement :

StoreSupport hybrideNotes
Weaviate✅ Natifhybrid_search(query, alpha=0.5)
Qdrant✅ Natifsearch(..., sparse_vector=...)
Milvus✅ NatifDepuis v2.4
ChromaDBAjoutez BM25 manuellement
PineconeSparse-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 ligne
results = 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 BM25

Le retriever retourne 50-150 candidats rapidement. Le reranker les réduit aux 10-20 meilleurs avec plus de précision.

AspectRetrieverReranker
VitesseMillisecondesCentaines de ms
MéthodeBi-encoder (vecteurs pré-calculés)Cross-encoder (query + doc ensemble)
PrécisionBonneExcellente
CoûtFaibleModéré

Le cross-encoder voit la question ET le document ensemble, contrairement au retriever qui compare des vecteurs calculés séparément.

Selon les benchmarks d’Anthropic (sur leurs datasets internes) :

ConfigurationAmélioration du recall
Embeddings seulsBaseline
+ BM25 hybrideJusqu’à ~50%
+ RerankingJusqu’à ~67%
RerankerTypeQualitéCoût
Cohere RerankAPIExcellent$$
Voyage RerankAPIExcellent$$
Jina RerankerAPITrès bon$
cross-encoder/ms-marcoLocalBonGratuit

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 Rerank
import 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 reranking
reranked = 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 pertinence
for result in reranked.results:
print(f"Score: {result.relevance_score:.3f} - {result.document.text[:50]}...")

Ce qui se passe sous le capot :

  1. Le retriever a retourné 50 candidats rapidement (bi-encoder)
  2. Le reranker analyse chacun avec la question (cross-encoder)
  3. Il retourne les 10 meilleurs avec un score de 0 à 1

Technique développée par Anthropic pour résoudre le problème du contexte perdu lors du chunking.

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 ?

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érence

Contextual Retrieval : ajouter du contexte à chaque chunk

  1. Pour chaque chunk, envoyez au LLM le document complet + le chunk

  2. Demandez un résumé de contexte de 50-100 tokens

  3. Préfixez le chunk avec ce contexte

  4. Embedez le chunk contextualisé (pas le chunk brut)

  5. 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 chunk
dans 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 contextualized

Workflow complet :

  1. Découper le document en chunks (voir guide chunking)
  2. Pour chaque chunk : générer le contexte avec generate_context()
  3. Préfixer le chunk avec ce contexte
  4. Encoder le chunk contextualisé (pas le chunk brut)
  5. 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%”.

ContrainteImpactMitigation
Coût d’indexationChaque chunk = 1 appel LLMBudget à prévoir, utiliser Haiku
Latence d’indexationPlus longueIndexation async/batch
VersioningContexte invalide si doc changeStocker doc_version / hash
Ré-indexationNécessaire si source changeAutomatiser avec CI/CD

Cette technique est rentable sur des corpus stables (docs internes, wikis, runbooks) plus que sur des données très dynamiques.

Configuration de départ recommandée pour un RAG hybride :

ParamètreValeurNotes
Dense top_k50-100Candidats initiaux
BM25 top_k50-100Candidats initiaux
FusionRRF (k=60)Combine les deux
Rerank top_n15-20Après fusion
Inject final_k6-12Chunks dans le prompt

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 response

Pourquoi ces nombres ?

ÉtapeNombreRaison
Dense + BM25100 chacunRatisser large, ne pas rater le bon doc
Après RRF50Éliminer les doublons et le bruit
Après rerank15Garder les plus pertinents
Dans le prompt8Éviter de dépasser la fenêtre de contexte
ErreurConséquenceSolution
Pas de BM25Rate les termes exacts (codes, IDs)Ajouter recherche hybride
Pas de rerankingFaux positifs dans le top-KAjouter un reranker
top_k trop faibleManque le bon documentAugmenter à 50-100 puis reranker
Contextual sans versioningContexte obsolèteStocker hash + doc_version
RRF k trop petitSur-pondère les premiers résultatsUtiliser k=60
  1. Recherche hybride : dense (sens) + BM25 (termes exacts) > dense seul

  2. RRF : fusionne les rankings avec 1/(k+rank), k=60 par défaut

  3. Reranker : cross-encoder plus précis mais plus lent, utiliser après fusion

  4. Contextual Retrieval : préfixer chaque chunk avec son contexte améliore le recall

  5. Baseline : dense 100 + BM25 100 → RRF → rerank 15 → inject 6-12

Ce site vous est utile ?

Sachez que moins de 1% des lecteurs soutiennent ce site.

Je maintiens +700 guides gratuits, sans pub ni tracing. Aujourd'hui, ce site ne couvre même pas mes frais d'hébergement, d'électricité, de matériel, de logiciels, mais surtout de cafés.

Un soutien régulier, même symbolique, m'aide à garder ces ressources gratuites et à continuer de produire des guides de qualité. Merci pour votre appui.