Django+Tritonnで日本語全文検索〜重み付けもあるよ〜

DBにTritonnを使ってDjangoから全文検索をする という話

Tritonnってなに?どうやって入れんの

かつあい

モデル構造

class Book(models.Model):
    """本"""
    title = models.CharField(max_length=255)
    desc = models.TextField()
    pub_date = models.DateTimeField(auto_now_add=True)
    
class BookSearch(models.Model):
    """本の検索テーブル"""
    book = models.OneToOneField(Book)
    title = models.CharField(max_length=255)
    desc = models.TextField()

全文検索に使いたいカラムを持った〜〜Searchモデルみたいなのを定義。

OneToOneとして元オブジェクトにリレーションを張る。

下準備

当然このままだとsyncdbしても(多くは)InnoDBだわFulltext index張られてないわなので、BookSearchモデルを定義したアプリのフォルダにsql/BookSearch.sqlを書いてテーブル定義をむりくり書き換える。

DROP TABLE `book_search`;
CREATE TABLE `book_search` (
  にゃんにゃん,
  FULLTEXT KEY `search` USING MECAB, NORMALIZE, SECTIONALIZE, 256 (`title`, `desc`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8;

manage.py sqlで出したテーブル定義をまるコピしつつ既存テーブルをDROPしてfulltext keyとEngine=MyISAMを付けて再定義する感じ。

シグナル定義

BookのsaveにフックしてBookSearchも作らないと検索出来ないですよね。
なのでシグナル書く。

def book_search_signal(sender, instance, **kwargs):
    try:
        bs = BookSearch.objects.get(book=instance)
    except:
        bs = BookSearch()

    bs.title = instance.title
    bs.desc = instance.desc
    
    bs.save()

models.signals.post_save.connect(book_search_signal, sender=Book)

検索メソッド

パフォーマンスを気にするのであればBookSearchからQuerySetを作るべきだけど、そうすると最終的に出てくるのがBookSearchオブジェクトのリストになってしまうのでとりまわしが面倒。
取り回しを重視するのであればBookから作ってBookオブジェクトのリストを得ると楽でよいです。

今回は後者で。

class BookManager(models.Manager):

    にゃんにゃん

    def fulltext(self, keywords):
        match = u"""MATCH (
                        `book_search`.`title`,
                        `book_search`.`desc`,
                        ) AGAINST (
                        %s IN BOOLEAN MODE
                    )"""

        param = "*W1:3,2:1 %s" %  u" ".join([u"+" + x for x in keywords])

        query = self.be()\
                    .extra(
                    select=SortedDict([
                        ("fulltext_score", match),
                    ]),
                    select_params=(
                        param,
                    ),
                    tables=[
                        "`book_search`"
                    ],
                    where=[
                        match,
                        """
                        `book_search`.`book_id` = `book`.`id`
                        """
                    ],
                    params=[
                        param,
                    ],
                ).order_by("-fulltext_score")
        return query

extraでがんばって最終的に重み付け検索+fulltext_scoreでソートしたquerysetを得る。

SELECTカラムにfulltext_scoreなるカラムをつける辺りが結構複雑なので、MySQL側のクエリログを見ながら試行錯誤する必要あり。*1

使う

こんな感じに使える。

Book.objects.fulltext(["ぬる", "ぽ"]).filter(pub_date__gte=datetime.now()-timedelta(days=7))

過去一週間以内に発売された、"ぬる"か"ぽ"をタイトルか紹介文に含むBookオブジェクト。

おわり

わりと富豪的プログラミングだと思うので、お使いの際はデータ量等々とご相談の上どうぞ。

*1:queryset.query.as_sql()は信用ならないです。