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 6b4d69996c
commit d92fa089d0
6 changed files with 77 additions and 5 deletions

View File

@ -308,6 +308,10 @@ InvalidSearchShowRankingScoreDetails , InvalidRequest , BAD_REQU
InvalidSimilarShowRankingScoreDetails , InvalidRequest , BAD_REQUEST ;
InvalidSearchSort , InvalidRequest , BAD_REQUEST ;
InvalidSearchDistinct , InvalidRequest , BAD_REQUEST ;
InvalidSearchPersonalization , InvalidRequest , BAD_REQUEST ;
InvalidSearchPersonalizationPersonalized , InvalidRequest , BAD_REQUEST ;
InvalidSearchPersonalizationUserProfile , InvalidRequest , BAD_REQUEST ;
InvalidSearchMediaAndVector , InvalidRequest , BAD_REQUEST ;
InvalidSettingsDisplayedAttributes , InvalidRequest , BAD_REQUEST ;
InvalidSettingsDistinctAttribute , InvalidRequest , BAD_REQUEST ;
InvalidSettingsProximityPrecision , InvalidRequest , BAD_REQUEST ;
@ -636,6 +640,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_rules! internal_error {
($target:ty : $($other:path), *) => {

View File

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

View File

@ -22,10 +22,10 @@ use crate::extractors::sequential_extractor::SeqHandler;
use crate::metrics::MEILISEARCH_DEGRADED_SEARCH_REQUESTS;
use crate::routes::indexes::search_analytics::{SearchAggregator, SearchGET, SearchPOST};
use crate::search::{
add_search_rules, perform_search, HybridQuery, MatchingStrategy, RankingScoreThreshold,
RetrieveVectors, SearchKind, SearchQuery, SearchResult, SemanticRatio, DEFAULT_CROP_LENGTH,
DEFAULT_CROP_MARKER, DEFAULT_HIGHLIGHT_POST_TAG, DEFAULT_HIGHLIGHT_PRE_TAG,
DEFAULT_SEARCH_LIMIT, DEFAULT_SEARCH_OFFSET, DEFAULT_SEMANTIC_RATIO,
add_search_rules, perform_search, HybridQuery, MatchingStrategy, Personalization,
RankingScoreThreshold, RetrieveVectors, SearchKind, SearchQuery, SearchResult, SemanticRatio,
DEFAULT_CROP_LENGTH, DEFAULT_CROP_MARKER, DEFAULT_HIGHLIGHT_POST_TAG,
DEFAULT_HIGHLIGHT_PRE_TAG, DEFAULT_SEARCH_LIMIT, DEFAULT_SEARCH_OFFSET, DEFAULT_SEMANTIC_RATIO,
};
use crate::search_queue::SearchQueue;
@ -132,6 +132,11 @@ pub struct SearchQueryGet {
#[deserr(default, error = DeserrQueryParamError<InvalidSearchLocales>)]
#[param(value_type = Vec<Locale>, explode = false)]
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)]
@ -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 {
q: other.q,
vector: other.vector.map(CS::into_inner),
@ -230,6 +251,7 @@ impl TryFrom<SearchQueryGet> for SearchQuery {
hybrid,
ranking_score_threshold: other.ranking_score_threshold.map(|o| o.0),
locales: other.locales.map(|o| o.into_iter().collect()),
personalization,
})
}
}

View File

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

View File

@ -66,6 +66,7 @@ impl MultiSearchAggregator {
hybrid: _,
ranking_score_threshold: _,
locales: _,
personalization: _,
} in &federated_search.queries
{
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_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)]
#[deserr(error = DeserrJsonError, rename_all = camelCase, deny_unknown_fields)]
pub struct SearchQuery {
@ -118,6 +127,8 @@ pub struct SearchQuery {
pub ranking_score_threshold: Option<RankingScoreThreshold>,
#[deserr(default, error = DeserrJsonError<InvalidSearchLocales>)]
pub locales: Option<Vec<Locale>>,
#[deserr(default, error = DeserrJsonError<InvalidSearchPersonalization>, default)]
pub personalization: Option<Personalization>,
}
impl From<SearchParameters> for SearchQuery {
@ -164,6 +175,7 @@ impl From<SearchParameters> for SearchQuery {
highlight_post_tag: DEFAULT_HIGHLIGHT_POST_TAG(),
crop_marker: DEFAULT_CROP_MARKER(),
locales: None,
personalization: None,
}
}
}
@ -244,6 +256,7 @@ impl fmt::Debug for SearchQuery {
attributes_to_search_on,
ranking_score_threshold,
locales,
personalization,
} = self;
let mut debug = f.debug_struct("SearchQuery");
@ -329,6 +342,10 @@ impl fmt::Debug for SearchQuery {
debug.field("locales", &locales);
}
if let Some(personalization) = personalization {
debug.field("personalization", &personalization);
}
debug.finish()
}
}
@ -531,6 +548,9 @@ pub struct SearchQueryWithIndex {
pub ranking_score_threshold: Option<RankingScoreThreshold>,
#[deserr(default, error = DeserrJsonError<InvalidSearchLocales>, default)]
pub locales: Option<Vec<Locale>>,
#[deserr(default, error = DeserrJsonError<InvalidSearchPersonalization>, default)]
#[serde(skip)]
pub personalization: Option<Personalization>,
#[deserr(default)]
pub federation_options: Option<FederationOptions>,
@ -587,6 +607,7 @@ impl SearchQueryWithIndex {
attributes_to_search_on,
ranking_score_threshold,
locales,
personalization,
} = query;
SearchQueryWithIndex {
@ -617,6 +638,7 @@ impl SearchQueryWithIndex {
attributes_to_search_on,
ranking_score_threshold,
locales,
personalization,
federation_options,
}
}
@ -651,6 +673,7 @@ impl SearchQueryWithIndex {
hybrid,
ranking_score_threshold,
locales,
personalization,
} = self;
(
index_uid,
@ -681,6 +704,7 @@ impl SearchQueryWithIndex {
hybrid,
ranking_score_threshold,
locales,
personalization,
// do not use ..Default::default() here,
// rather add any missing field from `SearchQuery` to `SearchQueryWithIndex`
},
@ -905,7 +929,7 @@ pub struct SearchResultWithIndex {
pub result: SearchResult,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, ToSchema)]
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, ToSchema)]
#[serde(untagged)]
pub enum HitsInfo {
#[serde(rename_all = "camelCase")]
@ -1133,6 +1157,7 @@ pub fn perform_search(
attributes_to_search_on: _,
filter: _,
distinct: _,
personalization: _,
} = query;
let format = AttributesFormat {