Full text search has a blind spot. If a reader on your blog searches for “database performance issues” but your article talks about query optimization and index tuning, a keyword based search engine like Lunr.js will not connect the two. The words do not match, even though the intent does.
Milan Jovanovic ran into exactly this problem on his own static site. He already had full text search working well for exact phrase matches, but it could not handle searches based on meaning rather than wording. Amazon’s recent announcement of S3 Vectors, a vector storage option built directly into S3 and priced roughly 90 percent lower than dedicated vector database services, gave him a reason to add semantic search without standing up new infrastructure. He built the whole thing in an afternoon, and the walkthrough below follows his implementation using Semantic Kernel on the .NET side.
How semantic search actually works
Before touching any code, it helps to fix the mental model. Semantic search is not a different kind of search box, it is a different kind of index sitting behind the same box. The flow has five moving parts, and once you can name them, the AWS console screens and the SDK calls stop looking like magic.
- Start with your source data, in this case a set of blog articles.
- Run each article through an embedding model, which converts the text into a vector, a list of numbers that captures its meaning.
- Store that vector alongside the article in a vector index, here an S3 Vector Bucket.
- When a visitor searches, convert their query into a vector using the same embedding model.
- Compare the query vector against every stored vector and return the closest matches.

Notice the emphasis on “same embedding model” in step four. This is the single most common mistake people make when they add semantic search later on. If you generate your stored vectors with one model and your query vectors with another, or even a different version of the same model, the distances between vectors stop meaning anything and your search results turn into noise. Pick a model once and stick with it for the entire index.
Generating embeddings with Semantic Kernel
Microsoft’s Semantic Kernel has connectors for most of the major model providers, and the Bedrock connector in particular is straightforward to wire up. You need three NuGet packages before anything else.
# Semantic kernel packages
Install-Package Microsoft.SemanticKernel
Install-Package Microsoft.SemanticKernel.Connectors.Amazon
# AWS SDK for Bedrock
Install-Package AWSSDK.BedrockRuntime
With the packages in place, register the embedding generator against a specific Bedrock model. Milan picked amazon.titan-embed-text-v2:0, which produces 1024 dimensional vectors. This choice matters later because you will need to tell S3 Vectors the exact dimension count when you create an index, so note it down now.
builder.Services.AddBedrockEmbeddingGenerator("amazon.titan-embed-text-v2:0");
builder.Services.AddTransient(sp =>
{
return new Kernel(sp);
});
This registers the Titan embedding model with the dependency injection container and adds a Kernel instance you can resolve wherever you need it. Once registered, generating an embedding for a piece of text is a couple of lines using the IEmbeddingGenerator abstraction.
var kernel = app.Services.GetRequiredService<Kernel>();
var embeddingGenerator = kernel.Services
.GetRequiredService<IEmbeddingGenerator<string, Embedding<float>>>();
var articleContent = await blogService.GetBlogContentAsync(articleUrl);
Embedding<float> embedding = await embeddingGenerator.GenerateAsync(articleContent);
embeddings.Add((articleUrl, embedding.Vector.ToArray()));
The call to GenerateAsync sends the article text to Bedrock and gets back a 1024 element float array, which is what gets stored later. Watch out for one detail: the dimension count is not fixed across models. If you swap Titan for a different embedding model, check its documentation for the output size before you create your S3 index, because that number cannot be changed afterward without recreating the index.
Creating an S3 Vector Bucket
S3 Vectors is not a setting you flip on an existing bucket. It is a new bucket type entirely, built and billed separately, and optimized specifically for storing and querying vector data rather than arbitrary objects. The console experience feels familiar if you have created a regular S3 bucket before, just with a narrower set of options.

Creating one starts with picking a unique name, same as any S3 bucket.

One thing worth flagging here: the encryption setting is permanent once the bucket is created. If your organization has specific key management requirements, decide on that before clicking create rather than after. Once the bucket exists, the next step is creating a vector index inside it.

