はてだBlog(仮称)

私的なブログど真ん中のつもりでしたが、気づけばWebサイト系のアプリケーション開発周りで感じたこと寄りの自分メモなどをつれづれ述べています。2020年6月現在、Elasticsearch、pandas、CMSなどに関する話題が多めです。...ですが、だんだんとより私的なプログラムのスニペット置き場になりつつあります。ブログで述べている内容は所属組織で販売している製品などに関するものではなく、また所属する組織の見解を代表するものではありません。

Elasticsearch公式JavaScriptクライアントをブラウザで使ってみる&Vue.js/Vuetifyでなんちゃって検索サービスSPA風をスクラッチ

はじめに

Elasticsearchには、JavaScript版の公式クライアントライブラリがあるのですが、ブラウザでも動作するようなので、それで主に検索中心に少し遊んでみました。 といっても、ここ↓にある例のまんまです。

www.elastic.co

ただし、さすがに上記のものまんまでは記事としてはウリが弱いです。

そこで、Vue.jsとその有名UIフレームワークであるvuetifyを付け焼き刃で付け足してみて、検索サービスっぽい絵面にしてみました。

f:id:azotar:20190305203709p:plain

Elasticsearchのエンドポイントへhttp/httpsが届く圏内にある端末であれば、後述の内容のコピペ+生のHTMLをちょいと修正するだけで、(ほぼ)ブラウザだけで、Elasticsearchに接続して、多少自分でゴリゴリ・ごにょごにょできるサンドボックスができるねという話です。

この記事の隠れ想定シーン

長くなるので詳しくは述べませんが、この記事は気持ち的には次の記事の続きです。

itdepends.hateblo.jp

本格的ツールを持ち込めない謎区画に、HTMLファイルならただのテキストファイルですからってことで正規に持ち込める中で苦しくも頑張ろう...というようなシーンを意識しています。

その割にVue.jsなどを使って少しズル?をしています。

が...これらも含めて生のjsファイルも持ち込めば、本当に端末のデスクトップでHTMLファイルのアイコンをダブルクリックしても動くものになっています。 *1

ライブラリ等について

ElasticsearchのJavaScript版の公式クライアントについては、様々なAPIが提供されていますが、今回は、クエリDSLがそのまま使えるsearch apiのみを使いました。*2

www.elastic.co

Vue.jsについてはここでは説明しませんが、見てのとおりCDNから読み込みすることとしました。つまりビルド環境などは特に用いません。

vuetifyは、Vue.jsをプラットフォームとしたUIフレームワークです。雑にいうと、マテリアルデザイン風のパーツをBootstrapライクなグリッドシステム型など指定でCSSを用意せずに、それっぽく組み合わせてUIが作成できます。

なお、vuetifyも、CDNから読み込みしています。なのでひとまずエコシステム等を意識せずにひとまず動かしてみることができます。

また、ここでは深くふれませんが、代わりにvuetifyの公式リファレンスへリンクしておきます。 Getting startedのページを流し読みして、その後、画面左のメニューのところで、UI Componentsのところをつまみ食いして覚えていくのが近道だと思いました。

vuetifyjs.com

予備知識: 公式クライアントを用いた最小限の動作方法の例

この手のものは、公式ドキュメントが充実しててもしてなくても、それが動いてみるまではなかなかに厄介です。

試行錯誤の結果(?)、これがおおよそ最小限の検索の例かな...というところを予備知識として示しておきます。

Elasticsearchブラウザ版 JavaScriptクライアントライブラリのミニマム使用例

下記をhoge.htmlなどにコピペ保存して、インデックスや接続先を所定のものに書き換えてください。

ブラウザを起動して、consoleを開くと、検索結果のJSONが返ってくるのが分かると思います。

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
</head>
<body>
    ここには何もでません。<br>
    ブラウザのConsoleをごらんください。
</body>
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
<script src="./elasticsearch.jquery.min.js"></script><!-- https://github.com/elastic/bower-elasticsearch-js -->
<script>
    const ES_HOST = { hosts: 'http://localhost:9200' }
    const INDEX_NAME = 'es_index'

    const client = new $.es.Client(ES_HOST)
    async function search() {
        let rslt = await client.search(
            {
                index: INDEX_NAME,
                body: {
                    query: { match_all: {} }
                }
            }
        )
        console.info(JSON.stringify(rslt))
    }

    search()
</script>
</html>

今回用いるElasticsearchのインデックスの塩梅

さて、本題に戻ります。

インデックス名は、es_indexとしました。

また、ここでは簡単のため、localhost:9200でElasticsearchのエンドポイントが待っていることとしました。

なお、インデックスデータですが「livedoorグルメDataSets」 http://blog.livedoor.jp/techblog/archives/65836960.html

を次のイメージのJSONファイルに変換して、これをバルクロードしたものを元ネタとしたという体で記載しています。

{"index": { "_id":999 }}
{"id":105,"name":"お店の名前","alphabet":null,"name_kana":"おみせのなまえのかな","pref_id":13,"area_id":1,"category_id1":509,"category_id2":0,"category_id3":0,"category_id4":0,"category_id5":0,"zip":"111-9999","address":"中央区金座1-2-3","north_latitude":"99.40.09.271","east_longitude":"199.46.66.911","description":" 金座駅A13出口徒歩1分。","special_count":1,"menu_count":3,"fan_count":0,"name_area":"銀座、新橋、有楽町","cats":["地酒","","","",""],"name_pref":"東京都"}

参考:livedoorデータセットを取り込むための手順の例

なお、elasticsearch.jsはCDNが分からなかったので、ローカルに置いています。

Vue.jsとvuetifyを使って検索サービスの雰囲気を出してみる(本記事の本題)

ということで、SPAならぬ、ほぼシングルファイル1枚資材による検索サービス風、PoC、トラブル調査のツールです。

なお、Elasticsearchに繋げなくても、ひとまず、インターネットに繋がっていれば、それっぽい検索窓は表示されると思います。

<!DOCTYPE html>
<html>

<head>
    <meta charset="UTF-8">
    <link href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900|Material+Icons" rel="stylesheet">
    <link href="https://cdn.jsdelivr.net/npm/vuetify/dist/vuetify.min.css" rel="stylesheet">
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, minimal-ui">
</head>
<style>
    html {
        overflow-y: scroll;
    }
</style>
<body>
    <div id="app" app-data="true">
        <v-container>
            <v-card>
                <v-flex>
                    <v-card color="grey lighten-4" flat>
                        <v-flex px-4 py-4>
                            <v-layout column>

                                <v-layout row>
                                    <v-flex md5>
                                        <v-text-field v-model="sbox.kw1" label="Find" solo clearable autocomplete="on"
                                            list="sugList" @input="sbox_suggest(1)">
                                        </v-text-field>
                                        <datalist id="sugList">
                                            <option v-for="(item, index) in sugList" :key="index" :value="item"></option>
                                        </datalist>
                                    </v-flex>
                                    <v-flex md5>
                                        <v-text-field v-model="sbox.kw2" label="Near" solo clearable autocompleate="on"
                                            list="sugList2" @input="sbox_suggest(2)"></v-text-field>
                                        <datalist id="sugList2">
                                            <option v-for="(item, index) in sugList2" :key="index" :value="item"></option>
                                        </datalist>
                                    </v-flex>
                                    <v-flex md2>
                                        <v-btn color="blue" v-on:click="es_search(0)">
                                            <v-icon color="white">search</v-icon><span class="white--text">Search</span>
                                        </v-btn>
                                    </v-flex>
                                </v-layout>

                                <v-layout row>
                                    <v-flex md3>
                                        <v-select :items="s_items" solo label="Restaurants">
                                        </v-select>
                                    </v-flex>
                                    <v-flex md3>
                                        <v-select :items="s_items" solo label="Home Services"></v-select>
                                    </v-flex>
                                    <v-flex md3>
                                        <v-select :items="s_items" solo label="Auto Services"></v-select>
                                    </v-flex>
                                    <v-flex md3>
                                        <v-select :items="s_items" solo label="More..."></v-select>
                                    </v-flex>
                                </v-layout>

                            </v-layout>
                            <v-layout>
                                <v-flex>
                                    <v-btn flat small color="blue" class="font-weight-bold">
                                        <v-icon>highlight</v-icon>hot genre
                                    </v-btn>
                                    <v-btn flat small color="blue" class="font-weight-bold">
                                        <v-icon>highlight</v-icon>hot word
                                    </v-btn>
                                    <v-btn flat small color="blue" class="font-weight-bold">
                                        <v-icon>highlight</v-icon>hot tag
                                    </v-btn>
                                    <v-btn flat small color="blue" class="font-weight-bold">
                                        <v-icon>highlight</v-icon>sponsered word
                                    </v-btn>
                                    <v-btn flat small color="blue" class="font-weight-bold">
                                        <v-icon>highlight</v-icon>sponsered filter
                                    </v-btn>
                                    <v-btn flat small color="blue" class="font-weight-bold">
                                        <v-icon>highlight</v-icon>recomend...
                                    </v-btn>
                                </v-flex>
                            </v-layout>

                        </v-flex>
                    </v-card>
                </v-flex>


                <v-flex v-if="total" px-4 py-4>
                    <v-card dark tile flat color="grey lighten-4">
                        <v-layout row>
                            <v-flex md10 grow px-4 py-1>
                                <v-card-text class="black--text">
                                    {{cond}} {{total}}件ヒットしました: 現在{{ctrl.current}}件表示中
                                </v-card-text>
                            </v-flex>
                            <v-flex md1 shrink>
                                <v-menu offset-y open-on-hover>
                                    <v-btn slot="activator" flat color="black">
                                        Sort by<v-icon>expand_more</v-icon>
                                    </v-btn>
                                    <v-list>
                                        <v-list-tile v-for="(item, index) in s_items" :key="index" @click="">
                                            <v-list-tile-title>選択肢</v-list-tile-title>
                                        </v-list-tile>
                                    </v-list>
                                </v-menu>
                            </v-flex>
                        </v-layout>
                    </v-card>
                    <v-flex px-4>
                        <v-breadcrumbs :items="b_items" divider=">"></v-breadcrumbs>
                    </v-flex>

                    <v-flex px-4 py-2>
                        <v-layout column>
                            <v-flex>
                                <v-dialog v-model="push_me_dialog" width="500">

                                    <v-btn color="white" small slot="activator">
                                        <v-icon>flag</v-icon><span>Push Me!</span>
                                    </v-btn>


                                    <v-card>
                                        <v-card-title class="headline grey lighten-2" primary-title>
                                            探すエリアを変更
                                        </v-card-title>

                                        <v-card-text>
                                            下記に選択肢を表示し、検索BOX2の条件を選択し直します。レストラン検索なら住所やエリア、駅などの一覧を表示。
                                        </v-card-text>

                                        <v-divider></v-divider>

                                        <v-card-text>
                                            住所一覧/エリア一覧/駅一覧...
                                        </v-card-text>
                                        <v-card-actions>
                                            <v-spacer></v-spacer>
                                            <v-btn color="primary" flat @click="push_me_dialog = false">
                                                <v-icon>search</v-icon> 再検索
                                            </v-btn>
                                        </v-card-actions>
                                    </v-card>
                                </v-dialog>

                                <template v-for="n in 3">
                                    <v-btn color="white" small>
                                        <v-icon>flag</v-icon><span>Push</span>
                                    </v-btn>
                                </template>

                                <template v-for="n in 2">
                                    <v-menu offset-y open-on-hover>
                                        <v-btn slot="activator" small color="white">
                                            <v-icon>flag</v-icon>
                                            SELECT
                                        </v-btn>
                                        <v-list>
                                            <v-list-tile v-for="(item, index) in s_items" :key="index" @click="">
                                                <v-list-tile-title>単一絞り込み</v-list-tile-title>
                                            </v-list-tile>
                                        </v-list>
                                    </v-menu>
                                </template>

                                <template v-for="n in 3">
                                    <v-btn color="white" small>
                                        <v-icon>flag</v-icon><span>Push</span>
                                    </v-btn>
                                </template>
                            </v-flex>
                            <v-flex>
                                <v-btn-toggle v-model="toggle_multiple" multiple>
                                    <template v-for="n in 4">
                                        <v-btn color="white" small>
                                            <v-icon>flag</v-icon><span>PUSH</span>
                                        </v-btn>
                                    </template>
                                </v-btn-toggle>
                                <v-btn-toggle v-model="toggle_multiple" multiple>
                                    <v-btn color="white" small>
                                        <v-icon>schedule</v-icon><span>OpenNow</span>
                                    </v-btn>
                                </v-btn-toggle>

                                <v-btn-toggle v-model="toggle_multiple" multiple>
                                    <template v-for="n in 3">
                                        <v-btn color="white" small>
                                            <v-icon>flag</v-icon><span>PUSH</span>
                                        </v-btn>
                                    </template>
                                </v-btn-toggle>

                                <v-btn-toggle v-model="toggle_multiple" multiple>
                                    <template v-for="n in 2">
                                        <v-btn color="white" small>
                                            <v-icon>flag</v-icon><span>PUSH</span>
                                        </v-btn>
                                    </template>
                                </v-btn-toggle>

                            </v-flex>
                            <v-flex>
                                <template v-for="(v,k) in aggs">
                                    <v-btn small outline round v-on:click="es_search(0,k,v2.key)" v-for="(v2,i2) in v.buckets"
                                        v-bind:key="v2.key">
                                        {{v2.key}}
                                    </v-btn>
                                </template>
                            </v-flex>
                        </v-layout>
                    </v-flex>




                    <hr>
                    <v-list two-line>
                        <template v-for="(item, index) in hits">
                            <v-subheader v-if="item.header" :key="item.header">
                                {{ item.header }}
                            </v-subheader>

                            <v-divider v-else-if="item.divider" :key="index" :inset="item.inset"></v-divider>

                            <v-list-tile v-else :key="item._source._id" avatar @click=""> 

                                <v-list-tile-avatar>
                                    <v-icon>card_giftcard</v-icon>
                                </v-list-tile-avatar>
                                <v-list-tile-content>
                                    <v-list-tile-title>
                                        <span>{{index + 1}}</span>.
                                          {{item._source.name}}({{item._source.name_kana}})
                                        <!-- //CONFIG -->
                                    </v-list-tile-title>
                                    <v-list-tile-sub-title v-if="item._source.cats && item._source.location">
                                        レコードID:{{item._id}} / {{item._source.address}}
                                        /カテゴリ:{{item._source.cats.join(',')}}/緯度経度:{{item._source.location.join(',')}}/スコア:{{item._score}}
                                    </v-list-tile-sub-title>
                                </v-list-tile-content>
                            </v-list-tile>
                        </template>
                        <span id="rslt_jump_anchor"></span>

                        <v-list-tile>
                            <v-list-tile-content>
                                <v-layout v-show="ctrl.current != total">
                                    <v-fab-transition>
                                        <v-btn color="grey lighten-1 " dark v-on:click="fetch_by_pushed" href="#rslt_jump_anchor">
                                            <v-icon>flash_on</v-icon>次のページを読み込み
                                        </v-btn>
                                    </v-fab-transition>
                                </v-layout>
                            </v-list-tile-content>
                        </v-list-tile>

                    </v-list>

                    <v-layout column style="position: fixed;  bottom: 0px; right: 50px; z-index: 999; ">
                        <v-fab-transition>
                            <v-btn v-show="current_scroll_position > 800 && ctrl.expand_less.valid" color="red lighten-1"
                                dark fab href="#app">
                                <v-icon>expand_less</v-icon>
                            </v-btn>
                        </v-fab-transition>
                    </v-layout>


                </v-flex>

                <div v-show="total==0">
                    検索結果が0件でした。以下の...
                    <v-layout column>
                        <v-btn flat small color="primary"> <v-icon>highlight</v-icon>もしかして(正確な用語に補正、ピボット、検索範囲を拡大...)</v-btn>
                        <v-btn flat small color="primary"> <v-icon>highlight</v-icon>関連検索ワード(履歴、ピボット、検索範囲を拡大...)</v-btn>
                    </v-layout>
                </div>

            </v-card>
        </v-container>

    </div>

