ヽ(´・肉・`)ノログ

How do we fighting without fighting?

PhoenixFrameworkでPostgreSQLとPGroongaを使って日本語全文検索を実現する方法

Ruby on RailsでPostgreSQLとPGroongaを使って日本語全文検索を実現する方法 - ククログ(2015-11-09) を参考に, PhoenixFrameworkでPostgreSQLとPGroongaを使って日本語全文検索を実現する方法を書いた.

OSはOSX10.10.5(Yosemite),パッケージ管理に Homebrew を利用している.

PostgreSQLとPGroongaのインストール

PostgreSQLとPGroongaをインストールし,起動しておく. (これ以降は別のコンソールを立ち上げて操作する)

もしログイン時にバックグラウンドでPostgreSQLを起動しておきたいなら brew info postgresql にやり方が書いてある.

$ brew install pgroonga
$ postgres -D /usr/local/var/postgres

Elixirのインストール

$ brew install elixir

PhoenixFrameworkのインストール

node.jsも必要なので一緒に入れておく

$ brew install nodejs
$ mix archive.install https://github.com/phoenixframework/phoenix/releases/download/v1.0.3/phoenix_new-1.0.3.ez

ドキュメント検索システムの開発

mix new で雛形を作る.

$ mix phoenix.new document_search --database postgres
$ cd document_search

document_searchがPostgreSQLへ接続するユーザー名を設定する. document_search/config/dev.exs の =username: “postgres”,= を =username: System.get_env(“USER”),= へと書き換える.

diff --git a/config/dev.exs b/config/dev.exs
index b2fa07a..43acfdb 100644
--- a/config/dev.exs
+++ b/config/dev.exs
@@ -35,7 +35,7 @@ config :phoenix, :stacktrace_depth, 20
 # Configure your database
 config :document_search, DocumentSearch.Repo,
   adapter: Ecto.Adapters.Postgres,
-  username: "postgres",
+  username: System.get_env("USER"),
   password: "postgres",
   database: "document_search_dev",
   hostname: "localhost",

document_searchがPostgreSQLへ接続するユーザー名を設定したので,データベースを作成する.

$ mix ecto.create

ここまでは(ほぼ)PGroongaと関係なく,アプリケーションがPostgreSQLを使う場合にはよくある手順だ.

ここからPGroongaを使う場合に特有の手順になる.

まず,データベースでPGroongaを使えるようにする.

マイグレーションファイルを作成する.

$ mix ecto.gen.migration enable_pgroonga
* creating priv/repo/migrations
* creating priv/repo/migrations/20151112034251_enable_pgroonga.exs

priv/repo/migrations/20151112034251_enable_pgroonga.exs次のような内容にする. search_pathを設定しているのはPGroongaが提供している演算子を pg_catalog にある組み込みの演算子よりも優先的に使うためだ.

defmodule DocumentSearch.Repo.Migrations.EnablePgroonga do
  use Ecto.Migration

  def up do
    execute "CREATE EXTENSION pgroonga;"
    execute """
    ALTER DATABASE #{current_database}
      SET search_path = '$user', public, pgroonga, pg_catalog;
    """
  end

  def down do
    execute "ALTER DATABASE #{current_database} RESET search_path;"
    execute "DROP EXTENSION pgroonga CASCADE;"
  end

  defp current_database, do: Application.get_env(:document_search, DocumentSearch.Repo)[:database]
end

続いて検索対象のドキュメントを格納するテーブルを作成する.

$ mix phoenix.gen.html Document documents title:text content:text

ラウティング(routing)は自分で足さなければならない(mix phoenix.gen.html したときに表示されるメッセージにも書いてある) これを足しておかないとDocumentControllerのコンパイル時にエラーになる.

diff --git a/web/router.ex b/web/router.ex
index cd80f61..2648b4b 100644
--- a/web/router.ex
+++ b/web/router.ex
@@ -17,6 +17,7 @@ defmodule DocumentSearch.Router do
     pipe_through :browser # Use the default browser stack

     get "/", PageController, :index
+    resources "/documents", DocumentController
   end

   # Other scopes may use custom stacks.

全文検索用のインデックスを作成する.

$ mix ecto.gen.migration add_full_text_search_index_to_documents
* creating priv/repo/migrations
* creating priv/repo/migrations/20151112034948_add_full_text_search_index_to_documents.exs

priv/repo/migrations/20151112034948_add_full_text_search_index_to_documents.exs次のような内容にする. ここで using: “pgroonga” を指定してインデックスを追加することがポイントだ.

defmodule DocumentSearch.Repo.Migrations.AddFullTextSearchIndexToDocuments do
  use Ecto.Migration

  def change do
    index(:documents, [:content], using: "pgroonga")
  end
end

このマイグレーションファイルを反映する.

$ mix ecto.migrate

PostgreSQL側の準備はできたのでアプリケーション側に全文検索機能を実装する.

モデルに全文検索用の関数を定義する.PGroongaでは@@演算子で全文検索をする. この演算子を使うと「 キーワード1 OR キーワード2 」のようにORを使ったクエリーを指定できる.

diff --git a/web/models/document.ex b/web/models/document.ex
index f62ab9f..d3f37e1 100644
--- a/web/models/document.ex
+++ b/web/models/document.ex
@@ -21,4 +21,9 @@ defmodule DocumentSearch.Document do
     model
     |> cast(params, @required_fields, @optional_fields)
   end
+
+  def full_text_search(base_query, searching_query) do
+    from d in base_query,
+    where: fragment("content @@ ?", ^searching_query)
+  end
 end

ビューにヒット件数表示機能と検索フォームをつける. 検索フォームではqueryというパラメーターに検索クエリーを指定することにする.

diff --git a/web/templates/document/index.html.eex b/web/templates/document/index.html.eex
index 2270378..5d7a07f 100644
--- a/web/templates/document/index.html.eex
+++ b/web/templates/document/index.html.eex
@@ -1,5 +1,15 @@
 <h2>Listing documents</h2>

+<p><%= length(@documents) %> records</p>
+
+<%= form_for @conn, document_path(@conn, :index), [as: :document, method: :get], fn f -> %>
+  <div class="form-group">
+    <%= label f, :query, "query", class: "control-label" %>
+    <%= text_input f, :query, class: "form-control" %>
+  </div>
+  <%= submit "Submit" %>
+<% end %>
+
 <table class="table">
   <thead>
     <tr>

最後に,コントローラーで全文検索を使うようにする

diff --git a/web/controllers/document_controller.ex b/web/controllers/document_controller.ex
index ecbee46..a956d11 100644
--- a/web/controllers/document_controller.ex
+++ b/web/controllers/document_controller.ex
@@ -5,6 +5,11 @@ defmodule DocumentSearch.DocumentController do

   plug :scrub_params, "document" when action in [:create, :update]

+  def index(conn, %{"document" => %{"query" => query}}) do
+    documents = Repo.all(Document.full_text_search(Document, query))
+    render(conn, "index.html", documents: documents)
+  end
+
   def index(conn, _params) do
     documents = Repo.all(Document)
     render(conn, "index.html", documents: documents)

これで日本語全文検索機能は実現できる.

ここからは,動作を確認するためにQiitaから検索対象のドキュメントを取得するMixタスクを作る

ElixirでHTTPアクセスするには外部ライブラリのHTTPoisonを利用すると簡単なので,mix.exsの依存関係へと追加する.

diff --git a/mix.exs b/mix.exs
index 462b3c4..5e6febc 100644
--- a/mix.exs
+++ b/mix.exs
@@ -35,7 +35,8 @@ defmodule DocumentSearch.Mixfile do
      {:postgrex, ">= 0.0.0"},
      {:phoenix_html, "~> 2.1"},
      {:phoenix_live_reload, "~> 1.0", only: :dev},
-     {:cowboy, "~> 1.0"}]
+     {:cowboy, "~> 1.0"},
+     {:httpoison, "~> 0.8", only: :dev}]
   end

依存関係を追加したら,パッケージを取得する.

$ mix deps.get

準備ができたのでMixタスクを作成する.

lib/mix/tasks/document_search/data/load/qiita.ex

defmodule Mix.Tasks.DocumentSearch.Data.Load.Qiita do
  use Mix.Task

  @shortdoc "Load data from Qiita"
  def run(_args) do
    HTTPoison.start
    DocumentSearch.Repo.start_link

    tag = "groonga"
    url = "https://qiita.com/api/v2/items?page=1&per_page=100&query=tag:#{tag}"
    %HTTPoison.Response{body: body} = HTTPoison.get!(url)
    Poison.Parser.parse!(body)
    |> Enum.map(fn entry ->
      params = %{title: entry["title"], content: entry["body"]}
      DocumentSearch.Document.changeset(%DocumentSearch.Document{}, params)
    end)
    |> Enum.each(&DocumentSearch.Repo.insert!/1)
  end
end

Mixタスクができたら,コンパイルする.コンパイルしないとmix helpへタスクとして出てこないので注意すること.

コンパイルが完了したら,実行して検索対象のドキュメントを作成する.

$ mix compile
$ mix document_search.data.load.qiita

サーバーを起動する.

$ mix phoenix.server

http://localhost:4000/documents にアクセスすると,ドキュメントは100件ある.

./empty_query.png

フォームに「オブジェクト」と日本語のクエリーを入力すると, 「オブジェクト」で絞り込んで16件になっている.日本語で全文検索ができている.

./object_query.png

次のようにOR検索もできる.「オブジェクト」単体で検索したときの16件よりも件数が増えているのでORが効いていることがわかる.

./object_or_api_query.png

まとめ

PotgreSQLとPGroonga(ぴーじーるんが)を使ってPhoenixFrameworkで日本語全文検索機能を実現する方法を説明した.

ポイントは次の通り.

PhoenixFrameworkからも簡単に日本語全文検索が利用できることがわかった.

告知

この記事でお世話になったPGroongaやGroongaのイベントが2015/11/29にある.興味がでてきた人はイベントページから申し込むとよい.

発表内容から有益な情報を得られるし,開発者に直接質問することもできるそうだ.

Groonga Meatup 2015 - Groonga | Doorkeeper