PostgreSQLとPGroongaでn作るnPHPマニュアルn高速全文検索システム

: author

須藤功平

: institution

クリアコード

: content-source

PHPカンファレンス2017

: date

2017-10-08

: start-time

2017-10-08T11:00:00+09:00

: end-time

2017-10-08T12:00:00+09:00

: theme

.

PHPマニュアル

# image
# src = images/php-manual-ja.png
# relative_width = 80

(('tag:center')) php.net/manual/ja/

@で検索

# image
# src = images/php-manual-ja-search-by-at.png
# relative_width = 80

(('tag:center')) @:エラー制御演算子

@でnot found

# image
# src = images/php-manual-ja-search-by-at-not-found.png
# relative_width = 80

(('tag:center')) 関数・クラス・例外のみ検索対象

全文検索あり!

# image
# src = images/php-manual-ja-full-text-search-link.png
# relative_height = 80

(('tag:center')) Googleカスタム検索

@でnot found

# image
# src = images/php-manual-ja-full-text-search-by-at-not-found.png
# relative_width = 80

(('tag:center')) Googleは自然言語向けだから

マニュアル検索

* 自然言語向けと傾向が違う
  * @:自然言語ではノイズ\n
    (('note:特に日本語ではノイズ'))
  * @:マニュアルでは重要語
* プログラミング言語用の\n
  チューニングが必要
  * 🔍欲しい情報が見つかる!

マニュアルn検索システムのn作り方

PHPn+nPostgreSQL

PostgreSQLと全文検索

* LIKE:組込機能
* textsearch:組込機能
* pg_trgm:標準添付
  * アーカイブには含まれている
  * 別途インストールすれば使える

LIKE

* 少ないデータ
  * 十分実用的
  * 400文字×20万件くらいなら1秒とか
* 少なくないデータ
  * 性能問題アリ

PHPマニュアルのデータ

# RT

件数, 13095
平均文字数, 871文字

PHPマニュアルでLIKE

* 👍速度は十分実用的
  * (({LIKE '%@%'}))で約100ms
* 👎それっぽい順のソート不可
  * 全文検索ではソート順が重要
  * ユーザーは先頭n件しか見ない

textsearch

* インデックスを作るので速い
* 言語毎にモジュールが必要
  * 英語やフランス語などは組込
  * 日本語は別途必要
* 日本語用モジュール
  * 公式にはメンテナンスされていない\n
    (('note:forkして動くようにしている人はいる'))

pg_trgm

* インデックスを作るので速い
  * 注:ヒット件数が増えると遅い
  * 注:テキスト量が多いと遅い
  * 注:1,2文字の検索は遅い(('note:(米・日本)'))

* 日本語を使うにはひと工夫必要
  * C.UTF-8を使う
  * ソースを変更してビルド

プラグイン

* pg_bigm
  * pg_trgmの日本語対応強化版
  * それっぽい順のソート不可
* PGroonga
  * 本気の全文検索エンジンを利用
  * 速いし日本語もバッチリ!
  * それっぽい順のソート可

ベンチマーク:pg_bigm

# image
# src = images/search-pg-bigm.pdf
# relative_height = 100

ベンチマーク:PGroonga

# image
# src = images/search-pgroonga-pg-bigm.pdf
# relative_height = 100

PostgreSQLで全文検索システム

* PostgreSQLで全文検索
  * PGroongaがベスト!💯
* PGroonga
  * 高速
  * 日本語対応
  * それっぽい順でソート可

PHP document search

# image
# src = images/php-document-search.png
# relative_height = 80

(('tag:center')) PHP + PostgreSQL + PGroonga

基本機能

* 高速全文検索+ソート
* 検索キーワードハイライト
* キーワード周辺テキスト表示

高度な機能

* オートコンプリート
  * ローマ字対応(seiki→正規表現)
* 類似マニュアル検索
* 同義語展開
  * 「@」→「@ OR エラー制御演算子」

作り方:ツール

* フレームワーク
  * Laravel
* RDBMS
  * PostgreSQL
* 高速日本語全文検索機能
  * PGroonga

作り方:インストール

* Laravel
  * 省略
* PostgreSQL
  * パッケージで
