NEWVectors or files. Pick a path.Start →
    Retrieval
    14 min read
    Updated 2026-10-14

    Did my search get better? How to test a retrieval change before you ship it

    To know whether a new embedding model, reranker or ranking tweak made search better, run the old and new system on the same set of judged queries, score both with recall@k, MRR and nDCG@k, and test whether the per-query differences are larger than noise. How to build the judged query set, which metric answers which question, what to do about unjudged results, how many queries you need, when an LLM judge can stand in for people, and how to confirm the result online after shipping. Includes a worked example and runnable Python.

    Search Evaluation
    Offline Evaluation
    nDCG
    MRR
    Recall@k
    Relevance Judgments
    A/B Testing
    Interleaving

    How do I know if my search results got better?



    Run the old and the new search system on the same fixed set of queries whose relevant results you have already labeled, score both with the same ranking metrics, and compare them query by query. If the new system wins on most queries and a paired significance test says the average gain is larger than the noise between queries, the change helped. If you only eyeball a few searches, or compare two numbers computed on different query sets, you cannot tell a real improvement from luck.

    That procedure is called offline evaluation, and it has been the standard way to compare search systems since the TREC evaluations began at NIST (TREC overview). This guide covers the search side: how well a retriever ranks results. If you are grading generated answers in a RAG system, read how to evaluate a RAG pipeline after this one.

    What do I need before I can compare two search systems?



    You need three things, and all three stay fixed while you compare:

  1. A query set. The searches you will run against both systems.
  2. Relevance judgments ("qrels"). For each query, which documents are relevant and how relevant.
  3. A metric, and a cutoff k that matches your UI. If users see 10 results, score the top 10.


  4. The two systems differ in exactly one thing: the change you are testing. Same corpus, same index snapshot, same filters. If the corpus changed between runs, you are measuring the corpus.

    Where do test queries come from, and what bias does each source add?



    Real query logs. The best source, because they are what users actually type. Sample across head (frequent), torso and tail (rare) queries. Sampling uniformly from distinct queries overweights the tail; sampling from traffic overweights the head. Pick on purpose and write down which you chose.

    Click and interaction data. Clicks tell you which results users found worth opening, so they can produce labels without a human judge. They are biased. An eye-tracking study by Joachims et al. (2005) found a "trust bias" (users click higher-ranked links partly because they are higher) and a "quality bias" (clicks depend on how good the other results are). Their conclusion: clicks are informative but biased, and relative preferences derived from clicks are more reliable than treating a click as an absolute relevance label. A dataset mined from clicks also only contains documents your current system already showed, which favors the current system in any comparison.

    Synthetic queries. An LLM writes a question for each document, and that document becomes its relevant answer. This is cheap and covers content nobody has searched for yet. It was popularized for training retrievers (InPars, arXiv 2202.05144). For evaluation it has two weaknesses: generated questions tend to reuse the document's own words, which flatters keyword matching, and each query gets exactly one relevant document even when several would satisfy it.

    A practical mix: real queries from logs as the core, a slice of synthetic queries for new content, and click-derived labels only as a cross-check.

    Should relevance labels be graded or binary?



    Binary labels (relevant or not) are faster to collect and are all that recall, precision and MRR use. Graded labels (for example 0 = not relevant, 1 = partly, 2 = relevant, 3 = exactly what I wanted) let a metric reward putting the best result first rather than any relevant result first. Järvelin and Kekäläinen (2002) introduced cumulated gain measures for this reason, using graded judgments on a four-point scale.

    Graded labels cost more per judgment and judges disagree more on the middle grades. Write a one-paragraph rubric with an example for each grade before anyone labels anything.

    Recall@k vs nDCG vs MRR: which metric should I use?



    Each metric answers a different question about the same ranked list.

    MetricQuestion it answersUses grades?Use it when
    Recall@kOf all the relevant documents, how many made the top k?NoUsers or a downstream reranker need every match (compliance, a first-stage retriever feeding a reranker)
    Precision@kOf the top k, how many are relevant?NoUsers see a short, fixed list and junk is costly
    MRRHow far down is the first relevant result?NoOne good answer is enough (navigational search, agents reading the top hit)
    nDCG@kHow close is the ordering to the ideal ordering?YesUsers scan a page and some results are better than others (product, media, document search)
    The formulas, for one query:

  5. Recall@k = (relevant documents in top k) / (all relevant documents for the query)
  6. Precision@k = (relevant documents in top k) / k
  7. Reciprocal rank = 1 / (rank of the first relevant result), or 0 if none appears. MRR is its mean over queries.
  8. DCG@k = sum over positions i = 1..k of rel_i / log2(i + 1), where rel_i is the grade of the result at position i.
  9. nDCG@k = DCG@k / IDCG@k, where IDCG@k is the DCG of the best possible ordering of the judged documents. nDCG is 1.0 for a perfect ranking.


  10. The discounted form follows Järvelin and Kekäläinen (2002). Many libraries use an exponential gain, (2^rel_i - 1), in place of rel_i, which weights the top grade more heavily (Wikipedia: Discounted cumulative gain). Both are valid. Pick one and keep it fixed, because the two give different numbers for the same ranking. The Stanford IR book chapter on ranked evaluation covers precision, recall and MAP in more depth, and our precision and recall glossary entry has the short version.

    Report recall@k together with a ranking metric. A reranker can raise nDCG@10 while a first-stage change quietly drops relevant documents below the cutoff, and only recall shows the second effect.

    Worked example: scoring one query by hand



    This is a worked example with made-up data, computed by hand and checked with the code below. It is not a measurement of any system.

    Query: "waterproof hiking boots". The judged documents are A (grade 3), B (grade 2) and C (grade 1). Everything else is grade 0. You switch embedding models and get two top-5 lists:

  11. Old system: X, A, Y, C, Z
  12. New system: A, B, X, Y, Z


  13. MetricOldNew
    Precision@52/5 = 0.4002/5 = 0.400
    Recall@52/3 = 0.6672/3 = 0.667
    Reciprocal rank1/2 = 0.5001/1 = 1.000
    nDCG@50.4880.895
    The nDCG arithmetic, with linear gain:

  14. Ideal order is A, B, C: IDCG@5 = 3/log2(2) + 2/log2(3) + 1/log2(4) = 3 + 1.262 + 0.5 = 4.762
  15. Old: A at rank 2 and C at rank 4: DCG@5 = 3/log2(3) + 1/log2(5) = 1.893 + 0.431 = 2.323, so nDCG@5 = 2.323 / 4.762 = 0.488
  16. New: A at rank 1 and B at rank 2: DCG@5 = 3 + 1.262 = 4.262, so nDCG@5 = 4.262 / 4.762 = 0.895


  17. Precision and recall did not move: both systems found two of the three relevant documents. The new system found the better two and put them at the top, which only MRR and nDCG register. If you had tracked recall@5 alone you would have called this change neutral.

    Python: recall@k, MRR and nDCG@k



    This runs as-is on Python 3 with no dependencies and reproduces the worked example above. For production use, trec_eval and ir_measures implement the standard definitions.
    import math, random
    
    def recall_at_k(ranked, qrels, k):
        relevant = {d for d, g in qrels.items() if g > 0}
        return len(relevant & set(ranked[:k])) / len(relevant)
    
    def precision_at_k(ranked, qrels, k):
        return sum(1 for d in ranked[:k] if qrels.get(d, 0) > 0) / k
    
    def reciprocal_rank(ranked, qrels):
        for i, d in enumerate(ranked, start=1):
            if qrels.get(d, 0) > 0:
                return 1 / i
        return 0.0
    
    def ndcg_at_k(ranked, qrels, k):
        dcg = sum(qrels.get(d, 0) / math.log2(i + 1)
                  for i, d in enumerate(ranked[:k], start=1))
        ideal = sorted(qrels.values(), reverse=True)[:k]
        idcg = sum(g / math.log2(i + 1) for i, g in enumerate(ideal, start=1))
        return dcg / idcg if idcg else 0.0
    
    def paired_randomization_test(a, b, n=10_000, seed=0):
        """Two-sided p-value: randomly swap old/new labels per query."""
        rng = random.Random(seed)
        deltas = [y - x for x, y in zip(a, b)]
        observed = abs(sum(deltas))
        hits = 0
        for _ in range(n):
            flipped = sum(d if rng.random() < 0.5 else -d for d in deltas)
            if abs(flipped) >= observed:
                hits += 1
        return sum(deltas) / len(deltas), hits / n
    
    # Worked example: one query, graded labels 0-3
    qrels = {"A": 3, "B": 2, "C": 1}
    old = ["X", "A", "Y", "C", "Z"]
    new = ["A", "B", "X", "Y", "Z"]
    for name, run in [("old", old), ("new", new)]:
        print(name,
              f"P@5={precision_at_k(run, qrels, 5):.3f}",
              f"R@5={recall_at_k(run, qrels, 5):.3f}",
              f"RR={reciprocal_rank(run, qrels):.3f}",
              f"nDCG@5={ndcg_at_k(run, qrels, 5):.3f}")
    
    # Made-up per-query nDCG@10 for 12 queries, old vs new
    old_q = [0.61, 0.72, 0.40, 0.88, 0.55, 0.69, 0.47, 0.90, 0.33, 0.76, 0.58, 0.64]
    new_q = [0.66, 0.70, 0.52, 0.91, 0.60, 0.69, 0.45, 0.93, 0.41, 0.80, 0.57, 0.71]
    mean_delta, p = paired_randomization_test(old_q, new_q)
    wins = sum(n > o for o, n in zip(old_q, new_q))
    losses = sum(n < o for o, n in zip(old_q, new_q))
    print(f"mean delta={mean_delta:.3f}  p={p:.3f}  wins={wins} losses={losses}")
    Output:
    old P@5=0.400 R@5=0.667 RR=0.500 nDCG@5=0.488
    new P@5=0.400 R@5=0.667 RR=1.000 nDCG@5=0.895
    mean delta=0.035  p=0.017  wins=8 losses=3

    What happens when the new system returns results nobody judged?



    This is the most common way an offline evaluation lies about a retrieval change. Labels were collected for documents the old system returned. A new embedding model surfaces different documents, those documents have no label, and the metric counts every unlabeled document as irrelevant. The new system gets penalized for being different.

    TREC handles this with pooling: the top results from every participating system are merged into a pool, and assessors judge the pool (TREC relevance judgments). The BEIR authors measured what happens when a system was not in the pool. On TREC-COVID, 31.8% of TAS-B's and 14.4% of ANCE's top-10 results had never been judged, against 6.4% for BM25. After the missing documents were judged, ANCE's nDCG@10 rose from 0.654 to 0.735 (BEIR, arXiv 2104.08663, Table 4).

    What to do:

    1. Pool both systems. Before comparing, take the top k from old and new, and judge every document that does not have a label yet. 2. Report the hole rate. The share of top-k results with no judgment, per system. If one system's hole rate is much higher, its score is a lower bound. 3. Re-pool after every change. A judged set built for last quarter's model goes stale as soon as you swap the model.

    How do I compare the old and new system fairly?



    Compare them per query, on the same queries. Query difficulty varies far more than the difference between two reasonable systems, so two averages hide most of the information. Compute delta = new score minus old score for each query and analyze those deltas.

    Three things to look at:

  18. Mean delta and its confidence interval.
  19. Wins, losses and ties. A small average gain built from big wins on a few queries and small losses on many is a different change from a broad, even gain.
  20. The biggest losers. Read the 10 queries where the new system dropped the most. They tell you what the change broke, which an average cannot. The debugging retrieval results guide covers how to trace a single bad query through a pipeline.


  21. Is the difference real? A simple significance check



    Use a paired test on the per-query deltas. Smucker, Allan and Carterette (2007) compared five tests on TREC runs and found little practical difference between the paired t-test, the bootstrap and the randomization (permutation) test. They found that the Wilcoxon signed rank test and the sign test disagreed with the other three, recommended the randomization test, and advised against the Wilcoxon and sign tests for IR evaluation.

    The randomization test is short enough to write yourself (it is in the snippet above). If the old and new system were interchangeable, swapping their labels on any query would not matter. So you flip the sign of each delta at random many times and count how often the shuffled total is at least as large as the real one. That share is the p-value.

    On the made-up 12-query data in the snippet, the new system wins 8 queries, loses 3 and ties 1, with a mean nDCG@10 gain of 0.035. The randomization test gives p = 0.017. A sign test on the same data uses only win/loss counts and gives p = 0.23 (two-sided binomial, 8 of 11 non-tied queries), because it ignores how large each win or loss was. That gap is the reason the sign test is a weak choice here. Both numbers come from a made-up example and illustrate the method only.

    How many queries do I need?



    It depends on how noisy the per-query deltas are and how small a gain you care about. A common approximation for a paired test at 5% significance and 80% power is:

    n ≈ ((1.96 + 0.84) × sd / delta)^2

    where sd is the standard deviation of the per-query deltas and delta is the smallest gain you want to detect. Worked example, using the made-up data above: the deltas have sd ≈ 0.043. Detecting a 0.02 nDCG@10 gain needs about 36 queries. Detecting 0.01 needs about 145, four times as many, because n grows with the square of 1/delta.

    Estimate sd from a small pilot run of a few dozen queries, then size the full set. Small, real improvements need hundreds of queries. A 30-query set is enough to catch a regression that breaks a whole class of queries and too small to settle a 1-point disagreement.

    Can an LLM judge relevance instead of people?



    Often yes, after you check it against people. Thomas et al., reporting on large-scale LLM relevance labelling at Bing, found LLM labels as accurate as human labellers and better than third-party workers when measured against first-party "gold" labels from real searchers (arXiv 2309.10621). The same paper found that systematic prompt changes and simple paraphrases of the prompt both changed accuracy.

    The known failure modes:

  22. Prompt sensitivity. Small wording changes move the labels (Thomas et al., arXiv 2309.10621).
  23. Circularity. If the system under test uses an LLM reranker and the judge is a similar LLM, the evaluation rewards agreeing with the judge. Clarke and Dietz built a system deliberately crafted to exploit an LLM-based evaluation and got inflated scores without better retrieval (arXiv 2412.17156).
  24. Unclear degree of automation. Faggioli et al. lay out a spectrum from humans assisted by an LLM to fully automated judging, with arguments for and against the fully automated end (arXiv 2304.09161).


  25. A calibration step before you trust it:

    1. Have two people label a random sample of query-document pairs using your written rubric. 2. Run the LLM judge with a fixed prompt on the same pairs. 3. Measure agreement between the two people, and between each person and the LLM (Cohen's kappa for binary labels, weighted kappa for graded ones). 4. Use the LLM only if it agrees with people about as well as people agree with each other. Freeze the prompt and model version, and re-run the calibration when either changes. 5. Check that the LLM-labeled set ranks your known-better and known-worse systems the same way the human-labeled sample does.

    How do I confirm the change helped after I ship it?



    Offline evaluation tells you the new ranking matches your labels better. Online evaluation tells you users behave differently. Run both.

  26. A/B test. Split users between the old and new system and compare a pre-registered success metric, such as sessions with a click that is not followed by a quick return to the results. The Kohavi, Tang and Xu book on online controlled experiments is the standard reference for doing this without fooling yourself.
  27. Interleaving. Merge the two rankings into one list per query, show it to the same user, and credit whichever system contributed the clicked results. In a study on arXiv.org search, Radlinski, Kurup and Joachims (2008) found that none of eight absolute usage metrics (such as click counts, reformulation rate and abandonment) reliably reflected retrieval quality at their sample sizes, while interleaved paired comparisons gave accurate and consistent results. Chapelle et al. (2012) validated interleaving on two commercial search engines and a scientific literature search system.
  28. Click-through rate as a weak signal. Watch it for sudden drops, and do not use it alone to declare a win. Position bias means a ranking change moves clicks even when relevance did not change (Joachims et al. 2005).


  29. A checklist for testing a retrieval change



    1. Freeze the corpus snapshot, the query set and the metric definitions. 2. Run old and new, top k for each query. 3. Judge every unlabeled document in either top k. Report hole rates. 4. Score recall@k plus nDCG@k (or MRR if one good result is enough). 5. Compute per-query deltas, wins and losses, and a paired randomization or bootstrap test. 6. Read the worst 10 regressions by hand. 7. Ship behind an A/B test or interleaving experiment, and keep the query set for the next change.

    If the change you are testing is a reranker, the same procedure applies; the best rerankers list covers the options. For multimodal corpora (video, images, audio), evaluating multimodal retrieval covers what changes in the labels.

    How to run this in Mixpeek



    Mixpeek is a multimodal data platform that indexes video, images, audio and documents from object storage and serves search through configurable retrievers. Its retriever evaluations implement the offline half of this guide.

  30. Evaluation datasets. A dataset has a dataset_name and a list of queries. Each query has a query_id, a query_input that uses the keys your retriever's input schema declares, and relevant_documents. Optional relevance_scores take a 0 to 5 grade per document, which nDCG uses for graded relevance; without them all metrics use binary relevance (Evaluations docs; Create dataset API).
  31. Generating a dataset from logged interactions. The generate-from-interactions endpoint mines user interactions for a retriever and turns queries with enough positive interactions into a dataset. You set min_interactions (default 5), lookback_days (default 30) and which interaction types count as positive (default: click, purchase, positive_feedback, bookmark, share) (Generate from interactions API; Interactions docs). The click biases described above apply to these labels, so pool and spot-check them before you compare two retrievers.
  32. Running an evaluation. You run a retriever against a dataset by name. The run is asynchronous and returns a task_id while it proceeds (Run evaluation API).
  33. Metrics reported. precision, recall, F1, F2, nDCG, MAP and MRR, computed at each cutoff in k_values (default 1, 5, 10 and 20). Results include overall_metrics and metrics_by_k, plus total_queries, evaluated_queries, skipped_queries and per-query query_results, so you can see which queries failed input validation (Evaluations docs; Get results API).
  34. Comparing retrievers. The docs pattern is to run the same dataset against a baseline retriever and a candidate (for example, the same pipeline with a rerank stage added) and compare metrics_by_k side by side. The paired per-query test from this guide can be run on query_results (Evaluations docs).
  35. Replaying live sessions. Retriever benchmarks replay historical sessions against candidate retrievers and measure alignment with observed user behavior (Create benchmark API).


  36. Frequently Asked Questions



    My search got worse after changing the embedding model. How do I prove it?

    Run the old and new model on the same judged query set, judge any results the new model returns that have no label yet, and compare recall@k and nDCG@k per query with a paired randomization test. Then read the queries with the largest drops. If the new model's hole rate is high, the "worse" score may come from missing labels, as the BEIR authors showed on TREC-COVID (arXiv 2104.08663).

    Recall@k vs nDCG vs MRR: which metric should I use?

    Use recall@k when missing a relevant result is the main risk, MRR when users need one good result fast, and nDCG@k when users scan a page and some results are better than others. Report recall@k next to whichever ranking metric you choose, because a ranking can improve while relevant documents fall out of the top k.

    How do I evaluate semantic search without labeled data?

    Start with a few dozen real queries from your logs and have one person label the top results from both your current system and the candidate, using a written rubric. Add synthetic queries for content nobody has searched yet. An LLM judge can scale the labeling once it agrees with your human labels on a sample (Thomas et al., arXiv 2309.10621).

    How many test queries do I need for offline evaluation of vector search?

    Enough that the confidence interval on the mean per-query delta excludes zero for the smallest gain you care about. With the approximation n ≈ ((1.96 + 0.84) × sd / delta)^2, halving the gain you want to detect quadruples the queries you need. Run a small pilot to estimate sd, then size the set.

    Can I use click-through rate to decide whether search improved?

    Use it as a weak signal. Clicks follow rank position and the quality of nearby results as well as relevance (Joachims et al. 2005), and absolute usage metrics did not reliably track retrieval quality in a controlled study, while interleaved comparisons did (Radlinski et al. 2008). Prefer interleaving or a controlled A/B test with a pre-registered metric.

    Sources



  37. Järvelin and Kekäläinen, "Cumulated Gain-Based Evaluation of IR Techniques", ACM TOIS 2002
  38. Thakur et al., "BEIR: A Heterogeneous Benchmark for Zero-shot Evaluation of Information Retrieval Models", arXiv 2104.08663
  39. TREC: relevance judgments and pooling
  40. TREC overview, NIST
  41. Joachims et al., "Accurately Interpreting Clickthrough Data as Implicit Feedback", SIGIR 2005
  42. Radlinski, Kurup and Joachims, "How Does Clickthrough Data Reflect Retrieval Quality?", CIKM 2008
  43. Chapelle, Joachims, Radlinski and Yue, "Large-Scale Validation and Analysis of Interleaved Search Evaluation", ACM TOIS 2012
  44. Smucker, Allan and Carterette, "A Comparison of Statistical Significance Tests for Information Retrieval Evaluation", CIKM 2007
  45. Thomas et al., "Large language models can accurately predict searcher preferences", arXiv 2309.10621
  46. Faggioli et al., "Perspectives on Large Language Models for Relevance Judgment", arXiv 2304.09161
  47. Clarke and Dietz, "LLM-based relevance assessment still can't replace human relevance assessment", arXiv 2412.17156
  48. Bonifacio et al., "InPars: Data Augmentation for Information Retrieval using Large Language Models", arXiv 2202.05144
  49. Manning, Raghavan and Schütze, Introduction to Information Retrieval: Evaluation of ranked retrieval results
  50. Kohavi, Tang and Xu, Trustworthy Online Controlled Experiments
  51. trec_eval (NIST) and ir_measures
  52. Mixpeek docs: Evaluations
  53. Mixpeek API: Generate evaluation dataset from interactions
  54. Managed Mixpeek

    Put multimodal search to work

    Connect a bucket and Mixpeek runs the whole multimodal search pipeline for you: extraction, indexing, and search over your own objects. No models to wire up, nothing to host.

    Start with Managed
    MVS · bring your own

    Already have vectors?

    Keep your embeddings on your own cloud and run dense, sparse, and BM25 search directly on object storage. From $25/mo.

    Start with MVS

    Run this on your own data

    Point Mixpeek at the storage you already have and search your video, images, audio, and documents the way this guide describes. Build starts at $25/mo for up to 1M vectors.

    Search your own archiveRead Docs