4.5.2. Search Queries
In this chapter, you’ll learn about Query's search method and how to use it to run full-text search queries with filters, facets, highlighting, and vector search.
What is a Search Query?#
A search query runs against the Search Module to retrieve and rank documents based on relevance. For example, searching products with full-text search, filtering by price, and sorting by popularity.
Under the hood, the Search Module retrieves the results from the integrated Search Module Provider, such as Medusa Search or your custom third-party provider.
For searching from a storefront, Medusa provides a Store Search API route that uses query.search under the hood. You can also use query.search in your custom API routes, such as to search data models that the route doesn't expose.
query.search over query.graph when the request has a free-text term to rank by. query.graph filters records in the database without relevance ranking, whereas query.search delegates the ranking to a Search Module Provider.- The request has no search term and no relevance ordering. Use
query.graphinstead.
Search Example#
Assuming you want a separate API route for searching products, create an API route at src/api/store/products/custom-search/route.ts with the following content:
6 ContainerRegistrationKeys,7} from "@medusajs/framework/utils"8 9export const GET = async (10 req: MedusaRequest,11 res: MedusaResponse12) => {13 const query = req.scope.resolve(14 ContainerRegistrationKeys.QUERY15 )16 17 const { data, search_result } = await query.search({18 entity: "product",19 fields: [20 "id",21 "title",22 "handle",23 "variants.id",24 "variants.sku",25 ],26 filters: {27 q: req.query.q as string,28 status: "published",29 },30 pagination: {31 skip: 0,32 take: 20,33 },34 })35 36 res.json({37 products: data,38 metadata: search_result.metadata,39 })40}
In the above example, you resolve Query from the Medusa container using the ContainerRegistrationKeys.QUERY (query) key.
Then, you run a search using its search method. This method accepts as a parameter an object with the following properties:
entity(required): The name of the index to search, as specified in thenameproperty of the index definition.fields: The fields to return on every result, including fields that the index doesn't hold, such asvariants.sku. If you omitfields, you'll receive every retrievable field the index holds.filters: The filters to apply, with the free-text term passed asq.pagination: Theskipandtakeoptions that page through the results.
The method returns an object with two properties:
data: The hydrated entities, in the relevance order the provider returned.search_result: What the provider reported, including hits, scores, facets, and pagination metadata.
For example, if you pass q=t-shirt in the request, the response may look like this:
1{2 "data": [3 {4 "id": "prod_123",5 "title": "Medusa T-Shirt",6 "handle": "t-shirt",7 "variants": [8 {9 "id": "variant_123",10 "sku": "SHIRT-S"11 }12 ]13 }14 ],15 "search_result": {16 "hits": [17 {18 "id": "prod_123",19 "document": {20 "id": "prod_123",21 "title": "Medusa T-Shirt",22 "handle": "t-shirt"23 }24 }25 ],26 "metadata": {27 "skip": 0,28 "take": 20,29 "count": 1,30 "query": "t-shirt",31 "processing_time_ms": 932 }33 }34}
Search Usage in Workflows#
To run a search query in a workflow, create a step that resolves Query from the container and uses it to run the search query.
For example:
1import { Modules } from "@medusajs/framework/utils"2import {3 createStep,4 StepResponse,5} from "@medusajs/framework/workflows-sdk"6 7export const searchProductsStep = createStep(8 "search-products",9 async (input: { q: string }, { container }) => {10 const searchModuleService = container.resolve(11 ContainerRegistrationKeys.QUERY12 )13 14 const { data, search_result } = await query.search({15 entity: "product",16 fields: [17 "id",18 "title",19 "handle",20 "variants.id",21 "variants.sku",22 ],23 filters: {24 q: input.q,25 status: "published",26 },27 pagination: {28 skip: 0,29 take: 20,30 },31 })32 33 return new StepResponse({34 products: data,35 metadata: search_result.metadata,36 })37 }38)
How Search Queries Work#
When you pass fields to query.search, the Search Module splits them into two groups:
- The fields the index holds. The provider returns them on every hit.
- The remaining fields.
query.graphfetches them, and they're merged to the returneddata.
So you can request fields of relations that the index never stores, such as variants.sku, without adding them to the index definition.
Select Specific Fields#
Always list in fields the fields you need, rather than relying on the default or passing {relation}.*.
Selecting specific fields keeps the hydration query cheaper, and it keeps the response smaller. For example, instead of retrieving all properties of a product and its variants:
Retrieve only the fields you use:
Apply Filters#
The search method accepts a filters property, whose value is an object of filters to apply. Its keys are index field names, and its values are the values to filter on.
To perform a free-text search, pass the term in the q property. The Search Module lifts q out before it compiles the rest, so a provider never treats q as a field.
In this example, the search query looks for products that match the free-text term "running shoe" and also have a status of "published".
filterable. The Search Module validates the query against the definition before it reaches the provider, so an unsupported field fails with a clear error.You can also filter by multiple values of a field. For example:
Filter Operators#
A filter value can be:
- A literal value performing an equality check, such as filtering by a specific status.
- An array performing an "is one of" check, such as filtering by multiple brands.
- An object of operators for advanced filtering, such as filtering by a minimum price.
For object filters, you can use the following operators:
Filter by a Range
For example, to filter products by a price range:
Filter an Array Field
For example, to filter products by tags that overlap with a given set:
In the example above, you retrieve the products having at least one of the summer and sale tags. Use $contains instead to require all of them.
Filter a Nested Field
For example, to filter products by a nested field:
In the example above, you filter on a nested field of the index. The index definition must declare variants.color as filterable.
Combine Filters
You can nest conditions with the $and, $or, and $not operators:
In the example above, you retrieve the products that are either published or from the acme brand, excluding the ones tagged as clearance.
Apply Pagination#
The search method's object parameter accepts a pagination property to configure the pagination of returned hits.
For example:
In this example, the pagination property specifies that the search should skip the first 0 hits and return the next 15 hits.
pagination is optional. If you omit it, or omit one of its properties, the Search Module applies skip: 0 and take: 20. So a search returns at most 20 hits unless you raise take.
query.graph, which returns every matching record when you don't pass pagination.The result's metadata property is an object with the following properties:
pagination.cursor, so paginate with skip and take.Sort Hits#
To sort the returned hits, pass an order property to pagination. Its value is an object whose keys are field names, and whose values are either ASC or DESC.
The index definition must mark a field as sortable before you can order by it. The only exception is the reserved _score key, which orders by relevance:
Change the Count Strategy#
Counting every matching document can be expensive on a large index, so the count search option tells the provider how accurate the count has to be:
count accepts one of the following values:
estimated(default): The provider returns whichever count its engine can produce cheaply, which may be an estimate.exact: The provider counts every matching document.none: The provider skips the count query, andmetadata.countisnull. Use this when the page you're building never shows a total or page numbers, such as a storefront with a "Load more" button. To find out whether more hits exist, check whether the returned hits filled the page, meaning their number equalstake.
exact differently from estimated. All of them run a real count unless you pass none. The distinction only matters for a provider whose engine can't count exactly, which either rejects exact or runs it slowly.Query Search Options#
The search_options property of the search method's parameter object is an object of options passed to the provider to control how it treats the query. For example, you can tell it which fields to match the term against, whether to enable typo tolerance, and which facets to compute.
If a provider doesn't support an option, it either ignores it or throws an error. Refer to Medusa Search vs PostgreSQL for what each one does with the options below.
It accepts the following properties:
For example, to match any of the query's terms in the product's title only:
Facets#
Facets return the distinct values of a field and how many documents fall into each one, which is what a storefront's filter sidebar shows.
facetable in the index definition. Learn more in the Search Index Field Modifiers guide.Pass a field name for a value facet, or an object for more control:
There are three facet types:
value(default): The distinct values of the field and their counts.range: The number of documents falling into each range you define. Available on numeric and date fields.stats: The minimum, maximum, average, sum, and count of the field.
For example, to compute a range facet and a stats facet on the same field:
1const { search_result } = await query.search({2 entity: "product",3 fields: ["id", "title"],4 filters: { q: "shoe" },5 search_options: {6 facets: [7 {8 field: "min_price",9 type: "range",10 ranges: [11 { key: "cheap", to: 50 },12 { key: "mid", from: 50, to: 200 },13 { key: "expensive", from: 200 },14 ],15 },16 { field: "min_price", type: "stats" },17 ],18 },19})
The facets are in the result's facets property, keyed by field name. For example:
Disjunctive Facets#
When a customer filters by one brand, a facet on brand normally returns only that brand. Set disjunctive_facets to true so the provider computes each facet while ignoring the filter on its own field, keeping the sibling values visible:
The result's facets property then holds the following:
1{2 "facets": {3 "brand": {4 "type": "value",5 "values": [6 { "value": "acme", "count": 12 },7 { "value": "borg", "count": 7 },8 { "value": "zeta", "count": 3 }9 ]10 },11 "status": {12 "type": "value",13 "values": [14 { "value": "published", "count": 10 },15 { "value": "draft", "count": 2 }16 ]17 }18 }19}
The brand facet still lists borg and zeta, despite the brand: "acme" filter. A shopper can see how many results switching brands would give them.
The status facet is unaffected because the query didn't filter on status. So, its results only show the counts of published and draft products whose brand is acme.
Highlighting#
Highlighting wraps the matched terms in the fields you name, so a storefront can show why a result matched.
highlight accepts the following properties:
The highlighted fragments are in each hit's highlights property, keyed by field name. Each key holds an array, since a field can match in more than one place:
1{2 "hits": [3 {4 "id": "prod_123",5 "document": {6 "id": "prod_123",7 "title": "Trail Running Shoe",8 "description": "A light shoe for trails."9 },10 "highlights": {11 "title": ["Trail Running <mark>Shoe</mark>"],12 "description": ["A light <mark>shoe</mark> for trails."]13 }14 }15 ]16}
The document property keeps the original values, so render the highlights fragments only where you want the matched terms marked.
Vector and Hybrid Search#
A field declared with search.vector(dimensions) holds an embedding, which lets the provider rank results by semantic similarity instead of term matching.
Pass text as search_options.vector.query for the provider's embedder to embed:
Alternatively, pass a pre-computed embedding as search_options.vector.value:
search_options.vector accepts the following properties: