株式会社クリアコード > ククログ

ククログ

タグ:

Groonga Meatup 2017:Groonga族2016 #groonga

2017年2月9日(年に一度の肉の日!)にGroonga Meatup 2017が開催されました。ここでGroongaMroongaPGroongaの2016一年間での進化の様子を紹介しました。最近のGroonga・Mroonga・PGroongaの情報をざっと把握できるのでご活用ください。

関連リンク:

内容

2016年はGroonga・PGroongaの改良が多く、Mroongaの改良は少なめでした。ただ、MroongaはGroongaを使っているので、Groongaの改良(の多く)はそのままMroongaでも利用できます。そのため、Groonga・PGroongaだけでなくMroongaも進化しています。

Groongaの大きな改良はこのあたりです。

Mroongaの大きな改良はこのあたりです。

  • FOREIGN KEY制約をサポート
  • マルチカラムインデックスの更新性能劣化を解消
  • *SSプラグマを追加(MATCH AGAINSTないでGroongaの検索条件を使える。Groongaの検索条件では複数インデックスを使えるのでMySQLで検索するよりも圧倒的に速くなる。)

PGroongaの大きな改良はこのあたりです。

  • ストリーミングレプリケーションをサポート
  • 検索の高速化(PostgreSQLがより適切な実行計画を選べるように、PostgreSQLにより精度の高い情報を提供するようになった)
  • Zstandardによるカラム圧縮をサポート
  • 各種便利演算子を追加(類似文書検索演算子・前方一致検索演算子・前方一致RK検索演算子)

他にもいろいろあるのでぜひスライドも確認してください。

まとめ

Groonga Meatup 2017で2016年のGroonga族の様子を紹介しました。

なお、今回のGroonga Meatup 2017は東京で開催しましたが、今月、名古屋と大阪でも開催されるので近辺の方はぜひお越しください!

他の発表については以下を参照してください。

おまけみたいな書き方になってしまいますが、同日、初心者向けのMroongaの電子書籍Groongaではじめる全文検索がリリースされました!初心者の人がくじけずに始められるようにできるだけ短い内容(約30ページ!)で動くものが作れる、という内容になっています。具体的にはPHPでMroongaを使ってPDF検索システムを作っています。全文検索をしたいけどどこから始めればいいかピンときていない…という方はまずはこれを読んでみてください。

タグ: Groonga
2017-02-09

Ruby on RailsでGroongaを使って日本語全文検索を実現する方法

MySQL・PostgreSQL・SQLite3の標準機能では日本語テキストの全文検索に難があります。MySQL・PostgreSQLに高速・高機能な日本語全文検索機能を追加するMroongaPGroongaというプラグインがあります。これらを導入することによりSQLで高速・高機能な日本語全文検索機能を実現できます。詳細は以下を参照してください。

また、データはMySQL・PostgreSQL・SQLite3に保存して日本語全文検索機能は別途全文検索エンジンGroongaサーバーに任せるという方法もあります。詳細は以下を参照してください。

ここではMySQL・PostgreSQL・SQLite3を一切使わずに、データもGroongaに保存して日本語全文検索を実現する方法を紹介します。

Groongaにデータも保存して使うメリットは以下の通りです。

  • Groongaのフル機能を使える
  • 検索とデータの取得を一度にできるので速い

一方、デメリットは以下の通りです。

  • MySQL・PostgreSQL・SQLite3を使うだけの場合と比べて学習コストが高い(最初にGroongaのことを覚えないといけない)
  • マスターデータを別途安全に管理する必要がある(Groongaにはトランザクション・クラッシュリカバリー機能がないため)

このデメリットのうち学習コストの方をできるだけ抑えつつGroongaを使えるようにするためのライブラリーがあります。それがgroonga-client-modelです。groonga-client-modelがGroongaを使う部分の多くをフォローしてくれるため利用者は学習コストを抑えたままGroongaを使って高速な日本語全文検索システムを実現できます。

この記事ではRuby on Railsで作ったアプリケーションからGroongaを使って日本語全文検索機能を実現する方法を説明します。実際にドキュメント検索システムを開発する手順を示すことで説明します。ここではCentOS 7を用いますが、他の環境でも同様の手順で実現できます。

Groongaのインストール

まずGroongaをインストールします。CentOS 7以外の場合にどうすればよいかはGroongaのインストールドキュメントを参照してください。

% sudo -H yum install -y http://packages.groonga.org/centos/groonga-release-1.2.0-1.noarch.rpm
% sudo -H yum install -y groonga-httpd
% sudo -H systemctl start groonga-httpd

Rubyのインストール

CentOS 7にはRuby 2.0のパッケージがありますが、Ruby on Rails 5.0.1はRuby 2.2以降が必要なのでrbenvとruby-buildでRuby 2.4をインストールします。

% sudo -H yum install -y git
% git clone https://github.com/sstephenson/rbenv.git ~/.rbenv
% git clone https://github.com/sstephenson/ruby-build.git ~/.rbenv/plugins/ruby-build
% echo 'export PATH="$HOME/.rbenv/bin:$PATH"' >> ~/.bash_profile
% echo 'eval "$(rbenv init -)"' >> ~/.bash_profile
% exec ${SHELL} --login
% sudo -H yum install -y gcc make patch openssl-devel readline-devel zlib-devel
% rbenv install 2.4.0
% rbenv global 2.4.0

Ruby on Railsのインストール

Ruby on Railsをインストールします。

% sudo -H yum install -y sqlite-devel nodejs
% gem install rails

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

いよいよ日本語全文検索機能を持ったドキュメント検索システムを開発します。

まずはrails newで雛形を作ります。Active Recordを一切使わないので--skip-active-recordを指定しています。

% rails new document_search --skip-active-record
% cd document_search

Gemfileにgroonga-client-model gemを追加します。

gem 'groonga-client-model'

groonga-client-model gemをインストールします。

% bundle install

検索対象のドキュメントを格納するテーブルとそれを高速に検索するためのインデックスを定義します。定義はdb/schema.grnにGroongaのコマンドの書式で書きます。参考になるドキュメントは後で示すのでまずは実際の定義を確認しましょう。

db/schema.grn:

# ドキュメントを格納するテーブル。キーなし。
table_create \
  --name documents \
  --flags TABLE_NO_KEY
# ドキュメントのタイトルを格納するカラム。
column_create \
  --table documents \
  --name title \
  --flags COLUMN_SCALAR \
  --type ShortText
# ドキュメントの内容を格納するカラム。
column_create \
  --table documents \
  --name content \
  --flags COLUMN_SCALAR \
  --type Text

# 全文検索インデックス用のテーブル。
table_create \
  --name terms \
  --flags TABLE_PAT_KEY \
  --key_type ShortText \
  --normalizer NormalizerAuto \
  --default_tokenizer TokenBigram
# ドキュメントのタイトルと内容を全文検索するためのインデックス。
# Groongaではインデックスはカラムの一種。
column_create \
  --table terms \
  --name documents_index \
  --flags COLUMN_INDEX|WITH_POSITION|WITH_SECTION \
  --type documents \
  --source title,content

以下は参考になるドキュメントです。

作成したテーブル・インデックス定義はgroonga:schema:loadタスクでGroongaに取り込めます。

% bin/rails groonga:schema:load

これでGroongaに検索対象のドキュメントを格納するテーブルができたので対応するモデルを作ります

% bin/rails generate scaffold document title:text content:text

これでDocumentクラスがapp/models/document.rbに生成されます。DocumentオブジェクトはActive RecordのようなAPIを提供するのでActive Recordと同じような感じで使えます。

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

lib/tasks/data.rake:

require "open-uri"
require "json"

namespace :data do
  namespace :load do
    desc "Load data from Qiita"
    task :qiita => :environment do
      tag = "groonga"
      url = "https://qiita.com/api/v2/items?page=1&per_page=100&query=tag:#{tag}"
      open(url) do |entries_json|
        entries = JSON.parse(entries_json.read)
        entries.each do |entry|
          Document.create(title:   entry["title"],
                          content: entry["body"])
        end
      end
    end
  end
end

実行して検索対象のドキュメントを作ります。

% bin/rails data:load:qiita

http://localhost:3000/documentsにアクセスし、データが入っていることを確認します。

Qiitaのデータをロード

ビューにヒット件数表示機能と検索フォームをつけてコントローラーで全文検索するようにします。

検索フォームではqueryというパラメーターに検索クエリーを指定することにします。

@documents@requestに変更してビューで@request.responseとしているのは、コントローラーの時点ではまだGroongaにリクエストを発行せず、ビューで必要になった時点で発行するためです。(Active Recordも同じことをやっていますが、Active Recordはto_aが必要になった時点で暗黙的に行っているのでユーザーが気にすることはありません。groonga-client-modelも同じようにすることができるのですが…長くなるので別の機会に説明します。)

@request.responseとしている理由はもう1つあります。groonga-client-modelとActive Recordで検索結果が違うからです。Active Recordはヒットしたモデルの配列を返しますが、groonga-client-modelはそれだけではなくさらに追加の情報も返します。たとえば、「ヒット数」(@request.response.n_hits)も持っています。SQLでは別途SELECT COUNT(*)を実行しないといけませんが、Groongaでは1回の検索で検索結果もヒット数も両方取得できるので効率的です。

app/views/documents/index.html.erb:


 <h1>Documents</h1>

+<p><%= @request.response.n_hits %> records</p>
+
+<%= form_tag(documents_path, method: "get") do %>
+  <%= search_field_tag "query", @query %>
+  <%= submit_tag "Search" %>
+<% end %>
+
 <table>
   <thead>
     <tr>
@@ -12,7 +19,8 @@
   </thead>

   <tbody>
-    <% @documents.each do |document| %>
+    <% @request.response.records.each do |document| %>
       <tr>
         <td><%= document.title %></td>
         <td><%= document.content %></td>

app/controllers/documents_controller.rb:

@@ -4,7 +4,11 @@ class DocumentsController < ApplicationController
   # GET /documents
   # GET /documents.json
   def index
-    @documents = Document.all
+    @query = params[:query]
+    @request = Document.select.
+      query(@query)
   end

   # GET /documents/1

この状態で次のようにレコード数とフォームが表示されるようになります。

フォームを追加

また、この状態で日本語全文検索機能を実現できています。確認してみましょう。

