Back to blogs

Building Your First RAG System: From Zero to QA Hero

November 27, 2025

Building Your First RAG System: From Zero to QA Hero

As a CSE student constantly wrestling with algorithms and data structures, my mind often wanders to the ultimate frontier: the human brain. How does it work? How does it recall facts, reason, and adapt? Large Language Models (LLMs) are incredible, but they're static snapshots of knowledge, prone to 'hallucination' when venturing beyond their training data. This limitation always felt... un-brain-like. Our brains don't just memorize everything; they effectively retrieve relevant context from a vast, dynamic knowledge base and then reason with it.

This is where Retrieval Augmented Generation (RAG) enters the picture, providing a pragmatic, performant path towards a more dynamic and grounded AI system. For me, RAG isn't just a workaround for LLM limitations; it's a foundational step towards architectures that mimic human cognitive processes – especially when thinking about how specialized "expert" modules (like in a Mixture of Experts, MoE model) could each leverage their own finely tuned memory systems.

Let's cut the fluff and build one.

Why RAG? Beyond Static Knowledge, Towards Dynamic Understanding

Imagine asking an LLM about your company's latest internal policy updates. Unless it was trained yesterday on that exact document, it will likely guess, generalize, or outright hallucinate. Fine-tuning an LLM for every new piece of information is computationally expensive, time-consuming, and inflexible. It's like re-writing an entire textbook every time a footnote changes.

RAG offers an elegant solution:

  1. Grounding: Provide the LLM with verifiable, external information at inference time.
  2. Recency: Easily update the external knowledge base without retraining the LLM.
  3. Reduced Hallucination: By providing factual context, the LLM is less likely to invent answers.
  4. Cost-Effective: Avoids expensive continuous fine-tuning.

For a competitive programmer, this is about efficiency and robustness. Why rebuild the model when you can augment its performance with a smart retrieval strategy?

The RAG Pipeline: Deconstructing Knowledge

A RAG system comprises several distinct, yet interconnected, stages. We'll build each part from scratch, prioritizing raw API calls and minimal dependencies. For simplicity, I'll use Python, but the concepts and API interactions are directly transferable to TypeScript/JavaScript with node-fetch and similar libraries.

1. Document Loading: Ingesting Raw Data

The first step is getting your data into a usable format. For this guide, we'll keep it super simple: a single text file. In a real-world scenario, you'd integrate with databases, APIs, or various document formats (PDFs, Markdown, etc.).

Let's assume we have a document named my_document.txt:

The quick brown fox jumps over the lazy dog.
This is a sample document to demonstrate RAG.
RAG systems combine retrieval and generation for better answers.
It helps LLMs by providing relevant context.
Mixture of Experts (MoE) models can enhance this by routing queries to specialized sub-models.
Each expert could have its own RAG system, mimicking specialized cognitive domains.
Performance is key in AI systems, demanding efficient data pipelines.
# document_loader.py
 
def load_document(file_path: str) -> str:
    """Loads a text document from the specified path."""
    try:
        with open(file_path, 'r', encoding='utf-8') as f:
            return f.read()
    except FileNotFoundError:
        print(f"Error: Document not found at {file_path}")
        return ""
 
if __name__ == "__main__":
    document_content = load_document("my_document.txt")
    print(f"Loaded document (first 100 chars):\n{document_content[:100]}...")

2. Chunking: Slicing for Precision

LLMs and embedding models have input token limits. Feeding them an entire book as a single chunk is infeasible and inefficient. We need to break down the document into smaller, semantically meaningful pieces. A common, simple strategy is fixed-size chunks with a small overlap to maintain context across chunk boundaries.

# chunker.py
from typing import List
 
