Skip to content

Repository files navigation

@imqueue/search-ranker

A relevance ranker for technical documentation, and the search UI @imqueue puts on top of it. Two halves, and which one you want depends on what you are building:

Source What it is Lines
src/ranker/ The engine. Query parsing, scoring, spelling correction, grouping. No DOM, no network, no @imqueue. Give it two JSON feeds and it answers queries. Builds to dist/ranker.js. ~3,550
src/ui/ imqueue's browser UI. The ⌘K dialog, the /search/ page, the blog sidebar, the feed URLs, the analytics. Reads the engine off window.SearchRanker. Builds to dist/search.js. ~1,650
Consumer How it uses this repo
imqueue.org / imqueue.com Submodule at vendor/search-ranker. Runs npm ci && npm run build, then concatenates dist/ranker.js and dist/search.js engine-first, content-hashed and served as one /js/search.<hash>.js.
@imqueue/mcp Submodule. Imports src/ranker/ directly into its own TypeScript build, 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.

Building

npm ci
npm run build      # dist/ranker.js, dist/search.js, dist/ranker.mjs, dist/ranker.cjs, dist/types/
npm run check      # type-check, contract, and whether the answers moved

The build output is not committed. Both consumers build it — see the note in .gitignore for why a generated file in git is a second copy of the source that can disagree with it silently.

There is a build at all because there has to be. TypeScript 7 removed --outFile and the amd / system / umd / none module kinds, so tsc can emit one ES module per source file and nothing else; the single self-contained IIFE both consumers load has to come from a bundler. tsc type-checks and emits declarations, esbuild bundles. See build.mjs.

target is ES2023. TypeScript 7's lowest target is es2015, so the "ES5-compatible syntax throughout" this engine used to promise is no longer something a tsc-based build can offer at all. Nothing depended on ES5 in practice: the real constraints are that the engine touches no DOM and calls no eval or new Function — a Cloudflare Worker forbids both by policy — and npm run check:contract asserts each of those against the built file.

The engine cannot see the DOM, and that is now a compiler error

tsconfig.ranker.json compiles src/ranker/ without the dom lib. So document in the engine is a type error in this repository, before anything is published — rather than a grep over a built file in a consumer's CI, which is where that rule used to be enforced. The one intentional global touch, publishing to window.SearchRanker, declares its own narrow shape in src/ranker/global.ts.

src/ui/ is the only half compiled with DOM types, and it references the engine project, so the UI is type-checked against the engine's real export surface. That check used to be a hand-maintained list of names in imqueue.com/scripts/check-search-ranker.js.

Which way the optimisation runs

The engine 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. They live in src/ranker/constants.ts, except where a constant governs exactly one function — then it sits with that function, because the comment on each of them is a record of what was measured and is only readable beside the code it explains.

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.

Two environments, one engine

dist/ranker.js is an IIFE that publishes itself to whichever environment it finds:

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.

dist/package.json pins dist/ to "type": "commonjs", and it is load-bearing rather than tidy. The root package is "type": "module", and under that Node reads any .js below it as ESM — where module is not defined, window is not either, and the IIFE publishes to nothing while throwing no error at all. One file keeps require("./ranker.js") working exactly as it always did.

dist/search.js reads that global 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.

For a consumer that would rather import than load, dist/ranker.mjs and dist/ranker.cjs are the same engine as modules, with declarations in dist/types/.

Using the engine somewhere else

import * as ranker from "@imqueue/search-ranker";  // 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 floor

state.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 feed contract is versioned, and that is not decoration

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.

Three independent declarations guard that now:

  • src/ranker/types.ts gives every tuple a named tuple type, and the slot indices are const literals — so section[S_FOLDED] is a string and section[S_HEADTOK] is a string[]. A slot inserted mid-tuple stops compiling here rather than mis-scoring in somebody else's process. This is the check the rewrite was worth doing for.
  • FEED_V here says which shape this ranker reads;
  • FEED_V in the website's corpus generator says which shape it writes, and scripts/check-search-index.js fails the build when they disagree, or when a built feed carries a third value.

The types catch a slot moving in code that compiles against them; they cannot see a feed built by a generator that has moved on. FEED_V still does that, so bump it 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.

Adding a top-level key that older code ignores is the one exception, and it is a real one rather than a loophole: the MCP server deploys separately from the site, so a FEED_V bump takes the already deployed server down the moment the site ships, for however long it takes to publish and redeploy. e (below) was added under this exception. Moving or removing a key is not covered by it.

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.

ENGINE_V — the version FEED_V cannot be

FEED_V guards the feed's shape, and it has been 1 through every ranking change ever made to this engine. That is correct, and it is also why it never sees the failure that actually happens: two consumers pinning this repo by commit, and pinning different halves of it. The website takes both halves; the MCP server takes the engine alone. So the pins fall out of step on a commit to either half — in August 2026 they sat a fortnight apart with nothing red anywhere, harmlessly, because the divergent commit was in the UI. Harmless is the problem: a signal that cries wolf on a UI commit is one both repos learn to ignore.

ENGINE_V moves only when the answers move:

  • it is declared in src/ranker/constants.ts and exported on the API object;
  • the website stamps it into every feed as the top-level e, beside v;
  • scripts/check-search-index.js asserts the built feeds carry the vendored engine's value;
  • the MCP server compares e against its own bundled ENGINE_V at runtime and warns — it must never throw, because the site necessarily deploys before the server does;
  • check:ranker-engine, in both consumers' CI, compares the vendored value against this repo's master tip.

Bump it in any commit that changes a score, a constant, a tokenizer rule or a retrieval path. Never for src/ui/; never for a comment. .github/workflows/checks.yml fails a push that touches src/ranker/ without moving it, and takes [no-engine-change] in the commit subject as the documented, auditable way out for an edit that really is only prose.

Working on it

There is still no relevance 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 query

Edit vendor/search-ranker/src/ranker/ inside that clone — the engine, not src/ui/, which is imqueue.com's own UI half. Measure, then commit in the submodule and update the pointer in both consumers.

What this repo can check: whether the answers moved

npm run check:answers scores a deterministic synthetic corpus (scripts/corpus.mjs) and compares a digest of every result list against scripts/answers.snapshot.json.

This is not a relevance measure and must never be made into one — nothing in that corpus has a correct answer, because "queue worker cache" means nothing. It asks the other question, the one ENGINE_V is a claim about: did the answers change? The snapshot was generated from the hand-written ES5 engine before the TypeScript rewrite, and the rewrite reproduces it exactly, which is what makes the file worth keeping.

A failure is not automatically a bug — moving the answers is allowed, in a commit that bumps ENGINE_V and regenerates the snapshot:

npm run build && node scripts/snapshot.mjs

git diff on the result is then the list of queries whose answers changed, which is the review this repository actually wants. Regenerating it to turn a red check green is the check deleting itself, and it will not say so out loud.

Three things the KPI harness has already established, worth knowing before tuning:

  • A delta is not a result until it is tested. --ref reports 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.

Licence

GPL-3.0, matching every other repo in the organisation.

About

The search ranker shared by the @imqueue documentation site and the @imqueue/mcp server.

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages