This is a step-by-step guide to building a production-ready Generative AI FAQ system using AWS Bedrock for LLM + embeddings, OpenSearch for semantic search (RAG), Lambda for processing, a C# .NET API, and a Blazor UI. Bedrock Guardrails enforce compliance throughout.
Architecture Overview
User → Blazor UI → C# .NET API → API Gateway → TypeScript Lambda
↓
AWS Bedrock (Claude)
↓
OpenSearch (k-NN semantic search)
↑
S3 → Python Lambda (clean) → Python Lambda (embed)
Components:
- S3 — stores raw and cleaned FAQ documents
- Python Lambda (CleanDocuments) — triggered on S3 upload, cleans text, publishes to SNS
- Python Lambda (GenerateEmbeddings) — reads cleaned docs, generates Titan embeddings, stores in OpenSearch
- OpenSearch — k-NN index for semantic document retrieval
- TypeScript Lambda (ProcessQuestion) — embeds user question, searches OpenSearch (RAG), calls Claude with Guardrails
- C# .NET API — RESTful layer between Blazor and API Gateway
- Blazor UI — web interface for asking questions
- Bedrock Guardrails — blocks off-topic or prohibited responses
Step 1: AWS Infrastructure Setup
S3 Bucket
Create bucket faq-documents-2025 (us-east-1), private, versioning on, SSE-S3 encryption. Create two folders: faqs/ (raw) and processed/ (cleaned).
Sample FAQ document for faqs/product_faq.txt:
Q: What is the product warranty?
A: The product comes with a one-year warranty covering manufacturing defects.
Q: How do I contact support?
A: Email support@company.com or call 1-800-555-1234.
OpenSearch Domain
Create domain faq-embeddings-2025, t3.small.search, fine-grained access control. Then create the k-NN index:
curl -X PUT "https://<domain>/faq_index" \
-H 'Content-Type: application/json' \
-u admin:password \
-d '{
"settings": { "index": { "knn": true, "knn.space_type": "cosinesimil" } },
"mappings": {
"properties": {
"text": { "type": "text" },
"embedding": { "type": "knn_vector", "dimension": 1536 },
"source_key": { "type": "keyword" }
}
}
}'
The dimension: 1536 matches Bedrock's Titan Embeddings model output.
Bedrock
In the Bedrock Console (us-east-1), request access to Anthropic Claude v2 and Amazon Titan Embeddings G1 — Text.
Create Guardrail FaqGuardrail:
- Content filters: hate speech, violence, profanity — threshold: Medium
- Topical filter: allow keywords
product, support, warranty, features, faq; block unrelated topics
IAM Role for Lambda
Create FaqLambdaRole with policies for S3 (s3:GetObject, s3:PutObject, s3:ListBucket), OpenSearch (es:ESHttpPut, es:ESHttpGet, es:ESHttpPost), Bedrock (bedrock:InvokeModel, bedrock:ApplyGuardrail), and AWSLambdaBasicExecutionRole.
API Gateway
Create REST API FaqApi, resource /faq, POST method with Lambda proxy integration pointing to ProcessQuestion. Deploy to stage prod.
Step 2: Data Cleaning Lambda (Python)
Triggered by S3 upload to faqs/. Cleans text, saves to processed/, notifies GenerateEmbeddings via SNS.
import boto3, re, json, logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
s3_client = boto3.client('s3')
def clean_text(text):
text = re.sub(r'\s+', ' ', text.strip())
text = re.sub(r'[^\w\s.,!?]', '', text)
return text.lower()
def lambda_handler(event, context):
bucket = event['Records'][0]['s3']['bucket']['name']
key = event['Records'][0]['s3']['object']['key']
logger.info(f"Processing: {key}")
raw_text = s3_client.get_object(Bucket=bucket, Key=key)['Body'].read().decode('utf-8')
cleaned_text = clean_text(raw_text)
cleaned_key = f"processed/{key.split('/')[-1]}"
s3_client.put_object(Bucket=bucket, Key=cleaned_key, Body=cleaned_text.encode('utf-8'))
boto3.client('sns').publish(
TopicArn='arn:aws:sns:us-east-1:YOUR_ACCOUNT:FaqProcessingTopic',
Message=json.dumps({'bucket': bucket, 'cleaned_key': cleaned_key})
)
return {'statusCode': 200, 'body': json.dumps({'cleaned_key': cleaned_key})}
Deploy: Runtime Python 3.9, role FaqLambdaRole, S3 trigger on faqs/ prefix, timeout 30s, memory 256MB.
Step 3: Embedding Generation Lambda (Python)
Triggered by SNS from the cleaning Lambda. Generates Titan embeddings and stores them in OpenSearch.
import boto3, json, logging
from opensearchpy import OpenSearch, RequestsHttpConnection
from aws_requests_auth.boto_utils import BotoAWSRequestsAuth
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
s3_client = boto3.client('s3')
bedrock_client = boto3.client('bedrock-runtime', region_name='us-east-1')
DOMAIN = 'search-faq-embeddings-2025-XXXXXXXXXX.us-east-1.es.amazonaws.com'
auth = BotoAWSRequestsAuth(aws_host=DOMAIN, aws_region='us-east-1', aws_service='es')
os_client = OpenSearch(
hosts=[{'host': DOMAIN, 'port': 443}],
http_auth=auth,
use_ssl=True,
verify_certs=True,
connection_class=RequestsHttpConnection
)
def generate_embedding(text):
response = bedrock_client.invoke_model(
modelId='amazon.titan-embed-text-v1',
body=json.dumps({'inputText': text})
)
return json.loads(response['body'].read())['embedding']
def lambda_handler(event, context):
msg = json.loads(event['Records'][0]['Sns']['Message'])
bucket = msg['bucket']
cleaned_key = msg['cleaned_key']
text = s3_client.get_object(Bucket=bucket, Key=cleaned_key)['Body'].read().decode('utf-8')
embedding = generate_embedding(text)
os_client.index(index='faq_index', body={
'text': text,
'embedding': embedding,
'source_key': cleaned_key
})
logger.info(f"Stored embedding for {cleaned_key}")
return {'statusCode': 200}
Deploy: Runtime Python 3.9, SNS trigger on FaqProcessingTopic, timeout 60s, memory 512MB. Package with pip install opensearch-py aws-requests-auth -t ..
Step 4: Question Processing Lambda (TypeScript)
Embeds the user question, runs k-NN search on OpenSearch, builds a RAG prompt, and calls Claude with Guardrails.
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { Client } from '@opensearch-project/opensearch';
import { defaultProvider } from '@aws-sdk/credential-provider-node';
import { BedrockRuntimeClient, InvokeModelCommand } from '@aws-sdk/client-bedrock-runtime';
const DOMAIN = 'https://search-faq-embeddings-2025-XXXXXXXX.us-east-1.es.amazonaws.com';
const osClient = new Client({
node: DOMAIN,
auth: { credentials: defaultProvider() },
});
const bedrockClient = new BedrockRuntimeClient({ region: 'us-east-1' });
export const handler = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
const { question } = JSON.parse(event.body || '{}');
if (!question) return { statusCode: 400, body: JSON.stringify({ error: 'Question required' }) };
try {
// Embed the question
const questionEmbedding = await generateEmbedding(question);
// Semantic search — retrieve top 3 relevant documents
const searchResult = await osClient.search({
index: 'faq_index',
body: {
query: {
knn: { embedding: { vector: questionEmbedding, k: 3 } }
}
}
});
const context = searchResult.body.hits.hits
.map((hit: any) => hit._source.text)
.join(' ');
// Call Claude with Guardrails applied
const response = await bedrockClient.send(new InvokeModelCommand({
modelId: 'anthropic.claude-v2',
body: JSON.stringify({
prompt: `Answer based on context. Be concise.\nQuestion: ${question}\nContext: ${context}`,
max_tokens_to_sample: 500,
guardrailIdentifier: 'guardrail-12345', // replace with your guardrail ID
guardrailVersion: '1',
}),
}));
const answer = JSON.parse(Buffer.from(response.body).toString()).completion;
return { statusCode: 200, body: JSON.stringify({ answer }) };
} catch (error) {
console.error(error);
return { statusCode: 500, body: JSON.stringify({ error: 'Internal server error' }) };
}
};
async function generateEmbedding(text: string): Promise<number[]> {
const response = await bedrockClient.send(new InvokeModelCommand({
modelId: 'amazon.titan-embed-text-v1',
body: JSON.stringify({ inputText: text }),
}));
return JSON.parse(Buffer.from(response.body).toString()).embedding;
}
Deploy: Runtime Node.js 18.x, compile with npx tsc, package with zip -r function.zip dist node_modules, timeout 60s, memory 512MB.
Step 5: C# .NET API
Sits between Blazor and API Gateway. Validates input, forwards to Lambda, returns the answer.
[ApiController]
[Route("api/faq")]
public class FaqController : ControllerBase
{
private readonly HttpClient _httpClient;
private readonly string _lambdaUrl = "https://YOUR_API_GW_URL/prod/faq";
public FaqController(IHttpClientFactory httpClientFactory)
=> _httpClient = httpClientFactory.CreateClient();
[HttpPost("ask")]
public async Task<IActionResult> AskQuestion([FromBody] QuestionRequest request)
{
if (string.IsNullOrEmpty(request.Question))
return BadRequest(new { Error = "Question is required" });
var content = new StringContent(
JsonSerializer.Serialize(new { question = request.Question }),
Encoding.UTF8, "application/json");
var response = await _httpClient.PostAsync(_lambdaUrl, content);
response.EnsureSuccessStatusCode();
var result = JsonSerializer.Deserialize<QuestionResponse>(
await response.Content.ReadAsStringAsync());
return Ok(result);
}
}
public record QuestionRequest(string Question);
public record QuestionResponse(string Answer);
Program.cs — CORS for Blazor:
builder.Services.AddHttpClient();
builder.Services.AddCors(o => o.AddPolicy("AllowBlazor", b =>
b.WithOrigins("https://localhost:5001").AllowAnyMethod().AllowAnyHeader()));
app.UseCors("AllowBlazor");
Deploy to Elastic Beanstalk: eb init -p dotnetcore FaqApi && eb create faq-api-env && eb deploy.
Step 6: Blazor UI
@page "/faq"
@using System.Net.Http.Json
@inject HttpClient Http
<h3>Frequently Asked Questions</h3>
<div class="form-group">
<label>Your Question:</label>
<input class="form-control" @bind="question" placeholder="Enter your question" />
</div>
<button class="btn btn-primary mt-2" @onclick="SubmitQuestion">Submit</button>
@if (!string.IsNullOrEmpty(answer))
{
<div class="mt-4"><h4>Answer:</h4><p>@answer</p></div>
}
@if (!string.IsNullOrEmpty(error))
{
<div class="mt-4 text-danger"><p>@error</p></div>
}
@code {
private string question = "", answer = "", error = "";
private async Task SubmitQuestion()
{
error = "";
try
{
var response = await Http.PostAsJsonAsync(
"http://YOUR_API_URL/api/faq/ask",
new { Question = question });
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<QuestionResponse>();
answer = result!.Answer;
}
catch (Exception ex) { error = $"Error: {ex.Message}"; }
}
record QuestionResponse(string Answer);
}
Step 7: Security & Production Checklist
- IAM least privilege on all roles — no wildcards in production
- SSE-S3 on the bucket, HTTPS everywhere
- Update
FaqGuardrailregularly with new prohibited terms - Enable CloudWatch logs for all Lambdas and API Gateway
- S3 lifecycle rule: archive
processed/to Glacier after 30 days - OpenSearch: VPC access in production, not public endpoint
- Scale OpenSearch index replicas:
"number_of_replicas": 2
Step 8: IaC with CloudFormation
Automate the entire setup:
Resources:
FaqBucket:
Type: AWS::S3::Bucket
Properties:
BucketName: faq-documents-2025
VersioningConfiguration:
Status: Enabled
FaqLambdaRole:
Type: AWS::IAM::Role
Properties:
RoleName: FaqLambdaRole
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal: { Service: lambda.amazonaws.com }
Action: sts:AssumeRole
Policies:
- PolicyName: S3Access
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Action: [s3:GetObject, s3:PutObject, s3:ListBucket]
Resource:
- arn:aws:s3:::faq-documents-2025
- arn:aws:s3:::faq-documents-2025/*
aws cloudformation deploy \
--template-file template.yaml \
--stack-name FaqStack \
--region us-east-1
Common Troubleshooting
| Symptom | Fix |
|---|---|
| No embeddings in OpenSearch | Check CloudWatch logs for GenerateEmbeddings
|
| Guardrail blocks valid questions | Widen topical filter keywords in Bedrock Console |
| API Gateway 502 | Verify proxy integration is enabled on the POST method |
| Blazor CORS error | Check WithOrigins in Program.cs
|
| Bedrock access denied | Confirm model access is approved in Bedrock Console |
| OpenSearch connection fails | Verify domain endpoint and IAM auth credentials |
Originally published on Medium.
Top comments (0)