<?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>Hybrid Search and Reranking: What Each One Actually Buys You</title>
      <dc:creator>Ankur Jindal</dc:creator>
      <pubDate>Tue, 06 Oct 2026 12:29:22 +0000</pubDate>
      <link>https://dev.to/aws_sa_sg/hybrid-search-and-reranking-what-each-one-actually-buys-you-151h</link>
      <guid>https://dev.to/aws_sa_sg/hybrid-search-and-reranking-what-each-one-actually-buys-you-151h</guid>
      <description>&lt;p&gt;&lt;em&gt;Enterprise RAG Engineering — Part 2 of 3&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Part 1 Left Off
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://dev.to/aws_sa_sg/why-your-enterprise-rag-pipeline-is-failing-before-the-first-query-runs-3lmb"&gt;Part 1&lt;/a&gt; argued that most enterprise RAG systems fail at the ingestion layer, before a single query is ever issued — and that the fix is layout-aware parsing rather than a better model.&lt;/p&gt;

&lt;p&gt;We built that stage. Docling with &lt;code&gt;HeadingHierarchyOptions&lt;/code&gt; enabled to recover section structure, &lt;code&gt;generate_parsed_pages&lt;/code&gt; on so the font-style signal survives, &lt;code&gt;HybridChunker&lt;/code&gt; to split at structural boundaries rather than fixed token counts, and metadata carried through to every chunk: page number, filename, and the heading breadcrumb the chunk sits under.&lt;/p&gt;

&lt;p&gt;The result is an index where structure survived. Tables did not get flattened into a single text stream. Headings stayed attached to the content they govern. Chunks mean something on their own.&lt;/p&gt;

&lt;p&gt;That was the write path. This article is about the read path — what happens when the first query arrives.&lt;/p&gt;

&lt;p&gt;Because a corpus can be indexed perfectly and still fail to return the right chunk.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Query That Breaks Pure Semantic Search
&lt;/h2&gt;

&lt;p&gt;The default retrieval pattern in almost every RAG tutorial is three lines long. Embed the question, run a nearest-neighbour search, return the top &lt;code&gt;k&lt;/code&gt;. It works well enough in a proof of concept that most teams ship it.&lt;/p&gt;

&lt;p&gt;Then a user searches for &lt;code&gt;WH-1000XM5&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The system returns wireless headphones. All of them. Not that one.&lt;/p&gt;

&lt;p&gt;This is not a tuning problem, and no amount of prompt engineering fixes it. It is a property of how embeddings work.&lt;/p&gt;

&lt;p&gt;An embedding model compresses a span of text into a fixed-width vector that encodes what the text is &lt;em&gt;about&lt;/em&gt;. A model number is not about anything. It is an opaque string with no distributional meaning, and whatever position it occupies in vector space is determined almost entirely by the words around it. Two chunks that both discuss wireless headphones sit close together regardless of which specific model each one names.&lt;/p&gt;

&lt;p&gt;The same failure appears with any exact identifier: a part number, an error code, a control reference, a contract clause, an internal project name. Enterprise corpora are full of them, and users search by them constantly.&lt;/p&gt;

&lt;p&gt;Lexical search has the exact opposite profile. It matches the token and ranks by term statistics, so it finds &lt;code&gt;WH-1000XM5&lt;/code&gt; on the first hop. And it fails completely when a user asks "how long does the battery last" against a page that only ever says "playback time" — because there is no shared token to match.&lt;/p&gt;

&lt;p&gt;Neither retriever is better. They fail on different things. That is the entire argument for running both.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why You Cannot Just Run Both and Add the Scores
&lt;/h2&gt;

&lt;p&gt;Running two retrievers is easy. Merging their results is where hybrid search is actually decided, and the obvious approach fails in a way worth understanding precisely.&lt;/p&gt;

&lt;h3&gt;
  
  
  The two scores are not comparable
&lt;/h3&gt;

&lt;p&gt;Cosine distance is bounded. For normalised vectors it falls in a known range, and a given value means roughly the same thing across queries and across corpora.&lt;/p&gt;

