AIAug 2026 · 10 min read

I built a chatbot that refuses to make things up

What I learned building an internal Q&A assistant where the AI answers from your documents — and my code, not the AI, decides whether the answer counts.

Every team I've worked on has the same problem: important stuff lives in documents nobody wants to read. Policies, runbooks, contract clauses, onboarding guides. You know the answer is in there somewhere, but you don't want to spend twenty minutes finding it.

The obvious idea is a chatbot. Point it at your docs, ask a question, get an answer. And the obvious way to build one — hand the LLM your documents and let it talk — has one obvious problem: it makes stuff up. Confidently. With made-up article numbers and made-up quotes and a tone that says "trust me."

So I built one that can't. Or rather, one where making stuff up gets caught before the user sees it.

You can try it at ai-assistant.bikashlama.com — accounts are admin-provisioned, so ping me for a login if you want a real poke around.

What the app actually does

Someone on the team signs in, uploads a document (PDF, Markdown, or plain text), and asks the assistant a question. The answer streams back with little numbered tags — [1], [2] — that you can click to see the exact passage from the document that supports the claim. If the assistant doesn't have a good source, it says so instead of guessing.

A real query might be: "What does Article 51 say about contract non-compliance?" You get a short answer streamed in, with [1] next to the relevant sentence. Click it and the source passage slides in from the side.

Ask something the documents don't cover — "What's the weather today?" — and you get: "Hi — I answer questions from your uploaded internal documents. Ask me about a specific topic, article, or policy in your corpus." No answer, no fake citation, no hallucinated weather report.

Admins get a separate view: who's using it, how much each user is spending on AI calls, and what people have thumbs-up'd or thumbs-down'd.

The core idea: the AI writes, my code decides

Most "AI with citations" demos work like this: you tell the model "please cite your sources," it does, and you trust that the citations are real. This is a bad plan. The model is very good at writing text that looks like a citation. It's less good at only writing citations that are actually true.

