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

# The Missing Link Between LlamaParse and Optimal Retrieval in Qdrant

<ByAuthor />

**The short answer:** LlamaParse gives you excellent per-page markdown; Qdrant gives you excellent hybrid vector search. Wired together the standard LlamaIndex way — `MarkdownElementNodeParser` into a `VectorStoreIndex` over Qdrant — they still produce mediocre RAG, because the nodes in between are isolated fragments. The missing link is POMA: `PrimeCut().ingest()` consumes the saved LlamaParse result JSON (auto-detected), rebuilds the cross-page heading hierarchy 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

**LlamaParse**, LlamaIndex's markdown-native parser, returns a JSON result with `pages[]`, each carrying a `page` number, an `md` string (headings marked, tables inline), a flattened `text` string, and an `images` list. The `md` field is where the value lives. What it doesn't provide: cross-page hierarchy (each page's `md` is independent — page 41's `## Termination clauses` has no link to page 3's `# Master Services Agreement`), retrieval units, or inline image bytes (those stay server-side, one `/result/image/{name}` fetch each). Details: [The Optimal Chunker for LlamaParse](/optimal-chunker-llamaparse).

**Qdrant** provides named dense + sparse vectors on one point, payload indexes for filtered ANN, and HNSW with quantization options, in Qdrant Cloud or open source. 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 canonical LlamaIndex recipe — `LlamaParse(result_type="markdown").load_data(...)` → `MarkdownElementNodeParser` → `VectorStoreIndex` over a Qdrant vector store — is three lines of glue, which is exactly the problem. It breaks in a pair-specific way:

- **Nodes arrive in Qdrant as orphans.** `MarkdownElementNodeParser` separates tables from prose, but the node under `### Early termination` carries no machine-readable link to the chapter it lives in. Qdrant embeds and retrieves it faithfully — an out-of-context paragraph, at excellent latency.
- **LlamaParse's page numbers never reach the payload.** Markdown mode hands the pipeline page-independent markdown; by the time nodes exist, `pages[].page` is gone, so no Qdrant payload can cite a page and page-range filters are off the table.
- **Dead image references become noise vectors.** The saved result carries only `![](name)` refs — the bytes live server-side at LlamaParse. Fed through a node parser, those dead links either pollute the embedded text or get regex-stripped with no record of what was lost.
- **Qdrant's sparse side goes unused.** The default wiring embeds dense-only. Even when hybrid is enabled, fragments starve BM25 of section vocabulary — the terms worth matching live in the headings the node parser threw away.

Neither tool is at fault. LlamaParse parsed correctly; Qdrant retrieved correctly. The step between them discarded the structure one produced and the other could have filtered on.

## The pipeline, end to end

```bash
pip install llama-parse 'poma[qdrant]'
```

```python
import json
import os

from llama_parse import LlamaParse
from poma import PrimeCut
from poma.integrations.qdrant import PomaQdrant

# 1. LlamaParse — your existing parse job, unchanged.
parser = LlamaParse(api_key=os.environ["LLAMA_CLOUD_API_KEY"])
json_result = parser.get_json_result("contract.pdf")
with open("contract.llamaparse.json", "w") as f:
    json.dump(json_result[0], f)

# 2. The missing link — raw result JSON in, chunks + chunksets out.
poma = PrimeCut()  # reads POMA_API_KEY
result = poma.ingest("contract.llamaparse.json")  # LlamaParse 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 fingerprints the payload up front (`pages[]` with `md` + `page` — a corrupted or mislabeled upload 422s immediately), reads `md` and ignores the flattened `text`, neutralizes the server-side image refs and counts them in `content_metadata`, strips running headers and footers, and rebuilds the cross-page heading tree before chunking. To force or suppress detection, set `external_ocr_source` to `"llamaparse"` or `"none"`.

If you're building on LlamaIndex, POMA replaces the node-parser step, not your framework — from the retrieved cheatsheets onward, everything is your stack.

## 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 LlamaParse `pages[].page`) | payload field, **payload-indexed** | page-cited answers, page-range filters |
| `depth` | 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 |

## What retrieval looks like afterwards

`get_cheatsheets` runs the hybrid query (dense + BM25 sparse, fused), then deduplicates the heading lineage the retrieved chunksets share and merges them into one coherent context block. That assembly step is where the token savings land: on our reference legal-document benchmark, the same query answered with **337 tokens** of context instead of **1,542** for a recursive character splitter, with zero information loss — [methodology here](/document-ingestion-chunking-rag). The point is not that Qdrant got faster; it's that every point in the collection was worth retrieving.

## Frequently asked questions

### How do I get LlamaParse output into Qdrant for RAG?

Save the raw JSON result, run `PrimeCut().ingest()` on it (auto-detected, `md` preferred, hierarchy rebuilt), then `PomaQdrant.upsert_poma_points(result)` — hybrid points with `file_id`/`page`/`depth` payloads. Retrieve with `get_cheatsheets(query=...)`.

### Do I still need MarkdownElementNodeParser and VectorStoreIndex to use LlamaParse with Qdrant?

No — that pairing is where the quality loss happens: isolated nodes, no ancestor context, page numbers gone. POMA replaces the node-parser step and writes chunksets to Qdrant directly; everything downstream of retrieval stays your LlamaIndex stack.

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

`file_id`, `page` (from `pages[].page`), `depth`, `chunk_index` plus content; payload-index `file_id` and `page`. `PomaQdrant` writes these by default — but only if the page number survives chunking, which concatenate-then-split pipelines prevent.

### Do images in a LlamaParse result make it into Qdrant?

Not as content — the bytes live server-side at LlamaParse and the saved JSON has only `![](name)` refs. POMA neutralizes those dead refs and counts each in `content_metadata`, so the loss is visible in the ingest result instead of embedded as broken links.

### Should the Qdrant collection use hybrid search for LlamaParse content?

Yes: parsed documents are full of exact tokens that dense embeddings blur. `PomaQdrant` writes BM25 sparse vectors by default and fuses both at query time — and chunkset heading breadcrumbs give the sparse side section vocabulary that fragments lack.

## Related recipes

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

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

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