ぽっぺんしゃんにょろりんこ

匿名・非追跡型アダルト動画検索エンジンの設計ノート

Pythonで作る形態素解析API:SudachiPyとFlaskで最速分割サーバー構築①

こんにちは、にょろりんこの備忘録的技術ブログです。

今回のテーマは形態素解析です。

日本語テキストを扱うなら必ず通るこの処理は、検索クエリのタグ生成といった、自然言語処理全般で欠かせないステップです。

日傘をさすティアラ

私が運営しているAV検索エンジン「SaePorns」でも、毎日数千件単位のアダルト動画の日本語タイトルを分割しています。そんな中で求められるのは、「速くて安定して動く解析API」。今回は、そのために私が選んだ組み合わせ──SudachiPy × Flask──で、形態素解析APIサーバーを最速構築する方法を紹介します。

この記事を読むことで、

  • SudachiPyをFlask APIとしてラップする手順
  • 実装上の落とし穴(Tokenizerのスレッドセーフ問題など)
  • プロダクション運用で安定化させるポイント

がわかります。

形態素解析APIを内製化したい人、「Google Cloud Natural Language API」や「AWS Comprehend」に頼らず自分で高速サーバーを持ちたい人は、ぜひ参考にしてください。

API構成の全体像

今回作るのは、日本語テキストをPOSTすると、形態素解析した語句や類義語を返すAPI です。

技術スタックはシンプル:
  • SudachiPy : 日本語形態素解析器。IPA辞書よりも語彙が豊富で、現代的な固有名詞や口語にも強い。
  • Flask : 軽量Python Webフレームワーク。APIサーバー構築に最適。
  • gensim KeyedVectors : Word2Vecモデルの読み込みと類義語検索。
処理の流れ
  • クライアントから POST /extract にJSONでタイトル文字列を送信
  • SudachiPyで形態素解析し、名詞・動詞・形容詞などを抽出
  • gensimでWord2Vecモデルから類義語候補を検索
  • 最終語句リストとしてJSONレスポンスで返却

では、実際にコードを見ていきましょう

コード解説

ここからは、実際に稼働している形態素解析APIサーバーのコードを解説していきます。

まず、このAPIPythonで書かれており、Flask を使ってAPIサーバーを構築しています。形態素解析には SudachiPy を使用し、さらに gensim の KeyedVectors でWord2Vecモデルを読み込み、類義語検索も可能にしています。

初期設定

このスクリプトでは、まず最初にWord2Vecモデルを読み込んでいます。

# 設定
MODEL_PATH = "/var/www/html/cc.ja.300.kv"
SIMILARITY_THRESHOLD = 0.8
MAX_SYNONYMS = 3
EXCLUDED = {"こと", "もの", "ため"}

# ------------------------------
# 初期化(起動時に1回だけ実行)
print("SudachiとWord2Vecモデルを初期化中...")
model = KeyedVectors.load(MODEL_PATH)
print("初期化完了")

ここで設定している MODEL_PATH は、Word2Vecの日本語モデルファイルのパスです。

今回は /var/www/html/cc.ja.300.kv という学習済みモデルを使っていますが、このモデルは読み込みに数秒かかるだけでなく、常駐メモリとして約3GB程度を消費します。そのため、VPSやサーバーに載せる際は、メモリに十分な余裕を持たせる必要があります。

また、SIMILARITY_THRESHOLD は類義語検索での類似度の閾値です。ここでは0.8以上のスコアを持つ単語だけを類義語として採用するように設定しています。

MAX_SYNONYMS は各単語に対して取得する最大類義語数で、今回は3件までとしています。

さらに EXCLUDED に設定されているのは、解析結果から除外する語です。ここでは「こと」「もの」「ため」という曖昧でタグとして役立たない汎用語を除外対象にしています。

この初期設定パートで重要なのは、APIサーバー起動時に一度だけ重いモデル読み込みを行い、以降はリクエストごとに高速応答できる状態を作っているという点です。

ユーティリティ関数群

このスクリプトには、解析結果を整形するためのユーティリティ関数がいくつか定義されています。単純に形態素解析するだけでなく、不要な語句を除外したり、正規化して同一語をまとめたりすることで、検索クエリやタグ生成に使いやすい形に変換しています。

1個ずつみていきましょう

is_excluded関数

def is_excluded(word: str) -> bool:
    return any(word.replace(" ", "").startswith(ex) for ex in EXCLUDED)

この関数は、渡された単語からスペースを除去し、EXCLUDED に登録されている除外語(例:「こと」「もの」「ため」)で始まるかどうかを判定する関数です。

検索タグとして不要な汎用語を除外するためのフィルタ関数です。

convert_base関数

def convert_base(form: str) -> str:
    return "する" if form == "為る" else form

この関数は、形態素解析で得られた単語の基本形を変換する関数です。

具体的には、SudachiPyが「為る」と返す場合に、より一般的な「する」に置き換えて返します。

普通の日本語ではあまり見かけない表現ですが、英→日の変換処理をしていると、このパターンが案外多かったため、独立したユーティリティ関数として定義しています。

normalize関数

def normalize(word: str) -> str:
    return unicodedata.normalize('NFKC', word).replace('\x00', '').strip()

この関数は、渡された文字列をNFKC正規化することで、全角半角や異体字の揺れを統一し、\x00 のような制御文字を除去して、最後に前後の空白を取り除いています。  

検索タグとして扱う際に、表記ゆれや不要文字を排除してクリーンな形にするための関数です。

