Skip to main content
artificial-intelligence12 min read

Build Your Own RAG from Scratch: A Production Engineering Guide

Engineer a production RAG pipeline: semantic chunking, dense embeddings, BM25 hybrid search, cross-encoder reranking, and hallucination mitigation.

QUERYVectorizeDense VectorCosine SimSparse BM25Lexical IndexRRF FUSIONRank MergeRERANKCross-Enc

Most naive tutorials on Retrieval-Augmented Generation (RAG) follow a trivial three-step script: load text with LangChain, split on arbitrary 500-character boundaries, generate OpenAI embeddings, and dump them into an in-memory vector store.

When deployed to production, these naive setups immediately break down:

  1. Short chunking severs critical semantic context across paragraphs.
  2. Pure vector search fails on exact keyword lookups (like part numbers, error codes, and customer IDs).
  3. The LLM hallucinates because irrelevant context fragments poison the prompt window.

To achieve production-grade precision, you must build a Two-Stage Hybrid Retrieval Pipeline. Here is how to construct one from first principles using Python and modern vector infrastructure.


1. High-Level Architecture of Production RAG

[User Query]
     |
     +----------------------------------+
     |                                  |
     v Dense Query Vector               v Sparse Tokens (BM25)
[Embedding Model]                 [Lexical Analyzer]
     |                                  |
     +------------+        +------------+
                  |        |
                  v        v
          [Vector DB (e.g. Qdrant)]
          Executes Reciprocal Rank Fusion (RRF)
                  |
                  v (Top 50 Initial Candidate Chunks)
          [Cross-Encoder Reranker] (Deep Context Scoring)
                  |
                  v (Top 5 High-Scoring Context Chunks)
          [Context Injection into Prompt Template]
                  |
                  v
          [Frontier LLM (Claude / GPT / Llama)]
                  |
                  v
          [Grounded, Factual Response with Citations]

2. Step 1: Semantic-Aware Document Chunking

Never split text arbitrarily by character count. If a code block or table is split in half, its semantic meaning is destroyed.

Use recursive, delimiter-aware chunking with context overlap:

# semantic_chunker.py
import re
from typing import List

def recursive_chunk_text(text: str, max_tokens: int = 512, overlap: int = 64) -> List[str]:
    """
    Recursively splits text on natural linguistic boundaries:
    1. Double newlines (paragraphs)
    2. Single newlines (list items)
    3. Sentence periods
    """
    paragraphs = re.split(r'\n\s*\n', text)
    chunks = []
    current_chunk = []
    current_len = 0

    for para in paragraphs:
        para_words = para.split()
        if current_len + len(para_words) <= max_tokens:
            current_chunk.append(para)
            current_len += len(para_words)
        else:
            if current_chunk:
                chunks.append("\n\n".join(current_chunk))
            current_chunk = [para]
            current_len = len(para_words)

    if current_chunk:
        chunks.append("\n\n".join(current_chunk))
        
    return chunks

3. Step 2: Hybrid Search with Reciprocal Rank Fusion (RRF)

Dense vector search (cosine similarity on embedding vectors) captures general conceptual meaning, but struggles with exact strings (e.g., "CVE-2024-38077" or "User ID 89211"). Sparse search (BM25) excels at exact matches.

Modern vector databases like Qdrant Cloud ★ implement native Hybrid Search:

# qdrant_hybrid_search.py
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, SparseVectorParams

client = QdrantClient(url="https://your-cluster.qdrant.io", api_key="YOUR_API_KEY")

# Create collection supporting both dense embeddings and sparse BM25 indices
client.create_collection(
    collection_name="knowledge_base",
    vectors_config={
        "dense": VectorParams(size=1024, distance=Distance.COSINE),
    },
    sparse_vectors_config={
        "sparse": SparseVectorParams(),
    }
)

During query time, results from dense vector distance and sparse BM25 ranking are combined using Reciprocal Rank Fusion:

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

Where $k \approx 60$ is a constant and $r_m(d)$ is the rank position of document $d$ in system $m$. Documents that score well in both semantic context and exact keywords rise to the top.


4. Step 3: Two-Stage Retrieval with Cross-Encoder Reranking

Bi-encoder models (standard embeddings) encode the query and document independently into fixed-length vectors. This is fast for vector search, but misses fine-grained interactions.

A Cross-Encoder Reranker passes the query and candidate chunk together through a full self-attention layer:

# rerank.py
from sentence_transformers import CrossEncoder

reranker = CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')

def rerank_candidates(query: str, candidates: list[str], top_k: int = 5) -> list[str]:
    # Form query-document pairs
    pairs = [[query, doc] for doc in candidates]
    
    # Compute cross-attention relevance scores
    scores = reranker.predict(pairs)
    
    # Sort and return the highest-scoring chunks
    ranked_indices = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)
    return [candidates[i] for i in ranked_indices[:top_k]]

Reranking reduces noise by up to 70%, filtering out false-positive vector neighbors before they contaminate the LLM prompt.


5. Step 4: Grounded Prompt Engineering & Anti-Hallucination Guardrails

Now inject the verified top-5 chunks into the prompt context with strict citation instructions:

# prompt_builder.py

SYSTEM_PROMPT = """
You are a senior technical documentation assistant.
Answer the user's question using ONLY the provided context blocks.
If the answer cannot be determined strictly from the context, state:
"I do not have sufficient verifiable documentation to answer this question."

Always cite the exact [Source ID] when asserting factual statements.
"""

def build_rag_prompt(query: str, chunks: list[dict]) -> str:
    context_str = "\n\n".join([
        f"[Source {i+1}]: {chunk['content']}" for i, chunk in enumerate(chunks)
    ])
    
    return f"""
Context Documentation:
{context_str}

User Question: {query}

Grounded Technical Response:
"""

For teams looking for fully managed, high-performance vector infrastructure with built-in hybrid search, Qdrant Cloud ★ and Pinecone ★ provide native sub-10ms query execution with multi-tenant namespaces.


Summary Takeaways

  1. Chunk on natural semantics: Never split across paragraphs, code functions, or Markdown tables.
  2. Combine dense + sparse search: Dense captures intent; BM25 guarantees keyword and error code fidelity.
  3. Always rerank: A cross-encoder reranker is the single most effective upgrade for eliminating RAG hallucinations.

Primary References & Scientific Citations

  1. Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks — Lewis et al. (Meta AI / NeurIPS)
  2. Qdrant Vector Database Architecture and Hybrid Search Docs — Qdrant Documentation[Source]
  3. Rerankers and Two-Stage Retrieval Systems — Cohere AI Research
Tags:#RAG Systems#Vector Search#Embeddings#Python#Qdrant#LLMs
Signal Insight

Signal Insight

AI & Machine Learning

Algorithmic explorations into transformer architectures, inference economics, RAG systems, and evaluation benchmarks.