<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Ashel Vasquez Palomino</title>
    <description>The latest articles on DEV Community by Ashel Vasquez Palomino (@ashel_vasquez).</description>
    <link>https://dev.to/ashel_vasquez</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4125348%2F45157eba-67d1-4172-a491-82042dc9d7fa.jpeg</url>
      <title>DEV Community: Ashel Vasquez Palomino</title>
      <link>https://dev.to/ashel_vasquez</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ashel_vasquez"/>
    <language>en</language>
    <item>
      <title>Build a Serverless Search Engine with DynamoDB Vector Search and AWS CDK</title>
      <dc:creator>Ashel Vasquez Palomino</dc:creator>
      <pubDate>Wed, 16 Sep 2026 02:00:35 +0000</pubDate>
      <link>https://dev.to/aws-builders/build-a-serverless-search-engine-with-dynamodb-vector-search-and-aws-cdk-4j4p</link>
      <guid>https://dev.to/aws-builders/build-a-serverless-search-engine-with-dynamodb-vector-search-and-aws-cdk-4j4p</guid>
      <description>&lt;p&gt;Some time ago at my work, we had to implement a small search engine for help articles. The search was very simple: it used lexical, prefix-based matching. This was a problem because many words did not match.&lt;/p&gt;

&lt;p&gt;That is exactly the kind of problem I wanted to solve with this project. Instead of relying solely on the article name or prefix matches, I wanted the search engine to understand the intent behind the query without sacrificing the speed and predictability of traditional search.&lt;/p&gt;

&lt;p&gt;It was in this context that AWS announced support for vector indexes in DynamoDB, which was also the service where we were already storing the article data. The result is a small serverless search engine built with &lt;strong&gt;DynamoDB vector search, Amazon Bedrock, AWS Lambda&lt;/strong&gt;, and &lt;strong&gt;API Gateway&lt;/strong&gt;, with all the infrastructure provisioned using &lt;strong&gt;AWS CDK&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The main flow works as follows:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;DynamoDB stores the product catalog, embeddings, and popularity scores.&lt;/li&gt;
&lt;li&gt;A DynamoDB vector index performs semantic similarity searches.&lt;/li&gt;
&lt;li&gt;Amazon Titan Text Embeddings V2 generates 512-dimensional embeddings.&lt;/li&gt;
&lt;li&gt;Lambda runs lexical and semantic searches in parallel.&lt;/li&gt;
&lt;li&gt;API Gateway exposes the HTTP endpoints.&lt;/li&gt;
&lt;li&gt;AWS CDK defines and deploys all the infrastructure using TypeScript.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You do not need prior machine learning experience to follow this tutorial. Familiarity with TypeScript and basic AWS concepts should be enough.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This article reflects the project and AWS services available as of September 2026. DynamoDB vector search &lt;a href="https://aws.amazon.com/about-aws/whats-new/2026/08/amazon-dynamodb-vector-search/" rel="noopener noreferrer"&gt;became generally available&lt;/a&gt; on August 5, 2026. Before using this design in production, review the official AWS documentation.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What Are Embeddings and Vector Search?
&lt;/h2&gt;

&lt;p&gt;An embedding is essentially an array of numbers that represents the meaning of a piece of content.&lt;/p&gt;

&lt;p&gt;For example, an embedding model can transform:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;waterproof shoes for mountain paths
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;into something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[0.018, -0.041, 0.092, ... 509 more numbers]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important property is that texts with similar meanings tend to produce vectors that are close to one another.&lt;/p&gt;

&lt;p&gt;In a product search engine, this allows us to build a relatively simple workflow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Generate an embedding for each product.&lt;/li&gt;
&lt;li&gt;Store those embeddings alongside the product information.&lt;/li&gt;
&lt;li&gt;Generate another embedding when the user performs a search.&lt;/li&gt;
&lt;li&gt;Ask DynamoDB which stored vectors are closest to the query vector.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;DynamoDB performs this final step using an &lt;strong&gt;approximate nearest neighbor (ANN)&lt;/strong&gt; index. Its &lt;code&gt;SearchVectors&lt;/code&gt; API returns the closest items according to the distance function configured for the index.&lt;/p&gt;

&lt;p&gt;For this implementation, I chose cosine distance. With this metric, &lt;code&gt;0&lt;/code&gt; represents identical vectors, and lower values indicate greater similarity.&lt;/p&gt;

&lt;p&gt;One important detail is to maintain consistency across all embeddings. Both products and queries must use &lt;strong&gt;the same model, number of dimensions, and normalization settings&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;In this project, I use:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Model: &lt;strong&gt;amazon.titan-embed-text-v2:0&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Dimensions: &lt;strong&gt;512&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Normalization: &lt;strong&gt;enabled&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Titan Text Embeddings V2 supports 256, 512, and 1,024 dimensions, according to the &lt;a href="https://docs.aws.amazon.com/bedrock/latest/userguide/titan-embedding-models.html" rel="noopener noreferrer"&gt;Amazon Bedrock documentation&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;At my work, we use help article titles, but for this tutorial we will use products.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The application has two separate flows: indexing and querying.&lt;/p&gt;

&lt;p&gt;The indexing flow prepares products before they can be searched. The query flow processes searches coming from the frontend.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx1f0xlf8p44gip2fhu50.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx1f0xlf8p44gip2fhu50.png" alt="Architecture Flow" width="800" height="379"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;API Gateway receives the requests, a Lambda function runs the search logic, and DynamoDB uses on-demand capacity.&lt;/p&gt;

&lt;p&gt;The vector index projects the product attributes needed in the search response. This means a &lt;code&gt;SearchVectors&lt;/code&gt; result already contains enough information to respond without performing another read.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;p&gt;Clone or open the &lt;a href="https://github.com/ashelenlanube/serverless-search-engine-with-dynamo-vectors" rel="noopener noreferrer"&gt;repository&lt;/a&gt; and make sure you have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An AWS account with your credentials configured.&lt;/li&gt;
&lt;li&gt;Node.js 22 or later.&lt;/li&gt;
&lt;li&gt;npm 11 or later.&lt;/li&gt;
&lt;li&gt;An AWS Region where Amazon Titan Text Embeddings V2 is available.&lt;/li&gt;
&lt;li&gt;Permissions for DynamoDB, Lambda, API Gateway, CloudFormation, IAM, and &lt;code&gt;bedrock:InvokeModel&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;An AWS environment previously initialized with CDK bootstrap.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In new Amazon Bedrock accounts, model access is usually enabled by default, although availability may still vary by Region. You can confirm Titan V2 availability in the &lt;a href="https://docs.aws.amazon.com/bedrock/latest/userguide/models-region-compatibility.html" rel="noopener noreferrer"&gt;Bedrock model catalog and Region documentation&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Install the dependencies and run the local checks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install
&lt;/span&gt;npm run build
npm &lt;span class="nb"&gt;test
&lt;/span&gt;npm run lint
npm run format:check
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If this is the first CDK application you are deploying in that account and Region, initialize the environment once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm run cdk &lt;span class="nt"&gt;--&lt;/span&gt; bootstrap
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 1: Create the DynamoDB Table with AWS CDK
&lt;/h2&gt;

&lt;p&gt;The central resource is an on-demand DynamoDB table whose partition key is the product &lt;code&gt;id&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;table&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;dynamodb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ProductsTable&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;partitionKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;dynamodb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;AttributeType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRING&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;billingMode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;dynamodb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;BillingMode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PAY_PER_REQUEST&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;encryption&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;dynamodb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TableEncryption&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;AWS_MANAGED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;pointInTimeRecoverySpecification&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;pointInTimeRecoveryEnabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The complete definition is available in &lt;a href="https://github.com/ashelenlanube/serverless-search-engine-with-dynamo-vectors/blob/main/infra/lib/search-engine-stack.ts" rel="noopener noreferrer"&gt;&lt;code&gt;infra/lib/search-engine-stack.ts&lt;/code&gt;&lt;/a&gt;. Each item contains both the application’s regular data and its embedding:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"7381bede-ffcf-4efb-8d78-3db7168d2408"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Summit Trail Shoes"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"normalizedName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"summit trail shoes"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"nameInitial"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"s"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Grippy trail shoes with a rock plate for wet mountain paths."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"footwear"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tags"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"hiking"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"trail"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"waterproof"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"score"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"embeddingModel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"amazon.titan-embed-text-v2:0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"embeddingDimensions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;512&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"embedding"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;0.018&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;-0.041&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.092&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The actual &lt;code&gt;embedding&lt;/code&gt; field contains 512 numbers.&lt;/p&gt;

&lt;p&gt;I also store &lt;code&gt;embeddingModel&lt;/code&gt; and &lt;code&gt;embeddingDimensions&lt;/code&gt;. These fields are not required to run the search, but keeping that metadata alongside the vector simplifies future model or dimensionality migrations.&lt;/p&gt;

&lt;p&gt;The table also contains two additional GSIs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;AutocompleteIndex&lt;/code&gt; enables prefix searches using the product’s normalized name.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PopularityIndex&lt;/code&gt; sorts products by their number of clicks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Neither of these indexes is required for vector search. I added them because I did not want to rely solely on semantic similarity to rank the results.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Define a DynamoDB Vector Index from CDK
&lt;/h2&gt;

&lt;p&gt;I placed the vector index creation logic in a reusable construct called &lt;code&gt;DynamoDbVectorIndex&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;DynamoDbVectorIndex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ProductEmbeddingVectorIndex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;indexName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ProductEmbeddingIndex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;vectorAttribute&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;embedding&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;dimensions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;512&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;distanceFunction&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;COSINE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;projectedAttributes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;name&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;normalizedName&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;description&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;category&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tags&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;score&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The number of dimensions must match the vectors generated by Titan. I chose cosine distance because the embeddings are normalized and I am more interested in comparing the direction of the vectors than their magnitude.&lt;/p&gt;

&lt;p&gt;CDK does not yet natively support adding a vector index, so I used an asynchronous CloudFormation &lt;a href="https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/template-custom-resources.html" rel="noopener noreferrer"&gt;custom resource&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The provider Lambda uses DynamoDB’s &lt;code&gt;UpdateTable&lt;/code&gt; operation with &lt;code&gt;VectorIndexUpdates&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;UpdateTableCommand&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;TableName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TableName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;VectorIndexUpdates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;Create&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="na"&gt;IndexName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;IndexName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;VectorAttribute&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="na"&gt;AttributeName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;VectorAttribute&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="p"&gt;},&lt;/span&gt;
          &lt;span class="na"&gt;Dimensions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Dimensions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;DistanceFunction&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DistanceFunction&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;Projection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="na"&gt;ProjectionType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;INCLUDE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;NonKeyAttributes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ProjectedAttributes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This was one of the most interesting infrastructure details in the project.&lt;/p&gt;

&lt;p&gt;Vector index creation is asynchronous. A successful response from &lt;code&gt;UpdateTable&lt;/code&gt; &lt;strong&gt;does not mean that the index is ready to use&lt;/strong&gt;. If CloudFormation considered the resource complete at that point, deployment could continue while the index was still being created.&lt;/p&gt;

&lt;p&gt;To avoid a race condition, the custom resource provider calls &lt;code&gt;DescribeTable&lt;/code&gt; every ten seconds and reports success only when the index reaches the &lt;code&gt;ACTIVE&lt;/code&gt; state. It also handles deletion and replacement when immutable index properties change.&lt;/p&gt;

&lt;p&gt;The complete implementation is available in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/ashelenlanube/serverless-search-engine-with-dynamo-vectors/blob/main/infra/constructs/vector-index.ts" rel="noopener noreferrer"&gt;&lt;code&gt;infra/constructs/vector-index.ts&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/ashelenlanube/serverless-search-engine-with-dynamo-vectors/blob/main/infra/constructs/vector-index-provider.ts" rel="noopener noreferrer"&gt;&lt;code&gt;infra/constructs/vector-index-provider.ts&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I find this pattern useful beyond this specific case: when CDK does not yet expose a feature directly, you can encapsulate the low-level AWS API and its associated logic within a reusable construct.&lt;/p&gt;

&lt;p&gt;CDK’s custom resource framework is very helpful here because it lets you separate the handler that starts the operation from the handler that checks when it has finished.&lt;/p&gt;

&lt;p&gt;Attribute projection also deserves attention. Returning the product fields directly from &lt;code&gt;SearchVectors&lt;/code&gt; avoids an additional read, but projected attributes also consume vector index storage. That is why we project only the fields required by the response. AWS explains this trade-off in its &lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/VectorSearchStorage.html" rel="noopener noreferrer"&gt;vector index storage documentation&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Generate and Store the Product Embeddings
&lt;/h2&gt;

&lt;p&gt;After deployment, we have an empty table and vector index. Now we need to transform the product catalog into searchable data.&lt;/p&gt;

