Skip to main content

hermes_core/query/
global_stats.rs

1//! Lazy global statistics for cross-segment IDF computation
2//!
3//! Provides lazily computed and cached statistics across multiple segments for:
4//! - Sparse vector dimensions (for sparse vector queries)
5//! - Full-text terms (for BM25/TF-IDF scoring)
6//!
7//! Key design principles:
8//! - **Lazy computation**: IDF values computed on first access, not upfront
9//! - **Per-term caching**: Each term/dimension's IDF is cached independently
10//! - **Bound to Searcher**: Stats tied to segment snapshot lifetime
11
12use std::sync::Arc;
13
14use parking_lot::RwLock;
15use rustc_hash::FxHashMap;
16
17use crate::dsl::Field;
18use crate::segment::SegmentReader;
19
20/// Lazy global statistics bound to a fixed set of segments
21///
22/// Computes IDF values lazily on first access and caches them.
23/// Lifetime is bound to the Searcher that created it, ensuring
24/// statistics always match the current segment set.
25pub struct LazyGlobalStats {
26    /// Segment readers (Arc for shared ownership with Searcher)
27    segments: Vec<Arc<SegmentReader>>,
28    /// Total documents (computed once on construction)
29    total_docs: u64,
30    /// Cached sparse IDF values: field_id -> (dim_id -> idf)
31    sparse_idf_cache: RwLock<FxHashMap<u32, FxHashMap<u32, f32>>>,
32    /// Cached total_vectors per sparse field (computed once per field, not per dim)
33    sparse_total_vectors_cache: RwLock<FxHashMap<u32, u64>>,
34    /// Cached text IDF values: field_id -> (term -> idf)
35    text_idf_cache: RwLock<FxHashMap<u32, FxHashMap<String, f32>>>,
36    /// Cached average field lengths: field_id -> avg_len
37    avg_field_len_cache: RwLock<FxHashMap<u32, f32>>,
38    /// Cached text document frequencies: field_id -> (term -> df), summed
39    /// over the segments (chunk counts for chunked fields).
40    text_df_cache: RwLock<FxHashMap<u32, FxHashMap<Vec<u8>, u64>>>,
41    /// Cached BM25 corpus size per text field (documents, or chunks for a
42    /// chunked field), summed over the segments.
43    text_corpus_cache: RwLock<FxHashMap<u32, u64>>,
44}
45
46impl LazyGlobalStats {
47    /// Create new lazy stats bound to a set of segments
48    pub fn new(segments: Vec<Arc<SegmentReader>>) -> Self {
49        let total_docs: u64 = segments.iter().map(|s| s.num_docs() as u64).sum();
50        Self {
51            segments,
52            total_docs,
53            sparse_idf_cache: RwLock::new(FxHashMap::default()),
54            sparse_total_vectors_cache: RwLock::new(FxHashMap::default()),
55            text_idf_cache: RwLock::new(FxHashMap::default()),
56            avg_field_len_cache: RwLock::new(FxHashMap::default()),
57            text_df_cache: RwLock::new(FxHashMap::default()),
58            text_corpus_cache: RwLock::new(FxHashMap::default()),
59        }
60    }
61
62    /// Document frequency of a text term summed over every segment (chunk
63    /// frequency for a chunked field), cached per term. Uses the synchronous
64    /// term dictionary lookup; without the `sync` feature it is 0 and scorers
65    /// fall back to per-segment statistics.
66    pub fn text_df(&self, field: Field, term: &[u8]) -> u64 {
67        {
68            let cache = self.text_df_cache.read();
69            if let Some(field_cache) = cache.get(&field.0)
70                && let Some(&df) = field_cache.get(term)
71            {
72                return df;
73            }
74        }
75        let df = self.compute_text_df_bytes(field, term);
76        self.text_df_cache
77            .write()
78            .entry(field.0)
79            .or_default()
80            .insert(term.to_vec(), df);
81        df
82    }
83
84    /// BM25 corpus size of a text field summed over every segment: documents
85    /// for a plain field, chunks for a chunked field.
86    pub fn text_corpus_size(&self, field: Field) -> u64 {
87        if let Some(&size) = self.text_corpus_cache.read().get(&field.0) {
88            return size;
89        }
90        let size: u64 = self
91            .segments
92            .iter()
93            .map(|segment| segment.text_corpus_size(field) as u64)
94            .sum();
95        self.text_corpus_cache.write().insert(field.0, size);
96        size
97    }
98
99    /// Materialise the statistics one query needs: per-field corpus size and
100    /// average length, and the searcher-wide document frequency of every
101    /// `(field, term)` in `terms`. Scorers given these statistics score a
102    /// term identically in every segment.
103    pub fn text_stats_for(&self, terms: &[(Field, Vec<u8>)]) -> GlobalStats {
104        let mut builder = GlobalStatsBuilder::new();
105        builder.total_docs = self.total_docs;
106        let mut fields_seen: FxHashMap<u32, ()> = FxHashMap::default();
107        for (field, term) in terms {
108            if fields_seen.insert(field.0, ()).is_none() {
109                builder.set_avg_field_len(*field, self.avg_field_len(*field));
110                builder.set_text_corpus_size(*field, self.text_corpus_size(*field));
111            }
112            let df = self.text_df(*field, term);
113            if df > 0 {
114                builder.add_text_df(*field, String::from_utf8_lossy(term).into_owned(), df);
115            }
116        }
117        builder.build(0)
118    }
119
120    #[cfg(feature = "sync")]
121    fn compute_text_df_bytes(&self, field: Field, term: &[u8]) -> u64 {
122        self.segments
123            .iter()
124            .map(|segment| segment.text_doc_freq_sync(field, term).unwrap_or(0) as u64)
125            .sum()
126    }
127
128    #[cfg(not(feature = "sync"))]
129    fn compute_text_df_bytes(&self, _field: Field, _term: &[u8]) -> u64 {
130        0
131    }
132
133    /// Total documents across all segments
134    #[inline]
135    pub fn total_docs(&self) -> u64 {
136        self.total_docs
137    }
138
139    /// Get or compute IDF for a sparse vector dimension (lazy + cached)
140    ///
141    /// IDF = ln(N / df) where N = total docs, df = docs containing dimension
142    pub fn sparse_idf(&self, field: Field, dim_id: u32) -> f32 {
143        // Fast path: check cache
144        {
145            let cache = self.sparse_idf_cache.read();
146            if let Some(field_cache) = cache.get(&field.0)
147                && let Some(&idf) = field_cache.get(&dim_id)
148            {
149                return idf;
150            }
151        }
152
153        // Slow path: compute and cache
154        let df = self.compute_sparse_df(field, dim_id);
155        let n = self.cached_sparse_n(field);
156        let idf = if df > 0 && n > 0 {
157            (n as f32 / df as f32).ln().max(0.0)
158        } else {
159            0.0
160        };
161
162        // Cache the result
163        {
164            let mut cache = self.sparse_idf_cache.write();
165            cache.entry(field.0).or_default().insert(dim_id, idf);
166        }
167
168        idf
169    }
170
171    /// Compute IDF weights for multiple sparse dimensions (batch, uses cache)
172    ///
173    /// More efficient than calling `sparse_idf()` per dimension: resolves
174    /// total_vectors once and acquires write lock once for all cache misses.
175    pub fn sparse_idf_weights(&self, field: Field, dim_ids: &[u32]) -> Vec<f32> {
176        // Fast path: check how many are already cached
177        let mut result = vec![0.0f32; dim_ids.len()];
178        let mut misses: Vec<usize> = Vec::new();
179        {
180            let cache = self.sparse_idf_cache.read();
181            if let Some(field_cache) = cache.get(&field.0) {
182                for (i, &dim_id) in dim_ids.iter().enumerate() {
183                    if let Some(&idf) = field_cache.get(&dim_id) {
184                        result[i] = idf;
185                    } else {
186                        misses.push(i);
187                    }
188                }
189            } else {
190                misses.extend(0..dim_ids.len());
191            }
192        }
193
194        if misses.is_empty() {
195            return result;
196        }
197
198        // Compute N once for all misses (was previously per-dimension)
199        let n = self.cached_sparse_n(field);
200
201        // Compute missing IDF values
202        let mut new_entries: Vec<(u32, f32)> = Vec::with_capacity(misses.len());
203        for &i in &misses {
204            let dim_id = dim_ids[i];
205            let df = self.compute_sparse_df(field, dim_id);
206            let idf = if df > 0 && n > 0 {
207                (n as f32 / df as f32).ln().max(0.0)
208            } else {
209                0.0
210            };
211            result[i] = idf;
212            new_entries.push((dim_id, idf));
213        }
214
215        // Batch-insert into cache with single write lock
216        {
217            let mut cache = self.sparse_idf_cache.write();
218            let field_cache = cache.entry(field.0).or_default();
219            for (dim_id, idf) in new_entries {
220                field_cache.insert(dim_id, idf);
221            }
222        }
223
224        result
225    }
226
227    /// Get cached N = max(total_vectors, total_docs) for a sparse field.
228    /// Computed once per field and cached.
229    fn cached_sparse_n(&self, field: Field) -> u64 {
230        // Fast path
231        {
232            let cache = self.sparse_total_vectors_cache.read();
233            if let Some(&tv) = cache.get(&field.0) {
234                return tv.max(self.total_docs);
235            }
236        }
237        // Slow path: compute and cache
238        let tv = self.compute_sparse_total_vectors(field);
239        self.sparse_total_vectors_cache.write().insert(field.0, tv);
240        tv.max(self.total_docs)
241    }
242
243    /// Get or compute IDF for a full-text term (lazy + cached)
244    ///
245    /// IDF = ln((N - df + 0.5) / (df + 0.5) + 1) (BM25 variant)
246    pub fn text_idf(&self, field: Field, term: &str) -> f32 {
247        // Fast path: check cache
248        {
249            let cache = self.text_idf_cache.read();
250            if let Some(field_cache) = cache.get(&field.0)
251                && let Some(&idf) = field_cache.get(term)
252            {
253                return idf;
254            }
255        }
256
257        // Slow path: compute and cache. The corpus is the field's scoring
258        // units (chunks for a chunked field), matching the local formula.
259        let df = self.compute_text_df(field, term);
260        let n = self.text_corpus_size(field) as f32;
261        let df_f = df as f32;
262        let idf = if df > 0 {
263            ((n - df_f + 0.5) / (df_f + 0.5) + 1.0).ln()
264        } else {
265            0.0
266        };
267
268        // Cache the result
269        {
270            let mut cache = self.text_idf_cache.write();
271            cache
272                .entry(field.0)
273                .or_default()
274                .insert(term.to_string(), idf);
275        }
276
277        idf
278    }
279
280    /// Get or compute average field length for BM25 (lazy + cached)
281    pub fn avg_field_len(&self, field: Field) -> f32 {
282        // Fast path: check cache
283        {
284            let cache = self.avg_field_len_cache.read();
285            if let Some(&avg) = cache.get(&field.0) {
286                return avg;
287            }
288        }
289
290        // Slow path: compute weighted average across segments
291        let mut weighted_sum = 0.0f64;
292        let mut total_weight = 0u64;
293
294        for segment in &self.segments {
295            let avg_len = segment.avg_field_len(field);
296            // Chunked fields average over chunks, not documents.
297            let doc_count = segment.text_corpus_size(field) as u64;
298            if avg_len > 0.0 && doc_count > 0 {
299                weighted_sum += avg_len as f64 * doc_count as f64;
300                total_weight += doc_count;
301            }
302        }
303
304        let avg = if total_weight > 0 {
305            (weighted_sum / total_weight as f64) as f32
306        } else {
307            1.0
308        };
309
310        // Cache the result
311        {
312            let mut cache = self.avg_field_len_cache.write();
313            cache.insert(field.0, avg);
314        }
315
316        avg
317    }
318
319    /// Compute document frequency for a sparse dimension (not cached - internal)
320    /// Uses skip list metadata - no I/O needed
321    fn compute_sparse_df(&self, field: Field, dim_id: u32) -> u64 {
322        let mut df = 0u64;
323        for segment in &self.segments {
324            if let Some(sparse_index) = segment.sparse_indexes().get(&field.0) {
325                df += sparse_index.doc_count(dim_id) as u64;
326            }
327        }
328        df
329    }
330
331    /// Compute total sparse vectors for a field across all segments
332    /// For multi-valued fields, this may exceed total_docs
333    fn compute_sparse_total_vectors(&self, field: Field) -> u64 {
334        let mut total = 0u64;
335        for segment in &self.segments {
336            if let Some(sparse_index) = segment.sparse_indexes().get(&field.0) {
337                total += sparse_index.total_vectors as u64;
338            }
339        }
340        total
341    }
342
343    /// Document frequency of a text term over the segments (not cached).
344    fn compute_text_df(&self, field: Field, term: &str) -> u64 {
345        self.text_df(field, term.as_bytes())
346    }
347
348    /// Number of segments
349    pub fn num_segments(&self) -> usize {
350        self.segments.len()
351    }
352}
353
354impl std::fmt::Debug for LazyGlobalStats {
355    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
356        f.debug_struct("LazyGlobalStats")
357            .field("total_docs", &self.total_docs)
358            .field("num_segments", &self.segments.len())
359            .field("sparse_cache_fields", &self.sparse_idf_cache.read().len())
360            .field("text_cache_fields", &self.text_idf_cache.read().len())
361            .finish()
362    }
363}
364
365// Keep old types for backwards compatibility during transition
366
367/// Global statistics aggregated across all segments (legacy)
368#[derive(Debug)]
369pub struct GlobalStats {
370    /// Total documents across all segments
371    total_docs: u64,
372    /// Sparse vector statistics per field: field_id -> dimension stats
373    sparse_stats: FxHashMap<u32, SparseFieldStats>,
374    /// Full-text statistics per field: field_id -> term stats
375    text_stats: FxHashMap<u32, TextFieldStats>,
376    /// Generation counter for cache invalidation
377    generation: u64,
378}
379
380/// Statistics for a sparse vector field
381#[derive(Debug, Default)]
382pub struct SparseFieldStats {
383    /// Document frequency per dimension: dim_id -> doc_count
384    pub doc_freqs: FxHashMap<u32, u64>,
385}
386
387/// Statistics for a full-text field
388#[derive(Debug, Default)]
389pub struct TextFieldStats {
390    /// Document frequency per term: term -> doc_count
391    pub doc_freqs: FxHashMap<String, u64>,
392    /// Average field length (for BM25)
393    pub avg_field_len: f32,
394    /// BM25 corpus size of the field (documents, or chunks for a chunked
395    /// field); 0 = use the index-wide document total.
396    pub corpus_size: u64,
397}
398
399impl GlobalStats {
400    /// Create empty stats
401    pub fn new() -> Self {
402        Self {
403            total_docs: 0,
404            sparse_stats: FxHashMap::default(),
405            text_stats: FxHashMap::default(),
406            generation: 0,
407        }
408    }
409
410    /// Total documents in the index
411    #[inline]
412    pub fn total_docs(&self) -> u64 {
413        self.total_docs
414    }
415
416    /// Compute IDF for a sparse vector dimension
417    #[inline]
418    pub fn sparse_idf(&self, field: Field, dim_id: u32) -> f32 {
419        if let Some(stats) = self.sparse_stats.get(&field.0)
420            && let Some(&df) = stats.doc_freqs.get(&dim_id)
421            && df > 0
422        {
423            return (self.total_docs as f32 / df as f32).ln();
424        }
425        0.0
426    }
427
428    /// Compute IDF weights for multiple sparse dimensions
429    pub fn sparse_idf_weights(&self, field: Field, dim_ids: &[u32]) -> Vec<f32> {
430        dim_ids.iter().map(|&d| self.sparse_idf(field, d)).collect()
431    }
432
433    /// Compute IDF for a full-text term
434    #[inline]
435    pub fn text_idf(&self, field: Field, term: &str) -> f32 {
436        if let Some(stats) = self.text_stats.get(&field.0)
437            && let Some(&df) = stats.doc_freqs.get(term)
438        {
439            let n = if stats.corpus_size > 0 {
440                stats.corpus_size as f32
441            } else {
442                self.total_docs as f32
443            };
444            let df = df as f32;
445            return ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
446        }
447        0.0
448    }
449
450    /// Document frequency recorded for a text term, if any.
451    pub fn text_df(&self, field: Field, term: &str) -> Option<u64> {
452        self.text_stats
453            .get(&field.0)
454            .and_then(|stats| stats.doc_freqs.get(term).copied())
455    }
456
457    /// BM25 corpus size recorded for a text field (0 when unknown).
458    pub fn text_corpus_size(&self, field: Field) -> u64 {
459        self.text_stats
460            .get(&field.0)
461            .map_or(0, |stats| stats.corpus_size)
462    }
463
464    /// Text fields with recorded statistics.
465    pub fn text_fields(&self) -> impl Iterator<Item = (Field, &TextFieldStats)> {
466        self.text_stats
467            .iter()
468            .map(|(id, stats)| (Field(*id), stats))
469    }
470
471    /// Get average field length for BM25
472    #[inline]
473    pub fn avg_field_len(&self, field: Field) -> f32 {
474        self.text_stats
475            .get(&field.0)
476            .map(|s| s.avg_field_len)
477            .unwrap_or(1.0)
478    }
479
480    /// Current generation
481    #[inline]
482    pub fn generation(&self) -> u64 {
483        self.generation
484    }
485}
486
487impl Default for GlobalStats {
488    fn default() -> Self {
489        Self::new()
490    }
491}
492
493/// Builder for aggregating statistics from multiple segments
494pub struct GlobalStatsBuilder {
495    /// Total documents across all segments
496    pub total_docs: u64,
497    sparse_stats: FxHashMap<u32, SparseFieldStats>,
498    text_stats: FxHashMap<u32, TextFieldStats>,
499}
500
501impl GlobalStatsBuilder {
502    /// Create a new builder
503    pub fn new() -> Self {
504        Self {
505            total_docs: 0,
506            sparse_stats: FxHashMap::default(),
507            text_stats: FxHashMap::default(),
508        }
509    }
510
511    /// Add statistics from a segment reader
512    pub fn add_segment(&mut self, reader: &SegmentReader) {
513        self.total_docs += reader.num_docs() as u64;
514
515        // Aggregate sparse vector statistics
516        // Note: This requires access to sparse_indexes which may need to be exposed
517    }
518
519    /// Add sparse dimension document frequency
520    pub fn add_sparse_df(&mut self, field: Field, dim_id: u32, doc_count: u64) {
521        let stats = self.sparse_stats.entry(field.0).or_default();
522        *stats.doc_freqs.entry(dim_id).or_insert(0) += doc_count;
523    }
524
525    /// Add text term document frequency
526    pub fn add_text_df(&mut self, field: Field, term: String, doc_count: u64) {
527        let stats = self.text_stats.entry(field.0).or_default();
528        *stats.doc_freqs.entry(term).or_insert(0) += doc_count;
529    }
530
531    /// Set average field length for a text field
532    pub fn set_avg_field_len(&mut self, field: Field, avg_len: f32) {
533        let stats = self.text_stats.entry(field.0).or_default();
534        stats.avg_field_len = avg_len;
535    }
536
537    /// Set the BM25 corpus size of a text field (documents or chunks).
538    pub fn set_text_corpus_size(&mut self, field: Field, corpus_size: u64) {
539        let stats = self.text_stats.entry(field.0).or_default();
540        stats.corpus_size = corpus_size;
541    }
542
543    /// Build the final GlobalStats
544    pub fn build(self, generation: u64) -> GlobalStats {
545        GlobalStats {
546            total_docs: self.total_docs,
547            sparse_stats: self.sparse_stats,
548            text_stats: self.text_stats,
549            generation,
550        }
551    }
552}
553
554impl Default for GlobalStatsBuilder {
555    fn default() -> Self {
556        Self::new()
557    }
558}
559
560/// Cached global statistics with automatic invalidation
561///
562/// This is the main entry point for getting global IDF values.
563/// It caches statistics and rebuilds them when the segment list changes.
564pub struct GlobalStatsCache {
565    /// Cached statistics
566    stats: RwLock<Option<Arc<GlobalStats>>>,
567    /// Current generation (incremented when segments change)
568    generation: RwLock<u64>,
569}
570
571impl GlobalStatsCache {
572    /// Create a new cache
573    pub fn new() -> Self {
574        Self {
575            stats: RwLock::new(None),
576            generation: RwLock::new(0),
577        }
578    }
579
580    /// Invalidate the cache (call when segments are added/removed/merged)
581    pub fn invalidate(&self) {
582        let mut current_gen = self.generation.write();
583        *current_gen += 1;
584        let mut stats = self.stats.write();
585        *stats = None;
586    }
587
588    /// Get current generation
589    pub fn generation(&self) -> u64 {
590        *self.generation.read()
591    }
592
593    /// Get cached stats if valid, or None if needs rebuild
594    pub fn get(&self) -> Option<Arc<GlobalStats>> {
595        self.stats.read().clone()
596    }
597
598    /// Update the cache with new stats
599    pub fn set(&self, stats: GlobalStats) {
600        let mut cached = self.stats.write();
601        *cached = Some(Arc::new(stats));
602    }
603
604    /// Get or compute stats using the provided builder function (sync version)
605    ///
606    /// For basic stats that don't require async iteration.
607    pub fn get_or_compute<F>(&self, compute: F) -> Arc<GlobalStats>
608    where
609        F: FnOnce(&mut GlobalStatsBuilder),
610    {
611        // Fast path: return cached if available
612        if let Some(stats) = self.get() {
613            return stats;
614        }
615
616        // Slow path: compute new stats
617        let current_gen = self.generation();
618        let mut builder = GlobalStatsBuilder::new();
619        compute(&mut builder);
620        let stats = Arc::new(builder.build(current_gen));
621
622        // Cache the result
623        let mut cached = self.stats.write();
624        *cached = Some(Arc::clone(&stats));
625
626        stats
627    }
628
629    /// Check if stats need to be rebuilt
630    pub fn needs_rebuild(&self) -> bool {
631        self.stats.read().is_none()
632    }
633
634    /// Set pre-built stats (for async computation)
635    pub fn set_stats(&self, stats: GlobalStats) {
636        let mut cached = self.stats.write();
637        *cached = Some(Arc::new(stats));
638    }
639}
640
641impl Default for GlobalStatsCache {
642    fn default() -> Self {
643        Self::new()
644    }
645}
646
647#[cfg(test)]
648mod tests {
649    use super::*;
650
651    #[test]
652    fn test_sparse_idf_computation() {
653        let mut builder = GlobalStatsBuilder::new();
654        builder.total_docs = 1000;
655        builder.add_sparse_df(Field(0), 42, 100); // dim 42 appears in 100 docs
656        builder.add_sparse_df(Field(0), 43, 10); // dim 43 appears in 10 docs
657
658        let stats = builder.build(1);
659
660        // IDF = ln(N/df)
661        let idf_42 = stats.sparse_idf(Field(0), 42);
662        let idf_43 = stats.sparse_idf(Field(0), 43);
663
664        // dim 43 should have higher IDF (rarer)
665        assert!(idf_43 > idf_42);
666        assert!((idf_42 - (1000.0_f32 / 100.0).ln()).abs() < 0.001);
667        assert!((idf_43 - (1000.0_f32 / 10.0).ln()).abs() < 0.001);
668    }
669
670    #[test]
671    fn test_text_idf_computation() {
672        let mut builder = GlobalStatsBuilder::new();
673        builder.total_docs = 10000;
674        builder.add_text_df(Field(0), "common".to_string(), 5000);
675        builder.add_text_df(Field(0), "rare".to_string(), 10);
676
677        let stats = builder.build(1);
678
679        let idf_common = stats.text_idf(Field(0), "common");
680        let idf_rare = stats.text_idf(Field(0), "rare");
681
682        // Rare term should have higher IDF
683        assert!(idf_rare > idf_common);
684    }
685
686    #[test]
687    fn test_cache_invalidation() {
688        let cache = GlobalStatsCache::new();
689
690        // Initially no stats
691        assert!(cache.get().is_none());
692
693        // Compute stats
694        let stats = cache.get_or_compute(|builder| {
695            builder.total_docs = 100;
696        });
697        assert_eq!(stats.total_docs(), 100);
698
699        // Should be cached now
700        assert!(cache.get().is_some());
701
702        // Invalidate
703        cache.invalidate();
704        assert!(cache.get().is_none());
705    }
706}