Kasper Grøntved Front page →

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.

Try

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

QUERYDOCUMENTq1q2q3d1d2d3d4d5d6EncoderEncoderpoolpoolone dot productpool(q) · pool(d)

encoder at query time

SPLADEone weight per word

QUERYDOCUMENTq1q2q3d1d2d3d4d5d6lookup tableEncoderfindingsomeonelostthewoodssarrescuedrones+ 30,514 more rows, all emptyaddedin textsparse dot producta row is a word · q · d

no model at query time

Late interactionone vector per token

QUERYDOCUMENTq1q2q3d1d2d3d4d5d6EncoderEncodersum of best matchesΣ max · nothing pooled

encoder at query time

Figure 1. Dense retrieval [7] pools each side to a single point and compares the points, so the compression happens before anyone knows what will be asked. Late interaction [8] keeps every token vector and lets each query token pick its best match, so a rare local clue survives. SPLADE [1] keeps one weight per vocabulary word, so the vector can be read, and in the inference-free variant the query side needs no model at all. The orange cells are words the encoder put on the document that the text never contained.
DenseSPLADE, inference-freeLate interaction
Stored per passageone vector, typically 768 floats, 3 KBa few hundred weighted words; here 2.1 KB, 0.6 KB gzippedone 128-d vector per token; 20–36 bytes each with ColBERTv2's compression [9]
Scoreone dot productsparse dot product over shared wordssum over query tokens of the best-matching document token
Query timerun the encodertokenize, look up a weight per tokenrun the encoder, then MaxSim
Vocabulary gap bridged byboth encoders, invisiblythe document encoder alone, in words you can readboth encoders, per token
Why a result matchedit just didthis word times that wordthese 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 tokenStatic weightReading
the0.135a stopword by arithmetic, not by list
how2.76question words carry a little
search4.62
drones9.2268× the weight of the
sp · ##lad · ##e4.76 · 7.90 · 3.04what 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.

67M params 268 MB WordPiece 61 KB table Index time GitHub Actions · about 3 minutes · once per content change Query time your browser · 0.7 ms · on every keystroke Where the weight lives All the intelligence is spent before anyone types a query
Figure 2. Inference-free SPLADE moves all of the model to index time. The right pan holds everything the browser needs to turn a query into a vector: a WordPiece tokenizer, about a hundred lines of JavaScript, and one static weight per vocabulary entry. Timings are from Section 6.

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.

SAREnv abstract, top of its sparse vector weight per vocabulary term · teal is in the passage, orange the model added sar0.79 wilderness0.73 search0.71 data0.67 drone *0.60 path0.57 drones0.56 unmanned0.55 rescue0.50 lost0.48 map *0.45 mapping *0.37 wild *0.37 camping *0.29 hunting *0.29 forest *0.28 * not in the text · 144 further terms omitted
Figure 3. Sixteen of the 160 terms, read straight out of 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.

finding someone lost in the woods scored against the top passage, “Modeling Lost Person Locations” (SAREnv, Section 4) TOKENQUERY w ×DOC w =SCORE SOURCE lost5.42 1.005.42 in the passage someone4.75 0.190.90 added by the model woods7.18 0.120.86 added by the model finding5.92 0.120.71 added by the model in0.50 0.000 not in the vector the0.14 0.000 not in the vector Σ 7.90 rank 1 of 203 weights shown to two decimals; the sum uses the unrounded query table
Figure 4. Six query tokens, four of which land. Only lost is in the passage. Someone, woods and finding were put into the document vector by the encoder, at index time, in anticipation of a query like this one. The stopwords were given almost no weight by the table and no weight at all by the model, and cancel out of the arithmetic entirely.

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.

Where the work happensthe model runs once, on the left; a reader only ever touches the rightgit pushyou edit apage or a paperGitHub Actions67M encoder, once203 passages · ~3 mincommits the index€0GitHub Pages3 static files288 KB gzippedfetched on focus€0Your browsertokenize · weight tabledot product · 0.7 msno model, no server€0only if a reader presses “Answer this”the four best passages,by id, never by textCloudflare Workerholds the API keycaps and a 24 h cacheWorkers Free€0Mistral2–4 sentences, [n] citesfree Experiment tier429 → the page quotes [1]€0no payment method on any account · every limit fails with an error and never with a bill · sizes measured on the live index
Figure 5. The top row is the whole of search. A push that touches indexed content runs the encoder on a free runner and commits the result; Pages serves it; the browser fetches 288 KB gzipped on first focus, 930× less than the 268 MB model, and scores all 31,556 postings by brute force in 0.7 ms. The bottom row exists only for the Answer this button and is the one part that talks to a server, because a static site cannot hold an API key. The Worker accepts passage ids, never text, so it can only ever generate from this site's own corpus.

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:

PassagesIndex, gzippedBrute-force queryVerdict
203 (today)123 KB0.7 msmeasured
1,0000.6 MB3.5 msfine
10,0006 MB35 msthe download is now the problem
100,00061 MB350 msship 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 inside search().
  • .github/workflows/search-index.yml: the top row of Figure 5, including the dispatch of the Pages deploy that a GITHUB_TOKEN push 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

  1. T. Formal, B. Piwowarski, S. Clinchant. SPLADE: Sparse Lexical and Expansion Model for First Stage Ranking. SIGIR 2021. arXiv:2107.05720
  2. T. Formal, C. Lassance, B. Piwowarski, S. Clinchant. SPLADE v2: Sparse Lexical and Expansion Model for Information Retrieval. 2021. arXiv:2109.10086
  3. 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
  4. 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
  5. OpenSearch Project. opensearch-neural-sparse-encoding-doc-v3-distill, model card with the BEIR table and the reference encoding code. Apache-2.0.
  6. 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.
  7. 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
  8. O. Khattab, M. Zaharia. ColBERT: Efficient and Effective Passage Search via Contextualized Late Interaction over BERT. SIGIR 2020. arXiv:2004.12832
  9. 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
  10. 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
  11. P. Yu, A. Mallia, M. Petri. Improved Learned Sparse Retrieval with Corpus-Specific Vocabularies. ECIR 2024. arXiv:2401.06703
  12. T. Formal, M. Louis, H. Déjean, S. Clinchant. Learning Retrieval Models with Sparse Autoencoders. ICLR 2026. SPLARE. arXiv:2603.13277
  13. Provider limits, as read on 8 September 2026: GitHub Pages, GitHub Actions billing, Workers pricing, Mistral usage limits.