Skip to content

Query rewriting, HyDE, RAG-Fusion — réécrire avant de chercher

TL;DR Les utilisateurs ne posent jamais la "bonne" question pour ton embedder. "art. L121-1 CCH" pour un avocat, "cadeau papa 50€" pour une cliente e-commerce, "le truc qui plante quand je clique" pour un user du helpdesk : tes embeddings cosine sim sont nazes sur ces inputs. Solution : réécrire la query avant le retrieval. Multi-query, step-back, HyDE, RAG-Fusion, décomposition, expansion métier — c'est la deuxième vague de gains après le chunking. Sur un catalogue e-commerce 100K SKU, RAG-Fusion + multi-query = +28 pts de recall@10 et +9 % de conversion. Pour le freelance, c'est 3-5 jours de mission packageable à 4-6 k€ HT au-dessus d'un RAG existant.

🧠 Mental model

La query utilisateur est rarement dans la même distribution sémantique que tes documents indexés. Ton job : la transformer pour qu'elle ressemble à ce que tu as en base.

   User query "vague"                       Documents indexés "denses"
       │                                            ▲
       │      ┌──────────────────────────┐          │
       └─────▶│   QUERY TRANSFORMATION   │──embed──▶│
              │                          │          │
              │ rewrite / expand / HyDE  │          │
              │ multi / decompose        │          │
              └──────────────────────────┘          │
                           │                        │
                           └────retrieve────────────┘

Analogie : tu vas chez un libraire et tu demandes "tu sais le livre avec le mec qui court". Le libraire (= embedder) ne sait pas. Mais si tu lui dis "un roman d'aventure des années 90, environ 400 pages, sur la fuite d'un prisonnier en Sibérie", il te tend le bon livre. Query rewriting = transformer ta demande en une demande que le libraire comprend.

Autre image pour ton réflexe SQL : la query utilisateur c'est du WHERE name LIKE '%truc%'. Multi-query c'est WHERE name LIKE ANY(ARRAY['truc', 'machin', 'bidule']). HyDE c'est WHERE embedding <=> (SELECT embedding FROM hypothetical_answer). RAG-Fusion c'est UNION + ORDER BY rrf_score.

🛠️ Code minimal

Multi-query rewriting basique en 15 lignes :

python
import anthropic

client = anthropic.AsyncAnthropic()

REWRITE_PROMPT = """Tu reformules une question pour améliorer un retrieval.
Génère {n} variantes diverses (synonymes, reformulations, sous-questions).
Une variante par ligne, sans numérotation.

Question : {query}"""

async def multi_query(query: str, n: int = 4) -> list[str]:
    msg = await client.messages.create(
        model="claude-haiku-4-5",  # cheap — la réécriture n'a pas besoin d'Opus
        max_tokens=400,
        messages=[{"role": "user", "content": REWRITE_PROMPT.format(n=n, query=query)}],
    )
    variants = [v.strip() for v in msg.content[0].text.split("\n") if v.strip()]
    return [query] + variants[:n]

Tu retrouves tes pieds : la query devient une liste de queries, chaque variante embed séparément, et on fusionne via RRF.

🎬 Cas d'usage concrets

Scénario 1 — Plateforme jurisprudence pour avocats lyonnais

Qui : startup LegalTech (12 personnes), SaaS de recherche jurisprudence pour cabinets d'avocats, 1,8 M décisions indexées (Légifrance, Doctrine.fr).

Problème : les avocats tapent en argot métier : "art. L121-1 CCH", "JP CDC 2023", "soulte succ.". L'embedder voit "JP CDC" comme du bruit. Résultat : recall@10 catastrophique à 0.34. Le user revient au moteur Google.

Solution : query expansion métier — un mini-dictionnaire d'abréviations juridiques que tu expand AVANT de réécrire avec un LLM.

python
LEGAL_ABBREV = {
    "CCH": "Code de la Construction et de l'Habitation",
    "CGI": "Code Général des Impôts",
    "CPC": "Code de Procédure Civile",
    "JP": "jurisprudence",
    "CDC": "Cour de cassation",
    "CE": "Conseil d'État",
    "TJ": "Tribunal Judiciaire",
    "art.": "article",
    "soulte succ.": "soulte successorale",
    "donat.": "donation",
}

def expand_abbrev(query: str) -> str:
    out = query
    for short, long in LEGAL_ABBREV.items():
        out = out.replace(short, f"{short} ({long})")
    return out

# Puis multi-query LLM sur la version expandée
async def legal_retrieve(query: str):
    expanded = expand_abbrev(query)
    variants = await multi_query(expanded, n=3)
    return await rag_fusion(variants)

Gains : recall@10 passe de 0.34 → 0.72. Time-to-first-relevant-result divisé par 4. Taux de rétention semaine 2 +18 %. Mission : 8 jours × 1 350 €/j = 10,8 k€ HT pour le freelance.

Scénario 2 — Helpdesk IT d'une banque mutualiste régionale

Qui : DSI d'une banque mutualiste (~3 000 collaborateurs), 24 K articles KB internes, support level 1 saturé.

Problème : un agent commercial tape "outlook bug après maj" → la KB contient l'article "Procédure de réinitialisation du cache MAPI Outlook 365 suite à mise à jour KB502X". Les deux ne se rencontrent jamais en cosine sim. Les agents level 1 cliquent sur 12 articles avant de trouver, perte de 4 minutes par ticket.

