Skip to main content

llm_kernel/search/
federation.rs

1//! Cross-engine search federation over multiple vector backends.
2//!
3//! Federation queries several [`AsyncVectorIndex`](crate::embedding::AsyncVectorIndex)
4//! backends concurrently and merges their results with the existing fusion
5//! functions ([`rrf_fuse`],
6//! [`weighted_sum_fuse`]).
7//!
8//! # Why RRF is the default
9//!
10//! Heterogeneous backends score on incompatible scales: Qdrant (cosine
11//! distance) returns scores in `[0, 1]`; Elasticsearch knn `_score` is
12//! `(1 + cosine) / 2`, also in `[0, 1]` but a *different* monotonic transform;
13//! the in-memory `TurbovecIndex` returns raw cosine in `[-1, 1]`. Score-based
14//! fusion (weighted sum) over these raw values ranks documents incorrectly
15//! because a `0.3` from one backend is not comparable to a `0.3` from another.
16//!
17//! [`FusionStrategy::Rrf`] is **rank-based** (`1/(k + rank)`), so it is
18//! scale-invariant — it fuses heterogeneous backends correctly with **no**
19//! normalization. That is why it is the default. [`FusionStrategy::WeightedSum`]
20//! is opt-in: it normalizes each list with min-max first, which is only correct
21//! when every backend scores on a comparable scale, so it carries a caveat.
22
23use crate::search::SearchResult;
24use crate::search::fusion::{normalize_minmax, weighted_sum_fuse};
25use crate::search::rrf_fuse;
26
27/// How federated result lists are merged.
28///
29/// Defaults to [`FusionStrategy::Rrf`] (rank-based, scale-invariant). See the
30/// [module docs](self) for why this matters across heterogeneous backends.
31#[derive(Debug, Clone)]
32pub enum FusionStrategy {
33    /// Reciprocal Rank Fusion with constant `k` (typically 60). Rank-based, so
34    /// no score normalization is required across backends.
35    ///
36    /// In [`FederatedSearch`] each backend's
37    /// `with_backend` weight scales its rank credit (all `1.0` reduces to plain
38    /// RRF). [`federate_results`] fuses uniformly — pass weighted positional
39    /// lists to [`rrf_fuse_weighted`](crate::search::rrf_fuse_weighted) directly.
40    Rrf {
41        /// RRF smoothing constant (larger = flatter).
42        k: u32,
43    },
44    /// Weighted sum of min-max-normalized per-list scores. Each list is
45    /// normalized in isolation before summing, so this is only correct when
46    /// every backend scores on a comparable scale — otherwise prefer
47    /// [`FusionStrategy::Rrf`].
48    WeightedSum,
49}
50
51impl Default for FusionStrategy {
52    fn default() -> Self {
53        FusionStrategy::Rrf { k: 60 }
54    }
55}
56
57/// Fuse pre-fetched result lists with no I/O.
58///
59/// Lets a synchronous backend (e.g. the in-memory
60/// [`TurbovecIndex`](crate::embedding::TurbovecIndex)) participate in
61/// federation: the caller searches it directly, then folds its list in here
62/// alongside lists gathered from async backends (or any source). All lists
63/// contribute equally here — per-list weights are a one-call away with
64/// [`rrf_fuse_weighted`](crate::search::rrf_fuse_weighted).
65///
66/// ```
67/// use llm_kernel::search::{SearchResult, federation::{federate_results, FusionStrategy}};
68///
69/// let qdrant = vec![SearchResult { id: "1".into(), score: 0.9, text: String::new() }];
70/// let es = vec![SearchResult { id: "1".into(), score: 0.97, text: String::new() }];
71/// let turbovec = vec![SearchResult { id: "1".into(), score: 0.3, text: String::new() }];
72///
73/// let merged = federate_results(&[qdrant, es, turbovec], &FusionStrategy::default());
74/// assert_eq!(merged.len(), 1); // shared id deduped, not tripled
75/// ```
76pub fn federate_results(
77    lists: &[Vec<SearchResult>],
78    strategy: &FusionStrategy,
79) -> Vec<SearchResult> {
80    match strategy {
81        FusionStrategy::Rrf { k } => rrf_fuse(lists, *k),
82        FusionStrategy::WeightedSum => {
83            let normed: Vec<Vec<SearchResult>> = lists
84                .iter()
85                .map(|l| {
86                    let mut c = l.clone();
87                    normalize_minmax(&mut c);
88                    c
89                })
90                .collect();
91            let weights = vec![1.0_f32; normed.len()];
92            weighted_sum_fuse(&normed, &weights)
93        }
94    }
95}
96
97// ---------------------------------------------------------------------------
98// Async federation over AsyncVectorIndex backends (needs the `federation` feature).
99// ---------------------------------------------------------------------------
100
101#[cfg(feature = "federation")]
102mod federated {
103    use std::sync::Arc;
104    use std::time::Duration;
105
106    use futures_util::future::join_all;
107
108    use crate::embedding::{AsyncVectorIndex, SearchHit};
109    use crate::error::{KernelError, Result};
110    use crate::search::SearchResult;
111    use crate::search::fusion::{normalize_minmax, weighted_sum_fuse};
112    use crate::search::rrf_fuse_weighted;
113
114    use super::FusionStrategy;
115
116    /// One backend in a [`FederatedSearch`]: the index and its fusion weight.
117    struct Backend {
118        index: Arc<dyn AsyncVectorIndex>,
119        weight: f32,
120    }
121
122    /// Map u64-keyed [`SearchHit`]s into the String-id [`SearchResult`] shape the
123    /// fusion functions expect, canonicalizing the id so a shared document
124    /// merges across backends rather than appearing multiple times.
125    fn hits_to_results(hits: Vec<SearchHit>) -> Vec<SearchResult> {
126        hits.into_iter()
127            .map(|h| SearchResult {
128                id: h.id.to_string(),
129                score: h.score,
130                text: String::new(),
131            })
132            .collect()
133    }
134
135    /// Concurrent search over multiple [`AsyncVectorIndex`] backends.
136    ///
137    /// Queries every backend at once, applies a per-backend timeout so one slow
138    /// remote cannot stall the whole query, drops failing or timed-out backends
139    /// with an observable `tracing::warn!`, and merges the survivors with the
140    /// configured [`FusionStrategy`]. If **every** backend fails, returns
141    /// [`KernelError::Search`].
142    ///
143    /// Synchronous backends (e.g. `TurbovecIndex`) participate via
144    /// [`federate_results`](super::federate_results) instead — search them
145    /// directly and fold the list in.
146    pub struct FederatedSearch {
147        backends: Vec<Backend>,
148        strategy: FusionStrategy,
149        timeout: Duration,
150    }
151
152    impl Default for FederatedSearch {
153        fn default() -> Self {
154            Self {
155                backends: Vec::new(),
156                strategy: FusionStrategy::default(),
157                timeout: Duration::from_secs(5),
158            }
159        }
160    }
161
162    impl FederatedSearch {
163        /// Create an empty federated search (default strategy RRF k=60, 5s timeout).
164        pub fn new() -> Self {
165            Self::default()
166        }
167
168        /// Add a backend with a fusion weight — scales its contribution under
169        /// both [`FusionStrategy::WeightedSum`] and RRF (where it multiplies
170        /// the backend's rank credit; `1.0` for all backends is plain RRF).
171        ///
172        /// # Panics
173        ///
174        /// Panics if `weight` is negative or non-finite (`NaN`/`±inf`) — a
175        /// negative weight would invert rank credit under RRF, and a non-finite
176        /// one poisons the fused scores.
177        #[must_use]
178        pub fn with_backend(mut self, index: Arc<dyn AsyncVectorIndex>, weight: f32) -> Self {
179            assert!(
180                weight.is_finite() && weight >= 0.0,
181                "backend weight must be finite and >= 0.0, got {weight}"
182            );
183            self.backends.push(Backend { index, weight });
184            self
185        }
186
187        /// Set the fusion strategy (default [`FusionStrategy::Rrf`] k=60).
188        #[must_use]
189        pub fn strategy(mut self, strategy: FusionStrategy) -> Self {
190            self.strategy = strategy;
191            self
192        }
193
194        /// Set the per-backend query timeout (default 5s). A backend that
195        /// exceeds it is dropped with a warning rather than blocking the query.
196        #[must_use]
197        pub fn timeout(mut self, timeout: Duration) -> Self {
198            self.timeout = timeout;
199            self
200        }
201
202        /// Run `query` against every backend concurrently, merge survivors.
203        ///
204        /// Each backend is queried for `2 * k` results (over-fetch) so RRF
205        /// rank-credit is preserved for a document that ranks just below `k` in
206        /// one backend but near the top in another; the fused list is then
207        /// truncated to the requested `k`. A per-backend timeout drops slow or
208        /// failing backends with an observable `tracing::warn!` rather than
209        /// stalling the query.
210        ///
211        /// Returns the fused result list (at most `k` items).
212        /// [`KernelError::Search`] is returned only when *no* backend succeeded;
213        /// one or more survivors yield a partial (but non-empty) merged result.
214        pub async fn search(&self, query: &[f32], k_req: usize) -> Result<Vec<SearchResult>> {
215            if self.backends.is_empty() {
216                return Ok(Vec::new());
217            }
218
219            // Snapshot (index, weight) so each future is self-contained.
220            let entries: Vec<(Arc<dyn AsyncVectorIndex>, f32)> = self
221                .backends
222                .iter()
223                .map(|b| (b.index.clone(), b.weight))
224                .collect();
225            let timeout = self.timeout;
226
227            // Over-fetch each backend so RRF rank-credit is preserved for
228            // documents that rank just below k in one backend but appear near
229            // the top in another. Standard RRF practice: fetch ~2k, fuse, then
230            // truncate the merged list to the requested k (done after fusion).
231            // `saturating_mul` guards usize overflow and yields 0 for k == 0.
232            let fetch_k = k_req.saturating_mul(2);
233
234            let futs = entries.into_iter().map(|(index, weight)| {
235                let q = query.to_vec();
236                async move {
237                    match tokio::time::timeout(timeout, index.search(&q, fetch_k)).await {
238                        Ok(Ok(hits)) => Some((weight, hits)),
239                        Ok(Err(e)) => {
240                            tracing::warn!("federated backend errored; excluding: {e}");
241                            None
242                        }
243                        Err(_elapsed) => {
244                            tracing::warn!(
245                                "federated backend timed out after {:?}; excluding",
246                                timeout
247                            );
248                            None
249                        }
250                    }
251                }
252            });
253            let collected: Vec<Option<(f32, Vec<SearchHit>)>> = join_all(futs).await;
254
255            let ok: Vec<(f32, Vec<SearchHit>)> = collected.into_iter().flatten().collect();
256            if ok.is_empty() {
257                return Err(KernelError::Search(
258                    "all federated backends failed or timed out".into(),
259                ));
260            }
261
262            // Adapt u64-keyed hits into the String-id SearchResult shape fusion
263            // expects, canonicalizing the id so a shared document merges across
264            // backends rather than appearing multiple times. `ok` is consumed
265            // once: both RRF and WeightedSum read the per-backend weights
266            // (weights travel with their backend, so a dropped backend removes
267            // its weight with it — no positional misalignment). Note: the RRF
268            // smoothing constant is named `k` by the `FusionStrategy::Rrf`
269            // variant, which is why the requested count is `k_req` here — the
270            // two must not be confused at the truncation step.
271            let mut fused = match self.strategy {
272                FusionStrategy::Rrf { k } => {
273                    let mut lists: Vec<Vec<SearchResult>> = Vec::with_capacity(ok.len());
274                    let mut weights: Vec<f32> = Vec::with_capacity(ok.len());
275                    for (w, hits) in ok {
276                        lists.push(hits_to_results(hits));
277                        weights.push(w);
278                    }
279                    rrf_fuse_weighted(&lists, &weights, k)
280                }
281                FusionStrategy::WeightedSum => {
282                    let mut lists: Vec<Vec<SearchResult>> = Vec::with_capacity(ok.len());
283                    let mut weights: Vec<f32> = Vec::with_capacity(ok.len());
284                    for (w, hits) in ok {
285                        let mut list = hits_to_results(hits);
286                        normalize_minmax(&mut list);
287                        lists.push(list);
288                        weights.push(w);
289                    }
290                    weighted_sum_fuse(&lists, &weights)
291                }
292            };
293            // The over-fetch produced lists longer than requested; trim the
294            // fused output back to exactly `k_req`. `truncate` is a no-op if
295            // the fused list is already shorter (e.g. a backend with fewer than
296            // `fetch_k` documents).
297            fused.truncate(k_req);
298            Ok(fused)
299        }
300    }
301}
302
303#[cfg(feature = "federation")]
304pub use federated::FederatedSearch;
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309
310    fn hits(ids: &[(&str, f32)]) -> Vec<SearchResult> {
311        ids.iter()
312            .map(|(id, score)| SearchResult {
313                id: (*id).to_string(),
314                score: *score,
315                text: String::new(),
316            })
317            .collect()
318    }
319
320    /// AC5: RRF is scale-invariant — heterogeneous raw-score scales (Qdrant
321    /// cosine, ES `(1+cos)/2`, TurboVec raw cosine) fuse correctly under the
322    /// default strategy with no manual normalization. A document ranked #1 in
323    /// all three lists tops the merge regardless of wildly different scores.
324    #[test]
325    fn rrf_fuses_heterogeneous_scales_correctly() {
326        // Qdrant cosine [0,1]
327        let qdrant = hits(&[("shared", 0.90), ("a", 0.50)]);
328        // ES _score (1+cos)/2 [0,1] — note 0.97 here corresponds to cos≈0.94
329        let es = hits(&[("shared", 0.97), ("b", 0.70)]);
330        // TurboVec raw cosine [-1,1] — shared only scores 0.3 on this scale
331        let turbovec = hits(&[("shared", 0.30), ("c", -0.50)]);
332
333        let merged = federate_results(&[qdrant, es, turbovec], &FusionStrategy::default());
334
335        // "shared" is rank 0 in all three → highest RRF score.
336        assert_eq!(merged[0].id, "shared");
337    }
338
339    /// AC6: a shared id present in all backends is deduped (merged) once, and
340    /// accumulates rank-credit so it outranks any single-backend document.
341    #[test]
342    fn shared_id_is_deduped_and_boosted() {
343        let qdrant = hits(&[("shared", 1.0), ("only_q", 0.9)]);
344        let es = hits(&[("shared", 1.0)]);
345        let turbovec = hits(&[("shared", 1.0)]);
346
347        let merged = federate_results(&[qdrant, es, turbovec], &FusionStrategy::default());
348
349        // "shared" appears exactly once.
350        let shared_count = merged.iter().filter(|r| r.id == "shared").count();
351        assert_eq!(shared_count, 1);
352        assert_eq!(merged.len(), 2); // shared + only_q
353        // shared: rank 0 in three lists; only_q: rank 1 in one list.
354        let shared_score = merged.iter().find(|r| r.id == "shared").unwrap().score;
355        let only_q_score = merged.iter().find(|r| r.id == "only_q").unwrap().score;
356        assert!(shared_score > only_q_score);
357    }
358
359    #[test]
360    fn weighted_sum_strategy_runs() {
361        let a = hits(&[("x", 0.0), ("y", 1.0)]);
362        let b = hits(&[("y", 1.0), ("z", 0.4)]);
363        let merged = federate_results(&[a, b], &FusionStrategy::WeightedSum);
364        assert_eq!(merged.len(), 3);
365        assert_eq!(merged[0].id, "y");
366    }
367}
368
369#[cfg(all(test, feature = "federation"))]
370mod async_tests {
371    use std::sync::Arc;
372    use std::time::Duration;
373
374    use async_trait::async_trait;
375
376    use crate::embedding::{AsyncVectorIndex, SearchHit};
377    use crate::error::{KernelError, Result};
378    use crate::search::federation::{FederatedSearch, FusionStrategy};
379
380    /// Configurable stub backend: returns canned hits, optionally fails, or
381    /// delays past a timeout.
382    struct StubIndex {
383        hits: Vec<SearchHit>,
384        delay: Option<Duration>,
385        fail: bool,
386        dim: usize,
387    }
388
389    #[async_trait]
390    impl AsyncVectorIndex for StubIndex {
391        async fn add(&self, _vectors: &[Vec<f32>], _ids: &[u64]) -> Result<()> {
392            Ok(())
393        }
394        async fn remove(&self, _ids: &[u64]) -> Result<()> {
395            Ok(())
396        }
397        async fn search(&self, _query: &[f32], _k: usize) -> Result<Vec<SearchHit>> {
398            if let Some(d) = self.delay {
399                tokio::time::sleep(d).await;
400            }
401            if self.fail {
402                return Err(KernelError::Embedding("stub backend failure".into()));
403            }
404            Ok(self.hits.clone())
405        }
406        async fn search_filtered(
407            &self,
408            _query: &[f32],
409            _k: usize,
410            _allowlist: &[u64],
411        ) -> Result<Vec<SearchHit>> {
412            Ok(self.hits.clone())
413        }
414        async fn len(&self) -> Result<usize> {
415            Ok(self.hits.len())
416        }
417        fn dim(&self) -> usize {
418            self.dim
419        }
420    }
421
422    fn hit(id: u64, score: f32) -> SearchHit {
423        SearchHit { id, score }
424    }
425
426    /// AC4: a slow backend that exceeds the timeout is dropped without failing
427    /// the query; the fast backend's results still come through.
428    #[tokio::test]
429    async fn slow_backend_is_dropped_not_blocking() {
430        let fast = Arc::new(StubIndex {
431            hits: vec![hit(1, 0.9), hit(2, 0.5)],
432            delay: None,
433            fail: false,
434            dim: 4,
435        });
436        let slow = Arc::new(StubIndex {
437            hits: vec![hit(3, 1.0)],
438            delay: Some(Duration::from_millis(500)),
439            fail: false,
440            dim: 4,
441        });
442
443        let fed = FederatedSearch::new()
444            .with_backend(fast, 1.0)
445            .with_backend(slow, 1.0)
446            .timeout(Duration::from_millis(50));
447
448        let merged = fed.search(&[1.0, 0.0, 0.0, 0.0], 5).await.unwrap();
449        // Only the fast backend contributed; id 3 (from the timed-out backend)
450        // is absent, but the query still succeeded.
451        assert!(merged.iter().any(|r| r.id == "1"));
452        assert!(!merged.iter().any(|r| r.id == "3"));
453    }
454
455    /// AC4: a failing backend is excluded; survivors still return results.
456    #[tokio::test]
457    async fn failing_backend_is_excluded() {
458        let good = Arc::new(StubIndex {
459            hits: vec![hit(7, 0.8)],
460            delay: None,
461            fail: false,
462            dim: 4,
463        });
464        let bad = Arc::new(StubIndex {
465            hits: vec![],
466            delay: None,
467            fail: true,
468            dim: 4,
469        });
470
471        let merged = FederatedSearch::new()
472            .with_backend(good, 1.0)
473            .with_backend(bad, 1.0)
474            .search(&[0.0, 0.0, 0.0, 1.0], 3)
475            .await
476            .unwrap();
477        assert!(merged.iter().any(|r| r.id == "7"));
478    }
479
480    /// AC4: when *every* backend fails, `search` returns `Err`.
481    #[tokio::test]
482    async fn all_backends_failing_returns_err() {
483        let bad = Arc::new(StubIndex {
484            hits: vec![],
485            delay: None,
486            fail: true,
487            dim: 4,
488        });
489        let res = FederatedSearch::new()
490            .with_backend(bad.clone(), 1.0)
491            .with_backend(bad, 1.0)
492            .search(&[0.0; 4], 3)
493            .await;
494        assert!(res.is_err());
495    }
496
497    /// AC4/AC5: two healthy backends merge under the default RRF strategy.
498    #[tokio::test]
499    async fn two_backends_merge_via_rrf() {
500        let a = Arc::new(StubIndex {
501            hits: vec![hit(1, 0.99), hit(2, 0.4)],
502            delay: None,
503            fail: false,
504            dim: 4,
505        });
506        let b = Arc::new(StubIndex {
507            hits: vec![hit(2, 0.95), hit(3, 0.6)],
508            delay: None,
509            fail: false,
510            dim: 4,
511        });
512        let merged = FederatedSearch::new()
513            .with_backend(a, 1.0)
514            .with_backend(b, 1.0)
515            .search(&[1.0, 0.0, 0.0, 0.0], 5)
516            .await
517            .unwrap();
518        // id 2 is rank 0/1 across both → top.
519        assert_eq!(merged[0].id, "2");
520        assert_eq!(merged.len(), 3);
521        // Strategy default is RRF k=60.
522        assert!(matches!(
523            FusionStrategy::default(),
524            FusionStrategy::Rrf { k: 60 }
525        ));
526    }
527
528    #[test]
529    #[should_panic(expected = "backend weight must be finite and >= 0.0")]
530    fn rejects_negative_weight() {
531        let idx: Arc<dyn AsyncVectorIndex> = Arc::new(StubIndex {
532            hits: vec![],
533            delay: None,
534            fail: false,
535            dim: 4,
536        });
537        let _ = FederatedSearch::new().with_backend(idx, -1.0);
538    }
539
540    /// RRF honors backend weights: a heavy-weight backend's top hit outranks a
541    /// document that gets correlated rank-credit from both backends — the
542    /// issue-#110 failure mode. With k=60 and the heavy doc at rank 0 (weight
543    /// w_a) vs the correlated doc at ranks 1/0 (weights w_a, w_b), the flip
544    /// needs w_a > (k+2)·w_b; uniform weights (plain RRF) leave the correlated
545    /// doc on top.
546    #[tokio::test]
547    async fn rrf_weights_flip_correlated_noise() {
548        let a = Arc::new(StubIndex {
549            hits: vec![hit(1, 0.99), hit(2, 0.4)],
550            delay: None,
551            fail: false,
552            dim: 4,
553        });
554        let b = Arc::new(StubIndex {
555            hits: vec![hit(2, 0.95), hit(3, 0.6)],
556            delay: None,
557            fail: false,
558            dim: 4,
559        });
560
561        // Uniform (1.0/1.0): id 2 is ranked in BOTH backends → top, like plain RRF.
562        let uniform = FederatedSearch::new()
563            .with_backend(a.clone(), 1.0)
564            .with_backend(b.clone(), 1.0)
565            .search(&[1.0, 0.0, 0.0, 0.0], 5)
566            .await
567            .unwrap();
568        assert_eq!(uniform[0].id, "2");
569
570        // Weighted (100.0/1.0): id 1 (a's top) overtakes the correlated id 2.
571        let weighted = FederatedSearch::new()
572            .with_backend(a, 100.0)
573            .with_backend(b, 1.0)
574            .search(&[1.0, 0.0, 0.0, 0.0], 5)
575            .await
576            .unwrap();
577        assert_eq!(weighted[0].id, "1");
578    }
579
580    /// Guards the refactored WeightedSum async arm (the weights-collection loop
581    /// moved inside that arm): two backends with distinct weights still merge,
582    /// with results from both backends present.
583    #[tokio::test]
584    async fn two_backends_merge_via_weighted_sum() {
585        let a = Arc::new(StubIndex {
586            hits: vec![hit(1, 1.0), hit(2, 0.2)],
587            delay: None,
588            fail: false,
589            dim: 4,
590        });
591        let b = Arc::new(StubIndex {
592            hits: vec![hit(2, 1.0), hit(3, 0.1)],
593            delay: None,
594            fail: false,
595            dim: 4,
596        });
597        let merged = FederatedSearch::new()
598            .with_backend(a, 0.75)
599            .with_backend(b, 0.25)
600            .strategy(FusionStrategy::WeightedSum)
601            .search(&[1.0, 0.0, 0.0, 0.0], 5)
602            .await
603            .unwrap();
604        // a's top (id 1, normalized weight 0.75) leads; both backends present.
605        assert!(!merged.is_empty());
606        assert_eq!(merged[0].id, "1");
607        assert!(merged.iter().any(|r| r.id == "2")); // shared, deduped
608        assert!(merged.iter().any(|r| r.id == "3")); // from b
609    }
610
611    /// No backends configured → empty result, no error.
612    #[tokio::test]
613    async fn no_backends_returns_empty() {
614        let merged = FederatedSearch::new().search(&[0.0; 4], 3).await.unwrap();
615        assert!(merged.is_empty());
616    }
617
618    // --- over-fetch / truncate (hardening) ---------------------------------
619    //
620    // `StubIndex` above ignores its `k` argument, so it cannot exercise the
621    // fetch-2k-then-truncate behavior. `RankAwareStub` honors `k` by returning
622    // only the first `k` of its canned list, letting us prove the over-fetch
623    // preserves RRF rank-credit and the output is truncated to the requested k.
624
625    /// Stub backend that honors the requested `k`: returns the first `k` of its
626    /// canned list (clamped to the list length). This mirrors how a real
627    /// `AsyncVectorIndex` returns at most `k` neighbors.
628    struct RankAwareStub {
629        hits: Vec<SearchHit>,
630        dim: usize,
631    }
632
633    #[async_trait]
634    impl AsyncVectorIndex for RankAwareStub {
635        async fn add(&self, _vectors: &[Vec<f32>], _ids: &[u64]) -> Result<()> {
636            Ok(())
637        }
638        async fn remove(&self, _ids: &[u64]) -> Result<()> {
639            Ok(())
640        }
641        async fn search(&self, _query: &[f32], k: usize) -> Result<Vec<SearchHit>> {
642            Ok(self.hits.iter().take(k).cloned().collect())
643        }
644        async fn search_filtered(
645            &self,
646            _query: &[f32],
647            k: usize,
648            _allowlist: &[u64],
649        ) -> Result<Vec<SearchHit>> {
650            Ok(self.hits.iter().take(k).cloned().collect())
651        }
652        async fn len(&self) -> Result<usize> {
653            Ok(self.hits.len())
654        }
655        fn dim(&self) -> usize {
656            self.dim
657        }
658    }
659
660    /// Without over-fetch, a document that ranks just below `k` in one backend
661    /// but at the top in another loses its rank-credit: the first backend never
662    /// returns it (it asked for only `k`). Over-fetching `2 * k` per backend
663    /// lets that document enter both lists, so RRF credits it from both and it
664    /// survives the final truncate to `k`.
665    #[tokio::test]
666    async fn over_fetch_preserves_rank_credit_across_backends() {
667        // Backend A ranks `shared` at position 2 (rank index 2) — below k=2,
668        // so a bare `k` query would never return it. Backend B ranks `shared`
669        // first. With over-fetch (fetch_k = 4), A DOES return `shared`.
670        let a = Arc::new(RankAwareStub {
671            hits: vec![hit(101, 0.99), hit(102, 0.9), hit(7, 0.8), hit(8, 0.7)],
672            dim: 4,
673        });
674        let b = Arc::new(RankAwareStub {
675            hits: vec![hit(7, 1.0), hit(9, 0.6)],
676            dim: 4,
677        });
678
679        let merged = FederatedSearch::new()
680            .with_backend(a, 1.0)
681            .with_backend(b, 1.0)
682            // k = 2 → each backend is queried for 4; id 7 appears in BOTH
683            // lists (rank 2 in A, rank 0 in B) and accumulates rank-credit, so
684            // it outranks the single-backend filler docs and makes the top 2.
685            .search(&[1.0, 0.0, 0.0, 0.0], 2)
686            .await
687            .unwrap();
688
689        // Truncated to exactly k = 2.
690        assert_eq!(merged.len(), 2);
691        // id 7 is present (it appeared in both over-fetched lists). Without
692        // over-fetch it would have been dropped by backend A and could not
693        // accumulate cross-backend credit.
694        assert!(
695            merged.iter().any(|r| r.id == "7"),
696            "id 7 should survive via over-fetch rank-credit: {merged:?}"
697        );
698    }
699
700    /// The fused output is truncated to the requested `k`, never more, even
701    /// when every backend has far more than `k` documents.
702    #[tokio::test]
703    async fn fused_output_is_truncated_to_requested_k() {
704        let a = Arc::new(RankAwareStub {
705            hits: (1..=20).map(|i| hit(i, 1.0 - i as f32 * 0.01)).collect(),
706            dim: 4,
707        });
708        let b = Arc::new(RankAwareStub {
709            hits: (21..=40).map(|i| hit(i, 0.5 - i as f32 * 0.01)).collect(),
710            dim: 4,
711        });
712        let merged = FederatedSearch::new()
713            .with_backend(a, 1.0)
714            .with_backend(b, 1.0)
715            .search(&[1.0, 0.0, 0.0, 0.0], 5)
716            .await
717            .unwrap();
718        assert_eq!(merged.len(), 5, "fused output must be truncated to k");
719    }
720
721    /// `k == 0` with configured backends yields an empty (non-error) result:
722    /// fetch_k == 0 → each backend returns nothing → fused empty → truncate(0).
723    #[tokio::test]
724    async fn k_zero_with_backends_returns_empty_not_err() {
725        let a = Arc::new(RankAwareStub {
726            hits: vec![hit(1, 0.9)],
727            dim: 4,
728        });
729        let merged = FederatedSearch::new()
730            .with_backend(a, 1.0)
731            .search(&[1.0, 0.0, 0.0, 0.0], 0)
732            .await
733            .unwrap();
734        assert!(merged.is_empty());
735    }
736}