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 :
- Livrable : une image Docker qui expose
POST /v1/infer,GET /healthetGET /metrics. - Environnement : 2 vCPU et 4 Go de RAM maximum. Le GPU est optionnel ; s'il est utilisé, la VRAM est mesurée.
- Évaluation : un jeu de questions caché, envoyé avec 8 requêtes concurrentes.
- 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ère | Compétition ML classique (format Kaggle typique) | Challenge NextData RAG-Prod 01 |
|---|---|---|
| Livrable | Fichier de prédictions ou notebook | Image Docker + API HTTP |
| Métrique de classement | Une métrique unique (AUC, RMSE, F1…) | Score composite : F1 + latence P99 + mémoire + coût |
| Latence | Hors score (sauf prix d’efficacité ponctuels) | Mesurée au P99, seuil éliminatoire à 1 500 ms |
| Mémoire | Hors score | RAM/VRAM de pointe mesurée, plafond 4 Go |
| Coût d’inférence | Non mesuré | $ / 1 000 requêtes (tokens LLM + infrastructure) |
| Charge | Inférence hors ligne par lot | 8 requêtes concurrentes, taux d’erreur ≤ 1 % |
| Robustesse | Non évaluée | Entrée invalide → code HTTP 4xx attendu, jamais 500 |
| Transposable en entreprise | Faible à moyen | Direct : 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 evalfastapi==0.115.0
uvicorn==0.30.6
pydantic==2.9.2
scikit-learn==1.5.2
httpx==0.27.2
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"]
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
"""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),
)
"""É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) :
| Soumission | F1 | P99 | Mémoire | Coût / 1 000 req | Score S |
|---|---|---|---|---|---|
| A — RAG compact | 0,72 | 640 ms | 900 Mo | 0,35 $ | 72,4 |
| B — gros LLM | 0,81 | 1 400 ms | 3 600 Mo | 1,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