Skip to content

feat: add custom:mrr metric for RAG retrieval quality evaluation - #302

Open
x86girl wants to merge 5 commits into
lightspeed-core:mainfrom
x86girl:prgutier/custom-mrr-metric
Open

feat: add custom:mrr metric for RAG retrieval quality evaluation#302
x86girl wants to merge 5 commits into
lightspeed-core:mainfrom
x86girl:prgutier/custom-mrr-metric

Conversation

@x86girl

@x86girl x86girl commented Jul 29, 2026

Copy link
Copy Markdown

Mean Reciprocal Rank (MRR) is a well-established information retrieval metric introduced by Voorhees (1999) in the TREC-8 Question Answering Track Report. It measures the rank position of the first relevant result in a ranked list, computed as 1/rank, yielding 1.0 when the
first retrieved item is relevant, 0.5 for the second, and so on.

▎ "The reciprocal rank of a query response is the multiplicative
▎ inverse of the rank of the first correct answer."
▎ Voorhees, E.M. (1999). The TREC-8 Question Answering Track Report.
▎ Proceedings of the 8th Text REtrieval Conference (TREC-8), NIST
▎ Special Publication 500-246, pp. 77–82.

In the context of RAG (Retrieval-Augmented Generation) evaluation, MRR provides a deterministic, non-LLM signal that directly measures retrieval ranking quality
It mesuares how quickly the system surfaces a relevant context chunk. This complements the existing LLM-judge-based Ragas metrics (context_recall, context_precision, context_relevance) by adding a fast, reproducible, and cost-free retrieval quality indicator.

Summary by CodeRabbit

  • New Features

    • Added NLP-based Mean Reciprocal Rank (MRR) with optional semantic matching via embeddings.
    • Introduced configurable embedding model selection and similarity thresholding, with default similarity and optional conformal calibration.
  • Validation

    • MRR now requires non-empty retrieved and expected contexts.
    • Renamed metric identifier from custom:mrr to nlp:mrr.
  • Tests

    • Expanded unit coverage for substring scoring, semantic behavior, threshold calibration, and stricter input validation.
    • Added dedicated tests for conformal calibration logic.

Add Mean Reciprocal Rank (MRR) as a new custom metric that measures
how high the first relevant context appears in the ranked list of
retrieved contexts. This is a deterministic, non-LLM metric that
complements existing Ragas context metrics.

- Add `expected_contexts` field to TurnData for ground-truth contexts
- Implement MRR evaluation with normalized containment matching
- Register metric in CustomMetrics, validator, and system config
- Add 26 comprehensive tests covering all edge cases

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Adds semantic-similarity MRR under nlp:mrr, with configurable and conformal-calibrated thresholds. It preserves substring fallback behavior, enforces non-empty expected contexts, integrates lazy embedding-model loading, and expands unit tests.

Changes

Semantic MRR Evaluation

Layer / File(s) Summary
MRR contract and registration
config/system.yaml, src/lightspeed_evaluation/core/models/data.py, src/lightspeed_evaluation/core/system/validator.py, src/lightspeed_evaluation/core/metrics/custom/__init__.py
Renames MRR to nlp:mrr, adds similarity metadata, requires non-empty expected contexts, and exports MRR utilities.
Conformal threshold calibration
src/lightspeed_evaluation/core/metrics/custom/conformal.py, tests/unit/core/metrics/custom/test_conformal.py
Adds calibrated threshold selection from similarity calibration data with coverage for bounds, alpha behavior, and edge cases.
Dual-mode MRR evaluation
src/lightspeed_evaluation/core/metrics/custom/mrr_eval.py, tests/unit/core/metrics/custom/test_mrr_eval.py
Adds embedding-based similarity scoring, threshold resolution and caching, substring fallback handling, validation, and expanded tests.
NLP metric integration
src/lightspeed_evaluation/core/metrics/nlp.py
Registers MRR, lazily loads and caches sentence-transformers models, supports model overrides, and delegates scoring to evaluate_mrr.

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

Sequence Diagram(s)

sequenceDiagram
  participant NLPMetrics
  participant evaluate_mrr
  participant EmbeddingModel
  participant ConformalCalibration
  NLPMetrics->>evaluate_mrr: evaluate turn with model and configuration
  evaluate_mrr->>EmbeddingModel: encode contexts
  EmbeddingModel-->>evaluate_mrr: embeddings
  evaluate_mrr->>ConformalCalibration: resolve threshold when calibration is configured
  ConformalCalibration-->>evaluate_mrr: threshold
  evaluate_mrr-->>NLPMetrics: MRR score and reason
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title matches the main change: adding an MRR metric for RAG retrieval evaluation, though the metric key now uses nlp:mrr.
Docstring Coverage ✅ Passed Docstring coverage is 97.92% which is sufficient. The required threshold is 80.00%.
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.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@asamal4

asamal4 commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 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.

Inline comments:
In `@src/lightspeed_evaluation/core/metrics/custom/mrr_eval.py`:
- Around line 21-23: Update the containment check in the custom MRR matching
function around `_normalize_text` so it returns `False` whenever
`norm_retrieved` or `norm_expected` is empty before evaluating substring
containment. Add regression tests covering whitespace-only retrieved and
expected contexts.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 065e6a3d-25ef-4df1-b84e-dac605a54fd1

📥 Commits

Reviewing files that changed from the base of the PR and between 590f807 and 91c8ae1.

📒 Files selected for processing (7)
  • config/system.yaml
  • src/lightspeed_evaluation/core/metrics/custom/__init__.py
  • src/lightspeed_evaluation/core/metrics/custom/custom.py
  • src/lightspeed_evaluation/core/metrics/custom/mrr_eval.py
  • src/lightspeed_evaluation/core/models/data.py
  • src/lightspeed_evaluation/core/system/validator.py
  • tests/unit/core/metrics/custom/test_mrr_eval.py

Comment thread src/lightspeed_evaluation/core/metrics/custom/mrr_eval.py

@asamal4 asamal4 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.

Thanks !!
Please check coderabbit's comment.. and PTAL my inline comment.

Primarily I have concern about the custom implementation especially checking the relevancy part. We should either adopt a standard package or replace the matching logic with something more robust.

Comment thread src/lightspeed_evaluation/core/metrics/custom/mrr_eval.py Outdated
Comment thread src/lightspeed_evaluation/core/models/data.py
Comment thread config/system.yaml Outdated
x86girl and others added 3 commits July 30, 2026 09:58
…_length, rename to nlp:mrr

- Reject empty normalized contexts in _is_context_match before containment check
- Add min_length=1 to expected_contexts field for early Pydantic validation
- Rename metric from custom:mrr to nlp:mrr and move registration to NLPMetrics

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace naive substring containment in nlp:mrr with cosine similarity
using sentence-transformers embeddings (default: all-MiniLM-L6-v2).
Threshold 0.65 based on STS Benchmark empirical data for this model.
Falls back to substring matching when sentence-transformers is not installed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add optional CRC (Angelopoulos et al., ICLR 2024) for users who want
data-driven threshold calibration instead of the fixed default.
Activated by providing calibration_pairs in nlp:mrr metric metadata.
Algorithm vendored from github.com/aangelopoulos/conformal-risk (MIT).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (3)
src/lightspeed_evaluation/core/metrics/nlp.py (1)

100-107: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

_evaluate_mrr docstring lacks Args/Returns, unlike sibling handlers.

Every other metric handler in this file (_evaluate_bleu, _evaluate_rouge, _evaluate_semantic_similarity_distance) documents Args and Returns in Google style; _evaluate_mrr has only a one-line docstring.

This shares a root cause with a similar gap in mrr_eval.py's evaluate_mrr; see consolidated comment.

🤖 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 `@src/lightspeed_evaluation/core/metrics/nlp.py` around lines 100 - 107, Expand
the _evaluate_mrr docstring to use the same Google-style Args and Returns
structure as _evaluate_bleu, _evaluate_rouge, and
_evaluate_semantic_similarity_distance, documenting conv_data, turn_idx,
turn_data, is_conversation, and the returned optional score and status string.

