<?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: Ponsubash Raj R</title>
    <description>The latest articles on DEV Community by Ponsubash Raj R (@justatalentedguy).</description>
    <link>https://dev.to/justatalentedguy</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%2F1212790%2F109d6513-fdf4-4015-8b7f-e26edf195ae8.jpg</url>
      <title>DEV Community: Ponsubash Raj R</title>
      <link>https://dev.to/justatalentedguy</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/justatalentedguy"/>
    <language>en</language>
    <item>
      <title>Free-Tier AI Made Me Build a Traffic Cop for Tokens</title>
      <dc:creator>Ponsubash Raj R</dc:creator>
      <pubDate>Sun, 26 Jul 2026 07:21:56 +0000</pubDate>
      <link>https://dev.to/justatalentedguy/free-tier-ai-made-me-build-a-traffic-cop-for-tokens-4d3m</link>
      <guid>https://dev.to/justatalentedguy/free-tier-ai-made-me-build-a-traffic-cop-for-tokens-4d3m</guid>
      <description>&lt;h2&gt;
  
  
  My Project Brief
&lt;/h2&gt;

&lt;p&gt;CourseFlow is my attempt to solve a very practical problem: long YouTube courses are useful, but consuming them is slow, messy, and usually trapped inside video timelines.&lt;/p&gt;

&lt;p&gt;So I built a system that takes a course playlist and turns it into structured learning material:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;transcripts&lt;/li&gt;
&lt;li&gt;cleaned lesson notes&lt;/li&gt;
&lt;li&gt;Anki flashcards&lt;/li&gt;
&lt;li&gt;course-level exports&lt;/li&gt;
&lt;li&gt;optional diagrams&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;PROJECT REPOSITORY: &lt;a href="https://github.com/JustATalentedGuy/courseflow" rel="noopener noreferrer"&gt;Open&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The fun part is that this is not a toy “summarize one video” app. It has to process entire playlists, sometimes 50 or 60 videos long. That means many transcript chunks, many LLM calls, many retries, and many ways to discover that free-tier AI is not free from consequences.&lt;/p&gt;

&lt;p&gt;The first version worked locally. Then I started asking the real question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Can this process a full course without wasting API quota, crashing halfway, or pretending 429 errors are a lifestyle?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is where the quota-aware scheduler came in.&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzujmynfkuxwwkt53nq0a.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzujmynfkuxwwkt53nq0a.png" alt="Scheduling Jobs" width="800" height="468"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Local Architecture
&lt;/h2&gt;

&lt;p&gt;For this blog, I am focusing only on the local architecture.&lt;/p&gt;

&lt;p&gt;The local setup uses a normal distributed application shape, just running on one machine:&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmaid4ti8fqzd5b3r7rra.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmaid4ti8fqzd5b3r7rra.png" alt="Architecture" width="800" height="1200"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The frontend starts course processing. The backend stores the course, videos, jobs, notes, and usage records. Celery workers handle the slow work. Redis handles fast coordination. PostgreSQL stores durable truth.&lt;/p&gt;

&lt;p&gt;That split matters.&lt;/p&gt;

&lt;p&gt;Redis is fast, but temporary. PostgreSQL is slower, but trustworthy. &lt;/p&gt;

&lt;p&gt;The AI provider is external, rate-limited, and has opinions. &lt;/p&gt;

&lt;p&gt;Celery workers are concurrent and mildly chaotic, as workers usually are when left unsupervised.&lt;/p&gt;

&lt;p&gt;So the core design rule became simple:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Redis decides who may go now. PostgreSQL remembers what actually happened.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That one sentence saved the system from a surprising amount of pain.&lt;/p&gt;




&lt;h2&gt;
  
  
  AI APIs Are Shared Distributed Resources
&lt;/h2&gt;

&lt;p&gt;At first glance, an LLM API looks like a function 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;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cute.&lt;/p&gt;

&lt;p&gt;In reality, it behaves more like a shared distributed resource with multiple constraints:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;requests per minute/requests per day&lt;/li&gt;
&lt;li&gt;tokens per minute/tokens per day&lt;/li&gt;
&lt;li&gt;audio seconds per hour&lt;/li&gt;
&lt;li&gt;model-specific limits&lt;/li&gt;
&lt;li&gt;organization-level limits&lt;/li&gt;
&lt;li&gt;retry windows&lt;/li&gt;
&lt;li&gt;partial failures&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So if five workers call the API at the same time, they are not five independent workers anymore. They are five people drinking from the same bottle and acting shocked when it becomes empty.&lt;/p&gt;

&lt;p&gt;This is why local “sleep for 10 seconds” throttling was not enough. It works only when one worker exists, the moon is aligned, and no request uses more tokens than expected.&lt;/p&gt;

&lt;p&gt;CourseFlow needed an organization-wide scheduler.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Useful Part: Groq Gives Rate Limit Headers
&lt;/h2&gt;

&lt;p&gt;Groq exposes rate limit information through response headers, including:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;x-ratelimit-limit-requests&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;x-ratelimit-remaining-requests&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;x-ratelimit-reset-requests&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;x-ratelimit-limit-tokens&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;x-ratelimit-remaining-tokens&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;x-ratelimit-reset-tokens&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;retry-after&lt;/code&gt; for 429 responses&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Reference: &lt;a href="https://console.groq.com/docs/rate-limits" rel="noopener noreferrer"&gt;Groq rate limit documentation&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That is extremely useful because the server is the source of truth. Local counters are helpful, but the provider knows the real remaining quota.&lt;/p&gt;

&lt;p&gt;So the scheduler treats provider headers as authoritative whenever available.&lt;/p&gt;

&lt;p&gt;A simplified version of the header parsing looks like:&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;parse_groq_headers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;headers&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rpd_remaining&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;headers&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;x-ratelimit-remaining-requests&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rpd_reset&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;parse_duration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;headers&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;x-ratelimit-reset-requests&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;tpm_remaining&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;headers&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;x-ratelimit-remaining-tokens&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tpm_reset&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;parse_duration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;headers&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;x-ratelimit-reset-tokens&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;retry_after&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;parse_seconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;headers&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;retry-after&lt;/span&gt;&lt;span class="sh"&gt;"&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;h2&gt;
  
  
  Redis Atomic Reservations: The Bouncer at the Door
&lt;/h2&gt;

&lt;p&gt;The biggest risk with concurrent workers is double-spending quota.&lt;/p&gt;

&lt;p&gt;Imagine Redis says there are 2 requests left. Three workers check at the same time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Worker A: sees 2 left
Worker B: sees 2 left
Worker C: sees 2 left
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All three proceed.&lt;/p&gt;

&lt;p&gt;Congratulations, you have now duplicated the source of spending.&lt;/p&gt;

&lt;p&gt;To prevent that, CourseFlow uses atomic Redis reservations. The decision and the counter update must happen together.&lt;/p&gt;

&lt;p&gt;Conceptually, the reservation looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight lua"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Pseudocode&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;remaining_requests&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;requested_requests&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;"blocked"&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;remaining_tokens&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;estimated_tokens&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;"blocked"&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="n"&gt;remaining_requests&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;remaining_requests&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;requested_requests&lt;/span&gt;
&lt;span class="n"&gt;remaining_tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;remaining_tokens&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;estimated_tokens&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;"reserved"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Redis Lua scripts execute atomically, so no other worker can sneak in halfway through. &lt;a href="https://redis.io/docs/latest/develop/programmability/eval-intro/" rel="noopener noreferrer"&gt;Redis Lua scripting&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;This turns quota into something workers reserve before calling the API.&lt;/p&gt;

&lt;p&gt;Not after.&lt;/p&gt;

&lt;p&gt;After is too late. After is when you are writing an incident report to yourself.&lt;/p&gt;




&lt;h2&gt;
  
  
  PostgreSQL Durable Usage Ledger: The Memory That Survives Restart
&lt;/h2&gt;

&lt;p&gt;Redis is great for fast coordination, but if the local machine restarts, Redis may lose volatile state depending on configuration.&lt;/p&gt;

&lt;p&gt;PostgreSQL is where CourseFlow records durable usage:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;groq_usage_ledger&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;usage_type&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;request_id&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;requests_used&lt;/span&gt; &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;tokens_used&lt;/span&gt; &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&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;audio_seconds_used&lt;/span&gt; &lt;span class="nb"&gt;NUMERIC&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&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;created_at&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The actual schema can evolve, but the idea is stable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;record successful requests&lt;/li&gt;
&lt;li&gt;record token usage&lt;/li&gt;
&lt;li&gt;record audio seconds&lt;/li&gt;
&lt;li&gt;record model and request type&lt;/li&gt;
&lt;li&gt;allow quota state to be reconstructed&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;PostgreSQL gives the durable database foundation here.&lt;/p&gt;

&lt;p&gt;This matters because workers can crash. Redis can be rebuilt. But completed usage should not become expired information.&lt;/p&gt;




&lt;h2&gt;
  
  
  Short Limits vs Daily Exhaustion
&lt;/h2&gt;

&lt;p&gt;Not all rate limits mean the same thing.&lt;/p&gt;

&lt;p&gt;Some are short-term limits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;requests per minute&lt;/li&gt;
&lt;li&gt;tokens per minute&lt;/li&gt;
&lt;li&gt;audio seconds per hour&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For these, the correct behavior is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Wait, then retry.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The job should be marked as &lt;code&gt;rate_limited&lt;/code&gt;, scheduled after the reset time, and resumed later.&lt;/p&gt;

&lt;p&gt;Daily exhaustion is different:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;requests per day&lt;/li&gt;
&lt;li&gt;tokens per day&lt;/li&gt;
&lt;li&gt;daily provider allowance&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For these, retrying in 30 seconds is just performance art.&lt;/p&gt;

&lt;p&gt;The correct behavior is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Defer until the daily window resets.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;CourseFlow maps short waits to &lt;code&gt;rate_limited&lt;/code&gt; and daily exhaustion to &lt;code&gt;deferred&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That distinction is important for UX too. A user should know whether the system is temporarily waiting or done for the day.&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhj6m1nmu8igwgi2d1vqz.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhj6m1nmu8igwgi2d1vqz.png" alt="User UX" width="800" height="446"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Whisper Is Not “Just Another API Call”
&lt;/h2&gt;

&lt;p&gt;Whisper transcription adds another dimension: audio duration.&lt;/p&gt;

&lt;p&gt;Groq’s speech-to-text API has its own constraints. Reference: &lt;a href="https://console.groq.com/docs/speech-to-text" rel="noopener noreferrer"&gt;Groq speech-to-text documentation&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;For long videos, CourseFlow chunks audio and sends each chunk separately. But each chunk needs quota reservation too.&lt;/p&gt;

&lt;p&gt;The scheduler reserves:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;one request&lt;/li&gt;
&lt;li&gt;estimated billable audio duration&lt;/li&gt;
&lt;li&gt;model-specific Whisper capacity&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tricky bit is minimum billable duration.&lt;/p&gt;

&lt;p&gt;If a provider bills short audio chunks using a minimum duration, your scheduler must account for that. Otherwise, you will underestimate usage.&lt;/p&gt;

&lt;p&gt;Conceptually:&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;billable_seconds&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;span class="n"&gt;actual_chunk_seconds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That tiny &lt;code&gt;max()&lt;/code&gt; carries real engineering weight. Without it, 100 tiny chunks can look cheap locally while the provider counts them differently.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why 429s Should Not Consume Quota
&lt;/h2&gt;

&lt;p&gt;This one is subtle and important.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;429 Too Many Requests&lt;/code&gt; means the provider rejected the request because a limit was exceeded.&lt;/p&gt;

&lt;p&gt;So the local reservation should be released.&lt;/p&gt;

&lt;p&gt;If the scheduler reserves quota, sends the request, receives a 429, and still counts the reservation as consumed, the system punishes itself twice:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Provider: "No."
Local scheduler: "Understood. I will now reduce my own local quota too."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Completely wrong.&lt;/p&gt;

&lt;p&gt;The better behavior is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Reserve estimated capacity.&lt;/li&gt;
&lt;li&gt;Send request.&lt;/li&gt;
&lt;li&gt;If success, reconcile with actual usage and headers.&lt;/li&gt;
&lt;li&gt;If 429, release the reservation.&lt;/li&gt;
&lt;li&gt;Set a shared &lt;code&gt;blocked_until&lt;/code&gt; gate using &lt;code&gt;retry-after&lt;/code&gt; or reset headers.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Simplified:&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;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;call_groq&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;scheduler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit_success&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reservation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;RateLimitError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;scheduler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;release&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reservation&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;scheduler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;block_until&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;parse_retry_time&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Basically, a rejected request should influence scheduling, not usage accounting.&lt;/p&gt;

&lt;p&gt;That is how the system avoids slowly starving itself after temporary rate limits.&lt;/p&gt;




&lt;h2&gt;
  
  
  Resumability: The Quiet Superpower
&lt;/h2&gt;

&lt;p&gt;The scheduler is not just about avoiding 429s. It also makes long course processing resumable.&lt;/p&gt;

&lt;p&gt;A course can take a while. During that time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a worker can crash&lt;/li&gt;
&lt;li&gt;the machine can restart&lt;/li&gt;
&lt;li&gt;Redis can be cleared&lt;/li&gt;
&lt;li&gt;the API can rate limit&lt;/li&gt;
&lt;li&gt;Whisper can fail on one chunk&lt;/li&gt;
&lt;li&gt;one lesson can be deferred while others continue&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;CourseFlow handles this by making every meaningful unit durable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;videos have statuses&lt;/li&gt;
&lt;li&gt;notes are stored&lt;/li&gt;
&lt;li&gt;transcript chunks are tracked&lt;/li&gt;
&lt;li&gt;Whisper chunks are stored&lt;/li&gt;
&lt;li&gt;usage is recorded&lt;/li&gt;
&lt;li&gt;retries have timestamps&lt;/li&gt;
&lt;li&gt;completed work is skipped on restart&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So if chunk 17 of a video hits a rate limit, the system does not redo chunks 1-16 like it has short-term memory issues.&lt;/p&gt;

&lt;p&gt;It retries the blocked chunk later.&lt;/p&gt;

&lt;p&gt;That is the difference between a demo and a system.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Final Local Design
&lt;/h2&gt;

&lt;p&gt;The final flow looks like this:&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5ambyqm5nad5qcrii4kw.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5ambyqm5nad5qcrii4kw.png" alt="Final Flow" width="799" height="333"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The important part is not one specific model or provider.&lt;/p&gt;

&lt;p&gt;The important part is the pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Estimate before request.
Reserve atomically.
Call provider.
Reconcile from headers.
Persist durable usage.
Retry only the failed unit.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That pattern works for LLMs, Whisper, image generation, and basically any paid or limited API where “oops” has a quota cost.&lt;/p&gt;




&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;Free-tier AI sounds like a pricing detail.&lt;/p&gt;

&lt;p&gt;It is not.&lt;/p&gt;

&lt;p&gt;When you build a real application on top of free-tier or quota-limited APIs, you are suddenly dealing with distributed systems problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;shared state&lt;/li&gt;
&lt;li&gt;concurrent workers&lt;/li&gt;
&lt;li&gt;partial failure&lt;/li&gt;
&lt;li&gt;durable accounting&lt;/li&gt;
&lt;li&gt;retry classification&lt;/li&gt;
&lt;li&gt;idempotency&lt;/li&gt;
&lt;li&gt;backpressure&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In summary:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I wanted video summaries. I got consensus-adjacent quota coordination.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is the engineering lesson CourseFlow taught me.&lt;/p&gt;

&lt;p&gt;And honestly, it was a good one.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>redis</category>
      <category>systemdesign</category>
      <category>backend</category>
    </item>
    <item>
      <title>RAG Apps Don’t Fail at Generation. They Fail at Retrieval</title>
      <dc:creator>Ponsubash Raj R</dc:creator>
      <pubDate>Sun, 19 Jul 2026 12:42:48 +0000</pubDate>
      <link>https://dev.to/justatalentedguy/rag-apps-dont-fail-at-generation-they-fail-at-retrieval-l8d</link>
      <guid>https://dev.to/justatalentedguy/rag-apps-dont-fail-at-generation-they-fail-at-retrieval-l8d</guid>
      <description>&lt;h2&gt;
  
  
  Why My RAG App Uses BM25, Vectors, Parent-Child Chunks, and Chat Memory Together
&lt;/h2&gt;

&lt;p&gt;PROJECT REPOSITORY: &lt;a href="https://github.com/JustATalentedGuy/docflow" rel="noopener noreferrer"&gt;Open&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Most RAG projects start with a very confident idea:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“Let us split the PDF, embed the chunks, store them in a vector database, and ask questions.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Beautiful. Elegant. Wrong often enough to be annoying.&lt;/p&gt;

&lt;p&gt;That was also my first idea while building &lt;strong&gt;Docflow&lt;/strong&gt;, a multi-user document question-answering app where users upload PDFs/images and chat with their own files. The application processes documents, stores chunks in Qdrant, retrieves relevant context, and sends that context to an LLM.&lt;/p&gt;

&lt;p&gt;The first version looked simple:&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbffe7tjstmaevfpz8qww.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbffe7tjstmaevfpz8qww.png" alt="Initial Version" width="799" height="163"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And honestly, for a simple scenario with three clean paragraphs, this works nicely. Unfortunately, my documents were not three clean paragraphs. They were college lecture notes, policy PDFs, invoice files, scanned documents full of headers, footers, tables, page numbers, short codes, and lines like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Company Confidential
Employee Benefits Guide
Page 7 of 42
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Very official. Also very noisy.&lt;/p&gt;

&lt;p&gt;So the retrieval system had to grow up a little.&lt;/p&gt;

&lt;p&gt;Final retrieval design:&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl62nyl8p8bmmae82fwac.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl62nyl8p8bmmae82fwac.png" alt="Actual Version" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This blog explains why each piece exists.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Project In One Minute
&lt;/h2&gt;

&lt;p&gt;Docflow is a full-stack RAG application.&lt;/p&gt;

&lt;p&gt;Users can:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;register and log in&lt;/li&gt;
&lt;li&gt;upload PDFs/images&lt;/li&gt;
&lt;li&gt;manage uploaded files&lt;/li&gt;
&lt;li&gt;create multiple chats&lt;/li&gt;
&lt;li&gt;ask questions over only their own documents&lt;/li&gt;
&lt;li&gt;continue old chats with memory&lt;/li&gt;
&lt;li&gt;delete files from storage and index&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The system uses:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;FastAPI&lt;/strong&gt; for backend APIs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;React&lt;/strong&gt; for UI&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Celery + Redis&lt;/strong&gt; for async document processing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;S3/MinIO&lt;/strong&gt; for raw file storage&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Qdrant&lt;/strong&gt; for vector search&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;BM25&lt;/strong&gt; for keyword retrieval&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Groq LLM&lt;/strong&gt; for final answers&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SQLite/PostgreSQL&lt;/strong&gt; for users, files, chats, and messages&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The important part: retrieval is user-scoped. A user should never retrieve another user’s document chunks. That is not a feature. That is a lawsuit warming up.&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhaee1jzkt0ltqzsw28qk.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhaee1jzkt0ltqzsw28qk.png" alt="Project" width="800" height="367"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Problem With The First Naive Idea: Just Embed Chunks And Search Qdrant
&lt;/h2&gt;

&lt;p&gt;The first plan was simple:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Extract text from a PDF.&lt;/li&gt;
&lt;li&gt;Split it into chunks.&lt;/li&gt;
&lt;li&gt;Convert chunks into embeddings.&lt;/li&gt;
&lt;li&gt;Store vectors in Qdrant.&lt;/li&gt;
&lt;li&gt;Embed the user query.&lt;/li&gt;
&lt;li&gt;Retrieve nearest chunks by cosine similarity.&lt;/li&gt;
&lt;li&gt;Send those chunks to the LLM.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is the classic semantic search flow. Sentence Transformers describes semantic search as embedding both the query and corpus into the same vector space, then finding the closest embeddings by semantic similarity: &lt;a href="https://www.sbert.net/examples/sentence_transformer/applications/semantic-search/README.html" rel="noopener noreferrer"&gt;Sentence Transformers Semantic Search&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;That already gives a decent system.&lt;/p&gt;

&lt;p&gt;Vector search is good at meaning.&lt;/p&gt;

&lt;p&gt;If the document says:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Employees can request paid leave after completing the probation period.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the user asks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;When am I eligible for paid time off?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A vector model may understand that &lt;code&gt;paid leave&lt;/code&gt; and &lt;code&gt;paid time off&lt;/code&gt; are related.&lt;/p&gt;

&lt;p&gt;Might.&lt;/p&gt;

&lt;p&gt;That word is carrying a lot of emotional damage.&lt;/p&gt;

&lt;p&gt;Semantic search can struggle with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;acronyms: &lt;code&gt;PTO&lt;/code&gt;, &lt;code&gt;SLA&lt;/code&gt;, &lt;code&gt;KYC&lt;/code&gt;, &lt;code&gt;GST&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;policy codes: &lt;code&gt;HR-204&lt;/code&gt;, &lt;code&gt;SEC-17&lt;/code&gt;, &lt;code&gt;INV-009&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;exact clause names&lt;/li&gt;
&lt;li&gt;invoice numbers&lt;/li&gt;
&lt;li&gt;section titles&lt;/li&gt;
&lt;li&gt;short technical queries&lt;/li&gt;
&lt;li&gt;rare terms that are important&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  BM25: Because Exact Words Still Matter
&lt;/h2&gt;

&lt;p&gt;To fix this, I added &lt;strong&gt;BM25&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;BM25 is a classic lexical ranking method. It scores documents based on query term matches, term frequency, inverse document frequency, and document length.&lt;/p&gt;

&lt;h3&gt;
  
  
  Real Example
&lt;/h3&gt;

&lt;p&gt;Suppose the uploaded document contains:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Payment terms: Net 30. The customer must complete payment within 30 calendar days from the invoice date.
Late payments may incur a 2% monthly service charge.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;User asks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;What are the Net 30 payment terms and late fee?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Vector search may retrieve semantically similar billing chunks.&lt;/p&gt;

&lt;p&gt;BM25 strongly boosts chunks containing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Net
30
payment
late
fee
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That gives better recall for technical phrases, policy names, codes, and numbers.&lt;/p&gt;

&lt;p&gt;So now we have:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Vector search -&amp;gt; meaning
BM25 search   -&amp;gt; exact terms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Combining Rankings With RRF
&lt;/h2&gt;

&lt;p&gt;Now we have two ranked lists:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Vector results:
1. chunk A
2. chunk B
3. chunk C

BM25 results:
1. chunk C
2. chunk D
3. chunk A
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;How do we combine them?&lt;/p&gt;

&lt;p&gt;Bad idea:&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;final_score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;vector_score&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;bm25_score&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why bad?&lt;/p&gt;

&lt;p&gt;Because vector similarity and BM25 score are not on the same scale. Adding them directly is like adding kilograms and degrees Celsius.&lt;/p&gt;

&lt;p&gt;So I used &lt;strong&gt;Reciprocal Rank Fusion&lt;/strong&gt;, or RRF.&lt;/p&gt;

&lt;p&gt;RRF combines rankings using rank positions instead of raw scores: &lt;a href="https://plg.uwaterloo.ca/~gvcormac/cormacksigir09-rrf.pdf" rel="noopener noreferrer"&gt;Reciprocal Rank Fusion paper&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The scoring idea:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;score(document) = sum over rankings: 1 / (k + rank)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gives a nice behavior:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If a chunk ranks high in both vector and BM25, it rises.&lt;/li&gt;
&lt;li&gt;If a chunk ranks high in only one method, it can still survive.&lt;/li&gt;
&lt;li&gt;We do not care about incompatible score scales.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Simple. Effective.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Parent-Child Chunking Is Better
&lt;/h2&gt;

&lt;p&gt;The next problem: what size should chunks be?&lt;/p&gt;

&lt;p&gt;Small chunks are great for retrieval:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"Late payments may incur a 2% monthly service charge."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is focused and searchable.&lt;/p&gt;

&lt;p&gt;But small chunks are bad for final answering because the LLM may miss surrounding context:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Which invoice does this apply to?
Is the 2% charge monthly or one-time?
Does the 30-day period start from invoice date or delivery date?
Are there exceptions?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Large chunks are better for context, but worse for search. A giant chunk may contain the right answer buried under three pages of policy text, one approval matrix, and a footer reminding you that the document is confidential. Very searchable. Obviously.&lt;/p&gt;

&lt;p&gt;So I use &lt;strong&gt;parent-child chunking&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;Parent chunk: larger block, sent to LLM
Child chunk: smaller block, used for retrieval
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Structure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;parent: abcd1234-p0
child:  abcd1234-p0-c0
child:  abcd1234-p0-c1
child:  abcd1234-p0-c2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Indexing stores both:&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="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;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;chunk&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&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;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;chunk&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="s"&gt;type&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;child&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;parent_id&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;abcd1234-p0&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_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;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;file_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;file_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;job_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;job_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;filename&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;filename&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;Search only looks at children:&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="nc"&gt;FieldCondition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;match&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;MatchValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;child&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;Then after RRF, the system fetches parent chunks:&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;parent_ids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;parent_id&lt;/span&gt;&lt;span class="sh"&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;hit&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;fused_hits&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;parents&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;retrieve_by_ids&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parent_ids&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gives the best of both worlds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Child chunks -&amp;gt; precise search
Parent chunks -&amp;gt; useful LLM context
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This matters a lot for real documents, where a rule, exception, effective date, and approval condition may be spread across nearby lines. Sending only the tiny matching sentence can make the LLM answer like it skimmed the policy during lunch and decided that was enough.&lt;/p&gt;




&lt;h2&gt;
  
  
  Answering Follow-Up Questions
&lt;/h2&gt;

&lt;p&gt;The user does not always ask beautiful standalone questions.&lt;/p&gt;

&lt;p&gt;They ask:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;What is the company policy for remote work?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Who needs to approve it?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Humans understand &lt;code&gt;it&lt;/code&gt;. A stateless API does not.&lt;/p&gt;

&lt;p&gt;So each user can create multiple chats. Each chat has messages stored in the database:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;chats
messages
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When a new message arrives, Docflow loads recent messages:&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;history&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_recent_chat_history&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chat_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then it builds a retrieval query using recent context, so that the retrieval layer sees:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;user: What is the company policy for remote work?
assistant: ...
user: Who needs to approve it?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the search query has enough context to understand that &lt;code&gt;it&lt;/code&gt; probably means &lt;code&gt;remote work&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Important design choice:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Chat history helps understand the question. It is not treated as factual source material.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That distinction matters. The LLM should not invent facts from conversation history. The uploaded documents remain the authority.&lt;/p&gt;




&lt;h2&gt;
  
  
  Grounded Answers And Fallback Behavior
&lt;/h2&gt;

&lt;p&gt;The system prompt tells the model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Use only the current user's retrieved document context for factual answers.
Use conversation history only to understand follow-up references.
If the answer is not in the context, say:
"I could not find this information in the provided documents."
If the user asks for an example and no example appears in the context,
say that no source example was found before giving a clearly labeled general example.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is there because RAG systems should not pretend the document said something it did not say.&lt;/p&gt;

&lt;p&gt;For example, if the document explains the expense reimbursement policy but gives no sample reimbursement scenario, and the user asks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Give an example for it.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A good answer is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;I could not find an example in the provided documents.

General example:
If an employee spends ₹1,200 on approved client travel and uploads the receipt within the required period, the finance team may reimburse the amount after manager approval.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is much better than:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The document says...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;when the document absolutely did not say it. Classic LLM confidence.&lt;/p&gt;




&lt;h2&gt;
  
  
  How The Final Retrieval Flow Looks
&lt;/h2&gt;

&lt;p&gt;Here is the final flow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. User asks a question
2. API loads recent chat history
3. API creates retrieval query
4. Query is embedded
5. Qdrant finds semantic child chunk matches
6. BM25 finds exact keyword child chunk matches
7. RRF combines both rankings
8. Best child chunks are mapped to parent chunks
9. Parent chunks become LLM context
10. LLM answers with sources
11. Chat history is saved
&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxe47dduwdwop19tk9mvg.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxe47dduwdwop19tk9mvg.png" alt="Simple Retrieval and Answering" width="800" height="169"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;The main lesson from building Docflow:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;RAG quality depends more on retrieval design than on just calling a powerful LLM.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Embeddings are useful, but they are not magic. BM25 is old, but old does not mean useless. Parent-child chunking sounds fancy, but it solves a very real context problem. Chat memory is necessary because users ask follow-up questions like normal humans. Grounded fallback behavior is needed because LLMs are very comfortable saying things with full confidence and zero evidence.&lt;/p&gt;

&lt;p&gt;Final design:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Vectors for meaning
BM25 for exact terms
RRF for rank fusion
Child chunks for retrieval
Parent chunks for context
Chat history for follow-ups
Source-grounded prompting for honesty
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A RAG app is not impressive because it uses embeddings. Everyone and their grandma can do that now.&lt;/p&gt;

&lt;p&gt;It becomes impressive when it handles the messy parts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;noisy PDFs&lt;/li&gt;
&lt;li&gt;exact terminology&lt;/li&gt;
&lt;li&gt;multi-user isolation&lt;/li&gt;
&lt;li&gt;follow-up questions&lt;/li&gt;
&lt;li&gt;missing source evidence&lt;/li&gt;
&lt;li&gt;explainable retrieval decisions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is where the engineering starts.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>rag</category>
      <category>vectordatabase</category>
      <category>python</category>
    </item>
    <item>
      <title>The LLM Didn’t Need to See the Diagram. It Just Needed a Seat Number</title>
      <dc:creator>Ponsubash Raj R</dc:creator>
      <pubDate>Sun, 12 Jul 2026 05:53:37 +0000</pubDate>
      <link>https://dev.to/justatalentedguy/the-llm-didnt-need-to-see-the-diagram-it-just-needed-a-seat-number-51i5</link>
      <guid>https://dev.to/justatalentedguy/the-llm-didnt-need-to-see-the-diagram-it-just-needed-a-seat-number-51i5</guid>
      <description>&lt;p&gt;AI note apps love doing one very modern thing: taking a clean lecture PDF, feeding it to an LLM, and proudly returning notes with all the diagrams missing.&lt;/p&gt;

&lt;p&gt;Amazing. We automated disappointment.&lt;/p&gt;

&lt;p&gt;This project started with a simple goal: turn lecture PDFs and slides into useful study notes, without losing diagrams. Not “diagram summaries.” Not “imagine a flowchart here.” The actual source diagrams, in the right places.&lt;/p&gt;

&lt;p&gt;PROJECT REPOSITORY: &lt;a href="https://github.com/JustATalentedGuy/SmartNotes" rel="noopener noreferrer"&gt;Open&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Project Brief
&lt;/h2&gt;

&lt;p&gt;I built &lt;strong&gt;Smart Notes Generator&lt;/strong&gt;, a local-first app that takes PDFs and PowerPoint files, extracts text and diagrams, sends only the useful text structure to an LLM, and then rebuilds the final notes with the original diagrams inserted back.&lt;/p&gt;

&lt;p&gt;The stack is simple:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;React + TypeScript&lt;/strong&gt; for the frontend.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FastAPI&lt;/strong&gt; for the backend.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PyMuPDF&lt;/strong&gt; for PDF text/image/vector extraction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;python-pptx&lt;/strong&gt; for PowerPoint parsing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pillow&lt;/strong&gt; for image scoring.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SQLite&lt;/strong&gt; for local saved notes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LLM API or manual copy-paste mode&lt;/strong&gt; for generation.&lt;/li&gt;
&lt;/ul&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F631jzpw5fdw4rv3dj6cj.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F631jzpw5fdw4rv3dj6cj.png" alt="Application" width="800" height="366"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Normal Approach: Throw Everything at the Model and Pray
&lt;/h2&gt;

&lt;p&gt;The naive approach is:&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F929r7ao4d7jbkzii4c0k.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F929r7ao4d7jbkzii4c0k.png" alt="Naive Approach" width="800" height="90"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The problem is that most document extraction pipelines treat images as either:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Noise.&lt;/li&gt;
&lt;li&gt;Base64 blobs.&lt;/li&gt;
&lt;li&gt;Something to send to a vision model.&lt;/li&gt;
&lt;li&gt;Someone else’s problem.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Sending every diagram to a vision-capable model sounds fancy, but it creates new problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It increases input size.&lt;/li&gt;
&lt;li&gt;It adds cost.&lt;/li&gt;
&lt;li&gt;It adds latency.&lt;/li&gt;
&lt;li&gt;It still may not place the diagram correctly.&lt;/li&gt;
&lt;li&gt;It can describe the diagram, but that is not the same as preserving it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The key realization was:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The LLM does not need to understand every pixel of the diagram. It mostly needs to know where the diagram belongs.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That one sentence changed the architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Idea: Give Every Diagram a Seat Number
&lt;/h2&gt;

&lt;p&gt;Instead of sending images to the model, I extract diagrams locally and replace them with stable placeholders.&lt;/p&gt;

&lt;p&gt;Example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;A finite automaton can be represented using states and transitions.

{{IMG_001}}

The transition function defines how the automaton moves between states.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The LLM sees the placeholder as part of the source context. It can move it into the right location in the generated notes.&lt;/p&gt;

&lt;p&gt;After generation, the backend replaces:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{{IMG_001}}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;with the original local image.&lt;/p&gt;

&lt;p&gt;So the model handles reasoning and structure. The system handles files, images, and reliability.&lt;/p&gt;

&lt;h2&gt;
  
  
  How It Works
&lt;/h2&gt;

&lt;p&gt;The flow basically looks like:&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxoxdwzcwg3fuqasyotcc.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxoxdwzcwg3fuqasyotcc.png" alt="Flow Diagram" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A simplified version of the placeholder registry looks like this:&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;registry&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;IMG_001&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="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;placeholder&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;{{IMG_001}}&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;path&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;/local/session/imgs/automata_p2_f0.png&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;source_file&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;lecture_automata.pdf&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;page&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;score&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.82&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;context&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;DFA transition diagram&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;included&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the prompt contains text, not image bytes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Use the following source material to create clear study notes.
Preserve image placeholders exactly where they belong.

Source:
A DFA consists of states, alphabet, transition function...

{{IMG_001}}

The accepting state is shown with a double circle.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The LLM returns something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## Deterministic Finite Automata&lt;/span&gt;

A &lt;span class="gs"&gt;**DFA**&lt;/span&gt; is a finite-state machine where each input symbol leads to exactly one next state.

{{IMG_001}}

The double-circled state represents an accepting state.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the postprocessor turns the token into a real figure:&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;inject_images&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;markdown&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="n"&gt;registry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&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;str&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;img_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;info&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;registry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;figure_html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
&amp;lt;figure&amp;gt;
  &amp;lt;img src=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;path&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; alt=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;alt_text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; /&amp;gt;
  &amp;lt;figcaption&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;source_file&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, page &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;page&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="mi"&gt;1&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;/figcaption&amp;gt;
&amp;lt;/figure&amp;gt;
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
        &lt;span class="n"&gt;markdown&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;markdown&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;placeholder&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;figure_html&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;markdown&lt;/span&gt;
&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqd43snwwaacy9r84sf6a.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqd43snwwaacy9r84sf6a.png" alt="Notes with Diagram" width="800" height="326"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  But Not Every Image Deserves a VIP Pass
&lt;/h2&gt;

&lt;p&gt;Lecture PDFs contain useful diagrams, yes. They also contain logos, headers, footers, slide backgrounds, decorative lines, and other visual confetti.&lt;/p&gt;

&lt;p&gt;So I added image filtering.&lt;/p&gt;

&lt;p&gt;Each extracted image gets a quality score based on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pixel dimensions&lt;/li&gt;
&lt;li&gt;Aspect ratio&lt;/li&gt;
&lt;li&gt;File size&lt;/li&gt;
&lt;li&gt;Color entropy&lt;/li&gt;
&lt;li&gt;Non-blankness&lt;/li&gt;
&lt;li&gt;Duplicate hash&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A simplified scoring idea:&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;score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;size_score&lt;/span&gt;    &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.25&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
    &lt;span class="n"&gt;aspect_score&lt;/span&gt;  &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.20&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
    &lt;span class="n"&gt;file_score&lt;/span&gt;    &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.20&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
    &lt;span class="n"&gt;entropy_score&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.20&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
    &lt;span class="n"&gt;blank_score&lt;/span&gt;   &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.15&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This removes tiny logos, blank slide backgrounds, repeated assets, and weird banner strips.&lt;/p&gt;

&lt;p&gt;The user can still review the gallery and manually include or exclude images. Because yes, sometimes the “low quality” image is actually the one diagram the professor will ask for in the exam. Naturally.&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqsapzjyocljzeoo83f5q.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqsapzjyocljzeoo83f5q.png" alt="Select Image" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Handling Failure: Because LLMs Have Vibes, Not Contracts
&lt;/h2&gt;

&lt;p&gt;The LLM is told to preserve placeholders.&lt;/p&gt;

&lt;p&gt;Does it always do that?&lt;/p&gt;

&lt;p&gt;Of course not. It is an LLM, not a legally binding agreement.&lt;/p&gt;

&lt;p&gt;Sometimes it drops &lt;code&gt;{{IMG_003}}&lt;/code&gt;. So the system needs a fallback.&lt;/p&gt;

&lt;p&gt;For every image, I store nearby text context and extract keywords:&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fallback_keywords&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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;transition&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;state&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;dfa&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;accepting&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;alphabet&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;If a placeholder is missing after generation, the backend scans the generated notes and inserts the image near the paragraph with the strongest keyword match.&lt;/p&gt;

&lt;p&gt;Simplified version:&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;place_dropped_image&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;paragraphs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;keywords&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fallback_keywords&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

    &lt;span class="n"&gt;best_index&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="n"&gt;best_score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;paragraph&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;paragraphs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;words&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;paragraph&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="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="n"&gt;score&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;words&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;keywords&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;best_score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;best_score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt;
            &lt;span class="n"&gt;best_index&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;

    &lt;span class="n"&gt;paragraphs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;best_index&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;image&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;placeholder&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;paragraphs&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The LLM gets freedom to structure the notes. The system keeps guardrails around the parts that must not break.&lt;/p&gt;

&lt;h2&gt;
  
  
  Other Benefits I Got Almost for Free
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Lower Cost
&lt;/h3&gt;

&lt;p&gt;Images are not sent as model input. A placeholder like &lt;code&gt;{{IMG_001}}&lt;/code&gt; is tiny. A base64 image is a suitcase full of nonsense as far as the prompt is concerned.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Better Privacy
&lt;/h3&gt;

&lt;p&gt;Source files and extracted diagrams stay local. The model only sees text and placeholder IDs. For student notes, academic content, or internal training material, this matters.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Exact Diagrams
&lt;/h3&gt;

&lt;p&gt;The final document uses the original image. No re-generated diagram. No “close enough.” No AI slop.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Easier Debugging
&lt;/h3&gt;

&lt;p&gt;Placeholders make the pipeline inspectable.&lt;/p&gt;

&lt;p&gt;If a diagram is missing, I can ask:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Was it extracted?&lt;/li&gt;
&lt;li&gt;Was it filtered out?&lt;/li&gt;
&lt;li&gt;Was the placeholder assigned?&lt;/li&gt;
&lt;li&gt;Did the model drop it?&lt;/li&gt;
&lt;li&gt;Did postprocessing fail?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That beats staring at a final blob of generated Markdown wondering where everything went.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Actual Design Decision
&lt;/h2&gt;

&lt;p&gt;The big decision was separating responsibilities:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Document parser: extract facts and files
Image filter: decide what is useful
Prompt builder: create clean LLM input
LLM: rewrite and organize
Postprocessor: restore local diagrams
Evaluator/RAG: help review and reuse notes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The LLM is not the system. It is one component inside the system.&lt;/p&gt;

&lt;p&gt;That distinction matters.&lt;/p&gt;

&lt;p&gt;A lot of AI apps are just:&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr28jf5uh7bi3egs9lkmd.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr28jf5uh7bi3egs9lkmd.png" alt="AI Slop" width="800" height="90"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This project is closer to:&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq31vp4s2devv2933gx8t.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq31vp4s2devv2933gx8t.png" alt="My flow" width="800" height="150"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That is the difference between a demo and an actual product.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;The best AI engineering trick in this project was not a giant prompt. It was knowing what &lt;strong&gt;not&lt;/strong&gt; to send to the model.&lt;/p&gt;

&lt;p&gt;The diagram did not need to be seen. It needed to be tracked.&lt;/p&gt;

&lt;p&gt;The LLM did not need image pixels. It needed a placeholder, nearby context, and a clear instruction.&lt;/p&gt;

&lt;p&gt;The system did the rest.&lt;/p&gt;

&lt;p&gt;And honestly, that is the lesson I keep coming back to:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Good AI systems are not built by asking the model to do everything. They are built by giving the model the right job, then surrounding it with boring, reliable software.&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>productivity</category>
      <category>architecture</category>
    </item>
    <item>
      <title>I Built an AI Feed, Then Spent Most of the Time Fighting Bad Input</title>
      <dc:creator>Ponsubash Raj R</dc:creator>
      <pubDate>Sun, 05 Jul 2026 05:05:29 +0000</pubDate>
      <link>https://dev.to/justatalentedguy/i-built-an-ai-feed-then-spent-most-of-the-time-fighting-bad-input-24mj</link>
      <guid>https://dev.to/justatalentedguy/i-built-an-ai-feed-then-spent-most-of-the-time-fighting-bad-input-24mj</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;I thought I was building an AI app.&lt;br&gt;
Turns out, I was building a garbage sorting machine with embeddings.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://github.com/JustATalentedGuy/pulse" rel="noopener noreferrer"&gt;PROJECT REPOSITORY&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Finu91zjkf0foi0c4xqpv.jpeg" 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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Finu91zjkf0foi0c4xqpv.jpeg" alt="Home Feed" width="610" height="1229"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Brief: Make the Internet Less Annoying
&lt;/h2&gt;

&lt;p&gt;Pulse is a personal AI feed for keeping up with AI engineering.&lt;/p&gt;

&lt;p&gt;The idea was simple:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pull content from RSS feeds, GitHub, arXiv, and Gmail newsletters.&lt;/li&gt;
&lt;li&gt;Clean the text.&lt;/li&gt;
&lt;li&gt;Ask an LLM to summarize and classify it.&lt;/li&gt;
&lt;li&gt;Store embeddings.&lt;/li&gt;
&lt;li&gt;Serve it in a mobile app with search, bookmarks, digest, trends, quizzes, and Ask mode.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In diagram form, the dream looked like this:&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8gspyq13kegu3lj0mzgt.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8gspyq13kegu3lj0mzgt.png" alt="Simple Flow" width="800" height="120"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Very elegant. Very architectural. Very “drawn before reality entered the room”.&lt;/p&gt;

&lt;p&gt;The actual version looked more like this:&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frtup4g8cys2ynf35h6tb.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frtup4g8cys2ynf35h6tb.png" alt="Actual Flow" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The lesson came quickly:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The model is not the hard part. The hard part is getting sane input into the model.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  "The Sources Were Messy" - is an understatement.
&lt;/h2&gt;

&lt;p&gt;Pulse ingests from four main source types:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;RSS / Atom feeds&lt;/li&gt;
&lt;li&gt;GitHub repositories&lt;/li&gt;
&lt;li&gt;arXiv papers&lt;/li&gt;
&lt;li&gt;Gmail newsletters&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each one brought its own special personality disorder.&lt;/p&gt;

&lt;p&gt;RSS feeds sound simple until you meet malformed XML. Some feeds work perfectly. Some return partial content. Some fail for a day and then return like nothing happened. Very mature behaviour.&lt;/p&gt;

&lt;p&gt;GitHub was cleaner because the official &lt;a href="https://docs.github.com/en/rest/search/search#search-repositories" rel="noopener noreferrer"&gt;GitHub Search API&lt;/a&gt; gives structured JSON. I used repository search as a fallback for AI-related repos, sorting by stars and limiting the result count. Still, even clean APIs need defensive handling. A repository might have no description. A URL might be invalid. The API might fail. A good ingestion pipeline should not fall apart because one repo decided to be mysterious.&lt;/p&gt;

&lt;p&gt;arXiv was nicer because it has an official API with &lt;code&gt;search_query&lt;/code&gt;, &lt;code&gt;start&lt;/code&gt;, &lt;code&gt;max_results&lt;/code&gt;, &lt;code&gt;sortBy&lt;/code&gt;, and &lt;code&gt;sortOrder&lt;/code&gt; parameters, documented in the &lt;a href="https://info.arxiv.org/help/api/user-manual.html" rel="noopener noreferrer"&gt;arXiv API manual&lt;/a&gt;. I used category queries and sorted by submitted date. But arXiv abstracts still need cleaning. LaTeX needs stripping. The API gives you structured data, not finished product data. That distinction matters.&lt;/p&gt;

&lt;p&gt;And then there was Gmail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gmail Newsletters Are GOATed
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Great On Arrival, Awful To Transform.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Newsletters provide latest hand-picked news. Reading such newsletters everyday really boosts our knowledge.&lt;/p&gt;

&lt;p&gt;But they are awful for processing.&lt;/p&gt;

&lt;p&gt;A human sees:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“Here are five interesting AI links”.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A parser sees:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;hidden preheader
sponsor block
unsubscribe link
view in browser link
social share buttons
CSS
HTML tables
actual article
footer
another footer
legal footer
unsubscribe again, just in case
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pulse uses the Gmail API, not IMAP. The Gmail &lt;code&gt;users.messages.get&lt;/code&gt; endpoint supports retrieving message data: &lt;a href="https://developers.google.com/workspace/gmail/api/reference/rest/v1/users.messages/get" rel="noopener noreferrer"&gt;Gmail API docs&lt;/a&gt;. I intentionally used read-only access because this app has no business modifying my inbox.&lt;/p&gt;

&lt;p&gt;The ingestion query only looks at selected newsletter senders, unread messages, and a recent time window.&lt;/p&gt;

&lt;p&gt;Then each email gets fetched in full, parsed completely, and turned into one or more article candidates.&lt;/p&gt;

&lt;p&gt;The Gmail pipeline had to handle:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;nested MIME parts&lt;/li&gt;
&lt;li&gt;&lt;code&gt;text/plain&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;text/html&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;redirect links&lt;/li&gt;
&lt;li&gt;sponsor blocks&lt;/li&gt;
&lt;li&gt;promotional-only emails&lt;/li&gt;
&lt;li&gt;social/share/footer links&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is where the project stopped being “AI summarizer” and became “forensic email cleaner”.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Link Problem
&lt;/h2&gt;

&lt;p&gt;Newsletter links are often not the actual article links.&lt;/p&gt;

&lt;p&gt;They are tracking links.&lt;/p&gt;

&lt;p&gt;Something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://newsletter.com/click?url=https%3A%2F%2Factual-article.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or worse:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://tracking-domain.com/CL0/https:%2F%2Factual-site.com%2Fpost
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So Pulse tries to recover the real destination.&lt;/p&gt;

&lt;p&gt;The logic is intentionally bounded. It resolves at most one redirect and uses a timeout. Because if a newsletter tracker wants to become a distributed systems problem, I politely decline.&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;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;resolve_redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&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;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AsyncClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timeout&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;follow_redirects&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&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;client&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;response&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;client&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;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;location&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&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;location&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;location&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule was simple:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Recover useful links, but do not let one link hold the ingestion worker hostage.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If redirect resolution fails, the system keeps the original link or uses the inline newsletter context. Failing open is better than losing the article.&lt;/p&gt;

&lt;h2&gt;
  
  
  Data Quality Decisions, Also Known As “Please Stop Feeding Trash To The LLM”
&lt;/h2&gt;

&lt;p&gt;LLM calls cost quota. Burning it on garbage input is not AI engineering. It is donation.&lt;/p&gt;

&lt;p&gt;So Pulse makes several data quality decisions before enrichment.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Skip Tiny Articles
&lt;/h3&gt;

&lt;p&gt;Some records have almost no useful text. A title, a link, maybe three words. Very inspiring. Not worth a model 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;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;clean_text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;enrichment_status&lt;/span&gt; &lt;span class="o"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In my corpus, 798 records were skipped because they had fewer than 50 useful characters.&lt;/p&gt;

&lt;p&gt;That saved hundreds of LLM calls.&lt;/p&gt;

&lt;p&gt;The LLM did not need to summarize “Click here”. Thankfully, I was capable enough to handle that complex academic material myself.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Deduplicate Aggressively
&lt;/h3&gt;

&lt;p&gt;There are two kinds of duplicate problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;same source item appears again&lt;/li&gt;
&lt;li&gt;same content appears from a different path&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each item gets normalized and hashed.&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;content_hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;normalized_url&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;clean_title&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;clean_text&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So Pulse uses both source IDs and content hashes.&lt;/p&gt;

&lt;p&gt;This matters because ingestion is scheduled. If the same newsletter or feed entry returns again, the system should not create another article and proudly announce, “Good news, I found the same thing again”.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Cap Text Before Enrichment
&lt;/h3&gt;

&lt;p&gt;The enrichment worker trims article text before sending it to Groq.&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;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;clean_body&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;raw_text&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This prevents long newsletters from becoming expensive prompt sludge.&lt;/p&gt;

&lt;p&gt;The goal of enrichment is not to preserve every footer, tracking disclaimer, and “You are receiving this email because...” paragraph.&lt;/p&gt;

&lt;h2&gt;
  
  
  LLM Safety: Because JSON Mode Still Has Hobbies
&lt;/h2&gt;

&lt;p&gt;The enrichment model returns structured metadata:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;summary&lt;/li&gt;
&lt;li&gt;category&lt;/li&gt;
&lt;li&gt;importance&lt;/li&gt;
&lt;li&gt;entities&lt;/li&gt;
&lt;li&gt;keywords&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The ideal response is JSON.&lt;/p&gt;

&lt;p&gt;The actual response can be JSON, markdown-wrapped JSON, JSON with leading prose, JSON with trailing commas, or JSON wearing a small theatrical costume.&lt;/p&gt;

&lt;p&gt;So Pulse does not trust the raw output.&lt;/p&gt;

&lt;p&gt;It extracts a JSON object, then validates it with Pydantic. Pydantic supports custom validators for enforcing constraints and cleaning values: &lt;a href="https://docs.pydantic.dev/latest/concepts/validators/" rel="noopener noreferrer"&gt;Pydantic validator docs&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The schema enforces rules like:&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;EnrichmentResult&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;summary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;min_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Category&lt;/span&gt;
    &lt;span class="n"&gt;importance&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ge&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;le&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;entities&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;EntityMap&lt;/span&gt;
    &lt;span class="n"&gt;keywords&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Prompt Was Not Trusted Either
&lt;/h2&gt;

&lt;p&gt;The prompt asks for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;two-sentence summary&lt;/li&gt;
&lt;li&gt;one supported category&lt;/li&gt;
&lt;li&gt;importance from 1 to 5&lt;/li&gt;
&lt;li&gt;entities grouped by known keys&lt;/li&gt;
&lt;li&gt;5 to 8 keywords&lt;/li&gt;
&lt;li&gt;JSON only&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But the system still validates everything afterward.&lt;/p&gt;

&lt;p&gt;Because prompts are requests, not contracts.&lt;/p&gt;

&lt;p&gt;A contract looks like this:&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;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parse_enrichment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model_output&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;summary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;summary&lt;/span&gt;
&lt;span class="n"&gt;article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt;
&lt;span class="n"&gt;article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;importance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;importance&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The database only gets validated output.&lt;/p&gt;

&lt;p&gt;If parsing fails, the article is marked failed or retried. It does not sneak into the feed half-broken and become the mobile app’s problem. Frontend developers deserve peace too. Occasionally.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reliability: Do Not Enrich The Same Article Twice
&lt;/h2&gt;

&lt;p&gt;The enrichment worker claims work using PostgreSQL row locks.&lt;/p&gt;

&lt;p&gt;PostgreSQL supports &lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt;, which is useful for queue-like tables where multiple consumers should avoid fighting over the same row. &lt;code&gt;SKIP LOCKED&lt;/code&gt; skips rows that cannot be locked immediately: &lt;a href="https://www.postgresql.org/docs/current/sql-select.html" rel="noopener noreferrer"&gt;PostgreSQL SELECT docs&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Pulse uses that pattern:&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;statement&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Article&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;enrichment_status&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pending&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;order_by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ingested_at&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;desc&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;with_for_update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;skip_locked&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="nf"&gt;limit&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="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once an article is claimed:&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;article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;enrichment_status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;processing&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the worker calls the LLM.&lt;/p&gt;

&lt;p&gt;On success:&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;article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;enrichment_status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;done&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;summary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;summary&lt;/span&gt;
&lt;span class="n"&gt;article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On quota exhaustion:&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;article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;enrichment_status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pending&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On handled failure:&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;article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;enrichment_status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;failed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;retry_count&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important rule:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Never leave a row stuck in &lt;code&gt;processing&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A stuck processing row is the backend version of getting seen zoned.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quota Is A Product Feature
&lt;/h2&gt;

&lt;p&gt;Groq enrichment uses a daily quota.&lt;/p&gt;

&lt;p&gt;Pulse reserves quota before external calls:&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="ow"&gt;not&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;reserve_quota&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;quota_manager&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;enrichment_status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pending&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;quota_exhausted&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This avoids half-started work.&lt;/p&gt;

&lt;p&gt;It also lets the system degrade gracefully. If quota is exhausted:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;ingestion can still store new articles&lt;/li&gt;
&lt;li&gt;feed can still serve old articles&lt;/li&gt;
&lt;li&gt;search still works&lt;/li&gt;
&lt;li&gt;enrichment waits until quota resets&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is a much better failure mode than “everything exploded because one external service said no”.&lt;/p&gt;

&lt;p&gt;External APIs are not loyal friends. They are business relationships.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Final Shape
&lt;/h2&gt;

&lt;p&gt;After all this, the pipeline became:&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fya7d900eqbck3f6662x5.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fya7d900eqbck3f6662x5.png" alt="Final Flow" width="800" height="267"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Not glamorous.&lt;/p&gt;

&lt;p&gt;But reliable.&lt;/p&gt;

&lt;p&gt;And once this pipeline exists, the fun features become much easier:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;semantic search&lt;/li&gt;
&lt;li&gt;hybrid search&lt;/li&gt;
&lt;li&gt;daily digest&lt;/li&gt;
&lt;li&gt;trends&lt;/li&gt;
&lt;li&gt;Ask mode&lt;/li&gt;
&lt;li&gt;quizzes&lt;/li&gt;
&lt;li&gt;mobile offline cache&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;AI apps are built on boring data discipline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;I started by thinking:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“I will build an AI feed”.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I ended up learning:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“I will build a defensive ingestion system, and if the data behaves, I may allow an LLM near it”.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The LLM was useful. But only after the input was cleaned, filtered, validated, deduplicated, capped, retried, locked, and politely threatened.&lt;/p&gt;

&lt;p&gt;The real architecture was not:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;content -&amp;gt; LLM -&amp;gt; magic
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;mess -&amp;gt; discipline -&amp;gt; model -&amp;gt; useful product
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;AI systems are not impressive when they work on spoon-fed input.&lt;/p&gt;

&lt;p&gt;They are impressive when they survive the internet.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>backend</category>
      <category>dataengineering</category>
      <category>llm</category>
    </item>
    <item>
      <title>RAG Is Easy. Useful RAG Is the Hard Part</title>
      <dc:creator>Ponsubash Raj R</dc:creator>
      <pubDate>Sat, 04 Jul 2026 13:55:57 +0000</pubDate>
      <link>https://dev.to/justatalentedguy/rag-is-easy-useful-rag-is-the-hard-part-32fd</link>
      <guid>https://dev.to/justatalentedguy/rag-is-easy-useful-rag-is-the-hard-part-32fd</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Everybody says “just add RAG” like it is a button in settings.&lt;br&gt;&lt;br&gt;
It is not. I checked. Very disappointing.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The Brief: Personalized News Feeds
&lt;/h2&gt;

&lt;p&gt;Pulse started as a personal AI intelligence feed.&lt;/p&gt;

&lt;p&gt;Not a chatbot with a search bar glued to it. Not another app where an LLM confidently explains an article it has never seen. I wanted something more useful:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;collect AI engineering content from RSS, GitHub, arXiv, and Gmail newsletters&lt;/li&gt;
&lt;li&gt;summarize and classify articles&lt;/li&gt;
&lt;li&gt;store embeddings&lt;/li&gt;
&lt;li&gt;support exact, semantic, and hybrid search&lt;/li&gt;
&lt;li&gt;answer questions from my own corpus&lt;/li&gt;
&lt;li&gt;cite the articles it used&lt;/li&gt;
&lt;li&gt;say “I do not know” when the corpus has no answer&lt;/li&gt;
&lt;/ul&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvtrag2iyt602o29c3huu.jpeg" 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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvtrag2iyt602o29c3huu.jpeg" alt="Home Page" width="610" height="1229"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That last part is important.&lt;/p&gt;

&lt;p&gt;A RAG system that cannot say “I do not know” is not intelligent. It is just overconfident autocomplete in formal clothes.&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu9t4fu2clvvjfti206py.jpeg" 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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu9t4fu2clvvjfti206py.jpeg" alt="Retrieving suitable article for the question" width="790" height="1600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The simple version looked like this:&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcu0qnq1rxi11qe2y9y2v.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcu0qnq1rxi11qe2y9y2v.png" alt="Simple flow" width="800" height="152"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Very clean. Very incomplete.&lt;/p&gt;

&lt;p&gt;The useful version needed much more.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/JustATalentedGuy/pulse" rel="noopener noreferrer"&gt;PROJECT REPOSITORY&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Actual System Architecture
&lt;/h2&gt;

&lt;p&gt;Pulse uses a FastAPI backend, PostgreSQL with pgvector, Groq for generation, and an Expo Android app.&lt;/p&gt;

&lt;p&gt;At a high level:&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyknj2lda2w9dv2fxjn3s.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyknj2lda2w9dv2fxjn3s.png" alt="Flowchart" width="800" height="267"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For retrieval, the important database columns are:&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;Article&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Base&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapped&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="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;keywords&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;list&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;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;embedding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapped_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Vector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;384&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;embedding_model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapped&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="n"&gt;enrichment_status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapped&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="n"&gt;hidden&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The vector column uses pgvector, which supports vector similarity search inside Postgres including cosine distance and approximate indexes: &lt;a href="https://github.com/pgvector/pgvector" rel="noopener noreferrer"&gt;pgvector README&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;PostgreSQL also gives full-text search, documented in the &lt;a href="https://www.postgresql.org/docs/current/textsearch.html" rel="noopener noreferrer"&gt;PostgreSQL full-text search docs&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;So Pulse does not choose between SQL search and vector search.&lt;/p&gt;

&lt;p&gt;It uses both.&lt;/p&gt;

&lt;p&gt;Because of course one search mode was too peaceful.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why “Just Use Embeddings” Was Not Enough
&lt;/h2&gt;

&lt;p&gt;Embeddings are useful. They are not magic.&lt;/p&gt;

&lt;p&gt;If the user searches:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;on-device foundation models
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;semantic search is great. It can find articles about local AI, small models, mobile inference, and related topics even if the exact words do not match.&lt;/p&gt;

&lt;p&gt;But if the user searches:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Anthropic
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;exact search is often better. The word itself matters. I do not need a poetic interpretation of Anthropic. I need articles that mention Anthropic.&lt;/p&gt;

&lt;p&gt;This is where pure vector search becomes annoying.&lt;/p&gt;

&lt;p&gt;Vector search is good at meaning. Full-text search is good at exact language. A useful product usually needs both.&lt;/p&gt;

&lt;p&gt;So Pulse supports three modes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Exact      -&amp;gt; PostgreSQL full-text search
Semantic   -&amp;gt; pgvector cosine similarity
Hybrid     -&amp;gt; merge both result sets
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Search Mode 1: Exact Search
&lt;/h2&gt;

&lt;p&gt;Exact search uses PostgreSQL full-text search.&lt;/p&gt;

&lt;p&gt;This works well for names, tools, companies, and terms that should match literally.&lt;/p&gt;

&lt;p&gt;It is also fast and boring.&lt;/p&gt;

&lt;p&gt;But boring is underrated. Many production systems are just boring things that work while exciting things are busy timing out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Search Mode 2: Semantic Search
&lt;/h2&gt;

&lt;p&gt;Semantic search embeds the query and compares it with article embeddings using cosine distance.&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;query_embedding&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;call_embedder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query_text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;distance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cosine_distance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query_embedding&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;rows&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;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Article&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;distance&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;Article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;enrichment_status&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;done&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_not&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;Article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hidden&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;order_by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;distance&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ingested_at&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;desc&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;limit&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;h2&gt;
  
  
  Search Mode 3: Hybrid Search
&lt;/h2&gt;

&lt;p&gt;Hybrid search combines exact and semantic results using Reciprocal Rank Fusion.&lt;/p&gt;

&lt;p&gt;The idea is simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;score = 1 / (k + rank)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If an article ranks well in exact search and semantic search, it rises. If it ranks well in only one, it still has a chance.&lt;/p&gt;

&lt;p&gt;We merge both result lists:&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;scores&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;article_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nf"&gt;rrf_score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exact_rank&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;article_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nf"&gt;rrf_score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;semantic_rank&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This made hybrid the default.&lt;/p&gt;

&lt;p&gt;Why?&lt;/p&gt;

&lt;p&gt;Because users do not wake up thinking:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“Today I shall formulate a query that is best served by cosine similarity.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;They type words. The system should adapt.&lt;/p&gt;

&lt;p&gt;Hybrid search lets exact names win when they should, while semantic matches still catch broader ideas.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ask Mode: RAG With Brakes
&lt;/h2&gt;

&lt;p&gt;The Ask mode is where retrieval becomes generation.&lt;/p&gt;

&lt;p&gt;The user asks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;What are the recent themes around AI coding tools?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pulse does this:&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjtisprotg30z4szzs2hm.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjtisprotg30z4szzs2hm.png" alt="Answering Flow" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here, the rejection step matters.&lt;/p&gt;

&lt;p&gt;If the top retrieved articles are weak, Pulse does not call the LLM.&lt;/p&gt;

&lt;p&gt;This is not a failure.&lt;/p&gt;

&lt;p&gt;This is the product behaving responsibly.&lt;/p&gt;

&lt;p&gt;If I ask:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;What is the weather in Mumbai?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pulse should not a produce meteorology fan fiction.&lt;/p&gt;

&lt;p&gt;It should say:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;I do not have enough relevant context in the corpus.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Prompting With Context, Not Hope
&lt;/h2&gt;

&lt;p&gt;The Ask prompt includes only controlled context:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Article ID
Title
Summary
URL
Similarity score
Recent conversation messages
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not raw HTML. Not full article bodies. Not the entire database. Not “please be accurate” as a magical spell.&lt;/p&gt;

&lt;p&gt;A simplified prompt shape:&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;build_ask_prompt&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;articles&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;]&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Title: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Summary: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;URL: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="si"&gt;}&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;article&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;articles&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
Answer the user using only the context below.
If the context is not enough, say so.

Context:
&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;

Question:
&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The answer includes citations back to article IDs and URLs.&lt;/p&gt;

&lt;p&gt;This keeps the system grounded.&lt;/p&gt;

&lt;p&gt;Not perfectly. Nothing with an LLM is perfect. But much better than letting the model free-climb the truth.&lt;/p&gt;

&lt;h2&gt;
  
  
  Personalization: Ranking Is Also Retrieval
&lt;/h2&gt;

&lt;p&gt;Search is not the only retrieval problem.&lt;/p&gt;

&lt;p&gt;The feed itself is retrieval.&lt;/p&gt;

&lt;p&gt;Pulse learns from reading behavior:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;short reads are weak signals&lt;/li&gt;
&lt;li&gt;longer reads are stronger signals&lt;/li&gt;
&lt;li&gt;read categories update category weights&lt;/li&gt;
&lt;li&gt;article keywords update interest terms&lt;/li&gt;
&lt;li&gt;bookmarks and hidden articles affect what should appear&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The engagement score is intentionally simple:&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;engagement_signal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;duration_seconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;duration_seconds&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;duration_seconds&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mf"&gt;0.2&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;duration_seconds&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No fake machine learning ceremony. No “neural preference engine” because I read one article for 14 seconds.&lt;/p&gt;

&lt;p&gt;Category weights use an exponential moving average:&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;new_weight&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;old_weight&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;signal&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;old_weight&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The feed score combines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;importance + category preference + recency + keyword overlap
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Learning Features: RAG Was Only One Part Of The Loop
&lt;/h2&gt;

&lt;p&gt;Once articles are cleaned, summarized, embedded, and ranked, other AI features become easier.&lt;/p&gt;

&lt;p&gt;Pulse uses the same enriched corpus for:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Daily Digest
&lt;/h3&gt;

&lt;p&gt;The digest selects recent high-importance enriched articles and asks Groq for a three-paragraph briefing.&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhjhnqa1oe9yvnqb4578g.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhjhnqa1oe9yvnqb4578g.png" alt="Daily Digest Flow" width="800" height="149"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is not just summarization. It is scheduled synthesis.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Trends
&lt;/h3&gt;

&lt;p&gt;Trend detection scans enriched entities from recent articles.&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;for&lt;/span&gt; &lt;span class="n"&gt;entity&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;entities&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;mentions&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;normalized_entity&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;trends&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="n"&gt;entity&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;entity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;article_ids&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;mentions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&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;article_ids&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;3&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This lets the app show repeated topics like companies, models, tools, or research themes.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. LangGraph Quiz Agent
&lt;/h3&gt;

&lt;p&gt;For learning retention, Pulse generates three-question quizzes from an article summary and entities.&lt;/p&gt;

&lt;p&gt;LangGraph is useful for modeling multi-step agent flows.&lt;/p&gt;

&lt;p&gt;Pulse uses the quiz flow for:&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftrq76tj34n6m6x3d8c2w.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftrq76tj34n6m6x3d8c2w.png" alt="Quiz Flow" width="800" height="164"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Quiz sessions are stored server-side with expiry. The answer key is not trusted from the client.&lt;/p&gt;

&lt;p&gt;Because yes, even in a personal app, the client should not grade itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Product Rule: Retrieval Before Generation
&lt;/h2&gt;

&lt;p&gt;The biggest design rule became:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Retrieve first. Generate second. Refuse when retrieval is weak.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That rule shows up everywhere:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Search can run without Groq.&lt;/li&gt;
&lt;li&gt;Ask mode refuses unrelated questions before spending quota.&lt;/li&gt;
&lt;li&gt;Digest uses selected articles, not the entire database.&lt;/li&gt;
&lt;li&gt;Quiz generation only works on enriched articles.&lt;/li&gt;
&lt;li&gt;Feed ranking uses stored signals, not live model calls.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This made the system cheaper, faster, and less ridiculous.&lt;/p&gt;

&lt;p&gt;LLMs are powerful. They are also expensive, rate-limited, and occasionally very committed to being wrong.&lt;/p&gt;

&lt;p&gt;So Pulse uses them where they add value, and keeps boring deterministic code around them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Final Shape
&lt;/h2&gt;

&lt;p&gt;The final RAG architecture looked like this:&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feedgndjhmlpn90fonvju.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feedgndjhmlpn90fonvju.png" alt="Ingestion Pipeline" width="784" height="441"&gt;&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhye9oamr5b2mgy7786lb.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhye9oamr5b2mgy7786lb.png" alt="Question Answering Pipeline" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That is more work than:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;documents -&amp;gt; embeddings -&amp;gt; chatbot
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;RAG is easy when the input data is clean, the query is friendly, and nobody asks anything weird.&lt;/p&gt;

&lt;p&gt;Useful RAG is different.&lt;/p&gt;

&lt;p&gt;Useful RAG needs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;clean source data&lt;/li&gt;
&lt;li&gt;validated enrichment&lt;/li&gt;
&lt;li&gt;exact search&lt;/li&gt;
&lt;li&gt;semantic search&lt;/li&gt;
&lt;li&gt;hybrid ranking&lt;/li&gt;
&lt;li&gt;relevance thresholds&lt;/li&gt;
&lt;li&gt;citations&lt;/li&gt;
&lt;li&gt;refusal paths&lt;/li&gt;
&lt;li&gt;personalization&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The hard part is not putting vectors in a database.&lt;/p&gt;

&lt;p&gt;The hard part is deciding when the vector result is not good enough.&lt;/p&gt;

&lt;p&gt;The hard part is not calling the LLM.&lt;/p&gt;

&lt;p&gt;The hard part is knowing when not to call it.&lt;/p&gt;

&lt;p&gt;That is what made Pulse useful.&lt;/p&gt;

&lt;p&gt;Not because it could answer everything.&lt;/p&gt;

&lt;p&gt;Because it knew when it could not.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>rag</category>
      <category>postgres</category>
      <category>systemdesign</category>
    </item>
  </channel>
</rss>
