use std::sync::Arc;
use axum::extract::State;
use axum::http::StatusCode;
use axum::Json;
use serde::Deserialize;
use ferrox_models::{EmbedError, EmbeddingModel};
use crate::openai_extra::Call;
use crate::{join_error_response, unsupported_feature, ApiError, AppState};
#[derive(Debug, Deserialize)]
pub(crate) struct RerankRequest {
#[serde(default)]
model: Option<String>,
query: String,
documents: Vec<String>,
#[serde(default)]
top_n: Option<usize>,
#[serde(default)]
return_documents: bool,
}
pub(crate) fn encoder_endpoints(encoder: &EmbeddingModel) -> Vec<&'static str> {
let mut out = Vec::with_capacity(2);
if encoder.pooling_type() != ferrox_models::pooling::PoolingType::Rank {
out.push(ferrox_api::routes::V1_EMBEDDINGS);
}
if encoder.rank_head().is_some() {
out.push(ferrox_api::routes::V1_RERANK);
}
out
}
fn bad_request(message: String) -> ApiError {
(
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": { "message": message } })),
)
}
fn ranking(scores: &[f32], top_n: Option<usize>) -> Vec<usize> {
let mut order: Vec<usize> = (0..scores.len()).collect();
order.sort_by(|&a, &b| scores[b].total_cmp(&scores[a]));
order.truncate(top_n.unwrap_or(order.len()));
order
}
fn finite_score(score: f32, index: usize) -> Result<f32, ApiError> {
if score.is_finite() {
return Ok(score);
}
Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": {
"message": format!(
"the classification head produced a non-finite relevance score ({score}) \
for document {index}. JSON cannot carry it, and reporting it as null \
would read as a valid score of zero"
),
"type": "internal_error",
}})),
))
}
fn no_rank_head(name: &str, arch: &str) -> ApiError {
let why = EmbedError::NoRankHead {
name: name.to_string(),
arch: arch.to_string(),
};
unsupported_feature(&format!(
"{why}. POST {} to use it for embeddings, or load a reranker checkpoint",
ferrox_api::routes::V1_EMBEDDINGS,
))
}
fn not_a_reranker(model: &str) -> ApiError {
unsupported_feature(&format!(
"the loaded model '{model}' is not a reranker. {} needs a cross-encoder checkpoint \
carrying a classification head (a `bert` GGUF with `cls` / `cls.output` tensors), \
loaded as FERROX_MODEL_PATH or beside this one as FERROX_EMBEDDING_MODEL_PATH. A \
generative model cannot answer this route, and the cosine of its embeddings is \
not a substitute for a rerank score",
ferrox_api::routes::V1_RERANK,
))
}
fn require_reranker(state: &AppState) -> Result<Arc<EmbeddingModel>, ApiError> {
let Some(encoder) = state.embedding_model() else {
return Err(state.require_active().err().unwrap_or_else(|| {
not_a_reranker(&state.active_model_name().unwrap_or_else(|| "?".to_string()))
}));
};
if encoder.rank_head().is_none() {
return Err(no_rank_head(encoder.name(), encoder.architecture()));
}
Ok(encoder)
}
fn validate(query: &str, documents: &[String]) -> Result<(), ApiError> {
if documents.is_empty() {
return Err(bad_request(
"documents must be a non-empty array of strings".to_string(),
));
}
if query.is_empty() {
return Err(bad_request(
"query must be a non-empty string; a rerank scores each document against it"
.to_string(),
));
}
Ok(())
}
fn embed_error(index: usize, e: EmbedError) -> ApiError {
match e {
EmbedError::NoRankHead { .. } | EmbedError::NoPairInput { .. } => {
unsupported_feature(&e.to_string())
}
other => bad_request(format!("document {index}: {other}")),
}
}
async fn score_documents(
encoder: Arc<EmbeddingModel>,
query: String,
documents: Arc<Vec<String>>,
) -> Result<(Vec<f32>, usize), ApiError> {
tokio::task::spawn_blocking(move || {
let mut scores = Vec::with_capacity(documents.len());
let mut prompt_tokens = 0usize;
for (i, doc) in documents.iter().enumerate() {
let ids = encoder
.rerank_token_ids(&query, doc)
.map_err(|e| embed_error(i, e))?;
prompt_tokens += ids.len();
let score = encoder.rerank_score(&ids).map_err(|e| embed_error(i, e))?;
scores.push(finite_score(score, i)?);
}
Ok::<_, ApiError>((scores, prompt_tokens))
})
.await
.map_err(join_error_response)?
}
fn results_json(
order: &[usize],
scores: &[f32],
documents: &[String],
return_documents: bool,
) -> Vec<serde_json::Value> {
order
.iter()
.map(|&i| {
let mut row = serde_json::json!({
"index": i,
"relevance_score": scores[i],
});
if return_documents {
row["document"] = serde_json::json!({ "text": documents[i] });
}
row
})
.collect()
}
pub async fn rerank(
State(state): State<Arc<AppState>>,
headers: axum::http::HeaderMap,
Json(req): Json<RerankRequest>,
) -> Result<Json<serde_json::Value>, ApiError> {
let call = Call::new(&headers);
let result = rerank_inner(&state, req).await;
let usage = result
.as_ref()
.ok()
.map(|(_, prompt_tokens)| ferrox_api::Usage::new(*prompt_tokens, 0));
call.record(
&state,
ferrox_api::routes::V1_RERANK,
state.embedding_model_name(),
&result,
usage.as_ref(),
);
result.map(|(body, _)| Json(body))
}
async fn rerank_inner(
state: &AppState,
req: RerankRequest,
) -> Result<(serde_json::Value, usize), ApiError> {
validate(&req.query, &req.documents)?;
let encoder = require_reranker(state)?;
let model_name = req
.model
.clone()
.unwrap_or_else(|| encoder.name().to_string());
let score_label = encoder
.rank_head()
.and_then(|h| h.labels().first().cloned());
let documents = Arc::new(req.documents);
let (scores, prompt_tokens) =
score_documents(encoder, req.query, Arc::clone(&documents)).await?;
let order = ranking(&scores, req.top_n);
let mut body = serde_json::json!({
"object": "list",
"model": model_name,
"results": results_json(&order, &scores, &documents, req.return_documents),
"usage": {
"prompt_tokens": prompt_tokens,
"total_tokens": prompt_tokens,
}
});
if let Some(label) = score_label {
body["ferrox_score_label"] = serde_json::json!(label);
}
Ok((body, prompt_tokens))
}
#[cfg(test)]
mod tests {
use super::*;
fn docs(n: usize) -> Vec<String> {
(0..n).map(|i| format!("doc{i}")).collect()
}
#[test]
fn the_index_is_the_position_in_the_request_not_in_the_sorted_output() {
let scores = [0.1f32, 0.9, 0.5];
assert_eq!(ranking(&scores, None), vec![1, 2, 0]);
let rows = results_json(&ranking(&scores, None), &scores, &docs(3), false);
assert_eq!(rows[0]["index"], 1);
assert_eq!(rows[1]["index"], 2);
assert_eq!(rows[2]["index"], 0);
assert_eq!(rows[0]["relevance_score"], 0.9f32);
assert_eq!(rows[2]["relevance_score"], 0.1f32);
}
#[test]
fn ties_keep_the_order_the_caller_sent() {
let scores: Vec<f32> = (0..64).map(|i| (i % 2) as f32).collect();
let expected: Vec<usize> = (0..64)
.filter(|i| i % 2 == 1)
.chain((0..64).filter(|i| i % 2 == 0))
.collect();
assert_eq!(ranking(&scores, None), expected);
}
#[test]
fn a_top_n_larger_than_the_document_list_returns_every_document() {
let scores = [0.1f32, 0.9];
assert_eq!(ranking(&scores, Some(1000)), vec![1, 0]);
assert_eq!(ranking(&scores, Some(2)), vec![1, 0]);
assert_eq!(ranking(&scores, None), vec![1, 0]);
}
#[test]
fn a_top_n_of_zero_returns_no_results() {
let scores = [0.1f32, 0.9, 0.5];
assert!(ranking(&scores, Some(0)).is_empty());
assert!(results_json(&ranking(&scores, Some(0)), &scores, &docs(3), true).is_empty());
}
#[test]
fn an_empty_documents_array_and_an_empty_query_are_refused_by_name() {
assert!(validate("q", &docs(1)).is_ok());
assert!(validate("q", &[String::new()]).is_ok());
let (status, body) = validate("q", &[]).unwrap_err();
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.0["error"]["message"]
.as_str()
.unwrap()
.contains("documents"));
let (status, body) = validate("", &docs(1)).unwrap_err();
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body.0["error"]["message"]
.as_str()
.unwrap()
.contains("query"));
}
#[test]
fn the_two_501s_name_the_model_and_the_route_that_would_serve_it() {
let (status, body) = no_rank_head("bge-small-en-v1.5", "bert");
assert_eq!(status, StatusCode::NOT_IMPLEMENTED);
let msg = body.0["error"]["message"].as_str().unwrap().to_string();
for fact in ["bge-small-en-v1.5", "bert", "cls", "/v1/embeddings"] {
assert!(msg.contains(fact), "{msg} does not carry {fact}");
}
let (status, body) = not_a_reranker("llama-3.2-3b");
assert_eq!(status, StatusCode::NOT_IMPLEMENTED);
let msg = body.0["error"]["message"].as_str().unwrap().to_string();
for fact in ["llama-3.2-3b", "cross-encoder", "/v1/rerank"] {
assert!(msg.contains(fact), "{msg} does not carry {fact}");
}
assert_ne!(status, StatusCode::SERVICE_UNAVAILABLE);
}
#[test]
fn a_non_finite_score_is_refused_rather_than_serialized_as_null() {
assert!(serde_json::json!(f32::NAN).is_null());
assert!(serde_json::json!(f32::INFINITY).is_null());
assert_eq!(finite_score(0.0, 0).unwrap(), 0.0);
assert_eq!(finite_score(-12.5, 0).unwrap(), -12.5);
for bad in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
let (status, body) = finite_score(bad, 3).unwrap_err();
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
let msg = body.0["error"]["message"].as_str().unwrap();
assert!(msg.contains("document 3"), "{msg}");
}
}
#[test]
fn return_documents_echoes_the_row_s_own_document_and_omitting_it_omits_the_key() {
let scores = [0.1f32, 0.9, 0.5];
let order = ranking(&scores, None);
let rows = results_json(&order, &scores, &docs(3), true);
assert_eq!(rows[0]["document"]["text"], "doc1");
assert_eq!(rows[2]["document"]["text"], "doc0");
let bare = results_json(&order, &scores, &docs(3), false);
assert!(bare[0].get("document").is_none());
}
}