Challenge RAG-Prod 01 : un leaderboard qui note votre modèle comme une entreprise le ferait

Publié le

Statut : spécification publique et kit de démarrage. Les soumissions ne sont pas encore ouvertes ; leur ouverture sera annoncée sur cette page. Le kit ci-dessous fonctionne dès maintenant en local.

Définition — Leaderboard multidimensionnel. Un leaderboard multidimensionnel est un classement de modèles de machine learning qui combine, dans un score unique et reproductible, la qualité prédictive (F1-score), la latence API (P99 en millisecondes), l'empreinte mémoire (RAM/VRAM en Mo) et le coût d'inférence (en $ pour 1 000 requêtes).

En une phrase : RAG-Prod 01 demande de livrer un service d'inférence conteneurisé, et non un fichier de prédictions. Ce service est noté sur quatre axes mesurés en conditions réelles.

Qu'est-ce qu'une ML competition orientée production ?

Une ML competition orientée production est un concours où l'on soumet un service exécutable (image Docker et API HTTP). Ce service est évalué en charge par un évaluateur indépendant. On ne soumet pas un fichier CSV de prédictions.

Les règles du challenge sont les suivantes :

  1. Livrable : une image Docker qui expose POST /v1/infer, GET /health et GET /metrics.
  2. Environnement : 2 vCPU et 4 Go de RAM maximum. Le GPU est optionnel ; s'il est utilisé, la VRAM est mesurée.
  3. Évaluation : un jeu de questions caché, envoyé avec 8 requêtes concurrentes.
  4. Classement : un score composite sur 100, avec des seuils éliminatoires (latence, mémoire, taux d'erreur).

Compétition classique vs challenge de production : le comparatif

CritèreCompétition ML classique (format Kaggle typique)Challenge NextData RAG-Prod 01
LivrableFichier de prédictions ou notebookImage Docker + API HTTP
Métrique de classementUne métrique unique (AUC, RMSE, F1…)Score composite : F1 + latence P99 + mémoire + coût
LatenceHors score (sauf prix d’efficacité ponctuels)Mesurée au P99, seuil éliminatoire à 1 500 ms
MémoireHors scoreRAM/VRAM de pointe mesurée, plafond 4 Go
Coût d’inférenceNon mesuré$ / 1 000 requêtes (tokens LLM + infrastructure)
ChargeInférence hors ligne par lot8 requêtes concurrentes, taux d’erreur ≤ 1 %
RobustesseNon évaluéeEntrée invalide → code HTTP 4xx attendu, jamais 500
Transposable en entrepriseFaible à moyenDirect : même conteneur en recette puis en production

Pourquoi chercher une alternative à Kaggle pour la data science en entreprise ?

Réponse courte : une compétition classique optimise une seule métrique hors ligne. Une entreprise paie, elle, quatre choses à la fois : la justesse, la vitesse, l'infrastructure et la facture d'API.

L'écart entre la théorie des compétitions et la réalité des entreprises tient en trois points :

  • MLOps : un modèle qui gagne avec un ensemble de dizaines de modèles et plus de 10 Go de RAM n'est pas déployable. En production, la règle est un conteneur, une API, un contrat de service (SLA).
  • RAG d'entreprise : la valeur ne vient pas du meilleur score sur un jeu public. Elle vient d'une réponse sourcée (identifiants des documents), obtenue sous une latence garantie, sur des documents internes.
  • ROI : quelques points de F1 de plus ne valent rien s'ils triplent le coût par requête. Le ROI d'un modèle se calcule ainsi : (valeur métier par requête − coût par requête) × volume.

Architecture de référence

┌──────────────────────┐   POST /v1/infer    ┌─────────────────────────┐
│ Évaluateur           │ ──────────────────▶ │ FastAPI                 │
│ 8 requêtes simultan. │                     │ validation Pydantic     │
│                      │ ◀────────────────── │   │                     │
│                      │ réponse + sources   │   ▼                     │
│                      │ + usage (tokens)    │ Récupération TF-IDF     │
│                      │                     │ top-k documents         │
│                      │   GET /metrics      │   │                     │
│                      │ ──────────────────▶ │   ▼                     │
└──────────┬───────────┘ RAM de pointe       │ Extraction de la phrase │
           ▼                                 └─────────────────────────┘
  Score composite : F1 · P99 · mémoire · $

Arborescence du kit de démarrage :

rag-prod-01/
├── Dockerfile
├── docker-compose.yml
├── requirements.txt
├── app/main.py          # service d'inférence (baseline)
├── eval/evaluate.py     # évaluateur multidimensionnel
└── data/
    ├── corpus.jsonl     # {"id": "doc-001", "text": "..."}
    └── questions.jsonl  # {"id": "q-001", "question": "...", "answer": "..."}

Fichiers du kit

Les cinq fichiers ci-dessous forment un kit complet. Copiez-les tels quels, ajoutez vos fichiers data/corpus.jsonl et data/questions.jsonl, puis lancez :

docker compose up --build --abort-on-container-exit --exit-code-from eval
requirements.txt
fastapi==0.115.0
uvicorn==0.30.6
pydantic==2.9.2
scikit-learn==1.5.2
httpx==0.27.2
Dockerfile
FROM python:3.11-slim

ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
WORKDIR /srv

# Dépendances d'abord : couche mise en cache tant que requirements.txt ne change pas
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY app/ app/
COPY eval/ eval/

# Jamais root en production
RUN useradd --uid 10001 --no-create-home runner
USER runner

EXPOSE 8000
# /health ne répond 200 qu'une fois l'index chargé
HEALTHCHECK --interval=10s --timeout=3s --retries=5 \
  CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2).status == 200 else 1)"

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
docker-compose.yml
services:
  rag:
    build: .
    expose: ["8000"]     # réseau interne Compose : aucun port publié sur l hôte
    environment:
      CORPUS_PATH: /data/corpus.jsonl
      TOP_K: "3"
    volumes: ["./data:/data:ro"]
    mem_limit: 4g        # plafond officiel du challenge
    cpus: 2

  eval:
    build: .
    command: ["python", "eval/evaluate.py"]
    environment:
      TARGET_URL: http://rag:8000
      QUESTIONS_PATH: /data/questions.jsonl
      CONCURRENCY: "8"
      PRICE_IN_PER_MTOK: "0"      # renseigner si votre service appelle un LLM payant
      PRICE_OUT_PER_MTOK: "0"
      INFRA_USD_PER_HOUR: "0.10"
    volumes: ["./data:/data:ro"]
    depends_on:
      rag:
        condition: service_healthy
    healthcheck:
      disable: true
