<?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: Sowaiba Arshad</title>
    <description>The latest articles on DEV Community by Sowaiba Arshad (@sowaiba01).</description>
    <link>https://dev.to/sowaiba01</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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4041961%2Fa1e26ab7-6b3d-4d53-9ee2-6d32c5ec103f.jpeg</url>
      <title>DEV Community: Sowaiba Arshad</title>
      <link>https://dev.to/sowaiba01</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sowaiba01"/>
    <language>en</language>
    <item>
      <title>My RAG app confidently made up a drug dosage. So I rebuilt it to say 'I don't know'.</title>
      <dc:creator>Sowaiba Arshad</dc:creator>
      <pubDate>Thu, 30 Jul 2026 09:49:35 +0000</pubDate>
      <link>https://dev.to/sowaiba01/my-rag-app-confidently-made-up-a-drug-dosage-so-i-rebuilt-it-to-say-i-dont-know-3k61</link>
      <guid>https://dev.to/sowaiba01/my-rag-app-confidently-made-up-a-drug-dosage-so-i-rebuilt-it-to-say-i-dont-know-3k61</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;⭐ &lt;strong&gt;Star the repo:&lt;/strong&gt; &lt;a href="https://github.com/Sowaiba-01/ClinicaQuery-AI" rel="noopener noreferrer"&gt;ClinicaQuery-AI on GitHub&lt;/a&gt;&lt;br&gt;
🔔 &lt;strong&gt;Follow me&lt;/strong&gt; here on Dev.to and on &lt;a href="https://github.com/Sowaiba-01" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. I build full-stack AI systems and write up every ugly detail, including the parts that took four hours to fix.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;I asked my medical research assistant a question about a paper it had never seen.&lt;/p&gt;

&lt;p&gt;It answered anyway.&lt;/p&gt;

&lt;p&gt;Fluent, confident, correctly formatted, with a citation and a green "high confidence" badge. Every part of that answer was invented. The paper it cited was about cardiovascular exercise. The question was about antibiotic dosing.&lt;/p&gt;

&lt;p&gt;Nothing in my system was broken. That is the terrifying part. Retrieval ran, found the five least-irrelevant paragraphs it had, handed them to the LLM, and the LLM did what LLMs do: it produced fluent text from whatever you give it. There was no error, no exception, no log line. Just a wrong answer wearing a lab coat.&lt;/p&gt;

&lt;p&gt;This is the failure mode nobody warns you about when you build your first RAG app. Your system cannot tell the difference between "I found the answer" and "I found five paragraphs." It has no concept of failure, so it can never report one.&lt;/p&gt;

&lt;p&gt;I spent two weeks fixing that. Here is everything I learned, including the dependency conflict that had no solution and the DLL error that forced me to build a better architecture than I originally planned.&lt;/p&gt;




&lt;h2&gt;
  
  
  First, what actually is RAG? (skip if you know)
&lt;/h2&gt;

&lt;p&gt;If you are new to this, here is the whole idea in one paragraph.&lt;/p&gt;

&lt;p&gt;An LLM does not know about your PDF. So before you ask it anything, you cut your PDF into chunks, convert each chunk into a list of numbers that captures its meaning (an "embedding"), and store those numbers in a database. When a question comes in, you convert the question into numbers too, find the chunks whose numbers are closest, and paste those chunks into the prompt. The model answers from text you handed it rather than from memory. That is Retrieval-Augmented Generation.&lt;/p&gt;

&lt;p&gt;Standard RAG is three steps:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;embed the question  -&amp;gt;  find the 5 closest chunks  -&amp;gt;  generate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works well enough in a demo to make you think you are done. You are not done. Those three steps have at least four serious weaknesses, and I want to walk through each one, because the fixes are the interesting part.&lt;/p&gt;




&lt;h2&gt;
  
  
  Weakness 1: your chunks don't know where they came from
&lt;/h2&gt;

&lt;p&gt;Here is a real chunk from a paper in my system:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"The reduction was 6.9 mmHg (95% CI: 5.2-8.6, p&amp;lt;0.001)."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Reduction in what? Measured in whom? After which intervention? You cannot tell, and neither can the embedding model. That chunk gets converted into a vector that means roughly "a number went down a bit," which is useless. It will never be retrieved for the question it actually answers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix, from Anthropic's contextual retrieval research:&lt;/strong&gt; before embedding a chunk, ask an LLM to write one sentence explaining where the chunk sits in its document, and glue that sentence to the front.&lt;/p&gt;

&lt;p&gt;The same chunk becomes:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"From a meta-analysis of aerobic exercise trials, section on blood pressure outcomes in hypertensive adults. The reduction was 6.9 mmHg (95% CI: 5.2-8.6, p&amp;lt;0.001)."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Now the embedding actually means something.&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="n"&gt;_CONTEXT_PROMPT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;document&amp;gt;
{doc_preview}
&amp;lt;/document&amp;gt;

Here is a chunk taken from that document:
&amp;lt;chunk&amp;gt;
{chunk}
&amp;lt;/chunk&amp;gt;

Write ONE short sentence (max 25 words) that situates this chunk within the
document, so the chunk can be understood and retrieved on its own.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It costs one small LLM call per chunk at upload time, and it runs in a thread pool so a 14-chunk paper takes about 6 seconds. Anthropic measured a 49% reduction in retrieval failures from this technique alone.&lt;/p&gt;

&lt;p&gt;One detail that matters: I store the original text separately in &lt;code&gt;node.metadata["raw_text"]&lt;/code&gt;. The context sentence helps the machine find the chunk. The user should still read what the paper actually said.&lt;/p&gt;




&lt;h2&gt;
  
  
  Weakness 2: vector search is bad at exact terms
&lt;/h2&gt;

&lt;p&gt;Ask a vector database about "eNOS phosphorylation at Ser1177" and it will cheerfully return a passage about nitric oxide signalling that never mentions Ser1177, because in embedding space those two things sit right next to each other.&lt;/p&gt;

&lt;p&gt;Embeddings capture meaning, and meaning is fuzzy by design. That is usually a feature. In medicine it is a liability, because medicine runs on exact strings: drug names, gene symbols, ICD codes, dosages, p-values. Those are precisely the tokens embeddings blur together.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix: run a keyword search alongside the vector search.&lt;/strong&gt; BM25 is the classic algorithm here and it finds literal tokens instantly.&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="n"&gt;_TOKEN_RE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[a-z0-9]+(?:[-_][a-z0-9]+)*&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;tokenize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;_TOKEN_RE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;_STOPWORDS&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That regex is deliberately built to keep &lt;code&gt;ser1177&lt;/code&gt;, &lt;code&gt;covid-19&lt;/code&gt;, and &lt;code&gt;il-6&lt;/code&gt; intact instead of shredding them into meaningless fragments.&lt;/p&gt;