フォームに「オブジェクト」と日本語のクエリーを入力します。元のドキュメントは100件あり、「オブジェクト」で絞り込んで4件になっています。日本語で全文検索できていますね。

「オブジェクト」で検索

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

「オブジェクト OR API」で検索

全文検索エンジンならではの機能を利用

これで基本的な全文検索機能は実現できていますが、せっかく全文検索エンジンを直接使って検索しているので全文検索エンジンならではの機能も使ってみましょう。

ドリルダウン

まずはドリルダウン機能を使います。ドリルダウンとはある軸に注目して情報を絞り込んでいくことです。例えば、商品カテゴリーに注目して商品を絞り込む(例:家電→洗濯機→ドラム式)、タグに注目して記事を絞り込むといった具合です。

まずは各ドキュメントにタグを付けられるようにしましょう。

タグ用のテーブルを作成し、ドキュメント用のテーブルからそのテーブルを参照するようにします。RDBMSと違い、Groongaは直接他のテーブルを参照する機能があります。

db/schema.grnに以下を追加します。

db/schema.grn:

# タグを格納するテーブル。正規化したタグ名がキー。
table_create \
  --name tags \
  --flags TABLE_HASH_KEY \
  --key_type ShortText \
  --normalizer NormalizerAuto
# 表示用のタグ名。たとえば、タグのキーは「rails」でラベルは「Rails」にする。
column_create \
  --table tags \
  --name label \
  --flags COLUMN_SCALAR \
  --type ShortText

# ドキュメントテーブルにタグテーブルを参照するカラムを追加。
# タグは複数設定できる。
column_create \
  --table documents \
  --name tags \
  --flags COLUMN_VECTOR \
  --type tags

# タグ検索を高速にするためのインデックスカラム。
column_create \
  --table tags \
  --name documents_tags \
  --flags COLUMN_INDEX \
  --type documents \
  --source tags

以下は参考になるドキュメントです。

更新したスキーマをロードします。

% bin/rails groonga:schema:load

タグを作ります

% bin/rails generate scaffold tag _key:string label:string

Qiitaのデータからタグ情報もロードするようにします。Tagを毎回createして大丈夫なのかと思うかもしれませんが、大丈夫です。groonga-client-modelはレコード保存にGroongaのloadコマンドを使っています。このloadコマンドの挙動はupsert(すでに同じキーのレコードがなかったら追加、あったら上書き)なのです。

lib/tasks/data.rake:

@@ -10,8 +10,12 @@ namespace :data do
       open(url) do |entries_json|
         entries = JSON.parse(entries_json.read)
         entries.each do |entry|
+          tags = entry["tags"].collect do |tag|
+            tag_name = tag["name"]
+            Tag.create(_key: tag_name, label: tag_name)
+          end
           Document.create(title:   entry["title"],
-                          content: entry["body"])
+                          content: entry["body"],
+                          tags:    tags)
         end
       end
     end

データベース内のデータを削除してQiitaのロードし直します。

% bin/rails runner 'Document.all.each(&:destroy)'
% bin/rails data:load:qiita

ビューにタグ情報も表示します。コントローラーでoutput_columnsを指定しているのは(参照先の)タグテーブルのラベルカラムも取得するためです。デフォルトではタグテーブルのキーしか取得しないので明示的に指定しています。

app/controllers/documents_controller.rb:

@@ -6,6 +6,7 @@ class DocumentsController < ApplicationController
   def index
     @query = params[:query]
     @request = Document.select.
+      output_columns(["_id", "_key", "*", "tags.label"]).
       query(@query)
   end

app/views/documents/index.html.erb:

@@ -14,6 +14,7 @@
     <tr>
       <th>Title</th>
       <th>Content</th>
+      <th>Tags</th>
       <th colspan="3"></th>
     </tr>
   </thead>
@@ -24,6 +25,13 @@
       <tr>
         <td><%= document.title %></td>
         <td><%= document.content %></td>
+        <td>
+          <ul>
+          <% document.tags.each do |tag| %>
+            <li><%= tag.label %></li>
+          <% end %>
+          </ul>
+        </td>
         <td><%= link_to 'Show', document %></td>
         <td><%= link_to 'Edit', edit_document_path(document) %></td>
         <td><%= link_to 'Destroy', document, method: :delete, data: { confirm: 'Are you sure?' } %></td>

「Tags」カラムにタグがあるのでタグがロードされていることを確認できます。

タグがロードされている

実はすでにタグで高速に検索できるようにもなっています。フォームに「tags:@全文検索」と入力すると「全文検索」タグで絞り込めます。(tags:@...は「tagsカラムの値を検索する」というGroongaの構文です。Googleのsite:...に似せた構文です。)

「全文検索」タグで検索

それではこのタグ情報を使ってドリルダウンできるようにします。

ユーザーにとっては、タグをキーボードから入力して絞り込む(ドリルダウンする)のは面倒なので、クリックでドリルダウンできるようにします

コントローラーには次の2つの処理を追加しています。

  • クエリーパラメーターとしてtagが指定されていたらfilter("tags @ %{tag}", tag: tag)でタグ検索をする条件を追加する。
  • タグでドリルダウンするための情報(どのタグ名で絞りこめるのか、また、絞り込んだらどのくらいの件数になるのか、という情報)を取得する

「タグでドリルダウンするための情報を取得する」とはSQLでいうと「GROUP BY tagの結果も取得する」という処理になります。SQLではGROUP BYの結果も取得すると追加でSQLを実行しないといけませんが、Groongaでは1回のクエリーで検索もヒット数の取得もドリルダウン用の情報も取得できるので効率的です。

app/controllers/documents_controller.rb:

@@ -5,9 +5,18 @@ class DocumentsController < ApplicationController
   # GET /documents.json
   def index
     @query = params[:query]
-    @request = Document.select.
+    @tag = params[:tag]
+
+    request = Document.select.
       output_columns(["_id", "_key", "*", "tags.label"]).
       query(@query)
+    if @tag.present?
+      request = request.filter("tags @ %{tag}", tag: @tag)
+    end
+    @request = request.
+      drilldowns("tag").keys("tags").
+      drilldowns("tag").sort_keys("-_nsubrecs").
+      drilldowns("tag").output_columns(["_key", "_nsubrecs", "label"])
   end

ビューではクリックでドリルダウンできる(タグで絞り込める)ようにリンクを表示します。

app/views/documents/index.html.erb:

@@ -5,10 +5,21 @@
 <p><%= @request.response.n_hits %> records</p>

 <%= form_tag(documents_path, method: "get") do %>
+  <%= hidden_field_tag "tag", @tag %>
   <%= search_field_tag "query", @query %>
   <%= submit_tag "Search" %>
 <% end %>

+<nav>
+  <% @request.response.drilldowns["tag"].records.each do |tag| %>
+  <%= link_to_unless @tag == tag._key,
+                     "#{tag.label} (#{tag._nsubrecs})",
+                     url_for(query: @query, tag: tag._key) %>
+  <% end %>
+  <%= link_to "タグ絞り込み解除",
+              url_for(query: @query) %>
+</nav>
+
 <table>
   <thead>
     <tr>
@@ -27,7 +38,9 @@
         <td>
           <ul>
           <% document.tags.each do |tag| %>
-            <li><%= tag.label %></li>
+            <li><%= link_to_unless @tag == tag._key,
+                                   tag.label,
+                                   url_for(query: @query, tag: tag._key) %></li>
           <% end %>
           </ul>
         </td>

これで次のような画面になります。「全文検索 (20)」というリンクがあるので、「全文検索」タグでドリルダウンすると「20件」ヒットすることがわかります。

タグでドリルダウンできる

「全文検索 (20)」のリンクをクリックすると「全文検索」タグでドリルダウンできます。たしかに20件ヒットしています。

「全文検索」タグでドリルダウン

ここからさらにキーワードで絞り込むこともできます。以下はさらに「ruby」で絞り込んだ結果です。ヒット数がさらに減って3件になっています。

「全文検索」タグでドリルダウンして「ruby」で全文検索

全文検索エンジンの機能を使うと簡単・高速にドリルダウンできるようになります。

キーワードハイライト

検索結果を確認しているとき、キーワードがどこに含まれているかがパッとわかると目的のドキュメントかどうかを判断しやすくなります。そのための機能も全文検索エンジンならではの機能です。

highlight_html()を使うとキーワードを<span class="keyword">...</span>で囲んだ結果を取得できます。

snippet_html()を使うとキーワード周辺のテキストを取得できます。

これらを使ってキーワードをハイライトするには次のようにします。

app/controllers/documents_controller.rb:

@@ -8,7 +8,14 @@ class DocumentsController < ApplicationController
     @tag = params[:tag]

     request = Document.select.
-      output_columns(["_id", "_key", "*", "tags.label"]).
+      output_columns([
+                       "_id",
+                       "_key",
+                       "*",
+                       "tags.label",
+                       "highlight_html(title)",
+                       "snippet_html(content)",
+                     ]).
       query(@query)
     if @tag.present?
       request = request.filter("tags @ %{tag}", tag: @tag)

app/views/documents/index.html.erb:

@@ -33,8 +33,16 @@
   <tbody>
     <% @request.response.records.each do |document| %>
       <tr>
-        <td><%= document.title %></td>
-        <td><%= document.content %></td>
+        <td><%= document.highlight_html.html_safe %></td>
+        <td>
+          <% if document.snippet_html.present? %>
+            <% document.snippet_html.each do |chunk| %>
+              <div>...<%= chunk.html_safe %>...</div>
+            <% end %>
+          <% else %>
+            <%= document.content %>
+          <% end %>
+        </td>
         <td>
           <ul>
           <% document.tags.each do |tag| %>

app/assets/stylesheets/documents.scss:

@@ -1,3 +1,7 @@
 // Place all the styles related to the documents controller here.
 // They will automatically be included in application.css.
 // You can use Sass (SCSS) here: http://sass-lang.com/
+
+.keyword {
+  color: red;
+}

「全文検索」タグでドリルダウンして「ruby」で全文検索した状態では次のようになります。どこにキーワードがあるかすぐにわかりますね。

「全文検索」タグでドリルダウンして「ruby」で全文検索した結果をハイライト

スコアでソート

検索結果の表示順はユーザーが求めていそうな順番にするとユーザーはうれしいです。

