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 +129 -0
- package/README.ko.md +18 -7
- package/README.md +236 -8
- package/dist/cli/build-info.json +2 -2
- package/dist/cli/qmd.d.ts +10 -0
- package/dist/cli/qmd.js +261 -25
- package/dist/collections.d.ts +2 -0
- package/dist/collections.js +9 -4
- package/dist/db.js +1 -0
- package/dist/filters.d.ts +46 -0
- package/dist/filters.js +120 -0
- package/dist/grep.d.ts +82 -0
- package/dist/grep.js +159 -0
- package/dist/hangul.d.ts +32 -3
- package/dist/hangul.js +142 -6
- package/dist/index.d.ts +24 -0
- package/dist/index.js +19 -2
- package/dist/llm.d.ts +7 -1
- package/dist/llm.js +23 -4
- package/dist/mcp/server.js +275 -12
- package/dist/metadata-filter.d.ts +74 -0
- package/dist/metadata-filter.js +279 -0
- package/dist/metadata-store.d.ts +45 -0
- package/dist/metadata-store.js +170 -0
- package/dist/metadata.d.ts +61 -0
- package/dist/metadata.js +215 -0
- package/dist/query-log.d.ts +30 -9
- package/dist/query-log.js +22 -3
- package/dist/refresh.d.ts +58 -0
- package/dist/refresh.js +139 -0
- package/dist/store.d.ts +49 -14
- package/dist/store.js +356 -155
- package/package.json +4 -2
- package/skills/qmd/SKILL.md +14 -0
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
|
|
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·질의
|
|
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
|
|
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
|
|
97
|
-
|
|
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
|
-
|
|
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
|
|
168
|
-
one JSON line to
|
|
169
|
-
`~/.cache/qmd`, one file
|
|
170
|
-
|
|
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),
|
|
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.
|
|
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
|
|
package/dist/cli/build-info.json
CHANGED
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 {};
|