&lt;p&gt;Lexical ranking functions are not bounded. Postgres &lt;code&gt;ts_rank_cd&lt;/code&gt; and BM25 both produce unbounded positive numbers whose magnitude depends on term frequency, match proximity, document length, and the statistics of the corpus being searched. Index another ten thousand documents and the same document scores differently against the same query.&lt;/p&gt;

&lt;p&gt;So one retriever returns &lt;code&gt;0.83&lt;/code&gt; and the other returns &lt;code&gt;24.7&lt;/code&gt;, and there is no constant that reconciles them — because the relationship between the two scales is not fixed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Normalising first makes it worse
&lt;/h3&gt;

&lt;p&gt;The usual workaround is min-max normalisation: scale each list to the range 0 to 1, then blend with a weight.&lt;/p&gt;

&lt;p&gt;This introduces a subtler failure. The normalisation is computed over the candidates that were returned, not over the corpus. If the lexical arm returns 50 documents that are all weak matches, the best of those 50 normalises to 1.0 — the same score a perfect match would receive.&lt;/p&gt;

&lt;p&gt;The normalisation destroys exactly the signal you were trying to preserve, and it does so silently, in precisely the case where the retriever had nothing good to offer.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rank fusion sidesteps the problem instead of solving it
&lt;/h3&gt;

&lt;p&gt;Reciprocal Rank Fusion, introduced by Cormack, Clarke and Büttcher at SIGIR 2009, takes the position that the scores are unsalvageable and discards them entirely. Each document is scored on its position in each list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;RRF(d) = Σ  1 / (k + rank_i(d))
         i
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;rank_i(d)&lt;/code&gt; is the document's 1-indexed position in list &lt;em&gt;i&lt;/em&gt;, and &lt;code&gt;k&lt;/code&gt; is a smoothing constant. The original paper fixed &lt;code&gt;k = 60&lt;/code&gt; during a pilot investigation and found it near-optimal across TREC collections without being especially sensitive to the exact value. It is now the default in Elasticsearch and OpenSearch.&lt;/p&gt;

&lt;p&gt;Because it consumes only rank positions, RRF can fuse any number of ranked lists from any retrievers, whatever they score and however they score it.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the constant 60 does
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Rank&lt;/th&gt;
&lt;th&gt;Contribution&lt;/th&gt;
&lt;th&gt;Share of rank-1&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1 / 61 = 0.01639&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;1 / 62 = 0.01613&lt;/td&gt;
&lt;td&gt;98.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;1 / 80 = 0.01250&lt;/td&gt;
&lt;td&gt;76.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;1 / 160 = 0.00625&lt;/td&gt;
&lt;td&gt;38.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The curve is deliberately flat at the top. Moving from rank 1 to rank 2 costs 1.6% of the score. Moving from rank 1 to rank 20 costs about 24%.&lt;/p&gt;

&lt;p&gt;The consequence is that &lt;strong&gt;agreement outranks confidence&lt;/strong&gt;. A chunk ranked second by both retrievers scores roughly &lt;code&gt;0.0322&lt;/code&gt;. A chunk ranked first by one retriever and missing from the other scores &lt;code&gt;0.0164&lt;/code&gt;. The chunk both retrievers found wins by a factor of two, despite neither ranking it first.&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;k = 0&lt;/code&gt; the formula collapses to pure reciprocal rank, where rank 1 scores 1.0 and rank 2 scores 0.5, and a single retriever's top hit dominates everything. The 60 damps that until consensus between independent signals becomes the deciding factor.&lt;/p&gt;




&lt;h2&gt;
  
  
  How We Built It
&lt;/h2&gt;

&lt;p&gt;Both retrieval arms, the fusion, and the payload fetch run in one database round trip.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why one statement
&lt;/h3&gt;

&lt;p&gt;The alternative is two queries, both result sets pulled into the application, and fusion in Python. That costs two round trips plus a merge loop, and it moves ranking logic away from the layer that holds the data.&lt;/p&gt;

