<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Md Ayan Arshad</title>
    <description>The latest articles on DEV Community by Md Ayan Arshad (@ayanarshad02).</description>
    <link>https://dev.to/ayanarshad02</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3817591%2F0fa1e4d1-8be4-4467-b7f3-2fa3543e804b.png</url>
      <title>DEV Community: Md Ayan Arshad</title>
      <link>https://dev.to/ayanarshad02</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ayanarshad02"/>
    <language>en</language>
    <item>
      <title>wrote article about why increasing retrieval from top 5 to top 20 worsens my answer quality: https://dev.to/ayanarshad02/i-increased-retrieval-from-top-5-to-top-20-my-answers-got-worse-3mke</title>
      <dc:creator>Md Ayan Arshad</dc:creator>
      <pubDate>Fri, 08 May 2026 03:21:48 +0000</pubDate>
      <link>https://dev.to/ayanarshad02/wrote-article-about-why-increasing-retrieval-from-top-5-to-top-20-worsens-my-answer-quality-4ffn</link>
      <guid>https://dev.to/ayanarshad02/wrote-article-about-why-increasing-retrieval-from-top-5-to-top-20-worsens-my-answer-quality-4ffn</guid>
      <description>&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/ayanarshad02/i-increased-retrieval-from-top-5-to-top-20-my-answers-got-worse-3mke" class="crayons-story__hidden-navigation-link"&gt;I Increased Retrieval From Top-5 to Top-20. My Answers Got Worse&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/ayanarshad02" class="crayons-avatar  crayons-avatar--l  "&gt;
            &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3817591%2F0fa1e4d1-8be4-4467-b7f3-2fa3543e804b.png" alt="ayanarshad02 profile" class="crayons-avatar__image" width="800" height="800"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/ayanarshad02" class="crayons-story__secondary fw-medium m:hidden"&gt;
              Md Ayan Arshad
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                Md Ayan Arshad
                
              
              &lt;div id="story-author-preview-content-3627495" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/ayanarshad02" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&gt;
                        &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3817591%2F0fa1e4d1-8be4-4467-b7f3-2fa3543e804b.png" class="crayons-avatar__image" alt="" width="800" height="800"&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;Md Ayan Arshad&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/ayanarshad02/i-increased-retrieval-from-top-5-to-top-20-my-answers-got-worse-3mke" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;May 7&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/ayanarshad02/i-increased-retrieval-from-top-5-to-top-20-my-answers-got-worse-3mke" id="article-link-3627495"&gt;
          I Increased Retrieval From Top-5 to Top-20. My Answers Got Worse
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag crayons-tag--filled  " href="/t/discuss"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;discuss&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/ai"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;ai&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/programming"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;programming&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/machinelearning"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;machinelearning&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
          &lt;a href="https://dev.to/ayanarshad02/i-increased-retrieval-from-top-5-to-top-20-my-answers-got-worse-3mke" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left"&gt;
            &lt;div class="multiple_reactions_aggregate"&gt;
              &lt;span class="multiple_reactions_icons_container"&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/sparkle-heart-5f9bee3767e18deb1bb725290cb151c25234768a0e9a2bd39370c382d02920cf.svg" width="24" height="24"&gt;
                  &lt;/span&gt;
              &lt;/span&gt;
              &lt;span class="aggregate_reactions_counter"&gt;1&lt;span class="hidden s:inline"&gt; reaction&lt;/span&gt;&lt;/span&gt;
            &lt;/div&gt;
          &lt;/a&gt;
            &lt;a href="https://dev.to/ayanarshad02/i-increased-retrieval-from-top-5-to-top-20-my-answers-got-worse-3mke#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              Comments


              1&lt;span class="hidden s:inline"&gt; comment&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            6 min read
          &lt;/small&gt;
            
              &lt;span class="bm-initial"&gt;
                

              &lt;/span&gt;
              &lt;span class="bm-success"&gt;
                

              &lt;/span&gt;
            
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;


</description>
    </item>
    <item>
      <title>I Increased Retrieval From Top-5 to Top-20. My Answers Got Worse</title>
      <dc:creator>Md Ayan Arshad</dc:creator>
      <pubDate>Thu, 07 May 2026 13:53:07 +0000</pubDate>
      <link>https://dev.to/ayanarshad02/i-increased-retrieval-from-top-5-to-top-20-my-answers-got-worse-3mke</link>
      <guid>https://dev.to/ayanarshad02/i-increased-retrieval-from-top-5-to-top-20-my-answers-got-worse-3mke</guid>
      <description>&lt;p&gt;The standard advice for improving RAG retrieval quality is: retrieve more candidates, then filter down. Bigger pool, better reranker, better answers. I followed that advice in &lt;a href="https://github.com/AyanArshad02/kapa-inspired-rag-mcp" rel="noopener noreferrer"&gt;my RAG System&lt;/a&gt;. On PDFs, going from top-5 to top-20 made my RAGAS scores drop. The answers got worse, not better.&lt;/p&gt;

&lt;p&gt;Here's what actually happened and the experiment design that explained it.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;PDFs&lt;/strong&gt; (40 QA pairs, 5 technical documents):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Condition&lt;/th&gt;
&lt;th&gt;RAGAS SUM&lt;/th&gt;
&lt;th&gt;Context Precision&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;top-5, no reranker (baseline)&lt;/td&gt;
&lt;td&gt;3.4330&lt;/td&gt;
&lt;td&gt;0.8102&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;top-20, no reranker&lt;/td&gt;
&lt;td&gt;3.4051 ↓&lt;/td&gt;
&lt;td&gt;0.8118&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;top-20 → Cohere rerank → top-5&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;3.4843&lt;/strong&gt; ↑&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.8368&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;GitHub code&lt;/strong&gt; (50 QA pairs, &lt;code&gt;encode/httpx&lt;/code&gt; repo):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Condition&lt;/th&gt;
&lt;th&gt;RAGAS SUM&lt;/th&gt;
&lt;th&gt;Context Precision&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;top-5, no reranker (baseline)&lt;/td&gt;
&lt;td&gt;3.5680&lt;/td&gt;
&lt;td&gt;0.7812&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;top-20, no reranker&lt;/td&gt;
&lt;td&gt;3.5766&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;0.7812&lt;/strong&gt; ← identical&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;top-20 → Cohere rerank → top-5&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;3.7079&lt;/strong&gt; ↑&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.9335&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On PDFs, more candidates without a quality filter made scores drop. On code, a 4x larger pool produced zero improvement in Context Precision i.e. 0.7812 versus 0.7812. Every gain in both cases came entirely from the reranker.&lt;/p&gt;

&lt;h2&gt;
  
  
  The standard advice
&lt;/h2&gt;

&lt;p&gt;Most RAG tutorials recommend something like: retrieve top-20 or top-50 candidates, then rerank to top-5. The reasoning is intuitive, a bigger retrieval pool gives the reranker more material to work with, so the final 5 chunks are better quality.&lt;/p&gt;

&lt;p&gt;That reasoning isn't wrong. But it hides an important assumption: the reranker is present. Without it, a bigger pool doesn't help. It actively hurts.&lt;/p&gt;

&lt;p&gt;To separate these two effects, I designed a 3 condition experiment. Most people only test "with reranker vs without reranker", that confounds pool size and reranking quality into one comparison. Breaking it into three conditions isolates what's actually causing the change.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 3-condition experiment design
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Condition A: top-5,  no reranker    → baseline
Condition B: top-20, no reranker    → isolates pool size effect
Condition C: top-20 → Cohere → top-5 → isolates reranker contribution
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The logic:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If C &amp;gt; B: the reranker is doing real work, not just benefiting from more candidates&lt;/li&gt;
&lt;li&gt;If B &amp;gt; A: a bigger pool helps even without reranking&lt;/li&gt;
&lt;li&gt;If B ≈ A or B &amp;lt; A: pool size doesn't matter and all improvement comes from the reranker&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Running this on two different data types produced two different failure modes. Both pointed at the same root cause.&lt;/p&gt;

&lt;h2&gt;
  
  
  Result 1 : PDFs
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Corpus:&lt;/strong&gt; 5 technical PDFs from FastAPI, Kubernetes, React, Stripe API reference, AWS overview along with 40 QA pairs&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Condition&lt;/th&gt;
&lt;th&gt;Faithfulness&lt;/th&gt;
&lt;th&gt;Ctx Precision&lt;/th&gt;
&lt;th&gt;Ctx Recall&lt;/th&gt;
&lt;th&gt;RAGAS SUM&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;top-5, no reranker&lt;/td&gt;
&lt;td&gt;0.9137&lt;/td&gt;
&lt;td&gt;0.8102&lt;/td&gt;
&lt;td&gt;0.8917&lt;/td&gt;
&lt;td&gt;3.4330&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;top-20, no reranker&lt;/td&gt;
&lt;td&gt;0.9004&lt;/td&gt;
&lt;td&gt;0.8118&lt;/td&gt;
&lt;td&gt;0.8750&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;3.4051&lt;/strong&gt; ↓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;top-20 → Cohere → top-5&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.9267&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.8368&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.8929&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;3.4843&lt;/strong&gt; ↑&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Condition B scored lower than Condition A on every metric except Context Precision, where it gained 0.0016 and its statistically meaningless. Overall RAGAS SUM dropped from 3.4330 to 3.4051.&lt;/p&gt;

&lt;p&gt;More candidates made the answers worse.&lt;/p&gt;

&lt;p&gt;The reranker (Condition C) recovered the loss and added on top of it: SUM 3.4843, Context Precision 0.8368. The difference between C and B is entirely the reranker's contribution.&lt;/p&gt;

&lt;h2&gt;
  
  
  Result 2 : GitHub code
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Corpus:&lt;/strong&gt; &lt;code&gt;encode/httpx&lt;/code&gt; repository and 90 files, 50 QA pairs on function behavior and parameters. Full experiment code and eval sets are in the &lt;a href="https://github.com/AyanArshad02/kapa-inspired-rag-mcp" rel="noopener noreferrer"&gt;repo&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Condition&lt;/th&gt;
&lt;th&gt;Ctx Precision&lt;/th&gt;
&lt;th&gt;Ctx Recall&lt;/th&gt;
&lt;th&gt;RAGAS SUM&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;top-5, no reranker&lt;/td&gt;
&lt;td&gt;0.7812&lt;/td&gt;
&lt;td&gt;0.9700&lt;/td&gt;
&lt;td&gt;3.5680&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;top-20, no reranker&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.7812&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.9700&lt;/td&gt;
&lt;td&gt;3.5766&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;top-20 → Cohere → top-5&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.9335&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.9300&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3.7079&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Condition B versus Condition A: Context Precision 0.7812 versus 0.7812. Identical. A 4x larger retrieval pool produced zero improvement in precision.&lt;/p&gt;

&lt;p&gt;Then the reranker: Context Precision jumps from 0.7812 to 0.9335. That's +0.1523, the largest precision gain in any experiment across this entire project. RAGAS SUM 3.7079 is the highest score in the project. The PDF best was 3.4843.&lt;/p&gt;

&lt;p&gt;One tradeoff worth naming: Context Recall dropped slightly from 0.9700 to 0.9300 when the reranker was added. The reranker filters aggressively for relevance, occasionally it discards a chunk that contained useful information but didn't score highest on the query. For most QA use cases, a +0.1523 precision gain at the cost of -0.0400 recall is clearly the right tradeoff. But it's real, and worth monitoring if recall matters more than precision for your use case.&lt;/p&gt;

&lt;p&gt;Every point of the precision improvement came from the reranker, not from the pool size.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this happens
&lt;/h2&gt;

&lt;p&gt;Without a reranker, the top-k selection is purely based on embedding similarity. The embedding model retrieves the 20 chunks whose vectors are closest to the query vector. In the top 5, those are the 5 closest. In the top 20, you get those same 5 plus 15 more, which are further away in embedding space and increasingly likely to be noise.&lt;/p&gt;

&lt;p&gt;Those 15 extra chunks go directly into the LLM's context window. The LLM sees 20 chunks instead of 5. The signal-to-noise ratio drops. The answers get worse.&lt;/p&gt;

&lt;p&gt;The reranker changes the game because it operates on a completely different signal. Cohere's reranker doesn't use vector proximity — it reads the query and each chunk as text, then scores relevance directly. It can distinguish between a chunk that contains the query's keywords but doesn't answer the question, and a chunk that answers the question using different words. Embedding similarity can't do that.&lt;/p&gt;

&lt;p&gt;So the reranker takes the noisy top-20 pool and discards 15 chunks. The 5 it keeps are genuinely relevant, not just vectorially close. That's why Context Precision jumped from 0.7812 to 0.9335 on code and why adding more candidates without the reranker did nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The "reranker does real work" proof
&lt;/h2&gt;

&lt;p&gt;The 3 condition design specifically tests this.&lt;/p&gt;

&lt;p&gt;If all the improvement in Condition C came from the larger pool rather than the reranker, then Condition B (same pool, no reranker) would show similar gains. It didn't, on code, B and A were identical. On PDFs, B was worse than A.&lt;/p&gt;

&lt;p&gt;Every gain in Condition C came from the reranker acting on the larger pool. The pool size is not the lever. The reranker is.&lt;/p&gt;

&lt;p&gt;This matters practically. A common optimization people reach for is "increase k." It's a one line config change. But the data shows it has no effect without a reranker, and can actively hurt. The right lever is adding a reranker, not increasing k.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Increasing retrieval candidates without a reranker adds noise, not signal, on PDFs, top-20 without a reranker scored lower than top-5 on every metric&lt;/li&gt;
&lt;li&gt;On code, expanding from top-5 to top-20 produced 0.0000 improvement in Context Precision, the pool size was genuinely irrelevant&lt;/li&gt;
&lt;li&gt;The 3 condition design (top-5 / top-20 / top-20+rerank) is the correct way to test this, "with vs without reranker" conflates two separate effects&lt;/li&gt;
&lt;li&gt;The reranker's advantage is operating on text, not vectors, it catches semantic relevance that embedding similarity misses&lt;/li&gt;
&lt;li&gt;+0.1523 Context Precision on code is the largest single-component gain in this project, one API call, one reranker, that result&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The practical takeaway
&lt;/h2&gt;

&lt;p&gt;If you're trying to improve RAG answer quality, don't reach for a larger k first.&lt;/p&gt;

&lt;p&gt;Add a reranker. Then increase k if you want to give it more to work with.&lt;/p&gt;

&lt;p&gt;Increasing k without a reranker gives the LLM more context to get confused by. With a reranker, a larger pool means the right chunks are more likely to be in the candidate set before filtering. The order matters.&lt;/p&gt;

&lt;p&gt;A top-20 retrieve → Cohere rerank → top-5 pipeline consistently outperformed both top-5 (baseline) and top-20 without reranking across two separate data types and 90 total QA pairs. The pattern is stable.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part of an ongoing series on building and evaluating a production RAG system.&lt;/em&gt;&lt;br&gt;
&lt;em&gt;Full code in GitHub : &lt;a href="https://github.com/AyanArshad02/kapa-inspired-rag-mcp" rel="noopener noreferrer"&gt;Reverse Engineering YC Startup&lt;/a&gt;&lt;/em&gt;&lt;br&gt;
&lt;em&gt;Previous post: &lt;a href="https://dev.to/ayanarshad02/i-tested-chunking-on-docs-pdfs-and-code-the-winner-changed-every-time-1lof"&gt;I Tested Chunking on Docs, PDFs, and Code. The Winner Changed Every Time.&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>programming</category>
      <category>machinelearning</category>
      <category>discuss</category>
    </item>
    <item>
      <title>I Tested Chunking on Docs, PDFs, and Code. The Winner Changed Every Time.</title>
      <dc:creator>Md Ayan Arshad</dc:creator>
      <pubDate>Mon, 04 May 2026 16:32:41 +0000</pubDate>
      <link>https://dev.to/ayanarshad02/i-tested-chunking-on-docs-pdfs-and-code-the-winner-changed-every-time-1lof</link>
      <guid>https://dev.to/ayanarshad02/i-tested-chunking-on-docs-pdfs-and-code-the-winner-changed-every-time-1lof</guid>
      <description>&lt;p&gt;I assumed chunking was a solved problem. Pick a text splitter, set 512 tokens, add some overlap, move on. After running structured experiments across three different data types, that assumption collapsed. The best chunker for markdown documentation actively hurt performance on code. The winner changed completely depending on what I was chunking.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Data type&lt;/th&gt;
&lt;th&gt;Winner&lt;/th&gt;
&lt;th&gt;Headline metric&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Markdown docs&lt;/td&gt;
&lt;td&gt;HeadingAwareChunker&lt;/td&gt;
&lt;td&gt;MRR 0.755 vs SlidingWindow 0.687&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PDFs&lt;/td&gt;
&lt;td&gt;RecursiveChar (512 tok)&lt;/td&gt;
&lt;td&gt;Context Recall 0.9250, RAGAS SUM 3.4249&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitHub code&lt;/td&gt;
&lt;td&gt;CodeBlockAwareChunker&lt;/td&gt;
&lt;td&gt;RAGAS SUM 3.5680 — highest across all experiments&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;RecursiveChar won on PDFs. The same chunker scored 0.5690 Context Precision on code, roughly half the retrieved chunks were irrelevant. There is no universal best chunker. The data type decides.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I was building
&lt;/h2&gt;

&lt;p&gt;A RAG system that ingests documentation sites, PDFs, and GitHub repositories for multiple tenants, then answers developer questions with citations. Before embedding anything, I had to decide how to chunk each source type.&lt;/p&gt;

&lt;p&gt;The standard advice is "use a recursive text splitter." Every tutorial does this. But markdown docs have headings, PDFs have paragraphs, code has functions. A function is a complete semantic unit, splitting it at token 256 and you've lost the return type, the error handling, the docstring. None of that is recoverable at query time&lt;/p&gt;

&lt;p&gt;So I ran experiments, one variable changed per experiment: the chunker&lt;/p&gt;

&lt;p&gt;The embedding model, retrieval method, reranker, LLM, and eval set stayed fixed.&lt;br&gt;
RAGAS scored every pipeline on the same frozen question set.&lt;/p&gt;

&lt;p&gt;3 data types, 3 experiments and here's what happened.&lt;/p&gt;

