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 `max(ef_search, k, ann_ef_floor)` candidates from
116    /// HNSW (the file's `ef_construction` is the floor a caller cannot go
117    /// under), reranks with the exact dot product, returns top-k. Falls
118    /// back to `search()` if no ANN section is present.
119    pub fn search_ann(
120        &self,
121        query: &[f32],
122        k: i32,
123        ef_search: usize,
124    ) -> Result<SearchResult, RuntimeError> {
125        let t0 = std::time::Instant::now();
126        let Some(idx) = self.ann_index.as_ref() else {
127            return self.search(query, k);
128        };
129        let qnorm = self.validate_query(query, k)?;
130        let candidates = idx.search(&qnorm, ef_search.max(k as usize));
131        let ann_candidates = candidates.len();
132        let mut reranked = self.score_subset(&qnorm, &candidates)?;
133        sort_scores_desc(&mut reranked);
134        let k_usize = k as usize;
135        let truncated = k_usize < self.n_embeddings;
136        let top = &reranked[..k_usize.min(reranked.len())];
137        let hits = self.materialize_hits(top, "hnsw", true);
138        Ok(SearchResult {
139            hits: hits.clone(),
140            query_time_ms: t0.elapsed().as_secs_f64() * 1000.0,
141            index_type: "hnsw",
142            // Recall is candidate-set dependent; runtime caller can
143            // measure it against `search()` directly. We don't lie here.
144            recall: f32::NAN,
145            truncated,
146            k_requested: k,
147            k_returned: hits.len(),
148            explain: SearchExplain::hnsw(ann_candidates, self.rerank_source_kind()),
149        })
150    }
151
152    /// Graph search: seed from the exact-cosine top-`ef`, expand a bounded
153    /// bfs over the chunk-to-chunk csr, union seed + frontier, then run the
154    /// SAME mandatory exact rerank on the union. the graph ONLY generates
155    /// candidates; the returned score is real cosine, identical contract to
156    /// `search_ann`. falls back to `search()` when no graph section is
157    /// present. recall stays `f32::NAN` (we never lie).
158    pub fn search_graph(
159        &self,
160        query: &[f32],
161        k: i32,
162        hops: usize,
163        ef: usize,
164    ) -> Result<SearchResult, RuntimeError> {
165        let t0 = std::time::Instant::now();
166        let Some(graph) = self.graph_index.as_ref() else {
167            return self.search(query, k);
168        };
169        let qnorm = self.validate_query(query, k)?;
170
171        // exact-cosine top-ef seed (reuse score_all + sort), like the exact
172        // path but truncated to the seed budget.
173        let mut seed_scores = self.score_all(&qnorm)?;
174        sort_scores_desc(&mut seed_scores);
175        let seed_budget = ef.max(k as usize).min(seed_scores.len());
176        let seeds: Vec<usize> = seed_scores[..seed_budget].iter().map(|p| p.0).collect();
177
178        // bounded bfs expands the frontier over the csr; the union of seeds +
179        // frontier is the candidate set. cap the frontier so a dense graph
180        // cannot blow up the rerank cost.
181        let max_frontier = (seed_budget.saturating_mul(8)).max(seed_budget);
182        let mut traversal = crate::graph::Traversal::new(graph.n_nodes());
183        let union = traversal.bounded_bfs(graph, &seeds, hops, max_frontier);
184        let graph_candidates = union.len();
185
186        let mut reranked = self.score_subset(&qnorm, &union)?;
187        sort_scores_desc(&mut reranked);
188        let k_usize = k as usize;
189        let truncated = k_usize < self.n_embeddings;
190        let top = &reranked[..k_usize.min(reranked.len())];
191        let hits = self.materialize_hits(top, "graph", true);
192        Ok(SearchResult {
193            hits: hits.clone(),
194            query_time_ms: t0.elapsed().as_secs_f64() * 1000.0,
195            index_type: "graph",
196            recall: f32::NAN,
197            truncated,
198            k_requested: k,
199            k_returned: hits.len(),
200            explain: SearchExplain::graph(seed_budget, graph_candidates, self.rerank_source_kind()),
201        })
202    }
203
204    /// Hybrid search: BM25 candidates ∪ ANN (or exact) candidates,
205    /// reciprocal-rank fusion, then exact cosine rerank on the union.
206    /// Final score is the real cosine.
207    pub fn search_hybrid(
208        &self,
209        query_vec: &[f32],
210        query_text: &str,
211        k: i32,
212        candidates_per_path: usize,
213    ) -> Result<SearchResult, RuntimeError> {
214        let t0 = std::time::Instant::now();
215        let qnorm = self.validate_query(query_vec, k)?;
216        let src = self.rerank_source_kind();
217
218        // Vector path: ANN if available, otherwise top-`candidates`.
219        let has_ann = self.ann_index.is_some();
220        let vec_candidates: Vec<usize> = if let Some(idx) = self.ann_index.as_ref() {
221            idx.search(&qnorm, candidates_per_path.max(k as usize))
222        } else {
223            let mut all = self.score_all(&qnorm)?;
224            sort_scores_desc(&mut all);
225            all.iter().take(candidates_per_path).map(|p| p.0).collect()
226        };
227
228        // Lexical path.
229        let lex_candidates: Vec<usize> = if let Some(bm) = self.bm25_index.as_ref() {
230            bm.search(query_text, candidates_per_path)
231                .into_iter()
232                .map(|(idx, _score)| idx)
233                .collect()
234        } else {
235            Vec::new()
236        };
237
238        // attribute the vector count to the generator that ran (ann vs
239        // exact-flat shortlist) so the explain line is honest.
240        let nvec = vec_candidates.len();
241        let (ann_candidates, exact_candidates) = if has_ann { (nvec, 0) } else { (0, nvec) };
242        let bm25_candidates = lex_candidates.len();
243
244        // Reciprocal-rank fusion to pick a union, then exact rerank.
245        let union = bm25::rrf_union(&vec_candidates, &lex_candidates);
246        let mut reranked = self.score_subset(&qnorm, &union)?;
247        sort_scores_desc(&mut reranked);
248        let k_usize = k as usize;
249        let truncated = k_usize < self.n_embeddings;
250        let top = &reranked[..k_usize.min(reranked.len())];
251        let hits = self.materialize_hits(top, "hybrid", true);
252        Ok(SearchResult {
253            hits: hits.clone(),
254            query_time_ms: t0.elapsed().as_secs_f64() * 1000.0,
255            index_type: "hybrid",
256            recall: f32::NAN,
257            truncated,
258            k_requested: k,
259            k_returned: hits.len(),
260            explain: SearchExplain::hybrid(ann_candidates, exact_candidates, bm25_candidates, src),
261        })
262    }
263
264    /// Route by what the file carries, the one order `ask`, `retrieve` and
265    /// `search-text` share: hybrid when a BM25 section is present and the
266    /// caller has the query text, hnsw when an HNSW section is present,
267    /// exact otherwise. the manifest's `index_type` names the vector index
268    /// only; the lexical index is the `supports_bm25` capability, so a file
269    /// built with the `hybrid` preset (`index_type = "hnsw"`, a BM25
270    /// section) takes the hybrid route here. the graph is never routed to
271    /// automatically; `search_graph` is its entry point. `candidates` is the
272    /// hnsw beam, or the per-path shortlist size on the hybrid route.
273    pub fn search_routed(
274        &self,
275        query: &[f32],
276        query_text: Option<&str>,
277        k: i32,
278        candidates: usize,
279    ) -> Result<SearchResult, RuntimeError> {
280        match query_text {
281            Some(text) if self.bm25_index.is_some() && !text.trim().is_empty() => {
282                self.search_hybrid(query, text, k, candidates)
283            }
284            _ if self.ann_index.is_some() => self.search_ann(query, k, candidates),
285            _ => self.search(query, k),
286        }
287    }
288
289    pub(crate) fn materialize_hits(
290        &self,
291        scored: &[(usize, f32)],
292        index_type: &'static str,
293        reranked: bool,
294    ) -> Vec<SearchHit> {
295        scored
296            .iter()
297            .map(|(idx, score)| {
298                let span = &self.spans[*idx];
299                let id = &self.chunk_ids[*idx];
300                SearchHit {
301                    chunk_id: id.clone(),
302                    score: *score,
303                    score_type: "cosine",
304                    source_uri: span.source_uri.clone(),
305                    offset_start: span.byte_start,
306                    offset_end: span.byte_end,
307                    embedding_model: self.embedding_model.clone(),
308                    index_type,
309                    reranked,
310                    file_hash: self.file_hash.clone(),
311                    content_hash: self.content_hash.clone(),
312                    citation_id: format!("urna://{}/{}", self.content_hash, id),
313                }
314            })
315            .collect()
316    }
317}
318
319#[inline]
320pub(crate) fn sort_scores_desc(scores: &mut [(usize, f32)]) {
321    scores.sort_by(|a, b| crate::order::cmp_score_desc(*a, *b));
322}