2018年3月 9日

infrastructure

インターンシップ生がVespaを使って検索システムを作ってみた話

  • このエントリーをはてなブックマークに追加

はじめに

こんにちは、インターンシップ生の室岡です。

今回は、私がインターンシップで作った「郵便番号検索システム」について紹介させていただきます。

Vespaとは

今回の郵便番号検索システムを作るにあたって、検索エンジンはVespaを使いました。Yahoo! JAPAN内の多くの検索システムでVespaが採用されています。Vespaは2017年9月にOSSとして公開されました。

Vespaには数多くの機能があり本家ドキュメントが充実しており、その機能のうちのいくつかを紹介します。

  • リアルタイム
    大規模なデータを数msで検索できます。

  • ランキング
    多様なマッチングロジックが実装されており、ランキング方法を自分で柔軟にカスタマイズできます。

  • テンソルフレームワーク
    ベクトル、行列演算などの基本的な数学的演算を使うことができます。これにより学習済みモデルを用いて分類や回帰ができます。

また、GitHubのリポジトリはこちらです。

Vespaの動かし方

チュートリアルの通りに実施すれば簡単な検索システムを試すことができました。

やることをまとめると

  • Vespaをセットアップ
  • Vespaを使うサンプルプログラムをGitからダウンロード
  • VespaのConfigServerを起動
  • ダウンロードしたサンプルプログラムをVespaサーバーにデプロイ
  • Vespa本体の起動
  • Vespaに検索データをフィード
  • Vespaに対して検索を実施

これでVespaの動かし方が分かりました。これから実際に郵便番号検索システムにカスタマイズしていきます。

郵便番号検索インデックスの構造を定義

Vespaの動作には、検索インデックスの定義、システム構成、サービスの詳細設定といった設定が必要なため変更を行っていきます。

検索インデックスの定義

基本的に1つのデータに関して1つのfieldが対応します。以下のコードでは表示用と検索用の郵便番号や住所に加え、それらを数値化したデータを持っています。数値化したデータは後に紹介するソートで使います。

src/main/application/searchdefinitions/address.sd

search address { 
    document address {
        field postal_codes type array<string> {
            indexing: index
        }

        field full_addresses type array<string> {
            indexing: index
        }

        field postal_code_int type int {
            indexing: attribute
        }
        field prefecture_int type int {
            indexing: attribute
        }
        field city_int type int {
            indexing: attribute
        }
        field oaza_int type int {
            indexing: attribute
        }

        field full_address type string {
            indexing: summary | index
        }
        field postal_code type string {
            indexing: summary | index
        }
        field prefecture type string {
            indexing: summary | index
        }
        field city type string {
            indexing: summary | index
        }
        field oaza type string {
            indexing: summary | index
        }
    }

    fieldset default {
        fields: postal_codes, full_addresses
    }

    rank-profile default inherits default {
        first-phase {
            expression:nativeRank
        }
        second-phase {
            expression{
                1.0 * fieldMatch(postal_code) + 0.7 * fieldMatch(prefecture) + 0.4 * fieldMatch(city) + 0.1 * fieldMatch(oaza)
            }
        }
    }
}

システム構成の設定

今回は検索に使用するノードが1ノードのみなのでチュートリアル通りの設定を利用しました。

src/main/application/hosts.xml

<?xml version="1.0" encoding="utf-8" ?>
<hosts>
  <host name="localhost">
    <alias>node1</alias>
  </host>
</hosts>

サービスの詳細設定

Vespaをサーバーとして稼働させるために必要な設定です。チュートリアルの設定をインデックス定義にあわせて変更しております。

src/main/application/service.xml

<?xml version="1.0" encoding="utf-8" ?>
<services version="1.0">

  <container id="container" version="1.0">
    <document-api />
    <nodes>
      <node hostalias="node1" />
    </nodes>
  </container>

  <content id="address" version="1.0">
    <redundancy>1</redundancy>
    <documents>
      <document type="address" mode="index" />
    </documents>
    <nodes>
      <node hostalias="node1" distribution-key="0" />
    </nodes>
  </content>

</services>

上記の編集が終わったらこの設定をVespaにデプロイします。

$ vespa-deploy prepare src/main/application && vespa-deploy activate

検索データの作成

全国の郵便番号を郵便局からダウンロードします。ダウンロードした書式はCSVファイルなのでVespaが対応しているJSONファイルに変換する必要があります。今回はPythonを用いてこの処理をしました。CSVファイルを入力としてJSONファイルを出力するPythonのソースコードは以下です。

csv_to_json.py

#!/usr/bin/env python
# -*- coding: utf-8 -*-
import csv
import json

data = []

# Open CSV File
with open('KEN_ALL.CSV', 'r') as f:
    reader = csv.reader(f)
    for row in reader:
        # Get element data
        postal_code = unicode(row[2], encoding='shift_jis').encode('utf-8')
        prefecture = unicode(row[6], encoding='shift_jis').encode('utf-8')
        city = unicode(row[7], encoding='shift_jis').encode('utf-8')
        oaza = unicode(row[8], encoding='shift_jis').encode('utf-8')
        full_address = prefecture + city + oaza

        # Quantified top of character string for sorting
        postal_code_int = int(postal_code[0])
        prefecture_int = int(unicode(row[3], encoding='shift_jis').encode('utf-8')[0:3].encode('hex'), 16)
        city_int = int(unicode(row[4], encoding='shift_jis').encode('utf-8')[0:3].encode('hex'), 16)
        oaza_int = int(unicode(row[5], encoding='shift_jis').encode('utf-8')[0:3].encode('hex'), 16)

        # Generate a part of character string for perfect matching in VESPA
        full_addresses = []

        postal_codes = []
        for i in range(len(postal_code)):
            postal_codes.append(postal_code[0:i+1])

        full_address0 = prefecture + city + oaza
        for i in range(len(full_address0) / 3):
            full_addresses.append(full_address0[0:3 * (i + 1)])

        full_address1 = city + oaza
        for i in range(len(full_address1) / 3):
            full_addresses.append(full_address1[0:3 * (i + 1)])

        full_address2 = oaza
        for i in range(len(full_address2) / 3):
            full_addresses.append(full_address2[0:3 * (i + 1)])

        meta_data = "id:address:address::" + postal_code

        field_data = {"postal_codes": postal_codes,"full_addresses": full_addresses, 
                      "postal_code" : postal_code, "full_address" : full_address, "prefecture" : prefecture, "city" : city, "oaza" : oaza, 
                      "postal_code_int": postal_code_int, "prefecture_int" : prefecture_int, "city_int" : city_int, "oaza_int" : oaza_int}
        data.append({"put": meta_data,"fields": field_data})

# Open JSON File and Write data
with open('postal-code.json', 'w') as jf:
    json.dump(data, jf, indent=4, ensure_ascii=False)

検索データのデプロイ・展開

先ほど作ったJSONファイルをサーバー上に配置します。scpコマンドなどを使ってできます。次にJSONファイルを展開します。以下のコマンドでpostal-code.jsonをVespa仕様に展開できます。

$ java -jar /opt/vespa/lib/jars/vespa-http-client-jar-with-dependencies.jar --file postal-code.json --host localhost --port 8080

検索の実行

Vespaの設定が終わったのでVespaで正しく検索できるか確認してみます。

一般的な検索例

サーバー上で以下のコマンドを入力することで検索できます。検索内容(query)と表示件数(count)を指定して検索しています。

$ curl -s 'http://localhost:8080/search/?query=北海道&count=1' | python -c 'import sys,json;print json.dumps(json.loads(sys.stdin.read()),indent=4,ensure_ascii=False)'
{
    "root": {
        "relevance": 1.0, 
        "fields": {
            "totalCount": 8009
        }, 
        "id": "toplevel", 
        "coverage": {
            "documents": 120012, 
            "resultsFull": 1, 
            "results": 1, 
            "full": true, 
            "coverage": 100, 
            "nodes": 1
        }, 
        "children": [
            {
                "relevance": 0.0, 
                "source": "address", 
                "id": "id:address:address::0640941", 
                "fields": {
                    "city": "札幌市中央区", 
                    "full_address": "北海道札幌市中央区旭ケ丘", 
                    "sddocname": "address", 
                    "postal_code": "0640941", 
                    "prefecture": "北海道", 
                    "documentid": "id:address:address::0640941", 
                    "oaza": "旭ケ丘"
                }
            }
        ]
    }
}

レスポンス形式の変更

検索結果のレスポンスをXMLに変更することもできます。

$ curl -s 'http://localhost:8080/search/?query=359&count=1&template=xml'
<?xml version="1.0" encoding="utf-8" ?>
<result total-hit-count="83" coverage-docs="120012" coverage-nodes="1" coverage-full="true" coverage="100" results-full="1" results="1">
  <hit relevancy="0.0" source="address">
    <field name="relevancy">0.0</field>
    <field name="sddocname">address</field>
    <field name="full_address">埼玉県所沢市並木</field>
    <field name="postal_code">3590042</field>
    <field name="prefecture">埼玉県</field>
    <field name="city">所沢市</field>
    <field name="oaza">並木</field>
    <field name="documentid">id:address:address::3590042</field>
  </hit>
</result>

ソート

検索結果の表示順を名前順で昇順や降順で並べ替えたい時にこの機能を使います。郵便番号や住所を名前順になるように数値化(文字コードに変換)しておき、それを検索インデックスとして登録することでソートを可能にします。

$ curl -s 'http://localhost:8080/search/?query=567&count=3&template=xml&sorting=+oaza_int'
<?xml version="1.0" encoding="utf-8" ?>
<result total-hit-count="149" coverage-docs="120012" coverage-nodes="1" coverage-full="true" coverage="100" results-full="1" results="1">
  <hit relevancy="0.0" source="address">
    <field name="relevancy">0.0</field>
    <field name="sddocname">address</field>
    <field name="full_address">大阪府茨木市安威</field>
    <field name="postal_code">5670001</field>
    <field name="prefecture">大阪府</field>
    <field name="city">茨木市</field>
    <field name="oaza">安威</field>
    <field name="documentid">id:address:address::5670001</field>
  </hit>
  <hit relevancy="0.0" source="address">
    <field name="relevancy">0.0</field>
    <field name="sddocname">address</field>
    <field name="full_address">大阪府茨木市主原町</field>
    <field name="postal_code">5670897</field>
    <field name="prefecture">大阪府</field>
    <field name="city">茨木市</field>
    <field name="oaza">主原町</field>
    <field name="documentid">id:address:address::5670897</field>
  </hit>
  <hit relevancy="0.0" source="address">
    <field name="relevancy">0.0</field>
    <field name="sddocname">address</field>
    <field name="full_address">大阪府茨木市鮎川</field>
    <field name="postal_code">5670831</field>
    <field name="prefecture">大阪府</field>
    <field name="city">茨木市</field>
    <field name="oaza">鮎川</field>
    <field name="documentid">id:address:address::5670831</field>
  </hit>
</result>

ランキング

検索結果の表示順をより高度に制御するためにランキング式を定義できます。もし、ランキング式を定義しなかった場合、「岡山」というワードで検索すると以下のように大阪府のヒットした住所が一番上に出てきてしまいます。

$ curl -s 'http://localhost:8080/search/?query=岡山&count=3' | python -c 'import sys,json;print json.dumps(json.loads(sys.stdin.read()),indent=4,ensure_ascii=False)'
{
    "root": {
        "relevance": 1.0, 
        "fields": {
            "totalCount": 2202
        }, 
        "id": "toplevel", 
        "coverage": {
            "documents": 124172, 
            "resultsFull": 1, 
            "results": 1, 
            "full": true, 
            "coverage": 100, 
            "nodes": 1
        }, 
        "children": [
            {
                "relevance": 0.21866642984009677, 
                "source": "address", 
                "id": "id:address:address::大阪府四條畷市岡山(丁目)", 
                "fields": {
                    "sddocname": "address", 
                    "documentid": "id:address:address::大阪府四條畷市岡山(丁目)"
                }
            }, 
            {
                "relevance": 0.21866642984009677, 
                "source": "address", 
                "id": "id:address:address::大阪府四條畷市岡山(番地)", 
                "fields": {
                    "sddocname": "address", 
                    "documentid": "id:address:address::大阪府四條畷市岡山(番地)"
                }
            }, 
            {
                "relevance": 0.20204402297237936, 
                "source": "address", 
                "id": "id:address:address::岡山県岡山市北区御津矢知", 
                "fields": {
                    "sddocname": "address", 
                    "documentid": "id:address:address::岡山県岡山市北区御津矢知"
                }
            }
        ]
    }
}

通常は「岡山」と検索した場合は岡山県が一番上に出てくることを期待することが多いのでランキングの機能を使います。今回はこの機能を実現するためにaddress.sd中のrank-profile中でランキング方法の定義をしています。

src/main/application/searchdefinitions/address.sdから抜粋

# ランキングの設定
rank-profile default inherits default {
    first-phase {
        expression:nativeRank
    }
    second-phase {
        expression{
            1.0 * fieldMatch(postal_code) + 0.7 * fieldMatch(prefecture) + 0.4 * fieldMatch(city) + 0.1 * fieldMatch(oaza_int)
        }
    }
}

通常ランキングは複数の段階に分けます。最初は大ざっぱなランキングをしてから次に細部のランキングをすることで計算時間を短くできます。

first-phaseが最初のランキングでデフォルトの大ざっぱなランキングであるnativeRankを使っています。次にsecond-phase1.0 * fieldMatch(postal_code) + 0.7 * fieldMatch(prefecture) + 0.4 * fieldMatch(city) + 0.1 * fieldMatch(house_number)の数式に従いランキングをしています。簡単に説明すると、これはprefecturecityhouse_numberが検索に引っかかっているかどうかに関して重みづけをしてその線形和を点数とし、その点数が高い順にランキングします。

このランキングを設定して再びデプロイしたのち「岡山」で検索をかけると、以下のように岡山県のみがヒットしていることがわかります。

$ curl -s 'http://localhost:8080/search/?query=岡山&count=3' | python -c 'import sys,json;print json.dumps(json.loads(sys.stdin.read()),indent=4,ensure_ascii=False)'
{
    "root": {
        "relevance": 1.0, 
        "fields": {
            "totalCount": 2192
        }, 
        "id": "toplevel", 
        "coverage": {
            "documents": 120012, 
            "resultsFull": 1, 
            "results": 1, 
            "full": true, 
            "coverage": 100, 
            "nodes": 1
        }, 
        "children": [
            {
                "relevance": 0.0, 
                "source": "address", 
                "id": "id:address:address::7038267", 
                "fields": {
                    "city": "岡山市中区", 
                    "full_address": "岡山県岡山市中区山崎", 
                    "sddocname": "address", 
                    "postal_code": "7038267", 
                    "prefecture": "岡山県", 
                    "documentid": "id:address:address::7038267", 
                    "oaza": "山崎"
                }
            }, 
            {
                "relevance": 0.0, 
                "source": "address", 
                "id": "id:address:address::7028046", 
                "fields": {
                    "city": "岡山市南区", 
                    "full_address": "岡山県岡山市南区立川町", 
                    "sddocname": "address", 
                    "postal_code": "7028046", 
                    "prefecture": "岡山県", 
                    "documentid": "id:address:address::7028046", 
                    "oaza": "立川町"
                }
            }, 
            {
                "relevance": 0.0, 
                "source": "address", 
                "id": "id:address:address::7038256", 
                "fields": {
                    "city": "岡山市中区", 
                    "full_address": "岡山県岡山市中区浜", 
                    "sddocname": "address", 
                    "postal_code": "7038256", 
                    "prefecture": "岡山県", 
                    "documentid": "id:address:address::7038256", 
                    "oaza": "浜"
                }
            }
        ]
    }
}

Searchについて詳しくはこちらをご覧ください。

クエリの正規化

検索クエリは、ユーザーが実際に入力する文字列のため、表記のユレが存在します。例えば「1028282」という郵便番号を検索する場合、「102-8282」や「〒1028282」で検索をかけるユーザーもいるかもしれません。その場合でも検索を可能にするため、「-」や「〒」などの余分な文字があった場合にそれらを排除した方が良いかもしれません。

Vespaではこのような処理をInjecting componentsで処理を挿入することができます。

今回の要件を実装してみたコードは以下の通りです。
src/main/java/com/yahoo/search/zipsearch/vespa/ZipSearcher.java

package com.yahoo.search.example;

import com.yahoo.search.*;
import com.yahoo.search.result.Hit;
import com.yahoo.search.searchchain.Execution;

public class ZipSearcher extends Searcher {

    public Result search(Query query, Execution execution) {
        String queryString = query.properties().getString("query");
        queryString = queryString.replace("〒", "");
        queryString = queryString.replace(" ", "");
        queryString = queryString.replace(" ", "");
        queryString = queryString.replace("-", "");
        queryString = queryString.replace("ー", "");
        queryString = queryString.replace("郵便番号", "");

        query.getModel().setQueryString(queryString);
        Result result = execution.search(query);

        if(queryString.contains("埼玉市") || queryString.contains("埼玉県埼玉")) {
            Hit hit = new Hit("test");
            hit.setField("message", "常識ですがさいたま市はひらがなで書きます。");
            result.hits().add(hit);
            return result;
        }

        return result;
    }

}

そして依存関係を注入するためにservices.xmlに追記を行います。

src/main/application/services.xml

<services version="1.0">

  <container id="container" version="1.0">
    <document-api />
    <search>
          <chain id="default" inherits="vespa">
            <searcher id="com.yahoo.search.example.ZipSearcher" bundle="components"/>
          </chain>
    </search>
    <nodes>
      <node hostalias="node1" />
    </nodes>
  </container>

  <content id="address" version="1.0">
    <redundancy>1</redundancy>
    <documents>
      <document type="address" mode="index" />
    </documents>
    <nodes>
      <node hostalias="node1" distribution-key="0" />
    </nodes>
  </content>

</services>

上記の編集が終わったらこの設定をVespaにデプロイします。

$ vespa-deploy prepare src/main/application && vespa-deploy activate

こうすることで任意のAPIなどを用意することなく検索に関する処理を追加できるのもVespaのユニークな特徴かもしれません。

詳細はこちらをご覧ください。

今後の課題

今回実装した郵便番号検索システムでは「北海道札幌市北区北十条西5丁目」のような「丁目」や「番地」のワードが入っている住所の検索は対応できませんでした。郵便局からダウンロードした郵便番号CSVファイルが以下のような書式になっており、「丁目」や「番地」をうまくJSONファイルに書き下すのが困難だったからです。

KEN_ALL.CSVより一部抜粋

"北一条西海道","札幌市中央区","北一条西(1~19丁目)",
"宮城県","刈田郡蔵王町","円田(釜沢、善舞森、土浮谷地、土浮山2~5番地)",
"北海道","旭川市","東旭川町東桜岡(30~499番地)",
"北海道","石狩市","八幡町(その他)",
"北海道","山越郡長万部町","静狩(3~1番地)",
"北海道","奥尻郡奥尻町","湯浜(神威脇、幌内)",
"北海道","久遠郡せたな町","瀬棚区本町(4区)",
"北海道","虻田郡京極町","以下に掲載がない場合",

また、郵便番号CSVファイルの仕様として以下のように住所が長い場合は複数行にまたいで書かれています。この住所も今の実装だと検索することはできません。

KEN_ALL.CSVより一部抜粋

千葉県","旭市","琴田(85~160、1386~1815、2\\
593~2820、",
"千葉県","旭市","3598~3614番地)",

これらのユースケースに対応することが今後の課題です。

おわりに

今回、Vespaを用いて郵便番号検索システムを作りました。インターンの期間が一週間と短かったですが、Vespaの特徴や使い方を学ぶことができました。その他、サーバーサイド側や機械学習を使って開発をしているエンジニアの方達とお話をする機会をいただきとてもためになる一週間でした。Vespaはとてもレスポンスが速く追加機能も豊富な検索エンジンです。とても手軽に試せるのでみなさんぜひ一度Vespaを使ってみてはいかがでしょうか。

余談ですが、3月13日にYahoo! JAPAN MEETUPでVespaの開発者の話を聞くことができます。

予定が空いている方はぜひお越しください!

PS. N-gramについてもチャレンジできると良いかもしれませんね。匿名希望のメンターMより。

Yahoo! JAPANでは情報技術を駆使して人々や社会の課題を一緒に解決していける方を募集しています。詳しくは採用情報をご覧ください。

  • このエントリーをはてなブックマークに追加