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
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.
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.
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.
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 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.
"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>)."
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.
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.
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.
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.
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.
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:
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."
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
Where to go next
- Groq - model documentationCurrent model ids and rate limits for the primary provider behind this widget.
- Gemini API - OpenAI compatibilityThe OpenAI-shaped endpoint this site's Gemini fallback calls, so both providers share one request shape.
- Your AI Assistant Can Query My PortfolioThe companion piece - the same site's content, exposed instead as a read-only MCP server for AI assistants that can call tools.
Field notes: Anupam Kumar · Backend & Generative AI Engineer. v1.0