Skip to content

Commit 0168674

Browse files
committed
fix: correct _UnicodeTokenizer for CJK segmentation and stemming
The previous tokenizer had two defects: - Its char-level fallback was unreachable: it split non-ASCII text on whitespace first, and scripts without spaces (Chinese, Japanese, Thai) yield a single token, so the `list(text)` fallback never ran. Two different CJK strings sharing characters scored 0.0 instead of getting partial credit. - Passing a custom `tokenizer=` makes rouge-score ignore `use_stemmer`, so English stemming was silently dropped (e.g. "running" no longer matched "run"). Now ASCII-majority text is delegated to rouge-score's DefaultTokenizer (preserving Porter stemming and existing behavior exactly), and non-ASCII text keeps Latin/digit runs as words while splitting remaining word characters individually so partial overlap is scored. Verified: Thai exact=1.0, CJK exact=1.0, CJK partial(你好世界 vs 你好朋友)=0.5, English stemming(running fast vs run fast)=1.0, Latin sanity matches default.
1 parent d04be01 commit 0168674

1 file changed

Lines changed: 17 additions & 12 deletions

File tree

src/google/adk/evaluation/final_response_match_v1.py

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -96,24 +96,29 @@ def _get_eval_status(score: float, threshold: float):
9696
class _UnicodeTokenizer:
9797
"""Tokenizer that handles Unicode text with word-boundary awareness.
9898
99-
The default RougeScorer tokenizer splits on whitespace, which works for
100-
ASCII and Latin-script text but produces zero tokens for text in scripts
101-
without word boundaries (Chinese, Japanese, Thai, etc.).
102-
103-
For ASCII-majority text this tokenizer uses Unicode-aware word-character
104-
matching (``\\w+`` in re). For non-ASCII text it falls back to whitespace
105-
splitting, then character-level tokenization.
99+
The default RougeScorer tokenizer strips characters outside ``[a-z0-9]``, so
100+
text in scripts without Latin word boundaries (Chinese, Japanese, Thai, etc.)
101+
produces zero tokens and scores 0.0 even on an exact match.
102+
103+
ASCII-majority text is delegated to rouge-score's ``DefaultTokenizer`` so the
104+
existing behavior -- including Porter stemming -- is preserved exactly. For
105+
non-ASCII text, Latin/digit runs are kept as words and each remaining word
106+
character (e.g. a CJK ideograph) becomes its own token, so partial overlap is
107+
scored instead of collapsing into a single opaque token.
106108
"""
107109

110+
def __init__(self, use_stemmer: bool = True):
111+
self._default = rouge_scorer.tokenizers.DefaultTokenizer(use_stemmer)
112+
108113
def tokenize(self, text: str) -> list[str]:
109114
"""Tokenizes text using Unicode-aware word boundaries."""
115+
text = text.lower()
116+
if not text:
117+
return []
110118
ascii_chars = sum(1 for c in text if ord(c) < 128)
111119
if ascii_chars > len(text) * 0.5:
112-
return re.findall(r"\w+", text.lower())
113-
tokens = text.lower().split()
114-
if tokens:
115-
return tokens
116-
return list(text.lower())
120+
return self._default.tokenize(text)
121+
return re.findall(r"[a-z0-9]+|\w", text, re.UNICODE)
117122

118123

119124
def _calculate_rouge_1_scores(candidate: str, reference: str):

0 commit comments

Comments
 (0)