Source: http://www.poma-ai.com/docs/pipelines/docling-to-qdrant

# The Missing Link Between Docling and Optimal Retrieval in Qdrant

<ByAuthor />

**The short answer:** Docling gives you one of the best structure detectors in open source — a typed DoclingDocument tree with explicit heading levels and pre-isolated page furniture. Qdrant gives you excellent hybrid vector search. Wired together naively — `export_to_markdown()`, split, embed — they still produce mediocre RAG, because the flattening step throws away exactly what Docling worked to recover. The missing link is POMA: `PrimeCut().ingest()` consumes the `export_to_dict()` JSON (auto-detected), parses the tree natively into [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

**Docling** (IBM's open-source converter, also served via docling-serve) returns the DoclingDocument: a typed `texts`/`tables`/`pictures`/`groups` tree in which `section_header` items carry an explicit numeric `level`, tables are cell grids rather than pipe approximations, and repeating page furniture is parked in the `furniture` group with `page_header`/`page_footer` labels. What it doesn't provide: retrieval units. Its HybridChunker packs items into token windows, which controls chunk length, not chunk meaning. Details: [The Optimal Chunker for Docling](/optimal-chunker-docling).

**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 orphaned fragments and page-number noise, 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 common recipe — `doc.export_to_markdown()` → `RecursiveCharacterTextSplitter` → embed → `client.upsert(...)` — breaks in a pair-specific way: the flattening step discards the two things Docling is best at, and Qdrant then indexes the loss.

- **Explicit heading levels become glyphs, then nothing.** A `section_header` with `level: 2` is reduced to `##` in the markdown, and the character splitter cuts wherever the count lands. The passage under *Early termination* reaches Qdrant with no machine-readable link to *Termination clauses* or *Master Services Agreement* — the LLM reads an orphaned fragment and answers out of context.
- **Furniture gets re-injected, then embedded.** Docling had already isolated running headers, footers, and page numbers in the `furniture` group. Flattening pours them back into the text stream, and every one becomes a vector — near-identical noise points, repeated once per page, that crowd top-k results.
- **Overlap inflates the collection.** Splitter overlap embeds every boundary span twice, growing the HNSW graph and stacking near-duplicates behind hit 1.

POMA parses the tree natively instead: explicit `section_header` levels become real hierarchy depth, the furniture classification is honored (deterministic, and free — your precursor did the work), tables survive as HTML, and chunks group into overlap-free chunksets.

## The pipeline, end to end

```bash
pip install docling 'poma[qdrant]'
```

```python
import json
import os
from docling.document_converter import DocumentConverter
from poma import PrimeCut
from poma.integrations.qdrant import PomaQdrant

# 1. Docling conversion — your existing call, unchanged.
converter = DocumentConverter()
doc = converter.convert("contract.pdf").document
with open("contract.docling.json", "w") as f:
    json.dump(doc.export_to_dict(), f)

# 2. The missing link — DoclingDocument tree in, chunks + chunksets out.
poma = PrimeCut()  # reads POMA_API_KEY
result = poma.ingest("contract.docling.json")  # DoclingDocument 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 — auto-detection fingerprints `schema_name == "DoclingDocument"`, and a corrupted or mislabeled upload 422s immediately, never silently degrading the index. A docling-serve envelope carrying only `md_content` falls back to a markdown passthrough with the same downstream stages. To force or suppress detection, set `external_ocr_source` to `"docling"` or `"none"`. Because Docling already ran the parsing, POMA charges only for the downstream structure and chunking value.

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 Docling item provenance) | payload field, **payload-indexed** | page-cited answers, page-range filters |
| `depth` (from explicit `section_header` levels) | 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 Docling output into Qdrant for RAG?

Save `export_to_dict()` as JSON, run `PrimeCut().ingest()` on it (the DoclingDocument shape is auto-detected and the tree parsed natively), then `PomaQdrant.upsert_poma_points(result)` — hybrid points with `file_id`/`page`/`depth` payloads. Retrieve with `get_cheatsheets(query=...)`.

### Should I export the DoclingDocument to markdown before embedding into Qdrant?

No. Flattening reduces explicit `section_header` levels to glyphs and re-injects the `furniture` group into the text stream, so Qdrant stores orphaned fragments plus page-noise vectors. Feed the tree; POMA keeps levels as depth and drops the furniture.

### What Qdrant payload fields should Docling chunks carry?

`file_id`, `page`, `depth` (from Docling's explicit heading levels), `chunk_index`, plus content; payload-index `file_id` and `page`. `PomaQdrant` writes these by default.

### Do Docling's page headers and footers end up in my Qdrant collection?

Not when POMA gets the tree: the `furniture` group and `page_header`/`page_footer` labels are honored and dropped, with POMA's own running-header/footer strip on top. Flatten first and they become per-page noise points.

### Can I use Docling's HybridChunker to prepare points for Qdrant?

It emits token windows without the ancestor heading path, and Qdrant returns them as-is. Chunksets keep every leaf attached to all its ancestors — no window size to tune — and `PomaQdrant` writes them as hybrid points directly.

## Related recipes

Same parser, different store: [Docling → Pinecone](/pipelines/docling-to-pinecone) · [Docling → Weaviate](/pipelines/docling-to-weaviate) · [Docling → pgvector](/pipelines/docling-to-pgvector)

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

Foundations: [The Optimal Chunker for Docling](/optimal-chunker-docling) · [The Optimal Chunks for Qdrant](/optimal-chunks-qdrant) · [All pipeline recipes](/pipelines/) · [RAG chunking guide](/guides/rag-chunking/)