Replace the reranker with a multilingual cross-encoder - #2312
Merged
Conversation
jina-reranker-v1-tiny-en is an English model, and on non-English content it was not adding ranking signal: 0.4636 nDCG@10 across 240 MIRACL queries in en/es/fr/de/ru/ja, against 0.4963 for the un-reranked first-stage order. cross-encoder/mmarco-mMiniLMv2-L12-H384-v1 scores 0.7992 on the same set. The int8 export is what ships, at 119MB and half the latency of fp32 for no measurable quality cost (0.7992 against 0.7973). Two consequences of quantizing show up in the code: - Documents are scored one at a time. Dynamic quantization takes each activation tensor's scale from its own range, and pad positions are inside that range even though the attention mask keeps them out of attention, so batching let one document's score move another's by up to 1.29 and reordered 2 of 10 fixtures. It also caps the event-loop stall at 13ms instead of 50ms. - The session is CPU-only. WebGPU has no kernels for the integer matmuls and hands each one back to the CPU: 812ms against 172ms, with scores drifting by 1.15. That removes the reason the two-attempt session logic existed. MAX_SEQUENCE_LENGTH drops to 512, which is the model's own limit rather than a choice: 513 tokens fails at the position-embedding gather. truncatePairTokens no longer reads token_type_ids, since XLM-RoBERTa has a type_vocab_size of 1 and emits zeros for the whole sequence.
Only `cpu` is ever passed now, so the constant and the log line built from it were plumbing for a choice that no longer exists. The rationale moves to createSession, where the provider is. Also corrects a docs line that still described rerankerService.test.ts as covering the execution-provider fallback, which the previous commit removed.
felladrin
force-pushed
the
feat/mmarco-multilingual-reranker
branch
from
August 5, 2026 13:07
10b7196 to
1f64646
Compare
felladrin
marked this pull request as ready for review
August 5, 2026 13:10
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Replaces
jina-reranker-v1-tiny-enwith the int8 export of cross-encoder/mmarco-mMiniLMv2-L12-H384-v1.Currently the reranker is an English model, and the docs say it "ranks non-English results (Portuguese, for example) well in practice". Measured, it doesn't: on 240 queries from mteb/MIRACLReranking across English, Spanish, French, German, Russian and Japanese, it scores 0.4636 nDCG@10 while the un-reranked SearXNG-style first-stage order scores 0.4963. On non-English content it was not adding ranking signal, and part of the mechanism is the tokenizer: an English WordPiece vocabulary spends 2.17x as many tokens on Russian and about 1.4x on Spanish, German and Japanese, so it paid more compute to see worse-fragmented subwords.
Per language, nDCG@10 goes 0.774 => 0.743 (en), 0.498 => 0.842 (es), 0.351 => 0.777 (fr), 0.370 => 0.774 (de), 0.387 => 0.819 (ru), 0.402 => 0.840 (ja). English regresses slightly; every other language roughly doubles.
Why the int8 export
The fp32 export of the same model is 471MB and twice the latency for 0.7973 nDCG@10, so quantization costs nothing measurable here. That is the opposite of what
docs/reranking.mdsaid about the previous model, where theq8export did degrade ranking; the loss doesn't carry over to a model with 12 layers and a large embedding table, so I've replaced that claim rather than just deleting it.Of the four per-kernel builds in the repository, this PR uses
model_quint8_avx2.onnx. Unsigned activations sidestep the signed-int8 saturation that x64 without VNNI has to work around, and it measured no slower than the arm64 build when run on arm64.qint8_arm64andqint8_avx512are bit-identical in output and score 0.8039, marginally higher, but that margin is inside the noise of a 240-query set and doesn't buy predictable behavior on hosts whose instruction set we can't know in advance.Since the file has "avx2" in the name, I checked it on both architectures rather than assuming. Loading it and scoring the same pair gives 7.698 / -9.777 on arm64 macOS and 7.290 / -9.622 on x64 Linux (
node:22-slim): the same ordering, with the relevant document about 17 above the irrelevant one on both. So the logits do shift by around 0.4 between kernel paths, which is worth knowing, but it is nowhere near the gaps the ranking depends on.Documents are now scored one at a time
Dynamic quantization takes each activation tensor's scale from that tensor's own range at runtime. Padding rows out to a shared width puts the pad positions inside that range, and while the attention mask keeps them out of attention it cannot keep them out of the scale, so the quantization of the real tokens shifts. In batches of 10 that moved logits by up to 1.29 at ordinary snippet lengths (33-63 tokens) and reordered 2 of 10 fixtures depending only on which documents shared a batch. The fp32 export of the same model shows a difference of exactly zero, so this is a property of quantization and not of the graph.
Scoring one pair at a time removes the padding and the coupling with it. It also suits the reason batching was added in the first place: the event-loop stall drops to about 13ms instead of the 50ms a batch of 10 takes. The cost is throughput, roughly 12% more wall time for 30 documents on two threads and up to 47% more where there are cores to spare.
The session is now CPU-only
The WebGPU provider has no kernels for the integer matmuls in a quantized graph. It registers happily and then hands every one of them back to the CPU, paying a round trip each time: 812ms against 172ms for the same work, with scores drifting by up to 1.15 and reordering results. So
["webgpu", "cpu"]is out, and with it the two-attempt session logic, which existed only to survive a GPU provider failing to initialize.Since
["cpu"]is the only thing ever passed, the provider constant went with it: the rationale now lives oncreateSession, and the startup log says "Loading model on CPU" plus the architecture, which is the part that actually varies between hosts (it selects the quantized kernel).If keeping GPU acceleration matters more than the download size, the fp32 export is the trade: 471MB and 213ms per 30 documents on CPU, at 0.7973 nDCG@10.
Smaller changes that follow from the model
MAX_SEQUENCE_LENGTHdrops from 2048 to 512. That is the model's own limit, not a preference: it has 514 learned position embeddings and XLM-RoBERTa reserves two, so a 513-token pair fails outright withindices element out of data boundsat the position-embedding gather. Still more generous than what shipped before fix: truncate reranker input by tokens, not characters #2260, which cut documents to 512 characters upstream.truncatePairTokensno longer takestoken_type_ids. XLM-RoBERTa has atype_vocab_sizeof 1 and emits zeros for the whole sequence, so there is no query segment to read off it. The function is now a two-argument slice, and it produces the same output the old one did for every case except a query that fills the budget on its own, where it now keeps the trailing separator.PAD_TOKEN_IDis gone, since nothing pads anymore.PREFERRED_EXECUTION_PROVIDERSandFALLBACK_EXECUTION_PROVIDERSare gone too, with no constant replacing them. There was never an environment variable for provider selection, so there's nothing to remove on that side.Known limitations
check-dockerpasses, though that doesn't verify much here: the Playwright smoke test passes whether or not results come back, and it never asserts that the reranker became ready. What it does confirm is that the swap doesn't break container startup.rankSearchResults.tsis documented as calibrated against the reranker's raw logit scale, and this model separates relevant from irrelevant by gaps of 5.7-11.2 where the old one used 0.98-2.59. I checked the filter rather than retuning it: across the 240 queries it keeps a median 18 of 30 results and retains 98.3% of the relevant ones, with the percentage fallback triggering once. It looks healthy on the new scale, so this PR leaveskStandardDeviationFactoralone.How to test
npm test(349 tests, includes the rewrittenserver/rerankerService.test.ts).npx vitest run --config vitest.integration.config.ts. This downloads ~136MB on first run and asserts that every relevant result outranks every irrelevant one on the four existing fixtures. All four pass, at score gaps of 5.7-11.2.npm run lint.npm run dev, then search for something non-English (configurar nginx como proxy reverso,cómo revertir el último commit en git) and check the ordering. First startup downloads the model, so give it a moment before the first search.This branch is rebased on top of #2313, which unblocked the
Lint Dockerfilestep. Before that, the step short-circuited the job and CI never reachedCheck formattingorRun testson any PR.The reranking measurements come from a harness outside the repository: 240 MIRACL queries with a 30-candidate window per query, plus 10 web-snippet fixtures whose first four are copied verbatim from the integration test, so the harness could be checked against labels that were already in the repo before comparing models. Happy to push that harness somewhere if it's worth keeping.