01 Case Studies 9 min read 1,874 words

Case Study: Enterprise RAG System

This case study walks through designing a production RAG system for enterprise document search. It covers requirements gathering, architecture decisions, and implementation details.

case-studyragretrievalcostapplied
01statement

Problem Statement

1.1

Scenario

A financial services company wants to build an AI-powered search system for their internal documentation:

  • 500,000 documents (policies, procedures, research reports)
  • 5,000 employees across multiple departments
  • Documents updated daily
  • Strict compliance and audit requirements
  • Need to answer questions with cited sources
1.2

Current Pain Points

  • Employees spend 2+ hours/day searching for information
  • Keyword search returns too many irrelevant results
  • Knowledge is siloed across departments
  • New employees take months to become productive
02analysis

Requirements Analysis

2.1

Functional Requirements

RequirementPriorityNotes
Natural language Q&AP0Core feature
Source citationsP0Compliance requirement
Multi-document reasoningP1Connect information across docs
Follow-up questionsP1Conversational context
Document summarizationP2Quick overview of long docs
2.2

Non-Functional Requirements

RequirementTargetRationale
Latency (P95)< 5 secondsUser experience
Accuracy> 90%Trust and adoption
Availability99.9%Business critical
Concurrent users500Peak usage
Document freshness< 1 hourPolicy updates
2.3

Security Requirements

  • Role-based access control (RBAC)
  • Audit logging of all queries
  • No data leaves company network
  • PII detection and handling
03architecture

System Architecture

3.1

High-Level Architecture

3.2

Technology Choices (Dec 2025 Update)

ComponentChoiceRationale
Primary LLMGemini 3.0 Pro2.5M context natively handles 100+ documents without fragmentation
Agentic LLMGPT-5.2Industry-leading tool-use accuracy for complex cross-doc analysis
RetrieverGemini 3 FlashLow-cost retrieval over massive context windows
Embeddingstext-embedding-3-largeProven quality and cost-efficient
Vector DBQdrant (Self-hosted)Performance, filtering, and on-prem compliance
RerankerBGE-Reranker-v2-XOpen-source SoTA for on-prem isolation
04deep dives

Component Deep Dives

4.1

Document Ingestion Pipeline

Pythonpython · 55 lines
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455
class IngestionPipeline:
    def __init__(self):
        self.parser = DocumentParser()
        self.chunker = SemanticChunker(
            chunk_size=512,
            chunk_overlap=50
        )
        self.embedder = OpenAIEmbedder(model="text-embedding-3-large")
        self.vector_db = QdrantClient()
        self.metadata_db = PostgresClient()
    
    async def ingest(self, document: Document, user_context: UserContext):
        # 1. Parse document
        parsed = self.parser.parse(document)
        
        # 2. Extract metadata
        metadata = self.extract_metadata(parsed, document)
        
        # 3. Chunk
        chunks = self.chunker.chunk(parsed.text)
        
        # 4. Generate embeddings (batch)
        embeddings = await self.embedder.embed_batch([c.text for c in chunks])
        
        # 5. Store in vector DB with metadata
        points = [
            {
                "id": f"{document.id}_{i}",
                "vector": embedding,
                "payload": {
                    "document_id": document.id,
                    "chunk_index": i,
                    "text": chunk.text,
                    "department": metadata.department,
                    "access_level": metadata.access_level,
                    "created_at": metadata.created_at.isoformat()
                }
            }
            for i, (chunk, embedding) in enumerate(zip(chunks, embeddings))
        ]
        
        await self.vector_db.upsert(collection="documents", points=points)
        
        # 6. Store full document
        await self.doc_store.put(document.id, parsed.text)
        
        # 7. Store metadata
        await self.metadata_db.insert_document(document.id, metadata)
        
        # 8. Index in Elasticsearch for keyword search
        await self.es_client.index(
            index="documents",
            id=document.id,
            body={"text": parsed.text, **metadata.to_dict()}
        )
4.2

Query Processing

