feat: add personalization parameters to /search route

- Add Personalization struct with personalized boolean and user_profile string
- Add personalizationPersonalized and personalizationUserProfile query parameters to SearchQueryGet
- Follow same pattern as hybrid parameters (hybridEmbedder, hybridSemanticRatio)
- Add validation: personalizationUserProfile requires personalizationPersonalized
- Add error codes for personalization parameters
- Update analytics and facet search to handle new personalization field
- Remove serde dependencies from Personalization struct, use Deserr only
This commit is contained in:
ManyTheFish
2025-07-22 11:57:55 +02:00
parent a6eb5ec9b0
commit 2b2bbad3f8
6 changed files with 76 additions and 5 deletions

View File

@ -310,6 +310,9 @@ InvalidSearchShowRankingScoreDetails , InvalidRequest , BAD_REQU
InvalidSimilarShowRankingScoreDetails , InvalidRequest , BAD_REQUEST ; InvalidSimilarShowRankingScoreDetails , InvalidRequest , BAD_REQUEST ;
InvalidSearchSort , InvalidRequest , BAD_REQUEST ; InvalidSearchSort , InvalidRequest , BAD_REQUEST ;
InvalidSearchDistinct , InvalidRequest , BAD_REQUEST ; InvalidSearchDistinct , InvalidRequest , BAD_REQUEST ;
InvalidSearchPersonalization , InvalidRequest , BAD_REQUEST ;
InvalidSearchPersonalizationPersonalized , InvalidRequest , BAD_REQUEST ;
InvalidSearchPersonalizationUserProfile , InvalidRequest , BAD_REQUEST ;
InvalidSearchMediaAndVector , InvalidRequest , BAD_REQUEST ; InvalidSearchMediaAndVector , InvalidRequest , BAD_REQUEST ;
InvalidSettingsDisplayedAttributes , InvalidRequest , BAD_REQUEST ; InvalidSettingsDisplayedAttributes , InvalidRequest , BAD_REQUEST ;
InvalidSettingsDistinctAttribute , InvalidRequest , BAD_REQUEST ; InvalidSettingsDistinctAttribute , InvalidRequest , BAD_REQUEST ;
@ -650,6 +653,24 @@ impl fmt::Display for deserr_codes::InvalidNetworkSearchApiKey {
} }
} }
impl fmt::Display for deserr_codes::InvalidSearchPersonalization {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "the value of `personalization` is invalid, expected a JSON object with `personalized` boolean and optional `userProfile` string.")
}
}
impl fmt::Display for deserr_codes::InvalidSearchPersonalizationPersonalized {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "the value of `personalized` is invalid, expected a boolean.")
}
}
impl fmt::Display for deserr_codes::InvalidSearchPersonalizationUserProfile {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "the value of `userProfile` is invalid, expected a string.")
}
}
#[macro_export] #[macro_export]
macro_rules! internal_error { macro_rules! internal_error {
($target:ty : $($other:path), *) => { ($target:ty : $($other:path), *) => {

View File

@ -343,6 +343,7 @@ impl From<FacetSearchQuery> for SearchQuery {
hybrid, hybrid,
ranking_score_threshold, ranking_score_threshold,
locales, locales,
personalization: None,
} }
} }
} }

View File

