Skip to main content

hermes_core/index/
mod.rs

1//! Index - multi-segment async search index
2//!
3//! The `Index` is the central concept that provides:
4//! - `Index::create()` / `Index::open()` - create or open an index
5//! - `index.writer()` - get an IndexWriter for adding documents
6//! - `index.reader()` - get an IndexReader for searching (with reload policy)
7//!
8//! The Index owns the SegmentManager which handles segment lifecycle and tracking.
9
10#[cfg(feature = "native")]
11use crate::dsl::Schema;
12#[cfg(feature = "native")]
13use crate::error::Result;
14#[cfg(feature = "native")]
15use std::sync::Arc;
16
17mod searcher;
18pub use searcher::Searcher;
19
20#[cfg(feature = "native")]
21mod primary_key;
22#[cfg(feature = "native")]
23mod reader;
24#[cfg(feature = "native")]
25mod vector_builder;
26#[cfg(all(feature = "wasm", not(feature = "native")))]
27mod wasm_writer;
28#[cfg(feature = "native")]
29mod writer;
30#[cfg(feature = "native")]
31pub use primary_key::PrimaryKeyIndex;
32#[cfg(feature = "native")]
33pub use reader::IndexReader;
34#[cfg(all(feature = "wasm", not(feature = "native")))]
35pub use wasm_writer::IndexWriter as WasmIndexWriter;
36#[cfg(feature = "native")]
37pub use writer::{IndexWriter, PreparedCommit};
38
39mod metadata;
40pub use metadata::{FieldVectorMeta, INDEX_META_FILENAME, IndexMetadata, VectorIndexState};
41
42#[cfg(feature = "native")]
43mod helpers;
44#[cfg(feature = "native")]
45pub use helpers::{
46    IndexingStats, SchemaConfig, SchemaFieldConfig, create_index_at_path, create_index_from_sdl,
47    index_documents_from_reader, index_json_document, parse_schema,
48};
49
50/// Default file name for the slice cache
51pub const SLICE_CACHE_FILENAME: &str = "index.slicecache";
52
53/// Index configuration
54#[derive(Debug, Clone)]
55pub struct IndexConfig {
56    /// Number of threads for CPU-intensive tasks (search parallelism)
57    pub num_threads: usize,
58    /// Number of parallel segment builders (documents distributed round-robin)
59    pub num_indexing_threads: usize,
60    /// Number of threads for parallel block compression within each segment
61    pub num_compression_threads: usize,
62    /// Block cache size for term dictionary per segment
63    pub term_cache_blocks: usize,
64    /// Block cache size for document store per segment
65    pub store_cache_blocks: usize,
66    /// Max memory (bytes) across all builders before auto-commit (global limit)
67    pub max_indexing_memory_bytes: usize,
68    /// Merge policy for background segment merging
69    pub merge_policy: Box<dyn crate::merge::MergePolicy>,
70    /// Index optimization mode (adaptive, size-optimized, performance-optimized)
71    pub optimization: crate::structures::IndexOptimization,
72    /// Reload interval in milliseconds for IndexReader (how often to check for new segments)
73    pub reload_interval_ms: u64,
74    /// Maximum number of concurrent background merges (default: 4)
75    pub max_concurrent_merges: usize,
76    /// Wall-clock budget for merge-time BP reorder per field (only applies
77    /// when the index has `reorder_on_merge`). A truncated pass still writes
78    /// a valid, better-ordered segment; it is marked `bp_converged = false`
79    /// and the background optimizer deepens it later (warm-started).
80    /// `None` = unbudgeted (BP runs to full depth inside the merge, which can
81    /// hold a merge slot for 10-30+ minutes on 10M+ doc outputs).
82    pub merge_bp_time_budget: Option<std::time::Duration>,
83    /// Memory budget (bytes) for the BP forward index during reorder passes
84    /// (merge-time and background). When a large segment's forward index
85    /// would exceed this, the highest-df dims are dropped from BP's input
86    /// (logged loudly) — clustering quality degrades gracefully. Production
87    /// evidence: 18M-doc merges exceed the 2 GB default and drop ~10% of
88    /// eligible dims; hosts with headroom should raise this.
89    pub bp_memory_budget_bytes: usize,
90}
91
92impl Default for IndexConfig {
93    fn default() -> Self {
94        #[cfg(feature = "native")]
95        let indexing_threads = crate::default_indexing_threads();
96        #[cfg(not(feature = "native"))]
97        let indexing_threads = 1;
98
99        #[cfg(feature = "native")]
100        let compression_threads = crate::default_compression_threads();
101        #[cfg(not(feature = "native"))]
102        let compression_threads = 1;
103
104        Self {
105            num_threads: indexing_threads,
106            num_indexing_threads: 1, // Increase to 2+ for production to avoid stalls during segment build
107            num_compression_threads: compression_threads,
108            term_cache_blocks: 256,
109            store_cache_blocks: 32,
110            max_indexing_memory_bytes: 256 * 1024 * 1024, // 256 MB default
111            // large_scale: wide fan-in + budget/scored selection. Safe for
112            // small indexes too (tier floors only shape *when* segments
113            // merge); merge-time BP is wall-clock budgeted, so giant merges
114            // cannot hold slots indefinitely.
115            merge_policy: Box::new(crate::merge::TieredMergePolicy::large_scale()),
116            optimization: crate::structures::IndexOptimization::default(),
117            reload_interval_ms: 1000, // 1 second default
118            max_concurrent_merges: 4,
119            merge_bp_time_budget: Some(std::time::Duration::from_secs(600)),
120            // 24 GB — mirrors segment::reorder::DEFAULT_MEMORY_BUDGET (that
121            // module is native-only; IndexConfig also compiles for wasm).
122            // A cap, not an allocation: usage is proportional to the segment
123            // being reordered (~4 B/posting + ~28 B/doc). Sized from prod
124            // evidence: a 58M-doc/5B-posting pass estimated 20.1 GB, which
125            // 8/16 GB budgets trimmed by dropping highest-df dims.
126            // 24 GB overflows 32-bit usize (wasm32) — reorder never runs
127            // there, so any large value works; use usize::MAX.
128            #[cfg(target_pointer_width = "64")]
129            bp_memory_budget_bytes: 24 * 1024 * 1024 * 1024,
130            #[cfg(not(target_pointer_width = "64"))]
131            bp_memory_budget_bytes: usize::MAX,
132        }
133    }
134}
135
136/// Multi-segment async Index
137///
138/// The central concept for search. Owns segment lifecycle and provides:
139/// - `Index::create()` / `Index::open()` - create or open an index
140/// - `index.writer()` - get an IndexWriter for adding documents
141/// - `index.reader()` - get an IndexReader for searching with reload policy
142///
143/// All segment management is delegated to SegmentManager.
144#[cfg(feature = "native")]
145pub struct Index<D: crate::directories::DirectoryWriter + 'static> {
146    directory: Arc<D>,
147    schema: Arc<Schema>,
148    config: IndexConfig,
149    /// Segment manager - owns segments, tracker, metadata, and trained structures
150    segment_manager: Arc<crate::merge::SegmentManager<D>>,
151    /// Cached reader (created lazily, reused across calls)
152    cached_reader: tokio::sync::OnceCell<IndexReader<D>>,
153}
154
155#[cfg(feature = "native")]
156impl<D: crate::directories::DirectoryWriter + 'static> Index<D> {
157    /// Create a new index in the directory
158    pub async fn create(directory: D, schema: Schema, config: IndexConfig) -> Result<Self> {
159        let directory = Arc::new(directory);
160        let schema = Arc::new(schema);
161        // Directory-layer metrics (cold writes, lazy reads) carry the index label
162        directory.set_index_label(schema.index_label());
163        let metadata = IndexMetadata::new((*schema).clone());
164
165        let segment_manager = Arc::new(crate::merge::SegmentManager::new(
166            Arc::clone(&directory),
167            Arc::clone(&schema),
168            metadata,
169            config.merge_policy.clone_box(),
170            config.term_cache_blocks,
171            config.max_concurrent_merges,
172            config.merge_bp_time_budget,
173            config.bp_memory_budget_bytes,
174        ));
175
176        // Save initial metadata
177        segment_manager.update_metadata(|_| {}).await?;
178
179        Ok(Self {
180            directory,
181            schema,
182            config,
183            segment_manager,
184            cached_reader: tokio::sync::OnceCell::new(),
185        })
186    }
187
188    /// Open an existing index from a directory
189    pub async fn open(directory: D, config: IndexConfig) -> Result<Self> {
190        let directory = Arc::new(directory);
191
192        // Load metadata (includes schema)
193        let metadata = IndexMetadata::load(directory.as_ref()).await?;
194        let schema = Arc::new(metadata.schema.clone());
195        // Directory-layer metrics (cold writes, lazy reads) carry the index label
196        directory.set_index_label(schema.index_label());
197
198        let segment_manager = Arc::new(crate::merge::SegmentManager::new(
199            Arc::clone(&directory),
200            Arc::clone(&schema),
201            metadata,
202            config.merge_policy.clone_box(),
203            config.term_cache_blocks,
204            config.max_concurrent_merges,
205            config.merge_bp_time_budget,
206            config.bp_memory_budget_bytes,
207        ));
208
209        // Load trained structures into SegmentManager's ArcSwap
210        segment_manager.load_and_publish_trained().await;
211
212        Ok(Self {
213            directory,
214            schema,
215            config,
216            segment_manager,
217            cached_reader: tokio::sync::OnceCell::new(),
218        })
219    }
220
221    /// Get the schema
222    pub fn schema(&self) -> &Schema {
223        &self.schema
224    }
225
226    /// Get the schema as an Arc reference (avoids clone when Arc is needed)
227    pub fn schema_arc(&self) -> &Arc<Schema> {
228        &self.schema
229    }
230
231    /// Get a reference to the underlying directory
232    pub fn directory(&self) -> &D {
233        &self.directory
234    }
235
236    /// Get the segment manager
237    pub fn segment_manager(&self) -> &Arc<crate::merge::SegmentManager<D>> {
238        &self.segment_manager
239    }
240
241    /// Get an IndexReader for searching (with reload policy)
242    ///
243    /// The reader is cached and reused across calls. The reader's internal
244    /// searcher will reload segments based on its reload interval (configurable via IndexConfig).
245    pub async fn reader(&self) -> Result<&IndexReader<D>> {
246        self.cached_reader
247            .get_or_try_init(|| async {
248                IndexReader::from_segment_manager(
249                    Arc::clone(&self.schema),
250                    Arc::clone(&self.segment_manager),
251                    self.config.term_cache_blocks,
252                    self.config.reload_interval_ms,
253                )
254                .await
255            })
256            .await
257    }
258
259    /// Get the config
260    pub fn config(&self) -> &IndexConfig {
261        &self.config
262    }
263
264    /// Get segment readers for query execution (convenience method)
265    pub async fn segment_readers(&self) -> Result<Vec<Arc<crate::segment::SegmentReader>>> {
266        let reader = self.reader().await?;
267        let searcher = reader.searcher().await?;
268        Ok(searcher.segment_readers().to_vec())
269    }
270
271    /// Total number of documents across all segments
272    pub async fn num_docs(&self) -> Result<u32> {
273        let reader = self.reader().await?;
274        let searcher = reader.searcher().await?;
275        Ok(searcher.num_docs())
276    }
277
278    /// Get default fields for search
279    pub fn default_fields(&self) -> Vec<crate::Field> {
280        if !self.schema.default_fields().is_empty() {
281            self.schema.default_fields().to_vec()
282        } else {
283            self.schema
284                .fields()
285                .filter(|(_, entry)| {
286                    entry.indexed && entry.field_type == crate::dsl::FieldType::Text
287                })
288                .map(|(field, _)| field)
289                .collect()
290        }
291    }
292
293    /// Get tokenizer registry
294    pub fn tokenizers(&self) -> Arc<crate::tokenizer::TokenizerRegistry> {
295        Arc::new(crate::tokenizer::TokenizerRegistry::default())
296    }
297
298    /// Create a query parser for this index
299    pub fn query_parser(&self) -> crate::dsl::QueryLanguageParser {
300        let default_fields = self.default_fields();
301        let tokenizers = self.tokenizers();
302
303        let query_routers = self.schema.query_routers();
304        if !query_routers.is_empty()
305            && let Ok(router) = crate::dsl::QueryFieldRouter::from_rules(query_routers)
306        {
307            return crate::dsl::QueryLanguageParser::with_router(
308                Arc::clone(&self.schema),
309                default_fields,
310                tokenizers,
311                router,
312            );
313        }
314
315        crate::dsl::QueryLanguageParser::new(Arc::clone(&self.schema), default_fields, tokenizers)
316    }
317
318    /// Parse and search using a query string
319    pub async fn query(
320        &self,
321        query_str: &str,
322        limit: usize,
323    ) -> Result<crate::query::SearchResponse> {
324        self.query_offset(query_str, limit, 0).await
325    }
326
327    /// Query with offset for pagination
328    pub async fn query_offset(
329        &self,
330        query_str: &str,
331        limit: usize,
332        offset: usize,
333    ) -> Result<crate::query::SearchResponse> {
334        let parser = self.query_parser();
335        let query = parser
336            .parse(query_str)
337            .map_err(crate::error::Error::Query)?;
338        self.search_offset(query.as_ref(), limit, offset).await
339    }
340
341    /// Search and return results
342    pub async fn search(
343        &self,
344        query: &dyn crate::query::Query,
345        limit: usize,
346    ) -> Result<crate::query::SearchResponse> {
347        self.search_offset(query, limit, 0).await
348    }
349
350    /// Search with offset for pagination
351    pub async fn search_offset(
352        &self,
353        query: &dyn crate::query::Query,
354        limit: usize,
355        offset: usize,
356    ) -> Result<crate::query::SearchResponse> {
357        let reader = self.reader().await?;
358        let searcher = reader.searcher().await?;
359
360        #[cfg(feature = "sync")]
361        let (results, total_seen) = {
362            // Sync search: rayon handles segment parallelism internally.
363            // On multi-threaded tokio, use block_in_place to yield the worker;
364            // on single-threaded (tests), call directly.
365            let runtime_flavor = tokio::runtime::Handle::current().runtime_flavor();
366            if runtime_flavor == tokio::runtime::RuntimeFlavor::MultiThread {
367                tokio::task::block_in_place(|| {
368                    searcher.search_with_offset_and_count_sync(query, limit, offset)
369                })?
370            } else {
371                searcher.search_with_offset_and_count_sync(query, limit, offset)?
372            }
373        };
374
375        #[cfg(not(feature = "sync"))]
376        let (results, total_seen) = {
377            searcher
378                .search_with_offset_and_count(query, limit, offset)
379                .await?
380        };
381
382        let total_hits = total_seen;
383        let hits: Vec<crate::query::SearchHit> = results
384            .into_iter()
385            .map(|result| crate::query::SearchHit {
386                address: crate::query::DocAddress::new(result.segment_id, result.doc_id),
387                score: result.score,
388                matched_fields: result.extract_ordinals(),
389            })
390            .collect();
391
392        Ok(crate::query::SearchResponse { hits, total_hits })
393    }
394
395    /// Get a document by its unique address
396    pub async fn get_document(
397        &self,
398        address: &crate::query::DocAddress,
399    ) -> Result<Option<crate::dsl::Document>> {
400        let reader = self.reader().await?;
401        let searcher = reader.searcher().await?;
402        searcher.get_document(address).await
403    }
404
405    /// Get posting lists for a term across all segments
406    pub async fn get_postings(
407        &self,
408        field: crate::Field,
409        term: &[u8],
410    ) -> Result<
411        Vec<(
412            Arc<crate::segment::SegmentReader>,
413            crate::structures::BlockPostingList,
414        )>,
415    > {
416        let segments = self.segment_readers().await?;
417        let mut results = Vec::new();
418
419        for segment in segments {
420            if let Some(postings) = segment.get_postings(field, term).await? {
421                results.push((segment, postings));
422            }
423        }
424
425        Ok(results)
426    }
427}
428
429/// Native-only methods for Index
430#[cfg(feature = "native")]
431impl<D: crate::directories::DirectoryWriter + 'static> Index<D> {
432    /// Get an IndexWriter for adding documents
433    pub fn writer(&self) -> writer::IndexWriter<D> {
434        writer::IndexWriter::from_index(self)
435    }
436}
437
438#[cfg(test)]
439mod tests;
440
441// (tests moved to index/tests/ module)