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

# The Missing Link Between Docling and Optimal Retrieval in Elasticsearch

<ByAuthor />

**The short answer:** Docling gives you a typed DoclingDocument tree with explicit heading levels and pre-isolated page furniture; Elasticsearch gives you a `dense_vector` mapping and two decades of mature BM25 to fuse against it. Wired together naively, skipping the tree and letting dynamic mapping infer field types from raw markdown splits quietly breaks the exact-match filters your pipeline depends on. The missing link is POMA: `PrimeCut` parses the DoclingDocument tree into [chunksets](/learn/chunking/chunksets), and `poma.vektoria` indexes them as content-free documents — `(id, vector, {file_id, chunkset_index})` — with the real text on a volume. Retrieval runs Elasticsearch's own `knn` query, then `assemble()` returns 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, or a schema for whatever database receives them. Details: [The Optimal Chunker for Docling](/optimal-chunker-docling).

**Elasticsearch** provides `dense_vector` fields with HNSW-backed approximate k-NN, native BM25 on any analyzed text field, and a single request combining a `knn` clause with a `bool`/`match` query, fused server-side via `rank`. What it doesn't provide: any opinion about what a document's fields should hold or how they should be typed. Details: [Elasticsearch Chunking Strategy for RAG](/optimal-chunks-elasticsearch).

## The naive wiring, and where it breaks

The common recipe — `doc.export_to_markdown()` → character splitter → `es.index(...)` without a declared mapping — breaks in a pair-specific way that surfaces only after the index is already populated.

Docling's typed tree already tells you which fields are identifiers (`file_id`) and which are hierarchy (`section_header.level`), a natural cue to type them as `keyword`/`integer` from the start. Skip the tree and index raw markdown splits instead, and most teams let Elasticsearch's dynamic mapping infer types from the first document indexed — and an identifier-looking string is typically inferred as analyzed `text`, not `keyword`. Elasticsearch field types are immutable once set: a later `bool.filter` on `file_id` expecting exact matches instead runs against a tokenized field and silently returns nothing (or matches the wrong documents), and there is no mapping patch — only a full reindex from source. Compounding it, flattening to markdown also throws away the `furniture` group's pre-isolated `page_header`/`page_footer` labels, so the analyzed `text` field that dynamic mapping did get right is full of running headers and footers indexed as if they were content.

## The pipeline, end to end

```bash
pip install docling elasticsearch poma
```

```python
import json
from docling.document_converter import DocumentConverter
from poma import PrimeCut
from poma.vektoria import assemble, records_from_archive, Volume
from poma.embeddings import get_embedder
from elasticsearch import Elasticsearch

# 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, .poma archive out.
client = PrimeCut()  # reads POMA_API_KEY
client.ingest("contract.docling.json", download_dir="archives", filename="contract.poma")

embedder = get_embedder("local:BAAI/bge-small-en-v1.5")
vol = Volume("s3://your-bucket/poma")
es = Elasticsearch("https://localhost:9200")

# Declare the mapping up front — file_id as keyword, never left to dynamic inference.
es.indices.create(index="poma", mappings={"properties": {
    "embedding": {"type": "dense_vector", "dims": embedder.dims, "index": True, "similarity": "cosine"},
    "file_id": {"type": "keyword"},
    "chunkset_index": {"type": "integer"},
    "text": {"type": "text"},
}}, ignore=400)

# 3. Content-free ingest — index holds only routing metadata; content lives on the volume.
records = records_from_archive("archives/contract.poma")  # -> list[Record] (id, text, payload)
for r in records:
    vol.write_doc(r.payload["file_id"], {"file_id": r.payload["file_id"],
                                          "chunks": r.payload["chunks"], "text": r.text})
    es.index(index="poma", id=r.id, document={
        "embedding": embedder.embed([r.text])[0],
        "file_id": r.payload["file_id"],
        "chunkset_index": r.payload["chunkset_index"],
        "text": r.text,
    })

# 4. Retrieval — knn returns _source by default, then assemble.
qv = embedder.embed(["What are the early termination conditions?"])[0]
res = es.search(index="poma", knn={"field": "embedding", "query_vector": qv,
                                    "k": 10, "num_candidates": 100})
context = assemble(res, volume=vol)  # -> [{"file_id", "content"}, ...]
print(context[0]["content"])
```

Auto-detection fingerprints `schema_name == "DoclingDocument"` up front — a corrupted or mislabeled upload 422s immediately, never silently degrading downstream. A docling-serve envelope carrying only `md_content` falls back to a markdown passthrough. On our reference legal-document benchmark, this pipeline answers 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 → Elasticsearch primitives

| POMA field | Elasticsearch primitive | What it enables |
| --- | --- | --- |
| `r.id` (deterministic `chunkset_uuid`) | document `_id` | same chunkset lands on the same document across every connector |
| `r.text` (the `to_embed` text) | `dense_vector` field + analyzed `text` field | `knn` + BM25 hybrid via `rank` |
| `file_id` | `keyword` field, declared in the mapping | exact-match `bool.filter` scoping to one document |
| `chunkset_index` | `integer` field | returned in `_source` by default — no extra flag for `assemble()` |
| `chunks` (member chunk list, including Docling `section_header` depth) | written to the volume document, not an index field | cheatsheet reconstruction without widening the mapping |

## Frequently asked questions

### How do I get Docling output into Elasticsearch for RAG?

Save `export_to_dict()` as JSON, run it through `PrimeCut`, declare a mapping with `dense_vector` + keyword `file_id` + integer `chunkset_index`, then index via `records_from_archive()`. Retrieve with `knn`, then `assemble()`.

### Why must the Elasticsearch mapping declare file_id as a keyword field before indexing Docling chunks?

Dynamic mapping infers types from the first document and typically picks analyzed `text` for identifier-looking strings. Field types are immutable once set, so an un-declared `file_id` breaks exact-match filters permanently until you reindex.

### What Elasticsearch fields should Docling chunksets populate?

`embedding` as `dense_vector` (dims matching your embedder), `file_id` as `keyword`, `chunkset_index` as `integer`, `text` as analyzed — declared in the mapping before the first index call.

### Does Elasticsearch's knn query return enough to retrieve content from a Docling-derived index?

Yes — `_source` returns by default, so `file_id` and `chunkset_index` come back with no extra request parameter, and `assemble()` resolves content from the volume directly.

### Should I flatten the DoclingDocument to markdown before indexing into Elasticsearch?

No. Flattening collapses explicit heading levels and re-injects furniture into the analyzed text field. Feed the tree; PrimeCut keeps levels as hierarchy and honors Docling's furniture classification first.

## Related recipes

Same parser, different store: [Docling → OpenSearch](/pipelines/docling-to-opensearch) · [Docling → Turbopuffer](/pipelines/docling-to-turbopuffer) · [Docling → Weaviate](/pipelines/docling-to-weaviate)

Foundations: [The Optimal Chunker for Docling](/optimal-chunker-docling) · [Elasticsearch Chunking Strategy for RAG](/optimal-chunks-elasticsearch) · [All pipeline recipes](/pipelines/) · [RAG chunking guide](/guides/rag-chunking/)