Zstem

RAG Chatbot

by Zstem · published 2026-07-16 · 1 fork

APIDatabaseEventsArchitectureDocsSequenceragaiserverless

RAG Chatbot

Open & fork this on Zstem →
AI / RetrievalIngestion PipelineBedrock Knowledge BaseClaude (Bedrock)OpenSearch ServerlessIngestion WorkerTitan EmbeddingsChat ClientCognitoAPI GatewayChat OrchestratorConversation StoreSource Docs BucketRAG Chatbotzstem.design

Architecture

Event flow

Sequence

Participants: Chat Client, API Gateway, Chat Orchestrator (Lambda), DynamoDB, Bedrock Knowledge Base, OpenSearch Serverless, Claude (Bedrock)

API contract

Database

conversations
conversation_idUUID v4
user_idCognito sub of the owner — every access is scoped to this
titleAuto-generated from the first user message, editable
message_count
last_message_previewFirst 120 chars of the latest turn, for list rendering
created_at
updated_at
messages
conversation_id
message_idULID — lexicographic sort == chronological order
roleuser | assistant
content
citationsassistant turns only: [{documentId, excerpt, score}] used to ground the answer
model_ide.g. anthropic.claude-sonnet-4-5, recorded per assistant turn
input_tokens
output_tokens
latency_ms
created_at
documents
document_idUUID v4, also embedded in the S3 key
s3_keyuploads/{document_id}/{filename}
filename
content_type
size_bytes
statusuploaded | chunking | embedding | indexed | failed
chunk_countSet when indexing completes
embedding_modelamazon.titan-embed-text-v2 — pinned per document so reindexing is detectable
error_messagePopulated when status = failed (stage + cause)
uploaded_byCognito sub
created_at
indexed_at

About this design

About this design

What this is

A retrieval-augmented (RAG) chatbot on AWS serverless. Users chat with an assistant that answers grounded in your own documents — every answer carries citations back to the source chunks it used. The stack is: API Gateway + a single Lambda orchestrator for the chat turn, Bedrock Knowledge Base over an OpenSearch Serverless vector index for retrieval, Claude on Bedrock for generation, DynamoDB for conversation history, and an event-driven ingestion pipeline that turns S3 uploads into searchable vectors. There are no servers to run and everything scales to zero.

How it works

Query path (see the sequence diagram): the client POSTs to /conversations/{id}/messages. The orchestrator Lambda loads the last ~10 turns from DynamoDB, calls the Knowledge Base Retrieve API (which embeds the question and runs a k-NN search over OpenSearch), then invokes Claude with a prompt of system instructions + retrieved chunks + history + question. The prompt tells Claude to answer only from the provided context and to say so when nothing relevant was retrieved — that single instruction is most of your hallucination defence. Both turns are persisted with citations and token usage, and the answer returns as { message, citations, usage }.

Ingestion path (see the event flow): clients upload via presigned S3 PUT URLs issued by POST /documents. The ObjectCreated event lands on an SQS queue (bursty uploads, DLQ after 3 attempts); the ingestion worker extracts text, chunks at ~500 tokens with 50 overlap, embeds each chunk with Titan (titan-embed-text-v2 — pinned per document so a model change is detectable), and bulk-indexes into OpenSearch. Outcomes fan out on an SNS topic: a status updater writes progress to the documents table, and a filtered subscription alerts on document.failed. The /documents API is how the UI shows ingestion progress.

Data model: three DynamoDB tables. messages is keyed (conversationid, messageid) with ULIDs, so one Query returns the transcript in order. Everything is scoped to the caller's Cognito sub.

How to extend

Streaming: swap InvokeModel for InvokeModelWithResponseStream and stream tokens back via Lambda response streaming (or a WebSocket API for typing indicators).

Better retrieval: add hybrid search (BM25 + vector) in OpenSearch, or a reranking step over the top-20 before taking the top-5.

Safety: put Bedrock Guardrails in front of both the user input and the model output — it slots into the orchestrator with one API change.

Multi-tenant: add tenant_id to every table key and as a metadata filter on Retrieve.

Evals: log (question, chunks, answer) triples from the messages table — that is a ready-made dataset for a retrieval-quality harness before you tune chunk sizes.

Open & fork this on Zstem →