Repository navigation
fix(local): keep a word's combining marks when the keyword index splits text - #1915
Conversation
…ts text unicode61 ends a token at every combining mark, so Devanagari स्कैनर (scanner) was indexed as स + नर and बैटरी as ब + टर: letters, not words. Python's \w drops marks too, so the question was cut the same way and the two agreed on nonsense. ADR 0031 gave the keyword leg 35% of the order, arguing coverage is absolute because a chunk matching one term of five is weak. That holds where a term is a word. Nearly every Hindi document holds nearly every Devanagari letter, so coverage stopped discriminating: the right document covered 96% of a Hindi question's terms and the best wrong one 85%, one pair tying at 91%. Harmless before ADR 0031, when the keyword leg could not reorder; it votes now, and Hindi is one of the eight shipped languages. Adding M* to the categories is only half of it. Fixing the index alone drops a Hindi question to 0 of 10 terms, and unicode61 also folds Latin diacritics, so a regex agreeing with it needs SQLite's own table and every drift is a wrong vote rather than a miss. The question is now tokenised by FTS5 itself, from one rule in shared/tokenizer.py. Hindi's margin over the best wrong document goes from +11% to +42%, the wrong document falling from 85% to 27%. Every other slice of the eval is unchanged to the digit, the blend still reaching an answering passage for 98% of 266 queries at mean rank 1.1. Migration 0017 rebuilds chunks_fts from chunks: no re-embed, no download. CJK stays open, trigram being the only tokenizer that segments it and changing what BM25 means everywhere else. Co-authored-by: Cursor <cursoragent@cursor.com>
The index was fingerprinted on the corpus and the embedder, so a tokenizer change reused an index built under the old rule and reported the old rule's numbers as the new one's. Measured: the first run of the change above showed no movement at all. The argument is required rather than defaulted, since a caller that omits it is the bug. Co-authored-by: Cursor <cursoragent@cursor.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Advanced Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
What
FTS5's
unicode61ends a token at every combining mark, so Devanagariस्कैनर(scanner) was indexed asस+नरandबैटरी(battery) asब+टर— letters, not words. Python's\wdrops marks too, so the question was cut the same way and the two sides agreed on nonsense.M*joins the tokenizer's categories. Latin, digits and identifiers tokenise identically either way, so this costs them nothing.shared/tokenizer.py. The question is tokenised by FTS5 itself — inserted into a throwaway in-memory table declared with the same option — rather than matched by a regex.0017rebuildschunks_fts. The index holds no text of its own, so it rebuilds fromchunks: no re-embed, no download.ADR 0032 records it and amends ADR 0031;
docs/architecture/search.mdfollows.Why
ADR 0031 gave the keyword leg 35% of the order and argued no stopword list is needed, because coverage is absolute: a chunk matching one term of five is weak whatever BM25 says. That holds where a term is a word. Nearly every Hindi document holds nearly every Devanagari letter, so coverage stopped discriminating.
Measured across the eval corpus — every document scored against every query the way
_keyword_legdoes, right document against best wrong document:One Hindi pair tied at 91% against 91%. This was harmless before ADR 0031, when a keyword match only widened recall and could not reorder; it votes now, and Hindi is one of the eight languages the app ships in.
The eval's own total does not move — 98% of 266 queries at mean rank 1.1, before and after. The Hindi slice was already 100% on five queries carried by the vector leg, so a top-5 rate cannot show a margin. The table above is the result; the eval is the check that nothing else regressed.
Two things ruled out by measurement:
unicode61also folds Latin diacritics, socaféindexes ascafe; a regex agreeing with that needs SQLite's own curated table, and every drift is a wrong vote rather than a miss.Still open, unchanged by this: an entire Japanese or Chinese clause is one token, so the keyword leg scores 0% there.
trigramis the only tokenizer that segments it and it changes what BM25 means for every other language. Cross-lingual retrieval stays at 25% — that is the embedder, not the ranking. Both are Known gaps insearch.md.Fixes #
How to test
Two guards are worth reading rather than just running:
test_a_hindi_question_finds_the_note_that_answers_it— three notes one word apart, so meaning leaves them near-tied and only the keyword leg can choose. Revertshared/tokenizer.py'sM*and it asks about the scanner and returns the printer.test_the_index_splits_text_the_way_a_question_will— the migration andshared/tokenizer.pystate the tokenizer separately, so editing one without the other fails here.The full measurement, against a rebuilt index:
Existing libraries upgrade on next launch;
alembic upgrade headrebuilds the index in place.High-level PR Summary
This PR fixes a critical tokenization issue where FTS5's
unicode61tokenizer was breaking Devanagari (and other combining-mark scripts) words into individual letters instead of keeping them whole. By addingM*(combining marks) to the tokenizer categories and ensuring both the search index and query use the same tokenization rule, Hindi queries now properly match Hindi documents. The fix significantly improves keyword matching for Hindi from +11% margin to +42% margin over wrong documents, while maintaining identical performance for other languages. A database migration rebuilds the keyword index with the new tokenizer, and the eval fingerprint now includes the tokenizer alongside the corpus and embedder to detect configuration changes.⏱️ Estimated Review Time: 30-90 minutes
💡 Review Order Suggestion
docs/adr/0032-one-tokenizer-for-index-and-question.mdshared/tokenizer.pysurfsense_local/backend/tests/unit/shared/test_tokenizer.pysurfsense_local/backend/shared/search.pysurfsense_local/backend/alembic/versions/0017_keyword_index_keeps_combining_marks.pysurfsense_local/backend/tests/integration/chunks/test_search_index.pysurfsense_local/backend/tests/integration/search/test_retrieve.pysurfsense_local/backend/scripts/retrieval_eval/fingerprint.pysurfsense_local/backend/scripts/run_retrieval_eval.pysurfsense_local/backend/tests/unit/scripts/test_retrieval_eval_fingerprint.pydocs/adr/0031-ranking-blends-absolute-leg-scores.mddocs/adr/README.mddocs/architecture/search.md