Hybrid search — BM25 + dense vector, le défaut sérieux en 2026
TL;DR Dense-only ne reconnaît pas les acronymes, les codes (
L121-1,ref SKU XJ-2200B), les versions (Postgres 17.2), les noms propres rares ni les requêtes très courtes. BM25-only manque les paraphrases et le sens. Hybrid (BM25 + dense, fusionnés via RRF ou Weighted Linear Sum) gagne en moyenne +15-30% nDCG@10 sur tous les benchmarks 2025. SPLADE (sparse learned) est encore au-dessus en qualité mais demande plus d'infra. Stacks pratiques : pgvector + tsvector (le plus simple), Elasticsearch + dense_vector, OpenSearch (open-source), Vespa (le plus performant en search). À mesurer toujours avec nDCG, MRR ou Recall@k sur un eval set curaté.
🧠 Mental model
Pourquoi pas dense-only ?
Requête | Dense gagne | BM25 gagne | Hybrid gagne
────────────────────┼─────────────┼────────────┼─────────────
"robe été légère" | ✓ | ─ | ✓
"L121-1 code consom"| ─ | ✓ | ✓
"SKU XJ-2200B" | ─ | ✓ | ✓
"comment résilier ?"| ✓ | ─ | ✓
"Postgres 17.2 cve" | ✗ | ✓ | ✓
"expert legaltech" | ✓ | partiel | ✓
"ISO 27001 annexe A"| ─ | ✓ | ✓Le dense projette le sens dans un espace, génial pour la sémantique. Mais "L121-1" n'a aucune voisinage sémantique avec "consommateur" tant que ton modèle n'a pas vu ce token assez souvent → recall qui s'effondre.
Le BM25 matche les tokens exacts pondérés par IDF — parfait pour les codes, références, versions, noms propres.
Hybrid = les deux, fusionnés. Et la fusion intelligente, ce n'est pas une simple moyenne.
RRF (Reciprocal Rank Fusion)
doc id | rank_dense | rank_bm25 | RRF score (k=60)
─────────┼────────────┼───────────┼──────────────────────
doc-42 | 1 | 8 | 1/(60+1) + 1/(60+8) = 0.0311
doc-12 | 4 | 1 | 1/(60+4) + 1/(60+1) = 0.0320 ←
doc-9 | 2 | 15 | 1/(60+2) + 1/(60+15) = 0.0294
doc-77 | 50 | 2 | 1/(60+50) + 1/(60+2) = 0.0252RRF utilise uniquement le rang, pas le score. Ça rend la fusion robuste quand les deux scores ne sont pas comparables (un cosinus 0-1 vs un BM25 0-20+). k=60 est la constante recommandée (Cormack et al. 2009).
Analogie : RRF c'est comme un jury olympique : peu importe le score absolu d'un juge, ce qui compte c'est ton classement chez chacun.
Weighted Linear Sum (quand RRF ne suffit pas)
score = α × normalize(dense_score) + (1-α) × normalize(bm25_score)
α = 0.6 par défaut (un peu plus de dense que de BM25)À utiliser quand tu as une eval set suffisante pour tuner α et que les scores normalisés sont calibrés (min-max scaling sur top-200 candidats).
🛠️ Code minimal
pgvector + tsvector (le plus simple)
WITH dense AS (
SELECT id, ROW_NUMBER() OVER (ORDER BY embedding <=> $1::vector) AS r
FROM documents
ORDER BY embedding <=> $1::vector
LIMIT 100
),
sparse AS (
SELECT id, ROW_NUMBER() OVER (ORDER BY ts_rank_cd(content_tsv, q) DESC) AS r
FROM documents, plainto_tsquery('french', $2) AS q
WHERE content_tsv @@ q
ORDER BY ts_rank_cd(content_tsv, q) DESC
LIMIT 100
)
SELECT d.id, COALESCE(1.0/(60+dense.r), 0) + COALESCE(1.0/(60+sparse.r), 0) AS rrf
FROM (SELECT id FROM dense UNION SELECT id FROM sparse) d
LEFT JOIN dense ON d.id = dense.id
LEFT JOIN sparse ON d.id = sparse.id
ORDER BY rrf DESC
LIMIT 20;Elasticsearch / OpenSearch (avec dense_vector et RRF)
POST /products/_search
{
"size": 20,
"retriever": {
"rrf": {
"retrievers": [
{
"standard": {
"query": {
"multi_match": {
"query": "robe rouge bohème été",
"fields": ["title^3", "brand^2", "description"]
}
}
}
},
{
"knn": {
"field": "text_embedding",
"query_vector": [0.01, 0.02, "..."],
"k": 100,
"num_candidates": 500
}
}
],
"rank_window_size": 100,
"rank_constant": 60
}
}
}Qdrant (prefetch + fusion server-side)
client.query_points(
collection_name="docs",
prefetch=[
Prefetch(query=q_dense, using="dense", limit=200),
Prefetch(query=q_sparse, using="splade", limit=200),
],
query=FusionQuery(fusion=Fusion.RRF),
limit=20,
)🎬 Cas d'usage concrets
Cas 1 — Recherche juridique : "article L121-1 du code de la consommation"
Contexte : Legaltech FR, 800K décisions et articles de codes. Demande typique d'un avocat : "article L121-1 du code de la consommation" → doit retourner exactement l'article L121-1, pas un article sémantiquement proche.
Problème dense-only : le modèle d'embedding ne voit pas "L121-1" comme un token unique fort. Il retourne L121-2, L122-1, et d'autres articles "proches" → faux positifs catastrophiques côté avocat.
Solution : hybrid BM25 + dense avec RRF. Le BM25 fait remonter L121-1 en rang 1 grâce à l'IDF élevée du token "L121-1". Le dense complète sur les requêtes paraphrasées ("résiliation contrat consommateur").
Résultat : précision@1 sur requêtes à code passe de 22% (dense) à 98% (hybrid). Pas de régression sur les requêtes sémantiques.
Cas 2 — E-commerce mode avec marques et SKU
Contexte : Marketplace 200K SKU. Requêtes "Sezane robe rouge", "ref XJ-2200B", "Sandro veste cuir noir M". Marques et références sont des tokens exacts.
Solution : hybrid avec BM25 boosté sur les champs brand, sku, title et dense sur la description. Fusion RRF + reranker Cohere top-50.
Résultat : conversion search → panier +18%. Réduction des "aucun résultat" de 12% à 3% (le BM25 sauve les requêtes très spécifiques).
Cas 3 — Helpdesk technique SaaS (codes erreur, versions)
Contexte : éditeur SaaS B2B, base de connaissances de 4 000 articles support. Les tickets utilisateurs contiennent souvent des codes d'erreur (ERR_TLS_RENEGO, 500.21, 0xC0000005) et des versions (v3.4.2, Windows 11 22H2).
Solution : hybrid pgvector + tsvector. Boost BM25 sur les champs error_codes[] et versions[] indexés en GIN. Dense sur le corps de l'article.
Résultat : taux de résolution self-service +24%. -18% de tickets escaladés vers le support N2.
🛠️ Exemple end-to-end
Contexte : e-commerce mode, ~200K SKU, Elasticsearch 8.16 (équivalent OpenSearch 2.18). Hybrid search BM25 + dense_vector + RRF. Mesure nDCG sur eval set 500 requêtes curatées par les opérateurs.
Schéma index
PUT /products
{
"settings": {
"analysis": {
"analyzer": {
"fr_analyzer": {
"tokenizer": "standard",
"filter": ["lowercase", "asciifolding", "french_elision", "french_stop", "french_stemmer"]
}
},
"filter": {
"french_elision": {"type": "elision", "articles_case": true, "articles": ["l","m","t","qu","n","s","j","d","c","jusqu","quoiqu","lorsqu","puisqu"]},
"french_stop": {"type": "stop", "stopwords": "_french_"},
"french_stemmer": {"type": "stemmer", "language": "light_french"}
}
}
},
"mappings": {
"properties": {
"sku": {"type": "keyword"},
"title": {"type": "text", "analyzer": "fr_analyzer", "fields": {"raw": {"type": "keyword"}}},
"brand": {"type": "keyword"},
"color": {"type": "keyword"},
"description": {"type": "text", "analyzer": "fr_analyzer"},
"price_cents": {"type": "integer"},
"text_embedding": {
"type": "dense_vector",
"dims": 1024,
"index": true,
"similarity": "cosine",
"index_options": {"type": "hnsw", "m": 16, "ef_construction": 100}
}
}
}
}Ingestion (FastEmbed pour BGE-M3 1024 dim)
# pipeline/ingest.py
import json
from elasticsearch import Elasticsearch, helpers
from fastembed import TextEmbedding
es = Elasticsearch("https://es:9200", basic_auth=("elastic", "..."))
model = TextEmbedding("BAAI/bge-m3")
def gen_actions(products: list[dict]):
texts = [f"{p['brand']} {p['title']}. {p.get('description','')}" for p in products]
embs = list(model.embed(texts))
for p, e in zip(products, embs):
yield {
"_index": "products",
"_id": p["sku"],
"_source": {**p, "text_embedding": e.tolist()},
}
helpers.bulk(es, gen_actions(json.load(open("data/products.json"))), chunk_size=200)Hybrid query avec RRF retriever
# api/search.py
from elasticsearch import Elasticsearch
from fastembed import TextEmbedding
es = Elasticsearch("https://es:9200", basic_auth=("elastic", "..."))
model = TextEmbedding("BAAI/bge-m3")
def hybrid_search(q: str, brand: str | None = None, color: str | None = None, k: int = 20) -> list[dict]:
q_emb = next(model.query_embed([q])).tolist()
filters = []
if brand: filters.append({"term": {"brand": brand}})
if color: filters.append({"term": {"color": color}})
body = {
"size": k,
"retriever": {
"rrf": {
"retrievers": [
{
"standard": {
"query": {
"bool": {
"filter": filters,
"must": {
"multi_match": {
"query": q,
"fields": ["title^3", "brand^2", "description"],
"type": "best_fields",
"operator": "or",
}
},
}
}
}
},
{
"knn": {
"field": "text_embedding",
"query_vector": q_emb,
"k": 100,
"num_candidates": 500,
"filter": {"bool": {"filter": filters}},
}
},
],
"rank_window_size": 100,
"rank_constant": 60,
}
},
"_source": ["sku", "title", "brand", "color", "price_cents"],
}
res = es.search(index="products", body=body)
return [h["_source"] | {"score": h["_score"]} for h in res["hits"]["hits"]]
def bm25_only_search(q, **kw):
# Pour benchmark
...
def dense_only_search(q, **kw):
# Pour benchmark
...Eval nDCG@10 : hybrid vs vector-only vs bm25-only
# evals/run_eval.py
import json, math
from api.search import hybrid_search, bm25_only_search, dense_only_search
EVAL = json.load(open("data/eval_set_500.json"))
def ndcg_at_k(predicted_skus: list[str], relevant: dict[str, int], k: int = 10) -> float:
"""relevant = {sku: relevance_grade ∈ {0,1,2,3}}"""
gains = [relevant.get(sku, 0) for sku in predicted_skus[:k]]
dcg = sum((2**g - 1) / math.log2(i + 2) for i, g in enumerate(gains))
ideal = sorted(relevant.values(), reverse=True)[:k]
idcg = sum((2**g - 1) / math.log2(i + 2) for i, g in enumerate(ideal)) or 1.0
return dcg / idcg
def run(search_fn, name):
scores = []
for item in EVAL:
hits = search_fn(item["q"], brand=item.get("brand"), color=item.get("color"), k=10)
skus = [h["sku"] for h in hits]
scores.append(ndcg_at_k(skus, item["relevant"]))
avg = sum(scores) / len(scores)
print(f"[{name}] nDCG@10 = {avg:.4f} (n={len(scores)})")
return avg
if __name__ == "__main__":
run(bm25_only_search, "bm25-only")
run(dense_only_search, "dense-only")
run(hybrid_search, "hybrid-rrf")Numbers réels (mode marketplace 200K SKU, 500 queries eval set) :
- BM25 only : 0.512
- Dense only (BGE-M3) : 0.604
- Hybrid RRF (BM25 + dense) : 0.731 (+21% vs dense seul)
- Hybrid RRF + Cohere rerank top-50 : 0.798 (+10% supplémentaires)
🎯 Patterns courants
- RRF par défaut quand tu n'as pas d'eval set encore. Robuste et zero-tuning.
- Weighted Linear Sum quand tu as un eval set ≥ 200 queries → α tuning améliore de 2-5% vs RRF brut.
- Boost les champs structurés (
brand,sku,error_code) sur le côté BM25 — c'est leur force. - Reranker en bout de chaîne (Cohere Rerank 3, Voyage rerank-2, Jina) sur le top-50 : +5-15% nDCG additionnel.
rank_window_sizeplus grand quesizepour donner du matériel à la fusion. 100-200 typique poursize=20.- SPLADE (sparse learned) > BM25 quand tu as l'infra. naver/efficient-splade-v et naver/efficient-splade-vi en 2026.
- Filtres dans les deux branches : si tu filtres sur
brand=Sezaneen BM25 et pas en kNN, ta fusion sera bidon. - Eval set curaté + grade 0-3 (irrelevant, marginal, relevant, perfect) bien plus utile que binaire 0/1.
🔄 Versions & écosystème 2026
| Stack | Version | Notes |
|---|---|---|
| Elasticsearch | 8.16+ | Retrievers rrf GA, text_expansion (ELSER v2), reranker built-in |
| OpenSearch | 2.18+ | Hybrid search pipelines, neural search GA, reranking processor |
| Vespa | 8.x | Le meilleur en hybrid + structured, courbe d'apprentissage forte |
| Tantivy / Quickwit | 0.22+ | Rust, hybrid search, S3-native (Quickwit) |
| pgvector + tsvector | Postgres 17 + pgvector 0.8 | Hybrid maison via SQL RRF |
| Qdrant | 1.13+ | Dense + sparse natif, fusion server-side |
| Weaviate | 1.28+ | Hybrid alpha + BM25 + reranker modules |
| SPLADE | naver/efficient-splade-vi | Sparse learned, top performer MTEB hybrid |
| BGE-M3 | BAAI | Dense + sparse + colbert simultané, multilingue (FR ok) |
⚠️ Pitfalls
- Comparer scores cosinus et BM25 directement (sans normalisation) → fusion bidon. Utiliser RRF (rang) ou min-max scaling.
- Filtre appliqué d'un seul côté de la fusion → résultats incohérents. Toujours filtrer les deux branches identiquement.
ktrop petit avant fusion (genretop_k=10des deux côtés) → RRF fait des miracles seulement si elle a du matériel. Prendre top-100+ avant fusion.- Stop words FR mal configurés dans l'analyzer Elasticsearch → BM25 ignore "le", "de", "à" mais aussi "comment" si mal réglé.
- Embeddings non re-générés après changement de modèle → recall qui drift silencieusement. Versionner les embeddings.
- Pas d'eval set → tu ne peux pas tuner α, ni prouver que hybrid > dense. Toujours commencer par 50-200 requêtes curatées.
- Reranker activé partout → latence × 2-3 et coût Cohere/Voyage qui explose. Reranker = bout de chaîne, ≤ 50 candidats.
- Mix de langues sans analyzer multilingue → BM25 stemming FR sur des docs EN = catastrophe. Multi-fields ou analyzer par langue.
- SPLADE en prod sans GPU d'inférence → ingestion ultra-lente. SPLADE veut du GPU.
- Oublier que RRF est insensible au score → si une de tes branches retourne du bruit, ses rangs polluent la fusion. Toujours filtrer les scores trop bas avant la fusion.
💰 Pricing / ROI client
Infra (ordres de grandeur 2026)
| Stack | 1M docs | 10M docs | 50M docs |
|---|---|---|---|
| pgvector + tsvector | ~80€/mois | ~600€/mois | non recommandé |
| Elasticsearch managed (Elastic Cloud EU) | ~250€ | ~1 200€ | ~4 500€ |
| OpenSearch managed (AWS Paris) | ~200€ | ~1 000€ | ~3 800€ |
| Qdrant + sparse | ~290€ | ~900€ | ~2 100€ |
| Vespa Cloud | ~400€ | ~1 800€ | ~5 500€ |
Reranker (1M requêtes/mois sur top-50)
- Cohere Rerank 3 : ~250€/mois
- Voyage rerank-2 : ~200€/mois
- Jina Reranker : ~150€/mois
- Self-hosted (BGE-Reranker-v2-m3 sur 1 GPU L4) : ~180€/mois (Scaleway H100 inférieur en cost)
Pricing freelance type
| Mission | Jours | TJM | Prix HT |
|---|---|---|---|
| Audit search existant + recommandation hybrid | 3 | 1300€ | 3 900€ |
| Setup hybrid pgvector + tsvector + eval set | 5 | 1400€ | 7 000€ |
| Migration vector-only → hybrid + reranker | 7 | 1500€ | 10 500€ |
| Tuning RRF / α / SPLADE + eval continuous | 4 | 1500€ | 6 000€ |
ROI client e-commerce typique : +18% conversion sur 1M€ CA mensuel search-attributed = +180K€/mois marginal. Mission rentabilisée en < 1 semaine.
🧪 Testing / Eval
# tests/test_hybrid.py
import pytest
from api.search import hybrid_search, bm25_only_search, dense_only_search
@pytest.mark.parametrize("q,must_contain_sku", [
("article L121-1 code consommation", "code-conso-L121-1"),
("ref XJ-2200B", "XJ-2200B"),
("ERR_TLS_RENEGO Windows 11 22H2", "kb-tls-renego-w11"),
])
def test_exact_token_recall(q, must_contain_sku):
"""Le BM25 doit faire remonter le doc exact en top-3."""
hits = hybrid_search(q, k=10)
skus = [h["sku"] for h in hits[:3]]
assert must_contain_sku in skus
def test_hybrid_beats_dense_avg_ndcg():
from evals.run_eval import run, EVAL
dense = run(dense_only_search, "dense")
hybrid = run(hybrid_search, "hybrid")
assert hybrid > dense + 0.05, "Hybrid ne bat pas dense de 5pts → suspect"À monitorer en continu :
- nDCG@10 hebdomadaire sur eval set (régressions silencieuses)
- Distribution des poids RRF (dense vs sparse) par catégorie de requête
- Latence p95 par branche (BM25, dense, fusion, rerank)
- "Zero results rate" — combien de requêtes retournent rien (≤ 2% sain)
- Click-through rate (CTR) top-3 et top-10 (signal métier)
📋 Méthodologie freelance : mission "hybrid search" type
Sur une mission "améliore notre search", voici le plan que je vends en 7 jours à 1500€/j (10 500€ HT) :
# Mission hybrid search — 7 jours
## J1 — Audit
- Interview équipe : top 20 requêtes problématiques
- Analyse logs : zero-results rate, CTR top-3
- Reproduction des 20 cas en sandbox
- Livrable : doc "Diagnostic search" avec scoring qualitatif
## J2 — Eval set
- Sélection 200 requêtes représentatives (logs + interviews)
- Labellisation 0-3 par expert métier (présentiel demi-journée)
- Stockage JSON versionné
- Livrable : `eval_set_v1.json` reproductible
## J3 — Baseline
- Mesure nDCG@10 / MRR / Recall@10 sur :
- BM25 only (état actuel ou simulé)
- Dense only (avec embedding-small)
- Hybrid RRF naïf
- Livrable : tableau comparatif baseline
## J4-J5 — Implémentation hybrid
- Setup pipeline (pgvector + tsvector OU Elasticsearch retriever RRF)
- Boosts par champ
- Configuration analyzer FR (stemming, elision, stop words)
- Livrable : endpoint `/search/hybrid` testable
## J6 — Reranker + tuning
- Intégration Cohere Rerank 3 (ou Voyage)
- Tuning α (Weighted LS) sur eval set
- Mesure finale nDCG@10
- Livrable : config tuning + courbe α
## J7 — Doc + handover
- Doc archi
- Runbook (eval pipeline en CI, alertes nDCG)
- Session 2h formation équipe
- Livrable : repo Git complet + Notion / Confluence
## Critère de succès
nDCG@10 final ≥ baseline dense_only × 1.20Toujours mettre un critère de succès chiffré dans le devis. C'est ce qui te différencie du consultant qui "fait des slides".
🔁 Quand utiliser / éviter
Utiliser hybrid quand :
- Tu as des codes, refs, acronymes, noms propres rares
- Domaine spécialisé (juridique, médical, technique, e-commerce)
- Eval set possible (200+ requêtes curatées)
- Multi-langue avec termes spécifiques par langue
- Recall + précision tous les deux importants
Rester dense-only quand :
- Domaine grand public uniquement avec requêtes naturelles courtes
- Pas le temps de curater un eval set
- Volume très faible (< 10K docs) où BM25 ajoute peu
- Embeddings très adaptés au domaine (embedding fine-tuné)
Aller vers SPLADE ou multivector (ColBERT) quand :
- Hybrid RRF déjà en place et tu veux squeezer 5-15% de plus
- Tu as du GPU pour l'inférence
- L'équipe maîtrise les late-interaction models
🧰 Annexes — SPLADE, tuning α, fusion avancée
SPLADE en pratique (quand BM25 n'est plus assez)
SPLADE (Sparse Lexical and Expansion model) génère des vecteurs sparses appris plutôt que les fréquences brutes BM25. Il "étend" le vocabulaire d'un document avec ses synonymes pondérés.
# pip install transformers torch
from transformers import AutoTokenizer, AutoModelForMaskedLM
import torch
tokenizer = AutoTokenizer.from_pretrained("naver/efficient-splade-VI-BT-large-query")
model = AutoModelForMaskedLM.from_pretrained("naver/efficient-splade-VI-BT-large-query")
def splade_encode(text: str) -> dict[int, float]:
tokens = tokenizer(text, return_tensors="pt", truncation=True, max_length=256)
with torch.no_grad():
logits = model(**tokens).logits
# Pooling : ReLU + log + max sur tokens
sparse = torch.max(torch.log(1 + torch.relu(logits)) * tokens["attention_mask"].unsqueeze(-1), dim=1).values
indices = torch.nonzero(sparse.squeeze()).flatten().tolist()
values = sparse.squeeze()[indices].tolist()
return dict(zip(indices, values))Coût SPLADE vs BM25 : inférence GPU obligatoire (~10ms/query sur L4), index plus gros (~2× BM25). Gain : +5-10% nDCG vs BM25 sur la plupart des benchmarks 2025.
Tuning α (Weighted Linear Sum)
# evals/tune_alpha.py
import numpy as np
def hybrid_score(dense_score, bm25_score, alpha):
d = (dense_score - dense_score.min()) / (dense_score.max() - dense_score.min() + 1e-9)
b = (bm25_score - bm25_score.min()) / (bm25_score.max() - bm25_score.min() + 1e-9)
return alpha * d + (1 - alpha) * b
best_alpha, best_ndcg = 0.5, 0
for alpha in np.linspace(0, 1, 21):
ndcgs = []
for q in EVAL:
scores = hybrid_score(q["dense"], q["bm25"], alpha)
ndcgs.append(ndcg_at_k(np.argsort(-scores)[:10], q["relevant"]))
avg = np.mean(ndcgs)
if avg > best_ndcg:
best_ndcg = avg
best_alpha = alpha
print(f"α* = {best_alpha:.2f}, nDCG@10 = {best_ndcg:.4f}")α typiques selon domaine :
- Juridique / technique (codes, refs) : α ≈ 0.4-0.5 (BM25 bien représenté)
- E-commerce mode : α ≈ 0.6-0.7
- Helpdesk : α ≈ 0.55-0.65
- Recherche conversationnelle pure : α ≈ 0.75-0.85
Fusion alternatives (au-delà de RRF)
| Méthode | Tuning ? | Pros | Cons |
|---|---|---|---|
| RRF | 1 param (k=60) | Robuste, zero eval set requis | Plafond performance |
| Weighted Linear Sum (norm min-max) | 1 param (α) | Tunable, gains 2-5% vs RRF | Sensible aux outliers |
| CombSUM, CombMNZ | 0 | Simple | Suppose scores comparables |
| Borda Count | 0 | Robuste comme RRF | Léger plafond perf |
| Learning to rank (XGBoost) | beaucoup | Top performer | Demande dataset, lourd |
Recommandation 2026 : RRF par défaut → Weighted LS si eval set fiable → SPLADE+LTR si tu vises le top.
Eval set : comment le construire vite
- Extraire 200-500 vraies requêtes des logs de production (ou interviewer 5 utilisateurs).
- Pour chaque requête, lancer la recherche actuelle et présenter top-20 à un expert métier.
- L'expert grade 0-3 : 0=irrelevant, 1=marginal, 2=relevant, 3=perfect.
- Stocker en JSON :
{q, filters, relevant: {sku: grade}}.
Budget : 1-2 jours d'expert métier. Sans ça, tu ne peux rien prouver, et la mission devient "j'ai cliqué et ça avait l'air bien" — pas pro.
Quand l'eval set humain coûte trop cher : l'eval LLM-as-judge
Sur un corpus à 800K docs avec 500+ requêtes, faire grader chaque (query, doc) top-20 à la main coûte des jours d'expert. Le pattern 2026 : pré-grader avec un LLM-judge, puis faire valider/corriger 15-20% par l'humain (échantillon stratifié sur les désaccords et les cas limites). Tu obtiens un eval set 5-10× plus gros pour le même budget humain.
Le judge note la pertinence (query, doc) sur l'échelle 0-3. Trois règles de senior pour que ce ne soit pas du bruit :
- Rubrique explicite dans le prompt, pas "note la pertinence" → "0 = hors-sujet, 1 = mentionne le thème sans répondre, 2 = répond partiellement, 3 = répond exactement à l'intention". Sinon le judge dérive.
- Schéma structuré (Pydantic via
messages.parse()) — pas de parsing XML/JSON à la main qui casse 1 fois sur 50. - Mesure l'accord judge↔humain (Cohen's κ) sur l'échantillon validé. κ < 0.6 → la rubrique est ambiguë, pas le modèle. Un judge non audité est un générateur de chiffres faux qui ont l'air vrais.
# evals/llm_judge.py — Anthropic SDK, modèle 2026
from anthropic import AsyncAnthropic
from pydantic import BaseModel, Field
import asyncio
client = AsyncAnthropic(max_retries=4) # backoff SDK sur 429/529/timeout
class Grade(BaseModel):
relevance: int = Field(ge=0, le=3, description="0 hors-sujet … 3 répond exactement")
reason: str = Field(description="1 phrase justifiant la note")
JUDGE_SYSTEM = """Tu notes la pertinence d'un document pour une requête de recherche.
Barème STRICT :
- 0 : hors-sujet, le document ne traite pas du thème de la requête
- 1 : mentionne le thème mais ne répond pas à l'intention
- 2 : répond partiellement (bon thème, mais incomplet ou tangent)
- 3 : répond exactement à l'intention de la requête
Sois sévère : dans le doute entre deux notes, prends la plus basse."""
async def grade(query: str, doc: str) -> Grade:
msg = await client.messages.parse(
model="claude-haiku-4-5", # cheap & suffisant pour un juge à barème net
max_tokens=256,
system=[{"type": "text", "text": JUDGE_SYSTEM,
"cache_control": {"type": "ephemeral"}}], # prefix stable → cache le system
messages=[{"role": "user",
"content": f"<requete>{query}</requete>\n<document>{doc[:2000]}</document>"}],
output_config={"format": Grade},
)
return msg.parsed
async def grade_pool(pairs: list[tuple[str, str]], concurrency: int = 16) -> list[Grade]:
sem = asyncio.Semaphore(concurrency) # borne la pression sur le rate limit
async def one(q, d):
async with sem:
return await grade(q, d)
return await asyncio.gather(*(one(q, d) for q, d in pairs))Choix de modèle de senior : Haiku 4.5 (1 USD / 5 USD par M tokens) pour un juge à barème net et court — pas besoin d'un flagship. Tu réserves Opus 4.8 (5 USD / 25 USD par M tokens, 1M contexte) pour les désaccords ambigus que tu veux ré-arbitrer avant l'humain. cache_control sur le system (barème identique à chaque appel) → ~90% de réduction sur le prefix. Loggue msg.usage pour chiffrer le coût exact de l'eval set ; 500 queries × 20 docs × ~600 tokens = ~6M tokens input ≈ 6 USD en Haiku. L'eval LLM n'est pas l'eval finale : c'est un amplificateur de l'eval humaine, pas un remplacement. La κ humain↔judge est ce qui te permet de la défendre en réunion.
🏗️ Production : observabilité, latence, sécurité
Un staff engineer ne livre pas "ça marche en démo". Il livre un système qu'on peut mesurer, débugger et défendre à 3h du matin.
Le budget de latence, décomposé
Hybrid + rerank, c'est un pipeline série-parallèle. Le piège : raisonner sur la moyenne. Raisonne sur le p95, parce que c'est ce que ton utilisateur le plus impatient ressent, et parce que les queues (file d'attente Elasticsearch, cold HNSW, GPU rerank) explosent en queue de distribution.
Étape | p50 | p95 | Parallélisable ?
─────────────────────────┼────────┼────────┼──────────────────
embed query (dense) | 8 ms | 25 ms | ─ (préalable)
BM25 retrieve (top-100) | 12 ms | 40 ms | ✓ avec le kNN
kNN HNSW (top-100) | 15 ms | 60 ms | ✓ avec le BM25
fusion RRF (server-side) | 1 ms | 3 ms | ─
rerank Cohere top-50 | 80 ms | 220 ms | ─ (réseau + modèle)
─────────────────────────┼────────┼────────┼──────────────────
TOTAL série naïf |116 ms |348 ms |
TOTAL bien parallélisé | ~95 ms |~290 ms | (BM25 ∥ kNN)Trois leviers de senior, dans l'ordre où on les tire :
- Paralléliser BM25 et kNN (ils sont indépendants) — gratuit côté Elasticsearch
rrfretriever, à coder explicitement avecasyncio.gathersi tu orchestres toi-même. - Le reranker domine le p95. C'est 60-70% du budget. Ne le mets que sur le top-50, jamais sur le top-200. Si le p95 doit passer sous 150 ms, self-host le reranker (BGE-reranker-v2-m3 sur GPU) pour tuer le RTT réseau, ou drop le rerank sur les requêtes où le gap de score RRF entre rang 1 et rang 5 est déjà énorme (la fusion est déjà confiante).
- Cache la query embedding sur les requêtes répétées (helpdesk : 30% des requêtes sont dans le top-100). LRU sur le hash de la query normalisée.
Observabilité : ce qu'on loggue vraiment
Le minimum pour pouvoir débugger une régression sans rejouer la prod :
- Par requête :
query,n_bm25_hits,n_dense_hits,n_after_fusion,reranked(bool), latence par branche,top_doc_id+ score. - Distribution de provenance : pour chaque résultat servi, est-ce que BM25 ou dense l'a fait remonter ? Un drift soudain "tout vient du dense" = ton BM25 analyzer a cassé (ré-indexation foireuse, stop words).
- Zero-results rate segmenté par type de requête (code/ref vs naturelle). Sain ≤ 2%. Un spike sur les requêtes à code = ton index keyword/GIN a un problème, pas ton embedding.
- nDCG@10 hebdo en CI sur l'eval set versionné, avec alerte si régression > 2 points. C'est le canari des régressions silencieuses (nouveau modèle d'embedding, changement d'analyzer, upgrade ES).
- Coût : si tu utilises un LLM-judge ou un reranker payant, loggue tokens/appels et euros. Le
usagede l'API Anthropic, l'x-ratelimit-*de Cohere.
Sécurité & multi-tenant : le filtre est un problème de sécurité, pas de pertinence
Le pitfall #2 (filtre appliqué d'un seul côté de la fusion) n'est pas qu'une histoire de qualité — en multi-tenant, c'est une fuite de données. Si tu filtres tenant_id=A sur la branche BM25 mais oublies la branche kNN, le client A voit des documents du client B dans ses résultats. Règles :
- Le filtre tenant est appliqué dans les DEUX branches, et c'est testé (test d'isolation dans la CI, pas juste un commentaire).
- Filtre
presur le kNN, paspost. Unpost_filtersur HNSW peut retourner 3 résultats là où tu en demandais 100 (il filtre après le top-k du graphe). En multi-tenant, préfère un index partitionné ou unpre-filter natif (Qdrant payload index, ESknn.filter). - Injection via la requête :
plainto_tsqueryet les analyzers ES sanitisent, mais si tu construis du DSL ES à la main avec des champs venant de l'utilisateur, tu as une injection. Paramètre, ne concatène pas.
Mental model : pourquoi RRF est si dur à battre
RRF jette de l'information (les scores) et garde seulement les rangs. Intuitivement, ça devrait perdre. En pratique, ça gagne presque toujours sans tuning, et voici le raisonnement de staff :
Les scores de deux retrievers vivent dans des espaces non comparables et non calibrés : un cosinus ∈ [0,1] quasi-uniforme vs un BM25 ∈ [0, 20+] à longue traîne. Toute fusion par score suppose une calibration que tu n'as pas. RRF transforme chaque score en un rang — une statistique invariante par transformation monotone. Du coup la fusion est robuste au fait qu'un retriever soit "trop confiant" ou mal échelonné. Le prix : un plafond de performance (tu ne peux pas exprimer "ce doc est rang 1 ET écrase tout le monde"). C'est exactement pourquoi Weighted Linear Sum + min-max scaling peut gratter 2-5% une fois que tu as un eval set pour calibrer, et pourquoi LTR/SPLADE+rerank te font passer au-dessus quand tu as les données et le GPU. RRF n'est pas le sommet ; c'est le meilleur point de départ sans données.
🏋️ Exercices
Progression du "implémente" au "défends le chiffre / casse-le puis répare". Demandes exigeantes — pas de "change cette constante".
Exercice 1 — Le pipeline qui mesure (fondation)
Objectif : monter un hybrid pgvector + tsvector avec RRF en SQL et prouver nDCG@10(hybrid) > nDCG@10(dense) sur un eval set de 50 requêtes que tu construis.
Indice/Solution : reprends le SQL RRF du doc. Construis l'eval set en extrayant 50 requêtes (mix code/ref + naturelles), grade 0-3 à la main (ou via le LLM-judge ci-dessus). Implémente ndcg_at_k. Le piège : ton dense_only et ton hybrid doivent utiliser exactement les mêmes embeddings et le même top-100 — sinon tu compares deux choses différentes. Attendu : +15-25 points sur les requêtes à code, ~neutre sur les naturelles.
Exercice 2 — RRF vs Weighted Linear Sum, le duel chiffré
Objectif : implémenter les deux fusions, tuner α par grid-search sur l'eval set, et décider laquelle livrer avec un argument défendable.
Indice/Solution : reprends tune_alpha.py. Calcule nDCG@10 pour RRF et pour WLS à α* optimal. Si WLS gagne de < 1 point → livre RRF (zero-tuning, robuste au drift). Si WLS gagne de > 3 points → vérifie que ce n'est pas de l'overfit sur 50 queries (split train/test de l'eval set, mesure α* sur train, évalue sur test). La vraie réponse de senior : "WLS gagne 2.3 pts sur test, mais α dérive quand le mix de requêtes change ; je livre RRF et je garde WLS comme option si on a un eval set continu."
Exercice 3 — Casse le filtre multi-tenant, puis répare-le
Objectif : reproduire une fuite de données cross-tenant en filtrant une seule branche de la fusion, l'observer, puis écrire le test d'isolation qui l'empêche.
Indice/Solution : indexe 2 tenants. Filtre tenant_id uniquement sur le BM25 (pas le kNN). Lance une requête tenant A → observe des docs tenant B remontés par la branche dense. Écris un test : for tenant in [A, B]: assert all(hit.tenant == tenant for hit in search(q, tenant=tenant)). Répare en filtrant les deux branches. Bonus : montre que le post_filter HNSW retourne moins de résultats que demandé et bascule sur un pre-filter.
Exercice 4 — Le reranker qui ne tient pas le p95
Objectif : mesurer le p95 du pipeline avec rerank Cohere sur top-200, montrer qu'il explose le SLA, et le ramener sous 150 ms sans perdre > 1 point de nDCG.
Indice/Solution : instrumente chaque étape (les decorators de timing). Tu verras le rerank dominer. Trois fixes à comparer : (a) rerank top-50 au lieu de top-200, (b) self-host BGE-reranker sur GPU pour tuer le RTT, (c) skip le rerank quand le gap RRF rang1−rang5 dépasse un seuil (fusion déjà confiante). Mesure nDCG ET p95 pour chaque. La bonne réponse dépend du trafic : montre le tradeoff, ne donne pas un dogme.
Exercice 5 — Construis et défends un eval set LLM-judge
Objectif : grader 500 requêtes × top-20 avec un LLM-judge, valider 15% à la main, mesurer le κ humain↔judge, et défendre le nDCG produit devant un sceptique.
Indice/Solution : utilise grade_pool ci-dessus (Haiku 4.5, schéma Pydantic, asyncio.gather borné par sémaphore). Échantillonne 15% stratifié sur les désaccords judge↔eval-actuel. Calcule Cohen's κ. Si κ < 0.6, durcis la rubrique et recommence (c'est la rubrique, pas le modèle). Loggue le usage pour chiffrer le coût. La défense : "nDCG@10 = 0.74 ± marge, judge κ=0.71 avec l'humain sur 75 paires validées, coût 6 USD — je peux ré-arbitrer n'importe quel désaccord en Opus 4.8." Sans le κ, ton chiffre est indéfendable.
Exercice 6 — Le drift silencieux (boss final)
Objectif : simuler un changement de modèle d'embedding sans re-générer l'index existant, détecter la régression uniquement via l'observabilité, puis écrire l'alerte qui l'aurait attrapée en prod.
Indice/Solution : ré-encode les requêtes avec un nouveau modèle d'embedding mais garde l'ancien index doc (incompatibilité d'espace vectoriel). Le recall dense s'effondre, mais pas le BM25 — donc le pipeline "marche encore" en surface (zero-results reste bas). C'est ça le piège : hybrid masque la panne d'une branche. Détecte-le via la distribution de provenance (soudain 0% des top résultats viennent du dense) et via le nDCG@10 hebdo en CI. Écris l'alerte : if dense_contribution_rate < 0.2 * baseline: page. Versionne les embeddings (modèle + dim + date) pour rendre l'incompatibilité impossible.
🎤 En entretien
Q : Pourquoi le hybrid bat le dense-only, et dans quels cas le dense seul suffit ? R : Le dense rate les tokens rares à IDF élevée (codes L121-1, SKU, versions, noms propres) car ils n'ont pas de voisinage sémantique appris ; le BM25 les matche exactement. Hybrid prend les deux. Dense seul suffit sur du grand public à requêtes naturelles courtes, faible volume, ou embedding fine-tuné domaine — sinon hybrid gagne +15-30% nDCG.
Q : RRF jette les scores et garde les rangs. Pourquoi ça marche mieux qu'une moyenne pondérée des scores ? R : Parce que les scores des deux retrievers vivent dans des espaces non comparables et non calibrés (cosinus [0,1] vs BM25 [0,20+]) ; le rang est invariant par transformation monotone, donc robuste à un retriever surconfiant. Prix payé : un plafond de perf que Weighted Linear Sum + calibration peut dépasser de 2-5% si tu as un eval set.
Q : Comment tu prouves qu'une migration vector-only → hybrid a marché, sans te mentir ? R : nDCG@10 (ou MRR/Recall@k) sur un eval set curaté gradé 0-3, avec critère de succès chiffré dans le devis (hybrid ≥ dense × 1.20), split train/test pour ne pas overfit le tuning d'α, et — si l'eval est LLM-judge — un κ humain↔judge ≥ 0.6 pour défendre le chiffre. "J'ai cliqué et ça avait l'air bien" n'est pas une preuve.
Q : Un client multi-tenant te dit "des résultats d'autres comptes apparaissent". Quel est ton premier réflexe ? R : Le filtre tenant n'est pas appliqué dans les deux branches de la fusion (classiquement présent sur BM25, oublié sur le kNN), ou c'est un post_filter HNSW. C'est une fuite de données, pas un bug de pertinence — je filtre pre dans les deux branches et j'ajoute un test d'isolation cross-tenant en CI.
🔗 Liens
- RRF paper : Cormack, Clarke, Buettcher, 2009, "Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods"
- Elasticsearch RRF retriever : https://www.elastic.co/guide/en/elasticsearch/reference/current/rrf.html
- OpenSearch hybrid search : https://opensearch.org/docs/latest/search-plugins/hybrid-search/
- SPLADE paper : Formal et al., "SPLADE v3"
- BGE-M3 : https://huggingface.co/BAAI/bge-m3
- Cohere Rerank 3 : https://docs.cohere.com/docs/rerank-overview
- BEIR benchmark : https://github.com/beir-cellar/beir
- MTEB leaderboard : https://huggingface.co/spaces/mteb/leaderboard
→ Voir aussi : 01-pgvector.md (hybrid en SQL), 02-qdrant.md (hybrid + multivector), 03-weaviate.md (hybrid + reranker modules), 06-realtime-vectors.md (pipelines de mise à jour).