def chunk_text(text: str, chunk_size: int = 200, chunk_overlap: int = 50) -> List[str]:
    """
    Splits text into fixed-size chunks with overlap.
    A simple, character-based chunker. For production, consider sentence-aware splitting.
    """
    if not text:
        return []
 
    chunks = []
    start = 0
    while start < len(text):
        end = min(start + chunk_size, len(text))
        chunk = text[start:end]
        chunks.append(chunk)
        if end == len(text):
            break
        # Move start position back by overlap for the next chunk
        start += chunk_size - chunk_overlap
        # Ensure start doesn't go negative if chunk_size < chunk_overlap
        if start < 0:
            start = 0
 
    return chunks
 
if __name__ == "__main__":
    sample_text = "This is a long sentence that needs to be chunked into smaller pieces. We want to ensure that context is maintained across chunks. Overlap helps with this."
    chunks = chunk_text(sample_text, chunk_size=50, chunk_overlap=10)
    print("Generated Chunks:")
    for i, chunk in enumerate(chunks):
        print(f"Chunk {i+1}: '{chunk}'")

Competitive Programmer's Note: This character-based chunking is O(N) where N is text length. Simple and efficient. For production, exploring more sophisticated chunking (e.g., recursive character text splitter, or semantic chunking using spaCy/NLTK) can yield better RAG results but adds complexity. Stick to simple for 0-to-hero.

3. Embedding: Giving Text a Vector Soul

Now, we convert our text chunks into numerical vectors. This is the magic that allows us to find "similar" chunks. Semantically similar text chunks will have vectors that are numerically "close" to each other in a high-dimensional space.

We'll use a sentence-transformers model, which is efficient and can run locally. Alternatively, you could use OpenAI's embedding API for cloud-based embeddings. We'll avoid high-level wrappers like LangChain's OpenAIEmbeddings and interact directly with the underlying library or API.

# embedder.py
from typing import List
import numpy as np
# Prefer a fast, local model for quick iteration.
# If you don't have it, run: pip install sentence-transformers
from sentence_transformers import SentenceTransformer
 
class Embedder:
    def __init__(self, model_name: str = 'all-MiniLM-L6-v2'):
        """
        Initializes the embedding model.
        'all-MiniLM-L6-v2' is a good balance of speed and performance for many tasks.
        """
        print(f"Loading embedding model: {model_name}...")
        self.model = SentenceTransformer(model_name)
        print("Model loaded.")
 
    def embed_chunks(self, chunks: List[str]) -> List[np.ndarray]:
        """
        Embeds a list of text chunks into vectors.
        """
        if not chunks:
            return []
        print(f"Embedding {len(chunks)} chunks...")
        embeddings = self.model.encode(chunks, convert_to_numpy=True)
        print("Embedding complete.")
        return embeddings.tolist() # Convert to list of lists/ndarrays for easier storage
 
if __name__ == "__main__":
    embedder = Embedder()
    sample_chunks = [
        "The quick brown fox jumps over the lazy dog.",
        "A fast mammal with reddish-brown fur leaps over a sleepy canine."
    ]
    embeddings = embedder.embed_chunks(sample_chunks)
    for i, emb in enumerate(embeddings):
        print(f"Embedding {i+1} shape: {len(emb)}")
        print(f"Embedding {i+1} (first 5 values): {emb[:5]}")

Performance Insight: all-MiniLM-L6-v2 is a lightweight model, making embedding relatively fast. For larger datasets, batching embedding calls is crucial. SentenceTransformer handles this internally when passed a list.

4. Vector Storage & Retrieval: The Brain's Index

Once we have our chunks and their corresponding embeddings, we need to store them efficiently and retrieve the most relevant ones given a query. For a "from zero" guide, an in-memory solution is ideal. While a simple list with brute-force search works, for any non-trivial dataset, a specialized vector index like FAISS (Facebook AI Similarity Search) is orders of magnitude faster.

# vector_store.py
from typing import List, Tuple
import numpy as np
# pip install faiss-cpu
import faiss
 
class VectorStore:
    def __init__(self, dimension: int):
        """
        Initializes an in-memory FAISS index.
        For larger datasets, consider `faiss.IndexFlatL2` for L2 distance, or more advanced indices.
        """
        self.index = faiss.IndexFlatIP(dimension)  # IP for Inner Product, suitable for normalized cosine similarity
        self.texts: List[str] = []
 
    def add_vectors(self, embeddings: List[np.ndarray], texts: List[str]):
        """
        Adds vectors and their corresponding texts to the store.
        """
        if not embeddings or not texts:
            return
        if len(embeddings) != len(texts):
            raise ValueError("Number of embeddings must match number of texts.")
 
        embeddings_np = np.array(embeddings).astype('float32')
        # Normalize embeddings for cosine similarity with Inner Product index
        faiss.normalize_L2(embeddings_np)
        self.index.add(embeddings_np)
        self.texts.extend(texts)
        print(f"Added {len(embeddings)} vectors to the store.")
 
    def search(self, query_embedding: np.ndarray, k: int = 3) -> List[Tuple[str, float]]:
        """
        Searches for the k most similar texts to the query embedding.
        Returns a list of (text, similarity_score) tuples.
        """
        if self.index.ntotal == 0:
            return []
 
        query_embedding_np = np.array([query_embedding]).astype('float32')
        faiss.normalize_L2(query_embedding_np) # Normalize query embedding too
 
        distances, indices = self.index.search(query_embedding_np, k)
 
        results = []
        for i, dist in zip(indices[0], distances[0]):
            if i != -1:  # -1 indicates no result found (shouldn't happen if k <= ntotal)
                results.append((self.texts[i], dist))
        print(f"Retrieved {len(results)} relevant chunks.")
        return results
 
if __name__ == "__main__":
    # Example usage (requires an Embedder instance)
    from embedder import Embedder
    embedder = Embedder()
    
    sample_chunks = [
        "The quick brown fox jumps over the lazy dog.",
        "A fast mammal with reddish-brown fur leaps over a sleepy canine.",
        "RAG systems combine retrieval and generation for better answers.",
        "Performance is key in AI systems."
    ]
    
    embeddings = embedder.embed_chunks(sample_chunks)
    
    # Initialize VectorStore with the dimension of our embeddings
    vector_store = VectorStore(dimension=len(embeddings[0]))
    vector_store.add_vectors(embeddings, sample_chunks)
    
    query = "What is RAG?"
    query_embedding = embedder.embed_chunks([query])[0]
    
    search_results = vector_store.search(query_embedding, k=2)
    print("\nSearch Results for 'What is RAG?':")
    for text, score in search_results:
        print(f"Score: {score:.4f}, Text: '{text}'")

Competitive Programmer's Note: FAISS provides optimized C++ implementations for nearest neighbor search, significantly outperforming Python-native loops, especially for large datasets. IndexFlatIP (Inner Product) when embeddings are L2 normalized correctly gives cosine similarity, which is a common and effective similarity metric.

5. Prompt Engineering: Orchestrating the Genius

This is where the retrieved context meets the LLM. We need to craft a prompt that effectively tells the LLM what its role is, provides the context, and then asks the user's question. Clear, concise prompts are paramount for getting good results.

We'll use OpenAI's API directly. If you prefer a local LLM, ollama or transformers can be used similarly.

# llm_client.py
import os
from typing import List, Dict
import openai # pip install openai
 
class LLMClient:
    def __init__(self, api_key: str, model_name: str = "gpt-3.5-turbo"):
        """
        Initializes the OpenAI LLM client.
        Ensure OPENAI_API_KEY is set in your environment or passed directly.
        """
        self.client = openai.OpenAI(api_key=api_key)
        self.model_name = model_name
 
    def generate_response(self, prompt_messages: List[Dict[str, str]]) -> str:
        """
        Sends a list of messages to the LLM and returns the generated response.
        """
        try:
            response = self.client.chat.completions.create(
                model=self.model_name,
                messages=prompt_messages,
                temperature=0.0 # For factual QA, lower temperature is usually better
            )
            return response.choices[0].message.content
        except openai.AuthenticationError:
            print("Error: OpenAI API key is invalid or not provided.")
            return "Error: Could not authenticate with OpenAI. Please check your API key."
        except Exception as e:
            print(f"Error calling LLM: {e}")
            return "Error: Could not generate response."
 
if __name__ == "__main__":
    # For testing, ensure OPENAI_API_KEY is set in your environment
    # os.environ["OPENAI_API_KEY"] = "YOUR_API_KEY"
    api_key = os.getenv("OPENAI_API_KEY")
    if not api_key:
        print("Please set the OPENAI_API_KEY environment variable.")
    else:
        llm_client = LLMClient(api_key=api_key)
        test_messages = [
            {"role": "system", "content": "You are a helpful assistant."},
            {"role": "user", "content": "What is the capital of France?"}
        ]
        response = llm_client.generate_response(test_messages)
        print(f"LLM Response: {response}")

Opinionated Stance: Direct API calls for LLM interactions offer maximum control. Bloated frameworks often abstract away critical parameters and add unnecessary overhead. For performance and precision, stick to the raw interface.

6. Tying It All Together: The QA Hero Function

Now, let's assemble our components into a functional RAG system.

# rag_system.py
import os
from document_loader import load_document
from chunker import chunk_text
from embedder import Embedder
from vector_store import VectorStore
from llm_client import LLMClient
from typing import List, Dict
 
class RAGSystem:
    def __init__(self, document_path: str, openai_api_key: str):
        self.document_path = document_path
        self.embedder = Embedder()
        self.llm_client = LLMClient(api_key=openai_api_key)
        self.vector_store: VectorStore = None
        self._initialize_knowledge_base()
 
    def _initialize_knowledge_base(self):
        """Loads, chunks, and embeds the document to set up the vector store."""
        print("Initializing RAG knowledge base...")
        document_content = load_document(self.document_path)
        if not document_content:
            raise ValueError("Failed to load document.")
 
        chunks = chunk_text(document_content)
        if not chunks:
            raise ValueError("No chunks generated from document.")
 
        embeddings = self.embedder.embed_chunks(chunks)
        if not embeddings:
            raise ValueError("No embeddings generated.")
 
        self.vector_store = VectorStore(dimension=len(embeddings[0]))
        self.vector_store.add_vectors(embeddings, chunks)
        print("RAG knowledge base initialized.")
 
    def ask(self, query: str, k_retrievals: int = 3) -> str:
        """
        Performs a RAG query:
        1. Embeds the user query.
        2. Retrieves relevant chunks from the vector store.
        3. Constructs a prompt with the retrieved context.
        4. Generates a response using the LLM.
        """
        if not self.vector_store:
            return "RAG system not initialized. Please check document loading."
 
        print(f"\nProcessing query: '{query}'")
 
        # 1. Embed the user query
        query_embedding = self.embedder.embed_chunks([query])[0]
 
        # 2. Retrieve relevant chunks
        retrieved_chunks_info = self.vector_store.search(query_embedding, k=k_retrievals)
        retrieved_texts = [text for text, score in retrieved_chunks_info]
        
        # Combine retrieved texts into a single context string
        context = "\n---\n".join(retrieved_texts)
 
        # 3. Construct the prompt
        system_message = {
            "role": "system",
            "content": (
                "You are an intelligent QA assistant. "
                "Use the provided context to answer the user's question. "
                "If the answer is not in the context, state that you don't have enough information."
                "Be concise and direct."
            )
        }
        user_message = {
            "role": "user",
            "content": (
                f"Context:\n{context}\n\n"
                f"Question: {query}"
            )
        }
        prompt_messages: List[Dict[str, str]] = [system_message, user_message]
 
        print("Sending prompt to LLM...")
        # 4. Generate response
        response = self.llm_client.generate_response(prompt_messages)
        print("LLM response received.")
        return response
 
