mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-06 02:49:56 +00:00
* fix: add platform-aware semantic fallback Make VECTOR an optional capability so Windows analysis remains stable while semantic embeddings can fall back to exact scan when native vector indexing is unavailable. Made-with: Cursor * fix: remove stale vector pool import Keep the merge with main lint-clean after VECTOR loading moved out of the read pool.
374 lines
12 KiB
TypeScript
374 lines
12 KiB
TypeScript
/**
|
|
* Embedder Module
|
|
*
|
|
* Singleton factory for transformers.js embedding pipeline.
|
|
* Handles model loading, caching, and both single and batch embedding operations.
|
|
*
|
|
* Uses snowflake-arctic-embed-xs by default (22M params, 384 dims, ~90MB)
|
|
*/
|
|
|
|
// Suppress ONNX Runtime native warnings (e.g. VerifyEachNodeIsAssignedToAnEp)
|
|
// Must be set BEFORE onnxruntime-node is imported by transformers.js
|
|
// Level 3 = Error only (skips Warning/Info)
|
|
if (!process.env.ORT_LOG_LEVEL) {
|
|
process.env.ORT_LOG_LEVEL = '3';
|
|
}
|
|
|
|
import { pipeline, env, type FeatureExtractionPipeline } from '@huggingface/transformers';
|
|
import os from 'os';
|
|
import { existsSync } from 'fs';
|
|
import { execFileSync } from 'child_process';
|
|
import { join, dirname } from 'path';
|
|
import { createRequire } from 'module';
|
|
import { DEFAULT_EMBEDDING_CONFIG, type EmbeddingConfig, type ModelProgress } from './types.js';
|
|
import { isHttpMode, getHttpDimensions, httpEmbed } from './http-client.js';
|
|
import { resolveEmbeddingConfig } from './config.js';
|
|
|
|
/**
|
|
* Check whether the onnxruntime-node package that @huggingface/transformers
|
|
* will actually load at runtime ships the CUDA execution provider.
|
|
*
|
|
* Critical: we resolve from transformers' own module scope, NOT from ours.
|
|
* npm may install two copies — a top-level 1.24.x (our dep) and a nested
|
|
* 1.21.0 (transformers' pinned dep). The guard must inspect whichever copy
|
|
* transformers.js will dlopen, otherwise the check is meaningless.
|
|
*/
|
|
function hasOrtCudaProvider(): boolean {
|
|
try {
|
|
const require = createRequire(import.meta.url);
|
|
// Resolve from @huggingface/transformers' scope so we find the same
|
|
// onnxruntime-node binary that transformers.js will use at runtime
|
|
const transformersDir = dirname(require.resolve('@huggingface/transformers/package.json'));
|
|
const ortRequire = createRequire(join(transformersDir, 'package.json'));
|
|
const ortPath = dirname(ortRequire.resolve('onnxruntime-node/package.json'));
|
|
// ORT 1.24.x only ships CUDA binaries for linux/x64 (downloaded from NuGet
|
|
// at postinstall). arm64 will correctly return false here until ORT adds support.
|
|
const arch = process.arch;
|
|
return existsSync(
|
|
join(ortPath, 'bin', 'napi-v6', 'linux', arch, 'libonnxruntime_providers_cuda.so'),
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check whether CUDA libraries are actually available on this system.
|
|
* ONNX Runtime's native layer crashes (uncatchable) if we attempt CUDA
|
|
* without the required shared libraries, so we probe first.
|
|
*
|
|
* Checks both:
|
|
* 1. That system CUDA libraries (libcublasLt) are present
|
|
* 2. That onnxruntime-node ships the CUDA execution provider binary
|
|
*
|
|
* Both conditions must be true — system CUDA libs alone are not enough
|
|
* if onnxruntime-node is a CPU-only build (versions < 1.24.0).
|
|
*/
|
|
function isCudaAvailable(): boolean {
|
|
// First, verify onnxruntime-node has the CUDA provider binary.
|
|
// Without this, requesting CUDA causes an uncatchable native crash.
|
|
if (!hasOrtCudaProvider()) return false;
|
|
|
|
// Primary: query the dynamic linker cache — covers all architectures,
|
|
// distro layouts, and custom install paths registered with ldconfig
|
|
try {
|
|
const out = execFileSync('ldconfig', ['-p'], { timeout: 3000, encoding: 'utf-8' });
|
|
if (out.includes('libcublasLt.so.12')) return true;
|
|
} catch {
|
|
// ldconfig not available (e.g. non-standard container)
|
|
}
|
|
|
|
// Fallback: check CUDA_PATH and LD_LIBRARY_PATH for environments where
|
|
// ldconfig doesn't know about the CUDA install (conda, manual /opt/cuda, etc.)
|
|
for (const envVar of ['CUDA_PATH', 'LD_LIBRARY_PATH']) {
|
|
const val = process.env[envVar];
|
|
if (!val) continue;
|
|
for (const dir of val.split(':').filter(Boolean)) {
|
|
if (
|
|
existsSync(join(dir, 'lib64', 'libcublasLt.so.12')) ||
|
|
existsSync(join(dir, 'lib', 'libcublasLt.so.12')) ||
|
|
existsSync(join(dir, 'libcublasLt.so.12'))
|
|
)
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// Module-level state for singleton pattern
|
|
let embedderInstance: FeatureExtractionPipeline | null = null;
|
|
let isInitializing = false;
|
|
let initPromise: Promise<FeatureExtractionPipeline> | null = null;
|
|
let currentDevice: 'dml' | 'cuda' | 'cpu' | 'wasm' | null = null;
|
|
|
|
/**
|
|
* Progress callback type for model loading
|
|
*/
|
|
export type ModelProgressCallback = (progress: ModelProgress) => void;
|
|
|
|
/**
|
|
* Get the current device being used for inference
|
|
*/
|
|
export const getCurrentDevice = (): 'dml' | 'cuda' | 'cpu' | 'wasm' | null => currentDevice;
|
|
|
|
/**
|
|
* Initialize the embedding model
|
|
* Uses singleton pattern - only loads once, subsequent calls return cached instance
|
|
*
|
|
* @param onProgress - Optional callback for model download progress
|
|
* @param config - Optional configuration override
|
|
* @param forceDevice - Force a specific device
|
|
* @returns Promise resolving to the embedder pipeline
|
|
*/
|
|
export const initEmbedder = async (
|
|
onProgress?: ModelProgressCallback,
|
|
config: Partial<EmbeddingConfig> = {},
|
|
forceDevice?: 'dml' | 'cuda' | 'cpu' | 'wasm',
|
|
): Promise<FeatureExtractionPipeline> => {
|
|
if (isHttpMode()) {
|
|
throw new Error(
|
|
'initEmbedder() should not be called in HTTP mode. ' +
|
|
'Use embedText()/embedBatch() which handle HTTP transparently.',
|
|
);
|
|
}
|
|
|
|
// Return existing instance if available
|
|
if (embedderInstance) {
|
|
return embedderInstance;
|
|
}
|
|
|
|
// If already initializing, wait for that promise
|
|
if (isInitializing && initPromise) {
|
|
return initPromise;
|
|
}
|
|
|
|
isInitializing = true;
|
|
|
|
const finalConfig = resolveEmbeddingConfig(config);
|
|
// CUDA is probe-gated because ONNX Runtime can crash in native code when
|
|
// provider libraries are missing. DirectML stays opt-in for the same reason.
|
|
// Probe for CUDA first — ONNX Runtime crashes (uncatchable native error)
|
|
// if we attempt CUDA without the required shared libraries
|
|
const gpuDevice = isCudaAvailable() ? 'cuda' : 'cpu';
|
|
const requestedDevice =
|
|
forceDevice || (finalConfig.device === 'auto' ? gpuDevice : finalConfig.device);
|
|
|
|
initPromise = (async () => {
|
|
try {
|
|
// Configure transformers.js environment
|
|
env.allowLocalModels = false;
|
|
// Default cache to user-writable location. transformers.js defaults to
|
|
// ./node_modules/.cache inside its own install dir, which is unwritable
|
|
// when gitnexus is installed globally (e.g. /usr/lib/node_modules/).
|
|
// Respect HF_HOME if set, otherwise fall back to ~/.cache/huggingface.
|
|
env.cacheDir = process.env.HF_HOME ?? join(os.homedir(), '.cache', 'huggingface');
|
|
|
|
const isDev = process.env.NODE_ENV === 'development';
|
|
if (isDev) {
|
|
console.log(`🧠 Loading embedding model: ${finalConfig.modelId}`);
|
|
}
|
|
|
|
const progressCallback = onProgress
|
|
? (data: any) => {
|
|
const progress: ModelProgress = {
|
|
status: data.status || 'progress',
|
|
file: data.file,
|
|
progress: data.progress,
|
|
loaded: data.loaded,
|
|
total: data.total,
|
|
};
|
|
onProgress(progress);
|
|
}
|
|
: undefined;
|
|
|
|
// Try GPU first if auto, fall back to CPU
|
|
// Windows: dml (DirectML/DirectX12), Linux: cuda
|
|
const devicesToTry: Array<'dml' | 'cuda' | 'cpu' | 'wasm'> =
|
|
requestedDevice === 'dml' || requestedDevice === 'cuda'
|
|
? [requestedDevice, 'cpu']
|
|
: [requestedDevice as 'cpu' | 'wasm'];
|
|
|
|
for (const device of devicesToTry) {
|
|
try {
|
|
if (isDev && device === 'dml') {
|
|
console.log('🔧 Trying DirectML (DirectX12) GPU backend...');
|
|
} else if (isDev && device === 'cuda') {
|
|
console.log('🔧 Trying CUDA GPU backend...');
|
|
} else if (isDev && device === 'cpu') {
|
|
console.log('🔧 Using CPU backend...');
|
|
} else if (isDev && device === 'wasm') {
|
|
console.log('🔧 Using WASM backend (slower)...');
|
|
}
|
|
|
|
embedderInstance = await (pipeline as any)('feature-extraction', finalConfig.modelId, {
|
|
device: device,
|
|
dtype: 'fp32',
|
|
progress_callback: progressCallback,
|
|
session_options: {
|
|
logSeverityLevel: 3,
|
|
intraOpNumThreads: finalConfig.threads,
|
|
interOpNumThreads: 1,
|
|
executionMode: 'sequential',
|
|
},
|
|
});
|
|
currentDevice = device;
|
|
|
|
if (isDev) {
|
|
const label =
|
|
device === 'dml'
|
|
? 'GPU (DirectML/DirectX12)'
|
|
: device === 'cuda'
|
|
? 'GPU (CUDA)'
|
|
: device.toUpperCase();
|
|
console.log(`✅ Using ${label} backend`);
|
|
console.log('✅ Embedding model loaded successfully');
|
|
}
|
|
|
|
return embedderInstance!;
|
|
} catch (deviceError) {
|
|
if (isDev && (device === 'cuda' || device === 'dml')) {
|
|
const gpuType = device === 'dml' ? 'DirectML' : 'CUDA';
|
|
console.log(`⚠️ ${gpuType} not available, falling back to CPU...`);
|
|
}
|
|
// Continue to next device in list
|
|
if (device === devicesToTry[devicesToTry.length - 1]) {
|
|
throw deviceError; // Last device failed, propagate error
|
|
}
|
|
}
|
|
}
|
|
|
|
throw new Error('No suitable device found for embedding model');
|
|
} catch (error) {
|
|
isInitializing = false;
|
|
initPromise = null;
|
|
embedderInstance = null;
|
|
throw error;
|
|
} finally {
|
|
isInitializing = false;
|
|
}
|
|
})();
|
|
|
|
return initPromise;
|
|
};
|
|
|
|
/**
|
|
* Check if the embedder is initialized and ready
|
|
*/
|
|
export const isEmbedderReady = (): boolean => {
|
|
return isHttpMode() || embedderInstance !== null;
|
|
};
|
|
|
|
/**
|
|
* Get the effective embedding dimensions.
|
|
* In HTTP mode, uses GITNEXUS_EMBEDDING_DIMS if set, otherwise the default.
|
|
*/
|
|
export const getEmbeddingDimensions = (): number => {
|
|
if (isHttpMode()) {
|
|
return getHttpDimensions() ?? DEFAULT_EMBEDDING_CONFIG.dimensions;
|
|
}
|
|
return DEFAULT_EMBEDDING_CONFIG.dimensions;
|
|
};
|
|
|
|
/**
|
|
* Get the embedder instance (throws if not initialized)
|
|
*/
|
|
export const getEmbedder = (): FeatureExtractionPipeline => {
|
|
if (isHttpMode()) {
|
|
throw new Error(
|
|
'getEmbedder() is not available in HTTP embedding mode. Use embedText()/embedBatch() instead.',
|
|
);
|
|
}
|
|
if (!embedderInstance) {
|
|
throw new Error('Embedder not initialized. Call initEmbedder() first.');
|
|
}
|
|
return embedderInstance;
|
|
};
|
|
|
|
/**
|
|
* Embed a single text string
|
|
*
|
|
* @param text - Text to embed
|
|
* @returns Float32Array of embedding vector
|
|
*/
|
|
export const embedText = async (text: string): Promise<Float32Array> => {
|
|
if (isHttpMode()) {
|
|
const [vec] = await httpEmbed([text]);
|
|
return vec;
|
|
}
|
|
|
|
const embedder = getEmbedder();
|
|
|
|
const result = await embedder(text, {
|
|
pooling: 'mean',
|
|
normalize: true,
|
|
});
|
|
|
|
// Result is a Tensor, convert to Float32Array
|
|
return new Float32Array(result.data as ArrayLike<number>);
|
|
};
|
|
|
|
/**
|
|
* Embed multiple texts in a single batch
|
|
* More efficient than calling embedText multiple times
|
|
*
|
|
* @param texts - Array of texts to embed
|
|
* @returns Array of Float32Array embedding vectors
|
|
*/
|
|
export const embedBatch = async (texts: string[]): Promise<Float32Array[]> => {
|
|
if (texts.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
if (isHttpMode()) {
|
|
return httpEmbed(texts);
|
|
}
|
|
|
|
const embedder = getEmbedder();
|
|
|
|
// Process batch
|
|
const result = await embedder(texts, {
|
|
pooling: 'mean',
|
|
normalize: true,
|
|
});
|
|
|
|
// Result shape is [batch_size, dimensions]
|
|
// Need to split into individual vectors
|
|
const data = result.data as ArrayLike<number>;
|
|
const dimensions = DEFAULT_EMBEDDING_CONFIG.dimensions;
|
|
const embeddings: Float32Array[] = [];
|
|
|
|
for (let i = 0; i < texts.length; i++) {
|
|
const start = i * dimensions;
|
|
const end = start + dimensions;
|
|
embeddings.push(new Float32Array(Array.prototype.slice.call(data, start, end)));
|
|
}
|
|
|
|
return embeddings;
|
|
};
|
|
|
|
/**
|
|
* Convert Float32Array to regular number array (for LadybugDB storage)
|
|
*/
|
|
export const embeddingToArray = (embedding: Float32Array): number[] => {
|
|
return Array.from(embedding);
|
|
};
|
|
|
|
/**
|
|
* Cleanup the embedder (free memory)
|
|
* Call this when done with embeddings
|
|
*/
|
|
export const disposeEmbedder = async (): Promise<void> => {
|
|
if (embedderInstance) {
|
|
// transformers.js pipelines may have a dispose method
|
|
try {
|
|
if ('dispose' in embedderInstance && typeof embedderInstance.dispose === 'function') {
|
|
await embedderInstance.dispose();
|
|
}
|
|
} catch {
|
|
// Ignore disposal errors
|
|
}
|
|
embedderInstance = null;
|
|
initPromise = null;
|
|
}
|
|
};
|