01 / 12

Meet Maya: One Question, Nine Steps

RAG
The journey ahead Ingest Clean Chunk Embed Store Retrieve Rerank Guard Reflect
stage 0 / 5 answer = generate(query, retrieve(query)) 9-stage pipeline Maya types her question
🙋Maya types one question into ByteToAI's support chat: "How long do I have to request a refund?" We'll follow this exact question through every stage of this tutorial.

Retrieval-Augmented Generation (RAG) pairs a search system with a large language model so answers are grounded in your data, not just what the model memorized during training. Watch the animation above — Maya's question travels to a retriever, which searches a vector database, hands back the right sentence, and the LLM turns it into a real answer: "You have 30 days from your purchase date."

🔑Key idea: the LLM never memorized ByteToAI's refund policy — RAG fetches the exact sentence that answers Maya's question, then asks the model to phrase it as a reply.
Why it matters: it cuts hallucination, keeps answers current without retraining, and works over documents the model never saw.
🗺️Where we're headed: the bar above this animation is your map — it'll track exactly which stage we're on for the rest of the tutorial.

Code Example

pythonfrom rag_pipeline import Retriever, LLM retriever = Retriever(index="docs-v1") llm = LLM(model="claude-sonnet-5") def ask(query: str) -> str: chunks = retriever.search(query, top_k=3) # 1. retrieve context = "\n".join(c.text for c in chunks) # 2. assemble return llm.generate(query, context) # 3. generate ask("How long do I have to request a refund?") # -> "You have 30 days from your purchase date to request a refund."

Three lines do the real work: retrieve the relevant sentence, assemble it into a prompt, and let the LLM generate a grounded answer.

Real-World Use Case
Perplexity AI

Perplexity answers questions by retrieving live web pages and citing them inline — the same retrieve-then-generate pattern Maya's question just used, applied to the open web.

RetrievalCitationsLLM
Why Professionals Use This
Cheaper than fine-tuning, always current

Retraining a model on new policy docs costs time and money and is stale the moment it finishes. RAG lets ByteToAI update the refund policy and have Maya's next question answered correctly instantly.

02 / 12

Ingesting Text, Images & Tables

RAG
Ingest Clean Chunk Embed Store Retrieve Rerank Guard Reflect
source 0 / 3 Document = extract(file, modality) Modality-specific I/O reading ByteToAI's knowledge base
📚Before ByteToAI can answer Maya, it has to read its own knowledge base: a refund policy PDF, a scanned invoice image, and a pricing spreadsheet.

Each of those three file types needs a different extraction tool before it becomes plain text the rest of the pipeline can use.

🔑Key idea: text/PDF uses parsers (PyPDF, Unstructured.io, LlamaParse), images use OCR/vision models (Tesseract, CLIP, GPT-4V), and tables use pandas/SQL connectors.
Why it matters: garbage extraction — broken layouts, missed image text — poisons every downstream step; the refund policy chunk Maya needs must come out clean and intact.
⚠️Common mistake: assuming one "read the file" call handles everything. Scanned PDFs need OCR, and tables inside PDFs need layout-aware extraction, not a raw text dump.

Code Example

pythonimport pandas as pd from unstructured.partition.auto import partition import pytesseract from PIL import Image def load_text(path): return partition(filename=path) # PDFs, HTML, docx def load_image(path): return pytesseract.image_to_string(Image.open(path)) # OCR def load_table(path): return pd.read_csv(path).to_string() # CSV / SQL rows

Each loader returns plain text, but extraction quality differs — a layout-aware PDF parser keeps the refund policy's sentences intact instead of scrambling them.

Real-World Use Case
LlamaIndex Readers

LlamaIndex ships 150+ "readers" that ingest Slack threads, Notion pages, S3 buckets, and scanned PDFs into a normalized Document format any RAG pipeline can consume.

PDFOCRSQL
Why Professionals Use This
Layout-aware beats "just read the text"

Layout-aware parsers preserve table structure, so a later question like "what does the Team plan cost" pulls the right cell instead of a scrambled row of numbers.

03 / 12

Cleaning & Preprocessing

RAG
Ingest Clean Chunk Embed Store Retrieve Rerank Guard Reflect
stage 0 / 3 clean = dedupe(normalize(raw)) O(n) per document raw policy text
🧹The refund policy PDF came out of ingestion with a repeated "Page 1 of 1" footer stuck to it twice. That has to go before Maya's answer gets built from it.

Raw extracted text is messy: repeated page headers and footers, broken character encodings, duplicate paragraphs, and leftover navigation boilerplate from scraped HTML.

