← All writing

Sizing chunks so Graph-RAG extraction never loses an edge

A four-tier routing rule that picks chunk size from document length, so every unit handed to the extractor can still yield at least one complete triple.

11 min readGraph-RAGChunkingRetrieval

Chunking is usually discussed as a retrieval problem: cut the corpus into pieces that fit an embedding window and that a nearest-neighbour search can rank. Graph-RAG changes what the chunk is for. Before anything is retrieved, every chunk is handed to an information extraction (IE) step — the process that reads unstructured text and emits structured facts. The output unit of IE is a triple: a subject, a predicate, and an object, such as (Policy 4.2, supersedes, Policy 3.9). Triples become the nodes and edges of the graph, so the chunk is not a retrieval unit first. It is an extraction unit, and its boundaries decide which facts the graph is capable of containing at all.

The rule below encodes that reframing. It routes each document by total token count into one of four strategies, and attaches a quality floor every chunk has to clear before extraction is worth paying for.

The original note

Quick rule when Chunking for Graph-RAG

≤300 tokens → don’t chunk. One extraction unit (prepend a rich header: IDs, section title). Run sentence-level IE.

300–900 → sentence-aware chunks (~300 tokens, 15–20% overlap). Stop at sentence boundaries; ensure ≥2 entities + 1 predicate — merge forward if not.

900–3500 → section-aware chunks (≈400–700 tokens, 15–20% overlap). Keep headers with the chunk; never split tables/code blocks.

>3500 → hierarchical: section → paragraph micro-chunks (300–500) for extraction; maintain parent section IDs.

Chunking for a graph is not chunking for a vector index

In vector RAG, a bad boundary is a ranking problem. A chunk cut in half still sits in the index; it just embeds worse or arrives with less context. The damage is graded and repairable at query time — retrieve the neighbours, widen the window, re-rank.

In Graph-RAG the same boundary is not graded. If a cut lands between a subject and its predicate, one chunk holds an entity with no assertion attached and the next holds an assertion with no subject. Neither half satisfies the minimum shape of a triple. The extractor does not emit a weak edge — it emits no edge. If that mention was the only place the entity appeared in a relational context, it lands in the graph as an orphan node: present, connected to nothing, unreachable by any traversal starting elsewhere.

Three properties make this worse than it sounds:

The objective function changes accordingly. Vector chunking optimises for retrievability per chunk; graph chunking optimises for self-containment per chunk.

The rule, tier by tier

Tokens Mode Boundary
≤300 one unit none
300–900 sentence-aware, ~300 sentence
900–3500 section-aware, 400–700 section
>3500 hierarchical, 300–500 paragraph

≤300 tokens — don’t chunk

At this size there is nothing to gain from splitting and a boundary to lose. The whole document becomes one extraction unit.

What the tier does add is a rich header: document ID, section path, section title. Headers matter because body text is routinely written for a reader who already knows the context. A ticket says “the timeout was raised to 30s” and never names the service; a policy clause says “this requirement applies to all vendors” and never names the policy. Without the header the extractor has no subject to bind to and either drops the fact or invents one. The header restores the subject, and it doubles as the provenance key that lets any edge in the finished graph be traced back to the span it came from.

The tier also specifies sentence-level IE: run extraction sentence by sentence with the header as standing context. Documents this short are cheap enough for per-sentence precision, which reduces the chance the extractor blends two adjacent facts into one wrong triple.

300–900 — sentence-aware chunks

Target roughly 300 tokens, stop only at sentence boundaries, and overlap 15–20%.

The sentence is the smallest span that reliably contains a complete predicate, so it is the smallest boundary that does not risk severing one. Cutting at a fixed token count instead puts the boundary wherever the arithmetic lands, which is mid-clause most of the time.

The 15–20% overlap makes the seam survivable. Roughly one to two sentences of the previous chunk are carried into the next, so a fact whose subject sits in one sentence and whose predicate sits in the following one appears intact in at least one of the two. The band has a ceiling for a reason: overlap duplicates text, duplicated text produces duplicate triples, and duplicate triples inflate any mention-count signal used for edge weight. Below the band the seam is naked; above it the same sentence starts appearing in three consecutive chunks.

900–3500 — section-aware chunks

Target 400–700 tokens with the same overlap band, but cut on sections rather than sentences.

A heading is an author-declared semantic boundary. Topic shifts and coreference chains tend to respect it, which makes it safer to cut on than any position chosen by token count. Two constraints ride along:

>3500 — hierarchical

Long documents get two levels: sections for structure and retrieval, paragraph micro-chunks of 300–500 tokens for extraction. Extraction runs on the small unit, where precision is highest, while the section remains the addressable container.

The load-bearing detail is maintaining parent section IDs. The micro-chunk is the extraction unit, but the section is the disambiguation unit. Carrying the parent ID does three things: triples can be re-attached to the section they came from, so retrieval returns coherent context rather than a fragment; entity resolution gets a scope, since two mentions of “the controller” under the same parent are probably one node while mentions under different parents probably are not; and orphan entities can be re-linked against their siblings instead of dropped. Without it, a long document decomposes into a pile of unrelated small texts and the author’s structure is thrown away.

The floor: two entities and one predicate

The rule attaches a quality gate to the sentence-aware tier: ≥2 entities + 1 predicate, merge forward if not met.

This is the minimum shape of a triple restated as a test on the input: two nodes and an edge between them. A chunk with one entity and no relational verb can at best contribute a bare node — no structure, no traversal value — while still costing a full extraction call.

The check is deliberately cheap: a lightweight named-entity pass and a predicate heuristic, run before the expensive call rather than by the extraction model itself. The remedy is merge forward, not backward, because forward preserves reading order and the common failure is a trailing subject whose predicate arrives in the next sentence. Merging backward would attach that subject to text preceding it and change what the chunk asserts.

Two details keep the merge safe. It must be bounded — it can only consume units that exist, so it terminates at the end of the document rather than spinning. And the final chunk has no forward neighbour, so a failing tail folds into its predecessor rather than being emitted as a chunk that cannot yield anything, or dropped.

A playbook

  1. Measure the token distribution of the corpus before choosing a strategy. Histogram per-document token counts with the same tokenizer the extractor uses. A corpus mostly under 300 tokens needs almost no chunker; one clustered above 3500 needs the hierarchical path from day one.
  2. Route per document, not per corpus. A single global chunk size is the failure the rule exists to prevent. The tier is a property of the document in front of the pipeline.
  3. Normalise structure before applying size logic. Parse headings, tables, and fenced code into typed units first, so the never-split guard has something to guard.
  4. Prepend the rich header to every chunk, including ones produced by splitting, with the parent section ID whenever the document took the hierarchical path.
  5. Enforce the floor before the expensive call, and merge forward when it fails.
  6. Validate with chunk QA, not just extraction QA. Sample chunks and check boundary properties directly: no chunk opens with an unresolved pronoun, no table or code block is split, the header is present, realised overlap sits inside the 15–20% band, and every chunk clears the floor.
  7. Count orphan nodes after extraction. Orphans concentrated in one document type or tier are evidence of a chunking bug, not a model bug, and the fix is a boundary change rather than a prompt change.

Failure modes

Implementation

The router turns the four thresholds into a small configuration object. Sizes inside each band are the midpoint of the stated range; overlap is the midpoint of 15–20%.

from dataclasses import dataclass
from typing import List, Tuple


@dataclass(frozen=True)
class ChunkPlan:
    """How one document should be cut before extraction runs."""

    mode: str             # whole | sentence | section | hierarchical
    target_tokens: int    # size aimed for, not a hard ceiling
    overlap_ratio: float  # 0.15-0.20, carried back across the seam
    boundary: str         # none | sentence | section | paragraph
    keep_parent_ids: bool


def choose_strategy(token_count: int) -> ChunkPlan:
    """Route a document to a chunking mode by total token count."""
    if token_count <= 300:
        # One extraction unit: no boundary, no severed predicate.
        return ChunkPlan("whole", token_count, 0.0, "none", False)
    if token_count <= 900:
        return ChunkPlan("sentence", 300, 0.175, "sentence", False)
    if token_count <= 3500:
        return ChunkPlan("section", 550, 0.175, "section", True)
    return ChunkPlan("hierarchical", 400, 0.175, "paragraph", True)

The header is built once per document and travels with every chunk cut from it; the atomic guard keeps tables and code blocks intact.

def build_header(doc_id, section_path, section_title, parent_id=None):
    """Build the rich header that travels with every chunk.

    The header exists so a chunk is self-identifying. Body text often
    says "the service" or "this control" and never names the subject;
    the header restores it, and it is also the provenance key used to
    trace an edge back to the span it came from.
    """
    parts = [
        f"doc={doc_id}",
        "path=" + " > ".join(section_path),
        f"section={section_title}",
    ]
    if parent_id:
        # Hierarchical mode: keeps a micro-chunk attached to a scope
        # for entity resolution and for re-assembly after extraction.
        parts.append(f"parent={parent_id}")
    return "[" + " | ".join(parts) + "]"


def is_atomic(unit: str) -> bool:
    """Never-split guard: tables and fenced code pass through whole."""
    text = unit.lstrip()
    if text.startswith("```") or text.startswith("~~~"):
        return True
    lines = [ln for ln in text.splitlines() if ln.strip()]
    if len(lines) >= 2 and all(ln.count("|") >= 2 for ln in lines):
        return True
    return False

The chunker fills to the target on sentence boundaries, merges forward when the floor fails, and steps back by the overlap budget without ever failing to advance.

def yields_a_triple(text, extract_entities, count_predicates) -> bool:
    """The floor: >=2 distinct entities and >=1 predicate.

    Below this floor a chunk cannot produce even one (subject,
    predicate, object) triple, so extraction spend on it is wasted.
    """
    return (len(set(extract_entities(text))) >= 2
            and count_predicates(text) >= 1)


def _next_start(start: int, end: int, sizes, overlap_budget: int) -> int:
    """Step back from `end` to carry overlap into the next chunk.

    Never steps below start + 1, so the outer loop always advances.
    """
    idx = end
    carried = 0
    while idx > start + 1 and carried + sizes[idx - 1] <= overlap_budget:
        idx -= 1
        carried += sizes[idx]
    return idx


def sentence_aware_chunks(units, plan, count_tokens, extract_entities,
                          count_predicates):
    """Cut an ordered list of units into half-open chunk spans.

    Atomic units are emitted alone. A span that fails the floor merges
    forward; a failing final span folds into its predecessor so that
    the tail is never dropped.
    """
    n = len(units)
    sizes = [count_tokens(u) for u in units]
    atomic = [is_atomic(u) for u in units]
    target = plan.target_tokens
    overlap_budget = int(round(target * plan.overlap_ratio))
    spans: List[Tuple[int, int]] = []

    start = 0
    while start < n:
        if atomic[start]:
            spans.append((start, start + 1))
            start += 1
            continue

        end, used = start, 0
        while end < n and not atomic[end]:
            if end > start and used + sizes[end] > target:
                break
            used += sizes[end]
            end += 1

        # Merge forward one unit at a time until the span can yield a
        # triple, or until an atomic unit or the end of the document
        # stops it. Bounded by n, so it cannot loop forever.
        while end < n and not atomic[end]:
            text = " ".join(units[start:end])
            if yields_a_triple(text, extract_entities, count_predicates):
                break
            end += 1

        spans.append((start, end))
        if end >= n:
            break
        start = _next_start(start, end, sizes, overlap_budget)

    if len(spans) > 1:
        s, e = spans[-1]
        prev_s, prev_e = spans[-2]
        tail = " ".join(units[s:e])
        mergeable = not atomic[s] and not atomic[prev_s]
        if mergeable and not yields_a_triple(
                tail, extract_entities, count_predicates):
            spans[-2] = (prev_s, e)
            spans.pop()
    return spans

The token counter and the entity/predicate probes are injected so the logic stands alone. A real deployment replaces the stubs with the extractor’s own tokenizer and a trained tagger; the interface does not change.

import re

# Stubs only. A real deployment swaps in the model tokenizer used by
# the extractor and a trained NER / relation tagger.

CAPITALISED = re.compile(r"\b[A-Z][A-Za-z0-9_.-]{2,}\b")
PREDICATES = ("is", "are", "was", "were", "has", "have", "uses",
              "supersedes", "replaces", "requires", "returns",
              "depends", "owns", "emits")


def count_tokens(text: str) -> int:
    """Whitespace proxy for a real tokenizer."""
    return max(1, len(text.split()))


def extract_entities(text: str) -> List[str]:
    """Cheap NER stub: capitalised spans stand in for entities."""
    return CAPITALISED.findall(text)


def count_predicates(text: str) -> int:
    """Cheap relation stub: count known predicate verbs."""
    words = [w.strip(".,;:()").lower() for w in text.split()]
    return sum(1 for w in words if w in PREDICATES)


def chunk_document(doc_id, section_title, section_path, units,
                   parent_id=None):
    """End to end: measure, route, cut, then prepend the header."""
    total = sum(count_tokens(u) for u in units)
    plan = choose_strategy(total)
    header = build_header(
        doc_id, section_path, section_title,
        parent_id if plan.keep_parent_ids else None,
    )
    if plan.mode == "whole":
        return [f"{header}\n" + " ".join(units)]
    spans = sentence_aware_chunks(units, plan, count_tokens,
                                  extract_entities, count_predicates)
    return [f"{header}\n" + " ".join(units[s:e]) for s, e in spans]

Closing

The rule encodes one idea: in Graph-RAG the chunk boundary is where facts are created or destroyed, so size should follow the document rather than be fixed across the corpus. Measure the distribution first, route each document to its tier, keep the header attached, refuse to split what cannot be split, and never send a chunk to the extractor that could not produce a triple even in the best case.

This post grew out of a note originally published on LinkedIn in August 2025. Read the original.
Get in touchRead the full story