Groongaはスコアという数値でどれだけ検索条件にマッチしていそうかという情報を返します。スコアでソートすることでユーザーが求めていそうな順番にできます。

@@ -15,8 +15,12 @@ class DocumentsController < ApplicationController
                        "tags.label",
                        "highlight_html(title)",
                        "snippet_html(content)",
-                     ]).
-      query(@query)
+                     ])
+    if @query.present?
+      request = request.
+        query(@query).
+        sort_keys(["-_score"])
+    end
     if @tag.present?
       request = request.filter("tags @ %{tag}", tag: @tag)
     end
ページネーション

groonga-client-modelは標準でページネーション機能を提供しています。Kaminariと連携することでページネーションのUIもすぐに作れます。

Gemfile:

@@ -53,3 +53,4 @@ end
 gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby]

 gem 'groonga-client-model'
+gem 'kaminari'

app/controllers/documents_controller.rb:

@@ -27,7 +27,8 @@ class DocumentsController < ApplicationController
     @request = request.
       drilldowns("tag").keys("tags").
       drilldowns("tag").sort_keys("-_nsubrecs").
-      drilldowns("tag").output_columns(["_key", "_nsubrecs", "label"])
+      drilldowns("tag").output_columns(["_key", "_nsubrecs", "label"]).
+      paginate(params[:page])
   end

app/views/documents/index.html.erb:

@@ -2,7 +2,7 @@

 <h1>Documents</h1>

-<p><%= @request.response.n_hits %> records</p>
+<p><%= page_entries_info(@request.response) %></p>

 <%= form_tag(documents_path, method: "get") do %>
   <%= hidden_field_tag "tag", @tag %>
@@ -62,4 +62,6 @@

 <br>

+<%= paginate(@request.response) %>
+
 <%= link_to 'New Document', new_document_path %>

RubyGemsを追加したのでGemfile.lockを更新します。アプリケーションサーバーを再起動することも忘れないでください。

% bundle install

画面の上にはページの情報が表示されます。

ページの情報

画面の下にはページを移動するためのリンクが表示されます。

ページネーション

まとめ

MySQL・PostgreSQL・SQLite3を一切使わずにRuby on Railsアプリケーションで日本語全文検索機能を実現する方法を説明しました。データの保存も取得も検索もすべてGroongaで実現しました。単に全文検索できるようにするだけではなく、ドリルダウンやハイライトといった全文検索ならではの機能の実現方法も紹介しました。

Groongaを使いたいけど学習コストが増えそうだなぁと思っていた人は試してみてください。実際に試してみて詰まった場合や、ここには書いていないこういうことをしたいけどどうすればいいの?ということがでてきた場合は以下の場所で相談してください。

Groongaを用いた全文検索アプリケーションの開発に関するご相談は問い合わせフォームからご連絡ください。

Groonga関連の開発・サポートを仕事にしたい方は採用情報を確認の上ご応募ください。

タグ: Ruby | Groonga
2017-01-27

Ruby on RailsでMySQL・PostgreSQL・SQLite3とGroongaを使って日本語全文検索を実現する方法

MySQL・PostgreSQL・SQLite3の標準機能では日本語テキストの全文検索に難があります。MySQL・PostgreSQLに高速・高機能な日本語全文検索機能を追加するMroongaPGroongaというプラグインがあります。これらを導入することによりSQLで高速・高機能な日本語全文検索機能を実現できます。詳細は以下を参照してください。

ここではMroonga・PGroongaを使わずに日本語全文検索を実現する方法を紹介します。それはGroongaを使う方法です。

GroongaはMroonga・PGroongaのバックエンドで使われている全文検索エンジンです。

Groongaを直接使うメリットは以下の通りです。

  • MySQL・PostgreSQLのオーバーヘッドがない分Mroonga・PGroongaよりもさらに速い
  • 1つのSQLでは表現できないような検索を1クエリーで実現できる(のでさらに速い)

一方、デメリットは以下の通りです。

  • Mroonga・PGroongaに比べて学習コストが増える(Mroonga・PGroongaはSELECTWHEREでの条件の書き方を学習するくらいでよいが、Groongaはインデックスの設計やクエリーの書き方について学習する必要がある)
  • MySQL・PostgreSQLだけでなくGroongaサーバーも管理する必要があるので運用コストが増える

このデメリットのうち学習コストの方をできるだけ抑えつつGroongaを使えるようにするためのライブラリーがあります。それがgroonga-client-railsです。groonga-client-railsがGroongaを使う部分の多くをフォローしてくれるため利用者は学習コストを抑えたままGroongaを使って高速な日本語全文検索システムを実現できます。

この記事ではRuby on Railsで作ったアプリケーションからGroongaを使って日本語全文検索機能を実現する方法を説明します。実際にドキュメント検索システムを開発する手順を示すことで説明します。ここではCentOS 7を用いますが、他の環境でも同様の手順で実現できます。

なお、この記事ではMySQL・PostgreSQLではなくSQLite3を使っていますが、アプリケーションのコードは変更せずにMySQL・PostgreSQLでも動くので気にしないでください。

@KitaitiMakotoさんが書いたgroonga-client-railsの使い方を紹介した記事もあるのでそちらもあわせて参照してください。違った視点で紹介しているので理解が深まるはずです。

Groongaのインストール

まずGroongaをインストールします。CentOS 7以外の場合にどうすればよいかはGroongaのインストールドキュメントを参照してください。

% sudo -H yum install -y http://packages.groonga.org/centos/groonga-release-1.2.0-1.noarch.rpm
% sudo -H yum install -y groonga-httpd
% sudo -H systemctl start groonga-httpd

Rubyのインストール

CentOS 7にはRuby 2.0のパッケージがありますが、Ruby on Rails 5.0.1はRuby 2.2以降が必要なのでrbenvとruby-buildでRuby 2.3をインストールします。

% sudo -H yum install -y git
% git clone https://github.com/sstephenson/rbenv.git ~/.rbenv
% git clone https://github.com/sstephenson/ruby-build.git ~/.rbenv/plugins/ruby-build
% echo 'export PATH="$HOME/.rbenv/bin:$PATH"' >> ~/.bash_profile
% echo 'eval "$(rbenv init -)"' >> ~/.bash_profile
% exec ${SHELL} --login
% sudo -H yum install -y gcc make patch openssl-devel readline-devel zlib-devel
% rbenv install 2.3.3
% rbenv global 2.3.3

Ruby on Railsのインストール

Ruby on Railsをインストールします。

% sudo -H yum install -y sqlite-devel nodejs
% gem install rails

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

いよいよ日本語全文検索機能を持ったドキュメント検索システムを開発します。

まずはrails newで雛形を作ります。

% rails new document_search
% cd document_search

データベースを作成します。

% bin/rails db:create

検索対象のドキュメントを格納するテーブルを作成します。

% bin/rails generate scaffold document title:text content:text
% bin/rails db:migrate

ここまでは(Groongaのインストール以外は)Groongaと関係ない手順です。

ここからはGroongaを使う場合に特有の手順になります。

まずGemfileにgroonga-client-rails gemを追加します。

gem 'groonga-client-rails'

groonga-client-rails gemをインストールします。

% bundle install

それではアプリケーション側に全文検索機能を実装します。

まず、サーチャーというオブジェクトを定義します。これはGroongaでいい感じに全文検索するための機能を提供するオブジェクトです。

サーチャー用のディレクトリーを作成します。

% mkdir -p app/searchers

app/searchers/application_searcher.rbApplicationSearcherを作成します。(ジェネレーターはまだ実装されていません。)

class ApplicationSearcher < Groonga::Client::Searcher
end

Documentモデル用のサーチャーDocumentsSearcherapp/searchers/documents_searcher.rbに作成します。

class DocumentsSearcher < ApplicationSearcher
  # Documentモデルのtitleカラムを全文検索するためのインデックスを作成
  schema.column :title, {
    type: "ShortText",
    index: true,
    index_type: :full_text_search,
  }
  # Documentモデルのcontentカラムを全文検索するためのインデックスを作成
  schema.column :content, {
    type: "Text",
    index: true,
    index_type: :full_text_search,
  }
end

モデルのカラムとサーチャーのインデックスを対応付けるコードをモデルに追加します。

app/models/document.rb:

class Document < ApplicationRecord
  # DocumentモデルをDocumentsSearcherの検索対象とする
  source = DocumentsSearcher.source(self)
  # Documentのtitleカラムと
  # DocumentsSearcherのtitleインデックスを対応付ける
  source.title = :title
  # Documentのcontentカラムと
  # DocumentsSearcherのcontentインデックスを対応付ける
  source.content = :content
end

この対応付けをGroongaのサーバーに反映します。

% bin/rails groonga:sync

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

lib/tasks/data.rake:

require "open-uri"
require "json"

namespace :data do
  namespace :load do
    desc "Load data from Qiita"
    task :qiita => :environment do
      tag = "groonga"
      url = "https://qiita.com/api/v2/items?page=1&per_page=100&query=tag:#{tag}"
      open(url) do |entries_json|
        entries = JSON.parse(entries_json.read)
        entries.each do |entry|
          Document.create(title:   entry["title"],
                          content: entry["body"])
        end
      end
    end
  end
end

実行して検索対象のドキュメントを作成します。

% bin/rails data:load:qiita

http://localhost:3000/documentsにアクセスし、データが入っていることを確認します。

Qiitaのデータをロード

ビューにヒット件数表示機能と検索フォームをつけてコントローラーで全文検索するようにします。

検索フォームではqueryというパラメーターに検索クエリーを指定することにします。

@documents@result_setに変更している理由はあとでわかります。端的に言うとヒットしたドキュメントだけでなくさらに情報も持っているので@result_set(結果セット)にしています。たとえば、「ヒット数」(@result_set.n_hits)も持っています。SQLでは別途SELECT COUNT(*)を実行しないといけませんが、Groongaでは1回の検索で検索結果もヒット数も両方取得できるので効率的です。

なお、ヒットしたドキュメントに対応するDocumentモデルは@result_set.records.each {|record| record.source}でアクセスできます。そのため、モデルが必要な処理(たとえばURLの生成)もこれまで通りの方法で使えます。

app/views/documents/index.html.erb:


 <h1>Documents</h1>