</body>
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vuetify/dist/vuetify.js"></script>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
<script src="./elasticsearch.jquery.min.js"></script><!-- https://github.com/elastic/bower-elasticsearch-js -->
<script>

    //CONFIG
    const ES_HOST = { hosts: 'http://localhost:9200' }
    const INDEX_NAME = 'es_index'

    class OrganicSearchQueryBuilder {
        constructor() {
            //CONFIG
            this.SIZE = 10
            this.KWBOX1_FIELDS = ["name"]
            this.KWBOX2_FIELDS = ["address"]
            this.AGGS_FIELDS = ["cats"]
            this.AGGS = {}
            for (let v of this.AGGS_FIELDS) {
                this.AGGS[v] = { terms: { field: v } }
            }
            this.postFilterWordsSet = new Set()
        }
        getMatchDSL(kw, fields) {
            return { multi_match: { query: kw, fields: fields, operator: "and" } }
        }
        getAggsDSL(from, pfw) {
            if (from == 0 || from === undefined) {
                this.postFilterWordsSet = new Set()
            }
            if (pfw !== undefined) {
                this.postFilterWordsSet.add(pfw)
            }
            if (this.postFilterWordsSet.size == 0) {
                return { bool: { must: [{ match_all: {} }] } }
            }
            let postFilter = { bool: { must: [] } }
            for (let i of this.postFilterWordsSet.values()) {
                postFilter.bool.must.push({ match: { [this.AGGS_FIELDS[0]]: i } }) //CONFIG
            }
            return postFilter
        }
        get2BoxQuery(kw1, kw2, from, pfw) {
            let b = {
                size: this.SIZE, from: from,
                query: {
                    bool: {
                        must: [
                            (kw1 === undefined || kw1 == null || kw1 == '') ? { match_all: {} } : this.getMatchDSL(kw1, this.KWBOX1_FIELDS),
                            (kw2 === undefined || kw2 == null || kw2 == '') ? { match_all: {} } : this.getMatchDSL(kw2, this.KWBOX2_FIELDS)
                        ]
                    }
                },
                post_filter: this.getAggsDSL(from, pfw),
                aggs: this.AGGS
            }
            // console.log(JSON.stringify(b))
            return { index: INDEX_NAME, body: b }
        }
        getFilterWordsString() {
            return "  " + Array.from(this.postFilterWordsSet).join(",")
        }
    }

    const client = new $.es.Client(ES_HOST)
    let QB = new OrganicSearchQueryBuilder()

    var vm = new Vue({
        el: '#app',
        data: () => ({
            s_items: ['Foo', 'Bar', 'Fizz', 'Buzz'],
            sbox: {
                kw1: "",
                kw2: ""
            },
            cond: "",
            total: null,
            hits: null,
            aggs: null,
            filter: null,
            ctrl: {
                current: 0,
                loading: false,
                expand_less: {
                    valid: true
                }
            },
            sugList: [],
            sugList2: [],
            sugLock: false,
            current_scroll_position: 0,
            toggle_multiple: [],
            b_items: [
                {
                    text: 'トップ',
                    disabled: false,
                    href: '#'
                },
                {
                    text: 'パンくず1',
                    disabled: false,
                    href: '#'
                },
                {
                    text: 'パンくず2',
                    disabled: false,
                    href: '#'
                },
                {
                    text: '条件1, 条件2',
                    disabled: true,
                    href: 'breadcrumbs_link_2'
                }
            ],
            push_me_dialog: false
        })
        ,
        mounted: function () {
            window.addEventListener('scroll', this.fetch_by_scrolled)
            window.removeEventListener('wheel', this.fetch_by_scrolled)

            document.onscroll = (e) => {
                this.current_scroll_position = document.documentElement.scrollTop || window.scrollY // document.body.scrollTop ||
            }
        },
        destroyed: function () {
            window.addEventListener('scroll', this.fetch_by_scrolled)
            window.removeEventListener('wheel', this.fetch_by_scrolled)
        },
        methods: {
            es_search: async function (from, aggsField, aggsValue) {
                if (from > 0 && (this.total != null && this.total == this.ctrl.current)) {
                    return true
                }
                if (this.ctrl.loading == true) { // ざっくり排他制御 -- ここから
                    return true;
                }
                this.ctrl.loading = true
                {
                    let rslt = await client.search(QB.get2BoxQuery(this.sbox.kw1, this.sbox.kw2, from, aggsValue))
                    if (from == 0) {
                        this.total = rslt.hits.total
                        this.filter = QB.getFilterWordsString()
                        this.hits = rslt.hits.hits
                        this.aggs = rslt.aggregations
                        this.ctrl.current = Object.keys(rslt.hits.hits).length   //rslt.hits.hitsは配列ではなくObjectらしい
                    } else {
                        this.hits = this.hits.concat(rslt.hits.hits) // 無限スクロールを考慮して後ろに結合する
                        this.ctrl.current += Object.keys(rslt.hits.hits).length   //rslt.hits.hitsは配列ではなくObjectらしい
                    }
                }
                this.cond = this.sbox.kw1 + " " + this.sbox.kw2 + " " + this.filter
                this.ctrl.loading = false; // ざっくり排他制御 -- ここまで
            },
            fetch_by_pushed: function () {
                let next = this.ctrl.current
                this.es_search(next)
            },
            fetch_by_scrolled: function (e) {
                let docHeight = document.documentElement.scrollHeight // 参考: window.innerHeight 現在表示中のウィンドウのサイズ
                // console.log([this.current_scroll_position, docHeight * 0.25])
                if (this.current_scroll_position > docHeight * 0.25) { // スクロールのカーソルがドキュメントの90%あたり(この計算方法だと、閾値 0.25ぐらい)まで来たらフェッチする。
                    let next = this.ctrl.current
                    this.es_search(next)
                } else {
                    //無視する
                }
            },
            sbox_suggest1: function (FIELD, timeout) {
                let kwbox = this.sbox.kw1
                if (this.sugLock || kwbox <= 1) {
                    return true
                }
                console.log(FIELD)
                this.sugLock = true
                setTimeout(async function () {
                    let suggest = await client.search({
                        index: INDEX_NAME,
                        body:
                            { query: { match_phrase_prefix: { [FIELD]: kwbox } }, _source: [FIELD], size: 10 }
                    })
                    this.sugList = Array.from(new Set(suggest.hits.hits.map(x => x._source[FIELD]))) //ユニークにする
                    this.sugLock = false
                }.bind(this), timeout)
            },
            sbox_suggest2: function (FIELD, timeout) {
                let kwbox = this.sbox.kw2
                if (this.sugLock || kwbox <= 1) {
                    return true
                }
                console.log(FIELD)
                this.sugLock = true
                setTimeout(async function () {
                    let suggest = await client.search({
                        index: INDEX_NAME,
                        body:
                            { query: { match_phrase_prefix: { [FIELD]: kwbox } }, _source: [FIELD], size: 10 }
                    })
                    this.sugList2 = Array.from(new Set(suggest.hits.hits.map(x => x._source[FIELD]))) //ユニークにする
                    this.sugLock = false
                }.bind(this), timeout)
            },
            sbox_suggest: function (box_id) {
                if (box_id == 1) {
                    this.sbox_suggest1("name", 300) //CONFIG
                } else {
                    this.sbox_suggest2("address", 300) //CONFIG
                }
            }
        }
    })

</script>
</html>

ここで、条件に従ったインデックスが用意してあれば、次の部分は動くようにしかけてあります。

  1. フリー入力の検索BOX(2BOXのAND検索)とお店名と住所のオートコンプリート
  2. 検索結果
  3. 検索時に表示される角丸のお店のカテゴリ分類による絞り込み
  4. おまけで、無限スクロール風のギミック
  5. Elasticsearchのクエリ設定と上記などの検索結果の編集部分は、「CONFIG」というコメント文をつけているので、その周辺をそれらしく編集して、接続先のインデックスを   変更したりしてみてください。私のスキル(弱いエンジニアリング)と今回のあえて薄い作りにしたいという

一方、話のついでにvuetifyを使ってみたくて、とりあえずコンポーネントを配置しただけのものもあります。...ので上記以外はハリボテで動かないパーツもあります。ご了承ください。

まとめ

冒頭で設定した(言い訳した?)とおり、閉区画などにありがちなシーンを想定して、開発環境らしきものがなくても、頑張ればサクッとファイルの内容を変更しやすいように、1プログラム資材としてみました。

が試しにやってみたのですが、ハイライトのないWindowsメモ帳エディタなどで開いたらそれだけで死にそうでした。が、その気になればなんとかなると思います。

また、見れば分かると思いますが、Vue.jsもvuetifyの利用の仕方や、同期系の処理や排他についてはツールとしての割り切りになっているところがあります。

例えば、オートコンプリートも本来はこの例だとwatchの方がスジが良いと思いますが、設定よりも規約よりもハードコーディングとしましたので、そんな人はいないと思いますが、動作確認や簡易なツール以上のものを用意する際には、あるべき姿のコーディングスタイルを別途ご確認ください。

付録1: livedoorレストランデータセットについて

上記でlivedoorレストランデータセットを使った...としました。

参考までに、Elasticsearchさえ入っていれば、というところになりますが、このデータセットを取り込む例を示します。ここでは、こういうフォーマットであれば、こうやって取り込む方法があるという例で示しますので、データそのものの引用は控えています。

行間を読んでいただく必要はありますが、ご参考になればということで以下ご覧ください。

データの変換〜インポート

元ネタはタイトル付きCSVですので、これをJSONファイルにしてやります。 コード値で確保されているカテゴリやエリアデータをそれらのマスタCSVと結合してやります。

次のpythonスクリプトを保存してください。

import pandas as pd
import json

#件数を絞り込む場合
res = pd.read_csv('restaurants.csv',skiprows=lambda x: x >1000)
#全件取り込む場合
#res = pd.read_csv('restaurants.csv')

#都道県名結合
pref = pd.read_csv('prefs.csv')
res = pd.merge(res,pref,left_on=["pref_id"], right_on=["id"],how='left',indicator='rp',suffixes=['','_pref'])

#エリア名結合
area = pd.read_csv('areas.csv')
res = pd.merge(res,area,left_on=["area_id"], right_on=["id"],how='left',indicator='ra',suffixes=['','_area'])

#カテゴリ結合〜catsフィールドの派生
cat = pd.read_csv('categories.csv')
res = pd.merge(res,cat,left_on=["category_id1"], right_on=["id"],how='left',indicator='rc',suffixes=['','_cat1'])
res = pd.merge(res,cat,left_on=["category_id2"], right_on=["id"],how='left',indicator='rcc',suffixes=['','_cat2'])
res = pd.merge(res,cat,left_on=["category_id3"], right_on=["id"],how='left',indicator='rccc',suffixes=['','_cat3'])
res = pd.merge(res,cat,left_on=["category_id4"], right_on=["id"],how='left',indicator='rcccc',suffixes=['','_cat4'])
res = pd.merge(res,cat,left_on=["category_id5"], right_on=["id"],how='left',indicator='rccccc',suffixes=['','_cat5'])

df = res

df.name_cat1 = df.name_cat1.fillna('')
df.name_cat2 = df.name_cat2.fillna('')
df.name_cat3 = df.name_cat3.fillna('')
df.name_cat4 = df.name_cat4.fillna('')
df.name_cat5 = df.name_cat5.fillna('')
catcols = 'name_cat1,name_cat2,name_cat3,name_cat4,name_cat5'.split(',')
df = df.assign(cats = df[catcols].values.tolist())

df.north_latitude = df.north_latitude.fillna('')
df.east_longitude = df.east_longitude.fillna('')

#その場しのぎのadhocな緯度経度形式の変換
def funny_dms_to_deg(dms_str):
    if dms_str == '':
        return None
    else:
        dms_str_arr = [ s for s in str(dms_str).split('.') ]
        deg = dms_str_arr[0] + '.' +  dms_str_arr[1] + dms_str_arr[2] + dms_str_arr[3]
        if deg == None:
            return None
        return float(aaa)
    
dtd = funny_dms_to_deg
    
df = df.assign(lon = df['east_longitude'].apply(dtd))
df = df.assign(lat = df['north_latitude'].apply(dtd))
df = df.assign(location =  df[['lon','lat']].values.tolist())

#バルクロード用のjsonldファイルの出力

columns = 'id,name,alphabet,name_kana,pref_id,area_id,category_id1,category_id2,category_id3,category_id4,category_id5,zip,address,north_latitude,east_longitude,description,special_count,menu_count,fan_count,access_count,created_on,modified_on,closed,name_area,cats,name_pref,location'.split(',')
df = pd.DataFrame(df,columns=columns )

