日本語の単語分割APIを立てて他の言語で利用する
形態素解析 (morphological analysis)
日本語は,英語などの言語のように単語間をスペースで区切りません.しかし,文書の解析や,検索など,現実には文章を単語の配列に変換したいことが多々あります.そんな時に活躍するソフトウェアが形態素解析機です.
形態素解析機を用いることで日本語の文章を単語単位で分割することができます.
"私はエンジニアです"
[
	"私",
	"は",
	"エンジニア",
	"です",
]
形態素解析機では単語分割の他に,単語に対する品詞情報の付与,,単語の原型の復元といった機能を使うことができます.
単語の原型の復元を利用する用途としては,活用の存在する単語の頻度の計測があります. 例えば, 「走る」「走らない」はそのまま単語分割すると「走る」「走ら」となります. このように,活用の存在する単語でも,原型を復元して使えば,同じ単語として認識することができます.
現在では,辞書ベースの分割方法の他にニューラルネットワークを用いた分割方法があります.本記事のサンプルコードでは辞書ベースの分割方法を用いています.
辞書ベースの形態素解析機
ニューラルネットワーク(RNNベース)の形態素解析機
補足: n-gramと課題
素朴なアイディアとしてn-gramがあります.windowを決めて,その長さに文章を分割する方法です.
1-gram
"私はエンジニアです"
[
	"私",
	"は",
	"エ",
	"ン",
	"ジ",
	"ニ",
	"ア",
	"で",
	"す",
]
2-gram
"私はエンジニアです"
[
	"私は",
	"はエ",
	"エン",
	"ンジ",
	"ジニ",
	"ニア",
	"アで",
	"です",
]
3-gram
"私はエンジニアです"
[
	"私はエ",
	"はエン",
	"エンジ",
	"ンジニ",
	"ジニア",
	"ニアで",
	"アです",
]
このようにn-gramは簡単なアルゴリズムで使いやすいですが,課題もあります.
例えば,2-gramの語彙数が大きくなりすぎて検索に悪影響を与えることです.
小さなコーパス,文字がひらがな50文字に限定されていたとしても,50*50 = 2500 種類 語彙が存在することになります.
もう少し大きいコーパスを考えます.UnicodeのBMP (Basic Multilingual Plane)で考えると 0x0000から0xFFFF までの範囲にあるため,最大で65,536文字となります.実際に割り当てられていない部分もあるものの,50000文字以上は存在するといわれています.仮に50,000文字として,50,000 * 50,000 = 2,500,000,000 種類ほどになります.なお,実際には,日本語の旧字体などの異体字はBMP範囲外にあるためもっと大きくなると考えられます.
上記の理由から,1-gram, 2-gramだけでもで膨大な語彙を持つことになり,検索速度に影響を与えることになります.
補足の補足: CJK(V)漢字拡張はAはBMPの中にありますが,B-FはBMPの範囲外です.
- hiragana (U+3040 to U+309F): Hiragana PDF
 - katakana (U+30A0 to U+30FF): Katakana PDF
 - katakana_half_width (U+FF65 to U+FF9F): Half-width Katakana PDF
 - katakana_phonetic_extensions (U+31F0 to U+31FF): Katakana Phonetic Extensions PDF
 - cjk_unified_ideographs (U+4E00 to U+9FFF): CJK Unified Ideographs PDF
 - cjk_ideographs_extension_a (U+3400 to U+4DBF): CJK Extension A PDF
 - cjk_ideographs_extension_b (U+20000 to U+2A6DF): CJK Extension B PDF
 - cjk_ideographs_extension_c (U+2A700 to U+2B73F): CJK Extension C PDF
 - cjk_ideographs_extension_d (U+2B740 to U+2b81F: CJK Extension D PDF
 - cjk_ideographs_extension_e (U+2B820 to U+2CEAF): CJK Extension E PDF
 - cjk_ideographs_extension_f (U+2CEB0 to U+2EBEF): CJK Extension F PDF
 - japanese_punctuation (U+3000 to U+303F): Japanese Punctuation PDF
 
形態素解析ライブラリを他の言語でも使いたい
C++やJava,Javaであれば,これらのライブラリを直接使用して実装を進めることができます.しかし,他の言語の場合,Wrapperライブラリはあるものの,保守がされていくか不明であったり,そもそもWrapperライブラリを自作する必要があるということがあります.
コードを使用する期間が短い場合,それらを利用するという手法をとってもそこまで問題にはならないかもしれないです.しかし,長く利用するのであれば,既存でメンテナンスされているライブラリを使いたいところです.
形態素解析機を利用する
形態素解析は,ライブラリを持っている側の言語に任せるとして,問題になるのが,相互の連携方法です.単純に同じ環境に入れてしまうことありますが,環境が複雑になり,どのソフトウェア間の依存関係が不明瞭になってしまうことはなるべく避けたいものです.
形態素解析をする言語の環境と利用する言語の環境を混ぜないようにしたい場合,API経由でシステム間の通信をするという手があります.以下は実際に,Dockerで2台のコンテナを立てるデモです.PythonのAPIに対して,PHPからリクエストを行い,PHPで形態素解析を利用します.
プロダクションで行う場合は,リクエスト制限やリバースプロキシの利用を考えるべきですが,今回はあくまでdemoなので簡素な作りになっています.
compose.yml
services:
  php:
    build: ./php
    tty: true
    volumes:
    - ./php/src:/root/opt/src
    working_dir: /root/opt/src
    command: ["sh"]
  python:
    build: ./python
    tty: true
    volumes:
    - ./python/src:/root/opt/src
    working_dir: /root/opt/src
    command: ["sh", "/root/opt/src/api/entrypoint.sh"]
app.py
from typing import List
from fastapi import FastAPI
from api.dto.tokenize_request_dto import TokenizeRequestDTO
from api.dto.tokenize_service_input_dto import TokenizeServiceInputDTO
from api.tokenize_service import run_tokenize_service
app: FastAPI = FastAPI()
@app.post("/tokenize")
def handler(dto: TokenizeRequestDTO) -> dict[str, list[list[str]]]:
    service_dto: TokenizeServiceInputDTO = TokenizeServiceInputDTO(
        sentences=dto.sentences
    )
    tokenized_sentences: List[List[str]] = run_tokenize_service(
        dto=service_dto
    ).tokenized_sentences
    return {"tokenized_sentences": tokenized_sentences}
tokenize_service.py
from api.dto.tokenize_service_input_dto import TokenizeServiceInputDTO
from api.dto.tokenize_service_output_dto import TokenizeServiceOutputDTO
from api.tokenize_repository import TokenizeRepository
def run_tokenize_service(dto: TokenizeServiceInputDTO) -> TokenizeServiceOutputDTO:
    tokenizer = TokenizeRepository(sentences=dto.sentences)
    return TokenizeServiceOutputDTO(tokenized_sentences=tokenizer.tokenize())
tokenize_repository.py
最後にライブラリのWrapper Classを追加します.
ここでは,Janomeという形態素解析ライブラリを使っています.Janomeは pip install janome  をするだけで形態素解析の環境が出来上がるので,すぐに試したい方におすすめです.※1
from typing import List
from janome.tokenizer import Tokenizer
from pydantic import BaseModel, ConfigDict, Field
class TokenizeRepository(BaseModel):
    tokenizer: Tokenizer = Field(default_factory=Tokenizer)
    sentences: List[str]
    model_config = ConfigDict(arbitrary_types_allowed=True)
    def tokenize(self) -> List[List[str]]:
        res = [
            [str(token) for token in self.tokenizer.tokenize(text, wakati=True)]
            for text in self.sentences
        ]
        return res
※ Janomeは環境構築で他のライブラリに勝る反面,実行速度が遅いという問題があります.そのため,必ずこのライブラリが良いというわけではありません.例えば,機械学習でそれなりに規模の大きなデータを使うのであれば,mecab-python3を使った方が高速に動くため適しているといえます.要件に合わせて選んでください.
DTO達はデータを運ぶだけなのでまとめています.
from typing import List
from pydantic import BaseModel
class TokenizeRequestDTO(BaseModel):
    sentences: List[str]
class TokenizeServiceInputDTO(BaseModel):
    sentences: List[str]
class TokenizeServiceOutputDTO(BaseModel):
    tokenized_sentences: List[List[str]]
PHP: Service層
<?php
declare(strict_types=1);
namespace Root\Src\Service\Tokenizer;
use RuntimeException;
use Root\Src\Infrastructure\FileReader\FileReader;
use Root\Src\Infrastructure\Fetcher\SendRequestToPythonApi;
final readonly class Tokenizer {
    /**
     * @var array<string> $sentences
     */
    public array $sentences;
    /**
     * @var array<array<string>> $tokenized_sentences
     */
    public array $tokenized_sentences;
    public function __construct(string $file_path) {
        $this->sentences = FileReader::readFileContents($file_path);
        $this->tokenized_sentences = $this->setTokenizedSentences();
    }
    /**
     * Send a POST request to the Python API to tokenize sentences.
     *
     * @return array<array<string>>
     * @throws RuntimeException If the request fails or the response format is invalid.
     */
    public function setTokenizedSentences(): array
    {
        return SendRequestToPythonApi::tokenize($this->sentences);
    }
}
PHP: Infrastructure層
<?php
declare(strict_types=1);
namespace Root\Src\Infrastructure\Fetcher;
use RuntimeException;
class SendRequestToPythonApi
{
    /**
     * Send a POST request to the Python API to tokenize sentences.
     *
     * @param array<string> $sentences Array of sentences to be tokenized.
     * @return array<array<string>> The response from the Python API containing tokenized sentences.
     * @throws RuntimeException If the request fails or the response format is invalid.
     */
    final public static function tokenize(array $sentences): array
    {
        // API endpoint
        $url = 'http://python:8000/tokenize';
        // Convert request data to JSON
        $data = json_encode(['sentences' => $sentences]);
        if ($data === false) {
            throw new RuntimeException('Failed to encode JSON.');
        }
        // Initialize cURL session
        $ch = curl_init($url);
        if ($ch === false) {
            throw new RuntimeException('Failed to initialize cURL session.');
        }
        // Set cURL options
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_POST, true);
        curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
        curl_setopt($ch, CURLOPT_HTTPHEADER, [
            'Content-Type: application/json',
            'Content-Length: ' . strlen($data)
        ]);
        // Execute request and get response
        $response = curl_exec($ch);
        // Check for errors
        if ($response === false) {
            $error = curl_error($ch);
            curl_close($ch);
            throw new RuntimeException('Request Error: ' . $error);
        }
        // Close the session
        curl_close($ch);
        // Check if response is a string
        if (!is_string($response)) {
            throw new RuntimeException('Response is not a valid string.');
        }
        // Decode and return the response
        $decodedResponse = json_decode($response, true);
        if ($decodedResponse === null) {
            throw new RuntimeException('Failed to decode JSON response.');
        }
        if (!is_array($decodedResponse) || !isset($decodedResponse['tokenized_sentences']) || !is_array($decodedResponse['tokenized_sentences'])) {
            throw new RuntimeException('Invalid response format.');
        }
        foreach ($decodedResponse['tokenized_sentences'] as $tokenizedSentence) {
            if (!is_array($tokenizedSentence) || array_filter($tokenizedSentence, 'is_string') !== $tokenizedSentence) {
                throw new RuntimeException('Invalid response format.');
            }
        }
        return $decodedResponse['tokenized_sentences'];
    }
}
実際にPHPで形態素解析をしてみます.
$raw_file_path = __DIR__ . '/../data/raw/sentences.txt';
$tokenizer = new Tokenizer(file_path: $raw_file_path);
print_r(value: $tokenizer->sentences[0] . "\n");
print_r(value: $tokenizer->tokenized_sentences[0]);
単語ごとに分割できている様子がわかります.
青い空に白い雲が浮かんでいるのを見て、気持ちが落ち着いた。
Array
(
    [0] => 青い
    [1] => 空
    [2] => に
    [3] => 白い
    [4] => 雲
    [5] => が
    [6] => 浮かん
    [7] => で
    [8] => いる
    [9] => の
    [10] => を
    [11] => 見
    [12] => て
    [13] => 、
    [14] => 気持ち
    [15] => が
    [16] => 落ち着い
    [17] => た
    [18] => 。
)
Discussion