Click to run and edit this dialog:

Run in SolveIt

Homework: Agentic RAG

In this homework, we build a RAG system from scratch and then make it agentic - the same path as the module.

Instead of the course FAQ, our knowledge base is the course lessons themselves.

The course repository is organized by module. Each module is a top-level folder with a lessons/ subfolder of numbered markdown pages:

01-agentic-rag/
└── lessons/
    ├── 01-intro.md
    ├── 02-environment.md
    ├── ...
    └── 16-other-frameworks.md

There are seven modules:

  • 01-agentic-rag
  • 02-vector-search
  • 03-orchestration
  • 04-evaluation
  • 05-monitoring
  • 06-best-practices
  • 07-project-example

Each lesson page is a single markdown file. These pages are exactly what you read as you go through the course.

We'll fetch this data from GitHub and use it as the knowledge base for our RAG system.

It's possible your answers won't match exactly. If so, select the closest one.

Setup

Prepare your environment the same way as in the module's Environment lesson.

This homework needs one extra library: gitsource, which downloads files from a GitHub repository.

Install it:

uv add gitsource

For the LLM, we recommend OpenAI with gpt-5.4-mini, but you can use any model and provider you like - just adapt the client and the usage fields accordingly.

Preparation

First, we will pull the lesson pages straight from the course repository. We will use the commit 8c1834d to make sure everyone works with the exact same data.

We will use gitsource for that:

from gitsource import GithubRepositoryDataReader

reader = GithubRepositoryDataReader(
    repo_owner="DataTalksClub",
    repo_name="llm-zoomcamp",
    commit_id="8c1834d",
    allowed_extensions={"md"},
    filename_filter=lambda path: "/lessons/" in path,
)

files = reader.read()

GithubRepositoryDataReader downloads the entire repository and goes over all the files in it. Because we specify allowed_extensions={"md"}, it only checks the markdown files.

We also pass a filename_filter so we don't grab every markdown file in the repo, like the top-level README. The lesson pages all live under a module's lessons/ folder, so filtering on /lessons/ keeps just those.

Each file has a parse() method that returns a dictionary with its filename and content:

documents = []

for file in files:
    doc = file.parse()
    documents.append(doc)

Q1. How many lesson pages

How many lesson pages are in the dataset?

  • 24
  • 72
  • 240
  • 720

Q2. Indexing and searching

Index the documents with minsearch - make content a text field and filename a keyword field. Then search with this query:

How does the agentic loop keep calling the model until it stops?

What's the filename of the first result?

  • 01-agentic-rag/lessons/03-rag.md
  • 01-agentic-rag/lessons/14-agentic-loop.md
  • 04-evaluation/lessons/13-llm-as-judge.md
  • 06-best-practices/lessons/02-hybrid-search.md

Q3. RAG

Now we will build a RAG assistant on top of this data. Let's use the rag helper script we prepared during the lessons:

wget https://raw.githubusercontent.com/DataTalksClub/llm-zoomcamp/main/01-agentic-rag/code/rag_helper.py

RAGBase was written for the FAQ schema (section/question/answer), while our documents have filename and content.

Two solutions are possible:

  • Implement the RAG flow yourself
  • Take RAGBase and change the parts related to the FAQ schema - search (to use our index) and build_context

Build a RAG over the index from Q2 and answer the query:

How does the agentic loop keep calling the model until it stops?

Use gpt-5.4-mini. How many input (prompt) tokens did we send to the model for this request?

  • 700
  • 7000
  • 70000
  • 700000

We count input tokens instead of price because the cost depends on the model and provider you use, but the size of the prompt we send is the same for everyone.

Most LLM APIs report token usage on the response object (e.g. response.usage.input_tokens / prompt_tokens). We'll read the input tokens from there.

You will need to modify the code for the rag helper to expose the usage.

In the RAG Helper class, llm returns only the text. Modify it to return the whole response, and change rag to return both the answer and usage (as a tuple or create a small dataclass for that).

Q4. Chunking

The lesson pages are long - some are thousands of characters. Long documents make retrieval less precise: a match deep inside a page still pulls in the whole page. A common fix is chunking: split each page into smaller, overlapping pieces and index those instead.

gitsource has a helper for this: chunk_documents. It uses a sliding window - a window of size characters slides across the text in steps of step characters, and each window position becomes one chunk:

from gitsource import chunk_documents

chunks = chunk_documents(documents, size=2000, step=1000)

With size=2000 and step=1000 (you can see the implementation here):

  • Each chunk is a window of size characters of the page.
  • The window moves forward by step characters between chunks. Since step is smaller than size, consecutive chunks overlap by size - step (1000) characters, so a passage split across a boundary still appears whole in one of the chunks.
  • Every chunk keeps the original fields (filename) and adds start (the offset in the page) and content (the chunk text).

How many chunks do you get?

  • 70
  • 295
  • 1100
  • 4500

Q5. RAG with chunking

Chunking makes each request smaller, because we send a smaller context to the LLM. Let's measure that.

Index the chunks from Q4 (same as before: content as a text field, filename as a keyword field), point your RAG at the chunk index, and answer the same query again - reading the input tokens the same way as in Q3.

Compare the input tokens with Q3. How many fewer input tokens does the chunked version send?

  • about the same
  • 3× fewer
  • 10× fewer
  • 30× fewer

Q6. Turning it into an agent

So far search runs once, with the exact query. Let's make it agentic: give the LLM a search tool and let it decide when (and what) to search. We suggest toyaikit, the small agent library from the module, but you can use anything you like - the OpenAI Agents SDK, PydanticAI, LangChain, or a hand-written loop.

If you go with toyaikit:

uv add toyaikit

Create a search function that uses the chunk index. Give it a type hint and a docstring - most frameworks read them to build the tool schema for you.

Build an agent with your search tool and run it (with toyaikit, the same way as in the ToyAIKit lesson). Use these instructions for the agent (they nudge it to search a few times):

You're a course teaching assistant. Answer the student's question using the search tool. Make multiple searches with different keywords before answering.

Ask it:

How does the agentic loop work, and how is it different from plain RAG?

The agent decides on its own when to search and when to answer. Count how many times it called the search tool.

How many times did the agent call search?

Note: the agent decides this itself, so it varies a little between runs - pick the closest option. We measured this with OpenAI gpt-5.4-mini; with a different model or provider the number may differ, so keep that in mind.

  • 0
  • 4
  • 10
  • 20

## Submit the results

* Submit your results here: https://courses.datatalks.club/llm-zoomcamp-2026/homework/hw1
* It's possible your answers won't match exactly. If so, select the closest one.

Hi, I'm Manjunath. I am a student of this course. This is the assignment, and in the same directory you have lessons and code for the first module.First, summarize what is the assignment, and we can plan to do it together, firstly by planning. You will be my Pairing partner in doing the assignment

Hi Manjunath! Great to meet you — happy to be your pairing partner on this!

Here's a summary of the assignment:

Goal: Build a RAG (Retrieval-Augmented Generation) system using the course lessons themselves as the knowledge base, then make it "agentic."

The steps, roughly:

  1. Fetch lesson data from GitHub (a specific commit) using the gitsource library
  2. Q1: Count how many lesson pages there are
  3. Q2: Index the documents with minsearch and run a search query — find the top result's filename
  4. Q3: Build a RAG pipeline on top of that index, ask a question, and measure how many input tokens were sent to the LLM
  5. Q4: Split the documents into smaller chunks and count how many chunks result
  6. Q5: Re-run the RAG with chunked documents and compare token usage vs Q3
  7. Q6: Make it agentic — give the LLM a search tool and count how many times it calls it autonomously

Tech involved: gitsource, minsearch, OpenAI (gpt-5.4-mini), and toyaikit for the agent.


Before we start coding, a couple of planning questions:

  • Have you set up your environment already (uv, OpenAI key, etc.)?
  • Have you looked at the lessons and code in the 01-agentic-rag folder yet?

How do we look at the code and the set of the environment? Can we start?

Sure! Let's take a look at what's already in your directory.

list_dialogs(subpath=".", depth="3")→"{'items': ['code/', 'code/__pycache__/',…"
{
  "id": "toolu_019qmiujw5CooYeTT3gGW8yx",
  "server": false,
  "call": {
    "function": "list_dialogs",
    "arguments": {
      "subpath": ".",
      "depth": "3"
    }
  },
  "result": "{'items': ['code/', 'code/__pycache__/', 'code/agents', 'code/data_ingestion', 'code/notebook', 'code/persinsent_rag', 'code/persistent_rag_ingest', 'code/rag_cleaned', 'homework', 'lessons/', 'lessons/data_ingestion', 'lessons/understanding_08_rag_helper']}"
}

Good — you have code/, lessons/, and a homework dialog right here. Let's peek at what's in code/:

pyrun(code="import os\nfor root, dirs, files in os.wa…")→"No output"
{
  "id": "toolu_01MhrNGcoZyoPXBdvFLKdafe",
  "server": false,
  "call": {
    "function": "pyrun",
    "arguments": {
      "code": "import os\nfor root, dirs, files in os.walk('/root/data/llm_zoomcamp/llm-zoomcamp/01-agentic-rag/code'):\n    dirs[:] = [d for d in dirs if d != '__pycache__']\n    for f in files:\n        print(os.path.join(root, f).replace('/root/data/llm_zoomcamp/llm-zoomcamp/01-agentic-rag/code/', ''))"
    }
  },
  "result": "No output"
}
realpath(subpath=".")→"{'path': '/app/data/llm_zoomcamp/llm-zoo…"
{
  "id": "toolu_01KqZVxF8aH8MfhSdYVHTdky",
  "server": false,
  "call": {
    "function": "realpath",
    "arguments": {
      "subpath": "."
    }
  },
  "result": "{'path': '/app/data/llm_zoomcamp/llm-zoomcamp/01-agentic-rag'}"
}
view(path="/app/data/llm_zoomcamp/llm-zoomcamp/01-a…")→"Directory contents of /app/data/llm_zoom…"
{
  "id": "toolu_01CCjXLURQMAJsYABn1qRCb4",
  "server": false,
  "call": {
    "function": "view",
    "arguments": {
      "path": "/app/data/llm_zoomcamp/llm-zoomcamp/01-agentic-rag/code"
    }
  },
  "result": "Directory contents of /app/data/llm_zoomcamp/llm-zoomcamp/01-agentic-rag/code:\n/app/data/llm_zoomcamp/llm-zoomcamp/01-agentic-rag/code/persinsent_rag.ipynb (2.0k)\n/app/data/llm_zoomcamp/llm-zoomcamp/01-agentic-rag/code/rag_cleaned.ipynb (1.8k)\n/app/data/llm_zoomcamp/llm-zoomcamp/01-agentic-rag/code/data_ingestion.ipynb (18.9k)\n/app/data/llm_zoomcamp/llm-zoomcamp/01-agentic-rag/code/persistent_rag_ingest.ipynb (16.6k)\n/app/data/llm_zoomcamp/llm-zoomcamp/01-agentic-rag/code/agents.ipynb (95.7k)\n/app/data/llm_zoomcamp/llm-zoomcamp/01-agentic-rag/code/pyproject.toml (0.4k)\n/app/data/llm_zoomcamp/llm-zoomcamp/01-agentic-rag/code/uv.lock (407.9k)\n/app/data/llm_zoomcamp/llm-zoomcamp/01-agentic-rag/code/faq.db (212.0k)\n/app/data/llm_zoomcamp/llm-zoomcamp/01-agentic-rag/code/rag_helper.py (2.1k)\n/app/data/llm_zoomcamp/llm-zoomcamp/01-agentic-rag/code/notebook.ipynb (24.3k)\n/app/data/llm_zoomcamp/llm-zoomcamp/01-agentic-rag/code/ingest.py (0.7k)"
}

