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.
- package/README.md +88 -14
- package/RESEARCH.md +182 -0
- package/bin/cli.mjs +197 -8
- package/dist/binary-format.js +9 -0
- package/dist/completions.js +5 -0
- package/dist/core.d.ts +16 -4
- package/dist/core.js +105 -14
- package/dist/federation.d.ts +16 -1
- package/dist/federation.js +33 -16
- package/dist/filter.d.ts +7 -0
- package/dist/filter.js +13 -0
- package/dist/hnsw.d.ts +96 -0
- package/dist/hnsw.js +326 -0
- package/dist/indexer.d.ts +17 -1
- package/dist/indexer.js +91 -17
- package/dist/lexical.d.ts +10 -0
- package/dist/lexical.js +98 -15
- package/dist/markdown-parser.d.ts +62 -0
- package/dist/markdown-parser.js +227 -30
- package/dist/mcp.d.ts +86 -3
- package/dist/mcp.js +259 -31
- package/dist/models.d.ts +7 -1
- package/dist/models.js +32 -1
- package/dist/providers.d.ts +56 -0
- package/dist/providers.js +305 -0
- package/dist/quantization.d.ts +12 -0
- package/dist/quantization.js +54 -0
- package/dist/rag.d.ts +60 -0
- package/dist/rag.js +133 -35
- package/dist/repair.d.ts +1 -1
- package/dist/repair.js +53 -2
- package/dist/rerank.d.ts +32 -3
- package/dist/rerank.js +58 -8
- package/dist/search.d.ts +48 -3
- package/dist/search.js +384 -78
- package/dist/serve.d.ts +35 -0
- package/dist/serve.js +478 -58
- package/dist/tui.d.ts +6 -0
- package/dist/tui.js +52 -2
- package/dist/watcher.js +23 -12
- package/dist/webui.d.ts +3 -3
- package/dist/webui.js +201 -2
- package/package.json +3 -3
- package/src/binary-format.ts +13 -0
- package/src/completions.ts +5 -0
- package/src/core.ts +115 -12
- package/src/federation.ts +51 -17
- package/src/filter.ts +14 -0
- package/src/hnsw.ts +414 -0
- package/src/indexer.ts +107 -18
- package/src/lexical.ts +91 -13
- package/src/markdown-parser.ts +279 -37
- package/src/mcp.ts +323 -34
- package/src/models.ts +39 -2
- package/src/providers.ts +345 -0
- package/src/quantization.ts +61 -0
- package/src/rag.ts +189 -34
- package/src/repair.ts +51 -2
- package/src/rerank.ts +81 -8
- package/src/search.ts +417 -82
- package/src/serve.ts +499 -66
- package/src/tui.ts +56 -2
- package/src/watcher.ts +21 -10
- 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
|
|
143
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
const
|
|
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
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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 (
|
|
171
|
-
|
|
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
|
-
//
|
|
263
|
+
// Fall through to extractive.
|
|
179
264
|
}
|
|
180
265
|
}
|
|
181
266
|
const answer = extractAnswerFallback(query, passages);
|
|
182
|
-
return
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
7
|
-
|
|
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 = `${
|
|
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(
|
|
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:
|
|
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(
|
|
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 `
|
|
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
|
-
|
|
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;
|