Cut It Right
The first real user message arrives on a Wednesday afternoon.
Not a test query. Not Arjun typing into his own terminal. A message from Meera — the boutique owner in Pune who had agreed to beta test ShopBot before it was ready — sent through the rough chat interface Arjun had wired up over the weekend.
"Does the silk saree need dry cleaning?"
A simple question. The answer exists in the catalog. Arjun had written it himself — Dry clean only. Machine washing may damage the zari border.
He watches the ShopBot response appear in the interface.
"The breathable cotton kurta is ideal for summer occasions and features a lightweight feel perfect for warm weather. Available in white, sky blue, and mint green, sizes S to XXL, priced at ₹1,299."
He reads it twice. ShopBot described the cotton kurta. In response to a question about the silk saree's care instructions.
He opens the query log. The retrieval had returned the cotton kurta as the most similar document to "Does the silk saree need dry cleaning?" The silk saree — the product with the actual answer — ranked third.
He stares at this for a long time, the chai beside his laptop going cold while his brain tries to reconcile what should have happened with what did.
Then he messages Meera: "Found a bug. Working on it."
Her reply comes in two minutes. "I noticed. My customer is waiting."
He closes the laptop. Opens it again. The problem does not go away.
Why the Wrong Product Was Returned
The silk saree is in ChromaDB. The dry-cleaning instruction is part of its stored text. The retrieval had access to it.
So why did the cotton kurta rank higher?
Arjun pulls up the exact text he stored for each product and reads them carefully — not as a developer checking for bugs, but as a reader trying to understand what each blob of text is actually about.
The silk saree entry, in its full ingested form, ran to two hundred and forty-one words: fabric type, occasion, colours, price, care instructions, return policy, wedding suitability, climate suitability, size guidance. Eight different topics in a single chunk.
The customer's query was about one topic. "Does the silk saree need dry cleaning?" But the saree's stored vector represents the average of all eight topics. The vector is pulled toward festive wear, toward pricing, toward returns, toward FAQ-shaped content — toward everything in the chunk at once. The care instruction — four words out of two hundred and forty-one — barely moves the average.
The cotton kurta entry, by contrast, had heavier emphasis on summer and lightweight fabric. The query phrase Does the … need has the linguistic shape of FAQ-style writing, and the kurta entry happened to have slightly more FAQ-adjacent phrasing in it. So the kurta's vector was, by accident, closer to the query's vector than the saree's diluted vector was.
The saree's care instruction was correct, present, and ingested. It was just diluted beyond retrieval usefulness by everything else in the same blob.
This is the blob problem. Not a bug. A structural flaw in how the data was prepared.
The Opposite Failure: Too Small
The instinct after seeing the blob problem is to cut everything into the smallest possible pieces. One sentence per chunk. Maximum precision.
Arjun tries it. Splits every product description sentence by sentence. Re-ingests. Then tests two queries.
"What colours does the silk saree come in?" — ChromaDB returns "Available in red, gold, and emerald." Score 0.91. Correct.
"Tell me about the silk saree." — ChromaDB returns "Dry clean only — do not machine wash." Score 0.79.
The care-instruction sentence ranked highest for a general product query because, on its own, "Dry clean only — do not machine wash" did not even contain the word saree. The chunk lost its context the moment it was separated from the rest. A sentence that says dry clean only is meaningless until something anchors it to a specific product.
Two failure modes, opposite but equal. Too large and the meaning is diluted. Too small and the context is lost. The right chunk size is neither — it is the right concept size, anchored with enough context to mean something in isolation.
The Three Approaches Arjun Considers
Before settling on the right structure, Arjun writes out three approaches. Drona's habit again — never reach for the elaborate fix before understanding why the simple ones fail.
Approach 1: Write better product descriptions.
Make each product description more focused — less on every feature, more on what distinguishes it. If the saree entry led with care requirements, the dry-cleaning query might retrieve it more reliably.
He tests this with a revised saree description that begins with care. The dry-cleaning query retrieves the saree correctly. He is briefly satisfied.
Then he tests "does the silk saree come in emerald?" — a colour query against the same care-focused description. The colour information is now underweighted in the chunk's embedding. The retrieval becomes unreliable for colour queries while improving for care queries.
The problem is structural. A product has multiple attributes that customers ask about. Any single description-level emphasis helps some queries and hurts others. There is no weighting of one chunk that satisfies all query types simultaneously.
Approach 2: Store each product multiple times with different emphases.
Create five different versions of each product entry — one emphasising care, one emphasising colours and sizes, one emphasising occasion, one emphasising price and returns, one general.
This works better. Each query type now has a focused entry to retrieve from.
It also creates an immediate problem. The collection has twenty-five entries for five products, with overlapping content and no clear relationship between them. Updates become a maintenance nightmare — changing the return policy means finding and editing the policy text in five different versions of every product. The data becomes difficult to reason about. The model retrieves contradictory chunks from the same product when phrasings drift apart over time.
Approach 3: Chunk by concern.
Keep one representation of each product, but split it into separate chunks — each chunk covering one type of information. The care instruction lives in a care chunk. The colour and size information lives in a variants chunk. The return policy lives in a policy chunk. Each FAQ lives in its own FAQ chunk.
Each chunk gets its own embedding — a vector that represents only that one aspect of the product, undiluted by everything else. Each chunk carries the product name, so it does not lose context when retrieved on its own.
This is the correct approach. Not because it is sophisticated — it is not — but because it aligns the structure of the stored data with the structure of customer questions. Customers ask one type of question at a time. The stored data should be one type of answer at a time.
The Chunking Strategy
Arjun pulls the previous week's support tickets — the same ones he read in Chapter 1 — and categorises them by what the customer was asking about.
Forty-three percent ask about variants and availability — "Does this come in XL?" "Is emerald in stock?"
Twenty-eight percent ask about identity and occasion — "What is this made of?" "Is this suitable for a wedding?"
Seventeen percent ask about policy — "What is the return window?" "Can I exchange for a different colour?"
Twelve percent ask FAQ-style follow-ups — "Will this shrink after washing?" "Does it come with a lining?"
Four query types. Five chunk types — identity, variants, policy, care, and one FAQ chunk per FAQ entry. The structure of the chunk schema is the structure of the customer's questions.
# src/chunker.py
def product_to_chunks(product: dict) -> list[dict]:
"""
Convert a product dictionary into focused chunks.
Each chunk covers one type of customer question.
"""
pid = product["id"]
name = product["name"]
chunks = []
# Chunk 1 — Identity. Answers: what is this? what fabric? what occasion?
identity_text = (
f"{name}. {product['description']} "
f"Category: {product['category']}. "
f"Fabric: {product.get('fabric', 'not specified')}. "
f"Occasion: {product.get('occasion', 'general')}."
)
chunks.append({
"id": f"{pid}_identity",
"text": identity_text,
"metadata": {
"product_id": pid, "chunk_type": "identity",
"category": product["category"], "price": product.get("price", 0),
},
})
# Chunk 2 — Variants. Answers: what colours? what sizes? what price?
variant_text = (
f"{name} is available in colours: {', '.join(product.get('colors', []))}. "
f"Available sizes: {', '.join(product.get('sizes', ['one size']))}. "
f"Price: ₹{product.get('price', 'not listed')}. "
f"Stock status: {product.get('stock_status', 'in stock')}."
)
chunks.append({
"id": f"{pid}_variants",
"text": variant_text,
"metadata": {
"product_id": pid, "chunk_type": "variants",
"category": product["category"], "price": product.get("price", 0),
},
})
# Chunk 3 — Policy. Answers: can I return? exchange? what is the window?
if product.get("return_policy"):
policy_text = (
f"Return and exchange policy for {name}: {product['return_policy']} "
f"Exchange policy: "
f"{product.get('exchange_policy', 'contact support for exchanges')}."
)
chunks.append({
"id": f"{pid}_policy",
"text": policy_text,
"metadata": {
"product_id": pid, "chunk_type": "policy",
"category": product["category"], "price": product.get("price", 0),
},
})
# Chunk 4 — Care. Answers: how do I wash? machine or hand?
if product.get("care_instructions"):
care_text = (
f"Care instructions for {name}: {product['care_instructions']}"
)
chunks.append({
"id": f"{pid}_care",
"text": care_text,
"metadata": {
"product_id": pid, "chunk_type": "care",
"category": product["category"], "price": product.get("price", 0),
},
})
# Chunk 5+ — FAQs. Each FAQ becomes its own chunk for maximum precision.
for i, faq in enumerate(product.get("faqs", [])):
faq_text = (
f"Question about {name}: {faq['question']} "
f"Answer: {faq['answer']}"
)
chunks.append({
"id": f"{pid}_faq_{i}",
"text": faq_text,
"metadata": {
"product_id": pid, "chunk_type": "faq",
"category": product["category"], "price": product.get("price", 0),
},
})
return chunks
The function takes a richer product dictionary than the simple strings used in earlier chapters and returns a list of focused chunks. Each chunk begins with the product name, so the embedding has the anchor it needs to be retrievable in isolation. Each chunk's metadata records its chunk_type, which becomes useful in Chapter 6 when the retriever wants to filter by question type before similarity runs.
The Expanded Catalog
The chunker requires structured product data. Arjun rebuilds the catalog with full attributes.
# data/products.py
PRODUCTS = [
{
"id": "p001",
"name": "Breathable Cotton Kurta",
"description": "Lightweight daily-wear kurta crafted from 100% breathable cotton. "
"Ideal for summer and warm weather.",
"category": "kurta",
"fabric": "100% cotton",
"occasion": "casual, daily wear",
"colors": ["white", "sky blue", "mint green"],
"sizes": ["S", "M", "L", "XL", "XXL"],
"price": 1299,
"stock_status": "in stock",
"return_policy": "Returns accepted within 7 days. Item must be unworn "
"and in original packaging with tags.",
"exchange_policy": "One free size exchange within 10 days of delivery.",
"care_instructions": "Machine wash cold on gentle cycle. Do not bleach. "
"Tumble dry low. Iron on medium heat.",
"faqs": [
{
"question": "Does this kurta shrink after washing?",
"answer": "Minimal shrinkage of 2–3% may occur after first wash. "
"We recommend washing cold and air drying to maintain fit.",
},
{
"question": "Is this suitable for office wear?",
"answer": "Yes, the cotton kurta is appropriate for casual office "
"environments. Pair with straight-fit trousers for a "
"polished look.",
},
],
},
{
"id": "p003",
"name": "Printed Silk Saree",
"description": "Six-yard printed silk saree with hand-embroidered zari border. "
"Rich festive drape suitable for weddings, receptions, and "
"formal celebrations.",
"category": "saree",
"fabric": "pure silk with zari embroidery",
"occasion": "festive, wedding, formal",
"colors": ["red", "gold", "emerald"],
"sizes": ["standard — fits most"],
"price": 4299,
"stock_status": "in stock",
"return_policy": "Returns accepted within 7 days. Saree must be unstitched, "
"unused, with original packaging.",
"exchange_policy": "Exchanges available for colour only within 5 days.",
"care_instructions": "Dry clean only. Machine washing will damage the zari border "
"and cause silk to lose its sheen. Store in a muslin cloth "
"bag away from direct sunlight.",
"faqs": [
{
"question": "Can this be worn for a wedding?",
"answer": "Yes. The zari border and silk fabric make this saree "
"appropriate for weddings, receptions, and formal festivals.",
},
{
"question": "Is the saree pre-stitched or unstitched?",
"answer": "The saree is unstitched — a standard six-yard drape. "
"No blouse is included. Blouse fabric can be sourced separately.",
},
],
},
# ... remaining three products follow the same structure
]
Run the chunker.
# check_chunks.py
from data.products import PRODUCTS
from src.chunker import product_to_chunks
all_chunks = []
for product in PRODUCTS:
chunks = product_to_chunks(product)
all_chunks.extend(chunks)
print(f"{product['name']}: {len(chunks)} chunks")
print(f"\nTotal chunks: {len(all_chunks)}")
Output:
Breathable Cotton Kurta: 5 chunks
Printed Silk Saree: 5 chunks
Heavyweight Woolen Shawl: 4 chunks
Linen Co-ord Set: 4 chunks
Embroidered Anarkali Suit: 5 chunks
Total chunks: 23
Twenty-three chunks from five products. Each one focused on a single concept, each one anchored with the product name, each one carrying metadata that the retriever can use later. The care instruction for the silk saree is now an isolated vector — it no longer competes with colour information, pricing, FAQ content, and occasion suitability for the meaning of the embedding.
Rebuilding the Index
The chunker is ready. The product catalog is ready. The ChromaDB index is not — it still holds the five flat product texts from Chapter 4. The retriever in Chapter 6 searches this index. If it is not rebuilt now, the queries there will land against stale data and the scores will not match.
Open src/ingest.py. The Chapter 4 version embedded one flat text per product. Replace it entirely with the version that uses the chunker.
# src/ingest.py ← replaces the Chapter 4 version entirely
import chromadb
from langchain_openai import OpenAIEmbeddings
from dotenv import load_dotenv
from data.products import PRODUCTS
from src.chunker import product_to_chunks
load_dotenv()
embeddings_model = OpenAIEmbeddings()
client = chromadb.PersistentClient(path="./chroma_db")
collection = client.get_or_create_collection(
name="zudyog_products",
metadata={"hnsw:space": "cosine"},
)
all_chunks = []
for product in PRODUCTS:
chunks = product_to_chunks(product)
all_chunks.extend(chunks)
print(f" {product['name']}: {len(chunks)} chunks")
print(f"\nTotal chunks to embed: {len(all_chunks)}")
texts = [c["text"] for c in all_chunks]
vectors = embeddings_model.embed_documents(texts)
collection.add(
ids=[c["id"] for c in all_chunks],
embeddings=vectors,
documents=texts,
metadatas=[c["metadata"] for c in all_chunks],
)
print(f"Ingested {collection.count()} chunks into ChromaDB.")
Delete the old index and rebuild it.
rm -rf chroma_db/
python src/ingest.py
Expected output:
Breathable Cotton Kurta: 5 chunks
Heavyweight Woolen Shawl: 4 chunks
Printed Silk Saree: 5 chunks
Linen Co-ord Set: 4 chunks
Embroidered Anarkali Suit: 5 chunks
Total chunks to embed: 23
Ingested 23 chunks into ChromaDB.
The index now holds twenty-three focused chunks. Every query from Chapter 6 onward searches this index.
The Meera Query, Fixed
Arjun re-ingests with the new chunked structure and reruns Meera's query.
# check_retrieval.py
# Run after re-ingesting: python src/ingest.py
# Then run: python check_retrieval.py
import os
import chromadb
from dotenv import load_dotenv
from langchain_openai import OpenAIEmbeddings
load_dotenv()
embedder = OpenAIEmbeddings(model="text-embedding-3-small")
client = chromadb.PersistentClient(path="./chroma_db")
collection = client.get_collection("zudyog_products")
results = collection.query(
query_embeddings=[embedder.embed_query("Does the silk saree need dry cleaning?")],
n_results=2,
include=["documents", "metadatas", "distances"],
)
for doc, meta in zip(results["documents"][0], results["metadatas"][0]):
print(f"[{meta['chunk_type']}] {doc[:80]}...")
Output:
[care] Care instructions for Printed Silk Saree: Dry clean only.
Machine washing will damage the zari border...
[faq] Question about Printed Silk Saree: Can this be worn for a wedding?
Answer: Yes. The zari work...
The care chunk is retrieved first. The answer is correct. The second result is a saree FAQ — related, from the right product, harmless as additional context.
The cotton kurta does not appear at all.
The same query, against the same data, with the same model. The only difference is the structure of what was stored. The Pramana Framework treats chunk design as evidence design — the shape of the stored chunk determines the shape of the retrieved truth, and bad evidence produces Grounding Failure even when the data is technically present.
Arjun messages Meera. "Fixed. Try again."
Her reply comes in four minutes. "Perfect. That's the one."
He reads her message and feels — quietly, in the small specific way these things land — that the difference between a system that almost works and one that actually works is invisible to the people building it and completely obvious to the first person who uses it.
The One Rule
Arjun stands up. Walks to the small whiteboard above his desk — the one Krishna has been using for whiteboard problems since the company was three months old. Picks up the marker. Writes one line in his careful capitals.
One chunk. One question. One clear answer.
He caps the marker. Sets it down. Sits back at the laptop. The line stays on the board for the rest of Book 1.
The rule is not always achievable perfectly. Some information overlaps. Some queries span types. But as a guiding principle — chunk by what customers ask, not by what products contain — it produces better retrieval than any alternative he has tested.
Common Chunking Mistakes
Before Chapter 6, it is worth naming the mistakes that appear regularly when developers first implement chunking.
Too small. Chunks of one or two sentences lose context. "Dry clean only." — dry clean what? Without the product name, the chunk is nearly unsearchable.
Too large. Chunks longer than three or four hundred words dilute meaning toward the average, recreating the blob problem in miniature.
Splitting mid-sentence. Automated chunkers that split at character-count boundaries often bisect sentences. "Machine washing may damage the zari border and" — and what? The answer is in the next chunk. The retrieval finds one, misses the other.
No metadata. Chunks with no attached metadata cannot be filtered. A query asking specifically about sarees cannot be pre-filtered to saree chunks. Every chunk in the collection is searched, including irrelevant ones.
Identical chunks across different products. If two products have similar return policies and both are stored with the same generic text, retrieval becomes arbitrary. Make each chunk specific to its product — include the product name explicitly, every time.
A Reader Exercise
Take the five-product catalog and add one additional product of your choice — a real garment you own or can describe accurately. Give it the same structured fields the existing products have: identity, variants, policy, care, FAQs.
Run it through the product_to_chunks function and inspect the output. Then ask three questions of the result.
Does each chunk contain enough context to be retrievable on its own — does it include the product name? Is any chunk longer than three hundred words? If so, which type of information should be split further? What customer question would each chunk answer? If you cannot answer that, the chunk is too broad.
Then ingest the chunks and test three queries against your new product — one identity question, one variant question, one care or policy question. Observe which chunk_type is retrieved for each. If the wrong chunk type surfaces — a variants chunk for a care question — examine the chunk text and consider what is causing the semantic confusion. [Effort: 25 minutes.]
What Just Happened
One embedding per product dilutes meaning across all product attributes — the blob problem. Any query targeting one attribute competes with all the others for dominance in the vector. Cutting everything into single sentences fixes the dilution but loses context — dry clean only is meaningless without something to anchor it to a product. The right chunk size is neither tiny nor blob-shaped; it is the right concept size, focused on one type of question and anchored with the product name. Twenty-three chunks from five products. Identity, variants, policy, care, FAQs. Meera's dry-cleaning query, broken before this chapter, retrieves the correct chunk after it.
The data was always there. The structure was wrong. Restructuring the data — without changing a single fact in it — moved the silk saree's care instruction from rank three to rank one, and moved the cotton kurta out of the result set entirely. The Grounding Layer is only as good as the evidence it holds. Evidence is shaped by chunking. Chunking, more than any other single decision in the book, determines whether retrieval works.
Chapter 6 builds the retriever — the component that takes a customer question, embeds it, queries the chunked store, and decides which chunks to pass to the model. It also adds the one feature that prevents an entire class of failure the current setup cannot catch: the moment when the closest chunk is still not close enough.