| #!/usr/bin/env node |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { AutoModel, AutoTokenizer } from '@huggingface/transformers'; |
| import { mkdirSync, writeFileSync } from 'node:fs'; |
| import { dirname, join } from 'node:path'; |
| import { fileURLToPath } from 'node:url'; |
|
|
| const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); |
| const OUT = join(ROOT, 'public', 'centroids', 'embeddinggemma-k8.json'); |
| const ENCODER = 'onnx-community/embeddinggemma-300m-ONNX'; |
| const CLUSTERING_PREFIX = 'task: clustering | query: '; |
| |
| |
| |
| |
| |
| |
| const DTYPE = 'q4'; |
| const K = 8; |
| const TARGET_SENTENCES = 400; |
| const SEED = 12345; |
|
|
| |
| function mulberry32(seed) { |
| let a = seed >>> 0; |
| return () => { |
| a |= 0; |
| a = (a + 0x6d2b79f5) | 0; |
| let t = Math.imul(a ^ (a >>> 15), 1 | a); |
| t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; |
| return ((t ^ (t >>> 14)) >>> 0) / 4294967296; |
| }; |
| } |
|
|
| async function fetchWikitextSentences() { |
| const sentences = []; |
| for (let offset = 0; offset < 1200 && sentences.length < TARGET_SENTENCES; offset += 100) { |
| const url = `https://datasets-server.huggingface.co/rows?dataset=Salesforce%2Fwikitext&config=wikitext-2-raw-v1&split=train&offset=${offset}&length=100`; |
| const res = await fetch(url); |
| if (!res.ok) throw new Error(`datasets-server HTTP ${res.status}`); |
| const json = await res.json(); |
| for (const row of json.rows ?? []) { |
| const text = (row.row?.text ?? '').trim(); |
| if (!text || text.startsWith('=')) continue; |
| for (const s of text.split(/(?<=[.!?])\s+/)) { |
| const t = s.trim(); |
| if (t.length >= 40 && t.length <= 300 && /[.!?]$/.test(t)) sentences.push(t); |
| if (sentences.length >= TARGET_SENTENCES) break; |
| } |
| if (sentences.length >= TARGET_SENTENCES) break; |
| } |
| } |
| return sentences; |
| } |
|
|
| const FALLBACK = [ |
| 'The committee approved the budget after a lengthy debate about infrastructure spending.', |
| 'Photosynthesis converts sunlight, water, and carbon dioxide into glucose and oxygen.', |
| 'The orchestra performed the symphony with remarkable precision and emotional depth.', |
| 'Quarterly earnings exceeded analyst expectations despite rising material costs.', |
| 'The hikers reached the summit just before the storm clouds rolled in.', |
| 'Researchers discovered a new species of frog in the remote rainforest valley.', |
| 'The museum unveiled a restored painting that had been hidden for decades.', |
| 'Global temperatures have risen steadily over the past century, reshaping ecosystems.', |
| 'The chef combined traditional techniques with unexpected local ingredients.', |
| 'Engineers tested the bridge design under simulated earthquake conditions.', |
| 'The novel follows three generations of a family through war and migration.', |
| 'Voter turnout reached a record high in several coastal districts this year.', |
| 'The spacecraft transmitted its first images of the icy moon at dawn.', |
| 'Local farmers adopted drip irrigation to cope with the prolonged drought.', |
| 'The startup raised new funding to expand its battery recycling operations.', |
| 'Archaeologists uncovered pottery fragments dating back three thousand years.', |
| 'The goalkeeper made a spectacular save in the final minute of the match.', |
| 'New regulations require companies to disclose their supply chain emissions.', |
| 'The pianist practiced the difficult passage until it sounded effortless.', |
| 'Coral reefs support a quarter of all marine species despite covering little area.', |
| ]; |
|
|
| function kmeans(vectors, k, rng) { |
| const n = vectors.length; |
| const dim = vectors[0].length; |
| |
| const centroids = [vectors[Math.floor(rng() * n)].slice()]; |
| while (centroids.length < k) { |
| const d2 = vectors.map((v) => { |
| let best = Infinity; |
| for (const c of centroids) { |
| let dot = 0; |
| for (let i = 0; i < dim; i++) dot += v[i] * c[i]; |
| const d = 1 - dot; |
| if (d < best) best = d; |
| } |
| return best * best; |
| }); |
| const sum = d2.reduce((a, b) => a + b, 0); |
| let r = rng() * sum; |
| let idx = 0; |
| for (; idx < n - 1; idx++) { |
| r -= d2[idx]; |
| if (r <= 0) break; |
| } |
| centroids.push(vectors[idx].slice()); |
| } |
|
|
| const assign = new Array(n).fill(0); |
| for (let iter = 0; iter < 100; iter++) { |
| let changed = 0; |
| for (let i = 0; i < n; i++) { |
| let best = 0; |
| let bestD = Infinity; |
| for (let c = 0; c < k; c++) { |
| let dot = 0; |
| for (let j = 0; j < dim; j++) dot += vectors[i][j] * centroids[c][j]; |
| const d = 1 - dot; |
| if (d < bestD) { |
| bestD = d; |
| best = c; |
| } |
| } |
| if (assign[i] !== best) { |
| assign[i] = best; |
| changed++; |
| } |
| } |
| |
| for (let c = 0; c < k; c++) { |
| const sum = new Array(dim).fill(0); |
| let count = 0; |
| for (let i = 0; i < n; i++) { |
| if (assign[i] !== c) continue; |
| count++; |
| for (let j = 0; j < dim; j++) sum[j] += vectors[i][j]; |
| } |
| if (count === 0) continue; |
| let norm = 0; |
| for (let j = 0; j < dim; j++) norm += sum[j] * sum[j]; |
| norm = Math.sqrt(norm) || 1; |
| for (let j = 0; j < dim; j++) centroids[c][j] = sum[j] / norm; |
| } |
| if (changed === 0) break; |
| } |
| const sizes = new Array(k).fill(0); |
| for (const a of assign) sizes[a]++; |
| return { centroids, sizes }; |
| } |
|
|
| |
| |
| |
| const NARRATIVE = [ |
| 'The old keeper climbed the spiral staircase as the wind howled outside.', |
| 'She had never seen a light quite like the one glowing beneath the waves.', |
| 'Every evening he wrote a single line in his weather-beaten journal.', |
| 'The village children whispered stories about the tower on the cliff.', |
| 'A strange ship appeared on the horizon just before midnight.', |
| 'His lantern flickered, and for a moment the darkness felt alive.', |
| 'The storm had passed, leaving the shore littered with pale driftwood.', |
| 'She wrapped the mysterious object in cloth and hid it beneath the floorboards.', |
| 'Nobody in town believed him, but the light kept returning every night.', |
| 'The fog rolled in thick, swallowing the beam before it reached the water.', |
| 'He remembered his grandfather saying the sea keeps what it takes.', |
| 'At dawn the tide revealed a door in the rocks that had never been there.', |
| 'Her heart pounded as the signal repeated: three flashes, then silence.', |
| 'The keeper brewed his tea and watched the horizon with tired eyes.', |
| 'Something metallic glinted in the sand where the gulls had gathered.', |
| 'The letters stopped coming in October, and the winter felt endless.', |
| 'He rowed out at first light, following the trail of green phosphorescence.', |
| 'The melody drifted up from the cellar, soft and impossibly old.', |
| 'They said the previous keeper had vanished without taking his coat.', |
| 'By morning the writing on the glass had faded, but she remembered every word.', |
| 'The captain lowered his voice and told them what he had seen in the trench.', |
| 'A key turned in a lock that no one had opened for forty years.', |
| 'The child drew the same spiral again and again in her notebook.', |
| 'When the generator failed, the stars felt suddenly very close.', |
| 'He counted the ships as they passed, and one of them counted him back.', |
| 'The tide pool held a color that did not belong to this coast.', |
| 'Her boots left the only prints on the long gray beach.', |
| 'The radio crackled with a voice reciting coordinates that did not exist.', |
| 'In the attic they found charts of a coastline no map had ever shown.', |
| 'The bell buoy rang twice, though the sea was perfectly still.', |
| ]; |
|
|
| |
| |
| |
| const KOREAN = [ |
| '๋ ๋ชฌ ์นํจ ํ์คํ ๋ ์ํผ๋ฅผ ์๋ ค์ค.', |
| '์ฌ๋ฆฌ๋ธ์ ์ ๋ง๋์ ๋ณถ๋ค๊ฐ ๋ญ๊ฐ์ด์ด์ ๋ฃ๊ณ ๋
ธ๋ฆํ๊ฒ ์ตํ๋ค.', |
| '๋ฉด์ ์๊ธ์ ๋๋ํ ๋ฃ์ ๋ฌผ์ ์ ๋ดํ
๋ก ์ถ๋๋ค.', |
| '๋ง์ง๋ง์ ๋ ๋ชฌ์ฆ๊ณผ ํ์ฌ๋ฆฌ๋ฅผ ๋ฟ๋ฆฌ๋ฉด ํฅ์ด ์ด์๋๋ค.', |
| '๋ฑ๋์ง๊ธฐ๋ ๋งค์ผ ๋ฐค ์ํ์ ์ ๋ฐ๋ผ๋ณด๋ฉฐ ๊ธฐ๋ก์ ๋จ๊ฒผ๋ค.', |
| 'ํญํ์ด ์ง๋๊ฐ ์์นจ, ํด๋ณ์๋ ๋ฏ์ ๋๋ฌด ์กฐ๊ฐ๋ค์ด ๋ฐ๋ ค์ ์์๋ค.', |
| '๋ง์ ์ฌ๋๋ค์ ์ ๋ฒฝ ์ ํ์ ๋ํ ์ด์ผ๊ธฐ๋ฅผ ์๊ณค๊ฑฐ๋ ธ๋ค.', |
| '๊ทธ๋ ๋ฑ๋ถ์ ๋ค๊ณ ๋์ ๊ณ๋จ์ ์ฒ์ฒํ ์ฌ๋ผ๊ฐ๋ค.', |
| '์ฐ๊ตฌ์ง์ ์๋ก์ด ๊ฐ๊ตฌ๋ฆฌ ์ข
์ ์ด๋ ์ฐ๋ฆผ์์ ๋ฐ๊ฒฌํ๋ค.', |
| '์ง๋ 100๋
๋์ ์ง๊ตฌ์ ํ๊ท ๊ธฐ์จ์ ๊พธ์คํ ์์นํ๋ค.', |
| '์ด๋ฒ ๋ถ๊ธฐ ์ค์ ์ ์์์ฌ ๊ฐ๊ฒฉ ์์น์๋ ์์์น๋ฅผ ์๋์๋ค.', |
| '์์ํ๋ ์ค๋ ๋
ผ์ ๋์ ์์ฐ์์ ์น์ธํ๋ค.', |
| '๋ฐ๋ฌผ๊ด์ ์์ญ ๋
๊ฐ ๋ณด๊ด๋ผ ์๋ ๊ทธ๋ฆผ์ ๋ณต์ํด ๊ณต๊ฐํ๋ค.', |
| '๊ดํฉ์ฑ์ ํ๋น๊ณผ ๋ฌผ, ์ด์ฐํํ์๋ฅผ ํฌ๋๋น๊ณผ ์ฐ์๋ก ๋ฐ๊พผ๋ค.', |
| '๊ธฐ์ ์ด ๋ฐ์ ํ๋ฉด์ ๋ฒ์ญ์ ํ์ง๋ ํฌ๊ฒ ์ข์์ก๋ค.', |
| '๊ทธ ์์ค์ ์ ์๊ณผ ์ด์ฃผ๋ฅผ ๊ฒช์ ์ธ ์ธ๋์ ์ด์ผ๊ธฐ๋ฅผ ๋ฐ๋ผ๊ฐ๋ค.', |
| '์์ด๋ ๊ณต์ฑ
์ ๊ฐ์ ๋์ ์ ๋ช ๋ฒ์ด๊ณ ๋ค์ ๊ทธ๋ ธ๋ค.', |
| '์๋ฒฝ์ด ๋์ ๋ฐ๋ฌผ์ด ๋น ์ง๋ฉฐ ๋ฐ์ ์ฌ์ด์ ๋ฌธ์ด ๋๋ฌ๋ฌ๋ค.', |
| '์์ง๋์ด๋ค์ ์ง์ง์ ๊ฐ์ ํ ์กฐ๊ฑด์์ ๋ค๋ฆฌ ์ค๊ณ๋ฅผ ์ํํ๋ค.', |
| '์ฃผ๋ง ๋์ฅ์์๋ ๋ฌผ์ ์๋ผ๊ธฐ ์ํด ์ ์ ๊ด์๋ฅผ ๋์
ํ๋ค.', |
| ]; |
|
|
| async function main() { |
| let sentences; |
| let corpus; |
| try { |
| const wiki = await fetchWikitextSentences(); |
| sentences = [...wiki, ...NARRATIVE, ...KOREAN]; |
| corpus = `wikitext-2-raw-v1 (${wiki.length}) + narrative (${NARRATIVE.length}) + Korean (${KOREAN.length})`; |
| console.log(`Fetched ${wiki.length} wikitext + ${NARRATIVE.length} narrative + ${KOREAN.length} Korean`); |
| } catch (e) { |
| console.warn('wikitext fetch failed, using built-in fallback corpus:', e.message); |
| sentences = [...FALLBACK, ...NARRATIVE, ...KOREAN]; |
| corpus = `built-in fallback (${sentences.length} sentences)`; |
| } |
|
|
| console.log('Loading EmbeddingGemma...'); |
| const tokenizer = await AutoTokenizer.from_pretrained(ENCODER); |
| const model = await AutoModel.from_pretrained(ENCODER, { dtype: DTYPE }); |
|
|
| console.log(`Embedding ${sentences.length} sentences...`); |
| const vectors = []; |
| const BATCH = 16; |
| for (let i = 0; i < sentences.length; i += BATCH) { |
| const batch = sentences.slice(i, i + BATCH).map((s) => CLUSTERING_PREFIX + s); |
| const inputs = await tokenizer(batch, { padding: true }); |
| const { sentence_embedding } = await model(inputs); |
| const [n, d] = sentence_embedding.dims; |
| for (let r = 0; r < n; r++) { |
| vectors.push(Array.from(sentence_embedding.data.slice(r * d, (r + 1) * d))); |
| } |
| console.log(` ${Math.min(i + BATCH, sentences.length)}/${sentences.length}`); |
| } |
|
|
| |
| |
| |
| |
| |
| const dim = vectors[0].length; |
| const meanVec = new Array(dim).fill(0); |
| for (const v of vectors) for (let j = 0; j < dim; j++) meanVec[j] += v[j] / vectors.length; |
| const centered = vectors.map((v) => { |
| const c = v.map((x, j) => x - meanVec[j]); |
| const norm = Math.hypot(...c) || 1; |
| return c.map((x) => x / norm); |
| }); |
|
|
| console.log('Running spherical k-means on mean-centered embeddings...'); |
| const rng = mulberry32(SEED); |
| const { centroids, sizes } = kmeans(centered, K, rng); |
| console.log('Cluster sizes:', sizes); |
|
|
| const payload = { |
| vectors: centroids.map((c) => c.map((x) => Number(x.toFixed(6)))), |
| mean: meanVec.map((x) => Number(x.toFixed(6))), |
| dim, |
| prefix: CLUSTERING_PREFIX, |
| dtype: DTYPE, |
| encoder: ENCODER, |
| corpus, |
| k: K, |
| seed: SEED, |
| generatedAt: new Date().toISOString(), |
| }; |
| mkdirSync(dirname(OUT), { recursive: true }); |
| writeFileSync(OUT, JSON.stringify(payload)); |
| console.log(`Wrote ${OUT}`); |
| } |
|
|
| main().catch((e) => { |
| console.error(e); |
| process.exit(1); |
| }); |
|
|