From 00eb258a538ed39220c3dbb469f5c193f160cade Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Thu, 19 Jun 2025 11:16:07 +0200 Subject: [PATCH 01/81] Fix comment --- crates/meilisearch-auth/src/lib.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/meilisearch-auth/src/lib.rs b/crates/meilisearch-auth/src/lib.rs index 27d163192..02d9201c5 100644 --- a/crates/meilisearch-auth/src/lib.rs +++ b/crates/meilisearch-auth/src/lib.rs @@ -158,7 +158,7 @@ impl AuthController { self.store.delete_all_keys() } - /// Delete all the keys in the DB. + /// Insert a key directly into the store. pub fn raw_insert_key(&mut self, key: Key) -> Result<()> { self.store.put_api_key(key)?; Ok(()) @@ -353,6 +353,7 @@ fn generate_default_keys(store: &HeedAuthStore) -> Result<()> { store.put_api_key(Key::default_chat())?; store.put_api_key(Key::default_admin())?; store.put_api_key(Key::default_search())?; + store.put_api_key(Key::default_management())?; Ok(()) } From b421c8e7deb13ca5bcfbf03ef33f958ffb8dbf32 Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Thu, 19 Jun 2025 11:29:16 +0200 Subject: [PATCH 02/81] Add an AllRead key --- crates/meilisearch-auth/src/store.rs | 1 + crates/meilisearch-types/src/keys.rs | 48 ++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/crates/meilisearch-auth/src/store.rs b/crates/meilisearch-auth/src/store.rs index bae27afe4..6e4ff8389 100644 --- a/crates/meilisearch-auth/src/store.rs +++ b/crates/meilisearch-auth/src/store.rs @@ -89,6 +89,7 @@ impl HeedAuthStore { for action in &key.actions { match action { Action::All => actions.extend(enum_iterator::all::()), + Action::AllRead => actions.extend(enum_iterator::all::().filter(|a| a.is_read())), Action::DocumentsAll => { actions.extend( [Action::DocumentsGet, Action::DocumentsDelete, Action::DocumentsAdd] diff --git a/crates/meilisearch-types/src/keys.rs b/crates/meilisearch-types/src/keys.rs index df2810727..023e7e786 100644 --- a/crates/meilisearch-types/src/keys.rs +++ b/crates/meilisearch-types/src/keys.rs @@ -218,6 +218,9 @@ pub enum Action { #[serde(rename = "*")] #[deserr(rename = "*")] All = 0, + #[serde(rename = "*.read")] + #[deserr(rename = "*.read")] + AllRead, #[serde(rename = "search")] #[deserr(rename = "search")] Search, @@ -396,6 +399,51 @@ impl Action { } } + /// Whether the action should be included in [Action::AllRead]. + pub fn is_read(&self) -> bool { + use Action::*; + + // It's using an exhaustive match to force the addition of new actions. + match self { + // Any action that expands to others must return false, as it wouldn't be able to expand recursively. + All | AllRead | DocumentsAll | IndexesAll | ChatsAll | TasksAll | SettingsAll + | StatsAll | MetricsAll | DumpsAll | SnapshotsAll | ChatsSettingsAll => false, + + Search => true, + DocumentsAdd => false, + DocumentsGet => true, + DocumentsDelete => false, + IndexesAdd => false, + IndexesGet => true, + IndexesUpdate => false, + IndexesDelete => false, + IndexesSwap => false, + TasksCancel => false, + TasksDelete => false, + TasksGet => true, + SettingsGet => true, + SettingsUpdate => false, + StatsGet => true, + MetricsGet => true, + DumpsCreate => false, + SnapshotsCreate => false, + Version => true, + KeysAdd => false, + KeysGet => false, // Prevent privilege escalation by not allowing reading other keys. + KeysUpdate => false, + KeysDelete => false, + ExperimentalFeaturesGet => true, + ExperimentalFeaturesUpdate => false, + NetworkGet => true, + NetworkUpdate => false, + ChatCompletions => false, // Disabled because it might trigger generation of new chats. + ChatsGet => true, + ChatsDelete => false, + ChatsSettingsGet => true, + ChatsSettingsUpdate => false, + } + } + pub const fn repr(&self) -> u8 { *self as u8 } From 032b34c37716e6fd11981c7046d8a824a5e826a7 Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Thu, 19 Jun 2025 11:29:32 +0200 Subject: [PATCH 03/81] Add a default management key --- crates/meilisearch-types/src/keys.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/crates/meilisearch-types/src/keys.rs b/crates/meilisearch-types/src/keys.rs index 023e7e786..e8db4014d 100644 --- a/crates/meilisearch-types/src/keys.rs +++ b/crates/meilisearch-types/src/keys.rs @@ -144,6 +144,21 @@ impl Key { } } + pub fn default_management() -> Self { + let now = OffsetDateTime::now_utc(); + let uid = Uuid::new_v4(); + Self { + name: Some("Default Management API Key".to_string()), + description: Some("Use it to peek into the instance in a read-only mode. Caution! Do not expose it on a public frontend".to_string()), + uid, + actions: vec![Action::AllRead], + indexes: vec![IndexUidPattern::all()], + expires_at: None, + created_at: now, + updated_at: now, + } + } + pub fn default_search() -> Self { let now = OffsetDateTime::now_utc(); let uid = Uuid::new_v4(); @@ -453,6 +468,7 @@ pub mod actions { use super::Action::*; pub(crate) const ALL: u8 = All.repr(); + pub const ALL_READ: u8 = AllRead.repr(); pub const SEARCH: u8 = Search.repr(); pub const DOCUMENTS_ALL: u8 = DocumentsAll.repr(); pub const DOCUMENTS_ADD: u8 = DocumentsAdd.repr(); From 11fedea788115448f6c6f7a854b340d65a1fd641 Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Thu, 19 Jun 2025 11:42:45 +0200 Subject: [PATCH 04/81] Set static uuids to keys --- crates/meilisearch-types/src/keys.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/meilisearch-types/src/keys.rs b/crates/meilisearch-types/src/keys.rs index e8db4014d..4a3b58c20 100644 --- a/crates/meilisearch-types/src/keys.rs +++ b/crates/meilisearch-types/src/keys.rs @@ -131,7 +131,7 @@ pub struct Key { impl Key { pub fn default_admin() -> Self { let now = OffsetDateTime::now_utc(); - let uid = Uuid::new_v4(); + let uid = Uuid::from_u128(0); Self { name: Some("Default Admin API Key".to_string()), description: Some("Use it for anything that is not a search operation. Caution! Do not expose it on a public frontend".to_string()), @@ -146,9 +146,9 @@ impl Key { pub fn default_management() -> Self { let now = OffsetDateTime::now_utc(); - let uid = Uuid::new_v4(); + let uid = Uuid::from_u128(1); Self { - name: Some("Default Management API Key".to_string()), + name: Some("Read-only Admin key".to_string()), description: Some("Use it to peek into the instance in a read-only mode. Caution! Do not expose it on a public frontend".to_string()), uid, actions: vec![Action::AllRead], @@ -161,7 +161,7 @@ impl Key { pub fn default_search() -> Self { let now = OffsetDateTime::now_utc(); - let uid = Uuid::new_v4(); + let uid = Uuid::from_u128(2); Self { name: Some("Default Search API Key".to_string()), description: Some("Use it to search from the frontend".to_string()), @@ -176,7 +176,7 @@ impl Key { pub fn default_chat() -> Self { let now = OffsetDateTime::now_utc(); - let uid = Uuid::new_v4(); + let uid = Uuid::from_u128(3); Self { name: Some("Default Chat API Key".to_string()), description: Some("Use it to chat and search from the frontend".to_string()), From f50e586a4f265a06295d3cbd364fa9c8f353002b Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Thu, 19 Jun 2025 11:52:58 +0200 Subject: [PATCH 05/81] Allow management key to read other keys --- crates/meilisearch-types/src/keys.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/meilisearch-types/src/keys.rs b/crates/meilisearch-types/src/keys.rs index 4a3b58c20..bfedf1e99 100644 --- a/crates/meilisearch-types/src/keys.rs +++ b/crates/meilisearch-types/src/keys.rs @@ -148,8 +148,8 @@ impl Key { let now = OffsetDateTime::now_utc(); let uid = Uuid::from_u128(1); Self { - name: Some("Read-only Admin key".to_string()), - description: Some("Use it to peek into the instance in a read-only mode. Caution! Do not expose it on a public frontend".to_string()), + name: Some("Default Read-Only Admin API Key".to_string()), + description: Some("Use it to peek into the instance in a read-only mode. Caution! Do not expose it on a public frontend. It would give access to all other keys".to_string()), uid, actions: vec![Action::AllRead], indexes: vec![IndexUidPattern::all()], @@ -444,7 +444,7 @@ impl Action { SnapshotsCreate => false, Version => true, KeysAdd => false, - KeysGet => false, // Prevent privilege escalation by not allowing reading other keys. + KeysGet => true, KeysUpdate => false, KeysDelete => false, ExperimentalFeaturesGet => true, From b6b7ede266af7221d9271c86ba08d4b9e5d2c0da Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Thu, 19 Jun 2025 11:53:42 +0200 Subject: [PATCH 06/81] Rename Action `*.read` to `*.get` --- crates/meilisearch-types/src/keys.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/meilisearch-types/src/keys.rs b/crates/meilisearch-types/src/keys.rs index bfedf1e99..c3269fb44 100644 --- a/crates/meilisearch-types/src/keys.rs +++ b/crates/meilisearch-types/src/keys.rs @@ -233,8 +233,8 @@ pub enum Action { #[serde(rename = "*")] #[deserr(rename = "*")] All = 0, - #[serde(rename = "*.read")] - #[deserr(rename = "*.read")] + #[serde(rename = "*.get")] + #[deserr(rename = "*.get")] AllRead, #[serde(rename = "search")] #[deserr(rename = "search")] From 9e1cb792f4940ee6e5897192c8925dc0dab2c344 Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Thu, 19 Jun 2025 11:55:25 +0200 Subject: [PATCH 07/81] Rename Action::AllRead to AllGet --- crates/meilisearch-auth/src/store.rs | 2 +- crates/meilisearch-types/src/keys.rs | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/meilisearch-auth/src/store.rs b/crates/meilisearch-auth/src/store.rs index 6e4ff8389..9c0ac4b00 100644 --- a/crates/meilisearch-auth/src/store.rs +++ b/crates/meilisearch-auth/src/store.rs @@ -89,7 +89,7 @@ impl HeedAuthStore { for action in &key.actions { match action { Action::All => actions.extend(enum_iterator::all::()), - Action::AllRead => actions.extend(enum_iterator::all::().filter(|a| a.is_read())), + Action::AllGet => actions.extend(enum_iterator::all::().filter(|a| a.is_read())), Action::DocumentsAll => { actions.extend( [Action::DocumentsGet, Action::DocumentsDelete, Action::DocumentsAdd] diff --git a/crates/meilisearch-types/src/keys.rs b/crates/meilisearch-types/src/keys.rs index c3269fb44..b9a9ae21c 100644 --- a/crates/meilisearch-types/src/keys.rs +++ b/crates/meilisearch-types/src/keys.rs @@ -151,7 +151,7 @@ impl Key { name: Some("Default Read-Only Admin API Key".to_string()), description: Some("Use it to peek into the instance in a read-only mode. Caution! Do not expose it on a public frontend. It would give access to all other keys".to_string()), uid, - actions: vec![Action::AllRead], + actions: vec![Action::AllGet], indexes: vec![IndexUidPattern::all()], expires_at: None, created_at: now, @@ -235,7 +235,7 @@ pub enum Action { All = 0, #[serde(rename = "*.get")] #[deserr(rename = "*.get")] - AllRead, + AllGet, #[serde(rename = "search")] #[deserr(rename = "search")] Search, @@ -421,7 +421,7 @@ impl Action { // It's using an exhaustive match to force the addition of new actions. match self { // Any action that expands to others must return false, as it wouldn't be able to expand recursively. - All | AllRead | DocumentsAll | IndexesAll | ChatsAll | TasksAll | SettingsAll + All | AllGet | DocumentsAll | IndexesAll | ChatsAll | TasksAll | SettingsAll | StatsAll | MetricsAll | DumpsAll | SnapshotsAll | ChatsSettingsAll => false, Search => true, @@ -468,7 +468,7 @@ pub mod actions { use super::Action::*; pub(crate) const ALL: u8 = All.repr(); - pub const ALL_READ: u8 = AllRead.repr(); + pub const ALL_READ: u8 = AllGet.repr(); pub const SEARCH: u8 = Search.repr(); pub const DOCUMENTS_ALL: u8 = DocumentsAll.repr(); pub const DOCUMENTS_ADD: u8 = DocumentsAdd.repr(); From 5081d837ea54dcba76038bb96afaa73f7278c189 Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Thu, 19 Jun 2025 12:12:30 +0200 Subject: [PATCH 08/81] Fix AllGet action being included in All --- crates/meilisearch-auth/src/store.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/meilisearch-auth/src/store.rs b/crates/meilisearch-auth/src/store.rs index 9c0ac4b00..bec5d3561 100644 --- a/crates/meilisearch-auth/src/store.rs +++ b/crates/meilisearch-auth/src/store.rs @@ -88,7 +88,10 @@ impl HeedAuthStore { let mut actions = HashSet::new(); for action in &key.actions { match action { - Action::All => actions.extend(enum_iterator::all::()), + Action::All => { + actions.extend(enum_iterator::all::()); + actions.remove(&Action::AllGet); + }, Action::AllGet => actions.extend(enum_iterator::all::().filter(|a| a.is_read())), Action::DocumentsAll => { actions.extend( From 99732f4084475b4955173c3f3c2b96e405b8327a Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Thu, 19 Jun 2025 13:04:55 +0200 Subject: [PATCH 09/81] Fix some tests --- crates/meilisearch-auth/src/lib.rs | 2 +- crates/meilisearch/tests/auth/api_keys.rs | 6 +++--- crates/meilisearch/tests/auth/errors.rs | 6 +++--- crates/meilisearch/tests/common/server.rs | 1 + 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/crates/meilisearch-auth/src/lib.rs b/crates/meilisearch-auth/src/lib.rs index 02d9201c5..000e574ac 100644 --- a/crates/meilisearch-auth/src/lib.rs +++ b/crates/meilisearch-auth/src/lib.rs @@ -351,9 +351,9 @@ pub struct IndexSearchRules { fn generate_default_keys(store: &HeedAuthStore) -> Result<()> { store.put_api_key(Key::default_chat())?; + store.put_api_key(Key::default_management())?; store.put_api_key(Key::default_admin())?; store.put_api_key(Key::default_search())?; - store.put_api_key(Key::default_management())?; Ok(()) } diff --git a/crates/meilisearch/tests/auth/api_keys.rs b/crates/meilisearch/tests/auth/api_keys.rs index 5a18b4dbf..63eb0d21c 100644 --- a/crates/meilisearch/tests/auth/api_keys.rs +++ b/crates/meilisearch/tests/auth/api_keys.rs @@ -419,14 +419,14 @@ async fn error_add_api_key_invalid_parameters_actions() { let (response, code) = server.add_api_key(content).await; meili_snap::snapshot!(code, @"400 Bad Request"); - meili_snap::snapshot!(meili_snap::json_string!(response, { ".createdAt" => "[ignored]", ".updatedAt" => "[ignored]" }), @r###" + meili_snap::snapshot!(meili_snap::json_string!(response, { ".createdAt" => "[ignored]", ".updatedAt" => "[ignored]" }), @r#" { - "message": "Unknown value `doc.add` at `.actions[0]`: expected one of `*`, `search`, `documents.*`, `documents.add`, `documents.get`, `documents.delete`, `indexes.*`, `indexes.create`, `indexes.get`, `indexes.update`, `indexes.delete`, `indexes.swap`, `tasks.*`, `tasks.cancel`, `tasks.delete`, `tasks.get`, `settings.*`, `settings.get`, `settings.update`, `stats.*`, `stats.get`, `metrics.*`, `metrics.get`, `dumps.*`, `dumps.create`, `snapshots.*`, `snapshots.create`, `version`, `keys.create`, `keys.get`, `keys.update`, `keys.delete`, `experimental.get`, `experimental.update`, `network.get`, `network.update`, `chatCompletions`, `chats.*`, `chats.get`, `chats.delete`, `chatsSettings.*`, `chatsSettings.get`, `chatsSettings.update`", + "message": "Unknown value `doc.add` at `.actions[0]`: expected one of `*`, `*.get`, `search`, `documents.*`, `documents.add`, `documents.get`, `documents.delete`, `indexes.*`, `indexes.create`, `indexes.get`, `indexes.update`, `indexes.delete`, `indexes.swap`, `tasks.*`, `tasks.cancel`, `tasks.delete`, `tasks.get`, `settings.*`, `settings.get`, `settings.update`, `stats.*`, `stats.get`, `metrics.*`, `metrics.get`, `dumps.*`, `dumps.create`, `snapshots.*`, `snapshots.create`, `version`, `keys.create`, `keys.get`, `keys.update`, `keys.delete`, `experimental.get`, `experimental.update`, `network.get`, `network.update`, `chatCompletions`, `chats.*`, `chats.get`, `chats.delete`, `chatsSettings.*`, `chatsSettings.get`, `chatsSettings.update`", "code": "invalid_api_key_actions", "type": "invalid_request", "link": "https://docs.meilisearch.com/errors#invalid_api_key_actions" } - "###); + "#); } #[actix_rt::test] diff --git a/crates/meilisearch/tests/auth/errors.rs b/crates/meilisearch/tests/auth/errors.rs index ebe2e53fa..c9fa2ee9c 100644 --- a/crates/meilisearch/tests/auth/errors.rs +++ b/crates/meilisearch/tests/auth/errors.rs @@ -91,14 +91,14 @@ async fn create_api_key_bad_actions() { // can't parse let (response, code) = server.add_api_key(json!({ "actions": ["doggo"] })).await; snapshot!(code, @"400 Bad Request"); - snapshot!(json_string!(response), @r###" + snapshot!(json_string!(response), @r#" { - "message": "Unknown value `doggo` at `.actions[0]`: expected one of `*`, `search`, `documents.*`, `documents.add`, `documents.get`, `documents.delete`, `indexes.*`, `indexes.create`, `indexes.get`, `indexes.update`, `indexes.delete`, `indexes.swap`, `tasks.*`, `tasks.cancel`, `tasks.delete`, `tasks.get`, `settings.*`, `settings.get`, `settings.update`, `stats.*`, `stats.get`, `metrics.*`, `metrics.get`, `dumps.*`, `dumps.create`, `snapshots.*`, `snapshots.create`, `version`, `keys.create`, `keys.get`, `keys.update`, `keys.delete`, `experimental.get`, `experimental.update`, `network.get`, `network.update`, `chatCompletions`, `chats.*`, `chats.get`, `chats.delete`, `chatsSettings.*`, `chatsSettings.get`, `chatsSettings.update`", + "message": "Unknown value `doggo` at `.actions[0]`: expected one of `*`, `*.get`, `search`, `documents.*`, `documents.add`, `documents.get`, `documents.delete`, `indexes.*`, `indexes.create`, `indexes.get`, `indexes.update`, `indexes.delete`, `indexes.swap`, `tasks.*`, `tasks.cancel`, `tasks.delete`, `tasks.get`, `settings.*`, `settings.get`, `settings.update`, `stats.*`, `stats.get`, `metrics.*`, `metrics.get`, `dumps.*`, `dumps.create`, `snapshots.*`, `snapshots.create`, `version`, `keys.create`, `keys.get`, `keys.update`, `keys.delete`, `experimental.get`, `experimental.update`, `network.get`, `network.update`, `chatCompletions`, `chats.*`, `chats.get`, `chats.delete`, `chatsSettings.*`, `chatsSettings.get`, `chatsSettings.update`", "code": "invalid_api_key_actions", "type": "invalid_request", "link": "https://docs.meilisearch.com/errors#invalid_api_key_actions" } - "###); + "#); } #[actix_rt::test] diff --git a/crates/meilisearch/tests/common/server.rs b/crates/meilisearch/tests/common/server.rs index 1f5688a02..19a082cf3 100644 --- a/crates/meilisearch/tests/common/server.rs +++ b/crates/meilisearch/tests/common/server.rs @@ -97,6 +97,7 @@ impl Server { self.use_api_key(master_key); let (response, code) = self.list_api_keys("").await; assert_eq!(200, code, "{:?}", response); + // TODO: relying on the order of keys is not ideal, we should use the static uuid let admin_key = &response["results"][1]["key"]; self.use_api_key(admin_key.as_str().unwrap()); } From 67f2a30d7c9b0b8331912b1ec7471a744e04fb64 Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Thu, 19 Jun 2025 13:10:08 +0200 Subject: [PATCH 10/81] Fix test --- crates/meilisearch/tests/auth/api_keys.rs | 27 ++++++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/crates/meilisearch/tests/auth/api_keys.rs b/crates/meilisearch/tests/auth/api_keys.rs index 63eb0d21c..15edd8f3a 100644 --- a/crates/meilisearch/tests/auth/api_keys.rs +++ b/crates/meilisearch/tests/auth/api_keys.rs @@ -790,7 +790,7 @@ async fn list_api_keys() { meili_snap::snapshot!(code, @"201 Created"); let (response, code) = server.list_api_keys("").await; - meili_snap::snapshot!(meili_snap::json_string!(response, { ".results[].createdAt" => "[ignored]", ".results[].updatedAt" => "[ignored]", ".results[].uid" => "[ignored]", ".results[].key" => "[ignored]" }), @r###" + meili_snap::snapshot!(meili_snap::json_string!(response, { ".results[].createdAt" => "[ignored]", ".results[].updatedAt" => "[ignored]", ".results[0].uid" => "[ignored]", ".results[].key" => "[ignored]" }), @r#" { "results": [ { @@ -824,7 +824,7 @@ async fn list_api_keys() { "name": "Default Search API Key", "description": "Use it to search from the frontend", "key": "[ignored]", - "uid": "[ignored]", + "uid": "00000000-0000-0000-0000-000000000002", "actions": [ "search" ], @@ -839,7 +839,7 @@ async fn list_api_keys() { "name": "Default Admin API Key", "description": "Use it for anything that is not a search operation. Caution! Do not expose it on a public frontend", "key": "[ignored]", - "uid": "[ignored]", + "uid": "00000000-0000-0000-0000-000000000000", "actions": [ "*" ], @@ -850,11 +850,26 @@ async fn list_api_keys() { "createdAt": "[ignored]", "updatedAt": "[ignored]" }, + { + "name": "Default Read-Only Admin API Key", + "description": "Use it to peek into the instance in a read-only mode. Caution! Do not expose it on a public frontend. It would give access to all other keys", + "key": "[ignored]", + "uid": "00000000-0000-0000-0000-000000000001", + "actions": [ + "*.get" + ], + "indexes": [ + "*" + ], + "expiresAt": null, + "createdAt": "[ignored]", + "updatedAt": "[ignored]" + }, { "name": "Default Chat API Key", "description": "Use it to chat and search from the frontend", "key": "[ignored]", - "uid": "[ignored]", + "uid": "00000000-0000-0000-0000-000000000003", "actions": [ "chatCompletions", "search" @@ -869,9 +884,9 @@ async fn list_api_keys() { ], "offset": 0, "limit": 20, - "total": 4 + "total": 5 } - "###); + "#); meili_snap::snapshot!(code, @"200 OK"); } From 705e9a9e5e2464cf5f33758e22272f6ff8984de4 Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Thu, 19 Jun 2025 15:45:09 +0200 Subject: [PATCH 11/81] Make the uuids random again to prevent abuse using rainbow tables --- crates/meilisearch-types/src/keys.rs | 8 ++++---- crates/meilisearch/tests/auth/api_keys.rs | 10 +++++----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/crates/meilisearch-types/src/keys.rs b/crates/meilisearch-types/src/keys.rs index b9a9ae21c..3d30c7a0e 100644 --- a/crates/meilisearch-types/src/keys.rs +++ b/crates/meilisearch-types/src/keys.rs @@ -131,7 +131,7 @@ pub struct Key { impl Key { pub fn default_admin() -> Self { let now = OffsetDateTime::now_utc(); - let uid = Uuid::from_u128(0); + let uid = Uuid::new_v4(); Self { name: Some("Default Admin API Key".to_string()), description: Some("Use it for anything that is not a search operation. Caution! Do not expose it on a public frontend".to_string()), @@ -146,7 +146,7 @@ impl Key { pub fn default_management() -> Self { let now = OffsetDateTime::now_utc(); - let uid = Uuid::from_u128(1); + let uid = Uuid::new_v4(); Self { name: Some("Default Read-Only Admin API Key".to_string()), description: Some("Use it to peek into the instance in a read-only mode. Caution! Do not expose it on a public frontend. It would give access to all other keys".to_string()), @@ -161,7 +161,7 @@ impl Key { pub fn default_search() -> Self { let now = OffsetDateTime::now_utc(); - let uid = Uuid::from_u128(2); + let uid = Uuid::new_v4(); Self { name: Some("Default Search API Key".to_string()), description: Some("Use it to search from the frontend".to_string()), @@ -176,7 +176,7 @@ impl Key { pub fn default_chat() -> Self { let now = OffsetDateTime::now_utc(); - let uid = Uuid::from_u128(3); + let uid = Uuid::new_v4(); Self { name: Some("Default Chat API Key".to_string()), description: Some("Use it to chat and search from the frontend".to_string()), diff --git a/crates/meilisearch/tests/auth/api_keys.rs b/crates/meilisearch/tests/auth/api_keys.rs index 15edd8f3a..fa09f17cb 100644 --- a/crates/meilisearch/tests/auth/api_keys.rs +++ b/crates/meilisearch/tests/auth/api_keys.rs @@ -790,7 +790,7 @@ async fn list_api_keys() { meili_snap::snapshot!(code, @"201 Created"); let (response, code) = server.list_api_keys("").await; - meili_snap::snapshot!(meili_snap::json_string!(response, { ".results[].createdAt" => "[ignored]", ".results[].updatedAt" => "[ignored]", ".results[0].uid" => "[ignored]", ".results[].key" => "[ignored]" }), @r#" + meili_snap::snapshot!(meili_snap::json_string!(response, { ".results[].createdAt" => "[ignored]", ".results[].updatedAt" => "[ignored]", ".results[].uid" => "[ignored]", ".results[].key" => "[ignored]" }), @r#" { "results": [ { @@ -824,7 +824,7 @@ async fn list_api_keys() { "name": "Default Search API Key", "description": "Use it to search from the frontend", "key": "[ignored]", - "uid": "00000000-0000-0000-0000-000000000002", + "uid": "[ignored]", "actions": [ "search" ], @@ -839,7 +839,7 @@ async fn list_api_keys() { "name": "Default Admin API Key", "description": "Use it for anything that is not a search operation. Caution! Do not expose it on a public frontend", "key": "[ignored]", - "uid": "00000000-0000-0000-0000-000000000000", + "uid": "[ignored]", "actions": [ "*" ], @@ -854,7 +854,7 @@ async fn list_api_keys() { "name": "Default Read-Only Admin API Key", "description": "Use it to peek into the instance in a read-only mode. Caution! Do not expose it on a public frontend. It would give access to all other keys", "key": "[ignored]", - "uid": "00000000-0000-0000-0000-000000000001", + "uid": "[ignored]", "actions": [ "*.get" ], @@ -869,7 +869,7 @@ async fn list_api_keys() { "name": "Default Chat API Key", "description": "Use it to chat and search from the frontend", "key": "[ignored]", - "uid": "00000000-0000-0000-0000-000000000003", + "uid": "[ignored]", "actions": [ "chatCompletions", "search" From ab768f379ff54ee66f9f3bdf4f643e92a04e8e92 Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Thu, 19 Jun 2025 15:49:34 +0200 Subject: [PATCH 12/81] Fix comment --- crates/meilisearch/tests/common/server.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/meilisearch/tests/common/server.rs b/crates/meilisearch/tests/common/server.rs index 19a082cf3..671ed1ab6 100644 --- a/crates/meilisearch/tests/common/server.rs +++ b/crates/meilisearch/tests/common/server.rs @@ -97,7 +97,7 @@ impl Server { self.use_api_key(master_key); let (response, code) = self.list_api_keys("").await; assert_eq!(200, code, "{:?}", response); - // TODO: relying on the order of keys is not ideal, we should use the static uuid + // TODO: relying on the order of keys is not ideal, we should use the name instead let admin_key = &response["results"][1]["key"]; self.use_api_key(admin_key.as_str().unwrap()); } From 2d6dc83940fe394389e91a6e53e63f53e36b3a22 Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Thu, 19 Jun 2025 15:55:12 +0200 Subject: [PATCH 13/81] Format the code --- crates/meilisearch-auth/src/store.rs | 6 ++++-- crates/meilisearch-types/src/keys.rs | 2 +- crates/meilisearch/tests/index/stats.rs | 1 - 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/meilisearch-auth/src/store.rs b/crates/meilisearch-auth/src/store.rs index bec5d3561..eb2170f08 100644 --- a/crates/meilisearch-auth/src/store.rs +++ b/crates/meilisearch-auth/src/store.rs @@ -91,8 +91,10 @@ impl HeedAuthStore { Action::All => { actions.extend(enum_iterator::all::()); actions.remove(&Action::AllGet); - }, - Action::AllGet => actions.extend(enum_iterator::all::().filter(|a| a.is_read())), + } + Action::AllGet => { + actions.extend(enum_iterator::all::().filter(|a| a.is_read())) + } Action::DocumentsAll => { actions.extend( [Action::DocumentsGet, Action::DocumentsDelete, Action::DocumentsAdd] diff --git a/crates/meilisearch-types/src/keys.rs b/crates/meilisearch-types/src/keys.rs index 3d30c7a0e..48f908a81 100644 --- a/crates/meilisearch-types/src/keys.rs +++ b/crates/meilisearch-types/src/keys.rs @@ -422,7 +422,7 @@ impl Action { match self { // Any action that expands to others must return false, as it wouldn't be able to expand recursively. All | AllGet | DocumentsAll | IndexesAll | ChatsAll | TasksAll | SettingsAll - | StatsAll | MetricsAll | DumpsAll | SnapshotsAll | ChatsSettingsAll => false, + | StatsAll | MetricsAll | DumpsAll | SnapshotsAll | ChatsSettingsAll => false, Search => true, DocumentsAdd => false, diff --git a/crates/meilisearch/tests/index/stats.rs b/crates/meilisearch/tests/index/stats.rs index 90c77cec8..6b2ba16ac 100644 --- a/crates/meilisearch/tests/index/stats.rs +++ b/crates/meilisearch/tests/index/stats.rs @@ -1,5 +1,4 @@ use crate::common::{shared_does_not_exists_index, Server}; - use crate::json; #[actix_rt::test] From c4a96b40eb050d85406d04ec4c7402e98140cde6 Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Tue, 24 Jun 2025 17:40:06 +0200 Subject: [PATCH 14/81] Remove KeysGet from AllGet --- crates/meilisearch-types/src/keys.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/meilisearch-types/src/keys.rs b/crates/meilisearch-types/src/keys.rs index 48f908a81..e4a0dd5d8 100644 --- a/crates/meilisearch-types/src/keys.rs +++ b/crates/meilisearch-types/src/keys.rs @@ -151,7 +151,7 @@ impl Key { name: Some("Default Read-Only Admin API Key".to_string()), description: Some("Use it to peek into the instance in a read-only mode. Caution! Do not expose it on a public frontend. It would give access to all other keys".to_string()), uid, - actions: vec![Action::AllGet], + actions: vec![Action::AllGet, Action::KeysGet], indexes: vec![IndexUidPattern::all()], expires_at: None, created_at: now, @@ -444,14 +444,14 @@ impl Action { SnapshotsCreate => false, Version => true, KeysAdd => false, - KeysGet => true, + KeysGet => false, // Disabled in order to prevent privilege escalation KeysUpdate => false, KeysDelete => false, ExperimentalFeaturesGet => true, ExperimentalFeaturesUpdate => false, NetworkGet => true, NetworkUpdate => false, - ChatCompletions => false, // Disabled because it might trigger generation of new chats. + ChatCompletions => false, // Disabled because it might trigger generation of new chats ChatsGet => true, ChatsDelete => false, ChatsSettingsGet => true, From 1c8f1c18f4fd1969613e75b80ca39c171f65cf3d Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Wed, 25 Jun 2025 09:59:34 +0200 Subject: [PATCH 15/81] Fix constant name and key description --- crates/meilisearch-types/src/keys.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/meilisearch-types/src/keys.rs b/crates/meilisearch-types/src/keys.rs index e4a0dd5d8..96b2e8ae1 100644 --- a/crates/meilisearch-types/src/keys.rs +++ b/crates/meilisearch-types/src/keys.rs @@ -149,7 +149,7 @@ impl Key { let uid = Uuid::new_v4(); Self { name: Some("Default Read-Only Admin API Key".to_string()), - description: Some("Use it to peek into the instance in a read-only mode. Caution! Do not expose it on a public frontend. It would give access to all other keys".to_string()), + description: Some("Use it to peek into the instance in a read-only mode.".to_string()), uid, actions: vec![Action::AllGet, Action::KeysGet], indexes: vec![IndexUidPattern::all()], @@ -468,7 +468,7 @@ pub mod actions { use super::Action::*; pub(crate) const ALL: u8 = All.repr(); - pub const ALL_READ: u8 = AllGet.repr(); + pub const ALL_GET: u8 = AllGet.repr(); pub const SEARCH: u8 = Search.repr(); pub const DOCUMENTS_ALL: u8 = DocumentsAll.repr(); pub const DOCUMENTS_ADD: u8 = DocumentsAdd.repr(); From 2090e9ea316b2dedfa272ba4a00d3b20109d8867 Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Wed, 25 Jun 2025 10:08:25 +0200 Subject: [PATCH 16/81] Update test --- crates/meilisearch/tests/auth/api_keys.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/meilisearch/tests/auth/api_keys.rs b/crates/meilisearch/tests/auth/api_keys.rs index fa09f17cb..60cb2ff46 100644 --- a/crates/meilisearch/tests/auth/api_keys.rs +++ b/crates/meilisearch/tests/auth/api_keys.rs @@ -852,11 +852,12 @@ async fn list_api_keys() { }, { "name": "Default Read-Only Admin API Key", - "description": "Use it to peek into the instance in a read-only mode. Caution! Do not expose it on a public frontend. It would give access to all other keys", + "description": "Use it to peek into the instance in a read-only mode.", "key": "[ignored]", "uid": "[ignored]", "actions": [ - "*.get" + "*.get", + "keys.get" ], "indexes": [ "*" From 6e0526090aa827ffd302d57d4a78f0fc25010589 Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Wed, 25 Jun 2025 15:36:12 +0200 Subject: [PATCH 17/81] Implement sorting documents --- .../src/routes/indexes/documents.rs | 13 ++++ .../milli/src/facet/facet_sort_recursive.rs | 68 +++++++++++++++++++ crates/milli/src/facet/mod.rs | 1 + 3 files changed, 82 insertions(+) create mode 100644 crates/milli/src/facet/facet_sort_recursive.rs diff --git a/crates/meilisearch/src/routes/indexes/documents.rs b/crates/meilisearch/src/routes/indexes/documents.rs index 50eec46fe..99ca2b7df 100644 --- a/crates/meilisearch/src/routes/indexes/documents.rs +++ b/crates/meilisearch/src/routes/indexes/documents.rs @@ -17,6 +17,10 @@ use meilisearch_types::error::deserr_codes::*; use meilisearch_types::error::{Code, ResponseError}; use meilisearch_types::heed::RoTxn; use meilisearch_types::index_uid::IndexUid; +use meilisearch_types::milli::facet::facet_sort_recursive::recursive_facet_sort; +use meilisearch_types::milli::facet::{ascending_facet_sort, descending_facet_sort}; +use meilisearch_types::milli::heed_codec::facet::FacetGroupKeyCodec; +use meilisearch_types::milli::heed_codec::BytesRefCodec; use meilisearch_types::milli::update::IndexDocumentsMethod; use meilisearch_types::milli::vector::parsed_vectors::ExplicitVectors; use meilisearch_types::milli::DocumentId; @@ -1533,6 +1537,15 @@ fn retrieve_documents>( })? } + let fields = vec![(0, true)]; + let number_db = index + .facet_id_f64_docids + .remap_key_type::>(); + let string_db = index + .facet_id_string_docids + .remap_key_type::>(); + candidates = recursive_facet_sort(&rtxn, number_db, string_db, &fields, candidates)?; + let (it, number_of_documents) = { let number_of_documents = candidates.len(); ( diff --git a/crates/milli/src/facet/facet_sort_recursive.rs b/crates/milli/src/facet/facet_sort_recursive.rs new file mode 100644 index 000000000..a6bbad906 --- /dev/null +++ b/crates/milli/src/facet/facet_sort_recursive.rs @@ -0,0 +1,68 @@ +use roaring::RoaringBitmap; +use heed::Database; +use crate::{facet::{ascending_facet_sort, descending_facet_sort}, heed_codec::{facet::{FacetGroupKeyCodec, FacetGroupValueCodec}, BytesRefCodec}}; + +pub fn recursive_facet_sort<'t>( + rtxn: &'t heed::RoTxn<'t>, + number_db: Database, FacetGroupValueCodec>, + string_db: Database, FacetGroupValueCodec>, + fields: &[(u16, bool)], + candidates: RoaringBitmap, +) -> heed::Result { + let (field_id, ascending) = match fields.first() { + Some(first) => *first, + None => return Ok(candidates), + }; + + let (number_iter, string_iter) = if ascending { + let number_iter = ascending_facet_sort( + rtxn, + number_db, + field_id, + candidates.clone(), + )?; + let string_iter = ascending_facet_sort( + rtxn, + string_db, + field_id, + candidates, + )?; + + (itertools::Either::Left(number_iter), itertools::Either::Left(string_iter)) + } else { + let number_iter = descending_facet_sort( + rtxn, + number_db, + field_id, + candidates.clone(), + )?; + let string_iter = descending_facet_sort( + rtxn, + string_db, + field_id, + candidates, + )?; + + (itertools::Either::Right(number_iter), itertools::Either::Right(string_iter)) + }; + + let chained_iter = number_iter.chain(string_iter); + let mut result = RoaringBitmap::new(); + for part in chained_iter { + let (inner_candidates, _) = part?; + if inner_candidates.len() <= 1 || fields.len() <= 1 { + result |= inner_candidates; + } else { + let inner_candidates = recursive_facet_sort( + rtxn, + number_db, + string_db, + &fields[1..], + inner_candidates, + )?; + result |= inner_candidates; + } + } + + Ok(result) +} diff --git a/crates/milli/src/facet/mod.rs b/crates/milli/src/facet/mod.rs index 274d2588d..a6351b42c 100644 --- a/crates/milli/src/facet/mod.rs +++ b/crates/milli/src/facet/mod.rs @@ -1,6 +1,7 @@ mod facet_type; mod facet_value; pub mod value_encoding; +pub mod facet_sort_recursive; pub use self::facet_type::FacetType; pub use self::facet_value::FacetValue; From b05cb80803873d4a17829ba15296b2ad33f3e856 Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Wed, 25 Jun 2025 16:41:08 +0200 Subject: [PATCH 18/81] Take sort criteria from the request --- .../src/routes/indexes/documents.rs | 50 ++++++++++++------- .../milli/src/facet/facet_sort_recursive.rs | 39 +++++++++++++-- crates/milli/src/search/new/mod.rs | 19 +++---- 3 files changed, 79 insertions(+), 29 deletions(-) diff --git a/crates/meilisearch/src/routes/indexes/documents.rs b/crates/meilisearch/src/routes/indexes/documents.rs index 99ca2b7df..d91f43d21 100644 --- a/crates/meilisearch/src/routes/indexes/documents.rs +++ b/crates/meilisearch/src/routes/indexes/documents.rs @@ -1,6 +1,7 @@ use std::collections::HashSet; use std::io::{ErrorKind, Seek as _}; use std::marker::PhantomData; +use std::str::FromStr; use actix_web::http::header::CONTENT_TYPE; use actix_web::web::Data; @@ -18,12 +19,9 @@ use meilisearch_types::error::{Code, ResponseError}; use meilisearch_types::heed::RoTxn; use meilisearch_types::index_uid::IndexUid; use meilisearch_types::milli::facet::facet_sort_recursive::recursive_facet_sort; -use meilisearch_types::milli::facet::{ascending_facet_sort, descending_facet_sort}; -use meilisearch_types::milli::heed_codec::facet::FacetGroupKeyCodec; -use meilisearch_types::milli::heed_codec::BytesRefCodec; use meilisearch_types::milli::update::IndexDocumentsMethod; use meilisearch_types::milli::vector::parsed_vectors::ExplicitVectors; -use meilisearch_types::milli::DocumentId; +use meilisearch_types::milli::{AscDesc, DocumentId}; use meilisearch_types::serde_cs::vec::CS; use meilisearch_types::star_or::OptionStarOrList; use meilisearch_types::tasks::KindWithContent; @@ -46,6 +44,7 @@ use crate::extractors::authentication::policies::*; use crate::extractors::authentication::GuardedData; use crate::extractors::payload::Payload; use crate::extractors::sequential_extractor::SeqHandler; +use crate::routes::indexes::search::fix_sort_query_parameters; use crate::routes::{ get_task_id, is_dry_run, PaginationView, SummarizedTaskView, PAGINATION_DEFAULT_LIMIT, }; @@ -410,6 +409,8 @@ pub struct BrowseQueryGet { #[param(default, value_type = Option, example = "popularity > 1000")] #[deserr(default, error = DeserrQueryParamError)] filter: Option, + #[deserr(default, error = DeserrQueryParamError)] + sort: Option, // TODO: change deser error } #[derive(Debug, Deserr, ToSchema)] @@ -434,6 +435,9 @@ pub struct BrowseQuery { #[schema(default, value_type = Option, example = "popularity > 1000")] #[deserr(default, error = DeserrJsonError)] filter: Option, + #[schema(default, value_type = Option>, example = json!(["title:asc", "rating:desc"]))] + #[deserr(default, error = DeserrJsonError)] // TODO: Change error + pub sort: Option>, } /// Get documents with POST @@ -575,7 +579,7 @@ pub async fn get_documents( ) -> Result { debug!(parameters = ?params, "Get documents GET"); - let BrowseQueryGet { limit, offset, fields, retrieve_vectors, filter, ids } = + let BrowseQueryGet { limit, offset, fields, retrieve_vectors, filter, ids, sort } = params.into_inner(); let filter = match filter { @@ -586,15 +590,14 @@ pub async fn get_documents( None => None, }; - let ids = ids.map(|ids| ids.into_iter().map(Into::into).collect()); - let query = BrowseQuery { offset: offset.0, limit: limit.0, fields: fields.merge_star_and_none(), retrieve_vectors: retrieve_vectors.0, filter, - ids, + ids: ids.map(|ids| ids.into_iter().map(Into::into).collect()), + sort: sort.map(|attr| fix_sort_query_parameters(&attr)), }; analytics.publish( @@ -619,7 +622,7 @@ fn documents_by_query( query: BrowseQuery, ) -> Result { let index_uid = IndexUid::try_from(index_uid.into_inner())?; - let BrowseQuery { offset, limit, fields, retrieve_vectors, filter, ids } = query; + let BrowseQuery { offset, limit, fields, retrieve_vectors, filter, ids, sort } = query; let retrieve_vectors = RetrieveVectors::new(retrieve_vectors); @@ -637,6 +640,22 @@ fn documents_by_query( None }; + let sort_criteria = if let Some(sort) = &sort { + let sorts: Vec<_> = + match sort.iter().map(|s| milli::AscDesc::from_str(s)).collect() { + Ok(sorts) => sorts, + Err(asc_desc_error) => { + return Err(milli::Error::from(milli::SortError::from( + asc_desc_error, + )) + .into()) + } + }; + Some(sorts) + } else { + None + }; + let index = index_scheduler.index(&index_uid)?; let (total, documents) = retrieve_documents( &index, @@ -647,6 +666,7 @@ fn documents_by_query( fields, retrieve_vectors, index_scheduler.features(), + sort_criteria, )?; let ret = PaginationView::new(offset, limit, total as usize, documents); @@ -1505,6 +1525,7 @@ fn retrieve_documents>( attributes_to_retrieve: Option>, retrieve_vectors: RetrieveVectors, features: RoFeatures, + sort_criteria: Option>, ) -> Result<(u64, Vec), ResponseError> { let rtxn = index.read_txn()?; let filter = &filter; @@ -1537,14 +1558,9 @@ fn retrieve_documents>( })? } - let fields = vec![(0, true)]; - let number_db = index - .facet_id_f64_docids - .remap_key_type::>(); - let string_db = index - .facet_id_string_docids - .remap_key_type::>(); - candidates = recursive_facet_sort(&rtxn, number_db, string_db, &fields, candidates)?; + if let Some(sort) = sort_criteria { + candidates = recursive_facet_sort(index, &rtxn, &sort, candidates)?; + } let (it, number_of_documents) = { let number_of_documents = candidates.len(); diff --git a/crates/milli/src/facet/facet_sort_recursive.rs b/crates/milli/src/facet/facet_sort_recursive.rs index a6bbad906..c0fd6ca6f 100644 --- a/crates/milli/src/facet/facet_sort_recursive.rs +++ b/crates/milli/src/facet/facet_sort_recursive.rs @@ -1,8 +1,8 @@ use roaring::RoaringBitmap; use heed::Database; -use crate::{facet::{ascending_facet_sort, descending_facet_sort}, heed_codec::{facet::{FacetGroupKeyCodec, FacetGroupValueCodec}, BytesRefCodec}}; +use crate::{heed_codec::{facet::{FacetGroupKeyCodec, FacetGroupValueCodec}, BytesRefCodec}, search::{facet::{ascending_facet_sort, descending_facet_sort}, new::check_sort_criteria}, AscDesc, Member}; -pub fn recursive_facet_sort<'t>( +fn recursive_facet_sort_inner<'t>( rtxn: &'t heed::RoTxn<'t>, number_db: Database, FacetGroupValueCodec>, string_db: Database, FacetGroupValueCodec>, @@ -53,7 +53,7 @@ pub fn recursive_facet_sort<'t>( if inner_candidates.len() <= 1 || fields.len() <= 1 { result |= inner_candidates; } else { - let inner_candidates = recursive_facet_sort( + let inner_candidates = recursive_facet_sort_inner( rtxn, number_db, string_db, @@ -66,3 +66,36 @@ pub fn recursive_facet_sort<'t>( Ok(result) } + +pub fn recursive_facet_sort<'t>( + index: &crate::Index, + rtxn: &'t heed::RoTxn<'t>, + sort: &[AscDesc], + candidates: RoaringBitmap, +) -> crate::Result { + check_sort_criteria(index, rtxn, Some(sort))?; + + let mut fields = Vec::new(); + let fields_ids_map = index.fields_ids_map(rtxn)?; + for sort in sort { + let (field_id, ascending) = match sort { + AscDesc::Asc(Member::Field(field)) => (fields_ids_map.id(field), true), + AscDesc::Desc(Member::Field(field)) => (fields_ids_map.id(field), false), + AscDesc::Asc(Member::Geo(_)) => todo!(), + AscDesc::Desc(Member::Geo(_)) => todo!(), + }; + if let Some(field_id) = field_id { + fields.push((field_id, ascending)); // FIXME: Should this return an error if the field is not found? + } + } + + let number_db = index + .facet_id_f64_docids + .remap_key_type::>(); + let string_db = index + .facet_id_string_docids + .remap_key_type::>(); + + let candidates = recursive_facet_sort_inner(rtxn, number_db, string_db, &fields, candidates)?; + Ok(candidates) +} diff --git a/crates/milli/src/search/new/mod.rs b/crates/milli/src/search/new/mod.rs index a65b4076b..5cb4c9fd5 100644 --- a/crates/milli/src/search/new/mod.rs +++ b/crates/milli/src/search/new/mod.rs @@ -638,7 +638,7 @@ pub fn execute_vector_search( time_budget: TimeBudget, ranking_score_threshold: Option, ) -> Result { - check_sort_criteria(ctx, sort_criteria.as_ref())?; + check_sort_criteria(ctx.index, ctx.txn, sort_criteria.as_deref())?; // FIXME: input universe = universe & documents_with_vectors // for now if we're computing embeddings for ALL documents, we can assume that this is just universe @@ -702,7 +702,7 @@ pub fn execute_search( ranking_score_threshold: Option, locales: Option<&Vec>, ) -> Result { - check_sort_criteria(ctx, sort_criteria.as_ref())?; + check_sort_criteria(ctx.index, ctx.txn, sort_criteria.as_deref())?; let mut used_negative_operator = false; let mut located_query_terms = None; @@ -872,9 +872,10 @@ pub fn execute_search( }) } -fn check_sort_criteria( - ctx: &SearchContext<'_>, - sort_criteria: Option<&Vec>, +pub(crate) fn check_sort_criteria( + index: &Index, + rtxn: &RoTxn<'_>, + sort_criteria: Option<&[AscDesc]>, ) -> Result<()> { let sort_criteria = if let Some(sort_criteria) = sort_criteria { sort_criteria @@ -888,19 +889,19 @@ fn check_sort_criteria( // We check that the sort ranking rule exists and throw an // error if we try to use it and that it doesn't. - let sort_ranking_rule_missing = !ctx.index.criteria(ctx.txn)?.contains(&crate::Criterion::Sort); + let sort_ranking_rule_missing = !index.criteria(rtxn)?.contains(&crate::Criterion::Sort); if sort_ranking_rule_missing { return Err(UserError::SortRankingRuleMissing.into()); } // We check that we are allowed to use the sort criteria, we check // that they are declared in the sortable fields. - let sortable_fields = ctx.index.sortable_fields(ctx.txn)?; + let sortable_fields = index.sortable_fields(rtxn)?; for asc_desc in sort_criteria { match asc_desc.member() { Member::Field(ref field) if !crate::is_faceted(field, &sortable_fields) => { let (valid_fields, hidden_fields) = - ctx.index.remove_hidden_fields(ctx.txn, sortable_fields)?; + index.remove_hidden_fields(rtxn, sortable_fields)?; return Err(UserError::InvalidSortableAttribute { field: field.to_string(), @@ -911,7 +912,7 @@ fn check_sort_criteria( } Member::Geo(_) if !sortable_fields.contains(RESERVED_GEO_FIELD_NAME) => { let (valid_fields, hidden_fields) = - ctx.index.remove_hidden_fields(ctx.txn, sortable_fields)?; + index.remove_hidden_fields(rtxn, sortable_fields)?; return Err(UserError::InvalidSortableAttribute { field: RESERVED_GEO_FIELD_NAME.to_string(), From 4534dc2cab1ede94527dc3c58690a3d0798b21c6 Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Wed, 25 Jun 2025 16:45:32 +0200 Subject: [PATCH 19/81] Create another deserr error --- crates/meilisearch-types/src/error.rs | 1 + crates/meilisearch/src/routes/indexes/documents.rs | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/meilisearch-types/src/error.rs b/crates/meilisearch-types/src/error.rs index d2500b7e1..2eb22035e 100644 --- a/crates/meilisearch-types/src/error.rs +++ b/crates/meilisearch-types/src/error.rs @@ -237,6 +237,7 @@ InvalidDocumentRetrieveVectors , InvalidRequest , BAD_REQU MissingDocumentFilter , InvalidRequest , BAD_REQUEST ; MissingDocumentEditionFunction , InvalidRequest , BAD_REQUEST ; InvalidDocumentFilter , InvalidRequest , BAD_REQUEST ; +InvalidDocumentSort , InvalidRequest , BAD_REQUEST ; InvalidDocumentGeoField , InvalidRequest , BAD_REQUEST ; InvalidVectorDimensions , InvalidRequest , BAD_REQUEST ; InvalidVectorsType , InvalidRequest , BAD_REQUEST ; diff --git a/crates/meilisearch/src/routes/indexes/documents.rs b/crates/meilisearch/src/routes/indexes/documents.rs index d91f43d21..425930ced 100644 --- a/crates/meilisearch/src/routes/indexes/documents.rs +++ b/crates/meilisearch/src/routes/indexes/documents.rs @@ -409,8 +409,8 @@ pub struct BrowseQueryGet { #[param(default, value_type = Option, example = "popularity > 1000")] #[deserr(default, error = DeserrQueryParamError)] filter: Option, - #[deserr(default, error = DeserrQueryParamError)] - sort: Option, // TODO: change deser error + #[deserr(default, error = DeserrQueryParamError)] + sort: Option, } #[derive(Debug, Deserr, ToSchema)] @@ -436,8 +436,8 @@ pub struct BrowseQuery { #[deserr(default, error = DeserrJsonError)] filter: Option, #[schema(default, value_type = Option>, example = json!(["title:asc", "rating:desc"]))] - #[deserr(default, error = DeserrJsonError)] // TODO: Change error - pub sort: Option>, + #[deserr(default, error = DeserrJsonError)] + sort: Option>, } /// Get documents with POST From c15763f9104d809cad0e9f1889de016addd27da8 Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Fri, 27 Jun 2025 09:39:24 +0200 Subject: [PATCH 20/81] Improve key description Co-authored-by: Tamo --- crates/meilisearch-auth/src/lib.rs | 2 +- crates/meilisearch-types/src/keys.rs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/meilisearch-auth/src/lib.rs b/crates/meilisearch-auth/src/lib.rs index 000e574ac..582606651 100644 --- a/crates/meilisearch-auth/src/lib.rs +++ b/crates/meilisearch-auth/src/lib.rs @@ -351,7 +351,7 @@ pub struct IndexSearchRules { fn generate_default_keys(store: &HeedAuthStore) -> Result<()> { store.put_api_key(Key::default_chat())?; - store.put_api_key(Key::default_management())?; + store.put_api_key(Key::default_read_only_admin_key())?; store.put_api_key(Key::default_admin())?; store.put_api_key(Key::default_search())?; diff --git a/crates/meilisearch-types/src/keys.rs b/crates/meilisearch-types/src/keys.rs index 96b2e8ae1..4a4bc40a8 100644 --- a/crates/meilisearch-types/src/keys.rs +++ b/crates/meilisearch-types/src/keys.rs @@ -144,14 +144,14 @@ impl Key { } } - pub fn default_management() -> Self { + pub fn default_read_only_admin_key() -> Self { let now = OffsetDateTime::now_utc(); let uid = Uuid::new_v4(); Self { name: Some("Default Read-Only Admin API Key".to_string()), - description: Some("Use it to peek into the instance in a read-only mode.".to_string()), + description: Some("Use it to peek into the instance in a read-only mode. Caution: This key gives you access to all the other api keys. Do not expose it on a public frontend".to_string()), uid, - actions: vec![Action::AllGet, Action::KeysGet], + actions: vec![Action::AllGet, Action::KeysGedt], indexes: vec![IndexUidPattern::all()], expires_at: None, created_at: now, From fb9170b8e3120f535a0a1949b8227ac5f862dd94 Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Fri, 27 Jun 2025 09:40:30 +0200 Subject: [PATCH 21/81] Keep name consistent with others --- crates/meilisearch-auth/src/lib.rs | 2 +- crates/meilisearch-types/src/keys.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/meilisearch-auth/src/lib.rs b/crates/meilisearch-auth/src/lib.rs index 582606651..6f5a5c2a2 100644 --- a/crates/meilisearch-auth/src/lib.rs +++ b/crates/meilisearch-auth/src/lib.rs @@ -351,7 +351,7 @@ pub struct IndexSearchRules { fn generate_default_keys(store: &HeedAuthStore) -> Result<()> { store.put_api_key(Key::default_chat())?; - store.put_api_key(Key::default_read_only_admin_key())?; + store.put_api_key(Key::default_read_only_admin())?; store.put_api_key(Key::default_admin())?; store.put_api_key(Key::default_search())?; diff --git a/crates/meilisearch-types/src/keys.rs b/crates/meilisearch-types/src/keys.rs index 4a4bc40a8..7f10e9265 100644 --- a/crates/meilisearch-types/src/keys.rs +++ b/crates/meilisearch-types/src/keys.rs @@ -144,7 +144,7 @@ impl Key { } } - pub fn default_read_only_admin_key() -> Self { + pub fn default_read_only_admin() -> Self { let now = OffsetDateTime::now_utc(); let uid = Uuid::new_v4(); Self { From e3fba62e13ebfc0615fdaac06dc27fd2a9085407 Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Fri, 27 Jun 2025 09:40:59 +0200 Subject: [PATCH 22/81] Fix typo --- crates/meilisearch-types/src/keys.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/meilisearch-types/src/keys.rs b/crates/meilisearch-types/src/keys.rs index 7f10e9265..2911f22a2 100644 --- a/crates/meilisearch-types/src/keys.rs +++ b/crates/meilisearch-types/src/keys.rs @@ -151,7 +151,7 @@ impl Key { name: Some("Default Read-Only Admin API Key".to_string()), description: Some("Use it to peek into the instance in a read-only mode. Caution: This key gives you access to all the other api keys. Do not expose it on a public frontend".to_string()), uid, - actions: vec![Action::AllGet, Action::KeysGedt], + actions: vec![Action::AllGet, Action::KeysGet], indexes: vec![IndexUidPattern::all()], expires_at: None, created_at: now, From 28adbc0d1878c365e295aabdf41a7089a5c4c01e Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Fri, 27 Jun 2025 09:47:46 +0200 Subject: [PATCH 23/81] Update tests --- crates/meilisearch/tests/auth/api_keys.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/meilisearch/tests/auth/api_keys.rs b/crates/meilisearch/tests/auth/api_keys.rs index 60cb2ff46..f717fd53e 100644 --- a/crates/meilisearch/tests/auth/api_keys.rs +++ b/crates/meilisearch/tests/auth/api_keys.rs @@ -852,7 +852,7 @@ async fn list_api_keys() { }, { "name": "Default Read-Only Admin API Key", - "description": "Use it to peek into the instance in a read-only mode.", + "description": "Use it to peek into the instance in a read-only mode. Caution: This key gives you access to all the other api keys. Do not expose it on a public frontend", "key": "[ignored]", "uid": "[ignored]", "actions": [ From 340d9e6edc71621b3c4dba7e95bbd7cf101453ff Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Fri, 27 Jun 2025 14:40:55 +0200 Subject: [PATCH 24/81] Optimize facet sort 5 to 10x speedup --- .../src/routes/indexes/documents.rs | 21 +- .../milli/src/facet/facet_sort_recursive.rs | 295 ++++++++++++++---- 2 files changed, 256 insertions(+), 60 deletions(-) diff --git a/crates/meilisearch/src/routes/indexes/documents.rs b/crates/meilisearch/src/routes/indexes/documents.rs index 425930ced..bcd227300 100644 --- a/crates/meilisearch/src/routes/indexes/documents.rs +++ b/crates/meilisearch/src/routes/indexes/documents.rs @@ -1558,19 +1558,32 @@ fn retrieve_documents>( })? } + let mut facet_sort = None; if let Some(sort) = sort_criteria { - candidates = recursive_facet_sort(index, &rtxn, &sort, candidates)?; + facet_sort = Some(recursive_facet_sort(index, &rtxn, &sort, &candidates)?) } - let (it, number_of_documents) = { + let (it, number_of_documents) = if let Some(facet_sort) = &facet_sort { + let number_of_documents = candidates.len(); + let iter = facet_sort.iter()?; + ( + itertools::Either::Left(some_documents( + index, + &rtxn, + iter.map(|d| d.unwrap()).skip(offset).take(limit), + retrieve_vectors, + )?), + number_of_documents, + ) + } else { let number_of_documents = candidates.len(); ( - some_documents( + itertools::Either::Right(some_documents( index, &rtxn, candidates.into_iter().skip(offset).take(limit), retrieve_vectors, - )?, + )?), number_of_documents, ) }; diff --git a/crates/milli/src/facet/facet_sort_recursive.rs b/crates/milli/src/facet/facet_sort_recursive.rs index c0fd6ca6f..47c3696f3 100644 --- a/crates/milli/src/facet/facet_sort_recursive.rs +++ b/crates/milli/src/facet/facet_sort_recursive.rs @@ -1,78 +1,256 @@ use roaring::RoaringBitmap; use heed::Database; -use crate::{heed_codec::{facet::{FacetGroupKeyCodec, FacetGroupValueCodec}, BytesRefCodec}, search::{facet::{ascending_facet_sort, descending_facet_sort}, new::check_sort_criteria}, AscDesc, Member}; +use crate::{heed_codec::{facet::{FacetGroupKeyCodec, FacetGroupValueCodec}, BytesRefCodec}, search::{facet::{ascending_facet_sort, descending_facet_sort}, new::check_sort_criteria}, AscDesc, DocumentId, Member}; -fn recursive_facet_sort_inner<'t>( - rtxn: &'t heed::RoTxn<'t>, +/// Builder for a [`SortedDocumentsIterator`]. +/// Most builders won't ever be built, because pagination will skip them. +pub struct SortedDocumentsIteratorBuilder<'ctx> { + rtxn: &'ctx heed::RoTxn<'ctx>, number_db: Database, FacetGroupValueCodec>, string_db: Database, FacetGroupValueCodec>, - fields: &[(u16, bool)], + fields: &'ctx [(u16, bool)], candidates: RoaringBitmap, -) -> heed::Result { - let (field_id, ascending) = match fields.first() { - Some(first) => *first, - None => return Ok(candidates), - }; +} - let (number_iter, string_iter) = if ascending { - let number_iter = ascending_facet_sort( +impl<'ctx> SortedDocumentsIteratorBuilder<'ctx> { + /// Performs the sort and builds a [`SortedDocumentsIterator`]. + fn build(self) -> heed::Result> { + let SortedDocumentsIteratorBuilder { rtxn, number_db, - field_id, - candidates.clone(), - )?; - let string_iter = ascending_facet_sort( - rtxn, string_db, - field_id, + fields, candidates, - )?; + } = self; + let size = candidates.len() as usize; - (itertools::Either::Left(number_iter), itertools::Either::Left(string_iter)) - } else { - let number_iter = descending_facet_sort( - rtxn, - number_db, - field_id, - candidates.clone(), - )?; - let string_iter = descending_facet_sort( - rtxn, - string_db, - field_id, - candidates, - )?; + // There is no point sorting a 1-element array + if size <= 1 { + return Ok(SortedDocumentsIterator::Leaf { + size, + values: Box::new(candidates.into_iter()), + }); + } - (itertools::Either::Right(number_iter), itertools::Either::Right(string_iter)) - }; + // There is no variable to sort on + let Some((field_id, ascending)) = fields.first().copied() else { + return Ok(SortedDocumentsIterator::Leaf { + size, + values: Box::new(candidates.into_iter()), + }); + }; - let chained_iter = number_iter.chain(string_iter); - let mut result = RoaringBitmap::new(); - for part in chained_iter { - let (inner_candidates, _) = part?; - if inner_candidates.len() <= 1 || fields.len() <= 1 { - result |= inner_candidates; + // Perform the sort on the first field + let (number_iter, string_iter) = if ascending { + let number_iter = ascending_facet_sort( + rtxn, + number_db, + field_id, + candidates.clone(), + )?; + let string_iter = ascending_facet_sort( + rtxn, + string_db, + field_id, + candidates, + )?; + + (itertools::Either::Left(number_iter), itertools::Either::Left(string_iter)) } else { - let inner_candidates = recursive_facet_sort_inner( + let number_iter = descending_facet_sort( + rtxn, + number_db, + field_id, + candidates.clone(), + )?; + let string_iter = descending_facet_sort( + rtxn, + string_db, + field_id, + candidates, + )?; + + (itertools::Either::Right(number_iter), itertools::Either::Right(string_iter)) + }; + + // Create builders for the next level of the tree + let number_db2 = number_db; + let string_db2 = string_db; + let number_iter = number_iter.map(move |r| -> heed::Result { + let (docids, _bytes) = r?; + Ok(SortedDocumentsIteratorBuilder { rtxn, number_db, string_db, - &fields[1..], - inner_candidates, - )?; - result |= inner_candidates; - } - } + fields: &fields[1..], + candidates: docids, + }) + }); + let string_iter = string_iter.map(move |r| -> heed::Result { + let (docids, _bytes) = r?; + Ok(SortedDocumentsIteratorBuilder { + rtxn, + number_db: number_db2, + string_db: string_db2, + fields: &fields[1..], + candidates: docids, + }) + }); - Ok(result) + Ok(SortedDocumentsIterator::Branch { + current_child: None, + next_children_size: size, + next_children: Box::new(number_iter.chain(string_iter)), + }) + } } -pub fn recursive_facet_sort<'t>( - index: &crate::Index, - rtxn: &'t heed::RoTxn<'t>, +/// A [`SortedDocumentsIterator`] allows efficient access to a continuous range of sorted documents. +/// This is ideal in the context of paginated queries in which only a small number of documents are needed at a time. +/// Search operations will only be performed upon access. +pub enum SortedDocumentsIterator<'ctx> { + Leaf { + /// The exact number of documents remaining + size: usize, + values: Box + 'ctx> + }, + Branch { + /// The current child, got from the children iterator + current_child: Option>>, + /// The exact number of documents remaining, excluding documents in the current child + next_children_size: usize, + /// Iterators to become the current child once it is exhausted + next_children: Box>> + 'ctx>, + } +} + +impl SortedDocumentsIterator<'_> { + /// Takes care of updating the current child if it is `None`, and also updates the size + fn update_current<'ctx>(current_child: &mut Option>>, next_children_size: &mut usize, next_children: &mut Box>> + 'ctx>) -> heed::Result<()> { + if current_child.is_none() { + *current_child = match next_children.next() { + Some(Ok(builder)) => { + let next_child = Box::new(builder.build()?); + *next_children_size -= next_child.size_hint().0; + Some(next_child) + }, + Some(Err(e)) => return Err(e), + None => return Ok(()), + }; + } + Ok(()) + } +} + +impl Iterator for SortedDocumentsIterator<'_> { + type Item = heed::Result; + + fn nth(&mut self, n: usize) -> Option { + // If it's at the leaf level, just forward the call to the values iterator + let (current_child, next_children, next_children_size) = match self { + SortedDocumentsIterator::Leaf { values, size } => { + *size = size.saturating_sub(n); + return values.nth(n).map(Ok) + }, + SortedDocumentsIterator::Branch { current_child, next_children, next_children_size } => (current_child, next_children, next_children_size), + }; + + // Otherwise don't directly iterate over children, skip them if we know we will go further + let mut to_skip = n - 1; + while to_skip > 0 { + if let Err(e) = SortedDocumentsIterator::update_current(current_child, next_children_size, next_children) { + return Some(Err(e)); + } + let Some(inner) = current_child else { + return None; // No more inner iterators, everything has been consumed. + }; + + if to_skip >= inner.size_hint().0 { + // The current child isn't large enough to contain the nth element. + // Skip it and continue with the next one. + to_skip -= inner.size_hint().0; + *current_child = None; + continue; + } else { + // The current iterator is large enough, so we can forward the call to it. + return inner.nth(to_skip + 1); + } + } + + self.next() + } + + fn size_hint(&self) -> (usize, Option) { + let size = match self { + SortedDocumentsIterator::Leaf { size, .. } => *size, + SortedDocumentsIterator::Branch { next_children_size, current_child: Some(current_child), .. } => current_child.size_hint().0 + next_children_size, + SortedDocumentsIterator::Branch { next_children_size, current_child: None, .. } => *next_children_size, + }; + + (size, Some(size)) + } + + fn next(&mut self) -> Option { + match self { + SortedDocumentsIterator::Leaf { values, size } => { + let result = values.next().map(Ok); + if result.is_some() { + *size -= 1; + } + result + }, + SortedDocumentsIterator::Branch { current_child, next_children_size, next_children } => { + let mut result = None; + while result.is_none() { + // Ensure we have selected an iterator to work with + if let Err(e) = SortedDocumentsIterator::update_current(current_child, next_children_size, next_children) { + return Some(Err(e)); + } + let Some(inner) = current_child else { + return None; + }; + + result = inner.next(); + + // If the current iterator is exhausted, we need to try the next one + if result.is_none() { + *current_child = None; + } + } + result + } + } + } +} + +/// A structure owning the data needed during the lifetime of a [`SortedDocumentsIterator`]. +pub struct SortedDocuments<'ctx> { + rtxn: &'ctx heed::RoTxn<'ctx>, + fields: Vec<(u16, bool)>, + number_db: Database, FacetGroupValueCodec>, + string_db: Database, FacetGroupValueCodec>, + candidates: &'ctx RoaringBitmap, +} + +impl <'ctx> SortedDocuments<'ctx> { + pub fn iter(&'ctx self) -> heed::Result> { + let builder = SortedDocumentsIteratorBuilder { + rtxn: self.rtxn, + number_db: self.number_db, + string_db: self.string_db, + fields: &self.fields, + candidates: self.candidates.clone(), + }; + builder.build() + } +} + +pub fn recursive_facet_sort<'ctx>( + index: &'ctx crate::Index, + rtxn: &'ctx heed::RoTxn<'ctx>, sort: &[AscDesc], - candidates: RoaringBitmap, -) -> crate::Result { + candidates: &'ctx RoaringBitmap, +) -> crate::Result> { check_sort_criteria(index, rtxn, Some(sort))?; let mut fields = Vec::new(); @@ -88,7 +266,7 @@ pub fn recursive_facet_sort<'t>( fields.push((field_id, ascending)); // FIXME: Should this return an error if the field is not found? } } - + let number_db = index .facet_id_f64_docids .remap_key_type::>(); @@ -96,6 +274,11 @@ pub fn recursive_facet_sort<'t>( .facet_id_string_docids .remap_key_type::>(); - let candidates = recursive_facet_sort_inner(rtxn, number_db, string_db, &fields, candidates)?; - Ok(candidates) + Ok(SortedDocuments { + rtxn, + fields, + number_db, + string_db, + candidates, + }) } From 63827bbee04e7a98e444901d2d0b2e83e46f7fe3 Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Mon, 30 Jun 2025 11:59:59 +0200 Subject: [PATCH 25/81] Move sorting code out of search --- .gitignore | 8 +- crates/milli/src/documents/geo_sort.rs | 182 ++++++++++++++++++++ crates/milli/src/documents/mod.rs | 1 + crates/milli/src/search/new/distinct.rs | 2 +- crates/milli/src/search/new/geo_sort.rs | 210 ++++-------------------- crates/milli/src/search/new/mod.rs | 2 +- 6 files changed, 227 insertions(+), 178 deletions(-) create mode 100644 crates/milli/src/documents/geo_sort.rs diff --git a/.gitignore b/.gitignore index 07453a58f..d28baee77 100644 --- a/.gitignore +++ b/.gitignore @@ -11,12 +11,18 @@ /bench /_xtask_benchmark.ms /benchmarks +.DS_Store # Snapshots ## ... large *.full.snap -## ... unreviewed +## ... unreviewed *.snap.new +## ... pending +*.pending-snap + +# Tmp files +.tmp* # Fuzzcheck data for the facet indexing fuzz test crates/milli/fuzz/update::facet::incremental::fuzz::fuzz/ diff --git a/crates/milli/src/documents/geo_sort.rs b/crates/milli/src/documents/geo_sort.rs new file mode 100644 index 000000000..5b3968b39 --- /dev/null +++ b/crates/milli/src/documents/geo_sort.rs @@ -0,0 +1,182 @@ +use std::collections::VecDeque; + +use heed::RoTxn; +use roaring::RoaringBitmap; +use rstar::RTree; + +use crate::{ + distance_between_two_points, lat_lng_to_xyz, + search::new::geo_sort::{geo_value, opposite_of}, + GeoPoint, GeoSortStrategy, Index, +}; + +// TODO: Make it take a mut reference to cache +#[allow(clippy::too_many_arguments)] +pub fn fill_cache( + index: &Index, + txn: &RoTxn, + strategy: GeoSortStrategy, + ascending: bool, + target_point: [f64; 2], + field_ids: &Option<[u16; 2]>, + rtree: &mut Option>, + geo_candidates: &RoaringBitmap, + cached_sorted_docids: &mut VecDeque<(u32, [f64; 2])>, +) -> crate::Result<()> { + debug_assert!(cached_sorted_docids.is_empty()); + + // lazily initialize the rtree if needed by the strategy, and cache it in `self.rtree` + let rtree = if strategy.use_rtree(geo_candidates.len() as usize) { + if let Some(rtree) = rtree.as_ref() { + // get rtree from cache + Some(rtree) + } else { + let rtree2 = index.geo_rtree(txn)?.expect("geo candidates but no rtree"); + // insert rtree in cache and returns it. + // Can't use `get_or_insert_with` because getting the rtree from the DB is a fallible operation. + Some(&*rtree.insert(rtree2)) + } + } else { + None + }; + + let cache_size = strategy.cache_size(); + if let Some(rtree) = rtree { + if ascending { + let point = lat_lng_to_xyz(&target_point); + for point in rtree.nearest_neighbor_iter(&point) { + if geo_candidates.contains(point.data.0) { + cached_sorted_docids.push_back(point.data); + if cached_sorted_docids.len() >= cache_size { + break; + } + } + } + } else { + // in the case of the desc geo sort we look for the closest point to the opposite of the queried point + // and we insert the points in reverse order they get reversed when emptying the cache later on + let point = lat_lng_to_xyz(&opposite_of(target_point)); + for point in rtree.nearest_neighbor_iter(&point) { + if geo_candidates.contains(point.data.0) { + cached_sorted_docids.push_front(point.data); + if cached_sorted_docids.len() >= cache_size { + break; + } + } + } + } + } else { + // the iterative version + let [lat, lng] = field_ids.expect("fill_buffer can't be called without the lat&lng"); + + let mut documents = geo_candidates + .iter() + .map(|id| -> crate::Result<_> { Ok((id, geo_value(id, lat, lng, index, txn)?)) }) + .collect::>>()?; + // computing the distance between two points is expensive thus we cache the result + documents.sort_by_cached_key(|(_, p)| distance_between_two_points(&target_point, p) as usize); + cached_sorted_docids.extend(documents); + }; + + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +pub fn next_bucket( + index: &Index, + txn: &RoTxn, + universe: &RoaringBitmap, + strategy: GeoSortStrategy, + ascending: bool, + target_point: [f64; 2], + field_ids: &Option<[u16; 2]>, + rtree: &mut Option>, + + cached_sorted_docids: &mut VecDeque<(u32, [f64; 2])>, + geo_candidates: &RoaringBitmap, + + // Limit the number of docs in a single bucket to avoid unexpectedly large overhead + max_bucket_size: u64, + // Considering the errors of GPS and geographical calculations, distances less than distance_error_margin will be treated as equal + distance_error_margin: f64, +) -> crate::Result)>> { + let mut geo_candidates = geo_candidates & universe; + + if geo_candidates.is_empty() { + return Ok(Some((universe.clone(), None))); + } + + let next = |cache: &mut VecDeque<_>| { + if ascending { + cache.pop_front() + } else { + cache.pop_back() + } + }; + let put_back = |cache: &mut VecDeque<_>, x: _| { + if ascending { + cache.push_front(x) + } else { + cache.push_back(x) + } + }; + + let mut current_bucket = RoaringBitmap::new(); + // current_distance stores the first point and distance in current bucket + let mut current_distance: Option<([f64; 2], f64)> = None; + loop { + // The loop will only exit when we have found all points with equal distance or have exhausted the candidates. + if let Some((id, point)) = next(cached_sorted_docids) { + if geo_candidates.contains(id) { + let distance = distance_between_two_points(&target_point, &point); + if let Some((point0, bucket_distance)) = current_distance.as_ref() { + if (bucket_distance - distance).abs() > distance_error_margin { + // different distance, point belongs to next bucket + put_back(cached_sorted_docids, (id, point)); + return Ok(Some((current_bucket, Some(point0.to_owned())))); + } else { + // same distance, point belongs to current bucket + current_bucket.insert(id); + // remove from candidates to prevent it from being added to the cache again + geo_candidates.remove(id); + // current bucket size reaches limit, force return + if current_bucket.len() == max_bucket_size { + return Ok(Some((current_bucket, Some(point0.to_owned())))); + } + } + } else { + // first doc in current bucket + current_distance = Some((point, distance)); + current_bucket.insert(id); + geo_candidates.remove(id); + // current bucket size reaches limit, force return + if current_bucket.len() == max_bucket_size { + return Ok(Some((current_bucket, Some(point.to_owned())))); + } + } + } + } else { + // cache exhausted, we need to refill it + fill_cache( + index, + txn, + strategy, + ascending, + target_point, + field_ids, + rtree, + &geo_candidates, + cached_sorted_docids, + )?; + + if cached_sorted_docids.is_empty() { + // candidates exhausted, exit + if let Some((point0, _)) = current_distance.as_ref() { + return Ok(Some((current_bucket, Some(point0.to_owned())))); + } else { + return Ok(Some((universe.clone(), None))); + } + } + } + } +} diff --git a/crates/milli/src/documents/mod.rs b/crates/milli/src/documents/mod.rs index f43f7e842..6a05f61a5 100644 --- a/crates/milli/src/documents/mod.rs +++ b/crates/milli/src/documents/mod.rs @@ -3,6 +3,7 @@ mod enriched; mod primary_key; mod reader; mod serde_impl; +pub mod geo_sort; use std::fmt::Debug; use std::io; diff --git a/crates/milli/src/search/new/distinct.rs b/crates/milli/src/search/new/distinct.rs index 36172302a..48ad152ee 100644 --- a/crates/milli/src/search/new/distinct.rs +++ b/crates/milli/src/search/new/distinct.rs @@ -82,7 +82,7 @@ fn facet_value_docids( } /// Return an iterator over each number value in the given field of the given document. -fn facet_number_values<'a>( +pub(crate) fn facet_number_values<'a>( docid: u32, field_id: u16, index: &Index, diff --git a/crates/milli/src/search/new/geo_sort.rs b/crates/milli/src/search/new/geo_sort.rs index 3e7fe3458..a52a84575 100644 --- a/crates/milli/src/search/new/geo_sort.rs +++ b/crates/milli/src/search/new/geo_sort.rs @@ -7,12 +7,10 @@ use rstar::RTree; use super::facet_string_values; use super::ranking_rules::{RankingRule, RankingRuleOutput, RankingRuleQueryTrait}; +use crate::documents::geo_sort::{fill_cache, next_bucket}; use crate::heed_codec::facet::{FieldDocIdFacetCodec, OrderedF64Codec}; use crate::score_details::{self, ScoreDetails}; -use crate::{ - distance_between_two_points, lat_lng_to_xyz, GeoPoint, Index, Result, SearchContext, - SearchLogger, -}; +use crate::{GeoPoint, Index, Result, SearchContext, SearchLogger}; const FID_SIZE: usize = 2; const DOCID_SIZE: usize = 4; @@ -134,62 +132,17 @@ impl GeoSort { ctx: &mut SearchContext<'_>, geo_candidates: &RoaringBitmap, ) -> Result<()> { - debug_assert!(self.field_ids.is_some(), "fill_buffer can't be called without the lat&lng"); - debug_assert!(self.cached_sorted_docids.is_empty()); - - // lazily initialize the rtree if needed by the strategy, and cache it in `self.rtree` - let rtree = if self.strategy.use_rtree(geo_candidates.len() as usize) { - if let Some(rtree) = self.rtree.as_ref() { - // get rtree from cache - Some(rtree) - } else { - let rtree = ctx.index.geo_rtree(ctx.txn)?.expect("geo candidates but no rtree"); - // insert rtree in cache and returns it. - // Can't use `get_or_insert_with` because getting the rtree from the DB is a fallible operation. - Some(&*self.rtree.insert(rtree)) - } - } else { - None - }; - - let cache_size = self.strategy.cache_size(); - if let Some(rtree) = rtree { - if self.ascending { - let point = lat_lng_to_xyz(&self.point); - for point in rtree.nearest_neighbor_iter(&point) { - if geo_candidates.contains(point.data.0) { - self.cached_sorted_docids.push_back(point.data); - if self.cached_sorted_docids.len() >= cache_size { - break; - } - } - } - } else { - // in the case of the desc geo sort we look for the closest point to the opposite of the queried point - // and we insert the points in reverse order they get reversed when emptying the cache later on - let point = lat_lng_to_xyz(&opposite_of(self.point)); - for point in rtree.nearest_neighbor_iter(&point) { - if geo_candidates.contains(point.data.0) { - self.cached_sorted_docids.push_front(point.data); - if self.cached_sorted_docids.len() >= cache_size { - break; - } - } - } - } - } else { - // the iterative version - let [lat, lng] = self.field_ids.unwrap(); - - let mut documents = geo_candidates - .iter() - .map(|id| -> Result<_> { Ok((id, geo_value(id, lat, lng, ctx.index, ctx.txn)?)) }) - .collect::>>()?; - // computing the distance between two points is expensive thus we cache the result - documents - .sort_by_cached_key(|(_, p)| distance_between_two_points(&self.point, p) as usize); - self.cached_sorted_docids.extend(documents); - }; + fill_cache( + ctx.index, + ctx.txn, + self.strategy, + self.ascending, + self.point, + &self.field_ids, + &mut self.rtree, + geo_candidates, + &mut self.cached_sorted_docids, + )?; Ok(()) } @@ -199,7 +152,7 @@ impl GeoSort { /// /// If it is not able to find it in the facet number index it will extract it /// from the facet string index and parse it as f64 (as the geo extraction behaves). -fn geo_value( +pub(crate) fn geo_value( docid: u32, field_lat: u16, field_lng: u16, @@ -267,124 +220,31 @@ impl<'ctx, Q: RankingRuleQueryTrait> RankingRule<'ctx, Q> for GeoSort { ) -> Result>> { let query = self.query.as_ref().unwrap().clone(); - let mut geo_candidates = &self.geo_candidates & universe; - - if geo_candidates.is_empty() { - return Ok(Some(RankingRuleOutput { + next_bucket( + ctx.index, + ctx.txn, + universe, + self.strategy, + self.ascending, + self.point, + &self.field_ids, + &mut self.rtree, + &mut self.cached_sorted_docids, + &self.geo_candidates, + self.max_bucket_size, + self.distance_error_margin, + ) + .map(|o| { + o.map(|(candidates, point)| RankingRuleOutput { query, - candidates: universe.clone(), + candidates, score: ScoreDetails::GeoSort(score_details::GeoSort { target_point: self.point, ascending: self.ascending, - value: None, + value: point, }), - })); - } - - let ascending = self.ascending; - let next = |cache: &mut VecDeque<_>| { - if ascending { - cache.pop_front() - } else { - cache.pop_back() - } - }; - let put_back = |cache: &mut VecDeque<_>, x: _| { - if ascending { - cache.push_front(x) - } else { - cache.push_back(x) - } - }; - - let mut current_bucket = RoaringBitmap::new(); - // current_distance stores the first point and distance in current bucket - let mut current_distance: Option<([f64; 2], f64)> = None; - loop { - // The loop will only exit when we have found all points with equal distance or have exhausted the candidates. - if let Some((id, point)) = next(&mut self.cached_sorted_docids) { - if geo_candidates.contains(id) { - let distance = distance_between_two_points(&self.point, &point); - if let Some((point0, bucket_distance)) = current_distance.as_ref() { - if (bucket_distance - distance).abs() > self.distance_error_margin { - // different distance, point belongs to next bucket - put_back(&mut self.cached_sorted_docids, (id, point)); - return Ok(Some(RankingRuleOutput { - query, - candidates: current_bucket, - score: ScoreDetails::GeoSort(score_details::GeoSort { - target_point: self.point, - ascending: self.ascending, - value: Some(point0.to_owned()), - }), - })); - } else { - // same distance, point belongs to current bucket - current_bucket.insert(id); - // remove from cadidates to prevent it from being added to the cache again - geo_candidates.remove(id); - // current bucket size reaches limit, force return - if current_bucket.len() == self.max_bucket_size { - return Ok(Some(RankingRuleOutput { - query, - candidates: current_bucket, - score: ScoreDetails::GeoSort(score_details::GeoSort { - target_point: self.point, - ascending: self.ascending, - value: Some(point0.to_owned()), - }), - })); - } - } - } else { - // first doc in current bucket - current_distance = Some((point, distance)); - current_bucket.insert(id); - geo_candidates.remove(id); - // current bucket size reaches limit, force return - if current_bucket.len() == self.max_bucket_size { - return Ok(Some(RankingRuleOutput { - query, - candidates: current_bucket, - score: ScoreDetails::GeoSort(score_details::GeoSort { - target_point: self.point, - ascending: self.ascending, - value: Some(point.to_owned()), - }), - })); - } - } - } - } else { - // cache exhausted, we need to refill it - self.fill_buffer(ctx, &geo_candidates)?; - - if self.cached_sorted_docids.is_empty() { - // candidates exhausted, exit - if let Some((point0, _)) = current_distance.as_ref() { - return Ok(Some(RankingRuleOutput { - query, - candidates: current_bucket, - score: ScoreDetails::GeoSort(score_details::GeoSort { - target_point: self.point, - ascending: self.ascending, - value: Some(point0.to_owned()), - }), - })); - } else { - return Ok(Some(RankingRuleOutput { - query, - candidates: universe.clone(), - score: ScoreDetails::GeoSort(score_details::GeoSort { - target_point: self.point, - ascending: self.ascending, - value: None, - }), - })); - } - } - } - } + }) + }) } #[tracing::instrument(level = "trace", skip_all, target = "search::geo_sort")] @@ -396,7 +256,7 @@ impl<'ctx, Q: RankingRuleQueryTrait> RankingRule<'ctx, Q> for GeoSort { } /// Compute the antipodal coordinate of `coord` -fn opposite_of(mut coord: [f64; 2]) -> [f64; 2] { +pub(crate) fn opposite_of(mut coord: [f64; 2]) -> [f64; 2] { coord[0] *= -1.; // in the case of x,0 we want to return x,180 if coord[1] > 0. { diff --git a/crates/milli/src/search/new/mod.rs b/crates/milli/src/search/new/mod.rs index 5cb4c9fd5..da5e971af 100644 --- a/crates/milli/src/search/new/mod.rs +++ b/crates/milli/src/search/new/mod.rs @@ -1,7 +1,7 @@ mod bucket_sort; mod db_cache; mod distinct; -mod geo_sort; +pub(crate) mod geo_sort; mod graph_based_ranking_rule; mod interner; mod limits; From e35d58b531d5753aa47371bc056831b6edf4b2c9 Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Mon, 30 Jun 2025 13:12:00 +0200 Subject: [PATCH 26/81] Move geosort code out of search --- .../src/routes/indexes/documents.rs | 16 +- crates/milli/src/documents/geo_sort.rs | 153 ++++++++++++++-- crates/milli/src/documents/mod.rs | 3 +- .../milli/src/facet/facet_sort_recursive.rs | 171 +++++++++--------- crates/milli/src/facet/mod.rs | 2 +- crates/milli/src/lib.rs | 5 +- crates/milli/src/search/mod.rs | 8 +- crates/milli/src/search/new/distinct.rs | 2 +- crates/milli/src/search/new/geo_sort.rs | 124 +------------ crates/milli/src/search/new/mod.rs | 16 +- 10 files changed, 257 insertions(+), 243 deletions(-) diff --git a/crates/meilisearch/src/routes/indexes/documents.rs b/crates/meilisearch/src/routes/indexes/documents.rs index bcd227300..5545c870e 100644 --- a/crates/meilisearch/src/routes/indexes/documents.rs +++ b/crates/meilisearch/src/routes/indexes/documents.rs @@ -641,16 +641,12 @@ fn documents_by_query( }; let sort_criteria = if let Some(sort) = &sort { - let sorts: Vec<_> = - match sort.iter().map(|s| milli::AscDesc::from_str(s)).collect() { - Ok(sorts) => sorts, - Err(asc_desc_error) => { - return Err(milli::Error::from(milli::SortError::from( - asc_desc_error, - )) - .into()) - } - }; + let sorts: Vec<_> = match sort.iter().map(|s| milli::AscDesc::from_str(s)).collect() { + Ok(sorts) => sorts, + Err(asc_desc_error) => { + return Err(milli::Error::from(milli::SortError::from(asc_desc_error)).into()) + } + }; Some(sorts) } else { None diff --git a/crates/milli/src/documents/geo_sort.rs b/crates/milli/src/documents/geo_sort.rs index 5b3968b39..5b899e6d5 100644 --- a/crates/milli/src/documents/geo_sort.rs +++ b/crates/milli/src/documents/geo_sort.rs @@ -1,14 +1,70 @@ -use std::collections::VecDeque; - -use heed::RoTxn; +use crate::{ + distance_between_two_points, + heed_codec::facet::{FieldDocIdFacetCodec, OrderedF64Codec}, + lat_lng_to_xyz, + search::new::{facet_string_values, facet_values_prefix_key}, + GeoPoint, Index, +}; +use heed::{ + types::{Bytes, Unit}, + RoPrefix, RoTxn, +}; use roaring::RoaringBitmap; use rstar::RTree; +use std::collections::VecDeque; -use crate::{ - distance_between_two_points, lat_lng_to_xyz, - search::new::geo_sort::{geo_value, opposite_of}, - GeoPoint, GeoSortStrategy, Index, -}; +#[derive(Debug, Clone, Copy)] +pub struct GeoSortParameter { + // Define the strategy used by the geo sort + pub strategy: GeoSortStrategy, + // Limit the number of docs in a single bucket to avoid unexpectedly large overhead + pub max_bucket_size: u64, + // Considering the errors of GPS and geographical calculations, distances less than distance_error_margin will be treated as equal + pub distance_error_margin: f64, +} + +impl Default for GeoSortParameter { + fn default() -> Self { + Self { + strategy: GeoSortStrategy::default(), + max_bucket_size: 1000, + distance_error_margin: 1.0, + } + } +} +/// Define the strategy used by the geo sort. +/// The parameter represents the cache size, and, in the case of the Dynamic strategy, +/// the point where we move from using the iterative strategy to the rtree. +#[derive(Debug, Clone, Copy)] +pub enum GeoSortStrategy { + AlwaysIterative(usize), + AlwaysRtree(usize), + Dynamic(usize), +} + +impl Default for GeoSortStrategy { + fn default() -> Self { + GeoSortStrategy::Dynamic(1000) + } +} + +impl GeoSortStrategy { + pub fn use_rtree(&self, candidates: usize) -> bool { + match self { + GeoSortStrategy::AlwaysIterative(_) => false, + GeoSortStrategy::AlwaysRtree(_) => true, + GeoSortStrategy::Dynamic(i) => candidates >= *i, + } + } + + pub fn cache_size(&self) -> usize { + match self { + GeoSortStrategy::AlwaysIterative(i) + | GeoSortStrategy::AlwaysRtree(i) + | GeoSortStrategy::Dynamic(i) => *i, + } + } +} // TODO: Make it take a mut reference to cache #[allow(clippy::too_many_arguments)] @@ -74,7 +130,8 @@ pub fn fill_cache( .map(|id| -> crate::Result<_> { Ok((id, geo_value(id, lat, lng, index, txn)?)) }) .collect::>>()?; // computing the distance between two points is expensive thus we cache the result - documents.sort_by_cached_key(|(_, p)| distance_between_two_points(&target_point, p) as usize); + documents + .sort_by_cached_key(|(_, p)| distance_between_two_points(&target_point, p) as usize); cached_sorted_docids.extend(documents); }; @@ -86,19 +143,13 @@ pub fn next_bucket( index: &Index, txn: &RoTxn, universe: &RoaringBitmap, - strategy: GeoSortStrategy, ascending: bool, target_point: [f64; 2], field_ids: &Option<[u16; 2]>, rtree: &mut Option>, - cached_sorted_docids: &mut VecDeque<(u32, [f64; 2])>, geo_candidates: &RoaringBitmap, - - // Limit the number of docs in a single bucket to avoid unexpectedly large overhead - max_bucket_size: u64, - // Considering the errors of GPS and geographical calculations, distances less than distance_error_margin will be treated as equal - distance_error_margin: f64, + parameter: GeoSortParameter, ) -> crate::Result)>> { let mut geo_candidates = geo_candidates & universe; @@ -130,7 +181,7 @@ pub fn next_bucket( if geo_candidates.contains(id) { let distance = distance_between_two_points(&target_point, &point); if let Some((point0, bucket_distance)) = current_distance.as_ref() { - if (bucket_distance - distance).abs() > distance_error_margin { + if (bucket_distance - distance).abs() > parameter.distance_error_margin { // different distance, point belongs to next bucket put_back(cached_sorted_docids, (id, point)); return Ok(Some((current_bucket, Some(point0.to_owned())))); @@ -140,7 +191,7 @@ pub fn next_bucket( // remove from candidates to prevent it from being added to the cache again geo_candidates.remove(id); // current bucket size reaches limit, force return - if current_bucket.len() == max_bucket_size { + if current_bucket.len() == parameter.max_bucket_size { return Ok(Some((current_bucket, Some(point0.to_owned())))); } } @@ -150,7 +201,7 @@ pub fn next_bucket( current_bucket.insert(id); geo_candidates.remove(id); // current bucket size reaches limit, force return - if current_bucket.len() == max_bucket_size { + if current_bucket.len() == parameter.max_bucket_size { return Ok(Some((current_bucket, Some(point.to_owned())))); } } @@ -160,7 +211,7 @@ pub fn next_bucket( fill_cache( index, txn, - strategy, + parameter.strategy, ascending, target_point, field_ids, @@ -180,3 +231,65 @@ pub fn next_bucket( } } } + +/// Return an iterator over each number value in the given field of the given document. +fn facet_number_values<'a>( + docid: u32, + field_id: u16, + index: &Index, + txn: &'a RoTxn<'a>, +) -> crate::Result, Unit>> { + let key = facet_values_prefix_key(field_id, docid); + + let iter = index + .field_id_docid_facet_f64s + .remap_key_type::() + .prefix_iter(txn, &key)? + .remap_key_type(); + + Ok(iter) +} + +/// Extracts the lat and long values from a single document. +/// +/// If it is not able to find it in the facet number index it will extract it +/// from the facet string index and parse it as f64 (as the geo extraction behaves). +pub(crate) fn geo_value( + docid: u32, + field_lat: u16, + field_lng: u16, + index: &Index, + rtxn: &RoTxn<'_>, +) -> crate::Result<[f64; 2]> { + let extract_geo = |geo_field: u16| -> crate::Result { + match facet_number_values(docid, geo_field, index, rtxn)?.next() { + Some(Ok(((_, _, geo), ()))) => Ok(geo), + Some(Err(e)) => Err(e.into()), + None => match facet_string_values(docid, geo_field, index, rtxn)?.next() { + Some(Ok((_, geo))) => { + Ok(geo.parse::().expect("cannot parse geo field as f64")) + } + Some(Err(e)) => Err(e.into()), + None => panic!("A geo faceted document doesn't contain any lat or lng"), + }, + } + }; + + let lat = extract_geo(field_lat)?; + let lng = extract_geo(field_lng)?; + + Ok([lat, lng]) +} + +/// Compute the antipodal coordinate of `coord` +pub(crate) fn opposite_of(mut coord: [f64; 2]) -> [f64; 2] { + coord[0] *= -1.; + // in the case of x,0 we want to return x,180 + if coord[1] > 0. { + coord[1] -= 180.; + } else { + coord[1] += 180.; + } + + coord +} diff --git a/crates/milli/src/documents/mod.rs b/crates/milli/src/documents/mod.rs index 6a05f61a5..b515c4e98 100644 --- a/crates/milli/src/documents/mod.rs +++ b/crates/milli/src/documents/mod.rs @@ -1,9 +1,9 @@ mod builder; mod enriched; +pub mod geo_sort; mod primary_key; mod reader; mod serde_impl; -pub mod geo_sort; use std::fmt::Debug; use std::io; @@ -20,6 +20,7 @@ pub use primary_key::{ pub use reader::{DocumentsBatchCursor, DocumentsBatchCursorError, DocumentsBatchReader}; use serde::{Deserialize, Serialize}; +pub use self::geo_sort::{GeoSortParameter, GeoSortStrategy}; use crate::error::{FieldIdMapMissingEntry, InternalError}; use crate::{FieldId, Object, Result}; diff --git a/crates/milli/src/facet/facet_sort_recursive.rs b/crates/milli/src/facet/facet_sort_recursive.rs index 47c3696f3..7342114ef 100644 --- a/crates/milli/src/facet/facet_sort_recursive.rs +++ b/crates/milli/src/facet/facet_sort_recursive.rs @@ -1,6 +1,16 @@ -use roaring::RoaringBitmap; +use crate::{ + heed_codec::{ + facet::{FacetGroupKeyCodec, FacetGroupValueCodec}, + BytesRefCodec, + }, + search::{ + facet::{ascending_facet_sort, descending_facet_sort}, + new::check_sort_criteria, + }, + AscDesc, DocumentId, Member, +}; use heed::Database; -use crate::{heed_codec::{facet::{FacetGroupKeyCodec, FacetGroupValueCodec}, BytesRefCodec}, search::{facet::{ascending_facet_sort, descending_facet_sort}, new::check_sort_criteria}, AscDesc, DocumentId, Member}; +use roaring::RoaringBitmap; /// Builder for a [`SortedDocumentsIterator`]. /// Most builders won't ever be built, because pagination will skip them. @@ -15,13 +25,8 @@ pub struct SortedDocumentsIteratorBuilder<'ctx> { impl<'ctx> SortedDocumentsIteratorBuilder<'ctx> { /// Performs the sort and builds a [`SortedDocumentsIterator`]. fn build(self) -> heed::Result> { - let SortedDocumentsIteratorBuilder { - rtxn, - number_db, - string_db, - fields, - candidates, - } = self; + let SortedDocumentsIteratorBuilder { rtxn, number_db, string_db, fields, candidates } = + self; let size = candidates.len() as usize; // There is no point sorting a 1-element array @@ -42,33 +47,13 @@ impl<'ctx> SortedDocumentsIteratorBuilder<'ctx> { // Perform the sort on the first field let (number_iter, string_iter) = if ascending { - let number_iter = ascending_facet_sort( - rtxn, - number_db, - field_id, - candidates.clone(), - )?; - let string_iter = ascending_facet_sort( - rtxn, - string_db, - field_id, - candidates, - )?; + let number_iter = ascending_facet_sort(rtxn, number_db, field_id, candidates.clone())?; + let string_iter = ascending_facet_sort(rtxn, string_db, field_id, candidates)?; (itertools::Either::Left(number_iter), itertools::Either::Left(string_iter)) } else { - let number_iter = descending_facet_sort( - rtxn, - number_db, - field_id, - candidates.clone(), - )?; - let string_iter = descending_facet_sort( - rtxn, - string_db, - field_id, - candidates, - )?; + let number_iter = descending_facet_sort(rtxn, number_db, field_id, candidates.clone())?; + let string_iter = descending_facet_sort(rtxn, string_db, field_id, candidates)?; (itertools::Either::Right(number_iter), itertools::Either::Right(string_iter)) }; @@ -76,26 +61,28 @@ impl<'ctx> SortedDocumentsIteratorBuilder<'ctx> { // Create builders for the next level of the tree let number_db2 = number_db; let string_db2 = string_db; - let number_iter = number_iter.map(move |r| -> heed::Result { - let (docids, _bytes) = r?; - Ok(SortedDocumentsIteratorBuilder { - rtxn, - number_db, - string_db, - fields: &fields[1..], - candidates: docids, - }) - }); - let string_iter = string_iter.map(move |r| -> heed::Result { - let (docids, _bytes) = r?; - Ok(SortedDocumentsIteratorBuilder { - rtxn, - number_db: number_db2, - string_db: string_db2, - fields: &fields[1..], - candidates: docids, - }) - }); + let number_iter = + number_iter.map(move |r| -> heed::Result { + let (docids, _bytes) = r?; + Ok(SortedDocumentsIteratorBuilder { + rtxn, + number_db, + string_db, + fields: &fields[1..], + candidates: docids, + }) + }); + let string_iter = + string_iter.map(move |r| -> heed::Result { + let (docids, _bytes) = r?; + Ok(SortedDocumentsIteratorBuilder { + rtxn, + number_db: number_db2, + string_db: string_db2, + fields: &fields[1..], + candidates: docids, + }) + }); Ok(SortedDocumentsIterator::Branch { current_child: None, @@ -112,7 +99,7 @@ pub enum SortedDocumentsIterator<'ctx> { Leaf { /// The exact number of documents remaining size: usize, - values: Box + 'ctx> + values: Box + 'ctx>, }, Branch { /// The current child, got from the children iterator @@ -120,20 +107,27 @@ pub enum SortedDocumentsIterator<'ctx> { /// The exact number of documents remaining, excluding documents in the current child next_children_size: usize, /// Iterators to become the current child once it is exhausted - next_children: Box>> + 'ctx>, - } + next_children: + Box>> + 'ctx>, + }, } impl SortedDocumentsIterator<'_> { /// Takes care of updating the current child if it is `None`, and also updates the size - fn update_current<'ctx>(current_child: &mut Option>>, next_children_size: &mut usize, next_children: &mut Box>> + 'ctx>) -> heed::Result<()> { + fn update_current<'ctx>( + current_child: &mut Option>>, + next_children_size: &mut usize, + next_children: &mut Box< + dyn Iterator>> + 'ctx, + >, + ) -> heed::Result<()> { if current_child.is_none() { *current_child = match next_children.next() { Some(Ok(builder)) => { let next_child = Box::new(builder.build()?); *next_children_size -= next_child.size_hint().0; Some(next_child) - }, + } Some(Err(e)) => return Err(e), None => return Ok(()), }; @@ -150,15 +144,23 @@ impl Iterator for SortedDocumentsIterator<'_> { let (current_child, next_children, next_children_size) = match self { SortedDocumentsIterator::Leaf { values, size } => { *size = size.saturating_sub(n); - return values.nth(n).map(Ok) - }, - SortedDocumentsIterator::Branch { current_child, next_children, next_children_size } => (current_child, next_children, next_children_size), + return values.nth(n).map(Ok); + } + SortedDocumentsIterator::Branch { + current_child, + next_children, + next_children_size, + } => (current_child, next_children, next_children_size), }; // Otherwise don't directly iterate over children, skip them if we know we will go further let mut to_skip = n - 1; while to_skip > 0 { - if let Err(e) = SortedDocumentsIterator::update_current(current_child, next_children_size, next_children) { + if let Err(e) = SortedDocumentsIterator::update_current( + current_child, + next_children_size, + next_children, + ) { return Some(Err(e)); } let Some(inner) = current_child else { @@ -183,8 +185,14 @@ impl Iterator for SortedDocumentsIterator<'_> { fn size_hint(&self) -> (usize, Option) { let size = match self { SortedDocumentsIterator::Leaf { size, .. } => *size, - SortedDocumentsIterator::Branch { next_children_size, current_child: Some(current_child), .. } => current_child.size_hint().0 + next_children_size, - SortedDocumentsIterator::Branch { next_children_size, current_child: None, .. } => *next_children_size, + SortedDocumentsIterator::Branch { + next_children_size, + current_child: Some(current_child), + .. + } => current_child.size_hint().0 + next_children_size, + SortedDocumentsIterator::Branch { next_children_size, current_child: None, .. } => { + *next_children_size + } }; (size, Some(size)) @@ -198,12 +206,20 @@ impl Iterator for SortedDocumentsIterator<'_> { *size -= 1; } result - }, - SortedDocumentsIterator::Branch { current_child, next_children_size, next_children } => { + } + SortedDocumentsIterator::Branch { + current_child, + next_children_size, + next_children, + } => { let mut result = None; while result.is_none() { // Ensure we have selected an iterator to work with - if let Err(e) = SortedDocumentsIterator::update_current(current_child, next_children_size, next_children) { + if let Err(e) = SortedDocumentsIterator::update_current( + current_child, + next_children_size, + next_children, + ) { return Some(Err(e)); } let Some(inner) = current_child else { @@ -232,7 +248,7 @@ pub struct SortedDocuments<'ctx> { candidates: &'ctx RoaringBitmap, } -impl <'ctx> SortedDocuments<'ctx> { +impl<'ctx> SortedDocuments<'ctx> { pub fn iter(&'ctx self) -> heed::Result> { let builder = SortedDocumentsIteratorBuilder { rtxn: self.rtxn, @@ -266,19 +282,10 @@ pub fn recursive_facet_sort<'ctx>( fields.push((field_id, ascending)); // FIXME: Should this return an error if the field is not found? } } - - let number_db = index - .facet_id_f64_docids - .remap_key_type::>(); - let string_db = index - .facet_id_string_docids - .remap_key_type::>(); - Ok(SortedDocuments { - rtxn, - fields, - number_db, - string_db, - candidates, - }) + let number_db = index.facet_id_f64_docids.remap_key_type::>(); + let string_db = + index.facet_id_string_docids.remap_key_type::>(); + + Ok(SortedDocuments { rtxn, fields, number_db, string_db, candidates }) } diff --git a/crates/milli/src/facet/mod.rs b/crates/milli/src/facet/mod.rs index a6351b42c..8b0b9a25e 100644 --- a/crates/milli/src/facet/mod.rs +++ b/crates/milli/src/facet/mod.rs @@ -1,7 +1,7 @@ +pub mod facet_sort_recursive; mod facet_type; mod facet_value; pub mod value_encoding; -pub mod facet_sort_recursive; pub use self::facet_type::FacetType; pub use self::facet_value::FacetValue; diff --git a/crates/milli/src/lib.rs b/crates/milli/src/lib.rs index 504b4c68d..6fdae86b3 100644 --- a/crates/milli/src/lib.rs +++ b/crates/milli/src/lib.rs @@ -43,12 +43,13 @@ use std::fmt; use std::hash::BuildHasherDefault; use charabia::normalizer::{CharNormalizer, CompatibilityDecompositionNormalizer}; +pub use documents::GeoSortStrategy; pub use filter_parser::{Condition, FilterCondition, Span, Token}; use fxhash::{FxHasher32, FxHasher64}; pub use grenad::CompressionType; pub use search::new::{ - execute_search, filtered_universe, DefaultSearchLogger, GeoSortStrategy, SearchContext, - SearchLogger, VisualSearchLogger, + execute_search, filtered_universe, DefaultSearchLogger, SearchContext, SearchLogger, + VisualSearchLogger, }; use serde_json::Value; pub use thread_pool_no_abort::{PanicCatched, ThreadPoolNoAbort, ThreadPoolNoAbortBuilder}; diff --git a/crates/milli/src/search/mod.rs b/crates/milli/src/search/mod.rs index 62183afc3..48013b2ee 100644 --- a/crates/milli/src/search/mod.rs +++ b/crates/milli/src/search/mod.rs @@ -9,6 +9,8 @@ use roaring::bitmap::RoaringBitmap; pub use self::facet::{FacetDistribution, Filter, OrderBy, DEFAULT_VALUES_PER_FACET}; pub use self::new::matches::{FormatOptions, MatchBounds, MatcherBuilder, MatchingWords}; use self::new::{execute_vector_search, PartialSearchResult, VectorStoreStats}; +use crate::documents::GeoSortParameter; +use crate::documents::GeoSortStrategy; use crate::filterable_attributes_rules::{filtered_matching_patterns, matching_features}; use crate::index::MatchingStrategy; use crate::score_details::{ScoreDetails, ScoringStrategy}; @@ -46,7 +48,7 @@ pub struct Search<'a> { sort_criteria: Option>, distinct: Option, searchable_attributes: Option<&'a [String]>, - geo_param: new::GeoSortParameter, + geo_param: GeoSortParameter, terms_matching_strategy: TermsMatchingStrategy, scoring_strategy: ScoringStrategy, words_limit: usize, @@ -69,7 +71,7 @@ impl<'a> Search<'a> { sort_criteria: None, distinct: None, searchable_attributes: None, - geo_param: new::GeoSortParameter::default(), + geo_param: GeoSortParameter::default(), terms_matching_strategy: TermsMatchingStrategy::default(), scoring_strategy: Default::default(), exhaustive_number_hits: false, @@ -145,7 +147,7 @@ impl<'a> Search<'a> { } #[cfg(test)] - pub fn geo_sort_strategy(&mut self, strategy: new::GeoSortStrategy) -> &mut Search<'a> { + pub fn geo_sort_strategy(&mut self, strategy: GeoSortStrategy) -> &mut Search<'a> { self.geo_param.strategy = strategy; self } diff --git a/crates/milli/src/search/new/distinct.rs b/crates/milli/src/search/new/distinct.rs index 48ad152ee..455b495f5 100644 --- a/crates/milli/src/search/new/distinct.rs +++ b/crates/milli/src/search/new/distinct.rs @@ -118,7 +118,7 @@ pub fn facet_string_values<'a>( } #[allow(clippy::drop_non_drop)] -fn facet_values_prefix_key(distinct: u16, id: u32) -> [u8; FID_SIZE + DOCID_SIZE] { +pub(crate) fn facet_values_prefix_key(distinct: u16, id: u32) -> [u8; FID_SIZE + DOCID_SIZE] { concat_arrays::concat_arrays!(distinct.to_be_bytes(), id.to_be_bytes()) } diff --git a/crates/milli/src/search/new/geo_sort.rs b/crates/milli/src/search/new/geo_sort.rs index a52a84575..47001267d 100644 --- a/crates/milli/src/search/new/geo_sort.rs +++ b/crates/milli/src/search/new/geo_sort.rs @@ -8,6 +8,7 @@ use rstar::RTree; use super::facet_string_values; use super::ranking_rules::{RankingRule, RankingRuleOutput, RankingRuleQueryTrait}; use crate::documents::geo_sort::{fill_cache, next_bucket}; +use crate::documents::{GeoSortParameter, GeoSortStrategy}; use crate::heed_codec::facet::{FieldDocIdFacetCodec, OrderedF64Codec}; use crate::score_details::{self, ScoreDetails}; use crate::{GeoPoint, Index, Result, SearchContext, SearchLogger}; @@ -20,75 +21,10 @@ fn facet_values_prefix_key(distinct: u16, id: u32) -> [u8; FID_SIZE + DOCID_SIZE concat_arrays::concat_arrays!(distinct.to_be_bytes(), id.to_be_bytes()) } -/// Return an iterator over each number value in the given field of the given document. -fn facet_number_values<'a>( - docid: u32, - field_id: u16, - index: &Index, - txn: &'a RoTxn<'a>, -) -> Result, Unit>> { - let key = facet_values_prefix_key(field_id, docid); - - let iter = index - .field_id_docid_facet_f64s - .remap_key_type::() - .prefix_iter(txn, &key)? - .remap_key_type(); - - Ok(iter) -} - -#[derive(Debug, Clone, Copy)] -pub struct Parameter { - // Define the strategy used by the geo sort - pub strategy: Strategy, - // Limit the number of docs in a single bucket to avoid unexpectedly large overhead - pub max_bucket_size: u64, - // Considering the errors of GPS and geographical calculations, distances less than distance_error_margin will be treated as equal - pub distance_error_margin: f64, -} - -impl Default for Parameter { - fn default() -> Self { - Self { strategy: Strategy::default(), max_bucket_size: 1000, distance_error_margin: 1.0 } - } -} -/// Define the strategy used by the geo sort. -/// The parameter represents the cache size, and, in the case of the Dynamic strategy, -/// the point where we move from using the iterative strategy to the rtree. -#[derive(Debug, Clone, Copy)] -pub enum Strategy { - AlwaysIterative(usize), - AlwaysRtree(usize), - Dynamic(usize), -} - -impl Default for Strategy { - fn default() -> Self { - Strategy::Dynamic(1000) - } -} - -impl Strategy { - pub fn use_rtree(&self, candidates: usize) -> bool { - match self { - Strategy::AlwaysIterative(_) => false, - Strategy::AlwaysRtree(_) => true, - Strategy::Dynamic(i) => candidates >= *i, - } - } - - pub fn cache_size(&self) -> usize { - match self { - Strategy::AlwaysIterative(i) | Strategy::AlwaysRtree(i) | Strategy::Dynamic(i) => *i, - } - } -} - pub struct GeoSort { query: Option, - strategy: Strategy, + strategy: GeoSortStrategy, ascending: bool, point: [f64; 2], field_ids: Option<[u16; 2]>, @@ -105,12 +41,12 @@ pub struct GeoSort { impl GeoSort { pub fn new( - parameter: Parameter, + parameter: GeoSortParameter, geo_faceted_docids: RoaringBitmap, point: [f64; 2], ascending: bool, ) -> Result { - let Parameter { strategy, max_bucket_size, distance_error_margin } = parameter; + let GeoSortParameter { strategy, max_bucket_size, distance_error_margin } = parameter; Ok(Self { query: None, strategy, @@ -148,37 +84,6 @@ impl GeoSort { } } -/// Extracts the lat and long values from a single document. -/// -/// If it is not able to find it in the facet number index it will extract it -/// from the facet string index and parse it as f64 (as the geo extraction behaves). -pub(crate) fn geo_value( - docid: u32, - field_lat: u16, - field_lng: u16, - index: &Index, - rtxn: &RoTxn<'_>, -) -> Result<[f64; 2]> { - let extract_geo = |geo_field: u16| -> Result { - match facet_number_values(docid, geo_field, index, rtxn)?.next() { - Some(Ok(((_, _, geo), ()))) => Ok(geo), - Some(Err(e)) => Err(e.into()), - None => match facet_string_values(docid, geo_field, index, rtxn)?.next() { - Some(Ok((_, geo))) => { - Ok(geo.parse::().expect("cannot parse geo field as f64")) - } - Some(Err(e)) => Err(e.into()), - None => panic!("A geo faceted document doesn't contain any lat or lng"), - }, - } - }; - - let lat = extract_geo(field_lat)?; - let lng = extract_geo(field_lng)?; - - Ok([lat, lng]) -} - impl<'ctx, Q: RankingRuleQueryTrait> RankingRule<'ctx, Q> for GeoSort { fn id(&self) -> String { "geo_sort".to_owned() @@ -224,15 +129,17 @@ impl<'ctx, Q: RankingRuleQueryTrait> RankingRule<'ctx, Q> for GeoSort { ctx.index, ctx.txn, universe, - self.strategy, self.ascending, self.point, &self.field_ids, &mut self.rtree, &mut self.cached_sorted_docids, &self.geo_candidates, - self.max_bucket_size, - self.distance_error_margin, + GeoSortParameter { + strategy: self.strategy, + max_bucket_size: self.max_bucket_size, + distance_error_margin: self.distance_error_margin, + }, ) .map(|o| { o.map(|(candidates, point)| RankingRuleOutput { @@ -254,16 +161,3 @@ impl<'ctx, Q: RankingRuleQueryTrait> RankingRule<'ctx, Q> for GeoSort { self.cached_sorted_docids.clear(); } } - -/// Compute the antipodal coordinate of `coord` -pub(crate) fn opposite_of(mut coord: [f64; 2]) -> [f64; 2] { - coord[0] *= -1.; - // in the case of x,0 we want to return x,180 - if coord[1] > 0. { - coord[1] -= 180.; - } else { - coord[1] += 180.; - } - - coord -} diff --git a/crates/milli/src/search/new/mod.rs b/crates/milli/src/search/new/mod.rs index da5e971af..b5258413e 100644 --- a/crates/milli/src/search/new/mod.rs +++ b/crates/milli/src/search/new/mod.rs @@ -46,14 +46,14 @@ use resolve_query_graph::{compute_query_graph_docids, PhraseDocIdsCache}; use roaring::RoaringBitmap; use sort::Sort; -use self::distinct::facet_string_values; +pub(crate) use self::distinct::{facet_string_values, facet_values_prefix_key}; use self::geo_sort::GeoSort; -pub use self::geo_sort::{Parameter as GeoSortParameter, Strategy as GeoSortStrategy}; use self::graph_based_ranking_rule::Words; use self::interner::Interned; use self::vector_sort::VectorSort; use crate::attribute_patterns::{match_pattern, PatternMatch}; use crate::constants::RESERVED_GEO_FIELD_NAME; +use crate::documents::GeoSortParameter; use crate::index::PrefixSearch; use crate::localized_attributes_rules::LocalizedFieldIds; use crate::score_details::{ScoreDetails, ScoringStrategy}; @@ -319,7 +319,7 @@ fn resolve_negative_phrases( fn get_ranking_rules_for_placeholder_search<'ctx>( ctx: &SearchContext<'ctx>, sort_criteria: &Option>, - geo_param: geo_sort::Parameter, + geo_param: GeoSortParameter, ) -> Result>> { let mut sort = false; let mut sorted_fields = HashSet::new(); @@ -371,7 +371,7 @@ fn get_ranking_rules_for_placeholder_search<'ctx>( fn get_ranking_rules_for_vector<'ctx>( ctx: &SearchContext<'ctx>, sort_criteria: &Option>, - geo_param: geo_sort::Parameter, + geo_param: GeoSortParameter, limit_plus_offset: usize, target: &[f32], embedder_name: &str, @@ -448,7 +448,7 @@ fn get_ranking_rules_for_vector<'ctx>( fn get_ranking_rules_for_query_graph_search<'ctx>( ctx: &SearchContext<'ctx>, sort_criteria: &Option>, - geo_param: geo_sort::Parameter, + geo_param: GeoSortParameter, terms_matching_strategy: TermsMatchingStrategy, ) -> Result>> { // query graph search @@ -559,7 +559,7 @@ fn resolve_sort_criteria<'ctx, Query: RankingRuleQueryTrait>( ranking_rules: &mut Vec>, sorted_fields: &mut HashSet, geo_sorted: &mut bool, - geo_param: geo_sort::Parameter, + geo_param: GeoSortParameter, ) -> Result<()> { let sort_criteria = sort_criteria.clone().unwrap_or_default(); ranking_rules.reserve(sort_criteria.len()); @@ -629,7 +629,7 @@ pub fn execute_vector_search( universe: RoaringBitmap, sort_criteria: &Option>, distinct: &Option, - geo_param: geo_sort::Parameter, + geo_param: GeoSortParameter, from: usize, length: usize, embedder_name: &str, @@ -692,7 +692,7 @@ pub fn execute_search( mut universe: RoaringBitmap, sort_criteria: &Option>, distinct: &Option, - geo_param: geo_sort::Parameter, + geo_param: GeoSortParameter, from: usize, length: usize, words_limit: Option, From f86f4f619f2b69d408347b85c631f0e3942e888c Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Mon, 30 Jun 2025 13:57:30 +0200 Subject: [PATCH 27/81] Implement geo sort on documents --- .../src/routes/indexes/documents.rs | 2 +- crates/milli/src/documents/geo_sort.rs | 1 - .../milli/src/facet/facet_sort_recursive.rs | 180 +++++++++++++++--- crates/milli/src/search/mod.rs | 3 +- crates/milli/src/search/new/geo_sort.rs | 14 +- 5 files changed, 152 insertions(+), 48 deletions(-) diff --git a/crates/meilisearch/src/routes/indexes/documents.rs b/crates/meilisearch/src/routes/indexes/documents.rs index 5545c870e..be6d647f7 100644 --- a/crates/meilisearch/src/routes/indexes/documents.rs +++ b/crates/meilisearch/src/routes/indexes/documents.rs @@ -1556,7 +1556,7 @@ fn retrieve_documents>( let mut facet_sort = None; if let Some(sort) = sort_criteria { - facet_sort = Some(recursive_facet_sort(index, &rtxn, &sort, &candidates)?) + facet_sort = Some(recursive_facet_sort(index, &rtxn, sort, &candidates)?) } let (it, number_of_documents) = if let Some(facet_sort) = &facet_sort { diff --git a/crates/milli/src/documents/geo_sort.rs b/crates/milli/src/documents/geo_sort.rs index 5b899e6d5..0750dfe5c 100644 --- a/crates/milli/src/documents/geo_sort.rs +++ b/crates/milli/src/documents/geo_sort.rs @@ -66,7 +66,6 @@ impl GeoSortStrategy { } } -// TODO: Make it take a mut reference to cache #[allow(clippy::too_many_arguments)] pub fn fill_cache( index: &Index, diff --git a/crates/milli/src/facet/facet_sort_recursive.rs b/crates/milli/src/facet/facet_sort_recursive.rs index 7342114ef..87da20391 100644 --- a/crates/milli/src/facet/facet_sort_recursive.rs +++ b/crates/milli/src/facet/facet_sort_recursive.rs @@ -1,4 +1,7 @@ +use std::collections::VecDeque; + use crate::{ + documents::{geo_sort::next_bucket, GeoSortParameter}, heed_codec::{ facet::{FacetGroupKeyCodec, FacetGroupValueCodec}, BytesRefCodec, @@ -12,38 +15,64 @@ use crate::{ use heed::Database; use roaring::RoaringBitmap; +#[derive(Debug, Clone, Copy)] +enum AscDescId { + Facet { field_id: u16, ascending: bool }, + Geo { field_ids: [u16; 2], target_point: [f64; 2], ascending: bool }, +} + /// Builder for a [`SortedDocumentsIterator`]. /// Most builders won't ever be built, because pagination will skip them. pub struct SortedDocumentsIteratorBuilder<'ctx> { + index: &'ctx crate::Index, rtxn: &'ctx heed::RoTxn<'ctx>, number_db: Database, FacetGroupValueCodec>, string_db: Database, FacetGroupValueCodec>, - fields: &'ctx [(u16, bool)], + fields: &'ctx [AscDescId], candidates: RoaringBitmap, + geo_candidates: &'ctx RoaringBitmap, } impl<'ctx> SortedDocumentsIteratorBuilder<'ctx> { /// Performs the sort and builds a [`SortedDocumentsIterator`]. - fn build(self) -> heed::Result> { - let SortedDocumentsIteratorBuilder { rtxn, number_db, string_db, fields, candidates } = - self; - let size = candidates.len() as usize; + fn build(self) -> crate::Result> { + let size = self.candidates.len() as usize; // There is no point sorting a 1-element array if size <= 1 { return Ok(SortedDocumentsIterator::Leaf { size, - values: Box::new(candidates.into_iter()), + values: Box::new(self.candidates.into_iter()), }); } - // There is no variable to sort on - let Some((field_id, ascending)) = fields.first().copied() else { - return Ok(SortedDocumentsIterator::Leaf { + match self.fields.first().copied() { + Some(AscDescId::Facet { field_id, ascending }) => self.build_facet(field_id, ascending), + Some(AscDescId::Geo { field_ids, target_point, ascending }) => { + self.build_geo(field_ids, target_point, ascending) + } + None => Ok(SortedDocumentsIterator::Leaf { size, - values: Box::new(candidates.into_iter()), - }); - }; + values: Box::new(self.candidates.into_iter()), + }), + } + } + + fn build_facet( + self, + field_id: u16, + ascending: bool, + ) -> crate::Result> { + let SortedDocumentsIteratorBuilder { + index, + rtxn, + number_db, + string_db, + fields, + candidates, + geo_candidates, + } = self; + let size = candidates.len() as usize; // Perform the sort on the first field let (number_iter, string_iter) = if ascending { @@ -62,25 +91,29 @@ impl<'ctx> SortedDocumentsIteratorBuilder<'ctx> { let number_db2 = number_db; let string_db2 = string_db; let number_iter = - number_iter.map(move |r| -> heed::Result { + number_iter.map(move |r| -> crate::Result { let (docids, _bytes) = r?; Ok(SortedDocumentsIteratorBuilder { + index, rtxn, number_db, string_db, fields: &fields[1..], candidates: docids, + geo_candidates, }) }); let string_iter = - string_iter.map(move |r| -> heed::Result { + string_iter.map(move |r| -> crate::Result { let (docids, _bytes) = r?; Ok(SortedDocumentsIteratorBuilder { + index, rtxn, number_db: number_db2, string_db: string_db2, fields: &fields[1..], candidates: docids, + geo_candidates, }) }); @@ -90,6 +123,60 @@ impl<'ctx> SortedDocumentsIteratorBuilder<'ctx> { next_children: Box::new(number_iter.chain(string_iter)), }) } + + fn build_geo( + self, + field_ids: [u16; 2], + target_point: [f64; 2], + ascending: bool, + ) -> crate::Result> { + let SortedDocumentsIteratorBuilder { + index, + rtxn, + number_db, + string_db, + fields, + candidates, + geo_candidates, + } = self; + + let mut cache = VecDeque::new(); + let mut rtree = None; + let size = candidates.len() as usize; + + let next_children = std::iter::from_fn(move || { + match next_bucket( + index, + rtxn, + &candidates, + ascending, + target_point, + &Some(field_ids), + &mut rtree, + &mut cache, + geo_candidates, + GeoSortParameter::default(), + ) { + Ok(Some((docids, _point))) => Some(Ok(SortedDocumentsIteratorBuilder { + index, + rtxn, + number_db, + string_db, + fields: &fields[1..], + candidates: docids, + geo_candidates, + })), + Ok(None) => None, + Err(e) => Some(Err(e)), + } + }); + + Ok(SortedDocumentsIterator::Branch { + current_child: None, + next_children_size: size, // TODO: confirm all candidates will be included + next_children: Box::new(next_children), + }) + } } /// A [`SortedDocumentsIterator`] allows efficient access to a continuous range of sorted documents. @@ -108,7 +195,7 @@ pub enum SortedDocumentsIterator<'ctx> { next_children_size: usize, /// Iterators to become the current child once it is exhausted next_children: - Box>> + 'ctx>, + Box>> + 'ctx>, }, } @@ -118,9 +205,9 @@ impl SortedDocumentsIterator<'_> { current_child: &mut Option>>, next_children_size: &mut usize, next_children: &mut Box< - dyn Iterator>> + 'ctx, + dyn Iterator>> + 'ctx, >, - ) -> heed::Result<()> { + ) -> crate::Result<()> { if current_child.is_none() { *current_child = match next_children.next() { Some(Ok(builder)) => { @@ -137,7 +224,7 @@ impl SortedDocumentsIterator<'_> { } impl Iterator for SortedDocumentsIterator<'_> { - type Item = heed::Result; + type Item = crate::Result; fn nth(&mut self, n: usize) -> Option { // If it's at the leaf level, just forward the call to the values iterator @@ -241,21 +328,25 @@ impl Iterator for SortedDocumentsIterator<'_> { /// A structure owning the data needed during the lifetime of a [`SortedDocumentsIterator`]. pub struct SortedDocuments<'ctx> { + index: &'ctx crate::Index, rtxn: &'ctx heed::RoTxn<'ctx>, - fields: Vec<(u16, bool)>, + fields: Vec, number_db: Database, FacetGroupValueCodec>, string_db: Database, FacetGroupValueCodec>, candidates: &'ctx RoaringBitmap, + geo_candidates: RoaringBitmap, } impl<'ctx> SortedDocuments<'ctx> { - pub fn iter(&'ctx self) -> heed::Result> { + pub fn iter(&'ctx self) -> crate::Result> { let builder = SortedDocumentsIteratorBuilder { + index: self.index, rtxn: self.rtxn, number_db: self.number_db, string_db: self.string_db, fields: &self.fields, candidates: self.candidates.clone(), + geo_candidates: &self.geo_candidates, }; builder.build() } @@ -264,28 +355,55 @@ impl<'ctx> SortedDocuments<'ctx> { pub fn recursive_facet_sort<'ctx>( index: &'ctx crate::Index, rtxn: &'ctx heed::RoTxn<'ctx>, - sort: &[AscDesc], + sort: Vec, candidates: &'ctx RoaringBitmap, ) -> crate::Result> { - check_sort_criteria(index, rtxn, Some(sort))?; + check_sort_criteria(index, rtxn, Some(&sort))?; let mut fields = Vec::new(); let fields_ids_map = index.fields_ids_map(rtxn)?; + let geo_candidates = index.geo_faceted_documents_ids(rtxn)?; // TODO: skip when no geo sort for sort in sort { - let (field_id, ascending) = match sort { - AscDesc::Asc(Member::Field(field)) => (fields_ids_map.id(field), true), - AscDesc::Desc(Member::Field(field)) => (fields_ids_map.id(field), false), - AscDesc::Asc(Member::Geo(_)) => todo!(), - AscDesc::Desc(Member::Geo(_)) => todo!(), + match sort { + AscDesc::Asc(Member::Field(field)) => { + if let Some(field_id) = fields_ids_map.id(&field) { + fields.push(AscDescId::Facet { field_id, ascending: true }); + } + } + AscDesc::Desc(Member::Field(field)) => { + if let Some(field_id) = fields_ids_map.id(&field) { + fields.push(AscDescId::Facet { field_id, ascending: false }); + } + } + AscDesc::Asc(Member::Geo(target_point)) => { + if let (Some(lat), Some(lng)) = + (fields_ids_map.id("_geo.lat"), fields_ids_map.id("_geo.lng")) + { + fields.push(AscDescId::Geo { + field_ids: [lat, lng], + target_point, + ascending: true, + }); + } + } + AscDesc::Desc(Member::Geo(target_point)) => { + if let (Some(lat), Some(lng)) = + (fields_ids_map.id("_geo.lat"), fields_ids_map.id("_geo.lng")) + { + fields.push(AscDescId::Geo { + field_ids: [lat, lng], + target_point, + ascending: false, + }); + } + } }; - if let Some(field_id) = field_id { - fields.push((field_id, ascending)); // FIXME: Should this return an error if the field is not found? - } + // FIXME: Should this return an error if the field is not found? } let number_db = index.facet_id_f64_docids.remap_key_type::>(); let string_db = index.facet_id_string_docids.remap_key_type::>(); - Ok(SortedDocuments { rtxn, fields, number_db, string_db, candidates }) + Ok(SortedDocuments { index, rtxn, fields, number_db, string_db, candidates, geo_candidates }) } diff --git a/crates/milli/src/search/mod.rs b/crates/milli/src/search/mod.rs index 48013b2ee..b073d271c 100644 --- a/crates/milli/src/search/mod.rs +++ b/crates/milli/src/search/mod.rs @@ -10,7 +10,6 @@ pub use self::facet::{FacetDistribution, Filter, OrderBy, DEFAULT_VALUES_PER_FAC pub use self::new::matches::{FormatOptions, MatchBounds, MatcherBuilder, MatchingWords}; use self::new::{execute_vector_search, PartialSearchResult, VectorStoreStats}; use crate::documents::GeoSortParameter; -use crate::documents::GeoSortStrategy; use crate::filterable_attributes_rules::{filtered_matching_patterns, matching_features}; use crate::index::MatchingStrategy; use crate::score_details::{ScoreDetails, ScoringStrategy}; @@ -147,7 +146,7 @@ impl<'a> Search<'a> { } #[cfg(test)] - pub fn geo_sort_strategy(&mut self, strategy: GeoSortStrategy) -> &mut Search<'a> { + pub fn geo_sort_strategy(&mut self, strategy: crate::GeoSortStrategy) -> &mut Search<'a> { self.geo_param.strategy = strategy; self } diff --git a/crates/milli/src/search/new/geo_sort.rs b/crates/milli/src/search/new/geo_sort.rs index 47001267d..6c7d7b03b 100644 --- a/crates/milli/src/search/new/geo_sort.rs +++ b/crates/milli/src/search/new/geo_sort.rs @@ -1,25 +1,13 @@ use std::collections::VecDeque; -use heed::types::{Bytes, Unit}; -use heed::{RoPrefix, RoTxn}; use roaring::RoaringBitmap; use rstar::RTree; -use super::facet_string_values; use super::ranking_rules::{RankingRule, RankingRuleOutput, RankingRuleQueryTrait}; use crate::documents::geo_sort::{fill_cache, next_bucket}; use crate::documents::{GeoSortParameter, GeoSortStrategy}; -use crate::heed_codec::facet::{FieldDocIdFacetCodec, OrderedF64Codec}; use crate::score_details::{self, ScoreDetails}; -use crate::{GeoPoint, Index, Result, SearchContext, SearchLogger}; - -const FID_SIZE: usize = 2; -const DOCID_SIZE: usize = 4; - -#[allow(clippy::drop_non_drop)] -fn facet_values_prefix_key(distinct: u16, id: u32) -> [u8; FID_SIZE + DOCID_SIZE] { - concat_arrays::concat_arrays!(distinct.to_be_bytes(), id.to_be_bytes()) -} +use crate::{GeoPoint, Result, SearchContext, SearchLogger}; pub struct GeoSort { query: Option, From f6803dd7d100b9d419747c69fc03f2207a64dfa9 Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Mon, 30 Jun 2025 14:05:23 +0200 Subject: [PATCH 28/81] Simplify iterator chaining in facet sort --- .../milli/src/facet/facet_sort_recursive.rs | 43 ++++++------------- 1 file changed, 14 insertions(+), 29 deletions(-) diff --git a/crates/milli/src/facet/facet_sort_recursive.rs b/crates/milli/src/facet/facet_sort_recursive.rs index 87da20391..6f26ad16f 100644 --- a/crates/milli/src/facet/facet_sort_recursive.rs +++ b/crates/milli/src/facet/facet_sort_recursive.rs @@ -88,39 +88,24 @@ impl<'ctx> SortedDocumentsIteratorBuilder<'ctx> { }; // Create builders for the next level of the tree - let number_db2 = number_db; - let string_db2 = string_db; - let number_iter = - number_iter.map(move |r| -> crate::Result { - let (docids, _bytes) = r?; - Ok(SortedDocumentsIteratorBuilder { - index, - rtxn, - number_db, - string_db, - fields: &fields[1..], - candidates: docids, - geo_candidates, - }) - }); - let string_iter = - string_iter.map(move |r| -> crate::Result { - let (docids, _bytes) = r?; - Ok(SortedDocumentsIteratorBuilder { - index, - rtxn, - number_db: number_db2, - string_db: string_db2, - fields: &fields[1..], - candidates: docids, - geo_candidates, - }) - }); + let number_iter = number_iter.map(|r| r.map(|(d, _)| d)); + let string_iter = string_iter.map(|r| r.map(|(d, _)| d)); + let next_children = number_iter.chain(string_iter).map(move |r| { + Ok(SortedDocumentsIteratorBuilder { + index, + rtxn, + number_db, + string_db, + fields: &fields[1..], + candidates: r?, + geo_candidates, + }) + }); Ok(SortedDocumentsIterator::Branch { current_child: None, next_children_size: size, - next_children: Box::new(number_iter.chain(string_iter)), + next_children: Box::new(next_children), }) } From 29e9c74a49dd152c1db37e6b54d547dfeebc3746 Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Mon, 30 Jun 2025 16:17:04 +0200 Subject: [PATCH 29/81] Merge two ifs --- crates/meilisearch/src/routes/indexes/documents.rs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/crates/meilisearch/src/routes/indexes/documents.rs b/crates/meilisearch/src/routes/indexes/documents.rs index be6d647f7..d9b3f106f 100644 --- a/crates/meilisearch/src/routes/indexes/documents.rs +++ b/crates/meilisearch/src/routes/indexes/documents.rs @@ -1554,13 +1554,10 @@ fn retrieve_documents>( })? } - let mut facet_sort = None; - if let Some(sort) = sort_criteria { - facet_sort = Some(recursive_facet_sort(index, &rtxn, sort, &candidates)?) - } - - let (it, number_of_documents) = if let Some(facet_sort) = &facet_sort { + let facet_sort; + let (it, number_of_documents) = if let Some(sort) = sort_criteria { let number_of_documents = candidates.len(); + facet_sort = recursive_facet_sort(index, &rtxn, sort, &candidates)?; let iter = facet_sort.iter()?; ( itertools::Either::Left(some_documents( From eb2c2815b63c049d5e2aeabe6b67283bb2759ca6 Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Tue, 1 Jul 2025 10:00:10 +0200 Subject: [PATCH 30/81] Fix panic --- .../milli/src/facet/facet_sort_recursive.rs | 65 +++++++++++++------ 1 file changed, 44 insertions(+), 21 deletions(-) diff --git a/crates/milli/src/facet/facet_sort_recursive.rs b/crates/milli/src/facet/facet_sort_recursive.rs index 6f26ad16f..213d18624 100644 --- a/crates/milli/src/facet/facet_sort_recursive.rs +++ b/crates/milli/src/facet/facet_sort_recursive.rs @@ -128,37 +128,60 @@ impl<'ctx> SortedDocumentsIteratorBuilder<'ctx> { let mut cache = VecDeque::new(); let mut rtree = None; let size = candidates.len() as usize; + let not_geo_candidates = candidates.clone() - geo_candidates; + let mut geo_remaining = size - not_geo_candidates.len() as usize; + let mut not_geo_candidates = Some(not_geo_candidates); let next_children = std::iter::from_fn(move || { - match next_bucket( - index, - rtxn, - &candidates, - ascending, - target_point, - &Some(field_ids), - &mut rtree, - &mut cache, - geo_candidates, - GeoSortParameter::default(), - ) { - Ok(Some((docids, _point))) => Some(Ok(SortedDocumentsIteratorBuilder { + // Find the next bucket of geo-sorted documents. + // next_bucket loops and will go back to the beginning so we use a variable to track how many are left. + if geo_remaining > 0 { + if let Ok(Some((docids, _point))) = next_bucket( index, rtxn, - number_db, - string_db, - fields: &fields[1..], - candidates: docids, + &candidates, + ascending, + target_point, + &Some(field_ids), + &mut rtree, + &mut cache, geo_candidates, - })), - Ok(None) => None, - Err(e) => Some(Err(e)), + GeoSortParameter::default(), + ) { + geo_remaining -= docids.len() as usize; + return Some(Ok(SortedDocumentsIteratorBuilder { + index, + rtxn, + number_db, + string_db, + fields: &fields[1..], + candidates: docids, + geo_candidates, + })); + } } + + // Once all geo candidates have been processed, we can return the others + if let Some(not_geo_candidates) = not_geo_candidates.take() { + if !not_geo_candidates.is_empty() { + return Some(Ok(SortedDocumentsIteratorBuilder { + index, + rtxn, + number_db, + string_db, + fields: &fields[1..], + candidates: not_geo_candidates, + geo_candidates, + })); + } + } + + None }); Ok(SortedDocumentsIterator::Branch { current_child: None, - next_children_size: size, // TODO: confirm all candidates will be included + next_children_size: size, next_children: Box::new(next_children), }) } From f4a908669cb4db3df0ce9ff68c5cd13d0b26cf5c Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Tue, 1 Jul 2025 10:02:15 +0200 Subject: [PATCH 31/81] Add tests --- crates/meilisearch/tests/common/index.rs | 2 + .../tests/documents/get_documents.rs | 289 +++++++++++++++++- crates/meilisearch/tests/vector/settings.rs | 9 +- .../milli/src/facet/facet_sort_recursive.rs | 2 +- 4 files changed, 291 insertions(+), 11 deletions(-) diff --git a/crates/meilisearch/tests/common/index.rs b/crates/meilisearch/tests/common/index.rs index e324d2ff5..f1fdeba91 100644 --- a/crates/meilisearch/tests/common/index.rs +++ b/crates/meilisearch/tests/common/index.rs @@ -562,5 +562,7 @@ pub struct GetAllDocumentsOptions { pub offset: Option, #[serde(skip_serializing_if = "Option::is_none")] pub fields: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub sort: Option>, pub retrieve_vectors: bool, } diff --git a/crates/meilisearch/tests/documents/get_documents.rs b/crates/meilisearch/tests/documents/get_documents.rs index 63dc224c2..2267b8f5d 100644 --- a/crates/meilisearch/tests/documents/get_documents.rs +++ b/crates/meilisearch/tests/documents/get_documents.rs @@ -5,8 +5,8 @@ use urlencoding::encode as urlencode; use crate::common::encoder::Encoder; use crate::common::{ - shared_does_not_exists_index, shared_empty_index, shared_index_with_test_set, - GetAllDocumentsOptions, Server, Value, + shared_does_not_exists_index, shared_empty_index, shared_index_with_geo_documents, + shared_index_with_test_set, GetAllDocumentsOptions, Server, Value, }; use crate::json; @@ -83,6 +83,291 @@ async fn get_document() { ); } +#[actix_rt::test] +async fn get_document_sorted() { + let server = Server::new_shared(); + let index = server.unique_index(); + index.load_test_set().await; + + let (task, _status_code) = + index.update_settings_sortable_attributes(json!(["age", "email", "gender", "name"])).await; + server.wait_task(task.uid()).await.succeeded(); + + let (response, _code) = index + .get_all_documents(GetAllDocumentsOptions { + fields: Some(vec!["id", "age", "email"]), + sort: Some(vec!["age:asc", "email:desc"]), + ..Default::default() + }) + .await; + let results = response["results"].as_array().unwrap(); + snapshot!(json_string!(results), @r#" + [ + { + "id": 5, + "age": 20, + "email": "warrenwatson@chorizon.com" + }, + { + "id": 6, + "age": 20, + "email": "sheliaberry@chorizon.com" + }, + { + "id": 57, + "age": 20, + "email": "kaitlinconner@chorizon.com" + }, + { + "id": 45, + "age": 20, + "email": "irenebennett@chorizon.com" + }, + { + "id": 40, + "age": 21, + "email": "staffordemerson@chorizon.com" + }, + { + "id": 41, + "age": 21, + "email": "salinasgamble@chorizon.com" + }, + { + "id": 63, + "age": 21, + "email": "knowleshebert@chorizon.com" + }, + { + "id": 50, + "age": 21, + "email": "guerramcintyre@chorizon.com" + }, + { + "id": 44, + "age": 22, + "email": "jonispears@chorizon.com" + }, + { + "id": 56, + "age": 23, + "email": "tuckerbarry@chorizon.com" + }, + { + "id": 51, + "age": 23, + "email": "keycervantes@chorizon.com" + }, + { + "id": 60, + "age": 23, + "email": "jodyherrera@chorizon.com" + }, + { + "id": 70, + "age": 23, + "email": "glassperkins@chorizon.com" + }, + { + "id": 75, + "age": 24, + "email": "emmajacobs@chorizon.com" + }, + { + "id": 68, + "age": 24, + "email": "angelinadyer@chorizon.com" + }, + { + "id": 17, + "age": 25, + "email": "ortegabrennan@chorizon.com" + }, + { + "id": 76, + "age": 25, + "email": "claricegardner@chorizon.com" + }, + { + "id": 43, + "age": 25, + "email": "arnoldbender@chorizon.com" + }, + { + "id": 12, + "age": 25, + "email": "aidakirby@chorizon.com" + }, + { + "id": 9, + "age": 26, + "email": "kellimendez@chorizon.com" + } + ] + "#); + + let (response, _code) = index + .get_all_documents(GetAllDocumentsOptions { + fields: Some(vec!["id", "gender", "name"]), + sort: Some(vec!["gender:asc", "name:asc"]), + ..Default::default() + }) + .await; + let results = response["results"].as_array().unwrap(); + snapshot!(json_string!(results), @r#" + [ + { + "id": 3, + "name": "Adeline Flynn", + "gender": "female" + }, + { + "id": 12, + "name": "Aida Kirby", + "gender": "female" + }, + { + "id": 68, + "name": "Angelina Dyer", + "gender": "female" + }, + { + "id": 15, + "name": "Aurelia Contreras", + "gender": "female" + }, + { + "id": 36, + "name": "Barbra Valenzuela", + "gender": "female" + }, + { + "id": 23, + "name": "Blanca Mcclain", + "gender": "female" + }, + { + "id": 53, + "name": "Caitlin Burnett", + "gender": "female" + }, + { + "id": 71, + "name": "Candace Sawyer", + "gender": "female" + }, + { + "id": 65, + "name": "Carole Rowland", + "gender": "female" + }, + { + "id": 33, + "name": "Cecilia Greer", + "gender": "female" + }, + { + "id": 1, + "name": "Cherry Orr", + "gender": "female" + }, + { + "id": 38, + "name": "Christina Short", + "gender": "female" + }, + { + "id": 7, + "name": "Chrystal Boyd", + "gender": "female" + }, + { + "id": 76, + "name": "Clarice Gardner", + "gender": "female" + }, + { + "id": 73, + "name": "Eleanor Shepherd", + "gender": "female" + }, + { + "id": 75, + "name": "Emma Jacobs", + "gender": "female" + }, + { + "id": 16, + "name": "Estella Bass", + "gender": "female" + }, + { + "id": 62, + "name": "Estelle Ramirez", + "gender": "female" + }, + { + "id": 20, + "name": "Florence Long", + "gender": "female" + }, + { + "id": 42, + "name": "Graciela Russell", + "gender": "female" + } + ] + "#); +} + +#[actix_rt::test] +async fn get_document_geosorted() { + let index = shared_index_with_geo_documents().await; + + let (response, _code) = index + .get_all_documents(GetAllDocumentsOptions { + sort: Some(vec!["_geoPoint(45.4777599, 9.1967508):asc"]), + ..Default::default() + }) + .await; + let results = response["results"].as_array().unwrap(); + snapshot!(json_string!(results), @r#" + [ + { + "id": 2, + "name": "La Bella Italia", + "address": "456 Elm Street, Townsville", + "type": "Italian", + "rating": 9, + "_geo": { + "lat": "45.4777599", + "lng": "9.1967508" + } + }, + { + "id": 1, + "name": "Taco Truck", + "address": "444 Salsa Street, Burritoville", + "type": "Mexican", + "rating": 9, + "_geo": { + "lat": 34.0522, + "lng": -118.2437 + } + }, + { + "id": 3, + "name": "Crêpe Truck", + "address": "2 Billig Avenue, Rouenville", + "type": "French", + "rating": 10 + } + ] + "#); +} + +// TODO test on not sortable attributes + #[actix_rt::test] async fn error_get_unexisting_index_all_documents() { let index = shared_does_not_exists_index().await; diff --git a/crates/meilisearch/tests/vector/settings.rs b/crates/meilisearch/tests/vector/settings.rs index 50253f930..d26174faf 100644 --- a/crates/meilisearch/tests/vector/settings.rs +++ b/crates/meilisearch/tests/vector/settings.rs @@ -101,14 +101,7 @@ async fn reset_embedder_documents() { server.wait_task(response.uid()).await; // Make sure the documents are still present - let (documents, _code) = index - .get_all_documents(GetAllDocumentsOptions { - limit: None, - offset: None, - retrieve_vectors: false, - fields: None, - }) - .await; + let (documents, _code) = index.get_all_documents(GetAllDocumentsOptions::default()).await; snapshot!(json_string!(documents), @r###" { "results": [ diff --git a/crates/milli/src/facet/facet_sort_recursive.rs b/crates/milli/src/facet/facet_sort_recursive.rs index 213d18624..504f80d12 100644 --- a/crates/milli/src/facet/facet_sort_recursive.rs +++ b/crates/milli/src/facet/facet_sort_recursive.rs @@ -160,7 +160,7 @@ impl<'ctx> SortedDocumentsIteratorBuilder<'ctx> { })); } } - + // Once all geo candidates have been processed, we can return the others if let Some(not_geo_candidates) = not_geo_candidates.take() { if !not_geo_candidates.is_empty() { From 8326f34ad127629bf33f81945fa621bfc1e16fc0 Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Tue, 1 Jul 2025 11:35:28 +0200 Subject: [PATCH 32/81] Add analytics --- .../src/routes/indexes/documents.rs | 31 +++++++++++++++---- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/crates/meilisearch/src/routes/indexes/documents.rs b/crates/meilisearch/src/routes/indexes/documents.rs index d9b3f106f..c8198d9a7 100644 --- a/crates/meilisearch/src/routes/indexes/documents.rs +++ b/crates/meilisearch/src/routes/indexes/documents.rs @@ -138,6 +138,8 @@ pub struct DocumentsFetchAggregator { per_document_id: bool, // if a filter was used per_filter: bool, + // if documents were sorted + sort: bool, #[serde(rename = "vector.retrieve_vectors")] retrieve_vectors: bool, @@ -156,16 +158,28 @@ pub struct DocumentsFetchAggregator { #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum DocumentFetchKind { - PerDocumentId { retrieve_vectors: bool }, - Normal { with_filter: bool, limit: usize, offset: usize, retrieve_vectors: bool, ids: usize }, + PerDocumentId { + retrieve_vectors: bool, + sort: bool, + }, + Normal { + with_filter: bool, + limit: usize, + offset: usize, + retrieve_vectors: bool, + sort: bool, + ids: usize, + }, } impl DocumentsFetchAggregator { pub fn from_query(query: &DocumentFetchKind) -> Self { - let (limit, offset, retrieve_vectors) = match query { - DocumentFetchKind::PerDocumentId { retrieve_vectors } => (1, 0, *retrieve_vectors), - DocumentFetchKind::Normal { limit, offset, retrieve_vectors, .. } => { - (*limit, *offset, *retrieve_vectors) + let (limit, offset, retrieve_vectors, sort) = match query { + DocumentFetchKind::PerDocumentId { retrieve_vectors, sort } => { + (1, 0, *retrieve_vectors, *sort) + } + DocumentFetchKind::Normal { limit, offset, retrieve_vectors, sort, .. } => { + (*limit, *offset, *retrieve_vectors, *sort) } }; @@ -179,6 +193,7 @@ impl DocumentsFetchAggregator { per_filter: matches!(query, DocumentFetchKind::Normal { with_filter, .. } if *with_filter), max_limit: limit, max_offset: offset, + sort, retrieve_vectors, max_document_ids: ids, @@ -196,6 +211,7 @@ impl Aggregate for DocumentsFetchAggregator { Box::new(Self { per_document_id: self.per_document_id | new.per_document_id, per_filter: self.per_filter | new.per_filter, + sort: self.sort | new.sort, retrieve_vectors: self.retrieve_vectors | new.retrieve_vectors, max_limit: self.max_limit.max(new.max_limit), max_offset: self.max_offset.max(new.max_offset), @@ -279,6 +295,7 @@ pub async fn get_document( retrieve_vectors: param_retrieve_vectors.0, per_document_id: true, per_filter: false, + sort: false, max_limit: 0, max_offset: 0, max_document_ids: 0, @@ -503,6 +520,7 @@ pub async fn documents_by_query_post( analytics.publish( DocumentsFetchAggregator:: { per_filter: body.filter.is_some(), + sort: body.sort.is_some(), retrieve_vectors: body.retrieve_vectors, max_limit: body.limit, max_offset: body.offset, @@ -603,6 +621,7 @@ pub async fn get_documents( analytics.publish( DocumentsFetchAggregator:: { per_filter: query.filter.is_some(), + sort: query.sort.is_some(), retrieve_vectors: query.retrieve_vectors, max_limit: query.limit, max_offset: query.offset, From 8aacd6374acd2387d2c66f46a09105ec2867e441 Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Tue, 1 Jul 2025 11:50:01 +0200 Subject: [PATCH 33/81] Optimize geo sort --- crates/milli/src/facet/facet_sort_recursive.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/milli/src/facet/facet_sort_recursive.rs b/crates/milli/src/facet/facet_sort_recursive.rs index 504f80d12..a4d65f91d 100644 --- a/crates/milli/src/facet/facet_sort_recursive.rs +++ b/crates/milli/src/facet/facet_sort_recursive.rs @@ -370,7 +370,7 @@ pub fn recursive_facet_sort<'ctx>( let mut fields = Vec::new(); let fields_ids_map = index.fields_ids_map(rtxn)?; - let geo_candidates = index.geo_faceted_documents_ids(rtxn)?; // TODO: skip when no geo sort + let mut need_geo_candidates = false; for sort in sort { match sort { AscDesc::Asc(Member::Field(field)) => { @@ -387,6 +387,7 @@ pub fn recursive_facet_sort<'ctx>( if let (Some(lat), Some(lng)) = (fields_ids_map.id("_geo.lat"), fields_ids_map.id("_geo.lng")) { + need_geo_candidates = true; fields.push(AscDescId::Geo { field_ids: [lat, lng], target_point, @@ -398,6 +399,7 @@ pub fn recursive_facet_sort<'ctx>( if let (Some(lat), Some(lng)) = (fields_ids_map.id("_geo.lat"), fields_ids_map.id("_geo.lng")) { + need_geo_candidates = true; fields.push(AscDescId::Geo { field_ids: [lat, lng], target_point, @@ -409,6 +411,12 @@ pub fn recursive_facet_sort<'ctx>( // FIXME: Should this return an error if the field is not found? } + let geo_candidates = if need_geo_candidates { + index.geo_faceted_documents_ids(rtxn)? + } else { + RoaringBitmap::new() + }; + let number_db = index.facet_id_f64_docids.remap_key_type::>(); let string_db = index.facet_id_string_docids.remap_key_type::>(); From 283944ea8979e007afb47f6dcd63e245d1542fcb Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Tue, 1 Jul 2025 12:03:50 +0200 Subject: [PATCH 34/81] Differentiate between document sort error and search sort error --- crates/meilisearch-types/src/error.rs | 3 ++- .../meilisearch/src/routes/indexes/documents.rs | 2 +- .../meilisearch/src/search/federated/perform.rs | 7 +++---- crates/meilisearch/src/search/mod.rs | 2 +- crates/milli/src/asc_desc.rs | 16 ++++++++++------ crates/milli/src/error.rs | 4 ++-- crates/milli/src/facet/facet_sort_recursive.rs | 2 +- 7 files changed, 20 insertions(+), 16 deletions(-) diff --git a/crates/meilisearch-types/src/error.rs b/crates/meilisearch-types/src/error.rs index 2eb22035e..9cf1b93a0 100644 --- a/crates/meilisearch-types/src/error.rs +++ b/crates/meilisearch-types/src/error.rs @@ -483,7 +483,8 @@ impl ErrorCode for milli::Error { UserError::InvalidVectorsMapType { .. } | UserError::InvalidVectorsEmbedderConf { .. } => Code::InvalidVectorsType, UserError::TooManyVectors(_, _) => Code::TooManyVectors, - UserError::SortError(_) => Code::InvalidSearchSort, + UserError::SortError { search: true, .. } => Code::InvalidSearchSort, + UserError::SortError { search: false, .. } => Code::InvalidDocumentSort, UserError::InvalidMinTypoWordLenSetting(_, _) => { Code::InvalidSettingsTypoTolerance } diff --git a/crates/meilisearch/src/routes/indexes/documents.rs b/crates/meilisearch/src/routes/indexes/documents.rs index c8198d9a7..b66eec535 100644 --- a/crates/meilisearch/src/routes/indexes/documents.rs +++ b/crates/meilisearch/src/routes/indexes/documents.rs @@ -663,7 +663,7 @@ fn documents_by_query( let sorts: Vec<_> = match sort.iter().map(|s| milli::AscDesc::from_str(s)).collect() { Ok(sorts) => sorts, Err(asc_desc_error) => { - return Err(milli::Error::from(milli::SortError::from(asc_desc_error)).into()) + return Err(milli::SortError::from(asc_desc_error).into_documents_error().into()) } }; Some(sorts) diff --git a/crates/meilisearch/src/search/federated/perform.rs b/crates/meilisearch/src/search/federated/perform.rs index 5ad64d63c..c0fec01e8 100644 --- a/crates/meilisearch/src/search/federated/perform.rs +++ b/crates/meilisearch/src/search/federated/perform.rs @@ -745,10 +745,9 @@ impl SearchByIndex { match sort.iter().map(|s| milli::AscDesc::from_str(s)).collect() { Ok(sorts) => sorts, Err(asc_desc_error) => { - return Err(milli::Error::from(milli::SortError::from( - asc_desc_error, - )) - .into()) + return Err(milli::SortError::from(asc_desc_error) + .into_search_error() + .into()) } }; Some(sorts) diff --git a/crates/meilisearch/src/search/mod.rs b/crates/meilisearch/src/search/mod.rs index 5e543c53f..f57bc9b9a 100644 --- a/crates/meilisearch/src/search/mod.rs +++ b/crates/meilisearch/src/search/mod.rs @@ -1060,7 +1060,7 @@ pub fn prepare_search<'t>( let sort = match sort.iter().map(|s| AscDesc::from_str(s)).collect() { Ok(sorts) => sorts, Err(asc_desc_error) => { - return Err(milli::Error::from(SortError::from(asc_desc_error)).into()) + return Err(SortError::from(asc_desc_error).into_search_error().into()) } }; diff --git a/crates/milli/src/asc_desc.rs b/crates/milli/src/asc_desc.rs index e75adf83d..999b02511 100644 --- a/crates/milli/src/asc_desc.rs +++ b/crates/milli/src/asc_desc.rs @@ -168,6 +168,16 @@ pub enum SortError { ReservedNameForFilter { name: String }, } +impl SortError { + pub fn into_search_error(self) -> Error { + Error::UserError(UserError::SortError { error: self, search: true }) + } + + pub fn into_documents_error(self) -> Error { + Error::UserError(UserError::SortError { error: self, search: false }) + } +} + impl From for SortError { fn from(error: AscDescError) -> Self { match error { @@ -190,12 +200,6 @@ impl From for SortError { } } -impl From for Error { - fn from(error: SortError) -> Self { - Self::UserError(UserError::SortError(error)) - } -} - #[cfg(test)] mod tests { use big_s::S; diff --git a/crates/milli/src/error.rs b/crates/milli/src/error.rs index 2136ec97e..2624a9824 100644 --- a/crates/milli/src/error.rs +++ b/crates/milli/src/error.rs @@ -272,8 +272,8 @@ and can not be more than 511 bytes.", .document_id.to_string() PrimaryKeyCannotBeChanged(String), #[error(transparent)] SerdeJson(serde_json::Error), - #[error(transparent)] - SortError(#[from] SortError), + #[error("{error}")] + SortError { error: SortError, search: bool }, #[error("An unknown internal document id have been used: `{document_id}`.")] UnknownInternalDocumentId { document_id: DocumentId }, #[error("`minWordSizeForTypos` setting is invalid. `oneTypo` and `twoTypos` fields should be between `0` and `255`, and `twoTypos` should be greater or equals to `oneTypo` but found `oneTypo: {0}` and twoTypos: {1}`.")] diff --git a/crates/milli/src/facet/facet_sort_recursive.rs b/crates/milli/src/facet/facet_sort_recursive.rs index a4d65f91d..19bf5afb9 100644 --- a/crates/milli/src/facet/facet_sort_recursive.rs +++ b/crates/milli/src/facet/facet_sort_recursive.rs @@ -411,7 +411,7 @@ pub fn recursive_facet_sort<'ctx>( // FIXME: Should this return an error if the field is not found? } - let geo_candidates = if need_geo_candidates { + let geo_candidates = if need_geo_candidates { index.geo_faceted_documents_ids(rtxn)? } else { RoaringBitmap::new() From 8419fd9b3b8dbadf8edfed87c6dca3bb1d798f07 Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Tue, 1 Jul 2025 13:42:38 +0200 Subject: [PATCH 35/81] Ditch usage of check_sort_criteria --- crates/meilisearch-types/src/error.rs | 3 +- crates/milli/src/error.rs | 18 +++- .../milli/src/facet/facet_sort_recursive.rs | 89 +++++++++---------- crates/milli/src/search/new/mod.rs | 4 +- 4 files changed, 63 insertions(+), 51 deletions(-) diff --git a/crates/meilisearch-types/src/error.rs b/crates/meilisearch-types/src/error.rs index 9cf1b93a0..e43b28fc6 100644 --- a/crates/meilisearch-types/src/error.rs +++ b/crates/meilisearch-types/src/error.rs @@ -467,7 +467,8 @@ impl ErrorCode for milli::Error { UserError::InvalidDistinctAttribute { .. } => Code::InvalidSearchDistinct, UserError::SortRankingRuleMissing => Code::InvalidSearchSort, UserError::InvalidFacetsDistribution { .. } => Code::InvalidSearchFacets, - UserError::InvalidSortableAttribute { .. } => Code::InvalidSearchSort, + UserError::InvalidSearchSortableAttribute { .. } => Code::InvalidSearchSort, + UserError::InvalidDocumentSortableAttribute { .. } => Code::InvalidDocumentSort, UserError::InvalidSearchableAttribute { .. } => { Code::InvalidSearchAttributesToSearchOn } diff --git a/crates/milli/src/error.rs b/crates/milli/src/error.rs index 2624a9824..f3b390690 100644 --- a/crates/milli/src/error.rs +++ b/crates/milli/src/error.rs @@ -191,7 +191,21 @@ and can not be more than 511 bytes.", .document_id.to_string() ), } )] - InvalidSortableAttribute { field: String, valid_fields: BTreeSet, hidden_fields: bool }, + InvalidSearchSortableAttribute { + field: String, + valid_fields: BTreeSet, + hidden_fields: bool, + }, + #[error("Attribute `{}` is not sortable. {}", + .field, + match .sortable_fields.is_empty() { + true => "This index does not have configured sortable attributes.".to_string(), + false => format!("Available sortable attributes are: `{}`.", + sortable_fields.iter().map(AsRef::as_ref).collect::>().join(", ") + ), + } + )] + InvalidDocumentSortableAttribute { field: String, sortable_fields: BTreeSet }, #[error("Attribute `{}` is not filterable and thus, cannot be used as distinct attribute. {}", .field, match (.valid_patterns.is_empty(), .matching_rule_index) { @@ -614,7 +628,7 @@ fn conditionally_lookup_for_error_message() { ]; for (list, suffix) in messages { - let err = UserError::InvalidSortableAttribute { + let err = UserError::InvalidSearchSortableAttribute { field: "name".to_string(), valid_fields: list, hidden_fields: false, diff --git a/crates/milli/src/facet/facet_sort_recursive.rs b/crates/milli/src/facet/facet_sort_recursive.rs index 19bf5afb9..ab62ebcfd 100644 --- a/crates/milli/src/facet/facet_sort_recursive.rs +++ b/crates/milli/src/facet/facet_sort_recursive.rs @@ -1,16 +1,15 @@ -use std::collections::VecDeque; +use std::collections::{BTreeSet, VecDeque}; use crate::{ + constants::RESERVED_GEO_FIELD_NAME, documents::{geo_sort::next_bucket, GeoSortParameter}, heed_codec::{ facet::{FacetGroupKeyCodec, FacetGroupValueCodec}, BytesRefCodec, }, - search::{ - facet::{ascending_facet_sort, descending_facet_sort}, - new::check_sort_criteria, - }, - AscDesc, DocumentId, Member, + is_faceted, + search::facet::{ascending_facet_sort, descending_facet_sort}, + AscDesc, DocumentId, Member, UserError, }; use heed::Database; use roaring::RoaringBitmap; @@ -366,49 +365,47 @@ pub fn recursive_facet_sort<'ctx>( sort: Vec, candidates: &'ctx RoaringBitmap, ) -> crate::Result> { - check_sort_criteria(index, rtxn, Some(&sort))?; - - let mut fields = Vec::new(); + let sortable_fields: BTreeSet<_> = index.sortable_fields(rtxn)?.into_iter().collect(); let fields_ids_map = index.fields_ids_map(rtxn)?; + + let mut fields = Vec::new(); let mut need_geo_candidates = false; - for sort in sort { - match sort { - AscDesc::Asc(Member::Field(field)) => { - if let Some(field_id) = fields_ids_map.id(&field) { - fields.push(AscDescId::Facet { field_id, ascending: true }); - } - } - AscDesc::Desc(Member::Field(field)) => { - if let Some(field_id) = fields_ids_map.id(&field) { - fields.push(AscDescId::Facet { field_id, ascending: false }); - } - } - AscDesc::Asc(Member::Geo(target_point)) => { - if let (Some(lat), Some(lng)) = - (fields_ids_map.id("_geo.lat"), fields_ids_map.id("_geo.lng")) - { - need_geo_candidates = true; - fields.push(AscDescId::Geo { - field_ids: [lat, lng], - target_point, - ascending: true, - }); - } - } - AscDesc::Desc(Member::Geo(target_point)) => { - if let (Some(lat), Some(lng)) = - (fields_ids_map.id("_geo.lat"), fields_ids_map.id("_geo.lng")) - { - need_geo_candidates = true; - fields.push(AscDescId::Geo { - field_ids: [lat, lng], - target_point, - ascending: false, - }); - } - } + for asc_desc in sort { + let (field, geofield) = match asc_desc { + AscDesc::Asc(Member::Field(field)) => (Some((field, true)), None), + AscDesc::Desc(Member::Field(field)) => (Some((field, false)), None), + AscDesc::Asc(Member::Geo(target_point)) => (None, Some((target_point, true))), + AscDesc::Desc(Member::Geo(target_point)) => (None, Some((target_point, false))), }; - // FIXME: Should this return an error if the field is not found? + if let Some((field, ascending)) = field { + if is_faceted(&field, &sortable_fields) { + if let Some(field_id) = fields_ids_map.id(&field) { + fields.push(AscDescId::Facet { field_id, ascending }); + continue; + } + } + return Err(UserError::InvalidDocumentSortableAttribute { + field: field.to_string(), + sortable_fields: sortable_fields.clone(), + } + .into()); + } + if let Some((target_point, ascending)) = geofield { + if sortable_fields.contains(RESERVED_GEO_FIELD_NAME) { + if let (Some(lat), Some(lng)) = + (fields_ids_map.id("_geo.lat"), fields_ids_map.id("_geo.lng")) + { + need_geo_candidates = true; + fields.push(AscDescId::Geo { field_ids: [lat, lng], target_point, ascending }); + continue; + } + } + return Err(UserError::InvalidDocumentSortableAttribute { + field: RESERVED_GEO_FIELD_NAME.to_string(), + sortable_fields: sortable_fields.clone(), + } + .into()); + } } let geo_candidates = if need_geo_candidates { diff --git a/crates/milli/src/search/new/mod.rs b/crates/milli/src/search/new/mod.rs index b5258413e..3983aa07a 100644 --- a/crates/milli/src/search/new/mod.rs +++ b/crates/milli/src/search/new/mod.rs @@ -903,7 +903,7 @@ pub(crate) fn check_sort_criteria( let (valid_fields, hidden_fields) = index.remove_hidden_fields(rtxn, sortable_fields)?; - return Err(UserError::InvalidSortableAttribute { + return Err(UserError::InvalidSearchSortableAttribute { field: field.to_string(), valid_fields, hidden_fields, @@ -914,7 +914,7 @@ pub(crate) fn check_sort_criteria( let (valid_fields, hidden_fields) = index.remove_hidden_fields(rtxn, sortable_fields)?; - return Err(UserError::InvalidSortableAttribute { + return Err(UserError::InvalidSearchSortableAttribute { field: RESERVED_GEO_FIELD_NAME.to_string(), valid_fields, hidden_fields, From 280c3907bebc9ad0077e4e3372428c8c2ce5b810 Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Tue, 1 Jul 2025 13:58:37 +0200 Subject: [PATCH 36/81] Add test to sort the unsortable --- .../tests/documents/get_documents.rs | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/crates/meilisearch/tests/documents/get_documents.rs b/crates/meilisearch/tests/documents/get_documents.rs index 2267b8f5d..5ee838232 100644 --- a/crates/meilisearch/tests/documents/get_documents.rs +++ b/crates/meilisearch/tests/documents/get_documents.rs @@ -366,7 +366,27 @@ async fn get_document_geosorted() { "#); } -// TODO test on not sortable attributes +#[actix_rt::test] +async fn get_document_sort_the_unsortable() { + let index = shared_index_with_test_set().await; + + let (response, _code) = index + .get_all_documents(GetAllDocumentsOptions { + fields: Some(vec!["id", "name"]), + sort: Some(vec!["name:asc"]), + ..Default::default() + }) + .await; + + snapshot!(json_string!(response), @r#" + { + "message": "Attribute `name` is not sortable. This index does not have configured sortable attributes.", + "code": "invalid_document_sort", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_document_sort" + } + "#); +} #[actix_rt::test] async fn error_get_unexisting_index_all_documents() { From 9f55708d84ddd55cdcca5e442a97ab32fa34ad66 Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Tue, 1 Jul 2025 13:58:56 +0200 Subject: [PATCH 37/81] Format --- crates/milli/src/facet/facet_sort_recursive.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/milli/src/facet/facet_sort_recursive.rs b/crates/milli/src/facet/facet_sort_recursive.rs index ab62ebcfd..596ce6335 100644 --- a/crates/milli/src/facet/facet_sort_recursive.rs +++ b/crates/milli/src/facet/facet_sort_recursive.rs @@ -367,7 +367,7 @@ pub fn recursive_facet_sort<'ctx>( ) -> crate::Result> { let sortable_fields: BTreeSet<_> = index.sortable_fields(rtxn)?.into_iter().collect(); let fields_ids_map = index.fields_ids_map(rtxn)?; - + let mut fields = Vec::new(); let mut need_geo_candidates = false; for asc_desc in sort { From d85480de898d5f6b1c829fdd1fbed2bc381a1864 Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Tue, 1 Jul 2025 14:05:47 +0200 Subject: [PATCH 38/81] Move sort code out of facet --- crates/meilisearch/src/routes/indexes/documents.rs | 4 ++-- crates/milli/src/documents/mod.rs | 1 + .../src/{facet/facet_sort_recursive.rs => documents/sort.rs} | 2 +- crates/milli/src/facet/mod.rs | 1 - 4 files changed, 4 insertions(+), 4 deletions(-) rename crates/milli/src/{facet/facet_sort_recursive.rs => documents/sort.rs} (99%) diff --git a/crates/meilisearch/src/routes/indexes/documents.rs b/crates/meilisearch/src/routes/indexes/documents.rs index b66eec535..e8499a789 100644 --- a/crates/meilisearch/src/routes/indexes/documents.rs +++ b/crates/meilisearch/src/routes/indexes/documents.rs @@ -18,7 +18,7 @@ use meilisearch_types::error::deserr_codes::*; use meilisearch_types::error::{Code, ResponseError}; use meilisearch_types::heed::RoTxn; use meilisearch_types::index_uid::IndexUid; -use meilisearch_types::milli::facet::facet_sort_recursive::recursive_facet_sort; +use meilisearch_types::milli::documents::sort::recursive_sort; use meilisearch_types::milli::update::IndexDocumentsMethod; use meilisearch_types::milli::vector::parsed_vectors::ExplicitVectors; use meilisearch_types::milli::{AscDesc, DocumentId}; @@ -1576,7 +1576,7 @@ fn retrieve_documents>( let facet_sort; let (it, number_of_documents) = if let Some(sort) = sort_criteria { let number_of_documents = candidates.len(); - facet_sort = recursive_facet_sort(index, &rtxn, sort, &candidates)?; + facet_sort = recursive_sort(index, &rtxn, sort, &candidates)?; let iter = facet_sort.iter()?; ( itertools::Either::Left(some_documents( diff --git a/crates/milli/src/documents/mod.rs b/crates/milli/src/documents/mod.rs index b515c4e98..7a4babfa8 100644 --- a/crates/milli/src/documents/mod.rs +++ b/crates/milli/src/documents/mod.rs @@ -4,6 +4,7 @@ pub mod geo_sort; mod primary_key; mod reader; mod serde_impl; +pub mod sort; use std::fmt::Debug; use std::io; diff --git a/crates/milli/src/facet/facet_sort_recursive.rs b/crates/milli/src/documents/sort.rs similarity index 99% rename from crates/milli/src/facet/facet_sort_recursive.rs rename to crates/milli/src/documents/sort.rs index 596ce6335..4008a37a4 100644 --- a/crates/milli/src/facet/facet_sort_recursive.rs +++ b/crates/milli/src/documents/sort.rs @@ -359,7 +359,7 @@ impl<'ctx> SortedDocuments<'ctx> { } } -pub fn recursive_facet_sort<'ctx>( +pub fn recursive_sort<'ctx>( index: &'ctx crate::Index, rtxn: &'ctx heed::RoTxn<'ctx>, sort: Vec, diff --git a/crates/milli/src/facet/mod.rs b/crates/milli/src/facet/mod.rs index 8b0b9a25e..274d2588d 100644 --- a/crates/milli/src/facet/mod.rs +++ b/crates/milli/src/facet/mod.rs @@ -1,4 +1,3 @@ -pub mod facet_sort_recursive; mod facet_type; mod facet_value; pub mod value_encoding; From 73dfeefc7ce3fbab172730bc1bfc37404dc21e87 Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Tue, 1 Jul 2025 14:08:46 +0200 Subject: [PATCH 39/81] Remove plural form --- crates/meilisearch/src/routes/indexes/documents.rs | 2 +- crates/milli/src/asc_desc.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/meilisearch/src/routes/indexes/documents.rs b/crates/meilisearch/src/routes/indexes/documents.rs index e8499a789..9c8d28e04 100644 --- a/crates/meilisearch/src/routes/indexes/documents.rs +++ b/crates/meilisearch/src/routes/indexes/documents.rs @@ -663,7 +663,7 @@ fn documents_by_query( let sorts: Vec<_> = match sort.iter().map(|s| milli::AscDesc::from_str(s)).collect() { Ok(sorts) => sorts, Err(asc_desc_error) => { - return Err(milli::SortError::from(asc_desc_error).into_documents_error().into()) + return Err(milli::SortError::from(asc_desc_error).into_document_error().into()) } }; Some(sorts) diff --git a/crates/milli/src/asc_desc.rs b/crates/milli/src/asc_desc.rs index 999b02511..d7288faa3 100644 --- a/crates/milli/src/asc_desc.rs +++ b/crates/milli/src/asc_desc.rs @@ -173,7 +173,7 @@ impl SortError { Error::UserError(UserError::SortError { error: self, search: true }) } - pub fn into_documents_error(self) -> Error { + pub fn into_document_error(self) -> Error { Error::UserError(UserError::SortError { error: self, search: false }) } } From 27cc3573624d6c3bcd80066c4a95b4d455690ba3 Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Tue, 1 Jul 2025 14:21:55 +0200 Subject: [PATCH 40/81] Document code --- crates/milli/src/documents/sort.rs | 302 +++++++++++++++-------------- 1 file changed, 155 insertions(+), 147 deletions(-) diff --git a/crates/milli/src/documents/sort.rs b/crates/milli/src/documents/sort.rs index 4008a37a4..59858caad 100644 --- a/crates/milli/src/documents/sort.rs +++ b/crates/milli/src/documents/sort.rs @@ -20,6 +20,158 @@ enum AscDescId { Geo { field_ids: [u16; 2], target_point: [f64; 2], ascending: bool }, } +/// A [`SortedDocumentsIterator`] allows efficient access to a continuous range of sorted documents. +/// This is ideal in the context of paginated queries in which only a small number of documents are needed at a time. +/// Search operations will only be performed upon access. +pub enum SortedDocumentsIterator<'ctx> { + Leaf { + /// The exact number of documents remaining + size: usize, + values: Box + 'ctx>, + }, + Branch { + /// The current child, got from the children iterator + current_child: Option>>, + /// The exact number of documents remaining, excluding documents in the current child + next_children_size: usize, + /// Iterators to become the current child once it is exhausted + next_children: + Box>> + 'ctx>, + }, +} + +impl SortedDocumentsIterator<'_> { + /// Takes care of updating the current child if it is `None`, and also updates the size + fn update_current<'ctx>( + current_child: &mut Option>>, + next_children_size: &mut usize, + next_children: &mut Box< + dyn Iterator>> + 'ctx, + >, + ) -> crate::Result<()> { + if current_child.is_none() { + *current_child = match next_children.next() { + Some(Ok(builder)) => { + let next_child = Box::new(builder.build()?); + *next_children_size -= next_child.size_hint().0; + Some(next_child) + } + Some(Err(e)) => return Err(e), + None => return Ok(()), + }; + } + Ok(()) + } +} + +impl Iterator for SortedDocumentsIterator<'_> { + type Item = crate::Result; + + /// Implementing the `nth` method allows for efficient access to the nth document in the sorted order. + /// It's used by `skip` internally. + /// The default implementation of `nth` would iterate over all children, which is inefficient for large datasets. + /// This implementation will jump over whole chunks of children until it gets close. + fn nth(&mut self, n: usize) -> Option { + // If it's at the leaf level, just forward the call to the values iterator + let (current_child, next_children, next_children_size) = match self { + SortedDocumentsIterator::Leaf { values, size } => { + *size = size.saturating_sub(n); + return values.nth(n).map(Ok); + } + SortedDocumentsIterator::Branch { + current_child, + next_children, + next_children_size, + } => (current_child, next_children, next_children_size), + }; + + // Otherwise don't directly iterate over children, skip them if we know we will go further + let mut to_skip = n - 1; + while to_skip > 0 { + if let Err(e) = SortedDocumentsIterator::update_current( + current_child, + next_children_size, + next_children, + ) { + return Some(Err(e)); + } + let Some(inner) = current_child else { + return None; // No more inner iterators, everything has been consumed. + }; + + if to_skip >= inner.size_hint().0 { + // The current child isn't large enough to contain the nth element. + // Skip it and continue with the next one. + to_skip -= inner.size_hint().0; + *current_child = None; + continue; + } else { + // The current iterator is large enough, so we can forward the call to it. + return inner.nth(to_skip + 1); + } + } + + self.next() + } + + /// Iterators need to keep track of their size so that they can be skipped efficiently by the `nth` method. + fn size_hint(&self) -> (usize, Option) { + let size = match self { + SortedDocumentsIterator::Leaf { size, .. } => *size, + SortedDocumentsIterator::Branch { + next_children_size, + current_child: Some(current_child), + .. + } => current_child.size_hint().0 + next_children_size, + SortedDocumentsIterator::Branch { next_children_size, current_child: None, .. } => { + *next_children_size + } + }; + + (size, Some(size)) + } + + fn next(&mut self) -> Option { + match self { + SortedDocumentsIterator::Leaf { values, size } => { + let result = values.next().map(Ok); + if result.is_some() { + *size -= 1; + } + result + } + SortedDocumentsIterator::Branch { + current_child, + next_children_size, + next_children, + } => { + let mut result = None; + while result.is_none() { + // Ensure we have selected an iterator to work with + if let Err(e) = SortedDocumentsIterator::update_current( + current_child, + next_children_size, + next_children, + ) { + return Some(Err(e)); + } + let Some(inner) = current_child else { + return None; + }; + + result = inner.next(); + + // If the current iterator is exhausted, we need to try the next one + if result.is_none() { + *current_child = None; + } + } + result + } + } + } +} + /// Builder for a [`SortedDocumentsIterator`]. /// Most builders won't ever be built, because pagination will skip them. pub struct SortedDocumentsIteratorBuilder<'ctx> { @@ -57,6 +209,7 @@ impl<'ctx> SortedDocumentsIteratorBuilder<'ctx> { } } + /// Builds a [`SortedDocumentsIterator`] based on the results of a facet sort. fn build_facet( self, field_id: u16, @@ -108,6 +261,7 @@ impl<'ctx> SortedDocumentsIteratorBuilder<'ctx> { }) } + /// Builds a [`SortedDocumentsIterator`] based on the (lazy) results of a geo sort. fn build_geo( self, field_ids: [u16; 2], @@ -186,153 +340,6 @@ impl<'ctx> SortedDocumentsIteratorBuilder<'ctx> { } } -/// A [`SortedDocumentsIterator`] allows efficient access to a continuous range of sorted documents. -/// This is ideal in the context of paginated queries in which only a small number of documents are needed at a time. -/// Search operations will only be performed upon access. -pub enum SortedDocumentsIterator<'ctx> { - Leaf { - /// The exact number of documents remaining - size: usize, - values: Box + 'ctx>, - }, - Branch { - /// The current child, got from the children iterator - current_child: Option>>, - /// The exact number of documents remaining, excluding documents in the current child - next_children_size: usize, - /// Iterators to become the current child once it is exhausted - next_children: - Box>> + 'ctx>, - }, -} - -impl SortedDocumentsIterator<'_> { - /// Takes care of updating the current child if it is `None`, and also updates the size - fn update_current<'ctx>( - current_child: &mut Option>>, - next_children_size: &mut usize, - next_children: &mut Box< - dyn Iterator>> + 'ctx, - >, - ) -> crate::Result<()> { - if current_child.is_none() { - *current_child = match next_children.next() { - Some(Ok(builder)) => { - let next_child = Box::new(builder.build()?); - *next_children_size -= next_child.size_hint().0; - Some(next_child) - } - Some(Err(e)) => return Err(e), - None => return Ok(()), - }; - } - Ok(()) - } -} - -impl Iterator for SortedDocumentsIterator<'_> { - type Item = crate::Result; - - fn nth(&mut self, n: usize) -> Option { - // If it's at the leaf level, just forward the call to the values iterator - let (current_child, next_children, next_children_size) = match self { - SortedDocumentsIterator::Leaf { values, size } => { - *size = size.saturating_sub(n); - return values.nth(n).map(Ok); - } - SortedDocumentsIterator::Branch { - current_child, - next_children, - next_children_size, - } => (current_child, next_children, next_children_size), - }; - - // Otherwise don't directly iterate over children, skip them if we know we will go further - let mut to_skip = n - 1; - while to_skip > 0 { - if let Err(e) = SortedDocumentsIterator::update_current( - current_child, - next_children_size, - next_children, - ) { - return Some(Err(e)); - } - let Some(inner) = current_child else { - return None; // No more inner iterators, everything has been consumed. - }; - - if to_skip >= inner.size_hint().0 { - // The current child isn't large enough to contain the nth element. - // Skip it and continue with the next one. - to_skip -= inner.size_hint().0; - *current_child = None; - continue; - } else { - // The current iterator is large enough, so we can forward the call to it. - return inner.nth(to_skip + 1); - } - } - - self.next() - } - - fn size_hint(&self) -> (usize, Option) { - let size = match self { - SortedDocumentsIterator::Leaf { size, .. } => *size, - SortedDocumentsIterator::Branch { - next_children_size, - current_child: Some(current_child), - .. - } => current_child.size_hint().0 + next_children_size, - SortedDocumentsIterator::Branch { next_children_size, current_child: None, .. } => { - *next_children_size - } - }; - - (size, Some(size)) - } - - fn next(&mut self) -> Option { - match self { - SortedDocumentsIterator::Leaf { values, size } => { - let result = values.next().map(Ok); - if result.is_some() { - *size -= 1; - } - result - } - SortedDocumentsIterator::Branch { - current_child, - next_children_size, - next_children, - } => { - let mut result = None; - while result.is_none() { - // Ensure we have selected an iterator to work with - if let Err(e) = SortedDocumentsIterator::update_current( - current_child, - next_children_size, - next_children, - ) { - return Some(Err(e)); - } - let Some(inner) = current_child else { - return None; - }; - - result = inner.next(); - - // If the current iterator is exhausted, we need to try the next one - if result.is_none() { - *current_child = None; - } - } - result - } - } - } -} - /// A structure owning the data needed during the lifetime of a [`SortedDocumentsIterator`]. pub struct SortedDocuments<'ctx> { index: &'ctx crate::Index, @@ -368,6 +375,7 @@ pub fn recursive_sort<'ctx>( let sortable_fields: BTreeSet<_> = index.sortable_fields(rtxn)?.into_iter().collect(); let fields_ids_map = index.fields_ids_map(rtxn)?; + // Retrieve the field ids that are used for sorting let mut fields = Vec::new(); let mut need_geo_candidates = false; for asc_desc in sort { From e92b6beb2011f8488d197d9a5e31d3e9b3f88dd6 Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Tue, 1 Jul 2025 14:26:55 +0200 Subject: [PATCH 41/81] Revert making check_sort_criteria usable without a search context --- crates/milli/src/search/new/mod.rs | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/crates/milli/src/search/new/mod.rs b/crates/milli/src/search/new/mod.rs index 3983aa07a..ecc51161d 100644 --- a/crates/milli/src/search/new/mod.rs +++ b/crates/milli/src/search/new/mod.rs @@ -638,7 +638,7 @@ pub fn execute_vector_search( time_budget: TimeBudget, ranking_score_threshold: Option, ) -> Result { - check_sort_criteria(ctx.index, ctx.txn, sort_criteria.as_deref())?; + check_sort_criteria(ctx, sort_criteria.as_ref())?; // FIXME: input universe = universe & documents_with_vectors // for now if we're computing embeddings for ALL documents, we can assume that this is just universe @@ -702,7 +702,7 @@ pub fn execute_search( ranking_score_threshold: Option, locales: Option<&Vec>, ) -> Result { - check_sort_criteria(ctx.index, ctx.txn, sort_criteria.as_deref())?; + check_sort_criteria(ctx, sort_criteria.as_ref())?; let mut used_negative_operator = false; let mut located_query_terms = None; @@ -873,9 +873,8 @@ pub fn execute_search( } pub(crate) fn check_sort_criteria( - index: &Index, - rtxn: &RoTxn<'_>, - sort_criteria: Option<&[AscDesc]>, + ctx: &SearchContext<'_>, + sort_criteria: Option<&Vec>, ) -> Result<()> { let sort_criteria = if let Some(sort_criteria) = sort_criteria { sort_criteria @@ -889,19 +888,19 @@ pub(crate) fn check_sort_criteria( // We check that the sort ranking rule exists and throw an // error if we try to use it and that it doesn't. - let sort_ranking_rule_missing = !index.criteria(rtxn)?.contains(&crate::Criterion::Sort); + let sort_ranking_rule_missing = !ctx.index.criteria(ctx.txn)?.contains(&crate::Criterion::Sort); if sort_ranking_rule_missing { return Err(UserError::SortRankingRuleMissing.into()); } // We check that we are allowed to use the sort criteria, we check // that they are declared in the sortable fields. - let sortable_fields = index.sortable_fields(rtxn)?; + let sortable_fields = ctx.index.sortable_fields(ctx.txn)?; for asc_desc in sort_criteria { match asc_desc.member() { Member::Field(ref field) if !crate::is_faceted(field, &sortable_fields) => { let (valid_fields, hidden_fields) = - index.remove_hidden_fields(rtxn, sortable_fields)?; + ctx.index.remove_hidden_fields(ctx.txn, sortable_fields)?; return Err(UserError::InvalidSearchSortableAttribute { field: field.to_string(), @@ -912,7 +911,7 @@ pub(crate) fn check_sort_criteria( } Member::Geo(_) if !sortable_fields.contains(RESERVED_GEO_FIELD_NAME) => { let (valid_fields, hidden_fields) = - index.remove_hidden_fields(rtxn, sortable_fields)?; + ctx.index.remove_hidden_fields(ctx.txn, sortable_fields)?; return Err(UserError::InvalidSearchSortableAttribute { field: RESERVED_GEO_FIELD_NAME.to_string(), From 7ae9a4afee3981ee98bfbde8777b0f03f11388f6 Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Tue, 1 Jul 2025 15:42:43 +0200 Subject: [PATCH 42/81] Add a test for issue #5274 --- crates/meilisearch/tests/search/pagination.rs | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/crates/meilisearch/tests/search/pagination.rs b/crates/meilisearch/tests/search/pagination.rs index c0752e7ec..6dd8b3181 100644 --- a/crates/meilisearch/tests/search/pagination.rs +++ b/crates/meilisearch/tests/search/pagination.rs @@ -1,6 +1,7 @@ use super::shared_index_with_documents; use crate::common::Server; use crate::json; +use meili_snap::{json_string, snapshot}; #[actix_rt::test] async fn default_search_should_return_estimated_total_hit() { @@ -133,3 +134,61 @@ async fn ensure_placeholder_search_hit_count_valid() { .await; } } + +#[actix_rt::test] +async fn test_issue_5274() { + let server = Server::new_shared(); + let index = server.unique_index(); + + let documents = json!([ + { + "id": 1, + "title": "Document 1", + "content": "This is the first." + }, + { + "id": 2, + "title": "Document 2", + "content": "This is the second doc." + } + ]); + let (task, _code) = index.add_documents(documents, None).await; + server.wait_task(task.uid()).await.succeeded(); + + // Find out the lowest ranking score among the documents + let (rep, _status) = index + .search_post(json!({"q": "doc", "page": 1, "hitsPerPage": 2, "showRankingScore": true})) + .await; + let hits = rep["hits"].as_array().expect("Missing hits array"); + let second_hit = hits.get(1).expect("Missing second hit"); + let ranking_score = second_hit + .get("_rankingScore") + .expect("Missing _rankingScore field") + .as_f64() + .expect("Expected _rankingScore to be a f64"); + + // Search with a ranking score threshold just above and expect to be a single hit + let (rep, _status) = index + .search_post(json!({"q": "doc", "page": 1, "hitsPerPage": 1, "rankingScoreThreshold": ranking_score + 0.0001})) + .await; + + snapshot!(json_string!(rep, { + ".processingTimeMs" => "[ignored]", + }), @r#" + { + "hits": [ + { + "id": 2, + "title": "Document 2", + "content": "This is the second doc." + } + ], + "query": "doc", + "processingTimeMs": "[ignored]", + "hitsPerPage": 1, + "page": 1, + "totalPages": 1, + "totalHits": 1 + } + "#); +} From dedae94102f7960d22befe0bf68edfba40fea3ee Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Tue, 1 Jul 2025 16:22:25 +0200 Subject: [PATCH 43/81] Fix #5274 --- crates/milli/src/search/mod.rs | 1 + crates/milli/src/search/new/bucket_sort.rs | 5 ++++- crates/milli/src/search/new/mod.rs | 4 ++++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/milli/src/search/mod.rs b/crates/milli/src/search/mod.rs index 62183afc3..ecb9af852 100644 --- a/crates/milli/src/search/mod.rs +++ b/crates/milli/src/search/mod.rs @@ -236,6 +236,7 @@ impl<'a> Search<'a> { &mut ctx, vector, self.scoring_strategy, + self.exhaustive_number_hits, universe, &self.sort_criteria, &self.distinct, diff --git a/crates/milli/src/search/new/bucket_sort.rs b/crates/milli/src/search/new/bucket_sort.rs index 3c26cad5c..f4fd62825 100644 --- a/crates/milli/src/search/new/bucket_sort.rs +++ b/crates/milli/src/search/new/bucket_sort.rs @@ -32,6 +32,7 @@ pub fn bucket_sort<'ctx, Q: RankingRuleQueryTrait>( logger: &mut dyn SearchLogger, time_budget: TimeBudget, ranking_score_threshold: Option, + exhaustive_number_hits: bool, ) -> Result { logger.initial_query(query); logger.ranking_rules(&ranking_rules); @@ -159,7 +160,9 @@ pub fn bucket_sort<'ctx, Q: RankingRuleQueryTrait>( }; } - while valid_docids.len() < length { + while valid_docids.len() < length + || (exhaustive_number_hits && ranking_score_threshold.is_some()) + { if time_budget.exceeded() { loop { let bucket = std::mem::take(&mut ranking_rule_universes[cur_ranking_rule_index]); diff --git a/crates/milli/src/search/new/mod.rs b/crates/milli/src/search/new/mod.rs index a65b4076b..2c6fe5c3c 100644 --- a/crates/milli/src/search/new/mod.rs +++ b/crates/milli/src/search/new/mod.rs @@ -626,6 +626,7 @@ pub fn execute_vector_search( ctx: &mut SearchContext<'_>, vector: &[f32], scoring_strategy: ScoringStrategy, + exhaustive_number_hits: bool, universe: RoaringBitmap, sort_criteria: &Option>, distinct: &Option, @@ -669,6 +670,7 @@ pub fn execute_vector_search( placeholder_search_logger, time_budget, ranking_score_threshold, + exhaustive_number_hits, )?; Ok(PartialSearchResult { @@ -825,6 +827,7 @@ pub fn execute_search( query_graph_logger, time_budget, ranking_score_threshold, + exhaustive_number_hits, )? } else { let ranking_rules = @@ -841,6 +844,7 @@ pub fn execute_search( placeholder_search_logger, time_budget, ranking_score_threshold, + exhaustive_number_hits, )? }; From 600178c5abb02d493937597ba12fa31419c933ab Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Tue, 1 Jul 2025 18:33:09 +0200 Subject: [PATCH 44/81] Still limit to max hits --- crates/meilisearch/src/search/mod.rs | 1 + crates/milli/src/search/hybrid.rs | 1 + crates/milli/src/search/mod.rs | 11 +++++++++++ crates/milli/src/search/new/bucket_sort.rs | 6 +++++- crates/milli/src/search/new/matches/mod.rs | 1 + crates/milli/src/search/new/mod.rs | 5 +++++ 6 files changed, 24 insertions(+), 1 deletion(-) diff --git a/crates/meilisearch/src/search/mod.rs b/crates/meilisearch/src/search/mod.rs index 5e543c53f..e1cfc542b 100644 --- a/crates/meilisearch/src/search/mod.rs +++ b/crates/meilisearch/src/search/mod.rs @@ -1020,6 +1020,7 @@ pub fn prepare_search<'t>( .unwrap_or(DEFAULT_PAGINATION_MAX_TOTAL_HITS); search.exhaustive_number_hits(is_finite_pagination); + search.max_total_hits(Some(max_total_hits)); search.scoring_strategy( if query.show_ranking_score || query.show_ranking_score_details diff --git a/crates/milli/src/search/hybrid.rs b/crates/milli/src/search/hybrid.rs index b63f6288f..5fc228807 100644 --- a/crates/milli/src/search/hybrid.rs +++ b/crates/milli/src/search/hybrid.rs @@ -209,6 +209,7 @@ impl Search<'_> { scoring_strategy: ScoringStrategy::Detailed, words_limit: self.words_limit, exhaustive_number_hits: self.exhaustive_number_hits, + max_total_hits: self.max_total_hits, rtxn: self.rtxn, index: self.index, semantic: self.semantic.clone(), diff --git a/crates/milli/src/search/mod.rs b/crates/milli/src/search/mod.rs index ecb9af852..2192ea9fd 100644 --- a/crates/milli/src/search/mod.rs +++ b/crates/milli/src/search/mod.rs @@ -51,6 +51,7 @@ pub struct Search<'a> { scoring_strategy: ScoringStrategy, words_limit: usize, exhaustive_number_hits: bool, + max_total_hits: Option, rtxn: &'a heed::RoTxn<'a>, index: &'a Index, semantic: Option, @@ -73,6 +74,7 @@ impl<'a> Search<'a> { terms_matching_strategy: TermsMatchingStrategy::default(), scoring_strategy: Default::default(), exhaustive_number_hits: false, + max_total_hits: None, words_limit: 10, rtxn, index, @@ -163,6 +165,11 @@ impl<'a> Search<'a> { self } + pub fn max_total_hits(&mut self, max_total_hits: Option) -> &mut Search<'a> { + self.max_total_hits = max_total_hits; + self + } + pub fn time_budget(&mut self, time_budget: TimeBudget) -> &mut Search<'a> { self.time_budget = time_budget; self @@ -237,6 +244,7 @@ impl<'a> Search<'a> { vector, self.scoring_strategy, self.exhaustive_number_hits, + self.max_total_hits, universe, &self.sort_criteria, &self.distinct, @@ -256,6 +264,7 @@ impl<'a> Search<'a> { self.terms_matching_strategy, self.scoring_strategy, self.exhaustive_number_hits, + self.max_total_hits, universe, &self.sort_criteria, &self.distinct, @@ -309,6 +318,7 @@ impl fmt::Debug for Search<'_> { scoring_strategy, words_limit, exhaustive_number_hits, + max_total_hits, rtxn: _, index: _, semantic, @@ -328,6 +338,7 @@ impl fmt::Debug for Search<'_> { .field("terms_matching_strategy", terms_matching_strategy) .field("scoring_strategy", scoring_strategy) .field("exhaustive_number_hits", exhaustive_number_hits) + .field("max_total_hits", max_total_hits) .field("words_limit", words_limit) .field( "semantic.embedder_name", diff --git a/crates/milli/src/search/new/bucket_sort.rs b/crates/milli/src/search/new/bucket_sort.rs index f4fd62825..298983091 100644 --- a/crates/milli/src/search/new/bucket_sort.rs +++ b/crates/milli/src/search/new/bucket_sort.rs @@ -33,6 +33,7 @@ pub fn bucket_sort<'ctx, Q: RankingRuleQueryTrait>( time_budget: TimeBudget, ranking_score_threshold: Option, exhaustive_number_hits: bool, + max_total_hits: Option, ) -> Result { logger.initial_query(query); logger.ranking_rules(&ranking_rules); @@ -160,8 +161,11 @@ pub fn bucket_sort<'ctx, Q: RankingRuleQueryTrait>( }; } + let max_total_hits = max_total_hits.unwrap_or(usize::MAX); while valid_docids.len() < length - || (exhaustive_number_hits && ranking_score_threshold.is_some()) + || (exhaustive_number_hits + && ranking_score_threshold.is_some() + && valid_docids.len() < max_total_hits) { if time_budget.exceeded() { loop { diff --git a/crates/milli/src/search/new/matches/mod.rs b/crates/milli/src/search/new/matches/mod.rs index 2d6f2cf17..66f65f5e5 100644 --- a/crates/milli/src/search/new/matches/mod.rs +++ b/crates/milli/src/search/new/matches/mod.rs @@ -510,6 +510,7 @@ mod tests { crate::TermsMatchingStrategy::default(), crate::score_details::ScoringStrategy::Skip, false, + None, universe, &None, &None, diff --git a/crates/milli/src/search/new/mod.rs b/crates/milli/src/search/new/mod.rs index 2c6fe5c3c..047d08202 100644 --- a/crates/milli/src/search/new/mod.rs +++ b/crates/milli/src/search/new/mod.rs @@ -627,6 +627,7 @@ pub fn execute_vector_search( vector: &[f32], scoring_strategy: ScoringStrategy, exhaustive_number_hits: bool, + max_total_hits: Option, universe: RoaringBitmap, sort_criteria: &Option>, distinct: &Option, @@ -671,6 +672,7 @@ pub fn execute_vector_search( time_budget, ranking_score_threshold, exhaustive_number_hits, + max_total_hits, )?; Ok(PartialSearchResult { @@ -691,6 +693,7 @@ pub fn execute_search( terms_matching_strategy: TermsMatchingStrategy, scoring_strategy: ScoringStrategy, exhaustive_number_hits: bool, + max_total_hits: Option, mut universe: RoaringBitmap, sort_criteria: &Option>, distinct: &Option, @@ -828,6 +831,7 @@ pub fn execute_search( time_budget, ranking_score_threshold, exhaustive_number_hits, + max_total_hits, )? } else { let ranking_rules = @@ -845,6 +849,7 @@ pub fn execute_search( time_budget, ranking_score_threshold, exhaustive_number_hits, + max_total_hits, )? }; From 5a675bcb827300632262223b0c9d16525de17320 Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Wed, 2 Jul 2025 11:50:32 +0200 Subject: [PATCH 45/81] Add benchmarks --- crates/benchmarks/Cargo.toml | 5 ++ crates/benchmarks/benches/sort.rs | 108 +++++++++++++++++++++++++++++ crates/benchmarks/benches/utils.rs | 98 +++++++++++++++++++++----- 3 files changed, 195 insertions(+), 16 deletions(-) create mode 100644 crates/benchmarks/benches/sort.rs diff --git a/crates/benchmarks/Cargo.toml b/crates/benchmarks/Cargo.toml index 9dccc444b..68ed5aff4 100644 --- a/crates/benchmarks/Cargo.toml +++ b/crates/benchmarks/Cargo.toml @@ -51,3 +51,8 @@ harness = false [[bench]] name = "indexing" harness = false + +[[bench]] +name = "sort" +harness = false + diff --git a/crates/benchmarks/benches/sort.rs b/crates/benchmarks/benches/sort.rs new file mode 100644 index 000000000..0dd392cb2 --- /dev/null +++ b/crates/benchmarks/benches/sort.rs @@ -0,0 +1,108 @@ +//! This benchmark module is used to compare the performance of sorting documents in /search VS /documents +//! +//! The tests/benchmarks were designed in the context of a query returning only 20 documents. + +mod datasets_paths; +mod utils; + +use criterion::{criterion_group, criterion_main}; +use milli::update::Settings; +use utils::Conf; + +#[cfg(not(windows))] +#[global_allocator] +static ALLOC: mimalloc::MiMalloc = mimalloc::MiMalloc; + +fn base_conf(builder: &mut Settings) { + let displayed_fields = + ["geonameid", "name", "asciiname", "alternatenames", "_geo", "population"] + .iter() + .map(|s| s.to_string()) + .collect(); + builder.set_displayed_fields(displayed_fields); + + let sortable_fields = + ["_geo", "name", "population", "elevation", "timezone", "modification-date"] + .iter() + .map(|s| s.to_string()) + .collect(); + builder.set_sortable_fields(sortable_fields); +} + +#[rustfmt::skip] +const BASE_CONF: Conf = Conf { + dataset: datasets_paths::SMOL_ALL_COUNTRIES, + dataset_format: "jsonl", + configure: base_conf, + primary_key: Some("geonameid"), + queries: &[""], + offsets: &[ + Some((0, 20)), // The most common query in the real world + Some((0, 500)), // A query that ranges over many documents + Some((980, 20)), // The worst query that could happen in the real world + Some((800_000, 20)) // The worst query + ], + get_documents: true, + ..Conf::BASE +}; + +fn bench_sort(c: &mut criterion::Criterion) { + #[rustfmt::skip] + let confs = &[ + // utils::Conf { + // group_name: "without sort", + // sort: None, + // ..BASE_CONF + // }, + + // utils::Conf { + // group_name: "sort on many different values", + // sort: Some(vec!["name:asc"]), + // ..BASE_CONF + // }, + + // utils::Conf { + // group_name: "sort on many similar values", + // sort: Some(vec!["timezone:desc"]), + // ..BASE_CONF + // }, + + // utils::Conf { + // group_name: "sort on many similar then different values", + // sort: Some(vec!["timezone:desc", "name:asc"]), + // ..BASE_CONF + // }, + + // utils::Conf { + // group_name: "sort on many different then similar values", + // sort: Some(vec!["timezone:desc", "name:asc"]), + // ..BASE_CONF + // }, + + utils::Conf { + group_name: "geo sort", + sample_size: Some(10), + sort: Some(vec!["_geoPoint(45.4777599, 9.1967508):asc"]), + ..BASE_CONF + }, + + utils::Conf { + group_name: "sort on many similar values then geo sort", + sample_size: Some(10), + sort: Some(vec!["timezone:desc", "_geoPoint(45.4777599, 9.1967508):asc"]), + ..BASE_CONF + }, + + utils::Conf { + group_name: "sort on many different values then geo sort", + sample_size: Some(10), + sort: Some(vec!["name:desc", "_geoPoint(45.4777599, 9.1967508):asc"]), + ..BASE_CONF + }, + ]; + + utils::run_benches(c, confs); +} + +criterion_group!(benches, bench_sort); +criterion_main!(benches); diff --git a/crates/benchmarks/benches/utils.rs b/crates/benchmarks/benches/utils.rs index aaa2d50a0..93fa7506f 100644 --- a/crates/benchmarks/benches/utils.rs +++ b/crates/benchmarks/benches/utils.rs @@ -9,6 +9,7 @@ use anyhow::Context; use bumpalo::Bump; use criterion::BenchmarkId; use memmap2::Mmap; +use milli::documents::sort::recursive_sort; use milli::heed::EnvOpenOptions; use milli::progress::Progress; use milli::update::new::indexer; @@ -35,6 +36,12 @@ pub struct Conf<'a> { pub configure: fn(&mut Settings), pub filter: Option<&'a str>, pub sort: Option>, + /// set to skip documents (offset, limit) + pub offsets: &'a [Option<(usize, usize)>], + /// enable if you want to bench getting documents without querying + pub get_documents: bool, + /// configure the benchmark sample size + pub sample_size: Option, /// enable or disable the optional words on the query pub optional_words: bool, /// primary key, if there is None we'll auto-generate docids for every documents @@ -52,6 +59,9 @@ impl Conf<'_> { configure: |_| (), filter: None, sort: None, + offsets: &[None], + get_documents: false, + sample_size: None, optional_words: true, primary_key: None, }; @@ -144,25 +154,81 @@ pub fn run_benches(c: &mut criterion::Criterion, confs: &[Conf]) { let file_name = Path::new(conf.dataset).file_name().and_then(|f| f.to_str()).unwrap(); let name = format!("{}: {}", file_name, conf.group_name); let mut group = c.benchmark_group(&name); + if let Some(sample_size) = conf.sample_size { + group.sample_size(sample_size); + } for &query in conf.queries { - group.bench_with_input(BenchmarkId::from_parameter(query), &query, |b, &query| { - b.iter(|| { - let rtxn = index.read_txn().unwrap(); - let mut search = index.search(&rtxn); - search.query(query).terms_matching_strategy(TermsMatchingStrategy::default()); - if let Some(filter) = conf.filter { - let filter = Filter::from_str(filter).unwrap().unwrap(); - search.filter(filter); - } - if let Some(sort) = &conf.sort { - let sort = sort.iter().map(|sort| sort.parse().unwrap()).collect(); - search.sort_criteria(sort); - } - let _ids = search.execute().unwrap(); - }); - }); + for offset in conf.offsets { + let parameter = match (query.is_empty(), offset) { + (true, None) => String::from("placeholder"), + (true, Some((offset, limit))) => format!("placeholder[{offset}:{limit}]"), + (false, None) => query.to_string(), + (false, Some((offset, limit))) => format!("{query}[{offset}:{limit}]"), + }; + group.bench_with_input( + BenchmarkId::from_parameter(parameter), + &query, + |b, &query| { + b.iter(|| { + let rtxn = index.read_txn().unwrap(); + let mut search = index.search(&rtxn); + search + .query(query) + .terms_matching_strategy(TermsMatchingStrategy::default()); + if let Some(filter) = conf.filter { + let filter = Filter::from_str(filter).unwrap().unwrap(); + search.filter(filter); + } + if let Some(sort) = &conf.sort { + let sort = sort.iter().map(|sort| sort.parse().unwrap()).collect(); + search.sort_criteria(sort); + } + if let Some((offset, limit)) = offset { + search.offset(*offset).limit(*limit); + } + + let _ids = search.execute().unwrap(); + }); + }, + ); + } } + + if conf.get_documents { + for offset in conf.offsets { + let parameter = match offset { + None => String::from("get_documents"), + Some((offset, limit)) => format!("get_documents[{offset}:{limit}]"), + }; + group.bench_with_input(BenchmarkId::from_parameter(parameter), &(), |b, &()| { + b.iter(|| { + let rtxn = index.read_txn().unwrap(); + if let Some(sort) = &conf.sort { + let sort = sort.iter().map(|sort| sort.parse().unwrap()).collect(); + let all_docs = index.documents_ids(&rtxn).unwrap(); + let facet_sort = + recursive_sort(&index, &rtxn, sort, &all_docs).unwrap(); + let iter = facet_sort.iter().unwrap(); + if let Some((offset, limit)) = offset { + let _results = iter.skip(*offset).take(*limit).collect::>(); + } else { + let _results = iter.collect::>(); + } + } else { + let all_docs = index.documents_ids(&rtxn).unwrap(); + if let Some((offset, limit)) = offset { + let _results = + all_docs.iter().skip(*offset).take(*limit).collect::>(); + } else { + let _results = all_docs.iter().collect::>(); + } + } + }); + }); + } + } + group.finish(); index.prepare_for_closing().wait(); From f60814b319b97beec0e2c98594d4c2cc55c2281f Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Wed, 2 Jul 2025 12:06:00 +0200 Subject: [PATCH 46/81] Add benchmark --- crates/benchmarks/benches/sort.rs | 60 +++++++++++++++++-------------- 1 file changed, 33 insertions(+), 27 deletions(-) diff --git a/crates/benchmarks/benches/sort.rs b/crates/benchmarks/benches/sort.rs index 0dd392cb2..c3e934432 100644 --- a/crates/benchmarks/benches/sort.rs +++ b/crates/benchmarks/benches/sort.rs @@ -49,35 +49,35 @@ const BASE_CONF: Conf = Conf { fn bench_sort(c: &mut criterion::Criterion) { #[rustfmt::skip] let confs = &[ - // utils::Conf { - // group_name: "without sort", - // sort: None, - // ..BASE_CONF - // }, + utils::Conf { + group_name: "without sort", + sort: None, + ..BASE_CONF + }, - // utils::Conf { - // group_name: "sort on many different values", - // sort: Some(vec!["name:asc"]), - // ..BASE_CONF - // }, + utils::Conf { + group_name: "sort on many different values", + sort: Some(vec!["name:asc"]), + ..BASE_CONF + }, - // utils::Conf { - // group_name: "sort on many similar values", - // sort: Some(vec!["timezone:desc"]), - // ..BASE_CONF - // }, + utils::Conf { + group_name: "sort on many similar values", + sort: Some(vec!["timezone:desc"]), + ..BASE_CONF + }, - // utils::Conf { - // group_name: "sort on many similar then different values", - // sort: Some(vec!["timezone:desc", "name:asc"]), - // ..BASE_CONF - // }, + utils::Conf { + group_name: "sort on many similar then different values", + sort: Some(vec!["timezone:desc", "name:asc"]), + ..BASE_CONF + }, - // utils::Conf { - // group_name: "sort on many different then similar values", - // sort: Some(vec!["timezone:desc", "name:asc"]), - // ..BASE_CONF - // }, + utils::Conf { + group_name: "sort on many different then similar values", + sort: Some(vec!["timezone:desc", "name:asc"]), + ..BASE_CONF + }, utils::Conf { group_name: "geo sort", @@ -88,17 +88,23 @@ fn bench_sort(c: &mut criterion::Criterion) { utils::Conf { group_name: "sort on many similar values then geo sort", - sample_size: Some(10), + sample_size: Some(50), sort: Some(vec!["timezone:desc", "_geoPoint(45.4777599, 9.1967508):asc"]), ..BASE_CONF }, utils::Conf { group_name: "sort on many different values then geo sort", - sample_size: Some(10), + sample_size: Some(50), sort: Some(vec!["name:desc", "_geoPoint(45.4777599, 9.1967508):asc"]), ..BASE_CONF }, + + utils::Conf { + group_name: "sort on many fields", + sort: Some(vec!["population:asc", "name:asc", "elevation:asc", "timezone:asc"]), + ..BASE_CONF + }, ]; utils::run_benches(c, confs); From 07bfed99e65360d62d17c051f6050c7d422ae455 Mon Sep 17 00:00:00 2001 From: Kerollmops Date: Fri, 4 Jul 2025 11:03:14 +0200 Subject: [PATCH 47/81] Expose the host in the analytics --- crates/meilisearch/src/routes/export_analytics.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/meilisearch/src/routes/export_analytics.rs b/crates/meilisearch/src/routes/export_analytics.rs index b66a5133b..bf1f667e0 100644 --- a/crates/meilisearch/src/routes/export_analytics.rs +++ b/crates/meilisearch/src/routes/export_analytics.rs @@ -1,3 +1,5 @@ +use url::Url; + use crate::analytics::Aggregate; use crate::routes::export::Export; @@ -5,6 +7,7 @@ use crate::routes::export::Export; pub struct ExportAnalytics { total_received: usize, has_api_key: bool, + hosts: Vec, sum_index_patterns: usize, sum_patterns_with_filter: usize, sum_patterns_with_override_settings: usize, @@ -13,8 +16,10 @@ pub struct ExportAnalytics { impl ExportAnalytics { pub fn from_export(export: &Export) -> Self { - let Export { url: _, api_key, payload_size, indexes } = export; + let Export { url, api_key, payload_size, indexes } = export; + let url = Url::parse(url).ok(); + let host = url.as_ref().and_then(Url::host_str); let has_api_key = api_key.is_some(); let index_patterns_count = indexes.as_ref().map_or(0, |indexes| indexes.len()); let patterns_with_filter_count = indexes.as_ref().map_or(0, |indexes| { @@ -33,6 +38,7 @@ impl ExportAnalytics { Self { total_received: 1, has_api_key, + hosts: host.map(ToOwned::to_owned).map_or_else(Default::default, |h| vec![h]), sum_index_patterns: index_patterns_count, sum_patterns_with_filter: patterns_with_filter_count, sum_patterns_with_override_settings: patterns_with_override_settings_count, @@ -49,6 +55,7 @@ impl Aggregate for ExportAnalytics { fn aggregate(mut self: Box, other: Box) -> Box { self.total_received += other.total_received; self.has_api_key |= other.has_api_key; + self.hosts.extend(other.hosts); self.sum_index_patterns += other.sum_index_patterns; self.sum_patterns_with_filter += other.sum_patterns_with_filter; self.sum_patterns_with_override_settings += other.sum_patterns_with_override_settings; @@ -84,6 +91,7 @@ impl Aggregate for ExportAnalytics { serde_json::json!({ "total_received": self.total_received, "has_api_key": self.has_api_key, + "hosts": self.hosts, "avg_index_patterns": avg_index_patterns, "avg_patterns_with_filter": avg_patterns_with_filter, "avg_patterns_with_override_settings": avg_patterns_with_override_settings, From 4c7a6e5c1bd25114dab164fb0fcfd67403012ea9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Renault?= Date: Mon, 7 Jul 2025 10:59:39 +0200 Subject: [PATCH 48/81] Do not leak private URLs --- .../src/routes/export_analytics.rs | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/crates/meilisearch/src/routes/export_analytics.rs b/crates/meilisearch/src/routes/export_analytics.rs index bf1f667e0..a2f0a129d 100644 --- a/crates/meilisearch/src/routes/export_analytics.rs +++ b/crates/meilisearch/src/routes/export_analytics.rs @@ -7,7 +7,7 @@ use crate::routes::export::Export; pub struct ExportAnalytics { total_received: usize, has_api_key: bool, - hosts: Vec, + sum_exports_meilisearch_cloud: usize, sum_index_patterns: usize, sum_patterns_with_filter: usize, sum_patterns_with_override_settings: usize, @@ -19,7 +19,11 @@ impl ExportAnalytics { let Export { url, api_key, payload_size, indexes } = export; let url = Url::parse(url).ok(); - let host = url.as_ref().and_then(Url::host_str); + let is_meilisearch_cloud = url.as_ref().and_then(Url::host_str).is_some_and(|host| { + host.ends_with("meilisearch.dev") + || host.ends_with("meilisearch.com") + || host.ends_with("meilisearch.io") + }); let has_api_key = api_key.is_some(); let index_patterns_count = indexes.as_ref().map_or(0, |indexes| indexes.len()); let patterns_with_filter_count = indexes.as_ref().map_or(0, |indexes| { @@ -38,7 +42,7 @@ impl ExportAnalytics { Self { total_received: 1, has_api_key, - hosts: host.map(ToOwned::to_owned).map_or_else(Default::default, |h| vec![h]), + sum_exports_meilisearch_cloud: is_meilisearch_cloud as usize, sum_index_patterns: index_patterns_count, sum_patterns_with_filter: patterns_with_filter_count, sum_patterns_with_override_settings: patterns_with_override_settings_count, @@ -55,7 +59,7 @@ impl Aggregate for ExportAnalytics { fn aggregate(mut self: Box, other: Box) -> Box { self.total_received += other.total_received; self.has_api_key |= other.has_api_key; - self.hosts.extend(other.hosts); + self.sum_exports_meilisearch_cloud += other.sum_exports_meilisearch_cloud; self.sum_index_patterns += other.sum_index_patterns; self.sum_patterns_with_filter += other.sum_patterns_with_filter; self.sum_patterns_with_override_settings += other.sum_patterns_with_override_settings; @@ -70,6 +74,12 @@ impl Aggregate for ExportAnalytics { Some(self.payload_sizes.iter().sum::() / self.payload_sizes.len() as u64) }; + let avg_exports_meilisearch_cloud = if self.total_received == 0 { + None + } else { + Some(self.sum_exports_meilisearch_cloud as f64 / self.total_received as f64) + }; + let avg_index_patterns = if self.total_received == 0 { None } else { @@ -91,7 +101,7 @@ impl Aggregate for ExportAnalytics { serde_json::json!({ "total_received": self.total_received, "has_api_key": self.has_api_key, - "hosts": self.hosts, + "avg_exports_meilisearch_cloud": avg_exports_meilisearch_cloud, "avg_index_patterns": avg_index_patterns, "avg_patterns_with_filter": avg_patterns_with_filter, "avg_patterns_with_override_settings": avg_patterns_with_override_settings, From 73c9c1ebdcd00d53483934e79ea6706e2d5a0586 Mon Sep 17 00:00:00 2001 From: Louis Dureuil Date: Mon, 7 Jul 2025 11:33:01 +0200 Subject: [PATCH 49/81] Add compile-time checks for dumpless upgrade --- crates/milli/src/update/upgrade/mod.rs | 78 ++++++++++++++++++-------- 1 file changed, 54 insertions(+), 24 deletions(-) diff --git a/crates/milli/src/update/upgrade/mod.rs b/crates/milli/src/update/upgrade/mod.rs index 9f64ca0e3..c23e5c8b1 100644 --- a/crates/milli/src/update/upgrade/mod.rs +++ b/crates/milli/src/update/upgrade/mod.rs @@ -24,6 +24,57 @@ trait UpgradeIndex { fn target_version(&self) -> (u32, u32, u32); } +const UPGRADE_FUNCTIONS: &[&dyn UpgradeIndex] = &[ + &V1_12_To_V1_12_3 {}, + &V1_12_3_To_V1_13_0 {}, + &V1_13_0_To_V1_13_1 {}, + &V1_13_1_To_Latest_V1_13 {}, + &Latest_V1_13_To_Latest_V1_14 {}, + &Latest_V1_14_To_Latest_V1_15 {}, + // This is the last upgrade function, it will be called when the index is up to date. + // any other upgrade function should be added before this one. + &ToCurrentNoOp {}, +]; + +/// Causes a compile-time error if the argument is not in range of `0..UPGRADE_FUNCTIONS.len()` +macro_rules! function_index { + ($start:expr) => {{ + const _CHECK_INDEX: () = { + if $start >= $crate::update::upgrade::UPGRADE_FUNCTIONS.len() { + panic!("upgrade functions out of range") + } + }; + + $start + }}; +} + +const fn start(from: (u32, u32, u32)) -> Option { + let start = match from { + (1, 12, 0..=2) => function_index!(0), + (1, 12, 3..) => function_index!(1), + (1, 13, 0) => function_index!(2), + (1, 13, _) => function_index!(4), + (1, 14, _) => function_index!(5), + // We must handle the current version in the match because in case of a failure some index may have been upgraded but not other. + (1, 15, _) => function_index!(6), + // We deliberately don't add a placeholder with (VERSION_MAJOR, VERSION_MINOR, VERSION_PATCH) here to force manually + // considering dumpless upgrade. + (_major, _minor, _patch) => return None, + }; + + Some(start) +} + +/// Causes a compile-time error if the latest package cannot be upgraded. +/// +/// This serves as a reminder to consider the proper dumpless upgrade implementation when changing the package version. +const _CHECK_PACKAGE_CAN_UPGRADE: () = { + if start((VERSION_MAJOR, VERSION_MINOR, VERSION_PATCH)).is_none() { + panic!("cannot upgrade from latest package version") + } +}; + /// Return true if the cached stats of the index must be regenerated pub fn upgrade( wtxn: &mut RwTxn, @@ -36,33 +87,12 @@ where MSP: Fn() -> bool + Sync, { let from = index.get_version(wtxn)?.unwrap_or(db_version); - let upgrade_functions: &[&dyn UpgradeIndex] = &[ - &V1_12_To_V1_12_3 {}, - &V1_12_3_To_V1_13_0 {}, - &V1_13_0_To_V1_13_1 {}, - &V1_13_1_To_Latest_V1_13 {}, - &Latest_V1_13_To_Latest_V1_14 {}, - &Latest_V1_14_To_Latest_V1_15 {}, - // This is the last upgrade function, it will be called when the index is up to date. - // any other upgrade function should be added before this one. - &ToCurrentNoOp {}, - ]; - let start = match from { - (1, 12, 0..=2) => 0, - (1, 12, 3..) => 1, - (1, 13, 0) => 2, - (1, 13, _) => 4, - (1, 14, _) => 5, - // We must handle the current version in the match because in case of a failure some index may have been upgraded but not other. - (1, 15, _) => 6, - (major, minor, patch) => { - return Err(InternalError::CannotUpgradeToVersion(major, minor, patch).into()) - } - }; + let start = + start(from).ok_or_else(|| InternalError::CannotUpgradeToVersion(from.0, from.1, from.2))?; enum UpgradeVersion {} - let upgrade_path = &upgrade_functions[start..]; + let upgrade_path = &UPGRADE_FUNCTIONS[start..]; let mut current_version = from; let mut regenerate_stats = false; From a3254d7d7d9085aa8c83929cec1f64fb88c1e686 Mon Sep 17 00:00:00 2001 From: Louis Dureuil Date: Mon, 7 Jul 2025 11:57:08 +0200 Subject: [PATCH 50/81] Implement dumpless upgrade from v1.15 to v1.16 --- crates/milli/src/update/upgrade/mod.rs | 4 ++ crates/milli/src/update/upgrade/v1_15.rs | 13 +++++++ crates/milli/src/update/upgrade/v1_16.rs | 48 ++++++++++++++++++++++++ crates/milli/src/vector/db.rs | 7 ++++ 4 files changed, 72 insertions(+) create mode 100644 crates/milli/src/update/upgrade/v1_16.rs diff --git a/crates/milli/src/update/upgrade/mod.rs b/crates/milli/src/update/upgrade/mod.rs index c23e5c8b1..f53319a37 100644 --- a/crates/milli/src/update/upgrade/mod.rs +++ b/crates/milli/src/update/upgrade/mod.rs @@ -2,6 +2,7 @@ mod v1_12; mod v1_13; mod v1_14; mod v1_15; +mod v1_16; use heed::RwTxn; use v1_12::{V1_12_3_To_V1_13_0, V1_12_To_V1_12_3}; use v1_13::{V1_13_0_To_V1_13_1, V1_13_1_To_Latest_V1_13}; @@ -10,6 +11,7 @@ use v1_15::Latest_V1_14_To_Latest_V1_15; use crate::constants::{VERSION_MAJOR, VERSION_MINOR, VERSION_PATCH}; use crate::progress::{Progress, VariableNameStep}; +use crate::update::upgrade::v1_16::Latest_V1_15_To_V1_16_0; use crate::{Index, InternalError, Result}; trait UpgradeIndex { @@ -31,6 +33,7 @@ const UPGRADE_FUNCTIONS: &[&dyn UpgradeIndex] = &[ &V1_13_1_To_Latest_V1_13 {}, &Latest_V1_13_To_Latest_V1_14 {}, &Latest_V1_14_To_Latest_V1_15 {}, + &Latest_V1_15_To_V1_16_0 {}, // This is the last upgrade function, it will be called when the index is up to date. // any other upgrade function should be added before this one. &ToCurrentNoOp {}, @@ -58,6 +61,7 @@ const fn start(from: (u32, u32, u32)) -> Option { (1, 14, _) => function_index!(5), // We must handle the current version in the match because in case of a failure some index may have been upgraded but not other. (1, 15, _) => function_index!(6), + (1, 16, _) => function_index!(7), // We deliberately don't add a placeholder with (VERSION_MAJOR, VERSION_MINOR, VERSION_PATCH) here to force manually // considering dumpless upgrade. (_major, _minor, _patch) => return None, diff --git a/crates/milli/src/update/upgrade/v1_15.rs b/crates/milli/src/update/upgrade/v1_15.rs index cea4783a1..9ca25d06b 100644 --- a/crates/milli/src/update/upgrade/v1_15.rs +++ b/crates/milli/src/update/upgrade/v1_15.rs @@ -1,4 +1,6 @@ use heed::RwTxn; +use roaring::RoaringBitmap; +use serde::{Deserialize, Serialize}; use super::UpgradeIndex; use crate::progress::Progress; @@ -26,3 +28,14 @@ impl UpgradeIndex for Latest_V1_14_To_Latest_V1_15 { (1, 15, 0) } } + +/// Parts of v1.15 `IndexingEmbeddingConfig` that are relevant for upgrade to v1.16 +/// +/// # Warning +/// +/// This object should not be rewritten to the DB, only read to get the name and `user_provided` roaring. +#[derive(Debug, Deserialize, Serialize)] +pub struct IndexEmbeddingConfig { + pub name: String, + pub user_provided: RoaringBitmap, +} diff --git a/crates/milli/src/update/upgrade/v1_16.rs b/crates/milli/src/update/upgrade/v1_16.rs new file mode 100644 index 000000000..f43efd77d --- /dev/null +++ b/crates/milli/src/update/upgrade/v1_16.rs @@ -0,0 +1,48 @@ +use heed::types::{SerdeJson, Str}; +use heed::RwTxn; + +use super::UpgradeIndex; +use crate::progress::Progress; +use crate::vector::db::{EmbedderInfo, EmbeddingStatus}; +use crate::{Index, InternalError, Result}; + +#[allow(non_camel_case_types)] +pub(super) struct Latest_V1_15_To_V1_16_0(); + +impl UpgradeIndex for Latest_V1_15_To_V1_16_0 { + fn upgrade( + &self, + wtxn: &mut RwTxn, + index: &Index, + _original: (u32, u32, u32), + _progress: Progress, + ) -> Result { + let v1_15_indexing_configs = index + .main + .remap_types::>>() + .get(wtxn, crate::index::main_key::EMBEDDING_CONFIGS)? + .unwrap_or_default(); + + let embedders = index.embedding_configs(); + for config in v1_15_indexing_configs { + let embedder_id = embedders.embedder_id(wtxn, &config.name)?.ok_or( + InternalError::DatabaseMissingEntry { + db_name: crate::index::db_name::VECTOR_EMBEDDER_CATEGORY_ID, + key: None, + }, + )?; + let info = EmbedderInfo { + embedder_id, + // v1.15 used not to make a difference between `user_provided` and `! regenerate`. + embedding_status: EmbeddingStatus::from_user_provided(config.user_provided), + }; + embedders.put_embedder_info(wtxn, &config.name, &info)?; + } + + Ok(false) + } + + fn target_version(&self) -> (u32, u32, u32) { + (1, 16, 0) + } +} diff --git a/crates/milli/src/vector/db.rs b/crates/milli/src/vector/db.rs index 0e890fac9..2fea75d68 100644 --- a/crates/milli/src/vector/db.rs +++ b/crates/milli/src/vector/db.rs @@ -117,6 +117,13 @@ impl EmbeddingStatus { Default::default() } + /// Create a new `EmbeddingStatus` that assumes that any `user_provided` docid is also skipping regenerate. + /// + /// Used for migration from v1.15 and earlier DBs. + pub(crate) fn from_user_provided(user_provided: RoaringBitmap) -> Self { + Self { user_provided, skip_regenerate_different_from_user_provided: Default::default() } + } + /// Whether the document contains user-provided vectors for that embedder. pub fn is_user_provided(&self, docid: DocumentId) -> bool { self.user_provided.contains(docid) From f7c8a77f89c2d449492421de2f812613bb8e4234 Mon Sep 17 00:00:00 2001 From: Louis Dureuil Date: Mon, 7 Jul 2025 16:01:50 +0200 Subject: [PATCH 51/81] Update v1.12.0 DB to contain vectors --- .../upgrade/v1_12/v1_12_0.ms/auth/lock.mdb | Bin 8192 -> 8192 bytes .../data.mdb | Bin 163840 -> 229376 bytes .../lock.mdb | Bin 65664 -> 65664 bytes .../upgrade/v1_12/v1_12_0.ms/tasks/data.mdb | Bin 212992 -> 225280 bytes .../upgrade/v1_12/v1_12_0.ms/tasks/lock.mdb | Bin 8192 -> 8192 bytes 5 files changed, 0 insertions(+), 0 deletions(-) diff --git a/crates/meilisearch/tests/upgrade/v1_12/v1_12_0.ms/auth/lock.mdb b/crates/meilisearch/tests/upgrade/v1_12/v1_12_0.ms/auth/lock.mdb index 4c80ffe2c03fba62008aca5da473370d7b421a38..80fb2b9d5b76d935cb8bddeabccb21fee83a51a6 100644 GIT binary patch delta 53 zcmZp0XmAj}ci{a#rUjiEtPBvq2&C3BF;2ACohYL*F+pTvg8(ao!H;~#jSJ)DCnoR! E0Ha9{P5=M^ delta 42 ucmZp0XmAj`ci{a#rUZQ*Rt5-QoM>r0(Zz@DfY@b+`_&s4#>-Dk;06F8#|_f} diff --git a/crates/meilisearch/tests/upgrade/v1_12/v1_12_0.ms/indexes/381abe91-f939-4b91-92f2-01a24c2e8e3d/data.mdb b/crates/meilisearch/tests/upgrade/v1_12/v1_12_0.ms/indexes/381abe91-f939-4b91-92f2-01a24c2e8e3d/data.mdb index c31db3415612b78106346996d5ff20d3fd293ae9..95ca0a9da78bb8e4d843ec3e7b4450b4b92fe076 100644 GIT binary patch literal 229376 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 ZpRY5viA~gOe)yN#_}M&fKdb%&e*$0Q%=Q2P delta 2441 zcmcImZEO@p7@pZH$M$Y^-@Wp6mP(g$w57Ip-MjYgdSJY&5rXD=MiLPNRRY9TXlbl) z6bMH`j6o!YF&!nb<(Hx%0lR@Cg0?CeKfn@=4Izq6O$-u#GzR%qz}dZ7QVi;{gWIs%g;3F+Cqq6B<;PrMHFJ;F4tupUuCTVJh~)}leSCW} zP|{6~_@!Q;NBb7{*AiVTLZdJW7u1v*h95n%NUkJ{yMP`K*3gftC%X&Ky60QkC=U7k z(-Gpn5gNFB${MF161M+2qM1bhO{RY=okRU^9iW5rUSfyaT(UX8s$9Y)=E~~FFb>f7 zY96PVkV>WCqj)-97#zhCtqP@Rrg?s0p$KytJrSx)PZKlw7K@OC5=ixa3n}=}d(rDx zUQhyXMeXrclC|Cqkn*bDchq6Blblo^CdKM5=v7c%686(OS>%|p#E?d_k&6bR67Cetj@FuqXJTJ?ZADM8#-DLx33_p5#Lx;Ri$cVNy}sbu>D4@_IDO^% zA{?PZhaO}kiAl~sf)5nl41ioB4&_=d;Tji>EuV{hR+-sY6V~<)5|fNw2CU?85?=?O zD!#UA%{iux=6p#!#EB)x@5BI@aHJT?Aj28h59=Wbd3a1+|KXn5@rkd}zE(zFaeV>p z=&NYd#j4}f<|JQp-htcRN2owp-L^7v@^Vq5Z^nJjDQ8y^U%O~yU#a1&5@%Id=TQ}j zxx-q`#QNpC%J9H)!GCjM(7-oQe~wLkUSoUsZm|{|3sAr3R_gbA?o9rFr2fX-zO}A3 zH@I)a6LpbbxFH&g#o~2&;<{|^>rTd27!x6`6c8WLr3Yv{mF}N^(fZc3nlVs3Ddzy9i;SF@G zU31plm=9$rzAChQw4i}2p7*>zq+%f zlP8dW8k3MS=PT^pA$&i}mP%{b?g6Yh zwm9|FN~GlP@G&d((|1LQiLUa!GRZQfQk#uX%t0t1s%0S*L}xidt2igi;T)vllDdPV zR0M<20|_{*UQ@rw%|3&VE9~M=cvx`RVZ+O97BiS0l4en<=~dQs$Rb{ob~G({w5X*% z!?yZ(NP-M|^2#oGfAwa_3Ep~py(@U@EmzL**1Okky+VCDJQy=BGXbpG4bZIGe9Fc9 zeOdC@6#{&%rbn~IWfjCYi!P|5#n?^Y^bDdv7n U$Nxfkwhu|$e76@Q|D6QsPgkof6951J diff --git a/crates/meilisearch/tests/upgrade/v1_12/v1_12_0.ms/indexes/381abe91-f939-4b91-92f2-01a24c2e8e3d/lock.mdb b/crates/meilisearch/tests/upgrade/v1_12/v1_12_0.ms/indexes/381abe91-f939-4b91-92f2-01a24c2e8e3d/lock.mdb index c99608b77f76ea52301126c16a6f78adc622d786..5fa5e6b49e4f2cba646080a3185fb08f08ff8957 100644 GIT binary patch delta 115 zcmZo@WNBz*5x;lf{XV7zof;wx5WohcHY77nwAY;|qcJf-WMYE=D?>q40pr9#Z7_R- ZeIcA(5R#9T{b4FXe8V;{dlOT`KLC!+BKQCR delta 104 zcmZo@WNBz*5xjTc{XV7yeH}pt2;iG&X*|)zhwXscXNUXM69cs-F$uChyDtrAe~Wj9 Yv$x%Mg0rv5BgDV`w*rfAVruvY0CVam9smFU diff --git a/crates/meilisearch/tests/upgrade/v1_12/v1_12_0.ms/tasks/data.mdb b/crates/meilisearch/tests/upgrade/v1_12/v1_12_0.ms/tasks/data.mdb index 226be2332b67c1f376e37de03c5ccebfaf80bf6b..f2bcb1b8b36f5e17f93dd60d4304fc39907f1138 100644 GIT binary patch literal 225280 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 aQsDDjff@bI?STJpv9JV;{SjAS|NjRwAVj(V delta 6380 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 Y@Xkfwgm!dxXRSRnyYoj}_KGt9KafYQZvX%Q diff --git a/crates/meilisearch/tests/upgrade/v1_12/v1_12_0.ms/tasks/lock.mdb b/crates/meilisearch/tests/upgrade/v1_12/v1_12_0.ms/tasks/lock.mdb index 6d38eab089708e3c50d476c7a142aea909c6b5c6..b8e0e358dd589f5c229210cdc998a1052c04878c 100644 GIT binary patch delta 78 zcmZp0XmAj}ci{a#rUjiEaSRZ^45UIB)F#^NPL$D@m>@E-L4cK^AgX|IVxTsdz2QU= PoL%q|%--z4ct8#Sgm4!{ delta 65 zcmZp0XmAj`ci{a#rUZQ*Zw3fpnP_P|(Zz@DfZKn^`_&TzwI&~66lS(UFs;GN%?^wQ F Date: Mon, 7 Jul 2025 16:42:50 +0200 Subject: [PATCH 52/81] Fix existing snaps --- .../kefir_settings.snap | 11 +- ...rEnqueuedAt_equal_2025-01-16T16_47_41.snap | 326 ++++++++---------- ...rFinishedAt_equal_2025-01-16T16_47_41.snap | 326 ++++++++---------- ...erStartedAt_equal_2025-01-16T16_47_41.snap | 326 ++++++++---------- ...rEnqueuedAt_equal_2025-01-16T16_47_41.snap | 246 +++++++------ ...rFinishedAt_equal_2025-01-16T16_47_41.snap | 246 +++++++------ ...erStartedAt_equal_2025-01-16T16_47_41.snap | 246 +++++++------ ...ue_once_everything_has_been_processed.snap | 155 ++++++++- ...ue_once_everything_has_been_processed.snap | 120 ++++++- .../tests/upgrade/v1_12/v1_12_0.rs | 58 ++-- 10 files changed, 1117 insertions(+), 943 deletions(-) diff --git a/crates/meilisearch/tests/upgrade/v1_12/snapshots/v1_12_0.rs/check_the_index_features/kefir_settings.snap b/crates/meilisearch/tests/upgrade/v1_12/snapshots/v1_12_0.rs/check_the_index_features/kefir_settings.snap index af7e82c8b..3c97dbe70 100644 --- a/crates/meilisearch/tests/upgrade/v1_12/snapshots/v1_12_0.rs/check_the_index_features/kefir_settings.snap +++ b/crates/meilisearch/tests/upgrade/v1_12/snapshots/v1_12_0.rs/check_the_index_features/kefir_settings.snap @@ -61,7 +61,16 @@ source: crates/meilisearch/tests/upgrade/v1_12/v1_12_0.rs "pagination": { "maxTotalHits": 15 }, - "embedders": {}, + "embedders": { + "doggo_embedder": { + "source": "huggingFace", + "model": "sentence-transformers/all-MiniLM-L6-v2", + "revision": "e4ce9877abf3edfe10b0d82785e83bdcb973e22e", + "pooling": "forceMean", + "documentTemplate": "{{doc.description}}", + "documentTemplateMaxBytes": 400 + } + }, "searchCutoffMs": 8000, "localizedAttributes": [ { diff --git a/crates/meilisearch/tests/upgrade/v1_12/snapshots/v1_12_0.rs/check_the_index_scheduler/batches_filter_afterEnqueuedAt_equal_2025-01-16T16_47_41.snap b/crates/meilisearch/tests/upgrade/v1_12/snapshots/v1_12_0.rs/check_the_index_scheduler/batches_filter_afterEnqueuedAt_equal_2025-01-16T16_47_41.snap index f4edae51b..b56cc5ca3 100644 --- a/crates/meilisearch/tests/upgrade/v1_12/snapshots/v1_12_0.rs/check_the_index_scheduler/batches_filter_afterEnqueuedAt_equal_2025-01-16T16_47_41.snap +++ b/crates/meilisearch/tests/upgrade/v1_12/snapshots/v1_12_0.rs/check_the_index_scheduler/batches_filter_afterEnqueuedAt_equal_2025-01-16T16_47_41.snap @@ -4,7 +4,7 @@ source: crates/meilisearch/tests/upgrade/v1_12/v1_12_0.rs { "results": [ { - "uid": 24, + "uid": 30, "progress": null, "details": { "upgradeFrom": "v1.12.0", @@ -26,6 +26,155 @@ source: crates/meilisearch/tests/upgrade/v1_12/v1_12_0.rs "finishedAt": "[date]", "batchStrategy": "stopped after the last task of type `upgradeDatabase` because they cannot be batched with tasks of any other type." }, + { + "uid": 29, + "progress": null, + "details": { + "receivedDocuments": 1, + "indexedDocuments": 1 + }, + "stats": { + "totalNbTasks": 1, + "status": { + "succeeded": 1 + }, + "types": { + "documentAdditionOrUpdate": 1 + }, + "indexUids": { + "kefir": 1 + } + }, + "duration": "PT0.067201S", + "startedAt": "2025-07-07T13:43:08.772854Z", + "finishedAt": "2025-07-07T13:43:08.840055Z", + "batchStrategy": "unspecified" + }, + { + "uid": 28, + "progress": null, + "details": { + "deletedDocuments": 1 + }, + "stats": { + "totalNbTasks": 1, + "status": { + "succeeded": 1 + }, + "types": { + "indexDeletion": 1 + }, + "indexUids": { + "mieli": 1 + } + }, + "duration": "PT0.012727S", + "startedAt": "2025-07-07T13:42:50.745461Z", + "finishedAt": "2025-07-07T13:42:50.758188Z", + "batchStrategy": "unspecified" + }, + { + "uid": 27, + "progress": null, + "details": { + "receivedDocuments": 1, + "indexedDocuments": 0 + }, + "stats": { + "totalNbTasks": 1, + "status": { + "failed": 1 + }, + "types": { + "documentAdditionOrUpdate": 1 + }, + "indexUids": { + "kefir": 1 + } + }, + "duration": "PT0.059920S", + "startedAt": "2025-07-07T13:42:15.625413Z", + "finishedAt": "2025-07-07T13:42:15.685333Z", + "batchStrategy": "unspecified" + }, + { + "uid": 26, + "progress": null, + "details": { + "receivedDocuments": 1, + "indexedDocuments": 1 + }, + "stats": { + "totalNbTasks": 1, + "status": { + "succeeded": 1 + }, + "types": { + "documentAdditionOrUpdate": 1 + }, + "indexUids": { + "mieli": 1 + } + }, + "duration": "PT0.088879S", + "startedAt": "2025-07-07T13:40:01.461741Z", + "finishedAt": "2025-07-07T13:40:01.55062Z", + "batchStrategy": "unspecified" + }, + { + "uid": 25, + "progress": null, + "details": { + "receivedDocuments": 1, + "indexedDocuments": 1 + }, + "stats": { + "totalNbTasks": 1, + "status": { + "succeeded": 1 + }, + "types": { + "documentAdditionOrUpdate": 1 + }, + "indexUids": { + "kefir": 1 + } + }, + "duration": "PT0.312911S", + "startedAt": "2025-07-07T13:32:46.139785Z", + "finishedAt": "2025-07-07T13:32:46.452696Z", + "batchStrategy": "unspecified" + }, + { + "uid": 24, + "progress": null, + "details": { + "embedders": { + "doggo_embedder": { + "source": "huggingFace", + "model": "sentence-transformers/all-MiniLM-L6-v2", + "revision": "e4ce9877abf3edfe10b0d82785e83bdcb973e22e", + "documentTemplate": "{{doc.description}}" + } + } + }, + "stats": { + "totalNbTasks": 1, + "status": { + "succeeded": 1 + }, + "types": { + "settingsUpdate": 1 + }, + "indexUids": { + "kefir": 1 + } + }, + "duration": "PT0.247378S", + "startedAt": "2025-07-07T13:28:27.391344Z", + "finishedAt": "2025-07-07T13:28:27.638722Z", + "batchStrategy": "unspecified" + }, { "uid": 23, "progress": null, @@ -348,179 +497,10 @@ source: crates/meilisearch/tests/upgrade/v1_12/v1_12_0.rs "startedAt": "2025-01-16T17:01:14.112756687Z", "finishedAt": "2025-01-16T17:01:14.120064527Z", "batchStrategy": "unspecified" - }, - { - "uid": 10, - "progress": null, - "details": { - "faceting": { - "maxValuesPerFacet": 99 - }, - "pagination": { - "maxTotalHits": 15 - } - }, - "stats": { - "totalNbTasks": 1, - "status": { - "succeeded": 1 - }, - "types": { - "settingsUpdate": 1 - }, - "indexUids": { - "kefir": 1 - } - }, - "duration": "PT0.007391353S", - "startedAt": "2025-01-16T17:00:29.201180268Z", - "finishedAt": "2025-01-16T17:00:29.208571621Z", - "batchStrategy": "unspecified" - }, - { - "uid": 9, - "progress": null, - "details": { - "faceting": { - "maxValuesPerFacet": 100 - }, - "pagination": { - "maxTotalHits": 1000 - } - }, - "stats": { - "totalNbTasks": 1, - "status": { - "succeeded": 1 - }, - "types": { - "settingsUpdate": 1 - }, - "indexUids": { - "kefir": 1 - } - }, - "duration": "PT0.007445825S", - "startedAt": "2025-01-16T17:00:15.77629445Z", - "finishedAt": "2025-01-16T17:00:15.783740275Z", - "batchStrategy": "unspecified" - }, - { - "uid": 8, - "progress": null, - "details": { - "typoTolerance": { - "minWordSizeForTypos": { - "oneTypo": 4 - }, - "disableOnWords": [ - "kefir" - ], - "disableOnAttributes": [ - "surname" - ] - } - }, - "stats": { - "totalNbTasks": 1, - "status": { - "succeeded": 1 - }, - "types": { - "settingsUpdate": 1 - }, - "indexUids": { - "kefir": 1 - } - }, - "duration": "PT0.012020083S", - "startedAt": "2025-01-16T16:59:42.744086671Z", - "finishedAt": "2025-01-16T16:59:42.756106754Z", - "batchStrategy": "unspecified" - }, - { - "uid": 7, - "progress": null, - "details": { - "typoTolerance": { - "minWordSizeForTypos": { - "oneTypo": 4 - } - } - }, - "stats": { - "totalNbTasks": 1, - "status": { - "succeeded": 1 - }, - "types": { - "settingsUpdate": 1 - }, - "indexUids": { - "kefir": 1 - } - }, - "duration": "PT0.007440092S", - "startedAt": "2025-01-16T16:58:41.2155771Z", - "finishedAt": "2025-01-16T16:58:41.223017192Z", - "batchStrategy": "unspecified" - }, - { - "uid": 6, - "progress": null, - "details": { - "synonyms": { - "boubou": [ - "kefir" - ] - } - }, - "stats": { - "totalNbTasks": 1, - "status": { - "succeeded": 1 - }, - "types": { - "settingsUpdate": 1 - }, - "indexUids": { - "kefir": 1 - } - }, - "duration": "PT0.007565161S", - "startedAt": "2025-01-16T16:54:51.940332781Z", - "finishedAt": "2025-01-16T16:54:51.947897942Z", - "batchStrategy": "unspecified" - }, - { - "uid": 5, - "progress": null, - "details": { - "stopWords": [ - "le", - "un" - ] - }, - "stats": { - "totalNbTasks": 1, - "status": { - "succeeded": 1 - }, - "types": { - "settingsUpdate": 1 - }, - "indexUids": { - "kefir": 1 - } - }, - "duration": "PT0.016307263S", - "startedAt": "2025-01-16T16:53:19.913351957Z", - "finishedAt": "2025-01-16T16:53:19.92965922Z", - "batchStrategy": "unspecified" } ], - "total": 23, + "total": 29, "limit": 20, - "from": 24, - "next": 4 + "from": 30, + "next": 10 } diff --git a/crates/meilisearch/tests/upgrade/v1_12/snapshots/v1_12_0.rs/check_the_index_scheduler/batches_filter_afterFinishedAt_equal_2025-01-16T16_47_41.snap b/crates/meilisearch/tests/upgrade/v1_12/snapshots/v1_12_0.rs/check_the_index_scheduler/batches_filter_afterFinishedAt_equal_2025-01-16T16_47_41.snap index f4edae51b..b56cc5ca3 100644 --- a/crates/meilisearch/tests/upgrade/v1_12/snapshots/v1_12_0.rs/check_the_index_scheduler/batches_filter_afterFinishedAt_equal_2025-01-16T16_47_41.snap +++ b/crates/meilisearch/tests/upgrade/v1_12/snapshots/v1_12_0.rs/check_the_index_scheduler/batches_filter_afterFinishedAt_equal_2025-01-16T16_47_41.snap @@ -4,7 +4,7 @@ source: crates/meilisearch/tests/upgrade/v1_12/v1_12_0.rs { "results": [ { - "uid": 24, + "uid": 30, "progress": null, "details": { "upgradeFrom": "v1.12.0", @@ -26,6 +26,155 @@ source: crates/meilisearch/tests/upgrade/v1_12/v1_12_0.rs "finishedAt": "[date]", "batchStrategy": "stopped after the last task of type `upgradeDatabase` because they cannot be batched with tasks of any other type." }, + { + "uid": 29, + "progress": null, + "details": { + "receivedDocuments": 1, + "indexedDocuments": 1 + }, + "stats": { + "totalNbTasks": 1, + "status": { + "succeeded": 1 + }, + "types": { + "documentAdditionOrUpdate": 1 + }, + "indexUids": { + "kefir": 1 + } + }, + "duration": "PT0.067201S", + "startedAt": "2025-07-07T13:43:08.772854Z", + "finishedAt": "2025-07-07T13:43:08.840055Z", + "batchStrategy": "unspecified" + }, + { + "uid": 28, + "progress": null, + "details": { + "deletedDocuments": 1 + }, + "stats": { + "totalNbTasks": 1, + "status": { + "succeeded": 1 + }, + "types": { + "indexDeletion": 1 + }, + "indexUids": { + "mieli": 1 + } + }, + "duration": "PT0.012727S", + "startedAt": "2025-07-07T13:42:50.745461Z", + "finishedAt": "2025-07-07T13:42:50.758188Z", + "batchStrategy": "unspecified" + }, + { + "uid": 27, + "progress": null, + "details": { + "receivedDocuments": 1, + "indexedDocuments": 0 + }, + "stats": { + "totalNbTasks": 1, + "status": { + "failed": 1 + }, + "types": { + "documentAdditionOrUpdate": 1 + }, + "indexUids": { + "kefir": 1 + } + }, + "duration": "PT0.059920S", + "startedAt": "2025-07-07T13:42:15.625413Z", + "finishedAt": "2025-07-07T13:42:15.685333Z", + "batchStrategy": "unspecified" + }, + { + "uid": 26, + "progress": null, + "details": { + "receivedDocuments": 1, + "indexedDocuments": 1 + }, + "stats": { + "totalNbTasks": 1, + "status": { + "succeeded": 1 + }, + "types": { + "documentAdditionOrUpdate": 1 + }, + "indexUids": { + "mieli": 1 + } + }, + "duration": "PT0.088879S", + "startedAt": "2025-07-07T13:40:01.461741Z", + "finishedAt": "2025-07-07T13:40:01.55062Z", + "batchStrategy": "unspecified" + }, + { + "uid": 25, + "progress": null, + "details": { + "receivedDocuments": 1, + "indexedDocuments": 1 + }, + "stats": { + "totalNbTasks": 1, + "status": { + "succeeded": 1 + }, + "types": { + "documentAdditionOrUpdate": 1 + }, + "indexUids": { + "kefir": 1 + } + }, + "duration": "PT0.312911S", + "startedAt": "2025-07-07T13:32:46.139785Z", + "finishedAt": "2025-07-07T13:32:46.452696Z", + "batchStrategy": "unspecified" + }, + { + "uid": 24, + "progress": null, + "details": { + "embedders": { + "doggo_embedder": { + "source": "huggingFace", + "model": "sentence-transformers/all-MiniLM-L6-v2", + "revision": "e4ce9877abf3edfe10b0d82785e83bdcb973e22e", + "documentTemplate": "{{doc.description}}" + } + } + }, + "stats": { + "totalNbTasks": 1, + "status": { + "succeeded": 1 + }, + "types": { + "settingsUpdate": 1 + }, + "indexUids": { + "kefir": 1 + } + }, + "duration": "PT0.247378S", + "startedAt": "2025-07-07T13:28:27.391344Z", + "finishedAt": "2025-07-07T13:28:27.638722Z", + "batchStrategy": "unspecified" + }, { "uid": 23, "progress": null, @@ -348,179 +497,10 @@ source: crates/meilisearch/tests/upgrade/v1_12/v1_12_0.rs "startedAt": "2025-01-16T17:01:14.112756687Z", "finishedAt": "2025-01-16T17:01:14.120064527Z", "batchStrategy": "unspecified" - }, - { - "uid": 10, - "progress": null, - "details": { - "faceting": { - "maxValuesPerFacet": 99 - }, - "pagination": { - "maxTotalHits": 15 - } - }, - "stats": { - "totalNbTasks": 1, - "status": { - "succeeded": 1 - }, - "types": { - "settingsUpdate": 1 - }, - "indexUids": { - "kefir": 1 - } - }, - "duration": "PT0.007391353S", - "startedAt": "2025-01-16T17:00:29.201180268Z", - "finishedAt": "2025-01-16T17:00:29.208571621Z", - "batchStrategy": "unspecified" - }, - { - "uid": 9, - "progress": null, - "details": { - "faceting": { - "maxValuesPerFacet": 100 - }, - "pagination": { - "maxTotalHits": 1000 - } - }, - "stats": { - "totalNbTasks": 1, - "status": { - "succeeded": 1 - }, - "types": { - "settingsUpdate": 1 - }, - "indexUids": { - "kefir": 1 - } - }, - "duration": "PT0.007445825S", - "startedAt": "2025-01-16T17:00:15.77629445Z", - "finishedAt": "2025-01-16T17:00:15.783740275Z", - "batchStrategy": "unspecified" - }, - { - "uid": 8, - "progress": null, - "details": { - "typoTolerance": { - "minWordSizeForTypos": { - "oneTypo": 4 - }, - "disableOnWords": [ - "kefir" - ], - "disableOnAttributes": [ - "surname" - ] - } - }, - "stats": { - "totalNbTasks": 1, - "status": { - "succeeded": 1 - }, - "types": { - "settingsUpdate": 1 - }, - "indexUids": { - "kefir": 1 - } - }, - "duration": "PT0.012020083S", - "startedAt": "2025-01-16T16:59:42.744086671Z", - "finishedAt": "2025-01-16T16:59:42.756106754Z", - "batchStrategy": "unspecified" - }, - { - "uid": 7, - "progress": null, - "details": { - "typoTolerance": { - "minWordSizeForTypos": { - "oneTypo": 4 - } - } - }, - "stats": { - "totalNbTasks": 1, - "status": { - "succeeded": 1 - }, - "types": { - "settingsUpdate": 1 - }, - "indexUids": { - "kefir": 1 - } - }, - "duration": "PT0.007440092S", - "startedAt": "2025-01-16T16:58:41.2155771Z", - "finishedAt": "2025-01-16T16:58:41.223017192Z", - "batchStrategy": "unspecified" - }, - { - "uid": 6, - "progress": null, - "details": { - "synonyms": { - "boubou": [ - "kefir" - ] - } - }, - "stats": { - "totalNbTasks": 1, - "status": { - "succeeded": 1 - }, - "types": { - "settingsUpdate": 1 - }, - "indexUids": { - "kefir": 1 - } - }, - "duration": "PT0.007565161S", - "startedAt": "2025-01-16T16:54:51.940332781Z", - "finishedAt": "2025-01-16T16:54:51.947897942Z", - "batchStrategy": "unspecified" - }, - { - "uid": 5, - "progress": null, - "details": { - "stopWords": [ - "le", - "un" - ] - }, - "stats": { - "totalNbTasks": 1, - "status": { - "succeeded": 1 - }, - "types": { - "settingsUpdate": 1 - }, - "indexUids": { - "kefir": 1 - } - }, - "duration": "PT0.016307263S", - "startedAt": "2025-01-16T16:53:19.913351957Z", - "finishedAt": "2025-01-16T16:53:19.92965922Z", - "batchStrategy": "unspecified" } ], - "total": 23, + "total": 29, "limit": 20, - "from": 24, - "next": 4 + "from": 30, + "next": 10 } diff --git a/crates/meilisearch/tests/upgrade/v1_12/snapshots/v1_12_0.rs/check_the_index_scheduler/batches_filter_afterStartedAt_equal_2025-01-16T16_47_41.snap b/crates/meilisearch/tests/upgrade/v1_12/snapshots/v1_12_0.rs/check_the_index_scheduler/batches_filter_afterStartedAt_equal_2025-01-16T16_47_41.snap index f4edae51b..b56cc5ca3 100644 --- a/crates/meilisearch/tests/upgrade/v1_12/snapshots/v1_12_0.rs/check_the_index_scheduler/batches_filter_afterStartedAt_equal_2025-01-16T16_47_41.snap +++ b/crates/meilisearch/tests/upgrade/v1_12/snapshots/v1_12_0.rs/check_the_index_scheduler/batches_filter_afterStartedAt_equal_2025-01-16T16_47_41.snap @@ -4,7 +4,7 @@ source: crates/meilisearch/tests/upgrade/v1_12/v1_12_0.rs { "results": [ { - "uid": 24, + "uid": 30, "progress": null, "details": { "upgradeFrom": "v1.12.0", @@ -26,6 +26,155 @@ source: crates/meilisearch/tests/upgrade/v1_12/v1_12_0.rs "finishedAt": "[date]", "batchStrategy": "stopped after the last task of type `upgradeDatabase` because they cannot be batched with tasks of any other type." }, + { + "uid": 29, + "progress": null, + "details": { + "receivedDocuments": 1, + "indexedDocuments": 1 + }, + "stats": { + "totalNbTasks": 1, + "status": { + "succeeded": 1 + }, + "types": { + "documentAdditionOrUpdate": 1 + }, + "indexUids": { + "kefir": 1 + } + }, + "duration": "PT0.067201S", + "startedAt": "2025-07-07T13:43:08.772854Z", + "finishedAt": "2025-07-07T13:43:08.840055Z", + "batchStrategy": "unspecified" + }, + { + "uid": 28, + "progress": null, + "details": { + "deletedDocuments": 1 + }, + "stats": { + "totalNbTasks": 1, + "status": { + "succeeded": 1 + }, + "types": { + "indexDeletion": 1 + }, + "indexUids": { + "mieli": 1 + } + }, + "duration": "PT0.012727S", + "startedAt": "2025-07-07T13:42:50.745461Z", + "finishedAt": "2025-07-07T13:42:50.758188Z", + "batchStrategy": "unspecified" + }, + { + "uid": 27, + "progress": null, + "details": { + "receivedDocuments": 1, + "indexedDocuments": 0 + }, + "stats": { + "totalNbTasks": 1, + "status": { + "failed": 1 + }, + "types": { + "documentAdditionOrUpdate": 1 + }, + "indexUids": { + "kefir": 1 + } + }, + "duration": "PT0.059920S", + "startedAt": "2025-07-07T13:42:15.625413Z", + "finishedAt": "2025-07-07T13:42:15.685333Z", + "batchStrategy": "unspecified" + }, + { + "uid": 26, + "progress": null, + "details": { + "receivedDocuments": 1, + "indexedDocuments": 1 + }, + "stats": { + "totalNbTasks": 1, + "status": { + "succeeded": 1 + }, + "types": { + "documentAdditionOrUpdate": 1 + }, + "indexUids": { + "mieli": 1 + } + }, + "duration": "PT0.088879S", + "startedAt": "2025-07-07T13:40:01.461741Z", + "finishedAt": "2025-07-07T13:40:01.55062Z", + "batchStrategy": "unspecified" + }, + { + "uid": 25, + "progress": null, + "details": { + "receivedDocuments": 1, + "indexedDocuments": 1 + }, + "stats": { + "totalNbTasks": 1, + "status": { + "succeeded": 1 + }, + "types": { + "documentAdditionOrUpdate": 1 + }, + "indexUids": { + "kefir": 1 + } + }, + "duration": "PT0.312911S", + "startedAt": "2025-07-07T13:32:46.139785Z", + "finishedAt": "2025-07-07T13:32:46.452696Z", + "batchStrategy": "unspecified" + }, + { + "uid": 24, + "progress": null, + "details": { + "embedders": { + "doggo_embedder": { + "source": "huggingFace", + "model": "sentence-transformers/all-MiniLM-L6-v2", + "revision": "e4ce9877abf3edfe10b0d82785e83bdcb973e22e", + "documentTemplate": "{{doc.description}}" + } + } + }, + "stats": { + "totalNbTasks": 1, + "status": { + "succeeded": 1 + }, + "types": { + "settingsUpdate": 1 + }, + "indexUids": { + "kefir": 1 + } + }, + "duration": "PT0.247378S", + "startedAt": "2025-07-07T13:28:27.391344Z", + "finishedAt": "2025-07-07T13:28:27.638722Z", + "batchStrategy": "unspecified" + }, { "uid": 23, "progress": null, @@ -348,179 +497,10 @@ source: crates/meilisearch/tests/upgrade/v1_12/v1_12_0.rs "startedAt": "2025-01-16T17:01:14.112756687Z", "finishedAt": "2025-01-16T17:01:14.120064527Z", "batchStrategy": "unspecified" - }, - { - "uid": 10, - "progress": null, - "details": { - "faceting": { - "maxValuesPerFacet": 99 - }, - "pagination": { - "maxTotalHits": 15 - } - }, - "stats": { - "totalNbTasks": 1, - "status": { - "succeeded": 1 - }, - "types": { - "settingsUpdate": 1 - }, - "indexUids": { - "kefir": 1 - } - }, - "duration": "PT0.007391353S", - "startedAt": "2025-01-16T17:00:29.201180268Z", - "finishedAt": "2025-01-16T17:00:29.208571621Z", - "batchStrategy": "unspecified" - }, - { - "uid": 9, - "progress": null, - "details": { - "faceting": { - "maxValuesPerFacet": 100 - }, - "pagination": { - "maxTotalHits": 1000 - } - }, - "stats": { - "totalNbTasks": 1, - "status": { - "succeeded": 1 - }, - "types": { - "settingsUpdate": 1 - }, - "indexUids": { - "kefir": 1 - } - }, - "duration": "PT0.007445825S", - "startedAt": "2025-01-16T17:00:15.77629445Z", - "finishedAt": "2025-01-16T17:00:15.783740275Z", - "batchStrategy": "unspecified" - }, - { - "uid": 8, - "progress": null, - "details": { - "typoTolerance": { - "minWordSizeForTypos": { - "oneTypo": 4 - }, - "disableOnWords": [ - "kefir" - ], - "disableOnAttributes": [ - "surname" - ] - } - }, - "stats": { - "totalNbTasks": 1, - "status": { - "succeeded": 1 - }, - "types": { - "settingsUpdate": 1 - }, - "indexUids": { - "kefir": 1 - } - }, - "duration": "PT0.012020083S", - "startedAt": "2025-01-16T16:59:42.744086671Z", - "finishedAt": "2025-01-16T16:59:42.756106754Z", - "batchStrategy": "unspecified" - }, - { - "uid": 7, - "progress": null, - "details": { - "typoTolerance": { - "minWordSizeForTypos": { - "oneTypo": 4 - } - } - }, - "stats": { - "totalNbTasks": 1, - "status": { - "succeeded": 1 - }, - "types": { - "settingsUpdate": 1 - }, - "indexUids": { - "kefir": 1 - } - }, - "duration": "PT0.007440092S", - "startedAt": "2025-01-16T16:58:41.2155771Z", - "finishedAt": "2025-01-16T16:58:41.223017192Z", - "batchStrategy": "unspecified" - }, - { - "uid": 6, - "progress": null, - "details": { - "synonyms": { - "boubou": [ - "kefir" - ] - } - }, - "stats": { - "totalNbTasks": 1, - "status": { - "succeeded": 1 - }, - "types": { - "settingsUpdate": 1 - }, - "indexUids": { - "kefir": 1 - } - }, - "duration": "PT0.007565161S", - "startedAt": "2025-01-16T16:54:51.940332781Z", - "finishedAt": "2025-01-16T16:54:51.947897942Z", - "batchStrategy": "unspecified" - }, - { - "uid": 5, - "progress": null, - "details": { - "stopWords": [ - "le", - "un" - ] - }, - "stats": { - "totalNbTasks": 1, - "status": { - "succeeded": 1 - }, - "types": { - "settingsUpdate": 1 - }, - "indexUids": { - "kefir": 1 - } - }, - "duration": "PT0.016307263S", - "startedAt": "2025-01-16T16:53:19.913351957Z", - "finishedAt": "2025-01-16T16:53:19.92965922Z", - "batchStrategy": "unspecified" } ], - "total": 23, + "total": 29, "limit": 20, - "from": 24, - "next": 4 + "from": 30, + "next": 10 } diff --git a/crates/meilisearch/tests/upgrade/v1_12/snapshots/v1_12_0.rs/check_the_index_scheduler/tasks_filter_afterEnqueuedAt_equal_2025-01-16T16_47_41.snap b/crates/meilisearch/tests/upgrade/v1_12/snapshots/v1_12_0.rs/check_the_index_scheduler/tasks_filter_afterEnqueuedAt_equal_2025-01-16T16_47_41.snap index 01d2ea341..a52072f56 100644 --- a/crates/meilisearch/tests/upgrade/v1_12/snapshots/v1_12_0.rs/check_the_index_scheduler/tasks_filter_afterEnqueuedAt_equal_2025-01-16T16_47_41.snap +++ b/crates/meilisearch/tests/upgrade/v1_12/snapshots/v1_12_0.rs/check_the_index_scheduler/tasks_filter_afterEnqueuedAt_equal_2025-01-16T16_47_41.snap @@ -4,8 +4,8 @@ source: crates/meilisearch/tests/upgrade/v1_12/v1_12_0.rs { "results": [ { - "uid": 25, - "batchUid": 24, + "uid": 31, + "batchUid": 30, "indexUid": null, "status": "succeeded", "type": "upgradeDatabase", @@ -20,6 +20,118 @@ source: crates/meilisearch/tests/upgrade/v1_12/v1_12_0.rs "startedAt": "[date]", "finishedAt": "[date]" }, + { + "uid": 30, + "batchUid": 29, + "indexUid": "kefir", + "status": "succeeded", + "type": "documentAdditionOrUpdate", + "canceledBy": null, + "details": { + "receivedDocuments": 1, + "indexedDocuments": 1 + }, + "error": null, + "duration": "PT0.067201S", + "enqueuedAt": "2025-07-07T13:43:08.772432Z", + "startedAt": "2025-07-07T13:43:08.772854Z", + "finishedAt": "2025-07-07T13:43:08.840055Z" + }, + { + "uid": 29, + "batchUid": 28, + "indexUid": "mieli", + "status": "succeeded", + "type": "indexDeletion", + "canceledBy": null, + "details": { + "deletedDocuments": 1 + }, + "error": null, + "duration": "PT0.012727S", + "enqueuedAt": "2025-07-07T13:42:50.744793Z", + "startedAt": "2025-07-07T13:42:50.745461Z", + "finishedAt": "2025-07-07T13:42:50.758188Z" + }, + { + "uid": 28, + "batchUid": 27, + "indexUid": "kefir", + "status": "failed", + "type": "documentAdditionOrUpdate", + "canceledBy": null, + "details": { + "receivedDocuments": 1, + "indexedDocuments": 0 + }, + "error": { + "message": "Index `kefir`: Bad embedder configuration in the document with id: `2`. Could not parse `._vectors.doggo_embedder`: trailing characters at line 1 column 13", + "code": "invalid_vectors_type", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_vectors_type" + }, + "duration": "PT0.059920S", + "enqueuedAt": "2025-07-07T13:42:15.624598Z", + "startedAt": "2025-07-07T13:42:15.625413Z", + "finishedAt": "2025-07-07T13:42:15.685333Z" + }, + { + "uid": 27, + "batchUid": 26, + "indexUid": "mieli", + "status": "succeeded", + "type": "documentAdditionOrUpdate", + "canceledBy": null, + "details": { + "receivedDocuments": 1, + "indexedDocuments": 1 + }, + "error": null, + "duration": "PT0.088879S", + "enqueuedAt": "2025-07-07T13:40:01.46081Z", + "startedAt": "2025-07-07T13:40:01.461741Z", + "finishedAt": "2025-07-07T13:40:01.55062Z" + }, + { + "uid": 26, + "batchUid": 25, + "indexUid": "kefir", + "status": "succeeded", + "type": "documentAdditionOrUpdate", + "canceledBy": null, + "details": { + "receivedDocuments": 1, + "indexedDocuments": 1 + }, + "error": null, + "duration": "PT0.312911S", + "enqueuedAt": "2025-07-07T13:32:46.13871Z", + "startedAt": "2025-07-07T13:32:46.139785Z", + "finishedAt": "2025-07-07T13:32:46.452696Z" + }, + { + "uid": 25, + "batchUid": 24, + "indexUid": "kefir", + "status": "succeeded", + "type": "settingsUpdate", + "canceledBy": null, + "details": { + "embedders": { + "doggo_embedder": { + "source": "huggingFace", + "model": "sentence-transformers/all-MiniLM-L6-v2", + "revision": "e4ce9877abf3edfe10b0d82785e83bdcb973e22e", + "documentTemplate": "{{doc.description}}" + } + } + }, + "error": null, + "duration": "PT0.247378S", + "enqueuedAt": "2025-07-07T13:28:27.390054Z", + "startedAt": "2025-07-07T13:28:27.391344Z", + "finishedAt": "2025-07-07T13:28:27.638722Z" + }, { "uid": 24, "batchUid": 23, @@ -264,134 +376,10 @@ source: crates/meilisearch/tests/upgrade/v1_12/v1_12_0.rs "enqueuedAt": "2025-01-16T17:02:52.527382964Z", "startedAt": "2025-01-16T17:02:52.539749853Z", "finishedAt": "2025-01-16T17:02:52.547390016Z" - }, - { - "uid": 11, - "batchUid": 11, - "indexUid": "kefir", - "status": "succeeded", - "type": "settingsUpdate", - "canceledBy": null, - "details": { - "searchCutoffMs": 8000 - }, - "error": null, - "duration": "PT0.007307840S", - "enqueuedAt": "2025-01-16T17:01:14.100316617Z", - "startedAt": "2025-01-16T17:01:14.112756687Z", - "finishedAt": "2025-01-16T17:01:14.120064527Z" - }, - { - "uid": 10, - "batchUid": 10, - "indexUid": "kefir", - "status": "succeeded", - "type": "settingsUpdate", - "canceledBy": null, - "details": { - "faceting": { - "maxValuesPerFacet": 99 - }, - "pagination": { - "maxTotalHits": 15 - } - }, - "error": null, - "duration": "PT0.007391353S", - "enqueuedAt": "2025-01-16T17:00:29.188815062Z", - "startedAt": "2025-01-16T17:00:29.201180268Z", - "finishedAt": "2025-01-16T17:00:29.208571621Z" - }, - { - "uid": 9, - "batchUid": 9, - "indexUid": "kefir", - "status": "succeeded", - "type": "settingsUpdate", - "canceledBy": null, - "details": { - "faceting": { - "maxValuesPerFacet": 100 - }, - "pagination": { - "maxTotalHits": 1000 - } - }, - "error": null, - "duration": "PT0.007445825S", - "enqueuedAt": "2025-01-16T17:00:15.759501709Z", - "startedAt": "2025-01-16T17:00:15.77629445Z", - "finishedAt": "2025-01-16T17:00:15.783740275Z" - }, - { - "uid": 8, - "batchUid": 8, - "indexUid": "kefir", - "status": "succeeded", - "type": "settingsUpdate", - "canceledBy": null, - "details": { - "typoTolerance": { - "minWordSizeForTypos": { - "oneTypo": 4 - }, - "disableOnWords": [ - "kefir" - ], - "disableOnAttributes": [ - "surname" - ] - } - }, - "error": null, - "duration": "PT0.012020083S", - "enqueuedAt": "2025-01-16T16:59:42.727292501Z", - "startedAt": "2025-01-16T16:59:42.744086671Z", - "finishedAt": "2025-01-16T16:59:42.756106754Z" - }, - { - "uid": 7, - "batchUid": 7, - "indexUid": "kefir", - "status": "succeeded", - "type": "settingsUpdate", - "canceledBy": null, - "details": { - "typoTolerance": { - "minWordSizeForTypos": { - "oneTypo": 4 - } - } - }, - "error": null, - "duration": "PT0.007440092S", - "enqueuedAt": "2025-01-16T16:58:41.203145044Z", - "startedAt": "2025-01-16T16:58:41.2155771Z", - "finishedAt": "2025-01-16T16:58:41.223017192Z" - }, - { - "uid": 6, - "batchUid": 6, - "indexUid": "kefir", - "status": "succeeded", - "type": "settingsUpdate", - "canceledBy": null, - "details": { - "synonyms": { - "boubou": [ - "kefir" - ] - } - }, - "error": null, - "duration": "PT0.007565161S", - "enqueuedAt": "2025-01-16T16:54:51.927866243Z", - "startedAt": "2025-01-16T16:54:51.940332781Z", - "finishedAt": "2025-01-16T16:54:51.947897942Z" } ], - "total": 24, + "total": 30, "limit": 20, - "from": 25, - "next": 5 + "from": 31, + "next": 11 } diff --git a/crates/meilisearch/tests/upgrade/v1_12/snapshots/v1_12_0.rs/check_the_index_scheduler/tasks_filter_afterFinishedAt_equal_2025-01-16T16_47_41.snap b/crates/meilisearch/tests/upgrade/v1_12/snapshots/v1_12_0.rs/check_the_index_scheduler/tasks_filter_afterFinishedAt_equal_2025-01-16T16_47_41.snap index 01d2ea341..a52072f56 100644 --- a/crates/meilisearch/tests/upgrade/v1_12/snapshots/v1_12_0.rs/check_the_index_scheduler/tasks_filter_afterFinishedAt_equal_2025-01-16T16_47_41.snap +++ b/crates/meilisearch/tests/upgrade/v1_12/snapshots/v1_12_0.rs/check_the_index_scheduler/tasks_filter_afterFinishedAt_equal_2025-01-16T16_47_41.snap @@ -4,8 +4,8 @@ source: crates/meilisearch/tests/upgrade/v1_12/v1_12_0.rs { "results": [ { - "uid": 25, - "batchUid": 24, + "uid": 31, + "batchUid": 30, "indexUid": null, "status": "succeeded", "type": "upgradeDatabase", @@ -20,6 +20,118 @@ source: crates/meilisearch/tests/upgrade/v1_12/v1_12_0.rs "startedAt": "[date]", "finishedAt": "[date]" }, + { + "uid": 30, + "batchUid": 29, + "indexUid": "kefir", + "status": "succeeded", + "type": "documentAdditionOrUpdate", + "canceledBy": null, + "details": { + "receivedDocuments": 1, + "indexedDocuments": 1 + }, + "error": null, + "duration": "PT0.067201S", + "enqueuedAt": "2025-07-07T13:43:08.772432Z", + "startedAt": "2025-07-07T13:43:08.772854Z", + "finishedAt": "2025-07-07T13:43:08.840055Z" + }, + { + "uid": 29, + "batchUid": 28, + "indexUid": "mieli", + "status": "succeeded", + "type": "indexDeletion", + "canceledBy": null, + "details": { + "deletedDocuments": 1 + }, + "error": null, + "duration": "PT0.012727S", + "enqueuedAt": "2025-07-07T13:42:50.744793Z", + "startedAt": "2025-07-07T13:42:50.745461Z", + "finishedAt": "2025-07-07T13:42:50.758188Z" + }, + { + "uid": 28, + "batchUid": 27, + "indexUid": "kefir", + "status": "failed", + "type": "documentAdditionOrUpdate", + "canceledBy": null, + "details": { + "receivedDocuments": 1, + "indexedDocuments": 0 + }, + "error": { + "message": "Index `kefir`: Bad embedder configuration in the document with id: `2`. Could not parse `._vectors.doggo_embedder`: trailing characters at line 1 column 13", + "code": "invalid_vectors_type", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_vectors_type" + }, + "duration": "PT0.059920S", + "enqueuedAt": "2025-07-07T13:42:15.624598Z", + "startedAt": "2025-07-07T13:42:15.625413Z", + "finishedAt": "2025-07-07T13:42:15.685333Z" + }, + { + "uid": 27, + "batchUid": 26, + "indexUid": "mieli", + "status": "succeeded", + "type": "documentAdditionOrUpdate", + "canceledBy": null, + "details": { + "receivedDocuments": 1, + "indexedDocuments": 1 + }, + "error": null, + "duration": "PT0.088879S", + "enqueuedAt": "2025-07-07T13:40:01.46081Z", + "startedAt": "2025-07-07T13:40:01.461741Z", + "finishedAt": "2025-07-07T13:40:01.55062Z" + }, + { + "uid": 26, + "batchUid": 25, + "indexUid": "kefir", + "status": "succeeded", + "type": "documentAdditionOrUpdate", + "canceledBy": null, + "details": { + "receivedDocuments": 1, + "indexedDocuments": 1 + }, + "error": null, + "duration": "PT0.312911S", + "enqueuedAt": "2025-07-07T13:32:46.13871Z", + "startedAt": "2025-07-07T13:32:46.139785Z", + "finishedAt": "2025-07-07T13:32:46.452696Z" + }, + { + "uid": 25, + "batchUid": 24, + "indexUid": "kefir", + "status": "succeeded", + "type": "settingsUpdate", + "canceledBy": null, + "details": { + "embedders": { + "doggo_embedder": { + "source": "huggingFace", + "model": "sentence-transformers/all-MiniLM-L6-v2", + "revision": "e4ce9877abf3edfe10b0d82785e83bdcb973e22e", + "documentTemplate": "{{doc.description}}" + } + } + }, + "error": null, + "duration": "PT0.247378S", + "enqueuedAt": "2025-07-07T13:28:27.390054Z", + "startedAt": "2025-07-07T13:28:27.391344Z", + "finishedAt": "2025-07-07T13:28:27.638722Z" + }, { "uid": 24, "batchUid": 23, @@ -264,134 +376,10 @@ source: crates/meilisearch/tests/upgrade/v1_12/v1_12_0.rs "enqueuedAt": "2025-01-16T17:02:52.527382964Z", "startedAt": "2025-01-16T17:02:52.539749853Z", "finishedAt": "2025-01-16T17:02:52.547390016Z" - }, - { - "uid": 11, - "batchUid": 11, - "indexUid": "kefir", - "status": "succeeded", - "type": "settingsUpdate", - "canceledBy": null, - "details": { - "searchCutoffMs": 8000 - }, - "error": null, - "duration": "PT0.007307840S", - "enqueuedAt": "2025-01-16T17:01:14.100316617Z", - "startedAt": "2025-01-16T17:01:14.112756687Z", - "finishedAt": "2025-01-16T17:01:14.120064527Z" - }, - { - "uid": 10, - "batchUid": 10, - "indexUid": "kefir", - "status": "succeeded", - "type": "settingsUpdate", - "canceledBy": null, - "details": { - "faceting": { - "maxValuesPerFacet": 99 - }, - "pagination": { - "maxTotalHits": 15 - } - }, - "error": null, - "duration": "PT0.007391353S", - "enqueuedAt": "2025-01-16T17:00:29.188815062Z", - "startedAt": "2025-01-16T17:00:29.201180268Z", - "finishedAt": "2025-01-16T17:00:29.208571621Z" - }, - { - "uid": 9, - "batchUid": 9, - "indexUid": "kefir", - "status": "succeeded", - "type": "settingsUpdate", - "canceledBy": null, - "details": { - "faceting": { - "maxValuesPerFacet": 100 - }, - "pagination": { - "maxTotalHits": 1000 - } - }, - "error": null, - "duration": "PT0.007445825S", - "enqueuedAt": "2025-01-16T17:00:15.759501709Z", - "startedAt": "2025-01-16T17:00:15.77629445Z", - "finishedAt": "2025-01-16T17:00:15.783740275Z" - }, - { - "uid": 8, - "batchUid": 8, - "indexUid": "kefir", - "status": "succeeded", - "type": "settingsUpdate", - "canceledBy": null, - "details": { - "typoTolerance": { - "minWordSizeForTypos": { - "oneTypo": 4 - }, - "disableOnWords": [ - "kefir" - ], - "disableOnAttributes": [ - "surname" - ] - } - }, - "error": null, - "duration": "PT0.012020083S", - "enqueuedAt": "2025-01-16T16:59:42.727292501Z", - "startedAt": "2025-01-16T16:59:42.744086671Z", - "finishedAt": "2025-01-16T16:59:42.756106754Z" - }, - { - "uid": 7, - "batchUid": 7, - "indexUid": "kefir", - "status": "succeeded", - "type": "settingsUpdate", - "canceledBy": null, - "details": { - "typoTolerance": { - "minWordSizeForTypos": { - "oneTypo": 4 - } - } - }, - "error": null, - "duration": "PT0.007440092S", - "enqueuedAt": "2025-01-16T16:58:41.203145044Z", - "startedAt": "2025-01-16T16:58:41.2155771Z", - "finishedAt": "2025-01-16T16:58:41.223017192Z" - }, - { - "uid": 6, - "batchUid": 6, - "indexUid": "kefir", - "status": "succeeded", - "type": "settingsUpdate", - "canceledBy": null, - "details": { - "synonyms": { - "boubou": [ - "kefir" - ] - } - }, - "error": null, - "duration": "PT0.007565161S", - "enqueuedAt": "2025-01-16T16:54:51.927866243Z", - "startedAt": "2025-01-16T16:54:51.940332781Z", - "finishedAt": "2025-01-16T16:54:51.947897942Z" } ], - "total": 24, + "total": 30, "limit": 20, - "from": 25, - "next": 5 + "from": 31, + "next": 11 } diff --git a/crates/meilisearch/tests/upgrade/v1_12/snapshots/v1_12_0.rs/check_the_index_scheduler/tasks_filter_afterStartedAt_equal_2025-01-16T16_47_41.snap b/crates/meilisearch/tests/upgrade/v1_12/snapshots/v1_12_0.rs/check_the_index_scheduler/tasks_filter_afterStartedAt_equal_2025-01-16T16_47_41.snap index 01d2ea341..a52072f56 100644 --- a/crates/meilisearch/tests/upgrade/v1_12/snapshots/v1_12_0.rs/check_the_index_scheduler/tasks_filter_afterStartedAt_equal_2025-01-16T16_47_41.snap +++ b/crates/meilisearch/tests/upgrade/v1_12/snapshots/v1_12_0.rs/check_the_index_scheduler/tasks_filter_afterStartedAt_equal_2025-01-16T16_47_41.snap @@ -4,8 +4,8 @@ source: crates/meilisearch/tests/upgrade/v1_12/v1_12_0.rs { "results": [ { - "uid": 25, - "batchUid": 24, + "uid": 31, + "batchUid": 30, "indexUid": null, "status": "succeeded", "type": "upgradeDatabase", @@ -20,6 +20,118 @@ source: crates/meilisearch/tests/upgrade/v1_12/v1_12_0.rs "startedAt": "[date]", "finishedAt": "[date]" }, + { + "uid": 30, + "batchUid": 29, + "indexUid": "kefir", + "status": "succeeded", + "type": "documentAdditionOrUpdate", + "canceledBy": null, + "details": { + "receivedDocuments": 1, + "indexedDocuments": 1 + }, + "error": null, + "duration": "PT0.067201S", + "enqueuedAt": "2025-07-07T13:43:08.772432Z", + "startedAt": "2025-07-07T13:43:08.772854Z", + "finishedAt": "2025-07-07T13:43:08.840055Z" + }, + { + "uid": 29, + "batchUid": 28, + "indexUid": "mieli", + "status": "succeeded", + "type": "indexDeletion", + "canceledBy": null, + "details": { + "deletedDocuments": 1 + }, + "error": null, + "duration": "PT0.012727S", + "enqueuedAt": "2025-07-07T13:42:50.744793Z", + "startedAt": "2025-07-07T13:42:50.745461Z", + "finishedAt": "2025-07-07T13:42:50.758188Z" + }, + { + "uid": 28, + "batchUid": 27, + "indexUid": "kefir", + "status": "failed", + "type": "documentAdditionOrUpdate", + "canceledBy": null, + "details": { + "receivedDocuments": 1, + "indexedDocuments": 0 + }, + "error": { + "message": "Index `kefir`: Bad embedder configuration in the document with id: `2`. Could not parse `._vectors.doggo_embedder`: trailing characters at line 1 column 13", + "code": "invalid_vectors_type", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_vectors_type" + }, + "duration": "PT0.059920S", + "enqueuedAt": "2025-07-07T13:42:15.624598Z", + "startedAt": "2025-07-07T13:42:15.625413Z", + "finishedAt": "2025-07-07T13:42:15.685333Z" + }, + { + "uid": 27, + "batchUid": 26, + "indexUid": "mieli", + "status": "succeeded", + "type": "documentAdditionOrUpdate", + "canceledBy": null, + "details": { + "receivedDocuments": 1, + "indexedDocuments": 1 + }, + "error": null, + "duration": "PT0.088879S", + "enqueuedAt": "2025-07-07T13:40:01.46081Z", + "startedAt": "2025-07-07T13:40:01.461741Z", + "finishedAt": "2025-07-07T13:40:01.55062Z" + }, + { + "uid": 26, + "batchUid": 25, + "indexUid": "kefir", + "status": "succeeded", + "type": "documentAdditionOrUpdate", + "canceledBy": null, + "details": { + "receivedDocuments": 1, + "indexedDocuments": 1 + }, + "error": null, + "duration": "PT0.312911S", + "enqueuedAt": "2025-07-07T13:32:46.13871Z", + "startedAt": "2025-07-07T13:32:46.139785Z", + "finishedAt": "2025-07-07T13:32:46.452696Z" + }, + { + "uid": 25, + "batchUid": 24, + "indexUid": "kefir", + "status": "succeeded", + "type": "settingsUpdate", + "canceledBy": null, + "details": { + "embedders": { + "doggo_embedder": { + "source": "huggingFace", + "model": "sentence-transformers/all-MiniLM-L6-v2", + "revision": "e4ce9877abf3edfe10b0d82785e83bdcb973e22e", + "documentTemplate": "{{doc.description}}" + } + } + }, + "error": null, + "duration": "PT0.247378S", + "enqueuedAt": "2025-07-07T13:28:27.390054Z", + "startedAt": "2025-07-07T13:28:27.391344Z", + "finishedAt": "2025-07-07T13:28:27.638722Z" + }, { "uid": 24, "batchUid": 23, @@ -264,134 +376,10 @@ source: crates/meilisearch/tests/upgrade/v1_12/v1_12_0.rs "enqueuedAt": "2025-01-16T17:02:52.527382964Z", "startedAt": "2025-01-16T17:02:52.539749853Z", "finishedAt": "2025-01-16T17:02:52.547390016Z" - }, - { - "uid": 11, - "batchUid": 11, - "indexUid": "kefir", - "status": "succeeded", - "type": "settingsUpdate", - "canceledBy": null, - "details": { - "searchCutoffMs": 8000 - }, - "error": null, - "duration": "PT0.007307840S", - "enqueuedAt": "2025-01-16T17:01:14.100316617Z", - "startedAt": "2025-01-16T17:01:14.112756687Z", - "finishedAt": "2025-01-16T17:01:14.120064527Z" - }, - { - "uid": 10, - "batchUid": 10, - "indexUid": "kefir", - "status": "succeeded", - "type": "settingsUpdate", - "canceledBy": null, - "details": { - "faceting": { - "maxValuesPerFacet": 99 - }, - "pagination": { - "maxTotalHits": 15 - } - }, - "error": null, - "duration": "PT0.007391353S", - "enqueuedAt": "2025-01-16T17:00:29.188815062Z", - "startedAt": "2025-01-16T17:00:29.201180268Z", - "finishedAt": "2025-01-16T17:00:29.208571621Z" - }, - { - "uid": 9, - "batchUid": 9, - "indexUid": "kefir", - "status": "succeeded", - "type": "settingsUpdate", - "canceledBy": null, - "details": { - "faceting": { - "maxValuesPerFacet": 100 - }, - "pagination": { - "maxTotalHits": 1000 - } - }, - "error": null, - "duration": "PT0.007445825S", - "enqueuedAt": "2025-01-16T17:00:15.759501709Z", - "startedAt": "2025-01-16T17:00:15.77629445Z", - "finishedAt": "2025-01-16T17:00:15.783740275Z" - }, - { - "uid": 8, - "batchUid": 8, - "indexUid": "kefir", - "status": "succeeded", - "type": "settingsUpdate", - "canceledBy": null, - "details": { - "typoTolerance": { - "minWordSizeForTypos": { - "oneTypo": 4 - }, - "disableOnWords": [ - "kefir" - ], - "disableOnAttributes": [ - "surname" - ] - } - }, - "error": null, - "duration": "PT0.012020083S", - "enqueuedAt": "2025-01-16T16:59:42.727292501Z", - "startedAt": "2025-01-16T16:59:42.744086671Z", - "finishedAt": "2025-01-16T16:59:42.756106754Z" - }, - { - "uid": 7, - "batchUid": 7, - "indexUid": "kefir", - "status": "succeeded", - "type": "settingsUpdate", - "canceledBy": null, - "details": { - "typoTolerance": { - "minWordSizeForTypos": { - "oneTypo": 4 - } - } - }, - "error": null, - "duration": "PT0.007440092S", - "enqueuedAt": "2025-01-16T16:58:41.203145044Z", - "startedAt": "2025-01-16T16:58:41.2155771Z", - "finishedAt": "2025-01-16T16:58:41.223017192Z" - }, - { - "uid": 6, - "batchUid": 6, - "indexUid": "kefir", - "status": "succeeded", - "type": "settingsUpdate", - "canceledBy": null, - "details": { - "synonyms": { - "boubou": [ - "kefir" - ] - } - }, - "error": null, - "duration": "PT0.007565161S", - "enqueuedAt": "2025-01-16T16:54:51.927866243Z", - "startedAt": "2025-01-16T16:54:51.940332781Z", - "finishedAt": "2025-01-16T16:54:51.947897942Z" } ], - "total": 24, + "total": 30, "limit": 20, - "from": 25, - "next": 5 + "from": 31, + "next": 11 } diff --git a/crates/meilisearch/tests/upgrade/v1_12/snapshots/v1_12_0.rs/check_the_index_scheduler/the_whole_batch_queue_once_everything_has_been_processed.snap b/crates/meilisearch/tests/upgrade/v1_12/snapshots/v1_12_0.rs/check_the_index_scheduler/the_whole_batch_queue_once_everything_has_been_processed.snap index fb62b35da..81b50fb92 100644 --- a/crates/meilisearch/tests/upgrade/v1_12/snapshots/v1_12_0.rs/check_the_index_scheduler/the_whole_batch_queue_once_everything_has_been_processed.snap +++ b/crates/meilisearch/tests/upgrade/v1_12/snapshots/v1_12_0.rs/check_the_index_scheduler/the_whole_batch_queue_once_everything_has_been_processed.snap @@ -4,7 +4,7 @@ source: crates/meilisearch/tests/upgrade/v1_12/v1_12_0.rs { "results": [ { - "uid": 24, + "uid": 30, "progress": null, "details": { "upgradeFrom": "v1.12.0", @@ -26,6 +26,155 @@ source: crates/meilisearch/tests/upgrade/v1_12/v1_12_0.rs "finishedAt": "[date]", "batchStrategy": "stopped after the last task of type `upgradeDatabase` because they cannot be batched with tasks of any other type." }, + { + "uid": 29, + "progress": null, + "details": { + "receivedDocuments": 1, + "indexedDocuments": 1 + }, + "stats": { + "totalNbTasks": 1, + "status": { + "succeeded": 1 + }, + "types": { + "documentAdditionOrUpdate": 1 + }, + "indexUids": { + "kefir": 1 + } + }, + "duration": "PT0.067201S", + "startedAt": "2025-07-07T13:43:08.772854Z", + "finishedAt": "2025-07-07T13:43:08.840055Z", + "batchStrategy": "unspecified" + }, + { + "uid": 28, + "progress": null, + "details": { + "deletedDocuments": 1 + }, + "stats": { + "totalNbTasks": 1, + "status": { + "succeeded": 1 + }, + "types": { + "indexDeletion": 1 + }, + "indexUids": { + "mieli": 1 + } + }, + "duration": "PT0.012727S", + "startedAt": "2025-07-07T13:42:50.745461Z", + "finishedAt": "2025-07-07T13:42:50.758188Z", + "batchStrategy": "unspecified" + }, + { + "uid": 27, + "progress": null, + "details": { + "receivedDocuments": 1, + "indexedDocuments": 0 + }, + "stats": { + "totalNbTasks": 1, + "status": { + "failed": 1 + }, + "types": { + "documentAdditionOrUpdate": 1 + }, + "indexUids": { + "kefir": 1 + } + }, + "duration": "PT0.059920S", + "startedAt": "2025-07-07T13:42:15.625413Z", + "finishedAt": "2025-07-07T13:42:15.685333Z", + "batchStrategy": "unspecified" + }, + { + "uid": 26, + "progress": null, + "details": { + "receivedDocuments": 1, + "indexedDocuments": 1 + }, + "stats": { + "totalNbTasks": 1, + "status": { + "succeeded": 1 + }, + "types": { + "documentAdditionOrUpdate": 1 + }, + "indexUids": { + "mieli": 1 + } + }, + "duration": "PT0.088879S", + "startedAt": "2025-07-07T13:40:01.461741Z", + "finishedAt": "2025-07-07T13:40:01.55062Z", + "batchStrategy": "unspecified" + }, + { + "uid": 25, + "progress": null, + "details": { + "receivedDocuments": 1, + "indexedDocuments": 1 + }, + "stats": { + "totalNbTasks": 1, + "status": { + "succeeded": 1 + }, + "types": { + "documentAdditionOrUpdate": 1 + }, + "indexUids": { + "kefir": 1 + } + }, + "duration": "PT0.312911S", + "startedAt": "2025-07-07T13:32:46.139785Z", + "finishedAt": "2025-07-07T13:32:46.452696Z", + "batchStrategy": "unspecified" + }, + { + "uid": 24, + "progress": null, + "details": { + "embedders": { + "doggo_embedder": { + "source": "huggingFace", + "model": "sentence-transformers/all-MiniLM-L6-v2", + "revision": "e4ce9877abf3edfe10b0d82785e83bdcb973e22e", + "documentTemplate": "{{doc.description}}" + } + } + }, + "stats": { + "totalNbTasks": 1, + "status": { + "succeeded": 1 + }, + "types": { + "settingsUpdate": 1 + }, + "indexUids": { + "kefir": 1 + } + }, + "duration": "PT0.247378S", + "startedAt": "2025-07-07T13:28:27.391344Z", + "finishedAt": "2025-07-07T13:28:27.638722Z", + "batchStrategy": "unspecified" + }, { "uid": 23, "progress": null, @@ -642,8 +791,8 @@ source: crates/meilisearch/tests/upgrade/v1_12/v1_12_0.rs "batchStrategy": "unspecified" } ], - "total": 25, + "total": 31, "limit": 1000, - "from": 24, + "from": 30, "next": null } diff --git a/crates/meilisearch/tests/upgrade/v1_12/snapshots/v1_12_0.rs/check_the_index_scheduler/the_whole_task_queue_once_everything_has_been_processed.snap b/crates/meilisearch/tests/upgrade/v1_12/snapshots/v1_12_0.rs/check_the_index_scheduler/the_whole_task_queue_once_everything_has_been_processed.snap index abb4dcdd9..1ec334fed 100644 --- a/crates/meilisearch/tests/upgrade/v1_12/snapshots/v1_12_0.rs/check_the_index_scheduler/the_whole_task_queue_once_everything_has_been_processed.snap +++ b/crates/meilisearch/tests/upgrade/v1_12/snapshots/v1_12_0.rs/check_the_index_scheduler/the_whole_task_queue_once_everything_has_been_processed.snap @@ -4,8 +4,8 @@ source: crates/meilisearch/tests/upgrade/v1_12/v1_12_0.rs { "results": [ { - "uid": 25, - "batchUid": 24, + "uid": 31, + "batchUid": 30, "indexUid": null, "status": "succeeded", "type": "upgradeDatabase", @@ -20,6 +20,118 @@ source: crates/meilisearch/tests/upgrade/v1_12/v1_12_0.rs "startedAt": "[date]", "finishedAt": "[date]" }, + { + "uid": 30, + "batchUid": 29, + "indexUid": "kefir", + "status": "succeeded", + "type": "documentAdditionOrUpdate", + "canceledBy": null, + "details": { + "receivedDocuments": 1, + "indexedDocuments": 1 + }, + "error": null, + "duration": "PT0.067201S", + "enqueuedAt": "2025-07-07T13:43:08.772432Z", + "startedAt": "2025-07-07T13:43:08.772854Z", + "finishedAt": "2025-07-07T13:43:08.840055Z" + }, + { + "uid": 29, + "batchUid": 28, + "indexUid": "mieli", + "status": "succeeded", + "type": "indexDeletion", + "canceledBy": null, + "details": { + "deletedDocuments": 1 + }, + "error": null, + "duration": "PT0.012727S", + "enqueuedAt": "2025-07-07T13:42:50.744793Z", + "startedAt": "2025-07-07T13:42:50.745461Z", + "finishedAt": "2025-07-07T13:42:50.758188Z" + }, + { + "uid": 28, + "batchUid": 27, + "indexUid": "kefir", + "status": "failed", + "type": "documentAdditionOrUpdate", + "canceledBy": null, + "details": { + "receivedDocuments": 1, + "indexedDocuments": 0 + }, + "error": { + "message": "Index `kefir`: Bad embedder configuration in the document with id: `2`. Could not parse `._vectors.doggo_embedder`: trailing characters at line 1 column 13", + "code": "invalid_vectors_type", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_vectors_type" + }, + "duration": "PT0.059920S", + "enqueuedAt": "2025-07-07T13:42:15.624598Z", + "startedAt": "2025-07-07T13:42:15.625413Z", + "finishedAt": "2025-07-07T13:42:15.685333Z" + }, + { + "uid": 27, + "batchUid": 26, + "indexUid": "mieli", + "status": "succeeded", + "type": "documentAdditionOrUpdate", + "canceledBy": null, + "details": { + "receivedDocuments": 1, + "indexedDocuments": 1 + }, + "error": null, + "duration": "PT0.088879S", + "enqueuedAt": "2025-07-07T13:40:01.46081Z", + "startedAt": "2025-07-07T13:40:01.461741Z", + "finishedAt": "2025-07-07T13:40:01.55062Z" + }, + { + "uid": 26, + "batchUid": 25, + "indexUid": "kefir", + "status": "succeeded", + "type": "documentAdditionOrUpdate", + "canceledBy": null, + "details": { + "receivedDocuments": 1, + "indexedDocuments": 1 + }, + "error": null, + "duration": "PT0.312911S", + "enqueuedAt": "2025-07-07T13:32:46.13871Z", + "startedAt": "2025-07-07T13:32:46.139785Z", + "finishedAt": "2025-07-07T13:32:46.452696Z" + }, + { + "uid": 25, + "batchUid": 24, + "indexUid": "kefir", + "status": "succeeded", + "type": "settingsUpdate", + "canceledBy": null, + "details": { + "embedders": { + "doggo_embedder": { + "source": "huggingFace", + "model": "sentence-transformers/all-MiniLM-L6-v2", + "revision": "e4ce9877abf3edfe10b0d82785e83bdcb973e22e", + "documentTemplate": "{{doc.description}}" + } + } + }, + "error": null, + "duration": "PT0.247378S", + "enqueuedAt": "2025-07-07T13:28:27.390054Z", + "startedAt": "2025-07-07T13:28:27.391344Z", + "finishedAt": "2025-07-07T13:28:27.638722Z" + }, { "uid": 24, "batchUid": 23, @@ -497,8 +609,8 @@ source: crates/meilisearch/tests/upgrade/v1_12/v1_12_0.rs "finishedAt": "2025-01-16T16:45:16.131303739Z" } ], - "total": 26, + "total": 32, "limit": 1000, - "from": 25, + "from": 31, "next": null } diff --git a/crates/meilisearch/tests/upgrade/v1_12/v1_12_0.rs b/crates/meilisearch/tests/upgrade/v1_12/v1_12_0.rs index 1b2ae054c..372e24792 100644 --- a/crates/meilisearch/tests/upgrade/v1_12/v1_12_0.rs +++ b/crates/meilisearch/tests/upgrade/v1_12/v1_12_0.rs @@ -114,13 +114,13 @@ async fn check_the_index_scheduler(server: &Server) { // All the indexes are still present let (indexes, _) = server.list_indexes(None, None).await; - snapshot!(indexes, @r#" + snapshot!(indexes, @r###" { "results": [ { "uid": "kefir", "createdAt": "2025-01-16T16:45:16.020663157Z", - "updatedAt": "2025-01-23T11:36:22.634859166Z", + "updatedAt": "2025-07-07T13:43:08.835381Z", "primaryKey": "id" } ], @@ -128,7 +128,7 @@ async fn check_the_index_scheduler(server: &Server) { "limit": 20, "total": 1 } - "#); + "###); // And their metadata are still right let (stats, _) = server.stats().await; assert_json_snapshot!(stats, { @@ -141,21 +141,21 @@ async fn check_the_index_scheduler(server: &Server) { { "databaseSize": "[bytes]", "usedDatabaseSize": "[bytes]", - "lastUpdate": "2025-01-23T11:36:22.634859166Z", + "lastUpdate": "2025-07-07T13:43:08.835381Z", "indexes": { "kefir": { - "numberOfDocuments": 1, + "numberOfDocuments": 2, "rawDocumentDbSize": "[bytes]", "avgDocumentSize": "[bytes]", "isIndexing": false, - "numberOfEmbeddings": 0, - "numberOfEmbeddedDocuments": 0, + "numberOfEmbeddings": 2, + "numberOfEmbeddedDocuments": 2, "fieldDistribution": { - "age": 1, - "description": 1, - "id": 1, - "name": 1, - "surname": 1 + "age": 2, + "description": 2, + "id": 2, + "name": 2, + "surname": 2 } } } @@ -227,21 +227,21 @@ async fn check_the_index_scheduler(server: &Server) { { "databaseSize": "[bytes]", "usedDatabaseSize": "[bytes]", - "lastUpdate": "2025-01-23T11:36:22.634859166Z", + "lastUpdate": "2025-07-07T13:43:08.835381Z", "indexes": { "kefir": { - "numberOfDocuments": 1, + "numberOfDocuments": 2, "rawDocumentDbSize": "[bytes]", "avgDocumentSize": "[bytes]", "isIndexing": false, - "numberOfEmbeddings": 0, - "numberOfEmbeddedDocuments": 0, + "numberOfEmbeddings": 2, + "numberOfEmbeddedDocuments": 2, "fieldDistribution": { - "age": 1, - "description": 1, - "id": 1, - "name": 1, - "surname": 1 + "age": 2, + "description": 2, + "id": 2, + "name": 2, + "surname": 2 } } } @@ -254,18 +254,18 @@ async fn check_the_index_scheduler(server: &Server) { ".avgDocumentSize" => "[bytes]", }), @r###" { - "numberOfDocuments": 1, + "numberOfDocuments": 2, "rawDocumentDbSize": "[bytes]", "avgDocumentSize": "[bytes]", "isIndexing": false, - "numberOfEmbeddings": 0, - "numberOfEmbeddedDocuments": 0, + "numberOfEmbeddings": 2, + "numberOfEmbeddedDocuments": 2, "fieldDistribution": { - "age": 1, - "description": 1, - "id": 1, - "name": 1, - "surname": 1 + "age": 2, + "description": 2, + "id": 2, + "name": 2, + "surname": 2 } } "###); From 5f8f48ec95b7c29ebdf70a1af3246a6fdaf1fc39 Mon Sep 17 00:00:00 2001 From: Louis Dureuil Date: Mon, 7 Jul 2025 16:43:05 +0200 Subject: [PATCH 53/81] Add new snapshot checking for regenerativeness --- .../search_with_retrieve_vectors.snap | 40 +++++++++++++++++++ .../tests/upgrade/v1_12/v1_12_0.rs | 4 ++ 2 files changed, 44 insertions(+) create mode 100644 crates/meilisearch/tests/upgrade/v1_12/snapshots/v1_12_0.rs/check_the_index_features/search_with_retrieve_vectors.snap diff --git a/crates/meilisearch/tests/upgrade/v1_12/snapshots/v1_12_0.rs/check_the_index_features/search_with_retrieve_vectors.snap b/crates/meilisearch/tests/upgrade/v1_12/snapshots/v1_12_0.rs/check_the_index_features/search_with_retrieve_vectors.snap new file mode 100644 index 000000000..5baf8155c --- /dev/null +++ b/crates/meilisearch/tests/upgrade/v1_12/snapshots/v1_12_0.rs/check_the_index_features/search_with_retrieve_vectors.snap @@ -0,0 +1,40 @@ +--- +source: crates/meilisearch/tests/upgrade/v1_12/v1_12_0.rs +--- +[ + { + "id": 1, + "name": "kefir", + "surname": [ + "kef", + "kefkef", + "kefirounet", + "boubou" + ], + "age": 1.4, + "description": "kefir est un petit chien blanc très mignon", + "_vectors": { + "doggo_embedder": { + "embeddings": "[vector]", + "regenerate": true + } + } + }, + { + "id": 2, + "name": "intel", + "surname": [ + "untel", + "tétel", + "iouiou" + ], + "age": 11.5, + "description": "intel est un grand beagle très mignon", + "_vectors": { + "doggo_embedder": { + "embeddings": "[vector]", + "regenerate": false + } + } + } +] diff --git a/crates/meilisearch/tests/upgrade/v1_12/v1_12_0.rs b/crates/meilisearch/tests/upgrade/v1_12/v1_12_0.rs index 372e24792..b98f27b2d 100644 --- a/crates/meilisearch/tests/upgrade/v1_12/v1_12_0.rs +++ b/crates/meilisearch/tests/upgrade/v1_12/v1_12_0.rs @@ -295,4 +295,8 @@ async fn check_the_index_features(server: &Server) { let (results, _status) = kefir.search_post(json!({ "sort": ["age:asc"], "filter": "surname = kefirounet" })).await; snapshot!(results, name: "search_with_sort_and_filter"); + + // ensuring we can get the vectors and their `regenerate` is still good. + let (results, _status) = kefir.search_post(json!({"retrieveVectors": true})).await; + snapshot!(json_string!(results["hits"], {"[]._vectors.doggo_embedder.embeddings" => "[vector]"}), name: "search_with_retrieve_vectors"); } From 4623691d1fd3e40f3f47f0633798f321d7dc4331 Mon Sep 17 00:00:00 2001 From: Louis Dureuil Date: Tue, 8 Jul 2025 10:02:25 +0200 Subject: [PATCH 54/81] Don't make the type-that-shall-not-be-written serializable Following tamo's advice Co-Authored-By: Tamo --- crates/milli/src/update/upgrade/v1_15.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/milli/src/update/upgrade/v1_15.rs b/crates/milli/src/update/upgrade/v1_15.rs index 9ca25d06b..3457e69ba 100644 --- a/crates/milli/src/update/upgrade/v1_15.rs +++ b/crates/milli/src/update/upgrade/v1_15.rs @@ -1,6 +1,6 @@ use heed::RwTxn; use roaring::RoaringBitmap; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use super::UpgradeIndex; use crate::progress::Progress; @@ -34,7 +34,7 @@ impl UpgradeIndex for Latest_V1_14_To_Latest_V1_15 { /// # Warning /// /// This object should not be rewritten to the DB, only read to get the name and `user_provided` roaring. -#[derive(Debug, Deserialize, Serialize)] +#[derive(Debug, Deserialize)] pub struct IndexEmbeddingConfig { pub name: String, pub user_provided: RoaringBitmap, From a56c0369947760bf5980d4dfac97bec1951c0781 Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Tue, 8 Jul 2025 12:18:52 +0200 Subject: [PATCH 55/81] Update crates/meilisearch-types/src/keys.rs Co-authored-by: gui machiavelli --- crates/meilisearch-types/src/keys.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/meilisearch-types/src/keys.rs b/crates/meilisearch-types/src/keys.rs index 2911f22a2..b98f2d38d 100644 --- a/crates/meilisearch-types/src/keys.rs +++ b/crates/meilisearch-types/src/keys.rs @@ -149,7 +149,7 @@ impl Key { let uid = Uuid::new_v4(); Self { name: Some("Default Read-Only Admin API Key".to_string()), - description: Some("Use it to peek into the instance in a read-only mode. Caution: This key gives you access to all the other api keys. Do not expose it on a public frontend".to_string()), + description: Some("Use it to read information across the whole database. Caution! Do not expose this key on a public frontend".to_string()), uid, actions: vec![Action::AllGet, Action::KeysGet], indexes: vec![IndexUidPattern::all()], From 9cee432255f1a66b6b16c6b46bea243902eade2d Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Tue, 8 Jul 2025 13:36:26 +0200 Subject: [PATCH 56/81] Fix broken tests --- crates/meilisearch-types/src/keys.rs | 1 + crates/meilisearch/tests/auth/api_keys.rs | 4 ++-- crates/meilisearch/tests/auth/errors.rs | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/meilisearch-types/src/keys.rs b/crates/meilisearch-types/src/keys.rs index c0ec5ae0b..e210f8df3 100644 --- a/crates/meilisearch-types/src/keys.rs +++ b/crates/meilisearch-types/src/keys.rs @@ -431,6 +431,7 @@ impl Action { DocumentsAdd => false, DocumentsGet => true, DocumentsDelete => false, + Export => true, IndexesAdd => false, IndexesGet => true, IndexesUpdate => false, diff --git a/crates/meilisearch/tests/auth/api_keys.rs b/crates/meilisearch/tests/auth/api_keys.rs index f717fd53e..0b8a3d2c5 100644 --- a/crates/meilisearch/tests/auth/api_keys.rs +++ b/crates/meilisearch/tests/auth/api_keys.rs @@ -421,7 +421,7 @@ async fn error_add_api_key_invalid_parameters_actions() { meili_snap::snapshot!(code, @"400 Bad Request"); meili_snap::snapshot!(meili_snap::json_string!(response, { ".createdAt" => "[ignored]", ".updatedAt" => "[ignored]" }), @r#" { - "message": "Unknown value `doc.add` at `.actions[0]`: expected one of `*`, `*.get`, `search`, `documents.*`, `documents.add`, `documents.get`, `documents.delete`, `indexes.*`, `indexes.create`, `indexes.get`, `indexes.update`, `indexes.delete`, `indexes.swap`, `tasks.*`, `tasks.cancel`, `tasks.delete`, `tasks.get`, `settings.*`, `settings.get`, `settings.update`, `stats.*`, `stats.get`, `metrics.*`, `metrics.get`, `dumps.*`, `dumps.create`, `snapshots.*`, `snapshots.create`, `version`, `keys.create`, `keys.get`, `keys.update`, `keys.delete`, `experimental.get`, `experimental.update`, `network.get`, `network.update`, `chatCompletions`, `chats.*`, `chats.get`, `chats.delete`, `chatsSettings.*`, `chatsSettings.get`, `chatsSettings.update`", + "message": "Unknown value `doc.add` at `.actions[0]`: expected one of `*`, `*.get`, `search`, `documents.*`, `documents.add`, `documents.get`, `documents.delete`, `indexes.*`, `indexes.create`, `indexes.get`, `indexes.update`, `indexes.delete`, `indexes.swap`, `tasks.*`, `tasks.cancel`, `tasks.delete`, `tasks.get`, `settings.*`, `settings.get`, `settings.update`, `stats.*`, `stats.get`, `metrics.*`, `metrics.get`, `dumps.*`, `dumps.create`, `snapshots.*`, `snapshots.create`, `version`, `keys.create`, `keys.get`, `keys.update`, `keys.delete`, `experimental.get`, `experimental.update`, `export`, `network.get`, `network.update`, `chatCompletions`, `chats.*`, `chats.get`, `chats.delete`, `chatsSettings.*`, `chatsSettings.get`, `chatsSettings.update`", "code": "invalid_api_key_actions", "type": "invalid_request", "link": "https://docs.meilisearch.com/errors#invalid_api_key_actions" @@ -852,7 +852,7 @@ async fn list_api_keys() { }, { "name": "Default Read-Only Admin API Key", - "description": "Use it to peek into the instance in a read-only mode. Caution: This key gives you access to all the other api keys. Do not expose it on a public frontend", + "description": "Use it to read information across the whole database. Caution! Do not expose this key on a public frontend", "key": "[ignored]", "uid": "[ignored]", "actions": [ diff --git a/crates/meilisearch/tests/auth/errors.rs b/crates/meilisearch/tests/auth/errors.rs index 845fe7085..e8d935fde 100644 --- a/crates/meilisearch/tests/auth/errors.rs +++ b/crates/meilisearch/tests/auth/errors.rs @@ -93,7 +93,7 @@ async fn create_api_key_bad_actions() { snapshot!(code, @"400 Bad Request"); snapshot!(json_string!(response), @r#" { - "message": "Unknown value `doggo` at `.actions[0]`: expected one of `*`, `search`, `documents.*`, `documents.add`, `documents.get`, `documents.delete`, `indexes.*`, `indexes.create`, `indexes.get`, `indexes.update`, `indexes.delete`, `indexes.swap`, `tasks.*`, `tasks.cancel`, `tasks.delete`, `tasks.get`, `settings.*`, `settings.get`, `settings.update`, `stats.*`, `stats.get`, `metrics.*`, `metrics.get`, `dumps.*`, `dumps.create`, `snapshots.*`, `snapshots.create`, `version`, `keys.create`, `keys.get`, `keys.update`, `keys.delete`, `experimental.get`, `experimental.update`, `export`, `network.get`, `network.update`, `chatCompletions`, `chats.*`, `chats.get`, `chats.delete`, `chatsSettings.*`, `chatsSettings.get`, `chatsSettings.update`", + "message": "Unknown value `doggo` at `.actions[0]`: expected one of `*`, `*.get`, `search`, `documents.*`, `documents.add`, `documents.get`, `documents.delete`, `indexes.*`, `indexes.create`, `indexes.get`, `indexes.update`, `indexes.delete`, `indexes.swap`, `tasks.*`, `tasks.cancel`, `tasks.delete`, `tasks.get`, `settings.*`, `settings.get`, `settings.update`, `stats.*`, `stats.get`, `metrics.*`, `metrics.get`, `dumps.*`, `dumps.create`, `snapshots.*`, `snapshots.create`, `version`, `keys.create`, `keys.get`, `keys.update`, `keys.delete`, `experimental.get`, `experimental.update`, `export`, `network.get`, `network.update`, `chatCompletions`, `chats.*`, `chats.get`, `chats.delete`, `chatsSettings.*`, `chatsSettings.get`, `chatsSettings.update`", "code": "invalid_api_key_actions", "type": "invalid_request", "link": "https://docs.meilisearch.com/errors#invalid_api_key_actions" From 50bc1d55f3128235fa0c5ac3019286ca4082a3da Mon Sep 17 00:00:00 2001 From: ManyTheFish Date: Thu, 10 Jul 2025 18:23:46 +0200 Subject: [PATCH 57/81] Add test reproducing the bug --- .../tests/settings/get_settings.rs | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/crates/meilisearch/tests/settings/get_settings.rs b/crates/meilisearch/tests/settings/get_settings.rs index 47e699380..f50f7f940 100644 --- a/crates/meilisearch/tests/settings/get_settings.rs +++ b/crates/meilisearch/tests/settings/get_settings.rs @@ -692,3 +692,68 @@ async fn granular_filterable_attributes() { ] "###); } + +#[actix_rt::test] +async fn test_searchable_attributes_order() { + let server = Server::new_shared(); + let index = server.unique_index(); + + // 1) Create an index with settings "searchableAttributes": ["title", "overview"] + let (response, code) = index.create(None).await; + assert_eq!(code, 202, "{response}"); + server.wait_task(response.uid()).await.succeeded(); + + let (task, code) = index + .update_settings(json!({ + "searchableAttributes": ["title", "overview"] + })) + .await; + assert_eq!(code, 202, "{task}"); + server.wait_task(task.uid()).await.succeeded(); + + // 2) Add documents in the index + let documents = json!([ + { + "id": 1, + "title": "The Matrix", + "overview": "A computer hacker learns from mysterious rebels about the true nature of his reality." + }, + { + "id": 2, + "title": "Inception", + "overview": "A thief who steals corporate secrets through dream-sharing technology." + } + ]); + + let (response, code) = index.add_documents(documents, None).await; + assert_eq!(code, 202, "{response}"); + server.wait_task(response.uid()).await.succeeded(); + + // 3) Modify the settings "searchableAttributes": ["overview", "title"] (overview is put first) + let (task, code) = index + .update_settings(json!({ + "searchableAttributes": ["overview", "title"] + })) + .await; + assert_eq!(code, 202, "{task}"); + server.wait_task(task.uid()).await.succeeded(); + + // 4) Check if it has been applied + let (response, code) = index.settings().await; + assert_eq!(code, 200, "{response}"); + assert_eq!(response["searchableAttributes"], json!(["overview", "title"])); + + // 5) Re-modify the settings "searchableAttributes": ["title", "overview"] (title is put first) + let (task, code) = index + .update_settings(json!({ + "searchableAttributes": ["title", "overview"] + })) + .await; + assert_eq!(code, 202, "{task}"); + server.wait_task(task.uid()).await.succeeded(); + + // 6) Check if it has been applied + let (response, code) = index.settings().await; + assert_eq!(code, 200, "{response}"); + assert_eq!(response["searchableAttributes"], json!(["title", "overview"])); +} From 3f655ea20ef5238c11f2d23a2a0ad8812730d149 Mon Sep 17 00:00:00 2001 From: ManyTheFish Date: Thu, 10 Jul 2025 18:24:23 +0200 Subject: [PATCH 58/81] compare user defined searchable fields instead of internal searchable fields --- crates/milli/src/update/settings.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/milli/src/update/settings.rs b/crates/milli/src/update/settings.rs index 911f51865..fdc21797f 100644 --- a/crates/milli/src/update/settings.rs +++ b/crates/milli/src/update/settings.rs @@ -554,10 +554,10 @@ impl<'a, 't, 'i> Settings<'a, 't, 'i> { match self.searchable_fields { Setting::Set(ref fields) => { // Check to see if the searchable fields changed before doing anything else - let old_fields = self.index.searchable_fields(self.wtxn)?; + let old_fields = self.index.user_defined_searchable_fields(self.wtxn)?; let did_change = { let new_fields = fields.iter().map(String::as_str).collect::>(); - new_fields != old_fields + old_fields.map(|old| new_fields != old).unwrap_or(true) }; if !did_change { return Ok(false); From 78d0625a91051155c9f9454514734fdbebaf4b77 Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Fri, 11 Jul 2025 11:23:20 +0200 Subject: [PATCH 59/81] Decrease default payload size for exports --- crates/index-scheduler/src/scheduler/process_export.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/index-scheduler/src/scheduler/process_export.rs b/crates/index-scheduler/src/scheduler/process_export.rs index 2062e1c28..10d5cc11b 100644 --- a/crates/index-scheduler/src/scheduler/process_export.rs +++ b/crates/index-scheduler/src/scheduler/process_export.rs @@ -167,7 +167,7 @@ impl IndexScheduler { }, ); - let limit = payload_size.map(|ps| ps.as_u64() as usize).unwrap_or(50 * 1024 * 1024); // defaults to 50 MiB + let limit = payload_size.map(|ps| ps.as_u64() as usize).unwrap_or(20 * 1024 * 1024); // defaults to 20 MiB let documents_url = format!("{base_url}/indexes/{uid}/documents"); request_threads() From 9bdfdd395bc8a8ca690857fc9fa19bae0f8f5b6e Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Fri, 11 Jul 2025 11:29:47 +0200 Subject: [PATCH 60/81] Fix document step overflowing --- crates/index-scheduler/src/scheduler/process_export.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/index-scheduler/src/scheduler/process_export.rs b/crates/index-scheduler/src/scheduler/process_export.rs index 10d5cc11b..aeb22f441 100644 --- a/crates/index-scheduler/src/scheduler/process_export.rs +++ b/crates/index-scheduler/src/scheduler/process_export.rs @@ -276,7 +276,7 @@ impl IndexScheduler { } buffer.extend_from_slice(&tmp_buffer); - if i % 100 == 0 { + if i > 0 && i % 100 == 0 { step.fetch_add(100, atomic::Ordering::Relaxed); } } From 3f42f1a036ba94ce6f822d03e238013b41ca830e Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Fri, 11 Jul 2025 11:32:05 +0200 Subject: [PATCH 61/81] Get rid of bearer --- .../src/scheduler/process_export.rs | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/crates/index-scheduler/src/scheduler/process_export.rs b/crates/index-scheduler/src/scheduler/process_export.rs index aeb22f441..80ed873b4 100644 --- a/crates/index-scheduler/src/scheduler/process_export.rs +++ b/crates/index-scheduler/src/scheduler/process_export.rs @@ -62,13 +62,14 @@ impl IndexScheduler { let ExportIndexSettings { filter, override_settings } = export_settings; let index = self.index(uid)?; let index_rtxn = index.read_txn()?; + let bearer = api_key.map(|api_key| format!("Bearer {api_key}")); // First, check if the index already exists let url = format!("{base_url}/indexes/{uid}"); let response = retry(&must_stop_processing, || { let mut request = agent.get(&url); - if let Some(api_key) = api_key { - request = request.set("Authorization", &format!("Bearer {api_key}")); + if let Some(bearer) = &bearer { + request = request.set("Authorization", bearer); } request.send_bytes(Default::default()).map_err(into_backoff_error) @@ -90,8 +91,8 @@ impl IndexScheduler { let url = format!("{base_url}/indexes"); retry(&must_stop_processing, || { let mut request = agent.post(&url); - if let Some(api_key) = api_key { - request = request.set("Authorization", &format!("Bearer {api_key}")); + if let Some(bearer) = &bearer { + request = request.set("Authorization", bearer); } let index_param = json!({ "uid": uid, "primaryKey": primary_key }); request.send_json(&index_param).map_err(into_backoff_error) @@ -103,8 +104,8 @@ impl IndexScheduler { let url = format!("{base_url}/indexes/{uid}"); retry(&must_stop_processing, || { let mut request = agent.patch(&url); - if let Some(api_key) = api_key { - request = request.set("Authorization", &format!("Bearer {api_key}")); + if let Some(bearer) = &bearer { + request = request.set("Authorization", bearer); } let index_param = json!({ "primaryKey": primary_key }); request.send_json(&index_param).map_err(into_backoff_error) @@ -122,7 +123,6 @@ impl IndexScheduler { } // Retry logic for sending settings let url = format!("{base_url}/indexes/{uid}/settings"); - let bearer = api_key.map(|api_key| format!("Bearer {api_key}")); retry(&must_stop_processing, || { let mut request = agent.patch(&url); if let Some(bearer) = bearer.as_ref() { @@ -265,9 +265,8 @@ impl IndexScheduler { let mut request = agent.post(&documents_url); request = request.set("Content-Type", "application/x-ndjson"); request = request.set("Content-Encoding", "gzip"); - if let Some(api_key) = api_key { - request = request - .set("Authorization", &(format!("Bearer {api_key}"))); + if let Some(bearer) = &bearer { + request = request.set("Authorization", bearer); } request.send_bytes(&compressed_buffer).map_err(into_backoff_error) })?; @@ -284,8 +283,8 @@ impl IndexScheduler { retry(&must_stop_processing, || { let mut request = agent.post(&documents_url); request = request.set("Content-Type", "application/x-ndjson"); - if let Some(api_key) = api_key { - request = request.set("Authorization", &(format!("Bearer {api_key}"))); + if let Some(bearer) = &bearer { + request = request.set("Authorization", bearer); } request.send_bytes(&buffer).map_err(into_backoff_error) })?; From aa09edb3fb24f0f690da698c3ccc80dd7c444337 Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Fri, 11 Jul 2025 12:17:40 +0200 Subject: [PATCH 62/81] Fix errors being silently dropped --- crates/index-scheduler/src/scheduler/process_export.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/index-scheduler/src/scheduler/process_export.rs b/crates/index-scheduler/src/scheduler/process_export.rs index 80ed873b4..a951a7ca6 100644 --- a/crates/index-scheduler/src/scheduler/process_export.rs +++ b/crates/index-scheduler/src/scheduler/process_export.rs @@ -170,7 +170,7 @@ impl IndexScheduler { let limit = payload_size.map(|ps| ps.as_u64() as usize).unwrap_or(20 * 1024 * 1024); // defaults to 20 MiB let documents_url = format!("{base_url}/indexes/{uid}/documents"); - request_threads() + let results = request_threads() .broadcast(|ctx| { let index_rtxn = index .read_txn() @@ -297,6 +297,9 @@ impl IndexScheduler { Some(uid.to_string()), ) })?; + for result in results { + result?; + } step.store(total_documents, atomic::Ordering::Relaxed); } From ae26658913846a471173198dbda68b93b07e89bd Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Fri, 11 Jul 2025 12:18:24 +0200 Subject: [PATCH 63/81] Use the most appropriate unit in payload_too_large error --- crates/meilisearch/src/error.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/meilisearch/src/error.rs b/crates/meilisearch/src/error.rs index 91c6c23fa..d8cc41ad1 100644 --- a/crates/meilisearch/src/error.rs +++ b/crates/meilisearch/src/error.rs @@ -49,7 +49,7 @@ pub enum MeilisearchHttpError { TooManySearchRequests(usize), #[error("Internal error: Search limiter is down.")] SearchLimiterIsDown, - #[error("The provided payload reached the size limit. The maximum accepted payload size is {}.", Byte::from_u64(*.0 as u64).get_appropriate_unit(UnitType::Binary))] + #[error("The provided payload reached the size limit. The maximum accepted payload size is {}.", Byte::from_u64(dbg!(*.0 as u64)).get_appropriate_unit(if *.0 % 1024 == 0 { UnitType::Binary } else { UnitType::Decimal }))] PayloadTooLarge(usize), #[error("Two indexes must be given for each swap. The list `[{}]` contains {} indexes.", .0.iter().map(|uid| format!("\"{uid}\"")).collect::>().join(", "), .0.len() From 1ade76ba109fbd99441ad70d6c6876aa7ea86d9d Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Fri, 11 Jul 2025 12:23:41 +0200 Subject: [PATCH 64/81] Remove sneaky debug --- crates/meilisearch/src/error.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/meilisearch/src/error.rs b/crates/meilisearch/src/error.rs index d8cc41ad1..8d4430f07 100644 --- a/crates/meilisearch/src/error.rs +++ b/crates/meilisearch/src/error.rs @@ -49,7 +49,7 @@ pub enum MeilisearchHttpError { TooManySearchRequests(usize), #[error("Internal error: Search limiter is down.")] SearchLimiterIsDown, - #[error("The provided payload reached the size limit. The maximum accepted payload size is {}.", Byte::from_u64(dbg!(*.0 as u64)).get_appropriate_unit(if *.0 % 1024 == 0 { UnitType::Binary } else { UnitType::Decimal }))] + #[error("The provided payload reached the size limit. The maximum accepted payload size is {}.", Byte::from_u64(*.0 as u64).get_appropriate_unit(if *.0 % 1024 == 0 { UnitType::Binary } else { UnitType::Decimal }))] PayloadTooLarge(usize), #[error("Two indexes must be given for each swap. The list `[{}]` contains {} indexes.", .0.iter().map(|uid| format!("\"{uid}\"")).collect::>().join(", "), .0.len() From cfa6ba6c3b21b7317c8227525921a462cc0c68c9 Mon Sep 17 00:00:00 2001 From: kametsun Date: Sat, 12 Jul 2025 00:50:26 +0900 Subject: [PATCH 65/81] Fix stats showing wrong document count after clear all Update database stats after clearing documents to ensure /stats endpoint returns correct numberOfDocuments: 0 instead of stale count. --- crates/milli/src/update/clear_documents.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/milli/src/update/clear_documents.rs b/crates/milli/src/update/clear_documents.rs index 01631e9a3..ea07dfc3b 100644 --- a/crates/milli/src/update/clear_documents.rs +++ b/crates/milli/src/update/clear_documents.rs @@ -2,7 +2,7 @@ use heed::RwTxn; use roaring::RoaringBitmap; use time::OffsetDateTime; -use crate::{FieldDistribution, Index, Result}; +use crate::{database_stats::DatabaseStats, FieldDistribution, Index, Result}; pub struct ClearDocuments<'t, 'i> { wtxn: &'t mut RwTxn<'i>, @@ -92,6 +92,10 @@ impl<'t, 'i> ClearDocuments<'t, 'i> { documents.clear(self.wtxn)?; + // Update the stats of the documents database after clearing all documents. + let stats = DatabaseStats::new(self.index.documents.remap_data_type(), self.wtxn)?; + self.index.put_documents_stats(self.wtxn, stats)?; + Ok(number_of_documents) } } From 9a9be76757d9381d317f68ef4ab0f99629de6700 Mon Sep 17 00:00:00 2001 From: kametsun Date: Sat, 12 Jul 2025 01:09:17 +0900 Subject: [PATCH 66/81] add: verify that the statistics are correctly update assert --- crates/milli/src/update/clear_documents.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/milli/src/update/clear_documents.rs b/crates/milli/src/update/clear_documents.rs index ea07dfc3b..ff1057267 100644 --- a/crates/milli/src/update/clear_documents.rs +++ b/crates/milli/src/update/clear_documents.rs @@ -125,6 +125,9 @@ mod tests { wtxn.commit().unwrap(); let rtxn = index.read_txn().unwrap(); + + // Variables for statistics verification + let stats = index.documents_stats(&rtxn).unwrap().unwrap(); // the value is 7 because there is `[id, name, age, country, _geo, _geo.lng, _geo.lat]` assert_eq!(index.fields_ids_map(&rtxn).unwrap().len(), 7); @@ -146,5 +149,9 @@ mod tests { assert!(index.field_id_docid_facet_f64s.is_empty(&rtxn).unwrap()); assert!(index.field_id_docid_facet_strings.is_empty(&rtxn).unwrap()); assert!(index.documents.is_empty(&rtxn).unwrap()); + + // Verify that the statistics are correctly updated after clearing documents + assert_eq!(index.number_of_documents(&rtxn).unwrap(), 0); + assert_eq!(stats.number_of_entries(), 0); } } From 5cd61b50f944c6aefca040ba5ee1ab1049ffe463 Mon Sep 17 00:00:00 2001 From: kametsun Date: Sat, 12 Jul 2025 18:19:26 +0900 Subject: [PATCH 67/81] Fix formatting --- crates/milli/src/update/clear_documents.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/milli/src/update/clear_documents.rs b/crates/milli/src/update/clear_documents.rs index ff1057267..84eeca7f9 100644 --- a/crates/milli/src/update/clear_documents.rs +++ b/crates/milli/src/update/clear_documents.rs @@ -125,7 +125,7 @@ mod tests { wtxn.commit().unwrap(); let rtxn = index.read_txn().unwrap(); - + // Variables for statistics verification let stats = index.documents_stats(&rtxn).unwrap().unwrap(); From 662c5d98715c824200d74f8006fdf623d011af22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Renault?= Date: Tue, 24 Jun 2025 17:33:24 +0200 Subject: [PATCH 68/81] Introduce filters in the chat completions --- crates/meilisearch-types/src/error.rs | 1 + crates/meilisearch-types/src/features.rs | 3 +++ .../src/routes/chats/chat_completions.rs | 4 ++++ crates/meilisearch/src/routes/chats/settings.rs | 13 +++++++++++-- 4 files changed, 19 insertions(+), 2 deletions(-) diff --git a/crates/meilisearch-types/src/error.rs b/crates/meilisearch-types/src/error.rs index c57e2d042..fb5bd4f18 100644 --- a/crates/meilisearch-types/src/error.rs +++ b/crates/meilisearch-types/src/error.rs @@ -415,6 +415,7 @@ InvalidChatCompletionPrompts , InvalidRequest , BAD_REQU InvalidChatCompletionSystemPrompt , InvalidRequest , BAD_REQUEST ; InvalidChatCompletionSearchDescriptionPrompt , InvalidRequest , BAD_REQUEST ; InvalidChatCompletionSearchQueryParamPrompt , InvalidRequest , BAD_REQUEST ; +InvalidChatCompletionSearchFilterParamPrompt , InvalidRequest , BAD_REQUEST ; InvalidChatCompletionSearchIndexUidParamPrompt , InvalidRequest , BAD_REQUEST ; InvalidChatCompletionPreQueryPrompt , InvalidRequest , BAD_REQUEST } diff --git a/crates/meilisearch-types/src/features.rs b/crates/meilisearch-types/src/features.rs index 3c78035e8..0fabec32f 100644 --- a/crates/meilisearch-types/src/features.rs +++ b/crates/meilisearch-types/src/features.rs @@ -8,6 +8,7 @@ pub const DEFAULT_CHAT_SYSTEM_PROMPT: &str = "You are a highly capable research pub const DEFAULT_CHAT_SEARCH_DESCRIPTION_PROMPT: &str = "Search the database for relevant JSON documents using an optional query."; pub const DEFAULT_CHAT_SEARCH_Q_PARAM_PROMPT: &str = "The search query string used to find relevant documents in the index. This should contain keywords or phrases that best represent what the user is looking for. More specific queries will yield more precise results."; +pub const DEFAULT_CHAT_SEARCH_FILTER_PARAM_PROMPT: &str = "The search filter string used to find relevant documents in the index. It supports parentheses, AND, OR, NOT, EXISTS, IS EMPTY, IS NOT EMPTY. Here is an example: \"price > 100 AND category = 'electronics'\""; pub const DEFAULT_CHAT_SEARCH_INDEX_UID_PARAM_PROMPT: &str = "The name of the index to search within. An index is a collection of documents organized for search. Selecting the right index ensures the most relevant results for the user query."; #[derive(Serialize, Deserialize, Debug, Clone, Copy, Default, PartialEq, Eq)] @@ -164,6 +165,7 @@ pub struct ChatCompletionPrompts { pub system: String, pub search_description: String, pub search_q_param: String, + pub search_filter_param: String, pub search_index_uid_param: String, } @@ -173,6 +175,7 @@ impl Default for ChatCompletionPrompts { system: DEFAULT_CHAT_SYSTEM_PROMPT.to_string(), search_description: DEFAULT_CHAT_SEARCH_DESCRIPTION_PROMPT.to_string(), search_q_param: DEFAULT_CHAT_SEARCH_Q_PARAM_PROMPT.to_string(), + search_filter_param: DEFAULT_CHAT_SEARCH_FILTER_PARAM_PROMPT.to_string(), search_index_uid_param: DEFAULT_CHAT_SEARCH_INDEX_UID_PARAM_PROMPT.to_string(), } } diff --git a/crates/meilisearch/src/routes/chats/chat_completions.rs b/crates/meilisearch/src/routes/chats/chat_completions.rs index 4f7087ae8..161a1b851 100644 --- a/crates/meilisearch/src/routes/chats/chat_completions.rs +++ b/crates/meilisearch/src/routes/chats/chat_completions.rs @@ -203,6 +203,10 @@ fn setup_search_tool( // "type": ["string", "null"], "type": "string", "description": prompts.search_q_param, + }, + "filter": { + "type": "string", + "description": prompts.search_filter_param, } }, "required": ["index_uid", "q"], diff --git a/crates/meilisearch/src/routes/chats/settings.rs b/crates/meilisearch/src/routes/chats/settings.rs index 38eb0d3c5..44c099c14 100644 --- a/crates/meilisearch/src/routes/chats/settings.rs +++ b/crates/meilisearch/src/routes/chats/settings.rs @@ -8,8 +8,8 @@ use meilisearch_types::error::{Code, ResponseError}; use meilisearch_types::features::{ ChatCompletionPrompts as DbChatCompletionPrompts, ChatCompletionSettings, ChatCompletionSource as DbChatCompletionSource, DEFAULT_CHAT_SEARCH_DESCRIPTION_PROMPT, - DEFAULT_CHAT_SEARCH_INDEX_UID_PARAM_PROMPT, DEFAULT_CHAT_SEARCH_Q_PARAM_PROMPT, - DEFAULT_CHAT_SYSTEM_PROMPT, + DEFAULT_CHAT_SEARCH_FILTER_PARAM_PROMPT, DEFAULT_CHAT_SEARCH_INDEX_UID_PARAM_PROMPT, + DEFAULT_CHAT_SEARCH_Q_PARAM_PROMPT, DEFAULT_CHAT_SYSTEM_PROMPT, }; use meilisearch_types::keys::actions; use meilisearch_types::milli::update::Setting; @@ -84,6 +84,11 @@ async fn patch_settings( Setting::Reset => DEFAULT_CHAT_SEARCH_Q_PARAM_PROMPT.to_string(), Setting::NotSet => old_settings.prompts.search_q_param, }, + search_filter_param: match new_prompts.search_filter_param { + Setting::Set(new_description) => new_description, + Setting::Reset => DEFAULT_CHAT_SEARCH_FILTER_PARAM_PROMPT.to_string(), + Setting::NotSet => old_settings.prompts.search_filter_param, + }, search_index_uid_param: match new_prompts.search_index_uid_param { Setting::Set(new_description) => new_description, Setting::Reset => DEFAULT_CHAT_SEARCH_INDEX_UID_PARAM_PROMPT.to_string(), @@ -252,6 +257,10 @@ pub struct ChatPrompts { #[schema(value_type = Option, example = json!("This is query parameter..."))] pub search_q_param: Setting, #[serde(default)] + #[deserr(default, error = DeserrJsonError)] + #[schema(value_type = Option, example = json!("This is filter parameter..."))] + pub search_filter_param: Setting, + #[serde(default)] #[deserr(default, error = DeserrJsonError)] #[schema(value_type = Option, example = json!("This is index you want to search in..."))] pub search_index_uid_param: Setting, From 1a9dbd364eff68b17a43df1892bcdb3d27569a5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Renault?= Date: Tue, 24 Jun 2025 18:43:09 +0200 Subject: [PATCH 69/81] Fix some issues --- crates/meilisearch-types/src/features.rs | 2 +- .../src/routes/chats/chat_completions.rs | 39 ++++++++++++------- 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/crates/meilisearch-types/src/features.rs b/crates/meilisearch-types/src/features.rs index 0fabec32f..2fe4f7d43 100644 --- a/crates/meilisearch-types/src/features.rs +++ b/crates/meilisearch-types/src/features.rs @@ -8,7 +8,7 @@ pub const DEFAULT_CHAT_SYSTEM_PROMPT: &str = "You are a highly capable research pub const DEFAULT_CHAT_SEARCH_DESCRIPTION_PROMPT: &str = "Search the database for relevant JSON documents using an optional query."; pub const DEFAULT_CHAT_SEARCH_Q_PARAM_PROMPT: &str = "The search query string used to find relevant documents in the index. This should contain keywords or phrases that best represent what the user is looking for. More specific queries will yield more precise results."; -pub const DEFAULT_CHAT_SEARCH_FILTER_PARAM_PROMPT: &str = "The search filter string used to find relevant documents in the index. It supports parentheses, AND, OR, NOT, EXISTS, IS EMPTY, IS NOT EMPTY. Here is an example: \"price > 100 AND category = 'electronics'\""; +pub const DEFAULT_CHAT_SEARCH_FILTER_PARAM_PROMPT: &str = "The search filter string used to find relevant documents in the index. It supports parentheses, `=`, `!=`, `>=`, `>`, `<=`, `<`, `IN`, `NOT IN`, `TO`, `EXISTS`, `NOT EXISTS`, `IS NULL`, `IS NOT NULL`, `IS EMPTY`, `IS NOT EMPTY`, `CONTAINS`, `NOT CONTAINS`, `STARTS WITH`, `NOT STARTS WITH`, `_geoRadius`, or `_geoBoundingBox`. Here is an example: \"price > 100 AND category = 'electronics'\""; pub const DEFAULT_CHAT_SEARCH_INDEX_UID_PARAM_PROMPT: &str = "The name of the index to search within. An index is a collection of documents organized for search. Selecting the right index ensures the most relevant results for the user query."; #[derive(Serialize, Deserialize, Debug, Clone, Copy, Default, PartialEq, Eq)] diff --git a/crates/meilisearch/src/routes/chats/chat_completions.rs b/crates/meilisearch/src/routes/chats/chat_completions.rs index 161a1b851..830efa844 100644 --- a/crates/meilisearch/src/routes/chats/chat_completions.rs +++ b/crates/meilisearch/src/routes/chats/chat_completions.rs @@ -209,7 +209,7 @@ fn setup_search_tool( "description": prompts.search_filter_param, } }, - "required": ["index_uid", "q"], + "required": ["index_uid", "q", "filter"], "additionalProperties": false, })) .strict(true) @@ -251,11 +251,16 @@ async fn process_search_request( auth_token: &str, index_uid: String, q: Option, + filter: Option, ) -> Result<(Index, Vec, String), ResponseError> { let index = index_scheduler.index(&index_uid)?; let rtxn = index.static_read_txn()?; let ChatConfig { description: _, prompt: _, search_parameters } = index.chat_config(&rtxn)?; - let mut query = SearchQuery { q, ..SearchQuery::from(search_parameters) }; + let mut query = SearchQuery { + q, + filter: filter.map(serde_json::Value::from), + ..SearchQuery::from(search_parameters) + }; let auth_filter = ActionPolicy::<{ actions::SEARCH }>::authenticate( auth_ctrl, auth_token, @@ -399,16 +404,19 @@ async fn non_streamed_chat( for call in meili_calls { let result = match serde_json::from_str(&call.function.arguments) { - Ok(SearchInIndexParameters { index_uid, q }) => process_search_request( - &index_scheduler, - auth_ctrl.clone(), - &search_queue, - auth_token, - index_uid, - q, - ) - .await - .map_err(|e| e.to_string()), + Ok(SearchInIndexParameters { index_uid, q, filter }) => { + process_search_request( + &index_scheduler, + auth_ctrl.clone(), + &search_queue, + auth_token, + index_uid, + q, + filter, + ) + .await + .map_err(|e| e.to_string()) + } Err(err) => Err(err.to_string()), }; @@ -722,14 +730,15 @@ async fn handle_meili_tools( let mut error = None; - let result = match serde_json::from_str(&call.function.arguments) { - Ok(SearchInIndexParameters { index_uid, q }) => match process_search_request( + let answer = match serde_json::from_str(&call.function.arguments) { + Ok(SearchInIndexParameters { index_uid, q, filter }) => match process_search_request( index_scheduler, auth_ctrl.clone(), search_queue, auth_token, index_uid, q, + filter, ) .await { @@ -805,4 +814,6 @@ struct SearchInIndexParameters { index_uid: String, /// The query parameter to use. q: Option, + /// The filter parameter to use. + filter: Option, } From 34f2ab7093874ccf6617c5ff18c7e0a21e66afb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Renault?= Date: Thu, 26 Jun 2025 12:00:09 +0200 Subject: [PATCH 70/81] WIP report search errors to the LLM --- .../src/routes/chats/chat_completions.rs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/crates/meilisearch/src/routes/chats/chat_completions.rs b/crates/meilisearch/src/routes/chats/chat_completions.rs index 830efa844..1610419ed 100644 --- a/crates/meilisearch/src/routes/chats/chat_completions.rs +++ b/crates/meilisearch/src/routes/chats/chat_completions.rs @@ -289,14 +289,23 @@ async fn process_search_request( let (search, _is_finite_pagination, _max_total_hits, _offset) = prepare_search(&index_cloned, &rtxn, &query, &search_kind, time_budget, features)?; - search_from_kind(index_uid, search_kind, search) - .map(|(search_results, _)| (rtxn, search_results)) - .map_err(ResponseError::from) + match search_from_kind(index_uid, search_kind, search) { + Ok((search_results, _)) => Ok((rtxn, Ok(search_results))), + Err(MeilisearchHttpError::Milli { + error: meilisearch_types::milli::Error::UserError(user_error), + index_name: _, + }) => Ok((rtxn, Err(user_error))), + Err(err) => Err(ResponseError::from(err)), + } }) .await; permit.drop().await; - let output = output?; + let output = match output? { + Ok((rtxn, Ok(search_results))) => Ok((rtxn, search_results)), + Ok((_rtxn, Err(error))) => return Ok((index, Vec::new(), error.to_string())), + Err(err) => Err(ResponseError::from(err)), + }; let mut documents = Vec::new(); if let Ok((ref rtxn, ref search_result)) = output { MEILISEARCH_CHAT_SEARCH_REQUESTS.with_label_values(&["internal"]).inc(); @@ -730,7 +739,7 @@ async fn handle_meili_tools( let mut error = None; - let answer = match serde_json::from_str(&call.function.arguments) { + let result = match serde_json::from_str(&call.function.arguments) { Ok(SearchInIndexParameters { index_uid, q, filter }) => match process_search_request( index_scheduler, auth_ctrl.clone(), From e654f662230448662b4ee14aaf7d75c1550ae85b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Renault?= Date: Tue, 1 Jul 2025 16:24:02 +0200 Subject: [PATCH 71/81] Support filtering --- crates/meilisearch-types/src/features.rs | 4 +- .../src/routes/chats/chat_completions.rs | 53 +++++++++++++++++-- crates/milli/src/attribute_patterns.rs | 6 +++ 3 files changed, 58 insertions(+), 5 deletions(-) diff --git a/crates/meilisearch-types/src/features.rs b/crates/meilisearch-types/src/features.rs index 2fe4f7d43..006f39d15 100644 --- a/crates/meilisearch-types/src/features.rs +++ b/crates/meilisearch-types/src/features.rs @@ -4,11 +4,11 @@ use serde::{Deserialize, Serialize}; use crate::error::{Code, ResponseError}; -pub const DEFAULT_CHAT_SYSTEM_PROMPT: &str = "You are a highly capable research assistant with access to powerful search tools. IMPORTANT INSTRUCTIONS:1. When answering questions, you MUST make multiple tool calls (at least 2-3) to gather comprehensive information.2. Use different search queries for each tool call - vary keywords, rephrase questions, and explore different semantic angles to ensure broad coverage.3. Always explicitly announce BEFORE making each tool call by saying: \"I'll search for [specific information] now.\"4. Combine information from ALL tool calls to provide complete, nuanced answers rather than relying on a single source.5. For complex topics, break down your research into multiple targeted queries rather than using a single generic search."; +pub const DEFAULT_CHAT_SYSTEM_PROMPT: &str = "You are a highly capable research assistant with access to powerful search tools. IMPORTANT INSTRUCTIONS:1. When answering questions, you MUST make multiple tool calls (at least 2-3) to gather comprehensive information.2. Use different search queries for each tool call - vary keywords, rephrase questions, and explore different semantic angles to ensure broad coverage.3. Always explicitly announce BEFORE making each tool call by saying: \"I'll search for [specific information] now.\"4. Combine information from ALL tool calls to provide complete, nuanced answers rather than relying on a single source.5. For complex topics, break down your research into multiple targeted queries rather than using a single generic search. Meilisearch doesn't use the colon (:) syntax to filter but rather the equal (=) one. Separate filters from query and keep the q parameter empty if needed. Same for the filter parameter: keep it empty if need be. If you need to find documents that CONTAINS keywords simply put the keywords in the q parameter do no use a filter for this purpose. Whenever you get an error, read the error message and fix your error. "; pub const DEFAULT_CHAT_SEARCH_DESCRIPTION_PROMPT: &str = "Search the database for relevant JSON documents using an optional query."; pub const DEFAULT_CHAT_SEARCH_Q_PARAM_PROMPT: &str = "The search query string used to find relevant documents in the index. This should contain keywords or phrases that best represent what the user is looking for. More specific queries will yield more precise results."; -pub const DEFAULT_CHAT_SEARCH_FILTER_PARAM_PROMPT: &str = "The search filter string used to find relevant documents in the index. It supports parentheses, `=`, `!=`, `>=`, `>`, `<=`, `<`, `IN`, `NOT IN`, `TO`, `EXISTS`, `NOT EXISTS`, `IS NULL`, `IS NOT NULL`, `IS EMPTY`, `IS NOT EMPTY`, `CONTAINS`, `NOT CONTAINS`, `STARTS WITH`, `NOT STARTS WITH`, `_geoRadius`, or `_geoBoundingBox`. Here is an example: \"price > 100 AND category = 'electronics'\""; +pub const DEFAULT_CHAT_SEARCH_FILTER_PARAM_PROMPT: &str = "The search filter string used to find relevant documents in the index. It supports parentheses, `=`, `!=`, `>=`, `>`, `<=`, `<`, `IN`, `NOT IN`, `TO`, `EXISTS`, `NOT EXISTS`, `IS NULL`, `IS NOT NULL`, `IS EMPTY`, `IS NOT EMPTY`, `_geoRadius`, or `_geoBoundingBox`. Here is an example: \"price > 100 AND category = 'electronics'\". The following is a list of fields that can be filtered on: "; pub const DEFAULT_CHAT_SEARCH_INDEX_UID_PARAM_PROMPT: &str = "The name of the index to search within. An index is a collection of documents organized for search. Selecting the right index ensures the most relevant results for the user query."; #[derive(Serialize, Deserialize, Debug, Clone, Copy, Default, PartialEq, Eq)] diff --git a/crates/meilisearch/src/routes/chats/chat_completions.rs b/crates/meilisearch/src/routes/chats/chat_completions.rs index 1610419ed..b879f85f8 100644 --- a/crates/meilisearch/src/routes/chats/chat_completions.rs +++ b/crates/meilisearch/src/routes/chats/chat_completions.rs @@ -27,9 +27,10 @@ use meilisearch_types::features::{ ChatCompletionPrompts as DbChatCompletionPrompts, ChatCompletionSource as DbChatCompletionSource, SystemRole, }; +use meilisearch_types::heed::RoTxn; use meilisearch_types::keys::actions; use meilisearch_types::milli::index::ChatConfig; -use meilisearch_types::milli::{all_obkv_to_json, obkv_to_json, TimeBudget}; +use meilisearch_types::milli::{all_obkv_to_json, obkv_to_json, OrderBy, TimeBudget}; use meilisearch_types::{Document, Index}; use serde::Deserialize; use serde_json::json; @@ -169,6 +170,7 @@ fn setup_search_tool( let mut index_uids = Vec::new(); let mut function_description = prompts.search_description.clone(); + let mut filter_description = prompts.search_filter_param.clone(); index_scheduler.try_for_each_index::<_, ()>(|name, index| { // Make sure to skip unauthorized indexes if !filters.is_index_authorized(name) { @@ -180,16 +182,22 @@ fn setup_search_tool( let index_description = chat_config.description; let _ = writeln!(&mut function_description, "\n\n - {name}: {index_description}\n"); index_uids.push(name.to_string()); + let facet_distributions = format_facet_distributions(&index, &rtxn, 10).unwrap(); // TODO do not unwrap + let _ = writeln!(&mut filter_description, "\n## Facet distributions of the {name} index"); + let _ = writeln!(&mut filter_description, "{facet_distributions}"); Ok(()) })?; + tracing::debug!("LLM function description: {function_description}"); + tracing::debug!("LLM filter description: {filter_description}"); + let tool = ChatCompletionToolArgs::default() .r#type(ChatCompletionToolType::Function) .function( FunctionObjectArgs::default() .name(MEILI_SEARCH_IN_INDEX_FUNCTION_NAME) - .description(&function_description) + .description(function_description) .parameters(json!({ "type": "object", "properties": { @@ -206,7 +214,7 @@ fn setup_search_tool( }, "filter": { "type": "string", - "description": prompts.search_filter_param, + "description": filter_description, } }, "required": ["index_uid", "q", "filter"], @@ -261,6 +269,9 @@ async fn process_search_request( filter: filter.map(serde_json::Value::from), ..SearchQuery::from(search_parameters) }; + + tracing::debug!("LLM query: {:?}", query); + let auth_filter = ActionPolicy::<{ actions::SEARCH }>::authenticate( auth_ctrl, auth_token, @@ -826,3 +837,39 @@ struct SearchInIndexParameters { /// The filter parameter to use. filter: Option, } + +fn format_facet_distributions( + index: &Index, + rtxn: &RoTxn, + max_values_per_facet: usize, +) -> meilisearch_types::milli::Result { + let universe = index.documents_ids(&rtxn)?; + let rules = index.filterable_attributes_rules(&rtxn)?; + let fields_ids_map = index.fields_ids_map(&rtxn)?; + let filterable_attributes = fields_ids_map + .names() + .filter(|name| rules.iter().any(|rule| rule.match_str(name).matches())) + .map(|name| (name, OrderBy::Count)); + let facets_distribution = index + .facets_distribution(&rtxn) + .max_values_per_facet(max_values_per_facet) + .candidates(universe) + .facets(filterable_attributes) + .execute()?; + + let mut output = String::new(); + for (facet_name, entries) in facets_distribution { + let _ = write!(&mut output, "{}: ", facet_name); + let total_entries = entries.len(); + for (i, (value, count)) in entries.into_iter().enumerate() { + let _ = if total_entries.saturating_sub(1) == i { + write!(&mut output, "{} ({}).", value, count) + } else { + write!(&mut output, "{} ({}), ", value, count) + }; + } + let _ = writeln!(&mut output); + } + + Ok(output) +} diff --git a/crates/milli/src/attribute_patterns.rs b/crates/milli/src/attribute_patterns.rs index 8da6942a3..d879cb2c3 100644 --- a/crates/milli/src/attribute_patterns.rs +++ b/crates/milli/src/attribute_patterns.rs @@ -130,6 +130,12 @@ pub enum PatternMatch { NoMatch, } +impl PatternMatch { + pub fn matches(&self) -> bool { + matches!(self, PatternMatch::Match) + } +} + #[cfg(test)] mod tests { use super::*; From d76dcc8998738dee3c072e1244a4668294f1cb3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Renault?= Date: Mon, 7 Jul 2025 11:40:52 +0200 Subject: [PATCH 72/81] Make clippy happy --- .../meilisearch/src/routes/chats/chat_completions.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/meilisearch/src/routes/chats/chat_completions.rs b/crates/meilisearch/src/routes/chats/chat_completions.rs index b879f85f8..799f4c9f0 100644 --- a/crates/meilisearch/src/routes/chats/chat_completions.rs +++ b/crates/meilisearch/src/routes/chats/chat_completions.rs @@ -182,7 +182,7 @@ fn setup_search_tool( let index_description = chat_config.description; let _ = writeln!(&mut function_description, "\n\n - {name}: {index_description}\n"); index_uids.push(name.to_string()); - let facet_distributions = format_facet_distributions(&index, &rtxn, 10).unwrap(); // TODO do not unwrap + let facet_distributions = format_facet_distributions(index, &rtxn, 10).unwrap(); // TODO do not unwrap let _ = writeln!(&mut filter_description, "\n## Facet distributions of the {name} index"); let _ = writeln!(&mut filter_description, "{facet_distributions}"); @@ -315,7 +315,7 @@ async fn process_search_request( let output = match output? { Ok((rtxn, Ok(search_results))) => Ok((rtxn, search_results)), Ok((_rtxn, Err(error))) => return Ok((index, Vec::new(), error.to_string())), - Err(err) => Err(ResponseError::from(err)), + Err(err) => Err(err), }; let mut documents = Vec::new(); if let Ok((ref rtxn, ref search_result)) = output { @@ -843,15 +843,15 @@ fn format_facet_distributions( rtxn: &RoTxn, max_values_per_facet: usize, ) -> meilisearch_types::milli::Result { - let universe = index.documents_ids(&rtxn)?; - let rules = index.filterable_attributes_rules(&rtxn)?; - let fields_ids_map = index.fields_ids_map(&rtxn)?; + let universe = index.documents_ids(rtxn)?; + let rules = index.filterable_attributes_rules(rtxn)?; + let fields_ids_map = index.fields_ids_map(rtxn)?; let filterable_attributes = fields_ids_map .names() .filter(|name| rules.iter().any(|rule| rule.match_str(name).matches())) .map(|name| (name, OrderBy::Count)); let facets_distribution = index - .facets_distribution(&rtxn) + .facets_distribution(rtxn) .max_values_per_facet(max_values_per_facet) .candidates(universe) .facets(filterable_attributes) From d694e312ff6061b435b37e0c6ac9942076825185 Mon Sep 17 00:00:00 2001 From: Many the fish Date: Tue, 15 Jul 2025 11:54:59 +0200 Subject: [PATCH 73/81] Update crates/milli/src/update/settings.rs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Clément Renault --- crates/milli/src/update/settings.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/milli/src/update/settings.rs b/crates/milli/src/update/settings.rs index fdc21797f..d2f74da2a 100644 --- a/crates/milli/src/update/settings.rs +++ b/crates/milli/src/update/settings.rs @@ -557,7 +557,7 @@ impl<'a, 't, 'i> Settings<'a, 't, 'i> { let old_fields = self.index.user_defined_searchable_fields(self.wtxn)?; let did_change = { let new_fields = fields.iter().map(String::as_str).collect::>(); - old_fields.map(|old| new_fields != old).unwrap_or(true) + old_fields.is_none_or(|old| new_fields != old) }; if !did_change { return Ok(false); From 2a015ac3b821545b3e2fab8c7424a6584d536f17 Mon Sep 17 00:00:00 2001 From: Kerollmops Date: Tue, 15 Jul 2025 14:50:10 +0200 Subject: [PATCH 74/81] Implement basic few shot prompting to improve the query capabilities --- crates/meilisearch-types/src/features.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/meilisearch-types/src/features.rs b/crates/meilisearch-types/src/features.rs index 006f39d15..d1a6b61d8 100644 --- a/crates/meilisearch-types/src/features.rs +++ b/crates/meilisearch-types/src/features.rs @@ -6,7 +6,7 @@ use crate::error::{Code, ResponseError}; pub const DEFAULT_CHAT_SYSTEM_PROMPT: &str = "You are a highly capable research assistant with access to powerful search tools. IMPORTANT INSTRUCTIONS:1. When answering questions, you MUST make multiple tool calls (at least 2-3) to gather comprehensive information.2. Use different search queries for each tool call - vary keywords, rephrase questions, and explore different semantic angles to ensure broad coverage.3. Always explicitly announce BEFORE making each tool call by saying: \"I'll search for [specific information] now.\"4. Combine information from ALL tool calls to provide complete, nuanced answers rather than relying on a single source.5. For complex topics, break down your research into multiple targeted queries rather than using a single generic search. Meilisearch doesn't use the colon (:) syntax to filter but rather the equal (=) one. Separate filters from query and keep the q parameter empty if needed. Same for the filter parameter: keep it empty if need be. If you need to find documents that CONTAINS keywords simply put the keywords in the q parameter do no use a filter for this purpose. Whenever you get an error, read the error message and fix your error. "; pub const DEFAULT_CHAT_SEARCH_DESCRIPTION_PROMPT: &str = - "Search the database for relevant JSON documents using an optional query."; + "Query: 'best story about Rust before 2018' with year: 2018 (334), 2020 (212), 2021 (210)\r\nlabel: analysis (500), golang (435), javascript (545)\r\ntype: story (760), link (989)\r\nvote: 300 (1), 298 (1), 278 (3)\r\n: {\"q\": \"\", \"filter\": \"category = Rust AND year < 2018 AND vote > 100\"}\r\nQuery: 'A black or green car that can go fast with red brakes'\r\nmaxspeed_kmh: 200 (100), 150 (300), 130 (330)\r\ncolor: black (300), grey (250), red (200), green (50)\r\nbrand: Toyota (300), Renault (100), Jeep (98), Ferrari (50)\r\n: {\"q\": \"red brakes\", \"filter\": \"maxspeed > 150 AND color IN ['black', green]\"}\r\nQuery: 'Superman movie released in 2018 or after' with year: 2018 (334), 2020 (212), 2021 (210)\r\ngenres: Drama (218), Comedy (220), Adventure (210), Fiction (196)\r\n: {\"q\":\"Superman\",\"filter\":\"genres IN [Adventure, Fiction] AND year >= 2018\"}"; pub const DEFAULT_CHAT_SEARCH_Q_PARAM_PROMPT: &str = "The search query string used to find relevant documents in the index. This should contain keywords or phrases that best represent what the user is looking for. More specific queries will yield more precise results."; pub const DEFAULT_CHAT_SEARCH_FILTER_PARAM_PROMPT: &str = "The search filter string used to find relevant documents in the index. It supports parentheses, `=`, `!=`, `>=`, `>`, `<=`, `<`, `IN`, `NOT IN`, `TO`, `EXISTS`, `NOT EXISTS`, `IS NULL`, `IS NOT NULL`, `IS EMPTY`, `IS NOT EMPTY`, `_geoRadius`, or `_geoBoundingBox`. Here is an example: \"price > 100 AND category = 'electronics'\". The following is a list of fields that can be filtered on: "; pub const DEFAULT_CHAT_SEARCH_INDEX_UID_PARAM_PROMPT: &str = "The name of the index to search within. An index is a collection of documents organized for search. Selecting the right index ensures the most relevant results for the user query."; From 0791506124aa7aeff7f0fc715bab661eabe54fcd Mon Sep 17 00:00:00 2001 From: Kerollmops Date: Tue, 15 Jul 2025 17:10:45 +0200 Subject: [PATCH 75/81] Fix some proposals --- crates/meilisearch-types/src/features.rs | 2 +- .../meilisearch/src/routes/chats/chat_completions.rs | 10 +++++----- crates/milli/src/attribute_patterns.rs | 6 ------ 3 files changed, 6 insertions(+), 12 deletions(-) diff --git a/crates/meilisearch-types/src/features.rs b/crates/meilisearch-types/src/features.rs index d1a6b61d8..8878a8281 100644 --- a/crates/meilisearch-types/src/features.rs +++ b/crates/meilisearch-types/src/features.rs @@ -6,7 +6,7 @@ use crate::error::{Code, ResponseError}; pub const DEFAULT_CHAT_SYSTEM_PROMPT: &str = "You are a highly capable research assistant with access to powerful search tools. IMPORTANT INSTRUCTIONS:1. When answering questions, you MUST make multiple tool calls (at least 2-3) to gather comprehensive information.2. Use different search queries for each tool call - vary keywords, rephrase questions, and explore different semantic angles to ensure broad coverage.3. Always explicitly announce BEFORE making each tool call by saying: \"I'll search for [specific information] now.\"4. Combine information from ALL tool calls to provide complete, nuanced answers rather than relying on a single source.5. For complex topics, break down your research into multiple targeted queries rather than using a single generic search. Meilisearch doesn't use the colon (:) syntax to filter but rather the equal (=) one. Separate filters from query and keep the q parameter empty if needed. Same for the filter parameter: keep it empty if need be. If you need to find documents that CONTAINS keywords simply put the keywords in the q parameter do no use a filter for this purpose. Whenever you get an error, read the error message and fix your error. "; pub const DEFAULT_CHAT_SEARCH_DESCRIPTION_PROMPT: &str = - "Query: 'best story about Rust before 2018' with year: 2018 (334), 2020 (212), 2021 (210)\r\nlabel: analysis (500), golang (435), javascript (545)\r\ntype: story (760), link (989)\r\nvote: 300 (1), 298 (1), 278 (3)\r\n: {\"q\": \"\", \"filter\": \"category = Rust AND year < 2018 AND vote > 100\"}\r\nQuery: 'A black or green car that can go fast with red brakes'\r\nmaxspeed_kmh: 200 (100), 150 (300), 130 (330)\r\ncolor: black (300), grey (250), red (200), green (50)\r\nbrand: Toyota (300), Renault (100), Jeep (98), Ferrari (50)\r\n: {\"q\": \"red brakes\", \"filter\": \"maxspeed > 150 AND color IN ['black', green]\"}\r\nQuery: 'Superman movie released in 2018 or after' with year: 2018 (334), 2020 (212), 2021 (210)\r\ngenres: Drama (218), Comedy (220), Adventure (210), Fiction (196)\r\n: {\"q\":\"Superman\",\"filter\":\"genres IN [Adventure, Fiction] AND year >= 2018\"}"; + "Query: 'best story about Rust before 2018' with year: 2018, 2020, 2021\nlabel: analysis, golang, javascript\ntype: story, link\nvote: 300, 298, 278\n: {\"q\": \"\", \"filter\": \"category = Rust AND type = story AND year < 2018 AND vote > 100\"}\nQuery: 'A black or green car that can go fast with red brakes' with maxspeed_kmh: 200, 150, 130\ncolor: black, grey, red, green\nbrand: Toyota, Renault, Jeep, Ferrari\n: {\"q\": \"red brakes\", \"filter\": \"maxspeed_kmh > 150 AND color IN ['black', green]\"}\nQuery: 'Superman movie released in 2018 or after' with year: 2018, 2020, 2021\ngenres: Drama, Comedy, Adventure, Fiction\n: {\"q\":\"Superman\",\"filter\":\"genres IN [Adventure, Fiction] AND year >= 2018\"}"; pub const DEFAULT_CHAT_SEARCH_Q_PARAM_PROMPT: &str = "The search query string used to find relevant documents in the index. This should contain keywords or phrases that best represent what the user is looking for. More specific queries will yield more precise results."; pub const DEFAULT_CHAT_SEARCH_FILTER_PARAM_PROMPT: &str = "The search filter string used to find relevant documents in the index. It supports parentheses, `=`, `!=`, `>=`, `>`, `<=`, `<`, `IN`, `NOT IN`, `TO`, `EXISTS`, `NOT EXISTS`, `IS NULL`, `IS NOT NULL`, `IS EMPTY`, `IS NOT EMPTY`, `_geoRadius`, or `_geoBoundingBox`. Here is an example: \"price > 100 AND category = 'electronics'\". The following is a list of fields that can be filtered on: "; pub const DEFAULT_CHAT_SEARCH_INDEX_UID_PARAM_PROMPT: &str = "The name of the index to search within. An index is a collection of documents organized for search. Selecting the right index ensures the most relevant results for the user query."; diff --git a/crates/meilisearch/src/routes/chats/chat_completions.rs b/crates/meilisearch/src/routes/chats/chat_completions.rs index 799f4c9f0..b636678f5 100644 --- a/crates/meilisearch/src/routes/chats/chat_completions.rs +++ b/crates/meilisearch/src/routes/chats/chat_completions.rs @@ -30,7 +30,7 @@ use meilisearch_types::features::{ use meilisearch_types::heed::RoTxn; use meilisearch_types::keys::actions; use meilisearch_types::milli::index::ChatConfig; -use meilisearch_types::milli::{all_obkv_to_json, obkv_to_json, OrderBy, TimeBudget}; +use meilisearch_types::milli::{all_obkv_to_json, obkv_to_json, OrderBy, PatternMatch, TimeBudget}; use meilisearch_types::{Document, Index}; use serde::Deserialize; use serde_json::json; @@ -848,7 +848,7 @@ fn format_facet_distributions( let fields_ids_map = index.fields_ids_map(rtxn)?; let filterable_attributes = fields_ids_map .names() - .filter(|name| rules.iter().any(|rule| rule.match_str(name).matches())) + .filter(|name| rules.iter().any(|rule| matches!(rule.match_str(name), PatternMatch::Match))) .map(|name| (name, OrderBy::Count)); let facets_distribution = index .facets_distribution(rtxn) @@ -861,11 +861,11 @@ fn format_facet_distributions( for (facet_name, entries) in facets_distribution { let _ = write!(&mut output, "{}: ", facet_name); let total_entries = entries.len(); - for (i, (value, count)) in entries.into_iter().enumerate() { + for (i, (value, _count)) in entries.into_iter().enumerate() { let _ = if total_entries.saturating_sub(1) == i { - write!(&mut output, "{} ({}).", value, count) + write!(&mut output, "{value}.") } else { - write!(&mut output, "{} ({}), ", value, count) + write!(&mut output, "{value}, ") }; } let _ = writeln!(&mut output); diff --git a/crates/milli/src/attribute_patterns.rs b/crates/milli/src/attribute_patterns.rs index d879cb2c3..8da6942a3 100644 --- a/crates/milli/src/attribute_patterns.rs +++ b/crates/milli/src/attribute_patterns.rs @@ -130,12 +130,6 @@ pub enum PatternMatch { NoMatch, } -impl PatternMatch { - pub fn matches(&self) -> bool { - matches!(self, PatternMatch::Match) - } -} - #[cfg(test)] mod tests { use super::*; From 77138a42d61f0a4d31b6b17a00e5b4c0286aaa58 Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Tue, 15 Jul 2025 17:04:08 +0200 Subject: [PATCH 76/81] Apply review suggestions Add preconditions Fix underflow Remove unwrap Turn methods to associated functions Apply review suggestions --- crates/meilisearch/db.snapshot | Bin 0 -> 174088 bytes .../src/analytics/mock_analytics.rs | 2 - crates/meilisearch/src/analytics/mod.rs | 6 -- .../src/routes/indexes/documents.rs | 55 ++--------- crates/milli/src/documents/sort.rs | 90 ++++++++++-------- 5 files changed, 58 insertions(+), 95 deletions(-) create mode 100644 crates/meilisearch/db.snapshot diff --git a/crates/meilisearch/db.snapshot b/crates/meilisearch/db.snapshot new file mode 100644 index 0000000000000000000000000000000000000000..29377ce4225430d3481206f468db3826b1ccb8ba GIT binary patch literal 174088 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≪t8=i(I}Dl@4-nSL==uD1icH|Kcp8@A5tHJAC>P{ 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 u(v { marker: std::marker::PhantomData, } -#[derive(Copy, Clone, Debug, PartialEq, Eq)] -pub enum DocumentFetchKind { - PerDocumentId { - retrieve_vectors: bool, - sort: bool, - }, - Normal { - with_filter: bool, - limit: usize, - offset: usize, - retrieve_vectors: bool, - sort: bool, - ids: usize, - }, -} - -impl DocumentsFetchAggregator { - pub fn from_query(query: &DocumentFetchKind) -> Self { - let (limit, offset, retrieve_vectors, sort) = match query { - DocumentFetchKind::PerDocumentId { retrieve_vectors, sort } => { - (1, 0, *retrieve_vectors, *sort) - } - DocumentFetchKind::Normal { limit, offset, retrieve_vectors, sort, .. } => { - (*limit, *offset, *retrieve_vectors, *sort) - } - }; - - let ids = match query { - DocumentFetchKind::Normal { ids, .. } => *ids, - DocumentFetchKind::PerDocumentId { .. } => 0, - }; - - Self { - per_document_id: matches!(query, DocumentFetchKind::PerDocumentId { .. }), - per_filter: matches!(query, DocumentFetchKind::Normal { with_filter, .. } if *with_filter), - max_limit: limit, - max_offset: offset, - sort, - retrieve_vectors, - max_document_ids: ids, - - marker: PhantomData, - } - } -} - impl Aggregate for DocumentsFetchAggregator { fn event_name(&self) -> &'static str { Method::event_name() @@ -1573,16 +1527,19 @@ fn retrieve_documents>( })? } - let facet_sort; let (it, number_of_documents) = if let Some(sort) = sort_criteria { let number_of_documents = candidates.len(); - facet_sort = recursive_sort(index, &rtxn, sort, &candidates)?; + let facet_sort = recursive_sort(index, &rtxn, sort, &candidates)?; let iter = facet_sort.iter()?; + let mut documents = Vec::with_capacity(limit); + for result in iter.skip(offset).take(limit) { + documents.push(result?); + } ( itertools::Either::Left(some_documents( index, &rtxn, - iter.map(|d| d.unwrap()).skip(offset).take(limit), + documents.into_iter(), retrieve_vectors, )?), number_of_documents, diff --git a/crates/milli/src/documents/sort.rs b/crates/milli/src/documents/sort.rs index 59858caad..3866d9e27 100644 --- a/crates/milli/src/documents/sort.rs +++ b/crates/milli/src/documents/sort.rs @@ -72,6 +72,10 @@ impl Iterator for SortedDocumentsIterator<'_> { /// The default implementation of `nth` would iterate over all children, which is inefficient for large datasets. /// This implementation will jump over whole chunks of children until it gets close. fn nth(&mut self, n: usize) -> Option { + if n == 0 { + return self.next(); + } + // If it's at the leaf level, just forward the call to the values iterator let (current_child, next_children, next_children_size) = match self { SortedDocumentsIterator::Leaf { values, size } => { @@ -189,41 +193,54 @@ impl<'ctx> SortedDocumentsIteratorBuilder<'ctx> { fn build(self) -> crate::Result> { let size = self.candidates.len() as usize; - // There is no point sorting a 1-element array - if size <= 1 { - return Ok(SortedDocumentsIterator::Leaf { - size, - values: Box::new(self.candidates.into_iter()), - }); - } - - match self.fields.first().copied() { - Some(AscDescId::Facet { field_id, ascending }) => self.build_facet(field_id, ascending), - Some(AscDescId::Geo { field_ids, target_point, ascending }) => { - self.build_geo(field_ids, target_point, ascending) - } - None => Ok(SortedDocumentsIterator::Leaf { + match self.fields { + [] => Ok(SortedDocumentsIterator::Leaf { size, values: Box::new(self.candidates.into_iter()), }), + [AscDescId::Facet { field_id, ascending }, next_fields @ ..] => { + SortedDocumentsIteratorBuilder::build_facet( + self.index, + self.rtxn, + self.number_db, + self.string_db, + next_fields, + self.candidates, + self.geo_candidates, + *field_id, + *ascending, + ) + } + [AscDescId::Geo { field_ids, target_point, ascending }, next_fields @ ..] => { + SortedDocumentsIteratorBuilder::build_geo( + self.index, + self.rtxn, + self.number_db, + self.string_db, + next_fields, + self.candidates, + self.geo_candidates, + *field_ids, + *target_point, + *ascending, + ) + } } } /// Builds a [`SortedDocumentsIterator`] based on the results of a facet sort. + #[allow(clippy::too_many_arguments)] fn build_facet( - self, + index: &'ctx crate::Index, + rtxn: &'ctx heed::RoTxn<'ctx>, + number_db: Database, FacetGroupValueCodec>, + string_db: Database, FacetGroupValueCodec>, + next_fields: &'ctx [AscDescId], + candidates: RoaringBitmap, + geo_candidates: &'ctx RoaringBitmap, field_id: u16, ascending: bool, ) -> crate::Result> { - let SortedDocumentsIteratorBuilder { - index, - rtxn, - number_db, - string_db, - fields, - candidates, - geo_candidates, - } = self; let size = candidates.len() as usize; // Perform the sort on the first field @@ -248,7 +265,7 @@ impl<'ctx> SortedDocumentsIteratorBuilder<'ctx> { rtxn, number_db, string_db, - fields: &fields[1..], + fields: next_fields, candidates: r?, geo_candidates, }) @@ -262,22 +279,19 @@ impl<'ctx> SortedDocumentsIteratorBuilder<'ctx> { } /// Builds a [`SortedDocumentsIterator`] based on the (lazy) results of a geo sort. + #[allow(clippy::too_many_arguments)] fn build_geo( - self, + index: &'ctx crate::Index, + rtxn: &'ctx heed::RoTxn<'ctx>, + number_db: Database, FacetGroupValueCodec>, + string_db: Database, FacetGroupValueCodec>, + next_fields: &'ctx [AscDescId], + candidates: RoaringBitmap, + geo_candidates: &'ctx RoaringBitmap, field_ids: [u16; 2], target_point: [f64; 2], ascending: bool, ) -> crate::Result> { - let SortedDocumentsIteratorBuilder { - index, - rtxn, - number_db, - string_db, - fields, - candidates, - geo_candidates, - } = self; - let mut cache = VecDeque::new(); let mut rtree = None; let size = candidates.len() as usize; @@ -307,7 +321,7 @@ impl<'ctx> SortedDocumentsIteratorBuilder<'ctx> { rtxn, number_db, string_db, - fields: &fields[1..], + fields: next_fields, candidates: docids, geo_candidates, })); @@ -322,7 +336,7 @@ impl<'ctx> SortedDocumentsIteratorBuilder<'ctx> { rtxn, number_db, string_db, - fields: &fields[1..], + fields: next_fields, candidates: not_geo_candidates, geo_candidates, })); From 1bc30cb4c8c5f89fa75e80d83b427ed54fe40e29 Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Tue, 15 Jul 2025 17:34:04 +0200 Subject: [PATCH 77/81] Restore old benchmark names --- crates/benchmarks/benches/utils.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/crates/benchmarks/benches/utils.rs b/crates/benchmarks/benches/utils.rs index 93fa7506f..8eb3c344a 100644 --- a/crates/benchmarks/benches/utils.rs +++ b/crates/benchmarks/benches/utils.rs @@ -160,11 +160,9 @@ pub fn run_benches(c: &mut criterion::Criterion, confs: &[Conf]) { for &query in conf.queries { for offset in conf.offsets { - let parameter = match (query.is_empty(), offset) { - (true, None) => String::from("placeholder"), - (true, Some((offset, limit))) => format!("placeholder[{offset}:{limit}]"), - (false, None) => query.to_string(), - (false, Some((offset, limit))) => format!("{query}[{offset}:{limit}]"), + let parameter = match offset { + None => query.to_string(), + Some((offset, limit)) => format!("{query}[{offset}:{limit}]"), }; group.bench_with_input( BenchmarkId::from_parameter(parameter), From d6bd60d569d4a07578c4891542756bd0cf38705a Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Tue, 15 Jul 2025 18:00:37 +0200 Subject: [PATCH 78/81] Apply review suggestions Co-Authored-By: Louis Dureuil --- crates/milli/src/search/new/bucket_sort.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/crates/milli/src/search/new/bucket_sort.rs b/crates/milli/src/search/new/bucket_sort.rs index 298983091..645d36e16 100644 --- a/crates/milli/src/search/new/bucket_sort.rs +++ b/crates/milli/src/search/new/bucket_sort.rs @@ -161,12 +161,13 @@ pub fn bucket_sort<'ctx, Q: RankingRuleQueryTrait>( }; } - let max_total_hits = max_total_hits.unwrap_or(usize::MAX); - while valid_docids.len() < length - || (exhaustive_number_hits - && ranking_score_threshold.is_some() - && valid_docids.len() < max_total_hits) - { + let max_len_to_evaluate = + match (max_total_hits, exhaustive_number_hits && ranking_score_threshold.is_some()) { + (Some(max_total_hits), true) => max_total_hits, + _ => length, + }; + + while valid_docids.len() < max_len_to_evaluate { if time_budget.exceeded() { loop { let bucket = std::mem::take(&mut ranking_rule_universes[cur_ranking_rule_index]); From a683faa882ae5c996650ada5d58822eb3f71e226 Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Wed, 16 Jul 2025 11:03:24 +0200 Subject: [PATCH 79/81] Apply review suggestions --- crates/meilisearch/tests/common/mod.rs | 173 +++++++++++++++++- crates/meilisearch/tests/common/server.rs | 4 +- crates/meilisearch/tests/vector/fragments.rs | 175 +------------------ 3 files changed, 177 insertions(+), 175 deletions(-) diff --git a/crates/meilisearch/tests/common/mod.rs b/crates/meilisearch/tests/common/mod.rs index 1a73a7532..1345fa197 100644 --- a/crates/meilisearch/tests/common/mod.rs +++ b/crates/meilisearch/tests/common/mod.rs @@ -3,8 +3,12 @@ pub mod index; pub mod server; pub mod service; -use std::fmt::{self, Display}; +use std::{ + collections::BTreeMap, + fmt::{self, Display}, +}; +use actix_http::StatusCode; #[allow(unused)] pub use index::GetAllDocumentsOptions; use meili_snap::json_string; @@ -13,6 +17,10 @@ use serde::{Deserialize, Serialize}; #[allow(unused)] pub use server::{default_settings, Server}; use tokio::sync::OnceCell; +use wiremock::{ + matchers::{method, path}, + Mock, MockServer, Request, ResponseTemplate, +}; use crate::common::index::Index; @@ -508,3 +516,166 @@ pub async fn shared_index_with_geo_documents() -> &'static Index<'static, Shared }) .await } + +pub async fn shared_index_for_fragments() -> Index<'static, Shared> { + static INDEX: OnceCell<(Server, String)> = OnceCell::const_new(); + let (server, uid) = INDEX + .get_or_init(|| async { + let (server, uid, _) = init_fragments_index().await; + (server.into_shared(), uid) + }) + .await; + server._index(uid).to_shared() +} + +async fn fragment_mock_server() -> String { + let text_to_embedding: BTreeMap<_, _> = vec![ + ("kefir", [0.5, -0.5, 0.0]), + ("intel", [1.0, 1.0, 0.0]), + ("dustin", [-0.5, 0.5, 0.0]), + ("bulldog", [0.0, 0.0, 1.0]), + ("labrador", [0.0, 0.0, -1.0]), + ("{{ doc.", [-9999.0, -9999.0, -9999.0]), // If a template didn't render + ] + .into_iter() + .collect(); + + let mock_server = Box::leak(Box::new(MockServer::start().await)); + + Mock::given(method("POST")) + .and(path("/")) + .respond_with(move |req: &Request| { + let text = String::from_utf8_lossy(&req.body).to_string(); + + let mut data = [0.0, 0.0, 0.0]; + for (inner_text, inner_data) in &text_to_embedding { + if text.contains(inner_text) { + for (i, &value) in inner_data.iter().enumerate() { + data[i] += value; + } + } + } + ResponseTemplate::new(200).set_body_json(json!({ "data": data })) + }) + .mount(mock_server) + .await; + + mock_server.uri() +} + +pub async fn init_fragments_index() -> (Server, String, crate::common::Value) { + let url = fragment_mock_server().await; + let server = Server::new().await; + let index = server.unique_index(); + + let (_response, code) = server.set_features(json!({"multimodal": true})).await; + assert_eq!(code, StatusCode::OK); + + // Configure the index to use our mock embedder + let settings = json!({ + "embedders": { + "rest": { + "source": "rest", + "url": url, + "dimensions": 3, + "request": "{{fragment}}", + "response": { + "data": "{{embedding}}" + }, + "indexingFragments": { + "withBreed": {"value": "{{ doc.name }} is a {{ doc.breed }}"}, + "basic": {"value": "{{ doc.name }} is a dog"}, + }, + "searchFragments": { + "justBreed": {"value": "It's a {{ media.breed }}"}, + "justName": {"value": "{{ media.name }} is a dog"}, + "query": {"value": "Some pre-prompt for query {{ q }}"}, + } + }, + }, + }); + let (response, code) = index.update_settings(settings.clone()).await; + assert_eq!(code, StatusCode::ACCEPTED); + + server.wait_task(response.uid()).await.succeeded(); + + // Send documents + let documents = json!([ + {"id": 0, "name": "kefir"}, + {"id": 1, "name": "echo", "_vectors": { "rest": [1, 1, 1] }}, + {"id": 2, "name": "intel", "breed": "labrador"}, + {"id": 3, "name": "dustin", "breed": "bulldog"}, + ]); + let (value, code) = index.add_documents(documents, None).await; + assert_eq!(code, StatusCode::ACCEPTED); + + let _task = index.wait_task(value.uid()).await.succeeded(); + + let uid = index.uid.clone(); + (server, uid, settings) +} + +pub async fn init_fragments_index_composite() -> (Server, String, crate::common::Value) { + let url = fragment_mock_server().await; + let server = Server::new().await; + let index = server.unique_index(); + + let (_response, code) = server.set_features(json!({"multimodal": true})).await; + assert_eq!(code, StatusCode::OK); + + let (_response, code) = server.set_features(json!({"compositeEmbedders": true})).await; + assert_eq!(code, StatusCode::OK); + + // Configure the index to use our mock embedder + let settings = json!({ + "embedders": { + "rest": { + "source": "composite", + "searchEmbedder": { + "source": "rest", + "url": url, + "dimensions": 3, + "request": "{{fragment}}", + "response": { + "data": "{{embedding}}" + }, + "searchFragments": { + "query": {"value": "Some pre-prompt for query {{ q }}"}, + } + }, + "indexingEmbedder": { + "source": "rest", + "url": url, + "dimensions": 3, + "request": "{{fragment}}", + "response": { + "data": "{{embedding}}" + }, + "indexingFragments": { + "withBreed": {"value": "{{ doc.name }} is a {{ doc.breed }}"}, + "basic": {"value": "{{ doc.name }} is a dog"}, + } + }, + }, + }, + }); + let (response, code) = index.update_settings(settings.clone()).await; + assert_eq!(code, StatusCode::ACCEPTED); + + server.wait_task(response.uid()).await.succeeded(); + + // Send documents + let documents = json!([ + {"id": 0, "name": "kefir"}, + {"id": 1, "name": "echo", "_vectors": { "rest": [1, 1, 1] }}, + {"id": 2, "name": "intel", "breed": "labrador"}, + {"id": 3, "name": "dustin", "breed": "bulldog"}, + ]); + let (value, code) = index.add_documents(documents, None).await; + assert_eq!(code, StatusCode::ACCEPTED); + + index.wait_task(value.uid()).await.succeeded(); + + let uid = index.uid.clone(); + (server, uid, settings) +} diff --git a/crates/meilisearch/tests/common/server.rs b/crates/meilisearch/tests/common/server.rs index e3839855b..390652340 100644 --- a/crates/meilisearch/tests/common/server.rs +++ b/crates/meilisearch/tests/common/server.rs @@ -35,7 +35,7 @@ pub struct Server { pub static TEST_TEMP_DIR: Lazy = Lazy::new(|| TempDir::new().unwrap()); impl Server { - pub fn into_shared(self) -> Server { + pub(super) fn into_shared(self) -> Server { Server { service: self.service, _dir: self._dir, _marker: PhantomData } } @@ -327,7 +327,7 @@ impl Server { self.service.get(url).await } - pub fn _index(&self, uid: impl AsRef) -> Index<'_> { + pub(super) fn _index(&self, uid: impl AsRef) -> Index<'_> { Index { uid: uid.as_ref().to_string(), service: &self.service, diff --git a/crates/meilisearch/tests/vector/fragments.rs b/crates/meilisearch/tests/vector/fragments.rs index 4fe2bddb6..a994eb64c 100644 --- a/crates/meilisearch/tests/vector/fragments.rs +++ b/crates/meilisearch/tests/vector/fragments.rs @@ -1,180 +1,11 @@ -use std::collections::BTreeMap; - use meili_snap::{json_string, snapshot}; -use tokio::sync::OnceCell; -use wiremock::matchers::{method, path}; -use wiremock::{Mock, MockServer, Request, ResponseTemplate}; -use crate::common::index::Index; -use crate::common::{Owned, Shared}; +use crate::common::{ + init_fragments_index, init_fragments_index_composite, shared_index_for_fragments, +}; use crate::json; use crate::vector::{GetAllDocumentsOptions, Server}; -async fn shared_index_for_fragments() -> Index<'static, Shared> { - static INDEX: OnceCell<(Server, String)> = OnceCell::const_new(); - let (server, uid) = INDEX - .get_or_init(|| async { - let (server, uid, _) = init_fragments_index().await; - (server.into_shared(), uid) - }) - .await; - server._index(uid).to_shared() -} - -async fn fragment_mock_server() -> String { - let text_to_embedding: BTreeMap<_, _> = vec![ - ("kefir", [0.5, -0.5, 0.0]), - ("intel", [1.0, 1.0, 0.0]), - ("dustin", [-0.5, 0.5, 0.0]), - ("bulldog", [0.0, 0.0, 1.0]), - ("labrador", [0.0, 0.0, -1.0]), - ("{{ doc.", [-9999.0, -9999.0, -9999.0]), // If a template didn't render - ] - .into_iter() - .collect(); - - let mock_server = Box::leak(Box::new(MockServer::start().await)); - - Mock::given(method("POST")) - .and(path("/")) - .respond_with(move |req: &Request| { - let text = String::from_utf8_lossy(&req.body).to_string(); - - let mut data = [0.0, 0.0, 0.0]; - for (inner_text, inner_data) in &text_to_embedding { - if text.contains(inner_text) { - for (i, &value) in inner_data.iter().enumerate() { - data[i] += value; - } - } - } - ResponseTemplate::new(200).set_body_json(json!({ "data": data })) - }) - .mount(mock_server) - .await; - - mock_server.uri() -} - -pub async fn init_fragments_index() -> (Server, String, crate::common::Value) { - let url = fragment_mock_server().await; - let server = Server::new().await; - let index = server.unique_index(); - - let (_response, code) = server.set_features(json!({"multimodal": true})).await; - snapshot!(code, @"200 OK"); - - // Configure the index to use our mock embedder - let settings = json!({ - "embedders": { - "rest": { - "source": "rest", - "url": url, - "dimensions": 3, - "request": "{{fragment}}", - "response": { - "data": "{{embedding}}" - }, - "indexingFragments": { - "withBreed": {"value": "{{ doc.name }} is a {{ doc.breed }}"}, - "basic": {"value": "{{ doc.name }} is a dog"}, - }, - "searchFragments": { - "justBreed": {"value": "It's a {{ media.breed }}"}, - "justName": {"value": "{{ media.name }} is a dog"}, - "query": {"value": "Some pre-prompt for query {{ q }}"}, - } - }, - }, - }); - let (response, code) = index.update_settings(settings.clone()).await; - snapshot!(code, @"202 Accepted"); - - server.wait_task(response.uid()).await.succeeded(); - - // Send documents - let documents = json!([ - {"id": 0, "name": "kefir"}, - {"id": 1, "name": "echo", "_vectors": { "rest": [1, 1, 1] }}, - {"id": 2, "name": "intel", "breed": "labrador"}, - {"id": 3, "name": "dustin", "breed": "bulldog"}, - ]); - let (value, code) = index.add_documents(documents, None).await; - snapshot!(code, @"202 Accepted"); - - let task = index.wait_task(value.uid()).await; - snapshot!(task["status"], @r###""succeeded""###); - - let uid = index.uid.clone(); - (server, uid, settings) -} - -pub async fn init_fragments_index_composite() -> (Server, String, crate::common::Value) { - let url = fragment_mock_server().await; - let server = Server::new().await; - let index = server.unique_index(); - - let (_response, code) = server.set_features(json!({"multimodal": true})).await; - snapshot!(code, @"200 OK"); - - let (_response, code) = server.set_features(json!({"compositeEmbedders": true})).await; - snapshot!(code, @"200 OK"); - - // Configure the index to use our mock embedder - let settings = json!({ - "embedders": { - "rest": { - "source": "composite", - "searchEmbedder": { - "source": "rest", - "url": url, - "dimensions": 3, - "request": "{{fragment}}", - "response": { - "data": "{{embedding}}" - }, - "searchFragments": { - "query": {"value": "Some pre-prompt for query {{ q }}"}, - } - }, - "indexingEmbedder": { - "source": "rest", - "url": url, - "dimensions": 3, - "request": "{{fragment}}", - "response": { - "data": "{{embedding}}" - }, - "indexingFragments": { - "withBreed": {"value": "{{ doc.name }} is a {{ doc.breed }}"}, - "basic": {"value": "{{ doc.name }} is a dog"}, - } - }, - }, - }, - }); - let (response, code) = index.update_settings(settings.clone()).await; - println!("Update settings response: {:?}", response); - snapshot!(code, @"202 Accepted"); - - server.wait_task(response.uid()).await.succeeded(); - - // Send documents - let documents = json!([ - {"id": 0, "name": "kefir"}, - {"id": 1, "name": "echo", "_vectors": { "rest": [1, 1, 1] }}, - {"id": 2, "name": "intel", "breed": "labrador"}, - {"id": 3, "name": "dustin", "breed": "bulldog"}, - ]); - let (value, code) = index.add_documents(documents, None).await; - snapshot!(code, @"202 Accepted"); - - index.wait_task(value.uid()).await.succeeded(); - - let uid = index.uid.clone(); - (server, uid, settings) -} - #[actix_rt::test] async fn experimental_feature_not_enabled() { let server = Server::new().await; From a005a062da374e945b840cccf1938713362f405f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Renault?= Date: Thu, 17 Jul 2025 15:27:53 +0200 Subject: [PATCH 80/81] Add security if chat settings parameters are missing --- crates/meilisearch-types/src/features.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/meilisearch-types/src/features.rs b/crates/meilisearch-types/src/features.rs index 8878a8281..44a0071e4 100644 --- a/crates/meilisearch-types/src/features.rs +++ b/crates/meilisearch-types/src/features.rs @@ -162,10 +162,14 @@ impl ChatCompletionSource { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct ChatCompletionPrompts { + #[serde(default)] pub system: String, + #[serde(default)] pub search_description: String, + #[serde(default)] pub search_q_param: String, pub search_filter_param: String, + #[serde(default)] pub search_index_uid_param: String, } From f1d92bfeadd3d4ac36227633754987786ec6163e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Renault?= Date: Thu, 17 Jul 2025 15:28:18 +0200 Subject: [PATCH 81/81] Make sure the new filter chat setting is set to it's default value if missing --- crates/meilisearch-types/src/features.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/meilisearch-types/src/features.rs b/crates/meilisearch-types/src/features.rs index 44a0071e4..ddffb107c 100644 --- a/crates/meilisearch-types/src/features.rs +++ b/crates/meilisearch-types/src/features.rs @@ -168,11 +168,18 @@ pub struct ChatCompletionPrompts { pub search_description: String, #[serde(default)] pub search_q_param: String, + #[serde(default = "default_search_filter_param")] pub search_filter_param: String, #[serde(default)] pub search_index_uid_param: String, } +/// This function is used for when the search_filter_param is +/// not provided and this can happen when the database is in v1.15. +fn default_search_filter_param() -> String { + DEFAULT_CHAT_SEARCH_FILTER_PARAM_PROMPT.to_string() +} + impl Default for ChatCompletionPrompts { fn default() -> Self { Self {