AI · e-commerce · serverless
Semantic Search with AWS Bedrock

In the fifth part of this multi-part series, we present Radixia Maca, our open-source solution for AI-composable commerce.

In this episode, we'll implement semantic product search using AWS Bedrock and the Model Context Protocol (MCP). This will allow users to search for products using natural language queries, providing a more intuitive and powerful search experience.
Understanding AWS Bedrock and MCP
AWS Bedrock is a fully managed service that provides access to foundation models (FMs) from leading AI companies. It allows us to build generative AI applications without having to train and deploy our own models.
The Model Context Protocol (MCP) is a protocol that enables foundation models to access external data sources and tools. It allows the model to retrieve relevant information from our product catalog to provide accurate responses to user queries.
MCP Server Implementation
The MCP server acts as a connector between AWS Bedrock Agents and our data sources. Let's implement it as a Node.js application that will run in a Docker container on AWS Fargate:
// docker/mcp-server/server.js
const express = require('express');
const bodyParser = require('body-parser');
const { SecretsManager } = require('@aws-sdk/client-secrets-manager');
const { DynamoDB } = require('@aws-sdk/client-dynamodb');
const { DynamoDBDocument } = require('@aws-sdk/lib-dynamodb');
const fetch = require('node-fetch');
// Initialize Express app
const app = express();
app.use(bodyParser.json());
// Initialize AWS clients
const secretsManager = new SecretsManager();
const dynamoDB = DynamoDBDocument.from(new DynamoDB());
// Function to get secrets from AWS Secrets Manager
async function getSecret(secretArn) {
const response = await secretsManager.getSecretValue({ SecretId: secretArn });
return JSON.parse(response.SecretString);
}
// Function to get a Commerce Layer token
async function getCommerceLayerToken(credentials) {
const { clientId, clientSecret, endpoint } = credentials;
const response = await fetch(`${endpoint}/oauth/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({
grant_type: 'client_credentials',
client_id: clientId,
client_secret: clientSecret
})
});
const data = await response.json();
return data.access_token;
}
// Health check endpoint
app.get('/health', (req, res) => {
res.status(200).json({ status: 'ok' });
});
// MCP server endpoint
app.post('/mcp', async (req, res) => {
try {
console.log('MCP request received:', JSON.stringify(req.body));
const { action, parameters } = req.body;
if (!action) {
return res.status(400).json({ error: 'Missing action in request' });
}
let result;
switch (action) {
case 'search_products':
result = await searchProducts(parameters);
break;
case 'get_product_details':
result = await getProductDetails(parameters);
break;
case 'get_product_recommendations':
result = await getProductRecommendations(parameters);
break;
default:
return res.status(400).json({ error: `Unsupported action: ${action}` });
}
console.log('MCP response:', JSON.stringify(result));
res.status(200).json(result);
} catch (error) {
console.error('Error in MCP server:', error);
res.status(500).json({ error: error.message });
}
});
// Function to search products
async function searchProducts(parameters) {
const { query, category, maxResults = 10 } = parameters;
if (!query) {
return { error: 'Missing query parameter' };
}
try {
// Get products from DynamoDB
const productsTableName = process.env.PRODUCTS_TABLE;
let scanParams = {
TableName: productsTableName,
Limit: maxResults
};
// If category is specified, use the CategoryIndex
if (category) {
scanParams = {
TableName: productsTableName,
IndexName: 'CategoryIndex',
KeyConditionExpression: 'category = :category',
ExpressionAttributeValues: {
':category': category
},
Limit: maxResults
};
const result = await dynamoDB.query(scanParams);
return { products: result.Items };
}
// Otherwise, scan the table
const result = await dynamoDB.scan(scanParams);
// Filter products based on the query
// In a real implementation, we would use a vector database or a more sophisticated search algorithm
const filteredProducts = result.Items.filter(product => {
const searchText = `${product.name} ${product.description} ${product.category} ${product.colors || ''} ${product.sizes || ''}`.toLowerCase();
const queryTerms = query.toLowerCase().split(' ');
return queryTerms.every(term => searchText.includes(term));
});
return { products: filteredProducts.slice(0, maxResults) };
} catch (error) {
console.error('Error searching products:', error);
return { error: error.message };
}
}
// Function to get product details
async function getProductDetails(parameters) {
const { productId, sku } = parameters;
if (!productId && !sku) {
return { error: 'Missing productId or sku parameter' };
}
try {
// Get product from DynamoDB
const productsTableName = process.env.PRODUCTS_TABLE;
let getParams;
if (productId) {
getParams = {
TableName: productsTableName,
Key: { id: productId }
};
} else {
// If only SKU is provided, we need to scan the table
const scanParams = {
TableName: productsTableName,
FilterExpression: 'sku = :sku',
ExpressionAttributeValues: {
':sku': sku
}
};
const scanResult = await dynamoDB.scan(scanParams);
if (scanResult.Items.length === 0) {
return { error: `Product with SKU ${sku} not found` };
}
return { product: scanResult.Items[0] };
}
const result = await dynamoDB.get(getParams);
if (!result.Item) {
return { error: `Product with ID ${productId} not found` };
}
return { product: result.Item };
} catch (error) {
console.error('Error getting product details:', error);
return { error: error.message };
}
}
// Function to get product recommendations
async function getProductRecommendations(parameters) {
const { productId, category, maxResults = 5 } = parameters;
if (!productId && !category) {
return { error: 'Missing productId or category parameter' };
}
try {
// Get products from DynamoDB
const productsTableName = process.env.PRODUCTS_TABLE;
// If category is specified, use the CategoryIndex
if (category) {
const queryParams = {
TableName: productsTableName,
IndexName: 'CategoryIndex',
KeyConditionExpression: 'category = :category',
ExpressionAttributeValues: {
':category': category
},
Limit: maxResults
};
const result = await dynamoDB.query(queryParams);
return { recommendations: result.Items };
}
// If productId is specified, get the product first to find its category
if (productId) {
const getParams = {
TableName: productsTableName,
Key: { id: productId }
};
const productResult = await dynamoDB.get(getParams);
if (!productResult.Item) {
return { error: `Product with ID ${productId} not found` };
}
const product = productResult.Item;
// Get other products in the same category
const queryParams = {
TableName: productsTableName,
IndexName: 'CategoryIndex',
KeyConditionExpression: 'category = :category',
FilterExpression: 'id <> :productId',
ExpressionAttributeValues: {
':category': product.category,
':productId': productId
},
Limit: maxResults
};
const recommendationsResult = await dynamoDB.query(queryParams);
return { recommendations: recommendationsResult.Items };
}
return { error: 'Invalid parameters for recommendations' };
} catch (error) {
console.error('Error getting product recommendations:', error);
return { error: error.message };
}
}
// Start the server
const PORT = process.env.PORT || 3000;
app.listen(PORT, '0.0.0.0', () => {
console.log(`MCP server listening on port ${PORT}`);
});
Let's also create a Dockerfile for the MCP server:
# docker/mcp-server/Dockerfile
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]
And a package.json file:
{
"name": "mcp-server",
"version": "1.0.0",
"description": "Model Context Protocol server for AWS Bedrock",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"@aws-sdk/client-dynamodb": "^3.350.0",
"@aws-sdk/client-secrets-manager": "^3.350.0",
"@aws-sdk/lib-dynamodb": "^3.350.0",
"body-parser": "^1.20.2",
"express": "^4.18.2",
"node-fetch": "^3.3.1"
}
}
AWS Bedrock Agent Definition
Now let's define the AWS Bedrock Agent that will use our MCP server to search for products:
// lambda/semantic-search/agent-definition.json
{
"name": "ProductSearchAgent",
"description": "An agent that helps users find ServerlessDay merchandise products",
"instructions": "You are a helpful assistant that helps users find ServerlessDay merchandise products. You can search for products, get product details, and provide recommendations. Always be friendly and helpful. If a user asks about products, use the search_products action to find relevant products. If they want more details about a specific product, use the get_product_details action. If they want recommendations, use the get_product_recommendations action.",
"actionGroups": [
{
"name": "ProductActions",
"description": "Actions for searching and retrieving product information",
"actions": [
{
"name": "search_products",
"description": "Search for products based on user query",
"parameters": [
{
"name": "query",
"type": "string",
"description": "The search query",
"required": true
},
{
"name": "category",
"type": "string",
"description": "Optional category to filter by",
"required": false
},
{
"name": "maxResults",
"type": "integer",
"description": "Maximum number of results to return",
"required": false
}
]
},
{
"name": "get_product_details",
"description": "Get detailed information about a specific product",
"parameters": [
{
"name": "productId",
"type": "string",
"description": "The ID of the product",
"required": false
},
{
"name": "sku",
"type": "string",
"description": "The SKU of the product",
"required": false
}
]
},
{
"name": "get_product_recommendations",
"description": "Get product recommendations based on a product or category",
"parameters": [
{
"name": "productId",
"type": "string",
"description": "The ID of the product to get recommendations for",
"required": false
},
{
"name": "category",
"type": "string",
"description": "The category to get recommendations for",
"required": false
},
{
"name": "maxResults",
"type": "integer",
"description": "Maximum number of recommendations to return",
"required": false
}
]
}
]
}
],
"actionGroupExecutors": [
{
"actionGroupName": "ProductActions",
"executorType": "MCP",
"executorDetails": {
"endpoint": "${MCP_SERVER_URL}/mcp"
}
}
]
}
Lambda Function for Semantic Search
Now let's implement the Lambda function that will handle semantic search requests:
// lambda/semantic-search/index.js
const AWS = require('aws-sdk');
const { SecretsManager } = require('@aws-sdk/client-secrets-manager');
const { BedrockRuntime } = require('@aws-sdk/client-bedrock-runtime');
const { BedrockAgent } = require('@aws-sdk/client-bedrock-agent');
const { DynamoDB } = require('@aws-sdk/client-dynamodb');
const { DynamoDBDocument } = require('@aws-sdk/lib-dynamodb');
// Initialize AWS clients
const secretsManager = new SecretsManager();
const bedrockRuntime = new BedrockRuntime();
const bedrockAgent = new BedrockAgent();
const dynamoDB = DynamoDBDocument.from(new DynamoDB());
// Function to get secrets from AWS Secrets Manager
async function getSecret(secretArn) {
const response = await secretsManager.getSecretValue({ SecretId: secretArn });
return JSON.parse(response.SecretString);
}
// Function to invoke the Bedrock agent
async function invokeBedrockAgent(agentId, agentAliasId, input) {
const response = await bedrockAgent.invokeAgent({
agentId,
agentAliasId,
sessionId: `session-${Date.now()}`,
inputText: input
});
return response.completion;
}
// Function to search products using the Bedrock agent
async function searchProductsWithBedrock(query) {
try {
// Get the agent ID and alias ID from environment variables
const agentId = process.env.BEDROCK_AGENT_ID;
const agentAliasId = process.env.BEDROCK_AGENT_ALIAS_ID;
if (!agentId || !agentAliasId) {
throw new Error('Missing Bedrock agent configuration');
}
// Invoke the Bedrock agent
const result = await invokeBedrockAgent(agentId, agentAliasId, query);
return result;
} catch (error) {
console.error('Error searching products with Bedrock:', error);
throw error;
}
}
// Function to search products directly in DynamoDB
async function searchProductsInDynamoDB(query, category) {
try {
// Get products from DynamoDB
const productsTableName = process.env.PRODUCTS_TABLE;
let scanParams = {
TableName: productsTableName
};
// If category is specified, use the CategoryIndex
if (category) {
scanParams = {
TableName: productsTableName,
IndexName: 'CategoryIndex',
KeyConditionExpression: 'category = :category',
ExpressionAttributeValues: {
':category': category
}
};
const result = await dynamoDB.query(scanParams);
return result.Items;
}
// Otherwise, scan the table
const result = await dynamoDB.scan(scanParams);
// Filter products based on the query
const filteredProducts = result.Items.filter(product => {
const searchText = `${product.name} ${product.description} ${product.category} ${product.colors || ''} ${product.sizes || ''}`.toLowerCase();
const queryTerms = query.toLowerCase().split(' ');
return queryTerms.every(term => searchText.includes(term));
});
return filteredProducts;
} catch (error) {
console.error('Error searching products in DynamoDB:', error);
throw error;
}
}
// Main Lambda handler
exports.handler = async (event) => {
try {
console.log('Event received:', JSON.stringify(event));
// Extract query parameters
const query = event.queryStringParameters?.query || '';
const category = event.queryStringParameters?.category || '';
const useBedrock = event.queryStringParameters?.useBedrock !== 'false'; // Default to true
if (!query) {
return {
statusCode: 400,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
},
body: JSON.stringify({ error: 'Missing query parameter' })
};
}
let results;
if (useBedrock) {
// Use Bedrock for semantic search
results = await searchProductsWithBedrock(query);
} else {
// Use direct DynamoDB search as fallback
results = await searchProductsInDynamoDB(query, category);
}
return {
statusCode: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
},
body: JSON.stringify({
query,
category: category || null,
usedBedrock: useBedrock,
results
})
};
} catch (error) {
console.error('Error in semantic search:', error);
return {
statusCode: 500,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
},
body: JSON.stringify({ error: error.message })
};
}
};
Creating and Configuring the Bedrock Agent
To create and configure the Bedrock Agent, we can use the AWS Management Console or the AWS CLI:
# Create the Bedrock agent
aws bedrock-agent create-agent \
--agent-name "ProductSearchAgent" \
--agent-resource-role-arn "arn:aws:iam::123456789012:role/BedrockAgentRole" \
--instruction-configuration file://agent-definition.json \
--foundation-model "anthropic.claude-v2"
# Create an action group
aws bedrock-agent create-action-group \
--agent-id "your-agent-id" \
--action-group-name "ProductActions" \
--action-group-executor '{"type": "MCP", "endpoint": "https://your-mcp-server-url/mcp"}' \
--action-group-definition file://action-group-definition.json
# Create an agent alias
aws bedrock-agent create-agent-alias \
--agent-id "your-agent-id" \
--agent-alias-name "ProductSearchAgentAlias" \
--routing-configuration '[{"foundation-model": "anthropic.claude-v2"}]'
Testing the Semantic Search
Let's test our semantic search implementation with some example queries:
- Simple product search:
- Query: "Show me all t-shirts"
- Expected result: List of all t-shirts in the catalog
You're halfway in — the full article lives on our blog.
Continue reading on blog.radixia.ai →Free to read. Subscribe there to get new posts by email.