&lt;p&gt;The database can express RRF directly. &lt;code&gt;ROW_NUMBER()&lt;/code&gt; produces the ranks. &lt;code&gt;FULL OUTER JOIN&lt;/code&gt; produces the union. The arithmetic is two divisions and an addition per row. The rows are already there — making the database do the merge is strictly cheaper than shipping them out to merge elsewhere.&lt;/p&gt;

&lt;h3&gt;
  
  
  The query
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Builds an OR query from the question's words, entirely in the database.
# Same stemming and stopword handling as the stored index itself.
&lt;/span&gt;&lt;span class="n"&gt;ANY_TSQUERY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;CAST(array_to_string(ARRAY(
    SELECT &lt;/span&gt;&lt;span class="sh"&gt;''''&lt;/span&gt;&lt;span class="s"&gt; || replace(replace(lexeme, E&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="s"&gt;, E&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="s"&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="s"&gt;) || &lt;/span&gt;&lt;span class="sh"&gt;''''&lt;/span&gt;&lt;span class="s"&gt;
    FROM unnest(tsvector_to_array(to_tsvector(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;english&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;, :question))) AS lexeme
), &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="s"&gt;) AS tsquery)&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

&lt;span class="n"&gt;HYBRID_SQL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
WITH keyword AS (
    SELECT id,
           ROW_NUMBER() OVER (ORDER BY ts_rank_cd(fts, &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ANY_TSQUERY&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;) DESC) AS rank
    FROM chunks
    WHERE fts @@ &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ANY_TSQUERY&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;
    ORDER BY ts_rank_cd(fts, &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ANY_TSQUERY&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;) DESC
    LIMIT :pool
),
semantic AS (
    SELECT id, ROW_NUMBER() OVER (ORDER BY embedding &amp;lt;=&amp;gt; CAST(:qv AS vector)) AS rank
    FROM chunks
    ORDER BY embedding &amp;lt;=&amp;gt; CAST(:qv AS vector)
    LIMIT :pool
)
SELECT c.id, c.content, c.page, c.heading,
       COALESCE(1.0/(60+k.rank), 0) + COALESCE(1.0/(60+s.rank), 0) AS score,
       k.rank AS keyword_rank,
       s.rank AS vector_rank
FROM keyword k
FULL OUTER JOIN semantic s USING (id)
JOIN chunks c USING (id)
ORDER BY score DESC
LIMIT :pool;
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The decisions inside it
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Building the lexical query in the database, not in Python.&lt;/strong&gt; The obvious approach is to split the question in Python and join the terms with an OR operator. That introduces a second tokenizer — and Python does not apply the same stemming or stopword list that produced the stored index. The result is a query whose terms do not match what was indexed.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ANY_TSQUERY&lt;/code&gt; avoids this by running the question through &lt;code&gt;to_tsvector('english', ...)&lt;/code&gt;, the same function with the same configuration that produced the stored vectors, then unnesting the lexemes, quoting each, and joining them. Query-side and index-side analysis are identical because they are the same call.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;replace&lt;/code&gt; calls escape backslashes and quotes so a lexeme containing either cannot break out of the quoted literal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OR, not AND.&lt;/strong&gt; Terms are joined with the OR operator, making this recall-oriented: a chunk matching any term is a candidate. In a fusion pipeline that is correct. The lexical arm's job is to surface anything plausible; precision is handled downstream.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;FULL OUTER JOIN&lt;/code&gt;, not &lt;code&gt;INNER JOIN&lt;/code&gt;.&lt;/strong&gt; This is the structural decision that makes the fusion behave correctly.&lt;/p&gt;

