Skip to main content

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}