~/blog/architecting-trade-exim-document-intelligence
Architecting trade-EXIM document intelligence: OCR, vision LLMs and a 49-rule judge
How Liquidmind reads Shipping Bills, invoices and tariff schedules: Textract and OCRia-1 for layout, Claude on Bedrock for vision and extraction, a 49-rule cross-check judged in one LLM call, a cascade that asks clarifying questions before it guesses an HSN code, and the bake-off that keeps it all honest.
On this page
An Indian export shipment carries a Tax Invoice and a Shipping Bill. An import carries a Commercial Invoice, Bill of Entry, Bill of Lading and often a Certificate of Origin. If the HS code, quantity, FOB value or consignee differs between them, the consignment can sit at customs, the RoDTEP or Drawback claim can be denied, or worse.
At Liquidmind the products that catch this are TradeGuard (document cross-checks), TariffIQ (HSN classification and duties) and Patram AI (questions over trade documents). I architected the intelligence layer they share, on AWS after the Azure migration. This post walks through it layer by layer.
Layer 1: get the page into text you can trust
Trade documents arrive in every state: born-digital PDFs, scans of printed forms, phone photos of stamped pages, tables that span pages. No single parser wins on all of them, so parsing is a choice per product, made with data.
- Amazon Textract (
AnalyzeDocument, FORMS + TABLES, and LAYOUT for Patram) is the default: predictable, in-region, and it gives geometry we can cite. - Claude vision on Bedrock reads what OCR mangles: stamps, handwriting next to printed fields, figures and charts. In Patram every figure is cropped and captioned by Claude, so a chart becomes searchable text.
- LlamaParse's OCRia-1 is the newest entrant (more on it below).
The bake-off
To stop arguing about parsers, I ran a bake-off: 13 real trade documents, 71 pages, 9 parsers and 12 end-to-end extraction pipelines, each scored on field accuracy and cost per page.
| Finding | Number |
|---|---|
| OCRia-1 (LlamaParse Cost Effective) on table cells and charts | 100%, at $3.75 per 1k pages (Textract: about $20) |
| OCRia-1 on a born-digital invoice header | dropped 10 of 17 header fields |
| Single-step Claude Sonnet vision on Bedrock, TradeGuard fields | 99.2% at $0.018/page |
| Current TradeGuard import path (Textract + LLM) | 98.9% at $0.080/page |
The result is a routing decision, not a single winner:
- OCRia-1 for Patram's ingest, where documents are table- and chart-heavy and you need everything indexed.
- Vision-first extraction for TradeGuard, where every header field matters and a silently dropped consignee name is a compliance miss.
The bake-off also caught a configuration bug that made one extraction path silently fall back to a weaker parser. An evaluation you can rerun is a regression test.
Layer 2: extraction as a contract
The extract worker turns pages into a fixed schema per document type: 49 fields for a Shipping Bill and 42 for a Tax Invoice. Prompts live as versioned JSON next to the code. Every field comes back in the same shape, for example:
{ "field_id": "fob_value", "value": "1245600.00", "confidence": 0.93 }Rules that made extraction stable:
- Temperature 0, and the schema in the prompt. Bedrock's Converse API has no JSON mode, so the parser strips markdown fences and validates before anything downstream sees it.
- Confidence is mandatory. Low-confidence fields are shown to the user with the source page, never silently trusted.
- Bounded cost. Textract runs one page per call with a page cap, because a 200-page scan uploaded by mistake should not produce a 200-page bill.
Layer 3: the cross-check is a rule engine with an LLM inside
Comparing an invoice to a Shipping Bill is not "ask the LLM if these match". It is 49 explicit rules for exports and 19 for imports, each with a match type and a severity:
- 5 exact match rules
- 16 numeric tolerance (quantities and values, default 1%)
- HS code at 6 digits, invoice references, line items and invoice blocks
- 24 semantic ("ABC EXPORTS PVT LTD" vs "M/S ABC Exports Private Limited")
By severity: 6 CRITICAL, 26 HIGH, 16 MEDIUM, 1 LOW. Import rules add FTA validation and date ordering, for example the Bill of Lading cannot predate the invoice.
Deterministic rules run as code. All semantic rules for a document pair go to Claude on Bedrock in one batched call that returns consistent or inconsistent per rule with a reason. Batching gives the model context across fields, and the cost is one call per check instead of one per field.
Since October one check can carry several invoices. The comparator pairs invoice rows to Shipping Bill rows n:m and reports per invoice and per row.
On top of the rule results:
- An executive brief from Claude Opus: what is wrong, how much it matters, what to fix first.
- A risk panel from Claude Haiku: SCOMET indications and screening of parties against the US Consolidated Screening List.
- Export calculations against RoDTEP, RoSCTL and Drawback tables.
Layer 4: HSN classification that asks before it guesses
TariffIQ classifies a product description to an Indian HSN code. A wrong code means a wrong duty, so the pipeline is a cascade that spends money only when cheaper steps fail:
- Direct lookup for descriptions already seen.
- Vector search over about 14.6k tariff lines in a Bedrock Knowledge Base (Titan Embed v2), top 30 candidates.
- Claude disambiguation, iterative: if confidence is under 95%, it asks the user a clarifying question ("Is the fabric knitted or woven?") rather than picking the likeliest code.
Every decision is cached in Postgres with its audit trail. The rebuild moved 6-digit accuracy on our goldens from 19% to 62–75% and cut /import/classify latency from about 62 s to about 4 s. On the wider 644-case benchmark the chapter is right 86.7% of the time; the 8-digit tail is exactly where the clarifying questions earn their keep.
Layer 5: retrieval that respects identifiers
Patram answers questions over trade PDFs with page-level citations. Trade questions are full of identifiers: HS codes, notification numbers, invoice numbers. Dense embeddings are bad at those. In our tests dense retrieval found about 5% of exact-identifier queries, while lexical search found about 70%.
So retrieval fuses four retrievers with Reciprocal Rank Fusion:
| Retriever | Weight |
|---|---|
| Exact identifier match | 1.5 |
Dense vector (Titan v2, 1024-d halfvec in pgvector) | 1.0 |
Postgres full-text (tsvector) | 1.0 |
| Trigram similarity | 0.5 |
An LLM reranker then picks the top 8. Chunks are 512 tokens with 75 overlap, built from layout-aware OCR at 300 DPI.
Two design choices worth naming:
- pgvector on the RDS we already run, not OpenSearch Serverless. Its monthly cost floor is hard to justify at our scale.
- An in-region model lane. By default Patram uses Claude models served in the APAC region, so documents stay within the region, which matters under India's DPDP Act. A global lane with newer models is opt-in.
What ties it together
- Every model call goes through Bedrock, behind cross-region inference profiles, with model ids in config, so upgrades (Claude 3.5 Sonnet to Sonnet 4.5 for TariffIQ in September) are a config change plus an eval run.
- Every output carries provenance: confidence, source page, rule id, model.
- Every choice has a benchmark that can be rerun when a new model ships.
The infrastructure underneath is in the migration case study.
Linked from
Keep reading
Monthly notes
Get the next one in your inbox.
One email a month on infrastructure, backend design and applied AI. No noise, unsubscribe any time.
Comments (0)
name + comment · no login