Output Schema
The exact object every chunk is — with complete, real chunks for every content type, every mode, and every return shape.
Every chunk is an object with exactly three keys — content,
content_type, metadata — in all three SDKs. Everything else on this page is
about what actually turns up inside them.
| Key | Type | Always present |
|---|---|---|
content | string | yes |
content_type | string (contentType in JS) | yes |
metadata | object / dict / serde_json::Value | yes, but every key inside it is optional |
A complete chunk
Not an excerpt — this is the whole object, captured from a real run at default parameters.
chunk = get_chunks("notes.md", mode="semantic")[3]
# Every chunk is a dict with exactly three keys:
{
"content": "A naive character splitter cuts mid-sentence and mid-table. The retrieved\npassage then answers half a question, and the model fills in the rest. Keeping\na table whole costs nothing at index time and saves a wrong answer at query\ntime.",
"content_type": "semantic",
"metadata": {
"avg_block_length": 234,
"block_types": ["paragraph"],
"chunk_index": 3,
"document_metadata": {"source_type": "md", "total_input_blocks": 7},
"has_list": False,
"heading_path": ["Chunking Notes", "Why structure matters"],
"keyword_density": 0.6,
"merge_reasons": [],
"paragraph_count": 1,
"primary_merge_reason": "initial",
"section_heading": "Why structure matters",
"section_level": 2,
},
}Reading the fields
for chunk in chunks:
chunk["content"] # str
chunk["content_type"] # str, e.g. "heading", "semantic"
chunk["metadata"] # dictWhat a whole call returns
from py_chunks import get_chunks, ChunksResult
# Without list_images the return is a plain list[dict].
chunks = get_chunks("report.docx")
# With it, a ChunksResult dataclass — two fields, never a bare list.
result = get_chunks("report.docx", list_images=True)
result.chunks # list[dict] — text chunks AND image chunks, in document order
result.images # dict[str, bytes] — {"75a3c27ad7854d78.png": b"\x89PNG\r\n..."}
# An image chunk: content is the image NAME, not the bytes.
# {"content": "75a3c27ad7854d78.png",
# "content_type": "image",
# "metadata": {"alt_text": "", "image_name": "75a3c27ad7854d78.png"}}
# Formats with no embedded-image support still return a ChunksResult —
# with an empty images dict, not an error.| SDK | Without images | With images |
|---|---|---|
| Python | list[dict] | ChunksResult — .chunks: list[dict], .images: dict[str, bytes] |
| JavaScript | Promise<Chunk[]> | Promise<{ chunks, images }> — images is an array of { name, data }, not a dict |
| Rust | Vec<Chunk> | (Vec<Chunk>, Vec<(String, Vec<u8>)>) |
The images container differs by language
Python keys images by name (result.images["75a3c27ad7854d78.png"]);
JavaScript and Rust hand you an ordered sequence of pairs. A port that
assumes a dict on the JS side gets undefined, not an error.
Order and indexing
Chunks come back in document order — the order a person reads the file in. That is the only ordering guarantee, and it is the one worth relying on.
Position metadata is a different story, and the traps are worth stating plainly:
chunk_indexis absent indefault/structuralfor almost every format. It appears in the derived modes (section,semantic,sliding_window,sentence,page_aware) and in every spreadsheet and CSV mode. The exceptions run both ways:.docand.pptemit it in all modes, while DOCX omits it insection,semanticandsliding_window. If you need a stable position, use the index of the array you were handed.total_chunksis emitted by.docand.pptin every mode, and by the whole DOCX family (.docx,.docm,.dotx,.dotm) insectionandpage_aware— measured across the fixture corpus 2026-08-16. In those two DOCX modes it means "part i of n of this section / this page", not a document-wide position, and it appears only on units that had to be split: alists.docxrun at default parameters returns seven page chunks, of which exactly two carry the keys. No other format emits it. Treat it as a split marker, never as a document length — for a stable document-wide position, use the index of the array you were handed.- Image chunks are not always where you expect. DOCX places each image chunk
at its anchoring paragraph's position (indices 4 and 5 of 13 in
all_round.docx). Every other image-bearing format puts all image chunks first — PDF, PPTX, XLSX, HTML, EPUB, IPYNB, EML/MBOX/MSG, ODT/ODP, DOC and PPT. So slicing "the first N chunks" as a preview can hand you nothing but filenames.
Chunk size
In the structural pipeline — default / structural / section, which is
what most formats run through — chunks target a maximum of 1,200 characters,
widening to 1,550 when a short trailing paragraph is merged in rather than
emitted as an orphan fragment. The bound is deliberate: a single
10,000-character chunk breaks the size contract every embedding model depends
on, so oversized structures are split, not kept whole — a table larger than
the budget becomes a run of table chunks, an oversized list or code block a
run of bounded bullet_list / code_block chunks.
The 1,200/1,550 figures are per-mode, not global
Three modes bound their output differently, and reading 1,200 as a library-wide guarantee will surprise you:
| mode | what bounds a chunk |
|---|---|
default · structural · section | 1,200 chars, widening to 1,550 |
semantic | the 1,500-character cap (see semantic) |
page_aware | paragraph count, not characters — paragraphs_per_page (default 15). A page of 15 long paragraphs is legitimately several thousand characters. |
sliding_window | window_size blocks, not characters |
row (spreadsheets / CSV) | rows_per_chunk; max_chunk_chars caps table / sheet / page_aware spreadsheet chunks only |
Measured across the fixture corpus in section mode: 4 chunks out of hundreds
of files exceed 2,000 characters, and every one is a single atomic unit — the
exception described below, not a leak in the bound.
What the engine never does is cut through an atomic unit: a table split always lands between rows, a list split between items, a code split between lines — never mid-row or mid-item. The one case where a chunk exceeds the bound is a single atomic unit that is itself wider than the budget — one very wide table, CSV/TSV, or spreadsheet row lands whole in one oversized chunk, because slicing it would destroy the thing it represents.
Prose is always bounded: an over-long paragraph is cut at a sentence, and a sentence with no boundary at all is cut at a word.
content_type: two families, not one
The single most expensive assumption you can make about this API is that selecting a mode makes every chunk carry that mode's name. It does not, and which way it goes depends on the format.
Two real censuses, same mode, default parameters:
get_chunks("arxiv_1706.03762_attention.pdf", mode="section")
44 chunks → heading: 16 section: 28 ← 36% are NOT "section"
get_chunks("all_round.docx", mode="section")
9 chunks → section: 9 ← everything absorbedFiltering on the mode name silently drops content
[c for c in chunks if c["content_type"] == "section"] throws away sixteen
heading chunks from that PDF — the section titles, the highest-signal text in
the document. Filter on what you want to exclude, or don't filter at all.
Family A — structure survives the mode. PDF, Markdown, HTML, TXT, RTF, EPUB, IPYNB, EML/MBOX/MSG, ODT/ODP and JSON keep structural chunks alongside the mode's own chunks. Measured on the fixtures named above:
| Mode | What PDF emits | What Markdown emits |
|---|---|---|
default / structural | heading, plain_paragraph, long_single_paragraph, short_disconnected_paragraph, bullet_list, table | heading, plain_paragraph, long_single_paragraph, short_disconnected_paragraph, code_block, bullet_list, table |
section | heading + section | heading + section |
semantic | heading + table + semantic | heading + code_block + table + semantic |
sentence | heading + bullet_list + table + sentence | heading + code_block + table + bullet_list + sentence |
page_aware | heading + page_aware | heading + page_aware |
sliding_window | sliding_window only | sliding_window only |
The rule inside this family: section and page_aware re-group prose and keep
headings separate; semantic and sentence additionally pass atomic units
(tables, code blocks, lists) through untouched, because re-grouping them would
destroy them. HTML and EPUB are the soft edge — their semantic chunker can
merge a heading into the chunk beneath it, so some headings do disappear there.
Family B — the mode replaces everything. DOCX, DOC, PPTX, PPT and the
tabular formats emit one content type per mode, with no structural remainder.
DOCX default emits mixed_content, heading and table; every derived mode
emits only its own name.
sliding_window is Family B in every format, without exception — it is a
flat window over the block stream and has no structure to preserve.
The catalogue
| Value | Where it comes from |
|---|---|
heading | A heading or section title, alone in its own chunk. |
plain_paragraph | A standard paragraph. |
long_single_paragraph | One paragraph long enough to stand alone as a chunk. |
short_disconnected_paragraph | A short paragraph with no neighbour to merge with. |
bullet_list | A bulleted or numbered list — also the per-record type for JSON/JSONL/NDJSON. |
table | A table, or one row-aligned segment of an oversized one. |
code_block | A fenced or indented code block; an IPYNB code cell and its output. |
image | See Image chunks — two different things share this name. |
mixed_content, footnote_caption, header_footer | DOCX only. |
section, semantic, sliding_window, sentence, page_aware | The mode's own chunks. |
row_document, table_region, sheet, row_window, sheet_region, semantic_group | Spreadsheets (XLSX/XLS/XLSM/XLSB/ODS/XLTX/XLTM). |
row_group, row_window | CSV / TSV. |
One real chunk per type
Every block below is verbatim engine output at default parameters.
// heading — md/test.md, default
{ "content": "Complex Markdown Test Document",
"content_type": "heading",
"metadata": { "document_metadata": { "source_type": "md" },
"section_heading": null, "section_level": 1 } }
// table — md/test.md, default (content abridged to two rows for width)
{ "content": "| Region | Nodes | Avg Latency |\n|---|---:|---:|\n| us-east-1 | 12 | 140 ms |",
"content_type": "table",
"metadata": { "document_metadata": { "source_type": "md" },
"section_heading": "2.2 Secondary Table — Region Breakdown",
"section_level": 3 } }
// code_block — md/test.md, default
{ "content": "```\n[Source File]\n │\n ▼\n[/extract] ──► ExtractedData (JSON)\n```",
"content_type": "code_block",
"metadata": { "document_metadata": { "source_type": "md" },
"section_heading": "3.2 Data-flow diagram (text)",
"section_level": 3 } }
// bullet_list — md/test.md, default
{ "content": "Docs: Pylint C0415 guidance\nAPI home: Example API\nIssue tracker: GitHub Issues\nChangelog: CHANGELOG.md",
"content_type": "bullet_list",
"metadata": { "document_metadata": { "source_type": "md" },
"section_heading": "6.1 Links and images", "section_level": 3 } }
// long_single_paragraph — pdf/arxiv_1706.03762_attention.pdf, default
{ "content": "The dominant sequence transduction models are based on complex recurrent or convolutional neural networks…",
"content_type": "long_single_paragraph",
"metadata": { "document_metadata": { "source_type": "pdf", "total_pages": 15 },
"section_heading": "Abstract", "section_level": 3 } }
// row_document — excel/file_example_XLSX_50.xlsx, mode="row"
{ "content": "0: 1 | First Name: Dulce | Last Name: Abril | Gender: Female | Country: United States | Age: 32 | Date: 15/10/2017 | Id: 1562",
"content_type": "row_document",
"metadata": { "sheet_name": "Sheet1", "sheet_index": 0, "row_index": 1,
"header_row": ["0","First Name","Last Name","Gender","Country","Age","Date","Id"],
"col_count": 8, "rows_per_chunk": 1, "actual_row_count": 1,
"chunk_index": 0, "skipped_sheets": [] } }
// row_group — csv/plotly_iris.csv, mode="row" (3 rows per chunk, the default)
{ "content": "sepal length: 5.1 | sepal width: 3.5 | petal length: 1.4 | petal width: 0.2 | class: Iris-setosa\nsepal length: 4.9 | …",
"content_type": "row_group",
"metadata": { "row_start": 1, "row_end": 3, "row_count": 3, "col_count": 5,
"header_row": ["sepal length","sepal width","petal length","petal width","class"],
"has_header": true, "delimiter_detected": ",", "encoding": "utf-8",
"chunk_index": 0 } }
// semantic — md/test.md, mode="semantic"
{ "content": "Project: temp-doc\nOwner: Platform Team\nDate: 2026-04-21\n\nJump to Metrics | Jump to Architecture | …",
"content_type": "semantic",
"metadata": { "avg_block_length": 89, "block_types": ["paragraph"], "chunk_index": 1,
"document_metadata": { "source_type": "md", "total_input_blocks": 90 },
"has_list": false, "heading_path": ["Complex Markdown Test Document"],
"keyword_density": 0.607, "merge_reasons": ["short_paragraph"],
"paragraph_count": 2, "primary_merge_reason": "short_paragraph",
"section_heading": "Complex Markdown Test Document", "section_level": 1 } }One real chunk per mode
The metadata is where a mode actually shows itself. Same file, different mode:
// mode="section" — pdf/arxiv_1706.03762_attention.pdf
{ "content_type": "section",
"metadata": { "block_types": ["paragraph"], "char_count": 455, "chunk_index": 2,
"document_metadata": { "source_type": "pdf", "total_pages": 15 },
"heading_path": ["scholarly works."], "paragraph_count": 4,
"section_heading": "scholarly works.", "section_level": 2,
"split_part": null, "split_total": null } }
// mode="sliding_window" — md/test.md (window_size 3, overlap 1)
{ "content_type": "sliding_window",
"metadata": { "block_count": 3, "chunk_index": 0, "window_index": 0,
"window_size": 3, "overlap": 1, "paragraph_range": [0, 2],
"document_metadata": { "source_type": "md", "total_input_blocks": 90 },
"heading_path": ["Complex Markdown Test Document"],
"section_heading": "Complex Markdown Test Document" } }
// mode="sentence" — md/test.md (sentences_per_chunk 3)
{ "content_type": "sentence",
"metadata": { "sentences_per_chunk": 3, "actual_sentence_count": 2,
"source_paragraph_index": 1, "chunk_index": 1,
"heading_path": ["Complex Markdown Test Document"],
"section_heading": "Complex Markdown Test Document", "section_level": 1,
"document_metadata": { "source_type": "md", "total_input_blocks": 90 } } }
// mode="page_aware" — docx/lists.docx (a page that had to be split in two)
{ "content_type": "page_aware",
"metadata": { "page_number": 5, "page_break_type": "estimated",
"paragraph_count": 15, "list_item_count": 3, "table_count": 0,
"headings": [{ "level": 1, "text": "5. Definition-Style List" },
{ "level": 1, "text": "6. Mixed Content with Lists" }],
"section_heading_level": 1,
"chunk_index": 1, "total_chunks": 2,
"document_metadata": { "source_type": "docx" } } }semantic is above; structural is what default resolves to for prose
formats, so its chunks look like the heading / plain_paragraph examples.
Full per-mode behaviour lives in Chunking Modes.
Reading metadata
metadata is a flat map of scalars, lists and one nested object. Two rules
cover almost every mistake:
- Read every key optionally. No key is guaranteed by the contract — not
page_number, notchunk_index, notsection_heading. Which keys exist is a function of format × mode, and it is enumerated in the Metadata Reference. - A key that exists can still be
null.section_headingisnullfor a chunk before the first heading;.docpage_numberisnullwhen the file declares no hard page breaks.nullmeans "known to be absent", missing means "this format/mode does not produce this at all".
document_metadata
One nested object carries file-level provenance — the same for every chunk in the call. Its shape depends entirely on the format, so treat it as a union, not a struct:
{ "source_type": "pdf", "total_pages": 15 }
{ "source_type": "md", "total_input_blocks": 90 }
{ "source_type": "docx", "header_text": null, "footer_text": null, "image_count": 2 }
{ "source_type": "pptx", "total_slides": 48 }
{ "source_type": "ipynb", "nbformat": "4.0", "kernel": null, "language": null,
"cell_count": 1, "code_cell_count": 1, "markdown_cell_count": 0 }
{ "source_type": "epub", "title": "Minimal EPUB 3.0", "epub_version": "3.0",
"creator": null, "creators": [], "contributors": [], "publishers": [], "subjects": [],
"identifier": "NOID", "identifiers": ["NOID"], "language": "en", "languages": ["en"],
"spine_count": 1, "toc": [ … ] }
{ "source_type": "eml", "subject": "This is a test message", "from": "John X. Doe <bbb@ddd.com>",
"to": ["bbb@zzz.org"], "cc": [], "bcc": [], "date": "2001-05-04T14:05:44-04:00",
"message_id": "15090.61304.110929.45684@aaa.zzz.org", "in_reply_to": [], "references": [],
"has_attachments": false, "attachment_count": 0 }Two absences are worth knowing: .doc emits no document_metadata at all,
and spreadsheets and CSV/TSV emit none either — their provenance lives in
per-chunk keys such as sheet_name and encoding. Note also that DOCX's
default mode is the one format whose document_metadata has no
source_type.
Image chunks
content_type: "image" covers two different objects. Confusing them is the
most common image bug.
1. The structural placeholder — plain default / structural, no
list_images. The chunk's content is the paragraph text with an inline
[Image: …] marker, and there is no image_name. It tells you an image sits
here; it does not give you one.
// docx/corpus_floating_image.docx, mode="default", list_images off
{ "content": "A floating image is anchored to this paragraph.\n[Image: Float 1]",
"content_type": "image",
"metadata": { "page_number": 1, "section_heading": null, "section_heading_level": null,
"footnotes": [], "endnotes": [],
"document_metadata": { "header_text": null, "footer_text": null, "image_count": 2 } } }2. The extraction chunk — with list_images on. Its content is the image
filename, not bytes and not text. The bytes live in the result's images
container under that same name.
// docx/all_round.docx, list_images=True → chunks[4]
{ "content": "69400fb8a9cb4130.gif",
"content_type": "image",
"metadata": { "image_name": "69400fb8a9cb4130.gif", "alt_text": "" } }
// pdf/arxiv_1706.03762_attention.pdf, list_images=True → chunks[0]
{ "content": "image_p3_1.png",
"content_type": "image",
"metadata": { "image_name": "image_p3_1.png" } }Turning list_images on replaces the placeholders with extraction chunks;
the two never coexist.
What an image chunk carries
Deliberately minimal, and not uniform:
| Format | Image-chunk metadata |
|---|---|
| DOCX | image_name, alt_text — nothing else |
image_name — nothing else | |
| XLSX family | image_name, alt_text, sheet_name, sheet_index |
| PPTX family | image_name, alt_text, slide_number, document_metadata |
| HTML | image_name, alt_text, document_metadata |
| EPUB | image_name, href |
| DOC / PPT | image_name plus that format's full structural block (source, chunk_index, total_chunks, paragraph_type: "image", page_number) |
| IPYNB, EML/MBOX/MSG, ODT/ODP | image_name only |
Names
Most formats name an image by a content hash — "<16 hex>.<ext>", FNV-1a
64-bit over the bytes, pinned in the engine so Python, JavaScript and Rust all
produce the same string. Identical bytes collapse to one entry: the HTML fixture
sample_with_image.html emits two image chunks that share
2668ca08e54e28cc.png with different alt_text, and one entry in images.
Two families do not hash:
- PDF names positionally:
image_p{page}_{n}.{ext}—image_p3_1.png,image_p4_1.png,image_p4_2.png. Always re-encoded to.png. - EPUB, IPYNB and the email formats keep the source name:
7433694763631080598_cover.jpg(the EPUB manifest basename),output_image_1.png(a notebook cell output),msg1_image_1.gif(message 1 of an mbox),testPNG.png(an.emlattachment).
Only web-renderable rasters are extracted (.png .jpg .jpeg .gif
.webp); .emf / .wmf / .tiff payloads are skipped silently. A format
with no image support still returns a result object — with an empty images
container, never an error. See
Supported Formats.
get_markdown(..., list_images=True) mirrors all of this, returning
markdown (with  references) and the same images container.
Next
- Metadata Reference — the exact keys per format and mode.
- Chunking Modes — what each mode does and when to pick it.
- Supported Formats — all 36 extensions and their image support.
- Error Handling — what happens when a file will not parse.