Part II: Discovery Through Software Engineering and Vibe Coding
Chapter 11: Context Engineering at Repository Scale

11.2 Chunking and Summarization

"I tried splitting by blank lines. Then by character count. Then by regex. It turns out code has structure, and ignoring it was the problem all along."

A Tokenizer That Discovered Syntax Trees
The Big Picture

Section 11.1 established that a coding agent can see only a small fraction of any large codebase. Before we can retrieve the right fraction, we need two preprocessing steps: chunking (breaking source files into units that can be individually scored and selected) and summarization (compressing those units so more of them fit in the context window). This section builds both capabilities from scratch using tree-sitter for parsing, then shows the library shortcuts that production systems use.

1. Why Naive Chunking Fails

Imagine handing a colleague the top half of a function: the signature, the docstring, and seven lines of setup, but nothing after the for loop begins. Then you hand another colleague the bottom half: a loop body that references variables declared nowhere in the fragment, followed by a return statement whose meaning depends on the missing setup. Neither person can understand the code, and neither piece is useful in isolation. That is exactly what happens when a naive chunker splits source files into fixed-size token windows. The simplest such strategy uses windows of \(k\) tokens, optionally overlapping by \(k/2\) tokens. This works reasonably well for natural-language documents, where paragraph boundaries are somewhat arbitrary. For code, fixed-size chunking is catastrophic. A 512-token window might split a function signature from its body, separate a class definition from its methods, or cut a multi-line conditional in half. The resulting chunks are syntactically invalid and semantically incoherent.

Chunking, in retrieval-augmented generation, partitions a source document into discrete, self-contained segments. Each segment can be independently embedded, indexed, and retrieved. Chunk quality matters because every downstream step (embedding, ranking, context packing) depends on whether each chunk represents a coherent semantic unit. A chunk that mixes unrelated logic produces a noisy embedding that matches the wrong queries. The core mechanism: a splitter walks the document according to some boundary rule (character count, line breaks, or parse-tree nodes) and emits one segment per boundary. Use syntax-aware chunking (described below) whenever the source has formal grammar (code, markup, structured data). Reserve fixed-size or sentence-level splitting for unstructured prose where no grammar is available.

What Goes Wrong in Practice

Consider what happens when you chunk a 60-line class with a 30-line window. The first chunk contains the class declaration, __init__, and half of the second method. The second chunk contains the rest of the second method and the start of the third. Neither chunk is a self-contained unit. An embedding model (a neural network that maps text to fixed-length numerical vectors for similarity search) trained on these chunks learns a representation of code fragments, not code concepts. A retrieval system that returns these chunks gives the agent code it cannot understand without the missing halves.

The solution is to chunk along syntactic boundaries defined by the language's parse tree. Functions, classes, and top-level assignment blocks are natural chunks because they reflect the programmer's intent about what constitutes a logical unit. Figure 11.2 illustrates the complete pipeline that implements this idea, from raw source files through syntax-aware chunking and summarization to a searchable chunk store.

Source Files tree-sitter Parser Hierarchical Chunker Classes, Methods, Functions Summarizer Signatures, Docstrings, LLM Chunk Store (SQLite)
Figure 11.2: The chunking and summarization pipeline. Source files are parsed by tree-sitter into syntax trees, split into hierarchical chunks (classes, methods, standalone functions), compressed by a summarizer (signatures, docstrings, or LLM-generated descriptions), and persisted in a SQLite-backed chunk store for downstream retrieval.

2. Syntactic Chunking with Tree-sitter

When retrieval feeds a coding agent broken fragments, the agent generates code that references undefined variables, calls functions with wrong signatures, or silently drops logic from incomplete loops. Getting chunk boundaries right is the difference between an agent that fixes bugs and one that creates them.

Turning the idea of "chunk along syntactic boundaries" into working code requires a parser that understands every language your repository contains. Tree-sitter is an incremental parsing library that builds a concrete syntax tree (CST) for source code in over 100 languages. Unlike an abstract syntax tree (AST) produced by Python's ast module, a CST preserves every character of the original source, including whitespace and comments. This makes tree-sitter ideal for chunking: we can extract syntactically complete units and reconstruct the original code exactly. In short: Let the parse tree, not a token counter, decide where one unit of meaning ends and the next begins.

import tree_sitter_python as tspython
from tree_sitter import Language, Parser

# Initialize the Python parser
PY_LANGUAGE = Language(tspython.language())
parser = Parser(PY_LANGUAGE)

def parse_source(source: str) -> "tree_sitter.Tree":
    """Parse Python source code into a concrete syntax tree."""
    return parser.parse(bytes(source, "utf-8"))

# Parse a simple example
source = """
class DataLoader:
    def __init__(self, path: str):
        self.path = path
        self.data = None

    def load(self) -> list[dict]:
        import json
        with open(self.path) as f:
            self.data = json.load(f)
        return self.data

def preprocess(records: list[dict]) -> list[dict]:
    return [r for r in records if r.get("valid")]
"""

tree = parse_source(source)
root = tree.root_node
print(f"Root type: {root.type}")
print(f"Children: {[child.type for child in root.children]}")
Listing 11.6: Parsing Python source with tree-sitter to obtain a concrete syntax tree for chunking.

With the syntax tree in hand, we walk it to extract top-level definitions as chunks. Each chunk preserves the complete syntactic unit: the full class with all its methods, or the full standalone function.

from dataclasses import dataclass

@dataclass
class CodeChunk:
    """A syntactically complete unit of code extracted from a source file."""
    file_path: str
    chunk_type: str          # "class", "function", "assignment", "import_block"
    name: str                # symbol name (class/function name, or variable name)
    start_line: int
    end_line: int
    content: str             # the raw source code
    token_estimate: int      # estimated token count

    @property
    def id(self) -> str:
        return f"{self.file_path}:{self.name}"

# Node types that represent top-level definitions in Python
CHUNK_NODE_TYPES = {
    "class_definition",
    "function_definition",
    "decorated_definition",
    "expression_statement",  # top-level assignments
}

def extract_chunks(source: str, file_path: str) -> list[CodeChunk]:
    """Extract syntactically complete chunks from Python source."""
    tree = parse_source(source)
    chunks = []

    for node in tree.root_node.children:
        if node.type not in CHUNK_NODE_TYPES:
            continue

        content = source[node.start_byte:node.end_byte]

        # Determine chunk type and name
        if node.type == "class_definition":
            name_node = node.child_by_field_name("name")
            chunk_type = "class"
        elif node.type == "function_definition":
            name_node = node.child_by_field_name("name")
            chunk_type = "function"
        elif node.type == "decorated_definition":
            # The actual definition is a child of the decorator wrapper
            inner = [c for c in node.children
                     if c.type in ("class_definition", "function_definition")]
            if inner:
                name_node = inner[0].child_by_field_name("name")
                chunk_type = "class" if inner[0].type == "class_definition" else "function"
            else:
                name_node = None
                chunk_type = "decorated"
        else:
            name_node = None
            chunk_type = "assignment"

        name = (source[name_node.start_byte:name_node.end_byte]
                if name_node else f"block_{node.start_point[0]}")

        chunks.append(CodeChunk(
            file_path=file_path,
            chunk_type=chunk_type,
            name=name,
            start_line=node.start_point[0] + 1,
            end_line=node.end_point[0] + 1,
            content=content,
            token_estimate=len(content.split()) * 2,  # rough estimate
        ))

    return chunks

# Demo
chunks = extract_chunks(source, "data_loader.py")
for chunk in chunks:
    print(f"  [{chunk.chunk_type}] {chunk.name} "
          f"(lines {chunk.start_line}-{chunk.end_line}, ~{chunk.token_estimate} tokens)")
Listing 11.7: Extracting syntactically complete chunks by walking top-level tree-sitter node types such as class_definition and function_definition.
Key Insight: Chunk Granularity Is a Trade-off

Coarse chunks (entire classes) preserve internal coherence but consume large portions of the context budget. Fine chunks (individual methods) are budget-friendly but lose the class-level context that explains how methods relate. The optimal granularity depends on the task: for code completion within a method, a fine-grained chunk of that method plus its class signature suffices; for refactoring a class hierarchy, you need coarse chunks of multiple classes. The hierarchical chunking strategy in the next subsection lets you serve both use cases from a single index.

Common Misconception

A frequent misconception is that smaller chunks are always better because they save tokens and let you pack more pieces into the context window. In reality, a chunk that is too small loses the surrounding context that gives it meaning: a five-line method body without its class signature, imports, or sibling methods can be ambiguous or misleading to both the embedding model and the consuming agent. The goal is not to minimize chunk size but to maximize semantic self-containedness per chunk, which often means keeping logically coupled code together even at the cost of larger individual chunks.

3. Hierarchical Chunking

Real code has nested structure: a module contains classes, classes contain methods, methods contain blocks. A hierarchical chunking strategy preserves this nesting by producing chunks at multiple granularity levels, linked by parent-child relationships. When the retriever selects a method-level chunk, the packer can optionally include the parent class signature for context, spending only the tokens needed for the signature rather than the entire class body. Figure 11.2.1 illustrates hierarchical chunking with parent-child chunk relationships.

