作者別: fukui

Solrで日本語版Wikipediaのインデックスを作った場合のサイズをTokenizerの設定毎に調べる

はじめに

Solrで日本語の文書を扱う場合に考えなくてはならないことの一つに、Tokenizerをどれにするか、があります。ざっくり言うと、n-gram(だいたいは2-gram)にするか、形態素解析にするか、です。
それぞれの長所、短所はこんな感じです。

n-gram

長所

  • 検索漏れが少ない
  • 未知語に強い

短所

  • nより短いキーワードを扱えない
  • インデックスサイズが大きくなる
  • 検索結果のノイズが多い

形態素解析

長所

  • 検索結果のノイズが少ない
  • インデックスサイズを小さくする余地が大きい

短所

  • 未知語に弱い
  • 入力キーワードに対する完全一致検索が難しい

双方の長所・短所に出てきたインデックスサイズがどれくらいの差になるのかを日本語版Wikipediaを対象に調べてみました。

SolrにWikipediaを投入する

Wikipediaのダンプデータをダウンロード

https://dumps.wikimedia.org/jawiki/
の latest から jawiki-latest-pages-articles.xml.bz2 をダウンロードして展開しておきます。

configset準備

_default をベースに設定ファイルを用意します。

cp -r server/solr/configsets/_default server/solr/configsets/wikipedia 

wikipedia/conf/solrconfig.xml に以下を追加

<lib dir="${solr.install.dir:../../../..}/dist/" regex="solr-dataimporthandler-.*\.jar" />

<requestHandler name="/dataimport" class="solr.DataImportHandler">
  <lst name="defaults">
    <str name="config">solr-data-config.xml</str>
  </lst>
</requestHandler>

Wikppediaインポート用の DataImportHander の設定 solr-data-config.xml を
https://wiki.apache.org/solr/DataImportHandler#Example:_Indexing_wikipedia
を参考にして作成

<dataConfig>
  <dataSource type="FileDataSource" encoding="UTF-8" />
  <document>
    <entity name="page"
                processor="XPathEntityProcessor"
                stream="true"
                forEach="/mediawiki/page/"
                url="data/jawiki-latest-pages-articles.xml"
                transformer="RegexTransformer,DateFormatTransformer">
      <field column="id"        xpath="/mediawiki/page/id" />
      <field column="title"     xpath="/mediawiki/page/title" />
      <field column="revision"  xpath="/mediawiki/page/revision/id" />
      <field column="user"      xpath="/mediawiki/page/revision/contributor/username" />
      <field column="userId"    xpath="/mediawiki/page/revision/contributor/id" />
      <field column="text"      xpath="/mediawiki/page/revision/text" />
      <field column="timestamp" xpath="/mediawiki/page/revision/timestamp" dateTimeFormat="yyyy-MM-dd'T'hh:mm:ss'Z'" />
      <field column="$skipDoc"  regex="^#REDIRECT .*" replaceWith="true" sourceColName="text"/>
    </entity>
  </document>
</dataConfig>

configsetをアップロード

(cd server/solr/configsets/wikipedia/conf && zip -r - *) |curl -X POST --header "Content-Type:application/octet-stream" --data-binary @- "http://localhost:8983/solr/admin/configs?action=UPLOAD&name=wikipedia"

コレクション”wikipedia”を作成

curl 'http://localhost:8983/solr/admin/collections?action=CREATE&name=wikipedia&numShards=1&replicationFactor=1&collection.configName=wikipedia&wt=xml'

インポート開始

管理画面からインポート開始

各Tokenizerの設定

以下の4種類の設定で試してみます。
今回は手っ取り早く上記の手順の「configsetの準備」のところで managed-schema ファイルを編集しています。

CJK bigram

managed-schema の変更箇所

(略)
<pre>
    <fieldType name="text_cjk" class="solr.TextField" positionIncrementGap="100">
      <analyzer>
        <tokenizer class="solr.StandardTokenizerFactory"/>
        <!-- normalize width before bigram, as e.g. half-width dakuten combine  -->
        <filter class="solr.CJKWidthFilterFactory"/>
        <!-- for any non-CJK -->
        <filter class="solr.LowerCaseFilterFactory"/>
        <filter class="solr.CJKBigramFilterFactory"/>
      </analyzer>
    </fieldType>
