Summary
For Qwen3-family rerankers (which carry a 2-class yes/no classifier head), LlamaRankingContext.rankAll() applies sigmoid() to a value that llama.cpp has already softmaxed, double-transforming the score. Relevant documents come back at ~0.73 and irrelevant at ~0.50, instead of the expected ~1.0 / ~0.0 — the usable range collapses from [0, 1] to roughly [0.5, 0.731].
Observed
Same model (ggml-org/Qwen3-Reranker-0.6B-Q8_0-GGUF), same query ("what is the capital of France") + docs, through node-llama-cpp rankAll vs. llama-server's /v1/rerank — plus sigmoid() applied to the llama-server score:
| doc |
llama-server /v1/rerank |
rankAll |
sigmoid(server) |
| Paris is the capital of France. |
0.9971 |
0.7306 |
0.7305 |
| The Eiffel Tower is a landmark located in Paris. |
0.0072 |
0.5010 |
0.5018 |
| Photosynthesis converts sunlight into chemical energy. |
0.0000 |
0.5000 |
0.5000 |
| I had a turkey sandwich for lunch and it was fine. |
0.0000 |
0.5000 |
0.5000 |
The rankAll column matches sigmoid(server) on every row — rankAll is returning sigmoid() of the value llama-server already returns. (The small residual, e.g. 0.5010 vs 0.5018, is run-to-run numerical noise: the two columns are two independent forward passes of the same GGUF — llama-server's build and node-llama-cpp's bundled llama.cpp — so the underlying p_yes each one sigmoids differs by ~0.001–0.003. The transform relationship is exact; the inputs to it differ by inference noise.) Since llama-server's output for this model is already a calibrated probability, the extra sigmoid is a redundant transform that compresses the usable range.
Reproduction
Minimal (node-llama-cpp only) — shows the compressed range:
import {getLlama, resolveModelFile} from "node-llama-cpp";
const llama = await getLlama();
const model = await llama.loadModel({
modelPath: await resolveModelFile("hf:ggml-org/Qwen3-Reranker-0.6B-Q8_0-GGUF/qwen3-reranker-0.6b-q8_0.gguf"),
});
const ctx = await model.createRankingContext({contextSize: 2048});
console.log(await ctx.rankAll("what is the capital of France", [
"Paris is the capital of France.", // relevant
"Photosynthesis converts sunlight into chemical energy.", // irrelevant
]));
// → [0.7306, 0.5000] (expected ~[1.0, 0.0])
Full A/B script that produced the table above (node-llama-cpp vs. llama-server)
Start llama-server with the same GGUF (this is the reference "correct" scale):
llama-server -hf ggml-org/Qwen3-Reranker-0.6B-Q8_0-GGUF --reranking --pooling rank --port 8090
Then run (bun or node ≥ a fetch-capable version):
import {getLlama, resolveModelFile} from "node-llama-cpp";
const query = "what is the capital of France";
const docs = [
"Paris is the capital of France.",
"The Eiffel Tower is a landmark located in Paris.",
"Photosynthesis converts sunlight into chemical energy.",
"I had a turkey sandwich for lunch and it was fine.",
];
// node-llama-cpp rankAll
const llama = await getLlama();
const model = await llama.loadModel({
modelPath: await resolveModelFile("hf:ggml-org/Qwen3-Reranker-0.6B-Q8_0-GGUF/qwen3-reranker-0.6b-q8_0.gguf"),
});
const ctx = await model.createRankingContext({contextSize: 2048});
const nlc = await ctx.rankAll(query, docs);
// llama-server /v1/rerank (same GGUF)
const res = await fetch("http://localhost:8090/v1/rerank", {
method: "POST", headers: {"Content-Type": "application/json"},
body: JSON.stringify({query, documents: docs}),
});
const server = new Array(docs.length).fill(0);
for (const r of (await res.json()).results) server[r.index] = r.relevance_score;
console.log("| doc | server | rankAll | sigmoid(server) |");
for (let i = 0; i < docs.length; i++) {
const sigServer = 1 / (1 + Math.exp(-server[i]));
console.log(`| ${docs[i].slice(0, 40)} | ${server[i].toFixed(4)} | ${nlc[i].toFixed(4)} | ${sigServer.toFixed(4)} |`);
}
// rankAll ≈ sigmoid(server) (residual = run-to-run numerical noise between the two engines)
Root cause
For Qwen3 rerankers, llama.cpp's RANK pooling path applies ggml_soft_max over the 2-class [yes, no] logits in the graph and stores p_yes ∈ [0, 1] in the embedding buffer — there is even an explicit comment for it in llama-graph.cpp:
// softmax for qwen3 reranker
if (arch == LLM_ARCH_QWEN3 || arch == LLM_ARCH_QWEN3VL) {
cur = ggml_soft_max(ctx0, cur);
}
(src/llama-graph.cpp, L3039-L3041 at the time of writing — line may drift; the comment is grep-stable.)
So getEmbedding(...)[0] is already p_yes. But LlamaRankingContext then sigmoids it again (src/evaluator/LlamaRankingContext.ts#L246-L253):
const embedding = this._llamaContext._ctx.getEmbedding(input.length, 1);
if (embedding.length === 0)
return 0;
const logit = embedding[0]!;
const probability = logitToSigmoid(logit); // <-- p_yes is treated as a raw logit
return probability;
where logitToSigmoid (L305-L307) is 1 / (1 + e^-logit).
So p_yes — already a probability — is passed through sigmoid() a second time: sigmoid(0.997) = 0.731, sigmoid(0.000) = 0.500, matching the table (modulo the run-to-run inference noise noted above).
Scope / suggested fix
The extra sigmoid is appropriate for rerankers that emit a raw logit (no in-graph normalization), but incorrect for models where llama.cpp's RANK pooling already produces a probability (Qwen3 applies softmax in-graph, gated on LLM_ARCH_QWEN3/QWEN3VL). The fix should be conditional — e.g. skip logitToSigmoid when the pooled output is already normalized, and/or expose a raw-score option so callers can match llama-server's /v1/rerank scale.
Happy to send a PR if you can point me at the preferred direction (arch/normalization check vs. a rawScore option).
Environment: node-llama-cpp v3.18.1, macOS (Metal), bun. Model ggml-org/Qwen3-Reranker-0.6B-Q8_0-GGUF.
Summary
For Qwen3-family rerankers (which carry a 2-class yes/no classifier head),
LlamaRankingContext.rankAll()appliessigmoid()to a value that llama.cpp has already softmaxed, double-transforming the score. Relevant documents come back at ~0.73 and irrelevant at ~0.50, instead of the expected ~1.0 / ~0.0 — the usable range collapses from[0, 1]to roughly[0.5, 0.731].Observed
Same model (
ggml-org/Qwen3-Reranker-0.6B-Q8_0-GGUF), same query ("what is the capital of France") + docs, through node-llama-cpprankAllvs.llama-server's/v1/rerank— plussigmoid()applied to the llama-server score:llama-server /v1/rerankrankAllsigmoid(server)The
rankAllcolumn matchessigmoid(server)on every row —rankAllis returningsigmoid()of the valuellama-serveralready returns. (The small residual, e.g. 0.5010 vs 0.5018, is run-to-run numerical noise: the two columns are two independent forward passes of the same GGUF — llama-server's build and node-llama-cpp's bundled llama.cpp — so the underlyingp_yeseach one sigmoids differs by ~0.001–0.003. The transform relationship is exact; the inputs to it differ by inference noise.) Sincellama-server's output for this model is already a calibrated probability, the extrasigmoidis a redundant transform that compresses the usable range.Reproduction
Minimal (node-llama-cpp only) — shows the compressed range:
Full A/B script that produced the table above (node-llama-cpp vs. llama-server)
Start
llama-serverwith the same GGUF (this is the reference "correct" scale):Then run (bun or node ≥ a fetch-capable version):
Root cause
For Qwen3 rerankers, llama.cpp's RANK pooling path applies
ggml_soft_maxover the 2-class[yes, no]logits in the graph and storesp_yes ∈ [0, 1]in the embedding buffer — there is even an explicit comment for it inllama-graph.cpp:(
src/llama-graph.cpp, L3039-L3041 at the time of writing — line may drift; the comment is grep-stable.)So
getEmbedding(...)[0]is alreadyp_yes. ButLlamaRankingContextthen sigmoids it again (src/evaluator/LlamaRankingContext.ts#L246-L253):where
logitToSigmoid(L305-L307) is1 / (1 + e^-logit).So
p_yes— already a probability — is passed throughsigmoid()a second time:sigmoid(0.997) = 0.731,sigmoid(0.000) = 0.500, matching the table (modulo the run-to-run inference noise noted above).Scope / suggested fix
The extra
sigmoidis appropriate for rerankers that emit a raw logit (no in-graph normalization), but incorrect for models where llama.cpp's RANK pooling already produces a probability (Qwen3 applies softmax in-graph, gated onLLM_ARCH_QWEN3/QWEN3VL). The fix should be conditional — e.g. skiplogitToSigmoidwhen the pooled output is already normalized, and/or expose a raw-score option so callers can matchllama-server's/v1/rerankscale.Happy to send a PR if you can point me at the preferred direction (arch/normalization check vs. a
rawScoreoption).Environment: node-llama-cpp v3.18.1, macOS (Metal), bun. Model
ggml-org/Qwen3-Reranker-0.6B-Q8_0-GGUF.