if __name__ == "__main__":
    # Create a dummy document for demonstration
    with open("my_document.txt", "w") as f:
        f.write("""The quick brown fox jumps over the lazy dog.
This is a sample document to demonstrate RAG.
RAG systems combine retrieval and generation for better answers.
It helps LLMs by providing relevant context.
Mixture of Experts (MoE) models can enhance this by routing queries to specialized sub-models.
Each expert could have its own RAG system, mimicking specialized cognitive domains.
Performance is key in AI systems, demanding efficient data pipelines.
My favorite fictional character is Sherlock Holmes, a brilliant detective.
He uses deductive reasoning to solve complex cases.
His methods are a great example of structured problem-solving.
""")
 
    api_key = os.getenv("OPENAI_API_KEY")
    if not api_key:
        print("Please set the OPENAI_API_KEY environment variable to run the RAG system.")
    else:
        try:
            rag_system = RAGSystem(document_path="my_document.txt", openai_api_key=api_key)
 
            print("\n--- RAG QA Session ---")
            queries = [
                "What is RAG and how does it help LLMs?",
                "What is the significance of MoE models in this context?",
                "Who is Sherlock Holmes?",
                "What is the capital of Mars?" # This should fail gracefully
            ]
 
            for q in queries:
                answer = rag_system.ask(q)
                print(f"\nQuestion: {q}")
                print(f"Answer: {answer}")
        except ValueError as e:
            print(f"Initialization Error: {e}")
        finally:
            # Clean up the dummy document
            if os.path.exists("my_document.txt"):
                os.remove("my_document.txt")
 

What I Learned: Beyond the Code, Towards AGI (and MoE's Promise)

Building this RAG system from scratch reinforced several core tenets:

  1. Modularity is Key: Each component (loader, chunker, embedder, vector store, LLM client) is a distinct, testable unit. This is fundamental for scalable and maintainable AI architectures.
  2. Performance Demands Directness: By avoiding bloated frameworks, we gain direct control over each step. This means we can profile bottlenecks and optimize aggressively, which is critical for real-time AI systems. Every microsecond counts.
  3. RAG is a Cognitive Primitve: The process of retrieving relevant information before generating a response deeply resonates with how I imagine advanced cognitive systems, like the human brain, operate. We don't just 'think' in a vacuum; we access memories, consult external knowledge, and synthesize.
  4. The MoE Connection: This RAG pipeline is a microcosm of what I envision for MoE architectures. Imagine a larger "brain" that, given a query, first routes it to a specialized "expert" (e.g., a "finance expert," a "medical expert," a "fictional literature expert"). Each of these experts could have its own finely tuned RAG system, optimized for its domain. This allows for unparalleled specificity, recency, and efficiency, mirroring how different cortical areas in our brains handle distinct types of information. The "router" becomes the ultimate orchestrator, deciding which memory and reasoning module to engage.

This "first RAG" is a mere pebble, but it points to a mountain. The journey from this simple QA bot to a truly intelligent, adaptive, and brain-like AI system is long, but understanding and mastering these foundational blocks, with an unyielding focus on efficiency and principled architecture, is the only way forward.

Next Steps & Challenges:

  • Advanced Chunking: Experiment with semantic chunking or recursive text splitters for better context preservation.
  • Different Embedding Models: Test various models (e.g., open-source models via HuggingFaceEmbeddings for transformers, or even fine-tune your own).
  • Persistent Vector Stores: Integrate with Qdrant, Weaviate, Pinecone, or pgvector for production-grade persistence and scaling.
  • Evaluation: Implement metrics to quantitatively measure RAG performance (e.g., ROUGE, faithfulness, answer relevancy).
  • TypeScript/JavaScript Port: Re-implementing this in TypeScript using node-fetch and a server-side embedding service would be a solid exercise for web-focused AI development.

The path to AGI, or even just remarkably performant, grounded AI, is paved with well-engineered RAG systems. Let's build.