+<p><%= @result_set.n_hits %> records</p>
+
+<%= form_tag(documents_path, method: "get") do %>
+  <%= search_field_tag "query", @query %>
+  <%= submit_tag "Search" %>
+<% end %>
+
 <table>
   <thead>
     <tr>
@@ -12,7 +19,8 @@
   </thead>

   <tbody>
-    <% @documents.each do |document| %>
+    <% @result_set.records.each do |record| %>
+      <% document = record.source %>
       <tr>
         <td><%= document.title %></td>
         <td><%= document.content %></td>

app/controllers/documents_controller.rb:

@@ -4,7 +4,11 @@ class DocumentsController < ApplicationController
   # GET /documents
   # GET /documents.json
   def index
-    @documents = Document.all
+    @query = params[:query]
+    searcher = DocumentsSearcher.new
+    @result_set = searcher.search.
+      query(@query).
+      result_set
   end

   # GET /documents/1

この状態で次のようにレコード数とフォームが表示されるようになります。

フォームを追加

また、この状態で日本語全文検索機能を実現できています。確認してみましょう。

フォームに「オブジェクト」と日本語のクエリーを入力します。元のドキュメントは100件あり、「オブジェクト」で絞り込んで11件になっています。日本語で全文検索できていますね。

「オブジェクト」で検索

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

「オブジェクト OR API」で検索

全文検索エンジンならではの機能を利用

これで基本的な全文検索機能は実現できていますが、せっかく全文検索エンジンを直接使って検索しているので全文検索エンジンならではの機能も使ってみましょう。

ドリルダウン

まずはドリルダウン機能を使います。ドリルダウンとはある軸に注目して情報を絞り込んでいくことです。例えば、商品カテゴリーに注目して商品を絞り込む(例:家電→洗濯機→ドラム式)、タグに注目して記事を絞り込むといった具合です。

まずは各ドキュメントにタグを付けられるようにしましょう。

タグを作ります

% bin/rails generate scaffold tags name:string

ドキュメントとタグを結びつける関連テーブルを作ります

% bin/rails generate model tagging document:references tag:references

スキーマを更新します。

% bin/rails db:migrate

モデルに関連情報を追加します。

app/models/document.rb:

@@ -1,4 +1,7 @@
 class Document < ApplicationRecord
+  has_many :taggings
+  has_many :tags, through: :taggings
+
   source = DocumentsSearcher.source(self)
   source.title = :title
   source.content = :content

app/models/tag.rb:

@@ -1,2 +1,4 @@
 class Tag < ApplicationRecord
+  has_many :taggings
+  has_many :documents, through: :taggings
 end

Qiitaのデータからタグ情報もロードするようにします。

lib/tasks/data.rake:

@@ -10,8 +10,12 @@ namespace :data do
       open(url) do |entries_json|
         entries = JSON.parse(entries_json.read)
         entries.each do |entry|
+          tags = entry["tags"].collect do |tag|
+            Tag.find_or_create_by(name: tag["name"])
+          end
           Document.create(title:   entry["title"],
-                          content: entry["body"])
+                          content: entry["body"],
+                          tags:    tags)
         end
       end
     end

データベース内のデータを削除してQiitaのロードし直します。

% bin/rails runner Document.destroy_all
% bin/rails data:load:qiita

ビューにタグ情報も表示します。

app/views/documents/index.html.erb:

@@ -14,6 +14,7 @@
     <tr>
       <th>Title</th>
       <th>Content</th>
+      <th>Tags</th>
       <th colspan="3"></th>
     </tr>
   </thead>
@@ -24,6 +25,13 @@
       <tr>
         <td><%= document.title %></td>
         <td><%= document.content %></td>
+        <td>
+          <ul>
+          <% document.tags.each do |tag| %>
+            <li><%= tag.name %></li>
+          <% end %>
+          </ul>
+        </td>
         <td><%= link_to 'Show', document %></td>
         <td><%= link_to 'Edit', edit_document_path(document) %></td>
         <td><%= link_to 'Destroy', document, method: :delete, data: { confirm: 'Are you sure?' } %></td>

「Tags」カラムにタグがあるのでタグがロードされていることを確認できます。

タグがロードされている

それではこのタグ情報を使ってドリルダウンできるようにします。

Groongaでタグ情報を使えるようにするにはサーチャーとモデルにタグ情報を使うというコードを追加します。

app/searchers/documents_searcher.rb:

@@ -9,4 +9,11 @@ class DocumentsSearcher < ApplicationSearcher
     index: true,
     index_type: :full_text_search,
   }
+  schema.column :tags, {
+    type: "ShortText",
+    reference: true,   # 文字列でドリルダウンをするときは指定すると高速になる
+    normalizer: false, # タグそのもので検索する
+    vector: true,      # 値が複数あるときは指定する
+    index: true,
+  }
 end

app/models/document.rb:

@@ -5,4 +5,7 @@ class Document < ApplicationRecord
   source = DocumentsSearcher.source(self)
   source.title = :title
   source.content = :content
+  source.tags = ->(model) do
+    model.tags.collect(&:name) # タグモデルではなくタグ名をGroongaに渡す
+  end
 end

マッピングを変更したらgroonga:syncで同期します。

% bin/rails groonga:sync

これでGroongaでタグ情報を使えるようになりました。フォームに「tags:@全文検索」と入力すると「全文検索」タグで絞り込めます。(tags:@...は「tagsカラムの値を検索する」というGroongaの構文です。Googleのsite:...に似せた構文です。)

「全文検索」タグで検索

ユーザーにとっては、タグをキーボードから入力して絞り込む(ドリルダウンする)のは面倒なので、クリックでドリルダウンできるようにします

コントローラーには次の2つの処理を追加しています。

  • クエリーパラメーターとしてtagが指定されていたらfilter("tags @ %{tag}", tag: tag)でタグ検索をする条件を追加する。
  • タグでドリルダウンするための情報(どのタグ名で絞りこめるのか、また、絞り込んだらどのくらいの件数になるのか、という情報)を取得する

「タグでドリルダウンするための情報を取得する」とはSQLでいうと「GROUP BY tagの結果も取得する」という処理になります。SQLではGROUP BYの結果も取得すると追加でSQLを実行しないといけませんが、Groongaでは1回のクエリーで検索もヒット数の取得もドリルダウン用の情報も取得できるので効率的です。

app/controllers/documents_controller.rb:

@@ -5,9 +5,16 @@ class DocumentsController < ApplicationController
   # GET /documents.json
   def index
     @query = params[:query]
+    @tag = params[:tag]
+
     searcher = DocumentsSearcher.new
-    @result_set = searcher.search.
-      query(@query).
+    request = searcher.search.query(@query)
+    if @tag.present?
+      request = request.filter("tags @ %{tag}", tag: @tag)
+    end
+    @result_set = request.
+      drilldowns("tag").keys("tags").
+      drilldowns("tag").sort_keys("-_nsubrecs").
       result_set
   end

ビューではクリックでドリルダウンできる(タグで絞り込める)ようにリンクを表示します。

app/views/documents/index.html.erb:

@@ -5,10 +5,21 @@
 <p><%= @result_set.n_hits %> records</p>

 <%= form_tag(documents_path, method: "get") do %>
+  <%= hidden_field_tag "tag", @tag %>
   <%= search_field_tag "query", @query %>
   <%= submit_tag "Search" %>
 <% end %>

+<nav>
+  <% @result_set.drilldowns["tag"].records.each do |record| %>
+  <%= link_to_unless @tag == record._key,
+                     "#{record._key} (#{record._nsubrecs})",
+                     url_for(query: @query, tag: record._key) %>
+  <% end %>
+  <%= link_to "タグ絞り込み解除",
+              url_for(query: @query) %>
+</nav>
+
 <table>
   <thead>
     <tr>
@@ -27,8 +38,10 @@
         <td><%= document.content %></td>
         <td>
           <ul>
-          <% document.tags.each do |tag| %>
-            <li><%= tag.name %></li>
+          <% record.tags.each do |tag| %>
+            <li><%= link_to_unless @tag == tag,
+                                   tag,
+                                   url_for(query: @query, tag: tag) %></li>
           <% end %>
           </ul>
         </td>

これで次のような画面になります。「全文検索 (20)」というリンクがあるので、「全文検索」タグでドリルダウンすると「20件」ヒットすることがわかります。

タグでドリルダウンできる

「全文検索 (20)」のリンクをクリックすると「全文検索」タグでドリルダウンできます。たしかに20件ヒットしています。

「全文検索」タグでドリルダウン

ここからさらにキーワードで絞り込むこともできます。以下はさらに「ruby」で絞り込んだ結果です。ヒット数がさらに減って3件になっています。

「全文検索」タグでドリルダウンして「ruby」で全文検索

全文検索エンジンの機能を使うと簡単・高速にドリルダウンできるようになります。

キーワードハイライト

検索結果を確認しているとき、キーワードがどこに含まれているかがパッとわかると目的のドキュメントかどうかを判断しやすくなります。そのための機能も全文検索エンジンならではの機能です。

highlight_html()を使うとキーワードを<span class="keyword">...</span>で囲んだ結果を取得できます。

snippet_html()を使うとキーワード周辺のテキストを取得できます。

これらを使ってキーワードをハイライトするには次のようにします。

app/controllers/documents_controller.rb:

@@ -13,6 +13,12 @@ class DocumentsController < ApplicationController
       request = request.filter("tags @ %{tag}", tag: @tag)
     end
     @result_set = request.
+      output_columns([
+                       "_key",
+                       "*",
+                       "highlight_html(title)",
+                       "snippet_html(content)",
+                     ]).
       drilldowns("tag").keys("tags").
       drilldowns("tag").sort_keys("-_nsubrecs").
       result_set

app/views/documents/index.html.erb:

@@ -34,8 +34,16 @@
     <% @result_set.records.each do |record| %>
       <% document = record.source %>
       <tr>
-        <td><%= document.title %></td>
-        <td><%= document.content %></td>
+        <td><%= record.highlight_html.html_safe %></td>
+        <td>
+          <% if record.snippet_html.present? %>
+            <% record.snippet_html.each do |chunk| %>
+              <div>...<%= chunk.html_safe %>...</div>
+            <% end %>
+          <% else %>
+            <%= document.content %>
+          <% end %>
+        </td>
         <td>
           <ul>
           <% record.tags.each do |tag| %>

