Candidate generation benchmark¶
When is it worth giving Cascade several generators instead of one? A ranker can only reorder
what the first stage retrieved, so this measures the first stage alone: how many of the items a
user goes on to like are among the N candidates, for single generators and for merges of them,
at the same budget of N distinct items. Each merged set is compared with the best of its own
members, which is the alternative a merge has to beat. No ranker is trained, so every number is
deterministic.
uv run python benchmarks/run.py run candidates # measure it, re-render the tables below
MovieLens 100K¶
MovieLens 100K, ColdStartSplit: 10% of users held out whole, the latest 20% of every other user's ratings held out, warm held-out users only, every generator at its default hyper-parameters. Regenerate with python benchmarks/run.py run candidates --dataset movielens-100k-cold.
candidate recall@N is measured at each budget N of distinct items retrieved per user: the share of a user's held-out items among the candidates, averaged over users, which is the most any ranker could recall from them. A merged set's gain over the best of its own members at the same N is in brackets.
| Generators | candidate recall@10 | candidate recall@20 | candidate recall@50 | candidate recall@100 | candidate recall@200 |
|---|---|---|---|---|---|
| BM25 | 0.101 | 0.171 | 0.305 | 0.425 | 0.536 |
| EASE | 0.108 | 0.184 | 0.326 | 0.460 | 0.604 |
| ItemKNN | 0.103 | 0.181 | 0.341 | 0.507 | 0.683 |
| RP3Beta | 0.091 | 0.153 | 0.300 | 0.453 | 0.625 |
| MostPopular | 0.060 | 0.098 | 0.195 | 0.316 | 0.476 |
| BM25+EASE | 0.107 (-0.001) | 0.184 (+0.000) | 0.335 (+0.010) | 0.472 (+0.012) | 0.626 (+0.022) |
| EASE+RP3Beta | 0.103 (-0.006) | 0.176 (-0.008) | 0.323 (-0.003) | 0.465 (+0.005) | 0.637 (+0.012) |
| EASE+ItemKNN | 0.108 (-0.000) | 0.189 (+0.006) | 0.344 (+0.002) | 0.498 (-0.009) | 0.670 (-0.013) |
| BM25+EASE+ItemKNN | 0.109 (+0.001) | 0.185 (+0.002) | 0.341 (+0.000) | 0.489 (-0.018) | 0.661 (-0.022) |
| EASE+MostPopular | 0.093 (-0.015) | 0.156 (-0.028) | 0.296 (-0.030) | 0.440 (-0.020) | 0.612 (+0.009) |
| BM25+EASE RRF | 0.108 (-0.001) | 0.186 (+0.002) | 0.335 (+0.010) | 0.473 (+0.013) | 0.627 (+0.023) |
| EASE+RP3Beta RRF | 0.102 (-0.007) | 0.175 (-0.009) | 0.323 (-0.003) | 0.470 (+0.010) | 0.639 (+0.014) |
| EASE+ItemKNN RRF | 0.110 (+0.002) | 0.192 (+0.008) | 0.344 (+0.002) | 0.504 (-0.003) | 0.673 (-0.010) |
| BM25+EASE+ItemKNN RRF | 0.109 (+0.000) | 0.189 (+0.005) | 0.346 (+0.005) | 0.502 (-0.004) | 0.673 (-0.010) |
| EASE+MostPopular RRF | 0.093 (-0.015) | 0.156 (-0.028) | 0.294 (-0.031) | 0.440 (-0.020) | 0.613 (+0.009) |
Measured on Apple M4 Pro (12 usable cores), macOS-27.0-arm64-arm-64bit-Mach-O, CPython 3.14.3, numpy 2.5.3, skrecsys 0.5.0, _core built in release mode. The quality table above is deterministic and portable; the timings below are not comparable across machines or builds.
median latency@N is the median time to retrieve one user's N candidates, the way Cascade.recommend does before any feature or ranker runs; a merged set's time includes the predict calls that fill in the score of every generator that did not retrieve a candidate. Sampled until 2s or 300 requests per cell, after 10 untimed ones.
| Generators | median latency@10 | median latency@20 | median latency@50 | median latency@100 | median latency@200 |
|---|---|---|---|---|---|
| BM25 | 61 us | 63 us | 62 us | 63 us | 63 us |
| EASE | 73 us | 72 us | 78 us | 78 us | 89 us |
| ItemKNN | 62 us | 63 us | 66 us | 69 us | 78 us |
| RP3Beta | 62 us | 63 us | 66 us | 68 us | 76 us |
| MostPopular | 69 us | 70 us | 71 us | 75 us | 81 us |
| BM25+EASE | 369 us | 366 us | 378 us | 401 us | 453 us |
| EASE+RP3Beta | 359 us | 371 us | 398 us | 410 us | 464 us |
| EASE+ItemKNN | 362 us | 366 us | 385 us | 406 us | 455 us |
| BM25+EASE+ItemKNN | 541 us | 550 us | 577 us | 606 us | 683 us |
| EASE+MostPopular | 315 us | 312 us | 330 us | 352 us | 394 us |
| BM25+EASE RRF | 327 us | 326 us | 329 us | 342 us | 368 us |
| EASE+RP3Beta RRF | 330 us | 325 us | 337 us | 350 us | 399 us |
| EASE+ItemKNN RRF | 334 us | 343 us | 346 us | 359 us | 379 us |
| BM25+EASE+ItemKNN RRF | 446 us | 447 us | 467 us | 477 us | 513 us |
| EASE+MostPopular RRF | 336 us | 338 us | 353 us | 365 us | 399 us |
MovieLens 1M¶
MovieLens 1M (ml-1m-l200), leave-one-out split, warm held-out users only, every generator at its default hyper-parameters. Regenerate with python benchmarks/run.py run candidates --dataset movielens-1m.
candidate recall@N is measured at each budget N of distinct items retrieved per user: the share of a user's held-out items among the candidates, averaged over users, which is the most any ranker could recall from them. A merged set's gain over the best of its own members at the same N is in brackets.
| Generators | candidate recall@10 | candidate recall@20 | candidate recall@50 | candidate recall@100 | candidate recall@200 |
|---|---|---|---|---|---|
| BM25 | 0.064 | 0.108 | 0.210 | 0.321 | 0.453 |
| EASE | 0.089 | 0.144 | 0.252 | 0.367 | 0.492 |
| ItemKNN | 0.071 | 0.122 | 0.231 | 0.353 | 0.511 |
| RP3Beta | 0.064 | 0.111 | 0.221 | 0.343 | 0.489 |
| MostPopular | 0.031 | 0.056 | 0.125 | 0.204 | 0.316 |
| BM25+EASE | 0.080 (-0.009) | 0.134 (-0.010) | 0.238 (-0.014) | 0.362 (-0.006) | 0.503 (+0.012) |
| EASE+RP3Beta | 0.081 (-0.008) | 0.133 (-0.011) | 0.245 (-0.007) | 0.367 (+0.000) | 0.510 (+0.018) |
| EASE+ItemKNN | 0.082 (-0.007) | 0.138 (-0.006) | 0.255 (+0.003) | 0.376 (+0.009) | 0.519 (+0.008) |
| BM25+EASE+ItemKNN | 0.081 (-0.009) | 0.132 (-0.012) | 0.246 (-0.006) | 0.369 (+0.001) | 0.517 (+0.006) |
| EASE+MostPopular | 0.063 (-0.026) | 0.114 (-0.030) | 0.213 (-0.039) | 0.328 (-0.040) | 0.472 (-0.020) |
| BM25+EASE RRF | 0.080 (-0.009) | 0.134 (-0.011) | 0.240 (-0.012) | 0.365 (-0.002) | 0.507 (+0.015) |
| EASE+RP3Beta RRF | 0.079 (-0.010) | 0.132 (-0.012) | 0.247 (-0.005) | 0.371 (+0.004) | 0.515 (+0.023) |
| EASE+ItemKNN RRF | 0.082 (-0.007) | 0.140 (-0.004) | 0.254 (+0.002) | 0.378 (+0.011) | 0.523 (+0.012) |
| BM25+EASE+ItemKNN RRF | 0.079 (-0.010) | 0.133 (-0.011) | 0.250 (-0.002) | 0.375 (+0.008) | 0.523 (+0.012) |
| EASE+MostPopular RRF | 0.062 (-0.027) | 0.113 (-0.031) | 0.212 (-0.040) | 0.328 (-0.039) | 0.473 (-0.019) |
Measured on Apple M4 Pro (12 usable cores), macOS-27.0-arm64-arm-64bit-Mach-O, CPython 3.14.3, numpy 2.5.3, skrecsys 0.5.0, _core built in release mode. The quality table above is deterministic and portable; the timings below are not comparable across machines or builds.
median latency@N is the median time to retrieve one user's N candidates, the way Cascade.recommend does before any feature or ranker runs; a merged set's time includes the predict calls that fill in the score of every generator that did not retrieve a candidate. Sampled until 2s or 300 requests per cell, after 10 untimed ones.
| Generators | median latency@10 | median latency@20 | median latency@50 | median latency@100 | median latency@200 |
|---|---|---|---|---|---|
| BM25 | 67 us | 66 us | 70 us | 73 us | 72 us |
| EASE | 128 us | 129 us | 134 us | 134 us | 157 us |
| ItemKNN | 72 us | 73 us | 76 us | 83 us | 93 us |
| RP3Beta | 69 us | 71 us | 74 us | 80 us | 94 us |
| MostPopular | 77 us | 78 us | 79 us | 87 us | 103 us |
| BM25+EASE | 439 us | 439 us | 459 us | 501 us | 566 us |
| EASE+RP3Beta | 444 us | 457 us | 482 us | 522 us | 602 us |
| EASE+ItemKNN | 444 us | 465 us | 487 us | 524 us | 610 us |
| BM25+EASE+ItemKNN | 648 us | 677 us | 700 us | 764 us | 862 us |
| EASE+MostPopular | 402 us | 423 us | 420 us | 458 us | 548 us |
| BM25+EASE RRF | 397 us | 394 us | 410 us | 426 us | 460 us |
| EASE+RP3Beta RRF | 436 us | 431 us | 453 us | 471 us | 524 us |
| EASE+ItemKNN RRF | 435 us | 427 us | 453 us | 457 us | 530 us |
| BM25+EASE+ItemKNN RRF | 567 us | 599 us | 697 us | 626 us | 683 us |
| EASE+MostPopular RRF | 420 us | 423 us | 464 us | 494 us | 506 us |
When a merge pays¶
A merge splits a fixed budget between its members. Round-robin gives each generator about the same number of slots, so the merge gains only when the items a second generator brings are worth more than the items of the first that they push out. On these two datasets, that happens under narrow conditions:
- Only at large budgets. At
Nof 50 and below, a merge moves recall by about 0.01 either way. On MovieLens 1M, every merge loses atN=10andN=20: by 0.006 to 0.012, and by up to 0.030 with popularity. Near the top of the list the generators largely agree, so the slots a second generator takes are mostly slots the first would have filled with hits. The gains appear atN=100andN=200, where each generator's tail reaches items the other misses. They are modest: the best is BM25+EASE atN=200on MovieLens 100K, 0.604 to 0.626, about 4% relative. - Only between generators of similar strength. EASE+MostPopular loses up to 0.040 of recall,
because popularity takes half the budget and recalls far less with it. Its one gain is +0.009
at
N=200on MovieLens 100K. The same holds for any weak partner. - More members is not better. BM25+EASE+ItemKNN is never more than 0.001 ahead of the best
pair, and on MovieLens 100K it trails ItemKNN alone by 0.018 at
N=100and 0.022 atN=200. - Compare with the best single generator at your budget, not only with the merge's members.
The strongest generator changes with
N: EASE leads at small budgets and ItemKNN at large ones. On MovieLens 100K, no merge beats ItemKNN alone atN=100orN=200. On MovieLens 1M, only the merges holding both EASE and ItemKNN beat every single generator atN=100andN=200. The better of them, EASE+ItemKNN, reaches 0.376 against EASE's 0.367 atN=100, and 0.519 against ItemKNN's 0.511 atN=200.
The merge also costs time. A request asks every member for the full budget, and then each member
scores the candidates it did not retrieve with predict, so the ranker sees every generator's
opinion of every candidate. That scoring is about half of a merged request. A two-generator
merge costs 0.3–0.6 ms against 60–160 µs for one generator, and a third member adds another
0.2 ms or so.
Reciprocal rank fusion merges slightly better, and usually faster, than round-robin. The rows
marked RRF merge the same members with ReciprocalRankFusion, which asks every member for the same
N items and keeps the N with the highest fused score, so items several members rank high come
first. Against round-robin it wins 29 of the 50 cells, ties 11 and loses 10, none by more than
0.002. It gains most where round-robin does worst: for BM25+EASE+ItemKNN on MovieLens 100K it cuts
the deficit to ItemKNN alone from 0.018 to 0.004 at N=100 and from 0.022 to 0.010 at N=200. A
fused request fills no missing scores with predict, since the fused score is the only one it
keeps, so it is up to a quarter cheaper, and more so for more members. The exception is
EASE+MostPopular, where filling in popularity costs almost nothing and fusion runs up to 10% slower.
Fusion does not rescue a weak partner either: EASE+MostPopular loses as much recall either way.
In short: merge generators when the ranker reorders a wide list (N of 100 or more) and the members
are comparably strong but disagree, and check the merge against the best single generator at the
same N. For a narrow list, spend the whole budget on the best single generator. Candidate
recall is the ceiling, not the result. Whether a ranker turns a higher ceiling into better top-10
lists is what the reranking benchmark measures. A merge can also be
worth its cost for a reason this benchmark does not measure: one Cascade whose list includes
MostPopularRecommender also serves cold users (see composing recommenders).