Index Ai Academy
Phase 1 · Building with LLMs · Context & retrieval

Making RAG good

Hybrid search, reranking and citations.

About 30 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

Why it matters

A RAG demo takes an afternoon. A RAG system people trust takes measurement. Most bad answers aren't the model's fault. The right passage never made it into the prompt. This lesson covers the fixes: hybrid search, reranking and checked citations. They're what separate a support bot that gets 6 in 10 questions right from one that gets 9 in 10. And you can only tell which bot you have if you measure.

What you'll be able to do

  • Measure retrieval with a small labeled test set (hit rate and MRR)
  • Combine keyword and embedding search with reciprocal rank fusion
  • Explain reranking and when it's worth the extra call
  • Check that an answer's citations point at documents you actually retrieved

What it covers

  1. Two places an answer can go wrong
  2. Measure retrieval on its own
  3. Two kinds of search
  4. Worked example: score two searches by hand
  5. Reranking
  6. Citations you can check
  7. A checklist of other fixes

Two places an answer can go wrong

Picture asking a librarian a question. Two things can go wrong. They might fetch the wrong book. Or they might fetch the right book and misread it. If you only hear their final answer, you can't tell which mistake happened.

A RAG system has the same two steps, and each one can fail:

  • Retrieval finds the passages that should answer the question. It fails when the right passage isn't found.
  • Generation writes the answer from those passages. It fails when the passage was found, but the model misused it.

So test the two steps separately. This lesson is mostly about retrieval, because many bad answers start there.

Try it now

When an answer is wrong, first ask which of the two failed. The fixes are completely different.

Measure retrieval on its own

You need a handful of real questions. Label each one with the document that answers it. This is called a labeled test set. Run your search on every question and see where the right document lands. Two numbers sum up the results.

Hit rate at k. Here k is how many results you look at, such as the top 3. Hit rate at k is the share of questions whose correct document appears in the top k results.

In plain words: hit rate at k = questions where the right document made the top k ÷ all questions.

Say you test 10 questions and look at the top 3 results each time. The right document shows up in 6 of them. Hit rate at 3 is 6 ÷ 10 = 0.6. That means four in ten answers start without the facts they need.

MRR, or mean reciprocal rank. The rank is the position of the correct document in the results: 1 for first place, 2 for second, and so on. The reciprocal of a number is 1 divided by that number. So the reciprocal rank is 1 ÷ rank. First place scores 1, second scores 0.5 and third scores 0.33. "Mean" is another word for average.

In plain words: MRR = add up 1 ÷ rank for every question, then divide by the number of questions.

Think of a race where first place earns a full point, second earns half and third earns a third. MRR rewards putting the right document near the top, not only somewhere in the list. Here's a small example. Three questions have their correct documents land 1st, 2nd and 4th. Their scores are 1, 0.5 and 0.25. Those add up to 1.75, and 1.75 ÷ 3 is about 0.58.

The cell below does both calculations in Python, for five real questions. It searches the practice help center with embedding search, which you met in earlier lessons. vector_search returns every article's id, best match first. Python counts list positions from 0, so ranking.index(expected) + 1 turns a position into a rank. The line for hit rate counts the questions whose reciprocal rank is exactly 1, meaning the right document came first.

import requests

API = "https://api.gradient.test/v1"
TESTS = [
    ("How long does express shipping take?", "doc_shipping"),
    ("Do Trail Club points expire?", "doc_loyalty"),
    ("Can you fix a broken zipper?", "doc_warranty"),
    ("Can we call a jacket carbon neutral?", "doc_voice"),
    ("How many exclamation marks can an email have?", "doc_voice"),
]

def vector_search(question):
    hits = requests.get(f"{API}/docs", params={"q": question, "limit": 11}).json()["data"]
    return [h["id"] for h in hits]

reciprocal_ranks = []
for question, expected in TESTS:
    ranking = vector_search(question)
    rank = ranking.index(expected) + 1
    reciprocal_ranks.append(1 / rank)
    print(f"rank {rank:>2}  {question}")
print("hit rate at 1:", sum(r == 1 for r in reciprocal_ranks) / len(TESTS))
print("MRR:", round(sum(reciprocal_ranks) / len(TESTS), 2))
It runs right here in your browser, with nothing to install.

Three questions get the right document in first place. The two about the brand voice guide rank it poorly: 2nd for "carbon neutral" and 4th for "exclamation marks". So hit rate at 1 is 3 ÷ 5 = 0.6. MRR is (1 + 1 + 1 + 0.5 + 0.25) ÷ 5 = 0.75.

Why did those two miss? Embedding search matched "carbon neutral" to the sustainability page. It's a similar topic, but the wrong document. And it found little about "exclamation marks" at all.

Tip: 20 to 50 labeled questions, taken from real customer emails or search logs, are enough to compare two retrieval setups. You'll build full evals in the evals lesson.

Quick check
You have three test questions. The correct document lands 1st, 2nd and 5th. What are hit rate at 1 and MRR?
  1. Hit rate at 1 is about 0.67, and MRR is 0.5
  2. Hit rate at 1 is about 0.33, and MRR is about 2.67
  3. Hit rate at 1 is 1.0, and MRR is about 0.57
  4. Hit rate at 1 is about 0.33, and MRR is about 0.57
Show the answer

Hit rate at 1 is about 0.33, and MRR is about 0.57

Only one of the three questions has the right document first, so hit rate at 1 is 1 ÷ 3, about 0.33. For MRR, the reciprocal ranks are 1, 0.5 and 0.2. They add up to 1.7, and 1.7 ÷ 3 is about 0.57. The 2.67 is the average rank, not the average of 1 ÷ rank.

Embeddings match meaning. That's why a question about a "parcel" finds the shipping page. But embeddings are weak on exact terms. Product names, SKUs (stock codes such as "WX-220"), error codes and rare words like "exclamation" often slip past them.

Classic keyword search is the opposite. It works like the Find box in a document. It only matches words that actually appear, and it's excellent at them.

The standard keyword method is called BM25. The cell below uses a simpler version that counts how many of the question's words each document contains. First it drops stopwords. These are common words like "the", "how" and "can" that appear everywhere and tell you little.

import re
import requests

STOPWORDS = {"a", "an", "the", "and", "or", "of", "to", "in", "on", "for", "with", "is", "are",
             "can", "do", "does", "we", "you", "i", "my", "how", "what", "many", "have", "it"}

def terms(text):
    return {w for w in re.findall(r"[a-z0-9]+", text.lower()) if w not in STOPWORDS}

docs = requests.get("https://api.gradient.test/v1/docs").json()["data"]
query = "How many exclamation marks can an email have?"
scores = {d["id"]: len(terms(query) & terms(d["text"])) for d in docs}
print(sorted(scores.items(), key=lambda item: -item[1])[:3])

Here's what happened. terms lowercases the text, splits it into words and drops the stopwords. The & keeps only the words that two sets share, and len counts them. So each document scores one point per shared word. The last line prints the top three.

The voice guide wins with two matching words: "exclamation" and "email". (The guide says "exclamation mark", so "marks" doesn't match. Plain counting treats them as different words.) The runners-up share only the word "email".

That exposes the weakness of plain counting. "Email" appears in four articles and "exclamation" in only one. Yet both count the same. A rare word tells you much more about which document you want. BM25 gives rare words more weight, which is a big part of why it beats this simple version.

Go deeper What BM25 adds to plain counting

BM25 makes two main changes to the counting you saw above.

First, it weights rare words more. A word that appears in only a few documents earns a big score when it matches. A word found in nearly every document earns very little.

Second, it adjusts for document length. A long document contains more words, so it matches more query words by chance. BM25 trims the score of long documents a little to make up for that.

Hybrid search: meaning plus exact words

Hybrid search runs both searches and merges the two rankings. Picture two friends who each give you a ranked list of restaurants. A place near the top of both lists is a safe bet. A place only one friend mentioned can still be worth a try, if they ranked it highly.

The simplest merge that works well is called reciprocal rank fusion, or RRF. ("Fusion" means merging.) Every list a document appears in gives it some points: 1 ÷ (60 + its rank in that list). Then you add up each document's points and sort by the total.

In plain words: RRF score = for every list the document appears in, add 1 ÷ (60 + its rank in that list).

Quick check
A document ranks first in one list and doesn't appear in the other. What is its RRF score?
print(round(1 / (60 + 1), 4))
  1. 1.0
  2. 0.0164
  3. 0.0167
  4. 0.0328
Show the answer

0.0164

1 / (60 + 1) is 1 / 61, about 0.0164 once rounded. It gets that from the one list it's in, and nothing from the other.

A document near the top of both lists wins. A document that only one method found can still rank well. The 60 is a conventional constant that stops first place from dominating.

Here's the worked example for "Can we call a jacket carbon neutral?". Vector search put the sustainability page first and the voice guide second. Keyword search put the voice guide first, because it contains "jacket", "carbon" and "neutral". The sustainability page came third.

docvector rankkeyword rankRRF score
doc_voice211/62 + 1/61 = 0.0325
doc_sustainability131/61 + 1/63 = 0.0323

Vector search alone put the wrong document first. The keyword evidence tips it, and the voice guide wins by a hair. You'll check this in Python in the worked example below. In the exercise, you'll measure how often this happens across a whole test set.

You rarely need to build this yourself. Most vector databases (pgvector with a text index, Weaviate, Qdrant, Elasticsearch) can run hybrid search for you.

Go deeper Why add 60?

Without the 60, the points would be 1 ÷ rank. First place would earn 1 point, second 0.5 and tenth 0.1. A document that tops just one list would score as much as one that comes second in both (1 = 0.5 + 0.5). One list's top pick would carry too much weight.

With the 60, first place earns 1 ÷ 61 (about 0.0164), second 1 ÷ 62 (about 0.0161) and tenth 1 ÷ 70 (about 0.0143). These are close together. So a document wins by doing well in both lists, not by topping one of them. Other values work too. The constant is a setting you can tune, and 60 is the usual default.

Quick check
Customers search for products by code, like "WX-220". Embedding search keeps returning the wrong product. What helps most?
  1. Ask the model to guess the product
  2. Use longer chunks
  3. Add keyword search and merge the rankings (hybrid search)
  4. Raise the minimum similarity score
Show the answer

Add keyword search and merge the rankings (hybrid search)

Exact codes are where keyword search shines and embeddings struggle. Hybrid search adds keyword matches without losing the meaning-based results.

Worked example

Worked example: score two searches by hand

Say you ran four test questions through two searches. The numbers below are made up for practice. Each list holds the rank of the correct document for each question. The code works out hit rate at 1 and MRR for both searches. Then it checks the RRF scores from the table above.

# Made-up results: where the correct document landed for four test questions
vector_ranks = [1, 3, 1, 2]
hybrid_ranks = [1, 1, 1, 3]

def hit_rate_at_1(ranks):
    return sum(r == 1 for r in ranks) / len(ranks)

def mrr(ranks):
    return sum(1 / r for r in ranks) / len(ranks)

print("vector:", hit_rate_at_1(vector_ranks), round(mrr(vector_ranks), 2))
print("hybrid:", hit_rate_at_1(hybrid_ranks), round(mrr(hybrid_ranks), 2))

# RRF for "Can we call a jacket carbon neutral?"
voice = 1 / (60 + 2) + 1 / (60 + 1)
sustainability = 1 / (60 + 1) + 1 / (60 + 3)
print("doc_voice:", round(voice, 4))
print("doc_sustainability:", round(sustainability, 4))
In the lesson you can change this and run it.

Vector search put 2 of the 4 right documents first, so its hit rate at 1 is 0.5. Its MRR is (1 + 0.33 + 1 + 0.5) ÷ 4, about 0.71. Hybrid search put 3 of 4 first, for a hit rate of 0.75. Its MRR is (1 + 1 + 1 + 0.33) ÷ 4, about 0.83. Both numbers went up, so hybrid search wins here.

In hit_rate_at_1, each r == 1 is True or False, and Python counts True as 1 when it adds them up. The RRF lines print 0.0325 for the voice guide and 0.0323 for the sustainability page. Those match the table.

Reranking

Think about hiring. A recruiter skims 200 job applications quickly and picks 20 that look close. Then the hiring manager reads those 20 carefully against the job. Careful reading is too slow for all 200, but it's worth it for a short list.

Search works the same way. Retrieval has to be fast. So it compares the question with each passage separately, using vectors worked out ahead of time. A reranker reads the question and each candidate passage together. Then it scores how well that passage answers that question.

A reranker is slower and more accurate. So you use it on a short list. Retrieve 20 to 50 candidates cheaply, rerank them, and keep the best 3 to 5.

Dedicated reranking models (from Cohere, Voyage AI and open-source cross-encoders) are fast and cheap. You can also ask an LLM to pick the most relevant passages:

def rerank(question, candidates, keep=3):
    listing = "\n\n".join(f'[{d["id"]}] {d["text"][:400]}' for d in candidates)
    reply = ask("You judge which passages answer a question. Reply with document ids only, best first, one per line.",
                f"Question: {question}\n\nPassages:\n{listing}\n\nList the {keep} most relevant ids.")
    ranked = [line.strip(" []") for line in reply.splitlines() if line.strip()]
    known = {d["id"]: d for d in candidates}
    return [known[i] for i in ranked if i in known][:keep]

Here ask is a small helper like the one in earlier lessons. It sends a system prompt and a prompt, and returns the reply's text. The function lists each candidate as its id in brackets plus its first 400 characters. It asks for the best ids, one per line. Then it strips spaces and brackets from each line of the reply.

Note the last two lines. The code keeps only ids it actually sent. A model can return an id that doesn't exist. Never trust a model's output to be a valid id without checking.

Go deeper Why reading together is more accurate

Embedding search turns the question and each passage into vectors separately. The passage's vector is made before anyone asks a question, so it can't take the question into account. That's what makes it fast: the work is done once, ahead of time.

A cross-encoder takes the question and one passage as a single input. It can weigh every word of the passage against every word of the question. That's more accurate, but it has to run again for every pair. With 10,000 passages, that's 10,000 runs for each question.

Quick check
The model returned three ids, but one of them isn't a real candidate. What does this print?
known = {"doc_returns": 1, "doc_sizing": 2}
ranked = ["doc_sizing", "doc_refunds", "doc_returns"]
print([i for i in ranked if i in known][:2])
  1. ['doc_sizing', 'doc_refunds', 'doc_returns']
  2. ['doc_returns', 'doc_sizing']
  3. ['doc_sizing', 'doc_returns']
  4. ['doc_sizing', 'doc_refunds']
Show the answer

['doc_sizing', 'doc_returns']

"doc_refunds" isn't a key in known, so the filter drops it. The rest keep the model's order, best first, and [:2] keeps them both: ['doc_sizing', 'doc_returns']. The made-up id never reaches your prompt.

Citations you can check

A citation is a tag in the answer that names the source of a fact, such as [doc_shipping]. Picture a student's essay with a footnote pointing to a book the library has never owned. That footnote is made up, however sure it sounds.

Asking for [doc_id] citations is only useful if you verify them. A citation to a document you didn't retrieve means the model invented a source:

import re

reply = "Express takes 1 to 2 business days [doc_shipping]. Members ship free in their birthday month [doc_loyalty]."
retrieved = ["doc_shipping", "doc_returns"]

cited = re.findall(r"\[(doc_[a-z_]+)\]", reply)
unknown = [c for c in cited if c not in retrieved]
print("cited:", cited)
print("not retrieved:", unknown)

re.findall returns every match of a pattern as a list. This pattern looks for [doc_, then lowercase letters or underscores, then ]. It keeps only the id inside the brackets. The reply cites two documents, but only doc_shipping was retrieved. So doc_loyalty is flagged. The birthday fact might even be true, but the model didn't get it from the passages you sent.

Claude's API can also do this for you. Send each document as a document content block with "citations": {"enabled": True}. The reply's text blocks then come back with a citations list. It points at the exact passages used.

{"type": "document",
 "source": {"type": "text", "media_type": "text/plain", "data": doc["text"]},
 "title": doc["title"],
 "citations": {"enabled": True}}
Quick check
What does this citation check print?
cited = ["doc_returns", "doc_care", "doc_returns"]
retrieved = ["doc_returns", "doc_sizing"]
print([c for c in cited if c not in retrieved])
  1. ['doc_sizing']
  2. ['doc_care', 'doc_returns']
  3. []
  4. ['doc_care']
Show the answer

['doc_care']

Only doc_care was cited without being retrieved, so it prints ['doc_care']. doc_returns was retrieved, so both mentions of it pass. doc_sizing was retrieved but never cited, which is fine: the check looks at citations, not at unused passages.

A checklist of other fixes

  • Rewrite the query. A chatty message ("hi, ordered last week, still nothing, help??") makes a poor search query. Ask a model to turn it into one, such as "order delivery time", before searching.
  • Add context to chunks. Before embedding a chunk, put its document title or a one-line summary in front of it. Then "It takes 2 to 4 weeks" is embedded as part of "Lifetime repair warranty".
  • Filter by metadata before ranking. Metadata is information about a document, such as its product line, region or date.
  • Tune k, the number of chunks you put in the prompt. Too few chunks miss facts. Too many bury them. Measure to find the right number.

Practice what you just read

  • 6 code examples you can edit and run
  • a practice exercise that checks your code
  • a 5-question quiz
  • 5 review cards for spaced repetition

Create a free account

Try a quiz question

Hit rate at 3 is 0.95 but answers are still often wrong. Where should you look next?

  1. Generation: the prompt, grounding rules or how the model uses the passages
  2. Embedding dimensions: more of them would make each passage's meaning sharper
  3. Chunk overlap: more of it would stop the right passage being cut across two chunks
  4. The number of documents: a bigger collection would give the model more to draw on

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson