ai engineering · september 2026

Building a Grounded Chatbot
for My Portfolio

How "Ask Anupam's AI" answers questions from this site's own content instead of guessing: a build-time chunk index, dependency-free BM25 retrieval, page-aware boosting, a Groq-then-Gemini fallback over SSE - and three real incidents from the week it went live.

↓ scroll to read  ·  click a section on the left to jump

COLD OPEN · why bother

A portfolio chatbot that's allowed to say "I don't know"

Every page on this site has a floating "Ask Anupam's AI" button. Ask it about my experience, a project, a cheatsheet, or how to book a call, and it answers - citing the actual page it pulled the answer from. The rule that mattered most while building it: it is not allowed to make anything up about me. A general-purpose chatbot bolted onto a job-search portfolio is a liability, not a feature, the moment it invents a job title or a price I never listed.

This is the field-note version of how it actually works - the retrieval index, why BM25 instead of an embeddings API, page-aware boosting, the provider fallback, the guardrails, and three bugs that only showed up once real traffic hit it.

WHAT THIS ARTICLE IS ACTUALLY ABOUT

The concrete architecture behind a small, honest RAG chatbot on a static site - and the incidents I'd rather admit to than pretend never happened.

01 / ARCHITECTURE · a chunk index built once, at deploy time

The whole site, chunked at build time - not indexed at request time

scripts/build-chat-index.ts runs as this project's npm prebuild script, so it always runs before astro build, including in CI. It walks every source of truth the site has: the profile data, the résumé markdown, every standalone article and perspective HTML file, every content collection (cheatsheets, /learn lessons, TIL notes, prompts) generically over src/content/**/*.{md,mdx}, and half a dozen data files (calls, resources, tools, the cross-OS table, services, interview Q&A) serialized straight from their real TypeScript exports - never hand-copied prose. The output is gitignored and rebuilt on every deploy, so it can never quietly drift out of sync with the content it describes.

  • ~1,100-character chunks, 150-character overlap. Each source is split by chunkPlainText(), anchored to the nearest heading so a chunk always carries some context about what it's part of.
  • A few hundred chunks, as of writing. Small enough that the entire retrieval side of this feature is a few hundred kilobytes of JSON - no vector database, no hosted index, nothing to keep warm.
  • Rebuilt, never hand-edited. Add a cheatsheet or a data-file entry and the next build picks it up with zero code change - see this file's own header comment for the full source list.
// simplified - scripts/build-chat-index.ts
const SOURCES = [
  { name: 'profile', build: buildProfileChunks },
  { name: 'résumé', build: buildResumeChunks },
  { name: 'articles + perspectives', build: ... },
  { name: 'content collections', build: buildContentCollectionChunks },
  { name: 'booking, resources, tools, ...', build: ... },
];
// one line to add a new source - main() never changes

Retrieval itself is a small, dependency-free BM25 index (src/lib/chat/bm25.ts) - the classic term-frequency ranking algorithm, no embeddings API involved. That's a deliberate choice, not a shortcut: at a few hundred chunks, rebuilt fresh on every deploy anyway, an embeddings pipeline buys latency and a per-call cost for a corpus small enough that exact keyword matches (a command name, a tool, a project id) already do most of the work a semantic match would. If this were indexing tens of thousands of documents that changed between deploys, that trade flips - it doesn't here.

THE POINT TO SIT WITH

The right retrieval architecture is a function of corpus size and update frequency, not a default reach for "the most sophisticated option." BM25 over a few hundred chunks, rebuilt on every deploy, is the boring, sufficient answer here.

02 / PAGE-AWARE · retrieval that knows where you're standing

"What does this do?" only makes sense if the bot knows what page you're on

The widget sends the visitor's current page ({ path, title } from location.pathname and document.title) with every request. The API route does two things with it. First, it doubles the BM25 score of any candidate chunk whose own URL matches the current page - and gives a current-page chunk a score floor even when it shares zero terms with the question, so "what does this do" on /cheatsheets/git still surfaces that page's content, even though every word in the question is a BM25 stopword. Second, it tells the model directly: "The visitor is currently reading: <title> (<url>)."

// simplified - src/pages/api/chat.ts
const candidates = bm25.search(question, CANDIDATE_K); // wide pool, 24
if (page) {
  // guarantee this page's chunks are candidates at all
  for (id of chunkIdsByPathname.get(page.path) ?? []) candidates.set(id, 0);
}
const boosted = candidates.map(c => onCurrentPage(c)
  ? { ...c, score: Math.max(c.score, PAGE_BASELINE_SCORE) * PAGE_MATCH_BOOST }
  : c
).sort(byScoreDesc).slice(0, TOP_K); // top 6

The starter chips change per page too - a cheatsheet gets "Explain a command on this page," an article gets "Summarise this article," /book gets "Which call should I book?" - all from one starterChipsForPath() function in the widget. The honest limitation, stated plainly in the code comments: the boost strongly favors the current page for on-topic phrasing, but doesn't guarantee it wins every generic query when another page's content is a much stronger raw text match.

03 / STREAMING · two providers, one fallback, no waiting for the full answer

Groq first, Gemini if Groq won't answer

The API route tries Groq first (openai/gpt-oss-20b by default), and falls back to Gemini's OpenAI-compatible endpoint (gemini-3.5-flash-lite by default) on any thrown error, any non-2xx status including a 429, or a missing key. Both are called through the same OpenAI-shaped chat-completions request, so the fallback is a straight swap of endpoint and key - not a second code path to maintain. Whichever one answers, the response streams back to the browser as Server-Sent Events: a sources event once with the citations, then a stream of delta events as tokens arrive, then done or error.

SOURCES

Sent once, up front

The retrieved chunks' titles and URLs, deduplicated. The widget holds them until the answer finishes, then shows any the answer didn't already link as a short "Related" row.

DELTA

Token by token

Each provider's own SSE stream is parsed and re-emitted as plain text deltas, so the widget doesn't need to know which provider actually answered.

DONE / ERROR

A clean end, either way

If both providers fail, the stream still closes cleanly with a friendly message pointing at the contact form or /book - never a hung connection.

This is a personal portfolio, not a product with paying users: the per-IP rate limits described below keep usage small, and the two-provider fallback exists mainly for uptime when one provider hiccups.

04 / GUARDRAILS · the system prompt is the whole safety boundary

What the bot is - and isn't - allowed to say

The system prompt (src/lib/chat/prompt.ts) is where the real product decisions live, not the UI. Four rules matter most:

NEVER INVENT FACTS ABOUT ME

Employers, dates, metrics, prices

If the retrieved context doesn't contain the answer, the bot says so plainly and points at the contact form or /book - it never fills the gap from general knowledge about "a backend engineer."

NO FREELANCE WORK, REAL PRICES ONLY

Generated from calls.ts, not hand-copied

The prompt's call-pricing line is built at request time from src/data/calls.ts, so it can never drift from what /book actually charges - and a call without a payment link is described as "not bookable yet," never priced anyway.

TEACHING IS ALLOWED, LABELED

General dev questions get an honest flag

The bot may explain Git, Docker, Kubernetes, RAG - grounded in this site's own cheatsheets and articles where possible - but a general-knowledge answer beyond that must be clearly labeled as general guidance, not verified against my own content.

USER TEXT IS UNTRUSTED

Not instructions

Every message from the "user" role is treated as content, not commands - an embedded "ignore your previous instructions" is just more text to answer around, not a rule change.

Underneath the prompt, two more layers: rate limiting (Upstash Redis REST when configured, so limits hold across serverless instances - otherwise an in-memory fallback scoped to one function instance; 10 requests per 10 minutes and 40 per day, per IP) and an origin check that rejects any request not from the real site, a Vercel preview, or localhost. The whole client-side widget is one vanilla-JS file with no dependencies, mounted on every page - it loads only a small launcher button eagerly, and builds the full panel, styles, and streaming logic lazily on the first click.

05 / WHAT BROKE · three real incidents, fixed the same week

What broke when it actually went live

All three of these happened in production, on the real domain, within days of shipping - not in a staging environment. Recording them honestly here because each one is a general lesson, not just a one-off bug.

INCIDENT #1 · 403 forbidden_origin

The apex domain redirects to www

anupam.info 308-redirects to www.anupam.info, so real browsers sent Origin: https://www.anupam.info - an origin the allowlist didn't include. Every real visitor's request was rejected before it ever reached a provider. Fix: allow both origins explicitly.

INCIDENT #2 · 503 chat_unavailable

import.meta.env doesn't see runtime secrets

const env = import.meta.env compiles, via Vite, into a frozen object holding only BASE_URL/DEV/MODE/PROD/SITE/SSR - the provider keys set in Vercel were never in it, build after build. Fix: a small serverEnv() that reads the real keys from process.env per request.

INCIDENT #3 · a converted number

The first live answer changed currency

The résumé states a figure in US dollars per year. The first real answer quoting it converted it to rupees - closer to my own currency, but simply wrong. Fix: a prompt rule to copy every number about me exactly, currency and unit included, never re-expressed.

// the actual fix for incident #2 - src/lib/chat/env.ts
export function serverEnv(): ImportMetaEnv {
  const runtime: Record<string, string> = {};
  for (key of RUNTIME_KEYS) {// GROQ_API_KEY, GEMINI_API_KEY, ...
    const value = process.env[key];
    if (value) runtime[key] = value; // runtime wins over build-time
  }
  return { ...import.meta.env, ...runtime };
}

Every one of these is now fixed on main: both origins are allowed, serverEnv() is what both /api/chat and /api/mcp read from, and the prompt now says explicitly to keep numbers about me in their original currency and unit.

06 / CLOSING · three lessons, generalized

What I'd tell someone shipping the same thing

Test against the real production origin, not the one you assume. A redirect you set up for a different reason (apex-to-www, trailing slashes, an old domain) can silently change what your browser actually sends on every real request. If an allowlist exists, verify it against a request from the live domain, not just localhost.

Don't spread import.meta.env around for server secrets in an SSR route. Vite inlines it at build time into a small, fixed object - a key added to your host after the last build simply isn't there, no matter how many times you redeploy without a fresh build. Reading secrets from process.env at request time means a newly added key works on the very next request, not the next build.

LLMs will "helpfully" re-express your numbers. A model that's never been told otherwise treats a dollar figure the way it treats any other fact worth paraphrasing - and paraphrasing a number is the one kind of paraphrase that's silently wrong. If an exact figure matters, say "copy it verbatim" in the system prompt; don't assume that's the default behavior.

OPINION, NOT A SPEC

A chatbot on a job-search portfolio is a strange thing to build carefully - it would be easy to treat it as a toy and skip the guardrails. I don't think that's right: the entire value of putting an AI in front of a recruiter or a hiring manager is that it's more accurate than they'd get guessing from a stale search result, not less. If it can't clear that bar, it's actively worse than no chatbot at all.

FURTHER READING · if you want to go deeper

Where to go next

$ ask it something - it'll tell you when it doesn't know

Field notes: Anupam Kumar · Backend & Generative AI Engineer. v1.0