Mindket

RAG chunking strategies: chunk size, overlap, contextual retrieval and hybrid search

A black banner titled "RAG" over the line "Embeddings, chunking, retrieval and evals".

Chunking decides what a RAG system can find. Start with a recursive splitter at a few hundred tokens with some overlap, split by headings where the document has them and keep source metadata on every chunk. Then raise retrieval quality one step at a time: add context to each chunk, add keyword search and rerank, and keep a step only if it helps on your own questions.

In this lesson

  • Pick a chunk size and overlap and know what each setting trades away.
  • Split Markdown by headings, keep the headings as metadata and handle Chinese text.
  • Add context to chunks with Anthropic's Contextual Retrieval prompt.
  • Fuse vector and keyword results with reciprocal rank fusion, then rerank the best candidates.
All courses
On this page
  1. Before you start
  2. What chunking decides
  3. Split by structure, then by size
  4. Chinese, Japanese and Thai
  5. Keep metadata on every chunk
  6. Add context to each chunk
  7. What the context gains, and what to check
  8. Hybrid search with rank fusion
  9. Rerank the candidates
  10. Jo Bergum on where embeddings fail, LlamaIndex on what context costs
  11. Common mistakes
  12. Try it yourself
  13. Next
  14. Check yourself
  15. Frequently asked questions
  16. Sources

Before you start

This lesson builds on lesson 1, where the splitter used a fixed size, and on the chunks table from lesson 2. You need Python 3 and pip install -qU langchain-text-splitters.

The context step calls the Claude API with your key, the rerank step uses a Voyage AI key as in lesson 1, and the hybrid query runs on Postgres with pgvector.

What chunking decides

A chunk is the unit your system embeds, retrieves and shows to the model. Cut it too large and one vector has to stand for several topics. Cut it too small and the chunk loses the words that say what it is about. LlamaIndex states the trade-off directly: a smaller chunk size means more precise embeddings, and a larger one means more general embeddings that can miss fine-grained details. Anthropic's post describes chunks as usually no more than a few hundred tokens.

Changing the size has a second effect. LlamaIndex advises raising similarity_top_k when you shrink chunks, and its example doubles it from 2 to 4 after halving the size.

OpenAI accepts 100 to 4096 tokens per chunk and an overlap of at most half the chunk size. LangChain's recursive splitter measures characters. From LangChain, LlamaIndex and OpenAI.
SourceChunk sizeOverlapCounted in
LangChain tutorial, RecursiveCharacterTextSplitter1000200Characters
LlamaIndex defaults102420Not stated on the page
OpenAI file search defaults800400Tokens

Split by structure, then by size

LangChain says to start with RecursiveCharacterTextSplitter for most cases. It tries a list of separators in order, ["\n\n", "\n", " ", ""] by default, so it keeps paragraphs together as long as it can, then sentences, then words.

A document with its own structure can do better. A Markdown file is organized by headers, and MarkdownHeaderTextSplitter splits on the ones you name and writes their text into each chunk's metadata. LangChain also lists splitters for HTML, JSON and code, and LlamaIndex has node parsers for the same formats.

from langchain_text_splitters import MarkdownHeaderTextSplitter, RecursiveCharacterTextSplitter

with open("refund-policy.md", encoding="utf-8") as f:
    markdown_document = f.read()

# 1. Split on headings. The heading text lands in each section's metadata.
headers_to_split_on = [
    ("#", "Header 1"),
    ("##", "Header 2"),
    ("###", "Header 3"),
]
markdown_splitter = MarkdownHeaderTextSplitter(headers_to_split_on, strip_headers=False)
sections = markdown_splitter.split_text(markdown_document)

# 2. Cut the sections that are still too long. split_documents keeps the metadata.
text_splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)
chunks = text_splitter.split_documents(sections)

for chunk in chunks:
    print(chunk.metadata, chunk.page_content[:60])
  • Call split_documents, not split_text, on the sections. Only then does each chunk keep its header metadata.
  • Overlap appears only when a single section is longer than chunk_size, and it never crosses a section boundary.
  • strip_headers is on by default, so the header line leaves the chunk text and stays in the metadata. If a header turns into a tiny chunk of its own, turn it back on.
  • The default splitter strips white space and new lines. LangChain points to ExperimentalMarkdownSyntaxTextSplitter when you need the original formatting.
  • Keep the empty string "" at the end of separators, so text with no newlines or spaces can still be split.

