use anyhow::{Context, Result};
use ndarray::Array2;
use ort::session::Session;
use ort::value::Tensor;
use std::sync::Mutex;
use tokenizers::Tokenizer;
use super::rerank::{Reranker, RerankerConfig};
pub struct OnnxCrossEncoder {
session: Mutex<Session>,
tokenizer: Tokenizer,
max_seq: usize,
wants_token_type_ids: bool,
output_name: String,
}
impl OnnxCrossEncoder {
pub fn load(cfg: &RerankerConfig) -> Result<Self> {
crate::index::embedder::auto_resolve_ort_dylib(false);
let session = Session::builder()
.map_err(|e| anyhow::anyhow!("Failed to create ONNX session builder: {e}"))?
.commit_from_file(&cfg.model_path)
.with_context(|| {
format!("Failed to load reranker ONNX model from {}", cfg.model_path)
})?;
let wants_token_type_ids = session
.inputs()
.iter()
.any(|input| input.name() == "token_type_ids");
let output_name = session
.outputs()
.first()
.map(|o| o.name().to_string())
.ok_or_else(|| anyhow::anyhow!("reranker model declares no outputs"))?;
let tokenizer = Tokenizer::from_file(&cfg.tokenizer_path)
.map_err(|e| anyhow::anyhow!("Failed to load reranker tokenizer: {e}"))?;
Ok(Self {
session: Mutex::new(session),
tokenizer,
max_seq: cfg.max_seq_len.max(8),
wants_token_type_ids,
output_name,
})
}
}
impl Reranker for OnnxCrossEncoder {
fn score(&self, query: &str, passages: &[&str]) -> Result<Vec<f32>> {
if passages.is_empty() {
return Ok(vec![]);
}
let pairs: Vec<(String, String)> = passages
.iter()
.map(|p| (query.to_string(), p.to_string()))
.collect();
let encodings = self
.tokenizer
.encode_batch(pairs, true)
.map_err(|e| anyhow::anyhow!("Reranker tokenization failed: {e}"))?;
let batch = encodings.len();
let max_len = encodings
.iter()
.map(|e| e.get_ids().len())
.max()
.unwrap_or(0)
.min(self.max_seq)
.max(1);
let mut input_ids = Array2::<i64>::zeros((batch, max_len));
let mut attention_mask = Array2::<i64>::zeros((batch, max_len));
let mut token_type_ids = Array2::<i64>::zeros((batch, max_len));
for (i, encoding) in encodings.iter().enumerate() {
let ids = encoding.get_ids();
let mask = encoding.get_attention_mask();
let types = encoding.get_type_ids();
for j in 0..ids.len().min(max_len) {
input_ids[[i, j]] = ids[j] as i64;
attention_mask[[i, j]] = mask[j] as i64;
token_type_ids[[i, j]] = types[j] as i64;
}
}
let mut session = self
.session
.lock()
.map_err(|e| anyhow::anyhow!("Reranker session lock poisoned: {e}"))?;
let to_tensor = |a: Array2<i64>, name: &str| {
Tensor::from_array(a)
.map_err(|e| anyhow::anyhow!("Failed to create {name} tensor: {e}"))
};
let outputs = if self.wants_token_type_ids {
session.run(ort::inputs![
"input_ids" => to_tensor(input_ids, "input_ids")?,
"attention_mask" => to_tensor(attention_mask, "attention_mask")?,
"token_type_ids" => to_tensor(token_type_ids, "token_type_ids")?,
])
} else {
session.run(ort::inputs![
"input_ids" => to_tensor(input_ids, "input_ids")?,
"attention_mask" => to_tensor(attention_mask, "attention_mask")?,
])
}
.map_err(|e| anyhow::anyhow!("Reranker ONNX inference failed: {e}"))?;
let (shape, data) = outputs[self.output_name.as_str()]
.try_extract_tensor::<f32>()
.map_err(|e| anyhow::anyhow!("Failed to extract reranker logits: {e}"))?;
let dims: Vec<usize> = shape.iter().map(|&d| d as usize).collect();
let per_row = data.len() / batch;
let single_logit = matches!(dims.as_slice(), [b, 1] | [b] if *b == batch);
if !single_logit || per_row == 0 {
anyhow::bail!(
"reranker model output {:?} has shape {:?}; expected a single \
logit per pair ([batch, 1] or [batch]) — is this a cross-encoder?",
self.output_name,
dims
);
}
Ok((0..batch).map(|i| data[i * per_row]).collect())
}
}