Solution : step-back prompting — on génère d'abord une question plus générale ("Quels sont les bugs courants Outlook après mise à jour Windows ?"), on retrieve les deux niveaux (général + spécifique), on fusionne.

python
STEP_BACK_PROMPT = """Reformule cette question support en une question plus générale
qui couvre la catégorie du problème. Ne réponds qu'avec la question reformulée.

Question : {query}
Question générale :"""

async def step_back(query: str) -> str:
    msg = await client.messages.create(
        model="claude-haiku-4-5",
        max_tokens=120,
        messages=[{"role": "user", "content": STEP_BACK_PROMPT.format(query=query)}],
    )
    return msg.content[0].text.strip()

async def support_retrieve(query: str):
    general = await step_back(query)
    docs_specific = await vector_search(query, k=8)
    docs_general = await vector_search(general, k=4)
    return rrf_merge([docs_specific, docs_general])

Gains : temps moyen de résolution L1 : 12 min → 7 min. 1 200 tickets/jour × 5 min économisées × 0,42 €/min (TJM L1) = 2 500 €/jour économisés. Mission packagée : 14 k€ HT.

Scénario 3 — E-commerce généraliste, recherche conversationnelle

Qui : pure-player généraliste (jouets, déco, papeterie), 95 K SKU, search bar conversationnelle ("cadeau papa 50€ pour Noël").

Problème : la query n'est ni un mot-clé ni une description produit. C'est une intention floue. BM25 trouve "papa" dans 800 fiches. Cosine sim avec query courte → bruit. Conversion sur ces queries : 1,2 %.

Solution : HyDE + multi-query + RRF. On génère 3 réponses hypothétiques ("un coffret cuisine bois 45 €", "une montre design 49 €", "un livre photo personnalisé 50 €"), on embed les 3, on retrieve, on fusionne. Le LLM "imagine" des produits ; les embeddings sont alors dans la bonne distribution.

python
HYDE_PROMPT = """Tu es un assistant cadeau. Pour cette demande utilisateur, propose
{n} idées de produits courts et concrets (1 phrase chacune, prix indicatif).

Demande : {query}
Idées (une par ligne) :"""

async def hyde_ecom(query: str, n: int = 4) -> list[str]:
    msg = await client.messages.create(
        model="claude-haiku-4-5",
        max_tokens=300,
        messages=[{"role": "user", "content": HYDE_PROMPT.format(n=n, query=query)}],
    )
    return [l.strip("- ").strip() for l in msg.content[0].text.split("\n") if l.strip()]

Gains : recall@10 sur queries conversationnelles : 0.39 → 0.71. Conversion : 1,2 % → 2,1 %. Sur CA mensuel de 4,8 M€ et 18 % de queries conversationnelles : +78 k€/mois de marge brute. Mission : 12 jours × 1 400 €/j = 16,8 k€.

Scénario 4 — Plateforme immobilière, recherche annonces

Qui : pure-player immobilier (28 personnes), 320 K annonces, recherche conversationnelle ("3 pièces lumineux Croix-Rousse balcon Sud moins de 350k").

Problème : la query mélange géo, surface, budget, exposition, agrément. Le moteur de recherche structuré demande à l'utilisateur de cliquer 8 fois. Vector search direct sur la description perd la géo et le budget.

Solution : query decomposition en sous-questions structurées + vector pour le reste. Un LLM extrait {location, type, budget_max, balcon, exposition, surface_min} ; on fait du SQL filter sur ces champs puis vector sur "lumineux, vue dégagée, refait à neuf".

Le réflexe senior ici : ne pas hand-roll du JSON parsing. Tu utilises les structured outputs natifs (client.messages.parse() avec un schéma Pydantic). Tu gagnes la validation, le typage, et tu élimines la classe de bug "le LLM a renvoyé du JSON cassé" — qui en prod arrive sur 0,5 à 2 % des requêtes et fait planter ton retrieval si tu ne l'as pas blindée. Au passage : la décomposition est une tâche un cran plus dure que la réécriture (le modèle doit raisonner sur ce qui est structurable vs free-text), donc on monte d'un palier de modèle — claude-sonnet-4-6 plutôt que Haiku. C'est une décision senior typique : on choisit le modèle par difficulté de la sous-tâche, pas un modèle unique pour tout le pipeline.

python
import anthropic
from pydantic import BaseModel

CLAUDE = anthropic.AsyncAnthropic(max_retries=3, timeout=8.0)
DECOMPOSE_MODEL = "claude-sonnet-4-6"  # extraction structurée = un cran au-dessus de Haiku

class ImmoCriteria(BaseModel):
    location: str | None
    type: str | None
    budget_max: int | None
    rooms: int | None
    filters: list[str]
    free_text_search: str

DECOMPOSE_IMMO = """Extrais les critères de cette recherche immobilière.
free_text_search ne doit contenir QUE les qualificatifs non structurables
(luminosité, état, vue) — pas la géo, le budget ni le nombre de pièces.

Recherche : {query}"""

async def parse_immo_query(query: str) -> ImmoCriteria:
    # messages.parse() valide la réponse contre le schéma et renvoie un objet typé,
    # ou lève une erreur typée — fini le json.loads() qui pète sur un trailing comma.
    msg = await CLAUDE.messages.parse(
        model=DECOMPOSE_MODEL,
        max_tokens=400,
        messages=[{"role": "user", "content": DECOMPOSE_IMMO.format(query=query)}],
        output_config={"format": ImmoCriteria},
    )
    return msg.parsed_output  # déjà validé contre le schéma Pydantic