&lt;p&gt;The full implementation, experiment notebooks, and eval sets are on &lt;a href="https://github.com/AyanArshad02/kapa-inspired-rag-mcp" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F18ag03gcgh5ra7wfbk26.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F18ag03gcgh5ra7wfbk26.png" alt=" " width="800" height="469"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Experiment 1: Documentation (.md / .mdx)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Corpus:&lt;/strong&gt; FastAPI and Supabase documentation, 78 QA pairs generated by GPT-4o, frozen after generation&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Chunkers tested:&lt;/strong&gt; HeadingAwareChunker (HAC), SlidingWindow-128, RecursiveChar, SemanticBlock&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key metric:&lt;/strong&gt; MRR (Mean Reciprocal Rank), recall@5 tells you if the answer is somewhere in the top 5. MRR tells you if it's at rank 1,  whether the right chunk comes first, not just eventually&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Chunker&lt;/th&gt;
&lt;th&gt;MRR (no reranker)&lt;/th&gt;
&lt;th&gt;Chunks produced&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;HeadingAwareChunker&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.755&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;127&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SlidingWindow-128&lt;/td&gt;
&lt;td&gt;0.687&lt;/td&gt;
&lt;td&gt;259&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;HAC produced the same Recall@5 as SlidingWindow (~0.82) but with significantly better MRR. The right answer appeared at rank 1 more often. And HAC did it with 127 chunks versus SlidingWindow's 259, half the chunks, better ranking, cheaper retrieval&lt;/p&gt;

&lt;p&gt;Why? Markdown documentation is already structured by headings. Each section covers one concept, one API endpoint, one configuration option. HAC splits exactly at those heading boundaries. SlidingWindow ignores them entirely, it cuts at token count, which means a chunk might start halfway through one concept and end halfway through the next.&lt;/p&gt;

&lt;p&gt;The embedding model then has to encode a chunk that mixes two ideas. The resulting vector is somewhere between them, and retrieval becomes imprecise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Winner: HeadingAwareChunker.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Experiment 2: PDFs
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Corpus:&lt;/strong&gt; 5 technical PDFs from FastAPI concepts, Kubernetes architecture, React patterns, Stripe API reference, AWS overview along with 40 QA pairs&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Chunkers tested:&lt;/strong&gt; SlidingWindow-128, SemanticBlock, RecursiveChar (512 tokens, 50 overlap). HeadingAwareChunker was not included here, &lt;code&gt;pymupdf4llm&lt;/code&gt; extracts PDFs to Markdown, but the heading hierarchy in PDFs is inconsistent across documents. Font-size-based heading detection is fragile enough that HAC's boundaries would be unreliable. The experiment focused on chunkers that work on paragraph level structure, which is what the extraction reliably produces&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Chunker&lt;/th&gt;
&lt;th&gt;Context Recall&lt;/th&gt;
&lt;th&gt;RAGAS SUM&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;RecursiveChar&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.9250&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3.4249&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SlidingWindow-128&lt;/td&gt;
&lt;td&gt;0.8750&lt;/td&gt;
&lt;td&gt;3.3691&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SemanticBlock&lt;/td&gt;
&lt;td&gt;0.8167&lt;/td&gt;
&lt;td&gt;3.2627&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;RecursiveChar won by a clear margin. Context Recall 0.9250 versus SlidingWindow's 0.8750.&lt;/p&gt;

&lt;p&gt;The reason is specific to how I extracted the PDFs. I used &lt;code&gt;pymupdf4llm&lt;/code&gt;, which converts PDFs to Markdown. The output is clean paragraphs with heading markers. RecursiveChar's default split points, double newlines, single newlines and it aligns naturally with those paragraph boundaries. It didn't need to classify blocks or detect headings. The structure was already there; RC just respected it.&lt;/p&gt;

&lt;p&gt;SemanticBlock failed on the Stripe API PDF. That document's navigation sidebar produced 12-token noise chunks, fragment after fragment of menu items. Those wasted retrieval slots on every single query.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Winner: RecursiveChar.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Note what just happened: HAC won on docs, RC won on PDFs. Two different data types, two different winners and the experiments are only half done&lt;/p&gt;

&lt;h2&gt;
  
  
  Experiment 3: GitHub code
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Corpus:&lt;/strong&gt; &lt;code&gt;encode/httpx&lt;/code&gt; repository having 90 files (60 Python, 29 Markdown, 1 text). 50 QA pairs focused on function behavior, parameters, and return values&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Chunkers tested:&lt;/strong&gt; CodeBlockAwareChunker (CBAC), RecursiveChar, SlidingWindow-128&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Chunker&lt;/th&gt;
&lt;th&gt;Ctx Precision&lt;/th&gt;
&lt;th&gt;Ctx Recall&lt;/th&gt;
&lt;th&gt;RAGAS SUM&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CodeBlockAwareChunker&lt;/td&gt;
&lt;td&gt;0.7812&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.9700&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3.5680&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SlidingWindow-128&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.8278&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.9150&lt;/td&gt;
&lt;td&gt;3.4957&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RecursiveChar&lt;/td&gt;
&lt;td&gt;0.5690&lt;/td&gt;
&lt;td&gt;0.9400&lt;/td&gt;
&lt;td&gt;3.2856&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;RecursiveChar scored 0.5690 on Context Precision, that means roughly half of the retrieved chunks were irrelevant to the question. The same chunker that won on PDFs failed on code.&lt;/p&gt;

&lt;p&gt;The failure mode is direct. Python code is full of blank lines between a function's docstring and its body, between logical sections inside a method, between a guard clause and the main logic. RecursiveChar splits at blank lines. So it routinely bundled two or three unrelated functions into a single chunk, averaging 457 tokens. When someone asks "what does &lt;code&gt;Client.send()&lt;/code&gt; return," the retrieved chunk contains &lt;code&gt;send()&lt;/code&gt; plus &lt;code&gt;get()&lt;/code&gt; plus the &lt;code&gt;__init__&lt;/code&gt; method. Everything but a focused answer.&lt;/p&gt;

&lt;p&gt;CBAC doesn't use blank lines. For Python files, it uses the &lt;code&gt;ast&lt;/code&gt; module, it finds the exact byte offset of every function and class definition in the syntax tree, then extracts each one as a separate chunk. Zero false splits. The average chunk was 120 tokens, one complete function.&lt;/p&gt;

&lt;p&gt;SlidingWindow 128 had the best Context Precision (0.8278), small windows avoid the bundling problem. But it split functions mid-body. A function's return value might land in the next window. That killed Recall: 0.9150 versus CBAC's 0.9700.&lt;/p&gt;

