| <html> |
| <head> |
| <meta content="text/html;charset=utf-8" http-equiv="Content-Type" /> |
| <title>Candle Bert</title> |
| </head> |
| <body></body> |
| </html> |
|
|
| <!DOCTYPE html> |
| <html> |
| <head> |
| <meta charset="UTF-8" /> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
| <style> |
| @import url("https://fonts.googleapis.com/css2?family=Source+Code+Pro:wght@200;300;400&family=Source+Sans+3:wght@100;200;300;400;500;600;700;800;900&display=swap"); |
| html, |
| body { |
| font-family: "Source Sans 3", sans-serif; |
| } |
| </style> |
| <script src="https://cdn.tailwindcss.com"></script> |
| <script type="module" src="./code.js"></script> |
| <script type="module"> |
| import { hcl } from "https://cdn.skypack.dev/d3-color@3"; |
| import { interpolateReds } from "https://cdn.skypack.dev/d3-scale-chromatic@3"; |
| import { scaleLinear } from "https://cdn.skypack.dev/d3-scale@4"; |
| import { |
| getModelInfo, |
| getEmbeddings, |
| getWikiText, |
| cosineSimilarity, |
| } from "./utils.js"; |
| |
| const bertWorker = new Worker("./bertWorker.js", { |
| type: "module", |
| }); |
| |
| const inputContainerEL = document.querySelector("#input-container"); |
| const textAreaEl = document.querySelector("#input-area"); |
| const outputAreaEl = document.querySelector("#output-area"); |
| const formEl = document.querySelector("#form"); |
| const searchInputEl = document.querySelector("#search-input"); |
| const formWikiEl = document.querySelector("#form-wiki"); |
| const searchWikiEl = document.querySelector("#search-wiki"); |
| const outputStatusEl = document.querySelector("#output-status"); |
| const modelSelectEl = document.querySelector("#model"); |
| |
| const sentencesRegex = |
| /(?<!\w\.\w.)(?<![A-Z][a-z]\.)(?<![A-Z]\.)(?<=\.|\?)\s/gm; |
| |
| let sentenceEmbeddings = []; |
| let currInputText = ""; |
| let isCalculating = false; |
| |
| function toggleTextArea(state) { |
| if (state) { |
| textAreaEl.hidden = false; |
| textAreaEl.focus(); |
| } else { |
| textAreaEl.hidden = true; |
| } |
| } |
| inputContainerEL.addEventListener("focus", (e) => { |
| toggleTextArea(true); |
| }); |
| textAreaEl.addEventListener("blur", (e) => { |
| toggleTextArea(false); |
| }); |
| textAreaEl.addEventListener("focusout", (e) => { |
| toggleTextArea(false); |
| if (currInputText === textAreaEl.value || isCalculating) return; |
| populateOutputArea(textAreaEl.value); |
| calculateEmbeddings(textAreaEl.value); |
| }); |
| |
| modelSelectEl.addEventListener("change", (e) => { |
| const query = new URLSearchParams(window.location.search); |
| query.set("model", modelSelectEl.value); |
| window.history.replaceState( |
| {}, |
| "", |
| `${window.location.pathname}?${query}` |
| ); |
| if (currInputText === "" || isCalculating) return; |
| populateOutputArea(textAreaEl.value); |
| calculateEmbeddings(textAreaEl.value); |
| }); |
| |
| function populateOutputArea(text) { |
| currInputText = text; |
| const sentences = text.split(sentencesRegex); |
| |
| outputAreaEl.innerHTML = ""; |
| for (const [id, sentence] of sentences.entries()) { |
| const sentenceEl = document.createElement("span"); |
| sentenceEl.id = `sentence-${id}`; |
| sentenceEl.innerText = sentence + " "; |
| outputAreaEl.appendChild(sentenceEl); |
| } |
| } |
| formEl.addEventListener("submit", async (e) => { |
| e.preventDefault(); |
| if (isCalculating || currInputText === "") return; |
| toggleInputs(true); |
| const modelID = modelSelectEl.value; |
| const { modelURL, tokenizerURL, configURL, search_prefix } = |
| getModelInfo(modelID); |
| |
| const text = searchInputEl.value; |
| const query = search_prefix + searchInputEl.value; |
| outputStatusEl.classList.remove("invisible"); |
| outputStatusEl.innerText = "Calculating embeddings for query..."; |
| isCalculating = true; |
| const out = await getEmbeddings( |
| bertWorker, |
| modelURL, |
| tokenizerURL, |
| configURL, |
| modelID, |
| [query] |
| ); |
| outputStatusEl.classList.add("invisible"); |
| const queryEmbeddings = out.output[0]; |
| |
| const distances = sentenceEmbeddings |
| .map((embedding, id) => ({ |
| id, |
| similarity: cosineSimilarity(queryEmbeddings, embedding), |
| })) |
| .sort((a, b) => b.similarity - a.similarity) |
| |
| .slice(0, 10); |
| |
| const colorScale = scaleLinear() |
| .domain([ |
| distances[distances.length - 1].similarity, |
| distances[0].similarity, |
| ]) |
| .range([0, 1]) |
| .interpolate(() => interpolateReds); |
| outputAreaEl.querySelectorAll("span").forEach((el) => { |
| el.style.color = "unset"; |
| el.style.backgroundColor = "unset"; |
| }); |
| distances.forEach((d) => { |
| const el = outputAreaEl.querySelector(`#sentence-${d.id}`); |
| const color = colorScale(d.similarity); |
| const fontColor = hcl(color).l < 70 ? "white" : "black"; |
| el.style.color = fontColor; |
| el.style.backgroundColor = color; |
| }); |
| |
| outputAreaEl |
| .querySelector(`#sentence-${distances[0].id}`) |
| .scrollIntoView({ |
| behavior: "smooth", |
| block: "center", |
| inline: "nearest", |
| }); |
| |
| isCalculating = false; |
| toggleInputs(false); |
| }); |
| async function calculateEmbeddings(text) { |
| isCalculating = true; |
| toggleInputs(true); |
| const modelID = modelSelectEl.value; |
| const { modelURL, tokenizerURL, configURL, document_prefix } = |
| getModelInfo(modelID); |
| |
| const sentences = text.split(sentencesRegex); |
| const allEmbeddings = []; |
| outputStatusEl.classList.remove("invisible"); |
| for (const [id, sentence] of sentences.entries()) { |
| const query = document_prefix + sentence; |
| outputStatusEl.innerText = `Calculating embeddings: sentence ${ |
| id + 1 |
| } of ${sentences.length}`; |
| const embeddings = await getEmbeddings( |
| bertWorker, |
| modelURL, |
| tokenizerURL, |
| configURL, |
| modelID, |
| [query], |
| updateStatus |
| ); |
| allEmbeddings.push(embeddings); |
| } |
| outputStatusEl.classList.add("invisible"); |
| sentenceEmbeddings = allEmbeddings.map((e) => e.output[0]); |
| isCalculating = false; |
| toggleInputs(false); |
| } |
| |
| function updateStatus(data) { |
| if ("status" in data) { |
| if (data.status === "loading") { |
| outputStatusEl.innerText = data.message; |
| outputStatusEl.classList.remove("invisible"); |
| } |
| } |
| } |
| function toggleInputs(state) { |
| const interactive = document.querySelectorAll(".interactive"); |
| interactive.forEach((el) => { |
| if (state) { |
| el.disabled = true; |
| } else { |
| el.disabled = false; |
| } |
| }); |
| } |
| |
| searchWikiEl.addEventListener("input", () => { |
| searchWikiEl.setCustomValidity(""); |
| }); |
| |
| formWikiEl.addEventListener("submit", async (e) => { |
| e.preventDefault(); |
| if ("example" in e.submitter.dataset) { |
| searchWikiEl.value = e.submitter.innerText; |
| } |
| const text = searchWikiEl.value; |
| |
| if (isCalculating || text === "") return; |
| try { |
| const wikiText = await getWikiText(text); |
| searchWikiEl.setCustomValidity(""); |
| textAreaEl.innerHTML = wikiText; |
| populateOutputArea(wikiText); |
| calculateEmbeddings(wikiText); |
| searchWikiEl.value = ""; |
| } catch { |
| searchWikiEl.setCustomValidity("Invalid Wikipedia article name"); |
| searchWikiEl.reportValidity(); |
| } |
| }); |
| document.addEventListener("DOMContentLoaded", () => { |
| const query = new URLSearchParams(window.location.search); |
| const modelID = query.get("model"); |
| if (modelID) { |
| modelSelectEl.value = modelID; |
| modelSelectEl.dispatchEvent(new Event("change")); |
| } |
| }); |
| </script> |
| </head> |
| <body class="container max-w-4xl mx-auto p-4"> |
| <main class="grid grid-cols-1 gap-5 relative"> |
| <span class="absolute text-5xl -ml-[1em]"> 🕯️ </span> |
| <div> |
| <h1 class="text-5xl font-bold">Candle BERT</h1> |
| <h2 class="text-2xl font-bold">Rust/WASM Demo</h2> |
| <p class="max-w-lg"> |
| Running sentence embeddings and similarity search in the browser using |
| the Bert Model written with |
| <a |
| href="https://github.com/huggingface/candle/" |
| target="_blank" |
| class="underline hover:text-blue-500 hover:no-underline" |
| >Candle |
| </a> |
| and compiled to Wasm. Embeddings models from are from |
| <a |
| href="https://huggingface.co/sentence-transformers/" |
| target="_blank" |
| class="underline hover:text-blue-500 hover:no-underline"> |
| Sentence Transformers |
| </a> |
| and |
| <a |
| href="https://huggingface.co/intfloat/" |
| target="_blank" |
| class="underline hover:text-blue-500 hover:no-underline"> |
| Liang Wang - e5 Models |
| </a> |
| </p> |
| </div> |
|
|
| <div> |
| <label for="model" class="font-medium block">Models Options: </label> |
| <select |
| id="model" |
| class="border-2 border-gray-500 rounded-md font-light interactive disabled:cursor-not-allowed w-full max-w-max"> |
| <option value="gte_tiny">gte_tiny (45.5 MB)</option> |
| <option value="intfloat_e5_small_v2" selected> |
| intfloat/e5-small-v2 (133 MB) |
| </option> |
| <option value="intfloat_e5_base_v2"> |
| intfloat/e5-base-v2 (438 MB) |
| </option> |
| <option value="intfloat_multilingual_e5_small"> |
| intfloat/multilingual-e5-small (471 MB) |
| </option> |
| <option value="sentence_transformers_all_MiniLM_L6_v2"> |
| sentence-transformers/all-MiniLM-L6-v2 (90.9 MB) |
| </option> |
| <option value="sentence_transformers_all_MiniLM_L12_v2"> |
| sentence-transformers/all-MiniLM-L12-v2 (133 MB) |
| </option> |
| </select> |
| </div> |
| <div> |
| <h3 class="font-medium">Examples:</h3> |
| <form |
| id="form-wiki" |
| class="flex text-xs rounded-md justify-between w-min gap-3"> |
| <input type="submit" hidden /> |
|
|
| <button data-example class="disabled:cursor-not-allowed interactive"> |
| Pizza |
| </button> |
| <button data-example class="disabled:cursor-not-allowed interactive"> |
| Paris |
| </button> |
| <button data-example class="disabled:cursor-not-allowed interactive"> |
| Physics |
| </button> |
| <input |
| type="text" |
| id="search-wiki" |
| title="Search Wikipedia article by title" |
| class="font-light py-0 mx-1 resize-none outline-none w-32 disabled:cursor-not-allowed interactive" |
| placeholder="Load Wikipedia article..." /> |
| <button |
| title="Search Wikipedia article and load into input" |
| class="bg-gray-700 hover:bg-gray-800 text-white font-normal px-2 py-1 rounded disabled:bg-gray-300 disabled:cursor-not-allowed interactive"> |
| Load |
| </button> |
| </form> |
| </div> |
| <form |
| id="form" |
| class="flex text-normal px-1 py-1 border border-gray-700 rounded-md items-center"> |
| <input type="submit" hidden /> |
| <input |
| type="text" |
| id="search-input" |
| class="font-light w-full px-3 py-2 mx-1 resize-none outline-none interactive disabled:cursor-not-allowed" |
| placeholder="Search query here..." /> |
| <button |
| class="bg-gray-700 hover:bg-gray-800 text-white font-normal py-2 w-16 rounded disabled:bg-gray-300 disabled:cursor-not-allowed interactive"> |
| Search |
| </button> |
| </form> |
| <div> |
| <h3 class="font-medium">Input text:</h3> |
| <div class="flex justify-between items-center"> |
| <div class="rounded-md inline text-xs"> |
| <span id="output-status" class="m-auto font-light invisible" |
| >C</span |
| > |
| </div> |
| </div> |
| <div |
| id="input-container" |
| tabindex="0" |
| class="min-h-[250px] bg-slate-100 text-gray-500 rounded-md p-4 flex flex-col gap-2 relative"> |
| <textarea |
| id="input-area" |
| hidden |
| value="" |
| placeholder="Input text to perform semantic similarity search..." |
| class="flex-1 resize-none outline-none left-0 right-0 top-0 bottom-0 m-4 absolute interactive disabled:invisible"></textarea> |
| <p id="output-area" class="grid-rows-2"> |
| Input text to perform semantic similarity search... |
| </p> |
| </div> |
| </div> |
| </main> |
| </body> |
| </html> |
|
|