feat: inference-free SPLADE (opensearch-neural-sparse-encoding-doc-v3-gte) - #289
Merged
Merged
Conversation
Adds `SparseModel::OpenSearchNeuralSparseDocV3Gte`, a port of the inference-free SPLADE support that landed in the Python fastembed (qdrant/fastembed#652), producing numerically identical vectors. The model is asymmetric: documents are expanded by the ONNX encoder, while queries are embedded from the tokenizer and the `idf.json` table shipped with the model, without touching the session. `try_new` now reads back a downloaded `idf.json` into an IDF lookup, and the new `query_embed` takes `&self` since it runs no inference. Document post-processing uses the double log activation of the v3 opensearch-neural-sparse family, `ln(1 + ln(1 + relu(x)))`, rather than the single `ln(1 + relu(x))` of SPLADE++, and max-pools before the ReLU to match the Python ordering. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Anush008
reviewed
Sep 12, 2026
Anush008
reviewed
Sep 12, 2026
Anush008
reviewed
Sep 12, 2026
Anush008
reviewed
Sep 12, 2026
Anush008
reviewed
Sep 12, 2026
Anush008
approved these changes
Sep 12, 2026
Owner
There was a problem hiding this comment.
Thanks for taking the time to contribute @KShivendu.
I've got some nit picks for you to consider.
Co-authored-by: Anush <mail@anush.sh>
github-actions Bot
pushed a commit
that referenced
this pull request
Sep 12, 2026
## [6.1.0](v6.0.3...v6.1.0) (2026-09-12) ### 🍕 Features * expose ONNX Runtime session config entries through InitOptions ([#292](#292)) ([adfefb9](adfefb9)) * inference-free SPLADE (opensearch-neural-sparse-encoding-doc-v3-gte) ([#289](#289)) ([6b9e45d](6b9e45d)) ### 📝 Documentation * Update LICENSE ([#290](#290)) ([a535a2f](a535a2f))
|
🎉 This PR is included in version 6.1.0 🎉 The release is available on:
Your semantic-release bot 📦🚀 |
Anush008
pushed a commit
that referenced
this pull request
Sep 12, 2026
## [6.1.0](v6.0.3...v6.1.0) (2026-09-12) ### 🍕 Features * expose ONNX Runtime session config entries through InitOptions ([#292](#292)) ([adfefb9](adfefb9)) * inference-free SPLADE (opensearch-neural-sparse-encoding-doc-v3-gte) ([#289](#289)) ([6b9e45d](6b9e45d)) ### 📝 Documentation * Update LICENSE ([#290](#290)) ([a535a2f](a535a2f))
Anush008
pushed a commit
that referenced
this pull request
Sep 12, 2026
## [6.1.0](v6.0.3...v6.1.0) (2026-09-12) ### 🍕 Features * expose ONNX Runtime session config entries through InitOptions ([#292](#292)) ([adfefb9](adfefb9)) * inference-free SPLADE (opensearch-neural-sparse-encoding-doc-v3-gte) ([#289](#289)) ([6b9e45d](6b9e45d)) ### 📝 Documentation * Update LICENSE ([#290](#290)) ([a535a2f](a535a2f))
Anush008
pushed a commit
that referenced
this pull request
Sep 12, 2026
## [6.1.0](v6.0.3...v6.1.0) (2026-09-12) ### 🍕 Features * expose ONNX Runtime session config entries through InitOptions ([#292](#292)) ([adfefb9](adfefb9)) * inference-free SPLADE (opensearch-neural-sparse-encoding-doc-v3-gte) ([#289](#289)) ([6b9e45d](6b9e45d)) ### 📝 Documentation * Update LICENSE ([#290](#290)) ([a535a2f](a535a2f))
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds
SparseModel::OpenSearchNeuralSparseDocV3Gte, an inference-free SPLADE model, ported from the Python fastembed implementation in qdrant/fastembed#652 so the two libraries produce identical vectors.The model
opensearch-project/opensearch-neural-sparse-encoding-doc-v3-gte, mirrored for fastembed atQdrant/opensearch-neural-sparse-encoding-doc-v3-gte(~0.55 GB, single-file ONNX, apache-2.0). It is an MLM head over a GTE backbone:input_ids+attention_maskin (type_vocab_sizeis 0, so there is notoken_type_idsinput), onelogitstensor of(batch, seq_len, 30522)out.It is asymmetric, which is the whole point of it:
That makes query latency essentially free while keeping the term-expansion quality of SPLADE on the document side.
What changed
src/models/sparse.rs— new variant withmodel_code: "Qdrant/opensearch-neural-sparse-encoding-doc-v3-gte",model_file: "model.onnx",additional_files: ["idf.json"].src/sparse_text_embedding/init.rs— two new fields onSparseTextEmbedding:token_id_to_idf: Option<HashMap<usize, f32>>andspecial_token_ids: HashSet<usize>. Both are inert for SPLADE++ and BGE-M3.src/sparse_text_embedding/impl.rstry_newalready downloadedadditional_filesbut never read them back. It now keeps the path of the downloadedidf.json, and any model that declares one gets its IDF table loaded eagerly next to the tokenizer — no per-variant special-casing in the loader.idf.jsonis keyed by token string, so it is resolved through the tokenizer vocab, exactly like Python's_load_idf.get_added_tokens_decoder(), filtered onspecial), which afterload_tokenizermatches what Python derives fromspecial_tokens_map.json:{0, 100, 101, 102, 103}here.post_process_if_spladearm.query_embed.tests/if_splade.rs+ a CI matrix entry, README model list and a short usage section.Document post-processing — this is not
post_process_spladeReusing the existing SPLADE++ path would silently produce plausible-but-wrong vectors, in two ways.
Activation. SPLADE++ uses a single
ln(1 + relu(x)). The v3 opensearch-neural-sparse models use a double one,ln(1 + ln(1 + relu(x))), to push document embeddings sparser:Order of operations.
post_process_spladedoes relu → log → mask → max-pool fromNEG_INFINITY. Python here masks → max-pools → relus:The new arm initialises each vocabulary accumulator to
0.0and maxes only over unmasked positions. That0.0encodes both the ReLU floor and the zero contribution of padding at once, so it agrees with Python whether or not the batch is padded — and it avoids materialising a secondbatch × seq_len × 30522tensor for the mask multiply.Special-token ids are then dropped from the document vector too (Python zeroes them after pooling); otherwise they would match every query.
query_embedTokenize, drop special tokens, dedupe, sort ascending by token id, look up the IDF weight, skip ids absent from the table.
It takes
&self, not&mut selflikeembed— a genuine ergonomic win, since aSparseTextEmbeddingcan now serve queries from behind a shared reference (anArc, a request handler) without a lock. It is also a compile-time proof of the inference-free property:Session::runneeds&mut self, so a&selfmethod cannot possibly run the model. Python asserts the same thing at runtime withassert not hasattr(model.model, "model").For SPLADE++ and BGE-M3, which have no separate query representation, it returns
Error::InvalidArgumentrather than silently falling back toembed.tests/text-embeddings.rsasserts that.Parity
Verified against the real weights on the same input as the Python test (
docs = ["Hello World"]), ONNX Runtime 1.24.4.Query — identical to the Python values to all 8 published digits:
worldhelloDocument — 65 non-zero dimensions, of which the leading 15 are the ones the Python test pins:
Largest absolute difference is 2.1e-6 (index 1010), i.e. ONNX Runtime float noise, three orders of magnitude inside the
abs=0.001tolerance the Python test uses.tests/if_splade.rschecks both sides against these goldens with a 1e-3 epsilon.Caveat worth knowing about: batch size
This model emits one score per vocabulary entry per token. At the crate's
DEFAULT_BATCH_SIZEof 256 and the default max length of 512 that is256 × 512 × 30522 × 4bytes ≈ 16 GB in a single output tensor.embed(docs, None)is therefore a bad idea here; the README section and the test both pass a small explicit batch size, and the test constant carries a comment saying why. I leftDEFAULT_BATCH_SIZEalone since it is shared with the other sparse models — happy to add a per-model default if you would rather the footgun not exist.One other intentional difference from Python: this crate's sparse
DEFAULT_MAX_LENGTHis 512, while Python honours the model'smodel_max_lengthof 8192. Documents beyond 512 tokens will diverge unless you pass.with_max_length(8192). Left as-is to match the crate's existing behaviour for the other sparse models.Checks
cargo fmt --all -- --check,cargo clippy(default features and--no-default-features --features image-models,ort-download-binaries-native-tls), and the test suite pass locally.🤖 Generated with Claude Code