hermes-core 1.8.50

Core async search engine library with WASM support
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
//! Hybrid score fusion: combine ranked lists from independent queries.
//!
//! Unlike the L2 reranker (which re-scores the *first-stage candidates*),
//! fusion takes the *union* of several result lists — a document only found
//! by the dense query can still surface in the fused top-k even if the
//! sparse query missed it entirely, and vice versa.
//!
//! Typical use: run a sparse (BM25/SPLADE) query and a dense vector query,
//! then fuse with Reciprocal Rank Fusion:
//!
//! ```ignore
//! let results = searcher
//!     .search_fused(
//!         &[(&sparse_query, 1.0), (&dense_query, 1.0)],
//!         10,
//!         FusionMethod::default(),
//!     )
//!     .await?;
//! ```

use rustc_hash::FxHashMap;

use super::vector::MultiValueCombiner;
use super::{ScoredPosition, SearchResult};

/// Default RRF rank constant (from Cormack et al., the standard choice).
pub const DEFAULT_RRF_K: f32 = 60.0;

/// Method for fusing multiple ranked result lists.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum FusionMethod {
    /// Reciprocal Rank Fusion: `score(d) = Σ_i w_i / (k + rank_i(d))`.
    ///
    /// Rank-based, so it is insensitive to incompatible score scales
    /// (BM25 vs cosine similarity). `k` dampens the impact of top ranks;
    /// 60 is the standard value.
    Rrf { k: f32 },
    /// Weighted sum of min-max normalized scores:
    /// `score(d) = Σ_i w_i * (s_i(d) - min_i) / (max_i - min_i)`.
    ///
    /// Score-based, preserves score gaps within each list. Sensitive to
    /// outliers; prefer RRF unless the score distributions are known.
    ///
    /// Degenerate lists where every score is identical (including
    /// single-result lists) have no min-max range; every document in such a
    /// list contributes the full `weight`, as if tied at the top. Avoid
    /// feeding filter-like subqueries (many docs, constant score) through
    /// this method — use `Rrf`, which only depends on ranks.
    NormalizedWeightedSum,
}

impl Default for FusionMethod {
    fn default() -> Self {
        FusionMethod::Rrf { k: DEFAULT_RRF_K }
    }
}

/// Reciprocal Rank Fusion contribution of a single 1-based rank.
/// Shared by list fusion here and the L1/L2 reranker fusion.
#[inline]
pub(crate) fn rrf_contribution(k: f32, rank: usize) -> f32 {
    1.0 / (k + rank as f32)
}

/// Fuse multiple ranked result lists into a single top-`limit` list.
///
/// Each input list must be sorted by descending score (the order produced
/// by `Searcher::search`). `weight` scales that list's contribution.
/// Documents are keyed by `(segment_id, doc_id)`; a document absent from a
/// list contributes nothing for that list. Positions from the first list
/// containing the document are preserved.
pub fn fuse_ranked_lists(
    lists: Vec<(Vec<SearchResult>, f32)>,
    method: FusionMethod,
    limit: usize,
) -> Vec<SearchResult> {
    let capacity = lists.iter().map(|(l, _)| l.len()).sum();
    let mut fused: FxHashMap<(u128, u32), SearchResult> =
        FxHashMap::with_capacity_and_hasher(capacity, Default::default());

    for (list, weight) in lists {
        // Precompute min-max normalization bounds for score-based fusion
        let (min_score, inv_range) = match method {
            FusionMethod::NormalizedWeightedSum if !list.is_empty() => {
                let mut min = f32::INFINITY;
                let mut max = f32::NEG_INFINITY;
                for r in &list {
                    min = min.min(r.score);
                    max = max.max(r.score);
                }
                let range = max - min;
                (min, if range > 0.0 { 1.0 / range } else { 0.0 })
            }
            _ => (0.0, 0.0),
        };

        for (idx, result) in list.into_iter().enumerate() {
            let contribution = match method {
                FusionMethod::Rrf { k } => weight * rrf_contribution(k, idx + 1),
                FusionMethod::NormalizedWeightedSum => {
                    // Single-result lists normalize to 1.0 (inv_range == 0)
                    if inv_range > 0.0 {
                        weight * (result.score - min_score) * inv_range
                    } else {
                        weight
                    }
                }
            };
            fused
                .entry((result.segment_id, result.doc_id))
                .and_modify(|r| r.score += contribution)
                .or_insert_with(|| SearchResult {
                    score: contribution,
                    ..result
                });
        }
    }

    let mut results: Vec<SearchResult> = fused.into_values().collect();
    if results.len() > limit {
        results.select_nth_unstable_by(limit, |a, b| b.score.total_cmp(&a.score));
        results.truncate(limit);
    }
    results.sort_unstable_by(|a, b| {
        b.score
            .total_cmp(&a.score)
            .then_with(|| a.doc_id.cmp(&b.doc_id))
    });
    results
}

/// Fuse multiple ranked result lists at **chunk granularity**.
///
/// Sub-query results are exploded into per-chunk entries keyed by
/// `(segment_id, doc_id, ordinal)` — for multi-vector fields the ordinal is
/// the chunk index, and results without per-ordinal scores (e.g. text
/// queries) contribute a single pseudo-chunk with ordinal 0. Chunks are
/// ranked *within each list by chunk score*, fused with `method` per chunk
/// key, then combined into a document score with `combiner`.
///
/// Compared to doc-level [`fuse_ranked_lists`]:
/// - Cross-vertical corroboration on the **same chunk** compounds (both
///   contributions land on one key), while scattered hits on different
///   chunks do not inflate the doc under a `Max`-style combiner — an
///   unreliable vertical's noise cannot outvote a strong single-vertical hit.
/// - Fused results carry per-chunk `positions`, so `ordinal_scores` survive
///   fusion (chunk attribution for snippets / chunk selection).
///
/// `MultiValueCombiner::Max` is the recommended combiner: RRF contributions
/// are small in magnitude, which makes `LogSumExp` degenerate (temperature
/// far exceeds the score scale).
pub fn fuse_ranked_lists_chunked(
    lists: Vec<(Vec<SearchResult>, f32)>,
    method: FusionMethod,
    combiner: MultiValueCombiner,
    limit: usize,
) -> Vec<SearchResult> {
    type ChunkKey = (u128, u32, u32); // (segment, doc, ordinal)

    let mut fused: FxHashMap<ChunkKey, f32> = FxHashMap::default();
    // Reused scratch: this list's chunks as (key, chunk_score)
    let mut chunks: Vec<(ChunkKey, f32)> = Vec::new();

    for (list, weight) in lists {
        chunks.clear();
        for result in &list {
            let mut had_positions = false;
            for (_field_id, scored_positions) in &result.positions {
                for sp in scored_positions {
                    had_positions = true;
                    chunks.push(((result.segment_id, result.doc_id, sp.position), sp.score));
                }
            }
            if !had_positions {
                // No per-chunk detail (text query / positions not collected):
                // the whole doc is one pseudo-chunk at ordinal 0.
                chunks.push(((result.segment_id, result.doc_id, 0), result.score));
            }
        }
        if chunks.is_empty() {
            continue;
        }

        // Rank chunks within this list by chunk score (desc); deterministic
        // tiebreak on the key.
        chunks.sort_unstable_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(&b.0)));

        // Min-max bounds for score-based fusion
        let (min_score, inv_range) = match method {
            FusionMethod::NormalizedWeightedSum => {
                let max = chunks.first().map(|c| c.1).unwrap_or(0.0);
                let min = chunks.last().map(|c| c.1).unwrap_or(0.0);
                let range = max - min;
                (min, if range > 0.0 { 1.0 / range } else { 0.0 })
            }
            _ => (0.0, 0.0),
        };

        for (rank, &(key, score)) in chunks.iter().enumerate() {
            let contribution = match method {
                FusionMethod::Rrf { k } => weight * rrf_contribution(k, rank + 1),
                FusionMethod::NormalizedWeightedSum => {
                    if inv_range > 0.0 {
                        weight * (score - min_score) * inv_range
                    } else {
                        weight
                    }
                }
            };
            *fused.entry(key).or_insert(0.0) += contribution;
        }
    }

    // Group fused chunks by document and combine into doc scores
    let mut docs: FxHashMap<(u128, u32), Vec<(u32, f32)>> = FxHashMap::default();
    for ((segment_id, doc_id, ordinal), score) in fused {
        docs.entry((segment_id, doc_id))
            .or_default()
            .push((ordinal, score));
    }

    let mut results: Vec<SearchResult> = docs
        .into_iter()
        .map(|((segment_id, doc_id), mut ordinals)| {
            ordinals.sort_unstable_by_key(|&(ord, _)| ord);
            let score = combiner.combine(&ordinals);
            let scored_positions: Vec<ScoredPosition> = ordinals
                .into_iter()
                .map(|(ord, s)| ScoredPosition::new(ord, s))
                .collect();
            SearchResult {
                doc_id,
                score,
                segment_id,
                positions: vec![(0, scored_positions)],
            }
        })
        .collect();

    if results.len() > limit {
        results.select_nth_unstable_by(limit, |a, b| b.score.total_cmp(&a.score));
        results.truncate(limit);
    }
    results.sort_unstable_by(|a, b| {
        b.score
            .total_cmp(&a.score)
            .then_with(|| a.doc_id.cmp(&b.doc_id))
    });
    results
}

#[cfg(test)]
mod tests {
    use super::*;

    fn result(doc_id: u32, score: f32) -> SearchResult {
        SearchResult {
            doc_id,
            score,
            segment_id: 1,
            positions: Vec::new(),
        }
    }

    #[test]
    fn test_rrf_union_includes_single_list_docs() {
        // doc 3 only appears in the dense list — union fusion must keep it
        let sparse = vec![result(1, 10.0), result(2, 5.0)];
        let dense = vec![result(3, 0.9), result(1, 0.8)];

        let fused = fuse_ranked_lists(
            vec![(sparse, 1.0), (dense, 1.0)],
            FusionMethod::Rrf { k: 60.0 },
            10,
        );

        assert_eq!(fused.len(), 3);
        // doc 1 is rank 1 + rank 2 → highest fused score
        assert_eq!(fused[0].doc_id, 1);
        let expected = 1.0 / 61.0 + 1.0 / 62.0;
        assert!((fused[0].score - expected).abs() < 1e-6);
        // docs 2 and 3 both have a single rank contribution
        let ids: Vec<u32> = fused.iter().map(|r| r.doc_id).collect();
        assert!(ids.contains(&2) && ids.contains(&3));
    }

    #[test]
    fn test_rrf_weights_scale_contribution() {
        let a = vec![result(1, 1.0)];
        let b = vec![result(2, 1.0)];

        // Same ranks, but list b weighted 2x → doc 2 wins
        let fused = fuse_ranked_lists(vec![(a, 1.0), (b, 2.0)], FusionMethod::Rrf { k: 60.0 }, 10);
        assert_eq!(fused[0].doc_id, 2);
        assert!((fused[0].score - 2.0 / 61.0).abs() < 1e-6);
    }

    #[test]
    fn test_normalized_weighted_sum() {
        // Incompatible scales: BM25-ish vs cosine-ish
        let sparse = vec![result(1, 20.0), result(2, 10.0), result(3, 0.0)];
        let dense = vec![result(2, 0.99), result(1, 0.55), result(3, 0.11)];

        let fused = fuse_ranked_lists(
            vec![(sparse, 0.5), (dense, 0.5)],
            FusionMethod::NormalizedWeightedSum,
            10,
        );

        assert_eq!(fused.len(), 3);
        // doc 1: 0.5*1.0 + 0.5*0.5 = 0.75; doc 2: 0.5*0.5 + 0.5*1.0 = 0.75;
        // doc 3: 0. Ties broken by doc_id.
        assert_eq!(fused[0].doc_id, 1);
        assert!((fused[0].score - 0.75).abs() < 1e-6);
        assert!((fused[1].score - 0.75).abs() < 1e-6);
        assert_eq!(fused[2].doc_id, 3);
        assert!(fused[2].score.abs() < 1e-6);
    }

    #[test]
    fn test_limit_truncation() {
        let list: Vec<SearchResult> = (0..100).map(|i| result(i, 100.0 - i as f32)).collect();
        let fused = fuse_ranked_lists(vec![(list, 1.0)], FusionMethod::default(), 5);
        assert_eq!(fused.len(), 5);
        assert_eq!(fused[0].doc_id, 0);
    }

    fn chunked(doc_id: u32, chunks: &[(u32, f32)]) -> SearchResult {
        let positions = vec![(
            0u32,
            chunks
                .iter()
                .map(|&(ord, s)| ScoredPosition::new(ord, s))
                .collect(),
        )];
        SearchResult {
            doc_id,
            // Doc score = max chunk (mirrors a Max combiner upstream)
            score: chunks.iter().map(|&(_, s)| s).fold(0.0, f32::max),
            segment_id: 1,
            positions,
        }
    }

