Search Index Fields

In this guide, you'll learn about the field types and modifiers you can use in a search index definition.

Note: Refer to the Search Index Field Modifiers guide for details on what each modifier does and which types it applies to.
Tip: The product indexes in this guide's snippets are simplified to show one feature at a time. For examples of indexing prices in multiple currencies, option values, categories, and other product data, refer to the Product Index Examples guide.

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:

src/search/product.ts
1import {2  defineSearchIndex,3  search,4} from "@medusajs/framework/utils"5
6export const productIndex = defineSearchIndex({7  fields: search.define({8    title: search.text().searchable({ weight: 3 }),9    min_price: search.float().sortable().facetable(),10    // ...11  }),12  // ...13})

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

keyword, text

searchable, filterable, sortable, facetable, array, retrievable, providerOptions

value

integer, float, date

filterable, sortable, facetable, array, retrievable, providerOptions

value, range, stats

boolean, geo

filterable, sortable, facetable, array, retrievable, providerOptions

value

object

filterable, sortable, facetable, array, retrievable, providerOptions

value

vector

embed, retrievable, providerOptions

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.

Note: Adding, removing, or changing a field changes the definition's hash, which the Search Module treats as a schema change. Refer to the Reindexing and Migrations guide to learn how it rebuilds the index.

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.

src/search/product.ts
1import {2  defineSearchIndex,3  search,4} from "@medusajs/framework/utils"5
6export const productIndex = defineSearchIndex({7  fields: search.define({8    status: search.keyword().filterable(),9    // ...10  }),11  // ...12})

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.

src/search/product.ts
1import {2  defineSearchIndex,3  search,4} from "@medusajs/framework/utils"5
6export const productIndex = defineSearchIndex({7  fields: search.define({8    title: search.text().searchable({ weight: 3 }),9    description: search.text().searchable(),10    // ...11  }),12  // ...13})

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:

src/search/product.ts
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.

Tip: You can also build this 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:

Code
1const { data } = await query.search({2  entity: "product",3  fields: ["id", "title_fr"],4  filters: {5    q: "chaussures",6  },7  search_options: {8    attributes_to_search_on: ["title_fr"],9  },10})

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.

src/search/product.ts
1import {2  defineSearchIndex,3  search,4} from "@medusajs/framework/utils"5
6export const productIndex = defineSearchIndex({7  fields: search.define({8    inventory_quantity: search9      .integer()10      .filterable()11      .sortable(),12    // ...13  }),14  // ...15})

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.

src/search/product.ts
1import {2  defineSearchIndex,3  search,4} from "@medusajs/framework/utils"5
6export const productIndex = defineSearchIndex({7  fields: search.define({8    min_price: search9      .float()10      .filterable()11      .sortable()12      .facetable({ types: ["range"] }),13    // ...14  }),15  // ...16})

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.

src/search/product.ts
1import {2  defineSearchIndex,3  search,4} from "@medusajs/framework/utils"5
6export const productIndex = defineSearchIndex({7  fields: search.define({8    is_giftcard: search.boolean().filterable(),9    // ...10  }),11  // ...12})

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.

src/search/product.ts
1import {2  defineSearchIndex,3  search,4} from "@medusajs/framework/utils"5
6export const productIndex = defineSearchIndex({7  fields: search.define({8    created_at: search.date().filterable().sortable(),9    // ...10  }),11  // ...12})

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.

Tip: No provider that Medusa ships supports geo fields. You can support it in your custom Search Module Provider.
src/search/warehouse.ts
1import {2  defineSearchIndex,3  search,4} from "@medusajs/framework/utils"5
6export const warehouseIndex = defineSearchIndex({7  fields: search.define({8    location: search.geo().filterable(),9    // ...10  }),11  // ...12})

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:

src/search/warehouse.ts
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.

src/search/product.ts
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.

src/search/product.ts
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.

Note: Filters on the sub-fields of an object array match across elements. So filtering a product on 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()#

Tip: Vector search with Medusa Search is available on the Scale and Enterprise plans. On the Scale plan, you compute the embeddings yourself, whereas the Enterprise plan can also create them for you.

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.

src/search/product.ts
1import {2  defineSearchIndex,3  search,4} from "@medusajs/framework/utils"5
6export const productIndex = defineSearchIndex({7  fields: search.define({8    title_embedding: search.vector(1536),9    // ...10  }),11  // ...12})

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.

Note: A 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:

src/search/product.ts
1import {2  defineSearchIndex,3  search,4} from "@medusajs/framework/utils"5
6export const productIndex = defineSearchIndex({7  fields: search.define({8    title: search.text().searchable(),9    title_embedding: search.vector(1536).embed(),10    // ...11  }),12  // ...13})

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.define types the field as a string once you chain embed(), 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. Without embed, a query can only search the field with a pre-computed embedding passed as search_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:

src/search/product.ts
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:

Code
1const { data } = await query.search({2  entity: "product",3  fields: ["id", "title"],4  search_options: {5    vector: {6      field: "title_embedding",7      query: "comfortable shoes for long runs",8    },9  },10})
Note: Medusa Search decides which embedding model produces the vector, so you don't choose one in the definition. Refer to Semantic Search with Medusa Search for more details.

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.
Was this page helpful?
Ask Bloom
For assistance in your development, use Claude Code Plugins or Medusa MCP server in Cursor, VSCode, etc...FAQ
What is Medusa?
How can I create a module?
How can I create a data model?
How do I create a workflow?
How can I extend a data model in the Product Module?
Recipes
How do I build a marketplace with Medusa?
How do I build digital products with Medusa?
How do I build subscription-based purchases with Medusa?
What other recipes are available in the Medusa documentation?
Chat is cleared on refresh
Line break