<?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: Ankur Jindal</title>
    <description>The latest articles on DEV Community by Ankur Jindal (@aws_sa_sg).</description>
    <link>https://dev.to/aws_sa_sg</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%2F4096934%2F04f3cb8a-93e9-4af0-b452-410524207a3f.png</url>
      <title>DEV Community: Ankur Jindal</title>
      <link>https://dev.to/aws_sa_sg</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/aws_sa_sg"/>
    <language>en</language>
    <item>
      <title>AWS Lambda Managed Instances: What Min=0/Max=0 Actually Does in Production</title>
      <dc:creator>Ankur Jindal</dc:creator>
      <pubDate>Thu, 17 Sep 2026 02:38:34 +0000</pubDate>
      <link>https://dev.to/aws_sa_sg/aws-lambda-managed-instances-what-min0max0-actually-does-in-production-en1</link>
      <guid>https://dev.to/aws_sa_sg/aws-lambda-managed-instances-what-min0max0-actually-does-in-production-en1</guid>
      <description>&lt;p&gt;&lt;em&gt;A hands-on walkthrough, including the exact failures and dead ends you'll hit setting this up.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Introduction: The Production Bottleneck
&lt;/h2&gt;

&lt;p&gt;AWS Lambda Managed Instances (LMI) puts Lambda functions on EC2 capacity you control, while keeping Lambda's programming model. Most of the coverage since re:Invent 2025 has focused on two things: the removal of the memory ceiling, and the EC2-backed pricing model that lets you apply Savings Plans and Reserved Instances.&lt;/p&gt;

&lt;p&gt;Almost none of it covers the scaling configuration — specifically, what happens when &lt;code&gt;MinExecutionEnvironments&lt;/code&gt; and &lt;code&gt;MaxExecutionEnvironments&lt;/code&gt; are both set to &lt;code&gt;0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The assumption most engineers carry over from standard Lambda is that a minimum of zero means "no standing capacity, scale up from zero on the next request." That assumption is wrong for LMI, and it's wrong in a way that produces a hard invoke failure instead of a slow cold start. This post documents the actual mechanics, verified against a live AWS account, along with the setup friction that isn't in the AWS documentation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Mechanics of the Failure Chain
&lt;/h2&gt;

&lt;p&gt;On Lambda Managed Instances, &lt;code&gt;MinExecutionEnvironments&lt;/code&gt; and &lt;code&gt;MaxExecutionEnvironments&lt;/code&gt; are not independent settings. AWS enforces a pairing rule: a minimum of &lt;code&gt;0&lt;/code&gt; is only accepted when the maximum is also &lt;code&gt;0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;When both are set to &lt;code&gt;0&lt;/code&gt;, Lambda does not idle the function and wait for the next invocation. It &lt;strong&gt;deactivates the function version&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Every EC2 Managed Instance backing that version is terminated.&lt;/li&gt;
&lt;li&gt;Instance charges continue until termination actually completes — the meter doesn't stop the moment you save the config.&lt;/li&gt;
&lt;li&gt;The version's &lt;code&gt;State&lt;/code&gt; flips to &lt;code&gt;Deactivated&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Any invocation against a deactivated version returns an explicit error. There's no cold start, no queueing, no retry — it fails outright.&lt;/li&gt;
&lt;li&gt;Reactivation is never automatic. You have to push a new scaling configuration with non-zero values, through the console, the &lt;code&gt;PutFunctionScalingConfig&lt;/code&gt; API, or a scheduled action.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is the opposite of how &lt;code&gt;ReservedConcurrentExecutions=0&lt;/code&gt; or standard Lambda concurrency throttling behaves, and it's the single most important operational difference between LMI and normal Lambda for anyone running non-continuous workloads.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production Architecture Pattern &amp;amp; Walkthrough
&lt;/h2&gt;

&lt;h3&gt;
  
  
  System Architecture Overview
&lt;/h3&gt;

&lt;p&gt;LMI has one prerequisite that standard Lambda doesn't: a &lt;strong&gt;Capacity Provider&lt;/strong&gt;. This is a separate resource that defines:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The VPC, subnets, and security groups the EC2 instances launch into&lt;/li&gt;
&lt;li&gt;Which instance architecture (x86_64 or arm64) and types are eligible&lt;/li&gt;
&lt;li&gt;The scaling mode (Auto or Manual)&lt;/li&gt;
&lt;li&gt;The IAM role Lambda uses to manage EC2 on your behalf (the "Operator Role")&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A function is then attached to a Capacity Provider at creation time by setting its compute type. Only after that attachment exists — and only on a &lt;strong&gt;published version&lt;/strong&gt;, not &lt;code&gt;$LATEST&lt;/code&gt; — do &lt;code&gt;MinExecutionEnvironments&lt;/code&gt; and &lt;code&gt;MaxExecutionEnvironments&lt;/code&gt; become meaningful.&lt;/p&gt;

&lt;h3&gt;
  
  
  Setup Walkthrough: Step by Step
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;1. Create the Capacity Provider and attach it to the function.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In the Lambda console, this doesn't appear as "Compute type" the way older documentation describes it — in the current console it's the &lt;strong&gt;EC2 capacity provider&lt;/strong&gt; toggle under Custom settings. Toggling it on opens a side panel to select the capacity provider ARN, memory size, and the execution-environment-memory-per-vCPU ratio:&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%2Fvipf1hm6idmnaoheahr0.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%2Fvipf1hm6idmnaoheahr0.png" alt="Capacity provider configuration panel" width="799" height="349"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The panel already resolves the capacity provider by ARN once created. Memory and the vCPU ratio (2:1, 4:1, or 8:1) are set per-function here, not per-capacity-provider.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Architecture must match between the function and the capacity provider.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The first real failure: creating the function returned this error on save —&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%2Fq6fclo73g92cws9872v8.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%2Fq6fclo73g92cws9872v8.png" alt="Architecture mismatch error" width="800" height="83"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"You cannot use a Lambda Managed Instances function with a capacity provider that does not support the architecture of the function."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The capacity provider's &lt;code&gt;InstanceRequirements.Architectures&lt;/code&gt; is fixed at creation time (in this case, &lt;code&gt;arm64&lt;/code&gt;). If the function's own architecture toggle doesn't match, function creation is rejected outright — there's no coercion or fallback. Confirmed via CLI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"CapacityProvider"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"CapacityProviderArn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:lambda:us-east-1:&amp;lt;ACCOUNT_ID&amp;gt;:capacity-provider:lambaprovisionedinstance"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"State"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Active"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"VpcConfig"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"SubnetIds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="s2"&gt;"subnet-&amp;lt;redacted&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="s2"&gt;"subnet-&amp;lt;redacted&amp;gt;"&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"SecurityGroupIds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="s2"&gt;"sg-&amp;lt;redacted&amp;gt;"&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"PermissionsConfig"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"CapacityProviderOperatorRoleArn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:iam::&amp;lt;ACCOUNT_ID&amp;gt;:role/service-role/lambaprovisionedinstance-role-&amp;lt;redacted&amp;gt;"&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"InstanceRequirements"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"Architectures"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"arm64"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"CapacityProviderScalingConfig"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"ScalingMode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Auto"&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fix: match the function's architecture toggle to whatever the capacity provider was created with. There's no cross-architecture capacity provider.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Publishing does not prompt for scaling configuration.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Creating and publishing the function does not ask for &lt;code&gt;MinExecutionEnvironments&lt;/code&gt; or &lt;code&gt;MaxExecutionEnvironments&lt;/code&gt; at any point. That configuration lives separately, under &lt;strong&gt;Configuration → Function scaling configuration&lt;/strong&gt;, edited independently after the function exists:&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%2F5icy0lurdo6andb2w3lh.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%2F5icy0lurdo6andb2w3lh.png" alt="Default scaling configuration — no minimum, no maximum set" width="799" height="349"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;With nothing configured, AWS's default behavior applies: &lt;strong&gt;3 Managed Instances provisioned&lt;/strong&gt; across availability zones before the version is marked &lt;code&gt;Active&lt;/code&gt;, invisible in this panel until you explicitly edit it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. &lt;code&gt;$LATEST&lt;/code&gt; is not invocable on LMI — only a published version is.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is a second point of confusion that isn't obvious from the console. Querying &lt;code&gt;$LATEST&lt;/code&gt; directly shows a state most engineers won't recognize:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Configuration"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"FunctionName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"lmi-function"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"FunctionArn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:lambda:us-east-1:&amp;lt;ACCOUNT_ID&amp;gt;:function:lmi-function:$LATEST"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"State"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ActiveNonInvocable"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"Architectures"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"arm64"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ActiveNonInvocable&lt;/code&gt; is a real, documented state (&lt;code&gt;FunctionConfiguration&lt;/code&gt; valid values: &lt;code&gt;Pending | Active | Inactive | Failed | Deactivating | Deactivated | ActiveNonInvocable | Deleting&lt;/code&gt;). It means exactly what it says — &lt;code&gt;$LATEST&lt;/code&gt; cannot run instances on LMI. Only a published version gets EC2 capacity.&lt;/p&gt;

