md-semantic-search 1.0.2 → 1.0.3
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 +154 -6
- package/bin/cli.mjs +403 -22
- package/dist/completions.d.ts +25 -0
- package/dist/completions.js +233 -0
- package/dist/core.d.ts +5 -0
- package/dist/core.js +17 -0
- package/dist/download-progress.d.ts +52 -0
- package/dist/download-progress.js +112 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/mcp.js +6 -1
- package/dist/open.d.ts +43 -0
- package/dist/open.js +84 -0
- package/dist/providers.d.ts +21 -0
- package/dist/providers.js +104 -0
- package/dist/query-cache-disk.d.ts +14 -0
- package/dist/query-cache-disk.js +175 -0
- package/dist/repair.d.ts +36 -0
- package/dist/repair.js +208 -0
- package/dist/rerank.js +3 -2
- package/dist/search.d.ts +34 -0
- package/dist/search.js +172 -5
- package/dist/serve.d.ts +126 -0
- package/dist/serve.js +579 -45
- package/dist/watcher.d.ts +16 -0
- package/dist/watcher.js +41 -0
- package/dist/webui.d.ts +13 -0
- package/dist/webui.js +231 -0
- package/package.json +1 -1
- package/src/completions.ts +250 -0
- package/src/core.ts +26 -0
- package/src/download-progress.ts +146 -0
- package/src/index.ts +4 -0
- package/src/mcp.ts +10 -3
- package/src/open.ts +119 -0
- package/src/providers.ts +134 -0
- package/src/query-cache-disk.ts +191 -0
- package/src/repair.ts +245 -0
- package/src/rerank.ts +3 -2
- package/src/search.ts +192 -6
- package/src/serve.ts +635 -42
- package/src/watcher.ts +46 -0
- package/src/webui.ts +234 -0
package/src/repair.ts
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Safe auto-repairs for `mdss check --fix` (issue #117).
|
|
3
|
+
*
|
|
4
|
+
* Plan/apply split: planRepairs() probes the index directory WITHOUT mutating
|
|
5
|
+
* anything (so --dry-run can print the exact plan), applyRepairs() executes
|
|
6
|
+
* actions in order and reports per-action outcomes. Source Markdown is NEVER
|
|
7
|
+
* touched; the only reads from --db are for recomputing `.hashes.json` or a
|
|
8
|
+
* full index rebuild.
|
|
9
|
+
*/
|
|
10
|
+
import crypto from 'node:crypto';
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { LOCK_FILENAME, pidAlive, walkMarkdown } from './core.js';
|
|
14
|
+
import { DEFAULT_MODEL } from './models.js';
|
|
15
|
+
|
|
16
|
+
const LOCK_STALE_MS = 10 * 60 * 1000;
|
|
17
|
+
|
|
18
|
+
export interface RepairAction {
|
|
19
|
+
action:
|
|
20
|
+
| 'remove-stale-lock'
|
|
21
|
+
| 'remove-broken-vectors-bin'
|
|
22
|
+
| 'remove-corrupt-ivf'
|
|
23
|
+
| 'rebuild-index'
|
|
24
|
+
| 'rewrite-hashes';
|
|
25
|
+
detail: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface RepairPlan {
|
|
29
|
+
actions: RepairAction[];
|
|
30
|
+
skipped: RepairAction[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ApplyResult {
|
|
34
|
+
performed: RepairAction[];
|
|
35
|
+
failed: Array<RepairAction & { error: string }>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface ProbeOptions {
|
|
39
|
+
db?: string | null;
|
|
40
|
+
log?: (msg: string) => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function readJsonIfParsable(filePath: string): { ok: true; value: any } | { ok: false } | { ok: 'missing' } {
|
|
44
|
+
let raw: string;
|
|
45
|
+
try {
|
|
46
|
+
raw = fs.readFileSync(filePath, 'utf8');
|
|
47
|
+
} catch {
|
|
48
|
+
return { ok: 'missing' };
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
return { ok: true, value: JSON.parse(raw) };
|
|
52
|
+
} catch {
|
|
53
|
+
return { ok: false };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Recompute the {relativePath: md5} map exactly as the indexer stores it. */
|
|
58
|
+
export function recomputeHashes(db: string): Record<string, string> {
|
|
59
|
+
const hashes: Record<string, string> = {};
|
|
60
|
+
for (const f of walkMarkdown(db)) {
|
|
61
|
+
const rel = path.relative(db, f).split(path.sep).join('/');
|
|
62
|
+
const md5 = crypto.createHash('md5').update(fs.readFileSync(f)).digest('hex');
|
|
63
|
+
hashes[rel] = md5;
|
|
64
|
+
}
|
|
65
|
+
return hashes;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Probe the index directory and decide what CAN be safely repaired.
|
|
70
|
+
* Never mutates anything — safe to call for --dry-run.
|
|
71
|
+
*/
|
|
72
|
+
export function planRepairs(indexDir: string, opts: ProbeOptions = {}): RepairPlan {
|
|
73
|
+
const plan: RepairPlan = { actions: [], skipped: [] };
|
|
74
|
+
const db = opts.db ?? null;
|
|
75
|
+
|
|
76
|
+
// --- 1. stale / abandoned lock ---
|
|
77
|
+
const lockPath = path.join(indexDir, LOCK_FILENAME);
|
|
78
|
+
if (fs.existsSync(lockPath)) {
|
|
79
|
+
let info: { pid?: unknown; since?: unknown } | null = null;
|
|
80
|
+
try {
|
|
81
|
+
info = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
|
|
82
|
+
} catch {
|
|
83
|
+
info = null;
|
|
84
|
+
}
|
|
85
|
+
const pid = Number.isInteger(info?.pid) ? (info!.pid as number) : null;
|
|
86
|
+
let statAgeMs = Infinity;
|
|
87
|
+
try {
|
|
88
|
+
statAgeMs = Date.now() - fs.statSync(lockPath).mtimeMs;
|
|
89
|
+
} catch {
|
|
90
|
+
/* vanished between exists and stat */
|
|
91
|
+
}
|
|
92
|
+
if (!info) {
|
|
93
|
+
plan.actions.push({ action: 'remove-stale-lock', detail: `${LOCK_FILENAME} is unreadable/abandoned` });
|
|
94
|
+
} else if (pid !== null && !pidAlive(pid)) {
|
|
95
|
+
plan.actions.push({ action: 'remove-stale-lock', detail: `${LOCK_FILENAME} held by dead pid ${pid}` });
|
|
96
|
+
} else if (statAgeMs > LOCK_STALE_MS) {
|
|
97
|
+
plan.actions.push({
|
|
98
|
+
action: 'remove-stale-lock',
|
|
99
|
+
detail: `${LOCK_FILENAME} is older than ${Math.round(LOCK_STALE_MS / 60000)} min`,
|
|
100
|
+
});
|
|
101
|
+
} else {
|
|
102
|
+
plan.skipped.push({
|
|
103
|
+
action: 'remove-stale-lock',
|
|
104
|
+
detail: `live writer holds the lock${pid ? ` (pid ${pid})` : ''}`,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// --- 2. vectors.bin integrity (SHA-256 sidecar) ---
|
|
110
|
+
const binPath = path.join(indexDir, 'vectors.bin');
|
|
111
|
+
const shaPath = `${binPath}.sha256`;
|
|
112
|
+
if (fs.existsSync(binPath)) {
|
|
113
|
+
let broken: string | null = null;
|
|
114
|
+
try {
|
|
115
|
+
const actual = crypto.createHash('sha256').update(fs.readFileSync(binPath)).digest('hex');
|
|
116
|
+
if (!fs.existsSync(shaPath)) {
|
|
117
|
+
broken = 'missing vectors.bin.sha256 sidecar';
|
|
118
|
+
} else {
|
|
119
|
+
const expected = fs.readFileSync(shaPath, 'utf8').trim().split(/\s+/)[0];
|
|
120
|
+
if (!expected || expected !== actual) broken = 'SHA-256 mismatch';
|
|
121
|
+
}
|
|
122
|
+
} catch (e: any) {
|
|
123
|
+
broken = e.message;
|
|
124
|
+
}
|
|
125
|
+
if (broken) {
|
|
126
|
+
plan.actions.push({
|
|
127
|
+
action: 'remove-broken-vectors-bin',
|
|
128
|
+
detail: `vectors.bin ignored (${broken}) — search falls back to vectors.json`,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// --- 3. corrupt ivf.json (optional ANN sidecar; loader silently ignores it) ---
|
|
134
|
+
const ivfPath = path.join(indexDir, 'ivf.json');
|
|
135
|
+
const ivfRaw = readJsonIfParsable(ivfPath);
|
|
136
|
+
if (ivfRaw.ok === false) {
|
|
137
|
+
plan.actions.push({
|
|
138
|
+
action: 'remove-corrupt-ivf',
|
|
139
|
+
detail: 'ivf.json is not valid JSON — removing it re-enables brute-force ANN fallback',
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// --- 4. corrupt vectors.json ---
|
|
144
|
+
const liveWriter = plan.skipped.some(
|
|
145
|
+
(s) => s.action === 'remove-stale-lock' && /live writer/.test(s.detail),
|
|
146
|
+
);
|
|
147
|
+
const vectorsRaw = readJsonIfParsable(path.join(indexDir, 'vectors.json'));
|
|
148
|
+
if (vectorsRaw.ok === false) {
|
|
149
|
+
if (db && !liveWriter) {
|
|
150
|
+
plan.actions.push({ action: 'rebuild-index', detail: `vectors.json is not valid JSON — rebuild from ${db}` });
|
|
151
|
+
} else if (liveWriter) {
|
|
152
|
+
plan.skipped.push({
|
|
153
|
+
action: 'rebuild-index',
|
|
154
|
+
detail: 'vectors.json is not valid JSON, but a live writer holds the lock — retry after it finishes',
|
|
155
|
+
});
|
|
156
|
+
} else {
|
|
157
|
+
plan.skipped.push({
|
|
158
|
+
action: 'rebuild-index',
|
|
159
|
+
detail: 'vectors.json is not valid JSON — pass --db so --fix can rebuild it',
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// --- 5. damaged .hashes.json ---
|
|
165
|
+
const hashesRaw = readJsonIfParsable(path.join(indexDir, '.hashes.json'));
|
|
166
|
+
if (hashesRaw.ok === false) {
|
|
167
|
+
if (db) {
|
|
168
|
+
plan.actions.push({ action: 'rewrite-hashes', detail: '.hashes.json is not valid JSON — recompute from source md5' });
|
|
169
|
+
} else {
|
|
170
|
+
plan.skipped.push({
|
|
171
|
+
action: 'rewrite-hashes',
|
|
172
|
+
detail: '.hashes.json is not valid JSON — pass --db so --fix can recompute it',
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return plan;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Execute planned actions in order. dryRun=true performs nothing. */
|
|
181
|
+
export function applyRepairs(
|
|
182
|
+
indexDir: string,
|
|
183
|
+
plan: RepairPlan,
|
|
184
|
+
opts: {
|
|
185
|
+
dryRun?: boolean;
|
|
186
|
+
db?: string | null;
|
|
187
|
+
cacheDir?: string;
|
|
188
|
+
modelName?: string;
|
|
189
|
+
embedFn?: any;
|
|
190
|
+
offline?: boolean;
|
|
191
|
+
log?: (msg: string) => void;
|
|
192
|
+
} = {}
|
|
193
|
+
): Promise<ApplyResult> {
|
|
194
|
+
const performed: RepairAction[] = [];
|
|
195
|
+
const failed: Array<RepairAction & { error: string }> = [];
|
|
196
|
+
const log = opts.log ?? (() => {});
|
|
197
|
+
|
|
198
|
+
const run = async (): Promise<ApplyResult> => {
|
|
199
|
+
for (const action of plan.actions) {
|
|
200
|
+
if (opts.dryRun) {
|
|
201
|
+
performed.push(action);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
try {
|
|
205
|
+
switch (action.action) {
|
|
206
|
+
case 'remove-stale-lock':
|
|
207
|
+
fs.unlinkSync(path.join(indexDir, LOCK_FILENAME));
|
|
208
|
+
break;
|
|
209
|
+
case 'remove-broken-vectors-bin':
|
|
210
|
+
fs.rmSync(path.join(indexDir, 'vectors.bin'), { force: true });
|
|
211
|
+
fs.rmSync(`${path.join(indexDir, 'vectors.bin')}.sha256`, { force: true });
|
|
212
|
+
break;
|
|
213
|
+
case 'remove-corrupt-ivf':
|
|
214
|
+
fs.rmSync(path.join(indexDir, 'ivf.json'), { force: true });
|
|
215
|
+
break;
|
|
216
|
+
case 'rewrite-hashes': {
|
|
217
|
+
const hashes = recomputeHashes(opts.db!);
|
|
218
|
+
fs.writeFileSync(path.join(indexDir, '.hashes.json'), JSON.stringify(hashes, null, 2));
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
case 'rebuild-index': {
|
|
222
|
+
const { buildIndex } = await import('./indexer.js');
|
|
223
|
+
const rebuildOpts: Parameters<typeof buildIndex>[0] = {
|
|
224
|
+
db: opts.db!,
|
|
225
|
+
indexDir,
|
|
226
|
+
cacheDir: opts.cacheDir!,
|
|
227
|
+
modelName: opts.modelName || DEFAULT_MODEL,
|
|
228
|
+
log,
|
|
229
|
+
_lockHeld: true, // we may have JUST removed a stale lock; never double-lock inside fix
|
|
230
|
+
};
|
|
231
|
+
if (opts.embedFn) rebuildOpts.embedFn = opts.embedFn; // test DI — keeps repairs model-free
|
|
232
|
+
await buildIndex(rebuildOpts);
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
performed.push(action);
|
|
237
|
+
} catch (e: any) {
|
|
238
|
+
failed.push({ ...action, error: e.message });
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return { performed, failed };
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
return run();
|
|
245
|
+
}
|
package/src/rerank.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Cross-encoder re-ranking (issue #15).
|
|
3
3
|
*/
|
|
4
|
-
import { resolveSessionOptions, retryWithBackoff } from './core.js';
|
|
4
|
+
import { resolveSessionOptions, retryWithBackoff, emitDownloadEvent } from './core.js';
|
|
5
5
|
|
|
6
6
|
const _rerankers = new Map<string, { tokenizer: any; model: any }>();
|
|
7
7
|
|
|
@@ -21,9 +21,10 @@ export async function getReranker(
|
|
|
21
21
|
const { AutoModelForSequenceClassification, AutoTokenizer, env } = await import('@huggingface/transformers');
|
|
22
22
|
if (cacheDir) env.cacheDir = cacheDir;
|
|
23
23
|
env.allowRemoteModels = !offline;
|
|
24
|
-
const tokenizer = await AutoTokenizer.from_pretrained(RERANK_MODEL);
|
|
24
|
+
const tokenizer = await AutoTokenizer.from_pretrained(RERANK_MODEL, { progress_callback: emitDownloadEvent });
|
|
25
25
|
const loadOpts: Record<string, unknown> = {
|
|
26
26
|
dtype: 'q8',
|
|
27
|
+
progress_callback: emitDownloadEvent,
|
|
27
28
|
...(sessionOptions ? { session_options: sessionOptions } : {}),
|
|
28
29
|
};
|
|
29
30
|
const model = await AutoModelForSequenceClassification.from_pretrained(RERANK_MODEL, loadOpts);
|
package/src/search.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { buildRelationshipGraph, computePageRank, expandGraphNeighborhood, Relat
|
|
|
12
12
|
import { evaluateFilter, FilterNode } from './filter.js';
|
|
13
13
|
import { deserializeBinaryIndex } from './binary-format.js';
|
|
14
14
|
import { dequantizeFromInt8 } from './quantization.js';
|
|
15
|
+
import { buildDiskKey, diskQueryGet, diskQueryPut } from './query-cache-disk.js';
|
|
15
16
|
|
|
16
17
|
export { tokenize } from './lexical.js';
|
|
17
18
|
|
|
@@ -208,8 +209,110 @@ export function getRuntimeGraph(runtime: RuntimeIndexState): { graph: Relationsh
|
|
|
208
209
|
return { graph, pageRank };
|
|
209
210
|
}
|
|
210
211
|
|
|
212
|
+
/**
|
|
213
|
+
* Issue #127 pure helpers — exported for deterministic unit tests.
|
|
214
|
+
*/
|
|
215
|
+
/** Frontmatter `created` → `updated` → file mtime; null when nothing parses. */
|
|
216
|
+
export function resolvePassageAgeMs(
|
|
217
|
+
meta: { created?: unknown; updated?: unknown } | undefined,
|
|
218
|
+
absFile?: string,
|
|
219
|
+
): number | null {
|
|
220
|
+
for (const v of [meta?.created, meta?.updated]) {
|
|
221
|
+
if (typeof v === 'string') {
|
|
222
|
+
const t = Date.parse(v);
|
|
223
|
+
if (Number.isFinite(t)) return t;
|
|
224
|
+
} else if (typeof v === 'number' && Number.isFinite(v)) {
|
|
225
|
+
return v;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (absFile) {
|
|
229
|
+
try {
|
|
230
|
+
return fs.statSync(absFile).mtimeMs;
|
|
231
|
+
} catch {
|
|
232
|
+
/* file gone — treat as unknown age */
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Multiplicative recency boost: 0.5^(ageDays / halfLifeDays); 1 when off/unknown/future. */
|
|
239
|
+
export function recencyBoost(ageMs: number | null, halfLifeDays: number, now: number = Date.now()): number {
|
|
240
|
+
if (ageMs === null || !(halfLifeDays > 0)) return 1;
|
|
241
|
+
const ageDays = Math.max(0, (now - ageMs) / 86_400_000);
|
|
242
|
+
return Math.pow(0.5, ageDays / halfLifeDays);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* HyDE passage generation via an OpenAI-compatible chat endpoint (issue #125).
|
|
247
|
+
* Returns null on ANY failure — HyDE must degrade to PRF/baseline silently.
|
|
248
|
+
* Injectable fetch for tests.
|
|
249
|
+
*/
|
|
250
|
+
export async function hydePassage(
|
|
251
|
+
endpoint: string,
|
|
252
|
+
query: string,
|
|
253
|
+
model?: string,
|
|
254
|
+
fetchImpl: typeof fetch = globalThis.fetch,
|
|
255
|
+
): Promise<string | null> {
|
|
256
|
+
try {
|
|
257
|
+
const res = await fetchImpl(endpoint.replace(/\/$/, '') + '/chat/completions', {
|
|
258
|
+
method: 'POST',
|
|
259
|
+
headers: { 'content-type': 'application/json' },
|
|
260
|
+
body: JSON.stringify({
|
|
261
|
+
...(model ? { model } : {}),
|
|
262
|
+
messages: [
|
|
263
|
+
{ role: 'system', content: 'Write one short passage (2-4 sentences) that would plausibly contain the answer. Output only the passage.' },
|
|
264
|
+
{ role: 'user', content: query },
|
|
265
|
+
],
|
|
266
|
+
}),
|
|
267
|
+
});
|
|
268
|
+
if (!res.ok) return null;
|
|
269
|
+
const data: any = await res.json();
|
|
270
|
+
const text = data?.choices?.[0]?.message?.content ?? data?.response ?? null;
|
|
271
|
+
return typeof text === 'string' && text.trim() ? text.trim() : null;
|
|
272
|
+
} catch {
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
211
277
|
const runtimeIndexStates = new WeakMap<IndexFile, RuntimeIndexState>();
|
|
212
278
|
|
|
279
|
+
/** Minimal EN+RU stopword set for PRF expansion (issue #125). */
|
|
280
|
+
const EXPANSION_STOPWORDS = new Set([
|
|
281
|
+
'the', 'a', 'an', 'and', 'or', 'of', 'to', 'in', 'on', 'for', 'with', 'is', 'are',
|
|
282
|
+
'be', 'this', 'that', 'it', 'as', 'at', 'by', 'from', 'not', 'but', 'we', 'you',
|
|
283
|
+
'и', 'в', 'на', 'с', 'по', 'для', 'как', 'это', 'не', 'а', 'но', 'или', 'из',
|
|
284
|
+
]);
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Salient-term extraction for PRF (issue #125): tokens that appear in the
|
|
288
|
+
* top-m feedback passages, are not query terms or stopwords, and are ranked
|
|
289
|
+
* by (tf-in-feedback × distinct-passage coverage). Pure + deterministic.
|
|
290
|
+
*/
|
|
291
|
+
export function extractExpansionTerms(
|
|
292
|
+
topTexts: string[],
|
|
293
|
+
queryTerms: string[],
|
|
294
|
+
maxTerms: number,
|
|
295
|
+
): string[] {
|
|
296
|
+
const qSet = new Set(queryTerms);
|
|
297
|
+
const tf = new Map<string, number>();
|
|
298
|
+
const df = new Map<string, number>();
|
|
299
|
+
for (const text of topTexts) {
|
|
300
|
+
const seen = new Set<string>();
|
|
301
|
+
for (const tok of tokenize(text)) {
|
|
302
|
+
if (tok.length < 3 || qSet.has(tok) || EXPANSION_STOPWORDS.has(tok)) continue;
|
|
303
|
+
tf.set(tok, (tf.get(tok) ?? 0) + 1);
|
|
304
|
+
seen.add(tok);
|
|
305
|
+
}
|
|
306
|
+
for (const tok of seen) df.set(tok, (df.get(tok) ?? 0) + 1);
|
|
307
|
+
}
|
|
308
|
+
return [...tf.entries()]
|
|
309
|
+
.filter(([, count]) => count >= 2 || topTexts.length === 1)
|
|
310
|
+
.map(([tok, count]) => [tok, count * Math.log(2 + (df.get(tok) ?? 0))] as const)
|
|
311
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
312
|
+
.slice(0, maxTerms)
|
|
313
|
+
.map(([tok]) => tok);
|
|
314
|
+
}
|
|
315
|
+
|
|
213
316
|
function snapshotRuntimeIndex(
|
|
214
317
|
index: IndexFile,
|
|
215
318
|
validation: {
|
|
@@ -500,6 +603,18 @@ export interface SearchOptions {
|
|
|
500
603
|
maxPerFile?: number;
|
|
501
604
|
maxPerDoc?: number;
|
|
502
605
|
useQueryCache?: boolean;
|
|
606
|
+
/** issue #114: persist query embeddings to <cacheDir>/query-cache.json (default on). */
|
|
607
|
+
queryDiskCache?: boolean;
|
|
608
|
+
/** issue #127: time-decay half-life in days; boost = 0.5^(ageDays/halfLife). Omitted = off. */
|
|
609
|
+
recency?: number;
|
|
610
|
+
/** issue #125: query expansion mode. 'prf' is fully offline; 'hyde' needs llmEndpoint (else baseline). */
|
|
611
|
+
expand?: 'prf' | 'hyde';
|
|
612
|
+
/** issue #125: feedback passages for 'prf' (default 3). */
|
|
613
|
+
expandPassages?: number;
|
|
614
|
+
/** issue #125: LLM endpoint used by 'hyde' (OpenAI-compatible chat completions). */
|
|
615
|
+
llmEndpoint?: string;
|
|
616
|
+
/** issue #125: model name for the HyDE chat call. */
|
|
617
|
+
llmModel?: string;
|
|
503
618
|
ann?: boolean;
|
|
504
619
|
nprobe?: number;
|
|
505
620
|
graphBoost?: number;
|
|
@@ -615,19 +730,57 @@ export async function searchIndex(opts: SearchOptions): Promise<SearchResultHit[
|
|
|
615
730
|
});
|
|
616
731
|
}
|
|
617
732
|
|
|
733
|
+
// Issue #125: offline pseudo-relevance feedback (PRF) query expansion.
|
|
734
|
+
// A lexical-only first pass picks the top-m passages; salient terms from
|
|
735
|
+
// them enrich the query, which is then embedded ONCE (replacing the raw
|
|
736
|
+
// query embed — zero extra model calls). HyDE would swap the enriched text
|
|
737
|
+
// for an LLM-generated passage; without a configured endpoint it degrades
|
|
738
|
+
// to the baseline silently.
|
|
739
|
+
let effectiveQuery = query;
|
|
740
|
+
let expandedTerms: string[] | null = null;
|
|
741
|
+
if ((opts.expand === 'prf' || opts.expand === 'hyde') && candidates.length > 0) {
|
|
742
|
+
const qt0 = [...new Set(tokenize(query))];
|
|
743
|
+
if (qt0.length > 0 && lexicalState.kind === 'persisted-bm25') {
|
|
744
|
+
const firstPass = [
|
|
745
|
+
...bm25Scores(lexicalState.lexical, qt0, new Set(candidates.map((c) => c.idx))).entries(),
|
|
746
|
+
]
|
|
747
|
+
.sort((a, b) => b[1] - a[1])
|
|
748
|
+
.slice(0, Math.max(1, opts.expandPassages ?? 3));
|
|
749
|
+
const topTexts = firstPass.map(([idx]) => chunks[idx].text);
|
|
750
|
+
expandedTerms = extractExpansionTerms(topTexts, qt0, 8);
|
|
751
|
+
if (opts.expand === 'hyde' && opts.llmEndpoint) {
|
|
752
|
+
const hydeText = await hydePassage(opts.llmEndpoint, query, opts.llmModel);
|
|
753
|
+
if (hydeText) effectiveQuery = hydeText;
|
|
754
|
+
else if (expandedTerms.length > 0) effectiveQuery = `${query} ${expandedTerms.join(' ')}`;
|
|
755
|
+
} else if (expandedTerms.length > 0) {
|
|
756
|
+
effectiveQuery = `${query} ${expandedTerms.join(' ')}`;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
618
761
|
let qVec: Float32Array | number[];
|
|
619
762
|
if (opts.useQueryCache !== false && runtime.queryCache) {
|
|
620
|
-
const
|
|
763
|
+
const normQuery = effectiveQuery.trim().toLowerCase();
|
|
764
|
+
const cacheKey = `${runtime.model.id}:${runtime.model.revision || 'main'}:${expectedDim || 0}:${normQuery}`;
|
|
765
|
+
// L2 disk layer (issue #114): persists across processes under cacheDir;
|
|
766
|
+
// corrupt/missing files degrade silently to a normal embed.
|
|
767
|
+
const diskKey = buildDiskKey(runtime.model.id, runtime.model.revision || 'main', expectedDim, normQuery);
|
|
768
|
+
const useDisk = opts.queryDiskCache !== false;
|
|
621
769
|
qVec = await runtime.queryCache.getOrCompute(cacheKey, async () => {
|
|
622
|
-
|
|
770
|
+
if (useDisk) {
|
|
771
|
+
const fromDisk = diskQueryGet(cacheDir, diskKey, expectedDim);
|
|
772
|
+
if (fromDisk) return fromDisk;
|
|
773
|
+
}
|
|
774
|
+
const [v] = await embedFn([effectiveQuery], 'query', runtime.model, cacheDir, offline);
|
|
623
775
|
if ((Array.isArray(v) || v instanceof Float32Array) && expectedDim !== undefined && v.length !== expectedDim) {
|
|
624
|
-
throw new Error(`query vector has ${v.length} dims, expected ${expectedDim}
|
|
776
|
+
throw new Error(`query vector has ${v.length} dims, expected ${expectedDim} - run \`mdss index\` to rebuild`);
|
|
625
777
|
}
|
|
626
778
|
validateNumericVector(v, undefined, 'query vector');
|
|
779
|
+
if (useDisk) diskQueryPut(cacheDir, diskKey, v);
|
|
627
780
|
return v;
|
|
628
781
|
});
|
|
629
782
|
} else {
|
|
630
|
-
const [v] = await embedFn([
|
|
783
|
+
const [v] = await embedFn([effectiveQuery], 'query', runtime.model, cacheDir, offline);
|
|
631
784
|
if ((Array.isArray(v) || v instanceof Float32Array) && expectedDim !== undefined && v.length !== expectedDim) {
|
|
632
785
|
throw new Error(`query vector has ${v.length} dims, expected ${expectedDim} — run \`mdss index\` to rebuild`);
|
|
633
786
|
}
|
|
@@ -685,7 +838,7 @@ export async function searchIndex(opts: SearchOptions): Promise<SearchResultHit[
|
|
|
685
838
|
});
|
|
686
839
|
}
|
|
687
840
|
|
|
688
|
-
let ranked: Array<{ idx: number; fscore: number; cos: number; rerank?: number }>;
|
|
841
|
+
let ranked: Array<{ idx: number; fscore: number; cos: number; rerank?: number; rawFscore?: number; recencyFactor?: number; recencyAgeDays?: number | null }>;
|
|
689
842
|
if (semanticOnly) {
|
|
690
843
|
if (graphBoost > 0) {
|
|
691
844
|
const fused = rrf([semantic, graphRanking], 60, [1, graphBoost]);
|
|
@@ -730,6 +883,28 @@ export async function searchIndex(opts: SearchOptions): Promise<SearchResultHit[
|
|
|
730
883
|
}
|
|
731
884
|
}
|
|
732
885
|
|
|
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
|
+
if (recencyHalfLife > 0) {
|
|
892
|
+
const now = Date.now();
|
|
893
|
+
const ageCache = new Map<string, number | null>();
|
|
894
|
+
for (const r of ranked) {
|
|
895
|
+
const c = chunks[r.idx];
|
|
896
|
+
const abs = runtime.db && !path.isAbsolute(c.file) ? path.join(runtime.db, c.file) : c.file;
|
|
897
|
+
const ageKey = `${c.file}|${c.meta?.updated ?? ''}|${c.meta?.created ?? ''}`;
|
|
898
|
+
if (!ageCache.has(ageKey)) ageCache.set(ageKey, resolvePassageAgeMs(c.meta, abs));
|
|
899
|
+
const ageMs = ageCache.get(ageKey) ?? null;
|
|
900
|
+
r.recencyFactor = recencyBoost(ageMs, recencyHalfLife, now);
|
|
901
|
+
r.recencyAgeDays = ageMs === null ? null : Math.max(0, (now - ageMs) / 86_400_000);
|
|
902
|
+
r.rawFscore = r.fscore;
|
|
903
|
+
r.fscore = r.fscore * r.recencyFactor;
|
|
904
|
+
}
|
|
905
|
+
ranked.sort((a, b) => b.fscore - a.fscore);
|
|
906
|
+
}
|
|
907
|
+
|
|
733
908
|
if (rerank && ranked.length > 0) {
|
|
734
909
|
const texts = ranked.map((r) => chunks[r.idx].text);
|
|
735
910
|
const scores = await rerankFn(query, texts, cacheDir, offline);
|
|
@@ -778,10 +953,20 @@ export async function searchIndex(opts: SearchOptions): Promise<SearchResultHit[
|
|
|
778
953
|
? {
|
|
779
954
|
explain: {
|
|
780
955
|
cosine: +r.cos.toFixed(4),
|
|
781
|
-
rrfScore: +r.fscore.toFixed(4),
|
|
956
|
+
rrfScore: +(r.rawFscore ?? r.fscore).toFixed(4),
|
|
782
957
|
bm25Weights: { title: 3.0, aliases: 3.0, headingPath: 1.8, body: 1.0 },
|
|
783
958
|
...(gScore !== undefined ? { graphScore: +gScore.toFixed(4) } : {}),
|
|
784
959
|
...(prScore !== undefined ? { pageRank: +prScore.toFixed(4) } : {}),
|
|
960
|
+
...(r.recencyFactor !== undefined
|
|
961
|
+
? {
|
|
962
|
+
recencyFactor: +r.recencyFactor.toFixed(4),
|
|
963
|
+
recencyAgeDays:
|
|
964
|
+
r.recencyAgeDays === null || r.recencyAgeDays === undefined
|
|
965
|
+
? null
|
|
966
|
+
: +r.recencyAgeDays.toFixed(2),
|
|
967
|
+
}
|
|
968
|
+
: {}),
|
|
969
|
+
...(expandedTerms ? { expandedTerms } : {}),
|
|
785
970
|
},
|
|
786
971
|
}
|
|
787
972
|
: {}),
|
|
@@ -803,3 +988,4 @@ export async function search(opts: OneShotSearchOptions): Promise<SearchResultHi
|
|
|
803
988
|
const loaded = loadIndex(indexDir);
|
|
804
989
|
return searchIndex({ ...rest, loaded });
|
|
805
990
|
}
|
|
991
|
+
|