🔑Key idea: cleaning normalizes text (unicode, whitespace), removes boilerplate and duplicates, and can redact PII before anything reaches the vector store.
Why it matters: if "Page 1 of 1" survives cleaning, it becomes its own chunk later and can get retrieved instead of the sentence Maya actually needs.
⚠️Common mistake: over-cleaning — stripping every newline can destroy the structure that chunking relies on later.

Code Example

pythonimport re, hashlib def clean_text(raw: str) -> str: text = raw.encode("utf-8", "ignore").decode("utf-8") # fix encoding text = re.sub(r"\s+", " ", text).strip() # normalize whitespace text = re.sub(r"Page \d+ of \d+", "", text) # strip boilerplate return text.strip() clean_text(raw_policy_text) # -> "You can request a refund within 30 days of purchase. ..."

One regex removes the repeated footer completely, leaving only the two sentences that actually matter for Maya's question.

Real-World Use Case
Healthcare & legal RAG systems

Regulated deployments run PII scrubbers such as Microsoft Presidio over documents before indexing, so patient or client names never enter a vector store that might be queried broadly.

PII redactionDedupNormalize
Why Professionals Use This
Compliance requires it, not just quality

HIPAA and GDPR require sensitive data to be redacted before storage — cleaning isn't just a quality step here, it's a legal gate the pipeline must pass.

04 / 12

Chunking Strategies

RAG
Ingest Clean Chunk Embed Store Retrieve Rerank Guard Reflect
Chunk size
3 clean chunks chunks = split(doc, size=40) O(n) characters sentence-aligned
✂️The cleaned policy becomes three bite-sized chunks — A, B, C — small enough to search individually, each still a complete thought.

LLMs and embedding models have limited context, so documents get split into chunks small enough to embed and retrieve individually, but large enough to keep meaning intact.

🔑Key idea: good chunking splits at sentence or paragraph boundaries; naive fixed-size chunking can slice a sentence in half.
Why it matters: chunk A ("...30 days of purchase.") is the one sentence that answers Maya — if it gets cut in half, no single chunk answers her question anymore.
⚠️Common mistake: drag the chunk size slider down — watch chunk A break mid-sentence. That's a real failure mode, not just a diagram.

Code Example

pythondef chunk_by_sentence(text: str, max_size: int = 80) -> list[str]: sentences = text.split(". ") chunks, buf = [], "" for s in sentences: if len(buf) + len(s) > max_size and buf: chunks.append(buf.strip()); buf = "" buf += s + ". " if buf: chunks.append(buf.strip()) return chunks # -> ["You can request a refund within 30 days of purchase.", ...]

Splitting on sentence boundaries instead of a hard character count is what keeps chunk A a complete, answerable sentence.

Real-World Use Case
LangChain RecursiveCharacterTextSplitter

Most production RAG stacks start with a recursive splitter that tries paragraph, then sentence, then word boundaries before falling back to a hard character cut.

chunk_sizeoverlapby_title
Why Professionals Use This
10–20% overlap is the default for a reason

A little overlap between chunks prevents a sentence that answers the question from being cut exactly at a boundary, without duplicating so much that the index bloats.

05 / 12

Embeddings: Meaning as Geometry

RAG
Ingest Clean Chunk Embed Store Retrieve Rerank Guard Reflect
iteration 0 sim(a,b) = (a·b) / (‖a‖‖b‖) O(d) per pair placing chunks in meaning-space
🧲Chunks A, B and C (all about refunds) drift together. Chunk D — "Team plan costs $49/month" — drifts away. Maya's question lands right next to A.

An embedding model converts each chunk into a vector of numbers such that semantically similar chunks land close together in space — meaning becomes geometry.

🔑Key idea: embedding models (OpenAI text-embedding-3, Cohere embed, open-source BGE/E5) turn text into geometry — cosine distance approximates semantic distance.
Why it matters: Maya's question never mentions "chunk A" — it lands near it because both talk about refunds and time windows, not because of shared keywords.
⚠️Common mistake: mixing embeddings from two different models in one index — distances become meaningless because the vector spaces aren't aligned.

Code Example

pythonfrom sentence_transformers import SentenceTransformer model = SentenceTransformer("BAAI/bge-small-en") vecs = model.encode([question, chunk_a, chunk_d]) cosine(vecs[0], vecs[1]) # question vs chunk A -> 0.86 (close) cosine(vecs[0], vecs[2]) # question vs chunk D -> 0.11 (far)

