@reftrixmcp/ml 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/dist/dinov2/index.d.ts +7 -0
- package/dist/dinov2/index.d.ts.map +1 -0
- package/dist/dinov2/index.js +7 -0
- package/dist/dinov2/index.js.map +1 -0
- package/dist/dinov2/service.d.ts +106 -0
- package/dist/dinov2/service.d.ts.map +1 -0
- package/dist/dinov2/service.js +446 -0
- package/dist/dinov2/service.js.map +1 -0
- package/dist/dinov2/worker-thread-types.d.ts +77 -0
- package/dist/dinov2/worker-thread-types.d.ts.map +1 -0
- package/dist/dinov2/worker-thread-types.js +4 -0
- package/dist/dinov2/worker-thread-types.js.map +1 -0
- package/dist/dinov2/worker-thread.d.ts +2 -0
- package/dist/dinov2/worker-thread.d.ts.map +1 -0
- package/dist/dinov2/worker-thread.js +231 -0
- package/dist/dinov2/worker-thread.js.map +1 -0
- package/dist/embeddings/index.d.ts +12 -0
- package/dist/embeddings/index.d.ts.map +1 -0
- package/dist/embeddings/index.js +13 -0
- package/dist/embeddings/index.js.map +1 -0
- package/dist/embeddings/multimodal-embedding.service.d.ts +400 -0
- package/dist/embeddings/multimodal-embedding.service.d.ts.map +1 -0
- package/dist/embeddings/multimodal-embedding.service.js +650 -0
- package/dist/embeddings/multimodal-embedding.service.js.map +1 -0
- package/dist/embeddings/service.d.ts +261 -0
- package/dist/embeddings/service.d.ts.map +1 -0
- package/dist/embeddings/service.js +1093 -0
- package/dist/embeddings/service.js.map +1 -0
- package/dist/embeddings/style-embedding.service.d.ts +117 -0
- package/dist/embeddings/style-embedding.service.d.ts.map +1 -0
- package/dist/embeddings/style-embedding.service.js +157 -0
- package/dist/embeddings/style-embedding.service.js.map +1 -0
- package/dist/embeddings/types.d.ts +83 -0
- package/dist/embeddings/types.d.ts.map +1 -0
- package/dist/embeddings/types.js +4 -0
- package/dist/embeddings/types.js.map +1 -0
- package/dist/embeddings/vision-embedding.service.d.ts +70 -0
- package/dist/embeddings/vision-embedding.service.d.ts.map +1 -0
- package/dist/embeddings/vision-embedding.service.js +221 -0
- package/dist/embeddings/vision-embedding.service.js.map +1 -0
- package/dist/embeddings/vision-embedding.types.d.ts +102 -0
- package/dist/embeddings/vision-embedding.types.d.ts.map +1 -0
- package/dist/embeddings/vision-embedding.types.js +4 -0
- package/dist/embeddings/vision-embedding.types.js.map +1 -0
- package/dist/embeddings/worker-thread-types.d.ts +152 -0
- package/dist/embeddings/worker-thread-types.d.ts.map +1 -0
- package/dist/embeddings/worker-thread-types.js +4 -0
- package/dist/embeddings/worker-thread-types.js.map +1 -0
- package/dist/embeddings/worker-thread.d.ts +4 -0
- package/dist/embeddings/worker-thread.d.ts.map +1 -0
- package/dist/embeddings/worker-thread.js +441 -0
- package/dist/embeddings/worker-thread.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/search/fulltext-helpers.d.ts +30 -0
- package/dist/search/fulltext-helpers.d.ts.map +1 -0
- package/dist/search/fulltext-helpers.js +40 -0
- package/dist/search/fulltext-helpers.js.map +1 -0
- package/dist/search/hybrid.d.ts +40 -0
- package/dist/search/hybrid.d.ts.map +1 -0
- package/dist/search/hybrid.js +61 -0
- package/dist/search/hybrid.js.map +1 -0
- package/dist/search/index.d.ts +12 -0
- package/dist/search/index.d.ts.map +1 -0
- package/dist/search/index.js +11 -0
- package/dist/search/index.js.map +1 -0
- package/dist/search/rrf.d.ts +69 -0
- package/dist/search/rrf.d.ts.map +1 -0
- package/dist/search/rrf.js +108 -0
- package/dist/search/rrf.js.map +1 -0
- package/dist/search/types.d.ts +130 -0
- package/dist/search/types.d.ts.map +1 -0
- package/dist/search/types.js +4 -0
- package/dist/search/types.js.map +1 -0
- package/package.json +65 -0
- package/src/dinov2/index.ts +21 -0
- package/src/dinov2/service.ts +539 -0
- package/src/dinov2/worker-thread-types.ts +103 -0
- package/src/dinov2/worker-thread.ts +306 -0
- package/src/embeddings/index.ts +74 -0
- package/src/embeddings/multimodal-embedding.service.ts +992 -0
- package/src/embeddings/service.ts +1287 -0
- package/src/embeddings/style-embedding.service.ts +201 -0
- package/src/embeddings/types.ts +95 -0
- package/src/embeddings/vision-embedding.service.ts +273 -0
- package/src/embeddings/vision-embedding.types.ts +117 -0
- package/src/embeddings/worker-thread-types.ts +194 -0
- package/src/embeddings/worker-thread.ts +529 -0
- package/src/index.ts +70 -0
- package/src/search/fulltext-helpers.ts +45 -0
- package/src/search/hybrid.ts +107 -0
- package/src/search/index.ts +25 -0
- package/src/search/rrf.ts +154 -0
- package/src/search/types.ts +141 -0
|
@@ -0,0 +1,1287 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 TKMD and Reftrix Contributors
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Embedding Service
|
|
6
|
+
*
|
|
7
|
+
* Generates embeddings using multilingual-e5-base model via Transformers.js.
|
|
8
|
+
* Supports both single and batch embedding generation with caching.
|
|
9
|
+
*
|
|
10
|
+
* v0.1.0: ONNX inference runs in a Worker Thread to prevent CPU-bound
|
|
11
|
+
* blocking of the main event loop. The main thread manages the LRU cache,
|
|
12
|
+
* E5 prefix logic, and normalization; the worker thread owns the ONNX
|
|
13
|
+
* pipeline lifecycle (init, inference, recycle, dispose).
|
|
14
|
+
*
|
|
15
|
+
* Set env EMBEDDING_WORKER_THREAD=false to disable worker thread and
|
|
16
|
+
* run inference in-process (legacy behavior, for testing or debugging).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { Worker } from "node:worker_threads";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import type { EmbeddingTextType, EmbeddingServiceConfig, CacheStats } from "./types.js";
|
|
23
|
+
import type { WorkerMessage, WorkerResponse } from "./worker-thread-types.js";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Disposable pipeline interface (used only in in-process fallback mode).
|
|
27
|
+
*
|
|
28
|
+
* The transformers.js Pipeline class exposes a dispose() method that
|
|
29
|
+
* calls model.dispose() which iterates all ONNX sessions and calls
|
|
30
|
+
* session.handler.dispose(). In onnxruntime-node, this tears down the
|
|
31
|
+
* C++ InferenceSession and frees the arena allocator's native memory.
|
|
32
|
+
*/
|
|
33
|
+
interface DisposablePipeline {
|
|
34
|
+
(
|
|
35
|
+
texts: string | string[],
|
|
36
|
+
options?: { pooling?: string; normalize?: boolean }
|
|
37
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
38
|
+
): Promise<any>;
|
|
39
|
+
dispose?: () => Promise<unknown[]>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Default maximum cache size for LRU cache
|
|
44
|
+
* Prevents unbounded memory growth
|
|
45
|
+
*/
|
|
46
|
+
export const DEFAULT_MAX_CACHE_SIZE = 5000;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Default number of inferences before recycling the ONNX pipeline.
|
|
50
|
+
*
|
|
51
|
+
* onnxruntime-node uses a native C++ arena allocator that grows monotonically
|
|
52
|
+
* during inference (~65-100MB per inference) but never returns memory to the OS.
|
|
53
|
+
* Pipeline recycling (dispose + recreate) tears down the InferenceSession via
|
|
54
|
+
* session.release(), which frees the arena's native memory back to the OS.
|
|
55
|
+
*
|
|
56
|
+
* With threshold=10, the arena is reset every 10 inferences, keeping peak
|
|
57
|
+
* additional native memory to ~650MB-1GB before each recycle. Model reload
|
|
58
|
+
* takes ~1.5-2s per recycle.
|
|
59
|
+
*
|
|
60
|
+
* v0.1.0: Reduced from 20 to 10 for universal embedding chunking.
|
|
61
|
+
* v0.1.0: Increased from 10 to 30 — chunking already limits items per chunk
|
|
62
|
+
* to 30, so threshold=30 means at most 1 recycle per chunk. With threshold=10,
|
|
63
|
+
* 3 recycles occurred per chunk, and model reload (~1.5-2s each) caused
|
|
64
|
+
* re-initialization overhead of ~45%. threshold=30 aligns with chunk size.
|
|
65
|
+
*
|
|
66
|
+
* Note: This project uses onnxruntime-node (native C++ bindings), NOT WASM.
|
|
67
|
+
* dispose() correctly frees native memory unlike WASM backing store pages.
|
|
68
|
+
*/
|
|
69
|
+
export const DEFAULT_PIPELINE_RECYCLE_THRESHOLD = 30;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Maximum number of automatic worker thread restarts after crash.
|
|
73
|
+
* After this limit is reached, subsequent inference calls will throw.
|
|
74
|
+
*/
|
|
75
|
+
const MAX_WORKER_RESTARTS = 5;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Timeout for worker thread responses (ms).
|
|
79
|
+
* Covers model loading (~15s) + inference (~5s). Set generously to avoid
|
|
80
|
+
* false timeouts on cold start or large batches.
|
|
81
|
+
*/
|
|
82
|
+
const WORKER_RESPONSE_TIMEOUT_MS = 120_000;
|
|
83
|
+
|
|
84
|
+
// Default configuration
|
|
85
|
+
const DEFAULT_CONFIG: Required<EmbeddingServiceConfig> = {
|
|
86
|
+
modelId: "Xenova/multilingual-e5-base",
|
|
87
|
+
cacheDir: process.env.MODEL_CACHE_DIR || "./.cache/models",
|
|
88
|
+
device: "cpu",
|
|
89
|
+
dtype: "fp32",
|
|
90
|
+
maxCacheSize: DEFAULT_MAX_CACHE_SIZE,
|
|
91
|
+
pipelineRecycleThreshold:
|
|
92
|
+
process.env.PIPELINE_RECYCLE_THRESHOLD !== undefined
|
|
93
|
+
? parseInt(process.env.PIPELINE_RECYCLE_THRESHOLD, 10)
|
|
94
|
+
: DEFAULT_PIPELINE_RECYCLE_THRESHOLD,
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// E5 model prefixes for query and passage
|
|
98
|
+
const E5_PREFIX = {
|
|
99
|
+
query: "query: ",
|
|
100
|
+
passage: "passage: ",
|
|
101
|
+
} as const;
|
|
102
|
+
|
|
103
|
+
// Embedding dimension for multilingual-e5-base
|
|
104
|
+
const EMBEDDING_DIMENSION = 768;
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Whether to use worker thread for ONNX inference.
|
|
108
|
+
* Disabled by env var EMBEDDING_WORKER_THREAD=false or in test environments.
|
|
109
|
+
*/
|
|
110
|
+
function isWorkerThreadEnabled(): boolean {
|
|
111
|
+
const envVal = process.env.EMBEDDING_WORKER_THREAD;
|
|
112
|
+
if (envVal === "false" || envVal === "0") return false;
|
|
113
|
+
// Disable in Vitest to avoid worker thread complications in test harness
|
|
114
|
+
if (process.env.VITEST === "true" || process.env.VITEST_WORKER_ID !== undefined) return false;
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Generate a unique request ID for worker thread message correlation.
|
|
120
|
+
*/
|
|
121
|
+
let requestIdCounter = 0;
|
|
122
|
+
function generateRequestId(): string {
|
|
123
|
+
return `req_${Date.now()}_${++requestIdCounter}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Resolve the path to the worker thread script.
|
|
128
|
+
* Uses import.meta.url for ESM compatibility.
|
|
129
|
+
*/
|
|
130
|
+
function resolveWorkerScriptPath(): string {
|
|
131
|
+
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
|
132
|
+
return path.resolve(currentDir, "worker-thread.js");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Normalize a vector to unit length (L2 normalization)
|
|
137
|
+
*/
|
|
138
|
+
function normalizeVector(vector: number[]): number[] {
|
|
139
|
+
const norm = Math.sqrt(vector.reduce((sum, val) => sum + val * val, 0));
|
|
140
|
+
if (norm === 0) return vector;
|
|
141
|
+
return vector.map((val) => val / norm);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Calculate cosine similarity between two vectors
|
|
146
|
+
*/
|
|
147
|
+
export function cosineSimilarity(a: number[], b: number[]): number {
|
|
148
|
+
if (a.length !== b.length) {
|
|
149
|
+
throw new Error("Vectors must have the same dimension");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
let dotProduct = 0;
|
|
153
|
+
let normA = 0;
|
|
154
|
+
let normB = 0;
|
|
155
|
+
|
|
156
|
+
for (let i = 0; i < a.length; i++) {
|
|
157
|
+
const aVal = a[i];
|
|
158
|
+
const bVal = b[i];
|
|
159
|
+
if (aVal === undefined || bVal === undefined) continue;
|
|
160
|
+
dotProduct += aVal * bVal;
|
|
161
|
+
normA += aVal * aVal;
|
|
162
|
+
normB += bVal * bVal;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
normA = Math.sqrt(normA);
|
|
166
|
+
normB = Math.sqrt(normB);
|
|
167
|
+
|
|
168
|
+
if (normA === 0 || normB === 0) return 0;
|
|
169
|
+
|
|
170
|
+
return dotProduct / (normA * normB);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// =====================================================
|
|
174
|
+
// Pending request tracking for worker thread
|
|
175
|
+
// =====================================================
|
|
176
|
+
|
|
177
|
+
interface PendingRequest {
|
|
178
|
+
resolve: (response: WorkerResponse) => void;
|
|
179
|
+
reject: (error: Error) => void;
|
|
180
|
+
timer: ReturnType<typeof setTimeout>;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// =====================================================
|
|
184
|
+
// EmbeddingService
|
|
185
|
+
// =====================================================
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* EmbeddingService - Generates embeddings using multilingual-e5-base
|
|
189
|
+
*
|
|
190
|
+
* v0.1.0: ONNX inference is offloaded to a Worker Thread by default.
|
|
191
|
+
* The main thread retains the LRU cache and E5 prefix logic. Only the
|
|
192
|
+
* actual ONNX pipeline.call() runs in the worker, keeping the main
|
|
193
|
+
* event loop responsive for BullMQ heartbeats and IPC messages.
|
|
194
|
+
*/
|
|
195
|
+
export class EmbeddingService {
|
|
196
|
+
private config: Required<EmbeddingServiceConfig>;
|
|
197
|
+
private cache: Map<string, number[]> = new Map();
|
|
198
|
+
private cacheHits = 0;
|
|
199
|
+
private cacheMisses = 0;
|
|
200
|
+
private cacheEvictions = 0;
|
|
201
|
+
|
|
202
|
+
// --- Worker Thread state ---
|
|
203
|
+
private useWorkerThread: boolean;
|
|
204
|
+
private worker: Worker | null = null;
|
|
205
|
+
private workerInitPromise: Promise<void> | null = null;
|
|
206
|
+
private workerReady = false;
|
|
207
|
+
private workerRestartCount = 0;
|
|
208
|
+
private pendingRequests: Map<string, PendingRequest> = new Map();
|
|
209
|
+
/** Timestamp of last worker crash, used to enforce cooldown between restarts. */
|
|
210
|
+
private lastCrashTime = 0;
|
|
211
|
+
|
|
212
|
+
// --- Provider tracking ---
|
|
213
|
+
private currentProvider: "cpu" | "cuda" = "cpu";
|
|
214
|
+
|
|
215
|
+
// --- Idle timer for automatic VRAM release ---
|
|
216
|
+
private idleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
217
|
+
private idleTimeoutMs: number = 0;
|
|
218
|
+
|
|
219
|
+
// --- In-process fallback state (legacy) ---
|
|
220
|
+
private pipeline: DisposablePipeline | null = null;
|
|
221
|
+
private initPromise: Promise<void> | null = null;
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Number of inference calls since the last pipeline recycle.
|
|
225
|
+
* In worker-thread mode, the worker manages its own counter.
|
|
226
|
+
* In fallback mode, this counter is used directly.
|
|
227
|
+
*/
|
|
228
|
+
private inferencesSinceRecycle = 0;
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Total number of pipeline recycles performed during this service's lifetime.
|
|
232
|
+
* In worker-thread mode, the count is approximate (tracked by dispose calls).
|
|
233
|
+
*/
|
|
234
|
+
private totalRecycles = 0;
|
|
235
|
+
|
|
236
|
+
constructor(config: EmbeddingServiceConfig = {}) {
|
|
237
|
+
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
238
|
+
|
|
239
|
+
// Apply ONNX_EXECUTION_PROVIDER env var if device not explicitly set
|
|
240
|
+
if (!config.device) {
|
|
241
|
+
const envProvider = process.env.ONNX_EXECUTION_PROVIDER;
|
|
242
|
+
if (envProvider === "cuda" || envProvider === "rocm") {
|
|
243
|
+
this.config.device = "cuda";
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
this.useWorkerThread = isWorkerThreadEnabled();
|
|
248
|
+
this.currentProvider = this.config.device === "cuda" ? "cuda" : "cpu";
|
|
249
|
+
|
|
250
|
+
if (process.env.NODE_ENV === "development") {
|
|
251
|
+
// eslint-disable-next-line no-console
|
|
252
|
+
console.log("[ML] EmbeddingService created with config:", {
|
|
253
|
+
modelId: this.config.modelId,
|
|
254
|
+
device: this.config.device,
|
|
255
|
+
dtype: this.config.dtype,
|
|
256
|
+
maxCacheSize: this.config.maxCacheSize,
|
|
257
|
+
pipelineRecycleThreshold: this.config.pipelineRecycleThreshold,
|
|
258
|
+
workerThread: this.useWorkerThread,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// =====================================================
|
|
264
|
+
// Worker Thread management
|
|
265
|
+
// =====================================================
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Spawn the worker thread and send init message.
|
|
269
|
+
* Enforces a cooldown after crashes to prevent rapid spawn-crash-spawn cycles
|
|
270
|
+
* that consume memory (~10GB per CUDA init attempt).
|
|
271
|
+
*/
|
|
272
|
+
private async ensureWorkerReady(): Promise<void> {
|
|
273
|
+
if (this.workerReady && this.worker) return;
|
|
274
|
+
if (this.workerInitPromise) return this.workerInitPromise;
|
|
275
|
+
|
|
276
|
+
// Cooldown: wait at least 3s after a crash before attempting restart.
|
|
277
|
+
// This prevents rapid spawn-crash cycles from consuming all system memory.
|
|
278
|
+
if (this.lastCrashTime > 0) {
|
|
279
|
+
const CRASH_COOLDOWN_MS = 3_000;
|
|
280
|
+
const elapsed = Date.now() - this.lastCrashTime;
|
|
281
|
+
if (elapsed < CRASH_COOLDOWN_MS) {
|
|
282
|
+
const waitMs = CRASH_COOLDOWN_MS - elapsed;
|
|
283
|
+
if (process.env.NODE_ENV === "development") {
|
|
284
|
+
// eslint-disable-next-line no-console
|
|
285
|
+
console.log("[ML] Waiting %dms before Worker Thread restart (crash cooldown)", waitMs);
|
|
286
|
+
}
|
|
287
|
+
await new Promise<void>((resolve) => setTimeout(resolve, waitMs));
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
this.workerInitPromise = this.spawnAndInitWorker();
|
|
292
|
+
return this.workerInitPromise;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private async spawnAndInitWorker(): Promise<void> {
|
|
296
|
+
try {
|
|
297
|
+
const scriptPath = resolveWorkerScriptPath();
|
|
298
|
+
|
|
299
|
+
if (process.env.NODE_ENV === "development") {
|
|
300
|
+
// eslint-disable-next-line no-console
|
|
301
|
+
console.log("[ML] Spawning ONNX Worker Thread:", scriptPath);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Worker threads inherit the parent's execArgv by default.
|
|
305
|
+
// Node.js >=20.x rejects --max-old-space-size and --expose-gc in
|
|
306
|
+
// worker_threads Workers, so we must explicitly pass an empty array
|
|
307
|
+
// to prevent the parent's flags (set by WorkerSupervisor fork())
|
|
308
|
+
// from propagating.
|
|
309
|
+
this.worker = new Worker(scriptPath, { execArgv: [] });
|
|
310
|
+
|
|
311
|
+
// Handle worker messages
|
|
312
|
+
this.worker.on("message", (response: WorkerResponse) => {
|
|
313
|
+
this.handleWorkerResponse(response);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
// Handle worker errors
|
|
317
|
+
this.worker.on("error", (error: Error) => {
|
|
318
|
+
if (process.env.NODE_ENV === "development") {
|
|
319
|
+
console.warn("[ML] Worker Thread error:", error.message);
|
|
320
|
+
}
|
|
321
|
+
this.handleWorkerCrash(error);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
// Handle worker exit
|
|
325
|
+
this.worker.on("exit", (code: number) => {
|
|
326
|
+
if (code !== 0) {
|
|
327
|
+
if (process.env.NODE_ENV === "development") {
|
|
328
|
+
console.warn("[ML] Worker Thread exited with code:", code);
|
|
329
|
+
}
|
|
330
|
+
this.handleWorkerCrash(new Error(`Worker thread exited with code ${code}`));
|
|
331
|
+
}
|
|
332
|
+
this.workerReady = false;
|
|
333
|
+
this.worker = null;
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
// Send init message and wait for response
|
|
337
|
+
const response = await this.sendWorkerMessage({
|
|
338
|
+
type: "init",
|
|
339
|
+
requestId: generateRequestId(),
|
|
340
|
+
config: {
|
|
341
|
+
modelId: this.config.modelId,
|
|
342
|
+
cacheDir: this.config.cacheDir,
|
|
343
|
+
device: this.config.device,
|
|
344
|
+
dtype: this.config.dtype,
|
|
345
|
+
pipelineRecycleThreshold: this.config.pipelineRecycleThreshold,
|
|
346
|
+
},
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
if (response.type === "error") {
|
|
350
|
+
throw new Error(`Worker init failed: ${response.error}`);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
this.workerReady = true;
|
|
354
|
+
|
|
355
|
+
if (response.type === "init") {
|
|
356
|
+
this.currentProvider = response.executionProvider === "cuda" ? "cuda" : "cpu";
|
|
357
|
+
// eslint-disable-next-line no-console
|
|
358
|
+
console.log(
|
|
359
|
+
"[ML] Worker Thread initialized in %dms (provider: %s)",
|
|
360
|
+
response.loadTimeMs,
|
|
361
|
+
response.executionProvider
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
} catch (error) {
|
|
365
|
+
this.workerInitPromise = null;
|
|
366
|
+
this.workerReady = false;
|
|
367
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
368
|
+
throw new Error(`Failed to initialize ONNX Worker Thread: ${message}`);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Send a message to the worker and return a Promise for the response.
|
|
374
|
+
*/
|
|
375
|
+
private sendWorkerMessage(message: WorkerMessage): Promise<WorkerResponse> {
|
|
376
|
+
if (!this.worker) {
|
|
377
|
+
return Promise.reject(new Error("Worker thread not available"));
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return new Promise<WorkerResponse>((resolve, reject) => {
|
|
381
|
+
const timer = setTimeout(() => {
|
|
382
|
+
this.pendingRequests.delete(message.requestId);
|
|
383
|
+
reject(
|
|
384
|
+
new Error(
|
|
385
|
+
`Worker thread response timeout (${WORKER_RESPONSE_TIMEOUT_MS}ms) for ${message.type}`
|
|
386
|
+
)
|
|
387
|
+
);
|
|
388
|
+
}, WORKER_RESPONSE_TIMEOUT_MS);
|
|
389
|
+
|
|
390
|
+
this.pendingRequests.set(message.requestId, { resolve, reject, timer });
|
|
391
|
+
this.worker!.postMessage(message);
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Route a response from the worker to the pending request's promise.
|
|
397
|
+
*/
|
|
398
|
+
private handleWorkerResponse(response: WorkerResponse): void {
|
|
399
|
+
const pending = this.pendingRequests.get(response.requestId);
|
|
400
|
+
if (!pending) return;
|
|
401
|
+
|
|
402
|
+
clearTimeout(pending.timer);
|
|
403
|
+
this.pendingRequests.delete(response.requestId);
|
|
404
|
+
pending.resolve(response);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Handle worker thread crash: reject all pending requests, attempt restart.
|
|
409
|
+
*/
|
|
410
|
+
private handleWorkerCrash(error: Error): void {
|
|
411
|
+
// Reject all pending requests
|
|
412
|
+
for (const [id, pending] of this.pendingRequests) {
|
|
413
|
+
clearTimeout(pending.timer);
|
|
414
|
+
pending.reject(new Error(`Worker thread crashed: ${error.message}`));
|
|
415
|
+
this.pendingRequests.delete(id);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
this.workerReady = false;
|
|
419
|
+
this.worker = null;
|
|
420
|
+
this.workerInitPromise = null;
|
|
421
|
+
this.workerRestartCount++;
|
|
422
|
+
this.lastCrashTime = Date.now();
|
|
423
|
+
|
|
424
|
+
if (process.env.NODE_ENV === "development") {
|
|
425
|
+
// eslint-disable-next-line no-console
|
|
426
|
+
console.log("[ML] Worker Thread crash, restart count:", this.workerRestartCount);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Check if the worker can be restarted (under max restart limit).
|
|
432
|
+
*/
|
|
433
|
+
private canRestartWorker(): boolean {
|
|
434
|
+
return this.workerRestartCount < MAX_WORKER_RESTARTS;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// =====================================================
|
|
438
|
+
// In-process fallback (legacy mode)
|
|
439
|
+
// =====================================================
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Initialize the in-process embedding pipeline (lazy loading).
|
|
443
|
+
* Used when worker thread is disabled.
|
|
444
|
+
*
|
|
445
|
+
* If the configured device is 'cuda' but initialization fails (e.g. because
|
|
446
|
+
* LD_LIBRARY_PATH was not set at the OS level), automatically falls back to CPU.
|
|
447
|
+
*/
|
|
448
|
+
private async initializeInProcess(): Promise<void> {
|
|
449
|
+
if (this.pipeline) return;
|
|
450
|
+
if (this.initPromise) return this.initPromise;
|
|
451
|
+
|
|
452
|
+
this.initPromise = (async (): Promise<void> => {
|
|
453
|
+
try {
|
|
454
|
+
if (process.env.NODE_ENV === "development") {
|
|
455
|
+
// eslint-disable-next-line no-console
|
|
456
|
+
console.log(
|
|
457
|
+
"[ML] Loading embedding model (in-process):",
|
|
458
|
+
this.config.modelId,
|
|
459
|
+
"(device:",
|
|
460
|
+
this.config.device + ")"
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const startTime = Date.now();
|
|
465
|
+
const { pipeline } = await import("@huggingface/transformers");
|
|
466
|
+
|
|
467
|
+
try {
|
|
468
|
+
this.pipeline = (await pipeline("feature-extraction", this.config.modelId, {
|
|
469
|
+
dtype: this.config.dtype,
|
|
470
|
+
device: this.config.device,
|
|
471
|
+
})) as unknown as DisposablePipeline;
|
|
472
|
+
} catch (deviceError) {
|
|
473
|
+
if (this.config.device !== "cpu") {
|
|
474
|
+
const msg = deviceError instanceof Error ? deviceError.message : String(deviceError);
|
|
475
|
+
console.warn(
|
|
476
|
+
"[ML] %s pipeline creation failed, falling back to CPU: %s",
|
|
477
|
+
this.config.device,
|
|
478
|
+
msg
|
|
479
|
+
);
|
|
480
|
+
this.config = { ...this.config, device: "cpu" };
|
|
481
|
+
this.currentProvider = "cpu";
|
|
482
|
+
|
|
483
|
+
this.pipeline = (await pipeline("feature-extraction", this.config.modelId, {
|
|
484
|
+
dtype: this.config.dtype,
|
|
485
|
+
device: "cpu",
|
|
486
|
+
})) as unknown as DisposablePipeline;
|
|
487
|
+
} else {
|
|
488
|
+
throw deviceError;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const loadTime = Date.now() - startTime;
|
|
493
|
+
|
|
494
|
+
if (process.env.NODE_ENV === "development") {
|
|
495
|
+
// eslint-disable-next-line no-console
|
|
496
|
+
console.log(
|
|
497
|
+
"[ML] Model loaded in-process in",
|
|
498
|
+
loadTime,
|
|
499
|
+
"ms (provider:",
|
|
500
|
+
this.currentProvider + ")"
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
} catch (error) {
|
|
504
|
+
this.initPromise = null;
|
|
505
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
506
|
+
throw new Error(`Failed to load embedding model: ${message}`);
|
|
507
|
+
}
|
|
508
|
+
})();
|
|
509
|
+
|
|
510
|
+
return this.initPromise;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Recycle the in-process pipeline if threshold reached.
|
|
515
|
+
*/
|
|
516
|
+
private async recyclePipelineIfNeeded(inferenceCount: number): Promise<void> {
|
|
517
|
+
this.inferencesSinceRecycle += inferenceCount;
|
|
518
|
+
|
|
519
|
+
const threshold = this.config.pipelineRecycleThreshold;
|
|
520
|
+
if (threshold <= 0) return;
|
|
521
|
+
|
|
522
|
+
if (this.inferencesSinceRecycle >= threshold) {
|
|
523
|
+
if (process.env.NODE_ENV === "development") {
|
|
524
|
+
// eslint-disable-next-line no-console
|
|
525
|
+
console.log("[ML] Pipeline recycle threshold reached", {
|
|
526
|
+
inferencesSinceRecycle: this.inferencesSinceRecycle,
|
|
527
|
+
threshold,
|
|
528
|
+
totalRecycles: this.totalRecycles,
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
await this.disposeInProcess();
|
|
533
|
+
this.totalRecycles++;
|
|
534
|
+
|
|
535
|
+
if (typeof global.gc === "function") {
|
|
536
|
+
global.gc();
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (process.env.NODE_ENV === "development") {
|
|
540
|
+
// eslint-disable-next-line no-console
|
|
541
|
+
console.log("[ML] Pipeline recycled successfully", {
|
|
542
|
+
totalRecycles: this.totalRecycles,
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Dispose the in-process pipeline.
|
|
550
|
+
*/
|
|
551
|
+
private async disposeInProcess(): Promise<void> {
|
|
552
|
+
if (this.pipeline && typeof this.pipeline.dispose === "function") {
|
|
553
|
+
try {
|
|
554
|
+
await this.pipeline.dispose();
|
|
555
|
+
} catch {
|
|
556
|
+
// Disposal failure should not break the workflow
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
this.pipeline = null;
|
|
560
|
+
this.initPromise = null;
|
|
561
|
+
this.inferencesSinceRecycle = 0;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Generate a single embedding in-process (fallback).
|
|
566
|
+
*/
|
|
567
|
+
private async generateInProcess(prefixedText: string): Promise<number[]> {
|
|
568
|
+
await this.initializeInProcess();
|
|
569
|
+
if (!this.pipeline) throw new Error("Embedding pipeline not initialized");
|
|
570
|
+
|
|
571
|
+
const output = await this.pipeline(prefixedText, { pooling: "mean", normalize: true });
|
|
572
|
+
|
|
573
|
+
let embedding: number[];
|
|
574
|
+
if (output && typeof output.tolist === "function") {
|
|
575
|
+
const result = output.tolist();
|
|
576
|
+
embedding = Array.isArray(result[0]) ? result[0] : result;
|
|
577
|
+
if (typeof output.dispose === "function") {
|
|
578
|
+
output.dispose();
|
|
579
|
+
}
|
|
580
|
+
} else if (Array.isArray(output)) {
|
|
581
|
+
embedding = output;
|
|
582
|
+
} else {
|
|
583
|
+
throw new Error("Unexpected embedding output format");
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
if (embedding.length !== EMBEDDING_DIMENSION) {
|
|
587
|
+
console.warn(
|
|
588
|
+
`[ML] Warning: Expected ${EMBEDDING_DIMENSION} dimensions, got ${embedding.length}`
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
await this.recyclePipelineIfNeeded(1);
|
|
593
|
+
return normalizeVector(embedding);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Generate batch embeddings in-process (fallback).
|
|
598
|
+
*/
|
|
599
|
+
private async generateBatchInProcess(prefixedTexts: string[]): Promise<number[][]> {
|
|
600
|
+
const BATCH_SIZE = 32;
|
|
601
|
+
const allEmbeddings: number[][] = [];
|
|
602
|
+
|
|
603
|
+
for (let batchStart = 0; batchStart < prefixedTexts.length; batchStart += BATCH_SIZE) {
|
|
604
|
+
const batchEnd = Math.min(batchStart + BATCH_SIZE, prefixedTexts.length);
|
|
605
|
+
const batch = prefixedTexts.slice(batchStart, batchEnd);
|
|
606
|
+
|
|
607
|
+
if (!this.pipeline) {
|
|
608
|
+
await this.initializeInProcess();
|
|
609
|
+
if (!this.pipeline) throw new Error("Embedding pipeline not initialized after recycle");
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const output = await this.pipeline(batch, { pooling: "mean", normalize: true });
|
|
613
|
+
|
|
614
|
+
let batchEmbeddings: number[][];
|
|
615
|
+
if (output && typeof output.tolist === "function") {
|
|
616
|
+
batchEmbeddings = output.tolist();
|
|
617
|
+
if (typeof output.dispose === "function") {
|
|
618
|
+
output.dispose();
|
|
619
|
+
}
|
|
620
|
+
} else if (Array.isArray(output)) {
|
|
621
|
+
batchEmbeddings = output;
|
|
622
|
+
} else {
|
|
623
|
+
throw new Error("Unexpected batch embedding output format");
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
for (const emb of batchEmbeddings) {
|
|
627
|
+
allEmbeddings.push(normalizeVector(emb));
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
await this.recyclePipelineIfNeeded(batch.length);
|
|
631
|
+
|
|
632
|
+
// Yield between batches
|
|
633
|
+
if (batchEnd < prefixedTexts.length) {
|
|
634
|
+
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
return allEmbeddings;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// =====================================================
|
|
642
|
+
// Idle timer for automatic VRAM release
|
|
643
|
+
// =====================================================
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Set the idle timeout for automatic VRAM release.
|
|
647
|
+
*
|
|
648
|
+
* When set to a positive value (ms), the service will automatically call
|
|
649
|
+
* dispose() after no embedding generation activity for the specified duration.
|
|
650
|
+
* This frees GPU VRAM (e.g. 1,406 MiB for multilingual-e5-base on CUDA)
|
|
651
|
+
* so that other GPU consumers (e.g. Ollama Vision at 11.7 GB) can use it.
|
|
652
|
+
*
|
|
653
|
+
* dispose() preserves the CUDA provider setting, so the next inference call
|
|
654
|
+
* will re-initialize with CUDA automatically (unlike releaseGpu() which
|
|
655
|
+
* forces CPU).
|
|
656
|
+
*
|
|
657
|
+
* @param ms - Timeout in milliseconds. 0 disables the idle timer.
|
|
658
|
+
*/
|
|
659
|
+
setIdleTimeout(ms: number): void {
|
|
660
|
+
this.idleTimeoutMs = ms;
|
|
661
|
+
// Clear any existing timer when reconfiguring
|
|
662
|
+
if (this.idleTimer !== null) {
|
|
663
|
+
clearTimeout(this.idleTimer);
|
|
664
|
+
this.idleTimer = null;
|
|
665
|
+
}
|
|
666
|
+
if (process.env.NODE_ENV === "development") {
|
|
667
|
+
// eslint-disable-next-line no-console
|
|
668
|
+
console.log("[ML] Idle timeout set to %dms (0 = disabled)", ms);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* Reset the idle timer after an embedding generation completes.
|
|
674
|
+
* Called in finally blocks of generateEmbedding() and generateBatchEmbeddings().
|
|
675
|
+
*/
|
|
676
|
+
private resetIdleTimer(): void {
|
|
677
|
+
if (this.idleTimeoutMs <= 0) return;
|
|
678
|
+
|
|
679
|
+
if (this.idleTimer !== null) {
|
|
680
|
+
clearTimeout(this.idleTimer);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
this.idleTimer = setTimeout(() => {
|
|
684
|
+
this.idleTimer = null;
|
|
685
|
+
if (process.env.NODE_ENV === "development") {
|
|
686
|
+
// eslint-disable-next-line no-console
|
|
687
|
+
console.log(
|
|
688
|
+
"[ML] Idle timeout reached (%dms), disposing ONNX pipeline to free VRAM",
|
|
689
|
+
this.idleTimeoutMs
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
// Fire-and-forget: dispose() is safe to call and errors are caught internally
|
|
693
|
+
void this.dispose().catch((err) => {
|
|
694
|
+
console.warn(
|
|
695
|
+
"[ML] Idle timer dispose failed:",
|
|
696
|
+
err instanceof Error ? err.message : String(err)
|
|
697
|
+
);
|
|
698
|
+
});
|
|
699
|
+
}, this.idleTimeoutMs);
|
|
700
|
+
|
|
701
|
+
// Ensure the timer does not prevent Node.js process from exiting
|
|
702
|
+
if (this.idleTimer && typeof this.idleTimer === "object" && "unref" in this.idleTimer) {
|
|
703
|
+
this.idleTimer.unref();
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// =====================================================
|
|
708
|
+
// Public API
|
|
709
|
+
// =====================================================
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* Check if the model is initialized (worker thread is ready or pipeline loaded).
|
|
713
|
+
*/
|
|
714
|
+
isInitialized(): boolean {
|
|
715
|
+
if (this.useWorkerThread) {
|
|
716
|
+
return this.workerReady;
|
|
717
|
+
}
|
|
718
|
+
return this.pipeline !== null;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* Get the total number of pipeline recycles performed.
|
|
723
|
+
*/
|
|
724
|
+
getRecycleCount(): number {
|
|
725
|
+
return this.totalRecycles;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* Get the number of inferences since the last pipeline recycle.
|
|
730
|
+
* In worker-thread mode this is approximate (tracked via dispose calls).
|
|
731
|
+
*/
|
|
732
|
+
getInferencesSinceRecycle(): number {
|
|
733
|
+
return this.inferencesSinceRecycle;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* Generate cache key for embedding
|
|
738
|
+
*/
|
|
739
|
+
private getCacheKey(text: string, type: EmbeddingTextType): string {
|
|
740
|
+
return `${type}:${text}`;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* Set cache entry with LRU eviction
|
|
745
|
+
*/
|
|
746
|
+
private setCacheEntry(key: string, value: number[]): void {
|
|
747
|
+
while (this.cache.size >= this.config.maxCacheSize) {
|
|
748
|
+
const oldestKey = this.cache.keys().next().value;
|
|
749
|
+
if (oldestKey !== undefined) {
|
|
750
|
+
this.cache.delete(oldestKey);
|
|
751
|
+
this.cacheEvictions++;
|
|
752
|
+
if (process.env.NODE_ENV === "development") {
|
|
753
|
+
// eslint-disable-next-line no-console
|
|
754
|
+
console.log("[ML] Cache evicted oldest entry, size:", this.cache.size);
|
|
755
|
+
}
|
|
756
|
+
} else {
|
|
757
|
+
break;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
this.cache.set(key, value);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* Get cache entry with LRU update (move to end)
|
|
765
|
+
*/
|
|
766
|
+
private getCacheEntry(key: string): number[] | undefined {
|
|
767
|
+
const value = this.cache.get(key);
|
|
768
|
+
if (value !== undefined) {
|
|
769
|
+
this.cache.delete(key);
|
|
770
|
+
this.cache.set(key, value);
|
|
771
|
+
}
|
|
772
|
+
return value;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* Generate embedding for a single text
|
|
777
|
+
*
|
|
778
|
+
* @param text - Input text to embed
|
|
779
|
+
* @param type - Type of text ('query' or 'passage')
|
|
780
|
+
* @returns 768-dimensional normalized embedding vector
|
|
781
|
+
*/
|
|
782
|
+
async generateEmbedding(text: string, type: EmbeddingTextType): Promise<number[]> {
|
|
783
|
+
// Check cache first (with LRU update)
|
|
784
|
+
const cacheKey = this.getCacheKey(text, type);
|
|
785
|
+
const cached = this.getCacheEntry(cacheKey);
|
|
786
|
+
if (cached) {
|
|
787
|
+
this.cacheHits++;
|
|
788
|
+
if (process.env.NODE_ENV === "development") {
|
|
789
|
+
// eslint-disable-next-line no-console
|
|
790
|
+
console.log("[ML] Cache hit for:", text.substring(0, 30));
|
|
791
|
+
}
|
|
792
|
+
// Cache hits also reset the idle timer (service is still "active")
|
|
793
|
+
this.resetIdleTimer();
|
|
794
|
+
return cached;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
this.cacheMisses++;
|
|
798
|
+
|
|
799
|
+
const startTime = Date.now();
|
|
800
|
+
const prefixedText = E5_PREFIX[type] + text;
|
|
801
|
+
|
|
802
|
+
if (process.env.NODE_ENV === "development") {
|
|
803
|
+
// eslint-disable-next-line no-console
|
|
804
|
+
console.log("[ML] Generating embedding for:", prefixedText.substring(0, 50));
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
try {
|
|
808
|
+
let embedding: number[];
|
|
809
|
+
|
|
810
|
+
if (this.useWorkerThread) {
|
|
811
|
+
embedding = await this.generateViaWorker(prefixedText);
|
|
812
|
+
} else {
|
|
813
|
+
embedding = await this.generateInProcess(prefixedText);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// Ensure proper dimension
|
|
817
|
+
if (embedding.length !== EMBEDDING_DIMENSION) {
|
|
818
|
+
console.warn(
|
|
819
|
+
`[ML] Warning: Expected ${EMBEDDING_DIMENSION} dimensions, got ${embedding.length}`
|
|
820
|
+
);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// Cache the result
|
|
824
|
+
this.setCacheEntry(cacheKey, embedding);
|
|
825
|
+
|
|
826
|
+
const elapsedMs = Date.now() - startTime;
|
|
827
|
+
if (process.env.NODE_ENV === "development") {
|
|
828
|
+
// eslint-disable-next-line no-console
|
|
829
|
+
console.log("[ML] Embedding generated in", elapsedMs, "ms");
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
return embedding;
|
|
833
|
+
} finally {
|
|
834
|
+
this.resetIdleTimer();
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* Generate embedding via Worker Thread, with crash recovery.
|
|
840
|
+
*/
|
|
841
|
+
private async generateViaWorker(prefixedText: string): Promise<number[]> {
|
|
842
|
+
// Ensure worker is running (spawn + init if needed, or restart after crash)
|
|
843
|
+
if (!this.workerReady || !this.worker) {
|
|
844
|
+
if (!this.canRestartWorker() && this.workerRestartCount > 0) {
|
|
845
|
+
throw new Error(`Worker thread exceeded max restarts (${MAX_WORKER_RESTARTS})`);
|
|
846
|
+
}
|
|
847
|
+
await this.ensureWorkerReady();
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
const response = await this.sendWorkerMessage({
|
|
851
|
+
type: "generate",
|
|
852
|
+
requestId: generateRequestId(),
|
|
853
|
+
text: prefixedText,
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
if (response.type === "error") {
|
|
857
|
+
throw new Error(`Worker inference failed: ${response.error}`);
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
if (response.type !== "generate") {
|
|
861
|
+
throw new Error(`Unexpected worker response type: ${response.type}`);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
return response.embedding;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/**
|
|
868
|
+
* Generate batch embeddings via Worker Thread, with crash recovery.
|
|
869
|
+
*/
|
|
870
|
+
private async generateBatchViaWorker(prefixedTexts: string[]): Promise<number[][]> {
|
|
871
|
+
if (!this.workerReady || !this.worker) {
|
|
872
|
+
if (!this.canRestartWorker() && this.workerRestartCount > 0) {
|
|
873
|
+
throw new Error(`Worker thread exceeded max restarts (${MAX_WORKER_RESTARTS})`);
|
|
874
|
+
}
|
|
875
|
+
await this.ensureWorkerReady();
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
const response = await this.sendWorkerMessage({
|
|
879
|
+
type: "generateBatch",
|
|
880
|
+
requestId: generateRequestId(),
|
|
881
|
+
texts: prefixedTexts,
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
if (response.type === "error") {
|
|
885
|
+
throw new Error(`Worker batch inference failed: ${response.error}`);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
if (response.type !== "generateBatch") {
|
|
889
|
+
throw new Error(`Unexpected worker response type: ${response.type}`);
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
return response.embeddings;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* Generate embeddings for multiple texts
|
|
897
|
+
*
|
|
898
|
+
* @param texts - Array of input texts
|
|
899
|
+
* @param type - Type of texts ('query' or 'passage')
|
|
900
|
+
* @returns Array of 768-dimensional normalized embedding vectors
|
|
901
|
+
*/
|
|
902
|
+
async generateBatchEmbeddings(texts: string[], type: EmbeddingTextType): Promise<number[][]> {
|
|
903
|
+
if (texts.length === 0) {
|
|
904
|
+
return [];
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
const startTime = Date.now();
|
|
908
|
+
|
|
909
|
+
if (process.env.NODE_ENV === "development") {
|
|
910
|
+
// eslint-disable-next-line no-console
|
|
911
|
+
console.log("[ML] Generating batch embeddings for", texts.length, "texts");
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
try {
|
|
915
|
+
// Check cache for each text
|
|
916
|
+
const results: (number[] | undefined)[] = new Array(texts.length);
|
|
917
|
+
const uncachedIndices: number[] = [];
|
|
918
|
+
const uncachedTexts: string[] = [];
|
|
919
|
+
|
|
920
|
+
for (let i = 0; i < texts.length; i++) {
|
|
921
|
+
const text = texts[i];
|
|
922
|
+
if (text === undefined) continue;
|
|
923
|
+
|
|
924
|
+
const cacheKey = this.getCacheKey(text, type);
|
|
925
|
+
const cached = this.getCacheEntry(cacheKey);
|
|
926
|
+
if (cached) {
|
|
927
|
+
this.cacheHits++;
|
|
928
|
+
results[i] = cached;
|
|
929
|
+
} else {
|
|
930
|
+
this.cacheMisses++;
|
|
931
|
+
uncachedIndices.push(i);
|
|
932
|
+
uncachedTexts.push(text);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// Generate embeddings for uncached texts
|
|
937
|
+
if (uncachedTexts.length > 0) {
|
|
938
|
+
const prefixedTexts = uncachedTexts.map((text) => E5_PREFIX[type] + text);
|
|
939
|
+
|
|
940
|
+
let generatedEmbeddings: number[][];
|
|
941
|
+
|
|
942
|
+
if (this.useWorkerThread) {
|
|
943
|
+
generatedEmbeddings = await this.generateBatchViaWorker(prefixedTexts);
|
|
944
|
+
} else {
|
|
945
|
+
generatedEmbeddings = await this.generateBatchInProcess(prefixedTexts);
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// Store results and cache
|
|
949
|
+
for (let j = 0; j < generatedEmbeddings.length; j++) {
|
|
950
|
+
const originalIndex = uncachedIndices[j];
|
|
951
|
+
const embedding = generatedEmbeddings[j];
|
|
952
|
+
|
|
953
|
+
if (originalIndex === undefined || embedding === undefined) continue;
|
|
954
|
+
|
|
955
|
+
results[originalIndex] = embedding;
|
|
956
|
+
|
|
957
|
+
// Cache the result
|
|
958
|
+
const originalText = texts[originalIndex];
|
|
959
|
+
if (originalText !== undefined) {
|
|
960
|
+
const cacheKey = this.getCacheKey(originalText, type);
|
|
961
|
+
this.setCacheEntry(cacheKey, embedding);
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
const elapsedMs = Date.now() - startTime;
|
|
967
|
+
|
|
968
|
+
if (process.env.NODE_ENV === "development") {
|
|
969
|
+
// eslint-disable-next-line no-console
|
|
970
|
+
console.log("[ML] Batch embeddings generated in", elapsedMs, "ms");
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
return results.filter((r): r is number[] => r !== undefined);
|
|
974
|
+
} finally {
|
|
975
|
+
this.resetIdleTimer();
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/**
|
|
980
|
+
* Dispose the ONNX pipeline to free native memory.
|
|
981
|
+
*
|
|
982
|
+
* In worker-thread mode, sends a dispose message to the worker.
|
|
983
|
+
* In fallback mode, disposes the in-process pipeline.
|
|
984
|
+
*
|
|
985
|
+
* After disposal, the next inference call will re-initialize automatically.
|
|
986
|
+
*/
|
|
987
|
+
async dispose(): Promise<void> {
|
|
988
|
+
// Clear idle timer to prevent re-entrant dispose calls
|
|
989
|
+
if (this.idleTimer !== null) {
|
|
990
|
+
clearTimeout(this.idleTimer);
|
|
991
|
+
this.idleTimer = null;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
if (process.env.NODE_ENV === "development") {
|
|
995
|
+
// eslint-disable-next-line no-console
|
|
996
|
+
console.log("[ML] Disposing ONNX pipeline", {
|
|
997
|
+
mode: this.useWorkerThread ? "worker-thread" : "in-process",
|
|
998
|
+
inferencesSinceRecycle: this.inferencesSinceRecycle,
|
|
999
|
+
totalRecycles: this.totalRecycles,
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
if (this.useWorkerThread) {
|
|
1004
|
+
if (this.worker && this.workerReady) {
|
|
1005
|
+
try {
|
|
1006
|
+
await this.sendWorkerMessage({
|
|
1007
|
+
type: "dispose",
|
|
1008
|
+
requestId: generateRequestId(),
|
|
1009
|
+
});
|
|
1010
|
+
} catch {
|
|
1011
|
+
// Disposal failure should not break the workflow
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
} else {
|
|
1015
|
+
await this.disposeInProcess();
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
this.inferencesSinceRecycle = 0;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
/**
|
|
1022
|
+
* Terminate the worker thread completely and clean up.
|
|
1023
|
+
* After this, the service cannot be used until a new instance is created.
|
|
1024
|
+
*/
|
|
1025
|
+
async terminate(): Promise<void> {
|
|
1026
|
+
// Clear idle timer
|
|
1027
|
+
if (this.idleTimer !== null) {
|
|
1028
|
+
clearTimeout(this.idleTimer);
|
|
1029
|
+
this.idleTimer = null;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
if (this.worker) {
|
|
1033
|
+
try {
|
|
1034
|
+
await this.sendWorkerMessage({
|
|
1035
|
+
type: "terminate",
|
|
1036
|
+
requestId: generateRequestId(),
|
|
1037
|
+
});
|
|
1038
|
+
} catch {
|
|
1039
|
+
// May fail if worker already exited
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
// Force terminate after a grace period
|
|
1043
|
+
try {
|
|
1044
|
+
await this.worker.terminate();
|
|
1045
|
+
} catch {
|
|
1046
|
+
// Already terminated
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
this.worker = null;
|
|
1050
|
+
this.workerReady = false;
|
|
1051
|
+
this.workerInitPromise = null;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// Also dispose in-process pipeline if present
|
|
1055
|
+
await this.disposeInProcess();
|
|
1056
|
+
|
|
1057
|
+
// Reject any remaining pending requests
|
|
1058
|
+
for (const [id, pending] of this.pendingRequests) {
|
|
1059
|
+
clearTimeout(pending.timer);
|
|
1060
|
+
pending.reject(new Error("Service terminated"));
|
|
1061
|
+
this.pendingRequests.delete(id);
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
/**
|
|
1066
|
+
* Get cache statistics
|
|
1067
|
+
*/
|
|
1068
|
+
getCacheStats(): CacheStats {
|
|
1069
|
+
return {
|
|
1070
|
+
hits: this.cacheHits,
|
|
1071
|
+
misses: this.cacheMisses,
|
|
1072
|
+
size: this.cache.size,
|
|
1073
|
+
evictions: this.cacheEvictions,
|
|
1074
|
+
};
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
/**
|
|
1078
|
+
* Clear the embedding cache
|
|
1079
|
+
*/
|
|
1080
|
+
clearCache(): void {
|
|
1081
|
+
this.cache.clear();
|
|
1082
|
+
this.cacheHits = 0;
|
|
1083
|
+
this.cacheMisses = 0;
|
|
1084
|
+
this.cacheEvictions = 0;
|
|
1085
|
+
|
|
1086
|
+
if (process.env.NODE_ENV === "development") {
|
|
1087
|
+
// eslint-disable-next-line no-console
|
|
1088
|
+
console.log("[ML] Cache cleared");
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
/**
|
|
1093
|
+
* Whether this service is using a Worker Thread for inference.
|
|
1094
|
+
*/
|
|
1095
|
+
isUsingWorkerThread(): boolean {
|
|
1096
|
+
return this.useWorkerThread;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
/**
|
|
1100
|
+
* Get the current ONNX execution provider ('cpu' or 'cuda').
|
|
1101
|
+
*/
|
|
1102
|
+
getCurrentProvider(): "cpu" | "cuda" {
|
|
1103
|
+
return this.currentProvider;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
/**
|
|
1107
|
+
* Switch ONNX execution provider at runtime.
|
|
1108
|
+
*
|
|
1109
|
+
* In worker-thread mode: sends a switch-provider message to the worker,
|
|
1110
|
+
* which disposes the current pipeline and updates the device config.
|
|
1111
|
+
* In in-process mode: disposes the pipeline and updates the config
|
|
1112
|
+
* so the next inference call re-initializes with the new provider.
|
|
1113
|
+
*
|
|
1114
|
+
* @param provider - The target execution provider ('cpu' or 'cuda').
|
|
1115
|
+
* @returns true if the switch succeeded and the requested provider is now
|
|
1116
|
+
* active; false if the switch was not possible (e.g. CUDA not
|
|
1117
|
+
* available) — in that case the service remains on CPU.
|
|
1118
|
+
*/
|
|
1119
|
+
async switchProvider(provider: "cpu" | "cuda"): Promise<boolean> {
|
|
1120
|
+
if (provider === this.currentProvider) {
|
|
1121
|
+
return true;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
if (process.env.NODE_ENV === "development") {
|
|
1125
|
+
// eslint-disable-next-line no-console
|
|
1126
|
+
console.log("[ML] Switching provider: %s → %s", this.currentProvider, provider);
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
if (this.useWorkerThread) {
|
|
1130
|
+
return this.switchProviderViaWorker(provider);
|
|
1131
|
+
}
|
|
1132
|
+
return this.switchProviderInProcess(provider);
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
/**
|
|
1136
|
+
* Switch provider via Worker Thread message.
|
|
1137
|
+
*/
|
|
1138
|
+
private async switchProviderViaWorker(provider: "cpu" | "cuda"): Promise<boolean> {
|
|
1139
|
+
if (!this.workerReady || !this.worker) {
|
|
1140
|
+
// Worker not running — just update local config. When the worker
|
|
1141
|
+
// starts next, it will pick up the new device setting.
|
|
1142
|
+
this.config = { ...this.config, device: provider };
|
|
1143
|
+
this.currentProvider = provider;
|
|
1144
|
+
// For CUDA, we can't verify availability without the worker, so
|
|
1145
|
+
// we optimistically set it. The worker's init will fall back to
|
|
1146
|
+
// CPU if onnxruntime-gpu is not installed.
|
|
1147
|
+
return true;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
const response = await this.sendWorkerMessage({
|
|
1151
|
+
type: "switch-provider",
|
|
1152
|
+
requestId: generateRequestId(),
|
|
1153
|
+
provider,
|
|
1154
|
+
});
|
|
1155
|
+
|
|
1156
|
+
if (response.type === "error") {
|
|
1157
|
+
if (process.env.NODE_ENV === "development") {
|
|
1158
|
+
console.warn("[ML] Provider switch failed:", response.error);
|
|
1159
|
+
}
|
|
1160
|
+
return false;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
if (response.type === "switch-provider") {
|
|
1164
|
+
this.currentProvider = response.provider;
|
|
1165
|
+
this.config = { ...this.config, device: response.provider };
|
|
1166
|
+
return response.provider === provider;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
return false;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
/**
|
|
1173
|
+
* Switch provider in-process (fallback mode).
|
|
1174
|
+
*/
|
|
1175
|
+
private async switchProviderInProcess(provider: "cpu" | "cuda"): Promise<boolean> {
|
|
1176
|
+
// For CUDA, verify the CUDA provider shared library is available
|
|
1177
|
+
if (provider === "cuda") {
|
|
1178
|
+
try {
|
|
1179
|
+
const fs = await import("node:fs");
|
|
1180
|
+
const pathMod = await import("node:path");
|
|
1181
|
+
const { createRequire } = await import("node:module");
|
|
1182
|
+
const require_ = createRequire(import.meta.url);
|
|
1183
|
+
const ortNodePath = require_.resolve("onnxruntime-node");
|
|
1184
|
+
// Walk up to package root (resolve returns .../dist/index.js)
|
|
1185
|
+
let packageDir = pathMod.dirname(ortNodePath);
|
|
1186
|
+
for (let i = 0; i < 5; i++) {
|
|
1187
|
+
if (fs.existsSync(pathMod.join(packageDir, "package.json"))) break;
|
|
1188
|
+
packageDir = pathMod.dirname(packageDir);
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
// Search across napi versions (v3, v6, etc.) for CUDA provider
|
|
1192
|
+
let cudaFound = false;
|
|
1193
|
+
const binDir = pathMod.join(packageDir, "bin");
|
|
1194
|
+
if (fs.existsSync(binDir)) {
|
|
1195
|
+
const napiDirs = fs.readdirSync(binDir).filter((d: string) => d.startsWith("napi-v"));
|
|
1196
|
+
for (const napiDir of napiDirs) {
|
|
1197
|
+
const p = pathMod.join(
|
|
1198
|
+
binDir,
|
|
1199
|
+
napiDir,
|
|
1200
|
+
"linux",
|
|
1201
|
+
"x64",
|
|
1202
|
+
"libonnxruntime_providers_cuda.so"
|
|
1203
|
+
);
|
|
1204
|
+
if (fs.existsSync(p)) {
|
|
1205
|
+
cudaFound = true;
|
|
1206
|
+
break;
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
if (!cudaFound) {
|
|
1211
|
+
if (process.env.NODE_ENV === "development") {
|
|
1212
|
+
console.warn("[ML] Cannot switch to CUDA in-process: CUDA provider not found");
|
|
1213
|
+
}
|
|
1214
|
+
return false;
|
|
1215
|
+
}
|
|
1216
|
+
} catch {
|
|
1217
|
+
if (process.env.NODE_ENV === "development") {
|
|
1218
|
+
console.warn("[ML] Cannot switch to CUDA in-process: onnxruntime-node not found");
|
|
1219
|
+
}
|
|
1220
|
+
return false;
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
// Dispose current pipeline so next inference re-inits with new device
|
|
1225
|
+
await this.disposeInProcess();
|
|
1226
|
+
this.config = { ...this.config, device: provider };
|
|
1227
|
+
this.currentProvider = provider;
|
|
1228
|
+
return true;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
/**
|
|
1232
|
+
* Release GPU resources by disposing the pipeline and switching to CPU.
|
|
1233
|
+
*
|
|
1234
|
+
* Safe to call even if already on CPU — acts as a dispose + ensures
|
|
1235
|
+
* the provider is set to CPU.
|
|
1236
|
+
*/
|
|
1237
|
+
async releaseGpu(): Promise<void> {
|
|
1238
|
+
if (process.env.NODE_ENV === "development") {
|
|
1239
|
+
// eslint-disable-next-line no-console
|
|
1240
|
+
console.log("[ML] Releasing GPU resources (current provider: %s)", this.currentProvider);
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
if (this.useWorkerThread) {
|
|
1244
|
+
await this.releaseGpuViaWorker();
|
|
1245
|
+
} else {
|
|
1246
|
+
await this.releaseGpuInProcess();
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
/**
|
|
1251
|
+
* Release GPU via Worker Thread message.
|
|
1252
|
+
*/
|
|
1253
|
+
private async releaseGpuViaWorker(): Promise<void> {
|
|
1254
|
+
if (this.worker && this.workerReady) {
|
|
1255
|
+
try {
|
|
1256
|
+
await this.sendWorkerMessage({
|
|
1257
|
+
type: "release-gpu",
|
|
1258
|
+
requestId: generateRequestId(),
|
|
1259
|
+
});
|
|
1260
|
+
} catch {
|
|
1261
|
+
// Release failure should not break the workflow
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
this.currentProvider = "cpu";
|
|
1266
|
+
this.config = { ...this.config, device: "cpu" };
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
/**
|
|
1270
|
+
* Release GPU in-process (fallback mode).
|
|
1271
|
+
*/
|
|
1272
|
+
private async releaseGpuInProcess(): Promise<void> {
|
|
1273
|
+
await this.disposeInProcess();
|
|
1274
|
+
this.currentProvider = "cpu";
|
|
1275
|
+
this.config = { ...this.config, device: "cpu" };
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
/**
|
|
1279
|
+
* Get the number of worker thread restarts that have occurred.
|
|
1280
|
+
*/
|
|
1281
|
+
getWorkerRestartCount(): number {
|
|
1282
|
+
return this.workerRestartCount;
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
// Singleton instance for default usage
|
|
1287
|
+
export const embeddingService = new EmbeddingService();
|