<!-- Rocchio relevance feedback demo — 2-D vector space visualization -->
<p class="hint">Click a document dot to cycle it through <b>unrated → relevant ✓ → non-relevant ✗</b>. Then press <b>Apply Rocchio</b> to move the query (Q) toward relevant documents and away from non-relevant ones.</p>
<canvas id="canvas" width="400" height="280"></canvas>
<div class="status" id="status">Click documents to rate them, then apply Rocchio.</div>
<div class="btns">
<button id="applyBtn" type="button">Apply Rocchio</button>
<button id="resetBtn" type="button" class="ghost">Reset</button>
</div>
<div class="legend">
<span class="leg-item"><span class="dot unrated"></span> Unrated</span>
<span class="leg-item"><span class="dot relevant"></span> Relevant</span>
<span class="leg-item"><span class="dot nonrelevant"></span> Non-relevant</span>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .88rem; color: #444; margin: 0 0 .6rem; line-height: 1.45; }
canvas { display: block; border: 1px solid #cdd9e3; border-radius: 10px;
background: #f7f9fb; cursor: pointer; max-width: 100%; }
.status { font-size: .95rem; font-weight: 600; margin: .5rem 0; min-height: 1.4em; }
.status.moved { color: #0a7d33; }
.status.idle { color: #555; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; margin-bottom: .4rem; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem;
border: 1px solid #1d3557; background: #1d3557; color: #fff;
border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
.legend { display: flex; gap: 1rem; flex-wrap: wrap; font-size: .82rem; color: #555; }
.leg-item { display: flex; align-items: center; gap: .35rem; }
.dot { width: 12px; height: 12px; border-radius: 50%; display: inline-block; }
.dot.unrated { background: #8ab0c8; border: 1.5px solid #5a7088; }
.dot.relevant { background: #3cb371; border: 1.5px solid #2a7a50; }
.dot.nonrelevant{ background: #e63946; border: 1.5px solid #c92f3c; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Rocchio weights: alpha=0.75 (query weight), beta=0.5 (pull to relevant), gamma=0.2 (push from non-relevant)
const ALPHA = 0.75, BETA = 0.5, GAMMA = 0.2;
// canvas and drawing context
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
// leave a margin so points never clip the border
const MARGIN = 30;
// document rating states: 0=unrated, 1=relevant, -1=non-relevant
const STATES = [0, 1, -1];
let docs = []; // array of document objects
let query = {}; // current query position
function rand(lo, hi) { return lo + Math.random() * (hi - lo); }
// generate 10 random documents spread across the canvas generate 10 random documents spread across the canvas
function generate() {
docs = [];
for (let i = 0; i < 10; i++) {
docs.push({ x: rand(MARGIN, W - MARGIN), y: rand(MARGIN, H - MARGIN), state: 0 });
}
// place query near the center with a slight random offset place query near the center with a slight random offset
query = { x: rand(W * 0.35, W * 0.65), y: rand(H * 0.35, H * 0.65) };
}
function docColor(state) {
if (state === 1) return '#3cb371';
if (state === -1) return '#e63946';
return '#8ab0c8';
}
function docStroke(state) {
if (state === 1) return '#2a7a50';
if (state === -1) return '#c92f3c';
return '#5a7088';
}
function draw() {
ctx.clearRect(0, 0, W, H);
// draw documents draw documents
docs.forEach((d, i) => {
ctx.beginPath();
ctx.arc(d.x, d.y, 10, 0, Math.PI * 2);
ctx.fillStyle = docColor(d.state);
ctx.fill();
ctx.strokeStyle = docStroke(d.state);
ctx.lineWidth = 1.5;
ctx.stroke();
// document index label document index label
ctx.fillStyle = '#fff';
ctx.font = 'bold 9px system-ui';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(String(i + 1), d.x, d.y);
// draw check or cross for rated docs draw check or cross for rated docs
if (d.state !== 0) {
ctx.font = 'bold 11px system-ui';
ctx.fillStyle = d.state === 1 ? '#fff' : '#fff';
ctx.fillText(d.state === 1 ? '✓' : '×', d.x + 9, d.y - 8);
}
});
// draw query circle draw query circle
ctx.beginPath();
ctx.arc(query.x, query.y, 16, 0, Math.PI * 2);
ctx.strokeStyle = '#1d3557';
ctx.lineWidth = 2.5;
ctx.stroke();
ctx.fillStyle = 'rgba(29,53,87,0.18)';
ctx.fill();
// query label
ctx.fillStyle = '#1d3557';
ctx.font = 'bold 11px system-ui';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('Q', query.x, query.y);
}
// check if a click hit a document (radius 14 px tolerance) check if a click hit a document (radius 14 px tolerance)
canvas.addEventListener('click', function(e) {
const rect = canvas.getBoundingClientRect();
const scaleX = W / rect.width;
const scaleY = H / rect.height;
const mx = (e.clientX - rect.left) * scaleX;
const my = (e.clientY - rect.top) * scaleY;
docs.forEach(d => {
const dx = d.x - mx, dy = d.y - my;
if (Math.sqrt(dx * dx + dy * dy) < 14) {
const idx = STATES.indexOf(d.state);
d.state = STATES[(idx + 1) % STATES.length];
}
});
draw();
setStatus('Click documents to rate them, then apply Rocchio.', 'idle');
});
// apply the Rocchio update: q' = alpha*q + beta*centroid(R) - gamma*centroid(NR) apply the Rocchio update: q' = alpha*q + beta*centroid(R) - gamma*centroid(NR)
function applyRocchio() {
const rel = docs.filter(d => d.state === 1);
const nonrel = docs.filter(d => d.state === -1);
if (rel.length === 0 && nonrel.length === 0) {
setStatus('Rate at least one document first.', 'idle');
return;
}
const centroid = (arr) => {
const cx = arr.reduce((s, d) => s + d.x, 0) / arr.length;
const cy = arr.reduce((s, d) => s + d.y, 0) / arr.length;
return { x: cx, y: cy };
};
let nx = ALPHA * query.x;
let ny = ALPHA * query.y;
if (rel.length > 0) {
const cr = centroid(rel);
nx += BETA * cr.x;
ny += BETA * cr.y;
}
if (nonrel.length > 0) {
const cn = centroid(nonrel);
nx -= GAMMA * cn.x;
ny -= GAMMA * cn.y;
}
// clamp to canvas clamp to canvas
query.x = Math.max(MARGIN, Math.min(W - MARGIN, nx));
query.y = Math.max(MARGIN, Math.min(H - MARGIN, ny));
draw();
setStatus(i18nFmt('Query moved: %r% relevant, %n% non-relevant rated.', { r: rel.length, n: nonrel.length }), 'moved');
}
function setStatus(t, cls) {
const s = document.getElementById('status');
s.textContent = t;
s.className = 'status ' + cls;
}
document.getElementById('applyBtn').addEventListener('click', applyRocchio);
document.getElementById('resetBtn').addEventListener('click', function() {
generate();
draw();
setStatus('Click documents to rate them, then apply Rocchio.', 'idle');
});
generate();
draw();
setStatus('Click documents to rate them, then apply Rocchio.', 'idle');
<!-- Rocchio relevance feedback demo — 2-D vector space visualization -->
<p class="hint">Click a document dot to cycle it through <b>unrated → relevant ✓ → non-relevant ✗</b>. Then press <b>Apply Rocchio</b> to move the query (Q) toward relevant documents and away from non-relevant ones.</p>
<canvas id="canvas" width="400" height="280"></canvas>
<div class="status" id="status">Click documents to rate them, then apply Rocchio.</div>
<div class="btns">
<button id="applyBtn" type="button">Apply Rocchio</button>
<button id="resetBtn" type="button" class="ghost">Reset</button>
</div>
<div class="legend">
<span class="leg-item"><span class="dot unrated"></span> Unrated</span>
<span class="leg-item"><span class="dot relevant"></span> Relevant</span>
<span class="leg-item"><span class="dot nonrelevant"></span> Non-relevant</span>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .88rem; color: #444; margin: 0 0 .6rem; line-height: 1.45; }
canvas { display: block; border: 1px solid #cdd9e3; border-radius: 10px;
background: #f7f9fb; cursor: pointer; max-width: 100%; }
.status { font-size: .95rem; font-weight: 600; margin: .5rem 0; min-height: 1.4em; }
.status.moved { color: #0a7d33; }
.status.idle { color: #555; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; margin-bottom: .4rem; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem;
border: 1px solid #1d3557; background: #1d3557; color: #fff;
border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
.legend { display: flex; gap: 1rem; flex-wrap: wrap; font-size: .82rem; color: #555; }
.leg-item { display: flex; align-items: center; gap: .35rem; }
.dot { width: 12px; height: 12px; border-radius: 50%; display: inline-block; }
.dot.unrated { background: #8ab0c8; border: 1.5px solid #5a7088; }
.dot.relevant { background: #3cb371; border: 1.5px solid #2a7a50; }
.dot.nonrelevant{ background: #e63946; border: 1.5px solid #c92f3c; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Rocchio weights: alpha=0.75 (query weight), beta=0.5 (pull to relevant), gamma=0.2 (push from non-relevant)
const ALPHA = 0.75, BETA = 0.5, GAMMA = 0.2;
// canvas and drawing context
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
// leave a margin so points never clip the border
const MARGIN = 30;
// document rating states: 0=unrated, 1=relevant, -1=non-relevant
const STATES = [0, 1, -1];
let docs = []; // array of document objects
let query = {}; // current query position
function rand(lo, hi) { return lo + Math.random() * (hi - lo); }
// generate 10 random documents spread across the canvas generate 10 random documents spread across the canvas
function generate() {
docs = [];
for (let i = 0; i < 10; i++) {
docs.push({ x: rand(MARGIN, W - MARGIN), y: rand(MARGIN, H - MARGIN), state: 0 });
}
// place query near the center with a slight random offset place query near the center with a slight random offset
query = { x: rand(W * 0.35, W * 0.65), y: rand(H * 0.35, H * 0.65) };
}
function docColor(state) {
if (state === 1) return '#3cb371';
if (state === -1) return '#e63946';
return '#8ab0c8';
}
function docStroke(state) {
if (state === 1) return '#2a7a50';
if (state === -1) return '#c92f3c';
return '#5a7088';
}
function draw() {
ctx.clearRect(0, 0, W, H);
// draw documents draw documents
docs.forEach((d, i) => {
ctx.beginPath();
ctx.arc(d.x, d.y, 10, 0, Math.PI * 2);
ctx.fillStyle = docColor(d.state);
ctx.fill();
ctx.strokeStyle = docStroke(d.state);
ctx.lineWidth = 1.5;
ctx.stroke();
// document index label document index label
ctx.fillStyle = '#fff';
ctx.font = 'bold 9px system-ui';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(String(i + 1), d.x, d.y);
// draw check or cross for rated docs draw check or cross for rated docs
if (d.state !== 0) {
ctx.font = 'bold 11px system-ui';
ctx.fillStyle = d.state === 1 ? '#fff' : '#fff';
ctx.fillText(d.state === 1 ? '✓' : '×', d.x + 9, d.y - 8);
}
});
// draw query circle draw query circle
ctx.beginPath();
ctx.arc(query.x, query.y, 16, 0, Math.PI * 2);
ctx.strokeStyle = '#1d3557';
ctx.lineWidth = 2.5;
ctx.stroke();
ctx.fillStyle = 'rgba(29,53,87,0.18)';
ctx.fill();
// query label
ctx.fillStyle = '#1d3557';
ctx.font = 'bold 11px system-ui';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('Q', query.x, query.y);
}
// check if a click hit a document (radius 14 px tolerance) check if a click hit a document (radius 14 px tolerance)
canvas.addEventListener('click', function(e) {
const rect = canvas.getBoundingClientRect();
const scaleX = W / rect.width;
const scaleY = H / rect.height;
const mx = (e.clientX - rect.left) * scaleX;
const my = (e.clientY - rect.top) * scaleY;
docs.forEach(d => {
const dx = d.x - mx, dy = d.y - my;
if (Math.sqrt(dx * dx + dy * dy) < 14) {
const idx = STATES.indexOf(d.state);
d.state = STATES[(idx + 1) % STATES.length];
}
});
draw();
setStatus('Click documents to rate them, then apply Rocchio.', 'idle');
});
// apply the Rocchio update: q' = alpha*q + beta*centroid(R) - gamma*centroid(NR) apply the Rocchio update: q' = alpha*q + beta*centroid(R) - gamma*centroid(NR)
function applyRocchio() {
const rel = docs.filter(d => d.state === 1);
const nonrel = docs.filter(d => d.state === -1);
if (rel.length === 0 && nonrel.length === 0) {
setStatus('Rate at least one document first.', 'idle');
return;
}
const centroid = (arr) => {
const cx = arr.reduce((s, d) => s + d.x, 0) / arr.length;
const cy = arr.reduce((s, d) => s + d.y, 0) / arr.length;
return { x: cx, y: cy };
};
let nx = ALPHA * query.x;
let ny = ALPHA * query.y;
if (rel.length > 0) {
const cr = centroid(rel);
nx += BETA * cr.x;
ny += BETA * cr.y;
}
if (nonrel.length > 0) {
const cn = centroid(nonrel);
nx -= GAMMA * cn.x;
ny -= GAMMA * cn.y;
}
// clamp to canvas clamp to canvas
query.x = Math.max(MARGIN, Math.min(W - MARGIN, nx));
query.y = Math.max(MARGIN, Math.min(H - MARGIN, ny));
draw();
setStatus(i18nFmt('Query moved: %r% relevant, %n% non-relevant rated.', { r: rel.length, n: nonrel.length }), 'moved');
}
function setStatus(t, cls) {
const s = document.getElementById('status');
s.textContent = t;
s.className = 'status ' + cls;
}
document.getElementById('applyBtn').addEventListener('click', applyRocchio);
document.getElementById('resetBtn').addEventListener('click', function() {
generate();
draw();
setStatus('Click documents to rate them, then apply Rocchio.', 'idle');
});
generate();
draw();
setStatus('Click documents to rate them, then apply Rocchio.', 'idle');
Notice that after feedback the query moves toward the green cluster and away from the red ones. Hit Reset to start fresh with a new random layout. Try marking all documents on one side as relevant — the query shoots straight toward them.
Comments
Loading comments...