「社内版ビズリーチ by HRMOS(以下、社内版ビズリーチ)」では、自然な言葉で社内の人材を探すことができるセマンティック検索機能を提供しています。セマンティック検索には、ベクトル検索を用いており、PostgreSQLの拡張機能であるpgvectorとHNSWインデックスを使用しています。

ベクトル検索には、専用のベクトルデータベースを使用するなど様々な選択肢があります。「社内版ビズリーチ」は新規プロダクトであり、高速な価値検証が求められていました。そのため、運用負荷を下げ、開発スピードを優先できるよう、APIサーバーで使用するPostgreSQLにpgvectorを導入するというシンプルなアプローチでセマンティックサーチを実現しました。

ベクトル検索で一般的に使用されるHNSWインデックスは、高速なベクトル検索を実現しますが、フィルター条件と組み合わせて使用することが難しいという仕組み上の課題が存在します。特にマルチテナントSaaSでは企業ごとのフィルターが必要となるため、この課題への対応が求められます。

本記事では、「社内版ビズリーチ」での実例を元に、pgvectorで高速なフィルター付きベクトル検索を実現するためのHNSWインデックス設計を紹介します。

1.HNSWインデックスの仕組みとフィルター付きベクトル検索の課題

1.1.HNSWインデックスの基本構造と探索の仕組み

HNSW(Hierarchical Navigable Small World graphs)は、階層的なグラフ構造でベクトルを管理する近似最近傍探索(ANN)アルゴリズムです。

インデックスを用いないベクトル検索では、すべてのベクトルとの距離を計算する必要があります。100万件のデータがあれば100万回の距離計算が必要となり、データ量に比例して検索時間が増加します。

HNSWは階層的なグラフ構造により、この問題を解決します。複数の層で構成され、上層ほど頂点数が少なく、下層ほど頂点数が多くなります。

探索時は、最上層から開始し、各層で隣接ノードとの距離を計算しながら検索クエリにより近いノードへ移動します。それ以上近いノードが見つからなくなったら次の層へ移動し、最下層で広範囲に探索して指定された数の近傍ベクトルを取得します。

この仕組みにより、全ベクトルをチェックすることなく、グラフを辿った少数のベクトルのみで近似最近傍を見つけられます。データ量が増えても探索時間の増加を大幅に抑えられるため、大規模なベクトル検索で広く使われています。

1.2.フィルター条件との組み合わせにおける課題

HNSWは、隣接ノードの中から検索クエリに最も近いノードへ移動する仕組みのため、フィルター条件と組み合わせることが困難であるという課題があります。

マルチテナントでのデータを例に説明します。複数の企業データが混在している場合、複数企業を横断したHNSWグラフが作成されます。 そのため、企業1のデータのみを検索したい場合でも、HNSWはフィルター条件を考慮せず検索クエリとの距離のみでグラフを構築しています。

HNSWは隣接ノードの中から検索クエリに最も近いノードへ移動する仕組み上、グラフ構築時にフィルター条件が考慮されていない場合、フィルター対象外のデータも経由する必要があります。これにより以下の課題が発生します。

これは、HNSWが距離空間上の近傍探索に特化した構造を持つことに起因する制約です。pgvectorに限らず、専用のベクトルデータベースでも同じ課題があり、各ベクトルデータベースでは様々なアプローチで対応が行われています。

2.pgvectorでのフィルター付きベクトル検索

2.1.HNSWインデックスを使用するためのSQL

pgvectorでHNSWインデックスを使用するには、ORDER BYLIMITを組み合わせたクエリを書く必要があります。HNSWは「上位N件を効率的に取得する」ことに最適化されたアルゴリズムです。そのため、PostgreSQLのクエリプランナーは、LIMITがないとHNSWインデックスを選択せず、全件スキャンを選択します。

10万件のデータを使った実際の実行計画で確認してみましょう。なお、本記事での実行計画は、より本番環境に近い条件での検証を行うため、各SQLを複数回実行してキャッシュにデータが存在するホットキャッシュ状態での実行計画を記載しています。

1
2
3
SELECT id FROM items
ORDER BY embedding <=> (SELECT embedding FROM items WHERE id = 1)
LIMIT 10;

実行計画:

1
2
3
4
5
6
7
8
Limit  (cost=133.87..137.46 rows=10 width=12) (actual time=2.576..2.592 rows=10 loops=1)
  InitPlan 1 (returns $0)
    ->  Index Scan using items_pkey on items items_1  (cost=0.29..2.51 rows=1 width=1032) (actual time=0.013..0.014 rows=1 loops=1)
          Index Cond: (id = 1)
  ->  Index Scan using items_embedding_idx on items  (cost=131.36..36451.98 rows=101099 width=12) (actual time=2.575..2.590 rows=10 loops=1)
        Order By: (embedding <=> $0)
Planning Time: 0.252 ms
Execution Time: 2.626 ms

Index Scan using items_embedding_idx が使用されており、HNSWインデックスによる高速な近傍探索が行われています。10万件のデータから上位10件を取得するのに2.626 msで完了しました。

一方、同じように約10件程度を取得するクエリでも、LIMITを使わずにコサイン距離でフィルターすると、実行計画が大きく変わります。

1
2
3
4
SELECT id, embedding <=> (SELECT embedding FROM items WHERE id = 1) as distance
FROM items
WHERE embedding <=> (SELECT embedding FROM items WHERE id = 1) < 0.78
ORDER BY distance;

実行計画:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
Sort  (cost=18583.07..18667.32 rows=33700 width=12) (actual time=40.663..40.664 rows=12 loops=1)
  Sort Key: ((items.embedding <=> $0))
  Sort Method: quicksort  Memory: 25kB
  InitPlan 1 (returns $0)
    ->  Index Scan using items_pkey on items items_1  (cost=0.29..2.51 rows=1 width=1032) (actual time=0.003..0.003 rows=1 loops=1)
          Index Cond: (id = 1)
  InitPlan 2 (returns $1)
    ->  Index Scan using items_pkey on items items_2  (cost=0.29..2.51 rows=1 width=1032) (actual time=0.006..0.006 rows=1 loops=1)
          Index Cond: (id = 1)
  ->  Seq Scan on items  (cost=0.00..16043.74 rows=33700 width=12) (actual time=0.025..40.623 rows=12 loops=1)
        Filter: ((embedding <=> $1) < '0.78'::double precision)
        Rows Removed by Filter: 100988
Planning Time: 0.463 ms
Execution Time: 40.696 ms

Seq Scan(全件スキャン)が選択され、HNSWインデックスは使用されませんでした。10万件すべてに対してコサイン距離を計算し、WHERE句の条件に一致する12件を抽出した後、ソートしています。実行時間は40.696 msとなり、LIMIT句を使った場合の約15倍遅くなっています。

取得件数はどちらも10件程度とほぼ同じですが、LIMIT句の有無によってPostgreSQLのクエリプランナーの判断が変わり、パフォーマンスに大きな差が生まれました。

2.2.フィルター条件との組み合わせ

pgvectorはPostgreSQLの拡張機能であるため、標準的なSQLのWHERE句を使ってフィルターとベクトル検索を組み合わせることができます。

1
2
3
4
SELECT id FROM items
WHERE company_id = 2
ORDER BY embedding <=> (SELECT embedding FROM items WHERE company_id = 2 LIMIT 1)
LIMIT 10;

2.3.Post-filteringによる実行と課題

pgvectorでは、WHERE句によるフィルターはデータ取得後にフィルターを行うPost-filteringとして実行されます。まずHNSWインデックスでベクトル検索を実行し、取得した候補に対してフィルター条件を適用します。

この仕組みにより、前章で説明した2つの課題「課題1. Recall(再現率)の低下」と「課題2. レイテンシーの悪化」が同様に発生します。具体的には、データ上は存在するものの、HNSWインデックスが使用されるとLIMITで指定した数が取得されない問題や、他テナントのデータ増加によりレイテンシーの悪化が発生する問題が発生します。

3.社内版ビズリーチでの取り組み

「社内版ビズリーチ」でも、同様の「課題1. Recall(再現率)の低下」と「課題2. レイテンシーの悪化」に直面しました。ここでは、「社内版ビズリーチ」でこれらの課題にどのように対処したのか紹介します。

3.1.Recall(再現率)の低下への対処: Iterative Index Scans

Iterative Index Scansは、フィルター後の結果が不足している場合に、自動的にHNSWグラフの追加探索を繰り返し行う機能です。この機能がpgvector 0.8.0で導入され、Recallの低下問題が大幅に改善されました。

10万件(company_id=1)と1,000件(company_id=2)のデータがある状態で、company_id=2のデータを検索する例で確認してみましょう。

まず、Iterative Index Scansを無効にした場合です。

1
2
3
4
SELECT id FROM items
WHERE company_id = 2
ORDER BY embedding <=> (SELECT embedding FROM items WHERE company_id = 2 LIMIT 1)
LIMIT 10;

