md-semantic-search 1.0.3 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +132 -16
- package/RESEARCH.md +207 -3
- package/bin/cli.mjs +449 -13
- package/dist/binary-format.js +9 -0
- package/dist/completions.js +11 -1
- package/dist/core.d.ts +42 -4
- package/dist/core.js +208 -14
- package/dist/federation.d.ts +16 -1
- package/dist/federation.js +33 -16
- package/dist/filter.d.ts +7 -0
- package/dist/filter.js +13 -0
- package/dist/hnsw.d.ts +96 -0
- package/dist/hnsw.js +326 -0
- package/dist/index-format.js +8 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.js +4 -4
- package/dist/indexer.d.ts +18 -1
- package/dist/indexer.js +92 -18
- package/dist/ivf.d.ts +14 -7
- package/dist/ivf.js +126 -18
- package/dist/lexical.d.ts +10 -0
- package/dist/lexical.js +98 -15
- package/dist/markdown-parser.d.ts +62 -0
- package/dist/markdown-parser.js +227 -30
- package/dist/mcp.d.ts +229 -7
- package/dist/mcp.js +743 -44
- package/dist/models.d.ts +8 -2
- package/dist/models.js +84 -32
- package/dist/providers.d.ts +56 -0
- package/dist/providers.js +305 -0
- package/dist/quantization.d.ts +12 -0
- package/dist/quantization.js +54 -0
- package/dist/rag.d.ts +132 -7
- package/dist/rag.js +314 -38
- package/dist/repair.d.ts +1 -1
- package/dist/repair.js +53 -2
- package/dist/rerank.d.ts +51 -6
- package/dist/rerank.js +69 -10
- package/dist/search.d.ts +49 -3
- package/dist/search.js +387 -78
- package/dist/serve.d.ts +35 -0
- package/dist/serve.js +574 -58
- package/dist/tui.d.ts +14 -0
- package/dist/tui.js +132 -15
- package/dist/watcher.js +23 -12
- package/dist/webui.d.ts +3 -3
- package/dist/webui.js +225 -5
- package/dist/wikilinks.d.ts +31 -0
- package/dist/wikilinks.js +185 -1
- package/package.json +3 -3
- package/src/binary-format.ts +13 -0
- package/src/completions.ts +11 -1
- package/src/core.ts +249 -13
- package/src/federation.ts +51 -17
- package/src/filter.ts +14 -0
- package/src/hnsw.ts +414 -0
- package/src/index-format.ts +7 -1
- package/src/index.ts +7 -2
- package/src/indexer.ts +112 -19
- package/src/ivf.ts +157 -20
- package/src/lexical.ts +91 -13
- package/src/markdown-parser.ts +279 -37
- package/src/mcp.ts +820 -47
- package/src/models.ts +97 -35
- package/src/providers.ts +345 -0
- package/src/quantization.ts +61 -0
- package/src/rag.ts +451 -34
- package/src/repair.ts +51 -2
- package/src/rerank.ts +121 -14
- package/src/search.ts +424 -82
- package/src/serve.ts +589 -66
- package/src/tui.ts +142 -16
- package/src/watcher.ts +21 -10
- package/src/webui.ts +225 -5
- package/src/wikilinks.ts +220 -1
package/src/search.ts
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import crypto from 'node:crypto';
|
|
4
|
-
import { embed, cosine, decodeVec, globToRegExp, walkMarkdown, ModelDescriptor } from './core.js';
|
|
4
|
+
import { embed, cosine, decodeVec, globToRegExp, walkMarkdown, fetchTimeoutMs, ModelDescriptor } from './core.js';
|
|
5
5
|
import { validateIndexEnvelope, validateNumericVector } from './index-format.js';
|
|
6
6
|
import { rerankScores } from './rerank.js';
|
|
7
7
|
import { bm25Scores, matchingTerms, tokenize, fuzzyTitleAliasScores, LexicalIndex } from './lexical.js';
|
|
8
8
|
import { collapseResults } from './collapse.js';
|
|
9
9
|
import { DocumentMetadata } from './frontmatter.js';
|
|
10
10
|
import { trainIVF, searchIVFCandidates, deserializeIVF, IVFIndex, ANN_THRESHOLD, DEFAULT_NPROBE } from './ivf.js';
|
|
11
|
+
import { buildHnsw, searchHnswCandidates, deserializeHnsw, HnswIndex, DEFAULT_HNSW_EF } from './hnsw.js';
|
|
11
12
|
import { buildRelationshipGraph, computePageRank, expandGraphNeighborhood, RelationshipGraph } from './wikilinks.js';
|
|
12
13
|
import { evaluateFilter, FilterNode } from './filter.js';
|
|
13
14
|
import { deserializeBinaryIndex } from './binary-format.js';
|
|
14
|
-
import { dequantizeFromInt8 } from './quantization.js';
|
|
15
|
+
import { dequantizeFromInt8, int8Norm, asymmetricCosineInt8CachedNorm } from './quantization.js';
|
|
15
16
|
import { buildDiskKey, diskQueryGet, diskQueryPut } from './query-cache-disk.js';
|
|
16
17
|
|
|
17
18
|
export { tokenize } from './lexical.js';
|
|
@@ -62,6 +63,73 @@ export function rrf(
|
|
|
62
63
|
return fused;
|
|
63
64
|
}
|
|
64
65
|
|
|
66
|
+
/**
|
|
67
|
+
* issue #191: bounded top-k selection over a dense sweep — a capped min-heap
|
|
68
|
+
* instead of materializing one object per chunk and fully sorting. Ties break
|
|
69
|
+
* toward the smaller idx, which is exactly what a stable sort by
|
|
70
|
+
* (score desc) over idx-ordered input produces, so the selected pool and its
|
|
71
|
+
* order are identical to `[...items].sort((a, b) => b.score - a.score).slice(0, k)`.
|
|
72
|
+
*/
|
|
73
|
+
export function topKByScore(
|
|
74
|
+
n: number,
|
|
75
|
+
scoreAt: (i: number) => number,
|
|
76
|
+
k: number,
|
|
77
|
+
): Array<{ idx: number; score: number }> {
|
|
78
|
+
if (k <= 0 || n <= 0) return [];
|
|
79
|
+
const cap = Math.min(k, n);
|
|
80
|
+
const scores = new Array<number>(cap);
|
|
81
|
+
const idxs = new Array<number>(cap);
|
|
82
|
+
let size = 0;
|
|
83
|
+
|
|
84
|
+
/** true when (aScore, aIdx) is a WORSE keeper than (bScore, bIdx). */
|
|
85
|
+
const worse = (aScore: number, aIdx: number, bScore: number, bIdx: number): boolean =>
|
|
86
|
+
aScore < bScore || (aScore === bScore && aIdx > bIdx);
|
|
87
|
+
|
|
88
|
+
const siftDown = (pos: number): void => {
|
|
89
|
+
for (;;) {
|
|
90
|
+
const left = pos * 2 + 1;
|
|
91
|
+
if (left >= size) return;
|
|
92
|
+
const right = left + 1;
|
|
93
|
+
const child = right < size && worse(scores[right], idxs[right], scores[left], idxs[left]) ? right : left;
|
|
94
|
+
if (!worse(scores[child], idxs[child], scores[pos], idxs[pos])) return;
|
|
95
|
+
const ts = scores[pos]; scores[pos] = scores[child]; scores[child] = ts;
|
|
96
|
+
const ti = idxs[pos]; idxs[pos] = idxs[child]; idxs[child] = ti;
|
|
97
|
+
pos = child;
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
for (let i = 0; i < n; i++) {
|
|
102
|
+
const s = scoreAt(i);
|
|
103
|
+
if (size < cap) {
|
|
104
|
+
let pos = size++;
|
|
105
|
+
scores[pos] = s;
|
|
106
|
+
idxs[pos] = i;
|
|
107
|
+
while (pos > 0) {
|
|
108
|
+
const parent = (pos - 1) >> 1;
|
|
109
|
+
if (!worse(scores[pos], idxs[pos], scores[parent], idxs[parent])) break;
|
|
110
|
+
const ts = scores[pos]; scores[pos] = scores[parent]; scores[parent] = ts;
|
|
111
|
+
const ti = idxs[pos]; idxs[pos] = idxs[parent]; idxs[parent] = ti;
|
|
112
|
+
pos = parent;
|
|
113
|
+
}
|
|
114
|
+
} else if (worse(scores[0], idxs[0], s, i)) {
|
|
115
|
+
scores[0] = s;
|
|
116
|
+
idxs[0] = i;
|
|
117
|
+
siftDown(0);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const out: Array<{ idx: number; score: number }> = [];
|
|
122
|
+
while (size > 0) {
|
|
123
|
+
out.push({ idx: idxs[0], score: scores[0] });
|
|
124
|
+
size--;
|
|
125
|
+
scores[0] = scores[size];
|
|
126
|
+
idxs[0] = idxs[size];
|
|
127
|
+
siftDown(0);
|
|
128
|
+
}
|
|
129
|
+
out.sort((a, b) => b.score - a.score || a.idx - b.idx);
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
|
|
65
133
|
export interface IndexChunk {
|
|
66
134
|
file: string;
|
|
67
135
|
title: string;
|
|
@@ -179,12 +247,23 @@ export interface RuntimeIndexState {
|
|
|
179
247
|
model: ModelDescriptor;
|
|
180
248
|
queryCache: QueryEmbeddingCache;
|
|
181
249
|
ivf?: IVFIndex;
|
|
250
|
+
/** issue #201: optional HNSW graph sidecar (hnsw.json), parallel to ivf. */
|
|
251
|
+
hnsw?: HnswIndex;
|
|
182
252
|
graph?: RelationshipGraph;
|
|
183
253
|
pageRank?: Map<string, number>;
|
|
254
|
+
/**
|
|
255
|
+
* issue #190: precomputed sqrt(sum(c^2)) for int8 vectors (zero-copy binary
|
|
256
|
+
* load only) — lets the dense sweep compute the dot product alone.
|
|
257
|
+
*/
|
|
258
|
+
chunkNorms?: Float32Array;
|
|
184
259
|
}
|
|
185
260
|
|
|
186
|
-
|
|
187
|
-
|
|
261
|
+
/** issue #190 tool/test seam: the cached int8 sweep norms, when present. */
|
|
262
|
+
export function getRuntimeChunkNorms(index: IndexFile): Float32Array | undefined {
|
|
263
|
+
return runtimeIndexStates.get(index)?.chunkNorms;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function getRuntimeGraph(runtime: RuntimeIndexState): { graph: RelationshipGraph; pageRank: Map<string, number> } { if (runtime.graph && runtime.pageRank) {
|
|
188
267
|
return { graph: runtime.graph, pageRank: runtime.pageRank };
|
|
189
268
|
}
|
|
190
269
|
const docsMap = new Map<string, { file: string; title?: string; meta?: DocumentMetadata; text?: string }>();
|
|
@@ -212,12 +291,14 @@ export function getRuntimeGraph(runtime: RuntimeIndexState): { graph: Relationsh
|
|
|
212
291
|
/**
|
|
213
292
|
* Issue #127 pure helpers — exported for deterministic unit tests.
|
|
214
293
|
*/
|
|
215
|
-
/** Frontmatter `
|
|
294
|
+
/** Frontmatter `updated` → `created` → file mtime; null when nothing parses.
|
|
295
|
+
* Issue #164: `updated` wins — long-lived notes that were recently revised
|
|
296
|
+
* must not be penalized by their original creation date. */
|
|
216
297
|
export function resolvePassageAgeMs(
|
|
217
298
|
meta: { created?: unknown; updated?: unknown } | undefined,
|
|
218
299
|
absFile?: string,
|
|
219
300
|
): number | null {
|
|
220
|
-
for (const v of [meta?.
|
|
301
|
+
for (const v of [meta?.updated, meta?.created]) {
|
|
221
302
|
if (typeof v === 'string') {
|
|
222
303
|
const t = Date.parse(v);
|
|
223
304
|
if (Number.isFinite(t)) return t;
|
|
@@ -257,6 +338,9 @@ export async function hydePassage(
|
|
|
257
338
|
const res = await fetchImpl(endpoint.replace(/\/$/, '') + '/chat/completions', {
|
|
258
339
|
method: 'POST',
|
|
259
340
|
headers: { 'content-type': 'application/json' },
|
|
341
|
+
// issue #184: HyDE must degrade fast on a hung endpoint, not hold a
|
|
342
|
+
// serve slot — failure paths return null below
|
|
343
|
+
signal: AbortSignal.timeout(fetchTimeoutMs()),
|
|
260
344
|
body: JSON.stringify({
|
|
261
345
|
...(model ? { model } : {}),
|
|
262
346
|
messages: [
|
|
@@ -276,6 +360,67 @@ export async function hydePassage(
|
|
|
276
360
|
|
|
277
361
|
const runtimeIndexStates = new WeakMap<IndexFile, RuntimeIndexState>();
|
|
278
362
|
|
|
363
|
+
/**
|
|
364
|
+
* issue #203: LLM paraphrases for multi-query expansion. ONE OpenAI-compatible
|
|
365
|
+
* chat completion asking for N alternative phrasings; returns clean, deduped,
|
|
366
|
+
* non-empty variants (the original query excluded). Returns [] on ANY failure —
|
|
367
|
+
* multi-query must degrade to the single-query baseline silently, exactly like
|
|
368
|
+
* HyDE. Injectable fetch for tests.
|
|
369
|
+
*/
|
|
370
|
+
export async function paraphraseQueries(
|
|
371
|
+
endpoint: string,
|
|
372
|
+
query: string,
|
|
373
|
+
count: number,
|
|
374
|
+
model?: string,
|
|
375
|
+
fetchImpl: typeof fetch = globalThis.fetch,
|
|
376
|
+
): Promise<string[]> {
|
|
377
|
+
if (!(count > 0)) return [];
|
|
378
|
+
const capped = Math.min(Math.floor(count), 5);
|
|
379
|
+
try {
|
|
380
|
+
const res = await fetchImpl(endpoint.replace(/\/$/, '') + '/chat/completions', {
|
|
381
|
+
method: 'POST',
|
|
382
|
+
headers: { 'content-type': 'application/json' },
|
|
383
|
+
signal: AbortSignal.timeout(fetchTimeoutMs()),
|
|
384
|
+
body: JSON.stringify({
|
|
385
|
+
...(model ? { model } : {}),
|
|
386
|
+
messages: [
|
|
387
|
+
{
|
|
388
|
+
role: 'system',
|
|
389
|
+
content:
|
|
390
|
+
`Write ${capped} alternative phrasings of the user's search query. ` +
|
|
391
|
+
'One per line, no numbering, no quotes, keep the same language and meaning. Output only the paraphrases.',
|
|
392
|
+
},
|
|
393
|
+
{ role: 'user', content: query },
|
|
394
|
+
],
|
|
395
|
+
}),
|
|
396
|
+
});
|
|
397
|
+
if (!res.ok) return [];
|
|
398
|
+
const data: any = await res.json();
|
|
399
|
+
const text = data?.choices?.[0]?.message?.content;
|
|
400
|
+
if (typeof text !== 'string' || !text.trim()) return [];
|
|
401
|
+
const seen = new Set([query.trim().toLowerCase()]);
|
|
402
|
+
const out: string[] = [];
|
|
403
|
+
for (const raw of text.split('\n')) {
|
|
404
|
+
// strip list markers, surrounding quotes, and decorative dashes LLMs
|
|
405
|
+
// like to pad output with; a bare "— junk —" line then fails the
|
|
406
|
+
// length floor instead of passing as a paraphrase
|
|
407
|
+
const line = raw
|
|
408
|
+
.replace(/^\s*(?:[-*\d]+[.)]?\s+)/, '')
|
|
409
|
+
.replace(/^["'«“”\s—–-]+|["'»“”\s—–-]+$/g, '')
|
|
410
|
+
.trim();
|
|
411
|
+
if (line.length < 3 || line.length > MAX_QUERY_LENGTH) continue;
|
|
412
|
+
const key = line.toLowerCase();
|
|
413
|
+
if (seen.has(key)) continue;
|
|
414
|
+
seen.add(key);
|
|
415
|
+
out.push(line);
|
|
416
|
+
if (out.length >= capped) break;
|
|
417
|
+
}
|
|
418
|
+
return out;
|
|
419
|
+
} catch {
|
|
420
|
+
return [];
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
279
424
|
/** Minimal EN+RU stopword set for PRF expansion (issue #125). */
|
|
280
425
|
const EXPANSION_STOPWORDS = new Set([
|
|
281
426
|
'the', 'a', 'an', 'and', 'or', 'of', 'to', 'in', 'on', 'for', 'with', 'is', 'are',
|
|
@@ -320,6 +465,7 @@ function snapshotRuntimeIndex(
|
|
|
320
465
|
dim: number | undefined;
|
|
321
466
|
model: ModelDescriptor;
|
|
322
467
|
ivf?: IVFIndex;
|
|
468
|
+
hnsw?: HnswIndex;
|
|
323
469
|
rawVectorsBuffer?: Float32Array | Int8Array;
|
|
324
470
|
}
|
|
325
471
|
): RuntimeIndexState {
|
|
@@ -360,19 +506,32 @@ function snapshotRuntimeIndex(
|
|
|
360
506
|
|
|
361
507
|
let lexicalState: LexicalState = { kind: 'legacy-overlap' };
|
|
362
508
|
if (validation.schema >= 3 && index.lexical) {
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
509
|
+
// issue #192: the per-term [docId, tf] pairs are treated as immutable
|
|
510
|
+
// everywhere (bm25Scores/matchingTerms only read), so sharing them skips
|
|
511
|
+
// the O(terms × postings) deep copy on every load/reindex. The two mutable
|
|
512
|
+
// top levels are still copied so caller-side mutations of the loaded index
|
|
513
|
+
// cannot reach the runtime state.
|
|
367
514
|
lexicalState = {
|
|
368
515
|
kind: 'persisted-bm25',
|
|
369
516
|
lexical: {
|
|
370
517
|
format: index.lexical.format,
|
|
371
518
|
documentLengths: [...index.lexical.documentLengths],
|
|
372
|
-
postings,
|
|
519
|
+
postings: { ...index.lexical.postings },
|
|
373
520
|
},
|
|
374
521
|
};
|
|
375
522
|
}
|
|
523
|
+
|
|
524
|
+
// issue #190: int8 chunk norms are constant per chunk — compute them once at
|
|
525
|
+
// load instead of inside every query's dense sweep
|
|
526
|
+
let chunkNorms: Float32Array | undefined;
|
|
527
|
+
if (validation.rawVectorsBuffer instanceof Int8Array && dim > 0 && count > 0) {
|
|
528
|
+
chunkNorms = new Float32Array(count);
|
|
529
|
+
const buffer = validation.rawVectorsBuffer;
|
|
530
|
+
for (let i = 0; i < count; i++) {
|
|
531
|
+
chunkNorms[i] = int8Norm(buffer.subarray(i * dim, (i + 1) * dim));
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
376
535
|
return {
|
|
377
536
|
schema: validation.schema,
|
|
378
537
|
db: index.db,
|
|
@@ -382,6 +541,8 @@ function snapshotRuntimeIndex(
|
|
|
382
541
|
model: { ...validation.model },
|
|
383
542
|
queryCache: new QueryEmbeddingCache(100),
|
|
384
543
|
ivf: validation.ivf,
|
|
544
|
+
hnsw: validation.hnsw,
|
|
545
|
+
chunkNorms,
|
|
385
546
|
};
|
|
386
547
|
}
|
|
387
548
|
|
|
@@ -430,7 +591,14 @@ function warnIfStale(index: IndexFile): void {
|
|
|
430
591
|
);
|
|
431
592
|
}
|
|
432
593
|
|
|
433
|
-
|
|
594
|
+
/**
|
|
595
|
+
* Sentinel (issue #187): vectors.bin failed its sidecar check — not corrupt,
|
|
596
|
+
* just unverified — so loadIndex falls back to vectors.json instead of throwing.
|
|
597
|
+
*/
|
|
598
|
+
const BIN_SIDECAR_STALE = Symbol('bin-sidecar-stale');
|
|
599
|
+
|
|
600
|
+
export function loadIndex(indexDir: string, opts: { /** skip the (expensive) whole-tree staleness walk — deferred to search() */ checkStale?: boolean } = {}): { index: IndexFile; model: ModelDescriptor } {
|
|
601
|
+
const { checkStale = true } = opts;
|
|
434
602
|
const vectorsBinPath = path.join(indexDir, 'vectors.bin');
|
|
435
603
|
const vectorsPath = path.join(indexDir, 'vectors.json');
|
|
436
604
|
|
|
@@ -442,7 +610,9 @@ export function loadIndex(indexDir: string): { index: IndexFile; model: ModelDes
|
|
|
442
610
|
try {
|
|
443
611
|
const binStat = fs.statSync(vectorsBinPath);
|
|
444
612
|
const jsonStat = fs.statSync(vectorsPath);
|
|
445
|
-
|
|
613
|
+
// issue #187: strict `>` — written in the same millisecond, vectors.json
|
|
614
|
+
// (the format of record) wins over a possibly half-published vectors.bin
|
|
615
|
+
if (binStat.mtimeMs > jsonStat.mtimeMs) {
|
|
446
616
|
useBinary = true;
|
|
447
617
|
}
|
|
448
618
|
} catch {
|
|
@@ -459,7 +629,14 @@ export function loadIndex(indexDir: string): { index: IndexFile; model: ModelDes
|
|
|
459
629
|
const expectedHash = fs.readFileSync(shaBinPath, 'utf8').trim().split(/\s+/)[0];
|
|
460
630
|
const actualHash = crypto.createHash('sha256').update(binBuffer).digest('hex');
|
|
461
631
|
if (expectedHash && actualHash !== expectedHash) {
|
|
462
|
-
|
|
632
|
+
// issue #187: vectors.bin is a derived cache of vectors.json — a
|
|
633
|
+
// stale sidecar (crash between the two renames) must not brick the
|
|
634
|
+
// daemon when the format of record is available. Fall through.
|
|
635
|
+
process.stderr.write(
|
|
636
|
+
'warning: vectors.bin.sha256 does not match vectors.bin — ignoring the binary cache and loading vectors.json. ' +
|
|
637
|
+
'Run `mdss check --fix` to rebuild the binary cache.\n'
|
|
638
|
+
);
|
|
639
|
+
throw BIN_SIDECAR_STALE;
|
|
463
640
|
}
|
|
464
641
|
}
|
|
465
642
|
const deserialized = deserializeBinaryIndex(binBuffer);
|
|
@@ -468,7 +645,9 @@ export function loadIndex(indexDir: string): { index: IndexFile; model: ModelDes
|
|
|
468
645
|
const { model, dim: validatedDim } = validated;
|
|
469
646
|
const expectedDim = validatedDim ?? deserialized.dim;
|
|
470
647
|
|
|
471
|
-
|
|
648
|
+
// issue #192: the staleness walk is a synchronous whole-tree traversal —
|
|
649
|
+
// when the caller defers it (search()), it runs AFTER results exist
|
|
650
|
+
if (checkStale) warnIfStale(index);
|
|
472
651
|
|
|
473
652
|
const ivfPath = path.join(indexDir, 'ivf.json');
|
|
474
653
|
let ivf: IVFIndex | undefined;
|
|
@@ -478,6 +657,16 @@ export function loadIndex(indexDir: string): { index: IndexFile; model: ModelDes
|
|
|
478
657
|
ivf = deserializeIVF(raw);
|
|
479
658
|
} catch {}
|
|
480
659
|
}
|
|
660
|
+
// issue #201: optional HNSW sidecar — corrupt file degrades to brute
|
|
661
|
+
// force exactly like a corrupt ivf.json
|
|
662
|
+
const hnswPath = path.join(indexDir, 'hnsw.json');
|
|
663
|
+
let hnsw: HnswIndex | undefined;
|
|
664
|
+
if (fs.existsSync(hnswPath)) {
|
|
665
|
+
try {
|
|
666
|
+
const raw = JSON.parse(fs.readFileSync(hnswPath, 'utf8'));
|
|
667
|
+
hnsw = deserializeHnsw(raw, index.chunks.length);
|
|
668
|
+
} catch {}
|
|
669
|
+
}
|
|
481
670
|
|
|
482
671
|
runtimeIndexStates.set(
|
|
483
672
|
index,
|
|
@@ -486,14 +675,17 @@ export function loadIndex(indexDir: string): { index: IndexFile; model: ModelDes
|
|
|
486
675
|
dim: expectedDim,
|
|
487
676
|
model,
|
|
488
677
|
ivf,
|
|
678
|
+
hnsw,
|
|
489
679
|
rawVectorsBuffer: deserialized.rawVectorsBuffer,
|
|
490
680
|
})
|
|
491
681
|
);
|
|
492
682
|
|
|
493
683
|
return { index, model };
|
|
494
684
|
} catch (e: any) {
|
|
495
|
-
if (e
|
|
496
|
-
|
|
685
|
+
if (e === BIN_SIDECAR_STALE) {
|
|
686
|
+
// fall through to vectors.json — the format of record (#187)
|
|
687
|
+
} else if (e.message?.includes('integrity check failed')) throw e;
|
|
688
|
+
else if (!fs.existsSync(vectorsPath)) throw e;
|
|
497
689
|
// If vectors.bin was corrupt or unreadable, fall through to vectors.json fallback
|
|
498
690
|
}
|
|
499
691
|
}
|
|
@@ -518,10 +710,19 @@ export function loadIndex(indexDir: string): { index: IndexFile; model: ModelDes
|
|
|
518
710
|
const expectedHash = shaContent.split(/\s+/)[0];
|
|
519
711
|
const actualHash = crypto.createHash('sha256').update(rawBytes).digest('hex');
|
|
520
712
|
if (expectedHash && actualHash !== expectedHash) {
|
|
521
|
-
|
|
713
|
+
// issue #187: atomicWrite renames the payload and its sidecar in two
|
|
714
|
+
// separate steps, so a crash mid-publish leaves a VALID payload with a
|
|
715
|
+
// STALE sidecar. The envelope above already parsed and validated —
|
|
716
|
+
// vectors.json is the format of record, so load it with a loud
|
|
717
|
+
// warning instead of bricking search/serve/mcp until a manual fix.
|
|
718
|
+
// A payload that cannot parse or validate still fails hard (above).
|
|
719
|
+
process.stderr.write(
|
|
720
|
+
'warning: vectors.json.sha256 does not match vectors.json (stale sidecar after a crash mid-publish?) — ' +
|
|
721
|
+
'the payload itself is valid and will be loaded. Run `mdss check --fix` to refresh the sidecar.\n'
|
|
722
|
+
);
|
|
522
723
|
}
|
|
523
724
|
} catch (e: any) {
|
|
524
|
-
if (e.message
|
|
725
|
+
if (e.message?.includes('integrity check failed')) throw e;
|
|
525
726
|
}
|
|
526
727
|
}
|
|
527
728
|
|
|
@@ -557,7 +758,7 @@ export function loadIndex(indexDir: string): { index: IndexFile; model: ModelDes
|
|
|
557
758
|
}
|
|
558
759
|
}
|
|
559
760
|
|
|
560
|
-
warnIfStale(index);
|
|
761
|
+
if (checkStale) warnIfStale(index);
|
|
561
762
|
|
|
562
763
|
const ivfPath = path.join(indexDir, 'ivf.json');
|
|
563
764
|
let ivf: IVFIndex | undefined;
|
|
@@ -567,8 +768,16 @@ export function loadIndex(indexDir: string): { index: IndexFile; model: ModelDes
|
|
|
567
768
|
ivf = deserializeIVF(raw);
|
|
568
769
|
} catch {}
|
|
569
770
|
}
|
|
771
|
+
const hnswPath2 = path.join(indexDir, 'hnsw.json');
|
|
772
|
+
let hnsw: HnswIndex | undefined;
|
|
773
|
+
if (fs.existsSync(hnswPath2)) {
|
|
774
|
+
try {
|
|
775
|
+
const raw = JSON.parse(fs.readFileSync(hnswPath2, 'utf8'));
|
|
776
|
+
hnsw = deserializeHnsw(raw, index.chunks.length);
|
|
777
|
+
} catch {}
|
|
778
|
+
}
|
|
570
779
|
|
|
571
|
-
runtimeIndexStates.set(index, snapshotRuntimeIndex(index, { schema: validated.schema, dim: expectedDim, model, ivf }));
|
|
780
|
+
runtimeIndexStates.set(index, snapshotRuntimeIndex(index, { schema: validated.schema, dim: expectedDim, model, ivf, hnsw }));
|
|
572
781
|
|
|
573
782
|
return { index, model };
|
|
574
783
|
}
|
|
@@ -615,10 +824,23 @@ export interface SearchOptions {
|
|
|
615
824
|
llmEndpoint?: string;
|
|
616
825
|
/** issue #125: model name for the HyDE chat call. */
|
|
617
826
|
llmModel?: string;
|
|
618
|
-
|
|
827
|
+
/**
|
|
828
|
+
* issue #203: multi-query expansion — N total query variants (original +
|
|
829
|
+
* N-1 LLM paraphrases), each embedded, rankings fused with union-RRF.
|
|
830
|
+
* Requires `llmEndpoint`; without one (or on any LLM failure) it degrades
|
|
831
|
+
* to the single-query baseline. 0/1/undefined = off.
|
|
832
|
+
*/
|
|
833
|
+
multiQuery?: number;
|
|
834
|
+
/** issue #203: reranker registry alias (`bge-reranker-base` default, `gte-reranker-modernbert`, ...). */
|
|
835
|
+
rerankModel?: string;
|
|
836
|
+
/** issue #201: ANN engine — `true` = default engine, or explicit 'ivf' | 'hnsw'. */
|
|
837
|
+
ann?: boolean | 'ivf' | 'hnsw';
|
|
619
838
|
nprobe?: number;
|
|
839
|
+
/** issue #201: HNSW ef-search breadth (default 64). */
|
|
840
|
+
ef?: number;
|
|
620
841
|
graphBoost?: number;
|
|
621
842
|
filter?: string | FilterNode;
|
|
843
|
+
dimensions?: number;
|
|
622
844
|
}
|
|
623
845
|
|
|
624
846
|
export interface SearchResultHit {
|
|
@@ -669,11 +891,20 @@ export async function searchIndex(opts: SearchOptions): Promise<SearchResultHit[
|
|
|
669
891
|
}
|
|
670
892
|
|
|
671
893
|
const embedFn = opts.embedFn ?? embed;
|
|
672
|
-
|
|
894
|
+
// issue #203: the registry alias rides the DEFAULT rerank path; an injected
|
|
895
|
+
// rerankFn (tests/serve) keeps its 4-arg signature and ignores it.
|
|
896
|
+
const rerankFn = opts.rerankFn ?? ((query: string, texts: string[], dir: string, off: boolean) =>
|
|
897
|
+
rerankScores(query, texts, dir, off, opts.rerankModel));
|
|
673
898
|
const { index } = loaded;
|
|
674
899
|
const runtime = validateRuntimeIndex(index);
|
|
675
900
|
const { chunks, db, lexicalState, expectedDim } = runtime;
|
|
676
901
|
|
|
902
|
+
if (opts.dimensions !== undefined && typeof opts.dimensions === 'number' && expectedDim !== undefined && opts.dimensions !== expectedDim) {
|
|
903
|
+
throw new Error(
|
|
904
|
+
`requested search dimensions (${opts.dimensions}) does not match index dimension (${expectedDim}) — run \`mdss index --dimensions ${opts.dimensions}\` to rebuild`
|
|
905
|
+
);
|
|
906
|
+
}
|
|
907
|
+
|
|
677
908
|
const pathRes = pathFilter ? (Array.isArray(pathFilter) ? pathFilter : [pathFilter]).map(globToRegExp) : [];
|
|
678
909
|
let sinceMs: number | undefined;
|
|
679
910
|
if (since !== undefined) {
|
|
@@ -758,60 +989,168 @@ export async function searchIndex(opts: SearchOptions): Promise<SearchResultHit[
|
|
|
758
989
|
}
|
|
759
990
|
}
|
|
760
991
|
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
if (
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
992
|
+
// issue #203: one embed helper for the original query AND every multi-query
|
|
993
|
+
// paraphrase — both ride the same in-memory + disk query-embedding cache.
|
|
994
|
+
const embedFor = async (text: string): Promise<Float32Array | number[]> => {
|
|
995
|
+
if (opts.useQueryCache !== false && runtime.queryCache) {
|
|
996
|
+
const normQuery = text.trim().toLowerCase();
|
|
997
|
+
const cacheKey = `${runtime.model.id}:${runtime.model.revision || 'main'}:${expectedDim || 0}:${normQuery}`;
|
|
998
|
+
// L2 disk layer (issue #114): persists across processes under cacheDir;
|
|
999
|
+
// corrupt/missing files degrade silently to a normal embed.
|
|
1000
|
+
const diskKey = buildDiskKey(runtime.model.id, runtime.model.revision || 'main', expectedDim, normQuery);
|
|
1001
|
+
const useDisk = opts.queryDiskCache !== false;
|
|
1002
|
+
return runtime.queryCache.getOrCompute(cacheKey, async () => {
|
|
1003
|
+
if (useDisk) {
|
|
1004
|
+
const fromDisk = diskQueryGet(cacheDir, diskKey, expectedDim);
|
|
1005
|
+
if (fromDisk) return fromDisk;
|
|
1006
|
+
}
|
|
1007
|
+
const [v] = await embedFn([text], 'query', runtime.model, cacheDir, offline);
|
|
1008
|
+
if ((Array.isArray(v) || v instanceof Float32Array) && expectedDim !== undefined && v.length !== expectedDim) {
|
|
1009
|
+
throw new Error(`query vector has ${v.length} dims, expected ${expectedDim} - run \`mdss index\` to rebuild`);
|
|
1010
|
+
}
|
|
1011
|
+
validateNumericVector(v, undefined, 'query vector');
|
|
1012
|
+
if (useDisk) diskQueryPut(cacheDir, diskKey, v);
|
|
1013
|
+
return v;
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
const [v] = await embedFn([text], 'query', runtime.model, cacheDir, offline);
|
|
784
1017
|
if ((Array.isArray(v) || v instanceof Float32Array) && expectedDim !== undefined && v.length !== expectedDim) {
|
|
785
1018
|
throw new Error(`query vector has ${v.length} dims, expected ${expectedDim} — run \`mdss index\` to rebuild`);
|
|
786
1019
|
}
|
|
787
1020
|
validateNumericVector(v, undefined, 'query vector');
|
|
788
|
-
|
|
1021
|
+
return v;
|
|
1022
|
+
};
|
|
1023
|
+
|
|
1024
|
+
const qVec = await embedFor(effectiveQuery);
|
|
1025
|
+
|
|
1026
|
+
// issue #203: multi-query expansion — N total variants (original first),
|
|
1027
|
+
// each embedded, dense rankings fused by union-RRF below. Paraphrases come
|
|
1028
|
+
// from the LLM bridge; without an endpoint (or on any failure) the list is
|
|
1029
|
+
// empty and the search is byte-identical to the single-query baseline.
|
|
1030
|
+
const multiQueryN = Number.isFinite(opts.multiQuery) ? Math.floor(Number(opts.multiQuery)) : 0;
|
|
1031
|
+
const paraphrases =
|
|
1032
|
+
multiQueryN >= 2 && opts.llmEndpoint
|
|
1033
|
+
? await paraphraseQueries(opts.llmEndpoint, query, multiQueryN - 1, opts.llmModel)
|
|
1034
|
+
: [];
|
|
1035
|
+
const qVecs: Array<Float32Array | number[]> = [qVec];
|
|
1036
|
+
for (const p of paraphrases.slice(0, multiQueryN - 1)) {
|
|
1037
|
+
try {
|
|
1038
|
+
qVecs.push(await embedFor(p));
|
|
1039
|
+
} catch {
|
|
1040
|
+
// a paraphrase that fails to embed (e.g. dim mismatch) is dropped, not fatal
|
|
1041
|
+
}
|
|
789
1042
|
}
|
|
790
1043
|
const queryTerms = [...new Set(tokenize(query))];
|
|
791
1044
|
|
|
1045
|
+
const maxPerDoc = opts.maxPerFile || opts.maxPerDoc;
|
|
1046
|
+
// Issue #127: time-decay recency boost — multiply the fused score by
|
|
1047
|
+
// 0.5^(ageDays / halfLifeDays), post-RRF and pre-collapse. Age source
|
|
1048
|
+
// priority: frontmatter `updated` → `created` → file mtime (issue #164). Missing dates
|
|
1049
|
+
// get factor 1 (evergreen content stays reachable via lexical strength).
|
|
1050
|
+
const recencyHalfLife = Number(opts.recency) > 0 ? Number(opts.recency) : 0;
|
|
1051
|
+
// Issue #163: recency rescores AFTER the pool slice, so the pool must be
|
|
1052
|
+
// expanded like the maxPerDoc path — otherwise candidates ranked k+1..n
|
|
1053
|
+
// by raw RRF are dropped before the fresh-document boost can lift them.
|
|
1054
|
+
// issue #191: the pool must be known BEFORE the sweep for the bounded top-k.
|
|
1055
|
+
const pool = rerank
|
|
1056
|
+
? (rerankPool || (maxPerDoc && maxPerDoc > 0 ? Math.max(50, k * 5) : Math.max(20, k * 3)))
|
|
1057
|
+
: (maxPerDoc && maxPerDoc > 0 || recencyHalfLife > 0 ? Math.max(50, k * 5) : k);
|
|
1058
|
+
|
|
792
1059
|
let semantic: Array<{ idx: number; score: number }>;
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
1060
|
+
let cosByIdx: Map<number, number> | undefined;
|
|
1061
|
+
// issue #201: engine selection — explicit 'ivf'/'hnsw' wins; `true` picks
|
|
1062
|
+
// whichever sidecar exists (hnsw preferred, then ivf); otherwise the
|
|
1063
|
+
// automatic threshold path (sidecar present AND corpus above ANN_THRESHOLD)
|
|
1064
|
+
const annOpt = opts.ann;
|
|
1065
|
+
const autoAnn = annOpt === undefined || annOpt === false
|
|
1066
|
+
? Boolean(runtime.hnsw || runtime.ivf) && candidates.length >= ANN_THRESHOLD
|
|
1067
|
+
: candidates.length >= ANN_THRESHOLD;
|
|
1068
|
+
const useAnn = Boolean(annOpt) || autoAnn;
|
|
1069
|
+
const annEngine: 'ivf' | 'hnsw' | undefined = useAnn
|
|
1070
|
+
? (annOpt === 'hnsw' || (annOpt !== 'ivf' && runtime.hnsw) ? 'hnsw' : 'ivf')
|
|
1071
|
+
: undefined;
|
|
1072
|
+
// issue #190: with cached int8 norms the sweep computes the dot product alone
|
|
1073
|
+
const norms = runtime.chunkNorms;
|
|
1074
|
+
const sweepScore = (chunk: RuntimeChunk, idx: number): number =>
|
|
1075
|
+
norms && chunk.vec instanceof Int8Array
|
|
1076
|
+
? asymmetricCosineInt8CachedNorm(qVec as Float32Array, chunk.vec, norms[idx])
|
|
1077
|
+
: cosine(qVec, chunk.vec);
|
|
1078
|
+
// issue #203: ANN probing unions the candidate sets of every query variant —
|
|
1079
|
+
// a paraphrase must not recall into chunks the original's probe excluded
|
|
1080
|
+
const probed = useAnn && candidates.length >= ANN_THRESHOLD
|
|
1081
|
+
? (() => {
|
|
1082
|
+
const probedUnion = new Set<number>();
|
|
1083
|
+
for (const vec of qVecs) {
|
|
1084
|
+
const qVecF32 = vec instanceof Float32Array ? vec : Float32Array.from(vec);
|
|
1085
|
+
if (annEngine === 'hnsw') {
|
|
1086
|
+
// issue #201: HNSW graph search — built on the hot path only when
|
|
1087
|
+
// the sidecar is missing (same semantics as the IVF sync train)
|
|
1088
|
+
if (!runtime.hnsw) {
|
|
1089
|
+
const rawVecs = chunks.map((c) => (c.vec instanceof Float32Array ? c.vec : dequantizeFromInt8(c.vec)));
|
|
1090
|
+
runtime.hnsw = buildHnsw(rawVecs, {
|
|
1091
|
+
vectors: rawVecs,
|
|
1092
|
+
distance: (a, b) => {
|
|
1093
|
+
const bv = rawVecs[b];
|
|
1094
|
+
return 1 - (a as Float32Array).reduce((sum, x, i) => sum + x * (bv as ArrayLike<number>)[i], 0);
|
|
1095
|
+
},
|
|
1096
|
+
});
|
|
1097
|
+
}
|
|
1098
|
+
for (const idx of searchHnswCandidates(qVecF32, runtime.hnsw, chunks.map((c) => c.vec), { ef: opts.ef ?? DEFAULT_HNSW_EF })) {
|
|
1099
|
+
probedUnion.add(idx);
|
|
1100
|
+
}
|
|
1101
|
+
} else {
|
|
1102
|
+
if (!runtime.ivf) {
|
|
1103
|
+
const rawVecs = chunks.map((c) => (c.vec instanceof Float32Array ? c.vec : dequantizeFromInt8(c.vec)));
|
|
1104
|
+
runtime.ivf = trainIVF(rawVecs);
|
|
1105
|
+
}
|
|
1106
|
+
for (const idx of searchIVFCandidates(qVecF32, runtime.ivf, opts.nprobe ?? DEFAULT_NPROBE)) {
|
|
1107
|
+
probedUnion.add(idx);
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
return probedUnion;
|
|
1112
|
+
})()
|
|
1113
|
+
: null;
|
|
1114
|
+
if (qVecs.length > 1) {
|
|
1115
|
+
// issue #203: multi-query — one dense sweep per variant, rankings fused by
|
|
1116
|
+
// union-RRF. `cos` stays anchored to the ORIGINAL query's cosine so the
|
|
1117
|
+
// hit shape (and score display) keeps its meaning.
|
|
1118
|
+
const sweepWith = (vec: Float32Array | number[]) => (chunk: RuntimeChunk, idx: number): number =>
|
|
1119
|
+
norms && chunk.vec instanceof Int8Array
|
|
1120
|
+
? asymmetricCosineInt8CachedNorm(vec as Float32Array, chunk.vec, norms[idx])
|
|
1121
|
+
: cosine(vec, chunk.vec);
|
|
1122
|
+
const perVectorRankings = qVecs.map((vec) =>
|
|
1123
|
+
candidates.map(({ chunk, idx }) => ({
|
|
1124
|
+
idx,
|
|
1125
|
+
score: probed && !probed.has(idx) ? -1 : sweepWith(vec)(chunk, idx),
|
|
1126
|
+
})),
|
|
1127
|
+
);
|
|
1128
|
+
const fusedSemantic = rrf(perVectorRankings, 60);
|
|
1129
|
+
semantic = [...fusedSemantic.entries()].map(([idx, score]) => ({ idx, score }));
|
|
1130
|
+
cosByIdx = new Map(perVectorRankings[0].map((s) => [s.idx, s.score]));
|
|
1131
|
+
} else if (semanticOnly && !(graphBoost > 0)) {
|
|
1132
|
+
// issue #191: the pure-semantic ranking only needs the top pool — select
|
|
1133
|
+
// it with a bounded min-heap in one pass (no per-chunk objects, no full
|
|
1134
|
+
// sort). Equivalent to [...candidates].map(score).sort(desc).slice(0, pool)
|
|
1135
|
+
// with the stable tie-break toward the smaller idx.
|
|
1136
|
+
semantic = topKByScore(
|
|
1137
|
+
candidates.length,
|
|
1138
|
+
(i) => {
|
|
1139
|
+
const { chunk, idx } = candidates[i];
|
|
1140
|
+
if (probed && !probed.has(idx)) return -1;
|
|
1141
|
+
return sweepScore(chunk, idx);
|
|
1142
|
+
},
|
|
1143
|
+
pool,
|
|
1144
|
+
);
|
|
1145
|
+
} else {
|
|
801
1146
|
semantic = candidates.map(({ chunk, idx }) => {
|
|
802
|
-
if (!
|
|
1147
|
+
if (probed && !probed.has(idx)) {
|
|
803
1148
|
return { idx, score: -1 };
|
|
804
1149
|
}
|
|
805
|
-
return { idx, score:
|
|
806
|
-
});
|
|
807
|
-
} else {
|
|
808
|
-
semantic = candidates.map(({ chunk, idx }) => {
|
|
809
|
-
return { idx, score: cosine(qVec, chunk.vec) };
|
|
1150
|
+
return { idx, score: sweepScore(chunk, idx) };
|
|
810
1151
|
});
|
|
1152
|
+
cosByIdx = new Map(semantic.map((s) => [s.idx, s.score]));
|
|
811
1153
|
}
|
|
812
|
-
const cosByIdx = new Map(semantic.map((s) => [s.idx, s.score]));
|
|
813
|
-
|
|
814
|
-
const pool = rerank ? rerankPool || Math.max(20, k * 3) : k;
|
|
815
1154
|
|
|
816
1155
|
let graphRanking: Array<{ idx: number; score: number }> = [];
|
|
817
1156
|
let graphMap: Map<number, number> | undefined;
|
|
@@ -843,14 +1182,14 @@ export async function searchIndex(opts: SearchOptions): Promise<SearchResultHit[
|
|
|
843
1182
|
if (graphBoost > 0) {
|
|
844
1183
|
const fused = rrf([semantic, graphRanking], 60, [1, graphBoost]);
|
|
845
1184
|
ranked = [...fused.entries()]
|
|
846
|
-
.map(([idx, fscore]) => ({ idx, fscore, cos: cosByIdx
|
|
1185
|
+
.map(([idx, fscore]) => ({ idx, fscore, cos: cosByIdx!.get(idx)! }))
|
|
847
1186
|
.sort((a, b) => b.fscore - a.fscore)
|
|
848
1187
|
.slice(0, pool);
|
|
849
1188
|
} else {
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
1189
|
+
// issue #191: `semantic` is already the top pool, ordered (score desc, idx asc);
|
|
1190
|
+
// issue #203: with multi-query the pool scores are RRF — cos stays the
|
|
1191
|
+
// original query's cosine via cosByIdx
|
|
1192
|
+
ranked = semantic.map((s) => ({ idx: s.idx, fscore: s.score, cos: cosByIdx ? cosByIdx.get(s.idx)! : s.score }));
|
|
854
1193
|
}
|
|
855
1194
|
} else {
|
|
856
1195
|
const kw =
|
|
@@ -871,23 +1210,18 @@ export async function searchIndex(opts: SearchOptions): Promise<SearchResultHit[
|
|
|
871
1210
|
if (graphBoost > 0) {
|
|
872
1211
|
const fused = rrf([semantic, kw, fuzzy, graphRanking], 60, [1, 1, 1, graphBoost]);
|
|
873
1212
|
ranked = [...fused.entries()]
|
|
874
|
-
.map(([idx, fscore]) => ({ idx, fscore, cos: cosByIdx
|
|
1213
|
+
.map(([idx, fscore]) => ({ idx, fscore, cos: cosByIdx!.get(idx)! }))
|
|
875
1214
|
.sort((a, b) => b.fscore - a.fscore)
|
|
876
1215
|
.slice(0, pool);
|
|
877
1216
|
} else {
|
|
878
1217
|
const fused = rrf([semantic, kw, fuzzy]);
|
|
879
1218
|
ranked = [...fused.entries()]
|
|
880
|
-
.map(([idx, fscore]) => ({ idx, fscore, cos: cosByIdx
|
|
1219
|
+
.map(([idx, fscore]) => ({ idx, fscore, cos: cosByIdx!.get(idx)! }))
|
|
881
1220
|
.sort((a, b) => b.fscore - a.fscore)
|
|
882
1221
|
.slice(0, pool);
|
|
883
1222
|
}
|
|
884
1223
|
}
|
|
885
1224
|
|
|
886
|
-
// Issue #127: time-decay recency boost — multiply the fused score by
|
|
887
|
-
// 0.5^(ageDays / halfLifeDays), post-RRF and pre-collapse. Age source
|
|
888
|
-
// priority: frontmatter `created` → `updated` → file mtime. Missing dates
|
|
889
|
-
// get factor 1 (evergreen content stays reachable via lexical strength).
|
|
890
|
-
const recencyHalfLife = Number(opts.recency) > 0 ? Number(opts.recency) : 0;
|
|
891
1225
|
if (recencyHalfLife > 0) {
|
|
892
1226
|
const now = Date.now();
|
|
893
1227
|
const ageCache = new Map<string, number | null>();
|
|
@@ -910,8 +1244,10 @@ export async function searchIndex(opts: SearchOptions): Promise<SearchResultHit[
|
|
|
910
1244
|
const scores = await rerankFn(query, texts, cacheDir, offline);
|
|
911
1245
|
ranked = ranked
|
|
912
1246
|
.map((r, i) => ({ ...r, rerank: scores[i] ?? -Infinity }))
|
|
913
|
-
.sort((a, b) => b.rerank - a.rerank)
|
|
914
|
-
|
|
1247
|
+
.sort((a, b) => b.rerank - a.rerank);
|
|
1248
|
+
if (!maxPerDoc || maxPerDoc <= 0) {
|
|
1249
|
+
ranked = ranked.slice(0, k);
|
|
1250
|
+
}
|
|
915
1251
|
}
|
|
916
1252
|
|
|
917
1253
|
const hasMetaFields = (m?: DocumentMetadata) =>
|
|
@@ -974,18 +1310,24 @@ export async function searchIndex(opts: SearchOptions): Promise<SearchResultHit[
|
|
|
974
1310
|
};
|
|
975
1311
|
});
|
|
976
1312
|
|
|
977
|
-
const maxPerDoc = opts.maxPerFile || opts.maxPerDoc;
|
|
978
1313
|
if (maxPerDoc && maxPerDoc > 0) {
|
|
979
1314
|
return collapseResults(hits, (h) => h.meta?.canonicalRef || h.file, maxPerDoc).slice(0, k);
|
|
980
1315
|
}
|
|
981
|
-
|
|
1316
|
+
// Issue #163: with the pool expanded for recency the final k-truncation
|
|
1317
|
+
// moves here (it used to be the pool slice itself). Paths that already
|
|
1318
|
+
// truncate (rerank, maxPerDoc collapse) keep their behavior.
|
|
1319
|
+
return hits.slice(0, k);
|
|
982
1320
|
}
|
|
983
1321
|
|
|
984
1322
|
export type OneShotSearchOptions = Omit<SearchOptions, 'loaded'> & { indexDir: string };
|
|
985
1323
|
|
|
986
1324
|
export async function search(opts: OneShotSearchOptions): Promise<SearchResultHit[]> {
|
|
987
1325
|
const { indexDir, ...rest } = opts;
|
|
988
|
-
|
|
989
|
-
|
|
1326
|
+
// issue #192: the staleness check walks the entire tree synchronously —
|
|
1327
|
+
// defer it until AFTER the results are computed so it never delays them
|
|
1328
|
+
const loaded = loadIndex(indexDir, { checkStale: false });
|
|
1329
|
+
const hits = await searchIndex({ ...rest, loaded });
|
|
1330
|
+
warnIfStale(loaded.index);
|
|
1331
|
+
return hits;
|
|
990
1332
|
}
|
|
991
1333
|
|