    /// The multilingual/short-query regression: a doc that is rank 1 in the
    /// reliable vertical must not be outvoted by a mediocre doc present in
    /// both lists on DIFFERENT chunks. Under doc-level RRF it was
    /// (2/(60+5) > 1/(60+1)); chunk-level fusion with Max fixes it.
    #[test]
    fn test_chunked_fusion_junk_vertical_does_not_outvote() {
        // Sparse (reliable): doc 1 is the clear best; doc 9 is mediocre.
        let sparse = vec![
            chunked(1, &[(0, 10.0)]),
            chunked(2, &[(0, 5.0)]),
            chunked(3, &[(0, 4.0)]),
            chunked(4, &[(0, 3.0)]),
            chunked(9, &[(2, 2.0)]),
        ];
        // Dense (junk for this query): confident ranks over noise; doc 9
        // appears again but on a DIFFERENT chunk.
        let dense = vec![
            chunked(7, &[(0, 0.31)]),
            chunked(8, &[(1, 0.30)]),
            chunked(6, &[(0, 0.29)]),
            chunked(5, &[(3, 0.28)]),
            chunked(9, &[(5, 0.27)]),
        ];

        let fused = fuse_ranked_lists_chunked(
            vec![(sparse, 1.0), (dense, 1.0)],
            FusionMethod::Rrf { k: 60.0 },
            MultiValueCombiner::Max,
            10,
        );

        assert_eq!(
            fused[0].doc_id, 1,
            "sparse rank-1 doc must win over doc 9 (present in both lists on different chunks)"
        );
    }

    /// Same-chunk corroboration across verticals compounds; different-chunk
    /// hits do not (under Max).
    #[test]
    fn test_chunked_fusion_same_chunk_corroboration_wins() {
        // Doc 1: sparse chunk 3 rank 1 + dense chunk 3 rank 1 (same chunk)
        // Doc 2: sparse chunk 0 rank 2 + dense chunk 7 rank 2 (different chunks)
        let sparse = vec![chunked(1, &[(3, 9.0)]), chunked(2, &[(0, 8.0)])];
        let dense = vec![chunked(1, &[(3, 0.9)]), chunked(2, &[(7, 0.8)])];

        let fused = fuse_ranked_lists_chunked(
            vec![(sparse, 1.0), (dense, 1.0)],
            FusionMethod::Rrf { k: 60.0 },
            MultiValueCombiner::Max,
            10,
        );

        assert_eq!(fused[0].doc_id, 1);
        // Doc 1's fused chunk 3 = 1/61 + 1/61; doc 2's best chunk = 1/62
        let expected_doc1 = 2.0 / 61.0;
        assert!((fused[0].score - expected_doc1).abs() < 1e-6);
        assert!(fused[1].score < expected_doc1 / 1.9);

        // Per-chunk attribution survives fusion
        let (_, positions) = &fused[0].positions[0..1][0];
        assert_eq!(positions.len(), 1);
        assert_eq!(positions[0].position, 3, "fused chunk ordinal preserved");
    }

    /// Results without per-chunk detail (e.g. text queries) fuse as a single
    /// pseudo-chunk at ordinal 0 and can corroborate vector chunk 0.
    #[test]
    fn test_chunked_fusion_pseudo_chunk_for_docs_without_positions() {
        let text = vec![result(1, 3.0), result(2, 2.0)]; // no positions
        let dense = vec![chunked(1, &[(0, 0.9)])];

        let fused = fuse_ranked_lists_chunked(
            vec![(text, 1.0), (dense, 1.0)],
            FusionMethod::Rrf { k: 60.0 },
            MultiValueCombiner::Max,
            10,
        );

        assert_eq!(fused[0].doc_id, 1);
        assert!((fused[0].score - 2.0 / 61.0).abs() < 1e-6);
        assert_eq!(fused.len(), 2);
    }

    #[test]
    fn test_duplicate_across_segments_not_merged() {
        // Same doc_id in different segments = different documents
        let mut a = result(1, 1.0);
        a.segment_id = 1;
        let mut b = result(1, 1.0);
        b.segment_id = 2;

        let fused = fuse_ranked_lists(
            vec![(vec![a], 1.0), (vec![b], 1.0)],
            FusionMethod::default(),
            10,
        );
        assert_eq!(fused.len(), 2);
    }
}