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/dist/search.js CHANGED
@@ -11,6 +11,7 @@ import { buildRelationshipGraph, computePageRank, expandGraphNeighborhood } from
11
11
  import { evaluateFilter } from './filter.js';
12
12
  import { deserializeBinaryIndex } from './binary-format.js';
13
13
  import { dequantizeFromInt8 } from './quantization.js';
14
+ import { buildDiskKey, diskQueryGet, diskQueryPut } from './query-cache-disk.js';
14
15
  export { tokenize } from './lexical.js';
15
16
  const chunkTokens = new WeakMap();
16
17
  export const _stats = { corpusTokenizedChars: 0 };
@@ -150,7 +151,100 @@ export function getRuntimeGraph(runtime) {
150
151
  runtime.pageRank = pageRank;
151
152
  return { graph, pageRank };
152
153
  }
154
+ /**
155
+ * Issue #127 pure helpers — exported for deterministic unit tests.
156
+ */
157
+ /** Frontmatter `created` → `updated` → file mtime; null when nothing parses. */
158
+ export function resolvePassageAgeMs(meta, absFile) {
159
+ for (const v of [meta?.created, meta?.updated]) {
160
+ if (typeof v === 'string') {
161
+ const t = Date.parse(v);
162
+ if (Number.isFinite(t))
163
+ return t;
164
+ }
165
+ else if (typeof v === 'number' && Number.isFinite(v)) {
166
+ return v;
167
+ }
168
+ }
169
+ if (absFile) {
170
+ try {
171
+ return fs.statSync(absFile).mtimeMs;
172
+ }
173
+ catch {
174
+ /* file gone — treat as unknown age */
175
+ }
176
+ }
177
+ return null;
178
+ }
179
+ /** Multiplicative recency boost: 0.5^(ageDays / halfLifeDays); 1 when off/unknown/future. */
180
+ export function recencyBoost(ageMs, halfLifeDays, now = Date.now()) {
181
+ if (ageMs === null || !(halfLifeDays > 0))
182
+ return 1;
183
+ const ageDays = Math.max(0, (now - ageMs) / 86_400_000);
184
+ return Math.pow(0.5, ageDays / halfLifeDays);
185
+ }
186
+ /**
187
+ * HyDE passage generation via an OpenAI-compatible chat endpoint (issue #125).
188
+ * Returns null on ANY failure — HyDE must degrade to PRF/baseline silently.
189
+ * Injectable fetch for tests.
190
+ */
191
+ export async function hydePassage(endpoint, query, model, fetchImpl = globalThis.fetch) {
192
+ try {
193
+ const res = await fetchImpl(endpoint.replace(/\/$/, '') + '/chat/completions', {
194
+ method: 'POST',
195
+ headers: { 'content-type': 'application/json' },
196
+ body: JSON.stringify({
197
+ ...(model ? { model } : {}),
198
+ messages: [
199
+ { role: 'system', content: 'Write one short passage (2-4 sentences) that would plausibly contain the answer. Output only the passage.' },
200
+ { role: 'user', content: query },
201
+ ],
202
+ }),
203
+ });
204
+ if (!res.ok)
205
+ return null;
206
+ const data = await res.json();
207
+ const text = data?.choices?.[0]?.message?.content ?? data?.response ?? null;
208
+ return typeof text === 'string' && text.trim() ? text.trim() : null;
209
+ }
210
+ catch {
211
+ return null;
212
+ }
213
+ }
153
214
  const runtimeIndexStates = new WeakMap();
215
+ /** Minimal EN+RU stopword set for PRF expansion (issue #125). */
216
+ const EXPANSION_STOPWORDS = new Set([
217
+ 'the', 'a', 'an', 'and', 'or', 'of', 'to', 'in', 'on', 'for', 'with', 'is', 'are',
218
+ 'be', 'this', 'that', 'it', 'as', 'at', 'by', 'from', 'not', 'but', 'we', 'you',
219
+ 'и', 'в', 'на', 'с', 'по', 'для', 'как', 'это', 'не', 'а', 'но', 'или', 'из',
220
+ ]);
221
+ /**
222
+ * Salient-term extraction for PRF (issue #125): tokens that appear in the
223
+ * top-m feedback passages, are not query terms or stopwords, and are ranked
224
+ * by (tf-in-feedback × distinct-passage coverage). Pure + deterministic.
225
+ */
226
+ export function extractExpansionTerms(topTexts, queryTerms, maxTerms) {
227
+ const qSet = new Set(queryTerms);
228
+ const tf = new Map();
229
+ const df = new Map();
230
+ for (const text of topTexts) {
231
+ const seen = new Set();
232
+ for (const tok of tokenize(text)) {
233
+ if (tok.length < 3 || qSet.has(tok) || EXPANSION_STOPWORDS.has(tok))
234
+ continue;
235
+ tf.set(tok, (tf.get(tok) ?? 0) + 1);
236
+ seen.add(tok);
237
+ }
238
+ for (const tok of seen)
239
+ df.set(tok, (df.get(tok) ?? 0) + 1);
240
+ }
241
+ return [...tf.entries()]
242
+ .filter(([, count]) => count >= 2 || topTexts.length === 1)
243
+ .map(([tok, count]) => [tok, count * Math.log(2 + (df.get(tok) ?? 0))])
244
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
245
+ .slice(0, maxTerms)
246
+ .map(([tok]) => tok);
247
+ }
154
248
  function snapshotRuntimeIndex(index, validation) {
155
249
  const dim = validation.dim || (index.chunks[0]?.vec?.length ?? 0);
156
250
  const count = index.chunks.length;
@@ -470,20 +564,62 @@ export async function searchIndex(opts) {
470
564
  return true;
471
565
  });
472
566
  }
567
+ // Issue #125: offline pseudo-relevance feedback (PRF) query expansion.
568
+ // A lexical-only first pass picks the top-m passages; salient terms from
569
+ // them enrich the query, which is then embedded ONCE (replacing the raw
570
+ // query embed — zero extra model calls). HyDE would swap the enriched text
571
+ // for an LLM-generated passage; without a configured endpoint it degrades
572
+ // to the baseline silently.
573
+ let effectiveQuery = query;
574
+ let expandedTerms = null;
575
+ if ((opts.expand === 'prf' || opts.expand === 'hyde') && candidates.length > 0) {
576
+ const qt0 = [...new Set(tokenize(query))];
577
+ if (qt0.length > 0 && lexicalState.kind === 'persisted-bm25') {
578
+ const firstPass = [
579
+ ...bm25Scores(lexicalState.lexical, qt0, new Set(candidates.map((c) => c.idx))).entries(),
580
+ ]
581
+ .sort((a, b) => b[1] - a[1])
582
+ .slice(0, Math.max(1, opts.expandPassages ?? 3));
583
+ const topTexts = firstPass.map(([idx]) => chunks[idx].text);
584
+ expandedTerms = extractExpansionTerms(topTexts, qt0, 8);
585
+ if (opts.expand === 'hyde' && opts.llmEndpoint) {
586
+ const hydeText = await hydePassage(opts.llmEndpoint, query, opts.llmModel);
587
+ if (hydeText)
588
+ effectiveQuery = hydeText;
589
+ else if (expandedTerms.length > 0)
590
+ effectiveQuery = `${query} ${expandedTerms.join(' ')}`;
591
+ }
592
+ else if (expandedTerms.length > 0) {
593
+ effectiveQuery = `${query} ${expandedTerms.join(' ')}`;
594
+ }
595
+ }
596
+ }
473
597
  let qVec;
