semantic
Merge adjacent blocks while an eleven-signal ladder says they're the same idea — and read back exactly which signal fired.
semantic walks the document one block at a time and asks a single question at
each step: does this block continue the one before it? If yes it is merged
into the current chunk; if no, the chunk is closed and a new one starts.
There is no embedding model and no similarity score. The decision is a fixed ladder of lexical signals, evaluated in strict priority order, and the signal that fired is written into the chunk's metadata — so a boundary you disagree with is always explainable.
Real input → real output
from py_chunks import get_chunks
# Adjacent blocks merge when they look like the same idea. Every chunk records
# WHY it starts where it does, in primary_merge_reason (+ merge_reasons).
for c in get_chunks("notes.md", mode="semantic"):
print(c["content_type"], "|", c["metadata"]["primary_merge_reason"])
# heading | initial
# semantic | initial
# heading | initial
# semantic | initial
# table | structural_boundary
# heading | initial
# semantic | initialThe signal ladder
For each candidate block, in this order. The first rule that matches decides.
| # | Signal | Fires when the block starts with… | Result |
|---|---|---|---|
| 0 | hard size limit | — (checked first, on length) | new chunk |
| 1 | transition_break | however, nevertheless, in contrast, on the other hand, meanwhile, conversely, that said, in summary, to summarize, to conclude, in conclusion, to wrap up, … | new chunk |
| 2 | reference_continuity | this , it , they , these , that , those , its , their , such , the above, the following, the latter, … | merge |
| 3 | elaboration | additionally, furthermore, moreover, in addition, what is more, on top of that, notably, importantly, it is worth, it should be noted, equally, similarly, … | merge |
| 4 | example | for example, for instance, such as, e.g., i.e., as an example, to illustrate, consider , as shown, as seen, as demonstrated, take , … | merge |
| 5 | cause_effect | because, therefore, thus, hence, as a result, consequently, this means, this leads, this causes, this results, this implies, this suggests, … | merge |
| 6 | contrast_continuation | although, even though, despite, whereas, even if, regardless, notwithstanding, while it, while this | merge |
| 7 | question_answer | (previous block ended with ?) | merge |
| 8 | definition_expansion | (previous block was ≤ 80 chars and ended with :, and this one is > 60 chars) | merge |
| 9 | short_paragraph | (this block is ≤ 80 chars) | merge |
| 10 | list_continuation | (block is a list sharing a significant keyword with the chunk so far) | merge |
| 11 | keyword_overlap | (block shares at least one significant keyword with the chunk so far) | merge |
| — | no signal matched | — | new chunk |
Prefix matching is ASCII case-insensitive.
The 1,500-character cap overrides every signal
Before any signal is evaluated, the chunker checks
chunk_chars + block_chars + 2 > 1500. If that is true the chunk is closed —
no matter how strong the continuity signal would have been, and a long run of
clearly related prose will still be split. This is the single most common
reason a boundary looks "wrong".
The one exception is an indivisible unit. The cap is applied between blocks, so a chunk is only ever closed at a block boundary. A single block that is itself larger than 1,500 characters — a wide spreadsheet row, a large markdown table, one long code fence — is emitted whole and exceeds the cap, because splitting inside it would corrupt the record. Everything else is bounded at 1,500.
Spreadsheets
On spreadsheets semantic groups rows by a detected category column and emits
semantic_group chunks. Those groups are bounded by the same 1,500-character
cap, split at row boundaries; a group larger than the cap becomes several
chunks that share one group_index. A single row wider than the cap is
indivisible and is emitted whole, as above.
The emitted vocabulary
primary_merge_reason is always one of:
initial · reference_continuity · elaboration · example ·
cause_effect · contrast_continuation · question_answer ·
definition_expansion · short_paragraph · list_continuation ·
keyword_overlap · structural_boundary
Two of those never come from the ladder:
initial— a chunk built from exactly one block. Nothing was merged, so there is no reason to report. Itsmerge_reasonsarray is empty.structural_boundary— the chunk is a code block or a table. Those are always emitted standalone and never participate in merging.
And one name from the ladder is never emitted: transition_break. It only
ever ends a chunk, so it is never a reason a block joined one.
merge_reasons is the deduplicated list of every reason that fired inside the
chunk, in the order they first fired, with initial excluded.
Deterministic tie-break
primary_merge_reason is the reason that fired the most times in that chunk.
When two reasons tie, the winner is chosen by count descending, then reason
name ascending — so elaboration beats keyword_overlap on a tie. This is a
sort, not a hash-map iteration, which is why the three SDKs agree byte-for-byte
on every chunk.
The key names differ by format
This is the part that bites cross-format code. Not every format writes the same merge keys:
| Format | merge_reason | merge_reasons | primary_merge_reason |
|---|---|---|---|
| Markdown-family (md, html, txt, pdf, epub, rtf, eml/mbox, msg, odt, odp, json, ipynb) | — | yes (array) | yes |
| DOCX (+ DOCM/DOTX/DOTM) | yes (string) — the only one | — | — |
| PPTX family | yes | yes | yes |
| PPT | — | — | — (semantic emits no merge metadata at all) |
| Spreadsheets | — | — | — (grouping metadata instead: category_column, group_index, avg_group_size, …) |
DOCX and PPTX also use vocabulary the markdown ladder does not:
- DOCX adds
docx_heading(chunk opened on a heading paragraph),heading_merge(a heading absorbed into the following body) andsize_limit(the chunk was closed by the cap). - PPTX adds
single_unit(the chunk is one whole slide, nothing merged).
Read metadata.get("primary_merge_reason") or metadata.get("merge_reason") if
you want one line that works everywhere.
Units and formats
| Family | Unit offered to the ladder |
|---|---|
| Markdown-family | markdown block (paragraph or list). Headings, code blocks and tables bypass the ladder and are emitted standalone. |
| DOCX, DOC | paragraph |
| PPTX, PPT | slide |
| Spreadsheets | row, grouped by a detected category column — a completely different algorithm |
What it emits
semantic mode does not emit only semantic chunks.
| Format | content_type values emitted |
|---|---|
| Markdown, HTML, TXT, PDF, EPUB, RTF, EML/MBOX, MSG, ODT, ODP, JSON | semantic, plus heading, code_block and table wherever the document has them |
| IPYNB | code_block for code cells; heading / semantic for markdown cells |
| DOCX, DOC, PPTX family, PPT | semantic only |
| Spreadsheets | semantic_group |
| CSV / TSV | not supported |
Measured: code_heavy.md → 13 semantic + 22 heading + 17 code_block +
2 table; all_round.docx → 13 semantic and nothing else. Lists are
merged into semantic chunks — unlike in
sentence mode, where they stay
bullet_list.
Parameters
semantic reads none of the four mode parameters. The 1,500-character cap is a
compile-time constant, not a knob.
Metadata
Beyond the merge keys, markdown-family semantic chunks carry heading_path,
section_heading, section_level, block_types, paragraph_count,
has_list, keyword_density, avg_block_length and chunk_index. Full
listing in the Metadata Reference.
When to use it
- Feeding an embedding model — coherent chunks embed better.
- LLM context where you want complete thoughts, not fragments.
- General-purpose RAG when you're not sure which mode to pick.
Recommended default
Start with semantic for LLM/embedding pipelines and
section for search indexes.