app/assets/stylesheets/documents.scss:

@@ -1,3 +1,7 @@
 // Place all the styles related to the documents controller here.
 // They will automatically be included in application.css.
 // You can use Sass (SCSS) here: http://sass-lang.com/
+
+.keyword {
+  color: red;
+}

「全文検索」タグでドリルダウンして「ruby」で全文検索した状態では次のようになります。どこにキーワードがあるかすぐにわかりますね。

「全文検索」タグでドリルダウンして「ruby」で全文検索した結果をハイライト

スコアでソート

検索結果の表示順はユーザーが求めていそうな順番にするとユーザーはうれしいです。

Groongaはスコアという数値でどれだけ検索条件にマッチしていそうかという情報を返します。スコアでソートすることでユーザーが求めていそうな順番にできます。

@@ -8,7 +8,12 @@ class DocumentsController < ApplicationController
     @tag = params[:tag]

     searcher = DocumentsSearcher.new
-    request = searcher.search.query(@query)
+    request = searcher.search
+    if @query.present?
+      request = request.
+        query(@query).
+        sort_keys("-_score")
+    end
     if @tag.present?
       request = request.filter("tags @ %{tag}", tag: @tag)
     end
ページネーション

groonga-client-railsは標準でページネーション機能を提供しています。Kaminariと連携することでページネーションのUIもすぐに作れます。

Gemfile:

@@ -53,3 +53,4 @@ end
 gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby]

 gem 'groonga-client-rails'
+gem 'kaminari'

app/controllers/documents_controller.rb:

@@ -26,6 +26,7 @@ class DocumentsController < ApplicationController
                      ]).
       drilldowns("tag").keys("tags").
       drilldowns("tag").sort_keys("-_nsubrecs").
+      paginate(params[:page]).
       result_set
   end

app/views/documents/index.html.erb:

@@ -2,7 +2,7 @@

 <h1>Documents</h1>

-<p><%= @result_set.n_hits %> records</p>
+<p><%= page_entries_info(@result_set, entry_name: "documents") %></p>

 <%= form_tag(documents_path, method: "get") do %>
   <%= hidden_field_tag "tag", @tag %>
@@ -63,4 +63,6 @@

 <br>

+<%= paginate(@result_set) %>
+
 <%= link_to 'New Document', new_document_path %>

RubyGemsを追加したのでGemfile.lockを更新します。アプリケーションサーバーを再起動することも忘れないでください。

% bundle install

画面の上にはページの情報が表示されます。

ページの情報

画面の下にはページを移動するためのリンクが表示されます。

ページネーション

まとめ

MySQL・PostgreSQL・SQLite3とGroongaを使ってRuby on Railsアプリケーションで日本語全文検索機能を実現する方法を説明しました。単に全文検索できるようにするだけではなく、ドリルダウンやハイライトといった全文検索ならではの機能の実現方法も紹介しました。

Groongaを使いたいけど学習コストが増えそうだなぁと思っていた人は試してみてください。実際に試してみて詰まった場合や、ここには書いていないこういうことをしたいけどどうすればいいの?ということがでてきた場合は以下の場所で相談してください。

Groongaを用いた全文検索アプリケーションの開発に関するご相談は問い合わせフォームからご連絡ください。

Groonga関連の開発・サポートを仕事にしたい方は採用情報を確認の上ご応募ください。

つづき: 2017-01-27
タグ: Ruby | Groonga
2016-12-22

PGConf.ASIA 2016:PGroonga – PostgreSQLを全言語対応高速全文検索プラットフォームに! #pgconfasia

2016年12月1日から12月3日にかけて開催されたPostgreSQLの国際カンファレンスPGConf.ASIA 2016PGroongaというPostgreSQLの全文検索モジュールの話をしました。

関連リンク:

内容

前半はPostgreSQLの全文検索まわりの課題の説明とPGroongaがどうやってそれらの課題を解決するかという内容になっています。

後半はPGroongaの説明になっています。

PostgreSQLの全文検索まわりの課題は次の通りです。

  • PostgreSQLが標準で提供する全文検索機能(textesarchcontrib/pg_trgm)は日本語や中国語などアジア圏の言語をサポートしていない。(アジア圏の言語以外にもサポートしていない言語はある。)
  • pg_bigmというモジュールを導入することで全言語をサポートできるがヒット数が増えると遅くなりがち。

PGroongaはこれらの課題を解決します。PGroongaは全言語対応でヒット数が増えても高速な全文検索機能を提供します。

以下はpg_bigmとの検索時間の比較です。棒が短いほど速いです。pg_bigm(青い棒)は極端に遅くなるケースがありますが、PGroonga(紫の棒)は安定して高速です。

PGroongaとpg_bigmの検索時間

この検索時間の比較はデータとして日本語版Wikipediaを用いています。他にもデータとして英語版Wikipediaを用いて、PGroongaとPostgreSQLが標準で提供する全文検索機能を比較したデータもあります。詳細はスライドの内容あるいは以下のドキュメントを参照してください。

後半のPGroongaの説明では以下のことに触れました。詳細はスライドと以下のリストに含まれているリンクを参考にしてください。

まとめ

PGConf.ASIA 2016でPGroongaについて話しました。現在のPostgreSQLの全文検索機能には課題がありますが、PGroongaを組み込むことで、PostgreSQLでアジア圏の言語(もちろん日本語も含む!)でも実用的な全文検索機能を実現することができます。PostgreSQLでの全文検索機能でこまったらPGroongaを検討してみてください。

つづき: 2017-01-05
タグ: Groonga
2016-12-06

Groongaでのmrubyの組み込み方:ビルド周り

全文検索エンジンGroongamrubyを組み込んでいます。理由は、速度はそれほど必要ではなく込み入った処理をCではなくRubyで書けると開発が捗るからです。

この記事ではどのようにmrubyを組み込んでいるかについてビルド周りだけを説明します。(ビルド周り以外には、バインディングをどうやって書くか、.rbファイルをどこに置くか、実装をCにするかRubyにするかの判断基準などの話があります。)

方針

mrubyはRakeを使ったビルドシステムを使っています。GroongaはGNU AutotoolsまたはCMakeを使ったビルドシステムを使っています。(どちらでもビルドできます。)

mrubyはRakeを使ってビルド、GroongaはGNU Autotoolsを使ってビルド、とするとコンパイルオプションの統一・クロスコンパイルあたりで面倒になります。また、Rakeを使ってビルドするためにはビルド時にRubyが必要になるのも面倒です。Groongaの開発者がGroongaをビルドするためにRubyが必要になるのはよいですが、GroongaのユーザーがGroongaをビルドするためにRubyが必要になるのはビルドの敷居が上がるのでできれば避けたいです。

以上の理由からGroongaではmrubyをビルドするためにRakeを使っていません。

ではどうしているかというと、次のようにしています。

  1. Groonga開発者はRakeを使って(= Rubyが必要)mrubyのビルドに必要なファイルを生成(一部自動生成のファイルがあるため)
  2. Groongaはmrubyのビルドに必要なファイルをGNU Autotools(またはCMake)を使ったビルドシステムに統合(= mrubyのビルドにRakeを使っていない = Rubyは必要ない)
  3. Groonga開発者はリリース版のソースアーカイブに1.で生成したファイルをすべて含める(= Groongaユーザーは1.を実行する必要がない = Rubyは必要ない)

それぞれどのようにしているか説明します。

Groongaの開発者はRakeを使ってmrubyのビルドに必要なファイルを生成

Groongaはvendor/mruby-sourceをGitのsubmoduleにしています。つまり、mrubyのリポジトリーのソースをまるっと参照しています。

この状態ではmrubyのビルドに必要なファイルは足りません。具体的にはRubyで実装されたコードをmrubyに組み込むファイル(mrblib.cmrbc -Bで生成するファイル。)や利用するmrbgemsを組み込むファイル(mrbgems_init.c)が足りません。

これらを生成するためにRakeを使ってビルドします。出力先はvendor/mruby-build/にしています。ビルドした後vendor/mruby-build/に出力されたファイルの中から必要なファイルをvendor/mruby/にコピーします。vendor/mruby-build/を直接ビルドに使って「いません」。ビルドに使っているのはvendor/mruby/にコピーしたファイルです。このあたりの実装がvendor/mruby/mruby_build.rbです。

Groongaはmrubyのビルドに必要なファイルをGNU Autotools(またはCMake)を使ったビルドシステムに統合

必要なファイルが揃ったらGNU Autotools(またはCMake)のビルドシステムに統合することは難しくありません。

GNU Autotoolsの場合はvendor/mruby/Makefile.amで実現しています。

CMakeの場合はvendor/mruby/CMakeLists.txtで実現しています。

工夫しているところはソースファイルのリストを共有しているところくらいです。mruby本体やmrbgemsが更新されるとソースファイルのリストは変わることがあるのでvendor/mruby/update.rbで自動生成しています。Makefile.amCMakeLists.txtでは自動生成されたリストを読み込んでビルドシステムに統合しています。

Groonga開発者はリリース版のソースアーカイブに生成したファイルをすべて含める

mrubyをRakeでビルドして生成されたファイルをソースアーカイブに含めることでGroongaユーザーはビルドするときにRubyがなくてもビルドできます。

ソースアーカイブには生成されたファイルといつ生成されたかを示すタイムスタンプファイルを入れています。タイムスタンプファイルが新しければ再生成(mrubyをRakeでビルドし直すこと)せずにすでにあるファイルを使うようにしています。こうすることでソースアーカイブからビルドするGroongaユーザーはRubyがなくてもビルドできるようになっています。

実現方法のポイントはvendor/mruby/Makefile.amBUILT_SOURCESlibmruby_la_SOURCESに追加しているところとmruby_build.timestampがあったら再生成しないようにしているところです。

libmruby_la_SOURCES += $(BUILT_SOURCES)
mrblib.c: mruby_build.timestamp
mrbgems_init.c: mruby_build.timestamp
# ...

mruby_build.timestamp: build_config.rb version
#   ...

まとめ

Groongaがどうやってmrubyを組み込んでいるかについてビルド周りだけを説明しました。

Groongaのmruby組み込み周りを触る人や自分のアプリケーションにmrubyを組み込みたい人は参考にしてください。

