Build a Simple RAG Application Using Python (Step-by-Step)

Build a simple RAG application in Python from scratch with OpenAI embeddings and NumPy. Step-by-step code for chunking, retrieval, and grounded answers.

Build a Simple RAG Application Using Python tutorial showing a rag.py code card and the load, chunk, embed, retrieve and answer pipeline

Introduction

Building a RAG application sounds complicated until you actually build one.

At its simplest, a RAG application does two things. It finds the passages in your documents that are relevant to a question, and then it asks an LLM to answer using only those passages.

In the previous articles of this series, we looked at each piece separately: embeddings, vector databases, semantic search, and document chunking.

Now we're putting them together.

In this tutorial, we'll build a small RAG application in Python from scratch.

No LangChain. No LlamaIndex. No vector database server.

Just the official openai SDK and NumPy.

By the end, you'll know how to:

· Load your own text and Markdown files

· Split them into chunks with overlap

· Create embeddings with the OpenAI API

· Store the vectors on disk

· Retrieve the most relevant chunks with cosine similarity

· Generate an answer that cites its sources

If RAG itself is completely new to you, read What Is RAG? Retrieval-Augmented Generation Explained first.

We won't repeat the theory here.


Quick Answer

To build a simple RAG application in Python, you load your documents, split them into chunks, embed each chunk, and save the vectors. At question time, you embed the question, find the most similar chunks, and send them to an LLM with the question.

The complete workflow looks like this:

Load Documents
      ↓
Split Into Chunks
      ↓
Create Embeddings
      ↓
Store Vectors
      ↓
Retrieve Relevant Chunks
      ↓
Generate Answer

That's six steps.

Each step becomes one Python function.


What We're Building

We'll build a command-line app called:

rag.py

It has two commands:

python rag.py index docs/

python rag.py ask "What is our refund policy?"

The first command embeds your documents once.

The second command answers questions any time, using only your documents.

Every answer cites the chunks it used, like this:

