6 ポイント 投稿者 GN⁺ 2023-07-13 | 1件のコメント | WhatsAppで共有
  • PostgreSQLの全文検索は、専用の検索エンジンなしで tsvectortsquery@@ts_rankGINインデックス を組み合わせて検索機能を構築するアプローチ
  • 検索語と文書を lexeme 単位で正規化し、AND・OR・NOT・FOLLOWED BY のような演算子でクエリを表現することで、一般的な検索文法のかなりの部分を実装できる
  • GINインデックスは、例の環境では検索時間を 200ms超から約4ms に短縮したが、結果が増えると ts_rank によるランキング・ソートのコストがボトルネックになりうる
  • 関連度の調整は、タイトルの重み、投票数、評価、ジャンル、新しさといったシグナルを ソート式 に加える、あるいは setweight でカラム重みを付与することで実装する
  • タイポ許容、ファセット検索、オートコンプリート、正確なフレーズ検索、ハイブリッド検索も可能だが、PostgreSQLでは構成要素を自分で組み合わせる必要があり、大規模データセットでは性能の限界を確認する必要がある

PostgreSQL全文検索のアプローチ

  • PostgreSQLは全文検索のための低レベルな 構成要素 を提供しており、それらを組み合わせて検索エンジン機能を作れる
  • この方式は柔軟だが、全文検索を主用途とする Elasticsearch、Typesense、Meilisearch よりも実装作業が多く必要になる
  • 例のクエリでは Kaggle の Wikipedia Movie Plots データセットを使用
    • 映画タイトル34,000件を含む
    • CSV形式のサイズは約 81MB

中核構成要素

  • PostgreSQL全文検索は次の要素を中心に動作する
    • tsvector: 検索対象テキストを正規化された lexeme の一覧として保存
    • tsquery: 正規化された検索クエリを表現
    • @@: tsquerytsvector と一致するかを確認するマッチ演算子
    • ts_rankts_rank_cd: 検索結果の関連度スコアを計算
    • GINインデックス: tsvector を効率的に問い合わせるための転置インデックス

tsvector と検索設定

  • tsvector はソート済みの lexeme 一覧を保存する
    • lexeme はトークンに似ているが、同じ単語の複数の形が1つにそろうよう正規化された文字列
    • 英語設定では、大文字を小文字に畳み込み、接尾辞を除去する形で正規化する
  • to_tsvector で英語文をパースすると、“I”、“to”、“an” のような ストップワード が除去される
    • “refuse” と “Refusing” はどちらも refus に変換される
    • 句読点は無視される
    • 元文での単語位置と重みも記録される
  • english 検索設定の代わりに simple 設定を使うと、単語はテキスト内で見つかった形のまま含まれる
    • “refuse” と “refusing” は別々の lexeme のまま残る
    • simple 設定は、ラベルやタグを含むカラムで特に有用
  • PostgreSQLは複数言語の組み込み検索設定を提供するが、CJK(中国語・日本語・韓国語)の設定はない
    • サポートされない言語には simple 設定が実用的にうまく動作することがある
    • ただし、CJKに十分かどうかは確かではない

tsquery とクエリ表現

  • tsquery は正規化された検索クエリを表現するデータ型
    • 検索語はすでに正規化された lexeme である必要がある
    • 複数の検索語は AND、OR、NOT、FOLLOWED BY 演算子で結合できる
  • to_tsqueryplainto_tsquerywebsearch_to_tsquery は、ユーザー入力のテキストを適切な tsquery に変換する助けになる
    • 中核となる役割は、入力テキストに含まれる単語を正規化すること
  • websearch_to_tsquery を使うと、一般的な検索ボックスに近いクエリを作れる
    • darth vader は、両方の単語が文書内に存在しなければならない論理ANDとして扱われる
    • OR検索や単語の除外も可能
    • フレーズ検索は、単語が順番どおりにつながる形を表現する
  • 英語設定では “the” のようなストップワードが除去されるため、一部のフレーズ検索ではほぼ全文句が消えてしまうことがある
    • この場合は simple 設定を使うと期待どおりの結果が得られる
  • @@ 演算子は、tsquerytsvector と一致するかを確認するときに使う

GINインデックスと検索性能

  • GIN は Generalized Inverted Index の略で、複合値の中に含まれる要素値を見つけるクエリ向けに設計されたインデックスタイプ
  • GINはテキスト検索だけでなく JSON クエリにも使える
  • 検索対象となる複数カラムを結合した tsvector カラムを作り、そのカラムに GIN インデックスを生成できる
  • 例の環境では GIN インデックスにより、検索時間は 200ms超から約4ms に短縮された

ランキングと関連度計算

  • 良い検索体験のためには、結果を 関連度 で並べ替える必要がある
  • PostgreSQLは ts_rankts_rank_cd の2つの事前定義ランキング関数を提供する
    • どちらの関数も検索語の出現頻度を考慮する
    • ts_rank_cd は一致した lexeme 間の近接性も反映する
  • 関連度はアプリケーションの性質に大きく左右される
    • 基本のランキング関数は出発点に近く、必要に応じて独自のランキング関数を作ったり他の要素と組み合わせたりできる
  • ts_rank は各結果の search カラムにアクセスする必要がある
    • WHERE 条件に多くの行がマッチすると、PostgreSQLはランキング計算とソートのためにそれらすべての行を訪問しなければならない
    • 例の環境では、あるクエリは5〜7msで返ったが、darth OR vader のように1,000件超の結果をランキングするクエリでは約 80ms かかった

関連度チューニング

  • 単語頻度ベースの関連度は良いデフォルトだが、データには頻度より重要なシグナルがある場合がある
  • 映画データセットでは、次のシグナルを関連度に反映できる
    • タイトルでマッチした結果を、説明やあらすじでマッチした結果より重要に扱う
    • 評価や投票数を基準に、より人気の高い映画を押し上げる
    • ユーザーがコメディを好むなら、コメディ映画をより上位に配置する
    • 古いタイトルより新しいタイトルのほうをより関連度が高いとみなす
  • 専用検索エンジンは、異なるカラムやフィールドがランキングに影響するよう設定する機能を提供している

数値、日付、正確な値に基づくブースト

  • PostgreSQLは他カラムに基づくブーストを直接は提供しないが、ランキングは結局 ソート式 なので独自シグナルを加えられる
  • 投票数を反映するには、ランキングスコアに投票数ベースのブーストを加える形で実装できる
    • 例ではログを使って影響を緩やかにしている
    • 0.01 の係数でブースターをランキングスコアと近いスケールに合わせている
  • 投票数が一定以上の場合にのみ評価をブーストする、より複雑な関数も作れる
  • 特定のジャンルを押し上げたいなら、値が特定カラム値と一致したときだけ係数を返す valueBooster のような関数を使える

カラム重み

  • tsvector の lexeme には 重み を付けられる
  • PostgreSQLは A、B、C、D の4種類の重みをサポートする
    • A が最も高い重み
    • D が最も低く、デフォルト値
  • setweight 関数を使って tsvector カラムを作る際に重みを制御できる
  • タイトルカラムにより高い重みを与えると、検索語がタイトルに含まれる映画が結果上位に上がり、ランキングスコアも増加する
  • 重みクラスが4つしかない点は制約であり、重みは tsvector を計算するときに適用する必要がある

タイポ許容とあいまい検索

  • PostgreSQLは tsvectortsquery を使う場合、あいまい検索 やタイポ許容を直接サポートしない
  • クエリ側にタイプミスがある前提で、次の方法で実装できる
    • コンテンツ中のすべての lexeme を別テーブルにインデックスする
    • クエリの各単語ごとに、類似度や Levenshtein distance で候補語を検索する
    • 見つかった単語を含むようにクエリを修正する
    • 修正後のクエリで検索を実行する
  • 例では、検索エンジンがあいまい検索に使う方式であることを理由に Levenshtein distance を使用している
  • 候補語の一覧を得たあとは、それらの単語をすべて含むようにクエリを調整する必要がある

