md-semantic-search 2.0.0 → 2.1.0
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 +1 -1
- package/RESEARCH.md +6 -2
- package/dist/hnsw.d.ts +9 -0
- package/dist/hnsw.js +72 -0
- package/dist/indexer.js +43 -12
- package/package.json +1 -1
- package/src/hnsw.ts +76 -0
- package/src/indexer.ts +43 -8
package/README.md
CHANGED
|
@@ -679,7 +679,7 @@ Add to your `claude_desktop_config.json`:
|
|
|
679
679
|
| `--multi-query <n>` | Multi-query expansion (issue #203): N query variants (original + N−1 LLM paraphrases) fused by union-RRF; needs `--llm-endpoint`, degrades silently without it. |
|
|
680
680
|
| `--graph-boost <n>` | Weight for Obsidian wikilink graph ranking and PageRank prior (search; default 0). Incorporates document centrality and 2-hop contextual propagation. |
|
|
681
681
|
| `--filter <expr>` | Rich boolean filter across frontmatter tags and properties (e.g. `tag:engineering AND status != archived`). |
|
|
682
|
-
| `--ann [ivf\|hnsw]` | Approximate nearest neighbor search (search; auto-enables for large corpora). Engines (issue #201): `ivf` (k-means, the default sidecar) or `hnsw` (graph — no training phase, 100% measured recall at equal-or-lower query latency, `hnsw.json` sidecar, corrupt → brute-force fallback). Bare `--ann` prefers an existing sidecar. |
|
|
682
|
+
| `--ann [ivf\|hnsw]` | Approximate nearest neighbor search (search; auto-enables for large corpora). Engines (issue #201): `ivf` (k-means, the default sidecar) or `hnsw` (graph — no training phase, 100% measured recall at equal-or-lower query latency, `hnsw.json` sidecar, corrupt → brute-force fallback; watch rebuilds reuse the persisted graph via chunk-hash remapping instead of rebuilding it — issue #223). Bare `--ann` prefers an existing sidecar. |
|
|
683
683
|
| `--nprobe <n>` | Number of nearest centroid clusters to probe during ANN search (default 8). |
|
|
684
684
|
| `--port <n>` | HTTP port for `serve` (default 8747, or `MDSS_PORT`). |
|
|
685
685
|
| `--host <ip>` | Bind address for `serve` (default `127.0.0.1` — loopback only; use `0.0.0.0` to expose on the LAN, or `MDSS_HOST`). |
|
package/RESEARCH.md
CHANGED
|
@@ -421,8 +421,12 @@ corpora keeps **IVF as the default engine**; the criterion's second branch
|
|
|
421
421
|
2. Bare `--ann` and the automatic threshold path stay on IVF until either
|
|
422
422
|
(a) the JS graph build gets an order of magnitude cheaper (worker
|
|
423
423
|
parallelism, WASM SIMD — see §13), or (b) incremental watch rebuilds
|
|
424
|
-
start reusing the persisted graph (
|
|
425
|
-
|
|
424
|
+
start reusing the persisted graph. **(b) is now implemented (issue
|
|
425
|
+
#223):** a watch rebuild remaps the persisted graph onto the new chunk
|
|
426
|
+
numbering via `chunkHash` (`remapHnsw` — vector reuse is hash-keyed, so
|
|
427
|
+
every carried edge distance stays valid), tombstones the gone nodes and
|
|
428
|
+
inserts only the new ones; the build-side gap now applies to FULL builds
|
|
429
|
+
only (first run, engine/budget switch, entry-point-unanchorable remap).
|
|
426
430
|
3. `--ef <n>` tunes the HNSW search breadth (default 64; recall saturates
|
|
427
431
|
at 100% already at ef 16 on the bench corpora — the default leaves
|
|
428
432
|
headroom for noisier real-world distributions).
|
package/dist/hnsw.d.ts
CHANGED
|
@@ -94,3 +94,12 @@ export declare function serializeHnsw(hnsw: HnswIndex): SerializedHnsw;
|
|
|
94
94
|
* falls back to brute force).
|
|
95
95
|
*/
|
|
96
96
|
export declare function deserializeHnsw(raw: unknown, expectedChunksCount?: number): HnswIndex;
|
|
97
|
+
/**
|
|
98
|
+
* issue #223: carry a persisted graph across a watch rebuild. `mapping[oldIdx]`
|
|
99
|
+
* is the chunk's NEW index (undefined = the chunk is gone → tombstone); nodes
|
|
100
|
+
* carry over because vector reuse is keyed by chunkHash — a surviving chunk
|
|
101
|
+
* keeps its exact vector, so every stored edge distance stays valid.
|
|
102
|
+
* Returns null when the remap cannot produce a navigable graph (e.g. the
|
|
103
|
+
* entry point did not survive) — the caller falls back to a full rebuild.
|
|
104
|
+
*/
|
|
105
|
+
export declare function remapHnsw(hnsw: HnswIndex, mapping: Array<number | undefined>, newCount: number): HnswIndex | null;
|
package/dist/hnsw.js
CHANGED
|
@@ -324,3 +324,75 @@ export function deserializeHnsw(raw, expectedChunksCount) {
|
|
|
324
324
|
}
|
|
325
325
|
return { dim, M, seed, entryPoint, maxLevel, levels, neighbors, deleted };
|
|
326
326
|
}
|
|
327
|
+
/**
|
|
328
|
+
* issue #223: carry a persisted graph across a watch rebuild. `mapping[oldIdx]`
|
|
329
|
+
* is the chunk's NEW index (undefined = the chunk is gone → tombstone); nodes
|
|
330
|
+
* carry over because vector reuse is keyed by chunkHash — a surviving chunk
|
|
331
|
+
* keeps its exact vector, so every stored edge distance stays valid.
|
|
332
|
+
* Returns null when the remap cannot produce a navigable graph (e.g. the
|
|
333
|
+
* entry point did not survive) — the caller falls back to a full rebuild.
|
|
334
|
+
*/
|
|
335
|
+
export function remapHnsw(hnsw, mapping, newCount) {
|
|
336
|
+
if (newCount <= 0)
|
|
337
|
+
return null;
|
|
338
|
+
// every new index may receive at most one old node
|
|
339
|
+
const taken = new Set();
|
|
340
|
+
const remapOf = new Map();
|
|
341
|
+
for (let oldIdx = 0; oldIdx < mapping.length; oldIdx++) {
|
|
342
|
+
const newIdx = mapping[oldIdx];
|
|
343
|
+
if (newIdx === undefined || newIdx < 0 || newIdx >= newCount)
|
|
344
|
+
continue;
|
|
345
|
+
if (taken.has(newIdx))
|
|
346
|
+
continue;
|
|
347
|
+
taken.add(newIdx);
|
|
348
|
+
remapOf.set(oldIdx, newIdx);
|
|
349
|
+
}
|
|
350
|
+
// the entry point does NOT need to survive — it re-anchors below
|
|
351
|
+
const levels = new Array(newCount);
|
|
352
|
+
const neighbors = new Array(newCount);
|
|
353
|
+
const deleted = new Set();
|
|
354
|
+
for (const [oldIdx, newIdx] of remapOf) {
|
|
355
|
+
levels[newIdx] = hnsw.levels[oldIdx];
|
|
356
|
+
neighbors[newIdx] = (hnsw.neighbors[oldIdx] ?? []).map((links) => (links ?? [])
|
|
357
|
+
.map((nb) => remapOf.get(nb))
|
|
358
|
+
.filter((nb) => nb !== undefined));
|
|
359
|
+
if (hnsw.deleted.has(oldIdx))
|
|
360
|
+
deleted.add(newIdx);
|
|
361
|
+
}
|
|
362
|
+
// structural holes would break insertHnsw/serialize — unmapped new indices
|
|
363
|
+
// are placeholders the caller fills with insertHnsw immediately after
|
|
364
|
+
for (let i = 0; i < newCount; i++) {
|
|
365
|
+
if (levels[i] === undefined) {
|
|
366
|
+
levels[i] = 0;
|
|
367
|
+
neighbors[i] = [[]];
|
|
368
|
+
deleted.add(i); // invisible until a real node is inserted over it
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
// the entry point must sit on the TOP layer: after a partial remap the
|
|
372
|
+
// old entry may carry a lower level, or some survivor may carry a higher
|
|
373
|
+
// one — re-anchor deterministically to the lowest-index survivor at maxLevel
|
|
374
|
+
let maxLevel = -1;
|
|
375
|
+
for (const newIdx of remapOf.values())
|
|
376
|
+
maxLevel = Math.max(maxLevel, levels[newIdx]);
|
|
377
|
+
if (maxLevel < 0)
|
|
378
|
+
return null;
|
|
379
|
+
let entryPoint = -1;
|
|
380
|
+
for (let i = 0; i < newCount; i++) {
|
|
381
|
+
if (!deleted.has(i) && levels[i] === maxLevel) {
|
|
382
|
+
entryPoint = i;
|
|
383
|
+
break;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
if (entryPoint < 0)
|
|
387
|
+
return null;
|
|
388
|
+
return {
|
|
389
|
+
dim: hnsw.dim,
|
|
390
|
+
M: hnsw.M,
|
|
391
|
+
seed: hnsw.seed,
|
|
392
|
+
entryPoint,
|
|
393
|
+
maxLevel,
|
|
394
|
+
levels,
|
|
395
|
+
neighbors,
|
|
396
|
+
deleted,
|
|
397
|
+
};
|
|
398
|
+
}
|
package/dist/indexer.js
CHANGED
|
@@ -15,7 +15,7 @@ import { validateIndexEnvelope, validateNumericVector } from './index-format.js'
|
|
|
15
15
|
import { analyzeLexicalDocument, buildLexicalIndex, lexicalIdentity, reverseLexicalIndex, validateLexicalIndex, } from './lexical.js';
|
|
16
16
|
import { embeddingAdapterFingerprint, legacyEmbeddingAdapterFingerprint } from './models.js';
|
|
17
17
|
import { trainIVF, serializeIVF, ANN_THRESHOLD } from './ivf.js';
|
|
18
|
-
import { buildHnsw, serializeHnsw } from './hnsw.js';
|
|
18
|
+
import { buildHnsw, insertHnsw, remapHnsw, serializeHnsw, deserializeHnsw } from './hnsw.js';
|
|
19
19
|
import { serializeBinaryIndex } from './binary-format.js';
|
|
20
20
|
import { defaultChunkTokenBudget } from './markdown-parser.js';
|
|
21
21
|
const md5 = (s) => crypto.createHash('md5').update(s).digest('hex');
|
|
@@ -484,17 +484,48 @@ async function _buildIndexInner({ db, indexDir, cacheDir, modelName, dimensions,
|
|
|
484
484
|
if (buildSidecar) {
|
|
485
485
|
const rawVecs = chunks.map((c) => (c.vec instanceof Float32Array ? c.vec : Float32Array.from(c.vec)));
|
|
486
486
|
if (annEngine === 'hnsw') {
|
|
487
|
-
const
|
|
488
|
-
vectors
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
487
|
+
const hnswDistance = (vectors) => (a, b) => {
|
|
488
|
+
const bv = vectors[b];
|
|
489
|
+
let dot = 0;
|
|
490
|
+
for (let i = 0; i < a.length; i++)
|
|
491
|
+
dot += a[i] * bv[i];
|
|
492
|
+
return 1 - dot;
|
|
493
|
+
};
|
|
494
|
+
// issue #223: reuse the persisted graph across watch rebuilds — remap
|
|
495
|
+
// surviving nodes onto the new chunk numbering via chunkHash (vector
|
|
496
|
+
// reuse is hash-keyed, so every carried edge distance stays valid),
|
|
497
|
+
// tombstone the gone ones, insert the new ones. Full rebuild only when
|
|
498
|
+
// no valid previous graph exists (first run, engine/budget switch) or
|
|
499
|
+
// the remap cannot anchor an entry point.
|
|
500
|
+
let hnswIndex = null;
|
|
501
|
+
if (!chunkerChanged && oldIndex.chunks.length > 0) {
|
|
502
|
+
try {
|
|
503
|
+
const prev = deserializeHnsw(JSON.parse(fs.readFileSync(hnswPath, 'utf8')), oldIndex.chunks.length);
|
|
504
|
+
if (prev.levels.length === oldIndex.chunks.length) {
|
|
505
|
+
const newIdxByHash = new Map();
|
|
506
|
+
chunks.forEach((c, newIdx) => newIdxByHash.set(c.chunkHash || chunkHash(model, c), newIdx));
|
|
507
|
+
const mapping = oldIndex.chunks.map((c) => newIdxByHash.get(c.chunkHash || chunkHash(model, c)));
|
|
508
|
+
hnswIndex = remapHnsw(prev, mapping, chunks.length);
|
|
509
|
+
if (hnswIndex) {
|
|
510
|
+
const inserted = [];
|
|
511
|
+
for (let newIdx = 0; newIdx < chunks.length; newIdx++) {
|
|
512
|
+
if (hnswIndex.levels[newIdx] !== undefined && !hnswIndex.deleted.has(newIdx))
|
|
513
|
+
continue;
|
|
514
|
+
insertHnsw(hnswIndex, newIdx, hnswDistance(rawVecs), rawVecs);
|
|
515
|
+
inserted.push(newIdx);
|
|
516
|
+
}
|
|
517
|
+
log(`hnsw: reused persisted graph — ` +
|
|
518
|
+
`${chunks.length - inserted.length} surviving node(s), ${inserted.length} inserted`);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
catch {
|
|
523
|
+
hnswIndex = null; // corrupt/foreign sidecar — full rebuild below
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
if (!hnswIndex) {
|
|
527
|
+
hnswIndex = buildHnsw(rawVecs, { vectors: rawVecs, distance: hnswDistance(rawVecs) });
|
|
528
|
+
}
|
|
498
529
|
atomicWrite(hnswPath, JSON.stringify(serializeHnsw(hnswIndex)));
|
|
499
530
|
if (fs.existsSync(ivfPath)) {
|
|
500
531
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "md-semantic-search",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "Local, private semantic (vector) search over any folder of Markdown files. Finds passages by meaning — cross-lingual — using transformers.js. No API keys, no cloud, no vector database.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
package/src/hnsw.ts
CHANGED
|
@@ -412,3 +412,79 @@ export function deserializeHnsw(raw: unknown, expectedChunksCount?: number): Hns
|
|
|
412
412
|
}
|
|
413
413
|
return { dim, M, seed, entryPoint, maxLevel, levels, neighbors, deleted };
|
|
414
414
|
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* issue #223: carry a persisted graph across a watch rebuild. `mapping[oldIdx]`
|
|
418
|
+
* is the chunk's NEW index (undefined = the chunk is gone → tombstone); nodes
|
|
419
|
+
* carry over because vector reuse is keyed by chunkHash — a surviving chunk
|
|
420
|
+
* keeps its exact vector, so every stored edge distance stays valid.
|
|
421
|
+
* Returns null when the remap cannot produce a navigable graph (e.g. the
|
|
422
|
+
* entry point did not survive) — the caller falls back to a full rebuild.
|
|
423
|
+
*/
|
|
424
|
+
export function remapHnsw(
|
|
425
|
+
hnsw: HnswIndex,
|
|
426
|
+
mapping: Array<number | undefined>,
|
|
427
|
+
newCount: number,
|
|
428
|
+
): HnswIndex | null {
|
|
429
|
+
if (newCount <= 0) return null;
|
|
430
|
+
// every new index may receive at most one old node
|
|
431
|
+
const taken = new Set<number>();
|
|
432
|
+
const remapOf = new Map<number, number>();
|
|
433
|
+
for (let oldIdx = 0; oldIdx < mapping.length; oldIdx++) {
|
|
434
|
+
const newIdx = mapping[oldIdx];
|
|
435
|
+
if (newIdx === undefined || newIdx < 0 || newIdx >= newCount) continue;
|
|
436
|
+
if (taken.has(newIdx)) continue;
|
|
437
|
+
taken.add(newIdx);
|
|
438
|
+
remapOf.set(oldIdx, newIdx);
|
|
439
|
+
}
|
|
440
|
+
// the entry point does NOT need to survive — it re-anchors below
|
|
441
|
+
|
|
442
|
+
const levels: number[] = new Array(newCount);
|
|
443
|
+
const neighbors: number[][][] = new Array(newCount);
|
|
444
|
+
const deleted = new Set<number>();
|
|
445
|
+
|
|
446
|
+
for (const [oldIdx, newIdx] of remapOf) {
|
|
447
|
+
levels[newIdx] = hnsw.levels[oldIdx];
|
|
448
|
+
neighbors[newIdx] = (hnsw.neighbors[oldIdx] ?? []).map((links) =>
|
|
449
|
+
(links ?? [])
|
|
450
|
+
.map((nb) => remapOf.get(nb))
|
|
451
|
+
.filter((nb): nb is number => nb !== undefined),
|
|
452
|
+
);
|
|
453
|
+
if (hnsw.deleted.has(oldIdx)) deleted.add(newIdx);
|
|
454
|
+
}
|
|
455
|
+
// structural holes would break insertHnsw/serialize — unmapped new indices
|
|
456
|
+
// are placeholders the caller fills with insertHnsw immediately after
|
|
457
|
+
for (let i = 0; i < newCount; i++) {
|
|
458
|
+
if (levels[i] === undefined) {
|
|
459
|
+
levels[i] = 0;
|
|
460
|
+
neighbors[i] = [[]];
|
|
461
|
+
deleted.add(i); // invisible until a real node is inserted over it
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// the entry point must sit on the TOP layer: after a partial remap the
|
|
466
|
+
// old entry may carry a lower level, or some survivor may carry a higher
|
|
467
|
+
// one — re-anchor deterministically to the lowest-index survivor at maxLevel
|
|
468
|
+
let maxLevel = -1;
|
|
469
|
+
for (const newIdx of remapOf.values()) maxLevel = Math.max(maxLevel, levels[newIdx]);
|
|
470
|
+
if (maxLevel < 0) return null;
|
|
471
|
+
let entryPoint = -1;
|
|
472
|
+
for (let i = 0; i < newCount; i++) {
|
|
473
|
+
if (!deleted.has(i) && levels[i] === maxLevel) {
|
|
474
|
+
entryPoint = i;
|
|
475
|
+
break;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
if (entryPoint < 0) return null;
|
|
479
|
+
|
|
480
|
+
return {
|
|
481
|
+
dim: hnsw.dim,
|
|
482
|
+
M: hnsw.M,
|
|
483
|
+
seed: hnsw.seed,
|
|
484
|
+
entryPoint,
|
|
485
|
+
maxLevel,
|
|
486
|
+
levels,
|
|
487
|
+
neighbors,
|
|
488
|
+
deleted,
|
|
489
|
+
};
|
|
490
|
+
}
|
package/src/indexer.ts
CHANGED
|
@@ -36,7 +36,7 @@ import {
|
|
|
36
36
|
} from './lexical.js';
|
|
37
37
|
import { embeddingAdapterFingerprint, legacyEmbeddingAdapterFingerprint } from './models.js';
|
|
38
38
|
import { trainIVF, serializeIVF, ANN_THRESHOLD } from './ivf.js';
|
|
39
|
-
import { buildHnsw, serializeHnsw, HnswIndex } from './hnsw.js';
|
|
39
|
+
import { buildHnsw, insertHnsw, remapHnsw, serializeHnsw, deserializeHnsw, HnswIndex } from './hnsw.js';
|
|
40
40
|
import { DocumentMetadata } from './frontmatter.js';
|
|
41
41
|
import { serializeBinaryIndex } from './binary-format.js';
|
|
42
42
|
import { defaultChunkTokenBudget } from './markdown-parser.js';
|
|
@@ -658,16 +658,51 @@ async function _buildIndexInner({
|
|
|
658
658
|
if (buildSidecar) {
|
|
659
659
|
const rawVecs = chunks.map((c) => (c.vec instanceof Float32Array ? c.vec : Float32Array.from(c.vec as number[])));
|
|
660
660
|
if (annEngine === 'hnsw') {
|
|
661
|
-
const
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
distance: (a, b) => {
|
|
665
|
-
const bv = rawVecs[b];
|
|
661
|
+
const hnswDistance = (vectors: Array<Float32Array | number[]>) =>
|
|
662
|
+
(a: Float32Array | Int8Array | number[], b: number) => {
|
|
663
|
+
const bv = vectors[b];
|
|
666
664
|
let dot = 0;
|
|
667
665
|
for (let i = 0; i < a.length; i++) dot += (a as ArrayLike<number>)[i] * (bv as ArrayLike<number>)[i];
|
|
668
666
|
return 1 - dot;
|
|
669
|
-
}
|
|
670
|
-
|
|
667
|
+
};
|
|
668
|
+
|
|
669
|
+
// issue #223: reuse the persisted graph across watch rebuilds — remap
|
|
670
|
+
// surviving nodes onto the new chunk numbering via chunkHash (vector
|
|
671
|
+
// reuse is hash-keyed, so every carried edge distance stays valid),
|
|
672
|
+
// tombstone the gone ones, insert the new ones. Full rebuild only when
|
|
673
|
+
// no valid previous graph exists (first run, engine/budget switch) or
|
|
674
|
+
// the remap cannot anchor an entry point.
|
|
675
|
+
let hnswIndex: HnswIndex | null = null;
|
|
676
|
+
if (!chunkerChanged && oldIndex.chunks.length > 0) {
|
|
677
|
+
try {
|
|
678
|
+
const prev = deserializeHnsw(JSON.parse(fs.readFileSync(hnswPath, 'utf8')), oldIndex.chunks.length);
|
|
679
|
+
if (prev.levels.length === oldIndex.chunks.length) {
|
|
680
|
+
const newIdxByHash = new Map<string, number>();
|
|
681
|
+
chunks.forEach((c, newIdx) => newIdxByHash.set(c.chunkHash || chunkHash(model, c), newIdx));
|
|
682
|
+
const mapping: Array<number | undefined> = oldIndex.chunks.map((c) =>
|
|
683
|
+
newIdxByHash.get(c.chunkHash || chunkHash(model, c)),
|
|
684
|
+
);
|
|
685
|
+
hnswIndex = remapHnsw(prev, mapping, chunks.length);
|
|
686
|
+
if (hnswIndex) {
|
|
687
|
+
const inserted: number[] = [];
|
|
688
|
+
for (let newIdx = 0; newIdx < chunks.length; newIdx++) {
|
|
689
|
+
if (hnswIndex.levels[newIdx] !== undefined && !hnswIndex.deleted.has(newIdx)) continue;
|
|
690
|
+
insertHnsw(hnswIndex, newIdx, hnswDistance(rawVecs), rawVecs);
|
|
691
|
+
inserted.push(newIdx);
|
|
692
|
+
}
|
|
693
|
+
log(
|
|
694
|
+
`hnsw: reused persisted graph — ` +
|
|
695
|
+
`${chunks.length - inserted.length} surviving node(s), ${inserted.length} inserted`,
|
|
696
|
+
);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
} catch {
|
|
700
|
+
hnswIndex = null; // corrupt/foreign sidecar — full rebuild below
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
if (!hnswIndex) {
|
|
704
|
+
hnswIndex = buildHnsw(rawVecs, { vectors: rawVecs, distance: hnswDistance(rawVecs) });
|
|
705
|
+
}
|
|
671
706
|
atomicWrite(hnswPath, JSON.stringify(serializeHnsw(hnswIndex)));
|
|
672
707
|
if (fs.existsSync(ivfPath)) {
|
|
673
708
|
try {
|