Spaces:
Sleeping
Sleeping
File size: 6,821 Bytes
2e818da | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 | """Labelled-evaluation quality scoring for the RAG observability benchmark.
Implements exactly the "Labelled evaluation quality measures" from the plan
preamble against this benchmark's real candidate shape (`document_id` +
`chunk_index`, as `ChromaDBClient.query_observed` / `_get_chunks` actually
return them -- see `app/websockets/handlers.py::_get_chunks`). `page_hit` from
the preamble's canonical list becomes `section_hit` here: this corpus is plain
text, not paginated PDFs, and the plan preamble explicitly allows substituting
sections for pages in that case.
Two entry points, matching the task-4 brief's two example tests:
* `score_retrieval(label, candidates)` -- pure ranking-quality scoring over an
already-successful candidate list. Knows nothing about *why* a retrieval
produced its candidates, only whether they're relevant.
* `score_outcome(label, outcome)` -- the full labelled-quality result for one
`RetrievalOutcome`, preserving the empty-vs-error distinction the whole
telemetry contract exists to keep visible: a fetch that legitimately found
nothing (`success_empty`) is never scored the same as a fetch that failed
outright (`error_fallback`). Delegates to `score_retrieval` only on the
success path.
Emits nothing -- pure functions over inputs already in memory, called by
`runner.py` once per measured query invocation.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from app.benchmarks.rag_observability.corpus import QueryLabel, SectionIndex
from app.observability.contracts import RetrievalOutcome
@dataclass(frozen=True)
class RetrievalQualityResult:
"""Ranking-quality result over a candidate list alone (no outcome context)."""
document_hit: bool
# None when the label has no expected_sections (not applicable). When not
# None, this is an ANY-match: True if at least one candidate's section is
# in `label.expected_sections`, not a requirement that every expected
# section be matched.
section_hit: bool | None
reciprocal_rank: float
@dataclass(frozen=True)
class QualityResult:
"""The full canonical labelled-quality row for one scored query invocation."""
document_hit: bool
section_hit: bool | None # any-expected-section-matched, not all -- see RetrievalQualityResult
reciprocal_rank: float
citation_validity: bool | None # None when the label doesn't require a citation
empty_result: bool
retrieval_error: bool
def score_retrieval(label: QueryLabel, candidates: list[dict[str, Any]]) -> RetrievalQualityResult:
"""Score a ranked candidate list against `label`'s expected documents/sections.
`reciprocal_rank` is standard MRR: 1 / rank of the first candidate whose
`document_id` is in `label.expected_documents` (1-indexed), or 0.0 if none
match. `section_hit` only considers candidates that *also* matched an
expected document -- a section match on an irrelevant document is not
meaningful (see `test_section_hit_only_counts_among_expected_document_candidates`).
"""
document_hit = False
reciprocal_rank = 0.0
section_hit: bool | None = None if not label.expected_sections else False
section_index = _section_index() if label.expected_sections else None
for rank, candidate in enumerate(candidates, start=1):
doc_id = candidate.get("document_id")
if doc_id not in label.expected_documents:
continue
if not document_hit:
document_hit = True
reciprocal_rank = 1.0 / rank
if section_index is not None:
section = section_index.section_for_chunk(doc_id, candidate.get("chunk_index"))
if section in label.expected_sections:
# Any-match semantics: one hit anywhere in the ranked list is
# enough. This never resets to False and never requires every
# entry in `label.expected_sections` to be covered.
section_hit = True
return RetrievalQualityResult(
document_hit=document_hit,
section_hit=section_hit,
reciprocal_rank=reciprocal_rank,
)
def score_outcome(label: QueryLabel, outcome: RetrievalOutcome) -> QualityResult:
"""Score one full `RetrievalOutcome`, preserving empty-vs-error distinctly.
A failed retrieval (`retrieval_error=True`) is scored as a total miss on
every ranking measure without ever being conflated with a legitimate empty
result -- an error and an empty result are different rows in the canonical
contract and must stay that way here too.
"""
if outcome.retrieval_error:
return QualityResult(
document_hit=False,
section_hit=None,
reciprocal_rank=0.0,
citation_validity=None,
empty_result=False,
retrieval_error=True,
)
ranking = score_retrieval(label, outcome.candidates)
citation_validity = _score_citation_validity(label, outcome, ranking)
return QualityResult(
document_hit=ranking.document_hit,
section_hit=ranking.section_hit,
reciprocal_rank=ranking.reciprocal_rank,
citation_validity=citation_validity,
empty_result=outcome.empty_result,
retrieval_error=False,
)
def _score_citation_validity(
label: QueryLabel, outcome: RetrievalOutcome, ranking: RetrievalQualityResult
) -> bool | None:
"""Whether a citation-required query has (or correctly lacks) grounding to cite.
Not a claim about any LLM-generated citation text -- this benchmark only
measures retrieval, never synthesis. It is a retrieval-observable proxy:
for an answerable query, valid means the expected document was actually
retrieved (`document_hit`); for a deliberately unanswerable query, valid
means retrieval came back empty -- any non-empty result would be exactly
the retrieval-side precondition for a downstream fabricated citation.
"""
if not label.citation_required:
return None
if label.answerability == "unanswerable":
return len(outcome.candidates) == 0
return ranking.document_hit
_section_index_cache: SectionIndex | None = None
def _section_index() -> SectionIndex:
"""Lazily build (and cache) the corpus section index on first use.
Scoring is called many times (once per measured repetition) against the
same fixed corpus, so this avoids re-chunking every document on every
query. Module-level cache is safe here: the benchmark process only ever
loads one corpus version per run.
"""
global _section_index_cache
if _section_index_cache is None:
from app.benchmarks.rag_observability.corpus import load_corpus
_section_index_cache = SectionIndex(load_corpus())
return _section_index_cache
|