The demo below pre-loads four short documents. Click Build index to tokenize them and construct the postings lists. Then type one or more words in the query box and click Search — the engine intersects the relevant lists and highlights every matching document.
<!-- Main layout: toolbar (build + show-postings toggle), doc cards, index panel, query row, status -->
<div class="toolbar">
<button id="btn-build" type="button">Build index</button>
<label class="show-label">
<input type="checkbox" id="chk-postings"> Show postings
</label>
</div>
<div id="docs-area" class="docs-area"></div>
<div id="index-area" class="index-area hidden"></div>
<div class="query-row">
<input id="query-input" type="text" placeholder="e.g. fast search">
<button id="btn-search" type="button" disabled>Search</button>
</div>
<div id="status" class="status"></div>
/* Styles: cards, postings grid, query row, status colours */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; font-size: 14px; }
button { font: 600 13px system-ui; padding: .4rem .85rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 7px; cursor: pointer; }
button:disabled { opacity: .45; cursor: default; }
.toolbar { display: flex; align-items: center; gap: .7rem; margin-bottom: .6rem; flex-wrap: wrap; }
.show-label { font-size: 13px; display: flex; align-items: center; gap: .3rem; cursor: pointer; }
.docs-area { display: grid; gap: .5rem; margin-bottom: .7rem; }
.doc-card { background: #f0f4f8; border: 1px solid #cdd9e3; border-radius: 8px; padding: .5rem .7rem; }
.doc-card.hit { background: #d4edda; border-color: #a3cfb3; }
.doc-id { font-weight: 700; color: #1d3557; margin-bottom: .2rem; font-size: 12px; }
.doc-text { line-height: 1.45; }
.doc-text mark { background: #ffe066; border-radius: 3px; padding: 0 2px; }
.index-area { margin-bottom: .7rem; background: #f8f9fb; border: 1px solid #dde3ea;
border-radius: 8px; padding: .5rem .7rem; }
.index-area.hidden { display: none; }
.index-title { font-weight: 700; font-size: 12px; color: #555; margin-bottom: .35rem; }
.postings-grid { display: grid; grid-template-columns: auto 1fr; gap: .18rem .6rem; align-items: baseline; }
.pt { font-family: ui-monospace, monospace; font-size: 12px; color: #1d3557; font-weight: 700; white-space: nowrap; }
.pl { font-family: ui-monospace, monospace; font-size: 12px; color: #444; }
.pl .active-doc { color: #0a7d33; font-weight: 700; }
.query-row { display: flex; gap: .5rem; }
input[type=text] { flex: 1; padding: .38rem .6rem; border: 1px solid #adb1b8; border-radius: 7px;
font: 14px system-ui; min-width: 0; }
.status { margin-top: .5rem; font-weight: 600; min-height: 1.3em; font-size: .95rem; }
.status.ok { color: #0a7d33; }
.status.bad { color: #c92f3c; }
.status.info { color: #555; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
/* Four short documents — the corpus to be indexed */
const CORPUS = [
{ id: 'D1', text: 'The inverted index makes search fast and efficient.' },
{ id: 'D2', text: 'Full-text search relies on an inverted index for speed.' },
{ id: 'D3', text: 'An index maps every term to the documents that contain it.' },
{ id: 'D4', text: 'Search engines build an index once and query it many times.' }
];
/* Tokenise: lowercase, strip punctuation, split on whitespace */
function tokenize(text) {
return text.toLowerCase().replace(/[^a-z0-9\s]/g, '').split(/\s+/).filter(Boolean);
}
/* Build the inverted index: term -> sorted list of doc IDs */
let INDEX = null;
function buildIndex(corpus) {
const idx = {};
for (const doc of corpus) {
const tokens = tokenize(doc.text);
const seen = new Set();
for (const tok of tokens) {
if (!idx[tok]) idx[tok] = [];
if (!seen.has(tok)) { idx[tok].push(doc.id); seen.add(tok); }
}
}
/* Sort each postings list for fast intersection */
for (const k of Object.keys(idx)) idx[k].sort();
return idx;
}
/* Two-pointer sorted-list intersection — O(k1 + k2) */
function intersect(lists) {
if (lists.length === 0) return [];
let result = lists[0].slice();
for (let i = 1; i < lists.length; i++) {
const b = lists[i];
const merged = [];
let ai = 0, bi = 0;
while (ai < result.length && bi < b.length) {
if (result[ai] === b[bi]) { merged.push(result[ai]); ai++; bi++; }
else if (result[ai] < b[bi]) ai++;
else bi++;
}
result = merged;
}
return result;
}
/* Render document cards, highlight hits and matched terms */
function renderDocs(hitSet, termsToMark) {
const area = document.getElementById('docs-area');
area.innerHTML = '';
for (const doc of CORPUS) {
const card = document.createElement('div');
card.className = 'doc-card' + (hitSet && hitSet.has(doc.id) ? ' hit' : '');
const idDiv = document.createElement('div');
idDiv.className = 'doc-id';
idDiv.textContent = doc.id;
const textDiv = document.createElement('div');
textDiv.className = 'doc-text';
if (termsToMark && termsToMark.size > 0) {
/* Regex-highlight all matched terms in the document text */
const escaped = doc.text.replace(/[.*+?^${}()|[\]\\]/g, '\\amp;');
const words = [...termsToMark].map(t => t.replace(/[.*+?^${}()|[\]\\]/g, '\\amp;'));
const re = new RegExp('(' + words.join('|') + ')', 'gi');
textDiv.innerHTML = doc.text.replace(re, '<mark>$1</mark>');
} else {
textDiv.textContent = doc.text;
}
card.appendChild(idDiv);
card.appendChild(textDiv);
area.appendChild(card);
}
}
/* Render the postings panel; highlight active query terms */
function renderIndex(activeTerms) {
const area = document.getElementById('index-area');
if (!INDEX) { area.classList.add('hidden'); return; }
area.classList.remove('hidden');
const title = document.createElement('div');
title.className = 'index-title';
title.textContent = 'Postings lists (term → documents):';
const grid = document.createElement('div');
grid.className = 'postings-grid';
const entries = Object.entries(INDEX).sort((a, b) => a[0].localeCompare(b[0]));
for (const [term, docs] of entries) {
const termEl = document.createElement('div');
termEl.className = 'pt';
termEl.textContent = term + ':';
const listEl = document.createElement('div');
listEl.className = 'pl';
if (activeTerms && activeTerms.has(term)) {
listEl.innerHTML = docs.map(d => `<span class="active-doc">${d}</span>`).join(', ');
} else {
listEl.textContent = docs.join(', ');
}
grid.appendChild(termEl);
grid.appendChild(listEl);
}
area.innerHTML = '';
area.appendChild(title);
area.appendChild(grid);
}
function setStatus(msg, cls) {
const el = document.getElementById('status');
el.textContent = msg;
el.className = 'status ' + cls;
}
/* Build button: construct index, unlock search */
document.getElementById('btn-build').addEventListener('click', () => {
INDEX = buildIndex(CORPUS);
renderDocs(null, null);
const chk = document.getElementById('chk-postings');
if (chk.checked) renderIndex(null);
document.getElementById('btn-search').disabled = false;
setStatus(i18nFmt('Index built — %n% unique terms.', { n: Object.keys(INDEX).length }), 'info');
});
/* Search button: look up each term, intersect postings lists */
document.getElementById('btn-search').addEventListener('click', () => {
if (!INDEX) return;
const raw = document.getElementById('query-input').value.trim();
if (!raw) { setStatus('Type a query first.', 'bad'); return; }
const terms = tokenize(raw);
const missing = terms.filter(t => !INDEX[t]);
if (missing.length > 0) {
setStatus(i18nFmt('Term not in index: %t%', { t: missing.join(', ') }), 'bad');
renderDocs(new Set(), null);
const chk = document.getElementById('chk-postings');
if (chk.checked) renderIndex(new Set(terms.filter(t => INDEX[t])));
return;
}
const lists = terms.map(t => INDEX[t]);
const hits = intersect(lists);
const hitSet = new Set(hits);
const termSet = new Set(terms);
renderDocs(hitSet, termSet);
const chk = document.getElementById('chk-postings');
if (chk.checked) renderIndex(termSet);
if (hits.length > 0) {
setStatus(i18nFmt('Found %n% document(s): %docs%', { n: hits.length, docs: hits.join(', ') }), 'ok');
} else {
setStatus('No documents match all query terms.', 'bad');
}
});
document.getElementById('chk-postings').addEventListener('change', (e) => {
if (e.target.checked) renderIndex(null);
else document.getElementById('index-area').classList.add('hidden');
});
document.getElementById('query-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter') document.getElementById('btn-search').click();
});
/* Initial render — show docs before the index is built */
renderDocs(null, null);
<!-- Main layout: toolbar (build + show-postings toggle), doc cards, index panel, query row, status -->
<div class="toolbar">
<button id="btn-build" type="button">Build index</button>
<label class="show-label">
<input type="checkbox" id="chk-postings"> Show postings
</label>
</div>
<div id="docs-area" class="docs-area"></div>
<div id="index-area" class="index-area hidden"></div>
<div class="query-row">
<input id="query-input" type="text" placeholder="e.g. fast search">
<button id="btn-search" type="button" disabled>Search</button>
</div>
<div id="status" class="status"></div>
/* Styles: cards, postings grid, query row, status colours */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; font-size: 14px; }
button { font: 600 13px system-ui; padding: .4rem .85rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 7px; cursor: pointer; }
button:disabled { opacity: .45; cursor: default; }
.toolbar { display: flex; align-items: center; gap: .7rem; margin-bottom: .6rem; flex-wrap: wrap; }
.show-label { font-size: 13px; display: flex; align-items: center; gap: .3rem; cursor: pointer; }
.docs-area { display: grid; gap: .5rem; margin-bottom: .7rem; }
.doc-card { background: #f0f4f8; border: 1px solid #cdd9e3; border-radius: 8px; padding: .5rem .7rem; }
.doc-card.hit { background: #d4edda; border-color: #a3cfb3; }
.doc-id { font-weight: 700; color: #1d3557; margin-bottom: .2rem; font-size: 12px; }
.doc-text { line-height: 1.45; }
.doc-text mark { background: #ffe066; border-radius: 3px; padding: 0 2px; }
.index-area { margin-bottom: .7rem; background: #f8f9fb; border: 1px solid #dde3ea;
border-radius: 8px; padding: .5rem .7rem; }
.index-area.hidden { display: none; }
.index-title { font-weight: 700; font-size: 12px; color: #555; margin-bottom: .35rem; }
.postings-grid { display: grid; grid-template-columns: auto 1fr; gap: .18rem .6rem; align-items: baseline; }
.pt { font-family: ui-monospace, monospace; font-size: 12px; color: #1d3557; font-weight: 700; white-space: nowrap; }
.pl { font-family: ui-monospace, monospace; font-size: 12px; color: #444; }
.pl .active-doc { color: #0a7d33; font-weight: 700; }
.query-row { display: flex; gap: .5rem; }
input[type=text] { flex: 1; padding: .38rem .6rem; border: 1px solid #adb1b8; border-radius: 7px;
font: 14px system-ui; min-width: 0; }
.status { margin-top: .5rem; font-weight: 600; min-height: 1.3em; font-size: .95rem; }
.status.ok { color: #0a7d33; }
.status.bad { color: #c92f3c; }
.status.info { color: #555; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
/* Four short documents — the corpus to be indexed */
const CORPUS = [
{ id: 'D1', text: 'The inverted index makes search fast and efficient.' },
{ id: 'D2', text: 'Full-text search relies on an inverted index for speed.' },
{ id: 'D3', text: 'An index maps every term to the documents that contain it.' },
{ id: 'D4', text: 'Search engines build an index once and query it many times.' }
];
/* Tokenise: lowercase, strip punctuation, split on whitespace */
function tokenize(text) {
return text.toLowerCase().replace(/[^a-z0-9\s]/g, '').split(/\s+/).filter(Boolean);
}
/* Build the inverted index: term -> sorted list of doc IDs */
let INDEX = null;
function buildIndex(corpus) {
const idx = {};
for (const doc of corpus) {
const tokens = tokenize(doc.text);
const seen = new Set();
for (const tok of tokens) {
if (!idx[tok]) idx[tok] = [];
if (!seen.has(tok)) { idx[tok].push(doc.id); seen.add(tok); }
}
}
/* Sort each postings list for fast intersection */
for (const k of Object.keys(idx)) idx[k].sort();
return idx;
}
/* Two-pointer sorted-list intersection — O(k1 + k2) */
function intersect(lists) {
if (lists.length === 0) return [];
let result = lists[0].slice();
for (let i = 1; i < lists.length; i++) {
const b = lists[i];
const merged = [];
let ai = 0, bi = 0;
while (ai < result.length && bi < b.length) {
if (result[ai] === b[bi]) { merged.push(result[ai]); ai++; bi++; }
else if (result[ai] < b[bi]) ai++;
else bi++;
}
result = merged;
}
return result;
}
/* Render document cards, highlight hits and matched terms */
function renderDocs(hitSet, termsToMark) {
const area = document.getElementById('docs-area');
area.innerHTML = '';
for (const doc of CORPUS) {
const card = document.createElement('div');
card.className = 'doc-card' + (hitSet && hitSet.has(doc.id) ? ' hit' : '');
const idDiv = document.createElement('div');
idDiv.className = 'doc-id';
idDiv.textContent = doc.id;
const textDiv = document.createElement('div');
textDiv.className = 'doc-text';
if (termsToMark && termsToMark.size > 0) {
/* Regex-highlight all matched terms in the document text */
const escaped = doc.text.replace(/[.*+?^${}()|[\]\\]/g, '\\amp;');
const words = [...termsToMark].map(t => t.replace(/[.*+?^${}()|[\]\\]/g, '\\amp;'));
const re = new RegExp('(' + words.join('|') + ')', 'gi');
textDiv.innerHTML = doc.text.replace(re, '<mark>$1</mark>');
} else {
textDiv.textContent = doc.text;
}
card.appendChild(idDiv);
card.appendChild(textDiv);
area.appendChild(card);
}
}
/* Render the postings panel; highlight active query terms */
function renderIndex(activeTerms) {
const area = document.getElementById('index-area');
if (!INDEX) { area.classList.add('hidden'); return; }
area.classList.remove('hidden');
const title = document.createElement('div');
title.className = 'index-title';
title.textContent = 'Postings lists (term → documents):';
const grid = document.createElement('div');
grid.className = 'postings-grid';
const entries = Object.entries(INDEX).sort((a, b) => a[0].localeCompare(b[0]));
for (const [term, docs] of entries) {
const termEl = document.createElement('div');
termEl.className = 'pt';
termEl.textContent = term + ':';
const listEl = document.createElement('div');
listEl.className = 'pl';
if (activeTerms && activeTerms.has(term)) {
listEl.innerHTML = docs.map(d => `<span class="active-doc">${d}</span>`).join(', ');
} else {
listEl.textContent = docs.join(', ');
}
grid.appendChild(termEl);
grid.appendChild(listEl);
}
area.innerHTML = '';
area.appendChild(title);
area.appendChild(grid);
}
function setStatus(msg, cls) {
const el = document.getElementById('status');
el.textContent = msg;
el.className = 'status ' + cls;
}
/* Build button: construct index, unlock search */
document.getElementById('btn-build').addEventListener('click', () => {
INDEX = buildIndex(CORPUS);
renderDocs(null, null);
const chk = document.getElementById('chk-postings');
if (chk.checked) renderIndex(null);
document.getElementById('btn-search').disabled = false;
setStatus(i18nFmt('Index built — %n% unique terms.', { n: Object.keys(INDEX).length }), 'info');
});
/* Search button: look up each term, intersect postings lists */
document.getElementById('btn-search').addEventListener('click', () => {
if (!INDEX) return;
const raw = document.getElementById('query-input').value.trim();
if (!raw) { setStatus('Type a query first.', 'bad'); return; }
const terms = tokenize(raw);
const missing = terms.filter(t => !INDEX[t]);
if (missing.length > 0) {
setStatus(i18nFmt('Term not in index: %t%', { t: missing.join(', ') }), 'bad');
renderDocs(new Set(), null);
const chk = document.getElementById('chk-postings');
if (chk.checked) renderIndex(new Set(terms.filter(t => INDEX[t])));
return;
}
const lists = terms.map(t => INDEX[t]);
const hits = intersect(lists);
const hitSet = new Set(hits);
const termSet = new Set(terms);
renderDocs(hitSet, termSet);
const chk = document.getElementById('chk-postings');
if (chk.checked) renderIndex(termSet);
if (hits.length > 0) {
setStatus(i18nFmt('Found %n% document(s): %docs%', { n: hits.length, docs: hits.join(', ') }), 'ok');
} else {
setStatus('No documents match all query terms.', 'bad');
}
});
document.getElementById('chk-postings').addEventListener('change', (e) => {
if (e.target.checked) renderIndex(null);
else document.getElementById('index-area').classList.add('hidden');
});
document.getElementById('query-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter') document.getElementById('btn-search').click();
});
/* Initial render — show docs before the index is built */
renderDocs(null, null);
Notice what happens with multi-word queries: each extra term shrinks the result set because only documents that contain all words survive the intersection. Checking the "Show postings" box lets you watch exactly which lists are being walked. A brute-force scan would read every document for every query; the index reduces the work to reading only the relevant postings lists — typically a tiny fraction of the corpus.
Comments
Loading comments...