Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Vector 検索

Vector 検索は、意味的類似性によってドキュメントを検索します。キーワードのマッチングではなく、ベクトル空間におけるクエリの意味とドキュメントエンベディングを比較します。

基本的な使い方

Builder API

#![allow(unused)]
fn main() {
use laurus::SearchRequestBuilder;
use laurus::vector::VectorSearchRequestBuilder;

let request = SearchRequestBuilder::new()
    .vector_query(
        VectorSearchRequestBuilder::new()
            .add_text("embedding", "systems programming language")
            .limit(10)
            .build()
    )
    .build();

let results = engine.search(request).await?;
}

add_text() メソッドはテキストをクエリペイロードとして格納します。検索時に、エンジンが設定されたエンベッダーを使用してテキストをエンベディングし、ベクトルインデックスを検索します。

Query DSL

#![allow(unused)]
fn main() {
use laurus::vector::VectorQueryParser;

let parser = VectorQueryParser::new(embedder.clone())
    .with_default_field("embedding");

let request = parser.parse(r#"embedding:"systems programming""#).await?;
}

VectorSearchRequestBuilder

Builder API により、きめ細かな制御が可能です。

#![allow(unused)]
fn main() {
use laurus::vector::VectorSearchRequestBuilder;
use laurus::vector::store::request::QueryVector;

let request = VectorSearchRequestBuilder::new()
    // Text query (will be embedded at search time)
    .add_text("text_vec", "machine learning")

    // Or use a pre-computed vector directly
    .add_vector("embedding", vec![0.1, 0.2, 0.3, /* ... */])

    // Search parameters
    .limit(20)

    .build();
}

メソッド

メソッド説明
add_text(field, text)特定のフィールドに対するテキストクエリを追加(検索時にエンベディング)
add_vector(field, vector)特定のフィールドに対する事前計算済みクエリベクトルを追加
add_vector_with_weight(field, vector, weight)明示的なウェイトを持つ事前計算済みベクトルを追加
add_payload(field, payload)エンベディング対象の汎用 DataValue ペイロードを追加
add_bytes(field, bytes, mime)バイナリペイロードを追加(例: マルチモーダル用の画像バイト)
field(name)検索を特定のフィールドに制限
fields(names)検索を複数のフィールドに制限
limit(n)結果の最大件数(デフォルト: 10)
score_mode(VectorScoreMode)スコア結合モード(WeightedSum、MaxSim、LateInteraction)
min_score(f32)最小スコア閾値(デフォルト: 0.0)
overfetch(f32)オーバーフェッチ係数(デフォルト: 2.0、#675)。各クエリは ceil(limit × overfetch) 件の候補を取得し、融合後に limit へ切り詰める。<= 1.0 で無効化
build()VectorSearchRequest を構築

マルチフィールド Vector 検索

単一のリクエストで複数のベクトルフィールドを横断して検索できます。

#![allow(unused)]
fn main() {
let request = VectorSearchRequestBuilder::new()
    .add_text("text_vec", "cute kitten")
    .add_text("image_vec", "fluffy cat")
    .build();
}

各クエリ句はベクトルを生成し、対応するフィールドに対して検索されます。結果は設定されたスコアモードで結合されます。

スコアモード

モード説明
WeightedSum(デフォルト)すべてのクエリ句にわたる(類似度 * ウェイト)の合計
MaxSimクエリ句間の最大類似度スコア
LateInteractionベクトルフィールドは 1 文書につき 1 本のベクトルを持つため、現在は WeightedSum と同じ。トークン単位(ColBERT 型)の late interaction には late interaction による再採点を使う

マルチベクトル検索の並列実行

複数のクエリベクトル(複数のフィールドや、スコアモードで結合する複数の クエリ埋め込みなど)を含むリクエストに対して、Laurus は rayon を使ってクエリごとの類似度検索を並列に実行します。

動作:

  • 並列化は VectorIndexSearcher::search_batch トレイトメソッドの デフォルト実装内に置かれています。ネイティブビルド(native feature が デフォルトで有効)では、queries.len() が searcher の parallel_threshold(デフォルト 4、 searcher 単位で override 可能) に達した時点で、HNSW / Flat / IVF のクエリごとの検索が rayon のグローバル スレッドプール上で並列実行されます。これを下回るとシリアルループのほうが 速くなります(rayon のディスパッチオーバーヘッド ~1-2 µs が、単一クエリの 50-200 µs を上回りやすいため)。
  • wasm32 ターゲットでは rayon が使用できないため、常にシリアルパスを 通ります。
  • 集約(スコアモードによるマージ)と最終ソートはクエリ並列フェーズの後に シリアル実行されます。スコアが同点の場合は doc_id の昇順でタイブレーク するため、rayon のワークスチール順序に依存しない決定的な結果になります。

外部 API(VectorStore::search、gRPC の Search、REST の POST /v1/search、各言語バインディング)は一切変更されません。並列実行は 完全に内部最適化で、laurus をアップグレードするだけで有効になります。 speedup はホストの利用可能コア数に応じてスケールアップします(4 物理コア / 8 スレッドの HT 有効ラップトップ CPU では、B = 64 クエリ時にスループットが およそ 2× 向上します。これは物理コア数と HT 共有による上限に近い値です)。

ブルートフォーススキャンの並列化

Flat と IVF インデックスは、グラフ探索ではなく総当たりの距離スキャンで候補を ランク付けします。1 つのクエリの候補数が内部しきい値(2048)に達すると、 そのスキャンは rayon のグローバルスレッドプールに分散されます。 これを下回るとシリアルループのほうが速くなります(rayon のジョブごとの ディスパッチ ~1-2 µs が小さなスキャンを上回るため)。

これは上記のクエリ単位の並列化とは直交します。バッチはクエリ間で並列化し、 さらに各大規模クエリは同じプール上で自身のスキャンを並列化します。ワーク スチールにより全体の並列度はプールサイズに制限されます(OS スレッドの oversubscription は発生しません)。距離カーネルは副作用を持たないため、結果は 任意の順序で収集された後にソートされ、出力は決定的に保たれます。wasm32 (rayon なし)では常にシリアルです。

speedup はホストの物理コア数に応じてスケールし、大きな Flat インデックスや 広い IVF n_probe で最大になります。しきい値未満のスキャンは影響を受けません。

IVF クラスタ選択

距離スキャンの前に、IVF クエリはまず どの クラスタをスキャンするかを選びます。 クエリを全セントロイドと照合し、最も近い n_probe 個を残します。プローブした クラスタはこの後にマージされ類似度で再ランク付けされるため、セントロイドの 相対順序は無関係です。したがって最近傍 n_probe 個は、K 個全体の完全ソート (O(K log K))ではなく O(K) の部分選択(select_nth_unstable_by)で取得 します。削減効果はクラスタ数 K が大きいほど増え、K = 2048 ではクエリ あたりの粗選択ステップを約 18% 短縮しました(Issue #668)。セントロイド スキャン自体はシリアルのままです。各セントロイドは単一の距離計算であり、 現実的なクラスタ数(K ≈ √N)では rayon へのディスパッチが節約分を上回る ためです。

ウェイト

DSL では ^ ブースト構文を使用するか、QueryVector の weight で各フィールドの寄与度を調整します。

text_vec:"cute kitten"^1.0 image_vec:"fluffy cat"^0.5

これは、テキストの類似度が画像の類似度の 2 倍の重みを持つことを意味します。

vector フィールドのスキーマレベルの base_weight(Issue #1084)も同様に 機能しますが、クエリ単位の上書きではなくフィールド単位のデフォルト値 である点が異なります。上記のクエリ重みに乗算されるため、base_weight と ^/weight は組み合わさります。どちらも vector 側自体のスコアリング にしか影響しないため、ハイブリッド検索の lexical-vs-vector のバランス を変えることはできません。FusionAlgorithm::RRF はランクのみを見て元の スコアを完全に無視しますし、FusionAlgorithm::WeightedSum は lexical_weight/vector_weight を適用する前に各側を min-max 正規化 するため、フィールド単位の一様なスカラー倍は打ち消されます。両方の 重み付け機構が実際に影響するのは、1 回のクエリで複数の vector フィール ドを対象にしたときの、フィールド間の相対的な優先度です。

このフィールド単位の加算が行われるのは、クエリが対象フィールドを明示 した場合のみです。フィールド未指定のクエリには参照すべき単一フィール ドが無いため base_weight を適用できず(Issue #1084)、同一次元を持つ 複数フィールドに文書がマッチした場合は、加算ではなくその文書の最も 良くマッチしたフィールドを採用します(Issue #1343。後述の フィールドルーティング を参照)。

フィールドルーティング

マルチフィールドスキーマでは、各 vector フィールドが独自の HNSW graph を 持ちます。デフォルトではクエリは全 vector フィールドを検索しますが、指定し たフィールドのみに限定すると、それ以外のフィールドの graph 探索を完全に 省略できます。

2 つのルーティング入力が、以下の優先順位で尊重されます。

  1. per-query — QueryVector.fields。DSL パーサがクエリ句の指定する フィールドからこれを設定するため、image_vec:"fluffy cat" は image_vec のみを検索します。
  2. request-level — VectorSearchParams.fields(セレクタのリスト):
    • Exact("image_vec") — フィールド名の完全一致。
    • Prefix("image_") — 指定 prefix で始まる全フィールドに一致 (インデックスのフィールド名から解決)。

どちらも未指定の場合は全フィールドを検索します(デフォルト)。フィールドに ルーティングされたクエリは、そのフィールドに vector を持たないドキュメントを 返しません。

検索対象の複数フィールドに vector を持つ文書も、返るのは 1 回だけです。 フィールド未指定のクエリの fan-out は、その文書の最も良くマッチした フィールドに畳み込まれるため(Issue #1343)、limit/top_k は (文書, フィールド) のペアではなく文書を数えます。これは、フィールドを 明示した複数フィールドクエリ(fields: [Exact("a"), Exact("b")])が 各フィールドのスコアを加算するのとは異なります。詳しくは前述の ウェイト を参照してください。

フィルター付き Vector 検索

Lexical フィルターを適用して Vector 検索の結果を絞り込むことができます。

#![allow(unused)]
fn main() {
use laurus::SearchRequestBuilder;
use laurus::lexical::TermQuery;
use laurus::vector::VectorSearchRequestBuilder;

// Vector search with a category filter
let request = SearchRequestBuilder::new()
    .vector_query(
        VectorSearchRequestBuilder::new()
            .add_text("embedding", "machine learning")
            .build()
    )
    .filter_query(Box::new(TermQuery::new("category", "tutorial")))
    .limit(10)
    .build();

let results = engine.search(request).await?;
}

フィルタークエリはまず Lexical インデックス上で実行されて許可されるドキュメント ID のセットを特定し、その後 Vector 検索がそれらの ID に制限されます。

filter-aware HNSW トラバーサル

HNSW フィールドでは、許可 ID セットは検索後に適用するだけでなく、graph 探索 そのものに渡されます。探索中、フロンティアはすべての近傍(非マッチの 近傍も含む)を経由して展開されるため、graph 上の非マッチ領域を横断して マッチに到達できますが、結果セットに入るのはマッチするドキュメントのみです。

これは選択的フィルターで重要になります。単純な post-filter は最近傍の固定 ef_search ウィンドウだけを見てマッチしたものを残すため、マッチが希少だと ウィンドウの外に完全に外れてしまい、到達可能なマッチが存在しても結果が実際 より大幅に少なく(時にはゼロに)なります。filter-aware トラバーサルは十分な マッチを集めるまで探索を続け、非常に選択的なフィルターでは内部の訪問上限 (ef_search の定数倍)で latency を抑えます。

フィルターなしの経路は不変です(フィルターがなければ従来どおりの挙動)。 Flat / IVF フィールドは許可セットをインラインで適用します。ドキュメント ID が セットに含まれない候補は距離カーネルの実行前にスキップされるため、選択的フィルター では post-filter が払う無駄な距離計算を回避できます。いずれにせよスキャンは網羅的 なので recall は変わりません。store 側の post-filter は冗長な安全網として後段で 引き続き実行されます。

許可セットが ef_search より小さい場合、HNSW フィールドは graph 探索を完全に スキップし、許可されたドキュメントを直接採点します。候補がこれほど少ないと graph で「探す」ものは無く、直接スキャンは許可ドキュメントだけを(graph 探索が 触れる数を超えずに)採点し、しかも exact です。そのため非常に選択的な フィルターでは、近似ではなく真の最近傍マッチが返ります。許可セットが大きい場合は 上記の filter-aware トラバーサルを引き続き使います。

deletion-aware HNSW トラバーサル

論理削除されたドキュメントはコンパクションまで HNSW graph に残るため (削除とコンパクション を参照)、トラバーサルは フィルター除外と同じ方法で削除ノードをスキップする必要があります。graph 探索は 単一の admission ルールを適用します。ノードが結果集合に入るのは、フィルターに マッチし(フィルターがある場合)かつ 削除されていない場合のみで、frontier は 連結性を保つために削除ノードも通過します。

これにより削除が蓄積しても recall が正しく保たれます。削除ノードを result heap に 入れてしまうと、固定の ef_search スロットを占有して生存ネイバーを押し出し、 最悪の場合は ef_search ウィンドウが削除ドキュメントだけで埋まって何も返らなく なります。トラバーサル中にスキップすれば同じスロットが生存結果で埋まるため、 最近傍のドキュメントを削除しても 10 件のページは満杯のままです。上記の小さな 許可セットの exact スキャンや Flat / IVF のインライン経路も同じ削除チェックを 適用します。

高速経路は維持されます。フィルターも削除も無い検索では、トラバーサルは従来の ループをそのまま実行し、ネイバーごとの admission 用ブックキーピングのコストを 一切払いません。

許可セットの表現(Allow-Set Representation)

許可セットは形状に応じて型が選ばれます。密なフィルターには Roaring ビットマップ、 疎なフィルターにはハッシュセットを使います。フィルター付きハイブリッド検索では、 lexical 側がマッチ集合をビットマップとして既に構築している (lexical の フィルタ結果キャッシュ 参照)ため、そのビットマップを vector 側にそのまま渡します。これにより集合は クエリ全体で一度だけ実体化され、両側で再構築されません。これは内部最適化であり、 公開フィルター API は不変です。

数値範囲によるフィルター

#![allow(unused)]
fn main() {
use laurus::lexical::NumericRangeQuery;
use laurus::lexical::core::field::NumericType;

let request = SearchRequestBuilder::new()
    .vector_query(
        VectorSearchRequestBuilder::new()
            .add_text("embedding", "type systems")
            .build()
    )
    .filter_query(Box::new(NumericRangeQuery::new(
        "year", NumericType::Integer,
        Some(2020.0), Some(2024.0), true, true
    )))
    .limit(10)
    .build();
}

Late Interaction による再採点(Rescore)

late interaction による再採点(Issue #1345)は、lexical・vector・ハイブリッドの どの検索でも、上位の候補を ColBERT 型の late interaction で並べ替えます。 各文書は MultiVector フィールド にトークンごとのベクトルを持ち、クエリも同じくトークンベクトルの集合です。 文書のスコアは、クエリの各ベクトルについて文書のベクトルの中で最もよく合う ものとの類似度を求め、それを合計した値(MaxSim)です。

score(q, d) = Σ_i max_j (q_i · d_j)

トークン単位で照合するため、文書全体を 1 本の埋め込みにすると平均化されて 失われる細部を拾えます。一方で 1 段目の検索が速度を保ちます。再採点するのは 上位の候補だけなので、MultiVector フィールドに ANN 索引は要りません。

クエリ ─→ 1 段目: lexical / vector / ハイブリッド(通常の検索)
              │ 上位 window_size 件(既定 100)
              ▼
          再採点: MultiVector フィールドに対する MaxSim
              ▼
          offset / limit → 文書を取得

MultiVector フィールドにトークン単位の Embedder(candle_colbert など。CandleColbertEmbedder を参照)が あれば、文書をテキストのまま取り込め、クエリもテキストで渡せます。

#![allow(unused)]
fn main() {
use laurus::{RescoreOptions, SearchRequestBuilder};

let request = SearchRequestBuilder::new()
    .query_dsl("body:lifetimes body_vec:\"how do lifetimes work\"")
    .rescore(
        RescoreOptions::late_interaction_text("body_colbert", "how do lifetimes work")
            .window_size(100),
    )
    .limit(10)
    .build();
let results = engine.search(request).await?;
}

Embedder がない場合は、文書のトークンベクトルを作ったのと同じモデルで、 クエリのトークンベクトルを計算して渡します。

#![allow(unused)]
fn main() {
use laurus::RescoreOptions;
use laurus::vector::Vector;

let query_tokens: Vec<Vector> = colbert_query_vectors("how do lifetimes work");
let rescore = RescoreOptions::late_interaction("body_colbert", query_tokens);
}

再採点は、Rust API のほか、gRPC、 HTTP gateway、 MCP の search ツール、 laurus search、 Python・ Ruby・ PHP・ Node.js・ WASM の各バインディングでも使えます。

オプション

フィールド型デフォルト説明
window_sizeusize100再採点する 1 段目の上位候補の件数。1..=10,000
rescorerRescorer—Rescorer::LateInteraction { field, query }: 対象の MultiVector フィールドとクエリ。クエリはトークンベクトル(LateInteractionQuery::Vectors)かテキスト(LateInteractionQuery::Text)

テキストのクエリは、1 段目の検索の前に、フィールドのトークン単位の Embedder が クエリとして埋め込みます。 EngineBuilder::embedding_cache_capacity を設定していれば、同じクエリ(次のページなど)はキャッシュから返ります。

クエリのベクトルは 1〜1,024 本で、どれもフィールドの次元と一致し、有限値で なければなりません。オプションが不正な場合、対象が MultiVector フィールドで ない場合、テキストのクエリが空かフィールドにトークン単位の Embedder がない場合、 フィールドによるソート(sort_by に SortField::Field)を同時に指定した場合は、 検索を始める前に LaurusError::InvalidArgument で失敗します。フィールドによる ソートはスコアで並べないため、再採点する対象がないからです。

並び順とスコア

  1. window(1 段目の上位 window_size 件)を MaxSim の降順に並べます。 同点は内部の文書 ID の順にするため、並び順は決定的です。
  2. フィールドにトークンベクトルを持たない window 内の候補を、1 段目の順で 続けます。
  3. window の外の候補を、元の順のまま最後に続けます。

再採点した結果の score は MaxSim の値になり、それ以外の結果は 1 段目の スコアのままです。2 種類のスコアは比較できないため、グループはスコアでは なく位置で並べます。

1 段目は max(window_size, offset + limit) 件の候補を取得するため、どの ページも同じ再採点済みの順位から切り出されます。ページを 1 つずつ取得して つなげると、1 回で大きなページを取得したときと同じ順位になります。ページが window の外に及ぶ場合も同じです。window を広げると 1 段目で下位だった文書を 引き上げられますが、コストは window に比例して増えます。1 段目で取得されな かった文書は、再採点でも拾えません。

類似度

クエリのベクトルと文書のベクトルの類似度は内積です。 DistanceMetric::Cosine(デフォルト)のフィールドでは、文書のベクトルを 書き込み時に、クエリのベクトルを検索時に L2 正規化するため、各ペアはコサイン 類似度になり、スコアはクエリのベクトルの本数を超えません。 DistanceMetric::DotProduct ではベクトルをそのまま使います。

フィールドに保存されたベクトルは圧縮されている場合もあります。 storage オプション は、既定の厳密な F32 の代わりに F16 や Int8 でトークンベクトルを 符号化でき、インデックスを小さくする代わりにスコアの精度をわずかに 犠牲にします — F16 は要素あたり約 2⁻¹¹ の相対誤差、Int8 の誤差は ベクトルごとのスケール(max(abs(vector)) / 127)で上界が決まります。

コスト

再採点の計算量は window_size × クエリのベクトル数 × 文書のベクトル数 回の 内積です。ネイティブビルドでは候補を rayon のグローバル スレッドプールで並列に採点し、wasm32 では 1 件ずつ採点します。目安として、 128 次元のベクトルを 300 本持つ候補 100 件を 32 本のクエリで再採点すると (約 1 億 2,300 万回の積和)、Apple M4 で 1 回の検索あたり約 2 ms、1 スレッド では約 7 ms でした。

距離メトリクス(Distance Metrics)

距離メトリクスはスキーマでフィールドごとに設定されます(Vector インデキシング を参照)。

メトリクス説明小さい値 = より類似
Cosine1 - コサイン類似度はい
EuclideanL2 距離はい
ManhattanL1 距離はい
DotProduct負の内積はい
Angular角度距離はい

コード例: 完全な Vector 検索

use std::sync::Arc;
use laurus::{Document, Engine, Schema, SearchRequestBuilder, PerFieldEmbedder};
use laurus::lexical::TextOption;
use laurus::vector::HnswOption;
use laurus::vector::VectorSearchRequestBuilder;
use laurus::storage::memory::MemoryStorage;

#[tokio::main]
async fn main() -> laurus::Result<()> {
    let storage = Arc::new(MemoryStorage::new(Default::default()));

    let schema = Schema::builder()
        .add_text_field("title", TextOption::default())
        .add_hnsw_field("text_vec", HnswOption {
            dimension: 384,
            ..Default::default()
        })
        .build();

    // Set up per-field embedder
    let embedder = Arc::new(my_embedder);
    let pfe = PerFieldEmbedder::new(embedder.clone());
    pfe.add_embedder("text_vec", embedder.clone());

    let engine = Engine::builder(storage, schema)
        .embedder(Arc::new(pfe))
        .build()
        .await?;

    // Index documents (text in vector field is auto-embedded)
    engine.add_document("doc-1", Document::builder()
        .add_text("title", "Rust Programming")
        .add_text("text_vec", "Rust is a systems programming language.")
        .build()
    ).await?;
    engine.commit().await?;

    // Search by semantic similarity
    let results = engine.search(
        SearchRequestBuilder::new()
            .vector_query(
                VectorSearchRequestBuilder::new()
                    .add_text("text_vec", "systems language")
                    .build()
            )
            .limit(5)
            .build()
    ).await?;

    for r in &results {
        println!("{}: score={:.4}", r.id, r.score);
    }

    Ok(())
}

次のステップ