<?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: Prasanth Pattathil</title>
    <description>The latest articles on DEV Community by Prasanth Pattathil (@prasanth_pattathil).</description>
    <link>https://dev.to/prasanth_pattathil</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%2F4213898%2F913ede6a-590f-4db0-95a8-5bede9fc1c37.png</url>
      <title>DEV Community: Prasanth Pattathil</title>
      <link>https://dev.to/prasanth_pattathil</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/prasanth_pattathil"/>
    <language>en</language>
    <item>
      <title>Invoice Extraction with Human Review: Route on Checks, Not on Confidence</title>
      <dc:creator>Prasanth Pattathil</dc:creator>
      <pubDate>Mon, 26 Oct 2026 11:01:02 +0000</pubDate>
      <link>https://dev.to/prasanth_pattathil/invoice-extraction-with-human-review-route-on-checks-not-on-confidence-1non</link>
      <guid>https://dev.to/prasanth_pattathil/invoice-extraction-with-human-review-route-on-checks-not-on-confidence-1non</guid>
      <description>&lt;p&gt;A design case study: why a confidence score can’t decide which invoices get paid, and what to route on instead.&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%2Fmy6e9p0ik9ttxc9bnon1.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%2Fmy6e9p0ik9ttxc9bnon1.png" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;About this case study.&lt;/strong&gt; This is a composite scenario built on synthetic invoices. It shows a design and how to measure it. The numbers are planning figures, not benchmark results.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  The problem
&lt;/h3&gt;

&lt;p&gt;A mid-sized distributor gets about 6,000 supplier invoices a month. They arrive as PDFs, scans and phone photos in more than one language. Three accounts-payable clerks type each one into the ERP: vendor, invoice number, dates, line items, tax, total, and the purchase order it belongs to.&lt;/p&gt;

&lt;p&gt;The ask from finance was simple: “Let the AI read the invoices, and have people check only the ones it is unsure about.”&lt;/p&gt;

&lt;p&gt;That sentence hides the hard part. &lt;strong&gt;How does the system know when it is unsure?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fttiqlc4nchj8x7d0z1ua.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%2Fttiqlc4nchj8x7d0z1ua.png" width="800" height="370"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Today every field is typed by hand. The goal splits invoices into clean and unsure, but who decides which is which?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The obvious answer is the model’s confidence score: auto-approve above 90%, send to a person below it. That is the design this post argues against.&lt;/p&gt;
&lt;h3&gt;
  
  
  Why confidence alone is the wrong gate
&lt;/h3&gt;

&lt;p&gt;Extraction services return a confidence number for each field. Amazon Textract’s AnalyzeExpense, for example, gives separate scores for the label it found (“Total:”) and the value next to it (“$55.64”). Those scores are useful, but they answer a narrow question: &lt;em&gt;how sure am I that I read these characters correctly?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq6wtuwzbweufp3be7sl3.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%2Fq6wtuwzbweufp3be7sl3.png" width="800" height="408"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;A total read with 99.9% confidence on an invoice that should never be paid.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;They do not answer the questions finance cares about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Do the line items add up to the total?&lt;/li&gt;
&lt;li&gt;Is this a vendor we actually do business with?&lt;/li&gt;
&lt;li&gt;Does it match an open purchase order?&lt;/li&gt;
&lt;li&gt;Have we already paid this invoice?&lt;/li&gt;
&lt;li&gt;Are the bank details the same ones we have on file?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A model can read a total of $12,480.00 with 99.9% confidence on an invoice that is a duplicate, that belongs to no purchase order, or that asks us to pay a new bank account. High confidence, wrong outcome. The worst errors in accounts payable are not misread digits. They are perfectly read invoices that should never be paid.&lt;/p&gt;

&lt;p&gt;So the rule for this system is the same one Post 1 used for permissions:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Model output is a claim. Checks against our own data are the evidence.&lt;/strong&gt; The model extracts; deterministic checks decide.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;
  
  
  The design in one picture
&lt;/h3&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%2F6lwpubndrc1sltert60t.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%2F6lwpubndrc1sltert60t.png" width="799" height="534"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Extract, validate, route: three outcomes, a 2% audit, and every correction stored.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Five decisions shape this design.&lt;/p&gt;
&lt;h3&gt;
  
  
  Decision 1: The extractor is replaceable; the routing is the product
&lt;/h3&gt;

&lt;p&gt;There are two reasonable extraction options on AWS today:&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%2Fjc20i75un863fdngnbru.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%2Fjc20i75un863fdngnbru.png" width="799" height="330"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Textract vs. Bedrock Data Automation.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnzdp1fxl631kzuhugf78.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%2Fnzdp1fxl631kzuhugf78.png" width="800" height="357"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The extractor sits behind an adapter. The checks, routing and review loop stay the same.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;We start with AnalyzeExpense and put it behind a small interface that returns one normalized Invoice object: fields, values, confidence and page geometry. If BDA does better on our golden set later, we swap the extractor and change nothing downstream.&lt;/p&gt;

&lt;p&gt;That is the point of this decision. Extraction quality will keep improving and vendors will keep changing. The checks, the routing and the review loop are where our business rules live, so they should not be tied to one extractor.&lt;/p&gt;
&lt;h3&gt;
  
  
  Decision 2: Validate with deterministic checks
&lt;/h3&gt;

&lt;p&gt;After extraction, every invoice runs the same checks. None of them uses a model.&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%2Fjg35h8rn2d4lj1kcr8uh.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%2Fjg35h8rn2d4lj1kcr8uh.png" width="800" height="512"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Two checks stop an invoice outright; the rest send it to review with a reason.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Two rules are worth pointing out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Match vendors by tax ID, not name.&lt;/strong&gt; “ACME Ltd”, “Acme Limited” and “ACME LTD.” are the same company to a person and three companies to a string comparison. Names are for display; IDs are for joins.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never update bank details from an invoice.&lt;/strong&gt; Changed bank details on an invoice are the most common sign of payment fraud. If the invoice disagrees with the vendor master, the invoice goes to a separate queue that only the vendor-management team can clear, and they confirm by calling a phone number already on file. No confidence score can turn this off.&lt;/p&gt;
&lt;h3&gt;
  
  
  Decision 3: Thresholds per field, set by the cost of being wrong
&lt;/h3&gt;

&lt;p&gt;One global threshold treats a misread invoice date the same as a misread total. Those mistakes do not cost the same.&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%2F3gcygxaf7zut132r1mtq.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%2F3gcygxaf7zut132r1mtq.png" width="800" height="361"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Higher cost of error, higher bar to skip review.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;These starting values are not magic. They are tuned on the golden set (Decision 5), and the target is set per field: &lt;em&gt;what is the most auto-approval we can get while keeping this field’s escaped error rate under X?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Arithmetic checks also give a second signal that confidence cannot. If the total was read with 99% confidence but the line items do not add up, one of the numbers is wrong, and confidence did not catch it.&lt;/p&gt;
&lt;h3&gt;
  
  
  Decision 4: Build the review queue; show reviewers only what is flagged
&lt;/h3&gt;
&lt;h4&gt;
  
  
  Why not Amazon A2I
&lt;/h4&gt;

&lt;p&gt;Amazon Augmented AI (A2I) used to be the default answer for “human review on AWS.” In June 2026 AWS announced that A2I, along with SageMaker Ground Truth, is moving to maintenance and stopped accepting new customers on July 30, 2026. A new system should not be built on it.&lt;/p&gt;

&lt;p&gt;The review loop we need is small enough to own:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Step Functions with a task token.&lt;/strong&gt; The workflow pauses at the review step with .waitForTaskToken. The review UI calls SendTaskSuccess with the corrected invoice, and the workflow continues to the ERP. No polling and no extra state machine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DynamoDB&lt;/strong&gt; holds the queue: invoice ID, reasons for review, assigned reviewer, SLA timestamp.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A small web UI&lt;/strong&gt; behind Cognito shows the PDF with bounding boxes &lt;strong&gt;only around the flagged fields&lt;/strong&gt; , plus the reason (“line items sum to 11,980.00; total says 12,480.00”).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwq3sy9agivzpjd8bo7xq.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%2Fwq3sy9agivzpjd8bo7xq.png" width="800" height="547"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Left: the workflow pauses on a task token until a reviewer finishes. Right: the reviewer sees only the flagged fields, with the reason.&lt;/em&gt;&lt;/p&gt;
&lt;h4&gt;
  
  
  Design the screen for speed, not completeness
&lt;/h4&gt;

&lt;p&gt;The first review screen showed all 25 fields. Reviewers re-checked every one, so reviewing took almost as long as typing. Showing only the flagged fields, with the reason and the highlighted region, turns a review into a 20-second decision instead of a three-minute re-key.&lt;/p&gt;

&lt;p&gt;Two rules keep this honest:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Reviewers must touch every flagged field.&lt;/strong&gt; Each flagged field needs an explicit “confirm” or a correction, so a reviewer can’t approve the whole invoice in one click.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hard stops never go to the normal queue.&lt;/strong&gt; Duplicate and bank-detail cases have their own queue, their own role, and a four-eyes rule.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;
  
  
  Decision 5: Measure what escapes, not just what gets reviewed
&lt;/h3&gt;

&lt;p&gt;This is the decision most teams skip, and it is the one that matters most.&lt;/p&gt;

&lt;p&gt;If you only look at invoices that went to review, you learn how often reviewers correct the model. You learn &lt;strong&gt;nothing&lt;/strong&gt; about the invoices that were auto-approved, and those are the ones that turn into wrong payments.&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%2Fc6fce9lsit8xd8issvgn.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%2Fc6fce9lsit8xd8issvgn.png" width="800" height="522"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The random audit is the only window into invoices nobody looked at.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;So we measure four things:&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%2F8hii4oxailf759h0ydt7.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%2F8hii4oxailf759h0ydt7.png" width="800" height="316"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Four numbers to watch. The escaped error rate is the one that matters most.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;2% random audit&lt;/strong&gt; is the key. A small random sample of auto-approved invoices goes to review anyway, without telling the reviewer it was auto-approved. That is the only unbiased estimate of the escaped error rate. Without it, a rising straight-through rate looks like progress even if it comes from errors slipping through.&lt;/p&gt;

&lt;p&gt;Every correction is stored as (invoice, field, extracted value, corrected value, reason). That store becomes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the &lt;strong&gt;golden set&lt;/strong&gt; for comparing extractors (Textract vs BDA) and tuning thresholds;&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;vendor watch list&lt;/strong&gt; : vendors whose invoices are corrected often get a stricter threshold or a vendor-specific blueprint;&lt;/li&gt;
&lt;li&gt;the &lt;strong&gt;release gate&lt;/strong&gt; for any change to the extractor or thresholds (more on that in Post 4).&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  A sketch of the router
&lt;/h3&gt;

&lt;p&gt;The routing logic is short, and it should be. It is the most important code in the system, so it needs to be easy to read and test.&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;dataclasses&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;dataclass&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;enum&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Enum&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Enum&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;AUTO_APPROVE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;auto_approve&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;REVIEW&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;review&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;HARD_STOP&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hard_stop&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="n"&gt;HARD_STOP_CHECKS&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;duplicate&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;bank_details&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;@dataclass&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CheckResult&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="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;passed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;
    &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;
    &lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;checks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;auto_limit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;audit_sample&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;failed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;checks&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;passed&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="c1"&gt;# 1. Fraud and duplicates: nothing overrides these.
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;HARD_STOP_CHECKS&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;failed&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;Route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HARD_STOP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;failed&lt;/span&gt;

    &lt;span class="c1"&gt;# 2. Any failed check: review, with the reasons.
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;failed&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;Route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;REVIEW&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;failed&lt;/span&gt;

    &lt;span class="c1"&gt;# 3. Large invoices always get a person.
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;auto_limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;why&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;CheckResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount_limit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                          &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;over &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;auto_limit&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;REVIEW&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;why&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="c1"&gt;# 4. Random audit of clean invoices.
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;audit_sample&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;why&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;CheckResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;audit_sample&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                          &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;random audit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;REVIEW&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;why&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;Route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AUTO_APPROVE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&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%2Fr3h9zi176v47sk2qh6m6.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%2Fr3h9zi176v47sk2qh6m6.png" width="800" height="711"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The router, top to bottom. Confidence never short-cuts the checks.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Notice what is missing: there is no line that says “if confidence is high, skip the checks.” Confidence is only one of the checks (per field), never a way around the others.&lt;/p&gt;

&lt;h3&gt;
  
  
  What we would expect, and how we would know
&lt;/h3&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%2Fsjqhkb2ez92ua7t3mgxq.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%2Fsjqhkb2ez92ua7t3mgxq.png" width="800" height="320"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Each stage opens only when the data from the previous one says it is safe.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;For a rollout like this, a reasonable plan is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Weeks 1–4, shadow mode.&lt;/strong&gt; Everything goes to review. The system’s route is recorded but not used. This builds the golden set and shows what the straight-through rate &lt;em&gt;would&lt;/em&gt; have been.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Then, turn on auto-approve for one vendor group&lt;/strong&gt; with clean history and low amounts. Watch the escaped error rate from the audit sample.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Widen only when the audit says so.&lt;/strong&gt; Each new vendor group needs its escaped error rate under the agreed limit for a full month.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A realistic early target is a straight-through rate between 40% and 60%, with most of the rest needing a fast review of one or two fields instead of full re-keying. The exact number depends on the vendor mix: clean, machine-generated PDFs from large suppliers will pass far more often than phone photos from small ones.&lt;/p&gt;

&lt;h3&gt;
  
  
  What we would do differently
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Start the corrections store on day one.&lt;/strong&gt; Shadow-mode data is the cheapest golden set you will ever get.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Agree the escaped error limit with finance before launch,&lt;/strong&gt; not after the first wrong payment. It is a business decision, not an engineering one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat the vendor master as part of the system.&lt;/strong&gt; Half of the “extraction errors” in a project like this turn out to be stale vendor data. The checks are only as good as the data they check against.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Key takeaways
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Confidence measures reading, not correctness.&lt;/strong&gt; Route on deterministic checks against your own data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Thresholds belong to fields,&lt;/strong&gt; and are set by the cost of being wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Some cases never auto-approve:&lt;/strong&gt; duplicates, bank-detail changes and large amounts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Show reviewers only what is flagged,&lt;/strong&gt; and make them touch each flagged field.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit a random sample of auto-approved invoices.&lt;/strong&gt; It is the only honest measure of risk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the extractor replaceable.&lt;/strong&gt; The routing and the review loop are the product.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;em&gt;Next in the series: Case 03, an LLM gateway that cut the bill (GCP). The design and decision records for this case, starting with “route on verified checks, not model confidence,” are in the&lt;/em&gt; &lt;a href="https://github.com/prasanth2006z/ai-architecture-casebook/tree/main/cases/02-invoice-extraction" rel="noopener noreferrer"&gt;&lt;strong&gt;&lt;em&gt;series repository&lt;/em&gt;&lt;/strong&gt;&lt;/a&gt;&lt;em&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;AWS service details reflect public documentation as of October 2026. Check current AWS docs before you build.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>aws</category>
      <category>machinelearning</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Designing a RAG System That Knows What You’re Allowed to See</title>
      <dc:creator>Prasanth Pattathil</dc:creator>
      <pubDate>Tue, 20 Oct 2026 12:31:01 +0000</pubDate>
      <link>https://dev.to/prasanth_pattathil/designing-a-rag-system-that-knows-what-youre-allowed-to-see-jf8</link>
      <guid>https://dev.to/prasanth_pattathil/designing-a-rag-system-that-knows-what-youre-allowed-to-see-jf8</guid>
      <description>&lt;p&gt;A design case study: choosing between fine-tuning, long context and RAG, and why access control belongs in retrieval, not in the prompt.&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%2Fu2qm81lymd2spjf3yia0.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%2Fu2qm81lymd2spjf3yia0.png" alt="Diagram: an identity filter for an India employee selects the India parental leave policy and blocks the near-identical UK policy." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Part of the AI Architecture Casebook: real-world AI problems, redesigned one decision at a time.&lt;/em&gt; &lt;a href="https://medium.com/@prasanth_pattathil/51131e78a179" rel="noopener noreferrer"&gt;&lt;em&gt;Start here&lt;/em&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;An employee in Bengaluru asks the company HR assistant how much parental leave she gets. The answer comes back confident, well written and wrong: it is the UK policy.&lt;/p&gt;

&lt;p&gt;A week later, a junior engineer asks about relocation support and receives details of the executive relocation package, a document he was never meant to see.&lt;/p&gt;

&lt;p&gt;Neither failure came from a bad model. Both came from the architecture around it. This post walks through how I approached the redesign as a design exercise: the options, the decisions, the trade-offs, and one problem that better search alone cannot solve.&lt;/p&gt;

&lt;p&gt;I work on enterprise APIs, where one rule is non-negotiable: authorization is decided at the gateway, from a verified token, never by trusting what the caller says. When I started looking at how RAG systems handle access control, I kept seeing the opposite: permissions written into prompts and left to the model. This case study applies the gateway mindset to retrieval.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The company, people and documents in this case study are fictional. The architecture patterns are real.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The problem
&lt;/h3&gt;

&lt;p&gt;Globex is a fictional company with 20,000 employees in 12 countries and about 4,000 HR policy documents. Leave, travel, benefits and expenses all differ by country, and some policies are restricted to senior grades. HR updates around 40 documents every week.&lt;/p&gt;

&lt;p&gt;The first chatbot was built in two weeks. It found the documents that best matched a question, placed them in a long prompt, and asked the model to answer. Within a month, HR had logged three kinds of failure:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Wrong country.&lt;/strong&gt; The UK and India parental leave policies match “parental leave” almost equally well. The bot picked the wrong one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Leaked policy.&lt;/strong&gt; A junior employee received text from a policy restricted to senior grades.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stale and uncited answers.&lt;/strong&gt; Answers had no source, and some quoted a travel policy that had been replaced two weeks earlier.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The bot was switched off. HR still answers about 6,000 policy questions a month by hand, and most of the answers are already written down somewhere.&lt;/p&gt;

&lt;p&gt;The second failure is the serious one. A wrong answer is an inconvenience. A restricted document shown to the wrong person is a compliance incident.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the fix had to satisfy
&lt;/h3&gt;

&lt;p&gt;Before choosing any technology, I wrote down what a good answer must look like. Most design mistakes come from skipping this step.&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%2F4kmwydl6mcmnr7e9cme3.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%2F4kmwydl6mcmnr7e9cme3.png" width="800" height="393"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The first line is the one that cannot bend. Every other decision is tuned around it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Four options, and why I rejected three
&lt;/h3&gt;

&lt;p&gt;All four options use a language model. The real question is where the model gets its policy knowledge: trained into the model, or looked up from the documents for each question.&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%2F4l3vddcr0pbur9rigwk6.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%2F4l3vddcr0pbur9rigwk6.png" width="800" height="257"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why not fine-tuning?&lt;/strong&gt; A model is billions of numbers, called weights. Fine-tuning blends the policies into those numbers: no document remains inside the model. Think of a student who memorized the whole handbook, versus one who looks up the right page each time. The memorizer can’t tell you which page a fact came from, so answers can’t be cited. Every one of the 40 weekly policy changes means retraining. And because one model answers everyone, the executive policy can’t be hidden from a junior engineer; it’s mixed into the same numbers. Fine-tuning is good for &lt;em&gt;how&lt;/em&gt; a model writes, not for &lt;em&gt;what&lt;/em&gt; it knows when facts change and have access rules.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why not long context?&lt;/strong&gt; Every model has a context window: how much text it can read for one question. Modern windows hold hundreds of pages, so it’s tempting to skip search and paste a large pile of policies into every prompt. If fine-tuning is the student who memorized the handbook, this is handing the student the &lt;em&gt;whole handbook for every single question&lt;/em&gt;. It fails three ways.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Cost: you pay for every word read, and 100,000 tokens per question costs far more than the 3,000 that matter.&lt;/li&gt;
&lt;li&gt;Accuracy: models miss facts buried in long prompts, and with two near-identical parental leave policies in the pile, they pick the wrong one.&lt;/li&gt;
&lt;li&gt;Security: if the executive policy is in the pile, the model has already read it. And once you choose which documents to send based on who is asking, you have built a retrieval system anyway.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Long context shines when the material is small, shared by everyone and rarely changes, like one contract or one product manual.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why not pure vector search?&lt;/strong&gt; This is the subtle one. Vector search finds text with similar &lt;em&gt;meaning&lt;/em&gt;, and the UK and India policies mean almost exactly the same thing. What separates them are exact words: country names, policy codes, grade labels. That is what keyword search is good at.&lt;/p&gt;

&lt;h3&gt;
  
  
  The architecture
&lt;/h3&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%2Fbvapjez75f2alzj5eguk.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%2Fbvapjez75f2alzj5eguk.png" width="799" height="302"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The Answer API is the only component that sees both who is asking and what is being searched. It reads country and grade from the verified sign-in token, builds the filter itself, and sends only the five best allowed chunks to the model. On AWS this maps to API Gateway and Lambda, Bedrock Knowledge Bases over OpenSearch Serverless, and a Bedrock model with a grounding check.&lt;/p&gt;

&lt;h3&gt;
  
  
  Three decisions that carry the design
&lt;/h3&gt;

&lt;p&gt;I recorded each decision as an Architecture Decision Record (ADR): the context, what we chose, what we rejected, and the signal that would make us change our mind.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Hybrid search with a reranker
&lt;/h3&gt;

&lt;p&gt;Run keyword search (BM25) and vector search together, merge the results, rerank the top candidates and keep the best five. Keyword search catches “India” and “HR-PL-IN-001”; vector search catches “time off after having a baby”.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The AWS detail that almost undid this:&lt;/strong&gt; Bedrock Knowledge Bases support hybrid search on several vector stores, including OpenSearch (Serverless and managed clusters), Aurora PostgreSQL and MongoDB Atlas. On other stores, such as the low-cost S3 Vectors, a hybrid request quietly falls back to semantic-only search, with no error. Choosing the cheapest store would have silently brought back the original bug. That is why the store choice is part of the decision, not an implementation detail.&lt;/p&gt;

&lt;p&gt;Trade-off: OpenSearch Serverless has a minimum cost even when idle, and reranking adds latency. I accept both: a cheaper store would bring back the wrong-country bug, and the reranker still fits within the 3-second budget.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Access control in retrieval, not in the prompt
&lt;/h3&gt;

&lt;p&gt;Every document carries the countries and grades allowed to see it. The API reads the user’s country and grade from their verified sign-in token, builds a filter, and passes it into every search. The model only ever receives chunks the user is entitled to.&lt;/p&gt;

&lt;p&gt;The tempting alternative is to tell the model: “Do not reveal documents the user is not allowed to see.” That is a security control in name only. The model has already received the restricted text, and a cleverly worded question can talk it out of the instruction.&lt;/p&gt;

&lt;p&gt;Two other alternatives I rejected:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Filtering after retrieval, in code:&lt;/strong&gt; restricted chunks still take up slots in the results, pushing out good ones, and one bug leaks data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A model-generated filter&lt;/strong&gt; (some platforms can build a filter from the question text): useful for relevance, but it is controlled by the user’s words, so it must never carry security.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The rule:&lt;/strong&gt; the security filter is built from verified identity only. Never from user input, never from model output.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Chunk by policy section
&lt;/h3&gt;

&lt;p&gt;HR policies have numbered sections, and the rules often depend on each other. “Up to 26 weeks of leave” in section 3.1 means little without “after 12 months of service” in section 3.2. A fixed 500-token split can cut a rule away from its condition, so the model answers with half the policy.&lt;/p&gt;

&lt;p&gt;So I split on section headings, and long sections on sub-headings, keeping the parent heading as a prefix.&lt;/p&gt;

&lt;h3&gt;
  
  
  The part that surprised me: better search is not enough
&lt;/h3&gt;

&lt;p&gt;While building the test documents, I put the UK and India parental leave policies side by side. They are the same template with different numbers. Here is section 3.1 from each:&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%2Fkz1bk02aoy9ije7cpz25.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%2Fkz1bk02aoy9ije7cpz25.png" width="800" height="197"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Notice what is missing: the country. Once a policy is split into sections, the chunk that holds the answer contains no country name and no policy code. Those live in the title and the first section. So neither vector search nor keyword search can tell these two chunks apart.&lt;/p&gt;

&lt;p&gt;That leads to two separate fixes for two separate cases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The question names the country&lt;/strong&gt; (“How much parental leave do employees in India get?”). Hybrid search can win here, but only if every chunk is prefixed with its document title, country and policy code. Section chunking without that prefix throws away exactly the words keyword search needs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The question doesn’t name the country&lt;/strong&gt; (“How much parental leave do I get?”). Here the text contains nothing to search on. No retrieval method can pick the right version from the words alone. Only the identity filter can, because it knows who is asking.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the real argument for access control in retrieval. It is not only a security feature. For a large share of everyday questions, the user’s identity is the &lt;em&gt;only&lt;/em&gt; signal that separates the right answer from the wrong one.&lt;/p&gt;

&lt;h3&gt;
  
  
  What can still go wrong, and how I’d measure it
&lt;/h3&gt;

&lt;p&gt;No design removes every risk. These are the ones I would watch:&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%2Fjnsg8gzcejlzlp2qbwdj.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%2Fjnsg8gzcejlzlp2qbwdj.png" width="800" height="435"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Measure retrieval separately from answers.&lt;/strong&gt; Most RAG failures are retrieval failures: if the right section isn’t in the top five, no prompt will fix it. So the evaluation set has 60 questions with a known correct document and section: 30 ordinary questions, 15 where the answer differs by country, 10 with no matching policy, and 5 probes asking about something the user may not see.&lt;/p&gt;

