Skip to content

Chapter 5: Chunking Strategies - Fixed vs Semantic Splitting

Theoretical Foundations

Imagine you are tasked with building a sophisticated search engine for a massive library of technical documentation. The raw source material—perhaps a collection of Markdown files describing a complex software API—is a continuous stream of text. A large language model, however, cannot process an entire 500-page manual in a single step. It operates on finite segments of text. The fundamental challenge, therefore, is to intelligently divide this raw text into smaller, meaningful pieces that can be individually processed, stored, and retrieved. This process is chunking. It is not merely a technical necessity; it is the architectural foundation upon which the entire quality of a Retrieval-Augmented Generation (RAG) system rests. The way you slice your data determines the precision of your retrieval and the coherence of your final generated answer.

To understand the critical importance of this step, we must first recall a concept from Book 2, Chapter 4: The Art of the Embedding. There, we established that an embedding is a numerical representation of text—a vector that captures its semantic meaning and contextual nuance. A key property of these embeddings is that they are generated for a segment of text. The quality of the resulting vector is directly dependent on the quality and coherency of the input text segment. If the segment is a jumbled, context-free snippet, the embedding will be a poor, noisy representation of its true meaning. This is where chunking strategies become paramount. We are not just breaking text; we are carefully crafting the "sentences" of our vector space, ensuring each one is a complete, self-contained thought.

Let's explore this through a web development analogy. Consider a large, monolithic JavaScript application. Initially, all the logic—user authentication, data fetching, UI rendering—is bundled into a single, massive file. This is analogous to an unchunked document. While a developer could theoretically read the entire file to understand a single feature, it's inefficient and error-prone. To improve maintainability and performance, we refactor this monolith into a series of discrete, single-responsibility modules or microservices. Each module (auth.js, api.js, components/) has a clear purpose and a well-defined interface.

Chunking is the process of refactoring our raw document into these semantic "modules." Each chunk becomes a self-contained unit of information that we will later "embed" and store in our vector database. The vector index, our specialized data structure for fast similarity search, is like a highly optimized routing system (e.g., a service mesh) that quickly directs a query to the most relevant module. If our modules are poorly defined—if auth.js also contains unrelated UI logic—our routing will be imprecise, and our application's behavior will become unpredictable. Similarly, if our chunks are poorly defined, our vector search will retrieve irrelevant or incomplete information, leading the LLM to generate inaccurate or nonsensical answers.

The Traditional Approach: Fixed-Size Chunking

The most straightforward and computationally simple method is fixed-size chunking. This strategy treats the document as a simple sequence of characters or tokens and splits it into segments of a predetermined, uniform length. It is the "brute-force" approach to data segmentation.

How It Works: Imagine taking a long, continuous rope and cutting it into equal-length pieces using a ruler. The process is mechanical and context-agnostic. In a Node.js environment, this is typically implemented by iterating through the text, counting tokens (or characters), and inserting a delimiter once the limit is reached. A common technique is to use a "sliding window" or "overlap" to mitigate the problem of cutting a single idea in half. For instance, with a chunk size of 512 tokens and an overlap of 128 tokens, the first chunk might be tokens 0-511, the second chunk would be tokens 384-895, and so on. This ensures that the context surrounding a boundary is preserved across adjacent chunks.

The "Why" and the Analogy: The primary advantage of fixed-size chunking is its predictability and speed. It is computationally trivial and guarantees that every vector in your index has the same dimensionality, which can simplify indexing and retrieval logic. It's like dividing a long, continuous wall into equal-sized panels for painting. The process is orderly and easy to manage.

However, this simplicity is also its greatest weakness. It completely ignores the semantic structure of the text. A single, coherent paragraph might be 600 tokens long, while a list of items might only be 50 tokens. A fixed-size chunker doesn't care. It will slice right through the middle of a critical definition, a key function signature, or a nuanced explanation, creating chunks that are semantically incomplete or fragmented.

Consider this analogy: You are given a detailed architectural blueprint and told to cut it into 8x10 inch squares. You might cut right through the middle of a load-bearing wall or a complex electrical schematic, rendering each piece meaningless on its own. The final set of squares might contain some complete rooms, but many will be nonsensical fragments. When a query comes in looking for "living room electrical wiring," the retrieved square might contain half of the living room's layout and half of the kitchen's plumbing, providing the LLM with a confusing and contradictory context. This phenomenon, where a single idea is split across multiple chunks, is known as context fragmentation, and it is the primary enemy of retrieval accuracy in RAG systems.

The Advanced Approach: Semantic Splitting

In contrast to the mechanical nature of fixed-size chunking, semantic splitting is a context-aware, intelligent process. Its goal is to divide the document along its natural logical boundaries, ensuring that each chunk is a complete, self-contained unit of meaning. It prioritizes semantic integrity over uniform size.

How It Works: Semantic splitting leverages the inherent structure of the document. It identifies and uses markers like double newlines in Markdown (which often signify paragraphs or sections), HTML tags (<h1>, <h2>, <p>), or even programmatic structures like function definitions in a code file. The process is hierarchical. It first attempts to split the document into large logical sections (e.g., chapters or major headings). If a section is too large to be embedded effectively (as some embedding models have token limits), it then recursively attempts to split that section into smaller, meaningful units (e.g., paragraphs, lists, or code blocks).

The "Why" and the Analogy: The "why" here is all about preserving semantic coherency. A chunk should represent a single, complete idea. This ensures that the embedding generated for it is a pure, high-fidelity representation of that specific concept. When a user query is embedded, it will be semantically closest to the chunk that represents the same concept, leading to highly precise retrieval.

Let's use a different analogy: Think of semantic splitting as the work of a skilled chef preparing ingredients. A fixed-size chunker is like a machine that simply chops everything into uniform 1-inch cubes. The chef, however, understands the structure of the ingredients. They don't cube a whole onion; they first peel it, then slice it, then dice it. They separate the whites from the yolks of an egg. They mince garlic but julienne carrots. Each cut is deliberate and serves a purpose, preserving the ingredient's unique properties for the final dish. Similarly, semantic splitting respects the document's structure. It keeps a function signature and its body together. It keeps a list item and its description together. It ensures that each "ingredient" we feed to our vector index is prepared correctly, preserving its full flavor and meaning.

This approach directly addresses the context fragmentation problem. Instead of a query for "living room electrical wiring" retrieving two disparate chunks, it retrieves a single, coherent chunk that contains the entire living room section of the blueprint. The LLM receives a clean, complete context, enabling it to synthesize a precise and reliable answer.

Under the Hood: A Visual Comparison

To truly grasp the structural difference, let's visualize the two strategies. Imagine a document with three main sections: "Introduction," "Core Concepts," and "Conclusion." The "Core Concepts" section contains two paragraphs.

The diagram illustrates a document structure with three top-level sections—Introduction, Core Concepts, and Conclusion—where the Core Concepts section is expanded to show two nested paragraph elements.
Hold "Ctrl" to enable pan & zoom

The diagram illustrates a document structure with three top-level sections—Introduction, Core Concepts, and Conclusion—where the Core Concepts section is expanded to show two nested paragraph elements.

This diagram clearly illustrates the structural difference. The fixed-size approach creates artificial boundaries that cut across the document's logical flow, while the semantic approach respects and preserves the author's intended structure.

Evaluating the Impact on Retrieval Accuracy

The choice of chunking strategy has a direct and profound impact on the performance of your RAG pipeline. Let's analyze the trade-offs.

Fixed-Size Chunking: * Retrieval Behavior: It can be surprisingly effective for documents that are already highly structured and have predictable lengths, like news articles or standardized forms. However, for unstructured or complex technical documentation, it often leads to "noisy" retrieval. A query might match a chunk simply because it contains a few keywords, even if the chunk's primary topic is unrelated. The retrieved context is often incomplete, forcing the LLM to either make assumptions or request more information. * Pros: * Simplicity: Easy to implement and fast to run. * Uniformity: Creates a predictable index structure. * Cons: * Context Fragmentation: Breaks up coherent ideas. * Irrelevant Chunks: Can create chunks that are semantically incoherent, containing unrelated topics. * Poor Boundary Handling: May create chunks that start or end in the middle of a critical sentence.

