delhi09の勉強日記

技術トピック専用のブログです。自分用のメモ書きの投稿が多いです。あくまで「勉強日記」なので記事の内容は鵜呑みにしないでください。

DjangoでMySQLの全文検索機能を使う

はじめに

概要

この記事はDjango Advent Calendar 2021の19日目の記事です。
本記事ではDjangoMySQL全文検索機能を使う方法について紹介します。

前提として、DjangoMySQL全文検索機能を標準ではサポートしていません。従って、いくつかのノウハウが必要になります。

Djangoの公式ドキュメントには「Full text search」という項目がありますが、中身を読むとこれはPostgreSQL専用の機能です。
docs.djangoproject.com

検証に使用したバージョン

お断り

  • 本記事の内容を実務に投入したことはないので、そういったナレッジは話せません。

役に立つ可能性のある場面

先にまとめ

やること・気をつけることは大きく以下の4つです。

  • Migrationファイルに全文検索インデックス作成用のSQLコマンドを追加する。
  • Custom Lookupsを作成する。
  • 複数カラムで検索したいときの戦略を決める。
  • UnitTestにはTransactionTestCaseを使用する。

以下のリポジトリにデモを公開しています。
github.com

DjangoMySQL全文検索機能を標準でサポートしてない経緯

DjangoMySQL全文検索機能を標準ではサポートしていないと冒頭で書きました。

厳密には、古いバージョンのDjangoではsearchというMySQL全文検索機能用のlookup構文が標準で備わっていたようです。

しかし、

  • MySQL専用の機能である。
  • 機能がかなり制限されている。

という理由で1.10のリリースノートの時点で、deprecatedが宣言されていました。

docs.djangoproject.com

同リリースノートでは、代わりにCustom Lookupsを使うことが推奨されています。(以降で詳しく説明します。)

早速、やり方を説明していきます。

Migrationファイルに全文検索インデックス作成用のSQLコマンドを追加する

Modelを定義する

Modelの定義においては、全文検索インデックスを貼るフィールドだからといって、特別なことをする必要はありません。
以下のように普通にCharFieldTextFieldで宣言します。

class Novel(models.Model):
    # ...
    search_text = models.TextField() # 全文検索インデックスを貼るフィールド
    # ...
【補足】FTS_DOC_IDカラムについて

MySQL徹底入門 MySQL 8.0対応』に以下のような記述がありました。

フルテキストインデックスを使用する場合は、ドキュメントを一意に識別するためにFTS_DOC_IDという名前の列を上記の定義で作成します(名称もFTS_DOC_IDにする必要があります)。(…) FTS_DOC_ID列を明示的に作成しなかった場合は、自動的に非表示のFTS_DOC_ID列が作成されますが、明示的に作成したほうがパフォーマンスがよくなります。(p.137)

気になったので公式ドキュメントを確認したところ、FTS_DOC_IDについて以下のような記載がありました。

MySQL :: MySQL 8.0 リファレンスマニュアル :: 15.6.2.4 InnoDB FULLTEXT インデックス

CREATE TABLE 時に FTS_DOC_ID カラムを定義すると、すでにデータがロードされているテーブルに全文インデックスを作成するよりもコストがかかりません。 データをロードする前に、テーブル上に FTS_DOC_ID カラムが定義されている場合は、新しいカラムが追加されるようにテーブルおよびそのインデックスを再構築する必要がありません。

公式ドキュメントによると、「パフォーマンスがよくなる」というのは、既にテーブルにレコードが存在する状態でインデックスを追加する際の話のようです。

多くのケースではメリットがなさそうかつ、テーブルにRDBの内部処理用のカラムを明示的に持つことによる保守性低下のデメリットの方が大きそうなので、本記事ではFTS_DOC_IDは定義しない方針とします。

Migrationファイルの編集

Djangoが生成したMigrationファイルに、migrations.RunSQL全文検索インデックス作成用のSQLコマンドを追加します。

class Migration(migrations.Migration):
    # ...
    operations = [
        migrations.CreateModel(
            name="Novel",
            fields=[
                # ...
                ("search_text", models.TextField()),
                # ...
            ],
            options={
                "db_table": "novel",
            },
        ),
        # 以下を追加
        migrations.RunSQL(
            "CREATE FULLTEXT INDEX search_text_fulltext_search_index"
            " ON novel (search_text)"
            " WITH PARSER ngram",
            "DROP INDEX search_text_fulltext_search_index ON novel",
        ),
    ]
説明
    raise IrreversibleError("Operation %s in %s is not reversible" % (operation, self))
django.db.migrations.exceptions.IrreversibleError: Operation <RunSQL 'CREATE FULLTEXT INDEX search_text_fulltext_search_index ON novel (search_text) WITH PARSER ngram'> in app.0002_novel is not reversible

Migrationを実行する

実行

Migrationを実行します。

$ python manage.py migrate
FULLTEXTインデックスの確認

以下のようにカラムにFULLTEXTインデックスが貼られていることを確認できます。

mysql> show indexes from  novel;
+-------+------------+-----------------------------------+--------------+-------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
| Table | Non_unique | Key_name                          | Seq_in_index | Column_name | Collation | Cardinality | Sub_part | Packed | Null | Index_type | Comment | Index_comment | Visible | Expression |
+-------+------------+-----------------------------------+--------------+-------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
| novel |          0 | PRIMARY                           |            1 | id          | A         |           0 |     NULL |   NULL |      | BTREE      |         |               | YES     | NULL       |
| novel |          1 | search_text_fulltext_search_index |            1 | search_text | NULL      |           0 |     NULL |   NULL |      | FULLTEXT   |         |               | YES     | NULL       |
+-------+------------+-----------------------------------+--------------+-------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
2 rows in set (0.02 sec)

また、データを登録した後に、以下のコマンドを順番に実行すると、実際にバイグラムのインデックスが作成されていることが確認できます。(今回のデモアプリではDjango Adminから登録します。)

mysql> SET GLOBAL innodb_ft_aux_table= "demo_app/novel" ; -- DB名/テーブル名は読み替える。
mysql> SET GLOBAL innodb_optimize_fulltext_only=ON;
mysql> OPTIMIZE TABLE novel; -- テーブル名は読み替える。
mysql> SELECT * from INFORMATION_SCHEMA.INNODB_FT_INDEX_TABLE;

MySQL :: MySQL 8.0 リファレンスマニュアル :: 15.15.4 InnoDB INFORMATION_SCHEMA FULLTEXT インデックステーブル

例えば夏目漱石の『草枕』の冒頭を登録して、上記のコマンドで確認した結果は以下です。

mysql> select * from INFORMATION_SCHEMA.INNODB_FT_INDEX_TABLE;
+--------+--------------+-------------+-----------+--------+----------+
| WORD   | FIRST_DOC_ID | LAST_DOC_ID | DOC_COUNT | DOC_ID | POSITION |
+--------+--------------+-------------+-----------+--------+----------+
| 、こ   |            2 |           2 |         1 |      2 |       31 |
| う考   |            2 |           2 |         1 |      2 |       37 |
| えた   |            2 |           2 |         1 |      2 |       43 |
| がら   |            2 |           2 |         1 |      2 |       25 |
| こう   |            2 |           2 |         1 |      2 |       34 |
| た。   |            2 |           2 |         1 |      2 |       46 |
| なが   |            2 |           2 |         1 |      2 |       22 |
| ら、   |            2 |           2 |         1 |      2 |       28 |
| りな   |            2 |           2 |         1 |      2 |       19 |
| を登   |            2 |           2 |         1 |      2 |       13 |
| 山路   |            2 |           2 |         1 |      2 |        7 |
| 登り   |            2 |           2 |         1 |      2 |       16 |
| 考え   |            2 |           2 |         1 |      2 |       40 |
| 草枕   |            2 |           2 |         1 |      2 |        0 |
| 路を   |            2 |           2 |         1 |      2 |       10 |
+--------+--------------+-------------+-----------+--------+----------+
15 rows in set (0.01 sec)

Custom Lookupsを作成する

次にCustom Lookupsを作成します。
Custom Lookupsという機能は、私はこれまで使ったことがなかったのですが、Django ORMの表現を独自拡張できるものという理解です。

一般的なCustom Lookupsの追加の方法はDjangoの公式ドキュメントに記載があります。
Custom Lookups | Django documentation | Django

Custom Lookupsを定義する

前提として、素のMySQLにおける全文検索を利用したクエリーは以下のように書きます。

mysql> SELECT id, title FROM novel WHERE author_name = "夏目漱石" and MATCH(search_text) AGAINST("考えた" IN BOOLEAN MODE);
+----+--------+
| id | title  |
+----+--------+
|  1 | 草枕   |
+----+--------+
1 row in set (0.01 sec)


上記のMATCH({検索対象カラム}) AGAINST("{検索キーワード}" IN BOOLEAN MODE)の部分を表現するためのsearchというMySQL全文検索用のCustom Lookupsを定義します。

demo/app/lookups.py

from django.db import models


class Search(models.Lookup):

    lookup_name = "search"

    def as_mysql(self, compiler, connection):
        lhs, lhs_params = self.process_lhs(compiler, connection)
        rhs, rhs_params = self.process_rhs(compiler, connection)
        params = lhs_params + rhs_params
        return "MATCH (%s) AGAINST (%s IN BOOLEAN MODE)" % (lhs, rhs), params
説明

Lookupを登録する

作成したsearchDjangoに登録して使えるようにします。

demo/app/lookups.py

from django.db import models


@models.CharField.register_lookup
@models.TextField.register_lookup
class Search(models.Lookup):
    # ...

demo/app/apps.py

from django.apps import AppConfig


class AppConfig(AppConfig):
    # ...
    def ready(self):
        from . import lookups
説明
  • 公式のサンプルでは、親クラスのFieldに直接登録していますが、今回対象となるフィールドは文字列系のフィールドだけなので、CharFieldTextFieldにのみ登録します。
  • Lookupを登録する方法は、関数で登録する方式とdecorator方式がありますが、decorator方式の方が凝集度が高いので、後者を採用しました。

Lookupを使う

これで以下のようにDjango ORMでMySQL全文検索機能を使えるようになります。

novels = Novel.objects.filter(author_name="夏目漱石").filter(search_text__search="考えた")

【念の為】SQLインジェクション安全なことの確認

念の為、Custom Lookupsを使った場合はSQLに渡したパラメータがエスケープされていることを確認しておきます。
デモアプリの画面から以下のような文字列を入力してみます。
hoge' IN BOOLEAN MODE) or 1 = 1 --
パラメータがエスケープされていれば検索結果が0件になるはずです。

結果

検索結果は0件となり、Debug Toolbarより、発行されたSQLエスケープされていることが確認できました。

f:id:kamatimaru:20211212231037p:plain

f:id:kamatimaru:20211212231051p:plain

複数カラムで検索したいときの戦略を決める

実際のアプリケーションでは検索対象としたいカラムが一つだけことは少ないと思います。

今回のデモアプリでも小説(novel)を検索したいので、タイトル(title)と内容(content)で全文検索したいです。

このような場合の戦略について考えます。

前提

素のMySQLでは2つ以上のカラムを全文検索できる

前提として、素のMySQLでは、FULLTEXTインデックスが貼られていれば、以下のように2つ以上のカラムであっても全文検索できます。

CREATE FULLTEXT INDEX title_content_fulltext_search_index  ON novel (title, content) WITH PARSER ngram;
SELECT id, title FROM novel WHERE MATCH(title, content) AGAINST("テスト" IN BOOLEAN MODE);

注意点としては、FULLTEXTインデックスを貼ったカラムに対してMATCHで指定するカラムに過不足があるとエラーになります。