Source: Coding guidelines

src/lightspeed_evaluation/core/metrics/custom/mrr_eval.py (2)

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

Incomplete Google-style docstrings on the new MRR entry points. Both new/changed MRR functions in this PR fall short of the project's Google-style docstring convention used by their sibling functions.

  • src/lightspeed_evaluation/core/metrics/custom/mrr_eval.py#L142-168: add embedding_model_name to the Args: section of evaluate_mrr's docstring.
  • src/lightspeed_evaluation/core/metrics/nlp.py#L100-107: expand _evaluate_mrr's docstring to include Args:/Returns: sections, matching _evaluate_bleu/_evaluate_rouge/_evaluate_semantic_similarity_distance in the same 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 `@src/lightspeed_evaluation/core/metrics/custom/mrr_eval.py` at line 1,
Complete the Google-style docstrings for the MRR entry points: update
evaluate_mrr to document embedding_model_name in its Args section, and expand
_evaluate_mrr with Args and Returns sections consistent with _evaluate_bleu,
_evaluate_rouge, and _evaluate_semantic_similarity_distance. Keep the
documentation aligned with each function’s actual parameters and return value.

Source: Coding guidelines


142-168: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

evaluate_mrr docstring omits embedding_model_name parameter.

The Args: section documents embedding_model and mrr_config but not the new keyword-only embedding_model_name parameter added to the signature.

This finding shares a root cause with a similar docstring gap in nlp.py's _evaluate_mrr; see consolidated comment.

🤖 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 `@src/lightspeed_evaluation/core/metrics/custom/mrr_eval.py` around lines 142 -
168, Update the Args section of evaluate_mrr to document the
embedding_model_name keyword-only parameter, including its purpose and
relationship to the embedding model. Leave the existing parameter documentation
unchanged.

Source: Coding guidelines

🤖 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.

Inline comments:
In `@src/lightspeed_evaluation/core/metrics/nlp.py`:
- Around line 45-67: The MRR embedding state in the NLP metrics evaluator is
shared across concurrent evaluations and can be mutated by per-turn model
overrides. Update `_evaluate_mrr` and the embedding initialization/cache logic
around `_embedding_model_name` and `_embedding_model` to use a thread-safe,
model-name-keyed cache or synchronization, ensuring each evaluation uses the
model matching its requested `mrr_config["embedding_model"]` without changing
another thread’s state.

---

Nitpick comments:
In `@src/lightspeed_evaluation/core/metrics/custom/mrr_eval.py`:
- Line 1: Complete the Google-style docstrings for the MRR entry points: update
evaluate_mrr to document embedding_model_name in its Args section, and expand
_evaluate_mrr with Args and Returns sections consistent with _evaluate_bleu,
_evaluate_rouge, and _evaluate_semantic_similarity_distance. Keep the
documentation aligned with each function’s actual parameters and return value.
- Around line 142-168: Update the Args section of evaluate_mrr to document the
embedding_model_name keyword-only parameter, including its purpose and
relationship to the embedding model. Leave the existing parameter documentation
unchanged.

In `@src/lightspeed_evaluation/core/metrics/nlp.py`:
- Around line 100-107: Expand the _evaluate_mrr docstring to use the same
Google-style Args and Returns structure as _evaluate_bleu, _evaluate_rouge, and
_evaluate_semantic_similarity_distance, documenting conv_data, turn_idx,
turn_data, is_conversation, and the returned optional score and status string.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 8194b0fd-d397-4399-86f6-82ab895ec867

📥 Commits

Reviewing files that changed from the base of the PR and between 91c8ae1 and dd9c74e.

📒 Files selected for processing (9)
  • config/system.yaml
  • src/lightspeed_evaluation/core/metrics/custom/__init__.py
  • src/lightspeed_evaluation/core/metrics/custom/conformal.py
  • src/lightspeed_evaluation/core/metrics/custom/mrr_eval.py
  • src/lightspeed_evaluation/core/metrics/nlp.py
  • src/lightspeed_evaluation/core/models/data.py
  • src/lightspeed_evaluation/core/system/validator.py
  • tests/unit/core/metrics/custom/test_conformal.py
  • tests/unit/core/metrics/custom/test_mrr_eval.py

