hypencoder-onnx / README.md
andreer's picture
fp32 ONNX exports of all four Hypencoder checkpoints
e0bd82d verified
|
Raw
History Blame Contribute Delete
6.86 kB
---
license: apache-2.0
base_model:
- jfkback/hypencoder.2_layer
- jfkback/hypencoder.4_layer
- jfkback/hypencoder.6_layer
- jfkback/hypencoder.8_layer
tags:
- onnx
- information-retrieval
- vespa
- hypencoder
library_name: onnx
---
# Hypencoder — ONNX exports (fp32)
Ready-to-run fp32 ONNX artifacts for all four released
[Hypencoder](https://arxiv.org/abs/2502.05364) checkpoints, so you can run them without
PyTorch, `transformers`, or the paper's research package.
A Hypencoder replaces the fixed inner product of a bi-encoder with a *learned,
query-specific* neural network: a hypernetwork turns the query into a small MLP (a
"q-net") whose input is a document vector and whose output is the relevance score.
Exported from the upstream checkpoints (all Apache-2.0) with
[`model2onnx.py`](https://github.com/vespa-engine/sample-apps/blob/master/hypencoder/model2onnx.py)
from the Vespa sample app.
> **Why this exists.** The upstream checkpoints cannot be loaded by `transformers>=5`:
> `Hypencoder.__init__` calls `AutoModel.from_pretrained()` *inside the constructor*, and
> transformers 5 wraps `cls(config)` in a meta-device context, so the nested load is
> rejected by `check_and_set_device_map`. Exporting once and shipping ONNX removes
> `torch`, `transformers`, and the editable `hypencoder-paper` install requirement for running our sample app.
## Contents
| directory | q-net blocks | query-encoder outputs | generated params per query | passage_encoder | query_encoder |
|---|---|---|---|---|---|
| `2_layer/` | 2 | 5 | 1,181,952 | 435.8 MB | 480.8 MB |
| `4_layer/` | 4 | 9 | 2,363,136 | 435.8 MB | 518.6 MB |
| `6_layer/` | 6 | 13 | 3,544,320 | 435.8 MB | 556.4 MB |
| `8_layer/` | 8 | 17 | 4,725,504 | 435.8 MB | 594.3 MB |
Each directory holds `passage_encoder.onnx`, `query_encoder.onnx` and `tokenizer.json`.
The four passage encoders are **not** interchangeable — each checkpoint is a separate
fine-tune, so the document towers differ. Pair a passage encoder only
with the query encoder from the same directory.
## I/O contract
Identical across depths except for the number of query-encoder outputs.
**`passage_encoder.onnx`** — `input_ids`, `attention_mask` (**int64**, `[batch, seq]`)
→ `last_hidden_state` `[batch, seq, 768]`. **CLS-pool it** (take index 0) and do **not**
L2-normalise.
**`query_encoder.onnx`** — `input_ids`, `attention_mask` (**float32**, `[batch, seq]`)
→ `W0` `[768,768]`, `b0` `[768]`, … , `W{n-1}`, `b{n-1}`, `Wout` `[768]`.
Inputs are float32 rather than int64 because Vespa tensors have no int64 cell type; the
export wrapper casts internally. Weight matrices are pre-transposed to `(out, in)` so
they arrive in Vespa's alphabetical dimension order.
## Scoring
Each q-net block is `linear → ReLU → parameter-free LayerNorm`, with a residual
connection, followed by a final linear projection to a scalar. For the 2-block model, as
written in the Vespa sample app's rank profile:
```python
import numpy as np
def layer_norm(v, eps=1e-5):
return (v - v.mean(-1, keepdims=True)) / np.sqrt(v.var(-1, keepdims=True) + eps)
def score_2layer(x0, W0, b0, W1, b1, Wout):
relu0 = np.maximum(x0 @ W0.T + b0, 0.0)
res0 = layer_norm(relu0) + x0 # residual on the first block
relu1 = np.maximum(res0 @ W1.T + b1, 0.0)
return layer_norm(relu1) @ Wout # no residual before the final projection
```
For the deeper checkpoints, treat
[`q_net.py`](https://github.com/jfkback/hypencoder-paper/blob/main/hypencoder_cb/modeling/q_net.py)
as authoritative for where residuals and layer norms are applied — the snippet above is
written for the 2-block case only.
## Usage with onnxruntime
```python
import numpy as np, onnxruntime as ort
from tokenizers import Tokenizer
D = "2_layer"
tok = Tokenizer.from_file(f"{D}/tokenizer.json")
enc = ort.InferenceSession(f"{D}/passage_encoder.onnx")
qenc = ort.InferenceSession(f"{D}/query_encoder.onnx")
d = tok.encode("Mount Everest is Earth's highest mountain, at 8,849 metres.")
doc_vec = enc.run(None, {"input_ids": np.array([d.ids], dtype=np.int64),
"attention_mask": np.array([d.attention_mask], dtype=np.int64)}
)[0][:, 0] # CLS pooling
q = tok.encode("tallest mountain in the world")
names = [o.name for o in qenc.get_outputs()]
w = dict(zip(names, qenc.run(None, {
"input_ids": np.array([q.ids], dtype=np.float32), # float, not int64
"attention_mask": np.array([q.attention_mask], dtype=np.float32)})))
print(score_2layer(doc_vec.astype(np.float64),
w["W0"], w["b0"], w["W1"], w["b1"], w["Wout"]))
```
## Usage with Vespa
The passage encoder and tokenizer can be fetched **by URL at deploy time**, so they never
enter your application package:
```xml
<component id="passage_embedder" type="hugging-face-embedder">
<transformer-model url="https://huggingface.co/andreer/hypencoder-onnx/resolve/main/2_layer/passage_encoder.onnx"/>
<tokenizer-model url="https://huggingface.co/andreer/hypencoder-onnx/resolve/main/2_layer/tokenizer.json"/>
<pooling-strategy>cls</pooling-strategy>
<normalize>false</normalize>
</component>
```
The query encoder **must be a local file** — `onnx-model` does not accept a URI
(`OnnxModel.setUri()` throws "URI for ONNX models are not currently supported"), so
download it into the package first:
```sh
mkdir -p app/models
curl -L -o app/models/query_encoder.onnx \
https://huggingface.co/andreer/hypencoder-onnx/resolve/main/2_layer/query_encoder.onnx
```
```
onnx-model query_encoder {
file: models/query_encoder.onnx
input "input_ids": query(input_ids)
input "attention_mask": query(attention_mask)
}
```
**The sample app's rank profile implements 2 blocks only.** Using `4_layer`, `6_layer` or
`8_layer` means extending that expression with the extra `W{i}`/`b{i}` blocks — the ONNX
outputs are there, but the ranking expression is not written for them.
## Modifications relative to the base models
Per Apache-2.0 §4(b): no weights were retrained or altered numerically. The export
wrapper (a) returns the q-net weight tensors directly instead of a callable, (b) casts
float `input_ids`/`attention_mask` to int64 internally, (c) pre-transposes weight matrices
to `(out, in)`, and (d) uses static layer-norm shapes so the legacy TorchScript exporter
inlines weights into a single file rather than an external `.data` sidecar.
## Citation
```bibtex
@inproceedings{killingback2025hypencoder,
title = {Hypencoder: Hypernetworks for Information Retrieval},
author = {Killingback, Julian and Zeng, Hansi and Zamani, Hamed},
booktitle = {SIGIR},
year = {2025}
}
```
Weights © the original authors, Apache-2.0. This repository redistributes them in ONNX
form under the same licence.