Modules/Gen AI/ MMR Lab

Maximal Marginal Relevance

The three most relevant chunks are sometimes three copies of the same idea. MMR picks the next chunk by how much it adds, not by how relevant it is in isolation.

Selection

0.5

1.0 = pure relevance (same as MMR off). 0.0 = pure diversity, ignores relevance entirely.

query: "database indexing performance", top 3 of 6 selected

Selected, In Order

—

Pairwise Similarity

Coverage

Sub-topics covered1 of 3

 

Overview

The three most relevant chunks are sometimes three copies of the same idea.

In this guide
  1. Context first
  2. The formula
  3. The problem with taking the top five
  4. The lambda dial
  5. A worked choice
  6. Running the choice, and watching lambda move it
  7. Guided tests
  8. What to recall
  9. Where diversity helps and where it hurts
  10. The options
  11. Real notes
  12. Questions people ask
  13. Recap in one screen

MMR: A Practical Guide

Trading a little relevance for a lot of coverage.

Context first

Plain top-k retrieval picks the k highest-scoring documents on its own. If a document collection has a few near-the same passages about the same popular sub-topic, all of them can legitimately score near the top — and a naive top-k fills the context with repetition instead of coverage. This wastes the retrieval budget on saying the same thing three times. Maximal Marginal Fit (MMR) fixes this by selecting documents iteratively. At each step, it picks the document that boosts a weighted combination of fit to the query and diversity from the documents now selected. The formula is MMR = argmax[ lambda * sim(doc, query) - (1 - lambda) * max(sim(doc, selected)) ]. Lambda controls the trade-off: lambda=1 is pure fit (same as top-k), lambda=0 is pure diversity (maximally unlike what you now have). Values between 0.5 and 0.7 are typical. The "marginal" in the name means the incremental value of adding one more document given what has now been chosen. MMR is used in RAG pipelines (LangChain, LlamaIndex) where the fetched passages turn context for a language model, and redundant context wastes the model's limited context window. It also appears in extractive summarisation, where selecting diverse sentences gives a better summary. This page fetches documents with plain top-k and with MMR, shows the likeness matrix, and lets you adjust lambda to see the fit-diversity trade-off.

The formula

MMR = argmaxd ∈ left [ λ · fit(d, query) − (1−λ) · maxs ∈ selected likeness(d, s) ]

Choice happens one document at a time, not all at once. The first pick is any is most apt, just like ordinary retrieval. Each pick after that is penalised by how close it is to any has now been chosen — so a document that would have ranked highly on fit alone can lose to a less apt document that covers new ground.

The problem with taking the top five

A vector search returns the five chunks closest to the query. On a corpus with any redundancy, those five are often near-copies of each other.

Ask "what is the refund policy?" of a corpus holding the policy in the handbook, in the FAQ, in a support macro and in two versions of the terms page. And the top five results are five statements of the same thing. Four slots wasted, and any nuance — the errors, the timescales, the process — is not fetched at all.

Maximal marginal fit fixes this by selecting results one at a time, each chosen to be apt to the query and unlike what has now been selected:

MMR = argmaxd [ λ · sim(d, q) − (1 − λ) · maxs ∈ selected sim(d, s) ]

The first term is fit. The second is a cost for likeness to a thing now chosen. λ sets the balance.

The lambda dial

λBehaviour
1.0Pure relevance — identical to plain top-k
0.7Mostly relevance, some diversity — the usual default
0.5Balanced
0.3Diversity-heavy; marginal results start appearing
0.0Maximum diversity, relevance ignored

0.5–0.7 is the range that works for most retrieval. Below about 0.3 the algorithm starts selecting documents chosen mainly for being unlike the others. This is not what you want.

The algorithm itself is greedy and short:

def mmr(query_vec, cand_vecs, lam=0.7, k=5):
    sim_q = cand_vecs @ query_vec                  # relevance to the query
    selected = [int(sim_q.argmax())]

    while len(selected) < k:
        best, best_score = None, -1e9
        for i in range(len(cand_vecs)):
            if i in selected:
                continue
            redundancy = max(cand_vecs[i] @ cand_vecs[j] for j in selected)
            score = lam * sim_q[i] - (1 - lam) * redundancy
            if score > best_score:
                best, best_score = i, score
        selected.append(best)
    return selected

Note that it runs on a candidate set, not the whole corpus. Retrieve 30–50 by plain likeness first, then apply MMR to select the final 5. Running it over a million vectors would be quadratic and pointless.

A worked choice

Query: "refund policy". Suppose five candidates with these similarities to the query, and high mutual likeness among the first three:

DocRelevance to query
A: Handbook refund section0.89
B: FAQ refund entry0.87
C: Terms page refunds0.86
D: Refund processing timescales0.71
E: Exceptions for digital goods0.68

Plain top-3 returns A, B, C — three statements of the same policy.

MMR with λ = 0.6 picks A first (highest fit). For the second slot, B scores 0.6×0.87 − 0.4×0.93 = 0.15. While D scores 0.6×0.71 − 0.4×0.35 = 0.29. D wins despite being less apt, because it adds data.

The result is A, D, E — the policy, the timescales and the errors. That is a far better context for answering the question. And it came from the same candidate set.

Running the choice, and watching lambda move it

The formula and the dial are described above. Here they are executed -- the greedy loop written out, each candidate's score at each step. And the point where turning lambda stops adding diversity and starts discarding fit.

example_01.pyNumPy
Output

