NEEDLE — search engine benchmarks

NEEDLE

19 min read Original article ↗

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

Last run: Oct 24, 11:52 UTC. News runs each hour; all other suites run each day. Times are UTC. Results table.

NEEDLE (News, Everyday, Expert, Deep-tail, and Legal Evaluation) is a live open-source search benchmark. It compares search engines with queries that model agent behavior. The five verticals match the use cases we see each day in Keenable's own search traffic:

  • Scholar: expert literature searches: find a specific paper from a degraded title, a full-text detail, or a tip-of-the-tongue description
  • News: breaking and developing stories; new queries each hour from curated RSS feeds and Google Trends
  • Finance: everyday company and SEC-filing lookups with known answers from public registries
  • AgenticRare: obscure, long-tail entities; rare-word queries sampled from the DeepResearchGym, OpenResearcher, and LRAT agentic search logs
  • Legal: 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, News each hour and the rest each day, so there is no fixed set to overfit. Appendix C gives the full comparison protocol. The benchmark is open source: the keenableai/needle repo is public, every run executes in plain GitHub Actions, and we welcome contributions.

Metrics. News and AgenticRare 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. Finance measures if the answer text reaches the agent: we scan the top-5 titles and snippets and report answer-recall@5 with MRR@5. Scholar and Legal are known-item tasks: we match identifiers and report recall@10 and recall@5 with MRR. Appendix D 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 by their relevance labels (Appendix C). The standings radar plots each engine as a share of ultimate.

Quality results

Standings — all verticals

share of ultimate (all engines pooled, the outer ring) per vertical, farther out is better; click a table row to toggle an engine · hatched band?

engineoveralloverall 95% CIScholarNewsFinanceAgenticRareLegal
ultimate (raw)100%0.9011.0000.9901.0000.971
gap to ultimate−27%−17%−36%−5%−50%−18%
Keenable73%[72%, 74%]83%59%90%50%82%
exa69%[67%, 70%]64%58%95%48%78%
perplexity66%[65%, 68%]67%64%92%46%63%
brave-llmcontext66%[65%, 67%]60%64%89%45%70%
perplexity-fast65%[63%, 67%]66%63%92%45%61%
parallel63%[62%, 65%]65%50%91%36%74%
exa-instant60%[59%, 61%]45%48%93%44%70%
linkup60%[58%, 61%]54%57%85%40%63%
brave59%[58%, 60%]55%56%82%35%67%
you58%[56%, 59%]53%56%81%34%64%
deepseek57%[55%, 58%]28%63%84%38%70%
context.dev57%[55%, 58%]61%45%83%33%61%
parallel-turbo54%[53%, 56%]42%44%88%33%65%
kagi53%[51%, 54%]63%32%78%29%62%
firecrawl52%[50%, 53%]56%28%85%25%65%
octen51%[49%, 53%]34%51%78%31%60%
google/serper49%[48%, 51%]54%26%78%25%62%
bing/searchapi48%[47%, 50%]50%32%76%18%64%
chatgpt-search47%[41%, 52%]27%32%73%35%70%
jina44%[42%, 46%]52%21%78%16%55%
tavily43%[42%, 45%]35%29%87%28%38%
claude-search39%[32%, 46%]43%32%51%21%49%
tinyfish39%[38%, 41%]14%25%79%21%56%

7-day leaderboards

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

Overall — 7-day mean

mean share of ultimate across 5 verticals, last 7 days

engineof ultimate95% CI
Keenable73%[72.0%, 74.1%]
exa69%[67.4%, 69.7%]
perplexity66%[64.5%, 68.2%]
brave-llmcontext66%[64.7%, 66.8%]
perplexity-fast65%[63.4%, 67.4%]
parallel63%[61.9%, 64.6%]
exa-instant60%[58.9%, 60.7%]
linkup60%[58.2%, 60.9%]
brave59%[58.0%, 60.5%]
you58%[56.3%, 59.0%]
deepseek57%[55.1%, 58.1%]
context.dev57%[54.9%, 58.2%]
parallel-turbo54%[53.1%, 55.9%]
kagi53%[51.2%, 54.1%]
firecrawl52%[50.4%, 53.2%]
octen51%[49.5%, 52.6%]
google/serper49%[47.7%, 50.7%]
bing/searchapi48%[46.6%, 49.6%]
chatgpt-search47%[41.2%, 51.9%]
jina44%[41.9%, 46.4%]
tavily43%[42.1%, 44.6%]
claude-search39%[32.1%, 45.9%]
tinyfish39%[37.6%, 40.6%]

Scholar — 7-day mean

