feat(core): guard trigram sub-3-codepoint search terms; hit-centered snippets - #78
Open
Cyb3rN8 wants to merge 2 commits into
Open
feat(core): guard trigram sub-3-codepoint search terms; hit-centered snippets#78Cyb3rN8 wants to merge 2 commits into
Cyb3rN8 wants to merge 2 commits into
Conversation
…ntered snippets
Under a trigram tokenizer (OBELISK_FTS_TOKENIZER=trigram or any manually
migrated index), a query term shorter than three code points emits no tokens.
Alone it can only match nothing; next to longer terms it becomes an empty
phrase that constrains nothing. Measured on a real 688k-message index:
MATCH 'quick ok' returned 799 rows where only 442 actually contain 'ok' —
silent false positives, worse than the known "short lone term returns zero"
because nothing looks wrong. Two-code-point CJK words ('修复') hit the same
wall, which is the main reason a CJK index is trigram in the first place.
search() now detects the live tokenizer from sqlite_master (not from
configuration, so it stays correct across tokenizer migrations) and, for plain
token-and-whitespace queries only:
- terms >= 3 code points go through MATCH as before (rank preserved); shorter
terms are enforced as literal substrings on the overselected result set,
reported as degraded: 'short-token-post-filter';
- when every term is short, the content table is LIKE-scanned directly,
ordered by recency, reported as degraded: 'like-scan' with rank: null;
- queries with explicit FTS5 operators or syntax bypass the guard entirely,
and the existing punctuation fallback (catch -> per-token quoting) routes
through the same guard, since its extracted tokens can be short too.
On the default unicode61 index the guard never engages and behavior is
byte-for-byte unchanged.
Every hit additionally carries snippet — a hit-centered window into
message.text. Long transcript messages routinely match far past the head, so
consumers that preview hits by truncating from the front never show the match;
the window centers on the earliest literal term occurrence and is omitted when
none appears (raw-syntax queries). The search() hit shape in
contract-helper-shapes.test.mjs and api-reference.md gains the two optional
fields; the exactKeys expectation was extended (not loosened) accordingly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…vives ranking
The first guard implementation post-filtered an overselected window (limit*5)
in JS. On a real 688k-message index, search('quick ok') returned zero rows:
every top bm25 hit for 'quick' was a short quick-dense message without 'ok',
so the window emptied out before the one real combined hit was reached.
Correctness held (nothing false was returned) but recall collapsed.
The short terms now ride the MATCH statement as AND m.text LIKE ? conditions,
filtering the full MATCH result set before LIMIT — no window, no guess, rank
preserved. Regression test pins the buried-hit scenario.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #76. Follow-up to #9/#14 (configurable tokenizer).
Root cause and reproduction
Under trigram, a term shorter than three code points emits no tokens. Alone it can only match nothing; next to longer terms it becomes an empty phrase constraining nothing. Real-index measurement:
MATCH 'quick ok'→ 799 rows, LIKE ground truth 442 — 357 silent false positives. Two-code-point CJK words (修复) hit the same wall, i.e. exactly the audience the trigram option serves.Change
search()detects the live tokenizer fromsqlite_master(correct across migrations, no config dependency) and, for plain token-and-whitespace queries only:AND m.text LIKE ?substring conditions, filtering the full MATCH set before LIMIT (rank preserved) — reported per-hit asdegraded: 'short-token-post-filter'. An earlier draft post-filtered an overselected window in JS and collapsed to zero recall on the real index (top bm25 hits for the long term lacked the short one); the SQL push-down is recall-exact and is pinned by a buried-hit regression test.degraded: 'like-scan',rank: null, recency-ordered) instead of returning zero rows;On the default unicode61 index the guard never engages; behavior is byte-for-byte unchanged (pinned by test).
Every hit also gains
snippet— a window centered on the earliest literal term occurrence (long transcript messages routinely match far past the head; head-truncating previews never show the match). Omitted when no term appears literally.Existing-assertion change (own section per the contributing guide)
contract-helper-shapes.test.mjsexactKeysfor the search() hit gains'snippet'— extended, not loosened: the fixture query guarantees a literal term hit, so the key is deterministically present.api-reference.mddocuments both new optional fields in the same commit (the doc-sync guard enforces this).Verification
tests/search-short-token-guard.test.mjs(8 tests): mixed-term filtering, all-short LIKE fallback, two-code-point CJK + short ASCII, punctuation-fallback routing, raw-syntax bypass, unicode61 untouched, buried-hit recall regression, hit-centered snippet.search('quick ok')returns only rows containing both terms with numeric rank;search('修复 ok')returns like-scan hits instead of zero; snippets center on the match.🤖 Generated with Claude Code