Retrieval-Augmented Generation (RAG) Architecture

33 min read
Software Engineering
Retrieval-Augmented Generation (RAG) Architecture

Learning Objectives

After reading this guide, you will be able to:

  • Deconstruct a production-grade Retrieval-Augmented Generation (RAG) architecture into its ingestion, retrieval, and generation phases.
  • Select and implement optimal document-chunking strategies based on document structure, syntax, and semantic limits.
  • Compare vector index types (Flat, IVF, HNSW) to balance search recall, query-per-second (QPS) throughput, and RAM constraints.
  • Design a hybrid retrieval pipeline that mathematically fuses sparse (BM25) and dense (vector) search results using Reciprocal Rank Fusion (RRF).
  • Integrate a two-stage retrieval process employing cross-encoder re-rankers to maximize semantic precision.
  • Implement a production-ready, modular RAG system in Python with strict data schemas, metadata filtering, and comprehensive error handling.
  • Evaluate RAG performance using the RAGAS framework across four dimensions: faithfulness, answer relevance, context recall, and context precision.

Prerequisites

This guide is designed for software engineers, data scientists, and systems architects.

  • Required Knowledge:
    • Proficient in Python (object-oriented patterns, type hinting, asynchronous operations).
    • Familiarity with vector spaces, high-dimensional matrices, and basic probability.
    • Conceptual understanding of Large Language Models (LLMs) and transformer architectures.
  • Estimated Reading Time: ~45 minutes.
  • Difficulty: Intermediate to Advanced.

Why This Matters {#whyThis Matters}

Large Language Models (LLMs) are historically constrained by two core limitations: parametric state locking and hallucination.

An LLM's knowledge is frozen at the point of its last pre-training or fine-tuning run. If an expat seeks the updated processing times for a specific digital nomad visa in Portugal or Spain, a vanilla LLM cannot reliably answer. It will either confess ignorance or, more dangerously, hallucinate an outdated or entirely fabricated policy.

                                  [ Traditional LLM ]
                                 /                   \
                 Outdated Parametric Memory      Hallucinations
                 
                                          vs.
                                          
                                    [ RAG Pipeline ]
                                 /                   \
                 Dynamic Non-Parametric Memory    Source-Grounded Answers

Fine-tuning an LLM to inject this dynamic information is expensive, slow, and alters model behavior in unpredictable ways. This is where Retrieval-Augmented Generation (RAG) comes in.

RAG decouples the reasoning engine (the LLM) from the knowledge source (external databases). By retrieving highly relevant, real-time context from a non-parametric data store and appending it to the user's query, we constrain the LLM's generation to verifiable facts.

Enterprise engineering teams at Stripe, Cloudflare, Meta, and Bloomberg rely on RAG to power internal code search, external support automation, compliance auditing, and complex document intelligence systems. Mastery of RAG architecture is a fundamental skill for building dependable, production-grade AI applications in 2026.


First Principles

To understand why RAG is architected the way it is, we must look at how semantic search operates from first principles.

Parametric vs. Non-Parametric Memory

An LLM contains parametric memory. These are the billions of weights learned during training. They store syntax, style, logic, and broad historical patterns.

A database (SQL, NoSQL, or Vector) contains non-parametric memory. This is an external, structured repository of raw data that can be updated, deleted, and audited instantly without modifying the LLM's weights.

RAG bridges these two worlds. It uses the non-parametric memory to locate precise, factual documents, and uses the parametric memory of the LLM to synthesize those documents into a coherent, natural-language response.

The High-Dimensional Vector Space

At the heart of dense retrieval is the transformation of unstructured text into a vector—a list of floating-point numbers representing a point in a high-dimensional space (typically 384 to 3072 dimensions).

When an embedding model processes a string of text, it maps the semantic concepts within that text to spatial coordinates. Text pieces with similar meanings are mapped to coordinates close to one another in this multi-dimensional space, regardless of whether they share the same vocabulary.

For example, the sentences "How do I apply for a student visa in Germany?" and "German educational permit application procedures" share very few words, but their semantic vectors will lie close together.

The Mathematics of Text Similarity

To find the most relevant context for a query, we calculate the geometric distance between the query's vector q\vec{q} and a document candidate's vector d\vec{d}. There are three dominant metrics used to measure this distance:

1. Dot Product

The dot product measures the alignment of two vectors. It is highly sensitive to the magnitude (length) of the vectors.

Dot Product(q,d)=qd=i=1nqidi\text{Dot Product}(\vec{q}, \vec{d}) = \vec{q} \cdot \vec{d} = \sum_{i=1}^{n} q_i d_i

Use Case: Use when your embedding model outputs vectors where the length represents document importance or frequency, or when vectors are already normalized to unit length.

2. Cosine Similarity

Cosine similarity measures the cosine of the angle between two vectors, completely ignoring their magnitudes. It ranges from -1 to 1 (or 0 to 1 for non-negative coordinates).

Cosine Similarity(q,d)=qdqd=i=1nqidii=1nqi2i=1ndi2\text{Cosine Similarity}(\vec{q}, \vec{d}) = \frac{\vec{q} \cdot \vec{d}}{\|\vec{q}\| \|\vec{d}\|} = \frac{\sum_{i=1}^{n} q_i d_i}{\sqrt{\sum_{i=1}^{n} q_i^2} \sqrt{\sum_{i=1}^{n} d_i^2}}

Use Case: This is the industry default for semantic search. It prevents longer chunks from dominating search results purely because they contain more total words.

3. Euclidean Distance (L2L_2 Distance)

Euclidean distance measures the straight-line distance between two points in high-dimensional space.

L2(q,d)=qd=i=1n(qidi)2L_2(\vec{q}, \vec{d}) = \|\vec{q} - \vec{d}\| = \sqrt{\sum_{i=1}^{n} (q_i - d_i)^2}

Use Case: Preferred when using clustering algorithms or specific models (like legacy dense passage retrieval models) trained to minimize Euclidean loss. Note that a smaller L2L_2 distance indicates greater similarity, unlike dot product and cosine similarity where higher values indicate greater similarity.


Core Concepts

A production RAG architecture consists of three interconnected pipelines:

  1. The Ingestion Pipeline: Reading, cleaning, chunking, embedding, and indexing documents.
  2. The Retrieval Pipeline: Parsing the query, searching the vector/sparse database, filtering, and re-ranking candidates.
  3. The Generation Pipeline: Formulating the system prompt, enforcing structured constraints, and invoking the LLM.
+--------------------------------------------------------------------------+
|                            INGESTION PIPELINE                            |
| Raw Docs ---> Parsing ---> Chunking ---> Embedding ---> Vector Index     |
+--------------------------------------------------------------------------+

+--------------------------------------------------------------------------+
|                            RETRIEVAL PIPELINE                            |
| Query --------------> Embedding ---> Vector Search ---+                  |
|       |                                               |                  |
|       +-------------> Tokenizer ---> Sparse Search ----> Fusion/Rerank   |
+--------------------------------------------------------------------------+

+--------------------------------------------------------------------------+
|                            GENERATION PIPELINE                           |
| Context + System Prompt + Query ---> LLM ---> Structured Response        |
+--------------------------------------------------------------------------+

1. Document Chunking Strategies

You cannot feed an entire 200-page immigration handbook into an embedding model at once. Embedding models have strict input token limits (e.g., 512, 2048, or 8192 tokens), and larger inputs dilute the precision of the resulting vector. Therefore, documents must be split into "chunks".

  • Fixed-Size Chunking: Splitting text into a predetermined number of characters or tokens (e.g., 500 characters) with a sliding window overlap (e.g., 50 characters). This is computationally cheap but frequently breaks paragraphs, sentences, or even words in half, severing critical context.
  • Recursive Character Chunking: Splitting text using a hierarchy of separators—typically double newlines \n\n (paragraphs), single newlines \n (lines), spaces , and finally empty strings "" (characters)—until the chunk falls under the target size limit. This preserves paragraph and sentence boundaries.
  • Semantic Chunking: Analyzing the semantic difference between consecutive sentences. When the semantic shift between sentence nn and sentence n+1n+1 exceeds a defined threshold (calculated via embedding cosine distance), a chunk boundary is placed.
  • Document-Structure-Aware Chunking: Parsing Markdown, HTML, or PDF elements to respect native structures like headers (#, ##), tables, and list items. This ensures tables or list options are never split into separate, unreadable fragments.

2. Vector Indexing Algorithms

Once text chunks are embedded, they are indexed in a vector database. Standard flat indexes (calculating cosine similarity against every single document in the DB) have a time complexity of O(N)O(N). At millions of vectors, search latency becomes unacceptable. Modern vector databases use Approximate Nearest Neighbor (ANN) indexing:

  • IVF (Inverted File Index): Uses k-means clustering to partition the vector space into CC centroids. During search, the query vector is compared only against the nearest centroids, reducing search scope from NN to a fraction of NN. This saves memory but can miss close vectors that lie near cluster boundaries.
  • HNSW (Hierarchical Navigable Small World): Builds a multi-layer graph structure where the bottom layer contains all vectors and higher layers contain increasingly sparse skip-lists. Search navigates down through the layers with O(logN)O(\log N) complexity. HNSW provides exceptional recall and extremely low query latency, but has high memory overhead and long build times.

3. Sparse vs. Dense Retrieval

  • Sparse Retrieval (BM25): Matches exact keywords, acronyms, and product codes based on term frequency-inverse document frequency (TFIDFTF-IDF). It is highly reliable for looking up exact passport form codes (e.g., "DS-11") or visa names ("Subclass 189").
  • Dense Retrieval (Embeddings): Matches semantic intent and synonyms. It excels when the user's vocabulary does not align with the document's vocabulary (e.g., querying "getting a work permit" and finding documents containing "authorization of employment").

Production systems rarely use dense retrieval alone. They combine both approaches in a hybrid retrieval pipeline.


Deep Dive

To move from basic ("naive") RAG to an enterprise-grade system that can handle complex queries over messy real-world data, we must implement several advanced patterns.

Advanced Query Transformation

Users often write poorly structured, ambiguous queries. If a user asks "Can I travel to France while my talent visa is processing, and what about Spain?", a direct vector lookup will fail because the query is too complex, containing multiple intents.

Query Decomposition

An intermediary LLM step breaks a compound query down into discrete sub-queries:

  1. "Can I travel to France while my French talent visa is processing?"
  2. "Can I travel to Spain while my Spanish talent visa is processing?"

Each sub-query runs through the retrieval pipeline independently, and the aggregated context is fed to the generator.

Query Expansion (HyDE - Hypothetical Document Embeddings)

Often, the semantic vector of a query (which is in the form of a question) does not align well with the semantic vector of the answer (which is in the form of an assertion).

HyDE prompts an LLM to generate a hypothetical answer to the user's query first. Even if this hypothetical answer contains minor factual errors, its vector representation will be structurally and semantically close to the actual documents in your database.

We embed this hypothetical document and use its vector to retrieve real documents.

User Query ---> LLM ---> Hypothetical Answer ---> Embed ---> Vector DB Search ---> Real Context

Advanced Retrieval Patterns

Parent-Document Retrieval (Sentence-Window Retrieval)

There is an inherent conflict in chunking:

  • Small chunks (e.g., 1-2 sentences) yield highly precise embeddings because they contain focused, undiluted ideas.
  • Large chunks (e.g., 1000 tokens) provide the LLM with the necessary surrounding context to answer questions comprehensively without losing track of pronouns or document flow.

The Parent-Document Retrieval pattern resolves this by decoupling the data stored in the index from the data passed to the generator.

       +--------------------------------------------------------+
       | Parent Document: Complete Spain Nomad Visa Guidelines  |
       +--------------------------------------------------------+
              /                    |                    \
             /                     |                     \
    +-----------------+   +-----------------+   +-----------------+
    | Child Chunk 1   |   | Child Chunk 2   |   | Child Chunk 3   |
    | (Embedded Only) |   | (Embedded Only) |   | (Embedded Only) |
    +-----------------+   +-----------------+   +-----------------+
             \                     |                     /
              \____ Match Child 2 _v____________________/
                                   |
                     Retrieve entire Parent Document!

During ingestion, we chunk the document into tiny fragments (e.g., 100 tokens, representing the "child chunks"). Each child chunk maps to its parent document (the larger context).

We embed and query only the child chunks. However, when a child chunk is matched, we retrieve its corresponding parent document (or a larger surrounding text window) and pass that larger block to the LLM.

Re-ranking with Cross-encoders

Vector databases use Bi-encoders (where queries and documents are embedded separately and compared via dot-product or cosine similarity). This is exceptionally fast but lacks token-to-token interaction during search.

A Cross-encoder processes the query and a document candidate together in a single pass through a Transformer, allowing full self-attention across every query token and document token.

Bi-Encoder:
Query ---> [Model] ---> Query Vector \
                                       +---> Similarity Score (Fast, lower accuracy)
Doc   ---> [Model] ---> Doc Vector   /

Cross-Encoder:
[Query + Doc] ---> [Model] ---> Direct Class Score (Slow, exceptionally accurate)

Cross-encoders are computationally expensive and cannot be used to search millions of documents. However, we can use them in a two-stage retrieval pipeline:

  1. Use a fast Bi-encoder (Vector DB) + BM25 to retrieve the top 50 candidates.
  2. Use a Cross-encoder re-ranker (e.g., CohereRerank or a local BGE-Reranker) to score and re-rank those top 50 candidates, passing only the top 5 highly verified contexts to the LLM context window.

Practical Examples

To demonstrate these concepts, let us build a system that indexes and queries immigration and visa policy documents. This domain contains many complex terms, numerical rules, and nested conditions.

Scenario

An agency needs to build an immigration-related search system for expats and students. The system must process PDF guides, support search over exact form IDs (e.g., "Form I-765") and semantic visa paths (e.g., "post-study work options"), and synthesize precise, grounded answers.

Workflow

  1. Parse a sample policy document.
  2. Chunk it using a custom recursive method.
  3. Index the chunks in an in-memory vector store (with metadata support) alongside a sparse BM25 index.
  4. Execute a hybrid search query.
  5. Apply a re-ranking model to selection candidates.
  6. Synthesize the final response with strict schema boundaries using an LLM.


Code Examples

The following Python code blocks provide a modular, production-ready implementation of an advanced hybrid search, re-ranked RAG system. It uses numpy for core vector math, rank_bm25 for sparse lookup, sentence-transformers for embeddings and re-ranking, and the official openai SDK for constrained generation.

Make sure you have these packages installed:

bash
pip install numpy sentence-transformers rank_bm25 openai pydantic

Module 1: Document Models and Chunking Engine

We define clean, production-grade schemas for our documents, maintaining explicit IDs and metadata fields.

python
import uuid
import re
from typing import List, Dict, Any, Optional
from pydantic import BaseModel, Field

class Document(BaseModel):
    id: str = Field(default_factory=lambda: str(uuid.uuid4()))
    text: str
    metadata: Dict[str, Any] = Field(default_factory=dict)

class Chunk(BaseModel):
    id: str = Field(default_factory=lambda: str(uuid.uuid4()))
    parent_id: str
    text: str
    metadata: Dict[str, Any]

class RecursiveChunker:
    """
    A structure-aware recursive character chunker that breaks text 
    down while preserving paragraph boundaries, sentences, and words.
    """
    def __init__(self, chunk_size: int = 500, chunk_overlap: int = 50):
        self.chunk_size = chunk_size
        self.chunk_overlap = chunk_overlap
        # Standard separator list ordered from coarse to fine
        self.separators = ["\n\n", "\n", " ", ""]

    def chunk_document(self, doc: Document) -> List[Chunk]:
        raw_text = doc.text
        raw_chunks = self._split_text(raw_text, self.separators, self.chunk_size)
        
        chunks = []
        for i, text_segment in enumerate(raw_chunks):
            # Capture metadata and inject parent contextual information
            chunk_metadata = doc.metadata.copy()
            chunk_metadata["chunk_index"] = i
            
            chunks.append(Chunk(
                parent_id=doc.id,
                text=text_segment,
                metadata=chunk_metadata
            ))
        return chunks

    def _split_text(self, text: str, separators: List[str], max_size: int) -> List[str]:
        if len(text) <= max_size or not separators:
            return [text]

        separator = separators[0]
        next_separators = separators[1:]
        
        # Split text by the current separator level
        if separator == "":
            splits = list(text)
        else:
            splits = text.split(separator)

        final_chunks = []
        current_chunk = ""

        for split in splits:
            # Reconstruct string representation with separator
            proposed_chunk = current_chunk + (separator if current_chunk else "") + split
            
            if len(proposed_chunk) <= max_size:
                current_chunk = proposed_chunk
            else:
                if current_chunk:
                    final_chunks.append(current_chunk)
                
                # If a single split exceeds max_size, recurse on it
                if len(split) > max_size:
                    sub_splits = self._split_text(split, next_separators, max_size)
                    final_chunks.extend(sub_splits[:-1])
                    current_chunk = sub_splits[-1] if sub_splits else ""
                else:
                    current_chunk = split

        if current_chunk:
            final_chunks.append(current_chunk)

        # Apply overlap logic to chunk boundaries
        return self._apply_overlap(final_chunks)

    def _apply_overlap(self, chunks: List[str]) -> List[str]:
        if len(chunks) <= 1:
            return chunks
        
        overlapped_chunks = []
        for i, chunk in enumerate(chunks):
            if i == 0:
                overlapped_chunks.append(chunk)
                continue
            
            # Extract overlap from previous chunk end
            prev_chunk = chunks[i-1]
            overlap_prefix = prev_chunk[-self.chunk_overlap:] if len(prev_chunk) >= self.chunk_overlap else prev_chunk
            overlapped_chunks.append(overlap_prefix + chunk)
            
        return overlapped_chunks

Module 2: In-Memory Hybrid Vector Store with Cross-Encoder Re-ranking

This module encapsulates vector embedding (via SentenceTransformers), sparse tokenization, BM25 indexing, retrieval, Reciprocal Rank Fusion, and Cross-Encoder re-ranking.

python
import numpy as np
from sentence_transformers import SentenceTransformer, CrossEncoder
from rank_bm25 import BM25Okapi

class HybridRetriever:
    """
    Combines dense retrieval (embeddings) and sparse retrieval (BM25)
    and resolves rankings using Reciprocal Rank Fusion (RRF),
    then re-ranks outputs using a Cross-Encoder.
    """
    def __init__(
        self, 
        embedding_model_name: str = "all-MiniLM-L6-v2", 
        reranker_model_name: str = "cross-encoder/ms-marco-MiniLM-L-6-v2"
    ):
        print("Initializing embedding and cross-encoder models...")
        self.embedding_model = SentenceTransformer(embedding_model_name)
        self.reranker_model = CrossEncoder(reranker_model_name)
        
        self.chunks: List[Chunk] = []
        self.dense_embeddings: Optional[np.ndarray] = None
        self.bm25: Optional[BM25Okapi] = None

    def index_chunks(self, chunks: List[Chunk]):
        if not chunks:
            return
        
        self.chunks.extend(chunks)
        texts = [chunk.text for chunk in self.chunks]
        
        # 1. Compute Dense Embeddings
        print(f"Generating dense embeddings for {len(texts)} chunks...")
        embeddings_list = self.embedding_model.encode(texts, show_progress_bar=False)
        self.dense_embeddings = np.array(embeddings_list)
        
        # Normalize dense vectors for rapid cosine similarity calculations (Dot Product is equivalent if normalized)
        norms = np.linalg.norm(self.dense_embeddings, axis=1, keepdims=True)
        self.dense_embeddings = self.dense_embeddings / np.where(norms == 0, 1e-12, norms)
        
        # 2. Compute Sparse BM25 Index
        print("Building sparse BM25 index...")
        tokenized_corpus = [self._tokenize(text) for text in texts]
        self.bm25 = BM25Okapi(tokenized_corpus)

    def _tokenize(self, text: str) -> List[str]:
        # Simple word tokenizer stripping basic punctuation and lowercasing
        return re.findall(r'\w+', text.lower())

    def _reciprocal_rank_fusion(
        self, 
        dense_results: List[Chunk], 
        sparse_results: List[Chunk], 
        k_constant: int = 60
    ) -> List[tuple[Chunk, float]]:
        """
        Fuses two ranked lists using Reciprocal Rank Fusion.
        Formula: RRF_Score(d) = sum(1 / (k_constant + rank_in_list))
        """
        rrf_scores: Dict[str, float] = {}
        chunk_map: Dict[str, Chunk] = {}
        
        # Map IDs to instances
        for chunk in dense_results + sparse_results:
            chunk_map[chunk.id] = chunk

        # Accumulate scores from dense search
        for rank, chunk in enumerate(dense_results):
            rrf_scores[chunk.id] = rrf_scores.get(chunk.id, 0.0) + (1.0 / (k_constant + rank + 1))
            
        # Accumulate scores from sparse search
        for rank, chunk in enumerate(sparse_results):
            rrf_scores[chunk.id] = rrf_scores.get(chunk.id, 0.0) + (1.0 / (k_constant + rank + 1))

        # Sort descending by fused score
        sorted_scores = sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True)
        return [(chunk_map[cid], score) for cid, score in sorted_scores]

    def retrieve(self, query: str, top_k: int = 10, final_top_n: int = 3) -> List[Chunk]:
        """
        Executes hybrid retrieval followed by a cross-encoder re-ranking step.
        """
        if not self.chunks or self.dense_embeddings is None or self.bm25 is None:
            raise ValueError("The retriever must index document chunks before query execution.")

        # ---- Dense Search ----
        query_vector = self.embedding_model.encode(query, show_progress_bar=False)
        query_norm = np.linalg.norm(query_vector)
        if query_norm > 0:
            query_vector = query_vector / query_norm
            
        # Fast normalized dot product similarity (equivalent to cosine)
        dense_similarities = np.dot(self.dense_embeddings, query_vector)
        dense_ranked_indices = np.argsort(dense_similarities)[::-1][:top_k]
        dense_candidates = [self.chunks[idx] for idx in dense_ranked_indices]

        # ---- Sparse Search ----
        tokenized_query = self._tokenize(query)
        sparse_scores = self.bm25.get_scores(tokenized_query)
        sparse_ranked_indices = np.argsort(sparse_scores)[::-1][:top_k]
        sparse_candidates = [self.chunks[idx] for idx in sparse_ranked_indices]

        # ---- Rank Fusion (RRF) ----
        fused_results = self._reciprocal_rank_fusion(dense_candidates, sparse_candidates, k_constant=60)
        
        # Take up to top_k raw candidates to pass to the re-ranker
        candidate_chunks = [item[0] for item in fused_results[:top_k]]
        
        if not candidate_chunks:
            return []

        # ---- Cross-Encoder Re-ranking ----
        # Format pairs for cross-encoder processing: [ [query, doc1_text], [query, doc2_text], ... ]
        pairs = [[query, chunk.text] for chunk in candidate_chunks]
        ce_scores = self.reranker_model.predict(pairs)
        
        # Attach cross-encoder score to candidates
        chunk_scores = list(zip(candidate_chunks, ce_scores))
        
        # Sort candidates descending by Cross-Encoder score
        chunk_scores.sort(key=lambda x: x[1], reverse=True)
        
        # Return final best matching fragments
        return [chunk for chunk, score in chunk_scores[:final_top_n]]

Module 3: Strict Orchestration and Constrained LLM Generation

This module defines the structured prompt templates and validates output formats using strict programmatic parsing.

python
import os
from openai import OpenAI

# Define schema for JSON schema enforcement
class AnswerSynthesis(BaseModel):
    has_sufficient_context: bool = Field(description="False if the retrieval context does not contain facts to resolve the query.")
    reasoned_answer: str = Field(description="The formal synthesized answer compiled from extracted facts. Strict prose.")
    grounding_citations: List[str] = Field(description="Literal text matches directly cited from context to prove correctness.")

class RAGPipeline:
    """
    The orchestrator managing document ingestion, search pipelines, 
    prompt templating, and OpenAI JSON-schema parsing.
    """
    def __init__(self, retriever: HybridRetriever):
        self.retriever = retriever
        # Initializing the client. API keys are loaded automatically from the environment (OPENAI_API_KEY)
        self.openai_client = OpenAI()

    def generate_response(self, query: str) -> AnswerSynthesis:
        # 1. Retrieve candidates
        relevant_chunks = self.retriever.retrieve(query, top_k=8, final_top_n=3)
        
        # 2. Format Context
        formatted_contexts = []
        for i, chunk in enumerate(relevant_chunks):
            metadata_str = f"Source: {chunk.metadata.get('source', 'Unknown')} | Section: {chunk.metadata.get('section', 'Unknown')}"
            formatted_contexts.append(f"[{i+1}] ({metadata_str})\n{chunk.text}")
            
        context_payload = "\n\n---\n\n".join(formatted_contexts)

        # 3. System Instructions
        system_prompt = (
            "You are a strict, professional compliance officer answering query requests based on internal policy documents.\n"
            "Rule set:\n"
            "1. Answer queries only with facts present in the Context block below.\n"
            "2. If you cannot solve the query with absolute certainty based on facts inside the Context block, "
            "set `has_sufficient_context` to false and write an explanation explaining what context is missing.\n"
            "3. Do not invent details. Cite raw strings used for grounding inside `grounding_citations`.\n"
            "4. Your response must match the requested JSON schema output exactly."
        )

        user_content = f"CONTEXT:\n{context_payload}\n\nQUERY: {query}"

        # 4. Request structured output from LLM using strict JSON schema modes
        try:
            completion = self.openai_client.beta.chat.completions.parse(
                model="gpt-4o-mini",  # Production cost-efficient and structurally accurate model
                messages=[
                    {"role": "system", "content": system_prompt},
                    {"role": "user", "content": user_content}
                ],
                response_format=AnswerSynthesis,
                temperature=0.0 # Strict zero deterministic decoding
            )
            return completion.choices[0].message.parsed
        except Exception as e:
            # Fallback error handling
            print(f"Error calling LLM: {e}")
            return AnswerSynthesis(
                has_sufficient_context=False,
                reasoned_answer="An error occurred while generating structural insights.",
                grounding_citations=[]
            )

Module 4: Pipeline Execution Execution Example

To demonstrate end-to-end functionality, let us write a driving script with complex visa rules.

python
if __name__ == "__main__":
    # Ensure OPENAI_API_KEY environment variable is defined
    if "OPENAI_API_KEY" not in os.environ:
        print("[WARNING]: Environment variable 'OPENAI_API_KEY' not found. Mocking the API key for validation.")
        os.environ["OPENAI_API_KEY"] = "sk-mock-key-for-compilation-purposes-only"

    # Define deep, messy legal guidelines for processing
    spanish_nomad_policy = """
    Spain Digital Nomad Visa (DNV) Requirements &#x26; Procedures (Policy Document Ref: ES-DNV-2026).
    The Spain Digital Nomad Visa provides non-EU foreign nationals remote working capability within Spain.
    
    Financial Verification:
    Applicants must show liquid financial reserves equivalent to at least 200% of the Spanish minimum wage (SMI). 
    In 2026, the SMI is established at €1,323 per month. Therefore, a primary applicant must prove a monthly income 
    of at least €2,646, or equivalent annual holdings of €31,752. Each additional family member requires 75% of the SMI 
    (an additional €992.25 per month).
    
    Taxation Framework:
    Approved visa holders are entitled to apply for the special tax scheme known colloquially as Beckham Law, 
    subject to registering within 6 months of active residency. Under this regime, remote income up to €600,000 
    is taxed at a flat rate of 24%, rather than the standard progressive tax rates exceeding 47%.
    
    Required Documentation:
    All applicants must submit:
    1. Form MI-DNV-2026 (Application of Residency Authorization).
    2. Background criminal checks from the applicant's origin countries with apostille certification.
    3. Proof of corporate contract dated at least 3 months prior to submission.
    """

    us_opt_policy = """
    United States F-1 Student visa Optional Practical Training Guidelines (Policy Document Ref: US-OPT-09).
    F-1 Students wishing to work must submit Form I-765 to USCIS. 
    
    Processing Windows:
    The application window opens 90 days before the academic program end date and extends up to 60 days post-graduation. 
    Failure to submit Form I-765 within this window nullifies eligibility.
    
    STEM Extensions:
    Students holding qualifying Science, Technology, Engineering, or Math (STEM) degrees are eligible for a 
    24-month extension beyond the basic 12-month post-completion OPT. This requires an active, registered E-Verify employer.
    """

    # Instantiate our pipeline components
    chunker = RecursiveChunker(chunk_size=350, chunk_overlap=40)
    retriever = HybridRetriever()
    pipeline = RAGPipeline(retriever=retriever)

    # Prepare Documents
    docs = [
        Document(text=spanish_nomad_policy, metadata={"source": "Spain-Nomad-Visa-Manual", "section": "Financial &#x26; Tax Laws"}),
        Document(text=us_opt_policy, metadata={"source": "USCIS-F1-Student-Handbook", "section": "Employment Authorization"})
    ]

    # Chunk and Register
    all_chunks = []
    for doc in docs:
        all_chunks.extend(chunker.chunk_document(doc))

    print(f"Total chunks created for indexing: {len(all_chunks)}")
    retriever.index_chunks(all_chunks)

    # Run Test Query 1: Complex numbers requiring accurate financial details
    query_1 = "How much monthly income do I need to prove for the Spain digital nomad visa, and what tax rates apply?"
    print(f"\nEvaluating Query 1: '{query_1}'")
    
    # Executing the search directly to verify relevance first
    results = retriever.retrieve(query_1, top_k=3, final_top_n=2)
    for c in results:
        print(f" -> Matched Chunk (Parent ID: {c.parent_id}): Text preview: '{c.text[:120]}...'")

    # Run complete synthesized pipeline
    response_1 = pipeline.generate_response(query_1)
    print("\n--- SYNTHESIZED SYSTEM RESPONSE 1 ---")
    print(f"Sufficient context: {response_1.has_sufficient_context}")
    print(f"Answer:\n{response_1.reasoned_answer}")
    print(f"Grounding Citations matches:\n{response_1.grounding_citations}")

    # Run Test Query 2: Sparse keyword matching ("Form I-765")
    query_2 = "What form code do I file for US Optional Practical Training, and when should I file it?"
    print(f"\nEvaluating Query 2: '{query_2}'")
    response_2 = pipeline.generate_response(query_2)
    print("\n--- SYNTHESIZED SYSTEM RESPONSE 2 ---")
    print(f"Sufficient context: {response_2.has_sufficient_context}")
    print(f"Answer:\n{response_2.reasoned_answer}")
    print(f"Grounding Citations matches:\n{response_2.grounding_citations}")

    # Run Test Query 3: Missing knowledge assertion testing (Hallucination check)
    query_3 = "What are the rules for a working holiday visa in Australia?"
    print(f"\nEvaluating Query 3: '{query_3}'")
    response_3 = pipeline.generate_response(query_3)
    print("\n--- SYNTHESIZED SYSTEM RESPONSE 3 ---")
    print(f"Sufficient context: {response_3.has_sufficient_context}")
    print(f"Answer:\n{response_3.reasoned_answer}")

Explanation of Code Design decisions:

  1. Recursion separator order: We step down from large blocks (paragraphs) to single lines, down to spaces to preserve sentence structure boundaries dynamically.
  2. Normalized Dot Product: Multiplying normalized arrays yields standard Cosine Similarity values, bypassing computation-heavy square root algorithms dynamically on every database query.
  3. Reciprocal Rank Fusion (RRF): Traditional rank metrics scale unpredictably across sparse indexes (which score absolute Term Frequency frequencies) and dense models (which score Cosine values). RRF circumvents this scaling issue by fusing results based solely on their rank indexes, converting arbitrary search scores into a unified, mathematically consistent ranking.
  4. Structured JSON Validation: Using structured models restricts output structures to conform to native client classes, eliminating erratic manual parsing logic of string prompts.

Diagrams

The following diagrams illustrate the structural differences and workflows inside a production RAG system.

Figure 1: Ingestion Pipeline Workflow

Figure 2: The Two-Stage Hybrid Retrieval and Re-ranking Engine


Comparison Tables

1. Vector Database Indexing Algorithmic Trade-offs

Index Type Search Latency Recall Precision RAM Footprint Build/Index Time Ideal Production Use Case
Flat Index (Exact) High (Linear O(N)O(N)) 100%100\% Perfect Extremely Low Immediate (00) Small corpora (<25,000< 25,000 documents) where flawless search accuracy is non-negotiable.
IVF (Inverted File) Medium (O(NC)O(\frac{N}{C})) High (can miss edge cluster anomalies) Low (Saves space via clustering) Low Large, cost-sensitive databases where RAM space is constrained.
HNSW (Hierarchical Navigable Small World) Low (Logarithmic O(logN)O(\log N)) Very High (95%99%95\% - 99\%) High (Requires keeping graphs in RAM) High (Requires building graphs) High-throughput, low-latency enterprise applications (sub-20ms search times on millions of items).

2. Standard Document Chunking Methodologies

Chunking Method Pros Cons Algorithm Complexity Primary Match Target
Fixed-Size Sliding Window Exceptionally fast; zero document parsing or structures required. Breaks sentences and arguments mid-thought, creating semantic fragmentation. O(1)O(1) Simple unstructured text files.
Recursive Character Keeps paragraphs and sentences intact, minimizing fragment noise. Vulnerable to structural anomalies (e.g., parsing a table splits rows unpredictably). O(N)O(N) Native text books, immigration policy reports.
Semantic Similarity Groupings are determined by changes in semantic topic. Highly compute-heavy (requires calling embedding model for every sentence). O(N×D)O(N \times D) Conversational transcripts or unstructured streams.
Structural-Aware (Markdown/PDF) Preserves tables, bullet points, headers, and structural hierarchy. Highly dependent on clean input document markup or PDF extraction tools. O(N)O(N) Complex regulatory documentation, visa guidelines, and APIs.

Common Mistakes

1. The "Lost in the Middle" Phenomenon

Many developers think that larger context windows (e.g., 128k to 2M tokens) mean they can stuff hundreds of retrieved chunks into the prompt. However, research shows that LLMs are highly proficient at parsing facts located at the very beginning and very end of long context payloads, but frequently miss details buried in the middle.

  • The Fix: Keep retrieved contexts tight. Use hybrid search and re-ranking to limit your inputs to the top 3-5 most relevant chunks. Do not stuff context windows with low-confidence documents.

2. Missing Co-Reference Resolution

If a chunk states: "Under this visa category, they are eligible to apply after two years," the pronoun they and the phrase this visa category have no clear referents out of context. An embedding model processing this chunk in isolation will yield a low semantic match for queries about "Spain digital nomad residency eligibility."

  • The Fix: Use Parent-Document Retrieval, or use a pre-processing LLM step to enrich child chunks with global context metadata (e.g., appending "Document Subject: Spain Digital Nomad Visa | Section: Eligibility" to the top of every chunk before embedding).

3. Mixing Up Embedding Models

A common production bug occurs when teams update their retrieval embedding model (e.g., from all-MiniLM-L6-v2 to text-embedding-3-small) but forget to re-embed their existing vector database. Since vectors from different models occupy entirely different high-dimensional coordinate spaces, search returns meaningless noise.

  • The Fix: Implement strict database schema versioning. Tie your vector collection directly to a specific embedding model tag in your codebase, and automate migration checks during deployment.

4. Over-reliance on Vector Search for Exact Numbers and IDs

If a student queries "What rules apply to form I-20?", vector embeddings might pull up documents about "Form I-765" or "F-1 visa guidelines" because they are semantically related. This happens because individual character identifiers are often washed out in high-dimensional semantic spaces.

  • The Fix: Implement hybrid search. Make sure exact token identifiers, reference codes, or numeric policies are matched via BM25 or Postgres exact phrase matching, then merge them using Reciprocal Rank Fusion.

Real Industry Examples

Stripe: Support Automation and API Document Grounding

Stripe handles developer support by running a hybrid RAG pipeline across API references, community forums, and help center guides. To ensure search returns correct API parameters (e.g., payment_intent.succeeded), Stripe uses custom markdown parsers that prevent API tables from being split. They use hybrid retrieval to match exact code variables alongside natural-language semantic explanations, resolving documentation lookups instantly.

Cloudflare: Scalable Zero-Trust Secure Search

Cloudflare utilizes RAG pipelines internally over highly confidential engineering, legal, and operational manuals. In secure corporate environments, a general index search could leak sensitive information if a user queries a topic they do not have clearance to view.

Cloudflare solves this by enforcing Document-Level Security (DLS). They append encrypted authorization lists (Access Control Lists - ACLs) as metadata to every chunk in their vector store. When a query is run, the system applies a metadata filter matching the user's active JSON Web Token (JWT) credentials, preventing unauthorized chunks from ever reaching the LLM.

Query -> [Filter by User Authorization ACLs] -> Vector Search Only Permitted Chunks -> Verified Response

Best Practices

1. The RAGAS Evaluation Framework

To scale a RAG system, you must be able to measure how changes to your chunking strategy, embedding models, or system prompts affect output quality. The RAGAS framework decomposes pipeline quality into four core, quantitative metrics:

Faithfulness=Number of Grounded Claims in AnswerTotal Claims in Answer\text{Faithfulness} = \frac{\text{Number of Grounded Claims in Answer}}{\text{Total Claims in Answer}}

Answer Relevance=Semantic similarity of synthesized answer to the query\text{Answer Relevance} = \text{Semantic similarity of synthesized answer to the query}

Context Recall=Percentage of correct ground-truth facts successfully retrieved\text{Context Recall} = \text{Percentage of correct ground-truth facts successfully retrieved}

Context Precision=The ratio of true relevant chunks to the total retrieved chunks\text{Context Precision} = \text{The ratio of true relevant chunks to the total retrieved chunks}

Run regression evaluations against a golden test set of ~100 query-and-ground-truth pairs before pushing pipeline updates to production.

2. Latency Optimization Patterns

A complex hybrid search with re-ranking and LLM synthesis can easily take several seconds. To keep response times sub-second:

  • Asynchronous Database I/O: Execute sparse database lookups and dense vector indexing concurrently using Python's asyncio.
  • Streaming Outputs: Stream response tokens from the LLM back to the client in real time. This lowers Time to First Token (TTFT), improving the perceived performance of the application.
  • Vector Quantization: Use Product Quantization (PQ) or Scalar Quantization (SQ) in your vector database to compress floating-point vectors from 32-bit to 8-bit integers, reducing memory bandwidth usage and boosting lookup speeds.

3. Data Governance and Drift Auditing

Documents change over time. If a policy document is revised or deleted, stale chunks must be purged from the index immediately.

  • Deterministic Chunk ID Generation: Generate chunk IDs deterministically by hashing the source document URL/filepath and chunk index (e.g., hash(doc_path + "_chunk_" + index)). This allows you to easily overwrite old chunks when a document is updated, or delete them when a document is removed.

Interview Questions

Junior Level

  1. What is the difference between parametric and non-parametric memory in LLMs, and how does RAG leverage both?
    • Answer: Parametric memory refers to the static weights learned by the LLM during pre-training. Non-parametric memory refers to external data sources (like databases). RAG uses dense and sparse retrieval to query non-parametric databases for accurate, up-to-date facts, then feeds those facts into the LLM's prompt. This allows the LLM to use its parametric language skills to write a factual response without needing to store every real-world fact in its weights.
  2. Why is recursive character chunking preferred over simple fixed-character splitting?
    • Answer: Recursive character chunking respects natural text structure by trying to split text along a hierarchy of separators (like double newlines for paragraphs, then single newlines, then spaces). This keeps complete paragraphs, sentences, and lists together. Fixed-character splitting cuts text off at arbitrary character limits, which often splits words or thoughts in half, breaking the semantic meaning of the chunk.

Intermediate Level

  1. How does Reciprocal Rank Fusion (RRF) combine sparse and dense search results without normalizing their raw similarity scores?
    • Answer: Sparse search (BM25) and dense search (cosine vector similarity) produce scores on completely different scales. Sparse scores are unbounded and depend on term frequency, while dense scores are bounded (usually between 0 and 1). Normalizing these raw scores directly is unreliable. RRF bypasses score normalization entirely by looking only at a document's rank position within each result list. It calculates a fused score using the formula: RRF_Score(d)=mM1k+rm(d)RRF\_Score(d) = \sum_{m \in M} \frac{1}{k + r_m(d)} where rm(d)r_m(d) is the document's rank in search run mm, and kk is a constant (typically 60) that prevents highly ranked outliers from dominating the scores.
  2. Explain the structural differences between Bi-encoders and Cross-encoders. Why don't we use Cross-encoders to search the entire vector database?
    • Answer: A Bi-encoder embeds queries and documents separately into independent vector spaces. Search is fast because we only need to compute simple vector similarity (like dot product) between pre-computed vectors. A Cross-encoder processes the query and document together as a single input through a Transformer, allowing every query token to attend to every document token. This is highly accurate but computationally expensive. We cannot run a Cross-encoder over millions of documents in real time. Instead, we use a two-stage pipeline: a fast Bi-encoder retrieves the top 50-100 candidates, and a Cross-encoder re-ranks only those top candidates.

Senior Level

  1. Design an architecture for a real-time, multi-tenant RAG system handling millions of files. The system must prevent document-level data leaks and update index deletions in under 5 seconds.
    • Answer: The system should be built on three core pillars:
      1. Tenant Isolation: Use a vector database (like Pinecone, Milvus, or Qdrant) that supports metadata partitioning or namespace isolation. Tag every chunk with metadata fields like tenant_id and an access list user_groups. During query execution, apply strict, un-bypassable database-level filters matching the user's active JWT credentials (e.g., tenant_id == user.tenant_id AND user_groups INTERSECT user.roles).
      2. Near Real-Time Updates (CDC): Implement a Change Data Capture (CDC) pipeline using tools like Debezium and Apache Kafka. When a document is modified or deleted in the primary database, a CDC event is published to a Kafka topic. A streaming consumer reads this event, computes the deterministic IDs of the document's chunks, and issues an asynchronous batch delete query to the vector and BM25 indexes.
      3. Query Engine Routing: Scale search throughput using an event-driven framework like FastAPI/asyncio, deploying read-replicas for vector indices, and applying Product Quantization (PQ) to reduce RAM usage and optimize query speeds.

Exercises

Easy: Overlapping Chunk Validation

Write a Python function that takes a string of text, splits it into words, and generates chunks of WW words with an overlap of OO words.

  • Input: "The Spain Digital Nomad Visa provides non-EU foreign nationals remote working capability within Spain in 2026.", W=6W=6, O=2O=2
  • Expected Output Chunks:
    1. "The Spain Digital Nomad Visa provides"
    2. "Nomad Visa provides non-EU foreign"
    3. "non-EU foreign nationals remote working"
    4. "remote working capability within Spain"
    5. "within Spain in 2026."
python
def create_word_chunks(text: str, w: int, o: int) -> List[str]:
    # Write your solution here
    words = text.split()
    if not words:
        return []
    
    chunks = []
    step = w - o
    if step <= 0:
        raise ValueError("Overlap must be strictly smaller than chunk size.")
        
    for i in range(0, len(words), step):
        chunk_words = words[i:i + w]
        chunks.append(" ".join(chunk_words))
        if i + w >= len(words):
            break
            
    return chunks

# Run validation test
print(create_word_chunks("The Spain Digital Nomad Visa provides non-EU foreign nationals remote working capability within Spain in 2026.", 6, 2))

Medium: Custom RRF Rank Merger

Implement a Reciprocal Rank Fusion (RRF) function in pure Python.

  • Input: Two lists of document IDs, sorted from best to worst.
    • dense_list = ["doc_A", "doc_B", "doc_C", "doc_D"]
    • sparse_list = ["doc_C", "doc_A", "doc_E", "doc_B"]
  • Goal: Fuse these rankings with a constant k=2k = 2 and output the final ranked IDs with their scores.
python
def rrf_merge(dense: List[str], sparse: List[str], k: int = 2) -> List[tuple[str, float]]:
    scores = {}
    for rank, doc_id in enumerate(dense):
        scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank + 1)
    for rank, doc_id in enumerate(sparse):
        scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank + 1)
    return sorted(scores.items(), key=lambda x: x[1], reverse=True)

