ko-qmd 2.8.3-ko.6 → 2.8.3-ko.8

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,71 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [2.8.3-ko.8] - 2026-10-09
6
+
7
+ - `structuredSearch` (MCP `searches`, SDK `search({ queries })`) no longer lets a relaxed
8
+ (any-word) first lex list keep the 2x positional boost: it now counts 0.25 instead of 1.0.
9
+ Even halved, a relaxed rank 1 still tied a vector rank 1 against a lex rank 4 (`hmu-05`
10
+ probe), so the decoy sharing a few question words kept rank 1 on the `rerank:false` path.
11
+ On the ko-vault seam form, hard `hybrid_r1` goes 0.4111 → 0.4444 with `full_r1` unchanged at
12
+ 0.6111; one easy query (`sem-08`) trades hybrid rank 1 for 2 while its `full` rank 1 and all
13
+ recall@5 lines hold. Non-first relaxed lists still count half, as before.
14
+ - Hangul loanword table gains 11 entries mined from real query-log traffic (`지연` →
15
+ `latency`, `병렬` → `parallel`, `게이트` → `gate`, `가드` → `guard`, `패널` → `panel`,
16
+ `팔레트` → `palette`, `하네스` → `harness`, `옵시디언` → `obsidian`, `백링크` → `backlink`,
17
+ `포즈` → `pause`, `루프` → `loop`). Native words from the same logs (`색인`·`질의`·`요약`·`문서`
18
+ …) stay unbridged. Query-side only, no re-index; ko-vault bench lines are unchanged.
19
+ - Negation routing stays strip-only by measurement: demoting X with lex `-term`s was tried
20
+ and reverted — the expected note often discusses X itself (`bm25-ranking.md` mentions TF-IDF),
21
+ so NOT removed the answer with the decoy (`hybrid_r1` 0.4111 → 0.3778). `stripHangulNegation`
22
+ now shares its parsing through an internal split helper; behavior is unchanged.
23
+
24
+ ## [2.8.3-ko.7] - 2026-10-02
25
+
26
+ The query path is now observable and bounded. Every query-log row breaks its latency into
27
+ stages, the daemon logs its heap once an hour, and expansion and rerank stop waiting past a
28
+ deadline instead of holding a query for 85 s. The reranker also sees a note's first chunk by default.
29
+
30
+ - `qmd mcp` (stdio and HTTP) no longer waits on a slow LLM stage without bound. Past
31
+ `QMD_EXPAND_DEADLINE_MS` (default 5 s) a query searches its original text alone, and past
32
+ `QMD_RERANK_DEADLINE_MS` (default 10 s) it answers in RRF order; `0` restores waiting. The clock
33
+ starts once the model is loaded, so a cold start or a reload after the idle unload is not a timeout;
34
+ the query log records that wait as a `load` stage. One MCP `query` with expansion and rerank took
35
+ 85 s. The REST `/query` response and the MCP `query` tool say which stage timed out (`timeouts`),
36
+ the query log row lists it the same way, and a timed-out rerank reads `rerank: "timeout"` there.
37
+ A fallback does not apply `minScore` to its 1/rank scores. An abandoned expansion or rerank keeps
38
+ running and fills its cache; until it finishes, later queries skip that stage (logged once) instead
39
+ of queueing behind it. The CLI search commands and the SDK (`rerankDeadlineMs`, `expandDeadlineMs`
40
+ options) never set a deadline on their own.
41
+
42
+ - The daemon's query log row now carries `stages`, the milliseconds spent in each stage of the
43
+ search: `refresh` (the pre-query re-index check), `expand`, `fts`, `embed`, `vec`, `chunk`,
44
+ `rerank` and `serialize`; a stage that did not run is absent. REST `rerank:false` queries had a p95 of 2.9–5.3 s
45
+ against a p50 of 0.2–0.5 s with no rerank in the path, and the single `ms` could not say which
46
+ stage the tail came from. Daemon log lines now start with a dated local ISO timestamp instead of
47
+ a UTC `HH:mm:ss.SSS`, so a slow line can be matched to its row.
48
+
49
+ - The HTTP daemon logs a `health` line every hour: heap used and total, rss, and how many
50
+ `subscriptions/listen` and other `/mcp` requests have a handler still waiting on its response. A daemon that ran ten days died with
51
+ "Reached heap limit" after its heap crept to 3.8 GB under idle discover/cancelled traffic, and
52
+ nothing in the log showed the slope or what was holding memory. `QMD_HEALTH_LOG_INTERVAL_MS`
53
+ changes the interval; `0` turns it off.
54
+
55
+ - `scripts/autoresearch/prepare.sh` builds the goldset from more than the collection folder and over
56
+ several runs. `AR_EXTRA_DIRS="outputs docs"` copies sibling folders of the collection into the corpus
57
+ (names only, so no vault path lands in git), `AR_RUN=N` writes `generated-N.json` from documents no
58
+ earlier run answered (`bench-vault-paraphrase.ts --dirs`, `--skip`), and `split` merges every run,
59
+ drops the query ids listed in `dropped.txt`, and splits only each run's new documents, so an earlier
60
+ train/held-out assignment never moves.
61
+
62
+ - The reranker now gets a note's first chunk, its title and summary: the first chunk starts 16
63
+ points ahead in chunk selection (8 when an intent is given, so intent words can still pick a later
64
+ chunk). A later chunk needs 17 more points, about nine English words or seventeen Hangul syllable
65
+ pairs, so for a typical short query the first chunk is always sent. A second autoresearch loop found
66
+ this on a goldset of 320 train and 71 held-out queries drawn from four vault folders. Held-out full
67
+ MRR goes 0.7291 → 0.7459, ko-vault seam and hard lines are unchanged, and bm25, vector and hybrid
68
+ results are identical (`BASELINE.md` 2026-09-29).
69
+
5
70
  ## [2.8.3-ko.6] - 2026-09-29
