text-splitters/

Chunk long text into overlapping pieces, respecting structure

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

4
classes
200
LOC
1000
default chunkSize
200
default chunkOverlap
5
recursive separators

📚 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

DecisionChoiceWhy
Strategy hierarchyAbstract 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 implementationmergeSplits shifts current[] from the front while length > chunkOverlapChar-accurate overlap regardless of separator. Alternative: re-slice from end of previous chunk — tangles with separator stripping.
Token estimationchars/4 heuristic in TokenTextSplitterAvoids tiktoken dep. Real BPE tokens average ~4 chars in English. lengthFunction seam lets a real tokenizer drop in later.
chunkOverlap < chunkSize validationThrow in constructorWithout this, mergeSplits would loop forever (overlap >= size means the cut never moves forward).
Per-chunk metadataInherit 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

FromToInterfaceWhy this interaction exists
User codeRecursiveCharSplittersplitDocuments(Document[])Default choice for prose — respects paragraph > sentence > word boundaries.
SplitterDocument (utils)new Document(text, metadata)The unified payload type carries metadata across the splitter boundary.
RecursiveCharSplitteritself (recursion)new RecursiveCharSplitter({...this, separators: nextSeparators})Recurse into oversize pieces with the next finer separator list.
TokenTextSplitterRecursiveCharSplitterdelegationRe-uses the recursion logic but with a token-counting lengthFunction.
PDFLoader.loadAndSplitany splittersplitter.splitDocuments(docs)Convenience method for the loader-then-splitter pattern.
Lesson 03BaseTextSplitter.mergeSplitsdirect 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

ScenarioWhat Actually Happens
Empty texttext.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 >= chunkSizeConstructor throws "chunkOverlap must be less than chunkSize". Without this guard, mergeSplits would loop forever.
All splits exactly chunkSizeEach 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 TokenTextSplitterchars/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

ParameterDefaultDescription
chunkSize1000Maximum chunk length in lengthFunction units.
chunkOverlap200Overlap maintained at chunk boundaries.
lengthFunctiont => t.lengthHook for token-aware sizing. Used by TokenTextSplitter.
keepSeparatorfalseWhether 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 metadataspread from input Document.metadataPreserves 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);