&lt;p&gt;I used &lt;code&gt;rank_bm25&lt;/code&gt;, which runs in-process and persists to a pickle file. No Elasticsearch cluster, no Docker, no second service to keep alive. For a single-instance app that is exactly the right amount of infrastructure.&lt;/p&gt;




&lt;h2&gt;
  
  
  Weakness 3: you cannot just add the two scores together
&lt;/h2&gt;

&lt;p&gt;So now you have two ranked lists. Merging them is where most people quietly break their own system.&lt;/p&gt;

&lt;p&gt;The obvious move is to normalise both scores to 0-1 and add them. Do not do this. Cosine similarity is bounded and roughly normal. BM25 is unbounded and heavily skewed by document length. Min-max normalising them means a single outlier BM25 score squashes everything else into a narrow band, and your ranking silently becomes noise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix: throw the scores away and keep only the ranks.&lt;/strong&gt;&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;reciprocal_rank_fusion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ranked_lists&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;scores&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ranked_lists&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;nid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;node_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
            &lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;nid&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the entire algorithm. Reciprocal Rank Fusion, from a 2009 SIGIR paper. The constant 60 is a damper: rank 1 contributes 1/61 and rank 2 contributes 1/62, so no single list can dominate and a document has to place well in several lists to win.&lt;/p&gt;

&lt;p&gt;Elasticsearch, Weaviate, and Azure AI Search all use this. It beats carefully tuned score normalisation in most published comparisons. Six lines.&lt;/p&gt;

&lt;p&gt;There is a free bonus here too. If a chunk appears in both the vector results and the keyword results, that is a strong signal, so I track it and surface it in the UI as a "both" badge:&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="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;retriever_agreement&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ranks&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;nid&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Weakness 4: your retriever was never trained to compare
&lt;/h2&gt;

&lt;p&gt;This one is architectural and it is the biggest win available.&lt;/p&gt;

&lt;p&gt;Your vector database uses a &lt;strong&gt;bi-encoder&lt;/strong&gt;. It embedded the chunk at upload time, long before anyone asked anything, so that chunk's vector is a compressed summary produced with zero knowledge of the future question. Then it embeds your question separately and compares the two vectors. Fast, because everything is precomputed. Lossy, for the same reason.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;cross-encoder&lt;/strong&gt; reads the question and the chunk together, in one pass, and outputs a single relevance number. It can see that "150 minutes" in the passage answers "how many minutes" in the question. Nothing was compressed away in advance.&lt;/p&gt;

&lt;p&gt;The catch is that nothing can be precomputed, so it is one model call per candidate. You never run it over your whole corpus. The pattern is always:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;retrieve 30 candidates cheaply  -&amp;gt;  rerank those 30 precisely  -&amp;gt;  keep 5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In my UI, any source that got promoted by the reranker shows a green &lt;code&gt;+4&lt;/code&gt; badge. Expand it and you see &lt;code&gt;rank 7 to 2&lt;/code&gt;. That is the reranker catching something vector search ranked seventh and putting it where it belongs.&lt;/p&gt;




&lt;h2&gt;
  
  
  Weakness 5: the one that actually scared me
&lt;/h2&gt;

&lt;p&gt;Everything above makes retrieval better. None of it helps when the answer simply is not in your documents.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CRAG (Corrective RAG)&lt;/strong&gt; adds a grader between retrieval and generation, and crucially, gives the pipeline somewhere to go when the grade is bad:&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;max_score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.70&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;action&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;correct&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;      &lt;span class="c1"&gt;# generate now
&lt;/span&gt;&lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;max_score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.35&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;action&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ambiguous&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;    &lt;span class="c1"&gt;# rewrite the query, retrieve again, merge
&lt;/span&gt;&lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;action&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;incorrect&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;    &lt;span class="c1"&gt;# do not generate at all
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last branch is the whole reason I rebuilt this thing.&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;incorrect&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;span&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;7_generation&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;skipped&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
        &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reason&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;crag_graded_retrieval_as_incorrect&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;word&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;NO_ANSWER_RESPONSE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;word&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The generator never runs. There is no answer to generate, so none gets generated.&lt;/p&gt;

&lt;p&gt;One design note I went back and forth on: I grade on the &lt;strong&gt;best&lt;/strong&gt; document, not the average. One excellent source is enough to answer well, and averaging punishes a good retrieval that also happened to pull in a couple of loosely related chunks.&lt;/p&gt;

&lt;p&gt;Ask my cardiology paper about amoxicillin dosing now and it tells you, plainly, that the answer is not in the indexed documents. That is not a limitation. That is the feature.&lt;/p&gt;




&lt;h2&gt;
  
  
  Bonus: HyDE, the technique that sounds like a bug
&lt;/h2&gt;

&lt;p&gt;There is a quiet asymmetry in dense retrieval. You embed a &lt;strong&gt;question&lt;/strong&gt; and search against embeddings of &lt;strong&gt;answers&lt;/strong&gt;. Look at what those look like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;question: "How much does exercise lower blood pressure?"
passage : "Systolic blood pressure decreased by a mean of 6.9 mmHg
           across 89 trials."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Almost no shared vocabulary. Different grammar, different register. Their embeddings sit further apart than you would like, and the retriever is being asked to bridge a gap nobody trained it to bridge.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;HyDE closes it by moving the query into answer-space first.&lt;/strong&gt; Ask the LLM to write a plausible answer, ignore whether it is true, and embed &lt;em&gt;that&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Yes, you are deliberately generating a hallucination and searching with it. It works because the fake passage is shaped like the real passage, so it lands near it in vector space and drags the true source out of the index.&lt;/p&gt;

&lt;p&gt;The hallucination never reaches the user. It is discarded the moment retrieval finishes. Its only job is to have the right shape.&lt;/p&gt;




&lt;h2&gt;
  
  
  Now the part where everything broke
&lt;/h2&gt;

&lt;p&gt;Six clean layers. Beautiful architecture. Then I ran &lt;code&gt;pip install -r requirements.txt&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Act 1: the DLL that would not load
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ImportError: DLL load failed while importing onnxruntime_pybind11_state
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;ChromaDB imports &lt;code&gt;onnxruntime&lt;/code&gt; at module load for a default embedding function I never use, because my embeddings come from Gemini. Did not matter. The import has to succeed for ChromaDB to import at all, and the whole app died at line one.&lt;/p&gt;

&lt;p&gt;Pinning &lt;code&gt;onnxruntime&amp;lt;1.20&lt;/code&gt; fixed it. Which immediately caused:&lt;/p&gt;

