Content libraryPlaybook

RAG Pipeline Cost Optimization

6-week playbook to cut RAG token cost via chunking, reranking, caching, and retrieval tuning.

MarkdownPlaybook

Playbook: RAG Pipeline Cost Optimization

Duration: 6 weeks | Team: 1 platform engineer, 1 ML engineer
Expected Outcome: 40–70% reduction in RAG pipeline token costs


RAG Cost Anatomy

Every RAG query incurs costs across four layers:

User Query ──► [1. Embedding] ──► [2. Retrieval] ──► [3. Context Packing] ──► [4. Generation]
                 $0.001/query      $0.0001/query       (design choice)         $0.01–0.10/query

Cost Breakdown (Typical Unoptimized RAG)

Layer Component Monthly Volume Unit Cost Monthly Cost % of Total
Embedding Query embedding 500K queries $0.02/1M tokens $50 1%
Embedding Document embedding (one-time amortized) 10M chunks $0.02/1M tokens $100/mo 2%
Retrieval Vector DB queries 500K queries $0.0001/query $50 1%
Generation Input tokens (system + context + query) 500K × 4,200 avg $5/1M tokens $10,500 72%
Generation Output tokens 500K × 500 avg $15/1M tokens $3,750 26%
Total $14,450 100%

Key insight: Generation input tokens (context) drive 72% of total cost. Optimizing retrieval quality (fewer, better chunks) has the highest cost impact.


Phase 1: Baseline Audit (Week 1)

Step 1: Instrument the RAG Pipeline

Tag every query with:

metadata:
  service: rag-pipeline
  query_id: uuid
  retrieval_top_k: 10
  chunks_retrieved: 10
  chunks_used_by_model: 3  # estimate via attribution
  total_input_tokens: 4200
  context_tokens: 3200
  query_tokens: 200
  system_prompt_tokens: 800
  output_tokens: 500

Step 2: Calculate Context Utilization Ratio

Context Utilization = (Chunks Actually Used by Model) / (Chunks Retrieved)

Target: ≥ 60%. Most unoptimized RAG pipelines score 20–30% (retrieving 10 chunks, model uses 2–3).

Step 3: Identify Cost Hotspots

SELECT
  AVG(context_tokens) AS avg_context_tokens,
  AVG(chunks_retrieved) AS avg_chunks,
  AVG(output_tokens) AS avg_output,
  PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY context_tokens) AS p95_context,
  SUM(cost_usd) AS monthly_cost
FROM rag_query_logs
WHERE date >= CURRENT_DATE - 30;

Phase 2: Retrieval Optimization (Weeks 2–3)

Strategy 1: Reduce top-k (10 → 3–5)

Most RAG pipelines default to top_k=10. Testing shows diminishing returns beyond 3–5 chunks.

top_k Avg Context Tokens Answer Quality (F1) Monthly Context Cost
10 3,200 0.89 $10,500
7 2,240 0.88 $7,350
5 1,600 0.87 $5,250
3 960 0.85 $3,150

Recommendation: Start with top_k=5 and validate quality. Drop to 3 if quality holds.

Strategy 2: Relevance Threshold Filtering

Don't include low-relevance chunks even if they're in the top-k:

def filter_by_relevance(chunks, threshold=0.78):
    """Only include chunks above similarity threshold."""
    return [c for c in chunks if c.score >= threshold]

# Before: Always send top-k chunks regardless of relevance
# After: Only send chunks that actually match the query

Impact: Removes 20–40% of irrelevant context.

Strategy 3: Chunk Size Optimization

Chunk Size Pros Cons Best For
128 tokens Precise retrieval May split context FAQ, definitions
256 tokens Good balance Standard choice General knowledge
512 tokens Rich context May include noise Technical docs
1024 tokens Full paragraphs Expensive, noisy Legal, academic

Test different sizes on your specific corpus. Smaller chunks = more precise retrieval but may need more chunks to cover context.

Strategy 4: Hybrid Search (Sparse + Dense)

def hybrid_search(query, alpha=0.7):
    """Combine dense (semantic) + sparse (keyword) search."""
    dense_results = vector_db.search(embed(query), top_k=10)
    sparse_results = bm25_index.search(query, top_k=10)
    
    # Reciprocal rank fusion
    combined = reciprocal_rank_fusion(
        dense_results, sparse_results, alpha=alpha
    )
    return combined[:5]  # Return top 5 after fusion

Why: Hybrid search improves retrieval precision by 10–20%, meaning fewer chunks needed for the same quality.


Phase 3: Context Packing Optimization (Week 3–4)

Strategy 1: Deduplicate Retrieved Chunks

def deduplicate_chunks(chunks, similarity_threshold=0.90):
    """Remove near-duplicate chunks."""
    unique = [chunks[0]]
    for chunk in chunks[1:]:
        is_duplicate = any(
            cosine_similarity(chunk.embedding, u.embedding) > similarity_threshold
            for u in unique
        )
        if not is_duplicate:
            unique.append(chunk)
    return unique

Strategy 2: Compress Context Before Injection

CONTEXT_TEMPLATE = """Relevant information:
{compressed_context}

---
Question: {query}
Answer based only on the above information."""

def compress_context(chunks):
    """Merge and compress retrieved chunks."""
    # Remove headers, footers, metadata from each chunk
    cleaned = [strip_metadata(c.text) for c in chunks]
    # Join with minimal separators
    return "\n---\n".join(cleaned)

Strategy 3: Query-Aware Context Pruning

Use a cheap model to select which retrieved chunks are actually relevant:

async def prune_context(query, chunks, pruning_model="gpt-4.1-nano"):
    """Use cheap model to filter chunks by relevance to query."""
    prompt = f"""Given this question: "{query}"

Rate each chunk's relevance (1-5):
{format_chunks(chunks)}

Return JSON: [{{"chunk_id": 0, "relevance": 5}}, ...]"""
    
    ratings = await call_model(pruning_model, prompt)
    return [c for c, r in zip(chunks, ratings) if r["relevance"] >= 3]

Cost of pruning: ~$0.0002/query with nano model
Savings from pruning: ~$0.005/query from removing 30% of context
Net savings: $0.0048/query (24× return on pruning investment)


Phase 4: Generation Optimization (Weeks 4–5)

Strategy 1: Model Tiering for RAG

Query Complexity Detection Signal Model Cost/Query
Simple lookup Short query, high-confidence top chunk (>0.92) GPT-4.1-nano $0.001
Standard Q&A Medium query, good retrieval (>0.82) GPT-4.1-mini $0.008
Complex reasoning Long query, low retrieval confidence (<0.82) GPT-4.1 $0.050

Strategy 2: Output Constraints

# Before: Open-ended generation
messages = [{"role": "user", "content": f"Answer: {query}\nContext: {context}"}]

# After: Constrained output
messages = [{
    "role": "system", 
    "content": "Answer in 2-3 sentences. Cite source. JSON: {answer, source, confidence}"
}, {
    "role": "user", 
    "content": f"Context: {context}\nQuestion: {query}"
}]
response = client.chat.completions.create(
    messages=messages, max_tokens=200, response_format={"type": "json_object"}
)

Strategy 3: Caching RAG Responses

class RAGCache:
    def get(self, query):
        # Exact match on normalized query
        key = normalize(query)
        cached = redis.get(f"rag:{hash(key)}")
        if cached:
            return cached
        
        # Semantic match
        embedding = embed(query)
        match = vector_cache.search(embedding, threshold=0.95)
        if match:
            return match.response
        
        return None  # Cache miss

Phase 5: Embedding Optimization (Week 5–6)

Cost Reduction Strategies

Strategy Savings Implementation
Use smaller embedding model (large → small) 60–80% Change model parameter
Cache query embeddings 30–50% Redis with hash key
Batch document embeddings 0% direct, faster Process in batches of 100+
Reduce dimensions (1536 → 512) 0% direct, lower storage Use Matryoshka embeddings
Deduplicate before embedding 10–30% Hash-based dedup

Results Template

Before vs. After Comparison

Metric Before After Change
Monthly RAG cost $14,450 $_____ −__%
Avg input tokens/query 4,200 _____ −__%
Chunks retrieved (top_k) 10 _____ −__%
Context utilization ratio 25% _____ +__pp
Cache hit rate 0% _____ +__%
Answer quality (F1) 0.89 _____ ±__%
P95 latency 3.2s _____ ±__%

Monitoring Checklist

  • Context utilization ratio tracked per query
  • Retrieval relevance scores logged
  • Cache hit rate dashboard operational
  • Cost per RAG query tracked by complexity tier
  • Answer quality evaluated weekly (sample of 100 queries)
  • Alert on context utilization dropping below 40%
  • Alert on average input tokens increasing >20% from baseline

Template from the TokenOps Atlas — tokenops-atlas