md-semantic-search 1.1.0 → 2.0.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.
Files changed (64) hide show
  1. package/README.md +88 -14
  2. package/RESEARCH.md +182 -0
  3. package/bin/cli.mjs +197 -8
  4. package/dist/binary-format.js +9 -0
  5. package/dist/completions.js +5 -0
  6. package/dist/core.d.ts +16 -4
  7. package/dist/core.js +105 -14
  8. package/dist/federation.d.ts +16 -1
  9. package/dist/federation.js +33 -16
  10. package/dist/filter.d.ts +7 -0
  11. package/dist/filter.js +13 -0
  12. package/dist/hnsw.d.ts +96 -0
  13. package/dist/hnsw.js +326 -0
  14. package/dist/indexer.d.ts +17 -1
  15. package/dist/indexer.js +91 -17
  16. package/dist/lexical.d.ts +10 -0
  17. package/dist/lexical.js +98 -15
  18. package/dist/markdown-parser.d.ts +62 -0
  19. package/dist/markdown-parser.js +227 -30
  20. package/dist/mcp.d.ts +86 -3
  21. package/dist/mcp.js +259 -31
  22. package/dist/models.d.ts +7 -1
  23. package/dist/models.js +32 -1
  24. package/dist/providers.d.ts +56 -0
  25. package/dist/providers.js +305 -0
  26. package/dist/quantization.d.ts +12 -0
  27. package/dist/quantization.js +54 -0
  28. package/dist/rag.d.ts +60 -0
  29. package/dist/rag.js +133 -35
  30. package/dist/repair.d.ts +1 -1
  31. package/dist/repair.js +53 -2
  32. package/dist/rerank.d.ts +32 -3
  33. package/dist/rerank.js +58 -8
  34. package/dist/search.d.ts +48 -3
  35. package/dist/search.js +384 -78
  36. package/dist/serve.d.ts +35 -0
  37. package/dist/serve.js +478 -58
  38. package/dist/tui.d.ts +6 -0
  39. package/dist/tui.js +52 -2
  40. package/dist/watcher.js +23 -12
  41. package/dist/webui.d.ts +3 -3
  42. package/dist/webui.js +201 -2
  43. package/package.json +3 -3
  44. package/src/binary-format.ts +13 -0
  45. package/src/completions.ts +5 -0
  46. package/src/core.ts +115 -12
  47. package/src/federation.ts +51 -17
  48. package/src/filter.ts +14 -0
  49. package/src/hnsw.ts +414 -0
  50. package/src/indexer.ts +107 -18
  51. package/src/lexical.ts +91 -13
  52. package/src/markdown-parser.ts +279 -37
  53. package/src/mcp.ts +323 -34
  54. package/src/models.ts +39 -2
  55. package/src/providers.ts +345 -0
  56. package/src/quantization.ts +61 -0
  57. package/src/rag.ts +189 -34
  58. package/src/repair.ts +51 -2
  59. package/src/rerank.ts +81 -8
  60. package/src/search.ts +417 -82
  61. package/src/serve.ts +499 -66
  62. package/src/tui.ts +56 -2
  63. package/src/watcher.ts +21 -10
  64. package/src/webui.ts +201 -2
package/dist/core.js CHANGED
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import fs from 'node:fs';
7
7
  import path from 'node:path';
8
+ import os from 'node:os';
8
9
  import { resolveModel } from './models.js';
9
10
  import { chunkMarkdownStructural, PARSER_VERSION } from './markdown-parser.js';
10
11
  import { parseFrontmatter } from './frontmatter.js';
@@ -58,7 +59,7 @@ function readLock(lockPath) {
58
59
  return null;
59
60
  }
60
61
  }
61
- export function acquireIndexLock(indexDir) {
62
+ export function acquireIndexLock(indexDir, _testHooks) {
62
63
  const lockPath = path.join(indexDir, LOCK_FILENAME);
63
64
  const payload = JSON.stringify({ pid: process.pid, since: new Date().toISOString() }) + '\n';
64
65
  for (let attempt = 0; attempt < 2; attempt++) {
@@ -86,6 +87,25 @@ export function acquireIndexLock(indexDir) {
86
87
  const dead = pid !== null && !pidAlive(pid);
87
88
  const staleMs = stat ? Date.now() - stat.mtimeMs : 0;
88
89
  if ((garbage || dead || staleMs > LOCK_STALE_MS) && stat) {
90
+ // issue #188: between the stat/read above and the unlink below another
91
+ // writer may reclaim the lock and write a fresh, live one. Re-read and
92
+ // unlink only when the owner identity (pid + since) still matches what
93
+ // was classified — never unlink a lock we did not classify.
94
+ _testHooks?.onClassified?.();
95
+ if (!fs.existsSync(lockPath))
96
+ continue;
97
+ const current = readLock(lockPath);
98
+ const sameOwner = garbage
99
+ ? current === null
100
+ : current !== null && current.pid === pid && current.since === heldSince;
101
+ if (!sameOwner) {
102
+ return {
103
+ acquired: false,
104
+ reason: 'lock changed while reclaiming — another writer got here first',
105
+ pid: current?.pid ?? pid,
106
+ heldSince: current?.since ?? heldSince,
107
+ };
108
+ }
89
109
  try {
90
110
  fs.unlinkSync(lockPath);
91
111
  }
@@ -148,14 +168,17 @@ export function resolveSessionOptions(model) {
148
168
  const intra = envIntra ? parseInt(envIntra, 10) : undefined;
149
169
  const inter = envInter ? parseInt(envInter, 10) : undefined;
150
170
  const opts = model?.sessionOptions || {};
151
- const intraOpNumThreads = Number.isSafeInteger(intra) && intra && intra > 0 ? intra : opts.intraOpNumThreads;
171
+ // issue #194: ONNX wasm sessions default to a single intra-op thread — the
172
+ // build/query sweep leaves most of the CPU idle. Default to the physical
173
+ // core count (measured 1.2x on a 4c/8t laptop; hyperthreads add contention,
174
+ // not throughput), still overridable via MDSS_INTRA_OP_THREADS.
175
+ const defaultIntra = Math.max(1, Math.min(4, os.cpus().length >> 1));
176
+ const intraOpNumThreads = Number.isSafeInteger(intra) && intra && intra > 0
177
+ ? intra
178
+ : opts.intraOpNumThreads ?? defaultIntra;
152
179
  const interOpNumThreads = Number.isSafeInteger(inter) && inter && inter > 0 ? inter : opts.interOpNumThreads;
153
- if (intraOpNumThreads === undefined && interOpNumThreads === undefined) {
154
- return undefined;
155
- }
156
180
  const result = {};
157
- if (intraOpNumThreads !== undefined)
158
- result.intraOpNumThreads = intraOpNumThreads;
181
+ result.intraOpNumThreads = intraOpNumThreads;
159
182
  if (interOpNumThreads !== undefined)
160
183
  result.interOpNumThreads = interOpNumThreads;
161
184
  return result;
@@ -170,6 +193,10 @@ export function getExtractorCacheKey(model, cacheDir, offline) {
170
193
  export function isNetworkError(err) {
171
194
  if (!err)
172
195
  return false;
196
+ // issue #184: outbound fetch timeouts abort with TimeoutError/AbortError —
197
+ // they must classify as network errors so retries and fallbacks engage
198
+ if (err.name === 'TimeoutError' || err.name === 'AbortError')
199
+ return true;
173
200
  const code = err.code || err.cause?.code;
174
201
  if (code && ['ENOTFOUND', 'ETIMEDOUT', 'ECONNRESET', 'ECONNREFUSED', 'EPIPE', 'EHOSTUNREACH', 'ENETUNREACH', 'FETCH_ERROR'].includes(code)) {
175
202
  return true;
@@ -184,10 +211,23 @@ export function isNetworkError(err) {
184
211
  msg.includes('etimedout') ||
185
212
  msg.includes('enotfound') ||
186
213
  msg.includes('network error') ||
214
+ msg.includes('aborted due to timeout') ||
187
215
  msg.includes('503 service unavailable') ||
188
216
  msg.includes('502 bad gateway') ||
189
217
  msg.includes('504 gateway timeout'));
190
218
  }
219
+ /**
220
+ * Outbound fetch timeout (issue #184): undici's default ~300s lets one hung
221
+ * embedder/LLM endpoint hold a serve concurrency slot or stall `mdss index`
222
+ * for minutes. All outbound calls carry `AbortSignal.timeout(fetchTimeoutMs())`;
223
+ * override with `MDSS_FETCH_TIMEOUT_MS` (0/negative falls back to the default).
224
+ */
225
+ export const DEFAULT_FETCH_TIMEOUT_MS = 30_000;
226
+ export function fetchTimeoutMs(envValue) {
227
+ const raw = envValue ?? process.env.MDSS_FETCH_TIMEOUT_MS;
228
+ const n = Number(raw);
229
+ return Number.isFinite(n) && n > 0 ? n : DEFAULT_FETCH_TIMEOUT_MS;
230
+ }
191
231
  export async function retryWithBackoff(fn, opts = {}) {
192
232
  const maxRetries = Number.isInteger(opts.maxRetries) && opts.maxRetries >= 0 ? opts.maxRetries : 3;
193
233
  const delays = opts.delays || [1000, 4000, 16000];
@@ -224,6 +264,46 @@ export async function getExtractor(model, cacheDir, offline = false, retryOpts =
224
264
  const key = getExtractorCacheKey(model, cacheDir, offline);
225
265
  if (_extractors.has(key))
226
266
  return _extractors.get(key);
267
+ // issue #200: adapters whose ONNX graph emits a pre-pooled
268
+ // `sentence_embedding` (EmbeddingGemma family) bypass the feature-extraction
269
+ // pipeline — it can only pool last_hidden_state/logits/token_embeddings and
270
+ // would crash on such graphs. The shim keeps the pipeline call signature so
271
+ // embed() is unchanged.
272
+ if (model.pooledOutput === true) {
273
+ const loadPooledModel = async () => {
274
+ const { AutoModel, AutoTokenizer, env } = await import('@huggingface/transformers');
275
+ if (cacheDir)
276
+ env.cacheDir = cacheDir;
277
+ env.allowRemoteModels = !offline;
278
+ const sessionOptions = resolveSessionOptions(model);
279
+ const modelOpts = {
280
+ revision,
281
+ dtype: model.dtype || 'q8',
282
+ progress_callback: emitDownloadEvent,
283
+ ...(sessionOptions ? { session_options: sessionOptions } : {}),
284
+ };
285
+ const tokenizer = await AutoTokenizer.from_pretrained(source, { revision });
286
+ const onnxModel = await AutoModel.from_pretrained(source, modelOpts);
287
+ const normalize = model.normalize !== false;
288
+ const ext = async (input) => {
289
+ const texts = Array.isArray(input) ? input : [input];
290
+ const modelInputs = await tokenizer(texts, { padding: true, truncation: true });
291
+ const outputs = await onnxModel(modelInputs);
292
+ let result = outputs.sentence_embedding;
293
+ if (!result) {
294
+ throw new Error(`model "${model.id}" declared pooledOutput but produced no sentence_embedding output`);
295
+ }
296
+ if (normalize)
297
+ result = result.normalize(2, -1);
298
+ return { tolist: () => result.tolist() };
299
+ };
300
+ _extractors.set(key, ext);
301
+ return ext;
302
+ };
303
+ if (offline)
304
+ return await loadPooledModel();
305
+ return await retryWithBackoff(loadPooledModel, retryOpts);
306
+ }
227
307
  const loadPipeline = async () => {
228
308
  const { pipeline, env } = await import('@huggingface/transformers');
229
309
  if (cacheDir)
@@ -380,7 +460,7 @@ export const SCHEMA_MIGRATIONS = {
380
460
  2: migrateToSchemaV2,
381
461
  3: migrateToSchemaV3,
382
462
  };
383
- export function walkMarkdown(dir, ignore = []) {
463
+ export function walkMarkdown(dir, ignore = [], errors) {
384
464
  const out = [];
385
465
  const ignoreRe = ignore.map(globToRegExp);
386
466
  const walk = (cur) => {
@@ -388,7 +468,14 @@ export function walkMarkdown(dir, ignore = []) {
388
468
  try {
389
469
  entries = fs.readdirSync(cur, { withFileTypes: true });
390
470
  }
391
- catch {
471
+ catch (e) {
472
+ // issue #189: an unreadable directory (permissions, OneDrive eviction)
473
+ // used to silently shrink the corpus; surface it when a collector is
474
+ // given, always at least as a stderr warning
475
+ const msg = `walkMarkdown: cannot read directory ${cur}: ${e?.message || e}`;
476
+ if (errors)
477
+ errors.push(msg);
478
+ process.stderr.write(`warning: ${msg}\n`);
392
479
  return;
393
480
  }
394
481
  for (const e of entries) {
@@ -490,17 +577,21 @@ export function extractTitle(frontmatter, body, relPath) {
490
577
  return path.basename(relPath).replace(/\.(md|markdown)$/i, '');
491
578
  }
492
579
  const DEFAULT_MAX_CHUNK = 1400;
493
- export function chunkMarkdown(body, maxChunk = DEFAULT_MAX_CHUNK) {
494
- return chunkMarkdownStructural(body, maxChunk);
495
- }
496
- export function parseFile(absPath, dbDir, maxChunk, raw) {
580
+ export function chunkMarkdown(body, maxChunk = DEFAULT_MAX_CHUNK, maxChunkTokens) {
581
+ // issue #202: a token budget (maxChunkTokens) takes precedence over the
582
+ // legacy char budget; without it the pre-#202 char behavior is unchanged
583
+ return chunkMarkdownStructural(body, maxChunkTokens && maxChunkTokens > 0 ? { maxChunkTokens } : maxChunk);
584
+ }
585
+ export function parseFile(absPath, dbDir, maxChunk, raw,
586
+ /** issue #202: token budget overrides the char budget when set. */
587
+ maxChunkTokens) {
497
588
  const content = raw ?? fs.readFileSync(absPath, 'utf8');
498
589
  const { frontmatter, body } = splitFrontmatter(content);
499
590
  const meta = parseFrontmatter(frontmatter);
500
591
  const rel = path.relative(dbDir, absPath).split(path.sep).join('/');
501
592
  const title = meta.title || extractTitle(frontmatter, body, rel);
502
593
  const frontmatterLineCount = frontmatter ? frontmatter.split('\n').length + 2 : 0;
503
- return chunkMarkdown(body, maxChunk).map((c) => {
594
+ return chunkMarkdown(body, maxChunk, maxChunkTokens).map((c) => {
504
595
  const heading = c.heading || title;
505
596
  return {
506
597
  file: rel,
@@ -7,9 +7,24 @@ export interface VaultConfig {
7
7
  export interface FederatedSearchOptions extends Omit<SearchOptions, 'loaded' | 'cacheDir'> {
8
8
  vaults: Array<string | VaultConfig>;
9
9
  cacheDir?: string;
10
+ /**
11
+ * issue #179: when set, every vault path (and explicit indexDir) must resolve
12
+ * inside one of these roots or a traversal-guard error is thrown. Callers
13
+ * exposing federation to remote clients (MCP) must pin this to their trust
14
+ * domain; direct CLI use stays guarded only by MDSS_ROOT_GUARD.
15
+ */
16
+ allowedRoots?: string[];
10
17
  }
11
18
  export interface FederatedSearchResultHit extends SearchResultHit {
12
19
  vault: string;
13
20
  vaultPath: string;
14
21
  }
15
- export declare function searchFederated(opts: FederatedSearchOptions): Promise<FederatedSearchResultHit[]>;
22
+ export interface FederatedSearchResult {
23
+ hits: FederatedSearchResultHit[];
24
+ /**
25
+ * issue #189: per-vault load/query failures surfaced to the caller — a
26
+ * broken vault must not silently look like "0 results".
27
+ */
28
+ vaultErrors: string[];
29
+ }
30
+ export declare function searchFederated(opts: FederatedSearchOptions): Promise<FederatedSearchResult>;
@@ -8,20 +8,23 @@
8
8
  import path from 'node:path';
9
9
  import fs from 'node:fs';
10
10
  import { loadIndex, searchIndex } from './search.js';
11
+ import { assertSafePath } from './core.js';
11
12
  export async function searchFederated(opts) {
12
- const { vaults, k = 10, cacheDir = '', ...searchOpts } = opts;
13
+ const { vaults, k = 10, cacheDir = '', allowedRoots, ...searchOpts } = opts;
13
14
  if (!vaults || vaults.length === 0) {
14
15
  throw new Error('Federated search requires at least one vault specified');
15
16
  }
16
17
  const normalizedVaults = vaults.map((v) => {
17
18
  if (typeof v === 'string') {
18
- const absPath = path.resolve(v);
19
+ const absPath = assertSafePath(path.resolve(v), allowedRoots);
19
20
  const name = path.basename(absPath);
20
21
  return { name, path: absPath };
21
22
  }
22
- const absPath = path.resolve(v.path);
23
+ const absPath = assertSafePath(path.resolve(v.path), allowedRoots);
23
24
  const name = v.name || path.basename(absPath);
24
- return { name, path: absPath, indexDir: v.indexDir };
25
+ // indexDir is loaded from disk too — guard it with the same roots (#179)
26
+ const indexDir = v.indexDir ? assertSafePath(path.resolve(v.indexDir), allowedRoots) : undefined;
27
+ return { name, path: absPath, indexDir };
25
28
  });
26
29
  // Load and query each vault concurrently
27
30
  const perVaultResults = await Promise.all(normalizedVaults.map(async (vault) => {
@@ -39,7 +42,10 @@ export async function searchFederated(opts) {
39
42
  try {
40
43
  // Query vault index with a slightly larger pool to ensure healthy global fusion
41
44
  const poolK = Math.max(k * 2, 20);
42
- const resolvedCache = cacheDir || path.join(vault.path, '.cache');
45
+ // issue #179: never invent a per-vault cache location — a falsy cacheDir
46
+ // disables the disk layer (searchIndex semantics), so query embeddings
47
+ // are only ever written to a cache dir the caller explicitly owns.
48
+ const resolvedCache = cacheDir;
43
49
  const hits = await searchIndex({
44
50
  ...searchOpts,
45
51
  cacheDir: resolvedCache,
@@ -52,14 +58,22 @@ export async function searchFederated(opts) {
52
58
  return { vault, hits: [], error: err.message };
53
59
  }
54
60
  }));
61
+ // issue #189: surface per-vault failures — a broken vault must not look
62
+ // like a vault with simply no matching notes
63
+ const vaultErrors = perVaultResults
64
+ .filter((r) => r.error !== null)
65
+ .map((r) => `vault "${r.vault.name || path.basename(r.vault.path)}" (${r.vault.path}): ${r.error}`);
55
66
  // If single vault, return directly with attribution
56
67
  if (normalizedVaults.length === 1) {
57
68
  const { vault, hits } = perVaultResults[0];
58
- return hits.slice(0, k).map((h) => ({
59
- ...h,
60
- vault: vault.name || path.basename(vault.path),
61
- vaultPath: vault.path,
62
- }));
69
+ return {
70
+ hits: hits.slice(0, k).map((h) => ({
71
+ ...h,
72
+ vault: vault.name || path.basename(vault.path),
73
+ vaultPath: vault.path,
74
+ })),
75
+ vaultErrors,
76
+ };
63
77
  }
64
78
  // Multi-vault reciprocal rank fusion (RRF)
65
79
  // Collect all hits across vaults
@@ -85,10 +99,13 @@ export async function searchFederated(opts) {
85
99
  return b.hit.score - a.hit.score;
86
100
  })
87
101
  .slice(0, k);
88
- return fused.map(({ hit, vault, rrfSum }) => ({
89
- ...hit,
90
- score: rrfSum,
91
- vault: vault.name || path.basename(vault.path),
92
- vaultPath: vault.path,
93
- }));
102
+ return {
103
+ hits: fused.map(({ hit, vault, rrfSum }) => ({
104
+ ...hit,
105
+ score: rrfSum,
106
+ vault: vault.name || path.basename(vault.path),
107
+ vaultPath: vault.path,
108
+ })),
109
+ vaultErrors,
110
+ };
94
111
  }
package/dist/filter.d.ts CHANGED
@@ -22,6 +22,13 @@ export type FilterNode = {
22
22
  value: string | number | boolean;
23
23
  };
24
24
  export declare function tokenizeFilter(expr: string): string[];
25
+ /**
26
+ * issue #180: parseOr/parseAnd/parseNot/parsePrimary are mutually recursive
27
+ * (~4 stack frames per nesting level) — an unbounded `(((((…` from a 64 KiB
28
+ * request body would exhaust the call stack. 64 levels is far beyond any
29
+ * legitimate filter and keeps the parser fail-closed with a clear error.
30
+ */
31
+ export declare const MAX_FILTER_DEPTH = 64;
25
32
  export declare function parseFilter(expr: string): FilterNode;
26
33
  export declare function evaluateFilter(node: FilterNode | string, meta?: DocumentMetadata, doc?: {
27
34
  file?: string;
package/dist/filter.js CHANGED
@@ -72,12 +72,20 @@ export function tokenizeFilter(expr) {
72
72
  }
73
73
  return tokens;
74
74
  }
75
+ /**
76
+ * issue #180: parseOr/parseAnd/parseNot/parsePrimary are mutually recursive
77
+ * (~4 stack frames per nesting level) — an unbounded `(((((…` from a 64 KiB
78
+ * request body would exhaust the call stack. 64 levels is far beyond any
79
+ * legitimate filter and keeps the parser fail-closed with a clear error.
80
+ */
81
+ export const MAX_FILTER_DEPTH = 64;
75
82
  export function parseFilter(expr) {
76
83
  const tokens = tokenizeFilter(expr);
77
84
  if (tokens.length === 0) {
78
85
  throw new Error('Empty filter expression');
79
86
  }
80
87
  let pos = 0;
88
+ let depth = 0;
81
89
  function peek() {
82
90
  return tokens[pos];
83
91
  }
@@ -131,12 +139,17 @@ export function parseFilter(expr) {
131
139
  throw new Error(`Unexpected end of filter expression at position ${pos}`);
132
140
  }
133
141
  if (token === '(') {
142
+ depth += 1;
143
+ if (depth > MAX_FILTER_DEPTH) {
144
+ throw new Error(`Filter expression too deeply nested (max ${MAX_FILTER_DEPTH} levels of parentheses)`);
145
+ }
134
146
  consume(); // (
135
147
  const node = parseExpression();
136
148
  if (peek() !== ')') {
137
149
  throw new Error('Expected closing parenthesis ")" in filter expression');
138
150
  }
139
151
  consume(); // )
152
+ depth -= 1;
140
153
  return node;
141
154
  }
142
155
  // Comparison term: <field> <op> <value> or <field>:<value>
package/dist/hnsw.d.ts ADDED
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Pure JS/TS HNSW approximate nearest neighbor index (issue #201).
3
+ *
4
+ * Hierarchical Navigable Small World graphs (Malkov & Yashunin 2016): no
5
+ * training phase, incremental inserts, ~99% recall with sublinear search —
6
+ * the counterpart of the k-means IVF in `ivf.ts`, sharing its contract:
7
+ * build → serialize → persist → search as a candidate pre-filter in front
8
+ * of the exact sweep. Cosine distance (1 − dot on normalized vectors) is
9
+ * computed through an injected distance function so int8 zero-copy vectors
10
+ * work unchanged. Deleted nodes are tombstones — searches skip them, which
11
+ * is what lets the incremental watch rebuild reuse a persisted graph.
12
+ */
13
+ export interface HnswIndex {
14
+ dim: number;
15
+ /** Max links per node on layers > 0. */
16
+ M: number;
17
+ /** Random level seed — rebuilds from the same insertion order are identical. */
18
+ seed: number;
19
+ /** Node index of the graph entry (top layer). */
20
+ entryPoint: number;
21
+ /** Highest layer in the graph. */
22
+ maxLevel: number;
23
+ /** levels[i] = top layer of node i. */
24
+ levels: number[];
25
+ /** neighbors[i][l] = link list of node i on layer l (l ≤ levels[i]). */
26
+ neighbors: number[][][];
27
+ /** Tombstoned node indices — skipped by search, kept for graph connectivity. */
28
+ deleted: Set<number>;
29
+ }
30
+ export interface SerializedHnsw {
31
+ format: 'hnsw-v1';
32
+ dim: number;
33
+ M: number;
34
+ seed: number;
35
+ entryPoint: number;
36
+ maxLevel: number;
37
+ count: number;
38
+ levels: number[];
39
+ neighbors: number[][][];
40
+ deleted: number[];
41
+ }
42
+ export interface BuildHnswOptions {
43
+ M?: number;
44
+ seed?: number;
45
+ /**
46
+ * Distance between the query/node vector at `a` and the stored node `b` —
47
+ * LOWER is closer (cosine distance: `1 − dot(a, vectors[b])`). Injected so
48
+ * int8 zero-copy and norm-cached sweeps work unchanged.
49
+ */
50
+ distance: (a: Float32Array | Int8Array | number[], b: number) => number;
51
+ vectors: Array<Float32Array | Int8Array | number[]>;
52
+ }
53
+ export declare const DEFAULT_HNSW_M = 16;
54
+ export declare const DEFAULT_HNSW_EF = 64;
55
+ export declare function buildHnsw(vectors: Array<Float32Array | Int8Array | number[]>, options: BuildHnswOptions): HnswIndex;
56
+ /**
57
+ * Insert node `idx` (vector index) into the graph. Incremental — new indices
58
+ * attach to the existing structure without a rebuild; vectors for nodes NOT
59
+ * yet inserted are read from `vectors` when needed, so the caller can insert
60
+ * lazily. Tombstoned indices can be re-inserted (undeletes).
61
+ */
62
+ export declare function insertHnsw(hnsw: HnswIndex, idx: number, distance: (a: Float32Array | Int8Array | number[], b: number) => number, vectors: Array<Float32Array | Int8Array | number[]>): void;
63
+ /** Tombstone a node — searches skip it; the graph keeps it for connectivity. */
64
+ export declare function deleteHnsw(hnsw: HnswIndex, idx: number): void;
65
+ /**
66
+ * Algorithm 4 of the HNSW paper (Malkov & Yashunin 2016): candidates sorted
67
+ * by distance to the query enter the result in order; a candidate is skipped
68
+ * when it is closer to some already-selected neighbor than to the query
69
+ * (redundant direction). `pairwise(a, b)` measures node-to-node distance.
70
+ * If fewer than `m` survive (disconnected candidate set), the closest
71
+ * leftovers backfill so the link count stays useful.
72
+ */
73
+ export declare function selectNeighborsHeuristic(candidates: Array<{
74
+ node: number;
75
+ dist: number;
76
+ }>, m: number, pairwise: (a: number, b: number) => number): Array<{
77
+ node: number;
78
+ dist: number;
79
+ }>;
80
+ /**
81
+ * ANN candidate pre-filter: ef-search from the entry point, returning LIVE
82
+ * candidate chunk indices (tombstones excluded). Mirrors
83
+ * `searchIVFCandidates` — the result feeds the same probed-candidate set.
84
+ */
85
+ export declare function searchHnswCandidates(queryVec: Float32Array | Int8Array | number[], hnsw: HnswIndex, vectors: Array<Float32Array | Int8Array | number[]>, options?: {
86
+ ef?: number;
87
+ /** Lower-is-closer distance; defaults to cosine distance via `cosine`. */
88
+ distance?: (a: Float32Array | Int8Array | number[], b: number) => number;
89
+ }): number[];
90
+ export declare function serializeHnsw(hnsw: HnswIndex): SerializedHnsw;
91
+ /**
92
+ * Hardened deserialization — every structural invariant is checked with the
93
+ * same discipline as `deserializeIVF`; any violation throws (the loader
94
+ * falls back to brute force).
95
+ */
96
+ export declare function deserializeHnsw(raw: unknown, expectedChunksCount?: number): HnswIndex;