Gridsomeでイチからブログを作る - サイト内全文検索機能をつける

Gridsomeでブログを作るシリーズ。今回はサイト内検索機能を実装していきます。大きくわけてAlgoriaのような外部サービスを使う方法と自前で実装していく方法の2種類ありますが、今回は自前で実装していく方法にでやっていきます。

自前でやる? 外部サービス使う?

サイト内検索を一番簡単に組み込むには、Googleのカスタム検索をサイトに仕込む方法でしょう。これは単純にフォームを追加すればいいだけなので簡単に使うことができます。

次に外部サービスとしてサイト内検索をAPIとして提供する仕組みで有名なものでAlgoliaがあります。個人ブログ程度なら無料の範囲内におさまるでしょうし、gridsome-plugin-algoliaというプラグインもあるので簡単そうに思います。

そしてその他に自前でやる方法があり、選択肢はいくつか方法がありますが今回はflexsearch.jsという全文検索用のライブラリを使ってやってみることにしました。なお、こちらもgridsome-plugin-flexsearchというプラグインがあるんですが、上手く動作しなかったので自分で実装していきます、

アプローチ

flexsearchはアルファベットで表現できる言語の対応がメインであり、日本語のように単語の区切りが機械的に判断しづらい言語の対応はやや甘い部分があります。公式ではCJK Word Break (Chinese, Japanese, Korean)という章に案内があります。

やってみたところ大きな問題なく動作しました。が、面白みがないですし精度をもっと上げるために日本語形態素解析も一緒に組みこんで見ることにしました。

flexsearchでは、単語ごとに区切って配列にしたものをtokensとして持ち処理します。このtokenを形態素解析して必要なさそうなものを除外し、有効なものをまさしく単語区切りにして精度を上げるアプローチです。

形態素解析is何?

形態素解析(けいたいそかいせき、Morphological Analysis)とは、文法的な情報の注記の無い自然言語のテキストデータ(文)から、対象言語の文法や、辞書と呼ばれる単語の品詞等の情報にもとづき、形態素(Morpheme, おおまかにいえば、言語で意味を持つ最小単位)の列に分割し、それぞれの形態素の品詞等を判別する作業である。
形態素解析 - Wikipedia

早い話、自然にかかれた文章を良い感じに品詞ごとに区切るように解析するもの、という感じでしょうか。

いくつか有名なライブラリがありますが、今回はKuromojiのJS実装であるKuromoji.jsをJSのPromiseで扱かえるようにしたラッパーライブラリのazu/kuromojinを使います。なお、作者のazuさんは日本語のリントライブラリであるtextlintの開発者であり、このkuromojinもtextlintで使われているものです。

実装

まず

$ yarn add flexsearch kuromojin

とサクッと入れてしまいましょう。kuromoji.jsとその辞書はkuromojinに含まれるため特に意識する必要はありません。

また、Markdownで書いてある本文から装飾要素を削除したいのでremark用のプラグインremarkjs/strip-markdownも入れます。

$ yarn add strip-markdown

tokenize

形態素解析自体は動的に処理する必要がないので、gridsome.server.jsに本文を形態素解析した結果をkeywordとして各記の要素にします。

先に必要なコードを書くとこういう感じです。

const { tokenize } = require(`kuromojin`)
const remark = require(`remark`)
const strip = require(`strip-markdown`)

function markdownToText(text) {
  let result
  remark()
    .use(strip)
    .process(text, (err, file) => {
      if (err) throw err
      result = file.contents
    })
  return result
}

