ko-qmd 2.8.3-ko.5 → 2.8.3-ko.7
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 +154 -1
- package/README.en.md +184 -0
- package/README.md +56 -13
- package/dist/bench/bench.d.ts +7 -1
- package/dist/bench/bench.js +44 -0
- package/dist/bench/types.d.ts +32 -11
- package/dist/bench/types.js +2 -1
- package/dist/cli/build-info.json +2 -2
- package/dist/cli/qmd.js +1 -1
- package/dist/hangul.d.ts +11 -1
- package/dist/hangul.js +32 -4
- package/dist/index.d.ts +7 -1
- package/dist/index.js +5 -0
- package/dist/llm.d.ts +5 -0
- package/dist/llm.js +2 -0
- package/dist/mcp/daemon-health.d.ts +17 -0
- package/dist/mcp/daemon-health.js +24 -0
- package/dist/mcp/server.d.ts +1 -0
- package/dist/mcp/server.js +113 -17
- package/dist/query-log.d.ts +30 -0
- package/dist/query-log.js +9 -2
- package/dist/rerank-input.d.ts +44 -0
- package/dist/rerank-input.js +61 -0
- package/dist/store.d.ts +40 -6
- package/dist/store.js +229 -107
- package/package.json +11 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,159 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [2.8.3-ko.7] - 2026-10-02
|
|
6
|
+
|
|
7
|
+
The query path is now observable and bounded. Every query-log row breaks its latency into
|
|
8
|
+
stages, the daemon logs its heap once an hour, and expansion and rerank stop waiting past a
|
|
9
|
+
deadline instead of holding a query for 85 s. The reranker also sees a note's first chunk by default.
|
|
10
|
+
|
|
11
|
+
- `qmd mcp` (stdio and HTTP) no longer waits on a slow LLM stage without bound. Past
|
|
12
|
+
`QMD_EXPAND_DEADLINE_MS` (default 5 s) a query searches its original text alone, and past
|
|
13
|
+
`QMD_RERANK_DEADLINE_MS` (default 10 s) it answers in RRF order; `0` restores waiting. The clock
|
|
14
|
+
starts once the model is loaded, so a cold start or a reload after the idle unload is not a timeout;
|
|
15
|
+
the query log records that wait as a `load` stage. One MCP `query` with expansion and rerank took
|
|
16
|
+
85 s. The REST `/query` response and the MCP `query` tool say which stage timed out (`timeouts`),
|
|
17
|
+
the query log row lists it the same way, and a timed-out rerank reads `rerank: "timeout"` there.
|
|
18
|
+
A fallback does not apply `minScore` to its 1/rank scores. An abandoned expansion or rerank keeps
|
|
19
|
+
running and fills its cache; until it finishes, later queries skip that stage (logged once) instead
|
|
20
|
+
of queueing behind it. The CLI search commands and the SDK (`rerankDeadlineMs`, `expandDeadlineMs`
|
|
21
|
+
options) never set a deadline on their own.
|
|
22
|
+
|
|
23
|
+
- The daemon's query log row now carries `stages`, the milliseconds spent in each stage of the
|
|
24
|
+
search: `refresh` (the pre-query re-index check), `expand`, `fts`, `embed`, `vec`, `chunk`,
|
|
25
|
+
`rerank` and `serialize`; a stage that did not run is absent. REST `rerank:false` queries had a p95 of 2.9–5.3 s
|
|
26
|
+
against a p50 of 0.2–0.5 s with no rerank in the path, and the single `ms` could not say which
|
|
27
|
+
stage the tail came from. Daemon log lines now start with a dated local ISO timestamp instead of
|
|
28
|
+
a UTC `HH:mm:ss.SSS`, so a slow line can be matched to its row.
|
|
29
|
+
|
|
30
|
+
- The HTTP daemon logs a `health` line every hour: heap used and total, rss, and how many
|
|
31
|
+
`subscriptions/listen` and other `/mcp` requests have a handler still waiting on its response. A daemon that ran ten days died with
|
|
32
|
+
"Reached heap limit" after its heap crept to 3.8 GB under idle discover/cancelled traffic, and
|
|
33
|
+
nothing in the log showed the slope or what was holding memory. `QMD_HEALTH_LOG_INTERVAL_MS`
|
|
34
|
+
changes the interval; `0` turns it off.
|
|
35
|
+
|
|
36
|
+
- `scripts/autoresearch/prepare.sh` builds the goldset from more than the collection folder and over
|
|
37
|
+
several runs. `AR_EXTRA_DIRS="outputs docs"` copies sibling folders of the collection into the corpus
|
|
38
|
+
(names only, so no vault path lands in git), `AR_RUN=N` writes `generated-N.json` from documents no
|
|
39
|
+
earlier run answered (`bench-vault-paraphrase.ts --dirs`, `--skip`), and `split` merges every run,
|
|
40
|
+
drops the query ids listed in `dropped.txt`, and splits only each run's new documents, so an earlier
|
|
41
|
+
train/held-out assignment never moves.
|
|
42
|
+
|
|
43
|
+
- The reranker now gets a note's first chunk, its title and summary: the first chunk starts 16
|
|
44
|
+
points ahead in chunk selection (8 when an intent is given, so intent words can still pick a later
|
|
45
|
+
chunk). A later chunk needs 17 more points, about nine English words or seventeen Hangul syllable
|
|
46
|
+
pairs, so for a typical short query the first chunk is always sent. A second autoresearch loop found
|
|
47
|
+
this on a goldset of 320 train and 71 held-out queries drawn from four vault folders. Held-out full
|
|
48
|
+
MRR goes 0.7291 → 0.7459, ko-vault seam and hard lines are unchanged, and bm25, vector and hybrid
|
|
49
|
+
results are identical (`BASELINE.md` 2026-09-29).
|
|
50
|
+
|
|
51
|
+
## [2.8.3-ko.6] - 2026-09-29
|
|
52
|
+
|
|
53
|
+
Korean queries are handled better before and during reranking. A query written
|
|
54
|
+
as "X 말고 Y" now searches for Y alone, and a Hangul query skips LLM expansion,
|
|
55
|
+
which steadies hard-query results and cuts hybrid latency from about 1s to tens
|
|
56
|
+
of ms per query. The reranker is now shown the chunk that best matches the
|
|
57
|
+
query's Hangul syllable bigrams, led by the note title: held-out full MRR goes
|
|
58
|
+
0.6589 → 0.7260 on a goldset generated from a real vault. Scoped vector search on
|
|
59
|
+
small indexes runs one KNN (vector stage 250–1300ms → about 25ms). The README
|
|
60
|
+
gains an English edition and an FAQ.
|
|
61
|
+
|
|
62
|
+
- `QMD_RERANK_CACHE=<path>` keeps rerank scores in their own sqlite file instead of the index's
|
|
63
|
+
`llm_cache`, so a store over a rebuilt index reuses them. The key carries the query, the rerank
|
|
64
|
+
model, the doc-token cap and the chunk text, not the scoring code in `src/llm.ts`, so the file is
|
|
65
|
+
safe across index rebuilds with the same reranker code. It is not trimmed to 1000 rows. With it
|
|
66
|
+
set, a second `scripts/bench-ko.sh` run on ko-vault gives the same lines and its `full` backend
|
|
67
|
+
spends 3.7 s reranking instead of 300 s (the whole run drops from 5:17 to 0:56). Unset, nothing
|
|
68
|
+
changes. A fallback result (no rerank context could be created, every score 0.5) is no longer
|
|
69
|
+
cached in either place.
|
|
70
|
+
|
|
71
|
+
- The reranker now sees a better chunk of a long Korean note. The chunk is chosen by the
|
|
72
|
+
two-syllable pairs of the query's Hangul words of three or more syllables, instead of whole
|
|
73
|
+
words, so `임베딩은` still finds a chunk about `임베딩`. Generic two-syllable words such as 방법
|
|
74
|
+
and 경우 no longer steer the choice. A query word with no Hangul (an English term, say) counts double. When the chosen chunk
|
|
75
|
+
does not contain the note title, `# <title>` is put in front of it. An autonomous loop
|
|
76
|
+
(`scripts/autoresearch/`) found this over 28 experiments on a goldset generated from a private
|
|
77
|
+
vault. The goldset's held-out split, which the loop never saw, goes from full MRR 0.6589 to
|
|
78
|
+
0.7260 (32 queries). ko-vault seam and hard lines are unchanged. The bm25, vector and hybrid
|
|
79
|
+
results do not read the rerank input and are identical on both sets (`BASELINE.md`
|
|
80
|
+
2026-09-28). An intent word now counts 1 point in chunk selection (was 0.5), keeping it at half a
|
|
81
|
+
query word now that a non-Hangul query word counts 2; no goldset carries intent, so no bench
|
|
82
|
+
number moves.
|
|
83
|
+
|
|
84
|
+
- The reranker's input (the query string, which chunk of each candidate is sent and the document
|
|
85
|
+
text) is now built in `src/rerank-input.ts` for both `hybridQuery` and `structuredSearch`, which
|
|
86
|
+
had copied the chunk-scoring loop. Results are unchanged.
|
|
87
|
+
|
|
88
|
+
- A Korean query of the form "X 말고 Y" now searches for Y alone. `hybridQuery` (CLI `qmd query`,
|
|
89
|
+
plain MCP `query`) and the `lex`/`vec` lines of `structuredSearch` (MCP `searches`, typed lines)
|
|
90
|
+
drop everything up to the last whitespace-separated 말고, 빼고, 제외하고 or 제외한. The rerank
|
|
91
|
+
query follows. `hyde` lines, `intent` and `qmd search`/`vsearch` stay as written. Before this
|
|
92
|
+
change every list also searched for X, the concept the user ruled out, so a note about X often
|
|
93
|
+
took rank 1. `아닌` and bare `제외` are not markers, because they also state conditions or act as
|
|
94
|
+
nouns. On the ko-vault seam form, the `neg` queries' full MRR goes 0.4500 → 0.6944. Hard
|
|
95
|
+
`full_r1` goes 0.5111 → 0.6111 and hard `full_mrr` 0.7472 → 0.8287. No query outside `neg`
|
|
96
|
+
changed its results, and that run is the gate's new reference (`BASELINE.md` 2026-09-27,
|
|
97
|
+
"a Korean negation clause is stripped before search").
|
|
98
|
+
|
|
99
|
+
- `structuredSearch` (MCP `searches`, SDK `search({ queries })`, typed `lex:`/`vec:` lines) now
|
|
100
|
+
counts a relaxed lex list, one that matched only through the any-word retry, at half its slot's
|
|
101
|
+
weight, as `hybridQuery` already did. At a flat 2.0 for the first list, a lex line that matched
|
|
102
|
+
some of a Korean question's words outranked the vector list, and a decoy sharing those words took
|
|
103
|
+
rank 1. This one weight was the whole gap between the two paths on the Korean ko-vault queries:
|
|
104
|
+
re-fusing each path's lists with the other's weights reproduced its top 10 for 85 of 85 queries.
|
|
105
|
+
On the seam form, hard `hybrid_r1` goes 0.3111 → 0.3778 and `hybrid_r5` 0.8996 → 0.9211, with
|
|
106
|
+
`full_r1` unchanged at 0.5111, and that run is the gate's new reference (`BASELINE.md` 2026-09-27).
|
|
107
|
+
|
|
108
|
+
- A query with any Hangul is no longer expanded by the LLM on the hybrid path (`hybridQuery`). That
|
|
109
|
+
path serves CLI `qmd query`, including an explicit `expand:` line, as well as a plain MCP `query`
|
|
110
|
+
and SDK `search({ query })`; `qmd vsearch` still expands. On the ko-vault goldset the
|
|
111
|
+
expansion model wrapped Korean questions in English sentence templates and stray Chinese, and its
|
|
112
|
+
sampling alone moved hard rank 1 by up to five of 30 queries between runs. Without it, hard
|
|
113
|
+
`hybrid_r1` reads 0.3778 (0.2611–0.3611 with expansion), `full_r1` holds at 0.5111, the easy
|
|
114
|
+
queries lose nothing, and the median hybrid query drops from about 1 s to 20 ms. The Korean
|
|
115
|
+
queries now return the same results on every run. English-only queries are still expanded, and a
|
|
116
|
+
caller who wants variants for a Korean question can pass its own `searches`
|
|
117
|
+
(`BASELINE.md` 2026-09-26).
|
|
118
|
+
|
|
119
|
+
- `scripts/bench-ko.sh` benches the seam form by default. Every one-line goldset query reaches
|
|
120
|
+
`qmd bench` as `lex: <q>` + `vec: <q>`, the shape REST/MCP callers send, which skips LLM query
|
|
121
|
+
expansion. `KO_FORM=plain` keeps the old path, and the `RESULT-HARD` line now ends with
|
|
122
|
+
`form=<seam|plain>`. The `dogfood.sh` gate refuses a bench whose form differs from its baseline
|
|
123
|
+
line's form. On the seam form it gates hard `hybrid_r1` as well as `full_r1`, against a reference
|
|
124
|
+
line with `tol=0`, since the seam form gave the same lines in every run. The reason is the plain
|
|
125
|
+
form's hard wobble, which turned out to be the expansion sampler alone. Replaying recorded
|
|
126
|
+
expansions reproduces every query's top 10, and every query that moved between same-commit runs
|
|
127
|
+
had drawn a different expansion. The draw moved `hybrid_r1` by up to five of 30 queries and once
|
|
128
|
+
put `full_r1` 0.0007 above the old gate's floor (`BASELINE.md` 2026-09-26). The gate also runs
|
|
129
|
+
the bench with any exported `KO_BENCH`/`KO_CORPUS`/`KO_FORM` removed, and `bench-ko.sh` refuses a
|
|
130
|
+
`KO_BENCH` inside `tmp/bench-ko/`, where the run clears its outputs.
|
|
131
|
+
|
|
132
|
+
- `scripts/bench-ko.sh` takes `KO_CORPUS` (a corpus directory holding `wiki/` and
|
|
133
|
+
`distractors/`) and `KO_BENCH` (a goldset) so an A/B runs through the same isolated harness
|
|
134
|
+
and stdout lines. Overrides resolve against the caller's cwd and are checked before the
|
|
135
|
+
previous run's output is removed. The first use settled a question: three everyday-language
|
|
136
|
+
question lines per document, decoys included, did not move hard rank 1 on the REST-shaped
|
|
137
|
+
goldset (hybrid_r1 0.3111 → 0.2778, full_r1 flat at 0.5111; hard hit@5 up 2 of 30). Index-time
|
|
138
|
+
summaries are therefore not being built. The same runs point the `hybrid_r1` wobble at LLM
|
|
139
|
+
query expansion rather than re-embedding (`BASELINE.md` 2026-09-25).
|
|
140
|
+
- ko-vault goldset: 69 near-topic distractor documents and 30 hard queries (`sem-hard` /
|
|
141
|
+
`multi` / `neg`) join the fixture; `bench-ko.sh` indexes both directories (97 files) and
|
|
142
|
+
prints a `RESULT-HARD hybrid_r1=… full_r1=… n=30` line next to `RESULT`. The `dogfood.sh`
|
|
143
|
+
gate now also fails when the hard `full_r1` falls below the baseline minus the `tol=` recorded
|
|
144
|
+
on that baseline line, and fails closed when `RESULT-HARD` or its `tol=` goes missing.
|
|
145
|
+
`full_r1` is gated rather than `hybrid_r1` because it held one value across eight same-commit
|
|
146
|
+
runs, while `hybrid_r1` moved by about 3.5 queries of 30.
|
|
147
|
+
Fixture invariants (no title-term leakage, no excluded concept among expected files, no basename
|
|
148
|
+
collisions) are enforced by `test/ko-bench-fixture.test.ts`.
|
|
149
|
+
|
|
150
|
+
- Collection- or filter-scoped vector search is faster on indexes of up to
|
|
151
|
+
4096 vectors. It now post-filters one sqlite-vec KNN over the whole table,
|
|
152
|
+
which is exact because vec0 KNN is a brute-force scan, instead of computing
|
|
153
|
+
`vec_distance_cosine` row by row. On the KB goldset (1047 of 3389 vectors,
|
|
154
|
+
M3 Max) the vector stage dropped from 250–1300ms to about 25ms, and a cold
|
|
155
|
+
capped query (cap 128, candidateLimit 15) went from p90 2000ms to 906ms with
|
|
156
|
+
identical rankings. Larger indexes keep the exact scan.
|
|
157
|
+
|
|
5
158
|
## [2.8.3-ko.5] - 2026-09-24
|
|
6
159
|
|
|
7
160
|
Rerank gets an input cap. Truncating each document to 128 tokens keeps KB
|
|
@@ -88,7 +241,7 @@ it to `scope`.
|
|
|
88
241
|
- Plain Hangul lex terms that spell a common technical loanword (`서치`, `웹`,
|
|
89
242
|
`코어`, `마이그레이션`, about 80 entries in a built-in table) also match the
|
|
90
243
|
Latin spelling, so `하이브리드 서치` finds a note that only says
|
|
91
|
-
`hybrid-search` and
|
|
244
|
+
`hybrid-search` and `리액트 웹 코어` reaches `react-web-core`. The lookup runs
|
|
92
245
|
on particle-stripped stems too (`서치를`), single-syllable entries (`웹`, `훅`)
|
|
93
246
|
are bridged when they stand alone (not `웹을`), and terms with no entry produce the same FTS5 query as before.
|
|
94
247
|
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
|
+
[](https://www.npmjs.com/package/ko-qmd)
|
|
4
|
+
[](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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/ko-qmd)
|
|
4
|
+
[](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.
|
|
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` 노트를,
|
|
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.
|
|
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 라벨, 기본 `
|
|
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·질의
|
|
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 을 질의별로 머지 전과
|
|
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))에게 있고, 이 배포판도 같은 라이선스를 따른다.
|
package/dist/bench/bench.d.ts
CHANGED
|
@@ -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;
|
package/dist/bench/bench.js
CHANGED
|
@@ -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)) {
|
package/dist/bench/types.d.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
72
|
-
|
|
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
|
}
|