I was reading Jina AI’s release notes for jina-reranker-v3.5, a listwise reranker: a model that scores a whole list of search candidates in one pass instead of scoring them one at a time. Two limits stopped me. The model handles at most 64 candidate documents per pass. Total context, query plus every candidate combined, caps out at 131,072 tokens.
Sixty-four sounds small the moment you picture a real pipeline. A first-stage vector search commonly returns 200 candidates. That’s already 3x over the per-pass limit, before token count even matters. The obvious fix is windowing. Split the candidate list into chunks that fit. Rerank each chunk on its own, then merge the results back into one ranking. I wanted real, measured numbers on what that merge step costs you in ranking quality.
What you’ll build
A small, self-contained Python harness that reranks a pool of candidates two ways. One pass scores everything at once, the happy path when the pool fits under the cap. The other pass splits the same candidates into fixed-size windows, the fallback for when it doesn’t. Both use the same underlying scorer, so any gap in ranking quality comes from the windowing step itself.
Stack
- Python 3 with NumPy for the scoring math
- tiktoken (
pip install tiktoken) for real token counts on my test text, using OpenAI’scl100k_basetokenizer, the BPE vocabulary behind GPT-3.5/4-class models, as a stand-in — jina-reranker-v3.5 is built on Qwen3, whose exact tokenizer would count slightly differently, so treat token counts here as close estimates - A synthetic, rule-labeled candidate pool I wrote myself (more on that below)
- No GPU and no hosted reranker API call. This is an offline simulation of the windowing mechanics, not a benchmark of jina-reranker-v3.5 itself.
I want to be direct about that last point. I did not run the actual jina-reranker-v3.5 model. I built a stand-in scorer. It uses TF-IDF cosine similarity as a base relevance score. That’s a classic way to measure how many important words a query and a passage share. Then it applies a softmax layer, scoped to whatever window a candidate lands in. Softmax turns a group of raw scores into probabilities that sum to 1. That mirrors the “listwise, softmax-normalized” scoring jina-reranker-v3.5’s own writeup describes. This isolates the windowing mechanic cleanly. It’s a controlled comparison using my own scorer, separate from Jina’s published benchmark numbers.
How windowed reranking works

A listwise reranker’s score for one candidate depends on what else is in its list. That’s the whole point of listwise scoring. It can say “this one is best among these ten,” not just “this one scores 0.73 on its own.” Window the list, and you’ve quietly changed the question. Now the model says “this one is best among these ten neighbors,” and those neighbors are different in every window. A score of 0.4 among weak competitors means something different than a 0.4 among strong ones. Merging by raw score treats the two as if they meant the same thing.
Building it
I built a 120-document candidate pool for one test query, about client-side caching that cuts database load in a multi-tenant SaaS app. The pool has three groups:
- 10 passages genuinely relevant to the query
- 20 passages partially relevant (they touch caching or database load, but miss the multi-tenant angle)
- 90 passages that are off-topic
I labeled relevance myself, by rule, based on which group a passage came from. Relevance is binary: 1 for the ten genuinely relevant passages, 0 for everything else, including the twenty partially relevant ones. It’s a synthetic, self-authored eval set. Treat the numbers below as illustrative rather than authoritative.
def listwise_softmax(ids, scores_by_id, temperature=8.0):
xs = np.array([scores_by_id[i] for i in ids]) * temperature
xs = xs - xs.max()
e = np.exp(xs)
return {i: float(p) for i, p in zip(ids, e / e.sum())}
# Full-context: one softmax over all N candidates
full_scores = listwise_softmax(all_ids, base_scores)
# Windowed: softmax inside each window, then just concatenate
def windowed_scores(ids, scores_by_id, window_size):
out = {}
for start in range(0, len(ids), window_size):
chunk = ids[start:start + window_size]
out.update(listwise_softmax(chunk, scores_by_id))
return out
I fixed the softmax temperature at 8.0 for both passes, the same value in both, so the comparison is fair. That’s one specific setting, and I did not test how sensitive the result is to it.
I ran the same 120 candidates through both functions. The window size was 25, giving 5 windows: four full windows of 25, plus one final window of 20. Then I ranked each output by score and measured nDCG@10:
def ndcg_at_k(ranked_ids, relevance, k=10):
dcg = sum((2**relevance[d] - 1) / math.log2(i + 2)
for i, d in enumerate(ranked_ids[:k]))
ideal = sorted(relevance.values(), reverse=True)[:k]
idcg = sum((2**r - 1) / math.log2(i + 2) for i, r in enumerate(ideal))
return dcg / idcg if idcg > 0 else 0.0
nDCG@10 is a standard ranking-quality score. It rewards a ranking for putting relevant results near the top, and it punishes a ranking more for getting position 1 wrong than position 10.
What broke
The gap showed up in one traceable case. Candidate A6, a genuinely relevant passage, ranked #10 in the full-context pass, just inside the top 10. Under windowing, it dropped to #13, pushed out by a less relevant candidate.
I checked why. A6 landed in the final window, the one with 20 candidates, alongside four other genuinely relevant passages, five relevant candidates total in that one window. That window’s softmax had to split its score mass five ways among strong competitors, so every one of those five, A6 included, got compressed. A mediocre candidate sitting in a different, weaker window faced almost no real competition there, so its softmax score inflated relative to its actual quality. Nothing crashed. Nothing errored. The ranking just quietly got worse for the candidates that most needed to reach the top.
Results
Real numbers from this run, all computed by the code above:
- nDCG@10, full context: 0.7788. Windowed: 0.6844. That’s a 12.1% relative drop from windowing alone, same scorer both times.
- One relevant candidate dropped out of the top 10. One less relevant candidate took its place.
- Token math against the model’s 131,072-token context cap: my 120 candidates averaged 19.64 tokens each, 2,357 tokens total, plus 18 for the query itself, 2,375 tokens all together. At an average of 19.64 tokens per candidate, you’d fit roughly 6,672 candidates before the token cap bites. The 64-document cap bites first, at under 1% of that candidate count (0.96%, to be exact).
- Speed, measured at N=1,200 candidates. I used a pairwise cost pass as a stand-in for attention-style computation, the same all-pairs comparison a transformer’s attention layer runs. That’s why reranking cost grows with the square of list length. The full pass took 44.838ms. The windowed pass, same window size of 25, took 3.268ms. That’s a 13.72x speedup, and it’s exactly why windowing is tempting. It’s fast. The accuracy cost stays invisible until you measure it.
- At $0.02 per million tokens, Jina’s published reranker API rate, reranking those 2,375 tokens would cost about $0.0000475. Cost was never the constraint in this test. Latency and the two caps are.
Takeaways
Windowing a listwise reranker costs you accuracy, even though nothing in your logs will tell you that. The 12.1% nDCG drop I measured came from one structural problem. Candidates competing against strong neighbors score lower than equally good candidates competing against weak ones. Raw scores from different windows don’t share a scale, so merging by score alone mixes two different measurements. Most vector search already sorts candidates roughly by relevance. If your upstream retriever does that, your best candidates are more likely to cluster into the same window. That makes this problem worse.
What’s next
Two fixes are worth testing. One is overlapping windows, with a second pass that re-scores the union of each window’s top candidates together. The other is a calibration step that rescales each window’s scores by how competitive that window was, before merging. Both add latency back.
If you’ve hit this on a real pipeline, tell me the candidate count and window size that broke it for you in the comments below.
Source: jina-reranker-v3.5: Faster Listwise Reranking with Hybrid Attention and Self-Distillation, Jina AI, Hugging Face Blog, published August 5, 2026. Model spec (64-document, 131,072-token caps) from jina.ai/models/jina-reranker-v3.5.
Leave a Reply