mlboydaisuke commited on
Commit
08a5eb3
Β·
verified Β·
1 Parent(s): 089ebc2

Upload README.md with huggingface_hub

Browse files
Files changed (1) hide show
  1. README.md +130 -0
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))