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'
|
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 {
|
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.
|
|
|
|
|
*
|
|
|
|
|
* @param texts Array of strings to embed
|
|
|
|
|
* @returns Array of embedding vectors (384 dimensions each)
|
|
|
|
|
*/
|
|
|
|
|
async embedBatch(texts: string[]): Promise<number[][]> {
|
|
|
|
|
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()
|
|
|
|
|
const results = await this.engine.embedBatch(texts)
|
|
|
|
|
this.embedCount += texts.length
|
|
|
|
|
return results
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
}
|