2025-09-02 10:00:52 -07:00
/ * *
* Unified Embedding Manager
2025-12-17 17:42:37 -08:00
*
2025-09-02 10:00:52 -07:00
* THE single source of truth for all embedding operations in Brainy .
2026-01-06 12:52:34 -08:00
* Uses Candle WASM inference for universal compatibility .
2025-12-17 17:42:37 -08:00
*
2025-09-02 10:00:52 -07:00
* Features :
* - Singleton pattern ensures ONE model instance
2026-01-06 12:52:34 -08:00
* - Candle WASM ( no transformers . js or ONNX Runtime dependency )
2025-12-17 17:42:37 -08:00
* - Bundled model ( no runtime downloads )
* - Works everywhere : Node.js , Bun , Bun -- compile , browsers
2025-09-02 10:00:52 -07:00
* - Memory monitoring
* /
import { Vector , EmbeddingFunction } from '../coreTypes.js'
2025-12-17 17:42:37 -08:00
import { WASMEmbeddingEngine } from './wasm/index.js'
feat: wire plugin system with provider resolution, storage factories, and browser deprecation
- Wire PluginRegistry into Brainy init() with provider resolution for distance,
metadataIndex, graphIndex, embeddings, roaring, msgpack, and storage adapters
- Add setupStorage() factory that resolves storage:* providers from plugins before
falling back to built-in createStorage()
- Export internals API (setGlobalCache, UnifiedCache, EntityIdMapper, etc.) for
cortex plugin consumption
- Add plugin.test.ts verifying registration, activation, and provider resolution
- Deprecate browser support (OPFS, Web Workers, WASM embeddings) with warnings
in preparation for v8.0 server-only release
- FileSystemStorage: fix setupStorage resolution for mmap-filesystem provider
2026-01-31 12:02:13 -08:00
import { isBrowser } 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
2025-12-17 17:42:37 -08:00
*
2026-01-06 12:52:34 -08:00
* Now powered by Candle WASM for universal compatibility .
2025-09-02 10:00:52 -07:00
* /
export class EmbeddingManager {
2025-12-17 17:42:37 -08:00
private engine : WASMEmbeddingEngine
private precision : ModelPrecision = 'q8'
private modelName = 'all-MiniLM-L6-v2'
2025-09-02 10:00:52 -07:00
private initialized = false
private initTime : number | null = null
private embedCount = 0
private locked = false
2025-12-17 17:42:37 -08:00
2025-09-02 10:00:52 -07:00
private constructor ( ) {
2025-12-17 17:42:37 -08:00
this . engine = WASMEmbeddingEngine . getInstance ( )
console . log ( '🎯 EmbeddingManager: Using Q8 precision (WASM)' )
2025-09-02 10:00:52 -07:00
}
2025-12-17 17:42:37 -08:00
2025-09-02 10:00:52 -07:00
/ * *
* Get the singleton instance
* /
static getInstance ( ) : EmbeddingManager {
if ( ! globalInstance ) {
globalInstance = new EmbeddingManager ( )
}
return globalInstance
}
2025-12-17 17:42:37 -08:00
2025-09-02 10:00:52 -07:00
/ * *
* Initialize the model ( happens once )
* /
async init ( ) : Promise < void > {
// In unit test mode, skip real model initialization
2025-12-17 17:42:37 -08:00
const isTestMode =
process . env . BRAINY_UNIT_TEST === 'true' ||
( globalThis as any ) . __BRAINY_UNIT_TEST__
2025-09-11 16:23:32 -07:00
if ( isTestMode ) {
2025-12-17 17:42:37 -08:00
// Production safeguard
2025-09-11 16:23:32 -07:00
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-12-17 17:42:37 -08:00
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
}
2025-12-17 17:42:37 -08:00
2025-09-02 10:00:52 -07:00
// Already initialized
2025-12-17 17:42:37 -08:00
if ( this . initialized && this . engine . isInitialized ( ) ) {
2025-09-02 10:00:52 -07:00
return
}
2025-12-17 17:42:37 -08:00
2025-09-02 10:00:52 -07:00
// Initialization in progress
if ( globalInitPromise ) {
await globalInitPromise
return
}
2025-12-17 17:42:37 -08:00
2025-09-02 10:00:52 -07:00
// Start initialization
globalInitPromise = this . performInit ( )
2025-12-17 17:42:37 -08:00
2025-09-02 10:00:52 -07:00
try {
await globalInitPromise
} finally {
globalInitPromise = null
}
}
2025-12-17 17:42:37 -08:00
2025-09-02 10:00:52 -07:00
/ * *
* Perform actual initialization
* /
private async performInit ( ) : Promise < void > {
const startTime = Date . now ( )
2025-12-17 17:42:37 -08:00
2025-09-02 10:00:52 -07:00
try {
feat: wire plugin system with provider resolution, storage factories, and browser deprecation
- Wire PluginRegistry into Brainy init() with provider resolution for distance,
metadataIndex, graphIndex, embeddings, roaring, msgpack, and storage adapters
- Add setupStorage() factory that resolves storage:* providers from plugins before
falling back to built-in createStorage()
- Export internals API (setGlobalCache, UnifiedCache, EntityIdMapper, etc.) for
cortex plugin consumption
- Add plugin.test.ts verifying registration, activation, and provider resolution
- Deprecate browser support (OPFS, Web Workers, WASM embeddings) with warnings
in preparation for v8.0 server-only release
- FileSystemStorage: fix setupStorage resolution for mmap-filesystem provider
2026-01-31 12:02:13 -08:00
if ( isBrowser ( ) ) {
console . warn ( '[brainy] Browser WASM embedding engine is deprecated and will be removed in v8.0. Use Node.js/Bun with native embeddings (@soulcraft/brainy-cortex) instead.' )
}
2025-12-17 17:42:37 -08:00
// Initialize WASM engine (handles all model loading)
await this . engine . initialize ( )
2025-09-02 10:00:52 -07:00
// Lock precision after successful initialization
this . locked = true
this . initialized = true
this . initTime = Date . now ( ) - startTime
2025-12-17 17:42:37 -08:00
2025-09-02 10:00:52 -07:00
// Log success
const memoryMB = this . getMemoryUsage ( )
2025-09-11 16:23:32 -07:00
console . log ( ` 📊 Precision: Q8 | Memory: ${ memoryMB } MB ` )
2025-12-17 17:42:37 -08:00
console . log ( '🔒 Configuration locked' )
2025-09-02 10:00:52 -07:00
} catch ( error ) {
this . initialized = false
2025-12-17 17:42:37 -08:00
throw new Error (
` Failed to initialize embedding model: ${ error instanceof Error ? error.message : String ( error ) } `
)
2025-09-02 10:00:52 -07:00
}
}
2025-12-17 17:42:37 -08:00
2025-09-02 10:00:52 -07:00
/ * *
* Generate embeddings
* /
2025-10-17 12:29:27 -07:00
async embed ( text : string | string [ ] | Record < string , unknown > ) : Promise < Vector > {
2025-12-17 17:42:37 -08:00
// Check for unit test environment
const isTestMode =
process . env . BRAINY_UNIT_TEST === 'true' ||
( globalThis as any ) . __BRAINY_UNIT_TEST__
2025-09-25 10:47:44 -07:00
2025-09-11 16:23:32 -07:00
if ( isTestMode ) {
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 )
}
2025-09-25 10:47:44 -07:00
2025-09-02 10:00:52 -07:00
// Ensure initialized
await this . init ( )
2025-09-25 10:47:44 -07:00
2025-12-17 17:42:37 -08:00
// Normalize input to string
2025-09-25 10:47:44 -07:00
let input : string
if ( Array . isArray ( text ) ) {
2025-12-17 17:42:37 -08:00
input = text . map ( ( t ) = > ( typeof t === 'string' ? t : String ( t ) ) ) . join ( ' ' )
2025-09-25 10:47:44 -07:00
} else if ( typeof text === 'string' ) {
input = text
2025-10-17 12:29:27 -07:00
} else if ( typeof text === 'object' ) {
input = JSON . stringify ( text )
2025-09-25 10:47:44 -07:00
} else {
2025-10-17 12:29:27 -07:00
console . warn ( 'EmbeddingManager.embed received unexpected input type:' , typeof text )
2025-09-25 10:47:44 -07:00
input = String ( text )
}
2025-12-17 17:42:37 -08:00
// Generate embedding using WASM engine
const embedding = await this . engine . embed ( input )
2025-09-02 10:00:52 -07:00
// Validate dimensions
if ( embedding . length !== 384 ) {
console . warn ( ` Unexpected embedding dimension: ${ embedding . length } ` )
if ( embedding . length < 384 ) {
return [ . . . embedding , . . . new Array ( 384 - embedding . length ) . fill ( 0 ) ]
} else {
return embedding . slice ( 0 , 384 )
}
}
2025-12-17 17:42:37 -08:00
2025-09-02 10:00:52 -07:00
this . embedCount ++
return embedding
}
2025-12-17 17:42:37 -08:00
2025-09-02 10:00:52 -07:00
/ * *
* Generate mock embeddings for unit tests
* /
2025-10-17 12:29:27 -07:00
private getMockEmbedding ( text : string | string [ ] | Record < string , unknown > ) : Vector {
2025-09-02 10:00:52 -07:00
const input = Array . isArray ( text ) ? text . join ( ' ' ) : text
const str = typeof input === 'string' ? input : JSON.stringify ( input )
const vector = new Array ( 384 ) . fill ( 0 )
2025-10-17 12:29:27 -07:00
2025-09-02 10:00:52 -07:00
// 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
}
2025-10-17 12:29:27 -07:00
2025-09-02 10:00:52 -07:00
// Add position-based variation
for ( let i = 0 ; i < 384 ; i ++ ) {
vector [ i ] += Math . sin ( i * 0.1 + str . length ) * 0.1
}
2025-10-17 12:29:27 -07:00
2025-09-02 10:00:52 -07:00
this . embedCount ++
return vector
}
2025-12-17 17:42:37 -08:00
feat: add structured content extraction and batch embedding optimization to highlight()
Fix highlight() hanging on structured text input by addressing 3 root causes:
1. embedBatch() now uses native WASM batch API (single forward pass instead
of N individual embed() calls via Promise.all)
2. highlight() auto-detects content type (plain text, rich-text JSON, HTML,
Markdown) and extracts meaningful text segments. Supports TipTap, Slate.js,
Lexical, Draft.js, and Quill Delta formats. New contentType hint and
contentExtractor callback for custom parsers.
3. Semantic matching phase has 10s timeout - falls back to text-only matches
instead of hanging indefinitely.
Also fixes extractTextContent() array check: uses type-based detection
(typeof data[0] === 'number') instead of length-based (data.length > 10)
so arrays of objects are properly indexed for text search.
New types: ContentType, ContentCategory, ExtractedSegment
New fields: HighlightParams.contentType, HighlightParams.contentExtractor,
Highlight.contentCategory
2026-01-27 10:27:22 -08:00
/ * *
* Batch embed multiple texts using native WASM batch API
*
* Uses the engine ' s embedBatch ( ) for a single WASM forward pass
* instead of N individual embed ( ) calls .
*
2026-01-27 16:49:26 -08:00
* Large batches ( > MICRO_BATCH_SIZE ) are split into micro - batches
* with event loop yielding between each , preventing the synchronous
* WASM call from blocking the server for hundreds of milliseconds .
*
feat: add structured content extraction and batch embedding optimization to highlight()
Fix highlight() hanging on structured text input by addressing 3 root causes:
1. embedBatch() now uses native WASM batch API (single forward pass instead
of N individual embed() calls via Promise.all)
2. highlight() auto-detects content type (plain text, rich-text JSON, HTML,
Markdown) and extracts meaningful text segments. Supports TipTap, Slate.js,
Lexical, Draft.js, and Quill Delta formats. New contentType hint and
contentExtractor callback for custom parsers.
3. Semantic matching phase has 10s timeout - falls back to text-only matches
instead of hanging indefinitely.
Also fixes extractTextContent() array check: uses type-based detection
(typeof data[0] === 'number') instead of length-based (data.length > 10)
so arrays of objects are properly indexed for text search.
New types: ContentType, ContentCategory, ExtractedSegment
New fields: HighlightParams.contentType, HighlightParams.contentExtractor,
Highlight.contentCategory
2026-01-27 10:27:22 -08:00
* @param texts Array of strings to embed
* @returns Array of embedding vectors ( 384 dimensions each )
* /
2026-01-27 18:26:37 -08:00
async embedBatch ( texts : string [ ] , options ? : { signal? : AbortSignal } ) : Promise < number [ ] [ ] > {
feat: add structured content extraction and batch embedding optimization to highlight()
Fix highlight() hanging on structured text input by addressing 3 root causes:
1. embedBatch() now uses native WASM batch API (single forward pass instead
of N individual embed() calls via Promise.all)
2. highlight() auto-detects content type (plain text, rich-text JSON, HTML,
Markdown) and extracts meaningful text segments. Supports TipTap, Slate.js,
Lexical, Draft.js, and Quill Delta formats. New contentType hint and
contentExtractor callback for custom parsers.
3. Semantic matching phase has 10s timeout - falls back to text-only matches
instead of hanging indefinitely.
Also fixes extractTextContent() array check: uses type-based detection
(typeof data[0] === 'number') instead of length-based (data.length > 10)
so arrays of objects are properly indexed for text search.
New types: ContentType, ContentCategory, ExtractedSegment
New fields: HighlightParams.contentType, HighlightParams.contentExtractor,
Highlight.contentCategory
2026-01-27 10:27:22 -08:00
if ( texts . length === 0 ) return [ ]
const isTestMode =
process . env . BRAINY_UNIT_TEST === 'true' ||
( globalThis as any ) . __BRAINY_UNIT_TEST__
if ( isTestMode ) {
if ( process . env . NODE_ENV === 'production' ) {
throw new Error ( 'CRITICAL: Mock embeddings in production!' )
}
return texts . map ( t = > this . getMockEmbedding ( t ) )
}
await this . init ( )
2026-01-27 16:49:26 -08:00
// Small batches: single WASM call (no overhead)
const MICRO_BATCH_SIZE = 20
if ( texts . length <= MICRO_BATCH_SIZE ) {
const results = await this . engine . embedBatch ( texts )
this . embedCount += texts . length
return results
}
// Large batches: micro-batch with event loop yielding
// Each micro-batch of ~20 texts blocks ~10-30ms, then yields
// so other requests (HTTP, timers, I/O) can proceed
const allResults : number [ ] [ ] = [ ]
for ( let i = 0 ; i < texts . length ; i += MICRO_BATCH_SIZE ) {
2026-01-27 18:26:37 -08:00
if ( options ? . signal ? . aborted ) {
return allResults
}
2026-01-27 16:49:26 -08:00
const batch = texts . slice ( i , i + MICRO_BATCH_SIZE )
const batchResults = await this . engine . embedBatch ( batch )
allResults . push ( . . . batchResults )
this . embedCount += batch . length
// Yield to event loop between micro-batches
if ( i + MICRO_BATCH_SIZE < texts . length ) {
await new Promise < void > ( resolve = > setTimeout ( resolve , 0 ) )
}
}
return allResults
feat: add structured content extraction and batch embedding optimization to highlight()
Fix highlight() hanging on structured text input by addressing 3 root causes:
1. embedBatch() now uses native WASM batch API (single forward pass instead
of N individual embed() calls via Promise.all)
2. highlight() auto-detects content type (plain text, rich-text JSON, HTML,
Markdown) and extracts meaningful text segments. Supports TipTap, Slate.js,
Lexical, Draft.js, and Quill Delta formats. New contentType hint and
contentExtractor callback for custom parsers.
3. Semantic matching phase has 10s timeout - falls back to text-only matches
instead of hanging indefinitely.
Also fixes extractTextContent() array check: uses type-based detection
(typeof data[0] === 'number') instead of length-based (data.length > 10)
so arrays of objects are properly indexed for text search.
New types: ContentType, ContentCategory, ExtractedSegment
New fields: HighlightParams.contentType, HighlightParams.contentExtractor,
Highlight.contentCategory
2026-01-27 10:27:22 -08:00
}
2025-09-02 10:00:52 -07:00
/ * *
* Get embedding function for compatibility
* /
getEmbeddingFunction ( ) : EmbeddingFunction {
2025-10-17 12:29:27 -07:00
return async ( data : string | string [ ] | Record < string , unknown > ) : Promise < Vector > = > {
2025-09-02 10:00:52 -07:00
return await this . embed ( data )
}
}
2025-09-17 14:53:54 -07:00
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
}
2025-12-17 17:42:37 -08:00
2025-09-02 10:00:52 -07:00
/ * *
* Get current statistics
* /
getStats ( ) : EmbeddingStats {
2025-12-17 17:42:37 -08:00
const engineStats = this . engine . getStats ( )
2025-09-02 10:00:52 -07:00
return {
initialized : this.initialized ,
precision : this.precision ,
modelName : this.modelName ,
2025-12-17 17:42:37 -08:00
embedCount : this.embedCount + engineStats . embedCount ,
2025-09-02 10:00:52 -07:00
initTime : this.initTime ,
2025-12-17 17:42:37 -08:00
memoryMB : this.getMemoryUsage ( ) ,
2025-09-02 10:00:52 -07:00
}
}
2025-12-17 17:42:37 -08:00
2025-09-02 10:00:52 -07:00
/ * *
* Check if initialized
* /
isInitialized ( ) : boolean {
return this . initialized
}
2025-12-17 17:42:37 -08:00
2025-09-02 10:00:52 -07:00
/ * *
* Get current precision
* /
getPrecision ( ) : ModelPrecision {
return this . precision
}
2025-12-17 17:42:37 -08:00
2025-09-02 10:00:52 -07:00
/ * *
* 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
* /
2025-12-17 17:42:37 -08:00
export async function embed (
text : string | string [ ] | Record < string , unknown >
) : Promise < Vector > {
2025-09-02 10:00:52 -07:00
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 ( )
2025-12-17 17:42:37 -08:00
}