async def hybrid_immo_search(query: str):
    crit = await parse_immo_query(query)
    sql_filter = build_sql_from_criteria(crit)
    return await pgvector_with_filter(crit.free_text_search, sql_filter)

Gains : 4× plus de leads qualifiés depuis la search bar (sessions avec recherche → contact agence : 2 % → 8 %). Le commercial du client estime +180 k€/an de mandats signés. Mission : 11 j × 1 350 €/j = 14,8 k€.

🛠️ Exemple end-to-end

Use case : RAG-Fusion sur un catalogue e-commerce 100K SKU avec multi-query + RRF + rerank Cohere. Tu es freelance chez une marketplace française (textile bébé), tu factures 19 k€ pour ce livrable (14 jours × 1 350 €/j).

python
# rag_fusion_ecom.py
import asyncio
import logging
import os
from dataclasses import dataclass
from collections import defaultdict
from typing import Any

import anthropic
import asyncpg
import cohere
from openai import AsyncOpenAI

log = logging.getLogger(__name__)

PG_DSN = os.environ["PG_DSN"]
COHERE = cohere.AsyncClient(os.environ["COHERE_API_KEY"])
OAI = AsyncOpenAI()
# AsyncAnthropic pour un serveur ; max_retries gère 429/529/5xx avec backoff ;
# timeout borne le P99 (un rewrite ne doit jamais bloquer le pipeline 30 s).
CLAUDE = anthropic.AsyncAnthropic(max_retries=3, timeout=8.0)

EMB_MODEL = "text-embedding-3-large"
CHAT_MODEL = "claude-haiku-4-5"
RERANK_MODEL = "rerank-v3.5"

MULTI_PROMPT = """Tu reformules une requête de recherche produit e-commerce
en {n} variantes diverses qui exploreront différents angles (couleur, occasion,
matière, public, gamme de prix). Une variante par ligne, sans numérotation,
pas plus de 15 mots chacune.

Requête : {query}"""

HYDE_PROMPT = """Pour cette requête de recherche cadeau, propose {n} descriptions
de produits hypothétiques qui pourraient matcher. Une description par ligne,
2-3 phrases chacune, incluant matière/style/prix.

Requête : {query}"""

@dataclass
class Hit:
    sku: str
    title: str
    score: float
    source: str  # which query variant produced it

async def llm_variants(query: str, prompt_tpl: str, n: int = 4) -> list[str]:
    """Réécrit la query. NE DOIT JAMAIS faire planter le retrieval :
    si le LLM est down/rate-limité, on retombe sur la query brute."""
    # Le préfixe d'instructions est stable (ne dépend que de n) → cacheable.
    # La query volatile va en message user, après le breakpoint.
    instructions = prompt_tpl.split("Requête :")[0].format(n=n)
    try:
        msg = await CLAUDE.messages.create(
            model=CHAT_MODEL,
            max_tokens=500,
            # cache_control sur le préfixe stable → -90 % sur la partie cacheable
            # des rewrites répétés (FAQ, queries populaires).
            system=[{
                "type": "text",
                "text": instructions,
                "cache_control": {"type": "ephemeral"},
            }],
            messages=[{"role": "user", "content": f"Requête : {query}"}],
        )
        # Logge la conso pour le coût : input/output tokens × prix/M.
        log.info("rewrite usage in=%d out=%d cache_read=%d",
                 msg.usage.input_tokens, msg.usage.output_tokens,
                 msg.usage.cache_read_input_tokens)
        return [l.strip() for l in msg.content[0].text.split("\n") if l.strip()][:n]
    except (anthropic.RateLimitError, anthropic.APITimeoutError,
            anthropic.OverloadedError, anthropic.APIStatusError) as e:
        log.warning("rewrite failed (%s), fallback to raw query", type(e).__name__)
        return []  # le pipeline garde `query` en variant par défaut

async def embed(texts: list[str]) -> list[list[float]]:
    resp = await OAI.embeddings.create(model=EMB_MODEL, input=texts)
    return [d.embedding for d in resp.data]

async def vector_search(pool: asyncpg.Pool, emb: list[float], k: int = 20) -> list[Hit]:
    rows = await pool.fetch(
        """SELECT sku, title, 1 - (embedding <=> $1::vector) AS score
           FROM products
           ORDER BY embedding <=> $1::vector
           LIMIT $2""",
        emb, k,
    )
    return [Hit(sku=r["sku"], title=r["title"], score=float(r["score"]), source="") for r in rows]

def rrf(rankings: list[list[Hit]], k: int = 60) -> list[Hit]:
    """Reciprocal Rank Fusion across multiple query results."""
    scores: dict[str, float] = defaultdict(float)
    info: dict[str, Hit] = {}
    for ranking in rankings:
        for rank, hit in enumerate(ranking):
            scores[hit.sku] += 1.0 / (k + rank)
            info.setdefault(hit.sku, hit)
    ordered = sorted(scores.items(), key=lambda kv: kv[1], reverse=True)
    return [Hit(sku=sku, title=info[sku].title, score=s, source="rrf")
            for sku, s in ordered]

async def cohere_rerank(query: str, candidates: list[Hit], top_n: int = 10) -> list[Hit]:
    docs = [c.title for c in candidates]
    resp = await COHERE.rerank(
        model=RERANK_MODEL, query=query, documents=docs, top_n=top_n,
    )
    out = []
    for r in resp.results:
        h = candidates[r.index]
        h.score = float(r.relevance_score)
        out.append(h)
    return out