Chinese, Japanese and Thai

These writing systems have no word boundaries. LangChain warns that the default separators can then split a word between two chunks. Its fix is to extend the list with the punctuation of those scripts: full stops, commas and the zero-width space.

The size is still counted in characters, because the recursive splitter measures chunk size by number of characters.

from langchain_text_splitters import RecursiveCharacterTextSplitter

text_splitter = RecursiveCharacterTextSplitter(
    separators=[
        "\n\n",
        "\n",
        " ",
        ".",
        ",",
        "​",  # Zero-width space
        ",",  # Fullwidth comma
        "、",  # Ideographic comma
        ".",  # Fullwidth full stop
        "。",  # Ideographic full stop
        "",
    ],
    chunk_size=1000,
    chunk_overlap=200,
)

Keep metadata on every chunk

In LangChain, a Document holds the text and a metadata dict that can capture its source and its relation to other documents. Setting add_start_index=True adds the character offset where each chunk began. That metadata does real work later:

  • Citations. Lesson 1 passes each chunk's source and title to Claude's search_result blocks, so the answer can point back to the passage.
  • Filtering. LlamaIndex notes that metadata can filter data before the top-k similarity search. OpenAI file search takes attributes for the same purpose, with up to 16 keys of 256 characters each.
  • Isolation. LlamaIndex's multi-tenancy note says metadata filters let each user search only their own indexed documents. On an approximate pgvector index the filter runs after the scan, as lesson 2 explains.

Add context to each chunk

Anthropic's Contextual Retrieval post starts from a chunk that reads "The company's revenue grew by 3% over the previous quarter." Alone it names neither the company nor the period, so retrieval cannot match it to a question about ACME Corp in Q2 2023. The fix is to prepend a short explanation to each chunk before embedding it, which the post calls Contextual Embeddings, and before building the BM25 index, which it calls Contextual BM25.

A model writes the explanation. Anthropic's prompt hands it the whole document and the chunk and asks for a short, succinct context that situates the chunk for search. The result is usually 50 to 100 tokens. The post used Claude 3 Haiku and pairs the method with prompt caching, so the document loads into the cache once instead of once per chunk.

import anthropic

claude = anthropic.Anthropic()  # reads ANTHROPIC_API_KEY

CONTEXT_PROMPT = """<document>
{whole_document}
</document>
Here is the chunk we want to situate within the whole document
<chunk>
{chunk_content}
</chunk>
Please give a short succinct context to situate this chunk within the overall document for the purposes of improving search retrieval of the chunk. Answer only with the succinct context and nothing else."""


def contextualize(whole_document: str, chunk_content: str) -> str:
    response = claude.messages.create(
        model="claude-haiku-5-5",
        max_tokens=200,
        messages=[
            {
                "role": "user",
                "content": CONTEXT_PROMPT.format(
                    whole_document=whole_document, chunk_content=chunk_content
                ),
            }
        ],
    )
    return next(block.text for block in response.content if block.type == "text")


for chunk in chunks:
    context = contextualize(document_text, chunk.page_content)  # document_text: the full text this chunk came from
    chunk.metadata["context"] = context
    text_to_index = f"{context}\n\n{chunk.page_content}"  # embed this, and build the BM25 index from it
  1. Split the document into chunks as before.
  2. For each chunk, send the whole document and the chunk to the model with the prompt above.
  3. Store the returned context beside the chunk and prepend it to the chunk text.
  4. Embed the contextualized text, and build the BM25 index from the same text.
  5. When you generate the answer, pass the contextualized chunk and mark which part is context and which is the chunk. The post says this can improve the response.

What the context gains, and what to check

Anthropic measured failed retrievals as 1 minus recall@20, the share of relevant documents missing from the top twenty chunks. The chart shows the averages across codebases, fiction, ArXiv papers and science papers with Gemini Text 004 embeddings. The reranked run used the Cohere reranker.

Two other ideas gave little in the same experiments. Generic document summaries added to chunks gave limited gains, and hypothetical document embeddings and summary-based indexing performed poorly.

  • Chunk boundaries still matter. Chunk size, boundary and overlap all affect retrieval.
  • The embedding model matters. Every model improved, and Anthropic found Gemini and Voyage embeddings particularly effective.
  • A prompt written for your domain may do better, for example one that includes a glossary of terms defined only in other documents.
  • Voyage AI sells a shortcut: voyage-context-4 returns chunk vectors that capture the full document context without manual augmentation. The Voyage page claims higher retrieval accuracy and less sensitivity to your chunking strategy. Anthropic's post did not test it.
  • Always run evals. Lesson 4 shows how.
Failed retrievals in the top 20 chunksLower is better. Anthropic reports the failure rate as 1 minus recall@20.
  • Embeddings alone5.7%
  • Contextual Embeddings3.7%
  • Contextual Embeddings and BM252.9%
  • Plus reranking1.9%

Source: Anthropic: Contextual Retrieval (read November 4, 2026)

Hybrid search with rank fusion

Embeddings capture meaning and can miss an exact string. The post's example is a query for "Error code TS-999" in a support database. An embedding model may find content about error codes in general and miss the exact match, while BM25 looks for that string. BM25 is a lexical ranking function built on TF-IDF. It adds document length and a saturation function on term frequency, so common words do not dominate.

Anthropic's recipe is to get the top chunks from each method, then combine and deduplicate them with rank fusion. Reciprocal Rank Fusion gives each chunk the sum of 1 / (k + rank) over the lists it appears in. The pgvector-python example below uses k = 60, one list from vector search and one from Postgres full-text search, which the pgvector README names as its hybrid pairing.

One difference from the post: Postgres ranks with ts_rank_cd, a different function from BM25, and the example uses the english text search configuration. Check how your language is tokenized before you trust the keyword half. A vector database with built-in hybrid search is the other route LlamaIndex lists, and OpenAI file search exposes ranking_options.hybrid_search with embedding_weight and text_weight for the same fusion.

Vector, keyword or both
EmbeddingsBM25Both, fused Our pick
Finds a paraphrase of the questionYesPartlyYes
Finds an exact identifier such as TS-999PartlyYesYes
Needs one kind of index onlyYesYesNo

Our pick: Both, fused. Anthropic's experiments found embeddings plus BM25 better than embeddings alone, at the cost of a second index and a fusion step.

Sources: Anthropic: Contextual Retrieval (read November 4, 2026), LlamaIndex: Basic strategies (read November 4, 2026)

Rerank the candidates

Hybrid search still returns chunks of mixed quality. A reranker scores them again. Voyage AI describes its rerankers as cross-encoders: embedding models encode the query and each document separately, while a reranker processes a query and document pair jointly, which allows a more accurate relevance score. You apply one to the top candidates from embedding search or from lexical search such as BM25.

Anthropic's pipeline takes the top 150 chunks from the first stage, reranks them and keeps the best twenty for the prompt. Reranking adds a step at run time and so a little latency, and the post tells you to tune how many chunks you rerank against cost and speed. The Voyage page lists rerank-3 as the highest-accuracy model for most applications and rerank-3-lite for latency-sensitive ones, both with a 32,000-token context.

import voyageai

vo = voyageai.Client()  # reads VOYAGE_API_KEY

# candidates: the text of each chunk the hybrid query returned (Anthropic's post started from 150)
reranked = vo.rerank(query, candidates, model="rerank-3", top_k=20)

for result in reranked.results:
    print(f"{result.relevance_score:.4f}", result.document[:60])

context_chunks = [result.document for result in reranked.results]  # these go into the prompt
  • LlamaIndex calls reranking one of the biggest levers for RAG quality. Its recipe pairs similarity_top_k=10 with a local cross-encoder, cross-encoder/ms-marco-MiniLM-L6-v2, and top_n=3.
  • An LLM-based reranker can beat a cross-encoder when latency is not critical, at the cost of extra LLM calls, per the same page.
  • Anthropic tested the Cohere reranker. The post says it did not have time to test Voyage's.

Jo Bergum on where embeddings fail, LlamaIndex on what context costs

Jo Kristian Bergum runs Hornet.dev, a retrieval engine built for agents, by his X bio. On 30 August 2025 he wrote on X that some people know him as "the BM25 guy", and shared a paper by researchers at Google DeepMind and Johns Hopkins. It proves that an embedding's dimension caps how many different top-k result sets it can return. On the paper's deliberately simple LIMIT test, BM25 found 93.6% of the relevant documents in its top hundred results. The best single-vector model found 18.9%.

The paper, revised in March 2026, also tests the reverse. With every item swapped for a synonym, BM25 lost nearly 90% of its score and fell behind most embedding models. Keep both halves of the fused query, then. Your test questions should include some that reuse your documents' exact words and some that paraphrase them.

LlamaIndex, an AI research lab for document parsing and extraction by its X bio, pointed out on X on 1 October 2024 what Contextual Retrieval costs. Every chunk takes one model call, and every call carries the whole document as a prefix. The contextualize() loop above has that shape. Its prompt opens with the document, so only the chunk changes from call to call, which is what a prompt cache needs. Send one document's chunks one after another rather than mixing documents.

This requires N LLM calls where each call has the entire document stuffed in as a prefix, so this depends heavily on prompt caching for token cost reduction.

LlamaIndex, AI research lab for document parsing and extraction, by its X bio · X (read November 5, 2026)
Jo Kristian Bergum (@jobergum) on X, 30 August 2025: So some of you know me as the BM25 guy and here is one for you. On The Unreasonable Effectiveness of BM25. The actual title is On the Theoretical Limitations of Embedding-Based Retrieval.
Jo Kristian Bergum on X, 30 August 2025
LlamaIndex (@llama_index) on X, 1 October 2024, on implementing Contextual Retrieval RAG. The post calls it a RAG trick from Anthropic that improves retrieval and is made cost-effective through prompt caching. It prepends metadata to each chunk about where the chunk sits in the whole document.
LlamaIndex on X, 1 October 2024
Greg Kamradt on YouTube, on five levels of text splitting for retrieval, as its title says. A notebook of the same name on GitHub lists them, from character splitting to agentic chunking.

Screenshots of X's own embeds of the posts, taken on 5 November 2026, with the time in UTC. X cuts LlamaIndex's post short with Show more. Select one to open the post on X.

Common mistakes

  • Mixing units. LangChain counts characters, OpenAI counts tokens, and a limit written in one is wrong in the other.
  • Changing the chunk size and leaving the number of retrieved chunks alone. LlamaIndex raises similarity_top_k when it shrinks the size.
  • Setting an OpenAI overlap above half the chunk size. The docs say it should not exceed max_chunk_size_tokens / 2.
  • Using the English default separators on Chinese text, which can cut words in two.
  • Showing the model the bare chunk when you indexed the contextualized one. Pass the context too, and label it.
  • Borrowing a number from someone else's corpus. A chunk count, size or overlap that won for Anthropic or OpenAI says little about your documents.

Try it yourself

  • Split one of your Markdown files with both splitters and print each chunk with its metadata. Which headings survive?
  • Run the same ten questions at three chunk sizes and note which chunk the right answer came from each time.
  • Write context for five chunks with the Anthropic prompt and read them. Would you find each chunk from its new text alone?
  • Search for an exact identifier from your documents with vectors only, then with the fused query.

Next

Lesson 4 shows how to measure retrieval and answers, so each choice above rests on numbers from your own questions. To feed an index from files, databases or the web, the MCP servers on Mindket connect an agent to those sources.

Check yourself

  1. You split a Markdown file by headers, then cut long sections down to size. Which call keeps each section's header metadata on the chunks?

    Show answer

    C. RecursiveCharacterTextSplitter.split_documents(sections) LangChain says to use split_documents rather than split_text after the header split, so the per-section metadata survives.

  2. Your Chinese documents are being cut in the middle of words. What does LangChain suggest?

    Show answer

    A. Add the ideographic full stop and comma to the separators list The default separators assume spaces. LangChain extends the list with full stops, commas and the zero-width space for scripts without word boundaries.

  3. In Anthropic's Contextual Retrieval, what gets prepended to each chunk before embedding and BM25 indexing?

    Show answer

    D. A short model-written note that places the chunk within its document The prompt asks for a short context that situates the chunk in the overall document. Generic document summaries gave limited gains in the same experiments.

  4. What does the rank fusion step do in hybrid search?

    Show answer

    B. Merges the vector and keyword result lists by rank into one list Reciprocal Rank Fusion adds 1 / (k + rank) for each list a chunk appears in, then sorts by the total.

Frequently asked questions

What is the best chunk size for RAG?

No source names one best size. Defaults differ: OpenAI file search uses 800 tokens, LlamaIndex 1024 and LangChain's tutorial 1000 characters. Start with one and compare retrieval on your own questions, because smaller chunks give more precise embeddings and larger ones can miss fine detail.

How much overlap should chunks have?

