Skip to content

Commit 82127d5

Browse files
committed
feat(search): the URL leads, but only for the words it adds
Element order is now URL > keywords > title > header > emphasis > body. The URL leads because a path is two to four words and a human chose every one — the argument that makes a title strong, applied to something terser. But only for the words it ADDS. A blog slug is generated from the title, so /blog/imqueue-vs-moleculer/ matching "imqueue" is the title matching twice; at the top weight that would quietly mean "titles count double" and demote every other element to pay for it. So urlScore splits: a term in the path AND the title is an echo worth less than the title it repeats (110), a term in the path and NOT in the title takes the top weight (480). /mcp/installation/ is titled "Add the MCP server to Claude, Cursor & VS Code" — "installation" exists nowhere on that page but its path, which is the case worth leading with. The reported symptom had nothing to do with weight, though. urlScore required a query term to EQUAL a path segment, so "installation mcp" ranked that page #1 and "install mcp" could not find it at all — one element doing exact string equality while every other element in the ranker matches substrings. A term is now credited when it equals a segment, is its lemma, or is a prefix of it from 5 characters up. keywords moves from 300 to 450, above title. At that weight the difference between "one of my declared phrases IS what you typed" and "your words appear somewhere in my list" is the whole value of the element, and without it the promotion did real damage: four blog comparison pages whose lists merely contain "imqueue" pushed the home page's own "What @imqueue is" heading from #1 to #9. A declared phrase now takes the full 450, word overlap takes 270 — near where the element sat before. /intro/ replaces / as the expected #1 for "what is imqueue", and it is a decision rather than a drift: /intro/ declares that literal phrase in its keywords and its title answers the question in a sentence. The home page is #2. The failure that case was written for is guarded by the stopword and bagScore checks, not by which of those two pages comes first. covers() now sees the path for exactly the records urlScore will score, and not for the API records it returns 0 on. Yesterday's bug was a floor rejecting what the scorer would have ranked first; crediting a path the scorer ignores is the same bug mirrored. how to install mcp #5 -> #1 install mcp absent from the top -> #1 what is imqueue /intro/ #1, / #2 (was / #1, /intro/ #2) imqueue vs moleculer its own article still #1 — no slug double count Also fixes a stale comment: the keywords element claimed to sit "BELOW emphasis deliberately" while its value was 300 and emphasis was 200. Full suite green on both editions; six new ranking checks.
1 parent 309c052 commit 82127d5

2 files changed

Lines changed: 177 additions & 30 deletions

File tree

‎scripts/check-search-ranking.js‎

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,15 @@ const CASES = [
6262
regression: 'none yet — this is the case KIND_BONUS exists for',
6363
},
6464
{
65+
// Was '/' until the element order changed to put curated `keywords` above `title`.
66+
// /intro/ declares the literal phrase "what is imqueue" in its keywords and its title
67+
// answers the question in a sentence — "@imqueue is a message-queue RPC framework for
68+
// Node.js and TypeScript back-ends" — so it wins on the signal that was promoted, which
69+
// is the promotion working rather than a regression. The home page is #2 and the failure
70+
// this case was written for has not come back: see the stopword and bagScore checks
71+
// below, which are what actually guard it.
6572
query: 'what is imqueue',
66-
url: '/',
73+
url: '/intro/',
6774
protects: 'the unordered whole-query match (bagScore) and stopword weighting',
6875
regression: '"Can I use @imqueue alongside gRPC or NATS?" was first: stopword removal reduced the query to "imqueue", thirty records tied, and the tie-break was URL length',
6976
},
@@ -575,6 +582,65 @@ if (!df || !ranker.state.t2.docs) {
575582
}
576583
}
577584

