2025-09-02 10:00:52 -07:00
|
|
|
/**
|
|
|
|
|
* Unified Embedding Manager
|
|
|
|
|
*
|
|
|
|
|
* THE single source of truth for all embedding operations in Brainy.
|
|
|
|
|
* Combines model management, precision configuration, and embedding generation
|
|
|
|
|
* into one clean, maintainable class.
|
|
|
|
|
*
|
|
|
|
|
* Features:
|
|
|
|
|
* - Singleton pattern ensures ONE model instance
|
|
|
|
|
* - Automatic Q8 (default) or FP32 precision
|
|
|
|
|
* - Model downloading and caching
|
|
|
|
|
* - Thread-safe initialization
|
|
|
|
|
* - Memory monitoring
|
|
|
|
|
*
|
|
|
|
|
* This replaces: SingletonModelManager, TransformerEmbedding, ModelPrecisionManager,
|
|
|
|
|
* hybridModelManager, universalMemoryManager, and more.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { Vector, EmbeddingFunction } from '../coreTypes.js'
|
|
|
|
|
import { pipeline, env } from '@huggingface/transformers'
|
2025-09-17 14:53:54 -07:00
|
|
|
import { isNode } from '../utils/environment.js'
|
2025-09-02 10:00:52 -07:00
|
|
|
|
|
|
|
|
// Types
|
|
|
|
|
export type ModelPrecision = 'q8' | 'fp32'
|
|
|
|
|
|
|
|
|
|
interface EmbeddingStats {
|
|
|
|
|
initialized: boolean
|
|
|
|
|
precision: ModelPrecision
|
|
|
|
|
modelName: string
|
|
|
|
|
embedCount: number
|
|
|
|
|
initTime: number | null
|
|
|
|
|
memoryMB: number | null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Global state for true singleton across entire process
|
|
|
|
|
let globalInstance: EmbeddingManager | null = null
|
|
|
|
|
let globalInitPromise: Promise<void> | null = null
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Unified Embedding Manager - Clean, simple, reliable
|
|
|
|
|
*/
|
|
|
|
|
export class EmbeddingManager {
|
|
|
|
|
private model: any = null
|
|
|
|
|
private precision: ModelPrecision
|
|
|
|
|
private modelName = 'Xenova/all-MiniLM-L6-v2'
|
|
|
|
|
private initialized = false
|
|
|
|
|
private initTime: number | null = null
|
|
|
|
|
private embedCount = 0
|
|
|
|
|
private locked = false
|
|
|
|
|
|
|
|
|
|
private constructor() {
|
2025-09-11 16:23:32 -07:00
|
|
|
// Always use Q8 for optimal size/performance (99% accuracy, 75% smaller)
|
|
|
|
|
this.precision = 'q8'
|
|
|
|
|
console.log(`🎯 EmbeddingManager: Using Q8 precision`)
|
2025-09-02 10:00:52 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get the singleton instance
|
|
|
|
|
*/
|
|
|
|
|
static getInstance(): EmbeddingManager {
|
|
|
|
|
if (!globalInstance) {
|
|
|
|
|
globalInstance = new EmbeddingManager()
|
|
|
|
|
}
|
|
|
|
|
return globalInstance
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Initialize the model (happens once)
|
|
|
|
|
*/
|
|
|
|
|
async init(): Promise<void> {
|
|
|
|
|
// In unit test mode, skip real model initialization
|
2025-09-11 16:23:32 -07:00
|
|
|
const isTestMode = process.env.BRAINY_UNIT_TEST === 'true' || (globalThis as any).__BRAINY_UNIT_TEST__
|
|
|
|
|
|
|
|
|
|
if (isTestMode) {
|
|
|
|
|
// Production safeguard: Warn if mock mode is active but NODE_ENV is production
|
|
|
|
|
if (process.env.NODE_ENV === 'production') {
|
|
|
|
|
throw new Error(
|
|
|
|
|
'CRITICAL: Mock embeddings detected in production environment! ' +
|
|
|
|
|
'BRAINY_UNIT_TEST or __BRAINY_UNIT_TEST__ is set while NODE_ENV=production. ' +
|
|
|
|
|
'This is a security risk. Remove test flags before deploying to production.'
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-02 10:00:52 -07:00
|
|
|
if (!this.initialized) {
|
|
|
|
|
this.initialized = true
|
|
|
|
|
this.initTime = 1 // Mock init time
|
|
|
|
|
console.log('🧪 EmbeddingManager: Using mocked embeddings for unit tests')
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Already initialized
|
|
|
|
|
if (this.initialized && this.model) {
|
|
|
|
|
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 embedding model (${this.precision.toUpperCase()})...`)
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// Configure transformers.js environment
|
|
|
|
|
const modelsPath = this.getModelsPath()
|
|
|
|
|
env.cacheDir = modelsPath
|
|
|
|
|
env.allowLocalModels = true
|
|
|
|
|
env.useFSCache = true
|
2025-09-17 14:53:54 -07:00
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
}
|
2025-09-02 10:00:52 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Configure pipeline options for the selected precision
|
|
|
|
|
const pipelineOptions: any = {
|
|
|
|
|
cache_dir: modelsPath,
|
|
|
|
|
local_files_only: false,
|
2025-09-11 16:23:32 -07:00
|
|
|
// Always use Q8 precision
|
|
|
|
|
dtype: 'q8',
|
|
|
|
|
quantized: true,
|
2025-09-02 10:00:52 -07:00
|
|
|
// 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
|
|
|
|
|
this.locked = true
|
|
|
|
|
this.initialized = true
|
|
|
|
|
this.initTime = Date.now() - startTime
|
|
|
|
|
|
|
|
|
|
// Log success
|
|
|
|
|
const memoryMB = this.getMemoryUsage()
|
|
|
|
|
console.log(`✅ Model loaded in ${this.initTime}ms`)
|
2025-09-11 16:23:32 -07:00
|
|
|
console.log(`📊 Precision: Q8 | Memory: ${memoryMB}MB`)
|
2025-09-02 10:00:52 -07:00
|
|
|
console.log(`🔒 Configuration locked`)
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
this.initialized = false
|
|
|
|
|
this.model = null
|
|
|
|
|
throw new Error(`Failed to initialize embedding model: ${error instanceof Error ? error.message : String(error)}`)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Generate embeddings
|
|
|
|
|
*/
|
|
|
|
|
async embed(text: string | string[]): Promise<Vector> {
|
|
|
|
|
// Check for unit test environment - use mocks to prevent ONNX conflicts
|
2025-09-11 16:23:32 -07:00
|
|
|
const isTestMode = process.env.BRAINY_UNIT_TEST === 'true' || (globalThis as any).__BRAINY_UNIT_TEST__
|
|
|
|
|
|
|
|
|
|
if (isTestMode) {
|
|
|
|
|
// Production safeguard
|
|
|
|
|
if (process.env.NODE_ENV === 'production') {
|
|
|
|
|
throw new Error('CRITICAL: Mock embeddings in production!')
|
|
|
|
|
}
|
2025-09-02 10:00:52 -07:00
|
|
|
return this.getMockEmbedding(text)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Ensure initialized
|
|
|
|
|
await this.init()
|
|
|
|
|
|
|
|
|
|
if (!this.model) {
|
|
|
|
|
throw new Error('Model not initialized')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Handle array input
|
|
|
|
|
const input = Array.isArray(text) ? text.join(' ') : text
|
|
|
|
|
|
|
|
|
|
// Generate embedding
|
|
|
|
|
const output = await this.model(input, {
|
|
|
|
|
pooling: 'mean',
|
|
|
|
|
normalize: true
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Extract embedding vector
|
|
|
|
|
const embedding = Array.from(output.data) as number[]
|
|
|
|
|
|
|
|
|
|
// Validate dimensions
|
|
|
|
|
if (embedding.length !== 384) {
|
|
|
|
|
console.warn(`Unexpected embedding dimension: ${embedding.length}`)
|
|
|
|
|
// Pad or truncate
|
|
|
|
|
if (embedding.length < 384) {
|
|
|
|
|
return [...embedding, ...new Array(384 - embedding.length).fill(0)]
|
|
|
|
|
} else {
|
|
|
|
|
return embedding.slice(0, 384)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.embedCount++
|
|
|
|
|
return embedding
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Generate mock embeddings for unit tests
|
|
|
|
|
*/
|
|
|
|
|
private getMockEmbedding(text: string | string[]): Vector {
|
|
|
|
|
// Use the same mock logic as setup-unit.ts for consistency
|
|
|
|
|
const input = Array.isArray(text) ? text.join(' ') : text
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Track mock embedding count
|
|
|
|
|
this.embedCount++
|
|
|
|
|
return vector
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get embedding function for compatibility
|
|
|
|
|
*/
|
|
|
|
|
getEmbeddingFunction(): EmbeddingFunction {
|
|
|
|
|
return async (data: string | string[]): Promise<Vector> => {
|
|
|
|
|
return await this.embed(data)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get models directory path
|
2025-09-17 14:53:54 -07:00
|
|
|
* Note: In browser environments, returns a simple default path
|
|
|
|
|
* In Node.js, checks multiple locations for the models directory
|
2025-09-02 10:00:52 -07:00
|
|
|
*/
|
|
|
|
|
private getModelsPath(): string {
|
2025-09-17 14:53:54 -07:00
|
|
|
// 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
|
|
|
|
|
}
|
2025-09-02 10:00:52 -07:00
|
|
|
}
|
2025-09-17 14:53:54 -07:00
|
|
|
|
|
|
|
|
// Default Node.js path
|
|
|
|
|
return path.join(process.cwd(), 'models')
|
|
|
|
|
} catch {
|
|
|
|
|
// Fallback if require fails
|
|
|
|
|
return defaultPath
|
2025-09-02 10:00:52 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get memory usage in MB
|
|
|
|
|
*/
|
|
|
|
|
private getMemoryUsage(): number | null {
|
|
|
|
|
if (typeof process !== 'undefined' && process.memoryUsage) {
|
|
|
|
|
const usage = process.memoryUsage()
|
|
|
|
|
return Math.round(usage.heapUsed / 1024 / 1024)
|
|
|
|
|
}
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get current statistics
|
|
|
|
|
*/
|
|
|
|
|
getStats(): EmbeddingStats {
|
|
|
|
|
return {
|
|
|
|
|
initialized: this.initialized,
|
|
|
|
|
precision: this.precision,
|
|
|
|
|
modelName: this.modelName,
|
|
|
|
|
embedCount: this.embedCount,
|
|
|
|
|
initTime: this.initTime,
|
|
|
|
|
memoryMB: this.getMemoryUsage()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check if initialized
|
|
|
|
|
*/
|
|
|
|
|
isInitialized(): boolean {
|
|
|
|
|
return this.initialized
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get current precision
|
|
|
|
|
*/
|
|
|
|
|
getPrecision(): ModelPrecision {
|
|
|
|
|
return this.precision
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Validate precision matches expected
|
|
|
|
|
*/
|
|
|
|
|
validatePrecision(expected: ModelPrecision): void {
|
|
|
|
|
if (this.locked && expected !== this.precision) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
`Precision mismatch! System using ${this.precision.toUpperCase()} ` +
|
|
|
|
|
`but ${expected.toUpperCase()} was requested. Cannot mix precisions.`
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Export singleton instance and convenience functions
|
|
|
|
|
export const embeddingManager = EmbeddingManager.getInstance()
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Direct embed function
|
|
|
|
|
*/
|
|
|
|
|
export async function embed(text: string | string[]): Promise<Vector> {
|
|
|
|
|
return await embeddingManager.embed(text)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get embedding function for compatibility
|
|
|
|
|
*/
|
|
|
|
|
export function getEmbeddingFunction(): EmbeddingFunction {
|
|
|
|
|
return embeddingManager.getEmbeddingFunction()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get statistics
|
|
|
|
|
*/
|
|
|
|
|
export function getEmbeddingStats(): EmbeddingStats {
|
|
|
|
|
return embeddingManager.getStats()
|
|
|
|
|
}
|