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 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 고갈) |
@@ -1,4 +1,4 @@
1
1
  {
2
- "commit": "6698cd5",
3
- "builtAt": "2026-10-09T05:15:35.019Z"
2
+ "commit": "ba437e0",
3
+ "builtAt": "2026-10-10T08:52:03.340Z"
4
4
  }
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");
@@ -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
- export declare function reciprocalRankFusion(resultLists: RankedResult[][], weights?: number[], k?: number): RankedResult[];
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
- export function reciprocalRankFusion(resultLists, weights = [], k = 60) {
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 rrfContribution = weight / (k + rank + 1);
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 contribution = weight / (k + rank);
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`. 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.
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
- if (state.running > 0) {
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
- // 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)]);
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 (finishedFirst)
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
- state.running++;
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
- async function rerankWithDeadline(store, query, chunks, intent, options) {
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 reranked = await runWithDeadline(store, "rerank", options?.rerankDeadlineMs, hooks, (onModelReady) => store.rerank(query, chunks, undefined, intent, { maxDocTokens: options?.rerankMaxDocTokens, onModelReady }));
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 fused = reciprocalRankFusion(rankedLists, weights);
4911
- const rrfTraceByFile = explain ? buildRrfTrace(rankedLists, weights, rankedListMeta) : null;
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 fused = reciprocalRankFusion(rankedLists, weights);
5210
- const rrfTraceByFile = explain ? buildRrfTrace(rankedLists, weights, rankedListMeta) : null;
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.8",
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.20.0",
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",