depiedencap-rag/docs/rag/eval/run_eval.py
grgmro 39cffbc1bb depiedencap-rag — export public initial
Plugin Discourse RAG (grain post, rerank cross-encoder, citations
whitelistées, gate déterministe, réécriture de requête multi-tour)
+ documentation du chantier (design, éval, sécurité, pipeline).

Export sanitisé depuis le monorepo privé via scripts/export_rag_public.py
(aucun endpoint interne, IP, compte démo ou chemin d'infrastructure).
WIP : agent en test sur groupe restreint, pas encore public sur le forum.
2026-09-17 09:26:45 +02:00

305 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Harness d'éval RAG — côté hôte (laptop/PC), le conteneur `app` doit tourner.
Rejoue les questions de questions.yml contre la réplique locale via
eval_retrieval.rb (rails runner dans le conteneur) et mesure le retrieval :
Hit@k et MRR par canal (semantic / keyword / hybrid).
Usage :
cd <clone>/scripts
uv run --with pyyaml python rag/eval/run_eval.py [--label baseline-topic] \
[--container app] [--questions rag/eval/questions.yml]
Sortie : tableau console + rag/eval/results/<date>-<label>.json
"""
import argparse
import json
import subprocess
import sys
import tempfile
from datetime import datetime, timezone
from pathlib import Path
import yaml
RERANK_MODEL = "cross-encoder/mmarco-mMiniLMv2-L12-H384-v1"
EVAL_DIR = Path(__file__).resolve().parent
RESULTS_DIR = EVAL_DIR / "results"
def hit(rank: int | None, k: int) -> int:
return int(rank is not None and rank <= k)
def mrr(rank: int | None) -> float:
return 0.0 if rank is None else 1.0 / rank
def first_hit_rank(ranked_ids: list[int], expected: set[int]) -> int | None:
for i, tid in enumerate(ranked_ids, 1):
if tid in expected:
return i
return None
def channel_ids(hits: list[dict]) -> list[int]:
out = []
for h in hits:
if "topic_id" in h:
out.append(h["topic_id"])
return out
def rrf_fuse(lists: list[list[dict]], k_rrf: int = 60) -> list[dict]:
"""Reciprocal Rank Fusion de plusieurs listes ordonnées de hits.
Chaque item fusionné garde le post_id de sa meilleure occurrence source
(sert à évaluer les hits grain post sur les canaux fusionnés).
"""
scores: dict[int, float] = {}
best_post: dict[int, int | None] = {}
for hits in lists:
for rank, h in enumerate(hits, 1):
tid = h.get("topic_id")
if tid is None:
continue
scores[tid] = scores.get(tid, 0.0) + 1.0 / (k_rrf + rank)
if tid not in best_post or rank < best_post[tid][1]:
best_post[tid] = (h.get("post_id"), rank)
return [
{"topic_id": tid, "post_id": best_post[tid][0], "rrf": s}
for tid, s in sorted(scores.items(), key=lambda kv: -kv[1])
]
def posts_topic_ranked(hits: list[dict]) -> list[dict]:
"""Canal posts → liste dédupliquée par topic (1re occurrence = rang)."""
seen: dict[int, dict] = {}
for h in hits:
seen.setdefault(h["topic_id"], h)
return list(seen.values())
_reranker = None
def get_reranker():
global _reranker
if _reranker is None:
from sentence_transformers import CrossEncoder
print(f"chargement reranker {RERANK_MODEL}...", file=sys.stderr)
_reranker = CrossEncoder(RERANK_MODEL)
return _reranker
def rerank(question: str, hits: list[dict]) -> list[dict]:
"""Re-trie les hits (canal posts, avec `text`) par score cross-encoder."""
scored = [h for h in hits if h.get("text")]
if not scored:
return hits
ce = get_reranker()
pairs = [(question, h["text"]) for h in scored]
scores = ce.predict(pairs)
order = sorted(zip(scored, scores), key=lambda t: -t[1])
return [h for h, _ in order]
def pollution_stats(hits: list[dict], k: int) -> tuple[float, float]:
"""Agrégation du canal posts : nb moyen de topics distincts dans le top-k
et part moyenne du topic dominant (1.0 = un seul topic squatte le top-k)."""
top = hits[:k]
if not top:
return 0.0, 0.0
counts: dict[int, int] = {}
for h in top:
counts[h["topic_id"]] = counts.get(h["topic_id"], 0) + 1
distinct = len(counts)
share = max(counts.values()) / len(top)
return distinct, share
def fetch_posts_count(topic_ids: set[int], container: str) -> dict[int, int]:
"""posts_count des topics, via une requête psql unique dans le conteneur."""
if not topic_ids:
return {}
ids = ",".join(str(i) for i in topic_ids)
proc = subprocess.run(
["docker", "exec", "-u", "postgres", container, "psql", "discourse",
"-tAc", f"SELECT id, posts_count FROM topics WHERE id IN ({ids})"],
capture_output=True, text=True, check=True,
)
return {int(l.split("|")[0]): int(l.split("|")[1])
for l in proc.stdout.splitlines() if "|" in l}
def run_ruby(questions: list[dict], container: str) -> list[dict]:
with tempfile.NamedTemporaryFile(
mode="w", suffix=".json", delete=False, encoding="utf-8"
) as f:
json.dump(questions, f)
qfile = f.name
rb_dst = "/tmp/eval_retrieval.rb"
q_dst = "/tmp/eval_questions.json"
for src, dst in ((EVAL_DIR / "eval_retrieval.rb", rb_dst), (qfile, q_dst)):
subprocess.run(
["docker", "cp", str(src), f"{container}:{dst}"], check=True
)
proc = subprocess.run(
[
"docker", "exec", container, "bash", "-lc",
f"cd /var/www/discourse && su discourse -c "
f"'bundle exec rails runner {rb_dst} {q_dst}'",
],
capture_output=True, text=True, timeout=3600,
)
if proc.returncode != 0:
print(proc.stderr[-3000:], file=sys.stderr)
sys.exit("rails runner a échoué — conteneur `app` up ?")
results = []
for line in proc.stdout.splitlines():
line = line.strip()
if line.startswith("{"):
results.append(json.loads(line))
return results
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--questions", default=str(EVAL_DIR / "questions.yml"))
ap.add_argument("--label", default="run")
ap.add_argument("--container", default="app")
ap.add_argument("--k", type=int, default=5)
ap.add_argument("--rerank", action="store_true",
help="ajoute les canaux rerank / rrf_rerank_hyb (cross-encoder, lent)")
args = ap.parse_args()
questions = yaml.safe_load(Path(args.questions).read_text(encoding="utf-8"))
questions = [q for q in questions if q.get("expected_topic_ids")]
print(f"{len(questions)} questions avec golden topics\n")
raw = run_ruby(questions, args.container)
by_id = {r["id"]: r for r in raw}
# canaux natifs (bruts du ruby) + fusions RRF calculées côté hôte
# + routing par taille : rrf posts × hybrid filtré aux topics ≤ N posts
# + config C : posts enrichis (strategy_id=2), seulement si backfill présent
ROUTED_THRESHOLDS = (25, 50, 100, 250)
has_enr = any(r.get("posts_enr") for r in raw)
channels = ["semantic", "keyword", "posts", "hybrid",
"rrf_post_sem", "rrf_post_kw", "rrf_post_hyb",
*(f"routed_{n}" for n in ROUTED_THRESHOLDS)]
if has_enr:
channels += ["posts_enr", "rrf_enr_hyb"]
if args.rerank:
channels += ["rerank", "rrf_rerank_hyb"]
if has_enr:
channels += ["rerank_enr"]
get_reranker() # charge le modèle une fois avant la boucle
# posts_count des topics renvoyés (une requête pour tout le run)
all_tids: set[int] = set()
for r in raw:
for ch in ("semantic", "keyword", "posts", "hybrid"):
for h in r.get(ch) or []:
if h.get("topic_id"):
all_tids.add(h["topic_id"])
size_of = fetch_posts_count(all_tids, args.container)
agg = {ch: {"hit": 0, "rr": 0.0, "n": 0} for ch in channels}
pol = {"distinct": 0.0, "share": 0.0, "n": 0}
rows = []
for q in questions:
expected = set(q["expected_topic_ids"])
r = by_id.get(q["id"])
if not r:
rows.append((q["id"], "ABSENT", None, None, None))
continue
expected_posts = set(q.get("expected_post_ids") or [])
# construit les canaux fusionnés à partir des listes brutes
post_hits = r.get("posts") or []
posts_t = posts_topic_ranked(post_hits)
enr_hits = r.get("posts_enr") or []
enr_t = posts_topic_ranked(enr_hits)
hybrid_hits = r.get("hybrid") or []
fused = {
"rrf_post_sem": rrf_fuse([posts_t, r.get("semantic") or []]),
"rrf_post_kw": rrf_fuse([posts_t, r.get("keyword") or []]),
"rrf_post_hyb": rrf_fuse([posts_t, hybrid_hits]),
"rrf_enr_hyb": rrf_fuse([enr_t, hybrid_hits]),
}
for n in ROUTED_THRESHOLDS:
short = [h for h in hybrid_hits
if size_of.get(h.get("topic_id", 0), 10**9) <= n]
fused[f"routed_{n}"] = rrf_fuse([posts_t, short])
if args.rerank:
reranked = rerank(q["question"], post_hits)
fused["rerank"] = posts_topic_ranked(reranked)
fused["rrf_rerank_hyb"] = rrf_fuse([fused["rerank"], hybrid_hits])
if enr_hits:
fused["rerank_enr"] = posts_topic_ranked(
rerank(q["question"], enr_hits))
if post_hits and "error" not in (post_hits[0] or {}):
d, s = pollution_stats(post_hits, args.k)
pol["distinct"] += d
pol["share"] += s
pol["n"] += 1
row = [q["id"]]
for ch in channels:
hits = fused[ch] if ch in fused else (r.get(ch) or [])
if hits and "error" in hits[0]:
row.append(f"ERR:{hits[0]['error'][:40]}")
continue
rank = first_hit_rank(channel_ids(hits), expected)
agg[ch]["n"] += 1
agg[ch]["hit"] += hit(rank, args.k)
agg[ch]["rr"] += mrr(rank)
cell = "-" if rank is None else f"#{rank}"
# hit grain post : un expected_post_id présent dans les k premiers
if expected_posts:
pids = [h.get("post_id") for h in hits[: args.k]]
cell += "·p" + ("✓" if any(p in expected_posts for p in pids) else "✗")
row.append(cell)
rows.append(row)
print(f"{'id':<22} " + " ".join(f"{ch:<10}" for ch in channels))
for row in rows:
print(f"{str(row[0]):<22} " + " ".join(f"{str(c):<10}" for c in row[1:]))
print()
report = {"label": args.label, "date": datetime.now(timezone.utc).isoformat(),
"k": args.k, "questions": len(questions), "channels": {}, "raw": raw}
for ch in channels:
n = agg[ch]["n"] or 1
report["channels"][ch] = {
"hit_at_k": round(agg[ch]["hit"] / n, 3),
"mrr": round(agg[ch]["rr"] / n, 3),
"n": agg[ch]["n"],
}
print(f"{ch:<12} Hit@{args.k}={agg[ch]['hit'] / n:.2f} "
f"MRR={agg[ch]['rr'] / n:.2f} (n={agg[ch]['n']})")
if pol["n"]:
report["posts_pollution"] = {
"avg_distinct_topics_at_k": round(pol["distinct"] / pol["n"], 2),
"avg_dominant_topic_share_at_k": round(pol["share"] / pol["n"], 2),
"n": pol["n"],
}
print(f"posts top-{args.k}: {pol['distinct'] / pol['n']:.1f} topics "
f"distincts, part dominante {pol['share'] / pol['n']:.2f}")
RESULTS_DIR.mkdir(exist_ok=True)
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
out = RESULTS_DIR / f"{stamp}-{args.label}.json"
out.write_text(json.dumps(report, ensure_ascii=False, indent=2))
print(f"\n→ {out}")
if __name__ == "__main__":
main()