# Run validation test
print(rrf_merge(["doc_A", "doc_B", "doc_C", "doc_D"], ["doc_C", "doc_A", "doc_E", "doc_B"], k=2))

Hard: Agentic Routing Pipeline

Build a routing mechanism that analyzes a user's query and routes it to the correct retrieval tool:

  • Route to SQL Tool if the query contains structural keywords (e.g., "How many visa applications...", "average processing fee...").
  • Route to Vector RAG Tool if the query is semantic and open-ended (e.g., "What is the lifestyle like for expats...", "how do I apply...").
  • Route to Fallback Tool for general conversational queries.
  • Requirement: Implement this router using Python and regular expressions, or design an structural LLM router using OpenAI Structured outputs.

Cheat Sheet

Retrieval Metrics Math At-A-Glance

Metric Mathematical Formula Range Best Applied When
Dot Product qd=i=1nqidi\vec{q} \cdot \vec{d} = \sum_{i=1}^{n} q_i d_i (,)(-\infty, \infty) Vector lengths carry semantic importance, or when vectors are already normalized.
Cosine Similarity cos(θ)=qdqd\cos(\theta) = \frac{\vec{q} \cdot \vec{d}}{\|\vec{q}\| \|\vec{d}\|} [1,1][-1, 1] The default for standard text search; prevents longer texts from skewing search results.
Euclidean (L2L_2) Distance qd=i=1n(qidi)2\|\vec{q} - \vec{d}\| = \sqrt{\sum_{i=1}^{n} (q_i - d_i)^2} [0,)[0, \infty) Using clustering algorithms (like IVF centroids) or specific distance-based models.

High-Yield Architecture Guidelines

  1. Overlap Rule of Thumb: Set your chunk overlap to 10%20%10\% - 20\% of your target chunk size to ensure context is preserved across chunk boundaries.
  2. The Two-Stage Retrieval Flow: Retrieve 50 chunks using speed-optimized hybrid search (BM25 + HNSW Vector), then narrow down to the top 3-5 chunks using a Cross-Encoder re-ranker before passing context to the LLM.
  3. Structured Outputs: Always enforce strict JSON schemas (e.g., via OpenAI's structured outputs or instructor packages) to guarantee that retrieved citations can be parsed programmatically.

Glossary

  • Parametric Memory: The static knowledge encoded in an LLM's weights during training.
  • Non-Parametric Memory: External data stores (databases, indexes, files) that can be updated dynamically without retraining the model.
  • Semantic Vector Embedding: The transformation of unstructured text into a dense, high-dimensional numerical vector representing its semantic meaning.
  • Cosine Similarity: A geometric metric that measures the cosine of the angle between two vectors, focusing on direction rather than magnitude.
  • Inverted File Index (IVF): An approximate nearest neighbor indexing technique that groups vectors into clusters to speed up searches.
  • Hierarchical Navigable Small World (HNSW): An ANN graph-based indexing algorithm that navigates multi-layered graphs for low-latency vector search.
  • BM25 (Best Match 25): A tf-idf-based search algorithm used by sparse search engines to rank documents based on exact keyword matches.
  • Reciprocal Rank Fusion (RRF): An algorithm that merges multiple ranked search result lists into a single ranking based on the rank positions of items.
  • Cross-encoder: A deep learning model that evaluates queries and documents together to calculate highly precise relevance scores.
  • Bi-encoder: A model that embeds queries and documents separately, allowing fast similarity lookups but losing token-to-token contextual interaction.
  • Hypothetical Document Embeddings (HyDE): A query expansion technique where an LLM generates a hypothetical answer to a query, and that hypothetical answer's vector is used to search the database.
  • Sentence-Window Retrieval (Parent-Document Retrieval): A pattern where small chunks are used for embedding lookup, but their larger parent documents are retrieved and passed to the LLM.
  • RAGAS (RAG Assessment): An evaluation framework used to measure RAG pipelines across key metrics like faithfulness, answer relevance, context precision, and recall.
  • Hallucination: A phenomenon where an LLM generates plausible-sounding but factually incorrect or unsupported claims.

References

  • Original RAG Paper: Lewis, P., et al. (2020). "Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks." NeurIPS. arXiv:2005.11401
  • Sentence-BERT Paper: Reimers, N., & Gurevych, I. (2019). "Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks." EMNLP. arXiv:1908.10084
  • BM25 Algorithm Reference: Robertson, S., & Zaragoza, H. (2009). "The Probabilistic Relevance Framework: BM25 and Beyond." Foundations and Trends in Information Retrieval.
  • HNSW Research Paper: Malkov, Y. A., & Yashunin, D. A. (2018). "Efficient and robust approximate nearest neighbor search using Hierarchical Navigable Small World graphs." IEEE Transactions on Pattern Analysis and Machine Intelligence. arXiv:1603.09320
  • "Lost in the Middle" Study: Liu, N. F., et al. (2023). "Lost in the Middle: How Language Models Use Long Contexts." Transactions of the Association for Computational Linguistics. arXiv:2307.03172
  • RAGAS Evaluation Framework: Es, S., et al. (2023). "Ragas: Automated Evaluation of Retrieval Augmented Generation." arXiv:2309.15217

FAQ Schema (Markdown Representation)

What is the primary difference between RAG and Fine-Tuning?

Fine-tuning updates the internal parameters (weights) of an LLM, teaching it new styles, formats, or behavioral rules. RAG retrieves external, factual documents and appends them to the prompt as context. Fine-tuning is slow and expensive, whereas RAG updates instantly when documents in your database are modified.

Why shouldn't I use only vector search for my RAG system?

Vector search excels at semantic matching and finding synonyms, but it is poor at matching exact character strings, product codes, or specific numbers (like visa form IDs or legal statute codes). A production RAG system should use hybrid search, combining vector search with sparse keyword search (BM25) to handle both semantic queries and exact term matches.

How can I stop my RAG system from hallucinating when it doesn't know the answer?

You can prevent hallucinations by implementing three guardrails:

  1. Use strict system prompts that forbid the LLM from answering using any facts outside the provided context.
  2. Set the generation temperature to 0.0 for deterministic outputs.
  3. Use structured output JSON schemas that require the model to explicitly return a boolean flag indicating if the context was sufficient, alongside verbatim citations to support every claim it makes.

STAY CONNECTED WITH THE EXPAT COMMUNITY

Subscribe to get expat tips, local insights, and connect with professionals around the world.