Lesson 11 / 23
Relevance Scoring
The scorer is built from three components — term frequency, inverse document frequency, and field length — and which component a document's position comes from is shown one by one; precision and recall are defined here, and the measurement shows ranking raising precision from 0.353 to 0.640 while hitting a ceiling on a measure the model cannot see.
Contents
The previous lesson always computed score with a placeholder formula: first satisfied-condition count, then term frequency divided by field length. Both produced an ordering, but both skipped half the question. A term occurring in a document carries no meaning without knowing how rare that term is across the corpus: the word “and” occurs in every one of the catalog’s 6,000 summaries and distinguishes nothing, “astronomy” occurs in about two hundred summaries and nearly determines the document by itself.
Relevance score is built from three components. Term frequency is how many times the term occurs in the document, and it saturates — a fifth occurrence says less than the first. Inverse document frequency is the term’s rarity across the corpus; a rare term carries much weight, a common term carries little. Field length normalization distinguishes a match in a three-word title from a match in a fourteen-word summary. This lesson builds the scorer, shows which component a document’s position comes from, and defines two measures for how correct the result is.
Corpus, Model, and Requirement
The corpus is the previous lessons’ module: 6,000 book records, seed 271828,
deterministic generation. QR8 (assumption): the scored fields are title and
summary; there is no field weight, the two are summed one-to-one. The term frequency
saturation constant is 1.2, the field length normalization constant is 0.75; inverse
document frequency is found from the logarithm of the ratio between corpus size and
document frequency. QR9 (assumption): the reader’s requirement is broader than the
query — they are looking for “a children’s book tagged story, in english”, but type only
“children story” into the search box. The tag, language, and year fields are not
indexed in this lesson; the scorer cannot see the requirement’s language criterion. This
is the ordinary state of affairs in a real catalog.
// corpus.mjs — library catalog corpus and position-aware inverted index. // Seed 271828, 6000 documents; every measurement in the topic shares this module. export const SEED = 271828, N = 6000; let c = SEED; // visible seed, deterministic generator const r = () => { c = (c + 0x6d2b79f5) | 0; let t = Math.imul(c ^ (c >>> 15), 1 | c); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; const pick = (d, e = 1) => d[Math.floor(r() ** e * d.length)]; // e>1: early items frequent, late items sparse const ADJECTIVE = ["critical", "short", "illustrated", "selected", "comparative"]; const GENRE = ["essay", "story", "novel", "study", "anthology"]; const CATEGORY = ["children", "history", "science", "philosophy", "society"]; const SUFFIX = ["selection", "book", "compilation", "series"]; const QUALIFIER = ["comprehensive", "concise", "introductory", "contentious"]; const SUBJECT = ["education", "migration", "city", "memory", "nature", "music", "law", "identity", "labor", "family", "war", "tradition", "health", "travel", "architecture", "archaeology", "seafaring", "astronomy"]; const AUTHOR = ["James Miller", "Anna Clarke", "Peter Brooks", "Laura Bennett", "Henry Cole"]; const LANGUAGE = ["english", "german", "french"]; export const CATALOG = []; for (let i = 1; i <= N; i += 1) { const s = pick(ADJECTIVE, 1.5), t = pick(GENRE, 1.5), a = pick(CATEGORY, 2), k = r(); const pattern = k < 0.18 ? `${s} ${t}` : k < 0.30 ? `${s} meets ${t}` : k < 0.40 ? `${s} and theoretical ${t}` : k < 0.50 ? `${t} and ${s} study` : k < 0.70 ? `${s} narrative piece` : k < 0.88 ? `a contemporary ${t}` : "review of literature"; CATALOG.push({ id: `K-${String(i).padStart(4, "0")}`, title: `${a} ${t} ${pick(SUFFIX)}`, summary: `${a} field: ${pattern}; readable on ${pick(SUBJECT, 3)} and ${pick(SUBJECT, 3)} as ` + `a ${pick(QUALIFIER)} ${pick(GENRE, 1.5)}.`, tag: [a, t], author: pick(AUTHOR), year: 1990 + Math.floor(r() * 35), language: pick(LANGUAGE, 2) }); } export const tokenize = (m) => m.toLocaleLowerCase("en-US").split(/[^\p{L}\p{N}]+/u).filter(Boolean); // Inverted index: "field|term" -> Map(document index -> positions); length: token count per document field. export function invertedIndex(fields) { const postings = new Map(), length = new Map(); CATALOG.forEach((b, i) => { for (const field of fields) { const tk = tokenize(String(b[field])); length.set(`${field}|${i}`, tk.length); tk.forEach((t, p) => { let list = postings.get(`${field}|${t}`); if (!list) postings.set(`${field}|${t}`, (list = new Map())); let positions = list.get(i); if (!positions) list.set(i, (positions = [])); positions.push(p); }); } }); return { postings, length }; }
Scorer
// score.mjs — relevance scorer. corpus.mjs is in the same directory. // Score comes from three components: term frequency (which saturates), inverse document // frequency, and field length normalization. import { CATALOG, SEED, invertedIndex } from "./corpus.mjs"; const { postings, length } = invertedIndex(["title", "summary"]); // tag, language, and year are not indexed const N = CATALOG.length, FIELDS = ["title", "summary"], K = 1.2, B = 0.75; const list = (field, term) => postings.get(`${field}|${term}`) ?? new Map(); const avgLength = Object.fromEntries(FIELDS.map((f) => { let t = 0; for (let d = 0; d < N; d += 1) t += length.get(`${f}|${d}`); return [f, t / N]; })); const idf = (field, term) => { const df = list(field, term).size; return Math.log(1 + (N - df + 0.5) / (df + 0.5)); }; function component(field, term, d, mode) { const tf = (list(field, term).get(d) ?? []).length; if (tf === 0) return 0; if (mode === "term frequency only") return tf; if (mode === "term x inverse document frequency") return tf * idf(field, term); const len = length.get(`${field}|${d}`) / avgLength[field]; // field length normalization return idf(field, term) * (tf * (K + 1)) / (tf + K * (1 - B + B * len)); } const scoreDoc = (d, mode) => TERM.reduce((p, t) => p + FIELDS.reduce((q, f) => q + component(f, t, d, mode), 0), 0); const TERM = ["children", "story"], MODES = ["term frequency only", "term x inverse document frequency", "full model"]; const candidate = [...Array(N).keys()].filter((d) => TERM.every((t) => FIELDS.some((f) => list(f, t).has(d)))); const relevant = new Set(candidate.filter((d) => CATALOG[d].tag.includes("children") && CATALOG[d].tag.includes("story") && CATALOG[d].language === "english")); console.log(`corpus ${N} documents, seed ${SEED}; question "children story"`); console.log(`candidate set ${candidate.length} documents; requirement: tagged children+story and english -> ${relevant.size} documents relevant`); for (const t of TERM) for (const f of FIELDS) console.log(` ${f}|${t}`.padEnd(21) + `document frequency ${String(list(f, t).size).padStart(4)} ` + `inverse document frequency ${idf(f, t).toFixed(3)} average field length ${avgLength[f].toFixed(2)}`); const rankedBy = (mode) => [...candidate].sort((x, y) => scoreDoc(y, mode) - scoreDoc(x, mode) || x - y); const full = rankedBy("full model"); console.log("score components: first and five-hundredth position"); for (const d of [full[0], full[499]]) { const parts = []; for (const t of TERM) for (const f of FIELDS) { const k = component(f, t, d, "full model"); if (k > 0) parts.push(`${f}|${t} ${k.toFixed(2)} (frequency ${(list(f, t).get(d) ?? []).length}, length ${length.get(`${f}|${d}`)})`); } console.log(` ${String(full.indexOf(d) + 1).padStart(3)}. ${CATALOG[d].id} score ${scoreDoc(d, "full model").toFixed(2)} = ${parts.join(" + ")}`); } const precision = (l, k) => l.slice(0, k).filter((d) => relevant.has(d)).length / Math.min(k, l.length); const recall = (l, k) => l.slice(0, k).filter((d) => relevant.has(d)).length / relevant.size; console.log("ranking model".padEnd(36) + "distinct scores top ten shared largest movement precision@10 precision@50"); for (const mode of MODES) { const l = rankedBy(mode); const values = new Set(candidate.map((d) => scoreDoc(d, mode).toFixed(6))).size; const shared = l.slice(0, 10).filter((d) => full.slice(0, 10).includes(d)).length; const movement = Math.max(...candidate.map((d) => Math.abs(l.indexOf(d) - full.indexOf(d)))); console.log(mode.padEnd(36) + String(values).padStart(11) + String(shared).padStart(14) + String(movement).padStart(17) + precision(l, 10).toFixed(3).padStart(13) + precision(l, 50).toFixed(3).padStart(13)); } console.log(`unranked set: precision ${(relevant.size / candidate.length).toFixed(3)}, recall 1.000`); for (const k of [10, 50, 200, 567, 984]) console.log(` top ${String(k).padStart(3)} results: precision ${precision(full, k).toFixed(3)} recall ${recall(full, k).toFixed(3)}`); const wrong = full.slice(0, 50).filter((d) => !relevant.has(d)); console.log(`the first of the ${wrong.length} wrong documents in the top 50 is ${CATALOG[wrong[0]].id}: ` + `${CATALOG[wrong[0]].title}, language ${CATALOG[wrong[0]].language}, tag ${CATALOG[wrong[0]].tag.join("+")}`);
corpus 6000 documents, seed 271828; question "children story"
candidate set 984 documents; requirement: tagged children+story and english -> 347 documents relevant
title|children document frequency 2678 inverse document frequency 0.807 average field length 3.00
summary|children document frequency 2678 inverse document frequency 0.807 average field length 14.02
title|story document frequency 1271 inverse document frequency 1.552 average field length 3.00
summary|story document frequency 1869 inverse document frequency 1.166 average field length 14.02
score components: first and five-hundredth position
1. K-0078 score 4.83 = title|children 0.81 (frequency 1, length 3) + summary|children 0.83 (frequency 1, length 13) + title|story 1.55 (frequency 1, length 3) + summary|story 1.64 (frequency 2, length 13)
500. K-3736 score 3.17 = title|children 0.81 (frequency 1, length 3) + summary|children 0.81 (frequency 1, length 14) + title|story 1.55 (frequency 1, length 3)
ranking model distinct scores top ten shared largest movement precision@10 precision@50
term frequency only 3 3 468 0.700 0.640
term x inverse document frequency 4 3 342 0.700 0.640
full model 10 10 0 0.600 0.640
unranked set: precision 0.353, recall 1.000
top 10 results: precision 0.600 recall 0.017
top 50 results: precision 0.640 recall 0.092
top 200 results: precision 0.615 recall 0.354
top 567 results: precision 0.612 recall 1.000
top 984 results: precision 0.353 recall 1.000
the first of the 18 wrong documents in the top 50 is K-0171: children story selection, language french, tag children+story
Where a Position Comes From
The first table gives the source of the components. “Children” occurs in 2,678 documents and its inverse document frequency is 0.807; “story” occurs in 1,271 book titles and carries 1.552. The same term occurs in 1,869 summaries, and there it is only 1.166 — the same word’s weight changes from field to field, because rarity is measured within the field.
The first-position document, K-0078, has a score of 4.83 and is the sum of four components, each greater than zero. Of that score, 3.19 — 66% — comes from the term “story”; “children” contributes 1.64. What determines order is the query’s rare term. The five-hundredth-position document, K-3736, has the same three components and its only difference is that the fourth component is missing: “story” never occurs in its summary. The absence of one component costs 1.66 points and 499 positions. Both are children’s story books in the catalog; the only difference is that one’s summary mentions the genre twice.
Field length’s contribution is smaller but visible. K-0078’s summary is 13 tokens,
K-3736’s is 14; the average is 14.02. This one-token difference lowers the
summary|children component from 0.83 to 0.81. In the title field, average length is
exactly 3.00, because every title is three words, so the normalization has no effect
there, so field length distinguishes nothing where every title is the same length;
normalization only works where length varies.
Three Models, Three Orders
The model table measures what the components do to order. With term frequency alone, the 984 candidate documents take only 3 distinct score values: the list splits into three piles and order within a pile falls to document number. Once inverse document frequency is added, the value count rises to 4; once field length normalization is also added, it rises to 10. As resolution rises, ties get broken and the list is reshuffled. Against the full model, the largest position movement is 468 in the term-frequency-only model, 342 in the intermediate model — in a 984-document list, this means a document moves from the middle to the front.
In the top ten results, the three models share only 3 documents; the full model’s top-ten precision is 0.600, below the other two models’ 0.700. This does not show the full model is worse: in the top fifty all three models come to 0.640, and the difference is noise from a measure the model cannot see. Choosing a model from a ten-result sample misuses the measure itself.
Precision and Recall
Two measures ask how correct the result is from different directions. Precision is how much of the returned result is relevant: it penalizes a document wrongly returned. Recall is how much of the relevant documents were returned: it penalizes a document missed. The same pair goes by other names in the same field elsewhere in this platform: in the Non-Functional Testing curriculum’s Static Application Security Testing lesson, a finding wrongly reported is a false positive, a real vulnerability never reported is a miss; precision measures false positives, recall measures misses.
The unranked set is 984 documents and holds 347 relevant documents: precision 0.353, recall 1.000. This is the typical balance of a Boolean query — no relevant document is missed, but two-thirds are unneeded. Ranking ties this balance to the cutoff point. In the top 50 results precision rises to 0.640, recall falls to 0.092. In the top 200, precision is 0.615, recall 0.354. At the top 567, recall reaches 1.000: all 347 relevant documents sit within the first 58% of the list, meaning the scorer has never pushed a relevant document to the bottom of the list.
Precision sticking around 0.64 is this lesson’s final finding. The first of the 18 wrong documents in the top 50 is K-0171, and its title is “children story selection” — exactly the kind of book the reader is looking for, only it is in French. The scorer has ranked it high correctly, since it is a textually flawless match; what fails the requirement sits in a field the scorer never sees. The rule is this: when a criterion sits in a field the scorer cannot see, no weight adjustment raises precision. The right tool is the previous lesson’s filter; a score adjustment only works in fields the model can see.
Summary
- Relevance score comes from three components: saturating term frequency, inverse document frequency, and field length normalization; the same term carries 1.552 weight in a book title, 1.166 in a summary.
- 66% of the first-position document’s 4.83 score comes from the query’s rare term; the absence of one component costs 1.66 points and 499 positions.
- Resolution rises as components are added: the 984 candidate documents take 3, 4, and 10 distinct score values; the largest position movement is 468.
- Precision is how much of the returned result is relevant, recall is how much of the relevant documents were returned; in the unranked set precision is 0.353 and recall is 1.000.
- Ranking raises precision to 0.640 in the top 50 results and carries recall to 1.000 by the top 567; but because the requirement’s language criterion is not indexed, precision hits a ceiling around 0.64.
Next Step
The scorer works, and which component a produced order comes from can now be seen. This does not mean the order is the wanted order. For a library, a match in a book’s title is a stronger signal than a match in its summary, yet in the current model the two are just summed one-to-one. Newer printings might be wanted to rank ahead of older ones, but publication year does not enter the score at all. Loan count, a quantity unrelated to the text, might enter the ranking too. All of these are outside interventions on the score’s components, each changing order without changing the set — like the should condition, but now with a measured coefficient. The next lesson builds these adjustments and counts how many documents each adjustment moves in the top ten results and by how many points it shifts precision.
To keep your progress and take notes, Log in
My notes
Log in to take notes.