diff --git a/.code-samples.meilisearch.yaml b/.code-samples.meilisearch.yaml index 48c194a2..246c7774 100644 --- a/.code-samples.meilisearch.yaml +++ b/.code-samples.meilisearch.yaml @@ -1941,6 +1941,20 @@ search_parameter_reference_retrieve_vectors_1: |- .execute() .await .unwrap(); +search_parameter_reference_media_1: |- + let results = index + .search() + .with_hybrid("EMBEDDER_NAME", 0.5) + .with_media(json!({ + "FIELD_A": "VALUE_A", + "FIELD_B": { + "FIELD_C": "VALUE_B", + "FIELD_D": "VALUE_C" + } + })) + .execute() + .await + .unwrap(); update_embedders_1: |- let embedders = HashMap::from([( String::from("default"), diff --git a/src/features.rs b/src/features.rs index d6ee7e34..9cce26b7 100644 --- a/src/features.rs +++ b/src/features.rs @@ -14,6 +14,8 @@ pub struct ExperimentalFeaturesResult { pub contains_filter: bool, pub network: bool, pub edit_documents_by_function: bool, + #[serde(default)] + pub multimodal: bool, } /// Struct representing the experimental features request. @@ -45,6 +47,8 @@ pub struct ExperimentalFeatures<'a, Http: HttpClient> { pub network: Option, #[serde(skip_serializing_if = "Option::is_none")] pub edit_documents_by_function: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub multimodal: Option, } impl<'a, Http: HttpClient> ExperimentalFeatures<'a, Http> { @@ -57,6 +61,7 @@ impl<'a, Http: HttpClient> ExperimentalFeatures<'a, Http> { network: None, contains_filter: None, edit_documents_by_function: None, + multimodal: None, } } @@ -140,6 +145,11 @@ impl<'a, Http: HttpClient> ExperimentalFeatures<'a, Http> { self.network = Some(network); self } + + pub fn set_multimodal(&mut self, multimodal: bool) -> &mut Self { + self.multimodal = Some(multimodal); + self + } } #[cfg(test)] @@ -155,6 +165,7 @@ mod tests { features.set_contains_filter(true); features.set_network(true); features.set_edit_documents_by_function(true); + features.set_multimodal(true); let _ = features.update().await.unwrap(); let res = features.get().await.unwrap(); @@ -163,5 +174,6 @@ mod tests { assert!(res.contains_filter); assert!(res.network); assert!(res.edit_documents_by_function); + assert!(res.multimodal); } } diff --git a/src/search.rs b/src/search.rs index 084a0d3f..34a4a43f 100644 --- a/src/search.rs +++ b/src/search.rs @@ -408,6 +408,10 @@ pub struct SearchQuery<'a, Http: HttpClient> { #[serde(skip_serializing_if = "Option::is_none")] pub retrieve_vectors: Option, + /// Provides multimodal data for search queries. + #[serde(skip_serializing_if = "Option::is_none")] + pub media: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub(crate) federation_options: Option, } @@ -449,6 +453,7 @@ impl<'a, Http: HttpClient> SearchQuery<'a, Http> { hybrid: None, vector: None, retrieve_vectors: None, + media: None, distinct: None, ranking_score_threshold: None, locales: None, @@ -695,6 +700,12 @@ impl<'a, Http: HttpClient> SearchQuery<'a, Http> { self } + /// Attach media fragments to the search query. + pub fn with_media<'b>(&'b mut self, media: Value) -> &'b mut SearchQuery<'a, Http> { + self.media = Some(media); + self + } + pub fn with_distinct<'b>(&'b mut self, distinct: &'a str) -> &'b mut SearchQuery<'a, Http> { self.distinct = Some(distinct); self @@ -1096,6 +1107,34 @@ pub(crate) mod tests { use serde::{Deserialize, Serialize}; use serde_json::{json, Map, Value}; + #[test] + fn search_query_serializes_media_parameter() { + let client = Client::new("http://localhost:7700", Some("masterKey")).unwrap(); + let index = client.index("media_query"); + let mut query = SearchQuery::new(&index); + + query.with_query("example").with_media(json!({ + "FIELD_A": "VALUE_A", + "FIELD_B": { + "FIELD_C": "VALUE_B", + "FIELD_D": "VALUE_C" + } + })); + + let serialized = serde_json::to_value(&query.build()).unwrap(); + + assert_eq!( + serialized.get("media"), + Some(&json!({ + "FIELD_A": "VALUE_A", + "FIELD_B": { + "FIELD_C": "VALUE_B", + "FIELD_D": "VALUE_C" + } + })) + ); + } + #[derive(Debug, Serialize, Deserialize, PartialEq)] pub struct Nested { child: String, diff --git a/src/settings.rs b/src/settings.rs index ba1c05e2..84262a18 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -146,6 +146,20 @@ pub struct Embedder { /// Configures embedder to vectorize search queries (composite embedders only) #[serde(skip_serializing_if = "Option::is_none")] pub search_embedder: Option>, + + /// Configures multimodal embedding generation at indexing time. + #[serde(skip_serializing_if = "Option::is_none")] + pub indexing_fragments: Option>, + + /// Configures incoming media fragments for multimodal search queries. + #[serde(skip_serializing_if = "Option::is_none")] + pub search_fragments: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +pub struct EmbedderFragment { + pub value: serde_json::Value, } #[derive(Serialize, Deserialize, Default, Debug, Clone, Eq, PartialEq)] @@ -2798,6 +2812,7 @@ mod tests { use crate::client::*; use meilisearch_test_macro::meilisearch_test; + use serde_json::json; #[meilisearch_test] async fn test_set_faceting_settings(client: Client, index: Index) { @@ -3139,6 +3154,92 @@ mod tests { assert_eq!(embedders, res); } + #[test] + fn embedder_with_fragments_serializes() { + let embedder = Embedder { + source: EmbedderSource::Rest, + url: Some(String::from("https://example.com/embeddings")), + indexing_fragments: Some(HashMap::from([( + String::from("default"), + EmbedderFragment { + value: json!({ + "content": [ + { "type": "text", "text": "{{ doc.description }}" } + ] + }), + }, + )])), + search_fragments: Some(HashMap::from([( + String::from("default"), + EmbedderFragment { + value: json!({ + "content": [ + { "type": "text", "text": "{{ query.q }}" } + ] + }), + }, + )])), + request: Some(json!({ + "input": [ + "{{fragment}}", + "{{..}}" + ], + "model": "example-model" + })), + response: Some(json!({ + "data": [ + { + "embedding": "{{embedding}}" + }, + "{{..}}" + ] + })), + ..Default::default() + }; + + let serialized = serde_json::to_value(&embedder).unwrap(); + + assert_eq!( + serialized + .get("indexingFragments") + .and_then(|value| value.get("default")) + .and_then(|value| value.get("value")) + .and_then(|value| value.get("content")) + .and_then(|value| value.get(0)) + .and_then(|value| value.get("text")), + Some(&json!("{{ doc.description }}")) + ); + + assert_eq!( + serialized + .get("searchFragments") + .and_then(|value| value.get("default")) + .and_then(|value| value.get("value")) + .and_then(|value| value.get("content")) + .and_then(|value| value.get(0)) + .and_then(|value| value.get("text")), + Some(&json!("{{ query.q }}")) + ); + + assert_eq!( + serialized.get("request"), + Some(&json!({ + "input": ["{{fragment}}", "{{..}}"], + "model": "example-model" + })) + ); + + assert_eq!( + serialized.get("response"), + Some(&json!({ + "data": [ + { "embedding": "{{embedding}}" }, + "{{..}}" + ] + })) + ); + } + #[meilisearch_test] async fn test_reset_proximity_precision(index: Index) { let expected = "byWord".to_string();