&lt;p&gt;CBAC with a full reranker pipeline achieved RAGAS SUM 3.7079, the highest score across all experiments in this project and the PDF best was 3.4843&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Winner: CodeBlockAwareChunker.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the results differ and why they shouldn't surprise you
&lt;/h2&gt;

&lt;p&gt;Each experiment picked a different chunker, but every result points at the same question: what is the natural semantic unit of this data?&lt;/p&gt;

&lt;p&gt;For markdown documentation, it's the section under a heading. That's a discrete concept, authored that way intentionally.&lt;/p&gt;

&lt;p&gt;For PDFs extracted to Markdown, it's the paragraph. The extraction tool already produces those boundaries. The chunker just has to respect them.&lt;/p&gt;

&lt;p&gt;For code, it's the function or class. A function is the smallest unit of behavior that makes sense alone. Split it and the chunk becomes meaningless without the surrounding context.&lt;/p&gt;

&lt;p&gt;Text splitters, recursive or sliding window, don't know any of this. They operate on character counts, token counts, or blank lines. None of those correspond to semantic boundaries in code. That's the root cause of RecursiveChar's 0.5690 Context Precision. It wasn't a hyperparameter problem. It was a conceptual mismatch.&lt;/p&gt;

&lt;p&gt;There's also a second effect worth naming: chunk count matters. HAC's 127 chunks versus SlidingWindow's 259 on the same corpus is not a coincidence. Fewer chunks means fewer candidates for noise to enter the retrieval pool. The embedding space is less diluted and rank 1 is cleaner&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The optimal chunker is determined by the data type, not by chunk size or overlap settings&lt;/li&gt;
&lt;li&gt;RecursiveChunker's blank-line heuristic is a real liability for code, 0.5690 Context Precision proves it&lt;/li&gt;
&lt;li&gt;Smaller average chunks (120 tokens) outperformed larger ones (457 tokens) on code by a significant margin, chunk size is a symptom, not a cause&lt;/li&gt;
&lt;li&gt;Visual inspection of actual chunks before running RAGAS catches structural bugs that aggregate scores smooth over, I caught CBAC producing 8KB chunks on Go files before the experiment ran&lt;/li&gt;
&lt;li&gt;Freezing the eval set before the first experiment is non negotiable because regenerating it mid experiment would invalidate every comparison&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The practical takeaway
&lt;/h2&gt;

&lt;p&gt;There is no universal best chunker&lt;/p&gt;

&lt;p&gt;For markdown documentation: split at heading boundaries&lt;br&gt;
For PDFs: convert to Markdown first, then split at paragraph boundaries&lt;br&gt;
For code: use an AST parser&lt;/p&gt;

&lt;p&gt;A generic 512-token splitter will technically work on all three. It will not be optimal on any of them. And on code specifically, the degradation is not marginal, it's a near-halving of retrieval precision.&lt;/p&gt;

&lt;p&gt;Pick the chunker that matches the semantic structure of the data, not the one that's easiest to configure.&lt;/p&gt;

&lt;p&gt;The harder version of this problem is mixed content, a PDF with embedded code blocks, a GitHub repo where half the files are Python and half are Markdown. Each file type still needs its own chunking strategy, which means the chunker has to detect content type at the file level and route accordingly. That's what the connector layer in this project handles, but it's a separate problem worth its own post.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm building a &lt;a href="https://github.com/AyanArshad02/kapa-inspired-rag-mcp" rel="noopener noreferrer"&gt;production RAG system&lt;/a&gt; that ingests multiple source types with per-source-type chunking strategies. Future posts cover the reranker experiments, eval methodology, and the CI pipeline I built around RAGAS scores.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>discuss</category>
      <category>programming</category>
      <category>datascience</category>
    </item>
    <item>
      <title>5 Critical Failures We Hit Shipping a Multi-Tenant RAG Chatbot to 500+ Enterprises</title>
      <dc:creator>Md Ayan Arshad</dc:creator>
      <pubDate>Sat, 04 Apr 2026 06:26:56 +0000</pubDate>
      <link>https://dev.to/ayanarshad02/we-shipped-a-rag-chatbot-to-500-enterprise-tenants-heres-what-actually-broke-first-1jia</link>
      <guid>https://dev.to/ayanarshad02/we-shipped-a-rag-chatbot-to-500-enterprise-tenants-heres-what-actually-broke-first-1jia</guid>
      <description>&lt;p&gt;Our first enterprise tenant onboarded on a Monday.&lt;/p&gt;

&lt;p&gt;By Wednesday, 30% of their documents had been silently indexed as empty strings. No error. No exception. The chatbot just said "I don't have enough information", confidently, every time.&lt;/p&gt;

&lt;p&gt;That was Failure #1. There were four more.&lt;/p&gt;

&lt;p&gt;Here's the honest account of shipping a multi-tenant RAG chatbot to 500+ enterprise clients — what broke, in what order, and what we should have caught earlier.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fsulg56rhevv4oloo38vj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fsulg56rhevv4oloo38vj.png" alt=" " width="800" height="719"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The System We Built
&lt;/h2&gt;

&lt;p&gt;Before the failures, the context.&lt;/p&gt;

&lt;p&gt;We built a RAG chatbot for enterprise warehouse management. Each tenant had their own isolated knowledge base — SOPs, compliance documents, operational guides. Users queried only their tenant's data. Scale target: ~25,000 queries per day at full rollout.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Indexing pipeline:&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;Document Upload → Type Detection → Preprocessing → Chunking → Embedding → Pinecone&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Query pipeline:&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;User Query → Cache Check → Query Rewrite → Hybrid Search (BM25 + Vector) → RRF Fusion → Reranker → LLM → Response&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Two pipelines in the design. One EC2 fleet in reality, which became Failure #4.&lt;/p&gt;

&lt;p&gt;Indexing consumed from SQS. Query API sat behind an ALB. One Pinecone namespace per tenant, every query scoped to the authenticated tenant's namespace before touching the vector DB.&lt;/p&gt;

&lt;p&gt;The architecture decisions were mostly right.&lt;/p&gt;

&lt;p&gt;What broke was the assumptions underneath them.&lt;/p&gt;
&lt;h2&gt;
  
  
  Failure #1: The PDF Preprocessing Assumption (Week 1)
