はじめに
概要
この記事はDjango Advent Calendar 2021の19日目の記事です。
本記事ではDjangoでMySQLの全文検索機能を使う方法について紹介します。
前提として、DjangoはMySQLの全文検索機能を標準ではサポートしていません。従って、いくつかのノウハウが必要になります。
※ Djangoの公式ドキュメントには「Full text search」という項目がありますが、中身を読むとこれはPostgreSQL専用の機能です。
docs.djangoproject.com
お断り
- 本記事の内容を実務に投入したことはないので、そういったナレッジは話せません。
役に立つ可能性のある場面
先にまとめ
やること・気をつけることは大きく以下の4つです。
- Migrationファイルに全文検索インデックス作成用のSQLコマンドを追加する。
- Custom Lookupsを作成する。
- 複数カラムで検索したいときの戦略を決める。
- UnitTestにはTransactionTestCaseを使用する。
以下のリポジトリにデモを公開しています。
github.com
DjangoがMySQLの全文検索機能を標準でサポートしてない経緯
DjangoはMySQLの全文検索機能を標準ではサポートしていないと冒頭で書きました。
厳密には、古いバージョンのDjangoではsearch
というMySQLの全文検索機能用のlookup構文が標準で備わっていたようです。
しかし、
- MySQL専用の機能である。
- 機能がかなり制限されている。
という理由で1.10のリリースノートの時点で、deprecated
が宣言されていました。
同リリースノートでは、代わりにCustom Lookupsを使うことが推奨されています。(以降で詳しく説明します。)
早速、やり方を説明していきます。
Migrationファイルに全文検索インデックス作成用のSQLコマンドを追加する
Modelを定義する
Modelの定義においては、全文検索インデックスを貼るフィールドだからといって、特別なことをする必要はありません。
以下のように普通にCharField
やTextField
で宣言します。
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", ), ]
説明
WITH PARSER ngram
の部分は、他にもデフォルトやmecab
の指定が可能ですが、今回はngramにしています。- パーサーにngamを指定した場合、MySQLの仕様によりデフォルトはn=2、すなわちバイグラムになります。
RunSQL
の第二引数のDROP INDEX...
はなくてもインデックスの作成はできますが、Migrationロールバック時に以下のようなエラーが発生するので必要です。
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を登録する
作成したsearch
をDjangoに登録して使えるようにします。
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
に直接登録していますが、今回対象となるフィールドは文字列系のフィールドだけなので、CharField
とTextField
にのみ登録します。 - Lookupを登録する方法は、関数で登録する方式とdecorator方式がありますが、decorator方式の方が凝集度が高いので、後者を採用しました。
複数カラムで検索したいときの戦略を決める
実際のアプリケーションでは検索対象としたいカラムが一つだけことは少ないと思います。
今回のデモアプリでも小説(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
→ title
とcontent
に複合でFULLTEXTインデックスを貼っているのに、content
しか指定していないので、エラーになっている。
戦略
以上を踏まえた上で、以下の2つの戦略が考えられそうです。
全文検索専用のカラムを定義する
本記事およびデモアプリで採用している戦略です。
今まで出てきたsearch_text
は、実は全文検索専用のフィールドとして想定していました。
実際にデモアプリでは、以下のようにDjango Adminから登録する際に、title
とcontent
を突っ込んでいます。
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上の情報が断片的だったり古かったりして苦労したので、この機会にまとめてみました。
参考文献
以下を参考にさせて頂きました。
ありがとうございます。
オンライン記事
- MySQL5.7の全文検索を試してみた | GMOインターネットグループ 次世代システム研究室
- 日々の覚書: MySQL 5.6 InnoDB FTS関連のinformation_schemaを覗くにはinnodb_ft_aux_tableを指定する
- DjangoでCustom Lookupsを使う. hokanのエンジニアのショウタです。 今回はDjangoのCustom… | by hokan公式アカウント | Medium
- Django 1.7 で、マイグレーションファイルでフルテキストインデックスを作成する | ytyng.com
- B'zを検索できるようになるまで - Qiita
- mysql - Django unittesting fulltext search - Stack Overflow