diff --git a/crates/meilisearch/tests/search/mod.rs b/crates/meilisearch/tests/search/mod.rs index b71008887..1f14a380e 100644 --- a/crates/meilisearch/tests/search/mod.rs +++ b/crates/meilisearch/tests/search/mod.rs @@ -2128,3 +2128,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..3ef61bd54 100644 --- a/crates/milli/src/score_details.rs +++ b/crates/milli/src/score_details.rs @@ -66,15 +66,29 @@ 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 { - 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>(