async def rag_fusion_search(pool: asyncpg.Pool, query: str) -> list[Hit]:
    # Step 1 : generate variants (multi-query + HyDE in parallel)
    variants, hyde_docs = await asyncio.gather(
        llm_variants(query, MULTI_PROMPT, n=4),
        llm_variants(query, HYDE_PROMPT, n=3),
    )
    all_queries = [query] + variants + hyde_docs
    # Step 2 : embed all in one batch (cheap)
    embeddings = await embed(all_queries)
    # Step 3 : parallel vector searches
    rankings = await asyncio.gather(*[
        vector_search(pool, e, k=20) for e in embeddings
    ])
    # Step 4 : RRF fusion
    fused = rrf(rankings)[:50]  # top 50 candidates
    # Step 5 : Cohere rerank on top 10
    final = await cohere_rerank(query, fused, top_n=10)
    return final

async def main():
    pool = await asyncpg.create_pool(PG_DSN, min_size=4, max_size=16)
    queries = [
        "cadeau papa 50 euros noel",
        "robe rouge mariage été lin",
        "vêtement bébé 6 mois bio garçon",
    ]
    for q in queries:
        hits = await rag_fusion_search(pool, q)
        print(f"\n== {q}")
        for h in hits[:5]:
            print(f"  {h.score:.3f}  {h.sku}  {h.title[:70]}")

if __name__ == "__main__":
    asyncio.run(main())

Schéma SQL :

sql
CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE products (
    sku        TEXT PRIMARY KEY,
    title      TEXT NOT NULL,
    description TEXT,
    price      NUMERIC(10,2),
    brand      TEXT,
    color      TEXT,
    fabric     TEXT,
    occasions  TEXT[],
    embedding  VECTOR(3072) NOT NULL
);
CREATE INDEX ON products USING hnsw (embedding vector_cosine_ops);

Eval comparatif (chiffres typiques sur ce volume) :

Pipelinerecall@10NDCG@10Latence p95Coût/query
Vector simple0.410.3890 ms0,0002 €
+ BM25 + RRF0.550.49110 ms0,0002 €
+ Multi-query0.660.58380 ms0,0012 €
+ HyDE0.720.63520 ms0,0020 €
Multi + HyDE + RRF + Cohere rerank0.790.71640 ms0,0028 €

Tu mets ce tableau sur ton README. Le client signe le retainer mensuel.

🎯 Patterns courants

Pattern 1 — Multi-query rewriting

Génère N variantes diverses (synonymes, reformulations). Bon défaut : n=3-5. Au-delà, diminishing returns.

python
variants = await multi_query(query, n=4)
rankings = await asyncio.gather(*[search(v) for v in variants])
return rrf(rankings)

Quand : queries courtes/ambiguës, catalogues hétérogènes. Évite : domaine où la précision lexicale prime (legal, code-search).

Pattern 2 — Step-back prompting

Reformule en une question plus générale pour récupérer le contexte global, en plus de la spécifique.

python
general = await step_back(query)
hits = rrf([await search(query), await search(general)])

Quand : helpdesk, FAQ, contextes où le user pose une question pointue dans un domaine large. Évite : retrieval purement factuel.

Pattern 3 — HyDE (Hypothetical Document Embeddings)

Génère une réponse hypothétique, embed la réponse (pas la question), search avec cet embedding.

python
fake_answer = await llm_answer(query, no_context=True)
emb = await embed([fake_answer])
return await vector_search(emb[0])

Quand : retrieval Q&A pur, queries courtes en domaine spécialisé. Évite : domaine où le LLM hallucine massivement sans contexte (le HyDE devient un anti-pattern). Test : si Claude sans contexte produit un FUD plausible, n'utilise pas HyDE.

Pattern 4 — RAG-Fusion (multi-query + RRF)

La combinaison qui marche le mieux en moyenne :

python
variants = await multi_query(query, n=4)
rankings = await asyncio.gather(*[search(v) for v in [query] + variants])
return rrf(rankings)

Le k=60 du RRF est l'optimum empirique (paper Cormack 2009).

Pattern 5 — Query decomposition

Pour les questions composées ("Compare le PER et l'assurance vie pour un revenu de 80 k€"), tu split en sous-questions.

python
DECOMPOSE_PROMPT = """Décompose cette question en 2-4 sous-questions factuelles
indépendantes nécessaires pour y répondre. Une par ligne."""

async def decompose(query: str) -> list[str]:
    msg = await CLAUDE.messages.create(
        model=CHAT_MODEL,
        max_tokens=300,
        messages=[{"role": "user", "content": f"{DECOMPOSE_PROMPT}\n\nQuestion : {query}"}],
    )
    return [l.strip("- ").strip() for l in msg.content[0].text.split("\n") if l.strip()]

Quand : questions analytiques, comparatives. Évite : questions simples factuelles (overkill).

Pattern 6 — Query expansion métier (dictionnaire)

Le pattern le plus sous-coté. Tu maintiens un dict {abréviation → version longue} métier. Tu l'expand avant le LLM. Marche tout seul, pas de coût LLM.

python
EXPANSIONS = {"PER": "Plan d'Épargne Retraite", "AV": "Assurance Vie", ...}
def expand(q: str) -> str:
    for k, v in EXPANSIONS.items():
        q = q.replace(k, f"{k} ({v})")
    return q

Quand : domaine jargonneux (legal, finance, médical, RH). Évite : domaine grand public.

