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

# Mistral OCR to Qdrant: Chunking for RAG

<ByAuthor />

**The short answer:** Mistral OCR gives you excellent per-page markdown; 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 `/v1/ocr` JSON (auto-detected), emits hierarchy-preserving [chunksets](/learn/chunking/chunksets), and `PomaQdrant.upsert_poma_points()` writes them as hybrid dense+sparse points that carry the chunkset's lineage in the payload. Retrieval comes back as assembled, prompt-ready cheatsheets.

## What each end of the pipeline actually provides

**Mistral OCR** (`/v1/ocr`, `mistral-ocr-latest`) returns `pages[]`, each with an `index` and a `markdown` string — headings, lists, and tables recovered *within* each page, image bytes inline when you set `include_image_base64`. What it doesn't provide: cross-page hierarchy (page 41's `## Termination clauses` has no link to page 3's `# Master Services Agreement`) or retrieval units. Details: [The Optimal Chunker for Mistral OCR](/optimal-chunker-mistral-ocr).

**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 common recipe — `"\n".join(p["markdown"] for p in pages)` → `RecursiveCharacterTextSplitter` → embed → `client.upsert(...)` — breaks in a pair-specific way:

- **Mistral's page indices vanish at the join**, so no Qdrant payload can cite a page, and per-document filters are all you have left.
- **Overlap inflates the collection.** Splitter overlap (typically 10–20%) embeds every boundary span twice. In Qdrant that means a measurably larger HNSW graph and — worse — top-k results where hits 2 and 3 are near-duplicates of hit 1, crowding out the passage that actually answers the question.
- **Mistral's heading levels are discarded**, so retrieved fragments arrive without lineage and the LLM answers out of context — the failure OCR quality can't fix.

## The pipeline, end to end

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

```python
import os
from mistralai.client import Mistral  # mistralai>=2 is a namespace package; the client lives in mistralai.client
from poma import PrimeCut
from poma.integrations.qdrant import PomaQdrant

# 1. Mistral OCR — your existing call, unchanged.
mistral = Mistral(api_key=os.environ["MISTRAL_API_KEY"])
ocr = mistral.ocr.process(
    model="mistral-ocr-latest",
    document={"type": "document_url", "document_url": "https://example.com/contract.pdf"},
    include_image_base64=True,
)
with open("contract.mistral-ocr.json", "w") as f:
    f.write(ocr.model_dump_json())

# 2. The missing link — raw result JSON in, chunks + chunksets out.
poma = PrimeCut()  # reads POMA_API_KEY
result = poma.ingest("contract.mistral-ocr.json")  # Mistral 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=False,  # True needs Qdrant Cloud's inference service; False embeds locally via fastembed and works on OSS/local Qdrant too
    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), handles all three Mistral image cases (base64 → described; annotation → spliced; neither → visible marker, counted in `content_metadata`), strips running headers/footers, and rebuilds the cross-page heading tree before chunking. To force or suppress detection, set `external_ocr_source` to `"mistral"` or `"none"`.

## Metadata mapping: POMA fields → Qdrant primitives

One point per chunkset. `upsert_poma_points` writes exactly this payload: `{"chunkset_index", "chunks", "file_id", "text"}` plus `"chunk_details"` (the chunkset's chunk records — `chunk_index`, `content`, `depth`, `file_id`, `code`) while `store_chunk_details` stays at its default `True`.

| POMA field | Qdrant primitive | What it enables |
| --- | --- | --- |
| `to_embed` | dense vector + BM25 sparse vector (named vectors, one point) | paraphrase + exact-term hybrid retrieval |
| `text` (a copy of `to_embed`) | payload field | read back what was embedded |
| `file_id` | payload field (no index is created for you — add one with `create_payload_index`) | scope queries to one document |
| `chunkset_index` | payload field | identifies the point: the id is a UUIDv5 of `file_id` + `chunkset_index` |
| `chunks` (chunk indices) | payload field | joins the point back to its chunk records |
| chunk lineage (`chunk_index`, `content`, `depth`, `code`) | payload (`chunk_details`) | cheatsheet assembly without a second store |
| `page` | **not written by PomaQdrant** — the SDK's `PomaChunk` drops it; it survives on the archive's chunk records | page-cited answers, page-range filters — see below |

`get_cheatsheets` reads only that payload back: it groups each hit's `chunkset_index` + `chunks` by `file_id` and assembles the text from the hit's `chunk_details` (or from a `chunk_data` you pass instead).

Page numbers are in the `.poma` archive, so keep it (`poma.ingest(..., download_dir="store", filename="contract.poma")`) and attach them to the same points:

```python
from poma.utils import unpack_poma_archive
from poma.integrations.qdrant.qdrant_poma_utils import chunk_uuid_string

