Chunking and metadata
How you slice documents — and what metadata you attach — often matters more than which vector DB you pick. Strategies, packing, evals, ops, and failure modes end-to-end.
Chunking is a product decision
Embeddings see chunks, not whole PDFs. Too large → diluted similarity (many topics in one vector). Too small → missing context (answer split across neighbors). Overlap, headings, tables, and code fences change retrieval quality more than swapping Pinecone for Weaviate on a small corpus.
If you only remember one line from the Data track: fix chunking before you rewrite the stack.
flowchart TD
Doc[Document] --> Clean[Clean / parse]
Clean --> Split[Chunker strategy]
Split --> Meta[Attach metadata + stable id]
Meta --> Emb[Embed]
Emb --> VDB[(Vector DB)]
Q[Query] --> Filter[Metadata filters]
Filter --> VDB
VDB --> Pack[Pack adjacent / parent context]
One-sentence definition
Chunking is the process of cutting source documents into retrieval units sized for embedding quality, then attaching metadata so filters, citations, and packing can reconstruct useful LLM context.
What / why / how (staff three-liner)
| Lens | Answer |
|---|---|
| What | Deterministic split of source text into retrieval units + payloads |
| Why | Embedding geometry and LLM packing both depend on unit boundaries |
| How | Parse → strip chrome → strategy split → stable ids → embed → filter → pack → cite |
Why chunking dominates vector DB choice (early)
| Lever | Typical impact on small/mid corpora |
|---|---|
| Chunk size / structure | High — changes what the embedding “means” |
| Metadata / ACL | High — correctness and security |
| Embedding model | Medium–high |
| ANN engine brand | Lower until scale/ops dominate |
You still need a solid vector database story — but many “RAG is broken” incidents are parse + chunk bugs. Vendor migrations do not fix nav-footer embeddings or tables split without headers.
Interview cue
If someone asks “Pinecone or Weaviate?”, answer with corpus shape, tenancy, and chunk eval results first. Brand second.
Strategies that show up in production
| Strategy | How it works | Best for | Failure mode |
|---|---|---|---|
| Fixed tokens | Every N tokens, optional overlap | Uniform blogs | Splits mid-sentence / mid-table |
| Recursive separators | Split on \n\n, \n, . … |
Mixed prose | Still weak on tables |
| Structure-aware | Headings, Markdown AST, HTML DOM | Docs sites | Bad HTML → bad chunks |
| Semantic | Split when topic embedding shifts | Long narrative | Costly; can over-split |
| Parent–child | Small child for retrieve, large parent for LLM | Precision + context | More storage / glue code |
| Late chunking (model-dependent) | Embed long context then derive chunk vectors | Compatible encoders | Not universal |
| Proposition / sentence | Atomic claims as units | Factoid QA | Needs packing for context |
# Conceptual fixed chunker with overlap
def chunk_tokens(tokens: list[str], size=400, overlap=80):
i = 0
while i < len(tokens):
yield tokens[i : i + size]
i += max(1, size - overlap)Overlap helps when the answer straddles a boundary; too much overlap wastes money and duplicates hits.
Choosing a default (practical)
- Start structure-aware if you have Markdown/HTML headings
- Else recursive with ~400–600 token target and ~10–15% overlap
- Adopt parent–child when evals show precise hits but thin LLM context
- Only then try semantic splitters if narrative docs dominate
Strategy decision tree (quick)
flowchart TD
Start[Have headings / DOM?] -->|Yes| Struct[Structure-aware]
Start -->|No| Rec[Recursive ~500 tok]
Struct --> Eval{recall@5 OK?}
Rec --> Eval
Eval -->|Thin LLM context| PC[Add parent-child]
Eval -->|Miss mid-boundary answers| Ov[Raise overlap 10-20%]
Eval -->|Narrative digressions| Sem[Trial semantic split]
Eval -->|Good| Ship[Version config + ship]
Tables, code, and PDFs
- Tables: Prefer row- or section-level chunks with the header repeated in each chunk; otherwise embeddings see gibberish columns.
- Code: Keep functions / classes intact when possible; include file path in metadata.
- PDFs: Layout extraction beats
pdftotextfor multi-column docs; bad parse → unrecoverable retrieval. - Boilerplate: Strip nav/chrome before chunking or you retrieve the footer forever.
- Images / scans: OCR quality is part of chunking; garbage OCR → garbage vectors.
- Slide decks: One slide ≈ one chunk often works; include slide title in text.
- Spreadsheets: Sheet name + column headers + row window; never dump raw CSV as one vector.
- Email / tickets: Thread subject + message boundaries; speaker/role tags in text or metadata.
flowchart LR
PDF[PDF] --> Layout[Layout-aware parse]
Layout --> Clean[Strip chrome]
Clean --> Struct[Detect headings/tables]
Struct --> Chunk[Strategy-specific splits]
PDF failure gallery (recognize these)
| Symptom in top-k dumps | Likely cause |
|---|---|
| Columns interleaved left-right | Naive text extract on multi-column layout |
| “Cookie settings / Accept” hits | Chrome not stripped |
| Half a table, no headers | Fixed token split across table |
| Repeated headers as “answers” | Running headers treated as body |
| Empty or near-empty chunks | Image-only pages without OCR |
Metadata that pays rent
| Field | Use |
|---|---|
source / url / doc_id |
Citations and debugging |
updated_at / version |
Freshness filters |
acl / tenant_id |
Security — mandatory in multi-tenant |
section / heading / page |
UI + rerank hints |
doc_type |
Routing / filters (policy vs FAQ) |
language |
Locale routing |
content_hash |
Skip re-embed |
embedding_model |
Prevent mixed-index disasters |
chunk_index / parent_id |
Packing neighbors / parents |
token_count |
Budgeting |
parser_version / chunker_version |
Repro and reparse triggers |
meta_schema_version |
Filter compatibility |
{
"text": "Refunds are issued within 5–7 business days…",
"doc_id": "policy-42",
"tenant_id": "acme",
"heading": "Returns / Refunds",
"updated_at": "2026-03-01T00:00:00Z",
"acl_roles": ["user", "support"],
"content_hash": "sha256:…",
"chunk_index": 3,
"parent_id": "policy-42#returns",
"chunker_version": "struct-v3",
"meta_schema_version": 2
}Ship rule: if you cannot cite it, do not retrieve it. Metadata must carry stable ids through fusion and hybrid search / rerank.
Metadata that should not be embedded as free text
Do not paste secrets, raw session tokens, or full PII dumps into the embedded text field “for convenience.” Prefer structured payload fields that never hit the embed API when possible, and minimize what third-party embedders see. Companion: Privacy and data for AI.
Stable IDs and re-chunking
Re-chunking without stable ids → duplicate vectors and ghost citations.
| Approach | Behavior |
|---|---|
(doc_id, chunk_index, model) |
Simple; shifts when splitter changes |
| Content hash of chunk text | Stable if text identical; duplicates across docs possible |
(doc_id, content_hash) |
Good idempotency for upserts |
Delete-by-doc_id then reinsert |
Cleanest on full doc refresh |
When the splitter changes, delete all chunks for doc_id then re-ingest — do not hope indexes self-heal.
Idempotent ingest sketch
def upsert_doc(doc_id: str, chunks: list[Chunk], store):
store.delete_by_filter({"doc_id": doc_id})
for c in chunks:
store.upsert(
id=c.stable_id, # e.g. f"{doc_id}:{c.content_hash}"
vector=embed(c.text),
payload=c.metadata | {"doc_id": doc_id, "text": c.text},
)Race note: concurrent workers on the same doc_id need a lease or “last writer wins” with a doc-level version — otherwise two deletes interleaved with upserts leave holes.
Packing context after retrieval
Top-k child chunks are not always what you paste into the LLM:
- Retrieve children (small, precise)
- Expand to parent section or ±1 neighbor chunk
- Deduplicate overlapping windows
- Enforce token budget; prefer diversity across
doc_id - Keep citation ids aligned with expanded text
flowchart LR
Hits[Top-k child hits] --> Expand[Expand to parent/neighbors]
Expand --> Dedupe[Dedupe + budget]
Dedupe --> Cite[Keep citation ids]
Cite --> LLM[Prompt]
Packing budget sketch
def pack(hits, budget_tokens=3000):
used = 0
out = []
seen_docs = set()
for h in hits: # already reranked
parent = expand(h)
if parent.tokens + used > budget_tokens:
continue
if h.doc_id in seen_docs and len(out) >= 3:
continue # encourage diversity after a few
out.append(parent)
seen_docs.add(h.doc_id)
used += parent.tokens
return outPacking anti-patterns
| Anti-pattern | Result |
|---|---|
| Paste top-20 children raw | Noise, truncated mid-sentence, weak citations |
| Expand every hit to full doc | Budget blowup; attention dilution |
| Cite child id after parent expand | User opens wrong span |
| No diversity rule | Five near-duplicate sections from one FAQ |
| Strip metadata before pack | Cannot enforce ACL or freshness in the prompt path |
Eval: ablate like an engineer
Hold out ~20–50 docs and 30+ questions with labeled relevant chunk ids (or answer spans).
| Knob | Try |
|---|---|
| Size | 200 / 500 / 1000 tokens |
| Overlap | 0 / 10% / 20% |
| Structure | fixed vs heading-aware |
| Parent–child | on/off |
| Boilerplate strip | on/off (catch chrome regressions) |
Report recall@5, MRR, and a quick answer faithfulness sample. Winner is corpus-specific — that is the point.
flowchart TD
Corpus[Frozen corpus + labels] --> A[Chunk config A]
Corpus --> B[Chunk config B]
A --> Emb[Same embedder]
B --> Emb
Emb --> R[recall@5 / nDCG]
R --> Pick[Pick default + document why]
Eval harness checklist
- Frozen corpus snapshot (immutable object-store prefix)
- Question → gold
chunk_idor answer span labels - Same embedder + same ANN knobs across ablations
- Log config hash next to metrics
- Spot-check faithfulness on 20 answers (human or LLM-judge with care)
- Gate: do not ship a chunker change that drops recall@5 below your floor
Three measurable metrics (chunking staff bar)
- recall@5 across chunk-size / structure ablation
- % chunks that are boilerplate / nav (should be ~0 on monthly sample)
- Duplicate-id rate after re-ingest (should be 0)
Add a fourth when multi-tenant: cross-tenant leak rate = 0 in isolation tests (filters on packed hits).
Interface contract (staff bar)
| Inputs | Raw docs, parser config, chunker config version |
| Outputs | Chunks with text, stable ids, metadata ready for embed/upsert |
| Invariants | No chrome-only chunks; tenant/ACL always set; ids deterministic for same config |
Two degrade modes
- Structure parser fails → fall back to recursive splitter; flag docs for reparse
- Retrieval empty after strict filters → broaden
doc_type/ freshness before letting LLM invent
Threat note
Chunks that include another tenant’s pasted content, or PII in metadata fields logged to third parties, create leaks. Catch with ACL on every chunk, PII minimization before embed, and cite-only packing. Prompt injection via retrieved docs is a packing + policy problem — sanitize and cite; do not trust retrieved HTML/scripts as instructions.
Failure modes
- One chunk = whole 40-page PDF
- Chunking after HTML chrome → nav embeddings
- No
tenant_id/ ACL on payload - Re-chunking without stable ids → duplicate vectors
- Evaluating only chatbot vibes → blind to retrieval rot
- Tables split without headers → unreadable hits
- Parent expansion blowing past context budget → truncated mid-policy
- Multilingual docs chunked with English-only separators
- Mixed embedding models in one collection after “quick” re-embed
- Parser upgrade without delete-by-
doc_id→ ghost old chunks - Semantic splitter thrashing on repetitive legalese
- Storing embeddings without chunk text → cannot cite or rebuild prompts
Debugging playbook
- Dump top-5 chunk texts for a failing query — readable to a human?
- Check parse output before chunking — chrome? columns scrambled?
- Ablate one size step larger/smaller — recall move?
- Verify metadata filters not over-narrowing
- Confirm re-ingest deleted old
doc_idchunks - Compare child hit vs packed parent — did expansion help or drown the answer?
- Diff
chunker_version/parser_versionbetween staging and prod
flowchart TD
Fail[Bad answer ticket] --> Dump[Dump top-k texts]
Dump --> Parse{Parse clean?}
Parse -->|No| FixParse[Fix parser / strip]
Parse -->|Yes| Size{Ablate size}
Size --> Filters{Filters too tight?}
Filters --> Ids{Ghost ids?}
Ids --> Pack{Packing budget?}
Production readiness checklist
- Chunker config versioned and stored with index / collection name
- Stable ids + delete-by-doc on refresh
- Metadata schema documented (esp. ACL)
- Ablation results attached to design doc
- Special handling for tables/code/PDF noted
- Packing + citation path tested
- Boilerplate strip verified on HTML sources
- Reparse triggers documented (parser / chunker / strip rules)
- Monthly random chunk sample for chrome regression
- Isolation tests for tenant filters on packed context
Interview prompts
- Why might smaller chunks raise recall but hurt answer quality?
- Explain parent–child retrieval.
- What metadata is mandatory for multi-tenant RAG?
- How do you evaluate a chunker change safely?
- Walk through a safe chunker-version upgrade without ghost vectors.
- How do you chunk tables so embeddings stay meaningful?
- What goes in the vector payload vs Postgres system of record?
Chunk size intuition (rules of thumb, not laws)
| Corpus | Starting point |
|---|---|
| Product docs / Markdown | 400–600 tokens, heading-aware |
| Support macros / FAQs | 200–400 tokens; one intent per chunk |
| Legal / policy | Heading + clause boundaries; parent–child |
| Code repos | Function/class units; path in metadata |
| Chat logs | Turn or sliding window with speaker tags |
| Research papers | Section-aware; abstract and captions separate |
| Release notes | Per-version or per-bullet cluster |
Always ablate — these are ignition points for experiments.
Overlap math
For size (S) and overlap (O), stride = (S - O). Storage ≈ (S / (S - O)) times a no-overlap corpus. At (S=500), (O=100) you store ~1.25× chunks — usually acceptable; at (O=250) you pay 2×.
Also pay for:
- Embed API cost × storage multiplier
- ANN RAM / disk
- Duplicate hits at query time (packing must dedupe)
Ship rule: pick the smallest overlap that recovers boundary-straddle questions on your eval set.
Citation UX requirements
Downstream UI needs:
doc_id, title, URL/page- Optional highlight span offsets if you store them
- Version/timestamp for “as of” honesty
If packing expands to a parent, cite the parent id the user can open — not an orphan child index.
Citation integrity tests
| Test | Expect |
|---|---|
Every packed span maps to openable doc_id |
Pass |
| Deleted doc never cited | Pass (tombstone + filter) |
| Child-only id after parent expand | Fail |
| Cross-tenant doc_id in pack | Fail hard |
Multilingual and Hinglish corpora
- Do not assume English sentence splitters
- Keep
languagemetadata; filter or route embedders if you use per-language models - Mixed Hinglish often works with strong multilingual embedders — verify with a labeled slice
- Digit / transliteration variants (“refund” vs “रिफंड”) may need hybrid keyword assist
Reparse triggers
Re-chunk when any of these change:
- Parser (HTML/PDF) version
- Chunker config version
- Boilerplate strip rules
- Embedding model (always re-embed; usually re-chunk only if splitter also changes)
Safe upgrade runbook
- Freeze eval set; record baseline recall@5 / faithfulness sample
- Build new chunks under a new collection or dual-write side index (
…_chunkerv4) - Shadow-query production traffic (or replay logs) against both
- Cut over app config to new version when gates pass
- Keep old collection read-only for N days; then delete
Never “edit in place” with a new splitter and hope upserts overwrite every old id.
Parent–child storage sketch
parents: { parent_id, text, doc_id, tokens, ... }
children: { child_id, parent_id, text, embedding, ... }Retrieve children via ANN; hydrate parents by parent_id for the prompt. Deduplicate multiple children that map to the same parent before packing.
When parent–child is worth the glue
| Signal | Action |
|---|---|
| High recall@5 on children, weak answers | Expand to parents |
| Parents alone dilute embeddings | Keep children for ANN |
| Storage tight | Store parent text in SoT; vectors only on children |
| Many children → same parent in top-k | Dedupe before budget math |
HTML chrome checklist
Before chunking HTML:
- Remove
nav,footer,header, cookie banners - Drop script/style
- Prefer
main/ article node - Keep heading hierarchy
- Normalize whitespace
- Resolve relative links if you store
urlfor citations - Drop “related articles” side rails that pollute retrieval
Sample 20 random chunks monthly — if you see menus, your stripper regressed.
Worked ablation table (example)
| Config | recall@5 | Notes |
|---|---|---|
| 200 tok / 0 overlap | 0.72 | Precise but fragmented answers |
| 500 / 10% | 0.86 | Best balance on this corpus |
| 1000 / 10% | 0.80 | Diluted embeddings |
| Heading-aware ~500 | 0.88 | Winner — ship it |
Replace numbers with yours. The point is the process, not these fake scores.
Worked narrative (support policy corpus)
- Corpus: 40 help-center articles + 5 PDF policies
- Baseline: fixed 1000-token chunks → recall@5 = 0.74; answers vague
- Heading-aware ~500: recall@5 = 0.88; refund timing questions land on the right section
- Parent–child: children ~200 under section parents → faithfulness sample up; storage +30%
- Ship:
chunker_version=struct-v3, collection suffix_structv3_te3small
Document that story in the design doc so the next engineer does not “optimize” back to 1000-token fixed splits.
Metadata schema versioning
When you add a required field (e.g. language):
- Bump
meta_schema_vNin ingest config - Backfill old payloads or re-ingest
- Reject query filters that assume fields missing on old points
Store schema version in the collection name or a side table.
Schema evolution examples
| Change | Safe approach |
|---|---|
Add optional page |
Backfill best-effort; filters treat missing as unknown |
Require acl_roles |
Re-ingest; block queries without filter helper |
Rename doc_id → source_id |
Dual-write both during window; then cut |
Split acl string → list |
Version bump + migration job |
From chunker to vector upsert (contract)
@dataclass
class Chunk:
stable_id: str
doc_id: str
tenant_id: str
text: str
metadata: dict
content_hash: str
# chunker outputs Chunk; embedder adds vector; store upserts id+vector+payloadKeep the chunker pure (no I/O). Easier to ablate and unit test.
Suggested module boundaries
| Module | Responsibility |
|---|---|
parse |
Bytes → clean structured text / AST |
chunk |
AST/text + config → list[Chunk] |
embed |
Texts → vectors (batched) |
upsert |
Delete-by-doc + write points |
retrieve |
Embed query + filters + ANN |
pack |
Expand / dedupe / budget / citations |
If chunk talks to the network, your ablations become flaky.
Observability for the chunking plane
Log at ingest:
doc_id, tenant_id, parser_version, chunker_version,
n_chunks, n_empty_dropped, n_boilerplate_dropped,
token_total, content_hash, duration_msLog at query / pack:
query_id, hit_child_ids[], packed_ids[], pack_tokens,
dropped_for_budget, diversity_skips, filter_hashAlert on:
- Spike in empty or boilerplate-dropped ratio (parser regress)
- Ingest lag (docs updated but not re-chunked)
- Duplicate-id rate > 0 after refresh jobs
- Pack budget exhaustion rate (always truncating → answers suffer)
Cost model (back of envelope)
| Factor | Scales with |
|---|---|
| Embed $ | #chunks × embed_price (overlap multiplies chunks) |
| Vector RAM | #chunks × dim × bytes × index_overhead |
| Reparse jobs | Full corpus × change frequency |
| LLM $ | Pack tokens × generations (bad packing wastes both) |
A “free” 20% overlap is not free. Measure dollars per recall point gained.
Consistency and freshness
| Situation | Expectation |
|---|---|
| Doc edited in CMS | Searchable within ingest SLO (e.g. 5–15 min) |
| Chunker config bump | Dual-write / new collection; not silent in-place |
| Delete doc | Tombstone SoT first; delete vectors; pack must not cite |
| Partial parse failure | Dead-letter doc; do not upsert half-chrome chunks |
Freshness SLOs belong next to query latency SLOs — stale policy chunks cause confident wrong answers.
Security deep dive (chunk payloads)
| Threat | Mitigation layer |
|---|---|
| Cross-tenant chunk in prompt | Mandatory tenant_id / ACL filters + isolation tests |
| PII in embed API | Redact / minimize before embed; DPA with provider |
| Prompt injection in retrieved HTML | Strip scripts; treat retrieved text as data; cite |
Over-broad doc_type filter removed “to fix empty” |
Change control + eval; never silently drop ACL |
| Logging full chunk text to third-party APM | Scrub or hash; respect retention |
Anti-patterns (chunking edition)
- One vector per entire PDF “to keep it simple”
- Chunking before stripping chrome
- Evaluating only final chatbot answers
- Re-chunk upsert without delete-by-
doc_id - Mixing structure-aware and fixed splits in one collection without
chunker_version - Parent expansion without token budget
- Storing only vectors (no text) so citations are impossible
- English-only regex on Indic corpora
- Letting product managers set chunk size from a blog post without an ablation
Design-doc template (paste into your RFC)
- Corpus types + volumes + growth
- Parser choice + chrome strip rules
- Default chunk strategy + ablation table
- Metadata schema + ACL fields
- Stable id scheme + re-ingest rule
- Packing / citation behavior
- Eval set location + gates (recall@5 floor)
- Reparse / dual-write runbook
- Cost sketch (embed + storage)
- Open risks (tables, PDFs, multilingual)
If any bullet is missing, the design is not ready for production review.
Structure-aware chunking (implementation sketch)
For Markdown / HTML docs sites, prefer an AST walk over blind token windows:
def chunk_by_headings(blocks, max_tokens=600):
"""blocks: list of {heading_path, text} from a Markdown/HTML parse."""
for b in blocks:
toks = tokenize(b["text"])
if len(toks) <= max_tokens:
yield make_chunk(b["heading_path"], b["text"])
continue
# Oversized section: recursive split, keep heading on every piece
for piece in recursive_split(toks, max_tokens):
yield make_chunk(b["heading_path"], detokenize(piece))Heading path in metadata (e.g. Returns > Refunds > Timing) helps rerankers and UI breadcrumbs even when the embedding only sees section body.
Separator priority (recursive baseline)
Typical order when structure is weak:
\n\n\n/ thematic breaks\n\n(paragraphs)\n./?/!(sentence)(last resort — avoid when possible)
Never split inside fenced code if you can detect fences; never split mid-HTML table row.
Late chunking and contextual embeddings (when available)
Some encoders let you embed a long document once, then derive per-span vectors that still “know” surrounding context (late chunking / contextualized chunk embeddings). That can beat naive independent chunk embeds on long narrative docs.
| Classical chunk-then-embed | Late / contextual | |
|---|---|---|
| Pros | Universal; simple ops | Better long-doc coherence |
| Cons | Boundary blindness | Model/API support required |
| Eval | Always your baseline | Adopt only if recall/faithfulness win |
Ship rule: treat late chunking as an ablation arm, not a rewrite of your metadata/ACL story.
Tokenizers vs “words” (gotcha)
Chunk sizes quoted in tokens depend on the tokenizer (cl100k, embedder-specific, etc.). Mismatching “500 tokens” measured with a chat tokenizer vs the embedder’s tokenizer skews ablations.
| Practice | Why |
|---|---|
| Count with the embedder tokenizer when possible | Matches what the model sees |
Log token_count per chunk |
Budget packing honestly |
| Cap on characters as a safety backstop | Avoid pathological token explosions |
Sliding windows on chat and transcripts
Support chats and call transcripts are not docs:
| Unit | Pros | Cons |
|---|---|---|
| Per turn | Precise | Loses prior speaker context |
| Turn + previous N | Better coreference | Overlap cost |
| Time window (e.g. 5 min) | Natural for calls | Uneven token sizes |
| Topic shift (semantic) | Cleaner scenes | Extra model calls |
Always tag speaker / role / channel in metadata. Packing should prefer contiguous turns from the same thread when expanding neighbors.
Code repositories as a corpus
| Rule | Detail |
|---|---|
| Prefer AST units | Function / class / module docstring blocks |
| Path in metadata | repo, path, symbol, language |
| Skip lockfiles / vendored | Noise destroys recall |
README / ADRs separate doc_type |
Different retrieval priors |
| Diffs / PRs | Optional second collection; short retention |
Do not embed minified bundles. Do not one-vector an entire src/ tree.
Tables: concrete patterns
# Good: header repeated per row-group chunk
Columns: Order ID | Status | Refund SLA
Row: 1842 | approved | 5-7 business days
Row: 1843 | pending | 5-7 business days
# Bad: split mid-table without headers
| approved | 5-7 business days |
| pending | …For wide tables, chunk by row groups (10–30 rows) or by primary key entity, not by raw character count across the CSV.
End-to-end sequence: one query
sequenceDiagram
participant U as User
participant App as App
participant Emb as Embedder
participant VDB as Vector DB
participant SoT as Postgres/SoT
participant LLM as LLM
U->>App: "How long do refunds take?"
App->>Emb: embed query
App->>VDB: ANN + tenant/ACL filters
VDB-->>App: child hits
App->>SoT: hydrate parents / neighbors
App->>App: pack + cite under budget
App->>LLM: prompt with packed spans
LLM-->>U: answer + citations
Every failure mode in this article maps to a labeled arrow: bad parse before VDB, missing ACL on the VDB call, or pack blowing the budget before LLM.
Scoring beyond recall@5
| Metric | Catches |
|---|---|
| recall@5 / recall@10 | Missed gold chunks |
| MRR / nDCG | Ranking junk above gold |
| Context precision | Packed noise ratio |
| Faithfulness / citation accuracy | LLM ignoring or inventing spans |
| Latency p95 (pack + retrieve) | Parent expansion too heavy |
| $/1k queries | Overlap + rerank + fat packs |
Optimize the metric that matches the product failure: FAQ bots die on faithfulness; enterprise search dies on recall under ACL.
Capacity planning for chunk counts
Rough planning numbers:
| Docs | Avg chunks/doc | Chunks | Notes |
|---|---|---|---|
| 1k help articles | 8 | ~8k | Fine for pgvector MVP |
| 50k wiki pages | 12 | ~600k | Watch HNSW RAM |
| 2M tickets (windowed) | 3 | ~6M | Dedicated ANN + hybrid |
Overlap at 20% adds ~25% more vectors — fold that into the vector database sizing sheet before you “just raise overlap.”
Team workflow (who owns what)
| Role | Owns |
|---|---|
| Search / RAG eng | Chunker config, eval harness, packer |
| Platform | Collection versioning, re-ingest jobs |
| Security | ACL fields, isolation tests, PII policy |
| Content / docs | Source quality, heading hygiene |
| On-call | Chrome regression alerts, ingest lag |
Chunking is not “an LLM prompt tweak.” It is a versioned data pipeline with owners.
Whiteboard prompt (practice)
Design chunking + metadata for a 30-tenant SaaS: help center (Markdown), PDF policies, and Zendesk tickets. Show id scheme, ACL fields, ablation plan, and how you upgrade the splitter without ghost vectors.
A strong answer hits: per-doc_type strategies, tenant_id+roles on every chunk, delete-by-doc re-ingest, recall@5 gate, packing/citations, dual-write on chunker_version bump.
FAQ (chunking)
Should every doc type share one chunk size?
No. Policies, code, and FAQs often need different strategies — route by doc_type.
Is semantic chunking always better?
No. It costs more and can over-split. Beat a strong heading-aware baseline before adopting it.
Where do I store raw PDFs?
Object store. Chunks and embeddings are derived; keep the raw bytes for reparse.
Can long-context models skip chunking?
They reduce how often you retrieve large spans; they do not remove ACL, cost, or citation needs. Chunking still defines what you index and what you can cite.
How do I pick overlap?
Ablate 0 / 10% / 20% on boundary-straddle questions. Stop when recall gains flatten relative to cost.
Do I embed the parent, the child, or both?
Usually embed children for ANN; store parents for packing. Embedding both doubles cost unless evals prove the need.
What if heading-aware splits yield huge sections?
Cap section size and recursively split oversized headings; keep heading metadata on every piece.
Glossary
| Term | Meaning |
|---|---|
| Chunk | Retrieval unit embedded as one vector |
| Overlap | Shared tokens between adjacent chunks |
| Parent–child | Small unit for ANN; large unit for LLM |
| Packing | Selecting/expanding hits under a token budget |
| content_hash | Fingerprint to skip re-embed |
| chrome | Nav/footer/boilerplate that pollutes embeddings |
| chunker_version | Config id that must travel with the index |
| SoT | System of record for canonical bytes / ACL |
Hands-on lab sequence
- Take 20 docs (Markdown + one multi-column PDF).
- Build fixed 200 / 500 / 1000 and one heading-aware config.
- Label ≥30 questions with gold chunk ids.
- Report recall@5 + a 10-answer faithfulness skim.
- Add parent–child on the winner; re-measure answer quality.
- Break chrome strip on purpose; watch menus enter top-k.
- Re-ingest with a new
chunker_versionusing delete-by-doc_id. - Write a half-page rationale for the shipped default.
That sequence teaches what / why / how better than reading splitter library docs alone.
Putting it together with the rest of the Data track
| Concern | Article |
|---|---|
| ANN / vector store mechanics | Vector databases |
| Vendor / pgvector choice | Choosing vector stores, Postgres + pgvector |
| Exact / semantic cache in front | Redis for AI caching |
| BM25 fusion + rerank after chunks exist | Hybrid search and rerankers |
Chunking sits before brand debates and beside hybrid/rerank: bad units make every later stage look broken.
Common interview traps
| Trap | Better answer |
|---|---|
| “We’ll just use 512 tokens like the blog” | Ablation + corpus-specific default |
| “Smaller chunks always win” | Recall vs answer context tradeoff; packing |
| “Metadata is optional until scale” | ACL and citations are day-one |
| “Re-embed in place when we change splitters” | Delete-by-doc + versioned collection |
| “Long context deletes chunking” | Complements retrieval; does not replace ACL/cost/cite |
Micro-project
Ablate 200 / 500 / 1000-token chunks on a 20-doc corpus; report recall@5 and pick a default with a one-paragraph rationale. Bonus: add a heading-aware arm and a parent–child packing comparison on the same labels.
Related
Guided Chunking; hybrid + rerank; vector databases; Privacy and data for AI for ACL fields.