uni_xervo/provider/llamacpp/rerank.rs
1//! [`ModelTask::Rerank`](crate::api::ModelTask::Rerank) on `POST /v1/rerank`.
2//!
3//! The endpoint is llama.cpp's own, reachable as `/rerank`, `/reranking`,
4//! `/v1/rerank` or `/v1/reranking`; this module posts to the `/v1` form so one
5//! `base_url` serves every task. The server must have been started with
6//! `--reranking` (or `--embedding --pooling rank`) and a cross-encoder reranker
7//! such as `jina-reranker-v1-tiny-en` or `bge-reranker-v2-m3`. A server without
8//! it answers HTTP 501, which surfaces as a non-retryable
9//! [`RuntimeError::InferenceError`] naming the flag.
10//!
11//! # Options
12//!
13//! None beyond the shared keys documented on the [parent module](super).
14//!
15//! # Score domain
16//!
17//! `llama-server` returns a sigmoid of the cross-encoder logit — bounded to
18//! `[0, 1]`, and in practice small even for a good match (0.13 for an exact
19//! answer is typical). The ONNX cross-encoder reranker returns the **raw
20//! logit** instead, unbounded in both directions. So rankings from the two
21//! providers are comparable but absolute scores are not: a threshold tuned
22//! against `local/onnx` will reject everything here. Neither score is converted
23//! — a provider that quietly rescaled would make the difference harder to spot,
24//! not easier.
25//!
26//! # `top_n` is deliberately not sent
27//!
28//! [`RerankerModel::rerank`] contracts to return a score for **every** input
29//! document, and the server truncates to `top_n` when asked. Callers that want
30//! the top *k* truncate the returned slice, which is already sorted.
31//!
32//! # Request shape
33//!
34//! The response shape depends on the *request* key: `documents` yields the Jina
35//! form (`{"results": [{"index", "relevance_score"}]}`) and `texts` yields the
36//! TEI form (a bare array of `{"index", "score"}`). This module always sends
37//! `documents`, so the Jina form is what a real `llama-server` returns — but
38//! [`parse_rerank_response`] accepts both, since a gateway in front of the
39//! server may normalise one into the other.
40
41use super::{LlamaCppCommon, post_json};
42use crate::error::{Result, RuntimeError};
43use crate::traits::{RerankerModel, ScoredDoc};
44use async_trait::async_trait;
45use reqwest::Client;
46use serde_json::{Value, json};
47
48// ---------------------------------------------------------------------------
49// Configuration
50// ---------------------------------------------------------------------------
51
52/// Resolved rerank configuration for one alias.
53///
54/// Reranking adds no keys of its own, so this is the shared set verbatim. It
55/// exists as a named type so the `load` dispatch reads the same way for every
56/// task.
57#[derive(Debug, Clone)]
58pub(crate) struct RerankConfig {
59 pub common: LlamaCppCommon,
60}
61
62impl RerankConfig {
63 /// Parse the alias `options`. The catalog validator already enforces the
64 /// same rules, but `ModelProvider::load` is public, so this is defensive.
65 pub(crate) fn from_options(options: &Value) -> Result<Self> {
66 Ok(Self {
67 common: LlamaCppCommon::from_options(options)?,
68 })
69 }
70}
71
72// ---------------------------------------------------------------------------
73// Response parsing (pure)
74// ---------------------------------------------------------------------------
75
76/// Read either response shape into `(index, score)` pairs, in the order the
77/// server listed them.
78///
79/// Accepts the Jina form this module's requests produce and the TEI form a
80/// proxy might, distinguished by structure rather than by any configured
81/// flag — an array is TEI, an object with `results` is Jina.
82fn parse_rerank_response(body: &Value) -> Result<Vec<(usize, f32)>> {
83 let (entries, score_key) = match body {
84 Value::Array(entries) => (entries, "score"),
85 _ => {
86 let entries = body
87 .get("results")
88 .and_then(|r| r.as_array())
89 .ok_or_else(|| {
90 RuntimeError::ApiError(
91 "llama.cpp /v1/rerank response malformed: expected a 'results' array \
92 or a top-level array"
93 .to_string(),
94 )
95 })?;
96 (entries, "relevance_score")
97 }
98 };
99
100 entries
101 .iter()
102 .enumerate()
103 .map(|(position, entry)| {
104 let index = entry.get("index").and_then(|i| i.as_u64()).ok_or_else(|| {
105 RuntimeError::ApiError(format!(
106 "llama.cpp /v1/rerank response malformed: result {} has no integer \
107 'index'",
108 position
109 ))
110 })? as usize;
111 // Accept either key regardless of the shape it arrived in: the two
112 // differ only in naming, and a mismatch is not worth an error.
113 // `score_key` only shapes the message when neither is present.
114 let score = entry
115 .get("relevance_score")
116 .or_else(|| entry.get("score"))
117 .and_then(|s| s.as_f64())
118 .ok_or_else(|| {
119 RuntimeError::ApiError(format!(
120 "llama.cpp /v1/rerank response malformed: result {} has no numeric \
121 '{}'",
122 position, score_key
123 ))
124 })? as f32;
125 Ok((index, score))
126 })
127 .collect()
128}
129
130/// Turn raw `(index, score)` pairs into [`ScoredDoc`]s against the original
131/// `docs` slice.
132///
133/// Every index is checked rather than trusted. A reranker's whole job is to
134/// permute the caller's list, so an index past the end or seen twice would
135/// silently mis-attribute a score to the wrong document — a corruption that
136/// reads as a ranking bug rather than a protocol one. `expected` results must
137/// all be present: a server that dropped one (by honouring a `top_n` we never
138/// sent, say) would otherwise look like a document scoring zero.
139///
140/// `text` is always `None`: `llama-server` echoes the document text only in the
141/// TEI form with `return_text`, which this module does not request, and the
142/// caller already holds the originals.
143fn build_scored_docs(pairs: Vec<(usize, f32)>, expected: usize) -> Result<Vec<ScoredDoc>> {
144 if pairs.len() != expected {
145 return Err(RuntimeError::ApiError(format!(
146 "llama.cpp /v1/rerank returned {} results for {} documents",
147 pairs.len(),
148 expected
149 )));
150 }
151
152 let mut seen = vec![false; expected];
153 let mut docs = Vec::with_capacity(expected);
154 for (index, score) in pairs {
155 if index >= expected {
156 return Err(RuntimeError::ApiError(format!(
157 "llama.cpp /v1/rerank returned index {} for a batch of {} documents",
158 index, expected
159 )));
160 }
161 if std::mem::replace(&mut seen[index], true) {
162 return Err(RuntimeError::ApiError(format!(
163 "llama.cpp /v1/rerank returned index {} more than once",
164 index
165 )));
166 }
167 docs.push(ScoredDoc {
168 index,
169 score,
170 text: None,
171 });
172 }
173
174 // The server sorts already; re-sorting makes the trait's "typically sorted
175 // by descending score" hold whatever a gateway in between did. `total_cmp`
176 // rather than `partial_cmp` so a NaN score cannot panic the comparator.
177 docs.sort_by(|a, b| b.score.total_cmp(&a.score));
178 Ok(docs)
179}
180
181// ---------------------------------------------------------------------------
182// Reranker model
183// ---------------------------------------------------------------------------
184
185/// Reranker backed by a llama.cpp server.
186pub struct LlamaCppRerankerModel {
187 client: Client,
188 cb: crate::reliability::CircuitBreakerWrapper,
189 model_id: String,
190 cfg: RerankConfig,
191}
192
193impl LlamaCppRerankerModel {
194 pub(crate) fn new(
195 client: Client,
196 cb: crate::reliability::CircuitBreakerWrapper,
197 model_id: String,
198 cfg: RerankConfig,
199 ) -> Self {
200 Self {
201 client,
202 cb,
203 model_id,
204 cfg,
205 }
206 }
207
208 async fn rerank_pipeline(&self, query: &str, docs: &[String]) -> Result<Vec<ScoredDoc>> {
209 let body = post_json(
210 &self.client,
211 &self.cfg.common,
212 format!("{}/rerank", self.cfg.common.base_url),
213 // No `top_n`: see the module header.
214 &json!({
215 "model": self.model_id,
216 "query": query,
217 "documents": docs,
218 }),
219 // Reranking has no `max_input_tokens` option, so an overflow can
220 // only be answered with the server's own flags.
221 None,
222 )
223 .await?;
224 build_scored_docs(parse_rerank_response(&body)?, docs.len())
225 }
226}
227
228#[async_trait]
229impl RerankerModel for LlamaCppRerankerModel {
230 async fn rerank(&self, query: &str, docs: &[&str]) -> Result<Vec<ScoredDoc>> {
231 // `llama-server` answers an empty `documents` array with HTTP 400
232 // ("must be a non-empty string array"), so nothing-to-rank is settled
233 // here rather than turned into a spurious error.
234 if docs.is_empty() {
235 return Ok(Vec::new());
236 }
237 let query = query.to_string();
238 let docs: Vec<String> = docs.iter().map(|s| s.to_string()).collect();
239
240 // Deterministic rejections (`InferenceError`, produced only by
241 // `map_llamacpp_status`) are a configuration problem rather than a
242 // server outage — a reranker server started without `--reranking`
243 // answers every request with 501 — so they are returned as
244 // `Ok(Err(..))`, leaving the breaker closed, and flattened here. Same
245 // contract as `embed` and `chat`.
246 self.cb
247 .call(move || async move {
248 match self.rerank_pipeline(&query, &docs).await {
249 Err(e @ RuntimeError::InferenceError(_)) => Ok(Err(e)),
250 other => other.map(Ok),
251 }
252 })
253 .await?
254 }
255}
256
257impl crate::traits::ModelInfo for LlamaCppRerankerModel {
258 fn model_id(&self) -> &str {
259 &self.model_id
260 }
261}
262
263// ---------------------------------------------------------------------------
264// Tests (no HTTP)
265// ---------------------------------------------------------------------------
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270
271 // --- config -------------------------------------------------------------
272
273 #[test]
274 fn config_needs_only_the_shared_keys() {
275 let cfg = RerankConfig::from_options(&json!({
276 "base_url": "http://127.0.0.1:8080/v1/"
277 }))
278 .unwrap();
279 assert_eq!(cfg.common.base_url, "http://127.0.0.1:8080/v1");
280 }
281
282 #[test]
283 fn config_requires_base_url() {
284 let err = RerankConfig::from_options(&json!({})).expect_err("must fail");
285 assert!(err.to_string().contains("base_url"), "{err}");
286 }
287
288 // --- parsing ------------------------------------------------------------
289
290 /// The shape a real `llama-server` returns for a `documents` request,
291 /// copied from a live response.
292 #[test]
293 fn parses_the_jina_shape() {
294 let body = json!({
295 "model": "jina",
296 "object": "list",
297 "usage": { "prompt_tokens": 59, "total_tokens": 59 },
298 "results": [
299 { "index": 0, "relevance_score": 0.12988508 },
300 { "index": 2, "relevance_score": 0.034857254 },
301 { "index": 1, "relevance_score": 0.009407487 },
302 ]
303 });
304 assert_eq!(
305 parse_rerank_response(&body).unwrap(),
306 vec![(0, 0.12988508), (2, 0.034857254), (1, 0.009407487)]
307 );
308 }
309
310 /// The bare-array shape a `texts` request (or a gateway) produces.
311 #[test]
312 fn parses_the_tei_shape() {
313 let body = json!([
314 { "index": 1, "score": 0.9 },
315 { "index": 0, "score": 0.1 },
316 ]);
317 assert_eq!(
318 parse_rerank_response(&body).unwrap(),
319 vec![(1, 0.9), (0, 0.1)]
320 );
321 }
322
323 #[test]
324 fn parses_either_score_key_in_either_shape() {
325 // A gateway that keeps the Jina envelope but the TEI score key.
326 let body = json!({ "results": [{ "index": 0, "score": 0.5 }] });
327 assert_eq!(parse_rerank_response(&body).unwrap(), vec![(0, 0.5)]);
328
329 // ... and the reverse.
330 let body = json!([{ "index": 0, "relevance_score": 0.5 }]);
331 assert_eq!(parse_rerank_response(&body).unwrap(), vec![(0, 0.5)]);
332 }
333
334 #[test]
335 fn a_response_that_is_neither_shape_is_an_error() {
336 for body in [json!({}), json!({ "results": "x" }), json!("nope")] {
337 let err = parse_rerank_response(&body).expect_err("must fail");
338 assert!(
339 matches!(err, RuntimeError::ApiError(ref m) if m.contains("results")),
340 "{err:?}"
341 );
342 }
343 }
344
345 #[test]
346 fn a_result_missing_index_or_score_is_an_error() {
347 for (body, needle) in [
348 (json!({ "results": [{ "relevance_score": 0.5 }] }), "index"),
349 (json!({ "results": [{ "index": 0 }] }), "relevance_score"),
350 (
351 json!({ "results": [{ "index": -1, "relevance_score": 0.5 }] }),
352 "index",
353 ),
354 ] {
355 let err = parse_rerank_response(&body).expect_err("must fail");
356 assert!(err.to_string().contains(needle), "{err}");
357 }
358 }
359
360 // --- index mapping ------------------------------------------------------
361
362 #[test]
363 fn scored_docs_keep_the_original_indices_and_sort_descending() {
364 let docs = build_scored_docs(vec![(2, 0.1), (0, 0.9), (1, 0.5)], 3).unwrap();
365 assert_eq!(docs.iter().map(|d| d.index).collect::<Vec<_>>(), [0, 1, 2]);
366 assert_eq!(
367 docs.iter().map(|d| d.score).collect::<Vec<_>>(),
368 [0.9, 0.5, 0.1]
369 );
370 assert!(docs.iter().all(|d| d.text.is_none()));
371 }
372
373 #[test]
374 fn an_unsorted_server_response_is_still_sorted() {
375 let docs = build_scored_docs(vec![(0, 0.1), (1, 0.9)], 2).unwrap();
376 assert_eq!(docs[0].index, 1);
377 assert_eq!(docs[1].index, 0);
378 }
379
380 #[test]
381 fn an_out_of_range_index_is_rejected() {
382 let err = build_scored_docs(vec![(0, 0.5), (7, 0.4)], 2).expect_err("must fail");
383 assert!(err.to_string().contains("index 7"), "{err}");
384 }
385
386 #[test]
387 fn a_duplicate_index_is_rejected() {
388 let err = build_scored_docs(vec![(1, 0.5), (1, 0.4)], 2).expect_err("must fail");
389 assert!(err.to_string().contains("more than once"), "{err}");
390 }
391
392 #[test]
393 fn a_short_or_long_result_list_is_rejected() {
394 for pairs in [vec![(0, 0.5)], vec![(0, 0.5), (1, 0.4), (2, 0.3)]] {
395 let err = build_scored_docs(pairs, 2).expect_err("must fail");
396 assert!(err.to_string().contains("for 2 documents"), "{err}");
397 }
398 }
399
400 /// A NaN score must not panic the comparator or lose the document.
401 #[test]
402 fn a_nan_score_does_not_panic_the_sort() {
403 let docs = build_scored_docs(vec![(0, f32::NAN), (1, 0.5)], 2).unwrap();
404 assert_eq!(docs.len(), 2);
405 assert!(docs.iter().any(|d| d.index == 0));
406 }
407}