Skip to content

Commit e6bc621

Browse files
committed
feat(search): score the curated description and keywords front matter
The corpus is built from the markdown mirrors, and the mirrors carry `# Title` plus a Source/Published/Author block and nothing else — front matter is not in them by design. So the two most deliberately curated relevance signals on this site were absent from its own search: `description` was ignored in favour of each page's first paragraph, and `keywords` was unused entirely. 186 curated phrases across 29 posts, 140 of which appear nowhere in the indexed text. The consequence, measured as the rank of the page each phrase was WRITTEN for: handle traffic spikes microservices absent of 32 -> #1 nodejs backpressure microservices absent of 32 -> #5 overload resilience absent -> #1 message queue throughput #32 of 134 -> #3 nodejs job queue #36 of 93 -> #3 imqueue benchmark #2 of 140 -> #1 Weighted at 300 — BELOW emphasis — and scored on coverage alone: no density, no position, no phrase bonus. A keyword list is a handful of comma-separated phrases, so occurrences-per-token sits near 1.0 for anything that matches at all, and density would rank by brevity while rewarding padding; order in a comma list means nothing, so position would be noise. That placement is the point rather than caution for its own sake. Google has ignored <meta name="keywords"> since 2009 and Bing treats a stuffed one as a spam signal — because neither can trust the author. A first-party index can: the author is the site. What does not change is that a self-declared list is cheap to pad, so it sits under the signals that cost something to fake, and check:search-ranking asserts "safeDelivery" still ranks the symbol first — the failure mode meta keywords earned its reputation for is an identifier query returning articles that merely list the identifier. Plumbed through a build intermediate: src/search-frontmatter.11ty.js emits url -> {description, keywords} and the generator reads it and DELETES it, so it never ships. A template rather than a front-matter parse because mapping a source file back to its URL means re-deriving permalink resolution, which Eleventy owns and which this repo has already been bitten by duplicating; `item.url` cannot drift from what Eleventy published. Keyword text also feeds the vocabulary, so inflections in a keyword list get lemmas like any other word. Tier 1 grows 65.2 -> 67.5 KB gz on org, 0.9 -> 1.1 on com. One defect worth recording: the new record was first named `entry`, inside a loop whose variable is the directory entry. Shadowing it in the same block is a temporal-dead-zone error reported at the line ABOVE, which reads as readdir having failed.
1 parent f075504 commit e6bc621

4 files changed

Lines changed: 207 additions & 10 deletions

File tree

‎scripts/check-search-ranking.js‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,45 @@ if (!sendTop || sendName !== 'send') {
322322
pass(`"send" ranks ${sendTop.record.t} first among symbols — identifiers stay literal`);
323323
}
324324

325+
// ---- curated keywords ---------------------------------------------------------
326+
// `keywords:` front matter states which queries a page exists to answer, and it was absent
327+
// from the index entirely until this element existed — 140 of 186 curated phrases appeared
328+
// nowhere in the indexed text, so the page written for "nodejs backpressure microservices"
329+
// was not among that query's 32 results at all. Each case below is a measured before/after.
330+
const KEYWORD_CASES = [
331+
['handle traffic spikes microservices', '/blog/backpressure-nodejs-services/', 'was absent entirely'],
332+
['nodejs backpressure microservices', '/blog/backpressure-nodejs-services/', 'was absent entirely'],
333+
['overload resilience', '/blog/backpressure-nodejs-services/', 'was absent entirely'],
334+
['message queue throughput', '/blog/benchmarking-imqueue-throughput/', 'was #32'],
335+
['nodejs job queue', '/blog/imqueue-vs-bullmq/', 'was #36'],
336+
['imqueue benchmark', '/blog/benchmarking-imqueue-throughput/', 'was #2'],
337+
];
338+
339+
for (const [query, target, was] of KEYWORD_CASES) {
340+
const hits = run(query);
341+
const at = hits.findIndex((hit) => hit.record.u.split('#')[0] === target);
342+
343+
if (at === -1) {
344+
fail(`"${query}" does not reach ${target} at all (${was}) — curated keywords are not being scored`);
345+
} else if (at > 5) {
346+
fail(`"${query}" ranks ${target} at #${at + 1} (${was}); the page written for this phrase should be in the top few`);
347+
} else {
348+
pass(`"${query}" ranks its target page #${at + 1} (${was})`);
349+
}
350+
}
351+
352+
// The counterweight. Keywords are self-declared and cheap to pad, so the element sits
353+
// below emphasis and is scored on coverage alone. If it ever outgrew that, an identifier
354+
// query would start returning articles that merely LIST the identifier — which is the
355+
// failure mode <meta name="keywords"> earned its reputation for.
356+
const identifier = run('safeDelivery')[0];
357+
358+
if (!identifier || identifier.record.g !== 1) {
359+
fail(`"safeDelivery" no longer ranks a symbol first — keyword weight may have overtaken the reference`);
360+
} else {
361+
pass('"safeDelivery" still ranks the symbol first — keywords cannot outrank reference');
362+
}
363+
325364
// ---- cross-site search -------------------------------------------------------
326365
// imqueue.org and imqueue.com search each other, reading the peer's index from their own
327366
// origin (scripts/copy-peer-index.js). Two properties matter, and they pull against each

