Repository navigation
fix(retrieval): unicode-aware tokenization; empty chunk text is never a relevant hit #45
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
145c8f4
564c9ee
6440760
e143821
d239c9c
2743373
e475052
29db5f2
7ec33dc
a244d5c
a12747b
104bf7f
681b48e
d85cb72
b203437
2c27ad6
a19828e
f6df221
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,21 +4,156 @@ | |
|
|
||
| import logging | ||
| import math | ||
| import re | ||
| import time | ||
| import unicodedata | ||
| from collections import Counter | ||
| from typing import Any, Dict, List, Optional, Tuple | ||
|
|
||
| from .models import RetrievedChunk, RetrievalResult | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
| TOKEN_PATTERN = re.compile(r"[a-z0-9]+") | ||
| # Scripts whose writing omits spaces between words (Han ideographs, | ||
| # Japanese kana, Thai, Lao, Khmer, Burmese): a run of them cannot rely on | ||
| # word boundaries, so they tokenize per character plus overlapping bigrams | ||
| # so multi-character queries still match adjacent characters. | ||
| _NGRAM_SCRIPTS = frozenset({"CJK", "HIRAGANA", "KATAKANA", "THAI", "LAO", "KHMER", "MYANMAR"}) | ||
|
|
||
| # Kanji and kana mix inside single Japanese words ("食べる"), so contiguous | ||
| # segments of these scripts tokenize as one run - otherwise no bigram spans | ||
| # the boundary and a punctuated decoy scores the same as the real word. | ||
| _JAPANESE_SCRIPTS = frozenset({"CJK", "HIRAGANA", "KATAKANA"}) | ||
|
|
||
| # Japanese iteration/prolonged marks whose Unicode names fall outside the | ||
| # script buckets ("IDEOGRAPHIC ITERATION MARK", "KATAKANA-HIRAGANA | ||
| # PROLONGED SOUND MARK"); they inherit the surrounding script so "人々" and | ||
| # "ユーザー" stay single segments. As tokens they only ever repeat the | ||
| # previous character, so the n-gram path keeps them inside bigrams but | ||
| # never emits them as standalone unigrams. U+3007 is deliberately absent: | ||
| # it is IDEOGRAPHIC NUMBER ZERO (a real character, classified CJK below), | ||
| # not a repetition mark. | ||
| _INHERIT_CODEPOINTS = frozenset( | ||
| {"\u3005", "\u3006", "\u30fc", "\u309d", "\u309e", "\u30fd", "\u30fe"} | ||
| ) | ||
|
|
||
|
|
||
| def _clusters(seg: str) -> List[str]: | ||
| """Split into base+marks clusters so n-grams never detach a vowel sign | ||
| or tone mark from its base (Thai "กิ" must not emit a bare "ิ").""" | ||
| clusters: List[str] = [] | ||
| for ch in seg: | ||
| if clusters and _is_mark(ch): | ||
| clusters[-1] += ch | ||
| else: | ||
| clusters.append(ch) | ||
| return clusters | ||
|
|
||
| # Characters that join the current token: letters and digits from any | ||
| # script, plus combining marks (Devanagari vowel signs, Arabic harakat, | ||
| # etc. - NFC leaves many of them decomposed, and dropping them collapses | ||
| # distinct words like "की" / "का" / "कि" onto one token). | ||
| def _is_mark(ch: str) -> bool: | ||
| return unicodedata.category(ch).startswith("M") | ||
|
|
||
|
|
||
| def _script_of(ch: str) -> Optional[str]: | ||
| """Script family of a character; None for script-neutral characters.""" | ||
| if ch == "\u3007": | ||
| # IDEOGRAPHIC NUMBER ZERO is a real character, not a mark. | ||
| return "CJK" | ||
| if ch.isdigit() or _is_mark(ch) or ch in _INHERIT_CODEPOINTS: | ||
| # Digits, combining marks, and Japanese iteration/prolonged marks | ||
| # inherit the surrounding script. | ||
| return None | ||
| name = unicodedata.name(ch, "") | ||
| return name.split(" ")[0] if name else None | ||
|
Comment on lines
+68
to
+69
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Japanese text contains common marks such as Useful? React with 👍 / 👎. |
||
|
|
||
|
|
||
| def _emit_word(word: str, tokens: List[str]) -> None: | ||
| """Emit a matched word, splitting at script boundaries. | ||
|
|
||
| Each script segment tokenizes its own way: CJK segments emit each | ||
| character (meaning is per-character and the text is not space-separated) | ||
| plus overlapping bigrams so a multi-character query phrase matches | ||
| adjacent characters in the document; every other script stays whole, so | ||
| "Python東京" and "Python으로" both still yield "python". | ||
| """ | ||
| segment = "" | ||
| segment_script: Optional[str] = None | ||
| segments: List[Tuple[str, str]] = [] | ||
| for ch in word: | ||
| script = _script_of(ch) | ||
| if script is None: | ||
| # Digits and unattributed characters inherit the current script - | ||
| # except digits beside (or opening a word before) a non-Latin | ||
| # script, which form their own whole token so "إصدار2026" and | ||
| # "2026東京" both keep a searchable "2026". | ||
| if ch.isdigit() and segment_script not in ("LATIN", "DIGIT"): | ||
| script = "DIGIT" | ||
| else: | ||
| segment += ch | ||
| continue | ||
| if segment_script is None: | ||
| segment_script = script | ||
| if script != segment_script: | ||
| segments.append((segment, segment_script)) | ||
| segment = ch | ||
| segment_script = script | ||
|
Comment on lines
+98
to
+101
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a Japanese word transitions between kanji and kana, this split prevents any n-gram from spanning the boundary. Consequently, Useful? React with 👍 / 👎. |
||
| else: | ||
| segment += ch | ||
| if segment: | ||
| segments.append((segment, segment_script or "")) | ||
| # Merge contiguous Japanese-script segments so n-grams span kanji/kana | ||
| # boundaries. | ||
| runs: List[List[Any]] = [] | ||
| for seg, script in segments: | ||
| if runs and script in _JAPANESE_SCRIPTS and runs[-1][1] in _JAPANESE_SCRIPTS: | ||
| runs[-1][0] += seg | ||
| else: | ||
| runs.append([seg, script]) | ||
| for seg, script in runs: | ||
| if script in _NGRAM_SCRIPTS: | ||
| clusters = _clusters(seg) | ||
| # Iteration/prolonged marks (々, ー) keep adjacency via bigrams | ||
| # but are never standalone terms - every Japanese word shares | ||
| # them, so as unigrams they false-match unrelated words. | ||
| tokens.extend(c for c in clusters if c not in _INHERIT_CODEPOINTS) | ||
| tokens.extend( | ||
| clusters[k] + clusters[k + 1] for k in range(len(clusters) - 1) | ||
| ) | ||
| else: | ||
| tokens.append(seg) | ||
|
|
||
|
|
||
| def tokenize(text: str) -> List[str]: | ||
| """Lowercase alphanumeric tokenization.""" | ||
| return TOKEN_PATTERN.findall(text.lower()) | ||
| """Casefolded, NFKC-normalized, unicode-aware tokenization. | ||
|
|
||
| NFKC first: extraction pipelines often produce decomposed or | ||
| compatibility-form Unicode, and a precomposed plain query must still | ||
| match ("Mu\u0308nchen" == "München", "Python" == "Python"). | ||
| """ | ||
| tokens: List[str] = [] | ||
| # Unicode default caseless matching is NFKC(casefold(NFKC(s))): the | ||
| # first pass turns compatibility forms into ordinary characters | ||
| # (full-width Latin, half-width katakana, squared unit symbols like | ||
| # "㎒" -> "MHz") so case folding reaches every letter, and the second | ||
| # settles anything case folding decomposed. | ||
| normalized = unicodedata.normalize("NFKC", unicodedata.normalize("NFKC", text).casefold()) | ||
| i = 0 | ||
| n = len(normalized) | ||
| while i < n: | ||
| if not normalized[i].isalnum(): | ||
| # Tokens start on letters/digits only: a detached combining mark | ||
| # (e.g. an emoji variation selector) must not become a token, or | ||
| # every emoji collapses onto one shared, meaningless token. | ||
| i += 1 | ||
| continue | ||
| j = i + 1 | ||
| while j < n and (normalized[j].isalnum() or _is_mark(normalized[j])): | ||
| j += 1 | ||
| _emit_word(normalized[i:j], tokens) | ||
| i = j | ||
| return tokens | ||
|
|
||
|
|
||
| class BM25Index: | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Because
_clustersattaches only Unicode category-M characters, the newly inherited々andーremain standalone clusters and are emitted as searchable unigrams. Thustokenize("ユーザー")andtokenize("スーパー")share onlyー, yet a BM25 index containing onlyスーパーreturns it for theユーザーquery; similarly,時々matches人々solely through々. Preserve these marks in adjacency n-grams without emitting them as independent terms.Useful? React with 👍 / 👎.