Spaces:
Running
Running
arbarikcp commited on
Commit ·
c884f27
1
Parent(s): 8643845
Adding OpenAI models aand llm-as-judge options
Browse files- .gitignore +1 -1
- app/app.py +43 -22
- app/scoring.py +33 -18
- src/evaluation/llm_judge.py +149 -100
- src/generation/llm_client.py +9 -11
- src/pipeline/rag_pipeline.py +14 -4
.gitignore
CHANGED
|
@@ -9,4 +9,4 @@ data/
|
|
| 9 |
!src/data/
|
| 10 |
.venv/
|
| 11 |
.env
|
| 12 |
-
|
|
|
|
| 9 |
!src/data/
|
| 10 |
.venv/
|
| 11 |
.env
|
| 12 |
+
.idea
|
app/app.py
CHANGED
|
@@ -77,6 +77,7 @@ EVAL_METHOD_CHOICES = [
|
|
| 77 |
("Local NLI (free, no API key needed)", "local_nli"),
|
| 78 |
("Groq Judge – LLaMA-70B (needs GROQ_API_KEY)", "groq"),
|
| 79 |
("OpenAI Judge – GPT-4o-mini (needs OPENAI_API_KEY)", "openai"),
|
|
|
|
| 80 |
]
|
| 81 |
|
| 82 |
|
|
@@ -290,23 +291,26 @@ def _gold_comparison_df(pred: dict, gold: dict) -> pd.DataFrame:
|
|
| 290 |
|
| 291 |
def run_single_query(dataset, embedding_model, chunk_label, retrieval_strategy, query_rewrite,
|
| 292 |
n_variants, reranker_choice, llm_model, dense_k, bm25_k, fusion_top_k, top_n,
|
| 293 |
-
question, trace_on, reference, gold_scores, eval_method):
|
| 294 |
if not question or not question.strip():
|
| 295 |
raise gr.Error("Please enter a question.")
|
| 296 |
|
| 297 |
pipeline, _ = get_pipeline(dataset, embedding_model, chunk_label, reranker_choice, retrieval_strategy,
|
| 298 |
query_rewrite, n_variants, llm_model, dense_k, bm25_k, fusion_top_k, top_n)
|
| 299 |
|
| 300 |
-
|
| 301 |
-
use_judge = eval_method in ("groq", "openai")
|
| 302 |
run_trace = bool(trace_on) or use_judge
|
|
|
|
| 303 |
|
| 304 |
-
result = pipeline.answer(question, trace=run_trace)
|
| 305 |
|
| 306 |
eval_trace = None
|
| 307 |
if use_judge:
|
| 308 |
try:
|
| 309 |
-
scores, eval_trace = score_result_with_judge_traced(
|
|
|
|
|
|
|
|
|
|
| 310 |
except Exception as exc:
|
| 311 |
raise gr.Error(f"Judge evaluation failed: {exc}")
|
| 312 |
else:
|
|
@@ -411,15 +415,9 @@ def _sanitize_stats_for_json(stats_df: pd.DataFrame) -> list:
|
|
| 411 |
|
| 412 |
# -- Benchmark tab ------------------------------------------------------------
|
| 413 |
|
| 414 |
-
# Max concurrent LLM pipelines during a benchmark run.
|
| 415 |
-
# Each slot may issue up to 2-3 API calls (query-transform + generation + judge).
|
| 416 |
-
# 5 is safe for Groq's free tier (~30 RPM); increase for paid tiers.
|
| 417 |
-
_BENCH_CONCURRENCY = 5
|
| 418 |
-
|
| 419 |
-
|
| 420 |
async def run_benchmark(dataset, embedding_model, chunk_label, retrieval_strategy, query_rewrite,
|
| 421 |
n_variants, reranker_choice, llm_model, dense_k, bm25_k, fusion_top_k, top_n,
|
| 422 |
-
n_samples, eval_method, progress=gr.Progress()):
|
| 423 |
pipeline, entry = get_pipeline(dataset, embedding_model, chunk_label, reranker_choice, retrieval_strategy,
|
| 424 |
query_rewrite, n_variants, llm_model, dense_k, bm25_k, fusion_top_k, top_n)
|
| 425 |
import random as _rng
|
|
@@ -443,7 +441,8 @@ async def run_benchmark(dataset, embedding_model, chunk_label, retrieval_strateg
|
|
| 443 |
cached = cache.get_cached_rows(results_df, chash)
|
| 444 |
cached_idx = set(cached["row_index"].tolist())
|
| 445 |
|
| 446 |
-
use_judge = eval_method in ("groq", "openai")
|
|
|
|
| 447 |
|
| 448 |
def _safe_float(series_row, col):
|
| 449 |
val = series_row.get(col, float("nan"))
|
|
@@ -452,7 +451,7 @@ async def run_benchmark(dataset, embedding_model, chunk_label, retrieval_strateg
|
|
| 452 |
def _fmt(v):
|
| 453 |
return "N/A" if (v is None or (isinstance(v, float) and math.isnan(v))) else f"{v:.3f}"
|
| 454 |
|
| 455 |
-
sem = asyncio.Semaphore(
|
| 456 |
sample_list = list(sample.iterrows())
|
| 457 |
total = len(sample_list)
|
| 458 |
completed_count = 0
|
|
@@ -469,11 +468,14 @@ async def run_benchmark(dataset, embedding_model, chunk_label, retrieval_strateg
|
|
| 469 |
reference = row.get("response", "") or None
|
| 470 |
|
| 471 |
async with sem:
|
| 472 |
-
result = await pipeline.aanswer(question, trace=use_judge)
|
| 473 |
|
| 474 |
if use_judge:
|
| 475 |
try:
|
| 476 |
-
scores = await score_result_with_judge_async(
|
|
|
|
|
|
|
|
|
|
| 477 |
log.info("[benchmark] row=%s adh=%s rel=%s util=%s comp=%s",
|
| 478 |
i, _fmt(scores['adherence']), _fmt(scores['relevance']),
|
| 479 |
_fmt(scores['utilization']), _fmt(scores['completeness']))
|
|
@@ -834,7 +836,7 @@ with gr.Blocks(title="Modular RAG Explorer", css=_CSS) as demo:
|
|
| 834 |
llm_dd = gr.Dropdown(
|
| 835 |
choices=_opt_choices("llm_models"),
|
| 836 |
value=RUNTIME_OPTIONS["llm_models"][0]["id"],
|
| 837 |
-
label="
|
| 838 |
)
|
| 839 |
|
| 840 |
# ── 5. Evaluation ─────────────────────────────────────────────
|
|
@@ -845,8 +847,19 @@ with gr.Blocks(title="Modular RAG Explorer", css=_CSS) as demo:
|
|
| 845 |
label="Method",
|
| 846 |
info=(
|
| 847 |
"Local NLI: free, on-device. "
|
| 848 |
-
"
|
| 849 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 850 |
),
|
| 851 |
)
|
| 852 |
|
|
@@ -919,7 +932,15 @@ with gr.Blocks(title="Modular RAG Explorer", css=_CSS) as demo:
|
|
| 919 |
with gr.Tab("Benchmark"):
|
| 920 |
benchmark_state = gr.State(None)
|
| 921 |
|
| 922 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 923 |
run_bench_btn = gr.Button("Run Benchmark", variant="primary")
|
| 924 |
|
| 925 |
summary_df = gr.Dataframe(label="Mean predicted vs gold scores per metric")
|
|
@@ -1057,7 +1078,7 @@ with gr.Blocks(title="Modular RAG Explorer", css=_CSS) as demo:
|
|
| 1057 |
inputs=[
|
| 1058 |
dataset_dd, embedding_dd, chunk_dd, retrieval_dd, rewrite_dd, n_variants_sl,
|
| 1059 |
reranker_dd, llm_dd, dense_k_sl, bm25_k_sl, fusion_top_k_sl, top_n_sl,
|
| 1060 |
-
question_tb, trace_cb, reference_state, gold_scores_state, eval_method_dd,
|
| 1061 |
],
|
| 1062 |
outputs=[
|
| 1063 |
answer_box,
|
|
@@ -1075,7 +1096,7 @@ with gr.Blocks(title="Modular RAG Explorer", css=_CSS) as demo:
|
|
| 1075 |
inputs=[
|
| 1076 |
dataset_dd, embedding_dd, chunk_dd, retrieval_dd, rewrite_dd, n_variants_sl,
|
| 1077 |
reranker_dd, llm_dd, dense_k_sl, bm25_k_sl, fusion_top_k_sl, top_n_sl,
|
| 1078 |
-
n_samples_dd, eval_method_dd,
|
| 1079 |
],
|
| 1080 |
outputs=[summary_df, per_q_df, stats_df_out, bench_bar, benchmark_state],
|
| 1081 |
)
|
|
|
|
| 77 |
("Local NLI (free, no API key needed)", "local_nli"),
|
| 78 |
("Groq Judge – LLaMA-70B (needs GROQ_API_KEY)", "groq"),
|
| 79 |
("OpenAI Judge – GPT-4o-mini (needs OPENAI_API_KEY)", "openai"),
|
| 80 |
+
("OpenAI GPT-4o Judge – highest quality (needs OPENAI_API_KEY)", "openai_gpt4"),
|
| 81 |
]
|
| 82 |
|
| 83 |
|
|
|
|
| 291 |
|
| 292 |
def run_single_query(dataset, embedding_model, chunk_label, retrieval_strategy, query_rewrite,
|
| 293 |
n_variants, reranker_choice, llm_model, dense_k, bm25_k, fusion_top_k, top_n,
|
| 294 |
+
question, trace_on, reference, gold_scores, eval_method, use_threshold):
|
| 295 |
if not question or not question.strip():
|
| 296 |
raise gr.Error("Please enter a question.")
|
| 297 |
|
| 298 |
pipeline, _ = get_pipeline(dataset, embedding_model, chunk_label, reranker_choice, retrieval_strategy,
|
| 299 |
query_rewrite, n_variants, llm_model, dense_k, bm25_k, fusion_top_k, top_n)
|
| 300 |
|
| 301 |
+
use_judge = eval_method in ("groq", "openai", "openai_gpt4")
|
|
|
|
| 302 |
run_trace = bool(trace_on) or use_judge
|
| 303 |
+
use_judge_relevance = use_judge and not use_threshold
|
| 304 |
|
| 305 |
+
result = pipeline.answer(question, trace=run_trace, use_threshold=bool(use_threshold))
|
| 306 |
|
| 307 |
eval_trace = None
|
| 308 |
if use_judge:
|
| 309 |
try:
|
| 310 |
+
scores, eval_trace = score_result_with_judge_traced(
|
| 311 |
+
result, question, backend=eval_method,
|
| 312 |
+
use_judge_relevance=use_judge_relevance,
|
| 313 |
+
)
|
| 314 |
except Exception as exc:
|
| 315 |
raise gr.Error(f"Judge evaluation failed: {exc}")
|
| 316 |
else:
|
|
|
|
| 415 |
|
| 416 |
# -- Benchmark tab ------------------------------------------------------------
|
| 417 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 418 |
async def run_benchmark(dataset, embedding_model, chunk_label, retrieval_strategy, query_rewrite,
|
| 419 |
n_variants, reranker_choice, llm_model, dense_k, bm25_k, fusion_top_k, top_n,
|
| 420 |
+
n_samples, eval_method, concurrency, use_threshold, progress=gr.Progress()):
|
| 421 |
pipeline, entry = get_pipeline(dataset, embedding_model, chunk_label, reranker_choice, retrieval_strategy,
|
| 422 |
query_rewrite, n_variants, llm_model, dense_k, bm25_k, fusion_top_k, top_n)
|
| 423 |
import random as _rng
|
|
|
|
| 441 |
cached = cache.get_cached_rows(results_df, chash)
|
| 442 |
cached_idx = set(cached["row_index"].tolist())
|
| 443 |
|
| 444 |
+
use_judge = eval_method in ("groq", "openai", "openai_gpt4")
|
| 445 |
+
use_judge_relevance = use_judge and not use_threshold
|
| 446 |
|
| 447 |
def _safe_float(series_row, col):
|
| 448 |
val = series_row.get(col, float("nan"))
|
|
|
|
| 451 |
def _fmt(v):
|
| 452 |
return "N/A" if (v is None or (isinstance(v, float) and math.isnan(v))) else f"{v:.3f}"
|
| 453 |
|
| 454 |
+
sem = asyncio.Semaphore(int(concurrency))
|
| 455 |
sample_list = list(sample.iterrows())
|
| 456 |
total = len(sample_list)
|
| 457 |
completed_count = 0
|
|
|
|
| 468 |
reference = row.get("response", "") or None
|
| 469 |
|
| 470 |
async with sem:
|
| 471 |
+
result = await pipeline.aanswer(question, trace=use_judge, use_threshold=bool(use_threshold))
|
| 472 |
|
| 473 |
if use_judge:
|
| 474 |
try:
|
| 475 |
+
scores = await score_result_with_judge_async(
|
| 476 |
+
result, question, backend=eval_method,
|
| 477 |
+
use_judge_relevance=use_judge_relevance,
|
| 478 |
+
)
|
| 479 |
log.info("[benchmark] row=%s adh=%s rel=%s util=%s comp=%s",
|
| 480 |
i, _fmt(scores['adherence']), _fmt(scores['relevance']),
|
| 481 |
_fmt(scores['utilization']), _fmt(scores['completeness']))
|
|
|
|
| 836 |
llm_dd = gr.Dropdown(
|
| 837 |
choices=_opt_choices("llm_models"),
|
| 838 |
value=RUNTIME_OPTIONS["llm_models"][0]["id"],
|
| 839 |
+
label="Generation LLM",
|
| 840 |
)
|
| 841 |
|
| 842 |
# ── 5. Evaluation ─────────────────────────────────────────────
|
|
|
|
| 847 |
label="Method",
|
| 848 |
info=(
|
| 849 |
"Local NLI: free, on-device. "
|
| 850 |
+
"LLM Judge: uses the RAGBench TRACe prompt (section 7.4) "
|
| 851 |
+
"to evaluate all four metrics in one call. "
|
| 852 |
+
"Requires the corresponding API key as a Space secret."
|
| 853 |
+
),
|
| 854 |
+
)
|
| 855 |
+
threshold_cb = gr.Checkbox(
|
| 856 |
+
label="Cross-encoder rejection gate",
|
| 857 |
+
value=True,
|
| 858 |
+
info=(
|
| 859 |
+
"When checked: reject queries whose cross-encoder relevance "
|
| 860 |
+
"falls below 0.3 and skip LLM generation (current behaviour). "
|
| 861 |
+
"When unchecked (RAGBench mode): always generate — the judge "
|
| 862 |
+
"computes relevance retrospectively from sentence keys."
|
| 863 |
),
|
| 864 |
)
|
| 865 |
|
|
|
|
| 932 |
with gr.Tab("Benchmark"):
|
| 933 |
benchmark_state = gr.State(None)
|
| 934 |
|
| 935 |
+
with gr.Row():
|
| 936 |
+
n_samples_dd = gr.Dropdown(
|
| 937 |
+
choices=[5, 10, 25, 50, 100], value=10, label="Sample size",
|
| 938 |
+
)
|
| 939 |
+
concurrency_dd = gr.Dropdown(
|
| 940 |
+
choices=[1, 2, 3, 5, 10, 15, 20], value=5,
|
| 941 |
+
label="Concurrency",
|
| 942 |
+
info="Max parallel LLM calls. 5 is safe for Groq free tier (~30 RPM); raise for paid tiers.",
|
| 943 |
+
)
|
| 944 |
run_bench_btn = gr.Button("Run Benchmark", variant="primary")
|
| 945 |
|
| 946 |
summary_df = gr.Dataframe(label="Mean predicted vs gold scores per metric")
|
|
|
|
| 1078 |
inputs=[
|
| 1079 |
dataset_dd, embedding_dd, chunk_dd, retrieval_dd, rewrite_dd, n_variants_sl,
|
| 1080 |
reranker_dd, llm_dd, dense_k_sl, bm25_k_sl, fusion_top_k_sl, top_n_sl,
|
| 1081 |
+
question_tb, trace_cb, reference_state, gold_scores_state, eval_method_dd, threshold_cb,
|
| 1082 |
],
|
| 1083 |
outputs=[
|
| 1084 |
answer_box,
|
|
|
|
| 1096 |
inputs=[
|
| 1097 |
dataset_dd, embedding_dd, chunk_dd, retrieval_dd, rewrite_dd, n_variants_sl,
|
| 1098 |
reranker_dd, llm_dd, dense_k_sl, bm25_k_sl, fusion_top_k_sl, top_n_sl,
|
| 1099 |
+
n_samples_dd, eval_method_dd, concurrency_dd, threshold_cb,
|
| 1100 |
],
|
| 1101 |
outputs=[summary_df, per_q_df, stats_df_out, bench_bar, benchmark_state],
|
| 1102 |
)
|
app/scoring.py
CHANGED
|
@@ -76,13 +76,20 @@ async def score_result_with_judge_async(
|
|
| 76 |
result: dict,
|
| 77 |
question: str,
|
| 78 |
backend: str,
|
|
|
|
| 79 |
) -> dict:
|
| 80 |
-
"""Async version of score_result_with_judge()
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
|
| 83 |
if result.get("rejected") or not result["context"].strip():
|
| 84 |
return {
|
| 85 |
-
"relevance":
|
| 86 |
"adherence": math.nan,
|
| 87 |
"completeness": math.nan,
|
| 88 |
"utilization": math.nan,
|
|
@@ -93,7 +100,8 @@ async def score_result_with_judge_async(
|
|
| 93 |
|
| 94 |
judge = get_llm_judge(backend)
|
| 95 |
scores = await judge.ascore(question, docs_texts, result["answer"])
|
| 96 |
-
|
|
|
|
| 97 |
return scores
|
| 98 |
|
| 99 |
|
|
@@ -101,18 +109,18 @@ def score_result_with_judge(
|
|
| 101 |
result: dict,
|
| 102 |
question: str,
|
| 103 |
backend: str,
|
|
|
|
| 104 |
) -> dict:
|
| 105 |
-
"""Score via LLM-as-Judge (Groq or
|
| 106 |
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
The cross-encoder relevance from the pipeline is preserved.
|
| 110 |
"""
|
| 111 |
-
|
| 112 |
|
| 113 |
if result.get("rejected") or not result["context"].strip():
|
| 114 |
return {
|
| 115 |
-
"relevance":
|
| 116 |
"adherence": math.nan,
|
| 117 |
"completeness": math.nan,
|
| 118 |
"utilization": math.nan,
|
|
@@ -123,7 +131,8 @@ def score_result_with_judge(
|
|
| 123 |
|
| 124 |
judge = get_llm_judge(backend)
|
| 125 |
scores = judge.score(question, docs_texts, result["answer"])
|
| 126 |
-
|
|
|
|
| 127 |
return scores
|
| 128 |
|
| 129 |
|
|
@@ -167,21 +176,27 @@ def score_result_traced(result: dict, reference: str = None) -> tuple[dict, dict
|
|
| 167 |
return scores, trace
|
| 168 |
|
| 169 |
|
| 170 |
-
def score_result_with_judge_traced(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
"""score_result_with_judge() with full judge prompt/response returned as a second value."""
|
| 172 |
-
|
| 173 |
|
| 174 |
if result.get("rejected") or not result["context"].strip():
|
| 175 |
-
scores = {"relevance":
|
| 176 |
"completeness": math.nan, "utilization": math.nan}
|
| 177 |
return scores, {"method": backend, "rejected": True}
|
| 178 |
|
| 179 |
-
expansions
|
| 180 |
-
docs_texts
|
| 181 |
|
| 182 |
-
judge
|
| 183 |
scores, judge_trace = judge.score_traced(question, docs_texts, result["answer"])
|
| 184 |
-
|
|
|
|
| 185 |
judge_trace["method"] = backend
|
| 186 |
return scores, judge_trace
|
| 187 |
|
|
|
|
| 76 |
result: dict,
|
| 77 |
question: str,
|
| 78 |
backend: str,
|
| 79 |
+
use_judge_relevance: bool = False,
|
| 80 |
) -> dict:
|
| 81 |
+
"""Async version of score_result_with_judge().
|
| 82 |
+
|
| 83 |
+
use_judge_relevance=True (threshold checkbox unchecked / RAGBench mode):
|
| 84 |
+
relevance = fraction of doc sentences the judge marks as relevant (TRACe eq. 2).
|
| 85 |
+
use_judge_relevance=False (threshold checkbox checked / default):
|
| 86 |
+
relevance = cross-encoder sigmoid score (preserved from pipeline).
|
| 87 |
+
"""
|
| 88 |
+
cross_encoder_relevance = result["relevance"]
|
| 89 |
|
| 90 |
if result.get("rejected") or not result["context"].strip():
|
| 91 |
return {
|
| 92 |
+
"relevance": cross_encoder_relevance,
|
| 93 |
"adherence": math.nan,
|
| 94 |
"completeness": math.nan,
|
| 95 |
"utilization": math.nan,
|
|
|
|
| 100 |
|
| 101 |
judge = get_llm_judge(backend)
|
| 102 |
scores = await judge.ascore(question, docs_texts, result["answer"])
|
| 103 |
+
if not use_judge_relevance:
|
| 104 |
+
scores["relevance"] = cross_encoder_relevance
|
| 105 |
return scores
|
| 106 |
|
| 107 |
|
|
|
|
| 109 |
result: dict,
|
| 110 |
question: str,
|
| 111 |
backend: str,
|
| 112 |
+
use_judge_relevance: bool = False,
|
| 113 |
) -> dict:
|
| 114 |
+
"""Score via LLM-as-Judge (Groq, OpenAI, or GPT-4o).
|
| 115 |
|
| 116 |
+
use_judge_relevance=True (RAGBench mode): relevance from judge sentence keys.
|
| 117 |
+
use_judge_relevance=False (default): relevance from cross-encoder (preserved).
|
|
|
|
| 118 |
"""
|
| 119 |
+
cross_encoder_relevance = result["relevance"]
|
| 120 |
|
| 121 |
if result.get("rejected") or not result["context"].strip():
|
| 122 |
return {
|
| 123 |
+
"relevance": cross_encoder_relevance,
|
| 124 |
"adherence": math.nan,
|
| 125 |
"completeness": math.nan,
|
| 126 |
"utilization": math.nan,
|
|
|
|
| 131 |
|
| 132 |
judge = get_llm_judge(backend)
|
| 133 |
scores = judge.score(question, docs_texts, result["answer"])
|
| 134 |
+
if not use_judge_relevance:
|
| 135 |
+
scores["relevance"] = cross_encoder_relevance
|
| 136 |
return scores
|
| 137 |
|
| 138 |
|
|
|
|
| 176 |
return scores, trace
|
| 177 |
|
| 178 |
|
| 179 |
+
def score_result_with_judge_traced(
|
| 180 |
+
result: dict,
|
| 181 |
+
question: str,
|
| 182 |
+
backend: str,
|
| 183 |
+
use_judge_relevance: bool = False,
|
| 184 |
+
) -> tuple[dict, dict]:
|
| 185 |
"""score_result_with_judge() with full judge prompt/response returned as a second value."""
|
| 186 |
+
cross_encoder_relevance = result["relevance"]
|
| 187 |
|
| 188 |
if result.get("rejected") or not result["context"].strip():
|
| 189 |
+
scores = {"relevance": cross_encoder_relevance, "adherence": math.nan,
|
| 190 |
"completeness": math.nan, "utilization": math.nan}
|
| 191 |
return scores, {"method": backend, "rejected": True}
|
| 192 |
|
| 193 |
+
expansions = result.get("trace", {}).get("expansions", [])
|
| 194 |
+
docs_texts = [e["text"] for e in expansions] if expansions else [result["context"]]
|
| 195 |
|
| 196 |
+
judge = get_llm_judge(backend)
|
| 197 |
scores, judge_trace = judge.score_traced(question, docs_texts, result["answer"])
|
| 198 |
+
if not use_judge_relevance:
|
| 199 |
+
scores["relevance"] = cross_encoder_relevance
|
| 200 |
judge_trace["method"] = backend
|
| 201 |
return scores, judge_trace
|
| 202 |
|
src/evaluation/llm_judge.py
CHANGED
|
@@ -1,23 +1,24 @@
|
|
| 1 |
"""LLM-as-Judge evaluation for RAG quality metrics.
|
| 2 |
|
| 3 |
-
|
| 4 |
- Splits retrieved docs and the generated answer into sentence-keyed strings.
|
| 5 |
-
- Sends a structured prompt to an LLM judge
|
| 6 |
-
|
| 7 |
-
- Validates the response against a Pydantic schema — missing fields get safe
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
Supported backends
|
| 12 |
-
"groq"
|
| 13 |
-
"openai"
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
|
|
|
| 21 |
"""
|
| 22 |
|
| 23 |
from typing import Literal
|
|
@@ -26,69 +27,121 @@ import numpy as np
|
|
| 26 |
from litellm import acompletion, completion
|
| 27 |
from pydantic import BaseModel, Field
|
| 28 |
|
| 29 |
-
from src import settings
|
| 30 |
from src.generation.llm_client import _sync_api_keys
|
| 31 |
from src.reranking.reranker import split_sentences
|
| 32 |
|
| 33 |
# ---------------------------------------------------------------------------
|
| 34 |
-
# Pydantic schema
|
| 35 |
# ---------------------------------------------------------------------------
|
| 36 |
|
| 37 |
class _SentenceSupport(BaseModel):
|
| 38 |
response_sentence_key: str = ""
|
|
|
|
| 39 |
supporting_sentence_keys: list[str] = Field(default_factory=list)
|
| 40 |
fully_supported: bool = False
|
| 41 |
|
| 42 |
|
| 43 |
class JudgeOutput(BaseModel):
|
| 44 |
-
|
| 45 |
all_relevant_sentence_keys: list[str] = Field(default_factory=list)
|
| 46 |
-
|
|
|
|
| 47 |
sentence_support_information: list[_SentenceSupport] = Field(default_factory=list)
|
|
|
|
| 48 |
|
| 49 |
|
| 50 |
# ---------------------------------------------------------------------------
|
| 51 |
-
#
|
| 52 |
# ---------------------------------------------------------------------------
|
| 53 |
|
| 54 |
_SYSTEM_PROMPT = (
|
| 55 |
"You are a strict RAG evaluation judge. "
|
| 56 |
-
"Respond with a JSON object only
|
|
|
|
| 57 |
)
|
| 58 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
_EVAL_PROMPT = """\
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
|
| 62 |
-
|
| 63 |
{documents_text}
|
|
|
|
| 64 |
|
| 65 |
-
|
|
|
|
| 66 |
{question}
|
|
|
|
| 67 |
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
{answer_text}
|
|
|
|
| 70 |
|
| 71 |
-
JSON
|
| 72 |
|
|
|
|
| 73 |
{{
|
| 74 |
-
"
|
| 75 |
-
"all_relevant_sentence_keys": [
|
| 76 |
-
"
|
|
|
|
| 77 |
"sentence_support_information": [
|
| 78 |
{{
|
| 79 |
-
"response_sentence_key":
|
| 80 |
-
"
|
| 81 |
-
"
|
|
|
|
| 82 |
}}
|
| 83 |
-
]
|
|
|
|
| 84 |
}}
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
"""
|
| 93 |
|
| 94 |
|
|
@@ -111,7 +164,7 @@ def build_keyed_response(answer: str) -> dict[str, str]:
|
|
| 111 |
|
| 112 |
|
| 113 |
def _make_eval_prompt(keyed_docs: dict, question: str, keyed_answer: dict) -> str:
|
| 114 |
-
docs_text
|
| 115 |
answer_text = "\n".join(f"{k}. {v}" for k, v in keyed_answer.items())
|
| 116 |
return _EVAL_PROMPT.format(
|
| 117 |
documents_text=docs_text,
|
|
@@ -121,15 +174,21 @@ def _make_eval_prompt(keyed_docs: dict, question: str, keyed_answer: dict) -> st
|
|
| 121 |
|
| 122 |
|
| 123 |
# ---------------------------------------------------------------------------
|
| 124 |
-
# Metric computation
|
| 125 |
# ---------------------------------------------------------------------------
|
| 126 |
|
| 127 |
def compute_judge_scores(judge: JudgeOutput, keyed_docs: dict) -> dict:
|
| 128 |
-
"""Derive [0,1] metric scores from a validated JudgeOutput.
|
| 129 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
relevant = set(judge.all_relevant_sentence_keys) & valid
|
| 131 |
utilized = set(judge.all_utilized_sentence_keys) & valid
|
| 132 |
-
total
|
| 133 |
|
| 134 |
relevance = len(relevant) / total if total else 0.0
|
| 135 |
utilization = len(utilized) / total if total else 0.0
|
|
@@ -149,13 +208,17 @@ def compute_judge_scores(judge: JudgeOutput, keyed_docs: dict) -> dict:
|
|
| 149 |
# ---------------------------------------------------------------------------
|
| 150 |
|
| 151 |
_BACKEND_MODELS = {
|
| 152 |
-
"groq":
|
| 153 |
-
"openai":
|
|
|
|
| 154 |
}
|
| 155 |
|
| 156 |
_ZERO_SCORES = {"adherence": 0.0, "relevance": 0.0, "utilization": 0.0, "completeness": 0.0}
|
| 157 |
-
_EMPTY_TRACE = {
|
| 158 |
-
|
|
|
|
|
|
|
|
|
|
| 159 |
|
| 160 |
|
| 161 |
# ---------------------------------------------------------------------------
|
|
@@ -163,30 +226,29 @@ _EMPTY_TRACE = {"prompt": "", "raw_response": "", "judge": None,
|
|
| 163 |
# ---------------------------------------------------------------------------
|
| 164 |
|
| 165 |
class LlmJudge:
|
| 166 |
-
"""LLM-as-Judge scorer
|
| 167 |
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
|
| 173 |
-
|
| 174 |
-
(
|
| 175 |
"""
|
| 176 |
|
| 177 |
def __init__(
|
| 178 |
self,
|
| 179 |
-
backend: Literal["groq", "openai"] | str = "groq",
|
| 180 |
model: str | None = None,
|
| 181 |
):
|
| 182 |
self.backend = backend
|
| 183 |
-
self.model
|
| 184 |
_sync_api_keys()
|
| 185 |
|
| 186 |
# -- sync -----------------------------------------------------------------
|
| 187 |
|
| 188 |
def _call_llm(self, prompt: str) -> str:
|
| 189 |
-
"""Sync LLM call with JSON mode. Returns raw JSON string."""
|
| 190 |
resp = completion(
|
| 191 |
model=self.model,
|
| 192 |
messages=[
|
|
@@ -194,53 +256,46 @@ class LlmJudge:
|
|
| 194 |
{"role": "user", "content": prompt},
|
| 195 |
],
|
| 196 |
temperature=0.0,
|
| 197 |
-
max_tokens=
|
| 198 |
response_format={"type": "json_object"},
|
| 199 |
num_retries=3,
|
| 200 |
)
|
| 201 |
return resp.choices[0].message.content
|
| 202 |
|
| 203 |
def score(self, question: str, docs_texts: list[str], answer: str) -> dict:
|
| 204 |
-
"""Run judge and return metric scores dict."""
|
| 205 |
keyed_docs = build_sentence_keyed_docs(docs_texts)
|
| 206 |
keyed_answer = build_keyed_response(answer)
|
| 207 |
-
|
| 208 |
if not keyed_docs:
|
| 209 |
return dict(_ZERO_SCORES)
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
raw = self._call_llm(prompt)
|
| 213 |
-
judge = JudgeOutput.model_validate_json(raw)
|
| 214 |
return compute_judge_scores(judge, keyed_docs)
|
| 215 |
|
| 216 |
def score_traced(self, question: str, docs_texts: list[str], answer: str) -> tuple[dict, dict]:
|
| 217 |
-
"""Same as score() but also returns a trace dict."""
|
| 218 |
keyed_docs = build_sentence_keyed_docs(docs_texts)
|
| 219 |
keyed_answer = build_keyed_response(answer)
|
| 220 |
-
|
| 221 |
if not keyed_docs:
|
| 222 |
return dict(_ZERO_SCORES), dict(_EMPTY_TRACE)
|
| 223 |
-
|
| 224 |
prompt = _make_eval_prompt(keyed_docs, question, keyed_answer)
|
| 225 |
raw = self._call_llm(prompt)
|
| 226 |
judge = JudgeOutput.model_validate_json(raw)
|
| 227 |
scores = compute_judge_scores(judge, keyed_docs)
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
"
|
| 232 |
-
"
|
| 233 |
-
"
|
| 234 |
-
"
|
| 235 |
-
"
|
| 236 |
-
"
|
|
|
|
| 237 |
}
|
| 238 |
return scores, trace
|
| 239 |
|
| 240 |
# -- async ----------------------------------------------------------------
|
| 241 |
|
| 242 |
async def _acall_llm(self, prompt: str) -> str:
|
| 243 |
-
"""Async LLM call with JSON mode. Returns raw JSON string."""
|
| 244 |
resp = await acompletion(
|
| 245 |
model=self.model,
|
| 246 |
messages=[
|
|
@@ -248,45 +303,39 @@ class LlmJudge:
|
|
| 248 |
{"role": "user", "content": prompt},
|
| 249 |
],
|
| 250 |
temperature=0.0,
|
| 251 |
-
max_tokens=
|
| 252 |
response_format={"type": "json_object"},
|
| 253 |
num_retries=3,
|
| 254 |
)
|
| 255 |
return resp.choices[0].message.content
|
| 256 |
|
| 257 |
async def ascore(self, question: str, docs_texts: list[str], answer: str) -> dict:
|
| 258 |
-
"""Async version of score() — use from the benchmark pipeline."""
|
| 259 |
keyed_docs = build_sentence_keyed_docs(docs_texts)
|
| 260 |
keyed_answer = build_keyed_response(answer)
|
| 261 |
-
|
| 262 |
if not keyed_docs:
|
| 263 |
return dict(_ZERO_SCORES)
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
raw = await self._acall_llm(prompt)
|
| 267 |
-
judge = JudgeOutput.model_validate_json(raw)
|
| 268 |
return compute_judge_scores(judge, keyed_docs)
|
| 269 |
|
| 270 |
async def ascore_traced(self, question: str, docs_texts: list[str], answer: str) -> tuple[dict, dict]:
|
| 271 |
-
"""Async version of score_traced()."""
|
| 272 |
keyed_docs = build_sentence_keyed_docs(docs_texts)
|
| 273 |
keyed_answer = build_keyed_response(answer)
|
| 274 |
-
|
| 275 |
if not keyed_docs:
|
| 276 |
return dict(_ZERO_SCORES), dict(_EMPTY_TRACE)
|
| 277 |
-
|
| 278 |
prompt = _make_eval_prompt(keyed_docs, question, keyed_answer)
|
| 279 |
raw = await self._acall_llm(prompt)
|
| 280 |
judge = JudgeOutput.model_validate_json(raw)
|
| 281 |
scores = compute_judge_scores(judge, keyed_docs)
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
"
|
| 286 |
-
"
|
| 287 |
-
"
|
| 288 |
-
"
|
| 289 |
-
"
|
| 290 |
-
"
|
|
|
|
| 291 |
}
|
| 292 |
return scores, trace
|
|
|
|
| 1 |
"""LLM-as-Judge evaluation for RAG quality metrics.
|
| 2 |
|
| 3 |
+
Implements the TRACe evaluation framework from RAGBench (Friel et al., 2025):
|
| 4 |
- Splits retrieved docs and the generated answer into sentence-keyed strings.
|
| 5 |
+
- Sends a structured prompt to an LLM judge (section 7.4 of the paper) that asks
|
| 6 |
+
the model to identify relevant, utilized, and supported sentences.
|
| 7 |
+
- Validates the response against a Pydantic schema — missing fields get safe defaults.
|
| 8 |
+
- Derives adherence, relevance, utilization, and completeness on [0, 1] using the
|
| 9 |
+
TRACe equations (paper section 3.2).
|
| 10 |
+
|
| 11 |
+
Supported backends:
|
| 12 |
+
"groq" → groq/llama-3.3-70b-versatile
|
| 13 |
+
"openai" → openai/gpt-4o-mini (cost-efficient judge)
|
| 14 |
+
"openai_gpt4" → openai/gpt-4o (highest quality, closest to paper)
|
| 15 |
+
Any "/" string is treated as a raw LiteLLM model string.
|
| 16 |
+
|
| 17 |
+
Metric definitions (RAGBench-compatible):
|
| 18 |
+
relevance : Σ Len(Rᵢ) / Σ Len(dᵢ) — fraction of doc sentences that are relevant.
|
| 19 |
+
utilization : Σ Len(Uᵢ) / Σ Len(dᵢ) — fraction of doc sentences that were used.
|
| 20 |
+
completeness: Len(R ∩ U) / Len(R) — fraction of relevant sentences that were used.
|
| 21 |
+
adherence : 1 if overall_supported else 0.
|
| 22 |
"""
|
| 23 |
|
| 24 |
from typing import Literal
|
|
|
|
| 27 |
from litellm import acompletion, completion
|
| 28 |
from pydantic import BaseModel, Field
|
| 29 |
|
|
|
|
| 30 |
from src.generation.llm_client import _sync_api_keys
|
| 31 |
from src.reranking.reranker import split_sentences
|
| 32 |
|
| 33 |
# ---------------------------------------------------------------------------
|
| 34 |
+
# Pydantic schema — matches RAGBench section 7.4 JSON structure
|
| 35 |
# ---------------------------------------------------------------------------
|
| 36 |
|
| 37 |
class _SentenceSupport(BaseModel):
|
| 38 |
response_sentence_key: str = ""
|
| 39 |
+
explanation: str = ""
|
| 40 |
supporting_sentence_keys: list[str] = Field(default_factory=list)
|
| 41 |
fully_supported: bool = False
|
| 42 |
|
| 43 |
|
| 44 |
class JudgeOutput(BaseModel):
|
| 45 |
+
relevance_explanation: str = ""
|
| 46 |
all_relevant_sentence_keys: list[str] = Field(default_factory=list)
|
| 47 |
+
overall_supported_explanation: str = ""
|
| 48 |
+
overall_supported: bool = False
|
| 49 |
sentence_support_information: list[_SentenceSupport] = Field(default_factory=list)
|
| 50 |
+
all_utilized_sentence_keys: list[str] = Field(default_factory=list)
|
| 51 |
|
| 52 |
|
| 53 |
# ---------------------------------------------------------------------------
|
| 54 |
+
# System prompt
|
| 55 |
# ---------------------------------------------------------------------------
|
| 56 |
|
| 57 |
_SYSTEM_PROMPT = (
|
| 58 |
"You are a strict RAG evaluation judge. "
|
| 59 |
+
"Respond with a valid JSON object only — no text before or after. "
|
| 60 |
+
"Use only the sentence keys provided in the prompt."
|
| 61 |
)
|
| 62 |
|
| 63 |
+
# ---------------------------------------------------------------------------
|
| 64 |
+
# Evaluation prompt — aligned with RAGBench Appendix 7.4
|
| 65 |
+
# ---------------------------------------------------------------------------
|
| 66 |
+
|
| 67 |
_EVAL_PROMPT = """\
|
| 68 |
+
I asked someone to answer a question based on one or more documents.
|
| 69 |
+
Your task is to review their response and assess whether or not each sentence \
|
| 70 |
+
in that response is supported by text in the documents. And if so, which \
|
| 71 |
+
sentences in the documents provide that support. You will also tell me which \
|
| 72 |
+
of the documents contain useful information for answering the question, and \
|
| 73 |
+
which of the documents the answer was sourced from.
|
| 74 |
+
|
| 75 |
+
Here are the documents, each split into sentences. Alongside each sentence \
|
| 76 |
+
is an associated key (e.g. '0_0', '0_1', '1_0') that you can use to refer to it:
|
| 77 |
|
| 78 |
+
```
|
| 79 |
{documents_text}
|
| 80 |
+
```
|
| 81 |
|
| 82 |
+
The question was:
|
| 83 |
+
```
|
| 84 |
{question}
|
| 85 |
+
```
|
| 86 |
|
| 87 |
+
Here is the response, split into sentences. Alongside each sentence is an \
|
| 88 |
+
associated key (e.g. 'r_0', 'r_1') that you can use to refer to it. \
|
| 89 |
+
Note that these keys are unique to the response and are not related to the document keys:
|
| 90 |
+
|
| 91 |
+
```
|
| 92 |
{answer_text}
|
| 93 |
+
```
|
| 94 |
|
| 95 |
+
You must respond with a JSON object matching this schema:
|
| 96 |
|
| 97 |
+
```
|
| 98 |
{{
|
| 99 |
+
"relevance_explanation": string,
|
| 100 |
+
"all_relevant_sentence_keys": [string],
|
| 101 |
+
"overall_supported_explanation": string,
|
| 102 |
+
"overall_supported": boolean,
|
| 103 |
"sentence_support_information": [
|
| 104 |
{{
|
| 105 |
+
"response_sentence_key": string,
|
| 106 |
+
"explanation": string,
|
| 107 |
+
"supporting_sentence_keys": [string],
|
| 108 |
+
"fully_supported": boolean
|
| 109 |
}}
|
| 110 |
+
],
|
| 111 |
+
"all_utilized_sentence_keys": [string]
|
| 112 |
}}
|
| 113 |
+
```
|
| 114 |
+
|
| 115 |
+
Field definitions:
|
| 116 |
+
|
| 117 |
+
relevance_explanation: Step-by-step breakdown of which document sentences contain \
|
| 118 |
+
useful information for answering the question and why.
|
| 119 |
+
|
| 120 |
+
all_relevant_sentence_keys: List of all document sentence keys (e.g. '0_0') that \
|
| 121 |
+
are relevant to the question. Include every sentence that is useful for answering, \
|
| 122 |
+
even if it was not used in the response or only parts are useful. \
|
| 123 |
+
Base this solely on the documents and question — ignore the response. \
|
| 124 |
+
Omit sentences that would not impact someone's ability to answer the question.
|
| 125 |
+
|
| 126 |
+
overall_supported_explanation: Step-by-step breakdown assessing whether the response \
|
| 127 |
+
as a whole is supported by the documents. Assess each claim separately before \
|
| 128 |
+
drawing an overall conclusion.
|
| 129 |
+
|
| 130 |
+
overall_supported: Boolean — true if the response as a whole is supported by the \
|
| 131 |
+
documents. Reflects the conclusion of overall_supported_explanation.
|
| 132 |
+
|
| 133 |
+
sentence_support_information: One object per response sentence:
|
| 134 |
+
- response_sentence_key: the key used above (e.g. 'r_0').
|
| 135 |
+
- explanation: why the sentence is or is not supported.
|
| 136 |
+
- supporting_sentence_keys: document sentence keys that support this response \
|
| 137 |
+
sentence. Must be empty if unsupported. Special values allowed: \
|
| 138 |
+
"supported_without_sentence" (generally supported but no specific sentence), \
|
| 139 |
+
"general" (general/transition statement), "well_known_fact", "numerical_reasoning".
|
| 140 |
+
- fully_supported: true only if the sentence is completely grounded in the documents.
|
| 141 |
+
|
| 142 |
+
all_utilized_sentence_keys: Document sentence keys that the response drew from. \
|
| 143 |
+
Include every sentence that either directly supported or was implicitly used to \
|
| 144 |
+
construct the answer. Omit sentences that were not used.\
|
| 145 |
"""
|
| 146 |
|
| 147 |
|
|
|
|
| 164 |
|
| 165 |
|
| 166 |
def _make_eval_prompt(keyed_docs: dict, question: str, keyed_answer: dict) -> str:
|
| 167 |
+
docs_text = "\n".join(f"{k}. {v}" for k, v in keyed_docs.items())
|
| 168 |
answer_text = "\n".join(f"{k}. {v}" for k, v in keyed_answer.items())
|
| 169 |
return _EVAL_PROMPT.format(
|
| 170 |
documents_text=docs_text,
|
|
|
|
| 174 |
|
| 175 |
|
| 176 |
# ---------------------------------------------------------------------------
|
| 177 |
+
# Metric computation — TRACe equations (paper section 3.2)
|
| 178 |
# ---------------------------------------------------------------------------
|
| 179 |
|
| 180 |
def compute_judge_scores(judge: JudgeOutput, keyed_docs: dict) -> dict:
|
| 181 |
+
"""Derive [0,1] TRACe metric scores from a validated JudgeOutput.
|
| 182 |
+
|
| 183 |
+
relevance = |Rᵢ| / |dᵢ| (RAGBench eq. 2)
|
| 184 |
+
utilization = |Uᵢ| / |dᵢ| (RAGBench eq. 3)
|
| 185 |
+
completeness= |R ∩ U| / |R| (RAGBench eq. 4)
|
| 186 |
+
adherence = overall_supported (boolean → float)
|
| 187 |
+
"""
|
| 188 |
+
valid = set(keyed_docs.keys())
|
| 189 |
relevant = set(judge.all_relevant_sentence_keys) & valid
|
| 190 |
utilized = set(judge.all_utilized_sentence_keys) & valid
|
| 191 |
+
total = len(valid)
|
| 192 |
|
| 193 |
relevance = len(relevant) / total if total else 0.0
|
| 194 |
utilization = len(utilized) / total if total else 0.0
|
|
|
|
| 208 |
# ---------------------------------------------------------------------------
|
| 209 |
|
| 210 |
_BACKEND_MODELS = {
|
| 211 |
+
"groq": "groq/llama-3.3-70b-versatile",
|
| 212 |
+
"openai": "openai/gpt-4o-mini",
|
| 213 |
+
"openai_gpt4": "openai/gpt-4o",
|
| 214 |
}
|
| 215 |
|
| 216 |
_ZERO_SCORES = {"adherence": 0.0, "relevance": 0.0, "utilization": 0.0, "completeness": 0.0}
|
| 217 |
+
_EMPTY_TRACE = {
|
| 218 |
+
"prompt": "", "raw_response": "", "judge": None,
|
| 219 |
+
"relevant_keys": [], "utilized_keys": [], "valid_keys_count": 0,
|
| 220 |
+
"relevance_explanation": "", "overall_supported_explanation": "",
|
| 221 |
+
}
|
| 222 |
|
| 223 |
|
| 224 |
# ---------------------------------------------------------------------------
|
|
|
|
| 226 |
# ---------------------------------------------------------------------------
|
| 227 |
|
| 228 |
class LlmJudge:
|
| 229 |
+
"""LLM-as-Judge scorer using the RAGBench TRACe evaluation prompt.
|
| 230 |
|
| 231 |
+
Backends:
|
| 232 |
+
"groq" — LLaMA-3.3-70B via Groq (GROQ_API_KEY)
|
| 233 |
+
"openai" — GPT-4o-mini via OpenAI (OPENAI_API_KEY)
|
| 234 |
+
"openai_gpt4" — GPT-4o via OpenAI (OPENAI_API_KEY) — closest to paper
|
| 235 |
|
| 236 |
+
Sync: score() / score_traced()
|
| 237 |
+
Async: ascore() / ascore_traced() — use from benchmark pipeline
|
| 238 |
"""
|
| 239 |
|
| 240 |
def __init__(
|
| 241 |
self,
|
| 242 |
+
backend: Literal["groq", "openai", "openai_gpt4"] | str = "groq",
|
| 243 |
model: str | None = None,
|
| 244 |
):
|
| 245 |
self.backend = backend
|
| 246 |
+
self.model = model or _BACKEND_MODELS.get(backend, backend)
|
| 247 |
_sync_api_keys()
|
| 248 |
|
| 249 |
# -- sync -----------------------------------------------------------------
|
| 250 |
|
| 251 |
def _call_llm(self, prompt: str) -> str:
|
|
|
|
| 252 |
resp = completion(
|
| 253 |
model=self.model,
|
| 254 |
messages=[
|
|
|
|
| 256 |
{"role": "user", "content": prompt},
|
| 257 |
],
|
| 258 |
temperature=0.0,
|
| 259 |
+
max_tokens=4096,
|
| 260 |
response_format={"type": "json_object"},
|
| 261 |
num_retries=3,
|
| 262 |
)
|
| 263 |
return resp.choices[0].message.content
|
| 264 |
|
| 265 |
def score(self, question: str, docs_texts: list[str], answer: str) -> dict:
|
|
|
|
| 266 |
keyed_docs = build_sentence_keyed_docs(docs_texts)
|
| 267 |
keyed_answer = build_keyed_response(answer)
|
|
|
|
| 268 |
if not keyed_docs:
|
| 269 |
return dict(_ZERO_SCORES)
|
| 270 |
+
raw = self._call_llm(_make_eval_prompt(keyed_docs, question, keyed_answer))
|
| 271 |
+
judge = JudgeOutput.model_validate_json(raw)
|
|
|
|
|
|
|
| 272 |
return compute_judge_scores(judge, keyed_docs)
|
| 273 |
|
| 274 |
def score_traced(self, question: str, docs_texts: list[str], answer: str) -> tuple[dict, dict]:
|
|
|
|
| 275 |
keyed_docs = build_sentence_keyed_docs(docs_texts)
|
| 276 |
keyed_answer = build_keyed_response(answer)
|
|
|
|
| 277 |
if not keyed_docs:
|
| 278 |
return dict(_ZERO_SCORES), dict(_EMPTY_TRACE)
|
|
|
|
| 279 |
prompt = _make_eval_prompt(keyed_docs, question, keyed_answer)
|
| 280 |
raw = self._call_llm(prompt)
|
| 281 |
judge = JudgeOutput.model_validate_json(raw)
|
| 282 |
scores = compute_judge_scores(judge, keyed_docs)
|
| 283 |
+
valid = set(keyed_docs.keys())
|
| 284 |
+
trace = {
|
| 285 |
+
"prompt": prompt,
|
| 286 |
+
"raw_response": raw,
|
| 287 |
+
"judge": judge.model_dump(),
|
| 288 |
+
"relevant_keys": sorted(set(judge.all_relevant_sentence_keys) & valid),
|
| 289 |
+
"utilized_keys": sorted(set(judge.all_utilized_sentence_keys) & valid),
|
| 290 |
+
"valid_keys_count": len(valid),
|
| 291 |
+
"relevance_explanation": judge.relevance_explanation,
|
| 292 |
+
"overall_supported_explanation": judge.overall_supported_explanation,
|
| 293 |
}
|
| 294 |
return scores, trace
|
| 295 |
|
| 296 |
# -- async ----------------------------------------------------------------
|
| 297 |
|
| 298 |
async def _acall_llm(self, prompt: str) -> str:
|
|
|
|
| 299 |
resp = await acompletion(
|
| 300 |
model=self.model,
|
| 301 |
messages=[
|
|
|
|
| 303 |
{"role": "user", "content": prompt},
|
| 304 |
],
|
| 305 |
temperature=0.0,
|
| 306 |
+
max_tokens=4096,
|
| 307 |
response_format={"type": "json_object"},
|
| 308 |
num_retries=3,
|
| 309 |
)
|
| 310 |
return resp.choices[0].message.content
|
| 311 |
|
| 312 |
async def ascore(self, question: str, docs_texts: list[str], answer: str) -> dict:
|
|
|
|
| 313 |
keyed_docs = build_sentence_keyed_docs(docs_texts)
|
| 314 |
keyed_answer = build_keyed_response(answer)
|
|
|
|
| 315 |
if not keyed_docs:
|
| 316 |
return dict(_ZERO_SCORES)
|
| 317 |
+
raw = await self._acall_llm(_make_eval_prompt(keyed_docs, question, keyed_answer))
|
| 318 |
+
judge = JudgeOutput.model_validate_json(raw)
|
|
|
|
|
|
|
| 319 |
return compute_judge_scores(judge, keyed_docs)
|
| 320 |
|
| 321 |
async def ascore_traced(self, question: str, docs_texts: list[str], answer: str) -> tuple[dict, dict]:
|
|
|
|
| 322 |
keyed_docs = build_sentence_keyed_docs(docs_texts)
|
| 323 |
keyed_answer = build_keyed_response(answer)
|
|
|
|
| 324 |
if not keyed_docs:
|
| 325 |
return dict(_ZERO_SCORES), dict(_EMPTY_TRACE)
|
|
|
|
| 326 |
prompt = _make_eval_prompt(keyed_docs, question, keyed_answer)
|
| 327 |
raw = await self._acall_llm(prompt)
|
| 328 |
judge = JudgeOutput.model_validate_json(raw)
|
| 329 |
scores = compute_judge_scores(judge, keyed_docs)
|
| 330 |
+
valid = set(keyed_docs.keys())
|
| 331 |
+
trace = {
|
| 332 |
+
"prompt": prompt,
|
| 333 |
+
"raw_response": raw,
|
| 334 |
+
"judge": judge.model_dump(),
|
| 335 |
+
"relevant_keys": sorted(set(judge.all_relevant_sentence_keys) & valid),
|
| 336 |
+
"utilized_keys": sorted(set(judge.all_utilized_sentence_keys) & valid),
|
| 337 |
+
"valid_keys_count": len(valid),
|
| 338 |
+
"relevance_explanation": judge.relevance_explanation,
|
| 339 |
+
"overall_supported_explanation": judge.overall_supported_explanation,
|
| 340 |
}
|
| 341 |
return scores, trace
|
src/generation/llm_client.py
CHANGED
|
@@ -22,24 +22,22 @@ litellm.num_retries = 3
|
|
| 22 |
|
| 23 |
DEFAULT_MODEL = "groq/llama-3.1-8b-instant"
|
| 24 |
|
|
|
|
| 25 |
NEGATIVE_RESPONSE = (
|
| 26 |
-
"
|
| 27 |
)
|
| 28 |
|
| 29 |
PROMPT_TEMPLATE = """\
|
| 30 |
-
You are a
|
|
|
|
| 31 |
|
| 32 |
-
|
| 33 |
-
{
|
| 34 |
-
|
| 35 |
-
Question: {question}
|
| 36 |
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
- If the context does not contain enough information to answer, respond exactly with: "{negative_response}"
|
| 40 |
-
- Be concise and direct.
|
| 41 |
|
| 42 |
-
|
| 43 |
|
| 44 |
|
| 45 |
def _resolve_model(model: str) -> str:
|
|
|
|
| 22 |
|
| 23 |
DEFAULT_MODEL = "groq/llama-3.1-8b-instant"
|
| 24 |
|
| 25 |
+
# Matches RAGBench section 7.3 / 7.7 "LONG" generation prompt.
|
| 26 |
NEGATIVE_RESPONSE = (
|
| 27 |
+
"The documents are missing some of the information required to answer the question."
|
| 28 |
)
|
| 29 |
|
| 30 |
PROMPT_TEMPLATE = """\
|
| 31 |
+
You are a chatbot providing answers to user queries. You will be given one or more context documents. \
|
| 32 |
+
Use the information in the documents to answer the question.
|
| 33 |
|
| 34 |
+
If the documents do not provide enough information for you to answer the question, then say \
|
| 35 |
+
"{negative_response}" Don't quote things not in the documents. Don't try to make up an answer.
|
|
|
|
|
|
|
| 36 |
|
| 37 |
+
Context Documents:
|
| 38 |
+
{context}
|
|
|
|
|
|
|
| 39 |
|
| 40 |
+
Question: {question}"""
|
| 41 |
|
| 42 |
|
| 43 |
def _resolve_model(model: str) -> str:
|
src/pipeline/rag_pipeline.py
CHANGED
|
@@ -172,13 +172,20 @@ class RagPipeline:
|
|
| 172 |
|
| 173 |
return {"context": context, "reranked": reranked, "relevance": relevance}
|
| 174 |
|
| 175 |
-
def answer(self, question: str, trace: bool = False) -> dict:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
cfg = self.config
|
| 177 |
trace_dict = {} if trace else None
|
| 178 |
|
| 179 |
retrieval = self.assemble_context(question, trace=trace_dict)
|
| 180 |
|
| 181 |
-
if retrieval["relevance"] < cfg.relevance_threshold:
|
| 182 |
result = {**retrieval, "answer": NEGATIVE_RESPONSE, "rejected": True}
|
| 183 |
if trace_dict is not None:
|
| 184 |
trace_dict["prompt"] = None
|
|
@@ -200,19 +207,22 @@ class RagPipeline:
|
|
| 200 |
result["trace"] = trace_dict
|
| 201 |
return result
|
| 202 |
|
| 203 |
-
async def aanswer(self, question: str, trace: bool = False) -> dict:
|
| 204 |
"""Async variant of answer() for concurrent benchmark execution.
|
| 205 |
|
| 206 |
CPU-bound retrieval and reranking run in a thread pool so they don't
|
| 207 |
block the asyncio event loop. The LLM generation call is truly async
|
| 208 |
via litellm.acompletion.
|
|
|
|
|
|
|
|
|
|
| 209 |
"""
|
| 210 |
cfg = self.config
|
| 211 |
trace_dict = {} if trace else None
|
| 212 |
|
| 213 |
retrieval = await asyncio.to_thread(self.assemble_context, question, trace_dict)
|
| 214 |
|
| 215 |
-
if retrieval["relevance"] < cfg.relevance_threshold:
|
| 216 |
result = {**retrieval, "answer": NEGATIVE_RESPONSE, "rejected": True}
|
| 217 |
if trace_dict is not None:
|
| 218 |
trace_dict["prompt"] = None
|
|
|
|
| 172 |
|
| 173 |
return {"context": context, "reranked": reranked, "relevance": relevance}
|
| 174 |
|
| 175 |
+
def answer(self, question: str, trace: bool = False, use_threshold: bool = True) -> dict:
|
| 176 |
+
"""Run the full pipeline synchronously.
|
| 177 |
+
|
| 178 |
+
use_threshold=True (default): reject queries whose cross-encoder
|
| 179 |
+
relevance score falls below cfg.relevance_threshold (current behaviour).
|
| 180 |
+
use_threshold=False (RAGBench mode): always generate — the LLM judge
|
| 181 |
+
evaluates relevance retrospectively from sentence keys.
|
| 182 |
+
"""
|
| 183 |
cfg = self.config
|
| 184 |
trace_dict = {} if trace else None
|
| 185 |
|
| 186 |
retrieval = self.assemble_context(question, trace=trace_dict)
|
| 187 |
|
| 188 |
+
if use_threshold and retrieval["relevance"] < cfg.relevance_threshold:
|
| 189 |
result = {**retrieval, "answer": NEGATIVE_RESPONSE, "rejected": True}
|
| 190 |
if trace_dict is not None:
|
| 191 |
trace_dict["prompt"] = None
|
|
|
|
| 207 |
result["trace"] = trace_dict
|
| 208 |
return result
|
| 209 |
|
| 210 |
+
async def aanswer(self, question: str, trace: bool = False, use_threshold: bool = True) -> dict:
|
| 211 |
"""Async variant of answer() for concurrent benchmark execution.
|
| 212 |
|
| 213 |
CPU-bound retrieval and reranking run in a thread pool so they don't
|
| 214 |
block the asyncio event loop. The LLM generation call is truly async
|
| 215 |
via litellm.acompletion.
|
| 216 |
+
|
| 217 |
+
use_threshold=False skips the cross-encoder rejection gate so the LLM
|
| 218 |
+
judge can evaluate relevance retrospectively (RAGBench approach).
|
| 219 |
"""
|
| 220 |
cfg = self.config
|
| 221 |
trace_dict = {} if trace else None
|
| 222 |
|
| 223 |
retrieval = await asyncio.to_thread(self.assemble_context, question, trace_dict)
|
| 224 |
|
| 225 |
+
if use_threshold and retrieval["relevance"] < cfg.relevance_threshold:
|
| 226 |
result = {**retrieval, "answer": NEGATIVE_RESPONSE, "rejected": True}
|
| 227 |
if trace_dict is not None:
|
| 228 |
trace_dict["prompt"] = None
|