md-semantic-search 1.1.0 → 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.
Files changed (64) hide show
  1. package/README.md +88 -14
  2. package/RESEARCH.md +182 -0
  3. package/bin/cli.mjs +197 -8
  4. package/dist/binary-format.js +9 -0
  5. package/dist/completions.js +5 -0
  6. package/dist/core.d.ts +16 -4
  7. package/dist/core.js +105 -14
  8. package/dist/federation.d.ts +16 -1
  9. package/dist/federation.js +33 -16
  10. package/dist/filter.d.ts +7 -0
  11. package/dist/filter.js +13 -0
  12. package/dist/hnsw.d.ts +96 -0
  13. package/dist/hnsw.js +326 -0
  14. package/dist/indexer.d.ts +17 -1
  15. package/dist/indexer.js +91 -17
  16. package/dist/lexical.d.ts +10 -0
  17. package/dist/lexical.js +98 -15
  18. package/dist/markdown-parser.d.ts +62 -0
  19. package/dist/markdown-parser.js +227 -30
  20. package/dist/mcp.d.ts +86 -3
  21. package/dist/mcp.js +259 -31
  22. package/dist/models.d.ts +7 -1
  23. package/dist/models.js +32 -1
  24. package/dist/providers.d.ts +56 -0
  25. package/dist/providers.js +305 -0
  26. package/dist/quantization.d.ts +12 -0
  27. package/dist/quantization.js +54 -0
  28. package/dist/rag.d.ts +60 -0
  29. package/dist/rag.js +133 -35
  30. package/dist/repair.d.ts +1 -1
  31. package/dist/repair.js +53 -2
  32. package/dist/rerank.d.ts +32 -3
  33. package/dist/rerank.js +58 -8
  34. package/dist/search.d.ts +48 -3
  35. package/dist/search.js +384 -78
  36. package/dist/serve.d.ts +35 -0
  37. package/dist/serve.js +478 -58
  38. package/dist/tui.d.ts +6 -0
  39. package/dist/tui.js +52 -2
  40. package/dist/watcher.js +23 -12
  41. package/dist/webui.d.ts +3 -3
  42. package/dist/webui.js +201 -2
  43. package/package.json +3 -3
  44. package/src/binary-format.ts +13 -0
  45. package/src/completions.ts +5 -0
  46. package/src/core.ts +115 -12
  47. package/src/federation.ts +51 -17
  48. package/src/filter.ts +14 -0
  49. package/src/hnsw.ts +414 -0
  50. package/src/indexer.ts +107 -18
  51. package/src/lexical.ts +91 -13
  52. package/src/markdown-parser.ts +279 -37
  53. package/src/mcp.ts +323 -34
  54. package/src/models.ts +39 -2
  55. package/src/providers.ts +345 -0
  56. package/src/quantization.ts +61 -0
  57. package/src/rag.ts +189 -34
  58. package/src/repair.ts +51 -2
  59. package/src/rerank.ts +81 -8
  60. package/src/search.ts +417 -82
  61. package/src/serve.ts +499 -66
  62. package/src/tui.ts +56 -2
  63. package/src/watcher.ts +21 -10
  64. package/src/webui.ts +201 -2
package/dist/rag.js CHANGED
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import crypto from 'node:crypto';
4
4
  import { searchIndex, loadIndex } from './search.js';
5
5
  import { tokenize } from './lexical.js';
6
+ import { resolveChatProvider } from './providers.js';
6
7
  export const DEFAULT_RAG_SYSTEM_PROMPT = 'You are a helpful assistant answering questions using only the provided Markdown notes. ' +
7
8
  'Always cite your sources using [file#heading] format. ' +
8
9
  'If the context does not contain the answer, say that the notes do not have enough information.';
@@ -12,10 +13,21 @@ export const DEFAULT_RAG_SYSTEM_PROMPT = 'You are a helpful assistant answering
12
13
  export function getSessionsDir(indexDir) {
13
14
  return path.join(indexDir, 'sessions');
14
15
  }
16
+ const SESSION_ID_PATTERN = /^[a-zA-Z0-9_-]{1,64}$/;
17
+ /**
18
+ * Validates a session id before it is used as a filesystem path component.
19
+ */
20
+ export function assertSafeSessionId(id) {
21
+ if (!SESSION_ID_PATTERN.test(id)) {
22
+ throw new Error('Invalid session ID: expected 1-64 letters, numbers, hyphens, or underscores');
23
+ }
24
+ return id;
25
+ }
15
26
  /**
16
27
  * Atomically saves a ChatSession to disk (<indexDir>/sessions/<id>.json).
17
28
  */
18
29
  export function saveChatSession(indexDir, session) {
30
+ assertSafeSessionId(session.id);
19
31
  const dir = getSessionsDir(indexDir);
20
32
  fs.mkdirSync(dir, { recursive: true });
21
33
  const targetPath = path.join(dir, `${session.id}.json`);
@@ -28,6 +40,7 @@ export function saveChatSession(indexDir, session) {
28
40
  * Loads a ChatSession by id or creates a new one. Degrades safely if corrupt.
29
41
  */
30
42
  export function loadChatSession(indexDir, id, log = () => { }) {
43
+ assertSafeSessionId(id);
31
44
  const sessionPath = path.join(getSessionsDir(indexDir), `${id}.json`);
32
45
  if (fs.existsSync(sessionPath)) {
33
46
  try {
@@ -128,6 +141,56 @@ export function extractAnswerFallback(query, passages) {
128
141
  const body = selected.map((s) => `${s.sentence} ${s.citation}`).join('\n\n');
129
142
  return `Based on your notes:\n\n${body}`;
130
143
  }
144
+ const CITATION_MENTION_RE = /\[([^\]\n]{1,200})\]/g;
145
+ /** `[file]` | `[file#heading]` | `[file › heading]` → {file, heading?}; null for non-citations. */
146
+ function parseCitationMention(text) {
147
+ const t = text.trim();
148
+ if (!t)
149
+ return null;
150
+ let file;
151
+ let heading;
152
+ const hash = t.indexOf('#');
153
+ const sep = t.indexOf('›');
154
+ if (hash > 0) {
155
+ file = t.slice(0, hash).trim();
156
+ heading = t.slice(hash + 1).trim();
157
+ }
158
+ else if (sep > 0) {
159
+ file = t.slice(0, sep).trim();
160
+ heading = t.slice(sep + 1).trim();
161
+ }
162
+ else {
163
+ file = t;
164
+ }
165
+ // markdown links, footnote refs, and empty brackets are not citations
166
+ if (!file || /https?:\/\//i.test(file))
167
+ return null;
168
+ return { file, heading: heading || undefined };
169
+ }
170
+ /**
171
+ * issue #199 follow-up: which of the retrieved citations the answer text
172
+ * actually cites. Bracket-only scan (the exact formats the system prompt and
173
+ * the extractive fallback emit), exact file/heading match against the
174
+ * retrieval manifest, order of first mention. No mention → [].
175
+ */
176
+ export function extractUsedCitations(answer, citations) {
177
+ const used = [];
178
+ const seen = new Set();
179
+ for (const m of answer.matchAll(CITATION_MENTION_RE)) {
180
+ const mention = parseCitationMention(m[1]);
181
+ if (!mention)
182
+ continue;
183
+ const hit = citations.find((c) => c.file === mention.file && (mention.heading === undefined || c.heading === mention.heading));
184
+ if (!hit)
185
+ continue;
186
+ const key = `${hit.file}\u0000${hit.heading ?? ''}`;
187
+ if (!seen.has(key)) {
188
+ seen.add(key);
189
+ used.push(hit);
190
+ }
191
+ }
192
+ return used;
193
+ }
131
194
  /**
132
195
  * Synthesizes an answer from retrieved passages using an LLM or fallback.
133
196
  */
@@ -137,55 +200,77 @@ export async function synthesizeAnswer(query, passages, options = {}) {
137
200
  heading: p.heading,
138
201
  score: p.score,
139
202
  }));
203
+ // One prompt assembly for every stage: `system + user` reproduces the
204
+ // historical single-string prompt byte-for-byte; the split serves the
205
+ // structured transports (MCP sampling, issue #197).
206
+ const system = options.systemPrompt || DEFAULT_RAG_SYSTEM_PROMPT;
140
207
  const historyHeader = options.conversationHistory ? `\nConversation History:\n${options.conversationHistory}\n` : '';
208
+ const contextBlocks = passages
209
+ .map((p, i) => {
210
+ const header = p.heading ? `${p.file} › ${p.heading}` : p.file;
211
+ return `[Source ${i + 1}: ${header}]\n${p.snippet}`;
212
+ })
213
+ .join('\n\n---\n\n');
214
+ const user = `${historyHeader}\n\nContext:\n${contextBlocks}\n\nQuestion: ${query}\n\nAnswer:`;
215
+ // every stage returns through one shape so citationsUsed stays consistent
216
+ const finish = (answer, mode) => ({
217
+ answer,
218
+ citations,
219
+ citationsUsed: extractUsedCitations(answer, citations),
220
+ passages,
221
+ mode,
222
+ });
223
+ // issue #197: structured hook (MCP sampling). Best-effort like the endpoint
224
+ // path — a refusal or timeout falls through to the extractive answer.
225
+ if (options.llmRequestFn) {
226
+ try {
227
+ const answer = await options.llmRequestFn({ system, user });
228
+ if (answer)
229
+ return finish(answer, 'llm');
230
+ }
231
+ catch {
232
+ // Fall through to extractive.
233
+ }
234
+ }
141
235
  if (options.llmFn) {
142
- const contextBlocks = passages
143
- .map((p, i) => {
144
- const header = p.heading ? `${p.file} › ${p.heading}` : p.file;
145
- return `[Source ${i + 1}: ${header}]\n${p.snippet}`;
146
- })
147
- .join('\n\n---\n\n');
148
- const prompt = `${options.systemPrompt || DEFAULT_RAG_SYSTEM_PROMPT}${historyHeader}\n\nContext:\n${contextBlocks}\n\nQuestion: ${query}\n\nAnswer:`;
149
- const answer = await options.llmFn(prompt);
150
- return { answer, citations, passages };
236
+ const answer = await options.llmFn(system + user);
237
+ return finish(answer, 'llm');
151
238
  }
152
- if (options.llmEndpoint) {
153
- const contextBlocks = passages
154
- .map((p, i) => {
155
- const header = p.heading ? `${p.file} › ${p.heading}` : p.file;
156
- return `[Source ${i + 1}: ${header}]\n${p.snippet}`;
157
- })
158
- .join('\n\n---\n\n');
159
- const prompt = `${options.systemPrompt || DEFAULT_RAG_SYSTEM_PROMPT}${historyHeader}\n\nContext:\n${contextBlocks}\n\nQuestion: ${query}\n\nAnswer:`;
239
+ // issue #199: unified chat provider path. `/chat/completions` by default
240
+ // (same wire format HyDE uses — llama.cpp, LM Studio, vLLM, Ollama /v1 all
241
+ // speak it), Ollama-native `/api/generate` under `--llm-provider ollama`.
242
+ // Streaming deltas flow to onDelta; the full text is assembled for the
243
+ // returned result. Best-effort like the old endpoint path — a refusal or
244
+ // idle timeout falls through to the extractive answer.
245
+ if (options.llmChat || options.llmEndpoint) {
246
+ const chat = options.llmChat ??
247
+ resolveChatProvider({
248
+ provider: options.llmProvider ?? 'openai',
249
+ model: options.llmModel,
250
+ baseUrl: options.llmEndpoint,
251
+ });
160
252
  try {
161
- const resp = await fetch(`${options.llmEndpoint}/api/generate`, {
162
- method: 'POST',
163
- headers: { 'Content-Type': 'application/json' },
164
- body: JSON.stringify({
165
- model: options.llmModel || 'llama3',
166
- prompt,
167
- stream: false,
168
- }),
253
+ const answer = await chat.complete({
254
+ system,
255
+ user,
256
+ ...(options.onDelta ? { onDelta: options.onDelta } : {}),
257
+ ...(options.signal ? { signal: options.signal } : {}),
169
258
  });
170
- if (resp.ok) {
171
- const data = await resp.json();
172
- const answer = data.response || data.text || '';
173
- if (answer)
174
- return { answer, citations, passages };
175
- }
259
+ if (answer)
260
+ return finish(answer, 'endpoint');
176
261
  }
177
262
  catch {
178
- // Fallback to extractive
263
+ // Fall through to extractive.
179
264
  }
180
265
  }
181
266
  const answer = extractAnswerFallback(query, passages);
182
- return { answer, citations, passages };
267
+ return finish(answer, 'extractive');
183
268
  }
184
269
  /**
185
270
  * End-to-end question answering against a Markdown index.
186
271
  */
187
272
  export async function askQuestion(opts) {
188
- const { query, loaded: inputLoaded, indexDir, cacheDir = '', k = 5, llmFn, llmEndpoint, llmModel, systemPrompt, embedFn, } = opts;
273
+ const { query, loaded: inputLoaded, indexDir, cacheDir = '', k = 5, llmFn, llmRequestFn, llmEndpoint, llmModel, llmProvider, llmChat, onDelta, systemPrompt, embedFn, } = opts;
189
274
  let loaded = inputLoaded;
190
275
  if (!loaded) {
191
276
  if (!indexDir)
@@ -198,11 +283,16 @@ export async function askQuestion(opts) {
198
283
  query,
199
284
  k,
200
285
  embedFn,
286
+ offline: opts.offline,
201
287
  });
202
288
  return synthesizeAnswer(query, passages, {
203
289
  llmFn,
290
+ llmRequestFn,
204
291
  llmEndpoint,
205
292
  llmModel,
293
+ llmProvider,
294
+ llmChat,
295
+ onDelta,
206
296
  systemPrompt,
207
297
  });
208
298
  }
@@ -210,7 +300,7 @@ export async function askQuestion(opts) {
210
300
  * Executes a single conversational turn in a ChatSession with context carry-over and source manifest.
211
301
  */
212
302
  export async function chatTurn(opts) {
213
- const { query, loaded: inputLoaded, indexDir, cacheDir = '', k = 5, llmFn, llmEndpoint, llmModel, systemPrompt, embedFn, log = () => { }, } = opts;
303
+ const { query, loaded: inputLoaded, indexDir, cacheDir = '', k = 5, llmFn, llmRequestFn, llmEndpoint, llmModel, llmProvider, llmChat, onDelta, systemPrompt, embedFn, log = () => { }, } = opts;
214
304
  let loaded = inputLoaded;
215
305
  if (!loaded) {
216
306
  if (!indexDir)
@@ -243,6 +333,7 @@ export async function chatTurn(opts) {
243
333
  query: retrievalQuery,
244
334
  k,
245
335
  embedFn,
336
+ offline: opts.offline,
246
337
  });
247
338
  // 3. Construct source manifest from retrieved chunks
248
339
  const manifest = passages.map((p) => {
@@ -268,8 +359,12 @@ export async function chatTurn(opts) {
268
359
  // 5. Synthesize answer
269
360
  const synth = await synthesizeAnswer(query, passages, {
270
361
  llmFn,
362
+ llmRequestFn,
271
363
  llmEndpoint,
272
364
  llmModel,
365
+ llmProvider,
366
+ llmChat,
367
+ onDelta,
273
368
  systemPrompt,
274
369
  conversationHistory,
275
370
  });
@@ -281,6 +376,7 @@ export async function chatTurn(opts) {
281
376
  answer: synth.answer,
282
377
  citations: synth.citations,
283
378
  manifest,
379
+ mode: synth.mode,
284
380
  };
285
381
  session.turns.push(turn);
286
382
  session.updatedAt = turn.timestamp;
@@ -302,5 +398,7 @@ export async function chatTurn(opts) {
302
398
  answer: turn.answer,
303
399
  citations: turn.citations,
304
400
  manifest: turn.manifest,
401
+ mode: turn.mode ?? 'extractive',
402
+ citationsUsed: extractUsedCitations(turn.answer, turn.citations),
305
403
  };
306
404
  }
package/dist/repair.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export interface RepairAction {
2
- action: 'remove-stale-lock' | 'remove-broken-vectors-bin' | 'remove-corrupt-ivf' | 'rebuild-index' | 'rewrite-hashes';
2
+ action: 'remove-stale-lock' | 'remove-broken-vectors-bin' | 'remove-corrupt-ivf' | 'remove-corrupt-hnsw' | 'rebuild-index' | 'rewrite-hashes';
3
3
  detail: string;
4
4
  }
5
5
  export interface RepairPlan {
package/dist/repair.js CHANGED
@@ -108,7 +108,7 @@ export function planRepairs(indexDir, opts = {}) {
108
108
  });
109
109
  }
110
110
  }
111
- // --- 3. corrupt ivf.json (optional ANN sidecar; loader silently ignores it) ---
111
+ // --- 3. corrupt ANN sidecars (optional; loader silently ignores them) ---
112
112
  const ivfPath = path.join(indexDir, 'ivf.json');
113
113
  const ivfRaw = readJsonIfParsable(ivfPath);
114
114
  if (ivfRaw.ok === false) {
@@ -117,9 +117,19 @@ export function planRepairs(indexDir, opts = {}) {
117
117
  detail: 'ivf.json is not valid JSON — removing it re-enables brute-force ANN fallback',
118
118
  });
119
119
  }
120
+ // issue #201: hnsw.json gets the same corrupt-sidecar treatment
121
+ const hnswPath = path.join(indexDir, 'hnsw.json');
122
+ const hnswRaw = readJsonIfParsable(hnswPath);
123
+ if (hnswRaw.ok === false) {
124
+ plan.actions.push({
125
+ action: 'remove-corrupt-hnsw',
126
+ detail: 'hnsw.json is not valid JSON — removing it re-enables brute-force ANN fallback',
127
+ });
128
+ }
120
129
  // --- 4. corrupt vectors.json ---
121
130
  const liveWriter = plan.skipped.some((s) => s.action === 'remove-stale-lock' && /live writer/.test(s.detail));
122
- const vectorsRaw = readJsonIfParsable(path.join(indexDir, 'vectors.json'));
131
+ const vectorsPath = path.join(indexDir, 'vectors.json');
132
+ const vectorsRaw = readJsonIfParsable(vectorsPath);
123
133
  if (vectorsRaw.ok === false) {
124
134
  if (db && !liveWriter) {
125
135
  plan.actions.push({ action: 'rebuild-index', detail: `vectors.json is not valid JSON — rebuild from ${db}` });
@@ -137,6 +147,44 @@ export function planRepairs(indexDir, opts = {}) {
137
147
  });
138
148
  }
139
149
  }
150
+ // --- 4b. vectors.json SHA-256 sidecar mismatch (issue #166) ---
151
+ // `mdss check` reports this as an integrity failure; without a planned
152
+ // repair `check --fix` printed "nothing to repair" and left the index
153
+ // broken. Mirror the check's hashing (raw bytes vs first sidecar token)
154
+ // and plan the same rebuild as the corrupt-JSON path — rewriting the
155
+ // sidecar would bless whatever tampered content is on disk.
156
+ if (vectorsRaw.ok === true) {
157
+ const vectorsShaPath = `${vectorsPath}.sha256`;
158
+ if (fs.existsSync(vectorsShaPath)) {
159
+ let mismatch = null;
160
+ try {
161
+ const actual = crypto.createHash('sha256').update(fs.readFileSync(vectorsPath)).digest('hex');
162
+ const expected = fs.readFileSync(vectorsShaPath, 'utf8').trim().split(/\s+/)[0];
163
+ if (expected && expected !== actual)
164
+ mismatch = 'SHA-256 mismatch';
165
+ }
166
+ catch (e) {
167
+ mismatch = e.message;
168
+ }
169
+ if (mismatch) {
170
+ if (db && !liveWriter) {
171
+ plan.actions.push({ action: 'rebuild-index', detail: `vectors.json integrity check failed (${mismatch}) — rebuild from ${db}` });
172
+ }
173
+ else if (liveWriter) {
174
+ plan.skipped.push({
175
+ action: 'rebuild-index',
176
+ detail: `vectors.json integrity check failed (${mismatch}), but a live writer holds the lock — retry after it finishes`,
177
+ });
178
+ }
179
+ else {
180
+ plan.skipped.push({
181
+ action: 'rebuild-index',
182
+ detail: `vectors.json integrity check failed (${mismatch}) — pass --db so --fix can rebuild it`,
183
+ });
184
+ }
185
+ }
186
+ }
187
+ }
140
188
  // --- 5. damaged .hashes.json ---
141
189
  const hashesRaw = readJsonIfParsable(path.join(indexDir, '.hashes.json'));
142
190
  if (hashesRaw.ok === false) {
@@ -175,6 +223,9 @@ export function applyRepairs(indexDir, plan, opts = {}) {
175
223
  case 'remove-corrupt-ivf':
176
224
  fs.rmSync(path.join(indexDir, 'ivf.json'), { force: true });
177
225
  break;
226
+ case 'remove-corrupt-hnsw':
227
+ fs.rmSync(path.join(indexDir, 'hnsw.json'), { force: true });
228
+ break;
178
229
  case 'rewrite-hashes': {
179
230
  const hashes = recomputeHashes(opts.db);
180
231
  fs.writeFileSync(path.join(indexDir, '.hashes.json'), JSON.stringify(hashes, null, 2));
package/dist/rerank.d.ts CHANGED
@@ -17,10 +17,39 @@ export interface LoadedReranker {
17
17
  tokenizer: RerankerTokenizer;
18
18
  model: RerankerModel;
19
19
  }
20
- export declare const RERANK_MODEL = "Xenova/bge-reranker-base";
20
+ /**
21
+ * Reranker registry (issue #203) — the cross-encoder counterpart of the
22
+ * embedder MODELS registry. Each entry is an explicit adapter: repo id,
23
+ * pinned revision, runtime dtype, and the tokenizer context budget the
24
+ * pair-truncation relies on. The loaded model must be a single-logit
25
+ * sequence classifier (logits[0] = relevance score) — the extraction in
26
+ * rerankScores is shared by every entry.
27
+ */
28
+ export interface RerankerAdapter {
29
+ id: string;
30
+ revision?: string;
31
+ dtype: string;
32
+ maxTokens: number;
33
+ family?: string;
34
+ note?: string;
35
+ }
36
+ export declare const RERANKERS: Record<string, RerankerAdapter>;
37
+ export declare const DEFAULT_RERANKER = "gte-reranker-modernbert";
38
+ /** Legacy export — the default adapter's repo id (kept for back-compat). */
39
+ export declare const RERANK_MODEL: string;
40
+ /**
41
+ * Resolve a reranker alias / raw repo id (optionally `id@revision`) to an
42
+ * adapter. Unknown ids are rejected — unlike embedders there is no
43
+ * "unknown adapter" escape hatch, because the single-logit contract cannot
44
+ * be guessed safely.
45
+ */
46
+ export declare function resolveReranker(name?: string | null): {
47
+ alias: string;
48
+ adapter: RerankerAdapter;
49
+ };
21
50
  export declare function getReranker(cacheDir: string, offline?: boolean, retryOpts?: {
22
51
  maxRetries?: number;
23
52
  delays?: number[];
24
53
  log?: (msg: string) => void;
25
- }): Promise<LoadedReranker>;
26
- export declare function rerankScores(query: string, passages: string[], cacheDir: string, offline?: boolean): Promise<number[]>;
54
+ }, rerankerName?: string | null): Promise<LoadedReranker>;
55
+ export declare function rerankScores(query: string, passages: string[], cacheDir: string, offline?: boolean, rerankerName?: string | null): Promise<number[]>;
package/dist/rerank.js CHANGED
@@ -3,11 +3,57 @@
3
3
  */
4
4
  import { resolveSessionOptions, retryWithBackoff, emitDownloadEvent } from './core.js';
5
5
  const _rerankers = new Map();
6
- export const RERANK_MODEL = 'Xenova/bge-reranker-base';
7
- export async function getReranker(cacheDir, offline = false, retryOpts = {}) {
6
+ export const RERANKERS = {
7
+ 'gte-reranker-modernbert': {
8
+ id: 'Alibaba-NLP/gte-reranker-modernbert-base',
9
+ revision: 'f7481e6055501a30fb19d090657df9ec1f79ab2c',
10
+ dtype: 'q8',
11
+ maxTokens: 8192,
12
+ family: 'gte',
13
+ note: 'Default since issue #203 — ~144 MB q8, Apache-2.0, ModernBERT 8k context. Golden set: nDCG@10 0.9318 vs bge-reranker-base 0.8839.',
14
+ },
15
+ 'bge-reranker-base': {
16
+ id: 'Xenova/bge-reranker-base',
17
+ dtype: 'q8',
18
+ maxTokens: 512,
19
+ family: 'bge',
20
+ note: '~278 MB q8. The pre-#203 default; kept for reproducibility (pin via --rerank-model bge-reranker-base).',
21
+ },
22
+ };
23
+ export const DEFAULT_RERANKER = 'gte-reranker-modernbert';
24
+ /** Legacy export — the default adapter's repo id (kept for back-compat). */
25
+ export const RERANK_MODEL = RERANKERS[DEFAULT_RERANKER].id;
26
+ /**
27
+ * Resolve a reranker alias / raw repo id (optionally `id@revision`) to an
28
+ * adapter. Unknown ids are rejected — unlike embedders there is no
29
+ * "unknown adapter" escape hatch, because the single-logit contract cannot
30
+ * be guessed safely.
31
+ */
32
+ export function resolveReranker(name) {
33
+ if (!name || name === DEFAULT_RERANKER) {
34
+ return { alias: DEFAULT_RERANKER, adapter: RERANKERS[DEFAULT_RERANKER] };
35
+ }
36
+ if (RERANKERS[name])
37
+ return { alias: name, adapter: RERANKERS[name] };
38
+ let id = name;
39
+ let revision;
40
+ const at = name.indexOf('@');
41
+ if (at > 0) {
42
+ id = name.slice(0, at);
43
+ revision = name.slice(at + 1) || undefined;
44
+ }
45
+ for (const [alias, adapter] of Object.entries(RERANKERS)) {
46
+ if (adapter.id === id) {
47
+ return { alias, adapter: revision ? { ...adapter, revision } : adapter };
48
+ }
49
+ }
50
+ throw new Error(`unknown reranker "${name}" — available: ${Object.keys(RERANKERS).join(', ')} (or register it in RERANKERS, issue #203)`);
51
+ }
52
+ export async function getReranker(cacheDir, offline = false, retryOpts = {}, rerankerName) {
53
+ const { adapter } = resolveReranker(rerankerName);
8
54
  const sessionOptions = resolveSessionOptions();
9
55
  const sessionKey = sessionOptions ? JSON.stringify(sessionOptions) : 'default';
10
- const key = `${RERANK_MODEL}|${offline ? 'off' : 'on'}|${sessionKey}`;
56
+ const key = `${adapter.id}@${adapter.revision || 'main'}|${adapter.dtype}|${offline ? 'off' : 'on'}|${sessionKey}`;
11
57
  if (_rerankers.has(key))
12
58
  return _rerankers.get(key);
13
59
  const loadPipeline = async () => {
@@ -15,13 +61,17 @@ export async function getReranker(cacheDir, offline = false, retryOpts = {}) {
15
61
  if (cacheDir)
16
62
  env.cacheDir = cacheDir;
17
63
  env.allowRemoteModels = !offline;
18
- const tokenizer = await AutoTokenizer.from_pretrained(RERANK_MODEL, { progress_callback: emitDownloadEvent });
64
+ const tokenizer = await AutoTokenizer.from_pretrained(adapter.id, {
65
+ ...(adapter.revision ? { revision: adapter.revision } : {}),
66
+ progress_callback: emitDownloadEvent,
67
+ });
19
68
  const loadOpts = {
20
- dtype: 'q8',
69
+ dtype: adapter.dtype,
21
70
  progress_callback: emitDownloadEvent,
71
+ ...(adapter.revision ? { revision: adapter.revision } : {}),
22
72
  ...(sessionOptions ? { session_options: sessionOptions } : {}),
23
73
  };
24
- const model = await AutoModelForSequenceClassification.from_pretrained(RERANK_MODEL, loadOpts);
74
+ const model = await AutoModelForSequenceClassification.from_pretrained(adapter.id, loadOpts);
25
75
  const r = { tokenizer, model };
26
76
  _rerankers.set(key, r);
27
77
  return r;
@@ -31,10 +81,10 @@ export async function getReranker(cacheDir, offline = false, retryOpts = {}) {
31
81
  }
32
82
  return await retryWithBackoff(loadPipeline, retryOpts);
33
83
  }
34
- export async function rerankScores(query, passages, cacheDir, offline = false) {
84
+ export async function rerankScores(query, passages, cacheDir, offline = false, rerankerName) {
35
85
  if (passages.length === 0)
36
86
  return [];
37
- const { tokenizer, model } = await getReranker(cacheDir, offline);
87
+ const { tokenizer, model } = await getReranker(cacheDir, offline, {}, rerankerName);
38
88
  const queries = new Array(passages.length).fill(query);
39
89
  const inputs = tokenizer(queries, { text_pair: passages, padding: true, truncation: true });
40
90
  const outputs = await model(inputs);
package/dist/search.d.ts CHANGED
@@ -2,6 +2,7 @@ import { ModelDescriptor } from './core.js';
2
2
  import { LexicalIndex } from './lexical.js';
3
3
  import { DocumentMetadata } from './frontmatter.js';
4
4
  import { IVFIndex } from './ivf.js';
5
+ import { HnswIndex } from './hnsw.js';
5
6
  import { RelationshipGraph } from './wikilinks.js';
6
7
  import { FilterNode } from './filter.js';
7
8
  export { tokenize } from './lexical.js';
@@ -13,6 +14,17 @@ export declare function rrf(rankings: Array<Array<{
13
14
  idx: number;
14
15
  score: number;
15
16
  }>>, k?: number, weights?: number[]): Map<number, number>;
17
+ /**
18
+ * issue #191: bounded top-k selection over a dense sweep — a capped min-heap
19
+ * instead of materializing one object per chunk and fully sorting. Ties break
20
+ * toward the smaller idx, which is exactly what a stable sort by
21
+ * (score desc) over idx-ordered input produces, so the selected pool and its
22
+ * order are identical to `[...items].sort((a, b) => b.score - a.score).slice(0, k)`.
23
+ */
24
+ export declare function topKByScore(n: number, scoreAt: (i: number) => number, k: number): Array<{
25
+ idx: number;
26
+ score: number;
27
+ }>;
16
28
  export interface IndexChunk {
17
29
  file: string;
18
30
  title: string;
@@ -74,9 +86,18 @@ export interface RuntimeIndexState {
74
86
  model: ModelDescriptor;
75
87
  queryCache: QueryEmbeddingCache;
76
88
  ivf?: IVFIndex;
89
+ /** issue #201: optional HNSW graph sidecar (hnsw.json), parallel to ivf. */
90
+ hnsw?: HnswIndex;
77
91
  graph?: RelationshipGraph;
78
92
  pageRank?: Map<string, number>;
93
+ /**
94
+ * issue #190: precomputed sqrt(sum(c^2)) for int8 vectors (zero-copy binary
95
+ * load only) — lets the dense sweep compute the dot product alone.
96
+ */
97
+ chunkNorms?: Float32Array;
79
98
  }
99
+ /** issue #190 tool/test seam: the cached int8 sweep norms, when present. */
100
+ export declare function getRuntimeChunkNorms(index: IndexFile): Float32Array | undefined;
80
101
  export declare function getRuntimeGraph(runtime: RuntimeIndexState): {
81
102
  graph: RelationshipGraph;
82
103
  pageRank: Map<string, number>;
@@ -84,7 +105,9 @@ export declare function getRuntimeGraph(runtime: RuntimeIndexState): {
84
105
  /**
85
106
  * Issue #127 pure helpers — exported for deterministic unit tests.
86
107
  */
87
- /** Frontmatter `created` → `updated` → file mtime; null when nothing parses. */
108
+ /** Frontmatter `updated` → `created` → file mtime; null when nothing parses.
109
+ * Issue #164: `updated` wins — long-lived notes that were recently revised
110
+ * must not be penalized by their original creation date. */
88
111
  export declare function resolvePassageAgeMs(meta: {
89
112
  created?: unknown;
90
113
  updated?: unknown;
@@ -97,13 +120,23 @@ export declare function recencyBoost(ageMs: number | null, halfLifeDays: number,
97
120
  * Injectable fetch for tests.
98
121
  */
99
122
  export declare function hydePassage(endpoint: string, query: string, model?: string, fetchImpl?: typeof fetch): Promise<string | null>;
123
+ /**
124
+ * issue #203: LLM paraphrases for multi-query expansion. ONE OpenAI-compatible
125
+ * chat completion asking for N alternative phrasings; returns clean, deduped,
126
+ * non-empty variants (the original query excluded). Returns [] on ANY failure —
127
+ * multi-query must degrade to the single-query baseline silently, exactly like
128
+ * HyDE. Injectable fetch for tests.
129
+ */
130
+ export declare function paraphraseQueries(endpoint: string, query: string, count: number, model?: string, fetchImpl?: typeof fetch): Promise<string[]>;
100
131
  /**
101
132
  * Salient-term extraction for PRF (issue #125): tokens that appear in the
102
133
  * top-m feedback passages, are not query terms or stopwords, and are ranked
103
134
  * by (tf-in-feedback × distinct-passage coverage). Pure + deterministic.
104
135
  */
105
136
  export declare function extractExpansionTerms(topTexts: string[], queryTerms: string[], maxTerms: number): string[];
106
- export declare function loadIndex(indexDir: string): {
137
+ export declare function loadIndex(indexDir: string, opts?: {
138
+ checkStale?: boolean;
139
+ }): {
107
140
  index: IndexFile;
108
141
  model: ModelDescriptor;
109
142
  };
@@ -147,8 +180,20 @@ export interface SearchOptions {
147
180
  llmEndpoint?: string;
148
181
  /** issue #125: model name for the HyDE chat call. */
149
182
  llmModel?: string;
150
- ann?: boolean;
183
+ /**
184
+ * issue #203: multi-query expansion — N total query variants (original +
185
+ * N-1 LLM paraphrases), each embedded, rankings fused with union-RRF.
186
+ * Requires `llmEndpoint`; without one (or on any LLM failure) it degrades
187
+ * to the single-query baseline. 0/1/undefined = off.
188
+ */
189
+ multiQuery?: number;
190
+ /** issue #203: reranker registry alias (`bge-reranker-base` default, `gte-reranker-modernbert`, ...). */
191
+ rerankModel?: string;
192
+ /** issue #201: ANN engine — `true` = default engine, or explicit 'ivf' | 'hnsw'. */
193
+ ann?: boolean | 'ivf' | 'hnsw';
151
194
  nprobe?: number;
195
+ /** issue #201: HNSW ef-search breadth (default 64). */
196
+ ef?: number;
152
197
  graphBoost?: number;
153
198
  filter?: string | FilterNode;
154
199
  dimensions?: number;