A few days ago, the bottom-right corner of this site got a chat button. It answers questions about my projects, articles, experience and how to reach me, always with links to the pages it used. I wanted a useful assistant with clear rules: talk only about what is published here, never make anything up about me, and never turn into a surprise bill at the end of the month.
The core idea: answer only from what is published
On its own, a language model knows nothing about this portfolio, and when it doesn't know, it tends to fill the gap with something plausible. To avoid that, the assistant uses RAG (retrieval-augmented generation): before answering, it looks up the passages of the site that are closest to the question and hands them to the model, with the instruction to answer only from them.
The knowledge base is the site itself: published articles, Labs projects, games, the résumé and the About page, in all three languages. Drafts never get in. If the answer isn't in that content, the assistant says so and points to the contact page.
The pieces

Everything runs in us-east-1 and is billed per use. Nothing here has a fixed monthly cost.
API Gateway and Lambda: the blog's HTTP API got a
POST /ai/chatroute, served by its own Lambda, the only one allowed to call the model.Amazon Bedrock Knowledge Bases: does RAG as a managed service. With the
RetrieveAndGenerateAPI, a single call retrieves the passages and generates the answer.Titan Text Embeddings V2: turns each passage and each question into a 1024-dimension vector. The model is multilingual, so a question in English finds a text in Portuguese.
Amazon S3 Vectors: stores the vectors and their metadata and runs the similarity search. I chose S3 Vectors over OpenSearch Serverless because, for a site this size, it costs cents, while OpenSearch would add a fixed cost.
Amazon Nova Lite: writes the answer. It is cheap, handles Portuguese, English and Spanish well, and runs in the same region, with no cross-region inference.
DynamoDB: the CMS's table keeps sessions, usage counters and the assistant's switches.
How a question becomes an answer
The browser sends the question and the page's language. On the first question of each conversation it also sends a Cloudflare Turnstile token, and the Lambda checks with Cloudflare that a person is on the other side.
The Lambda checks that the assistant is on and which documents were taken down.
In a single DynamoDB transaction, the question is counted against every limit: per conversation, per visitor and for the whole assistant. If any of them is exceeded, the request is refused right there, and the model is never called.
The Lambda calls Bedrock. It turns the question into a vector, retrieves the five closest passages in the page's language and hands everything to Nova Lite.
The answer comes back with its citations, the passages that support it. The links under the answer come from those passages' metadata, never from the model's text, and they can only point to pages of this site.
If no valid passage supports the answer, the visitor gets a fixed message saying the information isn't in the portfolio, with a link to the About page.
How the assistant learns
The assistant has no publishing process of its own. It rides on the site deploy I described in the first post.
Besides the pages, the site build generates the texts for projects, games, résumé and profile from the same content the pages use. That file stays out of the published package.
Before reporting the result, the workflow sends those texts to the API, proving who it is with the GitHub Actions OIDC token.
If the deploy succeeds, the API triggers an ingestion Lambda. It builds one Markdown file per page and language from the snapshot frozen by the deploy, compares them with what is already indexed and writes only what changed, with a metadata file next to each one.
A Bedrock ingestion job splits the new files into 300-token chunks, creates the vectors and updates the index. About a minute later, the assistant already knows the new article.
If the deploy fails, nothing is indexed. One privacy detail: the résumé PDF is not part of the knowledge base, because it has details the site doesn't show. The assistant reads the same version of the résumé the page displays.
Predictable cost
A public endpoint that calls a paid model needs limits that don't depend on goodwill. These are the assistant's:
Per conversation: up to 10 questions, and a conversation lasts one hour.
Per visitor: 5 questions per minute, 30 per day and 6 new conversations per day. A visitor is identified by a hash of their network address, which expires within two days.
For the whole assistant: 200 model calls per day, 3,000 per month and at most three at the same time.
The counters live in DynamoDB and are incremented in a single conditional transaction, so simultaneous requests can't get past a limit. If DynamoDB doesn't answer, the request is refused and the model is not called.
In numbers, each question costs at most about US$ 0.0005, and the monthly ceiling is around US$ 1.40 of model time. An AWS Budgets budget emails me when the account goes over US$ 5, 8 and 10 in a month, but it only warns: the quotas are what hold the spending.
Security
No tools: the model only writes text. It doesn't send emails, doesn't touch the CMS and can't reach anything private.
Content as data: if some text tries to give the model orders, the prompt makes it clear that passages are data, not instructions. And even if that worked, there would be nothing to execute.
Opaque sessions: the browser only gets a random token. The Bedrock session stays on the server, so nobody can continue someone else's conversation by guessing an id.
Privacy: questions and answers are not written to the logs, and the panel tells visitors that their question goes to Amazon Bedrock.
Switches: one command suspends the chat at once, and another takes a specific document down even before the index is updated.
Developing without AWS
As with the CMS, I can work on the assistant without an AWS account. The API's npm run dev simulates Bedrock with a keyword search over the same documents the ingestion generates, and Turnstile accepts Cloudflare's test token. The local answer says it is a simulation, but the rest of the path is the real one: limits, sessions, citations and links.
What I learned along the way
S3 got me again with a 403. The ingestion Lambda isn't allowed to list the CMS bucket, so a file that doesn't exist answers 403, not 404. Same lesson as the first post, now on the backend side.
RetrieveAndGeneratedoesn't report how many tokens it used. That's why the monthly quota counts calls, each with a known maximum cost, instead of tokens.The newest model isn't always the right one. Nova 2 Lite only runs with cross-region inference, and its model card doesn't list Knowledge Bases. It pays to read the model card before choosing.
Removing a document takes time. The vectors of an unpublished article only disappear when the ingestion job finishes. To close that window, the ingestion first puts the document on a revocation list that every search excludes, and only then deletes the files.
The assistant is in the corner of the screen. Ask it anything about the portfolio, and let me know if it gets something wrong. Thanks for reading this far.
