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, andqueryFieldscost extra.languages,barcodes, andkeyValuePairsare 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:
- Strips
<!-- PageHeader="..." -->,<!-- PageFooter="..." -->, and<!-- PageNumber="..." -->comments. - Converts
<!-- PageBreak -->into sequential tracking tokens (@@P{n}@@). - Masks each HTML
<table>...</table>block behind a short token (TBL0X,TBL1X, …) so no splitter can cut it. - If a table is larger than
chunk_size, splits it between<tr>rows and repeats the<th>header row on each group. - Splits on Markdown headings up to
header_levelsdepth using LangChainMarkdownHeaderTextSplitter. - Subdivides large sections with
RecursiveCharacterTextSplitter, measuring tables at their real size. - Restores table HTML, records the active page number, and adds
chunk_id,heading_path, andembed_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.
- 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 .
- 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}.
- 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 .
- 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.
- 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.jsonlcontains non-emptysource,page,chunk_id, andcontentfields. - The
report()summary showsover_max: 0andbroken_tables: 0across 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.jsonlexactly 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-layoutis billed per 1,000 pages analysed. Check the current price before approval. Prices vary by region and agreement. - Add-on features.
ocrHighResolution,formulas, andstyleFontincur 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:
- Deploy a Document Intelligence S0 resource with a custom-subdomain endpoint in the sandbox.
- Provide three sample documents: a structured PDF with tables, a scanned contract, and a flat prose document.
- Configure
profile.yamlwith values from PB-RAG-01-01 (header_levels: 3,prefix_heading: true). - Run Steps 1–6 of this playbook. Run Steps 1–5 twice to confirm the cache is used on the second pass.
- 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.jsonlcontains valid records for all three input documents.- Report shows
over_max: 0andbroken_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.
- Microsoft Learn, Retrieval-Augmented Generation with Azure Document Intelligence: RAG guidance, layout Markdown integration, semantic chunking concepts.
- Microsoft Learn, Document layout analysis (Layout model):
prebuilt-layoutcapabilities, headings, HTML tables, page break markers. - Microsoft Learn, Document Intelligence supported Markdown elements: Markdown format specification, HTML table structure,
PageBreakcomments. - Microsoft Learn, Add-on capabilities:
ocrHighResolution,formulas,styleFont, and free add-ons. - Microsoft Learn, Service quotas and limits: S0 and F0 rates, page limits, file size limits.
- Microsoft Learn, Azure Document Intelligence pricing: per-thousand page meters for layout and add-ons.
- Microsoft Learn, Analyze Document (REST API 2024-11-30): REST analyze endpoint, query parameters, polling protocol.
- Azure SDK for Python, azure-ai-documentintelligence README:
DocumentIntelligenceClient, authentication, Markdown content format. - Microsoft Learn, Document Layout skill: Azure AI Search managed skillset layout integration, timeout and billing limits.
- LangChain Documentation, Azure AI Document Intelligence loader: loader parameters, modes, and community package deprecation notes.
- LangChain Documentation, MarkdownHeaderTextSplitter: splitting on Markdown header tags and attaching header metadata.
- LangChain Documentation, LangChain text splitters: recursive character splitting concepts and parameters.
- AWS Documentation, Analyzing documents (Amazon Textract): Textract layout and table block structure.
- AWS Documentation, How content chunking works for Amazon Bedrock knowledge bases: Bedrock managed hierarchical and semantic chunking.
Related playbooks¶
- 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.