ファセット検索

  • ファセット検索 は、とくにECサイトでユーザーが検索範囲を段階的に絞り込むために広く使われている
  • PostgreSQLではカテゴリを手動で定義し、それを検索の WHERE 条件に追加して実装できる
  • 既存データをもとにカテゴリをアルゴリズム的に作る方法も可能
    • 例では映画の年をもとに “Decade” ファセットを作る
    • 各 decade ごとの一致件数も計算し、括弧内に表示できる
  • 複数ファセットを1回のクエリで取得するには CTE を組み合わせられる
  • この方法は小規模〜中規模のデータセットではうまく動く可能性があるが、非常に大きなデータセットでは遅くなることがある

PostgreSQL検索エンジンの範囲と限界

  • PostgreSQLの全文検索コンポーネントを組み合わせれば、かなり高度な検索エンジンを作れる
  • PostgreSQLベースの検索は、JOIN と ACIDトランザクション も併せてサポートする
    • これは他の検索エンジンが一般には持たない機能
  • さらに扱う価値のある高度な検索トピックとして次がある
    • サジェストとオートコンプリート
    • 正確なフレーズマッチ
    • pg-vector と組み合わせたハイブリッド検索
  • これらの機能はPostgreSQLでも可能だが、構成要素を自分で組み合わせる必要がある
  • 一部のケースでは、非常に大きなデータセットで性能が低下する可能性がある
  • 続編の part 2 では、PostgreSQLに検索を実装する場合と、Elasticsearchをインフラに追加してデータを同期する場合を比較している

