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

# The Missing Link Between AWS Textract and Optimal Retrieval in Elasticsearch

<ByAuthor />

**The short answer:** Textract's `AnalyzeDocument` with `LAYOUT` gives you multi-column-aware reading order and structured tables; Elasticsearch gives you two decades of full-text-search maturity plus `dense_vector` k-NN. Wired together naively — flatten `Blocks[]`, split, embed — they still produce mediocre RAG, because nothing in between rebuilds the document's hierarchy or maps fields correctly at index time. The missing link is POMA: `PrimeCut().ingest()` consumes the raw Textract response, emits hierarchy-preserving [chunksets](/learn/chunking/chunksets) into a `.poma` archive, and `poma.vektoria` indexes **content-free** documents — `dense_vector` embedding, `file_id`, `chunkset_index` — while the actual text lives on a volume. Retrieval runs your normal `knn` query, then `assemble()` fetches matching content and returns prompt-ready cheatsheets.

## What each end of the pipeline actually provides

**AWS Textract** (`AnalyzeDocument`) returns a flat `Blocks[]` graph. Without `LAYOUT`, only `PAGE`/`LINE`/`WORD` blocks exist and reading order isn't reliably reconstructible; with `LAYOUT`, the `LAYOUT_*` sequence is Textract's own multi-column-aware reading order, `LAYOUT_TITLE`/`LAYOUT_SECTION_HEADER` mark headings, and `TABLES` adds structured `TABLE`/`MERGED_CELL` blocks spliced to `LAYOUT_TABLE` regions by geometry (no Id link exists between them). What it doesn't provide: markdown, cross-page hierarchy, or retrieval units. Details: [The Optimal Chunker for AWS Textract](/optimal-chunker-textract).

**Elasticsearch** provides `dense_vector` fields for HNSW-backed approximate k-NN, native BM25 on any analyzed text field, a single `knn` + `bool`/`match` request body, and `rank` (RRF) to fuse both server-side. Field types — and whether a field is filterable exactly or analyzed as tokens — are decided by the mapping, explicit or dynamic. What it doesn't provide: any opinion about what a document should represent, or protection from a dynamic mapping guessing the wrong type. Details: [Elasticsearch Chunking Strategy for RAG](/optimal-chunks-elasticsearch).

## The naive wiring, and where it breaks

The common recipe — flatten `Blocks[]` into one string per page, `es.index(...)` it with whatever fields happen to be in the dict, filter later — breaks in a pair-specific way. Elasticsearch's dynamic mapping infers an unrecognized field from its first value: a raw Textract dump indexed ad hoc typically maps as analyzed `text`, not `keyword`. When the pipeline later filters `bool.filter` on that field to scope a query to one `file_id`, an analyzed field matches on tokens rather than the exact string — a filter that looks correct can silently pull chunks from the wrong document instead of erroring. Compounding it, the flattened text also carries Textract's melted-together table cells (no `LAYOUT_TABLE`-to-`TABLE` splice happened upstream), so the field that's now filtering wrong is also the field holding unreadable table fragments.

## The pipeline, end to end

```bash
pip install boto3 elasticsearch
```

```python
import json

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

# 1. Your existing Textract call — LAYOUT is required; add TABLES for cell grids.
textract = boto3.client("textract")
with open("contract.png", "rb") as f:
    response = textract.analyze_document(
        Document={"Bytes": f.read()},
        FeatureTypes=["LAYOUT", "TABLES"],
    )
with open("contract.textract.json", "w") as f:
    json.dump(response, f)

# 2. The missing link — raw result JSON in, .poma archive out (the volume's source of truth).
client = PrimeCut()  # reads POMA_API_KEY
client.ingest("contract.textract.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")

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"},
}}, ignore=400)

# 3. Content-free ingest — Elasticsearch 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"],
    })

# 4. Retrieval — Elasticsearch's verified knn shape, then assemble.
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"}, ...]
```

POMA validates the Textract payload up front — a response missing `LAYOUT` blocks 422s immediately rather than shipping scrambled reading order — and rebuilds the cross-page heading tree before chunking. 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).

## Metadata mapping: POMA fields → Elasticsearch primitives

| POMA field (vektoria `Record`) | Elasticsearch primitive | What it enables |
| --- | --- | --- |
| `r.text` (chunkset `to_embed`) | `dense_vector` field | k-NN via the `knn` search clause |
| `r.id` (`chunkset_uuid`) | document `_id` | deterministic identity — re-index overwrites, never duplicates |
| `payload["file_id"]` | `keyword` field | exact-match `bool.filter` scoping to one document; returned by default for `assemble()` |
| `payload["chunkset_index"]` | `integer` field | maps a hit back to volume content; returned by default for `assemble()` |
| `payload["chunks"]` / `text` | written to the **volume**, not the mapping | full chunkset content (incl. spliced Textract tables) reconstructed at retrieval, never indexed |

## Frequently asked questions

### How do I get AWS Textract output into Elasticsearch for RAG?

Run `AnalyzeDocument` with `LAYOUT` (and `TABLES`), save the response, and feed it to `PrimeCut.ingest`, which auto-detects the Textract shape and writes a `.poma` archive. Read the archive with `records_from_archive`, write each record's content to a volume, and index content-free documents — an embedding in a `dense_vector` field plus `file_id` and `chunkset_index` — into Elasticsearch. Query with `knn`, then hand the result to `assemble()`.

### Why does flattening Textract's Blocks[] into one field break Elasticsearch filtering?

Elasticsearch's dynamic mapping infers an unrecognized field as analyzed `text`, not `keyword` — so a flattened `Blocks[]` dump stored ad hoc, then filtered with `bool.filter` on `file_id`, matches on tokens rather than the exact value and can silently return documents from the wrong file. Declaring `file_id` as `keyword` up front, as vektoria's pattern does, avoids that scoping bug entirely.

### What Elasticsearch mapping should Textract chunksets use?

`embedding` as `dense_vector` (dims matching your embedder, `similarity: cosine`), `file_id` as `keyword` for exact-match filtering, and `chunkset_index` as `integer` — one document per chunkset. The content-free pattern means no text field is required for the chunkset's actual content; that lives on the volume vektoria writes to, and `_source` returns `file_id` and `chunkset_index` by default for `assemble()` to use.

### Does hybrid k-NN + BM25 work out of the box for Textract content in Elasticsearch?

The `knn` clause works immediately once the `dense_vector` field is mapped. Adding BM25 requires an analyzed text field in the mapping — vektoria's content-free pattern keeps chunkset text off the index by default, so if you want lexical matching on Textract's exact clause numbers or defined terms, add a text field and index it deliberately rather than assuming it's there.

### What happens to Textract tables and checkboxes when they reach Elasticsearch?

Nothing Elasticsearch-specific — the transformation happens upstream. PrimeCut splices Textract's `TABLE` blocks into HTML by geometry and keeps `SELECTION_ELEMENT` checkboxes as selected/unselected marks before the chunkset is ever written. Elasticsearch only ever sees the embedding and routing metadata; the table HTML and checkbox marks live in the chunkset content on the volume, fetched by `assemble()` after retrieval.

## Related recipes

Same parser, different store: [Textract → Turbopuffer](/pipelines/textract-to-turbopuffer) · [Textract → Vespa](/pipelines/textract-to-vespa) · [Textract → OpenSearch](/pipelines/textract-to-opensearch)

Also available: [Textract → Qdrant](/pipelines/textract-to-qdrant) · [Textract → Milvus](/pipelines/textract-to-milvus) · [Textract → Chroma](/pipelines/textract-to-chroma)

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