&lt;p&gt;LMI introduces &lt;code&gt;$LATEST.PUBLISHED&lt;/code&gt; specifically for this: a version you can republish repeatedly without managing numbered versions. When created through the console, this is generated automatically. Querying it directly confirms the actual invocable state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Configuration"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"FunctionName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"lmi-function"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"FunctionArn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:lambda:us-east-1:&amp;lt;ACCOUNT_ID&amp;gt;:function:lmi-function:$LATEST.PUBLISHED"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"Version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"$LATEST.PUBLISHED"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"State"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Active"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"Architectures"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"arm64"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Invoking the function via its unqualified ARN implicitly targets &lt;code&gt;$LATEST.PUBLISHED&lt;/code&gt;, not &lt;code&gt;$LATEST&lt;/code&gt; — the reverse of how unqualified invokes behave on standard Lambda.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Managed Instances are hidden from the EC2 console by default.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Once &lt;code&gt;$LATEST.PUBLISHED&lt;/code&gt; showed &lt;code&gt;Active&lt;/code&gt;, the expected 3 instances weren't visible anywhere in the EC2 console, and &lt;code&gt;describe-instances&lt;/code&gt; with a capacity-provider tag filter returned nothing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Reservations"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cause: &lt;strong&gt;Managed resource visibility&lt;/strong&gt;, an account-wide EC2 setting (introduced April 2026) that hides EC2 resources provisioned by services like LMI, EKS Auto Mode, and ECS Managed Instances from the console and &lt;code&gt;Describe*&lt;/code&gt; API calls by default for accounts that didn't have managed resources before the setting existed. The instances are running and billing regardless — visibility is purely a display filter.&lt;/p&gt;

&lt;p&gt;Fix, via CLI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws ec2 modify-managed-resource-visibility &lt;span class="nt"&gt;--default-visibility&lt;/span&gt; visible
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Visibility"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"DefaultVisibility"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"visible"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is account-wide and Region-specific, and applies to all managed-resource offerings, not just LMI. After setting it, the instances appeared after a while in the EC2 console:&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%2Fcvxpmiy36mooue9jpf8b.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%2Fcvxpmiy36mooue9jpf8b.png" alt="EC2 console showing the 3 provisioned Managed Instances" width="800" height="227"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two running, one terminated — consistent with AWS's documented default of 3 instances provisioned for availability across AZs, with normal instance replacement happening in the background.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Verifying the 0/0 deactivation.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Editing the Function scaling configuration to &lt;code&gt;MinExecutionEnvironments=0&lt;/code&gt; and &lt;code&gt;MaxExecutionEnvironments=0&lt;/code&gt; and saving triggers an immediate, visible state transition in the console:&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%2Fx301f0foggqte03x8l15.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%2Fx301f0foggqte03x8l15.png" alt="Deactivating version banner" width="800" height="344"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Deactivating version $LATEST.PUBLISHED."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Within a few minutes, all three EC2 instances move to &lt;code&gt;Terminated&lt;/code&gt;, and invoking the function returns an explicit error referencing the deactivated state — not a timeout, not a cold start, not a retry. This is the core behavior the rest of this post is about: &lt;strong&gt;0/0 is a deactivation switch, not a scale-to-zero setting.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementation Strategies &amp;amp; Deep-Dive Considerations
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Pricing changes the idle-cost math.&lt;/strong&gt; LMI drops Lambda's per-invocation duration billing entirely. Instead, pricing has three components: the standard $0.20 per million requests, standard EC2 instance charges (Savings Plans and Reserved Instances apply), and a flat &lt;strong&gt;15% management fee on top of the on-demand EC2 price&lt;/strong&gt; — applied even when Savings Plans or RIs are discounting the underlying instance cost. That fee doesn't pause during idle traffic. It only stops when instances are actually terminated, which only happens through deactivation or explicit scale-down.&lt;/p&gt;

&lt;p&gt;For a workload with, say, 5 hours of daily invocation activity, this means: unless &lt;code&gt;MinExecutionEnvironments&lt;/code&gt; is actively brought to &lt;code&gt;0/0&lt;/code&gt; during the other 19 hours, you are paying full EC2 rate plus the management fee for the entire idle window — not "only for the 5 hours," the way standard Lambda duration billing would imply.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There's no scale-out SLA.&lt;/strong&gt; AWS does not publish a guaranteed scale-out latency for LMI. The explicit documented warning is that traffic more than doubling within a 5-minute window can produce throttling while capacity catches up. Combined with the 0/0 deactivation behavior, this means bursty or unpredictable workloads should generally avoid 0/0 entirely — the risk isn't a slow response, it's outright invoke failures during both the reactivation window and any sufficiently sharp traffic spike even at steady-state minimums.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;EventBridge Scheduler is the correct pattern for predictable idle windows&lt;/strong&gt;, not manual toggling. A nightly batch job or a fixed 9-to-5 traffic pattern is exactly the case 0/0 is designed for — scheduled deactivation before the idle window, scheduled reactivation before traffic resumes, via &lt;code&gt;PutFunctionScalingConfig&lt;/code&gt; calls triggered on a schedule rather than through the console.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Managed resource visibility is a one-time account setting, not a per-function toggle.&lt;/strong&gt; Worth setting to &lt;code&gt;visible&lt;/code&gt; early in any LMI adoption, since the default-hidden behavior for new accounts otherwise makes basic troubleshooting (like confirming instance count) unexpectedly difficult.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architectural Tradeoffs &amp;amp; Operational Takeaways
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Workload shape&lt;/th&gt;
&lt;th&gt;Recommended scaling config&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Predictable idle windows (nightly batch, fixed business hours)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;0/0&lt;/code&gt; + EventBridge Scheduler&lt;/td&gt;
&lt;td&gt;Stops EC2 + management-fee billing during confirmed idle time; reactivation is scheduled, not reactive&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bursty or unpredictable traffic&lt;/td&gt;
&lt;td&gt;&lt;code&gt;MinExecutionEnvironments &amp;gt; 0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Avoids hard invoke failures during the deactivated window and reduces exposure to the "traffic doubles in 5 minutes" throttling case&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Continuous, steady-state traffic&lt;/td&gt;
&lt;td&gt;Standing non-zero minimum, sized to baseline&lt;/td&gt;
&lt;td&gt;You're paying for compute regardless; this is the workload LMI's EC2 pricing model is built for&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The mechanical rule worth internalizing: &lt;strong&gt;&lt;code&gt;MinExecutionEnvironments=0&lt;/code&gt; is not a cost-saving default the way it is on standard Lambda concurrency settings.&lt;/strong&gt; It only saves cost when paired with &lt;code&gt;Max=0&lt;/code&gt;, and pairing it with &lt;code&gt;Max=0&lt;/code&gt; converts idle time into a hard failure window unless you're actively scheduling reactivation around it.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>serverless</category>
      <category>lambda</category>
      <category>devops</category>
    </item>
    <item>
      <title>Why Your Enterprise RAG Pipeline Is Failing Before the First Query Runs</title>
      <dc:creator>Ankur Jindal</dc:creator>
      <pubDate>Fri, 11 Sep 2026 07:24:37 +0000</pubDate>
      <link>https://dev.to/aws_sa_sg/why-your-enterprise-rag-pipeline-is-failing-before-the-first-query-runs-3lmb</link>
      <guid>https://dev.to/aws_sa_sg/why-your-enterprise-rag-pipeline-is-failing-before-the-first-query-runs-3lmb</guid>
      <description>&lt;h2&gt;
  
  
  Introduction: The Ingestion Layer Is the Real Bottleneck
&lt;/h2&gt;

&lt;p&gt;Most teams building Retrieval-Augmented Generation (RAG) systems invest their engineering effort in two places: the LLM and the retrieval layer. They tune prompts, experiment with embedding models, compare vector databases, and benchmark retrieval precision. When the system underperforms in production, they go back to those same two layers and tune again.&lt;/p&gt;

&lt;p&gt;The failure, in the majority of enterprise RAG deployments, is not there.&lt;/p&gt;

&lt;p&gt;It is at the ingestion layer — specifically at the document parsing stage — and it happens before a single query is ever issued.&lt;/p&gt;

&lt;p&gt;This is not a problem that appears during proof-of-concept work. In a POC, teams typically use small, clean documents: a few well-structured PDFs, maybe a Markdown file or two. Basic parsers work fine on those inputs. The results look reasonable. The system gets approved for production.&lt;/p&gt;

&lt;p&gt;In production, the document corpus looks different. It contains 200-page regulatory policy documents where tables start on page 22 and continue on page 23. It contains scanned audit reports where the actual content lives in raster images. It contains financial disclosures where running headers and footers break into the body text every page. It contains multi-column layouts where two entirely separate sections sit side by side on the same page.&lt;/p&gt;

&lt;p&gt;A basic parser cannot handle any of these cases correctly. It does not fail loudly. It silently discards structural context, flattens layout into a text stream, and passes that degraded output downstream. Every component that follows — the chunker, the embedding model, the vector index — operates on corrupted input. The hallucinations and retrieval failures that appear at query time are traced back to the LLM or the retrieval logic. The actual root cause sits two stages upstream, in the parser, and is never touched.&lt;/p&gt;

&lt;p&gt;This article walks through the precise mechanics of why this happens, and documents a production-grade ingestion pipeline built with &lt;a href="https://github.com/docling-project/docling" rel="noopener noreferrer"&gt;Docling&lt;/a&gt;, LangChain, and a structured metadata extraction layer designed to address these failures at their actual source.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Mechanics of the Document Parsing Failure Chain
&lt;/h2&gt;

&lt;p&gt;Understanding why standard parsers fail requires understanding what a PDF actually is at the data level.&lt;/p&gt;

&lt;p&gt;A PDF file is not a document. It is a page description language. Its internal representation does not encode semantic structure — headings, paragraphs, tables, or sections. It encodes rendering instructions: draw this glyph at this coordinate using this font at this size. There is no native concept of "this text belongs to this table cell" or "this heading is the parent of this section." That structure is a human visual inference from the rendered page layout.&lt;/p&gt;

&lt;p&gt;When a library like PyPDF or PDFMiner processes a PDF, it extracts the character sequences and their approximate positions on the page. Without a layout model, it has no mechanism to determine reading order, column boundaries, or the relationship between spatially separated elements. Its default behaviour is to read the page sequentially — typically left to right, top to bottom — treating the full page width as a single text stream.&lt;/p&gt;

&lt;p&gt;The failure modes this produces in enterprise documents are specific and compounding:&lt;/p&gt;

&lt;h3&gt;
  
  
  Multi-Column Layout Corruption
&lt;/h3&gt;

&lt;p&gt;A two-column regulatory document has column A on the left and column B on the right. A sequential reader scans across the page at each y-coordinate, producing output that reads: left-column sentence fragment, right-column sentence fragment, left-column continuation, right-column continuation. The output is a mixed stream of two entirely unrelated content sections. Both sections are now unparseable as coherent text, and neither can be chunked into a semantically meaningful unit.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cross-Page Table Severing
&lt;/h3&gt;

&lt;p&gt;Enterprise documents routinely contain tables that span page boundaries. The table header row is on page 11; the data rows continue on page 12. A page-scoped parser processes each page independently. The table on page 11 is extracted as a fragment with no data rows. The continuation on page 12 is extracted as rows with no header context. Neither fragment contains enough information to be retrievable as a meaningful unit. When these fragments are chunked and embedded, they produce vectors that cannot answer any query correctly about that table's content.&lt;/p&gt;

&lt;h3&gt;
  
  
  Header and Footer Pollution
&lt;/h3&gt;

&lt;p&gt;Page numbers, document titles, section labels, and legal footers appear on every page of an enterprise document. A parser with no layout model injects these strings into the body text at the point where they appear spatially. A chunk that should contain a clean paragraph of policy text instead contains: policy sentence fragment, "Page 47 of 312 — CONFIDENTIAL", remainder of policy sentence. The embedding of that chunk is polluted by the header token sequence, shifting the vector away from its true semantic position.&lt;/p&gt;

&lt;h3&gt;
  
  
  Silent Image Discard
&lt;/h3&gt;