Watch the canvas — particles carrying the real chunk text drift toward chunks with similar meaning, exactly like nearby vectors in a real embedding space.

Real-World Use Case
GitHub code search & Notion AI search

Both use domain-tuned embedding models — a code-aware model for GitHub, a document-aware model for Notion — rather than a generic off-the-shelf embedding.

bge / e5cosine simANN index
Why Professionals Use This
Swapping embedding models moves the needle most

Switching from a generic to a domain-tuned embedding model can improve retrieval recall by 20–30% without touching the LLM at all.

06 / 12

Vector Databases & ANN Search

RAG
Ingest Clean Chunk Embed Store Retrieve Rerank Guard Reflect
shelf scan 0 / 3 index.search(q, k=3) → O(log n) via HNSW O(log n) vs O(n) Maya's question arrives
📦Think of the vector database as a shelf of labeled cards (A, B, C, D). Maya's question walks straight to the nearby shelf instead of reading every card on the shelf.

Vector databases store embeddings and find the "nearest" ones to a query in milliseconds using an approximate nearest neighbor (ANN) index instead of comparing against every single vector.

🔑Key idea: options range from libraries (FAISS) to managed databases (Pinecone, Weaviate, Qdrant, pgvector) — they trade off latency, cost, and hosting complexity.
Why it matters: with millions of chunks, checking every single one would be too slow — ANN indexes like HNSW jump straight to the nearby shelf.
⚠️Common mistake: picking a vector database before defining metadata filtering needs — retrofitting filtered search (by tenant, date, permission) later is painful.

Code Example

pythonimport chromadb client = chromadb.Client() col = client.create_collection("docs") col.add(ids=ids, embeddings=vecs, documents=texts) results = col.query(query_embeddings=[question_vec], n_results=3) # -> [chunk_a, chunk_b, chunk_c] (chunk_d never gets checked)

The search returns chunks A, B, C — the shelf near Maya's question — without ever touching chunk D.

Real-World Use Case
pgvector on existing Postgres

Many teams (Notion, Shopify Sidekick) add pgvector to a Postgres database they already run, avoiding a new distributed system just for vectors.

HNSWpgvectormetadata filter
Why Professionals Use This
Reuse vs. raw speed trade-off

Reusing Postgres cuts operational overhead at the cost of raw ANN throughput compared to a purpose-built vector database like Pinecone or Qdrant at huge scale.

07 / 12

Retrieval: Dense, Sparse & Hybrid

RAG
Ingest Clean Chunk Embed Store Retrieve Rerank Guard Reflect
α (dense weight)
α = 0.50 score = α·dense + (1−α)·BM25 top_k retrieval ranking A, B, C, D for Maya
⚖️Chunk A shares the word "refund" and the meaning "time window" with Maya's question — both signals should push it to the top.

Dense retrieval (embeddings) captures meaning; sparse retrieval (BM25/keyword) captures exact terms like IDs and product names; hybrid blends both into one score.

🔑Key idea: dense search alone can miss exact strings like an order number that never appears near similar-meaning text; hybrid search fixes this by fusing BM25 and vector scores.
Why it matters: drag α to 0 (sparse only) or 1 (dense only) and watch chunk A's rank change — real queries need both signals working together.
⚠️Common mistake: tuning only top_k and never the fusion weight α between dense and sparse scores.

Code Example

pythonfrom rank_bm25 import BM25Okapi bm25 = BM25Okapi([c.split() for c in [chunk_a, chunk_b, chunk_c, chunk_d]]) sparse = bm25.get_scores(question.split()) dense = cosine_scores(question_vec, chunk_vecs) alpha = 0.5 fused = alpha * normalize(dense) + (1 - alpha) * normalize(sparse) # -> chunk A scores highest for Maya's question

Drag α — at 1.0 you get pure semantic (dense) ranking, at 0.0 pure keyword (BM25) ranking, and in between a fused score that usually performs best.

Real-World Use Case
Elasticsearch & Weaviate hybrid search

Both ship built-in hybrid search combining BM25 with vector similarity via reciprocal rank fusion, so teams don't have to hand-roll the fusion logic.

BM25RRFdense vectors
Why Professionals Use This
Consistent recall gains

Hybrid retrieval reliably boosts recall@10 by double digits over dense-only search on standard benchmarks like BEIR.

08 / 12

Reranking for Precision

RAG
Ingest Clean Chunk Embed Store Retrieve Rerank Guard Reflect
reranked 0 / 3 score = CrossEncoder(query, chunk) O(k), k ≪ n retriever's rough order
🎯The retriever handed back A, B, C in a rough order. The reranker reads each one together with Maya's exact question and confirms A truly answers it best.