474
598
  if (opts.useQueryCache !== false && runtime.queryCache) {
475
- const cacheKey = `${runtime.model.id}:${runtime.model.revision || 'main'}:${expectedDim || 0}:${query.trim().toLowerCase()}`;
599
+ const normQuery = effectiveQuery.trim().toLowerCase();
600
+ const cacheKey = `${runtime.model.id}:${runtime.model.revision || 'main'}:${expectedDim || 0}:${normQuery}`;
601
+ // L2 disk layer (issue #114): persists across processes under cacheDir;
602
+ // corrupt/missing files degrade silently to a normal embed.
603
+ const diskKey = buildDiskKey(runtime.model.id, runtime.model.revision || 'main', expectedDim, normQuery);
604
+ const useDisk = opts.queryDiskCache !== false;
476
605
  qVec = await runtime.queryCache.getOrCompute(cacheKey, async () => {
477
- const [v] = await embedFn([query], 'query', runtime.model, cacheDir, offline);
606
+ if (useDisk) {
607
+ const fromDisk = diskQueryGet(cacheDir, diskKey, expectedDim);
608
+ if (fromDisk)
609
+ return fromDisk;
610
+ }
611
+ const [v] = await embedFn([effectiveQuery], 'query', runtime.model, cacheDir, offline);
478
612
  if ((Array.isArray(v) || v instanceof Float32Array) && expectedDim !== undefined && v.length !== expectedDim) {
479
- throw new Error(`query vector has ${v.length} dims, expected ${expectedDim} — run \`mdss index\` to rebuild`);
613
+ throw new Error(`query vector has ${v.length} dims, expected ${expectedDim} - run \`mdss index\` to rebuild`);
480
614
  }
481
615
  validateNumericVector(v, undefined, 'query vector');
616
+ if (useDisk)
617
+ diskQueryPut(cacheDir, diskKey, v);
482
618
  return v;
483
619
  });
484
620
  }
485
621
  else {
486
- const [v] = await embedFn([query], 'query', runtime.model, cacheDir, offline);
622
+ const [v] = await embedFn([effectiveQuery], 'query', runtime.model, cacheDir, offline);
487
623
  if ((Array.isArray(v) || v instanceof Float32Array) && expectedDim !== undefined && v.length !== expectedDim) {
488
624
  throw new Error(`query vector has ${v.length} dims, expected ${expectedDim} — run \`mdss index\` to rebuild`);
489
625
  }
@@ -580,6 +716,28 @@ export async function searchIndex(opts) {
580
716
  .slice(0, pool);
581
717
  }
582
718
  }
