Solrの環境をMacに構築する

仕事で検索プラットフォームの Apache Solr を扱うことになったのですが、今までブラックボックスにしてきてしまっていたので、この機会に勉強することにしました。

http://lucene.apache.org/solr/

今回は、Mac上にSolr (7.5.0) の環境を構築し、け日記のエントリを検索できるようにします。チュートリアルと↓の本を参考にしています。

[改訂第3版]Apache Solr入門――オープンソース全文検索エンジン (Software Design plus)

前提条件

Java 8.0以降のランタイムがインストールされている必要があります。

Solrのダウンロードと起動

Solrのダウンロードと起動を行っていきます。Dockerを使った方法もありますが、今回はバイナリをダウンロードしてきて直接起動するようにします。

バイナリをダウンロード・展開します。

$ pwd
/tmp/solr

$ wget http://ftp.jaist.ac.jp/pub/apache/lucene/solr/7.5.0/solr-7.5.0.zip

$ unzip solr-7.5.0.zip

$ cd solr-7.5.0

次に、Solrサーバの起動です。同梱されているbin/solrコマンドで立ち上げます。

  • SolrCloudで立ち上げてます
    • 動的なロードバランシングやZooKeeperによるクラスタ管理を行います
  • 2ノードを立ち上げます
    • ポートは8983 (pid=12771) と7574 (pid=12872) を割り当てます
$ ./bin/solr start -e cloud
Welcome to the SolrCloud example!
...
This interactive session will help you launch a SolrCloud cluster on your local workstation.
To begin, how many Solr nodes would you like to run in your local cluster? (specify 1-4 nodes) [2]:
2
Ok, let's start up 2 Solr nodes for your example SolrCloud cluster.
Please enter the port for node1 [8983]:
8983
Please enter the port for node2 [7574]:
7574
Creating Solr home directory /tmp/solr/solr-7.5.0/example/cloud/node1/solr
Cloning /tmp/solr/solr-7.5.0/example/cloud/node1 into
   /tmp/solr/solr-7.5.0/example/cloud/node2

Starting up Solr on port 8983 using command:
"/tmp/solr/solr-7.5.0/bin/solr" start -cloud -p 8983 -s "/tmp/solr/solr-7.5.0/example/cloud/node1/solr"
...
Started Solr server on port 8983 (pid=12771). Happy searching!
Starting up Solr on port 7574 using command:
"/tmp/solr/solr-7.5.0/bin/solr" start -cloud -p 7574 -s "/tmp/solr/solr-7.5.0/example/cloud/node2/solr" -z localhost:9983
...
Started Solr server on port 7574 (pid=12872). Happy searching!

INFO  - 2018-11-24 12:46:41.796; org.apache.solr.common.cloud.ConnectionManager; zkClient has connected
INFO  - 2018-11-24 12:46:41.816; org.apache.solr.common.cloud.ZkStateReader; Updated live nodes from ZooKeeper... (0) -> (2)
INFO  - 2018-11-24 12:46:41.843; org.apache.solr.client.solrj.impl.ZkClientClusterStateProvider; Cluster at localhost:9983 ready

...続く...

次に、け日記用のコレクションを作ります。

  • コレクションは、インデックスを作って検索するドキュメント集合の単位です
    • 後ほど検索クエリが出てきますが、パスにここで設定したコレクション名kenikkiが付きます
  • シャード数は2で設定
    • シャードはコレクションの論理的なパーティションで、ドキュメントのインデックス作成時にどのシャードに属するのかが決められます
    • クエリは各シャードにルーティングしてそれぞれで検索されます
  • レプリカ数も2で設定
    • レプリカはシャードごとに設定し、各シャードは1つ以上のレプリカを持ちます
    • レプリカの1つはleaderに指名され、leaderはインデックスの更新を他のreplicaに伝搬します
Now let's create a new collection for indexing documents in your 2-node cluster.
Please provide a name for your new collection: [gettingstarted]
kenikki
How many shards would you like to split kenikki into? [2]
2
How many replicas per shard would you like to create? [2]
2
Please choose a configuration for the kenikki collection, available options are:
_default or sample_techproducts_configs [_default]
_default
Created collection 'kenikki' with 2 shard(s), 2 replica(s) with config-set 'kenikki'

Enabling auto soft-commits with maxTime 3 secs using the Config API

POSTing request to Config API: http://localhost:8983/solr/kenikki/config
{"set-property":{"updateHandler.autoSoftCommit.maxTime":"3000"}}
Successfully set-property updateHandler.autoSoftCommit.maxTime to 3000

SolrCloud example running, please visit: http://localhost:8983/solr

これでSolrサーバが起動します。最後に表示されたhttp://localhost:8983/solrにブラウザからアクセスすると、ダッシュボードが表示されます。

f:id:ohke:20181124125344p:plain

管理画面からCloudタブを開くと、作成したコレクション"kenikki"に2本のシャードとそれぞれに2つのレプリカが紐付いていることがわかります。

f:id:ohke:20181124130654p:plain

なおコレクションを消すときは以下のコマンドです。

$ bin/solr delete -c kenikki

またサーバを停止するコマンドは以下です。

$ bin/solr stop -all

け日記のエントリページのダウンロード

け日記から過去の全エントリをHTMLファイルでダウンロードしておきます。このHTMLファイルをSolrで検索できるようにします。

import requests
import re
import urllib.request
import time

list_urls = [
    'https://ohke.hateblo.jp/archive/2015',
    'https://ohke.hateblo.jp/archive/2016',
    'https://ohke.hateblo.jp/archive/2017?page=1',
    'https://ohke.hateblo.jp/archive/2017?page=2',
    'https://ohke.hateblo.jp/archive/2018?page=1',
    'https://ohke.hateblo.jp/archive/2018?page=2'
]

downloaded = set()  # ダウンロード済みのURL

for list_url in list_urls:
    # リストページからエントリのURLを抽出 (ダウンロード済みのURLは除外)
    list_html = requests.get(list_url).text
    entry_urls = set(re.findall(r'https://ohke.hateblo.jp/entry/[0-9]{4}/[0-9]{2}/[0-9]{2}/[0-9]{6}', list_html)) - downloaded
    
    # エントリページ (HTMLファイル) をダウンロードして保存
    for entry_url in entry_urls:
        urllib.request.urlretrieve(entry_url, '/tmp/solr/documents/'+ entry_url[30:].replace('/', '-') + '.html')
        downloaded.add(entry_url)
        
        time.sleep(5)  # アクセスレート制限

ダウンロードされたファイルは/tmp/solr/documents/配下に保存してます。

$ ls /tmp/solr/documents
2015-06-18-231834.html
2015-12-07-000504.html
...
2018-11-17-230000.html

ドキュメントのインデックス

上でダウンロードしたエントリのファイルを、ドキュメントに追加します。

solrのパッケージに含まれるbin/postを実行することでインデックスされます。HTMLファイル以外にもxmlやjson、pdfやwordにも対応しているようです。

$ pwd
/tmp/solr/solr-7.5.0

$ ./bin/post -c kenikki /tmp/solr/documents/*
java -classpath /tmp/solr/solr-7.5.0/dist/solr-core-7.5.0.jar -Dauto=yes -Dc=kenikki -Ddata=files org.apache.solr.util.SimplePostTool /tmp/solr/documents/2015-06-18-231834.html /tmp/solr/documents/2015-12-07-000504.html ... /tmp/solr/documents/2018-11-17-230000.html
SimplePostTool version 5.0.0
Posting files to [base] url http://localhost:8983/solr/kenikki/update...
Entering auto mode. File endings considered are xml,json,jsonl,csv,pdf,doc,docx,ppt,pptx,xls,xlsx,odt,odp,ods,ott,otp,ots,rtf,htm,html,txt,log
POSTing file 2015-06-18-231834.html (text/html) to [base]/extract
POSTing file 2015-12-07-000504.html (text/html) to [base]/extract
...
POSTing file 2018-11-17-230000.html (text/html) to [base]/extract
113 files indexed.
COMMITting Solr index changes to http://localhost:8983/solr/kenikki/update...
Time spent: 0:00:11.996

これで検索の準備が整いました。

検索

インデックスされたドキュメントの検索するためには、Solrのエンドポイントを直接叩く方法と、管理画面から行う方法 (ここではhttp://localhost:8983/solr/#/kenikki/queryから) があります。

まずはクエリに何も指定せずに検索してみます。URLはhttp://localhost:8983/solr/kenikki/select?q=*:*となります。

  • 検索時間は15ミリ秒 (responseHeader.QTime)
  • ヒット件数が113件 (response.numFound)
  • response.docsに検索結果が入ってます
{
  "responseHeader":{
    "zkConnected":true,
    "status":0,
    "QTime":15,
    "params":{
      "q":"*:*",
      "_":"1543032835024"}},
  "response":{"numFound":113,"start":0,"maxScore":1.0,"docs":[
      {
        "id":"/tmp/solr/documents/2015-06-18-231834.html",
        "og_image":["https://cdn.image.st-hatena.com/image/scale/0b8f3ad86af86640c8733aa2f27dd3f0b2c09090/backend=imager;enlarge=0;height=1000;version=1;width=1200/https%3A%2F%2Fcdn.user.blog.st-hatena.com%2Fdefault_entry_og_image%2F99455833%2F1533459624629378"],
        "twitter_app_url_iphone":["hatenablog:///open?uri=https%3A%2F%2Fohke.hateblo.jp%2Fentry%2F2015%2F06%2F18%2F231834"],
        "article_published_time":[1434637114],
        "twitter_card":["summary"],
        "stream_content_type":["text/html"],
        "og_site_name":["け日記"],
        "description":["はじめまして。 このブログを始めた経緯と目的を忘れないようにするために、初投稿としてメモしておきます。 経緯 私自身はいわゆるシステムインテグレータ(SIer)に勤めており、主にシステム開発、部内の開発標準・開発環境の整備を担当しています。 古き良きSIerのエンジニアのご多分に漏れず、ありがちなもやもやを抱えています。 レガシーな技術・・・Java 1.4もCOBOLもバリバリの現役です 社内に閉じた謎ツール・・・DAOを生成するエクセルファイル コミュニケーション能力、マネジメント能力の方が大事です・・・携帯電話、メーラー、エクセルが必須ツール このあたりが原因となって、エンジニアとしての…"],
        "title":["このブログの趣旨 - け日記"],
        "twitter_app_id_iphone":[583299321],
        "og_description":["はじめまして。 このブログを始めた経緯と目的を忘れないようにするために、初投稿としてメモしておきます。 経緯 私自身はいわゆるシステムインテグレータ(SIer)に勤めており、主にシステム開発、部内の開発標準・開発環境の整備を担当しています。 古き良きSIerのエンジニアのご多分に漏れず、ありがちなもやもやを抱えています。 レガシーな技術・・・Java 1.4もCOBOLもバリバリの現役です 社内に閉じた謎ツール・・・DAOを生成するエクセルファイル コミュニケーション能力、マネジメント能力の方が大事です・・・携帯電話、メーラー、エクセルが必須ツール このあたりが原因となって、エンジニアとしての…"],
        "dc_title":["このブログの趣旨 - け日記"],
        "content_encoding":["UTF-8"],
        "content_type":["text/html; charset=UTF-8"],
        "stream_size":[37889],
        "x_parsed_by":["org.apache.tika.parser.DefaultParser",
          "org.apache.tika.parser.html.HtmlParser"],
        "og_type":["article"],
        "twitter_title":["このブログの趣旨 - け日記"],
        "google_site_verification":["j4_wDEBc7hC57lE-AA2X0V_vBF_DIzL1kf9mX-igpZk"],
        "og_title":["このブログの趣旨 - け日記"],
        "resourcename":["/tmp/solr/documents/2015-06-18-231834.html"],
        "article_tag":["雑記"],
        "x_ua_compatible":["IE=7; IE=9; IE=10; IE=11"],
        "twitter_description":["はじめまして。 このブログを始めた経緯と目的を忘れないようにするために、初投稿としてメモしておきます。 経緯 私自身はいわゆるシステムインテグレータ(SIer)に勤めており、主にシステム開発、部内の開発標準・開発環境の整備を担当しています。 古き良きSIerのエンジニアのご多分に漏れず、ありがちなもやもやを抱えています…"],
        "og_url":["https://ohke.hateblo.jp/entry/2015/06/18/231834"],
        "twitter_app_name_iphone":["はてなブログアプリ"],
        "_version_":1617986237913628672},
...

次に、"numpy"を含むエントリ1件を公開日時で降順ソートして検索します。クエリのURLはhttp://localhost:8983/solr/kenikki/select?q=numpy&rows=1&sort=article_published_time%20descとなります。

{
  "responseHeader":{
    "zkConnected":true,
    "status":0,
    "QTime":10,
    "params":{
      "q":"numpy",
      "sort":"article_published_time desc",
      "rows":"1",
      "_":"1543034009297"}},
  "response":{"numFound":28,"start":0,"docs":[
      {
        "id":"/tmp/solr/documents/2018-10-20-235500.html",
        "og_image":["https://cdn.user.blog.st-hatena.com/default_entry_og_image/99455833/1533459624629378"],
        "twitter_app_url_iphone":["hatenablog:///open?uri=https%3A%2F%2Fohke.hateblo.jp%2Fentry%2F2018%2F10%2F20%2F235500"],
        "article_published_time":[1540047300],
        "twitter_card":["summary_large_image"],
        "stream_content_type":["text/html"],
        "og_site_name":["け日記"],
        "description":["単語ベクトル化モデルの一つであるGloVeを試してみます。 GloVe GloVeは単語のベクトル表現を得る手法の一つで、Word2Vecの後発となります。論文はこちらです。 nlp.stanford.edu Word2Vec (skip-gram with negative sampling: SGNS) では各単語から周辺単語を予測するというタスクをニューラルネットワークで解くことによって単語ベクトルを得ますが、GloVeではコーパス全体から得られる単語間の共起行列を持ち込んだ最適化関数 (重み付き最小二乗法) で学習します。 単語iと単語jの共起行列が は、次元削減された単語ベクトル (…"],
        "title":["GloVeで単語ベクトルを得る - け日記"],
...

まとめ

Solrことはじめということで、今回はMacbookにSolrサーバーを立ち上げ、け日記を全文検索できるようにしました。

参考文献

[改訂第3版]Apache Solr入門――オープンソース全文検索エンジン (Software Design plus)

[改訂第3版]Apache Solr入門――オープンソース全文検索エンジン (Software Design plus)

  • 作者: 打田智子,大須賀稔,大杉直也,西潟一生,西本順平,平賀一昭,株式会社ロンウイット,株式会社リクルートテクノロジーズ
  • 出版社/メーカー: 技術評論社
  • 発売日: 2017/04/27
  • メディア: 大型本
  • この商品を含むブログを見る

Python: LexRankで日本語の記事を要約する

仕事で行っているPoCの中で、文章の要約が使えるのではと思い、調査をし始めています。

今回はsumyのLexRankの実装を使い、過去の投稿を要約してみます。

LexRank

LexRankは、抽出型に分類される要約アルゴリズムで、文書からグラフ構造を作り出して重要な文のランキングを作ることで要約と言える文を発見します。2004年に提案されています (提案論文はこちら) 。

  • 要約アルゴリズムは抽出型と生成型に大きく分けられます
    • 抽出型は、対象の文章内から要約と言える代表的な文を抜き出す方法 (大事なところに線を引くのと近い方法)
    • 生成型は、文章内の文をそのまま使わずに、要約文を作る方法 (読書メモを作るのと近い方法)

LexRankのキーポイントは2つで、PageRankから着想を得たTextRank (提案論文PDF) の派生となります。

  • 文をノード、文間の類似度をエッジとした無方向グラフを作る
    • 提案論文では、TF-IDFからコサイン類似度で計算 (現代的にはword2vecなども使えるはず)
  • 上のグラフから得られた推移確率行列 (M) と確率ベクトル (P) が安定する状態 (MP=P) まで計算して、最終的な確率ベクトルの値が大きい文を要約文として選択する
    • PageRankの計算と同じ

上の理論を視覚化したの下図 (提案論文Figure 2から抜粋) で、エッジ数が多く (=たくさんの文と類似している) かつエッジが太い (=類似度が高い) d5s1やd4s1などが要約文の候補となります。

実装

それではLexRankで要約文を抽出します。過去の投稿を2行で要約します (タグやマークダウンの記述は除外しました) 。

ohke.hateblo.jp

sumy

LexRankの実装として、sumyを使います。

  • LexRank以外にも様々な要約アルゴリズムが実装されています
  • 日本語もサポートしており、内部はtinysegmenterが形態素解析器に採用されています
    • が、tinysegmenterは本当に必要最低限のため、今回はJanomeで解析します

github.com

pip install sumy tinysegmenter Janome

形態素解析

最初にJanomeでトークンに分離します。Janomeについては Python janomeのanalyzerが便利 - け日記 も参考にしてみてください。

from janome.analyzer import Analyzer
from janome.charfilter import UnicodeNormalizeCharFilter, RegexReplaceCharFilter
from janome.tokenizer import Tokenizer as JanomeTokenizer  # sumyのTokenizerと名前が被るため
from janome.tokenfilter import POSKeepFilter, ExtractAttributeFilter

text = """転職 Advent Calendar 2016 - Qiitaの14日目となります。 少しポエムも含みます。
今年11月にSIerからWebサービスの会社へ転職しました。
早くから退職することを報告していたこともあって、幸いにも有給消化として1ヶ月のお休みをいただくことができました(これでも10日ほど余らせてしまいました)。
# ・・・ (省略) ・・・
だからこそ、有給消化期間はなんとしてでももぎ取るようにしましょう。"""

# 1行1文となっているため、改行コードで分離
sentences = [t for t in text.split('\n')]
for i in range(2):
    print(sentences[i])
# 転職 Advent Calendar 2016 - Qiitaの14日目となります。 少しポエムも含みます。
# 今年11月にSIerからWebサービスの会社へ転職しました。

# 形態素解析器を作る
analyzer = Analyzer(
    [UnicodeNormalizeCharFilter(), RegexReplaceCharFilter(r'[(\)「」、。]', ' ')],  # ()「」、。は全てスペースに置き換える
    JanomeTokenizer(),
    [POSKeepFilter(['名詞', '形容詞', '副詞', '動詞']), ExtractAttributeFilter('base_form')]  # 名詞・形容詞・副詞・動詞の原型のみ
)

# 抽出された単語をスペースで連結
# 末尾の'。'は、この後使うtinysegmenterで文として分離させるため。
corpus = [' '.join(analyzer.analyze(s)) + '。' for s in sentences]
for i in range(2):
    print(corpus[i])
# 転職 Advent Calendar 2016 - Qiita 14 日 目 なる 少し ポエム 含む。
# 今年 11 月 SIer Web サービス 会社 転職 する。

要約文の抽出

上で生成したコーパスから、sumyで要約文を抽出します。

  • PlaintextParserは、文字列やファイルから文書を読み込みます (他にもHtmlParserが用意されている)
    • Tokenizerには言語を指定する ('english', 'german', 'chinese', 'japanese', 'slovak'または'czech'が選択できる)
  • LexRankSummarizerで、LexRankの計算を行い、要約文を抽出します
    • stop_wordsプロパティでストップワードを指定する
    • callメソッドにparserで解析された文書オブジェクトと、抽出したい文の数を、それぞれ引数に渡す
from sumy.parsers.plaintext import PlaintextParser
from sumy.nlp.tokenizers import Tokenizer
from sumy.summarizers.lex_rank import LexRankSummarizer

# 連結したcorpusを再度tinysegmenterでトークナイズさせる
parser = PlaintextParser.from_string(''.join(corpus), Tokenizer('japanese'))

# LexRankで要約を2文抽出
summarizer = LexRankSummarizer()
summarizer.stop_words = [' ']  # スペースも1単語として認識されるため、ストップワードにすることで除外する

summary = summarizer(document=parser.document, sentences_count=2)

# 元の文を表示
for sentence in summary:
    print(sentences[corpus.index(sentence.__str__())])

要約文として抽出された文は、以下の2つです。

  • 1文目は何について記述されているのか説明しており、そういう意味では要約になってます
  • 2文目は外している感じがします (勉強が1つのテーマになっているのですが...)
今まで長期休みを取らずに働いていた人は、意外と同じような疑問を持つ人が多いのかなと思いましたので、参考までに自分が有給中に何をしていたのかを書いてみたいと思います。
もし不安なら内定後の面談で何を勉強しておいたほうが良いか直接確認しても良いと思います(私はそうしました)。

まとめ

小さな実験でしたが、今回の結果からLexRankの特徴について以下のことが言えそうです。

  • LexRankではTF-IDFから文書のベクトルを計算しており、"有給"や"勉強"などの主題となる単語を含む文をキャッチできているようです
  • そういった単語を含む文の中から相応しいのが選択できているかというとそうではなく、上手くいかないパターンがありそうです

また論文の方も読んで理解を深めていきたいと思います。

SQL ServerのテーブルをPandas DataFrameで読み書きする

SQL ServerのテーブルをPandasのDataFrameに読み込んだり、逆に書き出したりする方法の備忘録です。

ドライバにpymssqlを使います。また書き出しには

$ pip install pymssql SQLAlchemy

DataFrameへの読み込み

まずはSQL ServerのテーブルからDataFrameへ読み込みます。

read_sqlメソッドを使います。

import pandas as pd

# 接続情報
server = "db.host"
database = "db_name"
username = "user_name"
password = "PaSsWoRd"

with pymssql.connect(server=server, user=username, password=password, database=database) as conn:
    sql = """
    SELECT
        id,
        name,
        email,
        created_at
    FROM users
    """
    
    df = pd.read_sql(sql, conn)

テーブルへの書き込み

今度はDataFrameからテーブルへです。

流れは、SQLAlchemyでお目当てのDBに接続し、to_sqlメソッドで書き込みます。

  • nameはテーブル名、schema はスキーマ名をそれぞれ指定
  • conにSQLAlchemyのDBエンジンを渡す
  • if_existsにはテーブルが存在した場合の挙動で、"append"なら気にせずインサート、"replace"なら再作成、"fail"ならそこでエラーになります (デフォルトは"fail")
  • indexがFalseの場合はインデックスをインサートするカラムに含めないようにします (デフォルトはTrue)
    • インデックスのカラム名は index_label で指定できます (指定しない場合はインデックス名がそのまま使われます)
from sqlalchemy import create_engine

connection_string = 'mssql+pymssql://user_name:PaSsWoRd@db.host/db_name'

engine = create_engine(connection_string)

df.to_sql(name='users', con=engine, schema='dbo', if_exists='append', index=False)