Every combo box filters the same way: substring matching. Type "ala" and you get Alabama and Alaska. That works when users know what they're looking for and how it's spelled. It fails when they don't, which is often the case in the forms we build for government clients.

Consider a FOIA request form. The user needs to select which agency receives the request, from a list of 42. The user's mental model is “who has my medical records?” The label is “Department of Veterans Affairs.” Substring typeahead cannot bridge that gap. There is no substring of “who has my medical records” in “Department of Veterans Affairs.”

That gap between what the user knows and what the option is labeled is a retrieval problem. So we built a combo box that ranks options by vector similarity between the query and text the user never sees.

The Core Idea: Vectorize the Hidden Text

The trick is that we don't embed the option label. That's actually the least informative text an option has. Instead, each option carries a vectorText field: a description of what the option actually is. What data it collects, what it handles. For the FOIA selector, that's a paragraph describing each agency's jurisdiction and typical request categories. For a policy-document picker, it's the document's summary and keyword list.

Both the vectorText and the user's query pass through the same model, so they land in the same vector space. Ranking is a dot product. That is the whole mechanism.

// The data shape
{
  value: "dva",
  text: "Department of Veterans Affairs (VA)",
  vectorText: "VA provides healthcare, education benefits,
    disability compensation... FOIA requests often involve
    disability evaluations, mental health care access..."
}

// Query, on-load option vectors, and build-time precomputed
// vectors all go through the same embedMany()
const [q] = await embedMany([query], model);
const [option] = await embedMany([item.vectorText], model);
// L2-normalized vectors: cosine similarity == dot product
const score = q.reduce((sum, x, i) => sum + x * option[i], 0);

Type “who has my medical records” and the Department of Veterans Affairs and Health & Human Services rise to the top, not because of any string match, but because the query vector sits closest to those agencies' description vectors in a 128-dimensional space. Refine to “my VA medical records” and the VA takes first place decisively.

Why Not Just Vectors? The Hybrid Problem

Pure dense retrieval has a blind spot, and it shows up immediately in real use. Ask for “October” and semantic ranking has nothing to work with: October is not topically similar to any document's content. But one option is titled “Executive Order 14110 (October 2023).” The user's query is a lexical fact, not a semantic one, and no amount of embedding captures it.

The solution is boring and well-established: run both channels. Dense embedding for meaning, BM25 for exact tokens. The sparse index runs over the option title (boosted ×3 so title hits dominate) and the vectorText. BM25's IDF weighting means rare terms like “October” or “14110” score high while common terms get suppressed, with no stopword list required. The index builds synchronously from the same data array at page load, which turns out to matter a great deal (more on that below).

The sparse channel also carries a trick we first shipped earlier this year in the GovDoc Explainer search: every token is indexed twice, once exactly and once as a metaphone phonetic key. A query with “recods” in it phonetically collides with the indexed “records” and the IDF weighting barely notices the difference. Measured on a battery of realistic typos, phonetic keys took top-1 accuracy from 6/14 to 10/14 and top-3 from 12/14 to a perfect 14/14, with zero regression on correctly spelled queries. Users type fast in combo boxes; this is cheap insurance.

The remaining question is how to fuse two ranked lists whose scores live on incompatible scales. BM25 is unbounded; cosine similarity is 0..1. Score-based fusion requires normalization, and normalization requires tuning. The accepted best practice is reciprocal rank fusion: each channel contributes 1/(k + rank) to each item's score, with k=60. It is rank-based, needs no normalization, and no tuning. Elasticsearch 8, OpenSearch, and Azure AI Search all support RRF for hybrid queries; there is no reason a combo box should invent something worse.

The division of labor between the channels is clear: the sparse channel is always live; the dense channel is a progressive enhancement. Eligibility for the fused list is permissive: an option qualifies if its vector score clears a threshold or it has any keyword match, so a query like “October” surfaces the right document even when its semantic score is near zero, and a purely semantic query is never blocked by the absence of a token match.

Choosing the Model: A Narrower Fitness Measure

The current model is minishlab/potion-base-4M, a Model2Vec static embedder, just a distilled embedding table with mean pooling and L2 normalization, no transformer layers. We picked it with a narrow fitness measure: not "good at embedding in general" (MTEB/BEIR), but "can it distinguish the options in this list from each other?" On 28 intent queries with known targets across both demo datasets (57 options), the 15.1 MB static model beat the 17.5 MB transformer it replaced (top-1 25/28 vs 24/28, top-3 28/28 vs 27/28) while running inference as a single lookup. A 2M variant at 7.6 MB dropped to 20/28. That's where the size/quality frontier actually sits. A globally mediocre model can still perfectly separate 42 agency descriptions, and that's all this component needs.

