- Posted on
- • Artificial Intelligence
Chunking Strategies for RAG
- Author
-
-
- User
- linuxbash
- Posts by this author
- Posts by this author
-
Chunking Strategies for RAG: A Bash-Friendly, Practical Guide
If your Retrieval-Augmented Generation (RAG) pipeline is feeding entire PDFs or randomly sliced 500-token blocks into a vector store, you’re likely paying more, retrieving worse, and hallucinating more than you need to. Good chunking is one of the highest-leverage improvements you can make: it directly affects retrieval recall/precision, latency, and token cost.
This guide explains why chunking matters and gives you 3–5 actionable strategies you can run from a Linux terminal. You’ll get ready-to-use Bash-friendly scripts, plus installation instructions for apt, dnf, and zypper.
Why chunking matters in RAG
Relevance: Retrieval works best when chunks are self-contained, coherent units. Sentence boundaries, headings, and topic shifts matter.
Recall vs precision: Oversized chunks reduce precision (too much unrelated text), undersized chunks reduce recall (insufficient context to answer).
Cost and latency: Smaller, smarter chunks reduce tokens sent to the LLM and improve response times.
Model compatibility: Tokenization, not characters, determines how much context a chunk really consumes. Always chunk by tokens when possible.
Common anti-patterns:
Splitting purely by character count.
No overlap (boundary loss).
Ignoring document structure (headings, lists, code blocks).
Not evaluating chunk coverage or retrieval quality.
Prerequisites (Linux)
We’ll use a few command-line tools and Python packages for tokenization and sentence splitting.
Install system packages:
Debian/Ubuntu (apt):
sudo apt update sudo apt install -y python3 python3-pip jq ripgrepFedora/RHEL/CentOS Stream (dnf):
sudo dnf install -y python3 python3-pip jq ripgrepopenSUSE/SLE (zypper):
sudo zypper refresh sudo zypper install -y python3 python3-pip jq ripgrep
Install Python libraries (user scope):
python3 -m pip install --user tiktoken blingfire sentence-transformers
Tip: If you prefer isolation, create a virtual environment and install the same pip packages there.
Strategy 1: Fixed-size token chunks with overlap (robust baseline)
When to use:
Baseline for most corpora.
You want predictable token sizes and straightforward indexing.
What it does:
Splits text by tokens using a model-compatible tokenizer.
Adds overlap to avoid boundary loss.
Script: chunk_by_tokens.py
#!/usr/bin/env python3
import os, sys, json
import tiktoken
ENCODING = os.getenv("ENCODING", "cl100k_base") # good default for many OpenAI-like models
CHUNK_TOKENS = int(os.getenv("CHUNK_TOKENS", "512"))
OVERLAP = int(os.getenv("OVERLAP", "64"))
def read_text(path):
if path and path != "-":
with open(path, "r", encoding="utf-8", errors="ignore") as f:
return f.read()
return sys.stdin.read()
def main():
path = sys.argv[1] if len(sys.argv) > 1 else "-"
text = read_text(path)
enc = tiktoken.get_encoding(ENCODING)
toks = enc.encode(text)
step = CHUNK_TOKENS - OVERLAP if CHUNK_TOKENS > OVERLAP else CHUNK_TOKENS
i = 0
chunk_id = 0
while i < len(toks):
j = min(i + CHUNK_TOKENS, len(toks))
chunk_tokens = toks[i:j]
chunk_text = enc.decode(chunk_tokens)
out = {
"id": f"{os.path.basename(path)}::{chunk_id}",
"source": path,
"start_token": i,
"end_token": j,
"n_tokens": len(chunk_tokens),
"text": chunk_text
}
print(json.dumps(out, ensure_ascii=False))
chunk_id += 1
if j == len(toks): break
i += step
if __name__ == "__main__":
main()
Usage:
export CHUNK_TOKENS=512 OVERLAP=64
python3 chunk_by_tokens.py README.md > chunks_token.jsonl
Notes:
Adjust CHUNK_TOKENS and OVERLAP to your model and retrieval settings.
This yields durable, cost-predictable chunks.
Strategy 2: Sentence-aware packing up to a token budget
When to use:
You want coherent chunks with natural boundaries.
You have irregular sentence lengths or mixed prose/code.
What it does:
- Splits into sentences, then packs as many sentences as will fit into your token budget.
Script: chunk_sentence_pack.py
#!/usr/bin/env python3
import os, sys, json
import tiktoken
from blingfire import text_to_sentences
ENCODING = os.getenv("ENCODING", "cl100k_base")
CHUNK_TOKENS = int(os.getenv("CHUNK_TOKENS", "512"))
OVERLAP_SENTENCES = int(os.getenv("OVERLAP_SENTENCES", "1"))
def read_text(path):
if path and path != "-":
with open(path, "r", encoding="utf-8", errors="ignore") as f:
return f.read()
return sys.stdin.read()
def tokenize_len(enc, s): return len(enc.encode(s))
def main():
path = sys.argv[1] if len(sys.argv) > 1 else "-"
text = read_text(path)
enc = tiktoken.get_encoding(ENCODING)
sents = [s.strip() for s in text_to_sentences(text).splitlines() if s.strip()]
chunks, cur, cur_tokens = [], [], 0
cid = 0
i = 0
while i < len(sents):
s = sents[i]
s_tokens = tokenize_len(enc, s)
# If single sentence is too big, hard-split by tokens
if s_tokens > CHUNK_TOKENS:
toks = enc.encode(s)
start = 0
while start < len(toks):
end = min(start + CHUNK_TOKENS, len(toks))
chunk_text = enc.decode(toks[start:end])
print(json.dumps({
"id": f"{os.path.basename(path)}::big::{cid}",
"source": path,
"n_tokens": end - start,
"text": chunk_text
}, ensure_ascii=False))
cid += 1
start = end
i += 1
continue
if cur_tokens + s_tokens <= CHUNK_TOKENS:
cur.append(s)
cur_tokens += s_tokens
i += 1
else:
chunk_text = " ".join(cur).strip()
print(json.dumps({
"id": f"{os.path.basename(path)}::{cid}",
"source": path,
"n_tokens": cur_tokens,
"text": chunk_text
}, ensure_ascii=False))
cid += 1
# apply sentence overlap
cur = cur[-OVERLAP_SENTENCES:] if OVERLAP_SENTENCES > 0 else []
cur_tokens = tokenize_len(enc, " ".join(cur)) if cur else 0
if cur:
print(json.dumps({
"id": f"{os.path.basename(path)}::{cid}",
"source": path,
"n_tokens": cur_tokens,
"text": " ".join(cur).strip()
}, ensure_ascii=False))
if __name__ == "__main__":
main()
Usage:
export CHUNK_TOKENS=512 OVERLAP_SENTENCES=1
python3 chunk_sentence_pack.py docs/guide.md > chunks_sentence.jsonl
Strategy 3: Structure-aware chunking for Markdown and docs
When to use:
Technical docs with headings, lists, and code blocks.
You want chunks aligned to logical sections.
What it does:
- Splits by Markdown headings (H1–H3), respecting code fences to avoid splitting inside code blocks.
Script: chunk_md_heading.sh
#!/usr/bin/env bash
# Split a Markdown file by headings into JSONL chunks. Keeps headings with text.
# Usage: ./chunk_md_heading.sh README.md > chunks_md.jsonl
file="${1:-/dev/stdin}"
awk -v SRC="$file" '
BEGIN {
code=0; hdr=""; buf="";
}
function flush() {
if (length(buf) > 0) {
gsub(/\r/,"",buf)
gsub(/\r/,"",hdr)
# escape JSON
t=buf; gsub(/\\/,"\\\\",t); gsub(/"/,"\\\"",t)
h=hdr; gsub(/\\/,"\\\\",h); gsub(/"/,"\\\"",h)
print "{ \"source\": \"" SRC "\", \"heading\": \"" h "\", \"text\": \"" t "\" }"
buf=""
}
}
{
if ($0 ~ /^```/) { code = 1 - code; buf = buf $0 "\n"; next }
if (!code && $0 ~ /^#{1,3} /) {
flush()
hdr=$0
buf=$0 "\n"
} else {
buf = buf $0 "\n"
}
}
END { flush() }
' "$file"
Usage:
bash chunk_md_heading.sh README.md > chunks_md.jsonl
Tip:
- You can feed each emitted section into Strategy 1 or 2 for token-aware sub-chunking. For example:
bash chunk_md_heading.sh README.md \
| jq -r .text \
| python3 chunk_by_tokens.py - > chunks_md_token.jsonl
Strategy 4: Semantic chunking using embeddings (when topic shifts)
When to use:
Long narrative docs where topics shift mid-section.
You want to start new chunks when semantics drift, not just on headings.
What it does:
- Splits into sentences, embeds them, and starts a new chunk when semantic similarity drops or the token budget would be exceeded.
Script: chunk_semantic.py
#!/usr/bin/env python3
import os, sys, json, math
import tiktoken
from blingfire import text_to_sentences
from sentence_transformers import SentenceTransformer
import numpy as np
ENCODING = os.getenv("ENCODING", "cl100k_base")
CHUNK_TOKENS = int(os.getenv("CHUNK_TOKENS", "512"))
SIM_THRESHOLD = float(os.getenv("SIM_THRESHOLD", "0.65")) # lower => larger chunks
OVERLAP_SENTENCES = int(os.getenv("OVERLAP_SENTENCES", "1"))
def read_text(path):
if path and path != "-":
with open(path, "r", encoding="utf-8", errors="ignore") as f:
return f.read()
return sys.stdin.read()
def cosine(a, b):
na = np.linalg.norm(a); nb = np.linalg.norm(b)
if na == 0 or nb == 0: return 0.0
return float(np.dot(a, b) / (na * nb))
def main():
path = sys.argv[1] if len(sys.argv) > 1 else "-"
text = read_text(path)
enc = tiktoken.get_encoding(ENCODING)
sents = [s.strip() for s in text_to_sentences(text).splitlines() if s.strip()]
if not sents:
return
model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
embs = model.encode(sents, convert_to_numpy=True, normalize_embeddings=False)
cid = 0
cur_sents, cur_tokens = [], 0
ref_vec = None
for i, s in enumerate(sents):
s_tok = len(enc.encode(s))
if s_tok > CHUNK_TOKENS:
# flush current
if cur_sents:
print(json.dumps({
"id": f"{os.path.basename(path)}::{cid}",
"source": path,
"n_tokens": cur_tokens,
"text": " ".join(cur_sents)
}, ensure_ascii=False))
cid += 1
cur_sents, cur_tokens, ref_vec = [], 0, None
# hard-split big sentence by tokens
toks = enc.encode(s)
start = 0
while start < len(toks):
end = min(start + CHUNK_TOKENS, len(toks))
chunk_text = enc.decode(toks[start:end])
print(json.dumps({
"id": f"{os.path.basename(path)}::big::{cid}",
"source": path,
"n_tokens": end - start,
"text": chunk_text
}, ensure_ascii=False))
cid += 1
start = end
continue
sim_ok = True
if ref_vec is not None:
sim = cosine(ref_vec, embs[i])
sim_ok = sim >= SIM_THRESHOLD
would_fit = (cur_tokens + s_tok) <= CHUNK_TOKENS
if cur_sents and (not would_fit or not sim_ok):
print(json.dumps({
"id": f"{os.path.basename(path)}::{cid}",
"source": path,
"n_tokens": cur_tokens,
"text": " ".join(cur_sents)
}, ensure_ascii=False))
cid += 1
# overlap
cur_sents = cur_sents[-OVERLAP_SENTENCES:] if OVERLAP_SENTENCES > 0 else []
cur_tokens = len(enc.encode(" ".join(cur_sents))) if cur_sents else 0
ref_vec = embs[i] if cur_sents == [] else embs[i-1] # reset or keep near last
if not cur_sents:
ref_vec = embs[i]
cur_sents.append(s)
cur_tokens += s_tok
if cur_sents:
print(json.dumps({
"id": f"{os.path.basename(path)}::{cid}",
"source": path,
"n_tokens": cur_tokens,
"text": " ".join(cur_sents)
}, ensure_ascii=False))
if __name__ == "__main__":
main()
Usage:
export CHUNK_TOKENS=512 SIM_THRESHOLD=0.65 OVERLAP_SENTENCES=1
python3 chunk_semantic.py docs/whitepaper.md > chunks_semantic.jsonl
Notes:
Lower SIM_THRESHOLD yields fewer, larger chunks; higher splits more aggressively.
This is slower (embeddings) but can boost retrieval quality on narrative text.
Real-world pipeline: From docs to JSONL chunks
Example: Chunk a Markdown repo README using structure-aware + token packing, then preview a few chunks.
# 1) Structure-aware split
bash chunk_md_heading.sh README.md > sections.jsonl
# 2) Token pack each section to ~512 tokens with overlap
jq -r .text sections.jsonl \
| CHUNK_TOKENS=512 OVERLAP=64 python3 chunk_by_tokens.py - \
> chunks.jsonl
# 3) Quick peek
head -n 3 chunks.jsonl | jq .
Want to sanity-check coverage for a known keyword?
rg -n "kernel parameters|NUMA|cgroup" -e "$(jq -r .text chunks.jsonl)" # illustrative pattern usage
Or count tokens distribution quickly:
jq -r .n_tokens chunks.jsonl | awk '{sum+=$1; cnt+=1} END{print "avg:",sum/cnt,"n=",cnt}'
How to choose a strategy
If you need a fast, reliable baseline: Strategy 1 (fixed tokens + overlap).
If your content is prose-heavy or FAQs: Strategy 2 (sentence-aware packing).
If docs are technical with clear sections: Strategy 3 (heading-aware), optionally followed by 1 or 2 inside each section.
If long narratives with topic drift: Strategy 4 (semantic).
Default starting point for many teams:
- Split by headings (3), then sentence-pack to 400–700 tokens with 5–10% overlap (2). Evaluate, then iterate.
Quick evaluation ideas (before you index)
Keyword coverage: For a sample of questions, check whether the gold answer phrase exists in the top-k retrieved chunks. Quick and crude but useful.
Chunk quality spot-check: Randomly sample 20 chunks; verify each reads coherently and stands on its own.
Token cost modeling: Average tokens per chunk × expected top-k × request rate.
Conclusion and next steps
Chunking isn’t busywork; it’s a force multiplier for RAG accuracy and cost. Start simple with fixed-size tokens plus overlap, then graduate to sentence- or structure-aware chunking. When your docs are narrative and complex, add semantic chunking.
Call to action:
Install the prerequisites using apt/dnf/zypper above.
Drop the scripts into your repo.
Generate chunks for one representative document with Strategy 1 or 2.
Inspect the JSONL, adjust budgets/overlaps, and only then wire your chunks into your vector index.
Strong chunks in, strong retrieval out.