Replace llama.cpp with ONNX Runtime for reranking - #2235
Merged
Conversation
6 tasks
The reranker spawned `llama-server`, which meant compiling llama.cpp from source in a dedicated Docker stage on every rebuild in a new environment. That stage took 168s on its own. The same model now runs in-process through `onnxruntime-node`, whose binaries ship prebuilt for every supported platform, so no compiler is needed. The builder stage is gone. The model is unchanged (jina-reranker-v1-tiny-en), only the engine differs. Ranking agrees with the llama.cpp implementation: Spearman 0.976 to 1.000 across English and Portuguese fixtures, with identical top-1 in every case. Quantized variants are not used: the q8 export degrades ranking on this 33M-parameter model, and the fp16 export fails to load in onnxruntime-node. Scoring runs in batches of 10 because onnxruntime-node wraps a synchronous native call, so a single 30-document batch would block the event loop for ~78ms. Batching caps that at ~27ms for about 4% more wall time, with identical scores. Adds RERANKER_EXECUTION_PROVIDERS to opt into GPU inference, defaulting to CPU.
Replaces RERANKER_EXECUTION_PROVIDERS with a fixed ["webgpu", "cpu"] preference. WebGPU is about 3x faster than CPU (24ms against 77ms for 30 documents) and agrees with it to within float32 rounding, so there is no reason to make it opt-in. Hosts without a usable GPU provider fall back to CPU instead of failing to load. Also silences ONNX Runtime's non-actionable startup warnings, matching the quiet startup the llama-server implementation had.
Building from a git worktree failed: `.git` is a file pointing at a gitdir outside the build context, and git resolves the repository before running any subcommand, so even `git config --global` exited 128. The commit hash is optional build metadata, so the build now tolerates its absence and logs a warning instead of failing. `helper-git-hash` already returned an empty string on failure, so nothing else had to change. Also stops `appVersion` from ending in a dangling "+" when the hash is empty, since "2026.7.28+" is not a valid version string.
felladrin
force-pushed
the
claude/reranking-llama-cpp-alternative-3306a7
branch
from
July 28, 2026 17:51
0d0fe9a to
cf7e32b
Compare
…nt ones The previous check allowed half the relevant results to fall outside the top-k window, which made it vacuous for the two fixtures that have only two relevant results: the top-1 assertion already guaranteed one hit, and one of two clears a 50% bar. All four fixtures actually score a perfect p@k, with at least a 0.99 score gap between the last relevant result and the first irrelevant one, so the assertion can require the exact set without becoming sensitive to the ~1e-6 difference between the CPU and WebGPU execution providers. Reported by a review of #2235.
felladrin
marked this pull request as ready for review
July 28, 2026 17:55
This was referenced Jul 28, 2026
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
Currently, the reranker spawns
llama-server, so the Docker image has to compile llama.cpp from source in a dedicated builder stage. On a fresh environment that stage alone takes 168s (measured with--no-cache), and it needsbuild-essential,cmakeandccachein the image.This PR runs the same model in-process through
onnxruntime-node, whose binaries ship prebuilt for every supported platform. The builder stage is gone, and no compiler is needed at all.The model is unchanged (
jina-reranker-v1-tiny-en), only the engine differs. That was the constraint: it's the best reranker I found for this task, and it works surprisingly well on non-English results too.What changed
llama-serverchild process, GGUF Q8_0onnxruntime-nodein-process, ONNX fp32@huggingface/tokenizers(zero dependencies, ~8kB)/healthpolling, warmup POST, 5s restart loop,SIGTRAP/SIGILLhandlingllama-serveronPATHserver/rerankerService.tskeeps the same exports (rerank,getRerankerStatus,startRerankerService,stopRerankerService,sanitizeUnicodeSurrogates), sorankSearchResults.ts,searchEndpointServerHook.tsandstatusEndpointServerHook.tsare untouched. Two signature details for reviewers:stopRerankerServiceis nowasync(it awaits the session release).rerankerServiceHookis the only caller and now logs a rejection instead of leaving a floating promise.getRerankerModelPathis no longer exported. It was only used inside the module, and there are now three files to resolve rather than one.Ranking quality
I compared both engines on identical inputs, feeding the scores through the real
rankSearchResultsbefore comparing.Top-1 matched in every case. The one p@3 difference is a single adjacent swap in the nginx case.
The Portuguese behavior carries over, and the clearest evidence is that both engines make the same mistakes: on "como fazer pão de queijo caseiro" both rank a pizza recipe second, and both bury the English "Cheese Bread Recipe (Brazilian Pão de Queijo)". Same weights, same quirks.
Note: ONNX logits spread wider than llama.cpp's compressed scores, so the standard-deviation filter keeps slightly fewer results (4-6 of 8 where llama.cpp kept 5-7). Ordering is unaffected. I tried squashing the logits through a sigmoid to match the retention, but it only helped in 1 of the 4 cases, so I left the raw scale.
Why fp32
q8measurably degrades ranking on a 33M-parameter model (Spearman drops to 0.90, and p@3 drops in 2 of the 4 cases).fp16fails to load inonnxruntime-node(a graph fusion error inSimplifiedLayerNormFusion).So fp32 it is, at the cost of a larger file on disk.
Performance
Measured at the production result count (30 documents):
--threads 1(what we run today)Not a regression on either path: even the CPU fallback is about 4x faster than what we run today, because
--threads 1left cores idle and ONNX Runtime uses them by default. Cold start also drops to 0.16s once the model is cached.These are medians on an unpinned laptop, so treat them as ±15%. The two ONNX rows are from one run with the batching this PR ships, so they are comparable to each other; the llama.cpp rows were measured separately.
Event-loop blocking
onnxruntime-nodewraps a synchronous native call insetImmediate(seerun()injs/node/lib/backend.ts), so inference blocks the event loop for its whole duration. Scoring 30 documents in one batch blocked it for 78ms with zero timer ticks.Documents are now scored in batches of 10, which yields between calls and caps the stall at ~27ms for about 4% more wall time. Scores are bit-identical either way, since padding is per batch but the attention mask excludes it.
This is the one real trade-off against the old design:
llama-serverran in its own process, so reranking never blocked Node. If the remaining ~27ms ever matters, moving inference to a worker thread would remove it entirely.GPU
The session requests
["webgpu", "cpu"], with nothing to configure. WebGPU is about 3x faster than CPU (24ms vs 77ms for 30 documents) and matches CPU numerically to 1e-6 with identical ordering, so there's no reason to make it opt-in. Hosts with no usable GPU provider (Linux arm64, or any host without a GPU) fall back to CPU rather than failing to load.Worth noting: ONNX Runtime's WebGPU provider here is native, compiled into the
onnxruntime-nodebinary. It is not the browser API, so it needs neither a browser nor Deno.CoreML and CUDA are deliberately left out. CoreML is 2.8x slower than CPU for this model's dynamic shapes, and the CUDA binaries aren't bundled with the package.
ONNX Runtime's startup warnings are also silenced (it warns on every launch that it assigned shape operators to CPU, which is expected), matching the quiet startup
llama-serverhad.Building without a git repository
docker compose up --buildfailed from a git worktree, where.gitis a file pointing at a gitdir outside the build context. Git resolves the repository before running any subcommand, so evengit config --global --add safe.directoryexited 128 and killed the build.The commit hash is optional build metadata, so the build now tolerates its absence and prints a warning instead of failing.
helper-git-hashalready returned an empty string on failure, so nothing else needed changing.Consequences when no repository is available:
WARNING: no usable git repository in the build context, ..../statusreportsbuild.gitCommitas"".v2026.7.28instead ofv2026.7.28+0d0fe9a.appVersionnow omits the separator rather than rendering a dangling2026.7.28+.Builds from a normal checkout are unaffected and still embed the hash.
Dependencies
onnxruntime-node+@huggingface/tokenizers, which adds 263 lines to the lockfile. I also prototyped this with@huggingface/transformersand it produced bit-identical scores, but it pullssharp(image processing the reranker never touches) for 51 packages, 381MB and 4 advisories, against 26 packages, 214MB and 2 advisories here.Both remaining advisories are
adm-zip, reached only throughonnxruntime-node's install script, which downloads the binaries that aren't bundled (the CUDA EP). On a default CPU install it exits early and never opens an archive.Documentation
docs/reranking.mdis largely rewritten (service lifecycle, execution providers, batching, why fp32, how to run the integration test). Smaller updates remove thellama-serverreferences fromREADME.md(the architecture diagram node),agents.md,docs/overview.md,docs/quick-start.md,docs/development-commands.md,docs/glossary.mdanddocs/configuration.md.The llama.cpp mentions left in
README.mdanddocs/ai-integration.mdare deliberate: those describe llama.cpp as an OpenAI-compatible backend users can point MiniSearch at, which is unrelated to reranking.@wllama/wllama(browser-side inference) is untouched.Type of Change
Checklist
npm run lintpassesnpm run test), with tests added where it made senseserver/rerankerService.integration.test.tsloads the real model and asserts ranking quality on the English and Portuguese fixtures. It downloads ~130MB, so it's excluded from the default suite:How to test
npm run lintandnpm run test(288 tests).npx vitest run --config vitest.integration.config.tsto exercise the real model (6 tests). Deleteserver/modelsfirst if you want to check the download path.docker build -t minisearch .and confirm there's no compile step. It also works from a git worktree now./statusreports the reranker as available.Security, performance, or breaking changes? Expand if relevant.
No breaking changes for users: no new required configuration, and the model still downloads on first start.
Performance: reranking gets faster (397ms to 81ms on CPU, 32ms on WebGPU, at 30 results), but inference now blocks the event loop for up to ~27ms per search, where before it ran in a separate process. See the sections above.
Security: adds 2 high advisories, both
adm-zipviaonnxruntime-node, on a code path a default CPU install doesn't reach. In exchange it drops thegit/cmake/build-essentialtoolchain and a from-source clone of llama.cpp from the image build.GPU is used automatically where ONNX Runtime has a working provider, with a CPU fallback everywhere else. No new configuration.