LlamaIndex NodeParser — SentenceSplitter, TokenTextSplitter, SemanticSplitter selection
Node parser choice sits between raw documents and retrieval. Bad chunks silently degrade quality; good chunks let a mediocre retriever look great. This page maps the parsers to the situations where each shines.
Quick fix (TL;DR)
SentenceSplitter works well for prose with a target chunk size. TokenTextSplitter is strict on token count but cuts mid-sentence. SemanticSplitterNodeParser finds topic boundaries via embedding similarity (higher cost, best for messy documents). Format-specific parsers (MarkdownNodeParser, CodeSplitter, HTMLNodeParser) preserve structure. Fix by picking the parser that matches content type, then tuning chunk_size against a labeled retrieval test set.Real error messages you'll see
These are the exact strings returned by the LlamaIndex framework and its integrations when this error occurs. Copy-paste-searching any of them should land on this page.
# node 42 ends: "The refund policy applies when the customer" # node 43 starts: "requests a return within 30 days of purchase." # TokenTextSplitter with no sentence awareness
TimeoutError: SemanticSplitterNodeParser timed out on document — 45s per doc # semantic splitter runs embeddings per sentence-pair; slow on long docs
# Node ends at `def helper():` # Next node continues with function body # Should have used CodeSplitter to respect function boundaries
Reference
Node parser comparison
| Parser | Best for | Speed | Chunk boundary quality |
|---|---|---|---|
SentenceSplitter (default) | General prose | Fast | Sentence-aware |
TokenTextSplitter | Strict token limits | Fast | Poor — cuts anywhere |
SentenceWindowNodeParser | Per-sentence retrieval + windowed context | Fast | Sentence-based |
SemanticSplitterNodeParser | Unstructured docs, transcripts | Slow (embed calls) | Topic-aware |
MarkdownNodeParser | Markdown with headers | Fast | Header-based |
HTMLNodeParser | HTML with structure | Fast | Tag-based |
JSONNodeParser | Structured JSON | Fast | Path-based |
CodeSplitter | Source code | Fast | AST-aware (per language) |
HierarchicalNodeParser | Parent-child chunks | Fast | Multi-scale |
Root causes, ranked by frequency
Based on developer reports across LlamaIndex forums, GitHub issues, and Discord community during 2025–2026.
- 26%Default SentenceSplitter used on non-prose. Applied to code, JSON, or heavily-structured docs; loses structure.
- 18%Chunk size wrong for content. 128-token chunks on technical reference; 2000-token chunks on FAQs.
- 14%Overlap too small. 0-20 tokens overlap means concepts spanning boundaries never retrieve together.
- 10%SemanticSplitter enabled by default on huge corpus. Ingestion cost explodes.
- 8%Metadata lost during split. Custom node parser did not propagate document metadata.
- 7%Tokenizer mismatch. Chunked with GPT tokenizer, embedded with a different model's tokenizer.
- 7%Not using MarkdownNodeParser on Markdown. Header hierarchy lost; downstream cannot filter by section.
- 10%Chunk sizes vary wildly with semantic splitter. Some chunks tiny, some over token limit.
Fixes — copy-paste solutions
Match the parser to the content type
For each file type in your corpus, choose the parser that respects that format's structure. Compose them via IngestionPipeline if you have mixed content.
from llama_index.core.node_parser import ( SentenceSplitter, TokenTextSplitter, SentenceWindowNodeParser, MarkdownNodeParser, HTMLNodeParser, CodeSplitter, HierarchicalNodeParser, ) # --- Prose (default choice) --- sentence_splitter = SentenceSplitter( chunk_size=512, # target tokens per node chunk_overlap=64, # overlap between adjacent nodes paragraph_separator="\n\n", secondary_chunking_regex="[^,.;。]+[,.;。]?", ) # --- Markdown with header awareness --- markdown_parser = MarkdownNodeParser( include_metadata=True, # attach header context to each chunk include_prev_next_rel=True, # link chunks for context expansion ) # --- Source code (per-language AST-aware) --- python_splitter = CodeSplitter( language="python", # respects function/class boundaries chunk_lines=40, chunk_lines_overlap=8, max_chars=1500, ) # --- Hierarchical (parent-child for context expansion) --- # Creates chunks at three levels: 2048, 512, 128 tokens hierarchical = HierarchicalNodeParser.from_defaults( chunk_sizes=[2048, 512, 128] ) # --- Sentence-window (retrieval on sentence, context is window) --- sentence_window = SentenceWindowNodeParser.from_defaults( window_size=3, # retrieve 1 sentence, expand to ±3 for context window_metadata_key="window", original_text_metadata_key="original_text", ) # --- Route by file type --- def choose_parser(doc): ext = doc.metadata.get("file_type", "") if ext == "md": return markdown_parser elif ext == "py": return python_splitter elif ext in ("html", "htm"): return HTMLNodeParser() else: return sentence_splitter # Apply all_nodes = [] for doc in documents: parser = choose_parser(doc) all_nodes.extend(parser.get_nodes_from_documents([doc]))
CodeSplitter is essential — the default splitter cuts mid-function and destroys semantic units. Similarly, Markdown docs benefit enormously from MarkdownNodeParser's header awareness.Use SentenceWindowNodeParser for high-precision retrieval + expanded context
The sentence-window pattern embeds a single sentence per node but attaches the surrounding sentences as metadata. At query time, retrieve on the small embedding but hand the LLM the full window.
from llama_index.core import VectorStoreIndex from llama_index.core.node_parser import SentenceWindowNodeParser from llama_index.core.postprocessor import MetadataReplacementPostProcessor # 1) Parse into sentence-window nodes node_parser = SentenceWindowNodeParser.from_defaults( window_size=3, # ±3 sentences of context window_metadata_key="window", # stored in metadata for later original_text_metadata_key="original_text", ) nodes = node_parser.get_nodes_from_documents(documents) # 2) Build the index — each node is embedded on its SINGLE sentence # but carries the surrounding window in metadata index = VectorStoreIndex(nodes=nodes) # 3) At query time, replace node text with the window before sending to LLM query_engine = index.as_query_engine( similarity_top_k=6, node_postprocessors=[ MetadataReplacementPostProcessor(target_metadata_key="window"), ], ) response = query_engine.query("What is the refund policy?") print(response) # Retrieval precision: 1 sentence, so embedding matches focused topic # Context to LLM: 7 sentences, so the model has full context to answer # Common finding: ~10-20% hit-rate lift vs plain chunking with the same top_k
Use SemanticSplitterNodeParser only for messy documents
SemanticSplitterNodeParser finds boundaries via embedding similarity. Best for transcripts, meeting notes, unstructured reports where paragraph breaks do not align with topic shifts.
from llama_index.core.node_parser import SemanticSplitterNodeParser from llama_index.embeddings.openai import OpenAIEmbedding # Semantic splitter needs an embedding model to compute sentence similarities embed_model = OpenAIEmbedding(model="text-embedding-3-small") semantic_splitter = SemanticSplitterNodeParser( buffer_size=1, # sentences to group before comparing breakpoint_percentile_threshold=95, # split at top-5% similarity drops embed_model=embed_model, ) # Ingest a document nodes = semantic_splitter.get_nodes_from_documents(documents) print(f"Semantic splitter produced {len(nodes)} nodes") # --- Trade-offs --- # + Chunks align with topic boundaries # + Great for messy / unstructured text # - Slow: embeds each sentence pair to measure boundary # - Expensive: at scale, embedding cost during ingestion is 2-5x higher # - Chunk sizes vary; may exceed limits without a cap # Recommended pattern: semantic split THEN cap size from llama_index.core.node_parser import SentenceSplitter def safe_semantic_parse(documents, max_chunk_tokens=1500): nodes = semantic_splitter.get_nodes_from_documents(documents) # Cap over-sized chunks with a fallback splitter capper = SentenceSplitter(chunk_size=max_chunk_tokens, chunk_overlap=100) final_nodes = [] for node in nodes: if len(node.text.split()) > max_chunk_tokens * 0.8: # rough proxy # Re-split the oversized chunk sub_nodes = capper.get_nodes_from_documents( [Document(text=node.text, metadata=node.metadata)] ) final_nodes.extend(sub_nodes) else: final_nodes.append(node) return final_nodes nodes = safe_semantic_parse(documents, max_chunk_tokens=1500)
SentenceSplitter is faster AND better. Save semantic splitting for the messy sources where it earns its cost.Prevention checklist
Ship these seven safeguards once and this error stops appearing in your logs.
- Choose the parser based on content type — code deserves
CodeSplitter, Markdown deservesMarkdownNodeParser. - Default chunk sizes: 512-1024 tokens with 64-128 overlap; adjust based on measurement.
- For high-precision retrieval + generous LLM context, use
SentenceWindowNodeParser. - For hierarchical retrieval (small → large), use
HierarchicalNodeParser. - Use the same tokenizer for splitting as for embedding to avoid drift.
- Cap semantic-splitter output with a sentence splitter to prevent runaway chunk sizes.
- A/B test parser choices against a labeled retrieval test set; do not guess.
Frequently asked questions
NodeParser and implement _parse_nodes. Useful for domain-specific formats (SQL schemas, IETF RFCs) where existing parsers do not preserve structure well.Related errors & hubs
Get the weekly AI-error digest
New fixes, provider status recaps, and one deep tutorial — every Tuesday. 8,400+ engineers.