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/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 cacheKey = `${runtime.model.id}:${runtime.model.revision || 'main'}:${expectedDim || 0}:${query.trim().toLowerCase()}`;
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
- const [v] = await embedFn([query], 'query', runtime.model, cacheDir, offline);
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} — run \`mdss index\` to rebuild`);
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([query], 'query', runtime.model, cacheDir, offline);
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
+