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 per index (default: 4)
75    pub max_concurrent_merges: usize,
76    /// Application-wide background merge gate shared by clones of this
77    /// config. The per-index limit alone multiplied large merge working sets
78    /// by the number of active indexes.
79    #[cfg(feature = "native")]
80    pub background_merge_permits: Arc<tokio::sync::Semaphore>,
81    /// Wall-clock budget for merge-time BP reorder per field (only applies
82    /// when the index has `reorder_on_merge`). A truncated pass still writes
83    /// a valid, better-ordered segment; it is marked `bp_converged = false`
84    /// and the background optimizer deepens it later (warm-started).
85    /// `None` = unbudgeted (BP runs to full depth inside the merge, which can
86    /// hold a merge slot for 10-30+ minutes on 10M+ doc outputs).
87    pub merge_bp_time_budget: Option<std::time::Duration>,
88    /// Memory budget (bytes) for the BP forward index during reorder passes
89    /// (merge-time and background). When a large segment's forward index
90    /// would exceed this, the highest-df dims are dropped from BP's input
91    /// (logged loudly) — clustering quality degrades gracefully. Production
92    /// evidence: 18M-doc merges exceeded the former 2 GB default and dropped
93    /// ~10% of eligible dims; hosts with less headroom may lower this.
94    pub bp_memory_budget_bytes: usize,
95    /// Hard limit on simultaneous whole-segment BP rewrites. This is shared
96    /// by all indexes opened from clones of this config and applies to
97    /// optimizer, merge-time, and manual reorder passes. It is deliberately
98    /// separate from the Rayon pool width: one pass can already use every
99    /// background CPU thread and consume the full BP memory budget.
100    #[cfg(feature = "native")]
101    pub background_reorder_permits: Arc<tokio::sync::Semaphore>,
102    /// Optional process/application-owned Rayon pool for BP work. Supplying
103    /// one lets every index and the optimizer share the same worker threads;
104    /// `None` lazily uses one process-wide cores/2 fallback pool.
105    #[cfg(feature = "native")]
106    pub background_reorder_pool: Option<Arc<rayon::ThreadPool>>,
107}
108
109impl Default for IndexConfig {
110    fn default() -> Self {
111        #[cfg(feature = "native")]
112        let indexing_threads = crate::default_indexing_threads();
113        #[cfg(not(feature = "native"))]
114        let indexing_threads = 1;
115
116        #[cfg(feature = "native")]
117        let compression_threads = crate::default_compression_threads();
118        #[cfg(not(feature = "native"))]
119        let compression_threads = 1;
120
121        Self {
122            num_threads: indexing_threads,
123            num_indexing_threads: 1, // Increase to 2+ for production to avoid stalls during segment build
124            num_compression_threads: compression_threads,
125            term_cache_blocks: 256,
126            store_cache_blocks: 32,
127            max_indexing_memory_bytes: 256 * 1024 * 1024, // 256 MB default
128            // large_scale: wide fan-in + budget/scored selection. Safe for
129            // small indexes too (tier floors only shape *when* segments
130            // merge); merge-time BP is wall-clock budgeted, so giant merges
131            // cannot hold slots indefinitely.
132            merge_policy: Box::new(crate::merge::TieredMergePolicy::large_scale()),
133            optimization: crate::structures::IndexOptimization::default(),
134            reload_interval_ms: 1000, // 1 second default
135            max_concurrent_merges: 4,
136            #[cfg(feature = "native")]
137            background_merge_permits: Arc::new(tokio::sync::Semaphore::new(4)),
138            merge_bp_time_budget: Some(std::time::Duration::from_secs(600)),
139            // 24 GB — mirrors segment::reorder::DEFAULT_MEMORY_BUDGET (that
140            // module is native-only; IndexConfig also compiles for wasm).
141            // A cap, not an allocation: usage is proportional to the segment
142            // being reordered (~4 B/posting + ~32 B/doc). Sized from prod
143            // evidence: a 58M-doc/5B-posting pass estimated 20.1 GB, which
144            // 8/16 GB budgets trimmed by dropping highest-df dims.
145            // 24 GB overflows 32-bit usize (wasm32) — reorder never runs
146            // there, so any large value works; use usize::MAX.
147            #[cfg(target_pointer_width = "64")]
148            bp_memory_budget_bytes: 24 * 1024 * 1024 * 1024,
149            #[cfg(not(target_pointer_width = "64"))]
150            bp_memory_budget_bytes: usize::MAX,
151            #[cfg(feature = "native")]
152            background_reorder_permits: Arc::new(tokio::sync::Semaphore::new(2)),
153            #[cfg(feature = "native")]
154            background_reorder_pool: None,
155        }
156    }
157}
158
159/// Multi-segment async Index
160///
161/// The central concept for search. Owns segment lifecycle and provides:
162/// - `Index::create()` / `Index::open()` - create or open an index
163/// - `index.writer()` - get an IndexWriter for adding documents
164/// - `index.reader()` - get an IndexReader for searching with reload policy
165///
166/// All segment management is delegated to SegmentManager.
167#[cfg(feature = "native")]
168pub struct Index<D: crate::directories::DirectoryWriter + 'static> {
169    directory: Arc<D>,
170    schema: Arc<Schema>,
171    config: IndexConfig,
172    /// Segment manager - owns segments, tracker, metadata, and trained structures
173    segment_manager: Arc<crate::merge::SegmentManager<D>>,
174    /// Cached reader (created lazily, reused across calls)
175    cached_reader: tokio::sync::OnceCell<IndexReader<D>>,
176}
177
178#[cfg(feature = "native")]
179impl<D: crate::directories::DirectoryWriter + 'static> Index<D> {
180    /// Create a new index in the directory
181    pub async fn create(directory: D, schema: Schema, config: IndexConfig) -> Result<Self> {
182        let directory = Arc::new(directory);
183        let schema = Arc::new(schema);
184        // Directory-layer metrics (cold writes, lazy reads) carry the index label
185        directory.set_index_label(schema.index_label());
186        let metadata = IndexMetadata::new((*schema).clone());
187
188        let segment_manager = Arc::new(crate::merge::SegmentManager::new(
189            Arc::clone(&directory),
190            Arc::clone(&schema),
191            metadata,
192            config.merge_policy.clone_box(),
193            config.term_cache_blocks,
194            config.max_concurrent_merges,
195            Arc::clone(&config.background_merge_permits),
196            config.merge_bp_time_budget,
197            config.bp_memory_budget_bytes,
198            Arc::clone(&config.background_reorder_permits),
199            config.background_reorder_pool.clone(),
200        ));
201
202        // Save initial metadata
203        segment_manager.update_metadata(|_| {}).await?;
204
205        Ok(Self {
206            directory,
207            schema,
208            config,
209            segment_manager,
210            cached_reader: tokio::sync::OnceCell::new(),
211        })
212    }
213
214    /// Open an existing index from a directory
215    pub async fn open(directory: D, config: IndexConfig) -> Result<Self> {
216        let directory = Arc::new(directory);
217
218        // Load metadata (includes schema)
219        let metadata = IndexMetadata::load(directory.as_ref()).await?;
220        let schema = Arc::new(metadata.schema.clone());
221        // Directory-layer metrics (cold writes, lazy reads) carry the index label
222        directory.set_index_label(schema.index_label());
223
224        let segment_manager = Arc::new(crate::merge::SegmentManager::new(
225            Arc::clone(&directory),
226            Arc::clone(&schema),
227            metadata,
228            config.merge_policy.clone_box(),
229            config.term_cache_blocks,
230            config.max_concurrent_merges,
231            Arc::clone(&config.background_merge_permits),
232            config.merge_bp_time_budget,
233            config.bp_memory_budget_bytes,
234            Arc::clone(&config.background_reorder_permits),
235            config.background_reorder_pool.clone(),
236        ));
237
238        // Load trained structures into SegmentManager's ArcSwap
239        segment_manager.load_and_publish_trained().await;
240
241        Ok(Self {
242            directory,
243            schema,
244            config,
245            segment_manager,
246            cached_reader: tokio::sync::OnceCell::new(),
247        })
248    }
249
250    /// Get the schema
251    pub fn schema(&self) -> &Schema {
252        &self.schema
253    }
254
255    /// Get the schema as an Arc reference (avoids clone when Arc is needed)
256    pub fn schema_arc(&self) -> &Arc<Schema> {
257        &self.schema
258    }
259
260    /// Get a reference to the underlying directory
261    pub fn directory(&self) -> &D {
262        &self.directory
263    }
264
265    /// Get the segment manager
266    pub fn segment_manager(&self) -> &Arc<crate::merge::SegmentManager<D>> {
267        &self.segment_manager
268    }
269
270    /// Get an IndexReader for searching (with reload policy)
271    ///
272    /// The reader is cached and reused across calls. The reader's internal
273    /// searcher will reload segments based on its reload interval (configurable via IndexConfig).
274    pub async fn reader(&self) -> Result<&IndexReader<D>> {
275        self.cached_reader
276            .get_or_try_init(|| async {
277                IndexReader::from_segment_manager(
278                    Arc::clone(&self.schema),
279                    Arc::clone(&self.segment_manager),
280                    self.config.term_cache_blocks,
281                    self.config.reload_interval_ms,
282                )
283                .await
284            })
285            .await
286    }
287
288    /// Get the config
289    pub fn config(&self) -> &IndexConfig {
290        &self.config
291    }
292
293    /// Get segment readers for query execution (convenience method)
294    pub async fn segment_readers(&self) -> Result<Vec<Arc<crate::segment::SegmentReader>>> {
295        let reader = self.reader().await?;
296        let searcher = reader.searcher().await?;
297        Ok(searcher.segment_readers().to_vec())
298    }
299
300    /// Total number of documents across all segments
301    pub async fn num_docs(&self) -> Result<u32> {
302        let reader = self.reader().await?;
303        let searcher = reader.searcher().await?;
304        Ok(searcher.num_docs())
305    }
306
307    /// Get default fields for search
308    pub fn default_fields(&self) -> Vec<crate::Field> {
309        if !self.schema.default_fields().is_empty() {
310            self.schema.default_fields().to_vec()
311        } else {
312            self.schema
313                .fields()
314                .filter(|(_, entry)| {
315                    entry.indexed && entry.field_type == crate::dsl::FieldType::Text
316                })
317                .map(|(field, _)| field)
318                .collect()
319        }
320    }
321
322    /// Get tokenizer registry
323    pub fn tokenizers(&self) -> Arc<crate::tokenizer::TokenizerRegistry> {
324        Arc::new(crate::tokenizer::TokenizerRegistry::default())
325    }
326
327    /// Create a query parser for this index
328    pub fn query_parser(&self) -> crate::dsl::QueryLanguageParser {
329        let default_fields = self.default_fields();
330        let tokenizers = self.tokenizers();
331
332        let query_routers = self.schema.query_routers();
333        if !query_routers.is_empty()
334            && let Ok(router) = crate::dsl::QueryFieldRouter::from_rules(query_routers)
335        {
336            return crate::dsl::QueryLanguageParser::with_router(
337                Arc::clone(&self.schema),
338                default_fields,
339                tokenizers,
340                router,
341            );
342        }
343
344        crate::dsl::QueryLanguageParser::new(Arc::clone(&self.schema), default_fields, tokenizers)
345    }
346
347    /// Parse and search using a query string
348    pub async fn query(
349        &self,
350        query_str: &str,
351        limit: usize,
352    ) -> Result<crate::query::SearchResponse> {
353        self.query_offset(query_str, limit, 0).await
354    }
355
356    /// Query with offset for pagination
357    pub async fn query_offset(
358        &self,
359        query_str: &str,
360        limit: usize,
361        offset: usize,
362    ) -> Result<crate::query::SearchResponse> {
363        let parser = self.query_parser();
364        let query = parser
365            .parse(query_str)
366            .map_err(crate::error::Error::Query)?;
367        self.search_offset(query.as_ref(), limit, offset).await
368    }
369
370    /// Search and return results
371    pub async fn search(
372        &self,
373        query: &dyn crate::query::Query,
374        limit: usize,
375    ) -> Result<crate::query::SearchResponse> {
376        self.search_offset(query, limit, 0).await
377    }
378
379    /// Search with offset for pagination
380    pub async fn search_offset(
381        &self,
382        query: &dyn crate::query::Query,
383        limit: usize,
384        offset: usize,
385    ) -> Result<crate::query::SearchResponse> {
386        let reader = self.reader().await?;
387        let searcher = reader.searcher().await?;
388
389        #[cfg(feature = "sync")]
390        let (results, total_seen) = {
391            // Sync search: rayon handles segment parallelism internally.
392            // On multi-threaded tokio, use block_in_place to yield the worker;
393            // on single-threaded (tests), call directly.
394            let runtime_flavor = tokio::runtime::Handle::current().runtime_flavor();
395            if runtime_flavor == tokio::runtime::RuntimeFlavor::MultiThread {
396                tokio::task::block_in_place(|| {
397                    searcher.search_with_offset_and_count_sync(query, limit, offset)
398                })?
399            } else {
400                searcher.search_with_offset_and_count_sync(query, limit, offset)?
401            }
402        };
403
404        #[cfg(not(feature = "sync"))]
405        let (results, total_seen) = {
406            searcher
407                .search_with_offset_and_count(query, limit, offset)
408                .await?
409        };
410
411        let total_hits = total_seen;
412        let hits: Vec<crate::query::SearchHit> = results
413            .into_iter()
414            .map(|result| crate::query::SearchHit {
415                address: crate::query::DocAddress::new(result.segment_id, result.doc_id),
416                score: result.score,
417                matched_fields: result.extract_ordinals(),
418            })
419            .collect();
420
421        Ok(crate::query::SearchResponse { hits, total_hits })
422    }
423
424    /// Get a document by its unique address
425    pub async fn get_document(
426        &self,
427        address: &crate::query::DocAddress,
428    ) -> Result<Option<crate::dsl::Document>> {
429        let reader = self.reader().await?;
430        let searcher = reader.searcher().await?;
431        searcher.get_document(address).await
432    }
433
434    /// Get posting lists for a term across all segments
435    pub async fn get_postings(
436        &self,
437        field: crate::Field,
438        term: &[u8],
439    ) -> Result<
440        Vec<(
441            Arc<crate::segment::SegmentReader>,
442            crate::structures::BlockPostingList,
443        )>,
444    > {
445        let segments = self.segment_readers().await?;
446        let mut results = Vec::new();
447
448        for segment in segments {
449            if let Some(postings) = segment.get_postings(field, term).await? {
450                results.push((segment, postings));
451            }
452        }
453
454        Ok(results)
455    }
456}
457
458/// Native-only methods for Index
459#[cfg(feature = "native")]
460impl<D: crate::directories::DirectoryWriter + 'static> Index<D> {
461    /// Get an IndexWriter for adding documents
462    pub fn writer(&self) -> writer::IndexWriter<D> {
463        writer::IndexWriter::from_index(self)
464    }
465}
466
467#[cfg(test)]
468mod tests;
469
470// (tests moved to index/tests/ module)