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