585+
// ---- the URL element ----------------------------------------------------------
586+
// A path word the title does NOT have is the strongest element on the page, because it is
587+
// the only place that word exists. /mcp/installation/ is titled "Add the MCP server to
588+
// Claude, Cursor & VS Code" — "installation" appears nowhere but the path.
589+
//
590+
// This failed for a reason that had nothing to do with weight: urlScore required a query
591+
// term to EQUAL a segment, so "installation mcp" ranked the page #1 and "install mcp" could
592+
// not find it at all, while every other element in the ranker matched substrings.
593+
{
594+
const cases = [
595+
['how to install mcp', '/mcp/installation/'],
596+
['install mcp', '/mcp/installation/'],
597+
['installation mcp', '/mcp/installation/'],
598+
];
599+
600+
for (const [query, url] of cases) {
601+
const hits = ranker.search(ranker.parseQuery(query));
602+
const at = hits.findIndex((hit) => hit.record.u === url);
603+
604+
if (at !== 0) {
605+
fail(`"${query}" ranks ${url} ${at === -1 ? 'nowhere' : `#${at + 1}`}, expected #1`);
606+
} else {
607+
pass(`"${query}" ranks ${url} #1`);
608+
}
609+
}
610+
611+
// The other half: a path that merely echoes its own title must NOT be promoted. A blog
612+
// slug is generated from the title, so scoring it at the top weight would silently mean
613+
// "titles count double" — and every non-slug element would be demoted to pay for it.
614+
const echo = ranker.search(ranker.parseQuery('imqueue vs moleculer'));
615+
const first = echo[0] && echo[0].record.u;
616+
617+
if (first !== '/blog/imqueue-vs-moleculer/') {
618+
fail(`"imqueue vs moleculer" ranks ${first} first, expected the article itself`);
619+
} else {
620+
pass('a slug that echoes its title still ranks its own article first, without a double count');
621+
}
622+
}
623+
624+
// ---- a declared query beats scattered overlap ---------------------------------
625+
// `keywords` sits above `title` now, and at that weight the difference between "one of my
626+
// declared phrases IS what you typed" and "your words appear somewhere in my list" is the
627+
// whole value of the element. Without the distinction, four blog comparison pages whose
628+
// lists merely contain "imqueue" pushed the home page's own "What @imqueue is" heading from
629+
// #1 to #9 for the query "what is imqueue".
630+
{
631+
const hits = ranker.search(ranker.parseQuery('what is imqueue'));
632+
const home = hits.findIndex((hit) => hit.record.u === '/');
633+
634+
if (home === -1 || home > 2) {
635+
fail(
636+
'the home page ranks ' + (home === -1 ? 'nowhere' : `#${home + 1}`) +
637+
' for "what is imqueue" — word overlap in a keywords list is outscoring a heading again'
638+
);
639+
} else {
640+
pass(`the home page holds #${home + 1} for "what is imqueue" behind /intro/, which declares the phrase`);
641+
}
642+
}
643+
578644
// ---- a page has to CLAIM the query -------------------------------------------
579645
// "is imqueue free" ranked /license/ #50, then #27, and the ranker was not the whole
580646
// story: /license/ never used the word "free" in its title, description or keywords, so

‎src/_shared/js/search.js‎

Lines changed: 110 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -97,27 +97,58 @@
9797
// must never overtake exact evidence ("the title IS the query"). With title at 620
9898
// it did — `IMQOptions.safeDeliveryTtl` scored 836 for the query "safeDelivery" and
9999
// `IMQOptions.safeDelivery`, whose last segment is exactly that, scored 708.
100+
// Element order: URL > keywords > title > header > emphasis > body.
101+
//
102+
// The URL leads because a path is two to four words and a human chose every one of them —
103+
// the same argument that makes a title strong, applied to something even terser. But it
104+
// only leads for the words it ADDS. A blog slug is generated from the title, so
105+
// /blog/imqueue-vs-moleculer/ matching "imqueue" is the title matching twice, not a second
106+
// piece of evidence; scoring that at the top weight would quietly mean "titles count
107+
// double" and demote every other element to pay for it.
108+
//
109+
// Split in two, therefore. A query term in the path AND in the title is an echo, worth
110+
// less than the title it repeats. A term in the path and NOT in the title is the case
111+
// worth the top weight: /mcp/installation/ is titled "Add the MCP server to Claude,
112+
// Cursor & VS Code", and the word "installation" exists nowhere on that page except its
113+
// path. Same for /pricing/, /get-started/ and /glossary/.
100114
var E = {
115+
// A path word the title does not have. See urlScore.
116+
urlNew: 480,
117+
// Curated `keywords` front matter: the author stating which queries this page exists to
118+
// answer. Just under the URL, as another deliberately-chosen and terse label — and above
119+
// title, which is a promotion: it used to sit at 300.
120+
//
121+
// Google has ignored <meta name="keywords"> since 2009 and Bing treats a stuffed one as
122+
// a spam signal, because neither can trust the author. This index can: the author is the
123+
// site. What does not change is that a self-declared list is cheap to pad, which is why
124+
// it is scored on COVERAGE ONLY — no density, no repetition bonus, so lengthening the
125+
// list buys nothing (see keywordScore).
126+
keywords: 450,
101127
title: 430,
102128
header: 360,
103129
emphasis: 200,
104-
// Curated `keywords` front matter: the author stating which queries this page exists to
105-
// answer. BELOW emphasis deliberately. Google has ignored <meta name="keywords"> since
106-
// 2009 and Bing treats a stuffed one as a spam signal — because neither can trust the
107-
// author. This index can: the author is the site. What does not change is that a
108-
// self-declared list is cheap to pad, so it sits under the signals that cost something
109-
// to fake and is scored on COVERAGE ONLY (see keywordScore).
110-
keywords: 300,
130+
// A path word the title already carries. Below body on purpose: it is not independent
131+
// evidence, and its only job now is to stop a path match counting as nothing at all.
132+
url: 110,
111133
body: 120,
112-
// The URL path. Weak, and standard in every search engine for a reason: a site's
113-
// canonical page on a subject usually has the subject in its path — /license/,
114-
// /pricing/, /get-started/, /glossary/. Scored on coverage AND on how much of the path
115-
// the query accounts for, so /license/ matching "licensing" counts as the whole
116-
// identity of that page while /blog/imqueue-vs-moleculer/ matching "imqueue" counts as
117-
// a quarter of it.
118-
url: 190,
119134
};
120135

136+
// A query term counts against a path segment when it is a PREFIX of it, from this length up.
137+
//
138+
// The URL element used to require the term to EQUAL the segment. That is why "install mcp"
139+
// could not find /mcp/installation/ while "installation mcp" ranked it #1 — the one element
140+
// that knew the word was doing exact string equality while every other element matched on
141+
// substrings. Weight and ordering had nothing to do with it.
142+
//
143+
// Bounded by length because a path segment is short and unanchored prefixes of three
144+
// letters collide with everything: `com` is a prefix of `commercial`, `con` of `contact`
145+
// and `contributing`. Five is past the point where that happens on this site's paths.
146+
var URL_PREFIX_MIN = 5;
147+
148+
// Share of the keywords weight that mere word overlap gets, as against a declared phrase.
149+
// 450 * 0.6 = 270, close to the 300 the whole element used to be worth.
150+
var KEYWORD_OVERLAP = 0.6;
151+
121152
// A section's PAGE title is context, not the section's own name, so it counts at a
122153
// fraction — otherwise every section of a page whose title matches outranks the
123154
// page itself and the list fills up with one document.
@@ -858,6 +889,21 @@
858889
return 0;
859890
}
860891

892+
// A DECLARED QUERY beats scattered overlap, and by a lot.
893+
//
894+
// The list is comma-separated phrases, and the difference between "one of these phrases
895+
// IS what you typed" and "your words appear somewhere among these phrases" is the whole
896+
// value of the element. Promoting it to 450 without this distinction rewarded the second
897+
// as if it were the first: /intro/ and /license/ both declare a real query, but so did
898+
// four blog comparison pages whose lists merely contain the word "imqueue" — and they
899+
// pushed the home page's own "What @imqueue is" heading from #1 to #9.
900+
//
901+
// Substring rather than equality, so "what is imqueue" matches the declared phrase
902+
// "@imqueue introduction, what is imqueue, …" wherever in the list it sits.
903+
if (q.joined.length > 3 && record._w.indexOf(q.joined) !== -1) {
904+
return E.keywords;
905+
}
906+
861907
var matched = 0;
862908

863909
for (var i = 0; i < q.terms.length; i++) {
@@ -878,15 +924,21 @@
878924
}
879925
}
880926

881-
return matched ? E.keywords * Math.min(1, q.weightSum ? matched / q.weightSum : 0) : 0;
927+
// Word overlap, at KEYWORD_OVERLAP of the declared-phrase weight — near where the whole
928+
// element sat before it was promoted, which is the right place for "these words appear in
929+
// my list somewhere".
930+
return matched
931+
? E.keywords * KEYWORD_OVERLAP * Math.min(1, q.weightSum ? matched / q.weightSum : 0)
932+
: 0;
882933
}
883934

