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/README.md +357 -20
- package/RESEARCH.md +117 -37
- package/bin/cli.mjs +573 -32
- package/package.json +11 -2
- package/src/collapse.mjs +31 -0
- package/src/core.mjs +424 -60
- package/src/frontmatter.mjs +213 -0
- package/src/index-format.mjs +134 -0
- package/src/index.mjs +47 -0
- package/src/indexer.mjs +390 -47
- package/src/lexical.mjs +291 -0
- package/src/markdown-parser.mjs +347 -0
- package/src/metrics.mjs +186 -0
- package/src/models.mjs +250 -18
- package/src/rerank.mjs +75 -0
- package/src/search.mjs +571 -42
- package/src/serve.mjs +387 -0
- package/src/wikilinks.mjs +241 -0
package/src/search.mjs
CHANGED
|
@@ -1,32 +1,67 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
/**
|
|
2
|
-
* Hybrid semantic + lexical search over a prebuilt index.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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,
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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 =
|
|
62
|
+
const hay = tokenSet(c);
|
|
28
63
|
let s = 0;
|
|
29
|
-
for (const t of qTerms) if (hay.
|
|
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
|
-
|
|
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 {
|
|
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
|
-
* @
|
|
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
|
|
435
|
+
export async function searchIndex(opts) {
|
|
60
436
|
const {
|
|
61
|
-
|
|
62
|
-
offline = false,
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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 =
|
|
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,
|
|
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 =
|
|
89
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
}
|