df_lines = df.to_json(force_ascii=False, orient='records', lines=True)

for i in iter(df_lines.split("\n")):
    v_json = json.loads(i)
    print('{"index": { "_id" : ' +  str(v_json['id']) + '}}' )
    print(i)


なお、pandasを使っているので、pipなどでpandasをインストールしておいてください。

上記のpythonスクリプト(ld.py)とします。

ld.pyをレストランデータセットのダウンロード〜unzip先のディレクトリ(restaurants.csvなどが存在するディレクトリ)に置いて

python3 ld.py > output.jsonld.in

などとして、output.jsonld.inファイルを出力します。

さらに、curlなどでバルクロードします。

curl -H "Content-type: application/x-ndjson" -X POST localhost:9200/es_index/doc/bulk?refresh --data-binary @output.jsonld.in

さらに補足:mappingの設定

説明が前後しましたが、mappingは雑にdynamic mappingで次の設定としました。

PUT es_index
{
  "settings": {
    "analysis": {
      "tokenizer": {
        "my_kuromoji_tokenizer":{ "type": "kuromoji_tokenizer", "mode": "search" },
        "my_ngram_tokenizer":{ "type": "ngram","min_gram":2,"max_gram":3, "token_chars":["letter","digit" ]  }
      },
      "analyzer": {
        "my_ja_default_analyzer": { 
          "type": "custom", "tokenizer": "my_kuromoji_tokenizer",
          "char_filter": ["icu_normalizer","kuromoji_iteration_mark","html_strip" ],
      "filter": [ "kuromoji_baseform", "kuromoji_part_of_speech", "ja_stop", "lowercase", "kuromoji_number", "kuromoji_stemmer" ]
        },
        "my_kuromoji_readingform_analyzer": {
          "type": "custom", "tokenizer": "my_kuromoji_tokenizer",
          "char_filter": [ "icu_normalizer","kuromoji_iteration_mark","html_strip" ],
          "filter": [ "kuromoji_readingform", "kuromoji_part_of_speech", "ja_stop", "lowercase", "kuromoji_stemmer" ]
        },
        "my_ngram_analyzer":{ 
          "type":"custom", "tokenizer":"my_ngram_tokenizer",
          "char_filter": ["icu_normalizer","html_strip"], "filter": [ ]
        }
      }
    }
  },
  "mappings": {
    "_doc": {
      "dynamic_templates": [
        {
          "hybrid_style_for_string": {
            "match_mapping_type": "string",
            "mapping": {
              "analyzer": "my_ja_default_analyzer",
              "fielddata": true, "store": true,
              "fields": {
            "readingform":{ "type":"text", "analyzer":"my_kuromoji_readingform_analyzer" },
            "ngram":{ "type":"text","analyzer":"my_ngram_analyzer" },
        "raw": { "type":"keyword" }
              }
            }
          }
        }
      ]
    }
  }
}


付録2:速習vuetifyでの項目レイアウトについて

vuetifyの各コンポーネントはなんとなく配置しているうちにわかるのですが、bootstrapライクなグリッドシステムはなれない人にはちょいとわかりにくいかもしれません。というか私は良く分かっていません。

が、次の公式リンクにまとまっているのと、

Layout grid system — Vuetify.js vuetifyjs.com

これをみると、つまるところ、簡単なレイアウトであれば次のパターンで良いのではないかと思いましたので、骨組みとしてご紹介しておきます。

<v-container>
    これで、ページ全体の大枠を縛る。

    <v-layout row>
        囲んだものの中にあるコンポーネントをできるだけ横方向に詰め込むレイアウトとする
        <v-flex md5>
            5単位の幅確保
            <foo>
                表示したい項目A
            </foo>
        </v-flex>

        <v-flex md7>
            7単位の幅確保
            <foo>
                表示したい項目B
            </foo>
        </v-flex>

    </v-layout>

</v-container>

付録3: gistに貼りました

*1:が、モダンブラウザを想定しているので、あくまでシチュエーションコントということで、ご了承くださいまし。

*2:jquery版です。

Elasticsearch percolator再び

Elasticsearch percolatorと私

再びとはなんぞやといいますところ、このブログの中での登場が2回目ですという意味です(笑)。

最初の登場は、こちら。

itdepends.hateblo.jp

上記の説明では、percolatorについては、公式リファレンスの直訳・意訳で検索条件をインデックスに入れて...のようにまとめました。

が、

Elasticsearch percolatorの便利さは上記では説明できていない

今思えば、percolatorが何に向いているか・何ができそうかを中心に説明しても良い気分です。

ググったところ、percolatorの仕組みを先に説明されている例が多いため、ここでは多少外れていても、仕組みは後回しにどちらかと言えばpercolatorの良さをアピールするような説明をすると、だれかの役に立つのではと思ってこちらに書き綴って見ます。

とまあ、もったいつけていいますが、

Elasticsearchのpercolatorは、

Elasticsearchの構成を活かした、後述するある仕掛けにしたがうことで、

他の手法より簡単に

あるドキュメント(JSON形式で表せるあるドメインのオブジェクト)が、 特定の条件に収まっているか/外れているかを

判定できる仕組みです。

なんか普通ですか? 

ちょっとアピール不足かな。

すみません> Elasticsearchちゃん。

ちなみに、ググった範囲ですが、下記を見る限り、同じluceneファミリーのsolrには、percolatorに相当するものは無いようですね。

stackoverflow.com

percolatorは判定エンジン(仮称)

さて、ドキュメントが...判定できるというのは、つまるところ、次のようなことができます。

  1. カテゴリの分類
  2. なんらかの条件の範囲内であること
  3. なんらかの条件の範囲外であること
  4. ビジネスルールのバリデーション

などなどです。

上記の例全般はもちろん、その中でもエッジケースぽくてかつアプリでロジックを組みたく無いなー的なものにも相性が良いです。

例えば、ドキュメント中にNGワード(チェック対象のワードはそれなりのワード数)が入っていないかのチェック(あるいは入っているのチェック)とか、判定条件自体が形は似ているけど都度になるので判定条件自体をあらかじめ別の場所で生成しておきそれとぶつけたい*1というようなものがあげられます。

これは後述のとおり、通常はプログラムのメインのロジックで、ある条件1に該当するか、条件2に該当するか... というような対象の条件全てを適用してチェックするのと異なり、percolatorでは、あらかじめ定義した条件のうち、マッチするものがあるかという依存関係の逆転(dependency inversion principle) ぽい方式になることも貢献していると思います。

percolatorの判定ロジックはElasticsearchの検索クエリDSLで設定できる

Elasticsearchの構成を活かした、後述するある仕掛け...はさらに後述するとして、これらの判定条件は、ElasticsearchのクエリDSLが利用できます。

よって、(JOINや相関問い合わせを伴わない)SQLのWHERE句でいけるようなことはひととおりできますし、Elasticsearchの全文検索エンジンならではの次のような条件を使うこともできます。

つまるところ、プログラミング言語でif文やelse文を組み合わせてでごちゃごちゃしたり、ごちゃごちゃを回避しようとして潔癖に無理に継承などで集約してかえってわかりにくくなってしまうようなところを、判定用の式を組み合わせて宣言的に(贅肉を落として)構成できる&あえて外出しできるというところが、他の手法に対して一つのアドバンテージになっています。

  1. 全文検索のmatch句を使った条件判定
  2. Geo系のクエリの利用
  3. Range系のクエリの利用
  4. 検索対象フィールドのワイルドカード指定などのシンタックスシュガー
  5. (ちょっと飛躍するかもしれませんが)前項のシンタックスシュガーが用意されていることと全体的に「式」からなる宣言的な記法のため、コード値などをハードコーディングしても可読性やメンテナンス性が落ちにくい。(ここでは、原理主義的な外部定義よりは、適度なハードコーディングはビジネスロジックを直接表現しているのでわかりやすい場合もあるという立場で述べています。)
  6. Geo系のポリゴン当て、Rage系のINTERSECTS、(試していないですが)Span queriesのような、JSONならではの構造化データに対する複数次元の範囲でヒットさせるような方法*2

2019/2/12 23:00 追記 上記で、いろいろできるとぶっ放していますが、実は使えるクエリDSLの組み合わせに制限があるような気もしてきました。このような記事を書いておいて調査が甘くて申し訳ありませんが、実際の使用はmapping設定に留意の上、お手元の環境で実際にお試しください。

参考: www.elastic.co

判定ロジックをストックできる

もう一つの便利なところは、Elasticsearchの構成を活かして、この判定条件をインデックスに文字通りストックしていくようなことができます。

図で示すとこんな感じです。ここまで引っ張った「Esの構成を活かした...後述」も盛り込んだつもり。

f:id:azotar:20190211232556p:plain

頑張ってまとめてみましたが伝わるかな〜? 

余計わかりにくくなっているかもしれないと不安になりつつ、実際にpercolatorを試してみれば、腑に落ちる点もあると思いますが...

www.elastic.co

判定エンジンらしくAPIとして使うことができる(と思う)

さて、検索の世界から少し逸脱しますが、このような判定の仕組み&判定条件をストックしていけるというのは、アプリケーションの世界での更新系やデータ管理の基礎機能での使い勝手が良いです。

私見ですが*3、この判定条件をアプリの本体ロジックとは少し切り離して一見無関係なElasticsearchにストックしていける(HTTPのAPIとしても使える)ので、これを緊急回避的に設定可能なライブラリとして追い出す選択肢があると、設計のアソビをもうけやすくなります。

というのも、例えば、DDDなどにおいては、ビジネスルールだビジネスロジックだ、あるいは安定していると言われている「データ」に関するバリデーションのロジックについては、各所で知見がまとめられているように、ドメイン知識として濃淡や変化するものしにくいものの仕分けして適当な責務のクラス設計などに振り分けてことになるというのが定石だと思いますが、実のところ特定分野ではこのドメイン知識の密度の見極めが難しく、プログラムの設計としてはベストというものが実現できたとしても、それでもドメイン知識の重要なものとそうでないものを見誤って、それほど重視しなくて良いところを手厚く、またその逆になってしまうこともしばしばあります。

まあ、そんなことができるだけ少なくなるように、モデリングの工夫やうまい手法をみなさんが研究されているのだと思いますが、やはり当初はそれが「安定した論理=コアのドメイン知識」として見えやすい(そしてその当初の時点ではその見立ては間違っていない)のがやっかいで、コアだと思ってがっつり実装したらそうでもなかったということもよくある話だと思います。

ということで、そんな設計に迷うような場合に、PoCや初期時点では、ビジネルルールレベルのバリデーションなどをElasticsearchのpercolatorに追い出しておくというのは、悪くない案だと考えています。

まとめ

Elasticsearchのpercolatorについて、データ更新の際に便利だぞということで、自分の感じたことを書いてみました。

最後にもういちど検索の話に戻ると、メインの検索の前に、percolatorでなんらかの一次判定をしてやるという用途はもちろん使えます。

また、複雑な検索条件の前借りにも使えます。

何かというと、メインのインデックスにドキュメントをPOSTする前に、percolatorでカテゴリ判定したり、以前の下記の記事で主張させていただいた「ビジネスロジックの圧縮((percolatorは、複数フィールドを持つあるドキュメントに対してその複数フィールドの独特のビジネスロジックをpercolatorの検索で判定してやり、検索サービスに必要な最小限の知識(1フィルードのBoolean)に集約する」という用途でも力を発揮すると思います。

itdepends.hateblo.jp

是非みなさんもうまい使い方を見つけて情報公開いただけると私もマネマネさせていただいたいと思っています。

具体的なデータを使ったプッシュ記事を書きたいけど、今回はひとまずここまで。

ここまで読んでいただきありがとうございました。

*1:例えば、ある地点の座標の緯度経度を情報として持つデータがあって、これがあるあらかじめ定義したエリアの範囲にあるか → https://techlife.cookpad.com/entry/2016/03/17/090000

*2:※ Elasticsearchの検索系のDSLを実際に使ったことがあるひとならなんとなく分かるかもという表現としました。この記事他の説明と比べても、乱暴な用語を用いつつ、しかもそれの説明は割愛しています。ご了承ください。

*3:もちろん、このブログ全般が私見の塊ですが...

Vue.js版ReactiveSearchのお試しコード例の2例目(カスタムクエリ型利用の場合にVuexを使って複数検索BOXまたがりる)- ElasticsearchのPoC

はじめに

itdepends.hateblo.jp

この記事はこちら↑の続きです。

冒頭のリンクのとおり、Elasticsearchをターゲットにした便利なSPAプラグインであるReactiveSearchを使ってみたのですが、ここではReactiveSearchにクエリを任せる標準方式ではなく、カスタムクエリ型で実現しました。

カスタムクエリ型の場合、Vue.js版のチュートリアルを流し読みしたのと私の英語読解力の範囲では、コンポーネントのプロパティ設定レベルでコンポーネント間で検索条件を共有するすることが難しいように見えました。 (ReactiveSearch自体のソースは読んでいない上の見解です。)

例えば、検索クエリはオリジナルなものにしたいが、加えて検索BOXを2つにしてそれぞれ別フィールドを検索する、また検索BOX1つめと2つめのAND(Elasticsearch クエリDSLで言うと、must)で検索したいというようなものをやるにはどうしたらというところです。

f:id:azotar:20190210133336p:plain

とは言え、Vue.jsに載っているアプリですので、このようなことも多少味付けすればできるハズなのは確実なのですが、念のため確かめてみました。

で、結論としては、Vue.jsのコンポーネント間の状態共有を使う方式一般で取り扱えそうです。

今回は、Vue.jsのステータス管理のプラグインであるVuexを付け足して使うことにしました。以下、その簡単な経緯とひとまず手元では動作したというサンプルコードです。

目次

まずは動かす

先に動かしてからの方が話がとおりやすいと思いますので、以下のコピペ手順で動かしてからちょいとだけ補足します。

なお、説明を簡単にするため、冒頭に述べた前回のものが動作している&環境の前提条件が整っているとします。

Vuexをインストール

例えばnpmでインストールしてください。

npm install vuex

Vue CLIでVuexが有効なプロジェクトをcreate

例えば、こんな感じでVue CLIを起動します。

vue create rs-sample-search-vuex

「Manually select features」を選択します。

次にこんな感じの画面が表示されるので、Vuexが対象になるように選択してください。

f:id:azotar:20190210135517p:plain

createしたプロジェクトのディレクトリのmain.js、store.js、App.vueを置き換え

それぞれ次の内容で置き換えて保存してください。

なお、時間があれば、置き換え前に、雛形になっているデフォルトのmain.js stone.jsを見ておくと、Vuexのお作法が見て取れます。(キーワードは、store、state、mutations、actionsあたりでしょうか。)

main.js

ReactiveSearchのお試しコードなので、ReactiveSearchをimportしたり「use」しているのはもちろん、「store」というところに注目ください。

import Vue from 'vue'
import App from './App.vue'
import store from './store'
import ReactiveSearch from "@appbaseio/reactivesearch-vue";

Vue.config.productionTip = false

Vue.use(ReactiveSearch);

new Vue({
  store,
  render: h => h(App)
}).$mount('#app')

store.js

検索BOXを2つ設けるとして、それぞれの現在の入力値を保持する(つもり)プロパティ(ここではkwsと名付けました)とそれらを操作するためのメソッドを定義しています。

ちょっとアレなコーディング&実装アーキテクチャかもしれませんが、ここでは、ReactiveSearchとVuexの噛み合わせを示すための例にしてあります*1

import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

export default new Vuex.Store({
  state: {
    kws :{
      k1: "",
      k2: ""
    }
  },
  mutations: {
    mut_KW_UPDATE1(state,text){
      state.kws.k1 = text
    },
    mut_KW_UPDATE2(state,text){
      state.kws.k2 = text
    }
  },
  actions: {
    kw_update1(state,kw){
      state.commit("mut_KW_UPDATE1",kw);
    },
    kw_update2(state,kw){
      state.commit("mut_KW_UPDATE2",kw);
    }
  },
  getters:{
    kws(state){
      return state.kws;
    }
  }
})

App.vue

Vuexを使う&ここまでのおまじないで、「単一コンポーネント(App.vue)」の中で、「store」について次のことができるようになります。

  1. 「this.$store.dispatch」で、store.jsで定義されている「更新のためのメソッド」に引数を与えて、「store」のデータを更新できる。
  2. 「this.$store.getters」で、store.jsで定義されている「store」のデータを取得できる。
<template>
  <div id="app">
    <reactive-base app="rs_index" url="http://localhost:9200/" >
    <h5>検索お試しランチャー(Vuex)</h5>

    <data-search
        componentId="myDataSearch"
        placeholder="検索キーワード1"
        @valueChange="sb1ValueUpdate"
        :customQuery="myCustQuery"
        className="data-search"
    />

    <data-search
        componentId="myDataSearch2"
        placeholder="検索キーワード2"
        @valueChange="sb2ValueUpdate"
        :customQuery="myCustQuery"
        className="data-search"
    />
    <result-list
        componentId="mySearchResult"
        :react='{ or: ["myDataSearch","myDataSearch2"]  }'
        :renderData="getResultList"
        :from="0"
        :pages="5"
        :pagination="true"
     />
    </reactive-base>
  </div>
</template>

<script>
export default {
  name: "App",
  methods:{
    sb1ValueUpdate(value){ this.$store.dispatch('kw_update1',value) },
    sb2ValueUpdate(value){ this.$store.dispatch('kw_update2',value) },
    myCustQuery() {
       let kws_words = this.$store.getters.kws.k1 + " " + this.$store.getters.kws.k2 ;
       return { query:{ multi_match: { query: kws_words, fields:["title_ja","content_ja"] }}};
    },
    getResultList( data ) {
        return { title: data.title_ja,description:`<em>${data.title_ja}</em>` };
    }
  }
};
</script>

<style>
.data-search{ width: 800px; }
</style>

アプリを起動してブラウザでアクセス

例えば、次のように起動してください。

npm run start


手順ここまで。

今気づきましたが、ブラウザのコンソールにはワーニングが出まくっています。

また、操作によっては時々不思議な挙動*2をするもののひとまず動いています。

f:id:azotar:20190210135755p:plain

解説(もうちょっとだけ)

上記の単一コンポーネントApp.vueについて補足します。

  1. myCustQueryの返り値のJSONオブジェクトが、Elasticsearchに投げられるクエリDSLになります。ここでは今回の2BOXならではの検索クエリにはなっていませんが、例えば、この例のように、kws_wordsで検索ワードを与えることができていることは見て取れると思います。
  2. そのkws_wordsという文字列の変数は、storeから取得できています。
  3. storeは検索BOXの値を取得しているのですが、sb1ValueUpdate、sb2ValueUpdateという関数でアップデートされる仕組みになっています。
  4. sb1ValueUpdate、sb2ValueUpdateは、ReactiveSearchのdata-searchコンポーネント「@valueChange」でイベント関連付けされています。@valueChangeはそのコンポーネントvalueを指定された関数に引数として渡してくれるものです。要はこの検索BOXでいうと、検索BOXのテキストボックスに入力された値が変更されると、@valueChangeがReactiveSearchの仕組みで呼び出され、さらにsb1ValueUpdate、sb2...の対応するものが起動され、storeに保持される。というものになっています。

前回の例だと、customQueryで指定された関数の第一引数で、検索BOXのテキストボックスの値が受け取れるためこれを検索ワードに使っていましたが、今回は複数の検索BOXの値を使うため、storeを介して2つの検索BOXの値を取得するようにしています。

customQuery、@valueChangeその他についてはこちら↓ opensource.appbase.io ※こちらを見てこの記事で間違っているところがあれば是非是非ご指摘ください。

参考リンク

Vuex公式など

Vuexを特に予告なく登場させましたが、これらの公式についてリンクします。

vuex.vuejs.org

jp.vuejs.org

Vue.js Vuex、その他コーディングスタイルやアーキテクチャについて参考

本記事では記事を単純にするための私の構成力の都合で、いつもどおりアドホックな例に止まっていますが、コンポーネント間の通信についてはいろいろ考えどころがあります。 こちらについて、いくつか参考リンクを貼っておきます。

design.dena.com

medium.com

jp.vuejs.org

基礎から学ぶ Vue.js

*1:下手なコードの言い訳と本来はもう少し検討する必要があると思いますが、ここではそこまで述べていませんという意味です。

*2:不安定というよりは、あるコンポーネントが"こういう"条件の時に"こうなる"というReactiveSearchの仕様だと思いますが...

3分で分かる(ことを目標にした)Adobe XDの使い方速習メモ(邪道編 福笑い手法)

はじめに

本職のデザイナーさんはいるけど、いろいろあってエンジニアとしての自分もAdobe XDをちょっとさわる必要がある、あるいは検索UX担当としてイケてるツールでお絵かきしたい。 (というか、PowerPointや神EXCELは少し休みたい)

... という時に、さくっとAdobe XDが自分と水が合うか確認するという程度に、Adobe XDの操作感を速習するためのメモをまとめてみました。

PowerPointはある程度使える人を前提にしています。ただし、PowerPointに慣れているとそれがアダになる部分もあります。

また、このブログではやんわり検索サイト関連の弱いエンジニアリングを裏テーマとしているので、検索サイトに関わっているとしてという設定で記載しました。 (が、それっぽいことは出てこなくて、後述のUIキットの収集範囲が限られるよねという話。)


mac OS版で説明していますが、Windows版でもそんなに変わらない(レベルの話しかして述べていない)です。 3分で説明するため、正式用語は使っていません。「コレ」とか「ソレ」などを多用しています。

この順で触って見ることで、操作方法のエッセンスがつかめるのでは?、時間がない場合にも付け焼き刃で対応できるのではという例です。

私はデザイナーでも、XDを使いこなしているわけでもないので、本来のXDのパワーを引き出しているわけではありませんので、ご了承ください。

Adobe XD をインストール

Adobe XD をインストールしてください。 <略>

UIキットを入手

公式からUIキットを入手します。

www.adobe.com

Marcelo Silvaさん、Sarah ParmenterさんらのUIキットがダウンロードできますのでダウンロードしてください。

この記事執筆時点では、ダウンロードのリンクは、上記ページの真ん中あたりの「次のステップ」あたりにあります。

また、4つのUIキットがおすすめされていますが、Apple Watch用分も含めて4つをダウンロードをオススメします。

Apple Watch用は、Apple Watchのアプリデザインとしてではなくて、正方形の画像集として使います。

※UIキットと呼ばれていますが、拡張子.xdのファイルなので、ダブルクリックするとXDが起動します。

あと、ワイヤーフレームといえばということで、こちらでしょうか。

blogs.adobe.com

 XDの起動とこの時点でのミニマムな操作ショートカット

ここで、先ほどダウンロードした、UIキットのxdファイルをダブルクリックして起動してみましょう。

ツールバーの解説

f:id:azotar:20190207210131p:plain

(A) デザイン → これをポチるとお絵かきモードです。

  • 0のエリア → 矢印が青くなって選択されていることを確認ください。
  • 1のエリア → お絵かきワークスペースです。
  • 2のエリア → お絵かきワークスペースで選択したオブジェクトの色やフォントのプロパティです。 ただし今回の説明の範囲では使いません。
  • 3のエリア →  選択したオブジェクトの配置や形状などの調整パネルです。

ワークスペースの中の操作ですが、ひとまず次のワークスペースの範囲自体の操作を覚えてください。

操作 mac windows
拡大・縮小 command + マウスホイール ctrl*1 + マウスホイール
ワークスペースの表示枠の移動 スペースバー + マウス操作 未確認ですが、macと同じだと思います。

※オブジェクトの移動はそもそもどうなんじゃいというところですが、ここでは気にしないことにします。

(B) プロトタイプ → これをポチると画面遷移などを仕込むモードです。

プロトタイプモードの時に、オブジェクトをマウスで右クリックすると青い矢印の取手のようなアイコンが出てきます。 こいつをクリックして、リンク先のページやオブジェクトにドラッグしてやると、画面遷移の設定ができます。

f:id:azotar:20190207210216p:plain

(C) 右の方にある再生ボタン

プレビューウィンドウが立ち上がり、プロトタイプの画面遷移などの確認ができます。

福笑い操作の習得

再び、(A)デザインモードに切り替えてください。

気になっているUIオブジェクトをalt(windowsの場合は確認していませんがおそらくctrl)を押しながら、右クリックしてください。

そのまま適当なところに、ドラッグしてください。 → UIオブジェクトがコピーされました。

操作 mac windows
福笑い alt + 右クリックドラッグ ctrl + 右クリックドラッグ ※PowerPointと同じ(?)

f:id:azotar:20190207210301p:plain

アートボード(≒ デバイスのディスプレイ ≒ いわゆる画面)

先に福笑いを説明しましたが、本来は「画面」の下地に配置していくことになります。

先の(A)の0のエリアにある、コレをクリックしてください。

f:id:azotar:20190207210340p:plain

よく見ると、右の方に、iPhone X/XSなどとあるのでターゲットデバイスに該当するものを右クリックしてください。

すると、お絵かきワークスペースの適当な空き地に、白地のアートボードが配置されました。 あとは、0のエリアの矢印アイコンで、操作モードを元に戻し(キーボードだとEscキー)、前項の福笑い操作でぽちぽちUIオブジェクトを配置してみてください。

つまるところ、なんとなく使いそうなかつフリーで配布されているUIキットを集めておいて、あとは、オブジェクトを真面目にイチから作るのではなく配置していくことで、サンプルデザイン&プロトをつくりましょうということになります。


操作説明はここまでです。

マイ作業台のxdファイルを整頓

ダウンロードしたUIキット群からマイフェイバリットを抜き出したUIキットを作っておきましょう。普段は使わなくてもいざという時にパワポ絵よりイケているイメージ絵が短時間で作成できるのカモ。

f:id:azotar:20190207210434p:plain

なにやらサーブリック分析したくなります。

プロトタイプツールとして真面目に使う

HTMLやCSSのエクスポートはできないようですが*2、その分(?)配色などを一括で切り替えたりといったこともできるようです。 ここから先は、動画のチュートリアルを見るのがよさそうです。

www.youtube.com

以上です。ありがとうございました。

*1:ごめんなさい未確認です。PowerPointと同じと想定

*2:できたとしてもトレードオフがあるでしょうから、プロトタイプに特化した方が良いようにも思います。

Vue.js版ReactiveSearchのお試しコード例

Elasticsearchで検索の当たり具合などをいろいろ試すために、アドカレ2018で知ったReactivSearchに目をつけました。 そこで、ReactiveSearchのVue.js版のアドホックなかつできるだけミニマムな動作例の自分用コピペ元が欲しくなりまして、以下に手順的なものをダンプします。 (見てのとおり、全体的に「動けば良い」というスタイルのものなので、その点ご注意ください。)

動かしてみる

Elasticsearchの準備

  1. Elasticsearchに、インデックス(ここでは名前は「rs_index」とします)とデータ(フィールド名はtitle_ja、content_jaとします)を登録。
  2. ElasticsearchをCORS対応で起動(elasticsearch.yml)。→ http://localhost:9200/で起動したとします。

Node.jsをインストール

npmをインストール

ReactiveSearchのVue.js版をインストール

npm でreactivesearch-vueをインストール

npm install @appbaseio/reactivesearch-vue

Vue CLI (Vue.jsのcliコマンドラインインタフェース))をインストール

npm install -g @vue/cli

Vue CLIでプロジェクトを作成

vue create my-awesome-search

ちなみに、my-awesome-searchのところが、このアプリのブラウザでアクセスした際のtitleになります。

検索窓と検索結果の宣言(App.vue)

プロジェクトのディレクトリのsrcに移動して 、App.vueに次をコピペ。奇妙なインデントなのはご容赦。

<!-- App.vue  --> 

<template>
  <div id="app">
    <reactive-base app="rs_index" url="http://localhost:9200/" >
    <h5>検索お試しランチャー</h5>

    <data-search
        componentId="myDataSearch"
        :customQuery="myCustQuery"
        placeholder="検索キーワード"
        :dataField="['title_ja']"
        URLParams="true"
        className="data-search"
    />
    <result-card
        componentId="mySearchResult"
        :react='{ or: ["myDataSearch"]  }'
        :renderData="getResultList"
        :from="0"
        :pages="5"
        :pagination="true"
     />
    </reactive-base>
  </div>
</template>

<script>
export default {
  name: "App",
  methods:{
    getResultList( data ) {
        return { title: data.title_ja.slice(0,5), image:"http://localhost:80/" + `${data._id}.png`, description:`<em>${data.title_ja}</em>` };
    },
      myCustQuery(value) {
          return { query:{ multi_match: { query: `${value}`, fields:["title_ja","content_ja"] }}};
    }
  }
};
</script>

<style>
.data-search{ width: 800px; }
</style>

ラフに起動するだけなら写経のmain.js

main.jsを次でコピペして上書き。

// main.js

import Vue from 'vue'
import App from './App.vue'
import ReactiveSearch from "@appbaseio/reactivesearch-vue";

Vue.use(ReactiveSearch);
Vue.config.productionTip = false

new Vue({
    render: h => h(App),
}).$mount('#app')

アプリをひとまず起動して触ってみる

プロジェクトのsrcディレクトリで次をタイプ。

npm run serve

f:id:azotar:20190204001744p:plain

→ この例だと、http://localhost:8080/にアクセスすると、アプリの画面へ。

f:id:azotar:20190204002241p:plain

※手抜きなので、真っ白です。

アプリが動作するところまでは以上の通り。

補足

前述のApp.vueを手抜き解説します。今回必要になった(?)例に特化したオプション設定と、それに合わせた説明なので、公式が推しているスタンダードかつ便利な使い方と違うところもあります。 興味を持たれた方は、参考文献に示す有識者の方の解説サイトや公式リファレンスを参照ください。

f:id:azotar:20190204004723p:plain

  • result-cardのところは、result-listに変えると検索結果がリスト型になります。
  • 「react」のところは、data-searchなど、"リアクト"させる他のコンポーネントのcomponentIdを指定します。
  • ここでは、「:customQuery」を使って、自前のクエリを定義していますが、実はこちらはオプションで必須ではありません。定義しなければ、「:dataField」に指定のフィールドが検索対象になるようです。また、今回は自前のクエリを使いたかったのでこのようにしたのですが、他のコンポーネントを複数並べてできるだけ手間をかけずにそれでもリッチな検索UIを実現したい場合は、無理に「:customQuery」にしない方が(おそらく)得策です。
  • renderData="getResultList"からの、getResultListで、title、image、descriptionに(特にtitleは5桁でチョップ)妙な値を渡しているのは、title、image、descriptionはコンポーネントの約束事として、渡す値はJavaScript、Vue.jsの世界の範囲ならOKだよねという確認がしたくての、例のための例です。

参考文献など

ReactiveSearch Vue.js版の公式

opensource.appbase.io

↓こちらを先に読んだ方がわかりやすいかも。 opensource.appbase.io

Reactivesearchでいい感じの検索SPAを30分ぐらいで作る(j-yamaさん)

qiita.com

私のこの記事では結果オンリーのオレオレ説明となっていますが、↑こちらのブログでは非常に丁寧な説明がされています。 Vue.jsじゃなくてReact.jsだという人はもちろん、仕掛けや仕組みを抑えたい場合は、私のこの記事よりもこちらのブログを参照ください。私は公式のVue.js版のリファレンスよりもj-yamaさんのReact.jsのコード例を見て、上の方に貼り付けたApp.vueの動作確認コードを作成しました!!

とあるタイプの検索サイトのElasticsearchを使ったサービス設計などに関する私見(2019年改訂版)

検索サイトで、どのようにElasticsearchを活かしてサイトをディレクションするかについて自分の意見をまとめてみました。

まとめてみたと言いいつつ、アタマの整理の過程をダンプしたという体裁になっています。... のでまとまってないかもしれません。

何かの勢いで書いてはならないことを書いてしまわないようにしたため、筆者のドキュメント力とは別の問題として、本来は具体的なもので述べるところ、抽象的な言い方になっているところが多々あります。

一方で、多少リアルな例にしたいと思い、ある程度シーンを絞って記述したところもあるのですが、抽象化との兼ね合いで、論理の飛躍や検証が甘いところもあると思います。

つまるところポエムになっているかもしれません。

また、2019年改訂版としていますが、改訂前のものがあるわけではありません。今後、世の進歩とともに、陳腐化するかもという言い訳でして、2019年現在のこういうスタイルもある...という意味です。

TOC

この記事で想定する検索サイトユースケース

検索を伴うサイトと言いましてもいろいろありますが、次の表の分類4、5あたりをうっすら想定してまとめています。

f:id:azotar:20190123220411p:plain

説明の都合、本来は同時にあり得ない、求人検索サイト、レストラン検索サイト、ECの商品検索サイトの検索要件で求められそうな要素(例. 検索BOXは募集職種とエリアの2BOX系。原本データは基幹システムなど上流システムで管理されている。... など)を混在で気まぐれで引用して説明を試みています。

なお、クチコミなどUGC由来のテキストデータも検索対象としてイメージしていますが、UGC自体がドキュメントの最重要コンテンツとなるような検索サイトではないこととします。これは、このようなUGCの検索サイトにおいて本記事の内容が無意味というものではないと思いますが、UGCメインのサイトの場合は、コミュニティ運営あってこそで、そこを含めて論じないと話が繋がらないためです。

とある検索サイトの悩み

なお、分類4などは他の例と別の悩みを伴うことが多いのではと思います。 (これがこの記事を書いた背景です。)

コンテンツデータが増えてきて、カテゴリ目次だけのディレクトリ型では立ち行かなくなったので検索エンジンを入れようという話になりました。

一方で、一見さんの多い(かもしれないサイト)としては、どんな検索ワードが使われるか予想がつきません。

データ件数は多少揃ってきているものの、当初のディレクトリ型の目次のワードが正しくアタマに入っていて、これらのワードを検索語として検索してくれる人がどれほどいるでしょうか。

precisionとrecall、F値の世界とは別の世界が広がります。

結構データ件数があり本当はエンドユーザーの期待にそえる(かもしれない)データはそれなりのはずだが、実際は検索結果が0件になってしまう。

ということで、ローンチやリニューアル後の検索結果の0件分析は必須だとして、そうは言ってもリニューアル前に逃した魚が大きい...とならないようにせっせと工夫をすることになると思います。

同じ「検索」でも実は結構違う

f:id:azotar:20190125003816p:plain

検索語の派生について

本来は、チューニングと言えばF値という話になるのでしょうが、思いの他、ユーザーはドキュメントに合わせた検索ワードで検索してくれませんし、検索結果の最初の数件しか見ない傾向がより強いように思います。

ドメインが限られた世界では、多少幅を持たせてヒットさせてやる方が重要なんだと思います。

どっちかと言えば、再現率重視ということになるのかもしれません。 まちがっているかもしれないけど、本当はこの辺を探してたんでしょ?というところを端っこにでもhitさせてやるような方向だと思います。

Elasticsearchなど昨今の検索エンジンの機能や仕組みに乗っかると以前はできなかったようなことが平易になります。よって、これらを活用した場合、例えば次のような戦略です。

  1. 形態素解析n-gramのハイブリッド。→ 漏れの防止。一方、前者の加点を高めとすることで、適合度が高そうなものの優先確保。
  2. エリア条件など、厳密な語が入力された方が確実なヒットにつながりやすく、かつある程度エンドユーザーも知識があるような検索条件の入力BOXについては、オートコンプリートを(他の案件より優先して)設置。
  3. ユーザーの入力した語から検索語を派生させて、それらでOR検索する。派生させる語はできれば実際にドキュメント中に現れる語にするのが良いでしょう。
  4. 検索語を派生させてOR検索すると当たり幅が広がります。加えて、複数フィールドを串刺しで検索するとなおさらです。幸い、今回例にしているような検索サイトの例でいうと、複数のフィールドの中にも、ここに検索語が入っていれば優先したいというフィールドがそれなりに存在します。レストラン検索で言えば店舗名、求人募集検索で言えば企業名といったユニークになるフィールドです。また、同じ観点の次点の例としては、すごく当たり前のことを言いますが、レストラン検索のジャンルや求人募集検索の職種や資格のフィールドも視野に入ります。 

ここで言う、検索語の派生は次の図のような塩梅です。

なお、複数ワード入れられた場合はどう考えるのが良いでしょうか。次の図では複数ワードの場合も織り交ぜて私見を述べています。

f:id:azotar:20190123225928p:plain

ユーザー入力の個々のワードはOR検索用に派生させつつ、複数の入力ワード間は世の感覚に合わせてANDにする、というやり方の場合少し工夫が必要かもしれません。 (Elasticsearchのmatch系は入力ワードに対してデフォルトでOR検索です。ここで主張しているように派生させつつ、元の複数の入力ワードの間はANDにするという場合は、boolクエリのmustとの組み合わせになります。)

エリア条件のオートコンプリートの例

オートコンプリートの例としてエリア条件の例を挙げたのでここで少し寄り道します。

エリア条件というのは求人サイトであれば、勤務地とか企業のビルの所在地を検索条件に使うというやつです。

Elasticsearchだと、オートコンプリートには、Suggestersシリーズのサジェスト専用のクエリがあり、その中でもザ・オートコンプリート用のCompletion Suggesterというのがあります。

ただし、Suggestersシリーズのクエリは、性能等を考慮して専用のインデックスを作成することになるのと、クエリのDSLの構成および返り値のJSON構造ともに、通常の"query"系の検索と少し形式が違います。

私なぞは、どうせ専門のインデックスを作成することになるなら、元ネタになる住所コードマスタを入手するついでに、これを活用して、都合の良いインデックスを作成して、通常の"query"系の検索で検索してしまえと考える派です。

ということで、ひとつの案ですが、次のような例を挙げてみます。

f:id:azotar:20190126152838p:plain

これ自体は、階層ごとのデータを作ってやってそれを、prefix検索すると(部分一致より高速と思われるというメリットも含めて)、オートコンプリートのリストの元ネタおよび付帯情報が得られるというノウハウかと思います。

だんだん確定する部分を伸ばしていくということにも向いています。

例えば、次のとおりです。

「みな」と入力

→東京都港区

→さらに東京都港区の配下の候補一覧表示され、もう少し詳細なエリアを指定したければ

候補から選択 or 「東京都港区しん」を入力して「東京都港区新橋」で確定

この実装パターンの発想は他にも有用な用途があるかもしれません(あると思っています)。いつかデザインパターンだかイディオムだかとしてまとめてみたいと考えています。

また、この例であれば、そもそも入力中のひらがなでも候補を出すことが可能です*1

なお、基本に戻って、Suggestersは、通常の検索とやや異なります。過去にまとめた記事があるのでリンクしておきます。

itdepends.hateblo.jp

memo: オートコンプリートのパターン

オートコンプリートの要件に従い、Elasticsearchのどの方法でオートコンプリートを実現するかというはもちろん重要です。

ただ、その前に、オートコンプリートのスペースが限られた領域で、どういうUIにするか/ありうるかという自分なりのパターン分けをしておくと、本当に必要な要件を見出しやすくなると思います。

私は、次の4つをイメージして、あるサイトにマッチしたものは、これらのうちどれか、どれの延長線上かという捉え方でアタマの整理をすることにしています。

f:id:azotar:20190126143426p:plain

※ 戦略2、戦略2’では、候補のリスト表示を階層表示風のイメージ図にしていますが、これはあくまで元のデータが階層的になっていることを活かした候補一覧を表示するという意味で示しています。必ずしも実際のUIとしてリスト内の表示を階層型にすべきという主旨ではありません。

検索プロセスの見極め

話を元に戻します。

次に、このサイトでの「検索プロセス」のカタチ・型を見定めます。 

Elasticsearchや最近のSPAのUI等、ブラウザ上・サーバサイドともにやれることの選択肢は広がってきていますが、私の主張は、ある検索いちサイトでは、全ての検索要件をこれだと決めたカタチ・型にそろえるように寄せていくことで、設計・実装してくスタイルが何かと取り回しがききやすく、得られる検索UXの満足度が高くなるんじゃないかというものです。

私のお気に入りの「検索プロセス」の型は次のような図のものです。私は空手の心得がある訳ではありませんが、空手の型・流派みたいなものなのかもしれません。

※図中の点線で示している部分は、オプション(無くても機能は成り立つが、これらの扱いは議論して、重要だと考えるなら機能や仕掛けを設けること筆者はオススメしているもの)です。f:id:azotar:20190124005443p:plain

私のお気に入り検索プロセス解説

図の中で、ここまで述べたこと以外に、あるいは述べたことに重ねて、それとなくマイベタープラクティスとして示唆している点は次のとおりです。

  1. 全文検索には、(AND検索を主軸にすること、アプリで検索語派生する方針もあり、)multi_matchを使おう。multi_matchでヒットさせるフィールドの並べ方(正確にはboostの係数)で、優先フィールドを調整しよう。このフィールドの配列は、クエリビルダーの外で管理して、並び替えや係数を調整しやすいようにしよう。
  2. オーガニック検索は、複数ワード入れられた場合はAND検索です。ただし当たり前ですが、AND検索は検索結果が絞り込まれるので、利用者の想定以上に検索にヒットしないという結果になることもしばしばです。ということで、検索結果が0件の場合などにレスキュー処理(図中の二次検索など)の取り扱いを考えてやりましょう。まあ、実際にどこまで対応するかは別として、「救済」が必要なのかどうかというのは議論のポイントになると思います。
  3. 「救済」のための二次検索は、一例としては、派生したワードを含めて全てORで検索します。Elasticsearchの場合、1の例でのbool.mustをbool.shouldに組み替えるだけで全てをOR検索にするクエリが出来上がります。
  4. 図ではどちらと結論づけていませんが、レスキューの際に、検索結果が0件だったことを示すのか、なんとか検索結果が得られるような二次検索の検索結果を代わりに返してやるのかというのも「型」としての見定めどころだと考えています。

コラム: ElasticsearchのCompound query clausesとLeaf query clauses、match系・term系のクエリの検索ワードと対象フィールドの指定の仕方

上記では、Elasticsearchを下地にした、検索プロセス云々を論じて、極力リアルにアーキテクチャを示したつもりです。

ElasticsearchのクエリDSLの記法でどんなものがあるかを念頭に置いた上で、その中でもこれをこう組み合わせて...という組み合わせの結果です。

経過を全てお見せするのは難しいですが、組み合わせの試行錯誤の素材になった、ElasticsearchのクエリDSLのどの記法で、レコードのどのフィールドを指定の検索ワードで検索しにいくことになるかを復習しておきます。

f:id:azotar:20190130000925p:plain

私などは物覚えも物忘れもよろしくないので、時々、上のような図で再確認をすることにしています。

※ 先に述べた検索プロセスの型を持ちましょうというのは、このようなどのクエリをどう使うみたいなところの逐次のモグラ叩きから解放されるのではという考えもあって主張しているところもあります。

また、参考に過去記事をリンクしておきます。

itdepends.hateblo.jp

itdepends.hateblo.jp

検索のオニオンアーキテクチャ

ちなみに、このあたりで、アーキテクチャっぽい図をまとめて、チーム内の役割分担や責任範囲をすり合わせていくと良いと思います。

個人的には、オニオンアーキテクチャ風の書きっぷりの図でまとめると、余計なバイアスが入らない範囲でアソビを残しつつ気持ち合わせしたい程度には言いたいことが言えて良いのではと感じています。

f:id:azotar:20190130015302p:plain

実装や利用フレームワークはこの絵では規定しないのですが、ドメイン知識を示唆できるのと、どの塊・どこが内部/外部のインタフェースとなるかというところや、要件に見合った依存関係の方向を示せるので、参画メンバのキャラクターにもよりますが、この類の図を用いて認識合わせするのが有効だと考えています。

検索プロセスの設計アプローチイメージ

前の説の検索プロセスの型を定めると良いという話の少しだけ続きです。

自分(?)の得意な型を定めるというような言い方をしていますが、つまるところ、Elasticsearchのクエリビルダーの機能はできるだけ1機能に集約しようという主旨です。

↓こんな感じです。

検索機能の設計アプローチイメージ

f:id:azotar:20190125010120p:plain

※ 検索サービスや検索メニューとありますが、ここではサービスとメニューという用語にそれほど厳密な意味はありません。前者が企画目線で後者はやや機能や実際の操作方法目線ぐらいの意味です。

前の方で述べたことの言い直しですが、そのサイトの検索サービスのEsクエリのテンプレートおよび検索プロセスはできるだけ骨組みとしては一本に集約するようにしましょう。 逆に、それ以外はアドホックに、付け足ししていく方針で、設計も実装もアプローチしていくと、維持や継続的な改善含めて費用対効果が高いような検索サイトとして運営しやすいのではないか、という主張になります。

検索クエリの構造の標準化

クエリビルダーを構築しましょうのように申していますが、この記事のターゲットの検索サイトの範囲であれば、無理に汎用的なものにする必要もないと考えています。

といいますか、汎用クエリビルダーをどこまでどうしようかというのは、開発効率やもろもろ考えるとアーキテクトの人のウデの見せ所ではあると思いますが、一方でここまでのとおり、検索プロセスの型を定めるとおおよそクエリのDSLのテンプレートのJsonのカタチが決まるので、検索メニューに応じて異なる部分を差し替えるというやり方で泳げる(つまり、クエリビルダーClassのようなものでなんらかモジュール化はした方が良いものの、じゃあそれが、他の検索サイトPJで変更なく使える程のものにする必要はないのでは?)という 範囲になります。

SQLもそうですが、検索する・リストを返すための「クエリ」そのものは、文字列構築の魔法使いの世界だと思います。

宣言型で実現できることによる見通しの良さと、複数の検索メニューのうち共通する部分をドメイン知識として、多少むき出しでも良いので、プログラムに見栄えに可視化して残すというのが少なくともこの界隈の2019年1月時点ではうまい方法のように感じています。

ということで、先述の私のお気に入り「検索プロセス」の型の場合の、オーガニック検索のクエリDSLの「テンプレート」は次のようになります。

f:id:azotar:20190130003116p:plain

なお、ここに公式のElasticsearch Clientsの一覧がありますのでリンクしておきます。

上記のような主張のとおり、私自身は例えば、Javaであれば、Java REST Clientの「High Level」よりも、「Java Low Level REST Client」の方がクエリDSLを自分で取り回しやすく、プログラムの見栄えにドメイン知識がにじみ出る分、好んで利用したい気持ちです。

www.elastic.co

補足1: 型はめ論法の落とし穴に陥らないように点検しながら進める

ここまで、型を決めろとか、型が決まれば全て解決というような言い方をしていますが、念のため自問しておきます。

手順やプロセスありきで進めて、それが当てはまらない場合を見逃してしまうという、エンジニアリングあるあるにならないように気をつける必要はあります。

確かに、実際のところはケースバイケースです。 ただ、冒頭に述べたように、どのような種類の検索サイトなのかを客観視した上で、いけそうだということであれば、型ファーストで進めていくことも可能かなと考えています。

詳しくは説明しませんが、私なぞは、例えば、次にあげたような表を埋めていくなどしながら、要件とElasticsearchの機能や仕組みのマッピングをとっていく中で、イケそうか点検しながら検討することにしています。

f:id:azotar:20190125010105p:plain

f:id:azotar:20190125010112p:plain

点検のポイントとしては....

  1. 優先するフィールドを見極める
  2. (特に優先する)フィールドについてアナライザーのパターンの最小公倍数を明らかにする
  3. term、range、geoXXのような、特定の属性に対しての厳格な検索の要件を洗い出す。
  4. メインクエリの検索かファセット(aggsやpost_filter)での絞り込みかの役割分担のおおよその目処をつける。

です。

また、余力があれば、開発初期のダイナミックmappingテンプレートの設定をどうするかも見極めると良いでしょう。

...と言いながら、ひとまず機会的に次などを設定しちゃいますが....

PUT my_search_index
{
  "settings": {
    "analysis": {
      "tokenizer": {
        "my_kuromoji_tokenizer":{ "type": "kuromoji_tokenizer", "mode": "search" },
        "my_ngram_tokenizer":{ "type": "ngram","min_gram":2,"max_gram":3, "token_chars":["letter","digit" ]  }
      },
      "analyzer": {
        "my_ja_default_analyzer": { 
          "type": "custom", "tokenizer": "my_kuromoji_tokenizer",
          "char_filter": ["icu_normalizer","kuromoji_iteration_mark","html_strip" ],
      "filter": [ "kuromoji_baseform", "kuromoji_part_of_speech", "ja_stop", "lowercase", "kuromoji_number", "kuromoji_stemmer" ]
        },
        "my_kuromoji_readingform_analyzer": {
          "type": "custom", "tokenizer": "my_kuromoji_tokenizer",
          "char_filter": [ "icu_normalizer","kuromoji_iteration_mark","html_strip" ],
          "filter": [ "kuromoji_readingform", "kuromoji_part_of_speech", "ja_stop", "lowercase", "kuromoji_stemmer" ]
        },
        "my_ngram_analyzer":{ 
          "type":"custom", "tokenizer":"my_ngram_tokenizer",
          "char_filter": ["icu_normalizer","html_strip"], "filter": [ ]
        }
      }
    }
  },
  "mappings": {
    "_doc": {
      "dynamic_templates": [
        {
          "hybrid_style_for_string": {
            "match_mapping_type": "string",
            "mapping": {
              "analyzer": "my_ja_default_analyzer",
              "fielddata": true, "store": true,
              "fields": {
            "readingform":{ "type":"text", "analyzer":"my_kuromoji_readingform_analyzer" },
            "ngram":{ "type":"text","analyzer":"my_ngram_analyzer" },
        "raw": { "type":"keyword" }
              }
            }
          }
        }
      ]
    }
  }
}


そもそものMapping全体については、雰囲気だけですが、過去にまとめたものがありますので、リンクしておきます。

itdepends.hateblo.jp

補足2: bool、shouldの捉え方

点検では、アタマの片隅で、要件を実現するクエリDSLを適宜思い浮かべながら進めていくのですが、この際、boolとshouldについてこのような理解をしておくと、混乱しにくいのではというポイントを以下に図にしてみました。

shouldはOR検索条件のようで必ずしもそうではない f:id:azotar:20190130011252p:plain

SQLをビルドする時と異なり、ElasticsearchのクエリDSLを見定める際は、まずmustで考えて、違和感があるものはshouldに追い出す...ぐらいが良いように思います。.... という話。


検索は検索なんだけど、軸となる検索条件の塊を組み合わせて元の集合からフィルター*2しているようなイメージで設計しよう f:id:azotar:20190130011308p:plain

補足3: ファセットとpost_filter

検索結果のファセット(Elasticsearchであれば、aggsで実現するのが定石)とpost_filterの見定めを行います。

ファセットの大まかなパターンとしてどのようなものがあるかの自分の中でのパレットを描いておくのが良いと思います。概念的なものよりも、UIのイメージで捉えておき、このフィールドのファセットは、スタイルAのドリルダウン...などと整理していくのが良いでしょう。

f:id:azotar:20190130021530p:plain

私はpost_filterについては次のような理解(検索条件のピボットを重視するUX/UIの場合に重宝する)でいます。 f:id:azotar:20190130011336p:plain

点検の結果としてのオーガニック検索のクエリDSLのテンプレートブラッシュアップ

点検を通して、少し前で述べたオーガニック検索のクエリDSLの「テンプレート」は、次のようなより具体的なものにブラッシュアップされるとともに、文字どおりこのテンプレートの応用の範囲で今回のサイトの要件を現実的にコントロールしていけそうだという目処が立ちました(というストーリーです)。

f:id:azotar:20190130004459p:plain

また、上記の図のままではないですが、(補完がきいたとしても)初出のクエリDSLJSONをゼロから手打ちするのはめんどくさいので、シンタックス的に誤りのない(ハズ)のある程度盛りクエリのテキストを貼り付けておきます。

POST car_and_animal/_search
{
  "size": 10,
  "from":1,
  "query": {
    "function_score": {
      "query": {
        "bool": {
          "must": [
            {
              "bool": {
                "must": [
                  {
                    "boosting": {
                      "positive": {
                        "multi_match": {
                          "query": "鳥 産む 食べる",
                          "fields": [
                            "content_ja",
                            "content_ja.meishi"
                          ],
                          "boost": 1,
                          "operator": "or"
                        }
                      },
                      "negative": {
                        "match_all": {}
                      },
                      "negative_boost": 0.2
                    }
                  }
                ]
              }
            }
          ],
          "should": [
            {
              "match": {
                "title_ja": {
                  "query": "ヒゲワシ カリフォルニア",
                  "operator": "or"
                }
              }
            }
          ]
        }
      }
    }
  },
  "aggs": {
    "tokuchougo": {
      "significant_text": {
        "field": "content_ja.doushi_nado",
        "size": 10,
        "filter_duplicate_text": "true",
        "background_filter": {
          "term": {
            "content_ja": "車"
          }
        }
      }
    }
  },
  "_source": [
    "title_ja"
  ]
}

※このクエリ自体には意味はありません。

設計の流れ・アプローチの全体像のおさらい

ポエム度低減のため、できるだけElasticsearchの例を先行して述べてきました。気まぐれな流れで申し訳有りませんが、実は次の図のような流れで設計なりなんなりの検討を進めているというストーリーにそったものでした。

f:id:azotar:20190125005838p:plain

※ 図中の 1、2、3、A、C あたりをつまみ食いして述べてきたつもりです。

いうまでもなく、これが唯一無二の方法でもないですし、この流れにそえば手戻りも最小だったり失敗しないというものではないです。

ただ、試行錯誤でぐるぐる回ったりしている部分はあるにせよ、経験豊富な方はみなさんおおよそこんな感じで検討されているではないでしょうか。

さて、図で示した、太線で結ばれている部分が、検討のメインフローだとすると、メインフローに寄り添ったトピック:図中の【5】のところが、今回テーマにしたタイプの検索サイトでは見逃せない要素だと考えています。

次項では、この部分についてだらりと述べてみます。

検索用ドキュメント標準化(仮称)の営み

ElasticsearchではJSON形式のドキュメントをざっくりインデックスに投げ入れることができます。

また、LogstashなどログアグリゲーターやIngest Nodeなど、あるいは中の仕掛けはよく知らないのですがkibanaのデータロードツール的なものなどで、データソースのデータを取り扱いやすいデータ形式にお手軽に変換してやることもできますので今や便利づくしです。

とは言え、なんだかんだいっても半定形とも言えるログ型のデータと異なり、今回対象としているような検索サイトの例だと、データソース側はいびつなビジネスロジックの結晶とも言える形式になっているものもしばしばあります。せっかくのIngest Nodeなどの仕組みはありつつも、軽く下調べした際に妙な臭いが嗅ぎとれる場合は、自前のアプリケーションで変換してやる方に舵を切るというスタンスもありだと思います。

つまりこういうことです。↓

f:id:azotar:20190125014234p:plain

ともあれ、自前のアプリで変換等してやるかはともかく、ある程度腰をすえて検索エンジンを導入する場合は、検索要件に適した、かつ極力検索結果のリスティングにそのまま埋め込むだけで利用者向けの表示がなりたつようなドキュメントのカタチにしてやるのが遠回りなようで近道だということは言えると思います。

注:

この記事では、検索用ドキュメント標準化(仮称)的なものは、自前アプリでできるだけ頑張ってやる方向で割り切った方が今回のテーマにした用途には合いそう、というストーリーでこの後も続きます。

とは言え、ググった流れでたまたまこのページにたどり着いた方向けに誤解のないように、念のため公式のIngest Nodeのリファレンスへのリンクと、このブログの過去記事で、Ingest Nodeの勉強のために早見表をまとめたので、こちらのリンクを付けておきます。この記事のノリにしっくりこない方は、車輪の再発明にならないように、公式リファレンス等もあたってみてください。

www.elastic.co

itdepends.hateblo.jp

補足1: 標準化した方が良いパターンについて

前述の図にそれと無しに書きましたが、自前アプリで標準化しても良いかも...の見極め観点として、図の右上あたりですが、標準化パターンとして私の考えで分類して見ました。

特に、重要な見極めポイントではと考えているのは、図中に「データソース側固有のビジネスロジックの圧縮」としたものです。

これは、例としてしっくりくるか分かりませんが、例えばの例でいうと、RDBのあるテーブルで、カラム1の値がAの場合、カラム2、3、... の値がQならこのレコードでは「カラム2」の意味は「XXX」だが、カラム1の値がBの場合、カラム2の値がやはりQだったとしても、このレコードでは「カラム2」の意味が「YYY」となるというような場合に、Elasticsearchのインデックスにこのままのフィールド単位でデータを持ち込むことは現実的でありません。

なにやら変な例を持ち出したので、そんな経験があるんかいと思われそうですが、それはともかく、似た例でもう少し軽めのものとしては、◯◯項目群有効フラグのようなカラムがあって、これが「無効」の場合、関連するカラム2、3は値が登録されていても表の検索サイトでの表示には使ってはならん...ぐらいのことは歴史のある上流システムではあるあるだと思います。

標準化パターンとしてあげたもののについて、ざっくりした例を次の図に示します。

f:id:azotar:20190126065358p:plain

また、これらの解説を長々とまとめてみたのですが、本当に長くなったので付録に回しました。

検索用ドキュメント標準化パターン解説

補足2: インデックス更新のフレームワーク

自前アプリでの標準化に近いところの整理ポイントとして、(ビジネスルールで許容される範囲の割り切りはしつつも、)表示内容の整合性確保のために、更新トランザクションを確立したいという要件に対応するために、インデックス更新のやり方にこだわりがある場合があります。

こちらもあまり言うとボロがでそうなので、雰囲気の範囲で自説を述べておくと、更新処理のフレームワークを確立しておくと良さそうです... というところと、私の好みは次の図の3つ目の方式です。

f:id:azotar:20190130235537p:plain

ちなみに、図の3つ目の方式では、それとなく論理削除方式を用いていることを示唆しています。これは何かとアンチパターンに繋がりうる論理削除方式を推奨している訳ではなくて、フレームワークやなんらかビジネスルール等の都合で、論理削除方式を取るなら、こういうやり方もあるのではという例です。

トランザクションには、「結果整合性」重視というスタンスもありますが、この例だとインデックスの更新に関して「結果整合性」の視点で見るのがテック的に正攻法の視点だと思いますが、一方で、次のざっくり「検索サイトの機能構成イメージ(※気持ちECサイトを想定しています)」図で言うと、検索サイトのビジネス目線でのトランザクションは、商品ページで発生します。

f:id:azotar:20190130234816p:plain

つまるところ、ビジネス的な「結果整合性」は商品ページで確保する、例えば、その商品の在庫が切れてしまった時には商品ページでエラーにする、といったアプローチを前提に、検索インデックスは論理削除方式にするというスタンスもありでしょう。また、論理削除方式をアンチパターンと知らしめている一つの理由に、検索時に「論理削除 ≠ ON」のデータを検索しなければならないというものがありますが、後々物理削除されることを前提に、このデータは現在カートに入れることはできませんが、本来の商品説明としてはこんな感じですという情報として検索結果リスティングには成り行き表示することにして、検索時には論理削除を意識しないという手もありそうですね。

なお、ここでは、データソースが一元化されていてかつデータソースの更新=検索エンジンの即時反映とすべきのようなものではなく、多少ひねくれたかつデータソースが複数ありこれらの更新契機が様々なものを想定してこのような話を書いています。前者のような素直でまじめな構成の場合のトランザクション制御としては、「version」を使った楽観的ロックでの制御ができます。根元の要件が素直な場合はversionを使ったロックを行うものと思われます。

この「version」については、ググラビリティが低いのと、公式リファレンスのどこに書いてあったかわかりにくいので、自分メモも兼ねてリンクをしておきます。

補足3: 自前アプリでの検索用ドキュメント標準化のフレームワーク

話はドキュメント標準化に戻りますが、ドキュメント標準化処理もフレームワーク化しておいた方が良いでしょう。トランザクションや例外処理のフレームワーク化という意味もありますが、ここでは、私の芸風にありがちな、よくあるパターンの骨組みを見つけて、あとは多少ナイーブで冗長でも、最小限のDSLで実現できないかを模索して、それを型にしたものをフレームワークと呼んでいます。

例えば、次の図のようなものです。

f:id:azotar:20190131002035p:plain

標準化パターンは、項目単位のものと、項目またがりのものと、項目は意識せずドキュメント内の全項目に適用するという種類に分けられるので、これらをパイプラインで最大2回並べるようなUNIXのパイプ処理風の考え方で、それぞれのノードにコンフィグやプラグイン的に編集ロジックを付け足しできるようにしておくという考え方にしておけば、大半のものは対応できると考えています。

あと、そういう世界もあるという話で申しますと、これぐらいのバランスの方が、(何かと力の及ばない)上流システムの設計書(≒EXCELの表)などから、これらの変換処理を生成したり、Elasticsearchのインデックスフィールド項目の仕様書的なものを作成して上納のための事務作業の手間減らしにはちょうどよかったりするというところもつぶやいておきます。

補足4: 同じドキュメントでも項目群別に別インデックスにする考え方もある

最近のElasticsearchでは、1インデックスに1タイプが基本になったので、業務アプリ屋的にはむしろ迷いがなくなってありがたい状況です。

また、負荷分散や可用性方面の話、クラスタやシャードやノードといったところの世界観はElasticsearch自体が面倒を見てくれるので、(Elasticsearchに限らずですが)油断や過信は禁物なものの、1インデックス1タイプでお任せ設計を基本線とすれば良いと思います。

ただし、それでもリソース節約や外部要因(おもに上流のデータソースの事情含む)に引きずられて、ユースケース的には同じタイプなものの、実は全フィールドに値が入っているようなレコードはまれで、領域だけ確保されてスカスカのインデックスとなるという場合もあるでしょう。

スカスカのインデックスが本当にリソースが大ムダになるようなElasticsearch/luceneの仕様かどうかは確かめていないのですが、実際に期待した性能が出ない場合などに札束で殴るわけにもいかずという場合に、アプリでのインデックス分割を考えることになるかもしれません。 ということで、(下のレイヤのシャードやノードの分散・分割はやはりElasticsearch/luceneの世界なので)Elasticsearchのアプリレベルでのインデックス分割が効果がでるかはさておき、こういう分割パターンがあるよねというパターンとチーム内で議論できるようなボキャブラリーをしたためておくのは悪くないと思います。

同じ「タイプ」を別インデックスに保持する方式バリエーション f:id:azotar:20190126162046p:plain

※この分野では、水平分割・垂直分割という用語があるように記憶していますが、ここでは、分割方法の分類よりもどこにアプリケーション視点での分割できる切れ目を作るかという視点なので、自分で言い回しを定義して呼ぶことにしました。

また、この節の主題とはやや異なるのですが、「インデックス分割」から連想される、別のフォーメーションも示しておきます。

RDBのマテリアライズドビュー風に派生して併用 f:id:azotar:20190131014936p:plain

検索結果画面のUIについて

長くなったポエムもこれで最後です。

ここまで、色味やトーンなどのデザインの機微はあるものの、ワイヤーフレームレベルではすでに決まったものという体で特にふれなかった、検索結果画面のUIについてです。

検索結果画面とはつまるところ何なのか

次の

itdepends.hateblo.jp

という過去の記事でもそれとなく主張したのですが、検索サイトの少なくとも初期ローンチ時のフォーマットは他のサイトの標準的なUX/UIに慣れた人向けに、よくあるパターンにはめ込むことになると考えています。

この姿勢はひょっとすると画期的な案を潰しかねない面もありますが、逆に言うと、奇をてらったものに過ぎないのか本当に画期的な案なのかは、一旦「よくあるパターンにはめ込み」した上でそれと画期的な案のプロトタイプを比較するということで炙り出されるとも言えそうなので、そう言う意味でも思考停止にならない程度に、標準パターンにはめ込むことは悪いものではないでしょう。

ということで、パターンはめ込みスタイルで検討していけばいいじゃんというのがこの記事での再度の主張なのですが、もちろん実際の検索サイトでは、色味やトーンなどの見栄えや、ファセットや検索BOXの配置、今であればアニメーションなどを練りこんでいく必要があり、ここの試行錯誤がサイトの成功要否に関わりますので手を抜けないところでもあります。

また、チームに全方面に詳しい・明るいUX/UIエンジニアがプロジェクトにいてリードしてくれれば良いですが、必ずしもそうでないでしょう。しかし、見栄えの話は意見が言いやすいことから、いろいろ発散してしまいがちで、気がつけば「実際はUXに寄与しない誰かの好みの見栄えの確保」のために、せっかく上記で点検・整理した検索プロセスを崩さざるを得ないということにもなりかねません。

ともあれチームビルディングや合意形成のうまいやり方で乗り切るというところも一つですが、そこにどういう役割でかかわるにせよ、チームの一員としては検索結果画面について自分なりのブレないメンタルモデル*3をしっかりイメージしておくと良いと考えています。

私の場合は、

検索結果画面は、

検索処理を伴うものの、実際のところは

「ユーザーが気になるものを意識的・無意識的に机の上に広げたただのリスト」

というイメージで捉えています。

エンドユーザーは机の上あるいは自分の手のひらの中で、ドキュメントを転がしている、そのための場が検索結果画面、というものです。

このイメージをイメージにしたのが次の図です。

f:id:azotar:20190131015559p:plain

イメージを無理にイメージにした図というようにくだらないシャレなアレな図なので読者のみなさんに伝わるかは自信は無いですが、例えばこのような頭の整理をしてみると良いでしょうという主張としてあげさせていただきました。

検索結果画面よくあるパターンのバリエーション(の可視化)

上記の捉え方がイケているかはともかく、ひとまず自分なりのイメージが出来上がると、想定ペルソナとの組み合わせで、必須なあるいはサイトの目的にあうなら確実にあったら良いと思われるUI要素がニョキニョキと生えてきて、次の図のように、結果的に「よくあるパターン」のバリエーションの範囲に収束しそうな気がします。(しませんか???)

検索結果画面よくあるパターンのバリエーション f:id:azotar:20190131015643p:plain

あとは、自分がどの立場かにもよりますが、例えば検索UXとElasticsearchを軸にしたディレクター的立場なら、デザイナーさんやCSSデザイナーさんに、サイトのコンセプトとともに、このバリエーションの図およびバリエーションの図から引き出した今回の利用候補のコンポーネント一覧を伝えて検討してもらう、というような段取りになるかと思います。

まとめ

ある種の検索サイトにターゲットを絞ったとして、どのようにElasticsearchを活かしてサイトをディレクションするかについて自分の意見をまとめてみました。

Elasticsearchをダシにしつつかつターゲットを限定した割には、毎度のごとくイメージ図ばかりになってしまいましたので、テック系の内容やもう少し具体的な例で加筆できそうなものがあれば、(ここで述べているストーリーが古くならないうちに)随時追記したいと思います。ただし、意図せぬお漏らしなどはできないので、実際にすごいことをやっているかどうかは別として検証系はこの記事用に全く無関係なものの似た構造のデータを見つけて来てやってというところになるのでやりたくても悩ましいところ。

また、Elasticsearchを利用させてもらっている者として、その魅力を伝えるようなアピール作文ができたら追記したいです(優等生発言!)。

参考リンク:古くならないうちに....

この記事が「古くならないうち」にと言いましたが、今やこの記事の講釈を垂れたような内容(およびそれ以上の発展的なもの)は、例えばトップランナーの方が紹介されている次のようなもので、実際に簡単に試すことができるところまで来ているようです。

Reactive Searchの紹介

qiita.com

Reactive Search のVue.js版の公式サイト ↓

https://opensource.appbase.io/reactivesearch/vue

(この記事執筆時点では、私はどちらかといえば、Vue.js派なので...)

また、この記事ではスコープを絞り込むことで少ないリソースで検索サイトを仕上げるというスタンスでまとめていますが、そんなチマチマしなくとも、公式のクラウドサービスのうちのさらにいくつかサイト検索系の製品では(私は触って見たことがあるわけではありませんが、おそらく)いろんなユースケース用のパターンがプリセットかつオールインワンで同梱されており、Elasticsearchの生のコンフィグファイル設定ではなくGUIの管理画面などでサクサク検索サイトが実現できると思われます。 (いろいろ類似の例がありますが、私のなんとなく知っている範囲では、Cloudera社のHadoop系のスイーツのサービスモデル・ビジネスモデル、エコシステムと似た動向を感じています。)

自分の書いた記事の自己否定ではないですが、いくつかある検索エンジン、またluceneをベースにしたものの中でもElasticsearchはサービスのエコシステムが強力そうなので、まずは公式サイトを当たってみるのが良いと思います。

www.elastic.co www.elastic.co


========================= 本編終わり ==========================


付録

標準化パターンの解説

本編で「標準化パターン」として私見を述べましたが、これらについて解説(もし新人向けに話したら老害一歩前の説教か退屈な昔話風)しています。

観点 解説
レコード名寄せ そもそものデータソース側がそれでええんかいというところはありますが、同じデータ(とみなせるもの)が複数存在する場合は1件のドキュメントになるようにレコードの名寄せをしましょう。

名寄せっぽいものとして、Elasticsearchの検索機能側には、collapse機能があります。こちらは、同じ著者の複数著作を著者単位に折りたたんだり、複数職種を募集しているある企業を親検索結果として見せるという用途に絞った使い方かと思います。
平坦化 Elasticsearchでは、NestedなデータタイプやObject型の階層データを扱えますが、何かと得意ではありません。得意でないというと語弊があるかもしれませんが、ECでの商品検索のように属性検索と全文検索を併用するというような泥臭系の検索ニーズは検索対象のデータの構造化や階層化に必要以上にこだわりすぎない方が良いのではと考えています。 この世界はオブジェクト指向ではないと思います。

... ということで具体的には、「a.b.c」フィールドの値を、「a_b_c」フィールドに格納し直す、人の名前で「氏名」が「氏」と「名」にサブプロパティに分けて保持しているというようなものを結合して1つのフィールドに統合するというのは考え方の一つとしてあるかと思います。(後者の例はPainless Scriptでも平易に実現可能ですね。)
表示名派生 元のデータソースでコード値持ちのものについては、表示名に置き換えたフィールドを派生してやりましょう。これで、フリーワードでの検索メニューでも該当のフィールドを検索にひっかける土壌ができます。

なお、長くなるので詳しくは書きませんが、コード値と表示名の対応表がデータソース側でどういう運営になっているかを確認しましょう。

また、検索サービスとして、同じ意味を表す表示名の変遷があった場合にどういう扱いにするポリシーとするかを整理しておきましょう(例えば、埼玉市からさいたま市に変わった場合に、お店の住所フィールド自体は最新化するでしょうが、埼玉市でも引き続きヒットさせるようにするか、するとしてどこの何のしかけでそれに対応するか)。
簡易全文検索フィールド かつての「_all」フィールド相当をイメージしたものです。
Elasticsearchには、multi_matchなど、複数フィールド(個別指定、フィールド名ワイルドカード指定ともに可)で同じ条件で検索して(一方、個別にブーストできる)ものもあります。 

ここでは、それらはそれらで活用するとして、様々なところの性能などのトレードオフ等も考慮しつつ、元のデータソースでは、フィールドA、フィールドB、フィールドCのように別フィールドに格納されているものの、属性検索の要件があきらかに発生しないと思われるものについては、一つのフィールドに片寄せしたようなドキュメントになるように編集したものをインデックスにPOSTするという考え方に対応するという考え方もあります。

これにより、検索側のクエリが検索ならではの要件にシンプルになるようにインデックス時に頑張っておくというスタイルもあるでしょう。
データソース側固有のビジネスロジックの圧縮 背景としては前項と似たようなもの。
人材募集サイトで、お金を多めに払ったスポンサー企業の募集と通常の募集を検索時に区別してやるとして(つまり検索シーズからいうと優待顧客とそうでない顧客のYES/NOの区別さえつけば良いという場合に)、データソースのビジネスロジックの都合で、フィールドAがX、BがY、CはZ1またはZ3、ただし...のような判定で「優待顧客かそうでないかの判定」を行う必要がある場合があります。
これを検索側に持ち込むのは得策ではない。... ということで、このような例の場合は、インデックス時にBooleanのフィールドに変換してやるというある種の正規化をしてやるという手はありますよねという話。

ちなみにベタなロジックでドキュメント自体を編集するというのもあるが、ElasticsearchだとPercolatorをうまく使って宣言的に条件を定義しておいてというやり方も使えるのでせっかくなら活用したいところ。
オフライン分類 これまた背景としては前項と似たようなもの。
元のデータソースのあるフィールドをもとにこのドキュメント自体のカテゴリ分けやタグ付け、ラベル付けをしてやる。データソースの方では厳密な値のみの管理で大分類のようなものは管理していない・しようもないような場合でも、検索ニーズではビッグワードや大分類レベルで検索できると嬉しいという話がしばしばあって、検索側だけで頑張る場合は、大分類ワードをデータソースの該当属性の対応付けを元にOR検索になるように展開してやる必要があるのと、検索側に展開のための知識を保持して(ロジックを作って)やる必要があるので、それをどう評価するかという話かもしれません。

なお、OR検索がめんどくさいと言いましたが、大ジャンル→配下の小ジャンル群(配列でなくても空白区切やカンマ区切りの列挙で良い。要素数も特に気にしなくて良い。)をドキュメントとしたジャンル対応インデックスを作ってやることで、このインデックスを独自類義語辞書風につかって、取得した小ジャンル群を"terms"クエリでマルッと検索することができなくはないと思いますので念のため補足しておきます。(ただし、たすき掛けの検索になるので検索時性能としては、冒頭に述べたようなオフラインで事前に分類してやるというチョイスは頭に入れておいた方が良いかもと思います。
ドメイン情報フィールドの除外 うまい言葉が見つからなかったので妙な呼び名になっていますが、主旨としては、データソースのフィールドのうち明らかに表側の検索サービスに不要な項目は、ドキュメントそのものから取り除いておきましょうという方針です。

例えば、ECの商品情報でいうと、データソース側の商品管理システムでは、商品情報最終更新オペレータ名などを保持していると思いますが、このようなフィールドは検索エンジンのインデックスにつかづける前に削除するクリーニングをしましょう(もうちょっと言うと、このようなクリーニング処理を「宣言的」に設定するだけで対象項目を調整できるような仕掛けの要否を整理しておきましょう)という話です。
異体字の派生 お店名や事業者名に含まれる漢字の異体字については都合よくヒットさせて欲しいね(例: 渡辺で渡邊)という要望がつきものかと思います。
教科書的にはchar_filterで頑張るのが基本かもしれませんが、検索対象のデータやAWS Elasticsearch Serviceを利用する場合などは外付けの対応表ファイルを使うのも何かと難しいです。

ということで、異体字を一般にもっとも平易な漢字に変換したものをフィールド派生してやるというのは、案としてアリです。
あくまで対象フィールドを限定して、コメント欄等は対象外で良いと思いますが、得てして事業者名等については、実際のUXとしてどうかはともかく、奇妙だが譲れない(ただし、検索エンジン全体の設定を変える程ではないし、そうしたくない)という要望が出がちだと思いますので、そういう場合の逃げ道にもなると考えています。

なお、ここでは異体字は、派生させる方針としましたが。別案としては、ANALYZERで、kuromoji_readingform token filterの読み仮名化したフィールドをインデックス化するという手があります。kuromojiの辞書の読み仮名の範囲に限られますが、「渡辺」で標準フィールド格納の「渡邊」を検索して空振りしても、読み仮名化したフィールドの検索でひっかけることができます。
カナ項目の清音化 kuromojiやicuなどのANALYZERでは、残念ながら(?)、清音化まではやってくれません。
ここで言う清音化とは「ベット」や「バック」で「ベッド」や「バッグ」をよしなに検索できるようにしましょうという用途のものです。
これぐらいならプラグインを自前で作成しても良いのかもしれません。一応ここでは、ElasitcsearchにPOSTする前の前処理でいろいろやってみるという話の流れなので「ド」を「ト」に置き換えたような「清音化済みフィールド」を用意してやる、あるいは少しだけシンプルに間違えがちな単語に置き換えた派生フィールドを作ってやるというのは案としてあるかもしれません。
もっと素直に「Fuzzy検索」する手もありますね。
ただ、この類のものはどこで入り込んでくるかわからないので、全部をFuzzy検索にするのも...というところも悩ましい話かと思います。

いずれにせよ、他の表記の揺れに比べればニーズが小さいのかもしれませんが、「清音化」で救いたい検索ワードの入力誤り例はしばしば不特定多数が利用するサイトではあなどれないこともあるというところを主張しておきたいです。
aggsのbucket用にグループ化 データソースのRDBでは1つのカラムに0、1ビットでフラグ管理されている類の項目A、B、Cがあります。
検索サービスとしてみると、これらはaggsの同じbucketに分類したいという場合がしばしばあります。
このようなケースに対応しやすくするために、インデックスにPOSTするドキュメントは、A、B、Cの設定内容に応じて、これらを配列に入れてやるというアプローチです。

例えばレストラン検索サイトなら、A、B、Cは、個室有無、貸切可否、駐車場有無といったカラムで、これらを席・設備というbucketにします。

上記の表の中で簡易全文検索フィールドと言っているものについては次の図でそもそもどんなユースケースを想定しているかイメージを表してみています。

(この記事のためにフリーハンドで作成したので(?)、図の中では、「簡易全文検索フィールド」は、「全文検索保険フィールド(仮称)」という呼び名になっています。良い名前を付けてあげたいのですが、初見の人にも伝わるような名前は思いつかずというところです。何か非公開のドキュメントを転用したわけではなく、この記事のために書き下ろした都合やむなしということでご了承くださいませ。)

f:id:azotar:20190126074555p:plain

*1: 念のため、オートコンプリートならやっぱりSuggesters系とした場合の、ど真ん中の正攻法はこちらのまとめを参考にさせてもらうのが良いと思います。 https://qiita.com/kijtra/items/36dd35b3b9db75c88f55

*2:ここでは、クエリコンテキストとフィルターコンテキストの意味のフィルターではなく、EXCELのフィルターのような絞り込みのニュアンス

*3:メンタルモデルという用語の誤用かもしれませんが。ひとまず。

企業のカネと価値(ただしおカネ寄りの視点の意味)の動き・値踏みのオレオレメモ(1)

そろそろ財務諸表を雰囲気レベルで良いので「目利き」できるようになりたいということで、その辺の勉強メモ。

最終的な結果ではなく、理解を深めるために、図解などの試行錯誤の過程を残してみた。この過程をたどることで、従来より深い理解に至ることができたので、同じような悩みの人には有効かもしれないので公開する。

※自分の腹落ちを優先したので、用語などは正確ではないかもしれません。また、アカデミックな定説等からは外れている部分もあるかもしれません。

CFで捉える

BSがピンとこない人はCF(キャッシュフロー)で理解すると良い...というようなことを聞くので、CFがどんなものか確認。

f:id:azotar:20190103002705p:plain

... 頭が硬いのか、センスがないのか、まだピンとこない...

ただ、現代の企業の活動は、営業、財務、投資の軸で捉えるのは定石なんだろうなというのを再認識。

絵にしてみる

続いて、企業のカネまわりの活動について手を動かして絵に書き出してみる。

f:id:azotar:20190103002725p:plain

描いてみて分かったが(描かなくても分かるが...)、サイクルのようでサイクルではないし、メッシュ型のあるいは双方向のグラフ形状になるところもあるので、動的な表現が思ったほど馴染まないと思った。

自分の中の理解の話に過ぎないが、サーバレスアーキテクチャの形に近いんだよな〜と思った。

様々なパスを絵に書き出してみることができた。時間がある時にしかできないので、目的は果たせてないが多少スッキリした。

BS再び

前項で絵に描いてみたことで、分かった...というか諦めがついた。

(これまでの私見とは矛盾するが、)企業の活動やカネの動きを俯瞰するには、カネの入りと結果だけに絞った方が逆にわかりやすいかも。

ということで、先人の知恵にならい、BS視点に戻る。

f:id:azotar:20190103005120p:plain

※この形式の絵自体はいろんなところで見る形だと思います。ただ、BSをカネの動きとして捉える場合は、(横書きドキュメントで時間経過が左から右で表されるのと違い)実際のカネ(活動)の動きはこの図では右から左だということは時々思い出した方が良さそうです。... ということに今更気づいたという話。

少し寄り道

寄り道というか、前提となる、社会か経済かの中高生の教科書の最初のページに書いてあることの再確認。

f:id:azotar:20190103002730p:plain

自分が学生のころから、ここは変わってないような気がするが、その当時以上に、「カネ自体の市場」が実体経済より大きくなっているんだろうなと思う。

また、本来のカネの動きをつかむのには、「ビジネスモデル」の理解が不可欠だと思うが、一方で財務諸表レベルの初見の目利きで深く考えすぎるとかえって混乱するので、ここでは深掘りしないようにしよう。

そうそう、"ビジネスモデル"については、最近だと、次のものを参考にさせてもらっている。

note.mu

こちらで述べられている、「逆接の構造」や「八方よし」というのは、深い理解や分析には欠かせないなあと思う。

また、BSに近い考えの話としては「無形資産」(のれん代)も本来は見落とせないが、やはり切り口がぶれるので、ここでは深掘りはしないことにする。

財務3表の関係を俯瞰

BSを掘り下げるとして、どうしても性格上、釣り合っている部分がどこか気になる。3表のどこの値がどこにつながるかを確認。

ただ、どうも書籍によって違いがある(というか一般に用語に幅があり、一意の定義がない、というかそのような定義をすることが難しいようなので)、あえて目を細めてぼんやり眺めてみることにする。こういう時に、google 画像検索は便利。

財務諸表 関係 - Google 検索

自分の理解に合わせて財務3表の関係を図解しなおし。

f:id:azotar:20190103002744p:plain

もう少しかな...というところ。

BSを大きくする・小さくする

企業の活動は、BSを大きくするあるいは筋肉質にする活動だというようなことを聞いたことがある。

帳簿だけと向き合って机の上だけで物事を捉えるのは良くないと思うものの、CFのフローにならって、また、それらの中の主な活動ごとに、BSのどの領域が大きくなったり小さくなったりするのか(単純化して)書き出してみた。

f:id:azotar:20190103002750p:plain

書き出してみてやっと分かったのだけど、(今回のように単純化して見ると)「現金」を介した活動になるなあという実感。

企業や国は家計と違い資金集めに幅があるものの、やはり「現金」をどれだけ回せるか(あるいはそれ相当の構造を作れるか)は財務的な経営のうまさにつながるんだろうなという理解が進む。

この他、この絵を描いてみて、一般によく言われる次のようなことが多少なりともピンときた。

  1. 事業が成長していて、かつ営業が黒字でも倒産することがある。(例えば、現金が多くなく、仕入れなどのために流動負債が拡大していて、(いざとなったら)現金化できる固定資産も多くないというケース)
  2. 流動負債が大きいものは経営が暗転しやすい。
  3. 資本金が大きい→ 定性・定量的に、大きい会社というのはもちろん、返さなくて良いお金が豊富なので、経営の取り回しに余裕がある。
  4. 固定資産が大きい→ 定性・定量的に、大きい会社というのはもちろん、(いざとなったら)現金化できる。が、資産価値が思ったより低い場合は値踏みに注意が必要。→ドラマでしばしばみられるやつ。
  5. IPOや株式の分割で、返済不要なカネを集める。(もちろん、株主の期待に応えていく必要はあるが...)

企業のカネを通した評価

前項あたりがしっくりきだすと、企業の値踏みというものがなんとなく分かってくる気がする。

ここらへんをすっ飛ばして◯◯率が他社と比べてなんとかかんとかとやっているだけでは自分の性にあわないところだったが、BSと企業の経営のおおまかな関連が見えてくると、自分の感覚に従って捉えた方が良いような気がしてくる。

書籍などでの、企業の評価には「△△性」「◇◇性」... というのがあります...という入り口の場合、ROAは「△△性」「◇◇性」どっちだっけというのが気になって仕方ないが、自分にとっては別にどうでも良いことだった。

ここは人によってはしっくりこないかもしれない例えだが、ドラクエとかでのキャラメーキングやキャラ選択の際にどのスペックを重視するか、というようなものと同じ観点で企業の活動を理解するというのはアリだなと思うに至る。

ということで、次の表をまとめてみた。

f:id:azotar:20190103002758p:plain

通常の教科書だと、分類・指標・(指標の説明としての)観点...の並びで説明してあり(これはもちろん正しいのだけど)、ここでは、(モノの道理から考えると企業活動で重要そうな)観点、キャラ選択になぞらえたら何?、その指標値として財務分析でどのような指標値が使われることが多いか...という並びでまとめてみている。

※「ドラクエで例える」のところは、レベルが上がりやすいキャラ→戦闘を有利に進めるのに役立つ・早いクリアに貢献、HPが多いキャラ→やられにくい、素早さが高い→先手を取れる(アグレッシブな事業の回しがしやすい)、等の連想になっています。私と同じような視点でドラクエのキャラを見ているかにもよりますが、悪くない切り口だと自分では思っています。

並びを変えただけ、自分の言葉で言い直しただけだが、書籍にある表を写経したよりは随分自分の中では進歩した気がする。

この記事おわり。

参考文献・参考リンク

あとで記載。