&lt;h3&gt;
  
  
  Act 2: the fix that broke something else
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;opentelemetry-proto 1.42.1 requires protobuf&amp;lt;7.0,
but you have protobuf 7.35.1 which is incompatible.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Installing onnxruntime had pulled the newest protobuf, which broke ChromaDB's telemetry instrumentation. Pin protobuf. Move on.&lt;/p&gt;

&lt;h3&gt;
  
  
  Act 3: the conflict with no solution
&lt;/h3&gt;

&lt;p&gt;This is the one I want to talk about.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;llama-index 0.14.23 requires llama-index-llms-openai&amp;gt;=0.7.0
llama-index-llms-openai-like 0.5.3 requires llama-index-llms-openai&amp;lt;0.6
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read those two lines again. Package A needs version 7 or higher. Package B needs below version 6. Both are in my tree because &lt;code&gt;llama-index-llms-groq&lt;/code&gt; depends on &lt;code&gt;openai-like&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;There is no version of anything that satisfies both. I watched pip ping-pong between them four times, each install "fixing" the conflict by recreating the other half of it.&lt;/p&gt;

&lt;p&gt;I nearly downgraded the whole framework. Then I actually looked at what that package was doing for me.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One thing.&lt;/strong&gt; One single call:&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="n"&gt;Settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;llm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stream_complete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That was it. An entire branch of dependency hell to wrap an HTTP request I could make myself. And I was &lt;em&gt;already&lt;/em&gt; using the official Groq SDK directly in four other files: guardrails, HyDE, CRAG, and contextual chunking.&lt;/p&gt;

&lt;p&gt;So I deleted the dependency:&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="n"&gt;stream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_get_llm&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;_LLM_MODEL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;
    &lt;span class="n"&gt;temperature&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;full_answer&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;
        &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Conflict gone. Permanently. Fewer packages, less abstraction, more control over the stream.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The lesson I actually took from this:&lt;/strong&gt; when a dependency conflict has no solution, stop solving it and start asking what the dependency was buying you. Sometimes it is a framework. Sometimes it is a wrapper around six lines you can write yourself, and you have been renting a dependency tree to avoid typing them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Act 4: the error that improved the architecture
&lt;/h3&gt;

&lt;p&gt;Last one, and it is my favourite, because the error made the system genuinely better.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[reranker] local model unavailable
([WinError 1114] A dynamic link library (DLL) initialization
 routine failed. Error loading torch\lib\c10.dll)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PyTorch would not load on Windows. Which meant my cross-encoder would not load. Which meant the single highest-impact stage in the entire pipeline was dead, on a machine where nothing was actually wrong with my code.&lt;/p&gt;

&lt;p&gt;I could have fought the DLL. Instead I asked a better question: &lt;strong&gt;why is my most important stage sitting behind a 2GB native dependency with a known platform failure mode?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So I added a third tier. The reranker now picks the best backend available at boot:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Cohere rerank-v3.5&lt;/strong&gt; if an API key is set. Best accuracy money can buy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Local BGE cross-encoder&lt;/strong&gt; via sentence-transformers. Free, offline, fast.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Groq LLM-as-reranker.&lt;/strong&gt; Pure HTTP. No torch, no model download, no new key.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Tier 3 scores each candidate with a small parallel LLM call:&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="k"&gt;with&lt;/span&gt; &lt;span class="nc"&gt;ThreadPoolExecutor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_workers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;scores&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;_groq_score_one&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;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
        &lt;span class="n"&gt;candidates&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 here is entirely network-bound, so 20 sequential calls would add seconds while 20 concurrent calls cost roughly one. It is less precise than a real cross-encoder and burns more API quota, but it works on any machine with an internet connection.&lt;/p&gt;

&lt;p&gt;And if all three fail, the pipeline falls back to RRF order rather than crashing, and the trace records which backend actually ran.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;That is the real lesson of the whole project.&lt;/strong&gt; A DLL error on one laptop should degrade one stage, not delete a feature. Enterprise systems are not defined by having the best possible component. They are defined by what happens when the best possible component is unavailable.&lt;/p&gt;




&lt;h2&gt;
  
  
  You cannot improve what you cannot see
&lt;/h2&gt;

&lt;p&gt;Every stage is wrapped in a span:&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="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;span&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;5_rerank&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;documents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;backend&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;rerank&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;top_n&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;backend&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_reranker_name&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;promoted&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;promoted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;biggest_jump&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which produces a per-query timeline showing exactly where the milliseconds went:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;Typical&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;HyDE&lt;/td&gt;
&lt;td&gt;400 to 800ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vector search&lt;/td&gt;
&lt;td&gt;200 to 400ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BM25&lt;/td&gt;
&lt;td&gt;under 10ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RRF fusion&lt;/td&gt;
&lt;td&gt;under 5ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rerank&lt;/td&gt;
&lt;td&gt;150 to 400ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CRAG grading&lt;/td&gt;
&lt;td&gt;300 to 600ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Generation&lt;/td&gt;
&lt;td&gt;1 to 3s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;No LangSmith account, no Phoenix instance. A ring buffer, a JSONL file, and a dashboard page. About 200 lines.&lt;/p&gt;

&lt;p&gt;Two things it immediately taught me that I would never have guessed:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;BM25 is essentially free.&lt;/strong&gt; Under 10 milliseconds against 14 chunks. I had been mentally budgeting it as a real cost. It is a rounding error, and it is catching exact-term matches that vector search misses entirely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;HyDE is my second most expensive stage.&lt;/strong&gt; It costs more than reranking. That is a genuine trade-off I now get to make deliberately, with a number in front of me, instead of guessing.&lt;/p&gt;




&lt;h2&gt;
  
  
  The thing I am proudest of
&lt;/h2&gt;

&lt;p&gt;I put the pipeline toggles in the product.&lt;/p&gt;

&lt;p&gt;There is a panel above the chat input with four switches: HyDE, hybrid search, reranking, CRAG. Turn reranking off, ask the same question again, and watch the source ordering degrade in real time.&lt;/p&gt;

&lt;p&gt;That is an ablation study. In the UI. Running on the user's own documents.&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="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PipelineSettings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;use_hyde&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="n"&gt;use_hybrid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="n"&gt;use_rerank&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="n"&gt;use_crag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four booleans on the request body. Anyone can now verify my claims about this architecture on their own data instead of taking my word for it, and I can debug a bad answer by bisecting the pipeline live rather than editing code and restarting.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where it ended up
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. HyDE              write a hypothetical answer, embed that instead
2. Dense retrieval   vector search, top 20
3. Sparse retrieval  BM25 keyword search, top 20
4. RRF fusion        merge both lists by rank, not score
5. Cross-encoder     rerank the top 30, keep the best 5
6. CRAG grading      score relevance, rewrite and retry, or refuse
7. Generation        stream the answer
8. Guardrails        confidence score from lexical overlap plus LLM check
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From three steps to eight. Roughly 3 to 5 seconds per query instead of 1 to 2.&lt;/p&gt;

