ko-qmd 2.8.3-ko.6 → 2.8.3-ko.7

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/CHANGELOG.md CHANGED
@@ -2,6 +2,52 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [2.8.3-ko.7] - 2026-10-02
6
+
7
+ The query path is now observable and bounded. Every query-log row breaks its latency into
8
+ stages, the daemon logs its heap once an hour, and expansion and rerank stop waiting past a
9
+ deadline instead of holding a query for 85 s. The reranker also sees a note's first chunk by default.
10
+
11
+ - `qmd mcp` (stdio and HTTP) no longer waits on a slow LLM stage without bound. Past
12
+ `QMD_EXPAND_DEADLINE_MS` (default 5 s) a query searches its original text alone, and past
13
+ `QMD_RERANK_DEADLINE_MS` (default 10 s) it answers in RRF order; `0` restores waiting. The clock
14
+ starts once the model is loaded, so a cold start or a reload after the idle unload is not a timeout;
15
+ the query log records that wait as a `load` stage. One MCP `query` with expansion and rerank took
16
+ 85 s. The REST `/query` response and the MCP `query` tool say which stage timed out (`timeouts`),
17
+ the query log row lists it the same way, and a timed-out rerank reads `rerank: "timeout"` there.
18
+ A fallback does not apply `minScore` to its 1/rank scores. An abandoned expansion or rerank keeps
19
+ running and fills its cache; until it finishes, later queries skip that stage (logged once) instead
20
+ of queueing behind it. The CLI search commands and the SDK (`rerankDeadlineMs`, `expandDeadlineMs`
21
+ options) never set a deadline on their own.
22
+
23
+ - The daemon's query log row now carries `stages`, the milliseconds spent in each stage of the
24
+ search: `refresh` (the pre-query re-index check), `expand`, `fts`, `embed`, `vec`, `chunk`,
25
+ `rerank` and `serialize`; a stage that did not run is absent. REST `rerank:false` queries had a p95 of 2.9–5.3 s
26
+ against a p50 of 0.2–0.5 s with no rerank in the path, and the single `ms` could not say which
27
+ stage the tail came from. Daemon log lines now start with a dated local ISO timestamp instead of
28
+ a UTC `HH:mm:ss.SSS`, so a slow line can be matched to its row.
29
+
30
+ - The HTTP daemon logs a `health` line every hour: heap used and total, rss, and how many
31
+ `subscriptions/listen` and other `/mcp` requests have a handler still waiting on its response. A daemon that ran ten days died with
32
+ "Reached heap limit" after its heap crept to 3.8 GB under idle discover/cancelled traffic, and
33
+ nothing in the log showed the slope or what was holding memory. `QMD_HEALTH_LOG_INTERVAL_MS`
34
+ changes the interval; `0` turns it off.
35
+
36
+ - `scripts/autoresearch/prepare.sh` builds the goldset from more than the collection folder and over
37
+ several runs. `AR_EXTRA_DIRS="outputs docs"` copies sibling folders of the collection into the corpus
38
+ (names only, so no vault path lands in git), `AR_RUN=N` writes `generated-N.json` from documents no
39
+ earlier run answered (`bench-vault-paraphrase.ts --dirs`, `--skip`), and `split` merges every run,
40
+ drops the query ids listed in `dropped.txt`, and splits only each run's new documents, so an earlier
41
+ train/held-out assignment never moves.
42
+
43
+ - The reranker now gets a note's first chunk, its title and summary: the first chunk starts 16
44
+ points ahead in chunk selection (8 when an intent is given, so intent words can still pick a later
45
+ chunk). A later chunk needs 17 more points, about nine English words or seventeen Hangul syllable
46
+ pairs, so for a typical short query the first chunk is always sent. A second autoresearch loop found
47
+ this on a goldset of 320 train and 71 held-out queries drawn from four vault folders. Held-out full
48
+ MRR goes 0.7291 → 0.7459, ko-vault seam and hard lines are unchanged, and bm25, vector and hybrid
49
+ results are identical (`BASELINE.md` 2026-09-29).
50
+
5
51
  ## [2.8.3-ko.6] - 2026-09-29
6
52
 
7
53
  Korean queries are handled better before and during reranking. A query written
@@ -1,4 +1,4 @@
1
1
  {
2
- "commit": "c544042",
3
- "builtAt": "2026-09-29T00:59:00.970Z"
2
+ "commit": "ed12243",
3
+ "builtAt": "2026-10-02T04:28:17.608Z"
4
4
  }
package/dist/index.d.ts CHANGED
@@ -90,6 +90,12 @@ export interface SearchOptions {
90
90
  * ANDed with `filter`.
91
91
  */
92
92
  scope?: DocumentFilter;
93
+ /** Past this many ms, answer in RRF order instead of waiting for the reranker (unset or 0 = wait) */
94
+ rerankDeadlineMs?: number;
95
+ /** Past this many ms, search the original query only instead of waiting for expansion (unset or 0 = wait) */
96
+ expandDeadlineMs?: number;
97
+ /** Progress callbacks for expansion, embedding and reranking */
98
+ hooks?: SearchHooks;
93
99
  }
94
100
  /**
95
101
  * Options for searchLex() — BM25 keyword search.
package/dist/index.js CHANGED
@@ -122,6 +122,8 @@ export async function createStore(options) {
122
122
  skipRerank,
123
123
  chunkStrategy: opts.chunkStrategy,
124
124
  scope: opts.scope,
125
+ rerankDeadlineMs: opts.rerankDeadlineMs,
126
+ hooks: opts.hooks,
125
127
  });
126
128
  }
127
129
  // Simple query string — use hybridQuery (expand + search + rerank)
@@ -137,6 +139,9 @@ export async function createStore(options) {
137
139
  skipRerank,
138
140
  chunkStrategy: opts.chunkStrategy,
139
141
  scope: opts.scope,
142
+ rerankDeadlineMs: opts.rerankDeadlineMs,
143
+ expandDeadlineMs: opts.expandDeadlineMs,
144
+ hooks: opts.hooks,
140
145
  });
141
146
  },
142
147
  searchLex: async (q, opts) => {
package/dist/llm.d.ts CHANGED
@@ -122,6 +122,8 @@ export type RerankOptions = {
122
122
  model?: string;
123
123
  /** Per-call doc token cap; overrides QMD_RERANK_MAX_DOC_TOKENS, never raises the context budget. */
124
124
  maxDocTokens?: number;
125
+ /** Called once the rerank model and contexts are loaded, before any scoring (ko-qmd: deadlines start here). */
126
+ onModelReady?: () => void;
125
127
  };
126
128
  /**
127
129
  * Options for LLM sessions
@@ -143,6 +145,7 @@ export interface ILLMSession {
143
145
  expandQuery(query: string, options?: {
144
146
  context?: string;
145
147
  includeLexical?: boolean;
148
+ onModelReady?: () => void;
146
149
  }): Promise<Queryable[]>;
147
150
  rerank(query: string, documents: RerankDocument[], options?: RerankOptions): Promise<RerankResult>;
148
151
  /** Whether this session is still valid (not released or aborted) */
