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.
What is Semantic Search?#
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.
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:
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:
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:
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})
Run a Semantic Search#
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:
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.
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:
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:
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.
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:
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.
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:
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.
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:
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.
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:
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.
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:
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.
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:
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:
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:
- 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.
- The retrieval layer. Your API route passes the string the model produced to
query.searchassearch_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:
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.