NEEDLE: Which search API moves the needle for AI agents?

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:

  • : breaking and developing stories; new queries each hour from curated RSS feeds and Google Trends
  • : everyday company and SEC-filing lookups with known answers from public registries
  • : expert literature searches: find a specific paper from a degraded title, a full-text detail, or a tip-of-the-tongue description
  • : obscure, long-tail entities; rare-word queries sampled from the DeepResearchGym, OpenResearcher, and LRAT agentic search logs
  • : find a specific court opinion or section of the Code of Federal Regulations

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.

Quality results

Standings — all verticals

share of ultimate per spoke, farther out is better; whiskers are 95% CIs (run-level bootstrap); click legend names to toggle · hatched band?

7-day leaderboards

Query-weighted 7-day means with a 95% CI; n counts scored queries; suite columns split the score.

— 7-day mean

— 7-day mean

— 7-day mean

— 7-day mean

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.

Quality vs price

Overall quality vs price

7-day mean share of ultimate vs lowest public price per 1,000 queries

Price per 1,000 queries

Search latency

Agents wait on each search call, so latency adds directly to task time.

Search latency — last 7 days

p50 (filled) to p95 (open), successful calls, log scale; hover for values (A2)

Index independence

Repeated overlap on junk or near-identical lists suggests a shared upstream index (all runs, all benchmarks).

Definition: suspicious sharing

the pairwise test behind the matrix and the borrowing graph (src/needle/shared/overlap.py)

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.

Suspicious sharing (all runs)

Borrowing graph (all runs)

Shared result URLs (all runs)

Queries sharing ≥3 URLs (all runs)

Result uniqueness (all runs)

Result uniqueness — relevant only (all runs)

Table view — all runs in range

Appendix — methodology & configuration

We pin everything below in keenableai/needle; you can compute each number on this page again from the archived artifacts (A6).

A1 — query generation

how we build each slice's queries (per-bench generate.py); the exact rows every run used are archived (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).

verticalslicequery 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

A2 — comparison protocol

the fairness rules behind every number on this page

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.

A3 — metric definitions

src/needle/shared/metrics.py and per-bench score.py
metricbenchesdefinition
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 / p95all 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.

A4 — LLM judge

rates and results; fallback matcher only; and are deterministic

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.

A5 — engine configuration

request shapes pinned in git (src/needle/shared/search/); 5 results per query except (10). Engines are live, unversioned APIs; provider-side changes surface as trend shifts.
engineAPIpinned request
KeenableKeenable searchquery verbatim with site: kept; after:/before: → published_after:/published_before:; mode=realtime
google/serperSerper.dev (Google SERP)query verbatim, num=min(k,100), gl=us, hl=en; knowledge-graph and answer-box entries fold into the ranking
bing/searchapiSearchApi.io, engine=bingquery verbatim, num=min(k,50); knowledge-graph and answer-box fold in
braveBrave Web Searchcount=min(k,20), country=us, search_lang=en, result_filter=web; site: kept in the query text, after:/before: → freshness window
exaExa /searchtype=auto, numResults=k, highlights capped at the shared 2,000-char snippet limit; site: → includeDomains, after:/before: → published-date window
exa-instantExa /searchsame as exa but type=instant
parallelParallel /v1/searchmode=basic, max_results=k; site: → include_domains, after: → after_date
parallel-turboParallel /v1/searchsame as parallel but mode=turbo
perplexityPerplexity /searchmax_results=min(k,20), search_context_size=low; site: → search_domain_filter, after:/before: → date filters
tavilyTavily /searchsearch_depth=basic, max_results=min(k,20); site: → include_domains, after:/before: → start/end_date
youYou.com /v1/searchcount=min(k,100); site: → include_domains, after:/before: → freshness window

A6 — raw artifacts

everything you need to re-score a run from scratch
artifactcontentswhere
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

A7 — judgement browser

pick a benchmark, then a run, to inspect each query; full runs on the HF dataset