Building on the same setup, the next block shows the left cases.

example_02.pyNumPy
Output

The below block covers the left steps.

example_03.pyNumPy
Output

The next block goes on with the left demonstrations.

example_04.pyNumPy
Output

Building on the same setup, the next block shows the left cases.

example_05.pyNumPy
Output

Guided tests

  1. Read the pairwise likeness grid. Three of the six candidates are near-copies of each other — high likeness across that block — while the rest cover different ground.
  2. Leave MMR off. The top 3 by fit alone are the query-planning chunk plus two of the three near-copy B-tree chunks — the hash-index sub-topic is crowded out fully. This covers only 2 of 3 sub-topics with two of three slots spent restating the same B-tree point.
  3. Turn MMR on at λ=0.5. The first pick does not change — it is still the most apt document. Since nothing has been selected yet to be close to. The second and third picks do change: instead of a second B-tree chunk, MMR reaches for the hash-index one, and coverage goes from 2 of 3 sub-topics to all 3.
  4. Push λ to 1.0. The likeness cost vanishes fully and the result is the same to MMR being off — λ=1 is pure fit by construction.
  5. Pull λ to 0.0. Fit stops mattering at all; only spreading out matters. This can surface a barely-tied document purely because nothing now picked resembles it.

What to recall

MMR selects documents one at a time, and after the first pick, each later choice is weighed against how close it is to what has now been selected — trading some fit for coverage. λ controls the trade right: 1.0 is ordinary top-k fit ranking, 0.0 ignores fit and boosts spread. And all of it between balances the two. It costs one likeness check against the selected set per candidate per step. This is cheap next to embedding or reranking and is standard in any RAG system fetching more than a couple of chunks per query.

Where diversity helps and where it hurts

It helps when the question has a few facets. "What are the risks of this approach?" wants a few distinct risks, not five phrasings of the biggest one.

It helps on redundant corpora. Documentation that repeats itself, support content duplicated across channels, versioned documents.

It helps summarisation and overview tasks, where coverage matters more than precision.

It hurts on narrow factual questions. "What is the notice period?" has one answer. And the best three chunks are probably all about it. Diversity here means adding less apt material.

It hurts when the corpus is now diverse. If there is no redundancy, the cost term does nothing except distort the sorting slightly.

That split suggests treating λ as query-dependent rather than fixed: high for lookup questions, lower for exploratory ones. Some systems classify the query type and set it so.

The options

MethodHow it deduplicates
MMRPenalises similarity to already-selected results
Exact deduplicationHash the text, drop identical chunks
Near-duplicate filteringDrop candidates above a similarity threshold to an earlier result
ClusteringGroup candidates, take one per cluster
Parent deduplicationCollapse children of the same parent document

Threshold sifting is the simpler cousin of MMR and often enough. Go down the ranked list and skip a thing with cosine likeness above 0.95 to a thing now kept. It handles the near-copy case without changing the sorting of truly distinct results.

Parent deduplication is worth doing regardless if you use parent-child retrieval. And it fixes a set and common source of redundancy.

The pragmatic recommendation: start with threshold-based near-copy sifting, which is cheap and rarely harmful. Add MMR when the queries are truly multi-faceted.

Real notes

It needs the candidate vectors, not just their ids. The redundancy term compares candidates with each other. Retrieval must return the embeddings, or they must be re-fetched.

Order matters after choice. MMR chooses a set; the order in which it is presented to the model still matters,. This is because attention favours the ends of a long context. Keep the most apt first.

It interacts with reranking. A cross-encoder gives more exact fit scores; running MMR on reranked candidates using cross-encoder scores for the fit term and embedding likeness for the redundancy term works well.

Cost is tiny on 30–50 candidates — a few thousand dot products.

Questions people ask

What λ should I use? 0.5–0.7 as a default. Higher for factual lookup, lower for exploratory questions.

Is MMR better than simple deduplication? More principled, and more likely to change the result set. Threshold deduplication is simpler and adequate when the problem is exact repeats.

Does it improve answer grade? On multi-faceted questions and redundant corpora, measurably. On narrow factual questions it can reduce it slightly.

How many candidates should I pass it? 30–50. Too few and there is nothing diverse to choose; too many and the cost term starts favouring outliers.

Can I use it with hybrid search? Yes — fuse the ranked lists first. Then apply MMR to the fused candidates.

Does it help with the "lost in the middle" problem? Indirectly, by making each fetched chunk carry distinct data. So fewer slots are wasted.

Recap in one screen

  • Plain top-k on a redundant corpus returns a few statements of the same thing.
  • MMR selects greedily, scoring each candidate for fit minus likeness to what is now selected.
  • λ balances the two; 0.5–0.7 suits most retrieval, higher for narrow factual questions.
  • Apply it to a candidate set of 30–50, not to the whole corpus.
  • Simpler near-copy threshold sifting is often enough; MMR earns its place on multi-faceted queries.

Recall check

0 of 3

Say the answer out loud before you reveal it — recalling it is what makes it stick, and rereading it is not.

  1. What does this module say about “Context first”?

  2. What does this module say about “Selection”?

  3. What does this module say about “The formula”?

Cheat sheet

Maximal Marginal Relevance (MMR)

Pick the next document by relevance minus similarity to what you already picked, and watch three near-duplicate chunks stop crowding out everything else.

GEN AI · vizlearn.in/gen_ai/maximal_marginal_relevance.html

Further reading

About the author

Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.