Y's note

Web技術・プロダクトマネジメント・そして経営について

本ブログの更新を停止しており、今後は下記Noteに記載していきます。
https://note.com/yutakikuchi/

SolrのSpatial Searchを試してみた


前書き

10代の頃は(ゴースト)ライターという職業に憧れていた時期もありました@yutakikuchi_です。
Geospatial Indexes and Queries ― MongoDB Manual 2.4.9 はてなブックマーク - Geospatial Indexes and Queries ― MongoDB Manual 2.4.9
MySQL :: MySQL 4.1 リファレンスマニュアル :: 10.6.1 空間インデックスの作成 はてなブックマーク - MySQL :: MySQL 4.1 リファレンスマニュアル :: 10.6.1 空間インデックスの作成
位置情報IndexをMongoDBで管理する手法については前に調査済みで、mysqlにもSpatialindexはあまり普及していない印象、ということで...今日は検索SolrのSpatial Searchについて調べてみます。最終的にはFessやNutchでWebPageをCrawlingして得た住所データをGeocodingでLat/Lngデータに変換して自前のServerにIndexingしていく事を考えており、その前段階の作業です。Solrを選ぶ理由ですがSpatial Search以外にもTermVectorでの類似度を算出してくれるMoreLikeThisという機能があり、Lat/Lngデータの掛け合わせでコンテンツを面白くSuggestすることを考えています。MoreLikeThisについても調査したら書きますね。

Solr設定

java, tomcat6, Solr

javatomcat、Solr本体が必要なので以下の手順でInstallです。Solrは2014.3.15現在で最新のV4.7.0を取ってきます。僕が3年程前にSolrを使っていた時はV1.*とかだったので、もう過去の記憶や記録は役立たなさそうですね...

$ sudo yum install java-1.7.0-openjdk tomcat6 --enablerepo=remi
$ wget "ftp://ftp.riken.jp/net/apache/lucene/solr/4.7.0/solr-4.7.0.tgz"
$ tar xf solr-4.7.0.tgz
Portfowarding

Solrのadminツールに接続する為の設定です。僕の場合はMacVirtualBoxを立ち上げ、HostOSからGuestOS(CentOS)に接続してSolrを使っているのでVirtualBox内のPortfowardingとGuestOS側のFireWallの設定をします。VirtualBoxでは設定=>ネットワーク=>ポートフォワーディングで以下の画面に辿れます。Solrのdefaultportである8983を指定しておきます。※GuestOS側のIPアドレスをifconfigで調べて設定してください。

Firewall

下はHostOS側のFirewall設定です。

$ sudo vi /etc/sysconfig/iptables

-A INPUT -m state --state NEW -m tcp -p tcp --dport 22 -j ACCEPT
-A INPUT -m state --state NEW -m tcp -p tcp --dport 80 -j ACCEPT
#追加
-A INPUT -m state --state NEW -m tcp -p tcp --dport 8983 -j ACCEPT

$ sudo service iptables restart
Solr-exampleの起動

Solrのexampleにある管理画面を表示してみます。tarで展開したディレクトリ以下にstart.jarファイルがあるのでそれを実行します。下のコマンドを実行してhttp://localhost:8983/solrにアクセスします。start.jarの実行時もそうですが、色々とエラーがでていますが取りあえずは画面を見る事ができます。

$ cd solr-4.7.0/example
$ java -jar start.jar
0    [main] INFO  org.eclipse.jetty.server.Server  – jetty-8.1.10.v20130312
41   [main] INFO  org.eclipse.jetty.deploy.providers.ScanningAppProvider  – Deployment monitor /home/yuta/work/src/solr/solr-4.7.0/example/contexts at interval 0
50   [main] INFO  org.eclipse.jetty.deploy.DeploymentManager  – Deployable added: /home/yuta/work/src/solr/solr-4.7.0/example/contexts/solr-jetty-context.xml
1872 [main] INFO  org.eclipse.jetty.webapp.StandardDescriptorProcessor  – NO JSP Support for /solr, did not find org.apache.jasper.servlet.JspServlet
()


exampledocsの追加

展開したSolrのディレクトリにexampledocsがあるのでSolrに追加してみます。xmlの中身を見てみるとPCパーツや電化製品の情報みたいです。post.shを実行するとcollection1という新しいコレクションが生成され、32個のドキュメントが追加されます。indexのデータはsolr-4.7.0/example/solr/collection1/data/indexに生成されます。

$ cd solr-4.7.0/example/exampledocs
$ cat vidcard.xml
<add>
<doc>
  <field name="id">EN7800GTX/2DHTV/256M</field>
  <field name="name">ASUS Extreme N7800GTX/2DHTV (256 MB)</field>
  <!-- Denormalized -->
  <field name="manu">ASUS Computer Inc.</field>
  <!-- Join -->
  <field name="manu_id_s">asus</field>
  <field name="cat">electronics</field>
  <field name="cat">graphics card</field>
  <field name="features">NVIDIA GeForce 7800 GTX GPU/VPU clocked at 486MHz</field>
  <field name="features">256MB GDDR3 Memory clocked at 1.35GHz</field>
  <field name="features">PCI Express x16</field>
  <field name="features">Dual DVI connectors, HDTV out, video input</field>
  <field name="features">OpenGL 2.0, DirectX 9.0</field>
  <field name="weight">16</field>
  <field name="price">479.95</field>
  <field name="popularity">7</field>
  <field name="store">40.7143,-74.006</field>
  <field name="inStock">false</field>
  <field name="manufacturedate_dt">2006-02-13T15:26:37Z/DAY</field>
</doc>

$ ./post.sh *.xml
Posting file gb18030-example.xml to http://localhost:8983/solr/update
<?xml version="1.0" encoding="UTF-8"?>
<response>
<lst name="responseHeader"><int name="status">0</int><int name="QTime">164</int></lst>
</response>
()

$ ls solr-4.7.0/example/solr/collection1/data/index
-rw-rw-r--. 1 yuta yuta  4928  316 00:55 2014 _0.fdt
-rw-rw-r--. 1 yuta yuta    45  316 00:55 2014 _0.fdx
-rw-rw-r--. 1 yuta yuta  2300  316 00:55 2014 _0.fnm
-rw-rw-r--. 1 yuta yuta   250  316 00:55 2014 _0.nvd
-rw-rw-r--. 1 yuta yuta   112  316 00:55 2014 _0.nvm
-rw-rw-r--. 1 yuta yuta   395  316 00:55 2014 _0.si
-rw-rw-r--. 1 yuta yuta   144  316 00:55 2014 _0.tvd
-rw-rw-r--. 1 yuta yuta    45  316 00:55 2014 _0.tvx
-rw-rw-r--. 1 yuta yuta  1047  316 00:55 2014 _0_Lucene41_0.doc
-rw-rw-r--. 1 yuta yuta    34  316 00:55 2014 _0_Lucene41_0.pay
-rw-rw-r--. 1 yuta yuta  2204  316 00:55 2014 _0_Lucene41_0.pos
-rw-rw-r--. 1 yuta yuta 14134  316 00:55 2014 _0_Lucene41_0.tim
-rw-rw-r--. 1 yuta yuta   712  316 00:55 2014 _0_Lucene41_0.tip
-rw-rw-r--. 1 yuta yuta    20  316 00:55 2014 segments.gen
-rw-rw-r--. 1 yuta yuta   110  316 00:55 2014 segments_2
-rw-rw-r--. 1 yuta yuta     0  316 00:25 2014 write.lock
query実行

SolrはREST形式なのでUPDATEやSELECTも基本的にはcurlにて行います。solr-4.7.0/example/exampledocsにはtest_utf8.shというテストスクリプトがあるのでこれを参考に実行してみます。test_utf8.shの実行ではERROR:と表示されなければ問題ないと判断できます。test_utf8.sh中に記載されているcurlスクリプトを真似てqueryをASUS、outputをjsonとしてSolrからのresponseを確認します。

$ ./test_utf8.sh 
Solr server is up.
HTTP GET is accepting UTF-8
HTTP POST is accepting UTF-8
HTTP POST defaults to UTF-8
HTTP GET is accepting UTF-8 beyond the basic multilingual plane
HTTP POST is accepting UTF-8 beyond the basic multilingual plane
HTTP POST + URL params is accepting UTF-8 beyond the basic multilingual plane
Response correctly returns UTF-8 beyond the basic multilingual plane