@@ -257,6 +260,7 @@ export interface LLM {
257
260
  expandQuery(query: string, options?: {
258
261
  context?: string;
259
262
  includeLexical?: boolean;
263
+ onModelReady?: () => void;
260
264
  }): Promise<Queryable[]>;
261
265
  /**
262
266
  * Rerank documents by relevance to a query
@@ -483,6 +487,7 @@ export declare class LlamaCpp implements LLM {
483
487
  expandQuery(query: string, options?: {
484
488
  context?: string;
485
489
  includeLexical?: boolean;
490
+ onModelReady?: () => void;
486
491
  }): Promise<Queryable[]>;
487
492
  private static readonly RERANK_TEMPLATE_OVERHEAD;
488
493
  private static readonly RERANK_TARGET_DOCS_PER_CONTEXT;
package/dist/llm.js CHANGED
@@ -1320,6 +1320,7 @@ export class LlamaCpp {
1320
1320
  this.touchActivity();
1321
1321
  const llama = await this.ensureLlama();
1322
1322
  await this.ensureGenerateModel();
1323
+ options.onModelReady?.();
1323
1324
  const includeLexical = options.includeLexical ?? true;
1324
1325
  const context = options.context;
1325
1326
  // The expansion prompt consumes ONLY the query text. Caller intent is
@@ -1399,6 +1400,7 @@ export class LlamaCpp {
1399
1400
  // Ping activity at start to keep models alive during this operation
1400
1401
  this.touchActivity();
1401
1402
  const contexts = await this.ensureRerankContexts();
1403
+ options.onModelReady?.();
1402
1404
  if (contexts.length === 0) {
1403
1405
  return {
1404
1406
  results: documents.map((d) => ({ ...d, score: 0.5, index: 0 })),
@@ -0,0 +1,17 @@
1
+ /**
2
+ * ko-qmd: periodic health line for the HTTP daemon's log.
3
+ *
4
+ * One daemon ran ten days and died with "Reached heap limit" after its heap
5
+ * crept to 3.8 GB under nothing but idle discover/cancelled traffic. A line an
6
+ * hour of heap, rss and open `subscriptions/listen` streams gives the slope of
7
+ * that growth and whether it tracks the listen count.
8
+ */
9
+ export type DaemonRequestCounts = {
10
+ /** `subscriptions/listen` requests whose handler is still awaiting the response body — not open sockets */
11
+ listens: number;
12
+ /** the same for every /mcp request, listens included */
13
+ inflight: number;
14
+ };
15
+ export declare function formatDaemonHealthLine(memory: Pick<NodeJS.MemoryUsage, "heapUsed" | "heapTotal" | "rss">, counts: DaemonRequestCounts, uptimeMs: number): string;
16
+ /** QMD_HEALTH_LOG_INTERVAL_MS: 0 = off, a positive integer up to 2^31-1 = that many ms, anything else = one hour. */
17
+ export declare function resolveHealthLogIntervalMs(envValue?: string | undefined): number;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * ko-qmd: periodic health line for the HTTP daemon's log.
3
+ *
4
+ * One daemon ran ten days and died with "Reached heap limit" after its heap
5
+ * crept to 3.8 GB under nothing but idle discover/cancelled traffic. A line an
6
+ * hour of heap, rss and open `subscriptions/listen` streams gives the slope of
7
+ * that growth and whether it tracks the listen count.
8
+ */
9
+ const MB = 2 ** 20;
10
+ const DEFAULT_INTERVAL_MS = 3_600_000;
11
+ export function formatDaemonHealthLine(memory, counts, uptimeMs) {
12
+ const mb = (bytes) => `${Math.round(bytes / MB)}MB`;
13
+ return `health heap_used=${mb(memory.heapUsed)} heap_total=${mb(memory.heapTotal)} rss=${mb(memory.rss)}`
14
+ + ` listens=${counts.listens} mcp_inflight=${counts.inflight} uptime=${(uptimeMs / 3_600_000).toFixed(1)}h`;
15
+ }
16
+ /** Node clamps a longer setInterval delay to 1 ms, which would flood the log. */
17
+ const MAX_TIMER_MS = 2 ** 31 - 1;
18
+ /** QMD_HEALTH_LOG_INTERVAL_MS: 0 = off, a positive integer up to 2^31-1 = that many ms, anything else = one hour. */
19
+ export function resolveHealthLogIntervalMs(envValue = process.env.QMD_HEALTH_LOG_INTERVAL_MS) {
20
+ if (envValue === undefined || !/^\d+$/.test(envValue))
21
+ return DEFAULT_INTERVAL_MS;
22
+ const ms = Number(envValue);
23
+ return ms <= MAX_TIMER_MS ? ms : DEFAULT_INTERVAL_MS;
24
+ }
@@ -7,6 +7,7 @@
7
7
  * Speaks MCP spec 2026-07-28 (stateless, no initialize handshake) and dual-speaks
8
8
  * 2025-era clients via the official SDK entries (`serveStdio` / `createMcpHandler`).
9
9
  */
10
+ export declare function resolveDeadlineMs(envValue: string | undefined, fallback: number): number;
10
11
  export type McpStartupOptions = {
11
12
  dbPath?: string;
12
13
  };
@@ -19,7 +19,8 @@ import { createStore, extractSnippet, addLineNumbers, getDefaultDbPath, DEFAULT_
19
19
  import { getConfigPath } from "../collections.js";
20
20
  import { enableProductionMode } from "../store.js";
21
21
  import { checkRequestOrigin, resolveOriginGuard } from "./origin-guard.js";
22
- import { entryFromMcp, entryFromRest, logQuery, queryLogStatus } from "../query-log.js";
22
+ import { entryFromMcp, entryFromRest, isoWithOffset, logQuery, queryLogStatus } from "../query-log.js";
23
+ import { formatDaemonHealthLine, resolveHealthLogIntervalMs } from "./daemon-health.js";
23
24
  import { createStoreIndexRefresher } from "../refresh.js";
24
25
  import { buildDocumentFilter, describeFilter } from "../filters.js";
25
26
  import { countFilteredDocuments } from "../store.js";
@@ -95,6 +96,63 @@ function getPackageVersion() {
95
96
  * its own last pass are what stop every query from walking the collection again.
96
97
  */
97
98
  const refreshers = new WeakMap();
99
+ /**
100
+ * ko-qmd: per-stage milliseconds for the query log. The hooks fill `stages` as
101
+ * the search runs; the caller adds `refresh` and `serialize` around it. A stage
102
+ * past its deadline lands in `timeouts`, with the ms it was waited on.
103
+ */
104
+ function stageTimer() {
105
+ const stages = {};
106
+ const timeouts = [];
107
+ return {
108
+ stages,
109
+ timeouts,
110
+ hooks: {
111
+ // A stage skipped outright (an abandoned call still running) gets no ms: absent, never 0.
112
+ onDeadline: (stage, ms) => { if (ms !== undefined)
113
+ stages[stage] = ms; timeouts.push(stage); },
114
+ onModelLoad: (_stage, ms) => { stages.load = (stages.load ?? 0) + ms; },
115
+ // An empty list means expansion was skipped (pre-typed searches, Hangul, strong BM25 signal)
116
+ // or the model returned nothing; either way there are no expanded queries to account for.
117
+ onExpand: (_query, expanded, ms) => { if (expanded.length > 0)
118
+ stages.expand = ms; },
119
+ onFtsDone: (ms) => { stages.fts = ms; },
120
+ onEmbedDone: (ms) => { stages.embed = ms; },
121
+ onVecDone: (ms) => { stages.vec = ms; },
122
+ onChunkDone: (ms) => { stages.chunk = ms; },
123
+ onRerankDone: (ms) => { stages.rerank = ms; },
124
+ },
125
+ };
126
+ }
127
+ /**
128
+ * ko-qmd: how long the daemon waits for query expansion and for the reranker before answering
129
+ * without them. QMD_EXPAND_DEADLINE_MS / QMD_RERANK_DEADLINE_MS: 0 = wait, a positive integer =
130
+ * that many ms, anything else = the default. One MCP query once took 85 s.
131
+ */
132
+ const DEFAULT_EXPAND_DEADLINE_MS = 5_000;
133
+ const DEFAULT_RERANK_DEADLINE_MS = 10_000;
134
+ export function resolveDeadlineMs(envValue, fallback) {
135
+ return envValue !== undefined && /^\d+$/.test(envValue) ? Number(envValue) : fallback;
136
+ }
137
+ function searchDeadlines() {
138
+ return {
139
+ expandDeadlineMs: resolveDeadlineMs(process.env.QMD_EXPAND_DEADLINE_MS, DEFAULT_EXPAND_DEADLINE_MS),
140
+ rerankDeadlineMs: resolveDeadlineMs(process.env.QMD_RERANK_DEADLINE_MS, DEFAULT_RERANK_DEADLINE_MS),
141
+ };
142
+ }
143
+ /** `timeouts` for a search response — absent when every stage finished in time. */
144
+ function timeoutField(timeouts) {
145
+ return timeouts.length > 0 ? { timeouts } : {};
146
+ }
147
+ /** A line telling an agent its results were not reranked or not expanded, so it does not read scores as confident. */
148
+ function describeTimeouts(timeouts) {
149
+ if (timeouts.length === 0)
150
+ return "";
151
+ const parts = timeouts.map(t => t === "rerank"
152
+ ? "the reranker timed out, so results are in retrieval order and scores are 1/rank"
153
+ : "query expansion timed out, so only the original query was searched");
154
+ return `\n\nNote: ${parts.join("; ")}.`;
155
+ }
98
156
  /**
99
157
  * Bring the text index up to date for the collections a query is about to search. Never
100
158
  * throws — a failed refresh is logged and the query runs against the index as it was.
@@ -398,7 +456,10 @@ Intent-aware lex (C++ performance, not sports):
398
456
  const searchOptions = query
399
457
  ? { query }
400
458
  : { queries: (searches ?? []).map(s => ({ type: s.type, query: s.query })) };
459
+ const timer = stageTimer();
460
+ const refreshStart = Date.now();
401
461
  await refreshBeforeQuery(store, effectiveCollections);
462
+ timer.stages.refresh = Date.now() - refreshStart;
402
463
  const results = await store.search({
403
464
  ...searchOptions,
404
465
  collections: effectiveCollections.length > 0 ? effectiveCollections : undefined,
@@ -411,7 +472,10 @@ Intent-aware lex (C++ performance, not sports):
411
472
  explain: rerank === false,
412
473
  intent,
413
474
  scope,
475
+ ...searchDeadlines(),
476
+ hooks: timer.hooks,
414
477
  });
478
+ const serializeStart = Date.now();
415
479
  // Use the plain query, or the first lex/vec sub-query, for snippet extraction
416
480
  const primaryQuery = query
417
481
  || searches?.find(s => s.type === 'lex')?.query
@@ -432,7 +496,9 @@ Intent-aware lex (C++ performance, not sports):
432
496
  snippet: addLineNumbers(snippet, line),
433
497
  };
434
498
  });
435
- // ko-qmd: opt-in query log (QMD_QUERY_LOG), HTTP transport only.
499
+ // ko-qmd: opt-in query log (QMD_QUERY_LOG), HTTP transport only. `serialize` here is snippet
500
+ // extraction only — the SDK writes the response after this handler returns.
501
+ timer.stages.serialize = Date.now() - serializeStart;
436
502
  if (httpHeaders) {
437
503
  logQuery(entryFromMcp({
438
504
  headers: httpHeaders,
@@ -443,6 +509,8 @@ Intent-aware lex (C++ performance, not sports):
443
509
  rerank,
444
510
  results: filtered,
445
511
  ms: Date.now() - startedAt,
512
+ stages: timer.stages,
513
+ timeouts: timer.timeouts,
446
514
  }), store);
447
515
  }
448
516
  return {
@@ -450,9 +518,9 @@ Intent-aware lex (C++ performance, not sports):
450
518
  type: "text",
451
519
  text: filtered.length === 0 && scope
452
520
  ? describeEmptyFilteredSearch(store, scope, effectiveCollections)
453
- : formatSearchSummary(filtered, primaryQuery),
521
+ : formatSearchSummary(filtered, primaryQuery) + describeTimeouts(timer.timeouts),
454
522
  }],
455
- structuredContent: { results: filtered },
523
+ structuredContent: { results: filtered, ...timeoutField(timer.timeouts) },
456
524
  };
457
525
  }));
458
526
  // ---------------------------------------------------------------------------
@@ -894,7 +962,7 @@ export async function startMcpHttpServer(port, options = {}) {
894
962
  const quiet = options?.quiet ?? false;
895
963
  /** Format timestamp for request logging */
896
964
  function ts() {
897
- return new Date().toISOString().slice(11, 23); // HH:mm:ss.SSS
965
+ return isoWithOffset(new Date()); // ko-qmd: dated local time, so a log line can be matched to a query-log row
898
966
  }
899
967
  /** Extract a human-readable label from a JSON-RPC body */
900
968
  function describeRequest(body) {
@@ -921,6 +989,13 @@ export async function startMcpHttpServer(port, options = {}) {
921
989
  if (!quiet)
922
990
  console.error(msg);
923
991
  }
992
+ // ko-qmd: /mcp requests whose response has not ended — a `subscriptions/listen` stays open for
993
+ // as long as its client keeps it, so these are what the hourly health line counts.
994
+ const openMcp = { listens: 0, inflight: 0 };
995
+ const healthIntervalMs = resolveHealthLogIntervalMs();
996
+ const healthTimer = healthIntervalMs > 0
997
+ ? setInterval(() => log(`${ts()} ${formatDaemonHealthLine(process.memoryUsage(), openMcp, Date.now() - startTime)}`), healthIntervalMs).unref()
998
+ : null;
924
999
  function nodeHeadersToWeb(nodeReq) {
925
1000
  const headers = new Headers();
926
1001
  for (const [k, v] of Object.entries(nodeReq.headers)) {
@@ -1037,7 +1112,10 @@ export async function startMcpHttpServer(port, options = {}) {
1037
1112
  nodeRes.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
1038
1113
  return;
1039
1114
  }
1115
+ const timer = stageTimer();
1116
+ const refreshStart = Date.now();
1040
1117
  await refreshBeforeQuery(store, effectiveCollections);
1118
+ timer.stages.refresh = Date.now() - refreshStart;
1041
1119
  const rerank = typeof params.rerank === "boolean" ? params.rerank : undefined;
1042
1120
  const results = await store.search({
1043
1121
  queries,
@@ -1052,7 +1130,10 @@ export async function startMcpHttpServer(port, options = {}) {
1052
1130
  // rerank:false scores are 1/rank; explain carries the raw backend scores.
1053
1131
  explain: rerank === false,
1054
1132
  scope: restScope,
1133
+ ...searchDeadlines(),
1134
+ hooks: timer.hooks,
1055
1135
  });
1136
+ const serializeStart = Date.now();
1056
1137
  // Use first lex or vec query for snippet extraction
1057
1138
  const primaryQuery = searches.find((s) => s.type === 'lex')?.query
1058
1139
  || searches.find((s) => s.type === 'vec')?.query
@@ -1072,9 +1153,10 @@ export async function startMcpHttpServer(port, options = {}) {
1072
1153
  };
1073
1154
  });
1074
1155
  nodeRes.writeHead(200, { "Content-Type": "application/json" });
1075
- nodeRes.end(JSON.stringify({ results: formatted }));
1156
+ nodeRes.end(JSON.stringify({ results: formatted, ...timeoutField(timer.timeouts) }));
1157
+ timer.stages.serialize = Date.now() - serializeStart;
1076
1158
  // ko-qmd: opt-in query log (QMD_QUERY_LOG), written after the response.
1077
- logQuery(entryFromRest({ path: pathname, headers: nodeReq.headers, searches: queries, params, collections: effectiveCollections, results: formatted, ms: Date.now() - reqStart }), store);
1159
+ logQuery(entryFromRest({ path: pathname, headers: nodeReq.headers, searches: queries, params, collections: effectiveCollections, results: formatted, ms: Date.now() - reqStart, stages: timer.stages, timeouts: timer.timeouts }), store);
1078
1160
  log(`${ts()} POST /query ${params.searches.length} queries (${Date.now() - reqStart}ms)`);
1079
1161
  return;
1080
1162
  }
@@ -1101,9 +1183,20 @@ export async function startMcpHttpServer(port, options = {}) {
1101
1183
  headers: nodeHeadersToWeb(nodeReq),
1102
1184
  ...(rawBody !== undefined ? { body: rawBody } : {}),
1103
1185
  });
1104
- const response = await mcpHandler.fetch(request, parsedBody !== undefined ? { parsedBody } : undefined);
1105
- nodeRes.writeHead(response.status, Object.fromEntries(response.headers));
1106
- nodeRes.end(Buffer.from(await response.arrayBuffer()));
1186
+ const isListen = label === "subscriptions/listen";
1187
+ openMcp.inflight++;
1188
+ if (isListen)
1189
+ openMcp.listens++;
1190
+ try {
1191
+ const response = await mcpHandler.fetch(request, parsedBody !== undefined ? { parsedBody } : undefined);
1192
+ nodeRes.writeHead(response.status, Object.fromEntries(response.headers));
1193
+ nodeRes.end(Buffer.from(await response.arrayBuffer()));
1194
+ }
1195
+ finally {
1196
+ openMcp.inflight--;
1197
+ if (isListen)
1198
+ openMcp.listens--;
1199
+ }
1107
1200
  log(`${ts()} ${nodeReq.method} /mcp ${label} (${Date.now() - reqStart}ms)`);
1108
1201
  return;
1109
1202
  }
@@ -1126,6 +1219,8 @@ export async function startMcpHttpServer(port, options = {}) {
1126
1219
  if (stopping)
1127
1220
  return;
1128
1221
  stopping = true;
1222
+ if (healthTimer)
1223
+ clearInterval(healthTimer);
1129
1224
  await mcpHandler.close();
1130
1225
  httpServer.close();
1131
1226
  await store.close();
@@ -36,6 +36,27 @@ export type QueryLogResult = {
36
36
  vec_score?: number;
37
37
  fts_score?: number;
38
38
  };
39
+ /**
40
+ * Milliseconds each stage of one search took. A stage that did not run is
41
+ * absent, never 0: `refresh` is the pre-query re-index check, `expand` the LLM
42
+ * query expansion, `fts` the BM25 lookups, `embed` the query embeddings, `vec`
43
+ * the sqlite-vec lookups, `chunk` splitting candidates to pick each one's best
44
+ * chunk, `rerank` the LLM reranker, and `serialize` snippet extraction (plus
45
+ * writing the response on REST). RRF fusion and metadata lookups are not a
46
+ * stage, so the stages need not sum to `ms`.
47
+ */
48
+ export type QueryLogTimeout = "expand" | "rerank";
49
+ export type QueryLogStages = {
50
+ refresh?: number;
51
+ load?: number;
52
+ expand?: number;
53
+ fts?: number;
54
+ embed?: number;
55
+ vec?: number;
56
+ chunk?: number;
57
+ rerank?: number;
58
+ serialize?: number;
59
+ };
39
60
  export type QueryLogEntry = {
40
61
  via: "rest" | "mcp";
41
62
  tool: string;
@@ -50,6 +71,9 @@ export type QueryLogEntry = {
50
71
  rerank: boolean | null;
51
72
  results: QueryLogResult[];
52
73
  ms: number;
74
+ stages?: QueryLogStages;
75
+ /** Stages that ran past their deadline; the search went on without them */
76
+ timeouts?: QueryLogTimeout[];
53
77
  /** Injectable clock for tests; defaults to the time of the call. */
54
78
  now?: Date;
55
79
  };
@@ -61,6 +85,8 @@ export type QueryLogStatus = {
61
85
  };
62
86
  /** Path of the log file for the local month containing `date`. */
63
87
  export declare const queryLogPath: (date?: Date) => string;
88
+ /** ISO-8601 in local time with the UTC offset, e.g. 2026-09-17T16:05:03.123+09:00. */
89
+ export declare const isoWithOffset: (date: Date) => string;
64
90
  /**
65
91
  * Queue one row. Returns immediately; the write happens after the response.
66
92
  * No-op unless the flag is on and the request did not ask to be skipped.
@@ -106,6 +132,8 @@ export declare const entryFromRest: (input: {
106
132
  collections: string[];
107
133
  results: QueryLogResult[];
108
134
  ms: number;
135
+ stages?: QueryLogStages;
136
+ timeouts?: QueryLogTimeout[];
109
137
  }) => QueryLogEntry;
110
138
  /** Map an MCP `query` tool call. Only the HTTP transport supplies headers. */
111
139
  export declare const entryFromMcp: (input: {
@@ -121,6 +149,8 @@ export declare const entryFromMcp: (input: {
121
149
  rerank: boolean;
122
150
  results: QueryLogResult[];
123
151
  ms: number;
152
+ stages?: QueryLogStages;
153
+ timeouts?: QueryLogTimeout[];
124
154
  }) => QueryLogEntry;
125
155
  export declare const _resetQueryLogForTesting: () => void;
126
156
  export {};
package/dist/query-log.js CHANGED
@@ -36,7 +36,7 @@ const pad2 = (n) => String(n).padStart(2, "0");
36
36
  /** Path of the log file for the local month containing `date`. */
37
37
  export const queryLogPath = (date = new Date()) => join(queryLogDir(), `queries-${date.getFullYear()}-${pad2(date.getMonth() + 1)}.jsonl`);
38
38
  /** ISO-8601 in local time with the UTC offset, e.g. 2026-09-17T16:05:03.123+09:00. */
39
- const isoWithOffset = (date) => {
39
+ export const isoWithOffset = (date) => {
40
40
  const offsetMin = -date.getTimezoneOffset();
41
41
  const sign = offsetMin >= 0 ? "+" : "-";
42
42
  const abs = Math.abs(offsetMin);
@@ -147,7 +147,8 @@ const queueRow = (entry, store) => {
147
147
  searches: entry.searches.map(({ type, query }) => ({ type, query })),
148
148
  collections: entry.collections,
149
149
  limit: entry.limit,
150
- rerank: entry.rerank,
150
+ // A reranker past its deadline answered nothing: the results are in RRF order.
151
+ rerank: entry.timeouts?.includes("rerank") ? "timeout" : entry.rerank,
151
152
  results: entry.results.map((r, i) => ({
152
153
  file: normalizeResultFile(r.file),
153
154
  score: r.score,
@@ -156,6 +157,8 @@ const queueRow = (entry, store) => {
156
157
  rank: i + 1,
157
158
  })),
158
159
  ms: entry.ms,
160
+ ...(entry.stages && { stages: entry.stages }),
161
+ ...(entry.timeouts && entry.timeouts.length > 0 && { timeouts: entry.timeouts }),
159
162
  client: {
160
163
  tag: clip(readHeader(entry.headers, "x-qmd-tag"), TAG_MAX),
161
164
  qid: clip(readHeader(entry.headers, "x-qmd-qid"), QID_MAX),
@@ -220,6 +223,8 @@ export const entryFromRest = (input) => ({
220
223
  rerank: typeof input.params.rerank === "boolean" ? input.params.rerank : null,
221
224
  results: input.results,
222
225
  ms: input.ms,
226
+ stages: input.stages,
227
+ timeouts: input.timeouts,
223
228
  });
224
229
  /** Map an MCP `query` tool call. Only the HTTP transport supplies headers. */
225
230
  export const entryFromMcp = (input) => ({
@@ -232,6 +237,8 @@ export const entryFromMcp = (input) => ({
232
237
  rerank: input.rerank,
233
238
  results: input.results,
234
239
  ms: input.ms,
240
+ stages: input.stages,
241
+ timeouts: input.timeouts,
235
242
  });
236
243
  export const _resetQueryLogForTesting = () => {
237
244
  writeChain = Promise.resolve();
@@ -22,9 +22,22 @@ export declare const INTENT_WEIGHT_CHUNK = 1;
22
22
  /** The reranker's query: intent, a blank line, then the query; the query alone without intent. */
23
23
  export declare const formatRerankQuery: (query: string, intent?: string) => string;
24
24
  /**
25
- * Index of the chunk to rerank: the one containing the most query words longer than two
26
- * characters with no Hangul (two points each) plus query Hangul syllable bigrams, each intent word adding one point (half a non-Hangul query word, as before bigrams).
27
- * Case-insensitive substring match; a tie keeps the earlier chunk, and no match picks the first.
25
+ * Head start of the first chunk (title, summary); a later chunk must out-score it by more than this,
26
+ * which a typical short query cannot do, so its first chunk is always sent.
27
+ */
28
+ export declare const FIRST_CHUNK_HEAD_START = 16;
29
+ /** The head start when intent is given: halved, so intent words can still steer selection to a later chunk. */
30
+ export declare const FIRST_CHUNK_HEAD_START_WITH_INTENT = 8;
31
+ /**
32
+ * Scorer for one chunk's text against `query`: two points per query word longer than two characters
33
+ * with no Hangul, one per query Hangul syllable bigram, and one per intent word (half a non-Hangul
34
+ * query word). Case-insensitive substring match.
35
+ */
36
+ export declare const rerankChunkScorer: (query: string, intentTerms: string[]) => ((text: string) => number);
37
+ /**
38
+ * Index of the chunk to rerank: the highest `rerankChunkScorer` score, with the first chunk starting
39
+ * FIRST_CHUNK_HEAD_START ahead (FIRST_CHUNK_HEAD_START_WITH_INTENT with intent), so the note's lead is
40
+ * sent unless a later chunk matches far more of the query. A tie keeps the earlier chunk.
28
41
  */
29
42
  export declare const selectRerankChunk: (chunks: RerankChunk[], query: string, intentTerms: string[]) => number;
30
43
  /** The document text sent for one candidate: the selected chunk, led by the note title when the chunk lacks it. */
@@ -16,26 +16,42 @@ export const formatRerankQuery = (query, intent) => intent ? `${intent}\n\n${que
16
16
  */
17
17
  const hangulBigrams = (text) => (text.match(/[가-힣]{3,}/g) ?? []).flatMap((run) => Array.from({ length: run.length - 1 }, (_, i) => run.slice(i, i + 2)));
18
18
  /**
19
- * Index of the chunk to rerank: the one containing the most query words longer than two
20
- * characters with no Hangul (two points each) plus query Hangul syllable bigrams, each intent word adding one point (half a non-Hangul query word, as before bigrams).
21
- * Case-insensitive substring match; a tie keeps the earlier chunk, and no match picks the first.
19
+ * Head start of the first chunk (title, summary); a later chunk must out-score it by more than this,
20
+ * which a typical short query cannot do, so its first chunk is always sent.
22
21
  */
23
- export const selectRerankChunk = (chunks, query, intentTerms) => {
22
+ export const FIRST_CHUNK_HEAD_START = 16;
23
+ /** The head start when intent is given: halved, so intent words can still steer selection to a later chunk. */
24
+ export const FIRST_CHUNK_HEAD_START_WITH_INTENT = 8;
25
+ /**
26
+ * Scorer for one chunk's text against `query`: two points per query word longer than two characters
27
+ * with no Hangul, one per query Hangul syllable bigram, and one per intent word (half a non-Hangul
28
+ * query word). Case-insensitive substring match.
29
+ */
30
+ export const rerankChunkScorer = (query, intentTerms) => {
24
31
  const queryLower = query.toLowerCase();
25
32
  const queryTerms = queryLower.split(/\s+/).filter((t) => t.length > 2 && !/[가-힣]/.test(t));
26
33
  const bigrams = hangulBigrams(queryLower);
34
+ return (text) => {
35
+ const lower = text.toLowerCase();
36
+ return queryTerms.reduce((acc, term) => acc + (lower.includes(term) ? 2 : 0), 0)
37
+ + bigrams.reduce((acc, bg) => acc + (lower.includes(bg) ? 1 : 0), 0)
38
+ + intentTerms.reduce((acc, term) => acc + (lower.includes(term) ? INTENT_WEIGHT_CHUNK : 0), 0);
39
+ };
40
+ };
41
+ /**
42
+ * Index of the chunk to rerank: the highest `rerankChunkScorer` score, with the first chunk starting
43
+ * FIRST_CHUNK_HEAD_START ahead (FIRST_CHUNK_HEAD_START_WITH_INTENT with intent), so the note's lead is
44
+ * sent unless a later chunk matches far more of the query. A tie keeps the earlier chunk.
45
+ */
46
+ export const selectRerankChunk = (chunks, query, intentTerms) => {
47
+ const score = rerankChunkScorer(query, intentTerms);
48
+ const headStart = intentTerms.length === 0 ? FIRST_CHUNK_HEAD_START : FIRST_CHUNK_HEAD_START_WITH_INTENT;
27
49
  let bestIdx = 0;
28
50
  let bestScore = -1;
29
51
  for (let i = 0; i < chunks.length; i++) {
30
- const chunkLower = chunks[i].text.toLowerCase();
31
- let score = queryTerms.reduce((acc, term) => acc + (chunkLower.includes(term) ? 2 : 0), 0);
32
- score += bigrams.reduce((acc, bg) => acc + (chunkLower.includes(bg) ? 1 : 0), 0);
33
- for (const term of intentTerms) {
34
- if (chunkLower.includes(term))
35
- score += INTENT_WEIGHT_CHUNK;
36
- }
37
- if (score > bestScore) {
38
- bestScore = score;
52
+ const s = score(chunks[i].text) + (i === 0 ? headStart : 0);
53
+ if (s > bestScore) {
54
+ bestScore = s;
39
55
  bestIdx = i;
40
56
  }
41
57
  }
package/dist/store.d.ts CHANGED
@@ -276,7 +276,10 @@ export type Store = {
276
276
  toVirtualPath: (absolutePath: string) => string | null;
277
277
  searchFTS: (query: string, limit?: number, collectionName?: string | readonly string[], filter?: MetadataFilter, scope?: DocumentFilter) => SearchResult[];
278
278
  searchVec: (query: string, model: string, limit?: number, collectionName?: string | readonly string[], session?: ILLMSession, precomputedEmbedding?: number[], filter?: MetadataFilter, scope?: DocumentFilter) => Promise<SearchResult[]>;
279
- expandQuery: (query: string, model?: string) => Promise<ExpandedQuery[]>;
279
+ /** `onModelReady` fires once the generation model is loaded (not at all on a cache hit). */
280
+ expandQuery: (query: string, model?: string, options?: {
281
+ onModelReady?: () => void;
282
+ }) => Promise<ExpandedQuery[]>;
280
283
  /** Drop the cached expansion for a query so the next call regenerates. */
281
284
  invalidateExpansionCache: (query: string) => void;
282
285
  rerank: (query: string, documents: {
@@ -886,7 +889,9 @@ export declare function clearAllEmbeddings(db: Database, collection?: string): v
886
889
  * vec0 virtual tables silently ignore the OR REPLACE conflict clause.
887
890
  */
888
891
  export declare function insertEmbedding(db: Database, hash: string, seq: number, pos: number, embedding: Float32Array, model: string, embeddedAt: string, totalChunks?: number, fingerprint?: string): void;
889
- export declare function expandQuery(query: string, model: string | undefined, db: Database, llmOverride?: LlamaCpp): Promise<ExpandedQuery[]>;
892
+ export declare function expandQuery(query: string, model: string | undefined, db: Database, llmOverride?: LlamaCpp, options?: {
893
+ onModelReady?: () => void;
894
+ }): Promise<ExpandedQuery[]>;
890
895
  /**
891
896
  * Delete the cached expansion for a query. hybridQuery() calls this when an
892
897
  * expansion's sub-queries all came back empty — left in place, the dud entry
@@ -897,6 +902,8 @@ export declare function deleteExpansionCacheEntry(db: Database, query: string, m
897
902
  export type StoreRerankOptions = {
898
903
  /** Doc token cap for this request; overrides QMD_RERANK_MAX_DOC_TOKENS. */
899
904
  maxDocTokens?: number;
905
+ /** Called once the rerank model is loaded (not at all when every chunk is cached). */
906
+ onModelReady?: () => void;
900
907
  };
901
908
  export declare function rerank(query: string, documents: {
902
909
  file: string;
@@ -1007,6 +1014,20 @@ export interface SearchHooks {
1007
1014
  onEmbedStart?: (count: number) => void;
1008
1015
  /** Embedding complete */
1009
1016
  onEmbedDone?: (elapsedMs: number) => void;
1017
+ /** Every BM25 lookup of the search finished; elapsedMs is their total */
1018
+ onFtsDone?: (elapsedMs: number) => void;
1019
+ /** Every sqlite-vec lookup of the search finished (embedding excluded); elapsedMs is their total */
1020
+ onVecDone?: (elapsedMs: number) => void;
1021
+ /** Candidates were chunked and each one's best chunk picked (runs with or without rerank) */
1022
+ onChunkDone?: (elapsedMs: number) => void;
1023
+ /**
1024
+ * A stage went without its LLM (expansion: original query only, rerank: RRF order). elapsedMs is
1025
+ * how long it was waited on after its model loaded; undefined when it was skipped outright because
1026
+ * an earlier call of that stage, abandoned at its deadline, is still running.
1027
+ */
1028
+ onDeadline?: (stage: DeadlineStage, elapsedMs?: number) => void;
1029
+ /** The stage's model finished loading (cold start or reload after idle unload); elapsedMs is the wait */
1030
+ onModelLoad?: (stage: DeadlineStage, elapsedMs: number) => void;
1010
1031
  /** Reranking is about to start */
1011
1032
  onRerankStart?: (chunkCount: number) => void;
1012
1033
  /** Reranking finished */
@@ -1022,6 +1043,8 @@ export interface HybridQueryOptions {
1022
1043
  explain?: boolean;
1023
1044
  intent?: string;
1024
1045
  skipRerank?: boolean;
1046
+ rerankDeadlineMs?: number;
1047
+ expandDeadlineMs?: number;
1025
1048
  chunkStrategy?: ChunkStrategy;
1026
1049
  scope?: DocumentFilter;
1027
1050
  hooks?: SearchHooks;
@@ -1088,6 +1111,7 @@ export declare function getStructuredRrfWeights(rankedListMeta: RankedListMeta[]
1088
1111
  * decimals, which is what "the reranker cannot move rank 1" looks like as a number.
1089
1112
  */
1090
1113
  export declare function blendRerankScore(rrfRank: number, rerankScore: number, candidateLimit: number): number;
1114
+ export type DeadlineStage = "expand" | "rerank";
1091
1115
  /**
1092
1116
  * Hybrid search: BM25 + vector + query expansion + RRF + chunked reranking.
1093
1117
  *
@@ -1147,6 +1171,7 @@ export interface StructuredSearchOptions {
1147
1171
  intent?: string;
1148
1172
  /** Skip LLM reranking, use only RRF scores */
1149
1173
  skipRerank?: boolean;
1174
+ rerankDeadlineMs?: number;
1150
1175
  chunkStrategy?: ChunkStrategy;
1151
1176
  /** Narrow the corpus by path glob and modified_at before anything is ranked */
1152
1177
  scope?: DocumentFilter;
package/dist/store.js CHANGED
@@ -1826,7 +1826,7 @@ export function createStore(dbPath) {
1826
1826
  searchFTS: (query, limit, collectionName, filter, scope) => searchFTS(db, query, limit, collectionName, filter, scope),
1827
1827
  searchVec: (query, model, limit, collectionName, session, precomputedEmbedding, filter, scope) => searchVec(db, query, model, limit, collectionName, session, precomputedEmbedding, getLlm(store), filter, scope),
1828
1828
  // Query expansion & reranking
1829
- expandQuery: (query, model) => expandQuery(query, model ?? store.llm?.generateModelName ?? DEFAULT_QUERY_MODEL, db, store.llm),
1829
+ expandQuery: (query, model, options) => expandQuery(query, model ?? store.llm?.generateModelName ?? DEFAULT_QUERY_MODEL, db, store.llm, options),
1830
1830
  invalidateExpansionCache: (query) => deleteExpansionCacheEntry(db, query, store.llm?.generateModelName ?? DEFAULT_QUERY_MODEL),
1831
1831
  rerank: (query, documents, model, intent, options) => {
1832
1832
  // Cache keys must use the resolved rerank model (store.llm or the global
@@ -3782,7 +3782,7 @@ function removeIncompleteEmbeddings(db, expectedChunksByHash, model) {
3782
3782
  // =============================================================================
3783
3783
  // Query expansion
3784
3784
  // =============================================================================
3785
- export async function expandQuery(query, model = DEFAULT_QUERY_MODEL, db, llmOverride) {
3785
+ export async function expandQuery(query, model = DEFAULT_QUERY_MODEL, db, llmOverride, options = {}) {
3786
3786
  // Check cache first — stored as JSON preserving types. Intent is
3787
3787
  // deliberately absent from both the cache key and the generation call:
3788
3788
  // feeding caller intent to the expansion model contaminates sub-queries
@@ -3810,7 +3810,7 @@ export async function expandQuery(query, model = DEFAULT_QUERY_MODEL, db, llmOve
3810
3810
  }
3811
3811
  const llm = llmOverride ?? getDefaultLlamaCpp();
3812
3812
  // Note: LlamaCpp uses hardcoded model, model parameter is ignored
3813
- const results = await llm.expandQuery(query);
3813
+ const results = await llm.expandQuery(query, { onModelReady: options.onModelReady });
3814
3814
  // Map Queryable[] → ExpandedQuery[] (same shape, decoupled from llm.ts internals).
3815
3815
  // Filter out entries that duplicate the original query text.
3816
3816
  const expanded = results
@@ -3865,6 +3865,7 @@ export async function rerank(query, documents, model = DEFAULT_RERANK_MODEL, db,
3865
3865
  const rerankResult = await llm.rerank(rerankQuery, uncachedDocs, {
3866
3866
  model: cacheModel,
3867
3867
  ...(requestMaxDocTokens ? { maxDocTokens: requestMaxDocTokens } : {}),
3868
+ onModelReady: options.onModelReady,
3868
3869
  });
3869
3870
  // Cache results by chunk text so identical chunks across files are scored once.
3870
3871
  // A "fallback" result (no rerank context could be created) is a flat 0.5, not a score:
@@ -4637,6 +4638,90 @@ export function blendRerankScore(rrfRank, rerankScore, candidateLimit) {
4637
4638
  const positionScore = 1 - (rrfRank - 1) / candidateLimit;
4638
4639
  return (1 - POSITION_TIEBREAK_WEIGHT) * rerankScore + POSITION_TIEBREAK_WEIGHT * positionScore;
4639
4640
  }
4641
+ const TIMED_OUT = Symbol("timed out");
4642
+ /**
4643
+ * The promise's value, or TIMED_OUT once `deadlineMs` passes (unset or 0 = no deadline). The
4644
+ * promise is not cancelled: a late rerank still lands in the rerank cache for the next query.
4645
+ */
4646
+ async function withDeadline(promise, deadlineMs) {
4647
+ if (!deadlineMs || deadlineMs <= 0)
4648
+ return promise;
4649
+ let timer;
4650
+ const deadline = new Promise((resolve) => {
4651
+ timer = setTimeout(() => resolve(TIMED_OUT), deadlineMs);
4652
+ });
4653
+ try {
4654
+ return await Promise.race([promise, deadline]);
4655
+ }
4656
+ finally {
4657
+ clearTimeout(timer);
4658
+ }
4659
+ }
4660
+ /**
4661
+ * LLM calls abandoned at their deadline and still running, per store and stage. While one runs, the
4662
+ * next call of that stage would only queue behind it on the same model and time out too, so it is
4663
+ * skipped instead: this caps the backlog at one abandoned call per stage instead of one per query.
4664
+ */
4665
+ const abandoned = new WeakMap();
4666
+ /**
4667
+ * Run one LLM stage under a deadline. The clock starts once the stage's model is loaded
4668
+ * (`onModelReady`): the deadline bounds computation, not a cold load, which is reported through
4669
+ * `onModelLoad`. TIMED_OUT when the deadline passes, or at once while an earlier abandoned call of
4670
+ * the stage is still running. Unset or 0 deadline = plain await.
4671
+ */
4672
+ async function runWithDeadline(store, stage, deadlineMs, hooks, start) {
4673
+ const started = Date.now();
4674
+ let loadedAt;
4675
+ const reportLoad = () => { if (loadedAt !== undefined)
4676
+ hooks?.onModelLoad?.(stage, loadedAt - started); };
4677
+ if (!deadlineMs || deadlineMs <= 0) {
4678
+ const value = await start(() => { loadedAt = Date.now(); });
4679
+ reportLoad();
4680
+ return value;
4681
+ }
4682
+ const byStage = abandoned.get(store) ?? { expand: { running: 0, warned: false }, rerank: { running: 0, warned: false } };
4683
+ abandoned.set(store, byStage);
4684
+ const state = byStage[stage];
4685
+ if (state.running > 0) {
4686
+ if (!state.warned) {
4687
+ state.warned = true;
4688
+ process.stderr.write(`qmd: a ${stage} abandoned at its deadline is still running — answering without ${stage} until it finishes\n`);
4689
+ }
4690
+ hooks?.onDeadline?.(stage);
4691
+ return TIMED_OUT;
4692
+ }
4693
+ let markReady = () => { };
4694
+ const ready = new Promise((resolve) => { markReady = () => { loadedAt = Date.now(); resolve(); }; });
4695
+ const pending = start(markReady);
4696
+ // Wait out the model load with no deadline; a cache hit or a fast call may finish before it fires.
4697
+ const finishedFirst = await Promise.race([ready.then(() => false), pending.then(() => true, () => true)]);
4698
+ reportLoad();
4699
+ if (finishedFirst)
4700
+ return pending;
4701
+ const result = await withDeadline(pending, deadlineMs);
4702
+ if (result === TIMED_OUT) {
4703
+ state.running++;
4704
+ const settle = () => {
4705
+ state.running--;
4706
+ if (state.running === 0)
4707
+ state.warned = false;
4708
+ };
4709
+ pending.then(settle, settle);
4710
+ hooks?.onDeadline?.(stage, Date.now() - (loadedAt ?? started));
4711
+ }
4712
+ return result;
4713
+ }
4714
+ /** Rerank `chunks`, or null when the reranker misses `deadlineMs` (the caller then answers in RRF order). */
4715
+ async function rerankWithDeadline(store, query, chunks, intent, options) {
4716
+ const hooks = options?.hooks;
4717
+ hooks?.onRerankStart?.(chunks.length);
4718
+ const start = Date.now();
4719
+ const reranked = await runWithDeadline(store, "rerank", options?.rerankDeadlineMs, hooks, (onModelReady) => store.rerank(query, chunks, undefined, intent, { maxDocTokens: options?.rerankMaxDocTokens, onModelReady }));
4720
+ if (reranked === TIMED_OUT)
4721
+ return null;
4722
+ hooks?.onRerankDone?.(Date.now() - start);
4723
+ return reranked;
4724
+ }
4640
4725
  /**
4641
4726
  * Cut each candidate into chunks and pick the one to rerank (`selectRerankChunk`). A document
4642
4727
  * with no chunks is left out of the map.
@@ -4697,7 +4782,9 @@ export async function hybridQuery(store, rawQuery, options) {
4697
4782
  // Pass collection and metadata filter directly into FTS query (filter at
4698
4783
  // SQL level, not post-hoc) — the strong-signal decision must be based only
4699
4784
  // on eligible documents.
4785
+ let ftsStart = Date.now();
4700
4786
  const initialFts = store.searchFTS(query, 20, collection, filter, scope);
4787
+ let ftsMs = Date.now() - ftsStart;
4701
4788
  const topScore = initialFts[0]?.score ?? 0;
4702
4789
  const secondScore = initialFts[1]?.score ?? 0;
4703
4790
  // ko-qmd: a relaxed match is never a strong signal — it did not match all the words.
@@ -4714,9 +4801,10 @@ export async function hybridQuery(store, rawQuery, options) {
4714
4801
  const skipsExpansion = hasStrongSignal || containsHangul(query);
4715
4802
  hooks?.onExpandStart?.();
4716
4803
  const expandStart = Date.now();
4717
- const expanded = skipsExpansion
4804
+ const expansion = skipsExpansion
4718
4805
  ? []
4719
- : await store.expandQuery(query);
4806
+ : await runWithDeadline(store, "expand", options?.expandDeadlineMs, hooks, (onModelReady) => store.expandQuery(query, undefined, { onModelReady }));
4807
+ const expanded = expansion === TIMED_OUT ? [] : expansion;
4720
4808
  hooks?.onExpand?.(query, expanded, Date.now() - expandStart);
4721
4809
  // Seed with initial FTS results (avoid re-running original query FTS)
4722
4810
  if (initialFts.length > 0) {
@@ -4737,6 +4825,7 @@ export async function hybridQuery(store, rawQuery, options) {
4737
4825
  // batch-embed all vector queries in one embedBatch() call, then run
4738
4826
  // sqlite-vec lookups with pre-computed embeddings.
4739
4827
  // 3a: Run FTS for all lex expansions right away (no LLM needed)
4828
+ ftsStart = Date.now();
4740
4829
  for (const q of expanded) {
4741
4830
  if (q.type === 'lex') {
4742
4831
  const ftsResults = store.searchFTS(q.query, 20, collection, filter, scope);
@@ -4754,6 +4843,7 @@ export async function hybridQuery(store, rawQuery, options) {
4754
4843
  }
4755
4844
  }
4756
4845
  }
4846
+ hooks?.onFtsDone?.(ftsMs + Date.now() - ftsStart);
4757
4847
  // 3b: Collect all texts that need vector search (original query + vec/hyde expansions)
4758
4848
  if (hasVectors) {
4759
4849
  const vecQueries = [
@@ -4773,6 +4863,7 @@ export async function hybridQuery(store, rawQuery, options) {
4773
4863
  const embeddings = await llm.embedBatch(textsToEmbed);
4774
4864
  hooks?.onEmbedDone?.(Date.now() - embedStart);
4775
4865
  // Run sqlite-vec lookups with pre-computed embeddings
4866
+ const vecStart = Date.now();
4776
4867
  for (let i = 0; i < vecQueries.length; i++) {
4777
4868
  const embedding = embeddings[i]?.embedding;
4778
4869
  if (!embedding)
@@ -4792,6 +4883,7 @@ export async function hybridQuery(store, rawQuery, options) {
4792
4883
  });
4793
4884
  }
4794
4885
  }
4886
+ hooks?.onVecDone?.(Date.now() - vecStart);
4795
4887
  }
4796
4888
  // Step 3c: drop a cached expansion whose sub-queries all came back empty —
4797
4889
  // the dud is cached per (query, model), so left in place it deterministically
@@ -4815,8 +4907,14 @@ export async function hybridQuery(store, rawQuery, options) {
4815
4907
  return [];
4816
4908
  // Step 5: Chunk documents, pick best chunk per doc for reranking.
4817
4909
  // Reranking full bodies is O(tokens) — the critical perf lesson that motivated this refactor.
4910
+ const chunkStart = Date.now();
4818
4911
  const docChunkMap = await chunkCandidatesForRerank(candidates, query, intent, options?.chunkStrategy);
4819
- if (skipRerank) {
4912
+ hooks?.onChunkDone?.(Date.now() - chunkStart);
4913
+ // Step 6: Rerank chunks (NOT full bodies). No reranker, or one past its deadline, means RRF order.
4914
+ const reranked = skipRerank
4915
+ ? null
4916
+ : await rerankWithDeadline(store, query, rerankDocuments(candidates, docChunkMap), intent, options);
4917
+ if (reranked === null) {
4820
4918
  // Skip LLM reranking — return candidates scored by RRF only
4821
4919
  const seenFiles = new Set();
4822
4920
  const rrfResults = candidates
@@ -4862,16 +4960,12 @@ export async function hybridQuery(store, rawQuery, options) {
4862
4960
  seenFiles.add(r.file);
4863
4961
  return true;
4864
4962
  })
4865
- .filter(r => r.score >= minScore)
4963
+ // minScore is a threshold on blended rerank scores; a deadline fallback's 1/rank scores would
4964
+ // cut a 0.5 threshold to two results, so only a caller that skipped rerank gets it applied here.
4965
+ .filter(r => !skipRerank || r.score >= minScore)
4866
4966
  .slice(0, limit);
4867
4967
  return attachResultMetadata(store.db, rrfResults);
4868
4968
  }
4869
- // Step 6: Rerank chunks (NOT full bodies)
4870
- const chunksToRerank = rerankDocuments(candidates, docChunkMap);
4871
- hooks?.onRerankStart?.(chunksToRerank.length);
4872
- const rerankStart = Date.now();
4873
- const reranked = await store.rerank(query, chunksToRerank, undefined, intent, { maxDocTokens: options?.rerankMaxDocTokens });
4874
- hooks?.onRerankDone?.(Date.now() - rerankStart);
4875
4969
  // Step 7: Blend RRF position score with reranker score
4876
4970
  // Position-aware weights: top retrieval results get more protection from reranker disagreement
4877
4971
  const candidateMap = new Map(candidates.map(c => [c.file, {
@@ -5040,6 +5134,7 @@ export async function structuredSearch(store, rawSearches, options) {
5040
5134
  // Helper to run search across collections (or all if undefined)
5041
5135
  const collectionList = collections ?? [undefined]; // undefined = all collections
5042
5136
  // Step 1: Run FTS for all lex searches (sync, instant)
5137
+ const ftsStart = Date.now();
5043
5138
  for (const search of searches) {
5044
5139
  if (search.type === 'lex') {
5045
5140
  for (const coll of collectionList) {
@@ -5061,6 +5156,8 @@ export async function structuredSearch(store, rawSearches, options) {
5061
5156
  }
5062
5157
  }
5063
5158
  }
5159
+ if (searches.some(s => s.type === 'lex'))
5160
+ hooks?.onFtsDone?.(Date.now() - ftsStart);
5064
5161
  // Step 2: Batch embed and run vector searches for vec/hyde
5065
5162
  if (hasVectors) {
5066
5163
  const vecSearches = searches.filter((s) => s.type === 'vec' || s.type === 'hyde');
@@ -5072,6 +5169,7 @@ export async function structuredSearch(store, rawSearches, options) {
5072
5169
  const embedStart = Date.now();
5073
5170
  const embeddings = await llm.embedBatch(textsToEmbed);
5074
5171
  hooks?.onEmbedDone?.(Date.now() - embedStart);
5172
+ const vecStart = Date.now();
5075
5173
  for (let i = 0; i < vecSearches.length; i++) {
5076
5174
  const embedding = embeddings[i]?.embedding;
5077
5175
  if (!embedding)
@@ -5093,6 +5191,7 @@ export async function structuredSearch(store, rawSearches, options) {
5093
5191
  }
5094
5192
  }
5095
5193
  }
5194
+ hooks?.onVecDone?.(Date.now() - vecStart);
5096
5195
  }
5097
5196
  }
5098
5197
  if (rankedLists.length === 0)
@@ -5111,8 +5210,14 @@ export async function structuredSearch(store, rawSearches, options) {
5111
5210
  const primaryQuery = searches.find(s => s.type === 'lex')?.query
5112
5211
  || searches.find(s => s.type === 'vec')?.query
5113
5212
  || searches[0]?.query || "";
5213
+ const chunkStart = Date.now();
5114
5214
  const docChunkMap = await chunkCandidatesForRerank(candidates, primaryQuery, intent, options?.chunkStrategy);
5115
- if (skipRerank) {
5215
+ hooks?.onChunkDone?.(Date.now() - chunkStart);
5216
+ // Step 5: Rerank chunks. No reranker, or one past its deadline, means RRF order.
5217
+ const reranked = skipRerank
5218
+ ? null
5219
+ : await rerankWithDeadline(store, primaryQuery, rerankDocuments(candidates, docChunkMap), intent, options);
5220
+ if (reranked === null) {
5116
5221
  // Skip LLM reranking — return candidates scored by RRF only
5117
5222
  const seenFiles = new Set();
5118
5223
  const rrfResults = candidates
@@ -5158,16 +5263,12 @@ export async function structuredSearch(store, rawSearches, options) {
5158
5263
  seenFiles.add(r.file);
5159
5264
  return true;
5160
5265
  })
5161
- .filter(r => r.score >= minScore)
5266
+ // minScore is a threshold on blended rerank scores; a deadline fallback's 1/rank scores would
5267
+ // cut a 0.5 threshold to two results, so only a caller that skipped rerank gets it applied here.
5268
+ .filter(r => !skipRerank || r.score >= minScore)
5162
5269
  .slice(0, limit);
5163
5270
  return attachResultMetadata(store.db, rrfResults);
5164
5271
  }
5165
- // Step 5: Rerank chunks
5166
- const chunksToRerank = rerankDocuments(candidates, docChunkMap);
5167
- hooks?.onRerankStart?.(chunksToRerank.length);
5168
- const rerankStart2 = Date.now();
5169
- const reranked = await store.rerank(primaryQuery, chunksToRerank, undefined, intent, { maxDocTokens: options?.rerankMaxDocTokens });
5170
- hooks?.onRerankDone?.(Date.now() - rerankStart2);
5171
5272
  // Step 6: Blend RRF position score with reranker score
5172
5273
  const candidateMap = new Map(candidates.map(c => [c.file, {
5173
5274
  displayPath: c.displayPath, title: c.title, body: c.body,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ko-qmd",
3
- "version": "2.8.3-ko.6",
3
+ "version": "2.8.3-ko.7",
4
4
  "description": "Local hybrid search for Korean Markdown notes and Obsidian vaults — BM25 with Korean particle stripping, Hangul bigram FTS, Qwen3 vector search and LLM reranking, as CLI, MCP server and SDK",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",