@delali/narsil 0.1.1 → 0.1.2

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/README.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Narsil
2
2
 
3
+ [![CI](https://github.com/assetcorp/narsil/actions/workflows/ci.yml/badge.svg)](https://github.com/assetcorp/narsil/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/@delali/narsil)](https://www.npmjs.com/package/@delali/narsil)
5
+ [![downloads](https://img.shields.io/npm/dw/@delali/narsil)](https://www.npmjs.com/package/@delali/narsil)
6
+ [![types](https://img.shields.io/badge/types-TypeScript-blue)](https://www.npmjs.com/package/@delali/narsil)
7
+ [![license](https://img.shields.io/npm/l/@delali/narsil)](https://github.com/assetcorp/narsil/blob/main/LICENSE)
8
+
3
9
  Distributed search, reforged.
4
10
 
5
11
  Narsil is a distributed search engine with full-text, vector, hybrid, and geosearch. It auto-partitions large indexes across workers, serializes them into a cross-language binary format (.nrsl), and merges results back into a single ranked answer. The TypeScript package is the first implementation.
@@ -140,6 +146,18 @@ const results = await narsil.query('products', {
140
146
  // hit.highlights.title.snippet => '<mark>Mechanical</mark> Keyboard'
141
147
  ```
142
148
 
149
+ ### Term suggestions
150
+
151
+ Get autocomplete suggestions from the index's term dictionary. The API tokenizes the input, extracts the last word, and matches it against stored terms across all partitions. Results are ranked by document frequency.
152
+
153
+ ```ts
154
+ const suggestions = await narsil.suggest('products', {
155
+ prefix: 'mech',
156
+ limit: 5,
157
+ })
158
+ // suggestions.terms => [{ term: 'mechan', documentFrequency: 12 }, ...]
159
+ ```
160
+
143
161
  ### Cursor-based pagination
144
162
 
145
163
  Deep pagination using `searchAfter` cursors that track position across partitions. No offset-based performance degradation for deep result sets.
@@ -204,6 +222,27 @@ import '@delali/narsil/languages/twi'
204
222
  | Vector | `mode: 'vector'` | Cosine similarity, dot product, or Euclidean distance |
205
223
  | Hybrid | `mode: 'hybrid'` | Weighted combination of BM25 + vector similarity |
206
224
 
225
+ ## Search quality
226
+
227
+ Ranking quality is measured against the [Cranfield Collection](https://ir-datasets.com/cranfield.html), the foundational information retrieval benchmark created in the 1960s. It contains 1,400 documents and 225 queries with exhaustive human relevance judgments, meaning every query-document pair was scored by domain experts. All engines in this comparison use identical stop words (Lucene English, 35 terms), Porter stemming, and default BM25 parameters.
228
+
229
+ | Engine | nDCG@10 | P@10 | MAP | MRR |
230
+ | --- | ---: | ---: | ---: | ---: |
231
+ | **Narsil 0.1.1** | **0.3739** | **0.2458** | **0.2614** | **0.5638** |
232
+ | Orama 3.1.18 | 0.2911 | 0.1836 | 0.1846 | 0.4821 |
233
+ | MiniSearch 7.2.0 | 0.0077 | 0.0067 | 0.0027 | 0.0139 |
234
+
235
+ **What these metrics mean:**
236
+
237
+ - **nDCG@10** measures whether the most relevant documents appear near the top of the results. A score of 1.0 would mean perfect ranking; 0.0 means no relevant documents in the top 10.
238
+ - **P@10** is the fraction of the top 10 results that are relevant. Narsil averages about 2.5 relevant results in every top-10 list.
239
+ - **MAP** tracks precision at every rank where a relevant document appears. Higher MAP means relevant documents cluster near the top rather than being spread through the result set.
240
+ - **MRR** measures how far a user scrolls to find the first relevant result. Narsil's 0.56 means the first relevant document typically appears at position 2.
241
+
242
+ These scores fall within the expected range for BM25 on standard IR benchmarks (published BM25 baselines on BEIR datasets range from 0.24 to 0.63 nDCG@10). Narsil also runs a [Cranfield regression test](src/__tests__/relevance/cranfield.test.ts) in CI that fails the build if ranking quality drops below calibrated thresholds.
243
+
244
+ Reproduce: `pnpm -C packages/benchmarks bench --tiers quality`
245
+
207
246
  ## Runtime support (TypeScript)
208
247
 
209
248
  | Runtime | Concurrency | Persistence | Invalidation |
@@ -1,4 +1,4 @@
1
- import { NarsilError, ErrorCodes } from '../chunk-JKIBUPAY.mjs';
1
+ import { NarsilError, ErrorCodes } from '../chunk-7GKPWGCN.mjs';
2
2
 
3
3
  // src/persistence/filesystem.ts
4
4
  var cachedFs = null;
@@ -1,4 +1,4 @@
1
- import { NarsilError, ErrorCodes } from '../chunk-JKIBUPAY.mjs';
1
+ import { NarsilError, ErrorCodes } from '../chunk-7GKPWGCN.mjs';
2
2
 
3
3
  // src/persistence/indexeddb.ts
4
4
  var DEFAULT_DB_NAME = "narsil";
@@ -26,7 +26,8 @@ var ErrorCodes = {
26
26
  SEARCH_INVALID_CURSOR: "SEARCH_INVALID_CURSOR",
27
27
  LANGUAGE_NOT_SUPPORTED: "LANGUAGE_NOT_SUPPORTED",
28
28
  ENVELOPE_VERSION_MISMATCH: "ENVELOPE_VERSION_MISMATCH",
29
- ENVELOPE_INVALID_MAGIC: "ENVELOPE_INVALID_MAGIC"
29
+ ENVELOPE_INVALID_MAGIC: "ENVELOPE_INVALID_MAGIC",
30
+ PARTITION_CAPACITY_EXCEEDED: "PARTITION_CAPACITY_EXCEEDED"
30
31
  };
31
32
  var NarsilError = class extends Error {
32
33
  code;
@@ -0,0 +1,570 @@
1
+ // src/vector/simd-wasm-binary.ts
2
+ var SIMD_DISTANCE_WASM_BASE64 = "AGFzbQEAAAABDgJgA39/fwF9YAJ/fwF9AwUEAAEAAAUDAQABB1YFBm1lbW9yeQIAC2RvdF9wcm9kdWN0AAAJbWFnbml0dWRlAAEac3F1YXJlZF9ldWNsaWRlYW5fZGlzdGFuY2UAAhJldWNsaWRlYW5fZGlzdGFuY2UAAwqqBASuAQQBfwF7AX0BfyACQXxxIQb9DAAAAAAAAAAAAAAAAAAAAAAhBEEAIQMCQANAIAMgBk8NASAEIAAgA0ECdGr9AAQAIAEgA0ECdGr9AAQA/eYB/eQBIQQgA0EEaiEDDAALCyAE/R8AIAT9HwGSIAT9HwIgBP0fA5KSIQUCQANAIAMgAk8NASAFIAAgA0ECdGoqAgAgASADQQJ0aioCAJSSIQUgA0EBaiEDDAALCyAFC6kBBAF/AnsBfQF/IAFBfHEhBv0MAAAAAAAAAAAAAAAAAAAAACEDQQAhAgJAA0AgAiAGTw0BIAAgAkECdGr9AAQAIQQgAyAEIAT95gH95AEhAyACQQRqIQIMAAsLIAP9HwAgA/0fAZIgA/0fAiAD/R8DkpIhBQJAA0AgAiABTw0BIAUgACACQQJ0aioCACAAIAJBAnRqKgIAlJIhBSACQQFqIQIMAAsLIAWRC8ABBQF/AnsBfQF/AX0gAkF8cSEH/QwAAAAAAAAAAAAAAAAAAAAAIQRBACEDAkADQCADIAdPDQEgACADQQJ0av0ABAAgASADQQJ0av0ABAD95QEhBSAEIAUgBf3mAf3kASEEIANBBGohAwwACwsgBP0fACAE/R8BkiAE/R8CIAT9HwOSkiEGAkADQCADIAJPDQEgACADQQJ0aioCACABIANBAnRqKgIAkyEIIAYgCCAIlJIhBiADQQFqIQMMAAsLIAYLCwAgACABIAIQApEL";
3
+
4
+ // src/vector/simd.ts
5
+ var wasmExports = null;
6
+ var f32View = null;
7
+ function decodeBase64(base64) {
8
+ if (typeof Buffer !== "undefined") {
9
+ return new Uint8Array(Buffer.from(base64, "base64"));
10
+ }
11
+ const binary = atob(base64);
12
+ const bytes = new Uint8Array(binary.length);
13
+ for (let i = 0; i < binary.length; i++) {
14
+ bytes[i] = binary.charCodeAt(i);
15
+ }
16
+ return bytes;
17
+ }
18
+ try {
19
+ const bytes = decodeBase64(SIMD_DISTANCE_WASM_BASE64);
20
+ const wasmBuffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
21
+ if (typeof WebAssembly !== "undefined" && typeof WebAssembly.validate === "function" && WebAssembly.validate(wasmBuffer)) {
22
+ const module = new WebAssembly.Module(wasmBuffer);
23
+ const instance = new WebAssembly.Instance(module);
24
+ wasmExports = instance.exports;
25
+ f32View = new Float32Array(wasmExports.memory.buffer);
26
+ }
27
+ } catch {
28
+ wasmExports = null;
29
+ f32View = null;
30
+ }
31
+ var OFFSET_B_BYTES = 16384;
32
+ function ensureMemory(dim) {
33
+ if (!wasmExports || !f32View) return;
34
+ const needed = OFFSET_B_BYTES + dim * 4;
35
+ const currentSize = wasmExports.memory.buffer.byteLength;
36
+ if (needed > currentSize) {
37
+ const pagesNeeded = Math.ceil(needed / 65536);
38
+ const currentPages = currentSize / 65536;
39
+ wasmExports.memory.grow(pagesNeeded - currentPages);
40
+ f32View = new Float32Array(wasmExports.memory.buffer);
41
+ }
42
+ }
43
+ function getF32View() {
44
+ if (!f32View) {
45
+ throw new Error("SIMD memory is not initialized");
46
+ }
47
+ return f32View;
48
+ }
49
+ function copyOneVector(a) {
50
+ getF32View().set(a, 0);
51
+ }
52
+ function copyTwoVectors(a, b) {
53
+ const view = getF32View();
54
+ view.set(a, 0);
55
+ view.set(b, OFFSET_B_BYTES / 4);
56
+ }
57
+ function simdDotProduct(a, b) {
58
+ if (!wasmExports || !f32View) return null;
59
+ ensureMemory(a.length);
60
+ copyTwoVectors(a, b);
61
+ return wasmExports.dot_product(0, OFFSET_B_BYTES, a.length);
62
+ }
63
+ function simdMagnitude(a) {
64
+ if (!wasmExports || !f32View) return null;
65
+ ensureMemory(a.length);
66
+ copyOneVector(a);
67
+ return wasmExports.magnitude(0, a.length);
68
+ }
69
+ function simdEuclideanDistance(a, b) {
70
+ if (!wasmExports || !f32View) return null;
71
+ ensureMemory(a.length);
72
+ copyTwoVectors(a, b);
73
+ return wasmExports.euclidean_distance(0, OFFSET_B_BYTES, a.length);
74
+ }
75
+ function isSimdAvailable() {
76
+ return wasmExports !== null;
77
+ }
78
+
79
+ // src/core/heap.ts
80
+ function createMinHeap(compare) {
81
+ return createHeap(compare);
82
+ }
83
+ function createHeap(compare) {
84
+ const items = [];
85
+ function siftUp(idx) {
86
+ while (idx > 0) {
87
+ const parent = idx - 1 >> 1;
88
+ if (compare(items[idx], items[parent]) >= 0) break;
89
+ const tmp = items[idx];
90
+ items[idx] = items[parent];
91
+ items[parent] = tmp;
92
+ idx = parent;
93
+ }
94
+ }
95
+ function siftDown(idx) {
96
+ const len = items.length;
97
+ while (true) {
98
+ let smallest = idx;
99
+ const left = 2 * idx + 1;
100
+ const right = 2 * idx + 2;
101
+ if (left < len && compare(items[left], items[smallest]) < 0) smallest = left;
102
+ if (right < len && compare(items[right], items[smallest]) < 0) smallest = right;
103
+ if (smallest === idx) break;
104
+ const tmp = items[idx];
105
+ items[idx] = items[smallest];
106
+ items[smallest] = tmp;
107
+ idx = smallest;
108
+ }
109
+ }
110
+ return {
111
+ push(item) {
112
+ items.push(item);
113
+ siftUp(items.length - 1);
114
+ },
115
+ pop() {
116
+ if (items.length === 0) return void 0;
117
+ const top = items[0];
118
+ const last = items.pop();
119
+ if (items.length > 0) {
120
+ items[0] = last;
121
+ siftDown(0);
122
+ }
123
+ return top;
124
+ },
125
+ peek() {
126
+ return items[0];
127
+ },
128
+ get size() {
129
+ return items.length;
130
+ },
131
+ toSortedArray() {
132
+ return [...items].sort(compare);
133
+ }
134
+ };
135
+ }
136
+
137
+ // src/vector/similarity.ts
138
+ function magnitude(v) {
139
+ const result = simdMagnitude(v);
140
+ if (result !== null) return result;
141
+ let sum = 0;
142
+ for (let i = 0; i < v.length; i++) {
143
+ sum += v[i] * v[i];
144
+ }
145
+ return Math.sqrt(sum);
146
+ }
147
+ function cosineSimilarityWithMagnitudes(a, b, magA, magB) {
148
+ if (magA === 0 || magB === 0) return 0;
149
+ return dotProduct(a, b) / (magA * magB);
150
+ }
151
+ function dotProduct(a, b) {
152
+ const result = simdDotProduct(a, b);
153
+ if (result !== null) return result;
154
+ let sum = 0;
155
+ for (let i = 0; i < a.length; i++) {
156
+ sum += a[i] * b[i];
157
+ }
158
+ return sum;
159
+ }
160
+ function euclideanDistance(a, b) {
161
+ const result = simdEuclideanDistance(a, b);
162
+ if (result !== null) return result;
163
+ let sum = 0;
164
+ for (let i = 0; i < a.length; i++) {
165
+ const diff = a[i] - b[i];
166
+ sum += diff * diff;
167
+ }
168
+ return Math.sqrt(sum);
169
+ }
170
+
171
+ // src/vector/hnsw.ts
172
+ var MAX_LAYER_CAP = 32;
173
+ function toDistance(a, b, magA, magB, metric) {
174
+ switch (metric) {
175
+ case "cosine":
176
+ return 1 - cosineSimilarityWithMagnitudes(a, b, magA, magB);
177
+ case "dotProduct":
178
+ return -dotProduct(a, b);
179
+ case "euclidean":
180
+ return euclideanDistance(a, b);
181
+ }
182
+ }
183
+ function toScore(distance, metric) {
184
+ switch (metric) {
185
+ case "cosine":
186
+ return 1 - distance;
187
+ case "dotProduct":
188
+ return -distance;
189
+ case "euclidean":
190
+ return 1 / (1 + distance);
191
+ }
192
+ }
193
+ var distanceAsc = (a, b) => a.distance - b.distance;
194
+ var distanceDesc = (a, b) => b.distance - a.distance;
195
+ function createHNSWIndex(dimension, config) {
196
+ const M = config?.m ?? 16;
197
+ const Mmax0 = M * 2;
198
+ const efCons = config?.efConstruction ?? 200;
199
+ const buildMetric = config?.metric ?? "cosine";
200
+ const mL = 1 / Math.log(M);
201
+ const nodes = /* @__PURE__ */ new Map();
202
+ let entryPointId = null;
203
+ let topLayer = -1;
204
+ function randomLevel() {
205
+ let u = Math.random();
206
+ if (u === 0) u = Number.MIN_VALUE;
207
+ return Math.min(Math.floor(-Math.log(u) * mL), MAX_LAYER_CAP);
208
+ }
209
+ function nodeDistance(a, b, metric) {
210
+ return toDistance(a.vector, b.vector, a.mag, b.mag, metric);
211
+ }
212
+ function queryDistance(qVec, qMag, node, metric) {
213
+ return toDistance(qVec, node.vector, qMag, node.mag, metric);
214
+ }
215
+ function maxConns(layer) {
216
+ return layer === 0 ? Mmax0 : M;
217
+ }
218
+ function searchLayer(qVec, qMag, eps, ef, layer, metric) {
219
+ const visited = /* @__PURE__ */ new Set();
220
+ const candidates = createMinHeap(distanceAsc);
221
+ const results = createMinHeap(distanceDesc);
222
+ let furthestDist = Number.POSITIVE_INFINITY;
223
+ for (const epId of eps) {
224
+ if (visited.has(epId)) continue;
225
+ visited.add(epId);
226
+ const node = nodes.get(epId);
227
+ if (!node) continue;
228
+ const dist = queryDistance(qVec, qMag, node, metric);
229
+ const pair = { docId: epId, distance: dist };
230
+ candidates.push(pair);
231
+ results.push(pair);
232
+ if (results.size > ef) {
233
+ results.pop();
234
+ }
235
+ }
236
+ const topResult = results.peek();
237
+ if (topResult) furthestDist = topResult.distance;
238
+ while (candidates.size > 0) {
239
+ const nearest = candidates.pop();
240
+ if (!nearest) break;
241
+ if (nearest.distance > furthestDist) break;
242
+ const node = nodes.get(nearest.docId);
243
+ if (!node || layer >= node.connections.length) continue;
244
+ for (const neighborId of node.connections[layer]) {
245
+ if (visited.has(neighborId)) continue;
246
+ visited.add(neighborId);
247
+ const neighborNode = nodes.get(neighborId);
248
+ if (!neighborNode) continue;
249
+ const dist = queryDistance(qVec, qMag, neighborNode, metric);
250
+ if (dist < furthestDist || results.size < ef) {
251
+ const pair = { docId: neighborId, distance: dist };
252
+ candidates.push(pair);
253
+ results.push(pair);
254
+ if (results.size > ef) {
255
+ results.pop();
256
+ }
257
+ const newTop = results.peek();
258
+ if (newTop) furthestDist = newTop.distance;
259
+ }
260
+ }
261
+ }
262
+ return results.toSortedArray();
263
+ }
264
+ function selectNeighborsHeuristic(targetVec, targetMag, candidates, maxConnections, layer, metric, extendCandidates, keepPruned) {
265
+ const working = [...candidates].sort((a, b) => a.distance - b.distance);
266
+ const selected = [];
267
+ const discarded = [];
268
+ for (const candidate of working) {
269
+ if (selected.length >= maxConnections) break;
270
+ let accepted = true;
271
+ for (const sel of selected) {
272
+ const selNode = nodes.get(sel.docId);
273
+ const candNode = nodes.get(candidate.docId);
274
+ if (!selNode || !candNode) continue;
275
+ const distBetween = nodeDistance(candNode, selNode, metric);
276
+ if (candidate.distance >= distBetween) {
277
+ accepted = false;
278
+ break;
279
+ }
280
+ }
281
+ if (accepted) {
282
+ selected.push(candidate);
283
+ } else {
284
+ discarded.push(candidate);
285
+ }
286
+ }
287
+ {
288
+ for (const disc of discarded) {
289
+ if (selected.length >= maxConnections) break;
290
+ selected.push(disc);
291
+ }
292
+ }
293
+ return selected;
294
+ }
295
+ function pruneConnections(node, layer, metric) {
296
+ const mc = maxConns(layer);
297
+ if (node.connections[layer].size <= mc) return;
298
+ const conns = [];
299
+ for (const connId of node.connections[layer]) {
300
+ const connNode = nodes.get(connId);
301
+ if (!connNode) continue;
302
+ conns.push({
303
+ docId: connId,
304
+ distance: nodeDistance(node, connNode, metric)
305
+ });
306
+ }
307
+ const pruned = selectNeighborsHeuristic(node.vector, node.mag, conns, mc, layer, metric);
308
+ node.connections[layer] = new Set(pruned.map((p) => p.docId));
309
+ }
310
+ function insert(docId, vector) {
311
+ if (vector.length !== dimension) {
312
+ throw new Error(`Vector dimension mismatch: expected ${dimension}, got ${vector.length}`);
313
+ }
314
+ if (nodes.has(docId)) {
315
+ remove(docId);
316
+ }
317
+ const mag = magnitude(vector);
318
+ const l = randomLevel();
319
+ const node = {
320
+ docId,
321
+ vector,
322
+ mag,
323
+ maxLayer: l,
324
+ connections: Array.from({ length: l + 1 }, () => /* @__PURE__ */ new Set())
325
+ };
326
+ nodes.set(docId, node);
327
+ if (entryPointId === null) {
328
+ entryPointId = docId;
329
+ topLayer = l;
330
+ return;
331
+ }
332
+ const metric = buildMetric;
333
+ let currentEPs = [entryPointId];
334
+ for (let layer = topLayer; layer > l; layer--) {
335
+ const nearest = searchLayer(vector, mag, currentEPs, 1, layer, metric);
336
+ if (nearest.length > 0) {
337
+ currentEPs = [nearest[0].docId];
338
+ }
339
+ }
340
+ for (let layer = Math.min(l, topLayer); layer >= 0; layer--) {
341
+ const mc = maxConns(layer);
342
+ const nearest = searchLayer(vector, mag, currentEPs, efCons, layer, metric);
343
+ const neighbors = selectNeighborsHeuristic(vector, mag, nearest, mc, layer, metric);
344
+ for (const neighbor of neighbors) {
345
+ node.connections[layer].add(neighbor.docId);
346
+ const neighborNode = nodes.get(neighbor.docId);
347
+ if (neighborNode && layer < neighborNode.connections.length) {
348
+ neighborNode.connections[layer].add(docId);
349
+ pruneConnections(neighborNode, layer, metric);
350
+ }
351
+ }
352
+ if (nearest.length > 0) {
353
+ currentEPs = nearest.map((n) => n.docId);
354
+ }
355
+ }
356
+ if (l > topLayer) {
357
+ entryPointId = docId;
358
+ topLayer = l;
359
+ }
360
+ }
361
+ function remove(docId) {
362
+ const node = nodes.get(docId);
363
+ if (!node) return;
364
+ const metric = buildMetric;
365
+ for (let layer = 0; layer <= node.maxLayer; layer++) {
366
+ if (layer >= node.connections.length) continue;
367
+ const formerNeighborIds = new Set(node.connections[layer]);
368
+ for (const neighborId of formerNeighborIds) {
369
+ const neighborNode = nodes.get(neighborId);
370
+ if (neighborNode && layer < neighborNode.connections.length) {
371
+ neighborNode.connections[layer].delete(docId);
372
+ }
373
+ }
374
+ for (const neighborId of formerNeighborIds) {
375
+ const neighborNode = nodes.get(neighborId);
376
+ if (!neighborNode || layer >= neighborNode.connections.length) continue;
377
+ const mc = maxConns(layer);
378
+ if (neighborNode.connections[layer].size >= mc) continue;
379
+ const candidateIds = new Set(neighborNode.connections[layer]);
380
+ for (const otherId of formerNeighborIds) {
381
+ if (otherId !== neighborId && otherId !== docId) {
382
+ candidateIds.add(otherId);
383
+ }
384
+ }
385
+ const candidates = [];
386
+ for (const candId of candidateIds) {
387
+ const candNode = nodes.get(candId);
388
+ if (!candNode) continue;
389
+ candidates.push({
390
+ docId: candId,
391
+ distance: nodeDistance(neighborNode, candNode, metric)
392
+ });
393
+ }
394
+ const selected = selectNeighborsHeuristic(
395
+ neighborNode.vector,
396
+ neighborNode.mag,
397
+ candidates,
398
+ mc,
399
+ layer,
400
+ metric);
401
+ const newConns = new Set(selected.map((s) => s.docId));
402
+ neighborNode.connections[layer] = newConns;
403
+ for (const newConnId of newConns) {
404
+ const newConnNode = nodes.get(newConnId);
405
+ if (newConnNode && layer < newConnNode.connections.length) {
406
+ newConnNode.connections[layer].add(neighborId);
407
+ pruneConnections(newConnNode, layer, metric);
408
+ }
409
+ }
410
+ }
411
+ }
412
+ nodes.delete(docId);
413
+ if (entryPointId === docId) {
414
+ if (nodes.size === 0) {
415
+ entryPointId = null;
416
+ topLayer = -1;
417
+ return;
418
+ }
419
+ let newEntryId = null;
420
+ let newTopLayer = -1;
421
+ for (const [nId, n] of nodes) {
422
+ if (n.maxLayer > newTopLayer) {
423
+ newTopLayer = n.maxLayer;
424
+ newEntryId = nId;
425
+ }
426
+ }
427
+ entryPointId = newEntryId;
428
+ topLayer = newTopLayer;
429
+ }
430
+ }
431
+ function search(query, k, searchMetric, minSimilarity, filterDocIds, efSearch) {
432
+ if (query.length !== dimension) {
433
+ throw new Error(`Query dimension mismatch: expected ${dimension}, got ${query.length}`);
434
+ }
435
+ if (entryPointId === null || nodes.size === 0) {
436
+ return [];
437
+ }
438
+ let ef = Math.max(efSearch ?? 50, k);
439
+ if (filterDocIds && filterDocIds.size < nodes.size) {
440
+ const selectivity = filterDocIds.size / nodes.size;
441
+ ef = Math.max(ef, Math.ceil(k / Math.max(selectivity, 0.01)));
442
+ ef = Math.min(ef, nodes.size);
443
+ }
444
+ const qMag = magnitude(query);
445
+ let currentEPs = [entryPointId];
446
+ for (let layer = topLayer; layer >= 1; layer--) {
447
+ const nearest = searchLayer(query, qMag, currentEPs, 1, layer, searchMetric);
448
+ if (nearest.length > 0) {
449
+ currentEPs = [nearest[0].docId];
450
+ }
451
+ }
452
+ const candidates = searchLayer(query, qMag, currentEPs, ef, 0, searchMetric);
453
+ const results = [];
454
+ for (const cand of candidates) {
455
+ if (filterDocIds && !filterDocIds.has(cand.docId)) continue;
456
+ const score = toScore(cand.distance, searchMetric);
457
+ if (score < minSimilarity) continue;
458
+ results.push({
459
+ docId: cand.docId,
460
+ score,
461
+ termFrequencies: {},
462
+ fieldLengths: {},
463
+ idf: {}
464
+ });
465
+ }
466
+ results.sort((a, b) => b.score - a.score);
467
+ return results.slice(0, k);
468
+ }
469
+ function serialize() {
470
+ const nodeArray = [];
471
+ for (const [docId, node] of nodes) {
472
+ const layerConns = [];
473
+ for (let layer = 0; layer < node.connections.length; layer++) {
474
+ if (node.connections[layer].size > 0) {
475
+ layerConns.push([layer, Array.from(node.connections[layer])]);
476
+ }
477
+ }
478
+ nodeArray.push([docId, node.maxLayer, layerConns]);
479
+ }
480
+ return {
481
+ entryPoint: entryPointId ?? "",
482
+ maxLayer: topLayer,
483
+ m: M,
484
+ efConstruction: efCons,
485
+ metric: buildMetric,
486
+ nodes: nodeArray
487
+ };
488
+ }
489
+ function deserialize(data, vectors) {
490
+ nodes.clear();
491
+ for (const [docId, maxLayer, layerConns] of data.nodes) {
492
+ const vecData = vectors.get(docId);
493
+ if (!vecData) continue;
494
+ const connections = Array.from({ length: maxLayer + 1 }, () => /* @__PURE__ */ new Set());
495
+ for (const [layer, neighbors] of layerConns) {
496
+ if (layer < connections.length) {
497
+ connections[layer] = new Set(neighbors);
498
+ }
499
+ }
500
+ nodes.set(docId, {
501
+ docId,
502
+ vector: vecData.vector,
503
+ mag: vecData.mag,
504
+ maxLayer,
505
+ connections
506
+ });
507
+ }
508
+ if (data.entryPoint && nodes.has(data.entryPoint)) {
509
+ entryPointId = data.entryPoint;
510
+ topLayer = data.maxLayer;
511
+ } else if (nodes.size > 0) {
512
+ let bestId = null;
513
+ let bestLayer = -1;
514
+ for (const [nId, n] of nodes) {
515
+ if (n.maxLayer > bestLayer) {
516
+ bestLayer = n.maxLayer;
517
+ bestId = nId;
518
+ }
519
+ }
520
+ entryPointId = bestId;
521
+ topLayer = bestLayer;
522
+ } else {
523
+ entryPointId = null;
524
+ topLayer = -1;
525
+ }
526
+ }
527
+ function clear() {
528
+ nodes.clear();
529
+ entryPointId = null;
530
+ topLayer = -1;
531
+ }
532
+ function* entriesIterator() {
533
+ for (const [docId, node] of nodes) {
534
+ yield [docId, { docId, vector: node.vector, magnitude: node.mag }];
535
+ }
536
+ }
537
+ return {
538
+ get dimension() {
539
+ return dimension;
540
+ },
541
+ get size() {
542
+ return nodes.size;
543
+ },
544
+ get entryPointId() {
545
+ return entryPointId;
546
+ },
547
+ get topLayer() {
548
+ return topLayer;
549
+ },
550
+ get m() {
551
+ return M;
552
+ },
553
+ get efConstruction() {
554
+ return efCons;
555
+ },
556
+ get metric() {
557
+ return buildMetric;
558
+ },
559
+ insert,
560
+ remove,
561
+ has: (docId) => nodes.has(docId),
562
+ search,
563
+ clear,
564
+ entries: entriesIterator,
565
+ serialize,
566
+ deserialize
567
+ };
568
+ }
569
+
570
+ export { cosineSimilarityWithMagnitudes, createHNSWIndex, dotProduct, euclideanDistance, isSimdAvailable, magnitude };