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
- Semantic search:
- Query: "I need a black shirt with the ServerlessDay logo"
- Expected result: Black t-shirts with the ServerlessDay logo
- Search with size specification:
- Query: "Do you have any large t-shirts?"
- Expected result: T-shirts available in size L
- Search with color specification:
- Query: "I'm looking for blue merchandise"
- Expected result: Blue t-shirts, hats, or other items
- Product recommendations:
- Query: "What would go well with a ServerlessDay t-shirt?"
- Expected result: Recommendations for complementary items like hats or stickers
Frontend Integration
To integrate the semantic search in the frontend, we can create a search component:
// components/SemanticSearch.jsx
import React, { useState } from 'react';
import { useRouter } from 'next/router';
const SemanticSearch = () => {
const [query, setQuery] = useState('');
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const handleSearch = async (e) => {
e.preventDefault();
if (!query.trim()) return;
setIsLoading(true);
try {
// Redirect to search results page with the query
router.push(`/search?q=${encodeURIComponent(query)}`);
} catch (error) {
console.error('Error during search:', error);
} finally {
setIsLoading(false);
}
};
return (
<div className="semantic-search">
<form onSubmit={handleSearch}>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search for ServerlessDay merchandise..."
className="search-input"
/>
<button
type="submit"
className="search-button"
disabled={isLoading}
>
{isLoading ? 'Searching...' : 'Search'}
</button>
</form>
</div>
);
};
export default SemanticSearch;
And a search results page:
// pages/search.jsx
import React, { useEffect, useState } from 'react';
import { useRouter } from 'next/router';
import ProductList from '../components/ProductList';
const SearchResults = () => {
const router = useRouter();
const { q: query } = router.query;
const [results, setResults] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
if (!query) return;
const fetchResults = async () => {
setIsLoading(true);
setError(null);
try {
const response = await fetch(`/api/search?query=${encodeURIComponent(query)}`);
if (!response.ok) {
throw new Error('Search failed');
}
const data = await response.json();
setResults(data.results);
} catch (error) {
console.error('Error fetching search results:', error);
setError('Failed to fetch search results. Please try again.');
} finally {
setIsLoading(false);
}
};
fetchResults();
}, [query]);
if (isLoading) {
return <div className="loading">Searching for "{query}"...</div>;
}
if (error) {
return <div className="error">{error}</div>;
}
return (
<div className="search-results">
<h1>Search Results for "{query}"</h1>
{results.length === 0 ? (
<div className="no-results">
No products found for "{query}". Try a different search term.
</div>
) : (
<>
<p>{results.length} products found</p>
<ProductList products={results} />
</>
)}
</div>
);
};
export default SearchResults;
Enhancing the Search Experience
To enhance the search experience, we can implement additional features:
1. Search Suggestions
// components/SearchSuggestions.jsx
import React, { useEffect, useState } from 'react';
const SearchSuggestions = ({ query, onSelectSuggestion }) => {
const [suggestions, setSuggestions] = useState([]);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (!query || query.length < 2) {
setSuggestions([]);
return;
}
const fetchSuggestions = async () => {
setIsLoading(true);
try {
const response = await fetch(`/api/suggestions?query=${encodeURIComponent(query)}`);
if (!response.ok) {
throw new Error('Failed to fetch suggestions');
}
const data = await response.json();
setSuggestions(data.suggestions);
} catch (error) {
console.error('Error fetching suggestions:', error);
setSuggestions([]);
} finally {
setIsLoading(false);
}
};
const debounceTimer = setTimeout(fetchSuggestions, 300);
return () => clearTimeout(debounceTimer);
}, [query]);
if (isLoading) {
return <div className="suggestions-loading">Loading suggestions...</div>;
}
if (suggestions.length === 0) {
return null;
}
return (
<div className="search-suggestions">
<ul>
{suggestions.map((suggestion, index) => (
<li
key={index}
onClick={() => onSelectSuggestion(suggestion)}
className="suggestion-item"
>
{suggestion}
</li>
))}
</ul>
</div>
);
};
export default SearchSuggestions;
2. Filters and Facets
// components/SearchFilters.jsx
import React, { useState } from 'react';
const SearchFilters = ({ categories, colors, sizes, onFilterChange }) => {
const [selectedCategory, setSelectedCategory] = useState('');
const [selectedColor, setSelectedColor] = useState('');
const [selectedSize, setSelectedSize] = useState('');
const [priceRange, setPriceRange] = useState([0, 100]);
const handleCategoryChange = (e) => {
const category = e.target.value;
setSelectedCategory(category);
onFilterChange({ category, color: selectedColor, size: selectedSize, priceRange });
};
const handleColorChange = (e) => {
const color = e.target.value;
setSelectedColor(color);
onFilterChange({ category: selectedCategory, color, size: selectedSize, priceRange });
};
const handleSizeChange = (e) => {
const size = e.target.value;
setSelectedSize(size);
onFilterChange({ category: selectedCategory, color: selectedColor, size, priceRange });
};
const handlePriceChange = (e) => {
const value = e.target.value;
const [min, max] = value.split(',').map(Number);
setPriceRange([min, max]);
onFilterChange({ category: selectedCategory, color: selectedColor, size: selectedSize, priceRange: [min, max] });
};
return (
<div className="search-filters">
<h3>Filters</h3>
<div className="filter-group">
<label htmlFor="category-filter">Category</label>
<select
id="category-filter"
value={selectedCategory}
onChange={handleCategoryChange}
>
<option value="">All Categories</option>
{categories.map(category => (
<option key={category} value={category}>{category}</option>
))}
</select>
</div>
<div className="filter-group">
<label htmlFor="color-filter">Color</label>
<select
id="color-filter"
value={selectedColor}
onChange={handleColorChange}
>
<option value="">All Colors</option>
{colors.map(color => (
<option key={color} value={color}>{color}</option>
))}
</select>
</div>
<div className="filter-group">
<label htmlFor="size-filter">Size</label>
<select
id="size-filter"
value={selectedSize}
onChange={handleSizeChange}
>
<option value="">All Sizes</option>
{sizes.map(size => (
<option key={size} value={size}>{size}</option>
))}
</select>
</div>
<div className="filter-group">
<label htmlFor="price-filter">Price Range</label>
<select
id="price-filter"
value={`${priceRange[0]},${priceRange[1]}`}
onChange={handlePriceChange}
>
<option value="0,100">All Prices</option>
<option value="0,10">Under $10</option>
<option value="10,20">$10 - $20</option>
<option value="20,50">$20 - $50</option>
<option value="50,100">Over $50</option>
</select>
</div>
</div>
);
};
export default SearchFilters;
3. Search Analytics
To track and analyze search behavior, we can implement a search analytics system:
// lambda/search-analytics/index.js
const { DynamoDB } = require('@aws-sdk/client-dynamodb');
const { DynamoDBDocument } = require('@aws-sdk/lib-dynamodb');
// Initialize DynamoDB client
const dynamoDB = DynamoDBDocument.from(new DynamoDB());
// Main Lambda handler
exports.handler = async (event) => {
try {
console.log('Event received:', JSON.stringify(event));
// Extract search data from the event
const { query, filters, results, userId, sessionId, timestamp } = JSON.parse(event.body);
// Save search data to DynamoDB
const searchData = {
id: `search_${Date.now()}_${Math.random().toString(36).substring(2, 10)}`,
query,
filters: filters || {},
resultCount: results?.length || 0,
userId: userId || 'anonymous',
sessionId: sessionId || `session_${Date.now()}`,
timestamp: timestamp || new Date().toISOString(),
hasResults: (results?.length || 0) > 0
};
await dynamoDB.put({
TableName: process.env.SEARCH_ANALYTICS_TABLE,
Item: searchData
});
return {
statusCode: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
},
body: JSON.stringify({ message: 'Search analytics recorded successfully' })
};
} catch (error) {
console.error('Error recording search analytics:', error);
return {
statusCode: 500,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
},
body: JSON.stringify({ error: error.message })
};
}
};
Conclusion
In this section, we've implemented semantic product search using AWS Bedrock and the Model Context Protocol. We've created an MCP server that connects Bedrock Agents to our product data, and we've implemented a Lambda function that handles search requests.
We've also enhanced the search experience with features like search suggestions, filters, and analytics. This provides a powerful and intuitive way for users to find the ServerlessDay merchandise they're looking for.
In the final chapter, we'll explore how to test and deploy our complete serverless e-commerce solution.
Member discussion