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}