VespaのParent/Child(document reference)で検索改善の幅が広がります

この記事は何?

情報検索・検索技術 Advent Calendar 2022 - Adventarの12日目の記事です。

昨年の情報検索・検索技術 Advent Calendar 2021 - Adventarでは、検索エンジンVespaのランキング機能について触れ、Vespaでは柔軟な検索ランキングを実現できるよという話を紹介しました。

www.szdrblog.info

今年もVespaの機能について紹介しようと思います。
今回紹介するのは、VespaのParent/Child(document reference)機能です。
VespaのParent/Child機能を利用することで、「文書に紐づくカテゴリ×集計値」といった情報を簡単にVespaで扱うことができ、かつその情報を検索ランキングでも利用できます。

この記事では、

  • 「文書に紐づくカテゴリ×集計値」を利用した検索ランキング改善
  • Parent/Child機能の簡単な紹介
  • Parent/Child機能を実際に使ってみる

の順に紹介していきます。

「文書に紐づくカテゴリ×集計値」を利用した検索ランキング改善

例として、"アルバム(CD)"の検索エンジンを考えたいと思います。

この検索エンジンでは、アルバムに紐づく情報として、

  • タイトル
  • アーティスト
  • カテゴリ(pop, jazz, rock, ...)

を保持しており、タイトル・アーティストを検索できるようになっています。

ここで、検索クエリにマッチしたアルバム集合を、どのようなランキングで結果を返すかについて考えてみたいと思います。

例えば、「アルバムの売上本数」順にランキング結果を返すことで、人気のアルバムを多く含んだ検索結果を返すことができます。
ただし、単純に「アルバムの売上本数」の順にランキングを作ってしまうと、新作のアルバムがランキング上位に含まれないという問題が起こります。
新作のアルバムは、既存のアルバムよりも当然売上本数が低くなってしまいます。

この問題を解決するために、ここでは「アルバムカテゴリごとの売上本数平均」というランキング式を考えたいと思います*1
カテゴリごとに人気度を集約することで、新作のアルバムもランキング上位に含めることができます。
また、カテゴリごとの売上本数平均を日次で集計することで、人気カテゴリが時間経過で変わっても対応できるようにします。

例えば、

  • popカテゴリの売上本数平均:100
  • jazzカテゴリの売上本数平均:50
  • rockカテゴリの売上本数平均:10

としたときに、popカテゴリアルバムのランキングスコアは100となります。
上記の売上本数平均は日次で集計されるため、次の日にはスコアが変わっているかもしれません。


さて、上記のランキングを実現するためには、「カテゴリごとの売上本数平均」という情報を検索エンジンで扱えるようにしないといけません。

素朴には、以下図のような検索エンジンの構成が考えられます。

検索エンジンのフィールドに「カテゴリごとの売上本数平均」を追加しておきます。
アルバムフィーダーは、アルバムDBから引いたアルバムに対して集計バッチの集計結果をJOINし、検索エンジンにアルバム情報をフィードします。

ここで、「カテゴリごとの売上本数平均」は日次で集計したいということを思い出してください。
この構成では、「カテゴリごとの売上本数平均」が更新された際に、検索エンジンに格納されている全アルバムに対し更新フィードする必要があります。
検索エンジンに格納されている全文書に対して情報を付与して格納し直す…という作業は煩雑なので、なるべくなら避けたいところです。*2

…こんな課題において、まさにParent/Child機能がぴったりです。
Parent/Child機能を利用することで、検索エンジンに格納されている全アルバムに対し更新フィードすることなく、「カテゴリごとの売上本数平均」だけ更新することができます!

Parent/Child機能の簡単な紹介

VespaのParent/Child機能について簡単に紹介します。
詳細については、以下のドキュメントを参考にしてください。

Parent/Child機能を一言で言うと、「文書間に親子関係を導入し、親文書の値を参照しながら子文書を検索できる」機能です。

例えば、上で説明したアルバム情報・カテゴリごとの売上本数平均は、以下のような親子関係と見なすことができます。

親(Parent)文書では「カテゴリ・売上本数平均」を管理し、子(Child)文書では「タイトル・アーティスト・カテゴリ」を管理します。

Parent/Child機能を導入することで、親文書と子文書を独立に更新できます。
つまり、子文書を更新することなく、親文書の「カテゴリごとの売上本数平均」だけ更新できるようになります。
結果として、検索エンジンに格納されている全アルバムに対し更新フィードするのを避けることができます!