The retriever's job is recall — get plausible candidates fast. The reranker's job is precision: a slower, more accurate model re-scores each candidate against the query and reorders them.

🔑Key idea: a cross-encoder looks at the (query, chunk) pair jointly, catching relevance nuances that fast embedding similarity misses.
Why it matters: without reranking, whichever chunk the retriever happened to rank first is what the LLM sees — reranking makes sure that's really chunk A.
⚠️Common mistake: reranking the whole corpus instead of just the retriever's shortlist — cross-encoders are too slow to run over millions of chunks.

Code Example

pythonfrom sentence_transformers import CrossEncoder reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2") pairs = [(question, c) for c in [chunk_a, chunk_b, chunk_c]] scores = reranker.predict(pairs) # joint (query, chunk) scoring # -> chunk A: 0.94, chunk B: 0.41, chunk C: 0.22

The cross-encoder confirms chunk A — "within 30 days of purchase" — is the strongest match for Maya's exact question.

Real-World Use Case
Cohere Rerank API

Teams bolt a hosted reranker like Cohere Rerank onto an existing retriever with a single API call, no model hosting required.

cross-encodertop_k reorder
Why Professionals Use This
A cheap accuracy upgrade

Teams commonly report 10–15% answer-accuracy gains just from adding a reranker on top of an otherwise unchanged retriever.

09 / 12

Guardrails & Safety

RAG
Ingest Clean Chunk Embed Store Retrieve Rerank Guard Reflect
idle answer = guard_output(generate(context, query)) +1–2 model calls awaiting query
🛡️Maya's question sails through three checkpoints. Try the malicious query button to see a prompt-injection attempt get stopped at the very first gate.

Guardrails are checks placed around the RAG pipeline to stop bad inputs, leaked data, and ungrounded answers from ever reaching the user.

🔑Key idea: guardrails apply at three points — input (prompt-injection detection), retrieval (access control), and output (does the answer actually match chunk A?).
Why it matters: RAG answers feel authoritative even when wrong; without output guardrails, a hallucinated "60 days" could ship with a citation that doesn't actually say that.
⚠️Common mistake: guarding only the LLM's output and forgetting retrieval-time access control — a badly scoped index can leak one customer's private docs into another's answer.

Code Example

pythonBLOCKED = ["ignore previous instructions", "system prompt"] def input_guard(query: str) -> bool: return not any(p in query.lower() for p in BLOCKED) def output_guard(answer: str, chunk_a: str) -> bool: return "30 days" in answer and "30 days" in chunk_a

The output guard checks that "30 days" in the final answer really does come from chunk A, not from the model's imagination.

Real-World Use Case
NVIDIA NeMo Guardrails & Azure AI Content Safety

Enterprise RAG deployments wrap the whole pipeline in a guardrail layer like these, checking input, retrieval scope, and output before a response ever reaches the user.

Prompt injectionAccess controlFaithfulness
Why Professionals Use This
Required for compliance sign-off

Regulated industries like finance and healthcare require an auditable guardrail layer to pass compliance review before a RAG assistant can ship.

10 / 12

Feedback & Reflection Loops

RAG
Ingest Clean Chunk Embed Store Retrieve Rerank Guard Reflect
lap 0 faithfulness, relevancy = RAGAS(answer, context, query) offline eval Maya got her answer
👍Maya gives the "30 days" answer a thumbs up. That signal gets logged, scored, and quietly makes the next customer's answer a little better too.

A production RAG system keeps improving after launch by capturing feedback — thumbs up/down, follow-up questions — and running automated evaluation to find weak spots in retrieval or generation.

🔑Key idea: frameworks like RAGAS score answers on faithfulness, answer relevancy, and context precision/recall — turning "the bot feels wrong sometimes" into a measurable, trackable number.
Why it matters: without this loop, nobody would ever notice if chunking started splitting chunk A badly again after a policy update.
⚠️Common mistake: logging only what the LLM said, not what was retrieved — you can't tell whether a bad answer came from bad retrieval or bad generation without both.

Code Example

pythonfrom ragas import evaluate from ragas.metrics import faithfulness, answer_relevancy, context_precision results = evaluate(maya_eval_row, metrics=[faithfulness, answer_relevancy, context_precision]) print(results) # -> {'faithfulness': 0.97, 'context_precision': 0.91, ...}

