Skip to content

Strategies & tuning

Everything here operates on the same contract: a chunker is a factory that takes options and returns an async generator; the generator takes a string and yields [text, embedding] pairs.

type EmbedFunction = (text: string) => number[] | Promise<number[]>;
type Chunker = (text: string) => AsyncGenerator<[string, number[]]>;

Segments the text, embeds each segment, computes the cosine-similarity dropoff (1 - similarity) between each adjacent pair, asks a detection method which dropoffs are significant, and cuts there. Each final chunk’s text is re-embedded, so expect one extra embed call per yielded chunk.

Option Default What it does
embed null embedder Your embedding function. Required for meaningful results — see the no-embedder trap
method "SD" Boundary detection method (table below)
methodOptions {} Options for the chosen method
zScoreThreshold 2 Convenience shortcut for the default "SD" method’s threshold
splitMode "sentence" "sentence", "paragraph", or "markdown" segmentation
split 0 Hard-split segments longer than this many characters (0 disables)
overlap 0 Trailing segments from the previous chunk prepended to the next
maxChunkSize 0 Split oversized chunks at their weakest interior point (0 disables)
minChunkSize 0 Merge undersized chunks into their most-similar neighbor (0 disables)

One embedded chunk per segment. Takes embed, split, and splitMode with the same meanings. No boundary detection — this is the first pass of semantic, exposed directly.

The whole text as a single embedded chunk, or fixed split-character slices. Takes embed and split.

splitMode Segments are Notes
"sentence" Sentences via compromise Default
"paragraph" Blank-line-separated blocks For plain prose
"markdown" Headings, paragraphs, fenced code blocks A heading always starts a new segment; fenced code blocks are never split in half

Chunk text is reassembled by joining segments with a single space — original whitespace, newlines, and blank lines between segments are not preserved in the output chunks.

Pass method and methodOptions to semantic(). Dropoff values live in [0, 2] (1 - cosine), and most methods flag high outliers — points of unusual dissimilarity.

const chunker = semantic({
embed,
method: "MAD",
methodOptions: { madMultiplier: 3 },
});
Method Strategy methodOptions (defaults)
"SD" (default) Z-score against mean/standard deviation zScoreThreshold (2)
"IQ" Interquartile-range outliers iqrMultiplier (1.5)
"MAD" Median absolute deviation outliers madMultiplier (3)
"PercentChange" Percentage drop vs the previous dropoff percentThreshold (20)
"MA" Deviation below a moving average windowSize (3), deviationThreshold (1.5)
"LM" Local minima detection sensitivity (0.2)
"CUSUM" Cumulative sum of deviations from the mean threshold (5)
"ChangePoint" Single change point maximizing mean difference
"Hampel" Hampel filter (windowed median + MAD) windowSize (7), nSigma (3)
"ModifiedZScore" Modified z-score (median/MAD based) threshold (3.5)
"Agentic" Re-embeds segment text with a transformers.js model model (Xenova/all-MiniLM-L6-v2), threshold (0.5), windowSize (2)

Method-specific behavior worth knowing before you tune:

  • SD is one-sided; MAD, IQ, and ModifiedZScore are two-sided. SD only flags unusually high dropoffs (real topic breaks). The robust methods flag deviation in either direction — an unusually similar pair can also become a boundary.
  • PercentChange flags the point after the drop. It marks where the dropoff value fell sharply relative to its predecessor, not the spike itself — its boundaries land one segment later than SD’s on the same data.
  • CUSUM’s default threshold of 5 is large for dropoff data. Dropoffs are bounded by 2 and typically hover well under 1, so the cumulative sum rarely reaches 5 on short-to-medium documents. If CUSUM finds nothing, drop threshold to well under 1 before concluding your document has no shifts.
  • ChangePoint returns at most one boundary — the single strongest split. Use it to find the dominant topic shift, not to chunk a long document.
  • Agentic requires @huggingface/transformers (optional peer, loaded lazily) and must not run in the same process as the embed/xenova adapter — see the conflict trap.

The raw detection functions are exported for direct use:

import { dropoffMethods } from "@johnhenry/semantic-chunker";
const boundaries = dropoffMethods.findSignificantDropoffsIQ(dropoffs, 1.5);

Each takes { index, dropoff }[] and returns the sorted indices of significant dropoffs.

For the default "SD" method, zScoreThreshold is the main dial: lower values (0.5–1) cut more aggressively into smaller chunks; higher values (2–2.5) demand stronger evidence and produce fewer, larger chunks.

Hard limits reshape the statistically-chosen boundaries afterward:

const chunker = semantic({
embed,
maxChunkSize: 2000, // split chunks longer than 2000 characters
minChunkSize: 200, // merge chunks shorter than 200 characters
overlap: 1, // repeat each chunk's last segment in the next chunk
});
  • maxChunkSize splits an oversized chunk at its highest-dropoff interior point, repeatedly, until compliant. A chunk consisting of a single segment is never split — one very long sentence can still exceed the limit.
  • minChunkSize merges an undersized chunk across its lower-dropoff (more similar) boundary.
  • minChunkSize runs after maxChunkSize, so when the two conflict, minChunkSize wins — a merge can recreate a chunk larger than maxChunkSize.
  • overlap prepends the previous chunk’s trailing segments without moving boundaries; overlapped text is included when the chunk is re-embedded.

Two adapters ship as subpath imports. Each needs its optional peer dependency installed; neither is loaded unless you import it.

embed/xenova embed/ollama
Import @johnhenry/semantic-chunker/embed/xenova @johnhenry/semantic-chunker/embed/ollama
Peer dependency @xenova/transformers ollama
Runs In-process (onnxruntime) Against a server at localhost:11434
Model Supabase/gte-small (downloaded on first use) nomic-embed-text:latest (must be pulled first)
Needs Network on first run; HF_ACCESS_TOKEN env var if the model requires auth A running ollama serve with the model pulled

The xenova adapter loads its model at import time — the module has a top-level await pipeline(...), so import "@johnhenry/semantic-chunker/embed/xenova" blocks until the model is loaded (first run: downloaded). Import it lazily if startup latency matters, and don’t import it in environments without network access or a warm cache.

import semantic from "@johnhenry/semantic-chunker";
import { embed } from "@johnhenry/semantic-chunker/embed/xenova";
const chunker = semantic({ embed, zScoreThreshold: 1 });

Or write your own — anything matching (text) => number[] | Promise<number[]>:

const embed = async (text) => {
const res = await fetch("https://your-embedding-api.example/embed", {
method: "POST",
body: JSON.stringify({ text }),
headers: { "Content-Type": "application/json" },
});
return res.json();
};

Whatever you choose, use one embedder per run — mixed vector dimensions are silently accepted and silently wrong (see the dimension trap).