つづき: 2017-01-05
タグ: Groonga
2016-10-13

MySQLとPostgreSQLと日本語全文検索3:MroongaとPGroongaの導入方法例 #mypgft

2016年9月29日(肉の日!)に「MySQLとPostgreSQLと日本語全文検索3」というイベントを開催しました。その名の通りMySQLとPostgreSQLでの日本語全文検索についての話題を扱うイベントです。今回もDMM.comラボさんに会場を提供してもらいました。

2月9日に開催した1回目のイベントではMroongaPGroongaについては次の2つのことについて紹介しました。

  • Mroonga・PGroongaが速いということ
  • Mroonga・PGroongaの使い方

6月9日に開催した2回目のイベントではMroonga・PGroongaについては次の2つのことについて紹介しました。

  • Mroonga・PGroongaのオススメの使い方
  • レプリケーションまわり

今回はMroonga・PGroongaについては次のことについて紹介しました。

関連リンク:

Redmineへの導入方法

Redmineというチケット管理システムへのMroonga・PGroongaの導入方法を説明します。RedmineはRuby on Railsを利用しているのでRuby on Railsを使っているアプリケーションに導入する例ということになります。

Redmineは右上の検索ボックスから全文検索できます。ここから全文検索したときにMroonga・PGroongaを使うようにします。

Redmineの検索ボックス

redmine_full_text_searchプラグインを使うとRedmineでMroongaまたはPGroongaを使って全文検索できるようになります。

このプラグインを使うとRedmineの全文検索が高速になります。たとえば、クリアコードで使っているRedmineには3000件くらいのチケットがありますが、その環境では次のように高速になりました。

プラグイン 時間
なし 467ms
あり 93ms

200万件のチケットがある環境でも約380msで検索できているという報告もあります。

Mroongaを導入する方法

Mroongaはトランザクションに対応していないのでトランザクションが必須のRedmineに組み込む場合はひと工夫必要になります。単純に、ALTER TABLE table ENGINE=Mroonga ADD FULLTEXT INDEX (column)とするわけにはいきません。

ではどうするかというと別途全文検索用のテーブルを作成して元のテーブルとはJOINできるようにします。(他にもレプリケーションしてレプリケーション先をMroongaにするという2回目のイベントで紹介した方法もありますが、プラグインでやるには大掛かりなのでこの方法を使っています。)

マイグレーションファイルでいうと次のようにします。ここではissuesテーブル用の全文検索用のテーブルを作成しています。

def up
  create_table(:fts_issues, # 全文検索用テーブル作成
               id: false, # idは有効・無効どっちでも可
               options: "ENGINE=Mroonga") do |t|
    t.belongs_to :issue, index: true, null: false
    t.string :subject, default: "", null: false
    t.text :description, limit: 65535, null: false
    t.index [:subject, :description], type: "fulltext"
  end
end

全文検索用のテーブルには元のデータをコピーする必要があります。マイグレーション時には既存のデータを一気にコピーします。そのため、本当のマイグレーションの内容は次のようになります。データコピー後にインデックスを追加するようにしているのはそっちの方が速いからです。

def up
  create_table(:fts_issues, # 全文検索用テーブル作成
               id: false, # idは有効・無効どっちでも可
               options: "ENGINE=Mroonga") do |t|
    t.belongs_to :issue, index: true, null: false
    t.string :subject, default: "", null: false
    t.text :description, limit: 65535, null: false
  end
  execute("INSERT INTO " + # データをコピー
            "fts_issues(issue_id, subject, description) " +
            "SELECT id, subject, description FROM issues;")
  add_index(:fts_issues, [:subject, :description],
            type: "fulltext") # 静的インデックス構築(速い)
end

このテーブルのモデルは次のようになります。

class FtsIssue < ActiveRecord::Base
  # 実際はissue_idカラムは主キーではない。
  # 主キーなしのテーブルなので
  # Active Recordをごまかしているだけ。
  self.primary_key = :issue_id
  belongs_to :issue
end

Mroonga導入後に更新されたデータはアプリケーション(Redmine)側でデータをコピーします。Active Recordのafter_saveフックを利用します。Mroongaがトランザクションをサポートしていないため、ロールバックのタイミングによってはデータに不整合が発生することがありますが、再度保存すれば復旧できることとそれほどロールバックは発生しないため、実運用時には問題になることはないでしょう。

class Issue
  # この後にロールバックされることがあるのでカンペキではない
  # 再度同じチケットを更新するかデータを入れ直せば直る
  after_save do |record|
    fts_record = FtsIssue.find_or_initialize_by(issue_id: record.id)
    fts_record.subject     = record.subject
    fts_record.description = record.description
    fts_record.save!
  end
end

全文検索時は全文検索用のテーブルをJOINしてMATCH AGAINSTを使います。

issue.
  joins(:fts_issue).
  where(["MATCH(fts_issues.subject, " +
               "fts_issues.description) " +
          "AGAINST (? IN BOOLEAN MODE)",
         # ↓デフォルトANDで全文検索
         "*D+ #{keywords.join(', ')}"])

この説明はわかりやすさのために実際の実装を単純化しています。詳細が知りたい方は実装を確認してください。

PGroongaを導入する方法

PGroongaはトランザクションに対応しているので別途全文検索用のテーブルを作成する必要はありません。既存のテーブルに全文検索用のインデックスを作成します。

マイグレーションファイルでいうと次のようにします。ここではissuesテーブルに全文検索用のインデックスを作成しています。enable_extension("pgroonga")はPGroongaを使えるようにするためのSQLです。

def up
  enable_extension("pgroonga")
  add_index(:issues,
            [:id, :subject, :description],
            using: "pgroonga")
end

あとは検索時に全文検索条件をつけるだけです。

issue.
  # 検索対象のカラムごとに
  # クエリーを指定
  where(["subject @@ ? OR " +
         "description @@ ?",
         keywords.join(", "),
         keywords.join(", ")])

この説明もわかりやすさのために実際の実装を単純化しています。詳細が知りたい方は実装を確認してください。

Zulipへの導入方法

ZulipというチャットツールへのPGroongaの導入方法を説明します。ZulipはPostgreSQLを使っているので、導入するのはPGroongaだけです。ZulipはDjangoを使っているのでDjangoを使っているアプリケーションに導入する例ということになります。

Zulipは上部の検索ボックスから全文検索できます。ここから全文検索したときにPGroongaを使うようにします。

Zulipの検索ボックス

Zulipはチャットツールです。チャットツールなので小さなテキストの書き込みが頻繁に発生する傾向があります。各書き込みは十分速く完了する必要があります。書き込みが遅いとユーザーの不満が溜まりやすいからです。

Zulipは書き込みをできるだけ速くするためにインデックスの更新を遅延させています。インデックスの更新はデータの追加よりも重い処理なので、その処理を後回しにしているということです。(PGroongaは検索だけでなく更新も速いので遅延させずにリアルタイムで更新しても十分速いかもしれません。アプリケーションの要件次第でどのような実装にするか検討する必要があります。)

Zulipは、インデックスの更新を遅延させるため、カラムの値を直接全文検索対象にせずに、別途全文検索用のカラム(zulip_message.search_pgroongaカラム)を用意しています。その全文検索用のカラムの更新を後回しにすることでインデックスの更新を遅延させています。

マイグレーションファイルでいうと次のようにします。最初のALTER ROLEはPGroongaが提供する@@という全文検索用の演算子の優先順位を調整するためのものです。本質ではないのでここでは気にしなくて構いません。

migrations.RunSQL("""
ALTER ROLE zulip SET search_path
  TO zulip,public,pgroonga,pg_catalog;
ALTER TABLE zerver_message
  ADD COLUMN search_pgroonga text;
UPDATE zerver_message SET search_pgroonga =
  subject || ' ' || rendered_content;
CREATE INDEX pgrn_index ON zerver_message
  USING pgroonga(search_pgroonga);
""", "...")

全文検索対象のカラム(zerver_message.subjectカラムとzerver_message.rendered_contentカラム)が更新されたらそのレコードのIDをログテーブル(fts_update_logテーブル)に追加します。Zulipは次のトリガーでこれを実現しています。

CREATE FUNCTION append_to_fts_update_log()
  RETURNS trigger
  LANGUAGE plpgsql AS $$
    BEGIN
      INSERT INTO fts_update_log (message_id) VALUES (NEW.id);
      RETURN NEW;
    END
  $$;
CREATE TRIGGER update_fts_index_async
  BEFORE INSERT OR UPDATE OF
    subject, rendered_content ON zerver_message
  FOR EACH ROW
    EXECUTE PROCEDURE append_to_fts_update_log();

全文検索対象のカラムのインデックスは別プロセスで更新します。別プロセスで更新するためには、全文検索対象のカラムが更新されたことをその別プロセスが知らなければいけません。これを実現するためにはポーリングする方法と更新した側から通知を受け取る方法があります。PostgreSQLにはLISTEN/NOTIFYという通知の仕組みがあるので、Zulipはこれらを利用して通知を受け取る方法を実現しています。

更新した側は次のトリガーで更新したことを(fts_update_logチャネルに)通知します。このトリガーはログテーブル(fts_update_logテーブル)にレコードが追加されたら呼ばれるようになっているので、レコードが追加されるごとに通知しているということです。

CREATE FUNCTION do_notify_fts_update_log()
  RETURNS trigger
  LANGUAGE plpgsql AS $$
    BEGIN
      NOTIFY fts_update_log;
      RETURN NEW;
    END
  $$;
CREATE TRIGGER fts_update_log_notify
  AFTER INSERT ON fts_update_log
  FOR EACH STATEMENT
    EXECUTE PROCEDURE do_notify_fts_update_log();

通知を受け取るプロセスはPythonで実装されています。単純化すると次のようになっています。(詳細はpuppet/zulip/files/postgresql/processftsupdatesを参照。)(fts_update_logチャネルに)通知がきたら全文検索用カラムを更新する(update_fts_columns(cursor)を実行する)ということを繰り返しています。

  import psycopg2
  conn = psycopg2.connect("user=zulip")
  cursor = conn.cursor
  cursor.execute("LISTEN fts_update_log;")
  while True:
      if select.select([conn], [], [], 30) != ([], [], []):
          conn.poll()
          while conn.notifies:
              conn.notifies.pop()
              update_fts_columns(cursor)

