ko-qmd 2.8.3-ko.5 → 2.8.3-ko.6

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,113 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [2.8.3-ko.6] - 2026-09-29
6
+
7
+ Korean queries are handled better before and during reranking. A query written
8
+ as "X 말고 Y" now searches for Y alone, and a Hangul query skips LLM expansion,
9
+ which steadies hard-query results and cuts hybrid latency from about 1s to tens
10
+ of ms per query. The reranker is now shown the chunk that best matches the
11
+ query's Hangul syllable bigrams, led by the note title: held-out full MRR goes
12
+ 0.6589 → 0.7260 on a goldset generated from a real vault. Scoped vector search on
13
+ small indexes runs one KNN (vector stage 250–1300ms → about 25ms). The README
14
+ gains an English edition and an FAQ.
15
+
16
+ - `QMD_RERANK_CACHE=<path>` keeps rerank scores in their own sqlite file instead of the index's
17
+ `llm_cache`, so a store over a rebuilt index reuses them. The key carries the query, the rerank
18
+ model, the doc-token cap and the chunk text, not the scoring code in `src/llm.ts`, so the file is
19
+ safe across index rebuilds with the same reranker code. It is not trimmed to 1000 rows. With it
20
+ set, a second `scripts/bench-ko.sh` run on ko-vault gives the same lines and its `full` backend
21
+ spends 3.7 s reranking instead of 300 s (the whole run drops from 5:17 to 0:56). Unset, nothing
22
+ changes. A fallback result (no rerank context could be created, every score 0.5) is no longer
23
+ cached in either place.
24
+
25
+ - The reranker now sees a better chunk of a long Korean note. The chunk is chosen by the
26
+ two-syllable pairs of the query's Hangul words of three or more syllables, instead of whole
27
+ words, so `임베딩은` still finds a chunk about `임베딩`. Generic two-syllable words such as 방법
28
+ and 경우 no longer steer the choice. A query word with no Hangul (an English term, say) counts double. When the chosen chunk
29
+ does not contain the note title, `# <title>` is put in front of it. An autonomous loop
30
+ (`scripts/autoresearch/`) found this over 28 experiments on a goldset generated from a private
31
+ vault. The goldset's held-out split, which the loop never saw, goes from full MRR 0.6589 to
32
+ 0.7260 (32 queries). ko-vault seam and hard lines are unchanged. The bm25, vector and hybrid
33
+ results do not read the rerank input and are identical on both sets (`BASELINE.md`
34
+ 2026-09-28). An intent word now counts 1 point in chunk selection (was 0.5), keeping it at half a
35
+ query word now that a non-Hangul query word counts 2; no goldset carries intent, so no bench
36
+ number moves.
37
+
38
+ - The reranker's input (the query string, which chunk of each candidate is sent and the document
39
+ text) is now built in `src/rerank-input.ts` for both `hybridQuery` and `structuredSearch`, which
40
+ had copied the chunk-scoring loop. Results are unchanged.
41
+
42
+ - A Korean query of the form "X 말고 Y" now searches for Y alone. `hybridQuery` (CLI `qmd query`,
43
+ plain MCP `query`) and the `lex`/`vec` lines of `structuredSearch` (MCP `searches`, typed lines)
44
+ drop everything up to the last whitespace-separated 말고, 빼고, 제외하고 or 제외한. The rerank
45
+ query follows. `hyde` lines, `intent` and `qmd search`/`vsearch` stay as written. Before this
46
+ change every list also searched for X, the concept the user ruled out, so a note about X often
47
+ took rank 1. `아닌` and bare `제외` are not markers, because they also state conditions or act as
48
+ nouns. On the ko-vault seam form, the `neg` queries' full MRR goes 0.4500 → 0.6944. Hard
49
+ `full_r1` goes 0.5111 → 0.6111 and hard `full_mrr` 0.7472 → 0.8287. No query outside `neg`
50
+ changed its results, and that run is the gate's new reference (`BASELINE.md` 2026-09-27,
51
+ "a Korean negation clause is stripped before search").
52
+
53
+ - `structuredSearch` (MCP `searches`, SDK `search({ queries })`, typed `lex:`/`vec:` lines) now
54
+ counts a relaxed lex list, one that matched only through the any-word retry, at half its slot's
55
+ weight, as `hybridQuery` already did. At a flat 2.0 for the first list, a lex line that matched
56
+ some of a Korean question's words outranked the vector list, and a decoy sharing those words took
57
+ rank 1. This one weight was the whole gap between the two paths on the Korean ko-vault queries:
58
+ re-fusing each path's lists with the other's weights reproduced its top 10 for 85 of 85 queries.
59
+ On the seam form, hard `hybrid_r1` goes 0.3111 → 0.3778 and `hybrid_r5` 0.8996 → 0.9211, with
60
+ `full_r1` unchanged at 0.5111, and that run is the gate's new reference (`BASELINE.md` 2026-09-27).
61
+
62
+ - A query with any Hangul is no longer expanded by the LLM on the hybrid path (`hybridQuery`). That
63
+ path serves CLI `qmd query`, including an explicit `expand:` line, as well as a plain MCP `query`
64
+ and SDK `search({ query })`; `qmd vsearch` still expands. On the ko-vault goldset the
65
+ expansion model wrapped Korean questions in English sentence templates and stray Chinese, and its
66
+ sampling alone moved hard rank 1 by up to five of 30 queries between runs. Without it, hard
67
+ `hybrid_r1` reads 0.3778 (0.2611–0.3611 with expansion), `full_r1` holds at 0.5111, the easy
68
+ queries lose nothing, and the median hybrid query drops from about 1 s to 20 ms. The Korean
69
+ queries now return the same results on every run. English-only queries are still expanded, and a
70
+ caller who wants variants for a Korean question can pass its own `searches`
71
+ (`BASELINE.md` 2026-09-26).
72
+
73
+ - `scripts/bench-ko.sh` benches the seam form by default. Every one-line goldset query reaches
74
+ `qmd bench` as `lex: <q>` + `vec: <q>`, the shape REST/MCP callers send, which skips LLM query
75
+ expansion. `KO_FORM=plain` keeps the old path, and the `RESULT-HARD` line now ends with
76
+ `form=<seam|plain>`. The `dogfood.sh` gate refuses a bench whose form differs from its baseline
77
+ line's form. On the seam form it gates hard `hybrid_r1` as well as `full_r1`, against a reference
78
+ line with `tol=0`, since the seam form gave the same lines in every run. The reason is the plain
79
+ form's hard wobble, which turned out to be the expansion sampler alone. Replaying recorded
80
+ expansions reproduces every query's top 10, and every query that moved between same-commit runs
81
+ had drawn a different expansion. The draw moved `hybrid_r1` by up to five of 30 queries and once
82
+ put `full_r1` 0.0007 above the old gate's floor (`BASELINE.md` 2026-09-26). The gate also runs
83
+ the bench with any exported `KO_BENCH`/`KO_CORPUS`/`KO_FORM` removed, and `bench-ko.sh` refuses a
84
+ `KO_BENCH` inside `tmp/bench-ko/`, where the run clears its outputs.
85
+
86
+ - `scripts/bench-ko.sh` takes `KO_CORPUS` (a corpus directory holding `wiki/` and
87
+ `distractors/`) and `KO_BENCH` (a goldset) so an A/B runs through the same isolated harness
88
+ and stdout lines. Overrides resolve against the caller's cwd and are checked before the
89
+ previous run's output is removed. The first use settled a question: three everyday-language
90
+ question lines per document, decoys included, did not move hard rank 1 on the REST-shaped
91
+ goldset (hybrid_r1 0.3111 → 0.2778, full_r1 flat at 0.5111; hard hit@5 up 2 of 30). Index-time
92
+ summaries are therefore not being built. The same runs point the `hybrid_r1` wobble at LLM
93
+ query expansion rather than re-embedding (`BASELINE.md` 2026-09-25).
94
+ - ko-vault goldset: 69 near-topic distractor documents and 30 hard queries (`sem-hard` /
95
+ `multi` / `neg`) join the fixture; `bench-ko.sh` indexes both directories (97 files) and
96
+ prints a `RESULT-HARD hybrid_r1=… full_r1=… n=30` line next to `RESULT`. The `dogfood.sh`
97
+ gate now also fails when the hard `full_r1` falls below the baseline minus the `tol=` recorded
98
+ on that baseline line, and fails closed when `RESULT-HARD` or its `tol=` goes missing.
99
+ `full_r1` is gated rather than `hybrid_r1` because it held one value across eight same-commit
100
+ runs, while `hybrid_r1` moved by about 3.5 queries of 30.
101
+ Fixture invariants (no title-term leakage, no excluded concept among expected files, no basename
102
+ collisions) are enforced by `test/ko-bench-fixture.test.ts`.
103
+
104
+ - Collection- or filter-scoped vector search is faster on indexes of up to
105
+ 4096 vectors. It now post-filters one sqlite-vec KNN over the whole table,
106
+ which is exact because vec0 KNN is a brute-force scan, instead of computing
107
+ `vec_distance_cosine` row by row. On the KB goldset (1047 of 3389 vectors,
108
+ M3 Max) the vector stage dropped from 250–1300ms to about 25ms, and a cold
109
+ capped query (cap 128, candidateLimit 15) went from p90 2000ms to 906ms with
110
+ identical rankings. Larger indexes keep the exact scan.
111
+
5
112
  ## [2.8.3-ko.5] - 2026-09-24
6
113
 
7
114
  Rerank gets an input cap. Truncating each document to 128 tokens keeps KB
@@ -88,7 +195,7 @@ it to `scope`.
88
195
  - Plain Hangul lex terms that spell a common technical loanword (`서치`, `웹`,
89
196
  `코어`, `마이그레이션`, about 80 entries in a built-in table) also match the
90
197
  Latin spelling, so `하이브리드 서치` finds a note that only says
91
- `hybrid-search` and `레몬 웹 코어` reaches `lemon-web-core`. The lookup runs
198
+ `hybrid-search` and `리액트 웹 코어` reaches `react-web-core`. The lookup runs
92
199
  on particle-stripped stems too (`서치를`), single-syllable entries (`웹`, `훅`)
93
200
  are bridged when they stand alone (not `웹을`), and terms with no entry produce the same FTS5 query as before.
94
201
  Query-side only; no re-index. On the ko-vault bench the eleven new loanword
package/README.en.md ADDED
@@ -0,0 +1,184 @@
1
+ # ko-qmd — local hybrid search for Korean Markdown notes
2
+
3
+ [![npm](https://img.shields.io/npm/v/ko-qmd?label=npm%20ko-qmd)](https://www.npmjs.com/package/ko-qmd)
4
+ [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
5
+
6
+ [한국어](README.md) · **English**
7
+
8
+ **ko-qmd is an on-device search engine for Korean Markdown notes — Obsidian vaults, wikis, meeting
9
+ notes and docs folders — that finds them by keyword or by natural-language question.**
10
+ It combines BM25 full-text search, vector search and LLM reranking, and matches Korean query words
11
+ by their stem even when a particle or verb ending is attached. Every model runs locally through
12
+ [node-llama-cpp](https://github.com/withcatai/node-llama-cpp) (GGUF, no network calls), so your notes
13
+ never leave the machine. AI agents such as Claude Code and Claude Desktop use it through the CLI, an
14
+ MCP server, a REST endpoint or the TypeScript SDK.
15
+ It is the Korean distribution of [tobi/qmd](https://github.com/tobi/qmd) (MIT).
16
+
17
+ - Package: `ko-qmd` on npm. The binary and MCP server keep the upstream name, `qmd`
18
+ - Version: `<upstream version>-ko.N` (e.g. `2.8.3-ko.5`)
19
+ - Full reference (every CLI option, MCP tool parameters, SDK API, scoring): [docs/REFERENCE.md](docs/REFERENCE.md)
20
+ - Changes: [CHANGELOG.md](CHANGELOG.md) · [FAQ](#faq)
21
+
22
+ ```mermaid
23
+ flowchart LR
24
+ Q[Query] --> H[Korean handling<br/>particle/ending strip · loanword bridge]
25
+ H --> FTS[BM25<br/>FTS5 + syllable bigrams]
26
+ Q --> VS[Vector search<br/>Qwen3-Embedding]
27
+ FTS --> RRF[RRF fusion]
28
+ VS --> RRF
29
+ RRF --> RR[Rerank<br/>Qwen3-Reranker]
30
+ RR --> OUT[Results]
31
+ ```
32
+
33
+ ## What differs from upstream qmd
34
+
35
+ ### Korean keyword search (lex)
36
+
37
+ - **Particle and ending stripping** — an unquoted Hangul word also matches its stem: one trailing particle
38
+ (`검색을` → `검색`), a two-particle chain (`청킹에서의` → `청킹`), verb and nominalizing endings
39
+ (`토큰화하는` → `토큰화`, `검색하기` → `검색`), `한`/`된` endings (`필요한` → `필요`) and the 하↔해
40
+ contraction. Quoted phrases, Hanja and kana are left as written.
41
+ - **Loanword bridge** — about 80 built-in Hangul spellings of technical terms (`서치`, `웹`, `코어`,
42
+ `마이그레이션`, …) also search their English spelling, so `하이브리드 서치` finds a `hybrid-search` note.
43
+ Only the query changes; no re-index is needed.
44
+ - **Mixed-script token split** — `SKILL.md계약의핵심` becomes `skill` AND `md` AND `계약의핵심`.
45
+ - **Long-query relaxation** — a query of three or more words that matches nothing with AND is retried with
46
+ OR, so a natural-language sentence never returns zero hits.
47
+ - **Syllable-bigram index** — Hangul runs are indexed as syllable bigrams too, so a word inside a
48
+ compound written without spaces still matches.
49
+ - **Negation strip** — "X 말고 Y" ("Y, not X") searches for Y alone.
50
+
51
+ ### Models and chunking
52
+
53
+ | Role | Default model | Upstream |
54
+ |---|---|---|
55
+ | Embedding | `Qwen3-Embedding-0.6B` (Q8_0) | embeddinggemma-300M |
56
+ | Rerank | `Qwen3-Reranker-0.6B` (Q8_0) | same |
57
+ | Query expansion | `qmd-query-expansion-1.7B` | same |
58
+
59
+ - Korean documents are chunked by their real token budget: Hangul costs far more tokens per character
60
+ than English (measured on Qwen3-Embedding: 1.64 chars/token for Korean, 6.08 for English). English
61
+ chunk boundaries match upstream.
62
+ - Hybrid RRF weights are tuned for Korean, and a query containing Hangul skips LLM query expansion on
63
+ the hybrid path (English queries are still expanded).
64
+ - The rerank blend lets the reranker actually change the top result, and the reranker is shown the
65
+ chunk that best matches the query's Hangul syllable bigrams, led by the note title.
66
+
67
+ ### Scope, tools and daemon
68
+
69
+ - **Path and time scope** — CLI `--path` / `--since` / `--until`, the same fields over REST and MCP,
70
+ `scope` in the SDK. Ranking happens inside the filtered corpus.
71
+ - **`qmd grep`** — exact string or regex match over indexed bodies, for spellings the tokenizer never
72
+ produced.
73
+ - **Daemon** — re-indexes changed files before a search (once per collection every 30 s), optional query
74
+ log (`QMD_QUERY_LOG=1`), raw `vec_score` / `fts_score` with `rerank:false`, and a per-document
75
+ rerank token cap (`QMD_RERANK_MAX_DOC_TOKENS`, or `rerankMaxDocTokens` per request).
76
+
77
+ ## Install
78
+
79
+ ```sh
80
+ npm install -g ko-qmd # or pin: ko-qmd@2.8.3-ko.5
81
+ ```
82
+
83
+ - It cannot sit beside upstream `@tobilu/qmd` (same binary name): `npm uninstall -g @tobilu/qmd` first.
84
+ - Requires Node.js 22+ or Bun; on macOS, Homebrew SQLite (for extension loading). Models download to
85
+ `~/.cache/qmd/models/` on first use.
86
+
87
+ ## Quick start
88
+
89
+ ```sh
90
+ qmd collection add ~/notes --name notes # register and index a folder
91
+ qmd context add qmd://notes "Personal dev notes" # description returned with results
92
+ qmd embed # vector embeddings (first time, then changes only)
93
+
94
+ qmd query "하이브리드 서치 설계 결정" # fusion + rerank (recommended)
95
+ qmd search "리랭크" # BM25 only (fast, no model)
96
+ qmd vsearch "검색 지연을 줄인 방법" # vector only
97
+ qmd grep "QMD_QUERY_LOG" # exact body match
98
+ qmd get "#abc123" # fetch by docid
99
+ ```
100
+
101
+ Common options: `-c <collection>` · `-n <count>` · `--min-score <score>` · `--full` ·
102
+ `--intent "<what you are after>"` · `--no-rerank` · `--format json|csv|md|xml|files`.
103
+
104
+ ## Use from AI agents
105
+
106
+ ```sh
107
+ qmd mcp # stdio MCP server (Claude Desktop, Claude Code, …)
108
+ qmd mcp --http # HTTP on port 8181 — models load once, sessions share them
109
+ qmd mcp --http --daemon # background; stop with qmd mcp stop
110
+ ```
111
+
112
+ The HTTP daemon serves `POST /mcp` (MCP Streamable HTTP), `POST /query` (REST) and `GET /health`.
113
+
114
+ ```sh
115
+ curl -s http://127.0.0.1:8181/query -H 'Content-Type: application/json' -d '{
116
+ "searches": [{"type": "vec", "query": "리랭크 지연"}, {"type": "lex", "query": "리랭크 지연"}],
117
+ "collections": ["notes"], "limit": 10
118
+ }'
119
+ ```
120
+
121
+ ```ts
122
+ const { createStore } = await import('ko-qmd') // ESM only — require() does not work
123
+
124
+ const store = await createStore({ dbPath: './index.sqlite', configPath: `${process.env.HOME}/.config/qmd/index.yml` })
125
+ const results = await store.search({
126
+ queries: [{ type: 'vec', query: '리랭크 지연' }, { type: 'lex', query: '리랭크 지연' }],
127
+ collection: 'notes', limit: 10,
128
+ })
129
+ await store.close()
130
+ ```
131
+
132
+ Tool list, request fields and SDK API: [docs/REFERENCE.md](docs/REFERENCE.md). Search guidance for
133
+ agents, including a section on Korean queries: [skills/qmd/SKILL.md](skills/qmd/SKILL.md).
134
+
135
+ ## Benchmark
136
+
137
+ `bash scripts/bench-ko.sh` runs the `test/fixtures/ko-vault/` fixture (97 documents, 93 queries).
138
+ On its first 52 queries, bm25 recall@5 went from 0.6250 on upstream 2.8.3 to 0.9519 in this
139
+ distribution. Every run's numbers are in [BASELINE.md](test/fixtures/ko-vault/BASELINE.md).
140
+
141
+ ## FAQ
142
+
143
+ ### What is ko-qmd?
144
+ A CLI, MCP server and SDK that search Korean Markdown notes on your own machine. It adds Korean
145
+ particle and ending handling, a Hangul syllable-bigram index, a Korean-friendly default embedding
146
+ model (Qwen3-Embedding) and tuned fusion weights on top of upstream qmd.
147
+
148
+ ### Why not use upstream qmd for Korean?
149
+ Upstream BM25 treats a word with a particle attached (`검색을`, `청킹에서의`) as a different token from
150
+ its stem, so it misses notes that say `검색`. On the ko-vault benchmark (52 queries) bm25 recall@5 is
151
+ 0.6250 upstream and 0.9519 here.
152
+
153
+ ### Can it search an Obsidian vault?
154
+ Yes. Register the vault folder with `qmd collection add <vault path> --name <name>` and it indexes the
155
+ `*.md` files inside. It only reads your files.
156
+
157
+ ### Does it need internet access or an API key?
158
+ No. The embedding, rerank and query-expansion models download once on first use and run offline after
159
+ that.
160
+
161
+ ### How do I connect it to Claude Code or another agent?
162
+ Start the MCP server with `qmd mcp` (stdio) or `qmd mcp --http --daemon` (HTTP, shared models). See
163
+ [Use from AI agents](#use-from-ai-agents).
164
+
165
+ ### Does it work for English notes too?
166
+ Yes. English queries and documents behave as upstream: same chunk boundaries, same query expansion.
167
+ Korean and English notes can live in one index.
168
+
169
+ ## Development
170
+
171
+ ```sh
172
+ bun install
173
+ bun src/cli/qmd.ts <command> # run from source
174
+ bun run lint && bun run test:types
175
+ npx vitest run --reporter=verbose test/
176
+ npm run build # dist/ — never `bun build --compile` (it breaks sqlite-vec)
177
+ ```
178
+
179
+ `develop` is the working branch (PRs go there); `main` is the release branch. Release, dogfood and
180
+ upstream-sync procedures are documented in Korean in [README.md](README.md#개발).
181
+
182
+ ## License
183
+
184
+ MIT. Original copyright Tobi Lutke ([LICENSE](LICENSE)); this distribution uses the same license.
package/README.md CHANGED
@@ -1,13 +1,20 @@
1
- # ko-qmd
1
+ # ko-qmd — 한국어 마크다운 노트를 위한 로컬 하이브리드 검색 엔진
2
2
 
3
- 한국어 마크다운 노트를 위한 로컬 검색 엔진. [tobi/qmd](https://github.com/tobi/qmd)(MIT)의 한국어 배포판이다.
4
- 노트·회의록·문서를 색인해 키워드로도 자연어로도 찾고, 에이전트가 CLI·MCP·REST·SDK 로 쓴다.
5
- 모든 모델은 [node-llama-cpp](https://github.com/withcatai/node-llama-cpp) 로 로컬에서 돈다(GGUF, 네트워크 호출 없음).
3
+ [![npm](https://img.shields.io/npm/v/ko-qmd?label=npm%20ko-qmd)](https://www.npmjs.com/package/ko-qmd)
4
+ [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
5
+
6
+ **한국어** · [English](README.en.md)
7
+
8
+ **ko-qmd 는 한국어 마크다운 노트(Obsidian 볼트·위키·회의록·문서 폴더)를 키워드와 자연어로 찾는 로컬 검색 엔진이다.**
9
+ BM25 전문 검색, 벡터 검색, LLM 리랭크를 섞어 쓰고, 조사·어미가 붙은 한국어 질의도 어간으로 찾는다.
10
+ 모든 모델이 [node-llama-cpp](https://github.com/withcatai/node-llama-cpp) 로 내 컴퓨터에서 돌아 노트가 밖으로 나가지 않는다(GGUF, 네트워크 호출 없음).
11
+ AI 에이전트(Claude Code·Claude Desktop 등)는 CLI·MCP 서버·REST·TypeScript SDK 로 쓴다.
12
+ [tobi/qmd](https://github.com/tobi/qmd)(MIT)의 한국어 배포판이다.
6
13
 
7
14
  - 패키지: `ko-qmd` (npm). 실행 파일과 MCP 서버 이름은 업스트림과 같은 `qmd`
8
- - 버전: `<업스트림 버전>-ko.N` (예: `2.8.3-ko.4`)
15
+ - 버전: `<업스트림 버전>-ko.N` (예: `2.8.3-ko.5`)
9
16
  - 영어 레퍼런스(전체 CLI 옵션·MCP 툴 파라미터·SDK API·점수 계산): [docs/REFERENCE.md](docs/REFERENCE.md)
10
- - 변경 이력: [CHANGELOG.md](CHANGELOG.md)
17
+ - 변경 이력: [CHANGELOG.md](CHANGELOG.md) · 자주 묻는 질문: [FAQ](#자주-묻는-질문)
11
18
 
12
19
  ```mermaid
13
20
  flowchart LR
@@ -28,11 +35,12 @@ flowchart LR
28
35
  동사·명사화 어미(`토큰화하는` → `토큰화`, `검색하기` → `검색`), `한`/`된` 어미(`필요한` → `필요`), 하↔해 축약(`더하` ↔ `더해`).
29
36
  따옴표 구문·한자·가나는 그대로 둔다.
30
37
  - **외래어 다리** — 기술 용어의 한글 표기(`서치`·`웹`·`코어`·`마이그레이션` 등 약 80개 내장 표)는 영문 표기도 함께 찾는다.
31
- `하이브리드 서치`가 `hybrid-search` 노트를, `레몬 웹 코어`가 `lemon-web-core`를 찾는다. 질의 쪽만 바뀌므로 재색인은 필요 없다.
38
+ `하이브리드 서치`가 `hybrid-search` 노트를, `리액트 웹 코어`가 `react-web-core`를 찾는다. 질의 쪽만 바뀌므로 재색인은 필요 없다.
32
39
  - **문자 체계가 섞인 토큰 분리** — `SKILL.md계약의핵심`은 `skill` AND `md` AND `계약의핵심`으로 나눈다.
33
40
  - **긴 질의 완화** — 세 단어 이상 질의가 AND 로 한 건도 안 맞으면 같은 단어를 OR 로 다시 찾는다. 자연어 문장 질의가 0건이 되지 않는다.
34
41
  - **음절 bigram 색인** — 한글 구간의 음절 bigram 을 FTS 필드에 덧붙인다. 붙여 쓴 복합어 안의 단어도 걸린다.
35
42
  기존 인덱스는 처음 열 때 FTS 를 한 번 다시 만든다.
43
+ - **부정 절 제거** — "X 말고 Y" 꼴 질의는 Y 만 찾는다(`말고`·`빼고`·`제외하고`·`제외한`).
36
44
 
37
45
  ### 기본 모델과 청킹
38
46
 
@@ -45,8 +53,13 @@ flowchart LR
45
53
  - 한국어 문서는 실제 토큰 예산으로 청킹한다. 한글은 글자당 토큰이 영어보다 훨씬 많다(Qwen3-Embedding 실측 ko 1.64 / en 6.08 chars/token).
46
54
  그래서 청크 크기는 한글·기타 문자의 조화 평균으로 잡는다. 영어 문서의 청크 경계는 업스트림과 같다.
47
55
  - 하이브리드 RRF 가중치를 한국어에 맞게 조정했다. 벡터 목록은 절반, OR 로 완화한 lex 목록은 다시 절반, 확장 질의 목록은 0.75 로 센다.
56
+ - `qmd query`·MCP plain `query`·SDK `search({ query })` 의 하이브리드 경로에서 한글이 한 글자라도 든 질의는 LLM 질의
57
+ 확장을 하지 않는다(명시적 `expand:` 포함, `qmd vsearch` 는 아직 확장한다). 확장 모델은 한국어 질문에 영어 문장 틀과 중국어를 섞어 썼고,
58
+ 그 샘플링만으로 어려운 질의 1위가 실행마다 30개 중 5개까지 바뀌었다. 끄자 어려운 질의 1위는 줄지 않았고 hybrid 지연은
59
+ 질의당 약 1초에서 수십 ms 로 줄었다(BASELINE.md 2026-09-26). 영어 질의는 그대로 확장한다.
48
60
  - 리랭커가 실제로 1위를 바꿀 수 있게 점수 블렌드를 고쳤다. 검색 순위 항은 블렌드의 10% 인 선형 감쇠다.
49
61
  이전 블렌드에서는 리랭크 점수와 상관없이 검색 1위가 그대로 남았다.
62
+ - 리랭커에는 질의의 한글 음절 bigram 이 가장 많이 겹치는 청크를 보내고, 청크에 노트 제목이 없으면 제목을 앞에 붙인다.
50
63
 
51
64
  ### 검색 범위와 도구
52
65
 
@@ -72,7 +85,7 @@ flowchart LR
72
85
  ## 설치
73
86
 
74
87
  ```sh
75
- npm install -g ko-qmd # 또는 버전 핀: ko-qmd@2.8.3-ko.4
88
+ npm install -g ko-qmd # 또는 버전 핀: ko-qmd@2.8.3-ko.5
76
89
  ```
77
90
 
78
91
  - 업스트림 `@tobilu/qmd` 와 실행 파일 이름이 같아 함께 둘 수 없다. `npm uninstall -g @tobilu/qmd` 뒤 설치한다.
@@ -87,7 +100,7 @@ qmd collection add ~/notes --name notes # 컬렉션 등록 + 색인
87
100
  qmd context add qmd://notes "개인 개발 노트" # 검색 결과와 함께 돌려줄 설명
88
101
  qmd embed # 벡터 임베딩 (처음 한 번, 이후 변경분만)
89
102
 
90
- qmd query "하이브리드 서치 설계 결정" # 확장 + 융합 + 리랭크 (권장)
103
+ qmd query "하이브리드 서치 설계 결정" # 융합 + 리랭크 (권장, 한글 질의는 확장하지 않는다)
91
104
  qmd search "리랭크" # BM25 만 (빠름, 모델 없음)
92
105
  qmd vsearch "검색 지연을 줄인 방법" # 벡터만
93
106
  qmd grep "QMD_QUERY_LOG" # 본문 정확 일치
@@ -197,16 +210,23 @@ bash scripts/dogfood.sh --check "RESULT bm25_r5=…" # 게이트 판정만
197
210
  ```
198
211
 
199
212
  - 게이트: `bench-ko.sh` 의 `bm25_r5` 가 [BASELINE.md](test/fixtures/ko-vault/BASELINE.md) 의 마지막 `RESULT` 줄보다 낮으면 설치 전에 멈춘다(exit 3).
213
+ 베이스라인에 `RESULT-HARD` 줄이 있으면 어려운 유형 게이트도 돈다: 벤치를 돌린 form(`form=`, 없으면 plain)이
214
+ 그 줄과 같아야 하고, `full_r1` 이 그 줄의 값에서 그 줄의 `tol=` 을 뺀 값보다 낮으면 마찬가지로 멈춘다.
215
+ seam form(`bench-ko.sh` 기본값 — 질의마다 `lex:`·`vec:` 두 줄, LLM 질의 확장 없음)이면 `hybrid_r1` 도 같은 식으로 본다.
216
+ seam form 은 같은 커밋에서 매번 같은 값이 나와 기준줄이 `tol=0` 이다. plain form 의 `hybrid_r1` 은 확장 샘플링만으로
217
+ 질의 5개만큼 흔들려서 게이트하지 않는다(BASELINE.md 2026-09-26).
200
218
  - `npm link` 가 아니라 pack 설치다. 링크하면 데몬이 작업 트리의 `dist/` 를 서빙해 빌드 중에 깨질 수 있다.
201
- - 머신 배선 env: `DOGFOOD_LABEL`(launchd 라벨, 기본 `com.lemoncloud.qmd-daemon`) · `DOGFOOD_URL`(기본 `http://127.0.0.1:8181`) ·
219
+ - 머신 배선 env: `DOGFOOD_LABEL`(launchd 라벨, 기본 `io.github.ko-qmd.daemon`) · `DOGFOOD_URL`(기본 `http://127.0.0.1:8181`) ·
202
220
  `DOGFOOD_SMOKE` · `DOGFOOD_PIN_FILE`. 성공하면 `~/.cache/qmd/dogfood-deployed` 에 `<시각> <커밋>` 을 쓴다.
203
221
 
204
222
  ### ko-vault 벤치
205
223
 
206
- `bash scripts/bench-ko.sh` — 픽스처 `test/fixtures/ko-vault/`(문서 28·질의 63), 임베딩은 Qwen3-Embedding-0.6B-Q8_0 고정.
224
+ `bash scripts/bench-ko.sh` — 픽스처 `test/fixtures/ko-vault/`(문서 97: 위키 28·디스트랙터 69·질의 93), 임베딩은 Qwen3-Embedding-0.6B-Q8_0 고정.
225
+ 기본은 seam form(REST/MCP 호출과 같은 `lex:`+`vec:` 모양, 확장 없음)이고 `KO_FORM=plain` 이면 질의를 그대로 넘겨 확장 경로를 잰다.
207
226
  run별 수치는 [BASELINE.md](test/fixtures/ko-vault/BASELINE.md). 업스트림 2.8.3 의 bm25_r5 0.6250 에서 시작해,
208
227
  Qwen3-Embedding 기본값·질의 52 에서 bm25_r5 0.9519 · vector_r5 1.0000 · full_r5 1.0000 이다.
209
- 질의 63 은 외래어 표기 질의 11건을 더한 셋이고, 그 11건의 수치는 BASELINE.md 2026-09-23 절에 있다.
228
+ 질의 63 은 외래어 표기 질의 11건을 더한 셋이고, 그 11건의 수치는 BASELINE.md 2026-09-23 절에 있다. 질의 93 은 어려운 유형
229
+ (sem-hard 12·multi 8·neg 10) 30건을 더한 셋이고, 첫 수치는 BASELINE.md 2026-09-24 절에 있다.
210
230
 
211
231
  ### Electron 내장
212
232
 
@@ -234,12 +254,35 @@ pnpm install --lockfile-only # package.json 이 바
234
254
  bun run lint && bun run test:types && bun run test:unit && bash scripts/bench-ko.sh
235
255
  ```
236
256
 
237
- - 벤치는 bm25 recall@5·MRR 을 질의별로 머지 전과 비교한다(hybrid 는 실행마다 한 질의쯤 흔들린다).
257
+ - 벤치는 bm25 recall@5·MRR 을 질의별로 머지 전과 비교한다. 기본 seam form 은 실행마다 같은 값이 나와서, 움직였다면
258
+ 동기화가 바꾼 것이다. `KO_FORM=plain` 의 hybrid 는 질의 확장 샘플링만으로도 흔들린다.
238
259
  - PR 은 squash 가 아니라 **머지 커밋**으로 들인다. 업스트림 커밋이 조상으로 남아야 다음 동기화가 새 커밋만 본다.
239
260
  같은 이유로 이 브랜치는 squash·rebase 하지 않고, `develop`·`main` 을 upstream 위로 rebase 하지 않는다.
240
261
  - `pnpm install --frozen-lockfile --lockfile-only` 가 통과해야 한다. 낡은 `pnpm-lock.yaml` 은 GitHub 설치(`prepare`)와 릴리스 태그의 pre-push 검사를 막는다.
241
262
  - 업스트림 태그 위에 있을 때만 `v<업스트림 버전>-ko.N` 태그를 쓴다. `main` 스냅샷이면 버전은 마지막 태그 기준이다.
242
263
 
264
+ ## 자주 묻는 질문
265
+
266
+ ### ko-qmd 는 무엇인가?
267
+ 한국어 마크다운 노트를 로컬에서 검색하는 CLI·MCP 서버·SDK 다. 업스트림 qmd 에 한국어 조사·어미 처리, 한글 음절 bigram 색인,
268
+ 한국어에 맞춘 기본 임베딩(Qwen3-Embedding)과 융합 가중치를 더했다.
269
+
270
+ ### 업스트림 qmd 로 한국어를 검색하면 무엇이 안 되나?
271
+ 업스트림의 BM25 는 `검색을`·`청킹에서의` 처럼 조사가 붙은 단어를 다른 토큰으로 본다. 그래서 `검색` 이라고 적은 노트를 놓친다.
272
+ ko-vault 벤치(질의 52)에서 bm25 recall@5 는 업스트림 2.8.3 의 0.6250 에서 이 배포판의 0.9519 로 올랐다([BASELINE.md](test/fixtures/ko-vault/BASELINE.md)).
273
+
274
+ ### Obsidian 볼트를 그대로 검색할 수 있나?
275
+ 그렇다. 볼트 폴더를 `qmd collection add <볼트 경로> --name <이름>` 으로 등록하면 그 안의 `*.md` 를 색인한다. 볼트 파일은 읽기만 한다.
276
+
277
+ ### 인터넷 연결이나 API 키가 필요한가?
278
+ 아니다. 임베딩·리랭크·질의 확장 모델은 처음 쓸 때 한 번 내려받고(`~/.cache/qmd/models/`), 그 뒤로는 오프라인에서 돈다.
279
+
280
+ ### Claude Code 같은 AI 에이전트에 붙이려면?
281
+ `qmd mcp`(stdio) 또는 `qmd mcp --http --daemon`(HTTP, 여러 세션이 모델 공유)으로 MCP 서버를 띄운다. [에이전트에서 쓰기](#에이전트에서-쓰기) 참고.
282
+
283
+ ### 영어 노트도 되나?
284
+ 된다. 영어 질의와 영어 문서의 청크 경계·질의 확장은 업스트림과 같게 동작한다. 한국어와 영어가 섞인 노트도 한 인덱스에서 찾는다.
285
+
243
286
  ## 라이선스
244
287
 
245
288
  MIT. 원저작권은 Tobi Lutke([LICENSE](LICENSE))에게 있고, 이 배포판도 같은 라이선스를 따른다.
@@ -13,7 +13,13 @@
13
13
  * - hybrid: BM25 + vector RRF fusion without reranking
14
14
  * - full: Full hybrid pipeline with LLM reranking
15
15
  */
16
- import type { BenchmarkResult } from "./types.js";
16
+ import type { BackendSummaryByType, HardSummary, QueryResult, BenchmarkResult } from "./types.js";
17
+ /** Per-type summary built from the existing computeSummary on each type's slice of results.
18
+ * A type with zero queries gets no entry — pooling must skip it, never divide by zero. */
19
+ export declare const computeSummaryByType: (results: QueryResult[]) => BackendSummaryByType;
20
+ /** Count-weighted pool of the hard query types over the per-type summary — the numbers behind
21
+ * the `RESULT-HARD` line bench-ko.sh prints and dogfood.sh gates. Nulls when empty. */
22
+ export declare const computeHardSummary: (byType: BackendSummaryByType) => HardSummary;
17
23
  export type BenchCollectionInfo = {
18
24
  name: string;
19
25
  active_count: number;
@@ -17,6 +17,7 @@ import { readFileSync } from "node:fs";
17
17
  import { resolve } from "node:path";
18
18
  import { createStore, getDefaultDbPath, } from "../index.js";
19
19
  import { scoreResults } from "./score.js";
20
+ import { HARD_QUERY_TYPES } from "./types.js";
20
21
  function parseStructuredQuery(query) {
21
22
  const lines = query.split("\n").map((line, idx) => ({
22
23
  trimmed: line.trim(),
@@ -219,6 +220,46 @@ function computeSummary(results) {
219
220
  }
220
221
  return summary;
221
222
  }
223
+ /** Per-type summary built from the existing computeSummary on each type's slice of results.
224
+ * A type with zero queries gets no entry — pooling must skip it, never divide by zero. */
225
+ export const computeSummaryByType = (results) => {
226
+ const byType = {};
227
+ const types = Array.from(new Set(results.map(r => r.type))).sort();
228
+ for (const type of types) {
229
+ const slice = results.filter(r => r.type === type);
230
+ byType[type] = {};
231
+ for (const [name, s] of Object.entries(computeSummary(slice))) {
232
+ byType[type][name] = { ...s, count: slice.length };
233
+ }
234
+ }
235
+ return byType;
236
+ };
237
+ /** Count-weighted pool of the hard query types over the per-type summary — the numbers behind
238
+ * the `RESULT-HARD` line bench-ko.sh prints and dogfood.sh gates. Nulls when empty. */
239
+ export const computeHardSummary = (byType) => {
240
+ const METRICS = [
241
+ ["hybrid_r1", "hybrid", "avg_recall_at_1"],
242
+ ["hybrid_mrr", "hybrid", "avg_mrr"],
243
+ ["full_r1", "full", "avg_recall_at_1"],
244
+ ["full_mrr", "full", "avg_mrr"],
245
+ ];
246
+ const out = { hybrid_r1: null, hybrid_mrr: null, full_r1: null, full_mrr: null, n: 0 };
247
+ for (const type of HARD_QUERY_TYPES) {
248
+ out.n += Object.values(byType[type] ?? {})[0]?.count ?? 0;
249
+ }
250
+ for (const [key, backend, metric] of METRICS) {
251
+ let num = 0, den = 0;
252
+ for (const type of HARD_QUERY_TYPES) {
253
+ const entry = byType[type]?.[backend];
254
+ if (!entry)
255
+ continue;
256
+ num += entry[metric] * entry.count;
257
+ den += entry.count;
258
+ }
259
+ out[key] = den > 0 ? num / den : null;
260
+ }
261
+ return out;
262
+ };
222
263
  /**
223
264
  * Fail fast when the fixture's collection is missing or empty so `qmd bench`
224
265
  * does not spend minutes printing a wall of 0.00 (#716).
@@ -300,11 +341,14 @@ export async function runBenchmark(fixturePath, options = {}) {
300
341
  }
301
342
  const summary = computeSummary(results);
302
343
  const timestamp = new Date().toISOString().replace(/[:.]/g, "").slice(0, 15);
344
+ const summaryByType = computeSummaryByType(results);
303
345
  const benchResult = {
304
346
  timestamp,
305
347
  fixture: fixturePath,
306
348
  results,
307
349
  summary,
350
+ summary_by_type: summaryByType,
351
+ summary_hard: computeHardSummary(summaryByType),
308
352
  };
309
353
  // Output
310
354
  if (benchSummaryAllZero(summary)) {
@@ -5,19 +5,24 @@
5
5
  * The harness runs each query through multiple search backends
6
6
  * and measures precision, recall, MRR, and latency.
7
7
  */
8
+ export type BenchmarkQueryType = "exact" | "semantic" | "topical" | "cross-domain" | "alias" | "sem-hard" | "multi" | "neg";
9
+ /** Query types pooled into the hard `RESULT-HARD` gate line (bench-ko.sh / dogfood.sh). */
10
+ export declare const HARD_QUERY_TYPES: readonly BenchmarkQueryType[];
8
11
  export interface BenchmarkQuery {
9
12
  /** Unique identifier for the query */
10
13
  id: string;
11
14
  /** The search query text */
12
15
  query: string;
13
16
  /** Query difficulty/type for grouping results */
14
- type: "exact" | "semantic" | "topical" | "cross-domain" | "alias";
17
+ type: BenchmarkQueryType;
15
18
  /** Human-readable description of what this tests */
16
19
  description: string;
17
20
  /** File paths (relative to collection) that should appear in results */
18
21
  expected_files: string[];
19
22
  /** How many of expected_files should appear in top-k results */
20
23
  expected_in_top_k?: number;
24
+ /** For `neg` queries: the named-but-excluded concept's path (fixture-test invariant only) */
25
+ excluded?: string;
21
26
  }
22
27
  export interface BenchmarkFixture {
23
28
  /** Description of the benchmark */
@@ -63,18 +68,34 @@ export interface QueryResult {
63
68
  type: string;
64
69
  backends: Record<string, BackendResult>;
65
70
  }
71
+ /** Per-backend averages over a group of queries (the shape of summary[backend]). */
72
+ export interface BackendSummary {
73
+ avg_precision: number;
74
+ avg_recall: number;
75
+ avg_recall_at_1: number;
76
+ avg_recall_at_3: number;
77
+ avg_recall_at_5: number;
78
+ avg_mrr: number;
79
+ avg_f1: number;
80
+ avg_latency_ms: number;
81
+ }
82
+ /** Per-backend averages over one query type; count = queries of that type (the same for every backend). */
83
+ export type BackendSummaryByType = Record<string, Record<string, BackendSummary & {
84
+ count: number;
85
+ }>>;
86
+ /** Count-weighted pool over the hard query types; nulls when no hard queries ran (printed as nan). */
87
+ export interface HardSummary {
88
+ hybrid_r1: number | null;
89
+ hybrid_mrr: number | null;
90
+ full_r1: number | null;
91
+ full_mrr: number | null;
92
+ n: number;
93
+ }
66
94
  export interface BenchmarkResult {
67
95
  timestamp: string;
68
96
  fixture: string;
69
97
  results: QueryResult[];
70
- summary: Record<string, {
71
- avg_precision: number;
72
- avg_recall: number;
73
- avg_recall_at_1: number;
74
- avg_recall_at_3: number;
75
- avg_recall_at_5: number;
76
- avg_mrr: number;
77
- avg_f1: number;
78
- avg_latency_ms: number;
79
- }>;
98
+ summary: Record<string, BackendSummary>;
99
+ summary_by_type: BackendSummaryByType;
100
+ summary_hard: HardSummary;
80
101
  }
@@ -5,4 +5,5 @@
5
5
  * The harness runs each query through multiple search backends
6
6
  * and measures precision, recall, MRR, and latency.
7
7
  */
8
- export {};
8
+ /** Query types pooled into the hard `RESULT-HARD` gate line (bench-ko.sh / dogfood.sh). */
9
+ export const HARD_QUERY_TYPES = ["sem-hard", "multi", "neg"];
@@ -1,4 +1,4 @@
1
1
  {
2
- "commit": "a5a9355",
3
- "builtAt": "2026-09-24T11:09:07.100Z"
2
+ "commit": "c544042",
3
+ "builtAt": "2026-09-29T00:59:00.970Z"
4
4
  }
package/dist/cli/qmd.js CHANGED
@@ -2748,7 +2748,7 @@ async function querySearch(query, opts, _embedModel = DEFAULT_EMBED_MODEL, _rera
2748
2748
  });
2749
2749
  }
2750
2750
  else {
2751
- // Standard hybrid query with automatic expansion
2751
+ // Standard hybrid query with automatic expansion (skipped for a query with Hangul)
2752
2752
  results = await hybridQuery(store, query, {
2753
2753
  collection: collectionSearchFilter(collectionNames),
2754
2754
  filter: opts.filter,