Skip to main content

hermes_core/segment/reader/
mod.rs

1//! Async segment reader with lazy loading
2
3pub(crate) mod bmp;
4pub(crate) mod loader;
5mod types;
6
7pub use bmp::BmpIndex;
8#[cfg(feature = "diagnostics")]
9pub use types::DimRawData;
10pub use types::{SparseIndex, VectorIndex, VectorSearchResult};
11
12/// Memory statistics for a single segment
13#[derive(Debug, Clone, Default)]
14pub struct SegmentMemoryStats {
15    /// Segment ID
16    pub segment_id: u128,
17    /// Number of documents in segment
18    pub num_docs: u32,
19    /// Term dictionary block cache bytes
20    pub term_dict_cache_bytes: usize,
21    /// Document store block cache bytes
22    pub store_cache_bytes: usize,
23    /// Sparse vector index bytes (in-memory posting lists)
24    pub sparse_index_bytes: usize,
25    /// Dense vector index bytes (cluster assignments, quantized codes)
26    pub dense_index_bytes: usize,
27    /// Bloom filter bytes
28    pub bloom_filter_bytes: usize,
29    /// Hot metadata bytes actually pinned (mlock/heap-copy) at open
30    pub pinned_metadata_bytes: u64,
31    /// Hot metadata bytes eligible for pinning (gap vs pinned = budget
32    /// exhausted or mlock failures — operator-visible)
33    pub pin_intended_bytes: u64,
34}
35
36impl SegmentMemoryStats {
37    /// Total estimated memory for this segment
38    pub fn total_bytes(&self) -> usize {
39        self.term_dict_cache_bytes
40            + self.store_cache_bytes
41            + self.sparse_index_bytes
42            + self.dense_index_bytes
43            + self.bloom_filter_bytes
44    }
45}
46
47use std::sync::Arc;
48
49use rustc_hash::FxHashMap;
50
51use super::vector_data::LazyFlatVectorData;
52use crate::directories::{Directory, FileHandle};
53use crate::dsl::{DenseVectorQuantization, Document, Field, Schema};
54use crate::structures::{
55    AsyncSSTableReader, BlockPostingList, CoarseCentroids, IVFPQIndex, IVFRaBitQIndex, PQCodebook,
56    RaBitQIndex, SSTableStats, TermInfo,
57};
58use crate::{DocId, Error, Result};
59
60use super::store::{AsyncStoreReader, RawStoreBlock};
61use super::types::{SegmentFiles, SegmentId, SegmentMeta};
62
63/// Combine per-ordinal (doc_id, ordinal, score) triples into VectorSearchResults,
64/// applying the multi-value combiner, sorting by score desc, and truncating to `limit`.
65///
66/// Fast path: when all ordinals are 0 (single-valued field), skips the HashMap
67/// grouping entirely and just sorts + truncates the raw results.
68pub(crate) fn combine_ordinal_results(
69    raw: impl IntoIterator<Item = (u32, u16, f32)>,
70    combiner: crate::query::MultiValueCombiner,
71    limit: usize,
72) -> Vec<VectorSearchResult> {
73    let collected: Vec<(u32, u16, f32)> = raw.into_iter().collect();
74
75    let num_raw = collected.len();
76    if log::log_enabled!(log::Level::Debug) {
77        let mut ids: Vec<u32> = collected.iter().map(|(d, _, _)| *d).collect();
78        ids.sort_unstable();
79        ids.dedup();
80        log::debug!(
81            "combine_ordinal_results: {} raw entries, {} unique docs, combiner={:?}, limit={}",
82            num_raw,
83            ids.len(),
84            combiner,
85            limit
86        );
87    }
88
89    // Fast path: all ordinals are 0 → no grouping needed, skip HashMap
90    let all_single = collected.iter().all(|&(_, ord, _)| ord == 0);
91    if all_single {
92        let mut results: Vec<VectorSearchResult> = collected
93            .into_iter()
94            .map(|(doc_id, _, score)| VectorSearchResult::new(doc_id, score, vec![(0, score)]))
95            .collect();
96        results.sort_unstable_by(|a, b| {
97            b.score
98                .partial_cmp(&a.score)
99                .unwrap_or(std::cmp::Ordering::Equal)
100        });
101        results.truncate(limit);
102        return results;
103    }
104
105    // Slow path: multi-valued field — group by doc_id, apply combiner
106    let mut doc_ordinals: rustc_hash::FxHashMap<DocId, Vec<(u32, f32)>> =
107        rustc_hash::FxHashMap::default();
108    for (doc_id, ordinal, score) in collected {
109        doc_ordinals
110            .entry(doc_id as DocId)
111            .or_default()
112            .push((ordinal as u32, score));
113    }
114    let mut results: Vec<VectorSearchResult> = doc_ordinals
115        .into_iter()
116        .map(|(doc_id, ordinals)| {
117            let combined_score = combiner.combine(&ordinals);
118            VectorSearchResult::new(doc_id, combined_score, ordinals)
119        })
120        .collect();
121    results.sort_unstable_by(|a, b| {
122        b.score
123            .partial_cmp(&a.score)
124            .unwrap_or(std::cmp::Ordering::Equal)
125    });
126    results.truncate(limit);
127    results
128}
129
130/// Async segment reader with lazy loading
131///
132/// - Term dictionary: only index loaded, blocks loaded on-demand
133/// - Postings: loaded on-demand per term via HTTP range requests
134/// - Document store: only index loaded, blocks loaded on-demand via HTTP range requests
135pub struct SegmentReader {
136    meta: SegmentMeta,
137    /// Term dictionary with lazy block loading
138    term_dict: Arc<AsyncSSTableReader<TermInfo>>,
139    /// Postings file handle - fetches ranges on demand
140    postings_handle: FileHandle,
141    /// Document store with lazy block loading
142    store: Arc<AsyncStoreReader>,
143    schema: Arc<Schema>,
144    /// Dense vector indexes per field (RaBitQ or IVF-RaBitQ) — for search
145    vector_indexes: FxHashMap<u32, VectorIndex>,
146    /// Lazy flat vectors per field — for reranking and merge (doc_ids in memory, vectors via mmap)
147    flat_vectors: FxHashMap<u32, LazyFlatVectorData>,
148    /// Per-field coarse centroids for IVF/ScaNN search
149    coarse_centroids: FxHashMap<u32, Arc<CoarseCentroids>>,
150    /// Sparse vector indexes per field (MaxScore format)
151    sparse_indexes: FxHashMap<u32, SparseIndex>,
152    /// BMP sparse vector indexes per field (BMP format)
153    bmp_indexes: FxHashMap<u32, BmpIndex>,
154    /// Position file handle for phrase queries (lazy loading)
155    positions_handle: Option<FileHandle>,
156    /// Fast-field columnar readers per field_id
157    fast_fields: FxHashMap<u32, crate::structures::fast_field::FastFieldReader>,
158    /// Per-segment MaxScore threshold (f32 stored as AtomicU32 bits).
159    /// Allows per-field MaxScore groups within a single query to share thresholds:
160    /// field A's result seeds field B's pruning on the same segment.
161    /// Hot-metadata pin accounting (see `segment::pin`)
162    #[cfg(feature = "native")]
163    pin_report: crate::segment::pin::PinReport,
164}
165
166impl SegmentReader {
167    /// Open a segment with lazy loading
168    pub async fn open<D: Directory>(
169        dir: &D,
170        segment_id: SegmentId,
171        schema: Arc<Schema>,
172        cache_blocks: usize,
173    ) -> Result<Self> {
174        let files = SegmentFiles::new(segment_id.0);
175
176        // Read metadata (small, always loaded)
177        let meta_slice = dir.open_read(&files.meta).await?;
178        let meta_bytes = meta_slice.read_bytes().await?;
179        let meta = SegmentMeta::deserialize(meta_bytes.as_slice())?;
180        debug_assert_eq!(meta.id, segment_id.0);
181
182        // Open term dictionary with lazy loading (fetches ranges on demand)
183        let term_dict_handle = dir.open_lazy(&files.term_dict).await?;
184        let term_dict = AsyncSSTableReader::open(term_dict_handle, cache_blocks).await?;
185
186        // Get postings file handle (lazy - fetches ranges on demand)
187        let postings_handle = dir.open_lazy(&files.postings).await?;
188
189        // Open store with lazy loading
190        let store_handle = dir.open_lazy(&files.store).await?;
191        let store = AsyncStoreReader::open(store_handle, cache_blocks).await?;
192
193        // Load dense vector indexes from unified .vectors file
194        let vectors_data = loader::load_vectors_file(dir, &files, &schema).await?;
195        let vector_indexes = vectors_data.indexes;
196        let flat_vectors = vectors_data.flat_vectors;
197
198        // Fields served by an ANN index only touch flat vectors for scattered
199        // rerank reads — disable readahead for them once at open. Flat-only
200        // fields keep default advice: brute-force scans them sequentially.
201        // Advice is sticky on the mapping, so per-query re-advising is wasted.
202        #[cfg(feature = "native")]
203        for (field_id, lazy_flat) in &flat_vectors {
204            if vector_indexes.contains_key(field_id) {
205                lazy_flat.advise_random_access();
206            }
207        }
208
209        // Load sparse vector indexes from .sparse file (MaxScore + BMP)
210        let sparse_data = loader::load_sparse_file(dir, &files, meta.num_docs, &schema).await?;
211        let sparse_indexes = sparse_data.maxscore_indexes;
212        let bmp_indexes = sparse_data.bmp_indexes;
213
214        // Open positions file handle (if exists) - offsets are now in TermInfo
215        let positions_handle = loader::open_positions_file(dir, &files, &schema).await?;
216
217        // Load fast-field columns from .fast file
218        let fast_fields = loader::load_fast_fields_file(dir, &files, &schema).await?;
219
220        // Log segment loading stats
221        {
222            let mut parts = vec![format!(
223                "[segment] loaded {:016x}: docs={}",
224                segment_id.0, meta.num_docs
225            )];
226            if !vector_indexes.is_empty() || !flat_vectors.is_empty() {
227                parts.push(format!(
228                    "dense: {} ann + {} flat fields",
229                    vector_indexes.len(),
230                    flat_vectors.len()
231                ));
232            }
233            for (field_id, idx) in &sparse_indexes {
234                parts.push(format!(
235                    "sparse field {}: {} dims, ~{:.1} KB",
236                    field_id,
237                    idx.num_dimensions(),
238                    idx.num_dimensions() as f64 * 24.0 / 1024.0
239                ));
240            }
241            for (field_id, idx) in &bmp_indexes {
242                parts.push(format!(
243                    "bmp field {}: {} dims, {} blocks",
244                    field_id,
245                    idx.dims(),
246                    idx.num_blocks
247                ));
248            }
249            if !fast_fields.is_empty() {
250                parts.push(format!("fast: {} fields", fast_fields.len()));
251            }
252            log::debug!("{}", parts.join(", "));
253        }
254
255        #[allow(unused_mut)]
256        let mut reader = Self {
257            meta,
258            term_dict: Arc::new(term_dict),
259            postings_handle,
260            store: Arc::new(store),
261            schema,
262            vector_indexes,
263            flat_vectors,
264            coarse_centroids: FxHashMap::default(),
265            sparse_indexes,
266            bmp_indexes,
267            positions_handle,
268            fast_fields,
269            #[cfg(feature = "native")]
270            pin_report: Default::default(),
271        };
272
273        // Pin hot metadata per the process-wide policy (no-op when disabled)
274        #[cfg(feature = "native")]
275        reader.apply_pin_policy(&crate::segment::pin::pin_policy().to_owned());
276
277        Ok(reader)
278    }
279
280    /// Pin per-query-mandatory metadata sections in priority order until the
281    /// budget is exhausted (see `segment::pin` and docs/hot-metadata-pinning.md).
282    ///
283    /// Priority: BMP block-offset tables → sparse skip sections → doc-id maps
284    /// → BMP superblock grids. Bulk data (4-bit grid, block data, raw vectors)
285    /// is never pinned. Fail-loud: budget exhaustion and mlock failures are
286    /// logged and visible via `SegmentMemoryStats::{pin_intended_bytes,
287    /// pinned_metadata_bytes}`.
288    #[cfg(feature = "native")]
289    pub(crate) fn apply_pin_policy(&mut self, policy: &crate::segment::pin::PinPolicy) {
290        use crate::segment::pin::PinReport;
291
292        if !policy.is_enabled() {
293            return;
294        }
295        let mut remaining = policy.budget_bytes;
296        let mut report = PinReport::default();
297
298        // Priority 1: BMP block-offset tables
299        for bmp in self.bmp_indexes.values_mut() {
300            bmp.pin_block_starts(policy.mode, &mut remaining, &mut report);
301        }
302        // Priority 2: sparse skip sections
303        for sparse in self.sparse_indexes.values_mut() {
304            sparse.pin_skip_section(policy.mode, &mut remaining, &mut report);
305        }
306        // Priority 3: doc-id maps
307        for flat in self.flat_vectors.values_mut() {
308            flat.pin_doc_ids(policy.mode, &mut remaining, &mut report);
309        }
310        for bmp in self.bmp_indexes.values_mut() {
311            bmp.pin_doc_maps(policy.mode, &mut remaining, &mut report);
312        }
313        // Priority 4: BMP superblock grids
314        for bmp in self.bmp_indexes.values_mut() {
315            bmp.pin_sb_grid(policy.mode, &mut remaining, &mut report);
316        }
317
318        if report.skipped_budget_bytes > 0 || report.failed_bytes > 0 {
319            log::warn!(
320                "[pin] segment {:016x}: pinned {}/{} bytes (budget skipped {}, mlock failed {}) —                  raise HERMES_PIN_METADATA_BUDGET_MB or RLIMIT_MEMLOCK for full coverage",
321                self.meta.id,
322                report.pinned_bytes,
323                report.intended_bytes,
324                report.skipped_budget_bytes,
325                report.failed_bytes,
326            );
327        } else if report.pinned_bytes > 0 {
328            log::info!(
329                "[pin] segment {:016x}: pinned {} bytes of hot metadata ({:?})",
330                self.meta.id,
331                report.pinned_bytes,
332                policy.mode,
333            );
334        }
335        self.pin_report = report;
336    }
337
338    // NOTE: cross-group MaxScore threshold seeding is query-execution-local
339    // (a Cell in the boolean planner) — it must never live on the shared
340    // SegmentReader, where concurrent queries would leak thresholds into
341    // each other and wrongly prune results.
342
343    pub fn meta(&self) -> &SegmentMeta {
344        &self.meta
345    }
346
347    pub fn num_docs(&self) -> u32 {
348        self.meta.num_docs
349    }
350
351    /// Get average field length for BM25F scoring
352    pub fn avg_field_len(&self, field: Field) -> f32 {
353        self.meta.avg_field_len(field)
354    }
355
356    pub fn schema(&self) -> &Schema {
357        &self.schema
358    }
359
360    /// Get sparse indexes for all fields
361    pub fn sparse_indexes(&self) -> &FxHashMap<u32, SparseIndex> {
362        &self.sparse_indexes
363    }
364
365    /// Get sparse index for a specific field (MaxScore format)
366    pub fn sparse_index(&self, field: Field) -> Option<&SparseIndex> {
367        self.sparse_indexes.get(&field.0)
368    }
369
370    /// Get BMP index for a specific field
371    pub fn bmp_index(&self, field: Field) -> Option<&BmpIndex> {
372        self.bmp_indexes.get(&field.0)
373    }
374
375    /// Get all BMP indexes
376    pub fn bmp_indexes(&self) -> &FxHashMap<u32, BmpIndex> {
377        &self.bmp_indexes
378    }
379
380    /// Get vector indexes for all fields
381    pub fn vector_indexes(&self) -> &FxHashMap<u32, VectorIndex> {
382        &self.vector_indexes
383    }
384
385    /// Get lazy flat vectors for all fields (for reranking and merge)
386    pub fn flat_vectors(&self) -> &FxHashMap<u32, LazyFlatVectorData> {
387        &self.flat_vectors
388    }
389
390    /// Get a fast-field reader for a specific field.
391    pub fn fast_field(
392        &self,
393        field_id: u32,
394    ) -> Option<&crate::structures::fast_field::FastFieldReader> {
395        self.fast_fields.get(&field_id)
396    }
397
398    /// Get all fast-field readers.
399    pub fn fast_fields(&self) -> &FxHashMap<u32, crate::structures::fast_field::FastFieldReader> {
400        &self.fast_fields
401    }
402
403    /// Get term dictionary stats for debugging
404    pub fn term_dict_stats(&self) -> SSTableStats {
405        self.term_dict.stats()
406    }
407
408    /// Estimate memory usage of this segment reader
409    pub fn memory_stats(&self) -> SegmentMemoryStats {
410        let term_dict_stats = self.term_dict.stats();
411
412        // Term dict cache: num_blocks * avg_block_size (estimate 4KB per cached block)
413        let term_dict_cache_bytes = self.term_dict.cached_blocks() * 4096;
414
415        // Store cache: similar estimate
416        let store_cache_bytes = self.store.cached_blocks() * 4096;
417
418        // Sparse index: SoA dim table + OwnedBytes skip section + BMP grids
419        let sparse_index_bytes: usize = self
420            .sparse_indexes
421            .values()
422            .map(|s| s.estimated_memory_bytes())
423            .sum::<usize>()
424            + self
425                .bmp_indexes
426                .values()
427                .map(|b| b.estimated_memory_bytes())
428                .sum::<usize>();
429
430        // Dense index: vectors are memory-mapped, but we track index structures
431        // RaBitQ/IVF indexes have cluster assignments in memory
432        let dense_index_bytes: usize = self
433            .vector_indexes
434            .values()
435            .map(|v| v.estimated_memory_bytes())
436            .sum();
437
438        #[cfg(feature = "native")]
439        let (pinned_metadata_bytes, pin_intended_bytes) =
440            (self.pin_report.pinned_bytes, self.pin_report.intended_bytes);
441        #[cfg(not(feature = "native"))]
442        let (pinned_metadata_bytes, pin_intended_bytes) = (0u64, 0u64);
443
444        SegmentMemoryStats {
445            segment_id: self.meta.id,
446            num_docs: self.meta.num_docs,
447            term_dict_cache_bytes,
448            store_cache_bytes,
449            sparse_index_bytes,
450            dense_index_bytes,
451            bloom_filter_bytes: term_dict_stats.bloom_filter_size,
452            pinned_metadata_bytes,
453            pin_intended_bytes,
454        }
455    }
456
457    /// Get posting list for a term (async - loads on demand)
458    ///
459    /// For small posting lists (1-3 docs), the data is inlined in the term dictionary
460    /// and no additional I/O is needed. For larger lists, reads from .post file.
461    pub async fn get_postings(
462        &self,
463        field: Field,
464        term: &[u8],
465    ) -> Result<Option<BlockPostingList>> {
466        log::debug!(
467            "SegmentReader::get_postings field={} term_len={}",
468            field.0,
469            term.len()
470        );
471
472        // Build key: field_id + term
473        let mut key = Vec::with_capacity(4 + term.len());
474        key.extend_from_slice(&field.0.to_le_bytes());
475        key.extend_from_slice(term);
476
477        // Look up in term dictionary
478        let term_info = match self.term_dict.get(&key).await? {
479            Some(info) => {
480                log::debug!("SegmentReader::get_postings found term_info");
481                info
482            }
483            None => {
484                log::debug!("SegmentReader::get_postings term not found");
485                return Ok(None);
486            }
487        };
488
489        // Check if posting list is inlined
490        if let Some((doc_ids, term_freqs)) = term_info.decode_inline() {
491            // Build BlockPostingList from inline data (no I/O needed!)
492            let mut posting_list = crate::structures::PostingList::with_capacity(doc_ids.len());
493            for (doc_id, tf) in doc_ids.into_iter().zip(term_freqs) {
494                posting_list.push(doc_id, tf);
495            }
496            let block_list = BlockPostingList::from_posting_list(&posting_list)?;
497            return Ok(Some(block_list));
498        }
499
500        // External posting list - read from postings file handle (lazy - HTTP range request)
501        let (posting_offset, posting_len) = term_info.external_info().ok_or_else(|| {
502            Error::Corruption("TermInfo has neither inline nor external data".to_string())
503        })?;
504
505        let start = posting_offset;
506        let end = start + posting_len;
507
508        if end > self.postings_handle.len() {
509            return Err(Error::Corruption(
510                "Posting offset out of bounds".to_string(),
511            ));
512        }
513
514        let posting_bytes = self.postings_handle.read_bytes_range(start..end).await?;
515        let block_list = BlockPostingList::deserialize_zero_copy(posting_bytes)?;
516
517        Ok(Some(block_list))
518    }
519
520    /// Get all posting lists for terms that start with `prefix` in the given field.
521    pub async fn get_prefix_postings(
522        &self,
523        field: Field,
524        prefix: &[u8],
525    ) -> Result<Vec<BlockPostingList>> {
526        // Build composite key prefix: field_id ++ prefix
527        let mut key_prefix = Vec::with_capacity(4 + prefix.len());
528        key_prefix.extend_from_slice(&field.0.to_le_bytes());
529        key_prefix.extend_from_slice(prefix);
530
531        let entries = self.term_dict.prefix_scan(&key_prefix).await?;
532        let mut results = Vec::with_capacity(entries.len());
533
534        for (_key, term_info) in entries {
535            if let Some((doc_ids, term_freqs)) = term_info.decode_inline() {
536                let mut posting_list = crate::structures::PostingList::with_capacity(doc_ids.len());
537                for (doc_id, tf) in doc_ids.into_iter().zip(term_freqs) {
538                    posting_list.push(doc_id, tf);
539                }
540                results.push(BlockPostingList::from_posting_list(&posting_list)?);
541            } else if let Some((posting_offset, posting_len)) = term_info.external_info() {
542                let start = posting_offset;
543                let end = start + posting_len;
544                if end > self.postings_handle.len() {
545                    continue;
546                }
547                let posting_bytes = self.postings_handle.read_bytes_range(start..end).await?;
548                results.push(BlockPostingList::deserialize_zero_copy(posting_bytes)?);
549            }
550        }
551
552        Ok(results)
553    }
554
555    /// Get document by local doc_id (async - loads on demand).
556    ///
557    /// Dense vector fields are hydrated from LazyFlatVectorData (not stored in .store).
558    /// Uses binary search on sorted doc_ids for O(log N) lookup.
559    pub async fn doc(&self, local_doc_id: DocId) -> Result<Option<Document>> {
560        self.doc_with_fields(local_doc_id, None).await
561    }
562
563    /// Get document by local doc_id, hydrating only the specified fields.
564    ///
565    /// If `fields` is `None`, all fields (including dense vectors) are hydrated.
566    /// If `fields` is `Some(set)`, only dense vector fields in the set are hydrated,
567    /// skipping expensive mmap reads + dequantization for unrequested vector fields.
568    pub async fn doc_with_fields(
569        &self,
570        local_doc_id: DocId,
571        fields: Option<&rustc_hash::FxHashSet<u32>>,
572    ) -> Result<Option<Document>> {
573        let mut doc = match fields {
574            Some(set) => {
575                let field_ids: Vec<u32> = set.iter().copied().collect();
576                match self
577                    .store
578                    .get_fields(local_doc_id, &self.schema, &field_ids)
579                    .await
580                {
581                    Ok(Some(d)) => d,
582                    Ok(None) => return Ok(None),
583                    Err(e) => return Err(Error::from(e)),
584                }
585            }
586            None => match self.store.get(local_doc_id, &self.schema).await {
587                Ok(Some(d)) => d,
588                Ok(None) => return Ok(None),
589                Err(e) => return Err(Error::from(e)),
590            },
591        };
592
593        // Hydrate dense vector fields from flat vector data
594        for (&field_id, lazy_flat) in &self.flat_vectors {
595            // Skip vector fields not in the requested set
596            if let Some(set) = fields
597                && !set.contains(&field_id)
598            {
599                continue;
600            }
601
602            let is_binary = lazy_flat.quantization == DenseVectorQuantization::Binary;
603            let (start, entries) = lazy_flat.flat_indexes_for_doc(local_doc_id);
604            for (j, &(_doc_id, _ordinal)) in entries.iter().enumerate() {
605                let flat_idx = start + j;
606                if is_binary {
607                    let vbs = lazy_flat.vector_byte_size();
608                    let mut raw = vec![0u8; vbs];
609                    match lazy_flat.read_vector_raw_into(flat_idx, &mut raw).await {
610                        Ok(()) => {
611                            doc.add_binary_dense_vector(Field(field_id), raw);
612                        }
613                        Err(e) => {
614                            log::warn!("Failed to hydrate binary vector field {}: {}", field_id, e);
615                        }
616                    }
617                } else {
618                    match lazy_flat.get_vector(flat_idx).await {
619                        Ok(vec) => {
620                            doc.add_dense_vector(Field(field_id), vec);
621                        }
622                        Err(e) => {
623                            log::warn!("Failed to hydrate vector field {}: {}", field_id, e);
624                        }
625                    }
626                }
627            }
628        }
629
630        Ok(Some(doc))
631    }
632
633    /// Prefetch term dictionary blocks for a key range
634    pub async fn prefetch_terms(
635        &self,
636        field: Field,
637        start_term: &[u8],
638        end_term: &[u8],
639    ) -> Result<()> {
640        let mut start_key = Vec::with_capacity(4 + start_term.len());
641        start_key.extend_from_slice(&field.0.to_le_bytes());
642        start_key.extend_from_slice(start_term);
643
644        let mut end_key = Vec::with_capacity(4 + end_term.len());
645        end_key.extend_from_slice(&field.0.to_le_bytes());
646        end_key.extend_from_slice(end_term);
647
648        self.term_dict.prefetch_range(&start_key, &end_key).await?;
649        Ok(())
650    }
651
652    /// Check if store uses dictionary compression (incompatible with raw merging)
653    pub fn store_has_dict(&self) -> bool {
654        self.store.has_dict()
655    }
656
657    /// Get store reference for merge operations
658    pub fn store(&self) -> &super::store::AsyncStoreReader {
659        &self.store
660    }
661
662    /// Get raw store blocks for optimized merging
663    pub fn store_raw_blocks(&self) -> Vec<RawStoreBlock> {
664        self.store.raw_blocks()
665    }
666
667    /// Get store data slice for raw block access
668    pub fn store_data_slice(&self) -> &FileHandle {
669        self.store.data_slice()
670    }
671
672    /// Get all terms from this segment (for merge)
673    pub async fn all_terms(&self) -> Result<Vec<(Vec<u8>, TermInfo)>> {
674        self.term_dict.all_entries().await.map_err(Error::from)
675    }
676
677    /// Get all terms with parsed field and term string (for statistics aggregation)
678    ///
679    /// Returns (field, term_string, doc_freq) for each term in the dictionary.
680    /// Skips terms that aren't valid UTF-8.
681    pub async fn all_terms_with_stats(&self) -> Result<Vec<(Field, String, u32)>> {
682        let entries = self.term_dict.all_entries().await?;
683        let mut result = Vec::with_capacity(entries.len());
684
685        for (key, term_info) in entries {
686            // Key format: field_id (4 bytes little-endian) + term bytes
687            if key.len() > 4 {
688                let field_id = u32::from_le_bytes([key[0], key[1], key[2], key[3]]);
689                let term_bytes = &key[4..];
690                if let Ok(term_str) = std::str::from_utf8(term_bytes) {
691                    result.push((Field(field_id), term_str.to_string(), term_info.doc_freq()));
692                }
693            }
694        }
695
696        Ok(result)
697    }
698
699    /// Get streaming iterator over term dictionary (for memory-efficient merge)
700    pub fn term_dict_iter(&self) -> crate::structures::AsyncSSTableIterator<'_, TermInfo> {
701        self.term_dict.iter()
702    }
703
704    /// Prefetch all term dictionary blocks in a single bulk I/O call.
705    ///
706    /// Call before merge iteration to eliminate per-block cache misses.
707    pub async fn prefetch_term_dict(&self) -> crate::Result<()> {
708        self.term_dict
709            .prefetch_all_data_bulk()
710            .await
711            .map_err(crate::Error::from)
712    }
713
714    /// Read raw posting bytes at offset
715    pub async fn read_postings(&self, offset: u64, len: u64) -> Result<Vec<u8>> {
716        let start = offset;
717        let end = start + len;
718        let bytes = self.postings_handle.read_bytes_range(start..end).await?;
719        Ok(bytes.to_vec())
720    }
721
722    /// Read raw position bytes at offset (for merge)
723    pub async fn read_position_bytes(&self, offset: u64, len: u64) -> Result<Option<Vec<u8>>> {
724        let handle = match &self.positions_handle {
725            Some(h) => h,
726            None => return Ok(None),
727        };
728        let start = offset;
729        let end = start + len;
730        let bytes = handle.read_bytes_range(start..end).await?;
731        Ok(Some(bytes.to_vec()))
732    }
733
734    /// Check if this segment has a positions file
735    pub fn has_positions_file(&self) -> bool {
736        self.positions_handle.is_some()
737    }
738
739    /// Batch cosine scoring on raw quantized bytes.
740    ///
741    /// Dispatches to the appropriate SIMD scorer based on quantization type.
742    /// Vectors file uses data-first layout (offset 0) with 8-byte padding between
743    /// fields, so mmap slices are always properly aligned for f32/f16/u8 access.
744    fn score_quantized_batch(
745        query: &[f32],
746        raw: &[u8],
747        quant: crate::dsl::DenseVectorQuantization,
748        dim: usize,
749        scores: &mut [f32],
750        unit_norm: bool,
751    ) {
752        use crate::dsl::DenseVectorQuantization;
753        use crate::structures::simd;
754        match (quant, unit_norm) {
755            (DenseVectorQuantization::F32, false) => {
756                let num_floats = scores.len() * dim;
757                debug_assert!(
758                    (raw.as_ptr() as usize).is_multiple_of(std::mem::align_of::<f32>()),
759                    "f32 vector data not 4-byte aligned — vectors file may use legacy format"
760                );
761                let vectors: &[f32] =
762                    unsafe { std::slice::from_raw_parts(raw.as_ptr() as *const f32, num_floats) };
763                simd::batch_cosine_scores(query, vectors, dim, scores);
764            }
765            (DenseVectorQuantization::F32, true) => {
766                let num_floats = scores.len() * dim;
767                debug_assert!(
768                    (raw.as_ptr() as usize).is_multiple_of(std::mem::align_of::<f32>()),
769                    "f32 vector data not 4-byte aligned"
770                );
771                let vectors: &[f32] =
772                    unsafe { std::slice::from_raw_parts(raw.as_ptr() as *const f32, num_floats) };
773                simd::batch_dot_scores(query, vectors, dim, scores);
774            }
775            (DenseVectorQuantization::F16, false) => {
776                simd::batch_cosine_scores_f16(query, raw, dim, scores);
777            }
778            (DenseVectorQuantization::F16, true) => {
779                simd::batch_dot_scores_f16(query, raw, dim, scores);
780            }
781            (DenseVectorQuantization::UInt8, false) => {
782                simd::batch_cosine_scores_u8(query, raw, dim, scores);
783            }
784            (DenseVectorQuantization::UInt8, true) => {
785                simd::batch_dot_scores_u8(query, raw, dim, scores);
786            }
787            (DenseVectorQuantization::Binary, _) => {
788                // Binary vectors use search_binary_dense_vector(), not search_dense_vector()
789                unreachable!("Binary quantization should not reach score_quantized_batch");
790            }
791        }
792    }
793
794    /// Search dense vectors using RaBitQ
795    ///
796    /// Returns VectorSearchResult with ordinal tracking for multi-value fields.
797    /// Doc IDs are segment-local.
798    /// For multi-valued documents, scores are combined using the specified combiner.
799    pub async fn search_dense_vector(
800        &self,
801        field: Field,
802        query: &[f32],
803        k: usize,
804        nprobe: usize,
805        rerank_factor: f32,
806        combiner: crate::query::MultiValueCombiner,
807    ) -> Result<Vec<VectorSearchResult>> {
808        let ann_index = self.vector_indexes.get(&field.0);
809        let lazy_flat = self.flat_vectors.get(&field.0);
810
811        // No vectors at all for this field
812        if ann_index.is_none() && lazy_flat.is_none() {
813            return Ok(Vec::new());
814        }
815
816        // Check if vectors are pre-normalized (skip per-vector norm in scoring)
817        let unit_norm = self
818            .schema
819            .get_field_entry(field)
820            .and_then(|e| e.dense_vector_config.as_ref())
821            .is_some_and(|c| c.unit_norm);
822
823        /// Batch size for brute-force scoring (4096 vectors × 768 dims × 4 bytes ≈ 12MB)
824        const BRUTE_FORCE_BATCH: usize = 4096;
825
826        let fetch_k = (k as f32 * rerank_factor.max(1.0)).ceil() as usize;
827
828        // Results are (doc_id, ordinal, score) where score = similarity (higher = better)
829        let t0 = std::time::Instant::now();
830        let mut results: Vec<(u32, u16, f32)> = if let Some(index) = ann_index {
831            // ANN search (RaBitQ, IVF, ScaNN)
832            match index {
833                VectorIndex::RaBitQ(lazy) => {
834                    let rabitq = lazy.get().ok_or_else(|| {
835                        Error::Schema("RaBitQ index deserialization failed".to_string())
836                    })?;
837                    rabitq
838                        .search(query, fetch_k)
839                        .into_iter()
840                        .map(|(doc_id, ordinal, dist)| (doc_id, ordinal, 1.0 / (1.0 + dist)))
841                        .collect()
842                }
843                VectorIndex::IVF(lazy) => {
844                    let (index, codebook) = lazy.get().ok_or_else(|| {
845                        Error::Schema("IVF index deserialization failed".to_string())
846                    })?;
847                    let centroids = self.coarse_centroids.get(&field.0).ok_or_else(|| {
848                        Error::Schema(format!(
849                            "IVF index requires coarse centroids for field {}",
850                            field.0
851                        ))
852                    })?;
853                    let effective_nprobe = if nprobe > 0 { nprobe } else { 32 };
854                    index
855                        .search(centroids, codebook, query, fetch_k, Some(effective_nprobe))
856                        .into_iter()
857                        .map(|(doc_id, ordinal, dist)| (doc_id, ordinal, 1.0 / (1.0 + dist)))
858                        .collect()
859                }
860                VectorIndex::ScaNN(lazy) => {
861                    let (index, codebook) = lazy.get().ok_or_else(|| {
862                        Error::Schema("ScaNN index deserialization failed".to_string())
863                    })?;
864                    let centroids = self.coarse_centroids.get(&field.0).ok_or_else(|| {
865                        Error::Schema(format!(
866                            "ScaNN index requires coarse centroids for field {}",
867                            field.0
868                        ))
869                    })?;
870                    let effective_nprobe = if nprobe > 0 { nprobe } else { 32 };
871                    index
872                        .search(centroids, codebook, query, fetch_k, Some(effective_nprobe))
873                        .into_iter()
874                        .map(|(doc_id, ordinal, dist)| (doc_id, ordinal, 1.0 / (1.0 + dist)))
875                        .collect()
876                }
877                VectorIndex::BinaryIvf(_) => {
878                    // Binary IVF serves Hamming queries only (BinaryDenseVectorQuery)
879                    Vec::new()
880                }
881            }
882        } else if let Some(lazy_flat) = lazy_flat {
883            // Batched brute-force from lazy flat vectors (native-precision SIMD scoring)
884            // Uses a top-k heap to avoid collecting and sorting all N candidates.
885            log::debug!(
886                "[search_dense] field {}: brute-force on {} vectors (dim={}, quant={:?})",
887                field.0,
888                lazy_flat.num_vectors,
889                lazy_flat.dim,
890                lazy_flat.quantization
891            );
892            let dim = lazy_flat.dim;
893            let n = lazy_flat.num_vectors;
894            let quant = lazy_flat.quantization;
895            let mut collector = crate::query::ScoreCollector::new(fetch_k);
896            let mut scores = vec![0f32; BRUTE_FORCE_BATCH];
897
898            for batch_start in (0..n).step_by(BRUTE_FORCE_BATCH) {
899                let batch_count = BRUTE_FORCE_BATCH.min(n - batch_start);
900                let batch_bytes = lazy_flat
901                    .read_vectors_batch(batch_start, batch_count)
902                    .await
903                    .map_err(crate::Error::Io)?;
904                let raw = batch_bytes.as_slice();
905
906                Self::score_quantized_batch(
907                    query,
908                    raw,
909                    quant,
910                    dim,
911                    &mut scores[..batch_count],
912                    unit_norm,
913                );
914
915                for (i, &score) in scores.iter().enumerate().take(batch_count) {
916                    let (doc_id, ordinal) = lazy_flat.get_doc_id(batch_start + i);
917                    collector.insert_with_ordinal(doc_id, score, ordinal);
918                }
919            }
920
921            collector
922                .into_sorted_results()
923                .into_iter()
924                .map(|(doc_id, score, ordinal)| (doc_id, ordinal, score))
925                .collect()
926        } else {
927            return Ok(Vec::new());
928        };
929        let l1_elapsed = t0.elapsed();
930        {
931            let kind = match ann_index {
932                Some(VectorIndex::RaBitQ(_)) => "rabitq",
933                Some(VectorIndex::IVF(_)) => "ivf_rabitq",
934                Some(VectorIndex::ScaNN(_)) => "scann",
935                Some(VectorIndex::BinaryIvf(_)) => "binary_ivf",
936                None => "flat",
937            };
938            crate::observe::dense_l1(field.0, kind, l1_elapsed.as_secs_f64(), results.len());
939        }
940        log::debug!(
941            "[search_dense] field {}: L1 returned {} candidates in {:.1}ms",
942            field.0,
943            results.len(),
944            l1_elapsed.as_secs_f64() * 1000.0
945        );
946
947        // Rerank ANN candidates using raw vectors from lazy flat (binary search lookup)
948        // Uses native-precision SIMD scoring on quantized bytes — no dequantization overhead.
949        if ann_index.is_some()
950            && !results.is_empty()
951            && let Some(lazy_flat) = lazy_flat
952        {
953            let t_rerank = std::time::Instant::now();
954            let dim = lazy_flat.dim;
955            let quant = lazy_flat.quantization;
956            let vbs = lazy_flat.vector_byte_size();
957
958            // Resolve flat indexes for each candidate via binary search
959            let mut resolved: Vec<(usize, usize)> = Vec::new(); // (result_idx, flat_idx)
960            for (ri, c) in results.iter().enumerate() {
961                let (start, entries) = lazy_flat.flat_indexes_for_doc(c.0);
962                for (j, &(_, ord)) in entries.iter().enumerate() {
963                    if ord == c.1 {
964                        resolved.push((ri, start + j));
965                        break;
966                    }
967                }
968            }
969
970            let t_resolve = t_rerank.elapsed();
971            if !resolved.is_empty() {
972                // Sort by flat_idx for sequential mmap access (better page locality)
973                resolved.sort_unstable_by_key(|&(_, flat_idx)| flat_idx);
974
975                // Under memory pressure the candidate pages are likely evicted;
976                // batch-prefetch them so page-ins overlap instead of serializing
977                // as one major fault per vector. (MADV_RANDOM for this region is
978                // set once at segment open.)
979                #[cfg(feature = "native")]
980                lazy_flat.prefetch_vectors(resolved.iter().map(|&(_, flat_idx)| flat_idx));
981
982                // Batch-read raw quantized bytes into contiguous buffer
983                let t_read = std::time::Instant::now();
984                let mut raw_buf = vec![0u8; resolved.len() * vbs];
985                for (buf_idx, &(_, flat_idx)) in resolved.iter().enumerate() {
986                    let _ = lazy_flat
987                        .read_vector_raw_into(
988                            flat_idx,
989                            &mut raw_buf[buf_idx * vbs..(buf_idx + 1) * vbs],
990                        )
991                        .await;
992                }
993
994                let read_elapsed = t_read.elapsed();
995
996                // Native-precision batch SIMD cosine scoring
997                let t_score = std::time::Instant::now();
998                let mut scores = vec![0f32; resolved.len()];
999                Self::score_quantized_batch(query, &raw_buf, quant, dim, &mut scores, unit_norm);
1000                let score_elapsed = t_score.elapsed();
1001
1002                // Write scores back to results
1003                for (buf_idx, &(ri, _)) in resolved.iter().enumerate() {
1004                    results[ri].2 = scores[buf_idx];
1005                }
1006
1007                crate::observe::dense_rerank(
1008                    field.0,
1009                    t_rerank.elapsed().as_secs_f64(),
1010                    t_resolve.as_secs_f64(),
1011                    read_elapsed.as_secs_f64(),
1012                    resolved.len(),
1013                );
1014                log::debug!(
1015                    "[search_dense] field {}: rerank {} vectors (dim={}, quant={:?}, {}B/vec): resolve={:.1}ms read={:.1}ms score={:.1}ms",
1016                    field.0,
1017                    resolved.len(),
1018                    dim,
1019                    quant,
1020                    vbs,
1021                    t_resolve.as_secs_f64() * 1000.0,
1022                    read_elapsed.as_secs_f64() * 1000.0,
1023                    score_elapsed.as_secs_f64() * 1000.0,
1024                );
1025            }
1026
1027            if results.len() > fetch_k {
1028                results.select_nth_unstable_by(fetch_k, |a, b| b.2.total_cmp(&a.2));
1029                results.truncate(fetch_k);
1030            }
1031            results.sort_unstable_by(|a, b| b.2.total_cmp(&a.2));
1032            log::debug!(
1033                "[search_dense] field {}: rerank total={:.1}ms",
1034                field.0,
1035                t_rerank.elapsed().as_secs_f64() * 1000.0
1036            );
1037        }
1038
1039        Ok(combine_ordinal_results(results, combiner, k))
1040    }
1041
1042    /// Query-time nprobe for a binary field from its schema config (None = index default).
1043    fn binary_ivf_nprobe(&self, field: Field) -> Option<usize> {
1044        self.schema
1045            .get_field_entry(field)
1046            .and_then(|e| e.binary_dense_vector_config.as_ref())
1047            .map(|c| c.nprobe)
1048            .filter(|&n| n > 0)
1049    }
1050
1051    /// Search binary dense vectors using brute-force Hamming distance.
1052    ///
1053    /// Always flat brute-force (no ANN). Returns VectorSearchResult with ordinal tracking.
1054    pub async fn search_binary_dense_vector(
1055        &self,
1056        field: Field,
1057        query: &[u8],
1058        k: usize,
1059        combiner: crate::query::MultiValueCombiner,
1060    ) -> Result<Vec<VectorSearchResult>> {
1061        // Binary IVF index: probe nprobe nearest Hamming clusters (exact
1062        // distances within probed clusters — no rerank needed).
1063        if let Some(VectorIndex::BinaryIvf(lazy)) = self.vector_indexes.get(&field.0)
1064            && let Some(ivf) = lazy.get()
1065        {
1066            let nprobe = self.binary_ivf_nprobe(field);
1067            let results = ivf.search(query, k, nprobe);
1068            return Ok(combine_ordinal_results(results, combiner, k));
1069        }
1070
1071        let lazy_flat = match self.flat_vectors.get(&field.0) {
1072            Some(f) => f,
1073            None => return Ok(Vec::new()),
1074        };
1075
1076        const BRUTE_FORCE_BATCH: usize = 8192; // Binary vectors are tiny, use larger batches
1077
1078        let dim_bits = lazy_flat.dim;
1079        let byte_len = lazy_flat.vector_byte_size();
1080        let n = lazy_flat.num_vectors;
1081
1082        if byte_len != query.len() {
1083            return Err(Error::Schema(format!(
1084                "Binary query vector byte length {} != field byte length {}",
1085                query.len(),
1086                byte_len
1087            )));
1088        }
1089
1090        let mut collector = crate::query::ScoreCollector::new(k);
1091        let mut scores = vec![0f32; BRUTE_FORCE_BATCH];
1092
1093        for batch_start in (0..n).step_by(BRUTE_FORCE_BATCH) {
1094            let batch_count = BRUTE_FORCE_BATCH.min(n - batch_start);
1095            let batch_bytes = lazy_flat
1096                .read_vectors_batch(batch_start, batch_count)
1097                .await
1098                .map_err(crate::Error::Io)?;
1099            let raw = batch_bytes.as_slice();
1100
1101            crate::structures::simd::batch_hamming_scores(
1102                query,
1103                raw,
1104                byte_len,
1105                dim_bits,
1106                &mut scores[..batch_count],
1107            );
1108
1109            let threshold = collector.threshold();
1110            for (i, &score) in scores.iter().enumerate().take(batch_count) {
1111                if score > threshold {
1112                    let (doc_id, ordinal) = lazy_flat.get_doc_id(batch_start + i);
1113                    collector.insert_with_ordinal(doc_id, score, ordinal);
1114                }
1115            }
1116        }
1117
1118        let results: Vec<(u32, u16, f32)> = collector
1119            .into_sorted_results()
1120            .into_iter()
1121            .map(|(doc_id, score, ordinal)| (doc_id, ordinal, score))
1122            .collect();
1123
1124        Ok(combine_ordinal_results(results, combiner, k))
1125    }
1126
1127    /// Check if this segment has dense vectors for the given field
1128    pub fn has_dense_vector_index(&self, field: Field) -> bool {
1129        self.vector_indexes.contains_key(&field.0) || self.flat_vectors.contains_key(&field.0)
1130    }
1131
1132    /// Get the dense vector index for a field (if available)
1133    pub fn get_dense_vector_index(&self, field: Field) -> Option<Arc<RaBitQIndex>> {
1134        match self.vector_indexes.get(&field.0) {
1135            Some(VectorIndex::RaBitQ(lazy)) => lazy.get().cloned(),
1136            _ => None,
1137        }
1138    }
1139
1140    /// Get the IVF vector index for a field (if available)
1141    pub fn get_ivf_vector_index(
1142        &self,
1143        field: Field,
1144    ) -> Option<(Arc<IVFRaBitQIndex>, Arc<crate::structures::RaBitQCodebook>)> {
1145        match self.vector_indexes.get(&field.0) {
1146            Some(VectorIndex::IVF(lazy)) => lazy.get().map(|(i, c)| (i.clone(), c.clone())),
1147            _ => None,
1148        }
1149    }
1150
1151    /// Get coarse centroids for a field
1152    pub fn coarse_centroids(&self, field_id: u32) -> Option<&Arc<CoarseCentroids>> {
1153        self.coarse_centroids.get(&field_id)
1154    }
1155
1156    /// Set per-field coarse centroids from index-level trained structures
1157    pub fn set_coarse_centroids(&mut self, centroids: FxHashMap<u32, Arc<CoarseCentroids>>) {
1158        self.coarse_centroids = centroids;
1159    }
1160
1161    /// Get the ScaNN vector index for a field (if available)
1162    pub fn get_scann_vector_index(
1163        &self,
1164        field: Field,
1165    ) -> Option<(Arc<IVFPQIndex>, Arc<PQCodebook>)> {
1166        match self.vector_indexes.get(&field.0) {
1167            Some(VectorIndex::ScaNN(lazy)) => lazy.get().map(|(i, c)| (i.clone(), c.clone())),
1168            _ => None,
1169        }
1170    }
1171
1172    /// Get the vector index type for a field
1173    pub fn get_vector_index(&self, field: Field) -> Option<&VectorIndex> {
1174        self.vector_indexes.get(&field.0)
1175    }
1176
1177    /// Get positions for a term (for phrase queries)
1178    ///
1179    /// Position offsets are now embedded in TermInfo, so we first look up
1180    /// the term to get its TermInfo, then use position_info() to get the offset.
1181    pub async fn get_positions(
1182        &self,
1183        field: Field,
1184        term: &[u8],
1185    ) -> Result<Option<crate::structures::PositionPostingList>> {
1186        // Get positions handle
1187        let handle = match &self.positions_handle {
1188            Some(h) => h,
1189            None => return Ok(None),
1190        };
1191
1192        // Build key: field_id + term
1193        let mut key = Vec::with_capacity(4 + term.len());
1194        key.extend_from_slice(&field.0.to_le_bytes());
1195        key.extend_from_slice(term);
1196
1197        // Look up term in dictionary to get TermInfo with position offset
1198        let term_info = match self.term_dict.get(&key).await? {
1199            Some(info) => info,
1200            None => return Ok(None),
1201        };
1202
1203        // Get position offset from TermInfo
1204        let (offset, length) = match term_info.position_info() {
1205            Some((o, l)) => (o, l),
1206            None => return Ok(None),
1207        };
1208
1209        // Read the position data
1210        let slice = handle.slice(offset..offset + length);
1211        let data = slice.read_bytes().await?;
1212
1213        // Deserialize
1214        let pos_list = crate::structures::PositionPostingList::deserialize(data.as_slice())?;
1215
1216        Ok(Some(pos_list))
1217    }
1218
1219    /// Check if positions are available for a field
1220    pub fn has_positions(&self, field: Field) -> bool {
1221        // Check schema for position mode on this field
1222        if let Some(entry) = self.schema.get_field_entry(field) {
1223            entry.positions.is_some()
1224        } else {
1225            false
1226        }
1227    }
1228}
1229
1230// ── Synchronous search methods (mmap/RAM only) ─────────────────────────────
1231#[cfg(feature = "sync")]
1232impl SegmentReader {
1233    /// Synchronous posting list lookup — requires Inline (mmap/RAM) file handles.
1234    pub fn get_postings_sync(&self, field: Field, term: &[u8]) -> Result<Option<BlockPostingList>> {
1235        // Build key: field_id + term
1236        let mut key = Vec::with_capacity(4 + term.len());
1237        key.extend_from_slice(&field.0.to_le_bytes());
1238        key.extend_from_slice(term);
1239
1240        // Look up in term dictionary (sync)
1241        let term_info = match self.term_dict.get_sync(&key)? {
1242            Some(info) => info,
1243            None => return Ok(None),
1244        };
1245
1246        // Check if posting list is inlined
1247        if let Some((doc_ids, term_freqs)) = term_info.decode_inline() {
1248            let mut posting_list = crate::structures::PostingList::with_capacity(doc_ids.len());
1249            for (doc_id, tf) in doc_ids.into_iter().zip(term_freqs) {
1250                posting_list.push(doc_id, tf);
1251            }
1252            let block_list = BlockPostingList::from_posting_list(&posting_list)?;
1253            return Ok(Some(block_list));
1254        }
1255
1256        // External posting list — sync range read
1257        let (posting_offset, posting_len) = term_info.external_info().ok_or_else(|| {
1258            Error::Corruption("TermInfo has neither inline nor external data".to_string())
1259        })?;
1260
1261        let start = posting_offset;
1262        let end = start + posting_len;
1263
1264        if end > self.postings_handle.len() {
1265            return Err(Error::Corruption(
1266                "Posting offset out of bounds".to_string(),
1267            ));
1268        }
1269
1270        let posting_bytes = self.postings_handle.read_bytes_range_sync(start..end)?;
1271        let block_list = BlockPostingList::deserialize_zero_copy(posting_bytes)?;
1272
1273        Ok(Some(block_list))
1274    }
1275
1276    /// Synchronous prefix posting list lookup — requires Inline (mmap/RAM) file handles.
1277    pub fn get_prefix_postings_sync(
1278        &self,
1279        field: Field,
1280        prefix: &[u8],
1281    ) -> Result<Vec<BlockPostingList>> {
1282        let mut key_prefix = Vec::with_capacity(4 + prefix.len());
1283        key_prefix.extend_from_slice(&field.0.to_le_bytes());
1284        key_prefix.extend_from_slice(prefix);
1285
1286        let entries = self.term_dict.prefix_scan_sync(&key_prefix)?;
1287        let mut results = Vec::with_capacity(entries.len());
1288
1289        for (_key, term_info) in entries {
1290            if let Some((doc_ids, term_freqs)) = term_info.decode_inline() {
1291                let mut posting_list = crate::structures::PostingList::with_capacity(doc_ids.len());
1292                for (doc_id, tf) in doc_ids.into_iter().zip(term_freqs) {
1293                    posting_list.push(doc_id, tf);
1294                }
1295                results.push(BlockPostingList::from_posting_list(&posting_list)?);
1296            } else if let Some((posting_offset, posting_len)) = term_info.external_info() {
1297                let start = posting_offset;
1298                let end = start + posting_len;
1299                if end > self.postings_handle.len() {
1300                    continue;
1301                }
1302                let posting_bytes = self.postings_handle.read_bytes_range_sync(start..end)?;
1303                results.push(BlockPostingList::deserialize_zero_copy(posting_bytes)?);
1304            }
1305        }
1306
1307        Ok(results)
1308    }
1309
1310    /// Synchronous position list lookup — requires Inline (mmap/RAM) file handles.
1311    pub fn get_positions_sync(
1312        &self,
1313        field: Field,
1314        term: &[u8],
1315    ) -> Result<Option<crate::structures::PositionPostingList>> {
1316        let handle = match &self.positions_handle {
1317            Some(h) => h,
1318            None => return Ok(None),
1319        };
1320
1321        // Build key: field_id + term
1322        let mut key = Vec::with_capacity(4 + term.len());
1323        key.extend_from_slice(&field.0.to_le_bytes());
1324        key.extend_from_slice(term);
1325
1326        // Look up term in dictionary (sync)
1327        let term_info = match self.term_dict.get_sync(&key)? {
1328            Some(info) => info,
1329            None => return Ok(None),
1330        };
1331
1332        let (offset, length) = match term_info.position_info() {
1333            Some((o, l)) => (o, l),
1334            None => return Ok(None),
1335        };
1336
1337        let slice = handle.slice(offset..offset + length);
1338        let data = slice.read_bytes_sync()?;
1339
1340        let pos_list = crate::structures::PositionPostingList::deserialize(data.as_slice())?;
1341        Ok(Some(pos_list))
1342    }
1343
1344    /// Synchronous dense vector search — ANN indexes are already sync,
1345    /// brute-force uses sync mmap reads.
1346    pub fn search_dense_vector_sync(
1347        &self,
1348        field: Field,
1349        query: &[f32],
1350        k: usize,
1351        nprobe: usize,
1352        rerank_factor: f32,
1353        combiner: crate::query::MultiValueCombiner,
1354    ) -> Result<Vec<VectorSearchResult>> {
1355        let ann_index = self.vector_indexes.get(&field.0);
1356        let lazy_flat = self.flat_vectors.get(&field.0);
1357
1358        if ann_index.is_none() && lazy_flat.is_none() {
1359            return Ok(Vec::new());
1360        }
1361
1362        let unit_norm = self
1363            .schema
1364            .get_field_entry(field)
1365            .and_then(|e| e.dense_vector_config.as_ref())
1366            .is_some_and(|c| c.unit_norm);
1367
1368        const BRUTE_FORCE_BATCH: usize = 4096;
1369        let fetch_k = (k as f32 * rerank_factor.max(1.0)).ceil() as usize;
1370
1371        let mut results: Vec<(u32, u16, f32)> = if let Some(index) = ann_index {
1372            // ANN search (already sync)
1373            match index {
1374                VectorIndex::RaBitQ(lazy) => {
1375                    let rabitq = lazy.get().ok_or_else(|| {
1376                        Error::Schema("RaBitQ index deserialization failed".to_string())
1377                    })?;
1378                    rabitq
1379                        .search(query, fetch_k)
1380                        .into_iter()
1381                        .map(|(doc_id, ordinal, dist)| (doc_id, ordinal, 1.0 / (1.0 + dist)))
1382                        .collect()
1383                }
1384                VectorIndex::IVF(lazy) => {
1385                    let (index, codebook) = lazy.get().ok_or_else(|| {
1386                        Error::Schema("IVF index deserialization failed".to_string())
1387                    })?;
1388                    let centroids = self.coarse_centroids.get(&field.0).ok_or_else(|| {
1389                        Error::Schema(format!(
1390                            "IVF index requires coarse centroids for field {}",
1391                            field.0
1392                        ))
1393                    })?;
1394                    let effective_nprobe = if nprobe > 0 { nprobe } else { 32 };
1395                    index
1396                        .search(centroids, codebook, query, fetch_k, Some(effective_nprobe))
1397                        .into_iter()
1398                        .map(|(doc_id, ordinal, dist)| (doc_id, ordinal, 1.0 / (1.0 + dist)))
1399                        .collect()
1400                }
1401                VectorIndex::ScaNN(lazy) => {
1402                    let (index, codebook) = lazy.get().ok_or_else(|| {
1403                        Error::Schema("ScaNN index deserialization failed".to_string())
1404                    })?;
1405                    let centroids = self.coarse_centroids.get(&field.0).ok_or_else(|| {
1406                        Error::Schema(format!(
1407                            "ScaNN index requires coarse centroids for field {}",
1408                            field.0
1409                        ))
1410                    })?;
1411                    let effective_nprobe = if nprobe > 0 { nprobe } else { 32 };
1412                    index
1413                        .search(centroids, codebook, query, fetch_k, Some(effective_nprobe))
1414                        .into_iter()
1415                        .map(|(doc_id, ordinal, dist)| (doc_id, ordinal, 1.0 / (1.0 + dist)))
1416                        .collect()
1417                }
1418                VectorIndex::BinaryIvf(_) => {
1419                    // Binary IVF serves Hamming queries only (BinaryDenseVectorQuery)
1420                    Vec::new()
1421                }
1422            }
1423        } else if let Some(lazy_flat) = lazy_flat {
1424            // Batched brute-force (sync mmap reads)
1425            let dim = lazy_flat.dim;
1426            let n = lazy_flat.num_vectors;
1427            let quant = lazy_flat.quantization;
1428            let mut collector = crate::query::ScoreCollector::new(fetch_k);
1429            let mut scores = vec![0f32; BRUTE_FORCE_BATCH];
1430
1431            for batch_start in (0..n).step_by(BRUTE_FORCE_BATCH) {
1432                let batch_count = BRUTE_FORCE_BATCH.min(n - batch_start);
1433                let batch_bytes = lazy_flat
1434                    .read_vectors_batch_sync(batch_start, batch_count)
1435                    .map_err(crate::Error::Io)?;
1436                let raw = batch_bytes.as_slice();
1437
1438                Self::score_quantized_batch(
1439                    query,
1440                    raw,
1441                    quant,
1442                    dim,
1443                    &mut scores[..batch_count],
1444                    unit_norm,
1445                );
1446
1447                for (i, &score) in scores.iter().enumerate().take(batch_count) {
1448                    let (doc_id, ordinal) = lazy_flat.get_doc_id(batch_start + i);
1449                    collector.insert_with_ordinal(doc_id, score, ordinal);
1450                }
1451            }
1452
1453            collector
1454                .into_sorted_results()
1455                .into_iter()
1456                .map(|(doc_id, score, ordinal)| (doc_id, ordinal, score))
1457                .collect()
1458        } else {
1459            return Ok(Vec::new());
1460        };
1461
1462        // Rerank ANN candidates using raw vectors (sync)
1463        if ann_index.is_some()
1464            && !results.is_empty()
1465            && let Some(lazy_flat) = lazy_flat
1466        {
1467            let dim = lazy_flat.dim;
1468            let quant = lazy_flat.quantization;
1469            let vbs = lazy_flat.vector_byte_size();
1470
1471            let mut resolved: Vec<(usize, usize)> = Vec::new();
1472            for (ri, c) in results.iter().enumerate() {
1473                let (start, entries) = lazy_flat.flat_indexes_for_doc(c.0);
1474                for (j, &(_, ord)) in entries.iter().enumerate() {
1475                    if ord == c.1 {
1476                        resolved.push((ri, start + j));
1477                        break;
1478                    }
1479                }
1480            }
1481
1482            if !resolved.is_empty() {
1483                resolved.sort_unstable_by_key(|&(_, flat_idx)| flat_idx);
1484                let mut raw_buf = vec![0u8; resolved.len() * vbs];
1485                for (buf_idx, &(_, flat_idx)) in resolved.iter().enumerate() {
1486                    let _ = lazy_flat.read_vector_raw_into_sync(
1487                        flat_idx,
1488                        &mut raw_buf[buf_idx * vbs..(buf_idx + 1) * vbs],
1489                    );
1490                }
1491
1492                let mut scores = vec![0f32; resolved.len()];
1493                Self::score_quantized_batch(query, &raw_buf, quant, dim, &mut scores, unit_norm);
1494
1495                for (buf_idx, &(ri, _)) in resolved.iter().enumerate() {
1496                    results[ri].2 = scores[buf_idx];
1497                }
1498            }
1499
1500            if results.len() > fetch_k {
1501                results.select_nth_unstable_by(fetch_k, |a, b| b.2.total_cmp(&a.2));
1502                results.truncate(fetch_k);
1503            }
1504            results.sort_unstable_by(|a, b| b.2.total_cmp(&a.2));
1505        }
1506
1507        Ok(combine_ordinal_results(results, combiner, k))
1508    }
1509
1510    /// Synchronous binary dense vector search (mmap/RAM only).
1511    ///
1512    /// Mirrors [`Self::search_binary_dense_vector`] for the rayon-parallel
1513    /// sync scorer path used by multi-threaded runtimes.
1514    #[cfg(feature = "sync")]
1515    pub fn search_binary_dense_vector_sync(
1516        &self,
1517        field: Field,
1518        query: &[u8],
1519        k: usize,
1520        combiner: crate::query::MultiValueCombiner,
1521    ) -> Result<Vec<VectorSearchResult>> {
1522        // Binary IVF index: probe nprobe nearest Hamming clusters (exact
1523        // distances within probed clusters — no rerank needed).
1524        if let Some(VectorIndex::BinaryIvf(lazy)) = self.vector_indexes.get(&field.0)
1525            && let Some(ivf) = lazy.get()
1526        {
1527            let nprobe = self.binary_ivf_nprobe(field);
1528            let results = ivf.search(query, k, nprobe);
1529            return Ok(combine_ordinal_results(results, combiner, k));
1530        }
1531
1532        let lazy_flat = match self.flat_vectors.get(&field.0) {
1533            Some(f) => f,
1534            None => return Ok(Vec::new()),
1535        };
1536
1537        const BRUTE_FORCE_BATCH: usize = 8192; // Binary vectors are tiny, use larger batches
1538
1539        let dim_bits = lazy_flat.dim;
1540        let byte_len = lazy_flat.vector_byte_size();
1541        let n = lazy_flat.num_vectors;
1542
1543        if byte_len != query.len() {
1544            return Err(Error::Schema(format!(
1545                "Binary query vector byte length {} != field byte length {}",
1546                query.len(),
1547                byte_len
1548            )));
1549        }
1550
1551        let mut collector = crate::query::ScoreCollector::new(k);
1552        let mut scores = vec![0f32; BRUTE_FORCE_BATCH];
1553
1554        for batch_start in (0..n).step_by(BRUTE_FORCE_BATCH) {
1555            let batch_count = BRUTE_FORCE_BATCH.min(n - batch_start);
1556            let batch_bytes = lazy_flat
1557                .read_vectors_batch_sync(batch_start, batch_count)
1558                .map_err(crate::Error::Io)?;
1559            let raw = batch_bytes.as_slice();
1560
1561            crate::structures::simd::batch_hamming_scores(
1562                query,
1563                raw,
1564                byte_len,
1565                dim_bits,
1566                &mut scores[..batch_count],
1567            );
1568
1569            let threshold = collector.threshold();
1570            for (i, &score) in scores.iter().enumerate().take(batch_count) {
1571                if score > threshold {
1572                    let (doc_id, ordinal) = lazy_flat.get_doc_id(batch_start + i);
1573                    collector.insert_with_ordinal(doc_id, score, ordinal);
1574                }
1575            }
1576        }
1577
1578        let results: Vec<(u32, u16, f32)> = collector
1579            .into_sorted_results()
1580            .into_iter()
1581            .map(|(doc_id, score, ordinal)| (doc_id, ordinal, score))
1582            .collect();
1583
1584        Ok(combine_ordinal_results(results, combiner, k))
1585    }
1586}