RAG Crash Course Guide
Step-by-step tutorial · RAG from basic to agentic

RAG Crash Course: build a company chatbot in 6 levels

You build a chatbot that answers questions from a company's own documents. Each level adds one idea and ends with a question it still gets wrong, which is why you need the next level.

Time: 2–3 hours Level: beginner Python Stack: Actian VectorAI DB (Docker) · sentence-transformers · rank-bm25 · Gemini / OpenAI / Claude

Credit: based on "Complete RAG Crash Course 2027 For Beginners" by AI with Hassan, and its code in AIwithhassan/rag-crash-course. Code excerpts are quoted from that repo.

Contents

  1. 1What RAG is
  2. 2The 6 levels at a glance
  3. 3The demo data: Star Motors
  4. 4Setup
  5. 5Levels 0–5, step by step
  6. 6Which technique, when
  7. 7Gotchas and troubleshooting
  8. 8Quick quiz

1. What RAG is

An LLM doesn't know your company's documents. Retrieval-Augmented Generation (RAG) fixes that in two moves. First, retrieve the few document pieces most relevant to the question. Then generate an answer with the LLM, using only those pieces.

The full pipeline (from the repo README)
documents ─► chunk (paragraph) ─► embed (384-d vector) ─► VectorAI DB
                                                              │
question ─► [rewrite] ─► [dense + BM25 → RRF] ─► [filter] ─► [re-rank] ─► top chunks ─► LLM ─► answer
              3a              1, 2                  4           3b                 (agent in 5 decides
                                                                                    which of these to run)

2. The 6 levels at a glance

  1. L0Chunk → embed → store in a vector DB
  2. L1Semantic search + answer only from retrieved chunksexact codes like BRK-2270
  3. L2Hybrid search: meaning + keywords, merged with RRFa German question against English documents
  4. L3aQuery rewriting by the LLMthe right chunk ranks below look-alikes
  5. L3bRe-ranking with a cross-encoderan intern can still read the salary file
  6. L4Metadata filters (access, year)comparing two years needs two searches
  7. L5Agentic RAG: the LLM decides when and what to search

3. The demo data: Star Motors

A fictional motorcycle maker with 10 short documents in data/star_motors.py. Each paragraph becomes one chunk, which gives 32 chunks. Every chunk keeps its document's labels, and Levels 4 and 5 filter on them.

DocumentTypeAccessYear
Remote Work Policyhr_policyall_staff2026
Casual Leave Policy 2026hr_policyall_staff2026
Casual Leave Policy 2025hr_policyall_staff2025
Salary Bands (Confidential)hr_policyhr_only2026
Maintenance Manual: Brakesmanualall_staff2026
Maintenance Manual: Engine Oilmanualall_staff2026
Assembly Machine Error Codesmanualall_staff2026
Spare Parts Catalog: Brakesparts_catalogall_staff2026
Spare Parts Catalog: Engineparts_catalogall_staff2026
Workshop Safety Rulessafetyall_staff2026

4. Setup

NeedNotes
Python 3.11+ and uvPlain pip also works
Docker with Compose v2Runs Actian VectorAI DB (actian/vectorai:latest). Ports: gRPC 6574, REST 6573, web UI 6575. Starting it means accepting the Actian EULA, so read it first.
One LLM API keyGemini, OpenAI or Anthropic. Free option: a Gemini API key from Google AI Studio.
Embeddings and re-rankerRun locally, free, no key. Downloaded on first use.
Terminal
# 1. Get the code
git clone https://github.com/AIwithhassan/rag-crash-course.git
cd rag-crash-course

# 2. Install dependencies with uv (https://docs.astral.sh/uv/)
uv sync                       # base install (includes the OpenAI SDK)
uv sync --extra gemini        # add the Gemini SDK  (or: --extra anthropic)

# 3. Create .env (see the next block)

# 4. Start the vector database in Docker
docker compose up -d          # web UI: http://localhost:6575