Semantic Splitting: * Retrieval Behavior: This strategy excels at retrieving highly relevant and complete contexts. Because each chunk represents a single, cohesive idea, the embedding is a purer signal of its content. When a query is semantically similar to a concept, it is far more likely to retrieve the exact chunk that contains the full explanation. This dramatically improves the LLM's ability to generate accurate, well-supported answers. * Pros: * High Relevance: Retrieves more complete and contextually appropriate information. * Improved Coherency: The LLM receives cleaner, less fragmented input, leading to higher-quality synthesis. * Reduced Token Waste: Avoids retrieving large chunks of irrelevant text, saving on LLM inference costs. * Cons: * Complexity: More complex to implement, as it requires parsing the document's structure. * Variable Chunk Sizes: Can lead to a mix of very small and very large chunks, which may require additional processing (e.g., merging or re-splitting) to fit embedding model constraints. * Potential for "Over-Splitting": If not carefully tuned, it might create chunks that are too small, losing important surrounding context.

Conclusion: The Strategic Choice

There is no universally "correct" chunking strategy. The optimal choice is a function of your domain, your document types, and the specific retrieval goals of your application. Fixed-size chunking is a pragmatic starting point—simple, fast, and often "good enough" for straightforward use cases. It is the reliable workhorse.

Semantic splitting, however, represents the artisan's approach. It requires more effort and a deeper understanding of the document's anatomy, but the reward is a significant leap in retrieval precision and the overall quality of the RAG system's output. For domains where accuracy is paramount—such as legal, medical, or complex technical support—investing in a sophisticated semantic splitting strategy is not just an optimization; it is a fundamental requirement for building a trustworthy and effective application. The journey from raw text to a high-performing RAG pipeline begins with the thoughtful and deliberate act of chunking. It is the first and most critical step in mastering your data.

Basic Code Example

This example demonstrates the two primary chunking strategies within a Node.js context. We will simulate a document ingestion pipeline for a SaaS application that processes user-uploaded text files. The goal is to split the text into segments suitable for vectorization.

We will use TypeScript to ensure type safety, a critical practice in enterprise JavaScript environments.

The Code

/**
 * @fileoverview A basic demonstration of Fixed-Size and Semantic chunking strategies
 * for a RAG pipeline in a Node.js environment.
 * 
 * Context: SaaS Document Processing
 * 
 * Dependencies: None (Native Node.js APIs only)
 */

// --- 1. Type Definitions ---
// Using immutable interfaces ensures we don't modify data structures in place.

/**
 * Represents a single chunk of text extracted from a document.
 * @property content - The actual text segment.
 * @property metadata - Context about the chunk (source, page number, etc.).
 */
interface TextChunk {
  content: string;
  metadata: {
    source: string;
    chunkIndex: number;
    strategy: 'fixed' | 'semantic';
  };
}

// --- 2. Mock Data ---
// Simulating a document loaded from a database or file system.

const mockDocument: string = `
  Artificial Intelligence (AI) is intelligence demonstrated by machines, as opposed to natural intelligence displayed by animals including humans. Leading AI textbooks define the field as the study of "intelligent agents": any system that perceives its environment and takes actions that maximize its chance of achieving its goals. Some popular accounts use the term "artificial intelligence" to describe machines that mimic "cognitive" functions that humans associate with the human mind, such as "learning" and "problem solving", however, this definition is rejected by major AI researchers.

  AI applications include advanced web search engines (e.g., Google), recommendation systems (used by YouTube, Amazon and Netflix), understanding human speech (such as Siri and Alexa), self-driving cars (e.g., Waymo), automated decision-making and competing at the highest level in strategic game systems (such as chess and Go). As machines become increasingly capable, tasks considered to require "intelligence" are often removed from the definition of AI, a phenomenon known as the AI effect.

  The field of AI research was born at a workshop at Dartmouth College in 1956. Those who attended became the founders and leaders of AI research. They and their students produced programs that the press described as "astonishing": computers were learning checker strategies, solving word problems in algebra, proving logical theorems and speaking English. By the middle of the 1960s, research in the U.S. was heavily funded by the Department of Defense and laboratories had been established around the world.
`.trim();

// --- 3. Logic Implementation ---

/**
 * STRATEGY 1: Fixed-Size Chunking
 * 
 * How it works:
 * 1. Takes a raw string and a maximum character length.
 * 2. Splits the text strictly by character count.
 * 3. Ignores sentence structure or semantic boundaries.
 * 
 * Pros: Simple, predictable, fast.
 * Cons: Can cut sentences in half, losing context.
 * 
 * @param text - The raw document text.
 * @param maxSize - Maximum characters per chunk.
 * @param sourceName - Name of the file/source.
 * @returns Array of TextChunks
 */
function fixedSizeChunking(
  text: string, 
  maxSize: number, 
  sourceName: string
): TextChunk[] {
  const chunks: TextChunk[] = [];
  let currentIndex = 0;

  // Loop until we've processed the entire text
  while (currentIndex < text.length) {
    // Extract a substring of the max size
    let chunkContent = text.substring(currentIndex, currentIndex + maxSize);

    // Edge Case: If we are at the end, just take what's left.
    // If we are in the middle, we might be cutting a word. 
    // A simple fix is to find the last space before the limit (greedy approach).
    if (currentIndex + maxSize < text.length) {
      const lastSpace = chunkContent.lastIndexOf(' ');
      if (lastSpace > -1) {
        chunkContent = chunkContent.substring(0, lastSpace);
      }
    }

    chunks.push({
      content: chunkContent,
      metadata: {
        source: sourceName,
        chunkIndex: chunks.length,
        strategy: 'fixed'
      }
    });

    // Advance the index. 
    // Note: We add the length of the chunk we just pushed, 
    // not strictly 'maxSize', because we might have trimmed whitespace.
    currentIndex += chunkContent.length;

    // Skip the space we split on to avoid leading spaces in the next chunk
    if (text[currentIndex] === ' ') {
      currentIndex++;
    }
  }

  return chunks;
}

/**
 * STRATEGY 2: Semantic (Sentence-Aware) Chunking
 * 
 * How it works:
 * 1. Breaks text into logical units (sentences or paragraphs) first.
 * 2. Groups these units until the max size is reached.
 * 3. Preserves semantic integrity (no cut sentences).
 * 
 * Pros: Higher quality embeddings, better context for the LLM.
 * Pros: Slightly more complex logic.
 * 
 * @param text - The raw document text.
 * @param maxSize - Maximum characters per chunk.
 * @param sourceName - Name of the file/source.
 * @returns Array of TextChunks
 */
function semanticChunking(
  text: string, 
  maxSize: number, 
  sourceName: string
): TextChunk[] {
  const chunks: TextChunk[] = [];

  // Step 1: Split by sentences (using regex for punctuation)
  // This regex looks for periods, exclamation marks, or question marks followed by whitespace.
  const sentences = text.match(/[^.!?]+[.!?]+/g) || [];

  let currentChunkContent = '';

  for (const sentence of sentences) {
    const sentenceTrimmed = sentence.trim();

    // Check if adding the next sentence exceeds the limit
    if (currentChunkContent.length + sentenceTrimmed.length > maxSize) {
      // If current chunk has content, push it
      if (currentChunkContent.length > 0) {
        chunks.push({
          content: currentChunkContent,
          metadata: {
            source: sourceName,
            chunkIndex: chunks.length,
            strategy: 'semantic'
          }
        });
        currentChunkContent = '';
      }

      // If a single sentence is longer than maxSize, we must split it anyway
      // (Rare in text, common in code or logs)
      if (sentenceTrimmed.length > maxSize) {
        const words = sentenceTrimmed.split(' ');
        let tempSentence = '';
        for (const word of words) {
          if (tempSentence.length + word.length > maxSize) {
             chunks.push({
              content: tempSentence,
              metadata: {
                source: sourceName,
                chunkIndex: chunks.length,
                strategy: 'semantic'
              }
            });
            tempSentence = '';
          }
          tempSentence += word + ' ';
        }
        currentChunkContent = tempSentence;
      } else {
        currentChunkContent = sentenceTrimmed;
      }
    } else {
      // Append to current chunk
      currentChunkContent += (currentChunkContent ? ' ' : '') + sentenceTrimmed;
    }
  }

  // Push the final remaining chunk
  if (currentChunkContent.length > 0) {
    chunks.push({
      content: currentChunkContent,
      metadata: {
        source: sourceName,
        chunkIndex: chunks.length,
        strategy: 'semantic'
      }
    });
  }

  return chunks;
}

