From 00eb258a538ed39220c3dbb469f5c193f160cade Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Thu, 19 Jun 2025 11:16:07 +0200 Subject: [PATCH 001/135] 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 002/135] 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 003/135] 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 004/135] 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 005/135] 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 006/135] 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 007/135] 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 008/135] 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 009/135] 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 010/135] 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 011/135] 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 012/135] 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 013/135] 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 014/135] 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 015/135] 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 016/135] 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 017/135] 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 018/135] 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 019/135] 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 020/135] 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 021/135] 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 022/135] 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 023/135] 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 024/135] 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 025/135] 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 026/135] 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 027/135] 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 028/135] 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 029/135] 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 030/135] 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 031/135] 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 032/135] 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 033/135] 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 034/135] 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 035/135] 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 036/135] 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 037/135] 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 038/135] 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 039/135] 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 040/135] 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 041/135] 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 042/135] 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 043/135] 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 044/135] 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 045/135] 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 046/135] 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 8af76a65bfe4430075bded3ff95e8a343c46b2f1 Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Wed, 2 Jul 2025 18:04:44 +0200 Subject: [PATCH 047/135] Add test_fragment_indexing --- crates/meilisearch/tests/vector/fragments.rs | 198 +++++++++++++++++++ crates/meilisearch/tests/vector/mod.rs | 1 + 2 files changed, 199 insertions(+) create mode 100644 crates/meilisearch/tests/vector/fragments.rs diff --git a/crates/meilisearch/tests/vector/fragments.rs b/crates/meilisearch/tests/vector/fragments.rs new file mode 100644 index 000000000..5f6b1095e --- /dev/null +++ b/crates/meilisearch/tests/vector/fragments.rs @@ -0,0 +1,198 @@ +use std::collections::BTreeMap; + +use meili_snap::{json_string, snapshot}; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, Request, ResponseTemplate}; + +use crate::common::Value; +use crate::json; +use crate::vector::{get_server_vector, GetAllDocumentsOptions}; + +async fn create_mock(indexing_fragments: Value, search_fragments: Value) -> (MockServer, Value) { + let mock_server = MockServer::start().await; + + let text_to_embedding: BTreeMap<_, _> = vec![ + ("kefir", [0.5, -0.5, 2.0]), + ("intel", [1.0, 1.0, 1.0]), + ("bulldog", [1.5, -2.5, 0.0]), + ("dustin", [-0.5, 0.5, 2.5]), + ("labrador", [-3.5, 0.5, -1.0]), + ] + .into_iter() + .collect(); + + 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; + let url = mock_server.uri(); + + let embedder_settings = json!({ + "source": "rest", + "url": url, + "dimensions": 3, + "request": "{{fragment}}", + "response": { + "data": "{{embedding}}" + }, + "indexingFragments": indexing_fragments, + "searchFragments": search_fragments, + "documentTemplate": "document template: {{dog.name}}", + }); + + (mock_server, embedder_settings) +} + + +#[actix_rt::test] +async fn test_fragment_indexing() { + let (_mock, settings) = create_mock( + json!({ + "withBreed": {"value": "{{ doc.name }} is a {{ doc.breed }}"}, + "basic": {"value": "{{ doc.name }} is a dog"}, + }), + json!({ + "withBreed": {"value": "{{ doc.name }} is a {{ doc.breed }}"}, + "basic": {"value": "{{ doc.name }} is a dog"}, + }) + ).await; + let server = get_server_vector().await; + let index = server.index("doggo"); + + // Enable the experimental feature + let (_response, code) = server.set_features(json!({"multimodal": true})).await; + snapshot!(code, @"200 OK"); + + // Configure the index to use our mock embedder + let (response, code) = index + .update_settings(json!({ + "embedders": { + "rest": settings, + }, + })) + .await; + snapshot!(code, @"202 Accepted"); + + let task = server.wait_task(response.uid()).await; + println!("[task] {:?}", task); + snapshot!(task["status"], @r###""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""###); + + // Make sure the documents have been indexed and their embeddings retrieved + let (documents, code) = index + .get_all_documents(GetAllDocumentsOptions { retrieve_vectors: true, ..Default::default() }) + .await; + snapshot!(code, @"200 OK"); + snapshot!(json_string!(documents), @r#" + { + "results": [ + { + "id": 0, + "name": "kefir", + "_vectors": { + "rest": { + "embeddings": [ + [ + 0.5, + -0.5, + 2.0 + ] + ], + "regenerate": true + } + } + }, + { + "id": 1, + "name": "echo", + "_vectors": { + "rest": { + "embeddings": [ + [ + 1.0, + 1.0, + 1.0 + ] + ], + "regenerate": false + } + } + }, + { + "id": 2, + "name": "intel", + "breed": "labrador", + "_vectors": { + "rest": { + "embeddings": [ + [ + 1.0, + 1.0, + 1.0 + ], + [ + -2.5, + 1.5, + 0.0 + ] + ], + "regenerate": true + } + } + }, + { + "id": 3, + "name": "dustin", + "breed": "bulldog", + "_vectors": { + "rest": { + "embeddings": [ + [ + -0.5, + 0.5, + 2.5 + ], + [ + 1.0, + -2.0, + 2.5 + ] + ], + "regenerate": true + } + } + } + ], + "offset": 0, + "limit": 20, + "total": 4 + } + "#); +} + diff --git a/crates/meilisearch/tests/vector/mod.rs b/crates/meilisearch/tests/vector/mod.rs index 98555dfac..837c34289 100644 --- a/crates/meilisearch/tests/vector/mod.rs +++ b/crates/meilisearch/tests/vector/mod.rs @@ -4,6 +4,7 @@ mod ollama; mod openai; mod rest; mod settings; +mod fragments; use std::str::FromStr; From 65ba7b47af77015d1baab22ef61e2693c13cc74c Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Thu, 3 Jul 2025 10:43:27 +0200 Subject: [PATCH 048/135] Test search fragments --- crates/meilisearch/tests/vector/fragments.rs | 169 ++++++++++++++++++- 1 file changed, 164 insertions(+), 5 deletions(-) diff --git a/crates/meilisearch/tests/vector/fragments.rs b/crates/meilisearch/tests/vector/fragments.rs index 5f6b1095e..876e18ffe 100644 --- a/crates/meilisearch/tests/vector/fragments.rs +++ b/crates/meilisearch/tests/vector/fragments.rs @@ -12,11 +12,11 @@ async fn create_mock(indexing_fragments: Value, search_fragments: Value) -> (Moc let mock_server = MockServer::start().await; let text_to_embedding: BTreeMap<_, _> = vec![ - ("kefir", [0.5, -0.5, 2.0]), - ("intel", [1.0, 1.0, 1.0]), - ("bulldog", [1.5, -2.5, 0.0]), - ("dustin", [-0.5, 0.5, 2.5]), - ("labrador", [-3.5, 0.5, -1.0]), + ("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]), ] .into_iter() .collect(); @@ -196,3 +196,162 @@ async fn test_fragment_indexing() { "#); } +#[actix_rt::test] +async fn test_search_fragments() { + let (_mock, settings) = create_mock( + json!({ + "withBreed": {"value": "{{ doc.name }} is a {{ doc.breed }}"}, + "basic": {"value": "{{ doc.name }} is a dog"}, + }), + json!({ + "justBreed": {"value": "It's a {{ media.breed }}"}, + "justName": {"value": "{{ media.name }} is a dog"}, + "query": {"value": "Some pre-prompt for query {{ q }}"}, + }) + ).await; + let server = get_server_vector().await; + let index = server.index("doggo"); + + // Enable the experimental feature + let (_response, code) = server.set_features(json!({"multimodal": true})).await; + snapshot!(code, @"200 OK"); + + // Configure the index to use our mock embedder + let (response, code) = index + .update_settings(json!({ + "embedders": { + "rest": settings, + }, + })) + .await; + snapshot!(code, @"202 Accepted"); + + let task = server.wait_task(response.uid()).await; + snapshot!(task["status"], @r###""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""###); + + // Perform a search with a provided vector + let (value, code) = index.search_post( + json!({"vector": [1.0, 1.0, 1.0], "hybrid": {"semanticRatio": 1.0, "embedder": "rest"}, "limit": 1} + )).await; + snapshot!(code, @"200 OK"); + snapshot!(value, @r#" + { + "hits": [ + { + "id": 1, + "name": "echo" + } + ], + "query": "", + "processingTimeMs": "[duration]", + "limit": 1, + "offset": 0, + "estimatedTotalHits": 4, + "semanticHitCount": 1 + } + "#); + + // Perform a search with some media + let (value, code) = index.search_post( + json!({ + "media": { "breed": "labrador" }, + "hybrid": {"semanticRatio": 1.0, "embedder": "rest"}, + "limit": 1 + } + )).await; + snapshot!(code, @"200 OK"); + snapshot!(value, @r#" + { + "hits": [ + { + "id": 2, + "name": "intel", + "breed": "labrador" + } + ], + "query": "", + "processingTimeMs": "[duration]", + "limit": 1, + "offset": 0, + "estimatedTotalHits": 4, + "semanticHitCount": 1 + } + "#); + + // Perform a search that matches multiple media + let (value, code) = index.search_post( + json!({ + "media": { "name": "dustin", "breed": "labrador" }, + "hybrid": {"semanticRatio": 1.0, "embedder": "rest"}, + "limit": 1 + } + )).await; + snapshot!(code, @"400 Bad Request"); + snapshot!(value, @r#" + { + "message": "Error while generating embeddings: user error: Query matches multiple search fragments.\n - Note: First matched fragment `justBreed`.\n - Note: Second matched fragment `justName`.\n - Note: {\"q\":null,\"media\":{\"name\":\"dustin\",\"breed\":\"labrador\"}}", + "code": "vector_embedding_error", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#vector_embedding_error" + } + "#); + + // Perform a search that matches no media + let (value, code) = index.search_post( + json!({ + "media": { "ticker": "GME", "section": "portfolio" }, + "hybrid": {"semanticRatio": 1.0, "embedder": "rest"}, + "limit": 1 + } + )).await; + snapshot!(code, @"400 Bad Request"); + snapshot!(value, @r#" + { + "message": "Error while generating embeddings: user error: Query matches no search fragment.\n - Note: {\"q\":null,\"media\":{\"ticker\":\"GME\",\"section\":\"portfolio\"}}", + "code": "vector_embedding_error", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#vector_embedding_error" + } + "#); + + // Perform a search with a query media + let (value, code) = index.search_post( + json!({ + "q": "bulldog", + "hybrid": {"semanticRatio": 1.0, "embedder": "rest"}, + "limit": 1 + } + )).await; + snapshot!(code, @"200 OK"); + snapshot!(value, @r#" + { + "hits": [ + { + "id": 3, + "name": "dustin", + "breed": "bulldog" + } + ], + "query": "bulldog", + "processingTimeMs": "[duration]", + "limit": 1, + "offset": 0, + "estimatedTotalHits": 4, + "semanticHitCount": 1 + } + "#); +} + From 0b89ef1fd7c11c2909bc906911394bdc9ffd10fc Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Thu, 3 Jul 2025 11:24:09 +0200 Subject: [PATCH 049/135] Make tests use a shared index --- crates/meilisearch/tests/common/server.rs | 4 +- crates/meilisearch/tests/vector/fragments.rs | 261 +++++++++---------- 2 files changed, 128 insertions(+), 137 deletions(-) diff --git a/crates/meilisearch/tests/common/server.rs b/crates/meilisearch/tests/common/server.rs index 4367650c5..e3839855b 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 { - fn into_shared(self) -> Server { + pub 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(super) fn _index(&self, uid: impl AsRef) -> Index<'_> { + pub 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 876e18ffe..027a4069d 100644 --- a/crates/meilisearch/tests/vector/fragments.rs +++ b/crates/meilisearch/tests/vector/fragments.rs @@ -1,13 +1,73 @@ 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::Shared; use crate::common::Value; use crate::json; +use crate::vector::Server; use crate::vector::{get_server_vector, GetAllDocumentsOptions}; +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 (_mock, settings) = create_mock( + json!({ + "withBreed": {"value": "{{ doc.name }} is a {{ doc.breed }}"}, + "basic": {"value": "{{ doc.name }} is a dog"}, + }), + json!({ + "justBreed": {"value": "It's a {{ media.breed }}"}, + "justName": {"value": "{{ media.name }} is a dog"}, + "query": {"value": "Some pre-prompt for query {{ q }}"}, + }), + ) + .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 (response, code) = index + .update_settings(json!({ + "embedders": { + "rest": settings, + }, + })) + .await; + snapshot!(code, @"202 Accepted"); + + let task = server.wait_task(response.uid()).await; + snapshot!(task["status"], @r###""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.into_shared(), uid) + }) + .await; + server._index(uid).to_shared() +} + async fn create_mock(indexing_fragments: Value, search_fragments: Value) -> (MockServer, Value) { let mock_server = MockServer::start().await; @@ -33,9 +93,7 @@ async fn create_mock(indexing_fragments: Value, search_fragments: Value) -> (Moc } } } - ResponseTemplate::new(200).set_body_json( - json!({ "data": data }) - ) + ResponseTemplate::new(200).set_body_json(json!({ "data": data })) }) .mount(&mock_server) .await; @@ -57,52 +115,9 @@ async fn create_mock(indexing_fragments: Value, search_fragments: Value) -> (Moc (mock_server, embedder_settings) } - #[actix_rt::test] -async fn test_fragment_indexing() { - let (_mock, settings) = create_mock( - json!({ - "withBreed": {"value": "{{ doc.name }} is a {{ doc.breed }}"}, - "basic": {"value": "{{ doc.name }} is a dog"}, - }), - json!({ - "withBreed": {"value": "{{ doc.name }} is a {{ doc.breed }}"}, - "basic": {"value": "{{ doc.name }} is a dog"}, - }) - ).await; - let server = get_server_vector().await; - let index = server.index("doggo"); - - // Enable the experimental feature - let (_response, code) = server.set_features(json!({"multimodal": true})).await; - snapshot!(code, @"200 OK"); - - // Configure the index to use our mock embedder - let (response, code) = index - .update_settings(json!({ - "embedders": { - "rest": settings, - }, - })) - .await; - snapshot!(code, @"202 Accepted"); - - let task = server.wait_task(response.uid()).await; - println!("[task] {:?}", task); - snapshot!(task["status"], @r###""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""###); +async fn indexing_fragments() { + let index = shared_index_for_fragments().await; // Make sure the documents have been indexed and their embeddings retrieved let (documents, code) = index @@ -121,7 +136,7 @@ async fn test_fragment_indexing() { [ 0.5, -0.5, - 2.0 + 0.0 ] ], "regenerate": true @@ -154,12 +169,12 @@ async fn test_fragment_indexing() { [ 1.0, 1.0, - 1.0 + 0.0 ], [ - -2.5, - 1.5, - 0.0 + 1.0, + 1.0, + -1.0 ] ], "regenerate": true @@ -176,12 +191,12 @@ async fn test_fragment_indexing() { [ -0.5, 0.5, - 2.5 + 0.0 ], [ - 1.0, - -2.0, - 2.5 + -0.5, + 0.5, + 1.0 ] ], "regenerate": true @@ -197,52 +212,9 @@ async fn test_fragment_indexing() { } #[actix_rt::test] -async fn test_search_fragments() { - let (_mock, settings) = create_mock( - json!({ - "withBreed": {"value": "{{ doc.name }} is a {{ doc.breed }}"}, - "basic": {"value": "{{ doc.name }} is a dog"}, - }), - json!({ - "justBreed": {"value": "It's a {{ media.breed }}"}, - "justName": {"value": "{{ media.name }} is a dog"}, - "query": {"value": "Some pre-prompt for query {{ q }}"}, - }) - ).await; - let server = get_server_vector().await; - let index = server.index("doggo"); +async fn search_with_vector() { + let index = shared_index_for_fragments().await; - // Enable the experimental feature - let (_response, code) = server.set_features(json!({"multimodal": true})).await; - snapshot!(code, @"200 OK"); - - // Configure the index to use our mock embedder - let (response, code) = index - .update_settings(json!({ - "embedders": { - "rest": settings, - }, - })) - .await; - snapshot!(code, @"202 Accepted"); - - let task = server.wait_task(response.uid()).await; - snapshot!(task["status"], @r###""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""###); - - // Perform a search with a provided vector let (value, code) = index.search_post( json!({"vector": [1.0, 1.0, 1.0], "hybrid": {"semanticRatio": 1.0, "embedder": "rest"}, "limit": 1} )).await; @@ -263,15 +235,20 @@ async fn test_search_fragments() { "semanticHitCount": 1 } "#); +} - // Perform a search with some media - let (value, code) = index.search_post( - json!({ - "media": { "breed": "labrador" }, - "hybrid": {"semanticRatio": 1.0, "embedder": "rest"}, - "limit": 1 - } - )).await; +#[actix_rt::test] +async fn search_with_media() { + let index = shared_index_for_fragments().await; + + let (value, code) = index + .search_post(json!({ + "media": { "breed": "labrador" }, + "hybrid": {"semanticRatio": 1.0, "embedder": "rest"}, + "limit": 1 + } + )) + .await; snapshot!(code, @"200 OK"); snapshot!(value, @r#" { @@ -290,15 +267,20 @@ async fn test_search_fragments() { "semanticHitCount": 1 } "#); +} - // Perform a search that matches multiple media - let (value, code) = index.search_post( - json!({ - "media": { "name": "dustin", "breed": "labrador" }, - "hybrid": {"semanticRatio": 1.0, "embedder": "rest"}, - "limit": 1 - } - )).await; +#[actix_rt::test] +async fn search_with_media_matching_multiple_fragments() { + let index = shared_index_for_fragments().await; + + let (value, code) = index + .search_post(json!({ + "media": { "name": "dustin", "breed": "labrador" }, + "hybrid": {"semanticRatio": 1.0, "embedder": "rest"}, + "limit": 1 + } + )) + .await; snapshot!(code, @"400 Bad Request"); snapshot!(value, @r#" { @@ -308,15 +290,20 @@ async fn test_search_fragments() { "link": "https://docs.meilisearch.com/errors#vector_embedding_error" } "#); +} - // Perform a search that matches no media - let (value, code) = index.search_post( - json!({ - "media": { "ticker": "GME", "section": "portfolio" }, - "hybrid": {"semanticRatio": 1.0, "embedder": "rest"}, - "limit": 1 - } - )).await; +#[actix_rt::test] +async fn search_with_media_matching_no_fragment() { + let index = shared_index_for_fragments().await; + + let (value, code) = index + .search_post(json!({ + "media": { "ticker": "GME", "section": "portfolio" }, + "hybrid": {"semanticRatio": 1.0, "embedder": "rest"}, + "limit": 1 + } + )) + .await; snapshot!(code, @"400 Bad Request"); snapshot!(value, @r#" { @@ -326,15 +313,20 @@ async fn test_search_fragments() { "link": "https://docs.meilisearch.com/errors#vector_embedding_error" } "#); +} - // Perform a search with a query media - let (value, code) = index.search_post( - json!({ - "q": "bulldog", - "hybrid": {"semanticRatio": 1.0, "embedder": "rest"}, - "limit": 1 - } - )).await; +#[actix_rt::test] +async fn search_with_query() { + let index = shared_index_for_fragments().await; + + let (value, code) = index + .search_post(json!({ + "q": "bulldog", + "hybrid": {"semanticRatio": 1.0, "embedder": "rest"}, + "limit": 1 + } + )) + .await; snapshot!(code, @"200 OK"); snapshot!(value, @r#" { @@ -354,4 +346,3 @@ async fn test_search_fragments() { } "#); } - From b45eea0d3e101938ed7eaa58c839b754922504d4 Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Thu, 3 Jul 2025 13:26:44 +0200 Subject: [PATCH 050/135] Add test for fragment deletion --- crates/meilisearch/tests/common/mod.rs | 2 +- crates/meilisearch/tests/vector/fragments.rs | 258 ++++++++++++++----- 2 files changed, 196 insertions(+), 64 deletions(-) diff --git a/crates/meilisearch/tests/common/mod.rs b/crates/meilisearch/tests/common/mod.rs index 1a73a7532..2fafbd11f 100644 --- a/crates/meilisearch/tests/common/mod.rs +++ b/crates/meilisearch/tests/common/mod.rs @@ -3,7 +3,7 @@ pub mod index; pub mod server; pub mod service; -use std::fmt::{self, Display}; +use std::{fmt::{self, Display}, future::Future}; #[allow(unused)] pub use index::GetAllDocumentsOptions; diff --git a/crates/meilisearch/tests/vector/fragments.rs b/crates/meilisearch/tests/vector/fragments.rs index 027a4069d..c3fef8c79 100644 --- a/crates/meilisearch/tests/vector/fragments.rs +++ b/crates/meilisearch/tests/vector/fragments.rs @@ -6,69 +6,23 @@ use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, Request, ResponseTemplate}; use crate::common::index::Index; -use crate::common::Shared; -use crate::common::Value; +use crate::common::{Owned, Shared}; use crate::json; use crate::vector::Server; -use crate::vector::{get_server_vector, GetAllDocumentsOptions}; +use crate::vector::GetAllDocumentsOptions; 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 (_mock, settings) = create_mock( - json!({ - "withBreed": {"value": "{{ doc.name }} is a {{ doc.breed }}"}, - "basic": {"value": "{{ doc.name }} is a dog"}, - }), - json!({ - "justBreed": {"value": "It's a {{ media.breed }}"}, - "justName": {"value": "{{ media.name }} is a dog"}, - "query": {"value": "Some pre-prompt for query {{ q }}"}, - }), - ) - .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 (response, code) = index - .update_settings(json!({ - "embedders": { - "rest": settings, - }, - })) - .await; - snapshot!(code, @"202 Accepted"); - - let task = server.wait_task(response.uid()).await; - snapshot!(task["status"], @r###""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(); + let (server, uid, _) = init_fragments_index().await; (server.into_shared(), uid) }) .await; server._index(uid).to_shared() } -async fn create_mock(indexing_fragments: Value, search_fragments: Value) -> (MockServer, Value) { +pub async fn init_fragments_index() -> (Server, String, crate::common::Value) { let mock_server = MockServer::start().await; let text_to_embedding: BTreeMap<_, _> = vec![ @@ -99,22 +53,62 @@ async fn create_mock(indexing_fragments: Value, search_fragments: Value) -> (Moc .await; let url = mock_server.uri(); - let embedder_settings = json!({ - "source": "rest", - "url": url, - "dimensions": 3, - "request": "{{fragment}}", - "response": { - "data": "{{embedding}}" - }, - "indexingFragments": indexing_fragments, - "searchFragments": search_fragments, - "documentTemplate": "document template: {{dog.name}}", - }); + let server = Server::new().await; + let index = server.unique_index(); - (mock_server, embedder_settings) + 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"); + + let task = server.wait_task(response.uid()).await; + snapshot!(task["status"], @r###""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) } +// TODO: Test cannot pass both fragments and document + #[actix_rt::test] async fn indexing_fragments() { let index = shared_index_for_fragments().await; @@ -346,3 +340,141 @@ async fn search_with_query() { } "#); } + +#[actix_rt::test] +async fn deleting_fragments_deletes_vectors() { + let (server, uid, mut settings) = init_fragments_index().await; + let index = server.index(uid); + + settings["embedders"]["rest"]["indexingFragments"]["basic"] = serde_json::Value::Null; + + let (documents, code) = index + .get_all_documents(GetAllDocumentsOptions { retrieve_vectors: true, ..Default::default() }) + .await; + println!("Documents before update: {documents:?}"); + + let (response, code) = index + .update_settings(settings) + .await; + snapshot!(code, @"202 Accepted"); + let value = server.wait_task(response.uid()).await.succeeded(); + snapshot!(value, @r#" + { + "uid": "[uid]", + "batchUid": "[batch_uid]", + "indexUid": "[uuid]", + "status": "succeeded", + "type": "settingsUpdate", + "canceledBy": null, + "details": { + "embedders": { + "rest": { + "source": "rest", + "dimensions": 3, + "url": "[url]", + "indexingFragments": { + "basic": null, + "withBreed": { + "value": "{{ doc.name }} is a {{ doc.breed }}" + } + }, + "searchFragments": { + "justBreed": { + "value": "It's a {{ media.breed }}" + }, + "justName": { + "value": "{{ media.name }} is a dog" + }, + "query": { + "value": "Some pre-prompt for query {{ q }}" + } + }, + "request": "{{fragment}}", + "response": { + "data": "{{embedding}}" + } + } + } + }, + "error": null, + "duration": "[duration]", + "enqueuedAt": "[date]", + "startedAt": "[date]", + "finishedAt": "[date]" + } + "#); + + let (documents, code) = index + .get_all_documents(GetAllDocumentsOptions { retrieve_vectors: true, ..Default::default() }) + .await; + snapshot!(code, @"200 OK"); + snapshot!(json_string!(documents), @r#" + { + "results": [ + { + "id": 0, + "name": "kefir", + "_vectors": { + "rest": { + "embeddings": [], + "regenerate": true + } + } + }, + { + "id": 1, + "name": "echo", + "_vectors": { + "rest": { + "embeddings": [ + [ + 1.0, + 1.0, + 1.0 + ] + ], + "regenerate": false + } + } + }, + { + "id": 2, + "name": "intel", + "breed": "labrador", + "_vectors": { + "rest": { + "embeddings": [ + [ + 1.0, + 1.0, + 0.0 + ] + ], + "regenerate": true + } + } + }, + { + "id": 3, + "name": "dustin", + "breed": "bulldog", + "_vectors": { + "rest": { + "embeddings": [ + [ + -0.5, + 0.5, + 0.0 + ] + ], + "regenerate": true + } + } + } + ], + "offset": 0, + "limit": 20, + "total": 4 + } + "#); +} From 5c792737486899d2af03aaf7cb9e0acba2fcc883 Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Thu, 3 Jul 2025 14:42:49 +0200 Subject: [PATCH 051/135] Add TODOs --- crates/meilisearch/tests/vector/fragments.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/meilisearch/tests/vector/fragments.rs b/crates/meilisearch/tests/vector/fragments.rs index c3fef8c79..338f9e7c5 100644 --- a/crates/meilisearch/tests/vector/fragments.rs +++ b/crates/meilisearch/tests/vector/fragments.rs @@ -109,6 +109,12 @@ pub async fn init_fragments_index() -> (Server, String, crate::common::Va // TODO: Test cannot pass both fragments and document +// TODO: test with 2 embedders + +// TODO: edit fragment + +// TODO: document fragment replaced + #[actix_rt::test] async fn indexing_fragments() { let index = shared_index_for_fragments().await; From 90683d0e4e77a1f8d9924a4a9e8065767b49c76d Mon Sep 17 00:00:00 2001 From: Louis Dureuil Date: Thu, 3 Jul 2025 14:33:45 +0200 Subject: [PATCH 052/135] add snapshot of get settings --- crates/meilisearch/tests/vector/fragments.rs | 99 +++++++++++++++++--- 1 file changed, 87 insertions(+), 12 deletions(-) diff --git a/crates/meilisearch/tests/vector/fragments.rs b/crates/meilisearch/tests/vector/fragments.rs index 338f9e7c5..a21855d8d 100644 --- a/crates/meilisearch/tests/vector/fragments.rs +++ b/crates/meilisearch/tests/vector/fragments.rs @@ -8,8 +8,7 @@ use wiremock::{Mock, MockServer, Request, ResponseTemplate}; use crate::common::index::Index; use crate::common::{Owned, Shared}; use crate::json; -use crate::vector::Server; -use crate::vector::GetAllDocumentsOptions; +use crate::vector::{GetAllDocumentsOptions, Server}; async fn shared_index_for_fragments() -> Index<'static, Shared> { static INDEX: OnceCell<(Server, String)> = OnceCell::const_new(); @@ -82,9 +81,7 @@ pub async fn init_fragments_index() -> (Server, String, crate::common::Va }, }, }); - let (response, code) = index - .update_settings(settings.clone()) - .await; + let (response, code) = index.update_settings(settings.clone()).await; snapshot!(code, @"202 Accepted"); let task = server.wait_task(response.uid()).await; @@ -359,9 +356,7 @@ async fn deleting_fragments_deletes_vectors() { .await; println!("Documents before update: {documents:?}"); - let (response, code) = index - .update_settings(settings) - .await; + let (response, code) = index.update_settings(settings).await; snapshot!(code, @"202 Accepted"); let value = server.wait_task(response.uid()).await.succeeded(); snapshot!(value, @r#" @@ -410,11 +405,91 @@ async fn deleting_fragments_deletes_vectors() { } "#); + let (value, code) = index.settings().await; + snapshot!(value, @r###" + { + "displayedAttributes": [ + "*" + ], + "searchableAttributes": [ + "*" + ], + "filterableAttributes": [], + "sortableAttributes": [], + "rankingRules": [ + "words", + "typo", + "proximity", + "attribute", + "sort", + "exactness" + ], + "stopWords": [], + "nonSeparatorTokens": [], + "separatorTokens": [], + "dictionary": [], + "synonyms": {}, + "distinctAttribute": null, + "proximityPrecision": "byWord", + "typoTolerance": { + "enabled": true, + "minWordSizeForTypos": { + "oneTypo": 5, + "twoTypos": 9 + }, + "disableOnWords": [], + "disableOnAttributes": [], + "disableOnNumbers": false + }, + "faceting": { + "maxValuesPerFacet": 100, + "sortFacetValuesBy": { + "*": "alpha" + } + }, + "pagination": { + "maxTotalHits": 1000 + }, + "embedders": { + "rest": { + "source": "rest", + "dimensions": 3, + "url": "http://127.0.0.1:53832", + "indexingFragments": { + "withBreed": { + "value": "{{ doc.name }} is a {{ doc.breed }}" + } + }, + "searchFragments": { + "justBreed": { + "value": "It's a {{ media.breed }}" + }, + "justName": { + "value": "{{ media.name }} is a dog" + }, + "query": { + "value": "Some pre-prompt for query {{ q }}" + } + }, + "request": "{{fragment}}", + "response": { + "data": "{{embedding}}" + }, + "headers": {} + } + }, + "searchCutoffMs": null, + "localizedAttributes": null, + "facetSearch": true, + "prefixSearch": "indexingTime" + } + "###); + let (documents, code) = index .get_all_documents(GetAllDocumentsOptions { retrieve_vectors: true, ..Default::default() }) .await; snapshot!(code, @"200 OK"); - snapshot!(json_string!(documents), @r#" + snapshot!(json_string!(documents), @r###" { "results": [ { @@ -453,7 +528,7 @@ async fn deleting_fragments_deletes_vectors() { [ 1.0, 1.0, - 0.0 + -1.0 ] ], "regenerate": true @@ -470,7 +545,7 @@ async fn deleting_fragments_deletes_vectors() { [ -0.5, 0.5, - 0.0 + 1.0 ] ], "regenerate": true @@ -482,5 +557,5 @@ async fn deleting_fragments_deletes_vectors() { "limit": 20, "total": 4 } - "#); + "###); } From a3af9fe0578903895c6c210e66760d0ab48205a9 Mon Sep 17 00:00:00 2001 From: Louis Dureuil Date: Thu, 3 Jul 2025 14:35:02 +0200 Subject: [PATCH 053/135] new extractor bugfixes: - fix old_has_fragments - new_is_user_provided is always false when generating fragments, even if no fragment ever matches --- .../src/update/new/extract/vectors/mod.rs | 24 +++++-------------- 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/crates/milli/src/update/new/extract/vectors/mod.rs b/crates/milli/src/update/new/extract/vectors/mod.rs index 72a07dea6..4ca68027c 100644 --- a/crates/milli/src/update/new/extract/vectors/mod.rs +++ b/crates/milli/src/update/new/extract/vectors/mod.rs @@ -357,7 +357,7 @@ impl<'extractor, SD: SettingsDelta + Sync> SettingsChangeExtractor<'extractor> chunks.is_user_provided_must_regenerate(document.docid()); let old_has_fragments = old_embedders .get(embedder_name) - .map(|embedder| embedder.fragments().is_empty()) + .map(|embedder| !embedder.fragments().is_empty()) .unwrap_or_default(); let new_has_fragments = chunks.has_fragments(); @@ -628,9 +628,6 @@ impl<'a, 'b, 'extractor> Chunks<'a, 'b, 'extractor> { session.on_embed_mut().clear_vectors(docid); } - let mut extracted = false; - let extracted = &mut extracted; - settings_delta.try_for_each_fragment_diff( session.embedder_name(), |fragment_diff| { @@ -660,7 +657,6 @@ impl<'a, 'b, 'extractor> Chunks<'a, 'b, 'extractor> { ); } ExtractorDiff::Added(input) | ExtractorDiff::Updated(input) => { - *extracted = true; session.request_embedding( metadata, input, @@ -673,13 +669,7 @@ impl<'a, 'b, 'extractor> Chunks<'a, 'b, 'extractor> { Result::Ok(()) }, )?; - self.set_status( - docid, - old_is_user_provided, - true, - old_is_user_provided & !*extracted, - true, - ); + self.set_status(docid, old_is_user_provided, true, false, true); } ChunkType::DocumentTemplate { document_template, session } => { let doc_alloc = session.doc_alloc(); @@ -732,7 +722,7 @@ impl<'a, 'b, 'extractor> Chunks<'a, 'b, 'extractor> { where 'a: 'doc, { - let extracted = match &mut self.kind { + match &mut self.kind { ChunkType::DocumentTemplate { document_template, session } => { let doc_alloc = session.doc_alloc(); let ex = DocumentTemplateExtractor::new( @@ -785,7 +775,7 @@ impl<'a, 'b, 'extractor> Chunks<'a, 'b, 'extractor> { docid, old_is_user_provided, old_must_regenerate, - old_is_user_provided && !extracted, + false, new_must_regenerate, ); @@ -968,7 +958,7 @@ fn update_autogenerated<'doc, 'a: 'doc, 'b, E, OD, ND>( old_must_regenerate: bool, session: &mut EmbedSession<'a, OnEmbeddingDocumentUpdates<'a, 'b>, E::Input>, unused_vectors_distribution: &UnusedVectorsDistributionBump<'a>, -) -> Result +) -> Result<()> where OD: Document<'doc> + Debug, ND: Document<'doc> + Debug, @@ -976,7 +966,6 @@ where E::Input: Input, crate::Error: From, { - let mut extracted = false; for extractor in extractors { let new_rendered = extractor.extract(&new_document, meta)?; let must_regenerate = if !old_must_regenerate { @@ -995,7 +984,6 @@ where }; if must_regenerate { - extracted = true; let metadata = Metadata { docid, external_docid, extractor_id: extractor.extractor_id() }; @@ -1011,7 +999,7 @@ where } } - Ok(extracted) + Ok(()) } fn insert_autogenerated<'a, 'b, E, D: Document<'a> + Debug>( From de24e75be8a34da406d84656698c68185d7644a7 Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Thu, 3 Jul 2025 15:00:11 +0200 Subject: [PATCH 054/135] Update test --- crates/meilisearch/tests/vector/fragments.rs | 101 +++++-------------- 1 file changed, 28 insertions(+), 73 deletions(-) diff --git a/crates/meilisearch/tests/vector/fragments.rs b/crates/meilisearch/tests/vector/fragments.rs index a21855d8d..863d0127b 100644 --- a/crates/meilisearch/tests/vector/fragments.rs +++ b/crates/meilisearch/tests/vector/fragments.rs @@ -406,84 +406,39 @@ async fn deleting_fragments_deletes_vectors() { "#); let (value, code) = index.settings().await; - snapshot!(value, @r###" + snapshot!(code, @"200 OK"); + snapshot!(json_string!(value["embedders"], { + ".rest.url" => "[url]", + }), @r#" { - "displayedAttributes": [ - "*" - ], - "searchableAttributes": [ - "*" - ], - "filterableAttributes": [], - "sortableAttributes": [], - "rankingRules": [ - "words", - "typo", - "proximity", - "attribute", - "sort", - "exactness" - ], - "stopWords": [], - "nonSeparatorTokens": [], - "separatorTokens": [], - "dictionary": [], - "synonyms": {}, - "distinctAttribute": null, - "proximityPrecision": "byWord", - "typoTolerance": { - "enabled": true, - "minWordSizeForTypos": { - "oneTypo": 5, - "twoTypos": 9 + "rest": { + "source": "rest", + "dimensions": 3, + "url": "[url]", + "indexingFragments": { + "withBreed": { + "value": "{{ doc.name }} is a {{ doc.breed }}" + } }, - "disableOnWords": [], - "disableOnAttributes": [], - "disableOnNumbers": false - }, - "faceting": { - "maxValuesPerFacet": 100, - "sortFacetValuesBy": { - "*": "alpha" - } - }, - "pagination": { - "maxTotalHits": 1000 - }, - "embedders": { - "rest": { - "source": "rest", - "dimensions": 3, - "url": "http://127.0.0.1:53832", - "indexingFragments": { - "withBreed": { - "value": "{{ doc.name }} is a {{ doc.breed }}" - } + "searchFragments": { + "justBreed": { + "value": "It's a {{ media.breed }}" }, - "searchFragments": { - "justBreed": { - "value": "It's a {{ media.breed }}" - }, - "justName": { - "value": "{{ media.name }} is a dog" - }, - "query": { - "value": "Some pre-prompt for query {{ q }}" - } + "justName": { + "value": "{{ media.name }} is a dog" }, - "request": "{{fragment}}", - "response": { - "data": "{{embedding}}" - }, - "headers": {} - } - }, - "searchCutoffMs": null, - "localizedAttributes": null, - "facetSearch": true, - "prefixSearch": "indexingTime" + "query": { + "value": "Some pre-prompt for query {{ q }}" + } + }, + "request": "{{fragment}}", + "response": { + "data": "{{embedding}}" + }, + "headers": {} + } } - "###); + "#); let (documents, code) = index .get_all_documents(GetAllDocumentsOptions { retrieve_vectors: true, ..Default::default() }) From 2bcd69750f61fb3f46f8e8759fac80bd3b2b170f Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Thu, 3 Jul 2025 15:08:27 +0200 Subject: [PATCH 055/135] Add fragment modification test --- crates/meilisearch/tests/vector/fragments.rs | 158 ++++++++++++++++++- 1 file changed, 153 insertions(+), 5 deletions(-) diff --git a/crates/meilisearch/tests/vector/fragments.rs b/crates/meilisearch/tests/vector/fragments.rs index 863d0127b..2b7592316 100644 --- a/crates/meilisearch/tests/vector/fragments.rs +++ b/crates/meilisearch/tests/vector/fragments.rs @@ -112,6 +112,8 @@ pub async fn init_fragments_index() -> (Server, String, crate::common::Va // TODO: document fragment replaced +// TODO: not setting to null but ommitting settings + #[actix_rt::test] async fn indexing_fragments() { let index = shared_index_for_fragments().await; @@ -351,11 +353,6 @@ async fn deleting_fragments_deletes_vectors() { settings["embedders"]["rest"]["indexingFragments"]["basic"] = serde_json::Value::Null; - let (documents, code) = index - .get_all_documents(GetAllDocumentsOptions { retrieve_vectors: true, ..Default::default() }) - .await; - println!("Documents before update: {documents:?}"); - let (response, code) = index.update_settings(settings).await; snapshot!(code, @"202 Accepted"); let value = server.wait_task(response.uid()).await.succeeded(); @@ -514,3 +511,154 @@ async fn deleting_fragments_deletes_vectors() { } "###); } + +#[actix_rt::test] +async fn modifying_fragments_modifies_vectors() { + let (server, uid, mut settings) = init_fragments_index().await; + let index = server.index(uid); + + settings["embedders"]["rest"]["indexingFragments"]["basic"]["value"] = + serde_json::Value::String("{{ doc.name }} is a dog (maybe bulldog?)".to_string()); + + let (response, code) = index.update_settings(settings).await; + snapshot!(code, @"202 Accepted"); + let value = server.wait_task(response.uid()).await.succeeded(); + snapshot!(value, @r#" + { + "uid": "[uid]", + "batchUid": "[batch_uid]", + "indexUid": "[uuid]", + "status": "succeeded", + "type": "settingsUpdate", + "canceledBy": null, + "details": { + "embedders": { + "rest": { + "source": "rest", + "dimensions": 3, + "url": "[url]", + "indexingFragments": { + "basic": { + "value": "{{ doc.name }} is a dog (maybe bulldog?)" + }, + "withBreed": { + "value": "{{ doc.name }} is a {{ doc.breed }}" + } + }, + "searchFragments": { + "justBreed": { + "value": "It's a {{ media.breed }}" + }, + "justName": { + "value": "{{ media.name }} is a dog" + }, + "query": { + "value": "Some pre-prompt for query {{ q }}" + } + }, + "request": "{{fragment}}", + "response": { + "data": "{{embedding}}" + } + } + } + }, + "error": null, + "duration": "[duration]", + "enqueuedAt": "[date]", + "startedAt": "[date]", + "finishedAt": "[date]" + } + "#); + + let (documents, code) = index + .get_all_documents(GetAllDocumentsOptions { retrieve_vectors: true, ..Default::default() }) + .await; + snapshot!(code, @"200 OK"); + snapshot!(json_string!(documents), @r#" + { + "results": [ + { + "id": 0, + "name": "kefir", + "_vectors": { + "rest": { + "embeddings": [ + [ + 0.5, + -0.5, + 1.0 + ] + ], + "regenerate": true + } + } + }, + { + "id": 1, + "name": "echo", + "_vectors": { + "rest": { + "embeddings": [ + [ + 1.0, + 1.0, + 1.0 + ] + ], + "regenerate": false + } + } + }, + { + "id": 2, + "name": "intel", + "breed": "labrador", + "_vectors": { + "rest": { + "embeddings": [ + [ + 1.0, + 1.0, + 1.0 + ], + [ + 1.0, + 1.0, + -1.0 + ] + ], + "regenerate": true + } + } + }, + { + "id": 3, + "name": "dustin", + "breed": "bulldog", + "_vectors": { + "rest": { + "embeddings": [ + [ + -0.5, + 0.5, + 1.0 + ], + [ + -0.5, + 0.5, + 1.0 + ] + ], + "regenerate": true + } + } + } + ], + "offset": 0, + "limit": 20, + "total": 4 + } + "#); +} + From 2faad504c6d7c2195bd99cd947cc5a4a3d1db38b Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Thu, 3 Jul 2025 15:12:47 +0200 Subject: [PATCH 056/135] Add test --- crates/meilisearch/tests/vector/fragments.rs | 92 +++++++++++++++++++- 1 file changed, 90 insertions(+), 2 deletions(-) diff --git a/crates/meilisearch/tests/vector/fragments.rs b/crates/meilisearch/tests/vector/fragments.rs index 2b7592316..00682299b 100644 --- a/crates/meilisearch/tests/vector/fragments.rs +++ b/crates/meilisearch/tests/vector/fragments.rs @@ -112,8 +112,6 @@ pub async fn init_fragments_index() -> (Server, String, crate::common::Va // TODO: document fragment replaced -// TODO: not setting to null but ommitting settings - #[actix_rt::test] async fn indexing_fragments() { let index = shared_index_for_fragments().await; @@ -662,3 +660,93 @@ async fn modifying_fragments_modifies_vectors() { "#); } +#[actix_rt::test] +async fn ommitted_fragment_isnt_removed() { + let (server, uid, mut settings) = init_fragments_index().await; + let index = server.index(uid); + + settings["embedders"]["rest"]["indexingFragments"]["basic"] = serde_json::Value::Null; // basic is removed + settings["embedders"]["rest"]["indexingFragments"].as_object_mut().unwrap().remove("withBreed"); // withBreed isn't specified + + let (response, code) = index.update_settings(settings).await; + snapshot!(code, @"202 Accepted"); + let value = server.wait_task(response.uid()).await.succeeded(); + snapshot!(value, @r#" + { + "uid": "[uid]", + "batchUid": "[batch_uid]", + "indexUid": "[uuid]", + "status": "succeeded", + "type": "settingsUpdate", + "canceledBy": null, + "details": { + "embedders": { + "rest": { + "source": "rest", + "dimensions": 3, + "url": "[url]", + "indexingFragments": { + "basic": null + }, + "searchFragments": { + "justBreed": { + "value": "It's a {{ media.breed }}" + }, + "justName": { + "value": "{{ media.name }} is a dog" + }, + "query": { + "value": "Some pre-prompt for query {{ q }}" + } + }, + "request": "{{fragment}}", + "response": { + "data": "{{embedding}}" + } + } + } + }, + "error": null, + "duration": "[duration]", + "enqueuedAt": "[date]", + "startedAt": "[date]", + "finishedAt": "[date]" + } + "#); + + let (value, code) = index.settings().await; + snapshot!(code, @"200 OK"); + snapshot!(json_string!(value["embedders"], { + ".rest.url" => "[url]", + }), @r#" + { + "rest": { + "source": "rest", + "dimensions": 3, + "url": "[url]", + "indexingFragments": { + "withBreed": { + "value": "{{ doc.name }} is a {{ doc.breed }}" + } + }, + "searchFragments": { + "justBreed": { + "value": "It's a {{ media.breed }}" + }, + "justName": { + "value": "{{ media.name }} is a dog" + }, + "query": { + "value": "Some pre-prompt for query {{ q }}" + } + }, + "request": "{{fragment}}", + "response": { + "data": "{{embedding}}" + }, + "headers": {} + } + } + "#); +} + From 5690700601b4ef3970c8c9e4c7da57b6c3e6bbb0 Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Thu, 3 Jul 2025 15:19:31 +0200 Subject: [PATCH 057/135] Add fragment addition test --- crates/meilisearch/tests/vector/fragments.rs | 168 +++++++++++++++++++ 1 file changed, 168 insertions(+) diff --git a/crates/meilisearch/tests/vector/fragments.rs b/crates/meilisearch/tests/vector/fragments.rs index 00682299b..71bab6ea0 100644 --- a/crates/meilisearch/tests/vector/fragments.rs +++ b/crates/meilisearch/tests/vector/fragments.rs @@ -750,3 +750,171 @@ async fn ommitted_fragment_isnt_removed() { "#); } +#[actix_rt::test] +async fn fragment_insertion() { + let (server, uid, mut settings) = init_fragments_index().await; + let index = server.index(uid); + + settings["embedders"]["rest"]["indexingFragments"].as_object_mut().unwrap().insert(String::from("useless"), serde_json::json!({ + "value": "This fragment is useless" + })); + + let (response, code) = index.update_settings(settings).await; + snapshot!(code, @"202 Accepted"); + let value = server.wait_task(response.uid()).await.succeeded(); + snapshot!(value, @r#" + { + "uid": "[uid]", + "batchUid": "[batch_uid]", + "indexUid": "[uuid]", + "status": "succeeded", + "type": "settingsUpdate", + "canceledBy": null, + "details": { + "embedders": { + "rest": { + "source": "rest", + "dimensions": 3, + "url": "[url]", + "indexingFragments": { + "basic": { + "value": "{{ doc.name }} is a dog" + }, + "useless": { + "value": "This fragment is useless" + }, + "withBreed": { + "value": "{{ doc.name }} is a {{ doc.breed }}" + } + }, + "searchFragments": { + "justBreed": { + "value": "It's a {{ media.breed }}" + }, + "justName": { + "value": "{{ media.name }} is a dog" + }, + "query": { + "value": "Some pre-prompt for query {{ q }}" + } + }, + "request": "{{fragment}}", + "response": { + "data": "{{embedding}}" + } + } + } + }, + "error": null, + "duration": "[duration]", + "enqueuedAt": "[date]", + "startedAt": "[date]", + "finishedAt": "[date]" + } + "#); + + let (documents, code) = index + .get_all_documents(GetAllDocumentsOptions { retrieve_vectors: true, ..Default::default() }) + .await; + snapshot!(code, @"200 OK"); + snapshot!(json_string!(documents), @r#" + { + "results": [ + { + "id": 0, + "name": "kefir", + "_vectors": { + "rest": { + "embeddings": [ + [ + 0.5, + -0.5, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ] + ], + "regenerate": true + } + } + }, + { + "id": 1, + "name": "echo", + "_vectors": { + "rest": { + "embeddings": [ + [ + 1.0, + 1.0, + 1.0 + ] + ], + "regenerate": false + } + } + }, + { + "id": 2, + "name": "intel", + "breed": "labrador", + "_vectors": { + "rest": { + "embeddings": [ + [ + 1.0, + 1.0, + 0.0 + ], + [ + 1.0, + 1.0, + -1.0 + ], + [ + 0.0, + 0.0, + 0.0 + ] + ], + "regenerate": true + } + } + }, + { + "id": 3, + "name": "dustin", + "breed": "bulldog", + "_vectors": { + "rest": { + "embeddings": [ + [ + -0.5, + 0.5, + 0.0 + ], + [ + -0.5, + 0.5, + 1.0 + ], + [ + 0.0, + 0.0, + 0.0 + ] + ], + "regenerate": true + } + } + } + ], + "offset": 0, + "limit": 20, + "total": 4 + } + "#); +} From 7423243be0bc373a42b5518d6cc625e762187629 Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Thu, 3 Jul 2025 15:52:18 +0200 Subject: [PATCH 058/135] Add test with multiple embedders --- crates/meilisearch/tests/vector/fragments.rs | 447 ++++++++++++++++++- 1 file changed, 445 insertions(+), 2 deletions(-) diff --git a/crates/meilisearch/tests/vector/fragments.rs b/crates/meilisearch/tests/vector/fragments.rs index 71bab6ea0..03d0ffc7a 100644 --- a/crates/meilisearch/tests/vector/fragments.rs +++ b/crates/meilisearch/tests/vector/fragments.rs @@ -106,12 +106,14 @@ pub async fn init_fragments_index() -> (Server, String, crate::common::Va // TODO: Test cannot pass both fragments and document -// TODO: test with 2 embedders - // TODO: edit fragment // TODO: document fragment replaced +// TODO: complex value + +// TODO: swapping fragments + #[actix_rt::test] async fn indexing_fragments() { let index = shared_index_for_fragments().await; @@ -918,3 +920,444 @@ async fn fragment_insertion() { } "#); } + +#[actix_rt::test] +async fn multiple_embedders() { + let (server, uid, mut settings) = init_fragments_index().await; + let index = server.index(uid); + + let url = settings["embedders"]["rest"]["url"].as_str().unwrap(); + + let settings2 = json!({ + "embedders": { + "rest2": { + "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": { + "query": {"value": "Some pre-prompt for query {{ q }}"}, + } + }, + "rest3": { + "source": "rest", + "url": url, + "dimensions": 3, + "request": "{{fragment}}", + "response": { + "data": "{{embedding}}" + }, + "indexingFragments": { + "basic": {"value": "{{ doc.name }} is a dog"}, + }, + "searchFragments": { + "query": {"value": "Some pre-prompt for query {{ q }}"}, + } + }, + }, + }); + let (response, code) = index.update_settings(settings2).await; + snapshot!(code, @"202 Accepted"); + let task = server.wait_task(response.uid()).await; + snapshot!(task, @r#" + { + "uid": "[uid]", + "batchUid": "[batch_uid]", + "indexUid": "[uuid]", + "status": "succeeded", + "type": "settingsUpdate", + "canceledBy": null, + "details": { + "embedders": { + "rest2": { + "source": "rest", + "dimensions": 3, + "url": "[url]", + "indexingFragments": { + "basic": { + "value": "{{ doc.name }} is a dog" + }, + "withBreed": { + "value": "{{ doc.name }} is a {{ doc.breed }}" + } + }, + "searchFragments": { + "query": { + "value": "Some pre-prompt for query {{ q }}" + } + }, + "request": "{{fragment}}", + "response": { + "data": "{{embedding}}" + } + }, + "rest3": { + "source": "rest", + "dimensions": 3, + "url": "[url]", + "indexingFragments": { + "basic": { + "value": "{{ doc.name }} is a dog" + } + }, + "searchFragments": { + "query": { + "value": "Some pre-prompt for query {{ q }}" + } + }, + "request": "{{fragment}}", + "response": { + "data": "{{embedding}}" + } + } + } + }, + "error": null, + "duration": "[duration]", + "enqueuedAt": "[date]", + "startedAt": "[date]", + "finishedAt": "[date]" + } + "#); + + let (documents, code) = index + .get_all_documents(GetAllDocumentsOptions { retrieve_vectors: true, ..Default::default() }) + .await; + snapshot!(code, @"200 OK"); + snapshot!(json_string!(documents), @r#" + { + "results": [ + { + "id": 0, + "name": "kefir", + "_vectors": { + "rest": { + "embeddings": [ + [ + 0.5, + -0.5, + 0.0 + ] + ], + "regenerate": true + }, + "rest2": { + "embeddings": [ + [ + 0.5, + -0.5, + 0.0 + ] + ], + "regenerate": true + }, + "rest3": { + "embeddings": [ + [ + 0.5, + -0.5, + 0.0 + ] + ], + "regenerate": true + } + } + }, + { + "id": 1, + "name": "echo", + "_vectors": { + "rest": { + "embeddings": [ + [ + 1.0, + 1.0, + 1.0 + ] + ], + "regenerate": false + }, + "rest2": { + "embeddings": [ + [ + 0.0, + 0.0, + 0.0 + ] + ], + "regenerate": true + }, + "rest3": { + "embeddings": [ + [ + 0.0, + 0.0, + 0.0 + ] + ], + "regenerate": true + } + } + }, + { + "id": 2, + "name": "intel", + "breed": "labrador", + "_vectors": { + "rest": { + "embeddings": [ + [ + 1.0, + 1.0, + 0.0 + ], + [ + 1.0, + 1.0, + -1.0 + ] + ], + "regenerate": true + }, + "rest2": { + "embeddings": [ + [ + 1.0, + 1.0, + 0.0 + ], + [ + 1.0, + 1.0, + -1.0 + ] + ], + "regenerate": true + }, + "rest3": { + "embeddings": [ + [ + 1.0, + 1.0, + 0.0 + ] + ], + "regenerate": true + } + } + }, + { + "id": 3, + "name": "dustin", + "breed": "bulldog", + "_vectors": { + "rest": { + "embeddings": [ + [ + -0.5, + 0.5, + 0.0 + ], + [ + -0.5, + 0.5, + 1.0 + ] + ], + "regenerate": true + }, + "rest2": { + "embeddings": [ + [ + -0.5, + 0.5, + 0.0 + ], + [ + -0.5, + 0.5, + 1.0 + ] + ], + "regenerate": true + }, + "rest3": { + "embeddings": [ + [ + -0.5, + 0.5, + 0.0 + ] + ], + "regenerate": true + } + } + } + ], + "offset": 0, + "limit": 20, + "total": 4 + } + "#); + + // Remove Rest2 + + settings["embedders"]["rest2"] = serde_json::Value::Null; + + let (response, code) = index.update_settings(settings.clone()).await; + snapshot!(code, @"202 Accepted"); + let value = server.wait_task(response.uid()).await.succeeded(); + snapshot!(value["status"], @r###""succeeded""###); + + let (documents, code) = index + .get_all_documents(GetAllDocumentsOptions { retrieve_vectors: true, ..Default::default() }) + .await; + snapshot!(code, @"200 OK"); + snapshot!(json_string!(documents), @r#" + { + "results": [ + { + "id": 0, + "name": "kefir", + "_vectors": { + "rest": { + "embeddings": [ + [ + 0.5, + -0.5, + 0.0 + ] + ], + "regenerate": true + }, + "rest3": { + "embeddings": [ + [ + 0.5, + -0.5, + 0.0 + ] + ], + "regenerate": true + } + } + }, + { + "id": 1, + "name": "echo", + "_vectors": { + "rest": { + "embeddings": [ + [ + 1.0, + 1.0, + 1.0 + ] + ], + "regenerate": false + }, + "rest3": { + "embeddings": [ + [ + 0.0, + 0.0, + 0.0 + ] + ], + "regenerate": true + } + } + }, + { + "id": 2, + "name": "intel", + "breed": "labrador", + "_vectors": { + "rest": { + "embeddings": [ + [ + 1.0, + 1.0, + 0.0 + ], + [ + 1.0, + 1.0, + -1.0 + ] + ], + "regenerate": true + }, + "rest3": { + "embeddings": [ + [ + 1.0, + 1.0, + 0.0 + ] + ], + "regenerate": true + } + } + }, + { + "id": 3, + "name": "dustin", + "breed": "bulldog", + "_vectors": { + "rest": { + "embeddings": [ + [ + -0.5, + 0.5, + 0.0 + ], + [ + -0.5, + 0.5, + 1.0 + ] + ], + "regenerate": true + }, + "rest3": { + "embeddings": [ + [ + -0.5, + 0.5, + 0.0 + ] + ], + "regenerate": true + } + } + } + ], + "offset": 0, + "limit": 20, + "total": 4 + } + "#); + + // Remove rest's basic fragment + + settings["embedders"]["rest"]["indexingFragments"]["basic"] = serde_json::Value::Null; + + let (response, code) = index.update_settings(settings).await; + snapshot!(code, @"202 Accepted"); + let value = server.wait_task(response.uid()).await.succeeded(); + snapshot!(value["status"], @r###""succeeded""###); + + let (documents, code) = index + .get_all_documents(GetAllDocumentsOptions { retrieve_vectors: true, ..Default::default() }) + .await; + snapshot!(code, @"200 OK"); + snapshot!(json_string!(documents), @r""); +} From cf9b311f71d2316905a0d25487e953c68d6a345f Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Thu, 3 Jul 2025 15:53:09 +0200 Subject: [PATCH 059/135] Format --- crates/meilisearch/tests/common/mod.rs | 2 +- crates/meilisearch/tests/vector/fragments.rs | 13 ++++++++----- crates/meilisearch/tests/vector/mod.rs | 2 +- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/crates/meilisearch/tests/common/mod.rs b/crates/meilisearch/tests/common/mod.rs index 2fafbd11f..1a73a7532 100644 --- a/crates/meilisearch/tests/common/mod.rs +++ b/crates/meilisearch/tests/common/mod.rs @@ -3,7 +3,7 @@ pub mod index; pub mod server; pub mod service; -use std::{fmt::{self, Display}, future::Future}; +use std::fmt::{self, Display}; #[allow(unused)] pub use index::GetAllDocumentsOptions; diff --git a/crates/meilisearch/tests/vector/fragments.rs b/crates/meilisearch/tests/vector/fragments.rs index 03d0ffc7a..7cfa0c1af 100644 --- a/crates/meilisearch/tests/vector/fragments.rs +++ b/crates/meilisearch/tests/vector/fragments.rs @@ -757,9 +757,12 @@ async fn fragment_insertion() { let (server, uid, mut settings) = init_fragments_index().await; let index = server.index(uid); - settings["embedders"]["rest"]["indexingFragments"].as_object_mut().unwrap().insert(String::from("useless"), serde_json::json!({ - "value": "This fragment is useless" - })); + settings["embedders"]["rest"]["indexingFragments"].as_object_mut().unwrap().insert( + String::from("useless"), + serde_json::json!({ + "value": "This fragment is useless" + }), + ); let (response, code) = index.update_settings(settings).await; snapshot!(code, @"202 Accepted"); @@ -1215,7 +1218,7 @@ async fn multiple_embedders() { snapshot!(code, @"202 Accepted"); let value = server.wait_task(response.uid()).await.succeeded(); snapshot!(value["status"], @r###""succeeded""###); - + let (documents, code) = index .get_all_documents(GetAllDocumentsOptions { retrieve_vectors: true, ..Default::default() }) .await; @@ -1354,7 +1357,7 @@ async fn multiple_embedders() { snapshot!(code, @"202 Accepted"); let value = server.wait_task(response.uid()).await.succeeded(); snapshot!(value["status"], @r###""succeeded""###); - + let (documents, code) = index .get_all_documents(GetAllDocumentsOptions { retrieve_vectors: true, ..Default::default() }) .await; diff --git a/crates/meilisearch/tests/vector/mod.rs b/crates/meilisearch/tests/vector/mod.rs index 837c34289..7f54489b6 100644 --- a/crates/meilisearch/tests/vector/mod.rs +++ b/crates/meilisearch/tests/vector/mod.rs @@ -1,10 +1,10 @@ mod binary_quantized; +mod fragments; #[cfg(feature = "test-ollama")] mod ollama; mod openai; mod rest; mod settings; -mod fragments; use std::str::FromStr; From caccb5181449fcde12bf967688608b6ef9fc7188 Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Thu, 3 Jul 2025 16:10:23 +0200 Subject: [PATCH 060/135] Add a complex value test --- crates/meilisearch/tests/vector/fragments.rs | 186 ++++++++++++++++++- 1 file changed, 184 insertions(+), 2 deletions(-) diff --git a/crates/meilisearch/tests/vector/fragments.rs b/crates/meilisearch/tests/vector/fragments.rs index 7cfa0c1af..20bc4e7ce 100644 --- a/crates/meilisearch/tests/vector/fragments.rs +++ b/crates/meilisearch/tests/vector/fragments.rs @@ -30,6 +30,7 @@ pub async fn init_fragments_index() -> (Server, String, crate::common::Va ("dustin", [-0.5, 0.5, 0.0]), ("bulldog", [0.0, 0.0, 1.0]), ("labrador", [0.0, 0.0, -1.0]), + ("{", [-9999.0, -9999.0, -9999.0]), // That wouldn't be nice ] .into_iter() .collect(); @@ -110,8 +111,6 @@ pub async fn init_fragments_index() -> (Server, String, crate::common::Va // TODO: document fragment replaced -// TODO: complex value - // TODO: swapping fragments #[actix_rt::test] @@ -1364,3 +1363,186 @@ async fn multiple_embedders() { snapshot!(code, @"200 OK"); snapshot!(json_string!(documents), @r""); } + +#[actix_rt::test] +async fn complex_fragment() { + let (server, uid, mut settings) = init_fragments_index().await; + let index = server.index(uid); + + settings["embedders"]["rest"]["indexingFragments"].as_object_mut().unwrap().insert( + String::from("complex"), + serde_json::json!({ + "value": { + "breed": "{{ doc.breed }}", + "breeds": [ + "{{ doc.breed }}", + { + "breed": "{{ doc.breed }}", + } + ] + } + }), + ); + + let (response, code) = index.update_settings(settings).await; + snapshot!(code, @"202 Accepted"); + let task = server.wait_task(response.uid()).await; + snapshot!(task, @r#" + { + "uid": "[uid]", + "batchUid": "[batch_uid]", + "indexUid": "[uuid]", + "status": "succeeded", + "type": "settingsUpdate", + "canceledBy": null, + "details": { + "embedders": { + "rest": { + "source": "rest", + "dimensions": 3, + "url": "[url]", + "indexingFragments": { + "basic": { + "value": "{{ doc.name }} is a dog" + }, + "complex": { + "value": { + "breed": "{{ doc.breed }}", + "breeds": [ + "{{ doc.breed }}", + { + "breed": "{{ doc.breed }}" + } + ] + } + }, + "withBreed": { + "value": "{{ doc.name }} is a {{ doc.breed }}" + } + }, + "searchFragments": { + "justBreed": { + "value": "It's a {{ media.breed }}" + }, + "justName": { + "value": "{{ media.name }} is a dog" + }, + "query": { + "value": "Some pre-prompt for query {{ q }}" + } + }, + "request": "{{fragment}}", + "response": { + "data": "{{embedding}}" + } + } + } + }, + "error": null, + "duration": "[duration]", + "enqueuedAt": "[date]", + "startedAt": "[date]", + "finishedAt": "[date]" + } + "#); + + let (documents, code) = index + .get_all_documents(GetAllDocumentsOptions { retrieve_vectors: true, ..Default::default() }) + .await; + snapshot!(code, @"200 OK"); + snapshot!(json_string!(documents), @r#" + { + "results": [ + { + "id": 0, + "name": "kefir", + "_vectors": { + "rest": { + "embeddings": [ + [ + 0.5, + -0.5, + 0.0 + ] + ], + "regenerate": true + } + } + }, + { + "id": 1, + "name": "echo", + "_vectors": { + "rest": { + "embeddings": [ + [ + 1.0, + 1.0, + 1.0 + ] + ], + "regenerate": false + } + } + }, + { + "id": 2, + "name": "intel", + "breed": "labrador", + "_vectors": { + "rest": { + "embeddings": [ + [ + 1.0, + 1.0, + 0.0 + ], + [ + 1.0, + 1.0, + -1.0 + ], + [ + 0.0, + 0.0, + -1.0 + ] + ], + "regenerate": true + } + } + }, + { + "id": 3, + "name": "dustin", + "breed": "bulldog", + "_vectors": { + "rest": { + "embeddings": [ + [ + -0.5, + 0.5, + 0.0 + ], + [ + -0.5, + 0.5, + 1.0 + ], + [ + 0.0, + 0.0, + 1.0 + ] + ], + "regenerate": true + } + } + } + ], + "offset": 0, + "limit": 20, + "total": 4 + } + "#); +} From d0cd3cacecbaa01fe3a273924232fe9d119e37f6 Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Thu, 3 Jul 2025 18:18:04 +0200 Subject: [PATCH 061/135] Add a way to reproduce the bug --- crates/meilisearch/tests/vector/fragments.rs | 51 ++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/crates/meilisearch/tests/vector/fragments.rs b/crates/meilisearch/tests/vector/fragments.rs index 20bc4e7ce..0135e2044 100644 --- a/crates/meilisearch/tests/vector/fragments.rs +++ b/crates/meilisearch/tests/vector/fragments.rs @@ -1351,6 +1351,7 @@ async fn multiple_embedders() { // Remove rest's basic fragment settings["embedders"]["rest"]["indexingFragments"]["basic"] = serde_json::Value::Null; + //settings["embedders"].as_object_mut().unwrap().remove("rest2"); let (response, code) = index.update_settings(settings).await; snapshot!(code, @"202 Accepted"); @@ -1364,6 +1365,56 @@ async fn multiple_embedders() { snapshot!(json_string!(documents), @r""); } +#[actix_rt::test] +async fn remove_non_existant_embedder() { + let (server, uid, mut settings) = init_fragments_index().await; + let index = server.index(uid); + + settings["embedders"].as_object_mut().unwrap().insert(String::from("non-existant"), serde_json::Value::Null); + + let (response, code) = index.update_settings(settings).await; + snapshot!(code, @"202 Accepted"); + let task = server.wait_task(response.uid()).await; + snapshot!(task, @r""); +} + +#[actix_rt::test] +async fn double_remove_embedder() { + let (server, uid, mut settings) = init_fragments_index().await; + let index = server.index(uid); + + settings["embedders"].as_object_mut().unwrap().insert(String::from("rest"), serde_json::Value::Null); + + let (response, code) = index.update_settings(settings.clone()).await; + snapshot!(code, @"202 Accepted"); + let task = server.wait_task(response.uid()).await; + snapshot!(task, @r#" + { + "uid": "[uid]", + "batchUid": "[batch_uid]", + "indexUid": "[uuid]", + "status": "succeeded", + "type": "settingsUpdate", + "canceledBy": null, + "details": { + "embedders": { + "rest": null + } + }, + "error": null, + "duration": "[duration]", + "enqueuedAt": "[date]", + "startedAt": "[date]", + "finishedAt": "[date]" + } + "#); + + let (response, code) = index.update_settings(settings.clone()).await; + snapshot!(code, @"202 Accepted"); + let task = server.wait_task(response.uid()).await; + snapshot!(task, @r#""#); +} + #[actix_rt::test] async fn complex_fragment() { let (server, uid, mut settings) = init_fragments_index().await; From 3714f166967a37adc7f04a101bed4ca62e121762 Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Fri, 4 Jul 2025 10:40:50 +0200 Subject: [PATCH 062/135] Fix bug --- crates/milli/src/update/settings.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/milli/src/update/settings.rs b/crates/milli/src/update/settings.rs index 911f51865..4124aa540 100644 --- a/crates/milli/src/update/settings.rs +++ b/crates/milli/src/update/settings.rs @@ -101,6 +101,10 @@ impl Setting { matches!(self, Self::NotSet) } + pub const fn is_reset(&self) -> bool { + matches!(self, Self::Reset) + } + /// If `Self` is `Reset`, then map self to `Set` with the provided `val`. pub fn or_reset(self, val: T) -> Self { match self { @@ -1213,6 +1217,10 @@ impl<'a, 't, 'i> Settings<'a, 't, 'i> { // new config EitherOrBoth::Right((name, mut setting)) => { tracing::debug!(embedder = name, "new embedder"); + // if we are asked to reset an embedder that doesn't exist, just ignore it + if setting.is_reset() { + continue; + } // apply the default source in case the source was not set so that it gets validated crate::vector::settings::EmbeddingSettings::apply_default_source(&mut setting); crate::vector::settings::EmbeddingSettings::apply_default_openai_model( From 8dfded2993a13ea958593d8e0616660efd104758 Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Fri, 4 Jul 2025 10:49:03 +0200 Subject: [PATCH 063/135] Update tests --- crates/meilisearch/tests/vector/fragments.rs | 70 +++++++++++++++++++- 1 file changed, 68 insertions(+), 2 deletions(-) diff --git a/crates/meilisearch/tests/vector/fragments.rs b/crates/meilisearch/tests/vector/fragments.rs index 0135e2044..915f1c79d 100644 --- a/crates/meilisearch/tests/vector/fragments.rs +++ b/crates/meilisearch/tests/vector/fragments.rs @@ -1375,7 +1375,54 @@ async fn remove_non_existant_embedder() { let (response, code) = index.update_settings(settings).await; snapshot!(code, @"202 Accepted"); let task = server.wait_task(response.uid()).await; - snapshot!(task, @r""); + snapshot!(task, @r#" + { + "uid": "[uid]", + "batchUid": "[batch_uid]", + "indexUid": "[uuid]", + "status": "succeeded", + "type": "settingsUpdate", + "canceledBy": null, + "details": { + "embedders": { + "non-existant": null, + "rest": { + "source": "rest", + "dimensions": 3, + "url": "[url]", + "indexingFragments": { + "basic": { + "value": "{{ doc.name }} is a dog" + }, + "withBreed": { + "value": "{{ doc.name }} is a {{ doc.breed }}" + } + }, + "searchFragments": { + "justBreed": { + "value": "It's a {{ media.breed }}" + }, + "justName": { + "value": "{{ media.name }} is a dog" + }, + "query": { + "value": "Some pre-prompt for query {{ q }}" + } + }, + "request": "{{fragment}}", + "response": { + "data": "{{embedding}}" + } + } + } + }, + "error": null, + "duration": "[duration]", + "enqueuedAt": "[date]", + "startedAt": "[date]", + "finishedAt": "[date]" + } + "#); } #[actix_rt::test] @@ -1412,7 +1459,26 @@ async fn double_remove_embedder() { let (response, code) = index.update_settings(settings.clone()).await; snapshot!(code, @"202 Accepted"); let task = server.wait_task(response.uid()).await; - snapshot!(task, @r#""#); + snapshot!(task, @r#" + { + "uid": "[uid]", + "batchUid": "[batch_uid]", + "indexUid": "[uuid]", + "status": "succeeded", + "type": "settingsUpdate", + "canceledBy": null, + "details": { + "embedders": { + "rest": null + } + }, + "error": null, + "duration": "[duration]", + "enqueuedAt": "[date]", + "startedAt": "[date]", + "finishedAt": "[date]" + } + "#); } #[actix_rt::test] From 6792d048b8aafd13f799fe9254a7c67bb9f07495 Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Fri, 4 Jul 2025 11:47:38 +0200 Subject: [PATCH 064/135] Test both fragments and document template --- crates/meilisearch/tests/vector/fragments.rs | 155 ++++++++++++++++++- 1 file changed, 152 insertions(+), 3 deletions(-) diff --git a/crates/meilisearch/tests/vector/fragments.rs b/crates/meilisearch/tests/vector/fragments.rs index 915f1c79d..25d7a7ffc 100644 --- a/crates/meilisearch/tests/vector/fragments.rs +++ b/crates/meilisearch/tests/vector/fragments.rs @@ -30,7 +30,7 @@ pub async fn init_fragments_index() -> (Server, String, crate::common::Va ("dustin", [-0.5, 0.5, 0.0]), ("bulldog", [0.0, 0.0, 1.0]), ("labrador", [0.0, 0.0, -1.0]), - ("{", [-9999.0, -9999.0, -9999.0]), // That wouldn't be nice + ("{{ doc.", [-9999.0, -9999.0, -9999.0]), // If a template didn't render ] .into_iter() .collect(); @@ -68,7 +68,7 @@ pub async fn init_fragments_index() -> (Server, String, crate::common::Va "dimensions": 3, "request": "{{fragment}}", "response": { - "data": "{{embedding}}" + "data": "{{embedding}}" }, "indexingFragments": { "withBreed": {"value": "{{ doc.name }} is a {{ doc.breed }}"}, @@ -1362,7 +1362,115 @@ async fn multiple_embedders() { .get_all_documents(GetAllDocumentsOptions { retrieve_vectors: true, ..Default::default() }) .await; snapshot!(code, @"200 OK"); - snapshot!(json_string!(documents), @r""); + snapshot!(json_string!(documents), @r#" + { + "results": [ + { + "id": 0, + "name": "kefir", + "_vectors": { + "rest": { + "embeddings": [], + "regenerate": true + }, + "rest3": { + "embeddings": [ + [ + 0.5, + -0.5, + 0.0 + ] + ], + "regenerate": true + } + } + }, + { + "id": 1, + "name": "echo", + "_vectors": { + "rest": { + "embeddings": [ + [ + 1.0, + 1.0, + 1.0 + ] + ], + "regenerate": false + }, + "rest3": { + "embeddings": [ + [ + 0.0, + 0.0, + 0.0 + ] + ], + "regenerate": true + } + } + }, + { + "id": 2, + "name": "intel", + "breed": "labrador", + "_vectors": { + "rest": { + "embeddings": [ + [ + 1.0, + 1.0, + -1.0 + ] + ], + "regenerate": true + }, + "rest3": { + "embeddings": [ + [ + 1.0, + 1.0, + 0.0 + ] + ], + "regenerate": true + } + } + }, + { + "id": 3, + "name": "dustin", + "breed": "bulldog", + "_vectors": { + "rest": { + "embeddings": [ + [ + -0.5, + 0.5, + 1.0 + ] + ], + "regenerate": true + }, + "rest3": { + "embeddings": [ + [ + -0.5, + 0.5, + 0.0 + ] + ], + "regenerate": true + } + } + } + ], + "offset": 0, + "limit": 20, + "total": 4 + } + "#); } #[actix_rt::test] @@ -1663,3 +1771,44 @@ async fn complex_fragment() { } "#); } + +#[actix_rt::test] +async fn both_fragments_and_document_template() { + 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 settings = json!({ + "embedders": { + "rest": { + "source": "rest", + "url": "http://localhost:1337", + "dimensions": 3, + "request": "{{fragment}}", + "response": { + "data": "{{embedding}}" + }, + "indexingFragments": { + "basic": {"value": "{{ doc.name }} is a dog"}, + }, + "searchFragments": { + "justBreed": {"value": "It's a {{ media.breed }}"}, + }, + "documentTemplate": "{{ doc.name }} is a dog", + }, + }, + }); + + let (response, code) = index.update_settings(settings.clone()).await; + snapshot!(code, @"400 Bad Request"); + snapshot!(response, @r#" + { + "message": "Error while generating embeddings: user error: cannot pass both fragments and a document template.\n - Note: 1 fragments declared in `indexingFragments` and 1 fragments declared in `search_fragments_len`.\n - Hint: remove the declared fragments or remove the `documentTemplate`", + "code": "vector_embedding_error", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#vector_embedding_error" + } + "#); +} From 48527761e72bd04e87f28269430bccb31395e173 Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Fri, 4 Jul 2025 12:01:15 +0200 Subject: [PATCH 065/135] Add test --- crates/meilisearch/tests/vector/fragments.rs | 113 +++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/crates/meilisearch/tests/vector/fragments.rs b/crates/meilisearch/tests/vector/fragments.rs index 25d7a7ffc..3c0c154d5 100644 --- a/crates/meilisearch/tests/vector/fragments.rs +++ b/crates/meilisearch/tests/vector/fragments.rs @@ -1812,3 +1812,116 @@ async fn both_fragments_and_document_template() { } "#); } + +#[actix_rt::test] +async fn set_fragments_then_document_template() { + let (server, uid, settings) = init_fragments_index().await; + let index = server.index(uid); + + let url = settings["embedders"]["rest"]["url"].as_str().unwrap(); + + let settings = json!({ + "embedders": { + "rest": { + "source": "rest", + "url": url, + "dimensions": 3, + "request": "{{fragment}}", + "response": { + "data": "{{embedding}}" + }, + "documentTemplate": "{{ doc.name }} is a dog", + }, + }, + }); + + let (response, code) = index.update_settings(settings.clone()).await; + snapshot!(code, @"202 Accepted"); + let task = server.wait_task(response.uid()).await; + snapshot!(task, @r""); + + let (settings, code) = index.settings().await; + snapshot!(code, @"200 OK"); + snapshot!(settings, @r#" + { + "displayedAttributes": [ + "*" + ], + "searchableAttributes": [ + "*" + ], + "filterableAttributes": [], + "sortableAttributes": [], + "rankingRules": [ + "words", + "typo", + "proximity", + "attribute", + "sort", + "exactness" + ], + "stopWords": [], + "nonSeparatorTokens": [], + "separatorTokens": [], + "dictionary": [], + "synonyms": {}, + "distinctAttribute": null, + "proximityPrecision": "byWord", + "typoTolerance": { + "enabled": true, + "minWordSizeForTypos": { + "oneTypo": 5, + "twoTypos": 9 + }, + "disableOnWords": [], + "disableOnAttributes": [], + "disableOnNumbers": false + }, + "faceting": { + "maxValuesPerFacet": 100, + "sortFacetValuesBy": { + "*": "alpha" + } + }, + "pagination": { + "maxTotalHits": 1000 + }, + "embedders": { + "rest": { + "source": "rest", + "dimensions": 3, + "url": "http://127.0.0.1:55578", + "indexingFragments": { + "basic": { + "value": "{{ doc.name }} is a dog" + }, + "withBreed": { + "value": "{{ doc.name }} is a {{ doc.breed }}" + } + }, + "searchFragments": { + "justBreed": { + "value": "It's a {{ media.breed }}" + }, + "justName": { + "value": "{{ media.name }} is a dog" + }, + "query": { + "value": "Some pre-prompt for query {{ q }}" + } + }, + "request": "{{fragment}}", + "response": { + "data": "{{embedding}}" + }, + "headers": {} + } + }, + "searchCutoffMs": null, + "localizedAttributes": null, + "facetSearch": true, + "prefixSearch": "indexingTime" + } + "#); +} + From b274106ad3adb3a3224d29114ecb2795a09676a7 Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Fri, 4 Jul 2025 13:05:52 +0200 Subject: [PATCH 066/135] Add test --- crates/meilisearch/tests/vector/fragments.rs | 24 ++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/crates/meilisearch/tests/vector/fragments.rs b/crates/meilisearch/tests/vector/fragments.rs index 3c0c154d5..cf4ed4ab4 100644 --- a/crates/meilisearch/tests/vector/fragments.rs +++ b/crates/meilisearch/tests/vector/fragments.rs @@ -267,6 +267,30 @@ async fn search_with_media() { "#); } +#[actix_rt::test] +async fn search_with_media_and_vector() { + let index = shared_index_for_fragments().await; + + let (value, code) = index + .search_post(json!({ + "vector": [1.0, 1.0, 1.0], + "media": { "breed": "labrador" }, + "hybrid": {"semanticRatio": 1.0, "embedder": "rest"}, + "limit": 1 + } + )) + .await; + snapshot!(code, @"400 Bad Request"); + snapshot!(value, @r#" + { + "message": "Invalid request: both `media` and `vector` parameters are present.", + "code": "invalid_search_media_and_vector", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#invalid_search_media_and_vector" + } + "#); +} + #[actix_rt::test] async fn search_with_media_matching_multiple_fragments() { let index = shared_index_for_fragments().await; From be9f4f96dfd295a90c92f286f2b5903af209abad Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Fri, 4 Jul 2025 13:15:15 +0200 Subject: [PATCH 067/135] Add experimental feature test --- crates/meilisearch/tests/vector/fragments.rs | 40 +++++++++++++++++++- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/crates/meilisearch/tests/vector/fragments.rs b/crates/meilisearch/tests/vector/fragments.rs index cf4ed4ab4..0dde9dfc2 100644 --- a/crates/meilisearch/tests/vector/fragments.rs +++ b/crates/meilisearch/tests/vector/fragments.rs @@ -105,14 +105,50 @@ pub async fn init_fragments_index() -> (Server, String, crate::common::Va (server, uid, settings) } -// TODO: Test cannot pass both fragments and document - // TODO: edit fragment // TODO: document fragment replaced // TODO: swapping fragments +// TODO: consistency + +#[actix_rt::test] +async fn experimental_feature_not_enabled() { + let server = Server::new().await; + let index = server.unique_index(); + + let settings = json!({ + "embedders": { + "rest": { + "source": "rest", + "url": "http://localhost:1337", + "dimensions": 3, + "request": "{{fragment}}", + "response": { + "data": "{{embedding}}" + }, + "indexingFragments": { + "basic": {"value": "{{ doc.name }} is a dog"}, + }, + "searchFragments": { + "query": {"value": "Some pre-prompt for query {{ q }}"}, + } + }, + }, + }); + let (response, code) = index.update_settings(settings.clone()).await; + snapshot!(code, @"400 Bad Request"); + snapshot!(response, @r#" + { + "message": "setting `indexingFragments` requires enabling the `multimodal` experimental feature. See https://github.com/orgs/meilisearch/discussions/846", + "code": "feature_not_enabled", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#feature_not_enabled" + } + "#); +} + #[actix_rt::test] async fn indexing_fragments() { let index = shared_index_for_fragments().await; From 16234e1313c4d76b000c5c1ea1d229c6ff2c26c1 Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Fri, 4 Jul 2025 13:25:42 +0200 Subject: [PATCH 068/135] Add fragment swapping test --- crates/meilisearch/tests/vector/fragments.rs | 160 ++++++++++++++++++- 1 file changed, 153 insertions(+), 7 deletions(-) diff --git a/crates/meilisearch/tests/vector/fragments.rs b/crates/meilisearch/tests/vector/fragments.rs index 0dde9dfc2..f083e40d0 100644 --- a/crates/meilisearch/tests/vector/fragments.rs +++ b/crates/meilisearch/tests/vector/fragments.rs @@ -105,14 +105,8 @@ pub async fn init_fragments_index() -> (Server, String, crate::common::Va (server, uid, settings) } -// TODO: edit fragment - // TODO: document fragment replaced -// TODO: swapping fragments - -// TODO: consistency - #[actix_rt::test] async fn experimental_feature_not_enabled() { let server = Server::new().await; @@ -158,7 +152,7 @@ async fn indexing_fragments() { .get_all_documents(GetAllDocumentsOptions { retrieve_vectors: true, ..Default::default() }) .await; snapshot!(code, @"200 OK"); - snapshot!(json_string!(documents), @r#" + snapshot!(documents, @r#" { "results": [ { @@ -721,6 +715,158 @@ async fn modifying_fragments_modifies_vectors() { "#); } +#[actix_rt::test] +async fn swapping_fragments() { + let (server, uid, mut settings) = init_fragments_index().await; + let index = server.index(uid); + + let basic = settings["embedders"]["rest"]["indexingFragments"]["basic"].clone(); + let with_breed = settings["embedders"]["rest"]["indexingFragments"]["withBreed"].clone(); + settings["embedders"]["rest"]["indexingFragments"]["basic"] = with_breed; + settings["embedders"]["rest"]["indexingFragments"]["withBreed"] = basic; + + let (response, code) = index.update_settings(settings).await; + snapshot!(code, @"202 Accepted"); + let value = server.wait_task(response.uid()).await.succeeded(); + snapshot!(value, @r#" + { + "uid": "[uid]", + "batchUid": "[batch_uid]", + "indexUid": "[uuid]", + "status": "succeeded", + "type": "settingsUpdate", + "canceledBy": null, + "details": { + "embedders": { + "rest": { + "source": "rest", + "dimensions": 3, + "url": "[url]", + "indexingFragments": { + "basic": { + "value": "{{ doc.name }} is a {{ doc.breed }}" + }, + "withBreed": { + "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 }}" + } + }, + "request": "{{fragment}}", + "response": { + "data": "{{embedding}}" + } + } + } + }, + "error": null, + "duration": "[duration]", + "enqueuedAt": "[date]", + "startedAt": "[date]", + "finishedAt": "[date]" + } + "#); + + let (documents, code) = index + .get_all_documents(GetAllDocumentsOptions { retrieve_vectors: true, ..Default::default() }) + .await; + snapshot!(code, @"200 OK"); + snapshot!(documents, @r#" + { + "results": [ + { + "id": 0, + "name": "kefir", + "_vectors": { + "rest": { + "embeddings": [ + [ + 0.5, + -0.5, + 0.0 + ] + ], + "regenerate": true + } + } + }, + { + "id": 1, + "name": "echo", + "_vectors": { + "rest": { + "embeddings": [ + [ + 1.0, + 1.0, + 1.0 + ] + ], + "regenerate": false + } + } + }, + { + "id": 2, + "name": "intel", + "breed": "labrador", + "_vectors": { + "rest": { + "embeddings": [ + [ + 1.0, + 1.0, + -1.0 + ], + [ + 1.0, + 1.0, + 0.0 + ] + ], + "regenerate": true + } + } + }, + { + "id": 3, + "name": "dustin", + "breed": "bulldog", + "_vectors": { + "rest": { + "embeddings": [ + [ + -0.5, + 0.5, + 1.0 + ], + [ + -0.5, + 0.5, + 0.0 + ] + ], + "regenerate": true + } + } + } + ], + "offset": 0, + "limit": 20, + "total": 4 + } + "#); +} + #[actix_rt::test] async fn ommitted_fragment_isnt_removed() { let (server, uid, mut settings) = init_fragments_index().await; From c5993196b3c925dccd811afdbe3b9da5e112ed2b Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Fri, 4 Jul 2025 13:32:55 +0200 Subject: [PATCH 069/135] Add test --- crates/meilisearch/tests/vector/fragments.rs | 115 ++++++++++++++++++- 1 file changed, 113 insertions(+), 2 deletions(-) diff --git a/crates/meilisearch/tests/vector/fragments.rs b/crates/meilisearch/tests/vector/fragments.rs index f083e40d0..70e4433ed 100644 --- a/crates/meilisearch/tests/vector/fragments.rs +++ b/crates/meilisearch/tests/vector/fragments.rs @@ -105,8 +105,6 @@ pub async fn init_fragments_index() -> (Server, String, crate::common::Va (server, uid, settings) } -// TODO: document fragment replaced - #[actix_rt::test] async fn experimental_feature_not_enabled() { let server = Server::new().await; @@ -239,6 +237,119 @@ async fn indexing_fragments() { "#); } +#[actix_rt::test] +async fn replace_document() { + let (server, uid, _settings) = init_fragments_index().await; + let index = server.index(uid); + + let documents = json!([ + { "id": 0, "name": "kefir", "breed": "sorry-I-forgot" }, + ]); + 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""###); + + // Make sure kefir now has 2 vectors + let (documents, code) = index + .get_all_documents(GetAllDocumentsOptions { retrieve_vectors: true, ..Default::default() }) + .await; + snapshot!(code, @"200 OK"); + snapshot!(documents, @r#" + { + "results": [ + { + "id": 0, + "name": "kefir", + "breed": "sorry-I-forgot", + "_vectors": { + "rest": { + "embeddings": [ + [ + 0.5, + -0.5, + 0.0 + ], + [ + 0.5, + -0.5, + 0.0 + ] + ], + "regenerate": true + } + } + }, + { + "id": 1, + "name": "echo", + "_vectors": { + "rest": { + "embeddings": [ + [ + 1.0, + 1.0, + 1.0 + ] + ], + "regenerate": false + } + } + }, + { + "id": 2, + "name": "intel", + "breed": "labrador", + "_vectors": { + "rest": { + "embeddings": [ + [ + 1.0, + 1.0, + 0.0 + ], + [ + 1.0, + 1.0, + -1.0 + ] + ], + "regenerate": true + } + } + }, + { + "id": 3, + "name": "dustin", + "breed": "bulldog", + "_vectors": { + "rest": { + "embeddings": [ + [ + -0.5, + 0.5, + 0.0 + ], + [ + -0.5, + 0.5, + 1.0 + ] + ], + "regenerate": true + } + } + } + ], + "offset": 0, + "limit": 20, + "total": 4 + } + "#); +} + + #[actix_rt::test] async fn search_with_vector() { let index = shared_index_for_fragments().await; From fa3990daf90920bec99cf4051afb48ce46e79a66 Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Fri, 4 Jul 2025 13:33:49 +0200 Subject: [PATCH 070/135] Format --- crates/meilisearch/tests/vector/fragments.rs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/crates/meilisearch/tests/vector/fragments.rs b/crates/meilisearch/tests/vector/fragments.rs index 70e4433ed..337f01ca6 100644 --- a/crates/meilisearch/tests/vector/fragments.rs +++ b/crates/meilisearch/tests/vector/fragments.rs @@ -349,7 +349,6 @@ async fn replace_document() { "#); } - #[actix_rt::test] async fn search_with_vector() { let index = shared_index_for_fragments().await; @@ -891,7 +890,7 @@ async fn swapping_fragments() { .get_all_documents(GetAllDocumentsOptions { retrieve_vectors: true, ..Default::default() }) .await; snapshot!(code, @"200 OK"); - snapshot!(documents, @r#" + snapshot!(documents, @r#" { "results": [ { @@ -1795,7 +1794,10 @@ async fn remove_non_existant_embedder() { let (server, uid, mut settings) = init_fragments_index().await; let index = server.index(uid); - settings["embedders"].as_object_mut().unwrap().insert(String::from("non-existant"), serde_json::Value::Null); + settings["embedders"] + .as_object_mut() + .unwrap() + .insert(String::from("non-existant"), serde_json::Value::Null); let (response, code) = index.update_settings(settings).await; snapshot!(code, @"202 Accepted"); @@ -1855,7 +1857,10 @@ async fn double_remove_embedder() { let (server, uid, mut settings) = init_fragments_index().await; let index = server.index(uid); - settings["embedders"].as_object_mut().unwrap().insert(String::from("rest"), serde_json::Value::Null); + settings["embedders"] + .as_object_mut() + .unwrap() + .insert(String::from("rest"), serde_json::Value::Null); let (response, code) = index.update_settings(settings.clone()).await; snapshot!(code, @"202 Accepted"); @@ -2241,4 +2246,3 @@ async fn set_fragments_then_document_template() { } "#); } - From 73c9c1ebdcd00d53483934e79ea6706e2d5a0586 Mon Sep 17 00:00:00 2001 From: Louis Dureuil Date: Mon, 7 Jul 2025 11:33:01 +0200 Subject: [PATCH 071/135] 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 072/135] 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 132065afda95a6e4223de59f754e4155f162669e Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Mon, 7 Jul 2025 13:10:16 +0200 Subject: [PATCH 073/135] Minor improvements --- crates/meilisearch/tests/vector/fragments.rs | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/crates/meilisearch/tests/vector/fragments.rs b/crates/meilisearch/tests/vector/fragments.rs index 337f01ca6..db57f9f6e 100644 --- a/crates/meilisearch/tests/vector/fragments.rs +++ b/crates/meilisearch/tests/vector/fragments.rs @@ -85,8 +85,7 @@ pub async fn init_fragments_index() -> (Server, String, crate::common::Va let (response, code) = index.update_settings(settings.clone()).await; snapshot!(code, @"202 Accepted"); - let task = server.wait_task(response.uid()).await; - snapshot!(task["status"], @r###""succeeded""###); + server.wait_task(response.uid()).await.succeeded(); // Send documents let documents = json!([ @@ -1031,6 +1030,7 @@ async fn ommitted_fragment_isnt_removed() { } "#); + // Make sure withBreed is still here because it wasn't specified let (value, code) = index.settings().await; snapshot!(code, @"200 OK"); snapshot!(json_string!(value["embedders"], { @@ -1283,7 +1283,7 @@ async fn multiple_embedders() { }); let (response, code) = index.update_settings(settings2).await; snapshot!(code, @"202 Accepted"); - let task = server.wait_task(response.uid()).await; + let task = server.wait_task(response.uid()).await.succeeded(); snapshot!(task, @r#" { "uid": "[uid]", @@ -1531,8 +1531,7 @@ async fn multiple_embedders() { let (response, code) = index.update_settings(settings.clone()).await; snapshot!(code, @"202 Accepted"); - let value = server.wait_task(response.uid()).await.succeeded(); - snapshot!(value["status"], @r###""succeeded""###); + server.wait_task(response.uid()).await.succeeded(); let (documents, code) = index .get_all_documents(GetAllDocumentsOptions { retrieve_vectors: true, ..Default::default() }) @@ -1671,8 +1670,7 @@ async fn multiple_embedders() { let (response, code) = index.update_settings(settings).await; snapshot!(code, @"202 Accepted"); - let value = server.wait_task(response.uid()).await.succeeded(); - snapshot!(value["status"], @r###""succeeded""###); + server.wait_task(response.uid()).await.succeeded(); let (documents, code) = index .get_all_documents(GetAllDocumentsOptions { retrieve_vectors: true, ..Default::default() }) @@ -1801,7 +1799,7 @@ async fn remove_non_existant_embedder() { let (response, code) = index.update_settings(settings).await; snapshot!(code, @"202 Accepted"); - let task = server.wait_task(response.uid()).await; + let task = server.wait_task(response.uid()).await.succeeded(); snapshot!(task, @r#" { "uid": "[uid]", @@ -1864,7 +1862,7 @@ async fn double_remove_embedder() { let (response, code) = index.update_settings(settings.clone()).await; snapshot!(code, @"202 Accepted"); - let task = server.wait_task(response.uid()).await; + let task = server.wait_task(response.uid()).await.succeeded(); snapshot!(task, @r#" { "uid": "[uid]", @@ -1888,7 +1886,7 @@ async fn double_remove_embedder() { let (response, code) = index.update_settings(settings.clone()).await; snapshot!(code, @"202 Accepted"); - let task = server.wait_task(response.uid()).await; + let task = server.wait_task(response.uid()).await.succeeded(); snapshot!(task, @r#" { "uid": "[uid]", @@ -1933,7 +1931,7 @@ async fn complex_fragment() { let (response, code) = index.update_settings(settings).await; snapshot!(code, @"202 Accepted"); - let task = server.wait_task(response.uid()).await; + let task = server.wait_task(response.uid()).await.succeeded(); snapshot!(task, @r#" { "uid": "[uid]", From f7c8a77f89c2d449492421de2f812613bb8e4234 Mon Sep 17 00:00:00 2001 From: Louis Dureuil Date: Mon, 7 Jul 2025 16:01:50 +0200 Subject: [PATCH 074/135] 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 075/135] 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 076/135] 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 073e9f29675ccf26d733ccefa5f7eaf6e47db5cd Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Mon, 7 Jul 2025 16:46:16 +0200 Subject: [PATCH 077/135] Disable similarity check on composite embedders using fragments --- crates/milli/src/vector/composite.rs | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/crates/milli/src/vector/composite.rs b/crates/milli/src/vector/composite.rs index 8314b8649..130e2674a 100644 --- a/crates/milli/src/vector/composite.rs +++ b/crates/milli/src/vector/composite.rs @@ -59,12 +59,24 @@ pub struct EmbedderOptions { impl Embedder { pub fn new( - EmbedderOptions { search, index }: EmbedderOptions, + EmbedderOptions { search: search_options, index: index_options }: EmbedderOptions, cache_cap: usize, ) -> Result { - let search = SubEmbedder::new(search, cache_cap)?; + // don't check similarity if one child is a rest embedder with fragments + // FIXME: skipping the check isn't ideal but we are unsure how to handle fragments in this context + let mut skip_similarity_check = false; + for options in [&search_options, &index_options] { + if let SubEmbedderOptions::Rest(options) = &options { + if !options.search_fragments.is_empty() || !options.indexing_fragments.is_empty() { + skip_similarity_check = true; + break; + } + } + } + + let search = SubEmbedder::new(search_options, cache_cap)?; // cache is only used at search - let index = SubEmbedder::new(index, 0)?; + let index = SubEmbedder::new(index_options, 0)?; // check dimensions if search.dimensions() != index.dimensions() { @@ -73,7 +85,12 @@ impl Embedder { index.dimensions(), )); } + // check similarity + if skip_similarity_check { + return Ok(Self { search, index }); + } + let search_embeddings = search .embed( vec![ From 3261aadcf219705fe5ced715880522bcffb50f8a Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Mon, 7 Jul 2025 16:50:39 +0200 Subject: [PATCH 078/135] Add composite test --- crates/meilisearch/tests/vector/fragments.rs | 109 ++++++++++++++++++- crates/milli/src/vector/composite.rs | 2 +- 2 files changed, 104 insertions(+), 7 deletions(-) diff --git a/crates/meilisearch/tests/vector/fragments.rs b/crates/meilisearch/tests/vector/fragments.rs index db57f9f6e..2626284a0 100644 --- a/crates/meilisearch/tests/vector/fragments.rs +++ b/crates/meilisearch/tests/vector/fragments.rs @@ -21,9 +21,7 @@ async fn shared_index_for_fragments() -> Index<'static, Shared> { server._index(uid).to_shared() } -pub async fn init_fragments_index() -> (Server, String, crate::common::Value) { - let mock_server = MockServer::start().await; - +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]), @@ -35,10 +33,13 @@ pub async fn init_fragments_index() -> (Server, String, crate::common::Va .into_iter() .collect(); + let mock_server = 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) { @@ -51,8 +52,12 @@ pub async fn init_fragments_index() -> (Server, String, crate::common::Va }) .mount(&mock_server) .await; - let url = mock_server.uri(); + 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(); @@ -104,6 +109,72 @@ pub async fn init_fragments_index() -> (Server, String, crate::common::Va (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; @@ -247,8 +318,7 @@ async fn replace_document() { 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""###); + index.wait_task(value.uid()).await.succeeded(); // Make sure kefir now has 2 vectors let (documents, code) = index @@ -2244,3 +2314,30 @@ async fn set_fragments_then_document_template() { } "#); } + +#[actix_rt::test] +async fn composite() { + let (server, uid, _settings) = init_fragments_index_composite().await; + let index = server.index(uid); + + let (value, code) = index.search_post( + json!({"vector": [1.0, 1.0, 1.0], "hybrid": {"semanticRatio": 1.0, "embedder": "rest"}, "limit": 1} + )).await; + snapshot!(code, @"200 OK"); + snapshot!(value, @r#" + { + "hits": [ + { + "id": 1, + "name": "echo" + } + ], + "query": "", + "processingTimeMs": "[duration]", + "limit": 1, + "offset": 0, + "estimatedTotalHits": 4, + "semanticHitCount": 1 + } + "#); +} diff --git a/crates/milli/src/vector/composite.rs b/crates/milli/src/vector/composite.rs index 130e2674a..2e31da094 100644 --- a/crates/milli/src/vector/composite.rs +++ b/crates/milli/src/vector/composite.rs @@ -85,7 +85,7 @@ impl Embedder { index.dimensions(), )); } - + // check similarity if skip_similarity_check { return Ok(Self { search, index }); From 4623691d1fd3e40f3f47f0633798f321d7dc4331 Mon Sep 17 00:00:00 2001 From: Louis Dureuil Date: Tue, 8 Jul 2025 10:02:25 +0200 Subject: [PATCH 079/135] 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 080/135] 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 081/135] 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 2f1be0ff863fd4e272b6df30809ca8559e1bb12f Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Tue, 8 Jul 2025 13:55:07 +0200 Subject: [PATCH 082/135] Ignore faulty test (see #5746) --- crates/meilisearch/tests/vector/fragments.rs | 83 +------------------- 1 file changed, 2 insertions(+), 81 deletions(-) diff --git a/crates/meilisearch/tests/vector/fragments.rs b/crates/meilisearch/tests/vector/fragments.rs index 2626284a0..345155034 100644 --- a/crates/meilisearch/tests/vector/fragments.rs +++ b/crates/meilisearch/tests/vector/fragments.rs @@ -2203,6 +2203,7 @@ async fn both_fragments_and_document_template() { "#); } +#[ignore = "failing due to issue #5746"] #[actix_rt::test] async fn set_fragments_then_document_template() { let (server, uid, settings) = init_fragments_index().await; @@ -2232,87 +2233,7 @@ async fn set_fragments_then_document_template() { let (settings, code) = index.settings().await; snapshot!(code, @"200 OK"); - snapshot!(settings, @r#" - { - "displayedAttributes": [ - "*" - ], - "searchableAttributes": [ - "*" - ], - "filterableAttributes": [], - "sortableAttributes": [], - "rankingRules": [ - "words", - "typo", - "proximity", - "attribute", - "sort", - "exactness" - ], - "stopWords": [], - "nonSeparatorTokens": [], - "separatorTokens": [], - "dictionary": [], - "synonyms": {}, - "distinctAttribute": null, - "proximityPrecision": "byWord", - "typoTolerance": { - "enabled": true, - "minWordSizeForTypos": { - "oneTypo": 5, - "twoTypos": 9 - }, - "disableOnWords": [], - "disableOnAttributes": [], - "disableOnNumbers": false - }, - "faceting": { - "maxValuesPerFacet": 100, - "sortFacetValuesBy": { - "*": "alpha" - } - }, - "pagination": { - "maxTotalHits": 1000 - }, - "embedders": { - "rest": { - "source": "rest", - "dimensions": 3, - "url": "http://127.0.0.1:55578", - "indexingFragments": { - "basic": { - "value": "{{ doc.name }} is a dog" - }, - "withBreed": { - "value": "{{ doc.name }} is a {{ doc.breed }}" - } - }, - "searchFragments": { - "justBreed": { - "value": "It's a {{ media.breed }}" - }, - "justName": { - "value": "{{ media.name }} is a dog" - }, - "query": { - "value": "Some pre-prompt for query {{ q }}" - } - }, - "request": "{{fragment}}", - "response": { - "data": "{{embedding}}" - }, - "headers": {} - } - }, - "searchCutoffMs": null, - "localizedAttributes": null, - "facetSearch": true, - "prefixSearch": "indexingTime" - } - "#); + snapshot!(settings, @r#""#); // Should have removed fragments } #[actix_rt::test] From 1ae47bec77c5d89652082fcc2b7b0436a33a2526 Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Tue, 8 Jul 2025 13:57:07 +0200 Subject: [PATCH 083/135] Improve composite test --- crates/meilisearch/tests/vector/fragments.rs | 22 ++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/crates/meilisearch/tests/vector/fragments.rs b/crates/meilisearch/tests/vector/fragments.rs index 345155034..b1fddedf8 100644 --- a/crates/meilisearch/tests/vector/fragments.rs +++ b/crates/meilisearch/tests/vector/fragments.rs @@ -2261,4 +2261,26 @@ async fn composite() { "semanticHitCount": 1 } "#); + + let (value, code) = index.search_post( + json!({"q": "bulldog", "hybrid": {"semanticRatio": 1.0, "embedder": "rest"}, "limit": 1} + )).await; + snapshot!(code, @"200 OK"); + snapshot!(value, @r#" + { + "hits": [ + { + "id": 3, + "name": "dustin", + "breed": "bulldog" + } + ], + "query": "bulldog", + "processingTimeMs": "[duration]", + "limit": 1, + "offset": 0, + "estimatedTotalHits": 4, + "semanticHitCount": 1 + } + "#); } From 3cc5d86598543ac27a7afdad647d23e0892dc7c1 Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Tue, 8 Jul 2025 13:57:17 +0200 Subject: [PATCH 084/135] Format --- crates/meilisearch/tests/vector/fragments.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/meilisearch/tests/vector/fragments.rs b/crates/meilisearch/tests/vector/fragments.rs index b1fddedf8..15f9f9887 100644 --- a/crates/meilisearch/tests/vector/fragments.rs +++ b/crates/meilisearch/tests/vector/fragments.rs @@ -2262,9 +2262,12 @@ async fn composite() { } "#); - let (value, code) = index.search_post( - json!({"q": "bulldog", "hybrid": {"semanticRatio": 1.0, "embedder": "rest"}, "limit": 1} - )).await; + let (value, code) = index + .search_post( + json!({"q": "bulldog", "hybrid": {"semanticRatio": 1.0, "embedder": "rest"}, "limit": 1} + ), + ) + .await; snapshot!(code, @"200 OK"); snapshot!(value, @r#" { From 0a4f2ef89138b40be98f7d238c0e08236b8df57e Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Tue, 8 Jul 2025 15:27:35 +0200 Subject: [PATCH 085/135] Leak mock servers --- .../meilisearch/tests/search/multi/proxy.rs | 6 +-- crates/meilisearch/tests/vector/fragments.rs | 4 +- crates/meilisearch/tests/vector/openai.rs | 20 ++++----- crates/meilisearch/tests/vector/rest.rs | 42 +++++++++---------- 4 files changed, 36 insertions(+), 36 deletions(-) diff --git a/crates/meilisearch/tests/search/multi/proxy.rs b/crates/meilisearch/tests/search/multi/proxy.rs index 55736d058..c1d40ef3b 100644 --- a/crates/meilisearch/tests/search/multi/proxy.rs +++ b/crates/meilisearch/tests/search/multi/proxy.rs @@ -2499,7 +2499,7 @@ pub struct LocalMeiliParams { /// A server that exploits [`MockServer`] to provide an URL for testing network and the network. pub struct LocalMeili { - mock_server: MockServer, + mock_server: &'static MockServer, } impl LocalMeili { @@ -2508,7 +2508,7 @@ impl LocalMeili { } pub async fn with_params(server: Arc, params: LocalMeiliParams) -> Self { - let mock_server = MockServer::start().await; + let mock_server = Box::leak(Box::new(MockServer::start().await)); // tokio won't let us execute asynchronous code from a sync function inside of an async test, // so instead we spawn another thread that will call the service on a brand new tokio runtime @@ -2572,7 +2572,7 @@ impl LocalMeili { response.set_body_json(value) } }) - .mount(&mock_server) + .mount(mock_server) .await; Self { mock_server } } diff --git a/crates/meilisearch/tests/vector/fragments.rs b/crates/meilisearch/tests/vector/fragments.rs index 15f9f9887..4fe2bddb6 100644 --- a/crates/meilisearch/tests/vector/fragments.rs +++ b/crates/meilisearch/tests/vector/fragments.rs @@ -33,7 +33,7 @@ async fn fragment_mock_server() -> String { .into_iter() .collect(); - let mock_server = MockServer::start().await; + let mock_server = Box::leak(Box::new(MockServer::start().await)); Mock::given(method("POST")) .and(path("/")) @@ -50,7 +50,7 @@ async fn fragment_mock_server() -> String { } ResponseTemplate::new(200).set_body_json(json!({ "data": data })) }) - .mount(&mock_server) + .mount(mock_server) .await; mock_server.uri() diff --git a/crates/meilisearch/tests/vector/openai.rs b/crates/meilisearch/tests/vector/openai.rs index 4ae8cb041..e207c3eb6 100644 --- a/crates/meilisearch/tests/vector/openai.rs +++ b/crates/meilisearch/tests/vector/openai.rs @@ -136,7 +136,7 @@ fn long_text() -> &'static str { }) } -async fn create_mock_tokenized() -> (MockServer, Value) { +async fn create_mock_tokenized() -> (&'static MockServer, Value) { create_mock_with_template("{{doc.text}}", ModelDimensions::Large, false, false).await } @@ -145,8 +145,8 @@ async fn create_mock_with_template( model_dimensions: ModelDimensions, fallible: bool, slow: bool, -) -> (MockServer, Value) { - let mock_server = MockServer::start().await; +) -> (&'static MockServer, Value) { + let mock_server = Box::leak(Box::new(MockServer::start().await)); const API_KEY: &str = "my-api-key"; const API_KEY_BEARER: &str = "Bearer my-api-key"; @@ -299,7 +299,7 @@ async fn create_mock_with_template( } })) }) - .mount(&mock_server) + .mount(mock_server) .await; let url = mock_server.uri(); @@ -321,27 +321,27 @@ const DOGGO_TEMPLATE: &str = r#"{%- if doc.gender == "F" -%}Une chienne nommée Un chien nommé {{doc.name}}, né en {{doc.birthyear}} {%- endif %}, de race {{doc.breed}}."#; -async fn create_mock() -> (MockServer, Value) { +async fn create_mock() -> (&'static MockServer, Value) { create_mock_with_template(DOGGO_TEMPLATE, ModelDimensions::Large, false, false).await } -async fn create_mock_dimensions() -> (MockServer, Value) { +async fn create_mock_dimensions() -> (&'static MockServer, Value) { create_mock_with_template(DOGGO_TEMPLATE, ModelDimensions::Large512, false, false).await } -async fn create_mock_small_embedding_model() -> (MockServer, Value) { +async fn create_mock_small_embedding_model() -> (&'static MockServer, Value) { create_mock_with_template(DOGGO_TEMPLATE, ModelDimensions::Small, false, false).await } -async fn create_mock_legacy_embedding_model() -> (MockServer, Value) { +async fn create_mock_legacy_embedding_model() -> (&'static MockServer, Value) { create_mock_with_template(DOGGO_TEMPLATE, ModelDimensions::Ada, false, false).await } -async fn create_fallible_mock() -> (MockServer, Value) { +async fn create_fallible_mock() -> (&'static MockServer, Value) { create_mock_with_template(DOGGO_TEMPLATE, ModelDimensions::Large, true, false).await } -async fn create_slow_mock() -> (MockServer, Value) { +async fn create_slow_mock() -> (&'static MockServer, Value) { create_mock_with_template(DOGGO_TEMPLATE, ModelDimensions::Large, true, true).await } diff --git a/crates/meilisearch/tests/vector/rest.rs b/crates/meilisearch/tests/vector/rest.rs index e03563bcc..974341cd0 100644 --- a/crates/meilisearch/tests/vector/rest.rs +++ b/crates/meilisearch/tests/vector/rest.rs @@ -12,8 +12,8 @@ use crate::common::Value; use crate::json; use crate::vector::{get_server_vector, GetAllDocumentsOptions}; -async fn create_mock() -> (MockServer, Value) { - let mock_server = MockServer::start().await; +async fn create_mock() -> (&'static MockServer, Value) { + let mock_server = Box::leak(Box::new(MockServer::start().await)); let text_to_embedding: BTreeMap<_, _> = vec![ // text -> embedding @@ -32,7 +32,7 @@ async fn create_mock() -> (MockServer, Value) { json!({ "data": text_to_embedding.get(text.as_str()).unwrap_or(&[99., 99., 99.]) }), ) }) - .mount(&mock_server) + .mount(mock_server) .await; let url = mock_server.uri(); @@ -50,8 +50,8 @@ async fn create_mock() -> (MockServer, Value) { (mock_server, embedder_settings) } -async fn create_mock_default_template() -> (MockServer, Value) { - let mock_server = MockServer::start().await; +async fn create_mock_default_template() -> (&'static MockServer, Value) { + let mock_server = Box::leak(Box::new(MockServer::start().await)); let text_to_embedding: BTreeMap<_, _> = vec![ // text -> embedding @@ -73,7 +73,7 @@ async fn create_mock_default_template() -> (MockServer, Value) { .set_body_json(json!({"error": "text not found", "text": text})), } }) - .mount(&mock_server) + .mount(mock_server) .await; let url = mock_server.uri(); @@ -106,8 +106,8 @@ struct SingleResponse { embedding: Vec, } -async fn create_mock_multiple() -> (MockServer, Value) { - let mock_server = MockServer::start().await; +async fn create_mock_multiple() -> (&'static MockServer, Value) { + let mock_server = Box::leak(Box::new(MockServer::start().await)); let text_to_embedding: BTreeMap<_, _> = vec![ // text -> embedding @@ -146,7 +146,7 @@ async fn create_mock_multiple() -> (MockServer, Value) { ResponseTemplate::new(200).set_body_json(response) }) - .mount(&mock_server) + .mount(mock_server) .await; let url = mock_server.uri(); @@ -176,8 +176,8 @@ struct SingleRequest { input: String, } -async fn create_mock_single_response_in_array() -> (MockServer, Value) { - let mock_server = MockServer::start().await; +async fn create_mock_single_response_in_array() -> (&'static MockServer, Value) { + let mock_server = Box::leak(Box::new(MockServer::start().await)); let text_to_embedding: BTreeMap<_, _> = vec![ // text -> embedding @@ -212,7 +212,7 @@ async fn create_mock_single_response_in_array() -> (MockServer, Value) { ResponseTemplate::new(200).set_body_json(response) }) - .mount(&mock_server) + .mount(mock_server) .await; let url = mock_server.uri(); @@ -236,8 +236,8 @@ async fn create_mock_single_response_in_array() -> (MockServer, Value) { (mock_server, embedder_settings) } -async fn create_mock_raw_with_custom_header() -> (MockServer, Value) { - let mock_server = MockServer::start().await; +async fn create_mock_raw_with_custom_header() -> (&'static MockServer, Value) { + let mock_server = Box::leak(Box::new(MockServer::start().await)); let text_to_embedding: BTreeMap<_, _> = vec![ // text -> embedding @@ -277,7 +277,7 @@ async fn create_mock_raw_with_custom_header() -> (MockServer, Value) { ResponseTemplate::new(200).set_body_json(output) }) - .mount(&mock_server) + .mount(mock_server) .await; let url = mock_server.uri(); @@ -293,8 +293,8 @@ async fn create_mock_raw_with_custom_header() -> (MockServer, Value) { (mock_server, embedder_settings) } -async fn create_mock_raw() -> (MockServer, Value) { - let mock_server = MockServer::start().await; +async fn create_mock_raw() -> (&'static MockServer, Value) { + let mock_server = Box::leak(Box::new(MockServer::start().await)); let text_to_embedding: BTreeMap<_, _> = vec![ // text -> embedding @@ -321,7 +321,7 @@ async fn create_mock_raw() -> (MockServer, Value) { ResponseTemplate::new(200).set_body_json(output) }) - .mount(&mock_server) + .mount(mock_server) .await; let url = mock_server.uri(); @@ -337,8 +337,8 @@ async fn create_mock_raw() -> (MockServer, Value) { (mock_server, embedder_settings) } -async fn create_faulty_mock_raw(sender: mpsc::Sender<()>) -> (MockServer, Value) { - let mock_server = MockServer::start().await; +async fn create_faulty_mock_raw(sender: mpsc::Sender<()>) -> (&'static MockServer, Value) { + let mock_server = Box::leak(Box::new(MockServer::start().await)); let count = AtomicUsize::new(0); Mock::given(method("POST")) @@ -355,7 +355,7 @@ async fn create_faulty_mock_raw(sender: mpsc::Sender<()>) -> (MockServer, Value) ResponseTemplate::new(500).set_body_string("Service Unavailable") } }) - .mount(&mock_server) + .mount(mock_server) .await; let url = mock_server.uri(); From 50bc1d55f3128235fa0c5ac3019286ca4082a3da Mon Sep 17 00:00:00 2001 From: ManyTheFish Date: Thu, 10 Jul 2025 18:23:46 +0200 Subject: [PATCH 086/135] 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 087/135] 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 088/135] 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 089/135] 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 090/135] 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 091/135] 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 092/135] 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 093/135] 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 094/135] 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 095/135] 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 096/135] 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 097/135] 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 098/135] 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 099/135] 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 100/135] 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 101/135] 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 102/135] 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 103/135] 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 104/135] 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 105/135] 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 Date: Mon, 21 Jul 2025 15:07:29 +0200 Subject: [PATCH 123/135] EmbedderOptions::has_fragments() --- crates/milli/src/vector/mod.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/crates/milli/src/vector/mod.rs b/crates/milli/src/vector/mod.rs index f64223e41..873693a34 100644 --- a/crates/milli/src/vector/mod.rs +++ b/crates/milli/src/vector/mod.rs @@ -841,6 +841,25 @@ impl EmbedderOptions { } } } + + pub fn has_fragments(&self) -> bool { + match &self { + EmbedderOptions::HuggingFace(_) + | EmbedderOptions::OpenAi(_) + | EmbedderOptions::Ollama(_) + | EmbedderOptions::UserProvided(_) => false, + EmbedderOptions::Rest(embedder_options) => { + !embedder_options.indexing_fragments.is_empty() + } + EmbedderOptions::Composite(embedder_options) => { + if let SubEmbedderOptions::Rest(embedder_options) = &embedder_options.index { + !embedder_options.indexing_fragments.is_empty() + } else { + false + } + } + } + } } impl Default for EmbedderOptions { From 109395c199e03f96344907513fb0327c4f6467c3 Mon Sep 17 00:00:00 2001 From: Louis Dureuil Date: Mon, 21 Jul 2025 15:08:08 +0200 Subject: [PATCH 124/135] Index::embeddings specifies if the embedder has fragments --- crates/milli/src/index.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/milli/src/index.rs b/crates/milli/src/index.rs index b2ec992ba..9f32fdb04 100644 --- a/crates/milli/src/index.rs +++ b/crates/milli/src/index.rs @@ -1766,20 +1766,22 @@ impl Index { &self, rtxn: &RoTxn<'_>, docid: DocumentId, - ) -> Result, bool)>> { + ) -> Result> { let mut res = BTreeMap::new(); let embedders = self.embedding_configs(); for config in embedders.embedding_configs(rtxn)? { let embedder_info = embedders.embedder_info(rtxn, &config.name)?.unwrap(); + let has_fragments = config.config.embedder_options.has_fragments(); let reader = ArroyWrapper::new( self.vector_arroy, embedder_info.embedder_id, config.config.quantized(), ); let embeddings = reader.item_vectors(rtxn, docid)?; + let regenerate = embedder_info.embedding_status.must_regenerate(docid); res.insert( config.name.to_owned(), - (embeddings, embedder_info.embedding_status.must_regenerate(docid)), + EmbeddingsWithMetadata { embeddings, regenerate, has_fragments }, ); } Ok(res) @@ -1919,6 +1921,12 @@ impl Index { } } +pub struct EmbeddingsWithMetadata { + pub embeddings: Vec, + pub regenerate: bool, + pub has_fragments: bool, +} + #[derive(Debug, Default, Deserialize, Serialize)] pub struct ChatConfig { pub description: String, From 324666759032683e164e0dab8e1f52d57cb875bf Mon Sep 17 00:00:00 2001 From: Louis Dureuil Date: Mon, 21 Jul 2025 15:09:31 +0200 Subject: [PATCH 125/135] when exporting vectors, for regenerate to false when the embedder has fragments --- .../src/scheduler/process_dump_creation.rs | 14 ++++++++++++-- .../src/scheduler/process_export.rs | 14 ++++++++++++-- crates/meilitool/src/main.rs | 14 ++++++++++++-- 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/crates/index-scheduler/src/scheduler/process_dump_creation.rs b/crates/index-scheduler/src/scheduler/process_dump_creation.rs index b8d100415..b14f23d0b 100644 --- a/crates/index-scheduler/src/scheduler/process_dump_creation.rs +++ b/crates/index-scheduler/src/scheduler/process_dump_creation.rs @@ -5,6 +5,7 @@ use std::sync::atomic::Ordering; use dump::IndexMetadata; use meilisearch_types::milli::constants::RESERVED_VECTORS_FIELD_NAME; +use meilisearch_types::milli::index::EmbeddingsWithMetadata; use meilisearch_types::milli::progress::{Progress, VariableNameStep}; use meilisearch_types::milli::vector::parsed_vectors::{ExplicitVectors, VectorOrArrayOfVectors}; use meilisearch_types::milli::{self}; @@ -227,12 +228,21 @@ impl IndexScheduler { return Err(Error::from_milli(user_err, Some(uid.to_string()))); }; - for (embedder_name, (embeddings, regenerate)) in embeddings { + for ( + embedder_name, + EmbeddingsWithMetadata { embeddings, regenerate, has_fragments }, + ) in embeddings + { let embeddings = ExplicitVectors { embeddings: Some(VectorOrArrayOfVectors::from_array_of_vectors( embeddings, )), - regenerate, + regenerate: regenerate && + // Meilisearch does not handle well dumps with fragments, because as the fragments + // are marked as user-provided, + // all embeddings would be regenerated on any settings change or document update. + // To prevent this, we mark embeddings has non regenerate in this case. + !has_fragments, }; vectors.insert(embedder_name, serde_json::to_value(embeddings).unwrap()); } diff --git a/crates/index-scheduler/src/scheduler/process_export.rs b/crates/index-scheduler/src/scheduler/process_export.rs index a951a7ca6..0cd06f2e4 100644 --- a/crates/index-scheduler/src/scheduler/process_export.rs +++ b/crates/index-scheduler/src/scheduler/process_export.rs @@ -9,6 +9,7 @@ use flate2::write::GzEncoder; use flate2::Compression; use meilisearch_types::index_uid_pattern::IndexUidPattern; use meilisearch_types::milli::constants::RESERVED_VECTORS_FIELD_NAME; +use meilisearch_types::milli::index::EmbeddingsWithMetadata; use meilisearch_types::milli::progress::{Progress, VariableNameStep}; use meilisearch_types::milli::update::{request_threads, Setting}; use meilisearch_types::milli::vector::parsed_vectors::{ExplicitVectors, VectorOrArrayOfVectors}; @@ -229,12 +230,21 @@ impl IndexScheduler { )); }; - for (embedder_name, (embeddings, regenerate)) in embeddings { + for ( + embedder_name, + EmbeddingsWithMetadata { embeddings, regenerate, has_fragments }, + ) in embeddings + { let embeddings = ExplicitVectors { embeddings: Some( VectorOrArrayOfVectors::from_array_of_vectors(embeddings), ), - regenerate, + regenerate: regenerate && + // Meilisearch does not handle well dumps with fragments, because as the fragments + // are marked as user-provided, + // all embeddings would be regenerated on any settings change or document update. + // To prevent this, we mark embeddings has non regenerate in this case. + !has_fragments, }; vectors.insert( embedder_name, diff --git a/crates/meilitool/src/main.rs b/crates/meilitool/src/main.rs index b967e620c..170bbdcc8 100644 --- a/crates/meilitool/src/main.rs +++ b/crates/meilitool/src/main.rs @@ -15,6 +15,7 @@ use meilisearch_types::heed::{ }; use meilisearch_types::milli::constants::RESERVED_VECTORS_FIELD_NAME; use meilisearch_types::milli::documents::{obkv_to_object, DocumentsBatchReader}; +use meilisearch_types::milli::index::EmbeddingsWithMetadata; use meilisearch_types::milli::vector::parsed_vectors::{ExplicitVectors, VectorOrArrayOfVectors}; use meilisearch_types::milli::{obkv_to_json, BEU32}; use meilisearch_types::tasks::{Status, Task}; @@ -591,12 +592,21 @@ fn export_documents( .into()); }; - for (embedder_name, (embeddings, regenerate)) in embeddings { + for ( + embedder_name, + EmbeddingsWithMetadata { embeddings, regenerate, has_fragments }, + ) in embeddings + { let embeddings = ExplicitVectors { embeddings: Some(VectorOrArrayOfVectors::from_array_of_vectors( embeddings, )), - regenerate, + regenerate: regenerate && + // Meilisearch does not handle well dumps with fragments, because as the fragments + // are marked as user-provided, + // all embeddings would be regenerated on any settings change or document update. + // To prevent this, we mark embeddings has non regenerate in this case. + !has_fragments, }; vectors .insert(embedder_name, serde_json::to_value(embeddings).unwrap()); From 01d1ef65c4b3a3b306edf6f2589fe029f1c0f83f Mon Sep 17 00:00:00 2001 From: Louis Dureuil Date: Mon, 21 Jul 2025 15:10:25 +0200 Subject: [PATCH 126/135] Update search and docs usages --- crates/meilisearch/src/routes/indexes/documents.rs | 9 +++++++-- crates/meilisearch/src/search/mod.rs | 9 ++++++--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/crates/meilisearch/src/routes/indexes/documents.rs b/crates/meilisearch/src/routes/indexes/documents.rs index 947cd153f..138f5140f 100644 --- a/crates/meilisearch/src/routes/indexes/documents.rs +++ b/crates/meilisearch/src/routes/indexes/documents.rs @@ -19,6 +19,7 @@ use meilisearch_types::error::{Code, ResponseError}; use meilisearch_types::heed::RoTxn; use meilisearch_types::index_uid::IndexUid; use meilisearch_types::milli::documents::sort::recursive_sort; +use meilisearch_types::milli::index::EmbeddingsWithMetadata; use meilisearch_types::milli::update::IndexDocumentsMethod; use meilisearch_types::milli::vector::parsed_vectors::ExplicitVectors; use meilisearch_types::milli::{AscDesc, DocumentId}; @@ -1460,9 +1461,13 @@ fn some_documents<'a, 't: 'a>( Some(Value::Object(map)) => map, _ => Default::default(), }; - for (name, (vector, regenerate)) in index.embeddings(rtxn, key)? { + for ( + name, + EmbeddingsWithMetadata { embeddings, regenerate, has_fragments: _ }, + ) in index.embeddings(rtxn, key)? + { let embeddings = - ExplicitVectors { embeddings: Some(vector.into()), regenerate }; + ExplicitVectors { embeddings: Some(embeddings.into()), regenerate }; vectors.insert( name, serde_json::to_value(embeddings).map_err(MeilisearchHttpError::from)?, diff --git a/crates/meilisearch/src/search/mod.rs b/crates/meilisearch/src/search/mod.rs index 93efad67f..82096e7b4 100644 --- a/crates/meilisearch/src/search/mod.rs +++ b/crates/meilisearch/src/search/mod.rs @@ -16,7 +16,7 @@ use meilisearch_types::error::{Code, ResponseError}; use meilisearch_types::heed::RoTxn; use meilisearch_types::index_uid::IndexUid; use meilisearch_types::locales::Locale; -use meilisearch_types::milli::index::{self, SearchParameters}; +use meilisearch_types::milli::index::{self, EmbeddingsWithMetadata, SearchParameters}; use meilisearch_types::milli::score_details::{ScoreDetails, ScoringStrategy}; use meilisearch_types::milli::vector::parsed_vectors::ExplicitVectors; use meilisearch_types::milli::vector::Embedder; @@ -1528,8 +1528,11 @@ impl<'a> HitMaker<'a> { Some(Value::Object(map)) => map, _ => Default::default(), }; - for (name, (vector, regenerate)) in self.index.embeddings(self.rtxn, id)? { - let embeddings = ExplicitVectors { embeddings: Some(vector.into()), regenerate }; + for (name, EmbeddingsWithMetadata { embeddings, regenerate, has_fragments: _ }) in + self.index.embeddings(self.rtxn, id)? + { + let embeddings = + ExplicitVectors { embeddings: Some(embeddings.into()), regenerate }; vectors.insert( name, serde_json::to_value(embeddings).map_err(InternalError::SerdeJson)?, From 6dc241f9dec9bc9c91e1959e84cdab68e238e280 Mon Sep 17 00:00:00 2001 From: Louis Dureuil Date: Mon, 21 Jul 2025 15:10:39 +0200 Subject: [PATCH 127/135] Fix tests --- .../src/scheduler/test_embedders.rs | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/crates/index-scheduler/src/scheduler/test_embedders.rs b/crates/index-scheduler/src/scheduler/test_embedders.rs index a9b920bd2..791fed4d8 100644 --- a/crates/index-scheduler/src/scheduler/test_embedders.rs +++ b/crates/index-scheduler/src/scheduler/test_embedders.rs @@ -3,6 +3,7 @@ use std::collections::BTreeMap; use big_s::S; use insta::assert_json_snapshot; use meili_snap::{json_string, snapshot}; +use meilisearch_types::milli::index::EmbeddingsWithMetadata; use meilisearch_types::milli::update::Setting; use meilisearch_types::milli::vector::settings::EmbeddingSettings; use meilisearch_types::milli::vector::SearchQuery; @@ -220,8 +221,8 @@ fn import_vectors() { let embeddings = index.embeddings(&rtxn, 0).unwrap(); - assert_json_snapshot!(embeddings[&simple_hf_name].0[0] == lab_embed, @"true"); - assert_json_snapshot!(embeddings[&fakerest_name].0[0] == beagle_embed, @"true"); + assert_json_snapshot!(embeddings[&simple_hf_name].embeddings[0] == lab_embed, @"true"); + assert_json_snapshot!(embeddings[&fakerest_name].embeddings[0] == beagle_embed, @"true"); let doc = index.documents(&rtxn, std::iter::once(0)).unwrap()[0].1; let fields_ids_map = index.fields_ids_map(&rtxn).unwrap(); @@ -311,9 +312,9 @@ fn import_vectors() { let embeddings = index.embeddings(&rtxn, 0).unwrap(); // automatically changed to patou because set to regenerate - assert_json_snapshot!(embeddings[&simple_hf_name].0[0] == patou_embed, @"true"); + assert_json_snapshot!(embeddings[&simple_hf_name].embeddings[0] == patou_embed, @"true"); // remained beagle - assert_json_snapshot!(embeddings[&fakerest_name].0[0] == beagle_embed, @"true"); + assert_json_snapshot!(embeddings[&fakerest_name].embeddings[0] == beagle_embed, @"true"); let doc = index.documents(&rtxn, std::iter::once(0)).unwrap()[0].1; let fields_ids_map = index.fields_ids_map(&rtxn).unwrap(); @@ -497,13 +498,13 @@ fn import_vectors_first_and_embedder_later() { let docid = index.external_documents_ids.get(&rtxn, "0").unwrap().unwrap(); let embeddings = index.embeddings(&rtxn, docid).unwrap(); - let (embedding, _) = &embeddings["my_doggo_embedder"]; - assert!(!embedding.is_empty(), "{embedding:?}"); + let EmbeddingsWithMetadata { embeddings, .. } = &embeddings["my_doggo_embedder"]; + assert!(!embeddings.is_empty(), "{embeddings:?}"); // the document with the id 3 should keep its original embedding let docid = index.external_documents_ids.get(&rtxn, "3").unwrap().unwrap(); let embeddings = index.embeddings(&rtxn, docid).unwrap(); - let (embeddings, _) = &embeddings["my_doggo_embedder"]; + let EmbeddingsWithMetadata { embeddings, .. } = &embeddings["my_doggo_embedder"]; snapshot!(embeddings.len(), @"1"); assert!(embeddings[0].iter().all(|i| *i == 3.0), "{:?}", embeddings[0]); @@ -558,7 +559,7 @@ fn import_vectors_first_and_embedder_later() { "###); let embeddings = index.embeddings(&rtxn, docid).unwrap(); - let (embedding, _) = &embeddings["my_doggo_embedder"]; + let EmbeddingsWithMetadata { embeddings: embedding, .. } = &embeddings["my_doggo_embedder"]; assert!(!embedding.is_empty()); assert!(!embedding[0].iter().all(|i| *i == 3.0), "{:?}", embedding[0]); @@ -566,7 +567,7 @@ fn import_vectors_first_and_embedder_later() { // the document with the id 4 should generate an embedding let docid = index.external_documents_ids.get(&rtxn, "4").unwrap().unwrap(); let embeddings = index.embeddings(&rtxn, docid).unwrap(); - let (embedding, _) = &embeddings["my_doggo_embedder"]; + let EmbeddingsWithMetadata { embeddings: embedding, .. } = &embeddings["my_doggo_embedder"]; assert!(!embedding.is_empty()); } @@ -696,7 +697,7 @@ fn delete_document_containing_vector() { "###); let docid = index.external_documents_ids.get(&rtxn, "0").unwrap().unwrap(); let embeddings = index.embeddings(&rtxn, docid).unwrap(); - let (embedding, _) = &embeddings["manual"]; + let EmbeddingsWithMetadata { embeddings: embedding, .. } = &embeddings["manual"]; assert!(!embedding.is_empty(), "{embedding:?}"); index_scheduler From ba0f50e5efe280c111feb2714b1c635bd07a7434 Mon Sep 17 00:00:00 2001 From: nicolasvienot Date: Mon, 21 Jul 2025 20:00:36 +0200 Subject: [PATCH 128/135] fix: update default deserialization for ChatSearchParams limit field --- crates/milli/src/update/chat.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/milli/src/update/chat.rs b/crates/milli/src/update/chat.rs index 2f364894d..a6c0b3fbc 100644 --- a/crates/milli/src/update/chat.rs +++ b/crates/milli/src/update/chat.rs @@ -93,7 +93,7 @@ pub struct ChatSearchParams { pub hybrid: Setting, #[serde(default, skip_serializing_if = "Setting::is_not_set")] - #[deserr(default = Setting::Set(20))] + #[deserr(default)] #[schema(value_type = Option)] pub limit: Setting, From f6bc6854f8080af25453f0722016716c961ca36e Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Mon, 28 Jul 2025 11:10:55 +0200 Subject: [PATCH 129/135] Fix key action inconsistencies --- crates/meilisearch-types/src/keys.rs | 75 ++++++++++++++++++++++++++-- 1 file changed, 72 insertions(+), 3 deletions(-) diff --git a/crates/meilisearch-types/src/keys.rs b/crates/meilisearch-types/src/keys.rs index e210f8df3..aec3199a3 100644 --- a/crates/meilisearch-types/src/keys.rs +++ b/crates/meilisearch-types/src/keys.rs @@ -233,9 +233,6 @@ pub enum Action { #[serde(rename = "*")] #[deserr(rename = "*")] All = 0, - #[serde(rename = "*.get")] - #[deserr(rename = "*.get")] - AllGet, #[serde(rename = "search")] #[deserr(rename = "search")] Search, @@ -365,6 +362,9 @@ pub enum Action { #[serde(rename = "chatsSettings.update")] #[deserr(rename = "chatsSettings.update")] ChatsSettingsUpdate, + #[serde(rename = "*.get")] + #[deserr(rename = "*.get")] + AllGet, } impl Action { @@ -403,6 +403,7 @@ impl Action { METRICS_GET => Some(Self::MetricsGet), DUMPS_ALL => Some(Self::DumpsAll), DUMPS_CREATE => Some(Self::DumpsCreate), + SNAPSHOTS_ALL => Some(Self::SnapshotsAll), SNAPSHOTS_CREATE => Some(Self::SnapshotsCreate), VERSION => Some(Self::Version), KEYS_CREATE => Some(Self::KeysAdd), @@ -411,8 +412,10 @@ impl Action { KEYS_DELETE => Some(Self::KeysDelete), EXPERIMENTAL_FEATURES_GET => Some(Self::ExperimentalFeaturesGet), EXPERIMENTAL_FEATURES_UPDATE => Some(Self::ExperimentalFeaturesUpdate), + EXPORT => Some(Self::Export), NETWORK_GET => Some(Self::NetworkGet), NETWORK_UPDATE => Some(Self::NetworkUpdate), + ALL_GET => Some(Self::AllGet), _otherwise => None, } } @@ -497,6 +500,7 @@ pub mod actions { pub const METRICS_GET: u8 = MetricsGet.repr(); pub const DUMPS_ALL: u8 = DumpsAll.repr(); pub const DUMPS_CREATE: u8 = DumpsCreate.repr(); + pub const SNAPSHOTS_ALL: u8 = SnapshotsAll.repr(); pub const SNAPSHOTS_CREATE: u8 = SnapshotsCreate.repr(); pub const VERSION: u8 = Version.repr(); pub const KEYS_CREATE: u8 = KeysAdd.repr(); @@ -519,3 +523,68 @@ pub mod actions { pub const CHATS_SETTINGS_GET: u8 = ChatsSettingsGet.repr(); pub const CHATS_SETTINGS_UPDATE: u8 = ChatsSettingsUpdate.repr(); } + +#[cfg(test)] +pub(crate) mod test { + use super::actions::*; + use super::Action::*; + use super::*; + + #[test] + fn test_action_repr_and_constants() { + assert!(All.repr() == 0 && ALL == 0); + assert!(Search.repr() == 1 && SEARCH == 1); + assert!(DocumentsAll.repr() == 2 && DOCUMENTS_ALL == 2); + assert!(DocumentsAdd.repr() == 3 && DOCUMENTS_ADD == 3); + assert!(DocumentsGet.repr() == 4 && DOCUMENTS_GET == 4); + assert!(DocumentsDelete.repr() == 5 && DOCUMENTS_DELETE == 5); + assert!(IndexesAll.repr() == 6 && INDEXES_ALL == 6); + assert!(IndexesAdd.repr() == 7 && INDEXES_CREATE == 7); + assert!(IndexesGet.repr() == 8 && INDEXES_GET == 8); + assert!(IndexesUpdate.repr() == 9 && INDEXES_UPDATE == 9); + assert!(IndexesDelete.repr() == 10 && INDEXES_DELETE == 10); + assert!(IndexesSwap.repr() == 11 && INDEXES_SWAP == 11); + assert!(TasksAll.repr() == 12 && TASKS_ALL == 12); + assert!(TasksCancel.repr() == 13 && TASKS_CANCEL == 13); + assert!(TasksDelete.repr() == 14 && TASKS_DELETE == 14); + assert!(TasksGet.repr() == 15 && TASKS_GET == 15); + assert!(SettingsAll.repr() == 16 && SETTINGS_ALL == 16); + assert!(SettingsGet.repr() == 17 && SETTINGS_GET == 17); + assert!(SettingsUpdate.repr() == 18 && SETTINGS_UPDATE == 18); + assert!(StatsAll.repr() == 19 && STATS_ALL == 19); + assert!(StatsGet.repr() == 20 && STATS_GET == 20); + assert!(MetricsAll.repr() == 21 && METRICS_ALL == 21); + assert!(MetricsGet.repr() == 22 && METRICS_GET == 22); + assert!(DumpsAll.repr() == 23 && DUMPS_ALL == 23); + assert!(DumpsCreate.repr() == 24 && DUMPS_CREATE == 24); + assert!(SnapshotsAll.repr() == 25 && SNAPSHOTS_ALL == 25); + assert!(SnapshotsCreate.repr() == 26 && SNAPSHOTS_CREATE == 26); + assert!(Version.repr() == 27 && VERSION == 27); + assert!(KeysAdd.repr() == 28 && KEYS_CREATE == 28); + assert!(KeysGet.repr() == 29 && KEYS_GET == 29); + assert!(KeysUpdate.repr() == 30 && KEYS_UPDATE == 30); + assert!(KeysDelete.repr() == 31 && KEYS_DELETE == 31); + assert!(ExperimentalFeaturesGet.repr() == 32 && EXPERIMENTAL_FEATURES_GET == 32); + assert!(ExperimentalFeaturesUpdate.repr() == 33 && EXPERIMENTAL_FEATURES_UPDATE == 33); + assert!(Export.repr() == 34 && EXPORT == 34); + assert!(NetworkGet.repr() == 35 && NETWORK_GET == 35); + assert!(NetworkUpdate.repr() == 36 && NETWORK_UPDATE == 36); + assert!(ChatCompletions.repr() == 37 && CHAT_COMPLETIONS == 37); + assert!(ChatsAll.repr() == 38 && CHATS_ALL == 38); + assert!(ChatsGet.repr() == 39 && CHATS_GET == 39); + assert!(ChatsDelete.repr() == 40 && CHATS_DELETE == 40); + assert!(ChatsSettingsAll.repr() == 41 && CHATS_SETTINGS_ALL == 41); + assert!(ChatsSettingsGet.repr() == 42 && CHATS_SETTINGS_GET == 42); + assert!(ChatsSettingsUpdate.repr() == 43 && CHATS_SETTINGS_UPDATE == 43); + assert!(AllGet.repr() == 44 && ALL_GET == 44); + } + + #[test] + fn test_from_repr() { + for action in enum_iterator::all::() { + let repr = action.repr(); + let action_from_repr = Action::from_repr(repr); + assert_eq!(Some(action), action_from_repr, "Failed for action: {:?}", action); + } + } +} From d90c76d3cc24f9822ae091174aa27499176e646f Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Mon, 28 Jul 2025 11:35:15 +0200 Subject: [PATCH 130/135] Update tests --- crates/meilisearch/tests/auth/api_keys.rs | 2 +- crates/meilisearch/tests/auth/errors.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/meilisearch/tests/auth/api_keys.rs b/crates/meilisearch/tests/auth/api_keys.rs index 0b8a3d2c5..6dc3f429b 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`, `export`, `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 `*`, `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`, `*.get`", "code": "invalid_api_key_actions", "type": "invalid_request", "link": "https://docs.meilisearch.com/errors#invalid_api_key_actions" diff --git a/crates/meilisearch/tests/auth/errors.rs b/crates/meilisearch/tests/auth/errors.rs index e8d935fde..b16ccb2f5 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 `*`, `*.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`", + "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`, `*.get`", "code": "invalid_api_key_actions", "type": "invalid_request", "link": "https://docs.meilisearch.com/errors#invalid_api_key_actions" From 41262b008b8fb684626c0bb7fedb9d5d165e2833 Mon Sep 17 00:00:00 2001 From: nicolasvienot Date: Wed, 30 Jul 2025 17:55:02 +0200 Subject: [PATCH 131/135] feat(chat): update metrics name --- crates/meilisearch/src/metrics.rs | 20 +++++++++---------- .../src/routes/chats/chat_completions.rs | 12 +++++------ 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/crates/meilisearch/src/metrics.rs b/crates/meilisearch/src/metrics.rs index d52e04cc6..027fc9aa5 100644 --- a/crates/meilisearch/src/metrics.rs +++ b/crates/meilisearch/src/metrics.rs @@ -15,30 +15,30 @@ lazy_static! { "Meilisearch number of degraded search requests" )) .expect("Can't create a metric"); - pub static ref MEILISEARCH_CHAT_SEARCH_REQUESTS: IntCounterVec = register_int_counter_vec!( + pub static ref MEILISEARCH_CHAT_SEARCHES_TOTAL: IntCounterVec = register_int_counter_vec!( opts!( - "meilisearch_chat_search_requests", - "Meilisearch number of search requests performed by the chat route itself" + "meilisearch_chat_searches_total", + "Total number of searches performed by the chat route" ), &["type"] ) .expect("Can't create a metric"); - pub static ref MEILISEARCH_CHAT_PROMPT_TOKENS_USAGE: IntCounterVec = register_int_counter_vec!( - opts!("meilisearch_chat_prompt_tokens_usage", "Meilisearch Chat Prompt Tokens Usage"), + pub static ref MEILISEARCH_CHAT_PROMPT_TOKENS_TOTAL: IntCounterVec = register_int_counter_vec!( + opts!("meilisearch_chat_prompt_tokens_total", "Total number of prompt tokens consumed"), &["workspace", "model"] ) .expect("Can't create a metric"); - pub static ref MEILISEARCH_CHAT_COMPLETION_TOKENS_USAGE: IntCounterVec = + pub static ref MEILISEARCH_CHAT_COMPLETION_TOKENS_TOTAL: IntCounterVec = register_int_counter_vec!( opts!( - "meilisearch_chat_completion_tokens_usage", - "Meilisearch Chat Completion Tokens Usage" + "meilisearch_chat_completion_tokens_total", + "Total number of completion tokens consumed" ), &["workspace", "model"] ) .expect("Can't create a metric"); - pub static ref MEILISEARCH_CHAT_TOTAL_TOKENS_USAGE: IntCounterVec = register_int_counter_vec!( - opts!("meilisearch_chat_total_tokens_usage", "Meilisearch Chat Total Tokens Usage"), + pub static ref MEILISEARCH_CHAT_TOKENS_TOTAL: IntCounterVec = register_int_counter_vec!( + opts!("meilisearch_chat_tokens_total", "Total number of tokens consumed (prompt + completion)"), &["workspace", "model"] ) .expect("Can't create a metric"); diff --git a/crates/meilisearch/src/routes/chats/chat_completions.rs b/crates/meilisearch/src/routes/chats/chat_completions.rs index b636678f5..f2c17a696 100644 --- a/crates/meilisearch/src/routes/chats/chat_completions.rs +++ b/crates/meilisearch/src/routes/chats/chat_completions.rs @@ -50,8 +50,8 @@ use crate::error::MeilisearchHttpError; use crate::extractors::authentication::policies::ActionPolicy; use crate::extractors::authentication::{extract_token_from_request, GuardedData, Policy as _}; use crate::metrics::{ - MEILISEARCH_CHAT_COMPLETION_TOKENS_USAGE, MEILISEARCH_CHAT_PROMPT_TOKENS_USAGE, - MEILISEARCH_CHAT_SEARCH_REQUESTS, MEILISEARCH_CHAT_TOTAL_TOKENS_USAGE, + MEILISEARCH_CHAT_COMPLETION_TOKENS_TOTAL, MEILISEARCH_CHAT_PROMPT_TOKENS_TOTAL, + MEILISEARCH_CHAT_SEARCHES_TOTAL, MEILISEARCH_CHAT_TOKENS_TOTAL, MEILISEARCH_DEGRADED_SEARCH_REQUESTS, }; use crate::routes::chats::utils::SseEventSender; @@ -319,7 +319,7 @@ async fn process_search_request( }; let mut documents = Vec::new(); if let Ok((ref rtxn, ref search_result)) = output { - MEILISEARCH_CHAT_SEARCH_REQUESTS.with_label_values(&["internal"]).inc(); + MEILISEARCH_CHAT_SEARCHES_TOTAL.with_label_values(&["internal"]).inc(); if search_result.degraded { MEILISEARCH_DEGRADED_SEARCH_REQUESTS.inc(); } @@ -596,13 +596,13 @@ async fn run_conversation( match result { Ok(resp) => { if let Some(usage) = resp.usage.as_ref() { - MEILISEARCH_CHAT_PROMPT_TOKENS_USAGE + MEILISEARCH_CHAT_PROMPT_TOKENS_TOTAL .with_label_values(&[workspace_uid, &chat_completion.model]) .inc_by(usage.prompt_tokens as u64); - MEILISEARCH_CHAT_COMPLETION_TOKENS_USAGE + MEILISEARCH_CHAT_COMPLETION_TOKENS_TOTAL .with_label_values(&[workspace_uid, &chat_completion.model]) .inc_by(usage.completion_tokens as u64); - MEILISEARCH_CHAT_TOTAL_TOKENS_USAGE + MEILISEARCH_CHAT_TOKENS_TOTAL .with_label_values(&[workspace_uid, &chat_completion.model]) .inc_by(usage.total_tokens as u64); } From 941da56ee3a860db5b94149ab09440f548dd2f60 Mon Sep 17 00:00:00 2001 From: nicolasvienot Date: Thu, 31 Jul 2025 06:49:53 +0200 Subject: [PATCH 132/135] fix linter --- crates/meilisearch/src/metrics.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/meilisearch/src/metrics.rs b/crates/meilisearch/src/metrics.rs index 027fc9aa5..607bc91eb 100644 --- a/crates/meilisearch/src/metrics.rs +++ b/crates/meilisearch/src/metrics.rs @@ -38,7 +38,10 @@ lazy_static! { ) .expect("Can't create a metric"); pub static ref MEILISEARCH_CHAT_TOKENS_TOTAL: IntCounterVec = register_int_counter_vec!( - opts!("meilisearch_chat_tokens_total", "Total number of tokens consumed (prompt + completion)"), + opts!( + "meilisearch_chat_tokens_total", + "Total number of tokens consumed (prompt + completion)" + ), &["workspace", "model"] ) .expect("Can't create a metric"); From 2ec80a1ae2b67886da052b41d913a57335cbce69 Mon Sep 17 00:00:00 2001 From: ManyTheFish Date: Thu, 31 Jul 2025 17:14:38 +0200 Subject: [PATCH 133/135] update mini-dashboard to v0.2.21 --- crates/meilisearch/Cargo.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/meilisearch/Cargo.toml b/crates/meilisearch/Cargo.toml index 21f6b58e5..9da316b84 100644 --- a/crates/meilisearch/Cargo.toml +++ b/crates/meilisearch/Cargo.toml @@ -170,5 +170,5 @@ german = ["meilisearch-types/german"] turkish = ["meilisearch-types/turkish"] [package.metadata.mini-dashboard] -assets-url = "https://github.com/meilisearch/mini-dashboard/releases/download/v0.2.20/build.zip" -sha1 = "82a7ddd7bf14bb5323c3d235d2b62892a98b6a59" +assets-url = "https://github.com/meilisearch/mini-dashboard/releases/download/v0.2.21/build.zip" +sha1 = "94f56a8e24e2e3a1bc1bd7d9ceaa23464a5e241a" From 05dd8e0d6279f91a76e4bb2cd338e8deeec9fc58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Renault?= Date: Mon, 4 Aug 2025 11:14:10 +0200 Subject: [PATCH 134/135] update mini-dashboard to v0.2.22 --- crates/meilisearch/Cargo.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/meilisearch/Cargo.toml b/crates/meilisearch/Cargo.toml index 9da316b84..5cbbb6666 100644 --- a/crates/meilisearch/Cargo.toml +++ b/crates/meilisearch/Cargo.toml @@ -170,5 +170,5 @@ german = ["meilisearch-types/german"] turkish = ["meilisearch-types/turkish"] [package.metadata.mini-dashboard] -assets-url = "https://github.com/meilisearch/mini-dashboard/releases/download/v0.2.21/build.zip" -sha1 = "94f56a8e24e2e3a1bc1bd7d9ceaa23464a5e241a" +assets-url = "https://github.com/meilisearch/mini-dashboard/releases/download/v0.2.22/build.zip" +sha1 = "b70b2036b5f167da9ea0b637da8b320c7ea88254" From 454f8b36f45367c9e92df85e8bbb30d67ab77f62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Renault?= Date: Mon, 4 Aug 2025 16:14:33 +0200 Subject: [PATCH 135/135] Make clippy happy --- crates/meilisearch/tests/common/mod.rs | 16 ++++++---------- .../meilisearch/tests/documents/get_documents.rs | 2 +- crates/meilisearch/tests/vector/fragments.rs | 2 +- 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/crates/meilisearch/tests/common/mod.rs b/crates/meilisearch/tests/common/mod.rs index d6df761d4..03b1271f1 100644 --- a/crates/meilisearch/tests/common/mod.rs +++ b/crates/meilisearch/tests/common/mod.rs @@ -3,10 +3,8 @@ pub mod index; pub mod server; pub mod service; -use std::{ - collections::BTreeMap, - fmt::{self, Display}, -}; +use std::collections::BTreeMap; +use std::fmt::{self, Display}; use actix_http::StatusCode; #[allow(unused)] @@ -17,10 +15,8 @@ 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 wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, Request, ResponseTemplate}; use crate::common::index::Index; @@ -618,7 +614,7 @@ pub async fn init_fragments_index() -> (Server, String, crate::common::Va let (value, code) = index.add_documents(documents, None).await; assert_eq!(code, StatusCode::ACCEPTED); - let _task = index.wait_task(value.uid()).await.succeeded(); + let _task = server.wait_task(value.uid()).await.succeeded(); let uid = index.uid.clone(); (server, uid, settings) @@ -683,7 +679,7 @@ pub async fn init_fragments_index_composite() -> (Server, String, crate:: let (value, code) = index.add_documents(documents, None).await; assert_eq!(code, StatusCode::ACCEPTED); - index.wait_task(value.uid()).await.succeeded(); + server.wait_task(value.uid()).await.succeeded(); let uid = index.uid.clone(); (server, uid, settings) diff --git a/crates/meilisearch/tests/documents/get_documents.rs b/crates/meilisearch/tests/documents/get_documents.rs index 1209b74f0..b3c68351f 100644 --- a/crates/meilisearch/tests/documents/get_documents.rs +++ b/crates/meilisearch/tests/documents/get_documents.rs @@ -87,7 +87,7 @@ async fn get_document() { async fn get_document_sorted() { let server = Server::new_shared(); let index = server.unique_index(); - index.load_test_set().await; + index.load_test_set(server).await; let (task, _status_code) = index.update_settings_sortable_attributes(json!(["age", "email", "gender", "name"])).await; diff --git a/crates/meilisearch/tests/vector/fragments.rs b/crates/meilisearch/tests/vector/fragments.rs index a994eb64c..81c2e3a55 100644 --- a/crates/meilisearch/tests/vector/fragments.rs +++ b/crates/meilisearch/tests/vector/fragments.rs @@ -149,7 +149,7 @@ async fn replace_document() { let (value, code) = index.add_documents(documents, None).await; snapshot!(code, @"202 Accepted"); - index.wait_task(value.uid()).await.succeeded(); + server.wait_task(value.uid()).await.succeeded(); // Make sure kefir now has 2 vectors let (documents, code) = index

cO=OG1S!rnH1sm4vNKr+Z!3;$?&*k|Tcua5)I)4@es^MeYq&RlxeW-O%| z3R7`-jca;7X&JkvlUb;}8*WKwU2k@)73sY{b5RNaF^f3t$M!gzwx^y|x+UgUv`$$>3E++aK8&5++53)A=E%wRHH(WyP7##`!+)~cZf5`hoS;P& zrIUF;R<-xiB~_EIk?6#Te3ShA3F{X(;d%O#&0jxtppI)_9#4YICv{6j09nyAqq(8F-O(hlbH7Lam#?;exB6}s)Wh9hjQ z$o=r1z2w8w&I$cB5EUMQ7TK<+$pkSk+8TGHzI~9 z*6$$cf1OQ_ud8G($rsTz&qc`;OBzOmY#T(j7Djuq8iRw4(g$kzkaErwC_|K0$SAX1~jtE9Xld_E>TeoXj}|HIBi4_)}hP9^fc4GsQ)g2h*&;N&wr z*bwEv40bP&ag_G&aiA2%^6LD}#bQgJU`Pnn9^rGzVRzg>d-egjq(Gz0dv1H}2F~pI z_`5~;I;Ahd=23^Jgn7Gw#GXGpC;PBYzKZD4-BbU@$f9PFr)lhdr@UjO_(W@>)q3Zc%L$W(4q1Ik&+OFK*K^!5hMdCo5f zFd9+M_#q5AUyr8X&dj3BG#h@%;fFedpkS@+TlSx9xFpS`SVtneQMkSK&=uZo^g9K5?k?9Pl}kYUqFu|)6|5%)InP|$Y)unaJwKh*uZ7|0%6_>a>-N4|`uB(Amq>gc^~2z-_2diWP6sc^95VZ8 zOXSNFYe32PMXD&&$?UGCF^KH_mXmy}!{IN)+b_bvCBl4?U 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 106/135] 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 107/135] 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 108/135] 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 109/135] 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 110/135] 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 { From fe15e11c9de7ef9866c04fcd2b07d1d2644aad6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Renault?= Date: Thu, 17 Jul 2025 16:12:23 +0200 Subject: [PATCH 111/135] Introduce a new CLI and env var to use the old document indexer when importing dumps --- crates/index-scheduler/src/lib.rs | 12 +++ .../src/analytics/segment_analytics.rs | 3 + crates/meilisearch/src/lib.rs | 79 ++++++++++--------- crates/meilisearch/src/option.rs | 16 ++++ 4 files changed, 73 insertions(+), 37 deletions(-) diff --git a/crates/index-scheduler/src/lib.rs b/crates/index-scheduler/src/lib.rs index b2f27d66b..f91e45914 100644 --- a/crates/index-scheduler/src/lib.rs +++ b/crates/index-scheduler/src/lib.rs @@ -139,6 +139,8 @@ pub struct IndexSchedulerOptions { pub embedding_cache_cap: usize, /// Snapshot compaction status. pub experimental_no_snapshot_compaction: bool, + /// Whether dump import use the old document indexer or the new one. + pub experimental_no_edition_2024_for_dumps: bool, } /// Structure which holds meilisearch's indexes and schedules the tasks @@ -168,6 +170,9 @@ pub struct IndexScheduler { /// Whether we should automatically cleanup the task queue or not. pub(crate) cleanup_enabled: bool, + /// Whether we should use the old document indexer or the new one. + pub(crate) experimental_no_edition_2024_for_dumps: bool, + /// The webhook url we should send tasks to after processing every batches. pub(crate) webhook_url: Option, /// The Authorization header to send to the webhook URL. @@ -210,6 +215,7 @@ impl IndexScheduler { index_mapper: self.index_mapper.clone(), cleanup_enabled: self.cleanup_enabled, + experimental_no_edition_2024_for_dumps: self.experimental_no_edition_2024_for_dumps, webhook_url: self.webhook_url.clone(), webhook_authorization_header: self.webhook_authorization_header.clone(), embedders: self.embedders.clone(), @@ -296,6 +302,7 @@ impl IndexScheduler { index_mapper, env, cleanup_enabled: options.cleanup_enabled, + experimental_no_edition_2024_for_dumps: options.experimental_no_edition_2024_for_dumps, webhook_url: options.webhook_url, webhook_authorization_header: options.webhook_authorization_header, embedders: Default::default(), @@ -594,6 +601,11 @@ impl IndexScheduler { Ok(nbr_index_processing_tasks > 0) } + /// Whether the index should use the old document indexer. + pub fn no_edition_2024_for_dumps(&self) -> bool { + self.experimental_no_edition_2024_for_dumps + } + /// Return the tasks matching the query from the user's point of view along /// with the total number of tasks matching the query, ignoring from and limit. /// diff --git a/crates/meilisearch/src/analytics/segment_analytics.rs b/crates/meilisearch/src/analytics/segment_analytics.rs index 0abc5c817..a96ddf068 100644 --- a/crates/meilisearch/src/analytics/segment_analytics.rs +++ b/crates/meilisearch/src/analytics/segment_analytics.rs @@ -203,6 +203,7 @@ struct Infos { experimental_composite_embedders: bool, experimental_embedding_cache_entries: usize, experimental_no_snapshot_compaction: bool, + experimental_no_edition_2024_for_dumps: bool, experimental_no_edition_2024_for_settings: bool, gpu_enabled: bool, db_path: bool, @@ -253,6 +254,7 @@ impl Infos { experimental_limit_batched_tasks_total_size, experimental_embedding_cache_entries, experimental_no_snapshot_compaction, + experimental_no_edition_2024_for_dumps, http_addr, master_key: _, env, @@ -329,6 +331,7 @@ impl Infos { experimental_composite_embedders: composite_embedders, experimental_embedding_cache_entries, experimental_no_snapshot_compaction, + experimental_no_edition_2024_for_dumps, gpu_enabled: meilisearch_types::milli::vector::is_cuda_enabled(), db_path: db_path != PathBuf::from("./data.ms"), import_dump: import_dump.is_some(), diff --git a/crates/meilisearch/src/lib.rs b/crates/meilisearch/src/lib.rs index 43d7afe0e..8907a5632 100644 --- a/crates/meilisearch/src/lib.rs +++ b/crates/meilisearch/src/lib.rs @@ -238,6 +238,7 @@ pub fn setup_meilisearch(opt: &Opt) -> anyhow::Result<(Arc, Arc< auto_upgrade: opt.experimental_dumpless_upgrade, embedding_cache_cap: opt.experimental_embedding_cache_entries, experimental_no_snapshot_compaction: opt.experimental_no_snapshot_compaction, + experimental_no_edition_2024_for_dumps: opt.experimental_no_edition_2024_for_dumps, }; let binary_version = (VERSION_MAJOR, VERSION_MINOR, VERSION_PATCH); @@ -553,47 +554,51 @@ fn import_dump( let embedder_stats: Arc = Default::default(); builder.execute(&|| false, &progress, embedder_stats.clone())?; - // 5.3 Import the documents. - // 5.3.1 We need to recreate the grenad+obkv format accepted by the index. - tracing::info!("Importing the documents."); - let file = tempfile::tempfile()?; - let mut builder = DocumentsBatchBuilder::new(BufWriter::new(file)); - for document in index_reader.documents()? { - builder.append_json_object(&document?)?; + if index_scheduler.no_edition_2024_for_dumps() { + // 5.3 Import the documents. + // 5.3.1 We need to recreate the grenad+obkv format accepted by the index. + tracing::info!("Importing the documents."); + let file = tempfile::tempfile()?; + let mut builder = DocumentsBatchBuilder::new(BufWriter::new(file)); + for document in index_reader.documents()? { + builder.append_json_object(&document?)?; + } + + // This flush the content of the batch builder. + let file = builder.into_inner()?.into_inner()?; + + // 5.3.2 We feed it to the milli index. + let reader = BufReader::new(file); + let reader = DocumentsBatchReader::from_reader(reader)?; + + let embedder_configs = index.embedding_configs().embedding_configs(&wtxn)?; + let embedders = index_scheduler.embedders(uid.to_string(), embedder_configs)?; + + let builder = milli::update::IndexDocuments::new( + &mut wtxn, + &index, + indexer_config, + IndexDocumentsConfig { + update_method: IndexDocumentsMethod::ReplaceDocuments, + ..Default::default() + }, + |indexing_step| tracing::trace!("update: {:?}", indexing_step), + || false, + &embedder_stats, + )?; + + let builder = builder.with_embedders(embedders); + + let (builder, user_result) = builder.add_documents(reader)?; + let user_result = user_result?; + tracing::info!(documents_found = user_result, "{} documents found.", user_result); + builder.execute()?; + } else { + unimplemented!("new document indexer when importing dumps"); } - // This flush the content of the batch builder. - let file = builder.into_inner()?.into_inner()?; - - // 5.3.2 We feed it to the milli index. - let reader = BufReader::new(file); - let reader = DocumentsBatchReader::from_reader(reader)?; - - let embedder_configs = index.embedding_configs().embedding_configs(&wtxn)?; - let embedders = index_scheduler.embedders(uid.to_string(), embedder_configs)?; - - let builder = milli::update::IndexDocuments::new( - &mut wtxn, - &index, - indexer_config, - IndexDocumentsConfig { - update_method: IndexDocumentsMethod::ReplaceDocuments, - ..Default::default() - }, - |indexing_step| tracing::trace!("update: {:?}", indexing_step), - || false, - &embedder_stats, - )?; - - let builder = builder.with_embedders(embedders); - - let (builder, user_result) = builder.add_documents(reader)?; - let user_result = user_result?; - tracing::info!(documents_found = user_result, "{} documents found.", user_result); - builder.execute()?; wtxn.commit()?; tracing::info!("All documents successfully imported."); - index_scheduler.refresh_index_stats(&uid)?; } diff --git a/crates/meilisearch/src/option.rs b/crates/meilisearch/src/option.rs index 9658352c8..77106d362 100644 --- a/crates/meilisearch/src/option.rs +++ b/crates/meilisearch/src/option.rs @@ -68,6 +68,8 @@ const MEILI_EXPERIMENTAL_LIMIT_BATCHED_TASKS_TOTAL_SIZE: &str = const MEILI_EXPERIMENTAL_EMBEDDING_CACHE_ENTRIES: &str = "MEILI_EXPERIMENTAL_EMBEDDING_CACHE_ENTRIES"; const MEILI_EXPERIMENTAL_NO_SNAPSHOT_COMPACTION: &str = "MEILI_EXPERIMENTAL_NO_SNAPSHOT_COMPACTION"; +const MEILI_EXPERIMENTAL_NO_EDITION_2024_FOR_DUMPS: &str = + "MEILI_EXPERIMENTAL_NO_EDITION_2024_FOR_DUMPS"; const DEFAULT_CONFIG_FILE_PATH: &str = "./config.toml"; const DEFAULT_DB_PATH: &str = "./data.ms"; const DEFAULT_HTTP_ADDR: &str = "localhost:7700"; @@ -467,6 +469,15 @@ pub struct Opt { #[serde(default)] pub experimental_no_snapshot_compaction: bool, + /// Experimental make dump imports use the old document indexer. + /// + /// When enabled, Meilisearch will use the old document indexer when importing dumps. + /// + /// For more information, see . + #[clap(long, env = MEILI_EXPERIMENTAL_NO_EDITION_2024_FOR_DUMPS)] + #[serde(default)] + pub experimental_no_edition_2024_for_dumps: bool, + #[serde(flatten)] #[clap(flatten)] pub indexer_options: IndexerOpts, @@ -572,6 +583,7 @@ impl Opt { experimental_limit_batched_tasks_total_size, experimental_embedding_cache_entries, experimental_no_snapshot_compaction, + experimental_no_edition_2024_for_dumps, } = self; export_to_env_if_not_present(MEILI_DB_PATH, db_path); export_to_env_if_not_present(MEILI_HTTP_ADDR, http_addr); @@ -672,6 +684,10 @@ impl Opt { MEILI_EXPERIMENTAL_NO_SNAPSHOT_COMPACTION, experimental_no_snapshot_compaction.to_string(), ); + export_to_env_if_not_present( + MEILI_EXPERIMENTAL_NO_EDITION_2024_FOR_DUMPS, + experimental_no_edition_2024_for_dumps.to_string(), + ); indexer_options.export_to_env(); } From 338806283b1303691843150d6530904ca34fb717 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Renault?= Date: Thu, 17 Jul 2025 16:13:00 +0200 Subject: [PATCH 112/135] Do not track meilisearch databases --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 764447352..44cfa8f75 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,7 @@ **/*.json_lines **/*.rs.bk /*.mdb -/data.ms +/*.ms /snapshots /dumps /bench From 760ccffdbd2a1bb51b95de3c024e77e09f46f475 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Renault?= Date: Thu, 17 Jul 2025 17:12:18 +0200 Subject: [PATCH 113/135] Expose the documents files from the dumps --- crates/dump/src/reader/compat/v1_to_v2.rs | 5 +++++ crates/dump/src/reader/compat/v2_to_v3.rs | 8 ++++++++ crates/dump/src/reader/compat/v3_to_v4.rs | 9 +++++++++ crates/dump/src/reader/compat/v4_to_v5.rs | 9 +++++++++ crates/dump/src/reader/compat/v5_to_v6.rs | 8 ++++++++ crates/dump/src/reader/mod.rs | 7 +++++++ crates/dump/src/reader/v1/mod.rs | 4 ++++ crates/dump/src/reader/v2/mod.rs | 4 ++++ crates/dump/src/reader/v3/mod.rs | 4 ++++ crates/dump/src/reader/v4/mod.rs | 4 ++++ crates/dump/src/reader/v5/mod.rs | 4 ++++ crates/dump/src/reader/v6/mod.rs | 4 ++++ 12 files changed, 70 insertions(+) diff --git a/crates/dump/src/reader/compat/v1_to_v2.rs b/crates/dump/src/reader/compat/v1_to_v2.rs index 0d050497b..35d369c3a 100644 --- a/crates/dump/src/reader/compat/v1_to_v2.rs +++ b/crates/dump/src/reader/compat/v1_to_v2.rs @@ -1,3 +1,4 @@ +use std::fs::File; use std::str::FromStr; use super::v2_to_v3::CompatV2ToV3; @@ -94,6 +95,10 @@ impl CompatIndexV1ToV2 { self.from.documents().map(|it| Box::new(it) as Box>) } + pub fn documents_file(&self) -> &File { + self.from.documents_file() + } + pub fn settings(&mut self) -> Result> { Ok(v2::settings::Settings::::from(self.from.settings()?).check()) } diff --git a/crates/dump/src/reader/compat/v2_to_v3.rs b/crates/dump/src/reader/compat/v2_to_v3.rs index e7516e708..62326040e 100644 --- a/crates/dump/src/reader/compat/v2_to_v3.rs +++ b/crates/dump/src/reader/compat/v2_to_v3.rs @@ -1,3 +1,4 @@ +use std::fs::File; use std::str::FromStr; use time::OffsetDateTime; @@ -122,6 +123,13 @@ impl CompatIndexV2ToV3 { } } + pub fn documents_file(&self) -> &File { + match self { + CompatIndexV2ToV3::V2(v2) => v2.documents_file(), + CompatIndexV2ToV3::Compat(compat) => compat.documents_file(), + } + } + pub fn settings(&mut self) -> Result> { let settings = match self { CompatIndexV2ToV3::V2(from) => from.settings()?, diff --git a/crates/dump/src/reader/compat/v3_to_v4.rs b/crates/dump/src/reader/compat/v3_to_v4.rs index 5bb70e9b2..1dba37771 100644 --- a/crates/dump/src/reader/compat/v3_to_v4.rs +++ b/crates/dump/src/reader/compat/v3_to_v4.rs @@ -1,3 +1,5 @@ +use std::fs::File; + use super::v2_to_v3::{CompatIndexV2ToV3, CompatV2ToV3}; use super::v4_to_v5::CompatV4ToV5; use crate::reader::{v3, v4, UpdateFile}; @@ -252,6 +254,13 @@ impl CompatIndexV3ToV4 { } } + pub fn documents_file(&self) -> &File { + match self { + CompatIndexV3ToV4::V3(v3) => v3.documents_file(), + CompatIndexV3ToV4::Compat(compat) => compat.documents_file(), + } + } + pub fn settings(&mut self) -> Result> { Ok(match self { CompatIndexV3ToV4::V3(v3) => { diff --git a/crates/dump/src/reader/compat/v4_to_v5.rs b/crates/dump/src/reader/compat/v4_to_v5.rs index e52acb176..3f47b5b48 100644 --- a/crates/dump/src/reader/compat/v4_to_v5.rs +++ b/crates/dump/src/reader/compat/v4_to_v5.rs @@ -1,3 +1,5 @@ +use std::fs::File; + use super::v3_to_v4::{CompatIndexV3ToV4, CompatV3ToV4}; use super::v5_to_v6::CompatV5ToV6; use crate::reader::{v4, v5, Document}; @@ -241,6 +243,13 @@ impl CompatIndexV4ToV5 { } } + pub fn documents_file(&self) -> &File { + match self { + CompatIndexV4ToV5::V4(v4) => v4.documents_file(), + CompatIndexV4ToV5::Compat(compat) => compat.documents_file(), + } + } + pub fn settings(&mut self) -> Result> { match self { CompatIndexV4ToV5::V4(v4) => Ok(v5::Settings::from(v4.settings()?).check()), diff --git a/crates/dump/src/reader/compat/v5_to_v6.rs b/crates/dump/src/reader/compat/v5_to_v6.rs index f7bda81c6..f173bb6bd 100644 --- a/crates/dump/src/reader/compat/v5_to_v6.rs +++ b/crates/dump/src/reader/compat/v5_to_v6.rs @@ -1,3 +1,4 @@ +use std::fs::File; use std::num::NonZeroUsize; use std::str::FromStr; @@ -243,6 +244,13 @@ impl CompatIndexV5ToV6 { } } + pub fn documents_file(&self) -> &File { + match self { + CompatIndexV5ToV6::V5(v5) => v5.documents_file(), + CompatIndexV5ToV6::Compat(compat) => compat.documents_file(), + } + } + pub fn settings(&mut self) -> Result> { match self { CompatIndexV5ToV6::V5(v5) => Ok(v6::Settings::from(v5.settings()?).check()), diff --git a/crates/dump/src/reader/mod.rs b/crates/dump/src/reader/mod.rs index 23e7eec9e..91c6d5880 100644 --- a/crates/dump/src/reader/mod.rs +++ b/crates/dump/src/reader/mod.rs @@ -192,6 +192,13 @@ impl DumpIndexReader { } } + pub fn documents_file(&self) -> &File { + match self { + DumpIndexReader::Current(v6) => v6.documents_file(), + DumpIndexReader::Compat(compat) => compat.documents_file(), + } + } + pub fn settings(&mut self) -> Result> { match self { DumpIndexReader::Current(v6) => v6.settings(), diff --git a/crates/dump/src/reader/v1/mod.rs b/crates/dump/src/reader/v1/mod.rs index ac7324d9a..d86ede62c 100644 --- a/crates/dump/src/reader/v1/mod.rs +++ b/crates/dump/src/reader/v1/mod.rs @@ -72,6 +72,10 @@ impl V1IndexReader { .map(|line| -> Result<_> { Ok(serde_json::from_str(&line?)?) })) } + pub fn documents_file(&self) -> &File { + self.documents.get_ref() + } + pub fn settings(&mut self) -> Result { Ok(serde_json::from_reader(&mut self.settings)?) } diff --git a/crates/dump/src/reader/v2/mod.rs b/crates/dump/src/reader/v2/mod.rs index 14a643c2d..a74687381 100644 --- a/crates/dump/src/reader/v2/mod.rs +++ b/crates/dump/src/reader/v2/mod.rs @@ -203,6 +203,10 @@ impl V2IndexReader { .map(|line| -> Result<_> { Ok(serde_json::from_str(&line?)?) })) } + pub fn documents_file(&self) -> &File { + self.documents.get_ref() + } + pub fn settings(&mut self) -> Result> { Ok(self.settings.clone()) } diff --git a/crates/dump/src/reader/v3/mod.rs b/crates/dump/src/reader/v3/mod.rs index 920e1dc6e..5f89eb861 100644 --- a/crates/dump/src/reader/v3/mod.rs +++ b/crates/dump/src/reader/v3/mod.rs @@ -215,6 +215,10 @@ impl V3IndexReader { .map(|line| -> Result<_> { Ok(serde_json::from_str(&line?)?) })) } + pub fn documents_file(&self) -> &File { + self.documents.get_ref() + } + pub fn settings(&mut self) -> Result> { Ok(self.settings.clone()) } diff --git a/crates/dump/src/reader/v4/mod.rs b/crates/dump/src/reader/v4/mod.rs index 585786ae4..16a1e27c2 100644 --- a/crates/dump/src/reader/v4/mod.rs +++ b/crates/dump/src/reader/v4/mod.rs @@ -210,6 +210,10 @@ impl V4IndexReader { .map(|line| -> Result<_> { Ok(serde_json::from_str(&line?)?) })) } + pub fn documents_file(&self) -> &File { + self.documents.get_ref() + } + pub fn settings(&mut self) -> Result> { Ok(self.settings.clone()) } diff --git a/crates/dump/src/reader/v5/mod.rs b/crates/dump/src/reader/v5/mod.rs index dfbc6346c..0123db433 100644 --- a/crates/dump/src/reader/v5/mod.rs +++ b/crates/dump/src/reader/v5/mod.rs @@ -247,6 +247,10 @@ impl V5IndexReader { .map(|line| -> Result<_> { Ok(serde_json::from_str(&line?)?) })) } + pub fn documents_file(&self) -> &File { + self.documents.get_ref() + } + pub fn settings(&mut self) -> Result> { Ok(self.settings.clone()) } diff --git a/crates/dump/src/reader/v6/mod.rs b/crates/dump/src/reader/v6/mod.rs index 449a7e5fe..08d4700e5 100644 --- a/crates/dump/src/reader/v6/mod.rs +++ b/crates/dump/src/reader/v6/mod.rs @@ -284,6 +284,10 @@ impl V6IndexReader { .map(|line| -> Result<_> { Ok(serde_json::from_str(&line?)?) })) } + pub fn documents_file(&self) -> &File { + self.documents.get_ref() + } + pub fn settings(&mut self) -> Result> { let mut settings: Settings = serde_json::from_reader(&mut self.settings)?; patch_embedders(&mut settings); From d67db6e3c2a97acb956c55ccab4f38b1dd988212 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Renault?= Date: Thu, 17 Jul 2025 17:12:42 +0200 Subject: [PATCH 114/135] Use the edition 2024 documents indexer in the dumps --- Cargo.lock | 5 ++-- crates/meilisearch/Cargo.toml | 1 + crates/meilisearch/src/lib.rs | 54 +++++++++++++++++++++++++++++++++-- 3 files changed, 56 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ceec0a05e..8413b3d14 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3775,6 +3775,7 @@ dependencies = [ "meili-snap", "meilisearch-auth", "meilisearch-types", + "memmap2", "mimalloc", "mime", "mopa-maintained", @@ -3908,9 +3909,9 @@ checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" [[package]] name = "memmap2" -version = "0.9.5" +version = "0.9.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd3f7eed9d3848f8b98834af67102b720745c4ec028fcd0aa0239277e7de374f" +checksum = "483758ad303d734cec05e5c12b41d7e93e6a6390c5e9dae6bdeb7c1259012d28" dependencies = [ "libc", "stable_deref_trait", diff --git a/crates/meilisearch/Cargo.toml b/crates/meilisearch/Cargo.toml index 83eb439d9..21f6b58e5 100644 --- a/crates/meilisearch/Cargo.toml +++ b/crates/meilisearch/Cargo.toml @@ -50,6 +50,7 @@ jsonwebtoken = "9.3.1" lazy_static = "1.5.0" meilisearch-auth = { path = "../meilisearch-auth" } meilisearch-types = { path = "../meilisearch-types" } +memmap2 = "0.9.7" mimalloc = { version = "0.1.47", default-features = false } mime = "0.3.17" num_cpus = "1.17.0" diff --git a/crates/meilisearch/src/lib.rs b/crates/meilisearch/src/lib.rs index 8907a5632..57a20a633 100644 --- a/crates/meilisearch/src/lib.rs +++ b/crates/meilisearch/src/lib.rs @@ -30,6 +30,7 @@ use actix_web::web::Data; use actix_web::{web, HttpRequest}; use analytics::Analytics; use anyhow::bail; +use bumpalo::Bump; use error::PayloadError; use extractors::payload::PayloadConfig; use index_scheduler::versioning::Versioning; @@ -38,6 +39,7 @@ use meilisearch_auth::{open_auth_store_env, AuthController}; use meilisearch_types::milli::constants::VERSION_MAJOR; use meilisearch_types::milli::documents::{DocumentsBatchBuilder, DocumentsBatchReader}; use meilisearch_types::milli::progress::{EmbedderStats, Progress}; +use meilisearch_types::milli::update::new::indexer; use meilisearch_types::milli::update::{ default_thread_pool_and_threads, IndexDocumentsConfig, IndexDocumentsMethod, IndexerConfig, }; @@ -534,7 +536,7 @@ fn import_dump( let mut index_reader = index_reader?; let metadata = index_reader.metadata(); let uid = metadata.uid.clone(); - tracing::info!("Importing index `{}`.", metadata.uid); + tracing::info!("Importing index `{uid}`."); let date = Some((metadata.created_at, metadata.updated_at)); let index = index_scheduler.create_raw_index(&metadata.uid, date)?; @@ -553,6 +555,10 @@ fn import_dump( apply_settings_to_builder(&settings, &mut builder); let embedder_stats: Arc = Default::default(); builder.execute(&|| false, &progress, embedder_stats.clone())?; + wtxn.commit()?; + + let mut wtxn = index.write_txn()?; + let rtxn = index.read_txn()?; if index_scheduler.no_edition_2024_for_dumps() { // 5.3 Import the documents. @@ -594,7 +600,51 @@ fn import_dump( tracing::info!(documents_found = user_result, "{} documents found.", user_result); builder.execute()?; } else { - unimplemented!("new document indexer when importing dumps"); + let db_fields_ids_map = index.fields_ids_map(&rtxn)?; + let primary_key = index.primary_key(&rtxn)?; + let mut new_fields_ids_map = db_fields_ids_map.clone(); + + let mut indexer = indexer::DocumentOperation::new(); + let embedders = index.embedding_configs().embedding_configs(&mut wtxn)?; + let embedders = index_scheduler.embedders(uid.clone(), embedders)?; + + let mmap = unsafe { memmap2::Mmap::map(index_reader.documents_file())? }; + + indexer.replace_documents(&mmap)?; + + let indexer_config = index_scheduler.indexer_config(); + let pool = &indexer_config.thread_pool; + + let indexer_alloc = Bump::new(); + let (document_changes, mut operation_stats, primary_key) = indexer.into_changes( + &indexer_alloc, + &index, + &rtxn, + primary_key, + &mut new_fields_ids_map, + &|| false, // never stop processing a dump + progress.clone(), + )?; + + let operation_stats = operation_stats.pop().unwrap(); + if let Some(error) = operation_stats.error { + return Err(error.into()); + } + + let _congestion = indexer::index( + &mut wtxn, + &index, + pool, + indexer_config.grenad_parameters(), + &db_fields_ids_map, + new_fields_ids_map, + primary_key, + &document_changes, + embedders, + &|| false, // never stop processing a dump + &progress, + &embedder_stats, + )?; } wtxn.commit()?; From a1b42c10e2fceadd07f39ca1cac547a99053a8b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Renault?= Date: Thu, 17 Jul 2025 17:21:03 +0200 Subject: [PATCH 115/135] Make clippy happy --- crates/index-scheduler/src/insta_snapshot.rs | 1 + crates/index-scheduler/src/test_utils.rs | 1 + crates/meilisearch/src/lib.rs | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/index-scheduler/src/insta_snapshot.rs b/crates/index-scheduler/src/insta_snapshot.rs index 0cbbb2514..32ce131b5 100644 --- a/crates/index-scheduler/src/insta_snapshot.rs +++ b/crates/index-scheduler/src/insta_snapshot.rs @@ -20,6 +20,7 @@ pub fn snapshot_index_scheduler(scheduler: &IndexScheduler) -> String { let IndexScheduler { cleanup_enabled: _, + experimental_no_edition_2024_for_dumps: _, processing_tasks, env, version, diff --git a/crates/index-scheduler/src/test_utils.rs b/crates/index-scheduler/src/test_utils.rs index bfed7f53a..0a705b6c7 100644 --- a/crates/index-scheduler/src/test_utils.rs +++ b/crates/index-scheduler/src/test_utils.rs @@ -115,6 +115,7 @@ impl IndexScheduler { auto_upgrade: true, // Don't cost much and will ensure the happy path works embedding_cache_cap: 10, experimental_no_snapshot_compaction: false, + experimental_no_edition_2024_for_dumps: false, }; let version = configuration(&mut options).unwrap_or({ (versioning::VERSION_MAJOR, versioning::VERSION_MINOR, versioning::VERSION_PATCH) diff --git a/crates/meilisearch/src/lib.rs b/crates/meilisearch/src/lib.rs index 57a20a633..13d2eb789 100644 --- a/crates/meilisearch/src/lib.rs +++ b/crates/meilisearch/src/lib.rs @@ -605,7 +605,7 @@ fn import_dump( let mut new_fields_ids_map = db_fields_ids_map.clone(); let mut indexer = indexer::DocumentOperation::new(); - let embedders = index.embedding_configs().embedding_configs(&mut wtxn)?; + let embedders = index.embedding_configs().embedding_configs(&rtxn)?; let embedders = index_scheduler.embedders(uid.clone(), embedders)?; let mmap = unsafe { memmap2::Mmap::map(index_reader.documents_file())? }; From 1b476b8a35655283840cca3f37ce1af691d3feb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Renault?= Date: Thu, 17 Jul 2025 17:26:41 +0200 Subject: [PATCH 116/135] Add documentation to the new documents_file dump reader method Co-authored-by: Louis Dureuil --- crates/dump/src/reader/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/dump/src/reader/mod.rs b/crates/dump/src/reader/mod.rs index 91c6d5880..c894c255f 100644 --- a/crates/dump/src/reader/mod.rs +++ b/crates/dump/src/reader/mod.rs @@ -192,6 +192,7 @@ impl DumpIndexReader { } } + /// A reference to a file in the NDJSON format containing all the documents of the index pub fn documents_file(&self) -> &File { match self { DumpIndexReader::Current(v6) => v6.documents_file(), From 626be0ef28fef7e71ad8628d95f0b76d44509b61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Renault?= Date: Thu, 17 Jul 2025 17:27:00 +0200 Subject: [PATCH 117/135] Small typo fix Co-authored-by: Louis Dureuil --- crates/index-scheduler/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/index-scheduler/src/lib.rs b/crates/index-scheduler/src/lib.rs index f91e45914..8715bc100 100644 --- a/crates/index-scheduler/src/lib.rs +++ b/crates/index-scheduler/src/lib.rs @@ -139,7 +139,7 @@ pub struct IndexSchedulerOptions { pub embedding_cache_cap: usize, /// Snapshot compaction status. pub experimental_no_snapshot_compaction: bool, - /// Whether dump import use the old document indexer or the new one. + /// Whether dump import uses the old document indexer or the new one. pub experimental_no_edition_2024_for_dumps: bool, } From b85657de1eb075a5b5160dfcbec721237856feeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Renault?= Date: Thu, 17 Jul 2025 17:29:59 +0200 Subject: [PATCH 118/135] Update memmap2 version everywhere --- crates/benchmarks/Cargo.toml | 3 +-- crates/index-scheduler/Cargo.toml | 2 +- crates/meilisearch-types/Cargo.toml | 2 +- crates/milli/Cargo.toml | 2 +- 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/crates/benchmarks/Cargo.toml b/crates/benchmarks/Cargo.toml index 68ed5aff4..f60f0979c 100644 --- a/crates/benchmarks/Cargo.toml +++ b/crates/benchmarks/Cargo.toml @@ -14,7 +14,7 @@ license.workspace = true anyhow = "1.0.98" bumpalo = "3.18.1" csv = "1.3.1" -memmap2 = "0.9.5" +memmap2 = "0.9.7" milli = { path = "../milli" } mimalloc = { version = "0.1.47", default-features = false } serde_json = { version = "1.0.140", features = ["preserve_order"] } @@ -55,4 +55,3 @@ harness = false [[bench]] name = "sort" harness = false - diff --git a/crates/index-scheduler/Cargo.toml b/crates/index-scheduler/Cargo.toml index de0d01935..20cc49686 100644 --- a/crates/index-scheduler/Cargo.toml +++ b/crates/index-scheduler/Cargo.toml @@ -26,7 +26,7 @@ flate2 = "1.1.2" indexmap = "2.9.0" meilisearch-auth = { path = "../meilisearch-auth" } meilisearch-types = { path = "../meilisearch-types" } -memmap2 = "0.9.5" +memmap2 = "0.9.7" page_size = "0.6.0" rayon = "1.10.0" roaring = { version = "0.10.12", features = ["serde"] } diff --git a/crates/meilisearch-types/Cargo.toml b/crates/meilisearch-types/Cargo.toml index faf59643f..f3279a094 100644 --- a/crates/meilisearch-types/Cargo.toml +++ b/crates/meilisearch-types/Cargo.toml @@ -24,7 +24,7 @@ enum-iterator = "2.1.0" file-store = { path = "../file-store" } flate2 = "1.1.2" fst = "0.4.7" -memmap2 = "0.9.5" +memmap2 = "0.9.7" milli = { path = "../milli" } roaring = { version = "0.10.12", features = ["serde"] } rustc-hash = "2.1.1" diff --git a/crates/milli/Cargo.toml b/crates/milli/Cargo.toml index 3d08252ac..d94a4d4e1 100644 --- a/crates/milli/Cargo.toml +++ b/crates/milli/Cargo.toml @@ -40,7 +40,7 @@ indexmap = { version = "2.9.0", features = ["serde"] } json-depth-checker = { path = "../json-depth-checker" } levenshtein_automata = { version = "0.2.1", features = ["fst_automaton"] } memchr = "2.7.5" -memmap2 = "0.9.5" +memmap2 = "0.9.7" obkv = "0.3.0" once_cell = "1.21.3" ordered-float = "5.0.0" From bdc2d1e64dbb04fd2bce2b099600e605c16b51f9 Mon Sep 17 00:00:00 2001 From: Kerollmops Date: Mon, 21 Jul 2025 14:37:22 +0200 Subject: [PATCH 119/135] Move the edition 2024 dump parameter to the right place --- crates/index-scheduler/src/lib.rs | 6 ++-- crates/index-scheduler/src/test_utils.rs | 1 - .../src/analytics/segment_analytics.rs | 2 +- crates/meilisearch/src/lib.rs | 1 - crates/meilisearch/src/option.rs | 31 ++++++++++--------- crates/meilisearch/tests/common/server.rs | 1 + crates/milli/src/update/indexer_config.rs | 2 ++ 7 files changed, 24 insertions(+), 20 deletions(-) diff --git a/crates/index-scheduler/src/lib.rs b/crates/index-scheduler/src/lib.rs index 8715bc100..46566b9ba 100644 --- a/crates/index-scheduler/src/lib.rs +++ b/crates/index-scheduler/src/lib.rs @@ -139,8 +139,6 @@ pub struct IndexSchedulerOptions { pub embedding_cache_cap: usize, /// Snapshot compaction status. pub experimental_no_snapshot_compaction: bool, - /// Whether dump import uses the old document indexer or the new one. - pub experimental_no_edition_2024_for_dumps: bool, } /// Structure which holds meilisearch's indexes and schedules the tasks @@ -302,7 +300,9 @@ impl IndexScheduler { index_mapper, env, cleanup_enabled: options.cleanup_enabled, - experimental_no_edition_2024_for_dumps: options.experimental_no_edition_2024_for_dumps, + experimental_no_edition_2024_for_dumps: options + .indexer_config + .experimental_no_edition_2024_for_dumps, webhook_url: options.webhook_url, webhook_authorization_header: options.webhook_authorization_header, embedders: Default::default(), diff --git a/crates/index-scheduler/src/test_utils.rs b/crates/index-scheduler/src/test_utils.rs index 0a705b6c7..bfed7f53a 100644 --- a/crates/index-scheduler/src/test_utils.rs +++ b/crates/index-scheduler/src/test_utils.rs @@ -115,7 +115,6 @@ impl IndexScheduler { auto_upgrade: true, // Don't cost much and will ensure the happy path works embedding_cache_cap: 10, experimental_no_snapshot_compaction: false, - experimental_no_edition_2024_for_dumps: false, }; let version = configuration(&mut options).unwrap_or({ (versioning::VERSION_MAJOR, versioning::VERSION_MINOR, versioning::VERSION_PATCH) diff --git a/crates/meilisearch/src/analytics/segment_analytics.rs b/crates/meilisearch/src/analytics/segment_analytics.rs index a96ddf068..a2a0f0c05 100644 --- a/crates/meilisearch/src/analytics/segment_analytics.rs +++ b/crates/meilisearch/src/analytics/segment_analytics.rs @@ -254,7 +254,6 @@ impl Infos { experimental_limit_batched_tasks_total_size, experimental_embedding_cache_entries, experimental_no_snapshot_compaction, - experimental_no_edition_2024_for_dumps, http_addr, master_key: _, env, @@ -295,6 +294,7 @@ impl Infos { max_indexing_threads, skip_index_budget: _, experimental_no_edition_2024_for_settings, + experimental_no_edition_2024_for_dumps, } = indexer_options; let RuntimeTogglableFeatures { diff --git a/crates/meilisearch/src/lib.rs b/crates/meilisearch/src/lib.rs index 13d2eb789..0fb93b65a 100644 --- a/crates/meilisearch/src/lib.rs +++ b/crates/meilisearch/src/lib.rs @@ -240,7 +240,6 @@ pub fn setup_meilisearch(opt: &Opt) -> anyhow::Result<(Arc, Arc< auto_upgrade: opt.experimental_dumpless_upgrade, embedding_cache_cap: opt.experimental_embedding_cache_entries, experimental_no_snapshot_compaction: opt.experimental_no_snapshot_compaction, - experimental_no_edition_2024_for_dumps: opt.experimental_no_edition_2024_for_dumps, }; let binary_version = (VERSION_MAJOR, VERSION_MINOR, VERSION_PATCH); diff --git a/crates/meilisearch/src/option.rs b/crates/meilisearch/src/option.rs index 77106d362..dd77a1222 100644 --- a/crates/meilisearch/src/option.rs +++ b/crates/meilisearch/src/option.rs @@ -469,15 +469,6 @@ pub struct Opt { #[serde(default)] pub experimental_no_snapshot_compaction: bool, - /// Experimental make dump imports use the old document indexer. - /// - /// When enabled, Meilisearch will use the old document indexer when importing dumps. - /// - /// For more information, see . - #[clap(long, env = MEILI_EXPERIMENTAL_NO_EDITION_2024_FOR_DUMPS)] - #[serde(default)] - pub experimental_no_edition_2024_for_dumps: bool, - #[serde(flatten)] #[clap(flatten)] pub indexer_options: IndexerOpts, @@ -583,7 +574,6 @@ impl Opt { experimental_limit_batched_tasks_total_size, experimental_embedding_cache_entries, experimental_no_snapshot_compaction, - experimental_no_edition_2024_for_dumps, } = self; export_to_env_if_not_present(MEILI_DB_PATH, db_path); export_to_env_if_not_present(MEILI_HTTP_ADDR, http_addr); @@ -684,10 +674,6 @@ impl Opt { MEILI_EXPERIMENTAL_NO_SNAPSHOT_COMPACTION, experimental_no_snapshot_compaction.to_string(), ); - export_to_env_if_not_present( - MEILI_EXPERIMENTAL_NO_EDITION_2024_FOR_DUMPS, - experimental_no_edition_2024_for_dumps.to_string(), - ); indexer_options.export_to_env(); } @@ -775,6 +761,15 @@ pub struct IndexerOpts { #[clap(long, env = MEILI_EXPERIMENTAL_NO_EDITION_2024_FOR_SETTINGS)] #[serde(default)] pub experimental_no_edition_2024_for_settings: bool, + + /// Experimental make dump imports use the old document indexer. + /// + /// When enabled, Meilisearch will use the old document indexer when importing dumps. + /// + /// For more information, see . + #[clap(long, env = MEILI_EXPERIMENTAL_NO_EDITION_2024_FOR_DUMPS)] + #[serde(default)] + pub experimental_no_edition_2024_for_dumps: bool, } impl IndexerOpts { @@ -785,6 +780,7 @@ impl IndexerOpts { max_indexing_threads, skip_index_budget: _, experimental_no_edition_2024_for_settings, + experimental_no_edition_2024_for_dumps, } = self; if let Some(max_indexing_memory) = max_indexing_memory.0 { export_to_env_if_not_present( @@ -804,6 +800,12 @@ impl IndexerOpts { experimental_no_edition_2024_for_settings.to_string(), ); } + if experimental_no_edition_2024_for_dumps { + export_to_env_if_not_present( + MEILI_EXPERIMENTAL_NO_EDITION_2024_FOR_DUMPS, + experimental_no_edition_2024_for_dumps.to_string(), + ); + } } } @@ -824,6 +826,7 @@ impl TryFrom<&IndexerOpts> for IndexerConfig { skip_index_budget: other.skip_index_budget, experimental_no_edition_2024_for_settings: other .experimental_no_edition_2024_for_settings, + experimental_no_edition_2024_for_dumps: other.experimental_no_edition_2024_for_dumps, chunk_compression_type: Default::default(), chunk_compression_level: Default::default(), documents_chunk_size: Default::default(), diff --git a/crates/meilisearch/tests/common/server.rs b/crates/meilisearch/tests/common/server.rs index 5f82bb380..ad0678122 100644 --- a/crates/meilisearch/tests/common/server.rs +++ b/crates/meilisearch/tests/common/server.rs @@ -466,6 +466,7 @@ pub fn default_settings(dir: impl AsRef) -> Opt { // Having 2 threads makes the tests way faster max_indexing_threads: MaxThreads::from_str("2").unwrap(), experimental_no_edition_2024_for_settings: false, + experimental_no_edition_2024_for_dumps: false, }, experimental_enable_metrics: false, ..Parser::parse_from(None as Option<&str>) diff --git a/crates/milli/src/update/indexer_config.rs b/crates/milli/src/update/indexer_config.rs index a0f901818..845da5a51 100644 --- a/crates/milli/src/update/indexer_config.rs +++ b/crates/milli/src/update/indexer_config.rs @@ -16,6 +16,7 @@ pub struct IndexerConfig { pub max_positions_per_attributes: Option, pub skip_index_budget: bool, pub experimental_no_edition_2024_for_settings: bool, + pub experimental_no_edition_2024_for_dumps: bool, } impl IndexerConfig { @@ -65,6 +66,7 @@ impl Default for IndexerConfig { max_positions_per_attributes: None, skip_index_budget: false, experimental_no_edition_2024_for_settings: false, + experimental_no_edition_2024_for_dumps: false, } } } From afc164a2715224931e1a4fbc2bb90a3fe1930836 Mon Sep 17 00:00:00 2001 From: Louis Dureuil Date: Thu, 17 Jul 2025 16:09:11 +0200 Subject: [PATCH 120/135] Fix in old indexer --- .../extract/extract_vector_points.rs | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/crates/milli/src/update/index_documents/extract/extract_vector_points.rs b/crates/milli/src/update/index_documents/extract/extract_vector_points.rs index 064cfd154..a1dfa1aad 100644 --- a/crates/milli/src/update/index_documents/extract/extract_vector_points.rs +++ b/crates/milli/src/update/index_documents/extract/extract_vector_points.rs @@ -23,7 +23,7 @@ use crate::progress::EmbedderStats; use crate::prompt::Prompt; use crate::update::del_add::{DelAdd, KvReaderDelAdd, KvWriterDelAdd}; use crate::update::settings::InnerIndexSettingsDiff; -use crate::vector::db::{EmbedderInfo, EmbeddingStatus, EmbeddingStatusDelta}; +use crate::vector::db::{EmbedderInfo, EmbeddingStatusDelta}; use crate::vector::error::{EmbedErrorKind, PossibleEmbeddingMistakes, UnusedVectorsDistribution}; use crate::vector::extractor::{Extractor, ExtractorDiff, RequestFragmentExtractor}; use crate::vector::parsed_vectors::{ParsedVectorsDiff, VectorState}; @@ -441,6 +441,8 @@ pub fn extract_vector_points( { let embedder_is_manual = matches!(*runtime.embedder, Embedder::UserProvided(_)); + let (old_is_user_provided, old_must_regenerate) = + embedder_info.embedding_status.is_user_provided_must_regenerate(docid); let (old, new) = parsed_vectors.remove(embedder_name); let new_must_regenerate = new.must_regenerate(); let delta = match action { @@ -499,16 +501,19 @@ pub fn extract_vector_points( let is_adding_fragments = has_fragments && !old_has_fragments; - if is_adding_fragments { + if !has_fragments { + // removing fragments + regenerate_prompt(obkv, &runtime.document_template, new_fields_ids_map)? + } else if is_adding_fragments || + // regenerate all fragments when going from user provided to ! user provided + old_is_user_provided + { regenerate_all_fragments( runtime.fragments(), &doc_alloc, new_fields_ids_map, obkv, ) - } else if !has_fragments { - // removing fragments - regenerate_prompt(obkv, &runtime.document_template, new_fields_ids_map)? } else { let mut fragment_diff = Vec::new(); let new_fields_ids_map = new_fields_ids_map.as_fields_ids_map(); @@ -600,7 +605,8 @@ pub fn extract_vector_points( docid, &delta, new_must_regenerate, - &embedder_info.embedding_status, + old_is_user_provided, + old_must_regenerate, ); // and we finally push the unique vectors into the writer @@ -657,10 +663,9 @@ fn push_embedding_status_delta( docid: DocumentId, delta: &VectorStateDelta, new_must_regenerate: bool, - embedding_status: &EmbeddingStatus, + old_is_user_provided: bool, + old_must_regenerate: bool, ) { - let (old_is_user_provided, old_must_regenerate) = - embedding_status.is_user_provided_must_regenerate(docid); let new_is_user_provided = match delta { VectorStateDelta::NoChange => old_is_user_provided, VectorStateDelta::NowRemoved => { From 366c37a68616a0dc0216cc10053b3103e4769413 Mon Sep 17 00:00:00 2001 From: Louis Dureuil Date: Thu, 17 Jul 2025 17:13:40 +0200 Subject: [PATCH 121/135] Fix new indexer --- .../src/update/new/extract/vectors/mod.rs | 88 ++++++++++++++----- 1 file changed, 68 insertions(+), 20 deletions(-) diff --git a/crates/milli/src/update/new/extract/vectors/mod.rs b/crates/milli/src/update/new/extract/vectors/mod.rs index 4ca68027c..71fa9bf09 100644 --- a/crates/milli/src/update/new/extract/vectors/mod.rs +++ b/crates/milli/src/update/new/extract/vectors/mod.rs @@ -620,12 +620,35 @@ impl<'a, 'b, 'extractor> Chunks<'a, 'b, 'extractor> { where 'a: 'doc, { - match &mut self.kind { - ChunkType::Fragments { fragments: _, session } => { - let doc_alloc = session.doc_alloc(); + self.set_status(docid, old_is_user_provided, true, false, true); - if old_is_user_provided | full_reindex { + match &mut self.kind { + ChunkType::Fragments { fragments, session } => { + let doc_alloc = session.doc_alloc(); + let reindex_all_fragments = + // when the vectors were user-provided, Meilisearch cannot know if they come from a particular fragment, + // and so Meilisearch needs to clear all embeddings in that case. + // Fortunately, as dump export fragment vector with `regenerate` set to `false`, + // this case should be rare and opt-in. + old_is_user_provided || + // full-reindex case + full_reindex; + + if reindex_all_fragments { session.on_embed_mut().clear_vectors(docid); + let extractors = fragments.iter().map(|fragment| { + RequestFragmentExtractor::new(fragment, doc_alloc).ignore_errors() + }); + insert_autogenerated( + docid, + external_docid, + extractors, + document, + &(), + session, + unused_vectors_distribution, + )?; + return Ok(()); } settings_delta.try_for_each_fragment_diff( @@ -669,7 +692,6 @@ impl<'a, 'b, 'extractor> Chunks<'a, 'b, 'extractor> { Result::Ok(()) }, )?; - self.set_status(docid, old_is_user_provided, true, false, true); } ChunkType::DocumentTemplate { document_template, session } => { let doc_alloc = session.doc_alloc(); @@ -690,12 +712,18 @@ impl<'a, 'b, 'extractor> Chunks<'a, 'b, 'extractor> { match extractor.diff_settings(document, &external_docid, old_extractor.as_ref())? { ExtractorDiff::Removed => { + if old_is_user_provided || full_reindex { + session.on_embed_mut().clear_vectors(docid); + } OnEmbed::process_embedding_response( session.on_embed_mut(), crate::vector::session::EmbeddingResponse { metadata, embedding: None }, ); } ExtractorDiff::Added(input) | ExtractorDiff::Updated(input) => { + if old_is_user_provided || full_reindex { + session.on_embed_mut().clear_vectors(docid); + } session.request_embedding(metadata, input, unused_vectors_distribution)?; } ExtractorDiff::Unchanged => { /* do nothing */ } @@ -722,6 +750,13 @@ impl<'a, 'b, 'extractor> Chunks<'a, 'b, 'extractor> { where 'a: 'doc, { + self.set_status( + docid, + old_is_user_provided, + old_must_regenerate, + false, + new_must_regenerate, + ); match &mut self.kind { ChunkType::DocumentTemplate { document_template, session } => { let doc_alloc = session.doc_alloc(); @@ -731,10 +766,6 @@ impl<'a, 'b, 'extractor> Chunks<'a, 'b, 'extractor> { new_fields_ids_map, ); - if old_is_user_provided { - session.on_embed_mut().clear_vectors(docid); - } - update_autogenerated( docid, external_docid, @@ -743,6 +774,7 @@ impl<'a, 'b, 'extractor> Chunks<'a, 'b, 'extractor> { new_document, &external_docid, old_must_regenerate, + old_is_user_provided, session, unused_vectors_distribution, )? @@ -754,7 +786,21 @@ impl<'a, 'b, 'extractor> Chunks<'a, 'b, 'extractor> { }); if old_is_user_provided { + // when the document was `userProvided`, Meilisearch cannot know whose fragments a particular + // vector was referring to. + // So as a result Meilisearch will regenerate all fragments on this case. + // Fortunately, since dumps for fragments set regenerate to false, this case should be rare. session.on_embed_mut().clear_vectors(docid); + insert_autogenerated( + docid, + external_docid, + extractors, + new_document, + &(), + session, + unused_vectors_distribution, + )?; + return Ok(()); } update_autogenerated( @@ -765,25 +811,18 @@ impl<'a, 'b, 'extractor> Chunks<'a, 'b, 'extractor> { new_document, &(), old_must_regenerate, + false, session, unused_vectors_distribution, )? } }; - self.set_status( - docid, - old_is_user_provided, - old_must_regenerate, - false, - new_must_regenerate, - ); - Ok(()) } #[allow(clippy::too_many_arguments)] - pub fn insert_autogenerated + Debug>( + pub fn insert_autogenerated<'doc, D: Document<'doc> + Debug>( &mut self, docid: DocumentId, external_docid: &'a str, @@ -791,7 +830,10 @@ impl<'a, 'b, 'extractor> Chunks<'a, 'b, 'extractor> { new_fields_ids_map: &'a RefCell, unused_vectors_distribution: &UnusedVectorsDistributionBump<'a>, new_must_regenerate: bool, - ) -> Result<()> { + ) -> Result<()> + where + 'a: 'doc, + { let (default_is_user_provided, default_must_regenerate) = (false, true); self.set_status( docid, @@ -956,6 +998,7 @@ fn update_autogenerated<'doc, 'a: 'doc, 'b, E, OD, ND>( new_document: ND, meta: &E::DocumentMetadata, old_must_regenerate: bool, + mut must_clear_on_generation: bool, session: &mut EmbedSession<'a, OnEmbeddingDocumentUpdates<'a, 'b>, E::Input>, unused_vectors_distribution: &UnusedVectorsDistributionBump<'a>, ) -> Result<()> @@ -984,6 +1027,11 @@ where }; if must_regenerate { + if must_clear_on_generation { + must_clear_on_generation = false; + session.on_embed_mut().clear_vectors(docid); + } + let metadata = Metadata { docid, external_docid, extractor_id: extractor.extractor_id() }; @@ -1002,7 +1050,7 @@ where Ok(()) } -fn insert_autogenerated<'a, 'b, E, D: Document<'a> + Debug>( +fn insert_autogenerated<'doc, 'a: 'doc, 'b, E, D: Document<'doc> + Debug>( docid: DocumentId, external_docid: &'a str, extractors: impl IntoIterator, From 00a5c86f1366212ff94c9ac44e3c67eb232bc9e8 Mon Sep 17 00:00:00 2001 From: Louis Dureuil Date: Thu, 17 Jul 2025 17:14:39 +0200 Subject: [PATCH 122/135] Remove accidentally added db snap --- crates/meilisearch/db.snapshot | Bin 174088 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 crates/meilisearch/db.snapshot diff --git a/crates/meilisearch/db.snapshot b/crates/meilisearch/db.snapshot deleted file mode 100644 index 29377ce4225430d3481206f468db3826b1ccb8ba..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 174088 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≪t8=i(I}Dl@4-nSL==uD1icH|Kcp8@A5tHJAC>P{ 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