[docs/refunds.md#0]

So you can always check where a claim came from.

The whole app is about 120 lines of Python.

Architecture

There are two phases.

Indexing runs once, or whenever your documents change.

Querying runs on every question.

INDEXING (run once)

┌───────────────────────┐
│ Text / Markdown files │
└──────────┬────────────┘

▼
     Chunk into passages
 ▼
     Embed each chunk
 ▼
┌───────────────────────┐
│ vectors.npy           │
│ chunks.json           │
└──────────┬────────────┘
           │
QUERYING (every question)
           │
User question ──► Embed the question
           │            │
           ▼            ▼
   Cosine similarity vs. all chunks
           ▼
      Top-k chunks
           ▼
Prompt: instructions + context + question
           ▼
          LLM
           ▼
  Answer with citations

Each box maps to one function in our code.

Load

load_documents() reads .txt and .md files.

Chunk

chunk_text() splits text into passages of about 1,000 characters with overlap. Deep dive: How Document Chunking Works in RAG.

Embed

embed() turns text into vectors with an OpenAI embedding model. Deep dive: What Are Embeddings?

Store

build_index() saves the vectors and chunk text to disk. Deep dive: Vector Databases Explained.

Retrieve

retrieve() finds the chunks most similar to the question. Deep dive: Semantic Search vs Keyword Search.

Generate

answer() sends the context and the question to the LLM.


1. Prerequisites

You don't need advanced Python knowledge.

You'll need:

· Python 3.10 or newer, because the code uses list[str] style type hints

· An OpenAI API key, from the OpenAI API keys page

· A terminal and a code editor

Check Python:

python --version

We'll use two models.

Both can be changed with environment variables.

Embedding model

text-embedding-3-small

OpenAI's embeddings guide describes it as its cost-efficient third-generation embedding model. It returns 1,536-dimensional vectors by default.

Chat model

gpt-6-luna

OpenAI's models page positions it for cost-sensitive, high-volume workloads.

Any current chat model works. Set CHAT_MODEL to swap it.


2. Create the Project

Open your terminal.

Create a project folder:

mkdir simple-rag && cd simple-rag

Create a virtual environment:

python -m venv .venv

Activate it on macOS or Linux:

source .venv/bin/activate

On Windows:

.venv\Scripts\activate

Install the two dependencies:

pip install openai numpy

Set your API key on macOS or Linux:

export OPENAI_API_KEY="sk-..."

On Windows PowerShell:

$env:OPENAI_API_KEY="sk-..."

The OpenAI client reads OPENAI_API_KEY from the environment automatically.

Your key never appears in your code.


3. Add Some Sample Documents

We need something to search.

Create a docs folder:

mkdir docs

Add a refund policy:

printf 'Refund policy\n\nCustomers can request a full refund within 30 days of purchase by emailing support.\n\nAfter 30 days, refunds are issued as store credit only.\n' > docs/refunds.md

Add a shipping policy:

printf 'Shipping\n\nStandard shipping takes 3 to 5 business days within India.\n\nExpress shipping takes 1 to 2 business days and costs extra.\n' > docs/shipping.md

Your project should now look like this:

simple-rag/
├── .venv/
├── docs/
│   ├── refunds.md
│   └── shipping.md
└── rag.py

We'll create rag.py piece by piece.

If you prefer to copy everything at once, jump to Final Code.


4. Load Your Documents

Start with the simple part.

Create:

rag.py

Add this function:

from pathlib import Path

def load_documents(folder: str) -> list[dict]:
    docs = []
    for path in sorted(Path(folder).rglob("*")):
        if path.suffix.lower() in {".txt", ".md"}:
            docs.append({"source": str(path), "text": path.read_text(encoding="utf-8")})
    return docs

It walks the folder recursively and keeps every .txt and .md file.

Look at this line:

docs.append({"source": str(path), "text": path.read_text(encoding="utf-8")})

We keep the file path as source.

Later, that path becomes the citation in every answer.

PDFs, web pages, and databases are just different loaders.

They all end in the same shape:

[{"source": ..., "text": ...}, ...]

We'll handle PDFs properly in the Chat with PDF article later in this series.


5. Split the Text Into Chunks

Embedding a whole document into one vector blurs its meaning.

Stuffing whole documents into a prompt wastes tokens.

So we split each document into passages that are small enough to be specific, but large enough to make sense on their own.

Add this function:

def chunk_text(text: str, max_chars: int = 1000, overlap: int = 200) -> list[str]:
    """Pack paragraphs into chunks of ~max_chars, carrying a small overlap."""
    paragraphs = [p.strip() for p in text.split("\n\n") if p.strip()]
    chunks, current = [], ""
    for para in paragraphs:
        if current and len(current) + len(para) + 2 > max_chars:
            chunks.append(current)
            current = current[-overlap:]  # tail of the previous chunk
        current = f"{current}\n\n{para}" if current else para
        while len(current) > max_chars:  # a single huge paragraph
            chunks.append(current[:max_chars])
            current = current[max_chars - overlap:]
    if current:
        chunks.append(current)
    return chunks

The chunker packs paragraphs together until a chunk reaches about 1,000 characters.

Then it carries the last 200 characters into the next chunk.

That overlap means a sentence that sits on a boundary isn't lost.

Chunk 1: ................[overlap]
Chunk 2:                 [overlap]................[overlap]
Chunk 3:                                          [overlap]........

Character-based chunking is the simplest strategy that works.

There's no universally best chunk size.

It depends on your documents and your questions, so treat 1000 and 200 as a starting point.

Then tune them against real queries.

Our guide How Document Chunking Works in RAG compares fixed-size, recursive, and semantic approaches.


6. Create OpenAI Embeddings in Python

An embedding model maps text to a vector.

Passages with similar meaning end up close together.

The OpenAI embeddings API accepts a list of strings, so we embed in batches instead of making one request per chunk.

Add this:

import numpy as np
from openai import OpenAI

EMBED_MODEL = "text-embedding-3-small"
client = OpenAI()  # reads OPENAI_API_KEY

def embed(texts: list[str], batch_size: int = 100) -> np.ndarray:
    vectors = []
    for i in range(0, len(texts), batch_size):
        batch = [t.replace("\n", " ") for t in texts[i : i + batch_size]]
        response = client.embeddings.create(model=EMBED_MODEL, input=batch)
        vectors.extend(item.embedding for item in response.data)
    matrix = np.array(vectors, dtype=np.float32)
    # Normalize once so cosine similarity becomes a plain dot product.
    return matrix / np.linalg.norm(matrix, axis=1, keepdims=True)

Two details matter here.

Why do we batch?

The API reference documents per-input and per-request token limits.

At the time of writing, that's 8,192 tokens per input and 300,000 tokens summed across one request.

Batches of 100 chunks of about 1,000 characters stay comfortably inside both.

Why do we normalize?

Cosine similarity is:

cosine(a, b) = dot(a, b) / (|a| × |b|)

If every vector already has a length of 1, it becomes:

cosine(a, b) = dot(a, b)

That lets us score every chunk with a single matrix multiplication in the next step.

One rule you shouldn't break:

Always embed the question with the same model you used for the chunks.

Vectors from different models live in different spaces.

They can't be compared.


7. Store the Index on Disk

Embedding costs money and time.

So we do it once and save the result.

NumPy's .npy format holds the vectors.

A JSON file holds the chunk text and metadata in the same order.

Add this:

import json

INDEX_DIR = Path("index")

def build_index(folder: str) -> None:
    records = []
    for doc in load_documents(folder):
        for n, chunk in enumerate(chunk_text(doc["text"])):
            records.append({"id": f"{doc['source']}#{n}", "source": doc["source"], "text": chunk})
    if not records:
        raise SystemExit(f"No .txt or .md files found in {folder}")
    vectors = embed([r["text"] for r in records])
    INDEX_DIR.mkdir(exist_ok=True)
    np.save(INDEX_DIR / "vectors.npy", vectors)
    (INDEX_DIR / "chunks.json").write_text(json.dumps(records, indent=2), encoding="utf-8")
    print(f"Indexed {len(records)} chunks from {folder}")

def load_index() -> tuple[np.ndarray, list[dict]]:
    vectors = np.load(INDEX_DIR / "vectors.npy")
    records = json.loads((INDEX_DIR / "chunks.json").read_text(encoding="utf-8"))
    return vectors, records

After indexing, you'll have:

index/
├── vectors.npy
└── chunks.json

Row i of vectors.npy is the embedding of records[i].

That's the whole "vector database" for now.

It's the same idea a real one implements, minus indexing structures, filtering, updates, and concurrency.

Those are covered in Vector Databases Explained: How They Work in RAG.


8. Retrieve Relevant Chunks With Cosine Similarity

Retrieval embeds the question, scores it against every chunk, and returns the top k.

Add this:

def retrieve(question: str, k: int = 4, min_score: float = 0.2) -> list[dict]:
    vectors, records = load_index()
    query = embed([question])[0]
    scores = vectors @ query  # cosine similarity for every chunk
    top = np.argsort(scores)[::-1][:k]
    return [
        {**records[i], "score": float(scores[i])}
        for i in top
        if scores[i] >= min_score
    ]

Look at this line:

scores = vectors @ query

It produces one similarity score per chunk in a single vectorized operation.

This exact, brute-force search is perfectly fast for thousands of chunks on a laptop.

What does min_score do?

It drops chunks that are only weakly related.

So the model isn't handed irrelevant text.

Score ranges differ between embedding models.

Print a few scores for real questions and pick a threshold from what you see, rather than trusting 0.2 blindly.

What this search can't do yet

This is pure semantic search.

It's great at matching meaning.

For example, a question about "money back" can find a chunk about "refunds".

But it can miss exact terms such as error codes or product SKUs.

We'll fix that with hybrid search in Part 3 of the series.

For the background, see Semantic Search vs Keyword Search.


9. Generate a Grounded Answer

Now comes the "augmented generation" part.

We paste the retrieved chunks, labelled with their ids, into the prompt.

Then we give the model strict instructions.

We use the OpenAI Responses API, which takes instructions and input separately and exposes the final text as response.output_text.

Add this:

CHAT_MODEL = "gpt-6-luna"

INSTRUCTIONS = """You answer questions using ONLY the provided context.
Cite the chunk ids you used in square brackets, like [docs/faq.md#2].
If the context does not contain the answer, say "I don't know based on the provided documents."
"""

def answer(question: str, k: int = 4) -> str:
    hits = retrieve(question, k=k)
    if not hits:
        return "I don't know based on the provided documents."
    context = "\n\n---\n\n".join(f"[{h['id']}]\n{h['text']}" for h in hits)
    response = client.responses.create(
        model=CHAT_MODEL,
        instructions=INSTRUCTIONS,
        input=f"Context:\n{context}\n\nQuestion: {question}",
    )
    return response.output_text

The prompt the model receives looks roughly like this:

Context:
[docs/refunds.md#0]
Refund policy

Customers can request a full refund within 30 days...

Question: Can I get a refund after 30 days?

Three choices here reduce hallucinations.

1. "Only the provided context"

The model is told not to fall back on what it learned during training.

2. An explicit escape hatch

Giving the model a sanctioned way to say "I don't know" makes it far less likely to invent an answer.

3. Citations

Asking for chunk ids makes answers verifiable.

It also makes bad retrieval obvious when you debug.

Notice one more thing.

If nothing passes the similarity threshold, we skip the LLM call entirely.

That saves money and avoids giving the model an empty context to improvise from.


10. What Actually Happened?

Here's the full lifecycle of one question:

User asks a question
        ↓
retrieve() embeds the question
        ↓
vectors @ query scores every chunk
        ↓
Top-k chunks above min_score are kept
        ↓
answer() builds the prompt with chunk ids
        ↓
LLM answers using only that context
        ↓
Answer + citations are printed

That's RAG.

Search first, generate second.

Everything else in this series makes one of these steps better.


11. Final Code

Here's the complete rag.py, including a small argparse CLI:

"""rag.py - a minimal, framework-free RAG app in Python."""
import argparse
import json
import os
from pathlib import Path

import numpy as np
from openai import OpenAI

EMBED_MODEL = os.getenv("EMBED_MODEL", "text-embedding-3-small")
CHAT_MODEL = os.getenv("CHAT_MODEL", "gpt-6-luna")
INDEX_DIR = Path("index")

client = OpenAI()  # reads OPENAI_API_KEY from the environment


# 1. Load ----------------------------------------------------------------
def load_documents(folder: str) -> list[dict]:
    docs = []
    for path in sorted(Path(folder).rglob("*")):
        if path.suffix.lower() in {".txt", ".md"}:
            docs.append({"source": str(path), "text": path.read_text(encoding="utf-8")})
    return docs


# 2. Chunk ---------------------------------------------------------------
def chunk_text(text: str, max_chars: int = 1000, overlap: int = 200) -> list[str]:
    """Pack paragraphs into chunks of ~max_chars, carrying a small overlap."""
    paragraphs = [p.strip() for p in text.split("\n\n") if p.strip()]
    chunks, current = [], ""
    for para in paragraphs:
        if current and len(current) + len(para) + 2 > max_chars:
            chunks.append(current)
            current = current[-overlap:]  # tail of the previous chunk
        current = f"{current}\n\n{para}" if current else para
        while len(current) > max_chars:  # a single huge paragraph
            chunks.append(current[:max_chars])
            current = current[max_chars - overlap:]
    if current:
        chunks.append(current)
    return chunks


# 3. Embed ---------------------------------------------------------------
def embed(texts: list[str], batch_size: int = 100) -> np.ndarray:
    vectors = []
    for i in range(0, len(texts), batch_size):
        batch = [t.replace("\n", " ") for t in texts[i : i + batch_size]]
        response = client.embeddings.create(model=EMBED_MODEL, input=batch)
        vectors.extend(item.embedding for item in response.data)
    matrix = np.array(vectors, dtype=np.float32)
    # Normalize once so cosine similarity becomes a plain dot product.
    return matrix / np.linalg.norm(matrix, axis=1, keepdims=True)


# 4. Store ---------------------------------------------------------------
def build_index(folder: str) -> None:
    records = []
    for doc in load_documents(folder):
        for n, chunk in enumerate(chunk_text(doc["text"])):
            records.append({"id": f"{doc['source']}#{n}", "source": doc["source"], "text": chunk})
    if not records:
        raise SystemExit(f"No .txt or .md files found in {folder}")
    vectors = embed([r["text"] for r in records])
    INDEX_DIR.mkdir(exist_ok=True)
    np.save(INDEX_DIR / "vectors.npy", vectors)
    (INDEX_DIR / "chunks.json").write_text(json.dumps(records, indent=2), encoding="utf-8")
    print(f"Indexed {len(records)} chunks from {folder}")


def load_index() -> tuple[np.ndarray, list[dict]]:
    vectors = np.load(INDEX_DIR / "vectors.npy")
    records = json.loads((INDEX_DIR / "chunks.json").read_text(encoding="utf-8"))
    return vectors, records


# 5. Retrieve ------------------------------------------------------------
def retrieve(question: str, k: int = 4, min_score: float = 0.2) -> list[dict]:
    vectors, records = load_index()
    query = embed([question])[0]
    scores = vectors @ query  # cosine similarity for every chunk
    top = np.argsort(scores)[::-1][:k]
    return [
        {**records[i], "score": float(scores[i])}
        for i in top
        if scores[i] >= min_score
    ]


# 6. Generate ------------------------------------------------------------
INSTRUCTIONS = """You answer questions using ONLY the provided context.
Cite the chunk ids you used in square brackets, like [docs/faq.md#2].
If the context does not contain the answer, say "I don't know based on the provided documents."
"""


def answer(question: str, k: int = 4) -> str:
    hits = retrieve(question, k=k)
    if not hits:
        return "I don't know based on the provided documents."
    context = "\n\n---\n\n".join(f"[{h['id']}]\n{h['text']}" for h in hits)
    response = client.responses.create(
        model=CHAT_MODEL,
        instructions=INSTRUCTIONS,
        input=f"Context:\n{context}\n\nQuestion: {question}",
    )
    return response.output_text


# CLI --------------------------------------------------------------------
if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="A tiny RAG app")
    sub = parser.add_subparsers(dest="command", required=True)
    sub.add_parser("index").add_argument("folder")
    ask = sub.add_parser("ask")
    ask.add_argument("question")
    ask.add_argument("-k", type=int, default=4)
    args = parser.parse_args()

    if args.command == "index":
        build_index(args.folder)
    else:
        print(answer(args.question, k=args.k))

You don't need a framework to understand RAG.

The important pattern is:

Index:  documents → chunks → embeddings → saved vectors
Ask:    question → embedding → top-k chunks → LLM → answer + citations


12. Run the RAG App

Index the sample documents:

python rag.py index docs/

Expected output:

Indexed 2 chunks from docs/

Ask a question:

python rag.py ask "Can I get a refund after 30 days?"

The model should answer that refunds after 30 days are issued as store credit, and cite:

[docs/refunds.md#0]

Try a smaller k:

python rag.py ask "How long does express shipping take?" -k 2

Now ask something your documents don't cover:

python rag.py ask "Do you ship to the moon?"

Expected behaviour:

I don't know based on the provided documents.

That "I don't know" is a feature, not a bug.

Re-run index whenever your documents change.

To try it on something real, point it at a folder of your project's Markdown docs or notes.

What does it cost?

You pay once per chunk at indexing time.

After that, each question costs one short embedding plus one chat call.

Check OpenAI's API pricing page for current rates.

For a small folder of documents, indexing with text-embedding-3-small typically costs a tiny fraction of a dollar.


13. Common RAG Errors

openai.AuthenticationError

Your API key isn't set in the terminal running the script.

Check it:

echo $OPENAI_API_KEY

Then set it again with export (or $env: on Windows) and retry.

FileNotFoundError: index/vectors.npy

You asked a question before building the index.

Run:

python rag.py index docs/

No .txt or .md files found in docs/

The folder is empty, or your files use another extension.

The loader only reads .txt and .md.

Every answer is "I don't know"

Either the right chunk isn't being retrieved, or min_score is too strict for your embedding model.

Print the scores:

print([(h["id"], round(h["score"], 2)) for h in retrieve("your question", k=4)])

Then adjust the threshold based on what you see.


14. Common RAG Mistakes

1. Blaming the Model First

When answers are vague or wrong, print the retrieved chunks before changing the LLM.

Most RAG failures are retrieval failures.

Chunks are too big, too small, or missing the key passage.

2. Not Retrieving the Right Chunk

The right chunk exists, but it isn't retrieved.

Try smaller chunks, increase k, or add keyword search for exact terms.

That's coming in the hybrid search article.

3. Mixing Embedding Models

If you change EMBED_MODEL, rebuild the whole index.

4. Re-Embedding on Every Run

Persist vectors, as we did.

For larger collections, store a content hash per file and only re-embed what changed.

5. Leaking Secrets Into the Index

Anything you index can show up in an answer.

Don't index files that some users shouldn't see.

RAG security gets its own article in Part 4.

6. Skipping Evaluation

Keep a list of 10 to 20 real questions with known answers.

Re-run them whenever you change chunk size, k, or the prompt.


RAG From Scratch vs Frameworks vs Vector Databases

So when should you keep this hand-rolled design, and when should you reach for more tooling?

From Scratch (This Tutorial)

Best for: learning, prototypes, and small private document collections.

Pros: every line is visible, there are only two dependencies, and it's easy to debug.

Cons: you write loaders, chunkers, and evaluation yourself, and search is brute force.

Framework (LangChain, LlamaIndex)

Best for: faster iteration and many data sources.

Pros: ready-made loaders, splitters, retrievers, and integrations.

Cons: more abstraction to learn, and it's harder to see exactly what's sent to the model.

Vector Database (pgvector, Chroma, Qdrant, and Others)

Best for: large or frequently updated collections and multi-user apps.

Pros: approximate nearest-neighbor indexes, metadata filters, and incremental updates.

Cons: another service to run and pay for.

These aren't exclusive.

A common path is exactly the order of this series:

Build it by hand
      ↓
Use the OpenAI API more fully
      ↓
Try LangChain and LlamaIndex
      ↓
Move storage into PostgreSQL with pgvector


15. Try It Yourself

Don't just copy the tutorial.

Add one feature yourself:

--show-sources

When the flag is set, print each retrieved chunk id with its score before the answer.

Example:

docs/refunds.md#0   0.61
docs/shipping.md#0  0.24

Bonus

Skip files that haven't changed since the last index run.

Hint: store a hash of each file's text in chunks.json.

If your flag prints sensible scores and the bonus avoids re-embedding unchanged files, you've understood the core RAG workflow.


FAQs

How do I build a simple RAG application in Python?

Load your documents, split them into chunks, embed each chunk with an embedding model, and save the vectors.

At question time, embed the question, find the most similar chunks with cosine similarity, and send those chunks plus the question to an LLM with instructions to answer only from that context.


Do I need LangChain to build a RAG application?

No.

A basic RAG pipeline needs only an embedding API, a similarity search, and an LLM call, which you can write in about 100 lines of Python with the openai and numpy packages.

Frameworks like LangChain and LlamaIndex become useful when you need many data loaders, advanced retrievers, or faster iteration.


Do I need a vector database for RAG?

Not for small projects.

For a few thousand chunks, a NumPy array with brute-force cosine similarity is fast and simple.

A vector database such as pgvector, Chroma, or Qdrant becomes worthwhile when your data is large, changes often, needs metadata filtering, or serves many users at once.


What chunk size should I use for RAG?

There's no single best chunk size.

A practical starting point is roughly 500 to 1,000 characters with 10 to 20 percent overlap.

Then tune it by testing real questions and checking whether the right passages are retrieved.


How do I stop a RAG application from hallucinating?

Tell the model to answer only from the provided context, give it an explicit way to say "I don't know", require citations to source chunks, and skip the LLM call when no chunk passes a similarity threshold.

Most remaining errors come from poor retrieval, so inspect the retrieved chunks first.


Which OpenAI embedding model should I use for RAG?

Start with text-embedding-3-small, which OpenAI describes as its cost-efficient embedding model.

Move to text-embedding-3-large only if your own evaluation questions show a meaningful retrieval improvement.

Remember to re-embed all documents when you switch models.


Key Takeaways

You've now built a working RAG application from scratch.

Remember these points:

· A basic RAG pipeline has six steps: load, chunk, embed, store, retrieve, and generate.

· You can build it with only openai and numpy.

· Normalized embeddings turn cosine similarity into one matrix multiplication.

· Persist embeddings to disk so you only pay to embed your documents once.

· Always embed questions and chunks with the same model.

· A strict prompt with an "I don't know" escape hatch and citations does most of the work against hallucinations.

· Most bad answers are retrieval problems, so inspect the retrieved chunks first.

· This in-memory design is great for learning and for a few thousand chunks. Move to a vector database when you outgrow it.

The biggest lesson is simple:

RAG isn't magic. It's search first, generate second, and every step is code you can read.


Conclusion

RAG frameworks can make retrieval feel mysterious.

It isn't.

You loaded files, split them into chunks, turned them into vectors, and saved them.

Then you searched those vectors with one line of NumPy and gave the best matches to an LLM.

That's the same pipeline larger RAG systems use. They just add better loaders, smarter chunking, faster indexes, and evaluation.

Now that you understand every step, the frameworks we cover next will stop feeling like magic.

Next, we'll go deeper on the generation side with the OpenAI API: streaming answers, structured outputs, conversation history, and keeping token costs under control.

That's where things get more interesting.


Continue Learning

You're currently at Article 7 in the RAG series.

Previous Articles

1. What Is RAG? Retrieval-Augmented Generation Explained

Learn the basic RAG architecture and understand how retrieval and generation work together.

2. What Are Embeddings?

Understand how text is converted into numerical representations for semantic retrieval.

3. Vector Databases Explained

Learn how vector databases store and search embeddings.

4. Semantic Search vs Keyword Search

Understand the difference between exact-term matching and meaning-based retrieval.

5. How Document Chunking Works in RAG

Learn why documents are split into chunks and how chunking affects retrieval quality.


Have a Question?

If something isn't clear or your RAG app isn't working as expected, leave a comment below.

We'll do our best to explain it with simple words, practical examples, and code you can actually understand.

Happy Coding! 🚀


Follow TheTechLane

Keep learning AI, development, and modern developer technologies with TheTechLane.

Learn • Build • Share

Follow @thetechlanehq for more developer guides, tutorials, and practical technology content.