Postgresを検索エンジンとして活用する
(anyblockers.com)- Postgres 内で、セマンティック検索、全文検索、あいまい検索をすべて備えたハイブリッド検索エンジンを構築できる
- 検索は多くのアプリで重要な要素だが、適切に実装するのは簡単ではない。特に RAG パイプラインでは、検索品質がプロセス全体の成否を左右しうる
- 意味論的な(Semantic)検索はトレンドだが、従来の語彙ベース検索は依然として検索の中核である
- セマンティック技術は結果を改善できるが、堅牢なテキストベース検索の土台の上で最もよく機能する
Postgresを活用した検索エンジンの実装
- 3 つの技術を組み合わせる:
tsvectorを使った全文検索pgvectorを使ったセマンティック検索pg_trgmを使ったあいまいマッチング
- このアプローチはあらゆる状況で絶対的に最良とは限らないが、別個の検索サービスを構築することに対する優れた代替案である
- 既存の Postgres データベース内で実装・拡張できる堅実な出発点
- Postgres を何にでも使うべき理由: とにかく Postgres をあらゆる場所で使おう, PostgreSQLで十分だ, とにかく Postgres を使おう
FTSとセマンティック検索の実装
- Supabase にはハイブリッド検索実装に関する優れたドキュメントがあるため、これを出発点にする
- ガイドに従って GIN インデックスで FTS を実装し、pgvector(bi-encoder dense retrieval とも呼ばれる)でセマンティック検索を実装する
- 個人的な経験では、1536 次元の埋め込みを選ぶとより良い結果が得られる
- Supabase 関数を CTE とクエリに置き換え、パラメータの前に
$を付ける - ここでは RRF(Reciprocal Ranked Fusion)を使って結果を統合する
- この方法は、複数のリストで高順位の項目が最終リストでも高く評価されることを保証する
- また、あるリストでは高順位でも別のリストでは低順位の項目が、最終リストで過度に高く評価されないことも保証する
- 順位を分母に入れてスコアを計算すると、下位のレコードに不利になる場合がある
- 注目点
$rrf_k: 1 位の項目のスコアが極端に高くなるのを防ぐため(順位で割るため)、分母に k 定数を足してスコアを平滑化することが多い$ _weight: 各手法に重みを割り当てられる。結果を調整する際に非常に有用
あいまい検索の実装
- ここまでの方法でも多くは解決できるが、名前付きエンティティにタイプミスがあるとすぐに問題が生じうる
- セマンティック検索は類似性を捉えることで一部の問題を緩和するが、名前、略語、その他セマンティックに類似していないテキストには弱い
- これを軽減するため、
pg_trgm拡張を導入してあいまい検索を可能にする- トライグラムで動作する。トライグラムは単語を 3 文字列に分解するため、あいまい検索に有用
- これにより、タイプミスやわずかな表記揺れがあっても似た単語をマッチさせられる
- たとえば "hello" と "helo" は多くのトライグラムを共有するため、あいまい検索でよりマッチしやすい
- 対象のカラムに新しいインデックスを作成し、その後で検索クエリ全体に追加する
- pg_trgm 拡張は
%演算子を提供し、類似度がpg_trgm.similarity_threshold(デフォルトは 0.3)より大きいテキストをフィルタリングする - ほかにも便利な演算子がいくつかある
全文検索のチューニング
tsvectorの重みを調整する: 実際の文書にはタイトルだけでなく本文も含まれる- カラムが複数あっても、埋め込みカラムは 1 つだけ維持する
- 個人的には、複数の埋め込みを維持するより、
titleとbodyを同じ埋め込みに入れても性能差は大きくないと感じている - 結局のところ、
titleは本文の簡潔な表現であるべきだ。必要に応じて試すとよい titleは短くキーワードが豊富である一方、bodyはより長く詳細を含むと想定される- したがって、全文検索カラム同士の重み付けを調整する必要がある
- 文書内の単語の位置や重要度に応じて優先順位を付けられる
- A-weight: 最も重要(例: タイトル、ヘッダー)。デフォルト
1.0 - B-weight: 重要(例: 文書冒頭、要約)。デフォルト
0.4 - C-weight: 標準的な重要度(例: 本文テキスト)。デフォルト
0.2 - D-weight: 最も重要度が低い(例: 脚注、注釈)。デフォルト
0.1
- A-weight: 最も重要(例: タイトル、ヘッダー)。デフォルト
- 文書構造とアプリケーション要件に応じて重みを調整し、関連性を細かく最適化する
- タイトルにより高い重みを与える理由
- タイトルは通常、文書の主要テーマを簡潔に表現しているため
- ユーザーは検索時にまずタイトルをざっと見る傾向があり、タイトルのキーワード一致は一般に本文テキストの一致よりもユーザー意図と関連しやすい
長さに応じた調整
ts_rank_cdのドキュメントを読むと、正規化パラメータがあることが分かる-
両方のランキング関数は、文書の長さが順位にどのような影響を与えるべきかを指定する整数の
normalizationオプションを使う。この整数オプションは複数の動作を制御するため、ビットマスクになっている。|を使って 1 つ以上の動作を指定できる(例:2|4)。
-
- これらのオプションにより次のことが可能
- 文書長バイアスの調整
- さまざまな文書集合における関連性のバランス調整
- 一貫した表現のためのランキング結果の調整
- タイトルには
0(正規化なし)、本文には1(対数文書長)を設定すると良い結果が得られる - 繰り返しになるが、ユースケースに最適な設定を見つけるため、さまざまなオプションを試すのがよい
クロスエンコーダを使った再ランキング
- 多くの検索システムは 2 段階で構成される
- つまり、bi-encoder で最初の N 件の結果を取得し、その後 cross-encoder でそれらの結果と検索クエリを比較して順位付けする
- bi-encoder: 高速なので大量の文書検索に向いている
- cross-encoder
- より遅いが性能は高く、検索結果の再ランキングに適している
- クエリと文書を一緒に処理することで、両者の関係をより微妙なレベルで理解できる
- その代わり、計算時間とスケーラビリティを犠牲にして、より高いランキング精度を提供する
- これを行うためのさまざまなツールがある
- 最良のものの 1 つは CohereのRerank
- 別の方法は OpenAI の GPT を使って自前で構築すること
- クロスエンコーダは、クエリと文書の関係をより深く理解できるため、検索結果の精度を高められる
- ただし計算コストが高いため、スケーラビリティの面では制約がある
- そのため、初期検索には bi-encoder を使い、取得した少数の文書にだけ cross-encoder を適用する 2 段階アプローチが効果的である
代替ソリューションを検討すべきタイミング
- PostgreSQL は多くの検索シナリオに適した選択肢だが、限界がないわけではない
- BM25 のような高度なアルゴリズムがないことは、文書長のばらつきが大きい場合に感じられることがある
- PostgreSQL の全文検索は TF-IDF に依存しているため、非常に長い文書や大規模コレクション内の希少語に弱い場合がある
- 代替ソリューションを探す前に、必ず計測すべきだ。見合わないかもしれない
結論
- この記事では、基本的な全文検索からあいまいマッチング、セマンティック検索、結果ブーストのような高度な技術まで幅広く扱った
- Postgres の強力な機能を活用すれば、特定の要件に合わせた強力で柔軟な検索エンジンを作れる
- Postgres は検索用として最初に思い浮かぶツールではないかもしれないが、非常に多くのことを実現できる
- 優れた検索体験の鍵
- 継続的な反復と微調整
- 紹介したデバッグ手法を使って検索性能を理解し、ユーザーフィードバックや行動に基づいて重みやパラメータを調整することを恐れないこと
- PostgreSQL は高度な検索機能に欠ける場合もあるが、ほとんどのケースでは十分に強力な検索エンジンを構築できる
- 代替ソリューションを探す前に、まず Postgres の機能を最大限活用し、性能を計測してみるのがよい。それでも不足するなら、その時に別のソリューションを検討できる
2件のコメント
日本語検索もちゃんとできるのか気になりますね。
今日のWeeklyのテーマもPostgresでしたが、やはりまたPostgresですね。確かに人気に比例して記事がたくさん出てくるようです(笑)
BM25 については以下を参照してください。
pg_bm25 - PostgresでElastic並みの品質を提供する全文検索拡張
ParadeDB - PostgreSQL for Search