<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Rag system design blog]]></title><description><![CDATA[How I designed and shipped an AI document intelligence platform with async ingestion, hybrid retrieval, cross-encoder reranking, and real-time WebSocket orchest]]></description><link>https://ragsystemabhishekrajput.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>Rag system design blog</title><link>https://ragsystemabhishekrajput.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Fri, 16 Oct 2026 08:21:08 GMT</lastBuildDate><atom:link href="https://ragsystemabhishekrajput.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Building a Production-Grade RAG System: Architecture, Trade-offs, and Lessons Learned]]></title><description><![CDATA[Why Most RAG Demos Fail in Production
You've seen the 50-line RAG demo. Load a PDF, embed it, ask a question, get an answer. It works in a notebook. It falls apart the moment you add real users, large]]></description><link>https://ragsystemabhishekrajput.hashnode.dev/building-a-production-grade-rag-system-architecture-trade-offs-and-lessons-learned</link><guid isPermaLink="true">https://ragsystemabhishekrajput.hashnode.dev/building-a-production-grade-rag-system-architecture-trade-offs-and-lessons-learned</guid><category><![CDATA[RAG ]]></category><category><![CDATA[System Design]]></category><category><![CDATA[llm]]></category><category><![CDATA[Django]]></category><category><![CDATA[celery]]></category><category><![CDATA[vector database]]></category><category><![CDATA[Python]]></category><category><![CDATA[backend]]></category><dc:creator><![CDATA[Abhishek Rajput]]></dc:creator><pubDate>Tue, 09 Jun 2026 16:06:01 GMT</pubDate><content:encoded><![CDATA[<hr />
<h2>Why Most RAG Demos Fail in Production</h2>
<p>You've seen the 50-line RAG demo. Load a PDF, embed it, ask a question, get an answer. It works in a notebook. It falls apart the moment you add real users, large documents, concurrent requests, and an expectation that the system stays up.</p>
<p>The gap between a RAG prototype and a production RAG system is where the real engineering lives.</p>
<p>This post documents the full architecture of a production-grade RAG Knowledge Assistant — a platform where users upload PDFs and books, interact via conversational AI with source citations, and get AI-generated flashcards and quizzes. The decisions here are real ones I made while building it, not theoretical recommendations.</p>
<p>Let's go deep.</p>
<hr />
<h3>The 30,000-Foot View</h3>
<p>Before diving into components, here's the system at a glance:</p>
<img src="https://cdn.hashnode.com/uploads/covers/65b9d3b3f8fcdda4005dc626/7c7fd7d3-3739-4d0f-a9ec-51ba0aadd257.png" alt="" style="display:block;margin:0 auto" />

<p>Every architectural decision flows from one constraint: <strong>the user experience must feel fast, even when the underlying pipeline is not</strong>.</p>
<hr />
<h2>Part 1: The Ingestion Pipeline</h2>
<h3>Why Async Ingestion is Non-Negotiable</h3>
<p>The naïve approach is synchronous: receive file → process → respond. For a 200-page PDF, that means holding an HTTP connection open while you OCR, chunk, and embed the entire document. That's a 30–120 second request. That's unacceptable.</p>
<p>The solution is a task queue pattern using Celery + Redis:</p>
<pre><code class="language-python">
`@shared_task(
    bind=True,
    autoretry_for=(Exception,),
    retry_backoff=True,
    retry_kwargs={"max_retries": 3}
)
def ingest_document(self, document_id: int):
    with transaction.atomic():
        doc = Document.objects.select_for_update().get(id=document_id)
        doc.status = "PROCESSING"
        doc.save()

    try:
        text = extract_text(doc.file)
        chunks = semantic_chunk(text, chunk_size=512)
        embeddings = embed_batch(chunks)

        qdrant_client.upsert(
            collection_name="documents",
            points=[...]
        )

        doc.status = "INDEXED"
        doc.save()

        notify_document_ready(document_id, doc.user_id)

    except Exception as e:
        doc.status = "FAILED"
        doc.error_message = str(e)
        doc.save()
        raise `