# 5. Build the index once (and again whenever you edit the documents)
uv run python build_index.py
.env
# .env  (the repo's README mentions .env.example, but that file is not in the repo yet, so create this yourself)
LLM_PROVIDER=gemini              # gemini | openai | anthropic   (the code's default is anthropic)
LLM_MODEL=                       # empty = provider default; set a current model name if the default is retired
GEMINI_API_KEY=your_key_here     # free key: https://aistudio.google.com/apikey
VECTORAI_URL=localhost:6574

5. Levels 0–5, step by step

Read commons.py once. It holds the shared helpers, so every step file only shows its new idea. Each step's __main__ runs the question that breaks the previous step; earlier demo questions are commented out, so uncomment them to compare.

commons.py: embed, dense_search, answer_from
def embed(texts):
    """Text in, vectors out. One text -> one vector. A list -> a list of vectors."""
    global _embedder
    if _embedder is None:
        from sentence_transformers import SentenceTransformer
        _embedder = SentenceTransformer(EMBED_MODEL)

    single = isinstance(texts, str)
    vectors = _embedder.encode([texts] if single else texts, normalize_embeddings=True)
    vectors = [v.tolist() for v in vectors]
    return vectors[0] if single else vectors


def dense_search(client, question, limit=5, filter=None):
    """Semantic search: find the chunks whose MEANING is closest to the question."""
    return client.points.search(
        COLLECTION,
        embed(question),
        limit=limit,
        filter=filter,
        with_payload=True,
    )


def answer_from(question, results):
    """RAG's last step: give the LLM the chunks we found and ask it to answer from them."""
    context = "\n\n".join(
        f"[{r.payload['title']}]\n{r.payload['text']}" for r in results
    )
    prompt = (
        "Answer the question using ONLY the context below. "
        "If the answer is not in the context, say \"I couldn't find that.\"\n\n"
        f"Context:\n{context}\n\nQuestion: {question}"
    )
    return ask_llm(prompt)

Level 0 · Chunk, embed, store

File build_index.pyAnalogy cutting documents into index cards
build_index.py
def make_chunks(documents):
    """One paragraph = one chunk. Every chunk keeps its document's labels."""
    chunks = []
    for doc in documents:
        for paragraph in doc["text"].split("\n\n"):
            chunks.append({
                "text": paragraph.strip(),
                "title": doc["title"],
                "doc_type": doc["doc_type"],
                "department": doc["department"],
                "access": doc["access"],
                "year": doc["year"],
            })
    return chunks


def build_index():
    chunks = make_chunks(DOCUMENTS)
    print(f"Cut {len(DOCUMENTS)} documents into {len(chunks)} chunks")

    # ------------------------------------------------------ 2. EMBED
    vectors = embed([c["text"] for c in chunks])
    print(f"Each chunk is now a list of {len(vectors[0])} numbers")

    # ------------------------------------------------------ 3. STORE
    client = connect()

    # A collection is like a table. We tell it: 384 numbers per vector,
    # and compare vectors with cosine similarity.
    client.collections.recreate(
        COLLECTION,
        vectors_config=VectorParams(size=EMBED_SIZE, distance=Distance.Cosine),
    )

    # A point = an id + the vector + a payload (the original text and labels)
    points = [
        PointStruct(id=i, vector=vectors[i], payload=chunks[i])
        for i in range(len(chunks))
    ]
    client.upload_points(COLLECTION, points)
    print(f"Stored {client.points.count(COLLECTION)} points in VectorAI DB")
    return client
Expected output
Cut 10 documents into 32 chunks
Each chunk is now a list of 384 numbers
Stored 32 points in VectorAI DB

Embeddings turn text into 384 numbers that capture meaning (the model is all-MiniLM-L6-v2). Each chunk is stored as a point: an id, the vector, and a payload holding the text and labels.

Level 1 · Basic RAG (semantic search)

File step1_basic_rag.pySearch dense, top 3
step1_basic_rag.py
def ask(client, question):
    results = dense_search(client, question, limit=3)
    show(results, f"Top 3 chunks for: {question}")
    print("\nAnswer:", answer_from(question, results))
Works

"How much does the oil filter for the Trail 250 cost?" The catalog says 14 euros (ENG-0415).

Breaks

"What is the price of part BRK-2270?" The right answer is 46 euros, but a code like BRK-2270 has no "meaning" for an embedding model, and there are near-identical codes (BRK-2207, BRK-2210).

Level 2 · Hybrid search (meaning + exact words)

File step2_hybrid_search.pyAnalogy two judges; whoever ranks high on both lists wins
step2_hybrid_search.py
def tokenize(text):
    """Split text into lowercase words. Keeps codes like 'brk-2207' as ONE word."""
    return re.findall(r"[a-z0-9]+(?:-[a-z0-9]+)*", text.lower())


class KeywordSearch:
    """BM25 keyword search over every chunk stored in VectorAI DB."""

    def __init__(self, client):
        # Read every point back from the database (its payload holds the text)
        self.points = []
        for page in client.points.scroll_all(COLLECTION, with_payload=True):
            self.points.extend(page)
        self.bm25 = BM25Okapi([tokenize(p.payload["text"]) for p in self.points])

    def search(self, question, limit=5):
        scores = self.bm25.get_scores(tokenize(question))
        best = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[:limit]
        # Same result shape as VectorAI DB, so the fusion helper can merge both lists
        return [
            ScoredPoint(id=self.points[i].id, score=float(scores[i]), payload=self.points[i].payload)
            for i in best
            if scores[i] > 0
        ]


def hybrid_search(client, keyword_search, question, limit=5):
    meaning = dense_search(client, question, limit=20)    # search 1: meaning (VectorAI DB)
    keywords = keyword_search.search(question, limit=20)  # search 2: exact words (BM25)
    merged = reciprocal_rank_fusion([meaning, keywords], limit=limit)  # merge (SDK helper)
    return meaning, keywords, merged

BM25 is a smart Ctrl+F. The tokenizer keeps brk-2270 as one word. Reciprocal Rank Fusion (RRF) merges the two ranked lists using rank positions, not raw scores, so the different score scales of BM25 and cosine similarity don't matter.

Now works

BRK-2270 → 46 euros. Meaning questions like "Can I work from home on Fridays?" still work.

Breaks

"Meine Bremsen quietschen beim Anhalten, was soll ich tun?" (German). No shared words for BM25, and the embedding model only knows English.

Level 3a · Query rewriting

File step3a_query_rewriting.pyMerge server-side prefetch + RRF
step3a_query_rewriting.py
def rewrite(question):
    """Ask the LLM to turn a messy question into a clear search query."""
    return ask_llm(
        "Rewrite this user question as a short, clear search query for a motorcycle "
        "company's documents. Write it in English, the language of the documents. "
        "Use precise technical words. "
        f"Reply with the query only.\n\nQuestion: {question}"
    ).strip()


def search_both(client, original, rewritten, limit=5):
    """Search with both questions and let VectorAI DB merge the two lists."""
    return client.points.query(
        COLLECTION,
        prefetch=[
            PrefetchQuery(query=embed(original), limit=20),   # search 1: what the user typed
            PrefetchQuery(query=embed(rewritten), limit=20),  # search 2: the clean version
        ],
        query={"fusion": Fusion.RRF},                          # merge both on the server
        limit=limit,
    )

Searching with both the original and the rewritten question keeps what the user actually said. The merge runs inside VectorAI DB in one call: prefetch runs two searches and Fusion.RRF combines them.

Now works

The German brake question → clean the disc with brake cleaner, check pad thickness, replace pads thinner than 2 mm, on both sides of the wheel.

Breaks

"How thin can brake pads get before replacing?" The "2 mm" chunk ranks below brake-pad price chunks that look similar in meaning.