So in this app, the LLM never has the final word. Here's what happens end-to-end:

  1. When you upload a document, the app chops it into overlapping pieces (about 500 words each, with 50-word overlap so sentences that fall on a boundary don't get split). Each piece gets turned into a vector — a fingerprint of its meaning — and stored in Postgres.

  2. When you ask a question, your question gets the same fingerprint treatment. The database finds the five pieces most similar to it. If none of them are similar enough — I set the bar at 0.15 cosine similarity — the app refuses the question before calling the LLM at all. No tokens spent, no answer to fact-check.

  3. If some pieces do match, they get numbered [1] through [5] and handed to the LLM along with the question. The LLM is told: answer using these, and put the number in brackets next to each claim.

  4. As the answer streams back to the user, my code is watching. When the stream ends, a parser pulls out every [N] the LLM wrote and checks each one. Is N a real number in the list? Did it cite anything at all? If the answer has zero valid citations, the streamed text gets thrown away and replaced with the same "no source found" refusal.

That last part is what I care about. The LLM doesn't get to grade its own homework. If it writes a beautiful, plausible answer with no sources, the user still sees a refusal. The parser is deaf to how good the answer sounds.

Why this feels different from what most demos do

A few things I ended up caring about, in the order I ended up caring about them:

Refusal is not a prompt trick. A lot of RAG apps put "please refuse if unsure" in the system prompt and hope the model listens. Mine short-circuits in code — if the vector search comes back empty, the LLM never even runs. There's no line in a prompt that a persuasive question can talk around.

Citations are checked, not trusted. The list of valid citation numbers is a set my code owns. The LLM can write [7] all day; if there are only five sources, [7] gets dropped. If nothing valid is left, the whole answer gets replaced.

Cost is a first-class thing, not an afterthought. Every OpenAI call goes through a wrapper that records the token count and estimated cost in a usage_events table. Each user has a daily token budget (100k by default). If you've used it up, the app blocks the request in application code — the OpenAI call never happens. Admins can watch spending per user, per day, per model.

Secrets never touch the LLM. The API key lives in an environment variable and is only read inside the adapter that talks to OpenAI. The session cookie only carries { userId, isAdmin } — never emails, never hashes, never anything I don't want the LLM to see.

I ended up codifying these into an eight-rule pattern I now apply to any code that talks to an LLM. I wrote about that separately in What I learned building an LLM harness.

The stack, briefly

I tried to pick boring tools and use each one for what it's good at.

LayerChoiceWhy
RuntimeNext.js 15 on NodeOne process serves the UI, the API, and the streaming — no separate backend to run
LanguageTypeScript (strict)Catches most of my dumber mistakes at build time
DatabasePostgres + pgvectorRegular tables and vector search in the same place. One less thing to run.
Prod hostingVercel + NeonPush to main, it deploys. Free tier covers a demo.
Dev hostingDocker ComposeOne command spins up Postgres locally
LLMOpenAI (text-embedding-3-small + gpt-4o-mini)Cheapest models that pass the quality bar
UITailwind + shadcn/ui + Radix + next-themesDark mode without a fight
Authiron-session cookies + bcryptNo public signup. Admins provision accounts.
Logspino + a per-request ID stored in AsyncLocalStorageEvery log line — from the top of the request down to the database driver — carries the same ID. Grep the ID, see the whole story of one request.
StreamingServer-Sent Events over OpenAI's streamTokens show up as the model produces them

Everything else — PDF parsing, toasts, icons — is off-the-shelf.

The pieces I'm proudest of

Verifying citations after streaming. This one was fun. Users want tokens on the screen as they arrive — anything else feels laggy. But if I only start checking citations when the stream ends, and it turns out there weren't any, I've already shown the user a beautiful hallucinated answer. My solution: stream the text as provisional, and send a final "verified" event at the end. If verification fails, the client swaps the streamed text for the refusal message. It's the "write, then commit" pattern applied to a live-updating UI.

The HNSW-versus-IVFFlat migration. Migration 0003 used pgvector's ivfflat index. Everything worked in tests. In prod, with a smaller corpus, the query returned zero rows every time. Turns out ivfflat silently returns nothing if the lists parameter is bigger than the row count. Migration 0005 swapped it for HNSW, which doesn't have this trap. I now have this written down in the deployment runbook under "gotchas" so I don't lose an afternoon to it again.

Request tracing through async code. Every incoming request gets an x-request-id header assigned in the middleware. That ID goes into Node's AsyncLocalStorage for the lifetime of the request, and a pino mixin sneaks it into every log line automatically — including logs from deep inside the database driver. When something goes weird in prod, I grep the request ID and I can read the full story of one user's turn without stitching timestamps together.

Env-tunable everything. Chunk size, how many results to fetch, similarity threshold, daily token cap, which chat model to use, the JPY-to-USD conversion rate for cost display — all in .env.example, all with sensible defaults. Tuning retrieval doesn't require a code change or a deploy.

What still isn't great

Being honest about the shape of a v1:

  • Chunking counts words, not tokens. Real BPE tokens are smaller than words. A 500-word chunk is roughly 650 tokens — well under OpenAI's 8191-token limit, but the number in the config is a little misleading. A tiktoken integration would be more honest.
  • The chunker splits blindly on whitespace. A heading and its paragraph can end up in different chunks. Section-aware chunking would help on structured docs.
  • One similarity threshold for every question. Some questions match at 0.4, some at 0.15. A single number is a compromise — it either refuses too often or lets weak matches through.
  • No re-ranking. pgvector's HNSW is an approximation. Above ~10k chunks, real systems fetch top-20 and re-rank with a smaller model down to the real top-5. Mine just trusts HNSW.
  • No document versioning. Re-uploading the same policy creates new chunks next to the old ones. In an internal Q&A tool, where policies get revised, old chunks can win the retrieval and give you last year's answer.
  • PDF tables get flattened. pdf-parse turns them into jumbled text. Images and diagrams get dropped entirely.
  • Single-tenant. One corpus, one admin group. Turning this into a multi-company product would touch every table.
  • Citation pills disappear on reload. The [N] markers work while the message is fresh. I never persisted the citation mapping to the database, so reloading the page falls back to plain text. Annoying, fixable, hasn't been worth the schema change yet.
  • Feedback goes nowhere. Thumbs-up and thumbs-down get saved, but there's no loop back into retrieval tuning or a user-visible "we saw this."
  • Cost caps are per-user-per-day only. No hourly cap, no company-wide cap. A single compromised account could burn a day's budget in a minute.
  • No login rate limit. bcrypt makes brute force expensive but not impossible. Per-IP throttling on /api/auth/login is on the list.
  • No user data export or delete. Fine for an internal tool. A blocker before serving external users.
  • Chat is linear. No branching, no "answer that again differently" button. You retype.

What I'd do next

If this project keeps living, the order I'd tackle things:

Retrieval that gets smarter as people use it. Every thumbs-down question becomes an entry in the eval file, with the chunk that was cited (or null for refusals). Now retrieval tuning has real user friction as a regression test.

Actually understand the failures. Route each downvote plus its comment through a small classifier prompt that buckets it: retrieval miss, synthesis miss, off-topic. The admin dashboard can then show me not just what failed but how it failed.

Chunk-level feedback. A thumbs-down on the citation pill, not the whole message. Chunks flagged repeatedly go into a queue for re-indexing or removal.

Document lifecycle that respects revisions. Re-uploading a doc marks the old version's chunks historical; retrieval excludes them by default. Full audit trail retained.

Prompt template variables. Turn saved snippets from copy-paste text into fill-in-the-blank forms. {{contract_name}} in the body pops a modal when you insert it.

Real cost controls. Hourly caps, per-org caps, IP throttling on login, IP throttling on any public endpoint.

Multi-tenant. Every table gets a corpus_id. One deployment can then host multiple isolated companies.

A demo mode. A separate deployment with a small curated public corpus, aggressive rate limiting, and a link on my portfolio so anyone can try it without needing an admin invite.

That last one is what makes this a portfolio piece and not just a project. Recruiters skim. A working demo beats a screenshot.