llm_kernel/search/federation.rs
1//! Cross-engine search federation over multiple vector backends.
2//!
3//! Federation queries several `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` (feature `federation`) 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`, feature `vector-index`) 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}