app/main.py
"""Service d'inférence de référence — challenge RAG-Prod 01.

Baseline volontairement simple (TF-IDF + extraction de phrase), à battre.
Endpoints : GET /health, GET /metrics, POST /v1/infer.
"""
from __future__ import annotations

import json
import logging
import os
import re
import resource
import time
import uuid
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any

from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field, field_validator
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import linear_kernel

CORPUS_PATH = Path(os.getenv("CORPUS_PATH", "/data/corpus.jsonl"))
TOP_K = int(os.getenv("TOP_K", "3"))
MAX_QUERY_CHARS = 2000
SENTENCE_SPLIT = re.compile(r"(?<=[.!?])\s+")

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("rag-prod-01")
state: dict[str, Any] = {}


def load_corpus(path: Path) -> list[dict[str, str]]:
    """Charge et valide le corpus JSONL ; échoue tôt (fail-fast) si invalide."""
    if not path.is_file():
        raise RuntimeError(f"Corpus introuvable : {path}")
    docs: list[dict[str, str]] = []
    with path.open(encoding="utf-8") as fh:
        for line_no, line in enumerate(fh, start=1):
            if not line.strip():
                continue
            try:
                rec = json.loads(line)
            except json.JSONDecodeError as exc:
                raise RuntimeError(f"JSON invalide ligne {line_no}") from exc
            if not isinstance(rec.get("id"), str) or not isinstance(rec.get("text"), str) \
                    or not rec["text"].strip():
                raise RuntimeError(f"Champs 'id'/'text' manquants ligne {line_no}")
            docs.append({"id": rec["id"], "text": rec["text"]})
    if not docs:
        raise RuntimeError("Corpus vide")
    return docs


