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}
39
40impl LazyGlobalStats {
41    /// Create new lazy stats bound to a set of segments
42    pub fn new(segments: Vec<Arc<SegmentReader>>) -> Self {
43        let total_docs: u64 = segments.iter().map(|s| s.num_docs() as u64).sum();
44        Self {
45            segments,
46            total_docs,
47            sparse_idf_cache: RwLock::new(FxHashMap::default()),
48            sparse_total_vectors_cache: RwLock::new(FxHashMap::default()),
49            text_idf_cache: RwLock::new(FxHashMap::default()),
50            avg_field_len_cache: RwLock::new(FxHashMap::default()),
51        }
52    }
53
54    /// Total documents across all segments
55    #[inline]
56    pub fn total_docs(&self) -> u64 {
57        self.total_docs
58    }
59
60    /// Get or compute IDF for a sparse vector dimension (lazy + cached)
61    ///
62    /// IDF = ln(N / df) where N = total docs, df = docs containing dimension
63    pub fn sparse_idf(&self, field: Field, dim_id: u32) -> f32 {
64        // Fast path: check cache
65        {
66            let cache = self.sparse_idf_cache.read();
67            if let Some(field_cache) = cache.get(&field.0)
68                && let Some(&idf) = field_cache.get(&dim_id)
69            {
70                return idf;
71            }
72        }
73
74        // Slow path: compute and cache
75        let df = self.compute_sparse_df(field, dim_id);
76        let n = self.cached_sparse_n(field);
77        let idf = if df > 0 && n > 0 {
78            (n as f32 / df as f32).ln().max(0.0)
79        } else {
80            0.0
81        };
82
83        // Cache the result
84        {
85            let mut cache = self.sparse_idf_cache.write();
86            cache.entry(field.0).or_default().insert(dim_id, idf);
87        }
88
89        idf
90    }
91
92    /// Compute IDF weights for multiple sparse dimensions (batch, uses cache)
93    ///
94    /// More efficient than calling `sparse_idf()` per dimension: resolves
95    /// total_vectors once and acquires write lock once for all cache misses.
96    pub fn sparse_idf_weights(&self, field: Field, dim_ids: &[u32]) -> Vec<f32> {
97        // Fast path: check how many are already cached
98        let mut result = vec![0.0f32; dim_ids.len()];
99        let mut misses: Vec<usize> = Vec::new();
100        {
101            let cache = self.sparse_idf_cache.read();
102            if let Some(field_cache) = cache.get(&field.0) {
103                for (i, &dim_id) in dim_ids.iter().enumerate() {
104                    if let Some(&idf) = field_cache.get(&dim_id) {
105                        result[i] = idf;
106                    } else {
107                        misses.push(i);
108                    }
109                }
110            } else {
111                misses.extend(0..dim_ids.len());
112            }
113        }
114
115        if misses.is_empty() {
116            return result;
117        }
118
119        // Compute N once for all misses (was previously per-dimension)
120        let n = self.cached_sparse_n(field);
121
122        // Compute missing IDF values
123        let mut new_entries: Vec<(u32, f32)> = Vec::with_capacity(misses.len());
124        for &i in &misses {
125            let dim_id = dim_ids[i];
126            let df = self.compute_sparse_df(field, dim_id);
127            let idf = if df > 0 && n > 0 {
128                (n as f32 / df as f32).ln().max(0.0)
129            } else {
130                0.0
131            };
132            result[i] = idf;
133            new_entries.push((dim_id, idf));
134        }
135
136        // Batch-insert into cache with single write lock
137        {
138            let mut cache = self.sparse_idf_cache.write();
139            let field_cache = cache.entry(field.0).or_default();
140            for (dim_id, idf) in new_entries {
141                field_cache.insert(dim_id, idf);
142            }
143        }
144
145        result
146    }
147
148    /// Get cached N = max(total_vectors, total_docs) for a sparse field.
149    /// Computed once per field and cached.
150    fn cached_sparse_n(&self, field: Field) -> u64 {
151        // Fast path
152        {
153            let cache = self.sparse_total_vectors_cache.read();
154            if let Some(&tv) = cache.get(&field.0) {
155                return tv.max(self.total_docs);
156            }
157        }
158        // Slow path: compute and cache
159        let tv = self.compute_sparse_total_vectors(field);
160        self.sparse_total_vectors_cache.write().insert(field.0, tv);
161        tv.max(self.total_docs)
162    }
163
164    /// Get or compute IDF for a full-text term (lazy + cached)
165    ///
166    /// IDF = ln((N - df + 0.5) / (df + 0.5) + 1) (BM25 variant)
167    pub fn text_idf(&self, field: Field, term: &str) -> f32 {
168        // Fast path: check cache
169        {
170            let cache = self.text_idf_cache.read();
171            if let Some(field_cache) = cache.get(&field.0)
172                && let Some(&idf) = field_cache.get(term)
173            {
174                return idf;
175            }
176        }
177
178        // Slow path: compute and cache
179        let df = self.compute_text_df(field, term);
180        let n = self.total_docs as f32;
181        let df_f = df as f32;
182        let idf = if df > 0 {
183            ((n - df_f + 0.5) / (df_f + 0.5) + 1.0).ln()
184        } else {
185            0.0
186        };
187
188        // Cache the result
189        {
190            let mut cache = self.text_idf_cache.write();
191            cache
192                .entry(field.0)
193                .or_default()
194                .insert(term.to_string(), idf);
195        }
196
197        idf
198    }
199
200    /// Get or compute average field length for BM25 (lazy + cached)
201    pub fn avg_field_len(&self, field: Field) -> f32 {
202        // Fast path: check cache
203        {
204            let cache = self.avg_field_len_cache.read();
205            if let Some(&avg) = cache.get(&field.0) {
206                return avg;
207            }
208        }
209
210        // Slow path: compute weighted average across segments
211        let mut weighted_sum = 0.0f64;
212        let mut total_weight = 0u64;
213
214        for segment in &self.segments {
215            let avg_len = segment.avg_field_len(field);
216            // Chunked fields average over chunks, not documents.
217            let doc_count = segment.text_corpus_size(field) as u64;
218            if avg_len > 0.0 && doc_count > 0 {
219                weighted_sum += avg_len as f64 * doc_count as f64;
220                total_weight += doc_count;
221            }
222        }
223
224        let avg = if total_weight > 0 {
225            (weighted_sum / total_weight as f64) as f32
226        } else {
227            1.0
228        };
229
230        // Cache the result
231        {
232            let mut cache = self.avg_field_len_cache.write();
233            cache.insert(field.0, avg);
234        }
235
236        avg
237    }
238
239    /// Compute document frequency for a sparse dimension (not cached - internal)
240    /// Uses skip list metadata - no I/O needed
241    fn compute_sparse_df(&self, field: Field, dim_id: u32) -> u64 {
242        let mut df = 0u64;
243        for segment in &self.segments {
244            if let Some(sparse_index) = segment.sparse_indexes().get(&field.0) {
245                df += sparse_index.doc_count(dim_id) as u64;
246            }
247        }
248        df
249    }
250
251    /// Compute total sparse vectors for a field across all segments
252    /// For multi-valued fields, this may exceed total_docs
253    fn compute_sparse_total_vectors(&self, field: Field) -> u64 {
254        let mut total = 0u64;
255        for segment in &self.segments {
256            if let Some(sparse_index) = segment.sparse_indexes().get(&field.0) {
257                total += sparse_index.total_vectors as u64;
258            }
259        }
260        total
261    }
262
263    /// Compute document frequency for a text term (not cached - internal)
264    ///
265    /// Note: This is expensive as it requires async term lookup.
266    /// For now, returns 0 - text IDF should be computed via term dictionary.
267    fn compute_text_df(&self, _field: Field, _term: &str) -> u64 {
268        // Text term lookup requires async access to term dictionary
269        // For now, this is a placeholder - actual implementation would
270        // need to be async or use pre-computed stats
271        0
272    }
273
274    /// Number of segments
275    pub fn num_segments(&self) -> usize {
276        self.segments.len()
277    }
278}
279
280impl std::fmt::Debug for LazyGlobalStats {
281    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
282        f.debug_struct("LazyGlobalStats")
283            .field("total_docs", &self.total_docs)
284            .field("num_segments", &self.segments.len())
285            .field("sparse_cache_fields", &self.sparse_idf_cache.read().len())
286            .field("text_cache_fields", &self.text_idf_cache.read().len())
287            .finish()
288    }
289}
290
291// Keep old types for backwards compatibility during transition
292
293/// Global statistics aggregated across all segments (legacy)
294#[derive(Debug)]
295pub struct GlobalStats {
296    /// Total documents across all segments
297    total_docs: u64,
298    /// Sparse vector statistics per field: field_id -> dimension stats
299    sparse_stats: FxHashMap<u32, SparseFieldStats>,
300    /// Full-text statistics per field: field_id -> term stats
301    text_stats: FxHashMap<u32, TextFieldStats>,
302    /// Generation counter for cache invalidation
303    generation: u64,
304}
305
306/// Statistics for a sparse vector field
307#[derive(Debug, Default)]
308pub struct SparseFieldStats {
309    /// Document frequency per dimension: dim_id -> doc_count
310    pub doc_freqs: FxHashMap<u32, u64>,
311}
312
313/// Statistics for a full-text field
314#[derive(Debug, Default)]
315pub struct TextFieldStats {
316    /// Document frequency per term: term -> doc_count
317    pub doc_freqs: FxHashMap<String, u64>,
318    /// Average field length (for BM25)
319    pub avg_field_len: f32,
320}
321
322impl GlobalStats {
323    /// Create empty stats
324    pub fn new() -> Self {
325        Self {
326            total_docs: 0,
327            sparse_stats: FxHashMap::default(),
328            text_stats: FxHashMap::default(),
329            generation: 0,
330        }
331    }
332
333    /// Total documents in the index
334    #[inline]
335    pub fn total_docs(&self) -> u64 {
336        self.total_docs
337    }
338
339    /// Compute IDF for a sparse vector dimension
340    #[inline]
341    pub fn sparse_idf(&self, field: Field, dim_id: u32) -> f32 {
342        if let Some(stats) = self.sparse_stats.get(&field.0)
343            && let Some(&df) = stats.doc_freqs.get(&dim_id)
344            && df > 0
345        {
346            return (self.total_docs as f32 / df as f32).ln();
347        }
348        0.0
349    }
350
351    /// Compute IDF weights for multiple sparse dimensions
352    pub fn sparse_idf_weights(&self, field: Field, dim_ids: &[u32]) -> Vec<f32> {
353        dim_ids.iter().map(|&d| self.sparse_idf(field, d)).collect()
354    }
355
356    /// Compute IDF for a full-text term
357    #[inline]
358    pub fn text_idf(&self, field: Field, term: &str) -> f32 {
359        if let Some(stats) = self.text_stats.get(&field.0)
360            && let Some(&df) = stats.doc_freqs.get(term)
361        {
362            let n = self.total_docs as f32;
363            let df = df as f32;
364            return ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
365        }
366        0.0
367    }
368
369    /// Get average field length for BM25
370    #[inline]
371    pub fn avg_field_len(&self, field: Field) -> f32 {
372        self.text_stats
373            .get(&field.0)
374            .map(|s| s.avg_field_len)
375            .unwrap_or(1.0)
376    }
377
378    /// Current generation
379    #[inline]
380    pub fn generation(&self) -> u64 {
381        self.generation
382    }
383}
384
385impl Default for GlobalStats {
386    fn default() -> Self {
387        Self::new()
388    }
389}
390
391/// Builder for aggregating statistics from multiple segments
392pub struct GlobalStatsBuilder {
393    /// Total documents across all segments
394    pub total_docs: u64,
395    sparse_stats: FxHashMap<u32, SparseFieldStats>,
396    text_stats: FxHashMap<u32, TextFieldStats>,
397}
398
399impl GlobalStatsBuilder {
400    /// Create a new builder
401    pub fn new() -> Self {
402        Self {
403            total_docs: 0,
404            sparse_stats: FxHashMap::default(),
405            text_stats: FxHashMap::default(),
406        }
407    }
408
409    /// Add statistics from a segment reader
410    pub fn add_segment(&mut self, reader: &SegmentReader) {
411        self.total_docs += reader.num_docs() as u64;
412
413        // Aggregate sparse vector statistics
414        // Note: This requires access to sparse_indexes which may need to be exposed
415    }
416
417    /// Add sparse dimension document frequency
418    pub fn add_sparse_df(&mut self, field: Field, dim_id: u32, doc_count: u64) {
419        let stats = self.sparse_stats.entry(field.0).or_default();
420        *stats.doc_freqs.entry(dim_id).or_insert(0) += doc_count;
421    }
422
423    /// Add text term document frequency
424    pub fn add_text_df(&mut self, field: Field, term: String, doc_count: u64) {
425        let stats = self.text_stats.entry(field.0).or_default();
426        *stats.doc_freqs.entry(term).or_insert(0) += doc_count;
427    }
428
429    /// Set average field length for a text field
430    pub fn set_avg_field_len(&mut self, field: Field, avg_len: f32) {
431        let stats = self.text_stats.entry(field.0).or_default();
432        stats.avg_field_len = avg_len;
433    }
434
435    /// Build the final GlobalStats
436    pub fn build(self, generation: u64) -> GlobalStats {
437        GlobalStats {
438            total_docs: self.total_docs,
439            sparse_stats: self.sparse_stats,
440            text_stats: self.text_stats,
441            generation,
442        }
443    }
444}
445
446impl Default for GlobalStatsBuilder {
447    fn default() -> Self {
448        Self::new()
449    }
450}
451
452/// Cached global statistics with automatic invalidation
453///
454/// This is the main entry point for getting global IDF values.
455/// It caches statistics and rebuilds them when the segment list changes.
456pub struct GlobalStatsCache {
457    /// Cached statistics
458    stats: RwLock<Option<Arc<GlobalStats>>>,
459    /// Current generation (incremented when segments change)
460    generation: RwLock<u64>,
461}
462
463impl GlobalStatsCache {
464    /// Create a new cache
465    pub fn new() -> Self {
466        Self {
467            stats: RwLock::new(None),
468            generation: RwLock::new(0),
469        }
470    }
471
472    /// Invalidate the cache (call when segments are added/removed/merged)
473    pub fn invalidate(&self) {
474        let mut current_gen = self.generation.write();
475        *current_gen += 1;
476        let mut stats = self.stats.write();
477        *stats = None;
478    }
479
480    /// Get current generation
481    pub fn generation(&self) -> u64 {
482        *self.generation.read()
483    }
484
485    /// Get cached stats if valid, or None if needs rebuild
486    pub fn get(&self) -> Option<Arc<GlobalStats>> {
487        self.stats.read().clone()
488    }
489
490    /// Update the cache with new stats
491    pub fn set(&self, stats: GlobalStats) {
492        let mut cached = self.stats.write();
493        *cached = Some(Arc::new(stats));
494    }
495
496    /// Get or compute stats using the provided builder function (sync version)
497    ///
498    /// For basic stats that don't require async iteration.
499    pub fn get_or_compute<F>(&self, compute: F) -> Arc<GlobalStats>
500    where
501        F: FnOnce(&mut GlobalStatsBuilder),
502    {
503        // Fast path: return cached if available
504        if let Some(stats) = self.get() {
505            return stats;
506        }
507
508        // Slow path: compute new stats
509        let current_gen = self.generation();
510        let mut builder = GlobalStatsBuilder::new();
511        compute(&mut builder);
512        let stats = Arc::new(builder.build(current_gen));
513
514        // Cache the result
515        let mut cached = self.stats.write();
516        *cached = Some(Arc::clone(&stats));
517
518        stats
519    }
520
521    /// Check if stats need to be rebuilt
522    pub fn needs_rebuild(&self) -> bool {
523        self.stats.read().is_none()
524    }
525
526    /// Set pre-built stats (for async computation)
527    pub fn set_stats(&self, stats: GlobalStats) {
528        let mut cached = self.stats.write();
529        *cached = Some(Arc::new(stats));
530    }
531}
532
533impl Default for GlobalStatsCache {
534    fn default() -> Self {
535        Self::new()
536    }
537}
538
539#[cfg(test)]
540mod tests {
541    use super::*;
542
543    #[test]
544    fn test_sparse_idf_computation() {
545        let mut builder = GlobalStatsBuilder::new();
546        builder.total_docs = 1000;
547        builder.add_sparse_df(Field(0), 42, 100); // dim 42 appears in 100 docs
548        builder.add_sparse_df(Field(0), 43, 10); // dim 43 appears in 10 docs
549
550        let stats = builder.build(1);
551
552        // IDF = ln(N/df)
553        let idf_42 = stats.sparse_idf(Field(0), 42);
554        let idf_43 = stats.sparse_idf(Field(0), 43);
555
556        // dim 43 should have higher IDF (rarer)
557        assert!(idf_43 > idf_42);
558        assert!((idf_42 - (1000.0_f32 / 100.0).ln()).abs() < 0.001);
559        assert!((idf_43 - (1000.0_f32 / 10.0).ln()).abs() < 0.001);
560    }
561
562    #[test]
563    fn test_text_idf_computation() {
564        let mut builder = GlobalStatsBuilder::new();
565        builder.total_docs = 10000;
566        builder.add_text_df(Field(0), "common".to_string(), 5000);
567        builder.add_text_df(Field(0), "rare".to_string(), 10);
568
569        let stats = builder.build(1);
570
571        let idf_common = stats.text_idf(Field(0), "common");
572        let idf_rare = stats.text_idf(Field(0), "rare");
573
574        // Rare term should have higher IDF
575        assert!(idf_rare > idf_common);
576    }
577
578    #[test]
579    fn test_cache_invalidation() {
580        let cache = GlobalStatsCache::new();
581
582        // Initially no stats
583        assert!(cache.get().is_none());
584
585        // Compute stats
586        let stats = cache.get_or_compute(|builder| {
587            builder.total_docs = 100;
588        });
589        assert_eq!(stats.total_docs(), 100);
590
591        // Should be cached now
592        assert!(cache.get().is_some());
593
594        // Invalidate
595        cache.invalidate();
596        assert!(cache.get().is_none());
597    }
598}