Chunking, hybrid search and re-ranking
Most RAG failures are retrieval failures. Learn how to chunk documents, combine keyword and vector search with Reciprocal Rank Fusion, filter with metadata, and re-rank so the right passage reaches the model.
In the previous lesson, RAG worked on three tiny files. Real systems break in the retrieval step: the right passage exists but is not found, or it is found but buried at position 7 when you only send the top 3. This lesson covers the three tools that fix most retrieval problems: better chunking, hybrid search and re-ranking.
You need the pipeline from *Retrieval-augmented generation (RAG) end to end*.
Chunking: how to cut documents
A chunk is the unit you embed and retrieve. If chunks are too big, one vector has to represent many topics, so it matches nothing well, and you waste tokens sending unrelated text. If chunks are too small, a chunk loses its context: "It costs Rs 180" is useless without knowing what "it" is.
Analogy: when you revise from a textbook, a whole chapter is too much to find one fact, and a single sentence torn out of the page may not make sense. A paragraph or a section with its heading is usually right.
Common chunking strategies
| Strategy | How | Good for | Watch out |
|---|---|---|---|
| Fixed size with overlap | Every N characters or tokens, sharing some text with the previous chunk | Plain text with no structure | Cuts sentences and ideas in half |
| By paragraph or sentence | Split on blank lines, pack up to a size limit | Articles, notes, policies | Very uneven sizes if paragraphs vary a lot |
| By structure | Split on headings, functions, rows, Q&A pairs | Markdown, docs, code, FAQs, tables | Needs a parser per format |
| One record per chunk | Each product, menu item or FAQ is one chunk | Structured data | Make sure the text reads like a sentence, not raw JSON |
import re
def sliding_window(text, size=500, overlap=100):
"""Fixed-size chunks that share `overlap` characters with the previous one."""
chunks, start = [], 0
while start < len(text):
chunks.append(text[start : start + size])
start += size - overlap
return chunks
def split_by_headings(markdown):
"""One chunk per section; each chunk keeps its heading as context."""
parts = re.split(r"(?m)^(?=#{1,3} )", markdown)
return [p.strip() for p in parts if p.strip()]
def add_context(heading_path, text):
"""Prefix a chunk with where it came from, so it makes sense on its own."""
return f"{heading_path}\n{text}"
def reciprocal_rank_fusion(rankings, k=60):
"""Merge several ranked lists of ids. Each list: best id first."""
scores = {}
for ranking in rankings:
for position, doc_id in enumerate(ranking, start=1):
scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + position)
return sorted(scores, key=scores.get, reverse=True)Three chunkers' building blocks plus the rank-merging function used later in this lesson.
sliding_window is the baseline: size=500, overlap=100 means each chunk repeats the last 100 characters of the previous one, so a sentence cut at a boundary still appears whole in one chunk. split_by_headings is usually better for documents that have headings. add_context shows a very effective trick: prefix each chunk with its title or heading path (for example Refund policy > Digital items). The prefix is embedded with the chunk, so the chunk matches queries about its topic even when the paragraph itself never repeats the topic name.
Start here, then measure
There is no universal best size. A sensible start is 200 to 500 words for prose, or one natural unit (a Q&A pair, a menu item) for structured data. Then measure with real questions (see *Evaluating AI apps (evals)*) and adjust. Changing chunking means re-embedding everything, so keep the indexing script rerunnable.
Metadata and filtering
Store facts about each chunk next to it: source file, page, section, date, category, language, who may see it. Then you can filter before searching: only the bakery category, only documents from 2026, only files this user may read. Filtering is cheap, exact and removes whole classes of wrong answers. In a vector database this is a where filter (see *Vector databases: pgvector, Chroma and friends*). Metadata also gives you citations: "policies.txt, page 2".
Do not rely on the prompt for permissions
Access control belongs in the filter, not in the prompt. If a user must not see HR documents, those chunks must never be retrieved for that user. Asking the model to "ignore confidential parts" is not security.
Why vector search alone is not enough
Embeddings capture meaning, which is great for "Do you deliver?" matching "pickup only". But they are weak on exact tokens: product codes, error codes, names, IDs and rare words. A query like BB-4471 or ORA-00942 has no "meaning" to match. Classic keyword search (BM25, the ranking used by search engines for decades) is the opposite: excellent on exact words, blind to synonyms.
Each method fails where the other succeeds
| Query | Keyword (BM25) | Vector (embeddings) |
|---|---|---|
| "Do you deliver?" (docs say "pickup only") | Misses: no shared words | Finds it: same meaning |
| "Order BB-4471" | Finds it: exact token | Often misses: a code has no meaning |
| "something cold and not bitter" | Weak | Finds Cold Brew |
| "ORA-00942 error" | Finds the error page | May return generic error advice |
Hybrid search
Hybrid search runs both searches and merges the results. The two scores are on different scales (BM25 scores are unbounded, cosine is between -1 and 1), so adding them directly is tricky. Reciprocal Rank Fusion (RRF) avoids the problem by ignoring scores and using only rank: each list gives a document 1 / (k + rank) points, and you add up the points. A document that is near the top of both lists wins. k=60 is the usual default.
The reciprocal_rank_fusion function above is the whole algorithm. Here it is used with the rank-bm25 library (pip install rank-bm25) and the rag.py from the previous lesson.
import re
import numpy as np
from rank_bm25 import BM25Okapi
from chunking import reciprocal_rank_fusion
from rag import build_index, embed
def tokenize(text):
return re.findall(r"\w+", text.lower())
chunks, matrix = build_index("docs")
bm25 = BM25Okapi([tokenize(c["text"]) for c in chunks])
def hybrid_search(question, k=3, pool=10):
# 1. keyword ranking
kw_scores = bm25.get_scores(tokenize(question))
kw_rank = list(np.argsort(-kw_scores)[:pool])
# 2. vector ranking
q = embed([question], "RETRIEVAL_QUERY")[0]
vec_rank = list(np.argsort(-(matrix @ q))[:pool])
# 3. merge both lists
merged = reciprocal_rank_fusion([kw_rank, vec_rank])
return [chunks[int(i)] for i in merged[:k]]
if __name__ == "__main__":
for hit in hybrid_search("Is order code BB-4471 ready?"):
print(hit["source"], "|", hit["text"][:70])Runs BM25 and vector search over the same chunks and merges them with RRF. Run it from the folder that has rag.py, chunking.py and docs/.
Many databases do this for you: PostgreSQL full-text search next to pgvector, Elasticsearch/OpenSearch, and several vector databases have a built-in hybrid mode. Know the idea so you can use whichever your stack offers.
Re-ranking
Vector search is fast because it compares precomputed vectors, but it judges the question and the passage separately. A re-ranker reads the question and one passage together and scores how well the passage answers it. That is slower but much more accurate. So the usual pattern is two stages:
1. Retrieve a wide pool cheaply (for example the top 20 from hybrid search). 2. Re-rank those 20 and keep the best 3 to 5 for the prompt.
Re-rankers come in three flavours: a cross-encoder model (for example from the sentence-transformers library, runs locally), a hosted re-rank API, or simply an LLM asked to score passages. The last needs no new library, so it is a good first version.
You are a search relevance judge.
Question: [user question]
Below are numbered passages. For each passage, give a score from 0 to 10 for how directly it answers the question (10 = contains the answer, 0 = unrelated).
[1] [passage text]
[2] [passage text]
[3] [passage text]
Return only JSON in this shape: {"scores": [{"id": 1, "score": 7}, ...]}What to expect: Ask for JSON (see *Structured output with JSON schemas*) so you can sort programmatically. Keep only passages with a high score, and send the top few to the answering step.
Re-ranking is not free
Every re-ranking call adds latency and cost. Re-rank 20 short passages, not 200. Measure whether it actually improves your hit rate before keeping it.
Other levers worth knowing
| Technique | Idea | Use when |
|---|---|---|
| Query rewriting | Ask the model to rewrite the question into a clear search query, or into several | Users type short or vague questions, or follow-ups like "and the price?" |
| Retrieve more, send less | Fetch 20, re-rank, send 3 | Answers are in the corpus but ranked 4th to 10th |
| Parent-child chunks | Search small chunks, but send the larger parent section to the model | Small chunks match well but lack context |
| Keep context order | Put the most relevant chunk first (and last) in the prompt | Long prompts where the middle gets ignored |
A practical order of attack
Improve one thing at a time
- 1Build a small test set: 15 to 30 real questions, each with the document it should come from.
- 2Measure retrieval first: for each question, is the right chunk in the top k? (the hit rate).
- 3Fix chunking and add metadata filters. Re-measure.
- 4Add hybrid search if exact terms (codes, names) fail. Re-measure.
- 5Add re-ranking if the right chunk is in the top 20 but not the top 3. Re-measure.
- 6Only then tune the answer prompt.
How this comes up in interviews
Be ready to answer
- "Your RAG bot misses answers that are in the documents. What do you check?" Answer: look at retrieved chunks, chunk size and overlap, metadata filters, then hybrid search and re-ranking, measuring hit rate after each change.
- Explain why embeddings struggle with exact identifiers and how BM25 helps.
- Explain RRF in one sentence and why it avoids score normalisation.
- Explain the difference between a bi-encoder (embeddings) and a cross-encoder (re-ranker).
Try it: Compare chunk sizes
Take a document of about 3 pages. Chunk it with sliding_window at sizes 200, 500 and 1500 (overlap 20 percent). Write 10 questions whose answers are in the document. For each size, embed the chunks and count how many questions have the right chunk in the top 3.
Try it: Break and fix with an ID
Add the line "Order code BB-4471 is ready for pickup at counter 2." to a docs file. Ask vector-only search Where is BB-4471? and then run hybrid_search. Which finds it more reliably? Try three more made-up codes.
Key takeaways
- Chunks should be about one idea: big enough to keep context, small enough to match precisely. Add the title or heading to each chunk.
- Store metadata and filter before searching. Permissions belong in filters, not prompts.
- Vector search handles meaning, BM25 handles exact words. Hybrid search combines them, and Reciprocal Rank Fusion merges rankings without comparing scores.
- Re-ranking reads the question and passage together: retrieve wide (20), re-rank, send narrow (3 to 5).
- Measure retrieval hit rate on a test set, and change one thing at a time.
Check yourself
Finishing the quiz marks the lesson as done.
1. Chunks in your index are 3,000 words each and answers feel vague and expensive. What is the most likely fix?
2. Users search for 'ERR-5021' and the answer page is not retrieved. Which addition most directly helps?
3. What does Reciprocal Rank Fusion use to merge two result lists?
4. Why is re-ranking applied to a small pool (like 20) rather than the whole corpus?
5. A user must only see documents from their own department. Where should this be enforced?
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.