$ curl "http://localhost:8983/solr/select?q=ASUS&params=explicit&wt=json" | python -mjson.tool
{
    "response": {
        "docs": [
            {
                "_version_": 1462657512030339072, 
                "cat": [
                    "electronics", 
                    "graphics card"
                ], 
                "features": [
                    "NVIDIA GeForce 7800 GTX GPU/VPU clocked at 486MHz", 
                    "256MB GDDR3 Memory clocked at 1.35GHz", 
                    "PCI Express x16", 
                    "Dual DVI connectors, HDTV out, video input", 
                    "OpenGL 2.0, DirectX 9.0"
                ], 
                "id": "EN7800GTX/2DHTV/256M", 
                "inStock": false, 
                "manu": "ASUS Computer Inc.", 
                "manu_id_s": "asus", 
                "manufacturedate_dt": "2006-02-13T00:00:00Z", 
                "name": "ASUS Extreme N7800GTX/2DHTV (256 MB)", 
                "popularity": 7, 
                "price": 479.94999999999999, 
                "price_c": "479.95,USD", 
                "store": "40.7143,-74.006", 
                "weight": 16.0
            }
        ], 
        "numFound": 1, 
        "start": 0
    }, 
    "responseHeader": {
        "QTime": 1, 
        "params": {
            "params": "explicit", 
            "q": "ASUS", 
            "wt": "json"
        }, 
        "status": 0
    }
}
設定ファイル

solr-4.7.0/example/solr/collection1/conf/solrconfig.xmlに色々な記述が書かれているのでより深くexampleでのREST仕様を理解したい人は目を通してみると良いかもしれません。solr/selectにアクセスするとsolr.SearchHandlerClassを呼び出すなどの定義が書かれています。その他indexのschemaについてはsolr-4.7.0/example/solr/collection1/conf/schema.xmlで定義されています。bi-gram、tokenizer、filter等の指定がされています。

$ vi solr-4.7.0/example/solr/collection1/conf/solrconfig.xml

  <requestHandler name="/select" class="solr.SearchHandler">
    <!-- default values for query parameters can be specified, these
         will be overridden by parameters in the request
      -->
     <lst name="defaults">
       <str name="echoParams">explicit</str>
       <int name="rows">10</int>
       <str name="df">text</str>
     </lst>

()

$ vi solr-4.7.0/example/solr/collection1/conf/schema.xml

    <!-- CJK bigram (see text_ja for a Japanese configuration using morphological analysis) -->
    <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>
solrpy

solrpy - Python Solr Module - Google Project Hosting はてなブックマーク - solrpy - Python Solr Module - Google Project Hosting
Reference ― solrpy v0.9.2 documentation はてなブックマーク - Reference ― solrpy v0.9.2 documentation
SolrがREST形式ならclient言語は別に何だっていいんですが、僕はJavaよりPythonが好きなのでおしゃべりする言語に使用します。solrpyというライブラリがありますが日本語ドキュメントは無いので、英語版を見ましょう。solrpyには2種類のclassがあってclass solr.Solr(url)、class solr.SolrConnection(url)になります。SolrConnectionの方が記述が分かり易いのですが、Solrの方が新しいという事で以下ではclass solr.Solrを使っています。


Solr-exampleのschema.xmlを少し書き換えて配列形式のauthorを登録するような処理を書いてみます。複数のauthorが登録できるようにmultiValued="true"にします。設定しないとsolrpyのスクリプト実行時にsolr.core.SolrException: HTTP code=400, reason=Bad Requestとerrorが出ます。

$ sudo easy_install solrpy
()
Installed /usr/lib/python2.6/site-packages/solrpy-0.9.6-py2.6.egg
Processing dependencies for solrpy
Finished processing dependencies for solrpy

# 下のtest_solrpy.pyを実行
$ python test_solrpy.py
()
solr.core.SolrException: HTTP code=400, reason=Bad Requestとerror

# errorを回避する為に修正
$ vi solr-4.7.0/example/solr/collection1/conf/schema.xml

<!-- multiValued属性を"true"に設定 --> 
<field name="author" type="text_general" indexed="true" stored="true" multiValued="true"/>
<!-- author_sを追加 --> 
<field name="author_s" type="text_general" indexed="true" stored="true" multiValued="true"/>

# solrのrestart
$ java -jar start.jar

# 再度実行
$ python test_solrpy.py 
1 [u'Lucene in Action'] [u'Erik Hatcher', u'Otis Gospodneti\u0107']
#!/usr/bin/env python
#coding:utf-8
#solrpyのテスト

import solr
# add a document to the index
con = solr.Solr('http://localhost:8983/solr')
doc ={'id':1, 'title':u'Lucene in Action', 'author':[u'Erik Hatcher', u'Otis Gospodneti〓']}
con.add(doc,commit=True)
# do a search
response = con.select('title:lucene')
for hit in response.results:
    print hit['id'],hit['title'],hit['author']

Spatial Search

設定

SpatialSearch - Solr Wiki はてなブックマーク - SpatialSearch - Solr Wiki
SpatialSearchについては上のwikiに詳しく載っています。内容に書かれているschema.xmlのfield name="store"、fieldType name="location"は既にexampleでは定義済みなので追記は不要だと思います。

$ vi solr-4.7.0/example/solr/collection1/conf/schema.xml

# 以下はexampleのfieldTypeにdefaultで記載済み
<field name="store" type="location" indexed="true" stored="true"/>
<fieldType name="location" class="solr.LatLonType" subFieldSuffix="_coordinate"/>
テスト

post.shにてindex化したデータに対してSpatial Searchを掛けてみると問題なく対象となるデータが抽出出来ている事が確認できます。ちょいとSpatial Searchとして指定するパラメータが複雑ですが纏めると下の表のようになります。

parameter meaning
fl 結果として抽出するfield
q query (下では*:*を指定)
fq queryのfilter(下では{!geofilt}を指定)
sfield Spatial Searchの対象field
pt 検索位置のlatlng
d ptからの検索対象距離を指定
wt 出力形式

$ curl "http://localhost:8983/solr/select?fl=name,store&q=*%3A*&fq=%7B%21geofilt%7D&sfield=store&pt=45.15,-93.85&d=5&wt=json" | python -mjson.tool
{
    "response": {
        "docs": [
            {
                "name": "Maxtor DiamondMax 11 - hard drive - 500 GB - SATA-300", 
                "store": "45.17614,-93.87341"
            }, 
            {
                "name": "Belkin Mobile Power Cord for iPod w/ Dock", 
                "store": "45.18014,-93.87741"
            }, 
            {
                "name": "A-DATA V-Series 1GB 184-Pin DDR SDRAM Unbuffered DDR 400 (PC 3200) System Memory - OEM", 
                "store": "45.18414,-93.88141"
            }
        ], 
        "numFound": 3, 
        "start": 0
    }, 
    "responseHeader": {
        "QTime": 0, 
        "params": {
            "d": "5", 
            "fl": "name,store", 
            "fq": "{!geofilt}", 
            "pt": "45.15,-93.85", 
            "q": "*:*", 
            "sfield": "store", 
            "wt": "json"
        }, 
        "status": 0
    }
}
実用データでテスト

ソニア - 新橋/お好み焼き [食べログ] はてなブックマーク - ソニア - 新橋/お好み焼き [食べログ]
島風お好み焼きがとても美味しい上のお店のデータでテストしてみます。冒頭の写真はお店のお好み焼き(シングルというメニュー)です。新橋/汐留近辺ではとても有名なお店だと思うので近い方は一度行ってみてください。さて、YahooのGeocoderAPIを使ってお店の住所からlatlngを出し、SolrのIndexに格納しSpatialSearchをやってみます。
食べログの案内に汐留駅から372mと書かれており、パラメータd=0.3では抽出不可、d=0.4=抽出可能という点から正確にSpatialSearchが出来たと思います。

#!/usr/bin/env python
#coding:utf-8
#住所データからSpatial Indexを作成 
#latlon.pyとして保存する
import urllib,urllib2,json,solr
opener = urllib2.build_opener()
ua = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_6_8) AppleWebKit/534.51.22 (KHTML, like Gecko) Version/5.1.1 Safari/    534.51.22'
referer = 'http://www.yahoo.co.jp/'
opener.addheaders = [('User-Agent', ua),('Referer', referer)]
appid = 'Yahoo!' # yahooapiのappidをここに定義
address = u'東京都港区新橋5-15-1'
try:
    url = 'http://geo.search.olp.yahooapis.jp/OpenLocalPlatform/V1/geoCoder?appid=%s&query=%s&output=%s' % (appid, urllib.quote_plus(address.encode('utf-8')), 'json') 
    res = json.loads(opener.open(url).read())
    if (res['ResultInfo']['Status'] == 200 and res['ResultInfo']['Count'] > 0 ):
        (lng,lat) = res['Feature'][0]['Geometry']['Coordinates'].split(',')
        latlng = lat + ',' + lng 
except urllib2.URLError:
    print "Error: API"

try:
    con = solr.Solr('http://localhost:8983/solr')
    doc ={'id':'sonia_latlon', 'name':u'ソニア', 'store':latlng}
    con.add(doc,commit=True)
#汐留駅からの0.5KMの距離で計算
    response = con.select('*:*', 'id,name,store', '', 'true', 'id', 'desc', fq='{!geofilt}', sfield='store', d='0.4', pt='35.662800,139.760000')
    for hit in response.results:
        print hit['id'], hit['name'], hit['store']
except solr.SolrException:
    print "Error: Solr"
$ python latlon.py
sonia_latlon ソニア 35.66223082,139.75596233