</code></pre>
<p>The API layer queues the task and immediately returns a <code>202 Accepted</code> with a document ID. The user sees a loading state. When ingestion completes, a WebSocket event pushes the status update to the frontend. Zero polling.</p>
<p>The select_for_update() is important — it prevents concurrent tasks from double-processing the same document if a worker crashes and the task is retried.</p>
<h3>Semantic Chunking vs. Fixed-Size Chunking</h3>
<p>Most tutorials use fixed-size chunking (every N tokens, with overlap). It's fast but ignores document semantics. A 512-token chunk might split a paragraph across two chunks, separating a claim from its evidence. Semantic chunking respects sentence and paragraph boundaries. The implementation uses recursive splitting with <code>split_on_sentences=True</code>, targeting 256–512 token windows with 128-token overlap:</p>
<pre><code class="language-plaintext">chunk_size=512, overlap=128, split_on_sentences=True
</code></pre>
<p>The overlap ensures that context at chunk boundaries isn't lost during retrieval.</p>
<p><strong>The payoff</strong>: fewer hallucinations, because retrieved chunks contain coherent reasoning units rather than mid-sentence fragments.</p>
<h3>Embedding Infrastructure: Why TEI Over API Calls</h3>
<p>For embedding generation, the system uses <strong>HuggingFace Text Embeddings Inference (TEI)</strong> running locally, not an external API. The model is <code>sentence-transformers/bge-large-en-v1.5</code>.</p>
<p>Why not just call OpenAI's embedding API?</p>
<ol>
<li><p><strong>Latency</strong>: local inference adds ~5ms vs. ~80–150ms for an API round-trip per batch</p>
</li>
<li><p><strong>Cost</strong>: embedding 100k chunks per day at OpenAI rates adds up; local inference is flat-cost</p>
</li>
<li><p><strong>Consistency</strong>: the same model version always, no silent upstream changes</p>
</li>
<li><p><strong>Data privacy</strong>: document content never leaves your infrastructure</p>
</li>
</ol>
<p>The TEI container is registered in docker-compose alongside Qdrant, Postgres, and Redis:</p>
<p>Redis:</p>
<pre><code class="language-yaml">tei:
  image: ghcr.io/huggingface/text-embeddings-inference:0.4.0
  ports:
    - "8080:80"
  environment:
    MODEL_ID: sentence-transformers/bge-large-en-v1.5
</code></pre>
<hr />
<h2>Part 2: The Retrieval Pipeline</h2>
<p>This is where most RAG systems get it wrong. A single <code>similarity_search(query, k=5)</code> call is not a retrieval pipeline. It's a starting point.</p>
<h3>Stage 1: Query Contextualization</h3>
<p>Conversational RAG has a silent failure mode: the user asks "What did he say about that?" — and the retrieval system has no idea what "he" or "that" refers to.</p>
<p>The fix is query contextualization: rewrite the incoming query using the last N messages of chat history before retrieval.</p>
<pre><code class="language-python">def contextualize_query(query, chat_history):
    history_context = "\n".join([
        f"{msg.sender}: {msg.text}"
        for msg in chat_history[-5:]
    ])

    rewritten = llm.generate(
        f"Rewrite this query as a standalone question                     given context:\n"
        f"{history_context}\n"
        f"Query: {query}"
    )
    return rewritten
</code></pre>
<p>This adds ~200ms latency (an LLM call), but it's the difference between a retrieval system that works in multi-turn conversations and one that doesn't.</p>
<h3>Stage 2: Intent Routing</h3>
<p>Not every message needs the full RAG pipeline. "Hello!" doesn't need vector search. "Summarize this document" doesn't need retrieval at all.</p>
<p>An <strong>Intent Router</strong> — a lightweight LLM classifier — categorizes each query before dispatching it:</p>
<table>
<thead>
<tr>
<th>Intent</th>
<th>Handler</th>
<th>Latency</th>
</tr>
</thead>
<tbody><tr>
<td>Greeting / chitchat</td>
<td>Quick response template</td>
<td>~50ms</td>
</tr>
<tr>
<td>Summary request</td>
<td>Metadata summary from PostgreSQL</td>
<td>~200ms</td>
</tr>
<tr>
<td>Ambiguous query</td>
<td>Clarification prompt</td>
<td>~100ms</td>
</tr>
<tr>
<td>Document question</td>
<td>Full RAG pipeline</td>
<td>~1800ms</td>
</tr>
</tbody></table>
<p>This optimization alone saves 60–70% of LLM generation calls for a typical user session.</p>
<h3>Stage 3: Query Expansion</h3>
<p>Even after contextualization, a single query embedding is a single point in vector space. If the user phrases their question differently from how the document phrases the answer, cosine similarity will miss it.</p>
<p>Query expansion generates 3–4 semantic variations of the contextualized query, runs parallel vector searches for each, and merges the result sets:</p>
<pre><code class="language-python">def expand_query(query):
    variations = llm.generate_variations(query, count=3)
    return [query] + variations

def retrieve_chunks(queries):
    all_results = []
    for q in queries:
        q_embedding = tei_client.embed(q)
        results = qdrant_client.search(
            collection_name="documents",
            query_vector=q_embedding,
            limit=8,
            with_payload=True
        )
        all_results.extend(results)

    # Deduplicate
    unique_chunks = {r.payload["id"]: r for r in all_results}
    return list(unique_chunks.values())
</code></pre>
<p>Parallel execution using <code>asyncio</code>keeps the latency impact to ~180ms, not 4×180ms.</p>
<h3>Stage 4: Cross-Encoder Reranking</h3>
<p>Vector similarity gives you recall. Cross-encoder reranking gives you precision.</p>
<p>Bi-encoder models (used in vector search) embed query and document independently and compare them with cosine similarity. It's fast, but misses subtle semantic relationships. <strong>Cross-encoders jointly encode the query-document pair</strong>, which is slower but dramatically more accurate.</p>
<p>The architecture separates these two stages deliberately:</p>
<ul>
<li><p><strong>Vector search (bi-encoder)</strong>: cast a wide net — retrieve top-8 candidates per query variation (~30–40 chunks total after dedup)</p>
</li>
<li><p><strong>Cross-encoder reranking</strong>: score each candidate jointly with the query, keep only chunks scoring ≥ 4/10</p>
</li>
</ul>
<pre><code class="language-python">def rerank_chunks(query, chunks):
    scores = cross_encoder.predict([
        [query, chunk.payload["text"]]
        for chunk in chunks
    ])

    ranked = sorted(
        zip(chunks, scores),
        key=lambda x: x[1],
        reverse=True
    )

    return [chunk for chunk, score in ranked if score &gt;= 4]
</code></pre>
<p>The score threshold is intentional: it prevents low-relevance context from being injected into the LLM prompt, which is a leading cause of hallucinations in RAG systems.</p>
<img src="https://cdn.hashnode.com/uploads/covers/65b9d3b3f8fcdda4005dc626/ce2b262c-832a-4a89-a829-ccb04497f248.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h2>Part 3: Generation and Response Delivery</h2>
<h3>Context Assembly and Citation</h3>
<p>Once high-relevance chunks pass the reranker, they're assembled into a structured prompt with page-level citations:</p>
<p>python</p>
<pre><code class="language-python">context = "\n---\n".join([
    f"[{c.payload['page']}] {c.payload['text']}"
    for c in context_chunks
])

prompt = f"""Based on this context:
{context}

Answer the question: {query}

Include source citations like [page X]."""
</code></pre>
<p>Citations are first-class, not an afterthought. Every generated response is grounded in specific pages of the source document, which the frontend renders as clickable references.</p>
<h3>Streaming via WebSockets</h3>
<p>The LLM generation (Llama 3 via Groq) runs in streaming mode. Tokens are pushed to the frontend via Django Channels WebSocket as they arrive — first token to screen in ~300ms, regardless of response length.</p>
<pre><code class="language-python">class DocumentConsumer(AsyncWebsocketConsumer):
    async def connect(self):
        self.user_id = self.scope["user"].id
        self.group_name = f"user_{self.user_id}"
        await self.channel_layer.group_add(
            self.group_name,
            self.channel_name
        )
        await self.accept()

    async def document_status_update(self, event):
        await self.send(text_data=json.dumps({
            "type": "document_indexed",
            "document_id": event["document_id"],
            "status": event["status"],
            "chunks": event["chunks"]
        }))
</code></pre>
<p>WebSockets serve double duty: ingestion status updates and streaming LLM responses. One persistent connection per user session, multiplexed via Django Channels group messaging.</p>
<img src="https://cdn.hashnode.com/uploads/covers/65b9d3b3f8fcdda4005dc626/4717940c-6cf4-4a5c-8398-bae6146f97d1.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h2>Part 4: From Retrieval to Active Learning</h2>
<p>Retrieval solves one problem — finding information. It doesn't solve a second, equally important one: helping users actually retain it.</p>
<p>While testing the platform, a pattern kept appearing. Users would ask questions, get accurate answers, and leave. The system was working, but it wasn't doing anything to convert that retrieved knowledge into something durable.</p>
<p>So I built a content generation layer directly on top of the RAG pipeline. Instead of treating indexed documents as static knowledge sources to query against, the system can transform them into structured learning material on demand.</p>
<h4>Question Generation</h4>
<p>The workflow is deliberately simple — simplicity here is a feature, not a shortcut:</p>
<pre><code class="language-plaintext">Document
    ↓
Chunk Retrieval (reranked)
    ↓
Context Assembly
    ↓
LLM Question Generation
    ↓
Structured Question Set
</code></pre>
<p>The system retrieves and reranks chunks from the indexed document, assembles a focused context window, and prompts the LLM to generate practice questions grounded in that specific content — not the model's general knowledge. Users can test their understanding of their own material without manually authoring a single question.</p>
<img src="https://cdn.hashnode.com/uploads/covers/65b9d3b3f8fcdda4005dc626/eada1776-7241-4423-9f56-7e10dbd4cb0e.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h4>Flashcard Generation</h4>
<p>For active recall, a separate pipeline converts document knowledge into Q&amp;A pairs:</p>
<pre><code class="language-plaintext">Document
    ↓
Retrieve Key Concepts
    ↓
Extract Definitions and Relationships
    ↓
Generate Q/A Pairs
    ↓
Interactive Flashcard Set
</code></pre>
<p>The distinction from a generic "summarize this" prompt matters: the system specifically targets concepts, definitions, and relationships within the retrieved chunks — the building blocks of durable recall — rather than just condensing prose.</p>
<img src="https://cdn.hashnode.com/uploads/covers/65b9d3b3f8fcdda4005dc626/30947553-0594-4ca0-a86c-266c9d13ded4.png" alt="" style="display:block;margin:0 auto" />

<h4>Engineering Considerations</h4>
<p>Three problems surfaced during implementation worth naming explicitly.</p>
<p><strong>Context selection quality directly determines output quality.</strong> A bad retrieval result produces a bad flashcard. The fix was to run generation from reranked chunks, not raw vector search results. The cross-encoder's relevance scoring acts as a quality gate before any generative call is made.</p>
<p><strong>Hallucination control is structural, not prompt-based.</strong> Questions and flashcards are generated only from retrieved document context, with the prompt explicitly constrained to that window. The model cannot pull from its parametric knowledge. This keeps generated study material aligned with the user's source document, not with what the model happens to know about the topic.</p>
<p><strong>Generation results are persisted and served asynchronously.</strong> Running a full rerank + generation pass on every visit would be expensive and slow. Instead, generated question sets and flashcard decks are stored in PostgreSQL after first generation and retrieved on subsequent views — the expensive LLM call happens once, not repeatedly.</p>
<h4>Why Build This at All?</h4>
<p>Most document-chat systems stop at question answering. The goal here was to support the full learning loop:</p>
<pre><code class="language-plaintext">Upload → Understand → Revise → Test
</code></pre>
<p>Retrieval handles "Understand." Flashcards handle "Revise." Generated questions handle "Test." Without all three, the platform is just a smarter Ctrl+F.</p>
<hr />
<h2>Part 5: Resilience Patterns</h2>
<h3>Circuit Breaker for External AI Services</h3>
<p>Embedding and LLM inference services can fail transiently. Without a circuit breaker, a degraded TEI or Groq endpoint causes cascading failures — every incoming request queues up waiting for a timeout.</p>
<p>A simple circuit breaker implementation:</p>
<pre><code class="language-python">class CircuitBreaker:
    def __init__(self, failure_threshold=3, recovery_timeout=60):
        self.failure_count = 0
        self.failure_threshold = failure_threshold
        self.recovery_timeout = recovery_timeout
        self.state = "CLOSED"

    def call(self, func, *args, **kwargs):
        if self.state == "OPEN":
            if time.time() - self.last_failure_time &gt; self.recovery_timeout:
                self.state = "HALF_OPEN"
            else:
                raise CircuitBreakerOpen("Service temporarily unavailable")

        try:
            result = func(*args, **kwargs)
            if self.state == "HALF_OPEN":
                self.state = "CLOSED"
                self.failure_count = 0
            return result
        except Exception:
            self.failure_count += 1
            if self.failure_count &gt;= self.failure_threshold:
                self.state = "OPEN"
                self.last_failure_time = time.time()
            raise
