Expand description
The reranker classification head: cls, cls.output, cls.norm
and {arch}.classifier.output_labels.
Transcribed from llama.cpp llm_graph_context::build_pooling’s
LLAMA_POOLING_TYPE_RANK arm (src/llama-graph.cpp), which is a
classification head and not a pooling rule — the reason
crate::pooling::PoolingType::Rank still refuses in
crate::pooling::pool and always will. pool sees hidden states
and a width; it cannot see these matrices, so RANK is not a
question it can answer. The rank path is CLS pooling followed by
this head, and this module is the “followed by”.
§The graph, for the bert shape
cur = hidden[CLS] (row 0 — see below)
if cls: cur = cls · cur + cls_b
cur = tanh(cur)
if cls_norm: cur = LayerNorm(cur, cls_norm, no bias)
if cls_out: cur = cls_out · cur + cls_out_b
score = cur[0]Three of upstream’s branches are deliberately not here, and each
is refused rather than approximated, because each belongs to an
architecture crate::bert_gguf_loader already refuses by name:
modern-bertpools with MEAN rather than CLS and uses GELU in place of thetanh.qwen3/qwen3vltake the last token rather than row 0 (build_inp_cls’slastflag,llama-graph.cpp:296-299) and append a softmax over the outputs.jina-reranker-v1-tiny-enis the checkpoint upstream cites for thecls_out-absent case; it isjina-bert-v2, which this crate does not load.
Since only bert reaches here, row 0 is the CLS row unconditionally
and there is no softmax. If another architecture is ever admitted,
this module has to grow its branch — it must not inherit bert’s.
§The pooler, and the two score scales (issue #82)
cls IS HuggingFace’s bert.pooler.dense: llama.cpp’s tensor
mapping renames it, and the tanh above is
BertPooler.activation. A BertForSequenceClassification reranker
was trained as classifier(tanh(pooler(cls_hidden))), so the
dense branch is not an optional flourish — it is most of the
head’s calibration.
Every reranker GGUF in circulation is missing it, because
llama.cpp’s converter deletes it by name (conversion/bert.py,
BertModel.filter_tensors: “we are only using BERT for embeddings
so we don’t need the pooling layer”). This module does the one
thing an engine can do about that: it runs the pooler when the
file carries it and refuses to invent one when it does not. There
is no identity stand-in and no zero-filled cls — a made-up pooler
is a made-up score, and the direct-projection shape is legitimate
for jina-reranker-v1-tiny-en, so the absence cannot be refused
either.
What it must never do is leave the difference invisible. The two
regimes differ by roughly a factor of 50 in score magnitude
(about ±11 vs about ±0.2 on ms-marco-MiniLM-L6-v2), which changes
nothing for a caller that sorts and everything for a caller that
thresholds. RankHead::has_pooler and RankHead::graph are the
machine-readable answer, /v1/rerank reports the second one on
every response, and [missing_pooler_note] says it once at load for
whoever is reading the server’s log rather than its JSON.
§Which float is the score
send_rerank (tools/server/server-context.cpp) reads embd[0]:
the FIRST of n_cls_out outputs, whatever the rest are. That is
fine for a one-output relevance head and is a silent choice for a
many-output classifier, so load_rank_head refuses a multi-output
head that does not name its labels — see RankHead::labels.
Structs§
- Rank
Head - The classification head a reranker checkpoint carries on top of the encoder.
Functions§
- load_
rank_ head - Builds the head a
bertcheckpoint carries, orNonewhen it carries none (a plain embedding model).