Skip to content

Project 1 — Build Production RAG System (SPEC)

Your first portfolio piece. Builds in projects/01-rag-prod/. Time: 4-6 weeks at 8-12h/week.


Goal

Build, deploy, and document a production-grade RAG system on a real dataset relevant to your chosen vertical (10-vertical-positioning.md).

End state : A live URL where someone can ask questions, get answers with citations, and you have metrics in the README that prove it works.


Le modèle mental : RAG n'est pas "stuff dans le prompt"

Avant d'écrire une ligne, internalise ce qu'un staff engineer voit quand il regarde un RAG. RAG n'est pas une feature, c'est un pipeline d'information retrieval avec un LLM agrafé au bout. 80% de la qualité finale se joue avant l'appel au LLM, dans le retrieval. Le LLM ne peut pas répondre à partir de chunks qu'il n'a jamais reçus — un retrieval qui rate le bon passage est un échec que le meilleur modèle ne rattrapera jamais. C'est la première chose qu'on te demandera en entretien.

Le pipeline canonique, étape par étape, avec le levier de qualité de chacune :

Documents → Chunking → Embedding → Index (vector + BM25)

Question → Embedding ──────────────┐      │
Question → BM25 ───────────────────┤      ▼
                                   └──▶ Retrieval (top-k large, ex k=50)


                                    Fusion (RRF)  ← combine les deux listes


                                    Reranker (top-k → top-n, ex 50→8)  ← le plus gros gain qualité/$


                                    Prompt assembly (contexte + question + instructions citations)


                                    LLM (Claude) → réponse + citations


                                    Eval (Ragas) ── boucle de feedback ──▶ retour au chunking

Les deux nombres qui résument un RAG, et que tu dois pouvoir défendre :

MétriqueMesureCe qu'un mauvais score révèle
Context recall / precisionEst-ce que le bon passage est dans le contexte envoyé au LLM ?Problème de retrieval (chunking, embedding, fusion, reranker)
FaithfulnessLa réponse est-elle fondée sur le contexte, ou hallucinée ?Problème de génération (prompt, modèle, ou contexte trop bruité)

Si faithfulness est haut mais answer_relevancy bas → tu réponds fidèlement à la mauvaise question (retrieval). Si context_recall est haut mais faithfulness bas → le bon passage était là, le modèle a halluciné quand même (prompt/modèle). Savoir lire ce tableau te distingue d'un junior qui change des constantes au hasard.

Pourquoi la plupart des RAG sont mauvais en production