Pattern 7 — Adaptive routing par classifier LLM

Tu classifies la query (factoid simple / multi-hop / comparative / out-of-scope) et tu choisis la stratégie. Sweet spot prod 2026 (voir fichier 08 self-corrective).

python
CLASSIFY = """Classifie cette query :
- simple : factoid direct
- multi_hop : nécessite croiser plusieurs sources
- compare : comparaison entre entités
- conversational : intention floue
Réponse : un seul mot."""

async def route(query: str) -> str:
    msg = await CLAUDE.messages.create(model="claude-haiku-4-5", max_tokens=20,
        messages=[{"role": "user", "content": f"{CLASSIFY}\nQuery: {query}"}])
    return msg.content[0].text.strip().lower()

ROUTES = {
    "simple": vector_only,
    "multi_hop": decompose_then_retrieve,
    "compare": multi_query_rrf,
    "conversational": hyde_then_vector,
}

async def adaptive_search(query: str):
    cat = await route(query)
    return await ROUTES.get(cat, vector_only)(query)

Gain : tu n'allumes pas Cohere rerank + HyDE pour "quelle heure est-il ?". Économies 30-50 %.

Pattern 8 — Streaming des variants au front

Pour ne pas faire attendre l'utilisateur pendant le rewriting, tu streames les variants au front (UI optimiste qui montre "je cherche aussi : X, Y, Z").

ts
// Next.js + Vercel AI SDK 5
const { object } = await streamObject({
  model: anthropic('claude-haiku-4-5'),
  schema: z.object({ variants: z.array(z.string()) }),
  prompt: `Reformule ${query} en 4 variantes`,
})
for await (const partial of object) {
  yield partial.variants  // streamed to UI
}

Quand : UX consumer. Évite : back-office, batch processing.

🔄 Versions & écosystème 2026

Outil / techniqueStatut 2026
LangChain MultiQueryRetrieverOK, mais code soi-même pour plus de contrôle
LlamaIndex HyDEAPI stable, intégré à QueryEngine
LangGraph query routingStandard pour pipelines adaptatifs
Cohere Rerank v3.5Multilingue FR très solide
Anthropic prompt cachingRéutilise le prompt système des variants → -90 %
RAG-FusionImplémenté dans LangChain et LlamaIndex
Vercel AI SDK 5.xStreamable UI + tool calling pour query rewrite

Tendance 2026 : les pipelines adaptatifs (router LLM qui choisit la technique selon la query) gagnent du terrain. CRAG/Self-RAG/Adaptive RAG via LangGraph sont les standards (voir fichier 08).

⚠️ Pitfalls

  1. HyDE quand le LLM hallucine → ta query "fausse réponse" éloigne le retrieval. Test obligatoire : Ragas avec/sans HyDE.
  2. Multi-query avec un modèle trop gros → tu fais 4 appels Opus pour une query Haiku-friendly. Toujours Haiku/Sonnet sur la réécriture.
  3. N variants > 6 → diminishing returns, latence qui explose. Sweet spot : 3-5.
  4. Pas de cache sur les rewrites → mêmes queries répétées (FAQ), tu paies à chaque fois. Redis cache key=hash(query).
  5. RRF avec k=10 → tu écrases trop les rangs élevés. Reste à k=60 sauf raison validée.
  6. Step-back qui devient trop générique → "comment ça marche Outlook" pour tout. Force le LLM à garder un mot-clé clé.
  7. Query decomposition sans agrégation → tu retrieve pour chaque sous-question, tu ne sais pas combiner. Toujours un step "synthèse" final.
  8. Expansion métier hardcodée sans monitoring → tu n'updates jamais le dict. Mets-le en config rechargeable + monitoring des termes inconnus.
  9. Rewrite synchrone bloquant → 400 ms en LLM avant retrieval. Streaming + UI optimiste (montre les variants au user).
  10. Pas de fallback si le rewrite échoue → LLM down ou off-topic, ton retrieval crashe. Toujours query en variant par défaut.
  11. Latence non instrumentée → un sénior ne livre pas un pipeline sans p50/p95/p99 par étape. Ajoute OpenTelemetry dès le jour 1.
  12. Mêmes prompts en code dur → tu ne peux pas A/B tester les rewrites. Mets-les en config rechargeable (LangFuse prompt management, ou DB).
  13. HyDE + rerank coûteux sur queries triviales → "ouvrir une session" génère 3 fake answers + 4 cross-encoders, latence 1,2 s pour une question 1 mot. Adaptive routing obligatoire.

💰 Pricing / ROI client

Coût marginal d'un pipeline RAG-Fusion vs vector simple, par 1000 queries. Hypothèse de prix (Anthropic 2026) : claude-haiku-4-5 à 1 USD / M input, 5 USD / M output. Un rewrite typique = ~250 tok in + ~150 tok out ≈ 0,001 USD ; avec prompt caching sur le préfixe d'instructions (-90 % sur la partie cachée), l'input tombe quasi à zéro et le coût réel par rewrite est dominé par l'output.

TechniqueLLM calls / queryCoût LLM (Haiku 4.5)Coût total / 1k queries
Vector simple00 €0,20 €
Multi-query (n=4)1 (rewrite)0,0008 €1,00 €
Step-back10,0006 €0,80 €
HyDE1 (gen)0,0012 €1,40 €
RAG-Fusion (multi + RRF)10,0008 €1,20 €
Full (multi + HyDE + rerank)20,0020 €2,80 €