mean of 9 runs, last 7 days — latest Oct 23, 15:21 UTC

enginerecall@1095% CItitlebodycluetot
ultimate0.901[0.890, 0.910]1.0000.9000.9420.760
Keenable0.747[0.732, 0.761]0.9130.7540.8220.498
perplexity0.606[0.557, 0.658]0.8890.6090.6480.281
perplexity-fast0.597[0.547, 0.649]0.8830.5840.6430.278
parallel0.584[0.568, 0.600]0.9960.4290.6540.258
exa0.575[0.551, 0.599]0.9090.5880.4980.305
kagi0.564[0.548, 0.581]0.9720.6600.5750.050
context.dev0.546[0.504, 0.590]0.8770.6710.5140.120
brave-llmcontext0.539[0.522, 0.556]0.9130.4440.6240.176
firecrawl0.502[0.484, 0.520]0.8650.6670.4570.018
brave0.497[0.479, 0.515]0.9040.3590.6020.123
google/serper0.490[0.472, 0.509]0.8620.6300.4480.020
linkup0.483[0.466, 0.500]0.8930.3560.5800.105
you0.482[0.465, 0.498]0.9090.3410.5850.091
jina0.464[0.370, 0.521]0.8260.6020.4080.020
bing/searchapi0.451[0.434, 0.468]0.9480.3810.4330.041
exa-instant0.402[0.379, 0.427]0.7120.4010.3580.137
claude-search0.391[0.222, 0.514]0.6880.3150.4600.099
parallel-turbo0.378[0.362, 0.395]0.9230.1840.3260.080
tavily0.312[0.296, 0.328]0.8360.2410.1430.028
octen0.304[0.282, 0.324]0.7150.0610.2220.217
deepseek0.254[0.228, 0.279]0.5810.1760.1760.081
chatgpt-search0.241[0.162, 0.310]0.6710.0490.1660.076
tinyfish0.126[0.105, 0.149]0.2350.1510.1170.002

News — 7-day mean

mean of 161 runs, last 7 days — latest Oct 24, 11:52 UTC

enginenDCG@595% CI
ultimate1.000[1.000, 1.000]
brave-llmcontext0.644[0.635, 0.654]
perplexity0.635[0.624, 0.646]
deepseek0.634[0.623, 0.645]
perplexity-fast0.633[0.623, 0.644]
Keenable0.595[0.583, 0.606]
exa0.580[0.564, 0.594]
linkup0.569[0.559, 0.580]
brave0.562[0.552, 0.572]
you0.556[0.546, 0.566]
octen0.513[0.500, 0.526]
parallel0.497[0.478, 0.516]
exa-instant0.481[0.466, 0.495]
context.dev0.455[0.442, 0.468]
parallel-turbo0.436[0.420, 0.451]
chatgpt-search0.320[0.305, 0.335]
claude-search0.319[0.283, 0.353]
kagi0.319[0.310, 0.327]
bing/searchapi0.316[0.308, 0.324]
tavily0.295[0.285, 0.305]
firecrawl0.283[0.274, 0.292]
google/serper0.262[0.254, 0.270]
tinyfish0.254[0.246, 0.263]
jina0.207[0.200, 0.215]

Finance — 7-day mean

mean of 7 runs, last 7 days — latest Oct 24, 03:41 UTC

enginerecall@595% CIfilingsfilingdoc
ultimate0.990[0.983, 0.996]1.0000.945
exa0.938[0.920, 0.959]0.9570.754
exa-instant0.921[0.893, 0.954]0.9470.664
perplexity0.914[0.883, 0.945]0.9360.625
perplexity-fast0.909[0.877, 0.943]0.9260.613
parallel0.905[0.884, 0.936]0.9510.525
Keenable0.894[0.870, 0.929]0.8930.513
brave-llmcontext0.885[0.861, 0.922]0.9680.333
parallel-turbo0.875[0.844, 0.917]0.9060.545
tavily0.857[0.830, 0.900]0.8480.361
firecrawl0.842[0.813, 0.889]0.6920.467
linkup0.839[0.794, 0.896]0.7780.410
deepseek0.831[0.801, 0.881]0.9250.400
context.dev0.821[0.783, 0.877]0.6540.445
brave0.814[0.776, 0.872]0.6650.370
you0.801[0.764, 0.860]0.6330.347
tinyfish0.781[0.742, 0.839]0.5260.459
google/serper0.777[0.741, 0.839]0.5000.433
jina0.776[0.735, 0.839]0.6310.471
octen0.773[0.731, 0.842]0.7730.167
kagi0.768[0.724, 0.834]0.5030.342
bing/searchapi0.755[0.710, 0.827]0.4230.390
chatgpt-search0.719[0.466, 0.879]0.6500.555
claude-search0.509[0.282, 0.752]0.3240.387

AgenticRare — 7-day mean

mean of 8 runs, last 7 days — latest Oct 24, 08:38 UTC

enginenDCG@595% CI
ultimate1.000[1.000, 1.000]
Keenable0.505[0.481, 0.529]
exa0.479[0.454, 0.505]
perplexity0.459[0.434, 0.484]
brave-llmcontext0.454[0.430, 0.478]
perplexity-fast0.454[0.429, 0.478]
exa-instant0.439[0.414, 0.462]
linkup0.396[0.372, 0.420]
deepseek0.377[0.353, 0.402]
parallel0.363[0.336, 0.396]
brave0.352[0.331, 0.373]
chatgpt-search0.350[0.244, 0.412]
you0.340[0.319, 0.361]
parallel-turbo0.332[0.308, 0.356]
context.dev0.329[0.307, 0.350]
octen0.314[0.291, 0.336]
kagi0.291[0.272, 0.310]
tavily0.277[0.256, 0.297]
firecrawl0.252[0.234, 0.271]
google/serper0.247[0.229, 0.265]
claude-search0.210[0.106, 0.301]
tinyfish0.206[0.188, 0.224]
bing/searchapi0.181[0.165, 0.197]
jina0.165[0.149, 0.180]

Quality vs price

Overall quality vs price

7-day mean share of ultimate vs lowest public price per 1,000 queries; Oct 17, 12:13 – Oct 24, 11:52 UTC

engine$ / 1k queriesoverall
Keenable$1.0073%
exa$7.0069%
perplexity$5.0066%
brave-llmcontext$5.0066%
perplexity-fast$1.0065%
parallel$5.0063%
exa-instant$7.0060%
linkup$5.0060%
brave$5.0059%
you$5.0058%
deepseek$0.7557%
context.dev$0.5057%
parallel-turbo$1.0054%
kagi$12.0053%
firecrawl$1.2052%
octen$1.0051%
google/serper$0.3049%
bing/searchapi$1.0048%
chatgpt-search$10.0047%
jina$3.4044%
tavily$5.0043%
claude-search$10.0039%
tinyfish$0.0039%

Price per 1,000 queries

biggest self-serve tier, no enterprise quotes; collected 2026-08-21

engine$ / 1k queriesplan
tinyfish$0.00free
google/serper$0.30Serper Ultimate — $3,750 / 12.5M credits
context.dev$0.50Scale — $499/mo / 1M credits, 1 credit per 10 results
deepseek$0.75pay-as-you-go — $0.75 / 1k requests
bing/searchapi$1.00SearchApi.io Octo 5M — $5,000/mo
Keenable$1.00Frontier Tier — at 100 RPS+
octen$1.00pay-as-you-go
parallel-turbo$1.00pay-as-you-go
perplexity-fast$1.00fast search — $1 / 1k requests
firecrawl$1.20Scale — $599/mo / 1M credits, 2 credits per search
jina$3.40$0.05 / 1M tokens, ≈68k tokens per search
brave$5.00flat rate
brave-llmcontext$5.00flat rate
linkup$5.00pay-as-you-go — standard depth, searchResults
parallel$5.00pay-as-you-go
perplexity$5.00flat rate
tavily$5.00Growth — $500/mo / 100k credits
you$5.00Web Search API plan
exa$7.00pay-as-you-go
exa-instant$7.00pay-as-you-go
chatgpt-search$10.00web_search tool — $10 / 1k calls, plus tokens
claude-search$10.00web search tool — $10 / 1k searches, plus tokens
kagi$12.00pay-as-you-go

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 (Appendix C)

enginep50p95errors
Keenable143 ms351 ms0.0%
perplexity-fast161 ms232 ms0.0%
octen206 ms412 ms0.0%
perplexity262 ms1.6 s0.0%
parallel-turbo348 ms604 ms1.2%
exa-instant498 ms783 ms0.5%
brave509 ms737 ms0.0%
you515 ms697 ms0.0%
deepseek566 ms689 ms0.0%
firecrawl776 ms4.5 s0.0%
context.dev1.1 s4.6 s0.0%
kagi1.3 s2.1 s0.0%
brave-llmcontext1.3 s2.5 s0.0%
linkup1.3 s1.9 s0.0%
google/serper1.5 s3.4 s0.0%
tinyfish2.0 s3.6 s30.2%
tavily2.0 s3.7 s0.1%
bing/searchapi2.4 s8.7 s0.3%
exa2.5 s6.2 s0.5%
parallel3.2 s5.0 s1.2%
jina17 s25 s5.5%
claude-search34 s51 s27.6%
chatgpt-search42 s55 s13.4%

Index independence

Repeated overlap on junk or near-identical lists suggests a shared upstream index (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 the selected 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.

Index independence — four views

suspicious-sharing matrix, URL-intersection matrix, borrowing graph, and per-engine result uniqueness

Result uniqueness (all runs)

share of an engine's URLs that no other engine family returned for the same query and that are relevant (judged HM/FullyM on the judged verticals, the known-item gold hit on the known-answer verticals); the URLs column counts unique relevant URLs / all URLs the engine returned; same-family engines (Exa/Exa-instant, Parallel/Parallel-turbo) do not count against each other

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 (Appendix G).

Appendix A — judgement browser

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

Appendix B — query generation

how we build each slice's queries (per-bench generate.py); the exact rows every run used are archived (Appendix G)

We make each query set fresh from public sources: News each hour, the other verticals each day.

For Scholar 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 Scholar 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 Scholar 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 AgenticRare 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
Scholartitle the paper's title, lowercased with LaTeX and punctuation stripped; plain known-item lookup, answerable from metadata
Scholarbody 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
Scholarclue 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
Scholartot 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
Newsfresh 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
Financefinance / 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
Financefilings 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
Financefilingdoc known-item SEC-filing retrieval; the gold answer is the filing's accession number; half the queries carry operator syntax (site:, "quoted", after:/before:)
AgenticRarerare-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
Legalcaselaw 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
Legalcode 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

Appendix C — 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 (Appendix F). 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 (Appendix E 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 News queries each hour and new known-answer queries each day from public sources (Appendix B), so there is no fixed benchmark set to overfit. We archive the exact queries with each run (Appendix G).

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.

Appendix D — metric definitions

src/needle/shared/metrics.py and per-bench score.py

metricbenchesdefinition
recall@10, MRR@10Scholar known-item: the gold paper identified in the top 10 by arXiv ID / DOI / PMID match.
nDCG@5News, AgenticRare 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 penaltyNews, AgenticRare 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@5Finance 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@5, MRR@5Legal 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.

Appendix E — LLM judge

rates News and AgenticRare results; Finance fallback matcher only; Scholar and Legal are deterministic

We judge with openai/gpt-6-luna via OpenRouter, high reasoning effort, 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 caps 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.

Appendix F — engine configuration

request shapes pinned in git (src/needle/shared/search/); k = 5 results per query except Scholar (10). Engines are live, unversioned APIs; provider-side changes surface as trend shifts.

Every run builds each client with the same snippet budget of 2,000 characters. Keenable, Exa and Ceramic accept a request-side length parameter, so they get it in the request (snippet_max_length=2000, highlights.maxCharacters=2000, maxDescriptionLength=2000). The other engines have no such parameter; we clip whatever they return to the same 2,000 characters before judging and answer matching (Appendix C). Requests time out at 30 s (60 s for Perplexity, Parallel and Jina, 180 s for claude-search and chatgpt-search) and retry up to 3 times with exponential backoff on transport errors, 429, and 5xx.

engineAPIpinned request
KeenableKeenable POST /v1/searchmode=realtime, snippet_max_length=2000; query verbatim with site: kept in the text, after:/before: → published_after:/published_before: tokens; no count parameter — we keep the first k results
google/serperSerper.dev (Google SERP) POST /searchq = query verbatim (operators kept as text), num=min(k,100), gl=us, hl=en; knowledge-graph and answer-box entries fold into the ranking
bing/searchapiSearchApi.io GET /searchengine=bing, q = query verbatim (operators kept as text), num=min(k,50); knowledge-graph and answer-box fold in
braveBrave GET /web/searchq = query text with site: kept, clipped to 75 words / 600 chars; count=min(k,20), country=us, search_lang=en, result_filter=web, text_decorations=false; after:/before: → freshness=YYYY-MM-DDtoYYYY-MM-DD; extra_snippets joined with newlines, falling back to description when absent
brave-llmcontextBrave POST /llm/contextq = query text clipped to 75 words / 600 chars; country=us, search_lang=en; after:/before: → freshness=YYYY-MM-DDtoYYYY-MM-DD; per-URL snippets joined with newlines and clipped client-side to the 2,000-char budget, published date from the sources map
exaExa POST /searchtype=auto, numResults=k, contents.highlights.maxCharacters=2000; site: → includeDomains, after:/before: → startPublishedDate/endPublishedDate (full-day UTC bounds)
exa-instantExa POST /searchsame as exa but type=instant
parallelParallel POST /v1/searchmode=basic, search_queries=[query text], advanced_settings.max_results=k; site: → source_policy.include_domains, after: → source_policy.after_date (before: has no mapping and is dropped)
parallel-turboParallel POST /v1/searchsame as parallel but mode=turbo
perplexityPerplexity POST /searchmax_results=min(k,20), search_context_size=low; site: → search_domain_filter, after:/before: → search_after_date_filter/search_before_date_filter (MM/DD/YYYY)
perplexity-fastPerplexity POST /searchsame as perplexity but search_type=fast (perplexity leaves the field unset, so it takes the API default web)
tavilyTavily POST /searchsearch_depth=basic, max_results=min(k,20); site: → include_domains, after:/before: → start_date/end_date (ISO dates)
linkupLinkup POST /v1/searchdepth=standard, outputType=searchResults, maxResults=k; site: → includeDomains, after:/before: → fromDate/toDate (ISO dates); HTML entities in name/content unescaped, image results dropped; the API returns no published date
tinyfishTinyFish GET api.search.tinyfish.aiquery = query text clipped to 2,000 chars; no count parameter — we keep the first k results; site: → include_domains (comma-separated), after:/before: → after_date/before_date (ISO dates)
youYou.com POST /v1/searchcount=min(k,100); site: → include_domains, after:/before: → freshness=YYYY-MM-DDtoYYYY-MM-DD; web and news sections merge, deduplicated by URL
firecrawlFirecrawl POST /v2/searchlimit=min(k,100), sources=[web]; site: kept in the query text, after:/before: → tbs=cdr:1,cd_min:M/D/YYYY,cd_max:M/D/YYYY
kagiKagi POST /api/v1/searchquery = query text, limit=min(k,1024); site: → lens.sites_included, after:/before: → filters.after/filters.before (ISO dates); search and news sections merge, deduplicated by URL; HTML markup stripped from titles and snippets
octenOcten POST /searchquery = query text clipped to 500 chars, count=k, language=[en], safesearch=off, highlight.enable=true with max_tokens=512; site: → include_domains, after:/before: → start_time/end_time (full-day UTC bounds); highlight is the snippet
jinaJina GET s.jina.aiq = full query with site:/after:/before: kept as Google operators, num=min(k,20); default mode fetches each result page, so the snippet is the page markdown (content) clipped client-side to the 2,000-char budget, falling back to the SERP description when the fetch returns nothing; published date from publishedTime, else the SERP date string
context.devContext.dev POST /v1/web/searchquery = query text clipped to 500 chars, numResults=min(max(k,10),100) (API floor is 10), country=us, queryFanout=false; site: → includeDomains, after: (with no before:) → freshness last_24_hours/last_week/last_month/last_year covering that date, before: dropped; snippet is the SERP description, markdown scrape left off
deepseekDeepSeek POST /v1/tools/searchquery = query text clipped to fit 128 chars with site: (several joined by OR), lang:en and after:/before: as native operators, count=min(k,50), need_content=false; snippet is the long query-aware summary, falling back to the short snippet; published date from date
claude-searchAnthropic POST /v1/messagesmodel=claude-sonnet-5, max_tokens=32000, tools=[web_search_20250305 with max_uses=1], system prompt asks for a JSON array of every search result with the snippet copied verbatim (≤500 chars); rank = web_search_tool_result order, title/snippet from the JSON row with the same URL, published_date = page_age; the prompt tells the model to pass the query to the tool verbatim (operators kept as text), site: also → allowed_domains
chatgpt-searchOpenAI POST /v1/responsesmodel=gpt-5.5, reasoning.effort=low, max_output_tokens=32000, tools=[web_search with search_context_size=low], tool_choice forces the search, include=web_search_call.action.sources, same system prompt; rank = action.sources order, title/snippet from the JSON row with the same URL (utm_ parameters stripped); the prompt tells the model to pass the query to the tool verbatim (operators kept as text), site: also → filters.allowed_domains

Appendix G — 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/needle-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

Citation

if you use NEEDLE in your work, please cite

@misc{needle2026,
  author = {Styskin, Andrey and Petri, Matthias and Gusev, Ilya},
  title  = {{NEEDLE}: A Live Open-Source Search Benchmark for {AI} Agents},
  year   = {2026},
  url    = {https://keenableai.github.io/needle/},
  note   = {Contact: andrey@keenable.ai, matthias@keenable.ai, ilya.gusev@keenable.ai}
}