text-splitters/
Four classes: an abstract base plus three concrete strategies (Character, Recursive, Token). The recursive splitter walks a priority list of separators and falls through to finer granularity when chunks overflow — the LangChain-standard middle ground between hard windowing and full sentence parsing.
Mature 200 LOC 3 strategies Sliding-window overlap
📚 What is this & why does it exist?
Embedding models have a maximum input length (bge-small-en-v1.5 caps around 512 tokens). Documents are usually longer. text-splitters/ turns a long document into a sequence of overlapping chunks small enough to embed. The chunks become the indexable unit in the vector store: each chunk gets one embedding, and retrieval returns chunks (not whole documents).
💡 Real-world analogy
Think of slicing bread for a sandwich. You can use a hard cut every 1cm (character window — you might cut through a key word) or you can try to slice at the natural seams of the loaf (separator-aware) and only resort to harder cuts when a slice gets too long. The chunkOverlap is the bread you let stay on both adjacent slices so a meaningful phrase isn't lost at the boundary — like buttering both edges of the cut so the filling sticks.
🎯 Why This Component
Splitting is non-obvious because the embedding model has zero awareness of chunk boundaries. If you slice mid-sentence, the chunk's embedding becomes ambiguous — representing half a thought. If you slice every paragraph regardless of size, you get embeddings that overflow the context window. The recursive separator approach navigates this with three concrete claims: (a) coarse separators preserve more meaning, so try them first; (b) when a piece is still too big, recurse to finer separators; (c) maintain chunkOverlap chars of context across boundaries so phrases near a cut are still indexed somewhere whole.
The component exists as a family rather than one class because the right strategy depends on the document. Code and CSV want a different separator priority than prose. Token-aware splitting (where the chunk-size budget is in tokens, not characters) wants a different length function. The base class factors out everything that's shared (overlap, document construction, metadata propagation) so each strategy is just an override of splitText().
🏗️ Design Rationale
| Decision | Choice | Why |
|---|---|---|
| Strategy hierarchy | Abstract BaseTextSplitter + 3 subclasses overriding splitText() | Mirrors LangChain. Common machinery (overlap, doc construction) lives once in the base. |
| Recursive separator list | ['\n\n','\n','. ',' ',''] | Paragraph > line > sentence > word > char. The empty-string fallback guarantees termination on any text. |
| Overlap implementation | mergeSplits shifts current[] from the front while length > chunkOverlap | Char-accurate overlap regardless of separator. Alternative: re-slice from end of previous chunk — tangles with separator stripping. |
| Token estimation | chars/4 heuristic in TokenTextSplitter | Avoids tiktoken dep. Real BPE tokens average ~4 chars in English. lengthFunction seam lets a real tokenizer drop in later. |
chunkOverlap < chunkSize validation | Throw in constructor | Without this, mergeSplits would loop forever (overlap >= size means the cut never moves forward). |
| Per-chunk metadata | Inherit document metadata + add {chunk:j, totalChunks:N} | Preserves provenance — readers can cite "page 3, chunk 2 of 4". |
Architecture
classDiagram
class BaseTextSplitter {
+chunkSize: 1000
+chunkOverlap: 200
+lengthFunction: fn
+keepSeparator: false
+splitText()*
+splitDocuments(docs) Document[]
+createDocuments(texts, metadatas) Document[]
+mergeSplits(splits, separator) string[]
+joinSplits(splits, sep) string
}
class CharacterTextSplitter {
+separator: '\n\n'
+splitText(text) string[]
}
class RecursiveCharacterTextSplitter {
+separators: ['\n\n','\n','. ',' ','']
+splitText(text) string[]
}
class TokenTextSplitter {
+encodingName: 'cl100k_base'
+lengthFunction: chars/4
+splitText(text) string[]
}
BaseTextSplitter <|-- CharacterTextSplitter
BaseTextSplitter <|-- RecursiveCharacterTextSplitter
BaseTextSplitter <|-- TokenTextSplitter
TokenTextSplitter ..> RecursiveCharacterTextSplitter : delegates to
BaseTextSplitter ..> Document : emits via createDocuments
Lifecycle & Data Flow
sequenceDiagram
participant U as User
participant RS as RecursiveCharSplitter
participant Base as BaseTextSplitter
U->>RS: splitDocuments([Document(pageContent, meta)])
RS->>Base: createDocuments([text], [meta])
Base->>RS: splitText(text)
Note over RS: pick coarsest separator
that exists in text
RS->>RS: text.split(sep).filter(Boolean)
loop for each split
alt split <= chunkSize
RS->>RS: temp.push(split)
else split too big
RS->>Base: mergeSplits(temp, sep)
Base-->>RS: chunks (with overlap)
alt has finer separators
RS->>RS: recurse with nextSeparators
else no finer
RS->>RS: emit oversize as-is
end
end
end
RS->>Base: mergeSplits(remaining temp, sep)
Base-->>RS: final chunks
RS-->>Base: chunks: string[]
Base->>Base: wrap each chunk in Document
add {chunk:j, totalChunks:N}
Base-->>U: Document[]
Interactions
| From | To | Interface | Why this interaction exists |
|---|---|---|---|
| User code | RecursiveCharSplitter | splitDocuments(Document[]) | Default choice for prose — respects paragraph > sentence > word boundaries. |
| Splitter | Document (utils) | new Document(text, metadata) | The unified payload type carries metadata across the splitter boundary. |
RecursiveCharSplitter | itself (recursion) | new RecursiveCharSplitter({...this, separators: nextSeparators}) | Recurse into oversize pieces with the next finer separator list. |
TokenTextSplitter | RecursiveCharSplitter | delegation | Re-uses the recursion logic but with a token-counting lengthFunction. |
PDFLoader.loadAndSplit | any splitter | splitter.splitDocuments(docs) | Convenience method for the loader-then-splitter pattern. |
| Lesson 03 | BaseTextSplitter.mergeSplits | direct call (re-implementation) | The lesson re-implements the base class inline to teach the overlap algorithm. |
📝 Annotated Code Walkthrough — RecursiveCharacterTextSplitter.splitText
The most important algorithm in this module. Every line solves a specific failure mode.
Default to the finest separator
splitText(text) {
const finalChunks = [];
let separator = this.separators.at(-1);
let nextSeparators = [];
Default is the empty string — text.split('') splits per-character, the brute-force fallback. Without this default, a text containing none of the separators would produce empty results.
Choose the coarsest separator that appears
for (let i = 0; i < this.separators.length; i++) {
const sep = this.separators[i];
if (text.includes(sep)) {
separator = sep;
nextSeparators = this.separators.slice(i + 1);
break;
}
}
Walks left-to-right; first hit wins. nextSeparators is the suffix of the priority list — saved for recursion when a split overflows.
Split and filter empties
const splits = text.split(separator).filter(Boolean);
let temp = [];
filter(Boolean) removes empty strings caused by consecutive separators. temp accumulates fitting splits until they need to be flushed via mergeSplits.
Per-split: fits or doesn't
for (const s of splits) {
if (this.lengthFunction(s) <= this.chunkSize) {
temp.push(s);
} else {
The fast path: fits. The slow path: overflows — we have to flush temp and recurse on s.
Flush before recursing
if (temp.length) {
finalChunks.push(...this.mergeSplits(temp, separator));
temp = [];
}
Why flush first? Because the oversize piece's recursive splits should be after what's already accumulated, not interleaved with it. Order preservation matters for downstream context.
Recurse or surrender
if (nextSeparators.length === 0) {
finalChunks.push(s);
} else {
const recursiveSplitter = new RecursiveCharacterTextSplitter({
...this,
separators: nextSeparators
});
finalChunks.push(...recursiveSplitter.splitText(s));
}
}
}
If we've exhausted the priority list (already at empty-string), surrender and emit the oversize piece. Otherwise construct a child splitter with finer separators and recurse. The ...this spread carries chunkSize/Overlap/lengthFunction along.
Final flush
if (temp.length) {
finalChunks.push(...this.mergeSplits(temp, separator));
}
return finalChunks;
}
Don't forget the trailing pieces that fit. Without this final mergeSplits, the tail of any text would be silently dropped.
📝 BaseTextSplitter.mergeSplits — the overlap engine
State: current chunk + length tracker
mergeSplits(splits, separator) {
const chunks = [];
let current = [];
let length = 0;
current[] holds the pieces that will form the next chunk; length tracks total characters including separators.
For each split, check if it fits
for (const split of splits) {
const splitLength = this.lengthFunction(split);
const extraLength = current.length ? separator.length : 0;
if (length + splitLength + extraLength > this.chunkSize) {
extraLength accounts for the separator that would join this split to the previous one. Forgetting it makes chunks slightly oversize when separators are non-empty (e.g., '. ' is 2 chars).
Flush the chunk
if (current.length) {
chunks.push(this.joinSplits(current, separator));
}
while (length > this.chunkOverlap && current.length) {
length -= this.lengthFunction(current.shift()) + separator.length;
}
}
This is the overlap. After flushing, we don't reset current — we shift pieces off the front while length is still over the overlap budget. What remains in current becomes the prefix of the next chunk. That's how a phrase near a chunk boundary gets indexed in both chunks.
Add the current split
current.push(split);
length += splitLength + (current.length > 1 ? separator.length : 0);
}
The trailing ternary handles the first item in a chunk — no leading separator yet.
Final emission
if (current.length) {
chunks.push(this.joinSplits(current, separator));
}
return chunks.filter(Boolean);
}
Filter Boolean drops any chunk that ended up empty after joinSplits trimmed it.
Deep-Dive Highlights
Overlap as sliding window Algorithm
The while length > chunkOverlap loop is the secret — it keeps the tail of one chunk as the head of the next, char-accurate, regardless of separator.
Why recursion beats sentence tokenisation Design
Real text mixes prose with code, lists, tables. A sentence tokenizer mis-splits any of these. Recursion degrades gracefully: paragraph → line → sentence → word → char.
Token estimation tradeoff Heuristic
chars/4 is wrong by ~30% on languages with non-ASCII (Mandarin, Japanese). The lessons stay English; the seam (lengthFunction) lets you swap in a proper tokenizer when needed.
⚠ Edge Cases & Boundaries
| Scenario | What Actually Happens |
|---|---|
| Empty text | text.split(separator) yields ['']; filter(Boolean) drops it; loop doesn't run; returns []. |
| Single huge token (no separators) | Falls through every separator level to ''; the recursion surrenders and emits the oversize string. Better to oversize than to crash. |
chunkOverlap >= chunkSize | Constructor throws "chunkOverlap must be less than chunkSize". Without this guard, mergeSplits would loop forever. |
All splits exactly chunkSize | Each is a chunk of its own. Overlap collapses to zero in this degenerate case. |
Separator is multi-character (e.g., '. ') | extraLength = separator.length accounts for it — chunk size stays correct. |
Mixed CJK + Latin text in TokenTextSplitter | chars/4 over-estimates token count for CJK. Chunks become smaller than necessary — inefficient but correct. |
Directory Map
text-splitters/ files
src/text-splitters/
├── BaseTextSplitter.js (99 LOC) ← abstract: chunkSize/Overlap, mergeSplits, splitDocuments
├── CharacterTextSplitter.js (18 LOC) ← single-separator strategy
├── RecursiveCharacterTextSplitter.js (59 LOC) ← priority-list recursion (recommended)
├── TokenTextSplitter.js (23 LOC) ← chars/4 heuristic + delegates to recursive
└── index.js ← barrel re-export
Configuration Reference
| Parameter | Default | Description |
|---|---|---|
chunkSize | 1000 | Maximum chunk length in lengthFunction units. |
chunkOverlap | 200 | Overlap maintained at chunk boundaries. |
lengthFunction | t => t.length | Hook for token-aware sizing. Used by TokenTextSplitter. |
keepSeparator | false | Whether to retain the separator string in chunks. (Currently unused; reserved for future strategies.) |
CharacterTextSplitter.separator | '\n\n' | Single split delimiter. |
RecursiveCharSplitter.separators | ['\n\n','\n','. ',' ',''] | Priority list. Replace for code (e.g., ['\n\n','\nfunction ','\n','; ',' ']). |
TokenTextSplitter.encodingName | 'cl100k_base' | Currently unused (no real tokenizer); naming reserved for future tiktoken integration. |
Output: metadata.chunk | (set per chunk) | 0-based chunk index within the source document. |
Output: metadata.totalChunks | (set per chunk) | How many chunks the source document produced. |
| Inherited metadata | spread from input Document.metadata | Preserves source provenance (page number, file path, etc.). |
Code Examples
Recursive splitter (recommended for prose)
import { RecursiveCharacterTextSplitter } from './src/text-splitters/index.js';
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 800,
chunkOverlap: 150,
separators: ['\n\n', '\n', '. ', ' ', '']
});
const chunks = splitter.splitText(longDocument);
console.log(`${chunks.length} chunks`);
chunks.forEach((c, i) => console.log(`[${i}] ${c.slice(0, 80)}...`));
Split documents preserving metadata
import { PDFLoader, RecursiveCharacterTextSplitter } from './src/index.js';
const docs = await new PDFLoader(url, { splitPages: true }).load();
const chunks = await new RecursiveCharacterTextSplitter({
chunkSize: 1000, chunkOverlap: 200
}).splitDocuments(docs);
// Each chunk carries: pageContent + {source, page, loc, chunk, totalChunks, ...}
const cited = chunks.map(c => `(p${c.metadata.page}, chunk ${c.metadata.chunk + 1}/${c.metadata.totalChunks}) ${c.pageContent}`);
Token-aware splitter
import { TokenTextSplitter } from './src/text-splitters/index.js';
// Approx: 1 token = 4 chars. So 256 "tokens" = 1024 chars in the budget.
const splitter = new TokenTextSplitter({ chunkSize: 256, chunkOverlap: 32 });
const chunks = splitter.splitText(longDocument);