News runs each hour; all other suites run each day. Times are UTC.
NEEDLE (News, Everyday, Expert, Deep-tail, and Legal Evaluation) is an agentic search benchmark. It compares major search engines with queries that model real agent behavior. The five verticals match the use cases we see each day in Keenable's own search traffic:
Methodology. Each engine in a run receives the same query text. We score each engine on the ranking, titles, and snippets it returned. We do not fetch pages and we do not re-rank. The judge does not see the engine name. We make new query sets from public sources, each hour and the rest each day, so there is no fixed set to overfit. A2 gives the full comparison protocol; we keep the benchmarks in keenableai/needle.
Metrics. and queries have no single correct result, so we measure ranking quality. An LLM judge rates each result from 0 to 4. We report nDCG@5, normalized so that the pooled ultimate engine scores exactly 1, with a penalty for duplicate URLs. measures if the answer text reaches the agent: we scan the top-5 titles and snippets and report answer-recall@5 with MRR@5. and are known-item tasks: we match identifiers and report recall@10 and recall@5 with MRR. A3 gives the exact definitions.
The ultimate ceiling. The italicized row in each leaderboard is ultimate, a synthetic engine. It pools all engines' results per query and ranks them oracle-style (A2). The standings radar plots each engine as a share of ultimate.
Query-weighted 7-day means with a 95% CI;
n counts scored queries;
suite columns
split the score.
The same slices as the leaderboards, plotted over the full history: each engine's score as % of ultimate, smoothed with a 7-day rolling mean. Click legend names to highlight engines.
Agents wait on each search call, so latency adds directly to task time.
Repeated overlap on junk or near-identical lists suggests a shared upstream index (all runs, all benchmarks).
Let UA(q) be the normalized URLs engine A returned for query q, and LA(q) ⊆ UA(q) those judged below HM. Engines A and B share suspiciously on q when
|LA(q) ∩ LB(q)| ≥ 1 or |UA(q) ∩ UB(q)| ≥ 3.
Each matrix cell shows the share of comparable queries (both searches succeeded and at least one returned a result) where the pair shares suspiciously, pooled over all published runs. Engines that share good results show normal ranking behavior. Engines that share junk, or 3+ URLs at once, possibly share an upstream index. Results on the known-answer verticals have no judgements, so only the second clause can apply.
We pin everything below in keenableai/needle; you can compute each number on this page again from the archived artifacts (A6).
We make each query set fresh from public sources: each hour, the other verticals each day.
For we sample papers from arXiv (computer, physical, life, and social sciences) and Europe PMC (health sciences). Four age bands reach from the last week back to approximately five years. Each paper gives one query per slice, so the four slices rank engines on the same papers. The gaps between the slices are the point: title vs body isolates full-text indexing, body vs clue isolates keyword-vs-prose handling, and tot isolates semantic retrieval.
We leak-check each LLM-written query: at least two content words must not appear in the title or the abstract. We keep a paper only if each slice produced a valid query. The known-item and tip-of-the-tongue framing follows Exa's "SOTA Search Over Academic Publications" (Jul 2026).
The stream merges search queries from three public agent-trajectory releases: the anonymized DeepResearchGym logs (14M+ search requests from real agentic clients; "Agentic Search in the Wild", SIGIR '26), the OpenResearcher deep-research trajectories (TIGER-AI-Lab, 2026), and LRAT-Train ("Learning to Retrieve from Agent Trajectories", SIGIR '26).
| vertical | slice | query construction |
|---|---|---|
| fresh | the newest item per feed from ~124 curated RSS feeds within a freshness window, merged with Google Trends RSS across all 52 US geos (fuzzy-deduped, capped by traffic); an LLM projects each item into a keyword query and refuses evergreen content | |
| rare-entity | sampled from the merged DeepResearchGym + OpenResearcher + LRAT-Train query stream, keeping queries where BERT WordPiece splits a word into 5+ subwords or maps it to [UNK]; fastText language ID gates for English, an n-gram cap drops near-copies, and each run samples stratified by query length | |
| finance / finance_nl | registry facts per company (CEO, founding year, HQ country, website, employees, LEI) from Wikidata and GLEIF, ticker from SEC, asked as a keyword query and, where a template exists, as a natural-language variant | |
| filings | single-quarter 10-Q facts (net income, operating income, diluted EPS) from SEC XBRL, companies stratified by market cap; half the queries are quoted phrases | |
| filingdoc | known-item SEC-filing retrieval; the gold answer is the filing's accession number; half the queries carry operator syntax (site:, "quoted", after:/before:) | |
| title | the paper's title, lowercased with LaTeX and punctuation stripped; plain known-item lookup, answerable from metadata | |
| body | a keyword query (3–8 words) anchored on a globally distinctive detail that appears in the full text but not in the title or abstract (a named method, cohort, instrument, or an unusual measured value); answerable only by engines that index the paper body | |
| clue | a natural-language question built from 2–3 precise factual clues taken from the body, the way a researcher re-finds a paper from remembered specifics | |
| tot | a hedged, half-remembered description with every specific blurred: approximate quantities, no names the paper introduces, at most two content words reused from the title | |
| caselaw | recent published opinions from the CourtListener API across 14 federal courts, one caption-style query per case; gold is the reporter citation, docket number with party tokens, and cluster id | |
| code | eCFR sections; an LLM projects each section into a query about its regulatory substance with one verbatim quoted span, rejected if it leaks the citation; gold is the "title CFR section" citation |
We send each engine in a run the same query text. We pin the per-engine request shapes in git (A5). The runner sends requests in sequence, one call at a time across the whole run. Thus the latency percentiles are comparable across engines, and no engine gets concurrent load. Engines see the live web minutes apart in a run, not at the same instant.
We judge an engine on its own ranking, titles, and snippets. We do not fetch pages and we do not re-rank. We cap the evidence at the same snippet length for each engine, so engines that return more content get no extra signal to the judge or the answer matchers. We judge each distinct combination of URL, title, snippet, and date once per query. We use that rating again where the identical result occurs for other engines (A4 gives the full judging setup).
We remove failed searches and judgements from the mean and report them as search_errors and judge_errors. An outage decreases num_scored and increases the error rate; it does not decrease the quality score.
We make fresh queries each hour and new known-answer queries each day from public sources (A1), so there is no fixed benchmark set to overfit. We archive the exact queries with each run (A6).
Each vertical also reports ultimate, a synthetic engine. It pools all engines' results per query and ranks them oracle-style: best judge rating first on the judged verticals, and the gold item first on the known-answer verticals. Its score shows what one engine could get if it always returned the best results that the full field found. This separates "the engine ranked poorly" from "no engine found better results". We do not include ultimate in the overlap and uniqueness statistics.
| metric | benches | definition |
|---|---|---|
| nDCG@5 | , | DCG over the top 5 results (Σ gain(rᵢ) / log₂(i + 1), with judge ratings mapped to gain 4 → 1.0, 3 → 0.667, 2 → 0.117, 1/0 → 0), divided by the DCG of the ideal ranking: the pooled ultimate ordering of every engine's deduplicated results, so ultimate scores exactly 1. A query is scored only if every one of its results was judged (otherwise it is dropped from the mean and counted as judge errors) and only if the pooled ideal has any gain; queries where no engine found anything relevant are excluded. |
| redundancy penalty | , | applied to engine ratings before scoring: a duplicate URL drops the rating to 0. The pooled ideal is already deduplicated, so no penalty applies there. Skipped when the query itself carries a site: operator. Raw and penalized ratings are both shown in the judgement browser. |
| answer-recall@5, MRR@5 | a query is a hit when the gold answer appears in a top-5 title or snippet (deterministic matcher, with an LLM-judge backstop for paraphrased answers). MRR is the mean reciprocal rank of the first hit. | |
| recall@10, MRR@10 | known-item: the gold paper identified in the top 10 by arXiv ID / DOI / PMID match. | |
| recall@5, MRR@5 | known-item: the gold opinion or CFR section identified by citation, docket (with a party-token check), CFR citation, or URL match. | |
| latency p50 / p95 | all | percentiles over per-call durations of successful search API calls, pooled across queries in the window; runs published without per-call samples contribute their per-run percentiles instead. Failed calls carry no latency sample and show up in the error rate. |
We judge with openai/gpt-5.6-terra via OpenRouter, temperature 0, one pointwise call per document. We record the model for each run as judge_model in its archived report. The judgement browser shows it when you select a run, so judge changes stay visible in the history.
The judge does not see the engine identity. The prompt contains the query, the document title, the URL, the published date, and the snippet — not the engine name. We judge each distinct combination of URL, title, snippet, and date once per query and use that rating again where the identical result occurs for other engines. We judge the same URL with a different snippet separately, with the same blind judge and rubric.
A judge preference for a snippet style would be a shared bias across engines. We publish the per-result reasoning with each run, so you can audit this.
Rubric: 0–4 Needs Met scale (FailsM / SM / HM / FullyM) with hard gates for a wrong entity, stale coverage of dated events, and search-operator compliance ("quoted phrases", site:). The full prompt is one template in the repo: src/needle/shared/prompts/judgement.jinja.
Known limitation: we operate NEEDLE ourselves, and we did not yet do a systematic human-agreement audit of the judge. But you can check the numbers: the judge is blind and shared across engines, each rating includes its reasoning, and we publish the queries, prompts, and raw results. Thus anyone can score any rating again.
| engine | API | pinned request |
|---|---|---|
| Keenable | Keenable search | query verbatim with site: kept; after:/before: → published_after:/published_before:; mode=realtime |
| google/serper | Serper.dev (Google SERP) | query verbatim, num=min(k,100), gl=us, hl=en; knowledge-graph and answer-box entries fold into the ranking |
| bing/searchapi | SearchApi.io, engine=bing | query verbatim, num=min(k,50); knowledge-graph and answer-box fold in |
| brave | Brave Web Search | count=min(k,20), country=us, search_lang=en, result_filter=web; site: kept in the query text, after:/before: → freshness window |
| exa | Exa /search | type=auto, numResults=k, highlights capped at the shared 2,000-char snippet limit; site: → includeDomains, after:/before: → published-date window |
| exa-instant | Exa /search | same as exa but type=instant |
| parallel | Parallel /v1/search | mode=basic, max_results=k; site: → include_domains, after: → after_date |
| parallel-turbo | Parallel /v1/search | same as parallel but mode=turbo |
| perplexity | Perplexity /search | max_results=min(k,20), search_context_size=low; site: → search_domain_filter, after:/before: → date filters |
| tavily | Tavily /search | search_depth=basic, max_results=min(k,20); site: → include_domains, after:/before: → start/end_date |
| you | You.com /v1/search | count=min(k,100); site: → include_domains, after:/before: → freshness window |
| artifact | contents | where |
|---|---|---|
| per-run reports | every query and result (title, URL, snippet), judge rating + penalized rating + reasoning, per-call latencies, and the run's config (judge_model, num_results) | HF: keenable-ai/keenbench-results, runs/<run-id>/ |
| query files | the exact queries each run issued, including gold answers (fresh.jsonl, gold.jsonl, scholar.jsonl, legal.jsonl) | same run folder |
| dashboard aggregates | history.jsonl, overlap.jsonl, uniqueness.jsonl, runs.json, ci_scores.jsonl, ci.json | data/ on this site (gh-pages) |
| prompts & scoring code | judge prompt (shared/prompts/judgement.jinja), metric implementations, engine clients | GitHub: keenableai/needle |