md-semantic-search 0.3.0 → 0.4.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/src/search.mjs CHANGED
@@ -1,32 +1,67 @@
1
+ // @ts-check
1
2
  /**
2
- * Hybrid semantic + lexical search over a prebuilt index.
3
- * Ranking = Reciprocal Rank Fusion of cosine similarity (meaning) and
4
- * term-overlap (exact names). The model is read from the index, so callers
5
- * never have to repeat --model at search time.
3
+ * Hybrid semantic + lexical search over a prebuilt index. Schema-v3 uses
4
+ * persisted BM25 postings; schema-v0/v1/v2 retain exact token overlap.
5
+ * Both lexical lanes fuse with cosine ranking through Reciprocal Rank Fusion.
6
6
  */
7
7
  import fs from 'node:fs';
8
8
  import path from 'node:path';
9
- import { embed, cosine, resolveModel } from './core.mjs';
10
-
11
- // Common ru/en function words — they match everywhere and pollute lexical scores.
12
- const STOP = new Set([
13
- 'the', 'and', 'for', 'are', 'was', 'has', 'with', 'this', 'that', 'from',
14
- 'not', 'but', 'you', 'your', 'can', 'all', 'any', 'its',
15
- 'все', 'как', 'что', 'это', 'при', 'для', 'или', 'был', 'без', 'над',
16
- 'под', 'так', 'его', 'нет', 'код', 'кода', 'есть',
17
- ]);
18
-
19
- export function tokenize(text) {
20
- const m = text.toLowerCase().match(/[\p{L}\p{N}]{3,}/gu);
21
- return m ? m.filter(t => !STOP.has(t)) : [];
9
+ import { embed, cosine, decodeVec, globToRegExp, walkMarkdown } from './core.mjs';
10
+ import { validateIndexEnvelope, validateNumericVector } from './index-format.mjs';
11
+ import { rerankScores } from './rerank.mjs';
12
+ import { bm25Scores, matchingTerms, tokenize, fuzzyTitleAliasScores } from './lexical.mjs';
13
+ import { collapseResults } from './collapse.mjs';
14
+ export { tokenize } from './lexical.mjs';
15
+
16
+ // In-memory token-set cache for the lexical lane (issue #18). Tokenizing the
17
+ // corpus is a pure function of the chunk texts — but keywordScores used to
18
+ // re-tokenize ~1400 chars × chunk-count on EVERY query (~28M chars per query
19
+ // at the documented 10–20k chunk scale). A token Set is computed lazily once
20
+ // per CHUNK OBJECT and cached in a WeakMap, so sets survive across searchIndex
21
+ // calls AND across filtered views (--path/--since produce a new chunks ARRAY
22
+ // per query but the chunk OBJECTS are shared — caching per array would
23
+ // re-tokenize every filtered query; per chunk object never does), and are
24
+ // dropped with the index itself (no invalidation logic: one-shot CLI exits,
25
+ // serve replaces the whole `loaded` on re-index). Entries are shared-but-
26
+ // never-mutated after construction, so concurrent awaits are safe.
27
+ const chunkTokens = new WeakMap();
28
+ // Test observability (issue #18 acceptance criterion "tokenize ONCE"): total
29
+ // CHUNK-text characters tokenized through the cache (query tokens excluded).
30
+ // Exported for tests only — not part of the public API.
31
+ export const _stats = { corpusTokenizedChars: 0 };
32
+
33
+ /**
34
+ * Get (or lazily build) the token Set for one chunk. Cached per chunk object.
35
+ * @param {IndexChunk} c
36
+ * @returns {Set<string>}
37
+ */
38
+ function tokenSet(c) {
39
+ let s = chunkTokens.get(c);
40
+ if (!s) {
41
+ const text = `${c.title} ${c.heading} ${c.text}`;
42
+ _stats.corpusTokenizedChars += text.length;
43
+ s = new Set(tokenize(text));
44
+ chunkTokens.set(c, s);
45
+ }
46
+ return s;
22
47
  }
23
48
 
49
+ /**
50
+ * Legacy compatibility helper for schema-v0/v1/v2 and public API consumers.
51
+ * Scores by TOKEN OVERLAP (set intersection of query terms vs chunk
52
+ * terms), not substring. "win" must NOT match "window", "token" must NOT match
53
+ * "tokens" — only exact token overlap counts (issue #7). The haystack is
54
+ * tokenized once per chunk (issue #18 — cached per loaded index, not per
55
+ * query) with the same tokenize() as the query.
56
+ * @returns {number[]} per-chunk count of overlapping unique terms
57
+ */
24
58
  export function keywordScores(chunks, query) {
25
59
  const qTerms = new Set(tokenize(query));
60
+ if (qTerms.size === 0) return chunks.map(() => 0);
26
61
  return chunks.map(c => {
27
- const hay = `${c.title} ${c.heading} ${c.text}`.toLowerCase();
62
+ const hay = tokenSet(c);
28
63
  let s = 0;
29
- for (const t of qTerms) if (hay.includes(t)) s++;
64
+ for (const t of qTerms) if (hay.has(t)) s++;
30
65
  return s;
31
66
  });
32
67
  }
@@ -36,72 +71,566 @@ export function rrf(rankings, k = 60) {
36
71
  const fused = new Map();
37
72
  for (const ranking of rankings) {
38
73
  const sorted = [...ranking].sort((a, b) => b.score - a.score);
74
+ let scoreRank = 0;
39
75
  sorted.forEach((item, rank) => {
40
76
  if (item.score <= 0) return;
41
- fused.set(item.idx, (fused.get(item.idx) || 0) + 1 / (k + rank + 1));
77
+ if (rank > 0 && item.score < sorted[rank - 1].score) scoreRank = rank;
78
+ fused.set(item.idx, (fused.get(item.idx) || 0) + 1 / (k + scoreRank + 1));
42
79
  });
43
80
  }
44
81
  return fused;
45
82
  }
46
83
 
47
84
  /**
85
+ * @typedef {Object} IndexChunk
86
+ * @property {string} file
87
+ * @property {string} title
88
+ * @property {string} heading
89
+ * @property {string[]} [headingPath] - absent only on readable schema-v0/v1 indexes
90
+ * @property {string} text
91
+ * @property {number[]|Float32Array} vec
92
+ * @property {string} [chunkHash]
93
+ * @property {number} [startLine]
94
+ * @property {number} [endLine]
95
+ * @property {import('./frontmatter.mjs').DocumentMetadata} [meta]
96
+ */
97
+
98
+ /**
99
+ * @typedef {Object} IndexFile
100
+ * @property {string} [format]
101
+ * @property {number} [schemaVersion]
102
+ * @property {string} [model]
103
+ * @property {string} [modelAlias]
104
+ * @property {number} [dim]
105
+ * @property {string} [built]
106
+ * @property {number} [chunkCount]
107
+ * @property {string} [db] - markdown base the index was built from (issue #13 --since)
108
+ * @property {import('./lexical.mjs').LexicalIndex} [lexical]
109
+ * @property {IndexChunk[]} chunks
110
+ */
111
+
112
+ /**
113
+ * Bounded LRU cache for query vectors in daemon/serve mode (issue #33).
114
+ */
115
+ export class QueryEmbeddingCache {
116
+ /**
117
+ * @param {number} [maxSize=100]
118
+ */
119
+ constructor(maxSize = 100) {
120
+ this.maxSize = maxSize;
121
+ /** @type {Map<string, Float32Array>} */
122
+ this.cache = new Map();
123
+ /** @type {Map<string, Promise<Float32Array>>} */
124
+ this.inFlight = new Map();
125
+ }
126
+
127
+ /**
128
+ * @param {string} key
129
+ * @returns {Float32Array|undefined}
130
+ */
131
+ get(key) {
132
+ if (!this.cache.has(key)) return undefined;
133
+ const value = this.cache.get(key);
134
+ this.cache.delete(key);
135
+ this.cache.set(key, /** @type {Float32Array} */ (value));
136
+ return value;
137
+ }
138
+
139
+ /**
140
+ * @param {string} key
141
+ * @param {number[]|Float32Array} vector
142
+ */
143
+ set(key, vector) {
144
+ if (!Array.isArray(vector) && !(vector instanceof Float32Array)) return;
145
+ if (vector.length === 0) return;
146
+ const arr = vector instanceof Float32Array ? vector : Float32Array.from(vector);
147
+ for (let i = 0; i < arr.length; i++) {
148
+ if (!Number.isFinite(arr[i])) return;
149
+ }
150
+ if (this.cache.has(key)) {
151
+ this.cache.delete(key);
152
+ } else if (this.cache.size >= this.maxSize) {
153
+ const oldestKey = this.cache.keys().next().value;
154
+ if (oldestKey !== undefined) this.cache.delete(oldestKey);
155
+ }
156
+ this.cache.set(key, arr);
157
+ }
158
+
159
+ /**
160
+ * Get or compute with coalescing of concurrent in-flight requests.
161
+ * @param {string} key
162
+ * @param {() => Promise<number[]|Float32Array>} fn
163
+ * @returns {Promise<Float32Array>}
164
+ */
165
+ async getOrCompute(key, fn) {
166
+ const cached = this.get(key);
167
+ if (cached) return cached;
168
+ if (this.inFlight.has(key)) {
169
+ return this.inFlight.get(key);
170
+ }
171
+ const promise = (async () => {
172
+ try {
173
+ const raw = await fn();
174
+ this.set(key, raw);
175
+ const stored = this.get(key);
176
+ if (stored) return stored;
177
+ return raw instanceof Float32Array ? raw : Float32Array.from(raw);
178
+ } finally {
179
+ this.inFlight.delete(key);
180
+ }
181
+ })();
182
+ this.inFlight.set(key, promise);
183
+ return promise;
184
+ }
185
+
186
+ clear() {
187
+ this.cache.clear();
188
+ this.inFlight.clear();
189
+ }
190
+
191
+ get size() {
192
+ return this.cache.size;
193
+ }
194
+ }
195
+
196
+ /** @typedef {{kind:'persisted-bm25', lexical:import('./lexical.mjs').LexicalIndex}|{kind:'legacy-overlap'}} LexicalState */
197
+ /** @typedef {{file:string, title:string, heading:string, headingPath?:string[], text:string, startLine?:number, endLine?:number, meta?:import('./frontmatter.mjs').DocumentMetadata, vec:Float32Array}} RuntimeChunk */
198
+ /** @typedef {{schema:number, db:(string|undefined), chunks:RuntimeChunk[], lexicalState:LexicalState,
199
+ * expectedDim:(number|undefined), model:import('./core.mjs').ModelDescriptor, queryCache:QueryEmbeddingCache}} RuntimeIndexState */
200
+
201
+ const runtimeIndexStates = new WeakMap();
202
+
203
+ /**
204
+ * Copy exactly the state consumed by search. The public index remains mutable
205
+ * for compatibility, but no search reads it after this snapshot is created.
206
+ * @param {IndexFile} index
207
+ * @param {{schema:number, dim:(number|undefined), model:import('./core.mjs').ModelDescriptor}} validation
208
+ * @returns {RuntimeIndexState}
209
+ */
210
+ function snapshotRuntimeIndex(index, validation) {
211
+ const dim = validation.dim || (index.chunks[0]?.vec?.length ?? 0);
212
+ const count = index.chunks.length;
213
+ const vectorsBuffer = dim > 0 ? new Float32Array(count * dim) : null;
214
+
215
+ const chunks = index.chunks.map((chunk, i) => {
216
+ let vec;
217
+ if (vectorsBuffer && dim > 0) {
218
+ const offset = i * dim;
219
+ if (chunk.vec instanceof Float32Array) {
220
+ vectorsBuffer.set(chunk.vec, offset);
221
+ } else {
222
+ for (let j = 0; j < dim; j++) vectorsBuffer[offset + j] = chunk.vec[j];
223
+ }
224
+ vec = vectorsBuffer.subarray(offset, offset + dim);
225
+ } else {
226
+ vec = Float32Array.from(chunk.vec);
227
+ }
228
+
229
+ return {
230
+ file: chunk.file,
231
+ title: chunk.title,
232
+ heading: chunk.heading,
233
+ headingPath: chunk.headingPath,
234
+ text: chunk.text,
235
+ startLine: chunk.startLine,
236
+ endLine: chunk.endLine,
237
+ meta: chunk.meta,
238
+ vec,
239
+ };
240
+ });
241
+ let lexicalState = /** @type {LexicalState} */ ({ kind: 'legacy-overlap' });
242
+ if (validation.schema >= 3) {
243
+ /** @type {Record<string, Array<[number, number]>>} */
244
+ const postings = Object.create(null);
245
+ for (const [term, posting] of Object.entries(index.lexical.postings)) {
246
+ postings[term] = posting.map(([docId, frequency]) => [docId, frequency]);
247
+ }
248
+ lexicalState = {
249
+ kind: 'persisted-bm25',
250
+ lexical: {
251
+ format: index.lexical.format,
252
+ documentLengths: [...index.lexical.documentLengths],
253
+ postings,
254
+ },
255
+ };
256
+ }
257
+ return {
258
+ schema: validation.schema,
259
+ db: index.db,
260
+ chunks,
261
+ lexicalState,
262
+ expectedDim: validation.dim,
263
+ model: { ...validation.model },
264
+ queryCache: new QueryEmbeddingCache(100),
265
+ };
266
+ }
267
+
268
+ /** @param {IndexFile} index @returns {RuntimeIndexState} */
269
+ function validateRuntimeIndex(index) {
270
+ const cached = runtimeIndexStates.get(index);
271
+ if (cached) return cached;
272
+ const validated = validateIndexEnvelope(index, 'index', { encoding: 'loaded' });
273
+ let expectedDim = validated.dim;
274
+ if (validated.schema < 3) {
275
+ for (let position = 0; position < index.chunks.length; position++) {
276
+ const chunk = index.chunks[position];
277
+ if (chunk === null || typeof chunk !== 'object' || Array.isArray(chunk)) {
278
+ throw new Error(`chunk ${position} must be an object — run \`mdss index\` to rebuild`);
279
+ }
280
+ const where = chunk.file + (chunk.heading ? ` › ${chunk.heading}` : '');
281
+ const length = validateNumericVector(chunk.vec, expectedDim, `chunk ${where}`);
282
+ expectedDim ??= length;
283
+ }
284
+ }
285
+ const state = snapshotRuntimeIndex(index, {
286
+ schema: validated.schema,
287
+ dim: expectedDim,
288
+ model: validated.model,
289
+ });
290
+ runtimeIndexStates.set(index, state);
291
+ return state;
292
+ }
293
+
294
+ /**
295
+ * Warn (once, non-fatal) when the loaded index is older than the newest
296
+ * change under the markdown base it was built from (issue #20). The index
297
+ * stores both `db` and `built`, so a stale index is detectable without any
298
+ * re-embedding — just an mtime walk. Search still runs on the stale snapshot.
299
+ * @param {IndexFile} index
300
+ */
301
+ function warnIfStale(index) {
302
+ if (!index.db || !index.built) return; // legacy index: nothing to compare
303
+ const builtMs = Date.parse(index.built);
304
+ if (!Number.isFinite(builtMs)) return;
305
+ let newest = 0;
306
+ try {
307
+ for (const f of walkMarkdown(index.db)) {
308
+ const st = fs.statSync(f);
309
+ if (st.mtimeMs > newest) newest = st.mtimeMs;
310
+ }
311
+ } catch {
312
+ return; // db gone/unreadable → stay silent
313
+ }
314
+ // Tolerance: `built` is an ISO string truncated to milliseconds, while
315
+ // filesystem mtimes carry sub-millisecond precision (and some filesystems
316
+ // round up). A file written right after `built` was captured — e.g. the
317
+ // index build's own final write, or a same-second touch — must NOT look
318
+ // stale. Only warn when the newest change is clearly AFTER the build (a few
319
+ // seconds of grace), which is what a human means by "index is stale".
320
+ if (newest <= builtMs + 5000) return; // fresh within the grace period
321
+ const mins = Math.max(1, Math.round((newest - builtMs) / 60000));
322
+ process.stderr.write(
323
+ `warning: index is ${mins} min older than the newest change in ${index.db}; ` +
324
+ 'run `mdss index` to refresh.\n');
325
+ }
326
+
327
+ /**
328
+ * Parse + decode an index file once. Returns the raw index plus the resolved
329
+ * model, so a library consumer can hold BOTH in memory across searches and
330
+ * skip re-parsing vectors.json on every query (issue #2 / #14).
331
+ * @param {string} indexDir
332
+ * @returns {{index: IndexFile, model: import('./core.mjs').ModelDescriptor}}
333
+ */
334
+ export function loadIndex(indexDir) {
335
+ const vectorsPath = path.join(indexDir, 'vectors.json');
336
+ if (!fs.existsSync(vectorsPath)) {
337
+ throw new Error(`No index at ${vectorsPath}. Run \`mdss index\` first.`);
338
+ }
339
+ let parsed;
340
+ try {
341
+ parsed = JSON.parse(fs.readFileSync(vectorsPath, 'utf8'));
342
+ } catch (e) {
343
+ // Corrupt index file — name the file and the fix, no raw stack trace
344
+ // (issue #20).
345
+ throw new Error(
346
+ `${vectorsPath} is not valid JSON (${e.message}); run \`mdss index\` to rebuild.`);
347
+ }
348
+ const validated = validateIndexEnvelope(parsed, vectorsPath, { encoding: 'stored' });
349
+ const index = /** @type {IndexFile} */ (validated.index);
350
+ const { schema, model, dim: validatedDim } = validated;
351
+ let expectedDim = validatedDim;
352
+
353
+ // Validate the model the index was built with. Indexes written by v0.1.x have
354
+ // no "model" field at all — warn instead of silently assuming a default.
355
+ if (!index.model && !index.modelAlias) {
356
+ process.stderr.write('warning: index has no "model" field (built by an old ' +
357
+ 'version); assuming the default model. Re-run `mdss index` to refresh.\n');
358
+ }
359
+
360
+ // v0.4.0+ stores vectors as base64 Float32Array (issue #4); legacy ≤0.3.x
361
+ // indexes hold decimal arrays. Decode the binary format once up front so the
362
+ // cosine sweep below always sees plain numbers. Dim validation runs for BOTH
363
+ // shapes (issue #40): a wrong-length vector — from a truncated base64, a
364
+ // mixed-model index, or a partial write — used to silently produce NaN
365
+ // scores; now it fails loudly at load with the chunk's identity.
366
+ for (let position = 0; position < index.chunks.length; position++) {
367
+ const c = index.chunks[position];
368
+ if (c === null || typeof c !== 'object' || Array.isArray(c)) {
369
+ throw new Error(`chunk ${position} must be an object — run \`mdss index\` to rebuild`);
370
+ }
371
+ if (typeof c.vec === 'string') {
372
+ if (index.format !== 'binary-v1') {
373
+ throw new Error(`chunk ${c.file}: unexpected base64 vector in a legacy ` +
374
+ `decimal index — run \`mdss index\` to rebuild`);
375
+ }
376
+ try {
377
+ // decodeVec throws on corrupt base64 (truncated, non-finite, wrong dim)
378
+ c.vec = decodeVec(c.vec, expectedDim);
379
+ expectedDim ??= c.vec.length;
380
+ } catch (e) {
381
+ const where = c.file + (c.heading ? ` › ${c.heading}` : '');
382
+ throw new Error(`chunk ${where}: ${e.message}`);
383
+ }
384
+ } else {
385
+ const where = c.file + (c.heading ? ` › ${c.heading}` : '');
386
+ const length = validateNumericVector(c.vec, expectedDim, `chunk ${where}`);
387
+ expectedDim ??= length;
388
+ }
389
+ }
390
+
391
+ warnIfStale(index);
392
+
393
+ runtimeIndexStates.set(index, snapshotRuntimeIndex(index, { schema, dim: expectedDim, model }));
394
+
395
+ return { index, model };
396
+ }
397
+
398
+ /**
399
+ * Rank against an already-loaded index (see loadIndex). Reuses the parsed
400
+ * chunks, so repeated calls never re-read or re-parse vectors.json.
48
401
  * @param {object} opts
49
- * @param {string} opts.indexDir
402
+ * @param {{index: IndexFile, model: import('./core.mjs').ModelDescriptor}} opts.loaded - from loadIndex()
50
403
  * @param {string} opts.cacheDir
51
404
  * @param {string} opts.query
52
405
  * @param {number} [opts.k=6]
53
406
  * @param {boolean} [opts.semanticOnly=false]
54
407
  * @param {boolean} [opts.offline=false] - never download the model; require a cached one
408
+ * @param {string|string[]} [opts.path] - keep only chunks whose file matches
409
+ * any of these globs (issue #13)
410
+ * @param {string|Date} [opts.since] - keep only chunks from files modified at
411
+ * or after this date (ISO string or Date; resolved against index.db, #13)
412
+ * @param {boolean} [opts.rerank=false] - re-rank the candidate pool with a
413
+ * cross-encoder (issue #15). Costs a model load (~280MB) + one forward pass
414
+ * per candidate on the first query.
415
+ * @param {number} [opts.rerankPool] - how many top candidates to re-rank
416
+ * (default: max(20, k*3)). Larger = better recall, slower.
417
+ * @param {Function} [opts.rerankFn] - rerank override for tests/dependency
418
+ * injection; signature (query, texts, cacheDir, offline) => Promise<number[]>
55
419
  * @param {Function} [opts.embedFn] - embed override for tests/dependency injection;
56
420
  * signature (texts, kind, model, cacheDir, offline) => Promise<number[][]>
57
- * @returns {Promise<Array>} results with file, title, heading, cosine, score, snippet
421
+ * @param {string|string[]} [opts.tag] - filter by tag(s) (issue #58)
422
+ * @param {string} [opts.project] - filter by project (issue #58)
423
+ * @param {string} [opts.type] - filter by document type (issue #58)
424
+ * @param {string} [opts.status] - filter by status (issue #58)
425
+ * @param {boolean} [opts.canonicalOnly] - filter canonical documents only (issue #58)
426
+ * @param {Record<string, unknown>} [opts.custom] - custom metadata key-value filters (issue #58)
427
+ * @param {boolean} [opts.explain] - include explain output (issue #59)
428
+ * @param {number} [opts.maxPerFile] - cap max results per file (issue #45)
429
+ * @param {number} [opts.maxPerDoc] - cap max results per document (issue #45)
430
+ * @param {boolean} [opts.useQueryCache=true] - use query vector embedding cache (issue #33)
431
+ * @returns {Promise<Array>} results with file, title, heading, cosine, score,
432
+ * matches (query terms found in the chunk, issue #13), snippet, and
433
+ * rerankScore when reranking was enabled (issue #15)
58
434
  */
59
- export async function search(opts) {
435
+ export async function searchIndex(opts) {
60
436
  const {
61
- indexDir, cacheDir, query, k = 6, semanticOnly = false,
62
- offline = false, embedFn = embed,
437
+ loaded, cacheDir, query, k = 6, semanticOnly = false,
438
+ offline = false, path: pathFilter, since,
439
+ rerank = false, rerankPool,
440
+ tag, project, type, status, canonicalOnly, custom,
63
441
  } = opts;
64
- const vectorsPath = path.join(indexDir, 'vectors.json');
65
- if (!fs.existsSync(vectorsPath)) {
66
- throw new Error(`No index at ${vectorsPath}. Run \`mdss index\` first.`);
442
+ // `??` (not destructuring defaults): serve.mjs passes `embedFn: null` when
443
+ // no override is given, and JS defaults only fire on `undefined` — using ??
444
+ // here makes the real extractor/reranker the fallback for null AND undefined
445
+ // (issue #23).
446
+ const embedFn = opts.embedFn ?? embed;
447
+ const rerankFn = opts.rerankFn ?? rerankScores;
448
+ const { index } = loaded;
449
+ const runtime = validateRuntimeIndex(index);
450
+ const { chunks, db, lexicalState, expectedDim } = runtime;
451
+
452
+ // Candidate filtering happens BEFORE embedding/ranking (issue #13): --path
453
+ // globs, --since (file mtime), and frontmatter metadata filters (issue #58) shrink the sweep.
454
+ const pathRes = pathFilter
455
+ ? (Array.isArray(pathFilter) ? pathFilter : [pathFilter]).map(globToRegExp)
456
+ : [];
457
+ let sinceMs;
458
+ if (since !== undefined) {
459
+ sinceMs = since instanceof Date ? since.getTime() : Date.parse(String(since));
460
+ if (Number.isNaN(sinceMs)) throw new Error(`Invalid --since date: "${since}" (use YYYY-MM-DD or ISO 8601)`);
67
461
  }
68
- const index = JSON.parse(fs.readFileSync(vectorsPath, 'utf8'));
462
+ let candidates = chunks.map((chunk, idx) => ({ chunk, idx }));
463
+ const hasMetaFilter = tag !== undefined || project !== undefined || type !== undefined ||
464
+ status !== undefined || canonicalOnly !== undefined || custom !== undefined;
69
465
 
70
- // Validate the model the index was built with. Indexes written by v0.1.x have
71
- // no "model" field at all — warn instead of silently assuming a default.
72
- if (!index.model && !index.modelAlias) {
73
- process.stderr.write('warning: index has no "model" field (built by an old ' +
74
- 'version); assuming the default model. Re-run `mdss index` to refresh.\n');
466
+ if (pathRes.length > 0 || sinceMs !== undefined || hasMetaFilter) {
467
+ if (sinceMs !== undefined && !db) {
468
+ throw new Error('--since requires an index that knows its --db (index.db missing). Re-run `mdss index`.');
469
+ }
470
+ const mtimeCache = new Map();
471
+ candidates = candidates.filter(({ chunk }) => {
472
+ if (pathRes.length > 0 && !pathRes.some(re => re.test(chunk.file))) return false;
473
+ if (sinceMs !== undefined) {
474
+ let m = mtimeCache.get(chunk.file);
475
+ if (m === undefined) {
476
+ try { m = fs.statSync(path.join(db, chunk.file)).mtimeMs; }
477
+ catch { m = -Infinity; } // file vanished → chunk no longer eligible
478
+ mtimeCache.set(chunk.file, m);
479
+ }
480
+ if (m < sinceMs) return false;
481
+ }
482
+ if (tag !== undefined) {
483
+ const reqTags = (Array.isArray(tag) ? tag : [tag]).map(t => String(t).toLowerCase().replace(/^#/, ''));
484
+ const chunkTags = chunk.meta?.tags || [];
485
+ if (!reqTags.every(rt => chunkTags.includes(rt))) return false;
486
+ }
487
+ if (project !== undefined && chunk.meta?.project !== project) return false;
488
+ if (type !== undefined && chunk.meta?.type !== type) return false;
489
+ if (status !== undefined && chunk.meta?.status !== status) return false;
490
+ if (canonicalOnly && chunk.meta?.canonical === false) return false;
491
+ if (custom && typeof custom === 'object') {
492
+ for (const [k, v] of Object.entries(custom)) {
493
+ if (chunk.meta?.custom?.[k] !== v) return false;
494
+ }
495
+ }
496
+ return true;
497
+ });
75
498
  }
76
- const model = resolveModel(index.modelAlias || index.model);
77
499
 
78
- const [qVec] = await embedFn([query], 'query', model, cacheDir, offline);
500
+ let qVec;
501
+ if (opts.useQueryCache !== false && runtime.queryCache) {
502
+ const cacheKey = `${runtime.model.id}:${runtime.model.revision || 'main'}:${expectedDim || 0}:${query.trim().toLowerCase()}`;
503
+ qVec = await runtime.queryCache.getOrCompute(cacheKey, async () => {
504
+ const [v] = await embedFn([query], 'query', runtime.model, cacheDir, offline);
505
+ if ((Array.isArray(v) || v instanceof Float32Array) &&
506
+ expectedDim !== undefined && v.length !== expectedDim) {
507
+ throw new Error(`query vector has ${v.length} dims, expected ${expectedDim} — ` +
508
+ 'run `mdss index` to rebuild');
509
+ }
510
+ validateNumericVector(v, undefined, 'query vector');
511
+ return v;
512
+ });
513
+ } else {
514
+ const [v] = await embedFn([query], 'query', runtime.model, cacheDir, offline);
515
+ if ((Array.isArray(v) || v instanceof Float32Array) &&
516
+ expectedDim !== undefined && v.length !== expectedDim) {
517
+ throw new Error(`query vector has ${v.length} dims, expected ${expectedDim} — ` +
518
+ 'run `mdss index` to rebuild');
519
+ }
520
+ validateNumericVector(v, undefined, 'query vector');
521
+ qVec = v;
522
+ }
523
+ const queryTerms = [...new Set(tokenize(query))];
79
524
 
80
- const semantic = index.chunks.map((c, idx) => ({ idx, score: cosine(qVec, c.vec) }));
525
+ const semantic = candidates.map(({ chunk, idx }) => {
526
+ return { idx, score: cosine(qVec, chunk.vec) };
527
+ });
81
528
  const cosByIdx = new Map(semantic.map(s => [s.idx, s.score]));
82
529
 
530
+ // Candidate pool: without rerank we rank exactly k; with rerank we pull a
531
+ // wider pool (default max(20, k*3)) and let the cross-encoder pick the best k.
532
+ const pool = rerank ? (rerankPool || Math.max(20, k * 3)) : k;
533
+
83
534
  let ranked;
84
535
  if (semanticOnly) {
85
- ranked = [...semantic].sort((a, b) => b.score - a.score).slice(0, k)
536
+ ranked = [...semantic].sort((a, b) => b.score - a.score).slice(0, pool)
86
537
  .map(s => ({ idx: s.idx, fscore: s.score, cos: s.score }));
87
538
  } else {
88
- const kw = keywordScores(index.chunks, query).map((score, idx) => ({ idx, score }));
89
- const fused = rrf([semantic, kw]);
539
+ const kw = lexicalState.kind === 'persisted-bm25'
540
+ ? [...bm25Scores(lexicalState.lexical, queryTerms,
541
+ new Set(candidates.map(candidate => candidate.idx))).entries()]
542
+ .map(([idx, score]) => ({ idx, score }))
543
+ : keywordScores(candidates.map(candidate => candidate.chunk), query)
544
+ .map((score, position) => ({ idx: candidates[position].idx, score }));
545
+
546
+ const fuzzy = [...fuzzyTitleAliasScores(candidates.map(c => c.chunk), query).entries()]
547
+ .map(([position, score]) => ({ idx: candidates[position].idx, score }));
548
+
549
+ const fused = rrf([semantic, kw, fuzzy]);
90
550
  ranked = [...fused.entries()]
91
551
  .map(([idx, fscore]) => ({ idx, fscore, cos: cosByIdx.get(idx) }))
92
552
  .sort((a, b) => b.fscore - a.fscore)
553
+ .slice(0, pool);
554
+ }
555
+
556
+ // Cross-encoder re-ranking (issue #15): score each candidate as a
557
+ // (query, passage) pair, then keep the top-k by the pairwise score.
558
+ if (rerank && ranked.length > 0) {
559
+ const texts = ranked.map(r => chunks[r.idx].text);
560
+ const scores = await rerankFn(query, texts, cacheDir, offline);
561
+ ranked = ranked
562
+ .map((r, i) => ({ ...r, rerank: scores[i] ?? -Infinity }))
563
+ .sort((a, b) => b.rerank - a.rerank)
93
564
  .slice(0, k);
94
565
  }
95
566
 
96
- return ranked.map(r => {
97
- const c = index.chunks[r.idx];
567
+ const hasMetaFields = (m) => Boolean(m && (m.tags?.length > 0 || m.aliases?.length > 0 || m.project || m.type || m.status || m.canonical !== undefined || m.canonicalRef || m.created || m.updated || Object.keys(m.custom || {}).length > 0));
568
+
569
+ const explain = opts.explain === true;
570
+
571
+ // V3 matches come from postings; legacy indexes reuse cached token sets.
572
+ const hits = ranked.map(r => {
573
+ const c = chunks[r.idx];
574
+ const matches = lexicalState.kind === 'persisted-bm25'
575
+ ? matchingTerms(lexicalState.lexical, queryTerms, r.idx)
576
+ : queryTerms.filter(term => tokenSet(c).has(term));
98
577
  return {
99
578
  file: c.file,
100
579
  title: c.title,
101
580
  heading: c.heading,
102
581
  cosine: +r.cos.toFixed(3),
103
582
  score: +r.fscore.toFixed(4),
583
+ matches,
104
584
  snippet: c.text.replace(/\s+/g, ' ').slice(0, 220),
585
+ ...(hasMetaFields(c.meta) ? { meta: c.meta } : {}),
586
+ ...(explain ? { explain: { cosine: +r.cos.toFixed(4), rrfScore: +r.fscore.toFixed(4), bm25Weights: { title: 3.0, aliases: 3.0, headingPath: 1.8, body: 1.0 } } } : {}),
587
+ ...(rerank ? { rerankScore: +(r.rerank ?? 0).toFixed(4) } : {}),
105
588
  };
106
589
  });
590
+
591
+ const maxPerDoc = opts.maxPerFile || opts.maxPerDoc;
592
+ if (maxPerDoc && maxPerDoc > 0) {
593
+ return collapseResults(hits, h => h.meta?.canonical || h.file, maxPerDoc).slice(0, k);
594
+ }
595
+ return hits;
596
+ }
597
+
598
+ /**
599
+ * One-shot search: parse the index, rank, return results. For repeated queries
600
+ * in one process prefer loadIndex() + searchIndex() to skip re-parsing.
601
+ * @param {object} opts
602
+ * @param {string} opts.indexDir
603
+ * @param {string} opts.cacheDir
604
+ * @param {string} opts.query
605
+ * @param {number} [opts.k=6]
606
+ * @param {boolean} [opts.semanticOnly=false]
607
+ * @param {boolean} [opts.offline=false] - never download the model; require a cached one
608
+ * @param {string|string[]} [opts.path] - keep only chunks whose file matches
609
+ * any of these globs (issue #13)
610
+ * @param {string|Date} [opts.since] - keep only chunks from files modified at
611
+ * or after this date (issue #13)
612
+ * @param {boolean} [opts.rerank=false] - re-rank the candidate pool with a
613
+ * cross-encoder (issue #15)
614
+ * @param {number} [opts.rerankPool] - candidate pool size for rerank
615
+ * @param {Function} [opts.rerankFn] - rerank override for tests; signature
616
+ * (query, texts, cacheDir, offline) => Promise<number[]>
617
+ * @param {Function} [opts.embedFn] - embed override for tests/dependency injection;
618
+ * signature (texts, kind, model, cacheDir, offline) => Promise<number[][]>
619
+ * @param {string|string[]} [opts.tag] - filter by tag(s) (issue #58)
620
+ * @param {string} [opts.project] - filter by project (issue #58)
621
+ * @param {string} [opts.type] - filter by document type (issue #58)
622
+ * @param {string} [opts.status] - filter by status (issue #58)
623
+ * @param {boolean} [opts.canonicalOnly] - filter canonical documents only (issue #58)
624
+ * @param {Record<string, unknown>} [opts.custom] - custom metadata key-value filters (issue #58)
625
+ * @param {boolean} [opts.explain] - include explain output (issue #59)
626
+ * @param {number} [opts.maxPerFile] - cap max results per file (issue #45)
627
+ * @param {number} [opts.maxPerDoc] - cap max results per document (issue #45)
628
+ * @param {boolean} [opts.useQueryCache=true] - use query vector embedding cache (issue #33)
629
+ * @returns {Promise<Array>} results with file, title, heading, cosine, score,
630
+ * matches (query terms found in the chunk, issue #13), snippet
631
+ */
632
+ export async function search(opts) {
633
+ const { indexDir, ...rest } = opts;
634
+ const loaded = loadIndex(indexDir);
635
+ return searchIndex({ ...rest, loaded });
107
636
  }