page_of = {c["chunk_index"]: c.get("page") for c in unpack_poma_archive(poma_archive_path="store/contract.poma")["chunks"]}
for cs in result.chunksets:
    leaf = cs.chunks[-1]  # chunk indices are in document order; the last one is the leaf
    qdrant.set_payload(collection_name="contracts", payload={"page": page_of[leaf]}, points=[chunk_uuid_string(cs.file_id, cs.chunkset_index)])
```

## Frequently asked questions

### How do I get Mistral OCR results into Qdrant for RAG?

Save the raw `/v1/ocr` JSON, run `PrimeCut().ingest()` on it (auto-detected, hierarchy rebuilt), then `PomaQdrant.upsert_poma_points(result)` — hybrid points whose payload carries `file_id`, `chunkset_index`, the chunkset's chunk indices, the embedded `text`, and `chunk_details` (`chunk_index`, `content`, `depth`) for assembly. Retrieve with `get_cheatsheets(query=...)`.

### Why not just split Mistral's markdown and embed it into Qdrant directly?

Splitting discards Mistral's page indices and heading levels, and overlap fills Qdrant with near-duplicate vectors. Qdrant then retrieves context-free fragments — the pipeline underuses both tools.

### What Qdrant payload fields should Mistral OCR chunks carry?

`PomaQdrant` writes `file_id`, `chunkset_index`, `chunks`, `text` and `chunk_details` (the chunk records with `chunk_index`, `content` and `depth`) by default. It does not write `page`, and it creates no payload indexes: add a payload index on `file_id` yourself for per-document scoping, and if you need page-cited answers or page-range filters, read `page` from the archive's chunk records with `unpack_poma_archive` and attach it with `client.set_payload` on the same point ids.

### Do images in the Mistral OCR result survive the trip to Qdrant?

Yes — call `/v1/ocr` with `include_image_base64`, and POMA describes each figure so it becomes a searchable point. Images without bytes or annotation become visible, counted markers — never silent loss.

### Should the Qdrant collection use hybrid search for Mistral OCR content?

Yes: OCR'd documents are full of exact tokens (clause numbers, IDs, defined terms) that dense embeddings blur. `PomaQdrant` writes BM25 sparse vectors by default and fuses both at query time.

## Related recipes

Same parser, different store: [Mistral OCR → Pinecone](/pipelines/mistral-ocr-to-pinecone) · [Mistral OCR → pgvector](/pipelines/mistral-ocr-to-pgvector) · [Mistral OCR → Weaviate](/pipelines/mistral-ocr-to-weaviate)

Same store, different parser: [LlamaParse → Qdrant](/pipelines/llamaparse-to-qdrant) · [Textract → Qdrant](/pipelines/textract-to-qdrant) · [Docling → Qdrant](/pipelines/docling-to-qdrant)

Foundations: [The Optimal Chunker for Mistral OCR](/optimal-chunker-mistral-ocr) · [The Optimal Chunks for Qdrant](/optimal-chunks-qdrant) · [All pipeline recipes](/pipelines/) · [RAG architecture guide](/guides/rag-architecture/)