PowerCMS Xで管理しているコンテンツの類似文書検索にMroongaを利用する 

PowerCMS Xで管理しているコンテンツの類似文書検索（関連するコンテンツの検索）に「[Mroonga](https://mroonga.org/ja/)」を利用してみました。約8年前、Movable Typeでブログ記事を全文検索するために`mt-ftsearch.cgi`を介してMroongaを利用しましたが、類似文書検索は初めてです。

## Mroongaとは

> Mroongaは全文検索エンジンであるGroongaをベースとしたMySQLのストレージエンジンです。
>
> MySQLではver5.1からPluggable Storage Engineインタフェースが採用され、以前よりも柔軟に独自のストレージエンジンを利用できるようになりました。そこでGroongaにストレージエンジンインタフェースを実装し、MySQL経由でもGroongaを利用できるようにしました。

https://mroonga.org/ja/docs/characteristic.html#what-is-mroonga

## Mroongaのインストール

Amazon Linux 2にMySQL 8をインストールしているサーバーにMroongaを導入しました。公式ドキュメントに環境別のインストール方法が記載してあること、Amazon LinuxやCent OS等では`yum`で利用できるリポジトリがあることから、指定のコマンドを実行するだけで容易にインストールを行うことができました。文章を語彙に分割するトークナイザー「[MeCab](https://taku910.github.io/mecab/)」も`yum`でインストールできます。

https://mroonga.org/ja/docs/install.html

## テーブルの設定

今回はentryモデル（記事）を対象に実験したいので、mt_entryテーブルに設定を行います。phpMyAdminから操作しました。

- ストレージエンジンを「InnoDB」から「Mroonga」に変更
- FULLTEXTインデックスを設定（entry_title, entry_textカラム）

### Mroongaの動作モードについて

今回はMroongaの動作モードに「ストレージモード」を選択しています。ストレージモードでは「null値」・「トランザクション」をサポートしていないことに注意が必要です。

### 設定の確認

「東京都」と「京都府」を含む記事を用意して「京都」で検索した場合、結果に「東京都」が含まれず「京都」に関する記事のみがヒットすることが確認できました。

```sql
SELECT `entry_id`, `entry_title`, `entry_text` FROM `mt_entry`
  WHERE MATCH( `entry_title`, `entry_text` ) AGAINST("+京都" IN BOOLEAN MODE);
```

`my.cnf`にて`mroonga_query_log_file`で指定したログにも記録が残っていました。

```
2023-02-04 17:07:06.953039|0x7fe0c4396e90|:000000073892302 filter(3): (match columns) match "京都"
```

## 類似文書検索を行うスクリプトを記述する

類似文書検索の方法もMroongaのドキュメントに記載されています。

https://mroonga.org/ja/docs/tutorial/storage.html#how-to-use-similar-search

entryモデルの記事はHTMLファイルを出力して配信しているため、類似文書の表示はJavaScriptを用いて行います。HTMLファイルの中に類似文書を含めることも可能と考えますが、記事を編集する度に全てのHTMLファイルを再構築（再生成）する必要がありますので、1日1回深夜に全再構築する等、運用状況に合わせた検討が必要になるでしょう。

今回はPrototype（PowerCMS Xの実体）を利用した小さなアプリケーションに落とし込み`/blog/similar_entry.php`に設置しました。このスクリプトにJavaScriptからアクセスすることにします。

```php
<?php
require_once 'class.Prototype.php'; // パスは適宜合わせてください

class SimilarSearch {
    private $app;
    private $available_models = [ 'entry' ];

    public function __construct() {
        $this->app = new Prototype( [ 'id' => 'PTMroonga' ] );
        $this->app->init();
    }

    public function run() {
        $object_id = (int) $this->app->param( 'id' );
        $model = $this->app->param( 'model' ) ? $this->app->param( 'model' ) : 'entry';
        $workspace_id = $this->app->param( 'workspace_id' ) ? (int) $this->app->param( 'workspace_id' ) : 0;

        if ( ! isset( $object_id ) ||  ! in_array( $model, $this->available_models, true ) ) {
            // TODO: エラーメッセージを返す
            return;
        }

        $table = $this->app->get_table( $model ); // NOTE: テーブルの存在チェックをした方が良いそうです。
        if ( ! $table ) {
            // TODO: エラーメッセージを返す
            return;
        }

        // 指定されたIDのオブジェクト（今回は記事）を取得する
        $object = $this->app->db->model( $model )->load( $object_id );
        if ( empty( $object ) ) {
            // TODO: エラーメッセージを返す
            return;
        }

        // クエリを作成して実行する
        // テーブル毎にカラム名が変わりそう、全文検索用テーブルを用意しようか、等と思っているのでモデル名が`entry`決め打ちになっています。
        $content = $object->title . ' ' . $object->text;
        $query = <<<EOQ
SELECT `entry_id`, `entry_title`, `entry_workspace_id`, MATCH( `entry_title`, `entry_text` ) AGAINST ( ? IN NATURAL LANGUAGE MODE ) AS score
  FROM `mt_entry`
  WHERE MATCH( `entry_title`, `entry_text` ) AGAINST ( ? IN NATURAL LANGUAGE MODE )
    AND `entry_id` != ? AND `entry_rev_type` = 0 AND `entry_workspace_id` = ? AND `entry_status` = 4
  LIMIT 5;
EOQ;
        $values = [ $content, $content, $object_id, $workspace_id ];
        $similar_objects = $this->app->db->model( $model )->load( $query, $values );

        // レスポンスを作成する
        $response = [
            'totalResults' => empty( $similar_objects ) ? 0 : count( $similar_objects ),
            'items' => [],
        ];
        if ( ! empty( $similar_objects ) ) {
            foreach ( $similar_objects as $object ) {
                $response[ 'items' ][] = (object) [
                    'id'        => $object->id,
                    'title'     => $object->title,
                    'score'     => $object->score,
                    'permalink' => $this->app->get_permalink( $object ),
                ];
            }
        }

        header( 'Content-Type: application/json; charset=utf-8' );
        echo json_encode( $response );
    }

}

$similar_search = new SimilarSearch();
$similar_search->run();
```

## 類似文書検索を実行

サンプルデータとして[私のブログ記事](https://www.anothersky.jp/)を全て入れて「[axe-coreのRule DescriptionsやACT-Rulesを読み、スプレッドシートに詳細をまとめてみた](https://www.anothersky.jp/2022/06/axe-core-and-act-rules.html)」のHTMLを開き、JavaScriptで`/blog/similar_entry.php`にアクセスすると以下のような結果が得られました。

```json
{
	"totalResults": 5,
	"items": [
		{
			"id": "5",
			"title": "テキストのサイズ変更やリフロー、400％拡大にまつわる話",
			"score": "838881",
			"permalink": "/blog/2022/12/a11y-and-rwd.html"
		},
		{
			"id": "77",
			"title": "Puppeteerとaxe-coreで複数ページのアクセシビリティ検証を実現する",
			"score": "629148",
			"permalink": "/blog/2019/10/develop-axe-runner.html"
		},
		{
			"id": "144",
			"title": "広島空港の滑走路ウォークに参加",
			"score": "524290",
			"permalink": "/blog/2016/10/hij_runway_walk.html"
		},
		{
			"id": "123",
			"title": "Movable Type 6 + Apliko + React Nativeでブログリーダーアプリを制作",
			"score": "419432",
			"permalink": "/blog/2018/02/mt6-react-native.html"
		},
		{
			"id": "17",
			"title": "axe-coreを利用したアクセシビリティチェックのレポートCSVに達成基準などの情報を加えてみた",
			"score": "419432",
			"permalink": "/blog/2022/05/axe-runner-add-tags.html"
		}
	]
}
```

「axe-coreのRule DescriptionsやACT-Rulesを読み、スプレッドシートに詳細をまとめてみた」はアクセシビリティに関する記事なのでアクセシビリティに関する記事が並べば成功ですが、「広島空港の滑走路ウォークに参加」・「Movable Type 6 + Apliko + React Nativeでブログリーダーアプリを制作」のように筆者としては関連性がないと考える記事が混ざることに気付きました。何をすべきか一週間考えたのですが、クエリを作成する処理の前に以下の内容を試してみました。

1. PHPやJavaScriptのコードサンプルを削除する（code要素を削除する）
1. Markdownで書いた記事はHTMLに変換する
1. img要素のalt属性を除いてHTMLを削除する
 
```php
private function optimize_content( PADOMySQL $object, string $column_name ): string {
    $pt_tags = new PTTags();
    $text = $object->$column_name;

    $this->app->ctx->stash( 'entry', $object );
    $this->app->ctx->stash( 'current_context', 'entry' );

    $text = preg_replace( '/<code>[\w\W]*?<\/code>/', '', $text );
    $text = strip_tags( $pt_tags->filter_convert_breaks( $text, 'auto', $this->app->ctx ), [ 'img' ] );
    $text = preg_replace( '/<img.*?alt="(.*?)"[^>]+>/', '$1', $text );

    return $text;
}
```

この結果、「広島空港の滑走路ウォークに参加」・「Movable Type 6 + Apliko + React Nativeでブログリーダーアプリを制作」は結果に表れなくなりました。ただ、改修後の結果がまだ完璧とも言えないのが難しいところです。

```json
{
	"totalResults": 5,
	"items": [
		{
			"id": "5",
			"title": "テキストのサイズ変更やリフロー、400％拡大にまつわる話",
			"score": "1275793",
			"permalink": "/blog/2022/12/a11y-and-rwd.html"
		},
		{
			"id": "147",
			"title": "Re: 貸借対照表(B/S)のマークアップ",
			"score": "838864",
			"permalink": "/blog/2016/09/b_s_table_markup.html"
		},
		{
			"id": "265",
			"title": "CSSで縦書きを実装した話",
			"score": "480609",
			"permalink": "/blog/2013/10/000167.html"
		},
		{
			"id": "17",
			"title": "axe-coreを利用したアクセシビリティチェックのレポートCSVに達成基準などの情報を加えてみた",
			"score": "480607",
			"permalink": "/blog/2022/05/axe-runner-add-tags.html"
		},
		{
			"id": "310",
			"title": "スクロール完了（停止）の検出と、フローティングメニューの実装",
			"score": "466048",
			"permalink": "/blog/2011/11/000117.html"
		}
	]
}
```

カラムごとに重み付けして検索することもできるようなので、titleを重要視するような設定にしても良いかもしれません。（公式ドキュメントだと「[5.7.1.4.2. W プラグマ](https://mroonga.org/ja/docs/reference/full_text_search/boolean_mode.html#w-pragma)」を参照）

https://qiita.com/groonga/items/8bcd5f9647f62b96584f

## まとめ

類似文書検索対象が単一のテーブル（モデル）の場合、Mroongaで容易に類似文書検索の実行ができることが分かりました。SQLで検索できるので追加の検索条件（あるカラムの値がXであるもの、等）を加えることも容易でしょう。

複数のテーブル（モデル）にまたがる場合は全文検索用のテーブルを用意する必要があるかなと考えています。その際、「あるカラムの値がXであるもの」のような条件を加えるのが少し手間かもしれません。この点は現在提供されている「[サイト内全文検索機能(SearchEstraierプラグイン)](https://powercmsx.jp/about/search_estraier_plugin.html)」が有利なのかも…と思うなどします。

## 付録：観光施設の類似文書検索

福山市・広島市・倉敷市・鹿児島市の観光施設に関するオープンデータをインポートし、ここまでに紹介した内容で類似文書検索をしたところ、次のキャプチャのような結果になりました。  
![観光施設のページにMroongaとHyperEstraierで関連する観光施設を表示している画面のキャプチャ。広島市現代美術館のページなので関連する観光施設には主に美術館が表示されている。](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6914/72437b08-1e87-bbc1-6d6f-21d333bf2024.png)