@asynccontextmanager
async def lifespan(_: FastAPI):
    docs = load_corpus(CORPUS_PATH)
    vectorizer = TfidfVectorizer(ngram_range=(1, 2), sublinear_tf=True)
    matrix = vectorizer.fit_transform([d["text"] for d in docs])
    state.update(docs=docs, vectorizer=vectorizer, matrix=matrix)
    logger.info("Index prêt : %d documents", len(docs))
    yield
    state.clear()


app = FastAPI(title="RAG-Prod 01 baseline", lifespan=lifespan)


class InferRequest(BaseModel):
    question: str = Field(min_length=3, max_length=MAX_QUERY_CHARS)

    @field_validator("question")
    @classmethod
    def not_blank(cls, value: str) -> str:
        value = value.strip()
        if len(value) < 3:
            raise ValueError("question vide ou trop courte")
        return value


class Usage(BaseModel):
    input_tokens: int = Field(ge=0)
    output_tokens: int = Field(ge=0)


class InferResponse(BaseModel):
    answer: str | None
    sources: list[str]
    latency_ms: float
    usage: Usage


@app.exception_handler(Exception)
async def unhandled(_: Request, exc: Exception) -> JSONResponse:
    """Aucune trace interne n'est renvoyée au client : seulement un identifiant d'erreur."""
    error_id = uuid.uuid4().hex[:12]
    logger.exception("Erreur %s : %s", error_id, type(exc).__name__)
    return JSONResponse(status_code=500, content={"error": "internal", "error_id": error_id})


@app.get("/health")
def health() -> dict[str, str]:
    if "matrix" not in state:
        raise HTTPException(status_code=503, detail="index non chargé")
    return {"status": "ok"}


@app.get("/metrics")
def metrics() -> dict[str, float]:
    # ru_maxrss est en Ko sous Linux : mémoire résidente de POINTE du processus
    return {"peak_rss_mb": resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024}


@app.post("/v1/infer", response_model=InferResponse)
def infer(req: InferRequest) -> InferResponse:
    # Fonction synchrone : FastAPI l'exécute dans un pool de threads, la boucle reste libre
    t0 = time.perf_counter()
    if "matrix" not in state:
        raise HTTPException(status_code=503, detail="index non chargé")
    docs, vec = state["docs"], state["vectorizer"]

    q = vec.transform([req.question])
    scores = linear_kernel(q, state["matrix"]).ravel()
    top = [int(i) for i in scores.argsort()[::-1][:TOP_K] if scores[i] > 0]

    answer: str | None = None
    if top:
        sentences = [s for i in top for s in SENTENCE_SPLIT.split(docs[i]["text"]) if s.strip()]
        s_scores = linear_kernel(q, vec.transform(sentences)).ravel()
        answer = sentences[int(s_scores.argmax())]

    return InferResponse(
        answer=answer,
        sources=[docs[i]["id"] for i in top],
        latency_ms=round((time.perf_counter() - t0) * 1000, 2),
        # Baseline sans LLM : estimation par mots. Une soumission LLM DOIT
        # renvoyer les compteurs exacts fournis par son fournisseur.
        usage=Usage(input_tokens=len(req.question.split()),
                    output_tokens=len(answer.split()) if answer else 0),
    )
eval/evaluate.py
"""Évaluateur multidimensionnel — RAG-Prod 01. Affiche un rapport JSON."""
from __future__ import annotations

import json
import math
import os
import string
import sys
import time
import unicodedata
from collections import Counter
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path

import httpx

