1use 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#[derive(Debug, Clone)]
29enum Prepared<T> {
30 Cosine { corpus_norms: Array1<T> },
32 Plain,
34}
35
36#[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 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 #[must_use]
78 pub const fn metric(&self) -> Metric { self.metric }
79
80 #[must_use]
82 pub fn len(&self) -> usize { self.corpus.nrows() }
83
84 #[must_use]
86 pub fn is_empty(&self) -> bool { self.corpus.nrows() == 0 }
87
88 #[must_use]
90 pub fn dim(&self) -> usize { self.corpus.ncols() }
91
92 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 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 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 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 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 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}