&lt;p&gt;Charts, diagrams, process flow diagrams, and scanned pages exist in enterprise documents as embedded raster images. A text-extraction parser has no path to retrieve content from these images. It silently skips them. In regulated banking environments — financial disclosures, audit frameworks, risk matrices — a significant fraction of the information that actually matters lives in these images. It never enters the RAG index.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Embedding Corruption Effect
&lt;/h3&gt;

&lt;p&gt;The cumulative effect of these failures arrives at the embedding model. The embedding model has no knowledge of what the original document looked like. It encodes the text sequence it receives. A corrupted chunk — mixed columns, severed table rows, injected headers — produces a vector in a region of the embedding space that does not accurately represent the semantic content of the source material. That vector is then stored in the index and retrieved in response to queries. The retrieved context is wrong. The LLM generates a response based on wrong context. This is the hallucination. Its origin is the parser.&lt;/p&gt;

&lt;p&gt;The failure chain is deterministic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Bad parsing
  → Corrupted text sequences
    → Structurally invalid chunks
      → Semantically inaccurate vectors
        → Irrelevant retrieval
          → LLM hallucination
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No amount of prompt engineering, model selection, or retrieval tuning corrects a failure that originates at stage one.&lt;/p&gt;




&lt;h2&gt;
  
  
  Production Architecture Pattern and Code Walkthrough
&lt;/h2&gt;

&lt;h3&gt;
  
  
  System Architecture Overview
&lt;/h3&gt;

&lt;p&gt;The production ingestion pipeline is composed of four distinct functional layers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Discovery Layer&lt;/strong&gt; — recursive filesystem traversal with format filtering&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Parsing Layer&lt;/strong&gt; — layout-aware document conversion using Docling's &lt;code&gt;DocumentConverter&lt;/code&gt; with a configured &lt;code&gt;PdfPipelineOptions&lt;/code&gt; pipeline&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chunking Layer&lt;/strong&gt; — structure-aware tokenization and context enrichment using Docling's &lt;code&gt;HybridChunker&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Indexing Layer&lt;/strong&gt; — LangChain &lt;code&gt;Document&lt;/code&gt; construction with structured metadata, ready for embedding and vector store ingestion&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The pipeline is designed to be format-agnostic at the discovery layer and format-specific at the parsing layer. PDF files pass through the full &lt;code&gt;PdfPipelineOptions&lt;/code&gt; pipeline. DOCX and Markdown files pass through Docling's native handlers. This avoids the common mistake of running PDF-specific configuration against non-PDF inputs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Core Implementation: &lt;code&gt;chunks.py&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The parsing configuration is the most critical engineering decision in the entire pipeline. Every downstream component inherits the quality of this stage.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;docling.document_converter&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;DocumentConverter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PdfFormatOption&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;docling.datamodel.base_models&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;InputFormat&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;docling.datamodel.pipeline_options&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;PdfPipelineOptions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;TableFormerMode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;HeadingHierarchyOptions&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;docling.chunking&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;HybridChunker&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;load_docs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dir_path&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Recursively discover supported documents. Returns Path objects.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;ROOT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dir_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;EXTS&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;.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;.docx&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;.md&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="p"&gt;[&lt;/span&gt;
        &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ROOT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rglob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_file&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;suffix&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="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;EXTS&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;parse_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file_path&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
    Parse a single document using layout-aware Docling pipeline.
    Returns a (chunker, document) tuple for downstream chunking.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;file_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Pipeline configuration: heading hierarchy and parsed pages enabled;
&lt;/span&gt;    &lt;span class="c1"&gt;# table structure and OCR disabled for native-text PDFs.
&lt;/span&gt;    &lt;span class="n"&gt;opts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PdfPipelineOptions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;do_table_structure&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="n"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;heading_hierarchy_options&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;HeadingHierarchyOptions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;enabled&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="n"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;generate_parsed_pages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="n"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;do_ocr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;

    &lt;span class="n"&gt;converter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;DocumentConverter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;format_options&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;InputFormat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PDF&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;PdfFormatOption&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pipeline_options&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;doc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;converter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;convert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file_path&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt;
    &lt;span class="n"&gt;chunker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;HybridChunker&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;chunker&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;meta_data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
    Extract structured metadata from a chunk&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s ChunkMeta object.
    Page number is derived from provenance data across all doc_items in the chunk.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;page_no&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;doc_items&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prov&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;doc_items&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mi"&gt;0&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;page&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;     &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;filetype&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mimetype&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;metadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;origin&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;heading&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; &amp;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;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headings&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;metadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headings&lt;/span&gt; &lt;span class="k"&gt;else&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;h3&gt;
  
  
  Core Implementation: &lt;code&gt;index.py&lt;/code&gt;
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dotenv&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;load_dotenv&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;llm&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;embed_documents&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;load_docs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;meta_data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;parse_file&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;langchain_core.documents&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Document&lt;/span&gt;

&lt;span class="nf"&gt;load_dotenv&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;process_documents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dir_path&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
    End-to-end ingestion: discover → parse → chunk → contextualize → index.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;docs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;load_docs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dir_path&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;doc_path&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;docs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;chunker&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;parsed_doc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parse_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doc_path&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;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chunk&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;chunker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parsed_doc&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
            &lt;span class="n"&gt;metadata&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;meta_data&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="n"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

            &lt;span class="c1"&gt;# contextualize() prepends the heading breadcrumb path to chunk text.
&lt;/span&gt;            &lt;span class="c1"&gt;# This is the text that goes to the embedding model — not chunk.text.
&lt;/span&gt;            &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;chunker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contextualize&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="n"&gt;document&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Document&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;page_content&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;chunk_index&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;

            &lt;span class="c1"&gt;# embed_documents(document) — downstream call to vector store
&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;process_documents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&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;docs_path&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;h3&gt;
  
  
  Technical Walkthrough: Step by Step
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Step 1 — Document Discovery (&lt;code&gt;load_docs&lt;/code&gt;)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The discovery function uses &lt;code&gt;Path.rglob("*")&lt;/code&gt; to traverse the directory tree recursively. It filters on a defined extension set &lt;code&gt;{".pdf", ".docx", ".md"}&lt;/code&gt; and excludes hidden files by checking &lt;code&gt;p.name.startswith(".")&lt;/code&gt;. This prevents macOS resource fork files (&lt;code&gt;.DS_Store&lt;/code&gt;, &lt;code&gt;._filename&lt;/code&gt;) from entering the pipeline and causing parse errors. The function returns &lt;code&gt;Path&lt;/code&gt; objects rather than string paths, which allows the downstream &lt;code&gt;parse_file&lt;/code&gt; function to leverage &lt;code&gt;pathlib&lt;/code&gt;'s existence check and type-safe path composition.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2 — Layout-Aware Parsing (&lt;code&gt;parse_file&lt;/code&gt;)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is where the pipeline diverges from a naive implementation. Four specific pipeline options are set:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;do_table_structure=False&lt;/code&gt; — This disables the TableFormer model, which is Docling's ML-based table cell recognition model. For native-text PDFs (PDFs with an embedded text layer), the structural relationships between cells can be inferred from the text positions without running the full vision model. Disabling it significantly reduces parse latency per document. For scanned documents or PDFs where tables are rendered as images, this flag must be set to &lt;code&gt;True&lt;/code&gt; and the corresponding &lt;code&gt;do_ocr=True&lt;/code&gt; flag must be enabled.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;HeadingHierarchyOptions(enabled=True)&lt;/code&gt; — By default, Docling's layout model classifies page regions as &lt;code&gt;SECTION_HEADER&lt;/code&gt; without assigning a level. Every heading in the exported document is treated as level 1, producing a flat structure with no parent-child relationship between sections. Enabling &lt;code&gt;HeadingHierarchyOptions&lt;/code&gt; activates a post-processing stage that assigns heading levels using three signals in precedence order: PDF bookmarks from the document outline, leading numbering patterns in the heading text (e.g., &lt;code&gt;1.&lt;/code&gt;, &lt;code&gt;1.1&lt;/code&gt;, &lt;code&gt;2.3.4&lt;/code&gt;), and visual font styling. This recovered hierarchy is what makes the &lt;code&gt;HybridChunker&lt;/code&gt;'s structural chunking meaningful.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;generate_parsed_pages=True&lt;/code&gt; — This option keeps the raw parsed PDF cell data in memory after the layout analysis stage. It is specifically required for the font-style heading inference signal. Without it, the &lt;code&gt;HeadingHierarchyOptions&lt;/code&gt; stage can still use bookmarks and numbering patterns, but font weight, slant, and size comparisons are skipped silently. In enterprise documents that have no bookmarks and no numbered headings — dense policy documents, legal agreements, free-form reports — font styling is often the only reliable heading signal available. This flag must be enabled to use it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;do_ocr=False&lt;/code&gt; — OCR is disabled for native-text PDFs. Running Tesseract or another OCR engine on a PDF that already has an embedded text layer introduces character recognition errors and significantly increases processing time. OCR should only be enabled for scanned documents or for pages that contain image-only regions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 3 — Hybrid Chunking (&lt;code&gt;HybridChunker&lt;/code&gt;)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;HybridChunker&lt;/code&gt; operates in four sequential stages on the parsed &lt;code&gt;DoclingDocument&lt;/code&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Hierarchical pass&lt;/strong&gt; — walks the document's element tree and produces initial chunks at structural boundaries: paragraphs, list items, tables, and picture captions. Each chunk carries the heading path of the section it belongs to.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Merge pass&lt;/strong&gt; — combines consecutive elements that share the same heading path and fit within the token limit. This prevents the common failure of one-sentence chunks that contain insufficient context for accurate retrieval.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Split pass&lt;/strong&gt; — splits single elements that exceed the token limit. Tables use a &lt;code&gt;LineBasedTokenChunker&lt;/code&gt; that repeats the header row for each split segment. Text elements use the &lt;code&gt;semchunk&lt;/code&gt; algorithm, which splits at the most semantically meaningful boundary available rather than at a fixed character or token count.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contextualization&lt;/strong&gt; — the &lt;code&gt;chunker.contextualize(chunk)&lt;/code&gt; call prepends the full heading breadcrumb path to the chunk text before it is passed to the embedding model. A chunk from a section &lt;code&gt;3 &amp;gt; 3.2 &amp;gt; Access Control Requirements&lt;/code&gt; produces an embedding that encodes both the section context and the content — significantly improving retrieval precision for queries that reference specific sections without quoting them verbatim.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Step 4 — Metadata Extraction (&lt;code&gt;meta_data&lt;/code&gt;)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;meta_data&lt;/code&gt; function derives the page number from the provenance data attached to each &lt;code&gt;doc_item&lt;/code&gt; in the chunk's metadata. A chunk that spans multiple pages will have &lt;code&gt;prov&lt;/code&gt; entries from each page; the function takes the minimum page number as the canonical page reference. This is more accurate than line-based page attribution because the &lt;code&gt;HybridChunker&lt;/code&gt; can merge elements from adjacent pages into a single chunk.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;heading&lt;/code&gt; field concatenates the heading path as a &lt;code&gt;" &amp;gt; "&lt;/code&gt;-delimited breadcrumb string. This is stored as vector store metadata and enables filtered retrieval: a query scoped to a specific section of a document can filter on the &lt;code&gt;heading&lt;/code&gt; field rather than relying solely on vector similarity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 5 — LangChain Document Construction&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Each processed chunk is wrapped in a LangChain &lt;code&gt;Document&lt;/code&gt; object with &lt;code&gt;page_content&lt;/code&gt; set to the contextualized text (not &lt;code&gt;chunk.text&lt;/code&gt;) and &lt;code&gt;metadata&lt;/code&gt; containing the structured fields plus the sequential &lt;code&gt;chunk_index&lt;/code&gt;. The &lt;code&gt;chunk_index&lt;/code&gt; enables re-ranking by document position as a secondary signal when multiple chunks from the same document are retrieved with similar cosine scores.&lt;/p&gt;




&lt;h2&gt;
  
  
  Implementation Strategies and Deep-Dive Considerations
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Native PDF vs. Scanned Document Routing
&lt;/h3&gt;

&lt;p&gt;The current implementation sets &lt;code&gt;do_ocr=False&lt;/code&gt; globally. In a production corpus that contains a mix of native-text PDFs and scanned documents, this requires a pre-processing routing stage before the converter is invoked.&lt;/p&gt;

&lt;p&gt;A practical approach is to probe the document's text layer density before routing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;fitz&lt;/span&gt;  &lt;span class="c1"&gt;# PyMuPDF
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;is_native_text_pdf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file_path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sample_pages&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="mi"&gt;3&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;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
    Sample the first N pages to determine if the PDF has a native text layer.
    Returns True for native-text PDFs, False for scanned/image-only PDFs.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;doc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fitz&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file_path&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;pages_to_check&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sample_pages&lt;/span&gt;&lt;span class="p"&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;doc&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;total_chars&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&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;doc&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;get_text&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="nf"&gt;strip&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;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pages_to_check&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&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;total_chars&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;  &lt;span class="c1"&gt;# threshold: fewer than 100 chars implies image-only pages
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Documents that fail the native-text check should be routed to a configuration with &lt;code&gt;do_ocr=True&lt;/code&gt; and, depending on table density, &lt;code&gt;do_table_structure=True&lt;/code&gt; with &lt;code&gt;TableFormerMode.ACCURATE&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Heading Hierarchy Signal Selection
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;HeadingHierarchyOptions&lt;/code&gt; signals apply in precedence order. For enterprise document corpora, the appropriate configuration depends on the document type:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Document Type&lt;/th&gt;
&lt;th&gt;Recommended Signal Configuration&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Regulatory PDF with bookmarked ToC&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;use_bookmarks=True&lt;/code&gt; (default), others as fallback&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Technical specifications with numbered sections&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;use_numbering=True&lt;/code&gt;, &lt;code&gt;use_bookmarks=False&lt;/code&gt; if ToC is absent or unreliable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Legal agreements, free-form policy documents&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;use_style=True&lt;/code&gt;, &lt;code&gt;use_font_style=True&lt;/code&gt;, requires &lt;code&gt;generate_parsed_pages=True&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mixed corpus&lt;/td&gt;
&lt;td&gt;All signals enabled (default when &lt;code&gt;enabled=True&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For a banking regulatory corpus — MAS TRM guidelines, internal policy documents, audit frameworks — the mixed configuration is appropriate because the document types are heterogeneous.&lt;/p&gt;

&lt;h3&gt;
  
  
  The &lt;code&gt;contextualize()&lt;/code&gt; vs &lt;code&gt;chunk.text&lt;/code&gt; Decision
&lt;/h3&gt;

&lt;p&gt;This is a consistently misunderstood detail in Docling-based RAG implementations. &lt;code&gt;chunk.text&lt;/code&gt; contains only the raw text of the chunk's content elements. &lt;code&gt;chunker.contextualize(chunk)&lt;/code&gt; prepends the heading breadcrumb path to that text.&lt;/p&gt;

&lt;p&gt;For a chunk with headings &lt;code&gt;["3. Security Controls", "3.2 Access Management"]&lt;/code&gt; and body text &lt;code&gt;"All privileged access must be logged..."&lt;/code&gt;, the outputs 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="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;All privileged access must be logged...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="n"&gt;chunker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contextualize&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;3. Security Controls
3.2 Access Management
All privileged access must be logged...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The embedding model encodes both versions. The contextualized version places the vector in a region of the embedding space that reflects the section semantics, not just the sentence semantics. A query for "what are the access management logging requirements" retrieves the contextualized chunk with significantly higher cosine similarity than the raw text chunk, because the heading tokens anchor the embedding to the correct domain.&lt;/p&gt;

&lt;p&gt;Always pass &lt;code&gt;chunker.contextualize(chunk)&lt;/code&gt; to the embedding model. Store &lt;code&gt;chunk.text&lt;/code&gt; separately if you need the raw text for display purposes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Metadata as a Retrieval Filter Layer
&lt;/h3&gt;

&lt;p&gt;The metadata schema produced by &lt;code&gt;meta_data()&lt;/code&gt; — &lt;code&gt;page&lt;/code&gt;, &lt;code&gt;filetype&lt;/code&gt;, &lt;code&gt;filename&lt;/code&gt;, &lt;code&gt;heading&lt;/code&gt;, &lt;code&gt;chunk_index&lt;/code&gt; — serves a dual purpose. It is stored alongside each vector in the vector store and enables metadata-filtered retrieval.&lt;/p&gt;

&lt;p&gt;In regulated environments, this has a direct compliance application. A query scoped to a specific document version (&lt;code&gt;filename&lt;/code&gt; filter) or a specific section (&lt;code&gt;heading&lt;/code&gt; filter) can be constrained at the retrieval layer rather than relying on the LLM to perform that scoping from the retrieved context. This reduces the surface area for cross-document content bleed, where a retrieved chunk from document version N-1 is used to answer a query that should be answered from version N.&lt;/p&gt;




&lt;h2&gt;
  
  
  Architectural Tradeoffs and Operational Takeaways
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Compute Overhead: &lt;code&gt;generate_parsed_pages=True&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Keeping the parsed PDF cells in memory (&lt;code&gt;generate_parsed_pages=True&lt;/code&gt;) increases per-document memory consumption. For large documents — 200+ pages — this can add 300–500 MB of in-process memory overhead per concurrent parse operation. In a batch ingestion workload running on a fixed instance, the concurrency ceiling must be set to account for this.&lt;/p&gt;

&lt;p&gt;The tradeoff is worth it for enterprise corpora where free-form documents without bookmarks are common. For corpora where all documents have reliable PDF outlines or numbered sections, &lt;code&gt;generate_parsed_pages&lt;/code&gt; can be set to &lt;code&gt;False&lt;/code&gt; to reduce memory pressure, with &lt;code&gt;use_style=False&lt;/code&gt; in the &lt;code&gt;HeadingHierarchyOptions&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;do_table_structure=False&lt;/code&gt; Tradeoff
&lt;/h3&gt;

&lt;p&gt;Disabling the TableFormer model eliminates the ML inference cost for table structure recognition, which is the most expensive single operation in the Docling pipeline for table-dense documents. For native-text PDFs, this is a reasonable tradeoff because cell relationships can be preserved through text-position inference.&lt;/p&gt;

&lt;p&gt;The failure mode: if native-text PDFs contain tables that use borderless layouts or unconventional spacing, the text-position inference may still produce incorrect cell associations. A validation step that samples the parsed table output before indexing is strongly recommended for corpora with complex table structures.&lt;/p&gt;

&lt;h3&gt;
  
  
  Batch vs. Streaming Ingestion
&lt;/h3&gt;

&lt;p&gt;The current implementation processes documents sequentially in a single process. For initial corpus ingestion at enterprise scale — thousands of documents — this does not meet practical time constraints. A production deployment should parallelise at the document level using a worker pool:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;concurrent.futures&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ProcessPoolExecutor&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;process_documents_parallel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dir_path&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;max_workers&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="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;docs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;load_docs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dir_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nc"&gt;ProcessPoolExecutor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_workers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;max_workers&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;executor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;futures&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;executor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;submit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parse_and_index&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;doc_path&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="n"&gt;doc_path&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;doc_path&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;docs&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;future&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;futures&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;future&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;result&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="nf"&gt;print&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;Failed: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;futures&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;future&lt;/span&gt;&lt;span class="p"&gt;]&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;e&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Worker count should be tuned based on available CPU cores and the memory overhead of &lt;code&gt;generate_parsed_pages=True&lt;/code&gt;. On an &lt;code&gt;m6i.4xlarge&lt;/code&gt; (16 vCPU, 64 GiB), a practical ceiling is 4–6 workers for large-document corpora.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why This Architecture Scales Where Others Don't
&lt;/h3&gt;

&lt;p&gt;The combination of layout-aware parsing, structure-driven chunking, and heading-enriched contextualization produces an index where retrieved chunks carry their structural position in the source document as part of the vector encoding. This has a measurable effect on retrieval precision for the query types that dominate enterprise RAG use cases: policy lookups, section-specific compliance checks, and cross-document comparisons.&lt;/p&gt;

&lt;p&gt;The cost of this precision is paid once, at ingestion time. The alternative — fast parsing, naive chunking, no contextualization — produces an index that is cheap to build and unreliable to query. In a regulated environment, an unreliable RAG system is not a minor inconvenience. It is an audit risk.&lt;/p&gt;

&lt;p&gt;Fix the ingestion layer. Everything downstream inherits its quality.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>genai</category>
      <category>llm</category>
      <category>aiengineering</category>
    </item>
    <item>
      <title>Every LLM Request Has Two Halves. Only One Uses Your GPU Cores</title>
      <dc:creator>Ankur Jindal</dc:creator>
      <pubDate>Mon, 31 Aug 2026 06:50:21 +0000</pubDate>
      <link>https://dev.to/aws_sa_sg/every-llm-request-has-two-halves-only-one-uses-your-gpu-cores-1308</link>
      <guid>https://dev.to/aws_sa_sg/every-llm-request-has-two-halves-only-one-uses-your-gpu-cores-1308</guid>
      <description>&lt;p&gt;Paste a long document into ChatGPT and hit enter.&lt;/p&gt;

&lt;p&gt;Nothing happens for a second or two. Then the answer starts appearing, word by word, at a steady pace until it finishes.&lt;/p&gt;

&lt;p&gt;You have seen this hundreds of times. Most people never think about it.&lt;/p&gt;

