Vibe Engines
YouTube

Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path

CODING CHALLENGE · N°20

MMR Reranking

Medium AI EngineeringRetrievalRAG

Top-k by similarity returns five copies of the same paragraph. Maximal Marginal Relevance fixes it: pick results that are relevant to the query AND different from what you already picked. The greedy diversity algorithm inside serious RAG pipelines.

The problem

Given a query vector q, candidate document vectors docs, a tradeoff lam (λ ∈ [0,1]) and k, return the indices of the k documents selected by Maximal Marginal Relevance: repeatedly pick the unselected document maximizing lam · cos(q, d) − (1 − lam) · max(cos(d, s) for s in selected), where the penalty term is 0 for the first pick. Use cosine similarity. Break score ties by the lower index.

SOLVE IT YOURSELF

Your turn — write it

Edit the stub, hit Run (or ⌘/Ctrl + Enter), and watch the hidden tests. Stuck? open the hints under the problem, or Reveal solution is one click away.

YOUR TASK

Implement mmr(q, docs, lam, k) → list of k selected indices, greedily maximizing λ·relevance − (1−λ)·redundancy with cosine similarity.

CPython · WebAssembly
Examples and constraints 4 examples
EXAMPLE 1
Input q = [1, 0], docs = [[0.9, 0.1], [0.85, 0.15], [0.3, 0.8]], lam = 1, k = 3
Output [0, 1, 2]
λ=1 disables the diversity penalty: pure relevance order
EXAMPLE 2
Input same q and docs, lam = 0.3, k = 2
Output [0, 2]
doc 1 is a near-copy of doc 0 (cosine 0.998), so the diverse doc 2 takes the second slot
EXAMPLE 3
Input same q and docs, lam = 0.7, k = 2
Output [0, 1]
with more weight on relevance, the near-copy wins the second slot back
EXAMPLE 4
Input same q and docs, lam = 0.5, k = 1
Output [0]
edge case: the first pick is pure relevance, because no penalty exists yet
CONSTRAINTS
  • Return document indices (0-based), in selection order.
  • First selection = highest cos(q, d) — no penalty exists yet.
  • Tie on MMR score → the smaller index wins.
  • k ≤ len(docs); vectors are non-zero.
Hints 4 ideas
HINTS — 4 IDEAS
  1. Write a cosine helper first: dot(a,b) / (|a|·|b|).
  2. Keep a selected list; each round score every unselected candidate and take the argmax.
  3. The redundancy term is the MAX similarity to any already-selected doc — not the average.
  4. λ=1 should reduce to plain top-k by query similarity; use that as your sanity check.
Approach, complexity & discussion — open after you solve

Start from brute force

The obvious approach is plain top-k: score every document by cos(q, d), sort, and take the first k. It is correct for its own objective, relevance, and cheap: O(n·d + n log n) time for n documents of dimension d, and O(n) space for the scores. It is wrong for this one, because it never compares documents with each other. On the example documents it returns [0, 1], and document 1 is a near-copy of document 0 (cosine 0.998), so half of the context budget buys a paraphrase of what is already there while the different document 2 is left out.

The approach

Build the list greedily. Each round, score every unselected candidate by lam · rel − (1 − lam) · red, where rel = cos(q, d) is its relevance and red is its maximum cosine to anything already selected, and keep the best. Max, not average, is the point: one near-copy in the selection is enough to make a candidate redundant.

The reference does exactly this. _cos is the cosine helper, selected grows by one index per round, red = max((_cos(docs[i], docs[j]) for j in selected), default=0.0) makes the first round pure relevance, and the strict score > best_score keeps the lower index on a tie. Its cost is recomputing every rel and every red from scratch each round. The optimal version computes rel once per document and keeps a running red[i]: after each pick it sets red[i] = max(red[i], cos(docs[i], picked)) against only the new document, which leaves every score identical.

Walk through an example

With q = [1, 0], docs = [[0.9, 0.1], [0.85, 0.15], [0.3, 0.8]], lam = 0.3 and k = 2, round 1 is pure relevance, and in round 2 document 1’s 0.998 similarity to document 0 sinks it below document 2, giving [0, 2].

steprounddocrelredscore = 0.3·rel − 0.7·reddecision
1100.993900.2982best so far
2110.984800.2954lower
3120.351100.1053lower: pick 0
4210.98480.9980−0.4031best so far
5220.35110.4524−0.2113higher: pick 2

Complexity

With relevance cached and a running maximum, selection is O(k·n·d) time, one new cosine per candidate per round, with O(n) extra space for rel and red. The reference recomputes the maximum over the whole selected set every round: O(k²·n·d) time, O(k) space. Plain top-k is O(n·d + n log n) time and O(n) space.

Edge cases to test

  • An exact duplicate can survive at lam = 0.5: in mmr([1,0], [[1,0],[1,0],[0,1]], 0.5, 2) the copy scores 0.5·1 − 0.5·1 = 0, the orthogonal document scores 0.5·0 − 0.5·0 = 0, and the lower-index tie-break returns [0, 1].
  • lam = 0 ignores relevance entirely: every first-round score is 0, so the first pick is index 0 whatever its relevance. mmr([1,0], [[0.3,0.8],[0.9,0.1]], 0.0, 1) returns [0], the less relevant document.
  • lam = 1 is plain top-k: [0, 1, 2] on the example documents.
  • k larger than the number of documents: once no candidate is left, the reference appends its -1 sentinel, so k = 4 on the three example documents returns [0, 1, 2, -1] in both languages. Clamp k first.
  • A zero vector has no direction: Python’s _cos raises ZeroDivisionError, while TypeScript gets NaN, and because NaN > bestScore is always false that document is never picked; with no other candidate, the round returns -1.

Common mistakes

  • Ranking on relevance alone — you get five near-duplicate passages and waste the budget.
  • Setting λ wrong: all relevance is redundant, all diversity drifts off-topic.
  • Computing the penalty against the query instead of against the already-selected items.
  • Averaging the similarity to the selected set instead of taking the maximum, so one near-copy among several unrelated picks is diluted and slips through.

Follow-ups an interviewer asks next

How do you choose λ?
Tune it on real queries, measuring answer quality or coverage of distinct facts at a fixed context budget. The trade-off is sensitive to how similar your embeddings make documents: on the example documents, the second slot flips from document 2 to the near-copy document 1 once lam passes about 0.463. Defaults near 0.5 are common, but check them against your data.
Retrieval returns 1,000 candidates. How do you keep MMR fast?
Run it on a short list: take the top few dozen by relevance first and apply MMR to those, since a document far down the relevance ranking rarely wins a slot anyway. Cache relevance, keep the running maximum per candidate, and normalize vectors once so each cosine becomes a dot product; with the vectors in a matrix, a round is one matrix-vector product.
What other diversification methods are there?
Determinantal point processes choose a set whose similarity matrix has a large determinant, which rewards diversity across the whole set instead of one greedy step at a time. Simpler options cluster the candidates and take the best per cluster, or cap results per source document. MMR stays popular because it is one tunable knob and a few lines of code.
Must the redundancy term use the same embedding as relevance?
No. Relevance is query-to-document similarity, but redundancy is document-to-document similarity, which can be measured more cheaply, for example with n-gram overlap or a shared source id. In RAG the most common duplicates are overlapping chunks of the same document, and a source-id check catches those exactly.

Where this shows up

MMR diversifies RAG context and search results so the prompt is not stuffed with paraphrases of one passage — you cover more of the answer within the same token budget. It is a standard reranking step in retrieval and in extractive summarization.

Reference solution

Reference solution — Python
import math

def _cos(a, b):
    dot = sum(x * y for x, y in zip(a, b))
    na = math.sqrt(sum(x * x for x in a))
    nb = math.sqrt(sum(y * y for y in b))
    return dot / (na * nb)

def mmr(q, docs, lam, k):
    selected = []
    while len(selected) < k:
        best_i, best_score = -1, -float('inf')
        for i in range(len(docs)):
            if i in selected:
                continue
            rel = _cos(q, docs[i])
            red = max((_cos(docs[i], docs[j]) for j in selected), default=0.0)
            score = lam * rel - (1 - lam) * red
            if score > best_score:
                best_i, best_score = i, score
        selected.append(best_i)
    return selected


print(mmr([1, 0], [[0.9, 0.1], [0.85, 0.15], [0.3, 0.8]], 0.3, 2))
Reference solution — TypeScript
function cos(a: number[], b: number[]): number {
  let dot = 0, na = 0, nb = 0;
  for (let i = 0; i < a.length; i++) {
    dot += a[i] * b[i];
    na += a[i] * a[i];
    nb += b[i] * b[i];
  }
  return dot / (Math.sqrt(na) * Math.sqrt(nb));
}

function mmr(q: number[], docs: number[][], lam: number, k: number): number[] {
  const selected: number[] = [];
  while (selected.length < k) {
    let bestI = -1;
    let bestScore = -Infinity;
    for (let i = 0; i < docs.length; i++) {
      if (selected.includes(i)) continue;
      const rel = cos(q, docs[i]);
      const red = selected.length
        ? Math.max(...selected.map((j) => cos(docs[i], docs[j])))
        : 0;
      const score = lam * rel - (1 - lam) * red;
      if (score > bestScore) {
        bestScore = score;
        bestI = i;
      }
    }
    selected.push(bestI);
  }
  return selected;
}

console.log(mmr([1, 0], [[0.9, 0.1], [0.85, 0.15], [0.3, 0.8]], 0.3, 2));

Questions about MMR Reranking

What is maximal marginal relevance?

Maximal marginal relevance, or MMR, is a greedy reranking rule that picks results one at a time, each time choosing the candidate with the best balance between relevance to the query and novelty against what is already selected. The score is lambda times the similarity to the query, minus one minus lambda times the highest similarity to any selected result. For any lambda above 0 the first pick is the most relevant document, because nothing is selected yet. Later picks pay a penalty for resembling earlier ones, so a near-duplicate of the top result falls behind a less similar but still relevant document.

Why does MMR use the maximum similarity to the selected set, not the average?

Because one close copy is enough to make a document redundant. If a candidate nearly duplicates one selected passage but is unrelated to three others, its average similarity looks moderate and it slips through, even though it adds almost nothing. The maximum asks the right question: does anything already selected cover this? It also keeps the computation cheap, because after each pick you only compare every remaining candidate with the newly selected document and keep the larger of the old and new values.

What does the lambda parameter in MMR do?

Lambda sets the trade-off between relevance and diversity. At lambda equal to 1 the penalty vanishes and MMR returns plain top-k by relevance. At 0 relevance vanishes and MMR only avoids similarity, so even the first pick ignores the query. In between, the outcome depends on how similar your documents are: on the example documents here, the near-duplicate document 1 wins the second slot at lambda 0.5 or 0.7, and the diverse document 2 wins it at 0.3. Tune lambda on real queries rather than fixing a value once.

Why is MMR useful in RAG?

A retriever often returns several chunks that say the same thing, such as overlapping chunks of one page or a paragraph copied across documents. Putting all of them in the prompt spends tokens on repetition and can crowd out the chunk that holds a different, needed fact. MMR reorders the candidates so each new chunk adds something the earlier ones did not, which covers more of the question within the same token budget. It runs after retrieval and before the prompt is built, usually on a few dozen candidates, so its cost is small.

Finished this one? 0 / 76 Challenges done
NextToken-Level F1 Next on the “RAG & retrieval” ladder · rung 7 of 7

Explore the topic

See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.

More Challenges