1use std::sync::Arc;
7
8use rustc_hash::FxHashMap;
9
10use crate::directories::Directory;
11use crate::dsl::Schema;
12use crate::error::Result;
13use crate::query::LazyGlobalStats;
14use crate::segment::{SegmentId, SegmentReader, TrainedVectorStructures};
15#[cfg(feature = "native")]
16use crate::segment::{SegmentSnapshot, SegmentTracker};
17
18#[cfg(feature = "native")]
22#[derive(Clone)]
23pub(crate) struct SearcherResources {
24 pub(crate) term_cache_blocks: usize,
25 pub(crate) store_cache_blocks: usize,
26 #[cfg(feature = "sync")]
27 pub(crate) search_pool: Arc<rayon::ThreadPool>,
28}
29
30#[cfg(feature = "native")]
31impl SearcherResources {
32 pub(crate) fn new(
33 term_cache_blocks: usize,
34 store_cache_blocks: usize,
35 num_threads: usize,
36 ) -> Result<Self> {
37 if num_threads == 0 {
38 return Err(crate::Error::Internal(
39 "IndexConfig.num_threads must be greater than zero".into(),
40 ));
41 }
42
43 #[cfg(feature = "sync")]
44 let search_pool = super::shared_search_pool(num_threads)?;
45
46 Ok(Self {
47 term_cache_blocks,
48 store_cache_blocks,
49 #[cfg(feature = "sync")]
50 search_pool,
51 })
52 }
53}
54
55pub struct Searcher<D: Directory + 'static> {
60 #[cfg(feature = "native")]
62 _snapshot: SegmentSnapshot,
63 _phantom: std::marker::PhantomData<D>,
65 segments: Vec<Arc<SegmentReader>>,
67 schema: Arc<Schema>,
69 default_fields: Vec<crate::Field>,
71 tokenizers: Arc<crate::tokenizer::TokenizerRegistry>,
73 trained_vectors: Arc<TrainedVectorStructures>,
75 global_stats: Arc<LazyGlobalStats>,
77 segment_map: FxHashMap<u128, usize>,
79 total_docs: u32,
81 #[cfg(feature = "sync")]
83 search_pool: Arc<rayon::ThreadPool>,
84}
85
86impl<D: Directory + 'static> Searcher<D> {
87 pub async fn open(
92 directory: Arc<D>,
93 schema: Arc<Schema>,
94 segment_ids: &[String],
95 term_cache_blocks: usize,
96 ) -> Result<Self> {
97 Self::open_with_cache_blocks(
98 directory,
99 schema,
100 segment_ids,
101 term_cache_blocks,
102 term_cache_blocks,
103 )
104 .await
105 }
106
107 pub async fn open_with_cache_blocks(
109 directory: Arc<D>,
110 schema: Arc<Schema>,
111 segment_ids: &[String],
112 term_cache_blocks: usize,
113 store_cache_blocks: usize,
114 ) -> Result<Self> {
115 Self::create(
116 directory,
117 schema,
118 segment_ids,
119 Arc::new(TrainedVectorStructures::default()),
120 term_cache_blocks,
121 store_cache_blocks,
122 )
123 .await
124 }
125
126 #[cfg(feature = "native")]
128 pub(crate) async fn from_snapshot(
129 directory: Arc<D>,
130 schema: Arc<Schema>,
131 snapshot: SegmentSnapshot,
132 trained_vectors: Arc<TrainedVectorStructures>,
133 resources: SearcherResources,
134 ) -> Result<Self> {
135 let (segments, default_fields, global_stats, segment_map, total_docs) = Self::load_common(
136 &directory,
137 &schema,
138 snapshot.segment_ids(),
139 &trained_vectors,
140 resources.term_cache_blocks,
141 resources.store_cache_blocks,
142 &[],
143 )
144 .await?;
145
146 Ok(Self {
147 _snapshot: snapshot,
148 _phantom: std::marker::PhantomData,
149 segments,
150 schema,
151 default_fields,
152 tokenizers: Arc::new(crate::tokenizer::TokenizerRegistry::default()),
153 trained_vectors,
154 global_stats,
155 segment_map,
156 total_docs,
157 #[cfg(feature = "sync")]
158 search_pool: resources.search_pool,
159 })
160 }
161
162 #[cfg(feature = "native")]
166 pub(crate) async fn from_snapshot_reuse(
167 directory: Arc<D>,
168 schema: Arc<Schema>,
169 snapshot: SegmentSnapshot,
170 trained_vectors: Arc<TrainedVectorStructures>,
171 resources: SearcherResources,
172 existing_segments: &[Arc<SegmentReader>],
173 ) -> Result<Self> {
174 let (segments, default_fields, global_stats, segment_map, total_docs) = Self::load_common(
175 &directory,
176 &schema,
177 snapshot.segment_ids(),
178 &trained_vectors,
179 resources.term_cache_blocks,
180 resources.store_cache_blocks,
181 existing_segments,
182 )
183 .await?;
184
185 Ok(Self {
186 _snapshot: snapshot,
187 _phantom: std::marker::PhantomData,
188 segments,
189 schema,
190 default_fields,
191 tokenizers: Arc::new(crate::tokenizer::TokenizerRegistry::default()),
192 trained_vectors,
193 global_stats,
194 segment_map,
195 total_docs,
196 #[cfg(feature = "sync")]
197 search_pool: resources.search_pool,
198 })
199 }
200
201 async fn create(
203 directory: Arc<D>,
204 schema: Arc<Schema>,
205 segment_ids: &[String],
206 trained_vectors: Arc<TrainedVectorStructures>,
207 term_cache_blocks: usize,
208 store_cache_blocks: usize,
209 ) -> Result<Self> {
210 let (segments, default_fields, global_stats, segment_map, total_docs) = Self::load_common(
211 &directory,
212 &schema,
213 segment_ids,
214 &trained_vectors,
215 term_cache_blocks,
216 store_cache_blocks,
217 &[],
218 )
219 .await?;
220
221 #[cfg(feature = "native")]
222 let _snapshot = {
223 let tracker = Arc::new(SegmentTracker::new());
224 SegmentSnapshot::new(tracker, segment_ids.to_vec())
225 };
226
227 #[cfg(feature = "sync")]
228 let search_pool = super::shared_search_pool(crate::default_search_threads())?;
229
230 let _ = directory; Ok(Self {
232 #[cfg(feature = "native")]
233 _snapshot,
234 _phantom: std::marker::PhantomData,
235 segments,
236 schema,
237 default_fields,
238 tokenizers: Arc::new(crate::tokenizer::TokenizerRegistry::default()),
239 trained_vectors,
240 global_stats,
241 segment_map,
242 total_docs,
243 #[cfg(feature = "sync")]
244 search_pool,
245 })
246 }
247
248 async fn load_common(
250 directory: &Arc<D>,
251 schema: &Arc<Schema>,
252 segment_ids: &[String],
253 trained_vectors: &Arc<TrainedVectorStructures>,
254 term_cache_blocks: usize,
255 store_cache_blocks: usize,
256 existing_segments: &[Arc<SegmentReader>],
257 ) -> Result<(
258 Vec<Arc<SegmentReader>>,
259 Vec<crate::Field>,
260 Arc<LazyGlobalStats>,
261 FxHashMap<u128, usize>,
262 u32,
263 )> {
264 let segments = Self::load_segments(
265 directory,
266 schema,
267 segment_ids,
268 trained_vectors,
269 term_cache_blocks,
270 store_cache_blocks,
271 existing_segments,
272 )
273 .await?;
274 let default_fields = Self::build_default_fields(schema);
275 let global_stats = Arc::new(LazyGlobalStats::new(segments.clone()));
276 let (segment_map, total_docs) = Self::build_lookup_tables(&segments);
277 Ok((
278 segments,
279 default_fields,
280 global_stats,
281 segment_map,
282 total_docs,
283 ))
284 }
285
286 async fn load_segments(
290 directory: &Arc<D>,
291 schema: &Arc<Schema>,
292 segment_ids: &[String],
293 trained_vectors: &Arc<TrainedVectorStructures>,
294 term_cache_blocks: usize,
295 store_cache_blocks: usize,
296 existing_segments: &[Arc<SegmentReader>],
297 ) -> Result<Vec<Arc<SegmentReader>>> {
298 let existing_map: FxHashMap<u128, Arc<SegmentReader>> = existing_segments
300 .iter()
301 .map(|seg| (seg.meta().id, Arc::clone(seg)))
302 .collect();
303
304 let mut valid_segments: Vec<(usize, SegmentId)> = Vec::with_capacity(segment_ids.len());
309 for (idx, id_str) in segment_ids.iter().enumerate() {
310 let sid = SegmentId::from_hex(id_str).ok_or_else(|| {
311 crate::error::Error::Corruption(format!(
312 "Invalid segment ID in metadata: {id_str:?}"
313 ))
314 })?;
315 valid_segments.push((idx, sid));
316 }
317
318 let mut reused: Vec<(usize, Arc<SegmentReader>)> = Vec::new();
320 let mut to_load: Vec<(usize, SegmentId)> = Vec::new();
321 for (idx, sid) in &valid_segments {
322 if let Some(existing) = existing_map.get(&sid.0) {
323 reused.push((*idx, Arc::clone(existing)));
324 } else {
325 to_load.push((*idx, *sid));
326 }
327 }
328
329 if !existing_segments.is_empty() {
330 log::info!(
331 "[searcher] reusing {} segment readers, loading {} new",
332 reused.len(),
333 to_load.len(),
334 );
335 }
336
337 let futures: Vec<_> = to_load
339 .iter()
340 .map(|(_, segment_id)| {
341 let dir = Arc::clone(directory);
342 let sch = Arc::clone(schema);
343 let sid = *segment_id;
344 async move {
345 SegmentReader::open_with_cache_blocks(
346 dir.as_ref(),
347 sid,
348 sch,
349 term_cache_blocks,
350 store_cache_blocks,
351 )
352 .await
353 }
354 })
355 .collect();
356
357 let results = futures::future::join_all(futures).await;
358
359 let mut loaded: Vec<(usize, Arc<SegmentReader>)> = Vec::with_capacity(valid_segments.len());
361
362 loaded.extend(reused);
364
365 for ((idx, sid), result) in to_load.into_iter().zip(results) {
367 match result {
368 Ok(mut reader) => {
369 reader.set_trained_vectors(Arc::clone(trained_vectors));
371 loaded.push((idx, Arc::new(reader)));
372 }
373 Err(e) => {
374 return Err(crate::error::Error::Internal(format!(
375 "Failed to open segment {:016x}: {:?}",
376 sid.0, e
377 )));
378 }
379 }
380 }
381
382 loaded.sort_by_key(|(idx, _)| *idx);
384
385 let segments: Vec<Arc<SegmentReader>> = loaded.into_iter().map(|(_, seg)| seg).collect();
386
387 let total_docs: u64 = segments.iter().map(|s| s.meta().num_docs as u64).sum();
391 let mut total_heap = 0usize;
392 let mut total_file_backed = 0u64;
393 let mut total_pinned = 0u64;
394 let mut total_pin_intended = 0u64;
395 for seg in &segments {
396 let stats = seg.memory_stats();
397 let heap = stats.estimated_heap_bytes();
398 let file_backed = stats.file_backed_bytes();
399 total_heap = total_heap.saturating_add(heap);
400 total_file_backed = total_file_backed.saturating_add(file_backed);
401 total_pinned = total_pinned.saturating_add(stats.pinned_metadata_bytes);
402 total_pin_intended = total_pin_intended.saturating_add(stats.pin_intended_bytes);
403 log::info!(
404 "[searcher] segment {:016x}: docs={}, heap_estimate={} \
405 (term_cache={}, store_cache={}, sparse_vectors={}, dense_vectors={}), \
406 file_backed={} (term_bloom={}, sparse_vectors={}, dense_vectors={}), \
407 pinned_metadata={} of {} eligible \
408 (sparse_vectors={} of {}, dense_vectors={} of {})",
409 stats.segment_id,
410 stats.num_docs,
411 crate::format_bytes(heap as u64),
412 crate::format_bytes(stats.term_dict_cache_bytes as u64),
413 crate::format_bytes(stats.store_cache_bytes as u64),
414 crate::format_bytes(stats.sparse_heap_bytes as u64),
415 crate::format_bytes(stats.dense_heap_bytes as u64),
416 crate::format_bytes(file_backed),
417 crate::format_bytes(stats.term_bloom_file_bytes),
418 crate::format_bytes(stats.sparse_file_backed_bytes),
419 crate::format_bytes(stats.dense_file_backed_bytes),
420 crate::format_bytes(stats.pinned_metadata_bytes),
421 crate::format_bytes(stats.pin_intended_bytes),
422 crate::format_bytes(stats.sparse_pinned_metadata_bytes),
423 crate::format_bytes(stats.sparse_pin_intended_bytes),
424 crate::format_bytes(stats.dense_pinned_metadata_bytes),
425 crate::format_bytes(stats.dense_pin_intended_bytes),
426 );
427 }
428 let rss_bytes = process_rss_bytes();
430 log::info!(
431 "[searcher] loaded {} segments: total_docs={}, heap_estimate={}, \
432 file_backed={}, pinned_metadata={} of {} eligible, process_rss={}",
433 segments.len(),
434 total_docs,
435 crate::format_bytes(total_heap as u64),
436 crate::format_bytes(total_file_backed),
437 crate::format_bytes(total_pinned),
438 crate::format_bytes(total_pin_intended),
439 crate::format_bytes(rss_bytes),
440 );
441
442 Ok(segments)
443 }
444
445 fn build_default_fields(schema: &Schema) -> Vec<crate::Field> {
447 if !schema.default_fields().is_empty() {
448 schema.default_fields().to_vec()
449 } else {
450 schema
451 .fields()
452 .filter(|(_, entry)| {
453 entry.indexed && entry.field_type == crate::dsl::FieldType::Text
454 })
455 .map(|(field, _)| field)
456 .collect()
457 }
458 }
459
460 pub fn schema(&self) -> &Schema {
462 &self.schema
463 }
464
465 pub fn segment_readers(&self) -> &[Arc<SegmentReader>] {
467 &self.segments
468 }
469
470 pub fn default_fields(&self) -> &[crate::Field] {
472 &self.default_fields
473 }
474
475 pub fn tokenizers(&self) -> &crate::tokenizer::TokenizerRegistry {
477 &self.tokenizers
478 }
479
480 pub fn trained_centroids(&self) -> &FxHashMap<u32, Arc<crate::structures::CoarseCentroids>> {
482 &self.trained_vectors.centroids
483 }
484
485 pub fn trained_binary_quantizers(
486 &self,
487 ) -> &FxHashMap<u32, Arc<crate::structures::BinaryCoarseQuantizer>> {
488 &self.trained_vectors.binary_quantizers
489 }
490
491 pub fn global_stats(&self) -> &Arc<LazyGlobalStats> {
493 &self.global_stats
494 }
495
496 fn build_lookup_tables(segments: &[Arc<SegmentReader>]) -> (FxHashMap<u128, usize>, u32) {
498 let mut segment_map = FxHashMap::default();
499 let mut total = 0u32;
500 for (i, seg) in segments.iter().enumerate() {
501 segment_map.insert(seg.meta().id, i);
502 total = total.saturating_add(seg.meta().num_docs);
503 }
504 (segment_map, total)
505 }
506
507 pub fn num_docs(&self) -> u32 {
509 self.total_docs
510 }
511
512 pub fn segment_map(&self) -> &FxHashMap<u128, usize> {
514 &self.segment_map
515 }
516
517 #[cfg(feature = "sync")]
519 pub(crate) fn install_search_cpu<R: Send>(&self, operation: impl FnOnce() -> R + Send) -> R {
520 self.search_pool.install(operation)
521 }
522
523 #[cfg(not(feature = "sync"))]
527 pub(crate) fn install_search_cpu<R>(&self, operation: impl FnOnce() -> R) -> R {
528 operation()
529 }
530
531 pub fn num_segments(&self) -> usize {
533 self.segments.len()
534 }
535
536 pub async fn doc(&self, segment_id: u128, doc_id: u32) -> Result<Option<crate::dsl::Document>> {
538 if let Some(&idx) = self.segment_map.get(&segment_id) {
539 return self.segments[idx].doc(doc_id).await;
540 }
541 Ok(None)
542 }
543
544 pub async fn search(
546 &self,
547 query: &dyn crate::query::Query,
548 limit: usize,
549 ) -> Result<Vec<crate::query::SearchResult>> {
550 let (results, _) = self.search_with_count(query, limit).await?;
551 Ok(results)
552 }
553
554 pub async fn search_with_count(
557 &self,
558 query: &dyn crate::query::Query,
559 limit: usize,
560 ) -> Result<(Vec<crate::query::SearchResult>, u32)> {
561 self.search_with_offset_and_count(query, limit, 0).await
562 }
563
564 pub async fn search_with_offset(
566 &self,
567 query: &dyn crate::query::Query,
568 limit: usize,
569 offset: usize,
570 ) -> Result<Vec<crate::query::SearchResult>> {
571 let (results, _) = self
572 .search_with_offset_and_count(query, limit, offset)
573 .await?;
574 Ok(results)
575 }
576
577 pub async fn search_with_offset_and_count(
579 &self,
580 query: &dyn crate::query::Query,
581 limit: usize,
582 offset: usize,
583 ) -> Result<(Vec<crate::query::SearchResult>, u32)> {
584 self.search_internal(query, limit, offset, false).await
585 }
586
587 pub async fn search_with_positions(
591 &self,
592 query: &dyn crate::query::Query,
593 limit: usize,
594 ) -> Result<(Vec<crate::query::SearchResult>, u32)> {
595 self.search_internal(query, limit, 0, true).await
596 }
597
598 fn prepare_global_lsp(
605 &self,
606 query: &dyn crate::query::Query,
607 retrieval_depth: usize,
608 parallel: bool,
609 ) -> Result<Vec<Option<std::sync::Arc<crate::query::bmp::LspSegmentPlan>>>> {
610 let empty = || vec![None; self.segments.len()];
611 if retrieval_depth == 0 {
612 return Ok(empty());
613 }
614 let crate::query::QueryDecomposition::SparseTerms(infos) = query.decompose() else {
615 return Ok(empty());
616 };
617 let Some(first) = infos.first() else {
618 return Ok(empty());
619 };
620 if infos
621 .iter()
622 .any(|info| info.field != first.field || info.lsp_gamma != first.lsp_gamma)
623 {
624 return Ok(empty());
625 }
626 let field = first.field;
627 let (total_superblocks, planning_depth) = self
628 .segments
629 .iter()
630 .filter_map(|segment| segment.bmp_index(field))
631 .fold((0usize, retrieval_depth), |(total, depth), bmp| {
632 (
633 total.saturating_add(bmp.num_superblocks as usize),
634 depth.max(crate::query::bmp_executor_limit(
635 retrieval_depth,
636 first.over_fetch_factor,
637 bmp,
638 )),
639 )
640 });
641 let gamma = first
642 .lsp_gamma
643 .unwrap_or_else(|| crate::query::bmp::recommended_lsp_gamma(planning_depth));
644 if gamma == 0 {
645 return Ok(empty());
646 }
647 if total_superblocks == 0 || gamma >= total_superblocks {
648 return Ok(empty());
652 }
653 let candidate_terms: Vec<(u32, f32)> = infos
654 .iter()
655 .filter(|info| info.candidate)
656 .map(|info| (info.dim_id, info.weight))
657 .collect();
658 if candidate_terms.is_empty() {
659 return Ok(empty());
660 }
661 let scoring_terms: Vec<(u32, f32)> = infos
662 .iter()
663 .map(|info| (info.dim_id, info.weight))
664 .collect();
665 let prepare = |segment: &std::sync::Arc<crate::segment::SegmentReader>| {
666 segment
667 .bmp_index(field)
668 .map(|bmp| {
669 crate::query::bmp::prepare_lsp_superblock_ubs(
670 bmp,
671 &candidate_terms,
672 &scoring_terms,
673 )
674 })
675 .transpose()
676 };
677
678 #[cfg(feature = "sync")]
679 let bounds: Vec<Option<Vec<f32>>> = if parallel {
680 use rayon::prelude::*;
681 self.search_pool.install(|| {
682 self.segments
683 .par_iter()
684 .map(prepare)
685 .collect::<Result<Vec<_>>>()
686 })?
687 } else {
688 self.segments
689 .iter()
690 .map(prepare)
691 .collect::<Result<Vec<_>>>()?
692 };
693 #[cfg(not(feature = "sync"))]
694 let bounds: Vec<Option<Vec<f32>>> = {
695 let _ = parallel;
696 self.segments
697 .iter()
698 .map(prepare)
699 .collect::<Result<Vec<_>>>()?
700 };
701
702 let mut selected = select_global_lsp_superblocks(&bounds, gamma);
703
704 let mut plans = Vec::with_capacity(self.segments.len());
705 for (segment, segment_bounds) in bounds.into_iter().enumerate() {
706 let Some(segment_bounds) = segment_bounds else {
707 plans.push(None);
708 continue;
709 };
710 selected[segment].sort_unstable_by(|&left, &right| {
711 segment_bounds[right as usize]
712 .total_cmp(&segment_bounds[left as usize])
713 .then_with(|| left.cmp(&right))
714 });
715 let selected_bounds = selected[segment]
716 .iter()
717 .map(|&superblock| segment_bounds[superblock as usize])
718 .collect();
719 plans.push(Some(std::sync::Arc::new(
720 crate::query::bmp::LspSegmentPlan {
721 sb_ubs: selected_bounds,
722 sb_order: std::mem::take(&mut selected[segment]),
723 },
724 )));
725 }
726 Ok(plans)
727 }
728
729 async fn search_internal(
731 &self,
732 query: &dyn crate::query::Query,
733 limit: usize,
734 offset: usize,
735 collect_positions: bool,
736 ) -> Result<(Vec<crate::query::SearchResult>, u32)> {
737 let fetch_limit = checked_search_window(limit, offset)?;
738
739 #[cfg(feature = "sync")]
745 if !self.segments.is_empty()
746 && tokio::runtime::Handle::current().runtime_flavor()
747 == tokio::runtime::RuntimeFlavor::MultiThread
748 {
749 return self.search_internal_parallel(query, fetch_limit, offset, collect_positions);
750 }
751
752 const MAX_ASYNC_SEGMENT_SEARCHES: usize = 8;
756 use futures::StreamExt;
757 use futures::TryStreamExt;
758 let shared = crate::query::SharedThreshold::new();
761 let lsp_plans = self.prepare_global_lsp(query, fetch_limit, false)?;
762 let searches = futures::stream::iter(self.segments.iter().cloned().zip(lsp_plans).map(
763 |(segment, lsp_plan)| {
764 let sid = segment.meta().id;
765 let shared = shared.clone();
766 async move {
767 let (mut results, segment_seen) = crate::query::search_segment_shared_planned(
768 segment.as_ref(),
769 query,
770 fetch_limit,
771 collect_positions,
772 shared.clone(),
773 lsp_plan,
774 )
775 .await?;
776 if fetch_limit > 0 && results.len() >= fetch_limit {
777 shared.raise(results[fetch_limit - 1].score);
778 }
779 for r in &mut results {
781 r.segment_id = sid;
782 }
783 Ok::<_, crate::error::Error>((results, segment_seen))
784 }
785 },
786 ))
787 .buffer_unordered(MAX_ASYNC_SEGMENT_SEARCHES);
788 futures::pin_mut!(searches);
789
790 let mut total_seen: u32 = 0;
791 let mut merged = Vec::new();
792 while let Some((batch, segment_seen)) = searches.try_next().await? {
793 total_seen = total_seen.saturating_add(segment_seen);
794 merged = merge_two_ranked(merged, batch, fetch_limit);
795 }
796
797 let results = apply_result_offset(merged, fetch_limit, offset);
798 Ok((results, total_seen))
799 }
800
801 #[cfg(feature = "sync")]
806 fn search_internal_parallel(
807 &self,
808 query: &dyn crate::query::Query,
809 fetch_limit: usize,
810 offset: usize,
811 collect_positions: bool,
812 ) -> Result<(Vec<crate::query::SearchResult>, u32)> {
813 tokio::task::block_in_place(|| {
814 self.search_internal_sync(query, fetch_limit, offset, collect_positions)
815 })
816 }
817
818 #[cfg(feature = "sync")]
823 fn search_internal_sync(
824 &self,
825 query: &dyn crate::query::Query,
826 fetch_limit: usize,
827 offset: usize,
828 collect_positions: bool,
829 ) -> Result<(Vec<crate::query::SearchResult>, u32)> {
830 use rayon::prelude::*;
831
832 let lsp_plans = self.prepare_global_lsp(query, fetch_limit, true)?;
833 let shared = crate::query::SharedThreshold::new();
836 let (merged, total_seen) = self.search_pool.install(|| {
837 self.segments
838 .par_iter()
839 .zip(lsp_plans.par_iter())
840 .map(|(segment, lsp_plan)| {
841 let sid = segment.meta().id;
842 let (mut results, segment_seen) =
843 crate::query::search_segment_shared_sync_planned(
844 segment.as_ref(),
845 query,
846 fetch_limit,
847 collect_positions,
848 shared.clone(),
849 lsp_plan.clone(),
850 )?;
851 if fetch_limit > 0 && results.len() >= fetch_limit {
852 shared.raise(results[fetch_limit - 1].score);
853 }
854 for r in &mut results {
855 r.segment_id = sid;
856 }
857 Ok::<_, crate::Error>((results, segment_seen))
858 })
859 .try_reduce(
860 || (Vec::new(), 0u32),
861 |(left, left_seen), (right, right_seen)| {
862 Ok((
863 merge_two_ranked(left, right, fetch_limit),
864 left_seen.saturating_add(right_seen),
865 ))
866 },
867 )
868 })?;
869
870 let results = apply_result_offset(merged, fetch_limit, offset);
871 Ok((results, total_seen))
872 }
873
874 #[cfg(feature = "sync")]
878 pub fn search_with_offset_and_count_sync(
879 &self,
880 query: &dyn crate::query::Query,
881 limit: usize,
882 offset: usize,
883 ) -> Result<(Vec<crate::query::SearchResult>, u32)> {
884 use rayon::prelude::*;
885
886 let fetch_limit = checked_search_window(limit, offset)?;
887
888 let lsp_plans = self.prepare_global_lsp(query, fetch_limit, true)?;
889 let shared = crate::query::SharedThreshold::new();
891 let (merged, total_seen) = self.search_pool.install(|| {
892 self.segments
893 .par_iter()
894 .zip(lsp_plans.par_iter())
895 .map(|(segment, lsp_plan)| {
896 let sid = segment.meta().id;
897 let (mut results, segment_seen) =
898 crate::query::search_segment_shared_sync_planned(
899 segment.as_ref(),
900 query,
901 fetch_limit,
902 false,
903 shared.clone(),
904 lsp_plan.clone(),
905 )?;
906 if fetch_limit > 0 && results.len() >= fetch_limit {
907 shared.raise(results[fetch_limit - 1].score);
908 }
909 for r in &mut results {
910 r.segment_id = sid;
911 }
912 Ok::<_, crate::Error>((results, segment_seen))
913 })
914 .try_reduce(
915 || (Vec::new(), 0u32),
916 |(left, left_seen), (right, right_seen)| {
917 Ok((
918 merge_two_ranked(left, right, fetch_limit),
919 left_seen.saturating_add(right_seen),
920 ))
921 },
922 )
923 })?;
924
925 let results = apply_result_offset(merged, fetch_limit, offset);
926 Ok((results, total_seen))
927 }
928
929 pub async fn search_fused(
950 &self,
951 queries: &[(&dyn crate::query::Query, f32)],
952 fetch_limit: usize,
953 limit: usize,
954 method: crate::query::FusionMethod,
955 combiner: crate::query::MultiValueCombiner,
956 ) -> Result<Vec<crate::query::SearchResult>> {
957 let (results, _) = self
958 .search_fused_with_count(queries, fetch_limit, limit, method, combiner)
959 .await?;
960 Ok(results)
961 }
962
963 pub async fn search_fused_with_count(
967 &self,
968 queries: &[(&dyn crate::query::Query, f32)],
969 fetch_limit: usize,
970 limit: usize,
971 method: crate::query::FusionMethod,
972 combiner: crate::query::MultiValueCombiner,
973 ) -> Result<(Vec<crate::query::SearchResult>, u32)> {
974 if queries.is_empty() {
975 return Err(crate::Error::Query(
976 "fusion requires at least one sub-query".to_string(),
977 ));
978 }
979 if queries.len() > crate::query::MAX_FUSION_SUB_QUERIES {
980 return Err(crate::Error::Query(format!(
981 "fusion supports at most {} sub-queries, got {}",
982 crate::query::MAX_FUSION_SUB_QUERIES,
983 queries.len()
984 )));
985 }
986 if fetch_limit == 0 {
987 return Err(crate::Error::Query(
988 "fusion fetch_limit must be greater than zero".to_string(),
989 ));
990 }
991 let candidate_slots = fetch_limit
992 .checked_mul(queries.len())
993 .ok_or_else(|| crate::Error::Query("fusion candidate budget overflow".to_string()))?;
994 if candidate_slots > crate::query::MAX_FUSION_CANDIDATE_SLOTS {
995 return Err(crate::Error::Query(format!(
996 "fusion candidate budget must not exceed {}, got {candidate_slots}",
997 crate::query::MAX_FUSION_CANDIDATE_SLOTS
998 )));
999 }
1000 for (index, &(_, weight)) in queries.iter().enumerate() {
1001 if !weight.is_finite() || weight < 0.0 {
1002 return Err(crate::Error::Query(format!(
1003 "fusion query weight at index {index} must be finite and non-negative, \
1004 got {weight}"
1005 )));
1006 }
1007 }
1008 if let crate::query::FusionMethod::Rrf { k } = method
1009 && (!k.is_finite() || k < 0.0)
1010 {
1011 return Err(crate::Error::Query(format!(
1012 "fusion RRF k must be finite and non-negative, got {k}"
1013 )));
1014 }
1015 combiner.validate().map_err(crate::Error::Query)?;
1016
1017 #[cfg(feature = "sync")]
1022 if !self.segments.is_empty()
1023 && tokio::runtime::Handle::current().runtime_flavor()
1024 == tokio::runtime::RuntimeFlavor::MultiThread
1025 {
1026 let lists: Vec<(Vec<crate::query::SearchResult>, f32, u32)> =
1027 tokio::task::block_in_place(|| {
1028 queries
1029 .iter()
1030 .map(|&(query, weight)| {
1031 let (results, seen) =
1032 self.search_internal_sync(query, fetch_limit, 0, true)?;
1033 Ok((results, weight, seen))
1034 })
1035 .collect::<Result<Vec<_>>>()
1036 })?;
1037 let mut total_seen = 0u32;
1038 let ranked_lists = lists
1039 .into_iter()
1040 .map(|(results, weight, seen)| {
1041 total_seen = total_seen.saturating_add(seen);
1042 (results, weight)
1043 })
1044 .collect();
1045 let fused =
1046 crate::query::try_fuse_ranked_lists_chunked(ranked_lists, method, combiner, limit)
1047 .map_err(crate::Error::Query)?;
1048 return Ok((fused, total_seen));
1049 }
1050
1051 let mut lists = Vec::with_capacity(queries.len());
1054 for &(query, weight) in queries {
1055 let (results, seen) = self.search_with_positions(query, fetch_limit).await?;
1056 lists.push((results, weight, seen));
1057 }
1058 let mut total_seen = 0u32;
1059 let ranked_lists = lists
1060 .into_iter()
1061 .map(|(results, weight, seen)| {
1062 total_seen = total_seen.saturating_add(seen);
1063 (results, weight)
1064 })
1065 .collect();
1066 let fused =
1067 crate::query::try_fuse_ranked_lists_chunked(ranked_lists, method, combiner, limit)
1068 .map_err(crate::Error::Query)?;
1069 Ok((fused, total_seen))
1070 }
1071
1072 pub async fn search_and_rerank(
1077 &self,
1078 query: &dyn crate::query::Query,
1079 l1_limit: usize,
1080 final_limit: usize,
1081 config: &crate::query::RerankerConfig,
1082 ) -> Result<(Vec<crate::query::SearchResult>, u32)> {
1083 let (candidates, total_seen) = self.search_with_count(query, l1_limit).await?;
1084 let reranked = crate::query::rerank(self, &candidates, config, final_limit).await?;
1085 Ok((reranked, total_seen))
1086 }
1087
1088 pub async fn query(
1090 &self,
1091 query_str: &str,
1092 limit: usize,
1093 ) -> Result<crate::query::SearchResponse> {
1094 self.query_offset(query_str, limit, 0).await
1095 }
1096
1097 pub async fn query_offset(
1099 &self,
1100 query_str: &str,
1101 limit: usize,
1102 offset: usize,
1103 ) -> Result<crate::query::SearchResponse> {
1104 let parser = self.query_parser();
1105 let query = parser
1106 .parse(query_str)
1107 .map_err(crate::error::Error::Query)?;
1108
1109 let (results, _total_seen) = self
1110 .search_internal(query.as_ref(), limit, offset, false)
1111 .await?;
1112
1113 let total_hits = results.len() as u32;
1114 let hits: Vec<crate::query::SearchHit> = results
1115 .into_iter()
1116 .map(|result| crate::query::SearchHit {
1117 address: crate::query::DocAddress::new(result.segment_id, result.doc_id),
1118 score: result.score,
1119 matched_fields: result.extract_ordinals(),
1120 })
1121 .collect();
1122
1123 Ok(crate::query::SearchResponse { hits, total_hits })
1124 }
1125
1126 pub fn query_parser(&self) -> crate::dsl::QueryLanguageParser {
1128 let query_routers = self.schema.query_routers();
1129 if !query_routers.is_empty()
1130 && let Ok(router) = crate::dsl::QueryFieldRouter::from_rules(query_routers)
1131 {
1132 return crate::dsl::QueryLanguageParser::with_router(
1133 Arc::clone(&self.schema),
1134 self.default_fields.clone(),
1135 Arc::clone(&self.tokenizers),
1136 router,
1137 );
1138 }
1139
1140 crate::dsl::QueryLanguageParser::new(
1141 Arc::clone(&self.schema),
1142 self.default_fields.clone(),
1143 Arc::clone(&self.tokenizers),
1144 )
1145 }
1146
1147 pub async fn get_document(
1149 &self,
1150 address: &crate::query::DocAddress,
1151 ) -> Result<Option<crate::dsl::Document>> {
1152 self.get_document_with_fields(address, None).await
1153 }
1154
1155 pub async fn get_document_with_fields(
1161 &self,
1162 address: &crate::query::DocAddress,
1163 fields: Option<&rustc_hash::FxHashSet<u32>>,
1164 ) -> Result<Option<crate::dsl::Document>> {
1165 let segment_id = address.segment_id_u128().ok_or_else(|| {
1166 crate::error::Error::Query(format!("Invalid segment ID: {}", address.segment_id()))
1167 })?;
1168
1169 if let Some(&idx) = self.segment_map.get(&segment_id) {
1170 return self.segments[idx]
1171 .doc_with_fields(address.doc_id, fields)
1172 .await;
1173 }
1174
1175 Ok(None)
1176 }
1177}
1178
1179fn select_global_lsp_superblocks(bounds: &[Option<Vec<f32>>], gamma: usize) -> Vec<Vec<u32>> {
1183 let mut top =
1184 std::collections::BinaryHeap::<std::cmp::Reverse<(u32, usize, u32)>>::with_capacity(
1185 gamma.min(65_536),
1186 );
1187 for (segment, segment_bounds) in bounds.iter().enumerate() {
1188 let Some(segment_bounds) = segment_bounds else {
1189 continue;
1190 };
1191 for (superblock, &bound) in segment_bounds.iter().enumerate() {
1192 if bound <= 0.0 {
1193 continue;
1194 }
1195 let candidate = (bound.to_bits(), segment, superblock as u32);
1196 if top.len() < gamma {
1197 top.push(std::cmp::Reverse(candidate));
1198 } else if top.peek().is_some_and(|minimum| candidate > minimum.0) {
1199 top.pop();
1200 top.push(std::cmp::Reverse(candidate));
1201 }
1202 }
1203 }
1204
1205 let mut selected = vec![Vec::<u32>::new(); bounds.len()];
1206 for std::cmp::Reverse((_, segment, superblock)) in top {
1207 selected[segment].push(superblock);
1208 }
1209 selected
1210}
1211
1212fn merge_two_ranked(
1216 left: Vec<crate::query::SearchResult>,
1217 right: Vec<crate::query::SearchResult>,
1218 limit: usize,
1219) -> Vec<crate::query::SearchResult> {
1220 let mut left = left.into_iter().peekable();
1221 let mut right = right.into_iter().peekable();
1222 let mut merged = Vec::with_capacity(limit.min(left.len().saturating_add(right.len())));
1223
1224 while merged.len() < limit {
1225 let take_left = match (left.peek(), right.peek()) {
1226 (Some(left), Some(right)) => {
1227 !crate::query::compare_search_results_desc(left, right).is_gt()
1228 }
1229 (Some(_), None) => true,
1230 (None, Some(_)) => false,
1231 (None, None) => break,
1232 };
1233 if take_left {
1234 merged.push(left.next().expect("peeked left result"));
1235 } else {
1236 merged.push(right.next().expect("peeked right result"));
1237 }
1238 }
1239 merged
1240}
1241
1242fn apply_result_offset(
1243 results: Vec<crate::query::SearchResult>,
1244 fetch_limit: usize,
1245 offset: usize,
1246) -> Vec<crate::query::SearchResult> {
1247 results
1248 .into_iter()
1249 .skip(offset)
1250 .take(fetch_limit.saturating_sub(offset))
1251 .collect()
1252}
1253
1254fn checked_search_window(limit: usize, offset: usize) -> Result<usize> {
1255 offset
1256 .checked_add(limit)
1257 .ok_or_else(|| crate::Error::Query("search offset + limit overflow".into()))
1258}
1259
1260fn process_rss_bytes() -> u64 {
1262 #[cfg(target_os = "linux")]
1263 {
1264 if let Ok(status) = std::fs::read_to_string("/proc/self/status") {
1266 for line in status.lines() {
1267 if let Some(rest) = line.strip_prefix("VmRSS:") {
1268 let kib: u64 = rest
1269 .trim()
1270 .trim_end_matches("kB")
1271 .trim()
1272 .parse()
1273 .unwrap_or(0);
1274 return kib.saturating_mul(1024);
1275 }
1276 }
1277 }
1278 0
1279 }
1280 #[cfg(target_os = "macos")]
1281 {
1282 use std::mem;
1284 #[repr(C)]
1285 struct TaskBasicInfo {
1286 virtual_size: u64,
1287 resident_size: u64,
1288 resident_size_max: u64,
1289 user_time: [u32; 2],
1290 system_time: [u32; 2],
1291 policy: i32,
1292 suspend_count: i32,
1293 }
1294 unsafe extern "C" {
1295 fn mach_task_self() -> u32;
1296 fn task_info(task: u32, flavor: u32, info: *mut TaskBasicInfo, count: *mut u32) -> i32;
1297 }
1298 const MACH_TASK_BASIC_INFO: u32 = 20;
1299 let mut info: TaskBasicInfo = unsafe { mem::zeroed() };
1300 let mut count = (mem::size_of::<TaskBasicInfo>() / mem::size_of::<u32>()) as u32;
1301 let ret = unsafe {
1302 task_info(
1303 mach_task_self(),
1304 MACH_TASK_BASIC_INFO,
1305 &mut info,
1306 &mut count,
1307 )
1308 };
1309 if ret == 0 { info.resident_size } else { 0 }
1310 }
1311 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
1312 {
1313 0
1314 }
1315}
1316
1317#[cfg(test)]
1318mod load_segments_tests {
1319 use super::*;
1320
1321 #[tokio::test]
1322 async fn searcher_open_fails_loud_on_corrupt_metadata_segment_id() {
1323 let directory = Arc::new(crate::directories::RamDirectory::new());
1324 let schema = Arc::new(crate::dsl::SchemaBuilder::default().build());
1325
1326 let result =
1327 Searcher::open(directory, schema, &["not-a-hex-segment-id".to_string()], 8).await;
1328
1329 match result {
1330 Ok(searcher) => panic!(
1331 "corrupt segment ID must fail loud instead of silently serving {} segments",
1332 searcher.segment_readers().len()
1333 ),
1334 Err(crate::error::Error::Corruption(message)) => {
1335 assert!(message.contains("not-a-hex-segment-id"), "{message}");
1336 }
1337 Err(other) => panic!("expected Corruption error for invalid segment ID, got: {other}"),
1338 }
1339 }
1340}
1341
1342#[cfg(test)]
1343mod search_window_tests {
1344 use super::{
1345 apply_result_offset, checked_search_window, merge_two_ranked, select_global_lsp_superblocks,
1346 };
1347 use crate::query::SearchResult;
1348
1349 fn result(segment_id: u128, doc_id: u32, score: f32) -> SearchResult {
1350 SearchResult {
1351 doc_id,
1352 score,
1353 segment_id,
1354 positions: Vec::new(),
1355 }
1356 }
1357
1358 #[test]
1359 fn search_window_is_checked() {
1360 assert_eq!(checked_search_window(7, 5).unwrap(), 12);
1361 assert!(checked_search_window(1, usize::MAX).is_err());
1362 }
1363
1364 #[test]
1365 fn bounded_merge_preserves_canonical_order_and_ties() {
1366 let left = vec![result(2, 9, 10.0), result(2, 3, 7.0)];
1367 let right = vec![result(1, 8, 10.0), result(1, 2, 7.0)];
1368
1369 let merged = merge_two_ranked(left, right, 3);
1370 let keys: Vec<_> = merged
1371 .iter()
1372 .map(|result| (result.score, result.segment_id, result.doc_id))
1373 .collect();
1374 assert_eq!(keys, vec![(10.0, 1, 8), (10.0, 2, 9), (7.0, 1, 2)]);
1375 }
1376
1377 #[test]
1378 fn result_offset_returns_only_the_requested_window() {
1379 let results = (0..8)
1380 .map(|doc_id| result(1, doc_id, 8.0 - doc_id as f32))
1381 .collect();
1382
1383 let page = apply_result_offset(results, 5, 2);
1384 assert_eq!(
1385 page.iter().map(|result| result.doc_id).collect::<Vec<_>>(),
1386 vec![2, 3, 4]
1387 );
1388 }
1389
1390 #[test]
1391 fn lsp_gamma_is_global_not_per_segment() {
1392 let bounds = vec![
1393 Some(vec![9.0, 1.0, 8.0]),
1394 Some(vec![7.0, 6.0, 0.0]),
1395 None,
1396 Some(vec![5.0, 4.0]),
1397 ];
1398 let mut selected = select_global_lsp_superblocks(&bounds, 4);
1399 for segment in &mut selected {
1400 segment.sort_unstable();
1401 }
1402 assert_eq!(selected.iter().map(Vec::len).sum::<usize>(), 4);
1403 assert_eq!(selected[0], vec![0, 2]);
1404 assert_eq!(selected[1], vec![0, 1]);
1405 assert!(selected[2].is_empty());
1406 assert!(selected[3].is_empty());
1407 }
1408}
1409
1410#[cfg(test)]
1411mod fusion_parallelism_tests {
1412 #[test]
1413 fn fusion_keeps_parallelism_at_the_segment_level() {
1414 let source = include_str!("searcher.rs");
1415 let body = source
1416 .split("pub async fn search_fused_with_count")
1417 .nth(1)
1418 .and_then(|tail| tail.split("/// Two-stage search").next())
1419 .expect("bounded fusion search implementation");
1420
1421 assert!(
1422 !body.contains(".par_iter()"),
1423 "sub-query parallelism nests over segment parallelism"
1424 );
1425 assert!(
1426 !body.contains(".buffered("),
1427 "async fusion fallback must preserve the same bounded execution shape"
1428 );
1429 }
1430}