Structure-aware ingestion#
Use structural chunks when headings, code, tables and source provenance should survive ingestion. The engine normalizes explicit document representations, packs retained content, and measures the complete text sent to embeddings.
Install fastrag-ai[normalization] for the default BPE tokenizer and
Markdown/reStructuredText adapters. HTML normalization uses the Python standard
library. An explicitly selected byte tokenizer needs no optional dependency
for plain text or HTML.
Choose a representation#
An ingestion Parser converts source bytes into Document.content and sets
metadata["content_format"]. A Normalizer reads that already parsed string
into an ordered tree and an independent source coverage partition. Normalizers
keep the original string; they do not fetch URLs or execute markup.
Representation |
Structural adapter |
Important behavior |
|---|---|---|
|
|
Retains the whole string; no MIME sniffing. Missing legacy format metadata uses text. |
|
|
CommonMark with tables; headings, fenced/indented code, lists, links and original syntax. |
|
|
Docutils structure with original source locations; includes/raw execution disabled. |
|
|
Readable serialization of headings, lists, tables, code and links; script/style/template/comment exclusions recorded. |
|
Native |
Serialized |
StructureAwareChunker selects a registered adapter by explicit format. Unknown
formats raise an actionable error. ChunkerRouter validates each selected route:
use ContentFormatFilter to send native Docling to DoclingChunker, or request
DoclingParser(output_format="markdown") and use structural Markdown chunks.
This second path describes the converted Markdown, not original spreadsheet
cell coordinates or PDF byte offsets. See Ingestion for binary routing.
Measure the actual embedding input#
This complete offline recipe exercises packing and independent validation. Its tokenizer counts UTF-8 bytes, so its limits are deliberately byte units.
from fastrag.ingestion.steps.chunking import (
StructureAwareChunker,
Utf8ByteTokenizer,
build_embedding_text,
validate_structure_chunks,
)
from fastrag.models import Document
parent = Document(
"orchard-guide",
"local-guide",
"# Orchard\n## Water\nWater pears weekly.\n## Soil\nTest soil each spring.\n",
metadata={"content_format": "markdown"},
)
chunker = StructureAwareChunker(
tokenizer=Utf8ByteTokenizer(),
target_tokens=100,
max_tokens=160,
min_preferred_tokens=20,
cache=False,
)
normalized = chunker.normalizers.normalize(parent)
pieces = list(chunker.split_normalized(normalized))
report = validate_structure_chunks(normalized, pieces, chunker.tokenizer, 160)
assert report.chunk_count == len(pieces) > 0
for content, metadata in pieces:
embedding_text = build_embedding_text(content, metadata)
assert chunker.tokenizer.count(embedding_text) <= 160
assert metadata["embedding_token_count"] == chunker.tokenizer.count(embedding_text)
In a lazy graph, replace a character splitter with
StructureAwareChunker(cache=cache) and retain embedder(chunker(parser(sources))).
The existing Embedder base uses build_embedding_text for marked structural
chunks while preserving display content. Legacy chunks retain their previous
embedding input. Constructing the graph still performs no work.
The default TiktokenTokenizer counts cl100k_base with encode_ordinary,
including literal special-token strings as ordinary content. Tiktoken may need
its encoding cache on first use; prepare that cache for offline deployments.
This is a configured-tokenizer guarantee, not a promise about an arbitrary
embedding model’s tokenizer. Choose the provider’s matching encoding or inject
its tokenizer. Tiktoken’s encoding API
describes the underlying counting and Unicode-offset behavior.
A custom Tokenizer supplies a stable nonsecret identity, a nonnegative
integer count(text), and ordered Unicode character boundaries(text) including
zero and the final offset. Counts need not be additive or monotonic. The engine
checks complete serialized candidates, including repeated headings, fences,
table headers and overlap. Built-in tokenizer hints reduce large-block work;
arbitrary custom tokenizers retain exhaustive feasibility checks, which can be
expensive for large documents.
Packing, atomic structures and overlap#
Fitting direct sections remain intact. Small adjacent sibling sections can merge toward the target; crossing top-level sections requires the explicit merge option. Oversized sections pack adjacent blocks, then split oversized prose, code, table rows or list items with context. Code prefers logical lines and function boundaries; tables repeat schema, and nested lists repeat parent labels when those fit. Preservation flags relax these preferences while keeping retained source coverage. They do not make oversized objects fit automatically.
The target is a packing preference, and the maximum measures final embedding
input. The default split_with_context policy enforces the maximum or raises
when mandatory context cannot fit. preserve_and_flag deliberately permits
oversized atomic output and records overflow; consumers must handle that before
sending it to a provider. Splitting an indivisible table row or code line can
record atomic structure loss. Read the flags when deciding whether the result
is suitable for your application.
Overlap repeats only eligible trailing prose at sentence boundaries within the configured ceiling. It does not cross sections or add overlap to code/tables. Repeated overlap has separate provenance and does not count as newly retained source. Heading-only documents may yield no body chunks; empty and hierarchy-only results are explicitly classified by validation.
Keep provenance and parents#
Offsets are half-open Unicode character ranges into the parsed parent’s original
string. Node spans may overlap or be noncontiguous; CoverageUnit ownership plus
explicit Exclusion ranges partitions the source once. HTML output is a readable
serialization, so display offsets and source offsets are different coordinates.
Chunk metadata includes document/section lineage, heading paths, primary source
spans, retained intervals, serialization maps, repeated context, token counts
and overflow/atomic flags. Consecutive compatible mapping runs are coalesced
losslessly. structure_metadata is a lightweight node-ID/attribute projection;
complete interpreted node metadata stays on the normalized parent. Do not treat
it as a full parent document. validate_structure_chunks independently checks
coverage, maps, lineage, counts and ordering on fresh and persisted output.
Store normalized parents separately if retrieval needs larger context. This standalone recipe resolves a structural hit without depending on a database.
from fastrag.ingestion.steps.chunking import StructureAwareChunker, Utf8ByteTokenizer
from fastrag.models import Document
from fastrag.retrieval import SectionIndex
parent = Document(
"orchard-guide",
"local-guide",
"# Orchard\n## Water\nWater pears weekly.\n",
metadata={"content_format": "markdown"},
)
chunker = StructureAwareChunker(tokenizer=Utf8ByteTokenizer(), cache=False)
normalized = chunker.normalizers.normalize(parent)
content, metadata = next(chunker.split_normalized(normalized))
hit = Document("retrieved-hit", parent.source_id, content, metadata=metadata)
index = SectionIndex([normalized])
sections = index.resolve(hit, ancestors=True)
assert sections and all(section.document_id == parent.id for section in sections)
assert any("Water pears weekly." in section.content for section in sections)
SectionIndex.resolve returns every merged source section in source order and
can deduplicate ancestors. A missing parent/section raises KeyError; choose
missing="skip" for an available subset. Parent expansion changes neither
stored vectors nor prompts automatically: select and budget that context in your
retrieval/query application. The in-memory index has no persistence or eviction
policy; provide those in the application when parents outlive the process.
Extend normalization#
Register an adapter on the registry passed to your chunker. A stable identity
must distinguish output-changing options. Duplicate registrations require
explicit replace=True. Validate source slices, lineage and a complete
nonoverlapping retained/excluded coverage partition before returning.
This minimal adapter uses only public immutable models. A richer adapter adds section/heading/list/table nodes under the same contracts.
from fastrag.ingestion.steps.chunking import StructureAwareChunker, Utf8ByteTokenizer
from fastrag.models import Document
from fastrag.normalization import (
CoverageUnit,
NormalizedDocument,
NormalizedNode,
NormalizerRegistry,
SourceSpan,
)
class RecordNormalizer:
identity = "records:whole-source:v1"
def normalize(self, document):
span = SourceSpan(0, len(document.content))
root_id = f"{document.id}:root"
root = NormalizedNode(root_id, "document", document.content, (span,))
units = (
(CoverageUnit(f"{root_id}:unit", root_id, span),)
if document.content
else ()
)
result = NormalizedDocument(document, "records", (root,), units)
result.validate()
return result
registry = NormalizerRegistry()
registry.register("records", RecordNormalizer())
chunker = StructureAwareChunker(
normalizers=registry, tokenizer=Utf8ByteTokenizer(), cache=False
)
parent = Document(
"record-1", "local", "Pear: water weekly.", metadata={"content_format": "records"}
)
pieces = list(chunker.split(parent))
assert len(pieces) == 1 and pieces[0][0] == parent.content
assert "records" in chunker.accepted_content_formats
Customizing covers byte-parser and pipeline-step extension hooks. A normalizer extension adds interpretation of a representation; it does not require replacing the format-independent packing engine.
Optional semantic boundaries#
Heuristic structural splitting is deterministic and invokes no chat or embedding
model for boundaries. semantic_mode="disabled" disables sentence preferences.
For explicit chat assistance, inject ChatBoundaryRefiner(llm=chat, identity=..., allow_external_transmission=True) into StructureAwareChunker(semantic_mode="llm", semantic_refiner=...) and evaluate asynchronously. The refiner sends eligible
plain leaf paragraphs in Markdown/RST, never code/tables/lists/HTML or rewritten
content, and accepts only allowed source offsets in strictly validated JSON.
Timeout, retry/backoff, concurrency, input/response limits and a bounded
instance-local cache govern calls. Ordinary provider/validation failures use the
same deterministic heuristic fallback; cancellation propagates. Set the chat
provider’s output-token limit too. Diagnostic calls/cache/fallback observations
are runtime reports, not identity-changing chunk metadata.
EmbeddingSemanticChunker(embeddings=embedder) is a separate asynchronous
algorithm: buffered sentence vectors select cosine-distance breakpoints via
percentile, standard-deviation, interquartile or gradient thresholds, or a
requested chunk count. Its sentence regex, buffer, minimum character size,
start-index and batching options map to that algorithm. It preserves original
text but supplies neither structural hard-token limits nor protected code/table
boundaries. Configure the injected SDK Embedder for source transmission and
cost. Neither this class nor structural refinement renames the existing
SemanticChunker(llm=...), whose legacy chat-grouping targets use characters.
Migration and limitations#
Existing Chunker.split subclasses, ChunkerRouter routes, explicit caches and
lazy Pipeline events remain usable. Structural chunks have deterministic IDs
including content, lineage, configuration and embedding context; changing engine,
normalizer, tokenizer or refiners invalidates their cache identities. Chunk
indices are contiguous in source order per parent on fresh and cached built-in
output; concurrent parents retain completion ordering. Diagnostics do not alter
IDs. Keep complete metadata on transport; the default Milvus store rejects
metadata exceeding its supported size rather than silently discarding maps.
Normalization retains original syntax/provenance, not a rendered browser DOM or lossless reconstruction of original binary documents. Malformed markup can have conservative locations and diagnostics. RST includes/raw execution, HTML scripts and style content are intentionally excluded. Custom adapters and pathological large custom-tokenizer inputs can need application-specific limits. Optional semantic suggestions do not establish retrieval quality: compare configurations on your own labelled corpus with Benchmarking.
See also#
Ingestion for source parsing and Docling routing; Retrieval for retriever composition; API reference for exported APIs and options.