You already have quite a bit set up! Key files:

  • rag_helper.py — the RAG helper the homework references (you already have it, no need to wget)
  • pyproject.toml + uv.lock — your environment is configured
  • Several notebooks showing prior work

The next step is to check what's in pyproject.toml to see which packages are already installed. Want me to show you that, or would you like to open it yourself?

I will do a setup in my local environment. First, let's build it over here with you. Once that is done, I can take those files and run them in my local system.

Let's check the pyproject.toml first to see what packages are already available:

view(path="/app/data/llm_zoomcamp/llm-zoomcamp/01-a…")→"[project]\nname = \"llm-zoomcamp-2026-code…"
{
  "id": "toolu_01LxXb11V8QdSFoMmTQhYdNi",
  "server": false,
  "call": {
    "function": "view",
    "arguments": {
      "path": "/app/data/llm_zoomcamp/llm-zoomcamp/01-agentic-rag/code/pyproject.toml"
    }
  },
  "result": "[project]\nname = \"llm-zoomcamp-2026-code\"\nversion = \"0.1.0\"\ndescription = \"Add your description here\"\nreadme = \"README.md\"\nrequires-python = \">=3.12\"\ndependencies = [\n    \"jupyter>=1.1.1\",\n    \"minsearch>=0.0.11\",\n    \"openai>=2.37.0\",\n    \"psycopg[binary]>=3.3.4\",\n    \"python-dotenv>=1.2.2\",\n    \"requests>=2.34.2\",\n    \"sentence-transformers>=5.5.0\",\n    \"sqlitesearch>=0.0.5\",\n    \"toyaikit>=0.0.11\",\n]"
}

minsearch, openai, and toyaikit are all there. But gitsource is not listed — the homework requires it.

In solveit, you can install it directly with:

!pip install gitsource

Want to create a code cell and run that to get started?

!pip install gitsource

.

gitsource is installed. Now let's tackle Q1 — fetching the lesson pages.

Try creating a code cell with this to pull the data:

from gitsource import GithubRepositoryDataReader

reader = GithubRepositoryDataReader(
    repo_owner="DataTalksClub",
    repo_name="llm-zoomcamp",
    commit_id="8c1834d",
    allowed_extensions={"md"},
    filename_filter=lambda path: "/lessons/" in path,
)

files = reader.read()

Run it and let me know if it works — it'll download from GitHub so may take a moment.

fetching data

from gitsource import GithubRepositoryDataReader

reader = GithubRepositoryDataReader(
    repo_owner="DataTalksClub",
    repo_name="llm-zoomcamp",
    commit_id="8c1834d",
    allowed_extensions={"md"},
    filename_filter=lambda path: "/lessons/" in path,
)

files = reader.read()
files[0]
RawRepositoryFile(filename='01-agentic-rag/lessons/01-intro.md', content='# Introduction\n\nVideo: [Watch this lesson](https://www.youtube.com/watch?v=rQYyFxf1FWw&list=PL3MmuxUbc_hLZFNgSad56pDBKK8KO0XIv)\n\nIn this module, we\'ll build a working Retrieval-Augmented\nGeneration (RAG) system from scratch, step by step.\n\nWe write everything in plain Python. We build a small search index by\nhand and call the LLM ourselves. I want you to see every piece first.\nThat way you know what a framework does for you before you reach for\none.\n\nPlaces where you can find me:\n\n- [My substack](https://alexeyondata.substack.com/)\n- [LinkedIn](https://www.linkedin.com/in/agrigorev/)\n- [X](https://x.com/Al_Grigor)\n\n## LLMs\n\nAn LLM (Large Language Model) is a neural network trained on massive\namounts of text. Given a prompt, it generates a continuation - a\nplausible next piece of text.\n\nThink of your phone. When you type "how are" in WhatsApp, it suggests\n"you" as the next word. "How are you" is the most common continuation.\nYour phone uses a simple language model for that. It predicts the next\nword based on what you typed so far.\n\nA large language model does the same thing, but at a much larger scale.\nIt has billions of parameters and is trained on most of the text on the\ninternet. When it predicts the next word, it feels like you\'re talking\nto an intelligent being. It understands what you ask and gives\nmeaningful answers.\n\nIn this course, we treat LLMs as black boxes. We won\'t look inside or\ncover the theory, and we won\'t host a model ourselves. We use an LLM\nprovider and call it over an API. For us, an LLM is a box: text goes in,\ntext comes out.\n\nBut LLMs have limitations:\n\n- Knowledge cutoff: they only know what was in their training data.\n  If you ask about something that happened after training, they won\'t\n  know - or worse, they\'ll make something up.\n- No access to your data: they can\'t see your documents, databases,\n  or internal systems unless you provide that information.\n- Hallucinations: they sometimes produce confident-sounding answers\n  that are simply wrong.\n\n## The project\n\nRAG solves these problems by giving the LLM relevant documents at\nquestion time. We don\'t hope the model memorized the answer. We\nretrieve the right information and hand it to the LLM, and the model\ngenerates a grounded response. This lets us inject knowledge the model\nnever saw during training. That\'s why RAG is still the most common way\npeople use LLMs in the industry.\n\nTo make this concrete, we build a FAQ agent for our course. A student\nasks something like "when does the course start?" and the agent answers\nfrom the FAQ data we prepared.\n\nThis module has two parts.\n\nIn Part 1 (the next 9 lessons) we will:\n\n- Understand what RAG is and how it works\n- Build a search engine over a real FAQ dataset\n- Write a prompt that combines the user\'s question with search results\n- Wire it all together into a working RAG pipeline\n- Split ingestion and query into separate processes\n\nIn Part 2, we make the pipeline agentic. The LLM decides when and\nwhat to search, instead of running the same fixed flow every time.\n\nThe final code from this module is available in the\n[code/](../code/) directory.\n\n[← Back to module](../) | [Environment →](02-environment.md)')

So I'm able to see the content of files of Jiro. It's an FTIR repository file, so yeah. How can I know what all keys are there for the RAG as defined?

The files[0] output shows it's a RawRepositoryFile object with two fields:

  • filename — the path like 01-agentic-rag/lessons/01-intro.md
  • content — the full markdown text

But the homework wants us to call .parse() on each file, which returns a dictionary. Try this to see what keys you get:

doc = files[0].parse()
print(doc.keys())
doc = files[0].parse()
print(doc.keys())
dict_keys(['content', 'filename'])
from fastcore.utils import L

How can I apply fast core's map to use parse method?

With fastcore's L, you can map a method like this:

documents = L(files).map(lambda f: f.parse())

Give that a try and then check len(documents) for Q1!

documents = L(files).map(lambda f: f.parse())
documents[0]
{'content': '# Introduction\n\nVideo: [Watch this lesson](https://www.youtube.com/watch?v=rQYyFxf1FWw&list=PL3MmuxUbc_hLZFNgSad56pDBKK8KO0XIv)\n\nIn this module, we\'ll build a working Retrieval-Augmented\nGeneration (RAG) system from scratch, step by step.\n\nWe write everything in plain Python. We build a small search index by\nhand and call the LLM ourselves. I want you to see every piece first.\nThat way you know what a framework does for you before you reach for\none.\n\nPlaces where you can find me:\n\n- [My substack](https://alexeyondata.substack.com/)\n- [LinkedIn](https://www.linkedin.com/in/agrigorev/)\n- [X](https://x.com/Al_Grigor)\n\n## LLMs\n\nAn LLM (Large Language Model) is a neural network trained on massive\namounts of text. Given a prompt, it generates a continuation - a\nplausible next piece of text.\n\nThink of your phone. When you type "how are" in WhatsApp, it suggests\n"you" as the next word. "How are you" is the most common continuation.\nYour phone uses a simple language model for that. It predicts the next\nword based on what you typed so far.\n\nA large language model does the same thing, but at a much larger scale.\nIt has billions of parameters and is trained on most of the text on the\ninternet. When it predicts the next word, it feels like you\'re talking\nto an intelligent being. It understands what you ask and gives\nmeaningful answers.\n\nIn this course, we treat LLMs as black boxes. We won\'t look inside or\ncover the theory, and we won\'t host a model ourselves. We use an LLM\nprovider and call it over an API. For us, an LLM is a box: text goes in,\ntext comes out.\n\nBut LLMs have limitations:\n\n- Knowledge cutoff: they only know what was in their training data.\n  If you ask about something that happened after training, they won\'t\n  know - or worse, they\'ll make something up.\n- No access to your data: they can\'t see your documents, databases,\n  or internal systems unless you provide that information.\n- Hallucinations: they sometimes produce confident-sounding answers\n  that are simply wrong.\n\n## The project\n\nRAG solves these problems by giving the LLM relevant documents at\nquestion time. We don\'t hope the model memorized the answer. We\nretrieve the right information and hand it to the LLM, and the model\ngenerates a grounded response. This lets us inject knowledge the model\nnever saw during training. That\'s why RAG is still the most common way\npeople use LLMs in the industry.\n\nTo make this concrete, we build a FAQ agent for our course. A student\nasks something like "when does the course start?" and the agent answers\nfrom the FAQ data we prepared.\n\nThis module has two parts.\n\nIn Part 1 (the next 9 lessons) we will:\n\n- Understand what RAG is and how it works\n- Build a search engine over a real FAQ dataset\n- Write a prompt that combines the user\'s question with search results\n- Wire it all together into a working RAG pipeline\n- Split ingestion and query into separate processes\n\nIn Part 2, we make the pipeline agentic. The LLM decides when and\nwhat to search, instead of running the same fixed flow every time.\n\nThe final code from this module is available in the\n[code/](../code/) directory.\n\n[← Back to module](../) | [Environment →](02-environment.md)',
 'filename': '01-agentic-rag/lessons/01-intro.md'}