‎scripts/lib/search-corpus.js‎

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,16 @@ function verifyAnchor(part, outputDir, url) {
389389
* knows which terms are stopwords), so the exclusion lives in one place rather than
390390
* being duplicated here as a second list that could drift.
391391
*/
392+
function vocabularyOf(text, into) {
393+
for (const word of String(text).toLowerCase().split(/[^a-z0-9]+/)) {
394+
if (word) {
395+
into.add(word);
396+
}
397+
}
398+
399+
return into;
400+
}
401+
392402
function lemmaMap(words) {
393403
const map = {};
394404

@@ -403,13 +413,38 @@ function lemmaMap(words) {
403413
return map;
404414
}
405415

416+
/**
417+
* Curated `description`/`summary` and `keywords` per URL, from the build intermediate
418+
* src/search-frontmatter.11ty.js writes.
419+
*
420+
* Deleted after reading: it duplicates facts already public in llms.txt and in each page's
421+
* meta description, and publishing a third copy would be a maintenance surface with no
422+
* consumer. Absent is legal — the corpus then falls back to first paragraphs, exactly as
423+
* it did before front matter was wired in.
424+
*/
425+
function takeFrontmatter(outputDir) {
426+
const file = path.join(outputDir, "search-frontmatter.json");
427+
428+
if (!fs.existsSync(file)) {
429+
return {};
430+
}
431+
432+
const data = JSON.parse(fs.readFileSync(file, "utf8"));
433+
434+
fs.unlinkSync(file);
435+
436+
return data;
437+
}
438+
406439
function buildCorpus(outputDir) {
407440
const docs = [];
408441
const faq = [];
409442
const pages = [];
410443
const sections = [];
411444
const vocabulary = new Set();
412445

446+
const frontmatter = takeFrontmatter(outputDir);
447+
413448
idCache.clear();
414449
unanchored = 0;
415450

@@ -442,25 +477,38 @@ function buildCorpus(outputDir) {
442477

443478
const parts = splitSections(mirror.body).map((part) => verifyAnchor(part, outputDir, mirror.url));
444479

445-
docs.push({
480+
const meta = frontmatter[mirror.url] || {};
481+
// Named `record`, not `entry`: the enclosing loop's variable is the directory entry,
482+
// and shadowing it in the same block is a temporal-dead-zone error at the line ABOVE
483+
// this one, which reads as the readdir having failed.
484+
const record = {
446485
g: 0,
447486
t: mirror.title,
448487
u: mirror.url,
449-
s: summarize(mirror.body),
488+
// The CURATED description wins over the first paragraph. It was written to say
489+
// what the page answers, in one sentence; a lead paragraph was written to be read
490+
// next. Falling back matters for the pages that have neither.
491+
s: meta.d ? summarize(meta.d) : summarize(mirror.body),
450492
k: groupFor(mirror.url),
451-
});
493+
};
494+
495+
// Curated keywords, as their own scoring element — see E.keywords in search.js. This
496+
// is the one signal no amount of body analysis can reconstruct: it is the author
497+
// stating which queries the page exists to answer.
498+
if (meta.k) {
499+
vocabularyOf(meta.k, vocabulary);
500+
record.w = meta.k;
501+
}
502+
503+
docs.push(record);
452504

453505
faq.push(...faqRecords(parts, mirror));
454506

455507
const pageIdx = pages.length;
456508

457509
pages.push([mirror.url, mirror.title, groupFor(mirror.url)]);
458510

459-
for (const word of `${mirror.title} ${mirror.body}`.toLowerCase().split(/[^a-z0-9]+/)) {
460-
if (word) {
461-
vocabulary.add(word);
462-
}
463-
}
511+
vocabularyOf(`${mirror.title} ${mirror.body}`, vocabulary);
464512

465513
for (const part of parts) {
466514
if (part.text) {
@@ -506,6 +554,7 @@ function buildCorpus(outputDir) {
506554
// headings and its markdown mirror have drifted apart.
507555
unanchored,
508556
vocabulary: vocabulary.size,
557+
keyworded: docs.filter((d) => d.w).length,
509558
lemmas: Object.keys(lemmas).length,
510559
// Stems a detachment produced, that the dictionary rejected, and that the corpus
511560
// uses as words: candidates for scripts/data/project-words.txt. Reported rather

‎src/_shared/js/search.js‎

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,13 @@
9494
title: 430,
9595
header: 360,
9696
emphasis: 200,
97+
// Curated `keywords` front matter: the author stating which queries this page exists to
98+
// answer. BELOW emphasis deliberately. Google has ignored <meta name="keywords"> since
99+
// 2009 and Bing treats a stuffed one as a spam signal — because neither can trust the
100+
// author. This index can: the author is the site. What does not change is that a
101+
// self-declared list is cheap to pad, so it sits under the signals that cost something
102+
// to fake and is scored on COVERAGE ONLY (see keywordScore).
103+
keywords: 300,
97104
body: 120,
98105
};
99106

@@ -641,6 +648,36 @@
641648
) + positionBonus(E.title, lower, q);
642649
}
643650

651+
/**
652+
* The keywords element. Coverage only — no density, no position, no phrase bonus.
653+
*
654+
* A keyword list is a handful of comma-separated phrases, so occurrences-per-token sits
655+
* near 1.0 for every page that matches at all: density would rank by list brevity, and
656+
* reward padding. Order in a comma list means nothing, so the position bonus would be
657+
* noise. What the list can honestly say is "these words describe this page", and coverage
658+
* is exactly that statement.
659+
*/
660+
function keywordScore(record, q) {
661+
if (!record._w) {
662+
return 0;
663+
}
664+
665+
var matched = 0;
666+
667+
for (var i = 0; i < q.terms.length; i++) {
668+
var hit = scanFor(record._w, q.terms[i], q.whole[i]);
669+
670+
if (!hit.n && q.lemmas[i]) {
671+
hit = scanFor(record._w, q.lemmas[i], true);
672+
}
673+
if (hit.n) {
674+
matched += q.weights[i];
675+
}
676+
}
677+
678+
return matched ? E.keywords * Math.min(1, q.weightSum ? matched / q.weightSum : 0) : 0;
679+
}
680+
644681
function scoreRecord(record, q) {
645682
if (q.filters.pkg && fold(record.p || "").indexOf(q.filters.pkg) === -1) {
646683
return 0;
@@ -658,7 +695,8 @@
658695

659696
for (var t = 0; t < q.terms.length; t++) {
660697
if (q.weights[t] === 1 &&
661-
(record._l.indexOf(q.terms[t]) !== -1 || record._s.indexOf(q.terms[t]) !== -1)) {
698+
(record._l.indexOf(q.terms[t]) !== -1 || record._s.indexOf(q.terms[t]) !== -1 ||
699+
(record._w && record._w.indexOf(q.terms[t]) !== -1))) {
662700
found++;
663701
}
664702
}
@@ -671,7 +709,9 @@
671709
// scored as a body — same weight a section's prose gets — so a symbol whose
672710
// description happens to use the query words cannot outrank the symbol named
673711
// after them.
674-
var score = titleScore(record, q) + elementScore(E.body, record._s, record._sn, q, "");
712+
var score = titleScore(record, q) +
713+
keywordScore(record, q) +
714+
elementScore(E.body, record._s, record._sn, q, "");
675715

676716
if (score < MIN_SCORE) {
677717
return 0;
@@ -770,6 +810,7 @@
770810
// splitting on whitespace is within a token or two of idTokens() here and this
771811
// runs over 1,325 records at load.
772812
r._sn = r._s ? r._s.split(" ").length : 0;
813+
r._w = r.w ? fold(r.w) : "";
773814
}
774815

775816
return index;

‎src/search-frontmatter.11ty.js‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// Emits /search-frontmatter.json — every page's curated `description` and `keywords`,
2+
// keyed by URL, for scripts/lib/search-corpus.js to fold into the search index.
3+
//
4+
// A BUILD INTERMEDIATE, not a published feed: the generator reads it and deletes it, so
5+
// it never reaches the deployed site. That is deliberate — it duplicates data already
6+
// public in llms.txt and in each page's meta description, and one more machine-readable
7+
// index of the same facts is a maintenance surface with no consumer.
8+
//
9+
// WHY IT HAS TO EXIST AT ALL
10+
//
11+
// The search corpus is built from the markdown MIRRORS (see search-corpus.js for why),
12+
// and the mirrors carry `# Title` plus a Source/Published/Author block and nothing else.
13+
// Front matter is not in them by design. So the two most deliberately curated relevance
14+
// signals on the site were absent from its own search:
15+
//
16+
// * `description` — the corpus used each page's first paragraph as its summary instead
17+
// * `keywords` — unused entirely. 186 curated phrases across 29 posts, 140 of which
18+
// appear nowhere in the indexed text, so the page written for
19+
// "nodejs backpressure microservices" did not appear in that query's
20+
// 32 results at all
21+
//
22+
// WHY A TEMPLATE AND NOT A FRONT-MATTER PARSE
23+
//
24+
// The generator runs post-build over `_site-*`, and mapping a source file back to its URL
25+
// means re-deriving permalink resolution — which Eleventy owns, and which this repo has
26+
// already been bitten by duplicating (see the two `search-index.11ty.js` headers). Asking
27+
// Eleventy for `item.url` cannot drift from what Eleventy actually published.
28+
//
29+
// `/api/` is excluded: those descriptions are generated from the source's own doc comments
30+
// by scripts/lib/api-summary.js, and /api/search-index.json already carries them.
31+
32+
module.exports = class SearchFrontmatter {
33+
data() {
34+
return {
35+
permalink: "/search-frontmatter.json",
36+
eleventyExcludeFromCollections: true,
37+
};
38+
}
39+
40+
render(data) {
41+
const pages = {};
42+
43+
for (const item of data.collections.all || []) {
44+
const url = item.url || "";
45+
46+
if (!url || url.startsWith("/api/")) {
47+
continue;
48+
}
49+
50+
const description = item.data.description || "";
51+
// `summary` is the blog's longer form; a post has both, and the longer one names
52+
// more of what the post actually answers.
53+
const summary = item.data.summary || "";
54+
const keywords = item.data.keywords || "";
55+
56+
if (!description && !keywords && !summary) {
57+
continue;
58+
}
59+
60+
pages[url] = {
61+
d: String(summary || description).replace(/\s+/g, " ").trim(),
62+
k: String(keywords).replace(/\s+/g, " ").trim(),
63+
};
64+
}
65+
66+
return JSON.stringify(pages);
67+
}
68+
};

0 commit comments

Comments
 (0)