@ -22,10 +22,10 @@ use crate::extractors::sequential_extractor::SeqHandler;
use crate::metrics::MEILISEARCH_DEGRADED_SEARCH_REQUESTS; use crate::metrics::MEILISEARCH_DEGRADED_SEARCH_REQUESTS;
use crate::routes::indexes::search_analytics::{SearchAggregator, SearchGET, SearchPOST}; use crate::routes::indexes::search_analytics::{SearchAggregator, SearchGET, SearchPOST};
use crate::search::{ use crate::search::{
add_search_rules, perform_search, HybridQuery, MatchingStrategy, RankingScoreThreshold, add_search_rules, perform_search, HybridQuery, MatchingStrategy, Personalization,
RetrieveVectors, SearchKind, SearchQuery, SearchResult, SemanticRatio, DEFAULT_CROP_LENGTH, RankingScoreThreshold, RetrieveVectors, SearchKind, SearchQuery, SearchResult, SemanticRatio,
DEFAULT_CROP_MARKER, DEFAULT_HIGHLIGHT_POST_TAG, DEFAULT_HIGHLIGHT_PRE_TAG, DEFAULT_CROP_LENGTH, DEFAULT_CROP_MARKER, DEFAULT_HIGHLIGHT_POST_TAG,
DEFAULT_SEARCH_LIMIT, DEFAULT_SEARCH_OFFSET, DEFAULT_SEMANTIC_RATIO, DEFAULT_HIGHLIGHT_PRE_TAG, DEFAULT_SEARCH_LIMIT, DEFAULT_SEARCH_OFFSET, DEFAULT_SEMANTIC_RATIO,
}; };
use crate::search_queue::SearchQueue; use crate::search_queue::SearchQueue;
@ -132,6 +132,11 @@ pub struct SearchQueryGet {
#[deserr(default, error = DeserrQueryParamError<InvalidSearchLocales>)] #[deserr(default, error = DeserrQueryParamError<InvalidSearchLocales>)]
#[param(value_type = Vec<Locale>, explode = false)] #[param(value_type = Vec<Locale>, explode = false)]
pub locales: Option<CS<Locale>>, pub locales: Option<CS<Locale>>,
#[deserr(default, error = DeserrQueryParamError<InvalidSearchPersonalizationPersonalized>)]
#[param(value_type = bool)]
pub personalization_personalized: Option<bool>,
#[deserr(default, error = DeserrQueryParamError<InvalidSearchPersonalizationUserProfile>)]
pub personalization_user_profile: Option<String>,
} }
#[derive(Debug, Clone, Copy, PartialEq, deserr::Deserr)] #[derive(Debug, Clone, Copy, PartialEq, deserr::Deserr)]
@ -203,6 +208,22 @@ impl TryFrom<SearchQueryGet> for SearchQuery {
)); ));
} }
let personalization = match (
other.personalization_personalized,
other.personalization_user_profile,
) {
(None, None) => None,
(Some(personalized), user_profile) => {
Some(Personalization { personalized, user_profile })
}
(None, Some(_)) => {
return Err(ResponseError::from_msg(
"`personalizationPersonalized` is mandatory when `personalizationUserProfile` is present".into(),
meilisearch_types::error::Code::InvalidSearchPersonalization,
));
}
};
Ok(Self { Ok(Self {
q: other.q, q: other.q,
// `media` not supported for `GET` // `media` not supported for `GET`
@ -232,6 +253,7 @@ impl TryFrom<SearchQueryGet> for SearchQuery {
hybrid, hybrid,
ranking_score_threshold: other.ranking_score_threshold.map(|o| o.0), ranking_score_threshold: other.ranking_score_threshold.map(|o| o.0),
locales: other.locales.map(|o| o.into_iter().collect()), locales: other.locales.map(|o| o.into_iter().collect()),
personalization,
}) })
} }
} }

View File

@ -128,6 +128,7 @@ impl<Method: AggregateMethod> SearchAggregator<Method> {
hybrid, hybrid,
ranking_score_threshold, ranking_score_threshold,
locales, locales,
personalization: _,
} = query; } = query;
let mut ret = Self::default(); let mut ret = Self::default();

View File

