DEV Community

Arash Zand
Arash Zand

Posted on • Originally published at Medium

Architecting a GenAI FAQ System with AWS Bedrock, Python, TypeScript, C# .NET and Blazor

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)
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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" }
      }
    }
  }'
Enter fullscreen mode Exit fullscreen mode

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})}
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

Step 7: Security & Production Checklist

  • IAM least privilege on all roles — no wildcards in production
  • SSE-S3 on the bucket, HTTPS everywhere
  • Update FaqGuardrail regularly 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/*
Enter fullscreen mode Exit fullscreen mode
aws cloudformation deploy \
  --template-file template.yaml \
  --stack-name FaqStack \
  --region us-east-1
Enter fullscreen mode Exit fullscreen mode

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)