Learned sparse retrieval · inference-free SPLADE · dense vs sparse vs late interaction
Semantic search for the price of a git push
The search on this site knows that someone lost in the woods is a search-and-rescue question. It runs no model in your browser and no server anywhere. This post puts the three ways of doing neural retrieval side by side, shows why the inference-free one can drop the query encoder, and reads the vectors it produces.
Try it first
The index this post measures, running in this tab.
Results appear as you type. Arrow keys move through them, Enter opens the scoring for one, Escape dismisses the list.
Nothing downloaded until you focus the box.
A result opens its scoring on the search page. The third example is deliberate; Section 5 explains the silence.
I wanted a search box on a static site that understands meaning, and I did not want to pay for a process that stays up to provide it. Those two wants usually conflict: meaning implies an embedding model, which implies a GPU or at least a server, which implies a monthly line item. The corpus here is my papers, slides and CV, written by people who say wilderness search and rescue when a reader types finding someone lost in the woods. Keyword search never connects the two. What follows is the piece of retrieval research that connects them without a model running anywhere at query time, and a look at what that buys and costs next to the two other ways of doing it.
01Three ways to compare a query with a document
Every neural retriever answers the same question: how alike are this query and this passage? They differ in what they store per passage and when the two sides are allowed to meet. Read the three panels as the same drawing made three times.
Dense retrievalone vector per side
encoder at query time
SPLADEone weight per word
no model at query time
Late interactionone vector per token
encoder at query time
| Dense | SPLADE, inference-free | Late interaction | |
|---|---|---|---|
| Stored per passage | one vector, typically 768 floats, 3 KB | a few hundred weighted words; here 2.1 KB, 0.6 KB gzipped | one 128-d vector per token; 20–36 bytes each with ColBERTv2's compression [9] |
| Score | one dot product | sparse dot product over shared words | sum over query tokens of the best-matching document token |
| Query time | run the encoder | tokenize, look up a weight per token | run the encoder, then MaxSim |
| Vocabulary gap bridged by | both encoders, invisibly | the document encoder alone, in words you can read | both encoders, per token |
| Why a result matched | it just did | this word times that word | these token pairs |
Dense is the default and it is good. Late interaction is more accurate and pays for it in index size and in the encoder it still needs when a query arrives. Both need something to run when a reader types. The middle column is the one where that requirement disappears, and the rest of this post is about how, using the search on this page as the specimen.
02SPLADE in one breath
SPLADE [1] takes a BERT-style masked language model and reuses the head it was pre-trained with, the one that predicts a vocabulary token at every position. For n input tokens the head produces an n × |V| matrix of logits over the 30,522-entry WordPiece vocabulary, and SPLADE squashes it into one vector per passage:
wj = maxi log(1 + log(1 + ReLU(ℓij)))
ReLU keeps positive evidence, the max over positions keeps the strongest occurrence of each
word, and the double logarithm is the v3 saturation the OpenSearch models use
[3]. A sparsity regulariser in training leaves a few hundred non-zero
entries out of thirty thousand, and every entry has a name because every dimension is a
word. A passage about UAVs can come out with weight on drone without ever saying it.
That is the expansion in Sparse Lexical and Expansion, and it is what BM25 cannot do.
On the BEIR suite the quality is competitive with dense retrieval [2],
with the difference that you can read the vector and see why a result matched.
03The whole trick is an asymmetry
Vocabulary bridging only has to happen once, on one side. If the document side is
expanded aggressively enough at index time, the query side can be almost nothing: tokenize, look
up a weight per token, done. Models trained this way are called inference-free. The
naver splade-v3-doc line gives every query token weight 1.0. The OpenSearch line
[4][3] ships a fixed, learned, IDF-style weight per
token as a text file and trains the document encoder to expect exactly those weights. I used
opensearch-neural-sparse-encoding-doc-v3-distill:
67M parameters, Apache-2.0, average NDCG@10 of 0.517 on the model card's thirteen BEIR sets. From
the weight table this site ships:
| Query token | Static weight | Reading |
|---|---|---|
the | 0.135 | a stopword by arithmetic, not by list |
how | 2.76 | question words carry a little |
search | 4.62 | |
drones | 9.22 | 68× the weight of the |
sp · ##lad · ##e | 4.76 · 7.90 · 3.04 | what SPLADE thinks of the word SPLADE |
With bag-of-words weights, the and drones would count the same. With these, the query side already knows what is worth matching before it has seen a single document. It knows nothing about this corpus. That is the price, and Section 7 comes back to it.
04A vector you can read
Dense embeddings ask you to trust them. A sparse one shows its working. Here is the abstract of the SAREnv paper as the document encoder sees it: 160 activated words, 88 of them in the text and 72 added by the model.
search/index.json. The paper never says camping, hunting or
forest. The model decided a paper about wilderness search and rescue is the kind of
document those words retrieve. It is exactly right and slightly eerie. Note the singular
drone was added even though the plural is in the text: expansion is also
morphology.Across the corpus the picture is the same. Of 31,556 postings in the index, 59.5% are words not literally present in their passage, and the 203 passages between them touch 5,757 of the 30,522 dimensions. The vector is sparse in the sense that matters, about 0.5% density per passage, but it is far from a bag of the words on the page.
05The dot product, with the working shown
Retrieval is now a sparse dot product between a query vector that cost nothing and a document vector that was paid for weeks ago. Take the first example above the fold.
Now the honest counter-example, because this is where inference-free stops being magic and
becomes a design choice. Type heart attack and the top result is a U-space planning
section, scored 1.01 entirely from an expansion word heart the model put on a
passage about UAV corridors. Type cardiac arrest and you get nothing. A symmetric SPLADE
would have expanded cardiac towards heart on the query side; a dense model would
have placed the two phrases near each other. Here the bridge has to have been built from the
document side, and no document on this site is about hearts. Inference-free retrieval is exactly
as clever as the document encoder's guesses about future queries, and no cleverer.
06The plumbing, in one picture
Everything the model does happens on a GitHub Actions runner, once, when content changes. Everything the reader touches is three static files and about a hundred lines of JavaScript.
Three details from the top row earned their place the hard way. Document weights are stored
as one byte each, round(100·w), so the error on the weights that decide a ranking is
under 1%, and CI compares the browser's scores against an unquantised reference on every build.
The browser has its own WordPiece implementation, and a tokenizer that drifts from the model's
fails silently, so the same build tokenizes a fixture of awkward strings (grøntved,
ESP-NOW, café résumé naïve) both ways and fails on any disagreement. And
torch.set_num_threads(1) makes the encoding deterministic on a given runner image,
so an unchanged index produces no commit. The workflow, the build script and the
Worker are linked in Section 8.
07Where it stops
The index is 2.1 KB per passage uncompressed and scoring is linear in postings. Extrapolating from the measured figures:
| Passages | Index, gzipped | Brute-force query | Verdict |
|---|---|---|---|
| 203 (today) | 123 KB | 0.7 ms | measured |
| 1,000 | 0.6 MB | 3.5 ms | fine |
| 10,000 | 6 MB | 35 ms | the download is now the problem |
| 100,000 | 61 MB | 350 ms | ship one posting list per word, or admit you need a server |
The download column is the reliable one; the timing assumes the loop stays cache-friendly, which it will not. This architecture is right for a corpus you could read in an afternoon and wrong for one you could not. Past that, Lucene-based engines do this natively, and OpenSearch ships this exact model for the purpose [4]. That is the not-free path.
Two limits are about the model rather than the plumbing. The static query weights are a
learned IDF over MS MARCO-shaped web text: they rate drone far above system,
8.71 against 3.43, while on this site the two words are equally ordinary, in the vectors of 38%
and 36% of passages. A corpus-specific IDF would fix that and would break the contract the
document encoder was trained under, and I have not measured which effect wins on 203 passages.
And WordPiece shreds proper nouns: Grøntved becomes gr ##ø ##nt ##ved. The
fragments happen to be rare enough here that the right thing still surfaces, but that is luck.
Meaning is what this is for; finding a specific known thing is what the metadata filters on the
search page are for. The model is also English only, and a free API tier is typically paid for in
the prompts you send, so read the terms before you route anything sensitive through one. My
papers are public; that is the case where the trade is fine.
08Steal it
Everything is in the repository and none of it is clever enough to deserve a package:
tools/build_search_index.py: extract passages, encode, quantise, write three files. The v3 activation is the double logarithm; do not simplify it to the v1 formula.search/splade.js: WordPiece, the dot product, the loader. The search itself is six lines insidesearch()..github/workflows/search-index.yml: the top row of Figure 5, including the dispatch of the Pages deploy that aGITHUB_TOKENpush would otherwise never trigger.worker/: the bottom row, with the grounding prompt in a file of its own.
Then go and type something into the search. Pick a result and the page shows the query decomposed, the words that scored, and the whole 30,522-dimensional vector as a strip of mostly nothing. Then try cardiac arrest, and enjoy the silence.
09Up next
Inference-free visual SPLADE. The document encoder does not have to
read text. V-SPLADE [10] puts a vision-language model on the document
side, reads the page as an image, and leaves the query side as a tokenizer and a weight table.
splade.js was written not to care about the vocabulary size for exactly this reason.
My slides are images with structure and the papers have figures nobody can search; the open
question is whether a VLM encoding a few hundred page images fits in a free CPU runner's
six-hour job, and I intend to time it rather than guess.
A vocabulary of my own. Grøntved shreds because the vocabulary was cut for English web text in 2018. Yu, Mallia and Petri [11] show that a corpus-specific vocabulary improves learned sparse retrieval by up to 12% and, with shorter posting lists, halves latency. It means training the document encoder again, and that is the first item here a CPU runner might refuse to do for free.
SPLARE, and the end of readable dimensions. SPLARE [12] retrieves over the latent features of a sparse autoencoder trained on an LLM's hidden states rather than over the vocabulary. That buys a multilingual representation that holds up out of domain. The latents no longer have a WordPiece spelling, so Figure 3 would need a legend generated by a model, and it is not inference-free: the query goes through the LLM too. Whether a static table from tokens to latents can stand in for that, the way an IDF table stands in for the query encoder here, is a research question rather than a weekend, and it is the item on this list most likely to end with a GPU invoice.
SourcesReading list
- T. Formal, B. Piwowarski, S. Clinchant. SPLADE: Sparse Lexical and Expansion Model for First Stage Ranking. SIGIR 2021. arXiv:2107.05720
- T. Formal, C. Lassance, B. Piwowarski, S. Clinchant. SPLADE v2: Sparse Lexical and Expansion Model for Information Retrieval. 2021. arXiv:2109.10086
- X. Shen, Z. Geng, Y. Yang. Exploring ℓ0 Sparsification for Inference-free Sparse Retrievers. SIGIR 2025. The paper behind the v3 models. arXiv:2504.14839
- Z. Geng, Y. Wang, D. Ru, Y. Yang. Towards Competitive Search Relevance For Inference-Free Learned Sparse Retrievers. 2024. Where the IDF-weighted query side comes from. arXiv:2411.04403
- OpenSearch Project. opensearch-neural-sparse-encoding-doc-v3-distill, model card with the BEIR table and the reference encoding code. Apache-2.0.
- K. Shivendu. Inference-Free SPLADE: Full Quality, 13× Faster Queries. 2026. A proper benchmark of inference-free versus full SPLADE on BEIR SciFact; the place to go for the numbers.
- V. Karpukhin, B. Oğuz, S. Min, P. Lewis, L. Wu, S. Edunov, D. Chen, W. Yih. Dense Passage Retrieval for Open-Domain Question Answering. EMNLP 2020. arXiv:2004.04906
- O. Khattab, M. Zaharia. ColBERT: Efficient and Effective Passage Search via Contextualized Late Interaction over BERT. SIGIR 2020. arXiv:2004.12832
- K. Santhanam, O. Khattab, J. Saad-Falcon, C. Potts, M. Zaharia. ColBERTv2: Effective and Efficient Retrieval via Lightweight Late Interaction. NAACL 2022. arXiv:2112.01488
- G.-H. Cho, Y. Lee, K. Jeong, S. Lee, S. Han, H. Déjean, S. Clinchant, S.-w. Hwang. Inference-Free Multimodal Learned Sparse Retrieval for Production-Scale Visual Document Search. 2026. V-SPLADE. arXiv:2605.30917
- P. Yu, A. Mallia, M. Petri. Improved Learned Sparse Retrieval with Corpus-Specific Vocabularies. ECIR 2024. arXiv:2401.06703
- T. Formal, M. Louis, H. Déjean, S. Clinchant. Learning Retrieval Models with Sparse Autoencoders. ICLR 2026. SPLARE. arXiv:2603.13277
- Provider limits, as read on 8 September 2026: GitHub Pages, GitHub Actions billing, Workers pricing, Mistral usage limits.