Skip to main content

oxirs_vec/sparql_integration/
multimodal_functions.rs

1//! SPARQL function bindings for multimodal search
2//!
3//! This module provides SPARQL integration for multimodal search fusion,
4//! allowing queries to combine text, vector, and spatial search modalities.
5
6use 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/// Multimodal search configuration
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct MultimodalSearchConfig {
17    /// Default weights for weighted fusion [text, vector, spatial]
18    pub default_weights: Vec<f64>,
19    /// Default fusion strategy
20    pub default_strategy: String,
21    /// Score normalization method
22    pub normalization: String,
23    /// Cascade thresholds [text, vector, spatial]
24    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/// Multimodal search result for SPARQL
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct SparqlMultimodalResult {
41    /// Resource URI
42    pub uri: String,
43    /// Combined score
44    pub score: f64,
45    /// Individual modality scores
46    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/// Query inputs for a multimodal search, grouped so that
68/// [`sparql_multimodal_search`] stays within clippy's argument-count limit.
69#[derive(Debug, Clone, Default)]
70pub struct MultimodalSearchQuery {
71    /// Optional text/keyword query string
72    pub text_query: Option<String>,
73    /// Optional vector embedding (comma-separated)
74    pub vector_query: Option<String>,
75    /// Optional WKT geometry (e.g., "POINT(10.0 20.0)")
76    pub spatial_query: Option<String>,
77    /// Optional weights \[text, vector, spatial\] (comma-separated)
78    pub weights: Option<String>,
79    /// Optional fusion strategy: "weighted", "sequential", "cascade", "rankfusion"
80    pub strategy: Option<String>,
81}
82
83/// Execute multimodal search with multiple modalities
84///
85/// # Arguments
86/// * `query` - Text/vector/spatial query inputs plus weighting/strategy, see
87///   [`MultimodalSearchQuery`]
88/// * `limit` - Maximum number of results
89/// * `config` - Multimodal search configuration
90/// * `vector_store` - Optional backing store for the vector modality
91///
92/// # Returns
93/// Vector of multimodal search results with combined scores
94///
95/// # Real backends
96/// * `vector_query` is served by a real [`crate::VectorStore::similarity_search_vector`]
97///   call when `vector_store` is `Some`; when it is `None` the vector modality
98///   is excluded (empty results, logged warning) rather than fabricated.
99/// * `text_query` and `spatial_query` currently have no in-process, stateless
100///   full-text or spatial search backend wired into this free function (see
101///   [`execute_text_search`] / [`execute_spatial_search`] docs), so they are
102///   always excluded with a logged warning instead of returning fake scores.
103pub 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    // Parse fusion strategy
118    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    // Parse normalization method
126    let normalization = parse_normalization(&config.normalization)?;
127
128    // Create fusion engine
129    let fusion_config = FusionConfig {
130        default_strategy: fusion_strategy.clone(),
131        score_normalization: normalization,
132    };
133    let fusion = MultimodalFusion::new(fusion_config);
134
135    // Execute individual searches
136    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    // Fuse results
156    let fused = fusion.fuse(
157        &text_results,
158        &vector_results,
159        &spatial_results,
160        Some(fusion_strategy),
161    )?;
162
163    // Convert to SPARQL results and limit
164    let results: Vec<SparqlMultimodalResult> = fused
165        .into_iter()
166        .take(limit)
167        .map(SparqlMultimodalResult::from)
168        .collect();
169
170    Ok(results)
171}
172
173/// Parse fusion strategy from string
174fn 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            // Default order: Text → Vector
191            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
203/// Parse normalization method from string
204fn 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
213/// Parse weights from comma-separated string
214fn 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
225/// Parse vector embedding from comma-separated string
226fn 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
237/// Execute text/keyword search.
238///
239/// This crate's full-text engine ([`crate::hybrid_search::tantivy_searcher::TantivySearcher`])
240/// requires a persistent, pre-built index handle (documents must have been
241/// indexed ahead of time) that this stateless free function has no way to
242/// obtain. Rather than fabricating fake results, the text modality is
243/// excluded (empty result set) with a logged warning until a real searcher
244/// handle is threaded through the public API.
245fn 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
254/// Execute vector/semantic search against the real [`crate::VectorStore`]
255/// when one is supplied; excludes the vector modality (with a logged
256/// warning) instead of fabricating results when no store is configured.
257fn 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
284/// Execute spatial/geographic search.
285///
286/// This crate has no in-process spatial index backend (GeoSPARQL/spatial
287/// indexing lives in the separate `oxirs-geosparql` crate, which is not a
288/// dependency here). Rather than fabricating fake results, the spatial
289/// modality is always excluded (empty result set) with a logged warning.
290fn 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
299/// Convert SPARQL arguments to multimodal search
300pub fn sparql_multimodal_search_from_args(
301    args: &[VectorServiceArg],
302    config: &MultimodalSearchConfig,
303    vector_store: Option<&crate::VectorStore>,
304) -> Result<VectorServiceResult> {
305    // Parse arguments
306    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    // Extract named arguments (simplified parsing)
314    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    // Execute search
327    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    // Convert to SPARQL result format
341    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
349/// Generate SPARQL function definition for multimodal search
350pub 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    /// Regression test for the P1 finding: `execute_text_search` used to
489    /// fabricate fake `DocumentScore`s regardless of what was indexed. It
490    /// must now exclude the (unimplemented) text modality instead of
491    /// inventing results.
492    #[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    /// Regression test: `execute_vector_search` must use the real
503    /// `VectorStore::similarity_search_vector` path and return actual
504    /// indexed results, not synthetic `vector_result_*` placeholders.
505    #[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        // The closest match to [1,0,0] must be doc_a, not a fabricated ID.
518        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    /// Without a store, the vector modality must be excluded, not fabricated.
526    #[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    /// Regression test for the P1 finding: `execute_spatial_search` used to
535    /// fabricate fake `DocumentScore`s. There is no in-crate spatial backend,
536    /// so it must exclude the modality instead.
537    #[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        // Only the real (vector) modality contributes; text/spatial are
569        // excluded rather than fabricated, so results come solely from the
570        // indexed vector store.
571        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}