hierarchical chunking with parent-child chunk relationships
Figure 11.2.1: Hierarchical chunking decomposes a source file into a multi-level tree of parent and child chunks, enabling retrieval at different granularity levels while preserving structural context through parent signatures.

Checkpoint

So far: naive fixed-size chunking fails because it ignores code structure; syntactic chunking uses tree-sitter to extract complete functions and classes as chunks; hierarchical chunking adds parent-child links so the retriever can include a class signature when it returns a method, balancing context against token budget.

Mental Model

Think of hierarchical chunking like a library's catalog system. A library does not store books as loose pages (too fine) or as entire shelving units (too coarse). Instead, it indexes at multiple levels: the building has floors, each floor has sections, each section has shelves, and each shelf has individual books. When a patron asks for a specific recipe, the catalog directs them to the correct floor (the cookbook section), the right shelf (French cuisine), and the exact book. Crucially, the catalog card for the book also records which shelf and section it belongs to, so the patron can browse neighbors for related material. Hierarchical chunking works the same way: each method "book" carries a reference to its parent class "shelf," so the retrieval system can include the class signature as context without dragging along the entire shelf of code.

@dataclass
class HierarchicalChunk:
    """A chunk with parent-child relationships for multi-granularity retrieval."""
    chunk: CodeChunk
    children: list["HierarchicalChunk"]
    parent_id: str | None = None

    @property
    def signature(self) -> str:
        """Extract just the signature (first line + docstring) for compact representation."""
        lines = self.chunk.content.split("\n")
        sig_lines = [lines[0]]  # def/class line

        # Include docstring if present
        in_docstring = False
        for line in lines[1:]:
            stripped = line.strip()
            if stripped.startswith('"""') or stripped.startswith("'''"):
                sig_lines.append(line)
                if in_docstring or stripped.count('"""') >= 2 or stripped.count("'''") >= 2:
                    break
                in_docstring = True
            elif in_docstring:
                sig_lines.append(line)
            else:
                break

        return "\n".join(sig_lines)

def build_hierarchy(source: str, file_path: str) -> list[HierarchicalChunk]:
    """Build a hierarchical chunk tree from Python source."""
    tree = parse_source(source)
    result = []

    for node in tree.root_node.children:
        if node.type == "class_definition" or (
            node.type == "decorated_definition" and
            any(c.type == "class_definition" for c in node.children)
        ):
            # Get the class node (might be wrapped in decorator)
            class_node = node if node.type == "class_definition" else next(
                c for c in node.children if c.type == "class_definition"
            )
            class_name = source[class_node.child_by_field_name("name").start_byte:
                                class_node.child_by_field_name("name").end_byte]

            class_content = source[node.start_byte:node.end_byte]
            class_chunk = CodeChunk(
                file_path=file_path, chunk_type="class", name=class_name,
                start_line=node.start_point[0] + 1, end_line=node.end_point[0] + 1,
                content=class_content, token_estimate=len(class_content.split()) * 2,
            )

            # Extract methods as children
            children = []
            body = class_node.child_by_field_name("body")
            if body:
                for child in body.children:
                    if child.type in ("function_definition", "decorated_definition"):
                        func_node = child if child.type == "function_definition" else next(
                            (c for c in child.children if c.type == "function_definition"), None
                        )
                        if func_node is None:
                            continue
                        method_name = source[func_node.child_by_field_name("name").start_byte:
                                             func_node.child_by_field_name("name").end_byte]
                        method_content = source[child.start_byte:child.end_byte]
                        method_chunk = CodeChunk(
                            file_path=file_path, chunk_type="method",
                            name=f"{class_name}.{method_name}",
                            start_line=child.start_point[0] + 1,
                            end_line=child.end_point[0] + 1,
                            content=method_content,
                            token_estimate=len(method_content.split()) * 2,
                        )
                        children.append(HierarchicalChunk(
                            chunk=method_chunk, children=[], parent_id=class_chunk.id
                        ))

            result.append(HierarchicalChunk(
                chunk=class_chunk, children=children, parent_id=None
            ))

        elif node.type == "function_definition":
            func_name = source[node.child_by_field_name("name").start_byte:
                               node.child_by_field_name("name").end_byte]
            content = source[node.start_byte:node.end_byte]
            chunk = CodeChunk(
                file_path=file_path, chunk_type="function", name=func_name,
                start_line=node.start_point[0] + 1, end_line=node.end_point[0] + 1,
                content=content, token_estimate=len(content.split()) * 2,
            )
            result.append(HierarchicalChunk(chunk=chunk, children=[], parent_id=None))

    return result