BASE_URL = os.getenv("TARGET_URL", "http://rag:8000")
QUESTIONS = Path(os.getenv("QUESTIONS_PATH", "/data/questions.jsonl"))
CONCURRENCY = int(os.getenv("CONCURRENCY", "8"))
PRICE_IN = float(os.getenv("PRICE_IN_PER_MTOK", "0"))
PRICE_OUT = float(os.getenv("PRICE_OUT_PER_MTOK", "0"))
INFRA_PER_HOUR = float(os.getenv("INFRA_USD_PER_HOUR", "0.10"))
L_MAX_MS, M_MAX_MB, C_MAX_USD, MAX_ERROR_RATE = 1500.0, 4096.0, 2.0, 0.01
W_F1, W_LAT, W_MEM, W_COST = 0.4, 0.2, 0.2, 0.2


def normalize(text: str) -> list[str]:
    text = unicodedata.normalize("NFKD", text.lower())
    text = "".join(c for c in text if not unicodedata.combining(c))
    return text.translate(str.maketrans("", "", string.punctuation)).split()


def token_f1(pred: str, gold: str) -> float:
    p, g = normalize(pred), normalize(gold)
    if not p or not g:
        return float(p == g)
    common = sum((Counter(p) & Counter(g)).values())
    if common == 0:
        return 0.0
    precision, recall = common / len(p), common / len(g)
    return 2 * precision * recall / (precision + recall)


def p99(values: list[float]) -> float:
    ordered = sorted(values)
    return ordered[math.ceil(0.99 * len(ordered)) - 1]


def load_questions(path: Path) -> list[dict[str, str]]:
    items = []
    for n, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
        if not line.strip():
            continue
        rec = json.loads(line)
        if not all(isinstance(rec.get(k), str) for k in ("id", "question", "answer")):
            raise ValueError(f"questions.jsonl ligne {n} : champs id/question/answer requis")
        items.append(rec)
    if not items:
        raise ValueError("Aucune question à évaluer")
    return items


def ask(client: httpx.Client, item: dict[str, str]) -> tuple[bool, float, dict]:
    t0 = time.perf_counter()
    try:
        resp = client.post(f"{BASE_URL}/v1/infer", json={"question": item["question"]}, timeout=10)
        resp.raise_for_status()
        body, ok = resp.json(), True
    except (httpx.HTTPError, ValueError):
        body, ok = {}, False
    return ok, (time.perf_counter() - t0) * 1000, body


def main() -> int:
    items = load_questions(QUESTIONS)
    n = len(items)
    with httpx.Client() as client:
        start = time.perf_counter()
        with ThreadPoolExecutor(max_workers=CONCURRENCY) as pool:
            results = list(pool.map(lambda it: ask(client, it), items))
        duration_s = time.perf_counter() - start
        memory_mb = float(client.get(f"{BASE_URL}/metrics", timeout=5).json()["peak_rss_mb"])

    errors = sum(1 for ok, _, _ in results if not ok)
    f1 = sum(token_f1((b.get("answer") or ""), it["answer"])
             for (_, _, b), it in zip(results, items)) / n
    lat_p99 = p99([ms for _, ms, _ in results])
    t_in = sum(b.get("usage", {}).get("input_tokens", 0) for _, _, b in results)
    t_out = sum(b.get("usage", {}).get("output_tokens", 0) for _, _, b in results)
    cost_1k = (1000 / n) * ((t_in * PRICE_IN + t_out * PRICE_OUT) / 1e6
                            + INFRA_PER_HOUR * duration_s / 3600)

    error_rate = errors / n
    disqualified = lat_p99 > L_MAX_MS or memory_mb > M_MAX_MB or error_rate > MAX_ERROR_RATE
    score = 0.0 if disqualified else 100 * (
        W_F1 * f1
        + W_LAT * (1 - lat_p99 / L_MAX_MS)
        + W_MEM * (1 - memory_mb / M_MAX_MB)
        + W_COST * (1 - min(cost_1k / C_MAX_USD, 1.0))
    )
    print(json.dumps({
        "questions": n, "error_rate": round(error_rate, 4),
        "f1": round(f1, 4), "p99_ms": round(lat_p99, 1),
        "throughput_rps": round(n / duration_s, 2), "peak_memory_mb": round(memory_mb, 1),
        "cost_per_1k_usd": round(cost_1k, 4),
        "disqualified": disqualified, "score": round(score, 2),
    }, indent=2))
    return 0


