Ask, don’t search
Visitors can ask a focused question instead of scanning the entire site for one fact.
A secure portfolio chatbot that searches verified content about my background before answering, with hybrid retrieval, citation checks, and a feature-flagged rollout.
Mihir AI is not a generic chatbot pasted onto a résumé. It retrieves relevant passages from a controlled portfolio corpus, fuses semantic and keyword search, optionally reranks candidates, generates an answer from that evidence, and validates citation metadata server-side before the response returns to the widget.
A portfolio is usually a static brochure. Recruiters and hiring managers still have to hunt for answers: which project matches this role, which certification is current, is he available for contract work. I wanted a product that does that work for them.
Visitors can ask a focused question instead of scanning the entire site for one fact.
When hiring or collaboration intent shows up, the chatbot can offer a lightweight contact form in context.
Free-tier cost control, abuse protection, and answers grounded in a controlled corpus, not the open web.
Two constraints shaped every decision: run at effectively $0/month on free tiers, and treat a public AI endpoint with storage and email as a security surface from day one.
Retrieval-Augmented Generation is an open-book exam for the model. Instead of answering only from training memory or from one giant profile stuffed into every prompt, Mihir AI first searches a small library of verified portfolio passages, selects the most relevant ones, then writes an answer using that evidence.
Important: the system searches Mihir’s controlled portfolio corpus (Markdown source files I maintain), not the public internet.
Each passage is a chunk with a stable ID such as experience#0. The generation prompt includes the retrieved chunk text framed as reference material. The API returns structured citations and retrieved metadata alongside the reply text.
The first production chatbot put a structured professional profile directly into the system prompt. That was a deliberate stage-one design: small profile, fast to ship, easy to audit, and enough to build sessions, CSRF, rate limits, Turnstile, and lead capture around a real product.
As the system matured, selective context, source IDs, and measurable retrieval quality became more valuable than sending the full profile on every turn. Hybrid RAG was introduced behind a RAG_ENABLED feature flag so rollout and rollback stayed safe. When the flag is off, the legacy profile-in-prompt path remains available.
This is architectural evolution, not a rewrite of the product. The original security, session, rate-limit, Turnstile, lead-capture, and cost-conscious design still surrounds the retrieval pipeline.
Mihir’s experience, skills, projects, education, and certifications live as readable Markdown files. An offline script turns their sections into searchable passages, embeds them, and stores them in D1.
doc_id, source, title): experience, skills, projects, education, certifications.## heading boundaries. Oversized sections can split at paragraph boundaries with roughly 15% trailing overlap.[Title > Heading] so title and section context influence the vector.@cf/baai/bge-base-en-v1.5 → 768-dimensional vectors, L2-normalized.data/seed.sql populates chunks and the FTS5 virtual table chunks_fts.Security checks run before expensive AI generation. Sessions are anonymous; there is no user login. CSRF proves the browser holds a token issued with that session; it is not authentication.
Body shape, required fields, 2,000-character message cap.
D1 lookup; reject ended or missing sessions.
HMAC-SHA256 over session id and secret; constant-time compare.
Hard ceiling so one conversation cannot drain quota.
Time-bucketed counters with TTL.
Server-side siteverify after sustained use.
Regex filter before any model call; canned refusal on match.
Short conversation history for generation context.
When RAG_ENABLED is on: hybrid retrieve → top 5 passages.
Llama 3.1 8B FP8 via Workers AI.
Filter unknown IDs; lexical overlap is observability-only.
User message and assistant reply stored together.
reply, answer, citations, retrieved.
This is the centrepiece of the current architecture: two independent search methods, fused by rank (not raw score), then optionally reordered by an LLM before the top five passages reach generation.
The question is embedded with the same BGE model used at index time and L2-normalized so that a dot product equals cosine similarity.
With only 21 chunks, the Worker loads stored vectors and scores every one with a linear scan. A dedicated vector database would add operational cost without benefit at this scale. Something like Cloudflare Vectorize becomes appropriate only when the corpus grows materially larger. Mihir AI does not currently use Pinecone, pgvector, or Vectorize.
SQLite FTS5 with BM25 finds exact terms such as product names, company names, and certification codes that pure semantic search can miss.
Implementation accuracy: vector search and FTS5 are independent methods, but the current code awaits the vector path and then runs keyword search. They are not executed concurrently today.
Vector similarity and BM25 scores live on different scales, so averaging them is meaningless. RRF combines rank positions instead:
score(chunk) = Σ 1 / (k + rank)
with k = 60
Small example
Suppose chunk A ranks 1st by vector and 4th by keyword; chunk B ranks 3rd and 1st. With k = 60, both get a contribution of roughly 1/61 + 1/64 and 1/63 + 1/61. The fused list reorders by those combined rank scores, not by averaging raw similarity numbers.
Up to about 20 fused candidates can be sent to Llama 3.1 for a relevance reorder. The reranker must return up to five allowed passage IDs. If the call fails or returns invalid output, the system falls back to the RRF order, retrieval never fails solely because reranking failed.
The final five passages, up to six recent history messages, and the current question go to the generation model. The RAG system prompt requires the model to use only retrieved context, cite exact chunk IDs, avoid guessing, and refuse when evidence is insufficient. Retrieved text is framed as reference material, never as instructions.
Traceability is a first-class design goal, and so is being precise about what the code actually guarantees.
[doc_id#chunk_index] from the model reply.citations array in the API response.rag_hallucinated_citation when an unknown ID appears.assessGrounding().rag_ungrounded_claim when a citation-bearing sentence looks unsupported.What it does not do:
reply is not post-edited.assessGrounding() is not a blocking safety gate. It is observability-only. Citation-ID validation is the hard gate on metadata.reply only. It does not show a source panel, even though the backend already returns citations and retrieved.These are transparent engineering limitations and logical next improvements, not hidden failures.
RAG did not replace the security model; it sits behind it. Every mutating request still has to clear the same edge controls before any retrieval or generation cost is spent.
Input validation, body size limits, and control-character stripping still apply at the API boundary. Lead email is sent only to me from Worker code after an explicit form submit, never from free-form model output.
Each tool has a narrow job. The stack stayed small on purpose: static frontend, serverless Worker, no frontend framework, no runtime npm dependencies in production Worker code.
Portfolio and chat widget with no framework or bundler.
Static hosting for the public site and this case study.
Strict typing for the Worker API surface.
Serverless edge compute for every API request.
Hosted inference for chat, embeddings, and reranking.
Generation and optional passage reranking model.
768-dim embeddings for queries and chunks.
SQLite for sessions, messages, leads, and vectors.
Keyword retrieval in the same database.
TTL rate-limit counters for IP and global caps.
Server-verified bot protection after sustained use.
Transactional lead notification email.
Local dev, secrets, D1, and Worker deploys.
Hand-editable RAG corpus with frontmatter IDs.
Offline chunking and embedding/index generation.
Built-in test runner with zero extra framework.
In-memory D1 for realistic integration tests.
Bundles TypeScript modules for the test suite.
Two-repo history, reviews, and feature-flag rollouts.
The original product shipped in seven foundation phases. RAG did not exist in those phases; they built the protected product first.
| Phase | Historical foundation scope |
|---|---|
| 0 | Accounts and infrastructure: D1, KV, Turnstile, Resend, DNS |
| 1-2 | Worker skeleton, D1 schema, session creation with CSRF issuance |
| 3 | AI chat, persistence, KV rate limits, server-side Turnstile |
| 4 | Frontend widget shell and full backend integration |
| 5 | Lead capture, intent detection, Resend notifications |
| 6 | Production hardening, secret audit, deployment |
A separate expansion stage added the retrieval system without discarding the foundation:
RAG_ENABLED rollout flag with legacy fallbackA controlled golden set drives retrieval and safety checks against the real retrieval code and live Workers AI (via the evaluation harness), not mock scores invented for marketing.
| Mode | Recall@5 | MRR |
|---|---|---|
| Vector-only | 1.0000 | 0.9643 |
| Keyword-only | 1.0000 | 0.8988 |
| Hybrid without reranking | 1.0000 | 0.9583 |
| Hybrid with reranking | 1.0000 | 0.9821 |
Caveat: this is a small, controlled, single-author corpus. These results validate the implementation and test set; they do not prove performance at enterprise scale.
data/seed.sql, outside the main migration path.None of the “next” items are claimed as shipped. They are the natural follow-ons once you look honestly at the current system.
Open the chatbot and ask a hard question about background, projects, or availability. The system should retrieve evidence, answer, and notice hiring intent when it appears.
Explore the Worker implementation, evaluation harness, and long-form architecture guide. Every claim on this page maps back to public source.