&lt;p&gt;But those are two completely different things happening inside one request, running on two different parts of the same GPU, limited by two different bottlenecks. Once you know what they are, a lot of confusing things about serving AI models stop being confusing.&lt;/p&gt;

&lt;p&gt;Including why a faster GPU sometimes makes no difference at all.&lt;/p&gt;

&lt;p&gt;This is part two of a series. &lt;a href="https://dev.to/aj_aws_sa_sg/a-gpu-is-two-things-and-only-one-of-them-holds-your-model-4bbh"&gt;Part one&lt;/a&gt; covered the split between VRAM and cores. This post covers what each of them does when a request arrives.&lt;/p&gt;

&lt;h2&gt;
  
  
  The example we will follow
&lt;/h2&gt;

&lt;p&gt;I am going to trace one question the whole way through:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;what is the capital of france&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Simple, short, and it produces a short answer. Perfect for watching the machinery.&lt;/p&gt;

&lt;p&gt;Before anything happens, the model server has already loaded the model's weights into VRAM. That happened at startup and they stay there. Nothing about your request loads a model.&lt;/p&gt;

&lt;p&gt;Your words get chopped into tokens, which are just pieces of words turned into numbers, and placed in VRAM alongside the weights.&lt;/p&gt;

&lt;p&gt;Now the work begins.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase one: prefill, or "the pause"
&lt;/h2&gt;

&lt;p&gt;Before the model can write anything, it has to read everything you gave it.&lt;/p&gt;

&lt;p&gt;Here is the important part, and it is the opposite of what most people assume: &lt;strong&gt;it reads your entire prompt at once.&lt;/strong&gt; Not word by word. All six words go to the cores together, in a single pass.&lt;/p&gt;

&lt;p&gt;That is possible because your whole prompt is already there. Nothing has to wait for anything else. So thousands of cores fire simultaneously and chew through all of it in one burst.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
    A["what is the capital of france&amp;lt;br/&amp;gt;all 6 tokens together"] --&amp;gt; C
    W["16 GB of model weights"] --&amp;gt; C
    C["GPU CORES&amp;lt;br/&amp;gt;every core busy"] --&amp;gt; O1["First word: 'The'"]
    C --&amp;gt; O2["Notes saved to VRAM&amp;lt;br/&amp;gt;the KV cache"]&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;Two things come out of that single pass:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The first word of your answer.&lt;/strong&gt; Just one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A set of working notes&lt;/strong&gt;, saved into VRAM. These notes summarise everything the model worked out about your prompt. The technical name is the KV cache.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The moment that first word appears is the moment the pause ends.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How long is the pause?&lt;/strong&gt; It depends entirely on how much you gave it to read. Six words is nothing, so you would not notice. Paste a hundred-page contract and that is tens of thousands of tokens pushed through in one burst, and now the pause is real.&lt;/p&gt;

&lt;p&gt;This phase has a name: &lt;strong&gt;prefill&lt;/strong&gt;. And the pause has a name too: &lt;strong&gt;TTFT&lt;/strong&gt;, time to first token. It is one of the two numbers every serving benchmark reports.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is the bottleneck here?&lt;/strong&gt; The cores. There is a mountain of maths to do and they are all doing it. This phase is compute-heavy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase two: decode, or "the typing"
&lt;/h2&gt;

&lt;p&gt;Now the model writes the rest of the answer. One word per pass.&lt;/p&gt;

&lt;p&gt;To produce each word, the cores need three things: the notes from before, the word that was just produced, and the model's weights. All of it read out of VRAM.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
    N["Notes in VRAM&amp;lt;br/&amp;gt;the KV cache"] --&amp;gt; C
    L["The word just produced"] --&amp;gt; C
    W["16 GB of weights&amp;lt;br/&amp;gt;read again, in full"] --&amp;gt; C
    C["GPU CORES&amp;lt;br/&amp;gt;barely used"] --&amp;gt; O["One new word"]
    O -.-&amp;gt;|"repeat"| N&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;That loop runs once per word until the answer is done:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Lap 1 → "The"
Lap 2 → "capital"
Lap 3 → "is"
Lap 4 → "Paris"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is why the answer types itself out instead of appearing all at once. You are watching each lap finish. Streaming is not a feature someone built. It is just what happens when each word is sent the moment it exists.&lt;/p&gt;

&lt;p&gt;This phase is called &lt;strong&gt;decode&lt;/strong&gt;, and the gap between words is &lt;strong&gt;TPOT&lt;/strong&gt;, time per output token. That is the second benchmark number.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is the bottleneck here?&lt;/strong&gt; Not the cores. Look at what has to happen for a single word: the entire model, all 16 GB of it, has to travel out of VRAM and past the cores. The cores glance at it, do a small amount of maths, and produce one word.&lt;/p&gt;

&lt;p&gt;Then it all happens again for the next word.&lt;/p&gt;

&lt;p&gt;The cores are mostly idle. The memory pipe is flat out. &lt;strong&gt;Decode is limited by how fast the GPU can read its own memory.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why word three does not need a fresh pause
&lt;/h2&gt;

&lt;p&gt;Here is the question I got stuck on, and it is worth its own section.&lt;/p&gt;

&lt;p&gt;To predict word 3, the model needs to know everything before it. Your prompt, plus word 1, plus word 2. So does it read all of that again?&lt;/p&gt;

&lt;p&gt;If it did, every single word would have its own pause. The whole thing would be unusable.&lt;/p&gt;

&lt;p&gt;It does not, because of the notes.&lt;/p&gt;

&lt;p&gt;Every word that gets produced is added to the notes as it goes. So by the time lap 3 starts, the notes already contain your prompt, "The", and "capital". The model reads those notes and only has to process &lt;strong&gt;the one word that just came out.&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;Lap 3 reads:  notes (prompt + "The" + "capital")  +  the word "capital"
Lap 3 does:   process one word
Lap 3 gives:  "is"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing gets forgotten and nothing gets reprocessed. The model always sees the full history. It just never redoes the work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You pay for the pause once, not once per word.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Now the part that changes how you buy hardware
&lt;/h2&gt;

&lt;p&gt;Here is where this gets practical.&lt;/p&gt;

&lt;p&gt;Every word requires one full read of the model out of VRAM. So the maximum speed of a model on a given card is a division:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;How fast can the card read its own memory?
─────────────────────────────────────────  =  words per second
        How much is there to read?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Let me do it with real numbers.&lt;/p&gt;

&lt;p&gt;Take an AWS &lt;code&gt;g6e.xlarge&lt;/code&gt;. The GPU inside is a single NVIDIA L40S with 48 GB of VRAM. NVIDIA's datasheet gives its memory bandwidth as &lt;strong&gt;864 GB/s&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Now put a model on it. Qwen3.8-27B is a 28-billion-parameter model. In its published form each weight takes 2 bytes, so:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;28 billion × 2 bytes = 56 GB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That does not fit in 48 GB. So we use the Quantization version FP8 , where each weight takes 1 byte:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;28 billion × 1 byte = 28 GB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the division:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;864 GB/s ÷ 28 GB ≈ 31 words per second
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the ceiling. Two numbers off two spec sheets, and you know roughly how fast that model will generate on that card. No deployment required to calculate this.&lt;/p&gt;