len(documents)
72

Indexing and searching

So what I mean by indexing is we will take all the documents and use Minsearch to create an index. What I mean by creating an index is we'll be populating the TF-IDF matrices, and we'll be using that with a search functionality by supplying our own query. Is that correct? Please correct me over here. I am learning as a student.

You're mostly right! Let me clarify a little:

What minsearch does:

  • It takes your documents and builds an index over the text fields
  • The search uses TF-IDF (Term Frequency-Inverse Document Frequency) — exactly as you said. It measures how important a word is in a document relative to the whole collection
  • When you search, it computes a similarity score between your query and each document, and returns the top matches

One small correction: minsearch is a very lightweight in-memory search library — it's not as sophisticated as something like Elasticsearch. But the core idea is the same: TF-IDF scoring over text fields.

In our case:

  • content → text field (TF-IDF scored)
  • filename → keyword field (exact match only, used for filtering)

Now, do you want to try writing the indexing code yourself? The key steps are:

  1. Create a minsearch.Index with text_fields and keyword_fields
  2. Call .fit(documents) to build the index

Take a look at rag_helper.py if you want a hint — it has an example of this!

from minsearch import Index
index = Index(text_fields=['content'],keyword_fields=['filename'])
index.fit(documents)
<minsearch.minsearch.Index at 0x7d5283d7c1a0>
help(index.search)
Help on method search in module minsearch.minsearch:

search(query, filter_dict=None, boost_dict=None, num_results=10, output_ids=False) method of minsearch.minsearch.Index instance
    Searches the index with the given query, filters, and boost parameters.

    Args:
        query (str): The search query string.
        filter_dict (dict): Dictionary of filters. Can include:
            - Keyword fields: {"field": "value"} for exact match
            - Numeric/date fields: {"field": [('>=', value), ('<', value)]} for range filters
            - Multiple conditions on the same field are combined with AND
        boost_dict (dict): Dictionary of boost scores for text fields. Keys are field names and values are the boost scores.
        num_results (int): The number of top results to return. Defaults to 10.
        output_ids (bool): If True, adds an '_id' field to each document containing its index. Defaults to False.

    Returns:
        list of dict: List of documents matching the search criteria, ranked by relevance.
                     If output_ids is True, each document will have an additional '_id' field.

Do you suggest any parameter to use apart from the query? Any idea on using boost_dict number of agents? Any other parameters over here?

For Q2, keep it simple — just use the query parameter, no filters or boosts needed. The homework just asks for the top result with the default settings.

Try running the search:

results = index.search("How does the agentic loop keep calling the model until it stops?")
results[0]['filename']

Once you see the answer we can discuss what boost_dict does!

results = index.search("How does the agentic loop keep calling the model until it stops?")
results[0]['filename']
'01-agentic-rag/lessons/14-agentic-loop.md'

Q3 RAG

What's the third question about? Can you summarize what the teacher is asking to do?

Q3 asks you to build a full RAG pipeline on top of your index and measure the input token count.