実行計画(hnsw.iterative_scan = off):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
Limit  (cost=162.46..881.70 rows=10 width=12) (actual time=1.607..1.689 rows=1 loops=1)
  InitPlan 1 (returns $0)
    ->  Limit  (cost=0.00..31.10 rows=1 width=1032) (actual time=0.021..0.022 rows=1 loops=1)
          ->  Seq Scan on items items_1  (cost=0.00..15706.74 rows=505 width=1032) (actual time=0.021..0.021 rows=1 loops=1)
                Filter: (company_id = 2)
  ->  Index Scan using items_embedding_idx on items  (cost=131.36..36453.24 rows=505 width=12) (actual time=1.606..1.687 rows=1 loops=1)
        Order By: (embedding <=> $0)
        Filter: (company_id = 2)
        Rows Removed by Filter: 39
Planning Time: 0.258 ms
Execution Time: 1.730 ms

rows=1となっており、LIMIT 10を指定したにもかかわらず1件しか取得できていません。Rows Removed by Filter: 39から、HNSWインデックスで取得した40件の候補のうち、company_id=2の条件を満たすものが1件しかなかったことがわかります。この40件という値は、HNSWの探索時に取得する候補数を制御するhnsw.ef_searchパラメータのデフォルト値です。

ef_searchを増やせば、より多くの候補をチェックしてRecallを向上できます。試しに1000に増やしてみましょう。

1
2
3
4
5
6
7
BEGIN;
SET LOCAL hnsw.ef_search = 1000;
SELECT id FROM items
WHERE company_id = 2
ORDER BY embedding <=> (SELECT embedding FROM items WHERE company_id = 2 LIMIT 1)
LIMIT 10;
COMMIT;

実行計画(ef_search = 1000):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
Limit  (cost=1809.37..2496.01 rows=10 width=12) (actual time=21.106..21.858 rows=8 loops=1)
  InitPlan 1 (returns $0)
    ->  Limit  (cost=0.00..31.10 rows=1 width=1032) (actual time=0.019..0.020 rows=1 loops=1)
          ->  Seq Scan on items items_1  (cost=0.00..15706.74 rows=505 width=1032) (actual time=0.019..0.019 rows=1 loops=1)
                Filter: (company_id = 2)
  ->  Index Scan using items_embedding_idx on items  (cost=1778.27..36453.24 rows=505 width=12) (actual time=21.105..21.855 rows=8 loops=1)
        Order By: (embedding <=> $0)
        Filter: (company_id = 2)
        Rows Removed by Filter: 989
Planning Time: 0.330 ms
Execution Time: 21.972 ms

rows=8となり、デフォルトの1件から改善されましたが、LIMIT 10には届きません。1000件近い候補をチェックしても8件しか取得できず、完全には解決していません。

さらに、ef_searchを増やすと、データ量が多い企業(company_id=1)の検索でもレイテンシーが悪化します。

1
2
3
4
5
-- ef_search = 40(デフォルト)の場合
SELECT id FROM items
WHERE company_id = 1
ORDER BY embedding <=> (SELECT embedding FROM items WHERE company_id = 1 LIMIT 1)
LIMIT 10;

実行計画(ef_search = 40、company_id = 1):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
Limit  (cost=162.46..881.70 rows=10 width=12) (actual time=2.353..2.367 rows=10 loops=1)
  InitPlan 1 (returns $0)
    ->  Limit  (cost=0.00..31.10 rows=1 width=1032) (actual time=0.008..0.009 rows=1 loops=1)
          ->  Seq Scan on items items_1  (cost=0.00..15706.74 rows=505 width=1032) (actual time=0.008..0.008 rows=1 loops=1)
                Filter: (company_id = 1)
  ->  Index Scan using items_embedding_idx on items  (cost=131.36..36453.24 rows=505 width=12) (actual time=2.352..2.366 rows=10 loops=1)
        Order By: (embedding <=> $0)
        Filter: (company_id = 1)
Planning Time: 0.228 ms
Execution Time: 2.402 ms

company_id=1は10万件のデータがあるため、デフォルトのef_search=40でも問題なく10件取得でき、2.402 msで完了します。

しかし、ef_search=1000に増やすと、同じクエリでもレイテンシーが悪化します。

1
2
3
4
5
6
7
BEGIN;
SET LOCAL hnsw.ef_search = 1000;
SELECT id FROM items
WHERE company_id = 1
ORDER BY embedding <=> (SELECT embedding FROM items WHERE company_id = 1 LIMIT 1)
LIMIT 10;
COMMIT;

