Source: http://www.poma-ai.com/docs/pipelines/azure-document-intelligence-to-qdrant

# The Missing Link Between Azure Document Intelligence and Optimal Retrieval in Qdrant

<ByAuthor />

**The short answer:** Azure Document Intelligence's `prebuilt-layout` gives you one of the best layout analyses money can buy; Qdrant gives you excellent hybrid vector search. Wired together naively — flatten, split, embed — they still produce mediocre RAG, because nothing in between rebuilds the document's hierarchy. The missing link is POMA: `PrimeCut().ingest()` consumes the raw analyze result (markdown mode or JSON mode, auto-detected), emits hierarchy-preserving [chunksets](/learn/chunking/chunksets), and `PomaQdrant.upsert_poma_points()` writes them as hybrid dense+sparse points with indexed hierarchy payloads. Retrieval comes back as assembled, prompt-ready cheatsheets.

## What each end of the pipeline actually provides

**Azure Document Intelligence** (`prebuilt-layout`) returns its analysis in two shapes: markdown mode (`outputContentFormat="markdown"`) — one reading-order markdown string with inline HTML tables, `<!-- PageBreak -->` delimiters, and page-furniture comments — or classic JSON mode, a `paragraphs[]` array ordered by span offset with roles like `title` and `sectionHeading`, plus `tables[]` as cell grids. Multi-column de-interleaving happens server-side. What it doesn't provide: cross-page hierarchy (a `sectionHeading` on page 41 has no link to the `title` on page 3), retrieval units, or figure bytes. Details: [The Optimal Chunker for Azure Document Intelligence](/optimal-chunker-azure-document-intelligence).

**Qdrant** provides named dense + sparse vectors on one point, payload indexes for filtered ANN, and HNSW with quantization options. What it doesn't provide: any opinion about what a point should contain. It retrieves nearest neighbors of whatever you embedded — including context-free fragments, if that's what you gave it. Details: [The Optimal Chunks for the Best Retrieval in Qdrant](/optimal-chunks-qdrant).

## The naive wiring, and where it breaks

The pair-specific failure here is well documented, because Microsoft ships it: the RAG samples and accelerators in Azure's own ecosystem take prebuilt-layout's markdown output and run **fixed-size or token-window splitters** over it. Everything the layout model recovered vanishes before Qdrant sees a single vector:

- **`PageBreak` delimiters are flattened away**, so no Qdrant payload can cite a page. The one field that makes a legal or compliance answer auditable — "clause 14.2, page 41" — is gone at ingestion, not at retrieval.
- **Role-tagged headings are treated as ordinary text.** `title` and `sectionHeading` paragraphs — the skeleton of the document — get cut into whatever 512-token window they happen to fall in. Retrieved fragments arrive in the prompt without lineage, and the LLM answers out of context.
- **HTML tables are sliced mid-row**, separating header rows from data rows, which is precisely the failure Azure's table extraction was supposed to prevent.
- **Splitter overlap inflates the collection.** Every boundary span is embedded twice, which in Qdrant means a larger HNSW graph and top-k results where hits 2 and 3 are near-duplicates of hit 1, crowding out the passage that actually answers the question.

The result: an expensive, excellent analysis reduced to the same context-free fragments a plain-text scrape would have produced. Qdrant executes the retrieval flawlessly — over inputs that were ruined one step earlier.

## The pipeline, end to end

```bash
pip install azure-ai-documentintelligence 'poma[qdrant]'
```

```python
import json
import os

from azure.ai.documentintelligence import DocumentIntelligenceClient
from azure.core.credentials import AzureKeyCredential
from poma import PrimeCut
from poma.integrations.qdrant import PomaQdrant

# 1. Azure Document Intelligence — your existing call, unchanged.
di = DocumentIntelligenceClient(
    endpoint=os.environ["AZURE_DI_ENDPOINT"],
    credential=AzureKeyCredential(os.environ["AZURE_DI_KEY"]),
)
with open("contract.pdf", "rb") as f:
    poller = di.begin_analyze_document(
        "prebuilt-layout",
        body=f,
        output_content_format="markdown",  # omit for classic JSON mode — POMA handles both
    )
analysis = poller.result()
with open("contract.azure-di.json", "w") as f:
    json.dump(analysis.as_dict(), f)

# 2. The missing link — raw analyze result in, chunks + chunksets out.
poma = PrimeCut()  # reads POMA_API_KEY
result = poma.ingest("contract.azure-di.json")  # Azure shape auto-detected

# 3. Qdrant — hybrid points with hierarchy payloads, one call.
qdrant = PomaQdrant(
    url=os.environ["QDRANT_URL"],
    api_key=os.environ["QDRANT_API_KEY"],
    cloud_inference=True,
    collection_name="contracts",
    dense_model="sentence-transformers/all-MiniLM-L6-v2",
    sparse_model="Qdrant/bm25",
    dense_size=384,
    auto_create_collection=True,
)
qdrant.upsert_poma_points(result)

# 4. Retrieve prompt-ready context.
cheatsheets = qdrant.get_cheatsheets(
    query="What are the early termination conditions?",
    limit=3,
)
print(cheatsheets[0]["content"])
```

POMA validates the payload shape up front (a corrupted or mislabeled upload 422s immediately — never a silently degraded collection). Markdown mode is split on `PageBreak` comments with `PageHeader`/`PageFooter`/`PageNumber` furniture dropped; JSON mode is reconstructed from `paragraphs[]` in span-offset order with roles mapped to heading levels and `tables[]` converted to HTML. Figures — which Azure never returns as bytes — are counted as offloaded in `content_metadata`, so the loss is visible, never silent. To force or suppress detection, set `external_ocr_source` to `"azure_di"` or `"none"`.

Measured on our reference legal-document benchmark, this pipeline answers the same query with **337 tokens** of retrieved context instead of **1,542** for a recursive character splitter, with zero information loss — methodology in [Document Ingestion & Chunking for RAG](/document-ingestion-chunking-rag).

## Metadata mapping: POMA fields → Qdrant primitives

| POMA chunk field | Qdrant primitive | What it enables |
| --- | --- | --- |
| `to_embed` | dense vector + BM25 sparse vector (named vectors, one point) | paraphrase + exact-term hybrid retrieval |
| `file_id` | payload field, **payload-indexed** | scope queries to one document |
| `page` (from `PageBreak` delimiters or span-to-page mapping) | payload field, **payload-indexed** | page-cited answers, page-range filters |
| `depth` (from `title`/`sectionHeading` roles, rebuilt cross-page) | payload field | filter/re-rank by hierarchy level |
| `chunk_index` | payload field | stable ordering at assembly time |
| chunkset lineage | payload (`chunk_details`) | cheatsheet assembly without a second store |

## Frequently asked questions

### How do I get Azure Document Intelligence results into Qdrant for RAG?

Run `prebuilt-layout` as you already do — either mode — and save the raw analyze result. `PrimeCut().ingest()` auto-detects the Azure shape and rebuilds the cross-page hierarchy into chunks and chunksets; `PomaQdrant.upsert_poma_points(result)` writes hybrid points with `file_id`/`page`/`depth` payloads. Retrieve with `get_cheatsheets(query=...)`.

### Does POMA handle both prebuilt-layout markdown mode and JSON mode on the way to Qdrant?

Yes. Markdown mode is split on `PageBreak` comments (furniture comments dropped); JSON mode is reconstructed from `paragraphs[]` in span-offset order, roles mapped to headings, cell grids converted to HTML. Both end in identical chunks and chunksets — the Qdrant collection looks the same either way.

### What Qdrant payload fields should Azure Document Intelligence chunks carry?

`file_id`, `page` (recovered from `PageBreak` delimiters or the span-to-page mapping), `depth`, and `chunk_index`, plus content; payload-index `file_id` and `page`. `PomaQdrant` writes these by default.

### Do Azure Document Intelligence tables survive the trip to Qdrant?

Yes — inline HTML tables (markdown mode) and reconstructed cell grids (JSON mode) enter the chunk layer as HTML, merged cells intact, never cut mid-row, and each is embedded with its heading lineage. Fixed-size splitters routinely separate header rows from data rows instead.

### Why do Microsoft's own RAG samples underperform when the vectors land in Qdrant?

They run fixed-size or token-window splitters over the markdown output, so page breaks and role-tagged headings vanish before Qdrant sees a vector: no page payload, no depth, tables cut mid-row, near-duplicate overlap points. Qdrant retrieves exactly what it was given — context-free fragments.

## Related recipes

Same parser, different store: [Azure Document Intelligence → Pinecone](/pipelines/azure-document-intelligence-to-pinecone) · [Azure Document Intelligence → Weaviate](/pipelines/azure-document-intelligence-to-weaviate) · [Azure Document Intelligence → pgvector](/pipelines/azure-document-intelligence-to-pgvector)

Same store, different parser: [Mistral OCR → Qdrant](/pipelines/mistral-ocr-to-qdrant) · [LlamaParse → Qdrant](/pipelines/llamaparse-to-qdrant) · [Textract → Qdrant](/pipelines/textract-to-qdrant)

Foundations: [The Optimal Chunker for Azure Document Intelligence](/optimal-chunker-azure-document-intelligence) · [The Optimal Chunks for Qdrant](/optimal-chunks-qdrant) · [All pipeline recipes](/pipelines/) · [RAG chunking guide](/guides/rag-chunking/)