diff --git a/src/indexes.rs b/src/indexes.rs index 05c728f1..3de03556 100644 --- a/src/indexes.rs +++ b/src/indexes.rs @@ -4,6 +4,7 @@ use crate::{ errors::{Error, MeilisearchCommunicationError, MeilisearchError, MEILISEARCH_VERSION_HINT}, request::*, search::*, + similar::*, task_info::TaskInfo, tasks::*, DefaultHttpClient, @@ -1622,6 +1623,50 @@ impl Index { } Ok(task) } + + /// Get similar documents in the index. + /// + /// # Example + /// + /// ``` + /// # use serde::{Serialize, Deserialize}; + /// # use meilisearch_sdk::{client::*, indexes::*, similar::*}; + /// # + /// # let MEILISEARCH_URL = option_env!("MEILISEARCH_URL").unwrap_or("http://localhost:7700"); + /// # let MEILISEARCH_API_KEY = option_env!("MEILISEARCH_API_KEY").unwrap_or("masterKey"); + /// # + /// #[derive(Serialize, Deserialize, Debug)] + /// struct Movie { + /// name: String, + /// description: String, + /// } + /// # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { + /// # let client = Client::new(MEILISEARCH_URL, Some(MEILISEARCH_API_KEY)).unwrap(); + /// let movies = client.index("execute_query"); + /// + /// // add some documents + /// # movies.add_or_replace(&[Movie{name:String::from("Interstellar"), description:String::from("Interstellar chronicles the adventures of a group of explorers who make use of a newly discovered wormhole to surpass the limitations on human space travel and conquer the vast distances involved in an interstellar voyage.")},Movie{name:String::from("Unknown"), description:String::from("Unknown")}], Some("name")).await.unwrap().wait_for_completion(&client, None, None).await.unwrap(); + /// + /// let query = SimilarQuery::new(&movies, "1", "default").build(); + /// let results = movies.similar_query::(&query).await.unwrap(); + /// + /// assert!(results.hits.len() > 0); + /// # movies.delete().await.unwrap().wait_for_completion(&client, None, None).await.unwrap(); + /// # }); + /// ``` + pub async fn similar_query( + &self, + body: &SimilarQuery<'_, Http>, + ) -> Result, Error> { + self.client + .http_client + .request::<(), &SimilarQuery, SimilarResults>( + &format!("{}/indexes/{}/similar", self.client.host, self.uid), + Method::Post { body, query: () }, + 200, + ) + .await + } } impl AsRef for Index { diff --git a/src/lib.rs b/src/lib.rs index c0e4a74a..12fd1d70 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -261,6 +261,9 @@ mod tenant_tokens; /// Module containing utilizes functions. mod utils; +/// Module related to similar queries and results. +pub mod similar; + #[cfg(feature = "reqwest")] pub mod reqwest; diff --git a/src/search.rs b/src/search.rs index abc4befc..b197e524 100644 --- a/src/search.rs +++ b/src/search.rs @@ -107,7 +107,7 @@ pub struct SearchResults { pub index_uid: Option, } -fn serialize_with_wildcard( +pub(crate) fn serialize_with_wildcard( data: &Option>, s: S, ) -> Result { diff --git a/src/similar.rs b/src/similar.rs new file mode 100644 index 00000000..fed58ef1 --- /dev/null +++ b/src/similar.rs @@ -0,0 +1,430 @@ +use crate::{ + errors::Error, + indexes::Index, + request::HttpClient, + search::{serialize_with_wildcard, Filter, Selectors}, +}; +use either::Either; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use serde_json::{Map, Value}; + +/// A single result. +#[derive(Deserialize, Debug, Clone)] +pub struct SimilarResult { + /// The full result. + #[serde(flatten)] + pub result: T, + /// The relevancy score of the match. + #[serde(rename = "_rankingScore")] + pub ranking_score: Option, + #[serde(rename = "_rankingScoreDetails")] + pub ranking_score_details: Option>, +} + +#[derive(Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +/// A struct containing search results and other information about the search. +pub struct SimilarResults { + /// Results of the query. + pub hits: Vec>, + /// Number of documents skipped. + pub offset: Option, + /// Number of results returned. + pub limit: Option, + /// Estimated total number of matches. + pub estimated_total_hits: Option, + /// Processing time of the query. + pub processing_time_ms: usize, + /// Search Doc ID + pub id: String, +} + +/// A struct representing a query. +/// +/// You can add similar parameters using the builder syntax. +/// +/// See [this page](https://www.meilisearch.com/docs/reference/api/similar#get-similar-documents-with-post) for the official list and description of all parameters. +/// +/// # Examples +/// +/// ``` +/// # use serde::{Serialize, Deserialize}; +/// # use meilisearch_sdk::{client::Client, search::*, indexes::Index}; +/// # +/// # let MEILISEARCH_URL = option_env!("MEILISEARCH_URL").unwrap_or("http://localhost:7700"); +/// # let MEILISEARCH_API_KEY = option_env!("MEILISEARCH_API_KEY").unwrap_or("masterKey"); +/// # +/// #[derive(Serialize, Deserialize, Debug)] +/// struct Movie { +/// name: String, +/// description: String, +/// } +/// # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { +/// # let client = Client::new(MEILISEARCH_URL, Some(MEILISEARCH_API_KEY)).unwrap(); +/// # let index = client +/// # .create_index("similar_query_builder", None) +/// # .await +/// # .unwrap() +/// # .wait_for_completion(&client, None, None) +/// # .await.unwrap() +/// # .try_make_index(&client) +/// # .unwrap(); +/// +/// let mut res = SimilarQuery::new(&index, "100", "default") +/// .execute::() +/// .await +/// .unwrap(); +/// +/// # index.delete().await.unwrap().wait_for_completion(&client, None, None).await.unwrap(); +/// # }); +/// ``` +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SimilarQuery<'a, Http: HttpClient> { + #[serde(skip_serializing)] + index: &'a Index, + /// Document id + pub id: &'a str, + /// embedder name + pub embedder: &'a str, + /// The number of documents to skip. + /// If the value of the parameter `offset` is `n`, the `n` first documents (ordered by relevance) will not be returned. + /// This is helpful for pagination. + /// + /// Example: If you want to skip the first document, set offset to `1`. + #[serde(skip_serializing_if = "Option::is_none")] + pub offset: Option, + /// The maximum number of documents returned. + /// + /// If the value of the parameter `limit` is `n`, there will never be more than `n` documents in the response. + /// This is helpful for pagination. + /// + /// Example: If you don't want to get more than two documents, set limit to `2`. + /// + /// **Default: `20`** + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + /// Filter applied to documents. + /// + /// Read the [dedicated guide](https://www.meilisearch.com/docs/learn/advanced/filtering) to learn the syntax. + #[serde(skip_serializing_if = "Option::is_none")] + pub filter: Option>, + /// Attributes to display in the returned documents. + /// + /// Can be set to a [wildcard value](enum.Selectors.html#variant.All) that will select all existing attributes. + /// + /// **Default: all attributes found in the documents.** + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(serialize_with = "serialize_with_wildcard")] + pub attributes_to_retrieve: Option>, + + /// Defines whether to show the relevancy score of the match. + /// + /// **Default: `false`** + #[serde(skip_serializing_if = "Option::is_none")] + pub show_ranking_score: Option, + + ///Adds a detailed global ranking score field to each document. + /// + /// **Default: `false`** + #[serde(skip_serializing_if = "Option::is_none")] + pub show_ranking_score_details: Option, + + ///Excludes results below the specified ranking score. + #[serde(skip_serializing_if = "Option::is_none")] + pub ranking_score_threshold: Option, + + /// **Default: `false`** + #[serde(skip_serializing_if = "Option::is_none")] + pub retrieve_vectors: Option, +} + +#[allow(missing_docs)] +impl<'a, Http: HttpClient> SimilarQuery<'a, Http> { + #[must_use] + pub fn new(index: &'a Index, id: &'a str, embedder: &'a str) -> SimilarQuery<'a, Http> { + SimilarQuery { + index, + id, + embedder, + offset: None, + limit: None, + filter: None, + attributes_to_retrieve: None, + show_ranking_score: None, + show_ranking_score_details: None, + ranking_score_threshold: None, + retrieve_vectors: None, + } + } + + pub fn with_offset<'b>(&'b mut self, offset: usize) -> &'b mut SimilarQuery<'a, Http> { + self.offset = Some(offset); + self + } + pub fn with_limit<'b>(&'b mut self, limit: usize) -> &'b mut SimilarQuery<'a, Http> { + self.limit = Some(limit); + self + } + pub fn with_filter<'b>(&'b mut self, filter: &'a str) -> &'b mut SimilarQuery<'a, Http> { + self.filter = Some(Filter::new(Either::Left(filter))); + self + } + pub fn with_array_filter<'b>( + &'b mut self, + filter: Vec<&'a str>, + ) -> &'b mut SimilarQuery<'a, Http> { + self.filter = Some(Filter::new(Either::Right(filter))); + self + } + pub fn with_attributes_to_retrieve<'b>( + &'b mut self, + attributes_to_retrieve: Selectors<&'a [&'a str]>, + ) -> &'b mut SimilarQuery<'a, Http> { + self.attributes_to_retrieve = Some(attributes_to_retrieve); + self + } + + pub fn with_show_ranking_score<'b>( + &'b mut self, + show_ranking_score: bool, + ) -> &'b mut SimilarQuery<'a, Http> { + self.show_ranking_score = Some(show_ranking_score); + self + } + + pub fn with_show_ranking_score_details<'b>( + &'b mut self, + show_ranking_score_details: bool, + ) -> &'b mut SimilarQuery<'a, Http> { + self.show_ranking_score_details = Some(show_ranking_score_details); + self + } + + pub fn with_ranking_score_threshold<'b>( + &'b mut self, + ranking_score_threshold: f64, + ) -> &'b mut SimilarQuery<'a, Http> { + self.ranking_score_threshold = Some(ranking_score_threshold); + self + } + pub fn build(&mut self) -> SimilarQuery<'a, Http> { + self.clone() + } + /// Execute the query and fetch the results. + pub async fn execute( + &'a self, + ) -> Result, Error> { + self.index.similar_query::(self).await + } +} + +// TODO: set UserProvided EembdderConfig +// Embedder have not been implemented +// But Now It does't work +// #[cfg(test)] +// mod tests { +// use std::vec; + +// use super::*; +// use crate::{client::*, search::*}; +// use meilisearch_test_macro::meilisearch_test; +// use serde::{Deserialize, Serialize}; +// use std::collections::HashMap; + +// #[derive(Debug, Serialize, Deserialize, PartialEq)] +// struct Nested { +// child: String, +// } + +// #[derive(Debug, Serialize, Deserialize, PartialEq)] +// struct Document { +// id: usize, +// title: String, +// _vectors: HashMap>, +// } + +// async fn setup_test_vector_index(client: &Client, index: &Index) -> Result<(), Error> { +// let v = vec![0.5, 0.5]; +// let mut vectors = HashMap::new(); + +// vectors.insert("default".to_string(), v.clone()); + +// let t0 = index +// .add_documents( +// &[ +// Document { +// id: 0, +// title: "text".into(), +// _vectors: vectors.clone(), +// }, +// Document { +// id: 1, +// title: "text".into(), +// _vectors: vectors.clone(), +// }, +// Document { +// id: 2, +// title: "title".into(), +// _vectors: vectors.clone(), +// }, +// Document { +// id: 3, +// title: "title".into(), +// _vectors: vectors.clone(), +// }, +// Document { +// id: 4, +// title: "title".into(), +// _vectors: vectors.clone(), +// }, +// Document { +// id: 5, +// title: "title".into(), +// _vectors: vectors.clone(), +// }, +// Document { +// id: 6, +// title: "title".into(), +// _vectors: vectors.clone(), +// }, +// Document { +// id: 7, +// title: "title".into(), +// _vectors: vectors.clone(), +// }, +// Document { +// id: 8, +// title: "title".into(), +// _vectors: vectors.clone(), +// }, +// Document { +// id: 9, +// title: "title".into(), +// _vectors: vectors.clone(), +// }, +// ], +// None, +// ) +// .await?; + +// let t1 = index.set_filterable_attributes(["title"]).await?; +// t1.wait_for_completion(client, None, None).await?; +// t0.wait_for_completion(client, None, None).await?; +// Ok(()) +// } + +// #[meilisearch_test] +// async fn test_similar_builder(_client: Client, index: Index) -> Result<(), Error> { +// let mut query = SimilarQuery::new(&index, "1", "default"); +// query.with_offset(1).with_limit(1); + +// Ok(()) +// } + +// #[meilisearch_test] +// async fn test_query_limit(client: Client, index: Index) -> Result<(), Error> { +// setup_test_vector_index(&client, &index).await?; + +// let mut query = SimilarQuery::new(&index, "1", "default"); +// query.with_limit(5); + +// let results: SimilarResults = query.execute().await?; +// assert_eq!(results.hits.len(), 5); +// Ok(()) +// } + +// #[meilisearch_test] +// async fn test_query_offset(client: Client, index: Index) -> Result<(), Error> { +// setup_test_vector_index(&client, &index).await?; +// let mut query = SimilarQuery::new(&index, "1", "default"); +// query.with_offset(6); + +// let results: SimilarResults = query.execute().await?; +// assert_eq!(results.hits.len(), 3); +// Ok(()) +// } + +// #[meilisearch_test] +// async fn test_query_filter(client: Client, index: Index) -> Result<(), Error> { +// setup_test_vector_index(&client, &index).await?; + +// let mut query = SimilarQuery::new(&index, "1", "default"); + +// let results: SimilarResults = +// query.with_filter("title = \"title\"").execute().await?; +// assert_eq!(results.hits.len(), 8); + +// let results: SimilarResults = +// query.with_filter("NOT title = \"title\"").execute().await?; +// assert_eq!(results.hits.len(), 2); +// Ok(()) +// } + +// #[meilisearch_test] +// async fn test_query_filter_with_array(client: Client, index: Index) -> Result<(), Error> { +// setup_test_vector_index(&client, &index).await?; +// let mut query = SimilarQuery::new(&index, "1", "default"); +// let results: SimilarResults = query +// .with_array_filter(vec!["title = \"title\"", "title = \"text\""]) +// .execute() +// .await?; +// assert_eq!(results.hits.len(), 10); + +// Ok(()) +// } + +// #[meilisearch_test] +// async fn test_query_attributes_to_retrieve(client: Client, index: Index) -> Result<(), Error> { +// setup_test_vector_index(&client, &index).await?; +// let mut query = SimilarQuery::new(&index, "1", "default"); +// let results: SimilarResults = query +// .with_attributes_to_retrieve(Selectors::All) +// .execute() +// .await?; +// assert_eq!(results.hits.len(), 10); + +// let mut query = SimilarQuery::new(&index, "1", "default"); +// query.with_attributes_to_retrieve(Selectors::Some(&["title", "id"])); // omit the "value" field +// assert!(query.execute::().await.is_err()); // error: missing "value" field +// Ok(()) +// } + +// #[meilisearch_test] +// async fn test_query_show_ranking_score(client: Client, index: Index) -> Result<(), Error> { +// setup_test_vector_index(&client, &index).await?; + +// let mut query = SimilarQuery::new(&index, "1", "default"); +// query.with_show_ranking_score(true); +// let results: SimilarResults = query.execute().await?; +// assert!(results.hits[0].ranking_score.is_some()); +// Ok(()) +// } + +// #[meilisearch_test] +// async fn test_query_show_ranking_score_details( +// client: Client, +// index: Index, +// ) -> Result<(), Error> { +// setup_test_vector_index(&client, &index).await?; + +// let mut query = SimilarQuery::new(&index, "1", "default"); +// query.with_show_ranking_score_details(true); +// let results: SimilarResults = query.execute().await?; +// assert!(results.hits[0].ranking_score_details.is_some()); +// Ok(()) +// } + +// #[meilisearch_test] +// async fn test_query_show_ranking_score_threshold( +// client: Client, +// index: Index, +// ) -> Result<(), Error> { +// setup_test_vector_index(&client, &index).await?; +// let mut query = SimilarQuery::new(&index, "1", "default"); +// query.with_ranking_score_threshold(1.0); +// let results: SimilarResults = query.execute().await?; +// assert!(results.hits.is_empty()); +// Ok(()) +// } +// }