実行計画(ef_search = 1000、company_id = 1):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
Limit  (cost=1809.37..2496.01 rows=10 width=12) (actual time=21.357..21.367 rows=10 loops=1)
  InitPlan 1 (returns $0)
    ->  Limit  (cost=0.00..31.10 rows=1 width=1032) (actual time=0.009..0.009 rows=1 loops=1)
          ->  Seq Scan on items items_1  (cost=0.00..15706.74 rows=505 width=1032) (actual time=0.009..0.009 rows=1 loops=1)
                Filter: (company_id = 1)
  ->  Index Scan using items_embedding_idx on items  (cost=1778.27..36453.24 rows=505 width=12) (actual time=21.355..21.365 rows=10 loops=1)
        Order By: (embedding <=> $0)
        Filter: (company_id = 1)
Planning Time: 0.275 ms
Execution Time: 21.488 ms

実行時間が21.488 msとなり、デフォルトの2.402 msに比べて約10倍遅くなりました。

ef_searchは、HNSWの探索時に取得する候補数パラメーターです。指定した数の候補を取得した後にフィルターが実行されます。そのため、ef_searchの値を大きくすればフィルター結果のRecallが向上する可能性は高まりますが、計算量の増加によりレイテンシーが低下するというトレードオフが存在します。このように、ef_searchの調整だけでは、Recallとレイテンシーのトレードオフを解決できません。この課題に対処するのが、Iterative Index Scansです。

Iterative Index Scansは、検索結果が少なかった場合に、十分な結果が見つかるまでインデックスのスキャンを自動的に繰り返す機能です。この機能を有効化するhnsw.iterative_scanパラメータは、strict_orderrelaxed_orderの指定ができます。本記事ではこれらのパラメータの比較は割愛し、strict_orderを指定した場合の例を記載します。

Iterative Index Scansを有効にしてみます。

1
2
3
4
5
6
7
BEGIN;
SET LOCAL hnsw.iterative_scan = strict_order;
SELECT id FROM items
WHERE company_id = 2
ORDER BY embedding <=> (SELECT embedding FROM items WHERE company_id = 2 LIMIT 1)
LIMIT 10;
COMMIT;

実行計画(hnsw.iterative_scan = strict_order):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
Limit  (cost=162.46..881.70 rows=10 width=12) (actual time=1.555..21.682 rows=10 loops=1)
  InitPlan 1 (returns $0)
    ->  Limit  (cost=0.00..31.10 rows=1 width=1032) (actual time=0.027..0.028 rows=1 loops=1)
          ->  Seq Scan on items items_1  (cost=0.00..15706.74 rows=505 width=1032) (actual time=0.027..0.027 rows=1 loops=1)
                Filter: (company_id = 2)
  ->  Index Scan using items_embedding_idx on items  (cost=131.36..36453.24 rows=505 width=12) (actual time=1.554..21.677 rows=10 loops=1)
        Order By: (embedding <=> $0)
        Filter: (company_id = 2)
        Rows Removed by Filter: 1387
Planning Time: 0.240 ms
Execution Time: 21.919 ms

rows=10となっており、指定した件数を取得できました。しかし、フィルターで除外された件数が1,387件に増加しており、追加探索を繰り返したことがわかります。実行時間も21.919 msとなり、無効時の1.730 msに比べて約13倍に増加しています。

Iterative Index Scansにより、フィルター条件を指定してもLIMITで指定した件数を取得できる可能性が大幅に高まり、pgvectorでのフィルター付きベクトル検索が実用的になりました。「社内版ビズリーチ」でもこの機能を導入し、検索結果の品質向上を実現しています。

ただし、この例のようにデータの偏りがある場合にパフォーマンスが劣化します。company_id=2のデータは1,000件しかないのに対し、company_id=1は10万件あるため、HNSWグラフの大部分がcompany_id=1で占められています。10件を取得するために1,387件ものフィルター対象外のデータを経由する必要があり、追加探索を繰り返すことで探索時間が大幅に増加しました。そのため、「課題2. レイテンシーの悪化」への対処も必要です。

3.2.レイテンシーの悪化への対処 パーティション分割と部分インデックス

特にマルチテナントSaaSでは、企業ごとのデータ量に大きな偏りがあります。従業員数1,000名の企業と10万名の企業では、データ量が100倍異なります。小規模企業のデータを検索する際、HNSWグラフの大部分を占める大規模企業のデータを経由する必要があり、レイテンシーが悪化します。