Pythonpython · 70 lines
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970
class QueryService:
    def __init__(self):
        self.retriever = HybridRetriever()
        self.reranker = CohereReranker()
        self.generator = LLMGenerator()
        self.guardrails = GuardrailPipeline()
    
    async def process_query(
        self,
        query: str,
        user_context: UserContext,
        conversation_history: list[Message] = None
    ) -> QueryResponse:
        
        # 1. Input guardrails
        guardrail_result = self.guardrails.check_input(query)
        if not guardrail_result.passed:
            return QueryResponse(
                answer="I cannot help with that request.",
                blocked=True,
                reason=guardrail_result.reason
            )
        
        # 2. Query understanding (optional: rewrite query)
        processed_query = await self.understand_query(query, conversation_history)
        
        # 3. Retrieve candidates with permission filtering
        candidates = await self.retriever.search(
            query=processed_query,
            filters=self.build_permission_filter(user_context),
            top_k=50
        )
        
        # 4. Rerank
        reranked = await self.reranker.rerank(
            query=processed_query,
            documents=candidates,
            top_k=10
        )
        
        # 5. Build context
        context = self.build_context(reranked)
        
        # 6. Generate answer
        answer = await self.generator.generate(
            query=query,
            context=context,
            conversation_history=conversation_history
        )
        
        # 7. Output guardrails
        guardrail_result = self.guardrails.check_output(answer, context)
        if not guardrail_result.passed:
            answer = self.fallback_response()
        
        # 8. Build response with citations
        return QueryResponse(
            answer=answer,
            sources=[self.format_source(doc) for doc in reranked[:5]],
            confidence=self.calculate_confidence(reranked)
        )
    
    def build_permission_filter(self, user_context: UserContext) -> dict:
        return {
            "should": [
                {"key": "access_level", "match": {"value": "public"}},
                {"key": "department", "match": {"value": user_context.department}},
                {"key": "access_list", "match": {"any": [user_context.user_id]}}
            ]
        }
4.3

Hybrid Retrieval

Pythonpython · 63 lines
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263
class HybridRetriever:
    def __init__(self, vector_weight: float = 0.7, keyword_weight: float = 0.3):
        self.vector_db = QdrantClient()
        self.es_client = ElasticsearchClient()
        self.embedder = OpenAIEmbedder()
        self.vector_weight = vector_weight
        self.keyword_weight = keyword_weight
    
    async def search(
        self,
        query: str,
        filters: dict,
        top_k: int = 50
    ) -> list[Document]:
        
        # Parallel retrieval
        vector_results, keyword_results = await asyncio.gather(
            self.vector_search(query, filters, top_k * 2),
            self.keyword_search(query, filters, top_k * 2)
        )
        
        # Reciprocal Rank Fusion
        fused = self.rrf_fusion(
            [vector_results, keyword_results],
            weights=[self.vector_weight, self.keyword_weight],
            k=60
        )
        
        return fused[:top_k]
    
    async def vector_search(self, query: str, filters: dict, top_k: int):
        query_embedding = await self.embedder.embed(query)
        
        results = await self.vector_db.search(
            collection="documents",
            query_vector=query_embedding,
            query_filter=filters,
            limit=top_k
        )
        
        return [
            Document(
                id=r.payload["document_id"],
                chunk_id=r.id,
                text=r.payload["text"],
                score=r.score,
                metadata=r.payload
            )
            for r in results
        ]
    
    def rrf_fusion(self, result_lists: list, weights: list, k: int = 60) -> list:
        scores = defaultdict(float)
        docs = {}
        
        for results, weight in zip(result_lists, weights):
            for rank, doc in enumerate(results):
                rrf_score = weight / (k + rank + 1)
                scores[doc.chunk_id] += rrf_score
                docs[doc.chunk_id] = doc
        
        sorted_ids = sorted(scores.keys(), key=lambda x: scores[x], reverse=True)
        return [docs[id] for id in sorted_ids]
4.4

Generation with Massive Context (Dec 2025)

Pythonpython · 25 lines
12345678910111213141516171819202122232425
class GeminiGenerator:
    def __init__(self):
        self.client = genai.GenerativeModel("gemini-3.0-pro")
    
    async def generate(
        self,
        query: str,
        context_docs: list[Document],
        conversation_history: list[Message] = None
    ) -> str:
        # 2.5M context allows passing ENTIRE documents, not just snippets
        system_instruction = """
        You are an enterprise knowledge assistant. 
        Analyze the provided documents to answer the query accurately.
        Cite every claim using [[DocName:PageNumber]] format.
        """
        
        contents = [{"text": doc.text} for doc in context_docs]
        contents.append({"text": f"User Query: {query}"})
        
        response = await self.client.generate_content_async(
            contents,
            generation_config=genai.types.GenerationConfig(temperature=0.0)
        )
        return response.text
05considerations

Scaling Considerations

5.1

Handling 500K Documents

Pythonpython · 13 lines
12345678910111213
# Sharding strategy for Qdrant
qdrant_config = {
    "collection": "documents",
    "vectors": {
        "size": 3072,  # text-embedding-3-large
        "distance": "Cosine"
    },
    "optimizers": {
        "indexing_threshold": 20000  # Build index after 20K points
    },
    "replication_factor": 2,  # High availability
    "shard_number": 4  # Distribute across nodes
}
5.2

Handling 500 Concurrent Users

5.3

Caching Strategy

Pythonpython · 25 lines
12345678910111213141516171819202122232425
class QueryCache:
    def __init__(self):
        self.exact_cache = Redis(ttl=3600)  # 1 hour
        self.semantic_cache = SemanticCache(threshold=0.95, ttl=1800)
    
    async def get_or_compute(self, query: str, user_context: UserContext) -> QueryResponse:
        # Check exact cache
        cache_key = self.make_key(query, user_context.permissions)
        cached = await self.exact_cache.get(cache_key)
        if cached:
            return cached
        
        # Check semantic cache
        similar = await self.semantic_cache.find_similar(query, user_context.permissions)
        if similar:
            return similar
        
        # Compute
        response = await self.query_service.process_query(query, user_context)
        
        # Cache result
        await self.exact_cache.set(cache_key, response)
        await self.semantic_cache.add(query, user_context.permissions, response)
        
        return response
06analysis

Cost Analysis

6.1

Monthly Cost Estimate (500 Users, 100 Queries/User/Day)

ComponentCalculationMonthly Cost
LLM (Claude Sonnet)1.5M queries × 2K tokens × $3/1M in + 500 tokens × $15/1M out~$20,250
Embeddings1.5M queries × $0.13/1M~$200
Reranking (Cohere)1.5M × 50 docs × $0.001/1K~$75
Vector DB (Qdrant Cloud)3-node cluster~$1,500
Elasticsearch3-node cluster~$2,000
Compute (Query Service)4 instances~$1,000
Total~$25,000/month
6.2

Cost Optimization Opportunities

  1. Caching: 30% cache hit rate → $6K savings on LLM
  2. Model routing: Route simple queries to cheaper model → 40% savings
  3. Batch embeddings: Use async batching → 20% savings
  4. Self-hosted reranker: Replace Cohere with open source → Eliminate $75
07learned

Lessons Learned

7.1

What Worked Well

  1. Hybrid search: Combined semantic + keyword significantly improved recall
  2. Reranking: 15% improvement in top-5 precision
  3. Clear citations: Built trust with users
  4. Permission filtering at retrieval: No post-hoc filtering needed
7.2

Challenges Encountered

  1. Table extraction: PDFs with complex tables required custom parsing
  2. Acronyms: Domain-specific acronyms needed expansion
  3. Freshness: 1-hour freshness required streaming ingestion
  4. Long documents: 100+ page documents needed hierarchical chunking
7.3

What We Would Do Differently

  1. Start with better document parsing earlier
  2. Build evaluation pipeline before scaling
  3. Implement query logging from day one
  4. Create feedback loop with users sooner
08walkthrough

Interview Walkthrough

8.1

How to Present This in an Interview

Opening (2 min): "I will design an enterprise RAG system for internal document search. Let me clarify a few requirements first..."

Requirements (3 min):

  • Ask about scale, latency, accuracy targets
  • Clarify security requirements
  • Understand document types and update frequency

High-Level Design (5 min):

  • Draw the architecture diagram
  • Explain key components
  • Justify technology choices

Deep Dive (10 min):

  • Retrieval strategy (hybrid search, why)
  • Security (permission filtering at query time)
  • Generation (prompt engineering, citations)
  • Scaling (sharding, caching, replicas)

Tradeoffs (5 min):

  • Cost vs latency (model selection)
  • Accuracy vs latency (reranking adds time)
  • Freshness vs cost (streaming vs batch)

Monitoring (2 min):

  • Key metrics (latency, accuracy, user feedback)
  • How to detect issues
  • Continuous improvement loop

Next: Case Study: Conversational AI Agent

summary · added by this rebuild

Key takeaways

01

Huge contexts change the chunking question

With a 2.5M-token generator the design retrieves whole 10K to 50K token document segments rather than hunting a perfect 512-token chunk, letting native attention find the needle.

02

Compliance picks the stack, not benchmarks

Self-hosted Qdrant and the open BGE-Reranker-v2-X are chosen so no data leaves the network, with RBAC, audit logging and mandatory citations as P0 requirements.

03

Two caches catch two kinds of repeat

An exact Redis cache with a one-hour TTL fronts a semantic cache at 0.95 similarity and 30-minute TTL, both keyed by permissions so a hit cannot cross roles.

04

Caching and routing are the levers

Against a roughly $25K monthly estimate, a 30% cache hit rate is credited with $6K and routing simple queries to a cheaper model with 40%, dwarfing the $75 reranking line.