1use std::collections::{hash_map::Entry, HashMap, HashSet};
4
5use uuid::Uuid;
6
7use khive_score::DeterministicScore;
8use khive_storage::types::{
9 PageRequest, TextFilter, TextQueryMode, TextSearchHit, TextSearchRequest, VectorSearchHit,
10};
11use khive_storage::EntityFilter;
12use khive_types::SubstrateKind;
13
14use crate::error::{RuntimeError, RuntimeResult};
15use crate::retrieval::{SearchHit, SearchSource};
16use crate::runtime::{KhiveRuntime, NamespaceToken};
17
18pub use khive_fusion::FusionStrategy;
19
20const CANDIDATE_MULTIPLIER: u32 = 4;
21
22pub fn fuse_with_strategy(
24 text_hits: Vec<TextSearchHit>,
25 vector_hits: Vec<VectorSearchHit>,
26 strategy: &FusionStrategy,
27 limit: usize,
28) -> RuntimeResult<Vec<SearchHit>> {
29 match strategy {
30 FusionStrategy::VectorOnly => fuse_sources(Vec::new(), vector_hits, strategy, limit),
31 FusionStrategy::KeywordOnly => fuse_sources(text_hits, Vec::new(), strategy, limit),
32 FusionStrategy::Rrf { .. } | FusionStrategy::Weighted { .. } | FusionStrategy::Union => {
33 fuse_sources(text_hits, vector_hits, strategy, limit)
34 }
35 FusionStrategy::Custom { ref name, .. } => {
36 Err(khive_fusion::FuseError::CustomRequiresRuntime(name.clone()).into())
37 }
38 }
39}
40
41pub(crate) fn rrf_fuse_k(
43 text_hits: Vec<TextSearchHit>,
44 vector_hits: Vec<VectorSearchHit>,
45 k: usize,
46 limit: usize,
47) -> RuntimeResult<Vec<SearchHit>> {
48 fuse_with_strategy(text_hits, vector_hits, &FusionStrategy::Rrf { k }, limit)
49}
50
51fn fuse_sources(
52 text_hits: Vec<TextSearchHit>,
53 vector_hits: Vec<VectorSearchHit>,
54 strategy: &FusionStrategy,
55 limit: usize,
56) -> RuntimeResult<Vec<SearchHit>> {
57 let mut metadata: HashMap<Uuid, SearchHit> =
58 HashMap::with_capacity(text_hits.len() + vector_hits.len());
59
60 let text_source: Vec<(Uuid, DeterministicScore)> = text_hits
61 .into_iter()
62 .map(|h| {
63 let hit = SearchHit {
64 entity_id: h.subject_id,
65 score: h.score,
66 source: SearchSource::Text,
67 title: h.title,
68 snippet: h.snippet,
69 };
70 let id = hit.entity_id;
71 let score = hit.score;
72 merge_metadata(&mut metadata, hit);
73 (id, score)
74 })
75 .collect();
76
77 let vector_source: Vec<(Uuid, DeterministicScore)> = vector_hits
78 .into_iter()
79 .map(|h| {
80 let hit = SearchHit {
81 entity_id: h.subject_id,
82 score: h.score,
83 source: SearchSource::Vector,
84 title: None,
85 snippet: None,
86 };
87 let id = hit.entity_id;
88 let score = hit.score;
89 merge_metadata(&mut metadata, hit);
90 (id, score)
91 })
92 .collect();
93
94 let sources: Vec<Vec<(Uuid, DeterministicScore)>> = vec![text_source, vector_source]
95 .into_iter()
96 .filter(|s| !s.is_empty())
97 .collect();
98
99 Ok(khive_fusion::fuse(sources, strategy, limit)?
100 .into_iter()
101 .filter_map(|(id, score)| {
102 let mut hit = metadata.remove(&id)?;
103 hit.score = score;
104 Some(hit)
105 })
106 .collect())
107}
108
109fn merge_metadata(metadata: &mut HashMap<Uuid, SearchHit>, hit: SearchHit) {
110 match metadata.entry(hit.entity_id) {
111 Entry::Occupied(mut entry) => {
112 let existing = entry.get_mut();
113 existing.source = merge_sources(existing.source, hit.source);
114 if existing.title.is_none() {
115 existing.title = hit.title;
116 }
117 if existing.snippet.is_none() {
118 existing.snippet = hit.snippet;
119 }
120 }
121 Entry::Vacant(entry) => {
122 entry.insert(hit);
123 }
124 }
125}
126
127fn merge_sources(left: SearchSource, right: SearchSource) -> SearchSource {
128 match (left, right) {
129 (SearchSource::Both, _) | (_, SearchSource::Both) => SearchSource::Both,
130 (SearchSource::Text, SearchSource::Vector) | (SearchSource::Vector, SearchSource::Text) => {
131 SearchSource::Both
132 }
133 (SearchSource::Text, SearchSource::Text) => SearchSource::Text,
134 (SearchSource::Vector, SearchSource::Vector) => SearchSource::Vector,
135 }
136}
137
138impl KhiveRuntime {
139 pub async fn hybrid_search_with_strategy(
141 &self,
142 token: &NamespaceToken,
143 query_text: &str,
144 query_vector: Option<Vec<f32>>,
145 strategy: FusionStrategy,
146 limit: u32,
147 ) -> RuntimeResult<Vec<SearchHit>> {
148 let candidates = limit.saturating_mul(CANDIDATE_MULTIPLIER).max(limit);
149
150 let ns = token.namespace().as_str().to_owned();
151 let text_search_result = self
156 .text(token)?
157 .search(TextSearchRequest {
158 query: query_text.to_string(),
159 mode: TextQueryMode::Plain,
160 filter: Some(TextFilter {
161 namespaces: vec![ns.clone()],
162 ..TextFilter::default()
163 }),
164 top_k: candidates,
165 snippet_chars: 200,
166 })
167 .await;
168 let text_hits = crate::error::fts_text_leg_or_err(
169 text_search_result.map_err(RuntimeError::from),
170 "hybrid_search_with_strategy",
171 query_text,
172 )?;
173
174 let vector_hits = if query_vector.is_some() || self.config().embedding_model.is_some() {
175 self.vector_search(
176 token,
177 query_vector,
178 Some(query_text),
179 candidates,
180 Some(SubstrateKind::Entity),
181 )
182 .await?
183 } else {
184 Vec::new()
185 };
186
187 let mut fused = fuse_with_strategy(text_hits, vector_hits, &strategy, limit as usize)?;
188
189 if !fused.is_empty() {
192 let candidate_ids: Vec<Uuid> = fused.iter().map(|h| h.entity_id).collect();
193 let alive_page = self
194 .entities(token)?
195 .query_entities(
196 token.namespace().as_str(),
197 EntityFilter {
198 ids: candidate_ids,
199 ..EntityFilter::default()
200 },
201 PageRequest {
202 offset: 0,
203 limit: fused.len() as u32,
204 },
205 )
206 .await?;
207 let alive: HashSet<Uuid> = alive_page.items.into_iter().map(|e| e.id).collect();
208 fused.retain(|h| alive.contains(&h.entity_id));
209 }
210
211 Ok(fused)
212 }
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218 use khive_storage::types::{TextSearchHit, VectorSearchHit};
219
220 fn text_hit(id: Uuid, score: f64, title: &str) -> TextSearchHit {
221 TextSearchHit {
222 subject_id: id,
223 score: DeterministicScore::from_f64(score),
224 rank: 1,
225 title: Some(title.to_string()),
226 snippet: Some("...".to_string()),
227 }
228 }
229
230 fn vector_hit(id: Uuid, score: f64) -> VectorSearchHit {
231 VectorSearchHit {
232 subject_id: id,
233 score: DeterministicScore::from_f64(score),
234 rank: 1,
235 }
236 }
237
238 #[test]
240 fn rrf_custom_k_differs_from_k60() {
241 let a = Uuid::new_v4();
242 let b = Uuid::new_v4();
243 let text = vec![text_hit(a, 0.9, "a"), text_hit(b, 0.1, "b")];
247 let hits_k1 =
248 fuse_with_strategy(text.clone(), vec![], &FusionStrategy::Rrf { k: 1 }, 10).unwrap();
249 let hits_k60 =
250 fuse_with_strategy(text, vec![], &FusionStrategy::Rrf { k: 60 }, 10).unwrap();
251 assert_eq!(hits_k1[0].entity_id, a);
253 assert_eq!(hits_k60[0].entity_id, a);
254 assert!(hits_k1[0].score > hits_k60[0].score);
256 }
257
258 #[test]
260 fn weighted_ordering_depends_on_weights() {
261 let a = Uuid::new_v4();
262 let b = Uuid::new_v4();
263 let text = vec![text_hit(a, 0.9, "a"), text_hit(b, 0.1, "b")];
265 let vec_hits = vec![vector_hit(b, 0.9), vector_hit(a, 0.1)];
266
267 let heavy_text = fuse_with_strategy(
268 text.clone(),
269 vec_hits.clone(),
270 &FusionStrategy::Weighted {
271 weights: vec![0.7, 0.3],
272 },
273 10,
274 )
275 .unwrap();
276 let heavy_vec = fuse_with_strategy(
277 text,
278 vec_hits,
279 &FusionStrategy::Weighted {
280 weights: vec![0.3, 0.7],
281 },
282 10,
283 )
284 .unwrap();
285
286 assert_eq!(heavy_text[0].entity_id, a);
287 assert_eq!(heavy_vec[0].entity_id, b);
288 }
289
290 #[test]
292 fn weighted_scale_invariant() {
293 let a = Uuid::new_v4();
294 let b = Uuid::new_v4();
295 let text = vec![text_hit(a, 0.9, "a"), text_hit(b, 0.1, "b")];
296 let vec_hits = vec![vector_hit(b, 0.9), vector_hit(a, 0.1)];
297
298 let w1 = fuse_with_strategy(
299 text.clone(),
300 vec_hits.clone(),
301 &FusionStrategy::Weighted {
302 weights: vec![0.7, 0.3],
303 },
304 10,
305 )
306 .unwrap();
307 let w2 = fuse_with_strategy(
308 text,
309 vec_hits,
310 &FusionStrategy::Weighted {
311 weights: vec![7.0, 3.0],
312 },
313 10,
314 )
315 .unwrap();
316
317 assert_eq!(w1[0].entity_id, w2[0].entity_id);
318 assert_eq!(w1[1].entity_id, w2[1].entity_id);
319 let diff = (w1[0].score.to_f64() - w2[0].score.to_f64()).abs();
320 assert!(diff < 1e-9, "scores differ by {diff}");
321 }
322
323 #[test]
325 fn weighted_zero_weights_equal_fallback() {
326 let a = Uuid::new_v4();
327 let b = Uuid::new_v4();
328 let text = vec![text_hit(a, 0.9, "a"), text_hit(b, 0.1, "b")];
330 let vec_hits = vec![vector_hit(a, 0.9), vector_hit(b, 0.1)];
331
332 let hits = fuse_with_strategy(
333 text,
334 vec_hits,
335 &FusionStrategy::Weighted {
336 weights: vec![0.0, 0.0],
337 },
338 10,
339 )
340 .unwrap();
341 assert_eq!(hits[0].entity_id, a);
342 }
343
344 #[test]
346 fn weighted_negative_weight_clamped() {
347 let a = Uuid::new_v4();
348 let text = vec![text_hit(a, 0.9, "a")];
349 let hits = fuse_with_strategy(
351 text,
352 vec![],
353 &FusionStrategy::Weighted {
354 weights: vec![1.0, -0.5],
355 },
356 10,
357 )
358 .unwrap();
359 assert_eq!(hits.len(), 1);
360 assert_eq!(hits[0].entity_id, a);
361 }
362
363 #[test]
365 fn union_max_score_per_entity() {
366 let a = Uuid::new_v4();
367 let text = vec![text_hit(a, 0.3, "a")];
368 let vec_hits = vec![vector_hit(a, 0.9)];
369
370 let hits = fuse_with_strategy(text, vec_hits, &FusionStrategy::Union, 10).unwrap();
371 assert_eq!(hits.len(), 1);
372 assert!((hits[0].score.to_f64() - 0.9).abs() < 1e-6);
373 assert_eq!(hits[0].source, SearchSource::Both);
374 }
375
376 #[test]
378 fn vector_only_drops_text() {
379 let a = Uuid::new_v4();
380 let b = Uuid::new_v4();
381 let text = vec![text_hit(b, 0.9, "b")];
382 let vec_hits = vec![vector_hit(a, 0.8)];
383
384 let hits = fuse_with_strategy(text, vec_hits, &FusionStrategy::VectorOnly, 10).unwrap();
385 assert_eq!(hits.len(), 1);
386 assert_eq!(hits[0].entity_id, a);
387 assert_eq!(hits[0].source, SearchSource::Vector);
388 assert!(hits[0].title.is_none());
389 }
390
391 #[test]
393 fn default_strategy_is_rrf_k60() {
394 assert_eq!(FusionStrategy::default(), FusionStrategy::Rrf { k: 60 });
395 }
396
397 #[test]
399 fn serde_roundtrip() {
400 let cases = vec![
401 FusionStrategy::Rrf { k: 60 },
402 FusionStrategy::Rrf { k: 20 },
403 FusionStrategy::Weighted {
404 weights: vec![0.7, 0.3],
405 },
406 FusionStrategy::Union,
407 FusionStrategy::VectorOnly,
408 ];
409 for strategy in cases {
410 let json = serde_json::to_string(&strategy).expect("serialize");
411 let back: FusionStrategy = serde_json::from_str(&json).expect("deserialize");
412 assert_eq!(strategy, back, "roundtrip failed for {json}");
413 }
414 }
415
416 #[tokio::test]
421 async fn hybrid_search_with_strategy_dollar_sign_query_does_not_error() {
422 let rt = KhiveRuntime::memory().unwrap();
423 let tok = NamespaceToken::local();
424 rt.create_entity(
425 &tok,
426 "concept",
427 None,
428 "DSL docs",
429 Some("use $prev.id to chain calls"),
430 None,
431 vec![],
432 )
433 .await
434 .unwrap();
435
436 let result = rt
437 .hybrid_search_with_strategy(&tok, "$prev.id", None, FusionStrategy::default(), 10)
438 .await;
439
440 assert!(
441 result.is_ok(),
442 "#388 hybrid_search_with_strategy must not hard-fail on a '$'-bearing query, got: {:?}",
443 result.err()
444 );
445 }
446
447 #[tokio::test]
452 async fn hybrid_search_with_strategy_residual_fts5_char_fails_loud() {
453 let rt = KhiveRuntime::memory().unwrap();
454 let tok = NamespaceToken::local();
455 rt.create_entity(
456 &tok,
457 "concept",
458 None,
459 "DSL docs",
460 Some("use foo@bar to chain calls"),
461 None,
462 vec![],
463 )
464 .await
465 .unwrap();
466
467 let result = rt
468 .hybrid_search_with_strategy(&tok, "foo@bar", None, FusionStrategy::default(), 10)
469 .await;
470
471 assert!(
472 result.is_err(),
473 "#569 hybrid_search_with_strategy must fail loud when the FTS leg errors \
474 on a residual FTS5 char ('@'), not silently degrade to vector-only fusion, \
475 got: {:?}",
476 result.ok()
477 );
478 assert!(
479 matches!(result.unwrap_err(), RuntimeError::InvalidInput(_)),
480 "residual FTS5 parser failure must surface as RuntimeError::InvalidInput"
481 );
482 }
483}