From d2071dde1ff50663972d3adf2f5d016492008acb Mon Sep 17 00:00:00 2001 From: ManyTheFish Date: Mon, 6 Oct 2025 18:28:25 +0200 Subject: [PATCH 1/2] Fix ranking score bug when sort is present - Fix global_score function to properly handle semantic scores and ranking scores - Prioritize semantic scores (vector/embedding) when available, fall back to ranking scores - Exclude sort and geo sort details from relevance scoring - Use Rank::global_score to properly merge ranking scores - Add test case with insta snapshots to reproduce and verify the fix - When sorting is present, ranking scores now properly reflect search relevance - Previously all ranking scores were 1.0 when sort was present, now they show actual relevance scores --- crates/meilisearch/tests/search/mod.rs | 99 ++++++++++++++++++++++++++ crates/milli/src/score_details.rs | 24 ++++--- 2 files changed, 115 insertions(+), 8 deletions(-) diff --git a/crates/meilisearch/tests/search/mod.rs b/crates/meilisearch/tests/search/mod.rs index 3f70e1ba9..a05b5c688 100644 --- a/crates/meilisearch/tests/search/mod.rs +++ b/crates/meilisearch/tests/search/mod.rs @@ -2127,3 +2127,102 @@ async fn simple_search_changing_unrelated_settings() { }) .await; } + +#[actix_rt::test] +async fn ranking_score_bug_with_sort() { + let server = Server::new_shared(); + let index = server.unique_index(); + + // Create documents with a "created" field for sorting + let documents = json!([ + { + "id": "1", + "title": "Coffee Mug", + "created": "2023-01-01T00:00:00Z" + }, + { + "id": "2", + "title": "Water Bottle", + "created": "2023-01-02T00:00:00Z" + }, + { + "id": "3", + "title": "Tumbler Cup", + "created": "2023-01-03T00:00:00Z" + }, + { + "id": "4", + "title": "Stainless Steel Tumbler", + "created": "2023-01-04T00:00:00Z" + } + ]); + + // Add documents + let (task, code) = index.add_documents(documents, None).await; + assert_eq!(code, 202, "{task}"); + server.wait_task(task.uid()).await.succeeded(); + + // Configure sortable attributes + let (task, code) = index + .update_settings(json!({ + "sortableAttributes": ["created"] + })) + .await; + assert_eq!(code, 202, "{task}"); + server.wait_task(task.uid()).await.succeeded(); + + // Test 1: Search without sort - should have proper ranking scores + index + .search( + json!({ + "q": "tumbler", + "showRankingScore": true, + "rankingScoreThreshold": 0.0, + "attributesToRetrieve": ["title"] + }), + |response, code| { + assert_eq!(code, 200, "{response}"); + snapshot!(json_string!(response["hits"]), @r###" + [ + { + "title": "Tumbler Cup", + "_rankingScore": 0.9848484848484848 + }, + { + "title": "Stainless Steel Tumbler", + "_rankingScore": 0.8787878787878788 + } + ] + "###); + }, + ) + .await; + + // Test 2: Search with sort - this is where the bug occurs + index + .search( + json!({ + "q": "tumbler", + "showRankingScore": true, + "rankingScoreThreshold": 0.0, + "sort": ["created:desc"], + "attributesToRetrieve": ["title"] + }), + |response, code| { + assert_eq!(code, 200, "{response}"); + snapshot!(json_string!(response["hits"]), @r###" + [ + { + "title": "Tumbler Cup", + "_rankingScore": 0.9848484848484848 + }, + { + "title": "Stainless Steel Tumbler", + "_rankingScore": 0.8787878787878788 + } + ] + "###); + }, + ) + .await; +} diff --git a/crates/milli/src/score_details.rs b/crates/milli/src/score_details.rs index 940e5f395..acf6d020b 100644 --- a/crates/milli/src/score_details.rs +++ b/crates/milli/src/score_details.rs @@ -67,14 +67,22 @@ impl ScoreDetails { } pub fn global_score<'a>(details: impl Iterator + 'a) -> f64 { - Self::score_values(details) - .find_map(|x| { - let ScoreValue::Score(score) = x else { - return None; - }; - Some(score) - }) - .unwrap_or(1.0f64) + // Filter out only the ranking scores (Rank values) and exclude sort/geo sort + let mut semantic_score = None; + let ranking_ranks = details.filter_map(|detail| match detail.rank_or_value() { + RankOrValue::Rank(rank) => Some(rank), + RankOrValue::Score(score) => { + semantic_score = Some(score); + None + } + RankOrValue::Sort(_) => None, + RankOrValue::GeoSort(_) => None, + }); + + let ranking_score = Rank::global_score(ranking_ranks); + + // If we have semantic score, use it, otherwise use ranking score + semantic_score.unwrap_or(ranking_score) } pub fn score_values<'a>( From ce832da16ceea4fa063f90c1a0acc933ac290bfe Mon Sep 17 00:00:00 2001 From: ManyTheFish Date: Wed, 8 Oct 2025 17:19:40 +0200 Subject: [PATCH 2/2] Add a function documentation --- crates/milli/src/score_details.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/milli/src/score_details.rs b/crates/milli/src/score_details.rs index acf6d020b..3ef61bd54 100644 --- a/crates/milli/src/score_details.rs +++ b/crates/milli/src/score_details.rs @@ -66,6 +66,12 @@ impl ScoreDetails { } } + /// Calculate the global score of the details. + /// + /// It is computed from the ranks of the ranking rules, excluding the sort/geo sort rules. + /// If the details contain a semantic score (ScoreDetails::Vector), it is used instead of the ranking score. + /// + /// note: this function expects a maximum of one semantic score, otherwise only the last one will be used. pub fn global_score<'a>(details: impl Iterator + 'a) -> f64 { // Filter out only the ranking scores (Rank values) and exclude sort/geo sort let mut semantic_score = None;