fix(core): count non-Latin tokens when relaxing full-text queries - #1269
fix(core): count non-Latin tokens when relaxing full-text queries#1269gingeard wants to merge 6 commits into
Conversation
💡 Codex ReviewWhen a query uses Devanagari, Thai, vocalized Arabic/Hebrew, or decomposed Unicode text, this pattern splits a single word at every combining mark; for example, ℹ️ About Codex in GitHubCodex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback". |
5a760dc to
073eba5
Compare
`relaxed_query_words` decides whether a strict AND full-text query may be retried as an OR query. Its eligibility check counted tokens with `[A-Za-z0-9]+`, so any query written in a non-Latin alphabet produced zero tokens, tripped the "fewer than three tokens" guard, and never relaxed. Because only the hybrid path opts into relaxation, the effect was a silent degradation rather than an error: the FTS branch returned nothing for question-form queries, score fusion had a single non-zero side, and hybrid search became vector-only ranking. Nothing logs at default level, so the lexical half of hybrid search is simply absent for these languages. A dedicated CJK branch already worked around the same gate for Han, kana, and Hangul, which suggests the ASCII assumption was known but only patched for one script family. Switch the pattern to a Unicode-aware `[^\W_]+`. This keeps the alphanumeric intent (underscore stays excluded) and leaves every existing guard in place: short queries, quoted queries, explicit booleans, and pure-digit identifiers are rejected exactly as before, for Latin and non-Latin alike. Verified against a Russian corpus of 17 notes and 176 observations: before the change `fts_count=0` on every question-form query; after it the FTS branch contributes candidates and fusion has two sides again. Abugidas remain partially handled — Devanagari and Thai vowel signs are non-spacing marks outside `\w`, so words split into syllable fragments. Relaxation engages, but the OR terms are fragments; a test pins that behaviour so a future fix is deliberate. Tests: 12 added (6 alphabetic scripts relax, 4 guards still reject, 2 pin the abugida limitation). Existing search suites unchanged — 677 → 689 passing, same 32 pre-existing environment-dependent failures. Signed-off-by: gingeard <gingeard@users.noreply.github.com>
073eba5 to
57c1a88
Compare
…kens Combining marks are not alphanumeric, so counting them as token separators split abugida words (Devanagari, Thai) and NFD-decomposed text into syllable fragments. A single word then looked like several tokens, passed the three-token guard, and relaxed into a broad OR of one- and two-letter fragments whose top FTS row normalizes to 1.0 during hybrid fusion. Group marks with the base character they attach to. Single words in those scripts now stay one token and the short-query guard rejects them as intended, while multi-word queries relax into whole words. Signed-off-by: gingeard <gingeard@users.noreply.github.com>
|
Good catch, and it is now fixed in 5576b5f rather than pinned. You are right that fragmenting is worse than the no-op it replaced: a single word cleared the three-token guard and relaxed into an OR of one- and two-letter fragments, and in hybrid fusion the top lexical row normalizes to 1.0. Pinning that with a test was the wrong call on my part. Token counting now lives in
|
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5576b5fb9c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
phernandez
left a comment
There was a problem hiding this comment.
The exact-head Codex review found two regressions in the eligibility guards, and both are valid: in-word U+200C/U+200D join controls currently split Persian/Indic words into extra tokens, and Unicode numeric tokens such as Ⅻ or ½ bypass the existing isdigit() identifier guard. Please keep join controls inside an existing token, use the Unicode-wide numeric classification for the guard, and add focused regressions. I’m withdrawing approval until those are addressed.
…kens Two eligibility-guard regressions from widening token recognition beyond ASCII. U+200C and U+200D are written inside an orthographic word, so terminating a token on them split Persian words and explicit Devanagari ZWJ conjuncts: the two-word query "میروم خانه" became three tokens and cleared the three-token guard. Join controls are now read as word-internal, and a trailing one is treated as a separator rather than kept in the term. isdigit() is false for Nl/No characters, so admitting every alphanumeric character let "SPEC Ⅻ design" and "spec ½ design" past the numeric-identifier guard that the old ASCII path rejected as too short. Both guards now classify numbers Unicode-wide with isnumeric(); the CJK branch is aligned for the same reason, since it carried the identical hole. Signed-off-by: gingeard <gingeard@users.noreply.github.com>
|
Both are valid and both are fixed in 88b2453. Thanks for re-running the review on the exact head — I reproduced each one before changing anything. Join controls. Unicode numerics. One thing to flag, since it goes past the report: I applied the same classification to the CJK branch. It carried the identical hole — Verification: 30 tests in |
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 88b2453912
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
Splitting on an apostrophe cut Ukrainian words apart: the two-word query "п’ять проектів" became three tokens, cleared the three-token guard, and relaxed into an OR containing one-letter fragments. Both U+2019 and the ASCII apostrophe are affected. An apostrophe now continues a token only between two letters. That keeps "SPEC 16's design" split on the digit, so the numeric-identifier guard still rejects it, and it leaves a leading or trailing apostrophe as a separator. One consequence for ASCII input: contractions become a single token, so "don't touch this" yields ["don't", "touch"] where it previously yielded ["don", "t", "touch"]. Merging can only lower the token count, so no query the guards used to reject can begin relaxing because of it, and the hyphen and slash cases named in basicmachines-co#1022 are unchanged. Signed-off-by: gingeard <gingeard@users.noreply.github.com>
|
Valid, and fixed in 401d5d4. Same class as the previous two: Treating every apostrophe as word-internal would have opened a hole in the numeric guard — One ASCII consequence worth your call. Contractions now form one token: 35 tests now, 5 new for this finding. Search-related selection: 542 → 569 passing against the base commit, same 33 pre-existing environment-dependent failures on both sides. ruff, ruff format and pyright clean. |
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 482241b70e
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
Keeping apostrophes inside tokens let them reach the relaxed renderers, which interpolate each word straight into backend query syntax. In FTS5 an ASCII apostrophe is syntax, not text: "don't touch this" rendered as `don't* OR touch*`, which fails to parse with `fts5: syntax error near "'"`. The caller treats a syntax error as an empty result, so the relaxed retry contributed nothing — the same silent-empty-FTS failure this fallback exists to prevent, reintroduced for English contractions and Ukrainian words. Both renderers now quote a word when it carries an apostrophe and leave every other word byte-identical. Postgres gets the matching treatment because it receives the same tokens; its escaping is the documented tsquery form, but I could not exercise it against a live server. Tests cover the rendered expression rather than only the token helper: the SQLite output is executed against a real FTS5 table, and one test pins that the unquoted form raises, so removing the quoting fails loudly. Signed-off-by: gingeard <gingeard@users.noreply.github.com>
|
Confirmed and fixed in 1d5ed5d. This one was the serious one — thank you for pushing the review down to the renderer. Reproduced against a real FTS5 table before changing anything: Keeping apostrophes inside tokens let them reach Both renderers now quote a word only when it carries an apostrophe; every other term renders byte-identical, verified by test. Postgres receives the same tokens, so it gets the matching treatment in On the test gap you identified: there is now a separate 48 relaxation tests in total. Search-related selection: 542 → 582 passing against the current merge base, same 33 pre-existing environment-dependent failures on both sides. ruff, ruff format and pyright clean. |
|
@codex review |
|
Codex Review: Didn't find any major issues. More of your lovely PRs please. Reviewed commit: ℹ️ About Codex in GitHubCodex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback". |
|
@phernandez ready for another look when you have time. Codex is clean on
Findings 2–4 were regressions this PR introduced by widening token recognition, so each is covered by tests that fail without the fix. The rendering path now has its own file that executes the generated expression against a real FTS5 table across five scripts. Three things are your call rather than mine, all flagged in the commits above:
Numbers: 48 relaxation tests, search-related selection 542 → 582 passing against the current merge base, same 33 pre-existing environment-dependent failures on both sides. |
Problem
relaxed_query_words()decides whether a strict AND full-text query may be retried as an OR query. Its eligibility check counts tokens with an ASCII-only pattern:A query written in any non-Latin alphabet yields zero tokens, trips the "fewer than three tokens" guard, and never relaxes — regardless of how long it is.
Only the hybrid path opts into relaxation, so the result is a silent degradation, not an error. The comment right above the call site describes exactly what then happens:
That is precisely the state every non-Latin corpus is permanently in.
Reproduction
End to end on a Russian corpus (17 notes, 176 observations), from the timing log:
Every question-form query behaved this way. Fusion (
max(v, f) + 0.3 * min(v, f)) had one side pinned at zero, so ranking was vector-only. The practical cost is exact identifiers:Idempotency-Key,next_pageand similar tokens are what FTS is good at and embeddings are blind to.Why this looks unintended
This is the same defect #1022 already diagnosed, one script family at a time.
#994 introduced the relaxed retry because "questions rarely have every word in one document", and its guards are deliberate. #1022 then found that the eligibility check could not see the terms at all:
That fix added a CJK branch beside the gate rather than repairing the gate, so the ASCII assumption was known and worked around — but only for Han, kana and Hangul. Cyrillic, Greek, Hebrew, Arabic, Armenian and Georgian produce zero tokens for exactly the same reason, with no branch of their own.
#1022 asks that existing ASCII relaxation behaviour be preserved, including
client-side state managementandfoo/bar baz qux. It is: on ASCII input the new tokenizer returns token-for-token what[A-Za-z0-9]+returned, those two cases included. The CJK branch runs before this line and is untouched.This also does not reopen #577. The fusion formula and every guard are unchanged; Cyrillic queries simply reach the path the project already accepts for English and CJK, where "fusion plus bm25 keep relaxed lexical candidates from dominating precision".
Fix
Token counting moves into
relaxation_word_tokens: a token is a run of alphanumeric characters together with any combining marks attached to them.That repairs two distinct ways the ASCII rule mis-counted.
Non-Latin letters now count. Cyrillic, Greek, Hebrew, Arabic, Armenian and Georgian queries reach the same three-token guard as Latin ones, instead of being read as zero tokens and rejected wholesale.
Combining marks stay inside their word. Marks are not alphanumeric, so treating them as separators cuts abugidas (Devanagari, Thai) and NFD-decomposed text into syllable fragments.
अंतर्राष्ट्रीयकरणlooked like seven tokens, cleared the three-token guard, and relaxed into a broad OR of one- and two-letter fragments — whose top lexical row then normalizes to 1.0 during hybrid fusion.That second point comes from the Codex review on this PR, and it was right. My first push fixed only the ASCII gate and pinned the fragmenting as a known limitation, but relaxing a single word into fragments is worse than the silent no-op it replaced — so it is fixed rather than pinned. One word in those scripts is now one token, and the short-query guard rejects it as intended.
Every existing guard is untouched. Short queries, quoted queries, explicit booleans and pure-digit identifiers are still rejected, for Latin and non-Latin alike. The CJK branch runs before this line and is unaffected.
After the change the same corpus produces a live FTS branch and two-sided fusion.
Tests
35 tests in
tests/repository/test_search_relaxation.py, 27 of them added here:Ⅻ,½, Arabic-Indic digits)Measured over the search-related selection of
tests/: 542 to 569 passing, with the same 33 pre-existing environment-dependent failures on the base commit and on this branch — identical test IDs on both sides, they need sqlite-vec / embedding models.ruff check,ruff format --checkandpyrightare clean on both touched files.Not included
The second half of the same problem is morphology: SQLite FTS5 is configured with
unicode61, which has no stemmer, soмаркерного ведраdoes not match a query forмаркерное ведро. That needs a stemming dependency and an index migration — a separate discussion, and I'd be glad to open an issue if there's interest.