Source: http://www.poma-ai.com/docs/optimal-chunks-elasticsearch

# Elasticsearch Chunking Strategy for RAG: The Optimal Chunks for Retrieval

<ByAuthor />

**The short answer:** the optimal chunks for Elasticsearch are **chunksets** — self-explanatory units that carry their full heading lineage — indexed as documents with a `dense_vector` field for the embedding and `file_id`/`page`/`depth` as filterable keyword/integer fields, with the analyzed text field enabling native BM25 alongside k-NN. Elasticsearch's ranking is only as good as what you indexed; POMA's PrimeCut emits exactly the fields this mapping needs, and its `vektoria` package keeps the index content-free so retrieval assembles clean context from a volume instead of a bloated `_source`.

## Elasticsearch gives you mature hybrid search — over whatever you index

Elasticsearch's vector support sits on top of two decades of full-text search maturity:

- **`dense_vector` fields** — HNSW-backed approximate k-NN, `similarity: cosine` (or `dot_product`, `l2_norm`) declared per field in the mapping.
- **Native BM25** — every analyzed text field participates in relevance scoring without a second system.
- **One request, both signals** — a `knn` clause and a `bool`/`match` query combine in the same search body; `rank` (reciprocal rank fusion) merges them server-side.
- **Mature filtering** — `bool.filter` on `keyword`/`numeric` fields runs at index speed, not a post-filter scan.

None of this decides what a document should represent. Elasticsearch ranks whatever vector and text you indexed. A mapping whose `text` field holds a context-free character-count fragment will rank context-free fragments — correctly, and fast.

## What "optimal chunks" means for Elasticsearch, concretely

1. **One document per chunkset, self-explanatory alone.** A chunkset is a root-to-leaf path — `Master Services Agreement → Termination clauses → Early termination → "Either party may…"`. See [POMA chunksets](/learn/chunking/chunksets).
2. **Hierarchy as filterable fields.** `file_id` (keyword), `page` (integer), `depth` (integer) turn `bool.filter` into document-aware scoping — one contract, one page range, one hierarchy level.
3. **BM25-eligible text, always.** Keep the chunkset's `to_embed` text in an analyzed field so hybrid search has both a vector and a lexical signal to fuse.
4. **No overlap.** Overlap duplicates spans into both the HNSW graph and the inverted index. Chunksets need no overlap.
5. **Content-free index.** Store `{file_id, chunkset_index}` in the index and the actual content on a volume — smaller index, faster merges, and citations reconstructed at retrieval time.

## The pipeline: PrimeCut to Elasticsearch

```bash
pip install elasticsearch
```

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

# 1. Chunk any document and keep the .poma archive — the volume's source of truth.
client = PrimeCut()  # reads POMA_API_KEY
client.ingest("contract.pdf", 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")

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)

# 2. Content-free ingest — vector 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,
    })

# 3. Hybrid k-NN + BM25 retrieval, then assemble prompt-ready context.
qv = embedder.embed(["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"}, ...]
```

Retrieved chunksets share ancestor lineage across a document — `assemble()` deduplicates that lineage into one prompt-ready cheatsheet, the same discipline that answers our reference legal-document benchmark with **337 tokens** instead of **1,542** for a recursive-splitter baseline. [Methodology here](/document-ingestion-chunking-rag).

## Chunk shapes in Elasticsearch, compared

| Property | Fixed 512-token chunks + overlap | Semantic chunks | **POMA chunksets** |
| --- | --- | --- | --- |
| Document is self-explanatory alone | ✗ | Sometimes | **✓ (by construction)** |
| Hierarchy as filterable fields | Manual | Manual | **✓ (`file_id`, `page`, `depth`)** |
| Redundant HNSW/BM25 entries | Many (overlap) | Few | **None** |
| Content-free index + volume | DIY | DIY | **✓ (`vektoria` `assemble`/`Volume`)** |
| Metadata returned on hits | Default | Default | **Default — no extra flag needed** |

## Frequently asked questions

### What is the optimal chunk size for Elasticsearch?

There's no universal token count — chunksets are self-explanatory at any size, which is why they beat fixed 512-token windows. Start at 512 tokens if you must fix a size, but the size knob cannot fix missing context.

### How do I map chunksets into an Elasticsearch index?

`embedding` as `dense_vector` (dims matching your embedder, `similarity: cosine`), `file_id`/`page` as keyword/integer, `text` as an analyzed field for BM25 — one document per chunkset.

### Does Elasticsearch support hybrid vector and keyword search?

Yes — a single request combines `knn` with `bool`/`match`, and `rank` (RRF) fuses the two ranked lists server-side.

### How does POMA retrieve context from Elasticsearch results?

`vektoria` keeps the index content-free (vector + `file_id`/`chunkset_index` only); content lives on a volume. `assemble(results, volume=VOL)` auto-detects Elasticsearch's result shape and returns deduplicated cheatsheets — no extra metadata flag needed.

### How does chunk overlap affect an Elasticsearch index?

It duplicates spans into both the HNSW graph and the BM25 inverted index — more memory, top-k crowded with near-duplicates. Chunksets need no overlap.

## Feed Elasticsearch from the parser you already run

- [Mistral OCR → Elasticsearch](/pipelines/mistral-ocr-to-elasticsearch)
- [LlamaParse → Elasticsearch](/pipelines/llamaparse-to-elasticsearch)
- [Azure Document Intelligence → Elasticsearch](/pipelines/azure-document-intelligence-to-elasticsearch)
- [Unstructured.io → Elasticsearch](/pipelines/unstructured-to-elasticsearch)
- [Docling → Elasticsearch](/pipelines/docling-to-elasticsearch)
- [Marker → Elasticsearch](/pipelines/marker-to-elasticsearch)
- [AWS Textract → Elasticsearch](/pipelines/textract-to-elasticsearch)
- [PaddleOCR-VL → Elasticsearch](/pipelines/paddleocr-vl-to-elasticsearch)

Running a different store? Browse [all 13 vector databases](/pipelines/) or see [OpenSearch](/optimal-chunks-opensearch) if you run the fork instead.

Fundamentals: [RAG chunking guide](/guides/rag-chunking/) · [RAG architecture guide](/guides/rag-architecture/) · [POMA chunksets](/learn/chunking/chunksets).