Recursive chunking is the industry-standard method for splitting documents in RAG pipelines. It combines the predictable size control of fixed-size chunking with structural awareness by using a hierarchy of separators — trying the most significant boundaries first (double newlines for paragraphs) and progressively falling back to finer separators (single newlines, spaces, individual characters) only when chunks exceed the target size. This approach is the default in LangChain and is recommended by most RAG framework documentation.
The key insight behind recursive chunking is that text has a natural hierarchy of structure. Double newlines typically separate paragraphs, which are the strongest semantic boundaries in unformatted text. Single newlines separate lines within a paragraph. Spaces separate words. By respecting this hierarchy — splitting at paragraphs first, then sentences, then words — recursive chunking keeps semantically related text together as much as possible while still enforcing a maximum chunk size.
Unlike semantic chunking, recursive chunking requires no embedding model and no NLP processing. It operates purely on string separators, making it nearly as fast as fixed-size chunking but with significantly better output quality. For most RAG applications, recursive chunking is the right default choice — it’s the sweet spot between simplicity and semantic coherence.
Recursive chunking is parameterized by an ordered list of separators, tried from most significant to least significant. The default separator list for generic text is:
"\n\n" — Double newline (paragraph boundary)"\n" — Single newline (line boundary)" " — Space (word boundary)"" — Empty string (character boundary, last resort)The algorithm first splits the entire text using the most significant separator (e.g., "\n\n"). If any resulting segment is still larger than chunk_size, it recursively splits that segment using the next separator in the hierarchy (e.g., "\n"). This continues down the separator list until all chunks are within the size limit. The effect is that paragraphs are kept intact when possible, but long paragraphs are broken at sentence or word boundaries rather than at arbitrary character positions.
Consider a document with the following structure (sizes in tokens):
Paragraph 1 (150 tokens)
\n\n
Paragraph 2 (400 tokens) ← exceeds 256-token limit
\n\n
Paragraph 3 (200 tokens)
\n\n
Paragraph 4 (100 tokens)
With chunk_size=256 and chunk_overlap=20:
"\n\n": Produces 4 segments. Paragraphs 1, 3, and 4 are under 256 tokens — they become chunks as-is."\n" (line breaks). If still too large, split on " " (spaces).The RecursiveCharacterTextSplitter is LangChain’s default and most recommended text splitter:
from langchain.text_splitter import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=1000, # max characters per chunk
chunk_overlap=200, # overlap between chunks
separators=["\n\n", "\n", " ", ""], # default hierarchy
length_function=len # or use a token counter
)
chunks = splitter.split_text(document)
# Each chunk will be ≤ 1000 characters
# Paragraph boundaries preserved where possible
# Long paragraphs split at line or word boundaries
For production RAG systems, you want chunk sizes measured in tokens, not characters, since embedding models have token limits. LangChain supports this with a custom length function:
import tiktoken
# Create a token-counting length function
enc = tiktoken.encoding_for_model("text-embedding-3-small")
def token_length(text):
return len(enc.encode(text))
splitter = RecursiveCharacterTextSplitter(
chunk_size=256, # max tokens per chunk
chunk_overlap=25, # token overlap
separators=["\n\n", "\n", ". ", " ", ""],
length_function=token_length
)
chunks = splitter.split_text(document)
Note the addition of ". " (period-space) to the separator list. This splits at sentence boundaries before falling back to word boundaries, which is a common enhancement for prose-heavy documents.
One of recursive chunking’s greatest strengths is its adaptability to different content types through custom separator lists. LangChain provides built-in language-aware separators for programming languages:
from langchain.text_splitter import RecursiveCharacterTextSplitter, Language
# Python code splitting
python_splitter = RecursiveCharacterTextSplitter.from_language(
language=Language.PYTHON,
chunk_size=1000,
chunk_overlap=100
)
# Uses separators like: ["\nclass ", "\ndef ", "\n\n", "\n", " ", ""]
# Keeps classes and functions intact when possible
# Markdown splitting
md_splitter = RecursiveCharacterTextSplitter.from_language(
language=Language.MARKDOWN,
chunk_size=1000,
chunk_overlap=100
)
# Uses separators like: ["\n# ", "\n## ", "\n### ", "\n\n", "\n", " ", ""]
# Respects heading hierarchy
For domain-specific documents, crafting a custom separator list can dramatically improve chunk quality:
["\nArticle ", "\nSection ", "\n\n", "\n", ". ", " ", ""]["Recursive chunking occupies the sweet spot in the chunking strategy landscape:
chunk_size. Unlike semantic chunking, you won’t get a 2000-token chunk that exceeds your embedding model’s limit.In practice, teams typically observe the following patterns when comparing chunking strategies on RAG retrieval benchmarks:
A battle-tested production configuration for a general-purpose RAG pipeline:
# Production recursive chunking config
splitter = RecursiveCharacterTextSplitter(
chunk_size=512, # tokens (not chars) — matches most embedding model limits
chunk_overlap=50, # ~10% overlap
separators=[
"\n\n", # paragraph boundaries
"\n", # line boundaries
". ", # sentence boundaries
"? ", # question boundaries
"! ", # exclamation boundaries
"; ", # clause boundaries
", ", # phrase boundaries
" ", # word boundaries
"" # character fallback
],
length_function=token_length,
strip_whitespace=True
)
In production RAG systems, chunks carry metadata alongside their text content. Recursive chunking integrates well with document-level metadata propagation:
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain.schema import Document
# Split documents while preserving metadata
docs = [Document(page_content=text, metadata={"source": "manual_v3.pdf", "page": 12})]
splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=50)
chunks = splitter.split_documents(docs)
# Each chunk inherits the parent document's metadata
# chunks[0].metadata == {"source": "manual_v3.pdf", "page": 12}
["\n\n", "\n", " ", ""], which tries paragraphs → lines → words → characters.". " and "? " for prose-heavy documents to improve split quality.