Comment thread src/lightspeed_evaluation/core/metrics/nlp.py
Replace mutable _embedding_model/_embedding_model_name instance state
with a dict[str, Any] cache keyed by model name.  Each thread looks up
the model by name without mutating shared state, eliminating the race
condition under ThreadPoolExecutor.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
src/lightspeed_evaluation/core/metrics/nlp.py (1)

65-93: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Cache-miss race can trigger duplicate concurrent model loads.

_get_embedding_model does an unsynchronized check-then-act: two threads racing on a first-time cache miss for the same model_name will both pass the not in self._models check and both call SentenceTransformer(model_name) concurrently. This wastes memory/CPU loading the same model twice, and constructing the same SentenceTransformer model concurrently in one process is a known crash surface in some sentence-transformers versions. Correctness isn't at risk (dict writes are atomic under the GIL, so one instance simply gets discarded), but this is a real hazard worth closing with a lock, especially since NLPMetrics is shared under ThreadPoolExecutor per the pipeline wiring.

🔒 Proposed fix using double-checked locking
     def __init__(self, embedding_model_name: Optional[str] = None) -> None:
         self._default_model_name = embedding_model_name or _DEFAULT_EMBEDDING_MODEL
         self._models: dict[str, Any] = {}
         self._load_failed: set[str] = set()
+        self._load_lock = threading.Lock()
 
         self.supported_metrics = {
             ...
         }
 
     def _get_embedding_model(self, model_name: str) -> Any:
         """Lazy-load and cache the sentence-transformers model by name."""
         if model_name in self._load_failed:
             return None
         if model_name in self._models:
             return self._models[model_name]
-        try:
-            import sentence_transformers  # type: ignore[import-not-found] # pylint: disable=import-outside-toplevel
-
-            model = sentence_transformers.SentenceTransformer(model_name)
-            self._models[model_name] = model
-            logger.info("Loaded embedding model: %s", model_name)
-            return model
-        except ImportError:
-            ...
-            self._load_failed.add(model_name)
-        except (OSError, RuntimeError) as exc:
-            ...
-            self._load_failed.add(model_name)
-        return None
+        with self._load_lock:
+            if model_name in self._load_failed:
+                return None
+            if model_name in self._models:
+                return self._models[model_name]
+            try:
+                import sentence_transformers  # type: ignore[import-not-found] # pylint: disable=import-outside-toplevel
+
+                model = sentence_transformers.SentenceTransformer(model_name)
+                self._models[model_name] = model
+                logger.info("Loaded embedding model: %s", model_name)
+                return model
+            except ImportError:
+                ...
+                self._load_failed.add(model_name)
+            except (OSError, RuntimeError) as exc:
+                ...
+                self._load_failed.add(model_name)
+        return None

Please confirm whether sentence-transformers (as pinned in this project) documents any additional thread-safety caveats around concurrent model construction that would strengthen the case for this lock.

🤖 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 `@src/lightspeed_evaluation/core/metrics/nlp.py` around lines 65 - 93, Protect
the cache-miss path in _get_embedding_model with a lock so only one thread
constructs a given SentenceTransformer at a time. Recheck _load_failed and
_models after acquiring the lock, then perform the existing import,
construction, caching, and failure handling while holding it; retain the
fast-path checks before locking.
🤖 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 `@src/lightspeed_evaluation/core/metrics/nlp.py`:
- Around line 65-93: Protect the cache-miss path in _get_embedding_model with a
lock so only one thread constructs a given SentenceTransformer at a time.
Recheck _load_failed and _models after acquiring the lock, then perform the
existing import, construction, caching, and failure handling while holding it;
retain the fast-path checks before locking.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: a0da8f95-137e-4b2a-81e1-158f9f9227c7

📥 Commits

Reviewing files that changed from the base of the PR and between dd9c74e and f89d42a.

📒 Files selected for processing (1)
  • src/lightspeed_evaluation/core/metrics/nlp.py

