use std::{collections::BTreeSet, sync::Arc, time::Instant};
use runifold_core::Usage;
use runifold_retrieval::{DocumentId, RetrievalContext, RetrievalError, RetrievalQuery, Retriever};
use thiserror::Error;
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum RetrievalEvaluationError {
#[error("retrieval evaluation case id cannot be empty")]
EmptyCaseId,
#[error("retrieval evaluation case must contain at least one relevant document")]
EmptyRelevantDocuments,
#[error("retrieval evaluation cutoff must be greater than zero")]
ZeroCutoff,
#[error("retrieval evaluation collection is too large for metric calculation")]
CountOutOfRange,
#[error("retrieval evaluation failed: {0}")]
Retrieval(#[from] RetrievalError),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RetrievalEvaluationCase {
pub id: String,
pub query: String,
pub relevant: BTreeSet<DocumentId>,
pub cutoff: usize,
}
impl RetrievalEvaluationCase {
pub fn new(
id: impl Into<String>,
query: impl Into<String>,
relevant: impl IntoIterator<Item = DocumentId>,
cutoff: usize,
) -> Result<Self, RetrievalEvaluationError> {
let id = id.into();
if id.trim().is_empty() {
return Err(RetrievalEvaluationError::EmptyCaseId);
}
if cutoff == 0 {
return Err(RetrievalEvaluationError::ZeroCutoff);
}
let query = query.into();
RetrievalQuery::new(query.clone(), cutoff)?;
let relevant = relevant.into_iter().collect::<BTreeSet<_>>();
if relevant.is_empty() {
return Err(RetrievalEvaluationError::EmptyRelevantDocuments);
}
Ok(Self {
id,
query,
relevant,
cutoff,
})
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct RetrievalCaseMetrics {
pub id: String,
pub precision_at_k: f64,
pub recall_at_k: f64,
pub reciprocal_rank: f64,
pub ndcg_at_k: f64,
pub usage: Usage,
pub elapsed_micros: u64,
}
#[derive(Clone, Debug, PartialEq)]
pub struct RetrievalEvaluationReport {
pub cases: Vec<RetrievalCaseMetrics>,
pub mean_precision_at_k: f64,
pub mean_recall_at_k: f64,
pub mean_reciprocal_rank: f64,
pub mean_ndcg_at_k: f64,
pub mean_elapsed_micros: u64,
}
#[derive(Clone)]
pub struct RetrievalEvaluationRunner {
retriever: Arc<dyn Retriever>,
}
impl RetrievalEvaluationRunner {
pub fn new(retriever: Arc<dyn Retriever>) -> Self {
Self { retriever }
}
pub async fn run(
&self,
cases: &[RetrievalEvaluationCase],
) -> Result<RetrievalEvaluationReport, RetrievalEvaluationError> {
let mut metrics = Vec::with_capacity(cases.len());
for case in cases {
let started = Instant::now();
let response = self
.retriever
.retrieve(
RetrievalQuery::new(case.query.clone(), case.cutoff)?,
RetrievalContext::new(),
)
.await?;
let elapsed_micros = u64::try_from(started.elapsed().as_micros()).unwrap_or(u64::MAX);
let ranked = response
.documents
.iter()
.take(case.cutoff)
.map(|result| &result.document.id)
.collect::<Vec<_>>();
let hits = ranked
.iter()
.filter(|id| case.relevant.contains(*id))
.count();
let reciprocal_rank = ranked
.iter()
.position(|id| case.relevant.contains(*id))
.map(|index| count_as_f64(index + 1).map(|rank| 1.0 / rank))
.transpose()?
.unwrap_or(0.0);
let dcg = ranked
.iter()
.enumerate()
.filter(|(_, id)| case.relevant.contains(**id))
.map(|(index, _)| count_as_f64(index + 2).map(|rank| 1.0 / rank.log2()))
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.sum::<f64>();
let ideal = case.relevant.len().min(case.cutoff);
let idcg = (0..ideal)
.map(|index| count_as_f64(index + 2).map(|rank| 1.0 / rank.log2()))
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.sum::<f64>();
metrics.push(RetrievalCaseMetrics {
id: case.id.clone(),
precision_at_k: count_as_f64(hits)? / count_as_f64(case.cutoff)?,
recall_at_k: count_as_f64(hits)? / count_as_f64(case.relevant.len())?,
reciprocal_rank,
ndcg_at_k: dcg / idcg,
usage: response.usage,
elapsed_micros,
});
}
aggregate(metrics)
}
}
impl std::fmt::Debug for RetrievalEvaluationRunner {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("RetrievalEvaluationRunner")
.field("retriever", self.retriever.descriptor())
.finish()
}
}
fn aggregate(
cases: Vec<RetrievalCaseMetrics>,
) -> Result<RetrievalEvaluationReport, RetrievalEvaluationError> {
if cases.is_empty() {
return Ok(RetrievalEvaluationReport {
cases,
mean_precision_at_k: 0.0,
mean_recall_at_k: 0.0,
mean_reciprocal_rank: 0.0,
mean_ndcg_at_k: 0.0,
mean_elapsed_micros: 0,
});
}
let count = count_as_f64(cases.len())?;
let elapsed_total = cases
.iter()
.map(|case| u128::from(case.elapsed_micros))
.sum::<u128>();
let elapsed_mean = elapsed_total
/ u128::try_from(cases.len()).map_err(|_| RetrievalEvaluationError::CountOutOfRange)?;
Ok(RetrievalEvaluationReport {
mean_precision_at_k: cases.iter().map(|case| case.precision_at_k).sum::<f64>() / count,
mean_recall_at_k: cases.iter().map(|case| case.recall_at_k).sum::<f64>() / count,
mean_reciprocal_rank: cases.iter().map(|case| case.reciprocal_rank).sum::<f64>() / count,
mean_ndcg_at_k: cases.iter().map(|case| case.ndcg_at_k).sum::<f64>() / count,
mean_elapsed_micros: u64::try_from(elapsed_mean)
.map_err(|_| RetrievalEvaluationError::CountOutOfRange)?,
cases,
})
}
fn count_as_f64(value: usize) -> Result<f64, RetrievalEvaluationError> {
u32::try_from(value)
.map(f64::from)
.map_err(|_| RetrievalEvaluationError::CountOutOfRange)
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use runifold_retrieval::{
Document, RetrievalFuture, RetrievalResponse, RetrievedDocument, RetrieverDescriptor,
};
use super::*;
struct RankedRetriever {
descriptor: RetrieverDescriptor,
}
impl Retriever for RankedRetriever {
fn descriptor(&self) -> &RetrieverDescriptor {
&self.descriptor
}
fn retrieve(
&self,
_query: RetrievalQuery,
_context: RetrievalContext,
) -> RetrievalFuture<'_, Result<RetrievalResponse, RetrievalError>> {
Box::pin(async {
Ok(RetrievalResponse {
documents: vec![
RetrievedDocument {
document: Document::new("irrelevant", "noise").unwrap(),
score: 1.0,
},
RetrievedDocument {
document: Document::new("relevant", "answer").unwrap(),
score: 0.9,
},
],
usage: Usage::default(),
})
})
}
}
#[test]
fn computes_rank_sensitive_metrics_from_stable_evidence() {
let case = RetrievalEvaluationCase::new(
"case",
"query",
[DocumentId::new("relevant").unwrap()],
2,
)
.unwrap();
let runner = RetrievalEvaluationRunner::new(Arc::new(RankedRetriever {
descriptor: RetrieverDescriptor {
metadata: BTreeMap::new(),
..RetrieverDescriptor::read_only("ranked")
},
}));
let report = futures_executor::block_on(runner.run(&[case])).unwrap();
assert!((report.mean_precision_at_k - 0.5).abs() < f64::EPSILON);
assert!((report.mean_recall_at_k - 1.0).abs() < f64::EPSILON);
assert!((report.mean_reciprocal_rank - 0.5).abs() < f64::EPSILON);
assert!(report.mean_ndcg_at_k > 0.6 && report.mean_ndcg_at_k < 0.7);
}
}