* PGroonga
  * パッケージで\n
    (('tag:x-small:https://pgroonga.github.io/ja/install/'))

初期化:Laravel

# coderay console

% laravel new php-document-search
% cd php-document-search
% editor .env

初期化:データベース

# coderay console

% sudo -u postgres -H \
    createdb php_document_search

初期化:PGroonga

# rouge sql
-- ↓を実行する必要がある
CREATE EXTENSION pgroonga;

初期化:PGroonga

(('tag:center')) マイグレーションファイル作成

# coderay console

% php artisan \
    make:migration enable_pgroonga

マイグレーション

# coderay php

public function up()
{
  DB::statement("CREATE EXTENSION pgroonga;");
  // CREATE EXTENSION IF NOT EXISTS ...の方がよい
}

public function down()
{
  DB::statement("DROP EXTENSION pgroonga;");
}

モデル作成

* マニュアルをモデルにする
  * 名前:(({Entry}))
  * 1ページ1インスタンス

モデル作成

# coderay console

% php artisan \
    make:model \
      --migration \
      --controller \
      --resource \
      Entry

マイグレーション

# coderay php

Schema::create("entries", function ($table) {
  $table->increments("id");
  $table->text("url");
  $table->text("title");
  $table->text("content");
  // PGroongaインデックス(デフォルト:全文検索用)
  // 主キー(id)も入れるのが大事!
  // それっぽい順のソートで必要
  $table->index(
     ["id", "title", "content"], null, "pgroonga");
});

データ登録

(1) PHPのマニュアルを\n
    ローカルで生成
    * PHPのマニュアルの作り方\n
      http://doc.php.net/tutorial/
    * フィードバックチャンスが\n
      いろいろあったよ!
(2) ページ毎にPostgreSQLに挿入

コマンド作成

# coderay console

% php artisan \
    make:command \
      --command=doc:register \
      RegisterDocuments

登録コマンド実装(一部)

# coderay php

public function handle()
{
  foreach (glob("public/doc/*.html") as $html_path) {
    $document = new \DOMDocument();
    @$document->loadHTMLFile($html_path);
    $xpath = new \DOMXPath($document);
    $entry = new Entry();
    $entry->url = "/doc/" . basename($html_path);
    // XPathでテキスト抽出(詳細はソースを参照)
    $this->extract_title($entry, $xpath);
    $this->extract_content($entry, $xpath);
    $entry->save();
  }
}

登録

# coderay console

% php artisan doc:register

検索用コントローラー

# coderay php

public function index(Request $request)
{
  $query = $request["query"];
  $entries = Entry::query()
    // ↓はモデルに作る(後述)
    ->fullTextSearch($query)
    ->limit(10)
    ->get();
  return view("entry.search.index",
              ["entries" => $entries,
               "query"   => $query]);
}

検索対象モデル

# coderay php

public function
  scopeFullTextSearch($query, $search_query)
{
  if ($search_query) {
    return ...; // クエリーがあったら検索
  } else {
    return ...; // なかったら適当に返す(省略)
  }
}

検索対象モデル:検索

# coderay php

return $query
  ->select("id", "url")
  // それっぽさの度合い
  ->selectRaw("pgroonga_score(entries) AS score")
  // キーワードハイライト
  ->highlightHTML("title", $search_query)
  // キーワード周辺のテキスト(キーワードハイライト付き)
  ->snippetHTML("content", $search_query)
  // タイトルと本文を全文検索(タイトルの方が重要)
  ->whereRaw("title &@~ ? OR content &@~ ?",
             [">($search_query)", $search_query])
  // それっぽい順に返す
  ->orderBy("score", "DESC");

キーワードハイライト

# coderay php

public function scopeHighlightHTML($query,
                                   $column,
                                   $search_query)
{
  return $query
    // PGroonga提供ハイライト関数
    ->selectRaw("pgroonga_highlight_html($column, " .
    // PGroonga提供クエリーからキーワードを抽出する関数
                "pgroonga_query_extract_keywords(?)) " .
                "AS highlighted_$column",
                [$search_query]);
}

検索結果

# coderay php

