However clever an LLM is, its knowledge is frozen at the moment its training data was collected. Ask it about your company's internal information or an event from yesterday, and it will either be wrong or invent something.

The RAG (Retrieval-Augmented Generation) architecture fixes that weakness: instead of retraining the model, we find the passage that contains the answer and put it straight into the prompt. This lesson builds the complete five-stage RAG pipeline, implements TF-IDF and cosine similarity by hand in plain Python, and then measures the two places that pipeline breaks — both of which you will meet again in a production system.

✅ What you need before starting
Software: Ollama running with at least one chat model — exactly the setup from Lesson 13, which the generation step calls into. In addition, if you pull an embedding model (ollama pull bge-m3, around 1.2 GB) then section 14.5 can run the most important comparison in the lesson; without it the program still runs and simply skips that part.

Python libraries: none. TF-IDF and cosine similarity are written by hand rather than imported from scikit-learn, so the maths is not hidden behind a library.

Knowledge you need: Lesson 13 for calling Ollama; Lesson 11 for what a token is and why you cannot stuff an entire document set into the prompt; Lesson 8 for the idea of representing text as a vector — section 14.4 uses a far cruder method, and section 14.5 measures what that crudeness costs.

14.1 Why does an LLM need RAG? Solving hallucination

Hallucination is a neural model confidently producing false statements, fluently and persuasively. The cause is that an LLM works by predicting the next word probabilistically; it has no fact-checking mechanism against objective reality.

RAG turns the exam the model is sitting from "recite what you memorised" into "open-book comprehension". When a user asks a question, the system performs two phases:

  1. Phase 1 (Retrieval): search the internal document store for the passages whose keywords or meaning are closest to the question.
  2. Phase 2 (Generation): insert those passages as reference context, send them with the question, and instruct the model: "Answer using only the following context...".
⚠️ Pitfall: the "lost in the middle" effect
A well-known study shows that LLMs attend well to information at the start and the end of a prompt, but neglect what sits in the middle. If you retrieve too many loosely related documents and cram them all in, you both increase the token cost and reduce answer quality, because the important information drifts into that blind spot. This is why the retrieval phase must return few but correct passages — not as many as possible.

14.2 The standard RAG pipeline

An industry-standard RAG system has five consecutive stages:

🗺️ The 5 stages of a RAG pipeline:

  1. 1. Ingestion: read raw data out of whatever formats you have (PDF, DOCX, TXT, HTML) and clean up the formatting.
  2. 2. Chunking: cut long text into small blocks that fit the model's token budget and stay semantically focused.
  3. 3. Embedding: turn each raw passage into a numeric vector representing its meaning.
  4. 4. Retrieval: when a question arrives, turn it into a vector and compare its angle (cosine similarity) against every document vector to find the closest passages.
  5. 5. Generation: load the retrieved passages into a prompt for the LLM (here, the local Ollama from Lesson 13) to compose the final answer.

The two stages that sound most trivial — steps 2 and 3 — are exactly where the system breaks. Sections 14.3 and 14.4 explain them, and section 14.5 measures the real damage.

14.3 Splitting text into chunks

Loading an entire 500-page book into the prompt overflows the context window (and you pay for all those tokens on every turn — Lesson 11). So we have to split. Two core parameters:

  • Chunk size: the maximum number of characters or words in one chunk (150 characters, say).
  • Chunk overlap: the number of characters repeated at the boundary between two adjacent chunks (30 characters, say).

Why is overlap necessary? If you cut mechanically at character 150, an important sentence can be sliced in half. Overlap keeps the context flowing across the boundary.

That sounds convincing — and section 14.5 will show it is not enough. On this project's own corpus, a 30-character overlap still severs a rule from the condition it depends on, so the final answer loses its "if" clause. Keep that question in mind as you read on.

14.4 The mathematics behind TF-IDF & cosine similarity

To let the computer decide which passage best matches the question without a deep learning model, we start with the classic TF-IDF (Term Frequency - Inverse Document Frequency) algorithm. The underlying idea is homely: a word characterises a passage when it appears often in that passage but is rare across the whole corpus.

📐 The TF-IDF formula
TF-IDF scores the importance of a term $t$ in a document $d$ belonging to a corpus $D$:
  1. Term frequency ($\text{TF}$): how often the term appears in the passage: $$\text{TF}(t, d) = \frac{f_{t,d}}{\sum_{t'} f_{t',d}}$$ (occurrences divided by the passage's total word count).
  2. Inverse document frequency ($\text{IDF}$): how rare the term is across the whole corpus: $$\text{IDF}(t, D) = \log\left(\frac{|D|}{1 + |\{d \in D : t \in d\}|}\right)$$ A term appearing in too many passages (connectives like "and", "is", "the") has an IDF tending to 0. Distinctive keywords carrying real information score much higher.
  3. The combined score: $$\text{TF-IDF}(t, d, D) = \text{TF}(t, d) \times \text{IDF}(t, D)$$
Once we have TF-IDF vectors for the question $A$ and a passage $B$, we measure directional similarity with cosine similarity: $$\text{Cosine Similarity}(A, B) = \frac{A \cdot B}{\|A\|_2 \|B\|_2} = \frac{\sum A_i B_i}{\sqrt{\sum A_i^2} \sqrt{\sum B_i^2}}$$
🔢 Working TF-IDF out by hand on a tiny corpus
Suppose the corpus $D$ has only 3 passages: passage 0 about "employee leave", passage 1 about "sick leave and doctors", passage 2 about "director approval". Computing TF-IDF for two words in passage 0:
  • The word "leave" (present in passages 0 and 1, i.e. 2 of 3 documents): $\text{TF} = 0.25$, but $\text{IDF} = \log(3/3) = 0$ → $\text{TF-IDF} = 0$. The word is wiped out entirely because it appears in too many passages relative to corpus size.
  • The word "annual" (only in passage 0, i.e. 1 of 3 documents): $\text{TF} = 0.25$ (identical to "leave"), but $\text{IDF} = \log(3/2) \approx 0.405$ → $\text{TF-IDF} \approx 0.1014$.
Two words with the same in-passage frequency (identical TF) end up with completely different final scores — a direct demonstration that IDF is what decides which word genuinely characterises a passage.
⚡ The core limitation: TF-IDF counts words, it does not understand them
Look carefully at the formula: from start to finish, TF-IDF does exactly one thing — count shared words. It has no concept of meaning at all. To it, "car" and "automobile" are completely unrelated strangers, while two sentences that merely happen to share the word "company" look highly similar. Section 14.5 shows exactly how that blind spot produces a wrong answer — and measures the distance between it and a real semantic embedding.

Before the project, here is an interactive playground for getting a feel for the vector space: type a question, see where the passages sit relative to it, and watch similarity change as you change words. If the maths above still feels abstract, a few minutes here will help more than rereading the formula.

14.5 Lesson 14 project: the RAG pipeline, and the two places it breaks

The project builds all five stages from section 14.2 over a small corpus: a company's leave policy. It runs two questions — one that is in the documents ("who approves 10 days of leave?") and one that definitely is not ("what year was the company founded?") — and measures how the system handles each.

simple_rag.py
"""Lesson 14 project: a complete RAG pipeline, and a measurement of its weak spot.

Run:  python3 simple_rag.py
Needs: Ollama running (Lesson 13) for the generation step.
Optional: an embedding model (`ollama pull bge-m3`) for the comparison in part 4.

TF-IDF and cosine similarity are written out by hand rather than imported from
scikit-learn, so the maths stays visible.
"""

import json
import math
import re
import urllib.error
import urllib.request

OLLAMA = "http://localhost:11434"
CHAT_PREFERRED = ["qwen2.5:7b", "qwen2.5-coder:7b", "llama3.2", "llama3.1", "gemma2"]
EMBED_PREFERRED = ["bge-m3", "nomic-embed-text", "mxbai-embed-large"]

# The internal knowledge base. In a real system this is read from .txt/.pdf files.
KNOWLEDGE_BASE = """
Quy trình xin nghỉ phép của công ty JS-Tools:
Nhân viên cần gửi đơn xin nghỉ phép trước tối thiểu 3 ngày làm việc đối với nghỉ phép năm thông thường.
Trong trường hợp nghỉ ốm đột xuất, nhân viên phải thông báo cho quản lý trực tiếp qua Slack trước 9h00 sáng của ngày nghỉ và nộp giấy xác nhận của bác sĩ khi quay trở lại làm việc.
Nếu nghỉ phép dài hạn trên 5 ngày, đơn nghỉ phép bắt buộc phải được ký phê duyệt bởi Giám đốc điều hành (CEO).
Mọi đơn từ xin nghỉ phép đều phải được nhập dữ liệu chính thức lên hệ thống HR-Portal trực tuyến của công ty để bộ phận nhân sự chấm công cuối tháng.
"""

IN_SCOPE = "Tôi muốn nghỉ 10 ngày thì ai duyệt đơn nghỉ phép?"
OUT_OF_SCOPE = "Công ty thành lập vào năm nào?"


# ---------------------------------------------------------------------------
# Part 1 - chunking
# ---------------------------------------------------------------------------


def chunk_text(text, chunk_size=150, overlap=30):
    """Cut text into overlapping windows of characters."""
    if overlap >= chunk_size:
        # Without this guard the stride below is <= 0 and the loop never ends.
        raise ValueError("overlap must be smaller than chunk_size")
    chunks, start = [], 0
    while start < len(text):
        chunk = text[start:start + chunk_size].strip()
        if chunk:
            chunks.append(chunk)
        start += chunk_size - overlap
    return chunks


def chunk_by_sentence(text, max_chars=250):
    """Cut on sentence boundaries instead of on a fixed character grid.

    A rule like "if the leave is longer than 5 days, the CEO must sign it" only
    works when the condition and the consequence stay in the same chunk.
    """
    parts = [p.strip() for p in
             re.split(r"(?<=[.:!?])\s*\n|(?<=[.!?])\s+", text) if p.strip()]
    chunks, current = [], ""
    for part in parts:
        if current and len(current) + 1 + len(part) > max_chars:
            chunks.append(current)
            current = part
        else:
            current = f"{current} {part}".strip()
    if current:
        chunks.append(current)
    return chunks


# ---------------------------------------------------------------------------
# Part 2 - TF-IDF and cosine similarity, by hand
# ---------------------------------------------------------------------------


class SimpleTFIDF:
    """A minimal TF-IDF vectoriser fitted on one list of documents."""

    def __init__(self, documents):
        self.documents = [self._tokenize(doc) for doc in documents]
        self.vocab = sorted({word for doc in self.documents for word in doc})
        self.idf = self._calculate_idf()

    def _tokenize(self, text):
        return re.findall(r"\b\w+\b", text.lower())

    def _calculate_idf(self):
        idf = {}
        total_docs = len(self.documents)
        for term in self.vocab:
            containing = sum(1 for doc in self.documents if term in doc)
            # A term in every document scores 0 or below: it separates nothing.
            idf[term] = math.log(total_docs / (1 + containing))
        return idf

    def transform(self, text):
        tokens = self._tokenize(text)
        if not tokens:
            return [0.0] * len(self.vocab)
        return [tokens.count(term) / len(tokens) * self.idf.get(term, 0.0)
                for term in self.vocab]


def cosine_similarity(v1, v2):
    """The angle between two vectors, ignoring their lengths."""
    dot = sum(a * b for a, b in zip(v1, v2))
    norm1 = math.sqrt(sum(a * a for a in v1))
    norm2 = math.sqrt(sum(b * b for b in v2))
    if norm1 == 0 or norm2 == 0:
        return 0.0
    return dot / (norm1 * norm2)


def rank(query_vector, chunk_vectors):
    """Score every chunk and return (score, index) sorted best first."""
    scored = [(cosine_similarity(query_vector, vector), index)
              for index, vector in enumerate(chunk_vectors)]
    return sorted(scored, reverse=True)


# ---------------------------------------------------------------------------
# Part 3 - talking to Ollama (the pattern from Lesson 13)
# ---------------------------------------------------------------------------


def installed_models():
    """Names of the models this Ollama has, or [] if it is not reachable."""
    try:
        with urllib.request.urlopen(f"{OLLAMA}/api/tags", timeout=5) as response:
            return [m["name"] for m in json.loads(response.read())["models"]]
    except urllib.error.URLError:
        return []


def pick(names, preferred):
    for wanted in preferred:
        for name in names:
            if name == wanted or name.startswith(wanted + ":"):
                return name
    return None


def post(path, payload):
    """POST JSON, and keep the two failure modes distinguishable."""
    request = urllib.request.Request(
        f"{OLLAMA}{path}", data=json.dumps(payload).encode("utf-8"),
        headers={"Content-Type": "application/json"},
    )
    try:
        with urllib.request.urlopen(request) as response:
            return json.loads(response.read())
    except urllib.error.HTTPError as exc:
        detail = json.loads(exc.read() or b"{}").get("error", "no detail")
        raise RuntimeError(f"Ollama answered HTTP {exc.code}: {detail}") from None
    except urllib.error.URLError as exc:
        raise RuntimeError(f"cannot reach Ollama at {OLLAMA} - {exc.reason}") from None


def embed(text, model):
    """One embedding vector from a real embedding model."""
    return post("/api/embeddings", {"model": model, "prompt": text})["embedding"]


def generate_answer(question, context, model):
    """Step 5: hand the retrieved context to the model and forbid guessing."""
    prompt = (
        "Hãy trả lời câu hỏi dựa duy nhất vào phần Ngữ cảnh dưới đây. "
        "Nếu thông tin không có trong ngữ cảnh, hãy trả lời đúng câu "
        "'Tôi không tìm thấy thông tin này trong tài liệu'.\n\n"
        f"Ngữ cảnh:\n{context}\n\nCâu hỏi: {question}\nCâu trả lời của bạn:"
    )
    payload = {
        "model": model,
        "messages": [{"role": "user", "content": prompt}],
        "stream": False,
    }
    return post("/api/chat", payload)["message"]["content"].strip()


# ---------------------------------------------------------------------------
# Part 4 - the pipeline, and the measurement that exposes its weak spot
# ---------------------------------------------------------------------------


def show_ranking(label, question, scores, chunks):
    print(f"  {label} - {question}")
    for score, index in scores[:2]:
        print(f"    {score:.4f}  chunk {index}: {chunks[index][:52]!r}")


def compare_retrievers(chunks, embed_model):
    """Score both questions with TF-IDF, then with real embeddings.

    Returns the top score of each question under each retriever, so the claim
    at the end is checked rather than asserted in prose.
    """
    engine = SimpleTFIDF(chunks)
    tfidf_vectors = [engine.transform(c) for c in chunks]

    print("=== Retriever 1: TF-IDF (keyword overlap) ===")
    tfidf_top = {}
    for question in (IN_SCOPE, OUT_OF_SCOPE):
        scores = rank(engine.transform(question), tfidf_vectors)
        tfidf_top[question] = scores[0][0]
        show_ranking("tf-idf", question, scores, chunks)
    print()

    if embed_model is None:
        print("=== Retriever 2: skipped - no embedding model installed ===")
        print("    Install one with `ollama pull bge-m3` to run the comparison.\n")
        return tfidf_top, None

    print(f"=== Retriever 2: {embed_model} (real semantic embeddings) ===")
    chunk_vectors = [embed(c, embed_model) for c in chunks]
    print(f"  vector dimension: {len(chunk_vectors[0])}")
    embed_top = {}
    for question in (IN_SCOPE, OUT_OF_SCOPE):
        scores = rank(embed(question, embed_model), chunk_vectors)
        embed_top[question] = scores[0][0]
        show_ranking("embed ", question, scores, chunks)
    print()
    return tfidf_top, embed_top


def report_separation(tfidf_top, embed_top):
    """Can a similarity threshold tell the two questions apart?"""
    print("=== Can a threshold reject the out-of-scope question? ===")
    for label, top in (("tf-idf", tfidf_top), ("embeddings", embed_top)):
        if top is None:
            continue
        good, bad = top[IN_SCOPE], top[OUT_OF_SCOPE]
        gap = good - bad
        verdict = "YES" if gap > 0 else "NO - the wrong question scores higher"
        print(f"  {label:<11} in-scope {good:.4f}  out-of-scope {bad:.4f}"
              f"  gap {gap:+.4f}  -> {verdict}")
    print()


def run_pipeline(question, chunks, chat_model, embed_model, threshold=0.5):
    """The full five stages, with a retrieval threshold this time."""
    if embed_model:
        vectors = [embed(c, embed_model) for c in chunks]
        scores = rank(embed(question, embed_model), vectors)
        scorer = embed_model
    else:
        engine = SimpleTFIDF(chunks)
        scores = rank(engine.transform(question), [engine.transform(c) for c in chunks])
        scorer = "tf-idf"
    best_score, best_index = scores[0]
    print(f"  retrieved with {scorer}: {best_score:.4f}")

    if best_score < threshold:
        print(f"  below the {threshold} threshold - refusing to answer, and no")
        print("  tokens are spent calling the model at all.")
        return None
    print(f"  context: {chunks[best_index][:64]!r}")
    answer = generate_answer(question, chunks[best_index], chat_model)
    print(f"  answer : {answer}")
    return answer


def compare_chunking(chat_model, embed_model):
    """Same question, same model, same retriever - only the chunking changes."""
    print("=== Does the chunking change the answer? ===")
    grid = chunk_text(KNOWLEDGE_BASE, chunk_size=150, overlap=30)
    sentences = chunk_by_sentence(KNOWLEDGE_BASE)

    # The condition and its consequence are one sentence in the source. Does
    # each chunker keep them together? This part is deterministic.
    condition = "trên 5 ngày"
    grid_hit = next((c for c in grid if "Giám đốc điều hành" in c), "")
    sentence_hit = next((c for c in sentences if "Giám đốc điều hành" in c), "")
    print(f"  character grid  -> {len(grid)} chunks; the chunk naming the CEO "
          f"starts {grid_hit[:26]!r}")
    print(f"                     does it also contain '{condition}'? "
          f"{condition in grid_hit}")
    print(f"  sentence-aware  -> {len(sentences)} chunks; the chunk naming the CEO "
          f"starts {sentence_hit[:26]!r}")
    print(f"                     does it also contain '{condition}'? "
          f"{condition in sentence_hit}")
    print()

    answers = {}
    for label, chunks in (("character grid", grid), ("sentence-aware", sentences)):
        print(f"  --- {label} ---")
        answers[label] = run_pipeline(IN_SCOPE, chunks, chat_model, embed_model)
        print()

    # Structural, so it holds on every run regardless of what the model says.
    assert condition not in grid_hit, \
        "the grid chunk was expected to have lost the condition"
    assert condition in sentence_hit, \
        "the sentence chunk was expected to keep the condition"
    print("  PASS - the grid chunk states who approves but not WHEN it applies;")
    print("         the sentence chunk keeps the condition attached to the rule.")
    print("  The model's wording varies between runs; the missing condition does not.")
    return answers


def main():
    chunks = chunk_text(KNOWLEDGE_BASE, chunk_size=150, overlap=30)
    print(f"=== Chunking ===\n  {len(chunks)} chunks of at most 150 characters,"
          f" overlapping by 30\n")

    names = installed_models()
    if not names:
        print("Ollama is not reachable. Start it, then run this again.")
        return
    chat_model = pick(names, CHAT_PREFERRED) or names[0]
    embed_model = pick(names, EMBED_PREFERRED)

    tfidf_top, embed_top = compare_retrievers(chunks, embed_model)
    report_separation(tfidf_top, embed_top)

    print("=== The out-of-scope question, end to end ===")
    run_pipeline(OUT_OF_SCOPE, chunks, chat_model, embed_model)
    print()

    answers = compare_chunking(chat_model, embed_model)

    # The two claims this lesson makes, checked instead of asserted.
    assert tfidf_top[IN_SCOPE] < tfidf_top[OUT_OF_SCOPE], \
        "TF-IDF was expected to rank the out-of-scope question higher here"
    if embed_top:
        assert embed_top[IN_SCOPE] > embed_top[OUT_OF_SCOPE], \
            "embeddings were expected to rank the in-scope question higher"
        print("PASS - on this corpus TF-IDF ranks the wrong question higher,")
        print("       and semantic embeddings put it back in the right order.")


if __name__ == "__main__":
    main()

Break number one: TF-IDF scores the wrong question higher than the right one

This is the real output, and it is not what you would hope for:

Terminal
=== Retriever 1: TF-IDF (keyword overlap) ===
  tf-idf - Tôi muốn nghỉ 10 ngày thì ai duyệt đơn nghỉ phép?
    0.2414  chunk 3: 'ngày, đơn nghỉ phép bắt buộc phải được ký phê duyệt '
    0.0149  chunk 0: 'Quy trình xin nghỉ phép của công ty JS-Tools:\nNhân v'
  tf-idf - Công ty thành lập vào năm nào?
    0.2501  chunk 0: 'Quy trình xin nghỉ phép của công ty JS-Tools:\nNhân v'
    0.2357  chunk 4: 'c nhập dữ liệu chính thức lên hệ thống HR-Portal trự'

=== Retriever 2: bge-m3:latest (real semantic embeddings) ===
  vector dimension: 1024
  embed  - Tôi muốn nghỉ 10 ngày thì ai duyệt đơn nghỉ phép?
    0.6958  chunk 3: 'ngày, đơn nghỉ phép bắt buộc phải được ký phê duyệt '
    0.6849  chunk 2: 'ck trước 9h00 sáng của ngày nghỉ và nộp giấy xác nhậ'
  embed  - Công ty thành lập vào năm nào?
    0.3466  chunk 4: 'c nhập dữ liệu chính thức lên hệ thống HR-Portal trự'
    0.3364  chunk 3: 'ngày, đơn nghỉ phép bắt buộc phải được ký phê duyệt '

=== Can a threshold reject the out-of-scope question? ===
  tf-idf      in-scope 0.2414  out-of-scope 0.2501  gap -0.0087  -> NO - the wrong question scores higher
  embeddings  in-scope 0.6958  out-of-scope 0.3466  gap +0.3492  -> YES

Read the last two lines carefully. With TF-IDF, the out-of-corpus question ("what year was the company founded?") scores $0.2501$, higher than the on-topic question ($0.2414$) — purely because it happens to share the word "company" with the opening passage. The consequence is severe: no threshold exists that accepts the right question and rejects the wrong one, because the ordering itself is inverted. Wherever you put the cut, you either lose the good question or admit the bad one.

With a real semantic embedding (the bge-m3 model, 1024 dimensions, running on the same Ollama from Lesson 13), the distance opens up to $0.6958$ against $0.3466$ — a gap of $0.3492$. Now a threshold at $0.5$ cleanly accepts the right question and refuses the wrong one. That is the whole reason real RAG systems do not use TF-IDF as their primary retriever.

💡 Refusing to answer is a feature too
With a threshold in place, the pipeline handles the out-of-scope question like this:
=== The out-of-scope question, end to end ===
  retrieved with bge-m3:latest: 0.3466
  below the 0.5 threshold - refusing to answer, and no
  tokens are spent calling the model at all.
Two benefits at once: the user gets an honest "I do not have this information" rather than a fluent fabrication, and you spend no tokens at all — because the system stops before calling the model. In a real product, most junk questions are eliminated right here.

Break number two: cutting at character 150 drops the "if" clause

This break is far subtler, and it is why section 14.3 asked you to keep a question in mind. The corpus contains one complete rule: "If the leave is longer than 5 days, the leave request must be signed off by the Chief Executive Officer (CEO)." Cut on a 150-character grid with a 30-character overlap, that sentence lands on a boundary and is sliced in two:

Terminal
=== Does the chunking change the answer? ===
  character grid  -> 5 chunks; the chunk naming the CEO starts 'ngày, đơn nghỉ phép bắt bu'
                     does it also contain 'trên 5 ngày'? False
  sentence-aware  -> 4 chunks; the chunk naming the CEO starts 'Nếu nghỉ phép dài hạn trên'
                     does it also contain 'trên 5 ngày'? True

  --- character grid ---
  retrieved with bge-m3:latest: 0.6958
  context: 'ngày, đơn nghỉ phép bắt buộc phải được ký phê duyệt bởi Giám đốc'
  answer : Theo ngữ cảnh, đơn nghỉ phép bắt buộc phải được ký phê duyệt bởi Giám đốc điều hành (CEO).

  --- sentence-aware ---
  retrieved with bge-m3:latest: 0.7185
  context: 'Nếu nghỉ phép dài hạn trên 5 ngày, đơn nghỉ phép bắt buộc phải đ'
  answer : Theo ngữ cảnh cung cấp, nếu bạn muốn nghỉ phép dài hạn trên 5 ngày (trong trường hợp này là 10 ngày), đơn nghỉ phép bắt buộc phải được ký phê duyệt bởi Giám đốc điều hành (CEO).

At a glance the grid version's answer looks right — it names the CEO correctly. But it has dropped the conditional clause: reading it, a user concludes that every leave request needs the CEO's signature, even a single day. The actual policy only applies above 5 days. The sentence-aware version keeps the "if" and applies it correctly to the 10-day case.

⚠️ This is the most dangerous class of RAG failure: right words, wrong meaning
An empty answer is something anyone spots. An answer that quotes the document correctly but omits the condition under which it applies looks entirely trustworthy — and no automated check in the system raises the alarm, because retrieval still scored $0.6958$, the model still obeyed the prompt, and no exception was thrown anywhere. The source of the error sits in the one place nobody suspects: the text-splitting step.

That is why the check at the end of the project does not test the model's wording (which changes on every run) but a deterministic condition: does the chunk naming the approver also contain the condition that triggers it? With the character grid that is False, with sentence-aware chunking True, on every run.

How to run this project on your machine

  1. Start Ollama with a chat model available (Lesson 13). To run the most important comparison, also pull an embedding model: ollama pull bge-m3 (around 1.2 GB). Without it the program skips the comparison section and says so explicitly.
  2. Run python3 simple_rag.py. The TF-IDF numbers will match this lesson exactly (the algorithm is deterministic); the embedding numbers match if you use the same model; the LLM's wording will differ.
  3. Then try breaking it three ways:
    • Change chunk_size=150 to 400. The rule now fits inside one chunk and the dropped-condition bug disappears — but each chunk costs more tokens. This is exactly the trade-off Lesson 15 measures systematically.
    • Lower threshold from 0.5 to 0.3. The out-of-scope question gets through and is sent to the model. See what it answers — and remember that the only remaining line of defence at that point is the instruction in the prompt.
    • Add a passage on a completely different topic to KNOWLEDGE_BASE (parking rules, say). Run again and watch how the out-of-scope question's TF-IDF score moves — IDF depends on the whole corpus, so adding a document changes the score of every question.

Lesson summary & what comes next

🔑 What you achieved:
  • Achieved: building the complete five-stage RAG pipeline, with TF-IDF and cosine similarity written by hand in plain Python.
  • Achieved: measuring the limit of keyword retrieval: on this very corpus, TF-IDF scores the out-of-scope question at $0.2501$, above the on-topic question's $0.2414$, so no threshold can save it.
  • Achieved: seeing a real semantic embedding widen that gap to $0.3492$, enough for a $0.5$ threshold to reject the out-of-scope question without spending a single token.
  • Achieved: recognising the most dangerous class of RAG failure — chunking that severs a condition from its rule, producing an answer that quotes correctly but applies far too broadly.

Bridge to the next lesson: this lesson showed that how you cut text decides whether the answer is right, and compared two strategies by eye. Lesson 15 does it systematically: chunking strategies, and the internal structure of a vector database once the corpus is not 5 passages but 5 million.

Download the practice code for this lesson

The Python file simple_rag.py — the five-stage pipeline, hand-written TF-IDF and cosine similarity, and the two measurements that expose where it breaks (run python3 simple_rag.py):

Download simple_rag.py

📖 Further reading

Related lessons in this series

Lesson 13: Running an LLM offline with Ollama Lesson 15: Chunking strategies & vector databases in depth Back to the Practical AI Engineer roadmap

Comments