LangChain Text Splitters Compared: Which One for Which Content
Why your RAG pipeline is only as good as your splitter
Most people building a Retrieval-Augmented Generation pipeline spend days picking an embedding model, comparing vector databases, and tuning prompts — then throw a single default RecursiveCharacterTextSplitter at every document type they own and call it done. Markdown docs, HTML scraped pages, JSON API dumps, and raw Python files all get chopped the same way, at the same chunk size, with the same overlap.
Then retrieval quality is mediocre, and nobody thinks to blame the splitter.
Text splitting is the least glamorous step in the RAG pipeline and also one of the highest-leverage ones. A chunk that cuts a function definition in half, or severs a markdown table from its header, or breaks a sentence mid-clause, poisons everything downstream. The embedding for that chunk will be noisy. The retriever will rank it oddly. The LLM will get a fragment instead of an idea. No amount of prompt engineering fixes a bad chunk.
LangChain ships close to a dozen splitters under langchain_text_splitters, and each one encodes a different assumption about what "a meaningful unit of text" looks like. This article walks through the splitters that actually matter in production, explains the mental model behind each one, and gives you a decision framework for matching splitter to content type — plain prose, code, markdown, HTML, JSON, and token-constrained pipelines. By the end you should be able to look at a document type and immediately know which splitter to reach for, instead of defaulting to whatever the first tutorial you read used.
The core idea: splitting is a search for the least-bad cut point
Before comparing splitters, it helps to understand the shared problem they're all solving. You have a document longer than your embedding model's context window (or longer than you want a single chunk to be), and you need to cut it into pieces. Every splitter answers the same three questions differently:
- What counts as a valid place to cut? A blank line? A sentence boundary? A closing brace? A markdown header?
- How do you measure "how long" a chunk is? Characters? Tokens? Some other unit?
- What happens at the boundary? Do adjacent chunks share some overlapping text so context isn't lost at the seam?
Once you frame it this way, the splitter zoo stops looking like a pile of arbitrary classes and starts looking like a set of answers tuned to specific content shapes. A splitter that treats \n\n as the only valid cut point is great for prose and terrible for JSON. A splitter that counts tokens instead of characters is essential when you're paying per token or hitting a hard context limit, but it's overkill for a quick prototype.
Let's go through them one at a time.
CharacterTextSplitter: the naive baseline
CharacterTextSplitter is the simplest splitter in the library. It splits on a single separator (by default \n\n) and packs text into chunks up to chunk_size, falling back to raw character-count slicing if a section between separators is still too long.
from langchain_text_splitters import CharacterTextSplitter
splitter = CharacterTextSplitter(
separator="\n\n",
chunk_size=1000,
chunk_overlap=200,
length_function=len,
)
chunks = splitter.split_text(long_document)
print(f"Produced {len(chunks)} chunks")The honest way to describe CharacterTextSplitter is: it's what you use when you want predictable, simple behavior and your text is already reasonably well-formatted with consistent paragraph breaks. It does not recursively try smaller separators if a paragraph is too long — it just truncates at chunk_size regardless of whether that lands you mid-word.
Where this bites people: dense text with few double-newlines (think a legal contract exported as one giant paragraph, or a PDF-to-text dump with inconsistent line breaks) produces chunks that get hard-truncated in ugly places. If you've ever seen a retrieved chunk that ends mid-sentence with no punctuation, this is usually why.
Use `CharacterTextSplitter` when: your source text has reliable paragraph structure, you want the simplest possible mental model, and you're prototyping quickly rather than optimizing retrieval quality.
RecursiveCharacterTextSplitter: the default that earns its reputation
This is the splitter almost everyone should start with for general prose, and for good reason. Instead of one separator, it takes an ordered list of separators — by default ["\n\n", "\n", " ", ""] — and works through them recursively. It tries to split on the biggest, most semantically meaningful boundary first (paragraph breaks). If a resulting chunk is still too big, it moves to the next separator down the list (single newlines), then spaces, and only as a last resort splits mid-word by character count.
from langchain_text_splitters import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=1000,
chunk_overlap=150,
separators=["\n\n", "\n", ". ", " ", ""],
)
docs = splitter.create_documents([long_document])
for d in docs[:3]:
print(len(d.page_content), d.page_content[:80])The recursive fallback is what makes this splitter forgiving. Feed it a document with no double-newlines at all, and it degrades gracefully to splitting on single newlines, then spaces, instead of hard-truncating like CharacterTextSplitter would. That graceful degradation is why RecursiveCharacterTextSplitter is the documented default recommendation for generic text — it provides a solid balance between keeping semantically related content together and respecting your size budget, without requiring you to know your document's exact structure in advance.
Two parameters matter more than people realize:
chunk_sizesets the target maximum length of each chunk.chunk_overlapcontrols how much text repeats between consecutive chunks, which helps preserve context across a cut (a sentence that started in chunk 1 and finishes in chunk 2 will appear whole in at least one of them).
A common mistake is setting chunk_overlap too high relative to chunk_size — if overlap is 50% of chunk size, you're storing and embedding nearly double the data for marginal context benefit. A reasonable starting ratio is 10-20% overlap relative to chunk size, then tune based on retrieval evaluation, not vibes.
Use `RecursiveCharacterTextSplitter` when: you're not sure what to use. It's the right default for blog posts, articles, reports, transcripts, and most unstructured prose. If someone asks "which splitter should I use," and you don't know their content type yet, this is the safe answer.
Splitting code without breaking syntax
Prose splitters treat code as just another wall of text, which means they'll happily cut a Python function in half between the def line and its body, or split a JSON-in-a-docstring block at a comma. RecursiveCharacterTextSplitter solves this with a classmethod, from_language, that swaps in separators tuned to a specific programming language's syntax — things like class and function boundaries, indentation patterns, and block delimiters, ordered so that splits prefer to happen between top-level definitions rather than inside them.
from langchain_text_splitters import Language, RecursiveCharacterTextSplitter
python_code = '''
class VectorStore:
def __init__(self, embedding_fn):
self.embedding_fn = embedding_fn
self.records = []
def add(self, text, metadata=None):
vector = self.embedding_fn(text)
self.records.append((vector, text, metadata or {}))
def search(self, query, k=5):
query_vector = self.embedding_fn(query)
scored = [(cosine(query_vector, v), t, m) for v, t, m in self.records]
return sorted(scored, reverse=True)[:k]
'''
splitter = RecursiveCharacterTextSplitter.from_language(
language=Language.PYTHON,
chunk_size=200,
chunk_overlap=20,
)
code_chunks = splitter.create_documents([python_code])
for chunk in code_chunks:
print("---")
print(chunk.page_content)The Language enum covers a long list of languages beyond Python — JavaScript, TypeScript, Java, Go, Rust, C, C++, PHP, Ruby, Scala, Swift, Kotlin, Markdown, HTML, LaTeX, and more — each with separators appropriate to that language's structure. You can also inspect what separators a given language uses via RecursiveCharacterTextSplitter.get_separators_for_language(Language.PYTHON) if you want to customize them further, for example adding decorator boundaries or docstring markers specific to your codebase's conventions.
Use `from_language` when: you're building a code-search or code-documentation RAG system — think an internal tool that lets engineers ask questions against your codebase, or a coding assistant that retrieves relevant functions before answering. Plain RecursiveCharacterTextSplitter on source code will produce chunks that start mid-function and confuse both your embeddings and the LLM reading them back.
Markdown and HTML: splitting by structure, not by size
Markdown and HTML documents already encode their own structure through headers, and ignoring that structure wastes the most useful signal you have. MarkdownHeaderTextSplitter and HTMLHeaderTextSplitter both split based on header hierarchy rather than character count, and — critically — they attach the header path as metadata on every resulting chunk.
from langchain_text_splitters import MarkdownHeaderTextSplitter
markdown_doc = """
# LangChain Tutorial 2026
## Chapter 3: Retrieval
### Vector Stores
Vector stores index embeddings for fast similarity search.
### Retrievers
Retrievers wrap a vector store with additional query logic.
## Chapter 4: Agents
Agents combine an LLM with tools and a decision loop.
"""
headers_to_split_on = [
("#", "title"),
("##", "chapter"),
("###", "section"),
]
markdown_splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers_to_split_on)
md_chunks = markdown_splitter.split_text(markdown_doc)
for chunk in md_chunks:
print(chunk.metadata, "->", chunk.page_content[:60])Every chunk that comes out carries metadata like {"title": "LangChain Tutorial 2026", "chapter": "Chapter 3: Retrieval", "section": "Vector Stores"}. That metadata is enormously useful at query time: you can filter retrieval to a specific chapter, boost chunks whose header path matches keywords in the user's question, or display breadcrumb context in your UI so the user knows exactly where an answer came from in the source document.
HTMLHeaderTextSplitter does the same job for <h1>, <h2>, <h3> tags in scraped web pages, which matters a lot if you're building a RAG pipeline over documentation sites or knowledge bases. Because it treats HTML headers as split points rather than treating the whole DOM as an undifferentiated blob, related content under a heading stays together, much like keeping a book's chapters intact rather than slicing every 1,000 characters regardless of what section you land in.
Note that both of these are standalone classes with their own split_text methods — unlike CharacterTextSplitter and RecursiveCharacterTextSplitter, they don't inherit from the base TextSplitter class, because their splitting logic is structural rather than length-based. In practice, this usually means chaining them: split on headers first with MarkdownHeaderTextSplitter, then run each resulting section through RecursiveCharacterTextSplitter if any individual section is still too large for your chunk budget.
# Two-stage splitting: structure first, then size
header_chunks = markdown_splitter.split_text(markdown_doc)
size_splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=50)
final_chunks = size_splitter.split_documents(header_chunks)Use header-based splitters when: your source documents have real structural headers you can rely on — technical documentation, wikis, README files, scraped support articles, course content. Skip them for content that's nominally markdown but doesn't actually use headers consistently (chat exports formatted as markdown, for instance).
RecursiveJsonSplitter: don't treat JSON like prose
JSON is the content type most likely to be mangled by a prose-oriented splitter, because valid JSON syntax has zero tolerance for arbitrary truncation — cut a JSON blob at the wrong character and you don't get a slightly awkward chunk, you get invalid JSON that no downstream parser can read. RecursiveJsonSplitter is built specifically to avoid this by working on the parsed JSON structure itself rather than on the serialized string.
from langchain_text_splitters import RecursiveJsonSplitter
nested_data = {
"course": "LangChain Tutorial 2026",
"modules": [
{"name": "Splitters", "lessons": ["Character", "Recursive", "Semantic"]},
{"name": "Retrievers", "lessons": ["Vector", "Hybrid", "Reranking"]},
],
"metadata": {"instructor": "Ira Menon", "duration_hours": 6},
}
json_splitter = RecursiveJsonSplitter(max_chunk_size=200)
json_chunks = json_splitter.split_text(json_data=nested_data)
for chunk in json_chunks:
print(chunk)It walks nested dictionaries and lists, breaking down complex, deeply nested JSON into smaller units while preserving structural integrity, splitting at object and array boundaries so each output chunk remains valid JSON (or a valid fragment you can safely re-parse) rather than a syntactically broken string. This matters for RAG use cases where your source data is API responses, configuration files, structured product catalogs, or exported database records rather than free text.
Use `RecursiveJsonSplitter` when: you're indexing structured data — API documentation with example payloads, product catalogs, config schemas, or any pipeline where the source of truth is JSON rather than prose. Do not run RecursiveCharacterTextSplitter on raw JSON strings; it has no concept of brace matching and will happily orphan a closing bracket in the next chunk.
TokenTextSplitter: when characters aren't the unit that matters
Every splitter discussed so far measures chunk size in characters by default. But the thing you actually care about — the thing your embedding model and your LLM's context window actually consume — is tokens, not characters. A chunk of 1,000 characters could be anywhere from roughly 150 to 300 tokens depending on the language, punctuation density, and whether the text is prose or code. If you're chunking right up against a hard context limit, that variance can bite you.
TokenTextSplitter splits based on token count using a real tokenizer (by default tiktoken, the same tokenizer family used by OpenAI models), so your chunk size guarantee is actually a token guarantee, not an approximation.
from langchain_text_splitters import TokenTextSplitter
splitter = TokenTextSplitter(
encoding_name="cl100k_base",
chunk_size=256,
chunk_overlap=32,
)
chunks = splitter.split_text(long_document)
print(f"{len(chunks)} chunks, each capped at 256 tokens")You can also get token-aware behavior from RecursiveCharacterTextSplitter by passing it a length_function that counts tokens instead of characters (for example len(tokenizer.encode(text))), which gives you the best of both — recursive semantic-boundary splitting, measured in tokens instead of characters. This hybrid is worth knowing about because pure TokenTextSplitter doesn't do the same recursive-separator dance; it counts tokens and cuts, without the same layered fallback through paragraph, line, and word boundaries.
Use `TokenTextSplitter` when: you're billed per token, you're working with a strict context window you're trying to pack efficiently, or you've noticed retrieval quality issues that trace back to inconsistent chunk-to-token ratios across your corpus (common when your documents mix languages or mix prose with code, since token density differs a lot between the two).
A decision framework you can actually use
Rather than memorizing every class name, it helps to reduce the decision to a short checklist you run through for each content type in your pipeline:
- Does the content have real structural markup (markdown headers, HTML headers)? If yes, split on structure first with
MarkdownHeaderTextSplitterorHTMLHeaderTextSplitter, then apply a size-based splitter as a second pass on any oversized sections. - Is the content source code? Use
RecursiveCharacterTextSplitter.from_languagewith the matchingLanguageenum value. Never run prose splitters on code you intend to retrieve at function-level granularity. - Is the content JSON or another nested structured format? Use
RecursiveJsonSplitterso chunks stay parseable. - Is the content plain prose with unreliable or absent paragraph breaks? Use
RecursiveCharacterTextSplitterwith its default separator cascade — it degrades gracefully whereCharacterTextSplitterwould truncate mid-word. - Is the content plain prose with clean, consistent paragraph structure and you want the simplest possible behavior?
CharacterTextSplitteris fine and easier to reason about. - Are you token-constrained or billed per token? Wrap whichever splitter you chose above with a token-based
length_function, or useTokenTextSplitterdirectly if you don't need recursive semantic fallback.
Most production pipelines end up combining more than one of these. A realistic setup for a documentation-heavy RAG system looks like: MarkdownHeaderTextSplitter to carve out sections and attach header metadata, then RecursiveCharacterTextSplitter with a token-counting length_function to bring any oversized section down to a safe chunk size, with modest overlap to protect sentence boundaries at the seams. For a codebase-search tool, it's RecursiveCharacterTextSplitter.from_language per file, keyed off the file extension, with metadata tagging which repo and path each chunk came from.
Common mistakes worth calling out directly
A few patterns show up repeatedly in RAG pipelines that underperform, and all of them trace back to splitter choice rather than embedding or retrieval logic:
- Using one splitter and chunk size for an entire heterogeneous corpus. Support tickets, API docs, and marketing pages do not have the same optimal chunk size. Splitting them all identically because it's one line of code in the ingestion script is the single most common mistake.
- Setting `chunk_overlap` as an afterthought default (often 0) and wondering why retrieved chunks feel truncated at the edges. A small, deliberate overlap costs a little storage and buys a lot of context continuity.
- Splitting code with a prose splitter because "it's just text." It compiles as text, but it doesn't retrieve well as text — a chunk boundary through the middle of a function loses the very thing that made the function meaningful to retrieve in the first place.
- Ignoring available metadata.
MarkdownHeaderTextSplitterandHTMLHeaderTextSplitterhand you header-path metadata for free. Not using it for filtering or reranking at query time is leaving a retrieval-quality improvement on the table for no cost. - Never re-evaluating chunk size after changing embedding models. Different embedding models have different effective context limits and different sensitivity to chunk length. A chunk size tuned for one model isn't automatically right for another.
Where this fits in the bigger RAG picture
Splitting is upstream of everything else in retrieval: it happens before embedding, before indexing, before any retriever logic runs. Get it wrong and no amount of clever hybrid search, reranking, or query rewriting fully compensates, because the chunks themselves are the atomic unit everything else operates on. Get it right — matching splitter to content shape instead of defaulting to one splitter for everything — and you'll often see a bigger jump in retrieval quality than from swapping in a fancier embedding model.
The pattern to internalize is simple even if the splitter list looks long: identify what a "meaningful unit" looks like for your specific content — a paragraph, a function, a JSON record, a documentation section — and pick (or combine) the splitter that respects that unit's natural boundaries, then layer size and token constraints on top rather than starting from size constraints alone.
If you want to go deeper — building full ingestion pipelines with multi-stage splitting, wiring up metadata-aware retrievers, and combining structural and semantic chunking strategies for production RAG systems — that's exactly what we cover hands-on in LangChain Tutorial 2026 here on teachyou.ai, with real datasets instead of toy examples.
BootcampA 30-day guided bootcamp: build, harden and ship a production autonomous agent from scratch.
AI AgentsUnderstand how AI agents really work: the loop, the tools, the memory, and why most agent projects fail.