module.exports = function(api) {
  api.loadSource(({ getCollection, addSchemaResolvers }) => {
    const allPosts = getCollection(`Post`)
    addSchemaResolvers({
      Post: {
        keywords: {
          type: `[String]`,
          resolve(node) {
            const POS_LIST = [`名詞`, `動詞`, `形容詞`] // 対象品詞
            const IGNORE_WORDS = [`foo`] // 除外文字(完全一致)
            const IGNORE_REGEX = /^[!-/:-@[-`{-~-”’・]+$/ //半角記号のみ
            const MIN_LENGTH = 2 // 最低文字数
            const str = node.title + markdownToText(node.content)
            return tokenize(str).then(tokens => {
              const allTokens = tokens
                .filter(token => POS_LIST.includes(token.pos))
                .map(token => token.surface_form)
              return [...new Set(allTokens)]
                .filter(word => !IGNORE_WORDS.includes(word))
                .filter(word => !IGNORE_REGEX.test(word))
                .filter(word => word.length >= MIN_LENGTH)
            })
          },
        },
      },
    })
  })
}

前回使ったmarkdownToHTMLの応用でmakrdownToTextを定義して使っています。これは先程入れたstrip-markdownを使ってmarkdown → plain textとすることで不要な装飾などを省いた単純なテキストだけにしています。さらに除外文字や記号なども除いています。

POS_LISTの定数で指定しているのは、対象となる品詞をホワイトリスト形式で指定しています。例えば接続詞や助詞などは検索対象に含めたくないため、一般的に検索語句として用いられる名詞, 動詞, 形容詞を指定しています。

これでfrontmatterのkeywordsに本文から形態素解析した単語が配列として入るはずです。つまりGraphQL経由でattributeを指定でき、GridsomeのVue.js内で使えるようになったことを意味します。

検索ボックス

検索ボッスス用のコンポーネントを作り、その中でやらせるのが良さそうです。

<template lang="pug">
  .search_box
    input.search_query(type="text" v-model="searchTerm")
    ul.search_result
      g-link.result-link(v-for="result in searchResults" :key="result.id" :to="result.path")
        li.result--item
          .item--title {{ result.title }}
          .item--content {{ result.description }}
</template>

<static-query>
query Posts {
  posts: allPost {
    edges {
      node {
        id
        path
        title
        description
        keywords
      }
    }
  }
}
</static-query>

<script>
import Flexsearch from 'flexsearch'
export default {
  data() {
    return {
      searchTerm: ``,
      index: null,
    }
  },
  computed: {
    // 検索結果を返す算出プロパティ
    searchResults() {
      if (this.index === null) return []
      return this.index.search({
        query: this.searchTerm,
        limit: 5,
      })
    },
  },
  beforeMount() {
    this.index = new Flexsearch({
      tokenize: str => [...new Set(str)],
      doc: {
        id: `id`,
        field: [`title`, `keywords`],
      },
    })
    this.index.add(this.$static.posts.edges.map(e => e.node))
  },
}
</script>

こんな感じで、SearchBox.vueを作ってみました(スタイルの指定などの装飾要素は含めていません)。
ポイントは
1. GraphQL経由で(先程書いた形態素解析+フィルタした結果の)keywordsをとってきている
2. beforeMountflexsearchを使い、this.indexにつめている
3. searchTermを検索語句としてv-modelでバインドし、結果をcomputedでsearchResultとしてヒットした記事のデータを返している

v-modelでバインドしているため、問題がなければ文字に変更があるごとに検索結果が変わるインクリメンタルサーチの動作をします。

まとめと感想

今回は、Kuromojiを使って形態素解析で精度を上げたflexsearchを使った全文検索を自前で実装してみました。
多少改変してありますが、現在のこのブログでも使われており、実用的な動作とスピードを実現できています。

実は形態素解析をやってみたのは初めてだったんですが、精度は素晴しいですね。
また、flexsearchの動作の軽さも素晴しいですね。これらのライブラ作って公開してくれている方々に感謝の念しかないです。特に形態素解析はアイデア次第でもっといろいろ応用ができそうですね。

beforeMountをしていところがやや気になっていますが、代替案も今のところ思いつきませんのでひとまずこんな感じです。
なお、今回の実装にあたって先人のGridsomeで作成したブログにFlexsearch.jsで全文検索を導入する - Broadleavesの記事を大変参考にさせていただきました。

どんどんブログらしくなってきました。こうやってつけたい機能を自分で実装していく楽しさはブログサービスではなく開発可能なブログシステムならではですね。

Gridsomeでイチからブログを作る - 関連記事を表示する僕のReleaseNote[0.38.6] & 僕のRoadMap[0.38.7]