File size: 5,776 Bytes
08a5eb3 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 | ---
license: mit
tags:
- executorch
- xnnpack
- pte
- on-device
- feature-extraction
- sentence-similarity
base_model:
- BAAI/bge-m3
---
# bge-m3 β ExecuTorch (dense + sparse + multi-vector, one pass)
Three retrieval signals out of one forward pass. Every other embedding model on this
shelf returns a vector; this one returns a vector, a set of per-token lexical weights,
and a per-token matrix β and they are meant to be combined.
```
input_ids, attention_mask [1, 512] int64
-> dense [1, 1024] the CLS row, L2-normalised
-> sparse [1, 512] one weight per token, masked
-> colbert [1, 511, 1024] one vector per token, L2-normalised, CLS excluded
```
- **Source**: [BAAI/bge-m3](https://huggingface.co/BAAI/bge-m3) β 568M parameters,
XLM-RoBERTa large, 24 layers, 100+ languages
- **License**: MIT
- **No prefix.** Unlike E5 and Qwen3-Embedding on this shelf, bge-m3 wants the text as
it is, on both sides.
## Using the three heads
**Dense** is ordinary vector search: cosine against other dense vectors.
**Sparse** is lexical matching, BM25-shaped. The graph gives one weight per token
position; the vocabulary-space vector is one line of indexing in the caller:
```python
weights = {}
for w, t in zip(sparse[0], input_ids[0]):
if t in (0, 1, 2, 3): # <s>, <pad>, </s>, <unk> β never scored
continue
weights[int(t)] = max(weights.get(int(t), 0.0), float(w)) # max over repeats
score = sum(w * other[t] for t, w in weights.items() if t in other)
```
That scatter stays outside the graph on purpose: emitting `[1, 250002]` on every call
would be a megabyte of almost entirely zeros to save the caller those six lines.
**ColBERT** is late interaction: for each query token take its best match among the
document tokens, and sum. Row `i` of the output is token `i+1` of the input β the CLS
row is dropped before projection, which is what the reference implementation does and
is easy to get wrong by one.
## Verification
| build | file | size (MB) | Mac ms* | dense | colbert | sparse weight shift |
|---|---|---|---|---|---|---|
| XNNPACK fp32 | `bge_m3_xnnpack_fp32.pte` | 2271.5 | 233.3 | 1.000000 | 1.000000 | 0.0000 |
| Core ML | `bge_m3_coreml_all.pte` | 1137.2 | **64.8** | 0.999990 | 0.999976 | 0.0008 |
| XNNPACK fp16 | `bge_m3_xnnpack_fp16.pte` | 1136.3 | 484.3 | 0.999999 | 0.999998 | 0.0004 |
\*Mac arm64, median of 10, one 512-token sequence β a reference point for relative
cost, not a device number. Torch eager fp32 on the same machine is 182.8 ms, so the
Core ML build is **2.8x eager**, 100% delegated in a single subgraph. XNNPACK fp32 is
63.7% delegated across 100 subgraphs; its fp16 build is slower than fp32 because
XNNPACK has no fp16 kernels for this graph and inserts casts.
Dense and colbert are worst-case cosine against the eager model over six sentences;
sparse is the largest change to any single token's weight.
**The recipe was checked against the authors' implementation before anything was
exported.** All three heads have a detail that does not throw when wrong β dense is
CLS and not mean, colbert drops the CLS row, and `sparse_linear` is a
`Linear(1024, 1)` giving a scalar per token rather than a projection into vocabulary
space. Against `FlagEmbedding`'s `BGEM3FlagModel` on six sentences:
```
dense max_abs_diff 2.645e-07
sparse max_abs_diff 3.427e-07
colbert max_abs_diff 4.061e-07
```
**And the published number reproduces.** The model card computes a lexical matching
score of `0.19554901123046875` between its two example sentences. Running those same
sentences through the fp32 `.pte` and the scatter above gives **0.1955** β which is the
only independent check there is on a step that happens outside the graph.
Both retrieval heads separate an answer from an unrelated sentence:
```
dense 0.6259 answer vs 0.3625 unrelated
sparse 0.1955 answer vs 0.0115 unrelated
```
```bash
python convert/check_bge_m3.py fp32 --reference # against FlagEmbedding
python convert/check_bge_m3.py fp32 # or fp16, int8, coreml
```
## Two decisions worth knowing about
**The window is 512, not 8192.** bge-m3 accepts 8192 tokens, and the colbert head
returns one 1024-vector per token β so an 8192 window would be a 32 MB output on every
call for a passage that is almost always shorter. 512 covers an ordinary passage;
longer input is the caller's chunking problem.
**The sparse head is masked in the graph, which upstream does not do.** Upstream
returns the raw relu and relies on the caller dropping special tokens at scatter time.
Measured on one 31-token sentence padded to 512, the fp32 model puts weights of up to
**0.196** on padding positions. A caller who forgets to drop them scatters that onto
the pad token's vocabulary slot. Zeroing them here changes no score β the scatter
discards them either way β and removes a silent trap.
That masking also fixed the measurement. Before it, this build's sparse head read
**correlation -0.162** against fp32 eager, which looks like a broken head; on the 31
real token positions it was **+0.998**, and the other 481 were padding neither arm's
caller ever reads.
## Not shipped
**int8** converts and holds β worst head 0.985 β but it comes out at **1363.3 MB
against fp16's 1136.3 MB**. Dynamic int8 quantises the linear weights and leaves the
token embedding table in fp32, and with a 250k vocabulary at 1024 dimensions that
table is **1024 MB of the 2271 MB model, 45%**. This shelf's rule of thumb: int8 beats
fp16 only when the embedding table is under about a third of the weights.
torch.export -> to_edge_transform_and_lower(partitioner) -> .pte
(conversion scripts: [executorch-models](https://github.com/john-rocky/executorch-models))
|