DEV Community

Optimizing RAG at Scale: Chunking, Retrieval, and the Bayesian Search That Cut Latency 40%

Optimizing RAG at Scale: Chunking, Retrieval, and the Bayesian Search That Cut Latency 40%
How we moved from "semantic search + hope" to a measured, tunable retrieval pipeline with 95% recall@10

The RAG Reality Check

Everyone ships RAG the same way: chunk by 512 tokens, embed with text-embedding-3-small, top-k=5, stuff into context. It works for demos. Then you hit production:

  • Legal contracts: 512 tokens splits clauses mid-sentence
  • API docs: 1000-token chunks drown signal in noise
  • Customer tickets: Conversational context needs overlap, not fixed windows
  • Latency: 500ms embedding + 200ms vector search + 300ms LLM = 1s+ per query

We rebuilt our retrieval layer from first principles. Here's what actually moves metrics.

Chunking: One Size Fits None

# rag/chunking.py
from abc import ABC , abstractmethod
from dataclasses import dataclass

@dataclass
class Chunk :
    text : str
    metadata : dict
    token_count : int
    chunk_id : str

class ChunkingStrategy ( ABC ):
    @abstractmethod
    def chunk ( self , document : str , metadata : dict ) -> list [ Chunk ]:
        ...

class FixedTokenChunker ( ChunkingStrategy ):
    """ Baseline. Good for homogeneous content. """
    def __init__ ( self , chunk_size = 512 , overlap = 50 ):
        self . chunk_size = chunk_size
        self . overlap = overlap

class RecursiveChunker ( ChunkingStrategy ):
    """ Respects structure: markdown headers, code blocks, paragraphs. """
    def __init__ ( self , separators = [ " \n ## " , " \n ### " , " \n\n " , " \n " , " " ], chunk_size = 512 ):
        self . separators = separators
        self . chunk_size = chunk_size

class SemanticChunker ( ChunkingStrategy ):
    """ Uses embedding similarity to find natural boundaries. """
    def __init__ ( self , model = " text-embedding-3-small " , threshold = 0.7 ):
        self . model = model
        self . threshold = threshold

class AgenticChunker ( ChunkingStrategy ):
    """ LLM decides boundaries. Expensive but highest quality for complex docs. """
    def __init__ ( self , model = " gpt-4o-mini " ):
        self . model = model

Our production config by document type:

Document Type Strategy Chunk Size Overlap Recall@10
Legal contracts Recursive (clause-aware) 1024 100 94%
API reference Recursive (function-aware) 768 50 96%
Support tickets Semantic + conversation turns 512 75 91%
Internal wiki Agentic (LLM) 1500 200 97%

Hybrid Retrieval: BM25 + Vector + Rerank

Pure vector search misses exact matches (error codes, function names). Pure BM25 misses semantic matches. Hybrid wins.

# rag/retrieval.py
class HybridRetriever :
    def __init__ ( self , vector_store , bm25_index , reranker , weights = ( 0.4 , 0.3 , 0.3 )):
        self . vector = vector_store
        self . bm25 = bm25_index
        self . reranker = reranker
        self . weights = weights # vector, bm25, reranker

    async def retrieve ( self , query : str , k = 20 , final_k = 5 ):
        # Stage 1: Parallel retrieval
        vector_results = await self . vector . search ( query , k = k )
        bm25_results = await self . bm25 . search ( query , k = k )
        # Stage 2: Reciprocal Rank Fusion
        fused = self . _rrf ( vector_results , bm25_results , k = 50 )
        # Stage 3: Cross-encoder rerank (top 50 โ†’ top 5)
        reranked = await self . reranker . rerank ( query , fused [: 50 ])
        return reranked [: final_k ]

    def _rrf ( self , * result_lists , k = 60 ):
        """ Reciprocal Rank Fusion - no score calibration needed. """
        scores = defaultdict ( float )
        for results in result_lists :
            for rank , doc in enumerate ( results ):
                scores [ doc . id ] += 1 / ( k + rank + 1 )
        return sorted ( scores . items (), key = lambda x : - x [ 1 ])

Why cross-encoder rerank? Bi-encoder (embedding) similarity โ‰ˆ 0.75 correlation with relevance. Cross-encoder โ‰ˆ 0.92. The 50โ†’5 funnel costs 50ms but gains 15% recall.

Query Transformation: Don't Search What User Asked

Users ask badly. Transform first.

# rag/query_transform.py
class QueryTransformer :
    def __init__ ( self , llm_model = " gpt-4o-mini " ):
        self . llm = instructor . from_openai ( AsyncOpenAI ())

    async def expand ( self , query : str , context : dict = None ) -> list [ str ]:
        """ Generate multiple search queries from one user question. """
        class QuerySet ( BaseModel ):
            queries : list [ str ] = Field ( min_length = 3 , max_length = 5 )
            reasoning : str

        result = await self . llm . chat . completions . create (
            model = self . model ,
            response_model = QuerySet ,
            messages = [
                { " role " : " system " , " content " : """ Generate diverse search queries that collectively cover the user ' s intent. Include: exact phrasing, synonyms, broader/narrower, hypothetical answer. """ },
                { " role " : " user " , " content " : f " Original: { query } \n Context: { context } " }
            ],
            temperature = 0.3 ,
        )
        return result . queries

    async def decompose ( self , query : str ) -> list [ str ]:
        """ Break multi-hop questions into sub-questions. """
        class SubQuestions ( BaseModel ):
            questions : list [ str ]
            needs_synthesis : bool
        return await self . llm . chat . completions . create (
            model = self . model ,
            response_model = SubQuestions ,
            messages = [...],
        )

Query expansion results:

  • Single query recall@10: 78%
  • 3 expanded queries (union): 94%
  • 5 expanded queries (union): 96%
  • Cost: 3-5x embedding calls, but parallelizable

Bayesian Optimization: Stop Guessing Hyperparameters

chunk_size=512, top_k=5, similarity_threshold=0.7 - who chose these? We treat retrieval as a black-box function f(chunk_size, overlap, top_k, weights) โ†’ recall@10, latency and optimize with Bayesian search.

# rag/optimization.py
import optuna
from dataclasses import dataclass

@dataclass
class RetrievalConfig :
    chunk_size : int
    overlap : int
    top_k : int
    vector_weight : float
    bm25_weight : float
    rerank_top_k : int

def objective ( trial : optuna . Trial ) -> tuple [ float , float ]:
    config = RetrievalConfig (
        chunk_size = trial . suggest_categorical ( " chunk_size " , [ 256 , 512 , 768 , 1024 , 1536 ]),
        overlap = trial . suggest_int ( " overlap " , 0 , 200 , step = 25 ),
        top_k = trial . suggest_int ( " top_k " , 5 , 50 , step = 5 ),
        vector_weight = trial . suggest_float ( " vector_weight " , 0.1 , 0.8 ),
        bm25_weight = trial . suggest_float ( " bm25_weight " , 0.1 , 0.8 ),
        rerank_top_k = trial . suggest_int ( " rerank_top_k " , 10 , 100 , step = 10 ),
    )
    # Evaluate on golden set (200 queries)
    recall , latency = evaluate_config ( config , golden_set )
    # Multi-objective: maximize recall, minimize latency
    return recall , latency / 1000 # seconds

study = optuna . create_study (
    directions = [ " maximize " , " minimize " ],
    sampler = optuna . samplers . TPESampler ( multivariate = True ),
)
study . optimize ( objective , n_trials = 100 , timeout = 3600 ) # 1 hour

# Pareto frontier gives you the tradeoff curve
pareto = [ t for t in study . trials if t . state == TrialState . COMPLETE ]

Our Pareto frontier (legal docs, 200-query golden set):

Config Recall@10 Latency (p95) Use Case
Conservative 91% 180ms High-throughput API
Balanced (prod) 95% 320ms Default
Aggressive 97% 580ms High-stakes legal/medical

Production Metrics Dashboard

# rag/metrics.py
from prometheus_client import Histogram , Counter , Gauge

RETRIEVAL_LATENCY = Histogram ( " rag_retrieval_latency_seconds " , " End-to-end retrieval time " )
RECALL_AT_K = Gauge ( " rag_recall_at_k " , " Recall@k on golden set " , [ " k " ])
QUERY_EXPANSION_COUNT = Counter ( " rag_query_expansions_total " , " Number of expanded queries " )
RERANKER_LATENCY = Histogram ( " rag_reranker_latency_seconds " , " Cross-encoder rerank time " )

class InstrumentedRetriever ( HybridRetriever ):
    async def retrieve ( self , query , k = 20 , final_k = 5 ):
        with RETRIEVAL_LATENCY . time ():
            expanded = await self . transformer . expand ( query )
            QUERY_EXPANSION_COUNT . inc ( len ( expanded ))
            results = await super (). retrieve ( expanded , k , final_k )
        # Track recall on sampled golden queries (1% of traffic)
        if random . random () < 0.01 :
            RECALL_AT_K . labels ( k = 10 ). set ( self . _eval_recall ( query , results ))
        return results

Results: 6 Months of Iteration

Metric Baseline (naive) Optimized Improvement
Recall@10 78% 95% +17 pp
Latency p95 850ms 320ms -62%
Hallucination rate 12% 3% -75%
Cost/query $0.008 $0.005 -38%

The Checklist for Your RAG

  • [ ] Chunk by document structure, not fixed tokens
  • [ ] Hybrid retrieval (BM25 + vector + rerank) - never single modality
  • [ ] Query expansion for ambiguous/short queries
  • [ ] Golden dataset with stratified cases (version it in Git)
  • [ ] Bayesian optimization of hyperparams (re-run monthly)
  • [ ] Instrumentation on every retrieval (latency, recall sampling)
  • [ ] A/B framework for retrieval changes (feature flags)

The Mental Shift

  • Retrieval is infrastructure, not afterthought.
  • Treat chunking strategies as first-class code (versioned, tested, reviewed)
  • Golden dataset = your most valuable IP (curate it religiously)
  • Every retrieval change = eval run (enforced by CI)
  • Regression alerts = paging alerts (not email digests)
  • Your users don't care about your embedding model. They care that the answer is right.
  • Automated evaluation is how you guarantee that at scale.

Code: github.com/yourname/rag-eval-framework | Discussion: Hacker News | Follow: @yourname

Comments

No comments yet. Start the discussion.