&lt;p&gt;An &lt;code&gt;INNER JOIN&lt;/code&gt; keeps only chunks that both retrievers found — which discards exactly the cases hybrid search exists to catch. The SKU that only the lexical arm found. The paraphrase that only the vector arm found. A &lt;code&gt;LEFT JOIN&lt;/code&gt; privileges one arm arbitrarily.&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;FULL OUTER JOIN&lt;/code&gt;, a chunk present in one list and absent from the other survives with a &lt;code&gt;NULL&lt;/code&gt; rank on the missing side, and &lt;code&gt;COALESCE(..., 0)&lt;/code&gt; contributes zero for that side rather than propagating &lt;code&gt;NULL&lt;/code&gt; through the arithmetic. A single-arm hit scores about &lt;code&gt;0.0164&lt;/code&gt;. A two-arm hit scores up to &lt;code&gt;0.0328&lt;/code&gt;. Both are retrievable; the one both retrievers agreed on ranks higher.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deferring the payload fetch.&lt;/strong&gt; The CTEs carry only ids and ranks. Both arms rank over the full table, and carrying &lt;code&gt;content&lt;/code&gt; through them would mean materialising large text values for rows about to be discarded. Joining for the payload after the fusion keeps the intermediate result sets narrow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two different depths.&lt;/strong&gt; &lt;code&gt;pool&lt;/code&gt; controls how deep each arm searches and how many fused candidates survive. The request's &lt;code&gt;k&lt;/code&gt; controls how many documents come back at the end. They are not the same parameter, and binding the request's &lt;code&gt;k&lt;/code&gt; to the SQL's final &lt;code&gt;LIMIT&lt;/code&gt; would cap the candidate pool at the number of results the user asked for — starving the reranker of exactly the candidates it exists to re-order.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Hybrid Search Bought Us
&lt;/h2&gt;

&lt;p&gt;The gains are capability-level, and they are specific.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Identifier queries work.&lt;/strong&gt; A search for a model number, a part code or a clause reference now returns the chunk containing that string, because the lexical arm finds it on exact match. Before, those queries returned topically adjacent chunks and nothing else. This is the single largest practical difference for an enterprise corpus.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Paraphrase queries still work.&lt;/strong&gt; The vector arm is unchanged, so questions phrased conceptually against documents using different vocabulary behave exactly as they did. Hybrid search is additive — nothing that worked before stopped working.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Single-arm hits are not lost.&lt;/strong&gt; Because of the &lt;code&gt;FULL OUTER JOIN&lt;/code&gt;, a chunk only one retriever found still reaches the candidate set. It ranks below chunks both arms agreed on, which is the correct priority, but it is retrievable. An &lt;code&gt;INNER JOIN&lt;/code&gt; implementation would silently drop exactly the results that justify running two retrievers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The candidate pool is wider without being noisier.&lt;/strong&gt; Two independent retrievers surface a more diverse set of candidates than either alone, and RRF orders them by cross-retriever agreement rather than by whichever scoring function happened to produce a larger number.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No scale calibration to maintain.&lt;/strong&gt; Because RRF consumes only rank positions, there is no normalisation constant that drifts as the corpus grows, and no weight to re-tune after an ingestion run. The fusion behaves the same on 1,000 chunks as on 100,000.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One round trip, not two.&lt;/strong&gt; The entire fusion happens inside the database. There is no second query, no application-side merge loop, and no window where the two result sets could come from different states of the data.&lt;/p&gt;

&lt;p&gt;A note on evidence: these are mechanical gains, demonstrable by construction. Quantified relevance improvement requires running a labelled evaluation set against both configurations — which is the right next step, and which the candidate-depth and threshold parameters should be tuned against rather than by intuition.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why We Added a Reranker
&lt;/h2&gt;

&lt;p&gt;Hybrid search produces a better candidate set. It does not produce a better answer, and the reason is worth stating plainly.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fused score is not a relevance score
&lt;/h3&gt;

&lt;p&gt;The maximum possible RRF score in a two-retriever system is &lt;code&gt;2/61&lt;/code&gt; — about &lt;code&gt;0.0328&lt;/code&gt; — achieved by a chunk ranked first by both arms. That ceiling is fixed by the formula, not by match quality.&lt;/p&gt;

&lt;p&gt;A chunk that answers the question perfectly and a chunk that is merely well-positioned in both lists receive identical scores if they occupy identical ranks. The number tells you where a chunk sat in two ordered lists. It tells you nothing about whether the chunk answers anything.&lt;/p&gt;

&lt;p&gt;This has a direct operational consequence: &lt;strong&gt;any threshold applied to a fused score is a threshold on rank position&lt;/strong&gt;, which is not the same thing as a threshold on relevance and will behave unpredictably as the corpus changes.&lt;/p&gt;

