Document Chunking
Document chunking is the process of splitting large documents into smaller, semantically meaningful pieces before they are embedded and stored. It sits between raw ingestion and vector embedding in the knowledge pipeline, and its quality directly determines retrieval accuracy.
Why Chunking Is Necessary
Large language models and embedding models both operate within fixed context windows. A document that exceeds the window must be split, but naive splitting (e.g., every N characters) destroys meaning. Two specific problems make chunking essential:
Context window limits. Embedding models like all-mpnet-base-v2 accept a maximum of 512 tokens per input. Documents routinely exceed this. Passing a truncated document silently discards everything after the cutoff.
Diluted embeddings. Even when a document fits within the window, embedding an entire page produces a single vector that represents the average meaning of all its content. A page about both “network configuration” and “disk partitioning” yields a vector that is close to neither topic. Retrieval queries for either topic score poorly against this diluted vector. Chunking isolates each topic into its own vector, making similarity search precise.
Semantic Splitting
Chunking strategies differ between prose and code because their structural boundaries differ.
Prose Documents
Prose is split at paragraph boundaries first, then at sentence boundaries if a paragraph exceeds the maximum chunk size.
The split regex identifies paragraph breaks as two or more consecutive newlines:
PARAGRAPH_SPLIT = re.compile(r'\n{2,}')
The algorithm:
- Split the document on
PARAGRAPH_SPLIT. - Accumulate consecutive paragraphs into a chunk until adding the next paragraph would exceed
max_chunk_size. - If a single paragraph exceeds
max_chunk_size, split it further at sentence boundaries (.,?,!). - Emit each chunk with metadata recording its character offset within the source document.
def chunk_prose(text, max_size=1500, min_size=500):
paragraphs = PARAGRAPH_SPLIT.split(text)
current_chunk = []
current_length = 0
for para in paragraphs:
para = para.strip()
if not para:
continue
if current_length + len(para) > max_size and current_length >= min_size:
yield ''.join(current_chunk)
current_chunk = []
current_length = 0
current_chunk.append(para + '\n\n')
current_length += len(para)
if current_chunk:
yield ''.join(current_chunk)
Code Documents
Code is split at function and class boundaries. Splitting mid-function destroys the semantic unit that developers search for.
Boundary detection uses language-aware regex patterns:
CODE_BOUNDARIES = {
'python': re.compile(r'^(class |def |async def )', re.MULTILINE),
'javascript': re.compile(
r'^(function |class |const \w+ = |export (default )?(function|class) )',
re.MULTILINE
),
'java': re.compile(
r'^(\s*(public|private|protected)\s+(static\s+)?(class|void|int|String)\s)',
re.MULTILINE
),
}
Example: given a Python file with three functions, the chunker produces three chunks:
Source file:
+--------------------------+
| def connect(host, port): | --> Chunk 1
| ... |
+--------------------------+
| def query(sql): | --> Chunk 2
| ... |
+--------------------------+
| class ConnectionPool: | --> Chunk 3
| def __init__(self): |
| def acquire(self): |
| ... |
+--------------------------+
If a single function exceeds max_chunk_size, it is split at logical sub-boundaries (blank lines within the function body), preserving the function signature as a prefix on each sub-chunk.
Chunk Size Control
Three parameters govern chunk dimensions:
| Parameter | Default | Unit | Purpose |
|---|---|---|---|
min_chunk_size |
500 | characters | Prevents fragments too small to embed usefully |
max_chunk_size |
1500 | characters | Keeps chunks within embedding model limits |
overlap |
100 | characters | Preserves context across chunk boundaries |
These defaults are tuned for all-mpnet-base-v2 (512-token window, roughly 4 characters per token). Larger embedding models can use larger chunks.
Overlap
Adjacent chunks share an overlap region so that concepts spanning a boundary are captured in both chunks. Without overlap, a sentence split across two chunks would be incomplete in both, causing retrieval misses.
Document:
|<------------- Chunk 1 ------------->|
|<-- overlap -->|
|<------------- Chunk 2 ------------->|
|<-- overlap -->|
|<------------- Chunk 3 ------------->|
Character positions:
0 500 1000 1500 2000 2500 3000
|-----------|-----------|-----------|-----------|-----------|-----------|
|<---- Chunk 1 (0-1500) ---->|
|<---- Chunk 2 (1400-2900) ---->|
|<---- Chunk 3 (2800-4300) ---->|
^ ^
| |
overlap=100 overlap=100
The overlap is always taken from the end of the preceding chunk. This means the last 100 characters of Chunk 1 are identical to the first 100 characters of Chunk 2. During retrieval, deduplication logic detects overlapping chunks from the same source document and merges them.
Chunk Size Tradeoffs
| Size | Characters | Pros | Cons |
|---|---|---|---|
| Small | 200-500 | Precise retrieval; each chunk is tightly focused | More chunks to store and search; higher storage cost; loses broader context |
| Medium | 500-1500 | Good balance of precision and context; fits embedding model windows well | May split some multi-paragraph arguments across chunks |
| Large | 1500-3000 | Preserves long-form reasoning; fewer chunks | Diluted embeddings; may exceed model token limits; retrieval returns irrelevant padding |
The system defaults to medium (500-1500) as the best tradeoff for general-purpose knowledge retrieval.
Memory Efficiency
Chunking operates on documents that can be arbitrarily large. Loading an entire document into memory before chunking wastes resources and risks out-of-memory errors on constrained worker nodes.
Generator-Based Streaming
The chunker exposes both a batch interface (returns a list) and a streaming interface (returns a generator). The streaming interface is preferred:
# Batch: loads all chunks into memory at once
chunks = list(chunk_document(text))
# Streaming: yields one chunk at a time (preferred)
for chunk in chunk_document(text):
process(chunk)
The streaming interface keeps at most two chunks in memory at any time (the current chunk being built and the previous chunk for overlap extraction).
10 MB Document Limit
Documents larger than 10 MB are rejected before chunking begins. This limit exists because:
- Documents above 10 MB are almost always binary files (PDFs with embedded images, archives) that were misclassified as text.
- Chunking a 10 MB text document would produce thousands of chunks, overwhelming the embedding pipeline.
- Worker nodes have limited memory; a 10 MB string plus its chunks could consume 30+ MB of heap.
MAX_DOCUMENT_SIZE = 10 * 1024 * 1024 # 10 MB
def chunk_document(text, **kwargs):
if len(text) > MAX_DOCUMENT_SIZE:
raise DocumentTooLargeError(
f"Document size {len(text)} exceeds limit {MAX_DOCUMENT_SIZE}"
)
# ... proceed with chunking
Storage Schema
Chunks are stored in the knowledge.chunks table in PostgreSQL:
CREATE TABLE knowledge.chunks (
id SERIAL PRIMARY KEY,
document_id INTEGER NOT NULL REFERENCES knowledge.documents(id) ON DELETE CASCADE,
chunk_index INTEGER NOT NULL,
content TEXT NOT NULL,
char_offset INTEGER NOT NULL,
char_length INTEGER NOT NULL,
token_count INTEGER,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE (document_id, chunk_index)
);
-- Full-text search index on chunk content
CREATE INDEX idx_chunks_content_gin
ON knowledge.chunks
USING GIN (to_tsvector('english', content));
-- Fast lookup by document
CREATE INDEX idx_chunks_document_id
ON knowledge.chunks (document_id);
The GIN index on content enables PostgreSQL full-text search as a complement to vector similarity search. Queries that include exact keywords (e.g., error codes, function names) use the GIN index for precise matching, while semantic queries use the vector index on the associated embeddings table.
The metadata JSONB column stores chunk-specific attributes:
{
"language": "python",
"chunk_type": "function",
"function_name": "connect",
"source_file": "lib/db.py",
"split_reason": "code_boundary"
}
Pipeline Integration
Chunking sits in the middle of the linear ingestion pipeline:
+----------+ +----------+ +----------+
| Store | ---> | Chunk | ---> | Embed |
+----------+ +----------+ +----------+
Raw doc Split into Generate
ingested semantic vector for
to disk pieces each chunk
-
Store – The raw document arrives (via scraper, upload, or API) and is persisted to the
knowledge.documentstable with its full text and source metadata. -
Chunk – The chunker reads the document text, determines its type (prose vs. code, with language detection for code), and splits it into chunks. Each chunk is written to
knowledge.chunkswith its offset, length, and metadata. -
Embed – The embedding pipeline reads chunks that lack embeddings (joining
knowledge.chunksagainstknowledge.embeddingsto find gaps) and generates a vector for each. Vectors are stored in theknowledge.embeddingstable and indexed for similarity search.
Each stage is idempotent. Re-running the chunker on an already-chunked document detects existing chunks (via the UNIQUE(document_id, chunk_index) constraint) and skips them. This allows safe retries after partial failures.