Semantic Search with Medusa Search

In this guide, you'll learn how to use semantic search with Medusa Search, including how it creates the embeddings for you.

Semantic search ranks results by meaning rather than by matching terms, which needs an embedding of the text you search over. There are two ways to get those embeddings into your index:

  • You compute them with an embedding model of your choice, such as OpenAI's, and yield them on a vector field. A query then passes a pre-computed embedding as search_options.vector.value.
  • Medusa Search computes them for you from a text field, which the rest of this guide covers. A query then passes raw text as search_options.vector.query.
Note: Vector search is available on the Scale and Enterprise plans. On the Scale plan, you compute the embeddings yourself, and each embedding can have at most 1536 dimensions. Embeddings that Medusa Search computes for you, which the rest of this guide covers, are available on the Enterprise plan only.Refer to the Plans & Pricing guide for what your plan includes.

Add a Vector Field to an Index#

To use semantic search, add a vector field to your index definition and chain the embed() modifier on it:

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.
src/search/product.ts
1export const productIndex = defineSearchIndex({2  fields: search.define({3    title: search.text().searchable(),4    title_embedding: search.vector(1536).embed(),5    // ...6  }),7  // ...8})

In this example, title_embedding is a vector field that holds the embedding Medusa Search computes from the text you index on it, which is the product's title.

Index the Text to Embed#

Your documents pass the text to embed as a string on title_embedding, and Medusa Search replaces it with the embedding it computes.

The graphSeed and graphConsume helpers don't do that for you, since the vector field isn't a field of the entity they read. So, add a transform that copies the text onto the vector field, and share it between both helpers:

src/search/product.ts
1import {2  defineSearchIndex,3  graphConsume,4  graphSeed,5  search,6} from "@medusajs/framework/utils"7
8const source = {9  fields: ["id", "title"],10  transform: (products) => products.map((product) => ({11    id: product.id,12    title: product.title,13    title_embedding: product.title,14  })),15}16
17export const productIndex = defineSearchIndex({18  name: "product",19  entity: "product",20  fields: search.define({21    title: search.text().searchable(),22    title_embedding: search.vector(1536).embed(),23    // ...24  }),25  events: [26    "product.created",27    "product.updated",28    "product.deleted",29  ],30  consume: graphConsume(source),31  seed: graphSeed(source),32})

Every document the index writes now carries the product's title on title_embedding, so Medusa Search embeds it both on the first seed and as products change.

If you write a seed function yourself, set the same field on the documents you yield:

src/search/product.ts
1export const productIndex = defineSearchIndex({2  name: "product",3  entity: "product",4  fields: search.define({5    title: search.text().searchable(),6    title_embedding: search.vector(1536).embed(),7    // ...8  }),9  async *seed({ container }) {10    const { data: products } = await container.query.graph({11      entity: "product",12      fields: ["id", "title"],13      // ...14    })15
16    yield [{17      action: "upsert",18      documents: products.map((product) => ({19        id: product.id,20        title: product.title,21        title_embedding: product.title,22      })),23    }]24  },25  // ...26})

To run a semantic search, pass the text to search for as search_options.vector.query in query.search, and Medusa Search embeds it at query time:

Code
1const { data } = await query.search({2  entity: "product",3  fields: ["id", "title"],4  filters: { q: "comfortable running shoe" },5  search_options: {6    vector: {7      field: "title_embedding",8      query: "comfortable shoes for long runs",9      semantic_ratio: 0.7,10    },11  },12})

semantic_ratio balances the semantic score against the full-text score, where 0 is full-text only and 1 is semantic only. So this query blends both.

Without embed on the field, a query must pass a pre-computed embedding as search_options.vector.value instead, and Medusa Search rejects a query that passes query.

Note: Refer to Let Medusa Search Create the Embedding for the full details of the modifier, including how it changes the documents your index holds.

Best Practices for What to Embed#

An embedding only carries the meaning of the text you index on the vector field. So, the transform that builds that text decides how well semantic search ranks your results.

The examples in the previous sections embed the product's title alone, which is the smallest useful starting point. Use the following practices to build richer text to embed. Each example shows the index definition that builds the text, then the text a product ends up with.

Put the Most Defining Information First#

An embedding weighs the start of the text more than its tail, so open with what identifies the entity, such as its name, type, category, and primary description. Leave supporting details, such as tags or materials, for the end, and leave out boilerplate that every product repeats.

For example:

src/search/product.ts
1const source = {2  fields: ["id", "title", "type.value", "description"],3  transform: (products) => products.map((product) => ({4    id: product.id,5    content_embedding: [6      `Product: ${product.title}`,7      `Type: ${product.type?.value}`,8      `Description: ${product.description}`,9    ].join("\n"),10    // ...11  })),12}13
14export const productIndex = defineSearchIndex({15  name: "product",16  entity: "product",17  fields: search.define({18    id: search.keyword().filterable(),19    content_embedding: search.vector(1536).embed(),20    // ...21  }),22  consume: graphConsume(source),23  seed: graphSeed(source),24  // ...25})

In this example, the transform function constructs the content_embedding by concatenating the product's title, type, and description, ensuring that the most defining information appears first.

A product then carries the following text on content_embedding, which Medusa Search embeds:

Code
1Product: Aurora Lounge Chair2Type: Lounge Chair3Description: A low-slung lounge chair with a solid4oak frame and wool upholstery.

Had the transform opened with a shipping notice that every product repeats, the identity of the product would compete with text that carries no meaning of its own.

Flatten Relevant Relations Into the Document#

A shopper searches for a brand, a collection, or a material as readily as for a title, but those live on related entities. So, request the relations in the source's fields, then join their values into the text you embed.

src/search/product.ts
1const source = {2  fields: [3    "id",4    "title",5    "collection.title",6    "tags.value",7    "variants.material",8    // ...9  ],10  transform: (products) => products.map((product) => ({11    id: product.id,12    content_embedding: [13      `Product: ${product.title}`,14      `Collection: ${product.collection?.title}`,15      `Material: ${product.variants?.[0]?.material}`,16      `Tags: ${product.tags17        ?.map((tag) => tag.value)18        .join(", ")}`,19    ].join("\n"),20    // ...21  })),22}23
24export const productIndex = defineSearchIndex({25  name: "product",26  entity: "product",27  fields: search.define({28    id: search.keyword().filterable(),29    content_embedding: search.vector(1536).embed(),30    // ...31  }),32  consume: graphConsume(source),33  seed: graphSeed(source),34  // ...35})

A product then carries the following text on content_embedding:

Code
1Product: Aurora Lounge Chair2Collection: Nordic Winter3Material: solid oak4Tags: mid-century, handmade

Expand Terse Values Into Meaningful Context#

A bare value, such as oak, gives the model little to work with. Label it and keep the words around it that a shopper would use.

src/search/product.ts
1const source = {2  fields: ["id", "title", "variants.material", "length"],3  transform: (products) => products.map((product) => ({4    id: product.id,5    content_embedding: [6      `Product: ${product.title}`,7      `Material: solid ${8        product.variants?.[0]?.material9      } frame with wool upholstery`,10      `Dimensions: ${product.length} cm deep`,11    ].join("\n"),12    // ...13  })),14}15
16export const productIndex = defineSearchIndex({17  name: "product",18  entity: "product",19  fields: search.define({20    id: search.keyword().filterable(),21    content_embedding: search.vector(1536).embed(),22    // ...23  }),24  consume: graphConsume(source),25  seed: graphSeed(source),26  // ...27})

A product then carries the following text on content_embedding:

Code
1Product: Aurora Lounge Chair2Material: solid oak frame with wool upholstery3Dimensions: 82 cm deep

Had the transform joined the raw values instead, the text would read oak, 82, which matches far fewer of the phrasings a shopper types.

Include Hierarchy#

A category on its own drops the context that a shopper's phrasing carries. Walk the category's parent chain and join the full path, so the embedding holds the broader terms too.

src/search/product.ts
1const toCategoryPath = (category) => {2  const names = []3  let current = category4
5  while (current) {6    names.unshift(current.name)7    current = current.parent_category8  }9
10  return names.join(" > ")11}12
13const source = {14  fields: [15    "id",16    "title",17    "categories.name",18    "categories.parent_category.name",19    // ...20  ],21  transform: (products) => products.map((product) => ({22    id: product.id,23    content_embedding: [24      `Product: ${product.title}`,25      `Category: ${product.categories26        ?.map(toCategoryPath)27        .join(", ")}`,28    ].join("\n"),29    // ...30  })),31}32
33export const productIndex = defineSearchIndex({34  name: "product",35  entity: "product",36  fields: search.define({37    id: search.keyword().filterable(),38    content_embedding: search.vector(1536).embed(),39    // ...40  }),41  consume: graphConsume(source),42  seed: graphSeed(source),43  // ...44})

A product then carries the following text on content_embedding:

Code
1Product: Aurora Lounge Chair2Category: Furniture > Seating > Lounge Chairs

The path matches a shopper searching for seating, which the leaf category alone wouldn't.

Include User-Facing Attributes Only#

An ID, a timestamp, or an internal status carries no meaning that a shopper would ever search for, and it dilutes the rest of the text. So, keep those out of the transform's embedded text, and index them as their own fields when you filter on them.

src/search/product.ts
1const source = {2  fields: ["id", "title", "status", "collection.title"],3  transform: (products) => products.map((product) => ({4    id: product.id,5    status: product.status,6    content_embedding: [7      `Product: ${product.title}`,8      `Collection: ${product.collection?.title}`,9    ].join("\n"),10    // ...11  })),12}13
14export const productIndex = defineSearchIndex({15  name: "product",16  entity: "product",17  fields: search.define({18    id: search.keyword().filterable(),19    status: search.keyword().filterable(),20    content_embedding: search.vector(1536).embed(),21    // ...22  }),23  consume: graphConsume(source),24  seed: graphSeed(source),25  // ...26})

A product then carries the following text on content_embedding, while its ID and status stay on their own fields:

Code
1Product: Aurora Lounge Chair2Collection: Nordic Winter

Keep Filters Outside the Embedding#

A price range, an inventory count, a region, a permission, or a category ID is an exact constraint, and an embedding can't enforce it. So, keep those as structured fields with the filterable modifier, and leave them out of the embedded text.

src/search/product.ts
1const source = {2  fields: [3    "id",4    "title",5    "categories.id",6    "variants.calculated_price.calculated_amount",7    "variants.inventory_quantity",8    // ...9  ],10  transform: (products) => products.map((product) => ({11    id: product.id,12    price:13      product.variants?.[0]?.calculated_price14        ?.calculated_amount,15    inventory_quantity:16      product.variants?.[0]?.inventory_quantity,17    category_ids: product.categories?.map(18      (category) => category.id19    ),20    content_embedding: `Product: ${product.title}`,21    // ...22  })),23}24
25export const productIndex = defineSearchIndex({26  name: "product",27  entity: "product",28  fields: search.define({29    id: search.keyword().filterable(),30    price: search.integer().filterable().sortable(),31    inventory_quantity: search.integer().filterable(),32    category_ids: search.keyword().array().filterable(),33    content_embedding: search.vector(1536).embed(),34    // ...35  }),36  consume: graphConsume(source),37  seed: graphSeed(source),38  // ...39})

Your query then narrows the candidates with the filters and ranks what remains by meaning:

Code
1const { data } = await query.search({2  entity: "product",3  fields: ["id", "title"],4  filters: {5    category_ids: ["pcat_01J8Z"],6    price: { $lte: 50000 },7  },8  search_options: {9    vector: {10      field: "content_embedding",11      query: "cozy chair for a reading corner",12      semantic_ratio: 0.7,13    },14  },15})

Keep Exact-Search Fields Alongside the Embedding#

A shopper who types a SKU, a barcode, a model number, or an exact brand name expects that one record, and a semantic ranking dilutes it. So, keep those fields searchable next to the vector field, and keep semantic_ratio below 1 so the full-text score still counts.

src/search/product.ts
1const source = {2  fields: [3    "id",4    "title",5    "variants.sku",6    "variants.barcode",7    // ...8  ],9  transform: (products) => products.map((product) => ({10    id: product.id,11    title: product.title,12    sku: product.variants?.[0]?.sku,13    barcode: product.variants?.[0]?.barcode,14    content_embedding: `Product: ${product.title}`,15    // ...16  })),17}18
19export const productIndex = defineSearchIndex({20  name: "product",21  entity: "product",22  fields: search.define({23    id: search.keyword().filterable(),24    title: search.text().searchable({ weight: 3 }),25    sku: search.keyword().searchable(),26    barcode: search.keyword().searchable(),27    content_embedding: search.vector(1536).embed(),28    // ...29  }),30  consume: graphConsume(source),31  seed: graphSeed(source),32  // ...33})

A shopper who types AUR-LC-OAK-01 then matches that variant's SKU through the full-text score, while the same index still answers "cozy chair for a reading corner" through the embedding.

Full Example of Semantic Search Best Practices#

The following index definition builds the embedded text from the relations it resolves, keeps the filters structured, and keeps the exact-search fields full-text:

src/search/product.ts
1import {2  defineSearchIndex,3  graphConsume,4  graphSeed,5  search,6} from "@medusajs/framework/utils"7
8const toCategoryPath = (category) => {9  const names = []10  let current = category11
12  while (current) {13    names.unshift(current.name)14    current = current.parent_category15  }16
17  return names.join(" > ")18}19
20const buildEmbeddingText = (product) => {21  const categories = product.categories?.map(toCategoryPath)22  const materials = [23    ...new Set(24      product.variants25        ?.map((variant) => variant.material)26        .filter(Boolean)27    ),28  ]29  const tags = product.tags?.map((tag) => tag.value)30
31  return [32    `Product: ${product.title}`,33    product.type?.value && `Type: ${product.type.value}`,34    categories?.length &&35      `Category: ${categories.join(", ")}`,36    product.collection?.title &&37      `Collection: ${product.collection.title}`,38    product.description &&39      `Description: ${product.description}`,40    materials.length &&41      `Material: solid ${materials.join(", ")} frame`,42    tags?.length && `Tags: ${tags.join(", ")}`,43  ]44    .filter(Boolean)45    .join("\n")46}47
48const source = {49  fields: [50    "id",51    "title",52    "description",53    "status",54    "type.value",55    "categories.id",56    "categories.name",57    "categories.parent_category.name",58    "collection.title",59    "tags.value",60    "variants.sku",61    "variants.barcode",62    "variants.material",63  ],64  transform: (products) => {65    return products66      .filter((p) => p.status === "published")67      .map((product) => ({68        id: product.id,69        title: product.title,70        sku: product.variants?.[0]?.sku,71        barcode: product.variants?.[0]?.barcode,72        category_ids: product.categories?.map(73          (category) => category.id74        ),75        content_embedding:76          buildEmbeddingText(product),77      }))78  },79}80
81export const productIndex = defineSearchIndex({82  name: "product",83  entity: "product",84  fields: search.define({85    id: search.keyword().filterable(),86    title: search.text().searchable({ weight: 3 }),87    sku: search.keyword().searchable(),88    barcode: search.keyword().searchable(),89    category_ids: search.keyword().array().filterable(),90    content_embedding: search.vector(1536).embed(),91  }),92  events: [93    "product.created",94    "product.updated",95    "product.deleted",96  ],97  consume: graphConsume(source),98  seed: graphSeed(source),99})

A product then carries the following text on content_embedding:

Code
1Product: Aurora Lounge Chair2Type: Lounge Chair3Category: Furniture > Seating > Lounge Chairs4Collection: Nordic Winter5Description: A low-slung lounge chair with a solid6oak frame and wool upholstery.7Material: solid oak frame8Tags: mid-century, handmade

Example: Build an AI Search Assistant#

A chat-style assistant that answers a shopper in natural language and shows the products it found is two layers, and Medusa Search is the second one:

  1. The conversational layer. A large language model of your choice turns the shopper's message into a search string, and writes the answer around the results it gets back. Medusa Search doesn't run this layer, so you call the model from your own API route with the provider and prompt you want.
  2. The retrieval layer. Your API route passes the string the model produced to query.search as search_options.vector.query, and Medusa Search embeds it and ranks your products by meaning.

For example, create the API route src/api/store/assistant/route.ts with the following content:

src/api/store/assistant/route.ts
7} from "@medusajs/framework/utils"8import { toSearchQuery } from "../../../lib/assistant"9
10type AssistantRequest = {11  message: string12}13
14export const POST = async (15  req: MedusaRequest<AssistantRequest>,16  res: MedusaResponse17) => {18  const query = req.scope.resolve(19    ContainerRegistrationKeys.QUERY20  )21
22  const searchQuery = await toSearchQuery(req.body.message)23
24  const { data } = await query.search({25    entity: "product",26    fields: ["id", "title", "handle", "thumbnail"],27    filters: {28      q: searchQuery,29      status: "published",30    },31    search_options: {32      vector: {33        field: "title_embedding",34        query: searchQuery,35        semantic_ratio: 0.7,36      },37    },38  })39
40  res.json({41    query: searchQuery,42    products: data,43  })44}

toSearchQuery is the conversational layer, which you write yourself with your model provider's SDK. It receives the shopper's message, such as "something warm for a winter hike", and returns the string to search for, such as "insulated winter hiking jacket".

The route then returns the products Medusa Search ranked, so your storefront renders them next to the model's answer.

Keep the semantic_ratio below 1 so the search still respects the terms the shopper typed, such as a brand or a model number that a purely semantic ranking dilutes.

You can then call this API route from your storefront whenever a shopper sends a message to the assistant, ensuring that the conversational and retrieval layers work together seamlessly.

Was this guide 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