ko-qmd 2.8.3-ko.4 → 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,152 @@
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
+
112
+ ## [2.8.3-ko.5] - 2026-09-24
113
+
114
+ Rerank gets an input cap. Truncating each document to 128 tokens keeps KB
115
+ goldset quality (Hit@5 0.97 / Recall@5 0.77) while cutting cold rerank p90 from
116
+ 3.6s to 0.9s on an M3 Max. The cap can be set daemon-wide
117
+ (`QMD_RERANK_MAX_DOC_TOKENS`) or per request (`rerankMaxDocTokens` on REST
118
+ `/query` and SDK `search()`). The README is now a Korean guide to ko-qmd itself,
119
+ and the English reference lives in `docs/REFERENCE.md`.
120
+
121
+ ### Changes
122
+
123
+ - README.md is rewritten in Korean around ko-qmd itself: what differs from
124
+ upstream, install, quick start, MCP/REST/SDK, environment variables and the
125
+ maintenance guide that used to live in `README.ko.md` (now removed). The
126
+ upstream-derived English reference (full CLI, MCP tool parameters, SDK API,
127
+ score fusion) moved to `docs/REFERENCE.md`, with install and import names
128
+ switched to `ko-qmd`.
129
+ - REST `POST /query` and SDK `search()` take `rerankMaxDocTokens`, a
130
+ per-request rerank doc token cap. For that request it overrides the daemon's
131
+ `QMD_RERANK_MAX_DOC_TOKENS`, and it never raises the context budget. A caller
132
+ like the KB seam can then turn a capped rerank on for one machine without
133
+ changing the daemon's env. The rerank cache key follows the effective cap
134
+ (request, else env). Invalid values (non-integer, ≤ 0, strings) are ignored.
135
+ - `QMD_RERANK_MAX_DOC_TOKENS` caps the tokens of each document sent to the
136
+ reranker, below the context budget. Unset keeps today's behaviour. On the KB
137
+ goldset (40 queries, candidateLimit 15), a cap of 128 keeps quality at
138
+ Hit@5 0.97 / Recall@5 0.77 against 0.95 / 0.76 uncapped. It cuts cold p90 from
139
+ 3623ms to 897ms on an M3 Max and from 4720ms to 2192ms on another Apple Silicon
140
+ Mac. The cap is part of the rerank cache key, so capped and uncapped scores
141
+ never mix. Listed in `qmd doctor`.
142
+ - `scripts/dogfood.sh` warms the embedding model with one vec query after
143
+ every daemon restart (deploy and `--restore`). A fresh daemon's first vec
144
+ query took 15.5s, so the first KB seam search after a restart hit its 20s
145
+ limit and fell back to rg. A failed warm-up is a warning, not a failed restart.
146
+ - `scripts/dogfood.sh` waits for the *restarted* daemon. `launchctl kickstart -k`
147
+ returns while the old process is still shutting down and answering `/health`,
148
+ so the restart looked done ~9s before the new process listened. A `/health`
149
+ whose `uptime` predates the kickstart is now treated as the old process.
150
+
5
151
  ## [2.8.3-ko.4] - 2026-09-24
6
152
 
7
153
  Korean documents are now chunked at their real token budget, and Hangul
@@ -49,7 +195,7 @@ it to `scope`.
49
195
  - Plain Hangul lex terms that spell a common technical loanword (`서치`, `웹`,
50
196
  `코어`, `마이그레이션`, about 80 entries in a built-in table) also match the
51
197
  Latin spelling, so `하이브리드 서치` finds a note that only says
52
- `hybrid-search` and `레몬 웹 코어` reaches `lemon-web-core`. The lookup runs
198
+ `hybrid-search` and `리액트 웹 코어` reaches `react-web-core`. The lookup runs
53
199
  on particle-stripped stems too (`서치를`), single-syllable entries (`웹`, `훅`)
54
200
  are bridged when they stand alone (not `웹을`), and terms with no entry produce the same FTS5 query as before.
55
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.