&lt;h3&gt;
  
  
  What a reranker does differently
&lt;/h3&gt;

&lt;p&gt;Both retrieval arms score the query and the document &lt;em&gt;separately&lt;/em&gt;. The vector arm embeds each independently and compares the results. The lexical arm computes term statistics without ever considering the query and document jointly. That independence is what makes them fast and indexable — and it is also their ceiling.&lt;/p&gt;

&lt;p&gt;A cross-encoder reranker processes the query and the document &lt;em&gt;together&lt;/em&gt; in a single forward pass. It can model the interaction between them: whether this specific document actually answers this specific question, rather than whether they occupy nearby regions of a vector space or share tokens.&lt;/p&gt;

&lt;p&gt;That is far more expensive per document, which is exactly why it runs last, over a pool of around 50 candidates rather than the whole corpus. The architecture is deliberate: cheap indexed retrieval narrows millions of chunks to tens, then expensive joint scoring orders those tens.&lt;/p&gt;

&lt;p&gt;In this system that stage is Amazon Bedrock's &lt;code&gt;amazon.rerank-v1:0&lt;/code&gt;, called through the &lt;code&gt;bedrock-agent-runtime&lt;/code&gt; endpoint. The API accepts up to 1,000 inline sources per request, so a 50-candidate pool needs no batching.&lt;/p&gt;

&lt;h3&gt;
  
  
  What it bought us
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;A real quality signal.&lt;/strong&gt; The reranker's &lt;code&gt;relevanceScore&lt;/code&gt; is the only number in the entire pipeline that measures whether a chunk answers the question. Everything upstream measures position or proximity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A threshold that means something.&lt;/strong&gt; Because that score reflects relevance rather than rank, a minimum threshold becomes a meaningful gate. Chunks that survived retrieval on position alone but do not actually answer the question get dropped before they reach the prompt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Variable result counts, honestly.&lt;/strong&gt; A request asking for three results may return fewer — or none. In one evaluation run against a compliance corpus, a question with &lt;code&gt;k=3&lt;/code&gt; returned a single source scoring &lt;code&gt;0.2067&lt;/code&gt;, and the generated answer matched the expected ground truth. The system was not failing. It was reporting that one chunk in the corpus answered that question.&lt;/p&gt;

&lt;p&gt;That behaviour is only possible because a relevance score exists to threshold against. Without it, three chunks come back regardless, and the two weak ones go into the prompt alongside the good one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cleaner context for generation.&lt;/strong&gt; Fewer, better chunks means a shorter prompt, fewer tokens, and less opportunity for the model to anchor on a weakly relevant passage. The quality gate pays for itself at the generation stage as well as the retrieval stage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A usable degraded path.&lt;/strong&gt; Because RRF ordering already exists before the reranker runs, a rerank failure has a sensible fallback: return the fused results truncated to &lt;code&gt;k&lt;/code&gt;. This matters in practice — rerank endpoints throttle, and during testing a real &lt;code&gt;ThrottlingException&lt;/code&gt; was absorbed this way without failing the request.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;reranked_results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;rerank_documents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;documents&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="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exception&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rerank failed, falling back to RRF order&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="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;islice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;documents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things about this fallback are deliberate. It catches bare &lt;code&gt;Exception&lt;/code&gt;, which is correct here because any rerank failure should degrade rather than fail, and enumerating every exception a client library might raise is a losing game. And it skips the relevance threshold, because fallback documents carry no score to filter on — the caller gets up to &lt;code&gt;k&lt;/code&gt; RRF-ranked chunks that may include weak matches. That is the trade: a complete answer on a weaker ranking, rather than no answer. The absent score should be visible to the caller rather than silently defaulted.&lt;/p&gt;