Parent/Child機能を実際に使ってみる

さて、VespaのParent/Child機能を実際に使ってみましょう。
ここからはVespaのschema(検索エンジンで管理するフィールドを定義)についての知識が必要です。
詳細については、以下のドキュメントを参考にしてください。

親(Parent)文書では「カテゴリ・売上本数平均」を管理するため、以下のようにschemaを定義します。

schema musicCategory {
    document musicCategory {
        field category type string {
            indexing: summary | attribute
        }

        field sales type int {
            indexing: summary | attribute
        }
    }
}

musicCategoryというschemaの中に、field categorysalesを定義しました。

次に、子(Child)文書では以下のようにschemaを定義します。

schema music {

    document music {

        field artist type string {
            indexing: summary | index
        }

        field album type string {
            indexing: summary | index
            index: enable-bm25
        }

        field musicCategory_ref type reference<musicCategory> {
            indexing: summary | attribute
        }

    }

    import field musicCategory_ref.sales as musicCategory_sales {}

    fieldset default {
        fields: artist, album
    }

    rank-profile category_sales inherits default {
        first-phase {
            expression: attribute(musicCategory_sales)
        }

        summary-features {
            attribute(musicCategory_sales)
        }
    }
}
  • field artistおよびalbumは、それぞれアルバムのアーティスト、アルバム名を表します。
  • field musicCategory_refは、親(Parent)文書である`musicCategory`への参照IDを格納します。
    • 例えばアルバムのカテゴリが"pop"であれば、"id:mynamespace:musicCategory::pop"という値が格納されます。
  • import field musicCategory_ref.sales as musicCategory_salesは、親(Parent)文書で管理されているsalesmusicCategory_salesとして参照しますよということを表します。
  • fieldset defaultは検索対象フィールドの集合を表します。artistalbumを指定しています。
  • rank-profileでは検索で利用するランキング式を定義します。今回はカテゴリごとの売上本数平均でランキングしたいため、musicCategory_salesでランキングするように定義しています。


schemaを定義できたので、実際に文書をフィードしてみましょう。

まずは、親(Parent)文書に対応する「カテゴリ・売上本数平均」をフィードしていきます。
今回は、"pop"・"jazz"・"rock"カテゴリに対応する以下3つの文書をフィードします。
それぞれ売上平均は100, 50, 10としてみました。

==> pop.json <==
{"put": "id:mynamespace:musicCategory::pop", "fields": {"category": "pop", "sales": 100}}

==> jazz.json <==
{"put": "id:mynamespace:musicCategory::jazz", "fields": {"category": "jazz", "sales": 50}}

==> rock.json <==
{"put": "id:mynamespace:musicCategory::rock", "fields": {"category": "rock", "sales": 10}}


以下のコマンドで文書をフィードします。

$ vespa document pop.json
$ vespa document jazz.json
$ vespa document rock.json

次に、子(Child)文書に対応するアルバム情報をフィードしていきます。

==> A-Head-Full-of-Dreams.json <==
{
    "put": "id:mynamespace:music::a-head-full-of-dreams",
    "fields": {
        "artist": "Coldplay",
        "album": "A Head Full of Dreams",
        "musicCategory_ref": "id:mynamespace:musicCategory::pop"
    }
}

==> Hardwired...To-Self-Destruct.json <==
{
    "put": "id:mynamespace:music::hardwired-to-self-destruct",
    "fields": {
        "artist": "Metallica",
        "album": "Hardwired...To Self-Destruct",
        "musicCategory_ref": "id:mynamespace:musicCategory::rock"
     }
}


==> Liebe-ist-fur-alle-da.json <==
{
    "put": "id:mynamespace:music::liebe-ist-für-alle-da",
    "fields": {
        "artist": "Rammstein",
        "album": "Liebe ist für alle da",
        "musicCategory_ref": "id:mynamespace:musicCategory::rock"
    }
}

==> Love-Is-Here-To-Stay.json <==
{
    "put": "id:mynamespace:music::love-id-here-to-stay",
    "fields": {
        "artist": "Diana Krall",
        "album": "Love Is Here To Stay",
        "musicCategory_ref": "id:mynamespace:musicCategory::jazz"
     }
}


==> When-We-All-Fall-Asleep-Where-Do-We-Go.json <==
{
    "put": "id:mynamespace:music::when-we-all-fall-asleep-where-do-we-go",
    "fields": {
        "artist": "Billie Eilish",
        "album": "When We All Fall Asleep, Where Do We Go?",
        "musicCategory_ref": "id:mynamespace:musicCategory::pop"
    }
}

フィード時に親(Parent)文書への参照musicCategory_refを含めるのがポイントです。


文書をフィードできたので早速検索してみましょう。

全件検索・ランキング式はcategory_salesを使うように検索してみます。

# 分かりやすさのため、返す項目をjqで絞っています
$ vespa query "select * from music where true" "ranking.profile=category_sales" | jq -c ".root.children[] | [.id, .relevance, .fields.musicCategory_ref]"

["id:mynamespace:music::a-head-full-of-dreams",100,"id:mynamespace:musicCategory::pop"]
["id:mynamespace:music::when-we-all-fall-asleep-where-do-we-go",100,"id:mynamespace:musicCategory::pop"]
["id:mynamespace:music::love-id-here-to-stay",50,"id:mynamespace:musicCategory::jazz"]
["id:mynamespace:music::hardwired-to-self-destruct",10,"id:mynamespace:musicCategory::rock"]
["id:mynamespace:music::liebe-ist-für-alle-da",10,"id:mynamespace:musicCategory::rock"]

カテゴリ"pop"に対応する文書のスコアは100、"jazz"に対応する文書のスコアは50、"rock"に対応する文書のスコアは10となっています!
親(Parent)文書の要素を利用してランキングできることが確認できました。


次は、親(Parent)文書の情報を更新し、再度検索を投げてみます。

カテゴリ"rock"の売上本数平均を1,000としてみます。

{"put": "id:mynamespace:musicCategory::rock", "fields": {"category": "rock", "sales": 1000}}
$ vespa document rock2.json

親(Parent)文書の情報が更新されていることを確認します。

# 分かりやすさのため、返す項目をjqで絞っています
$ vespa query "select * from musicCategory where true" | jq -c ".root.children[] | [.id, .fields.sales]"

["id:mynamespace:musicCategory::pop",100]
["id:mynamespace:musicCategory::jazz",50]
["id:mynamespace:musicCategory::rock",1000]

カテゴリ"rock"の売上本数が1,000と更新されています。

子(Child)文書を検索してみます。

# 分かりやすさのため、返す項目をjqで絞っています
$ vespa query "select * from music where true" "ranking.profile=category_sales" | jq -c ".root.children[] | [.id, .relevance, .fields.musicCategory_ref]"

["id:mynamespace:music::hardwired-to-self-destruct",1000,"id:mynamespace:musicCategory::rock"]
["id:mynamespace:music::liebe-ist-für-alle-da",1000,"id:mynamespace:musicCategory::rock"]
["id:mynamespace:music::a-head-full-of-dreams",100,"id:mynamespace:musicCategory::pop"]
["id:mynamespace:music::when-we-all-fall-asleep-where-do-we-go",100,"id:mynamespace:musicCategory::pop"]
["id:mynamespace:music::love-id-here-to-stay",50,"id:mynamespace:musicCategory::jazz"]

カテゴリ"rock"に対応する文書のスコアが1,000となっていることが分かります!

さらなる応用

今回は「文書に紐づくカテゴリ×集計値」ということで、アルバムのカテゴリごと売上本数平均を考えてみました。
「文書に紐づくカテゴリ×集計値」として、他にも様々なランキング要素(特徴量)を考えることができます。

  • カテゴリ:曲カテゴリ、アーティスト、年代、…
  • 集計値:売上本数、金額、…
  • 集計期間:1週間、1ヶ月、1年、…

集計単位ごとに親(Parent)文書を用意することで、検索改善の幅をどんどん広げることができます。

まとめ

この記事では、アルバムの検索エンジンを例に、「文書に紐づくカテゴリ×集計値」を利用した検索ランキング改善を実現するため、VespaのParent/Child機能を利用できることを紹介しました。
Parent/Child機能を利用することで、子(Child)文書を更新することなく、「文書に紐づくカテゴリ×集計値」を更新し、ランキングに反映する流れを追ってみました。

おまけ

ElasticsearchでもParent-Childに相当する機能があるようです。*3
詳細は把握できておりませんが、Elasticsearchでも同様に親(Parent)文書の情報を検索の要素として使えるのかもしれません。

*1:もちろん、もっと良いランキングはいくらでも思いつくと思いますが、、

*2:検索エンジンに格納する文書数が少ないなど、リスクが小さければこの方法を取ることも考えられます。

*3:Parent-Child Relationship | Elasticsearch: The Definitive Guide [master] | Elastic