Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path →
Semantic Chunker (Token Budget)
Before you can embed a document for retrieval, you have to split it into chunks that fit a token budget — without slicing through a sentence. The greedy packer fills each chunk with whole sentences until the next one would overflow, then starts fresh. It’s the unglamorous step that makes or breaks a RAG pipeline. Solve it in Python or TypeScript, with hidden tests.
The problem
You are given sizes, the token count of each sentence in order, and a max_tokens budget per chunk. Greedily pack consecutive sentences into a chunk; when adding the next sentence would exceed the budget, start a new chunk. A single sentence larger than the budget becomes its own chunk. Implement chunk(sizes, max_tokens): return a list of chunks, each a list of the sentence indices it contains.
Your turn — write it
Edit the stub, hit Run (or ⌘/Ctrl + Enter), and watch the hidden tests. Stuck? open the hints under the problem, or Reveal solution is one click away.
Implement chunk(sizes, max_tokens): walk the sentences keeping a running total; before adding a sentence, if the current chunk is non-empty and the total would overflow, flush it and start a new one. Flush the last chunk at the end.
Examples and constraints 4 examples
sizes = [3, 4, 2, 5, 1], max_tokens = 7[[0, 1], [2, 3], [4]]sizes = [5, 5, 5], max_tokens = 4[[0], [1], [2]]sizes = [9, 1, 1], max_tokens = 5[[0], [1, 2]]sizes = [], max_tokens = 5[]- Sentences stay in order and are never split — chunks contain whole sentences only.
- Start a new chunk only when the current chunk is non-empty and adding the next sentence would exceed
max_tokens. - A sentence whose size alone exceeds the budget still forms a (one-sentence) chunk rather than being dropped.
Hints 4 ideas
- Keep a current chunk (list of indices) and its running token total.
- For each sentence i: if the current chunk is non-empty and
total + sizes[i] > max_tokens, push the current chunk and reset. - Then append i to the current chunk and add its size to the total.
- After the loop, do not forget to push the final non-empty chunk.
Approach, complexity & discussion — open after you solve
Start from brute force
The version most people write first adds the sentence, then checks: current.append(i), total += sz, and flush if total > max_tokens. It looks like the same greedy idea, and it does keep sentences whole and in order. It costs O(n) time and O(n) space for the output, so speed is not the problem. The order of the two steps is the problem: the sentence that causes the overflow has already joined the full chunk when the check fires. On the first example it returns [[0, 1, 2], [3, 4]], and the first chunk holds 3 + 4 + 2 = 9 tokens against a budget of 7.
The approach
The insight is to decide before adding a sentence. A chunk is flushed exactly when it is non-empty and the next sentence would push it past the budget, so every chunk either fits max_tokens or is one oversized sentence. Greedy is also the best possible packing by count: each chunk extends as far as the budget allows, so its end is never earlier than the matching chunk of any other valid split, and no in-order split uses fewer chunks.
The reference keeps chunks (the output), current (indices in the open chunk) and total (its running token count, so nothing is re-summed). For each i, sz it tests current and total + sz > max_tokens; if true it appends current to chunks and resets both. Then it always appends i and adds sz, which is how an oversized sentence gets a chunk of its own: the current and guard stops an empty chunk being flushed in front of it. After the loop, if current: flushes the last chunk, so empty input returns [].
Walk through an example
The first example, sizes = [3, 4, 2, 5, 1] and max_tokens = 7, one row per sentence as the reference processes it.
| step | i, sz | total + sz > 7? | action | current, total | chunks |
|---|---|---|---|---|---|
| 1 | 0, 3 | no check: current is empty | add | [0], 3 | [] |
| 2 | 1, 4 | 3 + 4 = 7, no | add (an exact fit is allowed) | [0, 1], 7 | [] |
| 3 | 2, 2 | 7 + 2 = 9, yes | flush, then add | [2], 2 | [[0, 1]] |
| 4 | 3, 5 | 2 + 5 = 7, no | add | [2, 3], 7 | [[0, 1]] |
| 5 | 4, 1 | 7 + 1 = 8, yes | flush, then add | [4], 1 | [[0, 1], [2, 3]] |
| end | — | — | final flush | — | [[0, 1], [2, 3], [4]] |
Complexity
Reference: O(n) time, one pass with constant work per sentence because total is a running sum, and O(n) space for the output, since every index appears once. Add-then-check: the same O(n) time and O(n) space; it is wrong, not slow. Re-summing current before each add would be correct but O(n²) when everything fits in one chunk.
Edge cases to test
sizes = []:[]. The final flush is skipped becausecurrentis empty, so no empty chunk appears.sizes = [4, 4],max_tokens = 4:[[0], [1]]. An exact fit is allowed (the test is>, not>=), and the second sentence starts a new chunk.sizes = [9, 1, 1],max_tokens = 5:[[0], [1, 2]]. An oversized first sentence gets its own chunk, and without thecurrent andguard the output would start with[].sizes = [5, 5, 5],max_tokens = 4:[[0], [1], [2]]. Every sentence is oversized, and none is dropped.sizes = [1, 1, 1],max_tokens = 10:[[0, 1, 2]]. Everything fits, and the only flush is the final one.
Common mistakes
- Checking after appending: the sentence that overflows stays in the chunk, so
chunk([3, 4, 2, 5, 1], 7)yields[[0, 1, 2], [3, 4]], a 9-token chunk. - Dropping the
current andguard: an oversized first sentence flushes an empty chunk, andchunk([5, 5, 5], 4)returns[[], [0], [1], [2]]. - Testing
>=instead of>: a chunk that exactly fills the budget is cut early, and the first example becomes[[0], [1, 2], [3, 4]]. - Forgetting the final flush, which silently drops the last chunk:
[4]disappears from the first example. - Resetting
currentbut nottotalafter a flush: the stale total makes every later sentence overflow, and the first example becomes[[0, 1], [2], [3], [4]].
Follow-ups an interviewer asks next
- Add overlap: each chunk should repeat the end of the previous one.
- Overlap keeps a fact that sits on a boundary retrievable from both sides. After a flush, seed the new
currentwith the trailing sentences of the flushed chunk whose sizes sum to at most an overlap budget, and starttotalat that sum. Drop the seed whenever seed plus the next sentence would itself exceedmax_tokens, or the overlap breaks the budget it exists to serve. - Sentences arrive as a stream, not a list.
- The reference already suits a stream: it reads each size once and never looks back. Turn it into a generator that yields
currentat every flush and once more at the end. Memory drops from every chunk to the open chunk alone, O(L) for L sentences per chunk, and the first chunk can be embedded before the document has finished arriving. - Greedy leaves a tiny last chunk. Can you balance the sizes?
- Keep the chunk count greedy found, then binary search the smallest budget that still achieves it, running this same packer at each candidate budget; the count never rises as the budget grows, so the search is valid. On the first example, a budget of 6 still gives three chunks,
[[0], [1, 2], [3, 4]]with 3, 6 and 6 tokens instead of 7, 7 and 1, while a budget of 5 needs five. - The token counts are not given. How do you get them?
- Tokenize each sentence with the tokenizer of the embedding model the chunks are for, because different tokenizers count the same text differently, and cache the counts so re-chunking never re-tokenizes. Tokenizing a joined chunk can differ by a token or so from the sum of its sentences, since whitespace merges into neighboring tokens, so keep a small margin below the model’s hard limit.
Where this shows up
Chunking strategy directly caps RAG retrieval quality: if the key context is split across two chunks, neither retrieves well and the model answers from the wrong context. Packing whole sentences up to a token budget is the usual first defense: the budget keeps each chunk inside the embedding model’s input limit, and cutting only between sentences keeps every chunk readable on its own. LangChain’s recursive text splitter, for one, has the same split-then-merge shape: it splits on separators such as paragraph breaks, then merges the pieces up to a chunk size.
Reference solution
Reference solution — Python
def chunk(sizes, max_tokens):
chunks = []
current = []
total = 0
for i, sz in enumerate(sizes):
if current and total + sz > max_tokens: # would overflow -> flush
chunks.append(current)
current = []
total = 0
current.append(i) # add whole sentence
total += sz
if current:
chunks.append(current) # flush the last chunk
return chunks
print(chunk([3, 4, 2, 5, 1], 7)) # -> [[0, 1], [2, 3], [4]]
print(chunk([5, 5, 5], 4)) # -> [[0], [1], [2]]Reference solution — TypeScript
function chunk(sizes: number[], maxTokens: number): number[][] {
const chunks: number[][] = [];
let current: number[] = [];
let total = 0;
for (let i = 0; i < sizes.length; i++) {
if (current.length && total + sizes[i] > maxTokens) { // would overflow
chunks.push(current);
current = [];
total = 0;
}
current.push(i); // add whole sentence
total += sizes[i];
}
if (current.length) chunks.push(current); // flush the last
return chunks;
}
console.log(JSON.stringify(chunk([3, 4, 2, 5, 1], 7))); // -> [[0,1],[2,3],[4]]
console.log(JSON.stringify(chunk([5, 5, 5], 4))); // -> [[0],[1],[2]]Questions about Semantic Chunker (Token Budget)
What is the difference between fixed-size and sentence-based chunking?
Fixed-size chunking cuts the text every N characters or tokens, wherever that lands, so a boundary can fall in the middle of a sentence and each half embeds as a fragment. Sentence-based chunking, the version in this challenge, only cuts between sentences: it packs whole sentences into a chunk until the next one would exceed the token budget. Chunks come out a little uneven in size, but every chunk is made of complete sentences, which keeps each one readable on its own. Semantic chunking goes a step further and cuts where the topic changes, measured by embedding similarity between neighboring sentences.
Why does the greedy chunker check the budget before adding a sentence?
Because a chunk must never exceed max_tokens unless it is a single oversized sentence. If you add the sentence first and check afterwards, the sentence that caused the overflow stays in the full chunk. On sizes [3, 4, 2, 5, 1] with a budget of 7, the add-then-check version produces [[0, 1, 2], [3, 4]], and its first chunk holds 9 tokens. Checking first, with the test current and total + sz greater than max_tokens, flushes [0, 1] at exactly 7 tokens and starts the next chunk with sentence 2.
What happens to a sentence that is longer than the token budget?
In this challenge it becomes a chunk of its own rather than being dropped or split, so chunk([5, 5, 5], 4) returns [[0], [1], [2]]. The reference gets this for free: the overflow test only flushes a non-empty chunk, and the sentence is always appended afterwards. In a real pipeline, a chunk that exceeds the embedding model’s input limit is either rejected or truncated, depending on the model and library, so production splitters usually fall back to a finer split for that one sentence, such as by clause or by a fixed token window.
Is greedy packing the best way to split sentences into chunks?
It is optimal for one goal: it produces the fewest chunks possible without reordering or splitting sentences. Each greedy chunk extends as far as the budget allows, so by induction its end is never earlier than the end of the matching chunk in any other valid split. What it does not optimize is balance: the last chunk can be tiny, as with [4] in the first example. If even sizes matter, binary search the smallest budget that keeps the same chunk count, or use a dynamic program that minimizes the largest chunk.
Explore the topic
See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.
More Challenges
- SoftmaxThe function at the end of every classifier and language model: turn raw scores (logits) into a probability distribution. Implement the numerically stable version so big logits do not overflow. Solve it in Python or TypeScript.Read →
- Cosine SimilarityThe measure behind every embedding search and RAG system: how aligned are two vectors, ignoring their length? Dot product over the product of magnitudes — 1 identical, 0 orthogonal, -1 opposite. Solve it in Python or TypeScript.Read →
- Top-K RetrievalThe core of the "R" in RAG: given a query embedding and a set of document embeddings, return the indices of the k most similar docs by cosine similarity, with a stable tie-break. Solve it in Python or TypeScript.Read →
- Single-Head AttentionThe operation at the heart of every Transformer: scaled dot-product attention. Given queries, keys, and values, let each query pull a weighted blend of the values — softmax(QKᵀ/√d)·V — with a numerically stable softmax and no numpy, just the math. Solve it in Python or TypeScript.Read →
- Layer NormalizationThe stabilizer wrapped around every Transformer sub-layer: re-center and re-scale a vector to mean 0 and variance 1, then let learned gamma and beta stretch and shift it back — y = gamma·(x−mean)/√(var+eps) + beta. Keeps deep nets trainable. Solve it in Python or TypeScript.Read →
- Cross-Entropy LossThe loss that trains almost every classifier and language model. It measures how surprised the model was by the right answer — high probability on the true class → near 0, confidently wrong → explodes. Return -log(p[target]), eps-guarded. Solve it in Python or TypeScript.Read →