719
+ // Issue #127: time-decay recency boost — multiply the fused score by
720
+ // 0.5^(ageDays / halfLifeDays), post-RRF and pre-collapse. Age source
721
+ // priority: frontmatter `created` → `updated` → file mtime. Missing dates
722
+ // get factor 1 (evergreen content stays reachable via lexical strength).
723
+ const recencyHalfLife = Number(opts.recency) > 0 ? Number(opts.recency) : 0;
724
+ if (recencyHalfLife > 0) {
725
+ const now = Date.now();
726
+ const ageCache = new Map();
727
+ for (const r of ranked) {
728
+ const c = chunks[r.idx];
729
+ const abs = runtime.db && !path.isAbsolute(c.file) ? path.join(runtime.db, c.file) : c.file;
730
+ const ageKey = `${c.file}|${c.meta?.updated ?? ''}|${c.meta?.created ?? ''}`;
731
+ if (!ageCache.has(ageKey))
732
+ ageCache.set(ageKey, resolvePassageAgeMs(c.meta, abs));
733
+ const ageMs = ageCache.get(ageKey) ?? null;
734
+ r.recencyFactor = recencyBoost(ageMs, recencyHalfLife, now);
735
+ r.recencyAgeDays = ageMs === null ? null : Math.max(0, (now - ageMs) / 86_400_000);
736
+ r.rawFscore = r.fscore;
737
+ r.fscore = r.fscore * r.recencyFactor;
738
+ }
739
+ ranked.sort((a, b) => b.fscore - a.fscore);
740
+ }
583
741
  if (rerank && ranked.length > 0) {
584
742
  const texts = ranked.map((r) => chunks[r.idx].text);
585
743
  const scores = await rerankFn(query, texts, cacheDir, offline);
@@ -621,10 +779,19 @@ export async function searchIndex(opts) {
621
779
  ? {
622
780
  explain: {
623
781
  cosine: +r.cos.toFixed(4),
624
- rrfScore: +r.fscore.toFixed(4),
782
+ rrfScore: +(r.rawFscore ?? r.fscore).toFixed(4),
625
783
  bm25Weights: { title: 3.0, aliases: 3.0, headingPath: 1.8, body: 1.0 },
626
784
  ...(gScore !== undefined ? { graphScore: +gScore.toFixed(4) } : {}),
627
785
  ...(prScore !== undefined ? { pageRank: +prScore.toFixed(4) } : {}),
786
+ ...(r.recencyFactor !== undefined
787
+ ? {
788
+ recencyFactor: +r.recencyFactor.toFixed(4),
789
+ recencyAgeDays: r.recencyAgeDays === null || r.recencyAgeDays === undefined
790
+ ? null
791
+ : +r.recencyAgeDays.toFixed(2),
792
+ }
793
+ : {}),
794
+ ...(expandedTerms ? { expandedTerms } : {}),
628
795
  },
629
796
  }
630
797
  : {}),
package/dist/serve.d.ts CHANGED
@@ -9,6 +9,12 @@ declare const DEFAULT_PORT = 8747;
9
9
  declare const DEFAULT_HOST = "127.0.0.1";
10
10
  declare const WATCH_INTERVAL_MS = 3000;
11
11
  declare const MAX_BODY_BYTES: number;
12
+ /** Hosts a browser is allowed to name in the Host header (issue #120). */
13
+ export declare const DEFAULT_ALLOWED_HOSTS: string[];
14
+ /** Fairness defaults (issue #119): protective yet friction-free locally. */
15
+ export declare const DEFAULT_RATE_LIMIT_PER_MIN = 60;
16
+ export declare const DEFAULT_RATE_BURST = 10;
17
+ export declare const DEFAULT_MAX_CONCURRENCY: number;
12
18
  export interface ServeState {
13
19
  loaded: {
14
20
  index: any;
@@ -23,6 +29,17 @@ export interface ServeState {
23
29
  reindexCount?: number;
24
30
  apiKey?: string;
25
31
  healthPublic?: boolean;
32
+ /** issue #111: serve the built-in web UI at `/` (default on). */
33
+ ui?: boolean;
34
+ /** issue #123: mount the Streamable HTTP MCP transport at /mcp (default off). */
35
+ mcp?: boolean;
36
+ mcpSessionId?: string;
37
+ /** Extra Host-header values accepted beyond the loopback defaults (issue #120). */
38
+ allowedHosts?: string[];
39
+ /** Exact origins reflected as `Access-Control-Allow-Origin`; absent → CORS off (issue #120). */
40
+ corsOrigins?: string[];
41
+ /** Fairness gates for /search (issue #119); `0` disables a gate. */
42
+ limiter?: ServeLimiter;
26
43
  }
27
44
  export interface CreateServeOptions {
28
45
  indexDir: string;
@@ -36,14 +53,123 @@ export interface CreateServeOptions {
36
53
  watchDelay?: number;
37
54
  apiKey?: string;
38
55
  healthPublic?: boolean;
56
+ allowedHosts?: string[];
57
+ corsOrigins?: string[];
58
+ rateLimit?: number;
59
+ maxConcurrency?: number;
60
+ /** issue #116: verbose watch-loop trace (polls, retries, classifications). */
61
+ watchDebug?: boolean;
62
+ /** issue #116 test seam: override hashing/stat so fault-injection needs no real locks. */
63
+ _testFs?: {
64
+ hash?: (absPath: string) => string;
65
+ stat?: (absPath: string) => {
66
+ mtimeMs: number;
67
+ };
68
+ };
69
+ ui?: boolean;
70
+ /** issue #123: mount Streamable HTTP MCP at /mcp (default off; stdio stays default). */
71
+ mcp?: boolean;
39
72
  embedFn?: any;
40
73
  rerankFn?: any;
41
74
  log?: (msg: string) => void;
42
75
  }
76
+ /**
77
+ * Token bucket for per-client request budgeting (issue #119): starts full
78
+ * (`burst`), refills continuously at `limitPerMinute`/60 tokens per second.
79
+ * `take` is injective in time so tests can time-travel via the `now` argument.
80
+ */
81
+ export declare class TokenBucket {
82
+ readonly limitPerMinute: number;
83
+ readonly burst: number;
84
+ private tokens;
85
+ /** Last refill timestamp — doubles as a staleness marker for map pruning. */
86
+ lastTouched: number;
87
+ constructor(limitPerMinute: number, burst: number);
88
+ take(now?: number): {
89
+ ok: true;
90
+ } | {
91
+ ok: false;
92
+ retryAfterSec: number;
93
+ };
94
+ }
95
+ /** A granted slot's release handle — idempotent, so `finally` is always safe. */
96
+ type SlotRelease = () => void;
97
+ /**
98
+ * Counting semaphore with a bounded wait queue (issue #119): once `maxQueued`
99
+ * callers already wait, acquire() returns null instead of enqueueing, so an
100
+ * overloaded daemon sheds load with 503 rather than ballooning memory.
101
+ */
102
+ export declare class BoundedSemaphore {
103
+ readonly size: number;
104
+ readonly maxQueued: number;
105
+ private available;
106
+ private readonly waiters;
107
+ constructor(size: number, maxQueued: number);
108
+ get executing(): number;
109
+ get queued(): number;
110
+ acquire(): Promise<SlotRelease | null>;
111
+ private releaseSlot;
112
+ }
113
+ /**
114
+ * Fairness gates for POST /search (issue #119):
115
+ * - per-client token bucket → 429 + Retry-After when a client exceeds its budget;
116
+ * - global concurrency cap around the embedding sweep; queue overflow → 503.
117
+ * Either gate disabled by passing `0`; counters are exposed via /health.
118
+ */
119
+ export declare class ServeLimiter {
120
+ readonly rateLimitPerMin: number;
121
+ readonly rateBurst: number;
122
+ readonly maxConcurrency: number;
123
+ private readonly buckets;
124
+ private readonly sem;
125
+ rejected429: number;
126
+ rejected503: number;
127
+ constructor(rateLimitPerMin: number, rateBurst: number, maxConcurrency: number);
128
+ /** Burst is capped by the per-minute budget itself (--rate-limit 5 → burst 5). */
129
+ private effectiveBurst;
130
+ get rateEnabled(): boolean;
131
+ get concurrencyEnabled(): boolean;
132
+ get inFlight(): number;
133
+ get queued(): number;
134
+ get rejectedTotal(): number;
135
+ /** Cheap synchronous rate check. Null → proceed; otherwise reject with 429. */
136
+ gate(clientKey: string, now?: number): {
137
+ status: 429;
138
+ retryAfterSec: number;
139
+ } | null;
140
+ /**
141
+ * Acquire a global search slot. Resolves with a release handle, or null when
142
+ * the wait queue is saturated (caller replies 503).
143
+ */
144
+ acquireSlot(): Promise<SlotRelease | null>;
145
+ }
43
146
  export declare function createServe(opts: CreateServeOptions): Promise<{
44
147
  server: http.Server;
45
148
  state: ServeState;
46
149
  close: () => Promise<void>;
47
150
  }>;
48
151
  export declare function isAuthorizedToken(token?: string | null, expectedKey?: string | null): boolean;
152
+ /** True when `host` binds to loopback only (issue #121). Empty/undefined means the loopback default. */
153
+ export declare function isLoopbackHost(host?: string | null): boolean;
154
+ /**
155
+ * Refuse to start an unauthenticated server on a non-loopback interface (issue #121):
156
+ * `mdss serve --host 0.0.0.0` without an API key silently serves the whole knowledge
157
+ * base to the LAN. Explicit `--allow-unsecured` opts in (banner still printed by CLI).
158
+ */
159
+ export declare function validateBindSecurity(opts: {
160
+ host?: string;
161
+ hasApiKey: boolean;
162
+ allowUnsecured?: boolean;
163
+ }): void;
164
+ /**
165
+ * Read an API key from a file (issue #121): avoids shell-history / CI-log leaks of
166
+ * literal `--api-key` values. Trailing newlines are trimmed; on POSIX a group- or
167
+ * world-readable key file draws a loud warning.
168
+ */
169
+ export declare function loadApiKeyFile(filePath: string, log?: (msg: string) => void): string;
170
+ /**
171
+ * Strip the port from a Host header value, keeping IPv6 bracket forms intact:
172
+ * "localhost:8747" → "localhost", "[::1]:8747" → "[::1]", "::1" → "::1" (issue #120).
173
+ */
174
+ export declare function splitHostHeader(hostHeader: string): string;
49
175
  export { DEFAULT_PORT, DEFAULT_HOST, MAX_BODY_BYTES, WATCH_INTERVAL_MS };