ko-qmd 2.8.3-ko.2 → 2.8.3-ko.4

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,259 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [2.8.3-ko.4] - 2026-09-24
6
+
7
+ Korean documents are now chunked at their real token budget, and Hangul
8
+ spellings of technical loanwords (`서치`, `웹 코어`) reach notes written in Latin
9
+ script. This release also syncs tobi/qmd up to `04e4dbd` — metadata filtering,
10
+ the oxlint fence and the REST JSON body guard — and adds `qmd grep`, `path`/
11
+ `since`/`until` scoping on MCP and REST, and a daemon that keeps the text index
12
+ fresh on its own. Existing indexes need one `qmd embed` run to pick up the new
13
+ chunking, and SDK callers that passed `filter` for path/time scoping must rename
14
+ it to `scope`.
15
+
16
+ ### Breaking
17
+
18
+ - SDK: ko-qmd's path/time narrowing option is now `scope` (was `filter`) on
19
+ `search()`, `searchLex()` and `searchVector()`, and the trailing `scope`
20
+ parameter of `searchFTS`/`searchVec`. `filter` is upstream's metadata filter.
21
+ CLI `--path/--since/--until` and REST/MCP `path/since/until` are unchanged,
22
+ and a request may pass both — a result has to satisfy each.
23
+
24
+ ### Upstream sync
25
+
26
+ - Merged tobi/qmd `main` up to `04e4dbd` (24 commits past v2.8.3): oxlint
27
+ fence (`bun run lint` is oxlint; the tsc check is `bun run test:types`),
28
+ metadata filtering, separator-aware FTS terms (quoted `"PIO-1384"` now
29
+ matches), REST JSON body guard, Windows index names, unreadable-gguf
30
+ diagnostics and the store-local embedding tokenizer. ko-qmd behaviour is
31
+ kept: the Hangul query branch runs ahead of the separator split, a metadata
32
+ filter gets the same anti-starvation scan as `--path`, and bm25 on the
33
+ ko-vault bench is identical per query.
34
+
35
+ ### Added
36
+
37
+ - Added Oxlint lint fence.
38
+ - Document metadata and metadata filtering. Markdown documents can opt into typed metadata through a namespaced frontmatter block (`qmd.metadata` with strings, numbers, booleans, or flat homogeneous arrays), and every search surface — CLI `search`/`vsearch`/`query` via `--filter <json>`, the SDK's `filter` option on `search()`/`searchLex()`/`searchVector()`, the MCP `query` tool, and HTTP `POST /query` and `/search` — accepts one shared recursive filter AST discriminated by `operator`: `and`/`or`/`not` logical groups, `eq`/`ne`/`gt`/`gte`/`lt`/`lte` comparisons, `in`/`nin`/`all` membership, and `exists` presence. Every returned result satisfies the filter (applied before RRF fusion and reranking); like collection filtering, highly selective filters remain best-effort for top-K completeness. Frontmatter stays ordinary searchable content — no chunking, embedding, snippet, or line-number changes — and documents without `qmd.metadata` behave exactly as before. JSON/SDK/MCP/HTTP results now include each document's indexed metadata, and `qmd status` reports how many documents still need metadata extraction (a normal `qmd update` backfills existing indexes).
39
+
40
+ ### Fixed
41
+
42
+ - Embedding generation and legacy fingerprint adoption now tokenize documents
43
+ with the store-selected embedding model instead of the global default. This
44
+ keeps chunk boundaries aligned with the model that creates and verifies the
45
+ stored vectors without initializing an unrelated provider.
46
+
47
+ ### Changes
48
+
49
+ - Plain Hangul lex terms that spell a common technical loanword (`서치`, `웹`,
50
+ `코어`, `마이그레이션`, about 80 entries in a built-in table) also match the
51
+ Latin spelling, so `하이브리드 서치` finds a note that only says
52
+ `hybrid-search` and `레몬 웹 코어` reaches `lemon-web-core`. The lookup runs
53
+ on particle-stripped stems too (`서치를`), single-syllable entries (`웹`, `훅`)
54
+ are bridged when they stand alone (not `웹을`), and terms with no entry produce the same FTS5 query as before.
55
+ Query-side only; no re-index. On the ko-vault bench the eleven new loanword
56
+ queries go from bm25_r5 0.0000 to 1.0000 while the original 52 hold at
57
+ 0.9519 per query.
58
+ - REST `POST /query` and the MCP `query` tool attach `vec_score` (cosine
59
+ similarity) and `fts_score` (normalized BM25) to each result on the
60
+ `rerank:false` path, where `score` is the 1/rank fusion position and cannot
61
+ be thresholded. Each is the max over the sub-queries that returned the hit
62
+ and is omitted, never 0, when that backend did not return it. The daemon
63
+ query log (`QMD_QUERY_LOG`) records the same two fields per result; the row
64
+ schema stays `v: 1` and `rerank:true` responses are unchanged.
65
+ - Korean documents are chunked at their real token budget instead of the
66
+ English-tuned 3.0 chars/token: `estimateCharsPerToken` blends CJK and other
67
+ scripts as a harmonic mean (measured ko 1.64 / en 6.08 chars per token on
68
+ Qwen3-Embedding-0.6B-Q8_0, 2026-09-21), and indexing plus both search-path
69
+ best-chunk selections size chunks with it. A 20KB Korean document now
70
+ first-passes into ~900-token chunks instead of being split ~855 and Korean
71
+ rerank chunks shrink from ~3600 to ~1440 chars. Non-CJK text keeps the ratio
72
+ each call site already used — 3.0 when indexing, 4.0 on the search paths — so
73
+ English chunk boundaries do not move, and the ko-vault bench holds
74
+ (bm25_r5=0.9519).
75
+ - Re-embedding a document whose chunk count shrank now prunes its stale
76
+ higher-`seq` vectors before inserting, so one `qmd embed` run converges
77
+ instead of waiting for a second run. The embedding fingerprint gains a
78
+ `chunk_chars_estimator` term, so `qmd status` reports re-embedding as needed
79
+ for existing indexes; run `qmd embed` once to pick up the new chunking.
80
+
81
+ - `search`, `vsearch` and `query` take `--path`, `--since` and `--until`, so a
82
+ search can be narrowed to part of a collection or to what changed recently.
83
+ `--path` matches globs against `collection/path` and is repeatable, with a
84
+ `!` prefix to exclude; `--since` / `--until` take a span (`7d`) or a date.
85
+ The filter narrows the corpus rather than the result list — excluded
86
+ documents are gone before ranking, on the vector path as well as BM25, so a
87
+ narrow filter returns its own best matches instead of whatever survived the
88
+ global top-K. A span that does not parse is an error, and a filter that
89
+ leaves nothing to search says so instead of looking like "no match".
90
+
91
+ - `qmd query --explain` now lists every expansion sub-query that placed the
92
+ document, with that sub-query's own rank, contribution, backend score and the
93
+ text it ran. It previously showed the top three contributions collapsed onto
94
+ one line without the query text, which could not distinguish a document that
95
+ ranked first in one sub-query and nowhere else from one that ranked middling
96
+ everywhere — two situations that call for different fixes.
97
+
98
+ - `qmd grep <pattern>` matches document bodies exactly, by string or regular
99
+ expression, with no ranking and no LLM: every matching line, grouped by file,
100
+ with line numbers that `qmd get <file>:<n>:<count>` accepts. It is the escape
101
+ hatch for a query that finds nothing because tokenization never produced the
102
+ term the document contains — the failure this fork keeps hitting with Korean
103
+ text — and for finding an identifier or error string verbatim. It searches
104
+ exactly what the index holds and honours collection scoping, `--path`,
105
+ `--since` and `--until`. Smart case: an uppercase letter in the pattern makes
106
+ it case-sensitive, and Hangul has no case so Korean patterns are unaffected.
107
+ `-F` searches for a literal string. An MCP `grep` tool exposes the same thing
108
+ to agents, which is the reason it exists: a client whose only tools are
109
+ `query`, `get` and `multi_get` has no other way to match a string exactly.
110
+
111
+ - The MCP `query` tool and the REST `/query` endpoint take the same `path`,
112
+ `since` and `until` parameters, so an agent can scope a search to a folder or
113
+ to recent changes without shelling out. A span that does not parse comes back
114
+ as an error rather than an unfiltered search, and an empty result under a
115
+ filter reports how many documents the filter left — an agent reading a bare
116
+ "No results" would otherwise conclude the corpus has nothing on the topic.
117
+
118
+ - The HTTP and stdio MCP daemon keeps the text index current on its own. Before
119
+ each search it checks the collections that search covers — at most once every
120
+ 30 seconds each — and re-indexes the ones whose files changed, so a note
121
+ written while the daemon runs is findable on the next query without `qmd
122
+ update`. It updates keyword search only; vectors still wait for `qmd embed`.
123
+ It does not run a collection's `update:` hook, does not clear the LLM cache
124
+ the way `qmd update` does, and does not touch a collection whose folder has
125
+ vanished or emptied, since reindexing would deactivate every document in it.
126
+
127
+ - The daemon's query log now covers the MCP `query` tool, not just the REST
128
+ endpoints. A search an agent runs through MCP over HTTP lands in the same
129
+ `queries-YYYY-MM.jsonl` with `via: "mcp"`, the same result path spelling and
130
+ the same `X-QMD-*` header handling, so a dogfooding session's real traffic is
131
+ in one file. A plain `query` argument is recorded as one search of type
132
+ `auto`. stdio MCP calls stay unlogged — they carry no request to annotate.
133
+
134
+ ## [2.8.3-ko.3] - 2026-09-18
135
+
136
+ The reranker now decides which document ranks first. The blend's retrieval-position
137
+ term could outweigh the entire reranker score, so the top retrieval hit was
138
+ unreachable; making position a 10% linear decay took recall@1 from .713 to .874 on
139
+ 223 name-like queries and from .467 to .867 on 30 paraphrase queries. The HTTP daemon
140
+ gains two operational knobs: `QMD_QUERY_LOG=1` records every search it serves to a
141
+ monthly JSONL, and `QMD_LLM_IDLE_TIMEOUT_MS=0` keeps models resident, which takes the
142
+ first search after an idle spell from ~1.8s to ~0.07s.
143
+
144
+ ### Changes
145
+
146
+ - `qmd query`'s reranker can now change which document comes first. The score
147
+ that orders results blended the reranker against a retrieval-position term of
148
+ `1 / rrfRank` under rank-gated weights, and that term falls 0.375 between rank
149
+ 1 and rank 2 — more than the 0.25 the entire reranker term could contribute.
150
+ The top retrieval hit was therefore unreachable: a document the reranker scored
151
+ 0.03 stayed ahead of one it scored 1.00. Position is now a linear decay across
152
+ the candidate window worth 10% of the blend, so it separates documents the
153
+ reranker scores alike instead of overruling it. Measured over a fixed candidate
154
+ dump per goldset, so every formula saw identical retrieval: recall@1 went .713
155
+ to .874 on 223 alias queries and .467 to .867 on 30 paraphrase queries. On both
156
+ goldsets the old blend's recall@1 equalled retrieval-only recall@1 to three
157
+ decimals, which is what an inert reranker looks like as a number.
158
+ - New bench tooling: `scripts/bench-vault-summarize.ts` writes a plain-language
159
+ summary per document with a local model, and `bench-vault-summary-apply.mjs`
160
+ builds the summary-augmented copy of a vault that the bench then indexes. This
161
+ measures whether the vocabulary gap the paraphrase goldset is made of closes at
162
+ index time. On 16 such queries, giving each document three everyday-language
163
+ lines took `search` (BM25, no LLM) from .250 to .562 recall@5 and the unreranked
164
+ hybrid path from .625 to .812. The reranked path did not move (.875 both ways,
165
+ the same two queries missed): it was already covering the gap, so the summaries
166
+ buy the cheap backends most of what the expensive one had. The BM25 number
167
+ carries no confound — that backend never calls query expansion. Summary lines
168
+ are filtered by document frequency, because a model that emits "이 도구는 뭘 하는
169
+ 건데?" would otherwise give every vague query a weak match on an unrelated
170
+ document.
171
+ - New bench tooling: `scripts/bench-dump-candidates.ts` records the RRF rank and
172
+ reranker score of every candidate for each goldset query, and
173
+ `scripts/bench-blend-sweep.mjs` re-scores that dump under different blend
174
+ formulas. Comparing two formulas used to mean two bench runs, which vary in
175
+ their retrieval and take about an hour each; it now costs one dump and a
176
+ millisecond per formula, and the variants are compared against the same
177
+ candidates.
178
+
179
+ - `bench-vault-goldset.mjs` adds two buckets that cost no model call: `alias`,
180
+ the display text of a `[[target|alias]]` wikilink, and `fmalias`, an entry of
181
+ the target's own `aliases:` frontmatter list. Both are human-written other
182
+ names for a document. First measurement on a 232-document vault (223 queries):
183
+ `full` scored 0.924 r@5 against `hybrid` 0.978 and `vector` 0.982 — of the 17
184
+ queries `full` missed, `hybrid` had 14 in its top five. The reranker demotes
185
+ short name-like queries that retrieval already found.
186
+ - `bench-vault-paraphrase.ts` writes its output after every document, so a run
187
+ killed from outside keeps what it had done.
188
+
189
+ - `QMD_LLM_IDLE_TIMEOUT_MS` sets how long loaded models stay idle before they
190
+ are unloaded (`0` = never), overriding the 5 minutes `createStore()` passes.
191
+ On a long-running HTTP daemon the first search after a quiet spell no longer
192
+ pays for reloading the models.
193
+ - `QMD_QUERY_LOG=1` makes the HTTP daemon append every `POST /query` /
194
+ `/search` search to `queries-YYYY-MM.jsonl` in the qmd cache directory: the
195
+ qmd version and commit, an index fingerprint, the searches, and each result's
196
+ path, score and rank — never snippets. Clients tag rows with `X-QMD-Tag`,
197
+ `X-QMD-Qid` and `X-QMD-Role`, and skip them with `X-QMD-No-Log: 1`. Writes
198
+ never fail a search; `qmd status` shows the newest log file and the MCP
199
+ `status` tool shows the daemon's flag and last write error.
200
+ - `scripts/dogfood.sh` builds the checkout, stops if bench-ko `bm25_r5` falls
201
+ below the newest BASELINE.md line, installs the packed tarball globally,
202
+ restarts the launchd daemon and smoke-tests it; `--restore` reinstalls the
203
+ published package.
204
+
205
+ - `develop` is the working branch; `main` only receives merges from it and is
206
+ where `/release` tags. `publish.yml` authenticates with the `NPM_TOKEN`
207
+ repository secret and skips a version that is already on the registry, so a
208
+ manually published version or a re-pushed tag still gets its GitHub release.
209
+ CI runs for `develop` pushes and pull requests too.
210
+
211
+ ### Fixes
212
+
213
+ - `bench-vault-report.mjs` writes its miss list next to the input, never on top
214
+ of it. The path came from `replace('bench.json', 'misses.json')`, a no-op for
215
+ any other file name, so reporting on a copied result overwrote that result.
216
+ - Query expansion drops sub-queries that have nothing to do with the query again.
217
+ The drift filter kept an expansion only if it shared a word with the query, but
218
+ it read the query as `[a-z0-9]` words, so a Korean query produced an empty word
219
+ list and the filter passed everything. Measured over 151 expansions of 30
220
+ Korean queries it rejected none, including 22 that were generic English
221
+ boilerplate ("Proper implementation follows established patterns and best
222
+ practices.") with no relation to the query — each one an embedding call and a
223
+ vector search spent on noise. A shared Hangul syllable bigram now counts as the
224
+ same kind of anchor: it drops those 22 and keeps 127 of the 129 Korean
225
+ expansions. A query with no anchor of either kind still keeps everything.
226
+ - Query expansion strips thinking tags from a sub-query before searching for it.
227
+ The grammar forbids newlines inside a line, so a `</think>` arrives welded to
228
+ the sub-query text rather than on a line of its own that parsing would skip; 20
229
+ of 46 hyde expansions in the same measurement carried one, and the tag was
230
+ embedded along with the text that mattered.
231
+ - Query expansion no longer runs the same sub-query twice on the same backend.
232
+ - `limit` no longer decides how wide a search looks, only how many results come
233
+ back. Both searches over-fetch by a multiple of `limit` — the FTS index by 10x
234
+ because the collection filter runs after the match, vectors by 3x or 30x
235
+ because chunks of one document collapse into one result — so a small `limit`
236
+ left a pool that post-filtering could exhaust, and `-n 5` was not the first five
237
+ of `-n 20`. The CLI made it visible: its default limit is 20 for `--json` and
238
+ `--files` and 5 otherwise, so the output format changed which documents were
239
+ found. Retrieval now looks at 20 documents at minimum whatever `limit` says,
240
+ which is the breadth `qmd query` already used internally.
241
+ - `bench-vault.sh` refuses to run when the vault it was given sits inside the
242
+ work directory it is about to `rm -rf`. The work directory is named after the
243
+ vault, so a vault under `tmp/real-bench/` resolved to the same path and the
244
+ corpus was deleted before it could be indexed, which showed up as a bench over
245
+ zero documents rather than as an error.
246
+ - `bench-vault-report.mjs` prints every backend that ran under each bucket, and
247
+ takes its miss list from the best backend rather than from BM25. The bucket
248
+ lines were BM25-only under a bare bucket name, so a run's `sem: 9/16` read as
249
+ the result of the pipeline the run was measuring.
250
+
251
+ - The Windows git-install probe failed on every push with `Permission denied
252
+ (publickey)`: npm and pnpm resolve any github.com dependency over SSH first
253
+ and do not retry an auth failure over https, the runner has no key, and the
254
+ repository is private so anonymous https fails as well. The job now rewrites
255
+ every github.com URL to https with the job token via git `insteadOf` before
256
+ installing.
257
+
5
258
  ## [2.8.3-ko.2] - 2026-09-17
6
259
 
7
260
  ### Changes
package/README.ko.md CHANGED
@@ -4,18 +4,23 @@
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
 
11
11
  1. 패키징: 이름·repository·플러그인 marketplace owner, `publish.yml` 은 `ko-qmd` 로 발행, CI에 `windows-latest`와 Electron 스모크 잡 추가
12
12
  2. Hangul FTS (`src/hangul.ts`, `store.ts` 접점 2곳)
13
13
  - 질의: 따옴표 없는 한글 단어는 끝 조사 하나 또는 `기`를 뗀 어간도 매칭한다(`검색을` → `검색`, 어간 2음절 이상). 따옴표 구문·한자·가나는 그대로.
14
+ - 외래어 다리: 기술 용어의 한글 외래어 표기(`서치`·`웹`·`코어`·`마이그레이션` 등 약 80개, `hangul.ts` 내장 표)는 영문 표기를 OR 로 함께 찾는다(`하이브리드 서치` → `hybrid search`). 어간에도 적용(`서치를` → `search`), 영문과 붙은 한글 구간도(`qmd서치`). 붙여 쓴 한글 복합어(`레몬웹코어`)와 1음절 항목에 조사가 붙은 꼴(`웹을`)은 대상 아님. 질의 쪽만 바뀌므로 재색인 불필요.
14
15
  - 색인: 한글 구간의 음절 bigram을 필드 끝에 덧붙인다. `FTS_CJK_NORMALIZED_VERSION`이 `"2"`라 기존 인덱스는 처음 열 때 FTS를 한 번 다시 만든다.
15
16
  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" 절은 업스트림 기본값 기준이다.
17
+ - 기본 임베딩 모델: `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
18
  - 스킬 `skills/qmd/SKILL.md`에 "Korean queries" 절(`lex:`에 어간·alias·영문 용어, `vec:`에 한국어 패러프레이즈).
18
19
 
20
+ 4. 데몬 질의 로그 (`src/query-log.ts`, `server.ts` 접점 = REST 핸들러·MCP `query` 툴·`status` 툴): `QMD_QUERY_LOG=1` 로 띄운 HTTP 데몬이 REST 검색과 HTTP MCP `query` 툴 호출마다 `queries-YYYY-MM.jsonl` 에 한 줄을 남긴다(stdio MCP 는 남기지 않는다)(결과 경로·점수, 스니펫 없음). `rerank:false` 응답·로그 행에는 결과마다 원시 점수 `vec_score`(코사인 유사도)·`fts_score`(정규화 BM25)가 붙는다 — 그 경로의 `score`는 1/순위라 임계값을 걸 수 없다. 헤더 `X-QMD-Tag`·`X-QMD-Qid`·`X-QMD-Role`·`X-QMD-No-Log`. 계약은 [README.md § Query log](README.md#query-log-ko-qmd). 업스트림 PR 대상 아님.
21
+ 5. 모델 idle 타임아웃 env (`src/llm.ts` `LlamaCpp` 생성자): `QMD_LLM_IDLE_TIMEOUT_MS` 가 `createStore()` 의 5분 고정값보다 우선한다. `0` 이면 모델을 내리지 않는다 — 상주 데몬의 첫 검색이 모델을 다시 올리지 않게 하는 용도다.
22
+ 6. 경로·시간 범위 필터 (`src/filters.ts`): CLI `--path`·`--since`·`--until`, REST·MCP `path`·`since`·`until`. 걸러진 코퍼스 안에서 순위를 매긴다(전역 top-K 뒤 후처리가 아님). SDK 옵션 이름은 `scope`다 — `filter`는 업스트림의 metadata 필터(`--filter <json>`)가 쓰고, 둘을 같이 주면 AND. ko-qmd 에서는 metadata 필터도 같은 전체 스캔·정확 스캔 경로를 타므로, 업스트림 README 의 "선택적 필터는 best-effort" 한계가 적용되지 않는다.
23
+
19
24
  ### 기본 임베딩 변경 후 재임베딩
20
25
 
21
26
  벡터는 모델 사이에 호환되지 않는다. embeddinggemma로 만든 기존 인덱스는 다시 임베딩해야 한다.
@@ -26,9 +31,24 @@ qmd embed -f
26
31
 
27
32
  embeddinggemma를 계속 쓰려면 `index.yml`의 `models: embed:` 또는 `QMD_EMBED_MODEL`로 지정한다.
28
33
 
34
+ ### dogfood — 작업 트리 빌드를 이 머신 데몬에 올리기
35
+
36
+ ```sh
37
+ bash scripts/dogfood.sh # build → bench-ko 게이트 → npm pack → npm i -g → 데몬 재시작 → 스모크
38
+ bash scripts/dogfood.sh --restore # 발행본(DOGFOOD_PIN_FILE 의 ko-qmd@<버전>, 없으면 latest)으로 되돌림
39
+ bash scripts/dogfood.sh --check "RESULT bm25_r5=…" # 게이트 판정만
40
+ ```
41
+
42
+ - 게이트: `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`)가 확인한다.
43
+ - `npm link` 가 아니라 pack 설치다 — 링크하면 데몬이 작업 트리의 `dist/` 를 서빙해 빌드 중에 깨질 수 있다.
44
+ - 머신 배선은 env: `DOGFOOD_LABEL`(launchd 라벨, 기본 `com.lemoncloud.qmd-daemon`) · `DOGFOOD_URL`(기본 `http://127.0.0.1:8181`) · `DOGFOOD_SMOKE`(추가 스모크 명령 — `"status":"hit"` JSON 을 내야 함) · `DOGFOOD_PIN_FILE`.
45
+ - 성공하면 `~/.cache/qmd/dogfood-deployed` 에 `<시각> <커밋>` 을 쓴다(`--restore` 가 지운다). 배치 중에는 설치된 바이너리가 발행본 핀과 다르다 — `qmd --version` 의 커밋이 구분자.
46
+
29
47
  ### ko-vault 벤치
30
48
 
31
- `bash scripts/bench-ko.sh` (픽스처 `test/fixtures/ko-vault/`, 문서 28·질의 36, 임베딩은 픽스처 `models.yml`의 embeddinggemma로 고정). 표와 run별 수치는 [BASELINE.md](test/fixtures/ko-vault/BASELINE.md).
49
+ `bash scripts/bench-ko.sh` (픽스처 `test/fixtures/ko-vault/`, 문서 28·질의 63, 임베딩은 픽스처 `models.yml`의 Qwen3-Embedding-0.6B-Q8_0으로 고정 — 패키지 기본값과 같다). run별 수치는 [BASELINE.md](test/fixtures/ko-vault/BASELINE.md).
50
+
51
+ 아래 표는 질의 36·embeddinggemma 시절의 단계별 기록이다.
32
52
 
33
53
  | 단계 | bm25_r5 | vector_r5 | full_r5 |
34
54
  |---|---|---|---|
@@ -36,7 +56,7 @@ embeddinggemma를 계속 쓰려면 `index.yml`의 `models: embed:` 또는 `QMD_E
36
56
  | + 질의 조사 처리 (B2) | 0.6528 | 0.9861 | 1.0000 |
37
57
  | + bigram 색인 (B3) | 0.6528 | 0.9861 | 1.0000 |
38
58
 
39
- Qwen3-Embedding 기본값의 벤치 수치는 미측정이다(design §6 라운드 M1).
59
+ Qwen3-Embedding 기본값에서 질의 52는 bm25_r5 0.9519 · vector_r5 1.0000 · full_r5 1.0000이다. 질의 63은 외래어 표기 질의 11건(`하이브리드 서치` → `hybrid-search.md`)을 더한 것으로, 그 11건의 bm25_r5는 BASELINE.md 2026-09-23 절에 따로 적는다.
40
60
 
41
61
  ## 설치
42
62
 
@@ -48,6 +68,8 @@ npm install -g ko-qmd@<version>
48
68
  "ko-qmd": "<version>"
49
69
  ```
50
70
 
71
+ 버전은 `<업스트림 버전>-ko.N` 형태의 prerelease 라, `^`·`~` 범위는 같은 `major.minor.patch` 의 `-ko.*` 안에서만 움직인다(`^2.8.3-ko.0` 은 `2.8.4-ko.0` 을 안 집는다). 정확한 버전으로 핀한다.
72
+
51
73
  업스트림 `@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
74
 
53
75
  설치 스모크(빈 디렉터리):
@@ -73,13 +95,20 @@ Windows CI 결과·리빌드 소요 시간은 `docs/vault-search/WINDOWS.md` 에
73
95
 
74
96
  ## 업스트림 동기화
75
97
 
98
+ `develop` 에서 `chore/upstream-sync-*` 브랜치를 따고, 업스트림의 first-parent 머지 지점을 하나씩 `--no-ff` 로 머지한다. 대상은 태그든 `main` 스냅샷이든 된다.
99
+
76
100
  ```sh
77
- git fetch upstream
78
- git rebase upstream/<tag> # on ko
79
- npm run test:unit && bash scripts/bench-ko.sh
101
+ git fetch upstream --tags
102
+ git log --first-parent --oneline develop..upstream/main # 머지할 지점 목록
103
+ bash scripts/bench-ko.sh # 머지 전 기준값 (tmp/bench-ko/bench.json 보관)
104
+ git merge --no-ff <지점> # 지점마다 반복, 충돌은 이 머지 커밋에서 해결
105
+ bun run lint && bun run test:types && bun run test:unit && bash scripts/bench-ko.sh
80
106
  ```
81
107
 
82
- 통과하면 `v<업스트림 버전>-ko.N` 태그를 push한다.
108
+ - 벤치는 전체 수치가 아니라 bm25 recall@5·MRR 을 질의별로 머지 전과 비교한다(hybrid 는 실행마다 한 질의쯤 흔들린다 — BASELINE.md 2026-09-23).
109
+ - PR 은 squash 가 아니라 **머지 커밋**으로 들인다. 업스트림 커밋이 조상으로 남아야 다음 동기화가 새 커밋만 본다. 같은 이유로 이 브랜치는 squash·rebase 하지 않는다.
110
+ - `develop`·`main` 을 upstream 위로 rebase 하지 않는다(보호 브랜치 force push).
111
+ - 업스트림 태그 위에 있을 때만 `v<업스트림 버전>-ko.N` 태그를 쓴다. `main` 스냅샷이면 버전은 `2.8.3-ko.N` 식으로 마지막 태그 기준이다.
83
112
 
84
113
  ## 라이선스
85
114