The Architecture: Same Combo Box, Different Controller

It's framework-agnostic: keep the existing markup, styling, and a11y semantics, replace only the filtering controller. The <select> stays in the DOM, keyboard nav and ARIA roles keep working. What changes is what happens on each keystroke. Substring filtering becomes becomes a debounced embedding call and re-rank. The demo uses USWDS classes, but the swap works against any input + list + select combo box.

Everything runs client-side through Transformers.js (plus a thin ONNX Runtime session for the static model). The same embedMany() embeds the query, the on-load vectors, and the build-time precomputed vectors. One function for all embedding, so you can't silently ship mismatched models that produce garbage similarity.

The first version embedded every vectorText at runtime on first open. That was fine for dozens, wrong for thousands. Now a build step (npm run precompute) batches all vectorTexts through the same embedMany() and ships the vectors as JSON. We verified parity by recomputing an item at runtime and diffing. Max component difference zero. Only the query is embedded at runtime now, fast enough to feel instant.

The 15 MB Problem

Here is the hard part. The model is 15 MB. For a combo box.

That number is defensible in some contexts and indefensible in others. Browser cache partitioning means the download is not shared across origins; even a user who already has the model from another site pays again. On a first visit over metered mobile data, You are spending real user resources before delivering any value.

The mitigations are all variations on one principle: the dense channel is a progressive enhancement, never a dependency.

  • BM25 runs unconditionally. The sparse index builds synchronously at init. The combo box is fully functional on first paint, not after model load.
  • Results re-rank transparently. Keyword results render immediately; when the model finishes loading, an open list is re-ranked with RRF fusion. The upgrade is invisible unless you are watching for it.
  • The dense channel is opt-out by design. Disabled through configuration, it short-circuits to BM25-only and skips model warmup entirely. A page deployed with vectors off downloads zero model bytes.
  • Failure degrades, never breaks. If the CDN is blocked or the user is offline, the component settles permanently into keyword-only mode with a visible notice, rather than an eternally-loading dropdown.

Whether that architecture justifies the download depends entirely on the usage pattern, and we think it is worth being precise about that rather than hand-waving.:

  • Strong fit: high-frequency internal tools, where opening the combo box on a day-to-day basis. makes 15 MB once a rounding error; opaque-label domains, where users know the concept but not the label (program codes, legal citations, clinical terminology—the FOIA selector is the canonical case); privacy or compliance boundaries, where sending the query to an embedding API is a non-starter since local inference keeps the data path entirely on-device; zero-backend deployments (static sites, GitHub Pages, offline-first PWAs), where standing up search infrastructure is not practical and the component keeps working offline after first load.
  • Poor fit: single-visit public pages, where you tax the 99% who will not return for the benefit of the 1% who might; small option sets (below roughly ten options, users scan the full list faster than they can type, and a native select or plain typeahead is both cheaper and better UX); bandwidth-constrained contexts, where 15 MB is 15 MB, cached or not, on first load.

What's Still Open

The static-model frontier is the open question that interests us most. Potion-base-2M at 7.6 MB already degraded to top-1 20/28 on the evaluation suite; potion-base-4M at 15.1 MB was the sweet spot. Where exactly does discrimination break between 2M and 4M parameters, and does it break differently for different option-list domains? The answer probably depends on how semantically distinct the options are: 42 agencies with different jurisdictions separate easily; 42 sub-programs within one agency might not. That is a question worth answering with per-domain evaluation data, not a general benchmark.

Finally, the fusion is currently fixed at RRF with k=60. That is intentionally boring. Channel weighting is worth revisiting per-domain, when labels are code-like strings, the sparse channel deserves more trust than RRF's equal footing gives it. But we'd want relevance-judged evaluation data before tuning, since hand-tuned fusion weights are where search systems decay silently.

The other open question is scaling. The precompute step handles hundreds or thousands of options fine. The vectors ship as JSON and the client only embeds the query. But the BM25 index builds synchronously at init, and the fused re-rank is O(n) per keystroke. That is instant at 42 options, fine at a few hundred, and worth profiling at a thousand. Beyond that, the sparse channel probably needs an inverted index rather than a linear scan, and the dense channel might need approximate nearest-neighbor search rather than a full dot-product sweep. Neither is hard, but neither is built yet.


The component is a single ES module, plus a local metaphone encoder and an ONNX Runtime session for the static model, and the demo and source include both a policy-document picker and the 42-agency FOIA selector, along with the evaluation scripts behind every number in this post. If you are building forms where users know the concept but not the label, we would like to hear about the domain. The interesting question is always what the vectorText should be.