Skip to main content

linkrs_core/
fusion.rs

1//! Application-layer fusion for fulltext and vector results.
2//!
3//! Both search paths emit `(document id, score)` rows independently. This
4//! module merges those rows without touching the existing planners or
5//! operators. Rank fusion needs no score calibration; weighted fusion
6//! normalizes each input list before combining.
7
8use std::collections::HashMap;
9
10/// One ranked hit from a single retrieval path.
11#[derive(Debug, Clone, PartialEq)]
12pub struct ScoredHit {
13    pub id: String,
14    pub score: f32,
15}
16
17impl ScoredHit {
18    pub fn new(id: impl Into<String>, score: f32) -> Self {
19        Self {
20            id: id.into(),
21            score,
22        }
23    }
24}
25
26/// Fused hit shared by both strategies.
27#[derive(Debug, Clone, PartialEq)]
28pub struct FusedHit {
29    pub id: String,
30    pub score: f32,
31}
32
33/// Fuse ranked lists with reciprocal rank fusion.
34///
35/// Each hit contributes `1 / (k + rank)` per list it appears in, with
36/// 1-based ranks. Output is sorted by fused score descending and truncated
37/// to `limit` entries.
38pub fn rrf_fuse(lists: &[Vec<ScoredHit>], k: u32, limit: usize) -> Vec<FusedHit> {
39    let shift = k as f32;
40    let mut totals: HashMap<&str, f32> = HashMap::new();
41    for list in lists {
42        for (position, hit) in list.iter().enumerate() {
43            let rank = position as f32 + 1.0;
44            let entry = totals.entry(hit.id.as_str()).or_insert(0.0);
45            *entry += 1.0 / (shift + rank);
46        }
47    }
48    let mut fused: Vec<FusedHit> = totals
49        .into_iter()
50        .map(|(id, score)| FusedHit {
51            id: id.to_string(),
52            score,
53        })
54        .collect();
55    fused.sort_by(|a, b| b.score.total_cmp(&a.score));
56    fused.truncate(limit);
57    fused
58}
59
60/// Fuse one vector list with one fulltext list by weighted sum.
61///
62/// Each list is min-max normalized into `0..=1` before combining as
63/// `alpha * vector + (1 - alpha) * fulltext`. Degenerate lists where all
64/// scores are equal normalize to `1.0` so presence still counts.
65pub fn weighted_fuse(
66    vector: &[ScoredHit],
67    fulltext: &[ScoredHit],
68    alpha: f32,
69    limit: usize,
70) -> Vec<FusedHit> {
71    let weight = alpha.clamp(0.0, 1.0);
72    let vector_norm = normalize(vector);
73    let fulltext_norm = normalize(fulltext);
74    let mut totals: HashMap<&str, f32> = HashMap::new();
75    for (id, score) in &vector_norm {
76        let entry = totals.entry(id.as_str()).or_insert(0.0);
77        *entry += weight * score;
78    }
79    for (id, score) in &fulltext_norm {
80        let entry = totals.entry(id.as_str()).or_insert(0.0);
81        *entry += (1.0 - weight) * score;
82    }
83    let mut fused: Vec<FusedHit> = totals
84        .into_iter()
85        .map(|(id, score)| FusedHit {
86            id: id.to_string(),
87            score,
88        })
89        .collect();
90    fused.sort_by(|a, b| b.score.total_cmp(&a.score));
91    fused.truncate(limit);
92    fused
93}
94
95fn normalize(hits: &[ScoredHit]) -> Vec<(String, f32)> {
96    if hits.is_empty() {
97        return Vec::new();
98    }
99    let mut min = f32::INFINITY;
100    let mut max = f32::NEG_INFINITY;
101    for hit in hits {
102        if hit.score < min {
103            min = hit.score;
104        }
105        if hit.score > max {
106            max = hit.score;
107        }
108    }
109    let span = max - min;
110    hits.iter()
111        .map(|hit| {
112            let normalized = if span <= f32::EPSILON {
113                1.0
114            } else {
115                (hit.score - min) / span
116            };
117            (hit.id.clone(), normalized)
118        })
119        .collect()
120}
121
122/// Normalize a vector point ID to its vertex document key for fusion.
123///
124/// Vector points use `{vertex}#tag#field` with `%` escaped as `%25` and `#`
125/// as `%23`. Fulltext vertex docs use the raw vertex display form, so callers
126/// must map both lists through this helper before `rrf_fuse` or
127/// `weighted_fuse`. Edge fulltext docs (`src->dst#ranking`) never match
128/// vector points; they pass through unchanged for caller-side filtering.
129pub fn normalize_vector_point_id(point_id: &str, tag: &str, field: &str) -> String {
130    let suffix = format!("#{}#{}", tag, field);
131    let encoded = point_id.strip_suffix(&suffix).unwrap_or(point_id);
132    encoded.replace("%23", "#").replace("%25", "%")
133}
134
135/// Map one vector hit list to fusion keys, dropping hits that do not belong
136/// to the requested index.
137pub fn normalize_vector_hits(hits: &[ScoredHit], tag: &str, field: &str) -> Vec<ScoredHit> {
138    let suffix = format!("#{}#{}", tag, field);
139    hits.iter()
140        .filter(|hit| hit.id.ends_with(&suffix))
141        .map(|hit| ScoredHit::new(normalize_vector_point_id(&hit.id, tag, field), hit.score))
142        .collect()
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn rrf_prefers_hits_present_in_both_lists() {
151        let vector = vec![ScoredHit::new("a", 0.9), ScoredHit::new("b", 0.5)];
152        let fulltext = vec![ScoredHit::new("b", 2.0), ScoredHit::new("c", 1.0)];
153        let fused = rrf_fuse(&[vector, fulltext], 60, 10);
154        assert_eq!(fused.len(), 3);
155        assert_eq!(fused[0].id, "b");
156    }
157
158    #[test]
159    fn rrf_respects_limit() {
160        let first = vec![ScoredHit::new("a", 1.0), ScoredHit::new("b", 0.5)];
161        let fused = rrf_fuse(&[first], 60, 1);
162        assert_eq!(fused.len(), 1);
163        assert_eq!(fused[0].id, "a");
164    }
165
166    #[test]
167    fn weighted_fuse_blends_both_sources() {
168        let vector = vec![ScoredHit::new("a", 1.0), ScoredHit::new("b", 0.0)];
169        let fulltext = vec![ScoredHit::new("b", 5.0), ScoredHit::new("c", 1.0)];
170        // Min-max normalization puts both a and b at 1.0 in their own list,
171        // so only a weight below the balance point lets the fulltext lead
172        // decide; an even split would tie and leave the order to hash order.
173        let fused = weighted_fuse(&vector, &fulltext, 0.3, 10);
174        assert_eq!(fused.len(), 3);
175        assert_eq!(fused[0].id, "b");
176    }
177
178    #[test]
179    fn weighted_fuse_handles_degenerate_lists() {
180        let vector = vec![ScoredHit::new("a", 2.0)];
181        let fulltext = vec![ScoredHit::new("a", 7.0)];
182        let fused = weighted_fuse(&vector, &fulltext, 0.5, 10);
183        assert_eq!(fused.len(), 1);
184        assert_eq!(fused[0].id, "a");
185        assert!((fused[0].score - 1.0).abs() < 1e-6);
186    }
187
188    #[test]
189    fn vector_point_id_normalization_strips_suffix() {
190        let normalized = normalize_vector_point_id("v1#Tag#bio", "Tag", "bio");
191        assert_eq!(normalized, "v1");
192        let escaped = normalize_vector_point_id("a%23b#Tag#bio", "Tag", "bio");
193        assert_eq!(escaped, "a#b");
194    }
195}