تحدي RAG-Prod 01: لوحة ترتيب تقيّم نموذجك كما تفعل الشركات
نُشر في
الحالة: مواصفات عامة وحزمة انطلاق. باب المشاركات لم يُفتح بعد، وسيُعلن عن فتحه في هذه الصفحة. الحزمة أدناه تعمل محليًا منذ الآن.
تعريف — لوحة الترتيب متعددة الأبعاد. لوحة الترتيب متعددة الأبعاد هي ترتيب لنماذج تعلّم الآلة يجمع في نتيجة واحدة قابلة لإعادة الإنتاج بين جودة التنبؤ (مقياس F1) وزمن استجابة الواجهة البرمجية (P99 بالمللي ثانية) والبصمة على الذاكرة (RAM/VRAM بالميغابايت) وتكلفة الاستدلال (بالدولار لكل 1000 طلب).
باختصار: يطلب تحدي RAG-Prod 01 تسليم خدمة استدلال داخل حاوية، لا ملف تنبؤات. وتُقيَّم هذه الخدمة على أربعة محاور تُقاس في ظروف حقيقية.
ما هي مسابقة تعلّم الآلة الموجّهة نحو الإنتاج؟
مسابقة تعلّم الآلة الموجّهة نحو الإنتاج هي مسابقة يُقدَّم فيها خدمة قابلة للتشغيل (صورة Docker وواجهة HTTP). يختبر مقيِّم مستقل هذه الخدمة تحت الضغط. ولا يُقدَّم فيها ملف CSV للتنبؤات.
قواعد التحدي هي التالية:
- المُخرَج: صورة Docker توفّر
POST /v1/inferوGET /healthوGET /metrics. - البيئة: معالجان افتراضيان (2 vCPU) و4 غيغابايت من الذاكرة كحد أقصى. وحدة GPU اختيارية، وإذا استُعملت تُقاس ذاكرتها VRAM.
- التقييم: مجموعة أسئلة مخفية تُرسل عبر 8 طلبات متزامنة.
- الترتيب: نتيجة مركّبة من 100، مع عتبات إقصاء (زمن الاستجابة، الذاكرة، نسبة الأخطاء).
المسابقة التقليدية مقابل تحدي الإنتاج: المقارنة
| المعيار | مسابقة تعلّم آلة تقليدية (صيغة Kaggle المعتادة) | تحدي NextData RAG-Prod 01 |
|---|---|---|
| المُخرَج | ملف تنبؤات أو دفتر Notebook | صورة Docker + واجهة HTTP |
| مقياس الترتيب | مقياس واحد (AUC، RMSE، F1…) | نتيجة مركّبة: F1 + زمن P99 + الذاكرة + التكلفة |
| زمن الاستجابة | خارج النتيجة (باستثناء جوائز كفاءة عرضية) | يُقاس عند P99، وعتبة الإقصاء 1500 مللي ثانية |
| الذاكرة | خارج النتيجة | تُقاس ذروة RAM/VRAM، والحد الأقصى 4 غيغابايت |
| تكلفة الاستدلال | لا تُقاس | دولار لكل 1000 طلب (رموز LLM + البنية التحتية) |
| الضغط | استدلال دفعي دون اتصال | 8 طلبات متزامنة، ونسبة أخطاء ≤ 1٪ |
| المتانة | لا تُقيَّم | مُدخل غير صالح ← رمز HTTP 4xx متوقع، وليس 500 أبدًا |
| قابلية النقل إلى الشركة | ضعيفة إلى متوسطة | مباشرة: الحاوية نفسها في الاختبار ثم في الإنتاج |
لماذا البحث عن بديل لـ Kaggle في علم البيانات داخل الشركات؟
الجواب المختصر: المسابقة التقليدية تُحسّن مقياسًا واحدًا دون اتصال. أما الشركة فتدفع ثمن أربعة أشياء في آن واحد: الدقة والسرعة والبنية التحتية وفاتورة الواجهة البرمجية.
الفجوة بين نظرية المسابقات وواقع الشركات تتلخّص في ثلاث نقاط:
- MLOps: النموذج الذي يفوز بمجموعة من عشرات النماذج وأكثر من 10 غيغابايت من الذاكرة لا يمكن نشره. في الإنتاج، القاعدة هي حاوية واحدة، واجهة واحدة، اتفاقية مستوى خدمة واحدة (SLA).
- RAG للمؤسسات: القيمة لا تأتي من أفضل نتيجة على مجموعة بيانات عامة، بل من إجابة موثّقة المصادر (معرّفات الوثائق)، تُقدَّم ضمن زمن استجابة مضمون، على وثائق داخلية.
- العائد على الاستثمار (ROI): بضع نقاط إضافية في F1 لا قيمة لها إذا ضاعفت تكلفة الطلب ثلاث مرات. ويُحسب العائد هكذا: (القيمة التجارية لكل طلب − التكلفة لكل طلب) × الحجم.
البنية المرجعية
┌──────────────────────┐ 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 · $هيكل ملفات حزمة الانطلاق:
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": "..."}ملفات الحزمة
الملفات الخمسة أدناه تشكّل حزمة كاملة. انسخوها كما هي، وأضيفوا ملفّي data/corpus.jsonl وdata/questions.jsonl، ثم شغّلوا:
docker compose up --build --abort-on-container-exit --exit-code-from evalالتعليقات داخل الشيفرة مكتوبة بالفرنسية، والشيفرة نفسها هي النسخة التي جرى اختبارها.
fastapi==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())
المقاييس وطريقة التقييم
1. الجودة: مقياس F1 على مستوى الرموز
بعد التوحيد (أحرف صغيرة، وحذف علامات التشكيل والترقيم)، نرمز بـ P للرموز المتنبأ بها وبـ G لرموز الإجابة المتوقعة:
Precision = |P ∩ G| / |P| · Recall = |P ∩ G| / |G|
F1 = 2 · Precision · Recall / (Precision + Recall)
قيمة F1 المعتمدة هي المتوسط على جميع الأسئلة.
2. زمن الاستجابة: P99 (طريقة الرتبة الأقرب)
نرتّب أزمنة الاستجابة الـ n المقيسة من جهة العميل تصاعديًا: x₍₁₎ ≤ … ≤ x₍ₙ₎.
P99 = x₍⌈0.99·n⌉₎
P99 هو الزمن الذي لا يتجاوزه 99٪ من الطلبات. وهو يقيس ما يعيشه أسوأ مستخدم من كل مئة، في حين يُخفي المتوسط الذُّرى.
3. الإنتاجية
Throughput (req/s) = n / total run duration
تُقاس عند 8 طلبات متزامنة.
4. البصمة على الذاكرة
هي ذروة الذاكرة المقيمة (RSS) للخدمة بالميغابايت. وعند استعمال GPU تُضاف أقصى قيمة لذاكرة VRAM التي يرصدها nvidia-smi --query-gpu=memory.used.
5. تكلفة الاستدلال لكل 1000 طلب
الرموز: T_in وT_out مجموع الرموز، وp_in وp_out السعر بالدولار لكل مليون رمز، وh سعر البنية التحتية بالدولار في الساعة، وD المدة بالثواني.
Cost₁₀₀₀ = (1000 / n) · [ (T_in · p_in + T_out · p_out) / 10⁶ + h · D / 3600 ]
6. النتيجة المركّبة وعتبات الإقصاء
S = 100 · [ 0.4 · F1 + 0.2 · (1 − P99/1500) + 0.2 · (1 − Mem/4096) + 0.2 · (1 − min(Cost₁₀₀₀/2, 1)) ]
تُقصى المشاركة (S = 0) إذا تجاوز P99 مدة 1500 مللي ثانية، أو تجاوزت الذاكرة 4096 ميغابايت، أو تجاوزت نسبة الأخطاء 1٪.
مثال حسابي (قيم افتراضية):
| المشاركة | F1 | P99 | الذاكرة | التكلفة / 1000 طلب | النتيجة S |
|---|---|---|---|---|---|
| A — RAG مدمج | 0.72 | 640 ms | 900 MB | $0.35 | 72.4 |
| B — LLM كبير | 0.81 | 1400 ms | 3600 MB | $1.60 | 40.2 |
تملك المشاركة B أفضل قيمة F1 لكنها تخسر الترتيب: وهذه بالضبط المفاضلة التي تجريها الشركة في الإنتاج.
الأسئلة الشائعة
- ما هي لوحة الترتيب متعددة الأبعاد؟
- هي ترتيب للنماذج يجمع في نتيجة واحدة بين مقياس F1 وزمن الاستجابة P99 والبصمة على الذاكرة وتكلفة الاستدلال، مع عتبات إقصاء.
- ما الفرق بين مسابقة Kaggle وتحدي NextData؟
- المسابقة التقليدية تقيّم ملف تنبؤات على مقياس واحد. أما تحدي NextData فيقيّم خدمة Docker منشورة على أربعة أبعاد تُقاس تحت الضغط.
- لماذا نقيس زمن الاستجابة عند P99 بدل المتوسط؟
- المتوسط يُخفي الذُّرى. أما P99 فيضمن أن 99٪ من المستخدمين يُخدَمون تحت العتبة، وهكذا تُكتب اتفاقيات مستوى الخدمة.
- هل يمكن استعمال نموذج لغوي مدفوع في RAG-Prod 01؟
- نعم. تُدرج حينها تكلفة الرموز في النتيجة عبر السعر لكل مليون رمز المصرّح به في المقيِّم.
- هل أحتاج إلى GPU؟
- لا. النموذج المرجعي يعمل على معالجين افتراضيين. وحدة GPU مسموح بها، لكن ذاكرتها VRAM تُحتسب في البصمة على الذاكرة.
هل تريدون اقتراح تحدٍّ على بياناتكم الخاصة أو المشاركة فيه؟
راسلونا: سنُعلمكم عند فتح باب المشاركات.
تواصلوا معنا