&lt;/h2&gt;

&lt;p&gt;We assumed all enterprise documents were text-based PDFs.&lt;/p&gt;

&lt;p&gt;They weren't.&lt;/p&gt;

&lt;p&gt;About 30% of what tenants uploaded were scanned PDFs, images of physical pages, no text layer. When PyMuPDF opened these files, it returned empty strings. We embedded empty strings. We indexed empty chunks. No error. No exception. Just silent failure.&lt;/p&gt;

&lt;p&gt;Users asked questions. Retrieval returned nothing relevant. The LLM said "I don't have enough information." Users assumed the chatbot was broken. They were right, just not for the reason they thought.&lt;/p&gt;

&lt;p&gt;The fix: A preprocessing gate that checks average characters per page. If avg_chars_per_page &amp;lt; 100, no text layer exists, trigger OCR via AWS Textract before chunking. We also added an admin-facing flag marking documents as "pending OCR" so tenants know their document is processing, not lost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The lesson:&lt;/strong&gt; Never assume your input format. Garbage input produces zero output in RAG. Preprocessing is the most boring part of the pipeline and the most catastrophic to skip.&lt;/p&gt;
&lt;h2&gt;
  
  
  Failure #2: Headers, Footers, and the Chunk Contamination Problem
&lt;/h2&gt;

&lt;p&gt;Even for text-based PDFs, every chunk was contaminated.&lt;/p&gt;

&lt;p&gt;Enterprise documents have headers and footers on every page. "Softeon WMS User Guide — Confidential — Page 14 of 203." When you chunk a 200-page document into 512-token pieces, that text bleeds into hundreds of chunks.&lt;/p&gt;

&lt;p&gt;The retrieval impact was subtle but real. Queries about "confidential" topics surfaced chunks with "Confidential" in the footer, not because the content was relevant, but because BM25 was matching on that exact term. Relevance scores were quietly polluted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; A stripping step before chunking. Text appearing in the top 5% and bottom 5% of every page gets flagged and removed. We also converted tables to markdown before chunking, a raw table extracted as "Product Price Refund Laptop 999 30 days" is useless for retrieval. The same table as structured markdown is self-contained and semantically meaningful.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The lesson:&lt;/strong&gt; Most RAG tutorials skip directly to chunking size debates — 256 vs 512 tokens. They assume clean input. Real enterprise documents are not clean.&lt;/p&gt;
&lt;h2&gt;
  
  
  Failure #3: The Parallel Pipeline Was Actually Sequential
&lt;/h2&gt;

&lt;p&gt;We ran BM25 and vector search in what we thought was parallel.&lt;/p&gt;

&lt;p&gt;It wasn't.&lt;/p&gt;

&lt;p&gt;The original implementation called BM25, waited for the result, then called Pinecone. "Parallel" on the architecture diagram. Sequential in the code. At p50 this cost us ~200ms we couldn't afford.&lt;/p&gt;

&lt;p&gt;The fix is one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Wrong — sequential
&lt;/span&gt;&lt;span class="n"&gt;bm25_results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;bm25_search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;vector_results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;pinecone_search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Right — parallel
&lt;/span&gt;&lt;span class="n"&gt;bm25_results&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;vector_results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;gather&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;bm25_search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nf"&gt;pinecone_search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Latency becomes the max of the two, not the sum. Dropped our p95 by ~180ms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The lesson:&lt;/strong&gt; "Parallel" on a diagram and "parallel" in code are different things. Profile your pipeline stage by stage. The bottleneck is always somewhere surprising.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure #4: One Tenant's Upload Degraded Everyone's Query Latency
&lt;/h2&gt;

&lt;p&gt;This one took a week to diagnose.&lt;/p&gt;

&lt;p&gt;We noticed periodic p99 spikes, not consistent, not tied to query volume. Random, unpredictable.&lt;/p&gt;

&lt;p&gt;The cause: our indexing pipeline and query pipeline were on the same EC2 instances.&lt;/p&gt;

&lt;p&gt;When a large tenant uploaded 500 documents, the embedding loop hammered the instance CPU. Live users querying on the same instance saw response time jump from 800ms to 6+ seconds. The indexer and query service were invisible to each other in code — but very visible to each other on the metal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; Complete infrastructure separation. Indexing workers on a dedicated EC2 fleet, completely outside the ALB. The query fleet has no knowledge that indexing is happening. A document upload spike now has zero effect on query latency for any tenant. The SQS queue buffers upload bursts and feeds indexing workers at a controlled pace.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The lesson:&lt;/strong&gt; Load isolation is not just an architectural principle. It's a user experience decision. Enterprise tenants don't care about your architecture, they care that the chatbot was slow when they needed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure #5: The Namespace Isolation Gap We Almost Missed
&lt;/h2&gt;

&lt;p&gt;Multi-tenant isolation in Pinecone is handled by namespaces. One namespace per tenant. Every write tags it. Every read is scoped to it.&lt;/p&gt;

&lt;p&gt;What we almost shipped: namespace scoped at the request body level.&lt;/p&gt;

&lt;p&gt;A bad actor passing a forged tenant_id in the request body could scope the query to a different tenant's namespace. Subtle. Critical.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Wrong — trusting request body
&lt;/span&gt;&lt;span class="n"&gt;namespace&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;

&lt;span class="c1"&gt;# Right — trusting validated token only
&lt;/span&gt;&lt;span class="n"&gt;namespace&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;token_context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;  &lt;span class="c1"&gt;# resolved from JWT at API layer
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; Namespace resolved exclusively from the validated JWT token at the API layer. The request body's tenant_id is ignored entirely. By the time a request reaches the vector DB call, the namespace has already been locked to the authenticated tenant — it cannot be overridden.&lt;/p&gt;

&lt;p&gt;If we had shipped the original version, any authenticated user who knew another tenant's ID could have queried their private documents. In a WMS context serving enterprise clients, that's not a security incident, that's a contract termination and a legal conversation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The lesson:&lt;/strong&gt; Namespace isolation is not the same as security. Enforce tenant identity at authentication, not the application layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We Still Haven't Built
&lt;/h2&gt;

&lt;p&gt;We don't have automated RAG evaluation in production.&lt;/p&gt;

