Skip to content

feat(evaluation) 4/15: fold promptfoo output into retrieval and answer metrics - #815

Open
Ahmath-Gadji wants to merge 1 commit into
eval/03-indexing-metricsfrom
eval/04-promptfoo-result-metrics
Open

feat(evaluation) 4/15: fold promptfoo output into retrieval and answer metrics#815
Ahmath-Gadji wants to merge 1 commit into
eval/03-indexing-metricsfrom
eval/04-promptfoo-result-metrics

Conversation

@Ahmath-Gadji

@Ahmath-Gadji Ahmath-Gadji commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Part 4 of 15 of the split of #811. Targets eval/03-indexing-metrics (#814).

What

The second half of metrics.py. summarize turns the two promptfoo results.json files into:

Metrics
Retrieval hit rate, MRR, recall, context relevance
Answer pass rate, factuality, rubric score

plus the per-question EvalCaseResult detail the run page tabulates.

Ranking definitions follow the write-up already in tests/load/automatic-evaluation-pipeline/README.md, so these numbers mean the same thing as the offline pipeline's.

Notable

  • Rows without expected_file_ids are skipped, not failed. They are reported in skipped_cases and left out of hit rate / MRR / recall. Scoring them as misses would make a sparsely-annotated test set look like a broken retriever, and the count is surfaced so a near-empty ground truth cannot masquerade as a perfect score either.
  • Matching is identifier-agnostic and sanitisation-symmetric. A document matches on either metadata.source or metadata.file_id, and both sides run through sanitize_file_id (part 2) — a test set naming A B.pdf matches the A_B.pdf the indexer stored. The document is displayed by file_id, because source is a server-side storage path and means nothing to the admin reading the table.
  • promptfoo's envelope is treated as unstable. extract_results accepts {"results": {"results": [...]}} or a bare list, and every field read off a row is optional. promptfoo can drop a row on a provider error; that leaves the case unscored rather than raising mid-run.

Review follow-up

Two review nitpicks taken, both in metrics.py / its tests:

  • Recall folds over a single union rather than scanning documents once per expected id. Rank-sensitive hit/MRR still walks the list in order; recall doesn't care about rank, so len(expected & retrieved) / len(expected) states the definition more directly than the nested any(...) did.
  • test_matching_survives_file_id_sanitisation_on_either_side is parametrized. The three metadata shapes are independent claims, and the manual loop reported only the first failure — hiding whether the others also broke. 34 → 36 tests in the file.

Testing

15 further unit tests (34 in the file): first-matching-rank MRR, misses, skipped cases, fractional recall, context-relevance averaging and its None case, answer component scores, missing rows, and sanitisation matching from either side. ruff, format check and the layer-import guard pass.

Summary by CodeRabbit

  • New Features

    • Added evaluation result parsing for both nested and list-based result formats.
    • Added summarized retrieval metrics, including hit rate, MRR, recall, and context relevance.
    • Added answer metrics, including pass rate, factuality, and rubric scores.
    • Added per-query evaluation details and retrieved file reporting.
    • Improved handling of missing data, unmatched sources, and sanitized file identifiers.
  • Tests

    • Expanded coverage for evaluation parsing, scoring, edge cases, and file matching.

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@EnjoyBacon7, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 59 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 801b66e5-1f00-434e-8a69-4111c3262d51

📥 Commits

Reviewing files that changed from the base of the PR and between daff0a7 and 204206e.

📒 Files selected for processing (3)
  • openrag/core/evaluation/__init__.py
  • openrag/core/evaluation/metrics.py
  • tests/unit/core/evaluation/test_metrics.py
📝 Walkthrough

Walkthrough

Evaluation now exposes promptfoo result extraction and summarization utilities. Retrieval outputs produce ranking and recall metrics, answer outputs produce pass and component scores, and per-case details include matched retrieved identifiers. Unit tests cover payload shapes, missing data, grading, and filename normalization.

Changes

Evaluation metrics

Layer / File(s) Summary
Public result exports
openrag/core/evaluation/__init__.py, openrag/core/evaluation/metrics.py, tests/unit/core/evaluation/test_metrics.py
extract_results accepts nested or bare promptfoo payloads, and extract_results/summarize are exported from the evaluation package.
Result parsing and grading
openrag/core/evaluation/metrics.py
Retrieval rows are indexed by query, document identifiers are normalized from metadata, and grading scores and reasons are extracted safely.
Metric summarization and validation
openrag/core/evaluation/metrics.py, tests/unit/core/evaluation/test_metrics.py
summarize computes hit rate, MRR, recall, context relevance, pass rate, factuality, and rubric metrics while returning per-case results; tests cover missing rows and identifier normalization.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant EvalTestCase
  participant summarize
  participant retrieval_payload
  participant answer_payload
  participant EvaluationMetrics

  summarize->>retrieval_payload: extract_results()
  summarize->>answer_payload: extract_results()
  summarize->>EvalTestCase: match queries and expected sources
  summarize->>EvaluationMetrics: compute retrieval and answer aggregates
  EvaluationMetrics-->>summarize: metrics and per-case details
Loading

Possibly related PRs

  • linagora/openrag#813: Introduces the sanitize_file_id normalization semantics used to match expected and retrieved file identifiers.

Suggested reviewers: enjoybacon7

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: folding promptfoo output into retrieval and answer metrics.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch eval/04-promptfoo-result-metrics

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@Ahmath-Gadji Ahmath-Gadji changed the title feat(evaluation) 4/14: fold promptfoo output into retrieval and answer metrics feat(evaluation) 4/15: fold promptfoo output into retrieval and answer metrics Jul 27, 2026
@Ahmath-Gadji
Ahmath-Gadji force-pushed the eval/03-indexing-metrics branch from 44c9830 to 7e09c04 Compare July 27, 2026 13:31
@Ahmath-Gadji
Ahmath-Gadji force-pushed the eval/04-promptfoo-result-metrics branch from 2860e1b to daff0a7 Compare July 27, 2026 13:31
@Ahmath-Gadji

Copy link
Copy Markdown
Collaborator Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
openrag/core/evaluation/metrics.py (1)

228-236: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Optional: merge the hit-rank and recall passes over documents.

matched (for hit/MRR) and found (for recall) both iterate documents independently. They could be computed in a single pass, e.g. by unioning all document identifier sets once and intersecting with expected. Given the small size of retrieval result lists in this offline evaluation tool, this is a readability nit rather than a real hot path.