Level 3b · Re-ranking

File step3b_reranking.pyAnalogy scan 200 CVs fast, shortlist 20, interview those properly
Model (small, local)
reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")  # small, runs locally
step3b_reranking.py
def rerank(question, results, top=3):
    """Score every (question, chunk) pair, then sort by the new score."""
    pairs = [(question, r.payload["text"]) for r in results]
    scores = reranker.predict(pairs)
    for r, s in zip(results, scores):
        r.score = float(s)   # replace the first-pass score with the re-ranker's score
    return sorted(results, key=lambda r: r.score, reverse=True)[:top]

The first search compares vectors computed separately. A cross-encoder reads the question and each chunk together, which is slower but much more accurate. So you run it only on the 20 shortlisted chunks.

Now works

Brake pads → replace when thinner than 2 mm.

Breaks

An intern asks "What is the manager's salary?" Search finds the confidential file and the bot answers (95,000–120,000 euros). Better ranking can't fix permissions.

Level 4 · Metadata filters (who can see what)

File step4_metadata_filters.pyRules must (AND) · must_not (NOT) · should (OR)
step4_metadata_filters.py
def access_filter(role, year=None):
    """Build the search rules for a user."""
    rules = FilterBuilder()
    if role != "hr_manager":
        rules = rules.must_not(Field("access").eq("hr_only"))  # hide confidential HR files
    if year is not None:
        rules = rules.must(Field("year").eq(year))             # only this year's documents
    return None if rules.is_empty() else rules.build()

The filter runs inside the vector search, so a blocked chunk never reaches the LLM. Your app decides who the user is and builds the filter; the database just applies it. Never rely on a prompt for permissions.

Now works

The intern's salary question finds nothing. "How many casual leave days?" with year 2026 → 14 days (the 2025 policy said 10).

Breaks

"Compare the 2025 and 2026 casual leave policies." One search with one fixed filter can't fetch both years.

Level 5 · Agentic RAG (the LLM decides)

File step5_agentic_rag.pyLoop max 4 steps
Agent rules and limits
MAX_STEPS = 4        # safety limit: the agent can't loop forever
WEAK_SCORE = 0.45    # if the best match scores below this, we tell the agent "weak results"

AGENT_RULES = """You are the Star Motors assistant. You can search company documents.

Each turn, reply with ONE JSON object and nothing else:
  {"action": "search", "query": "<search words>", "year": <2025, 2026 or null>}
  {"action": "answer", "text": "<your final answer>"}

Rules:
- Small talk or thanks: answer directly, do NOT search.
- Company questions: search first, then answer only from what you found.
- Comparing years: search once per year, with the year filled in.
- If results are marked WEAK, search again with different, more specific words.
- If you still can't find it, say so."""
step5_agentic_rag.py
def search_tool(client, query, year=None):
    """The agent's only tool: a VectorAI DB search, optionally filtered by year."""
    rules = FilterBuilder().must(Field("year").eq(year)).build() if year else None
    results = dense_search(client, query, limit=3, filter=rules)
    if not results:
        return "No results."
    quality = "WEAK" if results[0].score < WEAK_SCORE else "GOOD"
    found = "\n".join(f"- ({r.score:.2f}) {r.payload['title']}: {r.payload['text']}" for r in results)
    return f"Results ({quality}):\n{found}"


def parse_decision(reply):
    """Pull the FIRST JSON object out of the LLM's reply (it sometimes sends two)."""
    match = re.search(r"\{.*?\}", reply, re.DOTALL)
    return json.loads(match.group(0)) if match else {"action": "answer", "text": reply}