@ -67,6 +67,7 @@ impl MultiSearchAggregator {
hybrid: _, hybrid: _,
ranking_score_threshold: _, ranking_score_threshold: _,
locales: _, locales: _,
personalization: _,
} in &federated_search.queries } in &federated_search.queries
{ {
if let Some(federation_options) = federation_options { if let Some(federation_options) = federation_options {

View File

@ -57,6 +57,15 @@ pub const DEFAULT_HIGHLIGHT_PRE_TAG: fn() -> String = || "<em>".to_string();
pub const DEFAULT_HIGHLIGHT_POST_TAG: fn() -> String = || "</em>".to_string(); pub const DEFAULT_HIGHLIGHT_POST_TAG: fn() -> String = || "</em>".to_string();
pub const DEFAULT_SEMANTIC_RATIO: fn() -> SemanticRatio = || SemanticRatio(0.5); pub const DEFAULT_SEMANTIC_RATIO: fn() -> SemanticRatio = || SemanticRatio(0.5);
#[derive(Clone, Default, PartialEq, Deserr, ToSchema, Debug)]
#[deserr(error = DeserrJsonError<InvalidSearchPersonalization>, rename_all = camelCase, deny_unknown_fields)]
pub struct Personalization {
#[deserr(default, error = DeserrJsonError<InvalidSearchPersonalizationPersonalized>)]
pub personalized: bool,
#[deserr(default, error = DeserrJsonError<InvalidSearchPersonalizationUserProfile>)]
pub user_profile: Option<String>,
}
#[derive(Clone, Default, PartialEq, Deserr, ToSchema)] #[derive(Clone, Default, PartialEq, Deserr, ToSchema)]
#[deserr(error = DeserrJsonError, rename_all = camelCase, deny_unknown_fields)] #[deserr(error = DeserrJsonError, rename_all = camelCase, deny_unknown_fields)]
pub struct SearchQuery { pub struct SearchQuery {
@ -120,6 +129,8 @@ pub struct SearchQuery {
pub ranking_score_threshold: Option<RankingScoreThreshold>, pub ranking_score_threshold: Option<RankingScoreThreshold>,
#[deserr(default, error = DeserrJsonError<InvalidSearchLocales>)] #[deserr(default, error = DeserrJsonError<InvalidSearchLocales>)]
pub locales: Option<Vec<Locale>>, pub locales: Option<Vec<Locale>>,
#[deserr(default, error = DeserrJsonError<InvalidSearchPersonalization>, default)]
pub personalization: Option<Personalization>,
} }
impl From<SearchParameters> for SearchQuery { impl From<SearchParameters> for SearchQuery {
@ -167,6 +178,7 @@ impl From<SearchParameters> for SearchQuery {
highlight_post_tag: DEFAULT_HIGHLIGHT_POST_TAG(), highlight_post_tag: DEFAULT_HIGHLIGHT_POST_TAG(),
crop_marker: DEFAULT_CROP_MARKER(), crop_marker: DEFAULT_CROP_MARKER(),
locales: None, locales: None,
personalization: None,
} }
} }
} }
@ -248,6 +260,7 @@ impl fmt::Debug for SearchQuery {
attributes_to_search_on, attributes_to_search_on,
ranking_score_threshold, ranking_score_threshold,
locales, locales,
personalization,
} = self; } = self;
let mut debug = f.debug_struct("SearchQuery"); let mut debug = f.debug_struct("SearchQuery");
@ -336,6 +349,10 @@ impl fmt::Debug for SearchQuery {
debug.field("locales", &locales); debug.field("locales", &locales);
} }
if let Some(personalization) = personalization {
debug.field("personalization", &personalization);
}
debug.finish() debug.finish()
} }
} }
@ -541,6 +558,9 @@ pub struct SearchQueryWithIndex {
pub ranking_score_threshold: Option<RankingScoreThreshold>, pub ranking_score_threshold: Option<RankingScoreThreshold>,
#[deserr(default, error = DeserrJsonError<InvalidSearchLocales>, default)] #[deserr(default, error = DeserrJsonError<InvalidSearchLocales>, default)]
pub locales: Option<Vec<Locale>>, pub locales: Option<Vec<Locale>>,
#[deserr(default, error = DeserrJsonError<InvalidSearchPersonalization>, default)]
#[serde(skip)]
pub personalization: Option<Personalization>,
#[deserr(default)] #[deserr(default)]
pub federation_options: Option<FederationOptions>, pub federation_options: Option<FederationOptions>,
@ -598,6 +618,7 @@ impl SearchQueryWithIndex {
attributes_to_search_on, attributes_to_search_on,
ranking_score_threshold, ranking_score_threshold,
locales, locales,
personalization,
} = query; } = query;
SearchQueryWithIndex { SearchQueryWithIndex {
@ -629,6 +650,7 @@ impl SearchQueryWithIndex {
attributes_to_search_on, attributes_to_search_on,
ranking_score_threshold, ranking_score_threshold,
locales, locales,
personalization,
federation_options, federation_options,
} }
} }
@ -664,6 +686,7 @@ impl SearchQueryWithIndex {
hybrid, hybrid,
ranking_score_threshold, ranking_score_threshold,
locales, locales,
personalization,
} = self; } = self;
( (
index_uid, index_uid,
@ -695,6 +718,7 @@ impl SearchQueryWithIndex {
hybrid, hybrid,
ranking_score_threshold, ranking_score_threshold,
locales, locales,
personalization,
// do not use ..Default::default() here, // do not use ..Default::default() here,
// rather add any missing field from `SearchQuery` to `SearchQueryWithIndex` // rather add any missing field from `SearchQuery` to `SearchQueryWithIndex`
}, },
@ -919,7 +943,7 @@ pub struct SearchResultWithIndex {
pub result: SearchResult, pub result: SearchResult,
} }
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, ToSchema)] #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, ToSchema)]
#[serde(untagged)] #[serde(untagged)]
pub enum HitsInfo { pub enum HitsInfo {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@ -1166,6 +1190,7 @@ pub fn perform_search(
attributes_to_search_on: _, attributes_to_search_on: _,
filter: _, filter: _,
distinct: _, distinct: _,
personalization: _,
} = query; } = query;
let format = AttributesFormat { let format = AttributesFormat {