The demo below ranks four short documents against your query using both raw term frequency (TF) and BM25. Type a word that appears frequently in some documents, then watch how the two methods disagree — BM25 is less impressed by sheer repetition and more careful about document length.
<!-- BM25 vs raw TF demo: rank four documents by query word(s) -->
<div class="controls">
<input id="query" type="text" placeholder="Type a query word..." value="search" />
<div class="sliders">
<label>k<sub>1</sub> = <span id="k1val">1.2</span>
<input id="k1" type="range" min="0" max="3" step="0.1" value="1.2" title="Term frequency saturation (higher = closer to raw TF)" />
</label>
<label>b = <span id="bval">0.75</span>
<input id="b" type="range" min="0" max="1" step="0.05" value="0.75" title="Length normalization (0 = off, 1 = full)" />
</label>
</div>
</div>
<div id="results" class="results"></div>
<p class="legend">Documents sorted by BM25 score (dark bar). Grey bar = raw TF. Try 'search' or 'algorithm'.</p>
/* Base layout styles */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; padding: .5rem; }
.controls { display: flex; flex-direction: column; gap: .5rem; margin-bottom: .8rem; }
input[type="text"] { font: 15px system-ui; padding: .4rem .7rem; border: 1px solid #cdd; border-radius: 8px; width: 100%; }
.sliders { display: flex; gap: 1rem; flex-wrap: wrap; }
label { font-size: .85rem; color: #555; display: flex; flex-direction: column; gap: .2rem; }
input[type="range"] { width: 130px; }
/* Result card styles */
.results { display: flex; flex-direction: column; gap: .55rem; }
.doc-card { border-radius: 10px; padding: .55rem .8rem; border: 1px solid #dde; background: #f8fafc; }
.doc-title { font-weight: 700; font-size: .9rem; margin-bottom: .25rem; }
.bar-row { display: flex; align-items: center; gap: .5rem; font-size: .8rem; margin: .18rem 0; }
.bar-label { width: 46px; color: #666; flex-shrink: 0; }
.bar-track { flex: 1; height: 10px; background: #e4e8f0; border-radius: 5px; overflow: hidden; }
.bar-fill { height: 100%; border-radius: 5px; transition: width .3s; }
.bar-tf { background: #94a3b8; }
.bar-bm25 { background: #1d3557; }
.bar-val { width: 40px; text-align: right; color: #444; }
.legend { font-size: .78rem; color: #777; margin-top: .6rem; line-height: 1.5; }
/* BM25 scoring implementation */
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
/* Small corpus: doc A is short+focused, B is medium, C is long+repetitive, D is a non-match */
const CORPUS = [
{ id: 'A', title: 'A – Focused article',
text: 'search algorithm search ranking search' },
{ id: 'B', title: 'B – Medium article',
text: 'a search engine uses a ranking algorithm to order results the search algorithm assigns each document a score based on term frequency' },
{ id: 'C', title: 'C – Long repetitive doc',
text: 'search is a fundamental task in computing when you search for information you initiate a search query the search engine processes the search request and returns results search is used everywhere from web search to database search to search in file systems and search in emails search results depend on the search index which stores search terms and document references so that search can be performed quickly and each search can be ranked' },
{ id: 'D', title: 'D – Off-topic article',
text: 'the quick brown fox jumps over the lazy dog an irrelevant sentence with no query words at all' }
];
/* Split text into lowercase tokens */
function tokenize(text) {
return text.toLowerCase().split(/\s+/).filter(Boolean);
}
/* Raw term frequency: count occurrences of term in token list */
function rawTF(tokens, term) {
return tokens.filter(t => t === term).length;
}
/* Robertson-Sparck Jones IDF variant used in BM25 */
function idf(N, df) {
return Math.log((N - df + 0.5) / (df + 0.5) + 1);
}
/* BM25 score for a document given query terms, k1, b, and average document length */
function scoreBM25(tokens, query, k1, b, avgdl) {
const dl = tokens.length;
const N = CORPUS.length;
let score = 0;
for (const term of query) {
const df = CORPUS.filter(d => tokenize(d.text).includes(term)).length;
const tf = rawTF(tokens, term);
const numerator = tf * (k1 + 1);
const denominator = tf + k1 * (1 - b + b * dl / avgdl);
score += idf(N, df) * (numerator / denominator);
}
return score;
}
/* Raw TF score: sum of counts per query term (no saturation, no length norm) */
function scoreTF(tokens, query) {
return query.reduce((s, t) => s + rawTF(tokens, t), 0);
}
function render() {
const qRaw = document.getElementById('query').value.trim().toLowerCase();
const query = qRaw.split(/\s+/).filter(Boolean);
const k1 = parseFloat(document.getElementById('k1').value);
const b = parseFloat(document.getElementById('b').value);
document.getElementById('k1val').textContent = k1.toFixed(1);
document.getElementById('bval').textContent = b.toFixed(2);
const tokenized = CORPUS.map(d => ({ ...d, tokens: tokenize(d.text) }));
const avgdl = tokenized.reduce((s, d) => s + d.tokens.length, 0) / tokenized.length;
const scored = tokenized.map(d => ({
...d,
tf: query.length ? scoreTF(d.tokens, query) : 0,
bm25: query.length ? scoreBM25(d.tokens, query, k1, b, avgdl) : 0
}));
/* Sort by BM25 descending for display */
const byBM25 = [...scored].sort((a, b2) => b2.bm25 - a.bm25);
const maxTF = Math.max(...scored.map(d => d.tf), 0.001);
const maxBM25 = Math.max(...scored.map(d => d.bm25), 0.001);
const container = document.getElementById('results');
container.innerHTML = '';
for (const d of byBM25) {
const card = document.createElement('div');
card.className = 'doc-card';
const tfPct = (d.tf / maxTF) * 100;
const bm25Pct = (d.bm25 / maxBM25) * 100;
card.innerHTML =
`<div class="doc-title">${d.title}</div>` +
`<div class="bar-row"><span class="bar-label">TF</span>` +
`<div class="bar-track"><div class="bar-fill bar-tf" style="width:${tfPct.toFixed(1)}%"></div></div>` +
`<span class="bar-val">${d.tf.toFixed(1)}</span></div>` +
`<div class="bar-row"><span class="bar-label">BM25</span>` +
`<div class="bar-track"><div class="bar-fill bar-bm25" style="width:${bm25Pct.toFixed(1)}%"></div></div>` +
`<span class="bar-val">${d.bm25.toFixed(2)}</span></div>`;
container.appendChild(card);
}
}
document.getElementById('query').addEventListener('input', render);
document.getElementById('k1').addEventListener('input', render);
document.getElementById('b').addEventListener('input', render);
render();
<!-- BM25 vs raw TF demo: rank four documents by query word(s) -->
<div class="controls">
<input id="query" type="text" placeholder="Type a query word..." value="search" />
<div class="sliders">
<label>k<sub>1</sub> = <span id="k1val">1.2</span>
<input id="k1" type="range" min="0" max="3" step="0.1" value="1.2" title="Term frequency saturation (higher = closer to raw TF)" />
</label>
<label>b = <span id="bval">0.75</span>
<input id="b" type="range" min="0" max="1" step="0.05" value="0.75" title="Length normalization (0 = off, 1 = full)" />
</label>
</div>
</div>
<div id="results" class="results"></div>
<p class="legend">Documents sorted by BM25 score (dark bar). Grey bar = raw TF. Try 'search' or 'algorithm'.</p>
/* Base layout styles */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; padding: .5rem; }
.controls { display: flex; flex-direction: column; gap: .5rem; margin-bottom: .8rem; }
input[type="text"] { font: 15px system-ui; padding: .4rem .7rem; border: 1px solid #cdd; border-radius: 8px; width: 100%; }
.sliders { display: flex; gap: 1rem; flex-wrap: wrap; }
label { font-size: .85rem; color: #555; display: flex; flex-direction: column; gap: .2rem; }
input[type="range"] { width: 130px; }
/* Result card styles */
.results { display: flex; flex-direction: column; gap: .55rem; }
.doc-card { border-radius: 10px; padding: .55rem .8rem; border: 1px solid #dde; background: #f8fafc; }
.doc-title { font-weight: 700; font-size: .9rem; margin-bottom: .25rem; }
.bar-row { display: flex; align-items: center; gap: .5rem; font-size: .8rem; margin: .18rem 0; }
.bar-label { width: 46px; color: #666; flex-shrink: 0; }
.bar-track { flex: 1; height: 10px; background: #e4e8f0; border-radius: 5px; overflow: hidden; }
.bar-fill { height: 100%; border-radius: 5px; transition: width .3s; }
.bar-tf { background: #94a3b8; }
.bar-bm25 { background: #1d3557; }
.bar-val { width: 40px; text-align: right; color: #444; }
.legend { font-size: .78rem; color: #777; margin-top: .6rem; line-height: 1.5; }
/* BM25 scoring implementation */
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
/* Small corpus: doc A is short+focused, B is medium, C is long+repetitive, D is a non-match */
const CORPUS = [
{ id: 'A', title: 'A – Focused article',
text: 'search algorithm search ranking search' },
{ id: 'B', title: 'B – Medium article',
text: 'a search engine uses a ranking algorithm to order results the search algorithm assigns each document a score based on term frequency' },
{ id: 'C', title: 'C – Long repetitive doc',
text: 'search is a fundamental task in computing when you search for information you initiate a search query the search engine processes the search request and returns results search is used everywhere from web search to database search to search in file systems and search in emails search results depend on the search index which stores search terms and document references so that search can be performed quickly and each search can be ranked' },
{ id: 'D', title: 'D – Off-topic article',
text: 'the quick brown fox jumps over the lazy dog an irrelevant sentence with no query words at all' }
];
/* Split text into lowercase tokens */
function tokenize(text) {
return text.toLowerCase().split(/\s+/).filter(Boolean);
}
/* Raw term frequency: count occurrences of term in token list */
function rawTF(tokens, term) {
return tokens.filter(t => t === term).length;
}
/* Robertson-Sparck Jones IDF variant used in BM25 */
function idf(N, df) {
return Math.log((N - df + 0.5) / (df + 0.5) + 1);
}
/* BM25 score for a document given query terms, k1, b, and average document length */
function scoreBM25(tokens, query, k1, b, avgdl) {
const dl = tokens.length;
const N = CORPUS.length;
let score = 0;
for (const term of query) {
const df = CORPUS.filter(d => tokenize(d.text).includes(term)).length;
const tf = rawTF(tokens, term);
const numerator = tf * (k1 + 1);
const denominator = tf + k1 * (1 - b + b * dl / avgdl);
score += idf(N, df) * (numerator / denominator);
}
return score;
}
/* Raw TF score: sum of counts per query term (no saturation, no length norm) */
function scoreTF(tokens, query) {
return query.reduce((s, t) => s + rawTF(tokens, t), 0);
}
function render() {
const qRaw = document.getElementById('query').value.trim().toLowerCase();
const query = qRaw.split(/\s+/).filter(Boolean);
const k1 = parseFloat(document.getElementById('k1').value);
const b = parseFloat(document.getElementById('b').value);
document.getElementById('k1val').textContent = k1.toFixed(1);
document.getElementById('bval').textContent = b.toFixed(2);
const tokenized = CORPUS.map(d => ({ ...d, tokens: tokenize(d.text) }));
const avgdl = tokenized.reduce((s, d) => s + d.tokens.length, 0) / tokenized.length;
const scored = tokenized.map(d => ({
...d,
tf: query.length ? scoreTF(d.tokens, query) : 0,
bm25: query.length ? scoreBM25(d.tokens, query, k1, b, avgdl) : 0
}));
/* Sort by BM25 descending for display */
const byBM25 = [...scored].sort((a, b2) => b2.bm25 - a.bm25);
const maxTF = Math.max(...scored.map(d => d.tf), 0.001);
const maxBM25 = Math.max(...scored.map(d => d.bm25), 0.001);
const container = document.getElementById('results');
container.innerHTML = '';
for (const d of byBM25) {
const card = document.createElement('div');
card.className = 'doc-card';
const tfPct = (d.tf / maxTF) * 100;
const bm25Pct = (d.bm25 / maxBM25) * 100;
card.innerHTML =
`<div class="doc-title">${d.title}</div>` +
`<div class="bar-row"><span class="bar-label">TF</span>` +
`<div class="bar-track"><div class="bar-fill bar-tf" style="width:${tfPct.toFixed(1)}%"></div></div>` +
`<span class="bar-val">${d.tf.toFixed(1)}</span></div>` +
`<div class="bar-row"><span class="bar-label">BM25</span>` +
`<div class="bar-track"><div class="bar-fill bar-bm25" style="width:${bm25Pct.toFixed(1)}%"></div></div>` +
`<span class="bar-val">${d.bm25.toFixed(2)}</span></div>`;
container.appendChild(card);
}
}
document.getElementById('query').addEventListener('input', render);
document.getElementById('k1').addEventListener('input', render);
document.getElementById('b').addEventListener('input', render);
render();
Notice that a very long document stuffed with your query word rises to the top under raw TF but gets pulled back down by BM25's length penalty. Drag the k1 slider to see saturation kick in: at k1=0 every matching document gets the same TF score regardless of count; at high k1 BM25 behaves more like raw TF. Set b=0 to disable length normalization entirely.
Comments
Loading comments...