Specifically:

  1. Use the rag_helper.py file (already in your code/ folder)
  2. Adapt it to work with your filename/content schema (instead of the FAQ's section/question/answer)
  3. Ask it: "How does the agentic loop keep calling the model until it stops?"
  4. Read the input (prompt) tokens from the response — e.g. response.usage.prompt_tokens

The tricky part: rag_helper.py's llm method currently returns only the text. You'll need to modify it to also return usage info.

Want to first take a look at the current rag_helper.py to understand its structure?

Yes, please?

view(path="/app/data/llm_zoomcamp/llm-zoomcamp/01-a…")→"INSTRUCTIONS = '''\nYour task is to answe…"
{
  "id": "toolu_01AbkWyiqmG6kK4Y3jN1YF98",
  "server": false,
  "call": {
    "function": "view",
    "arguments": {
      "path": "/app/data/llm_zoomcamp/llm-zoomcamp/01-agentic-rag/code/rag_helper.py"
    }
  },
  "result": "INSTRUCTIONS = '''\nYour task is to answer questions from the course participants\nbased on the provided context.\n\nUse the context to find relevant information and provide accurate\nanswers. If the answer is not found in the context,\nrespond with \"I don't know.\"\n'''\n\nPROMPT_TEMPLATE = '''\nQUESTION: {question}\n\nCONTEXT:\n{context}\n'''.strip()\n\n\nclass RAGBase:\n\n    def __init__(\n        self,\n        index,\n        llm_client,\n        instructions=INSTRUCTIONS,\n        prompt_template=PROMPT_TEMPLATE,\n        course='llm-zoomcamp',\n        model='gpt-5.4-mini'\n    ):\n        self.index = index\n        self.llm_client = llm_client\n        self.instructions = instructions\n        self.course = course\n        self.prompt_template = prompt_template\n        self.model = model\n\n    def search(self, query, num_results=5):\n        boost_dict = {'question': 3.0, 'section': 0.5}\n        filter_dict = {'course': self.course}\n\n        return self.index.search(\n            query,\n            num_results=num_results,\n            boost_dict=boost_dict,\n            filter_dict=filter_dict\n        )\n\n    def build_context(self, search_results):\n        lines = []\n\n        for doc in search_results:\n            lines.append(doc['section'])\n            lines.append('Q: ' + doc['question'])\n            lines.append('A: ' + doc['answer'])\n            lines.append('')\n\n        return '\\n'.join(lines).strip()\n\n    def build_prompt(self, query, search_results):\n        context = self.build_context(search_results)\n        return self.prompt_template.format(\n            question=query, context=context\n        )\n\n    def llm(self, prompt):\n        input_messages = [\n            {'role': 'developer', 'content': self.instructions},\n            {'role': 'user', 'content': prompt}\n        ]\n\n        response = self.llm_client.responses.create(\n            model=self.model,\n            input=input_messages\n        )\n\n        return response.output_text\n\n    def rag(self, query):\n        search_results = self.search(query)\n        prompt = self.build_prompt(query, search_results)\n        answer = self.llm(prompt)\n        return answer"
}

Here's the structure. There are two methods you need to change for your schema:

  1. search — currently uses boost_dict with question/section and a filter_dict with course. You need to simplify it for your content/filename schema.

  2. build_context — currently builds context from section/question/answer fields. You need to change it to use filename and content.

And one method to change for Q3:

  1. llm — currently returns only response.output_text. You need it to also return response.usage.input_tokens.

Which of these three would you like to tackle first?

Can we write a helper inheriting the above rag? Is that a good idea, or what do you suggest?

Great idea! Inheriting from RAGBase and overriding just the three methods is clean and Pythonic — you only change what needs to change and reuse the rest.

Something like:

class LessonRAG(RAGBase):
    def search(self, query, num_results=5):
        ...  # your version

    def build_context(self, search_results):
        ...  # use filename + content

    def llm(self, prompt):
        ...  # return text + usage

Want to try filling in build_context first? It's the simplest one — just iterate over results and use doc['filename'] and doc['content'].

yes pls

Here's build_context:

def build_context(self, search_results):
    lines = []
    for doc in search_results:
        lines.append(f"File: {doc['filename']}")
        lines.append(doc['content'])
        lines.append('')
    return '\n'.join(lines).strip()

Try adding that into a LessonRAG class, then we'll do search next.

Is there any need to use the file name, and why are you having the empty string appended at the last?

Good questions!

  1. Filename — it's optional for the LLM to answer correctly, but it's useful context so the model knows which lesson the content came from. You could leave it out and it'd still work.

  2. Empty string — it adds a blank line between each document in the context, making it more readable. When joined with \n, '' becomes just a blank line separator. Not strictly necessary, but clean.

Want to include the filename or skip it? Up to you!

Let's start adding the search next.

For search, you just need to remove the boost_dict and filter_dict since your index doesn't have course or question fields. What do you think it should look like?

def search(self, query, num_results=2):
    

    return self.index.search(
        query,
        num_results=num_results
    )

Looks good! Clean and simple. One small thought — the homework uses num_results=5 as the default (same as the base class). Does it matter for Q3? Not really, but worth keeping consistent. Up to you!

Now the last piece — llm. Currently it returns response.output_text. You need it to also return response.usage.input_tokens. How would you modify it?

def llm(self, prompt):
    input_messages = [
        {'role': 'developer', 'content': self.instructions},
        {'role': 'user', 'content': prompt}
    ]

    response = self.llm_client.responses.create(
        model=self.model,
        input=input_messages
    )
    return response.output_text,response.usage.input_tokens,response.usage.output_tokens

That works! You're returning a tuple of (text, input_tokens, output_tokens).

Now you'll also need to update rag to handle this tuple — since llm now returns three values instead of one. How would you change rag to unpack that?

Now, instead of inheriting, can you give me a learner RAG with the above modifications? Don't inherit from rag base. Create just a lesson rag.

Sure! Since you explicitly asked, here's the full LessonRAG class:

class LessonRAG:

    def __init__(self, index, llm_client, model='gpt-4.1-mini'):
        self.index = index
        self.llm_client = llm_client
        self.model = model
        self.instructions = INSTRUCTIONS
        self.prompt_template = PROMPT_TEMPLATE

    def search(self, query, num_results=5):
        return self.index.search(query, num_results=num_results)

    def build_context(self, search_results):
        lines = []
        for doc in search_results:
            lines.append(f"File: {doc['filename']}")
            lines.append(doc['content'])
            lines.append('')
        return '\n'.join(lines).strip()

    def build_prompt(self, query, search_results):
        context = self.build_context(search_results)
        return self.prompt_template.format(question=query, context=context)

    def llm(self, prompt):
        input_messages = [
            {'role': 'developer', 'content': self.instructions},
            {'role': 'user', 'content': prompt}
        ]
        response = self.llm_client.responses.create(
            model=self.model,
            input=input_messages
        )
        return response.output_text, response.usage.input_tokens, response.usage.output_tokens

    def rag(self, query):
        search_results = self.search(query)
        prompt = self.build_prompt(query, search_results)
        answer, input_tokens, output_tokens = self.llm(prompt)
        return answer, input_tokens, output_tokens

Note I used INSTRUCTIONS and PROMPT_TEMPLATE from rag_helper.py — make sure to import or copy those. Try adding this to your notebook, then we can instantiate it and run Q3!

class LessonRAG:

    def __init__(self, index, llm_client, model='gpt-4.1-mini'):
        self.index = index
        self.llm_client = llm_client
        self.model = model
        self.instructions = INSTRUCTIONS
        self.prompt_template = PROMPT_TEMPLATE

    def search(self, query, num_results=5):
        return self.index.search(query, num_results=num_results)

    def build_context(self, search_results):
        lines = []
        for doc in search_results:
            lines.append(f"File: {doc['filename']}")
            lines.append(doc['content'])
            lines.append('')
        return '\n'.join(lines).strip()

    def build_prompt(self, query, search_results):
        context = self.build_context(search_results)
        return self.prompt_template.format(question=query, context=context)

    def llm(self, prompt):
        input_messages = [
            {'role': 'developer', 'content': self.instructions},
            {'role': 'user', 'content': prompt}
        ]
        response = self.llm_client.responses.create(
            model=self.model,
            input=input_messages
        )
        return response.output_text, response.usage.input_tokens, response.usage.output_tokens

    def rag(self, query):
        search_results = self.search(query)
        prompt = self.build_prompt(query, search_results)
        answer, input_tokens, output_tokens = self.llm(prompt)
        return answer, input_tokens, output_tokens

Can you also get those instructions and prompt templates from the course material itself? Just populate them as variables over here. Don't ask me just for any imports.

INSTRUCTIONS = '''
Your task is to answer questions from the course participants
based on the provided context.

Use the context to find relevant information and provide accurate
answers. If the answer is not found in the context,
respond with "I don't know."
'''

PROMPT_TEMPLATE = '''
QUESTION: {question}

CONTEXT:
{context}
'''.strip()

Add this in a cell above your LessonRAG class and you're good to go!

INSTRUCTIONS = '''
Your task is to answer questions from the course participants
based on the provided context.

Use the context to find relevant information and provide accurate
answers. If the answer is not found in the context,
respond with "I don't know."
'''

PROMPT_TEMPLATE = '''
QUESTION: {question}

CONTEXT:
{context}
'''.strip()

from openai import OpenAI
client = OpenAI()
l_rag = LessonRAG(index,client,)
answer,input_tokens,output_tokens = l_rag.rag("How does the agentic loop keep calling the model until it stops?")
input_tokens
7127

Q4 chunking

Can you summarize the question about chanking?

Q4 asks you to split the long lesson pages into smaller overlapping pieces using gitsource's chunk_documents, then count how many chunks result.

The parameters:

  • size=2000 — each chunk is 2000 characters
  • step=1000 — the window moves 1000 chars at a time, so consecutive chunks overlap by 1000 chars

The question is simply: how many chunks do you get?

Try it:

from gitsource import chunk_documents
chunks = chunk_documents(documents, size=2000, step=1000)
len(chunks)
from gitsource import chunk_documents
help(chunk_documents)
Help on function chunk_documents in module gitsource.chunking:

chunk_documents(documents: Iterable[Dict[str, str]], size: int = 2000, step: int = 1000, content_field_name: str = 'content') -> List[Dict[str, str]]
    Split a collection of documents into smaller chunks using sliding windows.

    Takes documents and breaks their content into overlapping chunks while preserving
    all other document metadata (filename, etc.) in each chunk.

    Args:
        documents: An iterable of document dictionaries. Each document must have a content field.
        size: The maximum size of each chunk. Defaults to 2000.
        step: The step size between chunks. Defaults to 1000.
        content_field_name: The name of the field containing document content.
            Defaults to 'content'.

    Returns:
        A list of chunk dictionaries. Each chunk contains:
            - All original document fields except the content field
            - 'start': Starting position of the chunk in original content
            - 'content': The chunk content

    Example:
        >>> documents = [{'content': 'long text...', 'filename': 'doc.txt'}]
        >>> chunks = chunk_documents(documents, size=100, step=50)
        >>> # Or with custom content field:
        >>> documents = [{'text': 'long text...', 'filename': 'doc.txt'}]
        >>> chunks = chunk_documents(documents, content_field_name='text')

chunks = chunk_documents(documents, size=2000, step=1000)
len(chunks)
295

Q5 Rag with chunking

Please summarize this part: what we need to do.

Q5 asks you to:

  1. Index the chunks (from Q4) the same way as before — content as text field, filename as keyword field
  2. Point your LessonRAG at the new chunk index
  3. Ask the same query as Q3
  4. Compare input tokens — how many fewer tokens does the chunked version send vs Q3's 7127?

The key insight: smaller chunks → smaller context → fewer tokens sent to the LLM.

Want to start by creating the new index?

index.fit(chunks)
<minsearch.minsearch.Index at 0x7d5283d7c1a0>
l_rag_c = LessonRAG(index,client,)
answer,input_tokens,output_tokens = l_rag_c.rag("How does the agentic loop keep calling the model until it stops?")
input_tokens
2310
7217/2310
3.124242424242424

Q6. Turning it into an agent

Yeah, so can you help me to understand question six?

Sure! But I notice you've jumped ahead to Q6 — you still have Q3, Q4, and Q5 to do first. Did you want to skip ahead, or shall we work through them in order?

That said, here's a quick conceptual summary of Q6:

In Q3–Q5, the RAG flow is fixed: search once → build context → ask LLM. You control when and what to search.

In Q6, you flip that around — you give the LLM a search tool and let it decide:

  • Should I search?
  • What should I search for?
  • Should I search again with different keywords?

The LLM runs in a loop, calling your search function as many times as it wants, then finally gives an answer. That's what makes it agentic — it has agency over its own retrieval process.

Your job for Q6 is just to count how many times the agent called search.

Shall we go back and tackle Q3 (building the RAG + counting input tokens) first?

I already completed questions 3, 4, and 5. We can proceed to question 6, also a little bit about the framework we are using.

Great progress! Let's look at the rag_helper.py you already have, since Q6 builds on it:

view(path="/app/data/llm_zoomcamp/llm-zoomcamp/01-a…")→"INSTRUCTIONS = '''\nYour task is to answe…"
{
  "id": "toolu_016tCXCjwGZzxaCihQg2p7Ys",
  "server": false,
  "call": {
    "function": "view",
    "arguments": {
      "path": "/app/data/llm_zoomcamp/llm-zoomcamp/01-agentic-rag/code/rag_helper.py"
    }
  },
  "result": "INSTRUCTIONS = '''\nYour task is to answer questions from the course participants\nbased on the provided context.\n\nUse the context to find relevant information and provide accurate\nanswers. If the answer is not found in the context,\nrespond with \"I don't know.\"\n'''\n\nPROMPT_TEMPLATE = '''\nQUESTION: {question}\n\nCONTEXT:\n{context}\n'''.strip()\n\n\nclass RAGBase:\n\n    def __init__(\n        self,\n        index,\n        llm_client,\n        instructions=INSTRUCTIONS,\n        prompt_template=PROMPT_TEMPLATE,\n        course='llm-zoomcamp',\n        model='gpt-5.4-mini'\n    ):\n        self.index = index\n        self.llm_client = llm_client\n        self.instructions = instructions\n        self.course = course\n        self.prompt_template = prompt_template\n        self.model = model\n\n    def search(self, query, num_results=5):\n        boost_dict = {'question': 3.0, 'section': 0.5}\n        filter_dict = {'course': self.course}\n\n        return self.index.search(\n            query,\n            num_results=num_results,\n            boost_dict=boost_dict,\n            filter_dict=filter_dict\n        )\n\n    def build_context(self, search_results):\n        lines = []\n\n        for doc in search_results:\n            lines.append(doc['section'])\n            lines.append('Q: ' + doc['question'])\n            lines.append('A: ' + doc['answer'])\n            lines.append('')\n\n        return '\\n'.join(lines).strip()\n\n    def build_prompt(self, query, search_results):\n        context = self.build_context(search_results)\n        return self.prompt_template.format(\n            question=query, context=context\n        )\n\n    def llm(self, prompt):\n        input_messages = [\n            {'role': 'developer', 'content': self.instructions},\n            {'role': 'user', 'content': prompt}\n        ]\n\n        response = self.llm_client.responses.create(\n            model=self.model,\n            input=input_messages\n        )\n\n        return response.output_text\n\n    def rag(self, query):\n        search_results = self.search(query)\n        prompt = self.build_prompt(query, search_results)\n        answer = self.llm(prompt)\n        return answer"
}

Good context. Now for Q6, you don't use RAGBase at all — instead you build an agent with toyaikit.

The key idea is:

  1. Define a search(query) function that uses your chunk index from Q4/Q5
  2. Register it as a tool the LLM can call
  3. Run the agent with the question

Have you looked at the agents.ipynb notebook yet? It shows how toyaikit is used in the course. Want to peek at it first, or shall we start writing the agent code directly?

Can we both look into agenda type NB and get the crux of it? What are the steps used in the agentic rag with Toyakit

view(path="/app/data/llm_zoomcamp/llm-zoomcamp/01-a…")→"{\n \"cells\": [\n {\n \"cell_type\": \"code\"…"
{
  "id": "toolu_017nf6G91jj46f32nxmiZqdi",
  "server": false,
  "call": {
    "function": "view",
    "arguments": {
      "path": "/app/data/llm_zoomcamp/llm-zoomcamp/01-agentic-rag/code/agents.ipynb"
    }
  },
  "result": "<TRUNCATED>…: [\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 1,\n   \"id\": \"0fffc852\",\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"name\": \"stdout\",\n     \"output_type\": \"stream\",\n     \"text\": [\n      \"123\\n\"\n     ]\n    }\n   ],\n   \"source\": [\n    \"print(123)\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 2,\n   \"id\": \"ef4c742d\",\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"name\": \"stdout\",\n     \"output_type\": \"stream\",\n     \"text\": [\n      \"--2026-05-26 09:42:53--  https://raw.githubusercontent.com/DataTalksClub/llm-zoomcamp/main/01-agentic-rag/code/rag_helper.py\\n\",\n      \"Resolving raw.githubusercontent.com (raw.githubusercontent.com)... 185.199.108.133, 185.199.110.133, 185.199.111.133, ...\\n\",\n      \"Connecting to raw.githubusercontent.com (raw.githubusercontent.com)|185.199.108.133|:443... connected.\\n\",\n      \"HTTP request sent, awaiting response... 200 OK\\n\",\n      \"Length: 2134 (2.1K) [text/plain]\\n\",\n      \"Saving to: ‘rag_helper.py’\\n\",\n      \"\\n\",\n      \"rag_helper.py       100%[===================>]   2.08K  --.-KB/s    in 0s      \\n\",\n      \"\\n\",\n      \"2026-05-26 09:42:54 (34.7 MB/s) - ‘rag_helper.py’ saved [2134/2134]\\n\",\n      \"\\n\",\n      \"--2026-05-26 09:42:54--  https://raw.githubusercontent.com/DataTalksClub/llm-zoomcamp/main/01-agentic-rag/code/ingest.py\\n\",\n      \"Resolving raw.githubusercontent.com (raw.githubusercontent.com)... 185.199.109.133, 185.199.111.133, 185.199.110.133, ...\\n\",\n      \"Connecting to raw.githubusercontent.com (raw.githubusercontent.com)|185.199.109.133|:443... connected.\\n\",\n      \"HTTP request sent, awaiting response... 200 OK\\n\",\n      \"Length: 738 [text/plain]\\n\",\n      \"Saving to: ‘ingest.py’\\n\",\n      \"\\n\",\n      \"ingest.py           100%[===================>]     738  --.-KB/s    in 0s      \\n\",\n      \"\\n\",\n      \"2026-05-26 09:42:54 (40.0 MB/s) - ‘ingest.py’ saved [738/738]\\n\",\n      \"\\n\"\n     ]\n    }\n   ],\n   \"source\": [\n    \"!wget https://raw.githubusercontent.com/DataTalksClub/llm-zoomcamp/main/01-agentic-rag/code/rag_helper.py\\n\",\n    \"!wget https://raw.githubusercontent.com/DataTalksClub/llm-zoomcamp/main/01-agentic-rag/code/ingest.py\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 3,\n   \"id\": \"e745ce08\",\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": [\n    \"from dotenv import load_dotenv\\n\",\n    \"from openai import OpenAI\\n\",\n    \"\\n\",\n    \"load_dotenv()\\n\",\n    \"openai_client = OpenAI()\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 4,\n   \"id\": \"2b13c05b\",\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": [\n    \"from ingest import load_faq_data, build_index\\n\",\n    \"\\n\",\n    \"documents = load_faq_data()\\n\",\n    \"index = build_index(documents)\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 5,\n   \"id\": \"a938cd09\",\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": [\n    \"from rag_helper import RAGBase\\n\",\n    \"\\n\",\n    \"\\n\",\n    \"instructions = \\\"\\\"\\\"\\n\",\n    \"You're a course teaching assistant.\\n\",\n    \"Answer the QUESTION based on the CONTEXT from the FAQ database.\\n\",\n    \"Use only the facts from the CONTEXT when answering the QUESTION.\\n\",\n    \"\\\"\\\"\\\".strip()\\n\",\n    \"\\n\",\n    \"assistant = RAGBase(\\n\",\n    \"    index=index,\\n\",\n    \"    llm_client=openai_client,\\n\",\n    \"    instructions=instructions,\\n\",\n    \")\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 7,\n   \"id\": \"ff15db64\",\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"name\": \"stdout\",\n     \"output_type\": \"stream\",\n     \"text\": [\n      \"To run Ollama locally:\\n\",\n      \"\\n\",\n      \"1. Install Ollama from: https://ollama.com/download  \\n\",\n      \"   - macOS: install the `.pkg`\\n\",\n      \"   - Windows: install the `.msi`\\n\",\n      \"   - Linux:\\n\",\n      \"     ```bash\\n\",\n      \"     curl -fsSL https://ollama.com/install.sh | sh\\n\",\n      \"     ```\\n\",\n      \"\\n\",\n      \"2. Open a terminal and run:\\n\",\n      \"   ```bash\\n\",\n      \"   ollama run llama3\\n\",\n      \"   ```\\n\",\n      \"\\n\",\n      \"This will download the LLaMA 3 model, start it locally, and open a chat-like interface.\\n\",\n      \"\\n\",\n      \"3. To test that the local server is running, you can use:\\n\",\n      \"   ```bash\\n\",\n      \"   curl http://localhost:11434\\n\",\n      \"   ```\\n\",\n      \"\\n\",\n      \"If needed, you can also install the Python client with:\\n\",\n      \"```bash\\n\",\n      \"pip install ollama\\n\",\n      \"```\\n\"\n     ]\n    }\n   ],\n   \"source\": [\n    \"answer = assistant.rag('How do I run Ollama locally?')\\n\",\n    \"print(answer)\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 8,\n   \"id\": \"495533af\",\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"name\": \"stdout\",\n     \"output_type\": \"stream\",\n     \"text\": [\n      \"I don’t have any information in the FAQ context about running Olama locally.\\n\"\n     ]\n    }\n   ],\n   \"source\": [\n    \"answer = assistant.rag('How do I run Olama locally?')\\n\",\n    \"print(answer)\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 11,\n   \"id\": \"8894af25\",\n   \"metadata\": {},\n   \"outputs\": [\n    …</TRUNCATED>"
}

The notebook is very instructive. Here's the crux of how toyaikit works for Q6:

The key pieces:

  1. Define your search function with type hints + docstring — toyaikit reads them to auto-build the tool schema:
def search(query: str) -> list:
    """Search the course lessons for chunks matching the query."""
    return chunk_index.search(query, num_results=5)
  1. Register it with Tools:
from toyaikit.tools import Tools
agent_tools = Tools()
agent_tools.add_tool(search)   # schema is auto-generated!
  1. Create a runner + callback, then call .loop():
from toyaikit.llm import OpenAIClient
from toyaikit.chat import IPythonChatInterface
from toyaikit.chat.runners import OpenAIResponsesRunner, DisplayingRunnerCallback

chat_interface = IPythonChatInterface()
callback = DisplayingRunnerCallback(chat_interface)

runner = OpenAIResponsesRunner(
    tools=agent_tools,
    developer_prompt=instructions,
    chat_interface=chat_interface,
    llm_client=OpenAIClient(model='gpt-5.4-mini')
)

result = runner.loop(prompt='How does the agentic loop work...', callback=callback)

What .loop() does internally: it's the same while loop you saw hand-written — keeps calling the LLM, handles any function_call items by running your search, feeds results back, and stops when the LLM returns a plain message with no more tool calls.

For counting search calls: look at result.all_messages and count items where item.type == 'function_call'.

The key difference from Q3–Q5: your search function should use the chunk index from Q4, not the original document index.

Ready to write the code?

!pip install toyaikit
from toyaikit.tools import Tools
agent_tools = Tools()
agent_tools.add_tool(search)   # schema is auto-generated!
instructions = """
You're a course teaching assistant. Answer the student's question using the search tool. Make multiple searches with different keywords before answering.
""".strip()
from toyaikit.llm import OpenAIClient
from toyaikit.chat import IPythonChatInterface
from toyaikit.chat.runners import OpenAIResponsesRunner, DisplayingRunnerCallback

chat_interface = IPythonChatInterface()
callback = DisplayingRunnerCallback(chat_interface)

runner = OpenAIResponsesRunner(
    tools=agent_tools,
    developer_prompt=instructions,
    chat_interface=chat_interface,
    llm_client=OpenAIClient(model='gpt-5.4-mini')
)

result = runner.loop(prompt="How does the agentic loop work, and how is it different from plain RAG?", callback=callback)
Function call: search({"query":"agentic loop RAG difference"})
Call
{"query":"agentic loop RAG difference"}
Output
[
  {
    "start": 4000,
    "content": "esult.\n\nThe `result` is a `LoopResult` with `all_messages` (the full\nconversation), token counts, and `cost` (computed from token usage).\n\n## Cost and tokens\n\nLook at what the call cost:\n\n```python\nresult.cost\n```\n\nUseful while developing - especially with multi-turn agents where one\nprompt can trigger several model calls. The handwritten loop made you\ncompute this by hand. The framework keeps a running total for you.\n\nYou can also look at the full message history.\n\n```python\nresult.all_messages\n```\n\nThis is just a list - the same `messages` list we maintained by hand.\n\n## Continuing the conversation\n\nTake the messages from the previous result and pass them as\n`previous_messages` on the next `loop` call:\n\n```python\nresult2 = runner.loop(\n    prompt=\"How do I run a different model?\",\n    previous_messages=result.all_messages,\n    callback=callback,\n)\n```\n\nThe runner picks up where the last call left off, with the same agent\nloop and an extended history. The model knows \"different model\" refers\nto Ollama because it sees the previous turn in memory. Without that\nhistory, it would have no idea what we're asking about.\n\n## Interactive chat\n\nFor a chat-like workflow, run the built-in input loop:\n\n```python\nrunner.run()\n```\n\nType questions and get answers. Type \"stop\" to exit.\n\n[\u2190 The Agentic Loop](14-agentic-loop.md) | [Other Frameworks \u2192](16-other-frameworks.md)",
    "filename": "01-agentic-rag/lessons/15-frameworks.md"
  },
  {
    "start": 0,
    "content": "# RAG with Vector Search\n\nVideo: [Watch this lesson](https://www.youtube.com/watch?v=-GBW3g3PVTM&list=PL3MmuxUbc_hLZFNgSad56pDBKK8KO0XIv)\n\nIn module 1, we built a RAG pipeline with three steps:\n\n```python\ndef rag(question):\n    search_results = search(question)\n    user_prompt = build_prompt(question, search_results)\n    return llm(user_prompt)\n```\n\nThe search step used keyword search. Now we swap in vector search.\nBecause RAG is modular, search is the only step we touch. Build prompt\nand the LLM call stay exactly as they were.\n\n## Using RAGBase\n\nIn [module 1](../../01-agentic-rag/) we put all the RAG logic into a\n[`RAGBase`](../../01-agentic-rag/code/rag_helper.py) helper class. It\nhas `search`, `build_prompt`, and `llm` methods, so we only need to\noverride `search`.\n\nDownload `rag_helper.py` (and `ingest.py` if you didn't get it earlier)\ninto your project:\n\n```bash\nwget https://raw.githubusercontent.com/DataTalksClub/llm-zoomcamp/main/01-agentic-rag/code/rag_helper.py\nwget https://raw.githubusercontent.com/DataTalksClub/llm-zoomcamp/main/01-agentic-rag/code/ingest.py\n```\n\nFirst, create the OpenAI client:\n\n```python\nfrom dotenv import load_dotenv\nfrom openai import OpenAI\n\nload_dotenv()\nopenai_client = OpenAI()\n```\n\nNext, download and index the data:\n\n```python\nfrom ingest import load_faq_data, build_index\n\ndocuments = load_faq_data()\nindex = build_index(documents)\n```\n\nThen use the `RAGBase` class:\n\n```python\nfrom rag_helper import RAGBase\n\nassistant = RAGBase(\n    index=index,\n    llm_client=openai_client,\n)\n```\n\nAsk it a question:\n\n```python\nquery = \"I just found out about the program, can I still sign up?\"\nassistant.rag(query)\n```\n\nThis still uses keyword search. Text search isn't bad here, so the\nanswer may already look right. Next we replace search with vector\nsearch.\n\nWe already have:\n\n- All the indexed documents `documents`\n- The embeddings matrix `X` with all these documents\n- The vector search engine `vindex`\n\nWe can't pass `vindex` to RAG as-is. Text sea",
    "filename": "02-vector-search/lessons/06-rag-vector.md"
  },
  {
    "start": 0,
    "content": "# AI Agents\n\nVideo: [Agentic Workflows](https://youtu.be/7tvpR8EE0gs)\n\nIn [Module 1](../../01-agentic-rag/lessons/11-agents-intro.md) you built the agentic loop by hand: a `while` loop that called the LLM, executed any tool calls it returned, sent the results back, and stopped when the model produced a final answer with no more tool calls. That pattern is the foundation of every agent framework.\n\nIn Kestra, the `AIAgent` plugin handles that loop for you. You define the goal, the tools, and optionally a system message - Kestra drives the loop, manages conversation history, and surfaces the result as a task output.\n\n> Note: The flows in this lesson use `{{ secret('GEMINI_API_KEY') }}`. Make sure you've completed the [setup instructions](03-setup.md) to configure this secret before running them.\n\nThe example flows use Gemini, but the `provider` block supports any major AI provider \u2014 swap `io.kestra.plugin.ai.provider.GoogleGemini` for `OpenAI`, `Anthropic`, or others. See the [full list of supported providers](https://kestra.io/plugins/plugin-ai/provider).\n\nTraditional Workflow \u2014 fixed sequence, predetermined logic:\n\n```yaml\ntasks:\n  - id: step1\n    type: Task1\n  - id: step2\n    type: Task2\n  - id: step3\n    type: Task3\n```\n\nAI Agent Workflow \u2014 agent decides what to do, in what order, based on the goal:\n\n```yaml\ntasks:\n  - id: agent\n    type: io.kestra.plugin.ai.agent.AIAgent\n    prompt: \"Research data engineering trends and create a report\"\n    tools:\n      - WebSearch\n      - TaskExecution\n```\n\n## When to Use AI Agents\n\nUse AI Agents when the exact sequence of steps isn't known in advance, decisions depend on dynamic changing information, or you need to adapt to unexpected conditions.\n\nUse traditional workflows when steps are deterministic and repeatable, compliance requires exact auditable processes, or cost and latency must be minimized.\n\n## Anatomy of an AI Agent\n\n```yaml\nid: example_agent\nnamespace: zoomcamp\n\ntasks:\n  - id: agent\n    type: io.kestra.plugin.ai.age",
    "filename": "03-orchestration/lessons/06-agents.md"
  },
  {
    "start": 7000,
    "content": "```python\nusage = response.usage\nusage.input_tokens, usage.output_tokens\n```\n\nFor each model the provider publishes a price per million input tokens\nand per million output tokens. Plug those numbers in to convert tokens\nto dollars.\n\n```python\ndef calculate_gpt54mini_price(input_tokens, output_tokens):\n    INPUT_PRICE_PER_MILLION = 0.15\n    OUTPUT_PRICE_PER_MILLION = 0.60\n\n    input_cost = (input_tokens / 1_000_000) * INPUT_PRICE_PER_MILLION\n    output_cost = (output_tokens / 1_000_000) * OUTPUT_PRICE_PER_MILLION\n    total_cost = input_cost + output_cost\n\n    return {\n        \"input_cost\": input_cost,\n        \"output_cost\": output_cost,\n        \"total_cost\": total_cost,\n    }\n\nresult = calculate_gpt54mini_price(652, 33)\nprint(\"Total cost: $\", round(result[\"total_cost\"], 8))\n```\n\nThis usage is only for the second API call. The first call also has\nits own usage and its own cost. That was the call where the model\ndecided to invoke `search`. Two calls means we pay twice. We pay even\nmore on the second call, because we resend the full history as input.\n\nWith a real agent loop the model can make many calls, so the costs add\nup. Keep an eye on `usage` while you develop.\n\n[\u2190 Quick RAG Revision](12-rag-revision.md) | [The Agentic Loop \u2192](14-agentic-loop.md)",
    "filename": "01-agentic-rag/lessons/13-function-calling.md"
  },
  {
    "start": 3000,
    "content": "   developer_prompt=instructions,\n    chat_interface=chat_interface,\n    llm_client=OpenAIClient(model=\"gpt-5.4-mini\")\n)\n```\n\nThe `chat_interface` handles display in the notebook. The `callback`\nrenders model messages and tool calls as they happen. The runner runs\nthe agent loop, the same `while True` we wrote by hand. It sends\nmessages, executes function calls, adds tool outputs back, and repeats\nuntil the model is done.\n\nWe pick `gpt-5.4-mini` here on purpose. Without it, ToyAIKit falls\nback to a smaller, faster default that doesn't follow the instructions\nas reliably.\n\n## Running one prompt\n\nRun a single prompt:\n\n```python\nresult = runner.loop(\n    prompt=\"How do I run Olama locally?\",\n    callback=callback,\n)\n```\n\nWe used the typo \"Olama\" on purpose. The agent searches and gets poor\nresults, then retries with \"Ollama\". The recovery is the same as the\nhandwritten loop. The notebook output is nicer to watch. Each tool\ncall and message renders inline, so you can look at every search\nresult.\n\nThe `result` is a `LoopResult` with `all_messages` (the full\nconversation), token counts, and `cost` (computed from token usage).\n\n## Cost and tokens\n\nLook at what the call cost:\n\n```python\nresult.cost\n```\n\nUseful while developing - especially with multi-turn agents where one\nprompt can trigger several model calls. The handwritten loop made you\ncompute this by hand. The framework keeps a running total for you.\n\nYou can also look at the full message history.\n\n```python\nresult.all_messages\n```\n\nThis is just a list - the same `messages` list we maintained by hand.\n\n## Continuing the conversation\n\nTake the messages from the previous result and pass them as\n`previous_messages` on the next `loop` call:\n\n```python\nresult2 = runner.loop(\n    prompt=\"How do I run a different model?\",\n    previous_messages=result.all_messages,\n    callback=callback,\n)\n```\n\nThe runner picks up where the last call left off, with the same agent\nloop and an extended history. The model knows \"different model\"",
    "filename": "01-agentic-rag/lessons/15-frameworks.md"
  }
]
Function call: search({"query":"agentic loop lesson retrieval generat...)
Call
{"query":"agentic loop lesson retrieval generation iteration"}
Output
[
  {
    "start": 4000,
    "content": "while` loop. The loop keeps calling the model until\nit returns a response without any function calls. We also keep an\niteration counter so we can see how many round-trips happened.\n\n```python\nit = 1\n\nwhile True:\n    print(f\"iteration #{it}...\")\n    has_function_calls = False\n\n    response = openai_client.responses.create(\n        model=\"gpt-5.4-mini\",\n        input=messages,\n        tools=[search_tool],\n    )\n\n    messages.extend(response.output)\n\n    for item in response.output:\n        if item.type == \"function_call\":\n            print(\"function_call:\", item.name, item.arguments)\n            call_output = make_call(item)\n            messages.append(call_output)\n            has_function_calls = True\n\n        elif item.type == \"message\":\n            print(\"ASSISTANT:\")\n            print(item.content[0].text)\n\n    it = it + 1\n    if has_function_calls == False:\n        break\n```\n\nThis is the core agent loop. The model reasons about the next action.\nYour code performs it, and the model sees the result on the next turn.\nThe loop stops when the model returns a final answer with no more tool\ncalls.\n\nWe don't decide how many times the model searches. The model does,\nand we keep looping until it stops asking for tools.\n\nThe exit condition is the simplest one possible. No function calls\nthis turn means we're done. Other frameworks add safety nets on top,\nlike a max iteration count, a token budget, or a wall-clock limit. You\nmight cap it at five iterations and force an answer on the last one.\nThe core is still this one flag.\n\n## Wrapping it in a function\n\nLet's wrap the loop in a function so we can reuse it. The function\ntakes the instructions and the question as parameters, and returns\nthe final answer.\n\n```python\ndef agent_loop(instructions, question, model=\"gpt-5.4-mini\") -> str:\n    messages = [\n        {\"role\": \"developer\", \"content\": instructions},\n        {\"role\": \"user\", \"content\": question}\n    ]\n\n    it = 1\n\n    while True:\n        print(f\"iteration #{it}...\")\n    ",
    "filename": "01-agentic-rag/lessons/14-agentic-loop.md"
  },
  {
    "start": 4000,
    "content": "esult.\n\nThe `result` is a `LoopResult` with `all_messages` (the full\nconversation), token counts, and `cost` (computed from token usage).\n\n## Cost and tokens\n\nLook at what the call cost:\n\n```python\nresult.cost\n```\n\nUseful while developing - especially with multi-turn agents where one\nprompt can trigger several model calls. The handwritten loop made you\ncompute this by hand. The framework keeps a running total for you.\n\nYou can also look at the full message history.\n\n```python\nresult.all_messages\n```\n\nThis is just a list - the same `messages` list we maintained by hand.\n\n## Continuing the conversation\n\nTake the messages from the previous result and pass them as\n`previous_messages` on the next `loop` call:\n\n```python\nresult2 = runner.loop(\n    prompt=\"How do I run a different model?\",\n    previous_messages=result.all_messages,\n    callback=callback,\n)\n```\n\nThe runner picks up where the last call left off, with the same agent\nloop and an extended history. The model knows \"different model\" refers\nto Ollama because it sees the previous turn in memory. Without that\nhistory, it would have no idea what we're asking about.\n\n## Interactive chat\n\nFor a chat-like workflow, run the built-in input loop:\n\n```python\nrunner.run()\n```\n\nType questions and get answers. Type \"stop\" to exit.\n\n[\u2190 The Agentic Loop](14-agentic-loop.md) | [Other Frameworks \u2192](16-other-frameworks.md)",
    "filename": "01-agentic-rag/lessons/15-frameworks.md"
  },
  {
    "start": 0,
    "content": "# Retrieval Augmented Generation\n\nVideo: [RAG Workflows](https://youtu.be/FhGZV173xrk)\n\nAI Copilot solves the context problem for flow generation. But what about workflows that need to answer questions from your own data? That's where RAG comes in.\n\n> Note: Flows 1 and 2 use `{{ secret('GEMINI_API_KEY') }}`. Flow 3 uses `{{ secret('OPENAI_API_KEY') }}` and `{{ secret('TAVILY_API_KEY') }}`. Make sure you've completed the [setup instructions](03-setup.md) to configure the relevant secrets before running them.\n\n## What is RAG?\n\nRAG (Retrieval Augmented Generation) is a technique that retrieves relevant information from your data sources, augments the AI prompt with that context, and generates a response grounded in real data. This solves the hallucination problem by ensuring the AI has access to current, accurate information at query time.\n\nFor a deeper dive into RAG concepts, see [Module 1: Intro to RAG](../../01-agentic-rag/lessons/03-rag.md). For vector search, see [Module 2: Vector Search](../../02-vector-search/lessons/04-vector-search.md).\n\n## How RAG Works in Kestra\n\nRAG has two phases. In the demo flows below they run back-to-back, but in production you'd typically schedule them separately \u2014 ingest on a cadence, query on demand.\n\n```mermaid\ngraph LR\n    subgraph Ingest [\"Ingest (run once / on schedule)\"]\n        A[Fetch Docs] --> B[Create Embeddings]\n        B --> C[Store in KV Store]\n    end\n    subgraph Query [\"Query (run on demand)\"]\n        D[User Question] --> E[Find Similar Content]\n        E --> F[Add Context to Prompt]\n        F --> G[LLM Answer]\n    end\n    C --> E\n```\n\nIngest phase (run once, or on a schedule when your data changes):\n\n1. Fetch documents: load documentation, release notes, or other data sources\n2. Create embeddings: convert text into vectors using an embedding model\n3. Store embeddings: save vectors in Kestra's KV Store\n\n> Note: The flows store embeddings in Kestra's KV Store for simplicity. This is convenient for learning and small-sc",
    "filename": "03-orchestration/lessons/05-rag.md"
  },
  {
    "start": 2000,
    "content": "s on Google Cloud or you specifically want\nGemini.\n\n## Others\n\nOther frameworks worth knowing:\n\n- CrewAI - multi-agent orchestration\n- AutoGen - multi-agent conversations from Microsoft\n- Semantic Kernel - from Microsoft, supports C# and Python\n- Smolagents - lightweight agent framework from HuggingFace\n- Anthropic Tool Use - Anthropic's native tool use API\n\nPick one that fits your stack and your needs. The hard part is\ndesigning good tools and prompts - the loop is always the same.\n\n## Avoiding agents when you can\n\nOne closing thought. You don't always need an agent, and that's fine.\nYou might assume your app needs one, but often it doesn't.\n\nAdding an agent is a real cost:\n\n- More API calls per request, since the loop can fire many tool calls\n  before the model is satisfied.\n- Higher latency, since each round-trip waits on the model.\n- More money spent, since every iteration is another billed call and\n  the full message history is re-sent each turn.\n- More moving parts, since you now monitor cost, iteration count, and\n  whether the agent is going in circles.\n- Less predictable behavior, since the LLM decides what to do and two\n  runs of the same prompt can take different paths.\n\nBefore reaching for an agent, ask whether the problem needs one. A lot\nof tasks are well served by simpler approaches.\n\nReach for one of these first:\n\n- plain RAG, with one search and one answer\n- parsing or templating a document into another form\n- a single LLM call with no tools\n\nTry the simpler approach first, and if it works, ship it. Reach for\nthe agent loop only when you've tried the simpler solution and it\ngenuinely can't handle the problem. By then you'll know the extra\ncomplexity is worth it, and you'll be ready to take it on.\n\n[\u2190 ToyAIKit](15-frameworks.md) | [Back to module \u2192](../)",
    "filename": "01-agentic-rag/lessons/16-other-frameworks.md"
  },
  {
    "start": 3000,
    "content": "we drop it.\n\nBuild a prompt that includes both the question and the context:\n\n```python\nprompt = f\"\"\"\nYour task is to answer questions from the course participants\nbased on the provided context.\n\nUse the context to find relevant information and provide accurate\nanswers. If the answer is not found in the context,\nrespond with \"I don't know.\"\n\nQuestion:\n{question}\n\nContext:\n{context}\n\"\"\"\n```\n\nInstead of sending the raw question to the LLM, we send this prompt:\n\n```python\nanswer = llm(prompt)\nprint(answer)\n```\n\nAfter that, the answer is correct: \"Yes, you can still join. If you want to\nreceive a certificate, you need to submit your project while\nsubmissions are still open.\"\n\nThis is the answer we actually want to give to our students. What we\njust did is nothing but RAG.\n\n## Retrieval plus generation\n\nRAG stands for Retrieval-Augmented Generation. Generation is the LLM\nproducing text, and retrieval is search. We retrieve relevant documents\nfrom our knowledge base and use them to augment what the LLM generates.\nThat search step is what gives the LLM the context it needs to answer\ncorrectly.\n\nWhat we just did was naive. I knew in advance which FAQ entry held the\nanswer and pasted it in by hand. What we want instead is to perform\nsearch automatically. We take the student's question, find the most\nrelevant documents, and send those to the LLM.\n\nIn code, it looks like this:\n\n```python\ndef rag(question):\n    search_results = search(question)\n    user_prompt = build_prompt(question, search_results)\n    return llm(user_prompt)\n```\n\nThat's the entire architecture. It comes down to three components.\n\nThe pieces are search, the prompt, and the LLM:\n\n- search\n- prompt\n- LLM\n\n\n```mermaid\nflowchart TD\n    U([User])\n\n    APP[Application]\n\n    DB[(DB)]\n    DOCS[[D1 ... D5]]\n\n    PROMPT[Build Prompt<br/>Question + Context]\n\n    LLM[LLM]\n\n    ANSWER([Answer])\n\n    U -->|Question| APP\n\n    APP -->|Query| DB\n    DB -->|Retrieved Data| DOCS\n    DOCS --> APP\n\n    APP --> PROMPT\n    PROMPT -",
    "filename": "01-agentic-rag/lessons/03-rag.md"
  }
]
Function call: search({"query":"plain RAG versus agentic RAG course"})
Call
{"query":"plain RAG versus agentic RAG course"}
Output
[
  {
    "start": 0,
    "content": "# RAG with Vector Search\n\nVideo: [Watch this lesson](https://www.youtube.com/watch?v=-GBW3g3PVTM&list=PL3MmuxUbc_hLZFNgSad56pDBKK8KO0XIv)\n\nIn module 1, we built a RAG pipeline with three steps:\n\n```python\ndef rag(question):\n    search_results = search(question)\n    user_prompt = build_prompt(question, search_results)\n    return llm(user_prompt)\n```\n\nThe search step used keyword search. Now we swap in vector search.\nBecause RAG is modular, search is the only step we touch. Build prompt\nand the LLM call stay exactly as they were.\n\n## Using RAGBase\n\nIn [module 1](../../01-agentic-rag/) we put all the RAG logic into a\n[`RAGBase`](../../01-agentic-rag/code/rag_helper.py) helper class. It\nhas `search`, `build_prompt`, and `llm` methods, so we only need to\noverride `search`.\n\nDownload `rag_helper.py` (and `ingest.py` if you didn't get it earlier)\ninto your project:\n\n```bash\nwget https://raw.githubusercontent.com/DataTalksClub/llm-zoomcamp/main/01-agentic-rag/code/rag_helper.py\nwget https://raw.githubusercontent.com/DataTalksClub/llm-zoomcamp/main/01-agentic-rag/code/ingest.py\n```\n\nFirst, create the OpenAI client:\n\n```python\nfrom dotenv import load_dotenv\nfrom openai import OpenAI\n\nload_dotenv()\nopenai_client = OpenAI()\n```\n\nNext, download and index the data:\n\n```python\nfrom ingest import load_faq_data, build_index\n\ndocuments = load_faq_data()\nindex = build_index(documents)\n```\n\nThen use the `RAGBase` class:\n\n```python\nfrom rag_helper import RAGBase\n\nassistant = RAGBase(\n    index=index,\n    llm_client=openai_client,\n)\n```\n\nAsk it a question:\n\n```python\nquery = \"I just found out about the program, can I still sign up?\"\nassistant.rag(query)\n```\n\nThis still uses keyword search. Text search isn't bad here, so the\nanswer may already look right. Next we replace search with vector\nsearch.\n\nWe already have:\n\n- All the indexed documents `documents`\n- The embeddings matrix `X` with all these documents\n- The vector search engine `vindex`\n\nWe can't pass `vindex` to RAG as-is. Text sea",
    "filename": "02-vector-search/lessons/06-rag-vector.md"
  },
  {
    "start": 0,
    "content": "# Quick RAG Revision (Optional)\n\nVideo: [Watch this lesson](https://www.youtube.com/watch?v=gH8fB-6Emmo&list=PL3MmuxUbc_hLZFNgSad56pDBKK8KO0XIv)\n\nBefore we talk about agents, let's set up the RAG pipeline we built\nin Part 1.\n\nOur courses have a lot of participants. They ask the same questions\nover and over, so we keep a FAQ document and point students to it. RAG\ntakes that FAQ and finds the entry that matches a question. It then\nsends the entry to an LLM so it can answer. That way a student gets a\nreply right away instead of scrolling through a long document.\n\nWe'll use two helpers we defined earlier in this module:\n\n- [`rag_helper.py`](../code/rag_helper.py) - the `RAGBase` class wrapping search, prompt building, and the LLM call\n- [`ingest.py`](../code/ingest.py) - `load_faq_data` and `build_index` for loading the FAQ and building a minsearch index\n\nIf you're working through Part 2 as a standalone workshop (without\nPart 1), download them into your project:\n\n```bash\nwget https://raw.githubusercontent.com/DataTalksClub/llm-zoomcamp/main/01-agentic-rag/code/rag_helper.py\nwget https://raw.githubusercontent.com/DataTalksClub/llm-zoomcamp/main/01-agentic-rag/code/ingest.py\n```\n\n## Setting up RAG\n\nSet up the OpenAI client:\n\n```python\nfrom dotenv import load_dotenv\nfrom openai import OpenAI\n\nload_dotenv()\nopenai_client = OpenAI()\n```\n\nLoad the data and build the search index:\n\n```python\nfrom rag_helper import RAGBase\nfrom ingest import load_faq_data, build_index\n\ndocuments = load_faq_data()\nindex = build_index(documents)\n```\n\nCreate the assistant:\n\n```python\ninstructions = \"\"\"\nYou're a course teaching assistant.\nAnswer the QUESTION based on the CONTEXT from the FAQ database.\nUse only the facts from the CONTEXT when answering the QUESTION.\n\"\"\".strip()\n\nassistant = RAGBase(\n    index=index,\n    llm_client=openai_client,\n    instructions=instructions,\n)\n```\n\n## Testing it\n\nLet's try a question:\n\n```python\nassistant.rag(\"How do I run Ollama locally?\")\n```\n\nThis works fine. Th",
    "filename": "01-agentic-rag/lessons/12-rag-revision.md"
  },
  {
    "start": 0,
    "content": "# Retrieval Augmented Generation\n\nVideo: [RAG Workflows](https://youtu.be/FhGZV173xrk)\n\nAI Copilot solves the context problem for flow generation. But what about workflows that need to answer questions from your own data? That's where RAG comes in.\n\n> Note: Flows 1 and 2 use `{{ secret('GEMINI_API_KEY') }}`. Flow 3 uses `{{ secret('OPENAI_API_KEY') }}` and `{{ secret('TAVILY_API_KEY') }}`. Make sure you've completed the [setup instructions](03-setup.md) to configure the relevant secrets before running them.\n\n## What is RAG?\n\nRAG (Retrieval Augmented Generation) is a technique that retrieves relevant information from your data sources, augments the AI prompt with that context, and generates a response grounded in real data. This solves the hallucination problem by ensuring the AI has access to current, accurate information at query time.\n\nFor a deeper dive into RAG concepts, see [Module 1: Intro to RAG](../../01-agentic-rag/lessons/03-rag.md). For vector search, see [Module 2: Vector Search](../../02-vector-search/lessons/04-vector-search.md).\n\n## How RAG Works in Kestra\n\nRAG has two phases. In the demo flows below they run back-to-back, but in production you'd typically schedule them separately \u2014 ingest on a cadence, query on demand.\n\n```mermaid\ngraph LR\n    subgraph Ingest [\"Ingest (run once / on schedule)\"]\n        A[Fetch Docs] --> B[Create Embeddings]\n        B --> C[Store in KV Store]\n    end\n    subgraph Query [\"Query (run on demand)\"]\n        D[User Question] --> E[Find Similar Content]\n        E --> F[Add Context to Prompt]\n        F --> G[LLM Answer]\n    end\n    C --> E\n```\n\nIngest phase (run once, or on a schedule when your data changes):\n\n1. Fetch documents: load documentation, release notes, or other data sources\n2. Create embeddings: convert text into vectors using an embedding model\n3. Store embeddings: save vectors in Kestra's KV Store\n\n> Note: The flows store embeddings in Kestra's KV Store for simplicity. This is convenient for learning and small-sc",
    "filename": "03-orchestration/lessons/05-rag.md"
  },
  {
    "start": 1000,
    "content": "ithubusercontent.com/DataTalksClub/llm-zoomcamp/main/01-agentic-rag/code/rag_helper.py\nwget https://raw.githubusercontent.com/DataTalksClub/llm-zoomcamp/main/01-agentic-rag/code/ingest.py\n```\n\n## Setting up RAG\n\nSet up the OpenAI client:\n\n```python\nfrom dotenv import load_dotenv\nfrom openai import OpenAI\n\nload_dotenv()\nopenai_client = OpenAI()\n```\n\nLoad the data and build the search index:\n\n```python\nfrom rag_helper import RAGBase\nfrom ingest import load_faq_data, build_index\n\ndocuments = load_faq_data()\nindex = build_index(documents)\n```\n\nCreate the assistant:\n\n```python\ninstructions = \"\"\"\nYou're a course teaching assistant.\nAnswer the QUESTION based on the CONTEXT from the FAQ database.\nUse only the facts from the CONTEXT when answering the QUESTION.\n\"\"\".strip()\n\nassistant = RAGBase(\n    index=index,\n    llm_client=openai_client,\n    instructions=instructions,\n)\n```\n\n## Testing it\n\nLet's try a question:\n\n```python\nassistant.rag(\"How do I run Ollama locally?\")\n```\n\nThis works fine. The search finds relevant FAQ entries about Ollama,\nand the LLM gives a good answer.\n\nNow try something slightly different:\n\n```python\nassistant.rag(\"How do I run Olama locally?\")\n```\n\nThe word \"Olama\" doesn't match \"Ollama\" in our index. We use lexical\nsearch, so it looks for the exact word and finds nothing. The LLM\ngets these bad results and either says \"I don't know\" or answers with\nirrelevant information.\n\nThis is the limitation of a fixed pipeline. The search runs once with\nthe exact query the user typed, and there's no second chance. The\npipeline doesn't know the search failed, so it can't try again with a\ncorrected query.\n\nWe need something smarter. We need an agent.\n\n[\u2190 Agents (Part 2 intro)](11-agents-intro.md) | [Function Calling \u2192](13-function-calling.md)",
    "filename": "01-agentic-rag/lessons/12-rag-revision.md"
  },
  {
    "start": 2000,
    "content": "wrong.\n\n## The project\n\nRAG solves these problems by giving the LLM relevant documents at\nquestion time. We don't hope the model memorized the answer. We\nretrieve the right information and hand it to the LLM, and the model\ngenerates a grounded response. This lets us inject knowledge the model\nnever saw during training. That's why RAG is still the most common way\npeople use LLMs in the industry.\n\nTo make this concrete, we build a FAQ agent for our course. A student\nasks something like \"when does the course start?\" and the agent answers\nfrom the FAQ data we prepared.\n\nThis module has two parts.\n\nIn Part 1 (the next 9 lessons) we will:\n\n- Understand what RAG is and how it works\n- Build a search engine over a real FAQ dataset\n- Write a prompt that combines the user's question with search results\n- Wire it all together into a working RAG pipeline\n- Split ingestion and query into separate processes\n\nIn Part 2, we make the pipeline agentic. The LLM decides when and\nwhat to search, instead of running the same fixed flow every time.\n\nThe final code from this module is available in the\n[code/](../code/) directory.\n\n[\u2190 Back to module](../) | [Environment \u2192](02-environment.md)",
    "filename": "01-agentic-rag/lessons/01-intro.md"
  }
]
Assistant:

The agentic loop is the core pattern where the model and your code take turns:

  1. You send the model the user question plus the conversation history.
  2. The model decides whether it needs a tool, like search.
  3. If it returns a function/tool call, your code executes it.
  4. You feed the tool result back to the model.
  5. Repeat until the model returns a normal answer with no more function calls.

In the lesson, this is described as a while loop that keeps calling the model until it stops asking for tools. The loop ends when has_function_calls == False. In other words, the model decides how many rounds are needed, not your code.

How that differs from plain RAG

Plain RAG is a fixed pipeline:

def rag(question):
    search_results = search(question)
    user_prompt = build_prompt(question, search_results)
    return llm(user_prompt)

That means:

  • search happens once
  • you build the prompt once
  • the LLM answers once

So plain RAG is single-pass and deterministic: the system does retrieval first, then generation.

Agentic loop vs plain RAG

Plain RAG

  • one retrieval step
  • one generation step
  • no self-correction
  • simpler, faster, cheaper
  • good when the query is straightforward

Agentic loop

  • multiple model calls may happen
  • the model can decide to search again, try a different query, or use other tools
  • supports retrying and adapting if the first attempt is poor
  • more flexible, but also more expensive and slower

The key difference

Plain RAG is:
retrieve once, answer once

Agentic loop is:
think → tool call → observe result → think again → maybe tool call again → answer

The lesson even notes that plain RAG can fail if the search query is slightly wrong, like "Olama" instead of "Ollama". With an agentic loop, the model can notice the bad result and retry with a corrected search term.

If you want, I can also give you a tiny side-by-side pseudocode example of both.

-> Response received
-> Response received

What are the key takeaways from the assignment, and what should they fit in my mind?

Great question — here are the core mental models to lock in:

1. RAG = retrieve once, answer once A fixed pipeline: search → build prompt → LLM. Simple, fast, cheap. But brittle — if the search fails, you get a bad answer with no recovery.

2. Chunking improves retrieval precision Long documents are noisy. Smaller overlapping chunks mean a search hit is more focused and relevant, and you send less context to the LLM (= fewer tokens = cheaper).

3. Agentic loop = the LLM drives itself You give the LLM tools. It decides when to call them and what to search for. The loop runs until it stops asking for tools. This makes it adaptive — it can retry, rephrase, or search multiple times.

4. Tools are just functions with type hints + docstrings Frameworks like toyaikit auto-generate the tool schema from your Python function. The LLM sees the schema and decides when to call it.

5. Token counting is the "ruler" for measuring cost Input tokens tell you how much context you're sending. Chunking reduces this significantly (that was Q5's lesson — ~10× fewer tokens).

6. The complexity tradeoff Agentic → more flexible, more API calls, more cost, less predictable. Plain RAG → simpler, faster, cheaper. Pick the right tool for the problem.

These six ideas are the backbone of the entire module. Which one feels least clear to you?