Pour 1 M queries/mois (catalogue e-commerce gros) : 2 800 €/mois de surcoût pour +30 pts de conversion sur queries conversationnelles. ROI : un client retail à 5 M€ de CA mensuel récupère 50-150 k€/mois.

Le levier coût que 90 % des juniors oublient : si tu passais ces rewrites sur Opus 4.8 (claude-opus-4-8, 5 USD / 25 USD par M), tu multiplierais le coût LLM par ~5-7 pour zéro gain de recall — la réécriture est une tâche que Haiku fait aussi bien. Le bon modèle, le prompt caching, et l'adaptive routing (ne pas allumer le pipeline lourd sur les queries triviales) sont les trois leviers qui font la différence entre 2 800 €/mois et 15 000 €/mois.

Mission packageable :

  • POC + benchmark (3-5 j, 4-6 k€) : tu testes 3 techniques sur leur dataset, tu chiffres avec Ragas.
  • Implémentation prod (8-14 j, 11-19 k€) : pipeline + cache + monitoring.
  • A/B test + tuning (5-7 j, 7-10 k€) : tu tournes 2 semaines en prod, tu compares les KPI business.

Argument client : "le rewriting ajoute 0,3 ms par query mais récupère 30 % de conversion. C'est le levier le moins cher du RAG."

🧪 Testing / Eval

Set d'eval avec slices par type de query :

python
# tests/eval_query_rewrite.py
EVAL_SET = [
    # Lexical exact
    {"q": "art. L121-1 CCH", "expected": ["doc_42", "doc_91"], "slice": "lexical"},
    # Conversational
    {"q": "cadeau papa 50€ noel", "expected": ["sku_111", "sku_222"], "slice": "conversational"},
    # Compositional
    {"q": "Compare PER et AV pour 80k revenus", "expected": ["doc_per", "doc_av"], "slice": "compositional"},
    # Ambiguous
    {"q": "outlook bug", "expected": ["kb_504"], "slice": "ambiguous"},
]

async def recall_at_k(pipeline_fn, k: int = 10) -> dict[str, float]:
    by_slice = defaultdict(list)
    for ex in EVAL_SET:
        hits = await pipeline_fn(ex["q"])
        retrieved = {h.sku if hasattr(h, "sku") else h["id"] for h in hits[:k]}
        rec = len(retrieved & set(ex["expected"])) / len(ex["expected"])
        by_slice[ex["slice"]].append(rec)
    return {s: sum(v) / len(v) for s, v in by_slice.items()}

Tu compares :

  1. Baseline (vector seul)
  2. Multi-query
  3. HyDE
  4. RAG-Fusion full

Tu présentes au client : "sur le slice 'conversational' tu passes de 0.42 → 0.79 ; sur le slice 'lexical' tu ne perds rien (0.94 → 0.94)". Le client comprend où ça vaut le coup.

Edge cases à tester :

  • query vide / 1 mot → fallback sans rewrite
  • query > 500 mots → tronque ou rejette
  • query en langue non-FR → détecte + skip rewrite ou translate
  • LLM rewrite renvoie du JSON cassé → robust parsing

🔁 Quand utiliser / éviter

TechniqueUtilise quandÉvite quand
Query expansion dictDomaine jargonneux, abréviations stablesDomaine généraliste
Multi-query rewritingCatalogue hétérogène, queries variéesQueries déjà longues et précises
Step-backFAQ, helpdesk, contexte général utileRetrieval factuel pur
HyDEQ&A pur, LLM fiable sur le domaineLLM hallucine sur le sujet
Query decompositionQuestions composées analytiquesQuestions simples
RAG-Fusion (RRF)Toujours, par défaut, c'est gratuitLatence sub-100ms exigée
Adaptive routingPipeline avec types de query très distinctsMVP / prototype

Garde-fous metier

Pour chaque domaine, garde une liste blanche de techniques. En LegalTech tu n'utilises JAMAIS HyDE (le LLM hallucine des références d'arrêts inexistants). En e-commerce conversationnel, HyDE shine. L'expérience freelance, c'est ça.

python
DOMAIN_RULES = {
    "legal": {"enabled": ["expansion_dict", "multi_query", "step_back"], "forbidden": ["hyde"]},
    "ecom": {"enabled": ["multi_query", "hyde", "rag_fusion"], "forbidden": []},
    "medical": {"enabled": ["expansion_dict", "decomposition"], "forbidden": ["hyde"]},
    "rh": {"enabled": ["multi_query", "step_back"], "forbidden": []},
}

Tu vends ce mapping comme une consultation : 2-3 jours pour évaluer le domaine du client et formaliser la table. 3-5 k€ packageable seul.

🧭 Comment un staff engineer raisonne là-dessus

Le débutant pense "quelle technique de rewriting choisir ?". Le staff pense budget d'erreur et budget de latence, et place le rewriting dans une chaîne qu'il sait mesurer de bout en bout.

  1. Le rewriting n'est jamais le premier levier. Avant de toucher à la query, le staff vérifie que le chunking, l'embedder, et le rerank sont déjà décents. Réécrire une query pour un index mal chunké, c'est polir une voiture sans moteur. Ordre canonique des gains : chunking → embedder/rerank → query transformation → fine-tuning. Le rewriting est la deuxième vague, pas la première.

  2. Chaque technique est un échange recall↔précision↔latence↔coût, pas un upgrade gratuit. Multi-query monte le recall mais peut diluer la précision (tu ramènes du bruit pertinent-en-apparence). HyDE shine en Q&A mais devient un anti-pattern dès que le LLM hallucine sur le domaine. RRF est quasi gratuit en compute mais ajoute un step. Le staff chiffre : +X pts recall@10 pour +Y ms P95 et +Z €/1k queries, par slice de query.

  3. La query est une distribution, pas un objet unique. Le staff ne mesure jamais un recall global — il slice par type (lexical exact / conversationnel / compositionnel / ambigu). Une technique qui fait +0.4 sur le slice conversationnel et -0.1 sur le lexical peut être un mauvais trade si 70 % du trafic est lexical. C'est exactement ce que vend la section Eval.

  4. Le pipeline doit dégrader proprement. LLM down → fallback sur la query brute, jamais un crash. Le try/except typé dans llm_variants n'est pas du zèle : c'est la différence entre "le rewriting est en panne, le search marche en mode dégradé" et "tout le search est par terre parce que Anthropic a un incident".

  5. Adaptive routing est la réponse mature à la question coût. Tu n'allumes pas HyDE + rerank pour "ouvrir une session". Un classifier Haiku à 20 tokens en amont économise 30-50 % du coût LLM et coupe la latence sur la longue traîne de queries triviales.

  6. Le budget de latence se découpe par étape, et chaque étape est un SLO. Le staff ne dit pas "le pipeline fait 640 ms P95" — il dit "rewrite 180 ms, embed 90 ms, vector ×N parallèle 110 ms, RRF 5 ms, rerank 250 ms". C'est cette décomposition qui te dit couper quand le P99 dérape. Le rewrite LLM est le plus gros poste et le plus variable (P50 90 ms / P99 600 ms quand Anthropic est chargé) : d'où le timeout=8.0 qui borne le P99, le fallback qui transforme un timeout en mode dégradé silencieux, et le cache_read_input_tokens loggé pour vérifier que le prompt caching mord vraiment. Un pipeline sans span par étape (OpenTelemetry) est un pipeline qu'on ne peut pas débugger en prod — on ne sait jamais si la lenteur vient du rewrite, du rerank Cohere, ou de pgvector qui n'a pas son index HNSW chaud.

Le réflexe coût/modèle en une phrase

La réécriture et le HyDE sont des tâches Haiku-friendly : le bon modèle (claude-haiku-4-5 à 1 / 5 USD par M) plutôt qu'Opus (claude-opus-4-8 à 5 / 25 USD par M) divise le coût LLM par ~5-7 pour zéro perte de recall. La décomposition structurée monte d'un cran (claude-sonnet-4-6, 3 / 15 USD) parce qu'elle demande du raisonnement. Le réflexe : le modèle suit la difficulté de la sous-tâche, jamais "un modèle pour tout". Combiné au prompt caching sur le préfixe d'instructions et à l'adaptive routing, c'est ce qui sépare 2 800 €/mois de 15 000 €/mois sur le même volume.

🏋️ Exercices

Progressifs et durs. Chaque exercice a un Objectif et une Indice/Solution. Ne lis l'indice qu'après avoir buté.

Exercice 1 — Implémente RAG-Fusion de zéro, sans framework

Objectif : reproduire le pipeline multi_query → embed parallèle → RRF en pur Python + pgvector, sans LangChain. La fonction RRF doit être testée unitairement (3 rankings d'entrée, ordre de sortie attendu hardcodé).

Indice/Solution : RRF = score[doc] += 1/(k + rang) avec k=60, agrégé sur tous les rankings, puis tri décroissant. Le piège : un même doc apparaît dans plusieurs rankings à des rangs différents — tu sommes ses contributions. Teste avec un doc présent rang 0 dans un ranking et rang 5 dans un autre : son score doit être 1/60 + 1/65.

Exercice 2 — Casse HyDE, puis prouve-le avec une eval

Objectif : construis un dataset de 30 queries dans un domaine pointu (jurisprudence, ou un domaine où Claude hallucine sans contexte). Mesure recall@10 avec et sans HyDE. Démontre chiffres à l'appui que HyDE dégrade le recall sur ce domaine.

Indice/Solution : le mécanisme — la "réponse hypothétique" du LLM contient des références plausibles mais fausses (numéros d'arrêts inventés), donc son embedding pointe vers une zone de l'espace qui ne contient aucun vrai document. Tu dois voir recall@10 baisser de ~0.1-0.2. Conclusion à écrire : HyDE n'est valide que si P(LLM produit une réponse dans la bonne distribution sans contexte) est élevé.

Exercice 3 — Rends le pipeline production-grade

Objectif : ajoute à rag_fusion_search : (a) un cache Redis key=sha256(query) sur les rewrites avec TTL 1h, (b) prompt caching Anthropic sur le préfixe d'instructions, (c) instrumentation OpenTelemetry avec un span par étape (rewrite / embed / vector / rrf / rerank) exposant P50/P95/P99, (d) un fallback complet : si le rewrite échoue, le search continue sur la query brute.

Indice/Solution : le cache Redis va devant l'appel LLM (cache-aside : check → miss → call → set). Le prompt caching va sur le system block stable (déjà dans le code mis à jour). Pour OTel, wrappe chaque await dans tracer.start_as_current_span("rewrite"). Vérifie ton cache hit rate avec msg.usage.cache_read_input_tokens > 0 sur la 2ᵉ requête identique.

Exercice 4 — Défends le nombre devant un client sceptique

Objectif : on te dit "+28 pts de recall, je n'y crois pas, prouve-le et chiffre le ROI business". Construis une eval slicée (4 slices), produis le tableau comparatif des 5 pipelines (vector → full), et traduis le gain de recall en gain de conversion puis en € de marge, avec tes hypothèses explicites et falsifiables.

Indice/Solution : tu ne peux PAS présenter un recall global — slice par type de query, montre que le gain est concentré sur le slice conversationnel (là où le client perd de l'argent) et nul sur le lexical (rassure-le : tu ne casses rien). Pour le € : Δconversion × queries_conversationnelles/mois × panier_moyen × marge. Rends chaque hypothèse contestable ("je suppose une conversion de 1,2 % → 2,1 %, voici la source") — un client sérieux attaque les hypothèses, pas le chiffre final.

Exercice 5 — Build l'adaptive router et mesure l'économie

Objectif : implémente le classifier LLM du Pattern 7, route 1000 queries d'un dataset réel à travers {vector_only, multi_query_rrf, hyde_then_vector, decompose}, et mesure l'économie de coût LLM et la latence P95 vs "tout le monde passe par le pipeline full".

Indice/Solution : le classifier coûte ~20 tokens out par query — quasi gratuit. Le gain vient de la longue traîne de queries simple qui évitent HyDE + rerank. Piège senior : mesure aussi le taux d'erreur de routing (combien de queries conversational ont été mal classées en simple → recall écroulé sur elles). Si le classifier se trompe > 5 %, le coût de l'erreur de recall peut dépasser l'économie. C'est le vrai trade-off à défendre.

Exercice 6 (boss) — Migre le pipeline sur structured outputs natifs et prouve la robustesse

Objectif : remplace tout parsing JSON hand-rollé (la décomposition immo, l'extraction de critères) par client.messages.parse() avec schémas Pydantic. Puis injecte 100 queries adverses (caractères spéciaux, injections, queries vides, queries en 3 langues) et démontre que le pipeline ne renvoie jamais d'exception non gérée.

Indice/Solution : messages.parse() + output_config={"format": MonSchema} te donne un objet validé ou une erreur typée — fini le json.loads qui pète sur un trailing comma. Pour les queries adverses : query vide → fallback sans rewrite ; query > 500 mots → tronque ou rejette ; langue non-FR → détecte (langdetect) puis skip ou traduis. Le critère de réussite : 100/100 queries traitées, 0 stacktrace, chaque cas dégradé loggé. C'est ce qui sépare un POC d'un livrable facturable 19 k€.

🎤 En entretien

Q : "Multi-query améliore le recall — quel est le coût caché ?" R : Il peut diluer la précision (tu ramènes du bruit sémantiquement proche), il multiplie les appels embed/vector et la latence, et sans cache tu repaies chaque rewrite sur les queries répétées. Le RRF + rerank en aval récupère la précision ; le cache + Haiku récupèrent le coût.

Q : "Quand HyDE est-il un anti-pattern, et comment le détectes-tu avant la prod ?" R : Dès que le LLM hallucine sur le domaine sans contexte — la fausse réponse éloigne l'embedding du retrieval (legal, médical). Détection : eval Ragas/recall@10 avec et sans HyDE sur un dataset du domaine ; si HyDE baisse le recall, tu l'interdis pour ce domaine (liste blanche par métier).

Q : "Pourquoi k=60 dans RRF, et que se passe-t-il si tu mets k=10 ?" R : k=60 est l'optimum empirique du papier Cormack 2009 — il lisse l'influence du rang. Avec k=10, tu écrases trop les rangs élevés : les tout premiers docs dominent et tu perds le bénéfice de la fusion (le RRF devient un quasi-max au lieu d'une agrégation). On ne baisse k que sur eval qui le justifie.

Q : "Le LLM de rewriting tombe en prod. Qu'arrive-t-il à ton search ?" R : Rien de visible côté user s'il est bien conçu : try/except typé (RateLimitError / OverloadedError / APITimeoutError / APIStatusError) → fallback sur la query brute, le retrieval continue en mode dégradé. Un pipeline qui crash le search entier parce que le rewriting est down est un pipeline junior. Et on monitore le taux de fallback comme un SLO.

Q : "Quel modèle pour la réécriture, et comment tu défends ce choix face à un client qui veut 'le meilleur modèle' ?" R : Haiku (claude-haiku-4-5). La réécriture/expansion ne demande pas de raisonnement profond — Opus (claude-opus-4-8) coûte ~5-7× plus cher pour zéro gain de recall mesurable, je peux le prouver avec une eval A/B Haiku vs Opus sur leur dataset. Je choisis le modèle par difficulté de sous-tâche : Haiku pour rewrite/HyDE, Sonnet 4.6 pour la décomposition structurée (qui demande du raisonnement). Le vrai levier coût, c'est ça + prompt caching sur le préfixe + adaptive routing, pas la taille du modèle.

Q : "Ton P99 de retrieval a doublé ce matin. Comment tu trouves la cause en 5 minutes ?" R : Span OpenTelemetry par étape (rewrite / embed / vector / rrf / rerank), donc je regarde direct quel poste a bougé. Les suspects classiques : le rewrite LLM (Anthropic chargé → je vois le timeout mordre et le taux de fallback grimper), le rerank Cohere, ou pgvector dont l'index HNSW n'est plus en cache mémoire après un redéploiement. Sans instrumentation par étape, c'est de la divination — c'est pour ça qu'on l'ajoute au jour 1, pas au premier incident.

🔗 Liens

Bibliothèque tech perso — Achref