&lt;p&gt;One rule for the leak count: if a result can’t be verified (say, it has no document id), count it as a leak. A broken system should make the security number worse, never better.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;At 10x scale&lt;/strong&gt; (200,000 employees), I would expect these to change first:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Country and grade lists become too coarse; a dedicated authorization service takes over.&lt;/li&gt;
&lt;li&gt;Repeated questions (open-enrollment week) need caching.&lt;/li&gt;
&lt;li&gt;Indexes split by region for data residency.&lt;/li&gt;
&lt;li&gt;Personal questions (“how many leave days do I have left?”) need access to the HR system, which turns this into an agent.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Takeaways
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Write the constraints before choosing the technology.&lt;/strong&gt; The one that cannot bend (access control here) decides most of the design.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fine-tuning stores style, not changing facts.&lt;/strong&gt; For policies that change weekly and need citations, retrieval wins.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security belongs in retrieval, not in the prompt.&lt;/strong&gt; If the model has seen the text, a prompt instruction will not reliably keep it secret.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Small platform details can undo big decisions.&lt;/strong&gt; A cheaper vector store would have silently turned hybrid search back into semantic search.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Identity is also a relevance signal.&lt;/strong&gt; When a question doesn’t name the country, only knowing who asks can pick the right answer.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is the first case study in a series where I take a realistic AI problem, compare the options, and record the decisions as ADRs. The design doc and ADRs are on GitHub: &lt;a href="https://github.com/prasanth2006z/ai-architecture-casebook" rel="noopener noreferrer"&gt;ai-architecture-casebook&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;One simplification to note: real enterprises usually have far richer access rules, such as departments, projects and temporary access. Country and grade are enough to show the pattern. At larger scale, a dedicated authorization service makes the decision, but the principle stays the same: the filter comes from verified identity, never from the prompt.&lt;/p&gt;

&lt;p&gt;How would you have handled the wrong-country problem? I’d like to hear other approaches in the comments.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>rag</category>
      <category>aws</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Designing AI Systems with an API Mindset</title>
      <dc:creator>Prasanth Pattathil</dc:creator>
      <pubDate>Tue, 20 Oct 2026 12:01:02 +0000</pubDate>
      <link>https://dev.to/prasanth_pattathil/designing-ai-systems-with-an-api-mindset-2gbd</link>
      <guid>https://dev.to/prasanth_pattathil/designing-ai-systems-with-an-api-mindset-2gbd</guid>
      <description>&lt;p&gt;Introducing the AI Architecture Casebook, a series on real-world AI design decisions&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%2Fp9st6abprgxv0ii6b8y0.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%2Fp9st6abprgxv0ii6b8y0.png" width="" height=""&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The system around the model&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;For years, my work has been about how systems talk to each other: who is allowed to call what, where trust is decided, what happens when a dependency fails, and what every call costs. Those questions shaped how I think about any system before a single line of code is written.&lt;/p&gt;

&lt;p&gt;When I started working with AI, I noticed how often those questions go unasked. Permissions end up written into prompts. Costs grow with no budget. Answers ship with no way to tell whether they’re right. The model gets all the attention, while the system around it, where most failures actually happen, gets very little.&lt;/p&gt;

&lt;p&gt;I’m not the only one noticing. In everyday conversations with friends and colleagues from different companies, AI scenarios keep coming up that made the people who designed them stop and rethink. Nobody has all the answers yet. Everyone is learning, me included. And each time, I catch myself asking the same question: &lt;em&gt;what would I do in this situation?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This series is my answer to that question, one problem at a time. Each post takes a real-world AI scenario and works through it the way I’d approach any design: what must never go wrong, which options exist, what each one costs, and which trade-offs I’d accept. I record the decisions as architecture decision records, including the options I rejected and when I’d revisit them.&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%2Fv5qrudnpuzr182lc5xzp.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%2Fv5qrudnpuzr182lc5xzp.png" width="800" height="510"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The companies and people in these stories are made up. The problems are very real. To keep each case study readable, I simplify on purpose: one company, a few rules, a clear scope. Your real systems are probably messier, with more roles, more exceptions and more history. Where a simplification would change the design at a larger scale, I’ll say so. If you’ve faced something similar, or you’d design it differently, I’d like to hear it in the comments. Learning together is the point.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>architecture</category>
      <category>systemdesign</category>
      <category>api</category>
    </item>
  </channel>
</rss>