def run_agent(client, question):
    print(f"\n========== User: {question}")
    history = f"User question: {question}"

    for step in range(1, MAX_STEPS + 1):
        decision = parse_decision(ask_llm(history, system=AGENT_RULES))

        if decision["action"] == "answer":
            print(f"Step {step}: ANSWER -> {decision['text']}")
            return decision["text"]

        query = decision.get("query") or question
        year = int(decision["year"]) if str(decision.get("year")).isdigit() else None
        print(f"Step {step}: SEARCH '{query}' (year={year})")
        observation = search_tool(client, query, year)
        print("   " + observation.splitlines()[0])
        history += f"\n\nYou searched: {query} (year={year})\n{observation}"

    print("Stopped: reached the step limit.")

The LLM gets one tool (search, with an optional year) and replies with a small JSON decision each turn. If the best match scores below 0.45, the result is labelled WEAK, which nudges the agent to search again with better words. MAX_STEPS stops endless loops.

Demo questionExpected behaviourAnswer in the documents
"Thanks, that helped!"No search; answers directlyn/a
"Compare the 2025 and 2026 casual leave policies."Two searches, year=2025 and year=20262025: 10 days, no carry-over, email HR 5 days ahead. 2026: 14 days, carry over up to 3, HR portal 2 days ahead.
"fix for E-14 on machine 3?"May search twice if the first results are weakTorch not reaching temperature: switch off, open the gas valve fully, clean the nozzle, restart; if it repeats, call the maintenance supervisor.
Run every level
uv run python step1_basic_rag.py
uv run python step2_hybrid_search.py
uv run python step3a_query_rewriting.py
uv run python step3b_reranking.py
uv run python step4_metadata_filters.py
uv run python step5_agentic_rag.py

6. Which technique, when

TechniqueSolvesCostUse it when
Dense (semantic) searchParaphrases, synonymsLowAlways, as the baseline
Hybrid + RRFCodes, IDs, names, rare termsLowYour data has part numbers, error codes or product names
Query rewritingMessy, short or foreign-language questions+1 LLM callUsers type casually or in other languages
Re-rankingRight chunk found but ranked too low+ cross-encoder pass on 20 chunksMany similar-looking chunks
Metadata filtersPermissions, freshness, scopeFree (inside the DB)Any real company data. This is not optional.
Agentic RAGMulti-step questions, deciding not to searchSeveral LLM callsComparisons, follow-ups, mixed chat and search

7. Gotchas and troubleshooting

ProblemFix
No .env.example in the repoCreate .env yourself from the block in section 4
Asked for an Anthropic key you don't haveThe code defaults to LLM_PROVIDER=anthropic. Set gemini or openai in .env, and install the matching extra.
Model not found (404)Default model names get retired. Set LLM_MODEL to a model on your provider's current list.
Connection refused on localhost:6574docker compose ps; run docker compose up -d
ModuleNotFoundError: anthropic / googleuv sync --extra anthropic or --extra gemini
Empty or stale resultsRe-run build_index.py (it recreates the collection)
First run is slowNormal; the embedding and re-ranker models are downloading
Answers vary between runsRetrieval is deterministic, but LLM wording is not

Before production (from the repo's own notes): paragraph chunking suits only short documents; BM25 loads every chunk into memory; access control here is a role string, with no real authentication; the agent parses the first JSON object in the reply. The repo has no license file yet, so ask the author before reusing the code.

8. Quick quiz

1. Why does semantic search miss "BRK-2270"?

An embedding captures meaning. A part code has almost none, and similar codes look the same to the model. Keyword search (BM25) matches it exactly.

2. Why does RRF merge by rank instead of score?

BM25 scores and cosine similarities are on different scales. Ranks are comparable, so a chunk near the top of both lists wins.

3. Why search with both the original and the rewritten question?

The rewrite can lose details. Searching both keeps what the user actually said while adding the cleaner version.

4. Why re-rank only 20 candidates?

A cross-encoder reads question and chunk together, which is accurate but slow. A fast first pass shortlists, and the slow pass orders the shortlist.

5. Where must the access filter live?

In your app, applied inside the database search, so restricted chunks never reach the LLM. Never in the prompt.

6. What stops the agent from looping forever?

MAX_STEPS = 4. After four decisions it stops.