md-semantic-search 0.4.0 → 0.5.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 (61) hide show
  1. package/README.md +81 -0
  2. package/bin/cli.mjs +368 -14
  3. package/dist/collapse.d.ts +6 -0
  4. package/dist/collapse.js +20 -0
  5. package/dist/core.d.ts +84 -0
  6. package/dist/core.js +479 -0
  7. package/dist/frontmatter.d.ts +20 -0
  8. package/dist/frontmatter.js +150 -0
  9. package/dist/index-format.d.ts +23 -0
  10. package/dist/index-format.js +125 -0
  11. package/dist/index.d.ts +29 -0
  12. package/dist/index.js +29 -0
  13. package/dist/indexer.d.ts +70 -0
  14. package/dist/indexer.js +452 -0
  15. package/dist/ivf.d.ts +33 -0
  16. package/dist/ivf.js +144 -0
  17. package/dist/lexical.d.ts +44 -0
  18. package/dist/lexical.js +226 -0
  19. package/dist/markdown-parser.d.ts +27 -0
  20. package/dist/markdown-parser.js +252 -0
  21. package/dist/mcp.d.ts +111 -0
  22. package/dist/mcp.js +216 -0
  23. package/dist/metrics.d.ts +43 -0
  24. package/dist/metrics.js +97 -0
  25. package/dist/models.d.ts +38 -0
  26. package/dist/models.js +181 -0
  27. package/dist/rerank.d.ts +10 -0
  28. package/dist/rerank.js +41 -0
  29. package/dist/search.d.ts +135 -0
  30. package/dist/search.js +492 -0
  31. package/dist/serve.d.ts +49 -0
  32. package/dist/serve.js +321 -0
  33. package/dist/tui.d.ts +17 -0
  34. package/dist/tui.js +196 -0
  35. package/dist/wikilinks.d.ts +47 -0
  36. package/dist/wikilinks.js +171 -0
  37. package/package.json +19 -5
  38. package/src/{collapse.mjs → collapse.ts} +7 -12
  39. package/src/core.ts +546 -0
  40. package/src/{frontmatter.mjs → frontmatter.ts} +27 -50
  41. package/src/{index-format.mjs → index-format.ts} +57 -40
  42. package/src/{index.mjs → index.ts} +16 -8
  43. package/src/indexer.ts +585 -0
  44. package/src/ivf.ts +190 -0
  45. package/src/{lexical.mjs → lexical.ts} +66 -100
  46. package/src/{markdown-parser.mjs → markdown-parser.ts} +56 -100
  47. package/src/mcp.ts +243 -0
  48. package/src/metrics.ts +135 -0
  49. package/src/models.ts +219 -0
  50. package/src/rerank.ts +54 -0
  51. package/src/search.ts +628 -0
  52. package/src/serve.ts +372 -0
  53. package/src/tui.ts +227 -0
  54. package/src/{wikilinks.mjs → wikilinks.ts} +70 -67
  55. package/src/core.mjs +0 -535
  56. package/src/indexer.mjs +0 -540
  57. package/src/metrics.mjs +0 -186
  58. package/src/models.mjs +0 -299
  59. package/src/rerank.mjs +0 -75
  60. package/src/search.mjs +0 -636
  61. package/src/serve.mjs +0 -387
@@ -0,0 +1,135 @@
1
+ import { ModelDescriptor } from './core.js';
2
+ import { LexicalIndex } from './lexical.js';
3
+ import { DocumentMetadata } from './frontmatter.js';
4
+ import { IVFIndex } from './ivf.js';
5
+ export { tokenize } from './lexical.js';
6
+ export declare const _stats: {
7
+ corpusTokenizedChars: number;
8
+ };
9
+ export declare function keywordScores(chunks: IndexChunk[], query: string): number[];
10
+ export declare function rrf(rankings: Array<Array<{
11
+ idx: number;
12
+ score: number;
13
+ }>>, k?: number): Map<number, number>;
14
+ export interface IndexChunk {
15
+ file: string;
16
+ title: string;
17
+ heading: string;
18
+ headingPath?: string[];
19
+ text: string;
20
+ vec: number[] | Float32Array;
21
+ chunkHash?: string;
22
+ startLine?: number;
23
+ endLine?: number;
24
+ meta?: DocumentMetadata;
25
+ }
26
+ export interface IndexFile {
27
+ format?: string;
28
+ schemaVersion?: number;
29
+ model?: string;
30
+ modelAlias?: string;
31
+ dim?: number;
32
+ built?: string;
33
+ chunkCount?: number;
34
+ db?: string;
35
+ lexical?: LexicalIndex;
36
+ chunks: IndexChunk[];
37
+ }
38
+ export declare class QueryEmbeddingCache {
39
+ maxSize: number;
40
+ cache: Map<string, Float32Array>;
41
+ inFlight: Map<string, Promise<Float32Array>>;
42
+ constructor(maxSize?: number);
43
+ get(key: string): Float32Array | undefined;
44
+ set(key: string, vector: number[] | Float32Array): void;
45
+ getOrCompute(key: string, fn: () => Promise<number[] | Float32Array>): Promise<Float32Array>;
46
+ clear(): void;
47
+ get size(): number;
48
+ }
49
+ export type LexicalState = {
50
+ kind: 'persisted-bm25';
51
+ lexical: LexicalIndex;
52
+ } | {
53
+ kind: 'legacy-overlap';
54
+ };
55
+ export interface RuntimeChunk {
56
+ file: string;
57
+ title: string;
58
+ heading: string;
59
+ headingPath?: string[];
60
+ text: string;
61
+ startLine?: number;
62
+ endLine?: number;
63
+ meta?: DocumentMetadata;
64
+ vec: Float32Array;
65
+ }
66
+ export interface RuntimeIndexState {
67
+ schema: number;
68
+ db: string | undefined;
69
+ chunks: RuntimeChunk[];
70
+ lexicalState: LexicalState;
71
+ expectedDim: number | undefined;
72
+ model: ModelDescriptor;
73
+ queryCache: QueryEmbeddingCache;
74
+ ivf?: IVFIndex;
75
+ }
76
+ export declare function loadIndex(indexDir: string): {
77
+ index: IndexFile;
78
+ model: ModelDescriptor;
79
+ };
80
+ export declare const MAX_QUERY_LENGTH = 2048;
81
+ export interface SearchOptions {
82
+ loaded: {
83
+ index: IndexFile;
84
+ model: ModelDescriptor;
85
+ };
86
+ cacheDir: string;
87
+ query: string;
88
+ k?: number;
89
+ semanticOnly?: boolean;
90
+ offline?: boolean;
91
+ path?: string | string[];
92
+ since?: string | Date;
93
+ rerank?: boolean;
94
+ rerankPool?: number;
95
+ rerankFn?: any;
96
+ embedFn?: any;
97
+ tag?: string | string[];
98
+ project?: string;
99
+ type?: string;
100
+ status?: string;
101
+ canonicalOnly?: boolean;
102
+ custom?: Record<string, unknown>;
103
+ explain?: boolean;
104
+ maxPerFile?: number;
105
+ maxPerDoc?: number;
106
+ useQueryCache?: boolean;
107
+ ann?: boolean;
108
+ nprobe?: number;
109
+ }
110
+ export interface SearchResultHit {
111
+ file: string;
112
+ title: string;
113
+ heading: string;
114
+ cosine: number;
115
+ score: number;
116
+ matches: string[];
117
+ snippet: string;
118
+ meta?: DocumentMetadata;
119
+ explain?: {
120
+ cosine: number;
121
+ rrfScore: number;
122
+ bm25Weights: {
123
+ title: number;
124
+ aliases: number;
125
+ headingPath: number;
126
+ body: number;
127
+ };
128
+ };
129
+ rerankScore?: number;
130
+ }
131
+ export declare function searchIndex(opts: SearchOptions): Promise<SearchResultHit[]>;
132
+ export type OneShotSearchOptions = Omit<SearchOptions, 'loaded'> & {
133
+ indexDir: string;
134
+ };
135
+ export declare function search(opts: OneShotSearchOptions): Promise<SearchResultHit[]>;
package/dist/search.js ADDED
@@ -0,0 +1,492 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import { embed, cosine, decodeVec, globToRegExp, walkMarkdown } from './core.js';
5
+ import { validateIndexEnvelope, validateNumericVector } from './index-format.js';
6
+ import { rerankScores } from './rerank.js';
7
+ import { bm25Scores, matchingTerms, tokenize, fuzzyTitleAliasScores } from './lexical.js';
8
+ import { collapseResults } from './collapse.js';
9
+ import { trainIVF, searchIVFCandidates, deserializeIVF, ANN_THRESHOLD, DEFAULT_NPROBE } from './ivf.js';
10
+ export { tokenize } from './lexical.js';
11
+ const chunkTokens = new WeakMap();
12
+ export const _stats = { corpusTokenizedChars: 0 };
13
+ function tokenSet(c) {
14
+ let s = chunkTokens.get(c);
15
+ if (!s) {
16
+ const text = `${c.title} ${c.heading} ${c.text}`;
17
+ _stats.corpusTokenizedChars += text.length;
18
+ s = new Set(tokenize(text));
19
+ chunkTokens.set(c, s);
20
+ }
21
+ return s;
22
+ }
23
+ export function keywordScores(chunks, query) {
24
+ const qTerms = new Set(tokenize(query));
25
+ if (qTerms.size === 0)
26
+ return chunks.map(() => 0);
27
+ return chunks.map((c) => {
28
+ const hay = tokenSet(c);
29
+ let s = 0;
30
+ for (const t of qTerms)
31
+ if (hay.has(t))
32
+ s++;
33
+ return s;
34
+ });
35
+ }
36
+ export function rrf(rankings, k = 60) {
37
+ const fused = new Map();
38
+ for (const ranking of rankings) {
39
+ const sorted = [...ranking].sort((a, b) => b.score - a.score);
40
+ let scoreRank = 0;
41
+ sorted.forEach((item, rank) => {
42
+ if (item.score <= 0)
43
+ return;
44
+ if (rank > 0 && item.score < sorted[rank - 1].score)
45
+ scoreRank = rank;
46
+ fused.set(item.idx, (fused.get(item.idx) || 0) + 1 / (k + scoreRank + 1));
47
+ });
48
+ }
49
+ return fused;
50
+ }
51
+ export class QueryEmbeddingCache {
52
+ maxSize;
53
+ cache;
54
+ inFlight;
55
+ constructor(maxSize = 100) {
56
+ this.maxSize = maxSize;
57
+ this.cache = new Map();
58
+ this.inFlight = new Map();
59
+ }
60
+ get(key) {
61
+ if (!this.cache.has(key))
62
+ return undefined;
63
+ const value = this.cache.get(key);
64
+ this.cache.delete(key);
65
+ this.cache.set(key, value);
66
+ return value;
67
+ }
68
+ set(key, vector) {
69
+ if (!Array.isArray(vector) && !(vector instanceof Float32Array))
70
+ return;
71
+ if (vector.length === 0)
72
+ return;
73
+ const arr = vector instanceof Float32Array ? vector : Float32Array.from(vector);
74
+ for (let i = 0; i < arr.length; i++) {
75
+ if (!Number.isFinite(arr[i]))
76
+ return;
77
+ }
78
+ if (this.cache.has(key)) {
79
+ this.cache.delete(key);
80
+ }
81
+ else if (this.cache.size >= this.maxSize) {
82
+ const oldestKey = this.cache.keys().next().value;
83
+ if (oldestKey !== undefined)
84
+ this.cache.delete(oldestKey);
85
+ }
86
+ this.cache.set(key, arr);
87
+ }
88
+ async getOrCompute(key, fn) {
89
+ const cached = this.get(key);
90
+ if (cached)
91
+ return cached;
92
+ if (this.inFlight.has(key)) {
93
+ return this.inFlight.get(key);
94
+ }
95
+ const promise = (async () => {
96
+ try {
97
+ const raw = await fn();
98
+ this.set(key, raw);
99
+ const stored = this.get(key);
100
+ if (stored)
101
+ return stored;
102
+ return raw instanceof Float32Array ? raw : Float32Array.from(raw);
103
+ }
104
+ finally {
105
+ this.inFlight.delete(key);
106
+ }
107
+ })();
108
+ this.inFlight.set(key, promise);
109
+ return promise;
110
+ }
111
+ clear() {
112
+ this.cache.clear();
113
+ this.inFlight.clear();
114
+ }
115
+ get size() {
116
+ return this.cache.size;
117
+ }
118
+ }
119
+ const runtimeIndexStates = new WeakMap();
120
+ function snapshotRuntimeIndex(index, validation) {
121
+ const dim = validation.dim || (index.chunks[0]?.vec?.length ?? 0);
122
+ const count = index.chunks.length;
123
+ const vectorsBuffer = dim > 0 ? new Float32Array(count * dim) : null;
124
+ const chunks = index.chunks.map((chunk, i) => {
125
+ let vec;
126
+ if (vectorsBuffer && dim > 0) {
127
+ const offset = i * dim;
128
+ if (chunk.vec instanceof Float32Array) {
129
+ vectorsBuffer.set(chunk.vec, offset);
130
+ }
131
+ else {
132
+ const arr = chunk.vec;
133
+ for (let j = 0; j < dim; j++)
134
+ vectorsBuffer[offset + j] = arr[j];
135
+ }
136
+ vec = vectorsBuffer.subarray(offset, offset + dim);
137
+ }
138
+ else {
139
+ vec = Float32Array.from(chunk.vec);
140
+ }
141
+ return {
142
+ file: chunk.file,
143
+ title: chunk.title,
144
+ heading: chunk.heading,
145
+ headingPath: chunk.headingPath,
146
+ text: chunk.text,
147
+ startLine: chunk.startLine,
148
+ endLine: chunk.endLine,
149
+ meta: chunk.meta,
150
+ vec,
151
+ };
152
+ });
153
+ let lexicalState = { kind: 'legacy-overlap' };
154
+ if (validation.schema >= 3 && index.lexical) {
155
+ const postings = Object.create(null);
156
+ for (const [term, posting] of Object.entries(index.lexical.postings)) {
157
+ postings[term] = posting.map(([docId, frequency]) => [docId, frequency]);
158
+ }
159
+ lexicalState = {
160
+ kind: 'persisted-bm25',
161
+ lexical: {
162
+ format: index.lexical.format,
163
+ documentLengths: [...index.lexical.documentLengths],
164
+ postings,
165
+ },
166
+ };
167
+ }
168
+ return {
169
+ schema: validation.schema,
170
+ db: index.db,
171
+ chunks,
172
+ lexicalState,
173
+ expectedDim: validation.dim,
174
+ model: { ...validation.model },
175
+ queryCache: new QueryEmbeddingCache(100),
176
+ ivf: validation.ivf,
177
+ };
178
+ }
179
+ function validateRuntimeIndex(index) {
180
+ const cached = runtimeIndexStates.get(index);
181
+ if (cached)
182
+ return cached;
183
+ const validated = validateIndexEnvelope(index, 'index', { encoding: 'loaded' });
184
+ let expectedDim = validated.dim;
185
+ if (validated.schema < 3) {
186
+ for (let position = 0; position < index.chunks.length; position++) {
187
+ const chunk = index.chunks[position];
188
+ if (chunk === null || typeof chunk !== 'object' || Array.isArray(chunk)) {
189
+ throw new Error(`chunk ${position} must be an object — run \`mdss index\` to rebuild`);
190
+ }
191
+ const where = chunk.file + (chunk.heading ? ` › ${chunk.heading}` : '');
192
+ const length = validateNumericVector(chunk.vec, expectedDim, `chunk ${where}`);
193
+ expectedDim ??= length;
194
+ }
195
+ }
196
+ const state = snapshotRuntimeIndex(index, {
197
+ schema: validated.schema,
198
+ dim: expectedDim,
199
+ model: validated.model,
200
+ });
201
+ runtimeIndexStates.set(index, state);
202
+ return state;
203
+ }
204
+ function warnIfStale(index) {
205
+ if (!index.db || !index.built)
206
+ return;
207
+ const builtMs = Date.parse(index.built);
208
+ if (!Number.isFinite(builtMs))
209
+ return;
210
+ let newest = 0;
211
+ try {
212
+ for (const f of walkMarkdown(index.db)) {
213
+ const st = fs.statSync(f);
214
+ if (st.mtimeMs > newest)
215
+ newest = st.mtimeMs;
216
+ }
217
+ }
218
+ catch {
219
+ return;
220
+ }
221
+ if (newest <= builtMs + 5000)
222
+ return;
223
+ const mins = Math.max(1, Math.round((newest - builtMs) / 60000));
224
+ process.stderr.write(`warning: index is ${mins} min older than the newest change in ${index.db}; run \`mdss index\` to refresh.\n`);
225
+ }
226
+ export function loadIndex(indexDir) {
227
+ const vectorsPath = path.join(indexDir, 'vectors.json');
228
+ if (!fs.existsSync(vectorsPath)) {
229
+ throw new Error(`No index at ${vectorsPath}. Run \`mdss index\` first.`);
230
+ }
231
+ const rawBytes = fs.readFileSync(vectorsPath, 'utf8');
232
+ let parsed;
233
+ try {
234
+ parsed = JSON.parse(rawBytes);
235
+ }
236
+ catch (e) {
237
+ throw new Error(`${vectorsPath} is not valid JSON (${e.message}); run \`mdss index\` to rebuild.`, { cause: e });
238
+ }
239
+ const validated = validateIndexEnvelope(parsed, vectorsPath, { encoding: 'stored' });
240
+ const index = validated.index;
241
+ const shaPath = path.join(indexDir, 'vectors.json.sha256');
242
+ if (fs.existsSync(shaPath)) {
243
+ try {
244
+ const shaContent = fs.readFileSync(shaPath, 'utf8').trim();
245
+ const expectedHash = shaContent.split(/\s+/)[0];
246
+ const actualHash = crypto.createHash('sha256').update(rawBytes).digest('hex');
247
+ if (expectedHash && actualHash !== expectedHash) {
248
+ throw new Error(`vectors.json integrity check failed (SHA-256 mismatch) — run \`mdss index\` to rebuild.`);
249
+ }
250
+ }
251
+ catch (e) {
252
+ if (e.message.includes('integrity check failed'))
253
+ throw e;
254
+ }
255
+ }
256
+ const { model, dim: validatedDim } = validated;
257
+ let expectedDim = validatedDim;
258
+ if (!index.model && !index.modelAlias) {
259
+ process.stderr.write('warning: index has no "model" field (built by an old version); assuming the default model. Re-run `mdss index` to refresh.\n');
260
+ }
261
+ for (let position = 0; position < index.chunks.length; position++) {
262
+ const c = index.chunks[position];
263
+ if (c === null || typeof c !== 'object' || Array.isArray(c)) {
264
+ throw new Error(`chunk ${position} must be an object — run \`mdss index\` to rebuild`);
265
+ }
266
+ if (typeof c.vec === 'string') {
267
+ if (index.format !== 'binary-v1') {
268
+ throw new Error(`chunk ${c.file}: unexpected base64 vector in a legacy decimal index — run \`mdss index\` to rebuild`);
269
+ }
270
+ try {
271
+ c.vec = decodeVec(c.vec, expectedDim);
272
+ expectedDim ??= c.vec.length;
273
+ }
274
+ catch (e) {
275
+ const where = c.file + (c.heading ? ` › ${c.heading}` : '');
276
+ throw new Error(`chunk ${where}: ${e.message}`, { cause: e });
277
+ }
278
+ }
279
+ else {
280
+ const where = c.file + (c.heading ? ` › ${c.heading}` : '');
281
+ const length = validateNumericVector(c.vec, expectedDim, `chunk ${where}`);
282
+ expectedDim ??= length;
283
+ }
284
+ }
285
+ warnIfStale(index);
286
+ const ivfPath = path.join(indexDir, 'ivf.json');
287
+ let ivf;
288
+ if (fs.existsSync(ivfPath)) {
289
+ try {
290
+ const raw = JSON.parse(fs.readFileSync(ivfPath, 'utf8'));
291
+ ivf = deserializeIVF(raw);
292
+ }
293
+ catch { }
294
+ }
295
+ runtimeIndexStates.set(index, snapshotRuntimeIndex(index, { schema: validated.schema, dim: expectedDim, model, ivf }));
296
+ return { index, model };
297
+ }
298
+ export const MAX_QUERY_LENGTH = 2048;
299
+ export async function searchIndex(opts) {
300
+ const { loaded, cacheDir, query, k = 6, semanticOnly = false, offline = false, path: pathFilter, since, rerank = false, rerankPool, tag, project, type, status, canonicalOnly, custom, } = opts;
301
+ if (typeof query === 'string' && query.length > MAX_QUERY_LENGTH) {
302
+ throw new Error(`query exceeds maximum length of ${MAX_QUERY_LENGTH} characters`);
303
+ }
304
+ const embedFn = opts.embedFn ?? embed;
305
+ const rerankFn = opts.rerankFn ?? rerankScores;
306
+ const { index } = loaded;
307
+ const runtime = validateRuntimeIndex(index);
308
+ const { chunks, db, lexicalState, expectedDim } = runtime;
309
+ const pathRes = pathFilter ? (Array.isArray(pathFilter) ? pathFilter : [pathFilter]).map(globToRegExp) : [];
310
+ let sinceMs;
311
+ if (since !== undefined) {
312
+ sinceMs = since instanceof Date ? since.getTime() : Date.parse(String(since));
313
+ if (Number.isNaN(sinceMs))
314
+ throw new Error(`Invalid --since date: "${since}" (use YYYY-MM-DD or ISO 8601)`);
315
+ }
316
+ let candidates = chunks.map((chunk, idx) => ({ chunk, idx }));
317
+ const hasMetaFilter = tag !== undefined ||
318
+ project !== undefined ||
319
+ type !== undefined ||
320
+ status !== undefined ||
321
+ canonicalOnly !== undefined ||
322
+ custom !== undefined;
323
+ if (pathRes.length > 0 || sinceMs !== undefined || hasMetaFilter) {
324
+ if (sinceMs !== undefined && !db) {
325
+ throw new Error('--since requires an index that knows its --db (index.db missing). Re-run `mdss index`.');
326
+ }
327
+ const mtimeCache = new Map();
328
+ candidates = candidates.filter(({ chunk }) => {
329
+ if (pathRes.length > 0 && !pathRes.some((re) => re.test(chunk.file)))
330
+ return false;
331
+ if (sinceMs !== undefined) {
332
+ let m = mtimeCache.get(chunk.file);
333
+ if (m === undefined) {
334
+ try {
335
+ m = fs.statSync(path.join(db, chunk.file)).mtimeMs;
336
+ }
337
+ catch {
338
+ m = -Infinity;
339
+ }
340
+ mtimeCache.set(chunk.file, m);
341
+ }
342
+ if (m < sinceMs)
343
+ return false;
344
+ }
345
+ if (tag !== undefined) {
346
+ const reqTags = (Array.isArray(tag) ? tag : [tag]).map((t) => String(t).toLowerCase().replace(/^#/, ''));
347
+ const chunkTags = chunk.meta?.tags || [];
348
+ if (!reqTags.every((rt) => chunkTags.includes(rt)))
349
+ return false;
350
+ }
351
+ if (project !== undefined && chunk.meta?.project !== project)
352
+ return false;
353
+ if (type !== undefined && chunk.meta?.type !== type)
354
+ return false;
355
+ if (status !== undefined && chunk.meta?.status !== status)
356
+ return false;
357
+ if (canonicalOnly && chunk.meta?.canonical === false)
358
+ return false;
359
+ if (custom && typeof custom === 'object') {
360
+ for (const [key, value] of Object.entries(custom)) {
361
+ if (chunk.meta?.custom?.[key] !== value)
362
+ return false;
363
+ }
364
+ }
365
+ return true;
366
+ });
367
+ }
368
+ let qVec;
369
+ if (opts.useQueryCache !== false && runtime.queryCache) {
370
+ const cacheKey = `${runtime.model.id}:${runtime.model.revision || 'main'}:${expectedDim || 0}:${query.trim().toLowerCase()}`;
371
+ qVec = await runtime.queryCache.getOrCompute(cacheKey, async () => {
372
+ const [v] = await embedFn([query], 'query', runtime.model, cacheDir, offline);
373
+ if ((Array.isArray(v) || v instanceof Float32Array) && expectedDim !== undefined && v.length !== expectedDim) {
374
+ throw new Error(`query vector has ${v.length} dims, expected ${expectedDim} — run \`mdss index\` to rebuild`);
375
+ }
376
+ validateNumericVector(v, undefined, 'query vector');
377
+ return v;
378
+ });
379
+ }
380
+ else {
381
+ const [v] = await embedFn([query], 'query', runtime.model, cacheDir, offline);
382
+ if ((Array.isArray(v) || v instanceof Float32Array) && expectedDim !== undefined && v.length !== expectedDim) {
383
+ throw new Error(`query vector has ${v.length} dims, expected ${expectedDim} — run \`mdss index\` to rebuild`);
384
+ }
385
+ validateNumericVector(v, undefined, 'query vector');
386
+ qVec = v;
387
+ }
388
+ const queryTerms = [...new Set(tokenize(query))];
389
+ let semantic;
390
+ const useAnn = opts.ann ?? (runtime.ivf !== undefined && candidates.length >= ANN_THRESHOLD);
391
+ if (useAnn && candidates.length >= ANN_THRESHOLD) {
392
+ if (!runtime.ivf) {
393
+ const rawVecs = chunks.map((c) => c.vec);
394
+ runtime.ivf = trainIVF(rawVecs);
395
+ }
396
+ const qVecF32 = qVec instanceof Float32Array ? qVec : Float32Array.from(qVec);
397
+ const probedSet = new Set(searchIVFCandidates(qVecF32, runtime.ivf, opts.nprobe ?? DEFAULT_NPROBE));
398
+ semantic = candidates.map(({ chunk, idx }) => {
399
+ if (!probedSet.has(idx)) {
400
+ return { idx, score: -1 };
401
+ }
402
+ return { idx, score: cosine(qVec, chunk.vec) };
403
+ });
404
+ }
405
+ else {
406
+ semantic = candidates.map(({ chunk, idx }) => {
407
+ return { idx, score: cosine(qVec, chunk.vec) };
408
+ });
409
+ }
410
+ const cosByIdx = new Map(semantic.map((s) => [s.idx, s.score]));
411
+ const pool = rerank ? rerankPool || Math.max(20, k * 3) : k;
412
+ let ranked;
413
+ if (semanticOnly) {
414
+ ranked = [...semantic]
415
+ .sort((a, b) => b.score - a.score)
416
+ .slice(0, pool)
417
+ .map((s) => ({ idx: s.idx, fscore: s.score, cos: s.score }));
418
+ }
419
+ else {
420
+ const kw = lexicalState.kind === 'persisted-bm25'
421
+ ? [...bm25Scores(lexicalState.lexical, queryTerms, new Set(candidates.map((c) => c.idx))).entries()].map(([idx, score]) => ({ idx, score }))
422
+ : keywordScores(candidates.map((c) => c.chunk), query).map((score, position) => ({
423
+ idx: candidates[position].idx,
424
+ score,
425
+ }));
426
+ const fuzzy = [...fuzzyTitleAliasScores(candidates.map((c) => c.chunk), query).entries()].map(([position, score]) => ({
427
+ idx: candidates[position].idx,
428
+ score,
429
+ }));
430
+ const fused = rrf([semantic, kw, fuzzy]);
431
+ ranked = [...fused.entries()]
432
+ .map(([idx, fscore]) => ({ idx, fscore, cos: cosByIdx.get(idx) }))
433
+ .sort((a, b) => b.fscore - a.fscore)
434
+ .slice(0, pool);
435
+ }
436
+ if (rerank && ranked.length > 0) {
437
+ const texts = ranked.map((r) => chunks[r.idx].text);
438
+ const scores = await rerankFn(query, texts, cacheDir, offline);
439
+ ranked = ranked
440
+ .map((r, i) => ({ ...r, rerank: scores[i] ?? -Infinity }))
441
+ .sort((a, b) => b.rerank - a.rerank)
442
+ .slice(0, k);
443
+ }
444
+ const hasMetaFields = (m) => Boolean(m &&
445
+ (m.tags?.length > 0 ||
446
+ m.aliases?.length > 0 ||
447
+ m.project ||
448
+ m.type ||
449
+ m.status ||
450
+ m.canonical !== undefined ||
451
+ m.canonicalRef ||
452
+ m.created ||
453
+ m.updated ||
454
+ Object.keys(m.custom || {}).length > 0));
455
+ const explain = opts.explain === true;
456
+ const hits = ranked.map((r) => {
457
+ const c = chunks[r.idx];
458
+ const matches = lexicalState.kind === 'persisted-bm25'
459
+ ? matchingTerms(lexicalState.lexical, queryTerms, r.idx)
460
+ : queryTerms.filter((term) => tokenSet(c).has(term));
461
+ return {
462
+ file: c.file,
463
+ title: c.title,
464
+ heading: c.heading,
465
+ cosine: +r.cos.toFixed(3),
466
+ score: +r.fscore.toFixed(4),
467
+ matches,
468
+ snippet: c.text.replace(/\s+/g, ' ').slice(0, 220),
469
+ ...(hasMetaFields(c.meta) ? { meta: c.meta } : {}),
470
+ ...(explain
471
+ ? {
472
+ explain: {
473
+ cosine: +r.cos.toFixed(4),
474
+ rrfScore: +r.fscore.toFixed(4),
475
+ bm25Weights: { title: 3.0, aliases: 3.0, headingPath: 1.8, body: 1.0 },
476
+ },
477
+ }
478
+ : {}),
479
+ ...(rerank ? { rerankScore: +(r.rerank ?? 0).toFixed(4) } : {}),
480
+ };
481
+ });
482
+ const maxPerDoc = opts.maxPerFile || opts.maxPerDoc;
483
+ if (maxPerDoc && maxPerDoc > 0) {
484
+ return collapseResults(hits, (h) => h.meta?.canonicalRef || h.file, maxPerDoc).slice(0, k);
485
+ }
486
+ return hits;
487
+ }
488
+ export async function search(opts) {
489
+ const { indexDir, ...rest } = opts;
490
+ const loaded = loadIndex(indexDir);
491
+ return searchIndex({ ...rest, loaded });
492
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Long-running daemon mode (issue #12): keeps the parsed index AND the
3
+ * embedding extractor in memory across queries, so repeated searches skip
4
+ * the ~280 MB model load and the full vectors.json parse on every call.
5
+ */
6
+ import http from 'node:http';
7
+ import { ModelDescriptor } from './core.js';
8
+ declare const DEFAULT_PORT = 8747;
9
+ declare const DEFAULT_HOST = "127.0.0.1";
10
+ declare const WATCH_INTERVAL_MS = 3000;
11
+ declare const MAX_BODY_BYTES: number;
12
+ export interface ServeState {
13
+ loaded: {
14
+ index: any;
15
+ model: ModelDescriptor;
16
+ };
17
+ indexDir: string;
18
+ cacheDir: string;
19
+ offline: boolean;
20
+ embedFn?: any;
21
+ rerankFn?: any;
22
+ watching: boolean;
23
+ reindexCount?: number;
24
+ apiKey?: string;
25
+ healthPublic?: boolean;
26
+ }
27
+ export interface CreateServeOptions {
28
+ indexDir: string;
29
+ cacheDir: string;
30
+ db?: string;
31
+ modelName?: string;
32
+ ignore?: string[];
33
+ offline?: boolean;
34
+ watch?: boolean;
35
+ watchInterval?: number;
36
+ watchDelay?: number;
37
+ apiKey?: string;
38
+ healthPublic?: boolean;
39
+ embedFn?: any;
40
+ rerankFn?: any;
41
+ log?: (msg: string) => void;
42
+ }
43
+ export declare function createServe(opts: CreateServeOptions): Promise<{
44
+ server: http.Server;
45
+ state: ServeState;
46
+ close: () => Promise<void>;
47
+ }>;
48
+ export declare function isAuthorizedToken(token?: string | null, expectedKey?: string | null): boolean;
49
+ export { DEFAULT_PORT, DEFAULT_HOST, MAX_BODY_BYTES, WATCH_INTERVAL_MS };