Optimizing RAG at Scale: Chunking, Retrieval, and Bayesian Search
- Authors

- Name
- Nino
- Occupation
- Senior Tech Editor
Retrieval-Augmented Generation (RAG) is the industry standard for grounding LLMs in proprietary data. However, the 'Hello World' version of RAG—splitting text into 512-token chunks and performing a simple vector search—almost always fails in production. When dealing with complex legal contracts, dense API documentation, or multi-turn customer support tickets, a naive approach leads to fragmented context and high hallucination rates.
In this guide, we explore how to move from 'semantic search + hope' to a measured, tunable retrieval pipeline that achieves 95% recall@10 while reducing latency by 40%. By leveraging high-performance LLM APIs from n1n.ai, you can implement these advanced strategies without managing complex infrastructure.
The Problem with Fixed-Size Chunking
Most developers start with a fixed window size. While easy to implement, it creates significant issues:
- Legal Contracts: A 512-token split might cut a crucial liability clause mid-sentence, losing the legal context.
- API Documentation: Small chunks drown out the 'signal' of the function signature with the 'noise' of surrounding boilerplate.
- Conversational Data: Customer tickets require overlapping context to maintain the flow of the dialogue.
To solve this, we must treat chunking as a first-class engineering problem. Below is a framework for implementing tiered chunking strategies.
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 RecursiveChunker(ChunkingStrategy):
"""Respects document structure: headers, code blocks, and 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 in text."""
def __init__(self, model="text-embedding-3-small", threshold=0.7):
self.model = model
self.threshold = threshold
Hybrid Retrieval and RRF
Pure vector search often misses exact keyword matches (like error codes or specific function names). Conversely, pure BM25 (keyword search) misses semantic nuance. The solution is Hybrid Retrieval combined with Reciprocal Rank Fusion (RRF).
RRF allows you to combine results from multiple search engines without needing to normalize their scores. This is critical because vector distances and BM25 scores are on completely different scales.
class HybridRetriever:
def __init__(self, vector_store, bm25_index, reranker):
self.vector = vector_store
self.bm25 = bm25_index
self.reranker = 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=60)
# Stage 3: Cross-encoder rerank
# We use a high-performance model via n1n.ai for reranking
reranked = await self.reranker.rerank(query, fused[:50])
return reranked[:final_k]
def _rrf(self, *result_lists, k=60):
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])
Query Transformation: Expanding the Search Space
Users are often bad at asking questions. They use ambiguous terms or provide too little context. Query expansion uses an LLM to generate multiple variations of a user's query to increase the chance of a 'hit' in the vector space.
Using models like Claude 3.5 Sonnet or GPT-4o via n1n.ai ensures that these transformations are both fast and intelligent. Research shows that expanding a single query into three targeted variations can increase Recall@10 from 78% to 94%.
Bayesian Optimization of Retrieval Hyperparameters
Should your chunk size be 512 or 768? Should your vector weight be 0.7 or 0.4? Instead of guessing, we use Bayesian Optimization via the Optuna library to find the optimal configuration on a 'Golden Dataset'.
import optuna
def objective(trial: optuna.Trial) -> tuple[float, float]:
config = {
"chunk_size": trial.suggest_categorical("chunk_size", [256, 512, 1024]),
"vector_weight": trial.suggest_float("vector_weight", 0.1, 0.9),
"top_k": trial.suggest_int("top_k", 5, 50)
}
recall, latency = evaluate_config(config, golden_set)
return recall, latency / 1000 # Return recall and latency in seconds
study = optuna.create_study(directions=["maximize", "minimize"])
study.optimize(objective, n_trials=100)
This approach yields a Pareto Frontier, allowing you to choose between a 'Conservative' config (low latency) and an 'Aggressive' config (maximum recall).
Conclusion
Building production-grade RAG requires moving away from static configurations. By implementing structured chunking, hybrid retrieval, and automated hyperparameter optimization, you can build systems that are both reliable and fast. For developers looking to scale these systems, n1n.ai provides the unified API access needed to switch between embedding and LLM providers seamlessly.
Get a free API key at n1n.ai