What is RAG? Let a café chatbot read the right policy section

What is RAG, and how does it let a chatbot read only the right policy sections? Split, search by keyword, build the prompt, and see where it still breaks.

What is RAG? It stands for retrieval-augmented generation: before the model answers, your code finds a few pieces of a document that relate to the question and pastes them into the prompt. The model reads only what it needs. Want a chatbot to answer from your shop's policies without sending the whole document every time? Let's build one.

Why not put the whole policy in the system prompt?

In the previous post, the chatbot for Mộc (a small Vietnamese coffee shop that sells online; the shop and its data in this series are made up) learned to remember the conversation. It already had an agent loop to handle several steps on its own. At the end of that post I mentioned something left unsolved. The shop has a set of policies on returns, refunds, allergies and promotions, while the chatbot's system prompt holds only a few lines about opening hours, the delivery fee and a price list.

The simplest approach is to paste the whole policy into the system prompt. For a short document that works well, and I think you should try it before doing anything more complicated. But it has two costs. Everything in the prompt is sent again on every call, even when the customer only asks about opening hours, so cost and latency grow with the length of the document. And if the document has 40 pages and the answer is a five-line passage buried in it, letting the model read all 40 pages isn't necessarily better than letting it read exactly those five lines.

RAG addresses both. The process has three steps, and this post builds them one at a time:

  1. Split the document into small sections, once, ahead of time.
  2. When a customer asks, find the few sections that match the question best. This is the "retrieval" part.
  3. Put those sections into the prompt, then ask the model. This is the "augmented generation" part: an answer supplemented with documents.

I'll build it with a sample policy of 14 sections so you can run it on your machine right away, with no key needed. The shop's real policy would be longer, but the method doesn't change.

What does the policy document look like?

Here's the whole policy.md, which I wrote just for this post. Each section starts with a ## heading, and I rely on those headings to split it:

policy.md
# Mộc café policies

## Opening hours and holidays
Mộc is open from 6:30 a.m. to 10 p.m. every day. For Tet, the Vietnamese Lunar New Year, the shop closes from the 28th day of the last lunar month through the 3rd day of the new year, and announces it on the home page a week ahead. On other public holidays the shop stays open, but home deliveries may be slower.

## Ordering and payment
Customers order on the website or by messaging the shop. The shop accepts bank transfer, e-wallets and cash on delivery. A bank-transfer order is made once the shop sees the money arrive in its account, which usually takes under two minutes.

## Delivery fee
Mộc delivers within 5 km for a fee of 15,000 đồng. Orders of 150,000 đồng or more get free delivery. Beyond 5 km the shop does not deliver, but customers can book a ride-hailing driver to pick the order up.

## Cancelling an order
Customers can cancel for free while the order is still in the "received" state. Once it moves to "being made", the shop cannot cancel because the ingredients are already used. A valid cancellation of a bank-transfer order is refunded in full, as described in the Refunds section.

## Replacing a wrong or badly made item
If the shop delivers the wrong item or makes it differently from the order, the customer should message within 30 minutes of receiving it, with a photo of what was delivered. The shop will remake the correct item and deliver it again for free, or refund the customer if they would rather not wait. An item that is more than half drunk cannot be replaced.

## Refunds
The shop refunds within three working days, to the same payment method the customer used. Cash orders are refunded by bank transfer to the account the customer provides. The refund covers the price of the item; the delivery fee is only refunded when the mistake was the shop's.

## Allergies and ingredients
Salt coffee and bac xiu contain milk. The egg banh mi contains gluten and egg. Customers with allergies should write a note in the order, and the shop will say whether it can accommodate them. The shop uses shared equipment, so it cannot completely guarantee that ingredients never mix.

## Promotions and discount codes
Each order can use one discount code. A code cannot be combined with the loyalty program in the same order. An expired code is not extended, and an order that has already been paid cannot have a code added.

## Loyalty points
Every 10,000 đồng earns one point. One hundred points can be exchanged for any drink priced up to 35,000 đồng. Points are valid for twelve months from the day they are earned. An order that is refunded loses the points it earned.

## Bulk orders
Orders of 20 cups or more must be placed one day ahead, and the shop will give a separate quote. Bulk orders do not get discount codes. The shop confirms by message before 5 p.m. the day before.

