2026-05-11
Core session 3
From extracted text to searchable evidence.
A user asks one question about a 100-page report. What should we send to the LLM?
A. The whole extracted report
B. The first page of the PDF
C. A few relevant chunks with source metadata
D. Nothing — the LLM should answer from memory
ℹ️ RAG works by finding the right evidence, not by sending everything or relying on model memory.
A good answer needs:
So before retrieval, we need:
Chunking is where we decide what the retriever is allowed to find.
Today we focus on the bridge between processed documents and retrieval.
Many of you may use or adapt a StatsChat-like system for your own organisation.
Keep one publication or data source from your own organisation in mind during the session.
By the end, you should be able to explain:
What is the best description of a “chunk” in a RAG system?
A. A random piece of text cut from a document
B. The unit of evidence the retriever can search for and send to the LLM
C. A whole PDF report
D. A summary written by the LLM
ℹ️ A chunk is the searchable evidence unit. Bad chunks make retrieval and generation harder.
Splitting is not just a technical convenience. It shapes what evidence the system can later find.
| Term | Simple meaning |
|---|---|
| Splitting | Breaking long documents/pages into smaller pieces |
| Chunking | Creating retrieval-sized evidence units |
| Embedding | Turning a chunk into a numerical representation of meaning |
| Vector store | The searchable database of chunk embeddings |
| Retrieval | Finding the chunks closest to the user’s question |
sample_text = """
Consumer Price Indices and Inflation Rates - March 2026.
Table 1: One month and twelve month percentage changes by CPI division.
Total: weight 100.0000, one month change 0.5%, twelve month change 4.4%.
Source: Kenya National Bureau of Statistics, CPI release, March 2026.
"""
def chunk_words(text: str, max_words: int, overlap: int = 0) -> list[str]:
words = text.split()
chunks = []
start = 0
while start < len(words):
end = start + max_words
chunks.append(" ".join(words[start:end]))
start = end - overlap
return chunksThe highlighted lines are where the chunk size and overlap shape the output.
max_words = 16, overlap = 4Chunk 1:
Consumer Price Indices and Inflation Rates - March 2026. Table 1: One month and twelve
Chunk 2:
month and twelve month percentage changes by CPI division. Total: weight 100.0000, one month
Chunk 3:
one month change 0.5%, twelve month change 4.4%. Source: Kenya National Bureau of Statistics,The evidence is split across chunks: table title, row and source note are separated.
max_words = 42, overlap = 4Chunk 1:
Consumer Price Indices and Inflation Rates - March 2026. Table 1: One month and twelve month percentage changes by CPI division. Total: weight 100.0000, one month change 0.5%, twelve month change 4.4%. Source: Kenya National Bureau of Statistics, CPI release, March 2026.The chunk is longer, but it keeps the question-relevant evidence together.
What is the biggest risk if chunks are too small?
A Retrieval becomes impossible
B. Important context may be split away from the value or statement
C. The vector store cannot be saved
D. The LLM will always refuse to answer
ℹ️ Small chunks can be precise, but they can lose the heading, table title, unit or source note needed to interpret the evidence.
The best chunk size depends on document structure, model context window, retrieval method and evaluation results.
| Method | Basic idea | When useful |
|---|---|---|
| Fixed-size | split every N characters/words | simple baseline, demos |
| Recursive | try paragraphs, then lines, then words | good general default |
| Structure-aware | split by headings, tables, pages or sections | official reports |
| Semantic | split where topic meaning changes | long narrative text |
| LLM-assisted | ask a model to identify evidence units | complex/messy documents |
Start simple, then improve where evaluation shows problems.
from langchain_text_splitters import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=1000,
chunk_overlap=150,
separators=["\n\n", "\n", " ", ""],
)
chunks = splitter.split_text(extracted_text)This still has a size limit, but it tries not to cut through natural boundaries such as paragraphs and lines.
| Content type | Better chunking choice |
|---|---|
| Prose | keep paragraphs/sections together |
| Tables | keep caption, headers, rows, units and source note together |
| Definitions | keep definition and caveat together |
| Factsheets | keep visual sections and labels together |
| Source notes | keep them with the evidence they qualify |
For official statistics, the best chunk is often a meaningful evidence unit, not a fixed number of characters.
A future StatsChat pipeline might combine methods rather than use one splitter everywhere.
A useful chunk should usually be understandable enough to act as evidence.
Which chunk would be safest for answering “What was annual inflation in March 2026?”
A. Total 100.0000 0.5 4.4
B. Table 1: CPI percentage changes. Total: weight 100.0000, one-month change 0.5%, twelve-month change 4.4%. March 2026.
C. Source: KNBS
D. Food and transport prices...
ℹ️ It keeps the value with the table context, unit meaning and reference period.
The current branch already carries lightweight document context into chunk text before embedding.
# Simplified from statschat/embedding/preprocess.py
self.text_splitter = RecursiveCharacterTextSplitter(
chunk_size=self.split_length,
chunk_overlap=self.split_overlap,
length_function=len,
)
self.chunks = self.text_splitter.split_documents(self.docs)
self.embeddings = HuggingFaceEmbeddings(
model_name=self.embedding_model_name,
model_kwargs={"local_files_only": True},
)
self.db = FAISS.from_documents(self.chunks, self.embeddings)
self.db.save_local(self.faiss_db_root)This is the core: split documents, embed chunks, save vectors and metadata to FAISS.
prefix_lines = []
if title:
prefix_lines.append(f"Title: {title}")
if date:
prefix_lines.append(f"Release date: {date}")
if page_number:
prefix_lines.append(f"Page number: {page_number}")
prefix = "\n".join(prefix_lines)
doc.page_content = f"{prefix}\n\n{body}"This helps otherwise similar numeric chunks carry their title, date and page context into retrieval.
The current approach is sensible for an early RAG prototype, but it reflects older assumptions.
Teaching point: chunking strategy should evolve with models, retrieval methods and evidence needs.
You do not need to understand every dimension. The useful idea is similarity in meaning.
Text:
"Annual inflation was 4.4 percent in March 2026."
Embedding:
[0.08, -0.22, 0.51, 0.17, ..., -0.04]The real vector usually has hundreds or thousands of numbers. We do not read the numbers directly; we use them to compare meanings.
Finds words that match.
Query: “inflation March 2026”
Strong match:
Vector search is powerful, but it still depends on good chunks and metadata.
The query is embedded, then the vector store returns chunks with nearby vectors.
chunks = [
"Annual inflation was 4.4 percent in March 2026.",
"Mobile phone ownership was 53.7 percent nationally.",
"The housing survey describes household conditions.",
"Food and transport prices changed consumer prices.",
]
query = "Annual inflation in March 2026?"
query_vec = vectorise(query)
ranked = sorted(
((cosine(query_vec, vectorise(chunk)), chunk) for chunk in chunks),
reverse=True,
)This is a toy example. Real StatsChat uses embedding models and FAISS, not simple word counts.
0.67 Annual inflation was 4.4 percent in March 2026.
0.00 The housing survey describes household conditions.
0.00 Mobile phone ownership was 53.7 percent nationally.
0.00 Food and transport prices changed consumer prices.The system does not “understand” like a human; it ranks chunks by vector similarity.
What does an embedding represent in this context?
A. A citation to a PDF page
B. A compressed numerical representation of text meaning
C. A cleaned JSON file
D. A final answer from the LLM
ℹ️ An embedding is a numerical representation used for similarity search.
FAISS is not the LLM. It is the local search index used before generation.
A user asks about “latest inflation”. Which choice most affects what evidence reaches the LLM?
A. The PDF file name only
B. The chunking, embeddings, retrieval ranking and date metadata
C. The colour of the report cover
D. The LLM temperature only
ℹ️ The answer depends on chunk quality, semantic similarity, retrieval ranking and recency information.
Your colleague may have used vectorising in a text classification project.
Keep this brief: useful context, but today’s main story is RAG retrieval.
In the lab, you will look at a short extracted text example and compare possible chunking strategies.