A relevance ranker for technical documentation, and the search UI @imqueue puts on top of it. Two files, and which one you want depends on what you are building:
| File | What it is | Lines |
|---|---|---|
ranker.js |
The engine. Query parsing, scoring, spelling correction, grouping. No DOM, no network, no @imqueue. Give it two JSON feeds and it answers queries. | ~2,800 |
search.js |
imqueue's browser UI. The ⌘K dialog, the /search/ page, the blog sidebar, the feed URLs, the analytics. Reads the engine off window.SearchRanker. |
~1,150 |
| Consumer | How it uses this repo |
|---|---|
| imqueue.org / imqueue.com | Submodule at vendor/search-ranker. Both files, concatenated engine-first, content-hashed and served as one /js/search.<hash>.js. |
@imqueue/mcp |
Submodule. ranker.js only, copied into dist/, running under Node and inside a Cloudflare Worker to answer the search_docs MCP tool. |
It exists as its own repo so that those two never drift. Before the split the MCP server carried its own ranker, and the two answered the same question differently — measurably so: on a 1,000-query corpus the site ranker placed a correct result in the top 6 for 99.5% of queries against the MCP ranker's 97.2%, and the gap was invisible because nothing compared them.
ranker.js is not tuned to imqueue.org's pages. It is tuned to the properties of technical
documentation, and imqueue.org's content is written to suit it. That direction is deliberate,
and it is what makes the engine portable: change the corpus and the ranker still behaves,
because nothing in it knows a URL, a package name or a heading.
The honest exception: the ~60 scoring constants were fitted against that corpus (four
labelled query sets, in the website repo's scripts/search-kpi/). Another corpus inherits
reasonable defaults, not optimal ones, and should refit.
What the engine does assume is structural rather than about @imqueue:
- Two populations with different query languages. Prose is searched by concept ("retry a failed call"); API symbols are searched by identifier ("watcherCheckDelay"). Stemming helps the first and actively breaks the second; prefix matching is essential for the second and produces noise in the first. So there are two retrievers behind one input, chosen per record.
- Prose indexed at heading-section granularity — a whole page is too coarse to say where the answer is, a sentence too small to score.
- Two tiers, so most queries can be answered before the prose corpus has arrived.
Both files are plain IIFEs, no build step, ES5-compatible syntax throughout — because they
are served to browsers directly and executed in a Worker where eval and new Function are
both forbidden.
ranker.js ends by publishing itself to whichever environment it finds:
var API = { parseQuery: parseQuery, prepare: prepare, /* … 14 names … */ };
if (typeof module !== "undefined" && module.exports) {
module.exports = API;
} else if (typeof window !== "undefined") {
window.SearchRanker = API;
}module is tested first, and that order is the whole trick: a browser has no module,
and a Worker bundling this file has no window. The single-file version of this ranker
branched on typeof document instead, which asked the wrong question and worked only because
the two answers happened to agree.
search.js then reads that global into locals at its own top level, so concatenation order
is load-bearing — engine first. It throws with an explanation if the engine is absent rather
than failing on the third keystroke.
The seam is asserted from both sides, so neither repo depends on the other having run:
imqueue.com/scripts/check-search-ranker.js checks that every name the UI reads is exported
and that the engine references no document; mcp/scripts/copy-ranker.mjs refuses an engine
that has grown a DOM or stopped exporting itself.
const ranker = require("./ranker.js"); // or a <script> tag, then window.SearchRanker
ranker.state.t1 = ranker.prepare(tier1Json); // records: pages, symbols, answers
ranker.state.t2 = ranker.prepareSections(tier2Json); // prose at heading granularity
const hits = ranker.search(ranker.parseQuery("how do i expose a method"));
// -> [{ score, record, section, external }, …] best first, above a relative and absolute floorstate.x1/state.x2 are an optional second corpus — a peer site — whose hits come back with
external: true and are ordered after every local hit. Leaving them null degrades to
local-only answers rather than failing.
The ranker does not carry a corpus. It reads four JSON feeds built by
scripts/lib/search-corpus.js
in the website repo:
/search-index.json every page, API symbol and question-shaped section — no bodies
/search-text.json the prose corpus at heading-section granularity
/search-peer-*.json the same two shapes for the other edition
Records are positional arrays, not objects, because the index is downloaded on every first search. So a field appended in the middle of a tuple does not throw and does not return nothing — it silently scores the wrong text.
Two independent declarations guard that:
FEED_Vhere says which shape this ranker reads;FEED_Vin the website's corpus generator says which shape it writes;scripts/check-search-index.jsfails the build when they disagree, or when a built feed carries a third value.
Bump FEED_V in the same change that alters a tuple, in both repos. It is deliberately
not one shared constant — a shared constant would agree with itself and assert nothing.
The MCP server fetches the feeds from the live site at runtime while its ranker is pinned to a commit here. A pinned-stale ranker reading today's feeds is exactly the failure this version number exists to make loud.
There is no test suite in this repo, on purpose: the ranker cannot be judged without a
corpus, and the corpus belongs to the website. The measurement harness lives in
imqueue.com/scripts/search-kpi/:
git clone --recurse-submodules https://github.com/imqueue/imqueue.com.git
cd imqueue.com && npm ci && npm run build:all
npm run kpi # THE KPI — 985 labels, one expected #1 per query
node scripts/search-kpi/gold.js --ref HEAD # this working copy against the pinned ranker, paired
npm run kpi:compare # the artificial tripwire against a ranker ref, query by queryEdit vendor/search-ranker/ranker.js inside that clone — the engine, not search.js, which
is imqueue.com's own UI half and exports nothing under Node. Measure, then commit in the
submodule and update the pointer in both consumers.
Three things the harness has already established, worth knowing before tuning:
- A delta is not a result until it is tested.
--refreports McNemar on P@1 and a paired bootstrap CI on MRR@target. Unpaired, P@1's standard error is about 1.6 points, so a two-point move is unfalsifiable; the same move as 27 gains against 1 loss is p < 0.0001. - A flat average hides mass churn. Read the per-query deltas, not the summary line. A change that moves the macro average by +0.1 while moving 300 queries is not an improvement, it is a different ranker.
- The artificial query set prefers a broken ranker on some signals — flattening the
element-weight hierarchy raises it and lowers everything real, because a third of it is
generated from prose. It is a tripwire for "can a page be found by its own title", never a
relevance measure. When it disagrees with
npm run kpi, the gold set wins.
GPL-3.0, matching every other repo in the organisation.