Two fields here deserve attention. The dimension field has to match your embedding model output exactly, 1024 for Titan v2 in this example, and every vector you store in this index must share that same dimension. The distance metric, cosine or Euclidean, decides how similarity gets calculated, and cosine similarity is the standard choice for text embeddings since it measures direction rather than magnitude. Milan also notes that the console itself is fairly bare bones right now, most of the real configuration work happens through the SDK or CLI rather than the UI, which is typical for a service still in preview.
Storing vectors with metadata
A vector on its own is just a list of numbers, it needs metadata attached to be useful for anything beyond a raw similarity score. Metadata is what lets you filter results by category, date range, or any other attribute alongside the semantic match. Here is a method that indexes a single blog post.
public async Task IndexBlogPost(BlogPost post)
{
List<float> embedding = await GenerateEmbedding(post.Content);
await s3VectorsClient.PutVectorsAsync(new PutVectorsRequest
{
VectorBucketName = "mjtech-articles-semantic-search",
IndexName = "mjtech-article-content",
Vectors = new List<Vector>
{
new PutInputVector
{
Key = post.Slug,
Data = new VectorData
{
Float32 = embedding
},
Metadata = new Document(new Dictionary<string, Document>
{
["title"] = post.Title,
["date"] = post.PublishedDate.ToString("yyyy-MM-dd"),
["category"] = post.Category,
["url"] = $"/posts/{post.Slug}"
})
}
}
});
}
PutVectorsAsync takes the bucket name, the index name, the vector itself, and a metadata document you define however your application needs. By default every metadata field is filterable, which is convenient during development but worth revisiting for production since filterable metadata carries additional storage and query cost. If you are indexing an entire archive rather than a single post, call this in a loop is wasteful and slow. The SDK accepts a list of vectors in one PutVectorsRequest, so batch your inserts instead of writing one article at a time.
Querying the vector index
Search follows the same pattern in reverse: convert the incoming query text into a vector using the identical embedding model, then ask S3 Vectors for the closest matches.
public async Task<List<SearchResult>> SemanticSearch(
string query,
int topK = 10)
{
// Convert search query to vector (use same model as vectors!)
List<float> queryEmbedding = await GenerateEmbedding(query);
var request = new QueryVectorsRequest
{
VectorBucketName = "mjtech-articles-semantic-search",
IndexName = "mjtech-article-content",
QueryVector = new VectorData
{
Float32 = queryEmbedding
},
TopK = topK,
ReturnMetadata = true,
ReturnDistance = true
};
QueryVectorsResponse response = await s3VectorsClient.QueryVectorsAsync(request);
return response.Vectors.Select(v => new SearchResult
{
Distance = v.Distance,
Title = v.Metadata.AsDictionary()["title"].ToString(),
Url = v.Metadata.AsDictionary()["url"].ToString(),
Category = v.Metadata.AsDictionary()["category"].ToString()
}).ToList();
}
TopK controls how many results come back, ReturnDistance gives you the similarity score for each match so you can filter out weak results, and ReturnMetadata brings back the fields you stored earlier without a second lookup. The output is a list of SearchResult objects ordered by closeness to the query, lower distance meaning a better match when using cosine similarity. This example skips metadata filtering for clarity, but combining a category or date filter with the vector search is where S3 Vectors becomes genuinely more useful than plain full text search.
Should you actually switch to S3 Vectors
If you already run a vector database like Pinecone, Weaviate, or Qdrant and it works fine, there is no strong case for migrating. The operational savings would need to outweigh the migration effort and the risk of moving a working system, and for most established setups that math does not favor a switch yet. S3 Vectors makes the most sense for people adding semantic search for the first time, or for teams whose vector database bill has become uncomfortable to justify for what is often a secondary feature.
Keep the preview status in mind before committing to it for anything critical. Amazon can and likely will change pricing, limits, and API details before general availability, so treat this as a good fit for blogs, internal tools, and side projects rather than a production system with strict uptime requirements today. Teams on Azure looking for an equivalent pattern would look at Azure AI Search’s vector indexes instead, which are further along in maturity and integrate directly with Azure OpenAI embeddings, though at a different price point than S3 Vectors claims.
Milan’s own next steps are a reasonable checklist for anyone following this pattern: wire up automatic re-indexing in your CI/CD pipeline whenever a new post publishes, expose a search endpoint that blends semantic results with existing full text search rather than replacing it outright, and set a concrete latency budget, he targets under 500 milliseconds, before calling the feature done. Cost tracking deserves the same attention, since both the embedding model calls and the vector storage carry ongoing charges that are easy to lose track of once the feature is live and forgotten.
Leave a Reply