Skip to main content

nabled_embeddings/
cache.rs

1//! Reusable corpus workspace for repeated queries against a static corpus.
2//!
3//! Retrieval serving evaluates many queries against the *same* corpus. The stateless
4//! [`query_corpus_scores`](crate::similarity) path recomputes the corpus's contribution (its L2
5//! norms for cosine) on every call. [`CorpusWorkspace`] precomputes that work **once** at
6//! [`build`](CorpusWorkspace::build) time and reuses it across calls, mirroring the
7//! `PairwiseCosineWorkspace::ensure_dims` precedent in `nabled-linalg`.
8//!
9//! # Chosen approach (cosine)
10//!
11//! `nabled-linalg` has no "corpus norms already provided" cosine entrypoint, so the workspace owns
12//! the corpus together with its precomputed row norms and scores cosine as
13//! `dot(query, corpus) / (||query|| * ||corpus||)`, supplying the cached corpus norms directly.
14//! This reproduces the stateless cosine kernel bit-for-bit (including its zero-norm rejection)
15//! while skipping the per-call corpus-norm recompute. `Dot` and `L2` own the corpus copy and
16//! delegate to the same kernels as the stateless path (there is no norm to cache, but the owned
17//! corpus still removes a borrow obligation from the caller across repeated queries).
18
19use nabled_core::scalar::NabledReal;
20use nabled_linalg::{matrix, vector};
21use ndarray::{Array1, Array2, ArrayView1, ArrayView2, Axis};
22
23use crate::error::EmbeddingError;
24use crate::similarity::Metric;
25use crate::topk::{Neighbor, top_k};
26
27/// Precomputed, metric-specific corpus state.
28#[derive(Debug, Clone)]
29enum Prepared<T> {
30    /// Cosine caches the corpus row L2 norms so they are not recomputed per query.
31    Cosine { corpus_norms: Array1<T> },
32    /// Dot and L2 have no reusable corpus-side scalar to cache.
33    Plain,
34}
35
36/// A corpus prepared once for repeated scoring under a fixed [`Metric`].
37///
38/// Build it with [`CorpusWorkspace::build`], then score many queries with
39/// [`query_corpus_scores`](CorpusWorkspace::query_corpus_scores) /
40/// [`query_corpus_scores_into`](CorpusWorkspace::query_corpus_scores_into) or rerank a single query
41/// with [`rerank_with`](CorpusWorkspace::rerank_with). The corpus and metric are fixed for the life
42/// of the workspace.
43#[derive(Debug, Clone)]
44pub struct CorpusWorkspace<T> {
45    metric:   Metric,
46    corpus:   Array2<T>,
47    prepared: Prepared<T>,
48}
49
50impl<T: NabledReal> CorpusWorkspace<T> {
51    /// Precompute reusable corpus state for `metric`.
52    ///
53    /// For [`Metric::Cosine`] this computes and caches the corpus row norms (rejecting zero-norm
54    /// rows up front, exactly as the stateless cosine path would).
55    ///
56    /// # Errors
57    /// Returns [`EmbeddingError::EmptyInput`] when `corpus` is empty and
58    /// [`EmbeddingError::ZeroNorm`] when a cosine corpus contains a zero-norm row.
59    pub fn build(corpus: &ArrayView2<'_, T>, metric: Metric) -> Result<Self, EmbeddingError> {
60        if corpus.is_empty() {
61            return Err(EmbeddingError::EmptyInput);
62        }
63        let prepared = match metric {
64            Metric::Cosine => {
65                let corpus_norms = vector::batched_l2_norm_view(corpus)?;
66                if corpus_norms.iter().any(|&n| n <= T::epsilon()) {
67                    return Err(EmbeddingError::ZeroNorm);
68                }
69                Prepared::Cosine { corpus_norms }
70            }
71            Metric::Dot | Metric::L2 => Prepared::Plain,
72        };
73        Ok(Self { metric, corpus: corpus.to_owned(), prepared })
74    }
75
76    /// The metric this workspace was built for.
77    #[must_use]
78    pub const fn metric(&self) -> Metric { self.metric }
79
80    /// Number of corpus rows.
81    #[must_use]
82    pub fn len(&self) -> usize { self.corpus.nrows() }
83
84    /// Whether the corpus has no rows. Always `false` for a successfully built workspace.
85    #[must_use]
86    pub fn is_empty(&self) -> bool { self.corpus.nrows() == 0 }
87
88    /// Feature dimension of the corpus rows.
89    #[must_use]
90    pub fn dim(&self) -> usize { self.corpus.ncols() }
91
92    /// Score every `queries` row against the cached corpus, reusing precomputed corpus state.
93    ///
94    /// Returns a `(queries.nrows(), corpus_len)` matrix identical to the stateless
95    /// [`query_corpus_scores`](crate::similarity::query_corpus_scores) result.
96    ///
97    /// # Errors
98    /// Returns [`EmbeddingError::EmptyInput`] for empty `queries`,
99    /// [`EmbeddingError::DimensionMismatch`] when the feature dimension differs, and
100    /// [`EmbeddingError::ZeroNorm`] for cosine queries with zero-norm rows.
101    pub fn query_corpus_scores(
102        &self,
103        queries: &ArrayView2<'_, T>,
104    ) -> Result<Array2<T>, EmbeddingError> {
105        let mut out = Array2::<T>::zeros((queries.nrows(), self.corpus.nrows()));
106        self.query_corpus_scores_into(queries, &mut out)?;
107        Ok(out)
108    }
109
110    /// Score every `queries` row against the cached corpus into a caller-provided `out` buffer.
111    ///
112    /// `out` must be shaped `(queries.nrows(), corpus_len)`.
113    ///
114    /// # Errors
115    /// See [`query_corpus_scores`](CorpusWorkspace::query_corpus_scores); also returns
116    /// [`EmbeddingError::DimensionMismatch`] when `out` is mis-sized.
117    pub fn query_corpus_scores_into(
118        &self,
119        queries: &ArrayView2<'_, T>,
120        out: &mut Array2<T>,
121    ) -> Result<(), EmbeddingError> {
122        if queries.is_empty() {
123            return Err(EmbeddingError::EmptyInput);
124        }
125        if queries.ncols() != self.corpus.ncols() {
126            return Err(EmbeddingError::DimensionMismatch);
127        }
128        if out.dim() != (queries.nrows(), self.corpus.nrows()) {
129            return Err(EmbeddingError::DimensionMismatch);
130        }
131
132        match &self.prepared {
133            Prepared::Cosine { corpus_norms } => {
134                let query_norms = vector::batched_l2_norm_view(queries)?;
135                if query_norms.iter().any(|&n| n <= T::epsilon()) {
136                    return Err(EmbeddingError::ZeroNorm);
137                }
138                matrix::matmat_view_into(queries, &self.corpus.t(), out.view_mut())?;
139                out.outer_iter_mut().zip(query_norms.iter()).for_each(|(mut row, &qn)| {
140                    row.iter_mut().zip(corpus_norms.iter()).for_each(|(value, &cn)| {
141                        *value /= qn * cn;
142                    });
143                });
144            }
145            Prepared::Plain if self.metric == Metric::Dot => {
146                matrix::matmat_view_into(queries, &self.corpus.t(), out.view_mut())?;
147            }
148            Prepared::Plain => {
149                vector::pairwise_l2_distance_view_into(queries, &self.corpus.view(), out)?;
150            }
151        }
152        Ok(())
153    }
154
155    /// Rerank the cached corpus against a single `query`, returning the best `k` neighbors.
156    ///
157    /// Equivalent to [`rerank`](crate::rerank::rerank) against this workspace's corpus and metric,
158    /// but reusing the precomputed corpus state.
159    ///
160    /// # Errors
161    /// See [`query_corpus_scores`](CorpusWorkspace::query_corpus_scores).
162    pub fn rerank_with(
163        &self,
164        query: &ArrayView1<'_, T>,
165        k: usize,
166    ) -> Result<Vec<Neighbor<T>>, EmbeddingError> {
167        let query_rows = query.view().insert_axis(Axis(0));
168        let scores = self.query_corpus_scores(&query_rows)?;
169        Ok(top_k(scores.row(0), k, self.metric.higher_is_better()))
170    }
171
172    /// Exact brute-force kNN over the cached corpus for every `queries` row.
173    ///
174    /// Per-query best-first neighbor lists, reusing the precomputed corpus state.
175    ///
176    /// # Errors
177    /// See [`query_corpus_scores`](CorpusWorkspace::query_corpus_scores).
178    pub fn knn_with(
179        &self,
180        queries: &ArrayView2<'_, T>,
181        k: usize,
182    ) -> Result<Vec<Vec<Neighbor<T>>>, EmbeddingError> {
183        let scores = self.query_corpus_scores(queries)?;
184        let higher_is_better = self.metric.higher_is_better();
185        Ok(scores.outer_iter().map(|row| top_k(row, k, higher_is_better)).collect())
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use ndarray::{arr1, arr2};
192
193    use super::*;
194    use crate::knn::brute_force_knn;
195    use crate::rerank::rerank;
196    use crate::similarity::query_corpus_scores_view;
197
198    fn corpus_f64() -> Array2<f64> {
199        arr2(&[[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [1.0, 1.0, 0.0], [0.2, 0.3, 0.9]])
200    }
201
202    fn queries_f64() -> Array2<f64> { arr2(&[[1.0, 0.0, 0.0], [0.1, 0.2, 0.9]]) }
203
204    #[test]
205    fn workspace_scores_match_stateless_for_all_metrics() {
206        let corpus = corpus_f64();
207        let queries = queries_f64();
208        for metric in [Metric::Cosine, Metric::Dot, Metric::L2] {
209            let ws = CorpusWorkspace::build(&corpus.view(), metric).unwrap();
210            let cached = ws.query_corpus_scores(&queries.view()).unwrap();
211            let stateless =
212                query_corpus_scores_view(&queries.view(), &corpus.view(), metric).unwrap();
213            for (lhs, rhs) in cached.iter().zip(stateless.iter()) {
214                assert!((lhs - rhs).abs() < 1e-12, "{metric:?}: {lhs} vs {rhs}");
215            }
216        }
217    }
218
219    #[test]
220    fn workspace_scores_match_stateless_f32() {
221        let corpus = arr2(&[[1.0_f32, 0.0], [0.0, 1.0], [0.7, 0.7]]);
222        let queries = arr2(&[[1.0_f32, 0.0], [0.3, 0.4]]);
223        for metric in [Metric::Cosine, Metric::Dot, Metric::L2] {
224            let ws = CorpusWorkspace::build(&corpus.view(), metric).unwrap();
225            let cached = ws.query_corpus_scores(&queries.view()).unwrap();
226            let stateless =
227                query_corpus_scores_view(&queries.view(), &corpus.view(), metric).unwrap();
228            for (lhs, rhs) in cached.iter().zip(stateless.iter()) {
229                assert!((lhs - rhs).abs() < 1e-5, "{metric:?}: {lhs} vs {rhs}");
230            }
231        }
232    }
233
234    #[test]
235    fn workspace_reuse_across_queries_is_stable() {
236        let corpus = corpus_f64();
237        let ws = CorpusWorkspace::build(&corpus.view(), Metric::Cosine).unwrap();
238        let q1 = arr1(&[1.0_f64, 0.0, 0.0]);
239        let q2 = arr1(&[0.0_f64, 1.0, 0.0]);
240        // Repeated single-query reuse must equal the stateless rerank for each query.
241        let r1 = ws.rerank_with(&q1.view(), 2).unwrap();
242        let r2 = ws.rerank_with(&q2.view(), 2).unwrap();
243        let e1 = rerank(&q1.view(), &corpus.view(), 2, Metric::Cosine).unwrap();
244        let e2 = rerank(&q2.view(), &corpus.view(), 2, Metric::Cosine).unwrap();
245        assert_eq!(r1[0].index, e1[0].index);
246        assert_eq!(r2[0].index, e2[0].index);
247        // Re-run the first query again to confirm the workspace is not mutated by use.
248        let r1_again = ws.rerank_with(&q1.view(), 2).unwrap();
249        assert_eq!(r1[0].index, r1_again[0].index);
250    }
251
252    #[test]
253    fn workspace_knn_matches_brute_force() {
254        let corpus = corpus_f64();
255        let queries = queries_f64();
256        let ws = CorpusWorkspace::build(&corpus.view(), Metric::L2).unwrap();
257        let cached = ws.knn_with(&queries.view(), 3).unwrap();
258        let expected = brute_force_knn(&queries.view(), &corpus.view(), 3, Metric::L2).unwrap();
259        for (clist, elist) in cached.iter().zip(expected.iter()) {
260            for (cn, en) in clist.iter().zip(elist.iter()) {
261                assert_eq!(cn.index, en.index);
262                assert!((cn.score - en.score).abs() < 1e-12);
263            }
264        }
265    }
266
267    #[test]
268    fn workspace_metadata_is_exposed() {
269        let corpus = corpus_f64();
270        let ws = CorpusWorkspace::build(&corpus.view(), Metric::Dot).unwrap();
271        assert_eq!(ws.metric(), Metric::Dot);
272        assert_eq!(ws.len(), 4);
273        assert_eq!(ws.dim(), 3);
274        assert!(!ws.is_empty());
275    }
276
277    #[test]
278    fn build_rejects_empty_corpus() {
279        let corpus = Array2::<f64>::zeros((0, 3));
280        assert_eq!(
281            CorpusWorkspace::build(&corpus.view(), Metric::Cosine).err(),
282            Some(EmbeddingError::EmptyInput)
283        );
284    }
285
286    #[test]
287    fn build_rejects_cosine_zero_norm_row() {
288        let corpus = arr2(&[[1.0_f64, 0.0], [0.0, 0.0]]);
289        assert_eq!(
290            CorpusWorkspace::build(&corpus.view(), Metric::Cosine).err(),
291            Some(EmbeddingError::ZeroNorm)
292        );
293    }
294
295    #[test]
296    fn query_rejects_dimension_mismatch() {
297        let corpus = corpus_f64();
298        let ws = CorpusWorkspace::build(&corpus.view(), Metric::Dot).unwrap();
299        let queries = arr2(&[[1.0_f64, 0.0]]);
300        assert_eq!(
301            ws.query_corpus_scores(&queries.view()).err(),
302            Some(EmbeddingError::DimensionMismatch)
303        );
304    }
305
306    #[test]
307    fn query_rejects_cosine_zero_norm_query() {
308        let corpus = corpus_f64();
309        let ws = CorpusWorkspace::build(&corpus.view(), Metric::Cosine).unwrap();
310        let queries = arr2(&[[0.0_f64, 0.0, 0.0]]);
311        assert_eq!(ws.query_corpus_scores(&queries.view()).err(), Some(EmbeddingError::ZeroNorm));
312    }
313
314    #[test]
315    fn query_into_rejects_wrong_output_shape() {
316        let corpus = corpus_f64();
317        let ws = CorpusWorkspace::build(&corpus.view(), Metric::Dot).unwrap();
318        let queries = queries_f64();
319        let mut out = Array2::<f64>::zeros((1, 1));
320        assert_eq!(
321            ws.query_corpus_scores_into(&queries.view(), &mut out).err(),
322            Some(EmbeddingError::DimensionMismatch)
323        );
324    }
325}