## Gift vouchers
Vouchers start at 100,000 đồng and are valid for six months. A voucher cannot be exchanged for cash and is not refundable after it is sold. One order can use several vouchers.

## Bring your own cup
Customers who bring their own cup get 3,000 đồng off each drink. The cup must be clean and hold at least 350 ml. The shop may refuse a cup that smells or is cracked.

## Customer privacy
The shop uses phone numbers and addresses only for delivery and order support. The shop does not sell or share customer information with third parties. A customer who wants their information deleted can message the shop.

## Contact
Customers message the shop during opening hours. In urgent cases, such as an allergic reaction after drinking, call the shop's hotline immediately.

(The đồng is Vietnam's currency: 15,000 đồng is about 60 US cents, and 35,000 đồng a little over a dollar.) Notice the two sections Cancelling an order and Refunds. The cancellation section only says "refunded in full, as described in the Refunds section", while the number "three working days" lives in the Refunds section. I left it that way on purpose, because real documents often point back and forth between sections. That detail matters near the end of the post.

How do you split the document into sections?

Each section is a unit the search will score and send. A section that's too big drags in extra words, and one that's too small lacks context. For a document whose author already divided it into sections, the simplest and least error-prone approach is to split by section:

src/split-sections.ts
import { readFileSync } from "node:fs";

export type Section = { title: string; body: string };

// Splits a Markdown document into sections: each "## ..." heading opens a new one.
export function splitSections(markdown: string): Section[] {
  return markdown
    .split(/^## /m)
    .slice(1) // whatever comes before the first "## " is just the document's overall title
    .map((block) => {
      const [title, ...lines] = block.split("\n");
      return { title: title.trim(), body: lines.join("\n").trim() };
    });
}

export function readPolicy(): Section[] {
  return splitSections(readFileSync(new URL("../policy.md", import.meta.url), "utf8"));
}

The regex ^## with the m flag matches ## at the start of every line. split cuts the document there, and slice(1) drops everything before the first heading. Running it gives 14 sections, exactly the number of headings.

Another way is to cut by a fixed number of characters, say 500 per piece. It works for documents without structure, but it easily cuts in the middle of a sentence, and that's one of the places where RAG answers go wrong. I'll leave it to later posts and keep to cutting by section here.

How do you find the sections that match a question?

This is the "retrieval" part. The most naive method that still does the job is counting words: which words are in the question, and which section contains the most of them. But if every word counts equally, common words like "order" appear in many sections and drown out rare words like "free". So I give each word a weight: the fewer sections contain it, the more points it earns. This formula is called IDF, short for inverse document frequency. It is half of the classic TF-IDF algorithm from text search, and I only use this half to keep things short:

src/find-sections.ts
import type { Section } from "./split-sections.ts";

// Very common words that don't help tell one section from another.
const STOP_WORDS = new Set(["the", "a", "an", "and", "or", "of", "to", "is", "are", "be", "it", "for", "in", "on", "if", "i", "my", "me", "you", "your", "can", "do", "does", "how", "what", "when", "will", "with", "at", "by", "that", "this", "than", "so", "get", "got", "from", "has", "have", "was", "were", "not", "no", "any", "much", "many", "long", "shop", "moc", "cafe"]);

export function splitWords(text: string): string[] {
  return (text.toLowerCase().match(/\p{L}+|\d+/gu) ?? []).filter((word) => !STOP_WORDS.has(word));
}

export type SearchResult = { section: Section; score: number; matched: string[] };

// Keyword scoring: every word of the question that appears in a section earns points,
// and the rarer the word (the fewer sections contain it) the more points. The title counts along with the body.
export function findSections(question: string, sections: Section[], limit = 2): SearchResult[] {
  const wordsOfSection = sections.map((s) => new Set(splitWords(`${s.title} ${s.body}`)));
  const questionWords = [...new Set(splitWords(question))];
  const idf = (word: string) => Math.log(1 + sections.length / (1 + wordsOfSection.filter((set) => set.has(word)).length));

  return sections
    .map((section, i) => {
      const matched = questionWords.filter((word) => wordsOfSection[i].has(word));
      return { section, matched, score: Number(matched.reduce((total, word) => total + idf(word), 0).toFixed(2)) };
    })
    .filter((result) => result.score > 0)
    .sort((a, b) => b.score - a.score)
    .slice(0, limit);
}

A few things in this code are worth knowing. The expression \p{L}+ matches a run of letters in any language, including accented letters, so "café" isn't cut at the "é". The STOP_WORDS list holds words like "the" and "of" that appear in every question, so they help find nothing. I wrote that list by hand, and you can see it's fairly arbitrary: I added words until the results got less noisy. Finally, the function returns only the first limit sections, 2 by default. That number is a choice. Taking more sections misses less but makes the prompt longer.

Before calling the model, I want to see what the search picks. The file try-search.ts prints the chosen sections with their scores and the words that matched, for five questions:

src/try-search.ts
// Prints the sections the search picks for each question, so you can see whether it's right before calling the model.
import { findSections } from "./find-sections.ts";
import { readPolicy } from "./split-sections.ts";

const SECTIONS = readPolicy();
console.log(`The document has ${SECTIONS.length} sections: ${SECTIONS.map((s) => s.title).join(" | ")}\n`);

const QUESTIONS = [
  "From what amount is delivery free?",
  "If the shop delivers the wrong item, how long until I get a refund?",
  "I cancelled my bank transfer order, how long until the money is back?",
  "My coffee was made wrong and I want the shop to compensate me",
  "I'm allergic to milk, what can I drink?",
];

for (const question of QUESTIONS) {
  console.log(`Customer: ${question}`);
  const results = findSections(question, SECTIONS);
  if (results.length === 0) console.log("  (no section matches)");
  for (const { section, score, matched } of results) console.log(`  ${score.toFixed(2)}  ${section.title}   [matched: ${matched.join(", ")}]`);
  console.log();
}
node src/try-search.ts
The document has 14 sections: Opening hours and holidays | Ordering and payment | Delivery fee | Cancelling an order | Replacing a wrong or badly made item | Refunds | Allergies and ingredients | Promotions and discount codes | Loyalty points | Bulk orders | Gift vouchers | Bring your own cup | Customer privacy | Contact

Customer: From what amount is delivery free?
  2.84  Delivery fee   [matched: delivery, free]
  1.50  Cancelling an order   [matched: free]

Customer: If the shop delivers the wrong item, how long until I get a refund?
  7.28  Replacing a wrong or badly made item   [matched: delivers, wrong, item, refund]
  3.47  Refunds   [matched: item, refund]

Customer: I cancelled my bank transfer order, how long until the money is back?
  5.96  Ordering and payment   [matched: bank, transfer, order, money]
  3.88  Cancelling an order   [matched: bank, transfer, order]

Customer: My coffee was made wrong and I want the shop to compensate me
  3.58  Replacing a wrong or badly made item   [matched: made, wrong]
  2.08  Allergies and ingredients   [matched: coffee]

Customer: I'm allergic to milk, what can I drink?
  2.08  Allergies and ingredients   [matched: milk]
  2.08  Contact   [matched: allergic]

The first two questions put the right section at number one. The fifth is right too, although the second section, "Contact", matched only because of the word "allergic". I'm fairly happy with those three. The other two are not good, and I'll come back to them after connecting this to a model.

How do you put the sections into the prompt?

With sections in hand, I now put them into the message sent to the model. Anthropic's documentation on prompting for long context has three pieces of advice I followed. Put long documents at the top of the prompt and the question at the end. Wrap each document in tags so the model can tell documents from the question. And for tasks that must stick to a document, tell the model what to rely on.

I add one more request: every section has a name, and the model must give the name of the section it used at the end of its answer. That way, when an answer is wrong, you can see at a glance which section it relied on.

src/answer.ts
import Anthropic from "@anthropic-ai/sdk";

import type { Section } from "./split-sections.ts";
import { splitWords } from "./find-sections.ts";

export const MODEL = "claude-opus-5-5";

export const SYSTEM = `You are a customer service assistant for Mộc café. Answer briefly and in a friendly tone, in English.
Answer only from the sections inside the <documents> tag. At the end of your answer, give the name of the section you used in square brackets.
If the sections don't contain the information needed, say the shop doesn't have that information yet. Don't guess.`;

// Puts the sections that were found into the customer's message. Each section has a name so the model can cite it.
export function buildPrompt(question: string, sections: Section[]): string {
  const documents = sections.map((s) => `<section name="${s.title}">\n${s.body}\n</section>`).join("\n");
  return `<documents>\n${documents}\n</documents>\n\nCustomer's question: ${question}`;
}

export function hasKey(): boolean {
  return Boolean(process.env.ANTHROPIC_API_KEY) && process.env.MOC_OFFLINE !== "1";
}

export async function answer(question: string, sections: Section[]): Promise<string> {
  if (!hasKey()) return offlineModel(question, sections);
  const client = new Anthropic();
  const response = await client.messages.create({
    model: MODEL,
    max_tokens: 16000,
    output_config: { effort: "low" },
    system: SYSTEM,
    messages: [{ role: "user", content: buildPrompt(question, sections) }],
  });
  return response.content.filter((b) => b.type === "text").map((b) => b.text).join("");
}

// A simulated model, not a real one: picks the sentences in the sections that share the most words with the question
// and reads them back word for word. It can only use words that are in the sections, so it shows clearly what the model "sees".
function offlineModel(question: string, sections: Section[]): string {
  const questionWords = new Set(splitWords(question));
  const sentences = sections.flatMap((s) =>
    s.body.split(/(?<=\.)\s+/).map((sentence) => ({ sentence, title: s.title, matches: splitWords(sentence).filter((word) => questionWords.has(word)).length })),
  );
  const best = sentences.filter((s) => s.matches >= 2).sort((a, b) => b.matches - a.matches).slice(0, 2);
  if (best.length === 0) return "Sorry, Mộc doesn't have that information yet.";
  return `${best.map((s) => s.sentence).join(" ")} [${[...new Set(best.map((s) => s.title))].join("; ")}]`;
}

I'll be straight with you about the simulated model. It understands nothing: it picks the two sentences that share the most words with the question among the sections it was sent, and reads them back. I use it because the point of this post is to show what the model *sees*, and a real model would almost certainly write far more fluently. But it has one useful property: it can't know anything that isn't in the sections it was sent, just like a real model that has been told "don't guess". I haven't run the real-Claude mode while writing this post, so every answer below comes from the simulated model. If you have a key, run it again and compare.

The last file chains the three steps together and prints how many characters are sent, to compare with sending the whole document. I measure in characters, not tokens, as in earlier posts:

src/run.ts
// The full RAG flow: find sections, build the prompt, ask the model. Compared with sending the whole document on every call.
import { answer, buildPrompt } from "./answer.ts";
import { findSections } from "./find-sections.ts";
import { readPolicy } from "./split-sections.ts";

const SECTIONS = readPolicy();
const QUESTIONS = process.argv.slice(2).length ? [process.argv.slice(2).join(" ")] : [
  "From what amount is delivery free?",
  "I cancelled my bank transfer order, how long until the money is back?",
];

const charsForWholeDocument = buildPrompt("", SECTIONS).length;
for (const question of QUESTIONS) {
  const chosen = findSections(question, SECTIONS).map((result) => result.section);
  console.log(`Customer: ${question}`);
  console.log(`  sent: ${chosen.map((s) => s.title).join(" + ")}`);
  console.log(`  ${buildPrompt(question, chosen).length} characters (sending the whole document: ${charsForWholeDocument} characters)`);
  console.log(`Mộc: ${await answer(question, chosen)}\n`);
}

To run it, create a folder, put policy.md and the files from src/ above in it, add a package.json containing "type": "module", and install the SDK with npm install @anthropic-ai/sdk@1.5.0. answer.ts imports the SDK even when you run offline. You need Node.js 22.18 or later to run .ts files directly. Without an ANTHROPIC_API_KEY (or with MOC_OFFLINE=1), it uses the simulated model:

node src/run.ts
Customer: From what amount is delivery free?
  sent: Delivery fee + Cancelling an order
  657 characters (sending the whole document: 4011 characters)
Mộc: Orders of 150,000 đồng or more get free delivery. [Delivery fee]

Customer: I cancelled my bank transfer order, how long until the money is back?
  sent: Ordering and payment + Cancelling an order
  735 characters (sending the whole document: 4011 characters)
Mộc: A bank-transfer order is made once the shop sees the money arrive in its account, which usually takes under two minutes. A valid cancellation of a bank-transfer order is refunded in full, as described in the Refunds section. [Ordering and payment; Cancelling an order]

The first question is answered correctly, and only 657 characters were sent instead of 4,011. With a sample document of only 14 sections the saving looks small, but the ratio is what matters. What RAG sends stays almost the same however long the document gets, while sending the whole document grows with its length. If the real policy were ten times longer than this sample, sending everything would cost ten times as much on every message.

What is missing from the second answer?

The second question is where RAG starts to show its limits. The customer asks how long until the money is back, and the answer says "refunded in full, as described in the Refunds section". The most important number is missing: three working days. The other sentence in the answer, about under two minutes, is about an order being made once the money arrives, which has nothing to do with refunds.

Looking back at the search results above, the reason is fairly clear. The question has "cancelled", "bank", "transfer", "order" and "money", but not the word "refund". The Refunds section has the number we need, yet it contains neither "cancelled" nor "bank transfer" in the combination the question uses, so its score doesn't make the top 2. Both sections that were chosen share words with the question, but neither has the answer. The search did what it was asked to do, count shared words. What the customer *wants to know* isn't something it can see.

I think this is something people new to RAG often miss. When an answer is wrong, it's easy to blame the model, and easy to keep tweaking the prompt without getting better. But the model in this example did the right thing. It was told to rely only on the sections it was given, and those two sections don't contain the number. A real model might do worse and guess "about 1 to 2 days" from general knowledge. Both outcomes trace back to the search step not handing over the section that was needed.

The fourth question is worth a look too. I ran it on its own:

node src/run.ts 'My coffee was made wrong and I want the shop to compensate me'
Customer: My coffee was made wrong and I want the shop to compensate me
  sent: Replacing a wrong or badly made item + Allergies and ingredients
  869 characters (sending the whole document: 4011 characters)
Mộc: Sorry, Mộc doesn't have that information yet.

This time the search did pick the right section, at number one. But the simulated model answered that the shop doesn't have the information, because no sentence in the sections shares at least two words with the question: the customer wrote "compensate" while the document says "refund". A real model would probably read the section and understand that "compensate" means a refund or a remake, but I haven't run one, so I won't claim it. What the search shows is the same weakness: two words with the same meaning and no letters in common. The second section, Allergies and ingredients, was chosen only because it contains "coffee", and it's irrelevant to a badly made drink. (There's also a detail specific to English: this search doesn't reduce words to their stems, so "delivers" doesn't match "delivery" and "allergic" doesn't match "allergies".)

When is RAG not the right approach?

After one full round, I think RAG is worth using when the document is long enough that sending all of it is expensive, or when it changes so often that you don't want to rewrite the system prompt each time. It also lets you control what an answer is based on, thanks to the section name at the end.

But if the document is only a few pages, I'd send all of it and skip the search. Today's models read very long contexts, and you don't take on an extra component that can return the wrong section. The search step is one more place for errors to happen, and errors there are harder to see than errors in the model, because the answer still sounds perfectly fluent. I think the sensible choice is to start with the simplest thing that works, and only build RAG when cost or latency becomes a real problem.

If you do build RAG, three habits are worth keeping. Print which sections were chosen for each question, as try-search.ts does, before looking at the answer. Tell the model to cite the section name and to say "no information" when the sections aren't enough, because an honest "I don't know" is better than an invented answer. And keep a list of real customer questions to rerun whenever you change how you split or search.

What if customers don't use the document's words?

The Mộc chatbot now reads the policy without being sent all of it on every call, and for questions that use the document's own words it finds the right section. But real customers don't read the document before asking. They write "compensate me", "money back", "got the wrong drink", while the document says "refund" and "wrong item". Two ways of saying the same thing with no words in common, and a word-counting search can't see it. The fact that the number "three working days" lives in a different section from the question is another form of the same problem.

How to search by meaning instead of by words, and how to split a document so each piece makes sense on its own, are jobs for later posts.

You can follow the whole series at Build your first AI agent. If you're preparing for interviews, the questions on AI agents are in the interview question bank.

Get the next post by email.

One email a week, only when there is something worth reading. No spam.