&lt;h2&gt;
  
  
  What You Give Up Without Each One
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Without hybrid search
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Exact-identifier queries fail silently.&lt;/strong&gt; This is the expensive one. A user searching for a model number, an error code or a clause reference gets plausible-looking results that do not contain what they asked for. There is no error and no empty result — just confidently wrong context handed to the model, which then produces a fluent answer about the wrong thing. The failure is invisible until someone checks the citation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rare and domain-specific terms degrade.&lt;/strong&gt; Jargon that is thin in the embedding model's training data has no reliable position in vector space. It embeds near whatever it superficially resembles. Lexical matching does not care how common a term is — it either appears or it does not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Recall depends entirely on one model's view of meaning.&lt;/strong&gt; If the embedding model's notion of similarity does not fit your domain, there is no second signal to compensate. Every retrieval failure traces to the same single point.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Negation and precise phrasing blur.&lt;/strong&gt; Embeddings capture topic well and precise logical structure poorly. Two passages stating opposite conclusions about the same subject can sit close together in vector space.&lt;/p&gt;

&lt;h3&gt;
  
  
  Without a reranker
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;You have no relevance signal at all.&lt;/strong&gt; This is the fundamental loss. Retrieval scores — fused or raw — measure position and proximity. Nothing in the pipeline measures whether a chunk answers the question. Any threshold you set is a threshold on something other than what you care about.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every query returns exactly &lt;code&gt;k&lt;/code&gt; results, whether or not they are any good.&lt;/strong&gt; A question your corpus cannot answer returns &lt;code&gt;k&lt;/code&gt; chunks anyway, because the retrievers always return their top &lt;code&gt;k&lt;/code&gt; by position. Those chunks go into the prompt, and the model does what models do with weakly relevant context — it finds something to say.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You cannot distinguish "nothing matched" from "something matched."&lt;/strong&gt; The system loses the ability to say &lt;em&gt;I don't have that&lt;/em&gt;, which in a compliance or support context is often the most valuable answer available.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The top of the list is nearly undifferentiated.&lt;/strong&gt; The flat RRF curve that makes consensus robust also means ranks 1 through 5 are separated by a few percent. Served directly, that ordering is weak — the fusion was designed to select a candidate set, not to produce a final ranking.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prompt context gets noisier.&lt;/strong&gt; More marginal chunks reach the model, consuming tokens and competing for attention with the one that actually answers the question.&lt;/p&gt;

&lt;h3&gt;
  
  
  The combined picture
&lt;/h3&gt;

&lt;p&gt;Hybrid search without reranking gives you a wider net and no way to judge what you caught. Reranking without hybrid search gives you good judgement applied to a candidate set that may never have contained the right answer.&lt;/p&gt;

&lt;p&gt;The two solve different halves of the same problem. Retrieval decides what is &lt;em&gt;available&lt;/em&gt; to answer with. Reranking decides what is &lt;em&gt;worth&lt;/em&gt; answering with.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;Everything described here runs against a database on a laptop. &lt;strong&gt;Part 3&lt;/strong&gt; takes it to AWS: S3 as the document store, Amazon OpenSearch Service as the vector and lexical index, and an event-driven ingestion path that starts when a file lands in a bucket rather than when someone runs a script.&lt;/p&gt;

&lt;p&gt;That move changes where this article's fusion logic lives. The &lt;code&gt;FULL OUTER JOIN&lt;/code&gt; and the hand-written &lt;code&gt;1/(60+rank)&lt;/code&gt; arithmetic become a search pipeline declaration — OpenSearch implements RRF as a &lt;code&gt;score-ranker-processor&lt;/code&gt; with a configurable &lt;code&gt;rank_constant&lt;/code&gt; that defaults to the same 60. Part 3 covers what that buys, what it costs, and what the migration does to retrieval quality measured against the same evaluation set.&lt;/p&gt;




&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Cormack, Clarke and Büttcher, &lt;em&gt;Reciprocal Rank Fusion Outperforms Condorcet and Individual Rank Learning Methods&lt;/em&gt;, SIGIR 2009&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/bedrock/latest/APIReference/API_agent-runtime_Rerank.html" rel="noopener noreferrer"&gt;Amazon Bedrock Rerank API reference&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/current/textsearch-controls.html" rel="noopener noreferrer"&gt;PostgreSQL text search controls&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>rag</category>
      <category>vectordatabase</category>
      <category>aws</category>
    </item>
    <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>
