Why RAG?

Table of Contents

  1. Models Have Not Read Your Files
  2. Why Not Paste Everything?
  3. Why Not Fine-Tune?
  4. The RAG Pipeline at a Glance
  5. Set Up the raglab Project
  6. The Mock LLM Server
  7. The Sample Corpus: Mekong Code Club
  8. The No-RAG Baseline
  9. Naive RAG in 30 Lines
  10. What the Naive Version Got Wrong
  11. Recipe: The Setup Files
  12. Try It Yourself
  13. Quick Review: Common Questions

1. Models Have Not Read Your Files

In How LLMs Work you saw that a model learns from a huge pile of public text, then stops. It has never seen your school handbook, your company's price list, your class notes, or the email your boss sent this morning. It also does not know anything that happened after its training ended.

Ask it about those things and one of two bad things happens:

  • It says it does not know. That is the good outcome.
  • It writes a smooth, confident answer that is made up. You met this in Why Models Hallucinate. A model is built to continue text in a plausible way, and a plausible fee, date or phone number looks just like a true one.

Think of a very smart student who walks into an exam without having read the textbook. Ask about general knowledge and the student does well. Ask "what does page 42 of our book say?" and the student either admits it or bluffs.

The fix is an open-book exam. We let the student look at the right page before answering. That is the whole idea of Retrieval-Augmented Generation, or RAG:

  1. Retrieve: find the few passages of your documents that are relevant to the question.
  2. Augment: put those passages into the prompt.
  3. Generate: ask the model to answer using only those passages, and to say which passage it used.

The model does not learn anything permanently. It simply reads the right page, every time, right before it answers.

2. Why Not Paste Everything?

A fair question: why search at all? Why not paste all your documents into the prompt and ask? For a tiny corpus, you should. We come back to this in the last chapter. But it stops working quickly, for four reasons.

1. The context limit. Every model has a maximum prompt size, called its context window, measured in tokens. Limits differ between models and change over time, so check your provider's documentation. Some windows are large, but a company wiki, a year of support tickets or a library of PDFs can still be bigger than any window.

2. Cost and speed. You pay for every token you send, on every question. Pasting everything means paying for the whole library again and again, and waiting for the model to read it each time. Here is the arithmetic. The price is only an example; real prices differ by provider and change, so look up your own.

// demo-context-math.js: why "just paste everything" gets expensive
// EXAMPLE price only. Look up your provider's real price list.
const examplePricePerMillionInputTokens = 1.0; // US dollars, an example

const pages = 2000;            // a company wiki
const tokensPerPage = 700;     // rough guess for a page of text
const wholeWiki = pages * tokensPerPage;
const ragPrompt = 4 * 150 + 200; // 4 chunks of about 150 tokens + instructions + question

const cost = (tokens) => (tokens / 1_000_000) * examplePricePerMillionInputTokens;
console.log("paste the whole wiki:", wholeWiki.toLocaleString("en-US"), "tokens, $" + cost(wholeWiki).toFixed(4), "per question");
console.log("RAG prompt          :", ragPrompt.toLocaleString("en-US"), "tokens, $" + cost(ragPrompt).toFixed(4), "per question");
console.log("ratio:", Math.round(wholeWiki / ragPrompt) + "x");
// output
// paste the whole wiki: 1,400,000 tokens, $1.4000 per question
// RAG prompt          : 800 tokens, $0.0008 per question
// ratio: 1750x

Every number here comes from the assumptions in the code, so the output is stable. Change the assumptions and you change the answer. The point is the ratio, not the dollars.

3. Lost in the middle. Even when everything fits, more text is not always better. Researchers have reported that models can use information less reliably when it sits in the middle of a very long prompt than when it sits near the start or the end. Results depend on the model, so treat this as a risk to test, not a law. A short prompt with the right three paragraphs is easier for a model than a long prompt with the right three paragraphs hidden among a thousand wrong ones.

4. Privacy and access. If you paste the whole library into every request, you send all of it to the model provider every time, even the parts that have nothing to do with the question, and even the parts the current user is not allowed to see. With retrieval you choose which passages leave your system, and you can check permissions before choosing (chapter 8).

3. Why Not Fine-Tune?

Fine-tuning means training a model further on your own data, so that its weights change. It sounds like the natural way to "teach it our documents". For facts, it is usually the wrong tool:

  • Freshness. Your documents change. A fee changes, a schedule changes. With RAG you edit the file and re-index it. With fine-tuning you must train again, and the old fact may still leak out.
  • Cost and effort. Training needs prepared data, time, money and expertise. Re-indexing a document costs almost nothing.
  • No citations. A fine-tuned model cannot show you where it learned something. A RAG answer can point to the exact passage, and you can click and check.
  • Hard to remove. If a document must be deleted (it was wrong, or secret, or someone asked you to remove their data), you cannot easily make a trained model forget it. With RAG you delete the chunk.
  • Not reliable for facts. Fine-tuning is good at changing style and behaviour: a tone, an output format, a task. It is a weak way to make a model memorise a long list of exact facts.

The two are not enemies. Teams sometimes fine-tune for behaviour and use RAG for knowledge. But if your problem is "answer from our documents", start with RAG.

4. The RAG Pipeline at a Glance

Every RAG system has two phases. Ingest runs once, and again when documents change. Query runs for every question.

INGEST  (offline, repeat when documents change)

  files ──► load ──► chunk ──► embed ──► store
  .md       read     cut into   text →    vector index
  .pdf      text     passages   numbers   (index.json)


QUERY   (online, for every question)

  question ──► embed ──► search ──► build prompt ──► generate ──► cite
               question  top-k      "Context: [1]…    the model    show
               → numbers chunks     Question: …"      writes       sources

In plain words:

  • Load reads your files into text.
  • Chunk cuts long text into small passages, because we want to retrieve the right paragraph, not the whole book (chapter 2).
  • Embed turns each chunk into a list of numbers that captures what it is about, so that texts about similar things get similar numbers (chapter 3).
  • Store keeps the chunks and their numbers so we can search them (chapter 4).
  • At question time we embed the question the same way, search for the chunks whose numbers are closest, build a prompt that contains those chunks and the question, generate the answer, and cite which chunks were used (chapter 5).

Chapters 6 and 7 make retrieval better and measure it. Chapter 8 wraps it in a command-line tool and a web endpoint.

5. Set Up the raglab Project

You build one plain Node.js project, raglab, with no dependencies. You need Node.js (the LTS version). This series uses the same ES modules and fetch as the chatbot series.

mkdir raglab
cd raglab
npm init -y
npm pkg set type=module
mkdir src docs

npm init -y writes a default package.json, and npm pkg set type=module adds "type": "module" so that import works in .js files. The folders will hold:

raglab/
  mock-llm.js        the fake LLM server (section 6)
  package.json
  docs/              the sample documents (section 7)
  src/
    config.js        settings from environment variables
    client.js        chat() and ApiError         (from the chatbot series)
    stream.js        streaming reader            (from the chatbot series)
    conversation.js  conversation memory         (from the chatbot series)
    load.js          chapter 2
    chunk.js         chapter 2
    embed.js         chapter 3
    ...

Three files are unchanged from Build a Chatbot with an LLM API: copy src/client.js from Your First API Call, src/stream.js from Streaming Responses, and src/conversation.js from Conversation and Memory. The only new thing in src/config.js is one line for the embedding model name. Its full text is in the recipe at the end of this chapter.

6. The Mock LLM Server

To keep everything free and offline, this series ships a mock LLM server, a small mock-llm.js file with no dependencies. It is the same idea as in the chatbot series, with one big addition. Its full text is in the recipe (section 11). It offers:

  • POST /v1/chat/completions, the chat endpoint, with streaming. The models mock-1, mock-flaky, mock-error and mock-slow behave as before.
  • New: POST /v1/embeddings, which turns text into a list of 256 numbers.

Please read these limits carefully. The mock is a stand-in, and we will remind you of them whenever they matter.

  • The mock embeddings are bag-of-words vectors. Each meaningful word is hashed to one of 256 slots, and the counts are scaled to length 1. So two texts match when they share words, not when they share meaning. "cost" will not match "price" here. A real embedding model captures synonyms, paraphrases and even other languages. You will see this difference in action in chapter 3.
  • The mock "answers" with a trick. If the last user message starts with Context:, has numbered passages [1], [2]... and ends with Question: ..., the mock picks the one sentence with the most words in common with the question and returns it with its number, like ... [1]. If fewer than 2 words overlap, it says I don't know based on the provided documents. A real model would instead write a fluent answer using the passages, combine several of them, and follow your rules. The mock is only good enough to let the pipeline run and be tested.

The prompt format we use for the whole series is this (the mock expects it, and so will our real code):

Context:
[1] (source: membership.md#Membership fee) text...

[2] (source: ...) text...

Question: <the question>

Start the mock in its own terminal and keep it running. We use port 8840 here; the default is 8787.

PORT=8840 node mock-llm.js
# output
# mock LLM listening on http://localhost:8840  (api key: test-key)

In another terminal, check it:

curl -s http://localhost:8840/health
# output
# {"ok":true}

Your programs read the address from LLM_BASE_URL. So in the second terminal, set it once (export LLM_BASE_URL=http://localhost:8840), or put it in front of each command like we do below. If you use the default port 8787 you do not need it at all. The three settings that switch you to a real provider later are LLM_BASE_URL, LLM_API_KEY and LLM_MODEL (plus LLM_EMBED_MODEL for embeddings).

7. The Sample Corpus: Mekong Code Club

We need documents to search. The series uses the handbook of Mekong Code Club, a student coding club in Phnom Penh. The club is fictional and the facts are invented sample data. There are eight short Markdown files in docs/:

FileWhat it covers
about.mdWhat the club is, where it meets, who can join
membership.mdFee, scholarships, how to pay, refunds
schedule.mdSaturday workshops, Wednesday help desk, holidays
laptops.mdThe laptop loan policy
conduct.mdCode of conduct and academic honesty
projects.mdTerm projects, Showcase Day, prizes
mentors.mdWho the mentors are and how to become one
faq.mdShort answers to common questions

Here is one of them, in full. Note the shape: one # title, then sections with ## headings. This is typical of handbooks, wikis and documentation.

# Membership and Fees

## Membership fee

Membership costs 10 US dollars per term. A term lasts about four months.
The fee pays for snacks, printing, and the Wi-Fi bill. There is no fee
for the first month, so new students can try the club before paying.

## Scholarships

No student is turned away because of money. Students who cannot pay can
apply for a free scholarship membership by talking to the club treasurer,
Dara, after any Saturday session. Scholarship students have exactly the
same rights as paying members.

## How to pay

Pay the treasurer in cash, or send the fee by bank transfer. The club
does not accept payment through social media messages. Always ask for a
receipt.

## Refunds

If you leave in the first two months of a term, you can ask for half of
your fee back. After two months there are no refunds.

You can create all eight files from the recipe at the end of this chapter (section 11). They are small on purpose:

// demo-corpus-size.js
import fs from "node:fs";

let chars = 0;
for (const f of fs.readdirSync("docs")) chars += fs.readFileSync(`docs/${f}`, "utf8").length;
console.log(chars, "characters, about", Math.ceil(chars / 4), "tokens");
// output
// 7059 characters, about 1765 tokens

(A token is roughly 4 characters of English. That is only a rule of thumb; chapter 2 says more.) Honestly, this corpus is small enough to paste into a prompt. We use it because small, known facts make it easy to check the answers. The same code works on a corpus a thousand times bigger, and that is where RAG earns its place.

A few facts that the questions in this series will use: the fee is 10 US dollars per term, the first month is free, the treasurer is Dara, the Saturday workshop runs 9:00 to 12:00, a laptop can be borrowed for up to 7 days, the club owns eight laptops, Showcase Day gives 7 minutes plus 3 minutes for questions, the president is Sophea, and the room is Room 204 with 24 seats. Some questions are not answerable from the documents, on purpose: "Does the club teach Rust?", "Who is the vice president?", "What is the Wi-Fi password?". A good system must say it does not know.

8. The No-RAG Baseline

Before building anything, see the problem. Ask the model about the club without giving it any documents. This is the baseline we will beat.

// demo-baseline.js: ask without giving the model any documents
import { chat } from "./src/client.js";

const question = "How much is the membership fee at Mekong Code Club?";
const { text, usage } = await chat({ messages: [{ role: "user", content: question }] });
console.log(text);
console.log(usage);
LLM_BASE_URL=http://localhost:8840 node demo-baseline.js
# output
# You said: How much is the membership fee at Mekong Code Club?
# { prompt_tokens: 13, completion_tokens: 16, total_tokens: 29 }

The mock simply repeats what you said, because it has no knowledge at all. That is not what a real model does, so be careful with the lesson. A real model would know a lot about the world, but nothing about this fictional club. It would typically do one of two things:

  • admit it has no information about "Mekong Code Club", or
  • guess, writing a plausible fee such as a number of dollars that has no connection to our handbook.

Which one you get depends on the model and the wording, and you cannot count on the first. Either way, the 10 US dollars in our handbook is not reachable. No amount of clever prompting can give the model a fact it never saw. The only way is to put the fact into the prompt.

The token numbers 13 and 16 come from the mock's rough estimate (characters divided by 4). They are stable here, but a real provider counts differently.

9. Naive RAG in 30 Lines

Now let us build the whole loop in one small file, with the crudest possible search: count the words the question and the passage share. No chunking library, no embeddings, no vector store. It shows the shape of every RAG system you will meet.

// naive-rag.js: the whole RAG loop in about 30 lines, with keyword matching
import fs from "node:fs";
import { chat } from "./src/client.js";

// 1. Load: one passage per paragraph, remembering file and heading.
const passages = [];
for (const file of fs.readdirSync("docs").filter((f) => f.endsWith(".md"))) {
  let heading = "";
  for (const para of fs.readFileSync(`docs/${file}`, "utf8").split(/\n\s*\n/)) {
    if (para.startsWith("#")) { heading = para.replace(/^#+\s*/, "").trim(); continue; }
    passages.push({ source: file, heading, text: para.replace(/\s+/g, " ").trim() });
  }
}

// 2. Search: count the words the question and the passage share.
const words = (s) => new Set(s.toLowerCase().match(/\p{L}{3,}/gu) ?? []);
function search(question, k = 2) {
  const q = words(question);
  return passages
    .map((p) => ({ p, score: [...words(p.text)].filter((w) => q.has(w)).length }))
    .filter((r) => r.score > 0)
    .sort((a, b) => b.score - a.score)
    .slice(0, k);
}

// 3. Build the prompt and 4. generate.
export async function ask(question) {
  const hits = search(question);
  const context = hits
    .map((h, i) => `[${i + 1}] (source: ${h.p.source}#${h.p.heading}) ${h.p.text}`)
    .join("\n\n");
  const prompt = `Context:\n${context}\n\nQuestion: ${question}`;
  const { text } = await chat({ messages: [{ role: "user", content: prompt }] });
  return { text, hits };
}

for (const q of process.argv.slice(2)) {
  const { text, hits } = await ask(q);
  console.log(`Q: ${q}`);
  console.log(`A: ${text}`);
  console.log("   found:", hits.map((h) => `${h.p.source}#${h.p.heading} (${h.score})`).join(", ") || "nothing");
}

Read it top to bottom. It is the pipeline from section 4 in miniature:

  1. Load reads every .md file, splits it on blank lines into paragraphs, and remembers the file and the nearest heading above each paragraph. That is our "chunking".
  2. Search gives each passage a score: how many words of three letters or more it shares with the question. The two best passages win (k = 2).
  3. Build the prompt puts the passages in the canonical format, each with a number and a source.
  4. Generate sends it to the model.

(The \p{L} in the regular expression means "any letter in any language". That matters later for Khmer.) Run it with six questions:

LLM_BASE_URL=http://localhost:8840 node naive-rag.js \
  "How much is the membership fee per term?" \
  "How long can I borrow a laptop?" \
  "Who is the treasurer?" \
  "What is the Wi-Fi password?" \
  "Does the club teach Rust?" \
  "What time does the Saturday workshop start?"
# output
# Q: How much is the membership fee per term?
# A: Membership costs 10 US dollars per term. [1]
#    found: membership.md#Membership fee (5), membership.md#Refunds (3)
# Q: How long can I borrow a laptop?
# A: You can bring your own, or borrow one of the club laptops if you are eligible. [1]
#    found: faq.md#Do I need my own laptop? (3), laptops.md#Who can borrow (3)
# Q: Who is the treasurer?
# A: I don't know based on the provided documents.
#    found: membership.md#Scholarships (3), about.md#About Mekong Code Club (2)
# Q: What is the Wi-Fi password?
# A: I don't know based on the provided documents.
#    found: laptops.md#Rules (2), about.md#About Mekong Code Club (1)
# Q: Does the club teach Rust?
# A: I don't know based on the provided documents.
#    found: membership.md#How to pay (3), projects.md#Prizes (3)
# Q: What time does the Saturday workshop start?
# A: The main workshop runs every Saturday from 9:00 to 12:00. [1]
#    found: schedule.md#Saturday workshops (4), laptops.md#How long (3)

Everything in this output is stable: there is no randomness in the mock or in the search, so you get the same text with the same files.

The fee question works, and so does the Saturday question. The Wi-Fi and Rust questions are refused, which is correct, because the documents do not answer them. The loop works. But look closer.

10. What the Naive Version Got Wrong

Two answers are not good, and the reasons are the reasons the rest of this series exists.

"How long can I borrow a laptop?" The right passage is laptops.md#How long ("a laptop can be borrowed for up to 7 days"). The search did not even put it in the top two. The question says "laptop" but the passage says "laptops" and "borrowed". Exact word matching cannot see that these are the same idea. The FAQ passage, which mentions "borrow" and "laptop", tied with a passage about who may borrow, and neither says how long. The mock picked a sentence from the FAQ. A user gets an answer that is true but does not answer the question. This is the retrieval miss. Embeddings (chapter 3), better chunks (chapter 2) and keyword ranking with BM25 (chapter 6) all attack this problem.

"Who is the treasurer?" The search counted "who" and "the" as matches (a score of 3 on the Scholarships passage). The mock refused, because after removing small words like those, only one word (treasurer) is shared with the best passage, and the mock needs two. A real model would read the Scholarships passage and answer "Dara". So here the mock is the weak part, not the retrieval. This is exactly why we told you the mock is a stand-in.

Also notice what the code cannot do:

  • it counts useless words like "the" and "for" as matches,
  • it cannot tell a strong match from a lucky one (a score of 3 on the Rust question is pure noise, and nothing stops us from sending that noise to the model),
  • it has no way to check whether the answer was right.

Here is the plan for the series, matching those weaknesses:

WeaknessFixed in
Paragraph splitting is crude, headings get lostChapter 2: chunking
Exact words onlyChapter 3: embeddings, chapter 6: BM25 and hybrid
Brute-force loop, no saved indexChapter 4: vector store
No rules for the model, no real citations, noise sent to the modelChapter 5: grounded prompt and refusal
No way to know if it worksChapter 7: evaluation

11. Recipe: The Setup Files

These are the files chapter 1 owns. Create them exactly like this.

src/config.js

// Settings come from environment variables, with safe local defaults.
export const baseUrl = process.env.LLM_BASE_URL ?? "http://localhost:8787";
export const apiKey = process.env.LLM_API_KEY ?? "test-key";
export const defaultModel = process.env.LLM_MODEL ?? "mock-1";
export const embedModel = process.env.LLM_EMBED_MODEL ?? "mock-embed";

The first three lines are the same as in the chatbot series. The last adds the embedding model name. With a real provider you would set LLM_BASE_URL, LLM_API_KEY, LLM_MODEL and LLM_EMBED_MODEL. Never put a real key in a file you share or commit.

mock-llm.js (copy exactly; 153 lines)

// mock-llm.js — a tiny fake LLM server with a chat-completions-style API.
// Zero dependencies. Run: node mock-llm.js   (default port 8787)
import http from "node:http";

const KEY = "test-key";
let requestCount = 0;

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const estimateTokens = (text) => Math.max(1, Math.ceil(text.length / 4));


const STOP = new Set("a an the is are was were be to of in on at for and or but with by from as it its this that these those i you we they he she what when where who which how do does did can could should would will my your our their me us about into than then there here not no yes if so also any all some more most".split(" "));
const DIM = 256;
export const contentWords = (text) =>
  text.toLowerCase().replace(/[^\p{L}\p{N}\s]/gu, " ").split(/\s+/)
    .filter((w) => w && !STOP.has(w)).map((w) => (w.length > 3 && w.endsWith("s") ? w.slice(0, -1) : w));

function hash(word) { // FNV-1a
  let h = 2166136261;
  for (const ch of word) { h ^= ch.codePointAt(0); h = Math.imul(h, 16777619) >>> 0; }
  return h;
}

// A toy embedding: hash each word into one of 256 slots, then normalise.
export function embedText(text) {
  const v = new Array(DIM).fill(0);
  for (const w of contentWords(text)) v[hash(w) % DIM] += 1;
  const norm = Math.sqrt(v.reduce((s, x) => s + x * x, 0)) || 1;
  return v.map((x) => Number((x / norm).toFixed(6)));
}

// If the last user message has a "Context:" block of [n] passages, answer from it.
function ragReply(content, say) {
  const [ctx, ...rest] = content.split(/\n\s*Question:/);
  const question = rest.join(" ").trim();
  const passages = [...ctx.matchAll(/^\[(\d+)\][^\n]*\n?([\s\S]*?)(?=^\[\d+\]|$(?![\s\S]))/gm)]
    .map((m) => ({ n: m[1], text: m[0].replace(/^\[\d+\]\s*(\(source:[^)]*\))?\s*/, "").trim() }));
  const q = new Set(contentWords(question));
  let best = null;
  for (const p of passages) {
    for (const sentence of p.text.split(/(?<=[.!?])\s+/)) {
      const score = contentWords(sentence).filter((w) => q.has(w)).length;
      if (!best || score > best.score) best = { score, sentence: sentence.trim(), n: p.n };
    }
  }
  if (!best || best.score < 2) return say("I don't know based on the provided documents.");
  return say(`${best.sentence} [${best.n}]`);
}

function makeReply(messages) {
  const users = messages.filter((m) => m.role === "user");
  const last = (users.at(-1)?.content ?? "").toLowerCase();
  const system = messages.find((m) => m.role === "system")?.content ?? "";
  const pirate = system.toLowerCase().includes("pirate");
  const say = (t) => (pirate ? `Arr! ${t}` : t);

  if (/^\s*Context:/.test(users.at(-1)?.content ?? "")) return ragReply(users.at(-1).content, say);
  if (/\bjson\b/.test(last)) return '{"answer": "mock json", "confidence": 0.9}';
  if (/what('| i)s my name|who am i/.test(last)) {
    for (const m of [...users].reverse()) {
      const hit = m.content.match(/my name is (\w+)/i);
      if (hit) return say(`Your name is ${hit[1]}.`);
    }
    return say("I do not know your name yet. You have not told me.");
  }
  if (/\b(hello|hi|hey)\b/.test(last)) return say("Hello! How can I help you today?");
  if (/phnom penh/.test(last)) return say("Phnom Penh is the capital of Cambodia.");
  return say(`You said: ${users.at(-1)?.content ?? ""}`);
}

export function startMock(port = 8787) {
  const server = http.createServer(async (req, res) => {
    const send = (status, obj, headers = {}) => {
      res.writeHead(status, { "content-type": "application/json", ...headers });
      res.end(JSON.stringify(obj));
    };
    if (req.method === "GET" && req.url === "/health") return send(200, { ok: true });
    if (req.method === "POST" && req.url === "/v1/embeddings") {
      if (req.headers.authorization !== `Bearer ${KEY}`)
        return send(401, { error: { message: "invalid api key" } });
      let raw = "";
      for await (const chunk of req) raw += chunk;
      let body;
      try { body = JSON.parse(raw); } catch { return send(400, { error: { message: "body must be valid JSON" } }); }
      const inputs = Array.isArray(body.input) ? body.input : [body.input];
      if (!body.model || inputs.length === 0 || inputs.some((x) => typeof x !== "string" || x === ""))
        return send(400, { error: { message: "model and a non-empty string (or array of strings) input are required" } });
      const tokens = inputs.reduce((n, t) => n + estimateTokens(t), 0);
      return send(200, {
        model: body.model,
        data: inputs.map((t, index) => ({ index, embedding: embedText(t) })),
        usage: { prompt_tokens: tokens, total_tokens: tokens },
      });
    }
    if (req.method !== "POST" || req.url !== "/v1/chat/completions")
      return send(404, { error: { message: "not found" } });

    if (req.headers.authorization !== `Bearer ${KEY}`)
      return send(401, { error: { message: "invalid api key" } });

    let raw = "";
    for await (const chunk of req) raw += chunk;
    let body;
    try { body = JSON.parse(raw); } catch {
      return send(400, { error: { message: "body must be valid JSON" } });
    }
    const { model, messages, stream } = body;
    if (!model || !Array.isArray(messages) || messages.length === 0)
      return send(400, { error: { message: "model and messages are required" } });

    requestCount += 1;
    if (model === "mock-flaky" && requestCount % 3 !== 0)
      return send(429, { error: { message: "rate limit, slow down" } }, { "retry-after": "0" });
    if (model === "mock-error") return send(500, { error: { message: "server exploded" } });
    if (model === "mock-slow") await sleep(3000);

    const text = makeReply(messages);
    const promptTokens = estimateTokens(messages.map((m) => m.content).join(" "));
    const completionTokens = estimateTokens(text);
    const usage = {
      prompt_tokens: promptTokens,
      completion_tokens: completionTokens,
      total_tokens: promptTokens + completionTokens,
    };

    if (!stream) {
      return send(200, {
        id: `mock-${requestCount}`,
        model,
        choices: [{ index: 0, message: { role: "assistant", content: text }, finish_reason: "stop" }],
        usage,
      });
    }

    res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" });
    const event = (obj) => res.write(`data: ${JSON.stringify(obj)}\n\n`);
    event({ id: `mock-${requestCount}`, choices: [{ index: 0, delta: { role: "assistant" } }] });
    for (const piece of text.match(/\S+\s*/g) ?? []) {
      await sleep(40);
      event({ id: `mock-${requestCount}`, choices: [{ index: 0, delta: { content: piece } }] });
    }
    event({ id: `mock-${requestCount}`, choices: [{ index: 0, delta: {}, finish_reason: "stop" }], usage });
    res.write("data: [DONE]\n\n");
    res.end();
  });
  return new Promise((resolve) => server.listen(port, () => resolve(server)));
}

if (import.meta.url === `file://${process.argv[1]}`) {
  const port = Number(process.env.PORT ?? 8787);
  await startMock(port);
  console.log(`mock LLM listening on http://localhost:${port}  (api key: ${KEY})`);
}

The eight documents. All of them are fictional sample data.

docs/about.md

# About Mekong Code Club

Mekong Code Club is a student coding club in Phnom Penh. It was started in
2021 by a group of university students who wanted a friendly place to
learn programming together. The club is fictional and is used only as
sample data for this tutorial.

## Our mission

We help students from grade 9 up to university learn to build real
software. We believe that everyone can learn to code if they have a
patient mentor, a working laptop, and a project they care about.

## Where we meet

The club meets in Room 204 of the Riverside Learning Center, near the
Tonle Sap river. The room has 24 seats, a projector, and fast Wi-Fi. The
building has a lift, so the room is accessible for wheelchair users.

## Who can join

Any student aged 14 to 25 can join. You do not need any experience. Many
of our members wrote their first line of code at the club.

docs/membership.md is the file shown in full in section 7.

docs/schedule.md

# Weekly Schedule

## Saturday workshops

The main workshop runs every Saturday from 9:00 to 12:00. A mentor
teaches one topic, such as HTML, Python, or Git, and then members practice
with exercises. Doors open at 8:30 and close at 9:15, so please arrive on
time.

## Wednesday help desk

On Wednesday evenings from 18:00 to 20:00 the room is open for homework
help. Bring your own project or question. A mentor is always present, but
there is no formal lesson.

## Holidays

The club is closed during Khmer New Year, Pchum Ben, and the Water
Festival. We also close for two weeks at the end of each term. Closing
dates are announced on the notice board and in the members' chat group at
least two weeks ahead.

## Cancelled sessions

If a session is cancelled, for example because of heavy rain or a power
cut, the mentors post a message in the members' chat group before 7:00 on
the same day.

docs/laptops.md

# Laptop Loan Policy

## Who can borrow

Some students do not own a laptop. The club owns eight laptops that paid
members and scholarship members can borrow. You must have been a member
for at least two weeks.

## How long

A laptop can be borrowed for up to 7 days. You can renew it once if nobody
is waiting. Laptops must be returned on a Saturday before the workshop
begins, so the mentors can check them.

## Rules

Do not install games or pirated software on a club laptop. Do not change
the administrator password. Keep food and drinks away from the laptop. If
the laptop is lost or broken because of carelessness, the borrower and a
parent or guardian will discuss a fair repair plan with the club
treasurer.

## Late returns

A laptop returned late loses its borrowing rights for one month. We use
this rule so that everyone gets a fair turn.

docs/conduct.md

# Code of Conduct

## Be kind

Treat every member with respect. We do not allow insults, bullying, or
making fun of someone's questions. Everyone was a beginner once.

## Academic honesty

You may ask mentors and friends for help, but do not copy someone else's
project and present it as your own. When you use code from the internet
or from an AI assistant, say so in your project's README.

## Safety online

Never share passwords or API keys in the members' chat group. Do not run
code from strangers on the club network. If you find a security problem
in a club system, tell a mentor privately.

## What happens if rules are broken

First we talk with the member. A second serious problem leads to a
one-month pause. Repeated or very serious problems can end the
membership. Members can always appeal to the club president, Sophea.

docs/projects.md

# Projects and Showcase Day

## Term projects

Each term, members form teams of three or four and build one project. Past
projects include a bus timetable website, a chatbot that answers school
questions, and a small game about the history of Angkor. Teams choose
their own idea, with advice from a mentor.

## Showcase Day

At the end of each term the club holds Showcase Day. Teams present their
projects to parents, teachers, and local developers. Each team has 7
minutes to present and 3 minutes for questions. A panel of three guest
judges gives feedback, and the audience votes for the Favourite Project.

## Prizes

The winning team receives a small prize, such as a book voucher. The club
does not give cash prizes. Every participating member receives a
certificate.

## Sharing your work

Projects are published on the club's GitHub organization with an open
source licence, unless the team decides to keep it private. Never publish
personal data of other people in a project.

docs/mentors.md

# Mentors

## Who our mentors are

Our mentors are volunteers: university students and working developers.
In the current term there are six mentors. They teach Python, web
development, mobile apps, and the basics of computers.

## Becoming a mentor

Members who have completed two terms can apply to become a junior mentor.
Junior mentors help during Saturday workshops and receive a mentor
certificate and a reference letter. Experienced developers who want to
volunteer can contact the club president, Sophea, to arrange a trial
session.

## What mentors do not do

Mentors do not do homework for you, and they do not take payment for
private lessons from members. If anyone asks you to pay a mentor
privately, please tell the club president.

## Mentor meeting

Mentors meet on the first Wednesday of every month at 19:00 to plan the
next topics. Members may suggest topics by writing on the notice board.

docs/faq.md

# Frequently Asked Questions

## How do I contact the club?

Send an email to hello@mekongcodeclub.example or message the club on its
public page. Emails are answered within three working days. This address
is fictional and used as sample data only.

## Do I need my own laptop?

No. You can bring your own, or borrow one of the club laptops if you are
eligible. See the laptop loan policy.

## Can my parents visit?

Parents are welcome on Showcase Day. For normal workshops, parents may
wait in the lobby on the ground floor, because the room has only 24 seats.

## Is the club connected to a school?

No. The club is independent and not part of any school or company. It is
run by volunteers and paid for by membership fees and small donations.

## What programming languages do you teach?

We mostly teach Python and JavaScript. Other languages appear only in
special workshops.

If you write your own versions of the documents, your outputs will differ from ours. For the tests in later chapters it is best to copy these exactly.

12. Try It Yourself

  1. Break the baseline. Change the question in demo-baseline.js to "hello", then to "what is my name?". What does the mock do, and why does it prove that the mock is not a real model?
  2. Add your own document. Write docs/transport.md about how to get to Room 204 (invent it). Ask naive-rag.js a question about it. Did the right paragraph win?
  3. Change k. In naive-rag.js, change k = 2 to 1, then to 5. How does the "found" list change? Does the mock's answer change? Why is a larger k not automatically better?
  4. Find another retrieval miss. Ask "When does the club open?" and "Can parents come?". Print which passages were found. Which word differences caused any misses?
  5. Count the noise. Add a line that prints how many passages have a score of 1 for the question "Does the club teach Rust?". What does that tell you about using "score greater than 0" as a rule?
  6. Estimate the cost. Change the numbers in demo-context-math.js to a corpus of your own (for example, 300 pages). Use a price you look up yourself and label it clearly as an example.

13. Quick Review: Common Questions

Questions beginners often ask about this chapter. Try answering each one yourself first, then click the question to open the answer.

Is RAG the same as giving the model memory?

No. The model remembers nothing between requests. RAG does not change the model at all. It looks up text and puts it into the prompt, so the model can read it right now. If the retrieval picks the wrong text, the model has the wrong page open, and the answer suffers.

Why not just fine-tune the model on our documents?

Fine-tuning is poor for facts that change, cannot show where an answer came from, and is hard to undo when a document must be removed. RAG lets you edit a file, rebuild the index, and have the new answer immediately, with a citation. Fine-tuning is better suited to changing style or format.

Does RAG stop hallucination completely?

No. It reduces it, because the model has the real text in front of it. But the model can still ignore the passages, misread them, or answer when the retrieval found nothing useful. That is why we add a "say you do not know" rule, citations and an evaluation set in later chapters.

How big does my data need to be before RAG makes sense?

If everything fits comfortably in the model's context and you do not mind paying for it every time, pasting it all is simpler. RAG starts to pay off when the data is large, changes often, or when different users may see different documents. There is no magic number, so test both.

Do I need an expensive vector database?

Not to start. In this series the whole index is a JSON file and a loop. That is fine for thousands of chunks. A dedicated database matters later, for large data, many users or frequent updates (chapter 4).

Can the model read documents in Khmer?

Often yes, and quality varies by model. Khmer needs special care in the chunking step, because it is written without spaces between words, and you need an embedding model that supports Khmer. We handle chunking in chapter 2 and multilingual embeddings in chapter 3.

Where to go next. You now know why RAG exists, how the pipeline is shaped, and you have a project, a mock server, a sample corpus and a crude working loop. In the next chapter, Loading and Chunking Documents, you replace the paragraph splitting with proper loading and chunking: splitting by heading, by size with overlap, and keeping the metadata you will need for citations.

Buy Me a Coffee

Found this tutorial helpful? Consider buying me a coffee to support more free content!