TechWayFit
● Complete Learning Series Python & AI Engineering for .NET Developers

Tokenization & Text Splitting Mechanics

Tech Buddy September 13, 2026 3 min read
Tokenization & Text Splitting Mechanics

Tokenization is the bridge between human text and the numbers that LLMs actually process. Every pricing quote, every context window limit, every embedding dimension — they all ultimately connect back to tokens. Getting tokenization and chunking right is the foundation of effective RAG systems.

What Is a Token?

A token is the smallest unit of text that a language model processes. Tokens are not characters, not words — they're byte-pair encoded subword units. The same text tokenizes differently depending on the model's vocabulary.

import tiktoken
                      
                      # cl100k_base is used by Claude (approximately) and GPT-4
                      enc = tiktoken.get_encoding("cl100k_base")
                      
                      # Tokenize and inspect
                      text = "Retrieval-Augmented Generation is a powerful technique."
                      tokens = enc.encode(text)
                      print(f"Token count: {len(tokens)}")           # 10
                      print(f"Token IDs: {tokens}")                  # [87316, 12, 32,  ...] etc.
                      
                      # Decode back to see token boundaries
                      for token_id in tokens:
                          token_bytes = enc.decode_single_token_bytes(token_id)
                          print(repr(token_bytes.decode("utf-8", errors="replace")))
                      # Output: 'Retrieval', '-', 'Aug', 'mented', ' Generation', ' is', ' a', ' powerful', ' technique', '.'
                      
                      # Common rule of thumb: ~4 characters per token (English prose)
                      chars_per_token = len(text) / len(tokens)
                      print(f"Chars per token: {chars_per_token:.1f}")

Why Tokenization Matters for AI Engineering

  • Cost: OpenAI charges per input + output token. A million-token run at GPT-4o pricing costs ~$2.50 input + ~$10 output. Count tokens before expensive batch runs.
  • Context window limits: Claude 3.5 Sonnet has a 200K token context window. Fit your system prompt + retrieved context + conversation history within that limit.
  • Chunk sizing: When you split documents for RAG, chunk size determines how much context each retrieved piece provides. Too small = insufficient context; too large = retrieved chunks contain irrelevant content.
  • Embedding limits: Embedding models have their own token limits (typically 512–8192 tokens). Chunks must fit within the embedding model's window.

Counting Tokens in Practice

Tokens are the unit models bill and budget by, so counting them is a routine engineering task — for staying under context limits and estimating cost before you send a request. OpenAI's tiktoken library encodes text with the same tokenizer the model uses, so len(enc.encode(text)) gives an exact count rather than the rough "4 characters per token" guess. The helper below wraps this for messages and cost estimation.

import tiktoken
                      from openai import OpenAI
                      
                      enc = tiktoken.get_encoding("cl100k_base")
                      
                      def count_tokens(text: str) -> int:
                          """Estimate token count for a text string."""
                          return len(enc.encode(text))
                      
                      
                      def estimate_request_cost(
                          system_prompt: str,
                          messages: list[dict],
                          max_output_tokens: int = 1024,
                          model: str = "gpt-4o",
                      ) -> dict:
                          """
                          Estimate cost before making an API call.
                          Prices as of 2025 (verify current pricing at openai.com/pricing).
                          """
                          PRICING = {
                              "gpt-4o": {"input": 3.00, "output": 15.00},  # per 1M tokens
                              "gpt-4o-mini-20240307": {"input": 0.25, "output": 1.25},
                              "gpt-4o-20240229": {"input": 15.00, "output": 75.00},
                          }
                      
                          prices = PRICING.get(model, PRICING["gpt-4o"])
                      
                          input_tokens = count_tokens(system_prompt)
                          for msg in messages:
                              input_tokens += count_tokens(msg.get("content", "")) + 4  # overhead per message
                      
                          input_cost = (input_tokens / 1_000_000) * prices["input"]
                          output_cost = (max_output_tokens / 1_000_000) * prices["output"]
                      
                          return {
                              "estimated_input_tokens": input_tokens,
                              "max_output_tokens": max_output_tokens,
                              "estimated_cost_usd": round(input_cost + output_cost, 6),
                          }
                      
                      
                      # Check before an expensive batch run
                      for doc in large_document_batch:
                          estimate = estimate_request_cost(
                              system_prompt=SYSTEM_PROMPT,
                              messages=[{"role": "user", "content": doc["text"]}],
                          )
                          if estimate["estimated_input_tokens"] > 150_000:
                              print(f"Warning: {doc['id']} is very large ({estimate['estimated_input_tokens']} tokens)")