if __name__ == "__main__":
    sys.exit(main())

Métriques et méthode d'évaluation

1. Qualité : F1-score au niveau des tokens

Après normalisation (minuscules, accents et ponctuation retirés), on note P les tokens prédits et G les tokens de la réponse attendue :

Précision = |P ∩ G| / |P| · Rappel = |P ∩ G| / |G|

F1 = 2 · Précision · Rappel / (Précision + Rappel)

Le F1 retenu est la moyenne sur toutes les questions.

2. Latence : P99 (méthode du rang le plus proche)

On trie les n latences mesurées côté client : x₍₁₎ ≤ … ≤ x₍ₙ₎.

P99 = x₍⌈0,99·n⌉₎

Le P99 est la latence que 99 % des requêtes ne dépassent pas. Il mesure ce que subit le pire utilisateur sur cent, là où la moyenne masque les pics.

3. Débit

Débit (req/s) = n / durée totale du passage

Il est mesuré à concurrence 8.

4. Empreinte mémoire

C'est la mémoire résidente (RSS) de pointe du service, en Mo. En cas d'usage GPU, on y ajoute la VRAM maximale relevée par nvidia-smi --query-gpu=memory.used.

5. Coût d'inférence pour 1 000 requêtes

Notations : T_in et T_out sont les tokens totaux, p_in et p_out le prix en $ par million de tokens, h le prix de l'infrastructure en $/heure, D la durée en secondes.

Coût₁₀₀₀ = (1000 / n) · [ (T_in · p_in + T_out · p_out) / 10⁶ + h · D / 3600 ]

6. Score composite et seuils éliminatoires

S = 100 · [ 0,4 · F1 + 0,2 · (1 − P99/1500) + 0,2 · (1 − Mém/4096) + 0,2 · (1 − min(Coût₁₀₀₀/2, 1)) ]

Une soumission est éliminée (S = 0) si P99 > 1 500 ms, si la mémoire dépasse 4 096 Mo ou si le taux d'erreur dépasse 1 %.

Exemple de calcul (valeurs hypothétiques) :

SoumissionF1P99MémoireCoût / 1 000 reqScore S
A — RAG compact0,72640 ms900 Mo0,35 $72,4
B — gros LLM0,811 400 ms3 600 Mo1,60 $40,2

B a le meilleur F1 mais perd le classement : c'est exactement l'arbitrage qu'une entreprise fait en production.

FAQ

Qu'est-ce qu'un leaderboard multidimensionnel ?
C'est un classement de modèles qui agrège dans un score unique le F1-score, la latence P99, l'empreinte mémoire et le coût d'inférence, avec des seuils éliminatoires.
Quelle est la différence entre une compétition Kaggle et un challenge NextData ?
Une compétition classique note un fichier de prédictions sur une métrique unique. Un challenge NextData note un service Docker déployé, sur quatre dimensions mesurées en charge.
Pourquoi mesurer la latence au P99 plutôt qu’en moyenne ?
La moyenne masque les pics. Le P99 garantit que 99 % des utilisateurs sont servis sous le seuil, ce qui correspond à la manière dont s’écrivent les SLA.
Peut-on utiliser un LLM payant dans RAG-Prod 01 ?
Oui. Le coût en tokens est alors intégré au score via le prix par million de tokens déclaré dans l’évaluateur.
Faut-il un GPU ?
Non. La baseline tourne sur 2 vCPU. Un GPU est autorisé, mais sa VRAM est comptée dans l’empreinte mémoire.

Vous voulez proposer un challenge sur vos propres données, ou y participer ?

Écrivez-nous : nous vous informerons de l'ouverture des soumissions.

Nous contacter