♻️ Possible simplification
-            matched = [rank for rank, (_, identifiers) in enumerate(documents, start=1) if identifiers & expected]
-            detail.hit = bool(matched)
-            detail.reciprocal_rank = 1.0 / matched[0] if matched else 0.0
-            hits.append(1.0 if matched else 0.0)
-            reciprocal_ranks.append(detail.reciprocal_rank)
-            found = {name for name in expected if any(name in ids for _, ids in documents)}
-            recalls.append(len(found) / len(expected))
+            matched = [rank for rank, (_, identifiers) in enumerate(documents, start=1) if identifiers & expected]
+            detail.hit = bool(matched)
+            detail.reciprocal_rank = 1.0 / matched[0] if matched else 0.0
+            hits.append(1.0 if matched else 0.0)
+            reciprocal_ranks.append(detail.reciprocal_rank)
+            all_ids = {i for _, ids in documents for i in ids}
+            recalls.append(len(expected & all_ids) / len(expected))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@openrag/core/evaluation/metrics.py` around lines 228 - 236, Optionally
simplify the ground-truth source evaluation in the case-handling block by
deriving recall from a single union of document identifiers, while preserving
the existing matched-rank logic for hit and reciprocal-rank metrics. Intersect
the union with expected and retain the current recalls calculation, including
its behavior for empty expected identifiers.
tests/unit/core/evaluation/test_metrics.py (1)

259-271: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Prefer pytest.mark.parametrize over a manual loop.

A failing assertion on an early metadata variant stops the loop, so later variants in the same test run are silently skipped until the first failure is fixed. Parametrizing surfaces each case as its own test result.

♻️ Suggested refactor
-def test_matching_survives_file_id_sanitisation_on_either_side():
-    """Whichever form the metadata carries, and whichever the author wrote,
-    must match: otherwise the ranking metrics silently read as zero."""
-    cases = [EvalTestCase(query="q1", expected_answer="a", expected_file_ids=("A B.pdf",))]
-    for metadata in (
-        {"file_id": "A_B.pdf", "source": "A_B.pdf"},
-        {"file_id": "A_B.pdf", "source": "/data/A B.pdf"},
-        {"file_id": "A_B.pdf"},
-    ):
-        row = {"vars": {"query": "q1"}, "response": {"output": [{"content": "c", "metadata": metadata}]}}
-        retrieval, _, _ = summarize(cases=cases, retrieval_payload=[row], answer_payload=[])
-        assert retrieval.hit_rate == 1.0, metadata
+@pytest.mark.parametrize(
+    "metadata",
+    [
+        {"file_id": "A_B.pdf", "source": "A_B.pdf"},
+        {"file_id": "A_B.pdf", "source": "/data/A B.pdf"},
+        {"file_id": "A_B.pdf"},
+    ],
+)
+def test_matching_survives_file_id_sanitisation_on_either_side(metadata):
+    """Whichever form the metadata carries, and whichever the author wrote,
+    must match: otherwise the ranking metrics silently read as zero."""
+    cases = [EvalTestCase(query="q1", expected_answer="a", expected_file_ids=("A B.pdf",))]
+    row = {"vars": {"query": "q1"}, "response": {"output": [{"content": "c", "metadata": metadata}]}}
+    retrieval, _, _ = summarize(cases=cases, retrieval_payload=[row], answer_payload=[])
+    assert retrieval.hit_rate == 1.0

(requires import pytest at the top of the file)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/core/evaluation/test_metrics.py` around lines 259 - 271, Refactor
test_matching_survives_file_id_sanitisation_on_either_side to use
pytest.mark.parametrize with each metadata variant as a separate case, adding
the required pytest import. Preserve the existing EvalTestCase setup, summarize
call, and hit_rate assertion while removing the manual loop.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@openrag/core/evaluation/metrics.py`:
- Around line 228-236: Optionally simplify the ground-truth source evaluation in
the case-handling block by deriving recall from a single union of document
identifiers, while preserving the existing matched-rank logic for hit and
reciprocal-rank metrics. Intersect the union with expected and retain the
current recalls calculation, including its behavior for empty expected
identifiers.

In `@tests/unit/core/evaluation/test_metrics.py`:
- Around line 259-271: Refactor
test_matching_survives_file_id_sanitisation_on_either_side to use
pytest.mark.parametrize with each metadata variant as a separate case, adding
the required pytest import. Preserve the existing EvalTestCase setup, summarize
call, and hit_rate assertion while removing the manual loop.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 5ba05720-8432-4bfa-9d60-94f90a65913a

📥 Commits

Reviewing files that changed from the base of the PR and between 7e09c04 and daff0a7.

📒 Files selected for processing (3)
  • openrag/core/evaluation/__init__.py
  • openrag/core/evaluation/metrics.py
  • tests/unit/core/evaluation/test_metrics.py

@Ahmath-Gadji
Ahmath-Gadji force-pushed the eval/03-indexing-metrics branch from 7e09c04 to 8430b48 Compare July 27, 2026 14:32
@Ahmath-Gadji
Ahmath-Gadji force-pushed the eval/04-promptfoo-result-metrics branch from daff0a7 to e595be2 Compare July 27, 2026 14:32
@Ahmath-Gadji

Copy link
Copy Markdown
Collaborator Author

Both nitpicks taken.

Recall now folds over a single union. matched (rank-sensitive, for hit/MRR) still scans documents in order, but recall doesn't care about rank, so it no longer needs a scan per expected id:

retrieved = {identifier for _, identifiers in documents for identifier in identifiers}
recalls.append(len(expected & retrieved) / len(expected))

Equivalent to the old any(...) form and easier to read as "how much of the ground truth came back at all". Agreed it isn't a hot path — top_k is 5 by default — but the set expression states the definition more directly than the nested generator did.

test_matching_survives_file_id_sanitisation_on_either_side is now parametrized. Your reasoning is the right one: the three metadata shapes are independent claims, and a manual loop reports only the first failure, hiding whether the others also broke. Each variant is now its own test id, so a regression in the source-only path is visible even when the file_id-only path is already red. Test count in the file goes 34 → 36.

ruff, ruff format --check and the suite pass.

@Ahmath-Gadji
Ahmath-Gadji force-pushed the eval/03-indexing-metrics branch from 8430b48 to d08f158 Compare July 27, 2026 15:14
@Ahmath-Gadji
Ahmath-Gadji force-pushed the eval/04-promptfoo-result-metrics branch from e595be2 to 19806fd Compare July 27, 2026 15:14
@Ahmath-Gadji
Ahmath-Gadji force-pushed the eval/03-indexing-metrics branch from d08f158 to f6f4569 Compare July 27, 2026 15:31
@Ahmath-Gadji
Ahmath-Gadji force-pushed the eval/04-promptfoo-result-metrics branch from 19806fd to 94ee72b Compare July 27, 2026 15:31
…rics

`summarize` turns the two promptfoo `results.json` files into hit rate,
MRR, recall and context relevance on the retrieval side, pass rate,
factuality and rubric score on the answer side, plus the per-question
detail the run page tabulates.

The ranking definitions follow the write-up already in
`tests/load/automatic-evaluation-pipeline/README.md`, so these numbers mean
the same thing as the offline pipeline's.

Decisions covered by tests:

- Rows with no `expected_file_ids` are reported as `skipped_cases`, never
  as misses. Scoring them as misses would make a sparsely-annotated test
  set look like a broken retriever.
- A document is matched on either identifier it carries, `metadata.source`
  or `metadata.file_id`, and both sides go through `sanitize_file_id` — a
  test set naming `A B.pdf` matches the stored `A_B.pdf`. It is displayed
  by `file_id`, since `source` is a server-side storage path.
- promptfoo's output envelope varies by release, so `extract_results`
  accepts the nested shape or a bare list, and every field read off a row
  is optional. A dropped row leaves its case unscored rather than raising.
@EnjoyBacon7
EnjoyBacon7 force-pushed the eval/03-indexing-metrics branch from f6f4569 to e573a8a Compare July 28, 2026 10:25
@EnjoyBacon7
EnjoyBacon7 force-pushed the eval/04-promptfoo-result-metrics branch from 94ee72b to 204206e Compare July 28, 2026 10:25

@hedhoud hedhoud left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The metric calculation looks clear overall, but I found two cases that can make the results misleading.

Provider errors are currently counted as quality failures. For example, if a retrieval request returns an error or promptfoo drops its row, the case is counted as a retrieval miss. An answer error is also counted as a failed answer. This makes an API outage look like poor model quality. Please keep real empty results as misses, but skip or clearly report rows that failed to run. If no valid rows remain, the evaluation should fail instead of completing with misleading scores.

Results are also matched only by the question text. When the test set contains the same question twice with different expected answers, the second promptfoo result is discarded and both cases reuse the first result. Matching by a unique test ID, or rejecting duplicate questions earlier, would prevent incorrect pass rates.

Please add focused tests for provider errors, missing rows, and duplicate questions. Once those cases are covered, the metric logic should be in good shape.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants