Search Index Fields
In this guide, you'll learn about the field types and modifiers you can use in a search index definition.
Define Index Fields#
A search index definition's fields property declares what the search engine holds. Use search.define to create a schema of field types and modifiers.
For example:
Each field has a type, such as search.text(), and any number of chained modifiers, such as .searchable(). A type sets what the field holds, and a modifier declares what the engine can do with it.
Modifiers and Facet Types by Field Type#
A modifier that doesn't apply to a field type isn't available on it, so an invalid combination fails to compile. The following table shows which modifiers apply to which field types, and which facet types each type allows.
Field Type | Modifiers it Accepts | Facet Types it Allows |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
| None |
Only integer, float, and date accept a types option on facetable. Every other type takes facetable() as a boolean and allows value facets. Refer to Facet Types for what each kind returns.
search.keyword()#
The keyword identifier indicates a string that is treated as a whole value. Use it for IDs, handles, statuses, and any value you filter or facet on.
In this example, status is a keyword field, so a query can filter on it with { status: "published" }.
search.text()#
The text identifier indicates a string analyzed for free-text matching. Use it for titles, descriptions, and other prose.
A text field only participates in free-text matching once you add the searchable modifier. Here title carries a higher weight than description, so a product matching a query in its title outranks one matching only in its description.
Search Text in Multiple Locales#
A text field holds a single string, and no field type stores translations of a value. An index only holds what its seed and consume functions write to it, so to match a free-text query in more than one locale, declare a field for each locale.
How you declare those fields depends on the provider:
Medusa Search matches a searchable field term by term, and it stems those terms only when the field's full_text_search provider option asks for it. So its tab names a language per locale field and enables stemming, which gives each field the stemmer of its own language. Refer to Set a Field's Language for the languages and options it accepts.
Other providers set the language for the whole index rather than per field, such as the language option of the PostgreSQL Search Module Provider, so a locale field needs no option of its own.
Then, write each locale's value to its own field in the index's seed function. If you use the Translation Module, pass the locale option to Query to retrieve the translated values of a locale:
1export const productIndex = defineSearchIndex({2 name: "product",3 entity: "product",4 fields: search.define({5 // ...6 }),7 async *seed({ container, catchup, last_key: lastKey }) {8 const batchSize = 2009 let cursor = lastKey10 11 while (true) {12 const { data: products } = await container.query13 .graph({14 entity: "product",15 fields: [16 "id",17 "title",18 "updated_at",19 "deleted_at",20 ],21 filters: {22 ...(catchup23 ? { updated_at: { $gte: catchup.since } }24 : {}),25 ...(cursor ? { id: { $gt: cursor } } : {}),26 },27 pagination: {28 take: batchSize,29 order: { id: "ASC" },30 },31 withDeleted: !!catchup,32 })33 34 if (!products.length) {35 return36 }37 38 const live = products.filter((p) => !p.deleted_at)39 const gone = products.filter((p) => !!p.deleted_at)40 41 const { data: frProducts } = await container.query42 .graph({43 entity: "product",44 fields: ["id", "title"],45 filters: { id: live.map((p) => p.id) },46 }, { locale: "fr-FR" })47 48 const frTitles = new Map(49 frProducts.map((product) => [50 product.id,51 product.title,52 ])53 )54 55 yield [56 ...(live.length57 ? [{58 action: "upsert" as const,59 documents: live.map((product) => ({60 id: product.id,61 title_en: product.title,62 title_fr:63 frTitles.get(product.id) ??64 product.title,65 })),66 }]67 : []),68 ...(gone.length69 ? [{70 action: "delete" as const,71 filters: { id: gone.map((p) => p.id) },72 }]73 : []),74 ]75 76 if (products.length < batchSize) {77 return78 }79 80 cursor = products[products.length - 1].id81 }82 },83})
The seed function pages the products by their ID, so an interrupted run resumes from the last_key it receives rather than starting over. It also runs as the catch-up pass that follows a full run, which is why it reads catchup: the pass narrows the read to the products changed since the run started, includes the deleted ones, and yields a delete write for them so the index loses their documents.
seed with graphSeed and its transform option, which is asynchronous and receives a whole page of rows. Refer to Read More Data in transform for an example that reads the same page again with the locale option.Then, when running a search query, such as in an API route, pass the search_options.attributes_to_search_on option to specify which locale-specific fields to search on:
search.integer()#
The integer identifier indicates a whole number such as an inventory quantity. Useful for filtering, sorting, and faceting. It isn't matched as free text.
In this example, a query can filter on inventory_quantity and order results by it with pagination.order. Adding facetable() would also let a query summarize the matched documents by quantity brackets.
search.float()#
The float identifier indicates a decimal number, such as a price. Useful for filtering, sorting, and faceting. It isn't matched as free text.
In this example, a query can filter on min_price, order results by it with pagination.order, and facet on it to summarize the matched documents by price brackets.
A range facet on a price turns into the price brackets a storefront shows beside its results. Learn more in the Search Index Field Modifiers guide.
search.boolean()#
The boolean identifier indicates a true or false value. Use it for flags such as whether a product is a gift card. Useful for filtering and faceting. It isn't matched as free text.
In this example, a query can filter on is_giftcard to find only gift cards or the opposite.
search.date()#
The date identifier indicates a date. A document can hold either a Date object or an ISO string, since query.graph returns Date objects while an event payload carries a string. Useful for filtering, sorting, and faceting. It isn't matched as free text.
In this example, a query can filter on created_at and order results by it with pagination.order. Adding facetable() would also let a query summarize the matched documents by date brackets.
search.geo()#
The geo identifier searches over { lat, lng } coordinate pairs. Use it for locations such as warehouses or stores. Useful for filtering by proximity. It isn't matched as free text.
In this example, a query can filter on location to find documents within a radius of a point.
Yield a Coordinate Pair#
A field's type describes the document your index holds, not a property on your data models. So the { lat, lng } shape is what seed and consume must yield, no matter how you store the coordinates.
For example, if your data model holds latitude and longitude as separate properties, combine them into the pair when you yield the documents:
1import {2 defineSearchIndex,3 search,4} from "@medusajs/framework/utils"5 6export const warehouseIndex = defineSearchIndex({7 name: "warehouse",8 entity: "warehouse",9 fields: search.define({10 id: search.keyword().filterable(),11 name: search.text().searchable(),12 location: search.geo().filterable(),13 }),14 async *seed({ container }) {15 const { data } = await container.query.graph({16 entity: "warehouse",17 fields: ["id", "name", "latitude", "longitude"],18 })19 20 yield [{21 action: "upsert",22 documents: data.map((warehouse) => ({23 id: warehouse.id,24 name: warehouse.name,25 location: {26 lat: warehouse.latitude,27 lng: warehouse.longitude,28 },29 })),30 }]31 },32})
search.define type-checks what you yield against the fields you declared, so a document whose location isn't a { lat, lng } pair fails to compile.
search.object()#
The object identifier indicates a nested object. Pass its sub-fields as a schema, using the same types and modifiers as top-level fields.
1import {2 defineSearchIndex,3 search,4} from "@medusajs/framework/utils"5 6export const productIndex = defineSearchIndex({7 fields: search.define({8 brand: search.object({9 name: search.keyword().searchable().facetable(),10 country: search.keyword().filterable(),11 }),12 // ...13 }),14 // ...15})
In this example, a query can filter on brand.country, search on brand.name, and facet on brand.name to summarize the matched documents by brand.
Define as an Array of Objects#
Add the array modifier to an object field to indicate that the field holds an array of objects, rather than a single object.
1import {2 defineSearchIndex,3 search,4} from "@medusajs/framework/utils"5 6export const productIndex = defineSearchIndex({7 fields: search.define({8 variants: search9 .object({10 title: search.text().searchable(),11 sku: search.keyword().filterable(),12 })13 .array(),14 // ...15 }),16 // ...17})
In this example, a query can filter on variants.sku to find products that have a variant with a specific SKU.
variants.color = "red" and variants.size = "XL" also matches a product with a red small variant and a blue extra-large one. No provider Medusa ships can restrict the match to a single element.search.vector()#
The vector identifier indicates an embedding, used for vector search. Unlike other types, a vector field can't be filtered, sorted, faceted, or turned into an array, so those modifiers aren't available on it.
vector accepts the number of dimensions in the embedding, which is how many numbers are in the list.
In this example, title_embedding is a vector field with 1536 dimensions, matching the output of OpenAI's text-embedding-3-small model. Your seed and consume functions yield the embedding for the field, so you compute it yourself before you yield the document.
With Medusa Search, an embedding you compute yourself can have at most 1536 dimensions. Medusa Search rejects a query whose embedding is longer than that.
vector field doesn't come back on a hit by default, since a list of 1536 numbers is rarely useful to the caller. Chain retrievable() on it if you need the embedding in the result.Let Medusa Search Create the Embedding#
With Medusa Search, you can chain the embed modifier on the field. Medusa Search then turns the text your documents pass on that field into an embedding as it indexes each document, and embeds a query's text the same way at query time.
For example:
An embedded field changes what your documents carry and how you query it:
- Your documents pass the text to embed as a string on the field itself, rather than a
number[].search.definetypes the field as a string once you chainembed(), so a document that passes an array fails to compile. - Medusa Search replaces that text with the embedding it computes, so the source text isn't stored under the field.
- A query can pass raw text as
search_options.vector.query, which Medusa Search embeds at query time to run a semantic search. Withoutembed, a query can only search the field with a pre-computed embedding passed assearch_options.vector.value. Refer to Vector and Hybrid Search for an example of both.
So, your seed and consume functions pass the text on title_embedding rather than an embedding:
1import {2 defineSearchIndex,3 search,4} from "@medusajs/framework/utils"5 6export const productIndex = defineSearchIndex({7 name: "product",8 entity: "product",9 fields: search.define({10 id: search.keyword().filterable(),11 title: search.text().searchable(),12 title_embedding: search.vector(1536).embed(),13 // ...14 }),15 async *seed({ container }) {16 const { data: products } = await container.query.graph({17 entity: "product",18 fields: ["id", "title"],19 })20 21 yield [{22 action: "upsert",23 documents: products.map((product) => ({24 id: product.id,25 title: product.title,26 title_embedding: product.title,27 })),28 }]29 },30 // ...31})
Then, you can perform a semantic search on the title_embedding field by passing raw text as the query:
Why the Number of Dimensions Matters#
You must declare the number of dimensions in a vector field because the provider needs to know how to store and compare the embeddings:
- The provider creates a column or index sized for exactly that many numbers, so it needs the value before any document exists.
- Two embeddings are only comparable if they have the same number of dimensions, so the provider rejects a document or query whose embedding is a different length.