1use 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 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 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 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 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 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 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 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: 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 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 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 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 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 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 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 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 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}