また、ベクトル検索は言葉の近しさで探すため、異なる文脈で使われる同じ言葉がヒットしてしまうことがあります。例えば、「社内での経歴」と「これから経験したいこと」では同じ言葉が使われることがありますが、文脈が異なります。検索前にどの文脈の話なのか特定してから検索することで、より適切な結果を得られます。「社内版ビズリーチ」では、このような種別ごとにデータを分けて管理しています。しかし、種別ごとのデータ量にも偏りが存在します。例えば、「社内での経歴」が10万件、「これから経験したいこと」が1,000件のような場合、小規模な種別のデータを検索する際に、大規模な種別のデータを経由することでレイテンシーが悪化します。

レイテンシーの悪化を防ぐため、PostgreSQLのパーティション分割と部分インデックスを使ってデータを物理的に分離しました。これにより、フィルター条件を満たすデータのみでHNSWグラフを構築し、フィルター対象外のデータを経由しない探索を実現します。

3.2.1.パーティション分割:企業ごとの分離

パーティション分割は、テーブルを複数の物理的なパーティションに分割する機能です。親テーブルに定義したインデックスが、各パーティションに自動的に作成されます。「社内版ビズリーチ」では、企業ごとのテナント単位でパーティション分割を行いました。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
-- パーティションテーブルの作成
CREATE TABLE partition_items (
    id INTEGER,
    embedding vector(256), -- 例として256次元を使用する
    created_at TIMESTAMP,
    company_id INTEGER NOT NULL
) PARTITION BY LIST (company_id);

-- 親テーブルにHNSWインデックスを作成
-- 新しいパーティションが作成されると自動的にインデックスも作成される
CREATE INDEX partition_items_embedding_idx
ON partition_items USING hnsw (embedding vector_cosine_ops);

-- 企業ごとのパーティション作成
CREATE TABLE partition_items_company_1 PARTITION OF partition_items FOR VALUES IN (1);
CREATE TABLE partition_items_company_2 PARTITION OF partition_items FOR VALUES IN (2);

パーティション分割により、各企業のデータは独立したHNSWグラフを持つようになります。クエリにWHERE company_id = 2が含まれている場合、PostgreSQLのパーティションプルーニングにより、該当する企業のパーティションのみがスキャン対象となります。

先ほどと同じ10万件(company_id=1)と1,000件(company_id=2)のデータを、パーティション分割したテーブルでも用意して効果を確認してみましょう。

1
2
3
4
SELECT id FROM partition_items
WHERE company_id = 2
ORDER BY embedding <=> (SELECT embedding FROM partition_items WHERE company_id = 2 LIMIT 1)
LIMIT 10;

実行計画(パーティション分割テーブル、company_id = 2):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
Limit  (cost=44.61..48.69 rows=10 width=12) (actual time=0.709..0.723 rows=10 loops=1)
  InitPlan 1 (returns $0)
    ->  Limit  (cost=0.00..0.30 rows=1 width=1032) (actual time=0.290..0.290 rows=1 loops=1)
          ->  Seq Scan on partition_items_company_2 partition_items_1  (cost=0.00..597.00 rows=2000 width=1032) (actual time=0.290..0.290 rows=1 loops=1)
                Filter: (company_id = 2)
  ->  Index Scan using partition_items_company_2_embedding_idx on partition_items_company_2 partition_items  (cost=44.32..859.00 rows=2000 width=12) (actual time=0.708..0.721 rows=10 loops=1)
        Order By: (embedding <=> $0)
        Filter: (company_id = 2)
Planning Time: 0.340 ms
Execution Time: 0.757 ms

partition_items_company_2パーティションのみがスキャン対象となり、company_id=2専用のHNSWインデックス(partition_items_company_2_embedding_idx)が使用されました。実行時間は0.757 msとなり、Iterative Index Scansを有効にした通常テーブルの21.919 msと比較して約30倍高速化されました。

また、Rows Removed by Filterの記載がないことから、フィルター対象外のデータを経由せずに直接目的のデータにアクセスできていることがわかります。これは、company_id=2のデータのみで構築されたHNSWグラフを使用しているためです。

パーティション分割を行うと、分割したパーティションのデータのみでHNSWグラフが構築されます。そのため、他テナントのデータ増減による検索への影響を大幅に抑えることができ、Recall、レイテンシーの改善が期待できます。トレードオフとして、以下の点がありますが、現状のデータ量では許容範囲内と判断しました。

3.2.2.部分インデックス:種別ごとの分離

