elasticsearch-relevance-tests
Author search-engine relevance regression tests for Elasticsearch, OpenSearch, and Apache Solr. Core workflow on the Elasticsearch Ranking Evaluation API (`POST {index}/_rank_eval`) - judgment lists (query + expected docs at ranks), per-query metrics (Precision@K, Recall@K, MRR, DCG, ERR), reproducible test corpora; pair with Quepid + Splainer for interactive judgment authoring. Per-engine references cover the OpenSearch delta (Search Relevance Workbench, neural query DSL, hybrid BM25 + neural pipelines, ES-to-OS migration parity) and the Apache Solr delta (no _rank_eval: debugQuery score explain, LTR feature/model store REST, eDisMax qf/pf/mm tuning, external nDCG harness). Use before changing analyzers, synonyms, boosts, or query templates on an Elasticsearch, OpenSearch, or Solr index that serves user-facing search, so the NDCG / MRR baseline is captured first.
Install with skills.sh (any agent)
npx skills add testland/qa --skill elasticsearch-relevance-testselasticsearch-relevance-tests
Per the Elasticsearch Rank Eval API (opens in new window), the _rank_eval endpoint "evaluates search result quality across typical queries using relevance metrics."
Engine routing
This skill is the single home for term-based engine relevance testing. The core _rank_eval workflow below is authored against Elasticsearch; the per-engine deltas live in references:
| Engine | Where | Delta |
|---|---|---|
| Elasticsearch | this SKILL.md | Built-in _rank_eval; the canonical workflow |
| OpenSearch | references/opensearch.md | _rank_eval-compatible fork; Search Relevance Workbench, neural query DSL, hybrid BM25 + neural pipelines, ES-to-OS migration parity |
| Apache Solr | references/solr.md | No _rank_eval; debugQuery explain, LTR feature/model store REST, eDisMax tuning, external nDCG harness |
When to use
Step 1 - Build the judgment list
A judgment is (query, doc_id, rating). Ratings: 0 = irrelevant, 1 = somewhat, 2 = relevant, 3 = highly relevant (4-point scale). Build judgments via:
| Source | Method |
|---|---|
| Query logs + click data | Click model (clicked = ≥1, multi-click = ≥2) |
| Quepid (open source) | Interactive UI for judges to rate per-query results |
| Splainer | Diagnose why a doc ranked where it did |
| Domain SMEs | High-stakes queries; manual rating |
Judgment list format (CSV is common):
query,doc_id,rating
"running shoes",sku-1234,3
"running shoes",sku-5678,2
"running shoes",sku-9999,0
"red dress",sku-2222,3Step 2 - Define metrics for your domain
Per the Elasticsearch Rank Eval API (opens in new window):
| Metric | When to use |
|---|---|
| Precision@K | flat top-K accuracy; no graded weighting |
| Recall@K | completeness of the relevant set within top K |
| MRR | one good answer suffices (navigational, Q&A) |
| DCG / NDCG | graded relevance, rank-discounted; default for graded judgments |
| ERR | user-stops-at-first-relevant; rank-decay sensitive |
For e-commerce with graded judgments → NDCG@10 + MRR. For Q&A → MRR
Step 3 - Submit a rank_eval request
Per the Elasticsearch Rank Eval API (opens in new window):
POST products/_rank_eval
{
"requests": [
{
"id": "running_shoes_query",
"request": {
"query": { "match": { "name": "running shoes" } }
},
"ratings": [
{ "_index": "products", "_id": "sku-1234", "rating": 3 },
{ "_index": "products", "_id": "sku-5678", "rating": 2 },
{ "_index": "products", "_id": "sku-9999", "rating": 0 }
]
},
{
"id": "red_dress_query",
"request": { "query": { "match": { "name": "red dress" } } },
"ratings": [
{ "_index": "products", "_id": "sku-2222", "rating": 3 }
]
}
],
"metric": {
"dcg": { "k": 10, "normalize": true }
}
}Response shape:
{
"metric_score": 0.84,
"details": {
"running_shoes_query": { "metric_score": 0.91, "unrated_docs": [...] },
"red_dress_query": { "metric_score": 0.77, "unrated_docs": [...] }
}
}Step 4 - Wrap as a test
import requests, csv
def load_judgments(path):
by_query = {}
with open(path) as f:
for row in csv.DictReader(f):
by_query.setdefault(row["query"], []).append({
"_index": "products",
"_id": row["doc_id"],
"rating": int(row["rating"]),
})
return by_query
def test_search_relevance_baseline():
judgments = load_judgments("tests/judgments.csv")
requests_payload = [
{
"id": q.replace(" ", "_"),
"request": { "query": { "match": { "name": q } } },
"ratings": ratings,
}
for q, ratings in judgments.items()
]
body = {
"requests": requests_payload,
"metric": { "dcg": { "k": 10, "normalize": true } },
}
r = requests.post("http://localhost:9200/products/_rank_eval", json=body)
result = r.json()
# Baseline NDCG must not regress vs known-good
assert result["metric_score"] >= 0.80, f"NDCG@10 regressed: {result['metric_score']}"Step 5 - Per-query regression detection
Aggregate metric only catches large shifts. Track per-query:
def test_no_query_drops_more_than_10_percent():
current = run_rank_eval()
baseline = json.loads(Path("tests/baseline.json").read_text())
for query_id, baseline_score in baseline["details"].items():
current_score = current["details"][query_id]["metric_score"]
delta = current_score - baseline_score["metric_score"]
assert delta >= -0.10, \
f"Query {query_id} dropped {delta:.2f} (was {baseline_score['metric_score']:.2f}, now {current_score:.2f})"Advanced topics
Binary-metric relevant_rating_threshold config, snapshotting a reproducible test corpus for CI, and Quepid + Splainer judgment authoring live in references/rank-eval-guide.md.
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Use binary judgments only | Loses graded info; NDCG degrades to Precision | 4-point scale (Step 1) |
| Rebuild judgments per test run | Bias from current ranking | Pinned judgment list (Step 1) |
| Track only aggregate NDCG | Hides per-query regressions | Per-query tracking (Step 5) |
| Test against changing index | Baselines move under your feet | Snapshot corpus (advanced guide) |
| 100% click-derived judgments | Click bias to top results, position bias | Mix click + SME judgments |
Limitations
References
OpenSearch delta - Workbench, neural, and hybrid relevance tests
View source (opens in new window)OpenSearch delta - Workbench, neural, and hybrid relevance tests
Per the OpenSearch search-relevance docs (opens in new window), _rank_eval is Elasticsearch-fork-compatible. The OpenSearch-specific surfaces worth testing: neural search, hybrid query, and the Search Relevance Workbench UI.
When this delta applies
Step 1 - Reuse the judgment list format and _rank_eval
OpenSearch's _rank_eval accepts the same JSON as Elasticsearch's - endpoint + metrics identical per the OpenSearch search-relevance docs (opens in new window). The main skill's Step 1 judgment list (CSV (query, doc_id, rating) on the 4-point scale) and Step 3 request body are reusable verbatim against an OpenSearch cluster. One extra sourcing option exists here: the Search Relevance Workbench's pairwise judgment UI + bulk import (Step 3 below).
Step 2 - Search Relevance Workbench
Per the OpenSearch search-relevance docs (opens in new window), the Search Relevance Workbench plugin (UI in OpenSearch Dashboards) provides:
Workbench experiments are the easiest pre-tuning baseline-and-compare workflow.
Step 3 - Neural search query
OpenSearch supports k-NN vector search natively. Test setup:
PUT my_index
{
"settings": { "index.knn": true },
"mappings": {
"properties": {
"embedding": {
"type": "knn_vector",
"dimension": 768,
"method": { "name": "hnsw", "engine": "lucene" }
},
"title": { "type": "text" }
}
}
}Query:
POST my_index/_search
{
"query": {
"neural": {
"embedding": {
"query_text": "running shoes for marathon",
"model_id": "<sentence-transformer-model>",
"k": 10
}
}
}
}Test that neural results meet a recall@10 target against a held-out ground truth set:
def test_neural_recall_at_10():
ground_truth = load_ground_truth("tests/marathon_queries.json")
for query in ground_truth["queries"]:
results = neural_search(query["text"], k=10)
retrieved_ids = {r["_id"] for r in results}
relevant_ids = set(query["relevant_ids"])
recall = len(retrieved_ids & relevant_ids) / len(relevant_ids)
assert recall >= 0.85, f"Recall {recall:.2f} below 0.85 for query: {query['text']}"Pair with vector-search-recall-tests for HNSW parameter tuning.
Step 4 - Hybrid (BM25 + neural)
POST my_index/_search?search_pipeline=hybrid_pipeline
{
"query": {
"hybrid": {
"queries": [
{ "match": { "title": "running shoes" } },
{ "neural": { "embedding": { "query_text": "running shoes", "k": 10 } } }
]
}
}
}Hybrid weighting set up via search pipeline:
PUT _search/pipeline/hybrid_pipeline
{
"phase_results_processors": [
{
"normalization-processor": {
"normalization": { "technique": "min_max" },
"combination": {
"technique": "arithmetic_mean",
"parameters": { "weights": [0.3, 0.7] }
}
}
}
]
}Test that hybrid weights matter:
def test_hybrid_weight_change_shifts_results():
bm25_heavy_results = search_with_pipeline("hybrid_pipeline_03_07") # 0.3 BM25 / 0.7 neural
neural_heavy_results = search_with_pipeline("hybrid_pipeline_07_03")
assert bm25_heavy_results != neural_heavy_resultsStep 5 - Per-query metric regression (same as ES)
Identical to the main skill's Step 5 - run it against the OpenSearch cluster with its own pinned baseline file (e.g. tests/baseline-os.json).
Step 6 - ES → OS migration parity test
Run the same judgment list against both clusters; metric scores should be within ε:
def test_es_os_parity():
es_score = rank_eval_against("http://es:9200/products", judgments)
os_score = rank_eval_against("http://os:9200/products", judgments)
delta = abs(es_score - os_score)
assert delta < 0.05, f"ES vs OS NDCG diff {delta:.2f} > 0.05"If the index settings (analyzers, mappings) are identical, scores should match. Differences point to subtle config drift.
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Test only BM25 path when neural enabled | Neural regression slips silently | Step 3 + Step 4 |
| Use neural without warm-up for tests | Cold cache → flaky latency tests | Warm before measuring |
| Set hybrid weights without testing both extremes | Subtle BM25/neural balance change ships | Step 4 |
| Skip migration parity test | OS deviation from ES surfaces in prod | Step 6 |
| Trust default analyzers across ES/OS | Subtle stemmer differences | Pin analyzer config |
Limitations
References
Rank Eval guide - binary thresholds, snapshot corpora, judgment tooling
View source (opens in new window)Rank Eval guide - binary thresholds, snapshot corpora, judgment tooling
Supplementary detail for elasticsearch-relevance-tests. The core _rank_eval workflow (judgments, metrics, request, test wrapper, per-query regression) stays in SKILL.md; this file holds the deeper configuration and tooling.
relevant_rating_threshold for binary metrics
Per the Elasticsearch Rank Eval API (opens in new window): Precision/Recall/MRR accept relevant_rating_threshold (default 1). For graded judgments:
"metric": {
"precision": {
"k": 10,
"relevant_rating_threshold": 2,
"ignore_unlabeled": false
}
}Rating >= 2 counted as "relevant"; below counted as "not relevant". The ignore_unlabeled flag controls whether unrated docs in results count against precision.
Reproducible test corpus
Snapshot the index state used for tests:
PUT _snapshot/test_repo/baseline_2026_05_06
{
"indices": "products",
"include_global_state": false
}Restore for each CI run:
- name: Restore index snapshot
run: |
curl -X POST localhost:9200/_snapshot/test_repo/baseline_2026_05_06/_restoreOtherwise document changes (new docs, re-indexes) silently shift relevance baselines.
Quepid + Splainer integration
Quepid (opens in new window) (open source from OpenSource Connections) provides:
Splainer (opens in new window) explains why a doc ranked where it did - invaluable for debugging unexpected results.
Apache Solr delta - LTR, debugQuery, and external nDCG
View source (opens in new window)Apache Solr delta - LTR, debugQuery, and external nDCG
Apache Solr is the primary Elasticsearch/OpenSearch alternative in enterprise search. Unlike the ES/OS _rank_eval endpoint, Solr has no single built-in IR-metrics endpoint: relevance testing is assembled from debugQuery score explain, the Learning To Rank (LTR) contrib module, eDisMax tuning, and a test harness that computes nDCG externally. This reference covers that assembly.
Engine differentiation within this skill:
| Engine | Where | Differentiation axis |
|---|---|---|
| Elasticsearch | main SKILL.md | Built-in _rank_eval endpoint; no LTR store API |
| OpenSearch | opensearch.md (opens in new window) | ES-fork + neural search; different LTR surface |
| Apache Solr | this reference | debugQuery explain, LTR feature/model store REST, eDisMax qf/pf/mm tuning |
When this delta applies
Step 1 - Start a test core
Per the Solr CLI reference (opens in new window):
bin/solr start -p 8983
bin/solr create -c test_products -d _defaultIndex a snapshot of your production corpus (or a representative subset). Freeze the index before running any judgment-driven tests - new documents shift relevance baselines silently.
Step 2 - Build the judgment list
Same CSV format and 4-point scale as the main skill's Step 1 (0 = irrelevant, 1 = somewhat, 2 = relevant, 3 = highly relevant). Collect judgments via query logs + click data, domain SME review, or Quepid (opens in new window) (open source judgment UI with Solr support).
Step 3 - Query and collect ranked results
Solr has no _rank_eval equivalent. Call the query endpoint and collect ranked doc IDs per query:
import requests
SOLR = "http://localhost:8983/solr/test_products"
def ranked_ids(query: str, rows: int = 10) -> list[str]:
r = requests.get(f"{SOLR}/select", params={
"q": query,
"defType": "edismax",
"qf": "title^5.0 description^1.0",
"rows": rows,
"fl": "id,score",
})
return [doc["id"] for doc in r.json()["response"]["docs"]]Step 4 - Compute nDCG externally
import csv, math
def load_judgments(path: str) -> dict[str, dict[str, int]]:
j: dict[str, dict[str, int]] = {}
with open(path) as f:
for row in csv.DictReader(f):
j.setdefault(row["query"], {})[row["doc_id"]] = int(row["rating"])
return j
def dcg(ratings: list[int]) -> float:
return sum(r / math.log2(i + 2) for i, r in enumerate(ratings))
def ndcg_at_k(query: str, doc_ids: list[str],
judgments: dict[str, int], k: int = 10) -> float:
ranked = [judgments.get(d, 0) for d in doc_ids[:k]]
ideal = sorted(judgments.values(), reverse=True)[:k]
idcg = dcg(ideal)
return dcg(ranked) / idcg if idcg > 0 else 0.0
def test_ndcg_baseline():
judgments = load_judgments("tests/judgments.csv")
scores = {}
for query, rels in judgments.items():
ids = ranked_ids(query)
scores[query] = ndcg_at_k(query, ids, rels)
mean = sum(scores.values()) / len(scores)
assert mean >= 0.75, f"Mean nDCG@10 regressed: {mean:.3f}"Step 5 - Per-query regression guard
import json
from pathlib import Path
def test_no_query_drops_more_than_10_percent():
baseline = json.loads(Path("tests/solr_baseline.json").read_text())
judgments = load_judgments("tests/judgments.csv")
for query, rels in judgments.items():
current = ndcg_at_k(query, ranked_ids(query), rels)
b = baseline[query]
delta = current - b
assert delta >= -0.10, (
f"Query '{query}' dropped {delta:.3f} "
f"(was {b:.3f}, now {current:.3f})"
)Save a new baseline after any intentional improvement:
python3 tests/capture_baseline.py > tests/solr_baseline.jsonStep 6 - debugQuery for score explain
Per the Solr debugQuery reference (opens in new window), append debug=results&debug.explain.structured=true to receive a nested score breakdown per document:
def explain_top(query: str, rows: int = 5) -> dict:
r = requests.get(f"{SOLR}/select", params={
"q": query,
"defType": "edismax",
"qf": "title^5.0 description^1.0",
"rows": rows,
"debug": "results",
"debug.explain.structured": "true",
})
return r.json()["debug"]["explain"]
def test_top_doc_score_above_threshold():
explain = explain_top("running shoes")
top_id = next(iter(explain))
score = explain[top_id]["value"]
assert score >= 5.0, f"Top document score {score:.2f} below expected floor"Use explainOther (per the Solr debugQuery reference (opens in new window)) to compare scoring of an expected document against the actual top results:
?q=running+shoes&explainOther=id:SKU-1234&debug=resultsThis surfaces why SKU-1234 ranked lower than expected.
Step 7 - eDisMax tuning verification
Per the Solr eDisMax reference (opens in new window), the key parameters affecting relevance are:
| Parameter | Effect |
|---|---|
qf | Field weights: title^5.0 description^1.0 |
pf | Phrase proximity boost when all terms appear together |
mm | Minimum-should-match: 75% requires 3 of 4 terms |
bq | Additive boost query: bq=category:shoes^2.0 |
bf | Additive function boost: bf=recip(rord(price),1,1000,1000) |
tie | Tie-breaker across qf fields (default 0.0) |
ps | Phrase slop: ps=3 allows 3 intervening words |
Pin the eDisMax config in tests so a config file change is caught before deploy:
EDISMAX_PARAMS = {
"defType": "edismax",
"qf": "title^5.0 description^1.0 brand^3.0",
"pf": "title^10.0",
"mm": "75%",
"tie": "0.1",
}
def test_edismax_params_unchanged():
# Fails if the live handler returns different defaults
r = requests.get(f"{SOLR}/config/requestHandler",
params={"componentName": "/select"})
handler = r.json()["config"]["requestHandler"]["/select"]
defaults = handler.get("defaults", {})
for key, expected in EDISMAX_PARAMS.items():
assert defaults.get(key) == expected, (
f"eDisMax param '{key}' changed: expected {expected!r}, "
f"got {defaults.get(key)!r}"
)Step 8 - LTR feature store upload and verification
Per the Solr LTR reference (opens in new window), the feature store REST API:
# Upload features
curl -XPUT 'http://localhost:8983/solr/test_products/schema/feature-store' \
--data-binary "@tests/ltr/features.json" \
-H 'Content-type:application/json'
# Verify store contents
curl 'http://localhost:8983/solr/test_products/schema/feature-store/_DEFAULT_'Minimal features.json with a field-value feature and a recency function:
[
{
"name": "titleMatch",
"class": "org.apache.solr.ltr.feature.SolrFeature",
"params": { "q": "title:(${query})" }
},
{
"name": "recency",
"class": "org.apache.solr.ltr.feature.FieldValueFeature",
"params": { "field": "published_date" }
}
]def test_feature_store_uploaded():
r = requests.get(
f"{SOLR}/schema/feature-store/_DEFAULT_"
)
names = {f["name"] for f in r.json()["features"]}
assert "titleMatch" in names
assert "recency" in namesStep 9 - LTR model upload and re-ranking test
Per the Solr LTR reference (opens in new window), upload a MultipleAdditiveTreesModel (LambdaMART):
curl -XPUT 'http://localhost:8983/solr/test_products/schema/model-store' \
--data-binary "@tests/ltr/lambdamart_v1.json" \
-H 'Content-type:application/json'Then assert the LTR re-ranked list improves nDCG vs the baseline BM25 list. Per the Solr LTR reference (opens in new window), the rq parameter with reRankDocs controls how many top BM25 candidates are re-scored:
def ranked_ids_ltr(query: str, rows: int = 10,
rerank_docs: int = 100) -> list[str]:
r = requests.get(f"{SOLR}/select", params={
"q": query,
"defType": "edismax",
"qf": "title^5.0 description^1.0",
"rq": "{!ltr model=lambdamart_v1 reRankDocs=" + str(rerank_docs) + "}",
"rows": rows,
"fl": "id,score,[features]",
})
return [doc["id"] for doc in r.json()["response"]["docs"]]
def test_ltr_improves_ndcg():
judgments = load_judgments("tests/judgments.csv")
bm25_scores, ltr_scores = {}, {}
for query, rels in judgments.items():
bm25_scores[query] = ndcg_at_k(query, ranked_ids(query), rels)
ltr_scores[query] = ndcg_at_k(query, ranked_ids_ltr(query), rels)
bm25_mean = sum(bm25_scores.values()) / len(bm25_scores)
ltr_mean = sum(ltr_scores.values()) / len(ltr_scores)
assert ltr_mean >= bm25_mean, (
f"LTR model did not improve nDCG: BM25={bm25_mean:.3f}, LTR={ltr_mean:.3f}"
)Step 10 - CI integration
# .github/workflows/solr-relevance.yml
jobs:
relevance:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Start Solr
run: |
bin/solr start -p 8983
bin/solr create -c test_products -d _default
- name: Index test corpus
run: python3 tests/index_corpus.py
- name: Upload LTR feature store
run: |
curl -XPUT 'http://localhost:8983/solr/test_products/schema/feature-store' \
--data-binary "@tests/ltr/features.json" \
-H 'Content-type:application/json'
- name: Upload LTR model
run: |
curl -XPUT 'http://localhost:8983/solr/test_products/schema/model-store' \
--data-binary "@tests/ltr/lambdamart_v1.json" \
-H 'Content-type:application/json'
- name: Run relevance tests
run: pytest tests/ -v --tb=shortAnti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Mutable test core | Index changes shift baselines between runs | Snapshot + restore before each CI run |
| Only asserting aggregate nDCG | Per-query regressions hide in the mean | Per-query guard (Step 5) |
| LTR model tested without BM25 baseline | Improvement is unmeasurable | Capture BM25 nDCG first, then compare (Step 9) |
Fetching debugQuery output without debug.explain.structured=true | String parse is fragile across Solr versions | Always use structured explain |
| Uploading model before feature store | Model references features that don't exist yet | Features first, model second (Steps 8-9) |
Hard-coded reRankDocs=10 | Candidate pool too small; LTR can't reorder enough docs | Set reRankDocs to at least 3x the result page size |
Limitations
References
Related skills
hybrid-search-eval-author
Evaluates hybrid retrieval pipelines (BM25 + vector + reranker) end-to-end: authors ground-truth judgment sets, computes nDCG@k and MRR over fused results, measures the lift from Reciprocal Rank Fusion vs weighted fusion vs single-stage retrieval, and quantifies reranker (cross-encoder/Cohere/bge) impact. Use when a production system combines lexical and semantic retrieval and you need a numeric relevance baseline, fusion-strategy comparison, or evidence that a reranker is earning its latency cost.
judgment-list-author
Bootstraps human-relevance judgment lists (query sets, grading scales, rater guidelines, inter-rater agreement, Quepid tooling, TREC-style pooling, and refresh cadence) that serve as ground truth for search-relevance test suites. Use when a team needs to create or refresh the judgment corpus before running NDCG / MRR / Recall@k evaluations.
vector-search-recall-tests
Vector search benchmarking - recall@k vs latency tradeoffs, ground-truth construction via brute-force, HNSW tuning (M / ef_construct / ef per Qdrant docs), embedding-model-upgrade drift detection. Use ANN-Benchmarks framework for cross-engine comparison; per-engine clients (Qdrant, Weaviate, pgvector, Pinecone, Elasticsearch k-NN, Milvus) for in-product tests. Use when HNSW / IVF parameters are being tuned or an embedding model is swapped, and recall@k on the existing corpus has never been measured against brute-force ground truth.