Text Splitting for RAG

Effective RAG depends on splitting documents into chunks that are large enough to carry meaningful context but small enough that retrieved chunks are focused. The naive approach (split every N characters) breaks mid-sentence and ruins retrieval quality.

Character-Based Splitting (Avoid)

The naive way to chunk a document for RAG is to cut it every N characters. It's shown here as the anti-pattern to recognize: it slices through the middle of words and sentences, so each chunk's embedding captures a garbled fragment and retrieval quality suffers. Understanding why this fails motivates the smarter splitters that follow.

# BAD: splits mid-sentence, destroys semantic coherence
                      def naive_split(text: str, chunk_size: int = 1000) -> list[str]:
                          return [text[i:i+chunk_size] for i in range(0, len(text), chunk_size)]
                      
                      # "...retrieval systems work by converting text into vec"
                      # "tor representations called embeddings..." ← broken sentence

Sentence-Aware Splitting

A better approach respects sentence boundaries: split the text into sentences, then pack sentences into chunks until you approach the size limit. Each chunk holds complete thoughts, so its embedding is coherent. The trade-off is that a purely sentence-based splitter struggles with text that has few sentence breaks (code, tables), which is what the recursive splitter next addresses.

import re
                      
                      def sentence_split(
                          text: str,
                          max_tokens: int = 500,
                          overlap_sentences: int = 1,
                      ) -> list[str]:
                          """
                          Split text into chunks at sentence boundaries.
                          Includes sentence overlap between consecutive chunks.
                          """
                          # Split into sentences
                          sentence_pattern = r'(?<=[.!?])\s+'
                          sentences = re.split(sentence_pattern, text.strip())
                          sentences = [s.strip() for s in sentences if s.strip()]
                      
                          chunks = []
                          current_chunk: list[str] = []
                          current_tokens = 0
                      
                          for sentence in sentences:
                              sentence_tokens = count_tokens(sentence)
                      
                              if current_tokens + sentence_tokens > max_tokens and current_chunk:
                                  # Save current chunk
                                  chunks.append(" ".join(current_chunk))
                                  # Keep overlap sentences for next chunk
                                  current_chunk = current_chunk[-overlap_sentences:] if overlap_sentences else []
                                  current_tokens = sum(count_tokens(s) for s in current_chunk)
                      
                              current_chunk.append(sentence)
                              current_tokens += sentence_tokens
                      
                          if current_chunk:
                              chunks.append(" ".join(current_chunk))
                      
                          return chunks

Recursive Character Splitting (Production Standard)

This is the strategy libraries like LangChain default to, and the one you'll ship. It tries to split on the largest natural boundary first — paragraphs — and only falls back to sentences, then words, then characters when a piece is still too big. The result keeps semantically related text together whatever the document's structure, and typically overlaps chunks slightly so context isn't lost at the seams.

class RecursiveTextSplitter:
                          """
                          Split text using a hierarchy of separators.
                          Tries each separator in order, falling back to smaller ones.
                          This is the approach used by LangChain's RecursiveCharacterTextSplitter.
                          """
                      
                          DEFAULT_SEPARATORS = ["\n\n", "\n", ". ", "! ", "? ", ", ", " ", ""]
                      
                          def __init__(
                              self,
                              chunk_size: int = 500,          # in tokens
                              chunk_overlap: int = 50,         # in tokens
                              separators: list[str] | None = None,
                          ):
                              self.chunk_size = chunk_size
                              self.chunk_overlap = chunk_overlap
                              self.separators = separators or self.DEFAULT_SEPARATORS
                      
                          def split_text(self, text: str) -> list[str]:
                              """Recursively split text using the separator hierarchy."""
                              return self._split(text, self.separators)
                      
                          def _split(self, text: str, separators: list[str]) -> list[str]:
                              if not separators:
                                  # Fallback: hard split by token
                                  return self._split_by_tokens(text)
                      
                              separator = separators[0]
                              remaining = separators[1:]
                      
                              if separator and separator in text:
                                  splits = text.split(separator)
                              else:
                                  return self._split(text, remaining)
                      
                              # Merge small splits back together up to chunk_size
                              chunks = []
                              current_parts: list[str] = []
                              current_tokens = 0
                      
                              for part in splits:
                                  part_tokens = count_tokens(part)
                      
                                  if current_tokens + part_tokens <= self.chunk_size:
                                      current_parts.append(part)
                                      current_tokens += part_tokens
                                  else:
                                      if current_parts:
                                          chunk = separator.join(current_parts)
                                          chunks.append(chunk)
                                          # Start next chunk with overlap
                                          overlap_parts = self._get_overlap(current_parts, separator)
                                          current_parts = overlap_parts + [part]
                                          current_tokens = sum(count_tokens(p) for p in current_parts)
                                      else:
                                          # Part itself is too big — recurse with next separator
                                          sub_chunks = self._split(part, remaining)
                                          chunks.extend(sub_chunks)
                      
                              if current_parts:
                                  chunks.append(separator.join(current_parts))
                      
                              return [c for c in chunks if c.strip()]
                      
                          def _get_overlap(self, parts: list[str], sep: str) -> list[str]:
                              """Get the last parts that fit within overlap token budget."""
                              overlap_parts = []
                              tokens = 0
                              for part in reversed(parts):
                                  t = count_tokens(part)
                                  if tokens + t > self.chunk_overlap:
                                      break
                                  overlap_parts.insert(0, part)
                                  tokens += t
                              return overlap_parts
                      
                          def _split_by_tokens(self, text: str) -> list[str]:
                              """Hard split at token boundaries."""
                              tokens = enc.encode(text)
                              chunks = []
                              for i in range(0, len(tokens), self.chunk_size - self.chunk_overlap):
                                  chunk_tokens = tokens[i:i + self.chunk_size]
                                  chunks.append(enc.decode(chunk_tokens))
                              return chunks
                      
                      
                      # Usage
                      splitter = RecursiveTextSplitter(chunk_size=400, chunk_overlap=40)
                      document = "Your long document text here..."
                      chunks = splitter.split_text(document)
                      print(f"Split into {len(chunks)} chunks")
                      for i, chunk in enumerate(chunks[:3]):
                          print(f"\nChunk {i+1} ({count_tokens(chunk)} tokens):\n{chunk[:100]}...")

Key Takeaways

  • Tokens are subword units (~4 characters each for English) — not words, not characters; count them before every API call
  • Use tiktoken to count tokens accurately; the cl100k_base encoding approximates Claude's tokenizer
  • Never split text at fixed character positions — split at semantic boundaries (paragraphs, sentences) to preserve retrieval quality
  • Chunk overlap (typically 10–20% of chunk size) prevents important context from being lost at chunk boundaries
  • Recursive splitting tries coarse separators (paragraphs) first, falling back to fine-grained ones (sentences, spaces) only when needed
  • Chunk sizing affects all downstream quality metrics — too small loses context, too large retrieves unfocused documents

Comments

Loading comments…

Leave a comment