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
Comments
No comments yet. Start the discussion.