全文検索用カラムの更新(update_fts_columnsの実装)は次のようになっています。ログテーブル(fts_update_logテーブル)から更新されたレコードのIDを取得してきて各レコードごとに全文検索用カラムを更新しています。最後に処理したレコードのIDをログテーブルから削除します。

def update_fts_columns(cursor):
    cursor.execute("SELECT id, message_id FROM fts_update_log;")
    ids = []
    for (id, message_id) in cursor.fetchall():
        cursor.execute("UPDATE zerver_message SET "
                       "search_pgroonga = "
                       "subject || ' ' || rendered_content "
                       "WHERE id = %s", (message_id,))
        ids.append(id)
    cursor.execute("DELETE FROM fts_update_log "
                   "WHERE id = ANY(%s)", (ids,))

このようにしてインデックスの更新を遅延し、書き込み時の処理時間を短くしています。書き込み時のレスポンスが大事なチャットツールならではの工夫です。

インデックスが更新できたらあとは全文検索するだけです。全文検索は次のようにWHERE search_pgroonga @@ 'クエリー'を追加するだけです。

from sqlalchemy.sql import column
def _by_search_pgroonga(self, query, operand):
    # WHERE search_pgroonga @@ 'クエリー'
    target = column("search_pgroonga")
    condition = target.op("@@")(operand)
    return query.where(condition)

全文検索してヒットしたキーワードがどこにあるかを見つけやすくするために、Zulipはキーワードハイライト機能を実現しています。以下は「problem」というキーワードをハイライトしている様子です。

Zulipのキーワードハイライト機能

PostgreSQLには標準でts_headline()関数というキーワードハイライト機能がありますが、ZulipのようにHTMLで結果を取得したい場合には使えません。これはts_headline()関数はHTMLエスケープ機能を提供していないからです。HTMLエスケープ機能がないと次のように不正なHTMLができあがってしまいます。

SELECT  ts_headline('english',
                    'PostgreSQL <is> great!',
                    to_tsquery('PostgreSQL'),
                    'HighlightAll=TRUE');
--           ts_headline          
-- -------------------------------
--  <b>PostgreSQL</b> <is> great!
-- (1 row)  不正なHTML↑

そのため、ZulipではPostgreSQLにキーワード出現位置を返す関数を追加して、Zulip側でキーワードハイライト機能を実現しています。PGroongaを使っている場合はpgroonga.match_positions_byte()関数pgroonga.query_extract_keywords()関数を利用してこの機能を実現しています。

なお、PGroongaはHTMLエスケープ機能付きのハイライト関数pgroonga.highlight_html()を提供しているため、Zulipのようにアプリケーション側でハイライト機能の一部を実装する必要はありません。Zulipではすでに実装されていたためPGroongaを使った場合でもpgroonga.highlight_html()関数を使わずにハイライト機能を実現しています。

この説明はわかりやすさのために実際の実装を単純化しています。詳細が知りたい方は実装を確認してください。

まとめ

MySQLとPostgreSQLと日本語全文検索3で、実例をもとにMroonga・PGroongaの導入方法を紹介しました。たとえMySQL・PostgreSQLレベルで日本語全文検索できても、実際にアプリケーションで使えるようにならないとユーザーに日本語全文検索を提供できません。そのため、このような導入方法の紹介にしました。

アプリケーションごとになにを大事にするかは変わるので、この事例をそのまま適用できるわけではありませんが、Mroonga・PGroongaを導入する際には参考にしてください。

つづき: 2017-01-05
タグ: Groonga
2016-09-29

MariaDBコミュニティイベント in Tokyo:Mroonga 2016 - 高速日本語全文検索 for MariaDB

2016年7月21日に「MariaDBコミュニティイベント in Tokyo」というイベントが開催されました。発表中でも随時参加者から質問があがり質疑応答が始まるという、日本のイベントではあまり見られない活発なイベントでした。

このイベントでMariaDBユーザー向けの高速全文検索ソリューションとしてMroongaを紹介しました。

関連リンク:

Mroongaを知らない人向けの資料となっています。MariaDBで日本語全文検索を実現する方法を検討している方はご活用ください。

つづき: 2017-01-05
タグ: Groonga
2016-07-22

MariaDB コミュニティイベント in Tokyo開催のおしらせ

2016年7月21日(木)の午後に「MariaDB コミュニティイベント in Tokyo」というイベントが開催されます。MariaDBの開発に関わっている人(エヴァンジェリストの人)が来日しており、その人からMraiaDBの最新情報を説明してもらったり、その人に質問したりできます。他にもAll AboutがどのようにMariaDBを使っているかという話(たぶん)や、MariaDBにバンドルされているMroonga(全文検索用プラグイン)・Spider(シャーディング用プラグイン)の最新情報の話もあります。MroongaとSpiderはそれぞれ開発している人が話をするので突っ込んだ質問をすることもできます。この機会をぜひ活かしてください。

イベントに参加するには事前に登録する必要があります。以下のどのイベントページからでも登録できるので自分が使いやすいイベントページを選んでください。

無料のイベントなので、MariaDBの最新情報を知りたい、MariaDBがどのように使われているか知りたい、MariaDBでの全文検索・シャーディングについて知りたい、という方はぜひお気軽にお越しください。

つづき: 2016-07-22
タグ: Groonga
2016-07-12

MySQLとPostgreSQLと日本語全文検索2:初心者向けMroonga・PGroonga情報 #mypgft

6月9日に「MySQLとPostgreSQLと日本語全文検索2」というイベントを開催しました。今回もDMM.comラボさんに会場を提供してもらいました。当日のツイートはMySQLとPostgreSQLと日本語全文検索2 - Togetterまとめにまとまっています。

2月9日に開催した1回目のイベントでは次の2つのことについて紹介しました。

  • MySQLでの日本語全文検索の歴史と実現方法
  • PostgreSQLでの日本語全文検索の歴史と実現方法

2回目となる今回は次のことについて紹介しました。

ここではMroonga(むるんが)とPGroonga(ぴーじーるんが)の1歩進んだ使い方について少し紹介します。

関連リンク:

今回の発表ではMroongaとPGroongaのオススメの使い方およびレプリケーションまわりについて紹介しました。

Mroongaのオススメの使い方

Mroongaは次のように使うのがオススメです。

  • できればストレージモードを使う
  • テーブル定義はデフォルトのパラメーターを使う(デフォルトでいい感じになるように調整してあります。)
  • 検索するときはIN BOOLEAN MODE*D+プラグマを使う

詳細はスライドに書いていますが、以下のようなSQLで使うということです。

CREATE TABLE items (
  title text,
  FULLTEXT INDEX (title)
  -- ↑をCOMMENTでカスタマイズできるが
  --   まずはデフォルトで使うのがオススメ
) ENGINE=Mroonga
  DEFAULT CHARSET=utf8mb4;

SELECT * FROM items
  WHERE MATCH (title)
        --        ↓ *D+プラグマを使ってデフォルトでANDにする
        AGAINST ('*D+ 激安 人気' IN BOOLEAN MODE);
        --                      ↑ ブーリアンモードを使う
        --                         Web検索エンジンのような使い勝手になる

また、ストレージモードを使うための工夫としてスレーブだけをMroongaにしてレプリケーションする構成とその設定方法も紹介しました。ストレージモードではトランザクションを使えませんが、この構成にするとトランザクションを使わなくてもよくなります。

PGroongaのオススメの使い方

PGroongaは次のように使うのがオススメです。

  • 主キーを用意する
  • インデックス定義はデフォルトで使う(デフォルトでいい感じになるように調整してあります。)
  • 検索はsearch_pathを設定した上で@@演算子を使う

詳細はスライドに書いていますが、以下のようなSQLで使うということです。

CREATE TABLE items (
  -- ↓主キーを用意する
  id integer PRIMARY KEY,
  title text,
);
CREATE INDEX pgroonga_items_index
  ON items
  --              ↓ インデックスに主キーを含める
  USING pgroonga (id, title);
  -- パラメーターは指定せずデフォルトで使う

ALTER DATABASE db1
  -- ↓ search_pathにpgroongaスキーマを入れ、pg_catalogスキーマよりも先にする
  SET search_path TO "$user",public,pgroonga,pg_catalog;

SELECT *,
       -- ↓検索スコアーを取得(このために主キーが必要)
       pgroonga.score(items) AS score
  FROM items
  --          ↓ @@演算子を使う
  WHERE title @@ "激安 人気"
  ORDER BY score DESC;

PGroongaのレプリケーションについても説明しました。

PGroongaはPostgreSQL標準のレプリケーション機能を使えませんが、pglogicalと組み合わせたレプリケーションは使えます。なお、PostgreSQL 9.6以降ではPostgreSQL標準のレプリケーション機能を使えるようにPGroongaの開発を進めています。

PostgreSQLでの日本語全文検索を実現する方法としてPGroongaに興味のある方は、PGroongaをWindows用のサーバーログ管理ソフトであるVVAULT AUDITでの利用事例「VVAULT AUDITにおけるPGROONGAの利用」も合わせて参照してください。

まとめ

6月9日に開催された「MySQLとPostgreSQLと日本語全文検索2」でのMroongaとPGroongaの発表内容について紹介しました。

MySQL・PostgreSQLで日本語全文検索したい場合はぜひこれらを検討してみてください。

タグ: Groonga
2016-06-09

東京Ruby会議11での発表「アプリケーションへのRubyインタープリターの組み込み」とOSS Gateワークショップ2016-05-28 #tkrk11 #oss_gate

5月28日に開催された東京Ruby会議11で「アプリケーションへのRubyインタープリターの組み込み」と題して、アプリケーションにRubyを組み込む実装について話しました。

関連リンク:

質疑応答の補足

内容は前述のスライドや発表動画を参照してください。ここでは発表後の質疑応答の内容について補足します。

質問:ruby_sysinit()は呼ばなくていいの?

ruby_sysinit()は呼ばなくていいの?」に対する当日の回答は「ruby_sysinit()の説明は省略した」だったのですが、どうして省略したかを補足します。

Rubyインタプリターを組み込んだアプリケーションの1つであるrubyコマンドの実装(main.c)を見るとruby_sysinit()を呼んでいます。そのため、Rubyインタプリターの初期化には必要なAPIにみえます。

