Semantic search over a Markdown knowledge base, served over MCP.
OR phrases for full-text searchkb-mcp’s hybrid search fuses two retrievers with Reciprocal Rank Fusion: a sqlite-vec KNN over embeddings, and SQLite FTS5 over a trigram tokenizer. Until v0.16.0 the FTS half received the user’s query wrapped in a single quoted phrase. Over a trigram tokenizer a quoted phrase is a contiguous-substring match, so that construction searched for the entire query verbatim.
For a keyword this behaves acceptably. For a sentence it matches nothing at all, because no document contains the question as written. Measured on the dogfood knowledge base (650 documents, 9,419 chunks), all ten natural-language golden queries returned zero FTS candidates, and of the 26 main golden queries only 16 had anything to fuse. On those queries the hybrid was not hybrid: RRF had one input, and the system had been running as vector-only retrieval while reporting itself as hybrid.
The defect is invisible from the outside. Nothing errors, no candidate count is surfaced to a user, and results still come back — worse ones, from one retriever. It survived fifteen releases, and the tests were structurally unable to catch it: every fusion test placed the FTS-matching chunk at the query vector, so the vector half alone satisfied the assertion.
The question this decision answers is how a query should be turned into an FTS5
MATCH expression, given a tokenizer that only matches substrings and a corpus
that is mostly Japanese, where words are not separated by spaces.
Chosen option: 3 — split at script boundaries, because it recovers most of the benefit of segmentation at none of its cost, and because it is a pure function of the query string that can be tested exhaustively.
再ランキングの評価について compiles to
"再ランキング" OR "ランキング" OR "の評価" OR "について". Script transitions are a
coarse but real proxy for word boundaries in Japanese: compounds are typically
kanji runs, loanwords katakana runs, and grammatical particles hiragana.
Measured on the dogfood corpus with the same scratch copy before and after (bge-m3, no reranker):
| before | after | |
|---|---|---|
| Golden queries where fusion has two inputs | 16/26 | 26/26 |
| MRR (main / binary golden) | 0.955 / 0.939 | 0.962 / 0.955 |
| recall@10 | 0.954 | 0.965 |
| recall@5 | 0.926 | 0.906 |
Why the others were not chosen:
AI と ML — the whole trimmed query is
searched verbatim, so no query class is made worse than it was in v0.15.x.Vec<String>, so a morphological splitter can replace it without
touching the callers. Tracked privately as a candidate.AND was rejected on the same evidence as option 1: a
sentence’s tokens do not co-occur in one chunk, so conjunction reproduces the
empty result set. OR widens the candidate pool and lets bm25 and RRF do the
ranking, which is what a fusion architecture is for.", *, :, ^, (, ), NEAR, AND, OR, NOT as syntax, so an
arbitrary user query is either a syntax error — failing the entire search,
not just the FTS half — or an unintended operator. Every phrase kb-mcp emits
is quoted and escaped for exactly this reason.Two things became part of the contract with users and with stored data:
"..." in a query is now meaningful. A quoted region is kept as a
verbatim phrase instead of being escaped into the text being searched for.
This is how the pre-v0.16.0 behaviour is requested on demand, and it is how a
multi-word English name is kept together. The reachable document set is
therefore not a superset of the old one: "a""b" used to search for the
literal text "a""b" and now searches for a"b.ConfigFingerprint carries fts_query_version. Evaluation history from
before this change is not comparable — the same golden queries against the
same index produce different candidates — so kb-mcp eval --fail-on-regression
refuses to compare across the boundary rather than reporting a regression or
an improvement that is really a change of method. Deserialising a fingerprint
without the field yields version 1.AI について the AI has a space on
one side and nothing on the other and never reaches the three-character
trigram floor; the full-text half searches only for について. Quoting it
does not rescue it — a quoted phrase under three characters is dropped by the
same floor — but quoting a wider region does, at the cost of searching that
region verbatim.LIMIT does not bound this, because
ORDER BY bm25(...) scores every match first. The phrase cap of 32 is the
only effective lever and is left where the retrieval evaluation above
measured it.db/fts_query.rs holds 38 unit tests across the four stages plus a property
test. Separately, a 50-input test runs every generated expression through a
real MATCH, because comparing strings never establishes that FTS5 accepts
what was built — and a rejected expression fails the whole search, not just
the full-text half.fts_or_expansion_is_one_statement_over_the_union_of_its_phrases pins that
the expansion is a union executed as one statement, counted from statements
traced out of SQLite rather than calls into the Rust wrapper.bu03_or_expansion_stays_within_a_small_multiple_of_a_single_phrase bounds
the cost multiple rather than an absolute duration.fts_decides_the_top_rank_when_the_vector_leg_prefers_another_chunk fails if
the full-text half stops contributing to the fused ranking — the property no
test held before.docs/retrieval-pipeline.md — the resulting mechanism, and its cost modelCHANGELOG.md, v0.16.0 → Changed