← Back to Logs

How RAG (Retrieval-Augmented Generation) Actually Works

Try the interactive lab for this articleTake the quiz (6 questions)

Large language models operating in production face fundamental boundaries set by their training data cutoffs and fixed parameter counts. Direct text generation relying purely on parametric memory suffers from continuous risk of hallucination, lack of access to private enterprise repositories, and an inability to cite authoritative data sources. Retrieval-Augmented Generation (RAG) resolves these structural limitations by decoupling knowledge storage from parametric generation. It bridges an immutable non-parametric index with an autoregressive neural generation backend.

Instead of expecting a transformer model to store billions of facts within fixed linear weight projections, a RAG system retrieves relevant context snippets from external datasets at inference time and injects them dynamically into the prompt window.

Building a production-grade RAG pipeline requires far more than wrapping a vector database API around a text file. High-throughput, low-latency retrieval demands deterministic document parsing, vector space projection math, high-dimensional Approximate Nearest Neighbor (ANN) index design, hybrid sparse-dense search fusion, cross-encoder re-ranking, and token-budgeted prompt construction.

+------------------+    +-------------------+    +--------------------+
| Document Sources | -> | Semantic Chunking | -> | Embedding Encoder  |
| (PDF, HTML, Code)|    | (AST / Markdown)  |    | (Bi-Encoder Model) |
+------------------+    +-------------------+    +--------------------+
                                                           |
                                                           v
+------------------+    +-------------------+    +--------------------+
| Vector DB (HNSW) | <- | Inverted Index    | <- | High-Dimensional   |
| Index Storage    |    | (BM25 Keywords)   |    | Dense Vectors      |
+------------------+    +-------------------+    +--------------------+
         |
         v
+------------------+    +-------------------+    +--------------------+
| User Query       | -> | Hybrid Retrieval  | -> | Reciprocal Rank    |
| Vector + Tokens  |    | (Sparse + Dense)  |    | Fusion (RRF)       |
+------------------+    +-------------------+    +--------------------+
                                                           |
                                                           v
+------------------+    +-------------------+    +--------------------+
| LLM Inference    | <- | Context Selection | <- | Cross-Encoder      |
| Generation Stage |    | & Prompt Assembly |    | Re-Ranking Stage  |
+------------------+    +-------------------+    +--------------------+

Document Parsing and Chunking Strategies

The ingestion pipeline converts unstructured raw documents into discrete textual chunks suitable for high-dimensional vector encoding. Retrieval quality is strictly bounded by chunk quality. If a text chunk contains truncated sentences, detached code blocks, or missing structural context, downstream embedding projections and neural generation steps inherit those corruptions.

Token-Aware Sliding Window Chunking

The baseline chunking methodology splits text into uniform segment sizes $C$ measured in tokens, with a fixed overlap length $O$. Character-based splitting is fundamentally flawed for language model pipelines because byte or character lengths do not map linearly to subword tokens generated by Byte-Pair Encoding (BPE) or WordPiece tokenizers. A single code symbol or Unicode character can expand into three or four BPE tokens, causing character-based splitters to violate target LLM context limits unexpectedly.

Given a token sequence $T = (t_1, t_2, \dots, t_N)$, chunk $k$ spans the token range:

$$\text{Chunk}k = (t{(k-1)(C - O) + 1}, \dots, t_{(k-1)(C - O) + C})$$

The total number of chunks $K$ generated from a document of length $N$ tokens is calculated as:

$$K = \left\lceil \frac{N - O}{C - O} \right\rceil$$

The redundancy ratio introduced by overlap is defined as:

$$R_{\text{overlap}} = \frac{O}{C - O}$$

Setting $C = 512$ tokens with an overlap of $O = 64$ tokens introduces an overlap redundancy ratio of approximately $14.3%$. The overlap ensures that semantic entities and contextual phrases straddling chunk boundaries are captured completely in at least one vector representation.

Chunk Overlap Optimization Math

Choosing optimal values for $C$ and $O$ involves a trade-off between semantic granularity and index storage cost. Let $L_{\text{entity}}$ represent the average token length of critical facts or semantic entities in the corpus. To guarantee that every semantic entity of length $L_{\text{entity}}$ appears unsevered in at least one chunk, the overlap $O$ must satisfy the inequality:

$$O \ge L_{\text{entity}} - 1$$

If $O < L_{\text{entity}} - 1$, there exists a non-zero probability $P_{\text{sever}}$ that an entity straddles the boundary between consecutive chunks such that neither chunk contains the full entity context:

$$P_{\text{sever}} = \frac{L_{\text{entity}} - 1 - O}{C - O} \quad \text{for } O < L_{\text{entity}} - 1$$

When $O \ge L_{\text{entity}} - 1$, $P_{\text{sever}} = 0$. However, setting $O$ excessively large increases total index size and introduces vector redundancy where nearby chunks map to nearly identical locations in vector space, reducing vector diversity during ANN retrieval.

Fixed-size sliding window chunking is computationally efficient, running in $O(N)$ time relative to document length. However, it operates without regard to linguistic syntax. Sentences are routinely severed mid-clause, mathematical proofs lose their initial condition definitions, and tabular data loses header context.

import tiktoken
 
class TokenSlidingWindowSplitter:
    def __init__(self, chunk_size: int = 512, chunk_overlap: int = 64, model_name: str = "cl100k_base"):
        self.chunk_size = chunk_size
        self.chunk_overlap = chunk_overlap
        self.tokenizer = tiktoken.get_encoding(model_name)
 
    def split_text(self, text: str) -> list[dict]:
        tokens = self.tokenizer.encode(text)
        total_tokens = len(tokens)
        if total_tokens == 0:
            return []
 
        chunks = []
        step = self.chunk_size - self.chunk_overlap
        chunk_idx = 0
 
        for i in range(0, total_tokens, step):
            chunk_tokens = tokens[i:i + self.chunk_size]
            chunk_text = self.tokenizer.decode(chunk_tokens)
            chunks.append({
                "chunk_id": chunk_idx,
                "start_token": i,
                "end_token": i + len(chunk_tokens),
                "token_count": len(chunk_tokens),
                "text": chunk_text
            })
            chunk_idx += 1
            if i + self.chunk_size >= total_tokens:
                break
 
        return chunks

Semantic Recursive Splitting

Semantic recursive chunking preserves structural coherence by iteratively evaluating a hierarchy of natural text boundaries. A recursive splitter uses an ordered sequence of delimiters, typically:

$$\text{Delimiters} = \left[ \text{"\n\n"}, \text{"\n"}, \text{". "}, \text{" "}, \text{""} \right]$$

The algorithm attempts to split a document on the highest-order delimiter ("\n\n" for paragraph breaks). If the resulting text block exceeds target size $C$, it recursively applies lower-order delimiters to that sub-block until all resulting chunks fall below $C$.

import tiktoken
 
class SemanticRecursiveSplitter:
    def __init__(self, chunk_size: int = 512, chunk_overlap: int = 64, model_name: str = "cl100k_base"):
        self.chunk_size = chunk_size
        self.chunk_overlap = chunk_overlap
        self.tokenizer = tiktoken.get_encoding(model_name)
        self.delimiters = ["\n\n", "\n", ". ", " ", ""]
 
    def count_tokens(self, text: str) -> int:
        return len(self.tokenizer.encode(text))
 
    def split_text(self, text: str) -> list[str]:
        return self._split_recursive(text, self.delimiters)
 
    def _split_recursive(self, text: str, delimiters: list[str]) -> list[str]:
        final_chunks = []
        if self.count_tokens(text) <= self.chunk_size:
            return [text]
 
        current_delimiter = delimiters[-1]
        new_delimiters = []
        for i, delim in enumerate(delimiters):
            if delim == "":
                current_delimiter = ""
                break
            if delim in text:
                current_delimiter = delim
                new_delimiters = delimiters[i + 1:]
                break
 
        splits = text.split(current_delimiter) if current_delimiter != "" else list(text)
        good_splits = []
 
        for split in splits:
            if self.count_tokens(split) < self.chunk_size:
                good_splits.append(split)
            else:
                if good_splits:
                    merged = self._merge_splits(good_splits, current_delimiter)
                    final_chunks.extend(merged)
                    good_splits = []
                if new_delimiters:
                    sub_chunks = self._split_recursive(split, new_delimiters)
                    final_chunks.extend(sub_chunks)
                else:
                    final_chunks.append(split)
 
        if good_splits:
            merged = self._merge_splits(good_splits, current_delimiter)
            final_chunks.extend(merged)
 
        return final_chunks
 
    def _merge_splits(self, splits: list[str], delimiter: str) -> list[str]:
        chunks = []
        current_doc = []
        current_len = 0
 
        for split in splits:
            split_len = self.count_tokens(split)
            if current_len + split_len + (self.count_tokens(delimiter) if current_doc else 0) > self.chunk_size:
                if current_doc:
                    doc_text = delimiter.join(current_doc)
                    chunks.append(doc_text)
                    while current_doc and current_len > self.chunk_overlap:
                        removed = current_doc.pop(0)
                        current_len -= self.count_tokens(removed)
                current_doc = [split]
                current_len = split_len
            else:
                current_doc.append(split)
                current_len += split_len
 
        if current_doc:
            chunks.append(delimiter.join(current_doc))
        return chunks

Semantic Markdown AST Node Splitting

For technical documentation, API specifications, and code repositories, plain text splitters destroy critical hierarchy. Semantic Markdown parsing parses documents into an Abstract Syntax Tree (AST) using Markdown block rules.

Markdown Document AST
├── HeadingNode (Level 1: "Network Protocols")
│   ├── ParagraphNode ("Overview of TCP/IP...")
│   └── HeadingNode (Level 2: "TCP Handshake")
│       ├── ParagraphNode ("The three-way handshake...")
│       └── CodeBlockNode (Language: "python", "def establish_connection()...")

AST splitting follows three rules:

  1. Header Stack Propagation: Every AST node inherits a breadcrumb path of its parent headers. A paragraph under ## TCP Handshake inside # Network Protocols receives metadata header prefix [Network Protocols > TCP Handshake]. When retrieved independently, this metadata provides context to the LLM even if the heading is outside the chunk text.
  2. Indivisible Node Enclosure: Complex AST elements such as code blocks (CodeBlockNode), tables (TableNode), and blockquotes (BlockQuoteNode) are marked as indivisible. If an indivisible node exceeds target chunk size $C$, it is allocated its own dedicated chunk rather than being split across lines.
  3. Table Structure Preservation: Markdown tables are parsed into headers and rows. If a table must be split due to context constraints, every sub-chunk retains the original table header row to preserve column meaning.
import re
 