Every lap in the animation is one feedback cycle: an answer goes out, feedback comes back, gets logged, evaluated, and feeds an improvement back into the pipeline.

Real-World Use Case
RAGAS + LangSmith / Arize Phoenix

Teams pair RAGAS metrics with observability tools like LangSmith or Arize Phoenix to monitor production RAG quality continuously, not just at launch.

RAGASTracingDashboards
Why Professionals Use This
Catches regressions before users complain

A 5% drop in context precision after a policy-doc refresh flags a chunking regression automatically, before it shows up as a wave of "Maya"s complaining.

11 / 12

Maya's Full Journey, End to End

RAG
Ingest Clean Chunk Embed Store Retrieve Rerank Guard Reflect
stage 0 / 8 answer = pipeline("How long do I have to request a refund?") ~500ms – 2s typical elapsed: 0ms
🏁Every stage you just learned, back to back, on Maya's exact question — ending in the same grounded answer: "You have 30 days from your purchase date."

The refund policy gets read, cleaned, and chunked once, ahead of time. From here on, every stage happens live, in under two seconds, the moment Maya hits enter.

🔑Key idea: each stage adds latency and can independently fail, so production systems trace every stage — with timing — to debug slow or wrong answers.
Why it matters: RAG quality is a systems problem, not a single "make the LLM smarter" problem — a bad answer could stem from any one of these eight live stages.
⚠️Common mistake: testing the LLM prompt in isolation and never tracing the full pipeline's latency and quality budget together.

Code Example

pythondef ask(query: str) -> dict: if not input_guard(query): return {"answer": "Blocked"} q_vec = embed(query) candidates = hybrid_search(query, q_vec, top_k=3) ranked = rerank(query, candidates) answer = llm.generate(query, ranked[0].text) return {"answer": answer} if output_guard(answer, ranked[0].text) else {"answer": "Uncertain"} ask("How long do I have to request a refund?") # -> {"answer": "You have 30 days from your purchase date to request a refund."}

Every function call in this snippet maps to a stage on the timeline above — step through it to see where the two seconds actually go.

Real-World Use Case
Enterprise copilots & answer engines

Internal copilots and answer engines like Perplexity trace this exact multi-stage journey per request, timing every hop from query to cited answer.

TracingLatency budgetCitations
Why Professionals Use This
Debug in seconds, not hours

End-to-end tracing (e.g. via LangSmith) lets teams pinpoint whether a bad answer came from retrieval, reranking, or generation in seconds instead of hours of guesswork.

12 / 12

Summary & Next Steps

RAG
Ingest Clean Chunk Embed Store Retrieve Rerank Guard Reflect
0 / 9 covered RAG = Ingest → Clean → Chunk → Embed → Store → Retrieve → Rerank → Guard → Reflect Full pipeline recap
🎉Maya asked one question. It took nine stages, three candidate chunks, and one grounded answer — "30 days" — with nothing invented.

You've now covered the full RAG pipeline: multi-modal ingestion, cleaning, chunking, embedding, vector storage, hybrid retrieval, reranking, guardrails, and feedback loops — the same architecture behind production systems like Perplexity, enterprise copilots, and internal document search assistants.

🔑Key idea: RAG quality is won or lost at the data layer (cleaning/chunking) and the retrieval layer (hybrid + rerank) far more often than at the LLM layer.
Why it matters: when a RAG system underperforms, debug in this order — ingestion, chunking, retrieval, reranking, generation, guardrails — rather than jumping straight to "try a bigger model."
🚀Next step: build a small RAG system over your own FAQ or notes this week — the fastest way to internalize every stage in this tutorial.

Starter Stack

pythonfrom langchain.text_splitter import RecursiveCharacterTextSplitter from langchain_community.vectorstores import Chroma from langchain.chains import RetrievalQA splitter = RecursiveCharacterTextSplitter(chunk_size=400, chunk_overlap=40) db = Chroma.from_documents(splitter.split_documents(docs), embeddings) qa = RetrievalQA.from_chain_type(llm=llm, retriever=db.as_retriever())

This four-line stack (splitter + vector store + retrieval chain) is a real, runnable starting point for everything you just learned.

Where To Go Next
LlamaIndex & LangChain docs, RAGAS docs

Both frameworks ship end-to-end RAG quickstarts; RAGAS docs cover setting up automated evaluation once your pipeline is live.

LlamaIndexLangChainRAGAS
Try It Yourself
Pick your own "Maya" question

Take three sentences from a document you actually have, chunk them, embed them, and ask a question — you'll see the exact same nine stages play out.