ruvector 0.2.32 → 0.2.33

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.
Files changed (42) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +2303 -2302
  3. package/bin/cli.js +10245 -10237
  4. package/bin/mcp-policy.js +95 -95
  5. package/bin/mcp-server.js +4054 -4054
  6. package/dist/core/onnx/bundled-parallel.mjs +169 -169
  7. package/dist/core/onnx/embed-worker.mjs +67 -67
  8. package/dist/core/onnx/loader.js +485 -461
  9. package/dist/core/onnx/package.json +3 -3
  10. package/dist/core/onnx/pkg/LICENSE +21 -21
  11. package/dist/core/onnx/pkg/loader.js +348 -348
  12. package/dist/core/onnx/pkg/package.json +3 -3
  13. package/dist/core/onnx/pkg/ruvector_onnx_embeddings_wasm.d.ts +112 -112
  14. package/dist/core/onnx/pkg/ruvector_onnx_embeddings_wasm.js +5 -5
  15. package/dist/core/onnx/pkg/ruvector_onnx_embeddings_wasm_bg.js +638 -638
  16. package/dist/core/onnx/pkg/ruvector_onnx_embeddings_wasm_bg.wasm.d.ts +29 -29
  17. package/dist/core/parallel-workers.js +439 -439
  18. package/dist/workers/benchmark.js +15 -15
  19. package/package.json +124 -123
  20. package/src/decompiler/api-prober.js +302 -302
  21. package/src/decompiler/index.js +463 -463
  22. package/src/decompiler/metrics.js +86 -86
  23. package/src/decompiler/model-decompiler.js +423 -423
  24. package/src/decompiler/module-splitter.js +498 -498
  25. package/src/decompiler/module-tree.js +142 -142
  26. package/src/decompiler/name-predictor.js +400 -400
  27. package/src/decompiler/npm-fetch.js +176 -176
  28. package/src/decompiler/reconstructor.js +499 -499
  29. package/src/decompiler/reference-tracker.js +285 -285
  30. package/src/decompiler/statement-parser.js +285 -285
  31. package/src/decompiler/style-improver.js +438 -438
  32. package/src/decompiler/subcategories.js +339 -339
  33. package/src/decompiler/validator.js +379 -379
  34. package/src/decompiler/witness.js +140 -140
  35. package/src/optimizer/context.js +149 -0
  36. package/src/optimizer/index.js +177 -0
  37. package/src/optimizer/settings-generator.js +176 -0
  38. package/src/optimizer/tool-schemas.js +190 -0
  39. package/wasm/package.json +26 -26
  40. package/wasm/ruvector_decompiler_wasm.d.ts +27 -27
  41. package/wasm/ruvector_decompiler_wasm.js +220 -220
  42. package/wasm/ruvector_decompiler_wasm_bg.wasm.d.ts +16 -16
@@ -1,461 +1,485 @@
1
- /**
2
- * Model Loader for RuVector ONNX Embeddings WASM
3
- *
4
- * Provides easy loading of pre-trained models from HuggingFace Hub
5
- */
6
-
7
- /**
8
- * Pre-configured models with their HuggingFace URLs
9
- */
10
- export const MODELS = {
11
- // Sentence Transformers - Small & Fast
12
- // prefixPolicy / queryPrefix / passagePrefix (ADR-210 D4) encode each
13
- // model card's query/passage convention: 'none' | 'required' |
14
- // 'query-recommended'. MiniLM models take NO prefixes.
15
- 'all-MiniLM-L6-v2': {
16
- name: 'all-MiniLM-L6-v2',
17
- dimension: 384,
18
- maxLength: 256,
19
- size: '23MB',
20
- description: 'Fast, general-purpose embeddings',
21
- model: 'https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2/resolve/main/onnx/model.onnx',
22
- tokenizer: 'https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2/resolve/main/tokenizer.json',
23
- prefixPolicy: 'none',
24
- queryPrefix: '',
25
- passagePrefix: '',
26
- },
27
- 'all-MiniLM-L12-v2': {
28
- name: 'all-MiniLM-L12-v2',
29
- dimension: 384,
30
- maxLength: 256,
31
- size: '33MB',
32
- description: 'Better quality, balanced speed',
33
- model: 'https://huggingface.co/sentence-transformers/all-MiniLM-L12-v2/resolve/main/onnx/model.onnx',
34
- tokenizer: 'https://huggingface.co/sentence-transformers/all-MiniLM-L12-v2/resolve/main/tokenizer.json',
35
- prefixPolicy: 'none',
36
- queryPrefix: '',
37
- passagePrefix: '',
38
- },
39
-
40
- // BGE Models - State of the art. Query instruction recommended for
41
- // short-query → long-passage retrieval; passages need no instruction.
42
- 'bge-small-en-v1.5': {
43
- name: 'bge-small-en-v1.5',
44
- dimension: 384,
45
- maxLength: 512,
46
- size: '33MB',
47
- description: 'State-of-the-art small model',
48
- model: 'https://huggingface.co/BAAI/bge-small-en-v1.5/resolve/main/onnx/model.onnx',
49
- tokenizer: 'https://huggingface.co/BAAI/bge-small-en-v1.5/resolve/main/tokenizer.json',
50
- prefixPolicy: 'query-recommended',
51
- queryPrefix: 'Represent this sentence for searching relevant passages: ',
52
- passagePrefix: '',
53
- },
54
- 'bge-base-en-v1.5': {
55
- name: 'bge-base-en-v1.5',
56
- dimension: 768,
57
- maxLength: 512,
58
- size: '110MB',
59
- description: 'Best overall quality',
60
- model: 'https://huggingface.co/BAAI/bge-base-en-v1.5/resolve/main/onnx/model.onnx',
61
- tokenizer: 'https://huggingface.co/BAAI/bge-base-en-v1.5/resolve/main/tokenizer.json',
62
- prefixPolicy: 'query-recommended',
63
- queryPrefix: 'Represent this sentence for searching relevant passages: ',
64
- passagePrefix: '',
65
- },
66
-
67
- // E5 Models - Microsoft. The model card REQUIRES 'query: '/'passage: '
68
- // prefixes; quality degrades without them.
69
- 'e5-small-v2': {
70
- name: 'e5-small-v2',
71
- dimension: 384,
72
- maxLength: 512,
73
- size: '33MB',
74
- description: 'Excellent for search & retrieval',
75
- model: 'https://huggingface.co/intfloat/e5-small-v2/resolve/main/onnx/model.onnx',
76
- tokenizer: 'https://huggingface.co/intfloat/e5-small-v2/resolve/main/tokenizer.json',
77
- prefixPolicy: 'required',
78
- queryPrefix: 'query: ',
79
- passagePrefix: 'passage: ',
80
- },
81
-
82
- // GTE Models - Alibaba (no prefixes documented)
83
- 'gte-small': {
84
- name: 'gte-small',
85
- dimension: 384,
86
- maxLength: 512,
87
- size: '33MB',
88
- description: 'Good multilingual support',
89
- model: 'https://huggingface.co/thenlper/gte-small/resolve/main/onnx/model.onnx',
90
- tokenizer: 'https://huggingface.co/thenlper/gte-small/resolve/main/tokenizer.json',
91
- prefixPolicy: 'none',
92
- queryPrefix: '',
93
- passagePrefix: '',
94
- },
95
- };
96
-
97
- /**
98
- * Default model for quick start
99
- */
100
- export const DEFAULT_MODEL = 'all-MiniLM-L6-v2';
101
-
102
- /**
103
- * In-memory memo of loaded models, keyed by model name. Deduplicates the
104
- * (re-)download + decode when multiple embedder instances load the same model
105
- * in one process. In Node there is no Cache API, so without this every
106
- * ModelLoader.loadModel() re-fetches the model from HuggingFace (issue #523).
107
- */
108
- const _inMemoryModelCache = new Map();
109
-
110
- /**
111
- * Model loader with caching support
112
- */
113
- export class ModelLoader {
114
- constructor(options = {}) {
115
- this.cache = options.cache ?? true;
116
- this.cacheStorage = options.cacheStorage ?? 'ruvector-models';
117
- this.onProgress = options.onProgress ?? null;
118
- }
119
-
120
- /**
121
- * Load a pre-configured model by name
122
- * @param {string} modelName - Model name from MODELS
123
- * @returns {Promise<{modelBytes: Uint8Array, tokenizerJson: string, config: object}>}
124
- */
125
- async loadModel(modelName = DEFAULT_MODEL) {
126
- // Own-property lookup only: a hostile model name like '__proto__'
127
- // must be rejected as unknown, not resolve to a prototype member.
128
- const modelConfig = Object.prototype.hasOwnProperty.call(MODELS, modelName)
129
- ? MODELS[modelName]
130
- : undefined;
131
- if (!modelConfig) {
132
- throw new Error(`Unknown model: ${modelName}. Available: ${Object.keys(MODELS).join(', ')}`);
133
- }
134
-
135
- // In-memory memo: a second load of the same model in this process reuses
136
- // the already-downloaded bytes instead of re-fetching (issue #523).
137
- if (this.cache && _inMemoryModelCache.has(modelName)) {
138
- return _inMemoryModelCache.get(modelName);
139
- }
140
-
141
- // On-disk cache (Node only): models persist across processes so they are
142
- // downloaded once, not every run. The browser has the Cache API instead
143
- // (handled in fetchWithCache). See issue #523.
144
- if (this.cache) {
145
- const disk = await this._loadFromDisk(modelName);
146
- if (disk) {
147
- const cached = { ...disk, config: modelConfig };
148
- _inMemoryModelCache.set(modelName, cached);
149
- return cached;
150
- }
151
- }
152
-
153
- console.log(`Loading model: ${modelConfig.name} (${modelConfig.size})`);
154
-
155
- const [modelBytes, tokenizerJson] = await Promise.all([
156
- this.fetchWithCache(modelConfig.model, `${modelName}-model.onnx`, 'arraybuffer'),
157
- this.fetchWithCache(modelConfig.tokenizer, `${modelName}-tokenizer.json`, 'text'),
158
- ]);
159
-
160
- const result = {
161
- modelBytes: new Uint8Array(modelBytes),
162
- tokenizerJson,
163
- config: modelConfig,
164
- };
165
-
166
- if (this.cache) {
167
- _inMemoryModelCache.set(modelName, result);
168
- await this._saveToDisk(modelName, result.modelBytes, tokenizerJson);
169
- }
170
-
171
- return result;
172
- }
173
-
174
- /**
175
- * Resolve the Node on-disk cache dir for a model (null in non-Node envs).
176
- * Uses dynamic import so this module stays loadable in browsers/bundlers.
177
- */
178
- async _diskCacheDir(modelName) {
179
- if (typeof process === 'undefined' || !process.versions?.node) return null;
180
- const home = process.env.RUVECTOR_CACHE_DIR
181
- || process.env.HOME || process.env.USERPROFILE || '/tmp';
182
- const path = await import('node:path');
183
- return path.join(home, '.ruvector', 'models', modelName);
184
- }
185
-
186
- /** Load model bytes + tokenizer from the Node disk cache, or null if absent. */
187
- async _loadFromDisk(modelName) {
188
- const dir = await this._diskCacheDir(modelName);
189
- if (!dir) return null;
190
- try {
191
- const fs = await import('node:fs');
192
- const path = await import('node:path');
193
- const modelPath = path.join(dir, 'model.onnx');
194
- const tokPath = path.join(dir, 'tokenizer.json');
195
- if (!fs.existsSync(modelPath) || !fs.existsSync(tokPath)) return null;
196
- const modelBytes = new Uint8Array(fs.readFileSync(modelPath));
197
- const tokenizerJson = fs.readFileSync(tokPath, 'utf8');
198
- if (modelBytes.length === 0 || tokenizerJson.length === 0) return null;
199
- console.log(` Disk cache hit: ${modelName}`);
200
- return { modelBytes, tokenizerJson };
201
- } catch {
202
- return null;
203
- }
204
- }
205
-
206
- /** Persist model bytes + tokenizer to the Node disk cache (best-effort). */
207
- async _saveToDisk(modelName, modelBytes, tokenizerJson) {
208
- const dir = await this._diskCacheDir(modelName);
209
- if (!dir) return;
210
- try {
211
- const fs = await import('node:fs');
212
- const path = await import('node:path');
213
- fs.mkdirSync(dir, { recursive: true });
214
- // Write to temp files then rename, so a crash mid-write can't leave a
215
- // truncated cache entry that later reads would trust.
216
- const mTmp = path.join(dir, 'model.onnx.tmp');
217
- const tTmp = path.join(dir, 'tokenizer.json.tmp');
218
- fs.writeFileSync(mTmp, Buffer.from(modelBytes));
219
- fs.writeFileSync(tTmp, tokenizerJson);
220
- fs.renameSync(mTmp, path.join(dir, 'model.onnx'));
221
- fs.renameSync(tTmp, path.join(dir, 'tokenizer.json'));
222
- } catch {
223
- // Cache write is best-effort; embedding still works without it.
224
- }
225
- }
226
-
227
- /**
228
- * Load model from custom URLs
229
- * @param {string} modelUrl - URL to ONNX model
230
- * @param {string} tokenizerUrl - URL to tokenizer.json
231
- * @returns {Promise<{modelBytes: Uint8Array, tokenizerJson: string}>}
232
- */
233
- async loadFromUrls(modelUrl, tokenizerUrl) {
234
- const [modelBytes, tokenizerJson] = await Promise.all([
235
- this.fetchWithCache(modelUrl, null, 'arraybuffer'),
236
- this.fetchWithCache(tokenizerUrl, null, 'text'),
237
- ]);
238
-
239
- return {
240
- modelBytes: new Uint8Array(modelBytes),
241
- tokenizerJson,
242
- };
243
- }
244
-
245
- /**
246
- * Load model from local files (Node.js)
247
- * @param {string} modelPath - Path to ONNX model
248
- * @param {string} tokenizerPath - Path to tokenizer.json
249
- * @returns {Promise<{modelBytes: Uint8Array, tokenizerJson: string}>}
250
- */
251
- async loadFromFiles(modelPath, tokenizerPath) {
252
- // Node.js environment
253
- if (typeof process !== 'undefined' && process.versions?.node) {
254
- const fs = await import('fs/promises');
255
- const [modelBytes, tokenizerJson] = await Promise.all([
256
- fs.readFile(modelPath),
257
- fs.readFile(tokenizerPath, 'utf8'),
258
- ]);
259
- return {
260
- modelBytes: new Uint8Array(modelBytes),
261
- tokenizerJson,
262
- };
263
- }
264
- throw new Error('loadFromFiles is only available in Node.js');
265
- }
266
-
267
- /**
268
- * Fetch with optional caching (uses Cache API in browsers)
269
- */
270
- async fetchWithCache(url, cacheKey, responseType) {
271
- // Try cache first (browser only)
272
- if (this.cache && typeof caches !== 'undefined' && cacheKey) {
273
- try {
274
- const cache = await caches.open(this.cacheStorage);
275
- const cached = await cache.match(cacheKey);
276
- if (cached) {
277
- console.log(` Cache hit: ${cacheKey}`);
278
- return responseType === 'arraybuffer'
279
- ? await cached.arrayBuffer()
280
- : await cached.text();
281
- }
282
- } catch (e) {
283
- // Cache API not available, continue with fetch
284
- }
285
- }
286
-
287
- // Fetch from network
288
- console.log(` Downloading: ${url}`);
289
- const response = await this.fetchWithProgress(url);
290
-
291
- if (!response.ok) {
292
- throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
293
- }
294
-
295
- // Clone for caching
296
- const responseClone = response.clone();
297
-
298
- // Cache the response (browser only)
299
- if (this.cache && typeof caches !== 'undefined' && cacheKey) {
300
- try {
301
- const cache = await caches.open(this.cacheStorage);
302
- await cache.put(cacheKey, responseClone);
303
- } catch (e) {
304
- // Cache write failed, continue
305
- }
306
- }
307
-
308
- return responseType === 'arraybuffer'
309
- ? await response.arrayBuffer()
310
- : await response.text();
311
- }
312
-
313
- /**
314
- * Fetch with progress reporting
315
- */
316
- async fetchWithProgress(url) {
317
- const response = await fetch(url);
318
-
319
- if (!this.onProgress || !response.body) {
320
- return response;
321
- }
322
-
323
- const contentLength = response.headers.get('content-length');
324
- if (!contentLength) {
325
- return response;
326
- }
327
-
328
- const total = parseInt(contentLength, 10);
329
- let loaded = 0;
330
-
331
- const reader = response.body.getReader();
332
- const chunks = [];
333
-
334
- while (true) {
335
- const { done, value } = await reader.read();
336
- if (done) break;
337
-
338
- chunks.push(value);
339
- loaded += value.length;
340
-
341
- this.onProgress({
342
- loaded,
343
- total,
344
- percent: Math.round((loaded / total) * 100),
345
- });
346
- }
347
-
348
- const body = new Uint8Array(loaded);
349
- let position = 0;
350
- for (const chunk of chunks) {
351
- body.set(chunk, position);
352
- position += chunk.length;
353
- }
354
-
355
- return new Response(body, {
356
- headers: response.headers,
357
- status: response.status,
358
- statusText: response.statusText,
359
- });
360
- }
361
-
362
- /**
363
- * Clear cached models
364
- */
365
- async clearCache() {
366
- if (typeof caches !== 'undefined') {
367
- await caches.delete(this.cacheStorage);
368
- console.log('Model cache cleared');
369
- }
370
- }
371
-
372
- /**
373
- * List available models
374
- */
375
- static listModels() {
376
- return Object.entries(MODELS).map(([key, config]) => ({
377
- id: key,
378
- ...config,
379
- }));
380
- }
381
- }
382
-
383
- /**
384
- * Quick helper to create an embedder with a pre-configured model
385
- *
386
- * @example
387
- * ```javascript
388
- * import { createEmbedder } from './loader.js';
389
- *
390
- * const embedder = await createEmbedder('all-MiniLM-L6-v2');
391
- * const embedding = embedder.embedOne("Hello world");
392
- * ```
393
- */
394
- export async function createEmbedder(modelName = DEFAULT_MODEL, wasmModule = null) {
395
- // Import WASM module if not provided
396
- if (!wasmModule) {
397
- wasmModule = await import('./pkg/ruvector_onnx_embeddings_wasm.js');
398
- await wasmModule.default();
399
- }
400
-
401
- const loader = new ModelLoader();
402
- const { modelBytes, tokenizerJson, config } = await loader.loadModel(modelName);
403
-
404
- const embedderConfig = new wasmModule.WasmEmbedderConfig()
405
- .setMaxLength(config.maxLength)
406
- .setNormalize(true)
407
- .setPooling(0); // Mean pooling
408
-
409
- const embedder = wasmModule.WasmEmbedder.withConfig(
410
- modelBytes,
411
- tokenizerJson,
412
- embedderConfig
413
- );
414
-
415
- return embedder;
416
- }
417
-
418
- /**
419
- * Quick helper for one-off embedding (loads model, embeds, returns)
420
- *
421
- * @example
422
- * ```javascript
423
- * import { embed } from './loader.js';
424
- *
425
- * const embedding = await embed("Hello world");
426
- * const embeddings = await embed(["Hello", "World"]);
427
- * ```
428
- */
429
- export async function embed(text, modelName = DEFAULT_MODEL) {
430
- const embedder = await createEmbedder(modelName);
431
-
432
- if (Array.isArray(text)) {
433
- return embedder.embedBatch(text);
434
- }
435
- return embedder.embedOne(text);
436
- }
437
-
438
- /**
439
- * Quick helper for similarity comparison
440
- *
441
- * @example
442
- * ```javascript
443
- * import { similarity } from './loader.js';
444
- *
445
- * const score = await similarity("I love dogs", "I adore puppies");
446
- * console.log(score); // ~0.85
447
- * ```
448
- */
449
- export async function similarity(text1, text2, modelName = DEFAULT_MODEL) {
450
- const embedder = await createEmbedder(modelName);
451
- return embedder.similarity(text1, text2);
452
- }
453
-
454
- export default {
455
- MODELS,
456
- DEFAULT_MODEL,
457
- ModelLoader,
458
- createEmbedder,
459
- embed,
460
- similarity,
461
- };
1
+ /**
2
+ * Model Loader for RuVector ONNX Embeddings WASM
3
+ *
4
+ * Provides easy loading of pre-trained models from HuggingFace Hub
5
+ */
6
+
7
+ /**
8
+ * Pre-configured models with their HuggingFace URLs
9
+ */
10
+ export const MODELS = {
11
+ // Sentence Transformers - Small & Fast
12
+ // prefixPolicy / queryPrefix / passagePrefix (ADR-210 D4) encode each
13
+ // model card's query/passage convention: 'none' | 'required' |
14
+ // 'query-recommended'. MiniLM models take NO prefixes.
15
+ 'all-MiniLM-L6-v2': {
16
+ name: 'all-MiniLM-L6-v2',
17
+ dimension: 384,
18
+ maxLength: 256,
19
+ size: '23MB',
20
+ description: 'Fast, general-purpose embeddings',
21
+ model: 'https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2/resolve/main/onnx/model.onnx',
22
+ tokenizer: 'https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2/resolve/main/tokenizer.json',
23
+ prefixPolicy: 'none',
24
+ queryPrefix: '',
25
+ passagePrefix: '',
26
+ },
27
+ 'all-MiniLM-L12-v2': {
28
+ name: 'all-MiniLM-L12-v2',
29
+ dimension: 384,
30
+ maxLength: 256,
31
+ size: '33MB',
32
+ description: 'Better quality, balanced speed',
33
+ model: 'https://huggingface.co/sentence-transformers/all-MiniLM-L12-v2/resolve/main/onnx/model.onnx',
34
+ tokenizer: 'https://huggingface.co/sentence-transformers/all-MiniLM-L12-v2/resolve/main/tokenizer.json',
35
+ prefixPolicy: 'none',
36
+ queryPrefix: '',
37
+ passagePrefix: '',
38
+ },
39
+
40
+ // BGE Models - State of the art. Query instruction recommended for
41
+ // short-query → long-passage retrieval; passages need no instruction.
42
+ 'bge-small-en-v1.5': {
43
+ name: 'bge-small-en-v1.5',
44
+ dimension: 384,
45
+ maxLength: 512,
46
+ size: '33MB',
47
+ description: 'State-of-the-art small model',
48
+ model: 'https://huggingface.co/BAAI/bge-small-en-v1.5/resolve/main/onnx/model.onnx',
49
+ tokenizer: 'https://huggingface.co/BAAI/bge-small-en-v1.5/resolve/main/tokenizer.json',
50
+ prefixPolicy: 'query-recommended',
51
+ queryPrefix: 'Represent this sentence for searching relevant passages: ',
52
+ passagePrefix: '',
53
+ },
54
+ 'bge-base-en-v1.5': {
55
+ name: 'bge-base-en-v1.5',
56
+ dimension: 768,
57
+ maxLength: 512,
58
+ size: '110MB',
59
+ description: 'Best overall quality',
60
+ model: 'https://huggingface.co/BAAI/bge-base-en-v1.5/resolve/main/onnx/model.onnx',
61
+ tokenizer: 'https://huggingface.co/BAAI/bge-base-en-v1.5/resolve/main/tokenizer.json',
62
+ prefixPolicy: 'query-recommended',
63
+ queryPrefix: 'Represent this sentence for searching relevant passages: ',
64
+ passagePrefix: '',
65
+ },
66
+
67
+ // E5 Models - Microsoft. The model card REQUIRES 'query: '/'passage: '
68
+ // prefixes; quality degrades without them.
69
+ 'e5-small-v2': {
70
+ name: 'e5-small-v2',
71
+ dimension: 384,
72
+ maxLength: 512,
73
+ size: '33MB',
74
+ description: 'Excellent for search & retrieval',
75
+ model: 'https://huggingface.co/intfloat/e5-small-v2/resolve/main/onnx/model.onnx',
76
+ tokenizer: 'https://huggingface.co/intfloat/e5-small-v2/resolve/main/tokenizer.json',
77
+ prefixPolicy: 'required',
78
+ queryPrefix: 'query: ',
79
+ passagePrefix: 'passage: ',
80
+ },
81
+
82
+ // GTE Models - Alibaba (no prefixes documented)
83
+ 'gte-small': {
84
+ name: 'gte-small',
85
+ dimension: 384,
86
+ maxLength: 512,
87
+ size: '33MB',
88
+ description: 'Good multilingual support',
89
+ model: 'https://huggingface.co/thenlper/gte-small/resolve/main/onnx/model.onnx',
90
+ tokenizer: 'https://huggingface.co/thenlper/gte-small/resolve/main/tokenizer.json',
91
+ prefixPolicy: 'none',
92
+ queryPrefix: '',
93
+ passagePrefix: '',
94
+ },
95
+ };
96
+
97
+ /**
98
+ * Default model for quick start
99
+ */
100
+ export const DEFAULT_MODEL = 'all-MiniLM-L6-v2';
101
+
102
+ /**
103
+ * In-memory memo of loaded models, keyed by model name. Deduplicates the
104
+ * (re-)download + decode when multiple embedder instances load the same model
105
+ * in one process. In Node there is no Cache API, so without this every
106
+ * ModelLoader.loadModel() re-fetches the model from HuggingFace (issue #523).
107
+ */
108
+ const _inMemoryModelCache = new Map();
109
+
110
+ /**
111
+ * Model loader with caching support
112
+ */
113
+ export class ModelLoader {
114
+ constructor(options = {}) {
115
+ this.cache = options.cache ?? true;
116
+ this.cacheStorage = options.cacheStorage ?? 'ruvector-models';
117
+ this.onProgress = options.onProgress ?? null;
118
+ }
119
+
120
+ /**
121
+ * Load a pre-configured model by name
122
+ * @param {string} modelName - Model name from MODELS
123
+ * @returns {Promise<{modelBytes: Uint8Array, tokenizerJson: string, config: object}>}
124
+ */
125
+ async loadModel(modelName = DEFAULT_MODEL) {
126
+ // Own-property lookup only: a hostile model name like '__proto__'
127
+ // must be rejected as unknown, not resolve to a prototype member.
128
+ const modelConfig = Object.prototype.hasOwnProperty.call(MODELS, modelName)
129
+ ? MODELS[modelName]
130
+ : undefined;
131
+ if (!modelConfig) {
132
+ throw new Error(`Unknown model: ${modelName}. Available: ${Object.keys(MODELS).join(', ')}`);
133
+ }
134
+
135
+ // In-memory memo: a second load of the same model in this process reuses
136
+ // the already-downloaded bytes instead of re-fetching (issue #523).
137
+ if (this.cache && _inMemoryModelCache.has(modelName)) {
138
+ return _inMemoryModelCache.get(modelName);
139
+ }
140
+
141
+ // On-disk cache (Node only): models persist across processes so they are
142
+ // downloaded once, not every run. The browser has the Cache API instead
143
+ // (handled in fetchWithCache). See issue #523.
144
+ if (this.cache) {
145
+ const disk = await this._loadFromDisk(modelName);
146
+ if (disk) {
147
+ const cached = { ...disk, config: modelConfig };
148
+ _inMemoryModelCache.set(modelName, cached);
149
+ return cached;
150
+ }
151
+ }
152
+
153
+ console.log(`Loading model: ${modelConfig.name} (${modelConfig.size})`);
154
+
155
+ const [modelBytes, tokenizerJson] = await Promise.all([
156
+ this.fetchWithCache(modelConfig.model, `${modelName}-model.onnx`, 'arraybuffer'),
157
+ this.fetchWithCache(modelConfig.tokenizer, `${modelName}-tokenizer.json`, 'text'),
158
+ ]);
159
+
160
+ const result = {
161
+ modelBytes: new Uint8Array(modelBytes),
162
+ tokenizerJson,
163
+ config: modelConfig,
164
+ };
165
+
166
+ if (this.cache) {
167
+ _inMemoryModelCache.set(modelName, result);
168
+ await this._saveToDisk(modelName, result.modelBytes, tokenizerJson);
169
+ }
170
+
171
+ return result;
172
+ }
173
+
174
+ /**
175
+ * Resolve the Node on-disk cache dir for a model (null in non-Node envs).
176
+ * Uses dynamic import so this module stays loadable in browsers/bundlers.
177
+ */
178
+ async _diskCacheDir(modelName) {
179
+ if (typeof process === 'undefined' || !process.versions?.node) return null;
180
+ const home = process.env.RUVECTOR_CACHE_DIR
181
+ || process.env.HOME || process.env.USERPROFILE || '/tmp';
182
+ const path = await import('node:path');
183
+ return path.join(home, '.ruvector', 'models', modelName);
184
+ }
185
+
186
+ /** Load model bytes + tokenizer from the Node disk cache, or null if absent. */
187
+ async _loadFromDisk(modelName) {
188
+ const dir = await this._diskCacheDir(modelName);
189
+ if (!dir) return null;
190
+ try {
191
+ const fs = await import('node:fs');
192
+ const path = await import('node:path');
193
+ const modelPath = path.join(dir, 'model.onnx');
194
+ const tokPath = path.join(dir, 'tokenizer.json');
195
+ if (!fs.existsSync(modelPath) || !fs.existsSync(tokPath)) return null;
196
+ const modelBytes = new Uint8Array(fs.readFileSync(modelPath));
197
+ const tokenizerJson = fs.readFileSync(tokPath, 'utf8');
198
+ if (modelBytes.length === 0 || tokenizerJson.length === 0) return null;
199
+ console.log(` Disk cache hit: ${modelName}`);
200
+ return { modelBytes, tokenizerJson };
201
+ } catch {
202
+ return null;
203
+ }
204
+ }
205
+
206
+ /** Persist model bytes + tokenizer to the Node disk cache (best-effort). */
207
+ async _saveToDisk(modelName, modelBytes, tokenizerJson) {
208
+ const dir = await this._diskCacheDir(modelName);
209
+ if (!dir) return;
210
+ try {
211
+ const fs = await import('node:fs');
212
+ const path = await import('node:path');
213
+ fs.mkdirSync(dir, { recursive: true });
214
+ // Write to temp files then rename, so a crash mid-write can't leave a
215
+ // truncated cache entry that later reads would trust.
216
+ const mTmp = path.join(dir, 'model.onnx.tmp');
217
+ const tTmp = path.join(dir, 'tokenizer.json.tmp');
218
+ fs.writeFileSync(mTmp, Buffer.from(modelBytes));
219
+ fs.writeFileSync(tTmp, tokenizerJson);
220
+ fs.renameSync(mTmp, path.join(dir, 'model.onnx'));
221
+ fs.renameSync(tTmp, path.join(dir, 'tokenizer.json'));
222
+ } catch {
223
+ // Cache write is best-effort; embedding still works without it.
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Load model from custom URLs
229
+ * @param {string} modelUrl - URL to ONNX model
230
+ * @param {string} tokenizerUrl - URL to tokenizer.json
231
+ * @returns {Promise<{modelBytes: Uint8Array, tokenizerJson: string}>}
232
+ */
233
+ async loadFromUrls(modelUrl, tokenizerUrl) {
234
+ const [modelBytes, tokenizerJson] = await Promise.all([
235
+ this.fetchWithCache(modelUrl, null, 'arraybuffer'),
236
+ this.fetchWithCache(tokenizerUrl, null, 'text'),
237
+ ]);
238
+
239
+ return {
240
+ modelBytes: new Uint8Array(modelBytes),
241
+ tokenizerJson,
242
+ };
243
+ }
244
+
245
+ /**
246
+ * Load model from local files (Node.js)
247
+ * @param {string} modelPath - Path to ONNX model
248
+ * @param {string} tokenizerPath - Path to tokenizer.json
249
+ * @returns {Promise<{modelBytes: Uint8Array, tokenizerJson: string}>}
250
+ */
251
+ async loadFromFiles(modelPath, tokenizerPath) {
252
+ // Node.js environment
253
+ if (typeof process !== 'undefined' && process.versions?.node) {
254
+ const fs = await import('fs/promises');
255
+ const [modelBytes, tokenizerJson] = await Promise.all([
256
+ fs.readFile(modelPath),
257
+ fs.readFile(tokenizerPath, 'utf8'),
258
+ ]);
259
+ return {
260
+ modelBytes: new Uint8Array(modelBytes),
261
+ tokenizerJson,
262
+ };
263
+ }
264
+ throw new Error('loadFromFiles is only available in Node.js');
265
+ }
266
+
267
+ /**
268
+ * Fetch with optional caching (uses Cache API in browsers)
269
+ */
270
+ async fetchWithCache(url, cacheKey, responseType) {
271
+ // Try cache first (browser only)
272
+ if (this.cache && typeof caches !== 'undefined' && cacheKey) {
273
+ try {
274
+ const cache = await caches.open(this.cacheStorage);
275
+ const cached = await cache.match(cacheKey);
276
+ if (cached) {
277
+ console.log(` Cache hit: ${cacheKey}`);
278
+ return responseType === 'arraybuffer'
279
+ ? await cached.arrayBuffer()
280
+ : await cached.text();
281
+ }
282
+ } catch (e) {
283
+ // Cache API not available, continue with fetch
284
+ }
285
+ }
286
+
287
+ // Fetch from network
288
+ console.log(` Downloading: ${url}`);
289
+ const response = await this.fetchWithProgress(url);
290
+
291
+ if (!response.ok) {
292
+ throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
293
+ }
294
+
295
+ // Clone for caching
296
+ const responseClone = response.clone();
297
+
298
+ // Cache the response (browser only)
299
+ if (this.cache && typeof caches !== 'undefined' && cacheKey) {
300
+ try {
301
+ const cache = await caches.open(this.cacheStorage);
302
+ await cache.put(cacheKey, responseClone);
303
+ } catch (e) {
304
+ // Cache write failed, continue
305
+ }
306
+ }
307
+
308
+ return responseType === 'arraybuffer'
309
+ ? await response.arrayBuffer()
310
+ : await response.text();
311
+ }
312
+
313
+ /**
314
+ * Fetch with progress reporting
315
+ */
316
+ async fetchWithProgress(url) {
317
+ const response = await fetch(url);
318
+
319
+ if (!this.onProgress || !response.body) {
320
+ return response;
321
+ }
322
+
323
+ const contentLength = response.headers.get('content-length');
324
+ if (!contentLength) {
325
+ return response;
326
+ }
327
+
328
+ const total = parseInt(contentLength, 10);
329
+ let loaded = 0;
330
+
331
+ const reader = response.body.getReader();
332
+ const chunks = [];
333
+
334
+ while (true) {
335
+ const { done, value } = await reader.read();
336
+ if (done) break;
337
+
338
+ chunks.push(value);
339
+ loaded += value.length;
340
+
341
+ this.onProgress({
342
+ loaded,
343
+ total,
344
+ percent: Math.round((loaded / total) * 100),
345
+ });
346
+ }
347
+
348
+ const body = new Uint8Array(loaded);
349
+ let position = 0;
350
+ for (const chunk of chunks) {
351
+ body.set(chunk, position);
352
+ position += chunk.length;
353
+ }
354
+
355
+ return new Response(body, {
356
+ headers: response.headers,
357
+ status: response.status,
358
+ statusText: response.statusText,
359
+ });
360
+ }
361
+
362
+ /**
363
+ * Clear cached models
364
+ */
365
+ async clearCache() {
366
+ if (typeof caches !== 'undefined') {
367
+ await caches.delete(this.cacheStorage);
368
+ console.log('Model cache cleared');
369
+ }
370
+ }
371
+
372
+ /**
373
+ * List available models
374
+ */
375
+ static listModels() {
376
+ return Object.entries(MODELS).map(([key, config]) => ({
377
+ id: key,
378
+ ...config,
379
+ }));
380
+ }
381
+ }
382
+
383
+ /**
384
+ * Quick helper to create an embedder with a pre-configured model
385
+ *
386
+ * @example
387
+ * ```javascript
388
+ * import { createEmbedder } from './loader.js';
389
+ *
390
+ * const embedder = await createEmbedder('all-MiniLM-L6-v2');
391
+ * const embedding = embedder.embedOne("Hello world");
392
+ * ```
393
+ */
394
+ export async function createEmbedder(modelName = DEFAULT_MODEL, wasmModule = null) {
395
+ // Import + initialize the WASM module if not provided.
396
+ if (!wasmModule) {
397
+ // The published artifact is a wasm-pack --target bundler build. Its wrapper
398
+ // (pkg/ruvector_onnx_embeddings_wasm.js) BOTH starts with
399
+ // `import * as wasm from "...bg.wasm"` — which plain Node ESM cannot resolve —
400
+ // AND exports no `default` init, so the old `import(wrapper); await default()`
401
+ // path throws under Node. Mirror the worker's proven init (embed-worker.mjs):
402
+ // import the _bg.js glue directly and instantiate the .wasm ourselves. This
403
+ // produces vectors identical to the worker path by construction. (issue #523)
404
+ if (typeof process !== 'undefined' && process.versions?.node) {
405
+ const { pathToFileURL, fileURLToPath } = await import('node:url');
406
+ const path = await import('node:path');
407
+ const fs = await import('node:fs');
408
+ const pkgDir = path.join(path.dirname(fileURLToPath(import.meta.url)), 'pkg');
409
+ const bgJs = path.join(pkgDir, 'ruvector_onnx_embeddings_wasm_bg.js');
410
+ const bgWasm = path.join(pkgDir, 'ruvector_onnx_embeddings_wasm_bg.wasm');
411
+ wasmModule = await import(pathToFileURL(bgJs).href);
412
+ const { instance } = await WebAssembly.instantiate(fs.readFileSync(bgWasm), {
413
+ './ruvector_onnx_embeddings_wasm_bg.js': wasmModule,
414
+ });
415
+ if (typeof wasmModule.__wbg_set_wasm === 'function') wasmModule.__wbg_set_wasm(instance.exports);
416
+ if (typeof instance.exports.__wbindgen_start === 'function') instance.exports.__wbindgen_start();
417
+ } else {
418
+ // Browser / bundler: the wrapper resolves the .wasm import and its
419
+ // default() performs initialization.
420
+ wasmModule = await import('./pkg/ruvector_onnx_embeddings_wasm.js');
421
+ await wasmModule.default();
422
+ }
423
+ }
424
+
425
+ const loader = new ModelLoader();
426
+ const { modelBytes, tokenizerJson, config } = await loader.loadModel(modelName);
427
+
428
+ const embedderConfig = new wasmModule.WasmEmbedderConfig()
429
+ .setMaxLength(config.maxLength)
430
+ .setNormalize(true)
431
+ .setPooling(0); // Mean pooling
432
+
433
+ const embedder = wasmModule.WasmEmbedder.withConfig(
434
+ modelBytes,
435
+ tokenizerJson,
436
+ embedderConfig
437
+ );
438
+
439
+ return embedder;
440
+ }
441
+
442
+ /**
443
+ * Quick helper for one-off embedding (loads model, embeds, returns)
444
+ *
445
+ * @example
446
+ * ```javascript
447
+ * import { embed } from './loader.js';
448
+ *
449
+ * const embedding = await embed("Hello world");
450
+ * const embeddings = await embed(["Hello", "World"]);
451
+ * ```
452
+ */
453
+ export async function embed(text, modelName = DEFAULT_MODEL) {
454
+ const embedder = await createEmbedder(modelName);
455
+
456
+ if (Array.isArray(text)) {
457
+ return embedder.embedBatch(text);
458
+ }
459
+ return embedder.embedOne(text);
460
+ }
461
+
462
+ /**
463
+ * Quick helper for similarity comparison
464
+ *
465
+ * @example
466
+ * ```javascript
467
+ * import { similarity } from './loader.js';
468
+ *
469
+ * const score = await similarity("I love dogs", "I adore puppies");
470
+ * console.log(score); // ~0.85
471
+ * ```
472
+ */
473
+ export async function similarity(text1, text2, modelName = DEFAULT_MODEL) {
474
+ const embedder = await createEmbedder(modelName);
475
+ return embedder.similarity(text1, text2);
476
+ }
477
+
478
+ export default {
479
+ MODELS,
480
+ DEFAULT_MODEL,
481
+ ModelLoader,
482
+ createEmbedder,
483
+ embed,
484
+ similarity,
485
+ };