// --- 4. Execution / Simulation ---

/**
 * Main entry point for the simulation.
 * Demonstrates the output difference between the two strategies.
 */
function runSimulation() {
  console.log('--- RUNNING CHUNKING SIMULATION ---\n');

  // Define a max chunk size (e.g., for an embedding model with 512 tokens)
  const MAX_SIZE = 300; 

  // 1. Run Fixed-Size Strategy
  console.log(`[1] Fixed-Size Strategy (Max: ${MAX_SIZE} chars)`);
  const fixedChunks = fixedSizeChunking(mockDocument, MAX_SIZE, 'doc_001');

  // Log the first 2 chunks to show the difference
  fixedChunks.slice(0, 2).forEach((chunk, idx) => {
    console.log(`   Chunk ${idx + 1}: ${chunk.content.substring(0, 50)}... (Len: ${chunk.content.length})`);
  });
  console.log(`   Total Chunks: ${fixedChunks.length}\n`);

  // 2. Run Semantic Strategy
  console.log(`[2] Semantic Strategy (Max: ${MAX_SIZE} chars)`);
  const semanticChunks = semanticChunking(mockDocument, MAX_SIZE, 'doc_001');

  semanticChunks.slice(0, 2).forEach((chunk, idx) => {
    console.log(`   Chunk ${idx + 1}: ${chunk.content.substring(0, 50)}... (Len: ${chunk.content.length})`);
  });
  console.log(`   Total Chunks: ${semanticChunks.length}\n`);

  // 3. Comparison Analysis
  console.log('--- ANALYSIS ---');
  console.log(`Fixed strategy produced ${fixedChunks.length} chunks.`);
  console.log(`Semantic strategy produced ${semanticChunks.length} chunks.`);
  console.log('Notice how Semantic chunks end with complete sentences, preserving meaning for the vector embedding.');
}

// Execute the simulation
runSimulation();

Line-by-Line Explanation

1. Type Definitions & Mock Data

  • interface TextChunk: We define a strict shape for our data. In a real SaaS app, this would be a database model. We enforce Immutable State Management by treating these objects as read-only once created.
  • mockDocument: A string variable simulating a document loaded from a database. It contains multiple paragraphs with varying sentence lengths.

2. Fixed-Size Chunking Logic (fixedSizeChunking)

This function implements the "naive" splitting method.

  1. Initialization: We create an empty array chunks and set currentIndex to 0.
  2. The Loop: The while loop runs as long as we haven't reached the end of the string.
  3. Substring Extraction:
    • text.substring(currentIndex, currentIndex + maxSize) grabs a slice of text.
    • Critical Detail: If we blindly cut at maxSize, we might sever a word in the middle (e.g., "Intelli" instead of "Intelligence"). This degrades embedding quality.
  4. Greedy Splitting (The Fix):
    • We check if we are not at the very end of the document.
    • chunkContent.lastIndexOf(' ') finds the nearest space before the limit. We slice the string there to ensure we end on a word boundary.
  5. State Update:
    • We push the cleaned chunk to the array.
    • We increment currentIndex by the actual length of the chunk we just saved (which might be slightly less than maxSize due to the space trimming).
    • We skip the next space character to avoid the next chunk starting with a leading whitespace.

3. Semantic Chunking Logic (semanticChunking)

This function prioritizes context over strict character counts.

  1. Sentence Tokenization:
    • text.match(/[^.!?]+[.!?]+/g) is a Regular Expression that splits the text into sentences. It looks for characters that are not sentence terminators, followed by a terminator.
    • Why this matters: Embeddings capture the meaning of a sentence. Splitting a sentence in half creates two vectors that are semantically incomplete.
  2. Iterative Grouping:
    • We iterate through the array of sentences.
    • We maintain a currentChunkContent buffer.
  3. Boundary Logic:
    • We check: currentChunkContent.length + sentenceTrimmed.length > maxSize.
    • If adding the next sentence exceeds the limit, we finalize the current buffer and push it as a chunk. Then, we start a new buffer with the current sentence.
  4. Edge Case Handling:
    • If a single sentence is longer than maxSize (common in code snippets or long quotes), the logic detects this. It falls back to a word-splitting approach to ensure the data is still processed, though this is rare in natural language.
  5. Final Flush: After the loop, any remaining text in currentChunkContent is pushed to the array.

4. Execution & Simulation

  • runSimulation: This function acts as the "Controller" in our MVC pattern.
  • Comparison: It runs both strategies on the same data and logs the results. You will observe that the Fixed strategy produces more chunks (because it cuts aggressively) and potentially breaks sentences. The Semantic strategy produces fewer, more contextually rich chunks.

Visualizing the Process

The following diagram illustrates the flow of data through the Semantic Chunking pipeline, highlighting the decision points.

This diagram illustrates the Semantic Chunking pipeline, which transforms raw text into fewer, contextually rich chunks by grouping semantically similar sentences and dynamically adjusting boundaries based on meaning.
Hold "Ctrl" to enable pan & zoom

This diagram illustrates the Semantic Chunking pipeline, which transforms raw text into fewer, contextually rich chunks by grouping semantically similar sentences and dynamically adjusting boundaries based on meaning.

Common Pitfalls

When implementing chunking in a production Node.js environment (e.g., Vercel Serverless Functions or a Docker container), watch out for these specific issues:

  1. Vercel/Serverless Timeouts:

    • Issue: Processing very large documents (e.g., 100MB PDFs converted to text) synchronously in a serverless function will hit the execution timeout limit (usually 10s for Vercel Hobby plans).
    • Solution: Implement streaming. Do not load the entire file into memory. Read the file stream, buffer text until a chunk boundary is reached, and yield chunks. Use libraries like pdf-parse with streams or node-html-parser for async processing.
  2. Async/Await Loop Deadlocks:

    • Issue: If you are processing a batch of documents using forEach with async/await, the loop will not wait for the promises to resolve. You might attempt to write chunks to a vector database before the chunking is complete.
    • Solution: Always use for...of loops or Promise.all when processing arrays asynchronously.
    // ❌ BAD: Fire and forget
    documents.forEach(async (doc) => {
       const chunks = await chunkDocument(doc); // Execution continues immediately
    });
    
    // ✅ GOOD: Sequential processing
    for (const doc of documents) {
       const chunks = await chunkDocument(doc);
       await saveToDB(chunks);
    }
    
  3. Token vs. Character Mismatch:

    • Issue: This example uses characters for simplicity. However, LLMs (like GPT-4) and embedding models use tokens. 1000 characters might be 250 tokens in English, but 500 tokens in Japanese or code.
    • Solution: In production, do not rely on string.length. Use a tokenizer library (like gpt-tokenizer or tiktoken) to calculate the exact token count before finalizing a chunk. If a chunk exceeds the model's context limit (e.g., 8192 tokens), the API call will fail.
  4. Hallucinated Metadata:

    • Issue: When splitting text, developers often lose track of the source document or page number. Later, when the RAG pipeline retrieves a chunk, the user sees the answer but cannot locate the source document.
    • Solution: Strictly enforce the metadata interface. Pass the source ID down through every function call. Never generate a chunk without a reference to its origin.

The chapter continues with advanced code, exercises and solutions with analysis, you can find them on the ebook on Leanpub.com or Amazon


Loading knowledge check...



Code License: All code examples are released under the MIT License. Github repo.

Content Copyright: Copyright © 2026 Edgar Milvus | Privacy & Cookie Policy. All rights reserved.

All textual explanations, original diagrams, and illustrations are the intellectual property of the author. To support the maintenance of this site via AdSense, please read this content exclusively online. Copying, redistribution, or reproduction is strictly prohibited.