6
71
 
7
72
  Korean queries are handled better before and during reranking. A query written
package/README.en.md CHANGED
@@ -38,7 +38,7 @@ flowchart LR
38
38
  (`검색을` → `검색`), a two-particle chain (`청킹에서의` → `청킹`), verb and nominalizing endings
39
39
  (`토큰화하는` → `토큰화`, `검색하기` → `검색`), `한`/`된` endings (`필요한` → `필요`) and the 하↔해
40
40
  contraction. Quoted phrases, Hanja and kana are left as written.
41
- - **Loanword bridge** — about 80 built-in Hangul spellings of technical terms (`서치`, `웹`, `코어`,
41
+ - **Loanword bridge** — about 90 built-in Hangul spellings of technical terms (`서치`, `웹`, `코어`,
42
42
  `마이그레이션`, …) also search their English spelling, so `하이브리드 서치` finds a `hybrid-search` note.
43
43
  Only the query changes; no re-index is needed.
44
44
  - **Mixed-script token split** — `SKILL.md계약의핵심` becomes `skill` AND `md` AND `계약의핵심`.
package/README.md CHANGED
@@ -34,7 +34,7 @@ flowchart LR
34
34
  - **조사·어미 제거** — 따옴표 없는 한글 단어는 어간도 매칭한다. 끝 조사 하나(`검색을` → `검색`), 두 조사 연쇄(`청킹에서의` → `청킹`),
35
35
  동사·명사화 어미(`토큰화하는` → `토큰화`, `검색하기` → `검색`), `한`/`된` 어미(`필요한` → `필요`), 하↔해 축약(`더하` ↔ `더해`).
36
36
  따옴표 구문·한자·가나는 그대로 둔다.
37
- - **외래어 다리** — 기술 용어의 한글 표기(`서치`·`웹`·`코어`·`마이그레이션` 등 약 80개 내장 표)는 영문 표기도 함께 찾는다.
37
+ - **외래어 다리** — 기술 용어의 한글 표기(`서치`·`웹`·`코어`·`마이그레이션` 등 약 90개 내장 표)는 영문 표기도 함께 찾는다.
38
38
  `하이브리드 서치`가 `hybrid-search` 노트를, `리액트 웹 코어`가 `react-web-core`를 찾는다. 질의 쪽만 바뀌므로 재색인은 필요 없다.
39
39
  - **문자 체계가 섞인 토큰 분리** — `SKILL.md계약의핵심`은 `skill` AND `md` AND `계약의핵심`으로 나눈다.
40
40
  - **긴 질의 완화** — 세 단어 이상 질의가 AND 로 한 건도 안 맞으면 같은 단어를 OR 로 다시 찾는다. 자연어 문장 질의가 0건이 되지 않는다.
@@ -1,4 +1,4 @@
1
1
  {
2
- "commit": "c544042",
3
- "builtAt": "2026-09-29T00:59:00.970Z"
2
+ "commit": "6698cd5",
3
+ "builtAt": "2026-10-09T05:15:35.019Z"
4
4
  }
package/dist/hangul.js CHANGED
@@ -65,24 +65,34 @@ export function sharesHangulBigram(a, b) {
65
65
  return false;
66
66
  }
67
67
  /**
68
- * Drop the excluded clause of a Korean negation query and keep what follows the
69
- * last marker (`tf-idf 말고 순위 공식` → `순위 공식`), so retrieval and rerank
70
- * never see the concept the user ruled out. Returns the query unchanged when a
71
- * side would be empty or hold only `-term` exclusions, a `"` phrase is present, or
72
- * no marker stands as its own token — a marker glued to X (`이거말고`) is not split.
68
+ * Split a "X 말고 Y" query at its last exclusion marker into the excluded (X)
69
+ * and kept (Y) token lists. Null when there is no rewrite: a `"` phrase is
70
+ * present, no Hangul, no standalone marker, an empty side, or a Y of only
71
+ * `-term` exclusions (lex needs a positive term to match).
73
72
  */
74
- export function stripHangulNegation(query) {
73
+ function splitHangulNegation(query) {
75
74
  if (query.includes('"') || !containsHangul(query))
76
- return query;
75
+ return null;
77
76
  const tokens = query.trim().split(/\s+/);
78
77
  const last = tokens.findLastIndex((token) => NEGATION_MARKERS.has(token));
79
78
  if (last < 1 || last === tokens.length - 1)
80
- return query;
79
+ return null;
81
80
  const kept = tokens.slice(last + 1);
82
81
  // A Y of only `-term` exclusions leaves lex no positive term to match.
83
82
  if (kept.every((token) => token.startsWith("-")))
84
- return query;
85
- return kept.join(" ");
83
+ return null;
84
+ return { excluded: tokens.slice(0, last), kept };
85
+ }
86
+ /**
87
+ * Drop the excluded clause of a Korean negation query and keep what follows the
88
+ * last marker (`tf-idf 말고 순위 공식` → `순위 공식`), so retrieval and rerank
89
+ * never see the concept the user ruled out. Returns the query unchanged when a
90
+ * side would be empty or hold only `-term` exclusions, a `"` phrase is present, or
91
+ * no marker stands as its own token — a marker glued to X (`이거말고`) is not split.
92
+ */
93
+ export function stripHangulNegation(query) {
94
+ const split = splitHangulNegation(query);
95
+ return split ? split.kept.join(" ") : query;
86
96
  }
87
97
  /**
88
98
  * Strip one trailing particle (or 기) from a pure-Hangul word when the stem
@@ -173,7 +183,9 @@ export function hangulTermQuery(term) {
173
183
  * ASCII words separated by single spaces so they are safe inside an FTS5 phrase.
174
184
  */
175
185
  const LOANWORDS = {
186
+ "가드": ["guard"],
176
187
  "가이드": ["guide"],
188
+ "게이트": ["gate"],
177
189
  "골든": ["golden"],
178
190
  "그래프": ["graph"],
179
191
  "노트": ["note"],
@@ -194,14 +206,17 @@ const LOANWORDS = {
194
206
  "리트리벌": ["retrieval"],
195
207
  "리피티션": ["repetition"],
196
208
  "린트": ["lint"],
209
+ "루프": ["loop"],
197
210
  "마이그레이션": ["migration"],
198
211
  "메타데이터": ["metadata"],
199
212
  "모델": ["model"],
200
213
  "미팅": ["meeting"],
201
214
  "바이그램": ["bigram"],
215
+ "백링크": ["backlink"],
202
216
  "벡터": ["vector"],
203
217
  "벤치": ["bench"],
204
218
  "벤치마크": ["benchmark"],
219
+ "병렬": ["parallel"],
205
220
  "서버": ["server"],
206
221
  "서치": ["search"],
207
222
  "세션": ["session"],
@@ -215,6 +230,7 @@ const LOANWORDS = {
215
230
  "엔그램": ["ngram", "n gram"],
216
231
  "오토메이션": ["automation"],
217
232
  "온리": ["only"],
233
+ "옵시디언": ["obsidian"],
218
234
  "워크플로": ["workflow"],
219
235
  "워크플로우": ["workflow"],
220
236
  "웹": ["web"],
@@ -227,6 +243,7 @@ const LOANWORDS = {
227
243
  "인제스트": ["ingest"],
228
244
  "인코더": ["encoder"],
229
245
  "임베딩": ["embedding"],
246
+ "지연": ["latency"],
230
247
  "청크": ["chunk"],
231
248
  "청킹": ["chunking"],
232
249
  "캐시": ["cache"],
@@ -242,6 +259,9 @@ const LOANWORDS = {
242
259
  "토픽": ["topic"],
243
260
  "트래킹": ["tracking"],
244
261
  "파이프라인": ["pipeline"],
262
+ "패널": ["panel"],
263
+ "팔레트": ["palette"],
264
+ "포즈": ["pause"],
245
265
  "퓨전": ["fusion"],
246
266
  "프로버넌스": ["provenance"],
247
267
  "프로비넌스": ["provenance"],
@@ -250,6 +270,7 @@ const LOANWORDS = {
250
270
  "프롬프트": ["prompt"],
251
271
  "플러그인": ["plugin"],
252
272
  "필터": ["filter"],
273
+ "하네스": ["harness"],
253
274
  "하이브리드": ["hybrid"],
254
275
  "훅": ["hook"],
255
276
  };
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;
@@ -1057,14 +1080,6 @@ export type RankedListMeta = {
1057
1080
  * original vector boost.
1058
1081
  */
1059
1082
  export declare function getHybridRrfWeights(rankedListMeta: RankedListMeta[]): number[];
1060
- /**
1061
- * RRF list weights for structuredSearch: the first list gets 2x (the caller ordered by
1062
- * importance), the rest 1x.
1063
- *
1064
- * ko-qmd: a relaxed FTS list counts half here too, as in getHybridRrfWeights. At a flat 2.0 a
1065
- * lex line that only matched some of a question's words outranked the vector list, and a decoy
1066
- * sharing a few of those words took rank 1 (test/fixtures/ko-vault/BASELINE.md 2026-09-27).
1067
- */
1068
1083
  export declare function getStructuredRrfWeights(rankedListMeta: RankedListMeta[]): number[];
1069
1084
  /**
1070
1085
  * Blend a reranker score with a linear retrieval-position tiebreak.
@@ -1088,6 +1103,7 @@ export declare function getStructuredRrfWeights(rankedListMeta: RankedListMeta[]
1088
1103
  * decimals, which is what "the reranker cannot move rank 1" looks like as a number.
1089
1104
  */
1090
1105
  export declare function blendRerankScore(rrfRank: number, rerankScore: number, candidateLimit: number): number;
1106
+ export type DeadlineStage = "expand" | "rerank";
1091
1107
  /**
1092
1108
  * Hybrid search: BM25 + vector + query expansion + RRF + chunked reranking.
1093
1109
  *
@@ -1147,6 +1163,7 @@ export interface StructuredSearchOptions {
1147
1163
  intent?: string;
1148
1164
  /** Skip LLM reranking, use only RRF scores */
1149
1165
  skipRerank?: boolean;
1166
+ rerankDeadlineMs?: number;
1150
1167
  chunkStrategy?: ChunkStrategy;
1151
1168
  /** Narrow the corpus by path glob and modified_at before anything is ranked */
1152
1169
  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:
@@ -4600,11 +4601,15 @@ export function getHybridRrfWeights(rankedListMeta) {
4600
4601
  * ko-qmd: a relaxed FTS list counts half here too, as in getHybridRrfWeights. At a flat 2.0 a
4601
4602
  * lex line that only matched some of a question's words outranked the vector list, and a decoy
4602
4603
  * sharing a few of those words took rank 1 (test/fixtures/ko-vault/BASELINE.md 2026-09-27).
4604
+ * ko-qmd: a relaxed first list forfeits the positional boost and halves again (0.25) — even
4605
+ * halved, its rank 1 still tied a vector rank 1 with a lex rank 4 (hmu-05, 2026-10-09 probe).
4603
4606
  */
4607
+ const RELAXED_FIRST_LIST_WEIGHT = 0.25;
4604
4608
  export function getStructuredRrfWeights(rankedListMeta) {
4605
4609
  return rankedListMeta.map((meta, i) => {
4606
- const weight = i === 0 ? 2.0 : 1.0;
4607
- return meta.relaxed ? weight * RELAXED_LIST_WEIGHT : weight;
4610
+ if (meta.relaxed)
4611
+ return i === 0 ? RELAXED_FIRST_LIST_WEIGHT : RELAXED_LIST_WEIGHT;
4612
+ return i === 0 ? 2.0 : 1.0;
4608
4613
  });
4609
4614
  }
4610
4615
  /**
@@ -4637,6 +4642,90 @@ export function blendRerankScore(rrfRank, rerankScore, candidateLimit) {
4637
4642
  const positionScore = 1 - (rrfRank - 1) / candidateLimit;
4638
4643
  return (1 - POSITION_TIEBREAK_WEIGHT) * rerankScore + POSITION_TIEBREAK_WEIGHT * positionScore;
4639
4644
  }
4645
+ const TIMED_OUT = Symbol("timed out");
4646
+ /**
4647
+ * The promise's value, or TIMED_OUT once `deadlineMs` passes (unset or 0 = no deadline). The
4648
+ * promise is not cancelled: a late rerank still lands in the rerank cache for the next query.
4649
+ */
4650
+ async function withDeadline(promise, deadlineMs) {
4651
+ if (!deadlineMs || deadlineMs <= 0)
4652
+ return promise;
4653
+ let timer;
4654
+ const deadline = new Promise((resolve) => {
4655
+ timer = setTimeout(() => resolve(TIMED_OUT), deadlineMs);
4656
+ });
4657
+ try {
4658
+ return await Promise.race([promise, deadline]);
4659
+ }
4660
+ finally {
4661
+ clearTimeout(timer);
4662
+ }
4663
+ }
4664
+ /**
4665
+ * LLM calls abandoned at their deadline and still running, per store and stage. While one runs, the
4666
+ * next call of that stage would only queue behind it on the same model and time out too, so it is
4667
+ * skipped instead: this caps the backlog at one abandoned call per stage instead of one per query.
4668
+ */
4669
+ const abandoned = new WeakMap();
4670
+ /**
4671
+ * Run one LLM stage under a deadline. The clock starts once the stage's model is loaded
4672
+ * (`onModelReady`): the deadline bounds computation, not a cold load, which is reported through
4673
+ * `onModelLoad`. TIMED_OUT when the deadline passes, or at once while an earlier abandoned call of
4674
+ * the stage is still running. Unset or 0 deadline = plain await.
4675
+ */
4676
+ async function runWithDeadline(store, stage, deadlineMs, hooks, start) {
4677
+ const started = Date.now();
4678
+ let loadedAt;
4679
+ const reportLoad = () => { if (loadedAt !== undefined)
4680
+ hooks?.onModelLoad?.(stage, loadedAt - started); };
4681
+ if (!deadlineMs || deadlineMs <= 0) {
4682
+ const value = await start(() => { loadedAt = Date.now(); });
4683
+ reportLoad();
4684
+ return value;
4685
+ }
4686
+ const byStage = abandoned.get(store) ?? { expand: { running: 0, warned: false }, rerank: { running: 0, warned: false } };
4687
+ abandoned.set(store, byStage);
4688
+ const state = byStage[stage];
4689
+ if (state.running > 0) {
4690
+ if (!state.warned) {
4691
+ state.warned = true;
4692
+ process.stderr.write(`qmd: a ${stage} abandoned at its deadline is still running — answering without ${stage} until it finishes\n`);
4693
+ }
4694
+ hooks?.onDeadline?.(stage);
4695
+ return TIMED_OUT;
4696
+ }
4697
+ let markReady = () => { };
4698
+ const ready = new Promise((resolve) => { markReady = () => { loadedAt = Date.now(); resolve(); }; });
4699
+ const pending = start(markReady);
4700
+ // Wait out the model load with no deadline; a cache hit or a fast call may finish before it fires.
4701
+ const finishedFirst = await Promise.race([ready.then(() => false), pending.then(() => true, () => true)]);
4702
+ reportLoad();
4703
+ if (finishedFirst)
4704
+ return pending;
4705
+ const result = await withDeadline(pending, deadlineMs);
4706
+ if (result === TIMED_OUT) {
4707
+ state.running++;
4708
+ const settle = () => {
4709
+ state.running--;
4710
+ if (state.running === 0)
4711
+ state.warned = false;
4712
+ };
4713
+ pending.then(settle, settle);
4714
+ hooks?.onDeadline?.(stage, Date.now() - (loadedAt ?? started));
4715
+ }
4716
+ return result;
4717
+ }
4718
+ /** Rerank `chunks`, or null when the reranker misses `deadlineMs` (the caller then answers in RRF order). */
4719
+ async function rerankWithDeadline(store, query, chunks, intent, options) {
4720
+ const hooks = options?.hooks;
4721
+ hooks?.onRerankStart?.(chunks.length);
4722
+ const start = Date.now();
4723
+ const reranked = await runWithDeadline(store, "rerank", options?.rerankDeadlineMs, hooks, (onModelReady) => store.rerank(query, chunks, undefined, intent, { maxDocTokens: options?.rerankMaxDocTokens, onModelReady }));
4724
+ if (reranked === TIMED_OUT)
4725
+ return null;
4726
+ hooks?.onRerankDone?.(Date.now() - start);
4727
+ return reranked;
4728
+ }
4640
4729
  /**
4641
4730
  * Cut each candidate into chunks and pick the one to rerank (`selectRerankChunk`). A document
4642
4731
  * with no chunks is left out of the map.
@@ -4675,6 +4764,9 @@ const rerankDocuments = (candidates, docChunkMap) => candidates.flatMap((cand) =
4675
4764
  */
4676
4765
  export async function hybridQuery(store, rawQuery, options) {
4677
4766
  // ko-qmd: "X 말고 Y" names X to exclude — every list and the reranker see only Y.
4767
+ // (Tried demoting X with lex -terms: falsified 2026-10-09 — the expected note
4768
+ // often discusses X itself, e.g. bm25-ranking.md mentions TF-IDF, so NOT
4769
+ // removes the answer along with the decoy.)
4678
4770
  const query = stripHangulNegation(rawQuery);
4679
4771
  const limit = options?.limit ?? 10;
4680
4772
  const minScore = options?.minScore ?? 0;
@@ -4697,7 +4789,9 @@ export async function hybridQuery(store, rawQuery, options) {
4697
4789
  // Pass collection and metadata filter directly into FTS query (filter at
4698
4790
  // SQL level, not post-hoc) — the strong-signal decision must be based only
4699
4791
  // on eligible documents.
4792
+ let ftsStart = Date.now();
4700
4793
  const initialFts = store.searchFTS(query, 20, collection, filter, scope);
4794
+ let ftsMs = Date.now() - ftsStart;
4701
4795
  const topScore = initialFts[0]?.score ?? 0;
4702
4796
  const secondScore = initialFts[1]?.score ?? 0;
4703
4797
  // ko-qmd: a relaxed match is never a strong signal — it did not match all the words.
@@ -4714,9 +4808,10 @@ export async function hybridQuery(store, rawQuery, options) {
4714
4808
  const skipsExpansion = hasStrongSignal || containsHangul(query);
4715
4809
  hooks?.onExpandStart?.();
4716
4810
  const expandStart = Date.now();
4717
- const expanded = skipsExpansion
4811
+ const expansion = skipsExpansion
4718
4812
  ? []
4719
- : await store.expandQuery(query);
4813
+ : await runWithDeadline(store, "expand", options?.expandDeadlineMs, hooks, (onModelReady) => store.expandQuery(query, undefined, { onModelReady }));
4814
+ const expanded = expansion === TIMED_OUT ? [] : expansion;
4720
4815
  hooks?.onExpand?.(query, expanded, Date.now() - expandStart);
4721
4816
  // Seed with initial FTS results (avoid re-running original query FTS)
4722
4817
  if (initialFts.length > 0) {
@@ -4737,6 +4832,7 @@ export async function hybridQuery(store, rawQuery, options) {
4737
4832
  // batch-embed all vector queries in one embedBatch() call, then run
4738
4833
  // sqlite-vec lookups with pre-computed embeddings.
4739
4834
  // 3a: Run FTS for all lex expansions right away (no LLM needed)
4835
+ ftsStart = Date.now();
4740
4836
  for (const q of expanded) {
4741
4837
  if (q.type === 'lex') {
4742
4838
  const ftsResults = store.searchFTS(q.query, 20, collection, filter, scope);
@@ -4754,6 +4850,7 @@ export async function hybridQuery(store, rawQuery, options) {
4754
4850
  }
4755
4851
  }
4756
4852
  }
4853
+ hooks?.onFtsDone?.(ftsMs + Date.now() - ftsStart);
4757
4854
  // 3b: Collect all texts that need vector search (original query + vec/hyde expansions)
4758
4855
  if (hasVectors) {
4759
4856
  const vecQueries = [
@@ -4773,6 +4870,7 @@ export async function hybridQuery(store, rawQuery, options) {
4773
4870
  const embeddings = await llm.embedBatch(textsToEmbed);
4774
4871
  hooks?.onEmbedDone?.(Date.now() - embedStart);
4775
4872
  // Run sqlite-vec lookups with pre-computed embeddings
4873
+ const vecStart = Date.now();
4776
4874
  for (let i = 0; i < vecQueries.length; i++) {
4777
4875
  const embedding = embeddings[i]?.embedding;
4778
4876
  if (!embedding)
@@ -4792,6 +4890,7 @@ export async function hybridQuery(store, rawQuery, options) {
4792
4890
  });
4793
4891
  }
4794
4892
  }
4893
+ hooks?.onVecDone?.(Date.now() - vecStart);
4795
4894
  }
4796
4895
  // Step 3c: drop a cached expansion whose sub-queries all came back empty —
4797
4896
  // the dud is cached per (query, model), so left in place it deterministically
@@ -4815,8 +4914,14 @@ export async function hybridQuery(store, rawQuery, options) {
4815
4914
  return [];
4816
4915
  // Step 5: Chunk documents, pick best chunk per doc for reranking.
4817
4916
  // Reranking full bodies is O(tokens) — the critical perf lesson that motivated this refactor.
4917
+ const chunkStart = Date.now();
4818
4918
  const docChunkMap = await chunkCandidatesForRerank(candidates, query, intent, options?.chunkStrategy);
4819
- if (skipRerank) {
4919
+ hooks?.onChunkDone?.(Date.now() - chunkStart);
4920
+ // Step 6: Rerank chunks (NOT full bodies). No reranker, or one past its deadline, means RRF order.
4921
+ const reranked = skipRerank
4922
+ ? null
4923
+ : await rerankWithDeadline(store, query, rerankDocuments(candidates, docChunkMap), intent, options);
4924
+ if (reranked === null) {
4820
4925
  // Skip LLM reranking — return candidates scored by RRF only
4821
4926
  const seenFiles = new Set();
4822
4927
  const rrfResults = candidates
@@ -4862,16 +4967,12 @@ export async function hybridQuery(store, rawQuery, options) {
4862
4967
  seenFiles.add(r.file);
4863
4968
  return true;
4864
4969
  })
4865
- .filter(r => r.score >= minScore)
4970
+ // minScore is a threshold on blended rerank scores; a deadline fallback's 1/rank scores would
4971
+ // cut a 0.5 threshold to two results, so only a caller that skipped rerank gets it applied here.
4972
+ .filter(r => !skipRerank || r.score >= minScore)
4866
4973
  .slice(0, limit);
4867
4974
  return attachResultMetadata(store.db, rrfResults);
4868
4975
  }
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
4976
  // Step 7: Blend RRF position score with reranker score
4876
4977
  // Position-aware weights: top retrieval results get more protection from reranker disagreement
4877
4978
  const candidateMap = new Map(candidates.map(c => [c.file, {
@@ -5040,6 +5141,7 @@ export async function structuredSearch(store, rawSearches, options) {
5040
5141
  // Helper to run search across collections (or all if undefined)
5041
5142
  const collectionList = collections ?? [undefined]; // undefined = all collections
5042
5143
  // Step 1: Run FTS for all lex searches (sync, instant)
5144
+ const ftsStart = Date.now();
5043
5145
  for (const search of searches) {
5044
5146
  if (search.type === 'lex') {
5045
5147
  for (const coll of collectionList) {
@@ -5061,6 +5163,8 @@ export async function structuredSearch(store, rawSearches, options) {
5061
5163
  }
5062
5164
  }
5063
5165
  }
5166
+ if (searches.some(s => s.type === 'lex'))
5167
+ hooks?.onFtsDone?.(Date.now() - ftsStart);
5064
5168
  // Step 2: Batch embed and run vector searches for vec/hyde
5065
5169
  if (hasVectors) {
5066
5170
  const vecSearches = searches.filter((s) => s.type === 'vec' || s.type === 'hyde');
@@ -5072,6 +5176,7 @@ export async function structuredSearch(store, rawSearches, options) {
5072
5176
  const embedStart = Date.now();
5073
5177
  const embeddings = await llm.embedBatch(textsToEmbed);
5074
5178
  hooks?.onEmbedDone?.(Date.now() - embedStart);
5179
+ const vecStart = Date.now();
5075
5180
  for (let i = 0; i < vecSearches.length; i++) {
5076
5181
  const embedding = embeddings[i]?.embedding;
5077
5182
  if (!embedding)
@@ -5093,6 +5198,7 @@ export async function structuredSearch(store, rawSearches, options) {
5093
5198
  }
5094
5199
  }
5095
5200
  }
5201
+ hooks?.onVecDone?.(Date.now() - vecStart);
5096
5202
  }
5097
5203
  }
5098
5204
  if (rankedLists.length === 0)
@@ -5111,8 +5217,14 @@ export async function structuredSearch(store, rawSearches, options) {
5111
5217
  const primaryQuery = searches.find(s => s.type === 'lex')?.query
5112
5218
  || searches.find(s => s.type === 'vec')?.query
5113
5219
  || searches[0]?.query || "";
5220
+ const chunkStart = Date.now();
5114
5221
  const docChunkMap = await chunkCandidatesForRerank(candidates, primaryQuery, intent, options?.chunkStrategy);
5115
- if (skipRerank) {
5222
+ hooks?.onChunkDone?.(Date.now() - chunkStart);
5223
+ // Step 5: Rerank chunks. No reranker, or one past its deadline, means RRF order.
5224
+ const reranked = skipRerank
5225
+ ? null
5226
+ : await rerankWithDeadline(store, primaryQuery, rerankDocuments(candidates, docChunkMap), intent, options);
5227
+ if (reranked === null) {
5116
5228
  // Skip LLM reranking — return candidates scored by RRF only
5117
5229
  const seenFiles = new Set();
5118
5230
  const rrfResults = candidates
@@ -5158,16 +5270,12 @@ export async function structuredSearch(store, rawSearches, options) {
5158
5270
  seenFiles.add(r.file);
5159
5271
  return true;
5160
5272
  })
5161
- .filter(r => r.score >= minScore)
5273
+ // minScore is a threshold on blended rerank scores; a deadline fallback's 1/rank scores would
5274
+ // cut a 0.5 threshold to two results, so only a caller that skipped rerank gets it applied here.
5275
+ .filter(r => !skipRerank || r.score >= minScore)
5162
5276
  .slice(0, limit);
5163
5277
  return attachResultMetadata(store.db, rrfResults);
5164
5278
  }
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
5279
  // Step 6: Blend RRF position score with reranker score
5172
5280
  const candidateMap = new Map(candidates.map(c => [c.file, {
5173
5281
  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.8",
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",