日本語テキストには全角英数字機種依存文字異体字が混ざることが多いため、そのままだと検索タグや辞書検索でマッチしにくくなります。

そのため、形態素解析NLP処理前にNFKC正規化しておくことで表記ゆれを吸収できるという利点があります。

looks_like_code関数

def looks_like_code(word: str) -> bool:
    return bool(re.match(r'^[A-Za-z]{1,3}\d{2,}$', word) or
                re.match(r'^\d{6,}$', word))

この関数は、渡された文字列が商品コードIDのようなパターンに見えるかどうかを判定する関数です。

具体的には、

  • 1~3文字の英字 + 2桁以上の数字(例: AB12, X12345)
  • 6桁以上の数字だけ(例: 123456, 987654321)

のどちらかにマッチする場合にTrueを返します。

AVタイトルや商品名解析では、こうしたコードが検索タグとして重要になるケースがあります。そのため、数値だけのトークンを単純に除外せず、この関数でコードらしいかを判定しています。

is_valid_token関数

def is_valid_token(word: str, pos: str = "") -> bool:
    w = normalize(word)
    if not w or w.isspace():
        return False
    if pos == "名詞":
        pass
    else:
        if len(w) <= 1 and not re.search(r'[\u4e00-\u9fff]', w):
            return False
    if re.fullmatch(r'[0-90-9]+', w) and not looks_like_code(w):
        return False
    return not is_excluded(w)

この関数は、渡されたトークンが検索タグとして有効かどうかを判定するためのフィルタ関数です。

処理内容は以下の通りです。

1:normalize関数で正規化

※入力文字列をNFKC正規化し、空文字や空白のみならFalseを返します。

2:品詞が名詞でない場合の判定

※名詞以外の品詞では、1文字以下かつ漢字を含まない場合は無効としています(例: アルファベット1文字などは除外)。

3:数字だけの文字列を除外

※この条件は、トークンが数字だけの場合に、商品コードのようなパターン(looks_like_codeがTrue)でなければ無効とする処理です。
単なる数字列を除外し、作品番号など重要なコードだけをタグとして残すための判定です。

4:除外語判定

※最後に is_excluded() で不要語(例:「こと」「もの」「ため」など)に該当すれば無効とします。

is_valid関数

def is_valid(word: str) -> bool:
    return all(is_valid_token(part) for part in word.split())

この関数は、渡された単語(もしくはフレーズ)を空白で分割し、それぞれの部分が is_valid_token をすべて満たしているかを判定します。

複数単語を含むバイグラムなどでも、部分的に無効なトークンが含まれていれば、そのトークンを除外するためのフィルタ関数です。

ここら辺のタグ化救出率は、正直100%ではない部分はあるので、今後改善をかけていこうとは考えています。

remove_particle関数

def remove_particle(word: str) -> str:
    particles = "にでをがはへとからまで"
    return word[:-1] if word and word[-1] in particles else word

この関数は、渡された単語の末尾に助詞がついている場合に、それを取り除く関数です。

例えば「映画を」という単語があれば、末尾の「を」を削除して「映画」という形に変換します。

検索タグとして扱う際に、余計な助詞が付いたままだとマッチ精度が下がるため、末尾助詞を除去してクリーンな名詞だけを残す役割があります。

normalize_noun関数

def normalize_noun(word: str) -> str:
    return remove_particle(normalize(word))

この関数は、渡された名詞を正規化してクリーンな形に整える関数です。

具体的には、

  • normalize() で文字列をNFKC正規化し、全角半角や異体字の揺れを統一
  • remove_particle() で末尾に助詞が付いていれば削除

という二段階の処理をまとめて行い、検索タグとして使いやすい noun(名詞)形に変換しています。

get_synonyms関数

@lru_cache(maxsize=10000)
def get_synonyms(word: str, topn: int = MAX_SYNONYMS) -> list[str]:
    results = []
    try:
        for similar, score in model.most_similar(word, topn=topn):
            if score >= SIMILARITY_THRESHOLD:
                results.append(similar)
    except KeyError:
        pass
    return results

この関数は、渡された単語の類義語(synonyms)をWord2Vecモデルから取得する関数です。

具体的には、

  • model.most_similar(word, topn=topn) を使って、指定した単語に似た単語を上位 topn 件取得
  • 取得した単語の中で、類似度スコアが SIMILARITY_THRESHOLD(ここでは0.8)以上のものだけを results に追加
  • もし単語がWord2Vecモデルに存在しない場合(KeyError)、空リストを返す

また、@lru_cache(maxsize=10000) デコレータが付いており、同じ単語への呼び出し結果はキャッシュされるため、APIレスポンスが高速化される仕組みです。

まとめ

今回は、Pythonで作る形態素解析APISudachiPyFlaskで最速分割サーバー構築①

というテーマで、下準備となるユーティリティ関数群の解説をメインに行いました。

これらの関数は、形態素解析で得られた結果をクリーンで使いやすい形に整えるために欠かせない存在です。

ちょっと長くなってきたので、今回はここまで。

次回は、今回解説した関数群を実際に使って、形態素解析APIの処理全体がどのように動いているのか、その全容を書いていこうと思います。

それではみなさん、よい開発ライフ。

今日のコードが実際に使われているあなたを追跡しないアダルト動画の検索エンジンSae-Pornsはこちら。よかったら見ていってください。※18歳未満の方はご利用いただけません。

sae-porns.org