&lt;p&gt;Worth every millisecond, because the system can now do the one thing it could not do before: &lt;strong&gt;notice that it does not know.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  If you take one thing from this post
&lt;/h2&gt;

&lt;p&gt;Not the six techniques. Those are Googleable and half of them will have better versions in a year.&lt;/p&gt;

&lt;p&gt;Take this: &lt;strong&gt;your RAG system's most dangerous state is not being wrong. It is being wrong with no mechanism to notice.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Retrieval quality, reranking, hybrid search, all of it exists to reduce how often you land in that state. But reducing it is not the same as detecting it. Until you build something that can grade its own retrieval and refuse, you have not built a system that can fail safely. You have built one that fails silently, which in a medical context is considerably worse.&lt;/p&gt;




&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Everything here is open source and runs locally.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/Sowaiba-01/ClinicaQuery-AI" rel="noopener noreferrer"&gt;github.com/Sowaiba-01/ClinicaQuery-AI&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Stack: Next.js 15, FastAPI, ChromaDB, rank-bm25, Groq (Llama 3.3 70B), Gemini embeddings.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Papers worth reading if this interested you:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Contextual Retrieval (Anthropic, 2024)&lt;/li&gt;
&lt;li&gt;Precise Zero-Shot Dense Retrieval without Relevance Labels (HyDE, Gao et al. 2022)&lt;/li&gt;
&lt;li&gt;Corrective Retrieval Augmented Generation (Yan et al. 2024)&lt;/li&gt;
&lt;li&gt;Reciprocal Rank Fusion (Cormack et al., SIGIR 2009)&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  If this was useful
&lt;/h3&gt;

&lt;p&gt;⭐ &lt;strong&gt;&lt;a href="https://github.com/Sowaiba-01/ClinicaQuery-AI" rel="noopener noreferrer"&gt;Star the repo&lt;/a&gt;&lt;/strong&gt; so I know it landed&lt;br&gt;
 &lt;strong&gt;Follow me&lt;/strong&gt; for the next one. I am working on GraphRAG and multi-document reasoning&lt;br&gt;
&lt;strong&gt;Tell me in the comments&lt;/strong&gt; what your RAG system does when it does not know the answer. I genuinely want to know how other people solved this.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;And if you have ever shipped a RAG app that hallucinated confidently at a real user, say so. I would like this comment section to be the place people admit that.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>rag</category>
      <category>ai</category>
      <category>python</category>
      <category>webdev</category>
    </item>
    <item>
      <title>"I built a deepfake detector. Then I built the tool that proves it actually works."</title>
      <dc:creator>Sowaiba Arshad</dc:creator>
      <pubDate>Mon, 27 Jul 2026 14:36:58 +0000</pubDate>
      <link>https://dev.to/sowaiba01/i-built-a-deepfake-detector-then-i-built-the-tool-that-proves-it-actually-works-47f9</link>
      <guid>https://dev.to/sowaiba01/i-built-a-deepfake-detector-then-i-built-the-tool-that-proves-it-actually-works-47f9</guid>
      <description>&lt;p&gt;There's a moment every ML engineer knows and nobody really talks about.&lt;/p&gt;

&lt;p&gt;You finish training. The validation accuracy looks great. You run &lt;code&gt;model.predict()&lt;/code&gt;, it works, and you ship it.&lt;/p&gt;

&lt;p&gt;Then the quiet questions start.&lt;/p&gt;

&lt;p&gt;Is it still accurate, or is it slowly getting worse? Has the incoming data drifted away from what it trained on? When the model says it's "92% confident," is it actually right 92% of the time, or is it just loud? Is the input data even valid, or is some silent schema change quietly poisoning every prediction? And the one that actually keeps you up at night: how would you even know if something broke?&lt;/p&gt;

&lt;p&gt;Building a model is mostly a solved problem now. Knowing whether you can trust it once it's in production is not. That gap is the thing I wanted to close.&lt;/p&gt;

&lt;h2&gt;
  
  
  Meet ModelSentinel
&lt;/h2&gt;

&lt;p&gt;ModelSentinel is an open-source Python toolkit for everything that happens after &lt;code&gt;model.predict()&lt;/code&gt;. It evaluates, monitors, and stress-tests ML models through one consistent API.&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;modelsentinel&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;ms&lt;/span&gt;

&lt;span class="n"&gt;monitor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Monitor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;classification&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;my-model&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;monitor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;y_true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y_pred&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y_score&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;       &lt;span class="c1"&gt;# accuracy, F1, ROC-AUC, confusion matrix
&lt;/span&gt;&lt;span class="n"&gt;monitor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;calibration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;y_true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y_score&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;            &lt;span class="c1"&gt;# are the probabilities trustworthy?
&lt;/span&gt;&lt;span class="n"&gt;monitor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;detect_drift&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reference_df&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;live_df&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;     &lt;span class="c1"&gt;# has the data shifted since training?
&lt;/span&gt;&lt;span class="n"&gt;monitor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;profile_data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;live_df&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                   &lt;span class="c1"&gt;# missing values, dupes, outliers, schema
&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;monitor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;health_score&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="c1"&gt;# {'overall': 99.2, 'grade': 'EXCELLENT', 'components': {...}}
&lt;/span&gt;
&lt;span class="n"&gt;monitor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generate_report&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;report.html&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Everything rolls up into a single Model Health Score, one number you can set an alert on instead of squinting at five dashboards.&lt;/p&gt;
&lt;h2&gt;
  
  
  I didn't want to demo it on toy data, so I audited my own model
&lt;/h2&gt;

&lt;p&gt;Talk is cheap, so I pointed ModelSentinel at a real system I'd already built: DeepGuard, an EfficientNet-B4 deepfake detector. Not a toy example. The actual PyTorch model, the actual weights, the actual face images.&lt;/p&gt;

&lt;p&gt;And I didn't just run it once. I tested it against two different datasets built with two different face-generation methods: a held-out split of the 140k Real and Fake Faces set (StyleGAN style), and a separate set I generated myself with &lt;code&gt;inswapper_128&lt;/code&gt; face-swapping. If the detector had only memorized one kind of fake, this would have caught it.&lt;/p&gt;

