How to Build a RAG Chatbot That Actually Retrieves
Most RAG chatbot projects fail at retrieval, not generation — here is how to chunk on structure, store in pgvector, measure recall@k, and build a refusal path that actually fires.
Almost every RAG chatbot project fails in the same place, and it is not the place students expect. The model call is four lines of code. The prompt is a paragraph. What actually decides whether your document chatbot answers correctly is whether the right passage was in the context window at all — and for most projects, it wasn't. The model then did what models do: it wrote a fluent, confident, wrong answer from whatever it was handed.
So if you are building a RAG chatbot as a mini project or a final-year capstone, understand the shape of the work before you start: the retrieval is the project. The LLM call is the easy part. This post walks through the parts that actually take engineering — structure-aware chunking, embedding and querying with pgvector, an evaluation set you build by hand, a citation contract, and a refusal path for when retrieval comes up empty.
The Python below is real. The chunker and the scoring functions are tested and their behaviour is described accurately. The database and API code is real code, but you will need to wire up your own DSN, schema, and API key before it runs — I am not going to pretend a blog post is a working repository. The embedding model runs locally and costs nothing, so you can build the whole retrieval half before you spend a rupee on API calls. That ordering is deliberate.
Why does my RAG chatbot answer confidently and wrongly?
Because a language model given irrelevant context does not say "this context is irrelevant." It pattern-matches. Hand it five chunks about leave policy when the question was about gratuity eligibility, and it will assemble something gratuity-shaped out of leave-policy vocabulary. The failure is silent, and it looks exactly like a correct answer.
That means the interesting question in your project is never "which model did you use." It is: for a question whose answer exists in your corpus, how often is the passage containing that answer actually in the top k results? That number has a name — recall@k — and if you cannot state yours, you do not know whether your system works.
An LLM cannot fix a retrieval failure. It can only make one harder to see.
What is wrong with fixed-size chunking?
Here is the chunker almost every tutorial starts with, and it is the single biggest destroyer of retrieval quality in student RAG projects:
It slices on character count, which has nothing to do with meaning. A definition gets severed from the term it defines. A table header lands in one chunk and its rows in the next. A clause that begins "This shall not apply where..." ends up in a different chunk from the rule it negates — so retrieval returns the rule, the model reads the rule, and the model tells the user the opposite of the truth. The overlap parameter does not save you; it just means you now have two chunks that are each half-wrong.
Chunk on structure instead. Documents already carry it: headings, paragraphs, list items, fenced code, table boundaries. Split on those, pack the pieces up to a token budget, and carry the heading into the chunk so the embedding knows what the text is about. Three details in the code below are load-bearing, and every one of them is a bug I have watched a structure-aware chunker ship with. It flushes the chunk whenever the heading changes, so a chunk is never labelled with a heading that only applies to its first block. It tracks fenced-code state, so the comments inside a Python listing are not mistaken for Markdown headings. And it requires whitespace after the hashes, so a table row beginning with a hash is not a section break.
The overlap is now a whole block, not 200 arbitrary characters, so the boundary between two chunks always falls where the document itself had a boundary. Without the heading-change flush, a chunk containing the tail of "Casual Leave" and the start of "Gratuity" gets stored under the heading "Casual Leave" and embedded as if it were about leave — which is precisely the definition-severed-from-its-term failure the structure-aware chunker exists to prevent.
Embedding and storing with pgvector
You do not need a dedicated vector database for a project of this size. Postgres with the pgvector extension gives you vector search, keyword search, and your application tables in one place, with one backup story and one connection pool. Start here; graduate later if you ever actually need to.
Now embed and insert. Three details matter. Normalize the vectors, so cosine distance behaves. BGE models are trained asymmetrically — passages are embedded as-is, but queries get a specific instruction prefix, and skipping it quietly costs you retrieval quality for no reason. And make re-ingest idempotent: the UNIQUE constraint plus ON CONFLICT DO UPDATE means running the ingest twice on the same document updates rows in place instead of inserting a second copy under fresh ids. Be clear-eyed about what that does and does not buy you, though. Chunk ids are a bigserial surrogate key. They are stable across a re-ingest of the same chunking, and they are meaningless across a different one — change the chunk size and every boundary moves, so the rows are genuinely different rows. That is why the eval set in the next section is labelled against text, not against ids.
Vector search alone will embarrass you
Embeddings are good at meaning and bad at literal tokens. Ask for "section 12(b)" or a part number, and the nearest-neighbour search returns passages that are semantically about the same topic while missing the one that literally contains the string. Postgres full-text search is good at exactly the thing embeddings are weak at, and you already created the index. Be precise about what it is, though: to_tsvector('english', ...) does lexeme matching, not exact-string matching. It stems and splits, so "section 12(b)" becomes the lexemes section, 12 and b — which is enough to find the right clause. It is not enough for an identifier like ERR_4021-B, which gets fragmented on the punctuation. For identifiers, add a second index using the simple config, or a GIN trigram index via pg_trgm for real substring matching. Then fuse the two ranked lists with Reciprocal Rank Fusion — no score calibration, no tuning, just ranks.
The 60 in the denominator is the standard RRF constant. Do not agonise over it. Do, however, notice the LIMIT 50 in the vec CTE, because there is a trap right there: pgvector's hnsw.ef_search defaults to 40, and asking an HNSW index for more rows than its candidate list size quietly degrades recall. The query above asks for 50. So ef_search is a precondition of this query, not a remedy you reach for later — set it before you run it. Joining from the two CTEs rather than scanning chunks and filtering matters too: the outer query then touches about a hundred candidate rows instead of the whole table.
Build the eval set before you touch the prompt
This is the step that separates a project that works from a project that demos. Open your corpus. Write 20 to 30 questions a real user would ask. For each one, copy out the exact span of text that answers it. That is your eval set. It takes an afternoon and it is the most valuable artifact in the repository — more valuable than the code, because the code is replaceable and the labels are not.
Label against the text, not against chunk ids. This is the part people get wrong, and it is worth being explicit about why. The moment you change the chunk size — the very first experiment this post tells you to run — every chunk boundary moves and every id is meaningless. If your labels point at chunk 412, they now point at a chunk that no longer contains what you meant. You would silently invalidate the entire eval set with the first thing you tried, and the numbers would still come out looking plausible. A gold answer span survives re-chunking because it is a property of the document, not of your chunking strategy. So a retrieval counts as a hit when the retrieved chunk's content contains the gold span.
Now you have a number. Change the chunk size and run it again — and because the labels are text spans, the eval set still means what it meant before. Turn off the keyword arm and run it again. Drop the query prefix and run it again. Every one of those is a five-minute experiment with an honest answer at the end, and you will be surprised at least twice. Put the resulting table in your report — a chart of recall@5 across four chunking strategies is worth more to an examiner than another screenshot of a chat bubble.
And be honest with yourself about what counts as evidence. "It works on my three test questions" is not evidence — three questions you invented after building the system will happen to be the three the system handles. Twenty-five questions written from the documents, before you tuned anything, with a measured recall@5 and every single failure listed and explained: that is evidence. The failures are the most interesting part. Go read them.
Citations are not a feature, they are the contract
A grounded answer must be checkable. That means every claim carries the id of the chunk it came from, the UI renders that id as a link to the source passage, and — critically — you verify after generation that the cited ids are actually in the set you retrieved. A model that cites a chunk you never gave it has hallucinated the citation too, and you should reject the whole answer rather than show it.
The refusal path is the anti-hallucination guardrail
Your system needs a way to say "I don't know." Two independent gates, because either one alone leaks. Gate one: if retrieval did not turn up anything that looks like a real match, do not call the model at all. Gate two: force the model into a structured response with an explicit sufficiency flag, and let it decline. Gate one has a subtlety that will bite you. The obvious implementation checks the similarity of the top result — but the top result is the top RRF-fused result, and it may have been surfaced entirely by the keyword arm. Someone asks for "section 12(b)", the keyword arm correctly ranks the exact-match clause first, its cosine similarity is mediocre because embeddings are bad at literal tokens, and your refusal gate throws away the exact case you added hybrid search to fix. So check the best similarity across all hits, and let a strong keyword hit override the floor.
The threshold itself is the other trap, and it is why the code below refuses to ship with a default. BGE embeddings are strongly anisotropic: their similarities are compressed into the upper part of the range, so two completely unrelated short texts typically score somewhere around 0.6 to 0.75, not near zero. A floor of 0.45 would therefore never fire — you would believe you had two gates while shipping one, and you would also conclude your retrieval is excellent because everything scores 0.7. For bge-small the useful threshold usually lands somewhere around 0.7 to 0.8, and the only way to find yours is to measure it. Add 10 questions to your eval set whose answers are deliberately not in the corpus, and pick the threshold that refuses those without refusing the answerable ones. Your refusal rate becomes a measured number too.
Note the last gate carefully: when the citation filter strips every id the model produced, the answer text is discarded along with them. Demoting the sufficiency flag but keeping the prose is a bug — a caller that renders result["answer"] would happily display an answer whose evidence you just proved was invented. If you would rather not hand-roll the JSON parsing at all, the SDK's client.messages.parse() validates the response against the same schema for you; you still need the stop_reason check.
The cost and latency budget
Do the arithmetic before you demo, because someone will ask. Six chunks at roughly 350 tokens is about 2,100 tokens of sources, plus the system prompt and the question — call it 2,400 input tokens. A grounded answer with citations is maybe 250 output tokens. At Claude Opus 4.8's list price of $5 per million input tokens and $25 per million output, that is roughly $0.018 per question, about 55 questions per dollar. The same call on Claude Haiku 4.5 ($1 and $5 per million) is roughly $0.004, about 270 questions per dollar. Those are the two numbers to put on the slide; which tradeoff you make is a product decision, not a technical one. And note where the money is not: retrieval costs you nothing in API spend, because the query embedding runs locally and the search is a database query. It is not free — the encoder burns CPU and the search burns I/O — but almost all of your money and almost all of your latency lives in the generation call. Optimising your vector index for speed before you have measured end-to-end p95 is premature by a wide margin.
Prompt caching will not save you here either, and you should know why before you try it. Caching is a prefix match — it only pays off when a large chunk of the beginning of the prompt is byte-identical across requests. In a RAG loop the retrieved sources change with every question, so the only stable prefix is the system prompt, and on Opus 4.8 the minimum cacheable prefix is 4,096 tokens. The system prompt above is under a hundred. It would never cache, silently, with no error. The lever that actually reduces cost is retrieving fewer, better chunks — which brings you right back to recall@k.
What to actually build, in order
- Ingest and chunk on structure. Assert that no chunk exceeds the encoder's token limit.
- Embed locally with a small open model. Store in pgvector with an HNSW index and a tsvector column.
- Hand-write 20 to 30 question/gold-span pairs. Measure recall@k and MRR. Write the number down.
- Iterate on retrieval only — chunk size, overlap, hybrid fusion, ef_search, query prefix — until recall@5 stops improving.
- Add 10 unanswerable questions and calibrate the similarity floor against them.
- Only now add the generation call, with a citation contract and both refusal gates.
Notice that the model does not appear until step six. That ordering is the whole argument of this post, and it is the thing that will make your project defensible when someone asks the hard question — not "does it work," but "how do you know."
This is how we work on final-year and mini projects at Tenzok: eval set first, retrieval measured, guardrails in the code rather than in the report. If you are building a RAG chatbot and your recall number does not exist yet, that is the place to start — with us or without us.
Frequently asked