Search Index Definitions
In this guide, you'll learn how to declare a search index and control what the search engine holds.
What is a Search Index Definition?#
A search index definition declares one index: the entity it indexes, the fields the search engine holds, and how to fill it. The Search Module owns everything around it, including creating the physical index, batching writes, and rebuilding the index when the definition changes.
Define an index with defineSearchIndex in a file under the src/search directory of your Medusa application or plugin. Medusa loads every file in that directory before the application boots, and each file registers its indexes on import.
For example, create the file src/search/brand.ts with the following content:
1import {2 defineSearchIndex,3 graphConsume,4 graphSeed,5 search,6} from "@medusajs/framework/utils"7 8const fields = ["id", "name", "country"]9 10export const brandIndex = defineSearchIndex({11 name: "brand",12 entity: "brand",13 fields: search.define({14 id: search.keyword().filterable(),15 name: search.text().searchable(),16 country: search.keyword().filterable(),17 }),18 events: [19 "brand.created",20 "brand.updated",21 "brand.deleted",22 ],23 consume: graphConsume({ fields }),24 seed: graphSeed({ fields }),25})
graphSeed and graphConsume are helpers that build the index's data from Query:
graphSeedreads the entity's records to fill the index for the first time.graphConsumereads the records that an event changed to keep the index current.
Most indexes are a projection of an entity that Query already exposes, so the helpers cover them. Refer to Custom seed and consume Implementation for an index whose data doesn't come from Query.
If you're using the PostgreSQL Search Module Provider or a provider that makes changes to the database, you must also run the migrations that create the necessary tables:
Index Definition Properties#
Property | Description | Required |
|---|---|---|
| A unique name for the index. This is the value for | Yes |
| The Query entry point used to hydrate the fields that the search index doesn't hold. | Yes |
| The fields the search engine holds. Must be a schema built with | Yes |
| An async generator that yields batches of writes, usually built with | Yes |
| The workflow events that change the data this index holds. Medusa subscribes to them and routes each one to | No |
| A function that turns an event into document writes, usually built with | No |
| The field holding each document's unique identifier. Defaults to | No |
| The identifier of the Search Module Provider that holds this index, such as | No |
| Engine settings for this index, such as the field to deduplicate results on. Only the Medusa Search provider supports them. Refer to Index Settings for the settings it accepts. | No |
fields or settings 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.Fill the Index with seed#
seed fills the index with documents. Every index definition must set it.
The Search Module decides when to run seed, such as when a migration creates the index, when you rebuild it on demand, or when it runs the catch-up pass. Refer to Seeding at Application Start for every case that triggers it and how the module plans each one.
Fill the Index from Query with graphSeed#
Build seed with the graphSeed helper when the index holds an entity that Query exposes, which is the case for most indexes.
For example:
1import {2 defineSearchIndex,3 graphSeed,4 search,5} from "@medusajs/framework/utils"6 7export const brandIndex = defineSearchIndex({8 name: "brand",9 entity: "brand",10 fields: search.define({11 id: search.keyword().filterable(),12 name: search.text().searchable(),13 }),14 seed: graphSeed({ fields: ["id", "name"] }),15})
graphSeed reads the entity through query.graph and writes every row it reads to the index. It also does the following for you:
- Pages the read, so a large catalog never loads into memory at once.
- Resumes an interrupted run from the record it stopped at, rather than restarting it.
- Handles the catch-up pass, including deleting the documents of records that were removed while the run was writing.
Index Part of an Entity
transform receives a page of records and returns the documents to index. To index a subset of an entity's records, leave the records the index must not hold out of the array you return, rather than narrowing the read with a filter.
For example, to index published products only:
graphSeed deletes the document of a record you leave out on the catch-up pass, so a product that stops qualifying leaves the index instead of going stale in it. A filtered read can't do that, since it never sees the record again.
id, since that's how graphSeed matches a document back to the record it came from. The helper throws if a document has no id.Use transform too when the document differs from the record, such as when you flatten a relation into an array of IDs.
For example, query.graph returns a product's categories as an array of objects, whereas the index holds their IDs in a category_ids field:
1export const productIndex = defineSearchIndex({2 // ...3 fields: search.define({4 id: search.keyword().filterable(),5 title: search.text().searchable(),6 category_ids: search.keyword().filterable(),7 }),8 seed: graphSeed({9 fields: ["id", "title", "categories.id"],10 transform: (products) => products.map((product) => ({11 id: product.id,12 title: product.title,13 category_ids: product.categories.map(14 (category) => category.id15 ),16 })),17 }),18})
Read More Data in transform
transform is asynchronous and receives a whole page of records at a time, so a document that needs data beyond what one query.graph call returns costs a fixed number of extra queries per page, rather than one per record.
For example, to index a French title alongside the English one, read the page again with the locale option:
1export const productIndex = defineSearchIndex({2 name: "product",3 entity: "product",4 fields: search.define({5 id: search.keyword().filterable(),6 title_en: search.text().searchable(),7 title_fr: search.text().searchable(),8 }),9 seed: graphSeed({10 fields: ["id", "title"],11 transform: async (products, { container }) => {12 const { query } = container13 const ids = products.map((product) => product.id)14 15 const { data: translated } = await query.graph({16 entity: "product",17 fields: ["id", "title"],18 filters: { id: ids },19 }, { locale: "fr-FR" })20 21 const frTitles = new Map(22 translated.map((product) => [23 product.id,24 product.title,25 ])26 )27 28 return products.map((product) => ({29 id: product.id,30 title_en: product.title,31 title_fr:32 frTitles.get(product.id) ?? product.title,33 }))34 },35 }),36})
Pass a Query Context
Some fields only resolve when query.graph receives a query context, such as variants.calculated_price, which needs a currency or a region to calculate a price for. Pass that context to graphSeed with the context option:
1import { QueryContext } from "@medusajs/framework/utils"2 3export const productIndex = defineSearchIndex({4 name: "product",5 entity: "product",6 fields: search.define({7 // ...8 }),9 seed: graphSeed({10 fields: [11 "id",12 "title",13 "variants.calculated_price.*",14 ],15 context: {16 variants: {17 calculated_price: QueryContext({18 currency_code: "usd",19 }),20 },21 },22 }),23})
graphSeed applies the context to the full run and to the catch-up pass alike. graphConsume accepts the same option, so pass it to both helpers to write the same document from an event.
You can also pass a function, which receives the same context seed receives. Use it when the query context depends on the index being filled:
graphSeed Options
Catch Up on Changes During a Run#
A full seed or rebuild reads your data while your application keeps writing to it, so a record that changes mid-run can land in the index stale, or not at all. To close that gap, the Search Module runs seed a second time as soon as the run finishes, passing catchup.since, which is the time the run started.
The pass must read the records updated at or after catchup.since, including the ones that were deleted, then write the changes to the index. graphSeed does all of that, so an index using it needs nothing else. Refer to Handle the Catch-Up Pass to handle it in a seed you write yourself.
The module skips the pass for a partial rebuild, since a filtered run is already scoped to what the caller asked for.
Keep an Index Up-to-Date#
If you omit events and consume, the Search Module only fills an index in the cases listed for seed. So the index holds whatever the last run produced until something triggers another one.
To ensure your index is always up-to-date, add the following to your index definition:
events: an array of workflow event names that change the data the index holds. Medusa subscribes to them and callsconsumefor each one.- See the Events Reference for the full list of events Medusa emits and their payloads. You can also use custom events that you emit.
consume: a function that turns an event into document writes.
Consume Events from Query with graphConsume#
Build consume with the graphConsume helper when the index holds an entity that Query exposes, the same way graphSeed builds seed.
For example:
1import {2 defineSearchIndex,3 graphConsume,4 graphSeed,5 search,6} from "@medusajs/framework/utils"7 8const fields = ["id", "name"]9 10export const brandIndex = defineSearchIndex({11 // ...12 events: [13 "brand.created",14 "brand.updated",15 "brand.deleted",16 ],17 consume: graphConsume({ fields }),18 seed: graphSeed({ fields }),19})
graphConsume reads the records the event affects through query.graph and writes them to the index.
It deletes a record's document instead when the event's name ends in .deleted, when the read doesn't return the record, or when a transform leaves it out of the documents it returns.
Share the Options with graphSeed
Pass the same options to both helpers so a document written by an event matches the one the seed writes. Declare them once and reuse them, rather than repeating the fields and transform in each call.
For example, this is the published-products index with its events applied to it:
1const source = {2 fields: ["id", "title", "status"],3 transform: (products) => {4 return products.filter(5 (product) => product.status === "published"6 )7 },8}9 10export const productIndex = defineSearchIndex({11 // ...12 events: [13 "product.created",14 "product.updated",15 "product.deleted",16 ],17 consume: graphConsume(source),18 seed: graphSeed(source),19})
graphConsume deletes the document of a product the transform leaves out, so unpublishing a product removes it from the index as soon as its product.updated event arrives, rather than waiting for the next seed.
The same holds for a transform that reshapes the record. For example, this is the category IDs index kept current by its events:
1const source = {2 fields: ["id", "title", "categories.id"],3 transform: (products) => products.map((product) => ({4 id: product.id,5 title: product.title,6 category_ids: product.categories.map(7 (category) => category.id8 ),9 })),10}11 12export const productIndex = defineSearchIndex({13 // ...14 events: [15 "product.created",16 "product.updated",17 "product.deleted",18 ],19 consume: graphConsume(source),20 seed: graphSeed(source),21})
graphConsume Options
graphSeed and graphConsume can also select fields from a linked module, such as a product's brand. Refer to the Index Data from a Linked Module guide to learn how.Custom seed and consume#
Write seed and consume yourself when the index doesn't map to a single Query read, such as when its documents come from an external service, or when building a document takes more than reading the entity's fields.
Both return the same writes, which are one of the following:
{ action: "upsert", documents }: writes the documents into the index, replacing any document already stored under the same ID.{ action: "delete", filters }: removes every document matching the filters.
The examples in this section index an article entity whose content lives in a headless CMS rather than in your Medusa application, so they call a cms client instead of Query.
container that seed and consume receive isn't the Medusa container. It only exposes query, to read data with Query, and logger, to report what an ingestion run is doing. You can't resolve a module or another registration from it, so import the client you need into the file instead, as the examples do.Custom seed Implementation#
seed is an async generator, so yield a batch of writes as you build it. The Search Module writes each batch to the engine as it arrives, rather than holding the entire index in memory.
For example:
1import { cms } from "../lib/cms"2 3export const articleIndex = defineSearchIndex({4 name: "article",5 entity: "article",6 fields: search.define({7 id: search.keyword().filterable(),8 title: search.text().searchable(),9 body: search.text().searchable(),10 }),11 async *seed() {12 let page = 113 14 while (true) {15 const articles = await cms.listArticles({16 page,17 limit: 200,18 })19 20 if (!articles.length) {21 break22 }23 24 yield [{25 action: "upsert",26 documents: articles,27 }]28 29 page++30 }31 },32})
Log an Ingestion Run
A full run can take a while, so use the logger of the container to report what it's doing. Its messages show in your application's logs alongside the messages that the Search Module logs itself.
seed receives an object parameter with the following properties:
Handle the Catch-Up Pass
A seed you write yourself also runs as the catch-up pass that follows a full run. Handle it by doing the following:
- Narrow your read to records changed at or after
catchup.since, so the pass stays small. - Include the records that were deleted, since a record removed mid-run is a document the index must lose.
- Yield an
upsertwrite for a record that still exists, and adeletewrite for one that's gone.
For example:
1export const articleIndex = defineSearchIndex({2 // ...3 async *seed({ catchup }) {4 const articles = await cms.listArticles({5 changed_since: catchup?.since,6 include_deleted: !!catchup,7 })8 9 const live = articles.filter((a) => !a.deleted)10 const gone = articles.filter((a) => a.deleted)11 12 yield [13 ...(live.length14 ? [{ action: "upsert", documents: live }]15 : []),16 ...gone.map((article) => ({17 action: "delete",18 filters: { id: [article.id] },19 })),20 ]21 },22})
Custom consume Implementation#
consume turns one event into the writes that apply it to the index. It returns them in an array rather than yielding them, since an event changes specific documents.
It returns the same writes seed yields:
{ action: "upsert", documents }: writes the documents into the index, replacing any document already stored under the same ID.{ action: "delete", filters }: removes every document matching the filters.
Return an empty array for an event that changes nothing the index holds.
For example:
1export const articleIndex = defineSearchIndex({2 // ...3 events: [4 "article.published",5 "article.unpublished",6 ],7 async consume(event) {8 if (event.name === "article.unpublished") {9 return [{10 action: "delete",11 filters: { id: [event.data.id] },12 }]13 }14 15 const article = await cms.retrieveArticle(16 event.data.id17 )18 19 return [{20 action: "upsert",21 documents: [article],22 }]23 },24})
consume receives two parameters:
context holds container and index only. Unlike seed, it carries no filters, last_key, or catchup, since an event changes specific documents rather than filling the whole index.Point an Index at a Specific Provider#
If you're using multiple Search Module Providers, set the provider property to the ID of a registered provider.
For example, you can keep a high-traffic index on a dedicated engine while quieter indexes stay on PostgreSQL.
When you omit it, the index uses the module's default_provider.