はじめに
個人開発中の1人でできるしりとりみたいなミニゲーム「ことバトル」では、プレイヤーが入力した単語が有効かどうかを判定する必要があります。
最初は「辞書に載っている単語かどうかを機械的にチェックすればいい」くらいに軽く考えていたのですが、実際に作ってみると難しかったのはむしろ別のところでした。人間がしりとりで単語を出すとき、いちいち辞書を引いて確認したりはしません。「なんとなく聞いたことがある」「ゲームの中で登場する」という直感的な妥当性判断で単語を出しています。この「人間の直感」をロジックとして再現しようとすると、単純な辞書完全一致では全く歯が立ちませんでした。
- 「東京タワー」のような複合名詞は直感的にOKだが、単語をランダムにつなげただけの語はNGにしたい
- 「ぱそこん」のようにひらがなで書かれた外来語も、人間なら違和感なく受け入れる
- 辞書には載っていない新語・固有名詞・スラングでも、実際に使われているものは通したい
- 「あいうえお」のような、辞書的には各文字が存在していても明らかにズルい文字列は弾きたい
- ゲーム中は一瞬で「大丈夫そう」と判断してテンポよく返す必要があるが、結果的には正確さも担保したい
といった要求が絡み合い、「辞書にあるか」の一点突破では解決しない問題でした。この記事では、kotobattle のバックエンドで実装している、直感的な妥当性判定に近づけるための Level1〜3の3段階ハイブリッド判定 の設計と実装を紹介します。
正直なところ、この判定はまだ完璧ではありません。Sudachi辞書にもWikipediaにも載っていない語(新しいスラングやゲーム内の固有名詞など)は今もどうしても弾かれてしまいます。そこで並行して、判定に漏れた単語をユーザーが「これは有効な単語です」と申請し、運営が承認したものを ホワイトリストとして辞書に追加していく仕組みも用意しています。判定ロジックだけで100点を目指すのではなく、判定+人手による継続的な辞書育成の両輪で精度を上げていく方針です。
全体像
判定は用途に応じて3段階に分かれています。
| レベル | 用途 | 使用技術 | 特徴 |
|---|---|---|---|
| Level 1 | インゲーム中の即時判定 | Janome | 高速・簡易。読み仮名の抽出とスパムパターン検知が中心 |
| Level 2 | 結果画面での高精度判定 | SudachiPy(フル辞書) | 品詞(POS)ベースで名詞かどうかを厳密にチェック |
| Level 3 | Level 2で判定できなかった語のレスキュー | Wiktionary / Wikipedia API + かな漢字変換 | 辞書外の固有名詞・新語を救済 |
| (番外)ホワイトリスト | Level 2/3でも救えなかった語の恒久対応 | ユーザー申請+承認 | 一度承認されれば以降は即valid判定 |
ゲームのテンポを優先する場面では軽量な Level 1 だけで即座に応答し、正確性が求められる結果画面では Level 2→3 と段階的に精度を上げていく、という設計です。全体の流れを図にすると次のようになります。
ポイントは、判定に失敗した単語がそこで終わりではなく、申請→承認のフローを通じて次回以降はホワイトリストが最優先で参照されるというループになっていることです。これにより、Sudachi辞書やWikipediaでカバーしきれない語彙が、運用しながら少しずつ育っていきます。
Level 1: インゲーム用の軽量判定
def validate_level1(self, word: str, topic: str = None) -> dict:
if not word:
return {"valid": False, "reason": "空文字です"}
if word.endswith('ん') or word.endswith('ン'):
return {"valid": False, "reason": "「ん」で終わる"}
# 同じ文字の連続(3回以上): "あああ" など
if re.search(r'(.)\1{2,}', word):
return {"valid": False, "reason": "同じ文字の連続"}
# 2種類以下の文字で構成された3文字以上の語: "ぱぱぱぱぱ", "ああいい" など
if len(word) >= 3 and len(set(word)) <= 2:
return {"valid": False, "reason": "無効な文字列"}
# 連続した文字コード(あいう、abc など)
...
tokens = list(self.janome_tokenizer.tokenize(word))
reading = "".join(t.reading if t.reading != '*' else t.surface for t in tokens)
if topic and not reading.startswith(self._to_katakana(topic)):
return {"valid": False, "reason": f"「{topic}」以外"}
return {"valid": True, "reading": reading}
ポイントは、単語の実在性チェックを最初から放棄していることです。Level 1 でやっているのは
- しりとりのルール違反(「ん」で終わる)の即弾き
- 「あああ」「ぱぱぱぱぱ」のような明らかなスパム文字列の検知(連続文字・少数文字種の反復・連番文字コード)
- Janome で読み仮名を取り、お題(トピック)の先頭文字と一致するか確認
だけです。辞書照合をここでやると SudachiPy のフル辞書ロードや複雑な品詞判定でレイテンシが乗ってしまい、対戦中のテンポを損ないます。「本当に有効な単語か」の最終判定は結果画面(Level 2/3)に委ね、ゲーム中は「明らかにダメなものだけ弾く」役割に徹しています。
Level 2: SudachiPy による品詞ベースの厳密判定
結果画面では SudachiPy のフル辞書(SudachiDict_full)を使い、品詞タグベースで「名詞として妥当か」を検証します。
allowed_pos = {'名詞', '接頭辞', '接尾辞', '形状詞', '数詞'}
particle_pos = {'助詞'}
noun_pos = {'名詞', '形状詞', '数詞'}
for token in tokens:
pos = token.part_of_speech()
if token.is_oov():
is_oov = True
if pos[0] in particle_pos:
has_particle = True
elif pos[0] not in allowed_pos:
has_invalid_pos = True
単純な完全一致ではなく、SudachiPy のトークナイザで形態素解析した上で「全トークンの品詞が名詞系(または名詞をつなぐ助詞)か」を見ています。これにより「食べる」のような動詞は弾きつつ、「東京タワー」のような複合名詞は許可する、といった柔軟な判定が可能になります。
助詞が挟まる場合(例:「〇〇の△△」のような複合語)は、助詞の前後が名詞系トークンで挟まれているかを追加検証しています。これがないと不自然な助詞の混入を許してしまうためです。
ひらがな→カタカナ フォールバック
SudachiPy はひらがな表記の外来語(例:「ぱそこん」)をうまく認識できないことがあります。一方でカタカナに変換すると正しく1トークンの名詞として認識されるケースが多いため、Level 2 では判定失敗時にカタカナへの変換を試すフォールバックを入れています。
can_fallback = True
if result.get("token_count") == 1 and not result.get("is_oov") and result.get("failed_pos"):
can_fallback = False
if not result["valid"] and can_fallback and self._is_kana_only(word):
katakana = self._to_katakana(word)
...
ただし単純にすべてのひらがな入力をカタカナへ変換して再判定すると、「たべる」(動詞)が「タベル」という別の名詞として誤って通ってしまう問題がありました。そこで「1トークンで、かつ非名詞として明確に判定された場合はフォールバックしない」という条件を追加し、動詞・形容詞の意図的なすり抜けを防いでいます。
さらに「ひらがなで複数トークンに分割された語が、カタカナに変換すると1トークンになる」場合(例:「ぐわっしゃ」→「グワッシャ」)は、Sudachiの誤分割を疑ってLevel 3のWeb照合に委譲する、という慎重な扱いもしています。
複合語の再検証(SplitMode C)
SudachiPy は分割モード(A/B/C)を切り替えられます。複数トークンに分かれた語は、より粗い分割単位である SplitMode C で再トークナイズし、1トークンにまとまるかどうかを見ています。1トークンにまとまれば「辞書に登録済みの正当な複合語」と判断でき、そうでなければ全トークンが名詞系かどうかで妥当性を評価します。
if len(tokens) > 1:
tokens_c = self.sudachi_tokenizer.tokenize(word, self.sudachi_mode_c)
if len(tokens_c) == 1 and not tokens_c[0].is_oov():
return {"valid": True, "reading": full_reading, "is_compound": True}
Level 3: 辞書外語のレスキュー判定
Level 2 で OOV(Out Of Vocabulary、辞書未登録)と判定された語は、即座に無効とはせず Wiktionary / Wikipedia の API に照会します。それでも見つからない場合は、Google IME の transliterate API でひらがなを漢字に変換した候補を作り、その候補を再度 Wiktionary/Wikipedia、さらに Level 2 の Sudachi 判定にかけ直します。
async def validate_level3(self, word: str) -> dict:
if await self._check_wiktionary(word):
return {"valid": True, "source": "Wiktionary"}
if await self._check_wikipedia(word):
return {"valid": True, "source": "Wikipedia"}
if self._is_kana_only(word):
candidates = await self._get_kanji_candidates(word)
for candidate in candidates:
if await self._check_wiktionary(candidate):
return {"valid": True, "source": f"Wiktionary (from {candidate})"}
res_l2 = self._validate_sudachi_tokens(candidate, None, allow_oov=False)
if res_l2.get("valid"):
return {"valid": True, "source": f"Sudachi Level2 (from {candidate})"}
return {"valid": False, "reason": "辞書・Web未登録"}
固有名詞や新語・専門用語は静的な辞書だけではどうしてもカバーしきれません。Wikipedia の項目名という「実在性の緩い指標」を使うことで、辞書には無いが実際に使われている単語も救済できるようにしています。
ホワイトリストとの連携 — 判定漏れを申請で育てる
Level 1〜3 をすり抜けてしまう単語(新語・スラング・ゲーム内固有名詞など)は現状どうしても存在します。これを都度あきらめるのではなく、ユーザーが「この単語は有効なはず」と申請できるようにし、運営が内容を確認して承認したものをホワイトリストとしてDBに蓄積しています。
一度承認された単語は、以降 Level 2/3 の重い判定(SudachiPyのトークナイズやWeb API照会)を経由せず、DBルックアップだけで即座に valid 判定されます。頻出する固有名詞まで毎回 Web API を叩くのは非効率なため、ホワイトリストは判定の最優先ステップとして参照する構成にしています。
def _find_approved_application(word: str):
db = database.SessionLocal()
try:
return db.query(models.WordApplication).filter(
models.WordApplication.word == word,
models.WordApplication.status == models.ApplicationStatus.APPROVED,
).first()
finally:
db.close()
# 1. ホワイトリストを最優先でチェック
approved_app = await run_in_threadpool(_find_approved_application, word)
if approved_app:
return {"valid": True, "reading": approved_app.reading, "reason": None}
# 2. 辞書判定(Level 2)
result = await run_in_threadpool(validator.validate_level2, word, None)
...
# 3. レスキュー判定(Level 3)
申請〜承認の運用フローはまだ簡易的なものですが、判定ロジックというプログラム側の努力だけでなく、人手による継続的な辞書育成を組み合わせることで、long tail な語彙にも徐々に対応できる設計を目指しています。
パフォーマンス面の工夫
SudachiPy によるトークナイズは CPU バウンドな同期処理です。FastAPI の非同期イベントループ上でそのまま呼ぶと、1件の判定処理が他の全リクエストをブロックしてしまいます。そのため run_in_threadpool で実際のスレッドプールに逃がしています。
ここで問題になったのが、SudachiPy 0.6.11 のトークナイザインスタンスがスレッドセーフでないことです。複数スレッドから同じトークナイザを同時に呼ぶと RuntimeError: Already borrowed が発生します。対策として threading.local() を使い、スレッドごとに独立したトークナイザインスタンスを遅延生成する実装にしています。
@property
def sudachi_tokenizer(self):
tok = getattr(self._sudachi_local, "tokenizer", None)
if tok is None:
tok = self._sudachi_dict.create()
self._sudachi_local.tokenizer = tok
return tok
辞書(Dictionary オブジェクト)自体はスレッド間で共有可能ですが、そこから生成する tokenizer はスレッドローカルに持つ、という使い分けがポイントです。
まとめ
- ゲーム中の即時性が必要な場面(Level 1)と、正確性が必要な場面(Level 2/3)で判定の重さを切り替える設計にした
- SudachiPy の品詞タグを使うことで、単純な辞書完全一致では実現できない「動詞は弾くが複合名詞は許可する」といった柔軟な判定ができた
- ひらがな⇔カタカナの表記揺れは、誤判定を防ぐガード条件付きのフォールバックで対応した
- 辞書に無い固有名詞・新語は Wikipedia API とかな漢字変換でレスキューする仕組みにした
- それでも漏れる単語は、ユーザー申請→運営承認のホワイトリストという形で辞書を継続的に育てていく方針にした
- CPUバウンドな形態素解析はスレッドプールに逃がしつつ、SudachiPyのスレッド非安全性には
threading.localで対処した
「単語が実在するかどうか」という一見シンプルな判定も、実際のプロダクトで使おうとすると速度・精度・表記揺れ・辞書の網羅性という複数の軸でトレードオフが発生します。今回はそれを1段階の判定ロジックに詰め込むのではなく、用途別の3段階判定+人手による辞書育成という形で、完璧ではなくとも運用しながら精度を上げていける設計にしました。