Skip to main content

hermes_core/index/
searcher.rs

1//! Searcher - read-only search over pre-built segments
2//!
3//! This module provides `Searcher` for read-only search access to indexes.
4//! It can be used standalone (for wasm/read-only) or via `IndexReader` (for native).
5
6use 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};
15#[cfg(feature = "native")]
16use crate::segment::{SegmentSnapshot, SegmentTracker};
17use crate::structures::CoarseCentroids;
18
19/// Searcher - provides search over loaded segments
20///
21/// For wasm/read-only use, create via `Searcher::open()`.
22/// For native use with Index, this is created via `IndexReader`.
23pub struct Searcher<D: Directory + 'static> {
24    /// Segment snapshot holding refs - prevents deletion during native use
25    #[cfg(feature = "native")]
26    _snapshot: SegmentSnapshot,
27    /// PhantomData for the directory generic
28    _phantom: std::marker::PhantomData<D>,
29    /// Loaded segment readers
30    segments: Vec<Arc<SegmentReader>>,
31    /// Schema
32    schema: Arc<Schema>,
33    /// Default fields for search
34    default_fields: Vec<crate::Field>,
35    /// Tokenizers
36    tokenizers: Arc<crate::tokenizer::TokenizerRegistry>,
37    /// Trained centroids per field (injected into segment readers for IVF/ScaNN search)
38    trained_centroids: FxHashMap<u32, Arc<CoarseCentroids>>,
39    /// Lazy global statistics for cross-segment IDF computation
40    global_stats: Arc<LazyGlobalStats>,
41    /// O(1) segment lookup by segment_id
42    segment_map: FxHashMap<u128, usize>,
43    /// Total document count across all segments
44    total_docs: u32,
45}
46
47impl<D: Directory + 'static> Searcher<D> {
48    /// Create a Searcher directly from segment IDs
49    ///
50    /// This is a simpler initialization path that doesn't require SegmentManager.
51    /// Use this for read-only access to pre-built indexes.
52    pub async fn open(
53        directory: Arc<D>,
54        schema: Arc<Schema>,
55        segment_ids: &[String],
56        term_cache_blocks: usize,
57    ) -> Result<Self> {
58        Self::create(
59            directory,
60            schema,
61            segment_ids,
62            FxHashMap::default(),
63            term_cache_blocks,
64        )
65        .await
66    }
67
68    /// Create from a snapshot (for native IndexReader use)
69    #[cfg(feature = "native")]
70    pub(crate) async fn from_snapshot(
71        directory: Arc<D>,
72        schema: Arc<Schema>,
73        snapshot: SegmentSnapshot,
74        trained_centroids: FxHashMap<u32, Arc<CoarseCentroids>>,
75        term_cache_blocks: usize,
76    ) -> Result<Self> {
77        let (segments, default_fields, global_stats, segment_map, total_docs) = Self::load_common(
78            &directory,
79            &schema,
80            snapshot.segment_ids(),
81            &trained_centroids,
82            term_cache_blocks,
83            &[],
84        )
85        .await?;
86
87        Ok(Self {
88            _snapshot: snapshot,
89            _phantom: std::marker::PhantomData,
90            segments,
91            schema,
92            default_fields,
93            tokenizers: Arc::new(crate::tokenizer::TokenizerRegistry::default()),
94            trained_centroids,
95            global_stats,
96            segment_map,
97            total_docs,
98        })
99    }
100
101    /// Create from a snapshot, reusing existing segment readers for unchanged segments.
102    /// This avoids re-opening mmaps, fast fields, sparse indexes, etc. for segments
103    /// that weren't touched by merge.
104    #[cfg(feature = "native")]
105    pub(crate) async fn from_snapshot_reuse(
106        directory: Arc<D>,
107        schema: Arc<Schema>,
108        snapshot: SegmentSnapshot,
109        trained_centroids: FxHashMap<u32, Arc<CoarseCentroids>>,
110        term_cache_blocks: usize,
111        existing_segments: &[Arc<SegmentReader>],
112    ) -> Result<Self> {
113        let (segments, default_fields, global_stats, segment_map, total_docs) = Self::load_common(
114            &directory,
115            &schema,
116            snapshot.segment_ids(),
117            &trained_centroids,
118            term_cache_blocks,
119            existing_segments,
120        )
121        .await?;
122
123        Ok(Self {
124            _snapshot: snapshot,
125            _phantom: std::marker::PhantomData,
126            segments,
127            schema,
128            default_fields,
129            tokenizers: Arc::new(crate::tokenizer::TokenizerRegistry::default()),
130            trained_centroids,
131            global_stats,
132            segment_map,
133            total_docs,
134        })
135    }
136
137    /// Internal create method
138    async fn create(
139        directory: Arc<D>,
140        schema: Arc<Schema>,
141        segment_ids: &[String],
142        trained_centroids: FxHashMap<u32, Arc<CoarseCentroids>>,
143        term_cache_blocks: usize,
144    ) -> Result<Self> {
145        let (segments, default_fields, global_stats, segment_map, total_docs) = Self::load_common(
146            &directory,
147            &schema,
148            segment_ids,
149            &trained_centroids,
150            term_cache_blocks,
151            &[],
152        )
153        .await?;
154
155        #[cfg(feature = "native")]
156        let _snapshot = {
157            let tracker = Arc::new(SegmentTracker::new());
158            SegmentSnapshot::new(tracker, segment_ids.to_vec())
159        };
160
161        let _ = directory; // suppress unused warning on wasm
162        Ok(Self {
163            #[cfg(feature = "native")]
164            _snapshot,
165            _phantom: std::marker::PhantomData,
166            segments,
167            schema,
168            default_fields,
169            tokenizers: Arc::new(crate::tokenizer::TokenizerRegistry::default()),
170            trained_centroids,
171            global_stats,
172            segment_map,
173            total_docs,
174        })
175    }
176
177    /// Common loading logic shared by create and from_snapshot
178    async fn load_common(
179        directory: &Arc<D>,
180        schema: &Arc<Schema>,
181        segment_ids: &[String],
182        trained_centroids: &FxHashMap<u32, Arc<CoarseCentroids>>,
183        term_cache_blocks: usize,
184        existing_segments: &[Arc<SegmentReader>],
185    ) -> Result<(
186        Vec<Arc<SegmentReader>>,
187        Vec<crate::Field>,
188        Arc<LazyGlobalStats>,
189        FxHashMap<u128, usize>,
190        u32,
191    )> {
192        let segments = Self::load_segments(
193            directory,
194            schema,
195            segment_ids,
196            trained_centroids,
197            term_cache_blocks,
198            existing_segments,
199        )
200        .await?;
201        let default_fields = Self::build_default_fields(schema);
202        let global_stats = Arc::new(LazyGlobalStats::new(segments.clone()));
203        let (segment_map, total_docs) = Self::build_lookup_tables(&segments);
204        Ok((
205            segments,
206            default_fields,
207            global_stats,
208            segment_map,
209            total_docs,
210        ))
211    }
212
213    /// Load segment readers from IDs (parallel loading for performance).
214    /// Reuses existing segment readers for unchanged segments when `existing_segments`
215    /// is non-empty — avoids re-opening mmaps, fast fields, sparse indexes, etc.
216    async fn load_segments(
217        directory: &Arc<D>,
218        schema: &Arc<Schema>,
219        segment_ids: &[String],
220        trained_centroids: &FxHashMap<u32, Arc<CoarseCentroids>>,
221        term_cache_blocks: usize,
222        existing_segments: &[Arc<SegmentReader>],
223    ) -> Result<Vec<Arc<SegmentReader>>> {
224        // Build lookup from existing segment readers for reuse
225        let existing_map: FxHashMap<u128, Arc<SegmentReader>> = existing_segments
226            .iter()
227            .map(|seg| (seg.meta().id, Arc::clone(seg)))
228            .collect();
229
230        // Parse segment IDs and filter invalid ones
231        let valid_segments: Vec<(usize, SegmentId)> = segment_ids
232            .iter()
233            .enumerate()
234            .filter_map(|(idx, id_str)| SegmentId::from_hex(id_str).map(|sid| (idx, sid)))
235            .collect();
236
237        // Separate into reusable and new segments
238        let mut reused: Vec<(usize, Arc<SegmentReader>)> = Vec::new();
239        let mut to_load: Vec<(usize, SegmentId)> = Vec::new();
240        for (idx, sid) in &valid_segments {
241            if let Some(existing) = existing_map.get(&sid.0) {
242                reused.push((*idx, Arc::clone(existing)));
243            } else {
244                to_load.push((*idx, *sid));
245            }
246        }
247
248        if !existing_segments.is_empty() {
249            log::info!(
250                "[searcher] reusing {} segment readers, loading {} new",
251                reused.len(),
252                to_load.len(),
253            );
254        }
255
256        // Load only NEW segments in parallel
257        let futures: Vec<_> = to_load
258            .iter()
259            .map(|(_, segment_id)| {
260                let dir = Arc::clone(directory);
261                let sch = Arc::clone(schema);
262                let sid = *segment_id;
263                async move { SegmentReader::open(dir.as_ref(), sid, sch, term_cache_blocks).await }
264            })
265            .collect();
266
267        let results = futures::future::join_all(futures).await;
268
269        // Collect newly loaded results — fail fast if any segment fails to open
270        let mut loaded: Vec<(usize, Arc<SegmentReader>)> = Vec::with_capacity(valid_segments.len());
271
272        // Add reused segments
273        loaded.extend(reused);
274
275        // Add newly loaded segments
276        for ((idx, sid), result) in to_load.into_iter().zip(results) {
277            match result {
278                Ok(mut reader) => {
279                    // Inject per-field centroids into reader for IVF/ScaNN search
280                    if !trained_centroids.is_empty() {
281                        reader.set_coarse_centroids(trained_centroids.clone());
282                    }
283                    loaded.push((idx, Arc::new(reader)));
284                }
285                Err(e) => {
286                    return Err(crate::error::Error::Internal(format!(
287                        "Failed to open segment {:016x}: {:?}",
288                        sid.0, e
289                    )));
290                }
291            }
292        }
293
294        // Sort by original index to maintain deterministic ordering
295        loaded.sort_by_key(|(idx, _)| *idx);
296
297        let segments: Vec<Arc<SegmentReader>> = loaded.into_iter().map(|(_, seg)| seg).collect();
298
299        // Log searcher loading summary with per-segment memory breakdown
300        let total_docs: u64 = segments.iter().map(|s| s.meta().num_docs as u64).sum();
301        let mut total_mem = 0usize;
302        for seg in &segments {
303            let stats = seg.memory_stats();
304            let seg_total = stats.total_bytes();
305            total_mem += seg_total;
306            log::info!(
307                "[searcher] segment {:016x}: docs={}, mem={:.2} MB \
308                 (term_dict={:.2} MB, store={:.2} MB, sparse={:.2} MB, dense={:.2} MB, bloom={:.2} MB)",
309                stats.segment_id,
310                stats.num_docs,
311                seg_total as f64 / (1024.0 * 1024.0),
312                stats.term_dict_cache_bytes as f64 / (1024.0 * 1024.0),
313                stats.store_cache_bytes as f64 / (1024.0 * 1024.0),
314                stats.sparse_index_bytes as f64 / (1024.0 * 1024.0),
315                stats.dense_index_bytes as f64 / (1024.0 * 1024.0),
316                stats.bloom_filter_bytes as f64 / (1024.0 * 1024.0),
317            );
318        }
319        // Log process RSS if available (helps diagnose OOM)
320        let rss_mb = process_rss_mb();
321        log::info!(
322            "[searcher] loaded {} segments: total_docs={}, estimated_mem={:.2} MB, process_rss={:.1} MB",
323            segments.len(),
324            total_docs,
325            total_mem as f64 / (1024.0 * 1024.0),
326            rss_mb,
327        );
328
329        Ok(segments)
330    }
331
332    /// Build default fields from schema
333    fn build_default_fields(schema: &Schema) -> Vec<crate::Field> {
334        if !schema.default_fields().is_empty() {
335            schema.default_fields().to_vec()
336        } else {
337            schema
338                .fields()
339                .filter(|(_, entry)| {
340                    entry.indexed && entry.field_type == crate::dsl::FieldType::Text
341                })
342                .map(|(field, _)| field)
343                .collect()
344        }
345    }
346
347    /// Get the schema
348    pub fn schema(&self) -> &Schema {
349        &self.schema
350    }
351
352    /// Get segment readers
353    pub fn segment_readers(&self) -> &[Arc<SegmentReader>] {
354        &self.segments
355    }
356
357    /// Get default fields for search
358    pub fn default_fields(&self) -> &[crate::Field] {
359        &self.default_fields
360    }
361
362    /// Get tokenizer registry
363    pub fn tokenizers(&self) -> &crate::tokenizer::TokenizerRegistry {
364        &self.tokenizers
365    }
366
367    /// Get trained centroids
368    pub fn trained_centroids(&self) -> &FxHashMap<u32, Arc<CoarseCentroids>> {
369        &self.trained_centroids
370    }
371
372    /// Get lazy global statistics for cross-segment IDF computation
373    pub fn global_stats(&self) -> &Arc<LazyGlobalStats> {
374        &self.global_stats
375    }
376
377    /// Build O(1) lookup tables from loaded segments
378    fn build_lookup_tables(segments: &[Arc<SegmentReader>]) -> (FxHashMap<u128, usize>, u32) {
379        let mut segment_map = FxHashMap::default();
380        let mut total = 0u32;
381        for (i, seg) in segments.iter().enumerate() {
382            segment_map.insert(seg.meta().id, i);
383            total = total.saturating_add(seg.meta().num_docs);
384        }
385        (segment_map, total)
386    }
387
388    /// Get total document count across all segments
389    pub fn num_docs(&self) -> u32 {
390        self.total_docs
391    }
392
393    /// Get O(1) segment_id → index map (used by reranker)
394    pub fn segment_map(&self) -> &FxHashMap<u128, usize> {
395        &self.segment_map
396    }
397
398    /// Get number of segments
399    pub fn num_segments(&self) -> usize {
400        self.segments.len()
401    }
402
403    /// Get a document by (segment_id, local_doc_id)
404    pub async fn doc(&self, segment_id: u128, doc_id: u32) -> Result<Option<crate::dsl::Document>> {
405        if let Some(&idx) = self.segment_map.get(&segment_id) {
406            return self.segments[idx].doc(doc_id).await;
407        }
408        Ok(None)
409    }
410
411    /// Search across all segments and return aggregated results
412    pub async fn search(
413        &self,
414        query: &dyn crate::query::Query,
415        limit: usize,
416    ) -> Result<Vec<crate::query::SearchResult>> {
417        let (results, _) = self.search_with_count(query, limit).await?;
418        Ok(results)
419    }
420
421    /// Search across all segments and return (results, total_seen)
422    /// total_seen is the number of documents that were scored across all segments
423    pub async fn search_with_count(
424        &self,
425        query: &dyn crate::query::Query,
426        limit: usize,
427    ) -> Result<(Vec<crate::query::SearchResult>, u32)> {
428        self.search_with_offset_and_count(query, limit, 0).await
429    }
430
431    /// Search with offset for pagination
432    pub async fn search_with_offset(
433        &self,
434        query: &dyn crate::query::Query,
435        limit: usize,
436        offset: usize,
437    ) -> Result<Vec<crate::query::SearchResult>> {
438        let (results, _) = self
439            .search_with_offset_and_count(query, limit, offset)
440            .await?;
441        Ok(results)
442    }
443
444    /// Search with offset and return (results, total_seen)
445    pub async fn search_with_offset_and_count(
446        &self,
447        query: &dyn crate::query::Query,
448        limit: usize,
449        offset: usize,
450    ) -> Result<(Vec<crate::query::SearchResult>, u32)> {
451        self.search_internal(query, limit, offset, false).await
452    }
453
454    /// Search with positions (ordinal tracking) and return (results, total_seen)
455    ///
456    /// Use this when you need per-ordinal scores for multi-valued fields.
457    pub async fn search_with_positions(
458        &self,
459        query: &dyn crate::query::Query,
460        limit: usize,
461    ) -> Result<(Vec<crate::query::SearchResult>, u32)> {
462        self.search_internal(query, limit, 0, true).await
463    }
464
465    /// Internal search implementation
466    async fn search_internal(
467        &self,
468        query: &dyn crate::query::Query,
469        limit: usize,
470        offset: usize,
471        collect_positions: bool,
472    ) -> Result<(Vec<crate::query::SearchResult>, u32)> {
473        let fetch_limit = offset + limit;
474
475        // Use rayon + block_in_place for CPU-bound scoring (sync feature required).
476        // Offloads the scoring loop from tokio workers so search doesn't starve
477        // other async tasks. Works for any segment count (rayon degrades gracefully
478        // to inline execution for a single segment).
479        // Only works on multi-threaded tokio runtime (block_in_place panics on current_thread).
480        #[cfg(feature = "sync")]
481        if !self.segments.is_empty()
482            && tokio::runtime::Handle::current().runtime_flavor()
483                == tokio::runtime::RuntimeFlavor::MultiThread
484        {
485            return self.search_internal_parallel(query, fetch_limit, offset, collect_positions);
486        }
487
488        // No segments, no sync feature, or current_thread runtime: use async path
489        let futures: Vec<_> = self
490            .segments
491            .iter()
492            .map(|segment| {
493                let sid = segment.meta().id;
494                async move {
495                    let (mut results, segment_seen) = if collect_positions {
496                        crate::query::search_segment_with_positions_and_count(
497                            segment.as_ref(),
498                            query,
499                            fetch_limit,
500                        )
501                        .await?
502                    } else {
503                        crate::query::search_segment_with_count(
504                            segment.as_ref(),
505                            query,
506                            fetch_limit,
507                        )
508                        .await?
509                    };
510                    // Stamp segment_id on each result
511                    for r in &mut results {
512                        r.segment_id = sid;
513                    }
514                    Ok::<_, crate::error::Error>((results, segment_seen))
515                }
516            })
517            .collect();
518
519        let batches = futures::future::try_join_all(futures).await?;
520        let mut total_seen: u32 = 0;
521
522        let mut sorted_batches: Vec<Vec<crate::query::SearchResult>> =
523            Vec::with_capacity(batches.len());
524        for (batch, segment_seen) in batches {
525            total_seen = total_seen.saturating_add(segment_seen);
526            if !batch.is_empty() {
527                sorted_batches.push(batch);
528            }
529        }
530
531        let results = merge_segment_results(sorted_batches, fetch_limit, offset);
532        Ok((results, total_seen))
533    }
534
535    /// Multi-segment parallel search using rayon (CPU-bound scoring on thread pool).
536    ///
537    /// `block_in_place` tells tokio this worker is occupied so it can steal tasks.
538    /// `rayon::par_iter` distributes segment scoring across the rayon thread pool.
539    #[cfg(feature = "sync")]
540    fn search_internal_parallel(
541        &self,
542        query: &dyn crate::query::Query,
543        fetch_limit: usize,
544        offset: usize,
545        collect_positions: bool,
546    ) -> Result<(Vec<crate::query::SearchResult>, u32)> {
547        tokio::task::block_in_place(|| {
548            self.search_internal_sync(query, fetch_limit, offset, collect_positions)
549        })
550    }
551
552    /// Sync body of the parallel search: rayon par_iter over segments.
553    /// Callers must already be off the async reactor (block_in_place or a
554    /// rayon/blocking thread) — safe to nest inside another par_iter
555    /// (rayon work-stealing composes).
556    #[cfg(feature = "sync")]
557    fn search_internal_sync(
558        &self,
559        query: &dyn crate::query::Query,
560        fetch_limit: usize,
561        offset: usize,
562        collect_positions: bool,
563    ) -> Result<(Vec<crate::query::SearchResult>, u32)> {
564        use rayon::prelude::*;
565
566        let batches: Vec<Result<(Vec<crate::query::SearchResult>, u32)>> = {
567            self.segments
568                .par_iter()
569                .map(|segment| {
570                    let sid = segment.meta().id;
571                    let (mut results, segment_seen) = if collect_positions {
572                        crate::query::search_segment_with_positions_and_count_sync(
573                            segment.as_ref(),
574                            query,
575                            fetch_limit,
576                        )?
577                    } else {
578                        crate::query::search_segment_with_count_sync(
579                            segment.as_ref(),
580                            query,
581                            fetch_limit,
582                        )?
583                    };
584                    for r in &mut results {
585                        r.segment_id = sid;
586                    }
587                    Ok((results, segment_seen))
588                })
589                .collect()
590        };
591
592        let mut total_seen: u32 = 0;
593        let mut sorted_batches: Vec<Vec<crate::query::SearchResult>> =
594            Vec::with_capacity(batches.len());
595        for result in batches {
596            let (batch, segment_seen) = result?;
597            total_seen = total_seen.saturating_add(segment_seen);
598            if !batch.is_empty() {
599                sorted_batches.push(batch);
600            }
601        }
602
603        let results = merge_segment_results(sorted_batches, fetch_limit, offset);
604        Ok((results, total_seen))
605    }
606
607    /// Synchronous search across all segments using rayon for parallelism.
608    ///
609    /// This is the async-free boundary — no tokio involvement from here down.
610    #[cfg(feature = "sync")]
611    pub fn search_with_offset_and_count_sync(
612        &self,
613        query: &dyn crate::query::Query,
614        limit: usize,
615        offset: usize,
616    ) -> Result<(Vec<crate::query::SearchResult>, u32)> {
617        use rayon::prelude::*;
618
619        let fetch_limit = offset + limit;
620
621        let batches: Vec<Result<(Vec<crate::query::SearchResult>, u32)>> = self
622            .segments
623            .par_iter()
624            .map(|segment| {
625                let sid = segment.meta().id;
626                let (mut results, segment_seen) = crate::query::search_segment_with_count_sync(
627                    segment.as_ref(),
628                    query,
629                    fetch_limit,
630                )?;
631                for r in &mut results {
632                    r.segment_id = sid;
633                }
634                Ok((results, segment_seen))
635            })
636            .collect();
637
638        let mut total_seen: u32 = 0;
639        let mut sorted_batches: Vec<Vec<crate::query::SearchResult>> =
640            Vec::with_capacity(batches.len());
641        for result in batches {
642            let (batch, segment_seen) = result?;
643            total_seen = total_seen.saturating_add(segment_seen);
644            if !batch.is_empty() {
645                sorted_batches.push(batch);
646            }
647        }
648
649        let results = merge_segment_results(sorted_batches, fetch_limit, offset);
650        Ok((results, total_seen))
651    }
652
653    /// Hybrid search: run several queries independently and fuse their
654    /// ranked lists (union) into a single top-`limit` result.
655    ///
656    /// Unlike [`Self::search_and_rerank`] — which can only re-score
657    /// documents the first-stage query already found — fusion keeps
658    /// documents found by *any* of the queries. Typical use is sparse
659    /// (BM25/SPLADE) + dense vector hybrid retrieval with
660    /// `FusionMethod::Rrf { k: 60.0 }`.
661    ///
662    /// Fusion happens at **chunk granularity**: per-ordinal scores are
663    /// collected from each sub-query, fused per `(doc, ordinal)` key, then
664    /// combined into a doc score with `combiner`
665    /// (`MultiValueCombiner::Max` recommended — same-chunk corroboration
666    /// across verticals compounds, scattered noise does not). Fused results
667    /// carry per-chunk `positions`.
668    ///
669    /// Each query is paired with a weight scaling its contribution.
670    /// `fetch_limit` is the per-query candidate depth; a common choice is
671    /// `4 * limit` (min 50) for good rank resolution.
672    pub async fn search_fused(
673        &self,
674        queries: &[(&dyn crate::query::Query, f32)],
675        fetch_limit: usize,
676        limit: usize,
677        method: crate::query::FusionMethod,
678        combiner: crate::query::MultiValueCombiner,
679    ) -> Result<Vec<crate::query::SearchResult>> {
680        // Sub-queries are independent — fan them out on rayon under a single
681        // block_in_place (each also par_iters its segments; rayon
682        // work-stealing composes the two levels). Sequential fallback for
683        // current_thread runtimes / non-sync builds.
684        #[cfg(feature = "sync")]
685        if !self.segments.is_empty()
686            && tokio::runtime::Handle::current().runtime_flavor()
687                == tokio::runtime::RuntimeFlavor::MultiThread
688        {
689            use rayon::prelude::*;
690            let lists: Vec<Result<(Vec<crate::query::SearchResult>, f32)>> =
691                tokio::task::block_in_place(|| {
692                    queries
693                        .par_iter()
694                        .map(|&(query, weight)| {
695                            let (results, _) =
696                                self.search_internal_sync(query, fetch_limit, 0, true)?;
697                            Ok((results, weight))
698                        })
699                        .collect()
700                });
701            let lists = lists.into_iter().collect::<Result<Vec<_>>>()?;
702            return Ok(crate::query::fuse_ranked_lists_chunked(
703                lists, method, combiner, limit,
704            ));
705        }
706
707        let mut lists = Vec::with_capacity(queries.len());
708        for &(query, weight) in queries {
709            let (results, _) = self.search_with_positions(query, fetch_limit).await?;
710            lists.push((results, weight));
711        }
712        Ok(crate::query::fuse_ranked_lists_chunked(
713            lists, method, combiner, limit,
714        ))
715    }
716
717    /// Two-stage search: L1 retrieval + L2 dense vector reranking
718    ///
719    /// Runs the query to get `l1_limit` candidates, then reranks by exact
720    /// dense vector distance and returns the top `final_limit` results.
721    pub async fn search_and_rerank(
722        &self,
723        query: &dyn crate::query::Query,
724        l1_limit: usize,
725        final_limit: usize,
726        config: &crate::query::RerankerConfig,
727    ) -> Result<(Vec<crate::query::SearchResult>, u32)> {
728        let (candidates, total_seen) = self.search_with_count(query, l1_limit).await?;
729        let reranked = crate::query::rerank(self, &candidates, config, final_limit).await?;
730        Ok((reranked, total_seen))
731    }
732
733    /// Parse query string and search (convenience method)
734    pub async fn query(
735        &self,
736        query_str: &str,
737        limit: usize,
738    ) -> Result<crate::query::SearchResponse> {
739        self.query_offset(query_str, limit, 0).await
740    }
741
742    /// Parse query string and search with offset (convenience method)
743    pub async fn query_offset(
744        &self,
745        query_str: &str,
746        limit: usize,
747        offset: usize,
748    ) -> Result<crate::query::SearchResponse> {
749        let parser = self.query_parser();
750        let query = parser
751            .parse(query_str)
752            .map_err(crate::error::Error::Query)?;
753
754        let (results, _total_seen) = self
755            .search_internal(query.as_ref(), limit, offset, false)
756            .await?;
757
758        let total_hits = results.len() as u32;
759        let hits: Vec<crate::query::SearchHit> = results
760            .into_iter()
761            .map(|result| crate::query::SearchHit {
762                address: crate::query::DocAddress::new(result.segment_id, result.doc_id),
763                score: result.score,
764                matched_fields: result.extract_ordinals(),
765            })
766            .collect();
767
768        Ok(crate::query::SearchResponse { hits, total_hits })
769    }
770
771    /// Get query parser for this searcher
772    pub fn query_parser(&self) -> crate::dsl::QueryLanguageParser {
773        let query_routers = self.schema.query_routers();
774        if !query_routers.is_empty()
775            && let Ok(router) = crate::dsl::QueryFieldRouter::from_rules(query_routers)
776        {
777            return crate::dsl::QueryLanguageParser::with_router(
778                Arc::clone(&self.schema),
779                self.default_fields.clone(),
780                Arc::clone(&self.tokenizers),
781                router,
782            );
783        }
784
785        crate::dsl::QueryLanguageParser::new(
786            Arc::clone(&self.schema),
787            self.default_fields.clone(),
788            Arc::clone(&self.tokenizers),
789        )
790    }
791
792    /// Get a document by address (segment_id + local doc_id)
793    pub async fn get_document(
794        &self,
795        address: &crate::query::DocAddress,
796    ) -> Result<Option<crate::dsl::Document>> {
797        self.get_document_with_fields(address, None).await
798    }
799
800    /// Get a document by address, hydrating only the specified field IDs.
801    ///
802    /// If `fields` is `None`, all fields are hydrated (including dense vectors).
803    /// If `fields` is `Some(set)`, only dense vector fields in the set are read
804    /// from flat storage — skipping expensive mmap reads for unrequested vectors.
805    pub async fn get_document_with_fields(
806        &self,
807        address: &crate::query::DocAddress,
808        fields: Option<&rustc_hash::FxHashSet<u32>>,
809    ) -> Result<Option<crate::dsl::Document>> {
810        let segment_id = address.segment_id_u128().ok_or_else(|| {
811            crate::error::Error::Query(format!("Invalid segment ID: {}", address.segment_id()))
812        })?;
813
814        if let Some(&idx) = self.segment_map.get(&segment_id) {
815            return self.segments[idx]
816                .doc_with_fields(address.doc_id, fields)
817                .await;
818        }
819
820        Ok(None)
821    }
822}
823
824/// K-way merge of pre-sorted segment result batches.
825///
826/// Each batch is sorted by score descending. Uses a max-heap of
827/// (score, batch_idx, position) to merge in O(N log K).
828fn merge_segment_results(
829    sorted_batches: Vec<Vec<crate::query::SearchResult>>,
830    fetch_limit: usize,
831    offset: usize,
832) -> Vec<crate::query::SearchResult> {
833    use std::cmp::Ordering;
834
835    struct MergeEntry {
836        score: f32,
837        batch_idx: usize,
838        pos: usize,
839    }
840    impl PartialEq for MergeEntry {
841        fn eq(&self, other: &Self) -> bool {
842            self.score == other.score
843        }
844    }
845    impl Eq for MergeEntry {}
846    impl PartialOrd for MergeEntry {
847        fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
848            Some(self.cmp(other))
849        }
850    }
851    impl Ord for MergeEntry {
852        fn cmp(&self, other: &Self) -> Ordering {
853            self.score
854                .partial_cmp(&other.score)
855                .unwrap_or(Ordering::Equal)
856        }
857    }
858
859    let mut heap = std::collections::BinaryHeap::with_capacity(sorted_batches.len());
860    for (i, batch) in sorted_batches.iter().enumerate() {
861        if !batch.is_empty() {
862            heap.push(MergeEntry {
863                score: batch[0].score,
864                batch_idx: i,
865                pos: 0,
866            });
867        }
868    }
869
870    let mut results = Vec::with_capacity(fetch_limit.min(64));
871    let mut emitted = 0usize;
872    while let Some(entry) = heap.pop() {
873        if emitted >= fetch_limit {
874            break;
875        }
876        let batch = &sorted_batches[entry.batch_idx];
877        if emitted >= offset {
878            results.push(batch[entry.pos].clone());
879        }
880        emitted += 1;
881        let next_pos = entry.pos + 1;
882        if next_pos < batch.len() {
883            heap.push(MergeEntry {
884                score: batch[next_pos].score,
885                batch_idx: entry.batch_idx,
886                pos: next_pos,
887            });
888        }
889    }
890
891    results
892}
893
894/// Get current process RSS in MB (best-effort, returns 0.0 on failure)
895fn process_rss_mb() -> f64 {
896    #[cfg(target_os = "linux")]
897    {
898        // Read from /proc/self/status — VmRSS line
899        if let Ok(status) = std::fs::read_to_string("/proc/self/status") {
900            for line in status.lines() {
901                if let Some(rest) = line.strip_prefix("VmRSS:") {
902                    let kb: f64 = rest
903                        .trim()
904                        .trim_end_matches("kB")
905                        .trim()
906                        .parse()
907                        .unwrap_or(0.0);
908                    return kb / 1024.0;
909                }
910            }
911        }
912        0.0
913    }
914    #[cfg(target_os = "macos")]
915    {
916        // Use mach_task_self / task_info via raw syscall
917        use std::mem;
918        #[repr(C)]
919        struct TaskBasicInfo {
920            virtual_size: u64,
921            resident_size: u64,
922            resident_size_max: u64,
923            user_time: [u32; 2],
924            system_time: [u32; 2],
925            policy: i32,
926            suspend_count: i32,
927        }
928        unsafe extern "C" {
929            fn mach_task_self() -> u32;
930            fn task_info(task: u32, flavor: u32, info: *mut TaskBasicInfo, count: *mut u32) -> i32;
931        }
932        const MACH_TASK_BASIC_INFO: u32 = 20;
933        let mut info: TaskBasicInfo = unsafe { mem::zeroed() };
934        let mut count = (mem::size_of::<TaskBasicInfo>() / mem::size_of::<u32>()) as u32;
935        let ret = unsafe {
936            task_info(
937                mach_task_self(),
938                MACH_TASK_BASIC_INFO,
939                &mut info,
940                &mut count,
941            )
942        };
943        if ret == 0 {
944            info.resident_size as f64 / (1024.0 * 1024.0)
945        } else {
946            0.0
947        }
948    }
949    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
950    {
951        0.0
952    }
953}