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 +124 -0
- package/README.ko.md +20 -2
- package/README.md +45 -16
- package/dist/cli/build-info.json +2 -2
- package/dist/cli/qmd.js +17 -1
- package/dist/hangul.d.ts +9 -0
- package/dist/hangul.js +19 -0
- package/dist/index.js +1 -1
- package/dist/llm.d.ts +27 -0
- package/dist/llm.js +90 -22
- package/dist/mcp/server.js +7 -1
- package/dist/query-log.d.ts +105 -0
- package/dist/query-log.js +223 -0
- package/dist/store.d.ts +22 -0
- package/dist/store.js +58 -24
- package/package.json +1 -1
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` = 배포 브랜치(
|
|
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).
|
|
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
|
|
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
|
-
| `
|
|
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.
|
|
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
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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
|
-
|
|
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
|
-
- **
|
|
588
|
-
- **
|
|
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/
|
|
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:
|
|
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:
|
|
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`
|
|
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
|
|
package/dist/cli/build-info.json
CHANGED
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,
|
|
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 =
|
|
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
|
|
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)
|
package/dist/mcp/server.js
CHANGED
|
@@ -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
|
|
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,
|
|
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,
|
|
3374
|
+
vecResults = annVecScan(db, embedding, breadth * 30);
|
|
3357
3375
|
}
|
|
3358
3376
|
}
|
|
3359
3377
|
else {
|
|
3360
|
-
vecResults = annVecScan(db, embedding,
|
|
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
|
-
|
|
4590
|
-
|
|
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:
|
|
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
|
-
|
|
4912
|
-
|
|
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:
|
|
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