Les modes d'échec classiques — anticipe-les, ton README doit montrer que tu les connais :

  • Naïve chunking (split tous les 512 tokens) coupe une phrase en deux, sépare un tableau de son titre, et noie la réponse dans du bruit. Le chunking est la décision #1.
  • Vector-only retrieval rate les requêtes à mots-clés exacts (numéros d'article, codes, noms propres, acronymes). C'est exactement le cas du Legal/Finance. D'où l'hybride BM25 + vecteur.
  • Pas de reranker : le top-k vectoriel est bruité. Le reranker (cross-encoder) relit paire par paire question/chunk et réordonne — c'est le meilleur ratio gain-qualité/effort de tout le pipeline.
  • Pas d'eval : sans ground truth, chaque "amélioration" est une croyance. Tu ne peux pas optimiser ce que tu ne mesures pas. C'est la raison pour laquelle l'eval vient en Semaine 2, avant les améliorations.
  • Contexte gonflé : envoyer 50 chunks "au cas où" augmente le coût, la latence, et dégrade la faithfulness (le modèle se perd). Plus de contexte ≠ meilleure réponse. Reranke et coupe.

Acceptance criteria

Recruiters will judge this project on :

Code quality

  • [ ] Modular code (not single-file notebook)
  • [ ] Typed (Pydantic for Python, Zod for TS if you do frontend)
  • [ ] Tests (at least integration tests for the happy path)
  • [ ] CI/CD (GitHub Actions runs lint + tests on every push)
  • [ ] No secrets committed (use .env + ignore)
  • [ ] README with diagrams + decisions + tradeoffs

Pipeline depth (must NOT be naive RAG)

  • [ ] Chunking strategy chosen + justified in README (why this one, what you compared)
  • [ ] Hybrid search : BM25 + vector with RRF fusion
  • [ ] Reranker : Cohere Rerank OR BAAI/bge-reranker (run locally if cost matters)
  • [ ] Citations : every answer includes source chunks with offsets
  • [ ] Metadata filtering : at least 1 facet (date, source, category)

Evaluation

  • [ ] Ragas eval suite (faithfulness, context_precision, answer_relevancy)
  • [ ] Ground truth dataset : at least 50 Q&A pairs you crafted
  • [ ] Eval results in README : numbers, not vibes
  • [ ] Continuous eval : re-run on PR (GitHub Action)

Production concerns

  • [ ] Observability : LangSmith OR OpenTelemetry trace per request
  • [ ] Cost tracking : log $/query in DB or logs, surface in dashboard
  • [ ] Latency tracking : p50, p95, p99 in README
  • [ ] Error handling : graceful fallbacks (e.g. LLM down → cached response or degraded mode)

Deployment

  • [ ] Dockerfile + docker-compose for local dev
  • [ ] Deployed publicly (Vercel front + your k3s back, or Railway, or HuggingFace Spaces)
  • [ ] Public URL in README
  • [ ] Demo video (Loom, 90 sec max)

Suggested stack

LayerTool
BackendFastAPI (Python) OR Next.js API routes (TS)
Vector DBpgvector on local Postgres (or your k3s)
EmbeddingsOpenAI text-embedding-3-small (cheap to start)
LLMClaude Sonnet 4.6 (claude-sonnet-4-6) default, escalate hard cases to Opus 4.8 (claude-opus-4-8)
RerankerCohere Rerank (cheap) OR BAAI/bge-reranker (local)
BM25rank_bm25 (Python) or Postgres full-text search
EvalRagas
ObservabilityLangSmith (easiest) or LangFuse self-hosted
FrontendNext.js + Vercel AI SDK (streaming, citations)
DeployVercel (front) + k3s (back) — leverage your infra

ℹ️ Sur ta stack cible (Python + NestJS + Angular) : le backend RAG en FastAPI est le bon choix — l'écosystème retrieval/eval (Ragas, rank_bm25, sentence-transformers) est Python-natif. Si tu veux exposer le RAG à travers une API NestJS existante, fais de FastAPI un service interne et proxy-le depuis NestJS — ne réécris pas Ragas en TS. Le front Angular consomme l'endpoint de streaming (SSE) ; le Vercel AI SDK est React-only, donc côté Angular tu gères le SSE à la main (EventSource ou fetch + ReadableStream).

Comment un staff engineer choisit chaque couche

Les recruteurs ne veulent pas la liste, ils veulent le raisonnement. Voici les arbitrages à pouvoir défendre :

DécisionChoix par défautAlternativeQuand basculer
Vector DBpgvectorPinecone / Qdrant / Weaviatepgvector tant que tu tiens en RAM et < ~1M vecteurs et que tu veux un seul datastore (vecteurs + métadonnées + filtres SQL dans la même requête). Bascule sur un vector store dédié quand le filtrage métadonnée + ANN à grande échelle devient le goulot, ou que tu veux du sharding managé.
Embeddingstext-embedding-3-smallbge / Voyage / -3-largeLe -small est suffisant pour démarrer et 5× moins cher. Mesure d'abord le context_recall ; ne paye -large que si l'eval prouve un gain. Un modèle local (bge) supprime le coût par requête mais ajoute du GPU à opérer.
RerankerCohere Rerank (API)bge-reranker (local)API = zéro infra, coût par requête. Local = coût fixe GPU, pas de fuite de données (critique en Legal/Médical). Le reranker local est le bon endroit pour économiser à volume.
LLMclaude-sonnet-4-6claude-opus-4-8 / claude-haiku-4-5Sonnet par défaut (équilibre, 3/15 $ par MTok in/out). Escalade les cas durs vers Opus 4.8 (5/25 $, contexte 1M) ; route les questions triviales vers Haiku 4.5 (1/5 $) pour le coût. Le choix de modèle est une décision par-requête, pas globale.

ℹ️ Pourquoi deux providers (OpenAI pour l'embedding, Claude pour la génération) ? Ce n'est pas une incohérence, c'est un découplage assumé. L'embedding et la génération sont des étapes indépendantes du pipeline avec des contraintes opposées : l'embedding est un appel massif et batchable à l'ingestion (tu encodes 100k chunks une fois), où ce qui compte est le coût/vecteur et la qualité de la similarité ; la génération est un appel unitaire dans le chemin chaud, où ce qui compte est le raisonnement et les citations. Tu choisis le meilleur outil pour chaque étape. Contrainte dure à connaître : l'espace d'embedding n'est pas portable — si tu changes de modèle d'embedding (OpenAI → Voyage, ou -small-large), tu dois réindexer tout le corpus (les anciens vecteurs sont dans un espace incompatible). C'est pour ça que le choix d'embedding est plus engageant que le choix de LLM : le LLM se swappe par requête, l'embedding se swappe par migration.

Le pattern d'appel LLM que les seniors attendent dans ton code

L'étape génération est un appel API serveur dans un chemin chaud (chaque requête utilisateur). Le code junior fait un client.messages.create() synchrone sans gestion d'erreur. Voici ce qui est attendu d'un staff engineer — AsyncAnthropic pour un serveur, prompt caching sur le préfixe stable, adaptive thinking, exceptions typées, et usage loggé pour le coût :

python
from anthropic import AsyncAnthropic, APIStatusError, RateLimitError, APITimeoutError

# Un seul client réutilisé sur tout le process (pool de connexions).
client = AsyncAnthropic(max_retries=3, timeout=30.0)

# Le préfixe système + instructions de citation est IDENTIQUE à chaque requête →
# on le met en cache (cache_control) pour ne pas le re-facturer à chaque appel.
SYSTEM_PROMPT = [
    {
        "type": "text",
        "text": (
            "Tu es un assistant qui répond UNIQUEMENT à partir des passages fournis. "
            "Cite chaque affirmation avec [source: <chunk_id>]. "
            "Si la réponse n'est pas dans les passages, dis-le explicitement — n'invente rien."
        ),
        "cache_control": {"type": "ephemeral"},
    }
]

async def answer(question: str, chunks: list[dict]) -> dict:
    # Le contexte (volatile, par requête) vient APRÈS le préfixe caché.
    context = "\n\n".join(f"[chunk {c['id']}]\n{c['text']}" for c in chunks)
    try:
        resp = await client.messages.create(
            model="claude-sonnet-4-6",                 # escalade vers claude-opus-4-8 sur les cas durs
            max_tokens=1024,
            thinking={"type": "adaptive"},             # PAS de budget_tokens (retiré / 400 sur 4.7/4.8)
            output_config={"effort": "medium"},        # low | medium | high | max
            system=SYSTEM_PROMPT,
            messages=[{"role": "user", "content": f"Passages:\n{context}\n\nQuestion: {question}"}],
        )
    except RateLimitError:
        raise               # le SDK a déjà retenté max_retries fois avec backoff
    except APITimeoutError:
        raise               # → fallback / mode dégradé côté appelant
    except APIStatusError as e:
        # .type distingue overloaded_error (529, réessayable) de invalid_request_error (400)
        raise

    # Logge usage à CHAQUE appel — c'est ta source de vérité pour le $/query.
    # input_tokens = portion NON cachée uniquement ; le contexte caché est dans
    # cache_read_input_tokens (facturé ~0.1×) et cache_creation (~1.25× au 1er appel).
    u = resp.usage
    cost = (
        u.input_tokens * 3                       # input plein tarif ($3/MTok Sonnet 4.6)
        + (u.cache_read_input_tokens or 0) * 0.30  # cache read ≈ 0.1×
        + (u.cache_creation_input_tokens or 0) * 3.75  # cache write ≈ 1.25×
        + u.output_tokens * 15                    # output ($15/MTok Sonnet 4.6)
    ) / 1_000_000
    log.info("rag.generate", cost_usd=cost, cache_read=u.cache_read_input_tokens, **vars(u))

    text = next(b.text for b in resp.content if b.type == "text")
    return {"answer": text, "usage": u}

Le fan-out parallèle (point #2 ci-dessous) ressemble concrètement à ça — embedding, BM25 et vector search sont indépendants, donc on les lance ensemble plutôt qu'en cascade :

python
import asyncio

async def retrieve(question: str) -> list[dict]:
    # Trois I/O indépendants → asyncio.gather, pas trois await en série.
    q_embedding, bm25_hits = await asyncio.gather(
        embed(question),        # appel au service d'embedding
        bm25_search(question),  # full-text search Postgres / rank_bm25
    )
    vec_hits = await vector_search(q_embedding)   # dépend de l'embedding → après
    fused = rrf_fuse(bm25_hits, vec_hits)         # Reciprocal Rank Fusion
    return await rerank(question, fused, top_n=8) # cross-encoder, 50→8

Points qu'un junior rate et qui font la différence en revue de code :

  • AsyncAnthropic + un seul client : un serveur traite N requêtes concurrentes ; un client sync les sérialise et un nouveau client par requête tue le pool de connexions.
  • asyncio.gather pour le fan-out : embedding de la question, BM25, et vector search sont indépendants → lance-les en parallèle, ne les enchaîne pas (le pitfall "sync code" plus bas).
  • Prompt caching sur le préfixe système : sur un RAG, le system prompt + instructions de citation sont byte-identiques à chaque requête. Les mettre en cache (cache_control) coupe ~90% du coût de cette portion (read ≈ 0.1× le prix input, write ≈ 1.25×). Vérifie usage.cache_read_input_tokens > 0 — s'il est à zéro, un invalidateur silencieux casse le cache. Les trois suspects classiques : un datetime.now() ou un request_id interpolé dans le system prompt, un json.dumps() sans sort_keys=True sur les définitions de tools, et — piège discret — un préfixe trop court : le minimum cachable est 4096 tokens sur Opus/Haiku, 2048 sur Sonnet 4.6 ; en dessous, le cache ne s'écrit pas (cache_creation_input_tokens: 0) sans erreur. Mets le contexte récupéré (volatile) après le dernier breakpoint, jamais dans le préfixe caché — sinon chaque requête invalide tout.
  • Adaptive thinking, pas budget_tokens : sur Claude 4.7/4.8 la forme thinking={"type":"enabled","budget_tokens":N} renvoie un 400. Utilise thinking={"type":"adaptive"} + output_config.effort. Sur un RAG simple, effort: "low" ou "medium" suffit — réserve high pour la synthèse multi-documents.
  • Streaming pour la réponse vers le front : améliore le TTFB perçu et évite les timeouts SDK sur les longues sorties.
  • Citations natives : si tu passes les sources comme document blocks (avec citations: {enabled: true}) plutôt que concaténées en texte, l'API Claude renvoie des citations structurées (offsets de caractères dans le document source, garantis par l'API) au lieu de te fier au modèle pour formater [source: ...] — qu'il peut halluciner ou mal formater. C'est le bon outil pour l'acceptance criterion "citations avec offsets". Note : les citations et output_config.format (structured outputs) sont mutuellement exclusifs (400 si tu combines les deux) — pour un RAG, choisis les citations, c'est ta valeur métier. Si tu as besoin d'une sortie structurée en plus (ex. un champ confidence), fais-le via un tool call séparé ou un second appel, pas dans le même message.
  • Structured outputs natifs plutôt que prompting XML/JSON : quand tu veux une réponse typée (classification d'intent, extraction de champs en amont du retrieval, scoring), utilise client.messages.parse() avec un schéma Pydantic/Zod (ou output_config.format) plutôt que de demander du JSON dans le prompt et de le json.loads() à la main. L'API contraint la grammaire de sortie → pas de JSON malformé à gérer. Le routing par intent de l'Exercice 4 est un cas d'usage direct.

Dataset ideas by vertical

  • French jurisprudence : Légifrance API
  • Public European GDPR / regulatory texts
  • Open data : CNIL decisions, ECHR cases

Finance / Compta

  • AMF / BCE regulatory texts
  • Annual reports (CAC 40 — public)
  • Open data : INSEE economic statistics

RH / Recrutement

  • Public LinkedIn job postings (scrape responsibly)
  • Open dataset job descriptions (kaggle)
  • CV public datasets (anonymized)

E-commerce

  • Amazon reviews public dataset
  • Open product catalog (Shopify exports, OpenFoodFacts)

Médical

  • ⚠️ NEVER use real patient data
  • Public : ClinicalTrials.gov, PubMed abstracts, OMS public docs

Week-by-week plan

Week 1 — Setup + baseline naive RAG

  • [ ] Repo init (TS or Python — pick one)
  • [ ] Postgres + pgvector up locally
  • [ ] Ingestion script : load 100 sample docs, chunk simple, embed, store
  • [ ] Query script : vector search → stuff in prompt → LLM
  • [ ] Baseline working in CLI

Week 2 — Eval + ground truth

  • [ ] Hand-craft 50 Q&A pairs from your dataset
  • [ ] Set up Ragas
  • [ ] Run baseline eval → record numbers (probably bad, that's ok)
  • [ ] Goal : have a reproducible eval

Week 3 — Improvements

  • [ ] Better chunking (try 2-3 strategies, eval each)
  • [ ] Add BM25 in parallel + RRF fusion
  • [ ] Add reranker
  • [ ] Re-run eval, compare to baseline

Week 4 — Production concerns

  • [ ] LangSmith integration
  • [ ] Cost tracking
  • [ ] Error handling + fallbacks
  • [ ] Pydantic everywhere

Week 5 — Frontend + Deploy

  • [ ] Next.js UI with streaming
  • [ ] Citations rendered as clickable
  • [ ] Deploy front to Vercel, back to k3s
  • [ ] Demo video

Week 6 — Documentation + Distribution

  • [ ] README with architecture, metrics, tradeoffs
  • [ ] Article on Medium / dev.to (1500-2500 words)
  • [ ] LinkedIn post with video
  • [ ] Submit to AI engineer newsletters

What to write in your README

Template :

markdown
# [Project Name] — RAG for [Vertical]

> One-line description.

## Demo
- Live: https://...
- Video: https://loom.com/...

## Metrics
- p50 latency: 800ms
- p95 latency: 2.1s
- Faithfulness: 0.92
- Context precision: 0.87
- Cost per query: $0.003

## Architecture
[Diagram]

## Tradeoffs
- Chose pgvector over Pinecone because [...]
- Chose Cohere Rerank over local because [...]
- Chose Sonnet 4.6 over Opus because [...]

## Tech stack
[List]

## Run locally
[Instructions]

## Eval
[How to run eval]

Common pitfalls (learn from others)

  • ❌ Ship before having eval → no way to measure improvements
  • ❌ Use 10k docs when 500 would prove the point
  • ❌ Skip the frontend → recruiters can't try it
  • ❌ Hard-code prompts → no iteration possible
  • ❌ Sync code (calling APIs sequentially) → slow for no reason
  • ❌ Pretend numbers ("p95 ~1s") → if asked, you'll be exposed
  • ❌ Mesurer la latence end-to-end seulement → tu ne sais pas si c'est l'embedding, le reranker ou le LLM. Instrumente chaque étape (span par étape).
  • ❌ Optimiser le LLM avant le retrieval → si context_recall est bas, changer de modèle ne change rien. Répare le retrieval d'abord.
  • ❌ Eval sur le même jeu de questions que celui qui a servi à tuner → tu mesures de l'overfit. Garde un holdout que tu ne regardes pas pendant l'itération.

Production concerns — la profondeur que les recruteurs creusent

L'acceptance criteria liste "observability / cost / latency / error handling". Voici le raisonnement senior derrière chaque ligne — c'est ce qui transforme un projet jouet en projet d'embauche.

Latence : décompose, ne moyenne pas

Un p50 global ne dit rien. Trace un span par étape et publie la décomposition. Ordre de grandeur typique :

ÉtapeBudget latenceLevier si trop lent
Embedding question20–80 msbatch, cache des questions fréquentes
Vector + BM25 search10–50 msindex HNSW bien tuné, lists/probes pgvector
Reranker100–400 msreranker local sur GPU, ou réduire le top-k entrant
LLM (génération)0.5–3 sstreaming (TTFB), effort plus bas, Haiku sur cas simples

La latence dominante est presque toujours le LLM → le streaming est la première optimisation de latence perçue, pas la dernière. p95/p99 comptent plus que p50 : un RAG qui répond en 800 ms p50 mais 9 s p99 est cassé pour 5% des users.

Coût : usage est ta seule source de vérité

Ne devine pas le coût, logge resp.usage à chaque appel et somme. Le coût par requête = embedding + reranker + (input_tokens × prix_in + output_tokens × prix_out). Prix LLM 2026 (USD / million de tokens, in / out) :

ModèleInputOutputUsage RAG
claude-haiku-4-515Questions triviales, classification d'intent
claude-sonnet-4-6315Défaut
claude-opus-4-8525 (contexte 1M)Synthèse multi-docs, cas durs escaladés

Les trois leviers de coût dans l'ordre d'impact : (1) prompt caching sur le préfixe stable (~90% de réduction sur cette portion), (2) routing par modèle (Haiku sur le trivial), (3) reranke pour réduire le contexte envoyé au LLM — moins de chunks = moins d'input tokens et meilleure faithfulness. Surface $/query dans un dashboard ; c'est le nombre qu'on te demandera de défendre.

Observability : un trace par requête, attribué

LangSmith / LangFuse / OpenTelemetry — peu importe l'outil, l'attendu est : chaque requête produit un trace avec un span par étape, les chunks retournés, les scores de rerank, et l'usage tokens. Quand un user signale une mauvaise réponse, tu dois pouvoir ouvrir ce trace et voir si le bon chunk était dans le contexte (problème génération) ou absent (problème retrieval). Sans ça, tu débugges à l'aveugle.

Error handling : le mode dégradé

Le LLM va tomber (429, 529, timeout). Le SDK Anthropic retente déjà 429/5xx avec backoff (max_retries) — n'écris pas ta propre boucle de retry par-dessus. Ta responsabilité commence après l'épuisement des retries :

  • 529 overloaded → bascule sur un modèle moins chargé (Haiku) ou file d'attente.
  • Timeout / down → réponse en cache pour cette question, ou mode dégradé ("Je n'arrive pas à générer une réponse, voici les passages les plus pertinents : …") — toujours mieux qu'une 500 nue.
  • stop_reason == "refusal" → le modèle a refusé ; surface-le proprement, ne réessaie pas le même prompt.
  • Retrieval vide (aucun chunk au-dessus du seuil) → réponds "Je n'ai pas d'information sur ce sujet" plutôt que de laisser le LLM halluciner sur du contexte vide. C'est un garde-fou faithfulness, pas juste de l'UX.

Sécurité : ce que personne ne regarde et qui te fait gagner l'entretien

  • Isolation multi-tenant du retrieval : si plusieurs users/orgs partagent l'index, le filtre métadonnée tenant_id doit être appliqué dans la requête de retrieval, jamais en post-filtrage côté LLM. Un chunk d'un autre tenant qui atteint le contexte est une fuite de données.
  • Prompt injection via documents : tes chunks viennent de documents externes. Un document peut contenir "ignore les instructions précédentes". Le contexte récupéré est non fiable — garde tes instructions système hors du canal user, et n'exécute jamais d'instruction venue d'un chunk.
  • Pas de secrets dans les logs/traces : tu logges les chunks et l'usage ; assure-toi que PII et secrets ne fuient pas dans LangSmith.

🏋️ Exercices

Du plus simple au "casse-le puis répare-le". Chaque exercice est un livrable concret pour le README ou un commit.

Exercice 1 — Le baseline honnête

Objectif : Construire le RAG naïf (vector-only, pas de reranker) et produire le premier rapport Ragas — des chiffres, même mauvais. Indice/Solution : 100 docs, chunking fixe 512 tokens, top-k=5, stuff dans le prompt. Mesure faithfulness, context_precision, answer_relevancy sur tes 50 Q&A. Note les chiffres : c'est ta ligne de base, tout le reste se mesure contre elle.

Exercice 2 — Prouve la valeur de l'hybride + reranker

Objectif : Ajouter BM25 + RRF fusion puis le reranker, et quantifier le gain de chacun séparément. Indice/Solution : Trois runs d'eval : (a) vector seul, (b) +BM25/RRF, (c) +reranker. Mets les context_recall côte à côte dans un tableau. Le reranker devrait donner le plus gros saut. Si BM25 n'aide pas, regarde tes questions : sont-elles sémantiques (vecteur gagne) ou à mots-clés exacts (BM25 gagne) ? Le but est de pouvoir dire "le reranker m'a fait gagner +0.14 de context_recall", pas "j'ai ajouté un reranker".

Exercice 3 — La guerre du chunking

Objectif : Comparer 3 stratégies de chunking sur la même eval et défendre ton choix par les chiffres. Indice/Solution : (a) fixe 512, (b) récursif par séparateurs structurels (titres/paragraphes), (c) avec overlap de 15%. Réindexe à chaque fois (le chunking est en amont de l'embedding, donc tout l'index change). Le récursif structurel gagne presque toujours en Legal/Finance car il garde un article entier ensemble. Documente le compromis : chunks plus gros = meilleur recall mais plus d'input tokens (coût) et plus de bruit (faithfulness).

Exercice 4 — Casse le coût, puis répare-le

Objectif : Réduire le $/query de moitié sans faire chuter faithfulness de plus de 2%. Indice/Solution : Trois leviers à combiner et mesurer : prompt caching sur le préfixe système (vérifie cache_read_input_tokens > 0), routing Haiku 4.5 sur les questions classées "triviales", et reranke à top-n=4 au lieu de 8 (moins d'input tokens). Trace le $/query avant/après et le delta faithfulness. Le piège : couper le contexte trop agressivement fait chuter le recall — trouve le point où le coût baisse sans casser la qualité.

Exercice 5 — Casse la faithfulness, puis prouve que tu la détectes

Objectif : Provoquer délibérément des hallucinations, puis ajouter un garde-fou qui les attrape avant l'utilisateur. Indice/Solution : Pose 10 questions dont la réponse n'est pas dans ton corpus. Un RAG naïf hallucine une réponse plausible. Ajoute : (1) un seuil de score de retrieval — si aucun chunk ne passe, réponds "pas d'information" ; (2) un check de faithfulness en ligne (un second appel LLM, ou un classifieur, qui vérifie que chaque affirmation est étayée par le contexte). Mesure le taux d'hallucination avant/après. C'est l'exercice qui te fait passer de "j'ai un RAG" à "j'ai un RAG en qui je peux avoir confiance".

Exercice 6 — Continuous eval qui bloque la PR (production-grade)

Objectif : Faire échouer une GitHub Action si une PR régresse une métrique au-delà d'un seuil. Indice/Solution : L'eval Ragas tourne en CI sur ton holdout. Stocke les scores baseline dans le repo. La PR échoue si faithfulness ou context_recall baisse de plus de X%. Subtilité senior : l'eval LLM-as-judge est bruitée et coûteuse — fixe la seed, utilise un sous-ensemble stable, et fixe le modèle juge (ex claude-sonnet-4-6) pour que le bruit n'apparaisse pas comme une régression. Documente pourquoi tu tolères un delta de X% et pas 0%.

Exercice 7 — Le trace qui débugge à ta place (observability)

Objectif : Instrumenter un span par étape, puis prouver que tu peux diagnostiquer une mauvaise réponse sans rejouer la requête — uniquement depuis le trace. Indice/Solution : Émets un trace par requête (LangSmith / LangFuse / OpenTelemetry) avec un span par étape (embedding, BM25, vector, fusion, rerank, génération), et attache à chaque span : les chunk_ids retournés, les scores de rerank, et resp.usage (dont cache_read_input_tokens). Puis le drill : prends 5 réponses jugées mauvaises, ouvre leur trace, et classe chacune en retrieval (le bon chunk était absent du contexte → context_recall bas) ou génération (le bon chunk était là, le modèle a halluciné → faithfulness bas). Le livrable est ce tableau de diagnostic. C'est l'exercice qui matérialise la phrase "sans trace, tu débugges à l'aveugle" — et en entretien, savoir lire ce split retrieval/génération depuis un trace est exactement ce qui te place au niveau senior.

Exercice 8 — Multi-tenant : prouve l'isolation, puis casse-la (sécurité)

Objectif : Garantir qu'un chunk d'un autre tenant ne peut jamais atteindre le contexte, et écrire le test qui le prouve. Indice/Solution : Indexe deux tenants (tenant_a, tenant_b) avec un chunk « secret » distinctif chacun. Applique le filtre tenant_id dans la requête de retrieval (clause SQL/WHERE sur pgvector, pas un post-filtrage côté LLM). Écris un test qui pose, en tant que tenant_a, une question dont la seule réponse est le secret de tenant_b, et asserte que le secret n'apparaît ni dans les chunks récupérés, ni dans la réponse. Puis casse-le volontairement (post-filtrage côté LLM au lieu du filtre en requête) et regarde le test devenir rouge — tu viens de transformer une affirmation de sécurité en garantie testée. Bonus : ajoute le cas prompt-injection (un chunk contenant « ignore les instructions précédentes ») et vérifie que tes instructions système tiennent.


🎤 En entretien

Questions que ce projet invite, avec la réponse senior en une ligne :

  • "Ton RAG répond à côté. Où tu regardes en premier ?" → Le retrieval, pas le modèle : j'ouvre le trace et je vérifie si le bon chunk était dans le contexte (context_recall) — sinon c'est un problème de chunking/fusion/reranker, et changer de LLM n'y fera rien.
  • "Pourquoi hybride BM25 + vecteur, pas juste du vectoriel ?" → Le vectoriel rate les correspondances de mots-clés exacts (codes, numéros d'article, acronymes) ; BM25 les attrape ; RRF fusionne les deux classements sans avoir à calibrer leurs scores hétérogènes.
  • "Tu dois diviser le coût par deux demain. Tu fais quoi ?" → Dans l'ordre d'impact : prompt caching sur le préfixe stable, routing Haiku sur les requêtes triviales, et reranke pour réduire le nombre de chunks envoyés — moins d'input tokens et meilleure faithfulness, donc gratuit en qualité.
  • "Comment tu empêches les hallucinations ?" → Un seuil de retrieval qui répond "pas d'information" sur contexte vide, des instructions de génération strictes ("réponds uniquement à partir des passages, cite tout"), et une mesure continue de faithfulness en CI — on ne supprime pas l'hallucination, on la mesure et on la borne.
  • "À quoi sert le reranker, concrètement ?" → Le top-k vectoriel est bruité car l'embedding compresse tout en un vecteur ; le reranker est un cross-encoder qui relit chaque paire question/chunk et réordonne — c'est le meilleur ratio gain-qualité/coût du pipeline, et il me laisse envoyer moins de chunks au LLM.
  • "Tu changes ton modèle d'embedding. Quel est le coût caché ?" → La réindexation complète du corpus : les vecteurs vivent dans un espace propre au modèle, donc anciens et nouveaux vecteurs ne sont pas comparables — il faut tout ré-encoder et reconstruire l'index. C'est pourquoi le choix d'embedding est une décision de migration, pas une décision par-requête comme le choix de LLM.
  • "Ton eval Ragas utilise un LLM comme juge. Pourquoi c'est un piège en CI ?" → Parce que le juge est bruité et coûteux : deux runs sur le même input peuvent donner des scores différents, ce qui ressemble à une régression alors que c'est du bruit. Je fixe le modèle juge (ex claude-sonnet-4-6), je fixe un holdout stable, je tolère un delta non-nul comme seuil de régression, et je ne juge jamais sur le jeu qui a servi à tuner — sinon je mesure de l'overfit.
  • "Comment tu décides quelle requête va sur Haiku vs Sonnet vs Opus ?" → Routing par-requête, pas global : un classifieur d'intent léger (ou un premier appel Haiku) trie trivial / standard / dur. Trivial → Haiku (coût). Standard → Sonnet (défaut). Synthèse multi-docs ou raisonnement long → Opus 4.8. Je mesure le $/query et la faithfulness par bucket pour vérifier que le routing ne dégrade pas les cas que j'ai dégradés vers Haiku.

When you're done

  • [ ] Push to GitHub (pinned on profile)
  • [ ] LinkedIn featured section
  • [ ] Article published
  • [ ] LinkedIn post with embed of video
  • [ ] Update CV / portfolio site
  • [ ] Submit to relevant newsletters / Discord communities

→ Move to Project 2 (Agentic + MCP).

Bibliothèque tech perso — Achref