Skip to content

Add BAAI/bge-m3 sparse (lexical_weights) support via --pooling m3_sparse#899

Open
SpongeRobert wants to merge 5 commits into
huggingface:mainfrom
SpongeRobert:kuzma/m3-sparse
Open

Add BAAI/bge-m3 sparse (lexical_weights) support via --pooling m3_sparse#899
SpongeRobert wants to merge 5 commits into
huggingface:mainfrom
SpongeRobert:kuzma/m3-sparse

Conversation

@SpongeRobert

Copy link
Copy Markdown

Summary

Adds BAAI/bge-m3 sparse (lexical_weights) support to the candle backend via a new pooling mode --pooling m3_sparse, addressing the core ask of #141. Dense bge-m3 already works in TEI (--pooling cls); this applies the model's trained sparse_linear head over the same hidden states, so the existing /embed_sparse endpoint returns bge-m3 sparse vectors with no wire-format change.

Closes the sparse half of #141. (Follows up my design comment above.)

Motivation

bge-m3 sparse is a separately trained sparse_linear layer (Linear(1024→1), shipped as sparse_linear.pt), not an MLM head — so --pooling splade can't produce it (load_roberta has no lm_head.decoder). Today the README/model support reflects "bge-m3, no sparse". This PR closes that gap while reusing the existing SPLADE plumbing.

Design

  • New Pool::M3Sparse (backends/core/src/lib.rs), CLI --pooling m3_sparse (kebab alias m3-sparse for CLI consistency).
  • BertM3Head (backends/candle/src/models/bert.rs): loads sparse_linear.pt via VarBuilder::from_pth (candle pickle; fp16→f32), computes relu(sparse_linear(hidden)).squeeze(-1), then scatter-max over the input token ids into a dense vocab_size vector, dropping special tokens and non-positive weights. The router's existing sparsify closure turns that into {index, value} pairs — routers/types untouched. Unlike SPLADE, indices are the input token ids (not vocab argmax), so input_ids are threaded into pooling.
  • Both device paths: CPU/Metal (bert.rs) and CUDA/flash (flash_bert.rs) share one token_weights + scatter_sequence implementation, so the two paths can't diverge. Flash uses cumulative_seq_lengths to scatter per-sequence.
  • core/src/download.rs fetches sparse_linear.pt when pool == M3Sparse.
  • Backends that don't support it (ort, python) reject m3_sparse the same way they reject Splade.

Correctness / parity

Verified bit-exact against FlagEmbedding (BGEM3FlagModel) on a golden fixture including a Russian sentence:

path index-set Jaccard max |Δ value|
CPU (f32) 1.0 ≤ 6.7e-4
CUDA / flash, A100 (fp16) 1.0 6.4e-4

The residual ~1e-4 is expected: the golden reference uses transformers' exact GeLU, whereas TEI logs hidden_act=gelu → GeLU+tanh approximation — index sets are identical. Test + fixture added under backends/candle/tests/ (runs in cargo test).

Points I'd like maintainer input on

These are the questions from my design comment, now concrete in code:

  1. Detection — I went with an explicit --pooling m3_sparse rather than auto-detecting from sparse_linear.pt presence. Happy to switch to auto-detect if you prefer.
  2. Two small public-ish API touch-points (flagged for review, not hidden):
    • is_splade() → renamed is_sparse() (gates /embed_sparse, embed_all, normalize — semantics shared by both sparse poolings).
    • download_artifacts(...) gains a pool: Option<Pool> arg (to fetch sparse_linear.pt).
    • Pool now derives Copy.
      I can shape these differently to minimize churn if you'd rather.
  3. Scope — candle-only, matching where SPLADE lives.

Notes

  • Branch is based on the v1.9.3 release tag; glad to rebase onto main on request.
  • README updated to list the m3_sparse pooling option.

Disclosure: implementation and this PR were prepared with AI assistance; the parity results above were run and verified by me.

Konstantin Serikov added 5 commits July 16, 2026 21:06
Reference output for the bge-m3 sparse head, verified bit-exact against
FlagEmbedding BGEM3FlagModel on three texts including Russian. Consumed by
the insta snapshot tests for --pooling m3_sparse.

Includes the generator script and the fp16 sparse_linear.pt caveat.
bge-m3 sparse is a standalone `sparse_linear` head shipped as
`sparse_linear.pt` next to the weights, applied to the same hidden states the
(already correct) dense bge-m3 path produces. Unlike SPLADE it is indexed by
the *input* token ids rather than by vocabulary argmax, so the pooled vector is
sparse by construction.

The head scatters `relu(sparse_linear(h))` into vocabulary space keyed by input
token id, deduplicating by max and dropping {cls, eos, pad, unk}, which the
router's existing `sparsify` then turns into the same {index, value} pairs
SPLADE produces — so `/embed_sparse` and its wire format are untouched.

Notes:
- `sparse_linear.pt` stores fp16 weights; the VarBuilder dtype casts them to
  the hidden-state dtype, without which the matmul dtype-mismatches.
- Special token ids are resolved from the tokenizer files rather than
  hardcoded, mirroring FlagEmbedding's `_process_token_weights`.
- `Pool` gained `Copy`; the variant is spelled `m3_sparse` on the CLI (matching
  the model card and `Display`) with a `m3-sparse` alias for consistency with
  the other variants.
- Backends and models that cannot serve it reject it at load with a message
  instead of reaching an `unreachable!()`. The flash path is not ported yet.
Runs the three golden texts (including Russian) through the candle CPU path and
asserts the index set matches exactly (Jaccard 1.0) with values within 1e-3.

Values are not bit-exact by design: the golden was captured against exact GeLU
while candle's bge-m3 uses the GeLU + tanh approximation, which lands ~6e-4
away. That is the real candle/FlagEmbedding delta, not a defect.

The batch is built locally rather than via `common::batch`: XLM-RoBERTa position
ids start at `pad_token_id + 1` and the shared helper starts at 0, which is only
self-consistent with the snapshots it produced. Against a real FlagEmbedding
reference the offset matters — without it values drift by ~0.13 while the index
set still matches.

Also covers that a model without a sparse head is rejected, not panicked on.
The SEN-03 `Pool::M3Sparse` head only served on the CPU `bert.rs` path;
production bge-m3 runs on the GPU/flash backbone, so port the head there.

- Move the head arithmetic (`token_weights`) and the scatter-max
  (`scatter_sequence`) onto `BertM3Head` so the padded and flash paths share
  one implementation and cannot drift; `bert.rs::forward` now calls both.
- flash_bert: replace the `bail!` stub with a real `M3Sparse` branch. Flash
  packs the batch into one varlen sequence, so scatter each request's token
  slice (delimited by `cumulative_seq_lengths`) into vocab space and return the
  dense `[n, vocab_size]` tensor the router's `sparsify` expects — wire format
  and router untouched. `load` rejects non-XLM-R; `load_roberta` loads
  `sparse_linear.pt` via the threaded `model_path`.

Position ids carry XLM-R's offset from the real tokenization pipeline, so the
flash path needs no special handling.

GPU parity smoke (A100, flash fp16) vs the FlagEmbedding golden incl. Russian:
index Jaccard 1.0, max|Δ| = 6.4e-4 (threshold 1e-3). Dense `--pooling cls`
regression unchanged (1024-dim, head matches golden).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant