Skip to content

feat(core): guard trigram sub-3-codepoint search terms; hit-centered snippets - #78

Open
Cyb3rN8 wants to merge 2 commits into
tommy0103:mainfrom
Cyb3rN8:feat/search-short-token-guard
Open

feat(core): guard trigram sub-3-codepoint search terms; hit-centered snippets#78
Cyb3rN8 wants to merge 2 commits into
tommy0103:mainfrom
Cyb3rN8:feat/search-short-token-guard

Conversation

@Cyb3rN8

@Cyb3rN8 Cyb3rN8 commented Aug 19, 2026

Copy link
Copy Markdown

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 from sqlite_master (correct across migrations, no config dependency) and, for plain token-and-whitespace queries only:

  • terms ≥3 code points MATCH as before; shorter terms ride the same statement as AND m.text LIKE ? substring conditions, filtering the full MATCH set before LIMIT (rank preserved) — reported per-hit as degraded: '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.
  • all-short queries fall back to a LIKE scan (degraded: 'like-scan', rank: null, recency-ordered) instead of returning zero rows;
  • explicit FTS5 operator/syntax queries bypass the guard; the existing punctuation fallback (catch → per-token quoting) routes through the same guard.

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.mjs exactKeys for the search() hit gains 'snippet' — extended, not loosened: the fixture query guarantees a literal term hit, so the key is deterministically present. api-reference.md documents both new optional fields in the same commit (the doc-sync guard enforces this).

Verification

  • New 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.
  • Full suite on this branch head: 473/473 pass, typecheck clean, lint 0 errors (4 pre-existing warnings in unrelated app tests, present on main).
  • Real-index spot check after deploy: 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

nathan and others added 2 commits August 19, 2026 20:37
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Trigram tokenizer: sub-3-codepoint search terms are silently dropped (false positives in multi-term queries)

1 participant