&lt;p&gt;And notice what shrinking the model did. At 56 GB the ceiling would have been about 15 words per second. Halving the weights doubled the speed, because there is half as much to read every single lap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A caveat worth stating plainly:&lt;/strong&gt; that number is a theoretical ceiling for one user, and real throughput lands meaningfully below it. The notes have to be read too, and no software achieves perfect memory utilisation. Treat it as a sanity check, not a promise.&lt;/p&gt;

&lt;h2&gt;
  
  
  The AWS gap nobody mentions
&lt;/h2&gt;

&lt;p&gt;Here is something that caught me out, and I suspect it catches out a lot of people.&lt;/p&gt;

&lt;p&gt;That bandwidth figure, the number that decides your generation speed, &lt;strong&gt;is not on AWS GPU Instance page.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Look at an EC2 GPU instance table and you get the card name, GPU memory, vCPUs, system memory, and storage. Some tables have a column called "EBS Bandwidth" and another called "Network Bandwidth", which look like the answer and are not. Those are disk and network throughput. Neither has anything to do with VRAM.&lt;/p&gt;

&lt;p&gt;The three bandwidths worth separating:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Bandwidth&lt;/th&gt;
&lt;th&gt;Between what&lt;/th&gt;
&lt;th&gt;Typical scale&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Memory bandwidth&lt;/td&gt;
&lt;td&gt;VRAM and cores&lt;/td&gt;
&lt;td&gt;hundreds of GB/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PCIe&lt;/td&gt;
&lt;td&gt;GPU card and server&lt;/td&gt;
&lt;td&gt;tens of GB/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Network / EBS&lt;/td&gt;
&lt;td&gt;Server and the outside world&lt;/td&gt;
&lt;td&gt;Gbps&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Only the first one governs how fast words come out, and AWS does not publish it.&lt;/p&gt;

&lt;p&gt;So the workflow is: read the card name off the AWS table, then go to NVIDIA's datasheet for that card and find the field labelled &lt;strong&gt;Memory Bandwidth&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Everything above describes &lt;strong&gt;one user&lt;/strong&gt;.
&lt;/h2&gt;

&lt;p&gt;You might reasonably be looking at "31 words per second" and thinking that sounds poor for a server. And it would be, if that was all it could do.&lt;/p&gt;

&lt;p&gt;But look again at what happens during decode. The GPU reads the entire model out of VRAM to produce one word for one person, and the cores sit mostly idle while it happens.&lt;/p&gt;

&lt;p&gt;So why not use that same read to produce a word for fifty people at once?&lt;/p&gt;

&lt;p&gt;That is exactly what real serving software does, and it is why the economics of hosting a model work at all. It also introduces a new limit, a different kind of bottleneck, and the reason ordinary load balancing falls apart completely for AI workloads.&lt;/p&gt;

&lt;p&gt;That will cover in upcoming posts.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>ai</category>
      <category>machinelearning</category>
      <category>llm</category>
    </item>
    <item>
      <title>A GPU Is Two Things, and Only One of Them Holds Your Model</title>
      <dc:creator>Ankur Jindal</dc:creator>
      <pubDate>Thu, 27 Aug 2026 08:39:52 +0000</pubDate>
      <link>https://dev.to/aws_sa_sg/a-gpu-is-two-things-and-only-one-of-them-holds-your-model-4bbh</link>
      <guid>https://dev.to/aws_sa_sg/a-gpu-is-two-things-and-only-one-of-them-holds-your-model-4bbh</guid>
      <description>&lt;p&gt;I have run AWS infrastructure for years. When I started looking at how large language models actually get served, I hit a wall on something basic.&lt;/p&gt;

&lt;p&gt;Someone said "the model sits on the GPU." Someone else said "the GPU does the calculation." Both sounded right. Neither told me where the model actually lives.&lt;/p&gt;

&lt;p&gt;The problem is that "GPU" is not one thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two parts, one board
&lt;/h2&gt;

&lt;p&gt;You already know how a normal machine is laid out. A CPU does the work. RAM holds the data. They sit apart, connected by a bus.&lt;/p&gt;

&lt;p&gt;A GPU card is the same pair, shrunk onto one board:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cores&lt;/strong&gt; — the chip in the middle. Thousands of small processors that do maths.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;VRAM&lt;/strong&gt; — memory chips around it. Holds the data.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So when people say different things, they are talking about different halves:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What they say&lt;/th&gt;
&lt;th&gt;What they mean&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;"The GPU has 80GB"&lt;/td&gt;
&lt;td&gt;The VRAM&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"The GPU is fast"&lt;/td&gt;
&lt;td&gt;The cores&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"The model is on the GPU"&lt;/td&gt;
&lt;td&gt;The weights are in VRAM&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Neither half is "the GPU" on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cores hold nothing
&lt;/h2&gt;

&lt;p&gt;This is the part that took me longest to accept.&lt;/p&gt;

&lt;p&gt;The cores have almost no storage. They are hands, not shelves. The model does not get loaded into them once and stay there. It has to be streamed across from VRAM, continuously, for every calculation.&lt;/p&gt;

&lt;p&gt;An 8 billion parameter model at full precision is about 16GB of numbers. Those numbers live in VRAM. To produce a single word of an answer, the GPU streams the whole thing across to the cores, does the maths, and gets one word back. Then it does it again for the next word.&lt;/p&gt;

&lt;p&gt;That one fact explains why answers type themselves out instead of appearing all at once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this bites you on AWS
&lt;/h2&gt;

&lt;p&gt;Here is the practical part.&lt;/p&gt;

&lt;p&gt;Look at a GPU instance in the EC2 pricing console:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;g6.4xlarge    $1.3232/hr    16 vCPU    64 GiB    1 x 600 GB NVMe
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That 64 GiB looks like plenty of room for a model. It is not GPU memory. It is the server's ordinary system RAM, and inference barely touches it.&lt;/p&gt;

&lt;p&gt;The GPU on that instance is a single NVIDIA L4 with &lt;strong&gt;24GB&lt;/strong&gt; of VRAM. That 24GB is the only number that decides whether your model loads.&lt;/p&gt;

&lt;p&gt;The EC2 pricing page has no GPU column at all. You have to cross-reference the instance-types page to find the card and its memory.&lt;/p&gt;

&lt;p&gt;Worth memorising for the common G-family instances:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Family&lt;/th&gt;
&lt;th&gt;GPU&lt;/th&gt;
&lt;th&gt;VRAM&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;g4dn&lt;/td&gt;
&lt;td&gt;T4&lt;/td&gt;
&lt;td&gt;16GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;g5&lt;/td&gt;
&lt;td&gt;A10G&lt;/td&gt;
&lt;td&gt;24GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;g6&lt;/td&gt;
&lt;td&gt;L4&lt;/td&gt;
&lt;td&gt;24GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;g6e&lt;/td&gt;
&lt;td&gt;L40S&lt;/td&gt;
&lt;td&gt;48GB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  One sentence to take away
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Weights live in VRAM. Cores do the maths. Neither one is "the GPU" by itself.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Everything else in model serving follows from that split.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>ai</category>
      <category>cloud</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
