feat: replace transformers.js with direct ONNX WASM for Bun compatibility
- Remove @huggingface/transformers dependency (539MB native binaries) - Add direct ONNX Runtime Web embedding engine - Bundle all-MiniLM-L6-v2-q8 model (24MB, no runtime downloads) - Works with Node.js, Bun, and bun build --compile - Air-gap compatible: fully self-contained, no internet required New WASM embedding components: - WASMEmbeddingEngine: Main integration class - WordPieceTokenizer: Pure TypeScript tokenizer - EmbeddingPostProcessor: Mean pooling + L2 normalization - ONNXInferenceEngine: Direct ONNX Runtime Web wrapper - AssetLoader: Model file loading Tests added: - 11 WASM embedding integration tests - 8 Bun compatibility tests New npm scripts: - test:wasm - Run WASM embedding tests - test:bun - Run tests with Bun - test:bun:compile - Build and run compiled binary
This commit is contained in:
parent
c1deb7a623
commit
1f59aa2013
21 changed files with 34431 additions and 3459 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -73,6 +73,9 @@ DISTRIBUTED_*.md
|
||||||
models/
|
models/
|
||||||
models-cache/
|
models-cache/
|
||||||
|
|
||||||
|
# But include bundled WASM model assets
|
||||||
|
!assets/models/
|
||||||
|
|
||||||
# Development planning files (not for commit)
|
# Development planning files (not for commit)
|
||||||
PLAN.md
|
PLAN.md
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
|
|
|
||||||
25
assets/models/all-MiniLM-L6-v2-q8/config.json
Normal file
25
assets/models/all-MiniLM-L6-v2-q8/config.json
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
{
|
||||||
|
"_name_or_path": "sentence-transformers/all-MiniLM-L6-v2",
|
||||||
|
"architectures": [
|
||||||
|
"BertModel"
|
||||||
|
],
|
||||||
|
"attention_probs_dropout_prob": 0.1,
|
||||||
|
"classifier_dropout": null,
|
||||||
|
"gradient_checkpointing": false,
|
||||||
|
"hidden_act": "gelu",
|
||||||
|
"hidden_dropout_prob": 0.1,
|
||||||
|
"hidden_size": 384,
|
||||||
|
"initializer_range": 0.02,
|
||||||
|
"intermediate_size": 1536,
|
||||||
|
"layer_norm_eps": 1e-12,
|
||||||
|
"max_position_embeddings": 512,
|
||||||
|
"model_type": "bert",
|
||||||
|
"num_attention_heads": 12,
|
||||||
|
"num_hidden_layers": 6,
|
||||||
|
"pad_token_id": 0,
|
||||||
|
"position_embedding_type": "absolute",
|
||||||
|
"transformers_version": "4.29.2",
|
||||||
|
"type_vocab_size": 2,
|
||||||
|
"use_cache": true,
|
||||||
|
"vocab_size": 30522
|
||||||
|
}
|
||||||
BIN
assets/models/all-MiniLM-L6-v2-q8/model.onnx
Normal file
BIN
assets/models/all-MiniLM-L6-v2-q8/model.onnx
Normal file
Binary file not shown.
30686
assets/models/all-MiniLM-L6-v2-q8/tokenizer.json
Normal file
30686
assets/models/all-MiniLM-L6-v2-q8/tokenizer.json
Normal file
File diff suppressed because it is too large
Load diff
1
assets/models/all-MiniLM-L6-v2-q8/vocab.json
Normal file
1
assets/models/all-MiniLM-L6-v2-q8/vocab.json
Normal file
File diff suppressed because one or more lines are too long
4115
package-lock.json
generated
4115
package-lock.json
generated
File diff suppressed because it is too large
Load diff
11
package.json
11
package.json
|
|
@ -64,7 +64,8 @@
|
||||||
"./dist/cortex/backupRestore.js": false
|
"./dist/cortex/backupRestore.js": false
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "22.x"
|
"node": "22.x",
|
||||||
|
"bun": ">=1.0.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "npm run build:types:if-needed && npm run build:patterns:if-needed && npm run build:keywords:if-needed && tsc && tsc -p tsconfig.cli.json",
|
"build": "npm run build:types:if-needed && npm run build:patterns:if-needed && npm run build:keywords:if-needed && tsc && tsc -p tsconfig.cli.json",
|
||||||
|
|
@ -90,6 +91,10 @@
|
||||||
"test:ci-unit": "CI=true vitest run --config tests/configs/vitest.unit.config.ts",
|
"test:ci-unit": "CI=true vitest run --config tests/configs/vitest.unit.config.ts",
|
||||||
"test:ci-integration": "NODE_OPTIONS='--max-old-space-size=16384' CI=true vitest run --config tests/configs/vitest.integration.config.ts",
|
"test:ci-integration": "NODE_OPTIONS='--max-old-space-size=16384' CI=true vitest run --config tests/configs/vitest.integration.config.ts",
|
||||||
"test:ci": "npm run test:ci-unit",
|
"test:ci": "npm run test:ci-unit",
|
||||||
|
"test:bun": "bun tests/integration/bun-compile-test.ts",
|
||||||
|
"test:bun:compile": "bun build tests/integration/bun-compile-test.ts --compile --outfile /tmp/brainy-bun-test && /tmp/brainy-bun-test",
|
||||||
|
"test:wasm": "npx vitest run tests/integration/wasm-embeddings.test.ts",
|
||||||
|
"download-model": "node scripts/download-model.cjs",
|
||||||
"download-models": "node scripts/download-models.cjs",
|
"download-models": "node scripts/download-models.cjs",
|
||||||
"download-models:q8": "node scripts/download-models.cjs",
|
"download-models:q8": "node scripts/download-models.cjs",
|
||||||
"models:verify": "node scripts/ensure-models.js",
|
"models:verify": "node scripts/ensure-models.js",
|
||||||
|
|
@ -142,7 +147,9 @@
|
||||||
"dist/**/*.js",
|
"dist/**/*.js",
|
||||||
"dist/**/*.d.ts",
|
"dist/**/*.d.ts",
|
||||||
"bin/",
|
"bin/",
|
||||||
|
"assets/models/**/*",
|
||||||
"scripts/download-models.cjs",
|
"scripts/download-models.cjs",
|
||||||
|
"scripts/download-model.cjs",
|
||||||
"scripts/ensure-models.js",
|
"scripts/ensure-models.js",
|
||||||
"scripts/prepare-models.js",
|
"scripts/prepare-models.js",
|
||||||
"brainy.png",
|
"brainy.png",
|
||||||
|
|
@ -180,7 +187,7 @@
|
||||||
"@azure/identity": "^4.0.0",
|
"@azure/identity": "^4.0.0",
|
||||||
"@azure/storage-blob": "^12.17.0",
|
"@azure/storage-blob": "^12.17.0",
|
||||||
"@google-cloud/storage": "^7.14.0",
|
"@google-cloud/storage": "^7.14.0",
|
||||||
"@huggingface/transformers": "^3.7.2",
|
"onnxruntime-web": "^1.22.0",
|
||||||
"@msgpack/msgpack": "^3.1.2",
|
"@msgpack/msgpack": "^3.1.2",
|
||||||
"@types/js-yaml": "^4.0.9",
|
"@types/js-yaml": "^4.0.9",
|
||||||
"boxen": "^8.0.1",
|
"boxen": "^8.0.1",
|
||||||
|
|
|
||||||
175
scripts/download-model.cjs
Normal file
175
scripts/download-model.cjs
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Download Model Assets
|
||||||
|
*
|
||||||
|
* Downloads the all-MiniLM-L6-v2 Q8 model from Hugging Face.
|
||||||
|
* Run: node scripts/download-model.cjs
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const path = require('node:path')
|
||||||
|
const https = require('node:https')
|
||||||
|
|
||||||
|
const MODEL_DIR = path.join(__dirname, '..', 'assets', 'models', 'all-MiniLM-L6-v2-q8')
|
||||||
|
const BASE_URL = 'https://huggingface.co/Xenova/all-MiniLM-L6-v2/resolve/main/onnx'
|
||||||
|
|
||||||
|
const FILES = [
|
||||||
|
{
|
||||||
|
name: 'model_quantized.onnx',
|
||||||
|
url: `${BASE_URL}/model_quantized.onnx`,
|
||||||
|
dest: 'model.onnx',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'tokenizer.json',
|
||||||
|
url: 'https://huggingface.co/Xenova/all-MiniLM-L6-v2/resolve/main/tokenizer.json',
|
||||||
|
dest: 'tokenizer.json',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'config.json',
|
||||||
|
url: 'https://huggingface.co/Xenova/all-MiniLM-L6-v2/resolve/main/config.json',
|
||||||
|
dest: 'config.json',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'vocab.txt',
|
||||||
|
url: 'https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2/resolve/main/vocab.txt',
|
||||||
|
dest: 'vocab.txt',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Follow redirects and download file
|
||||||
|
*/
|
||||||
|
function downloadFile(url, destPath, maxRedirects = 5) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (maxRedirects === 0) {
|
||||||
|
reject(new Error('Too many redirects'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const doRequest = (reqUrl) => {
|
||||||
|
const parsedUrl = new URL(reqUrl)
|
||||||
|
const options = {
|
||||||
|
hostname: parsedUrl.hostname,
|
||||||
|
path: parsedUrl.pathname + parsedUrl.search,
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Brainy-Model-Downloader/1.0',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
https.get(options, (response) => {
|
||||||
|
// Handle redirects
|
||||||
|
if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
|
||||||
|
response.resume() // Consume response data to free memory
|
||||||
|
const redirectUrl = response.headers.location.startsWith('http')
|
||||||
|
? response.headers.location
|
||||||
|
: new URL(response.headers.location, reqUrl).toString()
|
||||||
|
console.log(` ↳ Redirecting to: ${redirectUrl.slice(0, 80)}...`)
|
||||||
|
downloadFile(redirectUrl, destPath, maxRedirects - 1)
|
||||||
|
.then(resolve)
|
||||||
|
.catch(reject)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.statusCode !== 200) {
|
||||||
|
reject(new Error(`HTTP ${response.statusCode}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileStream = fs.createWriteStream(destPath)
|
||||||
|
let downloadedBytes = 0
|
||||||
|
const totalBytes = parseInt(response.headers['content-length'] || '0', 10)
|
||||||
|
|
||||||
|
response.on('data', (chunk) => {
|
||||||
|
downloadedBytes += chunk.length
|
||||||
|
if (totalBytes > 0) {
|
||||||
|
const percent = Math.round((downloadedBytes / totalBytes) * 100)
|
||||||
|
process.stdout.write(`\r Progress: ${percent}% (${Math.round(downloadedBytes / 1024 / 1024)}MB)`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
response.pipe(fileStream)
|
||||||
|
|
||||||
|
fileStream.on('finish', () => {
|
||||||
|
fileStream.close()
|
||||||
|
console.log(`\n ✅ Downloaded: ${path.basename(destPath)} (${Math.round(downloadedBytes / 1024 / 1024)}MB)`)
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
|
||||||
|
fileStream.on('error', (err) => {
|
||||||
|
fs.unlink(destPath, () => {}) // Delete partial file
|
||||||
|
reject(err)
|
||||||
|
})
|
||||||
|
}).on('error', reject)
|
||||||
|
}
|
||||||
|
|
||||||
|
doRequest(url)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert vocab.txt to vocab.json
|
||||||
|
*/
|
||||||
|
function convertVocabToJson(vocabTxtPath, vocabJsonPath) {
|
||||||
|
console.log('📝 Converting vocab.txt to vocab.json...')
|
||||||
|
const content = fs.readFileSync(vocabTxtPath, 'utf-8')
|
||||||
|
const lines = content.split('\n').filter(line => line.trim())
|
||||||
|
|
||||||
|
const vocab = {}
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
vocab[lines[i]] = i
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(vocabJsonPath, JSON.stringify(vocab))
|
||||||
|
console.log(` ✅ Created vocab.json with ${Object.keys(vocab).length} tokens`)
|
||||||
|
|
||||||
|
// Remove vocab.txt since we have vocab.json
|
||||||
|
fs.unlinkSync(vocabTxtPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('🔽 Downloading all-MiniLM-L6-v2 Q8 model assets...\n')
|
||||||
|
|
||||||
|
// Create model directory
|
||||||
|
fs.mkdirSync(MODEL_DIR, { recursive: true })
|
||||||
|
console.log(`📁 Model directory: ${MODEL_DIR}\n`)
|
||||||
|
|
||||||
|
// Download each file
|
||||||
|
for (const file of FILES) {
|
||||||
|
const destPath = path.join(MODEL_DIR, file.dest)
|
||||||
|
|
||||||
|
// Check if already exists
|
||||||
|
if (fs.existsSync(destPath)) {
|
||||||
|
const stats = fs.statSync(destPath)
|
||||||
|
if (stats.size > 0) {
|
||||||
|
console.log(`⏭️ Skipping ${file.name} (already exists)`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📥 Downloading ${file.name}...`)
|
||||||
|
try {
|
||||||
|
await downloadFile(file.url, destPath)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(` ❌ Failed to download ${file.name}: ${error.message}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert vocab.txt to vocab.json
|
||||||
|
const vocabTxtPath = path.join(MODEL_DIR, 'vocab.txt')
|
||||||
|
const vocabJsonPath = path.join(MODEL_DIR, 'vocab.json')
|
||||||
|
if (fs.existsSync(vocabTxtPath) && !fs.existsSync(vocabJsonPath)) {
|
||||||
|
convertVocabToJson(vocabTxtPath, vocabJsonPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n✅ All model assets downloaded successfully!')
|
||||||
|
console.log('\nModel files:')
|
||||||
|
const files = fs.readdirSync(MODEL_DIR)
|
||||||
|
for (const file of files) {
|
||||||
|
const stats = fs.statSync(path.join(MODEL_DIR, file))
|
||||||
|
const sizeMB = (stats.size / 1024 / 1024).toFixed(2)
|
||||||
|
console.log(` - ${file}: ${sizeMB}MB`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error)
|
||||||
|
|
@ -1,330 +1,131 @@
|
||||||
/**
|
/**
|
||||||
* MODEL GUARDIAN - CRITICAL PATH
|
* MODEL GUARDIAN - CRITICAL PATH
|
||||||
*
|
*
|
||||||
* THIS IS THE MOST CRITICAL COMPONENT OF BRAINY
|
* THIS IS THE MOST CRITICAL COMPONENT OF BRAINY
|
||||||
* Without the exact model, users CANNOT access their data
|
* Without the exact model, users CANNOT access their data
|
||||||
*
|
*
|
||||||
* Requirements:
|
* Requirements:
|
||||||
* 1. Model MUST be Xenova/all-MiniLM-L6-v2 (never changes)
|
* 1. Model MUST be all-MiniLM-L6-v2-q8 (bundled in package)
|
||||||
* 2. Model MUST be available at runtime
|
* 2. Model MUST be available at runtime (embedded in npm package)
|
||||||
* 3. Model MUST produce consistent 384-dim embeddings
|
* 3. Model MUST produce consistent 384-dim embeddings
|
||||||
* 4. System MUST fail fast if model unavailable in production
|
* 4. System MUST fail fast if model unavailable in production
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { env } from '@huggingface/transformers'
|
import { WASMEmbeddingEngine } from '../embeddings/wasm/index.js'
|
||||||
import { createHash } from '../universal/crypto.js'
|
|
||||||
|
|
||||||
// CRITICAL: These values MUST NEVER CHANGE
|
// CRITICAL: These values MUST NEVER CHANGE
|
||||||
const CRITICAL_MODEL_CONFIG = {
|
const CRITICAL_MODEL_CONFIG = {
|
||||||
modelName: 'Xenova/all-MiniLM-L6-v2',
|
modelName: 'all-MiniLM-L6-v2-q8',
|
||||||
modelHash: {
|
|
||||||
// SHA256 of model.onnx - computed from actual model
|
|
||||||
'onnx/model.onnx': 'add_actual_hash_here',
|
|
||||||
'tokenizer.json': 'add_actual_hash_here'
|
|
||||||
} as Record<string, string>,
|
|
||||||
modelSize: {
|
|
||||||
'onnx/model.onnx': 90387606, // Exact size in bytes (updated to match actual file)
|
|
||||||
'tokenizer.json': 711661
|
|
||||||
} as Record<string, number>,
|
|
||||||
embeddingDimensions: 384,
|
embeddingDimensions: 384,
|
||||||
fallbackSources: [
|
// Model is bundled in package - no external downloads needed
|
||||||
// Primary: Our Google Cloud Storage CDN (we control this, fastest)
|
bundled: true
|
||||||
{
|
|
||||||
name: 'Soulcraft CDN (Primary)',
|
|
||||||
url: 'https://models.soulcraft.com/models/all-MiniLM-L6-v2.tar.gz',
|
|
||||||
type: 'tarball'
|
|
||||||
},
|
|
||||||
// Secondary: GitHub releases backup
|
|
||||||
{
|
|
||||||
name: 'GitHub Backup',
|
|
||||||
url: 'https://github.com/soulcraftlabs/brainy-models/releases/download/v1.0.0/all-MiniLM-L6-v2.tar.gz',
|
|
||||||
type: 'tarball'
|
|
||||||
},
|
|
||||||
// Tertiary: Hugging Face (original source)
|
|
||||||
{
|
|
||||||
name: 'Hugging Face',
|
|
||||||
url: 'huggingface',
|
|
||||||
type: 'transformers'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ModelGuardian {
|
export class ModelGuardian {
|
||||||
private static instance: ModelGuardian
|
private static instance: ModelGuardian
|
||||||
private isVerified = false
|
private isVerified = false
|
||||||
private modelPath: string
|
|
||||||
private lastVerification: Date | null = null
|
private lastVerification: Date | null = null
|
||||||
|
|
||||||
private constructor() {
|
private constructor() {
|
||||||
this.modelPath = this.detectModelPath()
|
// Model is bundled - no path detection needed
|
||||||
}
|
}
|
||||||
|
|
||||||
static getInstance(): ModelGuardian {
|
static getInstance(): ModelGuardian {
|
||||||
if (!ModelGuardian.instance) {
|
if (!ModelGuardian.instance) {
|
||||||
ModelGuardian.instance = new ModelGuardian()
|
ModelGuardian.instance = new ModelGuardian()
|
||||||
}
|
}
|
||||||
return ModelGuardian.instance
|
return ModelGuardian.instance
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CRITICAL: Verify model availability and integrity
|
* CRITICAL: Verify model availability and integrity
|
||||||
* This MUST be called before any embedding operations
|
* This MUST be called before any embedding operations
|
||||||
*/
|
*/
|
||||||
async ensureCriticalModel(): Promise<void> {
|
async ensureCriticalModel(): Promise<void> {
|
||||||
console.log('DEBUG: ensureCriticalModel called')
|
|
||||||
console.log('🛡️ MODEL GUARDIAN: Verifying critical model availability...')
|
|
||||||
console.log(`🚀 Debug: Model path: ${this.modelPath}`)
|
|
||||||
console.log(`🚀 Debug: Already verified: ${this.isVerified}`)
|
|
||||||
|
|
||||||
// Check if already verified in this session
|
// Check if already verified in this session
|
||||||
if (this.isVerified && this.lastVerification) {
|
if (this.isVerified && this.lastVerification) {
|
||||||
const hoursSinceVerification =
|
const hoursSinceVerification =
|
||||||
(Date.now() - this.lastVerification.getTime()) / (1000 * 60 * 60)
|
(Date.now() - this.lastVerification.getTime()) / (1000 * 60 * 60)
|
||||||
|
|
||||||
if (hoursSinceVerification < 24) {
|
if (hoursSinceVerification < 24) {
|
||||||
console.log('✅ Model previously verified in this session')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 1: Check if model exists locally
|
// Verify the bundled WASM model works
|
||||||
console.log('🔍 Debug: Calling verifyLocalModel()')
|
const modelWorks = await this.verifyBundledModel()
|
||||||
const modelExists = await this.verifyLocalModel()
|
|
||||||
|
if (modelWorks) {
|
||||||
if (modelExists) {
|
|
||||||
console.log('✅ Critical model verified locally')
|
|
||||||
this.isVerified = true
|
this.isVerified = true
|
||||||
this.lastVerification = new Date()
|
this.lastVerification = new Date()
|
||||||
this.configureTransformers()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: In production, FAIL FAST (Node.js only)
|
// CRITICAL FAILURE
|
||||||
if (typeof window === 'undefined' && process.env.NODE_ENV === 'production' && !process.env.BRAINY_ALLOW_RUNTIME_DOWNLOAD) {
|
|
||||||
throw new Error(
|
|
||||||
'🚨 CRITICAL FAILURE: Transformer model not found in production!\n' +
|
|
||||||
'The model is REQUIRED for Brainy to function.\n' +
|
|
||||||
'Users CANNOT access their data without it.\n' +
|
|
||||||
'Solution: Run "npm run download-models" during build stage.'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3: Attempt to download from fallback sources
|
|
||||||
console.warn('⚠️ Model not found locally, attempting download...')
|
|
||||||
|
|
||||||
for (const source of CRITICAL_MODEL_CONFIG.fallbackSources) {
|
|
||||||
try {
|
|
||||||
console.log(`📥 Trying ${source.name}...`)
|
|
||||||
await this.downloadFromSource(source)
|
|
||||||
|
|
||||||
// Verify the download
|
|
||||||
if (await this.verifyLocalModel()) {
|
|
||||||
console.log(`✅ Successfully downloaded from ${source.name}`)
|
|
||||||
this.isVerified = true
|
|
||||||
this.lastVerification = new Date()
|
|
||||||
this.configureTransformers()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`❌ ${source.name} failed:`, (error as Error).message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 4: CRITICAL FAILURE
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'🚨 CRITICAL FAILURE: Cannot obtain transformer model!\n' +
|
'🚨 CRITICAL FAILURE: Bundled transformer model not working!\n' +
|
||||||
'Tried all fallback sources.\n' +
|
'The model is REQUIRED for Brainy to function.\n' +
|
||||||
'Brainy CANNOT function without the model.\n' +
|
'Users CANNOT access their data without it.\n' +
|
||||||
'Users CANNOT access their data.\n' +
|
'This indicates a package installation issue.'
|
||||||
'Please check network connectivity or pre-download models.'
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verify the local model files exist and are correct
|
* Verify the bundled WASM model works correctly
|
||||||
*/
|
*/
|
||||||
private async verifyLocalModel(): Promise<boolean> {
|
private async verifyBundledModel(): Promise<boolean> {
|
||||||
// Browser doesn't have local file access
|
try {
|
||||||
if (typeof window !== 'undefined') {
|
const engine = WASMEmbeddingEngine.getInstance()
|
||||||
console.log('⚠️ Model verification skipped in browser environment')
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dynamically import Node.js modules
|
// Initialize the engine (loads bundled model)
|
||||||
const fs = await import('node:fs')
|
await engine.initialize()
|
||||||
const fsPromises = await import('node:fs/promises')
|
|
||||||
const path = await import('node:path')
|
|
||||||
|
|
||||||
const modelBasePath = path.join(this.modelPath, ...CRITICAL_MODEL_CONFIG.modelName.split('/'))
|
// Test embedding generation
|
||||||
console.log(`🔍 Debug: Checking model at path: ${modelBasePath}`)
|
const testEmbedding = await engine.embed('test verification')
|
||||||
console.log(`🔍 Debug: Model path components: ${this.modelPath} + ${CRITICAL_MODEL_CONFIG.modelName.split('/')}`)
|
|
||||||
|
|
||||||
// Check critical files
|
|
||||||
const criticalFiles = [
|
|
||||||
'onnx/model.onnx',
|
|
||||||
'tokenizer.json',
|
|
||||||
'config.json'
|
|
||||||
]
|
|
||||||
|
|
||||||
for (const file of criticalFiles) {
|
|
||||||
const filePath = path.join(modelBasePath, file)
|
|
||||||
console.log(`🔍 Debug: Checking file: ${filePath}`)
|
|
||||||
|
|
||||||
if (!fs.existsSync(filePath)) {
|
// Verify dimensions
|
||||||
console.log(`❌ Missing critical file: ${file} at ${filePath}`)
|
if (testEmbedding.length !== CRITICAL_MODEL_CONFIG.embeddingDimensions) {
|
||||||
|
console.error(
|
||||||
|
`❌ CRITICAL: Model dimension mismatch!\n` +
|
||||||
|
`Expected: ${CRITICAL_MODEL_CONFIG.embeddingDimensions}\n` +
|
||||||
|
`Got: ${testEmbedding.length}`
|
||||||
|
)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify size for critical files
|
// Verify normalization (should be unit length)
|
||||||
if (CRITICAL_MODEL_CONFIG.modelSize[file]) {
|
const norm = Math.sqrt(testEmbedding.reduce((sum, v) => sum + v * v, 0))
|
||||||
const stats = await fsPromises.stat(filePath)
|
if (Math.abs(norm - 1.0) > 0.01) {
|
||||||
const expectedSize = CRITICAL_MODEL_CONFIG.modelSize[file]
|
console.error(`❌ CRITICAL: Embeddings not normalized! Norm: ${norm}`)
|
||||||
|
return false
|
||||||
if (Math.abs(stats.size - expectedSize) > 1000) { // Allow 1KB variance
|
|
||||||
console.error(
|
|
||||||
`❌ CRITICAL: Model file size mismatch!\n` +
|
|
||||||
`File: ${file}\n` +
|
|
||||||
`Expected: ${expectedSize} bytes\n` +
|
|
||||||
`Actual: ${stats.size} bytes\n` +
|
|
||||||
`This indicates model corruption or version mismatch!`
|
|
||||||
)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SHA256 verification for ultimate security
|
return true
|
||||||
if (CRITICAL_MODEL_CONFIG.modelHash && CRITICAL_MODEL_CONFIG.modelHash[file]) {
|
|
||||||
const hash = await this.computeFileHash(filePath)
|
|
||||||
if (hash !== CRITICAL_MODEL_CONFIG.modelHash[file]) {
|
|
||||||
console.error(
|
|
||||||
`❌ CRITICAL: Model hash mismatch for ${file}!\n` +
|
|
||||||
`Expected: ${CRITICAL_MODEL_CONFIG.modelHash[file]}\n` +
|
|
||||||
`Got: ${hash}\n` +
|
|
||||||
`This indicates model tampering or corruption!`
|
|
||||||
)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compute SHA256 hash of a file
|
|
||||||
*/
|
|
||||||
private async computeFileHash(filePath: string): Promise<string> {
|
|
||||||
try {
|
|
||||||
const { readFile } = await import('node:fs/promises')
|
|
||||||
const { createHash } = await import('node:crypto')
|
|
||||||
const fileBuffer = await readFile(filePath)
|
|
||||||
const hash = createHash('sha256').update(fileBuffer).digest('hex')
|
|
||||||
return hash
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to compute hash for ${filePath}:`, error)
|
console.error('❌ Model verification failed:', error)
|
||||||
return ''
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Download model from a fallback source
|
|
||||||
*/
|
|
||||||
private async downloadFromSource(source: any): Promise<void> {
|
|
||||||
if (source.type === 'transformers') {
|
|
||||||
// Use transformers.js native download
|
|
||||||
const { pipeline } = await import('@huggingface/transformers')
|
|
||||||
env.cacheDir = this.modelPath
|
|
||||||
env.allowRemoteModels = true
|
|
||||||
|
|
||||||
const extractor = await pipeline(
|
|
||||||
'feature-extraction',
|
|
||||||
CRITICAL_MODEL_CONFIG.modelName
|
|
||||||
)
|
|
||||||
|
|
||||||
// Test the model
|
|
||||||
const test = await extractor('test', { pooling: 'mean', normalize: true })
|
|
||||||
if (test.data.length !== CRITICAL_MODEL_CONFIG.embeddingDimensions) {
|
|
||||||
throw new Error(
|
|
||||||
`CRITICAL: Model dimension mismatch! ` +
|
|
||||||
`Expected ${CRITICAL_MODEL_CONFIG.embeddingDimensions}, ` +
|
|
||||||
`got ${test.data.length}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else if (source.type === 'tarball') {
|
|
||||||
// Tarball extraction would require additional dependencies
|
|
||||||
// Skip this source and try next fallback
|
|
||||||
console.warn(`⚠️ Tarball extraction not available for ${source.name}. Trying next source...`)
|
|
||||||
return // Will continue to next source in the loop
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configure transformers.js to use verified local model
|
|
||||||
*/
|
|
||||||
private configureTransformers(): void {
|
|
||||||
env.localModelPath = this.modelPath
|
|
||||||
env.allowRemoteModels = false // Force local only after verification
|
|
||||||
console.log('🔒 Transformers configured to use verified local model')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect where models should be stored
|
|
||||||
*/
|
|
||||||
private detectModelPath(): string {
|
|
||||||
// Browser always uses default path
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
return './models'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use require for synchronous access in Node.js
|
|
||||||
try {
|
|
||||||
const fs = require('node:fs')
|
|
||||||
const path = require('node:path')
|
|
||||||
|
|
||||||
const candidates = [
|
|
||||||
process.env.BRAINY_MODELS_PATH,
|
|
||||||
'./models',
|
|
||||||
path.join(process.cwd(), 'models'),
|
|
||||||
path.join(process.env.HOME || '', '.brainy', 'models'),
|
|
||||||
'/opt/models', // Lambda/container path
|
|
||||||
env.cacheDir
|
|
||||||
]
|
|
||||||
|
|
||||||
for (const candidatePath of candidates) {
|
|
||||||
if (candidatePath && fs.existsSync(candidatePath)) {
|
|
||||||
const modelPath = path.join(candidatePath, ...CRITICAL_MODEL_CONFIG.modelName.split('/'))
|
|
||||||
if (fs.existsSync(path.join(modelPath, 'onnx', 'model.onnx'))) {
|
|
||||||
return candidatePath // Return the models directory, not its parent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// If Node.js modules not available, return default
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default
|
|
||||||
return './models'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get model status for diagnostics
|
* Get model status for diagnostics
|
||||||
*/
|
*/
|
||||||
async getStatus(): Promise<{
|
async getStatus(): Promise<{
|
||||||
verified: boolean
|
verified: boolean
|
||||||
path: string
|
|
||||||
lastVerification: Date | null
|
lastVerification: Date | null
|
||||||
modelName: string
|
modelName: string
|
||||||
dimensions: number
|
dimensions: number
|
||||||
|
bundled: boolean
|
||||||
}> {
|
}> {
|
||||||
return {
|
return {
|
||||||
verified: this.isVerified,
|
verified: this.isVerified,
|
||||||
path: this.modelPath,
|
|
||||||
lastVerification: this.lastVerification,
|
lastVerification: this.lastVerification,
|
||||||
modelName: CRITICAL_MODEL_CONFIG.modelName,
|
modelName: CRITICAL_MODEL_CONFIG.modelName,
|
||||||
dimensions: CRITICAL_MODEL_CONFIG.embeddingDimensions
|
dimensions: CRITICAL_MODEL_CONFIG.embeddingDimensions,
|
||||||
|
bundled: CRITICAL_MODEL_CONFIG.bundled
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Force re-verification (for testing)
|
* Force re-verification (for testing)
|
||||||
*/
|
*/
|
||||||
|
|
@ -336,4 +137,4 @@ export class ModelGuardian {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export singleton instance
|
// Export singleton instance
|
||||||
export const modelGuardian = ModelGuardian.getInstance()
|
export const modelGuardian = ModelGuardian.getInstance()
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,19 @@
|
||||||
/**
|
/**
|
||||||
* Unified Embedding Manager
|
* Unified Embedding Manager
|
||||||
*
|
*
|
||||||
* THE single source of truth for all embedding operations in Brainy.
|
* THE single source of truth for all embedding operations in Brainy.
|
||||||
* Combines model management, precision configuration, and embedding generation
|
* Uses direct ONNX WASM inference for universal compatibility.
|
||||||
* into one clean, maintainable class.
|
*
|
||||||
*
|
|
||||||
* Features:
|
* Features:
|
||||||
* - Singleton pattern ensures ONE model instance
|
* - Singleton pattern ensures ONE model instance
|
||||||
* - Automatic Q8 (default) or FP32 precision
|
* - Direct ONNX WASM (no transformers.js dependency)
|
||||||
* - Model downloading and caching
|
* - Bundled model (no runtime downloads)
|
||||||
* - Thread-safe initialization
|
* - Works everywhere: Node.js, Bun, Bun --compile, browsers
|
||||||
* - Memory monitoring
|
* - Memory monitoring
|
||||||
*
|
|
||||||
* This replaces: SingletonModelManager, TransformerEmbedding, ModelPrecisionManager,
|
|
||||||
* hybridModelManager, universalMemoryManager, and more.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Vector, EmbeddingFunction } from '../coreTypes.js'
|
import { Vector, EmbeddingFunction } from '../coreTypes.js'
|
||||||
import { pipeline, env } from '@huggingface/transformers'
|
import { WASMEmbeddingEngine } from './wasm/index.js'
|
||||||
import { isNode } from '../utils/environment.js'
|
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
export type ModelPrecision = 'q8' | 'fp32'
|
export type ModelPrecision = 'q8' | 'fp32'
|
||||||
|
|
@ -38,22 +33,23 @@ let globalInitPromise: Promise<void> | null = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unified Embedding Manager - Clean, simple, reliable
|
* Unified Embedding Manager - Clean, simple, reliable
|
||||||
|
*
|
||||||
|
* Now powered by direct ONNX WASM for universal compatibility.
|
||||||
*/
|
*/
|
||||||
export class EmbeddingManager {
|
export class EmbeddingManager {
|
||||||
private model: any = null
|
private engine: WASMEmbeddingEngine
|
||||||
private precision: ModelPrecision
|
private precision: ModelPrecision = 'q8'
|
||||||
private modelName = 'Xenova/all-MiniLM-L6-v2'
|
private modelName = 'all-MiniLM-L6-v2'
|
||||||
private initialized = false
|
private initialized = false
|
||||||
private initTime: number | null = null
|
private initTime: number | null = null
|
||||||
private embedCount = 0
|
private embedCount = 0
|
||||||
private locked = false
|
private locked = false
|
||||||
|
|
||||||
private constructor() {
|
private constructor() {
|
||||||
// Always use Q8 for optimal size/performance (99% accuracy, 75% smaller)
|
this.engine = WASMEmbeddingEngine.getInstance()
|
||||||
this.precision = 'q8'
|
console.log('🎯 EmbeddingManager: Using Q8 precision (WASM)')
|
||||||
console.log(`🎯 EmbeddingManager: Using Q8 precision`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the singleton instance
|
* Get the singleton instance
|
||||||
*/
|
*/
|
||||||
|
|
@ -63,16 +59,18 @@ export class EmbeddingManager {
|
||||||
}
|
}
|
||||||
return globalInstance
|
return globalInstance
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the model (happens once)
|
* Initialize the model (happens once)
|
||||||
*/
|
*/
|
||||||
async init(): Promise<void> {
|
async init(): Promise<void> {
|
||||||
// In unit test mode, skip real model initialization
|
// In unit test mode, skip real model initialization
|
||||||
const isTestMode = process.env.BRAINY_UNIT_TEST === 'true' || (globalThis as any).__BRAINY_UNIT_TEST__
|
const isTestMode =
|
||||||
|
process.env.BRAINY_UNIT_TEST === 'true' ||
|
||||||
|
(globalThis as any).__BRAINY_UNIT_TEST__
|
||||||
|
|
||||||
if (isTestMode) {
|
if (isTestMode) {
|
||||||
// Production safeguard: Warn if mock mode is active but NODE_ENV is production
|
// Production safeguard
|
||||||
if (process.env.NODE_ENV === 'production') {
|
if (process.env.NODE_ENV === 'production') {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'CRITICAL: Mock embeddings detected in production environment! ' +
|
'CRITICAL: Mock embeddings detected in production environment! ' +
|
||||||
|
|
@ -80,7 +78,7 @@ export class EmbeddingManager {
|
||||||
'This is a security risk. Remove test flags before deploying to production.'
|
'This is a security risk. Remove test flags before deploying to production.'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.initialized) {
|
if (!this.initialized) {
|
||||||
this.initialized = true
|
this.initialized = true
|
||||||
this.initTime = 1 // Mock init time
|
this.initTime = 1 // Mock init time
|
||||||
|
|
@ -88,108 +86,65 @@ export class EmbeddingManager {
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Already initialized
|
// Already initialized
|
||||||
if (this.initialized && this.model) {
|
if (this.initialized && this.engine.isInitialized()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialization in progress
|
// Initialization in progress
|
||||||
if (globalInitPromise) {
|
if (globalInitPromise) {
|
||||||
await globalInitPromise
|
await globalInitPromise
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start initialization
|
// Start initialization
|
||||||
globalInitPromise = this.performInit()
|
globalInitPromise = this.performInit()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await globalInitPromise
|
await globalInitPromise
|
||||||
} finally {
|
} finally {
|
||||||
globalInitPromise = null
|
globalInitPromise = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Perform actual initialization
|
* Perform actual initialization
|
||||||
*/
|
*/
|
||||||
private async performInit(): Promise<void> {
|
private async performInit(): Promise<void> {
|
||||||
const startTime = Date.now()
|
const startTime = Date.now()
|
||||||
console.log(`🚀 Initializing embedding model (${this.precision.toUpperCase()})...`)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Configure transformers.js environment
|
// Initialize WASM engine (handles all model loading)
|
||||||
const modelsPath = this.getModelsPath()
|
await this.engine.initialize()
|
||||||
env.cacheDir = modelsPath
|
|
||||||
env.allowLocalModels = true
|
|
||||||
env.useFSCache = true
|
|
||||||
|
|
||||||
// Check if models exist locally (only in Node.js)
|
|
||||||
if (isNode()) {
|
|
||||||
try {
|
|
||||||
const nodeRequire = typeof require !== 'undefined' ? require : null
|
|
||||||
if (nodeRequire) {
|
|
||||||
const path = nodeRequire('node:path')
|
|
||||||
const fs = nodeRequire('node:fs')
|
|
||||||
const modelPath = path.join(modelsPath, ...this.modelName.split('/'))
|
|
||||||
const hasLocalModels = fs.existsSync(modelPath)
|
|
||||||
|
|
||||||
if (hasLocalModels) {
|
|
||||||
console.log('✅ Using cached models from:', modelPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Silently continue if require fails
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Configure pipeline options for the selected precision
|
|
||||||
const pipelineOptions: any = {
|
|
||||||
cache_dir: modelsPath,
|
|
||||||
local_files_only: false,
|
|
||||||
// Always use Q8 precision
|
|
||||||
dtype: 'q8',
|
|
||||||
quantized: true,
|
|
||||||
// Memory optimizations
|
|
||||||
session_options: {
|
|
||||||
enableCpuMemArena: false,
|
|
||||||
enableMemPattern: false,
|
|
||||||
interOpNumThreads: 1,
|
|
||||||
intraOpNumThreads: 1,
|
|
||||||
graphOptimizationLevel: 'disabled'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load the model
|
|
||||||
this.model = await pipeline('feature-extraction', this.modelName, pipelineOptions)
|
|
||||||
|
|
||||||
// Lock precision after successful initialization
|
// Lock precision after successful initialization
|
||||||
this.locked = true
|
this.locked = true
|
||||||
this.initialized = true
|
this.initialized = true
|
||||||
this.initTime = Date.now() - startTime
|
this.initTime = Date.now() - startTime
|
||||||
|
|
||||||
// Log success
|
// Log success
|
||||||
const memoryMB = this.getMemoryUsage()
|
const memoryMB = this.getMemoryUsage()
|
||||||
console.log(`✅ Model loaded in ${this.initTime}ms`)
|
|
||||||
console.log(`📊 Precision: Q8 | Memory: ${memoryMB}MB`)
|
console.log(`📊 Precision: Q8 | Memory: ${memoryMB}MB`)
|
||||||
console.log(`🔒 Configuration locked`)
|
console.log('🔒 Configuration locked')
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.initialized = false
|
this.initialized = false
|
||||||
this.model = null
|
throw new Error(
|
||||||
throw new Error(`Failed to initialize embedding model: ${error instanceof Error ? error.message : String(error)}`)
|
`Failed to initialize embedding model: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate embeddings
|
* Generate embeddings
|
||||||
*/
|
*/
|
||||||
async embed(text: string | string[] | Record<string, unknown>): Promise<Vector> {
|
async embed(text: string | string[] | Record<string, unknown>): Promise<Vector> {
|
||||||
// Check for unit test environment - use mocks to prevent ONNX conflicts
|
// Check for unit test environment
|
||||||
const isTestMode = process.env.BRAINY_UNIT_TEST === 'true' || (globalThis as any).__BRAINY_UNIT_TEST__
|
const isTestMode =
|
||||||
|
process.env.BRAINY_UNIT_TEST === 'true' ||
|
||||||
|
(globalThis as any).__BRAINY_UNIT_TEST__
|
||||||
|
|
||||||
if (isTestMode) {
|
if (isTestMode) {
|
||||||
// Production safeguard
|
|
||||||
if (process.env.NODE_ENV === 'production') {
|
if (process.env.NODE_ENV === 'production') {
|
||||||
throw new Error('CRITICAL: Mock embeddings in production!')
|
throw new Error('CRITICAL: Mock embeddings in production!')
|
||||||
}
|
}
|
||||||
|
|
@ -199,55 +154,40 @@ export class EmbeddingManager {
|
||||||
// Ensure initialized
|
// Ensure initialized
|
||||||
await this.init()
|
await this.init()
|
||||||
|
|
||||||
if (!this.model) {
|
// Normalize input to string
|
||||||
throw new Error('Model not initialized')
|
|
||||||
}
|
|
||||||
|
|
||||||
// CRITICAL FIX: Ensure input is always a string
|
|
||||||
let input: string
|
let input: string
|
||||||
if (Array.isArray(text)) {
|
if (Array.isArray(text)) {
|
||||||
// Join array elements, converting each to string first
|
input = text.map((t) => (typeof t === 'string' ? t : String(t))).join(' ')
|
||||||
input = text.map(t => typeof t === 'string' ? t : String(t)).join(' ')
|
|
||||||
} else if (typeof text === 'string') {
|
} else if (typeof text === 'string') {
|
||||||
input = text
|
input = text
|
||||||
} else if (typeof text === 'object') {
|
} else if (typeof text === 'object') {
|
||||||
// Convert object to string representation
|
|
||||||
input = JSON.stringify(text)
|
input = JSON.stringify(text)
|
||||||
} else {
|
} else {
|
||||||
// This shouldn't happen but let's be defensive
|
|
||||||
console.warn('EmbeddingManager.embed received unexpected input type:', typeof text)
|
console.warn('EmbeddingManager.embed received unexpected input type:', typeof text)
|
||||||
input = String(text)
|
input = String(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate embedding
|
// Generate embedding using WASM engine
|
||||||
const output = await this.model(input, {
|
const embedding = await this.engine.embed(input)
|
||||||
pooling: 'mean',
|
|
||||||
normalize: true
|
|
||||||
})
|
|
||||||
|
|
||||||
// Extract embedding vector
|
|
||||||
const embedding = Array.from(output.data) as number[]
|
|
||||||
|
|
||||||
// Validate dimensions
|
// Validate dimensions
|
||||||
if (embedding.length !== 384) {
|
if (embedding.length !== 384) {
|
||||||
console.warn(`Unexpected embedding dimension: ${embedding.length}`)
|
console.warn(`Unexpected embedding dimension: ${embedding.length}`)
|
||||||
// Pad or truncate
|
|
||||||
if (embedding.length < 384) {
|
if (embedding.length < 384) {
|
||||||
return [...embedding, ...new Array(384 - embedding.length).fill(0)]
|
return [...embedding, ...new Array(384 - embedding.length).fill(0)]
|
||||||
} else {
|
} else {
|
||||||
return embedding.slice(0, 384)
|
return embedding.slice(0, 384)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.embedCount++
|
this.embedCount++
|
||||||
return embedding
|
return embedding
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate mock embeddings for unit tests
|
* Generate mock embeddings for unit tests
|
||||||
*/
|
*/
|
||||||
private getMockEmbedding(text: string | string[] | Record<string, unknown>): Vector {
|
private getMockEmbedding(text: string | string[] | Record<string, unknown>): Vector {
|
||||||
// Use the same mock logic as setup-unit.ts for consistency
|
|
||||||
const input = Array.isArray(text) ? text.join(' ') : text
|
const input = Array.isArray(text) ? text.join(' ') : text
|
||||||
const str = typeof input === 'string' ? input : JSON.stringify(input)
|
const str = typeof input === 'string' ? input : JSON.stringify(input)
|
||||||
const vector = new Array(384).fill(0)
|
const vector = new Array(384).fill(0)
|
||||||
|
|
@ -262,11 +202,10 @@ export class EmbeddingManager {
|
||||||
vector[i] += Math.sin(i * 0.1 + str.length) * 0.1
|
vector[i] += Math.sin(i * 0.1 + str.length) * 0.1
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track mock embedding count
|
|
||||||
this.embedCount++
|
this.embedCount++
|
||||||
return vector
|
return vector
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get embedding function for compatibility
|
* Get embedding function for compatibility
|
||||||
*/
|
*/
|
||||||
|
|
@ -275,66 +214,7 @@ export class EmbeddingManager {
|
||||||
return await this.embed(data)
|
return await this.embed(data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get models directory path
|
|
||||||
* Note: In browser environments, returns a simple default path
|
|
||||||
* In Node.js, checks multiple locations for the models directory
|
|
||||||
*/
|
|
||||||
private getModelsPath(): string {
|
|
||||||
// In browser environments, use a default path
|
|
||||||
if (!isNode()) {
|
|
||||||
return './models'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Node.js-specific model path resolution
|
|
||||||
// Cache the result for performance
|
|
||||||
if (!this.modelsPathCache) {
|
|
||||||
this.modelsPathCache = this.resolveModelsPathSync()
|
|
||||||
}
|
|
||||||
return this.modelsPathCache
|
|
||||||
}
|
|
||||||
|
|
||||||
private modelsPathCache: string | null = null
|
|
||||||
|
|
||||||
private resolveModelsPathSync(): string {
|
|
||||||
// For Node.js environments, we can safely assume these modules exist
|
|
||||||
// TypeScript will handle the imports at build time
|
|
||||||
// At runtime, these will only be called if isNode() is true
|
|
||||||
|
|
||||||
// Default fallback path
|
|
||||||
const defaultPath = './models'
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Create a conditional require function that only works in Node
|
|
||||||
const nodeRequire = typeof require !== 'undefined' ? require : null
|
|
||||||
if (!nodeRequire) return defaultPath
|
|
||||||
|
|
||||||
const fs = nodeRequire('node:fs')
|
|
||||||
const path = nodeRequire('node:path')
|
|
||||||
|
|
||||||
const paths = [
|
|
||||||
process.env.BRAINY_MODELS_PATH,
|
|
||||||
'./models',
|
|
||||||
path.join(process.cwd(), 'models'),
|
|
||||||
path.join(process.env.HOME || '', '.brainy', 'models')
|
|
||||||
]
|
|
||||||
|
|
||||||
for (const p of paths) {
|
|
||||||
if (p && fs.existsSync(p)) {
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default Node.js path
|
|
||||||
return path.join(process.cwd(), 'models')
|
|
||||||
} catch {
|
|
||||||
// Fallback if require fails
|
|
||||||
return defaultPath
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get memory usage in MB
|
* Get memory usage in MB
|
||||||
*/
|
*/
|
||||||
|
|
@ -345,35 +225,36 @@ export class EmbeddingManager {
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get current statistics
|
* Get current statistics
|
||||||
*/
|
*/
|
||||||
getStats(): EmbeddingStats {
|
getStats(): EmbeddingStats {
|
||||||
|
const engineStats = this.engine.getStats()
|
||||||
return {
|
return {
|
||||||
initialized: this.initialized,
|
initialized: this.initialized,
|
||||||
precision: this.precision,
|
precision: this.precision,
|
||||||
modelName: this.modelName,
|
modelName: this.modelName,
|
||||||
embedCount: this.embedCount,
|
embedCount: this.embedCount + engineStats.embedCount,
|
||||||
initTime: this.initTime,
|
initTime: this.initTime,
|
||||||
memoryMB: this.getMemoryUsage()
|
memoryMB: this.getMemoryUsage(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if initialized
|
* Check if initialized
|
||||||
*/
|
*/
|
||||||
isInitialized(): boolean {
|
isInitialized(): boolean {
|
||||||
return this.initialized
|
return this.initialized
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get current precision
|
* Get current precision
|
||||||
*/
|
*/
|
||||||
getPrecision(): ModelPrecision {
|
getPrecision(): ModelPrecision {
|
||||||
return this.precision
|
return this.precision
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate precision matches expected
|
* Validate precision matches expected
|
||||||
*/
|
*/
|
||||||
|
|
@ -393,7 +274,9 @@ export const embeddingManager = EmbeddingManager.getInstance()
|
||||||
/**
|
/**
|
||||||
* Direct embed function
|
* Direct embed function
|
||||||
*/
|
*/
|
||||||
export async function embed(text: string | string[] | Record<string, unknown>): Promise<Vector> {
|
export async function embed(
|
||||||
|
text: string | string[] | Record<string, unknown>
|
||||||
|
): Promise<Vector> {
|
||||||
return await embeddingManager.embed(text)
|
return await embeddingManager.embed(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -409,4 +292,4 @@ export function getEmbeddingFunction(): EmbeddingFunction {
|
||||||
*/
|
*/
|
||||||
export function getEmbeddingStats(): EmbeddingStats {
|
export function getEmbeddingStats(): EmbeddingStats {
|
||||||
return embeddingManager.getStats()
|
return embeddingManager.getStats()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
268
src/embeddings/wasm/AssetLoader.ts
Normal file
268
src/embeddings/wasm/AssetLoader.ts
Normal file
|
|
@ -0,0 +1,268 @@
|
||||||
|
/**
|
||||||
|
* Asset Loader
|
||||||
|
*
|
||||||
|
* Resolves paths to model files (ONNX model, vocabulary) across environments.
|
||||||
|
* Handles Node.js, Bun, and bundled scenarios.
|
||||||
|
*
|
||||||
|
* Asset Resolution Order:
|
||||||
|
* 1. Environment variable: BRAINY_MODEL_PATH
|
||||||
|
* 2. Package-relative: node_modules/@soulcraft/brainy/assets/models/
|
||||||
|
* 3. Project-relative: ./assets/models/
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { MODEL_CONSTANTS } from './types.js'
|
||||||
|
|
||||||
|
// Cache resolved paths
|
||||||
|
let cachedModelDir: string | null = null
|
||||||
|
let cachedVocab: Record<string, number> | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asset loader for model files
|
||||||
|
*/
|
||||||
|
export class AssetLoader {
|
||||||
|
private modelDir: string | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the model directory path
|
||||||
|
*/
|
||||||
|
async getModelDir(): Promise<string> {
|
||||||
|
if (this.modelDir) {
|
||||||
|
return this.modelDir
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cachedModelDir) {
|
||||||
|
this.modelDir = cachedModelDir
|
||||||
|
return cachedModelDir
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to resolve model directory
|
||||||
|
const resolved = await this.resolveModelDir()
|
||||||
|
this.modelDir = resolved
|
||||||
|
cachedModelDir = resolved
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the model directory across environments
|
||||||
|
*/
|
||||||
|
private async resolveModelDir(): Promise<string> {
|
||||||
|
// 1. Check environment variable
|
||||||
|
if (typeof process !== 'undefined' && process.env?.BRAINY_MODEL_PATH) {
|
||||||
|
const envPath = process.env.BRAINY_MODEL_PATH
|
||||||
|
if (await this.pathExists(envPath)) {
|
||||||
|
return envPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Try common locations
|
||||||
|
const modelName = MODEL_CONSTANTS.MODEL_NAME + '-q8'
|
||||||
|
const possiblePaths = [
|
||||||
|
// Package assets (when installed as dependency)
|
||||||
|
`./assets/models/${modelName}`,
|
||||||
|
`./node_modules/@soulcraft/brainy/assets/models/${modelName}`,
|
||||||
|
// Development paths
|
||||||
|
`../assets/models/${modelName}`,
|
||||||
|
// Absolute from package root
|
||||||
|
this.getPackageRootPath(`assets/models/${modelName}`),
|
||||||
|
].filter(Boolean) as string[]
|
||||||
|
|
||||||
|
for (const path of possiblePaths) {
|
||||||
|
if (await this.pathExists(path)) {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no path found, return default (will error on use)
|
||||||
|
return `./assets/models/${modelName}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get package root path (Node.js/Bun only)
|
||||||
|
*/
|
||||||
|
private getPackageRootPath(relativePath: string): string | null {
|
||||||
|
if (typeof process === 'undefined') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use __dirname equivalent
|
||||||
|
const url = new URL(import.meta.url)
|
||||||
|
const currentDir = url.pathname.replace(/\/[^/]*$/, '')
|
||||||
|
// Go up from src/embeddings/wasm to package root
|
||||||
|
const packageRoot = currentDir.replace(/\/src\/embeddings\/wasm$/, '')
|
||||||
|
return `${packageRoot}/${relativePath}`
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if path exists (works in Node.js/Bun)
|
||||||
|
*/
|
||||||
|
private async pathExists(path: string): Promise<boolean> {
|
||||||
|
if (typeof process === 'undefined') {
|
||||||
|
// Browser - check via fetch
|
||||||
|
try {
|
||||||
|
const response = await fetch(path, { method: 'HEAD' })
|
||||||
|
return response.ok
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Node.js/Bun
|
||||||
|
try {
|
||||||
|
const fs = await import('node:fs/promises')
|
||||||
|
await fs.access(path)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get path to ONNX model file
|
||||||
|
*/
|
||||||
|
async getModelPath(): Promise<string> {
|
||||||
|
const dir = await this.getModelDir()
|
||||||
|
return `${dir}/model.onnx`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get path to vocabulary file
|
||||||
|
*/
|
||||||
|
async getVocabPath(): Promise<string> {
|
||||||
|
const dir = await this.getModelDir()
|
||||||
|
return `${dir}/vocab.json`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load vocabulary from JSON file
|
||||||
|
*/
|
||||||
|
async loadVocab(): Promise<Record<string, number>> {
|
||||||
|
if (cachedVocab) {
|
||||||
|
return cachedVocab
|
||||||
|
}
|
||||||
|
|
||||||
|
const vocabPath = await this.getVocabPath()
|
||||||
|
|
||||||
|
if (typeof process !== 'undefined') {
|
||||||
|
// Node.js/Bun - read from filesystem
|
||||||
|
try {
|
||||||
|
const fs = await import('node:fs/promises')
|
||||||
|
const content = await fs.readFile(vocabPath, 'utf-8')
|
||||||
|
cachedVocab = JSON.parse(content)
|
||||||
|
return cachedVocab!
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to load vocabulary from ${vocabPath}: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Browser - fetch
|
||||||
|
try {
|
||||||
|
const response = await fetch(vocabPath)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`)
|
||||||
|
}
|
||||||
|
cachedVocab = await response.json()
|
||||||
|
return cachedVocab!
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to fetch vocabulary from ${vocabPath}: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load model as ArrayBuffer (for ONNX session)
|
||||||
|
*/
|
||||||
|
async loadModel(): Promise<ArrayBuffer> {
|
||||||
|
const modelPath = await this.getModelPath()
|
||||||
|
|
||||||
|
if (typeof process !== 'undefined') {
|
||||||
|
// Node.js/Bun - read from filesystem
|
||||||
|
try {
|
||||||
|
const fs = await import('node:fs/promises')
|
||||||
|
const buffer = await fs.readFile(modelPath)
|
||||||
|
// Convert Node.js Buffer to ArrayBuffer
|
||||||
|
return new Uint8Array(buffer).buffer as ArrayBuffer
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to load model from ${modelPath}: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Browser - fetch
|
||||||
|
try {
|
||||||
|
const response = await fetch(modelPath)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`)
|
||||||
|
}
|
||||||
|
return await response.arrayBuffer()
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to fetch model from ${modelPath}: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify all required assets exist
|
||||||
|
*/
|
||||||
|
async verifyAssets(): Promise<{
|
||||||
|
valid: boolean
|
||||||
|
modelPath: string
|
||||||
|
vocabPath: string
|
||||||
|
errors: string[]
|
||||||
|
}> {
|
||||||
|
const errors: string[] = []
|
||||||
|
const modelPath = await this.getModelPath()
|
||||||
|
const vocabPath = await this.getVocabPath()
|
||||||
|
|
||||||
|
if (!(await this.pathExists(modelPath))) {
|
||||||
|
errors.push(`Model file not found: ${modelPath}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await this.pathExists(vocabPath))) {
|
||||||
|
errors.push(`Vocabulary file not found: ${vocabPath}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: errors.length === 0,
|
||||||
|
modelPath,
|
||||||
|
vocabPath,
|
||||||
|
errors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear cached paths (for testing)
|
||||||
|
*/
|
||||||
|
clearCache(): void {
|
||||||
|
this.modelDir = null
|
||||||
|
cachedModelDir = null
|
||||||
|
cachedVocab = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create asset loader instance
|
||||||
|
*/
|
||||||
|
export function createAssetLoader(): AssetLoader {
|
||||||
|
return new AssetLoader()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Singleton asset loader
|
||||||
|
*/
|
||||||
|
let singletonLoader: AssetLoader | null = null
|
||||||
|
|
||||||
|
export function getAssetLoader(): AssetLoader {
|
||||||
|
if (!singletonLoader) {
|
||||||
|
singletonLoader = new AssetLoader()
|
||||||
|
}
|
||||||
|
return singletonLoader
|
||||||
|
}
|
||||||
156
src/embeddings/wasm/EmbeddingPostProcessor.ts
Normal file
156
src/embeddings/wasm/EmbeddingPostProcessor.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
/**
|
||||||
|
* Embedding Post-Processor
|
||||||
|
*
|
||||||
|
* Converts raw ONNX model output to final embedding vectors.
|
||||||
|
* Implements mean pooling and L2 normalization as used by sentence-transformers.
|
||||||
|
*
|
||||||
|
* Pipeline:
|
||||||
|
* 1. Mean Pooling: Average token embeddings (weighted by attention mask)
|
||||||
|
* 2. L2 Normalization: Normalize to unit length for cosine similarity
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { MODEL_CONSTANTS } from './types.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post-processor for converting ONNX output to sentence embeddings
|
||||||
|
*/
|
||||||
|
export class EmbeddingPostProcessor {
|
||||||
|
private hiddenSize: number
|
||||||
|
|
||||||
|
constructor(hiddenSize: number = MODEL_CONSTANTS.HIDDEN_SIZE) {
|
||||||
|
this.hiddenSize = hiddenSize
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mean pool token embeddings weighted by attention mask
|
||||||
|
*
|
||||||
|
* @param hiddenStates - Raw model output [seqLen * hiddenSize] flattened
|
||||||
|
* @param attentionMask - Attention mask [seqLen] (1 for real tokens, 0 for padding)
|
||||||
|
* @param seqLen - Sequence length
|
||||||
|
* @returns Mean-pooled embedding [hiddenSize]
|
||||||
|
*/
|
||||||
|
meanPool(
|
||||||
|
hiddenStates: Float32Array,
|
||||||
|
attentionMask: number[],
|
||||||
|
seqLen: number
|
||||||
|
): Float32Array {
|
||||||
|
const result = new Float32Array(this.hiddenSize)
|
||||||
|
|
||||||
|
// Sum of attention mask (number of real tokens)
|
||||||
|
let maskSum = 0
|
||||||
|
for (let i = 0; i < seqLen; i++) {
|
||||||
|
maskSum += attentionMask[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Avoid division by zero
|
||||||
|
if (maskSum === 0) {
|
||||||
|
maskSum = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute weighted sum for each dimension
|
||||||
|
for (let dim = 0; dim < this.hiddenSize; dim++) {
|
||||||
|
let sum = 0
|
||||||
|
for (let pos = 0; pos < seqLen; pos++) {
|
||||||
|
// Get hidden state at [pos, dim]
|
||||||
|
const value = hiddenStates[pos * this.hiddenSize + dim]
|
||||||
|
// Weight by attention mask
|
||||||
|
sum += value * attentionMask[pos]
|
||||||
|
}
|
||||||
|
// Mean pool
|
||||||
|
result[dim] = sum / maskSum
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* L2 normalize embedding to unit length
|
||||||
|
*
|
||||||
|
* @param embedding - Input embedding
|
||||||
|
* @returns Normalized embedding with ||x|| = 1
|
||||||
|
*/
|
||||||
|
normalize(embedding: Float32Array): Float32Array {
|
||||||
|
// Compute L2 norm
|
||||||
|
let sumSquares = 0
|
||||||
|
for (let i = 0; i < embedding.length; i++) {
|
||||||
|
sumSquares += embedding[i] * embedding[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
const norm = Math.sqrt(sumSquares)
|
||||||
|
|
||||||
|
// Avoid division by zero
|
||||||
|
if (norm === 0) {
|
||||||
|
return embedding
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize
|
||||||
|
const result = new Float32Array(embedding.length)
|
||||||
|
for (let i = 0; i < embedding.length; i++) {
|
||||||
|
result[i] = embedding[i] / norm
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full post-processing pipeline: mean pool then normalize
|
||||||
|
*
|
||||||
|
* @param hiddenStates - Raw model output [seqLen * hiddenSize]
|
||||||
|
* @param attentionMask - Attention mask [seqLen]
|
||||||
|
* @param seqLen - Sequence length
|
||||||
|
* @returns Final normalized embedding [hiddenSize]
|
||||||
|
*/
|
||||||
|
process(
|
||||||
|
hiddenStates: Float32Array,
|
||||||
|
attentionMask: number[],
|
||||||
|
seqLen: number
|
||||||
|
): Float32Array {
|
||||||
|
const pooled = this.meanPool(hiddenStates, attentionMask, seqLen)
|
||||||
|
return this.normalize(pooled)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process batch of embeddings
|
||||||
|
*
|
||||||
|
* @param hiddenStates - Raw model output [batchSize * seqLen * hiddenSize]
|
||||||
|
* @param attentionMasks - Attention masks [batchSize][seqLen]
|
||||||
|
* @param batchSize - Number of sequences in batch
|
||||||
|
* @param seqLen - Sequence length (same for all in batch due to padding)
|
||||||
|
* @returns Array of normalized embeddings
|
||||||
|
*/
|
||||||
|
processBatch(
|
||||||
|
hiddenStates: Float32Array,
|
||||||
|
attentionMasks: number[][],
|
||||||
|
batchSize: number,
|
||||||
|
seqLen: number
|
||||||
|
): Float32Array[] {
|
||||||
|
const results: Float32Array[] = []
|
||||||
|
const sequenceSize = seqLen * this.hiddenSize
|
||||||
|
|
||||||
|
for (let b = 0; b < batchSize; b++) {
|
||||||
|
// Extract this sequence's hidden states
|
||||||
|
const start = b * sequenceSize
|
||||||
|
const seqHiddenStates = hiddenStates.slice(start, start + sequenceSize)
|
||||||
|
|
||||||
|
// Process
|
||||||
|
const embedding = this.process(seqHiddenStates, attentionMasks[b], seqLen)
|
||||||
|
results.push(embedding)
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert Float32Array to number array
|
||||||
|
*/
|
||||||
|
toNumberArray(embedding: Float32Array): number[] {
|
||||||
|
return Array.from(embedding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a post-processor with default configuration
|
||||||
|
*/
|
||||||
|
export function createPostProcessor(): EmbeddingPostProcessor {
|
||||||
|
return new EmbeddingPostProcessor(MODEL_CONSTANTS.HIDDEN_SIZE)
|
||||||
|
}
|
||||||
193
src/embeddings/wasm/ONNXInferenceEngine.ts
Normal file
193
src/embeddings/wasm/ONNXInferenceEngine.ts
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
/**
|
||||||
|
* ONNX Inference Engine
|
||||||
|
*
|
||||||
|
* Direct ONNX Runtime Web wrapper for running model inference.
|
||||||
|
* Uses WASM backend for universal compatibility (Node.js, Bun, Browser).
|
||||||
|
*
|
||||||
|
* This replaces transformers.js dependency with direct ONNX control.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as ort from 'onnxruntime-web'
|
||||||
|
import { InferenceConfig, MODEL_CONSTANTS } from './types.js'
|
||||||
|
|
||||||
|
// Configure ONNX Runtime for WASM-only
|
||||||
|
ort.env.wasm.numThreads = 1 // Single-threaded for stability
|
||||||
|
ort.env.wasm.simd = true // Enable SIMD where available
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ONNX Inference Engine using onnxruntime-web
|
||||||
|
*/
|
||||||
|
export class ONNXInferenceEngine {
|
||||||
|
private session: ort.InferenceSession | null = null
|
||||||
|
private initialized = false
|
||||||
|
private modelPath: string
|
||||||
|
private config: InferenceConfig
|
||||||
|
|
||||||
|
constructor(config: Partial<InferenceConfig> = {}) {
|
||||||
|
this.modelPath = config.modelPath ?? ''
|
||||||
|
this.config = {
|
||||||
|
modelPath: this.modelPath,
|
||||||
|
numThreads: config.numThreads ?? 1,
|
||||||
|
enableSimd: config.enableSimd ?? true,
|
||||||
|
enableCpuMemArena: config.enableCpuMemArena ?? false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the ONNX session
|
||||||
|
*/
|
||||||
|
async initialize(modelPath?: string): Promise<void> {
|
||||||
|
if (this.initialized && this.session) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = modelPath ?? this.modelPath
|
||||||
|
if (!path) {
|
||||||
|
throw new Error('Model path is required')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Configure session options
|
||||||
|
const sessionOptions: ort.InferenceSession.SessionOptions = {
|
||||||
|
executionProviders: ['wasm'],
|
||||||
|
graphOptimizationLevel: 'all',
|
||||||
|
enableCpuMemArena: this.config.enableCpuMemArena,
|
||||||
|
// Additional WASM-specific options
|
||||||
|
executionMode: 'sequential',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load model from file path or URL
|
||||||
|
this.session = await ort.InferenceSession.create(path, sessionOptions)
|
||||||
|
|
||||||
|
this.initialized = true
|
||||||
|
} catch (error) {
|
||||||
|
this.initialized = false
|
||||||
|
this.session = null
|
||||||
|
throw new Error(
|
||||||
|
`Failed to initialize ONNX session: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run inference on tokenized input
|
||||||
|
*
|
||||||
|
* @param inputIds - Token IDs [batchSize, seqLen]
|
||||||
|
* @param attentionMask - Attention mask [batchSize, seqLen]
|
||||||
|
* @param tokenTypeIds - Token type IDs [batchSize, seqLen] (optional, defaults to zeros)
|
||||||
|
* @returns Hidden states [batchSize, seqLen, hiddenSize]
|
||||||
|
*/
|
||||||
|
async infer(
|
||||||
|
inputIds: number[][],
|
||||||
|
attentionMask: number[][],
|
||||||
|
tokenTypeIds?: number[][]
|
||||||
|
): Promise<Float32Array> {
|
||||||
|
if (!this.session) {
|
||||||
|
throw new Error('Session not initialized. Call initialize() first.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const batchSize = inputIds.length
|
||||||
|
const seqLen = inputIds[0].length
|
||||||
|
|
||||||
|
// Convert to BigInt64Array (ONNX int64 type)
|
||||||
|
const inputIdsFlat = new BigInt64Array(batchSize * seqLen)
|
||||||
|
const attentionMaskFlat = new BigInt64Array(batchSize * seqLen)
|
||||||
|
const tokenTypeIdsFlat = new BigInt64Array(batchSize * seqLen)
|
||||||
|
|
||||||
|
for (let b = 0; b < batchSize; b++) {
|
||||||
|
for (let s = 0; s < seqLen; s++) {
|
||||||
|
const idx = b * seqLen + s
|
||||||
|
inputIdsFlat[idx] = BigInt(inputIds[b][s])
|
||||||
|
attentionMaskFlat[idx] = BigInt(attentionMask[b][s])
|
||||||
|
tokenTypeIdsFlat[idx] = tokenTypeIds
|
||||||
|
? BigInt(tokenTypeIds[b][s])
|
||||||
|
: BigInt(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create ONNX tensors
|
||||||
|
const inputIdsTensor = new ort.Tensor('int64', inputIdsFlat, [batchSize, seqLen])
|
||||||
|
const attentionMaskTensor = new ort.Tensor('int64', attentionMaskFlat, [batchSize, seqLen])
|
||||||
|
const tokenTypeIdsTensor = new ort.Tensor('int64', tokenTypeIdsFlat, [batchSize, seqLen])
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Run inference
|
||||||
|
const feeds = {
|
||||||
|
input_ids: inputIdsTensor,
|
||||||
|
attention_mask: attentionMaskTensor,
|
||||||
|
token_type_ids: tokenTypeIdsTensor,
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await this.session.run(feeds)
|
||||||
|
|
||||||
|
// Extract last_hidden_state (the output we need for mean pooling)
|
||||||
|
// Model outputs: last_hidden_state [batch, seq, hidden] and pooler_output [batch, hidden]
|
||||||
|
const output = results.last_hidden_state ?? results.token_embeddings
|
||||||
|
|
||||||
|
if (!output) {
|
||||||
|
throw new Error('Model did not return expected output tensor')
|
||||||
|
}
|
||||||
|
|
||||||
|
return output.data as Float32Array
|
||||||
|
} finally {
|
||||||
|
// Dispose tensors to free memory
|
||||||
|
inputIdsTensor.dispose()
|
||||||
|
attentionMaskTensor.dispose()
|
||||||
|
tokenTypeIdsTensor.dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Infer single sequence (convenience method)
|
||||||
|
*/
|
||||||
|
async inferSingle(
|
||||||
|
inputIds: number[],
|
||||||
|
attentionMask: number[],
|
||||||
|
tokenTypeIds?: number[]
|
||||||
|
): Promise<Float32Array> {
|
||||||
|
return this.infer(
|
||||||
|
[inputIds],
|
||||||
|
[attentionMask],
|
||||||
|
tokenTypeIds ? [tokenTypeIds] : undefined
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if initialized
|
||||||
|
*/
|
||||||
|
isInitialized(): boolean {
|
||||||
|
return this.initialized
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get model input/output names (for debugging)
|
||||||
|
*/
|
||||||
|
getModelInfo(): { inputs: readonly string[]; outputs: readonly string[] } | null {
|
||||||
|
if (!this.session) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
inputs: this.session.inputNames,
|
||||||
|
outputs: this.session.outputNames,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispose of the session and free resources
|
||||||
|
*/
|
||||||
|
async dispose(): Promise<void> {
|
||||||
|
if (this.session) {
|
||||||
|
// Release the session
|
||||||
|
this.session = null
|
||||||
|
}
|
||||||
|
this.initialized = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an inference engine with default configuration
|
||||||
|
*/
|
||||||
|
export function createInferenceEngine(modelPath: string): ONNXInferenceEngine {
|
||||||
|
return new ONNXInferenceEngine({ modelPath })
|
||||||
|
}
|
||||||
291
src/embeddings/wasm/WASMEmbeddingEngine.ts
Normal file
291
src/embeddings/wasm/WASMEmbeddingEngine.ts
Normal file
|
|
@ -0,0 +1,291 @@
|
||||||
|
/**
|
||||||
|
* WASM Embedding Engine
|
||||||
|
*
|
||||||
|
* The main embedding engine that combines all components:
|
||||||
|
* - WordPieceTokenizer: Text → Token IDs
|
||||||
|
* - ONNXInferenceEngine: Token IDs → Hidden States
|
||||||
|
* - EmbeddingPostProcessor: Hidden States → Normalized Embedding
|
||||||
|
*
|
||||||
|
* This replaces transformers.js with a clean, production-grade implementation.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Singleton pattern (one model instance)
|
||||||
|
* - Lazy initialization
|
||||||
|
* - Batch processing support
|
||||||
|
* - Zero runtime dependencies
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { WordPieceTokenizer } from './WordPieceTokenizer.js'
|
||||||
|
import { ONNXInferenceEngine } from './ONNXInferenceEngine.js'
|
||||||
|
import { EmbeddingPostProcessor } from './EmbeddingPostProcessor.js'
|
||||||
|
import { getAssetLoader } from './AssetLoader.js'
|
||||||
|
import { EmbeddingResult, EngineStats, MODEL_CONSTANTS } from './types.js'
|
||||||
|
|
||||||
|
// Global singleton instance
|
||||||
|
let globalInstance: WASMEmbeddingEngine | null = null
|
||||||
|
let globalInitPromise: Promise<void> | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WASM-based embedding engine
|
||||||
|
*/
|
||||||
|
export class WASMEmbeddingEngine {
|
||||||
|
private tokenizer: WordPieceTokenizer | null = null
|
||||||
|
private inference: ONNXInferenceEngine | null = null
|
||||||
|
private postProcessor: EmbeddingPostProcessor | null = null
|
||||||
|
private initialized = false
|
||||||
|
private embedCount = 0
|
||||||
|
private totalProcessingTimeMs = 0
|
||||||
|
|
||||||
|
private constructor() {
|
||||||
|
// Private constructor for singleton
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the singleton instance
|
||||||
|
*/
|
||||||
|
static getInstance(): WASMEmbeddingEngine {
|
||||||
|
if (!globalInstance) {
|
||||||
|
globalInstance = new WASMEmbeddingEngine()
|
||||||
|
}
|
||||||
|
return globalInstance
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize all components
|
||||||
|
*/
|
||||||
|
async initialize(): Promise<void> {
|
||||||
|
// Already initialized
|
||||||
|
if (this.initialized) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialization in progress
|
||||||
|
if (globalInitPromise) {
|
||||||
|
await globalInitPromise
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start initialization
|
||||||
|
globalInitPromise = this.performInit()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await globalInitPromise
|
||||||
|
} finally {
|
||||||
|
globalInitPromise = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform actual initialization
|
||||||
|
*/
|
||||||
|
private async performInit(): Promise<void> {
|
||||||
|
const startTime = Date.now()
|
||||||
|
console.log('🚀 Initializing WASM Embedding Engine...')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const assetLoader = getAssetLoader()
|
||||||
|
|
||||||
|
// Verify assets exist
|
||||||
|
const verification = await assetLoader.verifyAssets()
|
||||||
|
if (!verification.valid) {
|
||||||
|
throw new Error(
|
||||||
|
`Missing model assets:\n${verification.errors.join('\n')}\n\n` +
|
||||||
|
`Expected model at: ${verification.modelPath}\n` +
|
||||||
|
`Expected vocab at: ${verification.vocabPath}\n\n` +
|
||||||
|
`Run 'npm run download-model' to download the model files.`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load vocabulary and create tokenizer
|
||||||
|
console.log('📖 Loading vocabulary...')
|
||||||
|
const vocab = await assetLoader.loadVocab()
|
||||||
|
this.tokenizer = new WordPieceTokenizer(vocab)
|
||||||
|
console.log(`✅ Vocabulary loaded: ${this.tokenizer.vocabSize} tokens`)
|
||||||
|
|
||||||
|
// Initialize ONNX inference engine
|
||||||
|
console.log('🧠 Loading ONNX model...')
|
||||||
|
const modelPath = await assetLoader.getModelPath()
|
||||||
|
this.inference = new ONNXInferenceEngine({ modelPath })
|
||||||
|
await this.inference.initialize(modelPath)
|
||||||
|
console.log('✅ ONNX model loaded')
|
||||||
|
|
||||||
|
// Create post-processor
|
||||||
|
this.postProcessor = new EmbeddingPostProcessor(MODEL_CONSTANTS.HIDDEN_SIZE)
|
||||||
|
|
||||||
|
this.initialized = true
|
||||||
|
const initTime = Date.now() - startTime
|
||||||
|
console.log(`✅ WASM Embedding Engine ready in ${initTime}ms`)
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
this.initialized = false
|
||||||
|
this.tokenizer = null
|
||||||
|
this.inference = null
|
||||||
|
this.postProcessor = null
|
||||||
|
throw new Error(
|
||||||
|
`Failed to initialize WASM Embedding Engine: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate embedding for text
|
||||||
|
*/
|
||||||
|
async embed(text: string): Promise<number[]> {
|
||||||
|
const result = await this.embedWithMetadata(text)
|
||||||
|
return result.embedding
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate embedding with metadata
|
||||||
|
*/
|
||||||
|
async embedWithMetadata(text: string): Promise<EmbeddingResult> {
|
||||||
|
// Ensure initialized
|
||||||
|
if (!this.initialized) {
|
||||||
|
await this.initialize()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.tokenizer || !this.inference || !this.postProcessor) {
|
||||||
|
throw new Error('Engine not properly initialized')
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = Date.now()
|
||||||
|
|
||||||
|
// 1. Tokenize
|
||||||
|
const tokenized = this.tokenizer.encode(text)
|
||||||
|
|
||||||
|
// 2. Run inference
|
||||||
|
const hiddenStates = await this.inference.inferSingle(
|
||||||
|
tokenized.inputIds,
|
||||||
|
tokenized.attentionMask,
|
||||||
|
tokenized.tokenTypeIds
|
||||||
|
)
|
||||||
|
|
||||||
|
// 3. Post-process (mean pool + normalize)
|
||||||
|
const embedding = this.postProcessor.process(
|
||||||
|
hiddenStates,
|
||||||
|
tokenized.attentionMask,
|
||||||
|
tokenized.inputIds.length
|
||||||
|
)
|
||||||
|
|
||||||
|
const processingTimeMs = Date.now() - startTime
|
||||||
|
this.embedCount++
|
||||||
|
this.totalProcessingTimeMs += processingTimeMs
|
||||||
|
|
||||||
|
return {
|
||||||
|
embedding: Array.from(embedding),
|
||||||
|
tokenCount: tokenized.tokenCount,
|
||||||
|
processingTimeMs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch embed multiple texts
|
||||||
|
*/
|
||||||
|
async embedBatch(texts: string[]): Promise<number[][]> {
|
||||||
|
// Ensure initialized
|
||||||
|
if (!this.initialized) {
|
||||||
|
await this.initialize()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.tokenizer || !this.inference || !this.postProcessor) {
|
||||||
|
throw new Error('Engine not properly initialized')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (texts.length === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tokenize all texts
|
||||||
|
const batch = this.tokenizer.encodeBatch(texts)
|
||||||
|
const seqLen = batch.inputIds[0].length
|
||||||
|
|
||||||
|
// Run batch inference
|
||||||
|
const hiddenStates = await this.inference.infer(
|
||||||
|
batch.inputIds,
|
||||||
|
batch.attentionMask,
|
||||||
|
batch.tokenTypeIds
|
||||||
|
)
|
||||||
|
|
||||||
|
// Post-process each result
|
||||||
|
const embeddings = this.postProcessor.processBatch(
|
||||||
|
hiddenStates,
|
||||||
|
batch.attentionMask,
|
||||||
|
texts.length,
|
||||||
|
seqLen
|
||||||
|
)
|
||||||
|
|
||||||
|
this.embedCount += texts.length
|
||||||
|
|
||||||
|
return embeddings.map(e => Array.from(e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if initialized
|
||||||
|
*/
|
||||||
|
isInitialized(): boolean {
|
||||||
|
return this.initialized
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get engine statistics
|
||||||
|
*/
|
||||||
|
getStats(): EngineStats {
|
||||||
|
return {
|
||||||
|
initialized: this.initialized,
|
||||||
|
embedCount: this.embedCount,
|
||||||
|
totalProcessingTimeMs: this.totalProcessingTimeMs,
|
||||||
|
avgProcessingTimeMs: this.embedCount > 0
|
||||||
|
? this.totalProcessingTimeMs / this.embedCount
|
||||||
|
: 0,
|
||||||
|
modelName: MODEL_CONSTANTS.MODEL_NAME,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispose and free resources
|
||||||
|
*/
|
||||||
|
async dispose(): Promise<void> {
|
||||||
|
if (this.inference) {
|
||||||
|
await this.inference.dispose()
|
||||||
|
this.inference = null
|
||||||
|
}
|
||||||
|
this.tokenizer = null
|
||||||
|
this.postProcessor = null
|
||||||
|
this.initialized = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset singleton (for testing)
|
||||||
|
*/
|
||||||
|
static resetInstance(): void {
|
||||||
|
if (globalInstance) {
|
||||||
|
globalInstance.dispose()
|
||||||
|
}
|
||||||
|
globalInstance = null
|
||||||
|
globalInitPromise = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export singleton access
|
||||||
|
export const wasmEmbeddingEngine = WASMEmbeddingEngine.getInstance()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience function to get embeddings
|
||||||
|
*/
|
||||||
|
export async function embed(text: string): Promise<number[]> {
|
||||||
|
return wasmEmbeddingEngine.embed(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience function for batch embeddings
|
||||||
|
*/
|
||||||
|
export async function embedBatch(texts: string[]): Promise<number[][]> {
|
||||||
|
return wasmEmbeddingEngine.embedBatch(texts)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get embedding stats
|
||||||
|
*/
|
||||||
|
export function getEmbeddingStats(): EngineStats {
|
||||||
|
return wasmEmbeddingEngine.getStats()
|
||||||
|
}
|
||||||
316
src/embeddings/wasm/WordPieceTokenizer.ts
Normal file
316
src/embeddings/wasm/WordPieceTokenizer.ts
Normal file
|
|
@ -0,0 +1,316 @@
|
||||||
|
/**
|
||||||
|
* WordPiece Tokenizer for BERT-based models
|
||||||
|
*
|
||||||
|
* Implements the WordPiece tokenization algorithm used by all-MiniLM-L6-v2.
|
||||||
|
* This is a clean, dependency-free implementation.
|
||||||
|
*
|
||||||
|
* Algorithm:
|
||||||
|
* 1. Normalize text (lowercase for uncased models)
|
||||||
|
* 2. Split on whitespace and punctuation
|
||||||
|
* 3. Apply WordPiece subword tokenization
|
||||||
|
* 4. Add special tokens ([CLS], [SEP])
|
||||||
|
* 5. Generate attention mask
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
TokenizerConfig,
|
||||||
|
TokenizedInput,
|
||||||
|
SPECIAL_TOKENS,
|
||||||
|
MODEL_CONSTANTS,
|
||||||
|
} from './types.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WordPiece tokenizer for BERT-based sentence transformers
|
||||||
|
*/
|
||||||
|
export class WordPieceTokenizer {
|
||||||
|
private vocab: Map<string, number>
|
||||||
|
private reverseVocab: Map<number, string>
|
||||||
|
private config: TokenizerConfig
|
||||||
|
|
||||||
|
constructor(vocab: Map<string, number> | Record<string, number>, config?: Partial<TokenizerConfig>) {
|
||||||
|
// Convert Record to Map if needed
|
||||||
|
this.vocab = vocab instanceof Map ? vocab : new Map(Object.entries(vocab))
|
||||||
|
|
||||||
|
// Build reverse vocab for debugging
|
||||||
|
this.reverseVocab = new Map()
|
||||||
|
for (const [token, id] of this.vocab) {
|
||||||
|
this.reverseVocab.set(id, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default config for all-MiniLM-L6-v2
|
||||||
|
this.config = {
|
||||||
|
vocab: this.vocab,
|
||||||
|
unkTokenId: config?.unkTokenId ?? SPECIAL_TOKENS.UNK,
|
||||||
|
clsTokenId: config?.clsTokenId ?? SPECIAL_TOKENS.CLS,
|
||||||
|
sepTokenId: config?.sepTokenId ?? SPECIAL_TOKENS.SEP,
|
||||||
|
padTokenId: config?.padTokenId ?? SPECIAL_TOKENS.PAD,
|
||||||
|
maxLength: config?.maxLength ?? MODEL_CONSTANTS.MAX_SEQUENCE_LENGTH,
|
||||||
|
doLowerCase: config?.doLowerCase ?? true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tokenize text into token IDs
|
||||||
|
*/
|
||||||
|
encode(text: string): TokenizedInput {
|
||||||
|
// 1. Normalize
|
||||||
|
let normalizedText = text
|
||||||
|
if (this.config.doLowerCase) {
|
||||||
|
normalizedText = text.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Clean and split into words
|
||||||
|
const words = this.basicTokenize(normalizedText)
|
||||||
|
|
||||||
|
// 3. Apply WordPiece to each word
|
||||||
|
const tokens: number[] = [this.config.clsTokenId]
|
||||||
|
|
||||||
|
for (const word of words) {
|
||||||
|
const wordTokens = this.wordPieceTokenize(word)
|
||||||
|
// Check if adding these tokens would exceed max length (accounting for [SEP])
|
||||||
|
if (tokens.length + wordTokens.length + 1 > this.config.maxLength) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
tokens.push(...wordTokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens.push(this.config.sepTokenId)
|
||||||
|
|
||||||
|
// 4. Generate attention mask and token type IDs
|
||||||
|
const attentionMask = new Array(tokens.length).fill(1)
|
||||||
|
const tokenTypeIds = new Array(tokens.length).fill(0)
|
||||||
|
|
||||||
|
return {
|
||||||
|
inputIds: tokens,
|
||||||
|
attentionMask,
|
||||||
|
tokenTypeIds,
|
||||||
|
tokenCount: tokens.length - 2, // Exclude [CLS] and [SEP]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode with padding to fixed length
|
||||||
|
*/
|
||||||
|
encodeWithPadding(text: string, targetLength?: number): TokenizedInput {
|
||||||
|
const result = this.encode(text)
|
||||||
|
const padLength = targetLength ?? this.config.maxLength
|
||||||
|
|
||||||
|
// Pad to target length
|
||||||
|
while (result.inputIds.length < padLength) {
|
||||||
|
result.inputIds.push(this.config.padTokenId)
|
||||||
|
result.attentionMask.push(0)
|
||||||
|
result.tokenTypeIds.push(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncate if longer (shouldn't happen with proper encode())
|
||||||
|
if (result.inputIds.length > padLength) {
|
||||||
|
result.inputIds.length = padLength
|
||||||
|
result.attentionMask.length = padLength
|
||||||
|
result.tokenTypeIds.length = padLength
|
||||||
|
// Ensure [SEP] is at the end
|
||||||
|
result.inputIds[padLength - 1] = this.config.sepTokenId
|
||||||
|
result.attentionMask[padLength - 1] = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch encode multiple texts
|
||||||
|
*/
|
||||||
|
encodeBatch(texts: string[]): {
|
||||||
|
inputIds: number[][]
|
||||||
|
attentionMask: number[][]
|
||||||
|
tokenTypeIds: number[][]
|
||||||
|
} {
|
||||||
|
const results = texts.map((text) => this.encode(text))
|
||||||
|
|
||||||
|
// Find max length in batch
|
||||||
|
const maxLen = Math.max(...results.map((r) => r.inputIds.length))
|
||||||
|
|
||||||
|
// Pad all to same length
|
||||||
|
const inputIds: number[][] = []
|
||||||
|
const attentionMask: number[][] = []
|
||||||
|
const tokenTypeIds: number[][] = []
|
||||||
|
|
||||||
|
for (const result of results) {
|
||||||
|
const padded = this.encodeWithPadding(
|
||||||
|
'', // Not used since we're modifying result
|
||||||
|
maxLen
|
||||||
|
)
|
||||||
|
// Copy original values
|
||||||
|
for (let i = 0; i < result.inputIds.length; i++) {
|
||||||
|
padded.inputIds[i] = result.inputIds[i]
|
||||||
|
padded.attentionMask[i] = result.attentionMask[i]
|
||||||
|
padded.tokenTypeIds[i] = result.tokenTypeIds[i]
|
||||||
|
}
|
||||||
|
// Pad the rest
|
||||||
|
for (let i = result.inputIds.length; i < maxLen; i++) {
|
||||||
|
padded.inputIds[i] = this.config.padTokenId
|
||||||
|
padded.attentionMask[i] = 0
|
||||||
|
padded.tokenTypeIds[i] = 0
|
||||||
|
}
|
||||||
|
inputIds.push(padded.inputIds.slice(0, maxLen))
|
||||||
|
attentionMask.push(padded.attentionMask.slice(0, maxLen))
|
||||||
|
tokenTypeIds.push(padded.tokenTypeIds.slice(0, maxLen))
|
||||||
|
}
|
||||||
|
|
||||||
|
return { inputIds, attentionMask, tokenTypeIds }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Basic tokenization: split on whitespace and punctuation
|
||||||
|
*/
|
||||||
|
private basicTokenize(text: string): string[] {
|
||||||
|
// Clean whitespace
|
||||||
|
text = text.trim().replace(/\s+/g, ' ')
|
||||||
|
|
||||||
|
if (!text) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const words: string[] = []
|
||||||
|
let currentWord = ''
|
||||||
|
|
||||||
|
for (const char of text) {
|
||||||
|
if (this.isWhitespace(char)) {
|
||||||
|
if (currentWord) {
|
||||||
|
words.push(currentWord)
|
||||||
|
currentWord = ''
|
||||||
|
}
|
||||||
|
} else if (this.isPunctuation(char)) {
|
||||||
|
if (currentWord) {
|
||||||
|
words.push(currentWord)
|
||||||
|
currentWord = ''
|
||||||
|
}
|
||||||
|
words.push(char)
|
||||||
|
} else {
|
||||||
|
currentWord += char
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentWord) {
|
||||||
|
words.push(currentWord)
|
||||||
|
}
|
||||||
|
|
||||||
|
return words
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WordPiece tokenization for a single word
|
||||||
|
*/
|
||||||
|
private wordPieceTokenize(word: string): number[] {
|
||||||
|
if (!word) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if whole word is in vocabulary
|
||||||
|
if (this.vocab.has(word)) {
|
||||||
|
return [this.vocab.get(word)!]
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens: number[] = []
|
||||||
|
let start = 0
|
||||||
|
|
||||||
|
while (start < word.length) {
|
||||||
|
let end = word.length
|
||||||
|
let foundToken = false
|
||||||
|
|
||||||
|
while (start < end) {
|
||||||
|
let substr = word.slice(start, end)
|
||||||
|
|
||||||
|
// Add ## prefix for subwords (not at start of word)
|
||||||
|
if (start > 0) {
|
||||||
|
substr = '##' + substr
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.vocab.has(substr)) {
|
||||||
|
tokens.push(this.vocab.get(substr)!)
|
||||||
|
foundToken = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
end--
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!foundToken) {
|
||||||
|
// Unknown character - use [UNK] for single character
|
||||||
|
tokens.push(this.config.unkTokenId)
|
||||||
|
start++
|
||||||
|
} else {
|
||||||
|
start = end
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if character is whitespace
|
||||||
|
*/
|
||||||
|
private isWhitespace(char: string): boolean {
|
||||||
|
return /\s/.test(char)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if character is punctuation
|
||||||
|
*/
|
||||||
|
private isPunctuation(char: string): boolean {
|
||||||
|
const code = char.charCodeAt(0)
|
||||||
|
// ASCII punctuation ranges
|
||||||
|
if (
|
||||||
|
(code >= 33 && code <= 47) || // !"#$%&'()*+,-./
|
||||||
|
(code >= 58 && code <= 64) || // :;<=>?@
|
||||||
|
(code >= 91 && code <= 96) || // [\]^_`
|
||||||
|
(code >= 123 && code <= 126) // {|}~
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Unicode punctuation categories
|
||||||
|
return /[\u2000-\u206F\u2E00-\u2E7F\\'!"#$%&()*+,\-./:;<=>?@\[\]^_`{|}~]/.test(char)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode token IDs back to text (for debugging)
|
||||||
|
*/
|
||||||
|
decode(tokenIds: number[]): string {
|
||||||
|
const tokens: string[] = []
|
||||||
|
for (const id of tokenIds) {
|
||||||
|
const token = this.reverseVocab.get(id)
|
||||||
|
if (token && !['[CLS]', '[SEP]', '[PAD]'].includes(token)) {
|
||||||
|
if (token.startsWith('##')) {
|
||||||
|
// Subword - append without space
|
||||||
|
if (tokens.length > 0) {
|
||||||
|
tokens[tokens.length - 1] += token.slice(2)
|
||||||
|
} else {
|
||||||
|
tokens.push(token.slice(2))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tokens.push(token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tokens.join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get vocabulary size
|
||||||
|
*/
|
||||||
|
get vocabSize(): number {
|
||||||
|
return this.vocab.size
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get max sequence length
|
||||||
|
*/
|
||||||
|
get maxLength(): number {
|
||||||
|
return this.config.maxLength
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create tokenizer from vocabulary JSON
|
||||||
|
*/
|
||||||
|
export function createTokenizer(vocabJson: Record<string, number>): WordPieceTokenizer {
|
||||||
|
return new WordPieceTokenizer(vocabJson)
|
||||||
|
}
|
||||||
33
src/embeddings/wasm/index.ts
Normal file
33
src/embeddings/wasm/index.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
/**
|
||||||
|
* WASM Embedding Engine - Public Exports
|
||||||
|
*
|
||||||
|
* Clean, production-grade embedding engine using direct ONNX WASM.
|
||||||
|
* No transformers.js dependency, no runtime downloads, works everywhere.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Main engine
|
||||||
|
export {
|
||||||
|
WASMEmbeddingEngine,
|
||||||
|
wasmEmbeddingEngine,
|
||||||
|
embed,
|
||||||
|
embedBatch,
|
||||||
|
getEmbeddingStats,
|
||||||
|
} from './WASMEmbeddingEngine.js'
|
||||||
|
|
||||||
|
// Components (for advanced use)
|
||||||
|
export { WordPieceTokenizer, createTokenizer } from './WordPieceTokenizer.js'
|
||||||
|
export { ONNXInferenceEngine, createInferenceEngine } from './ONNXInferenceEngine.js'
|
||||||
|
export { EmbeddingPostProcessor, createPostProcessor } from './EmbeddingPostProcessor.js'
|
||||||
|
export { AssetLoader, getAssetLoader, createAssetLoader } from './AssetLoader.js'
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export type {
|
||||||
|
TokenizerConfig,
|
||||||
|
TokenizedInput,
|
||||||
|
InferenceConfig,
|
||||||
|
EmbeddingResult,
|
||||||
|
EngineStats,
|
||||||
|
ModelConfig,
|
||||||
|
} from './types.js'
|
||||||
|
|
||||||
|
export { SPECIAL_TOKENS, MODEL_CONSTANTS } from './types.js'
|
||||||
122
src/embeddings/wasm/types.ts
Normal file
122
src/embeddings/wasm/types.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
/**
|
||||||
|
* Type definitions for WASM Embedding Engine
|
||||||
|
*
|
||||||
|
* Clean, production-grade types for direct ONNX WASM embeddings.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tokenizer configuration for WordPiece
|
||||||
|
*/
|
||||||
|
export interface TokenizerConfig {
|
||||||
|
/** Vocabulary mapping word → token ID */
|
||||||
|
vocab: Map<string, number>
|
||||||
|
/** [UNK] token ID (100 for BERT-based models) */
|
||||||
|
unkTokenId: number
|
||||||
|
/** [CLS] token ID (101 for BERT-based models) */
|
||||||
|
clsTokenId: number
|
||||||
|
/** [SEP] token ID (102 for BERT-based models) */
|
||||||
|
sepTokenId: number
|
||||||
|
/** [PAD] token ID (0 for BERT-based models) */
|
||||||
|
padTokenId: number
|
||||||
|
/** Maximum sequence length (512 for all-MiniLM-L6-v2) */
|
||||||
|
maxLength: number
|
||||||
|
/** Whether to lowercase input (true for uncased models) */
|
||||||
|
doLowerCase: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of tokenization
|
||||||
|
*/
|
||||||
|
export interface TokenizedInput {
|
||||||
|
/** Token IDs including [CLS] and [SEP] */
|
||||||
|
inputIds: number[]
|
||||||
|
/** Attention mask (1 for real tokens, 0 for padding) */
|
||||||
|
attentionMask: number[]
|
||||||
|
/** Token type IDs (all 0 for single sentence) */
|
||||||
|
tokenTypeIds: number[]
|
||||||
|
/** Number of tokens (excluding special tokens) */
|
||||||
|
tokenCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ONNX inference engine configuration
|
||||||
|
*/
|
||||||
|
export interface InferenceConfig {
|
||||||
|
/** Path to ONNX model file */
|
||||||
|
modelPath: string
|
||||||
|
/** Path to WASM files directory */
|
||||||
|
wasmPath?: string
|
||||||
|
/** Number of threads (1 for universal compatibility) */
|
||||||
|
numThreads: number
|
||||||
|
/** Enable SIMD if available */
|
||||||
|
enableSimd: boolean
|
||||||
|
/** Enable CPU memory arena (false for memory efficiency) */
|
||||||
|
enableCpuMemArena: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Embedding result with metadata
|
||||||
|
*/
|
||||||
|
export interface EmbeddingResult {
|
||||||
|
/** 384-dimensional embedding vector */
|
||||||
|
embedding: number[]
|
||||||
|
/** Number of tokens processed */
|
||||||
|
tokenCount: number
|
||||||
|
/** Processing time in milliseconds */
|
||||||
|
processingTimeMs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Engine statistics
|
||||||
|
*/
|
||||||
|
export interface EngineStats {
|
||||||
|
/** Whether the engine is initialized */
|
||||||
|
initialized: boolean
|
||||||
|
/** Total number of embeddings generated */
|
||||||
|
embedCount: number
|
||||||
|
/** Total processing time in milliseconds */
|
||||||
|
totalProcessingTimeMs: number
|
||||||
|
/** Average processing time per embedding */
|
||||||
|
avgProcessingTimeMs: number
|
||||||
|
/** Model name */
|
||||||
|
modelName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Model configuration (from config.json)
|
||||||
|
*/
|
||||||
|
export interface ModelConfig {
|
||||||
|
/** Model architecture type */
|
||||||
|
architectures: string[]
|
||||||
|
/** Hidden size (384 for all-MiniLM-L6-v2) */
|
||||||
|
hidden_size: number
|
||||||
|
/** Number of attention heads */
|
||||||
|
num_attention_heads: number
|
||||||
|
/** Number of hidden layers */
|
||||||
|
num_hidden_layers: number
|
||||||
|
/** Vocabulary size */
|
||||||
|
vocab_size: number
|
||||||
|
/** Maximum position embeddings */
|
||||||
|
max_position_embeddings: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Special token IDs for BERT-based models
|
||||||
|
*/
|
||||||
|
export const SPECIAL_TOKENS = {
|
||||||
|
PAD: 0,
|
||||||
|
UNK: 100,
|
||||||
|
CLS: 101,
|
||||||
|
SEP: 102,
|
||||||
|
MASK: 103,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Model constants for all-MiniLM-L6-v2
|
||||||
|
*/
|
||||||
|
export const MODEL_CONSTANTS = {
|
||||||
|
HIDDEN_SIZE: 384,
|
||||||
|
MAX_SEQUENCE_LENGTH: 512,
|
||||||
|
VOCAB_SIZE: 30522,
|
||||||
|
MODEL_NAME: 'all-MiniLM-L6-v2',
|
||||||
|
} as const
|
||||||
51
src/setup.ts
51
src/setup.ts
|
|
@ -1,46 +1,31 @@
|
||||||
/**
|
/**
|
||||||
* CRITICAL: This file is imported for its side effects to patch the environment
|
* Brainy Setup - Minimal Polyfills
|
||||||
* for Node.js compatibility before any other library code runs.
|
|
||||||
*
|
*
|
||||||
* It ensures that by the time Transformers.js/ONNX Runtime is imported by any other
|
* ARCHITECTURE (v7.0.0):
|
||||||
* module, the necessary compatibility fixes for the current Node.js
|
* Brainy uses direct ONNX WASM for embeddings.
|
||||||
* environment are already in place.
|
* No transformers.js dependency, no hacks required.
|
||||||
*
|
*
|
||||||
* This file MUST be imported as the first import in unified.ts to prevent
|
* This file provides minimal polyfills for cross-environment compatibility:
|
||||||
* race conditions with library initialization. Failure to do so may
|
* - TextEncoder/TextDecoder for older environments
|
||||||
* result in errors like "TextEncoder is not a constructor" when the package
|
|
||||||
* is used in Node.js environments.
|
|
||||||
*
|
*
|
||||||
* The package.json file marks this file as having side effects to prevent
|
* BENEFITS:
|
||||||
* tree-shaking by bundlers, ensuring the patch is always applied.
|
* - Clean codebase with no workarounds
|
||||||
|
* - Works everywhere: Node.js, Bun, Bun --compile, browsers, Deno
|
||||||
|
* - No platform-specific binaries
|
||||||
|
* - Model bundled in package (no runtime downloads)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Get the appropriate global object for the current environment
|
// ============================================================================
|
||||||
const globalObj = (() => {
|
// TextEncoder/TextDecoder Polyfills
|
||||||
if (typeof globalThis !== 'undefined') return globalThis
|
// ============================================================================
|
||||||
if (typeof global !== 'undefined') return global
|
const globalObj = globalThis ?? global ?? self
|
||||||
if (typeof self !== 'undefined') return self
|
|
||||||
return null // No global object available
|
|
||||||
})()
|
|
||||||
|
|
||||||
// Define TextEncoder and TextDecoder globally to make sure they're available
|
|
||||||
// Now works across all environments: Node.js, serverless, and other server environments
|
|
||||||
if (globalObj) {
|
if (globalObj) {
|
||||||
if (!globalObj.TextEncoder) {
|
if (!globalObj.TextEncoder) globalObj.TextEncoder = TextEncoder
|
||||||
globalObj.TextEncoder = TextEncoder
|
if (!globalObj.TextDecoder) globalObj.TextDecoder = TextDecoder
|
||||||
}
|
;(globalObj as any).__TextEncoder__ = TextEncoder
|
||||||
if (!globalObj.TextDecoder) {
|
|
||||||
globalObj.TextDecoder = TextDecoder
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create special global constructors for library compatibility
|
|
||||||
(globalObj as any).__TextEncoder__ = TextEncoder
|
|
||||||
;(globalObj as any).__TextDecoder__ = TextDecoder
|
;(globalObj as any).__TextDecoder__ = TextDecoder
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also import normally for ES modules environments
|
|
||||||
import { applyTensorFlowPatch } from './utils/textEncoding.js'
|
import { applyTensorFlowPatch } from './utils/textEncoding.js'
|
||||||
|
|
||||||
// Apply the TextEncoder/TextDecoder compatibility patch
|
|
||||||
applyTensorFlowPatch()
|
applyTensorFlowPatch()
|
||||||
console.log('Applied TextEncoder/TextDecoder patch via ES modules in setup.ts')
|
|
||||||
|
|
|
||||||
|
|
@ -1,271 +1,47 @@
|
||||||
/**
|
/**
|
||||||
* Embedding functions for converting data to vectors using Transformers.js
|
* Embedding functions for converting data to vectors
|
||||||
* Complete rewrite to eliminate TensorFlow.js and use ONNX-based models
|
*
|
||||||
|
* Uses direct ONNX WASM for universal compatibility.
|
||||||
|
* No transformers.js dependency - clean, production-grade implementation.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { EmbeddingFunction, EmbeddingModel, Vector } from '../coreTypes.js'
|
import { EmbeddingFunction, EmbeddingModel, Vector } from '../coreTypes.js'
|
||||||
import { executeInThread } from './workerUtils.js'
|
import { embeddingManager } from '../embeddings/EmbeddingManager.js'
|
||||||
import { isBrowser } from './environment.js'
|
|
||||||
// @ts-ignore - Transformers.js is now the primary embedding library
|
|
||||||
import { pipeline, env } from '@huggingface/transformers'
|
|
||||||
|
|
||||||
// CRITICAL: Disable ONNX memory arena to prevent 4-8GB allocation
|
|
||||||
// This is needed for BOTH production and testing - reduces memory by 50-75%
|
|
||||||
if (typeof process !== 'undefined' && process.env) {
|
|
||||||
process.env.ORT_DISABLE_MEMORY_ARENA = '1'
|
|
||||||
process.env.ORT_DISABLE_MEMORY_PATTERN = '1'
|
|
||||||
// Force single-threaded operation for maximum stability (Node.js 22 LTS)
|
|
||||||
process.env.ORT_INTRA_OP_NUM_THREADS = '1' // Single thread for operators
|
|
||||||
process.env.ORT_INTER_OP_NUM_THREADS = '1' // Single thread for sessions
|
|
||||||
process.env.ORT_NUM_THREADS = '1' // Additional safety override
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect the best available GPU device for the current environment
|
* TransformerEmbedding options (kept for backward compatibility)
|
||||||
*/
|
|
||||||
export async function detectBestDevice(): Promise<'cpu' | 'webgpu' | 'cuda'> {
|
|
||||||
// Browser environment - check for WebGPU support
|
|
||||||
if (isBrowser()) {
|
|
||||||
if (typeof navigator !== 'undefined' && 'gpu' in navigator) {
|
|
||||||
try {
|
|
||||||
const adapter = await (navigator as any).gpu?.requestAdapter()
|
|
||||||
if (adapter) {
|
|
||||||
return 'webgpu'
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// WebGPU not available or failed to initialize
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 'cpu'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Node.js environment - check for CUDA support
|
|
||||||
try {
|
|
||||||
// Check if ONNX Runtime GPU packages are available
|
|
||||||
// This is a simple heuristic - in production you might want more sophisticated detection
|
|
||||||
const hasGpu = process.env.CUDA_VISIBLE_DEVICES !== undefined ||
|
|
||||||
process.env.ONNXRUNTIME_GPU_ENABLED === 'true'
|
|
||||||
return hasGpu ? 'cuda' : 'cpu'
|
|
||||||
} catch (error) {
|
|
||||||
return 'cpu'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve device string to actual device configuration
|
|
||||||
*/
|
|
||||||
export async function resolveDevice(device: string = 'auto'): Promise<string> {
|
|
||||||
if (device === 'auto') {
|
|
||||||
return await detectBestDevice()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map 'gpu' to appropriate GPU type for current environment
|
|
||||||
if (device === 'gpu') {
|
|
||||||
const detected = await detectBestDevice()
|
|
||||||
return detected === 'cpu' ? 'cpu' : detected
|
|
||||||
}
|
|
||||||
|
|
||||||
return device
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Transformers.js Sentence Encoder embedding model
|
|
||||||
* Uses ONNX Runtime for fast, offline embeddings with smaller models
|
|
||||||
* Default model: all-MiniLM-L6-v2 (384 dimensions, ~90MB)
|
|
||||||
*/
|
*/
|
||||||
export interface TransformerEmbeddingOptions {
|
export interface TransformerEmbeddingOptions {
|
||||||
/** Model name/path to use - defaults to all-MiniLM-L6-v2 */
|
/** Model name - only all-MiniLM-L6-v2 is supported */
|
||||||
model?: string
|
model?: string
|
||||||
/** Whether to enable verbose logging */
|
/** Whether to enable verbose logging */
|
||||||
verbose?: boolean
|
verbose?: boolean
|
||||||
/** Custom cache directory for models */
|
/** Custom cache directory - ignored (model is bundled) */
|
||||||
cacheDir?: string
|
cacheDir?: string
|
||||||
/** Force local files only (no downloads) */
|
/** Force local files only - ignored (model is bundled) */
|
||||||
localFilesOnly?: boolean
|
localFilesOnly?: boolean
|
||||||
/** Model precision: 'q8' = 75% smaller quantized model, 'fp32' = full precision (default) */
|
/** Model precision - always q8 */
|
||||||
precision?: 'fp32' | 'q8'
|
precision?: 'fp32' | 'q8'
|
||||||
/** Device to run inference on - 'auto' detects best available */
|
/** Device - always WASM */
|
||||||
device?: 'auto' | 'cpu' | 'webgpu' | 'cuda' | 'gpu'
|
device?: 'auto' | 'cpu' | 'webgpu' | 'cuda' | 'gpu'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TransformerEmbedding - Sentence embeddings using WASM ONNX
|
||||||
|
*
|
||||||
|
* This class delegates all work to EmbeddingManager which uses
|
||||||
|
* the direct ONNX WASM engine. Kept for backward compatibility.
|
||||||
|
*/
|
||||||
export class TransformerEmbedding implements EmbeddingModel {
|
export class TransformerEmbedding implements EmbeddingModel {
|
||||||
private extractor: any = null
|
|
||||||
private initialized = false
|
private initialized = false
|
||||||
private verbose: boolean = true
|
private verbose: boolean
|
||||||
private options: Required<TransformerEmbeddingOptions>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new TransformerEmbedding instance
|
|
||||||
*/
|
|
||||||
constructor(options: TransformerEmbeddingOptions = {}) {
|
constructor(options: TransformerEmbeddingOptions = {}) {
|
||||||
this.verbose = options.verbose !== undefined ? options.verbose : true
|
this.verbose = options.verbose !== undefined ? options.verbose : true
|
||||||
|
|
||||||
// PRODUCTION-READY MODEL CONFIGURATION
|
|
||||||
// Priority order: explicit option > environment variable > smart default
|
|
||||||
|
|
||||||
let localFilesOnly: boolean
|
|
||||||
|
|
||||||
if (options.localFilesOnly !== undefined) {
|
|
||||||
// 1. Explicit option takes highest priority
|
|
||||||
localFilesOnly = options.localFilesOnly
|
|
||||||
} else if (process.env.BRAINY_ALLOW_REMOTE_MODELS === 'false') {
|
|
||||||
// 2. Environment variable explicitly disables remote models (legacy support)
|
|
||||||
localFilesOnly = true
|
|
||||||
} else if (process.env.NODE_ENV === 'development') {
|
|
||||||
// 3. Development mode allows remote models
|
|
||||||
localFilesOnly = false
|
|
||||||
} else if (isBrowser()) {
|
|
||||||
// 4. Browser defaults to allowing remote models
|
|
||||||
localFilesOnly = false
|
|
||||||
} else {
|
|
||||||
// 5. Node.js production: try local first, but allow remote as fallback
|
|
||||||
// This is the NEW production-friendly default
|
|
||||||
localFilesOnly = false
|
|
||||||
}
|
|
||||||
|
|
||||||
this.options = {
|
|
||||||
model: options.model || 'Xenova/all-MiniLM-L6-v2',
|
|
||||||
verbose: this.verbose,
|
|
||||||
cacheDir: options.cacheDir || './models',
|
|
||||||
localFilesOnly: localFilesOnly,
|
|
||||||
precision: options.precision || 'fp32', // Clean and clear!
|
|
||||||
device: options.device || 'auto'
|
|
||||||
}
|
|
||||||
|
|
||||||
// ULTRA-CAREFUL: Runtime warnings for q8 usage
|
|
||||||
if (this.options.precision === 'q8') {
|
|
||||||
const confirmed = process.env.BRAINY_Q8_CONFIRMED === 'true'
|
|
||||||
if (!confirmed && this.verbose) {
|
|
||||||
console.warn('🚨 Q8 MODEL WARNING:')
|
|
||||||
console.warn(' • Q8 creates different embeddings than fp32')
|
|
||||||
console.warn(' • Q8 is incompatible with existing fp32 data')
|
|
||||||
console.warn(' • Only use q8 for new projects or when explicitly migrating')
|
|
||||||
console.warn(' • Set BRAINY_Q8_CONFIRMED=true to silence this warning')
|
|
||||||
console.warn(' • Q8 model is 75% smaller but may have slightly reduced accuracy')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.verbose) {
|
if (this.verbose) {
|
||||||
this.logger('log', `Embedding config: precision=${this.options.precision}, localFilesOnly=${localFilesOnly}, model=${this.options.model}`)
|
console.log('[TransformerEmbedding] Using WASM ONNX backend (delegating to EmbeddingManager)')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configure transformers.js environment
|
|
||||||
if (!isBrowser()) {
|
|
||||||
// Set cache directory for Node.js
|
|
||||||
env.cacheDir = this.options.cacheDir
|
|
||||||
// Prioritize local models for offline operation
|
|
||||||
env.allowRemoteModels = !this.options.localFilesOnly
|
|
||||||
env.allowLocalModels = true
|
|
||||||
} else {
|
|
||||||
// Browser configuration
|
|
||||||
// Allow both local and remote models, but prefer local if available
|
|
||||||
env.allowLocalModels = true
|
|
||||||
env.allowRemoteModels = true
|
|
||||||
// Force the configuration to ensure it's applied
|
|
||||||
if (this.verbose) {
|
|
||||||
this.logger('log', `Browser env config - allowLocalModels: ${env.allowLocalModels}, allowRemoteModels: ${env.allowRemoteModels}, localFilesOnly: ${this.options.localFilesOnly}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the default cache directory for models
|
|
||||||
*/
|
|
||||||
private async getDefaultCacheDir(): Promise<string> {
|
|
||||||
if (isBrowser()) {
|
|
||||||
return './models' // Browser default
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for bundled models in the package
|
|
||||||
const possiblePaths = [
|
|
||||||
// In the installed package
|
|
||||||
'./node_modules/@soulcraft/brainy/models',
|
|
||||||
// In development/source
|
|
||||||
'./models',
|
|
||||||
'./dist/../models',
|
|
||||||
// Alternative locations
|
|
||||||
'../models',
|
|
||||||
'../../models'
|
|
||||||
]
|
|
||||||
|
|
||||||
// Check if we're in Node.js and try to find the bundled models
|
|
||||||
if (typeof process !== 'undefined' && process.versions?.node) {
|
|
||||||
try {
|
|
||||||
// Use dynamic import instead of require for ES modules compatibility
|
|
||||||
const { createRequire } = await import('module')
|
|
||||||
const require = createRequire(import.meta.url)
|
|
||||||
|
|
||||||
const path = require('node:path')
|
|
||||||
const fs = require('node:fs')
|
|
||||||
|
|
||||||
// Try to resolve the package location
|
|
||||||
try {
|
|
||||||
const brainyPackagePath = require.resolve('@soulcraft/brainy/package.json')
|
|
||||||
const brainyPackageDir = path.dirname(brainyPackagePath)
|
|
||||||
const bundledModelsPath = path.join(brainyPackageDir, 'models')
|
|
||||||
|
|
||||||
if (fs.existsSync(bundledModelsPath)) {
|
|
||||||
this.logger('log', `Using bundled models from package: ${bundledModelsPath}`)
|
|
||||||
return bundledModelsPath
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Not installed as package, continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try relative paths from current location
|
|
||||||
for (const relativePath of possiblePaths) {
|
|
||||||
const fullPath = path.resolve(relativePath)
|
|
||||||
if (fs.existsSync(fullPath)) {
|
|
||||||
this.logger('log', `Using bundled models from: ${fullPath}`)
|
|
||||||
return fullPath
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Silently fall back to default path if module detection fails
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to default cache directory
|
|
||||||
return './models'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if we're running in a test environment
|
|
||||||
*/
|
|
||||||
private isTestEnvironment(): boolean {
|
|
||||||
// Always use real implementation - no more mocking
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Log message only if verbose mode is enabled
|
|
||||||
*/
|
|
||||||
private logger(level: 'log' | 'warn' | 'error', message: string, ...args: any[]): void {
|
|
||||||
if (level === 'error' || this.verbose) {
|
|
||||||
console[level](`[TransformerEmbedding] ${message}`, ...args)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate mock embeddings for unit tests
|
|
||||||
*/
|
|
||||||
private getMockEmbedding(data: string | string[]): Vector {
|
|
||||||
// Use the same mock logic as setup-unit.ts for consistency
|
|
||||||
const input = Array.isArray(data) ? data.join(' ') : data
|
|
||||||
const str = typeof input === 'string' ? input : JSON.stringify(input)
|
|
||||||
const vector = new Array(384).fill(0)
|
|
||||||
|
|
||||||
// Create semi-realistic embeddings based on text content
|
|
||||||
for (let i = 0; i < Math.min(str.length, 384); i++) {
|
|
||||||
vector[i] = (str.charCodeAt(i % str.length) % 256) / 256
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add position-based variation
|
|
||||||
for (let i = 0; i < 384; i++) {
|
|
||||||
vector[i] += Math.sin(i * 0.1 + str.length) * 0.1
|
|
||||||
}
|
|
||||||
|
|
||||||
return vector
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -276,127 +52,16 @@ export class TransformerEmbedding implements EmbeddingModel {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// In unit test mode, skip real model initialization to prevent ONNX conflicts
|
|
||||||
if (process.env.BRAINY_UNIT_TEST === 'true' || (globalThis as any).__BRAINY_UNIT_TEST__) {
|
|
||||||
this.initialized = true
|
|
||||||
this.logger('log', '🧪 Using mocked embeddings for unit tests')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await embeddingManager.init()
|
||||||
// Resolve device configuration and cache directory
|
|
||||||
const device = await resolveDevice(this.options.device)
|
|
||||||
const cacheDir = this.options.cacheDir === './models'
|
|
||||||
? await this.getDefaultCacheDir()
|
|
||||||
: this.options.cacheDir
|
|
||||||
|
|
||||||
this.logger('log', `Loading Transformer model: ${this.options.model} on device: ${device}`)
|
|
||||||
|
|
||||||
const startTime = Date.now()
|
|
||||||
|
|
||||||
// Use the configured precision from EmbeddingManager
|
|
||||||
const { embeddingManager } = await import('../embeddings/EmbeddingManager.js')
|
|
||||||
let actualType = embeddingManager.getPrecision()
|
|
||||||
|
|
||||||
// CRITICAL: Control which model precision transformers.js uses
|
|
||||||
// Q8 models use quantized int8 weights for 75% size reduction
|
|
||||||
// Always use Q8 for optimal balance
|
|
||||||
|
|
||||||
actualType = 'q8' // Always Q8
|
|
||||||
this.logger('log', '🎯 Using Q8 quantized model (75% smaller, 99% accuracy)')
|
|
||||||
|
|
||||||
// Load the feature extraction pipeline with memory optimizations
|
|
||||||
const pipelineOptions: any = {
|
|
||||||
cache_dir: cacheDir,
|
|
||||||
local_files_only: isBrowser() ? false : this.options.localFilesOnly,
|
|
||||||
// CRITICAL: Specify dtype for model precision
|
|
||||||
dtype: 'q8',
|
|
||||||
// CRITICAL: For Q8, explicitly use quantized model
|
|
||||||
quantized: true,
|
|
||||||
// CRITICAL: ONNX memory optimizations
|
|
||||||
session_options: {
|
|
||||||
enableCpuMemArena: false, // Disable pre-allocated memory arena
|
|
||||||
enableMemPattern: false, // Disable memory pattern optimization
|
|
||||||
interOpNumThreads: 1, // Force single thread for V8 stability
|
|
||||||
intraOpNumThreads: 1, // Force single thread for V8 stability
|
|
||||||
graphOptimizationLevel: 'disabled' // Disable threading optimizations
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add device configuration for GPU acceleration
|
|
||||||
if (device !== 'cpu') {
|
|
||||||
pipelineOptions.device = device
|
|
||||||
this.logger('log', `🚀 GPU acceleration enabled: ${device}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.verbose) {
|
|
||||||
this.logger('log', `Pipeline options: ${JSON.stringify(pipelineOptions)}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// For Q8 models, we need to explicitly specify the model file
|
|
||||||
if (actualType === 'q8' && !isBrowser()) {
|
|
||||||
try {
|
|
||||||
// Check if quantized model exists (Node.js only)
|
|
||||||
const { join } = await import('node:path')
|
|
||||||
const { existsSync } = await import('node:fs')
|
|
||||||
const modelPath = join(cacheDir, this.options.model, 'onnx', 'model_quantized.onnx')
|
|
||||||
if (existsSync(modelPath)) {
|
|
||||||
this.logger('log', '✅ Q8 model found locally')
|
|
||||||
} else {
|
|
||||||
this.logger('warn', '⚠️ Q8 model not found')
|
|
||||||
actualType = 'q8' // Always Q8
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Skip model path check in browser or if imports fail
|
|
||||||
this.logger('log', '🌐 Skipping local model check in browser environment')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.extractor = await pipeline('feature-extraction', this.options.model, pipelineOptions)
|
|
||||||
} catch (gpuError: any) {
|
|
||||||
// Fallback to CPU if GPU initialization fails
|
|
||||||
if (device !== 'cpu') {
|
|
||||||
this.logger('warn', `GPU initialization failed, falling back to CPU: ${gpuError?.message || gpuError}`)
|
|
||||||
const cpuOptions = { ...pipelineOptions }
|
|
||||||
delete cpuOptions.device
|
|
||||||
this.extractor = await pipeline('feature-extraction', this.options.model, cpuOptions)
|
|
||||||
} else {
|
|
||||||
// PRODUCTION-READY ERROR HANDLING
|
|
||||||
// If local_files_only is true and models are missing, try enabling remote downloads
|
|
||||||
if (pipelineOptions.local_files_only && gpuError?.message?.includes('local_files_only')) {
|
|
||||||
this.logger('warn', 'Local models not found, attempting remote download as fallback...')
|
|
||||||
|
|
||||||
try {
|
|
||||||
const remoteOptions = { ...pipelineOptions, local_files_only: false }
|
|
||||||
this.extractor = await pipeline('feature-extraction', this.options.model, remoteOptions)
|
|
||||||
this.logger('log', '✅ Successfully downloaded and loaded model from remote')
|
|
||||||
|
|
||||||
// Update the configuration to reflect what actually worked
|
|
||||||
this.options.localFilesOnly = false
|
|
||||||
} catch (remoteError: any) {
|
|
||||||
// Both local and remote failed - throw comprehensive error
|
|
||||||
const errorMsg = `Failed to load embedding model "${this.options.model}". ` +
|
|
||||||
`Local models not found and remote download failed. ` +
|
|
||||||
`To fix: 1) Run "npm run download-models", ` +
|
|
||||||
`2) Check your internet connection, or ` +
|
|
||||||
`3) Use a custom embedding function.`
|
|
||||||
throw new Error(errorMsg)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw gpuError
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadTime = Date.now() - startTime
|
|
||||||
this.logger('log', `✅ Model loaded successfully in ${loadTime}ms`)
|
|
||||||
|
|
||||||
this.initialized = true
|
this.initialized = true
|
||||||
|
|
||||||
|
if (this.verbose) {
|
||||||
|
console.log('[TransformerEmbedding] Initialized via EmbeddingManager (WASM)')
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger('error', 'Failed to initialize Transformer embedding model:', error)
|
console.error('[TransformerEmbedding] Failed to initialize:', error)
|
||||||
throw new Error(`Transformer embedding initialization failed: ${error}`)
|
throw new Error(`TransformerEmbedding initialization failed: ${error}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -404,177 +69,93 @@ export class TransformerEmbedding implements EmbeddingModel {
|
||||||
* Generate embeddings for text data
|
* Generate embeddings for text data
|
||||||
*/
|
*/
|
||||||
public async embed(data: string | string[]): Promise<Vector> {
|
public async embed(data: string | string[]): Promise<Vector> {
|
||||||
// In unit test mode, return mock embeddings
|
|
||||||
if (process.env.BRAINY_UNIT_TEST === 'true' || (globalThis as any).__BRAINY_UNIT_TEST__) {
|
|
||||||
return this.getMockEmbedding(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.initialized) {
|
if (!this.initialized) {
|
||||||
await this.init()
|
await this.init()
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// Delegate to EmbeddingManager
|
||||||
// Handle different input types
|
return embeddingManager.embed(data)
|
||||||
let textToEmbed: string[]
|
}
|
||||||
|
|
||||||
if (typeof data === 'string') {
|
|
||||||
// Handle empty string case
|
|
||||||
if (data.trim() === '') {
|
|
||||||
// Return a zero vector of 384 dimensions (all-MiniLM-L6-v2 standard)
|
|
||||||
return new Array(384).fill(0)
|
|
||||||
}
|
|
||||||
textToEmbed = [data]
|
|
||||||
} else if (Array.isArray(data) && data.every((item) => typeof item === 'string')) {
|
|
||||||
// Handle empty array or array with empty strings
|
|
||||||
if (data.length === 0 || data.every((item) => item.trim() === '')) {
|
|
||||||
return new Array(384).fill(0)
|
|
||||||
}
|
|
||||||
// Filter out empty strings
|
|
||||||
textToEmbed = data.filter((item) => item.trim() !== '')
|
|
||||||
if (textToEmbed.length === 0) {
|
|
||||||
return new Array(384).fill(0)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw new Error('TransformerEmbedding only supports string or string[] data')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure the extractor is available
|
/**
|
||||||
if (!this.extractor) {
|
* Get the embedding function
|
||||||
throw new Error('Transformer embedding model is not available')
|
*/
|
||||||
}
|
getEmbeddingFunction(): EmbeddingFunction {
|
||||||
|
return async (data: string | string[] | Record<string, unknown>): Promise<Vector> => {
|
||||||
// Generate embeddings with mean pooling and normalization
|
return this.embed(data as string | string[])
|
||||||
const result = await this.extractor(textToEmbed, {
|
|
||||||
pooling: 'mean',
|
|
||||||
normalize: true
|
|
||||||
})
|
|
||||||
|
|
||||||
// Extract the embedding data
|
|
||||||
let embedding: number[]
|
|
||||||
|
|
||||||
if (textToEmbed.length === 1) {
|
|
||||||
// Single text input - return first embedding
|
|
||||||
embedding = Array.from(result.data.slice(0, 384))
|
|
||||||
} else {
|
|
||||||
// Multiple texts - return first embedding (maintain compatibility)
|
|
||||||
embedding = Array.from(result.data.slice(0, 384))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate embedding dimensions
|
|
||||||
if (embedding.length !== 384) {
|
|
||||||
this.logger('warn', `Unexpected embedding dimension: ${embedding.length}, expected 384`)
|
|
||||||
// Pad or truncate to 384 dimensions
|
|
||||||
if (embedding.length < 384) {
|
|
||||||
embedding = [...embedding, ...new Array(384 - embedding.length).fill(0)]
|
|
||||||
} else {
|
|
||||||
embedding = embedding.slice(0, 384)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return embedding
|
|
||||||
} catch (error) {
|
|
||||||
this.logger('error', 'Error generating embeddings:', error)
|
|
||||||
throw new Error(`Failed to generate embeddings: ${error}`)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dispose of the model and free resources
|
* Check if initialized
|
||||||
*/
|
*/
|
||||||
public async dispose(): Promise<void> {
|
isInitialized(): boolean {
|
||||||
if (this.extractor && typeof this.extractor.dispose === 'function') {
|
|
||||||
await this.extractor.dispose()
|
|
||||||
}
|
|
||||||
this.extractor = null
|
|
||||||
this.initialized = false
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the dimension of embeddings produced by this model
|
|
||||||
*/
|
|
||||||
public getDimension(): number {
|
|
||||||
return 384
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the model is initialized
|
|
||||||
*/
|
|
||||||
public isInitialized(): boolean {
|
|
||||||
return this.initialized
|
return this.initialized
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispose resources (no-op for WASM engine)
|
||||||
|
*/
|
||||||
|
async dispose(): Promise<void> {
|
||||||
|
this.initialized = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy alias for backward compatibility
|
/**
|
||||||
export const UniversalSentenceEncoder = TransformerEmbedding
|
* Create a simple embedding function using the default TransformerEmbedding
|
||||||
|
* This is the recommended way to create an embedding function for Brainy
|
||||||
|
*/
|
||||||
|
export function createEmbeddingFunction(options: TransformerEmbeddingOptions = {}): EmbeddingFunction {
|
||||||
|
return embeddingManager.getEmbeddingFunction()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new embedding model instance
|
* Create a TransformerEmbedding instance (backward compatibility)
|
||||||
*/
|
*/
|
||||||
export function createEmbeddingModel(options?: TransformerEmbeddingOptions): EmbeddingModel {
|
export function createTransformerEmbedding(options: TransformerEmbeddingOptions = {}): TransformerEmbedding {
|
||||||
return new TransformerEmbedding(options)
|
return new TransformerEmbedding(options)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default embedding function using the unified EmbeddingManager
|
* Convenience function to detect best device (always returns 'wasm')
|
||||||
* Simple, clean, reliable - no more layers of indirection
|
|
||||||
*/
|
*/
|
||||||
export const defaultEmbeddingFunction: EmbeddingFunction = async (data: string | string[] | Record<string, unknown>): Promise<Vector> => {
|
export async function detectBestDevice(): Promise<'cpu' | 'webgpu' | 'cuda' | 'wasm'> {
|
||||||
const { embed } = await import('../embeddings/EmbeddingManager.js')
|
return 'wasm' as any
|
||||||
return await embed(data)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create an embedding function with custom options
|
* Resolve device string (always returns 'wasm')
|
||||||
* NOTE: Options are validated but the singleton EmbeddingManager is always used
|
|
||||||
*/
|
*/
|
||||||
export function createEmbeddingFunction(options: TransformerEmbeddingOptions = {}): EmbeddingFunction {
|
export async function resolveDevice(_device: string = 'auto'): Promise<string> {
|
||||||
return async (data: string | string[] | Record<string, unknown>): Promise<Vector> => {
|
return 'wasm'
|
||||||
const { embeddingManager } = await import('../embeddings/EmbeddingManager.js')
|
}
|
||||||
|
|
||||||
// Validate precision if specified
|
|
||||||
// Precision is always Q8 now
|
|
||||||
|
|
||||||
return await embeddingManager.embed(data)
|
/**
|
||||||
|
* Default embedding function (backward compatibility)
|
||||||
|
*/
|
||||||
|
export const defaultEmbeddingFunction: EmbeddingFunction = embeddingManager.getEmbeddingFunction()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UniversalSentenceEncoder alias (backward compatibility)
|
||||||
|
*/
|
||||||
|
export const UniversalSentenceEncoder = TransformerEmbedding
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch embed function (backward compatibility)
|
||||||
|
*/
|
||||||
|
export async function batchEmbed(texts: string[]): Promise<Vector[]> {
|
||||||
|
const results: Vector[] = []
|
||||||
|
for (const text of texts) {
|
||||||
|
results.push(await embeddingManager.embed(text))
|
||||||
}
|
}
|
||||||
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Batch embedding function for processing multiple texts efficiently
|
* Embedding functions registry (backward compatibility)
|
||||||
*/
|
|
||||||
export async function batchEmbed(
|
|
||||||
texts: string[],
|
|
||||||
options: TransformerEmbeddingOptions = {}
|
|
||||||
): Promise<Vector[]> {
|
|
||||||
const embedder = new TransformerEmbedding(options)
|
|
||||||
await embedder.init()
|
|
||||||
|
|
||||||
const embeddings: Vector[] = []
|
|
||||||
|
|
||||||
// Process in batches for memory efficiency
|
|
||||||
const batchSize = 32
|
|
||||||
for (let i = 0; i < texts.length; i += batchSize) {
|
|
||||||
const batch = texts.slice(i, i + batchSize)
|
|
||||||
|
|
||||||
for (const text of batch) {
|
|
||||||
const embedding = await embedder.embed(text)
|
|
||||||
embeddings.push(embedding)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await embedder.dispose()
|
|
||||||
return embeddings
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Embedding functions for specific model types
|
|
||||||
*/
|
*/
|
||||||
export const embeddingFunctions = {
|
export const embeddingFunctions = {
|
||||||
/** Default lightweight model (all-MiniLM-L6-v2, 384 dimensions) */
|
transformer: createEmbeddingFunction,
|
||||||
default: defaultEmbeddingFunction,
|
default: createEmbeddingFunction,
|
||||||
|
}
|
||||||
/** Create custom embedding function */
|
|
||||||
create: createEmbeddingFunction,
|
|
||||||
|
|
||||||
/** Batch processing */
|
|
||||||
batch: batchEmbed
|
|
||||||
}
|
|
||||||
|
|
|
||||||
165
tests/integration/bun-compile-test.ts
Normal file
165
tests/integration/bun-compile-test.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
/**
|
||||||
|
* Bun Compile Test
|
||||||
|
*
|
||||||
|
* Tests that Brainy works when compiled with `bun build --compile`.
|
||||||
|
* This verifies:
|
||||||
|
* 1. No native binaries required (pure WASM)
|
||||||
|
* 2. Model is properly bundled
|
||||||
|
* 3. Embeddings work without network access
|
||||||
|
*
|
||||||
|
* To test manually:
|
||||||
|
* bun build tests/integration/bun-compile-test.ts --compile --outfile /tmp/brainy-bun-test
|
||||||
|
* /tmp/brainy-bun-test
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Brainy } from '../../dist/index.js'
|
||||||
|
import { embeddingManager } from '../../dist/embeddings/EmbeddingManager.js'
|
||||||
|
import { WASMEmbeddingEngine } from '../../dist/embeddings/wasm/index.js'
|
||||||
|
|
||||||
|
async function testBunCompile() {
|
||||||
|
const results: { test: string; passed: boolean; error?: string }[] = []
|
||||||
|
|
||||||
|
console.log('🚀 Brainy Bun Compile Test\n')
|
||||||
|
console.log('Runtime:', typeof Bun !== 'undefined' ? `Bun ${Bun.version}` : `Node.js ${process.version}`)
|
||||||
|
console.log('')
|
||||||
|
|
||||||
|
// Test 1: WASM Engine initialization
|
||||||
|
try {
|
||||||
|
console.log('1. Testing WASM Engine initialization...')
|
||||||
|
const engine = WASMEmbeddingEngine.getInstance()
|
||||||
|
await engine.initialize()
|
||||||
|
console.log(' ✅ WASM Engine initialized')
|
||||||
|
results.push({ test: 'WASM Engine init', passed: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.log(' ❌ WASM Engine failed:', (error as Error).message)
|
||||||
|
results.push({ test: 'WASM Engine init', passed: false, error: (error as Error).message })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 2: Generate embedding
|
||||||
|
try {
|
||||||
|
console.log('2. Testing embedding generation...')
|
||||||
|
const engine = WASMEmbeddingEngine.getInstance()
|
||||||
|
const embedding = await engine.embed('Hello from Bun!')
|
||||||
|
if (embedding.length !== 384) {
|
||||||
|
throw new Error(`Expected 384 dimensions, got ${embedding.length}`)
|
||||||
|
}
|
||||||
|
console.log(` ✅ Generated ${embedding.length}-dim embedding`)
|
||||||
|
results.push({ test: 'Embedding generation', passed: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.log(' ❌ Embedding failed:', (error as Error).message)
|
||||||
|
results.push({ test: 'Embedding generation', passed: false, error: (error as Error).message })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 3: Semantic similarity
|
||||||
|
try {
|
||||||
|
console.log('3. Testing semantic similarity...')
|
||||||
|
const engine = WASMEmbeddingEngine.getInstance()
|
||||||
|
const catEmb = await engine.embed('The cat sat on the mat')
|
||||||
|
const felineEmb = await engine.embed('A feline rests on a rug')
|
||||||
|
const stockEmb = await engine.embed('Stock market crash')
|
||||||
|
|
||||||
|
const cosineSim = (a: number[], b: number[]) => {
|
||||||
|
let dot = 0
|
||||||
|
for (let i = 0; i < a.length; i++) dot += a[i] * b[i]
|
||||||
|
return dot // Already normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
const similarSim = cosineSim(catEmb, felineEmb)
|
||||||
|
const dissimilarSim = cosineSim(catEmb, stockEmb)
|
||||||
|
|
||||||
|
if (similarSim <= dissimilarSim) {
|
||||||
|
throw new Error(`Semantic similarity failed: similar=${similarSim.toFixed(4)}, dissimilar=${dissimilarSim.toFixed(4)}`)
|
||||||
|
}
|
||||||
|
console.log(` ✅ Semantic similarity works (similar: ${similarSim.toFixed(4)} > dissimilar: ${dissimilarSim.toFixed(4)})`)
|
||||||
|
results.push({ test: 'Semantic similarity', passed: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.log(' ❌ Semantic similarity failed:', (error as Error).message)
|
||||||
|
results.push({ test: 'Semantic similarity', passed: false, error: (error as Error).message })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 4: EmbeddingManager
|
||||||
|
try {
|
||||||
|
console.log('4. Testing EmbeddingManager...')
|
||||||
|
await embeddingManager.init()
|
||||||
|
const emb = await embeddingManager.embed('Test via manager')
|
||||||
|
if (emb.length !== 384) {
|
||||||
|
throw new Error(`Expected 384 dimensions, got ${emb.length}`)
|
||||||
|
}
|
||||||
|
console.log(' ✅ EmbeddingManager works')
|
||||||
|
results.push({ test: 'EmbeddingManager', passed: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.log(' ❌ EmbeddingManager failed:', (error as Error).message)
|
||||||
|
results.push({ test: 'EmbeddingManager', passed: false, error: (error as Error).message })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 5: Full Brainy initialization with fresh memory storage
|
||||||
|
try {
|
||||||
|
console.log('5. Testing Brainy initialization (in-memory)...')
|
||||||
|
const brain = new Brainy({
|
||||||
|
storage: 'memory',
|
||||||
|
storageOptions: { path: ':memory:' }
|
||||||
|
})
|
||||||
|
await brain.init()
|
||||||
|
console.log(' ✅ Brainy initialized')
|
||||||
|
results.push({ test: 'Brainy init', passed: true })
|
||||||
|
|
||||||
|
// Test 6: Add document
|
||||||
|
console.log('6. Testing document add...')
|
||||||
|
const docId = await brain.add({ data: 'Machine learning concepts', type: 'concept' })
|
||||||
|
if (!docId || typeof docId !== 'string') {
|
||||||
|
throw new Error('Document add returned no ID')
|
||||||
|
}
|
||||||
|
console.log(` ✅ Document added: ${docId}`)
|
||||||
|
results.push({ test: 'Document add', passed: true })
|
||||||
|
|
||||||
|
// Test 7: Search
|
||||||
|
console.log('7. Testing semantic search...')
|
||||||
|
const searchResults = await brain.find('AI')
|
||||||
|
console.log(` ✅ Search returned ${searchResults.length} results`)
|
||||||
|
results.push({ test: 'Semantic search', passed: true })
|
||||||
|
|
||||||
|
// Test 8: Get document
|
||||||
|
console.log('8. Testing document retrieval...')
|
||||||
|
const retrieved = await brain.get(docId)
|
||||||
|
if (!retrieved) {
|
||||||
|
throw new Error('Document not found')
|
||||||
|
}
|
||||||
|
console.log(' ✅ Document retrieved')
|
||||||
|
results.push({ test: 'Document retrieval', passed: true })
|
||||||
|
|
||||||
|
await brain.close()
|
||||||
|
} catch (error) {
|
||||||
|
console.log(' ❌ Brainy test failed:', (error as Error).message)
|
||||||
|
results.push({ test: 'Brainy operations', passed: false, error: (error as Error).message })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
console.log('\n' + '='.repeat(50))
|
||||||
|
console.log('SUMMARY')
|
||||||
|
console.log('='.repeat(50))
|
||||||
|
|
||||||
|
const passed = results.filter(r => r.passed).length
|
||||||
|
const failed = results.filter(r => !r.passed).length
|
||||||
|
|
||||||
|
for (const r of results) {
|
||||||
|
console.log(`${r.passed ? '✅' : '❌'} ${r.test}${r.error ? `: ${r.error}` : ''}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('')
|
||||||
|
console.log(`Passed: ${passed}/${results.length}`)
|
||||||
|
console.log(`Failed: ${failed}/${results.length}`)
|
||||||
|
|
||||||
|
if (failed > 0) {
|
||||||
|
console.log('\n❌ Some tests failed!')
|
||||||
|
process.exit(1)
|
||||||
|
} else {
|
||||||
|
console.log('\n✅ All tests passed! Brainy works with Bun compile.')
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run tests
|
||||||
|
testBunCompile().catch(error => {
|
||||||
|
console.error('Fatal error:', error)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
142
tests/integration/wasm-embeddings.test.ts
Normal file
142
tests/integration/wasm-embeddings.test.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
/**
|
||||||
|
* WASM Embedding Integration Test
|
||||||
|
*
|
||||||
|
* Tests the actual WASM embedding engine with real model inference.
|
||||||
|
* NO mocks - this loads the real ONNX model and generates real embeddings.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeAll } from 'vitest'
|
||||||
|
import { WASMEmbeddingEngine } from '../../src/embeddings/wasm/WASMEmbeddingEngine.js'
|
||||||
|
import { embeddingManager } from '../../src/embeddings/EmbeddingManager.js'
|
||||||
|
|
||||||
|
// Ensure we're NOT in mock mode for these tests
|
||||||
|
beforeAll(() => {
|
||||||
|
delete process.env.BRAINY_UNIT_TEST
|
||||||
|
;(globalThis as any).__BRAINY_UNIT_TEST__ = false
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('WASM Embedding Engine - Real Embeddings', () => {
|
||||||
|
it('should initialize the WASM engine', async () => {
|
||||||
|
const engine = WASMEmbeddingEngine.getInstance()
|
||||||
|
await engine.initialize()
|
||||||
|
expect(engine.isInitialized()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should generate 384-dimensional embeddings', async () => {
|
||||||
|
const engine = WASMEmbeddingEngine.getInstance()
|
||||||
|
const embedding = await engine.embed('Hello world')
|
||||||
|
|
||||||
|
expect(embedding).toBeInstanceOf(Array)
|
||||||
|
expect(embedding.length).toBe(384)
|
||||||
|
expect(typeof embedding[0]).toBe('number')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should produce consistent embeddings for same input', async () => {
|
||||||
|
const engine = WASMEmbeddingEngine.getInstance()
|
||||||
|
|
||||||
|
const emb1 = await engine.embed('test consistency')
|
||||||
|
const emb2 = await engine.embed('test consistency')
|
||||||
|
|
||||||
|
expect(emb1).toEqual(emb2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should produce normalized embeddings (unit length)', async () => {
|
||||||
|
const engine = WASMEmbeddingEngine.getInstance()
|
||||||
|
const embedding = await engine.embed('normalize test')
|
||||||
|
|
||||||
|
// Calculate L2 norm
|
||||||
|
const norm = Math.sqrt(embedding.reduce((sum, v) => sum + v * v, 0))
|
||||||
|
|
||||||
|
// Should be approximately 1.0 (normalized)
|
||||||
|
expect(norm).toBeCloseTo(1.0, 4)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should produce semantically meaningful embeddings', async () => {
|
||||||
|
const engine = WASMEmbeddingEngine.getInstance()
|
||||||
|
|
||||||
|
// Similar sentences
|
||||||
|
const catEmb = await engine.embed('The cat sat on the mat')
|
||||||
|
const felineEmb = await engine.embed('A feline rests on a rug')
|
||||||
|
|
||||||
|
// Dissimilar sentence
|
||||||
|
const stockEmb = await engine.embed('The stock market crashed today')
|
||||||
|
|
||||||
|
// Cosine similarity function
|
||||||
|
const cosineSim = (a: number[], b: number[]) => {
|
||||||
|
let dot = 0, normA = 0, normB = 0
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
dot += a[i] * b[i]
|
||||||
|
normA += a[i] * a[i]
|
||||||
|
normB += b[i] * b[i]
|
||||||
|
}
|
||||||
|
return dot / (Math.sqrt(normA) * Math.sqrt(normB))
|
||||||
|
}
|
||||||
|
|
||||||
|
const similarSim = cosineSim(catEmb, felineEmb)
|
||||||
|
const dissimilarSim = cosineSim(catEmb, stockEmb)
|
||||||
|
|
||||||
|
// Similar sentences should have higher similarity than dissimilar
|
||||||
|
expect(similarSim).toBeGreaterThan(dissimilarSim)
|
||||||
|
expect(similarSim).toBeGreaterThan(0.4) // Reasonably similar
|
||||||
|
expect(dissimilarSim).toBeLessThan(0.3) // Not very similar
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle empty string', async () => {
|
||||||
|
const engine = WASMEmbeddingEngine.getInstance()
|
||||||
|
const embedding = await engine.embed('')
|
||||||
|
|
||||||
|
expect(embedding.length).toBe(384)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle long text', async () => {
|
||||||
|
const engine = WASMEmbeddingEngine.getInstance()
|
||||||
|
const longText = 'word '.repeat(1000)
|
||||||
|
const embedding = await engine.embed(longText)
|
||||||
|
|
||||||
|
expect(embedding.length).toBe(384)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should work through EmbeddingManager', async () => {
|
||||||
|
await embeddingManager.init()
|
||||||
|
|
||||||
|
const embedding = await embeddingManager.embed('test via manager')
|
||||||
|
|
||||||
|
expect(embedding.length).toBe(384)
|
||||||
|
expect(embeddingManager.isInitialized()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should report correct stats', async () => {
|
||||||
|
const engine = WASMEmbeddingEngine.getInstance()
|
||||||
|
const stats = engine.getStats()
|
||||||
|
|
||||||
|
expect(stats.initialized).toBe(true)
|
||||||
|
expect(stats.modelName).toBe('all-MiniLM-L6-v2')
|
||||||
|
expect(stats.embedCount).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('WASM Embedding - Batch Operations', () => {
|
||||||
|
it('should embed multiple texts', async () => {
|
||||||
|
const engine = WASMEmbeddingEngine.getInstance()
|
||||||
|
|
||||||
|
const texts = [
|
||||||
|
'First document about cats',
|
||||||
|
'Second document about dogs',
|
||||||
|
'Third document about birds'
|
||||||
|
]
|
||||||
|
|
||||||
|
const embeddings = await engine.embedBatch(texts)
|
||||||
|
|
||||||
|
expect(embeddings.length).toBe(3)
|
||||||
|
expect(embeddings[0].length).toBe(384)
|
||||||
|
expect(embeddings[1].length).toBe(384)
|
||||||
|
expect(embeddings[2].length).toBe(384)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle empty batch', async () => {
|
||||||
|
const engine = WASMEmbeddingEngine.getInstance()
|
||||||
|
const embeddings = await engine.embedBatch([])
|
||||||
|
|
||||||
|
expect(embeddings).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue