Skip to content

fix: cap DocumentNDCGEvaluator score at 1.0 for duplicate retrievals#11959

Open
Otis0408 wants to merge 1 commit into
deepset-ai:mainfrom
Otis0408:fix/document-ndcg-score-capped
Open

fix: cap DocumentNDCGEvaluator score at 1.0 for duplicate retrievals#11959
Otis0408 wants to merge 1 commit into
deepset-ai:mainfrom
Otis0408:fix/document-ndcg-score-capped

Conversation

@Otis0408

Copy link
Copy Markdown
Contributor

What

DocumentNDCGEvaluator could return an NDCG score greater than 1.0, violating the 0.0 to 1.0 range promised in the class docstring and the individual_scores output description.

Why

calculate_dcg adds a relevance contribution for every retrieved document whose comparison value is present in the ground truth, without deduplicating. calculate_idcg, however, builds the ideal gain from the unique ground truth set. When the retrieved list contains the same relevant document more than once, its relevance is counted multiple times, so DCG exceeds IDCG and the resulting NDCG (dcg / idcg) climbs above 1.0.

Fix

Credit each ground truth comparison value at most once when computing DCG, by tracking the values already counted. A relevant document retrieved multiple times now contributes a single relevance term (the earliest, highest-discount position), keeping DCG bounded by IDCG and NDCG within [0.0, 1.0].

Before / after

from haystack import Document
from haystack.components.evaluators import DocumentNDCGEvaluator

evaluator = DocumentNDCGEvaluator()
result = evaluator.run(
    ground_truth_documents=[[Document(content="A", score=1.0)]],
    retrieved_documents=[[Document(content="A"), Document(content="A")]],
)
print(result["score"])
# Before: 1.6309297535714575   (score > 1.0, out of documented range)
# After:  1.0

Tests

Added test_run_with_duplicate_retrieved_document_does_not_exceed_one, which fails on the current code (score 1.63) and passes with the fix. The full test_document_ndcg.py suite (29 tests) passes.

@Otis0408
Otis0408 requested a review from a team as a code owner July 10, 2026 14:19
@Otis0408
Otis0408 requested review from anakin87 and removed request for a team July 10, 2026 14:19
@vercel

vercel Bot commented Jul 10, 2026

Copy link
Copy Markdown

@Otis0408 is attempting to deploy a commit to the deepset Team on Vercel.

A member of the Team first needs to authorize it.

@github-actions github-actions Bot added topic:tests type:documentation Improvements on the docs labels Jul 10, 2026
@github-actions

github-actions Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Coverage report

Click to see where and how coverage changed

FileStatementsMissingCoverageCoverage
(new stmts)
Lines missing
  haystack/components/evaluators
  document_ndcg.py
Project Total  

This report was generated by python-coverage-comment-action

@anakin87

Copy link
Copy Markdown
Member

Hey @Otis0408, thank you for contributing.

I see you currently have 6 different PRs open in Haystack. I recommend pausing this work for a bit, as it may be difficult for the team to keep up with reviews.

Thank you again!

@Otis0408

Copy link
Copy Markdown
Contributor Author

Thanks for saying so directly, and sorry for the pile-up. That's on me, and I should have paced it better.

I've stopped opening new PRs here and won't add more until the queue clears.

If it would help the team, I'm happy to close the two most recent ones (#11958 and #11959) and re-open them later once you've had a chance to work through the rest. Just say the word and I'll do it. Equally happy to leave them and simply wait.

Thanks for the reviews so far.

@anakin87

Copy link
Copy Markdown
Member

No need to close them. Thank you again!

@anakin87 anakin87 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thank you!

Looking into this topic, the IDCG computation also needs a fix for duplicates.

evaluator = DocumentNDCGEvaluator()
result = evaluator.run(
    ground_truth_documents=[[Document(content="A", score=1.0), Document(content="A", score=1.0)]],
    retrieved_documents=[[Document(content="A")]],
)
print(result["score"])  # 0.6131471927654584, expected 1.0

Could you take a look, fix and add a test?

DCG summed a relevant document's relevance for every occurrence in the
retrieved list while IDCG counts each ground truth document once, so a
duplicated relevant document inflated DCG above IDCG and pushed NDCG
above the documented 0.0-1.0 range. Credit each ground truth value at
most once when computing DCG.
@Otis0408
Otis0408 force-pushed the fix/document-ndcg-score-capped branch from 8a19370 to 4eef147 Compare July 16, 2026 04:00
@Otis0408

Copy link
Copy Markdown
Contributor Author

Good catch, thank you. You're right that calculate_idcg had the mirror of the same bug: it iterated every ground-truth document including duplicates, so a duplicated relevant document inflated the ideal ranking and kept NDCG below 1.0 even for a perfect retrieval. Meanwhile calculate_dcg now credits each comparison value at most once, so the two sides disagreed.

I updated calculate_idcg to collapse ground-truth documents by comparison value (keeping the highest relevance per value), mirroring calculate_dcg, so the ideal reflects the unique relevant set. Your example now returns 1.0. Added a regression test test_run_with_duplicate_ground_truth_document_can_still_reach_perfect_ndcg covering exactly that case; it fails on the previous code and passes now. Pushed.

@anakin87

Copy link
Copy Markdown
Member

Thank you.

I notice that now the two functions treat duplicates differently: calculate_idcg keeps max, while calculate_dcg keeps last. I'd create a small helper function used by both that always uses max.

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

Labels

topic:tests type:documentation Improvements on the docs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants