Last run: Nov 7, 10:50 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:
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.
| engine | overall | overall 95% CI | Scholar | News | Finance | AgenticRare | Legal |
|---|---|---|---|---|---|---|---|
| ultimate (raw) | 100% | 0.933 | 1.000 | 0.992 | 1.000 | 0.982 | |
| gap to ultimate | −25% | −18% | −35% | −7% | −49% | −9% | |
| perplexity | 75% | [74%, 76%] | 82% | 64% | 92% | 48% | 88% |
| perplexity-fast | 73% | [72%, 74%] | 74% | 64% | 90% | 47% | 91% |
| Keenable | 72% | [71%, 74%] | 77% | 62% | 89% | 51% | 85% |
| exa | 70% | [68%, 71%] | 60% | 64% | 91% | 49% | 84% |
| exa-lite | 69% | [68%, 70%] | 58% | 65% | 91% | 50% | 82% |
| parallel | 69% | [68%, 70%] | 63% | 58% | 93% | 44% | 87% |
| brave-llmcontext | 67% | [66%, 68%] | 61% | 62% | 87% | 44% | 81% |
| context.dev | 62% | [61%, 64%] | 80% | 51% | 77% | 34% | 69% |
| brave | 61% | [59%, 62%] | 56% | 56% | 77% | 36% | 79% |
| linkup | 60% | [59%, 61%] | 53% | 55% | 81% | 40% | 72% |
| you | 59% | [58%, 61%] | 53% | 55% | 76% | 35% | 78% |
| parallel-turbo | 59% | [58%, 60%] | 42% | 42% | 90% | 38% | 82% |
| deepseek | 58% | [56%, 59%] | 34% | 61% | 81% | 38% | 75% |
| claude-search | 55% | [54%, 57%] | 56% | 46% | 73% | 30% | 72% |
| chatgpt-search | 53% | [52%, 54%] | 34% | 36% | 81% | 38% | 75% |
| kagi | 50% | [43%, 52%] | 57% | 32% | 75% | 27% | 60% |
| firecrawl | 50% | [49%, 52%] | 51% | 29% | 81% | 27% | 64% |
| octen | 49% | [48%, 50%] | 31% | 52% | 72% | 32% | 58% |
| bing/searchapi | 48% | [47%, 49%] | 46% | 32% | 71% | 20% | 72% |
| google/serper | 47% | [45%, 48%] | 49% | 26% | 71% | 25% | 61% |
| tinyfish | 45% | [43%, 46%] | 47% | 26% | 70% | 24% | 57% |
| tavily | 44% | [42%, 45%] | 33% | 30% | 83% | 28% | 44% |
| jina | 40% | [40%, 41%] | 51% | 21% | 68% | 16% | 46% |
| engine | of ultimate | 95% CI |
|---|---|---|
| perplexity | 75% | [73.7%, 75.9%] |
| perplexity-fast | 73% | [72.1%, 74.0%] |
| Keenable | 72% | [71.2%, 73.6%] |
| exa | 70% | [68.4%, 71.0%] |
| exa-lite | 69% | [67.9%, 70.4%] |
| parallel | 69% | [67.5%, 69.9%] |
| brave-llmcontext | 67% | [66.0%, 68.1%] |
| context.dev | 62% | [60.9%, 63.6%] |
| brave | 61% | [59.5%, 61.8%] |
| linkup | 60% | [59.0%, 61.4%] |
| you | 59% | [58.2%, 60.5%] |
| parallel-turbo | 59% | [57.5%, 59.8%] |
| deepseek | 58% | [56.2%, 59.2%] |
| claude-search | 55% | [54.0%, 56.7%] |
| chatgpt-search | 53% | [51.6%, 53.6%] |
| kagi | 50% | [43.5%, 52.4%] |
| firecrawl | 50% | [48.9%, 51.6%] |
| octen | 49% | [47.9%, 50.0%] |
| bing/searchapi | 48% | [47.3%, 48.9%] |
| google/serper | 47% | [45.4%, 47.8%] |
| tinyfish | 45% | [43.3%, 46.0%] |
| tavily | 44% | [42.3%, 45.1%] |
| jina | 40% | [39.5%, 41.2%] |
| engine | recall@10 | 95% CI | title | body | clue | tot |
|---|---|---|---|---|---|---|
| ultimate | 0.933 | [0.924, 0.942] | 1.000 | 0.900 | 0.969 | 0.864 |
| perplexity | 0.764 | [0.749, 0.780] | 0.928 | 0.753 | 0.794 | 0.582 |
| context.dev | 0.747 | [0.714, 0.781] | 0.932 | 0.689 | 0.806 | 0.561 |
| Keenable | 0.715 | [0.698, 0.732] | 0.868 | 0.718 | 0.786 | 0.487 |
| perplexity-fast | 0.689 | [0.671, 0.705] | 0.918 | 0.708 | 0.754 | 0.374 |
| parallel | 0.584 | [0.566, 0.603] | 0.940 | 0.443 | 0.650 | 0.304 |
| brave-llmcontext | 0.569 | [0.551, 0.587] | 0.872 | 0.535 | 0.615 | 0.253 |
| exa | 0.563 | [0.514, 0.604] | 0.908 | 0.585 | 0.424 | 0.335 |
| exa-lite | 0.539 | [0.498, 0.574] | 0.900 | 0.521 | 0.386 | 0.349 |
| kagi | 0.534 | [0.506, 0.562] | 0.961 | 0.590 | 0.532 | 0.052 |
| brave | 0.523 | [0.505, 0.542] | 0.871 | 0.451 | 0.593 | 0.178 |
| claude-search | 0.520 | [0.490, 0.556] | 0.826 | 0.577 | 0.529 | 0.148 |
| you | 0.495 | [0.476, 0.514] | 0.871 | 0.414 | 0.560 | 0.138 |
| linkup | 0.491 | [0.473, 0.509] | 0.864 | 0.367 | 0.564 | 0.169 |
| jina | 0.474 | [0.448, 0.497] | 0.836 | 0.608 | 0.425 | 0.026 |
| firecrawl | 0.473 | [0.447, 0.499] | 0.825 | 0.628 | 0.410 | 0.028 |
| google/serper | 0.460 | [0.438, 0.480] | 0.814 | 0.593 | 0.408 | 0.026 |
| tinyfish | 0.438 | [0.408, 0.471] | 0.792 | 0.557 | 0.387 | 0.014 |
| bing/searchapi | 0.431 | [0.413, 0.450] | 0.944 | 0.344 | 0.401 | 0.035 |
| parallel-turbo | 0.394 | [0.376, 0.413] | 0.801 | 0.235 | 0.410 | 0.131 |
| chatgpt-search | 0.316 | [0.299, 0.334] | 0.886 | 0.069 | 0.172 | 0.136 |
| deepseek | 0.313 | [0.260, 0.367] | 0.713 | 0.212 | 0.178 | 0.149 |
| tavily | 0.311 | [0.288, 0.338] | 0.792 | 0.236 | 0.186 | 0.032 |
| octen | 0.289 | [0.273, 0.306] | 0.653 | 0.064 | 0.212 | 0.228 |
| engine | nDCG@5 | 95% CI |
|---|---|---|
| ultimate | 1.000 | [1.000, 1.000] |
| exa-lite | 0.650 | [0.640, 0.660] |
| exa | 0.645 | [0.635, 0.655] |
| perplexity | 0.640 | [0.629, 0.650] |
| perplexity-fast | 0.636 | [0.625, 0.646] |
| brave-llmcontext | 0.625 | [0.615, 0.634] |
| Keenable | 0.618 | [0.608, 0.628] |
| deepseek | 0.613 | [0.602, 0.624] |
| parallel | 0.575 | [0.566, 0.585] |
| brave | 0.560 | [0.549, 0.570] |
| linkup | 0.555 | [0.545, 0.565] |
| you | 0.550 | [0.540, 0.561] |
| octen | 0.518 | [0.504, 0.531] |
| context.dev | 0.512 | [0.502, 0.523] |
| claude-search | 0.458 | [0.444, 0.472] |
| parallel-turbo | 0.418 | [0.405, 0.432] |
| chatgpt-search | 0.356 | [0.345, 0.368] |
| bing/searchapi | 0.318 | [0.309, 0.326] |
| kagi | 0.317 | [0.304, 0.329] |
| tavily | 0.296 | [0.285, 0.307] |
| firecrawl | 0.288 | [0.279, 0.297] |
| google/serper | 0.261 | [0.252, 0.269] |
| tinyfish | 0.258 | [0.249, 0.266] |
| jina | 0.206 | [0.198, 0.214] |
| engine | recall@5 | 95% CI | filings | filingdoc |
|---|---|---|---|---|
| ultimate | 0.992 | [0.985, 0.997] | 0.994 | 0.972 |
| parallel | 0.921 | [0.900, 0.941] | 0.899 | 0.752 |
| perplexity | 0.909 | [0.886, 0.931] | 0.916 | 0.706 |
| exa-lite | 0.906 | [0.883, 0.927] | 0.929 | 0.624 |
| exa | 0.904 | [0.881, 0.926] | 0.899 | 0.661 |
| perplexity-fast | 0.896 | [0.872, 0.918] | 0.855 | 0.679 |
| parallel-turbo | 0.889 | [0.857, 0.912] | 0.833 | 0.725 |
| Keenable | 0.879 | [0.854, 0.903] | 0.821 | 0.560 |
| brave-llmcontext | 0.861 | [0.834, 0.887] | 0.833 | 0.459 |
| tavily | 0.825 | [0.795, 0.853] | 0.780 | 0.312 |
| linkup | 0.807 | [0.776, 0.837] | 0.667 | 0.413 |
| deepseek | 0.806 | [0.773, 0.836] | 0.875 | 0.321 |
| chatgpt-search | 0.804 | [0.772, 0.835] | 0.696 | 0.541 |
| firecrawl | 0.803 | [0.769, 0.834] | 0.599 | 0.500 |
| context.dev | 0.768 | [0.735, 0.800] | 0.488 | 0.486 |
| brave | 0.762 | [0.729, 0.795] | 0.482 | 0.413 |
| you | 0.754 | [0.720, 0.787] | 0.452 | 0.394 |
| kagi | 0.746 | [0.688, 0.801] | 0.400 | 0.472 |
| claude-search | 0.725 | [0.675, 0.773] | 0.400 | 0.611 |
| octen | 0.711 | [0.675, 0.746] | 0.560 | 0.165 |
| google/serper | 0.706 | [0.669, 0.741] | 0.274 | 0.450 |
| bing/searchapi | 0.703 | [0.667, 0.738] | 0.262 | 0.486 |
| tinyfish | 0.697 | [0.660, 0.733] | 0.292 | 0.404 |
| jina | 0.674 | [0.635, 0.713] | 0.333 | 0.431 |
| engine | nDCG@5 | 95% CI |
|---|---|---|
| ultimate | 1.000 | [1.000, 1.000] |
| Keenable | 0.508 | [0.475, 0.540] |
| exa-lite | 0.495 | [0.466, 0.526] |
| exa | 0.489 | [0.463, 0.516] |
| perplexity | 0.484 | [0.456, 0.512] |
| perplexity-fast | 0.469 | [0.441, 0.499] |
| parallel | 0.443 | [0.417, 0.473] |
| brave-llmcontext | 0.442 | [0.415, 0.470] |
| linkup | 0.401 | [0.375, 0.427] |
| parallel-turbo | 0.376 | [0.350, 0.402] |
| deepseek | 0.376 | [0.348, 0.402] |
| chatgpt-search | 0.375 | [0.348, 0.410] |
| brave | 0.360 | [0.336, 0.384] |
| you | 0.348 | [0.317, 0.379] |
| context.dev | 0.340 | [0.312, 0.374] |
| octen | 0.323 | [0.287, 0.354] |
| claude-search | 0.301 | [0.272, 0.331] |
| tavily | 0.283 | [0.261, 0.308] |
| kagi | 0.274 | [0.246, 0.300] |
| firecrawl | 0.267 | [0.243, 0.295] |
| google/serper | 0.253 | [0.227, 0.279] |
| tinyfish | 0.240 | [0.220, 0.261] |
| bing/searchapi | 0.196 | [0.174, 0.219] |
| jina | 0.163 | [0.145, 0.180] |
| engine | recall@5 | 95% CI | caselaw | code |
|---|---|---|---|---|
| ultimate | 0.982 | [0.971, 0.991] | 1.000 | 0.964 |
| perplexity-fast | 0.893 | [0.859, 0.919] | 0.978 | 0.797 |
| perplexity | 0.869 | [0.828, 0.905] | 0.967 | 0.757 |
| parallel | 0.853 | [0.798, 0.885] | 0.992 | 0.711 |
| Keenable | 0.831 | [0.782, 0.866] | 0.967 | 0.692 |
| exa | 0.824 | [0.782, 0.855] | 0.932 | 0.714 |
| exa-lite | 0.809 | [0.768, 0.837] | 0.938 | 0.678 |
| parallel-turbo | 0.809 | [0.762, 0.839] | 0.962 | 0.653 |
| brave-llmcontext | 0.799 | [0.747, 0.831] | 0.948 | 0.647 |
| brave | 0.773 | [0.727, 0.808] | 0.905 | 0.639 |
| you | 0.768 | [0.723, 0.800] | 0.927 | 0.606 |
| deepseek | 0.739 | [0.707, 0.771] | 0.897 | 0.578 |
| chatgpt-search | 0.735 | [0.703, 0.767] | 0.788 | 0.681 |
| claude-search | 0.709 | [0.665, 0.754] | 0.901 | 0.484 |
| linkup | 0.709 | [0.652, 0.746] | 0.878 | 0.536 |
| bing/searchapi | 0.707 | [0.674, 0.740] | 0.826 | 0.586 |
| context.dev | 0.677 | [0.632, 0.712] | 0.807 | 0.544 |
| firecrawl | 0.631 | [0.580, 0.679] | 0.771 | 0.478 |
| google/serper | 0.603 | [0.563, 0.641] | 0.701 | 0.503 |
| kagi | 0.589 | [0.530, 0.663] | 0.629 | 0.533 |
| octen | 0.570 | [0.534, 0.606] | 0.595 | 0.544 |
| tinyfish | 0.558 | [0.501, 0.606] | 0.679 | 0.433 |
| jina | 0.456 | [0.419, 0.493] | 0.500 | 0.411 |
| tavily | 0.433 | [0.377, 0.477] | 0.644 | 0.217 |
| engine | $ / 1k queries | overall |
|---|---|---|
| perplexity | $5.00 | 75% |
| perplexity-fast | $1.00 | 73% |
| Keenable | $1.00 | 72% |
| exa | $7.00 | 70% |
| exa-lite | $4.00 | 69% |
| parallel | $5.00 | 69% |
| brave-llmcontext | $5.00 | 67% |
| context.dev | $0.50 | 62% |
| brave | $5.00 | 61% |
| linkup | $5.00 | 60% |
| you | $5.00 | 59% |
| parallel-turbo | $1.00 | 59% |
| deepseek | $0.75 | 58% |
| claude-search | $10.00 | 55% |
| chatgpt-search | $10.00 | 53% |
| kagi | $12.00 | 50% |
| firecrawl | $1.20 | 50% |
| octen | $1.00 | 49% |
| bing/searchapi | $1.00 | 48% |
| google/serper | $0.30 | 47% |
| tinyfish | $0.00 | 45% |
| tavily | $5.00 | 44% |
| jina | $3.40 | 40% |
| engine | $ / 1k queries | plan |
|---|---|---|
| tinyfish | $0.00 | free |
| google/serper | $0.30 | Serper Ultimate — $3,750 / 12.5M credits |
| context.dev | $0.50 | Scale — $499/mo / 1M credits, 1 credit per 10 results |
| deepseek | $0.75 | pay-as-you-go — $0.75 / 1k requests |
| bing/searchapi | $1.00 | SearchApi.io Octo 5M — $5,000/mo |
| Keenable | $1.00 | Dedicated capacity — at 100+ RPS |
| octen | $1.00 | pay-as-you-go |
| parallel-turbo | $1.00 | pay-as-you-go |
| perplexity-fast | $1.00 | fast search — $1 / 1k requests |
| firecrawl | $1.20 | Scale — $599/mo / 1M credits, 2 credits per search |
| jina | $3.40 | $0.05 / 1M tokens, ≈68k tokens per search |
| exa-lite | $4.00 | pay-as-you-go |
| brave | $5.00 | flat rate |
| brave-llmcontext | $5.00 | flat rate |
| linkup | $5.00 | pay-as-you-go — standard depth, searchResults |
| parallel | $5.00 | pay-as-you-go |
| perplexity | $5.00 | flat rate |
| tavily | $5.00 | Growth — $500/mo / 100k credits |
| you | $5.00 | Web Search API plan |
| exa | $7.00 | pay-as-you-go |
| chatgpt-search | $10.00 | web_search tool — $10 / 1k calls, plus tokens |
| claude-search | $10.00 | web search tool — $10 / 1k searches, plus tokens |
| kagi | $12.00 | pay-as-you-go |
Agents wait on each search call, so latency adds directly to task time.
| engine | p50 | p95 | errors |
|---|---|---|---|
| Keenable | 157 ms | 307 ms | 0.0% |
| perplexity-fast | 181 ms | 260 ms | 1.6% |
| octen | 197 ms | 387 ms | 0.0% |
| parallel-turbo | 342 ms | 560 ms | 0.0% |
| exa-lite | 447 ms | 676 ms | 0.2% |
| you | 553 ms | 739 ms | 0.0% |
| brave | 569 ms | 791 ms | 0.0% |
| deepseek | 586 ms | 728 ms | 0.0% |
| firecrawl | 773 ms | 2.0 s | 4.6% |
| perplexity | 984 ms | 1.6 s | 1.6% |
| brave-llmcontext | 1.2 s | 2.3 s | 0.0% |
| kagi | 1.3 s | 2.1 s | 55.6% |
| linkup | 1.3 s | 1.9 s | 0.0% |
| google/serper | 1.4 s | 3.1 s | 0.0% |
| exa | 1.5 s | 3.4 s | 0.2% |
| tavily | 1.6 s | 3.0 s | 0.0% |
| context.dev | 1.6 s | 3.5 s | 0.0% |
| tinyfish | 1.8 s | 4.3 s | 2.6% |
| bing/searchapi | 2.1 s | 7.6 s | 0.3% |
| parallel | 2.4 s | 3.9 s | 0.0% |
| jina | 17 s | 25 s | 3.8% |
| claude-search | 35 s | 50 s | 50.4% |
| chatgpt-search | 43 s | 54 s | 1.0% |
Repeated overlap on junk or near-identical lists suggests a shared upstream index (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 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.
We pin everything below in keenableai/needle; you can compute each number on this page again from the archived artifacts (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).
| vertical | slice | query construction |
|---|---|---|
| Scholar | title | the paper's title, lowercased with LaTeX and punctuation stripped; plain known-item lookup, answerable from metadata |
| Scholar | 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 |
| Scholar | 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 |
| Scholar | 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 |
| News | 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 |
| Finance | 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 |
| Finance | 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 |
| Finance | 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:) |
| AgenticRare | 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 |
| Legal | 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 |
| Legal | 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 (Appendix F). Each engine has one request in flight at a time, two on Scholar, so the latency percentiles are comparable across engines. On News, AgenticRare and Scholar the runner queries all engines in parallel; on Finance and Legal it queries them one after another. We do not time each engine's connection warm-up, the requests that open its first connections in a run: they pay one-time connection setup and, in a parallel run, every engine starting at once. The latency figures therefore show performance on repeated requests. A later request that has to reconnect is timed, and reports count it in latency.reconnects. 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).
A failed search scores zero when the engine's side caused it (timeout, server error, content filter, a rejected query); an agent gets nothing back either. We remove a failed search from the mean only when our own account or plan caused it (HTTP 401, 402, 403 or 429, a plan limit, or a message about our credits, quota, billing or API key). We pace each engine to its rate limit and retry a 429 with exponential backoff. We also remove failed judgements. Reports list search_errors, split into account_errors and service_errors, and judge_errors.
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.
| metric | benches | definition |
|---|---|---|
| recall@10, MRR@10 | Scholar | known-item: the gold paper identified in the top 10 by arXiv ID / DOI / PMID match. |
| nDCG@5 | News, 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 penalty | News, 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@5 | Finance | a query is a hit when the gold answer appears in a top-5 title or snippet (deterministic matcher: a 0.1% band for money, with bare table figures in thousands or millions and parenthesized losses; an LLM-judge backstop for paraphrased non-money answers). MRR is the mean reciprocal rank of the first hit. |
| recall@5, MRR@5 | Legal | 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-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.
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.
| engine | API | pinned request |
|---|---|---|
| Keenable | Keenable POST /v1/search | mode=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/serper | Serper.dev (Google SERP) POST /search | q = 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/searchapi | SearchApi.io GET /search | engine=bing, q = query verbatim (operators kept as text), num=min(k,50); knowledge-graph and answer-box fold in |
| brave | Brave GET /web/search | q = 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-llmcontext | Brave POST /llm/context | q = 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 |
| exa | Exa POST /search | type=auto, numResults=k, contents.highlights.maxCharacters=2000; site: → includeDomains, after:/before: → startPublishedDate/endPublishedDate (full-day UTC bounds) |
| exa-lite | Exa POST /search | same as exa but type=lite |
| parallel | Parallel POST /v1/search | mode=advanced, 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-turbo | Parallel POST /v1/search | same as parallel but mode=turbo |
| perplexity | Perplexity POST /search | max_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-fast | Perplexity POST /search | same as perplexity but search_type=fast (perplexity leaves the field unset, so it takes the API default web) |
| tavily | Tavily POST /search | search_depth=basic, max_results=min(k,20); site: → include_domains, after:/before: → start_date/end_date (ISO dates) |
| linkup | Linkup POST /v1/search | depth=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 |
| tinyfish | TinyFish GET api.search.tinyfish.ai | query = 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) |
| you | You.com POST /v1/search | count=min(k,100); site: → include_domains, after:/before: → freshness=YYYY-MM-DDtoYYYY-MM-DD; web and news sections merge, deduplicated by URL |
| firecrawl | Firecrawl POST /v2/search | limit=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 |
| kagi | Kagi POST /api/v1/search | query = 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 |
| octen | Octen POST /search | query = 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 |
| jina | Jina GET s.jina.ai | q = 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.dev | Context.dev POST /v1/web/search | query = 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 |
| deepseek | DeepSeek POST /v1/tools/search | query = 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-search | Anthropic POST /v1/messages | model=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-search | OpenAI POST /v1/responses | model=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 |
| 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/needle-results, runs/<run-id>/ |
| query files | the exact queries each run issued, including gold answers (fresh.jsonl, gold.jsonl, scholar.jsonl, legal.jsonl, agentic_rare.jsonl) | same run folder |
| dashboard aggregates | history.jsonl, overlap.jsonl, uniqueness.jsonl, independence.json, 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 |
@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}
}