&lt;p&gt;No RAGAS running continuously. No Precision@5 after every deployment. Human review by an internal QA team, representative queries, manual quality ratings. It works at current scale. It won't at full rollout.&lt;/p&gt;

&lt;p&gt;What I'd build next with two weeks:&lt;br&gt;
→ A golden evaluation set with 200 curated question-to-chunk pairs from real tenant queries. Your retrieval quality baseline.&lt;br&gt;
→ RAGAS Faithfulness in CI/CD runs on every deployment, blocks release if faithfulness drops more than 5% from baseline.&lt;br&gt;
→ Context Precision tracking, tells you if your reranker is actually earning its latency cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  The One Thing That Mattered Most
&lt;/h2&gt;

&lt;p&gt;RAG systems fail at the edges of the pipeline, not the center.&lt;/p&gt;

&lt;p&gt;Most engineering effort goes into the center, embedding models, reranking algorithms, chunk sizes. The real production failures happen at the edges: what goes into the indexer, what happens when two workloads compete for the same compute, and where tenant identity gets resolved.&lt;/p&gt;

&lt;p&gt;What broke first in your RAG pipeline? Drop it in the comments. The failures nobody writes about are always the most useful. I'll compile the best ones into a follow-up post.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>programming</category>
      <category>security</category>
    </item>
    <item>
      <title>Why Our RAG System Was Silently Returning Wrong Answers — And How We Fixed It</title>
      <dc:creator>Md Ayan Arshad</dc:creator>
      <pubDate>Wed, 11 Mar 2026 00:19:49 +0000</pubDate>
      <link>https://dev.to/ayanarshad02/why-our-rag-system-was-silently-returning-wrong-answers-and-how-we-fixed-it-386g</link>
      <guid>https://dev.to/ayanarshad02/why-our-rag-system-was-silently-returning-wrong-answers-and-how-we-fixed-it-386g</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9cgbkpcvxtjvlb2fylie.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9cgbkpcvxtjvlb2fylie.webp" alt="Our RAG System Was Confidently Wrong"&gt;&lt;/a&gt;&lt;br&gt;
For 3 days, our RAG system was confident.&lt;/p&gt;

&lt;p&gt;Every query returned an answer. Response times were stable. No errors in the logs. By every operational metric, the system was working.&lt;/p&gt;

&lt;p&gt;Our RAGAS faithfulness score told a different story.&lt;/p&gt;

&lt;p&gt;It had dropped from 0.91 to 0.67 without a single code change.&lt;/p&gt;

&lt;p&gt;That meant roughly 1 in 3 responses was making claims our own retrieved context didn’t support. The system wasn’t crashing. It was hallucinating — silently, at scale, with complete confidence.&lt;/p&gt;

&lt;p&gt;Here is what happened.&lt;/p&gt;
&lt;h2&gt;
  
  
  The System When It Started Failing
&lt;/h2&gt;

&lt;p&gt;We were running a production RAG system serving enterprise clients across a large document corpus. Each client had their own isolated set of documents — product configuration files, setup guides, operational workflows — queried daily to answer operational questions.&lt;/p&gt;

&lt;p&gt;_The state of the system when the drift began:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;25K+ documents across corpus&lt;/li&gt;
&lt;li&gt;500+ enterprise tenants&lt;/li&gt;
&lt;li&gt;1 Pinecone namespace per tenant&lt;/li&gt;
&lt;li&gt;5 chunks retrieved per query (top-K)
_&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Stack:&lt;/strong&gt; GPT-4 for generation, text-embedding-ada-002 for embeddings, Pinecone with one namespace per tenant, FastAPI on ECS. Isolation was strict — no cross-tenant reads, ever.&lt;/p&gt;

&lt;p&gt;_A NOTE on the namespace decision: Pinecone namespaces share the same index and billing unit, 500 namespaces cost the same as 1. We chose namespaces over metadata filtering (tenant_id filter on a single index) for one specific reason: Metadata filtering requires every query to carry the correct filter, and one bug means Tenant A can read Tenant B's data. For enterprise clients, that risk surface isn't acceptable. Namespaces make cross-tenant leakage structurally impossible at query time.&lt;/p&gt;

&lt;p&gt;Namespaces give us defense-in-depth isolation at the infrastructure layer rather than relying on application-level filtering._&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Monitoring:&lt;/strong&gt; API latency, error rates, cost dashboards.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Answer quality monitoring:&lt;/strong&gt; None.&lt;/p&gt;

&lt;p&gt;That was the bug. Not in the code. In the architecture.&lt;/p&gt;
&lt;h2&gt;
  
  
  What Changed, And Why We Didn’t See It
&lt;/h2&gt;

&lt;p&gt;Three days before we caught the drift, a large batch of new documents was ingested. Different document type than the existing corpus — denser, longer sentences, more domain-specific terminology. Same domain, different structure.&lt;/p&gt;

&lt;p&gt;The new documents changed the distribution of our Pinecone namespaces. Queries that had previously retrieved highly relevant chunks now retrieved chunks that were topically related but not directly answering the query.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cosine similarity scores:&lt;/strong&gt; 0.76, 0.79, 0.81. High enough to clear any threshold we’d set. &lt;code&gt;text-embedding-ada-002&lt;/code&gt; couldn't distinguish between "this chunk discusses this topic" and "this chunk contains the specific answer this query is asking for." Retrieval looked confident. The chunks were wrong.&lt;/p&gt;

&lt;p&gt;GPT-4 did what LLMs do when context is adjacent-but-imprecise: it filled the gaps with plausible-sounding claims not present in the retrieved text.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;RAGAS faithfulness:&lt;/strong&gt; 0.91 → 0.67.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Context Precision:&lt;/strong&gt; 0.84 → 0.61.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;We had no alert for either. We found out from a user.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The core failure:&lt;/strong&gt; we instrumented everything easy to measure — latency, throughput, cost, error rates — and nothing that measured correctness. We were flying blind on the one metric that determined whether the system was actually useful.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One clarification on how we were running RAGAS.&lt;/strong&gt; We were not evaluating live traffic — that would be prohibitively expensive and slow. We maintained a golden evaluation set of ~300 representative queries with known-good answers and source chunks, curated when the system first launched. RAGAS ran against that set nightly, and on every ingestion event. The drop from 0.91 to 0.67 showed up the morning after the batch ingestion. We just had no alert configured to catch it.&lt;/p&gt;
&lt;h2&gt;
  
  
  What We Tried First — And Why It Wasn’t Enough
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;First instinct:&lt;/strong&gt; improve retrieval.&lt;/p&gt;