Listing 11.8: Building a hierarchical chunk tree that links each method to its parent class for multi-granularity retrieval.
Practical Example: Chunking a Django Application

Consider a Django REST API with 200 files across models/, views/, serializers/, and tests/. Flat syntactic chunking produces roughly 1,500 chunks (functions and classes). Hierarchical chunking produces the same 1,500 leaf chunks plus 180 class-level parent chunks and a module-level summary for each file, totaling about 1,880 indexed units. When the agent needs to fix a serializer validation bug, the retriever first matches the relevant serializer class at the coarse level, then drills into the specific validate_* method at the fine level. The packer includes the method body (the leaf chunk) plus the parent class signature (compact, roughly 5 lines), giving the agent both the specific code to edit and the class context to understand it. Without hierarchical chunking, the agent would either get the entire 200-line serializer class (wasting budget) or the isolated method (missing context).

4. Summarization Strategies

Chunking breaks code into retrievable units. Summarization compresses those units so that more of them fit in the context window. There are three levels of summarization, each trading fidelity for compression (see the Summarizer stage in Figure 11.2):

4.1 Signature Extraction

The lightest summarization extracts only the function or class signature: the name, parameters, type annotations, and return type. A 50-line function compresses to 1-2 lines. This is the approach used by integrated development environment (IDE) "outline" views, and it is often sufficient for an agent to decide whether a function is relevant and how to call it.

def extract_signature(chunk: CodeChunk) -> str:
    """Extract the signature (declaration + docstring) from a code chunk."""
    lines = chunk.content.strip().split("\n")
    signature_lines = []

    # Collect the definition line(s), handling multi-line signatures
    paren_depth = 0
    for line in lines:
        signature_lines.append(line)
        paren_depth += line.count("(") - line.count(")")
        if paren_depth <= 0 and (":" in line or "->" in line):
            break

    # Collect the docstring if present
    remaining = lines[len(signature_lines):]
    in_docstring = False
    for line in remaining:
        stripped = line.strip()
        if not in_docstring and (stripped.startswith('"""') or stripped.startswith("'''")):
            signature_lines.append(line)
            if stripped.count('"""') >= 2 or stripped.count("'''") >= 2:
                break  # single-line docstring
            in_docstring = True
        elif in_docstring:
            signature_lines.append(line)
            if '"""' in stripped or "'''" in stripped:
                break
        else:
            break

    return "\n".join(signature_lines)

# Compression ratio example
full_code = '''def compute_attention(
    queries: torch.Tensor,
    keys: torch.Tensor,
    values: torch.Tensor,
    mask: torch.Tensor | None = None,
    dropout_p: float = 0.0,
) -> torch.Tensor:
    """Compute scaled dot-product attention.

    Args:
        queries: (batch, heads, seq_len, d_k)
        keys: (batch, heads, seq_len, d_k)
        values: (batch, heads, seq_len, d_v)
        mask: optional attention mask
        dropout_p: dropout probability

    Returns:
        Attention output tensor of shape (batch, heads, seq_len, d_v)
    """
    d_k = queries.size(-1)
    scores = torch.matmul(queries, keys.transpose(-2, -1)) / math.sqrt(d_k)
    if mask is not None:
        scores = scores.masked_fill(mask == 0, float("-inf"))
    weights = torch.softmax(scores, dim=-1)
    if dropout_p > 0.0:
        weights = torch.dropout(weights, p=dropout_p, train=True)
    return torch.matmul(weights, values)'''

chunk = CodeChunk("attention.py", "function", "compute_attention", 1, 25,
                  full_code, len(full_code.split()) * 2)
sig = extract_signature(chunk)
ratio = len(full_code) / len(sig)
print(f"Compression ratio: {ratio:.1f}x")
Listing 11.9: Extracting the function signature and docstring from a compute_attention chunk to demonstrate 2-5x compression.

4.2 Docstring and Comment Extraction

When signatures are too terse, extracting docstrings and inline comments provides a middle ground. Comments encode the programmer's intent ("why"), which is often more useful for relevance scoring than the implementation details ("how"). A function with the comment # Apply Kruskal's algorithm for minimum spanning tree is immediately recognizable as graph-related, even if the variable names are opaque.

import ast

def extract_docstrings(source: str) -> dict[str, str]:
    """Extract all docstrings from a Python module, keyed by qualified name."""
    tree = ast.parse(source)
    docstrings = {}

    for node in ast.walk(tree):
        if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
            docstring = ast.get_docstring(node)
            if docstring:
                docstrings[node.name] = docstring

    # Module-level docstring
    module_docstring = ast.get_docstring(tree)
    if module_docstring:
        docstrings["__module__"] = module_docstring

    return docstrings

def extract_comments(source: str) -> list[str]:
    """Extract all comments from Python source code."""
    comments = []
    for line in source.split("\n"):
        stripped = line.strip()
        if stripped.startswith("#"):
            comments.append(stripped[1:].strip())
        elif "#" in stripped:
            # Inline comment (simple heuristic; ignores # inside strings)
            idx = stripped.rfind("#")
            comment = stripped[idx + 1:].strip()
            if comment:
                comments.append(comment)
    return comments
Listing 11.10: Using Python's ast module to extract docstrings and a line scanner to collect inline comments for semantic summaries.

4.3 LLM-Generated Summaries

Signatures and comments capture structure and intent, but they cannot explain complex interactions between components or describe undocumented edge cases. The most powerful (and most expensive) summarization strategy uses an LLM to generate natural-language descriptions of code chunks. The LLM reads the full implementation and produces a 2-3 sentence summary covering purpose, algorithm, key dependencies, and edge cases. These summaries excel at embedding-based retrieval because natural-language queries match natural-language summaries more easily than raw code. The trade-off: each chunk requires an LLM call during indexing.

Real-World Application: Sourcegraph Code Intelligence
Real-World Application: Sourcegraph Code Intelligence
SUMMARY_PROMPT = """Summarize this code in 2-3 sentences. State:
1. What the code does (purpose and algorithm)
2. Key inputs and outputs (types and shapes)
3. Important dependencies or side effects

Code:
```python
{code}
```

Summary:"""

def generate_summary(code: str, llm_client) -> str:
    """Generate an LLM summary for a code chunk.

    This is called during indexing (offline), not during retrieval.
    Cost: ~500 input tokens + ~100 output tokens per chunk.
    """
    prompt = SUMMARY_PROMPT.format(code=code)
    response = llm_client.complete(prompt, max_tokens=150, temperature=0.0)
    return response.text.strip()

# For a 1,500-chunk codebase at $0.003/1K input tokens (circa 2023):
# Cost estimate: 1500 chunks * 500 tokens * $0.003/1K = $2.25
# As of 2025, lightweight models such as GPT-4o-mini and Claude 3.5 Haiku
# have reduced per-token costs further, making this even cheaper.
# This is a one-time indexing cost; re-index only when code changes.
Listing 11.11: Prompt template and wrapper for generating LLM summaries of code chunks during offline indexing.

Research Frontier

Aider's "repository map" system (Gauthier, 2024) demonstrated that an LLM can chunk and summarize an entire repository into a compact, navigable outline using tree-sitter tags, then dynamically expand only the relevant sections into the context window. This approach, described in the Aider technical report, reportedly achieves near-oracle retrieval performance on the SWE-bench benchmark (a standardized test suite of real GitHub issues used to evaluate autonomous code-generation agents) by letting the model itself decide which chunks to "zoom into" across multiple turns. More recently, the 2025 wave of "code graph" systems (such as Sourcegraph's SCIP-based indexing and Cursor's codebase-wide semantic index) go further by building a cross-file dependency graph over chunks, so the retriever can follow import chains and call hierarchies rather than relying on embedding similarity alone. These graph-augmented approaches reduce the false-negative rate (missing a critical dependency that lives in another file) that plagues flat chunk-and-embed pipelines. The trade-off is indexing complexity: building and maintaining a cross-file graph requires incremental re-parsing on every commit, which tools like Aider and Cursor now handle with tree-sitter's incremental parsing mode (a technique where the parser updates only the parts of the syntax tree affected by an edit, rather than re-parsing the entire file).

5. Building a Chunk Store

With chunking and summarization in place, we assemble them into a persistent chunk store: a structured database of all chunks in the repository, their summaries, and their relationships. The chunk store is the foundation that the retrieval system of Section 11.3 queries against.

import json
import sqlite3
from pathlib import Path

class ChunkStore:
    """Persistent storage for code chunks and their summaries."""

    def __init__(self, db_path: str):
        self.conn = sqlite3.connect(db_path)
        self._create_tables()

    def _create_tables(self):
        self.conn.executescript("""
            CREATE TABLE IF NOT EXISTS chunks (
                id TEXT PRIMARY KEY,
                file_path TEXT NOT NULL,
                chunk_type TEXT NOT NULL,
                name TEXT NOT NULL,
                start_line INTEGER,
                end_line INTEGER,
                content TEXT NOT NULL,
                token_estimate INTEGER,
                parent_id TEXT,
                FOREIGN KEY (parent_id) REFERENCES chunks(id)
            );
            CREATE TABLE IF NOT EXISTS summaries (
                chunk_id TEXT PRIMARY KEY,
                signature TEXT,
                docstring TEXT,
                llm_summary TEXT,
                FOREIGN KEY (chunk_id) REFERENCES chunks(id)
            );
            CREATE INDEX IF NOT EXISTS idx_chunks_file ON chunks(file_path);
            CREATE INDEX IF NOT EXISTS idx_chunks_type ON chunks(chunk_type);
            CREATE INDEX IF NOT EXISTS idx_chunks_parent ON chunks(parent_id);
        """)

    def upsert_chunk(self, chunk: CodeChunk, parent_id: str | None = None):
        """Insert or update a chunk in the store."""
        self.conn.execute(
            """INSERT OR REPLACE INTO chunks
               (id, file_path, chunk_type, name, start_line, end_line,
                content, token_estimate, parent_id)
               VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
            (chunk.id, chunk.file_path, chunk.chunk_type, chunk.name,
             chunk.start_line, chunk.end_line, chunk.content,
             chunk.token_estimate, parent_id)
        )

    def upsert_summary(self, chunk_id: str, signature: str = "",
                       docstring: str = "", llm_summary: str = ""):
        """Insert or update summary data for a chunk."""
        self.conn.execute(
            """INSERT OR REPLACE INTO summaries
               (chunk_id, signature, docstring, llm_summary)
               VALUES (?, ?, ?, ?)""",
            (chunk_id, signature, docstring, llm_summary)
        )

    def get_chunk(self, chunk_id: str) -> dict | None:
        """Retrieve a chunk and its summary by ID."""
        row = self.conn.execute(
            """SELECT c.*, s.signature, s.docstring, s.llm_summary
               FROM chunks c
               LEFT JOIN summaries s ON c.id = s.chunk_id
               WHERE c.id = ?""",
            (chunk_id,)
        ).fetchone()
        if row is None:
            return None
        cols = ["id", "file_path", "chunk_type", "name", "start_line",
                "end_line", "content", "token_estimate", "parent_id",
                "signature", "docstring", "llm_summary"]
        return dict(zip(cols, row))

    def get_children(self, parent_id: str) -> list[dict]:
        """Get all child chunks of a parent (e.g., methods of a class)."""
        rows = self.conn.execute(
            "SELECT * FROM chunks WHERE parent_id = ?", (parent_id,)
        ).fetchall()
        cols = ["id", "file_path", "chunk_type", "name", "start_line",
                "end_line", "content", "token_estimate", "parent_id"]
        return [dict(zip(cols, row)) for row in rows]

    def commit(self):
        self.conn.commit()

    def close(self):
        self.conn.close()
Listing 11.12: ChunkStore class with SQLite schema, upsert methods, and parent-child queries for hierarchical chunk retrieval.

The indexing pipeline ties everything together: walk the repository, parse each file, extract hierarchical chunks, generate summaries at each level, and persist (upsert) each record, meaning insert it if new or update it if it already exists.

def index_repository(repo_root: str, db_path: str,
                     extensions: set[str] = {".py"},
                     llm_client=None) -> dict:
    """Index an entire repository into the chunk store.

    Args:
        repo_root: path to the repository root
        db_path: path to the SQLite database
        extensions: file extensions to index
        llm_client: optional LLM client for generating summaries

    Returns:
        Statistics about the indexing run.
    """
    store = ChunkStore(db_path)
    stats = {"files": 0, "chunks": 0, "summaries": 0}

    for path in Path(repo_root).rglob("*"):
        if path.suffix not in extensions or not path.is_file():
            continue
        if any(part.startswith(".") for part in path.parts):
            continue  # skip hidden directories

        try:
            source = path.read_text(encoding="utf-8")
        except (UnicodeDecodeError, PermissionError):
            continue

        rel_path = str(path.relative_to(repo_root))
        hierarchy = build_hierarchy(source, rel_path)
        stats["files"] += 1

        for h_chunk in hierarchy:
            store.upsert_chunk(h_chunk.chunk, parent_id=h_chunk.parent_id)
            stats["chunks"] += 1

            # Generate summaries
            sig = extract_signature(h_chunk.chunk)
            docstrings = extract_docstrings(source)
            doc = docstrings.get(h_chunk.chunk.name, "")

            llm_summary = ""
            if llm_client and h_chunk.chunk.token_estimate > 50:
                llm_summary = generate_summary(h_chunk.chunk.content, llm_client)
                stats["summaries"] += 1

            store.upsert_summary(h_chunk.chunk.id, sig, doc, llm_summary)

            # Index children (methods)
            for child in h_chunk.children:
                store.upsert_chunk(child.chunk, parent_id=h_chunk.chunk.id)
                child_sig = extract_signature(child.chunk)
                child_doc = docstrings.get(
                    child.chunk.name.split(".")[-1], "")
                store.upsert_summary(child.chunk.id, child_sig, child_doc, "")
                stats["chunks"] += 1

    store.commit()
    store.close()
    return stats
Listing 11.13: Repository indexing pipeline that walks files, builds hierarchical chunks, generates summaries, and persists everything to ChunkStore.
Library Shortcut: LlamaIndex's CodeSplitter

The LlamaIndex library provides a CodeSplitter that wraps tree-sitter and handles 15+ languages out of the box. In four lines of code, it replaces our entire chunking pipeline:

from llama_index.core.node_parser import CodeSplitter

splitter = CodeSplitter(language="python", chunk_lines=60, chunk_lines_overlap=10)
nodes = splitter.get_nodes_from_documents(documents)
Listing 11.14: Using LlamaIndex's CodeSplitter to chunk Python source in four lines, replacing the manual tree-sitter pipeline.

LlamaIndex also provides SentenceSplitter with configurable overlap for mixed code-and-prose documents. The four lines above replace roughly 150 lines of our hand-built chunker. The trade-off: LlamaIndex's splitter does not produce hierarchical chunks with parent-child relationships, so you lose the ability to include a class signature when retrieving a method. For most applications, the simplicity is worth it. As of 2025, LlamaIndex also offers a SemanticSplitterNodeParser that uses embedding similarity to detect topic boundaries within a file, and LangChain provides a comparable RecursiveCharacterTextSplitter with language-aware separators for over 15 languages.

Fun Note: The Comment-to-Code Ratio as a Quality Signal

Well-documented code is easier to chunk and summarize. A function with a thorough docstring produces a high-quality signature summary for free. A function with no comments and single-letter variable names forces the summarizer to infer intent from structure alone. In informal experiments on open-source Python projects, the correlation between comment density and retrieval accuracy (measured by whether the correct function is returned for a natural-language query) can be notable, with reported values around \(r \approx 0.4\). This is not because comments help the retrieval model directly (embeddings are computed on the full code), but because well-documented code tends to have descriptive function names and type annotations, which are the features that embedding models rely on most.

Step-Through: Hierarchical Chunking on a Tiny Module

Trace through the build_hierarchy function (Listing 11.8) with this 18-line source file:

class Dog:
    def __init__(self, name):
        self.name = name

    def bark(self):
        return "Woof!"

def adopt(shelter, dog):
    shelter.append(dog)
Step-through input: a minimal module with one class (Dog, two methods) and one standalone function (adopt).

Step 1. tree-sitter parses the source and returns a root node with two children: a class_definition (lines 1 through 6) and a function_definition (lines 8 through 9).
Step 2. The first child is a class_definition, so we enter the class branch. We extract class_name = "Dog" and build a CodeChunk(chunk_type="class", name="Dog", start_line=1, end_line=6, token_estimate=30).
Step 3. We inspect the class body and find two function_definition children: __init__ (lines 2 through 3, token_estimate=14) and bark (lines 5 through 6, token_estimate=8). Each becomes a child HierarchicalChunk with parent_id="Dog".
Step 4. The second root child is a standalone function_definition. We extract CodeChunk(chunk_type="function", name="adopt", start_line=8, end_line=9, token_estimate=12) with no children and parent_id=None.
Result: 3 leaf chunks (Dog.__init__, Dog.bark, adopt) plus 1 class-level parent chunk (Dog), totaling 4 indexed units. Retrieving Dog.bark lets the packer include Dog's signature (class Dog:) for context at a cost of only 2 tokens instead of the full 30.

Real-World Application: Sourcegraph Code Intelligence

Sourcegraph's code search platform uses SCIP (Source Code Intelligence Protocol) to chunk entire codebases at the symbol level, extracting functions, classes, and type definitions with their cross-file references. When a developer searches for "authentication middleware," Sourcegraph retrieves the relevant middleware function chunk along with its call sites and type definitions from other files, because the SCIP index records precisely which symbols each chunk references. This graph-aware chunking is what allows Sourcegraph's Cody assistant to answer questions that span multiple files without embedding the entire repository.

Try It: Compare Chunking Strategies on a Real File

Build and compare three chunking strategies on a single Python file from any open-source project. (1) Install the dependencies: pip install tree-sitter tree-sitter-python tiktoken. (2) Pick a Python file with at least 200 lines and multiple classes (for example, download requests/models.py from the Requests library on GitHub). (3) Implement a naive fixed-size chunker that splits the file into 512-token windows with 50% overlap, using tiktoken to count tokens accurately. Record how many chunks are produced and manually inspect two chunks to check whether they split a function in half. (4) Run the extract_chunks function from Listing 11.7 on the same file. Compare the chunk count, average chunk size, and whether each chunk is syntactically complete (check that every chunk parses without errors via ast.parse). (5) Measure the retrieval impact: embed all chunks from both strategies using a free embedding API or sentence-transformers, query with a natural-language description of one function in the file (for example, "prepare the HTTP request body"), and compare which strategy ranks the correct chunk highest. You will typically observe that the syntax-aware chunks rank the target function higher (often in the top 3), while the fixed-size chunks tend to scatter relevant code across multiple lower-ranked fragments.

Exercise 11.2.1

Given a Python file containing a 120-line class with four methods (averaging 25 lines each) and two standalone functions (15 lines each), calculate: (a) how many chunks a fixed-size splitter with a 512-token window and 50% overlap produces (assume roughly 2 tokens per whitespace-delimited word, and the file has about 600 words), and (b) how many chunks the syntactic chunker from Listing 11.7 produces. For each strategy, state how many of the resulting chunks are guaranteed to be syntactically valid Python.

Hint

For (a), compute total tokens (600 words x 2 = 1,200 tokens), then apply the sliding window formula: with window size 512 and step size 256, the number of windows is ceil((1200 - 512) / 256) + 1. None of these windows are guaranteed to align with syntactic boundaries. For (b), the syntactic chunker emits one chunk per top-level definition: 1 class + 2 functions = 3 chunks, and all three are syntactically complete by construction.

Lab: Measuring Chunk Quality Across Granularities

Goal: Empirically determine how chunk granularity affects embedding-based retrieval accuracy for code search.
Tools needed: Python 3.10+, tree-sitter, tree-sitter-python, sentence-transformers (the all-MiniLM-L6-v2 model runs on CPU in seconds; as of 2025, newer models such as all-mpnet-base-v2 or Nomic's nomic-embed-text-v1.5 offer stronger retrieval quality at similar speed), and a single open-source Python file with at least 10 functions (e.g., httpx/_client.py).
Procedure (15 to 20 minutes): (1) Chunk the file three ways: fixed 256-token windows, fixed 512-token windows, and syntactic chunks via tree-sitter. (2) Embed all chunks with SentenceTransformer.encode(). (3) Write five natural-language queries describing specific functions in the file (e.g., "send an HTTP GET request with timeout"). (4) For each query, compute cosine similarity against all chunks and record the rank of the chunk containing the target function.
What to vary: Window size (128, 256, 512), overlap ratio (0%, 25%, 50%), and whether you embed raw code or signature-only summaries.
What to observe: Mean reciprocal rank (MRR; the average of 1/rank for each query's correct result, so higher is better) across your five queries for each configuration. Syntactic chunks typically achieve the highest MRR , and among fixed-size strategies, larger windows with overlap tend to outperform smaller windows without it.

Exercises

  1. (Conceptual) Explain why fixed-size chunking with overlap is particularly problematic for Python (as opposed to, say, Java or Go). Consider how Python uses indentation for scoping and what happens when a chunk boundary falls mid-indent.
  2. (Coding) Extend the extract_chunks function in Listing 11.7 to handle JavaScript/TypeScript files. You will need the tree-sitter-javascript or tree-sitter-typescript grammar packages. Identify the node types that correspond to top-level definitions in those languages (hint: function_declaration, class_declaration, lexical_declaration).
  3. (Analysis) Index a medium-sized open-source Python project (e.g., Flask or FastAPI) using the pipeline from Listing 11.13 (without LLM summaries). Report the number of files, chunks, and the distribution of chunk sizes (in tokens). What percentage of chunks are under 200 tokens? Under 500? What does this distribution suggest about the optimal embedding model input length?

What's Next

We now have a repository decomposed into semantically meaningful, summarized chunks. In Section 11.3: Retrieval-Augmented Code Generation, we build the retrieval system that, given a task description, ranks these chunks by relevance and packs the best ones into the context window using dense embeddings, BM25 (a classical term-frequency scoring algorithm widely used in text search), and hybrid search.