Skip to content
Aditya Karnam
Building the infrastructure layer for world-model-driven AI.

How I Built a Persona-Adaptive RAG Chat Into My Portfolio's Hero Section

ai, rag, infrastructure, agents14 min read

This site used to have a standalone /ask page — a Q&A box bolted onto the nav, backed by OpenRouter and a hand-rolled retrieval layer. It worked, but it was a destination nobody visited on purpose. So I tore it out and put the same idea where people actually are: the homepage hero, as a multi-turn chat that adapts its answers to who's asking.

TL;DR: adityakarnam.com now embeds a persona-adaptive RAG chat directly in the hero. It's grounded on a corpus built from this site's own posts and project pages, served through Cloudflare AI Search (formerly AutoRAG), with rate limiting, caching, and a hard no-hallucination fallback. Two PRs shipped it end to end: #67 (content sync pipeline) and #71 (the chat UI and query path).

Why Replace /ask With Something in the Hero

A dedicated Q&A page has a discoverability problem: it's one more link in the nav that competes with "just read the posts." The fix wasn't a better /ask page, it was deleting the destination and moving the interaction to where attention already is. HeroChat now sits directly under the homepage headline, with five clickable persona chips — AI Researcher, Frontier Lab Recruiter, Founder, Engineer, Open Source Contributor — that change how the same underlying context gets framed in the answer.

The Architecture

Two lanes, decoupled from each other: a build-time pipeline that keeps a retrieval corpus in sync with this site's content, and a request-time path that answers a question using whatever that corpus currently holds.

Hero RAG chat architectureTwo-lane diagram. Build-time lane: blog posts and curated project pages flow through discover-content.mjs and render-corpus-doc.mjs into an R2 bucket, which Cloudflare AI Search indexes, triggered by a GitHub Actions workflow on push to main. Request-time lane: the HeroChat browser UI posts to a Cloudflare Pages Function that rate-limits and caches, injects a persona system prompt, queries the same AI Search instance for retrieval and generation, maps returned chunks to source links above a relevance threshold or falls back to static persona text on error, and returns the grounded answer with citations to the UI.BUILD-TIME · CONTENT SYNCREQUEST-TIME · RETRIEVAL + GENERATIONSOURCEcontent/posts/**SOURCErag-project-pages.jsonSCRIPTdiscover-content.mjsdedupe by slug,skip autoblog-taggedSCRIPTrender-corpus-doc.mjsone markdown docper sourceR2adityakarnam-rag-corpusTRIGGERGitHub Actions: push tomain, path content/posts/**CLOUDFLARE AI SEARCH · instance "hero-chat"reindexes R2 · embeds with qwen3-embedding-0.6bchunks, retrieves, and generates in one callshared indexBROWSERHeroChat.tsxhomepage hero,5 persona lensesCLOUDFLARE PAGES FUNCTIONPOST /api/hero-chatrate limit · 20 req / 10 min / IPresponse cache · 10 min TTLpersona system prompt injectedlast 10 turns, 2000 chars eachon error/no chunks -> fallbackAI SEARCH · hero-chatembed query -> retrievechunks -> generate answerscore threshold 0.4answer + source linksrendered as chat bubble

Build-Time: Turning Posts Into a Corpus

The corpus is built from two sources: every post under content/posts/** (skipping anything tagged autoblog — the n8n-generated posts shouldn't ground answers about my own work), plus a curated content/rag-project-pages.json fixture for project pages that don't live as blog posts. discover-content.mjs reads both, dedupes by slug, and hands the combined list to render-corpus-doc.mjs, which writes one markdown file per source.

sync-rag-corpus.mjs then uploads every generated file to an R2 bucket (adityakarnam-rag-corpus) via wrangler r2 object put, and explicitly triggers an AI Search sync job (wrangler ai-search jobs create hero-chat) instead of waiting on its default six-hour re-index schedule. A GitHub Actions workflow runs this on every push to main that touches content/posts/** — config changes, package.json, CI tooling, none of that fires a resync, only actual content edits do. It always rewrites the full corpus rather than diffing incrementally; at roughly 15-20 sources, simplicity won over incremental complexity, and AI Search's own indexing step only re-embeds files that actually changed anyway.

The decision to build on Cloudflare AI Search (formerly AutoRAG) instead of hand-rolling embeddings against Vectorize was a mid-project pivot, not the starting point — one hand-rolled retrieval layer already existed and got deleted for this. AI Search does chunking, embedding, indexing, retrieval, and generation behind a single managed instance, which removes an entire layer of custom code and a source of API-shape risk. The tradeoff is picking models from whatever AI Search actually supports: the original embedding model choice (bge-small-en-v1.5) isn't in AI Search's supported list, so it's @cf/qwen/qwen3-embedding-0.6b instead, and the generation model got swapped once mid-build after the original one was deprecated out from under it.

Request-Time: Retrieval, Persona, and Guardrails

HeroChat.tsx posts the full message list plus the selected persona to POST /api/hero-chat, a Cloudflare Pages Function. Before anything touches AI Search, the function:

  • Rate-limits by client IP — 20 requests per 10-minute window, in-memory.
  • Checks a response cache keyed on persona + message history, 10-minute TTL, so repeated questions don't re-hit AI Search.
  • Normalizes the conversation to the last 10 turns, each capped at 2,000 characters.
  • Builds a system message combining a shared instruction ("answer only from provided source context, never invent achievements or metrics, keep it under 150 words") with a persona-specific framing line — the same retrieved context reads differently depending on whether the system prompt says "speaking to a technical recruiter" versus "speaking to an AI researcher."

That message list goes to AI Search's chat/completions endpoint for the hero-chat instance in one call — it embeds the query, retrieves matching chunks, and generates the answer server-side. Back in the Pages Function, buildSourcesFromChunks maps each returned chunk's item.key to a canonical URL and filters out anything below a 0.4 relevance score, so citations reflect what's actually grounding the answer, not just whatever AI Search returned.

If the AI Search call fails, or nothing clears that relevance threshold, the response is a static per-persona fallback string plus a safe default source list (/systems/, /blog/) with fallback: true set on the payload — never a raw error, and never an invented answer dressed up as a real one.

Security Notes

Running this on Cloudflare's edge instead of a server I manage changes what the actual attack surface looks like, worth being explicit about:

  • Cloudflare credentials never reach the browser. CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID live only in the Pages Function's environment. The client talks to /api/hero-chat, never to AI Search's API directly, so there's no token to leak out of a bundled JS file.
  • No CORS headers are set on /api/hero-chat. Unlike the portfolio MCP endpoint, which needs permissive CORS because external MCP clients call it directly, hero-chat has no Access-Control-Allow-Origin header at all — the omission means browsers block cross-origin reads of the response by default, so only same-origin requests from this site can actually use it.
  • The rate limiter is honest about being best-effort, not a hard cap. It's an in-memory Map, scoped to whatever isolate handles a given request — not the KV-backed limiter the portfolio MCP endpoint uses. The design spec flagged this explicitly as an open question rather than a settled decision: an in-function counter versus Cloudflare's own rate-limiting rules. At current traffic it doesn't matter; it would need revisiting before this endpoint saw meaningful abuse.
  • Prompt injection is a live risk with a low ceiling. Nothing stops someone from pasting adversarial instructions into the chat input, and the system prompt is the only defense against a model that tries to comply with them. But the blast radius is capped by design: this path is read-only, makes no tool calls, and takes no actions — the entire retrieval corpus is already public content on this site. A successful injection gets you, at worst, a weird answer sourced from information anyone could already read on the page it cites.
  • AI-generated text can't XSS the page. Answers render through a plain React <Text> component, not dangerouslySetInnerHTML, so React's default escaping applies to model output the same as any other string — there's no path from "the model was tricked into emitting HTML" to a rendered script tag.
  • Failures never leak detail. Every error path — a failed AI Search call, a request below the relevance threshold, an unparseable body — collapses into the same static fallback response. Nothing about Cloudflare's internal error, timeout behavior, or account state is ever visible to a client.

What Broke Along the Way

The bug worth mentioning: this project had no Cloudflare adapter configured for Gatsby at all. Gatsby Functions under src/api/*.ts and createRedirect had never actually worked in production — confirmed by the fact that the pre-existing /api/ask-my-work endpoint was live-returning 405 the whole time. The fix was migrating hero-chat to Cloudflare Pages' native Functions format (functions/api/hero-chat.ts, Fetch API Request/Response, no Gatsby-specific wrapper) and adding gatsby-plugin-cloudflare-pages so redirects actually generate a real _redirects file — that's also what makes /ask// resolve as a genuine 301 now instead of a client-side no-op.

The rest was verification discipline: temporary debug logging added specifically to confirm the AI Search fallback path was triggering correctly on a preview deploy, then removed once confirmed. End-to-end checks ran against a real Cloudflare Pages preview, not just local dev — grounded answers with correct source links for in-corpus questions, multi-turn follow-ups correctly using prior context, persona switching producing genuinely different framing on the same retrieved content, and out-of-corpus questions falling back gracefully instead of hallucinating.

Highlights: How Little of This Was Hard

What stands out in hindsight is how small the actual surface area was. The whole feature — spec, plan, content pipeline, UI, query path, and the infra fix — went from first commit to fully verified live in about 14 hours in a single day: PR #67 (spec, plan, corpus pipeline) merged at 07:15 UTC on July 19, PR #71 (chat UI, query function, /ask removal) merged at 21:22 UTC the same day, with two small follow-up fixes (#68, #69) shortly after.

A few things made that possible:

  • No backend to provision. Every piece — Pages Function, R2 bucket, AI Search instance — is managed Cloudflare infrastructure. There's no server, no container, no queue to run; the entire runtime is functions/api/hero-chat.ts plus config.
  • AI Search collapsed the hardest part into one API call. Chunking, embedding, indexing, retrieval, and generation all happen behind a single chat/completions endpoint. The custom code is the parts that are actually specific to this site: which sources to include, how to frame answers per persona, and how to fail safely — not a hand-rolled retrieval pipeline.
  • The spec absorbed the pivot, not a rewrite. Switching from a hand-rolled Vectorize approach to managed AI Search happened at the design stage, before implementation, so it never became a mid-build detour.
  • Tests scaled with the feature, not after it. 9 tests covered the corpus pipeline in PR #67; by PR #71 the full feature — protocol wiring, rate limiter, source mapping, persona prompts — had 21/21 passing, and every claim in both PR descriptions was checked against a real Cloudflare Pages preview deploy, not just local dev.
  • The one real bug was infrastructural, not RAG-specific. The missing Cloudflare Pages adapter would have broken any Gatsby Function on this site, not just this one — finding it here just meant every future Pages Function on this site now actually works, for free.
  • It costs nothing at this traffic level. Pay-as-you-go Cloudflare, no plan fee floor — R2 storage, Pages Functions, and AI Search's embedding and generation calls all stay inside their free-tier allocations for a personal portfolio's actual traffic (see below).

Observability and Cost

Closing the loop: what does this actually look like from inside Cloudflare, and what does it cost?

The hero-chat instance overview shows exactly what the corpus sync pipeline produced — 17 items indexed, 0 errors, and the raw sync job log (Finished indexing data source, got 1 pages/batches, 17 files seen, Finished embedding files) from the last run triggered by the GitHub Actions workflow:

Cloudflare AI Search overview for the hero-chat instance showing 17 indexed items, 0 errors, and the raw sync job log

The Items tab lists every corpus file individually — subagent-fleet at 25 chunks, india-agent-infrastructure-layer at 25 chunks, embenx at 25 chunks, all sourced from R2 and fully indexed:

Cloudflare AI Search items table listing indexed corpus files with chunk counts, file sizes, and R2 as the source

And the Search playground shows retrieval working exactly as designed — a raw query for "what is subagent-fleet" pulls the actual chunk text back with a relevance score (0.624, 0.566), the same score threshold logic that buildSourcesFromChunks uses in production to decide what counts as a real citation versus what gets dropped:

Cloudflare AI Search playground showing retrieved chunks for a live query, each with a relevance score

The part that closes the loop cleanly: total cost for all of it — AI Search, R2 storage, embedding, generation, Pages Functions — is $0.00, comfortably inside Cloudflare's free tier at this traffic level:

Cloudflare billing showing $0.00 total cost and $0.00 projected cycle cost for the account running this RAG stack

Worth being precise about what that number means: this is a pay-as-you-go Cloudflare account, not a fixed subscription — the billing page's own budget-alert banner confirms that. There's no plan fee floor to clear. At the traffic a personal portfolio site actually gets, every piece of this stack — R2 storage, Pages Functions invocations, AI Search's embedding and generation calls — stays under its free-tier allocation, so the bill stays at zero. It doesn't scale to zero if this hero chat suddenly gets popular; it scales to whatever Cloudflare charges past those thresholds. But for the actual use case — a chat surface for people evaluating one person's work, not a public high-traffic product — it's a genuinely free stack, not a "free trial" that flips to a bill next month.

Try It

It's live in the hero at adityakarnam.com. Pick a lens, ask something about the work, and the answer — and its sources — should tell you whether the retrieval actually found something or is honestly telling you it didn't.

Here it is in action — switching personas mid-session and asking follow-up questions against the live corpus:

© 2026 Aditya Karnam. World Model Infrastructure Lab.
Field Notes · Current Systems · Status