&lt;p&gt;We raised the similarity threshold from 0.70 to 0.78, rejecting chunks below that score. Retrieval precision improved. We also started returning no results for legitimate queries with unusual phrasing. Users got empty responses. That was worse.&lt;/p&gt;

&lt;p&gt;We increased top-K from 5 to 10. Slightly helped recall. Sent 2× the tokens into every LLM call, which compounded a cost problem already building at 500+ active tenants.&lt;/p&gt;

&lt;p&gt;Context Precision recovered to 0.78. Faithfulness only reached 0.81, still below our 0.85 target.&lt;/p&gt;

&lt;p&gt;The retrieval fixes were necessary. They were not sufficient. We needed a layer that caught the gap between what retrieval returned and what the LLM claimed. Retrieval improvement was treating the symptom. We needed to treat the cause.&lt;/p&gt;
&lt;h2&gt;
  
  
  The Real Fix: Grounding Validation as a First-Class Architecture Layer
&lt;/h2&gt;

&lt;p&gt;We added a grounding validation step that runs after every LLM response, before it’s returned to the user.&lt;/p&gt;

&lt;p&gt;The mechanism:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Extract the factual claims from the generated response using a structured extraction prompt. This step is imperfect, LLM-based claim extraction can miss or misinterpret implicit claims, so we treat it as a signal, not a definitive verdict. A claim is flagged as unsupported if no retrieved chunk scores above a similarity threshold for that claim.&lt;/li&gt;
&lt;li&gt;Score each claim against the retrieved chunks, classified as supported, unsupported, or contradicted.&lt;/li&gt;
&lt;li&gt;Flag any response where more than 15% of claims are unsupported or contradicted.&lt;/li&gt;
&lt;li&gt;Regenerate flagged responses with an explicit grounding instruction injected into the prompt.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;The regeneration prompt:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;“Your response must only make claims directly supported by the provided context. If the context does not contain the answer, say so explicitly. Do not infer or extrapolate.”&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Critical implementation detail:&lt;/strong&gt; The claim extraction and verification call runs against &lt;code&gt;gpt-4o-mini&lt;/code&gt;, not GPT-4. Running a full GPT-4 call for every response validation would double our inference cost and add 600–800ms of latency. With &lt;code&gt;gpt-4o-mini&lt;/code&gt;, the validation step adds approximately 180–220ms on average for a 3–5 sentence response. That number is model-dependent, it will be higher on a slower model and lower with a fine-tuned classifier.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;After deploying:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Before:  Faithfulness 0.67  |  Context Precision 0.61  |  31% unsupported claim rate
After:   Faithfulness 0.91  |  Context Precision 0.87  |  &amp;lt;4% unsupported claim rate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6lkl8ekyylllf9dylbt5.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6lkl8ekyylllf9dylbt5.webp" alt="Production RAG Architecture : 500+ Enterprise Clients"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Core Decision — And When You’d Make It Differently
&lt;/h2&gt;

&lt;p&gt;We made an explicit trade-off: ~200ms of additional latency in exchange for verifiable answer quality.&lt;/p&gt;

&lt;p&gt;For our use case — enterprise clients making operational decisions based on the chatbot’s answers — that trade-off was not a discussion. The 200ms is noise. The trust cost of a wrong answer at enterprise scale is not.&lt;/p&gt;

&lt;p&gt;But this decision is not universal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Constraint                            Decision          Rationale
──────────────────────────────────────────────────────────────────────────────
Consumer product, SLA &amp;lt; 500ms         Run async          Log failures, don't block.
                                                         Stakes are low, UX matters more.

Low-stakes (drafting, summarisation)  Skip it            User edits the output.
                                                         Grounding matters less.

High-volume, cost-sensitive           Sample 10%         Statistical signal at
                                                         1/10th the overhead.

Enterprise / regulated / high-stakes  Mandatory sync     A wrong answer has real
                                                         downstream consequences.

Multi-tenant, strict isolation        Mandatory + audit  Every response must be
                                                         traceable to a source chunk.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Principle:&lt;/strong&gt; grounding validation is always worth measuring. Whether to block on it synchronously depends on your SLA and the cost of a wrong answer in your domain.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Anti-Pattern: Checking Faithfulness After Generation Is the Wrong Architecture
&lt;/h2&gt;

&lt;p&gt;Here is the deeper architectural mistake this exposed.&lt;/p&gt;

&lt;p&gt;We were checking faithfulness after generation — as a post-hoc audit — rather than as a gating condition on the response pipeline. The audit told us something was wrong. It didn’t stop a wrong answer from being returned.&lt;/p&gt;

&lt;p&gt;The correct architecture treats grounding validation as a blocking step in the response pipeline, not an observability metric reviewed after the fact.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Wrong: Generate → Return → [async] Validate → Log failure → Review weekly
Right: Generate → Validate → [if flagged] Regenerate → Return → Log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The async pattern gives you observability. It does not give you correctness. For any system where answer quality has downstream consequences, post-hoc monitoring is not a substitute for inline validation.&lt;/p&gt;

&lt;p&gt;We caught our failure because a user noticed. That should never be the detection mechanism for a production system serving enterprise clients.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We Changed Permanently
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Quality monitoring is now first-class. RAGAS faithfulness and context precision scored against our golden evaluation set on every ingestion event and every deployment. Grafana alerts fire if either drops more than 10% from the established baseline. We do not run RAGAS on live traffic, it’s too slow and expensive at scale. The golden set gives us the signal we need.&lt;/li&gt;
&lt;li&gt;Document ingestion now triggers a quality gate. When new documents are ingested, we run the benchmark query set against the updated index before traffic is shifted. Faithfulness drops &amp;gt;5% → ingestion rolled back.&lt;/li&gt;
&lt;li&gt;Grounding validation is synchronous and non-configurable. The ~200ms cost is included in our SLA. Not optional for any enterprise-tier query.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The One-Line Takeaway
&lt;/h2&gt;

&lt;p&gt;Your RAG system will hallucinate. The question is whether you find out before your users do.&lt;/p&gt;

</description>
      <category>genai</category>
      <category>rag</category>
      <category>ai</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