&lt;p&gt;Here's what ModelSentinel reported, straight from the model:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;140k test split (held-out)&lt;/th&gt;
&lt;th&gt;inswapper_128 set&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Accuracy&lt;/td&gt;
&lt;td&gt;0.9975&lt;/td&gt;
&lt;td&gt;0.9938&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;F1&lt;/td&gt;
&lt;td&gt;0.9975&lt;/td&gt;
&lt;td&gt;0.9938&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ROC-AUC&lt;/td&gt;
&lt;td&gt;0.99999&lt;/td&gt;
&lt;td&gt;0.9989&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Brier / ECE&lt;/td&gt;
&lt;td&gt;0.0028 / 0.0075&lt;/td&gt;
&lt;td&gt;0.0086 / 0.0147&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Health Score&lt;/td&gt;
&lt;td&gt;99.65 (EXCELLENT)&lt;/td&gt;
&lt;td&gt;99.23 (EXCELLENT)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Confusion matrices (rows = true &lt;code&gt;[real, fake]&lt;/code&gt;): &lt;code&gt;[[398, 2], [0, 400]]&lt;/code&gt; and &lt;code&gt;[[396, 4], [1, 399]]&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Two things jumped out that a plain accuracy number would never have told me. First, the detector stays above 99% on both generation methods, so it's generalizing across techniques rather than just fitting its training distribution. Second, the very low ECE (0.0075) means the confidence scores are actually calibrated. When DeepGuard says 95%, you can more or less believe it. That's a claim you can only make after you measure calibration, which most projects just never do.&lt;/p&gt;

&lt;p&gt;I'll be honest about one thing. One of those datasets is the same domain the model trained on, so treat 99% as an upper bound rather than a promise about the messy real world. The point is that ModelSentinel gives you the numbers to reason about it at all.&lt;/p&gt;
&lt;h2&gt;
  
  
  What's actually under the hood
&lt;/h2&gt;

&lt;p&gt;No magic, and nothing that pretends "AI is monitoring your AI." It's just solid, well-understood statistics wired together cleanly.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Evaluation: classification and regression metrics, plus probability calibration (Brier, ECE, MCE) and decision-threshold tuning (F1 and Youden's J).&lt;/li&gt;
&lt;li&gt;Drift detection: Kolmogorov-Smirnov and Population Stability Index for numeric features, chi-square and Jensen-Shannon divergence for categorical ones, all aggregated into a single dataset-level verdict.&lt;/li&gt;
&lt;li&gt;Data quality: missing values, duplicates, constant columns, IQR outliers, and schema validation between training and production.&lt;/li&gt;
&lt;li&gt;Health score: a weighted roll-up that renormalizes gracefully when one of the checks is missing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The metrics match a hand computation to six decimal places, because underneath it leans on scikit-learn and scipy, the boring and battle-tested stuff. ModelSentinel's job is just to make all of it effortless to use.&lt;/p&gt;
&lt;h2&gt;
  
  
  Built like a library, not a notebook
&lt;/h2&gt;

&lt;p&gt;This is the part I care about most. A reliability tool that isn't reliable itself is a bit of a joke, so I built ModelSentinel like a real open-source project. It's fully typed and documented, has a &lt;code&gt;pytest&lt;/code&gt; suite, passes &lt;code&gt;ruff&lt;/code&gt; linting cleanly, runs CI across Python 3.9 to 3.12, ships as a buildable wheel, includes real benchmarks with timings, and keeps a semantic-versioned changelog. You install it with &lt;code&gt;pip install -e ".[dev]"&lt;/code&gt; and the tests go green on a clean machine.&lt;/p&gt;
&lt;h2&gt;
  
  
  Where it's going
&lt;/h2&gt;

&lt;p&gt;ModelSentinel ships new versions regularly, and the roadmap is deliberately ambitious.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;v0.4: explainability (Grad-CAM and SHAP) and framework adapters for scikit-learn, PyTorch, and TensorFlow.&lt;/li&gt;
&lt;li&gt;v0.5: a FastAPI monitoring server with real-time drift.&lt;/li&gt;
&lt;li&gt;v0.6 and beyond: LLM and RAG evaluation, including hallucination, faithfulness, and toxicity checks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The goal is one toolkit that can answer "can I trust this model?" whether the model is a classifier, a vision network, or an LLM.&lt;/p&gt;
&lt;h2&gt;
  
  
  Try it, break it, build on it
&lt;/h2&gt;

&lt;p&gt;ModelSentinel is MIT-licensed and open to contributions. If you've ever shipped a model and felt that little flicker of doubt about whether it's still working, this one is for you.&lt;/p&gt;

&lt;p&gt;Star the repo, fork it, and tell me which reliability check you'd want next. New versions are landing regularly, and honestly the best ideas tend to come from people who have been burned by a silent model failure.&lt;/p&gt;

&lt;p&gt;Built by Sowaiba Arshad. Feedback and PRs genuinely welcome.&lt;br&gt;
&lt;/p&gt;
&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/Sowaiba-01" rel="noopener noreferrer"&gt;
        Sowaiba-01
      &lt;/a&gt; / &lt;a href="https://github.com/Sowaiba-01/ModelSentinel" rel="noopener noreferrer"&gt;
        ModelSentinel
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Open-source Python toolkit that checks whether a trained ML model is still healthy: metrics, data drift, calibration, data quality, and a weighted health score.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;ModelSentinel&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;AI reliability &amp;amp; observability toolkit&lt;/strong&gt; — monitor, evaluate, explain, and protect machine-learning models with a single, consistent Python API.&lt;/p&gt;

&lt;p&gt;&lt;a rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/5266ef63d0fec1ad4cee104c7aee0ad0f084d6c1388d39adfe556c420a0a28d2/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f707974686f6e2d332e392532422d626c7565"&gt;&lt;img src="https://camo.githubusercontent.com/5266ef63d0fec1ad4cee104c7aee0ad0f084d6c1388d39adfe556c420a0a28d2/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f707974686f6e2d332e392532422d626c7565" alt="Python"&gt;&lt;/a&gt;
&lt;a rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/f8df3091bbe1149f398a5369b2c39e896766f9f6efba3477c63e9b4aa940ef14/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d4d49542d677265656e"&gt;&lt;img src="https://camo.githubusercontent.com/f8df3091bbe1149f398a5369b2c39e896766f9f6efba3477c63e9b4aa940ef14/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d4d49542d677265656e" alt="License"&gt;&lt;/a&gt;
&lt;a rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/f165840b0b13ca742cb88e6b78873bcaea18cfb343d8e8ec7476bd29a64de714/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f7374617475732d626574612d6f72616e6765"&gt;&lt;img src="https://camo.githubusercontent.com/f165840b0b13ca742cb88e6b78873bcaea18cfb343d8e8ec7476bd29a64de714/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f7374617475732d626574612d6f72616e6765" alt="Status"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Shipping a model is easy. Knowing whether it is still trustworthy in production is not. ModelSentinel answers the questions that come &lt;em&gt;after&lt;/em&gt; &lt;code&gt;model.predict()&lt;/code&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Is my model still accurate, or is performance quietly degrading?&lt;/li&gt;
&lt;li&gt;Has the incoming data distribution drifted away from training?&lt;/li&gt;
&lt;li&gt;Is the input data even valid — missing values, duplicates, schema changes?&lt;/li&gt;
&lt;li&gt;Are my predicted probabilities calibrated, or overconfident?&lt;/li&gt;
&lt;li&gt;What single number tells me if this model is healthy right now?&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Install&lt;/h2&gt;
&lt;/div&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;pip install modelsentinel          &lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; from PyPI (once published)&lt;/span&gt;
pip install -e &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;.[dev]&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;            &lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; from source, with dev tools&lt;/span&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;30-second quick start&lt;/h2&gt;
&lt;/div&gt;
&lt;div class="highlight highlight-source-python notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;&lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-s1"&gt;modelsentinel&lt;/span&gt; &lt;span class="pl-k"&gt;as&lt;/span&gt; &lt;span class="pl-s1"&gt;ms&lt;/span&gt;
&lt;span class="pl-s1"&gt;monitor&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;ms&lt;/span&gt;.&lt;span class="pl-c1"&gt;Monitor&lt;/span&gt;(&lt;span class="pl-s1"&gt;task&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s"&gt;"classification"&lt;/span&gt;, &lt;span class="pl-s1"&gt;name&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s"&gt;"DeepGuard-B4"&lt;/span&gt;)

