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 +65 -0
- package/README.en.md +1 -1
- package/README.md +1 -1
- package/dist/cli/build-info.json +2 -2
- package/dist/hangul.js +31 -10
- package/dist/index.d.ts +6 -0
- package/dist/index.js +5 -0
- package/dist/llm.d.ts +5 -0
- package/dist/llm.js +2 -0
- package/dist/mcp/daemon-health.d.ts +17 -0
- package/dist/mcp/daemon-health.js +24 -0
- package/dist/mcp/server.d.ts +1 -0
- package/dist/mcp/server.js +105 -10
- package/dist/query-log.d.ts +30 -0
- package/dist/query-log.js +9 -2
- package/dist/rerank-input.d.ts +16 -3
- package/dist/rerank-input.js +29 -13
- package/dist/store.d.ts +27 -10
- package/dist/store.js +131 -23
- package/package.json +1 -1
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
|
|
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
|
-
- **외래어 다리** — 기술 용어의 한글 표기(`서치`·`웹`·`코어`·`마이그레이션` 등 약
|
|
37
|
+
- **외래어 다리** — 기술 용어의 한글 표기(`서치`·`웹`·`코어`·`마이그레이션` 등 약 90개 내장 표)는 영문 표기도 함께 찾는다.
|
|
38
38
|
`하이브리드 서치`가 `hybrid-search` 노트를, `리액트 웹 코어`가 `react-web-core`를 찾는다. 질의 쪽만 바뀌므로 재색인은 필요 없다.
|
|
39
39
|
- **문자 체계가 섞인 토큰 분리** — `SKILL.md계약의핵심`은 `skill` AND `md` AND `계약의핵심`으로 나눈다.
|
|
40
40
|
- **긴 질의 완화** — 세 단어 이상 질의가 AND 로 한 건도 안 맞으면 같은 단어를 OR 로 다시 찾는다. 자연어 문장 질의가 0건이 되지 않는다.
|
package/dist/cli/build-info.json
CHANGED
package/dist/hangul.js
CHANGED
|
@@ -65,24 +65,34 @@ export function sharesHangulBigram(a, b) {
|
|
|
65
65
|
return false;
|
|
66
66
|
}
|
|
67
67
|
/**
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
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
|
-
|
|
73
|
+
function splitHangulNegation(query) {
|
|
75
74
|
if (query.includes('"') || !containsHangul(query))
|
|
76
|
-
return
|
|
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
|
|
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
|
|
85
|
-
return
|
|
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
|
+
}
|
package/dist/mcp/server.d.ts
CHANGED
|
@@ -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
|
};
|
package/dist/mcp/server.js
CHANGED
|
@@ -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()
|
|
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
|
|
1105
|
-
|
|
1106
|
-
|
|
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();
|
package/dist/query-log.d.ts
CHANGED
|
@@ -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
|
-
|
|
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();
|
package/dist/rerank-input.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
26
|
-
*
|
|
27
|
-
|
|
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. */
|
package/dist/rerank-input.js
CHANGED
|
@@ -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
|
-
*
|
|
20
|
-
*
|
|
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
|
|
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
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
4607
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|