ko-qmd 2.8.3-ko.2 → 2.8.3-ko.3

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,130 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [2.8.3-ko.3] - 2026-09-18
6
+
7
+ The reranker now decides which document ranks first. The blend's retrieval-position
8
+ term could outweigh the entire reranker score, so the top retrieval hit was
9
+ unreachable; making position a 10% linear decay took recall@1 from .713 to .874 on
10
+ 223 name-like queries and from .467 to .867 on 30 paraphrase queries. The HTTP daemon
11
+ gains two operational knobs: `QMD_QUERY_LOG=1` records every search it serves to a
12
+ monthly JSONL, and `QMD_LLM_IDLE_TIMEOUT_MS=0` keeps models resident, which takes the
13
+ first search after an idle spell from ~1.8s to ~0.07s.
14
+
15
+ ### Changes
16
+
17
+ - `qmd query`'s reranker can now change which document comes first. The score
18
+ that orders results blended the reranker against a retrieval-position term of
19
+ `1 / rrfRank` under rank-gated weights, and that term falls 0.375 between rank
20
+ 1 and rank 2 — more than the 0.25 the entire reranker term could contribute.
21
+ The top retrieval hit was therefore unreachable: a document the reranker scored
22
+ 0.03 stayed ahead of one it scored 1.00. Position is now a linear decay across
23
+ the candidate window worth 10% of the blend, so it separates documents the
24
+ reranker scores alike instead of overruling it. Measured over a fixed candidate
25
+ dump per goldset, so every formula saw identical retrieval: recall@1 went .713
26
+ to .874 on 223 alias queries and .467 to .867 on 30 paraphrase queries. On both
27
+ goldsets the old blend's recall@1 equalled retrieval-only recall@1 to three
28
+ decimals, which is what an inert reranker looks like as a number.
29
+ - New bench tooling: `scripts/bench-vault-summarize.ts` writes a plain-language
30
+ summary per document with a local model, and `bench-vault-summary-apply.mjs`
31
+ builds the summary-augmented copy of a vault that the bench then indexes. This
32
+ measures whether the vocabulary gap the paraphrase goldset is made of closes at
33
+ index time. On 16 such queries, giving each document three everyday-language
34
+ lines took `search` (BM25, no LLM) from .250 to .562 recall@5 and the unreranked
35
+ hybrid path from .625 to .812. The reranked path did not move (.875 both ways,
36
+ the same two queries missed): it was already covering the gap, so the summaries
37
+ buy the cheap backends most of what the expensive one had. The BM25 number
38
+ carries no confound — that backend never calls query expansion. Summary lines
39
+ are filtered by document frequency, because a model that emits "이 도구는 뭘 하는
40
+ 건데?" would otherwise give every vague query a weak match on an unrelated
41
+ document.
42
+ - New bench tooling: `scripts/bench-dump-candidates.ts` records the RRF rank and
43
+ reranker score of every candidate for each goldset query, and
44
+ `scripts/bench-blend-sweep.mjs` re-scores that dump under different blend
45
+ formulas. Comparing two formulas used to mean two bench runs, which vary in
46
+ their retrieval and take about an hour each; it now costs one dump and a
47
+ millisecond per formula, and the variants are compared against the same
48
+ candidates.
49
+
50
+ - `bench-vault-goldset.mjs` adds two buckets that cost no model call: `alias`,
51
+ the display text of a `[[target|alias]]` wikilink, and `fmalias`, an entry of
52
+ the target's own `aliases:` frontmatter list. Both are human-written other
53
+ names for a document. First measurement on a 232-document vault (223 queries):
54
+ `full` scored 0.924 r@5 against `hybrid` 0.978 and `vector` 0.982 — of the 17
55
+ queries `full` missed, `hybrid` had 14 in its top five. The reranker demotes
56
+ short name-like queries that retrieval already found.
57
+ - `bench-vault-paraphrase.ts` writes its output after every document, so a run
58
+ killed from outside keeps what it had done.
59
+
60
+ - `QMD_LLM_IDLE_TIMEOUT_MS` sets how long loaded models stay idle before they
61
+ are unloaded (`0` = never), overriding the 5 minutes `createStore()` passes.
62
+ On a long-running HTTP daemon the first search after a quiet spell no longer
63
+ pays for reloading the models.
64
+ - `QMD_QUERY_LOG=1` makes the HTTP daemon append every `POST /query` /
65
+ `/search` search to `queries-YYYY-MM.jsonl` in the qmd cache directory: the
66
+ qmd version and commit, an index fingerprint, the searches, and each result's
67
+ path, score and rank — never snippets. Clients tag rows with `X-QMD-Tag`,
68
+ `X-QMD-Qid` and `X-QMD-Role`, and skip them with `X-QMD-No-Log: 1`. Writes
69
+ never fail a search; `qmd status` shows the newest log file and the MCP
70
+ `status` tool shows the daemon's flag and last write error.
71
+ - `scripts/dogfood.sh` builds the checkout, stops if bench-ko `bm25_r5` falls
72
+ below the newest BASELINE.md line, installs the packed tarball globally,
73
+ restarts the launchd daemon and smoke-tests it; `--restore` reinstalls the
74
+ published package.
75
+
76
+ - `develop` is the working branch; `main` only receives merges from it and is
77
+ where `/release` tags. `publish.yml` authenticates with the `NPM_TOKEN`
78
+ repository secret and skips a version that is already on the registry, so a
79
+ manually published version or a re-pushed tag still gets its GitHub release.
80
+ CI runs for `develop` pushes and pull requests too.
81
+
82
+ ### Fixes
83
+
84
+ - `bench-vault-report.mjs` writes its miss list next to the input, never on top
85
+ of it. The path came from `replace('bench.json', 'misses.json')`, a no-op for
86
+ any other file name, so reporting on a copied result overwrote that result.
87
+ - Query expansion drops sub-queries that have nothing to do with the query again.
88
+ The drift filter kept an expansion only if it shared a word with the query, but
89
+ it read the query as `[a-z0-9]` words, so a Korean query produced an empty word
90
+ list and the filter passed everything. Measured over 151 expansions of 30
91
+ Korean queries it rejected none, including 22 that were generic English
92
+ boilerplate ("Proper implementation follows established patterns and best
93
+ practices.") with no relation to the query — each one an embedding call and a
94
+ vector search spent on noise. A shared Hangul syllable bigram now counts as the
95
+ same kind of anchor: it drops those 22 and keeps 127 of the 129 Korean
96
+ expansions. A query with no anchor of either kind still keeps everything.
97
+ - Query expansion strips thinking tags from a sub-query before searching for it.
98
+ The grammar forbids newlines inside a line, so a `</think>` arrives welded to
99
+ the sub-query text rather than on a line of its own that parsing would skip; 20
100
+ of 46 hyde expansions in the same measurement carried one, and the tag was
101
+ embedded along with the text that mattered.
102
+ - Query expansion no longer runs the same sub-query twice on the same backend.
103
+ - `limit` no longer decides how wide a search looks, only how many results come
104
+ back. Both searches over-fetch by a multiple of `limit` — the FTS index by 10x
105
+ because the collection filter runs after the match, vectors by 3x or 30x
106
+ because chunks of one document collapse into one result — so a small `limit`
107
+ left a pool that post-filtering could exhaust, and `-n 5` was not the first five
108
+ of `-n 20`. The CLI made it visible: its default limit is 20 for `--json` and
109
+ `--files` and 5 otherwise, so the output format changed which documents were
110
+ found. Retrieval now looks at 20 documents at minimum whatever `limit` says,
111
+ which is the breadth `qmd query` already used internally.
112
+ - `bench-vault.sh` refuses to run when the vault it was given sits inside the
113
+ work directory it is about to `rm -rf`. The work directory is named after the
114
+ vault, so a vault under `tmp/real-bench/` resolved to the same path and the
115
+ corpus was deleted before it could be indexed, which showed up as a bench over
116
+ zero documents rather than as an error.
117
+ - `bench-vault-report.mjs` prints every backend that ran under each bucket, and
118
+ takes its miss list from the best backend rather than from BM25. The bucket
119
+ lines were BM25-only under a bare bucket name, so a run's `sem: 9/16` read as
120
+ the result of the pipeline the run was measuring.
121
+
122
+ - The Windows git-install probe failed on every push with `Permission denied
123
+ (publickey)`: npm and pnpm resolve any github.com dependency over SSH first
124
+ and do not retry an auth failure over https, the runner has no key, and the
125
+ repository is private so anonymous https fails as well. The job now rewrites
126
+ every github.com URL to https with the job token via git `insteadOf` before
127
+ installing.
128
+
5
129
  ## [2.8.3-ko.2] - 2026-09-17
6
130
 
7
131
  ### Changes
package/README.ko.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  - 패키지 이름: `ko-qmd` (npm 발행 — `v*` 태그 push 가 `publish.yml` 로 발행)
6
6
  - 버전 규칙: `<업스트림 버전>-ko.N` (예: `2.8.3-ko.0`)
7
- - 브랜치: `main` = 배포 브랜치(업스트림 릴리스 + 한국어 패치 스택). 업스트림은 `upstream` 원격으로만 따라간다 — 포크가 아니다.
7
+ - 브랜치: `develop` = 작업 브랜치(PR 은 여기로), `main` = 배포 브랜치(develop 을 머지한 뒤 `/release` 로 태그 → `publish.yml` 이 `NPM_TOKEN` 시크릿으로 발행). 업스트림은 `upstream` 원격으로만 따라간다 — 포크가 아니다.
8
8
 
9
9
  ## 업스트림과 다른 점
10
10
 
@@ -13,9 +13,12 @@
13
13
  - 질의: 따옴표 없는 한글 단어는 끝 조사 하나 또는 `기`를 뗀 어간도 매칭한다(`검색을` → `검색`, 어간 2음절 이상). 따옴표 구문·한자·가나는 그대로.
14
14
  - 색인: 한글 구간의 음절 bigram을 필드 끝에 덧붙인다. `FTS_CJK_NORMALIZED_VERSION`이 `"2"`라 기존 인덱스는 처음 열 때 FTS를 한 번 다시 만든다.
15
15
  3. 한국어 기본값
16
- - 기본 임베딩 모델: `hf:Qwen/Qwen3-Embedding-0.6B-GGUF/Qwen3-Embedding-0.6B-Q8_0.gguf`(업스트림은 embeddinggemma-300M). 리랭커·질의 확장 모델은 업스트림 기본값 그대로. 업스트림 [README.md](README.md)의 모델 표·"Custom Embedding Model" 절은 업스트림 기본값 기준이다.
16
+ - 기본 임베딩 모델: `hf:Qwen/Qwen3-Embedding-0.6B-GGUF/Qwen3-Embedding-0.6B-Q8_0.gguf`(업스트림은 embeddinggemma-300M). [README.md](README.md)의 모델 표·"Custom Embedding Model"·"Model Configuration" 절도 이 기본값 기준으로 고쳐 뒀다. 리랭커·질의 확장 모델은 업스트림 기본값 그대로.
17
17
  - 스킬 `skills/qmd/SKILL.md`에 "Korean queries" 절(`lex:`에 어간·alias·영문 용어, `vec:`에 한국어 패러프레이즈).
18
18
 
19
+ 4. 데몬 질의 로그 (`src/query-log.ts`, `server.ts` 접점 = REST 핸들러·`status` 툴): `QMD_QUERY_LOG=1` 로 띄운 HTTP 데몬이 REST 검색마다 `queries-YYYY-MM.jsonl` 에 한 줄을 남긴다(결과 경로·점수, 스니펫 없음). 헤더 `X-QMD-Tag`·`X-QMD-Qid`·`X-QMD-Role`·`X-QMD-No-Log`. 계약은 [README.md § Query log](README.md#query-log-ko-qmd). 업스트림 PR 대상 아님.
20
+ 5. 모델 idle 타임아웃 env (`src/llm.ts` `LlamaCpp` 생성자): `QMD_LLM_IDLE_TIMEOUT_MS` 가 `createStore()` 의 5분 고정값보다 우선한다. `0` 이면 모델을 내리지 않는다 — 상주 데몬의 첫 검색이 모델을 다시 올리지 않게 하는 용도다.
21
+
19
22
  ### 기본 임베딩 변경 후 재임베딩
20
23
 
21
24
  벡터는 모델 사이에 호환되지 않는다. embeddinggemma로 만든 기존 인덱스는 다시 임베딩해야 한다.
@@ -26,6 +29,19 @@ qmd embed -f
26
29
 
27
30
  embeddinggemma를 계속 쓰려면 `index.yml`의 `models: embed:` 또는 `QMD_EMBED_MODEL`로 지정한다.
28
31
 
32
+ ### dogfood — 작업 트리 빌드를 이 머신 데몬에 올리기
33
+
34
+ ```sh
35
+ bash scripts/dogfood.sh # build → bench-ko 게이트 → npm pack → npm i -g → 데몬 재시작 → 스모크
36
+ bash scripts/dogfood.sh --restore # 발행본(DOGFOOD_PIN_FILE 의 ko-qmd@<버전>, 없으면 latest)으로 되돌림
37
+ bash scripts/dogfood.sh --check "RESULT bm25_r5=…" # 게이트 판정만
38
+ ```
39
+
40
+ - 게이트: `bench-ko.sh` 의 `bm25_r5` 가 [BASELINE.md](test/fixtures/ko-vault/BASELINE.md) 의 마지막 bench-ko `RESULT` 줄보다 낮으면 설치 전에 멈춘다(exit 3). bench 는 소스를 tsx 로 돌리므로, 설치된 `dist/` 는 스모크(설치된 `qmd --version` 의 커밋 · `POST /query`)가 확인한다.
41
+ - `npm link` 가 아니라 pack 설치다 — 링크하면 데몬이 작업 트리의 `dist/` 를 서빙해 빌드 중에 깨질 수 있다.
42
+ - 머신 배선은 env: `DOGFOOD_LABEL`(launchd 라벨, 기본 `com.lemoncloud.qmd-daemon`) · `DOGFOOD_URL`(기본 `http://127.0.0.1:8181`) · `DOGFOOD_SMOKE`(추가 스모크 명령 — `"status":"hit"` JSON 을 내야 함) · `DOGFOOD_PIN_FILE`.
43
+ - 성공하면 `~/.cache/qmd/dogfood-deployed` 에 `<시각> <커밋>` 을 쓴다(`--restore` 가 지운다). 배치 중에는 설치된 바이너리가 발행본 핀과 다르다 — `qmd --version` 의 커밋이 구분자.
44
+
29
45
  ### ko-vault 벤치
30
46
 
31
47
  `bash scripts/bench-ko.sh` (픽스처 `test/fixtures/ko-vault/`, 문서 28·질의 36, 임베딩은 픽스처 `models.yml`의 embeddinggemma로 고정). 표와 run별 수치는 [BASELINE.md](test/fixtures/ko-vault/BASELINE.md).
@@ -48,6 +64,8 @@ npm install -g ko-qmd@<version>
48
64
  "ko-qmd": "<version>"
49
65
  ```
50
66
 
67
+ 버전은 `<업스트림 버전>-ko.N` 형태의 prerelease 라, `^`·`~` 범위는 같은 `major.minor.patch` 의 `-ko.*` 안에서만 움직인다(`^2.8.3-ko.0` 은 `2.8.4-ko.0` 을 안 집는다). 정확한 버전으로 핀한다.
68
+
51
69
  업스트림 `@tobilu/qmd` 와 bin 이름이 같아 둘을 함께 둘 수 없다 — `npm uninstall -g @tobilu/qmd` 뒤 설치한다. 미발행 커밋은 GitHub 에서 직접(`npm i github:tak-bro/ko-qmd#<sha>`) 받을 수 있고, 그때는 `prepare`가 `scripts/build.mjs`(= `tsc -p tsconfig.build.json`)로 `dist/`를 만든다. **설치에는 node + tsc만 필요하고 bun은 필요 없다.** 개발용 `npm run test:unit`은 vitest와 `bun test`를 둘 다 돌리므로 bun이 필요하다.
52
70
 
53
71
  설치 스모크(빈 디렉터리):
package/README.md CHANGED
@@ -1,7 +1,8 @@
1
1
  > **ko-qmd** (`npm i -g ko-qmd`) — a Korean-language distribution of [tobi/qmd](https://github.com/tobi/qmd) (MIT).
2
2
  > Hangul particle stripping and syllable-bigram FTS in the lex path, Qwen3-Embedding-0.6B as the
3
3
  > default embedding model. Everything below is upstream's README and still applies: the CLI, the MCP
4
- > server and the package binary are all called `qmd`.
4
+ > server and the package binary are all called `qmd`. Where it says `@tobilu/qmd` — install commands,
5
+ > SDK `import`s — use `ko-qmd`; the upstream name installs upstream, with none of the Hangul patches.
5
6
  > Korean notes, install and release process: [README.ko.md](README.ko.md).
6
7
 
7
8
  # QMD - Query Markup Documents
@@ -118,7 +119,7 @@ Although the tool works perfectly fine when you just tell your agent to use it o
118
119
  **Claude Code** — Install the plugin (recommended):
119
120
 
120
121
  ```bash
121
- claude plugin marketplace add tobi/qmd
122
+ claude plugin marketplace add tak-bro/ko-qmd
122
123
  claude plugin install qmd@qmd
123
124
  ```
124
125
 
@@ -160,6 +161,30 @@ The HTTP server exposes two endpoints:
160
161
  - `POST /query` (alias `/search`) — structured search without the MCP protocol
161
162
  - `GET /health` — liveness check with uptime
162
163
 
164
+ ##### Query log (ko-qmd)
165
+
166
+ Start the daemon with `QMD_QUERY_LOG=1` (`true`/`yes` also work; anything else
167
+ is off) and every search served by `POST /query` or `/search` is appended as
168
+ one JSON line to `$XDG_CACHE_HOME/qmd/queries-YYYY-MM.jsonl` (default
169
+ `~/.cache/qmd`, one file per local month, mode `0600`, never pruned). CLI
170
+ searches and MCP tool calls are not logged.
171
+
172
+ A row records the qmd version and commit, an index fingerprint (document count
173
+ and last update), the searches, collections, limit, rerank flag, each result's
174
+ `<collection>/<path>`, score and rank, and the elapsed milliseconds — no
175
+ snippets or document text. Clients can annotate rows with request headers:
176
+
177
+ | Header | Row field |
178
+ |--------|-----------|
179
+ | `X-QMD-Tag` | `client.tag` (first 32 characters) |
180
+ | `X-QMD-Qid` | `client.qid` (first 64 characters) — groups the requests of one lookup |
181
+ | `X-QMD-Role` | `client.role` — `primary`, `probe`, `canary` or `replay`; anything else is `null` |
182
+ | `X-QMD-No-Log` | `1` skips the row (tests, evaluation runs) |
183
+
184
+ Writes happen after the response and never fail a search. `qmd status` shows
185
+ the newest log file; the daemon's MCP `status` tool also shows whether logging
186
+ is on and the last write error.
187
+
163
188
 
164
189
  ##### Origin and Host validation
165
190
 
@@ -563,29 +588,29 @@ QMD uses three local GGUF models (auto-downloaded on first use):
563
588
 
564
589
  | Model | Purpose | Size |
565
590
  |-------|---------|------|
566
- | `embeddinggemma-300M-Q8_0` | Vector embeddings (default) | ~300MB |
591
+ | `Qwen3-Embedding-0.6B-Q8_0` | Vector embeddings (default) | ~640MB |
567
592
  | `qwen3-reranker-0.6b-q8_0` | Re-ranking | ~640MB |
568
- | `qmd-query-expansion-1.7B-q4_k_m` | Query expansion (fine-tuned) | ~1.1GB |
593
+ | `qmd-query-expansion-1.7B-q4_k_m` | Query expansion (fine-tuned) | ~1.3GB |
569
594
 
570
595
  Models are downloaded from HuggingFace and cached in `~/.cache/qmd/models/`.
571
596
 
572
597
  ### Custom Embedding Model
573
598
 
574
- Override the default embedding model via the `QMD_EMBED_MODEL` environment variable.
575
- This is useful for multilingual corpora (e.g. Chinese, Japanese, Korean) where
576
- `embeddinggemma-300M` has limited coverage.
599
+ The default is Qwen3-Embedding-0.6B — multilingual (119 languages including CJK),
600
+ MTEB top-ranked. Override it via the `QMD_EMBED_MODEL` environment variable, for
601
+ example to fall back to embeddinggemma-300M's smaller footprint on an English-only
602
+ corpus:
577
603
 
578
604
  ```sh
579
- # Use Qwen3-Embedding-0.6B for better multilingual (CJK) support
580
- export QMD_EMBED_MODEL="hf:Qwen/Qwen3-Embedding-0.6B-GGUF/Qwen3-Embedding-0.6B-Q8_0.gguf"
605
+ export QMD_EMBED_MODEL="hf:ggml-org/embeddinggemma-300M-GGUF/embeddinggemma-300M-Q8_0.gguf"
581
606
 
582
607
  # After changing the model, re-embed all collections:
583
608
  qmd embed -f
584
609
  ```
585
610
 
586
611
  Supported model families:
587
- - **embeddinggemma** (default) — English-optimized, small footprint
588
- - **Qwen3-Embedding** — Multilingual (119 languages including CJK), MTEB top-ranked
612
+ - **Qwen3-Embedding** (default) — Multilingual (119 languages including CJK), MTEB top-ranked
613
+ - **embeddinggemma** — English-optimized, small footprint
589
614
 
590
615
  > **Note:** When switching embedding models, you must re-index with `qmd embed -f`
591
616
  > since vectors are not cross-compatible between models. The prompt format is
@@ -602,8 +627,8 @@ bun install -g @tobilu/qmd
602
627
  ### Development
603
628
 
604
629
  ```sh
605
- git clone https://github.com/tobi/qmd
606
- cd qmd
630
+ git clone https://github.com/tak-bro/ko-qmd
631
+ cd ko-qmd
607
632
  npm install
608
633
  npm link
609
634
  ```
@@ -732,7 +757,7 @@ editor_uri: "vscode://file{path}:{line}:{col}"
732
757
  # built-in defaults. `qmd init` writes this block pre-filled with the
733
758
  # resolved defaults. See "Model Configuration" for the default URIs.
734
759
  models:
735
- embed: "hf:ggml-org/embeddinggemma-300M-GGUF/embeddinggemma-300M-Q8_0.gguf"
760
+ embed: "hf:Qwen/Qwen3-Embedding-0.6B-GGUF/Qwen3-Embedding-0.6B-Q8_0.gguf"
736
761
  rerank: "hf:ggml-org/Qwen3-Reranker-0.6B-Q8_0-GGUF/qwen3-reranker-0.6b-q8_0.gguf"
737
762
  generate: "hf:tobil/qmd-query-expansion-1.7B-gguf/qmd-query-expansion-1.7B-q4_k_m.gguf"
738
763
 
@@ -1155,6 +1180,8 @@ llm_cache -- Cached LLM responses (query expansion, rerank scores)
1155
1180
  | `QMD_CONFIG_DIR` | unset | Override the config directory outright (takes precedence over `XDG_CONFIG_HOME`) |
1156
1181
  | `QMD_LLAMA_GPU` | `auto` | Force llama.cpp GPU backend (`metal`, `vulkan`, `cuda`) or disable GPU with `false` |
1157
1182
  | `QMD_FORCE_CPU` | unset | Set to `1`/`true` to force CPU mode before any CUDA/Vulkan/Metal probing. Equivalent CLI flag: `--no-gpu`. |
1183
+ | `QMD_QUERY_LOG` | unset | ko-qmd: set to `1`/`true`/`yes` on the HTTP daemon to append each search to `$XDG_CACHE_HOME/qmd/queries-YYYY-MM.jsonl` (see [Query log](#query-log-ko-qmd)) |
1184
+ | `QMD_LLM_IDLE_TIMEOUT_MS` | `300000` | ko-qmd: idle time in ms before loaded models and contexts are unloaded; `0` never unloads. Overrides the SDK's `createStore()` timeout, so a long-running HTTP daemon can skip the model reload on its first search after a quiet spell, at the cost of keeping that memory in use. |
1158
1185
  | `QMD_EMBED_PARALLELISM` | automatic | Override embedding/reranking context parallelism (1-8). Windows CUDA defaults to `1` because parallel CUDA contexts can crash with `ggml-cuda.cu:98`; use Vulkan or raise this only if your driver is stable. |
1159
1186
 
1160
1187
  ## How It Works
@@ -1275,14 +1302,16 @@ Query ──► LLM Expansion ──► [Original, Variant 1, Variant 2]
1275
1302
  The default models are defined in `src/llm.ts` as HuggingFace URIs:
1276
1303
 
1277
1304
  ```typescript
1278
- const DEFAULT_EMBED_MODEL = "hf:ggml-org/embeddinggemma-300M-GGUF/embeddinggemma-300M-Q8_0.gguf";
1305
+ const DEFAULT_EMBED_MODEL = "hf:Qwen/Qwen3-Embedding-0.6B-GGUF/Qwen3-Embedding-0.6B-Q8_0.gguf";
1279
1306
  const DEFAULT_RERANK_MODEL = "hf:ggml-org/Qwen3-Reranker-0.6B-Q8_0-GGUF/qwen3-reranker-0.6b-q8_0.gguf";
1280
1307
  const DEFAULT_GENERATE_MODEL = "hf:tobil/qmd-query-expansion-1.7B-gguf/qmd-query-expansion-1.7B-q4_k_m.gguf";
1281
1308
  ```
1282
1309
 
1283
1310
  Override them per-role without touching source via the `models:` block in
1284
1311
  `index.yml` (see [Configuring `index.yml`](#configuring-indexyml)) or the
1285
- `QMD_EMBED_MODEL` env var. Re-run `qmd embed` after changing the embedding model.
1312
+ `QMD_EMBED_MODEL` / `QMD_RERANK_MODEL` / `QMD_GENERATE_MODEL` env vars. The
1313
+ config block wins over the env var, which wins over the built-in default.
1314
+ Re-run `qmd embed` after changing the embedding model.
1286
1315
 
1287
1316
  ### EmbeddingGemma Prompt Format
1288
1317
 
@@ -1,4 +1,4 @@
1
1
  {
2
- "commit": "3b53262",
3
- "builtAt": "2026-09-17T02:43:15.469Z"
2
+ "commit": "d4da081",
3
+ "builtAt": "2026-09-18T03:59:04.070Z"
4
4
  }
package/dist/cli/qmd.js CHANGED
@@ -13,6 +13,7 @@ import { getPwd, getRealPath, isPathInsideDir, homedir, resolve, enableProductio
13
13
  import { disposeDefaultLlamaCpp, getDefaultLlamaCpp, setDefaultLlamaCpp, LlamaCpp, withLLMSession, pullModels, DEFAULT_MODEL_CACHE_DIR, resolveEmbedModel, resolveGenerateModel, resolveRerankModel, resolveModels, inspectGgufFile, isDarwinMetalMitigationActive } from "../llm.js";
14
14
  import { formatSearchResults, formatDocuments, escapeXml, escapeCSV, } from "./formatter.js";
15
15
  import { resolveCommit } from "./version.js";
16
+ import { latestQueryLogFile } from "../query-log.js";
16
17
  import { getCollection as getCollectionFromYaml, listCollections as yamlListCollections, getDefaultCollectionNames, addContext as yamlAddContext, removeContext as yamlRemoveContext, removeCollection as yamlRemoveCollectionFn, renameCollection as yamlRenameCollectionFn, setGlobalContext, listAllContexts, setConfigIndexName, loadConfig, saveConfig, setConfigSource, findLocalConfigPath, getLocalDbPath, getConfigPath, configExists, } from "../collections.js";
17
18
  import { decideLocalConfigGate, gatedItems, hasGatedItems, isCollectionPathInsideProject, isLocalConfigPath, isLocalConfigTrustOptedIn, isTrusted, listTrusted, recordTrust, revokeTrust, sensitiveDigest, } from "../trust.js";
18
19
  // NOTE: enableProductionMode() is intentionally NOT called at module scope here.
@@ -384,6 +385,19 @@ async function showStatus() {
384
385
  // Stale / recycled PID file cleaned up silently
385
386
  }
386
387
  }
388
+ // ko-qmd: the daemon's query log. This process cannot see the daemon's env,
389
+ // so report what is on disk; the daemon's own `status` tool shows its flag and errors.
390
+ const queryLogFile = latestQueryLogFile();
391
+ if (queryLogFile.kind === "file") {
392
+ // File name only: status keeps filesystem paths out of its output (the Index line aside).
393
+ console.log(`Query log: ${basename(queryLogFile.path)} (last write ${formatTimeAgo(queryLogFile.modified)})`);
394
+ }
395
+ else if (queryLogFile.kind === "error") {
396
+ console.log(`Query log: ${c.yellow}unreadable — ${queryLogFile.message}${c.reset}`);
397
+ }
398
+ else {
399
+ console.log(`Query log: ${c.dim}none (daemon writes it when started with QMD_QUERY_LOG=1)${c.reset}`);
400
+ }
387
401
  console.log("");
388
402
  const orphanedVectors = countOrphanedVectors(db);
389
403
  console.log(`${c.bold}Documents${c.reset}`);
@@ -3333,7 +3347,8 @@ function collectEnvironmentOverrides(activeModels, configModels = {}) {
3333
3347
  add("INDEX_PATH", "overrides the SQLite index path; QMD reads/writes a different database");
3334
3348
  add("QMD_CONFIG_DIR", "overrides the QMD config directory and takes precedence over XDG_CONFIG_HOME");
3335
3349
  add("XDG_CONFIG_HOME", "moves QMD config to $XDG_CONFIG_HOME/qmd when QMD_CONFIG_DIR is not set");
3336
- add("XDG_CACHE_HOME", "moves the default index cache, model cache, and MCP daemon PID files");
3350
+ add("XDG_CACHE_HOME", "moves the default index cache, model cache, MCP daemon PID files, and query logs");
3351
+ add("QMD_QUERY_LOG", "1/true/yes makes the HTTP daemon append each search to queries-YYYY-MM.jsonl in the cache dir");
3337
3352
  addModel("QMD_EMBED_MODEL", "embed", activeModels.embed);
3338
3353
  addModel("QMD_GENERATE_MODEL", "generate", activeModels.generate);
3339
3354
  addModel("QMD_RERANK_MODEL", "rerank", activeModels.rerank);
@@ -3341,6 +3356,7 @@ function collectEnvironmentOverrides(activeModels, configModels = {}) {
3341
3356
  add("QMD_LLAMA_GPU", "selects llama.cpp GPU backend (metal/cuda/vulkan) or disables GPU when set to false/off/0");
3342
3357
  add("QMD_DOCTOR_DEVICE_PROBE", "controls qmd doctor native device probing; 0/off skips GPU probing");
3343
3358
  add("QMD_EMBED_PARALLELISM", "overrides embedding parallel context count; too high can exhaust RAM/VRAM");
3359
+ add("QMD_LLM_IDLE_TIMEOUT_MS", "ms of idle before models unload (0 = never); keeping them loaded holds their memory");
3344
3360
  add("QMD_EXPAND_CONTEXT_SIZE", "overrides query expansion context size; larger values use more memory");
3345
3361
  add("QMD_RERANK_CONTEXT_SIZE", "overrides reranker context size; larger values use more memory");
3346
3362
  add("QMD_EMBED_CONTEXT_SIZE", "overrides embed context size; larger values use more memory");
package/dist/hangul.d.ts CHANGED
@@ -12,6 +12,15 @@
12
12
  * one run stay contiguous.
13
13
  */
14
14
  export declare function hangulBigramTail(text: string): string;
15
+ /**
16
+ * Whether two texts share a Hangul syllable bigram (`검색을 고치기` and `문서 검색`
17
+ * share `검색`). Two Korean sentences about the same thing overlap in syllables long
18
+ * before they overlap in whole words, because the words carry different particles and
19
+ * endings, so a bigram is the smallest unit that says "these are about the same thing"
20
+ * without stemming both sides. Returns false when either side has no Hangul run of two
21
+ * or more syllables.
22
+ */
23
+ export declare function sharesHangulBigram(a: string, b: string): boolean;
15
24
  /**
16
25
  * Strip one trailing particle (or 기) from a pure-Hangul word when the stem
17
26
  * keeps at least two syllables (`검색을` → `검색`, `책을` stays). Returns null
package/dist/hangul.js CHANGED
@@ -36,6 +36,25 @@ export function hangulBigramTail(text) {
36
36
  const runs = text.match(HANGUL_RUN_PATTERN) ?? [];
37
37
  return runs.flatMap(syllableBigrams).join(" ");
38
38
  }
39
+ /**
40
+ * Whether two texts share a Hangul syllable bigram (`검색을 고치기` and `문서 검색`
41
+ * share `검색`). Two Korean sentences about the same thing overlap in syllables long
42
+ * before they overlap in whole words, because the words carry different particles and
43
+ * endings, so a bigram is the smallest unit that says "these are about the same thing"
44
+ * without stemming both sides. Returns false when either side has no Hangul run of two
45
+ * or more syllables.
46
+ */
47
+ export function sharesHangulBigram(a, b) {
48
+ const left = new Set((a.match(HANGUL_RUN_PATTERN) ?? []).flatMap(syllableBigrams));
49
+ if (left.size === 0)
50
+ return false;
51
+ for (const run of b.match(HANGUL_RUN_PATTERN) ?? []) {
52
+ for (const gram of syllableBigrams(run))
53
+ if (left.has(gram))
54
+ return true;
55
+ }
56
+ return false;
57
+ }
39
58
  /**
40
59
  * Strip one trailing particle (or 기) from a pure-Hangul word when the stem
41
60
  * keeps at least two syllables (`검색을` → `검색`, `책을` stays). Returns null
package/dist/index.js CHANGED
@@ -78,7 +78,7 @@ export async function createStore(options) {
78
78
  }
79
79
  // else: DB-only mode — no external config, use existing store_collections
80
80
  // Create a per-store LlamaCpp instance — lazy-loads models on first use,
81
- // auto-unloads after 5 min inactivity to free VRAM.
81
+ // auto-unloads after 5 min inactivity to free VRAM (QMD_LLM_IDLE_TIMEOUT_MS overrides).
82
82
  const llm = new LlamaCpp({
83
83
  embedModel: config?.models?.embed,
84
84
  generateModel: config?.models?.generate,
package/dist/llm.d.ts CHANGED
@@ -157,6 +157,26 @@ export type Queryable = {
157
157
  type: QueryType;
158
158
  text: string;
159
159
  };
160
+ /**
161
+ * Parse the expansion model's "type: text" lines into sub-queries, dropping the ones
162
+ * that would cost a search and return nothing useful.
163
+ *
164
+ * Three rejections, in order: a line whose text is empty once thinking tags are removed;
165
+ * a line that drifted off the query; and a line repeating a sub-query already kept for
166
+ * the same backend, which is the same search run twice.
167
+ *
168
+ * The drift rule needs an anchor shared with the query. Upstream reads [a-z0-9] words,
169
+ * so for a Hangul query the anchor list came out empty and the rule passed everything —
170
+ * measured 2026-09-18 over 151 expansions of 30 Korean queries it rejected none, keeping
171
+ * 22 that were generic English boilerplate ("Proper implementation follows established
172
+ * patterns and best practices.") unrelated to the query. Accepting a shared Hangul
173
+ * syllable bigram as an anchor drops those 22 and 2 of the 129 Korean expansions, both
174
+ * sentence fragments with no content word.
175
+ *
176
+ * A query with no anchor of either kind (a lone syllable, a symbol) keeps every line, as
177
+ * before: there is nothing to compare against.
178
+ */
179
+ export declare function parseExpansionLines(query: string, raw: string): Queryable[];
160
180
  /**
161
181
  * Document to rerank
162
182
  */
@@ -264,6 +284,13 @@ export type LlamaCppConfig = {
264
284
  */
265
285
  disposeModelsOnInactivity?: boolean;
266
286
  };
287
+ /**
288
+ * ko-qmd: QMD_LLM_IDLE_TIMEOUT_MS overrides the idle unload timeout, including
289
+ * the 5 minutes createStore() passes. A long-lived daemon sets it to keep the
290
+ * first search after a quiet spell from reloading models (0 = never unload).
291
+ * Returns undefined when unset or invalid (with a warning).
292
+ */
293
+ export declare function resolveIdleTimeoutOverride(envValue?: string | undefined): number | undefined;
267
294
  export type LlamaGpuMode = "auto" | "metal" | "vulkan" | "cuda" | false;
268
295
  type ParallelismOptions = {
269
296
  gpu: string | false;
package/dist/llm.js CHANGED
@@ -48,6 +48,7 @@ import { homedir } from "os";
48
48
  import { dirname, join } from "path";
49
49
  import { accessSync, constants, existsSync, mkdirSync, statSync, unlinkSync, readdirSync, readFileSync, writeFileSync, openSync, readSync, closeSync } from "fs";
50
50
  import { createRequire } from "node:module";
51
+ import { hangulBigramTail, sharesHangulBigram } from "./hangul.js";
51
52
  // =============================================================================
52
53
  // Embedding Formatting Functions
53
54
  // =============================================================================
@@ -105,6 +106,76 @@ export function canWriteLlamaDir(pkgDir) {
105
106
  return false;
106
107
  }
107
108
  }
109
+ /**
110
+ * Thinking tags welded onto an expansion line.
111
+ *
112
+ * The model is prompted with /no_think and the grammar forbids newlines inside a line,
113
+ * so a thinking block cannot arrive as a line of its own — it arrives attached to a
114
+ * sub-query, usually as a trailing "</think>" the grammar had no reason to reject.
115
+ * Measured 2026-09-18 on 30 Korean queries, 20 of 46 hyde expansions carried one; left
116
+ * in, the tag is embedded and searched for along with the text that matters.
117
+ */
118
+ function stripThinkTags(text) {
119
+ return text
120
+ .replace(/<think>[\s\S]*?<\/think>/g, " ")
121
+ .replace(/<\/?think>/g, " ")
122
+ .replace(/\s+/g, " ")
123
+ .trim();
124
+ }
125
+ /**
126
+ * Parse the expansion model's "type: text" lines into sub-queries, dropping the ones
127
+ * that would cost a search and return nothing useful.
128
+ *
129
+ * Three rejections, in order: a line whose text is empty once thinking tags are removed;
130
+ * a line that drifted off the query; and a line repeating a sub-query already kept for
131
+ * the same backend, which is the same search run twice.
132
+ *
133
+ * The drift rule needs an anchor shared with the query. Upstream reads [a-z0-9] words,
134
+ * so for a Hangul query the anchor list came out empty and the rule passed everything —
135
+ * measured 2026-09-18 over 151 expansions of 30 Korean queries it rejected none, keeping
136
+ * 22 that were generic English boilerplate ("Proper implementation follows established
137
+ * patterns and best practices.") unrelated to the query. Accepting a shared Hangul
138
+ * syllable bigram as an anchor drops those 22 and 2 of the 129 Korean expansions, both
139
+ * sentence fragments with no content word.
140
+ *
141
+ * A query with no anchor of either kind (a lone syllable, a symbol) keeps every line, as
142
+ * before: there is nothing to compare against.
143
+ */
144
+ export function parseExpansionLines(query, raw) {
145
+ // Single characters are not anchors: a query containing "1:1" would otherwise keep
146
+ // every expansion with a digit in it, which is most of them.
147
+ const queryTerms = query.toLowerCase().replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter(t => t.length >= 2);
148
+ const queryHasHangul = hangulBigramTail(query).length > 0;
149
+ const onQuery = (text) => {
150
+ if (queryTerms.length === 0 && !queryHasHangul)
151
+ return true;
152
+ const lower = text.toLowerCase();
153
+ if (queryTerms.some(term => lower.includes(term)))
154
+ return true;
155
+ return queryHasHangul && sharesHangulBigram(query, text);
156
+ };
157
+ const seen = new Set();
158
+ const queryables = [];
159
+ for (const line of raw.trim().split("\n")) {
160
+ const colonIdx = line.indexOf(":");
161
+ if (colonIdx === -1)
162
+ continue;
163
+ const type = line.slice(0, colonIdx).trim();
164
+ if (type !== "lex" && type !== "vec" && type !== "hyde")
165
+ continue;
166
+ const text = stripThinkTags(line.slice(colonIdx + 1));
167
+ if (!text)
168
+ continue;
169
+ if (!onQuery(text))
170
+ continue;
171
+ const key = `${type}\n${text.toLowerCase()}`;
172
+ if (seen.has(key))
173
+ continue;
174
+ seen.add(key);
175
+ queryables.push({ type, text });
176
+ }
177
+ return queryables;
178
+ }
108
179
  // =============================================================================
109
180
  // Model Configuration
110
181
  // =============================================================================
@@ -338,6 +409,22 @@ export async function pullModels(models, options = {}) {
338
409
  */
339
410
  // Default inactivity timeout: 5 minutes (keep models warm during typical search sessions)
340
411
  const DEFAULT_INACTIVITY_TIMEOUT_MS = 5 * 60 * 1000;
412
+ /**
413
+ * ko-qmd: QMD_LLM_IDLE_TIMEOUT_MS overrides the idle unload timeout, including
414
+ * the 5 minutes createStore() passes. A long-lived daemon sets it to keep the
415
+ * first search after a quiet spell from reloading models (0 = never unload).
416
+ * Returns undefined when unset or invalid (with a warning).
417
+ */
418
+ export function resolveIdleTimeoutOverride(envValue = process.env.QMD_LLM_IDLE_TIMEOUT_MS) {
419
+ const normalized = envValue?.trim() ?? "";
420
+ if (!normalized)
421
+ return undefined;
422
+ if (!/^\d+$/.test(normalized)) {
423
+ process.stderr.write(`QMD Warning: invalid QMD_LLM_IDLE_TIMEOUT_MS="${envValue}", using the default idle timeout.\n`);
424
+ return undefined;
425
+ }
426
+ return Number(normalized);
427
+ }
341
428
  const DEFAULT_EXPAND_CONTEXT_SIZE = 2048;
342
429
  export function resolveParallelismOverride(envValue = process.env.QMD_EMBED_PARALLELISM) {
343
430
  const normalized = envValue?.trim() ?? "";
@@ -509,7 +596,8 @@ export class LlamaCpp {
509
596
  this.rerankModelUri = resolveRerankModel({ rerank: config.rerankModel });
510
597
  this.modelCacheDir = config.modelCacheDir || MODEL_CACHE_DIR;
511
598
  this.expandContextSize = resolveExpandContextSize(config.expandContextSize);
512
- this.inactivityTimeoutMs = config.inactivityTimeoutMs ?? DEFAULT_INACTIVITY_TIMEOUT_MS;
599
+ this.inactivityTimeoutMs = resolveIdleTimeoutOverride()
600
+ ?? config.inactivityTimeoutMs ?? DEFAULT_INACTIVITY_TIMEOUT_MS;
513
601
  this.disposeModelsOnInactivity = config.disposeModelsOnInactivity ?? false;
514
602
  }
515
603
  get embedModelName() {
@@ -1244,27 +1332,7 @@ export class LlamaCpp {
1244
1332
  presencePenalty: 0.5,
1245
1333
  },
1246
1334
  });
1247
- const lines = result.trim().split("\n");
1248
- const queryLower = query.toLowerCase();
1249
- const queryTerms = queryLower.replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter(Boolean);
1250
- const hasQueryTerm = (text) => {
1251
- const lower = text.toLowerCase();
1252
- if (queryTerms.length === 0)
1253
- return true;
1254
- return queryTerms.some(term => lower.includes(term));
1255
- };
1256
- const queryables = lines.map(line => {
1257
- const colonIdx = line.indexOf(":");
1258
- if (colonIdx === -1)
1259
- return null;
1260
- const type = line.slice(0, colonIdx).trim();
1261
- if (type !== 'lex' && type !== 'vec' && type !== 'hyde')
1262
- return null;
1263
- const text = line.slice(colonIdx + 1).trim();
1264
- if (!hasQueryTerm(text))
1265
- return null;
1266
- return { type: type, text };
1267
- }).filter((q) => q !== null);
1335
+ const queryables = parseExpansionLines(query, result);
1268
1336
  // Filter out lex entries if not requested
1269
1337
  const filtered = includeLexical ? queryables : queryables.filter(q => q.type !== 'lex');
1270
1338
  if (filtered.length > 0)
@@ -19,6 +19,7 @@ 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 { entryFromRest, logQuery, queryLogStatus } from "../query-log.js";
22
23
  // =============================================================================
23
24
  // Helper functions
24
25
  // =============================================================================
@@ -457,9 +458,12 @@ Intent-aware lex (C++ performance, not sports):
457
458
  for (const col of status.collections) {
458
459
  summary.push(` - ${col.name}: ${col.path} (${col.documents} docs)`);
459
460
  }
461
+ // ko-qmd: daemon query log state (only this process knows its env and write errors).
462
+ const queryLog = queryLogStatus();
463
+ summary.push(` Query log (REST /query only): ${queryLog.enabled ? queryLog.path : "off"} (last write: ${queryLog.lastWrite ?? "none"}${queryLog.lastError ? `, last error: ${queryLog.lastError}` : ""})`);
460
464
  return {
461
465
  content: [{ type: "text", text: summary.join('\n') }],
462
- structuredContent: status,
466
+ structuredContent: { ...status, queryLog },
463
467
  };
464
468
  }));
465
469
  return server;
@@ -803,6 +807,8 @@ export async function startMcpHttpServer(port, options = {}) {
803
807
  });
804
808
  nodeRes.writeHead(200, { "Content-Type": "application/json" });
805
809
  nodeRes.end(JSON.stringify({ results: formatted }));
810
+ // ko-qmd: opt-in query log (QMD_QUERY_LOG), written after the response.
811
+ logQuery(entryFromRest({ path: pathname, headers: nodeReq.headers, searches: queries, params, collections: effectiveCollections, results: formatted, ms: Date.now() - reqStart }), store);
806
812
  log(`${ts()} POST /query ${params.searches.length} queries (${Date.now() - reqStart}ms)`);
807
813
  return;
808
814
  }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Daemon query log — an opt-in JSONL record of searches served by the HTTP
3
+ * daemon's REST `/query`·`/search` endpoints, so a user dogfooding a build can
4
+ * mine their real queries for misses. MCP tool calls are not logged yet.
5
+ *
6
+ * - Off unless `QMD_QUERY_LOG` is `1`, `true` or `yes` (case-insensitive).
7
+ * CLI searches never call this module.
8
+ * - One file per local month: `$XDG_CACHE_HOME/qmd/queries-YYYY-MM.jsonl`
9
+ * (default `~/.cache/qmd`), created 0600. Nothing is ever deleted.
10
+ * - Rows hold queries, result paths and scores — never snippets or bodies.
11
+ * - Fail-open: callers do not await and nothing here throws; a failure never
12
+ * reaches the search response. The first failure per process prints one
13
+ * stderr line; `queryLogStatus()` keeps the latest failure until the next
14
+ * successful write.
15
+ * - Clients annotate rows with `X-QMD-Tag` / `X-QMD-Qid` / `X-QMD-Role`, and
16
+ * skip logging with `X-QMD-No-Log: 1`. Header values are untrusted.
17
+ *
18
+ * Env and paths are read at call time so tests can redirect XDG_CACHE_HOME.
19
+ */
20
+ import type { IncomingHttpHeaders } from "node:http";
21
+ import type { IndexStatus } from "./store.js";
22
+ export declare const QUERY_LOG_SCHEMA_VERSION = 1;
23
+ type HeaderSource = Headers | IncomingHttpHeaders;
24
+ type StatusSource = {
25
+ getStatus(): Promise<IndexStatus>;
26
+ };
27
+ export type QueryLogEntry = {
28
+ via: "rest" | "mcp";
29
+ tool: string;
30
+ headers: HeaderSource;
31
+ searches: {
32
+ type: string;
33
+ query: string;
34
+ }[];
35
+ collections: string[] | null;
36
+ limit: number;
37
+ /** null = the request left reranking to the daemon default */
38
+ rerank: boolean | null;
39
+ results: {
40
+ file: string;
41
+ score: number;
42
+ }[];
43
+ ms: number;
44
+ /** Injectable clock for tests; defaults to the time of the call. */
45
+ now?: Date;
46
+ };
47
+ export type QueryLogStatus = {
48
+ enabled: boolean;
49
+ path: string;
50
+ lastWrite: string | null;
51
+ lastError: string | null;
52
+ };
53
+ /** Path of the log file for the local month containing `date`. */
54
+ export declare const queryLogPath: (date?: Date) => string;
55
+ /**
56
+ * Queue one row. Returns immediately; the write happens after the response.
57
+ * No-op unless the flag is on and the request did not ask to be skipped.
58
+ * Never throws — the REST handler calls this after the response has ended.
59
+ */
60
+ export declare const logQuery: (entry: QueryLogEntry, store: StatusSource) => void;
61
+ /** Resolves once every queued row has been written or has failed. */
62
+ export declare const flushQueryLog: () => Promise<void>;
63
+ /** In-process view (the daemon's own `status` tool). */
64
+ export declare const queryLogStatus: () => QueryLogStatus;
65
+ export type QueryLogFileState = {
66
+ kind: "none";
67
+ } | {
68
+ kind: "file";
69
+ path: string;
70
+ modified: Date;
71
+ } | {
72
+ kind: "error";
73
+ message: string;
74
+ };
75
+ /**
76
+ * Newest log file on disk, for `qmd status` — which runs in a different
77
+ * process than the daemon and so cannot see the daemon's env or state.
78
+ * A missing cache dir is "none"; any other failure yields kind "error" with
79
+ * the error code only (status output carries no filesystem paths).
80
+ */
81
+ export declare const latestQueryLogFile: () => QueryLogFileState;
82
+ /** Map a REST `/query` request the same way the handler does. */
83
+ export declare const entryFromRest: (input: {
84
+ path: string;
85
+ headers: IncomingHttpHeaders;
86
+ /** The handler's already-mapped searches. */
87
+ searches: {
88
+ type: string;
89
+ query: string;
90
+ }[];
91
+ /** Raw JSON body fields the handler reads with typeof checks. */
92
+ params: {
93
+ limit?: unknown;
94
+ rerank?: unknown;
95
+ };
96
+ /** The collections actually searched (the handler's defaults applied). */
97
+ collections: string[];
98
+ results: {
99
+ file: string;
100
+ score: number;
101
+ }[];
102
+ ms: number;
103
+ }) => QueryLogEntry;
104
+ export declare const _resetQueryLogForTesting: () => void;
105
+ export {};
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Daemon query log — an opt-in JSONL record of searches served by the HTTP
3
+ * daemon's REST `/query`·`/search` endpoints, so a user dogfooding a build can
4
+ * mine their real queries for misses. MCP tool calls are not logged yet.
5
+ *
6
+ * - Off unless `QMD_QUERY_LOG` is `1`, `true` or `yes` (case-insensitive).
7
+ * CLI searches never call this module.
8
+ * - One file per local month: `$XDG_CACHE_HOME/qmd/queries-YYYY-MM.jsonl`
9
+ * (default `~/.cache/qmd`), created 0600. Nothing is ever deleted.
10
+ * - Rows hold queries, result paths and scores — never snippets or bodies.
11
+ * - Fail-open: callers do not await and nothing here throws; a failure never
12
+ * reaches the search response. The first failure per process prints one
13
+ * stderr line; `queryLogStatus()` keeps the latest failure until the next
14
+ * successful write.
15
+ * - Clients annotate rows with `X-QMD-Tag` / `X-QMD-Qid` / `X-QMD-Role`, and
16
+ * skip logging with `X-QMD-No-Log: 1`. Header values are untrusted.
17
+ *
18
+ * Env and paths are read at call time so tests can redirect XDG_CACHE_HOME.
19
+ */
20
+ import { appendFile, mkdir } from "node:fs/promises";
21
+ import { readFileSync, readdirSync, statSync } from "node:fs";
22
+ import { dirname, join } from "node:path";
23
+ import { fileURLToPath } from "node:url";
24
+ import { qmdHomedir } from "./paths.js";
25
+ import { resolveCommit } from "./cli/version.js";
26
+ export const QUERY_LOG_SCHEMA_VERSION = 1;
27
+ const ROLES = ["primary", "probe", "canary", "replay"];
28
+ const TAG_MAX = 32;
29
+ const QID_MAX = 64;
30
+ const FINGERPRINT_TTL_MS = 60_000;
31
+ const isTruthyFlag = (value) => value !== undefined && ["1", "true", "yes"].includes(value.trim().toLowerCase());
32
+ const isQueryLogEnabled = () => isTruthyFlag(process.env.QMD_QUERY_LOG);
33
+ const queryLogDir = () => join(process.env.XDG_CACHE_HOME || join(qmdHomedir(), ".cache"), "qmd");
34
+ const pad2 = (n) => String(n).padStart(2, "0");
35
+ /** Path of the log file for the local month containing `date`. */
36
+ export const queryLogPath = (date = new Date()) => join(queryLogDir(), `queries-${date.getFullYear()}-${pad2(date.getMonth() + 1)}.jsonl`);
37
+ /** ISO-8601 in local time with the UTC offset, e.g. 2026-09-17T16:05:03.123+09:00. */
38
+ const isoWithOffset = (date) => {
39
+ const offsetMin = -date.getTimezoneOffset();
40
+ const sign = offsetMin >= 0 ? "+" : "-";
41
+ const abs = Math.abs(offsetMin);
42
+ const ms = String(date.getMilliseconds()).padStart(3, "0");
43
+ return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`
44
+ + `T${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}.${ms}`
45
+ + `${sign}${pad2(Math.floor(abs / 60))}:${pad2(abs % 60)}`;
46
+ };
47
+ const readHeader = (headers, name) => {
48
+ if (headers instanceof Headers)
49
+ return headers.get(name);
50
+ const raw = headers[name.toLowerCase()];
51
+ const value = Array.isArray(raw) ? raw[0] : raw;
52
+ return value ?? null;
53
+ };
54
+ const clip = (value, max) => value === null || value === "" ? null : value.slice(0, max);
55
+ const parseRole = (value) => ROLES.find((role) => role === value) ?? null;
56
+ /** REST emits `qmd://<collection>/<percent-encoded path>`; MCP emits the plain display path. */
57
+ const normalizeResultFile = (file) => {
58
+ const bare = file.startsWith("qmd://") ? file.slice("qmd://".length) : file;
59
+ return bare.split("/").map((segment) => {
60
+ try {
61
+ return decodeURIComponent(segment);
62
+ }
63
+ catch {
64
+ return segment;
65
+ }
66
+ }).join("/");
67
+ };
68
+ let packageVersion;
69
+ const qmdVersion = () => {
70
+ if (packageVersion !== undefined)
71
+ return packageVersion;
72
+ // This file sits one level below the package root both as src/ and dist/.
73
+ const here = dirname(fileURLToPath(import.meta.url));
74
+ const root = join(here, "..");
75
+ try {
76
+ const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf-8"));
77
+ // The commit tells a dogfood build apart from the published package of the same version.
78
+ const commit = resolveCommit(join(here, "cli"), root);
79
+ packageVersion = commit ? `${pkg.version ?? "unknown"} (${commit})` : (pkg.version ?? "unknown");
80
+ }
81
+ catch {
82
+ packageVersion = "unknown";
83
+ }
84
+ return packageVersion;
85
+ };
86
+ let fingerprintCache = null;
87
+ const indexFingerprint = async (store) => {
88
+ const now = Date.now();
89
+ if (fingerprintCache && fingerprintCache.store === store && now - fingerprintCache.at < FINGERPRINT_TTL_MS) {
90
+ return fingerprintCache.value;
91
+ }
92
+ let value;
93
+ try {
94
+ const status = await store.getStatus();
95
+ const updated = status.collections
96
+ .map((c) => c.lastUpdated)
97
+ .filter(Boolean)
98
+ .sort()
99
+ .at(-1) ?? null;
100
+ value = { docs: status.totalDocuments, updated };
101
+ }
102
+ catch {
103
+ // A missing fingerprint must not cost the query row.
104
+ value = { docs: null, updated: null };
105
+ }
106
+ fingerprintCache = { store, at: now, value };
107
+ return value;
108
+ };
109
+ let writeChain = Promise.resolve();
110
+ let lastWrite = null;
111
+ let lastError = null;
112
+ let reportedFailure = false;
113
+ const recordFailure = (path, message) => {
114
+ lastError = message;
115
+ if (reportedFailure)
116
+ return;
117
+ reportedFailure = true;
118
+ console.error(`qmd: query log write failed (${path}): ${message} — searches are unaffected`);
119
+ };
120
+ /**
121
+ * Queue one row. Returns immediately; the write happens after the response.
122
+ * No-op unless the flag is on and the request did not ask to be skipped.
123
+ * Never throws — the REST handler calls this after the response has ended.
124
+ */
125
+ export const logQuery = (entry, store) => {
126
+ try {
127
+ queueRow(entry, store);
128
+ }
129
+ catch (err) {
130
+ recordFailure(queryLogPath(), err instanceof Error ? err.message : String(err));
131
+ }
132
+ };
133
+ const queueRow = (entry, store) => {
134
+ if (!isQueryLogEnabled())
135
+ return;
136
+ if (isTruthyFlag(readHeader(entry.headers, "x-qmd-no-log") ?? undefined))
137
+ return;
138
+ const now = entry.now ?? new Date();
139
+ const path = queryLogPath(now);
140
+ const partial = {
141
+ v: QUERY_LOG_SCHEMA_VERSION,
142
+ ts: isoWithOffset(now),
143
+ qmd: qmdVersion(),
144
+ via: entry.via,
145
+ tool: entry.tool,
146
+ searches: entry.searches.map(({ type, query }) => ({ type, query })),
147
+ collections: entry.collections,
148
+ limit: entry.limit,
149
+ rerank: entry.rerank,
150
+ results: entry.results.map((r, i) => ({ file: normalizeResultFile(r.file), score: r.score, rank: i + 1 })),
151
+ ms: entry.ms,
152
+ client: {
153
+ tag: clip(readHeader(entry.headers, "x-qmd-tag"), TAG_MAX),
154
+ qid: clip(readHeader(entry.headers, "x-qmd-qid"), QID_MAX),
155
+ role: parseRole(readHeader(entry.headers, "x-qmd-role")),
156
+ },
157
+ };
158
+ writeChain = writeChain.then(async () => {
159
+ try {
160
+ const line = JSON.stringify({ ...partial, index: await indexFingerprint(store) }) + "\n";
161
+ await mkdir(dirname(path), { recursive: true });
162
+ await appendFile(path, line, { mode: 0o600 });
163
+ lastWrite = new Date().toISOString();
164
+ lastError = null;
165
+ }
166
+ catch (err) {
167
+ recordFailure(path, err instanceof Error ? err.message : String(err));
168
+ }
169
+ });
170
+ };
171
+ /** Resolves once every queued row has been written or has failed. */
172
+ export const flushQueryLog = () => writeChain;
173
+ /** In-process view (the daemon's own `status` tool). */
174
+ export const queryLogStatus = () => ({
175
+ enabled: isQueryLogEnabled(),
176
+ path: queryLogPath(),
177
+ lastWrite,
178
+ lastError,
179
+ });
180
+ const errorCode = (err) => err.code ?? err.message;
181
+ /**
182
+ * Newest log file on disk, for `qmd status` — which runs in a different
183
+ * process than the daemon and so cannot see the daemon's env or state.
184
+ * A missing cache dir is "none"; any other failure yields kind "error" with
185
+ * the error code only (status output carries no filesystem paths).
186
+ */
187
+ export const latestQueryLogFile = () => {
188
+ const dir = queryLogDir();
189
+ try {
190
+ const newest = readdirSync(dir)
191
+ .filter((n) => /^queries-\d{4}-\d{2}\.jsonl$/.test(n))
192
+ .sort()
193
+ .at(-1);
194
+ if (!newest)
195
+ return { kind: "none" };
196
+ const path = join(dir, newest);
197
+ return { kind: "file", path, modified: statSync(path).mtime };
198
+ }
199
+ catch (err) {
200
+ const code = errorCode(err); // fs errors are ErrnoException
201
+ return code === "ENOENT" ? { kind: "none" } : { kind: "error", message: code };
202
+ }
203
+ };
204
+ /** Map a REST `/query` request the same way the handler does. */
205
+ export const entryFromRest = (input) => ({
206
+ via: "rest",
207
+ tool: input.path,
208
+ headers: input.headers,
209
+ searches: input.searches,
210
+ collections: input.collections.length > 0 ? input.collections : null,
211
+ // Same default as the REST handler in src/mcp/server.ts.
212
+ limit: typeof input.params.limit === "number" ? input.params.limit : 10,
213
+ rerank: typeof input.params.rerank === "boolean" ? input.params.rerank : null,
214
+ results: input.results,
215
+ ms: input.ms,
216
+ });
217
+ export const _resetQueryLogForTesting = () => {
218
+ writeChain = Promise.resolve();
219
+ lastWrite = null;
220
+ lastError = null;
221
+ reportedFailure = false;
222
+ fingerprintCache = null;
223
+ };
package/dist/store.d.ts CHANGED
@@ -1022,6 +1022,28 @@ export type RankedListMeta = {
1022
1022
  * original vector boost.
1023
1023
  */
1024
1024
  export declare function getHybridRrfWeights(rankedListMeta: RankedListMeta[]): number[];
1025
+ /**
1026
+ * Blend a reranker score with a linear retrieval-position tiebreak.
1027
+ *
1028
+ * The position term used to be 1/rrfRank under rank-gated weights (0.75/0.60/0.40).
1029
+ * That score drops 50% between rank 1 and rank 2 — a gap of 0.375 at weight 0.75,
1030
+ * larger than the 0.25 the whole reranker term could ever contribute — so no
1031
+ * reranker score could displace the top retrieval hit, and the reranker was inert
1032
+ * exactly where it decided the answer. A linear decay over the candidate window
1033
+ * separates adjacent ranks by a constant the reranker can overcome.
1034
+ *
1035
+ * Measured 2026-09-18 on one fixed candidate dump per goldset, so every formula was
1036
+ * scored against identical retrieval (scripts/bench-dump-candidates.ts feeding
1037
+ * scripts/bench-blend-sweep.mjs):
1038
+ *
1039
+ * alias (n=223) paraphrase (n=30)
1040
+ * 1/rrfRank r@1 .713 mrr .808 r@1 .467 mrr .664
1041
+ * this r@1 .874 mrr .927 r@1 .867 mrr .903
1042
+ *
1043
+ * On both goldsets the old blend's r@1 equalled retrieval-only r@1 to three
1044
+ * decimals, which is what "the reranker cannot move rank 1" looks like as a number.
1045
+ */
1046
+ export declare function blendRerankScore(rrfRank: number, rerankScore: number, candidateLimit: number): number;
1025
1047
  /**
1026
1048
  * Hybrid search: BM25 + vector + query expansion + RRF + chunked reranking.
1027
1049
  *
package/dist/store.js CHANGED
@@ -3185,6 +3185,22 @@ function mergeSearchResultsByScore(lists, limit) {
3185
3185
  .sort((a, b) => b.score - a.score)
3186
3186
  .slice(0, limit);
3187
3187
  }
3188
+ /**
3189
+ * Fewest documents a retrieval pass looks at, whatever `limit` the caller asked for.
3190
+ *
3191
+ * Both searches over-fetch by a multiple of `limit` — FTS by 10x when the collection
3192
+ * filter runs after the match, vectors by 3x or 30x because several chunks of one
3193
+ * document collapse into one result. Those multiples are of a number the caller picked
3194
+ * for display, so a small `limit` leaves a pool that post-filtering can exhaust, and
3195
+ * `-n 5` was not the first five of `-n 20`. The CLI makes that visible: its default
3196
+ * limit is 20 for `--json`/`--files` and 5 otherwise (src/cli/qmd.ts), so the output
3197
+ * format changed which documents came back. README documents `limit` as "Max results"
3198
+ * and `candidateLimit` as the candidate knob; the floor is what makes that true.
3199
+ *
3200
+ * 20 is the breadth hybridQuery already passes explicitly on every one of its own
3201
+ * retrieval calls.
3202
+ */
3203
+ const MIN_RETRIEVAL_BREADTH = 20;
3188
3204
  export function searchFTS(db, query, limit = 20, collectionName) {
3189
3205
  const names = scopedCollectionNames(collectionName);
3190
3206
  // Search each requested collection before merging/truncating so a large
@@ -3206,7 +3222,8 @@ export function searchFTS(db, query, limit = 20, collectionName) {
3206
3222
  // When filtering by collection, fetch extra candidates from the FTS index
3207
3223
  // since some will be filtered out. Without a collection filter we can
3208
3224
  // fetch exactly the requested limit.
3209
- const ftsLimit = collectionFilter ? limit * 10 : limit;
3225
+ const breadth = Math.max(limit, MIN_RETRIEVAL_BREADTH);
3226
+ const ftsLimit = collectionFilter ? breadth * 10 : breadth;
3210
3227
  let sql = `
3211
3228
  WITH fts_matches AS (
3212
3229
  SELECT rowid, bm25(documents_fts, 1.5, 4.0, 1.0) as bm25_score
@@ -3326,6 +3343,7 @@ export async function searchVec(db, query, model, limit = 20, collectionName, se
3326
3343
  return mergeSearchResultsByScore(lists, limit);
3327
3344
  }
3328
3345
  const collectionFilter = names?.[0];
3346
+ const breadth = Math.max(limit, MIN_RETRIEVAL_BREADTH);
3329
3347
  // IMPORTANT: We use a two-step query approach here because sqlite-vec virtual tables
3330
3348
  // hang indefinitely when combined with JOINs in the same query. Do NOT try to
3331
3349
  // "optimize" this by combining into a single query with JOINs - it will break.
@@ -3349,15 +3367,15 @@ export async function searchVec(db, query, model, limit = 20, collectionName, se
3349
3367
  if (collectionHashSeqs.length === 0)
3350
3368
  return [];
3351
3369
  if (collectionHashSeqs.length <= COLLECTION_VEC_EXACT_SCAN_MAX) {
3352
- vecResults = exactVecScanByHashSeq(db, embedding, collectionHashSeqs, limit);
3370
+ vecResults = exactVecScanByHashSeq(db, embedding, collectionHashSeqs, breadth);
3353
3371
  }
3354
3372
  else {
3355
3373
  // Large collection: ANN with over-fetch, hard-capped at sqlite-vec's max k.
3356
- vecResults = annVecScan(db, embedding, Math.max(limit * 30, limit * 3));
3374
+ vecResults = annVecScan(db, embedding, breadth * 30);
3357
3375
  }
3358
3376
  }
3359
3377
  else {
3360
- vecResults = annVecScan(db, embedding, limit * 3);
3378
+ vecResults = annVecScan(db, embedding, breadth * 3);
3361
3379
  }
3362
3380
  if (vecResults.length === 0)
3363
3381
  return [];
@@ -4347,6 +4365,36 @@ export function getHybridRrfWeights(rankedListMeta) {
4347
4365
  return meta.relaxed ? weight * RELAXED_LIST_WEIGHT : weight;
4348
4366
  });
4349
4367
  }
4368
+ /**
4369
+ * How much of the blended score the retrieval position is worth. The reranker
4370
+ * decides; position only separates documents it scores alike.
4371
+ */
4372
+ const POSITION_TIEBREAK_WEIGHT = 0.1;
4373
+ /**
4374
+ * Blend a reranker score with a linear retrieval-position tiebreak.
4375
+ *
4376
+ * The position term used to be 1/rrfRank under rank-gated weights (0.75/0.60/0.40).
4377
+ * That score drops 50% between rank 1 and rank 2 — a gap of 0.375 at weight 0.75,
4378
+ * larger than the 0.25 the whole reranker term could ever contribute — so no
4379
+ * reranker score could displace the top retrieval hit, and the reranker was inert
4380
+ * exactly where it decided the answer. A linear decay over the candidate window
4381
+ * separates adjacent ranks by a constant the reranker can overcome.
4382
+ *
4383
+ * Measured 2026-09-18 on one fixed candidate dump per goldset, so every formula was
4384
+ * scored against identical retrieval (scripts/bench-dump-candidates.ts feeding
4385
+ * scripts/bench-blend-sweep.mjs):
4386
+ *
4387
+ * alias (n=223) paraphrase (n=30)
4388
+ * 1/rrfRank r@1 .713 mrr .808 r@1 .467 mrr .664
4389
+ * this r@1 .874 mrr .927 r@1 .867 mrr .903
4390
+ *
4391
+ * On both goldsets the old blend's r@1 equalled retrieval-only r@1 to three
4392
+ * decimals, which is what "the reranker cannot move rank 1" looks like as a number.
4393
+ */
4394
+ export function blendRerankScore(rrfRank, rerankScore, candidateLimit) {
4395
+ const positionScore = 1 - (rrfRank - 1) / candidateLimit;
4396
+ return (1 - POSITION_TIEBREAK_WEIGHT) * rerankScore + POSITION_TIEBREAK_WEIGHT * positionScore;
4397
+ }
4350
4398
  /**
4351
4399
  * Hybrid search: BM25 + vector + query expansion + RRF + chunked reranking.
4352
4400
  *
@@ -4586,15 +4634,8 @@ export async function hybridQuery(store, query, options) {
4586
4634
  const rrfRankMap = new Map(candidates.map((c, i) => [c.file, i + 1]));
4587
4635
  const blended = reranked.map(r => {
4588
4636
  const rrfRank = rrfRankMap.get(r.file) || candidateLimit;
4589
- let rrfWeight;
4590
- if (rrfRank <= 3)
4591
- rrfWeight = 0.75;
4592
- else if (rrfRank <= 10)
4593
- rrfWeight = 0.60;
4594
- else
4595
- rrfWeight = 0.40;
4596
- const rrfScore = 1 / rrfRank;
4597
- const blendedScore = rrfWeight * rrfScore + (1 - rrfWeight) * r.score;
4637
+ const rrfScore = 1 - (rrfRank - 1) / candidateLimit;
4638
+ const blendedScore = blendRerankScore(rrfRank, r.score, candidateLimit);
4598
4639
  const candidate = candidateMap.get(r.file);
4599
4640
  const chunkInfo = docChunkMap.get(r.file);
4600
4641
  const bestIdx = chunkInfo?.bestIdx ?? 0;
@@ -4607,7 +4648,7 @@ export async function hybridQuery(store, query, options) {
4607
4648
  rrf: {
4608
4649
  rank: rrfRank,
4609
4650
  positionScore: rrfScore,
4610
- weight: rrfWeight,
4651
+ weight: POSITION_TIEBREAK_WEIGHT,
4611
4652
  baseScore: trace?.baseScore ?? 0,
4612
4653
  topRankBonus: trace?.topRankBonus ?? 0,
4613
4654
  totalScore: trace?.totalScore ?? 0,
@@ -4908,15 +4949,8 @@ export async function structuredSearch(store, searches, options) {
4908
4949
  const rrfRankMap = new Map(candidates.map((c, i) => [c.file, i + 1]));
4909
4950
  const blended = reranked.map(r => {
4910
4951
  const rrfRank = rrfRankMap.get(r.file) || candidateLimit;
4911
- let rrfWeight;
4912
- if (rrfRank <= 3)
4913
- rrfWeight = 0.75;
4914
- else if (rrfRank <= 10)
4915
- rrfWeight = 0.60;
4916
- else
4917
- rrfWeight = 0.40;
4918
- const rrfScore = 1 / rrfRank;
4919
- const blendedScore = rrfWeight * rrfScore + (1 - rrfWeight) * r.score;
4952
+ const rrfScore = 1 - (rrfRank - 1) / candidateLimit;
4953
+ const blendedScore = blendRerankScore(rrfRank, r.score, candidateLimit);
4920
4954
  const candidate = candidateMap.get(r.file);
4921
4955
  const chunkInfo = docChunkMap.get(r.file);
4922
4956
  const bestIdx = chunkInfo?.bestIdx ?? 0;
@@ -4929,7 +4963,7 @@ export async function structuredSearch(store, searches, options) {
4929
4963
  rrf: {
4930
4964
  rank: rrfRank,
4931
4965
  positionScore: rrfScore,
4932
- weight: rrfWeight,
4966
+ weight: POSITION_TIEBREAK_WEIGHT,
4933
4967
  baseScore: trace?.baseScore ?? 0,
4934
4968
  topRankBonus: trace?.topRankBonus ?? 0,
4935
4969
  totalScore: trace?.totalScore ?? 0,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ko-qmd",
3
- "version": "2.8.3-ko.2",
3
+ "version": "2.8.3-ko.3",
4
4
  "description": "Query Markup Documents - On-device hybrid search for markdown files with BM25, vector search, and LLM reranking",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",