&lt;span class="pl-c"&gt;# 1. How good are the predictions?&lt;/span&gt;
&lt;span class="pl-s1"&gt;monitor&lt;/span&gt;.&lt;span class="pl-c1"&gt;evaluate&lt;/span&gt;(&lt;span class="pl-s1"&gt;y_true&lt;/span&gt;, &lt;span class="pl-s1"&gt;y_pred&lt;/span&gt;&lt;/pre&gt;…
&lt;/div&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/Sowaiba-01/ModelSentinel" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


</description>
      <category>machinelearning</category>
      <category>python</category>
      <category>opensource</category>
      <category>mlops</category>
    </item>
    <item>
      <title>I Opened a GitHub Issue and Four AI Agents Fixed It Without Me Touching a Single Line of Code</title>
      <dc:creator>Sowaiba Arshad</dc:creator>
      <pubDate>Sun, 26 Jul 2026 07:30:52 +0000</pubDate>
      <link>https://dev.to/sowaiba01/i-opened-a-github-issue-and-four-ai-agents-fixed-it-without-me-touching-a-single-line-of-code-1pg5</link>
      <guid>https://dev.to/sowaiba01/i-opened-a-github-issue-and-four-ai-agents-fixed-it-without-me-touching-a-single-line-of-code-1pg5</guid>
      <description>&lt;p&gt;I want to be honest with you. When I started this project I did not actually believe it would work.&lt;/p&gt;

&lt;p&gt;Not in a humble way. I mean I genuinely thought the idea was too ambitious for one person to pull off with free API tiers and a laptop. Fully autonomous AI that reads a GitHub issue,understands a codebase, writes working code, runs tests, gets the code reviewed by another AI, and opens a pull request. No human in the loop. No clicking approve at any step.&lt;/p&gt;

&lt;p&gt;But it works. And the first time it actually completed a full run I just sat there staring at the pull request on GitHub for a solid minute.&lt;/p&gt;




&lt;h2&gt;
  
  
  The moment that started it
&lt;/h2&gt;

&lt;p&gt;I was reviewing a bug ticket at 1am. It was a simple fix. Like embarrassingly simple. Change a function name, update two tests. Fifteen minutes of actual work, forty minutes of context switching, finding the right file, remembering where the tests live, writing the PR description.&lt;/p&gt;

&lt;p&gt;And I thought, what if I just did not have to do that.&lt;/p&gt;

&lt;p&gt;Not "what if AI helped me type faster." What if I described the problem in plain English and walked away.&lt;/p&gt;




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

&lt;p&gt;Four agents. Each one with a specific job.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;Architect&lt;/strong&gt; reads the repo, opens the most relevant files, understands the codebase structure, and produces a concrete implementation plan. It posts that plan as a comment on your GitHub issue so you can see its thinking.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;Coder&lt;/strong&gt; spins up a fresh cloud sandbox, clones your repo, writes the code following the architect's plan, runs the linter, commits, and runs the test suite. If tests fail it reads the error, adjusts, and tries again. It gets three attempts.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;Reviewer&lt;/strong&gt; reads the git diff line by line. Checks for hardcoded secrets, SQL injection risk, missing input validation, bad error handling. Returns APPROVED or sends it back with a specific reason.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;PR Agent&lt;/strong&gt; opens a draft pull request with a full description of what changed, why it changed, and what the test output was.&lt;/p&gt;

&lt;p&gt;The whole thing runs in about four minutes on a simple feature request.&lt;/p&gt;




&lt;h2&gt;
  
  
  What makes this different from just calling GPT
&lt;/h2&gt;

&lt;p&gt;A lot of "autonomous AI" demos are one agent calling a few tools in a loop until something works. This is different in a way that matters.&lt;/p&gt;

&lt;p&gt;The agents have completely separate roles, separate tools, and they cannot see each other's internal reasoning. The Architect does not know what the Coder is thinking. The Reviewer does not know what the Architect planned. Each one gets only what it needs.&lt;/p&gt;

&lt;p&gt;The supervisor that routes between them is pure Python if/else logic. No LLM deciding what to do next. That was a deliberate choice. I wanted the control flow to be predictable, not something that could hallucinate its way into an infinite loop.&lt;/p&gt;

&lt;p&gt;The code actually runs. This is the part people underestimate. It is not simulated. The Coder has a real Ubuntu sandbox with real git, real compilers, real test runners. When it runs tests, those are actual test results. When a test fails, it is a real failure. The agent reads the real error output and has to actually fix it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The stack I used
&lt;/h2&gt;

&lt;p&gt;All of this runs on free tiers.&lt;/p&gt;

&lt;p&gt;Groq gives you 500,000 tokens per day on Llama 3.1. That is enough to run the swarm several times a day without paying anything. E2B gives you cloud sandboxes that spin up in seconds and run arbitrary code safely. LangGraph handles the agent graph. FastAPI serves the backend. Next.js streams live agent logs to the browser over WebSocket so you can watch every thought the agents have in real time.&lt;/p&gt;

&lt;p&gt;The whole thing deploys with one command.&lt;/p&gt;




&lt;h2&gt;
  
  
  The part nobody talks about
&lt;/h2&gt;

&lt;p&gt;Building autonomous agents sounds clean in blog posts. The reality is that AI models at this scale do genuinely weird things.&lt;/p&gt;

&lt;p&gt;The model tried to call a tool that did not exist. Eleven times in a row. Because I told it to in the system prompt and forgot to actually add the tool.&lt;/p&gt;

&lt;p&gt;It hit a 13,000 token request on a 12,000 token limit because the message history grew with every tool result appended.&lt;/p&gt;

&lt;p&gt;The first git clone always failed because the cloud sandbox was still booting when the agent tried to use it.&lt;/p&gt;

&lt;p&gt;The test runner looped forever on a Java project because there was no Java branch in the detection logic and the model kept retrying pytest on a codebase with zero Python files.&lt;/p&gt;

&lt;p&gt;These are not theoretical edge cases. These all happened on the first real test run. Fixing them one by one until the swarm ran clean is what actually took the most time.&lt;/p&gt;




&lt;h2&gt;
  
  
  What it feels like when it works
&lt;/h2&gt;

&lt;p&gt;I created a GitHub issue on my old JavaFX Brick Breaker game asking for a high score tracker. I typed the description, hit submit, and watched the logs in the browser.&lt;/p&gt;

&lt;p&gt;The Architect opened BrickBreak.java, read 800 lines of game code, understood how scoring worked, and posted a plan to the GitHub issue. The Coder created ScoreManager.java from scratch, wired it into the game, compiled both files, verified they linked correctly. The Reviewer checked the diff and approved. A pull request appeared on GitHub with a description that explained exactly what was done and why.&lt;/p&gt;

&lt;p&gt;I did not write a single line of that.&lt;/p&gt;

&lt;p&gt;There is something genuinely strange about watching a machine understand your code and extend it correctly. It does not feel like autocomplete. It feels like something crossed a line.&lt;/p&gt;




&lt;h2&gt;
  
  
  What this actually is
&lt;/h2&gt;

&lt;p&gt;This is a personal project. &lt;strong&gt;Not a product, not a startup, not something with an uptime SLA&lt;/strong&gt;. I built it to see if I could, and to understand how multi-agent systems actually work under the hood rather than just reading about them.&lt;/p&gt;

&lt;p&gt;It is not production ready. The agents still make mistakes on complex codebases. The Architect sometimes misreads project structure. The Coder occasionally writes code that compiles but misses the intent. That is fine. The point was never a perfect system.&lt;/p&gt;

&lt;p&gt;The point was to build something real, hit the actual problems, and solve them one by one until it worked. And it works.&lt;/p&gt;

&lt;p&gt;I learned more about LLM context management, token budgets, deterministic vs LLM-driven routing, and sandbox execution environments from this one project than from anything else I have built. That is the real value.&lt;/p&gt;




&lt;h2&gt;
  
  
  It is open source
&lt;/h2&gt;

&lt;p&gt;If you want to dig into the code, run it locally, or steal ideas for your own agent project:&lt;/p&gt;

&lt;p&gt;github.com/Sowaiba-01/devops-swarm&lt;/p&gt;

&lt;p&gt;You need three free API keys: Groq, GitHub, and E2B. Docker Compose handles the rest. The live log stream in the browser is genuinely fun to watch.&lt;/p&gt;




</description>
      <category>ai</category>
      <category>programming</category>
      <category>webdev</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Why My Medical AI Took 6.4 Seconds Per Scan and How I Got It to 3.1.</title>
      <dc:creator>Sowaiba Arshad</dc:creator>
      <pubDate>Sat, 25 Jul 2026 08:31:16 +0000</pubDate>
      <link>https://dev.to/sowaiba01/why-my-medical-ai-took-64-seconds-per-scan-and-how-i-got-it-to-31-57i2</link>
      <guid>https://dev.to/sowaiba01/why-my-medical-ai-took-64-seconds-per-scan-and-how-i-got-it-to-31-57i2</guid>
      <description>&lt;p&gt;I built a chest X-ray diagnostic platform called &lt;strong&gt;ThoraxNet&lt;/strong&gt;. It detects 14 thoracic pathologies from a single image, reports how confident it is using Monte Carlo Dropout, draws **GradCAM **heatmaps over the regions that drove each prediction, and writes a structured radiology report with an LLM.&lt;/p&gt;

&lt;p&gt;The model was the interesting part to build. It was not the part that decided whether the thing felt like a product. That came down to one unglamorous number: &lt;strong&gt;&lt;em&gt;how long a user waits after they hit "analyze."&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This post is about how I took that wait from 6.4 seconds to 3.1 seconds, why the honest answer is "about 2x and not 10x," and the two bugs I only found because I stopped trusting the happy path and read the logs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Live demo: &lt;a href="https://thorax-tho.vercel.app" rel="noopener noreferrer"&gt;https://thorax-tho.vercel.app&lt;/a&gt;&lt;br&gt;
Code: &lt;a href="https://github.com/Sowaiba-01/ThoraxNet" rel="noopener noreferrer"&gt;https://github.com/Sowaiba-01/ThoraxNet&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The problem: a good model wrapped in a slow request
&lt;/h2&gt;

&lt;p&gt;The model worked. The demo worked. But every scan took roughly six and a half seconds, and a six second spinner makes anything feel broken no matter how good the output underneath it is.&lt;/p&gt;

&lt;p&gt;My first instinct was the same wrong instinct most people have: "the vision transformer forward pass must be the bottleneck, so let me go optimize the model." I have shipped enough regressions chasing that kind of hunch to know it is worth nothing until it is measured.&lt;/p&gt;

&lt;p&gt;So before changing a single line of model code, I instrumented the pipeline to report how long each stage actually took, and returned that breakdown on every single response:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"stage_timings_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"preprocess"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mc_dropout"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2868&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"gradcam"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"report"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;92&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result was not what I expected, and that is exactly why measuring first matters. A single forward pass through the transformer is about 15 milliseconds. The model was never the problem. Monte Carlo Dropout was, and it was eating nearly all of the server side budget.&lt;/p&gt;

&lt;h2&gt;
  
  
  The root cause: twenty forward passes, one at a time
&lt;/h2&gt;

&lt;p&gt;Monte Carlo Dropout estimates uncertainty by running the model many times with dropout left switched on, then measuring how much the predictions move. My original implementation did the obvious thing:&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="n"&gt;samples&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n_samples&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;        &lt;span class="c1"&gt;# n_samples = 20
&lt;/span&gt;    &lt;span class="n"&gt;logits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;             &lt;span class="c1"&gt;# one image, batch size 1
&lt;/span&gt;    &lt;span class="n"&gt;samples&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sigmoid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;logits&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twenty sequential forward passes, each with a batch size of one. Every pass pays the full per call overhead, and the hardware spends most of its time waiting between launches instead of computing.&lt;/p&gt;

&lt;p&gt;The fix is to stop asking twenty separate times and ask once. Tile the single image into a batch of twenty and run one forward pass:&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="n"&gt;tiled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;repeat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n_samples&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# (20, 3, 224, 224)
&lt;/span&gt;&lt;span class="n"&gt;logits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tiled&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                  &lt;span class="c1"&gt;# ONE forward pass
&lt;/span&gt;&lt;span class="n"&gt;probs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sigmoid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;logits&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;view&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n_samples&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;probs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;probs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;std&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The correctness argument is the part worth understanding, and it is the exact question a good interviewer will ask you. Are those twenty copies not identical? No. Dropout samples a fresh mask for every element in the batch. So the twenty tiled copies each get an independent dropout mask, which means they are exactly the twenty independent stochastic samples the estimator needs. Same statistics, one launch instead of twenty.&lt;/p&gt;

&lt;p&gt;I did not want to trust that reasoning on faith, so I wrote a test that runs both the old sequential version and the new batched version four hundred times each and asserts their means agree within Monte Carlo error:&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="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;allclose&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;batched_mean&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sequential_mean&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;atol&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That test is the difference between "I think this is equivalent" and "I proved this is equivalent."&lt;/p&gt;

&lt;h2&gt;
  
  
  The results, measured on identical hardware
&lt;/h2&gt;

&lt;p&gt;Same host, same image, same protocol of thirty requests with warmup discarded. The only variable is the code.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Before (v1.0.0)&lt;/th&gt;
&lt;th&gt;After (v1.1.0)&lt;/th&gt;
&lt;th&gt;Improvement&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;p50 latency&lt;/td&gt;
&lt;td&gt;6,387 ms&lt;/td&gt;
&lt;td&gt;3,127 ms&lt;/td&gt;
&lt;td&gt;1.9x faster&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p95 latency&lt;/td&gt;
&lt;td&gt;7,525 ms&lt;/td&gt;
&lt;td&gt;3,862 ms&lt;/td&gt;
&lt;td&gt;1.9x faster&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p99 latency&lt;/td&gt;
&lt;td&gt;8,026 ms&lt;/td&gt;
&lt;td&gt;4,768 ms&lt;/td&gt;
&lt;td&gt;1.7x faster&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Throughput&lt;/td&gt;
&lt;td&gt;0.15 req/s&lt;/td&gt;
&lt;td&gt;0.30 req/s&lt;/td&gt;
&lt;td&gt;2.0x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every percentile improved by roughly 2x, with no change to the model weights and no change to accuracy. This is purely a change in how the model is executed.&lt;/p&gt;

&lt;p&gt;Two smaller changes cleaned up the rest of the request path. The LLM report call was moved off the critical path onto a worker thread, so the user gets their pathology result without waiting on report generation. And GradCAM heatmaps, which were being recomputed on every request, are now cached per image and class.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest part: why it is 2x and not 10x
&lt;/h2&gt;

&lt;p&gt;Here is where it would be easy to lie, and where lying would eventually cost me an offer.&lt;/p&gt;

&lt;p&gt;On a GPU, this exact change is often close to 10x. The entire win comes from keeping an accelerator busy that was otherwise sitting idle between twenty tiny kernel launches.&lt;/p&gt;

&lt;p&gt;ThoraxNet runs on a free tier CPU host with two virtual cores. There is far less idle parallelism to reclaim there. So the same code change gives roughly 2x, not roughly 10x.&lt;/p&gt;

&lt;p&gt;I could have written "10x faster" in the title and most readers would never have checked. But the number that survives a technical interview is the one you can explain. The honest version is: 2x on CPU, because the batching win is bounded by how much idle parallelism there is to reclaim, and Monte Carlo Dropout on CPU is still the dominant cost, so the next real lever is GPU inference or quantization, not more batching. That sentence is worth more than a bigger number I cannot defend.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two bugs I found by reading the whole path, not the ticket
&lt;/h2&gt;

&lt;p&gt;Profiling forced me to read the entire request path from HTTP call to response. Two problems fell out that had nothing to do with latency.&lt;/p&gt;

&lt;p&gt;GradCAM had never worked. The route handler read an attribute on the pipeline that was never actually assigned anywhere. The inference code built the heatmaps into a local variable and then dropped them when the function returned. Every heatmap request came back as a 404. Nothing logged an error, and the frontend simply rendered an empty panel, so it shipped and sat broken because no code path ever raised. I fixed it and pinned it with a regression test that fails if the overlays are not recorded, because a silent bug earns a loud test.&lt;/p&gt;

&lt;p&gt;Every radiology report was quietly failing. While watching the deploy logs I saw this on repeat:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The model `llama3-70b-8192` has been decommissioned and is no longer supported.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The LLM provider had retired that model months earlier. Every report request was returning a 400 and falling back to a template, so users were getting canned text instead of a generated report, and nobody noticed because the fallback made it look fine. The fix was one line to point at the current model, plus an environment variable so the next deprecation is a config change and not a code change.&lt;/p&gt;

&lt;p&gt;Neither of these was in my task list. Both were only visible because I stopped trusting the happy path and actually read what the server was doing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do next
&lt;/h2&gt;

&lt;p&gt;Monte Carlo Dropout still dominates at roughly 2.8 seconds. Batching took the easy win. The remaining levers are honest about the hardware rather than clever in the request path:&lt;/p&gt;

&lt;p&gt;GPU or quantized inference to cut the per request cost of the twenty passes. The export and evaluation scripts are already written, and the accuracy delta table is the homework I still owe.&lt;/p&gt;

&lt;p&gt;A shared cache for the GradCAM session store, since it currently lives in process memory that will not survive a restart or a second replica.&lt;/p&gt;

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

&lt;p&gt;The model was 15 milliseconds. The product was 6.4 seconds. The entire gap lived in how the model was called, not in the model itself, and I only found that because I measured before I touched anything and read the logs instead of the ticket.&lt;/p&gt;

&lt;p&gt;The unglamorous work is the work. Profiling first, proving equivalence with a test, and being honest about a 2x instead of inflating it to a 10x are the habits that hold up when someone actually reads your code.&lt;/p&gt;




&lt;p&gt;If you found this useful, follow me on GitHub for more write ups like this: &lt;strong&gt;&lt;a href="https://github.com/Sowaiba-01" rel="noopener noreferrer"&gt;https://github.com/Sowaiba-01&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The full project is open source. Fork it, build on it, and if it helped you or you just think it is cool, a star means a lot: &lt;strong&gt;&lt;a href="https://github.com/Sowaiba-01/ThoraxNet" rel="noopener noreferrer"&gt;https://github.com/Sowaiba-01/ThoraxNet&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;ThoraxNet is for research use only. It is not FDA cleared and is not a substitute for a radiologist.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>machinelearning</category>
      <category>pytorch</category>
    </item>
  </channel>
</rss>
