Skip to main content

uni_xervo/
score.rs

1//! Host-side similarity scoring for sparse and multi-vector embeddings.
2//!
3//! uni-xervo emits sparse and multi-vector embeddings but deliberately does not
4//! own an index — native multi-vector / inverted-index storage is a downstream
5//! concern. These pure, dependency-free helpers close that gap for the common
6//! case of reranking a small top-k candidate set in the host process:
7//!
8//! - [`max_sim`] / [`colbert_rerank`] score per-token (ColBERT) vectors via late
9//!   interaction (sum of per-query-token maxima).
10//! - [`sparse_dot`] scores two learned-sparse vectors.
11//!
12//! They make these embeddings useful immediately, without waiting on a native
13//! index. For large corpora, prefer a real index over scoring every candidate
14//! here.
15
16use std::collections::HashMap;
17
18/// Dot product of two equal-length vectors, truncating to the shorter length.
19fn dot(a: &[f32], b: &[f32]) -> f32 {
20    a.iter().zip(b.iter()).map(|(x, y)| x * y).sum()
21}
22
23/// Compute the MaxSim late-interaction score between query and document tokens.
24///
25/// Late interaction (ColBERT) scores a query against a document as the sum, over
26/// each query token, of that token's maximum similarity to any document token.
27/// Similarity is the dot product, so pre-normalized (unit-norm) per-token
28/// vectors — uni-xervo's default — make it cosine similarity.
29///
30/// All vectors are expected to share the same dimensionality; mismatched
31/// vectors are scored over their shared prefix. Returns `0.0` when either side
32/// has no tokens.
33///
34/// # Examples
35/// ```
36/// use uni_xervo::score::max_sim;
37///
38/// let query = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
39/// let doc = vec![vec![1.0, 0.0], vec![0.5, 0.5]];
40/// // token 0 best-matches doc[0] (1.0); token 1 best-matches doc[1] (0.5).
41/// assert!((max_sim(&query, &doc) - 1.5).abs() < 1e-6);
42/// ```
43pub fn max_sim(query: &[Vec<f32>], doc: &[Vec<f32>]) -> f32 {
44    if query.is_empty() || doc.is_empty() {
45        return 0.0;
46    }
47    query
48        .iter()
49        .map(|q| {
50            doc.iter()
51                .map(|d| dot(q, d))
52                .fold(f32::NEG_INFINITY, f32::max)
53        })
54        .sum()
55}
56
57/// Rank `docs` against `query` by [`max_sim`], returning one score per document.
58///
59/// Scores are returned in `docs` order — the caller sorts and truncates to its
60/// top-k. Each document is its own list of per-token vectors.
61///
62/// # Examples
63/// ```
64/// use uni_xervo::score::colbert_rerank;
65///
66/// let query = vec![vec![1.0, 0.0]];
67/// let doc_a = vec![vec![1.0, 0.0]];
68/// let doc_b = vec![vec![0.0, 1.0]];
69/// let scores = colbert_rerank(&query, &[&doc_a, &doc_b]);
70/// assert!(scores[0] > scores[1]);
71/// ```
72pub fn colbert_rerank(query: &[Vec<f32>], docs: &[&[Vec<f32>]]) -> Vec<f32> {
73    docs.iter().map(|doc| max_sim(query, doc)).collect()
74}
75
76/// Compute the dot product of two learned-sparse vectors.
77///
78/// Each input is a list of `(term_id, weight)` pairs. The result sums
79/// `weight_a * weight_b` over terms present in both. Inputs need not be sorted;
80/// duplicate term ids within a vector are summed. The two vectors must come from
81/// the same model — term ids are not comparable across models.
82///
83/// # Examples
84/// ```
85/// use uni_xervo::score::sparse_dot;
86///
87/// let a = vec![(1u32, 0.5f32), (4, 2.0)];
88/// let b = vec![(4u32, 1.0f32), (9, 3.0)];
89/// // only term 4 overlaps: 2.0 * 1.0 = 2.0
90/// assert!((sparse_dot(&a, &b) - 2.0).abs() < 1e-6);
91/// ```
92pub fn sparse_dot(a: &[(u32, f32)], b: &[(u32, f32)]) -> f32 {
93    // Index the smaller side to keep the map compact.
94    let (index_src, scan_src) = if a.len() <= b.len() { (a, b) } else { (b, a) };
95
96    let mut index: HashMap<u32, f32> = HashMap::with_capacity(index_src.len());
97    for &(term, weight) in index_src {
98        *index.entry(term).or_insert(0.0) += weight;
99    }
100
101    scan_src
102        .iter()
103        .filter_map(|&(term, weight)| index.get(&term).map(|w| w * weight))
104        .sum()
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    #[test]
112    fn max_sim_sums_per_query_token_maxima() {
113        let query = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
114        let doc = vec![vec![1.0, 0.0], vec![0.5, 0.5]];
115        assert!((max_sim(&query, &doc) - 1.5).abs() < 1e-6);
116    }
117
118    #[test]
119    fn max_sim_empty_sides_score_zero() {
120        let q = vec![vec![1.0, 0.0]];
121        assert_eq!(max_sim(&[], &q), 0.0);
122        assert_eq!(max_sim(&q, &[]), 0.0);
123    }
124
125    #[test]
126    fn colbert_rerank_orders_relevant_doc_higher() {
127        let query = vec![vec![1.0, 0.0]];
128        let relevant = vec![vec![1.0, 0.0]];
129        let irrelevant = vec![vec![0.0, 1.0]];
130        let scores = colbert_rerank(&query, &[&relevant, &irrelevant]);
131        assert_eq!(scores.len(), 2);
132        assert!(scores[0] > scores[1]);
133    }
134
135    #[test]
136    fn sparse_dot_sums_overlapping_terms() {
137        let a = vec![(1u32, 0.5f32), (4, 2.0)];
138        let b = vec![(4u32, 1.0f32), (9, 3.0)];
139        assert!((sparse_dot(&a, &b) - 2.0).abs() < 1e-6);
140    }
141
142    #[test]
143    fn sparse_dot_disjoint_is_zero() {
144        let a = vec![(1u32, 1.0f32)];
145        let b = vec![(2u32, 1.0f32)];
146        assert_eq!(sparse_dot(&a, &b), 0.0);
147    }
148
149    #[test]
150    fn sparse_dot_is_symmetric() {
151        let a = vec![(1u32, 0.5f32), (4, 2.0), (7, 1.5)];
152        let b = vec![(4u32, 1.0f32), (7, 2.0), (9, 3.0)];
153        assert!((sparse_dot(&a, &b) - sparse_dot(&b, &a)).abs() < 1e-6);
154    }
155
156    #[test]
157    fn sparse_dot_sums_duplicate_terms() {
158        let a = vec![(4u32, 1.0f32), (4, 1.0)]; // duplicate term id -> weight 2.0
159        let b = vec![(4u32, 3.0f32)];
160        assert!((sparse_dot(&a, &b) - 6.0).abs() < 1e-6);
161    }
162}