1件のコメント

 
GN⁺ 2023-07-13
Hacker Newsのコメント
  • Elasticsearchの機能を真似する形でこれをやろうとしてはいけない
    2000年代にMySQL 3.xで画像のEXIFデータを解析し、3段階の分類体系とカウントを索引化する検索エンジンを作ったことがあるが、複数の高価なベンダー製品でもまともにできていなかった仕事で、Autonomyは最上位分類すらできなかった
    6週間、SELECTのカラム順を変えるだけで性能が変わるような脆いSQLを書いてようやく成功したが、二度とやりたくない。データベースは本質的には大きく変わっていなくても、検索エンジンははるかに進化している
    知的好奇心で試すことはできるが、検索は単にトークン化して終わりではない。すぐにカテゴリナビゲーション、多言語、自動同義語、"Did you mean"のスペル提案、大規模性能といった要求が入ってきて、自分で袋小路にはまり込むことになる。精神衛生のためにも検索エンジンを使うほうがよく、PGとESの同期にはZomboDBやPGSyncのようなツールもある

    • このスレッドには別々の2つの議論が混ざっている。顧客向け検索エンジンを作るなら、Elasticsearchのような強力なツールを使って車輪の再発明をしないのが正しい
      一方で、データアナリストや開発者が大きなデータベースのテキストカラムをLIKE/ILIKEより柔軟に検索したいだけなら、同じデータベース内に全文検索インデックス/テーブルを作って90%まで持っていくほうが、より簡単で速いこともある
  • 第2部のPostgres vs Elasticsearch比較に期待している。うちの会社のあるアプリケーションでは、オブジェクトCRUDはPG、検索はElasticを使っているが、2つのデータストアを同期する手間を完全に過小評価しており、実際にElasticsearchの撤去を検討している

    • 以前使っていたやり方は、Elasticsearchをいつでも再構築できる対象として扱うこと
      5分ごとにcronがlast_modified_at > last_indexing_started_timestampな索引対象オブジェクトをDBから探し、Elasticsearchに索引した後、last_indexing_started_timestampを同期開始時刻で更新する。そうすると、実行開始から終了までの間に更新されたオブジェクトは次回の実行で拾われる
      Elasticsearchを再構築する必要があれば、最後の索引時刻を空にして最初から再同期すればよく、自己修復が可能になって同期ずれも起きない
    • 前職で似た構成を使っていたが、特別難しくはなかった。PGのエンティティが更新されるたびにメッセージを送り、非同期でESに複製し、ES側はPGからIDで取得して埋めていた
      非同期ジョブではいつもそうだが、監視とリトライは必要だった。ただESは安定していて速かったので、問題はまれだった。もっとも、一貫性要件が緩く、PGの最新状態が妥当な時間内にESへ届けば十分だったので、要件が違えば話は変わるかもしれない
    • PostgresをCRUD、Elasticを検索に使う形で似たように進めたが、2つのストアの同期だけでなく、限られた人員と経験で安定したElasticクラスタを運用することも過小評価していた
      Postgresの全文検索でインデックスとクエリに重み付けを行う方向へ移した後は、更新トリガーと非常に高速な検索クエリだけで、必要なことをすべてPostgres内で処理できるようになった
    • zombodbは使ったことがある? [https://www.zombodb.com/]
    • 使ったことはあるが、かなり経験があっても難しい作業で、結果の一部はむしろ悪化した。大半は同程度で、ごく一部だけが良くなった
      全体としては成功で、運用負荷が大幅に減ったため投入したエンジニアリング時間は十分回収できたが、気軽に始めるようなものではない
      必要に応じてマテリアライズドビュー、通常のビュー、トリガーのほうが良い場合もある。組み込みのテキスト検索がユースケースに合わないこともあり、代替を作ることが必ずしも難しいとは限らない
  • 現実的な負荷でのp50/p99レイテンシがなければ、実証されたとは言いがたい。結果を1分で返す検索エンジンは「高度」ではなく、Postgresのようなリレーショナルデータベースでも机上ではもちろん可能だ

    • 筆者として、Elasticsearchと比較する続編を書く予定はあるが、ベンチマークはたぶん試さない。どんな現実的シナリオを作っても、それぞれのユースケースにぴったり一致するわけではないからだ
      おおむね同意で、大規模、たとえば数百万レコード以上ではこの方法を使わない可能性が高い。主な関心は、どこまで機能を再現できるかだった
      小規模な検索ユースケースでは、維持すべきインフラが少なく、強い整合性やJOINのような利点がある。Xataでは小規模ではPostgresを使い、最小限の破壊的変更でElasticsearchへ移行できる滑らかな移行も検討している
    • 元Google出身者と面接したとき、以前に見られた検索語の検索結果をすべてキャッシュし、インデックス更新時にキャッシュ済み結果も一緒に更新すると聞いた
      その観点では、速い検索結果自体はそれほど驚くことではない。バックグラウンドジョブでキャッシュ結果を継続的に更新しておき、リクエストが来たらそのまま返せるからだ。こうしたキャッシュと応答時間は、実際の検索結果の計算速度とは別問題に見える
    • 現実的な負荷でp50/p99を求めることには問題がある。多くの人が実際には検索しない状況で、検索エンジンの本当に現実的な負荷をどう作ればいいのだろうか? 無作為な検索語で叩くのは現実的ではない
      低速な接続を使う人もいるし、地震のような特定の検索語が特定地域でだけ急増することもある
      検索語がランダムすぎるとキャッシュに結果がなく実際より悪く出るし、十分にランダムでなければ実際より良く出る
    • Lucene、つまりElasticsearchやSolrが使っている基盤は転置インデックスであり、記事のGINも同じ方式を使っている
      したがって、ESなどの利点は複数ノードにまたがる水平スケーリングや、主インデックスの上に載せる追加機能にある
    • Postgresの全文検索を使っているが、うまく動いている。ただし、クエリで行の順位をどう付けるかは分かっている必要がある
      ts_rankだけ使えば十分なこともあるが、普通は他の関連度指標で順位を補正したくなる。その指標を主順位基準にはできないという点を解決できれば、結果はインデックス付きの通常のDBテーブルクエリと同じくらい高速だ
  • 10代のとき、検索エンジンもデータベースもよく分からない状態で、両方をゼロから作ってみた。どこまで行けるのか、どの程度の速さでどの程度関連性のある検索結果を返せるのか見てみたかった
    基本的なデータベースや検索エンジンを素早く作るのは、アマチュアプログラマにとってもかなり簡単だ。基礎的なコンピュータサイエンスのアルゴリズムと、OS・ハードウェアの使い方を理解していれば、1〜2か月で作れる
    高水準言語でも速度は悪くなく、2003年のノートPCで約 25万 QPS ほどだった。シャーディングすればスケーラビリティも大きな問題ではない。保存と検索よりも、インデックス作成、ロック、一貫性のほうが複雑だ
    本当に大きな問題は検索の 主観性 だ。何を本当に探したいのか、自分でも何を探しているのか分からないときにどう見つけるのか、システムを悪用しようとする人をどう防ぐのか、複雑なクエリやデータセットをどう扱うのか、といった話になると難易度は桁違いに上がる

    • 25万 RPS は今どきの SQLite でも出せない水準なので、かなり大きく見える。転置インデックスはもっと高コストな気がするし、RocksDB も約13万 RPS だが、その数字も自分のノートPCより強いハードウェアでの話か、あるいは自分の環境設定が悪いのかもしれない
      本当に 汎用データベース を使っていて、その数字が正しいのか気になる。こうしたエンジンを上回るためにどんな手法を使ったのか知りたい
    • 検索エンジンを作るときの最大の問題は QPS ではなく、インデックスする データセットのサイズ だ。検索構造が単一マシンのメモリに収まるなら、レイテンシはほぼ 0 に近く、事実上無限の QPS を提供できる
      それより大きくなると創造的な解決策が必要になり、そこからさまざまなトレードオフが現れる
    • オープンソースとしてどこかで公開されているのか気になる
  • Postgres 内で検索するときの問題の1つは、検索が CPU を大量に使う処理 だという点で、最近は GPU を使う方向にも進んでいる。理想的には、データベースの CPU はコアデータモデルのトランザクション更新のために残しておきたい
    ES や Solr のクラスターが再インデックス中に 10 ノード以上で 100% 稼働したり、平常時でも 10 ノード以上で 30〜50% を使っているケースをよく見てきた。対応するデータベースは、たとえば AWS の L/XL インスタンスで 50〜100GB のデータ、CPU 使用率 30% 程度だったりする
    検索用の CPU をすべてメイン DB に移すと、今度は DB をシャーディングしなければならない。それでもサイドプロジェクトでは、検索、再帰結合、ベクトルなどのための PG 拡張 は面白くてシンプルなので良い

    • 読み取り専用レプリカで検索すれば解決しないだろうか?
  • 以前これを実際にやってみて、かなり高速に作れた
    https://austingwalters.com/fast-full-text-search-in-postgres...
    現在のウェブサイトは https://askhn.ai

  • こうした手法を pgvector と組み合わせれば、埋め込みで関連コンテンツを見つけることもできる。かなり魔法のように感じた

    • もう少し詳しく説明するか、参考になる資料を教えてもらえる?
    • Vespa のようなものが使えるのに、人々がこういうハッキーな道に進むのは興味深い。性能、関連性、スケーラビリティ、開発者体験の面で Vespa のほうが何桁も優れていると思う
  • ちなみに "Dark" Vader ではなく Darth Vader だ。自分も子どものころは "Dark" だと思っていた

    • フランス語など一部の翻訳では実際に Dark Vador なので、その「誤り」はよく起こる
    • 記事のその部分が気になりすぎて、ほかの内容に集中できなかった。Yoda の引用を知っていながら、どうしてまだ Dark Vader と書けるんだ?
  • とても良くて明快な記事だ。SQLite も標準プラグインで 高度なインデックス機能とステミング を提供している
    英語であれば SQLite でも十分うまく動く

    • SQLite で実験した資料を見つけた
      https://github.com/daitangio/knowledge
      一度使ってみるといい。かなり強力だ
    • FDW でもできるのか気になる。検索対象データだけ複製すれば、書き込みワーカーは多くないだろうから、中規模データではうまく動くかもしれない
  • 良い記事だが、PostgreSQL が あいまい検索 をサポートしていないという部分は一部誤っている。pg_trgm 拡張と GIN トライグラムインデックスは、この記事の例のようなあいまい検索のユースケースをサポートしている
    https://www.postgresonline.com/article_pfriendly/169.html
    クエリは大幅に高速化できるが、その代償としてメモリ使用量と更新時の処理時間が増える