Skip to main content

docbox_search/opensearch/
mod.rs

1use crate::SearchError;
2use crate::models::FileSearchRequest;
3use crate::opensearch::models::{OsSearchIndexData, OsUpdateSearchIndexData, SearchResponse};
4
5use super::models::{FlattenedItemResult, PageResult, SearchScore};
6use super::{
7    SearchIndex,
8    models::{
9        FileSearchResults, SearchIndexData, SearchRequest, SearchResults, UpdateSearchIndexData,
10    },
11};
12use aws_config::SdkConfig;
13use docbox_database::DbTransaction;
14use docbox_database::models::document_box::DocumentBoxScopeRawRef;
15use docbox_database::models::file::FileId;
16use docbox_database::models::{
17    document_box::DocumentBoxScopeRaw, folder::FolderId, tenant::Tenant,
18};
19use opensearch::indices::IndicesGetParts;
20use opensearch::{
21    DeleteByQueryParts, OpenSearch, SearchParts,
22    http::{
23        Url,
24        request::JsonBody,
25        transport::{SingleNodeConnectionPool, TransportBuilder},
26    },
27    indices::{IndicesCreateParts, IndicesDeleteParts},
28};
29use reqwest::StatusCode;
30use serde::{Deserialize, Serialize};
31use serde_json::json;
32use serde_with::skip_serializing_none;
33use uuid::Uuid;
34
35pub use error::{OpenSearchIndexFactoryError, OpenSearchSearchError};
36
37pub mod error;
38mod models;
39
40#[derive(Debug, Clone, Deserialize, Serialize)]
41pub struct OpenSearchConfig {
42    /// URL of the OpenSearch server
43    pub url: String,
44}
45
46impl OpenSearchConfig {
47    pub fn from_env() -> Result<Self, OpenSearchIndexFactoryError> {
48        let url = std::env::var("OPENSEARCH_URL")
49            .or(std::env::var("DOCBOX_OPENSEARCH_URL"))
50            .map_err(|_| OpenSearchIndexFactoryError::MissingUrl)?;
51        Ok(Self { url })
52    }
53}
54
55#[derive(Clone)]
56pub struct OpenSearchIndexFactory {
57    client: OpenSearch,
58}
59
60impl OpenSearchIndexFactory {
61    pub fn from_config(
62        aws_config: &SdkConfig,
63        config: OpenSearchConfig,
64    ) -> Result<Self, OpenSearchIndexFactoryError> {
65        let url = reqwest::Url::parse(&config.url).map_err(|error| {
66            tracing::error!(?error, "failed to parse opensearch url");
67            OpenSearchIndexFactoryError::InvalidUrl
68        })?;
69        let client = create_open_search(aws_config, url)?;
70        Ok(Self { client })
71    }
72
73    pub fn create_search_index(&self, search_index: TenantSearchIndexName) -> OpenSearchIndex {
74        OpenSearchIndex {
75            client: self.client.clone(),
76            search_index,
77        }
78    }
79}
80
81#[derive(Clone)]
82pub struct OpenSearchIndex {
83    client: OpenSearch,
84    search_index: TenantSearchIndexName,
85}
86
87/// Represents a search index name for a specific tenant
88#[derive(Clone, Debug)]
89pub struct TenantSearchIndexName(String);
90
91impl TenantSearchIndexName {
92    pub fn from_tenant(tenant: &Tenant) -> Self {
93        Self(tenant.os_index_name.clone())
94    }
95}
96
97/// Create instance of [OpenSearch] from the environment
98pub fn create_open_search(
99    aws_config: &SdkConfig,
100    url: Url,
101) -> Result<OpenSearch, OpenSearchIndexFactoryError> {
102    if cfg!(debug_assertions) {
103        create_open_search_dev(url)
104    } else {
105        create_open_search_prod(aws_config, url)
106    }
107}
108
109/// Create instance of [OpenSearch] from the environment
110pub fn create_open_search_dev(url: Url) -> Result<OpenSearch, OpenSearchIndexFactoryError> {
111    let conn_pool = SingleNodeConnectionPool::new(url);
112
113    let transport = TransportBuilder::new(conn_pool)
114        // We don't want open search trying to access the index through our proxy. It has a direct route
115        .disable_proxy()
116        // Disable certificate validation for local development
117        .cert_validation(opensearch::cert::CertificateValidation::None)
118        .build()
119        .map_err(|error| {
120            tracing::error!(?error, "failed to build opensearch transport");
121            OpenSearchIndexFactoryError::BuildTransport
122        })?;
123
124    let open_search = OpenSearch::new(transport);
125
126    Ok(open_search)
127}
128
129/// Create instance of [OpenSearch] from the environment
130pub fn create_open_search_prod(
131    aws_config: &SdkConfig,
132    url: Url,
133) -> Result<OpenSearch, OpenSearchIndexFactoryError> {
134    // Setup opensearch connection pool
135    let conn_pool = SingleNodeConnectionPool::new(url);
136
137    let transport = TransportBuilder::new(conn_pool)
138        // We don't want open search trying to access the index through our proxy. It has a direct route
139        .disable_proxy()
140        .auth(aws_config.clone().try_into().map_err(|error| {
141            tracing::error!(?error, "failed to create opensearch transport auth config");
142            OpenSearchIndexFactoryError::CreateAuthConfig
143        })?)
144        .service_name("es")
145        .build()
146        .map_err(|error| {
147            tracing::error!(?error, "failed to build opensearch transport");
148            OpenSearchIndexFactoryError::BuildTransport
149        })?;
150
151    let open_search = OpenSearch::new(transport);
152
153    Ok(open_search)
154}
155
156impl SearchIndex for OpenSearchIndex {
157    async fn create_index(&self) -> Result<(), SearchError> {
158        // Create index for files
159        let response = self
160            .client
161            .indices()
162            .create(IndicesCreateParts::Index(&self.search_index.0))
163            .body(json!({
164                "settings": {
165                    "analysis": {
166                        "tokenizer": {
167                            "edge_ngram_tokenizer": {
168                                "type": "edge_ngram",
169                                "min_gram": 1,
170                                "max_gram": 25,
171                                "token_chars": [
172                                    "letter",
173                                    "digit"
174                                ]
175                            }
176                        },
177                        "analyzer": {
178                            "edge_ngram_analyzer": {
179                                "type": "custom",
180                                "tokenizer": "edge_ngram_tokenizer"
181                            }
182                        }
183                    }
184                },
185                "mappings" : {
186                    "properties" : {
187                        // ID of the document / file / link
188                        "item_id": { "type": "keyword" },
189                        // Folder, File, Link
190                        "item_type": { "type": "keyword" },
191                        // Mime type for files
192                        "mime": { "type": "keyword" },
193                        // Full text file/folder/link name search
194                        "name" : { "type" : "text", "analyzer": "edge_ngram_analyzer" },
195                        // Full text file/link value content search
196                        "content" : { "type" : "text" },
197                        // Created at date search
198                        "created_at": { "type": "date", "format": "rfc3339_lenient" },
199                        // Exact user for the creator user ID
200                        "created_by": { "type": "keyword" },
201                        // Exact search for the folder the item is within
202                        "folder_id": { "type": "keyword" },
203                         // Exact search for the document box the item is within
204                        "document_box": { "type": "keyword" },
205                        // Document versioning
206                        "version": {
207                            "type": "keyword"
208                        },
209                        // Document pages for files
210                        "pages": {
211                            "type": "nested",
212                            "properties": {
213                                // Full text file/link value content search
214                                "content" : { "type" : "text" },
215                                // Page number
216                                "page": { "type": "integer" },
217                            }
218                        }
219                    }
220                }
221            }))
222            .send()
223            .await
224            .map_err(|error| {
225                tracing::error!(?error, "failed to create index");
226                OpenSearchSearchError::CreateIndex
227            })?;
228
229        tracing::debug!("open search response {response:?}");
230
231        Ok(())
232    }
233
234    async fn index_exists(&self) -> Result<bool, SearchError> {
235        // Delete index for files
236        let response = self
237            .client
238            .indices()
239            .get(IndicesGetParts::Index(&[&self.search_index.0]))
240            .send()
241            .await
242            .map_err(|error| {
243                tracing::error!(?error, "failed to get index");
244                OpenSearchSearchError::GetIndex
245            })?;
246
247        if response.status_code() == StatusCode::NOT_FOUND {
248            return Ok(false);
249        }
250
251        response.error_for_status_code().map_err(|error| {
252            tracing::error!(?error, "failed to get index");
253            OpenSearchSearchError::GetIndex
254        })?;
255
256        Ok(true)
257    }
258
259    async fn delete_index(&self) -> Result<(), SearchError> {
260        // Delete index for files
261        let response = self
262            .client
263            .indices()
264            .delete(IndicesDeleteParts::Index(&[&self.search_index.0]))
265            .send()
266            .await
267            .map_err(|error| {
268                tracing::error!(?error, "failed to delete index");
269                OpenSearchSearchError::DeleteIndex
270            })?;
271
272        // Gracefully handle the index already not existing
273        if response.status_code() == StatusCode::NOT_FOUND {
274            return Ok(());
275        }
276
277        response.error_for_status_code().map_err(|error| {
278            tracing::error!(?error, "failed to delete search index (response)");
279            OpenSearchSearchError::DeleteIndex
280        })?;
281
282        Ok(())
283    }
284
285    async fn search_index_file(
286        &self,
287        scope: &DocumentBoxScopeRaw,
288        file_id: docbox_database::models::file::FileId,
289        query: super::models::FileSearchRequest,
290    ) -> Result<FileSearchResults, SearchError> {
291        let offset = query.offset;
292        let query = create_opensearch_file_query(query, scope, file_id);
293
294        tracing::debug!(%query, "searching with query");
295
296        // Search for field in content
297        let response = self
298            .client
299            .search(SearchParts::Index(&[&self.search_index.0]))
300            .from(offset.unwrap_or(0) as i64)
301            .body(query)
302            .send()
303            .await
304            .map_err(|error| {
305                tracing::error!(?error, "failed to search index file");
306                OpenSearchSearchError::SearchIndex
307            })?;
308
309        let response: serde_json::Value = response.json().await.map_err(|error| {
310            tracing::error!(?error, "failed to get file search response");
311            OpenSearchSearchError::SearchIndex
312        })?;
313
314        tracing::debug!(%response, "search response");
315
316        let response: SearchResponse = serde_json::from_value(response).map_err(|error| {
317            tracing::error!(?error, "failed to parse file search response");
318            OpenSearchSearchError::SearchIndex
319        })?;
320
321        let (total_hits, results) = response
322            .hits
323            .hits
324            .into_iter()
325            .next()
326            .and_then(|item| item.inner_hits)
327            .map(|inner_hits| {
328                let total_hits = inner_hits.pages.hits.total.value;
329                let page_matches: Vec<PageResult> = inner_hits
330                    .pages
331                    .hits
332                    .hits
333                    .into_iter()
334                    .map(|value| PageResult {
335                        page: value._source.page,
336                        matches: value.highlight.content,
337                    })
338                    .collect();
339                (total_hits, page_matches)
340            })
341            .unwrap_or_default();
342
343        Ok(FileSearchResults {
344            total_hits,
345            results,
346        })
347    }
348
349    async fn search_index(
350        &self,
351        scope: &[DocumentBoxScopeRaw],
352        query: SearchRequest,
353        folder_children: Option<Vec<FolderId>>,
354    ) -> Result<SearchResults, SearchError> {
355        let offset = query.offset;
356        let query = create_opensearch_query(query, scope, folder_children);
357
358        tracing::debug!(%query, "searching with query");
359
360        // Search for field in content
361        let response = self
362            .client
363            .search(SearchParts::Index(&[&self.search_index.0]))
364            .from(offset.unwrap_or(0) as i64)
365            .body(query)
366            .send()
367            .await
368            .map_err(|error| {
369                tracing::error!(?error, "failed to search index");
370                OpenSearchSearchError::SearchIndex
371            })?;
372
373        let response: serde_json::Value = response.json().await.map_err(|error| {
374            tracing::error!(?error, "failed to get search response");
375            OpenSearchSearchError::SearchIndex
376        })?;
377
378        tracing::debug!(%response);
379
380        let response: SearchResponse = serde_json::from_value(response).map_err(|error| {
381            tracing::error!(?error, "failed to parse search response");
382            OpenSearchSearchError::SearchIndex
383        })?;
384        let total_hits = response.hits.total.value;
385
386        const NAME_MATCH_KEYS: [&str; 2] = ["name_match_exact", "name_match_wildcard"];
387
388        let results: Vec<FlattenedItemResult> = response
389            .hits
390            .hits
391            .into_iter()
392            .map(|item| {
393                let (total_hits, page_matches) = match item.inner_hits {
394                    Some(inner_hits) => {
395                        let total_hits = inner_hits.pages.hits.total.value;
396                        let page_matches: Vec<PageResult> = inner_hits
397                            .pages
398                            .hits
399                            .hits
400                            .into_iter()
401                            .map(|value| PageResult {
402                                page: value._source.page,
403                                matches: value.highlight.content,
404                            })
405                            .collect();
406                        (total_hits, page_matches)
407                    }
408                    None => (0, vec![]),
409                };
410
411                let name_match = item.matched_queries.is_some_and(|matches| {
412                    matches
413                        .iter()
414                        .any(|value| NAME_MATCH_KEYS.contains(&value.as_str()))
415                });
416                let content_match = !page_matches.is_empty();
417
418                FlattenedItemResult {
419                    item_ty: item._source.item_type,
420                    item_id: item._source.item_id,
421                    document_box: item._source.document_box,
422                    score: SearchScore::Float(item._score),
423                    page_matches,
424                    total_hits,
425                    name_match,
426                    content_match,
427                }
428            })
429            .collect();
430
431        Ok(SearchResults {
432            total_hits,
433            results,
434        })
435    }
436
437    async fn add_data(&self, data: Vec<SearchIndexData>) -> Result<(), SearchError> {
438        let mapped_data: Vec<JsonBody<OsSearchIndexData>> = data
439            .into_iter()
440            .map(|data| {
441                JsonBody::new(OsSearchIndexData {
442                    ty: data.ty,
443                    folder_id: data.folder_id,
444                    document_box: data.document_box,
445                    item_id: data.item_id,
446                    name: data.name,
447                    mime: data.mime,
448                    content: data.content,
449                    created_at: data.created_at.to_rfc3339(),
450                    created_by: data.created_by,
451                    pages: data.pages,
452                })
453            })
454            .collect();
455
456        // Index a file
457        let result = self
458            .client
459            // Use file.id
460            .bulk(opensearch::BulkParts::Index(&self.search_index.0))
461            .body(mapped_data)
462            .send()
463            .await
464            .map_err(|error| {
465                tracing::error!(?error, "failed to bulk add data");
466                OpenSearchSearchError::AddData
467            })?;
468
469        let status_code = result.status_code();
470
471        let response = result.text().await.map_err(|error| {
472            tracing::error!(?error, "failed to get bulk add response");
473            OpenSearchSearchError::AddData
474        })?;
475
476        if status_code.is_client_error() || status_code.is_server_error() {
477            tracing::error!(?response, "bulk add error response");
478            return Err(OpenSearchSearchError::AddData.into());
479        }
480        Ok(())
481    }
482
483    async fn update_data(
484        &self,
485        item_id: Uuid,
486        data: UpdateSearchIndexData,
487    ) -> Result<(), SearchError> {
488        let data = OsUpdateSearchIndexData {
489            folder_id: data.folder_id,
490            name: data.name,
491            content: data.content,
492            pages: data.pages,
493        };
494
495        let items = self.get_by_item_id(item_id).await.map_err(|error| {
496            tracing::error!(?error, "failed to find items to update");
497            OpenSearchSearchError::UpdateData
498        })?;
499
500        // Nothing to update
501        if items.is_empty() {
502            return Ok(());
503        }
504
505        /// Structure for creating bulk update "update" or "doc" entries for serialization
506        #[derive(Serialize)]
507        enum BulkUpdateEntry<'a> {
508            /// Update query
509            #[serde(rename = "update")]
510            Update {
511                /// ID of the document to update
512                _id: String,
513            },
514            /// Data to update the document with
515            #[serde(rename = "doc")]
516            Document {
517                #[serde(flatten)]
518                data: &'a OsUpdateSearchIndexData,
519            },
520        }
521
522        // Create the updates
523        let updates: Vec<JsonBody<BulkUpdateEntry<'_>>> = items
524            .into_iter()
525            .flat_map(|_id| {
526                [
527                    BulkUpdateEntry::Update { _id },
528                    BulkUpdateEntry::Document { data: &data },
529                ]
530            })
531            .map(JsonBody::new)
532            .collect();
533
534        // Perform the bulk updates
535        let result = self
536            .client
537            .bulk(opensearch::BulkParts::Index(&self.search_index.0))
538            .body(updates)
539            .send()
540            .await
541            .map_err(|error| {
542                tracing::error!(?error, "failed to update data (request)");
543                OpenSearchSearchError::UpdateData
544            })?;
545
546        let status_code = result.status_code();
547        let response: serde_json::Value = result.json().await.map_err(|error| {
548            tracing::error!(?error, "failed to update data (response)");
549            OpenSearchSearchError::UpdateData
550        })?;
551
552        tracing::debug!(?response, "search index update response");
553
554        if status_code.is_client_error() || status_code.is_server_error() {
555            tracing::error!(?response, "update data error response");
556            return Err(OpenSearchSearchError::UpdateData.into());
557        }
558
559        Ok(())
560    }
561
562    async fn delete_data(&self, item_id: Uuid) -> Result<(), SearchError> {
563        self.client
564            .delete_by_query(DeleteByQueryParts::Index(&[&self.search_index.0]))
565            .body(json!({
566                "query": {
567                    "term": { "item_id": item_id }
568                }
569            }))
570            .send()
571            .await
572            .map_err(|error| {
573                tracing::error!(?error, "failed to delete data");
574                OpenSearchSearchError::DeleteData
575            })?;
576
577        Ok(())
578    }
579
580    async fn delete_by_scope(&self, scope: DocumentBoxScopeRawRef<'_>) -> Result<(), SearchError> {
581        self.client
582            .delete_by_query(DeleteByQueryParts::Index(&[&self.search_index.0]))
583            .body(json!({
584                "query": {
585                    "term": { "document_box": scope }
586                }
587            }))
588            .send()
589            .await
590            .map_err(|error| {
591                tracing::error!(?error, "failed to delete data by scope");
592                OpenSearchSearchError::DeleteData
593            })?;
594
595        Ok(())
596    }
597
598    async fn get_pending_migrations(
599        &self,
600        _applied_names: Vec<String>,
601    ) -> Result<Vec<String>, SearchError> {
602        Ok(Vec::new())
603    }
604
605    async fn apply_migration(
606        &self,
607        _tenant: &Tenant,
608        _root_t: &mut DbTransaction<'_>,
609        _t: &mut DbTransaction<'_>,
610        _name: &str,
611    ) -> Result<(), SearchError> {
612        Ok(())
613    }
614}
615
616impl OpenSearchIndex {
617    /// Collect all records for the provided `item_id`
618    async fn get_by_item_id(&self, item_id: Uuid) -> Result<Vec<String>, OpenSearchSearchError> {
619        #[derive(Debug, Deserialize, Serialize)]
620        struct Response {
621            hits: Hits,
622        }
623
624        #[derive(Debug, Deserialize, Serialize)]
625        struct Hits {
626            hits: Vec<Hit>,
627        }
628
629        #[derive(Debug, Deserialize, Serialize)]
630        struct Hit {
631            _id: String,
632        }
633
634        // Search for field in content
635        let response = self
636            .client
637            .search(SearchParts::Index(&[&self.search_index.0]))
638            .from(0)
639            .size(10)
640            .body(json!({
641                "query": {
642                   "term": { "item_id": item_id }
643                },
644            }))
645            .send()
646            .await
647            .map_err(|error| {
648                tracing::error!(?error, "failed to get search item by id");
649                OpenSearchSearchError::SearchIndex
650            })?;
651
652        let response: Response = response.json().await.map_err(|error| {
653            tracing::error!(?error, "failed to parse search item by id response");
654            OpenSearchSearchError::SearchIndex
655        })?;
656
657        Ok(response.hits.hits.into_iter().map(|hit| hit._id).collect())
658    }
659}
660
661/// Updates data within the search index
662#[skip_serializing_none]
663#[derive(Serialize)]
664struct DateRange {
665    gte: Option<String>,
666    lte: Option<String>,
667}
668
669pub fn create_opensearch_query(
670    req: SearchRequest,
671    scopes: &[DocumentBoxScopeRaw],
672    folder_children: Option<Vec<FolderId>>,
673) -> serde_json::Value {
674    let mut filters = vec![];
675    let mut should = Vec::new();
676
677    // Always filter to the specific document box scope
678    filters.push(json!({
679        "terms": { "document_box": scopes }
680    }));
681
682    let query = req
683        .query
684        // Filter out empty queries
685        .filter(|value| !value.is_empty());
686
687    if let Some(ref query) = query {
688        if req.include_name {
689            // Match name of documents
690            should.push(json!({
691                "term": {
692                    "name": {
693                        "value": query,
694                        "boost": 2,
695                        "_name": "name_match_exact",
696                        "case_insensitive": true
697                    }
698                }
699            }));
700            should.push(json!({
701                "wildcard": {
702                    "name": {
703                        "value": format!("*{query}*"),
704                        "boost": 1.5,
705                        "_name": "name_match_wildcard",
706                        "case_insensitive": true
707                    }
708                }
709            }));
710        }
711
712        if req.include_content {
713            // Match content on the document itself (Link value)
714            should.push(json!({
715                "match": {
716                    "content": {
717                        "query": query,
718                        // Name the match for scoring later
719                        "_name": "content_match"
720                    },
721                }
722            }));
723
724            // Match content pages
725            should.push(json!({
726                "nested": {
727                    "path": "pages",
728                    // Match nested page content
729                    "query": {
730                        "match": {
731                            "pages.content": {
732                                "query": query,
733                                // Name the match for scoring later
734                                "_name": "content_match"
735                            },
736                        }
737                    },
738                    "inner_hits": {
739                        "_source": ["pages.page"],
740                        // Highlight
741                        "highlight": {
742                            "fields": {
743                                "pages.content": {
744                                    "fragment_size": 150,
745                                    "number_of_fragments": 3,
746                                    "type": "unified"
747                                }
748                            }
749                        },
750                        // Order results by score
751                        "sort": [
752                            {
753                              "_score": {
754                                "order": "desc"
755                              }
756                            }
757                        ],
758                        // Pagination
759                        "size": req.max_pages.unwrap_or(3),
760                    }
761                }
762            }));
763        }
764    }
765
766    if let Some(folder_children) = folder_children {
767        filters.push(json!({
768            "terms": { "folder_id": folder_children }
769        }));
770    }
771
772    if let Some(ref mime) = req.mime {
773        filters.push(json!({
774            "term": { "mime": mime }
775        }));
776    }
777
778    if let Some(ref created_at) = req.created_at {
779        let start = created_at.start.map(|value| value.to_rfc3339());
780        let end = created_at.end.map(|value| value.to_rfc3339());
781
782        if start.is_some() || end.is_some() {
783            filters.push(json!({
784                "range": {
785                    "created_at": DateRange {
786                        gte: start,
787                        lte: end
788                    }
789                }
790            }));
791        }
792    }
793
794    if let Some(ref created_by) = req.created_by {
795        filters.push(json!({
796            "term": { "created_by": created_by }
797        }));
798    }
799
800    // When a "should" is provided we must at least match one part of it
801    let minimum_should_match = if !should.is_empty() { 1 } else { 0 };
802
803    json!({
804        // Search query itself
805        "query": {
806            "bool": {
807                "filter": filters,
808                "should": should,
809                "minimum_should_match": minimum_should_match
810            },
811        },
812
813        // Maximum number of results to find
814        "size": req.size.unwrap_or(50),
815        // Offset within results
816        "from": req.offset.unwrap_or(0),
817
818        // Only include relevant source fields
819        "_source": [
820            "item_id",
821            "item_type",
822            "document_box"
823        ],
824
825        // Sort results by match score
826        "sort": [
827            {
828                "_score": {
829                    "order": "desc"
830                }
831            }
832        ]
833    })
834}
835
836pub fn create_opensearch_file_query(
837    req: FileSearchRequest,
838    scope: &DocumentBoxScopeRaw,
839    file_id: FileId,
840) -> serde_json::Value {
841    let query = req.query.unwrap_or_default();
842
843    json!({
844        // Search query itself
845        "query": {
846            "bool": {
847                "filter": [
848                    {
849                        "term": { "document_box": scope }
850                    },
851                    {
852                        "term": { "item_id": file_id }
853                    }
854                ],
855                "should": [
856                    {
857                        "nested": {
858                            "path": "pages",
859                            // Match nested page content
860                            "query": {
861                                "match": {
862                                    "pages.content": {
863                                        "query": query,
864                                        // Name the match for scoring later
865                                        "_name": "content_match"
866                                    },
867                                }
868                            },
869                            "inner_hits": {
870                                "_source": ["pages.page"],
871                                // Highlight
872                                "highlight": {
873                                    "fields": {
874                                        "pages.content": {
875                                            "fragment_size": 150,
876                                            "number_of_fragments": 1,
877                                            "type": "unified"
878                                        }
879                                    }
880                                },
881                                // Order results by score
882                                "sort": [
883                                    {
884                                    "_score": {
885                                        "order": "desc"
886                                    }
887                                    }
888                                ],
889                                // Pagination
890                                "size": req.limit.unwrap_or(3),
891                                "from": req.offset.unwrap_or(0),
892                            }
893                        }
894                    }
895                ],
896                "minimum_should_match": 1
897            },
898        },
899
900        "size": 1,
901        "from": 0,
902
903        // Only include relevant source fields
904        "_source": [
905            "item_id",
906            "item_type",
907            "document_box"
908        ],
909
910        // Sort results by match score
911        "sort": [
912            {
913                "_score": {
914                    "order": "desc"
915                }
916            }
917        ]
918    })
919}