1use super::config::{VectorServiceArg, VectorServiceResult};
7use crate::hybrid_search::multimodal_fusion::{
8 FusedResult, FusionConfig, FusionStrategy, Modality, MultimodalFusion, NormalizationMethod,
9};
10use crate::hybrid_search::types::DocumentScore;
11use anyhow::{Context, Result};
12use serde::{Deserialize, Serialize};
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct MultimodalSearchConfig {
17 pub default_weights: Vec<f64>,
19 pub default_strategy: String,
21 pub normalization: String,
23 pub cascade_thresholds: Vec<f64>,
25}
26
27impl Default for MultimodalSearchConfig {
28 fn default() -> Self {
29 Self {
30 default_weights: vec![0.33, 0.33, 0.34],
31 default_strategy: "rankfusion".to_string(),
32 normalization: "minmax".to_string(),
33 cascade_thresholds: vec![0.5, 0.7, 0.8],
34 }
35 }
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct SparqlMultimodalResult {
41 pub uri: String,
43 pub score: f64,
45 pub text_score: Option<f64>,
47 pub vector_score: Option<f64>,
48 pub spatial_score: Option<f64>,
49}
50
51impl From<FusedResult> for SparqlMultimodalResult {
52 fn from(result: FusedResult) -> Self {
53 let text_score = result.get_score(Modality::Text);
54 let vector_score = result.get_score(Modality::Vector);
55 let spatial_score = result.get_score(Modality::Spatial);
56
57 Self {
58 uri: result.uri,
59 score: result.total_score,
60 text_score,
61 vector_score,
62 spatial_score,
63 }
64 }
65}
66
67#[derive(Debug, Clone, Default)]
70pub struct MultimodalSearchQuery {
71 pub text_query: Option<String>,
73 pub vector_query: Option<String>,
75 pub spatial_query: Option<String>,
77 pub weights: Option<String>,
79 pub strategy: Option<String>,
81}
82
83pub fn sparql_multimodal_search(
104 query: MultimodalSearchQuery,
105 limit: usize,
106 config: &MultimodalSearchConfig,
107 vector_store: Option<&crate::VectorStore>,
108) -> Result<Vec<SparqlMultimodalResult>> {
109 let MultimodalSearchQuery {
110 text_query,
111 vector_query,
112 spatial_query,
113 weights,
114 strategy,
115 } = query;
116
117 let fusion_strategy = parse_fusion_strategy(
119 strategy.as_deref(),
120 weights.as_deref(),
121 &config.default_weights,
122 &config.cascade_thresholds,
123 )?;
124
125 let normalization = parse_normalization(&config.normalization)?;
127
128 let fusion_config = FusionConfig {
130 default_strategy: fusion_strategy.clone(),
131 score_normalization: normalization,
132 };
133 let fusion = MultimodalFusion::new(fusion_config);
134
135 let text_results = if let Some(query) = text_query {
137 execute_text_search(&query, limit * 2)?
138 } else {
139 Vec::new()
140 };
141
142 let vector_results = if let Some(query) = vector_query {
143 let embedding = parse_vector(&query)?;
144 execute_vector_search(vector_store, &embedding, limit * 2)?
145 } else {
146 Vec::new()
147 };
148
149 let spatial_results = if let Some(query) = spatial_query {
150 execute_spatial_search(&query, limit * 2)?
151 } else {
152 Vec::new()
153 };
154
155 let fused = fusion.fuse(
157 &text_results,
158 &vector_results,
159 &spatial_results,
160 Some(fusion_strategy),
161 )?;
162
163 let results: Vec<SparqlMultimodalResult> = fused
165 .into_iter()
166 .take(limit)
167 .map(SparqlMultimodalResult::from)
168 .collect();
169
170 Ok(results)
171}
172
173fn parse_fusion_strategy(
175 strategy: Option<&str>,
176 weights: Option<&str>,
177 default_weights: &[f64],
178 cascade_thresholds: &[f64],
179) -> Result<FusionStrategy> {
180 match strategy {
181 Some("weighted") => {
182 let w = if let Some(weights_str) = weights {
183 parse_weights(weights_str)?
184 } else {
185 default_weights.to_vec()
186 };
187 Ok(FusionStrategy::Weighted { weights: w })
188 }
189 Some("sequential") => {
190 Ok(FusionStrategy::Sequential {
192 order: vec![Modality::Text, Modality::Vector],
193 })
194 }
195 Some("cascade") => Ok(FusionStrategy::Cascade {
196 thresholds: cascade_thresholds.to_vec(),
197 }),
198 Some("rankfusion") | None => Ok(FusionStrategy::RankFusion),
199 Some(s) => anyhow::bail!("Unknown fusion strategy: {}", s),
200 }
201}
202
203fn parse_normalization(normalization: &str) -> Result<NormalizationMethod> {
205 match normalization.to_lowercase().as_str() {
206 "minmax" => Ok(NormalizationMethod::MinMax),
207 "zscore" => Ok(NormalizationMethod::ZScore),
208 "sigmoid" => Ok(NormalizationMethod::Sigmoid),
209 _ => anyhow::bail!("Unknown normalization method: {}", normalization),
210 }
211}
212
213fn parse_weights(weights_str: &str) -> Result<Vec<f64>> {
215 weights_str
216 .split(',')
217 .map(|s| {
218 s.trim()
219 .parse::<f64>()
220 .context("Failed to parse weight value")
221 })
222 .collect()
223}
224
225fn parse_vector(vector_str: &str) -> Result<Vec<f32>> {
227 vector_str
228 .split(',')
229 .map(|s| {
230 s.trim()
231 .parse::<f32>()
232 .context("Failed to parse vector value")
233 })
234 .collect()
235}
236
237fn execute_text_search(query: &str, _limit: usize) -> Result<Vec<DocumentScore>> {
246 tracing::warn!(
247 "sparql_multimodal_search: no full-text search backend is wired up; \
248 excluding the text modality from fusion (query = {:?})",
249 query
250 );
251 Ok(Vec::new())
252}
253
254fn execute_vector_search(
258 vector_store: Option<&crate::VectorStore>,
259 embedding: &[f32],
260 limit: usize,
261) -> Result<Vec<DocumentScore>> {
262 let Some(store) = vector_store else {
263 tracing::warn!(
264 "sparql_multimodal_search: vector modality requested but no VectorStore \
265 was supplied; excluding it from fusion"
266 );
267 return Ok(Vec::new());
268 };
269
270 let query_vector = crate::Vector::new(embedding.to_vec());
271 let results = store.similarity_search_vector(&query_vector, limit)?;
272
273 Ok(results
274 .into_iter()
275 .enumerate()
276 .map(|(rank, (uri, score))| DocumentScore {
277 doc_id: uri,
278 score,
279 rank,
280 })
281 .collect())
282}
283
284fn execute_spatial_search(wkt: &str, _limit: usize) -> Result<Vec<DocumentScore>> {
291 tracing::warn!(
292 "sparql_multimodal_search: no spatial search backend is wired up; \
293 excluding the spatial modality from fusion (query = {:?})",
294 wkt
295 );
296 Ok(Vec::new())
297}
298
299pub fn sparql_multimodal_search_from_args(
301 args: &[VectorServiceArg],
302 config: &MultimodalSearchConfig,
303 vector_store: Option<&crate::VectorStore>,
304) -> Result<VectorServiceResult> {
305 let mut text_query: Option<String> = None;
307 let vector_query: Option<String> = None;
308 let spatial_query: Option<String> = None;
309 let weights: Option<String> = None;
310 let strategy: Option<String> = None;
311 let mut limit: usize = 10;
312
313 for arg in args {
315 match arg {
316 VectorServiceArg::String(s) if text_query.is_none() => {
317 text_query = Some(s.clone());
318 }
319 VectorServiceArg::Number(n) => {
320 limit = *n as usize;
321 }
322 _ => {}
323 }
324 }
325
326 let results = sparql_multimodal_search(
328 MultimodalSearchQuery {
329 text_query,
330 vector_query,
331 spatial_query,
332 weights,
333 strategy,
334 },
335 limit,
336 config,
337 vector_store,
338 )?;
339
340 let similarity_list: Vec<(String, f32)> = results
342 .into_iter()
343 .map(|r| (r.uri, r.score as f32))
344 .collect();
345
346 Ok(VectorServiceResult::SimilarityList(similarity_list))
347}
348
349pub fn generate_multimodal_sparql_function() -> String {
351 r#"
352PREFIX vec: <http://oxirs.org/vec#>
353PREFIX geo: <http://www.opengis.net/ont/geosparql#>
354
355# Multimodal Search Function
356# Combines text, vector, and spatial search with intelligent fusion
357#
358# Usage:
359# SELECT ?entity ?score WHERE {
360# ?entity vec:multimodal_search(
361# text: "machine learning conference",
362# vector: "0.1,0.2,0.3,...",
363# spatial: "POINT(10.0 20.0)",
364# weights: "0.4,0.4,0.2",
365# strategy: "rankfusion",
366# limit: 10
367# ) .
368# BIND(vec:score(?entity) AS ?score)
369# }
370# ORDER BY DESC(?score)
371#
372# Parameters:
373# - text: Text/keyword query (optional)
374# - vector: Comma-separated embedding values (optional)
375# - spatial: WKT geometry string (optional)
376# - weights: Comma-separated weights [text, vector, spatial] (optional)
377# - strategy: Fusion strategy - "weighted", "sequential", "cascade", "rankfusion" (optional)
378# - limit: Maximum results (default: 10)
379#
380# Fusion Strategies:
381# - weighted: Linear combination of normalized scores
382# - sequential: Filter with one modality, rank with another
383# - cascade: Progressive filtering (fast → expensive)
384# - rankfusion: Reciprocal Rank Fusion (position-based)
385"#
386 .to_string()
387}
388
389#[cfg(test)]
390mod tests {
391 use super::*;
392 use anyhow::Result;
393
394 #[test]
395 fn test_parse_fusion_strategy_weighted() -> Result<()> {
396 let strategy = parse_fusion_strategy(
397 Some("weighted"),
398 Some("0.5,0.3,0.2"),
399 &[0.33, 0.33, 0.34],
400 &[0.5, 0.7, 0.8],
401 )?;
402
403 match strategy {
404 FusionStrategy::Weighted { weights } => {
405 assert_eq!(weights.len(), 3);
406 assert!((weights[0] - 0.5).abs() < 1e-6);
407 assert!((weights[1] - 0.3).abs() < 1e-6);
408 assert!((weights[2] - 0.2).abs() < 1e-6);
409 }
410 _ => panic!("Expected Weighted strategy"),
411 }
412 Ok(())
413 }
414
415 #[test]
416 fn test_parse_fusion_strategy_default() -> Result<()> {
417 let strategy = parse_fusion_strategy(None, None, &[0.33, 0.33, 0.34], &[0.5, 0.7, 0.8])?;
418
419 match strategy {
420 FusionStrategy::RankFusion => {}
421 _ => panic!("Expected RankFusion as default"),
422 }
423 Ok(())
424 }
425
426 #[test]
427 fn test_parse_normalization() -> Result<()> {
428 assert!(matches!(
429 parse_normalization("minmax")?,
430 NormalizationMethod::MinMax
431 ));
432 assert!(matches!(
433 parse_normalization("zscore")?,
434 NormalizationMethod::ZScore
435 ));
436 assert!(matches!(
437 parse_normalization("sigmoid")?,
438 NormalizationMethod::Sigmoid
439 ));
440 Ok(())
441 }
442
443 #[test]
444 fn test_parse_weights() -> Result<()> {
445 let weights = parse_weights("0.4, 0.35, 0.25")?;
446 assert_eq!(weights.len(), 3);
447 assert!((weights[0] - 0.4).abs() < 1e-6);
448 assert!((weights[1] - 0.35).abs() < 1e-6);
449 assert!((weights[2] - 0.25).abs() < 1e-6);
450 Ok(())
451 }
452
453 #[test]
454 fn test_parse_vector() -> Result<()> {
455 let vector = parse_vector("0.1, 0.2, 0.3")?;
456 assert_eq!(vector.len(), 3);
457 assert!((vector[0] - 0.1).abs() < 1e-6);
458 assert!((vector[1] - 0.2).abs() < 1e-6);
459 assert!((vector[2] - 0.3).abs() < 1e-6);
460 Ok(())
461 }
462
463 #[test]
464 fn test_multimodal_search_config_default() {
465 let config = MultimodalSearchConfig::default();
466 assert_eq!(config.default_weights.len(), 3);
467 assert_eq!(config.default_strategy, "rankfusion");
468 assert_eq!(config.normalization, "minmax");
469 assert_eq!(config.cascade_thresholds.len(), 3);
470 }
471
472 #[test]
473 fn test_sparql_multimodal_result_conversion() {
474 let mut fused = FusedResult::new("test_doc".to_string());
475 fused.add_score(Modality::Text, 0.5);
476 fused.add_score(Modality::Vector, 0.3);
477 fused.calculate_total();
478
479 let sparql_result: SparqlMultimodalResult = fused.into();
480
481 assert_eq!(sparql_result.uri, "test_doc");
482 assert!((sparql_result.score - 0.8).abs() < 1e-6);
483 assert_eq!(sparql_result.text_score, Some(0.5));
484 assert_eq!(sparql_result.vector_score, Some(0.3));
485 assert_eq!(sparql_result.spatial_score, None);
486 }
487
488 #[test]
493 fn test_execute_text_search_excludes_instead_of_fabricating() -> Result<()> {
494 let results = execute_text_search("test query", 10)?;
495 assert!(
496 results.is_empty(),
497 "text search has no real backend and must return no results, not fabricated ones"
498 );
499 Ok(())
500 }
501
502 #[test]
506 fn test_execute_vector_search_uses_real_store() -> Result<()> {
507 use crate::VectorStore;
508
509 let mut store = VectorStore::new();
510 store.index_vector("doc_a".to_string(), crate::Vector::new(vec![1.0, 0.0, 0.0]))?;
511 store.index_vector("doc_b".to_string(), crate::Vector::new(vec![0.0, 1.0, 0.0]))?;
512
513 let embedding = vec![1.0, 0.0, 0.0];
514 let results = execute_vector_search(Some(&store), &embedding, 10)?;
515
516 assert!(!results.is_empty());
517 assert_eq!(results[0].doc_id, "doc_a");
519 assert!(!results
520 .iter()
521 .any(|r| r.doc_id.starts_with("vector_result_")));
522 Ok(())
523 }
524
525 #[test]
527 fn test_execute_vector_search_excludes_without_store() -> Result<()> {
528 let embedding = vec![0.1, 0.2, 0.3];
529 let results = execute_vector_search(None, &embedding, 10)?;
530 assert!(results.is_empty());
531 Ok(())
532 }
533
534 #[test]
538 fn test_execute_spatial_search_excludes_instead_of_fabricating() -> Result<()> {
539 let results = execute_spatial_search("POINT(10.0 20.0)", 10)?;
540 assert!(
541 results.is_empty(),
542 "spatial search has no in-crate backend and must return no results"
543 );
544 Ok(())
545 }
546
547 #[test]
548 fn test_sparql_multimodal_search_integration() -> Result<()> {
549 use crate::VectorStore;
550
551 let config = MultimodalSearchConfig::default();
552 let mut store = VectorStore::new();
553 store.index_vector("doc_a".to_string(), crate::Vector::new(vec![0.1, 0.2, 0.3]))?;
554
555 let results = sparql_multimodal_search(
556 MultimodalSearchQuery {
557 text_query: Some("machine learning".to_string()),
558 vector_query: Some("0.1,0.2,0.3".to_string()),
559 spatial_query: Some("POINT(10.0 20.0)".to_string()),
560 weights: Some("0.4,0.4,0.2".to_string()),
561 strategy: Some("rankfusion".to_string()),
562 },
563 10,
564 &config,
565 Some(&store),
566 )?;
567
568 assert!(!results.is_empty());
572 assert!(results[0].score > 0.0);
573 assert_eq!(results[0].uri, "doc_a");
574 Ok(())
575 }
576
577 #[test]
578 fn test_generate_multimodal_sparql_function() {
579 let sparql = generate_multimodal_sparql_function();
580 assert!(sparql.contains("vec:multimodal_search"));
581 assert!(sparql.contains("text:"));
582 assert!(sparql.contains("vector:"));
583 assert!(sparql.contains("spatial:"));
584 }
585}