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

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

イメージ画像から検索用タグを自動生成するというお話:③スダチ形態素解析編

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

今回は、前回の「MythoMaxでキャッチコピー生成」編で得られた日本語タイトルを、さらに検索性の高い語句に分解していく第3ステップ──「形態素解析」編です。

今日の私は言語学者です。

  • AIが画像から文章をつくる(BLIP)
  • 英語キャプションを日本語に翻訳(MythoMax)
  • 形態素解析で語句を抽出(SudachiPy)←今回のテーマ1
  • フィルタ&整形して、検索タグとして格納←今回のテーマ2

ティアラ_手を振る

タイトルは自然でも、「そのまま検索語」には向かない

MythoMaxで得られる出力は、たしかに自然な日本語の一文です。

しかし、たとえば:

「金髪少女が微笑む午後の光の中で」

このままでは長すぎる上に、検索ワードとしては冗長すぎます。

そこで、これを意味のある単語単位に分解し、さらに組み合わせたり、英語も混ぜたりして、タグ候補の語句セットを抽出する必要があります。

セクション①:初期処理と形態素解析

まずは、日本語のキャッチコピーから、正規表現を使って句読点や括弧などの不要な記号類を削除します(例:「、」「。」など)。

その後、SudachiPy を使って文字列を形態素(単語単位)に分解し、tokens に格納します。

この時点で、あとから使う単語や語句を一時的に保持するための空リストを初期化しておきます。

さらに、英単語が含まれているケースにも備えて、3文字以上の英単語も別途抽出しておきます。

cleaned = re.sub(r'[。、・「」『』【】()]', '', text)
tokens = tokenizer_obj.tokenize(cleaned, mode)

word_tokens =
normalized_forms =

bunsetsu_phrases =
compound_phrases =

english_tokens = re.findall(r'\b[a-zA-Z]{3,}\b', text)

セクション②:名詞・動詞・形容詞の抽出

次に、各品詞ごとに検索に使える語句を抽出します。

まず、名詞については、そのまま正規化した上で、is_valid_token() によるバリデーションを通ったものだけを抽出します。

一方、動詞・形容詞のように活用形がある語句については、原形(辞書形)に変換してからバリデーションし、リストに追加します。

for t in tokens:

    if t.part_of_speech()[0] == "名詞":
        noun = normalize_noun(t.surface())
        if is_valid_token(noun, "名詞"):
            word_tokens.append(noun)

for t in tokens:
    if t.part_of_speech()[0] in {"動詞", "形容詞"}:
        base = t.normalized_form()
        if base != t.surface() and is_valid_token(base, t.part_of_speech()[0]):
            normalized_forms.append(base)

なお、ここでそのまま抽出してしまうと、「する」や「なる」などの一般的すぎる語句や、「1」「2023」などの数字も含まれてしまいます。

そこで、以下のような is_valid_token() 関数を使って、ノイズとなる語句を除外しています。

 is_valid_token() 関数

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

複数語(バイグラムなど)のバリデーションには、すべての語に対して is_valid_token() を適用する以下の関数を使用します:

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

セクション③:文節(係り受けのまとまり)を抽出

ここまでで、名詞や動詞などの単語単体の抽出は完了しました。

しかし実際の検索では、「微笑んでいる」「見つめる視線」のように、複数の語がまとまって1つの意味を成す“語の塊”が重要になります。

そこでこのセクションでは、文節に近い意味のまとまりを取り出すためのロジックを導入しています。

自立語で区切る簡易的な「文節」分割

日本語には、「自立語が出たら意味のまとまりが切り替わる」という性質があります。
この性質を利用し、Sudachiによる形態素列を以下のルールで分割しています:

def split_bunsetsu(tokens):
    bunsetsu, current = ,
    for t in tokens:
        if t.part_of_speech()[0] in {"名詞", "動詞", "形容詞", "形状詞"}:
            if current:
                bunsetsu.append(current)
            current = [t]
        else:
            current.append(t)
    if current:
        bunsetsu.append(current)
    return bunsetsu

for seg in split_bunsetsu(tokens):
    phrase = ''.join(t.surface() for t in seg)
    if len(phrase) > 1 and is_valid(phrase):
        bunsetsu_phrases.append(phrase)

  • 名詞・動詞・形容詞・形状詞 → 自立語として、ここで区切りを入れる
  • 助詞や助動詞など → 直前の語にくっつけて、文節風のまとまりを作る

例:文節化の出力

たとえば以下のようなキャッチコピー:

原文「彼女が静かに微笑んだ午後」

これに対して、以下のような文節的なまとまりが得られます:

「彼女が」

「静かに」

「微笑んだ」

「午後」

このように、意味の単位である程度自然な検索語句が拾える構成になっています。

補足:「金髪の少女」はここでは抽出されない

ただし、この方法では「金髪の少女」のような複合名詞は抽出できません。

というのも、「金髪(名詞)」「の(助詞)」「少女(名詞)」という構成は、前述のロジックでは文節の途中で切れてしまうからです。

対応:「複合語抽出ロジック」へ

このような名詞+の/な+名詞の構造は、セクション④の「複合語抽出ロジック」によって別途検出されます。

金髪(名詞)+ の(助詞)+ 少女(名詞) → 「金髪の少女」

こうした語句は、検索で非常に有効であるため、文節とは独立に取り出す工夫をしています。

セクション④「複合語抽出ロジック」

このセクションでは、「金髪の少女」「大人な女性」のような、名詞+の/な+名詞で構成される複合語を抽出します。

これは検索で非常に頻出かつ意味の強い語句構造であり、検索利便性のためには、単語や文節とは別ロジックで確実に拾い上げる必要があります。

  for i in range(len(tokens) - 2):
        t1, t2, t3 = tokens[i], tokens[i+1], tokens[i+2]
        if (t1.part_of_speech()[0] == "名詞" and
            t2.surface() in {"の", "な"} and
            t3.part_of_speech()[0] == "名詞"):
            phrase = t1.surface() + t2.surface() + t3.surface()
            if is_valid(phrase):
                compound_phrases.append(phrase)

※名詞 → の/な → 名詞 の3連続にマッチ

※抽出後は is_valid() によるフィルタを通して compound_phrases に格納

これにより、以下のようなありそうな検索クエリの抽出が可能になります。

  • 金髪(名詞) の(助詞) 少女(名詞)→    金髪の少女
  • 大人(名詞) な(形容動詞語幹) 女性(名詞)→ 大人な女性
  • 少女(名詞) の(助詞) 微笑み(名詞)→ 少女の微笑み

セクション⑤:バイグラムの生成(日本語・英語)

その他のパターンとしては、「金髪 少女」「大人 女性」のようなよくあるバイグラムを抽出する必要があります。

 noun_bigrams =
    for i in range(len(word_tokens)):
        for j in range(i + 1, len(word_tokens)):
            for a, b in [(word_tokens[i], word_tokens[j]), (word_tokens[j], word_tokens[i])]:
                big = f"{a} {b}"
                if is_valid(big):
                    noun_bigrams.append(big)

上記コードのポイント:
  • 順不同で両方の並び(A B / B A)を生成
  • 全角スペースで結合
  • is_valid() でフィルタしてノイズを除去

    english_bigrams =
    for i in range(len(english_tokens)):
        for j in range(i + 1, len(english_tokens)):
            big = f"{english_tokens[i]} {english_tokens[j]}"
            if is_valid(big):
                english_bigrams.append(big)

上記コードのポイント:
  • 英語は順序付き(A B のみ)
  • 半角スペースで結合
  • 同じく is_valid() でノイズ除去

Sae-Pornsでは、「金髪 少女」と「少女 金髪」は検索意図が異なるとみなし、別の検索クエリとして扱います。

そのため、名詞バイグラムは、順序付きの組み合わせを両方保持するように設計しています。

この処理によって、

「金髪 少女」

「大人 女性」

「blonde girl」

「adult woman」

のような、シンプルで強い検索語の組み合わせを抽出できます。語順を含めたバリエーションの拡張により、タグの検索適合率がさらに向上します。

セクション⑥:すべての候補語を集約・正規化・出力

    final = set()
    final.update(word_tokens)         # 単語(名詞など)
  final.update(normalized_forms)    # 動詞・形容詞の原形
  final.update(bunsetsu_phrases)    # 文節っぽいまとまり
  final.update(compound_phrases)    # 名詞+の/な+名詞の複合語
  final.update(noun_bigrams)        # 名詞バイグラム(順序あり)
  final.update(english_tokens)      # 英語単語
  final.update(english_bigrams)     # 英語バイグラム
    final.add(text)  # キャプション自体も検索語に含める

    return sorted({ normalize(w) for w in final if is_valid(w) })

まず、final = set()で重複のない候補語一覧を作るためのセット(集合)を用意します。

そして、ここまでで準備した各語句を一か所にまとめて重複排除を行います。

この時、やはりキャプション自体を検索する(タイトルを覚えておいてそれを検索する)というケースも当然あり得るので、キャプション自体も検索語として保管します。

そして、最終的に予め用意していたis_valid関数と以下のようなnormalize関数を活用し最終仕上げをします。

normalize関数

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

最終仕上でやっていることがちょっと複雑なので解説します。

① for w in final if is_valid(w)

→ ノイズ語の除去(フィルタリング)

final に入っている語句の中から、is_valid(w) を満たすものだけを対象にします。

つまり、「意味のない1文字」「数字だけの語」「空白のみ」などをここで除外します。

② normalize(w)

→ 正規化(表記ゆれの統一)

通常、文字列には表記ゆれがあります(例:全角・半角、ひらがな・カタカナ、不可視文字など)。

normalize(w) によってそれらを統一された形に変換します。

たとえば:

入力    normalize後
"少女 "    "少女"
"ガール"    "ガール"
"AI"    "AI"

③ { ... }(内包表記)+ set

→ 重複の排除

集合(set)にしているので、同じ語が複数回登場しても1つに統合されます。

④ sorted(...)

→ 並べ替え(見た目や出力の安定化)

最後に sorted() でアルファベット順や50音順に並べ替えます。これにより、出力が毎回同じ順序になるので、テストやデバッグ時にも有利です。

これによって、「意味のある」「表記が統一され」「重複のない」「並び順が安定した」検索用の語句リストが完成するわけです。

長い道のりでしたが、ここまでの処理でようやく、画像から生成されたキャプションが自然な日本語に変換され、検索に適した語句の集合へと変わりました。

ネタバレですが、Sae-Pornsではユーザーが本当に探している動画にたどり着けるような検索体験を提供するため、単なる形態素解析にとどまらず、検索精度と実用性のバランスを意識した語句抽出を行っています。

今後、もっとよい仕組みを思いついたら、また柔軟に変えていくかもしれません。

それでは皆さま、よき開発ライフを。

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

 

sae-porns.org