&lt;p&gt;The loading script is located at &lt;a href="https://github.com/ashelenlanube/serverless-search-engine-with-dynamo-vectors/blob/main/scripts/seed.ts" rel="noopener noreferrer"&gt;&lt;code&gt;scripts/seed.ts&lt;/code&gt;&lt;/a&gt;, while the sample catalog is in &lt;a href="https://github.com/ashelenlanube/serverless-search-engine-with-dynamo-vectors/blob/main/data/products.json" rel="noopener noreferrer"&gt;&lt;code&gt;data/products.json&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;For each product, I combine several fields into a single textual representation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="s2"&gt;`Name: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s2"&gt;`Category: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;category&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s2"&gt;`Description: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s2"&gt;`Tags: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;, &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I could have generated embeddings from the product name alone, but that would discard much of the available context.&lt;/p&gt;

&lt;p&gt;Including the category, description, and tags gives the model more information about what each product actually represents.&lt;/p&gt;

&lt;p&gt;The seed script then invokes Titan Embed V2:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;InvokeModelCommand&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;modelId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;amazon.titan-embed-text-v2:0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;contentType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;accept&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextEncoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="nx"&gt;inputText&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;dimensions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;512&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The script also:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;validates the catalog,&lt;/li&gt;
&lt;li&gt;waits until the vector index is active,&lt;/li&gt;
&lt;li&gt;generates four embeddings in parallel,&lt;/li&gt;
&lt;li&gt;retries transient Bedrock errors,&lt;/li&gt;
&lt;li&gt;and performs an idempotent DynamoDB update that preserves existing click scores.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Before making calls to AWS, you can validate the catalog locally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm run seed &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nt"&gt;--dry-run&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is usually the first command I run when I modify the sample data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Search Vectors from Lambda
&lt;/h2&gt;

&lt;p&gt;Once the catalog has been indexed, the browser can make requests such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /search?q=shoes%20for%20wet%20trails&amp;amp;limit=5
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The search Lambda first validates and normalizes the query.&lt;/p&gt;

&lt;p&gt;It then starts two retrieval paths in parallel.&lt;/p&gt;

&lt;p&gt;The first runs a DynamoDB &lt;code&gt;Query&lt;/code&gt; against &lt;code&gt;AutocompleteIndex&lt;/code&gt; to find products whose names begin with the text entered by the user.&lt;/p&gt;

&lt;p&gt;The second generates an embedding for the query and uses &lt;code&gt;SearchVectors&lt;/code&gt; to retrieve the 25 semantically closest candidates.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;dynamo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SearchVectorsCommand&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;TableName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;tableName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;IndexName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ProductEmbeddingIndex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;SearchVector&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;embedding&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;N&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;})),&lt;/span&gt;
    &lt;span class="na"&gt;TopK&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One detail that is easy to misinterpret is the &lt;code&gt;Score&lt;/code&gt; returned by the vector search.&lt;/p&gt;

&lt;p&gt;Because the index uses &lt;strong&gt;cosine distance&lt;/strong&gt;, lower values are better. This is not a traditional relevance score in which a higher number means a better result.&lt;/p&gt;

&lt;p&gt;For ranking, I convert that distance into a normalized similarity value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;semantic score = 1 - cosine distance / 2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result is clamped to the range between zero and one.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;distance = 0  -&amp;gt; semantic score = 1
distance = 2  -&amp;gt; semantic score = 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 5: Combine Semantic and Lexical Results
&lt;/h2&gt;

&lt;p&gt;I decided not to use vector similarity as the final ranking mechanism. Traditional lexical search is still very effective in certain cases.&lt;/p&gt;

&lt;p&gt;If a user types &lt;code&gt;cloud run&lt;/code&gt; and the catalog contains &lt;code&gt;Cloud Runner Shoes&lt;/code&gt;, I would expect that product to appear near the top, even if another product happens to be semantically similar.&lt;/p&gt;

&lt;p&gt;The Lambda therefore combines candidates by product ID and calculates the following weighted score:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ranking score =
    0.50 × lexical score
  + 0.45 × semantic score
  + 0.05 × popularity score
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The complete implementation is in &lt;a href="https://github.com/ashelenlanube/serverless-search-engine-with-dynamo-vectors/blob/main/packages/shared/src/ranking.ts" rel="noopener noreferrer"&gt;&lt;code&gt;packages/shared/src/ranking.ts&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The weights slightly favor lexical matches. Semantic similarity retrieves results that a text search would not find, while popularity accounts for only 5%, mainly as a tiebreaker between candidates with similar scores.&lt;/p&gt;

&lt;p&gt;These weights are not universal. They are simply a starting point for this example. In a real product search engine, I would tune them using real queries and expected results instead of defining them based solely on intuition.&lt;/p&gt;

&lt;p&gt;Another decision was to run both retrieval paths with &lt;code&gt;Promise.allSettled&lt;/code&gt;. This gives the endpoint useful fallback behavior. If Bedrock fails temporarily, the lexical search can still return results. The entire request fails only when both paths fail.&lt;/p&gt;

&lt;p&gt;For a search endpoint, returning a slightly worse result is better than returning no result at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: Give Each Lambda Only the Permissions It Needs
&lt;/h2&gt;

&lt;p&gt;The search Lambda mainly needs two groups of permissions.&lt;/p&gt;

&lt;p&gt;The first allows it to query DynamoDB:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;functions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;search&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addToRolePolicy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;iam&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;PolicyStatement&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;actions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dynamodb:Query&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dynamodb:SearchVectors&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tableArn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tableArn&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/index/AutocompleteIndex`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tableArn&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/index/ProductEmbeddingIndex`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second allows it to invoke only the embedding model used by the application:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;functions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;search&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addToRolePolicy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;iam&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;PolicyStatement&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;actions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bedrock:InvokeModel&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="s2"&gt;`arn:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;cdk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Aws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PARTITION&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:bedrock:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;cdk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Aws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;REGION&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;::foundation-model/amazon.titan-embed-text-v2:0`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The other functions receive more specific permissions.&lt;/p&gt;

&lt;p&gt;The popular-products Lambda needs only &lt;code&gt;dynamodb:Query&lt;/code&gt;, while the Lambda responsible for recording clicks needs only &lt;code&gt;dynamodb:UpdateItem&lt;/code&gt;. This keeps each execution role limited to the actions required by that function instead of granting broad access across the application.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: Deploy, Load Data, and Test the Search Engine
&lt;/h2&gt;

&lt;p&gt;First, generate the CloudFormation template locally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm run cdk:synth
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then deploy the development stack:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm run cdk:deploy &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nt"&gt;--context&lt;/span&gt; &lt;span class="nv"&gt;stage&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The deployment saves its outputs to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cdk-outputs.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The seed script uses that file to obtain the generated names of the DynamoDB table and vector index.&lt;/p&gt;

&lt;p&gt;Load the catalog by running:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm run seed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To start the React interface, copy the environment variables file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cp &lt;/span&gt;apps/web/.env.example apps/web/.env.local
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;VITE_API_BASE_URL
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;to the &lt;code&gt;ApiUrl&lt;/code&gt; value generated in &lt;code&gt;cdk-outputs.json&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Then start Vite:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm run dev:web
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open &lt;code&gt;http://localhost:5173&lt;/code&gt; in your browser, and you will see the application with the search interface.&lt;/p&gt;

&lt;p&gt;The most interesting searches are not necessarily product names. Try queries that express an intent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;shoes for wet mountain trails
quiet keyboard for programming
something that keeps drinks cold
gear for working out while traveling
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In those cases, it is much easier to see the difference between lexical and semantic retrieval.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problems I Encountered While Building It
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;AccessDeniedException&lt;/code&gt; from Amazon Bedrock
&lt;/h3&gt;

&lt;p&gt;Verify that Titan Text Embeddings V2 is available in the selected Region and that your user has permission to invoke it.&lt;/p&gt;

&lt;p&gt;The deployed Lambda receives the required permission through CDK, but the local credentials used by the seed script also need &lt;code&gt;bedrock:InvokeModel&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Seed Command Cannot Find the Stack Outputs
&lt;/h3&gt;

&lt;p&gt;The project’s deployment command generates &lt;code&gt;cdk-outputs.json&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm run cdk:deploy &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nt"&gt;--context&lt;/span&gt; &lt;span class="nv"&gt;stage&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;before running the seed.&lt;/p&gt;

&lt;p&gt;You can also provide both resources explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm run seed &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--table-name&lt;/span&gt; YOUR_TABLE &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--vector-index-name&lt;/span&gt; ProductEmbeddingIndex &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--region&lt;/span&gt; YOUR_REGION
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The Vector Has the Wrong Number of Dimensions
&lt;/h3&gt;

&lt;p&gt;Three values must always match:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The vector index &lt;code&gt;dimensions&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The dimensions requested from Titan during seeding.&lt;/li&gt;
&lt;li&gt;The dimensions requested from Titan for real-time queries.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The example uses &lt;code&gt;512&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One small piece of technical debt in the project is that this constant still appears separately in the infrastructure, seed, and runtime code.&lt;/p&gt;

&lt;p&gt;If you change the number of dimensions, you will need to update all three places and rebuild both the index and the stored embeddings.&lt;/p&gt;

&lt;h3&gt;
  
  
  The First Deployment Appears to Be Stuck
&lt;/h3&gt;

&lt;p&gt;This is &lt;strong&gt;normal&lt;/strong&gt;. Vector index creation is asynchronous, so the custom resource may spend several minutes waiting for DynamoDB to report that the index is &lt;code&gt;ACTIVE&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The provider allows up to 30 minutes for this process. As an additional safety measure, the seed script checks the index status again before inserting vectors.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Semantic Results Look Strange
&lt;/h3&gt;

&lt;p&gt;Embeddings understand semantic relationships in text, but they do not automatically know your application’s business rules.&lt;/p&gt;

&lt;p&gt;If the search quality is poor, the first thing I would review is the text used to generate each embedding.&lt;/p&gt;

&lt;p&gt;For example, using only the product name may not provide enough information. Adding descriptions, categories, and tags can make a significant difference.&lt;/p&gt;

&lt;p&gt;After that, I would test the system with real queries and tune the ranking weights using an evaluation set.&lt;/p&gt;

&lt;p&gt;For a larger catalog, other signals—such as category filters, inventory availability, or business-specific rules—will probably become just as important as vector similarity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before Going to Production
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Search Evaluation
&lt;/h3&gt;

&lt;p&gt;This is probably the most important point if the search engine starts serving real users.&lt;/p&gt;

&lt;p&gt;Create a fixed set of representative queries and define which products should appear in the top positions for each one.&lt;/p&gt;

&lt;p&gt;Then run the same evaluation every time you change:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the embedding model,&lt;/li&gt;
&lt;li&gt;the number of dimensions,&lt;/li&gt;
&lt;li&gt;the text used to generate embeddings,&lt;/li&gt;
&lt;li&gt;the lexical scoring,&lt;/li&gt;
&lt;li&gt;or the ranking weights.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Otherwise, it is very easy to make the search “feel better” for one query while making several others worse.&lt;/p&gt;

&lt;h3&gt;
  
  
  Embedding Lifecycle
&lt;/h3&gt;

&lt;p&gt;Whenever relevant product information changes, its embedding should be regenerated.&lt;/p&gt;

&lt;p&gt;Model migrations also require planning.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;embeddingModel&lt;/code&gt; and &lt;code&gt;embeddingDimensions&lt;/code&gt; fields stored alongside the product can help identify which vectors need to be rebuilt or which ones can temporarily coexist during a migration.&lt;/p&gt;

&lt;p&gt;AWS currently documents a maximum of 4,096 dimensions, up to five vector indexes per table by default, and &lt;code&gt;TopK&lt;/code&gt; values of up to 100.&lt;/p&gt;

&lt;p&gt;Because these limits may change, use the &lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/ServiceQuotas.html" rel="noopener noreferrer"&gt;DynamoDB quotas documentation&lt;/a&gt; as the source of truth.&lt;/p&gt;

&lt;h2&gt;
  
  
  Clean Up the Resources
&lt;/h2&gt;

&lt;p&gt;When you have finished experimenting, delete the development resources:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm run cdk:destroy &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nt"&gt;--context&lt;/span&gt; &lt;span class="nv"&gt;stage&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The stack uses a &lt;code&gt;destroy&lt;/code&gt; removal policy and disables deletion protection for &lt;code&gt;dev&lt;/code&gt; and &lt;code&gt;demo&lt;/code&gt; environments.&lt;/p&gt;

&lt;p&gt;In production, the default behavior retains the table and enables deletion protection.&lt;/p&gt;

&lt;h3&gt;
  
  
  Repository
&lt;/h3&gt;

&lt;p&gt;The code for this tutorial is available in this &lt;a href="https://github.com/ashelenlanube/serverless-search-engine-with-dynamo-vectors" rel="noopener noreferrer"&gt;repository&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>aws</category>
      <category>database</category>
      <category>serverless</category>
    </item>
  </channel>
</rss>