<div class="entries">
  @foreach ($entries as $entry)
    <a href="{{ $entry->url }}">
      <h4>
        {{-- マークアップ済み! --}}
        {!! $entry->highlighted_title !!}
        <span class="score">{{ $entry->score }}</span>
      </h4>
      {{-- 周辺テキストはtext[](後で補足) --}}
      @foreach ($entry->content_snippets as $snippet)
        <pre class="snippet">{!! $snippet !!}</pre>
      @endforeach
    </a>
  @endforeach
</div>

検索対象モデル:配列

# coderay php

public function getContentSnippetsAttribute($value)
{ // PostgreSQLは配列をサポートしているがPDOは未サポート
  // '["...","..."]'という文字列になるのでそれを配列に変換
  // ※これは回避策なのでPDOに配列サポートを入れたい!
  return array_map(
    function ($e) {
      // 「"」が「\"」になっているので戻す
      return preg_replace('/\\\\(.)/', '$1', $e);
    },
    explode('","', substr($value, 2, -2)));
}

高速日本語全文検索!

# image
# src = images/php-document-search-search.png
# relative_height = 100

オートコンプリート

* 必要なもの
  * 候補用テーブル
  * 候補のヨミガナ(カタカナ)
  * PGroonga!!!

モデル作成

# coderay console

% php artisan \
    make:model \
      --migration \
      --controller \
      --resource \
      Term

マイグレーション:カラム

# coderay php

Schema::create("terms", function ($table) {
  $table->increments("id");
  $table->text("term");
  $table->text("label");
  $table->text("reading"); // 本当は配列にしたい
  $table->timestamps();
  // インデックス定義(後述)
});

マイグレーションnインデックス1

# coderay php

$table->index([
  // ヨミガナに対する前方一致RK検索用
  // RK:ローマ字・カナ(後述)
  DB::raw("reading " .
    "pgroonga_text_term_search_ops_v2"),
], null, "pgroonga");

マイグレーションnインデックス2

# coderay php

// 候補に対するゆるい全文検索用(中間一致用)
DB::statement(
  "CREATE INDEX terms_term_index " .
  "ON terms " .
  "USING pgroonga (term) " .
  // ↓がポイント
  // ※LaravelがWITHを未サポートなのでSQLで書いている
  // ※回避策なのでLaravelにWITHサポートを入れたい!
  "WITH (tokenizer='TokenBigramSplitSymbolAlphaDigit')");

データ登録

(1) マニュアルから候補を抽出\n
    (('note:(手動作成はコスト高すぎ)'))
    * 例:名詞は候補
    * 例:連続した名詞→1つの候補に
(2) ヨミガナも自動で推測\n
    (('note:(MeCabを利用)'))
(3) 候補をPostgreSQLに挿入

コマンド作成

# coderay console

% php artisan \
    make:command \
      --command=terms:register \
      RegisterTerms

登録コマンド実装(一部)

# coderay php

public function handle()
{
  foreach (Entry::query()->cursor() as $entry) {
    // processTextの実装はソースを参照
    // タイトルから抽出
    $this->processText($entry->title);
    // 本文から抽出
    $this->processText($entry->content);
  }
}

登録

# coderay console

% php artisan terms:register

前方一致RK検索

* 日本語特化の前方一致検索
  * ローマ字・ひらがな・カタカナで\n
    カタカナを前方一致検索できる
  * gy→ギュウニュウ
  * ぎ→ギュウニュウ
  * ギ→ギュウニュウ

候補モデル:検索

# coderay php

public function scopeComplete($query, $search_query)
{
  return $query
    ->select("label")
    ->highlightHTML("label", $search_query)
               // 前方一致RK検索
    ->whereRaw("reading &^~ :query OR " .
               // ゆるい全文検索
               "term &@~ :query",
               ["query" => $search_query])
    ->orderBy("label")
    ->limit(10);
}

コントローラー

# coderay php

public function index(Request $request)
{
  $query = $request["query"];
  // モデルに実装した検索処理を呼び出し
  $terms = Term::query()->complete($query);
  $data = [];
  foreach ($terms->get() as $term) {
    $data[] = [
      "value" => $term->label,
      "label" => $term->highlighted_label,
    ];
  }
  // JSONで候補を返す
  return response()->json($data);
}

UI

# coderay javascript

$("#query").autocomplete({
  source: function(request, response) {
    $.ajax({
      url: "/terms/", // コントローラー呼び出し
      dataType: "json",
      data: {query: this.term},
      success: response
    });
  }
}).autocomplete("instance")._renderItem = function(ul, item) {
  return $("<li>")
    .attr("data-value", item.value) // 候補には生データを使う
    .append(item.label) // ハイライトしたデータを表示
    .appendTo(ul);
};

オートコンプリート!

# image
# src = images/php-document-search.png
# relative_height = 100

類似マニュアル検索

# image
# src = images/php-document-search-similar-search.png
# relative_height = 100

実現方法

* 類似検索用インデックスが必要
  * 自然言語に合わせた処理で精度向上
  * 日本語ならMeCabを活用
* 類似検索用の演算子を使う
  * 類似検索クエリー\n
    →対象マニュアルのテキスト全体
  * 参考:全文検索クエリー\n
    →キーワード

インデックス作成

(('tag:center')) マイグレーションファイル作成

# coderay console

% php artisan \
    make:migration \
      add_similar_search_index

マイグレーション

# coderay php

public function up()
{ // WITHを使っているのでDB::statementを使用
  DB::statement(
    "CREATE INDEX similar_search_index " .
    "ON entries " .
    // タイトルと内容を合わせたテキストをインデックス
    // 理由1:タイトルも重要→対象に加えて精度向上
    // 理由2:PostgreSQLが全文検索インデックスと
    //        区別できるように
    "USING pgroonga (id, (title || ' ' || content)) " .
    // ポイント:MeCabを使う
    "WITH (tokenizer='TokenMecab')");
}

類似検索:スコープ

# coderay php

public function scopeSimilarSearch($query, $text)
{
  return $query
    ->select("id", "url", "title")
    ->selectRaw("pgroonga_score(entries) AS score")
    // インデックス定義と同じ式↓を指定すること!
    //   title || ' ' || content
    // &@*が類似検索の演算子
    ->whereRaw("(title || ' ' || content) &@* ?",
               [$text])
    ->orderBy("score", "DESC");
}

類似検索:インスタンスメソッド

# coderay php

public function similarEntries()
{
  return Entries::query()
    // タイトルと内容がクエリー
    ->similarSearch("{$this->title} {$this->content}")
    // 自分自身を除くこと!
    ->where("id", "<>", $this->id)
    // 最も類似している3件のみ取得
    ->limit(3)
    ->get();
}

類似検索:使い方

# coderay php

@foreach ($entries as $entry)
  <ol> {{-- ↓マニュアル毎に類似文書検索 --}}
  @foreach ($entry->similarEntries() as $similarEntry)
    <li>
      <a href="{{ $similarEntry->url }}">
        {{ $similarEntry->title }}
        <span class="score">{{ $similarEntry->score }}</span>
      </a>
    </li>
  @endforeach
  </ol>
@endforeach

類似マニュアル検索

# image
# src = images/php-document-search-similar-search.png
# relative_height = 100

同義語展開

# image
# src = images/php-document-search-synonym.png
# relative_height = 100

実現方法

* 同義語管理テーブルを作成
* 同義語を登録
* 同義語を展開して検索

モデル作成

# coderay console

% php artisan \
    make:model \
      --migration \
      --controller \
      --resource \
      Synonym

マイグレーション:カラム

# coderay php

public function up() {
  Schema::create('synonyms', function ($table) {
    $table->increments('id');
    $table->text('term');     // 展開対象の語
    $table->text('synonym');  // 展開後の語
    // 例:term: @, synonym: @
    // 例:term: @, synonym: エラー制御演算子
    //     「@」→「@ OR エラー制御演算子」
    $table->timestamps();
    // インデックス定義(後述)
  });
}

マイグレーションnインデックス

# coderay php

$table->index(
  // termで完全一致できるようにする設定
  [DB::raw(
    "term pgroonga_text_term_search_ops_v2")],
  null,
  "pgroonga");

同義語登録

(('tag:center')) シーダー作成

# coderay console

% php artisan \
    make:seeder \
      SynonymsTableSeeder

シーダー

# coderay php

public function run()
{
  $synonyms = [
// @そのもので検索させないならこれはいらない
    ["term" => "@", "synonym" => "@"],
    ["term" => "@",
     // synonymでは演算子を使える
     // ">": 重要度を上げる演算子
     "synonym" => ">エラー制御演算子"],
  ];
  DB::table("synonyms")->insert($synonyms);
}

動作確認

# coderay sql

SELECT pgroonga_query_expand(
  'synonyms', -- テーブル名
  'term', -- 展開対象語のカラム名
  'synonym', -- 展開後の語のカラム名
  '@'); -- 展開対象のクエリー
--     pgroonga_query_expand     
-- ------------------------------
--  ((@) OR (>エラー制御演算子))
-- (1 row)

検索

# coderay php

whereRaw("title &@~ ? OR content &@~ ?",
         [">({$search_query})", $search_query]);
// ↓クエリーをpgroonga_query_expand()で展開して利用
whereRaw(
  "title &@~ pgroonga_query_expand(?, ?, ?, ?) OR " .
  "content &@~ pgroonga_query_expand(?, ?, ?, ?)",
  ["synonyms", "term", "synonym", ">({$search_query})",
   "synonyms", "term", "synonym", $search_query]);

同義語展開

# image
# src = images/php-document-search-synonym.png
# relative_height = 100

おさらい:基本機能

* 高速全文検索+ソート
* 検索キーワードハイライト
* キーワード周辺テキスト表示

おさらい:高度な機能

* オートコンプリート
  * ローマ字対応(seiki→正規表現)
* 類似マニュアル検索
* 同義語展開
  * 「@」→「@ OR エラー制御演算子」

開発者募集!

* 公式検索システム置き換え!?
  * 必要そう:複数バージョン対応
  * 必要そう:複数言語対応
* マニュアルをさらによく!
  * 検索を便利に!→ユーザー増加!
  * →フィードバックする人も増加!

使いたい!

* WEICさんが運用予定!
  * 2017年10月中にリリース予定
  * URL: http://phpdocs.weic.co.jp/
* 宣伝(('note:(運用スポンサーの宣伝枠)'))
  * PHPエンジニア大募集!
  * 業務時間内にこれの開発もできる!?

PHP document search情報

* ソース
  * (('tag:x-small'))https://github.com/kou/php-document-search
* OSS
  * MITライセンス

まとめ

* PostgreSQL + PGroonga
  * 高速日本語全文検索サービスを\n
    ((*PHP*))で簡単に作れる!
* 開発者募集!
* サービス提供予定 by WEIC!
  * URL: http://phpdocs.weic.co.jp/

MySQLでもできる?

* MySQL・PostgreSQLだけで作る\n
  高速でリッチな\n
  全文検索システム
  * db tech showcase Tokyo 2017の資料
  * PGroongaの代わりにMroongaを使う
  * SQLでの書き方だけでPHPの話はない

(('tag:center')) (('tag:xx-small')) slide.rabbit-shocker.org/authors/kou/db-tech-showcase-tokyo-2017/

PHP+MySQL+Mroonga入門

* Groongaではじめる全文検索
  * https://grnbook-ja.tumblr.com/
  * 著者:北市真
  * PHP+Mroonga入門の電子書籍
  * 今はまだ無料!

PHPの開発へ参加!

* PHPの開発に参加しませんか?
  * 例:PDO/LaravelのPostgreSQL関連
  * 例:マニュアル生成まわり
  * 例:PHP document search関連
* (('wait'))やりたいけど自分はムリそう…
  * そんなことはないんですよ!

OSS Gate

* OSS Gate
  * OSS開発者を増やす取り組み
* OSS Gateワークショップ
  * OSS開発未経験者を経験者にする\n
    ワークショップ
  * PHPもOSS!

ワークショップ

* このカンファレンス内で開催!
  * 午後にこの部屋で開催(('note:(2時間45分)'))
  * 参加希望者は私に声をかけて!
* PHP関連のOSSの開発に\n
  参加する人を増やそう!
  * 今回だけで終わりにしないで\n
    ((*今回を始まりにしたい!*))