Skip to content

Instantly share code, notes, and snippets.

@zenUnicorn
Created June 4, 2026 13:52
Show Gist options
  • Select an option

  • Save zenUnicorn/8bf182d89c787cdb3e4e50bd6440ef37 to your computer and use it in GitHub Desktop.

Select an option

Save zenUnicorn/8bf182d89c787cdb3e4e50bd6440ef37 to your computer and use it in GitHub Desktop.
Building Semantic Search with Transformers.js and Sentence Embeddings
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Semantic Knowledge Base Search</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
max-width: 760px;
margin: 2rem auto;
padding: 0 1rem;
background: #f8fafc;
color: #1e293b;
}
h1 { font-size: 1.5rem; margin-bottom: 0.25rem; }
.subtitle { color: #64748b; font-size: 0.9rem; margin-bottom: 1.5rem; }
.search-bar {
display: flex;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
input[type="text"] {
flex: 1;
padding: 0.6rem 0.8rem;
font-size: 1rem;
border: 1px solid #cbd5e1;
border-radius: 6px;
}
button {
padding: 0.6rem 1.4rem;
font-size: 1rem;
background: #2563eb;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
}
button:disabled { background: #93c5fd; cursor: not-allowed; }
#status {
font-size: 0.85rem;
color: #64748b;
margin-bottom: 1rem;
min-height: 1.2em;
}
.examples {
font-size: 0.85rem;
color: #64748b;
margin-bottom: 1.5rem;
}
.example-query {
display: inline-block;
background: #e0f2fe;
color: #0369a1;
border-radius: 4px;
padding: 0.2rem 0.5rem;
margin: 0.2rem;
cursor: pointer;
font-size: 0.82rem;
}
.example-query:hover { background: #bae6fd; }
.result-card {
background: white;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 1rem;
margin-bottom: 0.75rem;
}
.result-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.4rem;
}
.result-title { font-weight: 600; font-size: 1rem; }
.score-badge {
font-size: 0.78rem;
font-weight: 700;
padding: 0.15rem 0.5rem;
border-radius: 12px;
white-space: nowrap;
}
.score-high { background: #dcfce7; color: #15803d; }
.score-medium { background: #fef9c3; color: #854d0e; }
.score-low { background: #f1f5f9; color: #64748b; }
.result-category { font-size: 0.78rem; color: #64748b; margin-bottom: 0.4rem; }
.result-text { font-size: 0.9rem; color: #475569; line-height: 1.5; }
#no-results { text-align: center; color: #94a3b8; padding: 2rem; }
</style>
</head>
<body>
<h1>Knowledge Base Search</h1>
<p class="subtitle">
Finds relevant articles even when your words don't appear in the text.
Runs entirely in your browser.
</p>
<div class="search-bar">
<input type="text" id="query-input" placeholder="Ask anything..." disabled />
<button id="search-btn" disabled>Search</button>
</div>
<div id="status">Loading model -- first run downloads ~23 MB...</div>
<div class="examples">
Try these (no keyword overlap with matching articles):
<span class="example-query">cheap shipping option</span>
<span class="example-query">I can't get into my account</span>
<span class="example-query">item arrived damaged</span>
<span class="example-query">stop receiving emails</span>
<span class="example-query">buy now pay later</span>
</div>
<div id="results"></div>
<script type="module">
import { pipeline }
from 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.0.2';
// ----------------------------------------------------------------
// Knowledge base corpus
// Each document has an id, title, category, and text to embed.
// In a real application, load this from an API or a JSON file.
// ----------------------------------------------------------------
const KNOWLEDGE_BASE = [
{
id: 'kb-001',
title: 'Standard Shipping Information',
category: 'Shipping',
text: 'Standard shipping takes 5-7 business days. Orders over $50 qualify for free standard shipping. We ship to all 50 US states and most US territories.'
},
{
id: 'kb-002',
title: 'Economy Delivery Options',
category: 'Shipping',
text: 'Our budget-friendly delivery option delivers in 7-10 business days at a reduced rate of $2.99 for small packages. This is our most affordable way to receive your order.'
},
{
id: 'kb-003',
title: 'Express and Overnight Shipping',
category: 'Shipping',
text: 'Expedited delivery guarantees arrival within 2 business days. Overnight delivery is available for orders placed before 2pm EST. Premium shipping rates apply.'
},
{
id: 'kb-004',
title: 'Return Policy Overview',
category: 'Returns',
text: 'You can send back most items within 30 days of purchase for a full refund. Items must be in original packaging. Electronics have a 15-day return window.'
},
{
id: 'kb-005',
title: 'Damaged Item Policy',
category: 'Returns',
text: 'If your package arrived broken or defective, please contact us within 7 days. We will send a prepaid label and ship a replacement at no additional cost. Take photos before returning.'
},
{
id: 'kb-006',
title: 'How Refunds Are Processed',
category: 'Returns',
text: 'After we receive your returned merchandise, refunds are issued to your original payment method within 3-5 business days. You will receive an email confirmation when processing is complete.'
},
{
id: 'kb-007',
title: 'Account Access and Password Reset',
category: 'Account',
text: 'If you cannot sign in or forgot your credentials, click Forgot Password on the login page. We will email you a reset link valid for 24 hours. Check your spam folder if you do not see it.'
},
{
id: 'kb-008',
title: 'Email Preferences and Unsubscribing',
category: 'Account',
text: 'To stop receiving promotional messages, click the unsubscribe link at the bottom of any marketing email. You can also manage notification preferences in your account settings under Communication.'
},
{
id: 'kb-009',
title: 'Updating Payment Methods',
category: 'Billing',
text: 'Add, remove, or change your credit card and billing details in the Wallet section of your account. We accept Visa, Mastercard, American Express, and PayPal.'
},
{
id: 'kb-010',
title: 'Buy Now Pay Later Options',
category: 'Billing',
text: 'We offer installment payment plans through Klarna and Afterpay, letting you split your purchase into four interest-free payments. Select your preferred pay-later service at checkout.'
},
{
id: 'kb-011',
title: 'Order Tracking and Status',
category: 'Orders',
text: 'Track your shipment using the link in your dispatch confirmation email or by entering your order number on our tracking page. Updates occur every 12-24 hours once your parcel is in transit.'
},
{
id: 'kb-012',
title: 'Cancelling an Order',
category: 'Orders',
text: 'Orders can be cancelled within 1 hour of placement before they enter our fulfillment process. After that window, you will need to wait for delivery and then initiate a return.'
}
];
// ----------------------------------------------------------------
// Cosine similarity -- dot product shortcut works because
// normalize: true gives us unit-length vectors
// ----------------------------------------------------------------
function cosineSimilarity(vecA, vecB) {
let dot = 0;
for (let i = 0; i < vecA.length; i++) {
dot += vecA[i] * vecB[i];
}
return Math.max(-1, Math.min(1, dot));
}
// ----------------------------------------------------------------
// SemanticSearch class -- embed documents once, search many times
// ----------------------------------------------------------------
class SemanticSearch {
constructor(extractor) {
this.extractor = extractor;
this.index = [];
}
async indexDocuments(docs) {
const texts = docs.map(d => d.text);
// One batch call embeds all documents simultaneously
const output = await this.extractor(texts, {
pooling: 'mean',
normalize: true
});
const vectors = output.tolist();
// Store each document with its pre-computed embedding attached
this.index = docs.map((doc, i) => ({ ...doc, vector: vectors[i] }));
return this;
}
async search(query, topK = 5) {
// Embed only the query -- documents are already cached in this.index
const qOutput = await this.extractor(query, {
pooling: 'mean',
normalize: true
});
const qVec = qOutput.tolist()[0];
// Score all documents (pure JS arithmetic, no model involved)
const scored = this.index
.map(doc => ({ doc, score: cosineSimilarity(qVec, doc.vector) }))
.sort((a, b) => b.score - a.score);
return scored.slice(0, topK);
}
}
// ----------------------------------------------------------------
// UI helpers
// ----------------------------------------------------------------
const statusEl = document.getElementById('status');
const queryEl = document.getElementById('query-input');
const searchBtn = document.getElementById('search-btn');
const resultsEl = document.getElementById('results');
function scoreClass(score) {
if (score >= 0.55) return 'score-high';
if (score >= 0.35) return 'score-medium';
return 'score-low';
}
function renderResults(results) {
if (results.length === 0) {
resultsEl.innerHTML = '<div id="no-results">No results found.</div>';
return;
}
resultsEl.innerHTML = results.map(({ doc, score }) => {
const pct = (score * 100).toFixed(1);
const cls = scoreClass(score);
return `
<div class="result-card">
<div class="result-header">
<span class="result-title">${doc.title}</span>
<span class="score-badge ${cls}">${pct}% match</span>
</div>
<div class="result-category">${doc.category}</div>
<div class="result-text">${doc.text}</div>
</div>`;
}).join('');
}
// ----------------------------------------------------------------
// Boot sequence: load model, index documents, enable UI
// ----------------------------------------------------------------
let searcher;
async function init() {
try {
// Load the feature-extraction pipeline
// dtype: 'q8' = 8-bit quantized -- smaller download, nearly identical accuracy
const extractor = await pipeline(
'feature-extraction',
'Xenova/all-MiniLM-L6-v2',
{
dtype: 'q8',
progress_callback: (p) => {
if (p.status === 'progress') {
const pct = Math.round(p.progress ?? 0);
statusEl.textContent = `Downloading model: ${pct}%`;
}
}
}
);
statusEl.textContent = 'Indexing knowledge base...';
// Embed all 12 documents -- cached in memory for the session
searcher = new SemanticSearch(extractor);
await searcher.indexDocuments(KNOWLEDGE_BASE);
statusEl.textContent =
`Ready -- ${KNOWLEDGE_BASE.length} articles indexed. ` +
`Model cached for instant reload.`;
queryEl.disabled = false;
searchBtn.disabled = false;
queryEl.focus();
} catch (err) {
statusEl.textContent = `Error: ${err.message}`;
console.error(err);
}
}
// ----------------------------------------------------------------
// Search handler
// ----------------------------------------------------------------
async function handleSearch() {
const query = queryEl.value.trim();
if (!query || !searcher) return;
searchBtn.disabled = true;
searchBtn.textContent = 'Searching...';
resultsEl.innerHTML = '';
try {
// Returns top 5 results ranked by cosine similarity score
const results = await searcher.search(query, 5);
renderResults(results);
} catch (err) {
resultsEl.innerHTML =
`<div style="color:#dc2626">Search error: ${err.message}</div>`;
}
searchBtn.disabled = false;
searchBtn.textContent = 'Search';
}
// Clicking an example chip populates and runs the search
document.querySelectorAll('.example-query').forEach(chip => {
chip.addEventListener('click', () => {
queryEl.value = chip.textContent;
handleSearch();
});
});
searchBtn.addEventListener('click', handleSearch);
queryEl.addEventListener('keydown', e => {
if (e.key === 'Enter' && !searchBtn.disabled) handleSearch();
});
// Start loading as soon as the page opens
init();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment