arbarikcp commited on
Commit
c884f27
·
1 Parent(s): 8643845

Adding OpenAI models aand llm-as-judge options

Browse files
.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
- # Judge eval needs individual expanded doc texts, which live in trace data.
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(result, question, backend=eval_method)
 
 
 
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(_BENCH_CONCURRENCY)
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(result, question, backend=eval_method)
 
 
 
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="LLM (Groq)",
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
- "Groq/OpenAI Judge: LLM-as-Judge "
849
- "(requires the corresponding API key as a Space secret)."
 
 
 
 
 
 
 
 
 
 
 
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
- n_samples_dd = gr.Dropdown(choices=[5, 10, 25, 50, 100], value=10, label="Sample size")
 
 
 
 
 
 
 
 
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() — use from the benchmark pipeline."""
81
- relevance = result["relevance"]
 
 
 
 
 
 
82
 
83
  if result.get("rejected") or not result["context"].strip():
84
  return {
85
- "relevance": 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
- scores["relevance"] = relevance
 
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 OpenAI).
106
 
107
- Requires the result to have been produced with ``trace=True`` so that
108
- the expanded doc texts are available in ``result['trace']['expansions']``.
109
- The cross-encoder relevance from the pipeline is preserved.
110
  """
111
- relevance = result["relevance"]
112
 
113
  if result.get("rejected") or not result["context"].strip():
114
  return {
115
- "relevance": 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
- scores["relevance"] = relevance # keep cross-encoder relevance
 
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(result: dict, question: str, backend: str) -> tuple[dict, dict]:
 
 
 
 
 
171
  """score_result_with_judge() with full judge prompt/response returned as a second value."""
172
- relevance = result["relevance"]
173
 
174
  if result.get("rejected") or not result["context"].strip():
175
- scores = {"relevance": relevance, "adherence": math.nan,
176
  "completeness": math.nan, "utilization": math.nan}
177
  return scores, {"method": backend, "rejected": True}
178
 
179
- expansions = result.get("trace", {}).get("expansions", [])
180
- docs_texts = [e["text"] for e in expansions] if expansions else [result["context"]]
181
 
182
- judge = get_llm_judge(backend)
183
  scores, judge_trace = judge.score_traced(question, docs_texts, result["answer"])
184
- scores["relevance"] = relevance
 
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
- Replicates the sentence-keyed judge pipeline from the capstone notebook:
4
  - Splits retrieved docs and the generated answer into sentence-keyed strings.
5
- - Sends a structured prompt to an LLM judge via LiteLLM with JSON mode so the
6
- response is constrained to valid JSON.
7
- - Validates the response against a Pydantic schema — missing fields get safe
8
- defaults instead of KeyError / NaN.
9
- - Derives adherence, relevance, utilization, and completeness on [0, 1].
10
-
11
- Supported backends (passed as model strings to LiteLLM):
12
- "groq" → groq/llama-3.3-70b-versatile
13
- "openai" → openai/gpt-4o-mini
14
- Any string containing "/" is treated as a raw LiteLLM model string.
15
-
16
- Metric definitions (notebook-compatible):
17
- - adherence : 1 if judge says the answer is overall supported, else 0.
18
- - relevance : fraction of doc sentences the judge marks as relevant.
19
- - utilization : fraction of doc sentences the judge says were used.
20
- - completeness: fraction of relevant sentences that were actually utilized.
 
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 for judge output
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
- overall_supported: bool = False
45
  all_relevant_sentence_keys: list[str] = Field(default_factory=list)
46
- all_utilized_sentence_keys: list[str] = Field(default_factory=list)
 
47
  sentence_support_information: list[_SentenceSupport] = Field(default_factory=list)
 
48
 
49
 
50
  # ---------------------------------------------------------------------------
51
- # Prompt
52
  # ---------------------------------------------------------------------------
53
 
54
  _SYSTEM_PROMPT = (
55
  "You are a strict RAG evaluation judge. "
56
- "Respond with a JSON object only. Use only sentence keys provided in the prompt."
 
57
  )
58
 
 
 
 
 
59
  _EVAL_PROMPT = """\
60
- Evaluate the RAG response using the provided documents. Return a JSON object.
 
 
 
 
 
 
 
 
61
 
62
- Documents (sentence-keyed):
63
  {documents_text}
 
64
 
65
- Question:
 
66
  {question}
 
67
 
68
- Response (sentence-keyed):
 
 
 
 
69
  {answer_text}
 
70
 
71
- JSON structure:
72
 
 
73
  {{
74
- "overall_supported": true,
75
- "all_relevant_sentence_keys": ["0_0"],
76
- "all_utilized_sentence_keys": ["0_0"],
 
77
  "sentence_support_information": [
78
  {{
79
- "response_sentence_key": "r_0",
80
- "supporting_sentence_keys": ["0_0"],
81
- "fully_supported": true
 
82
  }}
83
- ]
 
84
  }}
85
-
86
- Rules:
87
- - Document keys look like: 0_0, 0_1, 1_0, 1_1. Use ONLY keys present above.
88
- - Response keys look like: r_0, r_1, r_2.
89
- - all_relevant_sentence_keys: every document sentence useful for answering the question.
90
- - all_utilized_sentence_keys: document sentences the response actually drew from.
91
- - supporting_sentence_keys must be empty when a response sentence is unsupported.\
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 = "\n".join(f"{k}. {v}" for k, v in keyed_docs.items())
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
- valid = set(keyed_docs.keys())
 
 
 
 
 
 
130
  relevant = set(judge.all_relevant_sentence_keys) & valid
131
  utilized = set(judge.all_utilized_sentence_keys) & valid
132
- total = len(valid)
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": "groq/llama-3.3-70b-versatile",
153
- "openai": "openai/gpt-4o-mini",
 
154
  }
155
 
156
  _ZERO_SCORES = {"adherence": 0.0, "relevance": 0.0, "utilization": 0.0, "completeness": 0.0}
157
- _EMPTY_TRACE = {"prompt": "", "raw_response": "", "judge": None,
158
- "relevant_keys": [], "utilized_keys": [], "valid_keys_count": 0}
 
 
 
159
 
160
 
161
  # ---------------------------------------------------------------------------
@@ -163,30 +226,29 @@ _EMPTY_TRACE = {"prompt": "", "raw_response": "", "judge": None,
163
  # ---------------------------------------------------------------------------
164
 
165
  class LlmJudge:
166
- """LLM-as-Judge scorer backed by LiteLLM.
167
 
168
- Supports:
169
- - ``"groq"`` : LLaMA-3.3-70B via Groq (GROQ_API_KEY).
170
- - ``"openai"`` : GPT-4o-mini via OpenAI (OPENAI_API_KEY).
171
- - Any raw LiteLLM model string (e.g. ``"openrouter/..."``).
172
 
173
- Both ``score`` / ``score_traced`` (sync) and ``ascore`` / ``ascore_traced``
174
- (async) are provided. Use the async variants from the benchmark pipeline.
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 = model or _BACKEND_MODELS.get(backend, backend)
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=3000,
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
- prompt = _make_eval_prompt(keyed_docs, question, keyed_answer)
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
- valid = set(keyed_docs.keys())
230
- trace = {
231
- "prompt": prompt,
232
- "raw_response": raw,
233
- "judge": judge.model_dump(),
234
- "relevant_keys": sorted(set(judge.all_relevant_sentence_keys) & valid),
235
- "utilized_keys": sorted(set(judge.all_utilized_sentence_keys) & valid),
236
- "valid_keys_count": len(valid),
 
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=3000,
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
- prompt = _make_eval_prompt(keyed_docs, question, keyed_answer)
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
- 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
  }
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
- "I don't have enough information in the provided context to answer this question."
27
  )
28
 
29
  PROMPT_TEMPLATE = """\
30
- You are a helpful assistant answering questions using ONLY the provided context.
 
31
 
32
- Context:
33
- {context}
34
-
35
- Question: {question}
36
 
37
- Instructions:
38
- - Answer using only information from the context above.
39
- - If the context does not contain enough information to answer, respond exactly with: "{negative_response}"
40
- - Be concise and direct.
41
 
42
- Answer:"""
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