</code></pre>
<p>Three states:</p>
<ul>
<li><p><strong>CLOSED</strong>: normal operation, failures counted</p>
</li>
<li><p><strong>OPEN</strong>: service assumed down, calls rejected immediately with a meaningful error</p>
</li>
<li><p><strong>HALF_OPEN</strong>: single probe allowed to check recovery</p>
</li>
</ul>
<h4>Celery Retry Strategy</h4>
<pre><code class="language-python">`@shared_task(
    autoretry_for=(TimeoutError, ConnectionError),
    retry_backoff=True,
    retry_backoff_max=600,
    retry_jitter=True,
    max_retries=5
)
def process_chunk(chunk_id, embedding):
    pass
</code></pre>
<p><code>retry_jitter=True</code> is the key detail here. Without jitter, multiple failed tasks all retry at the same backoff interval, creating thundering herd behavior against a recovering service. Jitter spreads retries across a time window.</p>
<h3>Concurrency Control</h3>
<p>Without rate limiting, a burst of concurrent LLM requests will either exhaust the Groq rate limit or DOS your local inference server.</p>
<pre><code class="language-python">MAX_CONCURRENT_LLM_CALLS = 20
llm_semaphore = asyncio.Semaphore(MAX_CONCURRENT_LLM_CALLS)

async def generate_with_rate_limit(query, context):
    async with llm_semaphore:
        return await groq_client.generate_async(query, context)
</code></pre>
<hr />
<h3>Performance Profile</h3>
<p>End-to-end query latency on a warm system:</p>
<table>
<thead>
<tr>
<th>Stage</th>
<th>Avg Latency</th>
<th>% of Total</th>
</tr>
</thead>
<tbody><tr>
<td>Query contextualization</td>
<td>~200ms</td>
<td>11%</td>
</tr>
<tr>
<td>Query expansion</td>
<td>~180ms</td>
<td>10%</td>
</tr>
<tr>
<td>Vector search (parallel)</td>
<td>~45ms</td>
<td>2%</td>
</tr>
<tr>
<td>Deduplication</td>
<td>~25ms</td>
<td>1%</td>
</tr>
<tr>
<td>Cross-encoder reranking</td>
<td>~95ms</td>
<td>5%</td>
</tr>
<tr>
<td>LLM generation</td>
<td>~1200ms</td>
<td>67%</td>
</tr>
<tr>
<td><strong>Total</strong></td>
<td><strong>~1800ms</strong></td>
<td><strong>100%</strong></td>
</tr>
</tbody></table>
<p>LLM generation dominates. This is why streaming matters — the user sees words appearing at ~300ms, not a 1.8s blank screen.</p>
<p>The 45ms vector search (across parallel queries) is a direct result of Qdrant's HNSW index. A naive PostgreSQL full-scan equivalent would be ~800ms at comparable dataset sizes.</p>
<hr />
<h3>What I'd Do Differently</h3>
<p><strong>1. Semantic caching</strong></p>
<p>For queries semantically similar to previous ones, return the cached answer without running the full pipeline. A Redis layer with pgvector-based similarity lookup on query embeddings would cut latency by 60–80% for repeat question patterns.</p>
<p><strong>2. Chunking strategy needs per-document calibration</strong></p>
<p>A fixed 512-token semantic chunk works well for prose. For academic papers with structured sections, a section-aware chunker would improve retrieval quality. For code-heavy documents, a syntax-aware chunker matters. The current implementation doesn't distinguish document types.</p>
<p><strong>3. Qdrant collection design</strong></p>
<p>Currently a single <code>documents</code> collection with <code>document_id</code> as a payload filter for multi-tenant isolation. At scale, per-user collections would provide better isolation and allow collection-level configuration tuning (HNSW <code>m</code> and <code>ef_construct</code> parameters) per workload pattern.</p>
<hr />
<h3>Stack Summary</h3>
<table>
<thead>
<tr>
<th>Layer</th>
<th>Technology</th>
<th>Why</th>
</tr>
</thead>
<tbody><tr>
<td>API</td>
<td>Django + DRF</td>
<td>Battle-tested ORM, auth, admin — not worth reinventing</td>
</tr>
<tr>
<td>Async tasks</td>
<td>Celery + Redis</td>
<td>Mature task queue with rich retry semantics</td>
</tr>
<tr>
<td>Database</td>
<td>PostgreSQL</td>
<td>Chat history, document metadata, user data</td>
</tr>
<tr>
<td>Vector store</td>
<td>Qdrant</td>
<td>Purpose-built, fast HNSW, good filtering</td>
</tr>
<tr>
<td>Embeddings</td>
<td>TEI (bge-large-en-v1.5)</td>
<td>Local inference, consistent, no API cost</td>
</tr>
<tr>
<td>Reranking</td>
<td>Cross-Encoder (sentence-transformers)</td>
<td>Precision over the bi-encoder's recall</td>
</tr>
<tr>
<td>LLM</td>
<td>Llama 3 via Groq</td>
<td>Low-latency inference, good quality at 8B scale</td>
</tr>
<tr>
<td>Real-time</td>
<td>Django Channels</td>
<td>WebSocket layer on top of ASGI Django</td>
</tr>
<tr>
<td>Frontend</td>
<td>Next.js 14 + shadcn/ui</td>
<td>SSR, TypeScript, composable UI</td>
</tr>
</tbody></table>
<hr />
<h3>Closing Thoughts</h3>
<p>The delta between a RAG proof-of-concept and a production RAG system isn't model quality — it's infrastructure. It's what happens when the embedding service goes down, when a user uploads a 500-page book, when 50 users query simultaneously.</p>
<p>The patterns in this architecture aren't novel. Async task queues, circuit breakers, retry with jitter, semantic caching — these are standard distributed systems patterns. What's interesting is how they compose specifically for the RAG problem: a workload with unusually high variance in task duration, external AI service dependencies, and real-time user-facing latency requirements.</p>
<p>Build for failure. Instrument before you need it. Stream everything you can.</p>
<hr />
<p><em>If you want to explore the codebase, it's on GitHub. Questions, critiques, and architecture debates welcome in the comments.</em></p>
<hr />
<p><strong>Tags</strong>: <code>rag</code>, <code>system-design</code>, <code>llm</code>, <code>django</code>, <code>celery</code>, <code>vector-databases</code>, <code>ai-engineering</code>, <code>python</code>, <code>backend</code></p>
]]></content:encoded></item></channel></rss>