884935
/**
885-
* The URL element. Coverage of the query, plus how much of the path the query covers.
936+
* The URL element. Coverage of the query, plus how much of the path the query covers,
937+
* scored TWICE: once for the path words the title does not have, once for the echoes.
886938
*
887-
* Whole-word matching on path segments, so "license" matches /license/ and not
888-
* /licenses-and-things/; segments are split on `-` too, so "get started" reaches
889-
* /get-started/.
939+
* Segments are split on `-` too, so "get started" reaches /get-started/, and a term is
940+
* credited when it equals a segment, is its lemma, or is a prefix of it from
941+
* URL_PREFIX_MIN characters up — which is what lets "install" reach /mcp/installation/.
890942
*/
891943
function urlScore(record, q) {
892944
// NOT for generated reference. A symbol's path is derived from its own name, so scoring
@@ -899,31 +951,54 @@
899951
}
900952

901953
var segments = record._u;
902-
var matched = 0;
954+
// Two accumulators, split by whether the title already carries the term.
955+
var fresh = 0;
956+
var echo = 0;
903957
var hitSegments = 0;
904958

905959
for (var i = 0; i < q.terms.length; i++) {
906-
var found = false;
960+
var weight = 0;
907961

908962
for (var j = 0; j < segments.length; j++) {
909-
if (segments[j] === q.terms[i] || segments[j] === q.lemmas[i]) {
910-
found = true;
963+
var segment = segments[j];
964+
var exact = segment === q.terms[i] || (q.lemmas[i] && segment === q.lemmas[i]);
965+
// A prefix is real evidence but weaker than an exact segment, and weighted the same
966+
// as the other inexact routes: "install" is not certainly "installation".
967+
var prefix = !exact && q.terms[i].length >= URL_PREFIX_MIN &&
968+
segment.length > q.terms[i].length && segment.indexOf(q.terms[i]) === 0;
969+
970+
if (exact || prefix) {
971+
weight = Math.max(weight, q.weights[i] * (exact ? 1 : PREFIX_WEIGHT));
911972
hitSegments++;
912973
}
913974
}
914-
if (found) {
915-
matched += q.weights[i];
975+
if (!weight) {
976+
continue;
977+
}
978+
// In the title too? Then the path is repeating it, and titleScore already said so.
979+
if (scanFor(record._l, q.terms[i], q.whole[i]).n ||
980+
(q.lemmas[i] && scanFor(record._l, q.lemmas[i], true).n)) {
981+
echo += weight;
982+
} else {
983+
fresh += weight;
916984
}
917985
}
918986

919-
if (!matched) {
987+
if (!fresh && !echo) {
920988
return 0;
921989
}
922990

923-
var coverage = matched / q.weightSum;
991+
// `focus` — how much of the PATH the query accounts for — is shared by both halves:
992+
// /license/ matching "licensing" is the whole identity of that page, while
993+
// /blog/imqueue-vs-moleculer/ matching "imqueue" is a quarter of it.
924994
var focus = Math.min(1, hitSegments / segments.length);
995+
var graded = function (matched, weight) {
996+
return matched
997+
? weight * (0.5 * (matched / q.weightSum) + 0.5 * focus)
998+
: 0;
999+
};
9251000

926-
return E.url * (0.5 * coverage + 0.5 * focus);
1001+
return graded(fresh, E.urlNew) + graded(echo, E.url);
9271002
}
9281003

9291004
/**
@@ -975,7 +1050,13 @@
9751050
return 0;
9761051
}
9771052

978-
var texts = [record._l, record._s, record._w];
1053+
// The path is included for exactly the records urlScore will score, and excluded for the
1054+
// ones it returns 0 for. Yesterday's bug was a floor that rejected what the scorer would
1055+
// have ranked first; crediting a path the scorer ignores would be the same bug mirrored.
1056+
var texts = [
1057+
record._l, record._s, record._w,
1058+
record.g === G_API || !record._u ? "" : record._u.join(" "),
1059+
];
9791060

9801061
// Coverage floor. Matching ONE term of a four-term question is not a result:
9811062
// "does @imqueue retry a failed call?" matched 57 answers and 151 sections,

0 commit comments

Comments
 (0)