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.
- package/README.md +88 -14
- package/RESEARCH.md +182 -0
- package/bin/cli.mjs +197 -8
- package/dist/binary-format.js +9 -0
- package/dist/completions.js +5 -0
- package/dist/core.d.ts +16 -4
- package/dist/core.js +105 -14
- package/dist/federation.d.ts +16 -1
- package/dist/federation.js +33 -16
- package/dist/filter.d.ts +7 -0
- package/dist/filter.js +13 -0
- package/dist/hnsw.d.ts +96 -0
- package/dist/hnsw.js +326 -0
- package/dist/indexer.d.ts +17 -1
- package/dist/indexer.js +91 -17
- package/dist/lexical.d.ts +10 -0
- package/dist/lexical.js +98 -15
- package/dist/markdown-parser.d.ts +62 -0
- package/dist/markdown-parser.js +227 -30
- package/dist/mcp.d.ts +86 -3
- package/dist/mcp.js +259 -31
- package/dist/models.d.ts +7 -1
- package/dist/models.js +32 -1
- package/dist/providers.d.ts +56 -0
- package/dist/providers.js +305 -0
- package/dist/quantization.d.ts +12 -0
- package/dist/quantization.js +54 -0
- package/dist/rag.d.ts +60 -0
- package/dist/rag.js +133 -35
- package/dist/repair.d.ts +1 -1
- package/dist/repair.js +53 -2
- package/dist/rerank.d.ts +32 -3
- package/dist/rerank.js +58 -8
- package/dist/search.d.ts +48 -3
- package/dist/search.js +384 -78
- package/dist/serve.d.ts +35 -0
- package/dist/serve.js +478 -58
- package/dist/tui.d.ts +6 -0
- package/dist/tui.js +52 -2
- package/dist/watcher.js +23 -12
- package/dist/webui.d.ts +3 -3
- package/dist/webui.js +201 -2
- package/package.json +3 -3
- package/src/binary-format.ts +13 -0
- package/src/completions.ts +5 -0
- package/src/core.ts +115 -12
- package/src/federation.ts +51 -17
- package/src/filter.ts +14 -0
- package/src/hnsw.ts +414 -0
- package/src/indexer.ts +107 -18
- package/src/lexical.ts +91 -13
- package/src/markdown-parser.ts +279 -37
- package/src/mcp.ts +323 -34
- package/src/models.ts +39 -2
- package/src/providers.ts +345 -0
- package/src/quantization.ts +61 -0
- package/src/rag.ts +189 -34
- package/src/repair.ts +51 -2
- package/src/rerank.ts +81 -8
- package/src/search.ts +417 -82
- package/src/serve.ts +499 -66
- package/src/tui.ts +56 -2
- package/src/watcher.ts +21 -10
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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,
|
package/dist/federation.d.ts
CHANGED
|
@@ -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
|
|
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>;
|
package/dist/federation.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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;
|