Skip to main content

Module rank_head

Module rank_head 

Source
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-bert pools with MEAN rather than CLS and uses GELU in place of the tanh.
  • qwen3 / qwen3vl take the last token rather than row 0 (build_inp_cls’s last flag, llama-graph.cpp:296-299) and append a softmax over the outputs.
  • jina-reranker-v1-tiny-en is the checkpoint upstream cites for the cls_out-absent case; it is jina-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§

RankHead
The classification head a reranker checkpoint carries on top of the encoder.

Functions§

load_rank_head
Builds the head a bert checkpoint carries, or None when it carries none (a plain embedding model).