mysql> SELECT id, title FROM novel WHERE MATCH(content) AGAINST("テスト" IN BOOLEAN
ERROR 1191 (HY000): Can't find FULLTEXT index matching the column list

titlecontentに複合でFULLTEXTインデックスを貼っているのに、contentしか指定していないので、エラーになっている。

DjangoCustom Lookupsの制約

対して、DjangoCustom Lookupsでは、公式ドキュメントを読んだ限り以下のような複数のカラムを対象としたSQLは表現できなそうです。

SELECT id, title FROM novel WHERE MATCH(title, content) AGAINST("テスト" IN BOOLEAN MODE);

従って、DjangoCustom Lookupsを使いたいのであれば、このようなSQLは諦めます。

戦略

以上を踏まえた上で、以下の2つの戦略が考えられそうです。

  • 全文検索専用のカラムを定義して、そこに検索対象としたいカラムの値を全て突っ込む。
    • → Custom Lookupsを使う。
  • ORMで表現せずに生のSQLを書く。
全文検索専用のカラムを定義する

本記事およびデモアプリで採用している戦略です。
今まで出てきたsearch_textは、実は全文検索専用のフィールドとして想定していました。

実際にデモアプリでは、以下のようにDjango Adminから登録する際に、titlecontentを突っ込んでいます。

demo/app/admin.py

class NovelAdmin(admin.ModelAdmin):
    readonly_fields = ["search_text"]

    def save_model(self, request, obj, form, change):
        obj.search_text = f"{obj.title},{obj.content}"
        super().save_model(request, obj, form, change)

この戦略であれば、事実上、Django ORMでの複数フィールドの検索を実現できます。
※ 人によってはフレームワークの制約でカラムが増えるのが気になるかもしれません。

ちなみに、上記のサンプルコードのように「,(カンマ)」で連結すると、バイグラムの場合「,山」のようなゴミインデックスができるのではないか?と気持ち悪く感じる方もいるかもしれませんが、「,(カンマ)」はストップワード扱いされるのでこのようなインデックスは作成されません。(検証済み)

MySQL :: MySQL 8.0 リファレンスマニュアル :: 12.10.8 ngram 全文パーサー
→ 「ngram パーサーのストップワード処理」のセクションを参照

ORMで表現せずに生のSQLを書く。

もちろんSQLインジェクション対策はするという前提で、生のSQLを書くという選択肢もあるのかなと思います。

※ 私自身はDjangoで開発していて、生のSQLを書く必要に迫られたことがないということもあり、本記事では深掘りしません。

UnitTestにはTransactionTestCaseを使用する

最後にUnitTestについてです。

前提として、MySQL全文検索インデックスはコミット時に作成されます。
stackoverflow.com

これを踏まえた上で、django.test.TestCaseは1つのテストメソッドの実行中にコミットを行いません。

some database behaviors cannot be tested within a Django TestCase class. For instance, you cannot test that a block of code is executing within a transaction, as is required when using select_for_update(). In those cases, you should use TransactionTestCase.

従って、全文検索機能のテストではdjango.test.TransactionTestCaseを使用する必要があります。

TransactionTestCaseを使用すると、コミットする分テストの実行時間が遅くなるので、必要な箇所だけ使用するのがよいと思います。

これは成功する

from django.test import TransactionTestCase

from .factories import NovelFactory
from app.models import Novel


class TestSearchNovel(TransactionTestCase):
    def test_it(self):
        NovelFactory(
            title="草枕",
            search_text="山路を登りながら、こう考えた。",
        )
        novels = Novel.objects.filter(search_text__search="山路")
        self.assertEqual(len(novels), 1)
        self.assertEqual(novels[0].title, "草枕")

これは失敗する

from django.test import TestCase

from .factories import NovelFactory
from app.models import Novel


class TestSearchNovel(TestCase):
    def test_it(self):
        NovelFactory(
            title="草枕",
            search_text="山路を登りながら、こう考えた。",
        )
        novels = Novel.objects.filter(search_text__search="山路")
        self.assertEqual(len(novels), 1)
        self.assertEqual(novels[0].title, "草枕")
Traceback (most recent call last):
  File "/path/to/django_mysql_full-text-search_demo/demo/app/tests/test_queries.py", line 14, in test_it
    self.assertEqual(len(novels), 1)
AssertionError: 0 != 1

おわりに

記事は以上です。

読んでいただいた方ありがとうございます。

私自身が以前に趣味で開発したアプリでMySQL全文検索機能をDjangoで使おうとしたところ、WEB上の情報が断片的だったり古かったりして苦労したので、この機会にまとめてみました。