種別ごとの分離には、部分インデックスを使用しました。 部分インデックスは、特定の条件を満たす行のみにインデックスを作成する機能です。

1
2
3
4
5
6
7
8
-- 種別ごとに部分インデックスを作成(例)
CREATE INDEX partition_items_type_partial_past_idx
ON partition_items_type_partial USING hnsw (embedding vector_cosine_ops)
WHERE section_type = 'past';

CREATE INDEX partition_items_type_partial_future_idx
ON partition_items_type_partial USING hnsw (embedding vector_cosine_ops)
WHERE section_type = 'future';

部分インデックスにより、特定の種別のデータのみを含むHNSWグラフが構築されます。例えば、クエリにWHERE section_type = 'past'が含まれている場合、PostgreSQLのクエリプランナー(実行計画を決定する仕組み)は該当する部分インデックスを選択します。

種別ごとに独立したHNSWグラフを持つため、データ量の偏りの影響を受けません。パーティション分割と組み合わせることで、企業×種別の粒度でデータを分離することができます。

部分インデックスの効果を、company_id=1のデータで確認してみましょう。company_id=1には、type=‘past’が100,000件、type=‘future’が10,000件という偏りがあります。type=‘future’のデータを検索する場合、部分インデックスがない状態では、HNSWグラフの大部分がtype=‘past’で占められているため、Recallが低下します。

まず、部分インデックスなしでIterative Index Scansを無効にした場合です。

1
2
3
4
5
6
7
8
BEGIN;
SET hnsw.iterative_scan = off;
SELECT id, embedding <=> (SELECT embedding FROM partition_items WHERE company_id = 1 LIMIT 1) AS distance
FROM partition_items -- 部分インデックスを作成していないテーブル
WHERE company_id = 1 AND type = 'future'
ORDER BY embedding <=> (SELECT embedding FROM partition_items WHERE company_id = 1 LIMIT 1)
LIMIT 10;
COMMIT;

実行計画(部分インデックスなし、iterative_scan = off):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
Limit  (cost=132.37..172.07 rows=10 width=12) (actual time=1.912..1.952 rows=2 loops=1)
  InitPlan 1 (returns $0)
    ->  Limit  (cost=0.00..0.16 rows=1 width=1032) (actual time=0.005..0.005 rows=1 loops=1)
          ->  Seq Scan on partition_items_company_1 partition_items_1  (cost=0.00..17090.00 rows=110000 width=1032) (actual time=0.005..0.005 rows=1 loops=1)
                Filter: (company_id = 1)
  ->  Index Scan using partition_items_company_1_embedding_idx on partition_items_company_1 partition_items  (cost=132.21..39959.48 rows=10032 width=12) (actual time=1.911..1.950 rows=2 loops=1)
        Order By: (embedding <=> $0)
        Filter: ((company_id = 1) AND ((type)::text = 'future'::text))
        Rows Removed by Filter: 38
Planning Time: 0.349 ms
Execution Time: 1.987 ms

rows=2となっており、LIMIT 10を指定したにもかかわらず2件しか取得できていません。type=‘past’のデータが大半を占めるHNSWグラフを探索するため、type=‘future’のデータに到達できず、Recallが低下しています。

次に、Iterative Index Scansを有効にした場合です。

1
2
3
4
5
6
7
8
BEGIN;
SET hnsw.iterative_scan = strict_order;
SELECT id, embedding <=> (SELECT embedding FROM partition_items WHERE company_id = 1 LIMIT 1) AS distance
FROM partition_items -- 部分インデックスを作成していないテーブル
WHERE company_id = 1 AND type = 'future'
ORDER BY embedding <=> (SELECT embedding FROM partition_items WHERE company_id = 1 LIMIT 1)
LIMIT 10;
COMMIT;

実行計画(部分インデックスなし、iterative_scan = strict_order):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
Limit  (cost=132.37..172.07 rows=10 width=12) (actual time=2.195..6.722 rows=10 loops=1)
  InitPlan 1 (returns $0)
    ->  Limit  (cost=0.00..0.16 rows=1 width=1032) (actual time=0.005..0.006 rows=1 loops=1)
          ->  Seq Scan on partition_items_company_1 partition_items_1  (cost=0.00..17090.00 rows=110000 width=1032) (actual time=0.005..0.005 rows=1 loops=1)
                Filter: (company_id = 1)
  ->  Index Scan using partition_items_company_1_embedding_idx on partition_items_company_1 partition_items  (cost=132.21..39959.48 rows=10032 width=12) (actual time=2.194..6.719 rows=10 loops=1)
        Order By: (embedding <=> $0)
        Filter: ((company_id = 1) AND ((type)::text = 'future'::text))
        Rows Removed by Filter: 118
Planning Time: 0.357 ms
Execution Time: 6.803 ms

rows=10となり、Recallの低下は解消されましたが、実行時間が6.803 msに増加し、118件ものフィルター対象外のデータを経由しています。

最後に、部分インデックスを使用した場合です。

1
2
3
4
5
6
7
8
BEGIN;
SET hnsw.iterative_scan = off;
SELECT id, embedding <=> (SELECT embedding FROM partition_items_type_partial WHERE company_id = 1 LIMIT 1) AS distance
FROM partition_items_type_partial -- 部分インデックスを作成したテーブル
WHERE company_id = 1 AND type = 'future'
ORDER BY embedding <=> (SELECT embedding FROM partition_items_type_partial WHERE company_id = 1 LIMIT 1)
LIMIT 10;
COMMIT;

実行計画(部分インデックスあり、iterative_scan = off):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
Limit  (cost=107.23..117.64 rows=10 width=12) (actual time=2.291..2.303 rows=10 loops=1)
  InitPlan 1 (returns $0)
    ->  Limit  (cost=0.00..0.16 rows=1 width=1032) (actual time=0.006..0.006 rows=1 loops=1)
          ->  Seq Scan on partition_items_type_partial_company_1 partition_items_type_partial_1  (cost=0.00..17090.00 rows=110000 width=1032) (actual time=0.006..0.006 rows=1 loops=1)
                Filter: (company_id = 1)
  ->  Index Scan using partition_items_type_partial_company_1_embedding_idx1 on partition_items_type_partial_company_1 partition_items_type_partial  (cost=107.08..10215.34 rows=9713 width=12) (actual time=2.290..2.301 rows=10 loops=1)
        Order By: (embedding <=> $0)
        Filter: (company_id = 1)
Planning Time: 0.384 ms
Execution Time: 2.337 ms

rows=10となり、Iterative Index Scansを無効にした状態でも10件取得できました。実行時間は2.337 msで、Iterative Index Scansを有効にした場合(6.803 ms)の約1/3に高速化されています。また、Rows Removed by Filterの記載がないことから、type=‘future’のデータのみで構築されたHNSWグラフを使用しており、フィルター対象外のデータを経由していないことがわかります。

このように、部分インデックスにより、データ量の偏りがある場合でも、Recallを維持しながら高速な検索を実現できます。

部分インデックスにもトレードオフとして、下記の点がありますが、現状では許容範囲内と判断しました。

3.2.3.パーティション分割と部分インデックスの使い分け

パーティション分割と部分インデックスには、それぞれメリット・デメリットがあります。

パーティション分割 部分インデックス
メリット ・親テーブルで設定したINDEXが各パーティションに継承される
・パーティション毎にテーブルのデータが分離される
・複数カラムを組み合わせた複雑なインデックス作成を作成しやすい
デメリット ・パーティションを跨ぐ検索は効率が落ちる
・パーティション数が膨大になるとパフォーマンスが悪化する
・フィルター条件が増えるごとにインデックス作成が必要
どのような場合に使用するべきか? フィルター対象の種別が多い場合 フィルター対象の種別が少ない場合

「社内版ビズリーチ」では、company_idtypeの両方でフィルターが必要でした。company_idは絞り込み値が多く(数100社以上)、typeは絞り込み値が少ない(5〜10種類程度)ため、それぞれの特性に合わせて組み合わせることにしました。

部分インデックスのみで対応しようとすると、company_idtypeの全組み合わせのHNSWインデックスを作成する必要があります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
-- 企業100社 × 種別5種類 = 500個のHNSWインデックスが必要
CREATE INDEX partition_items_type_partial_company_1_type_past_idx
ON partition_items_type_partial USING hnsw (embedding vector_cosine_ops)
WHERE company_id = 1 AND type = 'past';

CREATE INDEX partition_items_type_partial_company_1_type_future_idx
ON partition_items_type_partial USING hnsw (embedding vector_cosine_ops)
WHERE company_id = 1 AND type = 'future';
...
-- 新しい企業が追加されるたびに、種別の数だけインデックスを個別に作成

