ko-qmd 2.8.3-ko.3 → 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,135 @@
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
+
5
134
  ## [2.8.3-ko.3] - 2026-09-18
6
135
 
7
136
  The reranker now decides which document ranks first. The blend's retrieval-position
package/README.ko.md CHANGED
@@ -11,13 +11,15 @@
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
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
 
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
+ 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 대상 아님.
20
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" 한계가 적용되지 않는다.
21
23
 
22
24
  ### 기본 임베딩 변경 후 재임베딩
23
25
 
@@ -44,7 +46,9 @@ bash scripts/dogfood.sh --check "RESULT bm25_r5=…" # 게이트 판정만
44
46
 
45
47
  ### ko-vault 벤치
46
48
 
47
- `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 시절의 단계별 기록이다.
48
52
 
49
53
  | 단계 | bm25_r5 | vector_r5 | full_r5 |
50
54
  |---|---|---|---|
@@ -52,7 +56,7 @@ bash scripts/dogfood.sh --check "RESULT bm25_r5=…" # 게이트 판정만
52
56
  | + 질의 조사 처리 (B2) | 0.6528 | 0.9861 | 1.0000 |
53
57
  | + bigram 색인 (B3) | 0.6528 | 0.9861 | 1.0000 |
54
58
 
55
- 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 절에 따로 적는다.
56
60
 
57
61
  ## 설치
58
62
 
@@ -91,13 +95,20 @@ Windows CI 결과·리빌드 소요 시간은 `docs/vault-search/WINDOWS.md` 에
91
95
 
92
96
  ## 업스트림 동기화
93
97
 
98
+ `develop` 에서 `chore/upstream-sync-*` 브랜치를 따고, 업스트림의 first-parent 머지 지점을 하나씩 `--no-ff` 로 머지한다. 대상은 태그든 `main` 스냅샷이든 된다.
99
+
94
100
  ```sh
95
- git fetch upstream
96
- git rebase upstream/<tag> # on ko
97
- 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
98
106
  ```
99
107
 
100
- 통과하면 `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` 식으로 마지막 태그 기준이다.
101
112
 
102
113
  ## 라이선스
103
114
 
package/README.md CHANGED
@@ -158,21 +158,41 @@ runs in a container and a liveness probe connects from a non-loopback address.
158
158
 
159
159
  The HTTP server exposes two endpoints:
160
160
  - `POST /mcp` — MCP Streamable HTTP (JSON responses, stateless)
161
- - `POST /query` (alias `/search`) — structured search without the MCP protocol
161
+ - `POST /query` (alias `/search`) — structured search without the MCP protocol. Accepts the same optional `filter` object as the `query` tool (invalid filters return `400`); see [Metadata Filtering](#metadata-filtering)
162
162
  - `GET /health` — liveness check with uptime
163
163
 
164
+ ##### Index refresh (ko-qmd)
165
+
166
+ Before each search, the daemon checks the collections that search covers and
167
+ re-indexes the ones that changed on disk, so a note written a minute ago is
168
+ findable without running `qmd update`. Each collection is checked at most once
169
+ every 30 seconds. The refresh updates the text index only: a new or edited note
170
+ is found by keyword search right away and by vector search after the next
171
+ `qmd embed`. It never runs a collection's `update:` command, never clears the
172
+ LLM cache, and leaves a collection alone when its folder has vanished or turned
173
+ up empty — an unmounted drive is not a reason to drop its documents. Collections
174
+ outside the project of a project-local `.qmd` config are never refreshed.
175
+ CLI searches do not refresh; run `qmd update` there.
176
+
164
177
  ##### Query log (ko-qmd)
165
178
 
166
179
  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.
180
+ is off) and every search it serves — `POST /query`, `/search`, and the MCP
181
+ `query` tool over HTTP — is appended as one JSON line to
182
+ `$XDG_CACHE_HOME/qmd/queries-YYYY-MM.jsonl` (default `~/.cache/qmd`, one file
183
+ per local month, mode `0600`, never pruned). CLI searches are not logged, and
184
+ neither is an MCP call over stdio: it carries no request to annotate.
171
185
 
172
186
  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
187
+ and last update), `via` (`rest` or `mcp`), the searches (a plain MCP `query`
188
+ argument is recorded as one search of type `auto`), collections, limit, rerank
189
+ flag, each result's
174
190
  `<collection>/<path>`, score and rank, and the elapsed milliseconds — no
175
- snippets or document text. Clients can annotate rows with request headers:
191
+ snippets or document text. On `rerank:false` rows each result also carries the
192
+ raw backend scores the response returned, `vec_score` (cosine similarity) and
193
+ `fts_score` (normalized BM25), since `score` there is only the 1/rank fusion
194
+ position; each is absent, never 0, when that backend did not return the hit,
195
+ and the row schema stays `v: 1`. Clients can annotate rows with request headers:
176
196
 
177
197
  | Header | Row field |
178
198
  |--------|-----------|
@@ -218,11 +238,15 @@ Point any MCP client at `http://localhost:8181/mcp` to connect.
218
238
  |------|-----------|------|-------|
219
239
  | `query` | `searches` | array | Typed sub-queries (`lex`/`vec`/`hyde`), 1–10. **Required.** First gets 2x weight. |
220
240
  | `query` | `collections` | string[] | Filter by collection names (OR). **Array only** — singular `collection` is silently ignored. |
241
+ | `query` | `filter` | object | Metadata filter (recursive `operator`-discriminated JSON AST; see [Metadata Filtering](#metadata-filtering)) |
221
242
  | `query` | `intent` | string | Disambiguation context (does not search on its own) |
222
243
  | `query` | `limit` | number | Max results (default 10) |
223
244
  | `query` | `minScore` | number | Minimum relevance 0–1 (default 0) |
224
245
  | `query` | `candidateLimit` | number | Max candidates to rerank (default 40) |
225
246
  | `query` | `rerank` | boolean | Run LLM reranking (default **true**); set false for RRF-only |
247
+ | `query` | `path` | string[] | ko-qmd: globs against `collection/path`; `!` prefix excludes |
248
+ | `query` | `since` | string | ko-qmd: span (`7d`) or date — documents modified at or after |
249
+ | `query` | `until` | string | ko-qmd: same formats — documents modified at or before |
226
250
  | `get` | `file` | string | Path, docid (`#abc123`), or `path:from:count` (e.g. `#abc123:120:40`) |
227
251
  | `get` | `fromLine` | number | Start line (1-indexed); overrides the `:from` suffix |
228
252
  | `get` | `maxLines` | number | Limit returned lines |
@@ -231,11 +255,27 @@ Point any MCP client at `http://localhost:8181/mcp` to connect.
231
255
  | `multi_get` | `maxBytes` | number | Skip files larger than N (default 10240) |
232
256
  | `multi_get` | `maxLines` | number | Limit lines per file |
233
257
  | `multi_get` | `lineNumbers` | boolean | Prefix lines with numbers (default **true**) |
258
+ | `grep` | `pattern` | string | ko-qmd: regex, or a literal when `fixedString` is set. **Required.** |
259
+ | `grep` | `collections` | string[] | Filter by collection names (OR) |
260
+ | `grep` | `path` / `since` / `until` | — | Same as on `query` |
261
+ | `grep` | `limit` | number | Max files (default 20) |
262
+ | `grep` | `maxLinesPerFile` | number | Max reported lines per file (default 20) |
263
+ | `grep` | `caseSensitive` | boolean | Override smart case |
264
+ | `grep` | `fixedString` | boolean | Treat the pattern as a literal string |
234
265
 
235
266
  Unknown parameters are silently ignored (not rejected) — double-check names if
236
267
  results seem unscoped. The HTTP `/query` and `/search` endpoints return
237
268
  `qmd://collection/path` URIs in the `file` field, matching the CLI and MCP output.
238
269
 
270
+ `path`, `since` and `until` are the MCP side of the CLI's filtering flags —
271
+ see [Path and Time Filtering](#path-and-time-filtering-ko-qmd) for their
272
+ semantics. They narrow the corpus before ranking, so a narrow filter returns
273
+ its own best matches rather than whatever survived the global top-K. A `since`
274
+ or `until` that does not parse is an error (a tool error on MCP, HTTP 400 on
275
+ `/query`) rather than a dropped filter, and an empty result under a filter
276
+ reports how much corpus the filter left so an agent can tell "nothing matched"
277
+ from "nothing to match against". The REST endpoints accept the same three.
278
+
239
279
  ### SDK / Library Usage
240
280
 
241
281
  Use QMD as a library in your own Node.js or Bun applications.
@@ -323,6 +363,20 @@ const results3 = await store.search({
323
363
 
324
364
  // Skip reranking for faster results
325
365
  const fast = await store.search({ query: "auth", rerank: false })
366
+
367
+ // Metadata filter — every returned result satisfies it (also available on
368
+ // searchLex() and searchVector()); results expose indexed metadata via
369
+ // r.metadata. See "Metadata Filtering" for the full grammar.
370
+ const published = await store.search({
371
+ query: "authentication flow",
372
+ filter: {
373
+ operator: "and",
374
+ operands: [
375
+ { key: "topics", operator: "all", value: ["typescript"] },
376
+ { key: "status", operator: "ne", value: "draft" },
377
+ ],
378
+ },
379
+ })
326
380
  ```
327
381
 
328
382
  For direct backend access:
@@ -891,8 +945,13 @@ and `deep-search` (→ `query`).
891
945
  --full # Show full document content
892
946
  --line-numbers # Add line numbers to output
893
947
  --explain # Include retrieval score traces (query, JSON/CLI output)
948
+ # ko-qmd: also lists each sub-query's own rank for the result
949
+ --filter <json> # Metadata filter (recursive JSON AST; see Metadata Filtering)
894
950
  --index <name> # Use named index
895
951
  --intent "<text>" # Disambiguation context (e.g. "web page load times")
952
+ --path <glob> # ko-qmd: only documents matching the glob (repeatable)
953
+ --since <when> # ko-qmd: only documents modified at or after this point
954
+ --until <when> # ko-qmd: only documents modified at or before this point
896
955
  --no-rerank # Skip LLM reranking (RRF scores only; faster on CPU)
897
956
  -C, --candidate-limit <n> # Max candidates to rerank (default: 40)
898
957
  --full-path # Emit on-disk filesystem paths instead of qmd:// URIs
@@ -934,6 +993,175 @@ explicitly with `-c`.
934
993
  > then filtered. If one collection dominates the rankings, matches from smaller
935
994
  > collections may not appear at the default limit — raise `-n` or use `--all`.
936
995
 
996
+ ### Reading `--explain` (ko-qmd)
997
+
998
+ `qmd query <q> --explain` prints, under the fused score, one line per expansion
999
+ sub-query that placed the document — with that sub-query's own rank, its RRF
1000
+ contribution, its backend score and the text it actually ran:
1001
+
1002
+ ```
1003
+ Explain: fts=[0.6700, 0.5100] vec=[0.4400]
1004
+ RRF: total=0.0889 base=0.0389 bonus=0.0500 rank=1
1005
+ Blend: 75%*1.0000 + 25%*0.8200 = 0.9550
1006
+ Sub-query ranks (3 of the expansion's lists placed this document):
1007
+ fts/original #1 rrf=0.0328 backend=0.6700 한글 토큰화
1008
+ vec/hyde #2 rrf=0.0161 backend=0.4400 형태소 분석기는 어절을 나눌 때...
1009
+ fts/lex #9 rrf=0.0061 backend=0.5100 토큰화 형태소
1010
+ ```
1011
+
1012
+ Expansion turns one query into a dozen and reports one number. That number
1013
+ cannot distinguish "first in the hyde list and nowhere else" from "middling
1014
+ everywhere", and those two call for different fixes — the first says the
1015
+ expansion invented a good query, the second says the document is broadly
1016
+ relevant. Previously only the top three contributions were shown, collapsed
1017
+ onto one line without the sub-query text.
1018
+
1019
+ ### `qmd grep` (ko-qmd)
1020
+
1021
+ ```sh
1022
+ qmd grep 'TODO' # smart case: lowercase matches either
1023
+ qmd grep 'TODO: (call|ship)' # a regular expression
1024
+ qmd grep 'v2.8.3-ko' -F # a literal string, regex chars and all
1025
+ qmd grep '놓친 문자열' --path 'journals/**'
1026
+ ```
1027
+
1028
+ Exact string and regular-expression matching over indexed document bodies: no
1029
+ ranking, no LLM, every matching line grouped by file with line numbers that
1030
+ `qmd get <file>:<n>:<count>` accepts.
1031
+
1032
+ This is the escape hatch for a query that finds nothing because tokenization
1033
+ never produced the term the document contains — the failure Korean text keeps
1034
+ hitting — and the way to find an identifier, error string or config key
1035
+ verbatim. Ranked search answers "what is this about"; grep answers "where does
1036
+ this string appear".
1037
+
1038
+ It is not a replacement for ripgrep. At a terminal with a shell, ripgrep is
1039
+ usually the right tool and faster. `qmd grep` earns its place for the caller
1040
+ with no shell — an MCP client whose only tools are `query`, `get` and
1041
+ `multi_get` — and for the caller who wants one corpus definition: it searches
1042
+ exactly what the index holds, honours collection exclusion and the `--path` /
1043
+ `--since` / `--until` filters, and returns `qmd://` paths and docids the rest
1044
+ of qmd accepts.
1045
+
1046
+ Smart case: a pattern containing an uppercase letter matches case-sensitively,
1047
+ otherwise either case. Hangul has no case, so Korean patterns are unaffected
1048
+ either way. `-i` and `-S` override it, `-F` treats the pattern as a literal
1049
+ string, `--max-lines` caps reported lines per file, and `-n` caps files.
1050
+
1051
+ Patterns are capped at 1000 characters and matched line by line, with long
1052
+ lines tested in overlapping slices. That bound is what keeps a pathological
1053
+ pattern from hanging the daemon: a catastrophic backtrack blocks the only
1054
+ thread, so a timeout could never fire to stop it.
1055
+
1056
+ ### Path and Time Filtering (ko-qmd)
1057
+
1058
+ `--path`, `--since` and `--until` narrow the corpus a search runs against, on
1059
+ `search`, `vsearch` and `query` alike. They are not a filter over the results:
1060
+ the documents they exclude are gone before anything is ranked, so a narrow
1061
+ filter returns its own best matches rather than whatever survived the global
1062
+ top-K.
1063
+
1064
+ ```sh
1065
+ qmd query "retry policy" --since 7d # touched in the last week
1066
+ qmd query "retry policy" --since 2026-09-01 # or an explicit date
1067
+ qmd search "auth" --path 'notes/journals/**' # only under this path
1068
+ qmd search "auth" --path 'notes/**' --path '!notes/archive/**'
1069
+ ```
1070
+
1071
+ `--path` matches globs against `collection/path` — the same string results
1072
+ print — and is repeatable. A `!` prefix excludes, and an exclude beats an
1073
+ include. A bare directory (`--path notes/journals`) means everything under it.
1074
+
1075
+ `--since` / `--until` take either a span back from now (`30m`, `3h`, `7d`,
1076
+ `2w`, `6mo`, `1y`) or a date (`2026-09-01`, or a full ISO timestamp). A bare
1077
+ date starts at that local day. Anything else is an error rather than a silently
1078
+ ignored filter, because a search that quietly dropped your filter returns a
1079
+ full-corpus answer that looks exactly like a narrow one.
1080
+
1081
+ They compare against the index's `modified_at`, which is when the document was
1082
+ last indexed with changed content — not the file's mtime. The daemon's index
1083
+ refresh keeps those within 30 seconds of each other; for CLI searches, `--since`
1084
+ is only as current as your last `qmd update`.
1085
+
1086
+ When a filter leaves nothing to search, the empty result says so rather than
1087
+ looking like "no match":
1088
+
1089
+ ```
1090
+ No results found — path 'notes/nowhere/**' matched 0 of 232 documents.
1091
+ No results found in the 4 of 232 documents matching since 2026-09-13T12:00:00.000Z.
1092
+ ```
1093
+
1094
+ These flags combine with `--filter` (Metadata Filtering, below): a result has to
1095
+ pass both. In the SDK the path/time narrowing is the `scope` option (build it
1096
+ with `buildDocumentFilter`), because `filter` is upstream's metadata filter; the
1097
+ REST and MCP `path` / `since` / `until` parameters are unchanged.
1098
+
1099
+ ### Metadata Filtering
1100
+
1101
+ Documents can opt into typed metadata through a namespaced frontmatter block. A document without `qmd.metadata` behaves exactly as before, and the frontmatter stays ordinary searchable content (no chunking, embedding, or line-number changes):
1102
+
1103
+ ```markdown
1104
+ ---
1105
+ qmd:
1106
+ metadata:
1107
+ topics:
1108
+ - typescript
1109
+ - programming
1110
+ status: published
1111
+ priority: 3
1112
+ reviewed: true
1113
+ ---
1114
+
1115
+ # Document body starts here
1116
+ ```
1117
+
1118
+ Supported values are strings, numbers, booleans, and flat homogeneous arrays of one of those. Nested objects, nulls, empty arrays, and mixed-type arrays are rejected (the document still indexes; it is excluded from filtered search until corrected). Metadata keys are user-defined data — `tags`, `topics`, and `labels` are all ordinary keys with no special semantics.
1119
+
1120
+ Every search surface (CLI, SDK, MCP, HTTP) accepts the same recursive filter, a JSON AST discriminated by `operator`:
1121
+
1122
+ ```sh
1123
+ # One condition
1124
+ qmd search "authentication" \
1125
+ --filter '{"key":"status","operator":"eq","value":"published"}'
1126
+
1127
+ # Composed conditions — works with search, vsearch, and query
1128
+ qmd query "dependency injection" --filter '{
1129
+ "operator": "and",
1130
+ "operands": [
1131
+ { "key": "topics", "operator": "all", "value": ["typescript", "programming"] },
1132
+ { "key": "status", "operator": "nin", "value": ["draft", "archived"] },
1133
+ { "operator": "or", "operands": [
1134
+ { "key": "priority", "operator": "gte", "value": 3 },
1135
+ { "key": "reviewed", "operator": "eq", "value": true }
1136
+ ] },
1137
+ { "operator": "not", "operand": { "key": "audience", "operator": "eq", "value": "internal" } }
1138
+ ]
1139
+ }'
1140
+ ```
1141
+
1142
+ | Node | Shape |
1143
+ |------|-------|
1144
+ | Logical group | `{ "operator": "and" \| "or", "operands": […] }` |
1145
+ | Negation | `{ "operator": "not", "operand": {…} }` |
1146
+ | Comparison | `{ "key", "operator": "eq" \| "ne" \| "gt" \| "gte" \| "lt" \| "lte", "value" }` |
1147
+ | Membership | `{ "key", "operator": "in" \| "nin" \| "all", "value": […] }` |
1148
+ | Presence | `{ "key", "operator": "exists", "value": true \| false }` |
1149
+
1150
+ Semantics:
1151
+
1152
+ - Matching is typed and exact — no string/number/boolean coercion, and a type mismatch never matches (including `ne` and `nin`).
1153
+ - Array-valued metadata is a set: a condition matches when any element satisfies it, `all` requires every filter value to be present.
1154
+ - Missing keys do not match `ne`/`nin`; combine with `{ "operator": "exists", "value": false }` in an `or` group to include them.
1155
+ - Multiple conditions require an explicit `and` group — there is no implicit AND, and no `$`-prefixed shorthand.
1156
+
1157
+ Guarantees and limits:
1158
+
1159
+ - Every returned result satisfies the filter, before RRF fusion and reranking.
1160
+ - Like collection filtering, highly selective filters are best-effort for top-K completeness: backends over-fetch and post-filter, so a very selective filter can return fewer than `limit` results. (ko-qmd: a metadata filter takes the same full-scan / exact-scan path as `--path`, so a selective filter still returns its own best matches.)
1161
+ - Filtered search only considers documents whose metadata has been extracted (run `qmd update` after upgrading; `qmd status` shows the pending count).
1162
+
1163
+ JSON output (`--format json`), the SDK, MCP structured results, and the HTTP endpoints include each result's indexed metadata.
1164
+
937
1165
  ### Output Format
938
1166
 
939
1167
  Default output is colorized CLI format (respects `NO_COLOR` env).
@@ -1180,7 +1408,7 @@ llm_cache -- Cached LLM responses (query expansion, rerank scores)
1180
1408
  | `QMD_CONFIG_DIR` | unset | Override the config directory outright (takes precedence over `XDG_CONFIG_HOME`) |
1181
1409
  | `QMD_LLAMA_GPU` | `auto` | Force llama.cpp GPU backend (`metal`, `vulkan`, `cuda`) or disable GPU with `false` |
1182
1410
  | `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)) |
1411
+ | `QMD_QUERY_LOG` | unset | ko-qmd: set to `1`/`true`/`yes` on the HTTP daemon to append each REST or MCP search to `$XDG_CACHE_HOME/qmd/queries-YYYY-MM.jsonl` (see [Query log](#query-log-ko-qmd)) |
1184
1412
  | `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. |
1185
1413
  | `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. |
1186
1414
 
@@ -1,4 +1,4 @@
1
1
  {
2
- "commit": "d4da081",
3
- "builtAt": "2026-09-18T03:59:04.070Z"
2
+ "commit": "bec590b",
3
+ "builtAt": "2026-09-24T04:13:20.171Z"
4
4
  }
package/dist/cli/qmd.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type RRFContributionTrace } from "../store.js";
1
2
  import { type OutputFormat } from "./formatter.js";
2
3
  type CliLifecycleWritable = {
3
4
  write(chunk: string | Uint8Array, callback?: (error?: Error | null) => void): boolean;
@@ -38,6 +39,15 @@ export declare function finishSuccessfulCliCommand(options: FinishSuccessfulCliC
38
39
  export declare function resolveEmbedModelForCli(): string;
39
40
  export declare function resolveGenerateModelForCli(): string;
40
41
  export declare function resolveRerankModelForCli(): string;
42
+ /**
43
+ * Which sub-query found this document, and where did it place in that sub-query's own list?
44
+ *
45
+ * Expansion turns one query into a dozen, fuses their lists and reports one number. A single
46
+ * fused rank cannot say whether a result placed first in the hyde list and nowhere else, or
47
+ * middling everywhere — and those call for different fixes. Each expansion gets a line, with
48
+ * the sub-query text it ran, so the fused score can be read back to the query that earned it.
49
+ */
50
+ export declare function explainGroupLines(contributions: readonly RRFContributionTrace[]): string[];
41
51
  export declare function buildEditorUri(template: string, absolutePath: string, line: number, col: number): string;
42
52
  export declare function termLink(text: string, url: string, isTTY?: boolean): string;
43
53
  export {};