Skip to content
⚠️ Before You Start
Risk: Low
Duration: 1 day to build, 2-4 hours to adapt per project
Last Validated: Not yet validated (draft)
Kind: How-to
Risk Note: Low (runs offline against sample files in the sandbox; no live index is changed)

Build layout-aware chunking with Document Intelligence and LangChain

Draft: not validated

Not yet dry-run. Do not use on a live system until the validation record is complete and signed off.

1. Summary

Turn PDFs and Office files into search-ready chunks that keep headings, tables, and page numbers.

Method Document Intelligence prebuilt-layout (Markdown) → LangChain splitters → a short script.
Output JSONL, one chunk per line: source, page, heading path, stable ID, embed text.
You need A Document Intelligence resource (S0), Python 3.10+, sample files, the chunking profile from PB-RAG-01-01.
Cost Layout analysis is billed per 1,000 pages. Check the current price before approval. Local re-chunking from the cache is free.
Risk Low. Offline, in the sandbox. No live index is changed.

Facts checked against Microsoft Learn on 27 October 2026. Stable data-plane REST API: 2024-11-30. No preview features are used in this playbook.

Why we do it this way

Choice Business reason
Document Intelligence Layout, not plain PDF text Plain extraction loses headings and tables. Half a table gives a wrong answer. Microsoft's own RAG guidance uses Layout Markdown for semantic chunking. One API covers PDFs, scans, and Office files.
LangChain splitters Open source, free, already the pack's standard. They work on AWS and open-source output too, so the chunker moves with the project.
SDK as the main route langchain-community, which holds the loader, is being sunset. Importing it showed a deprecation warning on 2026-10-23. The SDK is Microsoft's supported client.
Profile-driven, nothing hard-coded Chunk size is a per-corpus decision (PB-RAG-01-01). Changing the profile is a config change, not a code change.
Cache the Markdown Analysis is the only paid step. Re-chunking from the cache is free, so teams can tune the profile without a bigger bill.
Protect tables, keep page and heading Answers can cite the right page and section (PB-RAG-06), and security filters have the fields they need.

2. When to use and when not to

Use it when Do not use it when
The corpus has headings, tables, or multi-column layouts. The content has no structure, such as logs or flat transcripts. Use the recursive splitter alone (PB-RAG-01-01).
Plain splitting loses structure. The source has only bold text, not real headings. Set header_levels: 0 (see Step 5, case 2).
You need the reference chunker for a new project. Option D (short_records): discrete entities (FAQs, customer tickets, product cards) under 1,000 characters ingest 1:1 directly as individual chunks without chunk_layout.py.
You are changing a live index. Use PB-RAG-01-03.
No chunking profile is chosen yet. Do PB-RAG-01-01 first.

3. What you must know first

3.1 Who does what

file ──► DOCUMENT INTELLIGENCE ──► Markdown ──► LANGCHAIN splitters ──► YOUR SCRIPT ──► JSONL ──► index
         reads layout, OCR          headings,    split by heading,       keeps tables whole,
         paid, per page             HTML tables, then by size            adds page + IDs
                                    PageBreak    free, runs locally      free, runs locally
  • Document Intelligence reads the file and returns Markdown. It does not chunk.
  • LangChain splits text. It does not read PDFs. It needs the Markdown first.
  • The script (Step 3) fills the gaps: tables stay whole, pages are found, metadata is added.

3.2 Pick your case

Your situation What to do
Digital PDF with headings Use the defaults.
Scanned PDF or photo Same call. OCR is automatic. For small print, add ocrHighResolution. Check current add-on pricing in the docs.
Word, PowerPoint, or HTML Same call. Embedded images are not read. Pages are counted per 3,000 characters, so page numbers may not match the original. Verify in the sandbox.
Excel Tables are not supported for XLSX. Use the table kit (PB-RAG-10).
Only some pages pages="1-3,5" (Step 2A, 2C).
Over 2,000 pages Split the file first. Check current page limits in the docs.
Mixed languages locale="en-US" as a hint, or the free languages add-on.
Maths formulas add-on. Output is LaTeX. Check current add-on pricing in the docs.
No real headings header_levels: 0 in the profile.
Profile is in tokens Pass a token counter to chunk() (Step 5, case 1).
Many files Folder run with cache and retry (Step 5, case 3). A batch API also exists in the SDK.
Managed Azure AI Search pipeline The Document Layout skill (Step 2, route D).

3.3 What the Markdown looks like

Element Appears as Consequence
Headings # to ###### Split on these.
Tables HTML (<table>, <tr>) A plain splitter can cut a table mid-row. The script protects them.
Pages <!-- PageBreak --> The only source of page numbers.
Header, footer, page number <!-- PageHeader="..." --> The script removes them.
Figures <figure> Kept with their caption.

3.4 Models and add-ons

  • prebuilt-layout: headings, tables, figures, Markdown. Use it for RAG.
  • prebuilt-read: cheaper, but no headings or tables. Plain scanned text only.
  • Invoice, receipt, ID, and similar prebuilt models extract named fields. They are not for chunking.
  • Add-ons are switched on by feature name. ocrHighResolution, formulas, styleFont, and queryFields cost extra. languages, barcodes, and keyValuePairs are free. Check current pricing before enabling any.

3.5 Limits and price

Read on 2026-10-23. Check current limits and prices before you plan. Prices are US dollars, East US, list price, pay-as-you-go, and vary by region and agreement.

Limit / Quota Free (F0) Standard (S0)
Pages per PDF First 2 Up to 2,000 (check current limits in the docs)
File size 4 MB 500 MB (check current limits in the docs)
Analyse requests per second 1 15 default (check current limits in the docs)
  • Estimate: estimate from the pricing page before you submit a batch.
  • Counting: 1 PDF page = 1 page. Word and HTML = 1 page per 3,000 characters. Excel sheet or PowerPoint slide = 1 page.
  • Free allowance: 500 pages a month on the free tier (check current tier allowances in the docs ).

4. Prerequisites and access

Item Detail
Resource Azure Document Intelligence on Standard S0, in the sandbox only. Never use production data.
Roles Cognitive Services User for data-plane document analysis. Entra ID authentication is preferred. Use a resource key only in the sandbox, and store it in Key Vault (see RBAC verification note below).
Search RBAC Role-based access control enabled on Azure AI Search. The executing identity requires Search Service Contributor (to create and delete the scratch index), Search Index Data Contributor (to upload chunks), and Search Index Data Reader (to query).
Embeddings Azure OpenAI model deployment (${EMBEDDING_DEPLOYMENT}, e.g. text-embedding-3-large) with dimension size ${EMBEDDING_DIMENSIONS} (e.g. 3072; check current model defaults in the docs).
Endpoint Custom-subdomain endpoint (https://${DOCINTEL_NAME}.cognitiveservices.azure.com/). Regional endpoints do not support Entra ID.
Runtime Python 3.10 or later. Tested: Python 3.12, azure-ai-documentintelligence 1.0.2, langchain-text-splitters 1.1.3, openai 1.50+.
Install pip install azure-ai-documentintelligence azure-identity langchain-text-splitters langchain-core pyyaml openai. Add langchain-community only if you use the loader (Step 2, route B).
Test data Representative sample files (PDF, scan, Office) from the sandbox dataset.
Profile Validated chunking configuration from PB-RAG-01-01 saved as profile.yaml.

Set the required environment variables:

export DOCINTEL_ENDPOINT="https://${DOCINTEL_NAME}.cognitiveservices.azure.com/"
# Sandbox only (omit when authenticated with az login):
export DOCINTEL_KEY="${DOCINTEL_KEY}"

# Azure OpenAI for chunk embedding
export AZURE_OPENAI_ENDPOINT="https://${OPENAI_NAME}.openai.azure.com/"
export EMBEDDING_DEPLOYMENT="${EMBEDDING_DEPLOYMENT}"
export EMBEDDING_DIMENSIONS="${EMBEDDING_DIMENSIONS}"

# Azure AI Search scratch evaluation (requires Search RBAC enabled)
# Note: Bearer tokens from az account get-access-token expire after ~1 hour; refresh before long runs.
export SEARCH_ENDPOINT="https://${SEARCH_NAME}.search.windows.net"
export SEARCH_TOKEN=$(az account get-access-token --resource https://search.azure.com --query accessToken -o tsv)

5. Steps

Every value comes from the profile. Nothing is guessed.

Step 1. Save the profile

Save as profile.yaml. Real values come from PB-RAG-01-01.

profile_id: ${PROFILE_ID}
header_levels: 3        # heading levels to split on; 0 = no heading split
chunk_size: ${CHUNK_SIZE}
chunk_overlap: ${CHUNK_OVERLAP}
prefix_heading: true    # put the heading path in front of the text you embed
keep_tables_whole: true # protect HTML tables from mid-table splits

Verify: The chunker stops with a clear error message if any required field is missing.

Step 2. Get Markdown from the file

Pick one route. All four return the same Markdown.

Route A. SDK (reference route). See snippets/chunk_layout.py for the full make_client() and to_markdown() implementation. It handles a local file or an https:// URL, retries on HTTP 429 with backoff (2, 5, 13, 34) seconds, and caches the result by SHA-256 hash.

Cost driver: analysis is billable

Every layout analysis call incurs a per-page fee. Verify document page counts before submitting batches. Use the cache to prevent duplicate charges.

from chunk_layout import make_client, to_markdown
from azure.ai.documentintelligence.models import DocumentAnalysisFeature as F

client = make_client()
md = to_markdown(client, "samples/manual.pdf")                          # local file
md = to_markdown(client, "https://${STORAGE_ACCOUNT}.blob.core.windows.net/${CONTAINER}/manual.pdf?${SAS_TOKEN}")  # URL (blob + SAS)
md = to_markdown(client, "samples/manual.pdf", pages="1-3,5")           # some pages only
md = to_markdown(client, "samples/scan.pdf", features=[F.OCR_HIGH_RESOLUTION])  # small print
md = to_markdown(client, "samples/de.pdf", locale="de")                 # language hint
md = to_markdown(client, "samples/maths.pdf", features=[F.FORMULAS])    # maths (LaTeX)

Verify: The cache/ directory contains a .md file. It has # headings, HTML <table> blocks, and <!-- PageBreak --> comments. There is one fewer <!-- PageBreak --> than pages.

Route B. LangChain loader (quick test only). Returns one Document with no page field. langchain-community is being sunset; pin the version and do not use it in the reference build.

import os
from langchain_community.document_loaders import AzureAIDocumentIntelligenceLoader as Loader

auth = dict(api_endpoint=os.environ["DOCINTEL_ENDPOINT"],
            api_key=os.environ["DOCINTEL_KEY"])
# Entra ID: auth = dict(api_endpoint=..., azure_credential=DefaultAzureCredential())
docs = Loader(file_path="samples/manual.pdf", **auth).load()
markdown = docs[0].page_content
mode Documents page_content Metadata Use when
markdown (default) 1 Markdown Raw result You want headings and tables.
single 1 Plain text Raw result Plain text is enough.
page 1 per page Plain text, no headings {"page": n} You only need pages.

Verify: docs contains 1 item and docs[0].page_content contains valid Markdown headings and table markup.

Route C. REST. Any language. Submit, then poll until status is succeeded or failed.

# pseudo
import os, time, requests
base, hdr = os.environ["DOCINTEL_ENDPOINT"].rstrip("/"), {"Ocp-Apim-Subscription-Key": os.environ["DOCINTEL_KEY"]}
url = f"{base}/documentintelligence/documentModels/prebuilt-layout:analyze"
body = {"urlSource": "https://${STORAGE_ACCOUNT}.blob.core.windows.net/${CONTAINER}/manual.pdf?${SAS_TOKEN}"}
r = requests.post(url, params={"api-version": "2024-11-30", "outputContentFormat": "markdown"}, headers=hdr, json=body)
r.raise_for_status()                    # 202 Accepted
result_url = r.headers["Operation-Location"]
while True:
    s = requests.get(result_url, headers=hdr).json()
    if s["status"] in ("succeeded", "failed"):
        break
    time.sleep(2)                       # poll every 2 seconds
markdown = s["analyzeResult"]["content"]

Verify: Polling completes with s["status"] == "succeeded" and markdown is a non-empty string.

Route D. Azure AI Search Document Layout skill. Use it when Azure AI Search indexes the files for you. It returns Markdown sections by heading (h1 to h6), or text chunks of 300 to 50,000 characters. Verify table handling before choosing it over the script in Step 3.

{
  "@odata.type": "#Microsoft.Skills.Util.DocumentIntelligenceLayoutSkill",
  "context": "/document",
  "outputMode": "oneToMany",
  "outputFormat": "markdown",
  "markdownHeaderDepth": "h3",
  "inputs": [{ "name": "file_data", "source": "/document/file_data" }],
  "outputs": [{ "name": "markdown_document", "targetName": "markdown_document" }]
}

Over 20 documents per indexer per day needs a billable Foundry resource attached (check current limits in the docs ). A document that needs more than five minutes times out (check current limits in the docs ), and charges still apply.

Verify: Skillset execution populates /document/markdown_document on each indexed document.

Rollback (all routes): nothing is changed. Stop the script to abort.

Step 3. Chunk the Markdown

The full production chunker is in snippets/chunk_layout.py. It does the following in order:

  1. Strips <!-- PageHeader="..." -->, <!-- PageFooter="..." -->, and <!-- PageNumber="..." --> comments.
  2. Converts <!-- PageBreak --> into sequential tracking tokens (@@P{n}@@).
  3. Masks each HTML <table>...</table> block behind a short token (TBL0X, TBL1X, …) so no splitter can cut it.
  4. If a table is larger than chunk_size, splits it between <tr> rows and repeats the <th> header row on each group.
  5. Splits on Markdown headings up to header_levels depth using LangChain MarkdownHeaderTextSplitter.
  6. Subdivides large sections with RecursiveCharacterTextSplitter, measuring tables at their real size.
  7. Restores table HTML, records the active page number, and adds chunk_id, heading_path, and embed_text.

Verify: Import chunk and report from snippets/chunk_layout.py. Run report(chunks, profile) and confirm every count except chunks is 0.

Step 4. Try it on a small sample first

Run the built-in self-test in snippets/chunk_layout.py (which tests chunk_size: 150, chunk_overlap: 0, header_levels: 3 against an embedded pump-manual sample):

from chunk_layout import sample_run

sample_run()

Expected output from the pump-manual self-test:

Report: {"chunks": 5, "over_max": 0, "no_heading": 0, "broken_tables": 0}
P1 | Pump Manual > 1. Safety | 59 chars
P1 | Pump Manual > 2. Service intervals | 107 chars
P1 | Pump Manual > 2. Service intervals | 112 chars
P1 | Pump Manual > 2. Service intervals | 109 chars
P2 | Pump Manual > 2. Service intervals > 2.1 Seal replacement | 63 chars

Verify: Every count in the report is 0 except chunks (5 chunks generated).

The last chunk looks like this (see also snippets/chunk.sample.json):

{
  "chunk_id": "e49e517b94b70a29",
  "source": "samples/manual.pdf",
  "page": 2,
  "heading_path": "Pump Manual > 2. Service intervals > 2.1 Seal replacement",
  "seq": 4,
  "profile_id": "${PROFILE_ID}",
  "content": "Drain the casing. Replace the seal. Refill and check for leaks.",
  "embed_text": "Pump Manual > 2. Service intervals > 2.1 Seal replacement\n\nDrain the casing. Replace the seal. Refill and check for leaks."
}
Field Use
chunk_id Stable ID to compare chunking runs (PB-RAG-01-03).
source, page, heading_path Citations (PB-RAG-06) and security filters.
embed_text Send this to the embedding model.

Locally tested

chunk_layout.py was run locally on 2026-10-27 (Python 3.12) via sample_run(). Output matched the report above. Real Azure analysis calls (Routes A–D) require the sandbox and were not run.

Step 5. Advanced cases and folder run

Case 1. Profile in tokens, not characters. Pass your embedding model's counter and use the same counter in the report.

count = lambda text: len(tokenizer.encode(text))  # tokenizer = the one for your embedding model
chunks = chunk(md, source, p, measure=count)
print(report(chunks, p, measure=count))

Verify: Report reflects custom token-measured chunk boundaries with zero over_max violations.

Case 2. No real headings. Set header_levels: 0 in profile.yaml. The text is split by size only and heading_path stays empty.

Verify: Chunks are partitioned by character limits without hierarchical heading metadata.

Case 3. Many files. Cached and retried, so a failed run costs nothing to repeat.

import json, pathlib, yaml
from chunk_layout import chunk, make_client, report, to_markdown

p = yaml.safe_load(open("profile.yaml", encoding="utf-8"))
client = make_client()
pathlib.Path("out").mkdir(exist_ok=True)
with open("out/chunks.jsonl", "w", encoding="utf-8") as f:
    for path in sorted(pathlib.Path("samples").glob("*.pdf")):
        chunks = chunk(to_markdown(client, path), str(path), p)
        print(path.name, report(chunks, p))
        f.writelines(json.dumps(c) + "\n" for c in chunks)

Verify: out/chunks.jsonl contains valid JSON records. Line count matches the cumulative chunk count in the printed report.

Step 6. Index into a scratch index and evaluate

Upload the generated chunks from out/chunks.jsonl to a temporary scratch index in Azure AI Search using the Search REST API (2024-07-01 ). Both Azure OpenAI embeddings and Azure AI Search index upload enforce batch and payload size limits; check current limits in the docs. Run golden questions via vector retrieval (k: 10) and evaluate Recall@10 before deleting the temporary index.

  1. Create the scratch index with vector search capability:
SCHEMA=$(cat <<EOF
{"name":"scratch-chunk-eval","fields":[{"name":"chunk_id","type":"Edm.String","key":true},
{"name":"source","type":"Edm.String","filterable":true},{"name":"page","type":"Edm.Int32","filterable":true},
{"name":"heading_path","type":"Edm.String","searchable":true},{"name":"content","type":"Edm.String","searchable":true},
{"name":"seq","type":"Edm.Int32","filterable":true,"sortable":true},{"name":"profile_id","type":"Edm.String","filterable":true},
{"name":"embed_text","type":"Edm.String","searchable":true},
{"name":"content_vector","type":"Collection(Edm.Single)","dimensions":${EMBEDDING_DIMENSIONS},"vectorSearchProfile":"eval-vector"}],
"vectorSearch":{"algorithms":[{"name":"eval-hnsw","kind":"hnsw"}],"profiles":[{"name":"eval-vector","algorithm":"eval-hnsw"}]}}
EOF
)
curl -sS -X POST "${SEARCH_ENDPOINT}/indexes?api-version=2024-07-01" \
  -H "Authorization: Bearer ${SEARCH_TOKEN}" -H "Content-Type: application/json" -d "${SCHEMA}"

Verify: Response status is 201 Created .

  1. Embed chunk records in batches using Entra ID authentication:
import json, os
from azure.identity import DefaultAzureCredential, get_bearer_token_provider
from openai import AzureOpenAI

token = get_bearer_token_provider(DefaultAzureCredential(), "https://cognitiveservices.azure.com/.default")
client = AzureOpenAI(azure_endpoint=os.environ["AZURE_OPENAI_ENDPOINT"], azure_ad_token_provider=token, api_version="2024-06-01")
dims = int(os.environ["EMBEDDING_DIMENSIONS"])
docs = [json.loads(line) for line in open("out/chunks.jsonl", encoding="utf-8")]
for i in range(0, len(docs), 100):
    batch = docs[i : i + 100]
    resp = client.embeddings.create(input=[d["embed_text"] for d in batch], model=os.environ["EMBEDDING_DEPLOYMENT"], dimensions=dims)
    for d, emb in zip(batch, resp.data):
        d["@search.action"], d["content_vector"] = "upload", emb.embedding

Verify: All chunk records have content_vector populated with dimensions matching ${EMBEDDING_DIMENSIONS}.

  1. Upload embedded chunks to the scratch index in batches:
import json, os, urllib.request

url = f"{os.environ['SEARCH_ENDPOINT']}/indexes/scratch-chunk-eval/docs/index?api-version=2024-07-01"
headers = {"Authorization": f"Bearer {os.environ['SEARCH_TOKEN']}", "Content-Type": "application/json"}
for i in range(0, len(docs), 100):
    batch = docs[i : i + 100]
    payload = json.dumps({"value": batch}).encode("utf-8")
    req = urllib.request.Request(url, data=payload, headers=headers)
    with urllib.request.urlopen(req) as resp:
        res = json.loads(resp.read().decode("utf-8"))
        assert all(item["status"] for item in res["value"]), "Upload failed for chunk batch"
print(f"Uploaded {len(docs)} chunks to scratch index.")

Verify: Response status is 200 OK and "status": true for all uploaded keys .

  1. Run golden questions and evaluate Recall@10:
import csv, json, os, re, urllib.request

questions = list(csv.DictReader(open("data/golden/B-technical-manuals/B_01/questions.csv", encoding="utf-8")))
hits, search_url = 0, f"{os.environ['SEARCH_ENDPOINT']}/indexes/scratch-chunk-eval/docs/search?api-version=2024-07-01"
headers = {"Authorization": f"Bearer {os.environ['SEARCH_TOKEN']}", "Content-Type": "application/json"}
for q in questions:
    q_vec = client.embeddings.create(input=q["question_text"], model=os.environ["EMBEDDING_DEPLOYMENT"], dimensions=dims).data[0].embedding
    body = json.dumps({"select": "page, content", "vectorQueries": [{"kind": "vector", "vector": q_vec, "fields": "content_vector", "k": 10}]}).encode("utf-8")
    req = urllib.request.Request(search_url, data=body, headers=headers)
    res = json.loads(urllib.request.urlopen(req).read().decode("utf-8")).get("value", [])
    target_page = int(re.search(r"Page (\d+)", q["source_passage"]).group(1))
    target_quote = re.findall(r'"([^"]{15,})"', q["source_passage"])[-1][:35]
    hits += any(hit.get("page") == target_page and target_quote in hit.get("content", "") for hit in res)
print(f"Recall@10: {hits / len(questions):.2%} ({hits}/{len(questions)} questions matched)")

Verify: Recall@10 on the golden questions meets or exceeds the archetype baseline from PB-RAG-01-01 within the 3% team default tolerance.

  1. Delete the scratch index:

Clean up scratch resources

Delete the scratch index immediately after recording evaluation metrics. Abandoned indexes consume partition storage and vector index quota limits.

curl -sS -X DELETE "${SEARCH_ENDPOINT}/indexes/scratch-chunk-eval?api-version=2024-07-01" \
  -H "Authorization: Bearer ${SEARCH_TOKEN}" -o /dev/null -w "delete HTTP %{http_code}\n"

Verify: Response is 204 No Content . The scratch index is the rollback — deleting it removes all temporary data.

6. Verify it works

A second engineer runs the folder script (Step 5, case 3) against three sample files in the sandbox: a structured digital PDF with tables, a scanned document, and a flat prose document.

The test passes when:

  • Every record in out/chunks.jsonl contains non-empty source, page, chunk_id, and content fields.
  • The report() summary shows over_max: 0 and broken_tables: 0 across all files.
  • A manual inspection of 10 chunks per file type, including at least one table chunk, shows intact <tr> tags with repeated <th> headers on split rows.
  • The record count in out/chunks.jsonl exactly matches the cumulative chunk count in the report.
  • A second run reads from cache/ and makes no new Document Intelligence network requests.
  • Scratch index evaluation (Step 6) passes: Recall@10 on the golden questions meets or exceeds the archetype baseline from PB-RAG-01-01 within the 3% team default tolerance.

7. Known issues and fixes

Symptom Likely cause Fix
HTTP 429 during bulk analysis Exceeded the rate limit (S0 default 15 requests per second; F0 is 1; check current limits in the docs) to_markdown() retries automatically with backoff (2, 5, 13, 34) seconds. Ramp gradually, and submit a quota increase ticket if throttling is sustained.
Only 2 pages processed, or a file-size error Resource is on the Free F0 tier, or file exceeds the tier limit Upgrade to Standard S0. Check current page and file size limits in the docs.
ImportError: cannot import name 'ContentFormat' Microsoft's sample uses a preview SDK name On SDK 1.0.x use DocumentContentFormat.MARKDOWN.
heading_path is empty on every chunk Source uses bold text instead of semantic heading elements Set header_levels: 0 in profile.yaml.
Table cut across chunks Naive splitter applied directly to raw Markdown HTML Use chunk_layout.py. Without the protection, a test run cut one table across three chunks.
A chunk contains only a heading Heading kept in the text body Keep headings in metadata. The script does this with MarkdownHeaderTextSplitter(strip_headers=True).
No page numbers on any chunk Using the LangChain loader in single or page mode Take page numbers from <!-- PageBreak --> by using Route A (SDK).
Deprecation warning from langchain-community The package is being sunset Use Route A (SDK). If you must use the loader, pin the version.
Entra ID authentication fails Using a regional endpoint, or role assignment missing Switch to the custom-subdomain endpoint. Verify the role (Section 4).
Bill higher than expected Files re-analysed on every run, or add-ons left enabled Confirm cache/ persists between runs. Enable add-ons only for document types that need them.

8. Monitoring and alerts

Set thresholds from your sandbox baseline. Do not copy a number from another corpus.

Signal Alert when (team defaults) Action
over_max or broken_tables in the chunk report Any value above 0 Fail the pipeline build. Check the profile chunk_size or the chunker's table-splitting logic.
no_heading count Above the per-file-type baseline Review the source documents. Switch to header_levels: 0 if headings are absent throughout.
HTTP 429 from Document Intelligence Sustained across an entire run Slow the batch rate. Review the quota and request an increase if throttling persists.
Pages analysed (cost proxy) Exceeds the expected corpus page count Stop the pipeline. Investigate cache misses or unintended file duplication.

9. Cost and capacity notes

  • Analysis billing. Document Intelligence prebuilt-layout is billed per 1,000 pages analysed. Check the current price before approval. Prices vary by region and agreement.
  • Add-on features. ocrHighResolution, formulas, and styleFont incur additional per-page costs above the base layout rate. Enable them only for document types that need them.
  • Office document page calculations. Word, PowerPoint, and HTML count 1 billable page per 3,000 characters.
  • Local chunking cost. Splitting, table masking, and JSONL generation run locally. Re-chunking with changed overlap or heading settings incurs zero cloud cost.

10. AWS and open-source equivalents

Azure (this playbook) AWS Open source
Read the layout prebuilt-layout Textract AnalyzeDocument with LAYOUT and TABLES Docling DocumentConverter
Output format Markdown built in Geometry blocks; convert to Markdown yourself Docling export_to_markdown()
Chunking This playbook Bedrock Knowledge Bases: fixed-size, default, hierarchical, semantic, or none Docling HierarchicalChunker and HybridChunker (repeats table headers)

11. Validation record

Pending. Filled in by the dry-runner (see docs/validation-protocol.md).

Sandbox scenario:

  1. Deploy a Document Intelligence S0 resource with a custom-subdomain endpoint in the sandbox.
  2. Provide three sample documents: a structured PDF with tables, a scanned contract, and a flat prose document.
  3. Configure profile.yaml with values from PB-RAG-01-01 (header_levels: 3, prefix_heading: true).
  4. Run Steps 1–6 of this playbook. Run Steps 1–5 twice to confirm the cache is used on the second pass.
  5. A second engineer (not the author) performs the dry-run and fills the record below.

Pass criteria:

  • All steps execute without unhandled exceptions.
  • out/chunks.jsonl contains valid records for all three input documents.
  • Report shows over_max: 0 and broken_tables: 0.
  • Manual spot-check of 10 chunks per file type confirms table headers repeat on split rows.
  • Second run reads from disk cache and makes zero new service calls.

Record:

Field Value
Dry-run date
Dry-run engineer (not the author)
Sandbox resource and region
Sample documents tested
Python and SDK versions used
Total chunks generated
Report summary (over_max, broken_tables)
Cache retrieval confirmed (yes/no)
Senior reviewer and sign-off date
Set last_validated to

12. Sources

Checked on 27 October 2026.

  • PB-RAG-01-01: Choose a chunking strategy by document type — provides benchmarked profile parameters used by this chunker.
  • PB-RAG-01-03: Re-chunk and re-embed a live corpus without downtime — deploys new chunk collections into production search indexes using index aliases.