class MarkdownASTNodeSplitter:
    def __init__(self, max_tokens: int = 512):
        self.max_tokens = max_tokens
        self.header_pattern = re.compile(r'^(#{1,6})\s+(.+)$')
 
    def parse_and_split(self, markdown_text: str) -> list[dict]:
        lines = markdown_text.split('
')
        chunks = []
        header_stack = []
        current_block = []
        in_code_block = False
        code_block_lines = []
 
        for line in lines:
            if line.strip().startswith('```'):
                if in_code_block:
                    code_block_lines.append(line)
                    in_code_block = False
                    current_block.append('
'.join(code_block_lines))
                    code_block_lines = []
                else:
                    in_code_block = True
                    code_block_lines.append(line)
                continue
 
            if in_code_block:
                code_block_lines.append(line)
                continue
 
            match = self.header_pattern.match(line)
            if match:
                if current_block:
                    text_content = '
'.join(current_block).strip()
                    if text_content:
                        chunks.append({
                            "header_path": " > ".join([h[1] for h in header_stack]),
                            "content": text_content
                        })
                    current_block = []
 
                level = len(match.group(1))
                title = match.group(2).strip()
 
                while header_stack and header_stack[-1][0] >= level:
                    header_stack.pop()
                header_stack.append((level, title))
            else:
                if line.strip():
                    current_block.append(line)
 
        if current_block:
            text_content = '
'.join(current_block).strip()
            if text_content:
                chunks.append({
                    "header_path": " > ".join([h[1] for h in header_stack]),
                    "content": text_content
                })
 
        return chunks
Chunking Strategy Processing Latency Structural Integrity Context Boundary Preservation Metadata Overhead
Fixed-Size Sliding Window $< 0.1 \text{ ms/KB}$ Low Poor (truncates arbitrary words) None
Semantic Recursive $\sim 0.5 \text{ ms/KB}$ Medium-High Good (preserves sentences/paragraphs) Low
Markdown AST Node Splitting $\sim 1.2 \text{ ms/KB}$ High High (preserves document sections & headers) Medium (header stack paths)
Code Tree-Sitter AST Parsing $\sim 3.5 \text{ ms/KB}$ Very High Excellent (preserves functions/classes) High (AST scope paths)

Vector Embeddings and Dense Vector Spaces

Once text is partitioned into structural chunks, each chunk is passed through a dense embedding model (a bi-encoder transformer model such as text-embedding-3-large, bge-large-en-v1.5, or e5-mistral-7b-instruct) to map text into a dense vector space $\mathbb{R}^d$, where vector dimensionality $d$ typically ranges from 768 to 3072.

Dense Vector Space Projection

A bi-encoder transformer model processes an input sequence of $M$ subword tokens $(t_1, t_2, \dots, t_M)$. The sequence passes through $L$ multi-head self-attention transformer layers to generate final hidden state representation vectors $\mathbf{h}_1, \mathbf{h}_2, \dots, \mathbf{h}_M \in \mathbb{R}^d$.

To reduce the token-level hidden state matrix $\mathbf{H} \in \mathbb{R}^{M \times d}$ to a single document vector $\mathbf{v} \in \mathbb{R}^d$, embedding architectures apply pooling operations:

  1. Mean Pooling: Computes the unweighted average across all non-padding token hidden states:

$$\mathbf{v}{\text{mean}} = \frac{\sum{i=1}^{M} m_i \cdot \mathbf{h}i}{\sum{i=1}^{M} m_i}$$

where $m_i \in {0, 1}$ is the binary attention mask value ($m_i = 1$ for valid tokens, $m_i = 0$ for padding tokens).

  1. [CLS] Token Pooling: Extracts the first token hidden state $\mathbf{h}_{\text{CLS}} = \mathbf{h}_1$, relying on pre-training objective functions to force the [CLS] token to aggregate sequence semantics.

  2. Last Token Pooling: Used in decoder-only causal LLMs (such as Mistral or Llama-based embedders), extracting the final non-padding token hidden state $\mathbf{h}_M$.

Following pooling, vector normalization projects $\mathbf{v}$ onto the unit hypersphere $\mathbb{S}^{d-1}$:

$$\hat{\mathbf{v}} = \frac{\mathbf{v}}{|\mathbf{v}|2} = \frac{\mathbf{v}}{\sqrt{\sum{j=1}^{d} v_j^2}}$$

Unpooled Hidden States H       Pooling Layer               Unit Hypersphere Projection
[ h_1 (CLS)  ]                 +--------------+
[ h_2 (Token)] --------------> | Mean Pooling | ----> v ----> [ Unit Normalization ] ----> v_hat
[ h_M (End)  ]                 +--------------+               ||v_hat||_2 = 1.0

Embedding Architectures: BERT vs. e5 vs. BGE

Modern embedding architectures differ in their pre-training loss formulations and query-document handling protocols.

Encoder Architecture Comparison:
 
BERT / Classic Bi-Encoder:
Query Text   ----> Transformer Encoder ----> Mean Pool ----> Vector q
Doc Text     ----> Transformer Encoder ----> Mean Pool ----> Vector d
 
e5 / BGE Prefix-Aware Architectures:
"query: " + Q  --> Transformer Encoder ----> Mean Pool ----> Vector q
"passage: " + D -> Transformer Encoder ----> Mean Pool ----> Vector d
  1. BERT-style Bi-Encoders: Process queries and documents symmetrically through twin transformer weights using Masked Language Modeling (MLM) and Next Sentence Prediction (NSP) base weights. Symmetric processing struggles when queries and documents differ fundamentally in length and grammar.
  2. e5 Embedding Family: Introduces explicit asymmetric task prefixes during training. Queries are prepended with "query: " while document passages are prepended with "passage: ". This prefix instructs the self-attention layers to project queries and passages into complementary geometric manifolds.
  3. BGE (BAAI General Embedding): Trained using multi-stage contrastive learning with synthetic negative mining. BGE utilizes the InfoNCE contrastive loss function over positive query-document pairs $(q_i, d_i^+)$ and $K$ negative document pairs $d_{i,j}^-$:

$$\mathcal{L}{\text{InfoNCE}} = - \sum{i=1}^{B} \ln \frac{\exp\left( \frac{\hat{\mathbf{q}}_i \cdot \hat{\mathbf{d}}_i^+}{\tau} \right)}{\exp\left( \frac{\hat{\mathbf{q}}_i \cdot \hat{\mathbf{d}}i^+}{\tau} \right) + \sum{j=1}^{K} \exp\left( \frac{\hat{\mathbf{q}}i \cdot \hat{\mathbf{d}}{i,j}^-}{\tau} \right)}$$

where $\tau > 0$ is a temperature hyperparameter (typically set between $0.01$ and $0.05$) controlling the penalty sharpness for hard negative samples, and $B$ is the batch size.

Geometric Vector Distance Metrics

Vector retrieval evaluates geometric proximity between a query vector $\hat{\mathbf{q}}$ and candidate document vectors $\hat{\mathbf{d}}_i$ in $\mathbb{R}^d$.

  1. Cosine Similarity: Measures the cosine of the geometric angle $\theta$ between two vectors:

$$\text{Sim}_{\text{cosine}}(\mathbf{q}, \mathbf{d}) = \frac{\mathbf{q} \cdot \mathbf{d}}{|\mathbf{q}|_2 |\mathbf{d}|_2} = \cos(\theta)$$

  1. Inner Product (Dot Product): When candidate vectors are pre-normalized to unit length ($|\mathbf{q}|_2 = |\mathbf{d}|_2 = 1$), cosine similarity simplifies directly to dot product, eliminating expensive square root and division operations during query execution:

$$\text{Sim}{\text{dot}}(\hat{\mathbf{q}}, \hat{\mathbf{d}}) = \hat{\mathbf{q}} \cdot \hat{\mathbf{d}} = \sum{j=1}^{d} q_j d_j$$

  1. Euclidean Distance ($L_2$ Distance): Measures straight-line geometric distance between two vector endpoints:

$$D_{L2}(\mathbf{q}, \mathbf{d}) = \sqrt{\sum_{j=1}^{d} (q_j - d_j)^2} = \sqrt{|\mathbf{q}|_2^2 + |\mathbf{d}|_2^2 - 2(\mathbf{q} \cdot \mathbf{d})}$$

For unit-normalized vectors where $|\hat{\mathbf{q}}|_2^2 = 1$ and $|\hat{\mathbf{d}}|_2^2 = 1$, the Euclidean distance reduces to:

$$D_{L2}(\hat{\mathbf{q}}, \hat{\mathbf{d}}) = \sqrt{2 - 2(\hat{\mathbf{q}} \cdot \hat{\mathbf{d}})}$$

This derivation proves that for unit-normalized vectors, maximizing inner product $\hat{\mathbf{q}} \cdot \hat{\mathbf{d}}$ mathematically guarantees minimizing Euclidean distance $D_{L2}(\hat{\mathbf{q}}, \hat{\mathbf{d}})$. Vector engines exploit this identity by storing normalized vectors and executing fused multiply-add (FMA) SIMD instructions (AVX-512 / ARM Neon) for raw inner products.

       Cosine Angle 	heta
           q (Query Vector)
          ^
         / 
        /   	heta
       +--------------> d (Document Vector)
      (Unit Radius Sphere Boundary)

Hierarchical Navigable Small World (HNSW) Index Mechanics

Exact nearest neighbor search requires computing inner products between query vector $\mathbf{q}$ and every vector in index $D$, yielding $O(N \cdot d)$ computational complexity. At scale ($N = 10^7$ vectors at $d = 1536$), brute-force search requires executing over 15 billion floating-point operations per query, creating unacceptable latency ($> 500 \text{ ms}$).

Production vector databases (such as Qdrant, Milvus, and pgvector) utilize Approximate Nearest Neighbor (ANN) indexing based on Hierarchical Navigable Small World (HNSW) graphs. HNSW structures vectors across a multi-layer graph hierarchy inspired by skip-lists.

Layer 2 (Sparse Entry)    [Node A] ------------------------------> [Node K]
                             |                                        |
                             v                                        v
Layer 1 (Medium Density)  [Node A] ---------> [Node F] -----------> [Node K]
                             |                   |                    |
                             v                   v                    v
Layer 0 (Dense Ground)    [Node A] -> [Node B] -> [Node F] -> [Node H] -> [Node K]

Layer Assignment Probability Math

When a new vector $\mathbf{v}$ is inserted into the HNSW index, its maximum graph layer $l$ is assigned stochastically using an exponential probability decay parameter $m_L$:

$$l = \left\lfloor -\ln(\text{uniform}(0, 1)) \cdot m_L \right\rfloor \quad \text{where} \quad m_L = \frac{1}{\ln(M)}$$

where $\text{uniform}(0, 1)$ generates a random floating-point value in interval $(0, 1]$, and $M$ is the maximum number of bi-directional outgoing links per node in upper layers $1 \dots L$. Layer 0 maintains a higher link cap $M_{\text{max0}} = 2M$ to sustain graph connectivity across the dense ground layer.

The probability $P(l = k)$ that a node is assigned to layer $k$ decays exponentially:

$$P(l \ge k) = e^{-k / m_L} = M^{-k}$$

This decay guarantees that upper layers contain exponentially fewer nodes than lower layers, maintaining a skip-list logarithmic search performance bound of $O(\log N)$.

Query execution begins at top layer $L$ at a global entry point node $e_p$. The search operates in two distinct phases:

  1. Greedy Upper Layer Routing: At layer $l > 0$, the search evaluates distances from query vector $\mathbf{q}$ to all neighbors of current entry node $e_p$. The algorithm steps greedily to whichever neighbor reduces distance to $\mathbf{q}$. When no neighbor yields a smaller distance (a local minimum at layer $l$), search drops to layer $l-1$, using the local minimum node as the entry point for the lower layer.
  2. Layer 0 Dynamic Priority Queue Search: At Layer 0, search transitions from greedy routing to a dynamic priority queue traversal bounded by hyperparameter efSearch:
    • Maintain candidate min-heap $C$ (ordered by ascending distance to $\mathbf{q}$) and result max-heap $W$ (ordered by descending distance to $\mathbf{q}$) of size efSearch.
    • Pop nearest candidate $c \in C$. If distance of $c$ to $\mathbf{q}$ exceeds the distance of the furthest element in result set $W$, terminate traversal.
    • Otherwise, evaluate neighbors of $c$. For each unvisited neighbor $e$, compute distance to $\mathbf{q}$. If $e$ is closer to $\mathbf{q}$ than the furthest point in $W$, add $e$ to both $C$ and $W$.
    • If $|W| > \text{efSearch}$, drop the furthest element from $W$.

Heuristic Neighbor Selection and Edge Pruning

During node insertion, selecting edges strictly based on minimum distance causes node clustering and geometric isolation. HNSW resolves this using a Heuristic Neighbor Selection algorithm (Shrink Select Heuristic).

Given a set of candidate neighbors $C$ evaluated for target node $u$, candidates are processed in order of increasing distance to $u$. A candidate $e \in C$ is connected to $u$ if and only if $e$ is closer to $u$ than to any neighbor $r$ already added to $u$'s connection set $R$:

$$\text{Select Edge } (u, e) \iff \forall r \in R, \quad \text{Dist}(e, u) < \text{Dist}(e, r)$$

This heuristic forces edges to span across geometric voids to maintain global connectivity across different semantic clusters.

Heuristic Edge Selection:
Candidate e is accepted if Dist(u, e) < Dist(r, e) for all existing neighbors r in R.
 
     [Existing Neighbor r]
                       \  Dist(r, e)
               [Node u] ------> [Candidate e]
        Dist(u, e)

Parameter Trade-offs: efConstruction vs. efSearch

HNSW performance depends on three tuning parameters:

  1. $M$ (Max Links Per Node): Range $16 \dots 64$. Higher values improve recall for high-dimensional vectors ($d > 1024$) at the expense of higher index memory footprint and slower insertion rates. Memory overhead per vector scales as $O(M \cdot d)$.
  2. efConstruction: Controls candidate queue size during index building. Higher values improve index graph quality by evaluating broader search paths when creating edges, increasing index construction latency linearly.
  3. efSearch: Controls candidate queue size during query runtime. Increasing efSearch improves search recall (approaching $100%$ exact search recall) at the cost of higher query latency.
import heapq
import numpy as np
 
def cosine_distance(u: np.ndarray, v: np.ndarray) -> float:
    return 1.0 - float(np.dot(u, v) / (np.linalg.norm(u) * np.linalg.norm(v)))
 
class HNSWGraphSearch:
    def __init__(self, adjacency_list: dict[int, list[int]], vectors: dict[int, np.ndarray]):
        self.adj = adjacency_list
        self.vectors = vectors
 
    def select_neighbors_heuristic(self, target_id: int, candidates: list[int], M_max: int) -> list[int]:
        target_vec = self.vectors[target_id]
        sorted_candidates = sorted(candidates, key=lambda c: cosine_distance(target_vec, self.vectors[c]))
        
        result_neighbors: list[int] = []
        for e in sorted_candidates:
            if len(result_neighbors) >= M_max:
                break
            e_vec = self.vectors[e]
            dist_target_e = cosine_distance(target_vec, e_vec)
            
            keep = True
            for r in result_neighbors:
                dist_r_e = cosine_distance(self.vectors[r], e_vec)
                if dist_r_e < dist_target_e:
                    keep = False
                    break
            if keep:
                result_neighbors.append(e)
                
        return result_neighbors
 
    def search_layer_0(self, query: np.ndarray, entry_node: int, ef_search: int) -> list[tuple[float, int]]:
        visited = {entry_node}
        dist_entry = cosine_distance(query, self.vectors[entry_node])
        
        candidates = [(dist_entry, entry_node)]
        w_results = [(-dist_entry, entry_node)]
 
        while candidates:
            dist_c, current_c = heapq.heappop(candidates)
            furthest_result_dist = -w_results[0][0]
 
            if dist_c > furthest_result_dist:
                break
 
            for neighbor in self.adj.get(current_c, []):
                if neighbor not in visited:
                    visited.add(neighbor)
                    dist_n = cosine_distance(query, self.vectors[neighbor])
                    furthest_result_dist = -w_results[0][0]
 
                    if dist_n < furthest_result_dist or len(w_results) < ef_search:
                        heapq.heappush(candidates, (dist_n, neighbor))
                        heapq.heappush(w_results, (-dist_n, neighbor))
 
                        if len(w_results) > ef_search:
                            heapq.heappop(w_results)
 
        final_results = [(-dist, node) for dist, node in w_results]
        final_results.sort(key=lambda x: x[0])
        return final_results

Hybrid Retrieval Architectures

Dense vector embeddings capture broad semantic intent (such as matching "cardiac event" to "heart attack"). However, dense retrieval routinely fails when queries demand exact token matches, specialized alphanumeric identifiers, error logs, or function signatures (such as matching ERR_SOCKET_TIMEOUT_0x84).

Production systems address this failure mode through Hybrid Retrieval, combining dense vector ANN search with sparse keyword search (BM25) over inverted indexes.

BM25 Sparse Keyword Mechanics

BM25 (Best Matching 25) scores document relevance based on exact term frequencies adjusted by inverse document frequency and document length normalization. Given query terms $Q = (q_1, q_2, \dots, q_n)$ and document $D$:

$$\text{Score}{\text{BM25}}(D, Q) = \sum{i=1}^{n} \text{IDF}(q_i) \cdot \frac{f(q_i, D) \cdot (k_1 + 1)}{f(q_i, D) + k_1 \cdot \left(1 - b + b \cdot \frac{|D|}{\text{avgdl}}\right)}$$

where $f(q_i, D)$ is the raw term frequency of query term $q_i$ in document $D$, $|D|$ is the token length of document $D$, and $\text{avgdl}$ is the average document token length across the entire collection.

The Inverse Document Frequency (IDF) factor weights rare terms higher than common terms:

$$\text{IDF}(q_i) = \ln \left( \frac{N - n(q_i) + 0.5}{n(q_i) + 0.5} + 1 \right)$$

where $N$ is the total document count in the index, and $n(q_i)$ is the number of documents containing term $q_i$.

  • Parameter $k_1$ (typically set between $1.2$ and $2.0$) controls term frequency saturation. As term frequency $f(q_i, D)$ increases, the term score contribution asymptotically approaches $k_1 + 1$.
  • Parameter $b$ (typically set to $0.75$) controls document length normalization. Setting $b = 1$ penalizes long documents fully, while $b = 0$ removes length normalization completely.
BM25 Score Contribution
      ^
k1+1 -+----------------------- Asymptotic Ceiling
      |                     /
      |                  .-'
      |               .-'
      |            .-'
      |        _.-'
      +-------+--------------------> Term Frequency f(q_i, D)

Fast Inverted Index Execution: Block-Max WAND

Searching millions of documents using BM25 is optimized using inverted index posting lists and dynamic pruning algorithms such as Block-Max WAND (Weak AND). Posting lists store document IDs alongside term frequency counts. Block-Max WAND divides posting lists into blocks of documents (e.g. 64 or 128 documents per block) and precomputes the maximum term score contribution $\text{Score}_{\text{max}}$ for each block.

During query evaluation, WAND skips entire blocks of documents whose upper-bound score sum cannot exceed the score threshold of the current top-$K$ result heap, reducing sparse evaluation latency to sub-millisecond execution.

Score Combination: Reciprocal Rank Fusion (RRF) vs. Convex Normalization

Dense vector search returns continuous cosine similarity scores in range $[-1.0, 1.0]$, whereas sparse BM25 returns unbounded positive real scores $[0, \infty)$. Combining raw scores directly via weighted sums causes instability because BM25 score distributions fluctuate depending on document length and term rarity.

Reciprocal Rank Fusion (RRF)

Reciprocal Rank Fusion resolves score distribution mismatch by combining retrieval systems using rank positions instead of raw numerical scores.

Given document set $D$ and rank lists $R_m$ produced by $M$ independent retrieval engines (such as $R_{\text{dense}}$ and $R_{\text{sparse}}$), the RRF score for document $d$ is:

$$\text{RRF_Score}(d \in D) = \sum_{m \in M} \frac{1}{k + r_m(d)}$$

where $r_m(d) \in {1, 2, 3, \dots}$ is the 1-based rank position of document $d$ in result list $m$. If document $d$ is absent from list $m$, its rank term $r_m(d)$ is set to $\infty$.

The constant $k$ (typically set to $60$) acts as a smoothing factor, preventing top-ranked outliers in one engine from dominating the combined rank order.

def reciprocal_rank_fusion(
    dense_results: list[str], 
    sparse_results: list[str], 
    k: int = 60, 
    top_n: int = 10
) -> list[tuple[str, float]]:
    rrf_scores: dict[str, float] = {}
 
    for rank, doc_id in enumerate(dense_results, start=1):
        if doc_id not in rrf_scores:
            rrf_scores[doc_id] = 0.0
        rrf_scores[doc_id] += 1.0 / (k + rank)
 
    for rank, doc_id in enumerate(sparse_results, start=1):
        if doc_id not in rrf_scores:
            rrf_scores[doc_id] = 0.0
        rrf_scores[doc_id] += 1.0 / (k + rank)
 
    sorted_docs = sorted(rrf_scores.items(), key=lambda item: item[1], reverse=True)
    return sorted_docs[:top_n]

Convex Score Normalization (Min-Max Scaling)

When relative score distance must be preserved, pipelines apply Min-Max score normalization before linear weighting:

$$\tilde{S}{\text{dense}}(d) = \frac{S{\text{dense}}(d) - \min(S_{\text{dense}})}{\max(S_{\text{dense}}) - \min(S_{\text{dense}})}$$

$$\tilde{S}{\text{sparse}}(d) = \frac{S{\text{sparse}}(d) - \min(S_{\text{sparse}})}{\max(S_{\text{sparse}}) - \min(S_{\text{sparse}})}$$

$$\text{Score}{\text{hybrid}}(d) = \alpha \cdot \tilde{S}{\text{dense}}(d) + (1 - \alpha) \cdot \tilde{S}_{\text{sparse}}(d)$$

where $\alpha \in [0, 1]$ is a tunable hyperparameter (typically set to $0.7$ for dense preference).


Re-Ranking and Context Window Optimization

While hybrid retrieval over ANN graphs and inverted indexes operates in milliseconds, bi-encoder retrieval architectures introduce an inescapable trade-off: Query and document vectors are computed independently without token-level cross-attention.

Bi-Encoder Vector Processing:
Query Token Stream ----> Encoder Model --------> Vector q                                                            +--> Dot Product (Fast)
Document Token Stream -> Encoder Model --------> Vector d /
 
Cross-Encoder Joint Attention:
[CLS] + Query Tokens + [SEP] + Document Tokens -> Joint Transformer Layers -> Re-Rank Logit

Bi-Encoders vs. Cross-Encoders

Bi-encoders generate vector embeddings for queries and documents in isolated forward passes. This isolation allows document vectors to be pre-indexed offline. However, the vector dot product $\mathbf{q} \cdot \mathbf{d}$ compresses all semantic interaction into a single scalar value, preventing deep cross-token interaction.

Cross-encoders concatenate query tokens and document tokens into a single unified input sequence:

$$\text{Input}_{\text{cross}} = \text{[CLS]} ,, q_1 , q_2 , \dots , q_m ,, \text{[SEP]} ,, d_1 , d_2 , \dots , d_p ,, \text{[SEP]}$$

This sequence passes through every self-attention layer of a joint transformer model (such as ms-marco-MiniLM-L-6-v2 or bge-reranker-large). Every query token directly attends to every document token across all attention heads:

$$\text{Attention}(Q, K, V) = \text{softmax}\left(\frac{Q K^T}{\sqrt{d_k}}\right)V$$

The final hidden state of the [CLS] token passes through a linear classification head to output a raw relevance logit score $s \in (-\infty, \infty)$.

Cross-encoders deliver substantially higher retrieval precision than bi-encoders. However, because cross-encoders must process a full transformer forward pass for every candidate document at query time, they cannot be pre-indexed. Processing $10,000$ document chunks through a cross-encoder introduces several seconds of query latency.

Two-Stage Retrieval Pipeline Architecture

Production RAG designs implement a two-stage retrieval cascade to balance search speed with score precision:

  1. Stage 1 (Coarse Candidate Retrieval): Use Hybrid Sparse-Dense Search (HNSW + BM25) to scan millions of indexed chunks, returning candidate set $K \approx 100$ in $\sim 15 \text{ ms}$.
  2. Stage 2 (Fine-Grained Re-Ranking): Pass candidate set $K = 100$ through a Cross-Encoder model. Re-rank documents based on joint attention logits, returning top-$N \approx 5$ chunks in $\sim 35 \text{ ms}$.

Total retrieval latency stays within $50 \text{ ms}$ while achieving cross-encoder level precision.

Context Placement and Lost-in-the-Middle Optimization

Once top-$N$ chunks are selected, they are formatted into the LLM system prompt context block. How chunks are positioned within the prompt window directly dictates generation accuracy.

Research into transformer attention distribution demonstrates that LLMs exhibit a "lost in the middle" attention degradation curve. Models attend effectively to tokens situated near the beginning of the prompt context (primacy effect) and tokens near the end of the prompt context (recency effect), but struggle to recall facts placed in the middle third of long prompt contexts.

Model Attention Retrieval Performance
     High  ^   \                                            /
           |    \                                          /
           |     \                                        /
           |      \______________________________________/
      Low  +-------------------------------------------------->
           Beginning of Context     Middle       End of Context

To combat lost-in-the-middle degradation, context construction pipelines apply inverse rank ordering:

  1. Take the top re-ranked document chunks $(C_1, C_2, C_3, C_4, C_5)$.
  2. Interleave chunks such that highest-ranked documents reside at the outer boundaries of the prompt context:

$$\text{Prompt Context Layout} = \left[ C_1, C_3, C_5, \dots, C_4, C_2 \right]$$

def reorder_context_for_lost_in_the_middle(documents: list[dict]) -> list[dict]:
    sorted_docs = documents.copy()
    reordered: list[dict] = []
    
    for i, doc in enumerate(sorted_docs):
        if i % 2 == 0:
            reordered.insert(0, doc)
        else:
            reordered.append(doc)
            
    return reordered

Context Token Budgeting and Metadata Injection

Before injecting re-ordered context blocks into the LLM prompt, the pipeline must enforce strict token budget allocation.

$$\text{Budget}{\text{context}} = N{\text{max_window}} - N_{\text{system}} - N_{\text{query}} - N_{\text{generation_reserve}}$$

Each chunk is formatted with standardized metadata tags:

[DOCUMENT METADATA]
Source: docs/networking/tcp_handshake.md
Path: Network Protocols > Transport Layer > TCP
Relevance Score: 0.942
 
[CONTENT]
The TCP three-way handshake establishes a reliable socket connection using SYN, SYN-ACK, and ACK packets...

If adding a candidate chunk exceeds $\text{Budget}_{\text{context}}$, the pipeline truncates remaining candidate chunks to prevent context overflow errors.


Production Failure Modes, Failure Mitigations, and Pipeline Telemetry

RAG architectures deployed to production environments regularly encounter silent operational failures where systems report high vector similarity scores but yield corrupted or incomplete text generation.

1. Query-Document Embedding Asymmetry

A fundamental mismatch exists between user query structure and document chunk structure:

  • User Query: Short, interrogative, sparse ($5 \dots 15$ tokens), e.g. "How do I fix 0x80070005 access denied error?"
  • Document Chunk: Long, declarative, detailed ($250 \dots 500$ tokens), covering registry access rights, NTFS security descriptors, and system user groups.

Bi-encoder models projected into vector space often cluster long declarative texts separately from short interrogative prompts, causing relevant document chunks to land far from the query vector.

Mitigation: Hypothetical Document Embeddings (HyDE)

HyDE converts the short query into a synthetic document before performing vector search:

  1. Send query $q$ to a fast LLM with prompt: "Write a technical manual paragraph answering the following question: {q}".
  2. Receive synthetic hypothesis document $d_{\text{synth}}$.
  3. Pass $d_{\text{synth}}$ through the embedding encoder to generate vector $\mathbf{v}{\text{synth}} = E(d{\text{synth}})$.
  4. Execute vector search using $\mathbf{v}_{\text{synth}}$ instead of original query vector $\mathbf{q}$.

Because $d_{\text{synth}}$ shares declarative length and phrasing patterns with indexed document chunks, $\mathbf{v}_{\text{synth}}$ locates relevant chunks accurately within the vector space.

User Query q ---> Fast LLM Prompter ---> Synthetic Document d_synth
                                                     |
                                                     v
Document Index <--- HNSW Search <--- Embedding Encoder E(d_synth)

2. Context Window Contamination and Threshold Filtering

When hybrid retrieval returns $N$ candidate chunks, low-relevance chunks are frequently included to fill the requested top-$N$ quota. Ingesting irrelevant text into prompt contexts degrades LLM reasoning performance, causing the model to summarize irrelevant noise rather than state that information is absent.

Mitigation: Absolute Cutoffs and Relative Margin Gates

Production pipelines enforce dual relevance gates prior to context assembly:

  1. Absolute Similarity Threshold: Reject any chunk where cross-encoder score $s_i < \tau_{\text{abs}}$ (e.g. $\tau_{\text{abs}} < 0.35$).
  2. Relative Delta Threshold: Calculate distance relative to top-ranked chunk score $s_1$:

$$\text{Retain Chunk } i \iff (s_1 - s_i) \le \Delta_{\text{max}}$$

If no chunks satisfy these criteria, the retrieval pipeline short-circuits, returning a deterministic system response ("Required documentation unavailable") without triggering expensive LLM generation passes.

3. Stale Indexes and Index-Datastore Desynchronization

In enterprise applications, source documents undergo continuous CRUD modifications (updating permission policies, editing wiki pages, soft-deleting database entries). If vector indexes and inverted keyword stores fail to reflect updates in real time, RAG pipelines retrieve obsolete or deleted text.

Mitigation: Transactional Outbox and Dual-State Tombstoning

To maintain transactional synchronization between primary datastores and vector engines:

  1. Transactional Outbox Pattern: Primary database updates write record mutations and an outbox event within a single SQL transaction.
  2. CDC Event Ingestion: A Change Data Capture pipeline (such as Debezium listening to PostgreSQL WAL logs) reads outbox events and publishes mutation tasks to a durable queue (such as Apache Kafka).
  3. Vector Tombstoning: When a document is deleted, worker nodes write a tombstone entry (is_deleted = true) to vector payload metadata. Query filters append is_deleted EQUALS false to every HNSW traversal step, bypassing obsolete vectors prior to background graph index compaction.

4. Retrieval Evaluation Telemetry Metrics

To monitor retrieval quality continuously without relying on manual LLM output reviews, production pipelines instrument automated retrieval metrics:

  1. Hit Rate@K: Percentage of test queries for which at least one ground-truth relevant document appears in top-$K$ retrieved results:

$$\text{Hit Rate}@K = \frac{1}{|Q|} \sum_{i=1}^{|Q|} \mathbb{I}\left( |R_i^{(K)} \cap D_i^*| > 0 \right)$$

  1. Mean Reciprocal Rank (MRR@K): Evaluates the position of the first relevant document in the retrieved result list:

$$\text{MRR}@K = \frac{1}{|Q|} \sum_{i=1}^{|Q|} \frac{1}{\text{rank}_i^*}$$

where $\text{rank}_i^*$ is the 1-based rank position of the first relevant document for query $i$.

  1. Normalized Discounted Cumulative Gain (NDCG@K): Measures multi-level relevance ranking precision, discounting relevance scores logarithmically at lower rank positions:

$$\text{DCG}@K = \sum_{j=1}^{K} \frac{2^{\text{rel}_j} - 1}{\log_2(j + 1)}$$

$$\text{NDCG}@K = \frac{\text{DCG}@K}{\text{IDCG}@K}$$

where $\text{IDCG}@K$ is the Ideal Discounted Cumulative Gain calculated over ground-truth relevance order.


Architecture Summary

A production-grade RAG system requires strict mechanical alignment across every layer of the retrieval hierarchy:

[ Raw Documents ]
       |
       v (Tree-Sitter / Semantic AST Splitter)
[ Structural Chunks ]
       |
       +---> (BM25 Inverted Index Engine) -----       |                                         +--> [ Hybrid RRF Fusion ]
       +---> (Bi-Encoder HNSW Vector DB) ------/               |
                                                               v
                                                    [ Stage-1 Top-100 Set ]
                                                               |
                                                               v (Cross-Encoder Transformer)
                                                    [ Stage-2 Re-Ranked Top-5 ]
                                                               |
                                                               v (Lost-in-the-Middle Layout)
                                                    [ Reordered Context Block ]
                                                               |
                                                               v
                                                    [ LLM Generation Engine ]

Decoupling knowledge storage from autoregressive generation transforms large language models from opaque, static parameter stores into verifiable, dynamically updated enterprise reasoning systems. RAG performance is determined by deterministic engineering: rigorous chunk parsing, mathematical vector alignment, hybrid sparse-dense search, cross-encoder precision, and strict context budgeting.