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