一方、パーティション分割と部分インデックスを組み合わせることで、インデックス数を大幅に削減できます。親テーブルにtypeごとの部分インデックスを定義すれば、新しいパーティション作成時に自動的にインデックスが作成されます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
-- 親テーブルに種別ごとの部分インデックスを定義(5個のみ)
CREATE INDEX partition_items_type_partial_past_idx
ON partition_items_type_partial USING hnsw (embedding vector_cosine_ops)
WHERE type = 'past';

CREATE INDEX partition_items_type_partial_future_idx
ON partition_items_type_partial USING hnsw (embedding vector_cosine_ops)
WHERE type = 'future';
...

-- 新しい企業のパーティションを作成すると、親テーブルのインデックス定義が自動的に適用される
CREATE TABLE partition_items_type_partial_company_new PARTITION OF partition_items_type_partial
    FOR VALUES IN (new_company_id);

企業が増えるたびにインデックスを個別に管理する必要がなく、メンテナンスコストを大幅に削減できます。「社内版ビズリーチ」では、企業を跨いでデータを検索するユースケースはないため、この組み合わせが最適と判断しました。

また、パーティション分割、部分インデックスそれぞれにトレードオフが存在します。そのため、データの偏りが少なく、Iterative Index Scansで十分なRecallとレイテンシーが実現できるフィルター条件では、個別のHNSWインデックスは構築しないよう意識しています。

4.まとめ

HNSWインデックスとフィルターはアルゴリズムの原理的な制約により相性が悪く、2つの課題「課題1. Recall(再現率)の低下」と「課題2. レイテンシーの悪化」が発生します。本記事では、これらの課題に対処する3つのアプローチを検証しました。

4.1.Recall(再現率)の低下への対処

pgvector 0.8.0のIterative Index Scansにより、フィルター条件を指定した場合でもRecallの低下を大幅に削減できるようになりました。

4.2.レイテンシーの悪化への対処

Iterative Index ScansはRecallの低下を解消しますが、データの偏りがある環境では追加探索によりレイテンシーが悪化します。本記事の検証では、Iterative Index Scansを有効にした場合、無効時と比較して約13倍(1.730 ms → 21.919 ms)遅くなりました。

この課題に対し、PostgreSQLのパーティション分割と部分インデックスを組み合わせることで、大幅な改善を実現しました。

4.3.本番環境におけるインパクト

本記事の検証では数msから数十msの改善でしたが、本番環境ではさらに重要な意味を持ちます。

実際のプロダクションデータは本記事の検証データ(10万件)よりもはるかに大規模です。データ量の増加により、フィルター対象外のデータを経由するコストが増加し、数msの差が数秒、数十秒の差になる可能性があります。

また、我々のセマンティック検索では1回の検索内で複数回のベクトル検索を組み合わせて実行しています。1回あたりは小さな改善でも、複数回の実行が積み重なることで、ユーザー体験に大きな影響を与えます。

Recallにおいても、特定のテナントではRecallが高い一方、Recallの低いテナントも存在することは許容し難いです。さらに、マルチテナントSaaSにおいては、大規模なテナントが増えると、共有されたHNSWグラフが大きくなります。その結果、小規模なテナントは何もデータが変わっていないにもかかわらず、Recallの低下やレイテンシーの悪化が発生する可能性が高いです。パーティション分割により各テナントが独立したHNSWグラフを持つことで、このような影響を防ぎ、安定したサービス提供が可能になります。

5.おわりに

「社内版ビズリーチ」では、AIを活用した新しい人事システムの開発に取り組んでいます。リリース後、多くの企業様で導入していただき、AIを活用したプロダクト、セマンティック検索に関するナレッジも溜まってきましたが、改善していきたい点もまだ多く残されています。セマンティック検索としても、今回フィルター付きベクトル検索の設計事例を紹介しましたが、我々が目指す検索としては、更に改善していきたい点が複数存在します。

AIを活用したプロダクト、セマンティック検索やベクトル検索、RAGの本番運用に興味がある方、技術的な課題に向き合いながらプロダクトを成長させたい方を募集しています。

少しでも興味をお持ちいただいた方は、ぜひカジュアル面談でお話ししましょう。

ビズリーチでは、新しい仲間を募集しています。

お客様にとって価値あるモノをつくり、働く環境の変革に挑戦する仲間を募集しています。
募集中のポジションやプロダクト組織の詳細は、ぜひキャリア採用サイトをご覧ください。

ビズリーチ採用サイト
石塚 崇寛
石塚 崇寛

HRMOS 事業部のソフトウェアエンジニア。2021年新卒入社。音楽フェスとライブに行くことが好きなことから、社内ではモッシュと呼ばれている。好きな音楽のジャンルはラウドロック。