ko-qmd 2.8.3-ko.8 → 2.8.3-ko.9
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 +33 -0
- package/README.md +2 -0
- package/dist/cli/build-info.json +2 -2
- package/dist/cli/qmd.js +2 -0
- package/dist/mcp/server.js +3 -0
- package/dist/store.d.ts +59 -2
- package/dist/store.js +195 -29
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,39 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
- Dependencies: `node-llama-cpp` 3.20.0 → **3.22.1**. ko-vault seam bench lines
|
|
6
|
+
are byte-identical before and after; Nix flake `qmd-node-modules` FOD hashes
|
|
7
|
+
refreshed for the new tree.
|
|
8
|
+
|
|
9
|
+
## [2.8.3-ko.9] - 2026-10-10
|
|
10
|
+
|
|
11
|
+
ko-qmd 2.8.3-ko.9 buys rank-1 accuracy and rerank latency at once. Per-document
|
|
12
|
+
coverage weighting on relaxed lex lists takes hard `hybrid_r1` from 0.4444 to
|
|
13
|
+
0.4778, and head-first rerank cascade halves rerank p50 with identical top-1
|
|
14
|
+
answers. A load timeout (default 120 s) stops one stuck model load from hanging
|
|
15
|
+
the query, as the scoring deadlines never covered the pre-ready wait.
|
|
16
|
+
|
|
17
|
+
- `runWithDeadline` now bounds the model-load wait itself (`QMD_LLM_LOAD_TIMEOUT_MS`,
|
|
18
|
+
default 120 s, `0` = wait forever). The scoring deadlines never covered a load
|
|
19
|
+
that reports ready never: the pre-ready race hung with no timer, so one stuck
|
|
20
|
+
load hung the query. Past the load bound the stage is skipped exactly like a
|
|
21
|
+
scoring deadline (same `timeouts` entry, same RRF-order / original-query
|
|
22
|
+
fallback, same abandoned-run cache fill), and the bounded wait is reported as
|
|
23
|
+
the `load` stage. Listed in `qmd doctor`.
|
|
24
|
+
- `QMD_RERANK_CASCADE=0` (or `false`/`off`/`no`) restores single-batch rerank.
|
|
25
|
+
Kill switch for measuring the cascade and for operations; unset cascades.
|
|
26
|
+
Listed in `qmd doctor`.
|
|
27
|
+
- Rerank now cascades: the RRF head (15) is scored first, and the tail is
|
|
28
|
+
skipped with score 0 (RRF position order only) when the head's top-2 margin
|
|
29
|
+
reaches 0.08 (`RERANK_CASCADE_FIRST_BATCH`/`RERANK_CASCADE_MARGIN` in
|
|
30
|
+
`src/store.ts`). A contested top scores everything, as before; deadlines and
|
|
31
|
+
the rerank cache are unchanged (a skipped tail writes no cache entries).
|
|
32
|
+
- `structuredSearch` and `hybridQuery` now weight a relaxed (any-word) lex list per
|
|
33
|
+
document by query coverage (`relaxedDocCoverage` in `src/store.ts`: 0.5 + 0.5 ×
|
|
34
|
+
matched units, mirroring the rerank chunk scorer's Hangul bigrams). A global
|
|
35
|
+
constant could not split the mirrored tie of `hmu-05` vs `sem-08` (2026-10-09
|
|
36
|
+
probe); both lead one list and take the same top-rank bonus, so only a per-doc
|
|
37
|
+
signal separates them. List weights are unchanged.
|
|
5
38
|
## [2.8.3-ko.8] - 2026-10-09
|
|
6
39
|
|
|
7
40
|
- `structuredSearch` (MCP `searches`, SDK `search({ queries })`) no longer lets a relaxed
|
package/README.md
CHANGED
|
@@ -169,6 +169,8 @@ API 전체: [docs/REFERENCE.md § SDK / Library Usage](docs/REFERENCE.md#sdk--li
|
|
|
169
169
|
|---|---|
|
|
170
170
|
| `QMD_QUERY_LOG` | `1`/`true`/`yes` 면 HTTP 데몬이 검색마다 질의 로그를 남긴다 |
|
|
171
171
|
| `QMD_LLM_IDLE_TIMEOUT_MS` | 모델을 내리기까지 idle ms (`0` = 내리지 않음) |
|
|
172
|
+
| `QMD_LLM_LOAD_TIMEOUT_MS` | 모델 로드 대기의 상한 ms (기본 120000, `0` = 무한 대기). 넘으면 그 단계 없이 답하고 로드는 백그라운드에서 계속된다 |
|
|
173
|
+
| `QMD_RERANK_CASCADE` | `0`/`false`/`off`/`no` 면 리랭크 cascade를 끄고 후보 전체를 한 번에 채점한다 |
|
|
172
174
|
| `QMD_RERANK_MAX_DOC_TOKENS` | 리랭커에 보내는 문서당 토큰 cap (요청의 `rerankMaxDocTokens` 가 우선) |
|
|
173
175
|
| `QMD_RERANK_CONTEXT_SIZE` · `QMD_EMBED_CONTEXT_SIZE` · `QMD_EXPAND_CONTEXT_SIZE` | 역할별 context 크기 |
|
|
174
176
|
| `QMD_EMBED_PARALLELISM` | 병렬 context 수 (높으면 RAM/VRAM 고갈) |
|
package/dist/cli/build-info.json
CHANGED
package/dist/cli/qmd.js
CHANGED
|
@@ -3583,6 +3583,8 @@ function collectEnvironmentOverrides(activeModels, configModels = {}) {
|
|
|
3583
3583
|
add("QMD_DOCTOR_DEVICE_PROBE", "controls qmd doctor native device probing; 0/off skips GPU probing");
|
|
3584
3584
|
add("QMD_EMBED_PARALLELISM", "overrides embedding parallel context count; too high can exhaust RAM/VRAM");
|
|
3585
3585
|
add("QMD_LLM_IDLE_TIMEOUT_MS", "ms of idle before models unload (0 = never); keeping them loaded holds their memory");
|
|
3586
|
+
add("QMD_LLM_LOAD_TIMEOUT_MS", "caps the model-load wait per stage (default 120000, 0 = wait forever); past it the stage is skipped");
|
|
3587
|
+
add("QMD_RERANK_CASCADE", "0/false/off/no disables head-first cascade rerank and scores all candidates at once");
|
|
3586
3588
|
add("QMD_EXPAND_CONTEXT_SIZE", "overrides query expansion context size; larger values use more memory");
|
|
3587
3589
|
add("QMD_RERANK_CONTEXT_SIZE", "overrides reranker context size; larger values use more memory");
|
|
3588
3590
|
add("QMD_RERANK_MAX_DOC_TOKENS", "caps tokens per document sent to the reranker; lower is faster but scores less text");
|
package/dist/mcp/server.js
CHANGED
|
@@ -128,6 +128,9 @@ function stageTimer() {
|
|
|
128
128
|
* ko-qmd: how long the daemon waits for query expansion and for the reranker before answering
|
|
129
129
|
* without them. QMD_EXPAND_DEADLINE_MS / QMD_RERANK_DEADLINE_MS: 0 = wait, a positive integer =
|
|
130
130
|
* that many ms, anything else = the default. One MCP query once took 85 s.
|
|
131
|
+
* The model-load wait itself is bounded separately by QMD_LLM_LOAD_TIMEOUT_MS
|
|
132
|
+
* (default 120 s, 0 = wait forever), so a load that never reports ready also
|
|
133
|
+
* falls back instead of hanging.
|
|
131
134
|
*/
|
|
132
135
|
const DEFAULT_EXPAND_DEADLINE_MS = 5_000;
|
|
133
136
|
const DEFAULT_RERANK_DEADLINE_MS = 10_000;
|
package/dist/store.d.ts
CHANGED
|
@@ -477,6 +477,7 @@ export type RRFContributionTrace = {
|
|
|
477
477
|
query: string;
|
|
478
478
|
rank: number;
|
|
479
479
|
weight: number;
|
|
480
|
+
multiplier?: number;
|
|
480
481
|
backendScore: number;
|
|
481
482
|
rrfContribution: number;
|
|
482
483
|
};
|
|
@@ -912,11 +913,32 @@ export declare function rerank(query: string, documents: {
|
|
|
912
913
|
file: string;
|
|
913
914
|
score: number;
|
|
914
915
|
}[]>;
|
|
915
|
-
|
|
916
|
+
/**
|
|
917
|
+
* Fraction of the query's units present in the document body, used to weight a
|
|
918
|
+
* relaxed (any-word) FTS list per document instead of one global constant
|
|
919
|
+
* (BASELINE.md 2026-10-09 follow-up: a global weight cannot split the mirrored
|
|
920
|
+
* tie of `hmu-05` vs `sem-08`).
|
|
921
|
+
*
|
|
922
|
+
* Units mirror the rerank chunk scorer: one per Hangul run of two or more
|
|
923
|
+
* syllables (a run of three or more also matches on any shared syllable
|
|
924
|
+
* bigram, so a particle-suffixed word meets its stem), one per Latin/digit run
|
|
925
|
+
* longer than two characters. Single syllables and short tokens carry no
|
|
926
|
+
* signal and are skipped. A query with no units covers everything (1.0) so it
|
|
927
|
+
* never punishes.
|
|
928
|
+
*/
|
|
929
|
+
export declare function relaxedDocCoverage(query: string, body: string): number;
|
|
930
|
+
/**
|
|
931
|
+
* Per-document RRF multipliers, aligned with `rankedLists`: a relaxed list
|
|
932
|
+
* scales each document by `0.5 + 0.5 × coverage`, every other list is untouched
|
|
933
|
+
* (`undefined` entry = multiplier 1.0). The 0.5 floor keeps a zero-coverage
|
|
934
|
+
* candidate in the pool instead of erasing it.
|
|
935
|
+
*/
|
|
936
|
+
export declare function rrfDocMultipliers(rankedLists: RankedResult[][], rankedListMeta: RankedListMeta[]): Array<Map<string, number> | undefined>;
|
|
937
|
+
export declare function reciprocalRankFusion(resultLists: RankedResult[][], weights?: number[], k?: number, docMultipliers?: Array<Map<string, number> | undefined>): RankedResult[];
|
|
916
938
|
/**
|
|
917
939
|
* Build per-document RRF contribution traces for explain/debug output.
|
|
918
940
|
*/
|
|
919
|
-
export declare function buildRrfTrace(resultLists: RankedResult[][], weights?: number[], listMeta?: RankedListMeta[], k?: number): Map<string, RRFScoreTrace>;
|
|
941
|
+
export declare function buildRrfTrace(resultLists: RankedResult[][], weights?: number[], listMeta?: RankedListMeta[], k?: number, docMultipliers?: Array<Map<string, number> | undefined>): Map<string, RRFScoreTrace>;
|
|
920
942
|
/**
|
|
921
943
|
* Find a document by filename/path, docid (#hash), or with fuzzy matching.
|
|
922
944
|
* Returns document metadata without body by default.
|
|
@@ -1104,6 +1126,41 @@ export declare function getStructuredRrfWeights(rankedListMeta: RankedListMeta[]
|
|
|
1104
1126
|
*/
|
|
1105
1127
|
export declare function blendRerankScore(rrfRank: number, rerankScore: number, candidateLimit: number): number;
|
|
1106
1128
|
export type DeadlineStage = "expand" | "rerank";
|
|
1129
|
+
/**
|
|
1130
|
+
* Bound on the model-load wait inside runWithDeadline: a load that never
|
|
1131
|
+
* reports ready must not hang the query. QMD_LLM_LOAD_TIMEOUT_MS sets it
|
|
1132
|
+
* (positive ms, 0 = wait forever, anything else = the default).
|
|
1133
|
+
*/
|
|
1134
|
+
export declare const DEFAULT_LOAD_TIMEOUT_MS = 120000;
|
|
1135
|
+
/** Load-timeout resolver for runWithDeadline; exported for tests. */
|
|
1136
|
+
export declare function resolveLoadTimeoutMs(): number;
|
|
1137
|
+
/** Rerank `chunks`, or null when the reranker misses `deadlineMs` (the caller then answers in RRF order). */
|
|
1138
|
+
export declare const RERANK_CASCADE_FIRST_BATCH = 15;
|
|
1139
|
+
/** Skip the tail past the first batch when the top-2 rerank margin reaches this. */
|
|
1140
|
+
export declare const RERANK_CASCADE_MARGIN = 0.08;
|
|
1141
|
+
/**
|
|
1142
|
+
* QMD_RERANK_CASCADE=0 (or false/off/no) restores single-batch rerank;
|
|
1143
|
+
* anything else (including unset) cascades. Kill switch for measurement and
|
|
1144
|
+
* operations; exported for tests.
|
|
1145
|
+
*/
|
|
1146
|
+
export declare function isRerankCascadeEnabled(): boolean;
|
|
1147
|
+
/**
|
|
1148
|
+
* True when the first cascade batch's top-2 margin is too tight to skip the
|
|
1149
|
+
* tail: a contested top means an unscored document could still win the blend.
|
|
1150
|
+
* A batch of fewer than two holds nothing to contest.
|
|
1151
|
+
*/
|
|
1152
|
+
export declare function cascadeNeedsSecondBatch(sortedScores: number[], margin?: number): boolean;
|
|
1153
|
+
export declare function rerankWithDeadline(store: Store, query: string, chunks: {
|
|
1154
|
+
file: string;
|
|
1155
|
+
text: string;
|
|
1156
|
+
}[], intent: string | undefined, options: {
|
|
1157
|
+
rerankMaxDocTokens?: number;
|
|
1158
|
+
rerankDeadlineMs?: number;
|
|
1159
|
+
hooks?: SearchHooks;
|
|
1160
|
+
} | undefined): Promise<{
|
|
1161
|
+
file: string;
|
|
1162
|
+
score: number;
|
|
1163
|
+
}[] | null>;
|
|
1107
1164
|
/**
|
|
1108
1165
|
* Hybrid search: BM25 + vector + query expansion + RRF + chunked reranking.
|
|
1109
1166
|
*
|
package/dist/store.js
CHANGED
|
@@ -3889,18 +3889,88 @@ export async function rerank(query, documents, model = DEFAULT_RERANK_MODEL, db,
|
|
|
3889
3889
|
// =============================================================================
|
|
3890
3890
|
// Reciprocal Rank Fusion
|
|
3891
3891
|
// =============================================================================
|
|
3892
|
-
|
|
3892
|
+
const COVERAGE_HANGUL_RUN_PATTERN = /\p{Script=Hangul}+/gu;
|
|
3893
|
+
const COVERAGE_LATIN_RUN_PATTERN = /[a-z0-9]+/gi;
|
|
3894
|
+
/**
|
|
3895
|
+
* Fraction of the query's units present in the document body, used to weight a
|
|
3896
|
+
* relaxed (any-word) FTS list per document instead of one global constant
|
|
3897
|
+
* (BASELINE.md 2026-10-09 follow-up: a global weight cannot split the mirrored
|
|
3898
|
+
* tie of `hmu-05` vs `sem-08`).
|
|
3899
|
+
*
|
|
3900
|
+
* Units mirror the rerank chunk scorer: one per Hangul run of two or more
|
|
3901
|
+
* syllables (a run of three or more also matches on any shared syllable
|
|
3902
|
+
* bigram, so a particle-suffixed word meets its stem), one per Latin/digit run
|
|
3903
|
+
* longer than two characters. Single syllables and short tokens carry no
|
|
3904
|
+
* signal and are skipped. A query with no units covers everything (1.0) so it
|
|
3905
|
+
* never punishes.
|
|
3906
|
+
*/
|
|
3907
|
+
export function relaxedDocCoverage(query, body) {
|
|
3908
|
+
const lower = body.toLowerCase();
|
|
3909
|
+
let total = 0;
|
|
3910
|
+
let matched = 0;
|
|
3911
|
+
for (const token of query.toLowerCase().split(/\s+/)) {
|
|
3912
|
+
if (!token)
|
|
3913
|
+
continue;
|
|
3914
|
+
for (const run of token.match(COVERAGE_HANGUL_RUN_PATTERN) ?? []) {
|
|
3915
|
+
const syllables = Array.from(run);
|
|
3916
|
+
if (syllables.length < 2)
|
|
3917
|
+
continue;
|
|
3918
|
+
total++;
|
|
3919
|
+
if (lower.includes(run)) {
|
|
3920
|
+
matched++;
|
|
3921
|
+
continue;
|
|
3922
|
+
}
|
|
3923
|
+
if (syllables.length >= 3) {
|
|
3924
|
+
for (let i = 0; i + 1 < syllables.length; i++) {
|
|
3925
|
+
if (lower.includes(syllables[i] + syllables[i + 1])) {
|
|
3926
|
+
matched++;
|
|
3927
|
+
break;
|
|
3928
|
+
}
|
|
3929
|
+
}
|
|
3930
|
+
}
|
|
3931
|
+
}
|
|
3932
|
+
for (const run of token.match(COVERAGE_LATIN_RUN_PATTERN) ?? []) {
|
|
3933
|
+
if (run.length <= 2)
|
|
3934
|
+
continue;
|
|
3935
|
+
total++;
|
|
3936
|
+
if (lower.includes(run))
|
|
3937
|
+
matched++;
|
|
3938
|
+
}
|
|
3939
|
+
}
|
|
3940
|
+
return total === 0 ? 1 : matched / total;
|
|
3941
|
+
}
|
|
3942
|
+
/**
|
|
3943
|
+
* Per-document RRF multipliers, aligned with `rankedLists`: a relaxed list
|
|
3944
|
+
* scales each document by `0.5 + 0.5 × coverage`, every other list is untouched
|
|
3945
|
+
* (`undefined` entry = multiplier 1.0). The 0.5 floor keeps a zero-coverage
|
|
3946
|
+
* candidate in the pool instead of erasing it.
|
|
3947
|
+
*/
|
|
3948
|
+
export function rrfDocMultipliers(rankedLists, rankedListMeta) {
|
|
3949
|
+
return rankedLists.map((list, i) => {
|
|
3950
|
+
const meta = rankedListMeta[i];
|
|
3951
|
+
if (!meta?.relaxed)
|
|
3952
|
+
return undefined;
|
|
3953
|
+
const multipliers = new Map();
|
|
3954
|
+
for (const r of list) {
|
|
3955
|
+
multipliers.set(r.file, 0.5 + 0.5 * relaxedDocCoverage(meta.query, r.body));
|
|
3956
|
+
}
|
|
3957
|
+
return multipliers;
|
|
3958
|
+
});
|
|
3959
|
+
}
|
|
3960
|
+
export function reciprocalRankFusion(resultLists, weights = [], k = 60, docMultipliers = []) {
|
|
3893
3961
|
const scores = new Map();
|
|
3894
3962
|
for (let listIdx = 0; listIdx < resultLists.length; listIdx++) {
|
|
3895
3963
|
const list = resultLists[listIdx];
|
|
3896
3964
|
if (!list)
|
|
3897
3965
|
continue;
|
|
3898
3966
|
const weight = weights[listIdx] ?? 1.0;
|
|
3967
|
+
const multipliers = docMultipliers[listIdx];
|
|
3899
3968
|
for (let rank = 0; rank < list.length; rank++) {
|
|
3900
3969
|
const result = list[rank];
|
|
3901
3970
|
if (!result)
|
|
3902
3971
|
continue;
|
|
3903
|
-
const
|
|
3972
|
+
const multiplier = multipliers?.get(result.file) ?? 1.0;
|
|
3973
|
+
const rrfContribution = (weight * multiplier) / (k + rank + 1);
|
|
3904
3974
|
const existing = scores.get(result.file);
|
|
3905
3975
|
if (existing) {
|
|
3906
3976
|
existing.rrfScore += rrfContribution;
|
|
@@ -3931,13 +4001,14 @@ export function reciprocalRankFusion(resultLists, weights = [], k = 60) {
|
|
|
3931
4001
|
/**
|
|
3932
4002
|
* Build per-document RRF contribution traces for explain/debug output.
|
|
3933
4003
|
*/
|
|
3934
|
-
export function buildRrfTrace(resultLists, weights = [], listMeta = [], k = 60) {
|
|
4004
|
+
export function buildRrfTrace(resultLists, weights = [], listMeta = [], k = 60, docMultipliers = []) {
|
|
3935
4005
|
const traces = new Map();
|
|
3936
4006
|
for (let listIdx = 0; listIdx < resultLists.length; listIdx++) {
|
|
3937
4007
|
const list = resultLists[listIdx];
|
|
3938
4008
|
if (!list)
|
|
3939
4009
|
continue;
|
|
3940
4010
|
const weight = weights[listIdx] ?? 1.0;
|
|
4011
|
+
const multipliers = docMultipliers[listIdx];
|
|
3941
4012
|
const meta = listMeta[listIdx] ?? {
|
|
3942
4013
|
source: "fts",
|
|
3943
4014
|
queryType: "original",
|
|
@@ -3948,7 +4019,8 @@ export function buildRrfTrace(resultLists, weights = [], listMeta = [], k = 60)
|
|
|
3948
4019
|
if (!result)
|
|
3949
4020
|
continue;
|
|
3950
4021
|
const rank = rank0 + 1; // 1-indexed rank for explain output
|
|
3951
|
-
const
|
|
4022
|
+
const multiplier = multipliers?.get(result.file) ?? 1.0;
|
|
4023
|
+
const contribution = (weight * multiplier) / (k + rank);
|
|
3952
4024
|
const existing = traces.get(result.file);
|
|
3953
4025
|
const detail = {
|
|
3954
4026
|
listIndex: listIdx,
|
|
@@ -3957,6 +4029,7 @@ export function buildRrfTrace(resultLists, weights = [], listMeta = [], k = 60)
|
|
|
3957
4029
|
query: meta.query,
|
|
3958
4030
|
rank,
|
|
3959
4031
|
weight,
|
|
4032
|
+
multiplier,
|
|
3960
4033
|
backendScore: result.score,
|
|
3961
4034
|
rrfContribution: contribution,
|
|
3962
4035
|
};
|
|
@@ -4667,26 +4740,45 @@ async function withDeadline(promise, deadlineMs) {
|
|
|
4667
4740
|
* skipped instead: this caps the backlog at one abandoned call per stage instead of one per query.
|
|
4668
4741
|
*/
|
|
4669
4742
|
const abandoned = new WeakMap();
|
|
4743
|
+
/**
|
|
4744
|
+
* Bound on the model-load wait inside runWithDeadline: a load that never
|
|
4745
|
+
* reports ready must not hang the query. QMD_LLM_LOAD_TIMEOUT_MS sets it
|
|
4746
|
+
* (positive ms, 0 = wait forever, anything else = the default).
|
|
4747
|
+
*/
|
|
4748
|
+
export const DEFAULT_LOAD_TIMEOUT_MS = 120_000;
|
|
4749
|
+
/** Load-timeout resolver for runWithDeadline; exported for tests. */
|
|
4750
|
+
export function resolveLoadTimeoutMs() {
|
|
4751
|
+
const raw = process.env.QMD_LLM_LOAD_TIMEOUT_MS?.trim();
|
|
4752
|
+
if (raw === undefined || raw === "")
|
|
4753
|
+
return DEFAULT_LOAD_TIMEOUT_MS;
|
|
4754
|
+
return /^\d+$/.test(raw) ? Number(raw) : DEFAULT_LOAD_TIMEOUT_MS;
|
|
4755
|
+
}
|
|
4670
4756
|
/**
|
|
4671
4757
|
* Run one LLM stage under a deadline. The clock starts once the stage's model is loaded
|
|
4672
4758
|
* (`onModelReady`): the deadline bounds computation, not a cold load, which is reported through
|
|
4673
|
-
* `onModelLoad`.
|
|
4674
|
-
*
|
|
4759
|
+
* `onModelLoad`. The load wait itself is bounded separately by resolveLoadTimeoutMs, so a load
|
|
4760
|
+
* that never reports ready times out instead of hanging. TIMED_OUT when either bound passes,
|
|
4761
|
+
* or at once while an earlier abandoned call of the stage is still running.
|
|
4762
|
+
* Unset or 0 deadline = plain await (still under the load bound).
|
|
4675
4763
|
*/
|
|
4676
4764
|
async function runWithDeadline(store, stage, deadlineMs, hooks, start) {
|
|
4677
4765
|
const started = Date.now();
|
|
4678
4766
|
let loadedAt;
|
|
4679
4767
|
const reportLoad = () => { if (loadedAt !== undefined)
|
|
4680
4768
|
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
4769
|
const byStage = abandoned.get(store) ?? { expand: { running: 0, warned: false }, rerank: { running: 0, warned: false } };
|
|
4687
4770
|
abandoned.set(store, byStage);
|
|
4688
4771
|
const state = byStage[stage];
|
|
4689
|
-
|
|
4772
|
+
const abandon = () => {
|
|
4773
|
+
state.running++;
|
|
4774
|
+
const settle = () => {
|
|
4775
|
+
state.running--;
|
|
4776
|
+
if (state.running === 0)
|
|
4777
|
+
state.warned = false;
|
|
4778
|
+
};
|
|
4779
|
+
pending.then(settle, settle);
|
|
4780
|
+
};
|
|
4781
|
+
if (deadlineMs && deadlineMs > 0 && state.running > 0) {
|
|
4690
4782
|
if (!state.warned) {
|
|
4691
4783
|
state.warned = true;
|
|
4692
4784
|
process.stderr.write(`qmd: a ${stage} abandoned at its deadline is still running — answering without ${stage} until it finishes\n`);
|
|
@@ -4697,30 +4789,101 @@ async function runWithDeadline(store, stage, deadlineMs, hooks, start) {
|
|
|
4697
4789
|
let markReady = () => { };
|
|
4698
4790
|
const ready = new Promise((resolve) => { markReady = () => { loadedAt = Date.now(); resolve(); }; });
|
|
4699
4791
|
const pending = start(markReady);
|
|
4700
|
-
//
|
|
4701
|
-
|
|
4792
|
+
// Bound the load wait itself: neither a stuck load nor a call that finishes
|
|
4793
|
+
// without reporting ready may hold the query past the load timeout. The
|
|
4794
|
+
// timer is cleared on every settle so idle processes keep no handle.
|
|
4795
|
+
const loadTimeoutMs = resolveLoadTimeoutMs();
|
|
4796
|
+
let loadTimer;
|
|
4797
|
+
const loadTimedOut = new Promise((resolve) => {
|
|
4798
|
+
if (loadTimeoutMs > 0) {
|
|
4799
|
+
loadTimer = setTimeout(() => resolve("load-timeout"), loadTimeoutMs);
|
|
4800
|
+
loadTimer.unref?.();
|
|
4801
|
+
}
|
|
4802
|
+
});
|
|
4803
|
+
const settled = await Promise.race([
|
|
4804
|
+
ready.then(() => "ready"),
|
|
4805
|
+
loadTimedOut,
|
|
4806
|
+
pending.then(() => "done", () => "done"),
|
|
4807
|
+
]);
|
|
4808
|
+
clearTimeout(loadTimer);
|
|
4809
|
+
if (settled === "load-timeout") {
|
|
4810
|
+
abandon();
|
|
4811
|
+
hooks?.onModelLoad?.(stage, Date.now() - started);
|
|
4812
|
+
hooks?.onDeadline?.(stage, Date.now() - started);
|
|
4813
|
+
return TIMED_OUT;
|
|
4814
|
+
}
|
|
4702
4815
|
reportLoad();
|
|
4703
|
-
if (
|
|
4816
|
+
if (settled === "done")
|
|
4817
|
+
return pending;
|
|
4818
|
+
if (!deadlineMs || deadlineMs <= 0)
|
|
4704
4819
|
return pending;
|
|
4705
4820
|
const result = await withDeadline(pending, deadlineMs);
|
|
4706
4821
|
if (result === TIMED_OUT) {
|
|
4707
|
-
|
|
4708
|
-
const settle = () => {
|
|
4709
|
-
state.running--;
|
|
4710
|
-
if (state.running === 0)
|
|
4711
|
-
state.warned = false;
|
|
4712
|
-
};
|
|
4713
|
-
pending.then(settle, settle);
|
|
4822
|
+
abandon();
|
|
4714
4823
|
hooks?.onDeadline?.(stage, Date.now() - (loadedAt ?? started));
|
|
4715
4824
|
}
|
|
4716
4825
|
return result;
|
|
4717
4826
|
}
|
|
4718
4827
|
/** Rerank `chunks`, or null when the reranker misses `deadlineMs` (the caller then answers in RRF order). */
|
|
4719
|
-
|
|
4828
|
+
export const RERANK_CASCADE_FIRST_BATCH = 15;
|
|
4829
|
+
/** Skip the tail past the first batch when the top-2 rerank margin reaches this. */
|
|
4830
|
+
export const RERANK_CASCADE_MARGIN = 0.08;
|
|
4831
|
+
/**
|
|
4832
|
+
* QMD_RERANK_CASCADE=0 (or false/off/no) restores single-batch rerank;
|
|
4833
|
+
* anything else (including unset) cascades. Kill switch for measurement and
|
|
4834
|
+
* operations; exported for tests.
|
|
4835
|
+
*/
|
|
4836
|
+
export function isRerankCascadeEnabled() {
|
|
4837
|
+
const raw = process.env.QMD_RERANK_CASCADE?.trim().toLowerCase();
|
|
4838
|
+
return raw !== "0" && raw !== "false" && raw !== "off" && raw !== "no";
|
|
4839
|
+
}
|
|
4840
|
+
/**
|
|
4841
|
+
* True when the first cascade batch's top-2 margin is too tight to skip the
|
|
4842
|
+
* tail: a contested top means an unscored document could still win the blend.
|
|
4843
|
+
* A batch of fewer than two holds nothing to contest.
|
|
4844
|
+
*/
|
|
4845
|
+
export function cascadeNeedsSecondBatch(sortedScores, margin = RERANK_CASCADE_MARGIN) {
|
|
4846
|
+
if (sortedScores.length < 2)
|
|
4847
|
+
return false;
|
|
4848
|
+
const [top, second] = [sortedScores[0], sortedScores[1]];
|
|
4849
|
+
if (!Number.isFinite(top) || !Number.isFinite(second))
|
|
4850
|
+
return true;
|
|
4851
|
+
return top - second < margin;
|
|
4852
|
+
}
|
|
4853
|
+
export async function rerankWithDeadline(store, query, chunks, intent, options) {
|
|
4720
4854
|
const hooks = options?.hooks;
|
|
4721
4855
|
hooks?.onRerankStart?.(chunks.length);
|
|
4722
4856
|
const start = Date.now();
|
|
4723
|
-
const
|
|
4857
|
+
const scoreBatch = (docs, deadlineMs) => runWithDeadline(store, "rerank", deadlineMs, hooks, (onModelReady) => store.rerank(query, docs, undefined, intent, { maxDocTokens: options?.rerankMaxDocTokens, onModelReady }));
|
|
4858
|
+
// Cascade: score the RRF head first. On a decisive top margin the tail keeps
|
|
4859
|
+
// score 0 (RRF position tiebreak only) instead of paying for 25 more
|
|
4860
|
+
// cross-encoder pairs; a contested top scores everything, as before.
|
|
4861
|
+
if (isRerankCascadeEnabled() && chunks.length > RERANK_CASCADE_FIRST_BATCH) {
|
|
4862
|
+
const first = chunks.slice(0, RERANK_CASCADE_FIRST_BATCH);
|
|
4863
|
+
const rest = chunks.slice(RERANK_CASCADE_FIRST_BATCH);
|
|
4864
|
+
const firstScored = await scoreBatch(first, options?.rerankDeadlineMs);
|
|
4865
|
+
if (firstScored === TIMED_OUT)
|
|
4866
|
+
return null;
|
|
4867
|
+
const headroom = [...firstScored].sort((a, b) => b.score - a.score).map((r) => r.score);
|
|
4868
|
+
if (!cascadeNeedsSecondBatch(headroom)) {
|
|
4869
|
+
hooks?.onRerankDone?.(Date.now() - start);
|
|
4870
|
+
return [...firstScored, ...rest.map((d) => ({ file: d.file, score: 0 }))];
|
|
4871
|
+
}
|
|
4872
|
+
// The second batch shares the caller's deadline: it gets only the remaining
|
|
4873
|
+
// budget, so the worst case stays one deadline, not two. An exhausted budget
|
|
4874
|
+
// is the same as a timeout — the caller falls back to RRF order.
|
|
4875
|
+
// Same "no deadline" rule as runWithDeadline: unset or <= 0 waits unbounded.
|
|
4876
|
+
const deadlineMs = options?.rerankDeadlineMs;
|
|
4877
|
+
const remaining = deadlineMs && deadlineMs > 0 ? deadlineMs - (Date.now() - start) : undefined;
|
|
4878
|
+
if (remaining !== undefined && remaining <= 0)
|
|
4879
|
+
return null;
|
|
4880
|
+
const restScored = await scoreBatch(rest, remaining);
|
|
4881
|
+
if (restScored === TIMED_OUT)
|
|
4882
|
+
return null;
|
|
4883
|
+
hooks?.onRerankDone?.(Date.now() - start);
|
|
4884
|
+
return [...firstScored, ...restScored];
|
|
4885
|
+
}
|
|
4886
|
+
const reranked = await scoreBatch(chunks, options?.rerankDeadlineMs);
|
|
4724
4887
|
if (reranked === TIMED_OUT)
|
|
4725
4888
|
return null;
|
|
4726
4889
|
hooks?.onRerankDone?.(Date.now() - start);
|
|
@@ -4906,9 +5069,11 @@ export async function hybridQuery(store, rawQuery, options) {
|
|
|
4906
5069
|
}
|
|
4907
5070
|
// Step 4: RRF fusion — original-query FTS and vector lists get 2x weight;
|
|
4908
5071
|
// expansion-derived lists stay at 1x independent of insertion order.
|
|
5072
|
+
// A relaxed list additionally scales each document by its query coverage.
|
|
4909
5073
|
const weights = getHybridRrfWeights(rankedListMeta);
|
|
4910
|
-
const
|
|
4911
|
-
const
|
|
5074
|
+
const docMultipliers = rrfDocMultipliers(rankedLists, rankedListMeta);
|
|
5075
|
+
const fused = reciprocalRankFusion(rankedLists, weights, 60, docMultipliers);
|
|
5076
|
+
const rrfTraceByFile = explain ? buildRrfTrace(rankedLists, weights, rankedListMeta, 60, docMultipliers) : null;
|
|
4912
5077
|
const candidates = fused.slice(0, candidateLimit);
|
|
4913
5078
|
if (candidates.length === 0)
|
|
4914
5079
|
return [];
|
|
@@ -5204,10 +5369,11 @@ export async function structuredSearch(store, rawSearches, options) {
|
|
|
5204
5369
|
if (rankedLists.length === 0)
|
|
5205
5370
|
return [];
|
|
5206
5371
|
// Step 3: RRF fusion — first list gets 2x weight (assume caller ordered by importance),
|
|
5207
|
-
// a relaxed FTS list half of its slot's weight
|
|
5372
|
+
// a relaxed FTS list half of its slot's weight, scaled per document by query coverage
|
|
5208
5373
|
const weights = getStructuredRrfWeights(rankedListMeta);
|
|
5209
|
-
const
|
|
5210
|
-
const
|
|
5374
|
+
const docMultipliers = rrfDocMultipliers(rankedLists, rankedListMeta);
|
|
5375
|
+
const fused = reciprocalRankFusion(rankedLists, weights, 60, docMultipliers);
|
|
5376
|
+
const rrfTraceByFile = explain ? buildRrfTrace(rankedLists, weights, rankedListMeta, 60, docMultipliers) : null;
|
|
5211
5377
|
const candidates = fused.slice(0, candidateLimit);
|
|
5212
5378
|
if (candidates.length === 0)
|
|
5213
5379
|
return [];
|
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.9",
|
|
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",
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
"@modelcontextprotocol/server": "2.0.0",
|
|
61
61
|
"better-sqlite3": "^13.0.3",
|
|
62
62
|
"fast-glob": "3.3.3",
|
|
63
|
-
"node-llama-cpp": "3.
|
|
63
|
+
"node-llama-cpp": "3.22.1",
|
|
64
64
|
"picomatch": "4.0.5",
|
|
65
65
|
"sqlite-vec": "0.1.9",
|
|
66
66
|
"tree-sitter-go": "0.25.0",
|