Skip to main content

urna_runtime/
search.rs

1//! Search entry points: exact, ANN, hybrid. All paths return `SearchResult`
2//! with the real cosine score (ANN/hybrid rerank candidates with the exact
3//! dot product before returning).
4
5use crate::bm25;
6use crate::error::RuntimeError;
7use crate::mmap_file::MmapUrnaFile;
8use crate::rerank::RerankSource;
9use crate::{RerankSourceKind, SearchExplain, SearchHit, SearchResult};
10
11impl MmapUrnaFile {
12    /// the honesty marker: the precision the rerank reads from, from the
13    /// `embeddings_fp` (0x09) slab dtype when present, else the stored dtype.
14    fn rerank_source_kind(&self) -> RerankSourceKind {
15        let dtype = self
16            .embeddings_fp_slab()
17            .map(|(_, d)| d)
18            .unwrap_or(self.dtype);
19        RerankSourceKind::from_dtype(dtype)
20    }
21
22    /// Validate query, L2-normalize, return the normalized vector.
23    fn validate_query(&self, query: &[f32], k: i32) -> Result<Vec<f32>, RuntimeError> {
24        if k <= 0 {
25            return Err(RuntimeError::InvalidK(k));
26        }
27        if query.is_empty() {
28            return Err(RuntimeError::EmptyQuery);
29        }
30        if query.len() != self.embedding_dim {
31            return Err(RuntimeError::DimensionMismatch {
32                expected: self.embedding_dim,
33                got: query.len(),
34            });
35        }
36        for &v in query {
37            if v.is_nan() || v.is_infinite() {
38                return Err(RuntimeError::InvalidQueryValue);
39            }
40        }
41        let mut qnorm = query.to_vec();
42        let norm: f32 = qnorm.iter().map(|x| x * x).sum::<f32>().sqrt();
43        if norm == 0.0 {
44            return Err(RuntimeError::ZeroNormQuery);
45        }
46        for x in &mut qnorm {
47            *x /= norm;
48        }
49        Ok(qnorm)
50    }
51
52    /// The single rerank source the exact-cosine recompute reads from:
53    /// the full-precision `embeddings_fp` (0x09) slab when present, else
54    /// the stored dtype slab. Both `score_all` and `score_subset` route
55    /// through this so every path's returned score is the SAME real
56    /// cosine, and a future per-space path just hands a different slab.
57    fn rerank_source(&self) -> Result<RerankSource<'_>, RuntimeError> {
58        let (dtype, bytes) = match self.embeddings_fp_slab() {
59            Some((bytes, dtype)) => (dtype, bytes),
60            None => (self.dtype, self.embeddings_bytes()),
61        };
62        RerankSource::new(dtype, bytes, self.n_embeddings, self.embedding_dim)
63    }
64
65    /// Score every chunk against `qnorm` via the rerank source. Returns
66    /// `(idx, score)` pairs in the natural index order.
67    fn score_all(&self, qnorm: &[f32]) -> Result<Vec<(usize, f32)>, RuntimeError> {
68        let n = self.n_embeddings;
69        let mut src = self.rerank_source()?;
70        let mut scores: Vec<(usize, f32)> = Vec::with_capacity(n);
71        for i in 0..n {
72            scores.push((i, src.score(qnorm, i)));
73        }
74        Ok(scores)
75    }
76
77    /// Score a sliced subset of indices (used by ANN/BM25 rerank). The
78    /// returned vector mirrors `idxs.len()` in order. This IS the exact
79    /// rerank every candidate-generating path must end in.
80    fn score_subset(
81        &self,
82        qnorm: &[f32],
83        idxs: &[usize],
84    ) -> Result<Vec<(usize, f32)>, RuntimeError> {
85        let mut src = self.rerank_source()?;
86        let mut out: Vec<(usize, f32)> = Vec::with_capacity(idxs.len());
87        for &i in idxs {
88            out.push((i, src.score(qnorm, i)));
89        }
90        Ok(out)
91    }
92
93    /// Exact flat search. The recall=1.0 ground truth.
94    pub fn search(&self, query: &[f32], k: i32) -> Result<SearchResult, RuntimeError> {
95        let t0 = std::time::Instant::now();
96        let qnorm = self.validate_query(query, k)?;
97        let mut scores = self.score_all(&qnorm)?;
98        sort_scores_desc(&mut scores);
99        let k_usize = k as usize;
100        let truncated = k_usize < self.n_embeddings;
101        let top = &scores[..k_usize.min(self.n_embeddings)];
102        let hits = self.materialize_hits(top, "exact", false);
103        Ok(SearchResult {
104            hits: hits.clone(),
105            query_time_ms: t0.elapsed().as_secs_f64() * 1000.0,
106            index_type: "exact",
107            recall: 1.0,
108            truncated,
109            k_requested: k,
110            k_returned: hits.len(),
111            explain: SearchExplain::exact(self.n_embeddings, self.rerank_source_kind()),
112        })
113    }
114
115    /// ANN search. Pulls `ef_search` candidates from HNSW, reranks with
116    /// the exact dot product, returns top-k. Falls back to `search()` if
117    /// no ANN section is present.
118    pub fn search_ann(
119        &self,
120        query: &[f32],
121        k: i32,
122        ef_search: usize,
123    ) -> Result<SearchResult, RuntimeError> {
124        let t0 = std::time::Instant::now();
125        let Some(idx) = self.ann_index.as_ref() else {
126            return self.search(query, k);
127        };
128        let qnorm = self.validate_query(query, k)?;
129        let candidates = idx.search(&qnorm, ef_search.max(k as usize));
130        let ann_candidates = candidates.len();
131        let mut reranked = self.score_subset(&qnorm, &candidates)?;
132        sort_scores_desc(&mut reranked);
133        let k_usize = k as usize;
134        let truncated = k_usize < self.n_embeddings;
135        let top = &reranked[..k_usize.min(reranked.len())];
136        let hits = self.materialize_hits(top, "hnsw", true);
137        Ok(SearchResult {
138            hits: hits.clone(),
139            query_time_ms: t0.elapsed().as_secs_f64() * 1000.0,
140            index_type: "hnsw",
141            // Recall is candidate-set dependent; runtime caller can
142            // measure it against `search()` directly. We don't lie here.
143            recall: f32::NAN,
144            truncated,
145            k_requested: k,
146            k_returned: hits.len(),
147            explain: SearchExplain::hnsw(ann_candidates, self.rerank_source_kind()),
148        })
149    }
150
151    /// Graph search: seed from the exact-cosine top-`ef`, expand a bounded
152    /// bfs over the chunk-to-chunk csr, union seed + frontier, then run the
153    /// SAME mandatory exact rerank on the union. the graph ONLY generates
154    /// candidates; the returned score is real cosine, identical contract to
155    /// `search_ann`. falls back to `search()` when no graph section is
156    /// present. recall stays `f32::NAN` (we never lie).
157    pub fn search_graph(
158        &self,
159        query: &[f32],
160        k: i32,
161        hops: usize,
162        ef: usize,
163    ) -> Result<SearchResult, RuntimeError> {
164        let t0 = std::time::Instant::now();
165        let Some(graph) = self.graph_index.as_ref() else {
166            return self.search(query, k);
167        };
168        let qnorm = self.validate_query(query, k)?;
169
170        // exact-cosine top-ef seed (reuse score_all + sort), like the exact
171        // path but truncated to the seed budget.
172        let mut seed_scores = self.score_all(&qnorm)?;
173        sort_scores_desc(&mut seed_scores);
174        let seed_budget = ef.max(k as usize).min(seed_scores.len());
175        let seeds: Vec<usize> = seed_scores[..seed_budget].iter().map(|p| p.0).collect();
176
177        // bounded bfs expands the frontier over the csr; the union of seeds +
178        // frontier is the candidate set. cap the frontier so a dense graph
179        // cannot blow up the rerank cost.
180        let max_frontier = (seed_budget.saturating_mul(8)).max(seed_budget);
181        let mut traversal = crate::graph::Traversal::new(graph.n_nodes());
182        let union = traversal.bounded_bfs(graph, &seeds, hops, max_frontier);
183        let graph_candidates = union.len();
184
185        let mut reranked = self.score_subset(&qnorm, &union)?;
186        sort_scores_desc(&mut reranked);
187        let k_usize = k as usize;
188        let truncated = k_usize < self.n_embeddings;
189        let top = &reranked[..k_usize.min(reranked.len())];
190        let hits = self.materialize_hits(top, "graph", true);
191        Ok(SearchResult {
192            hits: hits.clone(),
193            query_time_ms: t0.elapsed().as_secs_f64() * 1000.0,
194            index_type: "graph",
195            recall: f32::NAN,
196            truncated,
197            k_requested: k,
198            k_returned: hits.len(),
199            explain: SearchExplain::graph(seed_budget, graph_candidates, self.rerank_source_kind()),
200        })
201    }
202
203    /// Hybrid search: BM25 candidates ∪ ANN (or exact) candidates,
204    /// reciprocal-rank fusion, then exact cosine rerank on the union.
205    /// Final score is the real cosine.
206    pub fn search_hybrid(
207        &self,
208        query_vec: &[f32],
209        query_text: &str,
210        k: i32,
211        candidates_per_path: usize,
212    ) -> Result<SearchResult, RuntimeError> {
213        let t0 = std::time::Instant::now();
214        let qnorm = self.validate_query(query_vec, k)?;
215        let src = self.rerank_source_kind();
216
217        // Vector path: ANN if available, otherwise top-`candidates`.
218        let has_ann = self.ann_index.is_some();
219        let vec_candidates: Vec<usize> = if let Some(idx) = self.ann_index.as_ref() {
220            idx.search(&qnorm, candidates_per_path.max(k as usize))
221        } else {
222            let mut all = self.score_all(&qnorm)?;
223            sort_scores_desc(&mut all);
224            all.iter().take(candidates_per_path).map(|p| p.0).collect()
225        };
226
227        // Lexical path.
228        let lex_candidates: Vec<usize> = if let Some(bm) = self.bm25_index.as_ref() {
229            bm.search(query_text, candidates_per_path)
230                .into_iter()
231                .map(|(idx, _score)| idx)
232                .collect()
233        } else {
234            Vec::new()
235        };
236
237        // attribute the vector count to the generator that ran (ann vs
238        // exact-flat shortlist) so the explain line is honest.
239        let nvec = vec_candidates.len();
240        let (ann_candidates, exact_candidates) = if has_ann { (nvec, 0) } else { (0, nvec) };
241        let bm25_candidates = lex_candidates.len();
242
243        // Reciprocal-rank fusion to pick a union, then exact rerank.
244        let union = bm25::rrf_union(&vec_candidates, &lex_candidates);
245        let mut reranked = self.score_subset(&qnorm, &union)?;
246        sort_scores_desc(&mut reranked);
247        let k_usize = k as usize;
248        let truncated = k_usize < self.n_embeddings;
249        let top = &reranked[..k_usize.min(reranked.len())];
250        let hits = self.materialize_hits(top, "hybrid", true);
251        Ok(SearchResult {
252            hits: hits.clone(),
253            query_time_ms: t0.elapsed().as_secs_f64() * 1000.0,
254            index_type: "hybrid",
255            recall: f32::NAN,
256            truncated,
257            k_requested: k,
258            k_returned: hits.len(),
259            explain: SearchExplain::hybrid(ann_candidates, exact_candidates, bm25_candidates, src),
260        })
261    }
262
263    pub(crate) fn materialize_hits(
264        &self,
265        scored: &[(usize, f32)],
266        index_type: &'static str,
267        reranked: bool,
268    ) -> Vec<SearchHit> {
269        scored
270            .iter()
271            .map(|(idx, score)| {
272                let span = &self.spans[*idx];
273                let id = &self.chunk_ids[*idx];
274                SearchHit {
275                    chunk_id: id.clone(),
276                    score: *score,
277                    score_type: "cosine",
278                    source_uri: span.source_uri.clone(),
279                    offset_start: span.byte_start,
280                    offset_end: span.byte_end,
281                    embedding_model: self.embedding_model.clone(),
282                    index_type,
283                    reranked,
284                    file_hash: self.file_hash.clone(),
285                    content_hash: self.content_hash.clone(),
286                    citation_id: format!("urna://{}/{}", self.content_hash, id),
287                }
288            })
289            .collect()
290    }
291}
292
293#[inline]
294pub(crate) fn sort_scores_desc(scores: &mut [(usize, f32)]) {
295    scores.sort_by(|a, b| crate::order::cmp_score_desc(*a, *b));
296}