@x86girl

x86girl commented Jul 30, 2026

Copy link
Copy Markdown
Author

On top of the semantic similarity matching, I also added an optional conformal risk control layer for threshold calibration. This is completely opt in and does not activate unless you provide calibration_pairs in the nlp:mrr metric metadata.

The idea is simple: instead of relying on a fixed similarity threshold (0.65 by default), users who have domain specific text pairs with known matches can feed them as calibration data. The algorithm (from Angelopoulos et al., ICLR 2024) then computes a threshold with a statistical guarantee that the false negative rate stays below a configurable alpha (default 10%).

Without calibration data, nothing changes. The metric uses the fixed default threshold and works exactly the same way. The conformal calibration is there for teams that want more rigor in their evaluation setup.

The core algorithm is vendored from github.com/aangelopoulos/conformal-risk (MIT license) and is about 5 lines of code, so the maintenance cost is minimal.

Example config with calibration enabled:

"nlp:mrr":
  threshold: 0.5
  default_similarity_threshold: 0.65
  alpha: 0.1
  calibration_pairs:
    - ["RHEL is a Linux distribution", "Red Hat Enterprise Linux is an operating system"]
    - ["kubernetes pods", "pods are the smallest deployable units in k8s"]

@x86girl

x86girl commented Jul 30, 2026

Copy link
Copy Markdown
Author

Regarding the original concern about the matching logic being too naive, I want to share some context on how the similarity threshold was chosen.

The previous substring containment approach had a fundamental limitation: it could only match contexts that literally contained each other. Semantically equivalent texts with different wording would always fail.

The new approach uses cosine similarity on sentence transformer embeddings (all-MiniLM-L6-v2 by default). The key question then becomes what threshold to use for deciding match vs no match.

For this model, the empirical similarity distributions from STS Benchmark data look roughly like this: paraphrases and semantically equivalent text typically score between 0.70 and 0.90, related but different content falls in the 0.40 to 0.65 range, and unrelated text scores below 0.30. A study on literature review pipelines using this same model (arxiv.org/abs/2509.15292) calculated an empirical threshold of 0.659 using Q3 + 0.5*IQR over cosine similarities. That study was in a different domain (scientific paper filtering, not RAG context matching), so the number is a reference point rather than a definitive answer for our use case.

We settled on 0.65 as the default because it sits at the boundary between "related but different" and "semantically equivalent", which is the right cutoff for RAG context matching. A retrieved context should convey the same information as the expected one, not just be topically related. The threshold is also configurable via default_similarity_threshold in the metric metadata, so teams can adjust it to their domain if needed.

One thing worth noting is that all-MiniLM-L6-v2 truncates inputs at 256 word pieces, so longer paragraph level contexts will only have their first ~200 words encoded. This is generally fine for RAG chunks but something to keep in mind for unusually long contexts.

@asamal4

asamal4 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

@x86girl Probably I should have given more clearer comment about robust relevancy check.
Current approach is sound, but added complexity more than you initially planned. with this change, now I have few other concerns.. Initially I thought about embedding approach, but intentionally skipped it for 2 reasons. 1. It will add embedding model related constraints, 2. currently we are using different mechanism to initialize embedding model and that is not tied to nlp workflow yet. The current approach of embedding model initialization is the quickest one, but it doesnot align. I was essentially thinking about just some n-gram approach, better than substring and without embedding model dependency.

Current embedding approach is having some issues + it increased the scope significantly.. I will let you take the final call - 1. Are going to retain embedding approach and fix related issue, 2. Switch to n-gram approach and add embedding later in alignment with existing embedding model processing without a hardcode value..

Current model is practically unusable, most chunks will be of bigger size

@x86girl

x86girl commented Jul 31, 2026

Copy link
Copy Markdown
Author

I am going to find more info about n-gram and try to switch to it.

@asamal4

asamal4 commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

@x86girl let's remove the embedding approach and new threshold, Keep the old approach along with review fixes (ignore the match logic related comment).. Then we can merge this PR and we can work together to add better match logic. WDYT ?

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.

2 participants