Treat it as a setting to tune. LangChain says overlap mitigates loss of information when context is divided between chunks. OpenAI's default is 400 tokens on an 800-token chunk and it allows at most half the chunk size, while LlamaIndex defaults to a much smaller overlap.

Is semantic chunking worth it?

Measure it first. LlamaIndex's semantic splitter picks breakpoints between sentences by embedding similarity, so each chunk holds related sentences. Its page warns that the sentence regex mainly works for English and that you may have to tune the breakpoint percentile threshold. The pages we read report no gain over a recursive splitter.

How do I chunk Chinese or Japanese text?

Extend the separator list of RecursiveCharacterTextSplitter with the ideographic full stop, the fullwidth full stop, commas and the zero-width space, as LangChain's guide shows.

What is contextual retrieval, and is it worth it?

It adds a short, model-written note to each chunk before indexing. In Anthropic's experiments that cut failed retrievals by 35% with embeddings alone and 49% with BM25 added, at the price of one model call per chunk. Prompt caching reduces that cost.

Is hybrid search better than vector search alone?

Anthropic found embeddings plus BM25 better than embeddings alone. LlamaIndex says embeddings can fail to return chunks with matching keywords and names hybrid search as the usual fix. It costs a second index and a fusion step.

How many chunks should I pass to the model?

Anthropic tried three counts and found the largest, twenty chunks, the most performant, with the warning that extra information can distract the model. Retrieve more than you keep, rerank, then test your own number.

Sources

  1. LangChain Docs: Text splitter integrations (read November 4, 2026)https://docs.langchain.com/oss/python/integrations/splitters
  2. LangChain Docs: Splitting recursively (read November 5, 2026)https://docs.langchain.com/oss/python/integrations/splitters/recursive_text_splitter
  3. LangChain Docs: Split markdown (read November 4, 2026)https://docs.langchain.com/oss/python/integrations/splitters/markdown_header_metadata_splitter
  4. LlamaIndex Docs: Basic strategies (read November 5, 2026)https://developers.llamaindex.ai/python/framework/optimizing/basic_strategies/basic_strategies/
  5. LlamaIndex Docs: Node parser modules (read November 4, 2026)https://developers.llamaindex.ai/python/framework/module_guides/loading/node_parsers/modules/
  6. OpenAI Docs: Retrieval (read November 5, 2026)https://developers.openai.com/api/docs/guides/retrieval
  7. Anthropic: Contextual Retrieval (read November 4, 2026)https://www.anthropic.com/research/contextual-retrieval
  8. Voyage AI Docs: Rerankers (read November 5, 2026)https://www.mongodb.com/docs/voyageai/models/rerankers
  9. Voyage AI Docs: Contextualized Chunk Embeddings (read November 5, 2026)https://www.mongodb.com/docs/voyageai/models/contextualized-chunk-embeddings
  10. Voyage AI Docs: Quickstart (read November 4, 2026)https://www.mongodb.com/docs/voyageai/quickstart
  11. pgvector README (read November 4, 2026)https://github.com/pgvector/pgvector
  12. pgvector-python: hybrid search example (read November 4, 2026)https://github.com/pgvector/pgvector-python
  13. Jo Kristian Bergum on X: On the Theoretical Limitations of Embedding-Based Retrieval (read November 5, 2026)https://x.com/jobergum/status/1961663599195144642
  14. arXiv: On the Theoretical Limitations of Embedding-Based Retrieval (v2, March 2026) (read November 5, 2026)https://arxiv.org/abs/2508.21038
  15. LlamaIndex on X: Implementing Contextual Retrieval RAG (read November 5, 2026)https://x.com/llama_index/status/1841210062167294287
  16. Greg Kamradt on YouTube: The 5 Levels Of Text Splitting For Retrieval (read November 5, 2026)https://www.youtube.com/watch?v=8OJC21T2SL4
  17. GitHub: 5 Levels Of Text Splitting notebook (read November 5, 2026)https://github.com/FullStackRetrieval-com/RetrievalTutorials/blob/main/tutorials/LevelsOfTextSplitting/5_Levels_Of_Text_Splitting.ipynb

Share this lesson

Find MCP servers for your data

Browse the MCP servers on Mindket that connect an agent to databases, files and search, the usual sources for a RAG index.

Browse MCP servers

This lesson as Markdown · RAG course: build retrieval-augmented generation · All courses