Created
June 4, 2026 13:52
-
-
Save zenUnicorn/8bf182d89c787cdb3e4e50bd6440ef37 to your computer and use it in GitHub Desktop.
Building Semantic Search with Transformers.js and Sentence Embeddings
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!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