</pre>
(略)
<field name="title"     type="string"  indexed="true" stored="false"/>
<field name="revision"  type="pint"    indexed="true" stored="true"/>
<field name="user"      type="string"  indexed="true" stored="true"/>
<field name="userId"    type="pint"     indexed="true" stored="true"/>
<field name="text"      type="text_cjk"    indexed="true" stored="false"/>
<field name="timestamp" type="pdate"    indexed="true" stored="true"/>
(略)

Kuromoji (mode=normal)

辞書通りの分割
(例) 株式会社→「株式会社」

managed-schema の変更箇所

(略)
<pre>
    <fieldType name="text_ja" class="solr.TextField" positionIncrementGap="100" autoGeneratePhraseQueries="false">
      <analyzer>
        <tokenizer class="solr.JapaneseTokenizerFactory" mode="normal"/>
        <filter class="solr.JapaneseBaseFormFilterFactory"/>
        <filter class="solr.JapanesePartOfSpeechStopFilterFactory" tags="lang/stoptags_ja.txt" />
        <filter class="solr.CJKWidthFilterFactory"/>
        <filter class="solr.StopFilterFactory" ignoreCase="true" words="lang/stopwords_ja.txt" />
        <filter class="solr.JapaneseKatakanaStemFilterFactory" minimumLength="4"/>
        <filter class="solr.LowerCaseFilterFactory"/>
      </analyzer>
    </fieldType>
</pre>
(略)
<field name="title"     type="string"  indexed="true" stored="false"/>
<field name="revision"  type="pint"    indexed="true" stored="true"/>
<field name="user"      type="string"  indexed="true" stored="true"/>
<field name="userId"    type="pint"     indexed="true" stored="true"/>
<field name="text"      type="text_ja"    indexed="true" stored="false"/>
<field name="timestamp" type="pdate"    indexed="true" stored="true"/>
(略)

Kuromoji (mode=search)

複合語を細かく分割
株式会社→「株式」「会社」

managed-schema の変更箇所

        <tokenizer class="solr.JapaneseTokenizerFactory" mode="search"/>

それ以外は mode=normal と同じ。

Kuromoji (mode=extended)

mode=search + 未知語を 1-gram に分割

managed-schema の変更箇所

        <tokenizer class="solr.JapaneseTokenizerFactory" mode="extended"/>

それ以外は mode=normal と同じ。

結果

インデックスサイズ生成時間
CJK5.9GB35分
Kuromoji(normal)3.1GB76分
Kuromoji(search)3.1GB83分
Kuromoji(extended)2.1GB79分

ちなみに、日本語版Wikipedia全記事のテキスト部分のサイズをカウントしてみたところ、約1.4GBでした。

まとめ

Solrで日本語版Wikipedia全記事のインデックスを作成しました。2-gramのインデックスサイズは形態素解析インデックスの約2倍になりました。

Kuromoji(extended)は未知語を1-gramに分割する分他のモードよりもインデックスサイズが大きくなると予想していたのですが、逆に30%強も小さくなりました。ここはもうちょっと調べてみる必要がありそうです。


Apache Solr を Eclipse でリモートでデバッグ

はじめに

先日、久しぶりに全文検索エンジン Apache Solr に触れる機会がありました。
現時点の最新版は 7.5 です(この記事を書いている間に 7.6 がリリースされました)。以前に扱ったことのある 5.5 からはかなり色々なところが変わっているようです。

Solr は OSS なので変更点の詳細を追いかけたければソースを読めばいいのですが、Solr くらいの規模のソフトウェアとなるとソースを読むだけでは取っ掛かりが掴みにくいことも有ります。たとえば、この factory オブジェクトが生成したのはどの具象クラスなんだ、とか、この if 文の分岐はどっちが使われるんだ、とか。そういう場合にはデバッガが役に立ちます。
今回は以下のような構成でデバッガを動かすための手順をまとめました。

  • Solr 7.5 バイナリパッケージ(稼働用)
  • Solr 7.5 ソースパッケージ(デバッガ参照用)
  • Eclipse IDE for Java Developers Version 2018-09

Solr のインストール

  1. 公式サイトからsolr-7.5.0.tgzをダウンロード。
  2. 展開
  3. $ tar zxf solr-7.5.0.tgz
    $ cd solr-7.5.0
    
  4. サンプル設定で起動
    $ bin/solr -e cloud
    (略)
    To begin, how many Solr nodes would you like to run in your local cluster? (specify 1-4 nodes) [2]: 1
    (略)
    Please enter the port for node1 [8983]:
    (略)
    Please provide a name for your new collection: [gettingstarted] 
    test
    (略)
    How many shards would you like to split test into? [2]
    1
    (略)
    How many replicas per shard would you like to create? [2] 
    1
    (略)
    Please choose a configuration for the test collection, available options are:
    _default or sample_techproducts_configs [_default] 
    (略)
    
  5. Solr を一旦停止しておく
    $ bin/solr stop -all
    

Solr のソースを Eclipse にインポート

  1. 公式サイトからsolr-7.5.0-src.tgzをダウンロード。
  2. 展開
    $ mkdir solr-src
    $ cd solr-src
    $ tar zxf solr-7.5.0-src.tgz
    $ mv solr-7.5.0 solr-7.5.0-src
    $ cd solr-7.5.0-src
    
  3. Eclipse のプロジェクトとして読み込めるようにビルド
    $ ant eclipse
    
  4. インポート
    1. 「ファイル」→「インポート」→「既存プロジェクトをワークスペースへ」→「次へ」
    2. 「ルート・ディレクトリの選択」で solr-src/solr-7.5.0-src を指定→「完了」

デバッグ開始

  1. Solr スタート
  2. リモートプロセスのデバッグなので java コマンドのオプションを指定して JDWP を利用します。

    $ bin/solr start -c -p 8983 -s example/cloud/node1/solr -a "-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=6900"
    

    suspend=y を指定するとデバッガが接続するまで実行を中断してくれます。ただし180以内に起動が完了しないと Solr が起動失敗と判断して自らプロセス終了してしまうので、それまでに以下の手順でデバッガを接続しなければなりません。

  3. デバッガ起動
    1. 「実行」→「デバッグの構成」
    2. プロジェクト solr-7.5.0 指定→ポート 6900 指定→「デバッグ」

動作確認

今回は Solr 6 で追加された ExtractingRequestHandler を試してみます。
このハンドラは PDF などのバイナリファイルからテキストを抽出してインデックスを作成するためのものです。solrconfig.xml では以下のように定義されています。


  <requestHandler name="/update/extract"
                  startup="lazy"
                  class="solr.extraction.ExtractingRequestHandler" >
    <lst name="defaults">
      <str name="lowernames">true</str>
      <str name="fmap.meta">ignored_</str>
      <str name="fmap.content">_text_</str>
    </lst>
  </requestHandler>

RequestHandler のメインの処理は handleRequestBody ですが、これは ExtractingRequestHandler の親クラスである ContentStreamHandlerBase クラスで定義されているので、そちらにブレイクポイントを設定しておきます。

そして PDF ファイルを post コマンドで送信します。

$ bin/post -c test -params "extractOnly=false&wt=json&indent=true" -out yes example/exampledocs/solr-word.pdf

指定しておいた場所でブレイクされます。

あとは普通にデバッガを使っていくだけです。

たとえば ExtractingDocumentLoader のこの行で変数の内容を確認すると、
実行時パラメータとして extractOnly=false だけを指定した状態では parser として AutoDetectParser、parsingHandler として SolrContentHandler が使われることが分かりました。
metadata としてどんな情報が抽出されるのかも良く分かります。

最後に

Solrのリモートデバッグは簡単です。Solr 内部の理解を深めるのに役立てたいと思います。


PDF をページ単位でインデックスする Solr の RequestHandlerを作成する

はじめに

Solr 6以降では PDF やワードなどのバイナリファイルをインデックスする機能(ExtractingRequstHandler)がサポートされています。
ファイル内に含まれるテキストをまとめて1つの文書として、メタデータ(作成日時、作成者等)と共にインデックスを作成してくれるのでこれはとても便利な機能ではありますが、用途によってはキーワードが何ページ目にヒットするのかを知りたいこともあります。そこで、自前の RequestHandler 作成の練習として PDF をページ単位でインデックスする ReqestHandler を作成してみました。

Apache Tika

Solr では PDF 等の各種フォーマットを扱うために Apache Tika を利用しています。
Tika では PDF 等を XHTML に変換した上で SAX パーサーにコンテンツハンドラを渡して XHML の要素毎の処理を実行させられます。それと同時にファイルに含まれるメタデータが Metadata クラスのオブジェクトに格納されます。
従って、Tika の呼び出し側は

  • ファイルに含まれる構造化コンテンツが XHTML に変換されたもの
  • ファイルのメタデータ

を扱うことができます。

Tika によって PDF から変換された XHTML がどんなフォーマットになっているかは、extractOnly=true オプションを指定することで見ることができます。

bin/post -c test -params "extractOnly=true&wt=json&indent=true" -out yes /tmp/test.pdf

XHTML の body 部分だけを抜粋すると以下のようになっています。

<body>
<div class="page">
<p/>
<p>テストドキュメント1ページ目です。</p>
<p/>
</div>
<div class="page">
<p/>
<p>2ページ目の文章です。</p>
<p/>
</div>
<div class="page">
<p/>
<p>これは3ページ目です。</p>
<p/>
</div>
</body>
</html>

ページ毎に

<div class="page">

に囲まれた構造になっていることが分かります。

ExtractingRequestHandler には capture という実行時パラメータがあり、これを指定することで特定の XHTML 要素(この場合div)を個別にインデックスすることができるのですが

  • 文書IDはファイル全体で共通
  • 同じ名前の要素が複数存在する場合は multivalued のフィールドに入れられる

という仕様のためページ番号との紐付けができそうもなかったのが、今回自前で実装してみようと思ったきっかけでした。

SolrがPDFを扱う仕組み

ExtractingRequestHander では以下のクラス構成で PDF 等のインデックス処理を実行しています。

  • ExtractingRequestHander
    リクエストのエントリポイント(/solr/update/extract)。

    • 設定の読み込み
    • Tika のパーサーが生成する SAX イベントを処理するコンテンツハンドラ(SolrContentHandler)用のファクトリ(SolrContentHandlerFactory)を生成
    • ExtractingDocumentLoader を生成して load メソッド(ファイル読み込みとインデックス処理のメイン)を実行
  • ExtractingDocumentLoader
    実際にファイルを読み込んでインデックス処理を実行。

    • メタデータ読み込み
    • コンテンツの種類に対応したパーサーを生成
    • ExtractingRequestHander から与えられたファクトリ(SolrContentHandlerFactory)を使ってコンテンツハンドラ(SolrContentHandler)オブジェクトを生成
    • パーサーにコンテンツハンドラ(SolrContentHandler)を与えてパース処理を実行
    • 生成されたメタデータオブジェクトとコンテンツ文字列をインデックスに投入
  • SolrContentHandler
    Tika のパーサーが生成する SAX イベントを処理する。
    基本的に XHTML に含まれるコンテンツ部分の文字列を結合して1つの大きな文字列を作っている。
  • SolrContentHandlerFactory
    SolrContentHandler のファクトリメソッドを提供する。

ページ単位のインデックス処理を実装

上記の4クラスをそれぞれ継承したクラスを実装しました。(コードはこの記事の最後に)
実装の内容は以下の通りです。

  • 設定の読み込みは親クラスに任せる
  • 具象クラスの生成処理は上書き
  • SolrContentHandler のサブクラスで
    <div class="page">
    

    を認識してページ番号とページ毎のコンテンツとが対応付けされたテーブルを作成

  • ExtractingDocumentLoader のサブクラスでページ毎に文書IDを振ってインデックス投入

利用方法

  1. 4クラスをコンパイルして Jar ファイルを生成
  2. contrib/extraction/lib に Jar ファイルをコピー
  3. solrconfig.xml の
    <requestHandler name="/update/extract"
                      startup="lazy"
                      class="solr.extraction.ExtractingRequestHandler" >
    

    の箇所を jp.co.splout.solr.plugin.MyExtractingRequestHandler に変更

  4. Solr 再起動

実行例

投入

$ bin/post -c test -params "extractOnly=false&wt=json&indent=true&literal.id=testpdf1" -out yes test.pdf

検索結果

最後に

PDF をページ単位でインデックスする Solr のプラグインを作成しました。
PoC ということで最小限の実装しかしていませんが、ちゃんとするなら

  • せっかくなのでメタデータも有効に使いたい
  • ExtractingRequestHandler の実行時パラメータで共用できるものは共用したい
  • もっというなら ExtractingRequestHandler の1機能として統合?

ということも考えたいと思います。

コード


停電の一夜をなんとか乗り切った話

2018年9月4日に西日本を直撃した台風のため、住んでいるマンションがほぼまる1日停電してしまいました。
その時体験したことを綴ってみました。

停電直前

びゅうびゅうと吹き荒れる雨風の様子を窓の外に見ながら、これは停電有り得るなあという予感はありました。ぽちぽちと瞬断が発生していましたし。
念の為に手回しのLEDランタンを出したり電池式のラジオのチェックをしたりしてるうちに家中の電気が落ちました。たしか午後3時くらい。

停電になると

  • 水が停まる
    貯水タンクがマンションの上の方にある構造だとポンプで水を引き上げなければならないので、電気が停まると水も出なくなります。
  • とにかく暇
    外が明るい内は本でも読んでいればいいんですが、暗くなるとスマホいじる以外に何もすることがなくなります。とは言え暇つぶしのためにスマホのバッテリーを浪費するわけにもいかない。
  • とにかく暑い
    エアコンの冷気は停電後1時間保ちませんでした。

対処したこと

  • 寝る
    台風が過ぎ去るまでは何もできないので、時間を進めるためにひとまず寝ることにしました。目が覚めたときに停電直っているといいなという淡い期待もありましたが、それは叶わず。
  • 情報収集
    マンションのロビーに行ってみると何人か人が集まっていて、町内会の防災担当の方からの情報を教えてもらえました。
    • このマンションを含めた何棟かだけがこの辺では停電している
    • 非常用発電機が動いている間は水が出るので、燃料切れの前に風呂桶に水を貯めておいた方がいい
    • 電力会社には連絡済みだが、復旧の目途は立っていない
  • 水の確保
    地震のときと違って今回は配管の損傷の心配はないので、ロビーで聞いてきたとおりに風呂に水を張っておきました。トイレを流すときに役に立ちました。(飲料水は備蓄してあった)
  • 冷蔵庫の整理
    うちの冷蔵庫には通常の冷凍室とは別に急速冷凍室という小さい引き出しの冷凍室があるので、なまものと保冷剤(普段から冷凍室に沢山入れている)をそこにかき集めました。ちょうど買い置きの少ないタイミングだったことも幸いして、約1日の停電でも食材を傷めずに済みました。
    翌日冷凍室を開けたとき、保冷剤はほとんど溶けていませんでした。
  • ブレーカーを落とす
    停電から回復したときに備えてブレーカーは落としておきました

便利だったもの

  • 手回しのLEDランタン
    ランタンは持ち運んで良し、置いて良しという点で便利でした。
    LEDライトに水のペットボトルを被せるとランタンになるというライフハックがあるようですが、持ち運びのことを考えると専用のLEDランタンを用意しておいた方がいいと思います。
    手回し発電機能も十分使い物になりました。数分間回せば一晩保ちました。
    1台しか用意してなかったせいで2人が別行動するときに地味に不便だったので、人数分用意しておけば良かったなと思いました。
  • 小型のLEDライト
    エネループ1本で使える小型のやつです。ピンポイントで明るくしたい場合にはランタンよりこちらが役に立ちました。
  • 大容量モバイルバッテリー
    災害時用に用意していた大型(20000mAh)で充電ポート2口の奴が役に立ちました。普段持ち歩く気にならない大きさと重さですが、こういうときには圧倒的な安心感があります。

その他いろいろ

  • トイレ
    我が家のトイレは停電時用の洗浄レバーがあるのですが、説明書を良く読むと断水のときはバケツで水を流せと書いてあったので風呂の水を使いました。タンクから水を流すときに比べて沢山の水が必要でした。
  • お皿にラップ
    お皿の汚れを防ぎ水を節約するために、お皿にラップを掛けて使うというのをやってみました。うまく張らないとラップが動いて食べにくいことが分かりました。こういうのは1回やってみないと分からないものですね。
  • 備蓄食料
    暗い中カセットコンロでレトルトパックのカレーを温めて食べて思ったのは、水が貴重なときに後から喉が乾くような味の濃いものは良くない、ということです。
  • スマホのバッテリー
    大きめのモバイルバッテリーを用意していたとはいえスマホのバッテリーを浪費したくなかったので、以下のような感じで節約しつつ使ってました。
    • 基本的にフライトモードで通信オフ。使うときだけオンにする
    • TwitterやLINEでときどきこちらの状況を親しい人向けに投下。細かいやりとりはしない
    • 情報収集が必要なときだけウェブ閲覧

    一晩で15%程度の消費に抑えることができました。

最後に

今回の関西での大規模停電はこんな感じの小規模停電が多数発生したものであったため、その分復旧に時間が掛かったようです。
我が家も長期戦になることをある程度覚悟していたのですが、幸いにも翌日の夕方までには電気が戻ってきました。復旧に尽力された電力会社および現場のみなさまに感謝します。


スマートオフィス化計画エアコン編

はじめに

エアコンはありがたいものです。夏の暑いときも冬の寒いときも快適な室温を保ってくれます。
…電源さえ入っていれば。

朝イチで出社するメンバーから、鍵開けてオフィスに入った時点で既にいい感じに涼しくなっていてくれたらとてもうれしいという話を聞いて、やってみることにしました。

オフィス用エアコンは案外賢くない

家庭用エアコンは赤外線リモコンで制御できるので、いわゆるIoTリモコン(スマートリモコン)とPCを連携させれば電源のオンオフ・温度変更・風量変更・モード変更を割と自由に制御出来ます。
一方、オフィス用エアコンは温度やモードが表示されるパネルを見ながら物理ボタンをポチポチ押して制御するしかありません。

という訳で、今回は電源のオンオフに専念します。

SwitchBot

エアコンの電源ボタンを物理的に押すためにSwitchBotを導入しました。

こんな感じでボタンを押してくれます。

SwitchBotはBluetoothを搭載していて、スマートフォンアプリでボタンプッシュの動作をさせることができます。今回は自動制御を目指したいので、ネット経由でトリガを送れるSwitchBot Hubを併用します。

SwitchBot HubをIFTTTと連携させて、以下のようにしてエアコンの自動起動を実現できます。

  1. ラズパイで朝7時にスイッチオン用のスクリプトを起動
  2. IFTTTで設定したWebhooksのURLをスクリプトが叩く
  3. IFTTTからSwitchBot Hub経由でSwitchBotにトリガが届く
  4. スイッチオン!

IFTTTとの連携

IFTTTでは’This’としてWebhooks、’That’としてSwitchBotのBot Pressを選んでアプレットを作成しました。

このアプレットへのアクセスキーをIFTTTのWebhooksのページで発行すれば、以下のようなURLを叩いてSwitchBotを動かせます。(XXXの部分がアクセスキーです)

https://maker.ifttt.com/trigger/PressSwitchBot12F_Red/with/key/XXXXXXXXXXXXXXXXX

残念ながら、IFTTTでもSwitchBot Hubでも1個のトリガで複数のSwitchBotを動かすアクションは実行できないので、オフィスの6台分のエアコンそれぞれに対応したアプレットを作って6つのURLを1個ずつ叩くスクリプトを作りました。IFTTTで「平日朝7時に実行」というトリガを作れるので、それを6台分設定することも考えましたが「平日だけど会社は休み」のパターンへの対応を考えてスクリプトから起動しています。

結果

自動起動を始めて2週間ほど経ちます。それなりにちゃんと動いていて、朝イチの出社のメンバーを空調の効いたオフィスが迎えてくれるようになっているようです。

「それなり」というのは、6台のエアコンのうち1,2台が動いていないことが多いからです。「オフィスに入ったら空調が効いている」という目的には大きな影響は無いと言えば無いのですが、何故全部ちゃんと動いてくれないのかは気になるところです。いろいろ試した感じではSwitchBot Hubがどうも怪しいようです。何回かに1回IFTTTからのトリガを取りこぼしたり1個のトリガで2回動いたりします。

https://github.com/OpenWonderLabs/python-host
を使えば SwitchBot Hub 無しでラズパイから直接SwitchBotを制御できるようなので、いずれ挑戦してみたいと思います。