There is no chunk size that is right for every corpus. MS MARCO passages are short and
self-contained; an ingested policy PDF is long and structured. Rather than pick one and
defend it, src/fastrag/chunking.py implements six strategies
and writes a strategy field into every Qdrant payload, so all six can live in one
collection and be compared at query time with a filter.
Pass strategy on /v1/query to pick one; omit it to search across all indexed strategies.
GET /v1/strategies reports which are available and which are actually indexed.
The six
fixed - uniform word windows with overlap. The baseline. Fast, predictable, and it
will happily cut a sentence in half.
sentence - packs whole sentences up to a word budget and never splits mid-sentence.
Overlap carries trailing sentences into the next chunk so a fact that spans a boundary still
appears intact somewhere.
sentence_window - indexes one sentence but returns its neighbours. A single-sentence
vector is tightly focused, which improves retrieval precision, but a lone sentence often
cannot answer anything on its own; the returned window restores the context it needs.
semantic - embeds consecutive sentences and cuts where similarity drops. The cut point
is a percentile of the similarity distribution actually observed in that document rather
than a fixed constant, so it adapts instead of being tuned for one corpus. This is the
expensive one at ingest time: it embeds every sentence.
hierarchical - small children are retrieved, large parents are generated from. Same
idea as sentence_window at a coarser granularity.
metadata_aware - prepends title | language | query as a header to the embedded text.
A bare MS MARCO passage frequently never names the entity it is about, and this restores
that signal. The citation excerpt still shows the untouched passage, so the header never
leaks into what the user reads.
Indexed text versus generated text
Three strategies deliberately separate the two. sentence_window and hierarchical return
more than they index; metadata_aware indexes more than it returns. Both directions are
expressed through a context_text payload field, and context_of() is what the pipeline
calls when assembling the generation context.
The chunk text is what gets embedded and cited. The context text is what the model reads. Keeping these separate is what lets retrieval precision and generation context be tuned independently.
Comparing strategies honestly
The strategy name is part of the cache namespace in
fingerprint.py, so an A/B comparison cannot accidentally
serve a cached answer produced under a different strategy. It is also recorded in the index
manifest alongside the languages and chunk count.
The StrategyCompare panel in the web/ console runs one query against each indexed strategy and
shows outcome, sources, and latency side by side. That is the intended way to choose one:
measure on your corpus, do not reason about it from first principles.
Cost
Indexing every strategy multiplies the vector count by roughly the number of strategies -
more for sentence_window, which produces one chunk per sentence. On Qdrant's 1 GB free
tier this is the binding constraint, which is why scripts/ingest-msmarco.py takes a
--max-chunks cap. In production you index the one strategy you chose, set
FASTRAG_CHUNK_STRATEGIES to it alone, and the multiplier disappears.
FASTRAG_CHUNK_SIZE and FASTRAG_CHUNK_OVERLAP apply to the strategies that take a budget
(fixed, sentence, semantic, metadata_aware).
Multilingual splitting
Sentence splitting uses SENTENCE_TERMINATORS from text.py,
which includes the Devanagari danda । and its double form alongside ASCII terminators.
An English-only [.!?] splitter returns Hindi, Bengali, and Marathi documents as one
enormous sentence, which silently defeats every sentence-based strategy and breaks
sentence-level citation streaming. The same regex is used by the citation validator for
exactly that reason.
Chunking