RAG-Prod 01 Challenge: a leaderboard that scores your model the way a company would

Published on

Status: public specification and starter kit. Submissions are not open yet; their opening will be announced on this page. The kit below already runs locally today.

Definition — Multi-dimensional leaderboard. A multi-dimensional leaderboard is a ranking of machine learning models that combines, in a single reproducible score, predictive quality (F1-score), API latency (P99 in milliseconds), memory footprint (RAM/VRAM in MB) and inference cost (in $ per 1,000 requests).

In one sentence: RAG-Prod 01 asks you to ship a containerised inference service, not a predictions file. That service is scored on four axes measured under real conditions.

What is a production-oriented ML competition?

A production-oriented ML competition is a contest in which you submit an executable service (Docker image and HTTP API). The service is load-tested by an independent evaluator. You do not submit a CSV file of predictions.

The challenge rules are as follows:

  1. Deliverable: a Docker image exposing POST /v1/infer, GET /health and GET /metrics.
  2. Environment: 2 vCPUs and 4 GB of RAM at most. A GPU is optional; if used, its VRAM is measured.
  3. Evaluation: a hidden question set, sent as 8 concurrent requests.
  4. Ranking: a composite score out of 100, with elimination thresholds (latency, memory, error rate).

Classic competition vs production challenge: the comparison

CriterionClassic ML competition (typical Kaggle format)NextData RAG-Prod 01 challenge
DeliverablePredictions file or notebookDocker image + HTTP API
Ranking metricA single metric (AUC, RMSE, F1…)Composite score: F1 + P99 latency + memory + cost
LatencyNot scored (except occasional efficiency prizes)Measured at P99, elimination threshold at 1,500 ms
MemoryNot scoredPeak RAM/VRAM measured, 4 GB ceiling
Inference costNot measured$ / 1,000 requests (LLM tokens + infrastructure)
LoadOffline batch inference8 concurrent requests, error rate ≤ 1%
RobustnessNot evaluatedInvalid input → HTTP 4xx expected, never 500
Transferable to a companyLow to mediumDirect: the same container in staging, then in production

Why look for a Kaggle alternative for enterprise data science?

Short answer: a classic competition optimises a single offline metric. A company pays for four things at once: accuracy, speed, infrastructure and the API bill.

The gap between competition theory and enterprise reality comes down to three points:

  • MLOps: a model that wins with an ensemble of dozens of models and more than 10 GB of RAM cannot be deployed. In production, the rule is one container, one API, one service-level agreement (SLA).
  • Enterprise RAG: value does not come from the best score on a public dataset. It comes from a sourced answer (document identifiers), delivered under a guaranteed latency, on internal documents.
  • ROI: a few extra F1 points are worthless if they triple the cost per request. A model’s ROI is computed as (business value per request − cost per request) × volume.

Reference architecture

┌──────────────────────┐   POST /v1/infer    ┌─────────────────────────┐
│ Evaluator            │ ──────────────────▶ │ FastAPI                 │
│ 8 concurrent requests│                     │ Pydantic validation     │
│                      │ ◀────────────────── │   │                     │
│                      │ answer + sources    │   ▼                     │
│                      │ + usage (tokens)    │ TF-IDF retrieval        │
│                      │                     │ top-k documents         │
│                      │   GET /metrics      │   │                     │
│                      │ ──────────────────▶ │   ▼                     │
└──────────┬───────────┘ peak RAM            │ Answer sentence extract.│
           ▼                                 └─────────────────────────┘
  Composite score: F1 · P99 · memory · $

Starter kit layout:

rag-prod-01/
├── Dockerfile
├── docker-compose.yml
├── requirements.txt
├── app/main.py          # inference service (baseline)
├── eval/evaluate.py     # multi-dimensional evaluator
└── data/
    ├── corpus.jsonl     # {"id": "doc-001", "text": "..."}
    └── questions.jsonl  # {"id": "q-001", "question": "...", "answer": "..."}

Kit files

The five files below make up a complete kit. Copy them as they are, add your data/corpus.jsonl and data/questions.jsonl files, then run:

docker compose up --build --abort-on-container-exit --exit-code-from eval

The comments in the code are in French; the code itself is the tested version.

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())

Metrics and evaluation method

1. Quality: token-level F1-score

After normalisation (lower case, accents and punctuation removed), let P be the predicted tokens and G the tokens of the expected answer:

Precision = |P ∩ G| / |P| · Recall = |P ∩ G| / |G|

F1 = 2 · Precision · Recall / (Precision + Recall)

The F1 retained is the average over all questions.

2. Latency: P99 (nearest-rank method)

Sort the n latencies measured on the client side: x₍₁₎ ≤ … ≤ x₍ₙ₎.

P99 = x₍⌈0.99·n⌉₎

P99 is the latency that 99% of requests do not exceed. It measures what the worst user out of a hundred experiences, where the average hides spikes.

3. Throughput

Throughput (req/s) = n / total run duration

It is measured at a concurrency of 8.

4. Memory footprint

This is the peak resident memory (RSS) of the service, in MB. When a GPU is used, the maximum VRAM reported by nvidia-smi --query-gpu=memory.used is added.

5. Inference cost per 1,000 requests

Notation: T_in and T_out are total tokens, p_in and p_out the price in $ per million tokens, h the infrastructure price in $/hour, D the duration in seconds.

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

6. Composite score and elimination thresholds

S = 100 · [ 0.4 · F1 + 0.2 · (1 − P99/1500) + 0.2 · (1 − Mem/4096) + 0.2 · (1 − min(Cost₁₀₀₀/2, 1)) ]

A submission is eliminated (S = 0) if P99 > 1,500 ms, if memory exceeds 4,096 MB or if the error rate exceeds 1%.

Worked example (hypothetical values):

SubmissionF1P99MemoryCost / 1,000 reqScore S
A — compact RAG0.72640 ms900 MB$0.3572.4
B — large LLM0.811,400 ms3,600 MB$1.6040.2

B has the best F1 but loses the ranking: this is exactly the trade-off a company makes in production.

FAQ

What is a multi-dimensional leaderboard?
It is a ranking of models that combines the F1-score, P99 latency, memory footprint and inference cost into a single score, with elimination thresholds.
What is the difference between a Kaggle competition and a NextData challenge?
A classic competition scores a predictions file on a single metric. A NextData challenge scores a deployed Docker service on four dimensions measured under load.
Why measure latency at P99 rather than on average?
The average hides spikes. P99 guarantees that 99% of users are served under the threshold, which is how SLAs are written.
Can I use a paid LLM in RAG-Prod 01?
Yes. The token cost is then included in the score through the price per million tokens declared in the evaluator.
Do I need a GPU?
No. The baseline runs on 2 vCPUs. A GPU is allowed, but its VRAM counts towards the memory footprint.

Want to propose a challenge on your own data, or take part?

Write to us: we will let you know when submissions open.

Contact us