しかし、ruby_sysinit()のコメントには次のように書いています。ざっくり言うとrubyコマンドを初期化するためのもので、Rubyインタプリターを組み込むときはこの関数を呼ぶんじゃなくて自分で初期化してね、と言っています。

Initializes the process for ruby(1).

This function assumes this process is ruby(1) and it has just started. Usually programs that embeds CRuby interpreter should not call this function, and should do their own initialization.

中身を見ると、コマンドライン引数と標準入出力を初期化しています。Windowsで動く場合はもっと初期化しています。

Rubyインタプリターを組み込んだアプリケーション例として紹介したmilter managerではruby_sysinit()を呼んでいません。コマンドライン引数は自前で処理しています。

ただ、ruby_sysinit()を呼ばないというのはmilter managerがWindowsをサポートしていないからできることです。Windows用の初期化をしているrb_w32_sysinit()をチラ見するとわかりますが、この関数がやっている処理のうち、自分に必要な分はどこかを判断してそれらを自前で実装することは難しいでしょう。(Windowsに詳しい人ならそうでもないかもしれません。)

そのため、Windowsでも動くアプリケーションにRubyインタプリターを組み込む場合はruby_sysinit()を呼ぶのがよいでしょう。(コメントでは呼ぶなと書いていますが。)

質問:共有ライブラリーの方にアプリケーションのメイン関数を渡せばアプリケーションでRUBY_INIT_STACKを呼ばなくていいんじゃない?

スライドで言うと以下のページの実装についての質問です。

以下のようにしてembedded_init()内でapplication_main()を呼ぶようにすればいいんじゃない?という話です。

int
main(void)
{
  embedded_ruby_module = dlopen();
  embedded_ruby_init = dlsym(embedded_ruby_module, "embedded_init");
  embedded_ruby_init(application_main);
}
void
embedded_init(main_func application_main)
{
  RUBY_INIT_STACK;
  /* ... */
  application_main();
}

これに対する回答は「RubyインタプリターとPythonインタプリターを一緒に組み込めないのでやりたいことを実現できない」でした。一緒に組み込む時のイメージは次の通りです。

void
main(void)
{
  embedded_ruby_module = dlopen();
  embedded_ruby_init = dlsym(embedded_ruby_module, "embedded_init");
  embedded_ruby_init(application_main);
  /* ↓はアプリケーションのメイン関数の前に実行しないといけないけど、
     ↑でメイン関数が実行されちゃう。 */

  embedded_python_module = dlopen();
  embedded_python_init = dlsym(embedded_python_module, "embedded_init");
  embedded_python_init(application_main);
}

それに対する別案が「他のインタプリターの初期化もやる関数を渡せば?」でした。こんなイメージです。

void
main(void)
{
  embedded_ruby_module = dlopen();
  embedded_ruby_init = dlsym(embedded_ruby_module, "embedded_init");
  embedded_python_module = dlopen();
  embedded_python_init = dlsym(embedded_python_module, "embedded_init");

  init_func init_functions[] = {
    embedded_python_init,
    application_main,
    NULL
  };
  embedded_ruby_init(run_init_functions, init_functions);
}

void
run_init_functions(init_func *init_functions)
{
  if (init_functions[0]) {
    init_functions[0]();
    run_init_functions(init_functions + 1);
  } else {
    application_main();
  }
}

質疑応答はここで時間切れでした。

たしかにこれで動きそうです。

会議後にはこんなアイディアもありました。

これはどういうことかというと、アプリケーション側ではRUBY_INIT_STACKに必要なデータを用意するだけにして、実際の呼び出しは別の共有ライブラリーの方に移す、という実装にすればいいんじゃない?ということです。

RUBY_INIT_STACKruby_init_stack()というアドレスを引数にとるAPIを呼び出しているだけなので、このアイディアも実現できます。こんなイメージです。

void
main(void)
{
  int address;

  embedded_ruby_module = dlopen();
  embedded_ruby_init = dlsym(embedded_ruby_module, "embedded_init");
  embedded_ruby_init(&address);

  application_main();
}
void
embedded_init(int *address)
{
  ruby_init_stack((VALUE *)address);
  /* ... */
}

milter managerでも実装できます。(アプリケーション本体に手を入れられないケースではこの方法の実装は難しいでしょう。)

「アプリケーションがRUBY_INIT_STACKを呼ばないといけない問題」は複数の方法で解決できますね!このような場で話をするといろんなアイディアを聞けて便利ですね!みなさんも積極的に実装の話をしてはいかがでしょうか。

RUBY_INIT_STACK問題が解決したので、複数言語のインタプリターを組み込んだケースのことを想像してみたところ、次はforkで問題にあたりそうです。Ruby以外の言語が複数のスレッドを作っている場合が問題になりそうです。

外部ライブラリーとGC

質疑応答では触れられませんでしたが、外部のライブラリーが使用しているメモリー量をRuby(mrubyもCRubyも)のGCシステムが知らないために適切なタイミングでGCが動かない問題についても補足します。

発表では「外部からRubyのGCシステムにメモリー使用量を通知する仕組みが必要だと思う」と話しました。

CRubyのTypedDataにはメモリー使用量を返すAPIがありますが、それは使えないはずです。それを使うと、GCシステム側が情報をpullする仕組みになるからです。pullする仕組みにすると、いつpullするの?pullした情報をどうやって管理するの?あたりが大変になりそうです。

通知する仕組みだとこれらの問題を解決できそうです。

ということで、gc-triggerという拡張ライブラリーを作ってみました。このライブラリーは他の拡張ライブラリーに使用メモリー通知用のAPIを提供します。他の拡張ライブラリーがこのAPIを使ってメモリー使用量を通知すると、適切なタイミングでGC.startを実行します。

本来はCで提供しているAPIを呼んだほうがよいですが、テスト用・説明用にRubyのAPIも用意したので、そっちを使って使い方を説明します。

ここでは、例としてrcairoを使います。rcairoは画像を扱うからです。画像を扱う拡張ライブラリーはRuby管理外のメモリー使用量が大きめなので、この問題に遭遇しやすいケースなのです。

次のコードは1000個画像オブジェクトを生成します。どの画像オブジェクトも参照されていないのですぐにGCで回収できます。

require "cairo"

width = 6000
height = 6000
1000.times do |i|
  Cairo::ImageSurface.new(:argb32, width, height)
end

gc-triggerでメモリー使用量の増減を通知するコードは次のようになります。

require "gc-trigger"
require "cairo"

def image_surface_finalizer(size)
  lambda do |id|
    GCTrigger.update_memory_usage(-size)
  end
end

def create_image_surface(width, height)
  size = 4 * width * height
  surface = Cairo::ImageSurface.new(:argb32, width, height)
  GCTrigger.update_memory_usage(size)
  ObjectSpace.define_finalizer(surface, image_surface_finalizer(size))
  surface
end

width = 6000
height = 6000
1000.times do |i|
  create_image_surface(width, height)
end

画像オブジェクトのサイズは、単純化して「1画素のサイズ(4バイト)×縦×横」で計算しています。

それぞれの場合でのメモリー使用量をグラフにすると次のようになります。従来の方はメモリー使用量が安定しませんが、gc-triggerを使った方はメモリー使用量が一定になります。

メモリー使用量

発表で話したアイディアを動くようにしてみました。これで通じるでしょうか。

OSS Gateワークショップ2016-05-28

発表でも紹介しましたが、OSS Gateワークショップ2016-05-28を東京Ruby会議11と同時開催しました。東京Ruby会議11でポスターを展示していたので、そこではじめて知ったという方も多かったのではないでしょうか。

OSS GateおよびOSS Gateワークショップを紹介していて痛感したことは「口頭での説明でないとちゃんと伝えられない」ということでした。たとえば、「私はすごくないのでメンターできないです。。。」に対して口頭では伝わるように回答できますが、資料では伝わらないです。これだとスケールしないので口頭での説明でなくても伝わる資料の作成が必要です。

ポスターを展示しての紹介は想像以上に効果があり、OSS Gateワークショップ2016-07-30の登録者が10人以上増えました。東京Ruby会議11実行委員会に協力してもらえて本当によかったです。ありがとうございました。

ちなみに、想像以上に登録者が増えたおかげでメンターが足りなそうです。OSS開発に参加したことがある人(技術力不問)でOSSの開発に参加する人が増えるといいなぁと思う人は「メンター」としてOSS Gateワークショップ2016-07-30に登録してください。すでに定員オーバーでキャンセル待ちでの登録になりますが、気にしないでください。キャンセル待ち扱いでも気にせずに当日来てください。入れます。

まとめ

5月28日に開催された東京Ruby会議11での発表とOSS Gateワークショップ2016-05-28について補足しました。

東京Ruby会議11の参加者アンケートはまだ回答を受け付けているので参加した方はぜひ回答してください。

2016-06-01

2008|05|06|07|08|09|10|11|12|
2009|01|02|03|04|05|06|07|08|09|10|11|12|
2010|01|02|03|04|05|06|07|08|09|10|11|12|
2011|01|02|03|04|05|06|07|08|09|10|11|12|
2012|01|02|03|04|05|06|07|08|09|10|11|12|
2013|01|02|03|04|05|06|07|08|09|10|11|12|
2014|01|02|03|04|05|06|07|08|09|10|11|12|
2015|01|02|03|04|05|06|07|08|09|10|11|12|
2016|01|02|03|04|05|06|07|08|09|10|11|12|
2017|01|02|03|
タグ:
RubyKaigi 2015 sponsor RubyKaigi 2015 speaker RubyKaigi 2015 committer RubyKaigi 2014 official-sponsor RubyKaigi 2014 speaker RubyKaigi 2014 committer RubyKaigi 2013 OfficialSponsor RubyKaigi 2013 Speaker RubyKaigi 2013 Committer SapporoRubyKaigi 2012 OfficialSponsor SapporoRubyKaigi 2012 Speaker RubyKaigi2010 Sponsor RubyKaigi2010 Speaker RubyKaigi2010 Committer badge_speaker.gif RubyKaigi2010 Sponsor RubyKaigi2010 Speaker RubyKaigi2010 Committer
SapporoRubyKaigi02Sponsor
SapporoRubyKaigi02Speaker
RubyKaigi2009Sponsor
RubyKaigi2009Speaker
RubyKaigi2008Speaker