Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

18 Commits
 
 
 
 
 
 
 
 
 
 

Repository files navigation

@imqueue search-ranker

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.

Which way the optimisation runs

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.

Two environments, one engine

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.

Using the engine somewhere else

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 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.

Two independent declarations guard that:

  • FEED_V here says which shape this ranker reads;
  • FEED_V in the website's corpus generator says which shape it writes;
  • scripts/check-search-index.js fails 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.

Working on it

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 query

Edit 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. --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