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.
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.
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.
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)BRK-2270A 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.
| Document | Type | Access | Year |
|---|---|---|---|
| Remote Work Policy | hr_policy | all_staff | 2026 |
| Casual Leave Policy 2026 | hr_policy | all_staff | 2026 |
| Casual Leave Policy 2025 | hr_policy | all_staff | 2025 |
| Salary Bands (Confidential) | hr_policy | hr_only | 2026 |
| Maintenance Manual: Brakes | manual | all_staff | 2026 |
| Maintenance Manual: Engine Oil | manual | all_staff | 2026 |
| Assembly Machine Error Codes | manual | all_staff | 2026 |
| Spare Parts Catalog: Brakes | parts_catalog | all_staff | 2026 |
| Spare Parts Catalog: Engine | parts_catalog | all_staff | 2026 |
| Workshop Safety Rules | safety | all_staff | 2026 |
| Need | Notes |
|---|---|
| Python 3.11+ and uv | Plain pip also works |
| Docker with Compose v2 | Runs 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 key | Gemini, OpenAI or Anthropic. Free option: a Gemini API key from Google AI Studio. |
| Embeddings and re-ranker | Run locally, free, no key. Downloaded on first use. |
# 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 (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:6574Read 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.
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)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 clientCut 10 documents into 32 chunks
Each chunk is now a list of 384 numbers
Stored 32 points in VectorAI DBEmbeddings 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.
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))"How much does the oil filter for the Trail 250 cost?" The catalog says 14 euros (ENG-0415).
"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).
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, mergedBM25 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.
BRK-2270 → 46 euros. Meaning questions like "Can I work from home on Fridays?" still work.
"Meine Bremsen quietschen beim Anhalten, was soll ich tun?" (German). No shared words for BM25, and the embedding model only knows English.
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.
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.
"How thin can brake pads get before replacing?" The "2 mm" chunk ranks below brake-pad price chunks that look similar in meaning.
reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2") # small, runs locallydef 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.
Brake pads → replace when thinner than 2 mm.
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.
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.
The intern's salary question finds nothing. "How many casual leave days?" with year 2026 → 14 days (the 2025 policy said 10).
"Compare the 2025 and 2026 casual leave policies." One search with one fixed filter can't fetch both years.
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."""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 question | Expected behaviour | Answer in the documents |
|---|---|---|
| "Thanks, that helped!" | No search; answers directly | n/a |
| "Compare the 2025 and 2026 casual leave policies." | Two searches, year=2025 and year=2026 | 2025: 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 weak | Torch not reaching temperature: switch off, open the gas valve fully, clean the nozzle, restart; if it repeats, call the maintenance supervisor. |
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| Technique | Solves | Cost | Use it when |
|---|---|---|---|
| Dense (semantic) search | Paraphrases, synonyms | Low | Always, as the baseline |
| Hybrid + RRF | Codes, IDs, names, rare terms | Low | Your data has part numbers, error codes or product names |
| Query rewriting | Messy, short or foreign-language questions | +1 LLM call | Users type casually or in other languages |
| Re-ranking | Right chunk found but ranked too low | + cross-encoder pass on 20 chunks | Many similar-looking chunks |
| Metadata filters | Permissions, freshness, scope | Free (inside the DB) | Any real company data. This is not optional. |
| Agentic RAG | Multi-step questions, deciding not to search | Several LLM calls | Comparisons, follow-ups, mixed chat and search |
| Problem | Fix |
|---|---|
No .env.example in the repo | Create .env yourself from the block in section 4 |
| Asked for an Anthropic key you don't have | The 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:6574 | docker compose ps; run docker compose up -d |
ModuleNotFoundError: anthropic / google | uv sync --extra anthropic or --extra gemini |
| Empty or stale results | Re-run build_index.py (it recreates the collection) |
| First run is slow | Normal; the embedding and re-ranker models are downloading |
| Answers vary between runs | Retrieval 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.
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.
BM25 scores and cosine similarities are on different scales. Ranks are comparable, so a chunk near the top of both lists wins.
The rewrite can lose details. Searching both keeps what the user actually said while adding the cleaner version.
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.
In your app, applied inside the database search, so restricted chunks never reach the LLM. Never in the prompt.
MAX_STEPS = 4. After four decisions it stops.