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

Laurus

Rust 向けの高速で多機能なハイブリッド検索ライブラリ。

Laurus は、Lexical 検索(転置インデックス(Inverted Index)によるキーワードマッチング)と Vector 検索(エンベディングによるセマンティック類似度検索)を単一のエンジンに統合した、純 Rust ライブラリです。外部サーバーを必要とせず、Rust アプリケーションに直接組み込んで使用できます。

主な機能

機能説明
Lexical 検索BM25 スコアリングを備えた転置インデックスによる全文検索
Vector 検索Flat、HNSW、IVF インデックスを用いた近似最近傍探索(ANN)
ハイブリッド検索Lexical と Vector の検索結果を融合アルゴリズム(RRF、WeightedSum)で統合
テキスト解析プラガブルなアナライザパイプライン — トークナイザ、フィルタ、ステマー、シノニム
エンベディングCandle(ローカル BERT/CLIP)、OpenAI API、カスタムエンベッダをビルトインサポート
ストレージプラガブルなバックエンド — インメモリ、ファイルベース、メモリマップド
Query DSLLexical、Vector、ハイブリッド検索のための人間が読みやすいクエリ構文
純 Rustコアに C/C++ 依存なし — 安全でポータブル、ビルドも容易

仕組み

graph LR
    subgraph Your Application
        D["Document"]
        Q["Query"]
    end

    subgraph Laurus Engine
        SCH["Schema"]
        AN["Analyzer"]
        EM["Embedder"]
        LI["Lexical Index\n(Inverted Index)"]
        VI["Vector Index\n(HNSW / Flat / IVF)"]
        FU["Fusion\n(RRF / WeightedSum)"]
    end

    D --> SCH
    SCH --> AN --> LI
    SCH --> EM --> VI
    Q --> LI --> FU
    Q --> VI --> FU
    FU --> R["Ranked Results"]
  1. スキーマを定義する — フィールドとその型(text、integer、vector など)を宣言します
  2. Engine を構築する — テキスト用のアナライザと Vector 用のエンベッダを接続します
  3. ドキュメントをインデックスする — Engine が各フィールドを適切なインデックスに自動的に振り分けます
  4. 検索する — Lexical、Vector、またはハイブリッドクエリを実行し、ランク付けされた結果を取得します

ドキュメントマップ

セクション学べること
はじめにLaurus をインストールし、数分で最初の検索を実行する
アーキテクチャEngine のコンポーネントとデータフローを理解する
コアコンセプトスキーマ、テキスト解析、エンベディング、ストレージ
インデクシング転置インデックスと Vector インデックスの内部動作
検索クエリの種類、Vector 検索、ハイブリッド融合
Query DSLすべての検索タイプに対応した人間が読みやすいクエリ構文
ライブラリ (laurus)Engine の内部構造、スコアリング、ファセット、拡張性
CLI (laurus-cli)インデックス管理と検索のためのコマンドラインツール
サーバー (laurus-server)HTTP Gateway を備えた gRPC サーバー
開発ガイドLaurus のビルド、テスト、コントリビュート

クイックサンプル

use std::sync::Arc;
use laurus::{Document, Engine, Schema, SearchRequestBuilder, Result};
use laurus::lexical::{TextOption, TermQuery};
use laurus::storage::memory::MemoryStorage;

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

    // 2. Schema
    let schema = Schema::builder()
        .add_text_field("title", TextOption::default())
        .add_text_field("body", TextOption::default())
        .add_default_field("body")
        .build();

    // 3. Engine
    let engine = Engine::builder(storage, schema).build().await?;

    // 4. Index a document
    let doc = Document::builder()
        .add_text("title", "Hello Laurus")
        .add_text("body", "A fast search library for Rust")
        .build();
    engine.add_document("doc-1", doc).await?;
    engine.commit().await?;

    // 5. Search
    let request = SearchRequestBuilder::new()
        .lexical_query(
            laurus::lexical::search::searcher::LexicalSearchQuery::Obj(
                Box::new(TermQuery::new("body", "rust"))
            )
        )
        .limit(10)
        .build();
    let results = engine.search(request).await?;

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

ライセンス

Laurus は MIT ライセンス のもとで提供されています。

アーキテクチャ

このページでは、Laurus の内部構造について説明します。アーキテクチャを理解することで、スキーマ設計、Analyzer の選択、検索戦略についてより適切な判断ができるようになります。

プロジェクト構成

Laurus は Cargo workspace として 9 つのクレート(コアライブラリ 1、自製バイナリ 3、言語バインディング 5)で構成されています。

graph TB
    CLI["laurus-cli\n(Binary)\nCLI + REPL + serve + mcp"]
    SRV["laurus-server\n(Library + Binary)\ngRPC + HTTP Gateway"]
    MCP["laurus-mcp\n(Binary)\nMCP stdio server"]
    PY["laurus-python\n(cdylib)\nPyO3 / Maturin"]
    NJS["laurus-nodejs\n(cdylib)\nNAPI-RS"]
    WASM["laurus-wasm\n(WebAssembly)\nwasm-bindgen"]
    RB["laurus-ruby\n(cdylib)\nmagnus + rb-sys"]
    PHP["laurus-php\n(PHP extension)\next-php-rs"]
    LIB["laurus\n(Library)\nコア検索エンジン"]

    CLI --> LIB
    CLI --> SRV
    CLI --> MCP
    SRV --> LIB
    MCP --> SRV
    MCP --> LIB
    PY --> LIB
    NJS --> LIB
    WASM --> LIB
    RB --> LIB
    PHP --> LIB
クレート種類説明
laurusLibraryコア検索エンジン – Lexical 検索、Vector 検索、ハイブリッド検索
laurus-cliBinaryインデックス管理と検索のためのコマンドラインインターフェース
laurus-serverLibrary + Binaryオプションの HTTP/JSON ゲートウェイ付き gRPC サーバー
laurus-mcpBinarylaurus-server へプロキシする MCP(Model Context Protocol)stdio サーバー
laurus-pythoncdylibPyO3 / Maturin による Python バインディング
laurus-nodejscdylibNAPI-RS による Node.js バインディング
laurus-wasmWebAssemblywasm-bindgen によるブラウザ・エッジバインディング
laurus-rubycdylibmagnus / rb-sys による Ruby バインディング
laurus-phpPHP 拡張ext-php-rs による PHP バインディング(ワークスペースからは除外)

各クレートの詳細については以下を参照してください。

全体概要

Laurus は単一の Engine を中心に構成されており、4 つの内部コンポーネントを統括します。

graph TB
    subgraph Engine
        SCH["Schema"]
        LS["LexicalStore\n(Inverted Index)"]
        VS["VectorStore\n(HNSW / Flat / IVF)"]
        DL["DocumentLog\n(WAL + Document Storage)"]
    end

    Storage["Storage (trait)\nMemory / File / File+Mmap"]

    LS --- Storage
    VS --- Storage
    DL --- Storage
コンポーネント役割
Schemaフィールドとその型を宣言し、各フィールドのルーティング先を決定する
LexicalStoreキーワード検索のための転置インデックス(Inverted Index)(BM25 スコアリング)
VectorStore類似度検索のためのベクトルインデックス(Flat、HNSW、または IVF)
DocumentLog耐久性のための WAL(Write-Ahead Log)+ ドキュメントストレージ

3 つのストアはすべて単一の Storage バックエンドを共有し、キープレフィックス(lexical/vector/documents/)によって分離されています。

Engine のライフサイクル

Engine の構築

EngineBuilder が各パーツから Engine を組み立てます。

#![allow(unused)]
fn main() {
let engine = Engine::builder(storage, schema)
    .analyzer(analyzer)      // optional: for text fields
    .embedder(embedder)      // optional: for vector fields
    .build()
    .await?;
}
sequenceDiagram
    participant User
    participant EngineBuilder
    participant Engine

    User->>EngineBuilder: new(storage, schema)
    User->>EngineBuilder: .analyzer(analyzer)
    User->>EngineBuilder: .embedder(embedder)
    User->>EngineBuilder: .build().await
    EngineBuilder->>EngineBuilder: split_schema()
    Note over EngineBuilder: Separate fields into\nLexicalIndexConfig\n+ VectorIndexConfig
    EngineBuilder->>Engine: Create LexicalStore
    EngineBuilder->>Engine: Create VectorStore
    EngineBuilder->>Engine: Create DocumentLog
    EngineBuilder->>Engine: Recover from WAL
    EngineBuilder-->>User: Engine ready

build() 時に Engine は以下の処理を行います。

  1. スキーマの分割 — Lexical フィールドは LexicalIndexConfig へ、Vector フィールドは VectorIndexConfig へ振り分けられる
  2. プレフィックス付きストレージの作成 — 各コンポーネントが分離された名前空間を取得する(lexical/vector/documents/
  3. ストアの初期化LexicalStoreVectorStore がそれぞれの設定で構築される
  4. WAL からの復旧 — 前回のセッションからの未コミット操作を再生する

スキーマの分割

Schema には Lexical フィールドと Vector フィールドの両方が含まれています。ビルド時に split_schema() がこれらを分離します。

graph LR
    S["Schema\ntitle: Text\nbody: Text\ncategory: Text\npage: Integer\ncontent_vec: HNSW"]

    S --> LC["LexicalIndexConfig\ntitle: TextOption\nbody: TextOption\ncategory: TextOption\npage: IntegerOption\n_id: KeywordAnalyzer"]

    S --> VC["VectorIndexConfig\ncontent_vec: HnswOption\n(dim=384, m=16, ef=200)"]

主なポイント:

  • 予約フィールド _id は常に KeywordAnalyzer(完全一致)で Lexical 設定に追加される
  • PerFieldAnalyzer はフィールドごとの Analyzer 設定をラップする。単純な StandardAnalyzer を渡した場合、すべてのテキストフィールドのデフォルトとなる
  • PerFieldEmbedder も Vector フィールドに対して同様に動作する

インデクシングのデータフロー

engine.add_document(id, doc) を呼び出した場合の処理:

sequenceDiagram
    participant User
    participant Engine
    participant WAL as DocumentLog (WAL)
    participant Lexical as LexicalStore
    participant Vector as VectorStore

    User->>Engine: add_document("doc-1", doc)
    Engine->>WAL: Append to WAL
    Engine->>Engine: Assign internal ID (u64)

    loop For each field in document
        alt Lexical field (text, integer, etc.)
            Engine->>Lexical: Analyze + index field
        else Vector field
            Engine->>Vector: Embed + index field
        end
    end

    Note over Engine: Document is buffered\nbut NOT yet searchable

    User->>Engine: commit()
    Engine->>Lexical: Flush segments to storage
    Engine->>Vector: Flush segments to storage
    Engine->>WAL: Truncate WAL
    Note over Engine: Documents are\nnow searchable

主なポイント:

  • WAL 優先: すべての書き込みは、インメモリ構造を変更する前にログに記録される
  • デュアルインデクシング: 各フィールドはスキーマに基づいて Lexical ストアまたは Vector ストアのいずれかにルーティングされる
  • コミットが必要: ドキュメントは commit() の後にのみ検索可能になる

検索のデータフロー

engine.search(request) を呼び出した場合の処理:

sequenceDiagram
    participant User
    participant Engine
    participant Lexical as LexicalStore
    participant Vector as VectorStore
    participant Fusion

    User->>Engine: search(request)

    opt Filter query present
        Engine->>Lexical: Execute filter query
        Lexical-->>Engine: Allowed document IDs
    end

    par Lexical search
        Engine->>Lexical: Execute lexical query
        Lexical-->>Engine: Ranked hits (BM25)
    and Vector search
        Engine->>Vector: Execute vector query
        Vector-->>Engine: Ranked hits (similarity)
    end

    alt Both lexical and vector results
        Engine->>Fusion: Fuse results (RRF or WeightedSum)
        Fusion-->>Engine: Merged ranked list
    end

    Engine->>Engine: Apply offset + limit
    Engine-->>User: Vec of SearchResult

検索パイプラインは 3 つのステージで構成されています。

  1. フィルタ(オプション) — Lexical インデックスに対してフィルタクエリを実行し、許可されたドキュメント ID のセットを取得する
  2. 検索 — Lexical クエリと Vector クエリを並列に実行する
  3. フュージョン — 両方のクエリタイプが存在する場合、RRF(デフォルト、k=60)または WeightedSum を使用して結果をマージする

ストレージアーキテクチャ

すべてのコンポーネントは単一の Storage trait 実装を共有しますが、キープレフィックスを使用してデータを分離します。

graph TB
    Engine --> PS1["PrefixedStorage\nprefix: 'lexical/'"]
    Engine --> PS2["PrefixedStorage\nprefix: 'vector/'"]
    Engine --> PS3["PrefixedStorage\nprefix: 'documents/'"]

    PS1 --> S["Storage Backend\n(Memory / File / File+Mmap)"]
    PS2 --> S
    PS3 --> S
バックエンド説明最適な用途
MemoryStorageすべてのデータをメモリ上に保持テスト、小規模データセット、一時的な利用
FileStorage標準的なファイル I/O一般的な本番利用
FileStorage (mmap)メモリマップドファイル(use_mmap = true大規模データセット、読み取り負荷の高いワークロード

フィールドごとのディスパッチ

PerFieldAnalyzer が提供されている場合、Engine はフィールド固有の Analyzer に解析処理をディスパッチします。同様のパターンが PerFieldEmbedder にも適用されます。

graph LR
    PFA["PerFieldAnalyzer"]
    PFA -->|"title"| KA["KeywordAnalyzer"]
    PFA -->|"body"| SA["StandardAnalyzer"]
    PFA -->|"description"| JA["JapaneseAnalyzer"]
    PFA -->|"_id"| KA2["KeywordAnalyzer\n(always)"]
    PFA -->|other fields| DEF["Default Analyzer\n(StandardAnalyzer)"]

これにより、同一 Engine 内で異なるフィールドに異なる解析戦略を使用できます。

まとめ

項目詳細
コア構造体Engine — すべての操作を統括する
ビルダーEngineBuilder — Storage + Schema + Analyzer + Embedder から Engine を組み立てる
スキーマ分割Lexical フィールド → LexicalIndexConfig、Vector フィールド → VectorIndexConfig
書き込みパスWAL → インメモリバッファ → commit() → 永続ストレージ
読み取りパスクエリ → 並列 Lexical/Vector 検索 → フュージョン → ランク付き結果
ストレージ分離PrefixedStorage による lexical/vector/documents/ プレフィックス
フィールドごとのディスパッチPerFieldAnalyzerPerFieldEmbedder がフィールド固有の実装にルーティングする

次のステップ

はじめに

Laurus へようこそ! このセクションでは、ライブラリのインストールから最初の検索の実行までをガイドします。

作成するもの

このガイドを終えると、以下の機能を持つ検索エンジンが動作するようになります:

  • テキストドキュメントのインデックス
  • キーワード(Lexical)検索の実行
  • セマンティック(Vector)検索の実行
  • ハイブリッド検索による両者の統合

前提条件

  • Rust 1.85 以降(edition 2024)
  • Cargo(Rust に同梱)
  • Tokio ランタイム(Laurus は非同期 API を使用します)

ステップ

  1. インストール — Laurus をプロジェクトに追加し、Feature Flags を選択する
  2. クイックスタート — 5 つのステップで完全な検索エンジンを構築する

ワークフロー概要

Laurus を使った検索アプリケーションの構築は、一貫したパターンに従います:

graph LR
    A["1. Create\nStorage"] --> B["2. Define\nSchema"]
    B --> C["3. Build\nEngine"]
    C --> D["4. Index\nDocuments"]
    D --> E["5. Search"]
ステップ内容
Storage の作成データの保存先を選択する — インメモリ、ディスク、メモリマップド
Schema の定義フィールドとその型(text、integer、vector など)を宣言する
Engine の構築アナライザ(テキスト用)とエンベッダ(Vector 用)を接続する
ドキュメントのインデックスドキュメントを追加すると、Engine がフィールドを適切なインデックスに振り分ける
検索Lexical、Vector、またはハイブリッドクエリを実行し、ランク付けされた結果を取得する

インストール

プロジェクトへの Laurus の追加

Cargo.tomllaurustokio(非同期ランタイム)を追加します:

[dependencies]
laurus = "0.9"
tokio = { version = "1", features = ["full"] }

Feature Flags

Laurus はデフォルトで最小限の機能セットで提供されます。必要に応じて追加の機能を有効にしてください:

Feature説明ユースケース
(default)コアライブラリ(Lexical 検索、ストレージ、アナライザ — エンベディングなし)キーワード検索のみ
embeddings-candleHugging Face Candle によるローカル BERT エンベディング外部 API 不要の Vector 検索
embeddings-openaiOpenAI API エンベディング(text-embedding-3-small 等)クラウドベースの Vector 検索
embeddings-multimodalCandle による CLIP エンベディング(テキスト + 画像)マルチモーダル(テキスト→画像)検索
embeddings-all上記すべてのエンベディング機能全エンベディング対応

Lexical 検索のみ(エンベディング不要):

[dependencies]
laurus = "0.9"

ローカルモデルによる Vector 検索(API キー不要):

[dependencies]
laurus = { version = "0.9", features = ["embeddings-candle"] }

OpenAI による Vector 検索:

[dependencies]
laurus = { version = "0.9", features = ["embeddings-openai"] }

すべての機能:

[dependencies]
laurus = { version = "0.9", features = ["embeddings-all"] }

インストールの確認

Laurus が正しくコンパイルされることを確認するために、最小限のプログラムを作成します:

use laurus::Result;

#[tokio::main]
async fn main() -> Result<()> {
    println!("Laurus version: {}", laurus::VERSION);
    Ok(())
}
cargo run

バージョンが表示されれば、クイックスタートに進む準備が整っています。

クイックスタート

このチュートリアルでは、5 つのステップで完全な検索エンジンを構築する方法を説明します。最後には、ドキュメントをインデックスしてキーワード検索ができるようになります。

ステップ 1 — Storage の作成

Storage は Laurus がインデックスデータを保存する場所を決定します。開発やテストには MemoryStorage を使用します:

#![allow(unused)]
fn main() {
use std::sync::Arc;
use laurus::storage::memory::MemoryStorage;
use laurus::Storage;

let storage: Arc<dyn Storage> = Arc::new(
    MemoryStorage::new(Default::default())
);
}

ヒント: 本番環境では FileStorage(オプションで use_mmap によるメモリマップド I/O)の使用を検討してください。詳細はストレージを参照してください。

ステップ 2 — Schema の定義

Schema はドキュメント内のフィールドと、各フィールドのインデックス方法を宣言します:

#![allow(unused)]
fn main() {
use laurus::Schema;
use laurus::lexical::TextOption;

let schema = Schema::builder()
    .add_text_field("title", TextOption::default())
    .add_text_field("body", TextOption::default())
    .add_default_field("body")  // used when no field is specified in a query
    .build();
}

各フィールドには型があります。主な型は以下の通りです:

メソッドフィールド型値の例
add_text_fieldText(全文検索可能)"Hello world"
add_integer_field64 ビット整数42
add_float_field64 ビット浮動小数点数3.14
add_boolean_fieldブール値true / false
add_datetime_fieldUTC 日時2024-01-15T10:30:00Z
add_hnsw_fieldVector(HNSW インデックス)[0.1, 0.2, ...]
add_flat_fieldVector(Flat インデックス)[0.1, 0.2, ...]

全一覧はスキーマとフィールドを参照してください。

ステップ 3 — Engine の構築

Engine は Storage、Schema、ランタイムコンポーネントを統合します:

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

let engine = Engine::builder(storage, schema)
    .build()
    .await?;
}

テキストフィールドのみを使用する場合、デフォルトの StandardAnalyzer が自動的に適用されます。解析のカスタマイズや Vector エンベディングの追加については、アーキテクチャを参照してください。

ステップ 4 — ドキュメントのインデックス

DocumentBuilder でドキュメントを作成し、Engine に追加します:

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

// Each document needs a unique external ID (string)
let doc = Document::builder()
    .add_text("title", "Introduction to Rust")
    .add_text("body", "Rust is a systems programming language focused on safety and performance.")
    .build();
engine.add_document("doc-1", doc).await?;

let doc = Document::builder()
    .add_text("title", "Python for Data Science")
    .add_text("body", "Python is widely used in machine learning and data analysis.")
    .build();
engine.add_document("doc-2", doc).await?;

let doc = Document::builder()
    .add_text("title", "Web Development with JavaScript")
    .add_text("body", "JavaScript powers interactive web applications and server-side code with Node.js.")
    .build();
engine.add_document("doc-3", doc).await?;

// Commit to make documents searchable
engine.commit().await?;
}

重要: ドキュメントは commit() が呼ばれるまで検索可能になりません。

ステップ 5 — 検索

SearchRequestBuilder とクエリを使ってインデックスを検索します:

#![allow(unused)]
fn main() {
use laurus::SearchRequestBuilder;
use laurus::lexical::TermQuery;
use laurus::lexical::search::searcher::LexicalSearchQuery;

// Search for "rust" in the "body" field
let request = SearchRequestBuilder::new()
    .lexical_query(
        LexicalSearchQuery::Obj(
            Box::new(TermQuery::new("body", "rust"))
        )
    )
    .limit(10)
    .build();

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

for result in &results {
    println!("ID: {}, Score: {:.4}", result.id, result.score);
    if let Some(doc) = &result.document {
        if let Some(title) = doc.get("title") {
            println!("  Title: {:?}", title);
        }
    }
}
}

完全なサンプル

以下は、コピー・ペーストしてそのまま実行できる完全なプログラムです:

use std::sync::Arc;
use laurus::{
    Document, Engine, Result, Schema, SearchRequestBuilder,
};
use laurus::lexical::{TextOption, TermQuery};
use laurus::lexical::search::searcher::LexicalSearchQuery;
use laurus::storage::memory::MemoryStorage;

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

    // 2. Schema
    let schema = Schema::builder()
        .add_text_field("title", TextOption::default())
        .add_text_field("body", TextOption::default())
        .add_default_field("body")
        .build();

    // 3. Engine
    let engine = Engine::builder(storage, schema).build().await?;

    // 4. Index documents
    for (id, title, body) in [
        ("doc-1", "Introduction to Rust", "Rust is a systems programming language focused on safety."),
        ("doc-2", "Python for Data Science", "Python is widely used in machine learning."),
        ("doc-3", "Web Development", "JavaScript powers interactive web applications."),
    ] {
        let doc = Document::builder()
            .add_text("title", title)
            .add_text("body", body)
            .build();
        engine.add_document(id, doc).await?;
    }
    engine.commit().await?;

    // 5. Search
    let request = SearchRequestBuilder::new()
        .lexical_query(
            LexicalSearchQuery::Obj(
                Box::new(TermQuery::new("body", "rust"))
            )
        )
        .limit(10)
        .build();

    let results = engine.search(request).await?;
    for r in &results {
        println!("{}: score={:.4}", r.id, r.score);
    }

    Ok(())
}

次のステップ

サンプル

laurus/examples/ ディレクトリには、ライブラリのさまざまな機能を示す実行可能なサンプルが含まれています。

サンプルの実行

# Feature Flags なしでサンプルを実行
cargo run --example <name>

# Feature Flags を指定してサンプルを実行
cargo run --example <name> --features <flag>

利用可能なサンプル

quickstart

基本的なワークフローを示す最小限のサンプルです: Storage の作成、Schema の定義、Engine の構築、ドキュメントのインデックス、検索を行います。

cargo run --example quickstart

デモ内容: インメモリストレージ、TextOptionTermQueryLexicalSearchQuery

すべての Lexical クエリ型を示す包括的なサンプルです。Builder API と QueryParser DSL の両方を使用します。

cargo run --example lexical_search

デモ内容: TermQueryPhraseQueryFuzzyQueryWildcardQueryNumericRangeQueryGeoQueryBooleanQuerySpanQuery

モックエンベッダを使用した Vector 検索のサンプルです。フィルタ付き Vector 検索や DSL 構文も含みます。

cargo run --example vector_search

デモ内容: PerFieldEmbedderVectorSearchRequestBuilder、フィルタ付き検索、DSL 構文(field:"query"

異なる融合アルゴリズムを用いた Lexical 検索と Vector 検索の統合サンプルです。

cargo run --example hybrid_search

デモ内容: Lexical のみ、Vector のみ、ハイブリッド検索。RRFWeightedSum の両方の融合アルゴリズム。Builder API と DSL。

Earth-Centered Earth-Fixed (ECEF) 座標系を用いた 3D 地理検索のサンプルです。6 つのランドマーク(東京タワー、東京スカイツリー、富士山頂、自由の女神像、シドニー・オペラハウス、ISS サンプル点)を wgs84_to_ecef で変換してインデックスします。

cargo run --example geo3d_search

デモ内容: Geo3dDistanceQuery(球)、Geo3dBoundingBoxQuery(3D AABB)、Geo3dNearestQuery(k-NN)、および wgs84_to_ecef 変換ユーティリティ。バウンディングボックスクエリは ISS サンプルを意図的に除外し、高度が第三の軸として機能することを示します。

search_with_candle

Hugging Face Candle を使用した実際の BERT エンベディングによる Vector 検索です。初回実行時にモデルが自動的にダウンロードされます(約 80 MB)。

cargo run --example search_with_candle --features embeddings-candle

必要条件: embeddings-candle Feature Flag

デモ内容: CandleBertEmbeddersentence-transformers/all-MiniLM-L6-v2、384 次元)

search_with_openai

OpenAI Embeddings API を使用した Vector 検索です。

export OPENAI_API_KEY=your-api-key
cargo run --example search_with_openai --features embeddings-openai

必要条件: embeddings-openai Feature Flag、OPENAI_API_KEY 環境変数

デモ内容: OpenAIEmbeddertext-embedding-3-small、1536 次元)

CLIP モデルを使用したマルチモーダル(テキスト + 画像)検索です。

cargo run --example multimodal_search --features embeddings-multimodal

必要条件: embeddings-multimodal Feature Flag

デモ内容: CandleClipEmbedder、ファイルシステムからの画像インデックス、テキスト→画像クエリおよび画像→画像クエリ

synonym_graph_filter

解析時のトークン展開のための SynonymGraphFilter のデモです。

cargo run --example synonym_graph_filter

デモ内容: シノニム辞書の作成、シノニムによるトークン展開、ブーストの適用、トークンの position および position_length 属性

ヘルパーモジュール: common.rs

common.rs ファイルは、サンプルで使用される共通ユーティリティを提供します:

  • memory_storage() – インメモリストレージインスタンスの作成
  • per_field_analyzer() – 特定のフィールドに KeywordAnalyzer を設定した PerFieldAnalyzer の作成
  • MockEmbedder – 実際のモデルなしで Vector 検索をテストするためのモック Embedder 実装

スキーマとフィールド

Schema はドキュメントの構造を定義します。どのフィールドが存在し、各フィールドがどのようにインデクシングされるかを指定します。Schema は Engine にとって唯一の情報源です。

CLI で使用される TOML ファイル形式については、スキーマフォーマットリファレンスを参照してください。

Schema

Schema は名前付きフィールドのコレクションです。各フィールドはLexical フィールド(キーワード検索用)または Vector フィールド(類似度検索用)のいずれかです。

#![allow(unused)]
fn main() {
use laurus::Schema;
use laurus::lexical::TextOption;
use laurus::lexical::core::field::IntegerOption;
use laurus::vector::HnswOption;

let schema = Schema::builder()
    .add_text_field("title", TextOption::default())
    .add_text_field("body", TextOption::default())
    .add_integer_field("year", IntegerOption::default())
    .add_hnsw_field("embedding", HnswOption::default())
    .add_default_field("body")
    .build();
}

デフォルトフィールド

add_default_field() は、クエリがフィールド名を明示的に指定しない場合に検索対象となるフィールドを指定します。これは Query DSL パーサーで使用されます。

フィールドタイプ

graph TB
    FO["FieldOption"]

    FO --> T["Text"]
    FO --> I["Integer"]
    FO --> FL["Float"]
    FO --> B["Boolean"]
    FO --> DT["DateTime"]
    FO --> G["Geo"]
    FO --> G3["Geo3d"]
    FO --> BY["Bytes"]

    FO --> FLAT["Flat"]
    FO --> HNSW["HNSW"]
    FO --> IVF["IVF"]

Lexical フィールド

Lexical フィールドは転置インデックス(Inverted Index)を使用してインデクシングされ、キーワードベースのクエリをサポートします。

タイプRust 型SchemaBuilder メソッド説明
TextTextOptionadd_text_field()全文検索可能。Analyzer によりトークン化される
IntegerIntegerOptionadd_integer_field()64 ビット符号付き整数。範囲クエリをサポート
FloatFloatOptionadd_float_field()64 ビット浮動小数点数。範囲クエリをサポート
BooleanBooleanOptionadd_boolean_field()true / false
DateTimeDateTimeOptionadd_datetime_field()UTC タイムスタンプ。範囲クエリをサポート
GeoGeoOptionadd_geo_field()緯度/経度のペア。半径検索とバウンディングボックスクエリをサポート
Geo3dGeo3dOptionadd_geo3d_field()3D ECEF 直交座標ポイント(x, y, z、メートル)。3D 距離検索・バウンディングボックス・k-NN クエリをサポート。詳細は 3D 地理検索 を参照
BytesBytesOptionadd_bytes_field()バイナリデータ

Text フィールドオプション

TextOption はテキストのインデクシング方法を制御します。

#![allow(unused)]
fn main() {
use laurus::lexical::TextOption;

// Default: indexed + stored + term vectors (all true)
let opt = TextOption::default();

// Customize
let opt = TextOption::default()
    .indexed(true)
    .stored(true)
    .term_vectors(true);
}
オプションデフォルト説明
indexedtrueフィールドが検索可能かどうか
storedtrue元の値が取得用に保存されるかどうか
term_vectorstrueターム位置が保存されるかどうか(フレーズクエリやハイライトに必要)

Vector フィールド

Vector フィールドは近似最近傍(ANN: Approximate Nearest Neighbor)検索のためのベクトルインデックスを使用してインデクシングされます。

タイプRust 型SchemaBuilder メソッド説明
FlatFlatOptionadd_flat_field()ブルートフォース線形スキャン。正確な結果
HNSWHnswOptionadd_hnsw_field()Hierarchical Navigable Small World グラフ。高速な近似検索
IVFIvfOptionadd_ivf_field()Inverted File Index。クラスタベースの近似検索

HNSW フィールドオプション(最も一般的)

#![allow(unused)]
fn main() {
use laurus::vector::HnswOption;
use laurus::vector::core::distance::DistanceMetric;
use laurus::vector::core::quantization::QuantizationMethod;

let opt = HnswOption {
    dimension: 384,                                  // vector dimensions
    distance: DistanceMetric::Cosine,                // distance metric
    m: 16,                                           // max connections per layer
    ef_construction: 200,                            // construction search width
    default_ef_search: Some(100),                    // schema-level ef_search default (issue #644)
    base_weight: 1.0,                                // default scoring weight
    quantizer: QuantizationMethod::Scalar8Bit,       // 必須(デフォルト Scalar8Bit)
    embedder: None,                                  // 任意の embedder 名
};
}

default_ef_search: 検索時の recall 調整パラメータ

ef_search はクエリ時の動的候補リストのサイズを制御するパラメータです(ef_construction がインデックスビルド時にだけ影響するのとは別物です)。値を大きくするほどグラフ近傍の探索範囲が広がり、レイテンシと引き換えに recall が上がります。

  • スキーマレベルのデフォルト: HnswOption.default_ef_search = Some(ef) でフィールドごとのデフォルトを引き上げられます。None の場合、サーチャは内部 fallback (50) を使用します。
  • クエリごとのオーバーライド: 検索リクエスト側で SearchRequestBuilder::vector_ef_search を指定すると、スキーマデフォルトより優先されます。
  • 自動引き上げ: いずれの経路で ef_search を指定した場合でも、サーチャは少なくとも top_krerank_factor 併用時は top_k * rerank_factor)まで持ち上げるため、top_k 要求に対して候補ヒープが不足することはありません。
  • Issue #644 で対応。

パラメータの詳細なガイダンスについては、Vector インデクシングを参照してください。

Document

Document は名前付きフィールド値のコレクションです。DocumentBuilder を使用してドキュメントを構築します。

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

let doc = Document::builder()
    .add_text("title", "Introduction to Rust")
    .add_text("body", "Rust is a systems programming language.")
    .add_integer("year", 2024)
    .add_float("rating", 4.8)
    .add_boolean("published", true)
    .build();
}

ドキュメントのインデクシング

Engine はドキュメントを追加するための 2 つのメソッドを提供しており、それぞれ異なるセマンティクスを持ちます。

メソッド動作ユースケース
put_document(id, doc)Upsert — 同じ ID のドキュメントが存在する場合、置き換えられる標準的なドキュメントインデクシング
add_document(id, doc)Append — 新しいチャンクとしてドキュメントを追加。同じ ID で複数のチャンクを持てるチャンク分割されたドキュメント(例: 段落に分割された長い記事)
#![allow(unused)]
fn main() {
// Upsert: replaces any existing document with id "doc1"
engine.put_document("doc1", doc).await?;

// Append: adds another chunk under the same id "doc1"
engine.add_document("doc1", chunk2).await?;

// Always commit after indexing
engine.commit().await?;
}

ドキュメントの取得

get_documents を使用して、外部 ID でドキュメント(チャンクを含む)を取得します。

#![allow(unused)]
fn main() {
let docs = engine.get_documents("doc1").await?;
for doc in &docs {
    if let Some(title) = doc.get("title") {
        println!("Title: {:?}", title);
    }
}
}

ドキュメントの削除

外部 ID を共有するすべてのドキュメントとチャンクを削除します。

#![allow(unused)]
fn main() {
engine.delete_documents("doc1").await?;
engine.commit().await?;
}

ドキュメントのライフサイクル

graph LR
    A["Build Document"] --> B["put/add_document()"]
    B --> C["WAL"]
    C --> D["commit()"]
    D --> E["Searchable"]
    E --> F["get_documents()"]
    E --> G["delete_documents()"]

重要: ドキュメントは commit() が呼び出されるまで検索可能になりません。

DocumentBuilder メソッド

メソッド値の型説明
add_text(name, value)Stringテキストフィールドを追加
add_integer(name, value)i64整数フィールドを追加
add_float(name, value)f64浮動小数点数フィールドを追加
add_boolean(name, value)boolブールフィールドを追加
add_datetime(name, value)DateTime<Utc>日時フィールドを追加
add_vector(name, value)Vec<f32>事前計算済みベクトルフィールドを追加
add_geo(name, lat, lon)(f64, f64)2D 地理座標フィールドを追加(WGS84)
add_geo_ecef(name, x, y, z)(f64, f64, f64)3D ECEF 直交座標ポイントを追加(メートル)
add_bytes(name, data)Vec<u8>バイナリデータを追加
add_field(name, value)DataValue任意の値型を追加

DataValue

DataValue は Laurus におけるフィールド値を表す統合列挙型です。

#![allow(unused)]
fn main() {
pub enum DataValue {
    Null,
    Bool(bool),
    Int64(i64),
    Float64(f64),
    Text(String),
    Bytes(Vec<u8>, Option<String>),  // (data, optional MIME type)
    Vector(Vec<f32>),
    DateTime(DateTime<Utc>),
    Geo(GeoPoint),                   // 2D WGS84 ポイント (latitude, longitude)
    GeoEcef(GeoEcefPoint),           // 3D ECEF 直交座標ポイント (x, y, z)、メートル
    Int64Array(Vec<i64>),            // 多値整数フィールド
    Float64Array(Vec<f64>),          // 多値浮動小数点フィールド
}
}

DataValue は一般的な型に対して From<T> を実装しているため、.into() 変換が使用できます。

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

let v: DataValue = "hello".into();       // Text
let v: DataValue = 42i64.into();         // Int64
let v: DataValue = 3.14f64.into();       // Float64
let v: DataValue = true.into();          // Bool
let v: DataValue = vec![0.1f32, 0.2].into(); // Vector
}

予約フィールド

アンダースコア(_)で始まるフィールド名はすべてエンジンの予約領域です。 ユーザーコードからそのような名前でフィールドを定義することはできず、 _ で始まるキーを含むドキュメントは投入時にエラーとなります。

唯一許可される _ プレフィックス名は、次に説明する _id システムフィールドです。

_id — 外部ドキュメント ID

put_document / add_document に渡された外部ドキュメント ID を格納します。 KeywordAnalyzer(完全一致)でインデクシングされ、自動的に挿入されるため スキーマに追加する必要はありません。

動的スキーマ

Laurus はスキーマに宣言されていないフィールドを含むドキュメントも受け付けます。 挙動は Schema に設定する DynamicFieldPolicy で制御します:

ポリシー未宣言フィールドに対する挙動
Strictわかりやすいエラーメッセージで投入を拒否する
Dynamic(デフォルト)値から型を推論してスキーマへ自動追加する
Ignore未宣言フィールドを静かに破棄し、他のフィールドはインデックスする

Builder でポリシーを設定します:

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

let schema = Schema::builder()
    .dynamic_field_policy(DynamicFieldPolicy::Dynamic)
    .build();
}

型推論ルール(Dynamic ポリシー)

投入される値推論されるフィールド型
stringText(転置インデックス、BM25)
integerInteger(BKD tree)
floatFloat(BKD tree)
boolBoolean
整数の配列(例: [1, 2, 3]Integermulti_valued = true
浮動小数点を含む数値配列(例: [1.5, 2.0, 3]Floatmulti_valued = true
緯度キー(lat または latitude)と経度キー(lonlnglongitude のいずれか)を持ち、値が範囲内の objectGeo
数値の xyz の 3 キーをすべて持つ object(有限値、ECEF メートル単位)Geo3d

ベクトルフィールド(Hnsw / Flat / Ivf)と Bytes自動推論の 対象外です。スキーマへ明示的に宣言してください。なお、同一 object 内で 2D 用キー(lat / lon)と 3D 用キー(x / y / z)を混在させた 場合は曖昧と判定してエラーとなります。いずれか一方の形式のみ使用して ください。

多値数値フィールド

IntegerFloat フィールドは multi_valued = true を指定することで、 1 ドキュメントに複数の値を保持できます。範囲クエリはいずれかの値が条件を満たせばマッチ する Lucene 流の挙動で、スコアは constant(マッチ件数による加点なし)です。

多値フィールドに単一値を送った場合は要素 1 個の配列に自動ラップされます。 逆に単一値フィールドに配列を送ると、暗黙の切り捨てではなくエラーになります。

型衝突

既に宣言されているフィールドに別の型の値が到着した場合、Laurus は 宣言された型への変換を試みます。変換ルールは以下の通りです:

宣言型受け取った値結果
IntegerInt64そのまま格納
IntegerFloat64(3.14)3 へ切り捨て(情報損失あり — 下の警告を参照)
IntegerText("42")42 としてパース
IntegerText("abc")エラー
FloatInt64f64 に拡張
FloatText("3.14")パース
BooleanInt64(0) / Int64(1)false / true
BooleanText("true"/"false")大文字小文字を無視してパース
Text任意のスカラー値文字列化
Geo / Geo3d / Bytes / ベクトル対応 variant 以外エラー

変換エラーの扱いはポリシーに依存します:

  • Strict: ただちにエラーを返す
  • Dynamic: エラーを返す(安全とみなせる変換はこの層ですべて試し切っている)
  • Ignore: 該当フィールドのみ破棄し、他のフィールドはインデックスする

⚠️ 警告: 静かな情報損失が発生しうる

いくつかの変換は、エラーを返さずに情報を失います:

  • Integer フィールドは受け取った Float 値を切り捨てます3.143-3.9-3)。投入は成功します
  • Float フィールドは f64 仮数部に収まらない巨大な整数で精度を失う可能性があります
  • Text フィールドはスカラーを文字列化して受け入れます(元の型情報は消えます)
  • Ignore は非互換なフィールドを静かに捨てます

データの正確性を優先したい場合は、DynamicFieldPolicy::Strict を使う(あるいは必要なフィールドをすべて事前に宣言する)ことを推奨します。 Dynamic ポリシーは「ドキュメントを投入できる」ことを「入力データを 1 ビットも失わない」ことより優先します。

Query DSL と未宣言フィールド

スキーマが確定した後、クエリパーサは field:value 句で参照されるフィールドが すべてスキーマに存在することを検証します。titl:hellotitle:hello の打ち間違い) のような typo は、結果が無言で空になるのではなく、明確なパースエラーとして返ります。

動的フィールド管理

稼働中のエンジンに対して、フィールドの追加および削除を動的に行えます。 フィールドの型変更はサポートされていません。

フィールドの追加

Engine::add_field() を使用すると、稼働中のエンジンにフィールドを動的に追加できます。

Lexical フィールドの追加

let updated_schema = engine.add_field(
    "category",
    FieldOption::Text(TextOption::default()),
).await?;

Vector フィールドの追加

let updated_schema = engine.add_field(
    "embedding",
    FieldOption::Flat(FlatOption::default().dimension(384)),
).await?;

既存のドキュメントには影響がありません(新しいフィールドの値が存在しないだけです)。

フィールドの削除

Engine::delete_field() を使用すると、稼働中のエンジンからフィールドを動的に削除できます。

let updated_schema = engine.delete_field("category").await?;

フィールド削除時の動作は以下の通りです。

  • スキーマからフィールド定義が削除されます。
  • default_fields に含まれている場合、そこからも削除されます。
  • フィールドに紐づくアナライザーおよびエンベッダーの登録が解除されます。
  • 既にインデックスされたデータは物理的に残りますが、スキーマから削除されたフィールドにはアクセスできなくなります。

共通の注意事項

返却された Schema は呼び出し側で永続化する必要があります(例: schema.toml への書き出し)。

スキーマ設計のヒント

  1. Lexical フィールドと Vector フィールドを分離する — フィールドは Lexical か Vector のいずれかであり、両方にはなりません。ハイブリッド検索には、別々のフィールドを作成してください(例: テキスト用に body、ベクトル用に body_vec)。

  2. 完全一致フィールドには KeywordAnalyzer を使用する — カテゴリ、ステータス、タグフィールドは PerFieldAnalyzer 経由で KeywordAnalyzer を使用し、トークン化を避けてください。

  3. 適切なベクトルインデックスを選択する — ほとんどの場合は HNSW、小規模データセットには Flat、非常に大規模なデータセットには IVF を使用してください。詳細は Vector インデクシングを参照。

  4. デフォルトフィールドを設定する — Query DSL を使用する場合、デフォルトフィールドを設定することで、ユーザーは body:hello の代わりに hello と記述できます。

  5. スキーマジェネレータを使用するlaurus create schema を実行して、手書きの代わりにインタラクティブにスキーマ TOML ファイルを構築できます。詳細は CLI コマンドを参照。

テキスト解析

テキスト解析(Text Analysis)は、生のテキストを検索可能なトークンに変換するプロセスです。ドキュメントがインデクシングされる際、Analyzer がテキストフィールドを個々のタームに分割します。クエリが実行される際も、同じ Analyzer がクエリテキストを処理し、一貫性を確保します。

解析パイプライン

graph LR
    Input["Raw Text\n'The quick brown FOX jumps!'"]
    CF["UnicodeNormalizationCharFilter"]
    T["Tokenizer\nSplit into words"]
    F1["LowercaseFilter"]
    F2["StopFilter"]
    F3["StemFilter"]
    Output["Terms\n'quick', 'brown', 'fox', 'jump'"]

    Input --> CF --> T --> F1 --> F2 --> F3 --> Output

解析パイプラインは以下で構成されます。

  1. Char Filter — トークン化の前に文字レベルで生テキストを正規化する
  2. Tokenizer — テキストを生トークン(単語、文字、n-gram)に分割する
  3. Token Filter — トークンの変換、削除、展開を行う(小文字化、ストップワード除去、ステミング、同義語展開)

Analyzer トレイト

すべての Analyzer は Analyzer トレイトを実装します。

#![allow(unused)]
fn main() {
pub trait Analyzer: Send + Sync + Debug {
    fn analyze(&self, text: &str) -> Result<TokenStream>;
    fn name(&self) -> &str;
    fn as_any(&self) -> &dyn Any;
}
}

TokenStreamBox<dyn Iterator<Item = Token> + Send> であり、トークンの遅延イテレータです。

Token には以下のフィールドが含まれます。

フィールド説明
textStringトークンテキスト
positionusize元テキスト内の位置
start_offsetusize元テキスト内の開始バイトオフセット
end_offsetusize元テキスト内の終了バイトオフセット
position_incrementusize前のトークンからの距離
position_lengthusizeトークンのスパン(同義語の場合は 1 より大きい)
boostf32トークンレベルのスコアリング重み
stoppedboolストップワードとしてマークされているかどうか
metadataOption<TokenMetadata>追加のトークンメタデータ

組み込み Analyzer

StandardAnalyzer

デフォルトの Analyzer です。ほとんどの西洋言語に適しています。

パイプライン: RegexTokenizer(Unicode 単語境界) → LowercaseFilterStopFilter(128 個の一般的な英語ストップワード)

#![allow(unused)]
fn main() {
use laurus::analysis::analyzer::standard::StandardAnalyzer;

let analyzer = StandardAnalyzer::default();
// "The Quick Brown Fox" → ["quick", "brown", "fox"]
// ("The" is removed by stop word filtering)
}

JapaneseAnalyzer

日本語テキストの分割に形態素解析を使用します。

パイプライン: UnicodeNormalizationCharFilter(NFKC) → JapaneseIterationMarkCharFilterLinderaTokenizerLowercaseFilterStopFilter(日本語ストップワード)

JapaneseAnalyzer::newLinderaTokenizer::new と同じ引数(segmentation mode、Lindera 辞書ディレクトリのパス、任意のユーザー辞書パス)を受け取ります。laurus は Lindera の embed-* features をデフォルトで有効化しないため、IPADIC 等の辞書を実ファイルパスとして必ず指定する必要があります。

#![allow(unused)]
fn main() {
use laurus::analysis::analyzer::language::japanese::JapaneseAnalyzer;

// Lindera 辞書を展開済みのパスを指定する。
let analyzer = JapaneseAnalyzer::new(
    "normal",
    "/var/lib/lindera/ipadic",
    None,
)?;
// "東京都に住んでいる" → ["東京", "都", "住ん", "いる"]
}

Schema 経由で参照する場合は構造化された AnalyzerSpec 形式でパラメータを渡します(後述の PerFieldAnalyzer を参照)。

KeywordAnalyzer

入力全体を単一のトークンとして扱います。トークン化や正規化は行いません。

#![allow(unused)]
fn main() {
use laurus::analysis::analyzer::keyword::KeywordAnalyzer;

let analyzer = KeywordAnalyzer::new();
// "Hello World" → ["Hello World"]
}

完全一致が必要なフィールド(カテゴリ、タグ、ステータスコード)に使用してください。

SimpleAnalyzer

フィルタリングなしでテキストをトークン化します。元の大文字小文字とすべてのトークンが保持されます。解析パイプラインを完全に制御したい場合や、Tokenizer を単独でテストしたい場合に便利です。

パイプライン: ユーザー指定の Tokenizer のみ(Char Filter なし、Token Filter なし)

#![allow(unused)]
fn main() {
use laurus::analysis::analyzer::simple::SimpleAnalyzer;
use laurus::analysis::tokenizer::regex::RegexTokenizer;
use std::sync::Arc;

let tokenizer = Arc::new(RegexTokenizer::new()?);
let analyzer = SimpleAnalyzer::new(tokenizer);
// "Hello World" → ["Hello", "World"]
// (no lowercasing, no stop word removal)
}

Tokenizer のテストや、別のステップで手動で Token Filter を適用したい場合に使用してください。

EnglishAnalyzer

英語に特化した Analyzer です。トークン化、小文字化、一般的な英語ストップワードの除去を行います。

パイプライン: RegexTokenizer(Unicode 単語境界) → LowercaseFilterStopFilter(128 個の一般的な英語ストップワード)

#![allow(unused)]
fn main() {
use laurus::analysis::analyzer::language::english::EnglishAnalyzer;

let analyzer = EnglishAnalyzer::new()?;
// "The Quick Brown Fox" → ["quick", "brown", "fox"]
// ("The" is removed by stop word filtering, remaining tokens are lowercased)
}

PipelineAnalyzer

任意の Char Filter、Tokenizer、Token Filter のシーケンスを組み合わせてカスタムパイプラインを構築します。

#![allow(unused)]
fn main() {
use laurus::analysis::analyzer::pipeline::PipelineAnalyzer;
use laurus::analysis::char_filter::unicode_normalize::{
    NormalizationForm, UnicodeNormalizationCharFilter,
};
use laurus::analysis::tokenizer::regex::RegexTokenizer;
use laurus::analysis::token_filter::lowercase::LowercaseFilter;
use laurus::analysis::token_filter::stop::StopFilter;
use laurus::analysis::token_filter::stem::StemFilter;

let analyzer = PipelineAnalyzer::new(Arc::new(RegexTokenizer::new()?))
    .add_char_filter(Arc::new(UnicodeNormalizationCharFilter::new(NormalizationForm::NFKC)))
    .add_filter(Arc::new(LowercaseFilter::new()))
    .add_filter(Arc::new(StopFilter::new()))
    .add_filter(Arc::new(StemFilter::new()));  // Porter stemmer
}

PerFieldAnalyzer

PerFieldAnalyzer を使用すると、同一 Engine 内で異なるフィールドに異なる Analyzer を割り当てることができます。

graph LR
    PFA["PerFieldAnalyzer"]
    PFA -->|"title"| KW["KeywordAnalyzer"]
    PFA -->|"body"| STD["StandardAnalyzer"]
    PFA -->|"description_ja"| JP["JapaneseAnalyzer"]
    PFA -->|other fields| DEF["Default\n(StandardAnalyzer)"]
#![allow(unused)]
fn main() {
use std::sync::Arc;
use laurus::analysis::analyzer::standard::StandardAnalyzer;
use laurus::analysis::analyzer::keyword::KeywordAnalyzer;
use laurus::analysis::analyzer::per_field::PerFieldAnalyzer;

// Default analyzer for fields not explicitly configured
let per_field = PerFieldAnalyzer::new(
    Arc::new(StandardAnalyzer::default())
);

// Use KeywordAnalyzer for exact-match fields
per_field.add_analyzer("category", Arc::new(KeywordAnalyzer::new()));
per_field.add_analyzer("status", Arc::new(KeywordAnalyzer::new()));

let engine = Engine::builder(storage, schema)
    .analyzer(Arc::new(per_field))
    .build()
    .await?;
}

注意: _id フィールドは設定に関係なく、常に KeywordAnalyzer で解析されます。

Schema からの per-field analyzer 設定

実装で直接 PerFieldAnalyzer を組み立てる代わりに、スキーマ宣言で analyzer を割り当てる場合がほとんどです。テキストフィールドの analyzer 設定は次の 2 つの形式を受け付けます。

// 1. パラメータ不要の組込 analyzer、または schema.analyzers に登録した名前。
{ "analyzer": "standard" }
{ "analyzer": "english" }
{ "analyzer": "my_custom_pipeline" }

// 2. パラメータ付きの組込プリセット。現状は Japanese プリセットのみで、Lindera 辞書のパスが必須。
{
  "analyzer": {
    "language": "japanese",
    "mode": "normal",
    "dict": "/var/lib/lindera/ipadic"
  }
}

文字列単独の "japanese" は辞書パスを伴わないためエラーとなります。既存スキーマで "analyzer": "japanese" を保存していた場合は、上記の構造化形式に移行してください。

プリセットに収まらないパイプラインを使いたい場合は、schema.analyzersAnalyzerDefinition として登録し、フィールドからは名前で参照します。

Char Filter

Char Filter は Tokenizer に渡される前の生入力テキストに対して動作します。Unicode 正規化、文字マッピング、パターンベースの置換などの文字レベルの正規化を行います。これにより、Tokenizer がクリーンで正規化されたテキストを受け取ることが保証されます。

すべての Char Filter は CharFilter トレイトを実装します。

#![allow(unused)]
fn main() {
pub trait CharFilter: Send + Sync {
    fn filter(&self, input: &str) -> (String, Vec<Transformation>);
    fn name(&self) -> &'static str;
}
}

Transformation レコードは文字位置がどのようにシフトしたかを記述し、Engine がトークン位置を元テキストにマッピングできるようにします。

Char Filter説明
UnicodeNormalizationCharFilterUnicode 正規化(NFC、NFD、NFKC、NFKD)
MappingCharFilterマッピング辞書に基づいて文字シーケンスを置換
PatternReplaceCharFilter正規表現パターンに一致する文字を置換
JapaneseIterationMarkCharFilter日本語の踊り字を基本文字に展開

UnicodeNormalizationCharFilter

入力テキストに Unicode 正規化を適用します。検索用途では NFKC が推奨されます。互換文字と合成形式の両方を正規化するためです。

#![allow(unused)]
fn main() {
use laurus::analysis::char_filter::unicode_normalize::{
    NormalizationForm, UnicodeNormalizationCharFilter,
};

let filter = UnicodeNormalizationCharFilter::new(NormalizationForm::NFKC);
// "Sony" (fullwidth) → "Sony" (halfwidth)
// "㌂" → "アンペア"
}
形式説明
NFC正準分解後に正準合成
NFD正準分解
NFKC互換分解後に正準合成
NFKD互換分解

MappingCharFilter

辞書を使用して文字シーケンスを置換します。Aho-Corasick アルゴリズム(最左最長一致)によりマッチングが行われます。

#![allow(unused)]
fn main() {
use std::collections::HashMap;
use laurus::analysis::char_filter::mapping::MappingCharFilter;

let mut mapping = HashMap::new();
mapping.insert("ph".to_string(), "f".to_string());
mapping.insert("qu".to_string(), "k".to_string());

let filter = MappingCharFilter::new(mapping)?;
// "phone queue" → "fone keue"
}

PatternReplaceCharFilter

正規表現パターンのすべての出現箇所を固定文字列で置換します。

#![allow(unused)]
fn main() {
use laurus::analysis::char_filter::pattern_replace::PatternReplaceCharFilter;

// Remove hyphens
let filter = PatternReplaceCharFilter::new(r"-", "")?;
// "123-456-789" → "123456789"

// Normalize numbers
let filter = PatternReplaceCharFilter::new(r"\d+", "NUM")?;
// "Year 2024" → "Year NUM"
}

JapaneseIterationMarkCharFilter

日本語の踊り字を基本文字に展開します。漢字()、ひらがな()、カタカナ()の踊り字をサポートします。

#![allow(unused)]
fn main() {
use laurus::analysis::char_filter::japanese_iteration_mark::JapaneseIterationMarkCharFilter;

let filter = JapaneseIterationMarkCharFilter::new(
    true,  // normalize kanji iteration marks
    true,  // normalize kana iteration marks
);
// "佐々木" → "佐佐木"
// "いすゞ" → "いすず"
}

パイプラインでの Char Filter の使用

PipelineAnalyzeradd_char_filter() で Char Filter を追加します。複数の Char Filter は追加された順序で適用され、すべて Tokenizer の実行前に処理されます。

#![allow(unused)]
fn main() {
use std::sync::Arc;
use laurus::analysis::analyzer::pipeline::PipelineAnalyzer;
use laurus::analysis::char_filter::unicode_normalize::{
    NormalizationForm, UnicodeNormalizationCharFilter,
};
use laurus::analysis::char_filter::pattern_replace::PatternReplaceCharFilter;
use laurus::analysis::tokenizer::regex::RegexTokenizer;
use laurus::analysis::token_filter::lowercase::LowercaseFilter;

let analyzer = PipelineAnalyzer::new(Arc::new(RegexTokenizer::new()?))
    .add_char_filter(Arc::new(
        UnicodeNormalizationCharFilter::new(NormalizationForm::NFKC),
    ))
    .add_char_filter(Arc::new(
        PatternReplaceCharFilter::new(r"-", "")?,
    ))
    .add_filter(Arc::new(LowercaseFilter::new()));
// "Tokyo-2024" → NFKC → "Tokyo-2024" → remove hyphens → "Tokyo2024" → tokenize → lowercase → ["tokyo2024"]
}

Tokenizer

Tokenizer説明
RegexTokenizerUnicode 単語境界で分割。空白と句読点で区切る
UnicodeWordTokenizerUnicode 単語境界で分割
WhitespaceTokenizer空白のみで分割
WholeTokenizer入力全体を単一のトークンとして返す
LinderaTokenizer日本語形態素解析(Lindera/MeCab)
NgramTokenizer設定可能なサイズの n-gram トークンを生成

Token Filter

フィルタ説明
LowercaseFilterトークンを小文字に変換
StopFilter一般的な単語を除去(“the”、“is”、“a”)
StemFilter単語を語幹に縮約(“running” → “run”)
SynonymGraphFilter同義語辞書でトークンを展開
BoostFilterトークンのブースト値を調整
LimitFilterトークン数を制限
StripFilterトークンの先頭/末尾の空白を除去
FlattenGraphFilterトークングラフをフラット化(同義語展開用)
RemoveEmptyFilter空トークンを除去

同義語展開

SynonymGraphFilter は同義語辞書を使用してタームを展開します。

#![allow(unused)]
fn main() {
use laurus::analysis::synonym::dictionary::SynonymDictionary;
use laurus::analysis::token_filter::synonym_graph::SynonymGraphFilter;

let mut dict = SynonymDictionary::new(None)?;
dict.add_synonym_group(vec!["ml".into(), "machine learning".into()]);
dict.add_synonym_group(vec!["ai".into(), "artificial intelligence".into()]);

// keep_original=true means original token is preserved alongside synonyms
let filter = SynonymGraphFilter::new(dict, true)
    .with_boost(0.8);  // synonyms get 80% weight
}

boost パラメータは、元のトークンに対する同義語の重みを制御します。値 0.8 は、同義語のマッチが完全一致のスコアの 80% を寄与することを意味します。

Embedding

Embedding は、テキスト(または画像)を意味的な情報を捉えた密なベクトル(数値ベクトル)に変換します。類似した意味を持つ 2 つのテキストは、ベクトル空間内で近い位置のベクトルを生成するため、類似度ベースの検索が可能になります。

Embedder トレイト

すべての Embedder は Embedder トレイトを実装します。

#![allow(unused)]
fn main() {
#[async_trait]
pub trait Embedder: Send + Sync + Debug {
    async fn embed(&self, input: &EmbedInput<'_>) -> Result<Vector>;
    async fn embed_batch(&self, inputs: &[EmbedInput<'_>]) -> Result<Vec<Vector>>;
    fn supported_input_types(&self) -> Vec<EmbedInputType>;
    fn name(&self) -> &str;
    fn as_any(&self) -> &dyn Any;
}
}

embed() メソッドは VectorVec<f32> をラップした構造体)を返します。

EmbedInput は 2 つのモダリティをサポートします。

バリアント説明
EmbedInput::Text(&str)テキスト入力
EmbedInput::Bytes(&[u8], Option<&str>)バイナリ入力(オプションの MIME タイプ付き、画像用)

組み込み Embedder

CandleBertEmbedder

Hugging Face Candle を使用して BERT モデルをローカルで実行します。API キーは不要です。

Feature flag: embeddings-candle

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

// Downloads model on first run (~80MB)
let embedder = CandleBertEmbedder::new(
    "sentence-transformers/all-MiniLM-L6-v2"  // model name
)?;
// Output: 384-dimensional vector
}
プロパティ
モデルsentence-transformers/all-MiniLM-L6-v2
次元数384
実行環境ローカル(CPU)
初回ダウンロード約 80 MB

OpenAIEmbedder

OpenAI Embeddings API を呼び出します。API キーが必要です。

Feature flag: embeddings-openai

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

let embedder = OpenAIEmbedder::new(
    api_key,
    "text-embedding-3-small".to_string()
).await?;
// Output: 1536-dimensional vector
}
プロパティ
モデルtext-embedding-3-small(または任意の OpenAI モデル)
次元数1536(text-embedding-3-small の場合)
実行環境リモート API 呼び出し
必要条件OPENAI_API_KEY 環境変数

CandleClipEmbedder

マルチモーダル(テキスト + 画像)Embedding のために CLIP モデルをローカルで実行します。

Feature flag: embeddings-multimodal

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

let embedder = CandleClipEmbedder::new(
    "openai/clip-vit-base-patch32"
)?;
// Text or images → 512-dimensional vector
}
プロパティ
モデルopenai/clip-vit-base-patch32
次元数512
入力タイプテキストおよび画像
ユースケーステキストから画像への検索、画像から画像への検索

PrecomputedEmbedder

Embedding 計算を行わず、事前計算済みのベクトルを直接使用します。ベクトルが外部で生成される場合に便利です。

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

let embedder = PrecomputedEmbedder::new();  // no parameters needed
}

PrecomputedEmbedder を使用する場合、ドキュメントには Embedding 用のテキストではなく、ベクトルを直接指定します。

#![allow(unused)]
fn main() {
let doc = Document::builder()
    .add_vector("embedding", vec![0.1, 0.2, 0.3, ...])
    .build();
}

PerFieldEmbedder

PerFieldEmbedder は Embedding リクエストをフィールド固有の Embedder にルーティングします。

graph LR
    PFE["PerFieldEmbedder"]
    PFE -->|"text_vec"| BERT["CandleBertEmbedder\n(384 dim)"]
    PFE -->|"image_vec"| CLIP["CandleClipEmbedder\n(512 dim)"]
    PFE -->|other fields| DEF["Default Embedder"]
#![allow(unused)]
fn main() {
use std::sync::Arc;
use laurus::PerFieldEmbedder;

let bert = Arc::new(CandleBertEmbedder::new("...")?);
let clip = Arc::new(CandleClipEmbedder::new("...")?);


let per_field = PerFieldEmbedder::new(bert.clone());
per_field.add_embedder("text_vec", bert.clone());
per_field.add_embedder("image_vec", clip.clone());

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

これは以下の場合に特に有用です。

  • 異なる Vector フィールドに異なるモデルが必要な場合(例: テキスト用に BERT、画像用に CLIP)
  • 異なるフィールドが異なるベクトル次元を持つ場合
  • ローカル Embedder とリモート Embedder を混在させたい場合

Embedding の使用方法

インデクシング時

Vector フィールドにテキスト値を追加すると、Engine が自動的に Embedding を生成します。

#![allow(unused)]
fn main() {
let doc = Document::builder()
    .add_text("text_vec", "Rust is a systems programming language")
    .build();
engine.add_document("doc-1", doc).await?;
// The embedder converts the text to a vector before indexing
}

検索時

テキストで検索すると、Engine がクエリテキストも同様に Embedding 化します。

#![allow(unused)]
fn main() {
// Builder API
let request = VectorSearchRequestBuilder::new()
    .add_text("text_vec", "systems programming")
    .build();

// Query DSL
let request = vector_parser.parse(r#"text_vec:"systems programming""#).await?;
}

どちらのアプローチも、インデクシング時と同じ Embedder を使用してクエリテキストを Embedding 化するため、一貫したベクトル空間が保証されます。

Feature Flag まとめ

各 Embedder は Cargo.toml で特定の Feature Flag を有効にする必要があります。

EmbedderFeature Flag依存関係
CandleBertEmbedderembeddings-candlecandle-core, candle-nn, candle-transformers, hf-hub, tokenizers
OpenAIEmbedderembeddings-openaireqwest
CandleClipEmbedderembeddings-multimodalimage + embeddings-candle
PrecomputedEmbedder(なし – 常に利用可能)

embeddings-all Feature ですべての Embedding 機能を一括で有効にできます。詳細は Feature Flags を参照してください。

Embedder の選択

シナリオ推奨 Embedder
クイックプロトタイピング、オフライン利用CandleBertEmbedder
高精度が求められる本番環境OpenAIEmbedder
テキスト + 画像検索CandleClipEmbedder
外部パイプラインからの事前計算済みベクトルPrecomputedEmbedder
フィールドごとに複数モデルを使用他の Embedder をラップした PerFieldEmbedder

ストレージ

Laurus はプラガブルなストレージレイヤーを使用し、インデックスデータの永続化方法と保存場所を抽象化します。すべてのコンポーネント(Lexical インデックス、Vector インデックス、ドキュメントログ)は単一のストレージバックエンドを共有します。

Storage トレイト

すべてのバックエンドは Storage トレイトを実装します。

#![allow(unused)]
fn main() {
pub trait Storage: Send + Sync + Debug {
    fn loading_mode(&self) -> LoadingMode;
    fn open_input(&self, name: &str) -> Result<Box<dyn StorageInput>>;
    fn create_output(&self, name: &str) -> Result<Box<dyn StorageOutput>>;
    fn file_exists(&self, name: &str) -> bool;
    fn delete_file(&self, name: &str) -> Result<()>;
    fn list_files(&self) -> Result<Vec<String>>;
    fn file_size(&self, name: &str) -> Result<u64>;
    // ... additional methods
}
}

このインターフェースはファイル指向です。すべてのデータ(インデックスセグメント、メタデータ、WAL エントリ、ドキュメント)は名前付きファイルとして保存され、ストリーミング StorageInput / StorageOutput ハンドルを通じてアクセスされます。

ストレージバックエンド

MemoryStorage

すべてのデータがメモリ上に保持されます。高速でシンプルですが、耐久性はありません。

#![allow(unused)]
fn main() {
use std::sync::Arc;
use laurus::Storage;
use laurus::storage::memory::MemoryStorage;

let storage: Arc<dyn Storage> = Arc::new(
    MemoryStorage::new(Default::default())
);
}
プロパティ
耐久性なし(プロセス終了時にデータ消失)
速度最速
ユースケーステスト、プロトタイピング、一時的なデータ

FileStorage

標準的なファイルシステムベースの永続化です。各キーがディスク上のファイルにマッピングされます。

#![allow(unused)]
fn main() {
use std::sync::Arc;
use laurus::Storage;
use laurus::storage::file::{FileStorage, FileStorageConfig};

let config = FileStorageConfig::new("/tmp/laurus-data");
let storage: Arc<dyn Storage> = Arc::new(FileStorage::new("/tmp/laurus-data", config)?);
}
プロパティ
耐久性完全(ディスクに永続化)
速度中程度(ディスク I/O)
ユースケース一般的な本番利用

メモリマッピング付き FileStorage

FileStorageuse_mmap 設定フラグによるメモリマップドファイル アクセスをサポートします。有効にすると OS がメモリとディスク間の ページングを管理し、レキシカルの posting デコーダ (Issue #504) は StorageInput::as_slice 経由の zero-copy パスを取り、PFOR ビットパック済みブロックを bitpacking::decompress* に直接渡します (Read 経由の Vec<u8> 確保とコピーを省略)。

デフォルトはプラットフォーム依存:

  • *Unix (Linux / macOS / BSD): true (Issue #504 以降)。デバッグ セッションや mmap が機能しないホストでバッファード I/O に切り替える には、FileStorageConfig::new 呼び出し時に LAURUS_NO_MMAP=1 環境変数を設定します。
  • Windows: false (Issue #508 以降)。Windows はメモリマップ ドファイルに排他ロックを持ち (ERROR_USER_MAPPED_FILE、os error 1224)、reader が mmap を保持したまま writer がセグメント ファイルを truncate / delete することを許可しません。現状の segment file lifecycle はこのロックと整合しません。コミット頻度 が低い read-only / read-mostly ワークロードでは LAURUS_USE_MMAP=1 でオプトイン可能です。Windows mmap の完全 サポートは Issue #508 で 追跡しています。
#![allow(unused)]
fn main() {
use std::sync::Arc;
use laurus::Storage;
use laurus::storage::file::{FileStorage, FileStorageConfig};

// Unix では mmap がデフォルトで有効、Windows では LAURUS_USE_MMAP=1
// を設定しない限り無効。
let config = FileStorageConfig::new("/tmp/laurus-data");
let storage: Arc<dyn Storage> = Arc::new(FileStorage::new("/tmp/laurus-data", config)?);

// 環境変数に触れずに明示的にオプトアウト(OS 不問)。
let mut buffered_config = FileStorageConfig::new("/tmp/laurus-data");
buffered_config.use_mmap = false;

// 明示的にオプトイン(Windows でも有効、OS 不問)。
let mut mmap_config = FileStorageConfig::new("/tmp/laurus-data");
mmap_config.use_mmap = true;
}
プロパティ
耐久性完全(ディスクに永続化)
速度高速(OS 管理のメモリマッピング、zero-copy posting デコード)
ユースケースプロダクション規模ワークロードのデフォルト

StorageFactory

設定を使用してストレージを作成することもできます。

#![allow(unused)]
fn main() {
use laurus::storage::{StorageConfig, StorageFactory};
use laurus::storage::memory::MemoryStorageConfig;

let storage = StorageFactory::create(
    StorageConfig::Memory(MemoryStorageConfig::default())
)?;
}

PrefixedStorage

Engine は PrefixedStorage を使用して、単一のストレージバックエンド内でコンポーネントを分離します。

graph TB
    E["Engine"]
    E --> P1["PrefixedStorage\nprefix = 'lexical/'"]
    E --> P2["PrefixedStorage\nprefix = 'vector/'"]
    E --> P3["PrefixedStorage\nprefix = 'documents/'"]
    P1 --> S["Storage Backend"]
    P2 --> S
    P3 --> S

Lexical ストアがキー segments/seg-001.dict を書き込む場合、実際には基盤バックエンドでは lexical/segments/seg-001.dict として保存されます。これにより、コンポーネント間のキー衝突が防止されます。

PrefixedStorage を自分で作成する必要はありません。EngineBuilder が自動的に処理します。

ColumnStorage

主要なストレージバックエンドに加えて、Laurus はフィールドレベルの高速アクセスのための ColumnStorage レイヤーを提供します。これはファセッティング、ソート、集計などの操作で内部的に使用され、ドキュメント全体をデシリアライズせずに個々のフィールド値にアクセスすることが重要な場合に利用されます。

ColumnValue

ColumnValue は単一の格納されたカラム値を表します。

バリアント説明
String(String)UTF-8 テキスト
I32(i32)32 ビット符号付き整数
I64(i64)64 ビット符号付き整数
U32(u32)32 ビット符号なし整数
U64(u64)64 ビット符号なし整数
F32(f32)32 ビット浮動小数点数
F64(f64)64 ビット浮動小数点数
Bool(bool)ブール値
DateTime(i64)Unix タイムスタンプ(秒)
Null値なし

ColumnStorage は Engine が内部的に管理するため、直接操作する必要はありません。

バックエンドの選択

要因MemoryStorageFileStorageFileStorage (mmap)
耐久性なし完全完全
読み取り速度最速中程度高速
書き込み速度最速中程度中程度
メモリ使用量データサイズに比例少ないOS 管理
最大データサイズRAM による制限ディスクによる制限ディスク + アドレス空間による制限
最適な用途テスト、小規模データセット一般的な利用大規模な読み取り負荷の高いデータセット

推奨事項

  • 開発 / テスト: ファイルクリーンアップなしで高速に反復するために MemoryStorage を使用
  • 本番(一般): 信頼性の高い永続化のために FileStorage を使用
  • 本番(大規模): 大規模なインデックスがあり OS のページキャッシュを活用したい場合は FileStorageuse_mmap = true を使用

次のステップ

インデキシング(Indexing)

このセクションでは、Laurus がデータを内部的にどのように格納・整理するかについて説明します。インデキシングレイヤーを理解することで、適切なフィールドタイプの選択やパフォーマンスチューニングに役立ちます。

トピック

Lexical インデキシング

転置インデックス(Inverted Index)を使用したテキスト、数値、地理フィールドのインデキシング方法について説明します。

  • 転置インデックスの構造(Term Dictionary、Posting Lists)
  • 数値範囲クエリのための BKD ツリー
  • セグメントファイルとそのフォーマット
  • BM25 スコアリング

Vector インデキシング

近似最近傍探索(Approximate Nearest Neighbor Search)のためのベクトルフィールドのインデキシング方法について説明します。

  • インデックスタイプ: Flat、HNSW、IVF
  • パラメータチューニング(m、ef_construction、n_clusters、n_probe)
  • 距離メトリクス(Cosine、Euclidean、DotProduct)
  • 量子化(Quantization): SQ8、PQ

Lexical インデキシング

Lexical インデキシングは、キーワードベースの検索を支える仕組みです。ドキュメントのテキストフィールドがインデキシングされると、Laurus は転置インデックス(Inverted Index) を構築します。これは、タームからそのタームを含むドキュメントへのマッピングを行うデータ構造です。

Lexical インデキシングの仕組み

sequenceDiagram
    participant Doc as Document
    participant Analyzer
    participant Writer as IndexWriter
    participant Seg as Segment

    Doc->>Analyzer: "The quick brown fox"
    Analyzer->>Analyzer: Tokenize + Filter
    Analyzer-->>Writer: ["quick", "brown", "fox"]
    Writer->>Writer: Buffer in memory
    Writer->>Seg: Flush to segment on commit()

ステップごとの流れ

  1. 解析(Analyze): テキストが設定されたアナライザー(トークナイザー + フィルター)を通過し、正規化されたタームのストリームが生成される
  2. バッファリング(Buffer): タームはフィールドごとに整理され、インメモリの書き込みバッファに格納される
  3. コミット(Commit): commit() の呼び出し時に、バッファがストレージ上の新しいセグメントにフラッシュされる

転置インデックス(Inverted Index)

転置インデックスは、基本的にタームからドキュメントリストへのマップです。

graph LR
    subgraph "Term Dictionary"
        T1["'brown'"]
        T2["'fox'"]
        T3["'quick'"]
        T4["'rust'"]
    end

    subgraph "Posting Lists"
        P1["doc_1, doc_3"]
        P2["doc_1"]
        P3["doc_1, doc_2"]
        P4["doc_2, doc_3"]
    end

    T1 --> P1
    T2 --> P2
    T3 --> P3
    T4 --> P4
コンポーネント説明
Term Dictionaryインデックス内のユニークなタームのソート済み辞書。ディスク形式は Lucene BlockTreeTermsWriter 互換のブロックツリー(FST + 128 ターム単位の front-coded ブロック + bit-packed TermInfo)でファイルサイズを最小化。メモリ形式は load 時に構築する AHashMap インデックス + parallel-array 形式のクエリ層で、get / iter / find_prefix を parallel-array 同等のレイテンシで提供。完全一致検索、順序イテレーション、プレフィックススキャンに対応
Posting Lists各タームに対する、ドキュメント ID とメタデータ(ターム頻度、位置情報)のリスト
Doc Values数値フィールドや日付フィールドでのソート/フィルター操作のためのカラム指向ストレージ

Posting List の内容

Posting List の各エントリには以下の情報が含まれます。

フィールド説明
Document ID内部 u64 識別子
Term Frequencyそのドキュメント内でタームが出現する回数
Positions(オプション)ドキュメント内でタームが出現する位置(フレーズクエリに必要)
Weightこのポスティングのスコアウェイト

Multi-Level Skip Table

N ≥ 8 ポスティングを持つ posting list はヘッダ直後に Lucene-90 互換のマルチレベルスキップテーブル(v2 フォーマット)を 持ちます。各レベルは doc_ids 上の固定ストライド窓の “末尾 doc id” を 保持し、レベル 0 は SKIP_INTERVAL = 8 ポスティングごとに 1 エントリ、 レベル 1 は 、トップレベルは 1 エントリに収束するまで重ねます。

PostingIterator::skip_to(target) はテーブルをトップダウンに走査し、 各レベルで partition_point により探索窓を SKIP_INTERVAL で 1 段ずつ 絞り込んでから、最下層の最大 8 ポスティング窓を線形スキャンします。 1 回あたりのコストは O(log_8 N + SKIP_INTERVAL) ── N = 1M で約 25 比較。従来の単一レベル block_cache が支払っていた線形 O(N / block_size) ウォークと比べて大幅に少なくなります。

スキップテーブルは Lucene 9 / Tantivy と同様に posting list の ファイル内に同居させ、別ファイル化はしません。スキップテーブルが on-disk に保存されていない v1 形式のセグメントもそのまま読めます ── SoA デコーダがロード時に doc_ids から再構築します。

Term Dictionary の 2 層構造

ディクショナリは ディスクメモリ で別の表現を持ち、 それぞれの最適化目標を分離しています。

  • ディスク層.dict ファイルは Lucene BlockTreeTermsWriter 風のブロックツリーレイアウト(マジック LTDD、スキーマ v1)。 100k ユニーク 5-10 バイトタームのコーパスで .dict は ~12.5 バイト/ターム と、旧 parallel-array 形式比 約 70% 削減
  • メモリ層 — build / load 時に AHashMap<term, ordinal> 索引、 ordinal indexed の Vec<String>、単一コピーの Arc<[TermInfo]> を構築。get / iter / find_prefix / find_range はすべて この in-memory 構造のみを参照するので、per-query レイテンシは 旧 parallel-array 実装と同等( FST traversal や block 内線形 スキャンのコストを払わない)

ディスク形式のスクラッチ (FST + BlockSection bytes) は [BlockTermDictionary::write_to_storage] でセグメントマージ時に 再エンコードせず再シリアライズするためにのみ保持しています。

[Header                ]  マジック "LTDD" + バージョン
[FstSection            ]  各ブロック末尾タームを key、ブロックの開始
                          バイトオフセットを value とする fst::Map<u64>
[BlockSection          ]  128 ターム単位のブロックを連結。各ブロックは
                          front-coded タームバイト列、bit-packed の
                          固定長 TermInfo ブロック、可変長の per-term
                          Block-Max-WAND メタデータ配列を含む
[Footer                ]  全タームカウント + ブロックカウント
  • 検索: FST を 1 回辿って (O(|term|)) target を含むブロックを特定 し、そのブロック内(≤ 128 件)を front-coded で線形スキャン
  • イテレーション: FST を経由せず BlockSection を順次走査。各ブロック の front-coding バッファを再利用するため、per-step コストは front-coding decode(≈ 5–10 ns)のみ
  • プレフィックススキャン: FST で先頭ブロックを特定し、prefix が一致 しなくなるまで順次ブロックを走査

flat per-term FST と比較して block-head FST は 1〜2 桁小さく、 front-coded タームバイト列と bit-packed TermInfo の組み合わせで production 規模ではディスクサイズが 50〜80 % 削減されます。

数値フィールドと日付フィールド

整数、浮動小数点数、日時フィールドは、BKD ツリー を使用してインデキシングされます。BKD ツリーは範囲クエリに最適化された空間分割データ構造です。

graph TB
    Root["BKD Root"]
    Root --> L["values < 50"]
    Root --> R["values >= 50"]
    L --> LL["values < 25"]
    L --> LR["25 <= values < 50"]
    R --> RL["50 <= values < 75"]
    R --> RR["values >= 75"]

BKD ツリーにより、price:[10 TO 100]date:[2024-01-01 TO 2024-12-31] のような範囲クエリを効率的に評価できます。

地理フィールド(Geo Fields)

地理フィールドには 2 種類あり、いずれも同じ多次元 BKD-Tree プリミティブにバックアップされています:

フィールド型次元数座標サポートされるクエリ
Geo2WGS84 緯度・経度(度)半径検索、バウンディングボックス
Geo3d3ECEF 直交座標 (x, y, z)(メートル)3D 距離検索(球)、3D バウンディングボックス、k-NN

Geo3d は高度が一級の次元になる用途(ドローン・衛星・屋内 3D 測位など、 2D の Geo フィールドでは情報が失われたり極で歪んだりするケース)で 適しています。座標系・WGS84 変換ヘルパー・DSL 構文については 3D 地理検索 (ECEF) を参照してください。

セグメント(Segments)

Lexical インデックスはセグメントに分割されて構成されます。各セグメントはイミュータブルで自己完結型のミニインデックスです。

graph TB
    LI["Lexical Index"]
    LI --> S1["Segment 0"]
    LI --> S2["Segment 1"]
    LI --> S3["Segment 2"]

    S1 --- F1[".dict (terms)"]
    S1 --- F2[".post (postings)"]
    S1 --- F3[".bkd (numerics)"]
    S1 --- F4[".docs (doc store)"]
    S1 --- F5[".dv (doc values)"]
    S1 --- F6[".meta (metadata)"]
    S1 --- F7[".lens (field lengths)"]
ファイル拡張子内容
.dictTerm Dictionary。v1 LTDD ブロックツリーレイアウト(FST + 128 ターム単位の front-coded ブロック + bit-packed TermInfo)。セグメント open 時に AHashMap バックの in-memory クエリ層へ展開
.postPosting Lists(ドキュメント ID、ターム頻度、位置情報)
.bkd数値・日付・Geo(2D)・Geo3d(3D ECEF)フィールドの BKD ツリー データ
.docs格納されたフィールド値(元のドキュメント内容)
.dvソートおよびフィルタリング用の Doc Values
.metaセグメントメタデータ(ドキュメント数、ターム数など)
.lensフィールド長の正規化値(BM25 スコアリング用)

セグメントのライフサイクル

  1. 作成(Create): commit() が呼び出されるたびに新しいセグメントが作成される
  2. 検索(Search): すべてのセグメントが並列に検索され、結果がマージされる
  3. マージ(Merge): 各 commit() 後に自動マージが走り、セグメント数が max_segments を超えると最小のセグメント群がマージされて数が有界に保たれる。手動 optimize() は全セグメントを 1 つに強制マージする
  4. 削除(Delete): ドキュメントが削除された場合、物理的に削除されるのではなく、削除ビットマップに ID が追加される(Deletions & Compaction を参照)

BM25 スコアリング

Laurus は Lexical 検索結果のスコアリングに BM25 アルゴリズムを使用します。BM25 は以下の要素を考慮します。

  • ターム頻度(Term Frequency, TF): ドキュメント内でタームが出現する頻度(多いほど良いが、収穫逓減あり)
  • 逆文書頻度(Inverse Document Frequency, IDF): 全ドキュメントにおけるタームの希少性(希少なほど重要)
  • フィールド長の正規化(Field Length Normalization): 短いフィールドは長いフィールドに対してブーストされる

計算式:

score(q, d) = IDF(q) * (TF(q, d) * (k1 + 1)) / (TF(q, d) + k1 * (1 - b + b * |d| / avgdl))

k1 = 1.2b = 0.75 がデフォルトのチューニングパラメータです。

SIMD 最適化

ベクトル距離計算では、利用可能な場合に SIMD(Single Instruction, Multiple Data)命令が活用され、ベクトル検索における類似度計算が大幅に高速化されます。

コード例

use std::sync::Arc;
use laurus::{Document, Engine, Schema};
use laurus::lexical::TextOption;
use laurus::lexical::core::field::IntegerOption;
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_text_field("body", TextOption::default())
        .add_integer_field("year", IntegerOption::default())
        .build();

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

    // Index documents
    engine.add_document("doc-1", Document::builder()
        .add_text("title", "Rust Programming")
        .add_text("body", "Rust is a systems programming language.")
        .add_integer("year", 2024)
        .build()
    ).await?;

    // Commit to flush segments to storage
    engine.commit().await?;

    Ok(())
}

次のステップ

Vector インデキシング

Vector インデキシングは、類似性ベースの検索を支える仕組みです。ドキュメントのベクトルフィールドがインデキシングされると、Laurus はエンベディングベクトルを専用のインデックス構造に格納し、高速な近似最近傍探索(Approximate Nearest Neighbor, ANN)を可能にします。

Vector インデキシングの仕組み

sequenceDiagram
    participant Doc as Document
    participant Embedder
    participant Normalize as Normalizer
    participant Index as Vector Index

    Doc->>Embedder: "Rust is a systems language"
    Embedder-->>Normalize: [0.12, -0.45, 0.78, ...]
    Normalize->>Normalize: L2 normalize
    Normalize-->>Index: [0.14, -0.52, 0.90, ...]
    Index->>Index: Insert into index structure

ステップごとの流れ

  1. エンベディング(Embed): テキスト(または画像)が、設定されたエンベッダーによってベクトルに変換される
  2. 正規化(Normalize): ベクトルは Cosine メトリックの場合のみ L2 正規化される(大きさ不変なメトリック)。Euclidean / DotProduct / Manhattan のフィールドは距離を保つため元のベクトルのまま保存される
  3. インデキシング(Index): ベクトルが設定されたインデックス構造(Flat、HNSW、または IVF)に挿入される
  4. コミット(Commit): commit() の呼び出し時に、インデックスが永続ストレージにフラッシュされる

インデックスタイプ

Laurus は 3 種類のベクトルインデックスタイプをサポートしており、それぞれ異なるパフォーマンス特性を持ちます。

比較

特性FlatHNSWIVF
精度100%(厳密)約 95-99%(近似)約 90-98%(近似)
検索速度O(n) 線形スキャンO(log n) グラフ走査O(n/k) クラスタスキャン
メモリ使用量高(グラフエッジ)中程度(セントロイド)
インデックス構築時間高速中程度低速(クラスタリング)
最適な用途1 万ベクトル未満1 万 - 1,000 万ベクトル100 万ベクトル以上

Flat インデックス

最もシンプルなインデックスです。クエリベクトルを格納されたすべてのベクトルと比較します(総当たり)。

#![allow(unused)]
fn main() {
use laurus::vector::FlatOption;
use laurus::vector::core::distance::DistanceMetric;

let opt = FlatOption {
    dimension: 384,
    distance: DistanceMetric::Cosine,
    ..Default::default()
};
}
  • 利点: 100% の再現率(厳密な結果)、シンプル、低メモリ
  • 欠点: 大規模データセットでは低速(線形スキャン)
  • 使用場面: ベクトル数が約 1 万未満の場合、または厳密な結果が必要な場合

HNSW インデックス

Hierarchical Navigable Small World グラフ。デフォルトで最も一般的に使用されるインデックスタイプです。

graph TB
    subgraph "Layer 2 (sparse)"
        A2["A"] --- C2["C"]
    end

    subgraph "Layer 1 (medium)"
        A1["A"] --- B1["B"]
        A1 --- C1["C"]
        B1 --- D1["D"]
        C1 --- D1
    end

    subgraph "Layer 0 (dense - all vectors)"
        A0["A"] --- B0["B"]
        A0 --- C0["C"]
        B0 --- D0["D"]
        B0 --- E0["E"]
        C0 --- D0
        C0 --- F0["F"]
        D0 --- E0
        E0 --- F0
    end

    A2 -.->|"entry point"| A1
    A1 -.-> A0
    C2 -.-> C1
    C1 -.-> C0
    B1 -.-> B0
    D1 -.-> D0

HNSW アルゴリズムは、上位の疎なレイヤーから下位の密なレイヤーへと検索し、各レベルで検索空間を絞り込みます。

#![allow(unused)]
fn main() {
use laurus::vector::HnswOption;
use laurus::vector::core::distance::DistanceMetric;

let opt = HnswOption {
    dimension: 384,
    distance: DistanceMetric::Cosine,
    m: 16,                  // max connections per node per layer
    ef_construction: 200,   // search width during index building
    ..Default::default()
};
}

HNSW パラメータ

パラメータデフォルト説明影響
m16レイヤーごとのノードあたりの最大双方向接続数大きいほど再現率が向上するが、メモリ消費が増加
ef_construction200インデックス構築時の探索幅大きいほど再現率が向上するが、構築が低速に
dimension128ベクトルの次元数エンベッダーの出力と一致させる必要あり
distanceCosine距離メトリクス下記の距離メトリクスを参照

チューニングのヒント:

  • 再現率を向上させるには m を増やす(例: 32 または 64)。ただしメモリ消費が増加する
  • インデックス品質を向上させるには ef_construction を増やす(例: 400)。ただし構築時間が増加する
  • 検索時には、検索リクエストで設定する ef_search パラメータが探索幅を制御する

IVF インデックス

Inverted File Index。ベクトルをクラスタに分割し、関連するクラスタのみを検索します。

graph TB
    Q["Query Vector"]
    Q --> C1["Cluster 1\n(centroid)"]
    Q --> C2["Cluster 2\n(centroid)"]

    C1 --> V1["vec_3"]
    C1 --> V2["vec_7"]
    C1 --> V3["vec_12"]

    C2 --> V4["vec_1"]
    C2 --> V5["vec_9"]
    C2 --> V6["vec_15"]

    style C1 fill:#f9f,stroke:#333
    style C2 fill:#f9f,stroke:#333
#![allow(unused)]
fn main() {
use laurus::vector::IvfOption;
use laurus::vector::core::distance::DistanceMetric;

let opt = IvfOption {
    dimension: 384,
    distance: DistanceMetric::Cosine,
    n_clusters: 100,   // number of clusters
    n_probe: 10,       // clusters to search at query time
    ..Default::default()
};
}

IVF パラメータ

パラメータデフォルト説明影響
n_clusters100ボロノイセル(Voronoi Cell)の数クラスタ数が多いほど検索は高速になるが、再現率は低下
n_probe1クエリ時に検索するクラスタ数大きいほど再現率が向上するが、検索が低速に
dimension(必須)ベクトルの次元数エンベッダーの出力と一致させる必要あり
distanceCosine距離メトリクス下記の距離メトリクスを参照

チューニングのヒント:

  • n_clusters はベクトル数 n に対して sqrt(n) 程度に設定する
  • 再現率と速度のバランスを取るため、n_proben_clusters の 5-20% に設定する
  • IVF はトレーニングフェーズが必要なため、初回のインデキシングが遅くなる場合がある

距離メトリクス(Distance Metrics)

メトリクス説明値の範囲最適な用途
Cosine1 - コサイン類似度[0, 2]テキストエンベディング(最も一般的)
EuclideanL2 距離[0, +inf)空間データ
ManhattanL1 距離[0, +inf)特徴ベクトル
DotProduct負の内積(-inf, +inf)事前正規化済みベクトル
Angular角度距離[0, pi]方向の類似性
#![allow(unused)]
fn main() {
use laurus::vector::core::distance::DistanceMetric;

let metric = DistanceMetric::Cosine;      // Default for text
let metric = DistanceMetric::Euclidean;    // For spatial data
let metric = DistanceMetric::Manhattan;    // L1 distance
let metric = DistanceMetric::DotProduct;   // For pre-normalized vectors
let metric = DistanceMetric::Angular;      // Angular distance
}

注意: ベクトルがインデキシング前に自動的に L2 正規化されるのは Cosine メトリックの場合のみです。正規化は大きさ不変なので Cosine では安全(かつ int8 量子化の範囲も締まる)ですが、大きさに依存するメトリックでは距離が変わってしまうため、Euclidean / DotProduct / Manhattan のフィールドは正規化せずに保存されます。距離が小さいほど、より類似していることを示します。

マイグレーション注記: この挙動が修正される前に作成した非 Cosine フィールドは、ディスク上に L2 正規化済みのベクトルを持ちます(元の大きさは復元できません)。正しい距離を得るにはインデックスを再構築してください。

量子化(Quantization)

ベクトルはディスク上で 8 ビットスカラー量子化された整数 として保存 されます(Issue #481 Stage 1)。以前の 32 ビット浮動小数点形式と比較 して 約 4 倍小さく、recall 損失は実用上ほぼ無視できる範囲(f32 ground truth に対して Recall@10 ≥ 0.95 — recall テスト laurus/tests/vector_recall_test.rs 参照)です。

方式Enum バリアント説明メモリ削減率
スカラー 8 ビット (デフォルト)Scalar8Bitper-segment global affine による u8 量子化約 4 倍
プロダクト量子化 (予約)ProductQuantization { subvector_count }Issue #481 Stage 3 — 現状 NotImplemented約 16-64 倍
#![allow(unused)]
fn main() {
use laurus::vector::HnswOption;
use laurus::vector::core::quantization::QuantizationMethod;

// `quantizer` は `Scalar8Bit` がデフォルト。下記は
// `HnswOption { dimension: 384, ..Default::default() }` と等価。
let opt = HnswOption {
    dimension: 384,
    quantizer: QuantizationMethod::Scalar8Bit,
    ..Default::default()
};
}

破壊的変更(Issue #481 Stage 1): quantizer フィールドはもはや Option<QuantizationMethod> ではなく必須となり、デフォルトは Scalar8Bit です。f32 のままディスクに保存する形式は廃止されまし た。Stage 1 より前に作成した既存 vector index は意図的に読み取り 不可能で、ソースデータからの再構築が必要です。

Scalar8Bit のしくみ

  • 各 segment は flush 時に f32 ベクトル群から global(offset, scale) ペア 1 組をトレーニング (offset = min, scale = (max - min) / 255)。
  • f32 要素は u8 = clamp(round((v - offset) / scale), 0, 255) でエンコード。
  • 各ベクトル単位のメタデータ(sum_q: u32, norm_q: f32)を 事前計算して int8 ペイロードと併置するため、cosine 検索の hot loop は int8 SIMD multiply-accumulate 1 回 + scalar 補正 3 回に縮約され、 検索時の per-element dequantize は不要。
  • segment ファイルは LVS1 magic + 16 byte header で始まり、reader はロード時にフォーマットを判定。

Two-stage rerank(Issue #481 Stage 2)

Stage 1 ではベクトルを int8 のみで保持します。グラフ検索は完全に int8 距離に対して行われ、高速ですがわずかな量子化誤差が入ります。Stage 2 ではフィールド単位で 任意の f32 sidecar を追加し、上位候補を元の 完全精度ベクトルで再スコアできるようにします:

  1. HNSW int8 グラフ検索が量子化コサイン距離で最大 ef_search 件の 候補を返す。
  2. 上位 top_k * rerank_factor 件を LRS1 sidecar*.hnsw.f32)から読み込んだ f32 ベクトルで再スコアする。
  3. 新しいランキングを top_k に切り詰めて返す。

Stage 2 はフィールド単位で HnswOption.rerank_storage で opt-in します:

#![allow(unused)]
fn main() {
use laurus::vector::HnswOption;
use laurus::vector::core::rerank::RerankStorageKind;

let opt = HnswOption {
    rerank_storage: Some(RerankStorageKind::F32),
    ..HnswOption::default()
};
}

クエリ側は VectorIndexQuery::rerank_factor(low-level)、 SearchRequestBuilder::vector_rerank_factor(engine)、 gRPC / JSON の VectorParams.rerank_factor のいずれかで rerank factor を渡します。

rerank_storage が無効なフィールドでは rerank_factor を指定しても silent に Stage 1 int8 ランキングへフォールバックします — Stage 1 セグメントから f32 情報を復元することはできません。

LRS1 rerank sidecar

sidecar は rerank_storage が有効なときに LVS1 セグメントの隣に 書かれる別ファイルです:

offset  size  field
------  ----  -------------------------------------------
     0     4  magic         ASCII "LRS1"
     4     2  version       u16 LE  (current = 1)
     6     2  storage_kind  u16 LE  (1 = F32; 0 reserved; 2.. future)
     8     8  reserved      zero-padded
    16     4  dim           u32 LE
    20     4  vector_count  u32 LE
    24     -  payload       vector_count * dim * bytes_per_element
   end     8  footer        magic "LRC1" u32 LE + CRC-32 u32 LE
                             (header + payload が対象)

ベクトルは LVS1 セグメントと同じ (doc_id, field_name) 順で書かれる ので、(sidecar position) → (LVS1 position) のマッピングは恒等関数に なります。HNSW reader は storage の loading mode が Eager のとき初期化 時に sidecar を RerankStoragePool にロードします。Lazy mode では memory savings の前提を尊重するため sidecar 読み込みをスキップします (Lazy mode で開いた Stage 2 セグメントは silent に Stage 1 へ degrade します)。

sidecar はスキーマの HNSW オプションの rerank_storage でフィールド ごとに有効化され、その設定はすべての書き込み経路で尊重されます。直接 の commit、アクティブな書き込みセグメント、セグメントの merge いずれも 生成したセグメントに対して sidecar を再出力します。マージ後セグメント の sidecar は ソースセグメントの原 f32 サイドカー から再構築されます (int8 復元値ではなく)。これによりマージはマージごとに量子化誤差を 1 段 ずつ蓄積させず、rerank ベクトルを無損失に保ちます。

新しい sidecar は末尾に header + payload を対象とする 8 バイトの CRC-32 footer を持ち、sidecar を読むすべての経路(searcher のロードと writer の再ロード)で検証されます。これにより、ディスク上の静かな破損 が rerank スコアを歪める代わりに拒否されます。header からコンテンツ長 が一意に決まるため、footer は payload の後ろに残っているバイト数で 検出されます — footer 導入前に書かれた sidecar は末尾バイトがゼロ なのでそのままロードでき、検証はスキップされます。

recall と速度の trade-off

rerank_factor は per-query rerank コスト(top_k * rerank_factor 回の exact distance 計算 — dim 128 で数 µs)と引き換えに Recall@10 の 向上を得る lever です。実際の効果はコーパスとグラフ検索 budget (ef_search)に依存します:

  • 実際の clustered な embedding(text-embedding-3、BERT など)は 低い ef_searchRecall@10 ≥ 0.99 に到達し、rerank は微小な latency 増で順位を磨きます。
  • 合成 random unit-norm(HNSW 復元の最悪ケース)では int8 グラフが 真の top-10 候補を十分に visit するために高い ef_search が必要で、 rerank は visit 済み候補を並び替えられても visit していないものは 取り戻せません。

recall acceptance は rerank kernel と HNSW フルパイプラインを独立に 落とせるよう、CI gate を 2 段に分けています:

  • stage2_brute_force_rerank_recall_at_10_meets_kernel_gateRecall@10 ≥ 0.99 を assert します。HNSW グラフを完全にバイパス し(brute-force int8 で corpus 全体を採点 → top_k * rerank_factor に絞る → f32 で再スコア)miss すれば rerank kernel の regression と切り分けられます。
  • hnsw_quantized_recall_at_10_with_rerank_meets_stage2_recall_gateRecall@10 ≥ 0.98 を assert します。HNSW build の non-determinism(f32 HNSW baseline でも同様に出るノイズ)を 含むため、合成 adversarial 分布での run-to-run 観測幅に合わせて しきい値を緩めています。実 embedding の clustered 分布や 強めの HNSW config(m=32, ef_construction=500)はこのパス でも ≥ 0.99 に到達します(下の diagnostic sweep を参照)。

companion の stage2_recall_sweep_diagnosticLAURUS_STAGE2_SWEEP=1 で opt-in)は (ef_search, rerank_factor) を 3 種の corpus / query 分布 × 2 種の HNSW config で sweep するので、production deployment は実際の embedding 分布で budget を calibrate できます。

実データ検証(Issue #498)

3 つめの opt-in CI gate として、 Stage 2 を実 ANN benchmark dataset (TEXMEX の SIFT1M)で検証します。 synthetic data の gate だけが signal にならないようにするためのものです。

  • hnsw_quantized_recall_at_10_with_rerank_on_sift_meets_stage2_real_data_recall_gate は SIFT1M の 50 000 ベクトルサブサンプル上で (m=16, ef_construction=200, ef_search=200, rerank_factor=5) の Recall@10 ≥ 0.99 を assert します。
  • companion bench bench_hnsw_graph_search_rerank_real_datalaurus/benches/vector_search_bench.rs)は同じ fixture で end-to-end Stage 2 latency を測定します。 同梱の laurus/examples/sift_rerank_probe.rs(ef_search × rerank_factor × HNSW config) を sweep して (Recall, latency) を per-cell で報告するので、運用側は自分の データに合った operating point を選べます。

両者は LAURUS_REAL_BENCHMARK=1.cache/sift/sift/ 配下の SIFT1M .fvecs ファイルの存在で gate されます。 デフォルトの CI runs は変化しません。 ローカルで有効化するには:

./scripts/fetch-sift.sh --large   # ~478MB
LAURUS_REAL_BENCHMARK=1 cargo test --release \
    --test vector_recall_test \
    hnsw_quantized_recall_at_10_with_rerank_on_sift_meets_stage2_real_data_recall_gate \
    -- --nocapture
LAURUS_REAL_BENCHMARK=1 cargo bench --bench vector_search_bench \
    -- "HNSW Graph Search Rerank Real"

Issue #481 の原文は “≥ 3× speedup vs the pre-Stage-1 f32 baseline” を要求していましたが、 SIFT1M-50k での cross-branch Criterion 計測 (30 サンプル中央値、同じ (m, ef_construction, ef_search))では pre-Stage-1 f32 HNSW path が 625 µs/query、 Stage 2 int8 + rerank path が 323 µs/query となり、 speedup は 1.94× でした。 この 実測結果を受けて Issue #498 では real-data gate を ≥ 1.5× に 下げています(recall 側は元の 0.99 を維持)。 3× との gap は、 rerank が int8 graph traversal で visit 済みの候補を re-rank する だけで、 ef_search を下げると candidate set 自体が狭くなり rerank で回収できないことに起因します。 follow-up としては graph search budget を ef_search から独立に広げる(Lucene 99 pattern)案が あります。

Product Quantization + rerank(Issue #481 Stage 3)

Stage 3 は HNSW index 向けに opt-in の Product Quantization path を 追加します。 各 segment は per-field の codebook(M 個の sub-vector × K = 256 centroid)を k-means++ + Lloyd 反復で学習し、 各ベクトル を M バイトで保存します(sub-vector あたり 1 centroid index)。 検索ホットループでは int8 SIMD kernel を asymmetric distance computation(ADC)に置き換えます: query 1 回ごとに query sub-vector と codebook entry の squared distance を M × K look-up table に展開し、候補ごとに Σ_m lut[m][codes[m]] で スコアリング(1 候補あたり M lookups + M − 1 add)。

PQ は per-field HnswOption.quantizer で有効化:

#![allow(unused)]
fn main() {
use laurus::vector::HnswOption;
use laurus::vector::core::quantization::QuantizationMethod;
use laurus::vector::core::rerank::RerankStorageKind;

let opt = HnswOption {
    dimension: 128,
    quantizer: QuantizationMethod::ProductQuantization { subvector_count: 32 },
    // SIFT1M の PQ-only Recall@10 は 0.78-0.92 で頭打ちのため、
    // production では LRS1 rerank sidecar との組み合わせを推奨
    // (Stage 2 と同じ仕組み、 candidate 生成側のみ int8 から PQ
    // に置き換わる)。
    rerank_storage: Some(RerankStorageKind::F32),
    ..Default::default()
};
}

subvector_countdimension を割り切る値を選択。 dim = 128 での候補: M ∈ {8, 16, 32}(sub_dim 16 / 8 / 4)。 M を大きく すると compression は下がるが recall は上がります。 Issue #481 Stage 3 は 8 bit(K = 256)のみを ship、 on-disk format は 将来の 4 bit(K = 16)variant 用に枠を予約しています。

on-disk format

PQ segment は Scalar8Bit と同じ LVS1 header を使用(quant_kind = 2)し、 codebook は per-segment metadata block 内に格納:

[ Fixed header           16 bytes ]
[ PQ params               8 bytes ]    m / k / sub_dim / padding (u16 × 4)
[ Codebook                m × k × sub_dim × 4 bytes ]
[ Per-vector codes        num_vectors × m bytes ]

dim = 128, M = 32, K = 256 の場合 codebook = 32 × 256 × 4 × 4 = 131 072 bytes(128 KB)+ ベクトル 1 件あたり 32 byte。

Recall と speed gate(Issue #481 Stage 3)

  • Kernel レベルテスト — synthetic 5 000 ベクトル / dim 128 / 100 query、 (m=16, ef_construction=200, ef_search=200, rerank_factor=10, M=32): hnsw_pq_rerank_recall_at_10_meets_stage3_recall_gate が Recall@10 ≥ 0.95 を assert。 実測 0.9660。
  • 実データテスト — SIFT1M-50k subsample(opt-in: LAURUS_REAL_BENCHMARK=1、同設定): hnsw_pq_rerank_recall_at_10_on_sift_meets_stage3_real_data_recall_gate が Recall@10 ≥ 0.95 を assert。 実測 0.9965。
  • 実データ speed benchbench_hnsw_graph_search_pq_rerank_real_data(opt-in、Criterion)。 同 SIFT1M-50k 設定での cross-branch 計測: pre-Stage-1 f32 HNSW = 625.21 µs/query(PR #500 計測値)、 Stage 3 PQ + rerank = 299.54 µs/query = 2.09× speedup

Issue #481 の原文は Recall ≥ 0.95 で ≥ 5× speedup を要求していました が、 本 PR の実測で SIFT1M ではその target が到達不可能と判明。 Stage 2(#500)と Stage 3 は両方とも 1.9-2.1× の band で着地しており、 candidate set を recall が回収可能な幅まで広げると rerank がホット パスを支配することが原因です。 これを受けて gate は ≥ 1.5× に 下げられています。 follow-up としては Lucene 99 pattern(graph search の独立 budget)や 4 bit PQ variant でこの gap を埋める方向が 考えられます。

セグメントファイル

各ベクトルインデックスタイプは、データを単一のセグメントファイルに格納します。

インデックスタイプファイル拡張子内容
HNSW.hnswグラフ構造、ベクトル、メタデータ
Flat.flat生ベクトルとメタデータ
IVF.ivfクラスタセントロイド、割り当て済みベクトル、メタデータ

コード例

use std::sync::Arc;
use laurus::{Document, Engine, Schema};
use laurus::lexical::TextOption;
use laurus::vector::HnswOption;
use laurus::vector::core::distance::DistanceMetric;
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("embedding", HnswOption {
            dimension: 384,
            distance: DistanceMetric::Cosine,
            m: 16,
            ef_construction: 200,
            ..Default::default()
        })
        .build();

    // With an embedder, text in vector fields is automatically embedded
    let engine = Engine::builder(storage, schema)
        .embedder(my_embedder)
        .build()
        .await?;

    // Add text to the vector field — it will be embedded automatically
    engine.add_document("doc-1", Document::builder()
        .add_text("title", "Rust Programming")
        .add_text("embedding", "Rust is a systems programming language.")
        .build()
    ).await?;

    engine.commit().await?;

    Ok(())
}

次のステップ

検索(Search)

このセクションでは、インデキシングされたデータに対するクエリの実行方法を説明します。Laurus は 3 つの検索モードをサポートしており、それぞれ独立して使用することも、組み合わせて使用することもできます。

トピック

Lexical 検索

転置インデックスを使用したキーワードベースの検索について説明します。

  • すべてのクエリタイプ: Term、Phrase、Boolean、Fuzzy、Wildcard、Range、Geo、Span
  • BM25 スコアリングとフィールドブースト
  • テキストベースのクエリのための Query DSL の使用方法

Vector 検索

ベクトルエンベディングを使用した意味的類似性検索について説明します。

  • VectorSearchRequestBuilder API
  • マルチフィールド Vector 検索とスコアモード
  • フィルター付き Vector 検索

ハイブリッド検索

Lexical 検索と Vector 検索を組み合わせた、両方の長所を活かす検索について説明します。

  • SearchRequestBuilder API
  • フュージョンアルゴリズム(RRF、WeightedSum)
  • フィルター付きハイブリッド検索
  • offset/limit によるページネーション

スペル修正については、ライブラリセクションの Spelling Correction を参照してください。

Lexical 検索

Lexical 検索は、転置インデックスに対してキーワードをマッチングすることでドキュメントを検索します。Laurus は、完全一致、フレーズ一致、あいまい一致など、豊富なクエリタイプを提供します。

基本的な使い方

#![allow(unused)]
fn main() {
use laurus::SearchRequestBuilder;
use laurus::lexical::TermQuery;
use laurus::lexical::search::searcher::LexicalSearchQuery;

let request = SearchRequestBuilder::new()
    .lexical_query(
        LexicalSearchQuery::Obj(
            Box::new(TermQuery::new("body", "rust"))
        )
    )
    .limit(10)
    .build();

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

クエリタイプ

TermQuery

特定のフィールドに完全一致するタームを含むドキュメントをマッチングします。

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

// Find documents where "body" contains the term "rust"
let query = TermQuery::new("body", "rust");
}

注意: タームは解析後にマッチングされます。フィールドが StandardAnalyzer を使用している場合、インデキシングされたテキストとクエリタームの両方が小文字化されるため、TermQuery::new("body", "rust") は元テキスト内の “Rust” にもマッチします。

PhraseQuery

正確なタームの並びを含むドキュメントをマッチングします。

#![allow(unused)]
fn main() {
use laurus::lexical::query::phrase::PhraseQuery;

// Find documents containing the exact phrase "machine learning"
let query = PhraseQuery::new("body", vec!["machine".to_string(), "learning".to_string()]);

// Or use the convenience method from a phrase string:
let query = PhraseQuery::from_phrase("body", "machine learning");
}

フレーズクエリは、ターム位置情報が格納されている必要があります(TextOption のデフォルト設定)。

BooleanQuery

複数のクエリをブーリアン論理で結合します。

#![allow(unused)]
fn main() {
use laurus::lexical::query::boolean::{BooleanQuery, BooleanQueryBuilder, Occur};

let query = BooleanQueryBuilder::new()
    .must(Box::new(TermQuery::new("body", "rust")))       // AND
    .must(Box::new(TermQuery::new("body", "programming"))) // AND
    .must_not(Box::new(TermQuery::new("body", "python")))  // NOT
    .build();
}
Occur意味DSL での表現
Mustドキュメントが必ずマッチしなければならない+term または AND
Shouldドキュメントがマッチすべき(スコアをブースト)term または OR
MustNotドキュメントがマッチしてはならない-term または NOT
Filter必ずマッチする必要があるが、スコアには影響しない(DSL に相当するものなし)

FuzzyQuery

指定された編集距離(レーベンシュタイン距離)内のタームをマッチングします。

#![allow(unused)]
fn main() {
use laurus::lexical::query::fuzzy::FuzzyQuery;

// Find documents matching "programing" within edit distance 2
// This will match "programming", "programing", etc.
let query = FuzzyQuery::new("body", "programing");  // default max_edits = 2
}

WildcardQuery

ワイルドカードパターンを使用してタームをマッチングします。

#![allow(unused)]
fn main() {
use laurus::lexical::query::wildcard::WildcardQuery;

// '?' matches exactly one character, '*' matches zero or more
let query = WildcardQuery::new("filename", "*.pdf")?;
let query = WildcardQuery::new("body", "pro*")?;
let query = WildcardQuery::new("body", "col?r")?;  // matches "color" and "colour"
}

PrefixQuery

特定のプレフィックスで始まるタームを含むドキュメントをマッチングします。

#![allow(unused)]
fn main() {
use laurus::lexical::query::prefix::PrefixQuery;

// Find documents where "body" contains terms starting with "pro"
// This matches "programming", "program", "production", etc.
let query = PrefixQuery::new("body", "pro");
}

RegexpQuery

正規表現パターンにマッチするタームを含むドキュメントをマッチングします。

#![allow(unused)]
fn main() {
use laurus::lexical::query::regexp::RegexpQuery;

// Find documents where "body" contains terms matching the regex
let query = RegexpQuery::new("body", "^pro.*ing$")?;

// Match version-like patterns
let query = RegexpQuery::new("version", r"^v\d+\.\d+")?;
}

注意: RegexpQuery::new()Result を返します。正規表現パターンは構築時にバリデーションされ、無効なパターンの場合はエラーが返されます。

NumericRangeQuery

数値フィールドの値が指定された範囲内にあるドキュメントをマッチングします。

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

// Find documents where "price" is between 10.0 and 100.0 (inclusive)
let query = NumericRangeQuery::new(
    "price",
    NumericType::Float,
    Some(10.0),   // min
    Some(100.0),  // max
    true,         // include min
    true,         // include max
);

// Open-ended range: price >= 50
let query = NumericRangeQuery::new(
    "price",
    NumericType::Float,
    Some(50.0),
    None,     // no upper bound
    true,
    false,
);
}

GeoQuery

2D 地理座標(WGS84 緯度・経度)に基づいてドキュメントをマッチングします。

#![allow(unused)]
fn main() {
use laurus::lexical::query::geo::GeoQuery;

// Find documents within 10 km (= 10 000 m) of Tokyo Station (35.6812, 139.7671)
let query = GeoQuery::within_radius("location", 35.6812, 139.7671, 10_000.0)?; // distance in metres

// Find documents within a bounding box (min_lat, min_lon, max_lat, max_lon)
let query = GeoQuery::within_bounding_box(
    "location",
    35.0, 139.0,  // min (lat, lon)
    36.0, 140.0,  // max (lat, lon)
)?;
}

Geo3dDistanceQuery / Geo3dBoundingBoxQuery / Geo3dNearestQuery

3 種類のクエリが、ECEF 直交座標(メートル)にバックアップされた 3D の Geo3d フィールドを対象とします。高度が意味を持つ用途や、2D の Geo フィールドでは極特異点が問題になるケースで使ってください。座標系・WGS84 変換ヘルパー・実例については 3D 地理検索 を参照してください。

#![allow(unused)]
fn main() {
use laurus::GeoEcefPoint;
use laurus::lexical::query::geo3d::{
    Geo3dDistanceQuery, Geo3dBoundingBoxQuery, Geo3dNearestQuery,
};

let centre = GeoEcefPoint::new(-3_955_182.0, 3_350_553.0, 3_700_276.0);

// 球: `centre` から 5 km 以内のドキュメント
let q = Geo3dDistanceQuery::new("position", centre, 5_000.0);

// 軸並行 3D バウンディングボックス(コンストラクタが軸ごとに min ≤ max を検証)
let min = GeoEcefPoint::new(-4_000_000.0, 3_300_000.0, 3_650_000.0);
let max = GeoEcefPoint::new(-3_900_000.0, 3_400_000.0, 3_750_000.0);
let q = Geo3dBoundingBoxQuery::new("position", min, max)?;

// k-NN: 最も近い 10 件、半径スケジュールをカスタマイズ
let q = Geo3dNearestQuery::new("position", centre, 10)
    .with_initial_radius(500.0)
    .with_max_radius(1_000_000.0);
}
クエリスコア
Geo3dDistanceQuery1 - distance / radius[0, 1] にクランプ
Geo3dBoundingBoxQueryマッチした全ドキュメントで定数 1.0
Geo3dNearestQuery最も近いヒットが 1.0、返却された集合内で最も遠いヒットが 0.0 となるよう正規化

SpanQuery

ドキュメント内のタームの近接度に基づいてマッチングします。SpanTermQuerySpanNearQuery を使用して近接クエリを構築します。

#![allow(unused)]
fn main() {
use laurus::lexical::query::span::{SpanQuery, SpanTermQuery, SpanNearQuery};

// Find documents where "quick" appears near "fox" (within 3 positions)
let query = SpanNearQuery::new(
    "body",
    vec![
        Box::new(SpanTermQuery::new("body", "quick")) as Box<dyn SpanQuery>,
        Box::new(SpanTermQuery::new("body", "fox")) as Box<dyn SpanQuery>,
    ],
    3,    // slop (max distance between terms)
    true, // in_order (terms must appear in order)
);
}

スコアリング

Lexical 検索結果は BM25 を使用してスコアリングされます。スコアは、ドキュメントがクエリに対してどの程度関連性があるかを反映します。

  • ドキュメント内のターム頻度が高いほどスコアが上昇する
  • インデックス全体でタームが希少なほどスコアが上昇する
  • 短いドキュメントは長いドキュメントに対してブーストされる

フィールドブースト

特定のフィールドをブーストして関連性に影響を与えることができます。

#![allow(unused)]
fn main() {
use laurus::SearchRequestBuilder;
use laurus::lexical::search::searcher::LexicalSearchQuery;

let request = SearchRequestBuilder::new()
    .lexical_query(LexicalSearchQuery::Obj(Box::new(query)))
    .add_field_boost("title", 2.0)  // title matches count double
    .add_field_boost("body", 1.0)
    .build();
}

Lexical 検索オプション(SearchRequestBuilder 経由)

Lexical 検索の動作パラメータは SearchRequestBuilder のメソッドで設定します。これらは SearchRequestlexical_options フィールドに格納されます。

オプションデフォルト説明
field_boostsフィールドごとのスコア倍率
min_score0.0最小スコア閾値
timeout_msNone検索の時間予算(ミリ秒、下記の注記を参照)
parallelfalseセグメント間の並列検索を有効にする
sort_byScore関連性スコアでソート、またはフィールドでソート(asc / desc

timeout_ms を設定すると、時間予算は検索の実行中に協調的に適用されます。スキャンループ (マルチセグメント fanout の各セグメントを含む)が定期的に deadline をチェックし、超過した時点で 即座に中断してタイムアウトエラーを返します(従来のようにクエリを完走してから判定するのではありません)。 チェックはバッチ化されている(数千ドキュメント走査ごと)ため、timeout_ms 未設定時や予算内に収まる 通常ケースでは計測可能なオーバーヘッドはありません。

ビルダーメソッド

SearchRequestBuilder を使用してクエリとオプションを設定します。

#![allow(unused)]
fn main() {
use laurus::SearchRequestBuilder;
use laurus::lexical::TermQuery;
use laurus::lexical::search::searcher::{LexicalSearchQuery, SortField};

let request = SearchRequestBuilder::new()
    .lexical_query(LexicalSearchQuery::Obj(Box::new(TermQuery::new("body", "rust"))))
    .limit(20)
    .lexical_min_score(0.5)
    .lexical_timeout_ms(5000)
    .lexical_parallel(true)
    .sort_by(SortField::FieldDesc("date".to_string()))
    .add_field_boost("title", 2.0)
    .add_field_boost("body", 1.0)
    .build();
}

Query DSL の使用

プログラマティックにクエリを構築する代わりに、テキストベースの Query DSL を使用できます。

#![allow(unused)]
fn main() {
use laurus::lexical::QueryParser;
use laurus::analysis::analyzer::standard::StandardAnalyzer;
use std::sync::Arc;

let analyzer = Arc::new(StandardAnalyzer::default());
let parser = QueryParser::new(analyzer).with_default_field("body");

// Simple term
let query = parser.parse("rust")?;

// Boolean
let query = parser.parse("rust AND programming")?;

// Phrase
let query = parser.parse("\"machine learning\"")?;

// Field-specific
let query = parser.parse("title:rust AND body:programming")?;

// Fuzzy
let query = parser.parse("programing~2")?;

// Range
let query = parser.parse("year:[2020 TO 2024]")?;
}

完全な構文リファレンスは Query DSL を参照してください。

フィルタ結果キャッシュ(Filter Result Cache)

テナント・カテゴリ・ステータスフラグなどの filter 句は、多数のリクエストで繰り返し 再利用されます。毎回ゼロから評価すると同じ posting list を何度も走査することになります。 laurus は filter がマッチするドキュメント ID の集合をメモ化し、繰り返しの filter を posting 走査ではなく単一のルックアップで済ませます。

  • スナップショット連動・自己無効化(snapshot-scoped, self-invalidating)。 キャッシュは reader 上に存在し、reader は commit() / optimize() / refresh() の たびに再構築されます。各 reader は point-in-time スナップショットなので、手動の無効化は 不要です。インデックス変更後の次の検索は空のキャッシュから始まり、常にコミット済みの データを反映します。
  • スコア非依存(score-independent)。 filter は relevance に影響せずドキュメントを 選別するだけなので、キャッシュ値は単なる doc-id 集合(Roaring ビットマップ)です。 ハイブリッド検索 / フィルタ検索filter_query に使われ、 lexical 側・vector 側の両方に供給されます。
  • BooleanQuery 内でも再利用。 BooleanQuery 内の Occur::Filter 句 (例: must(user_query).filter(tenant_filter))も、posting 再走査ではなくキャッシュから マッチ集合を取得します。マルチセグメント時の per-segment fanout 経路でも有効です。
  • 構造的に安全(safe by construction)。 canonical なキーを持つクエリのみキャッシュ されます。Term・Phrase・Prefix・Wildcard・Regexp・Fuzzy・Range・Geo・Geo3d クエリは キャッシュ可能で、キャッシュ可能な句のみで構成され、かつ少なくとも 1 つの正句 (Must / Should / Filter)を持つ BooleanQuery も同様です。正句のない BooleanQuery・ Span クエリ・マルチフィールドクエリは毎回新規評価され(キャッシュされず)、結果は常に 正しくなります。

キャッシュはデフォルトで有効です。インデックス設定で調整・無効化できます。

#![allow(unused)]
fn main() {
use laurus::lexical::store::config::LexicalIndexConfig;

let config = LexicalIndexConfig::builder()
    .query_filter_cache_capacity(4096) // スナップショットあたりのエントリ数。0 で無効化
    .build();
}

パース済みクエリキャッシュ(Parsed Query Cache)

DSL 文字列での検索(SearchRequest::from_dslLexicalSearchQuery::Dsl)は、毎回 pest 文法でパースし、語を analyzer で再トークン化します。オートコンプリートや人気クエリでは同じ 文字列が繰り返されるため、laurus は DSL 文字列 → パース済みクエリ をメモ化します。繰り返し の DSL 文字列は一度だけパースされ、以降は再利用されます(パース済みクエリツリーの安価な複製)。

フィルタキャッシュと同様にスナップショット連動です。キャッシュは searcher 上に存在し、 commit() / optimize() / refresh() のたびに再構築されます。analyzer と default fields は その searcher で固定なので DSL 文字列のみがキーになり、スキーマ/analyzer 変更時は空の新しい キャッシュになります。デフォルトで有効。インデックス設定で調整・無効化できます。

#![allow(unused)]
fn main() {
use laurus::lexical::store::config::LexicalIndexConfig;

let config = LexicalIndexConfig::builder()
    .parsed_query_cache_capacity(2048) // スナップショットあたりのエントリ数。0 で無効化
    .build();
}

Posting キャッシュ(Posting Cache)

語の評価ではセグメントの .post ファイルから posting list を読み、デコードします (varint doc-id、削除フィルタ、skip table)。キャッシュがないと同じ語のクエリごとに read + デコードを繰り返し、クラウド/リモートストレージでは read が支配的になります。各セグメント リーダーはデコード済み・削除フィルタ後の posting list を小さくキャッシュし、スナップショット 内の同一 (field, term) 参照を再利用します。

セグメントはスナップショット内で immutable なので、キャッシュ済みリストは常にその削除と整合 します。commit すると空キャッシュの新しいセグメントリーダーが構築されます。キャッシュは byte-budget で上限制御され(posting list はサイズ分散が大きい)、予算超過で least-recently-used リストを退避し、予算全体より大きい単一リストはキャッシュしません。 デフォルトで有効で max_cache_memory の予算を共有します。インデックス設定で制御できます。

#![allow(unused)]
fn main() {
use laurus::lexical::store::config::LexicalIndexConfig;
use laurus::lexical::index::config::InvertedIndexConfig;

let mut inverted = InvertedIndexConfig::default();
inverted.enable_posting_cache = false;        // 完全に無効化
inverted.max_cache_memory = 256 * 1024 * 1024; // またはキャッシュ予算(バイト)を変更
let config = LexicalIndexConfig::Inverted(inverted);
}

次のステップ

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)スコア結合モード(WeightedSumMaxSimLateInteraction
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クエリ句間の最大類似度スコア
LateInteractionColBERT スタイルの Late Interaction スコアリング

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

複数のクエリベクトル(ColBERT スタイルの late interaction、マルチベクトル MaxSim、ensemble reranker など)を含むリクエストに対して、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 では ^ ブースト構文を使用するか、QueryVectorweight で各フィールドの寄与度を調整します。

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

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

フィールドルーティング

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

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

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

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

フィルター付き 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();
}

距離メトリクス(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(())
}

次のステップ

ハイブリッド検索(Hybrid Search)

ハイブリッド検索は、Lexical 検索(キーワードマッチング)と Vector 検索(意味的類似性)を組み合わせることで、精度と意味的な関連性の両方を兼ね備えた結果を提供します。これは Laurus の最も強力な検索モードです。

なぜハイブリッド検索なのか

検索タイプ強み弱み
Lexical のみ正確なキーワードマッチング、希少なタームに強い同義語や言い換えを見逃す
Vector のみ意味を理解し、同義語に対応正確なキーワードを見逃す場合がある、精度が低い
ハイブリッド両方の長所を活用設定がやや複雑

仕組み

sequenceDiagram
    participant User
    participant Engine
    participant Lexical as LexicalStore
    participant Vector as VectorStore
    participant Fusion

    User->>Engine: SearchRequest\n(lexical + vector)

    par Execute in parallel
        Engine->>Lexical: BM25 keyword search
        Lexical-->>Engine: Ranked hits (by relevance)
    and
        Engine->>Vector: ANN similarity search
        Vector-->>Engine: Ranked hits (by distance)
    end

    Engine->>Fusion: Merge two result sets
    Note over Fusion: RRF or WeightedSum
    Fusion-->>Engine: Unified ranked list
    Engine-->>User: Vec of SearchResult

基本的な使い方

Builder API

#![allow(unused)]
fn main() {
use laurus::{SearchRequestBuilder, FusionAlgorithm};
use laurus::lexical::TermQuery;
use laurus::lexical::search::searcher::LexicalSearchQuery;
use laurus::vector::VectorSearchRequestBuilder;

let request = SearchRequestBuilder::new()
    // Lexical component
    .lexical_query(
        LexicalSearchQuery::Obj(
            Box::new(TermQuery::new("body", "rust"))
        )
    )
    // Vector component
    .vector_query(
        VectorSearchRequestBuilder::new()
            .add_text("text_vec", "systems programming")
            .build()
    )
    // Fusion algorithm
    .fusion_algorithm(FusionAlgorithm::RRF { k: 60.0 })
    .limit(10)
    .build();

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

Query DSL

単一のクエリ文字列内で Lexical 句と Vector 句を混在させることができます。

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

let unified = UnifiedQueryParser::new(
    QueryParser::new(analyzer).with_default_field("body"),
    VectorQueryParser::new(embedder),
);

// Lexical + vector in one query
let request = unified.parse(r#"body:rust text_vec:"systems programming""#).await?;
let results = engine.search(request).await?;
}

Vector 句の識別はスキーマのフィールド型に基づいて行われます。ベクトルフィールドとして定義されたフィールド名を持つ句は Vector クエリとして、それ以外は Lexical クエリとして解析されます。

フュージョンアルゴリズム(Fusion Algorithms)

Lexical と Vector の両方の結果が存在する場合、それらを単一のランキングリストにマージする必要があります。Laurus は 2 つのフュージョンアルゴリズムをサポートしています。

RRF(Reciprocal Rank Fusion)

デフォルトのアルゴリズムです。生のスコアではなく、ランク位置に基づいて結果を結合します。

score(doc) = sum( 1 / (k + rank_i) )

rank_i は各結果リストにおけるドキュメントの位置、k はスムージングパラメータ(デフォルト 60)です。

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

let fusion = FusionAlgorithm::RRF { k: 60.0 };
}

利点:

  • Lexical と Vector の結果間のスコア分布の違いに対してロバスト
  • ウェイトのチューニングが不要
  • すぐに使える(out of the box)

WeightedSum

正規化された Lexical スコアと Vector スコアを線形結合します。

score(doc) = lexical_weight * lexical_score + vector_weight * vector_score
#![allow(unused)]
fn main() {
use laurus::FusionAlgorithm;

let fusion = FusionAlgorithm::WeightedSum {
    lexical_weight: 0.3,
    vector_weight: 0.7,
};
}

使用場面:

  • Lexical と Vector の関連性のバランスを明示的に制御したい場合
  • 一方のシグナルが他方よりも重要であることがわかっている場合

SearchRequest のフィールド

フィールドデフォルト説明
querySearchQueryDsl("")検索クエリ仕様(Dsl / Lexical / Vector / Hybrid)
limitusize10返される結果の最大件数
offsetusize0スキップする結果の数(ページネーション用)
fusion_algorithmOption<FusionAlgorithm>None(ハイブリッド時は RRF { k: 60.0 } を使用)Lexical と Vector の結果をマージする方法
filter_queryOption<Box<dyn Query>>NoneLexical クエリによるプレフィルター(Lexical と Vector の両方の結果を制限)
lexical_optionsLexicalSearchOptionsデフォルトLexical 検索の動作パラメータ
vector_optionsVectorSearchOptionsデフォルトVector 検索の動作パラメータ

SearchResult

各結果には以下が含まれます。

フィールド説明
idString外部ドキュメント ID
scoref32フュージョン後の関連性スコア
documentOption<Document>ドキュメントの全内容(ロードされた場合)

フィルター付きハイブリッド検索

フィルターを適用して、Lexical と Vector の両方の結果を制限できます。

#![allow(unused)]
fn main() {
let request = SearchRequestBuilder::new()
    .lexical_query(
        LexicalSearchQuery::Obj(Box::new(TermQuery::new("body", "rust")))
    )
    .vector_query(
        VectorSearchRequestBuilder::new()
            .add_text("text_vec", "systems programming")
            .build()
    )
    // Only search within "tutorial" category
    .filter_query(Box::new(TermQuery::new("category", "tutorial")))
    .fusion_algorithm(FusionAlgorithm::RRF { k: 60.0 })
    .limit(10)
    .build();
}

フィルタリングの仕組み

  1. フィルタークエリが Lexical インデックス上で実行され、許可されるドキュメント ID のセットが生成される
  2. Lexical 検索: フィルターがユーザークエリとブーリアン AND で結合される
  3. Vector 検索: 許可された ID が ANN 検索の制限として渡される

ページネーション

offsetlimit を使用してページネーションを実現します。

#![allow(unused)]
fn main() {
// Page 1: results 0-9
let page1 = SearchRequestBuilder::new()
    .lexical_query(/* ... */)
    .vector_query(/* ... */)
    .offset(0)
    .limit(10)
    .build();

// Page 2: results 10-19
let page2 = SearchRequestBuilder::new()
    .lexical_query(/* ... */)
    .vector_query(/* ... */)
    .offset(10)
    .limit(10)
    .build();
}

完全な例

use std::sync::Arc;
use laurus::{
    Document, Engine, Schema, SearchRequestBuilder,
    FusionAlgorithm, PerFieldEmbedder,
};
use laurus::lexical::{TextOption, TermQuery};
use laurus::lexical::core::field::IntegerOption;
use laurus::lexical::search::searcher::LexicalSearchQuery;
use laurus::vector::{HnswOption, VectorSearchRequestBuilder};
use laurus::storage::memory::MemoryStorage;

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

    // Schema with both lexical and vector fields
    let schema = Schema::builder()
        .add_text_field("title", TextOption::default())
        .add_text_field("body", TextOption::default())
        .add_text_field("category", TextOption::default())
        .add_integer_field("year", IntegerOption::default())
        .add_hnsw_field("body_vec", HnswOption {
            dimension: 384,
            ..Default::default()
        })
        .build();

    // Configure analyzer and embedder (see Text Analysis and Embeddings docs)
    // let analyzer = Arc::new(StandardAnalyzer::new()?);
    // let embedder = Arc::new(CandleBertEmbedder::new("sentence-transformers/all-MiniLM-L6-v2")?);
    let engine = Engine::builder(storage, schema)
        // .analyzer(analyzer)
        // .embedder(embedder)
        .build()
        .await?;

    // Index documents with both text and vector fields
    engine.add_document("doc-1", Document::builder()
        .add_text("title", "Rust Programming Guide")
        .add_text("body", "Rust is a systems programming language.")
        .add_text("category", "programming")
        .add_integer("year", 2024)
        .add_text("body_vec", "Rust is a systems programming language.")
        .build()
    ).await?;
    engine.commit().await?;

    // Hybrid search: keyword "rust" + semantic "systems language"
    let results = engine.search(
        SearchRequestBuilder::new()
            .lexical_query(
                LexicalSearchQuery::Obj(Box::new(TermQuery::new("body", "rust")))
            )
            .vector_query(
                VectorSearchRequestBuilder::new()
                    .add_text("body_vec", "systems language")
                    .build()
            )
            .fusion_algorithm(FusionAlgorithm::RRF { k: 60.0 })
            .limit(10)
            .build()
    ).await?;

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

    Ok(())
}

次のステップ

Query DSL

Laurus は統合 Query DSL(Domain Specific Language)を提供しており、Lexical(キーワード)検索と Vector(意味的)検索を単一のクエリ文字列で記述できます。UnifiedQueryParser は入力を Lexical 部分と Vector 部分に分割し、適切なサブパーサーに委譲します。

概要

title:hello AND content:"cute kitten"^0.8
|--- lexical --|    |--- vector --------|

Vector 句と Lexical 句の区別は、フィールド名に基づいて行われます。スキーマ上でベクトルフィールドとして定義されたフィールド名が指定された場合、その句は Vector クエリとして扱われます。

フィールド検証

クエリ中の field:value 句は、パース時にスキーマと照合されます。スキーマに宣言されていないフィールドを参照したクエリは、結果が黙って空になるのではなく、エラーとして返されます。これにより typo(例: title:hello のつもりで titl:hello)を早期に検出できます。

未知のフィールドを含むドキュメントを受け付けたい場合は、スキーマの dynamic_field_policy を設定して、投入時にフィールドを追加する動作を有効にしてください。一度フィールドがスキーマに登録されれば、それを参照するクエリは正常に動作します。

Lexical クエリ構文

Lexical クエリは、完全一致または近似のキーワードマッチングを使用して転置インデックスを検索します。

Term クエリ

フィールド(またはデフォルトフィールド)に対して単一のタームをマッチングします。

hello
title:hello

ブーリアン演算子

ANDOR(大文字小文字を区別しない)で句を結合します。

title:hello AND body:world
title:hello OR title:goodbye

AND は対称的に動作します。両側の句を必須(Must)にマークするため、title:hello AND body:world両方 の句にマッチするドキュメントだけを返します。連鎖(a AND b AND c)でも 3 つすべてが必須となります。ただし +(必須)または -(禁止)が明示されている句は元の意図が維持され、AND で上書きされることはありません。

明示的な演算子なしでスペース区切りされた句は、暗黙的なブーリアン(スコアリング付きの OR として動作)を使用します。例えば a b AND c は「a は任意、bc の両方が必須」と解釈されます。

必須 / 禁止句

+(必ずマッチ)と -(マッチ禁止)を使用します。

+title:hello -title:goodbye

フレーズクエリ

ダブルクォートを使用して正確なフレーズをマッチングします。オプションの近接度(~N)で、ターム間に N 語を許可します。

"hello world"
"hello world"~2

ファジークエリ

編集距離を使用した近似マッチング。~ に続けてオプションで最大編集距離を指定します。

roam~
roam~2

ワイルドカードクエリ

?(1 文字)と *(0 文字以上)を使用します。

te?t
test*

範囲クエリ

包含的な [] または排他的な {} の範囲指定。数値フィールドや日付フィールドに有用です。

price:[100 TO 500]
date:{2024-01-01 TO 2024-12-31}
price:[* TO 100]

2D 地理クエリ(geo_*)

2 種類の関数形式を Geo(2D 緯度 / 経度)フィールドに対して使えます。 緯度・経度は度単位、距離はメートル単位の符号付き浮動小数点数:

location:geo_distance(lat, lon, distance_m)
location:geo_bbox(min_lat, min_lon, max_lat, max_lon)
形式動作
geo_distance(lat, lon, distance_m)中心 (lat, lon) から distance_m メートル以内の格納済み座標を返す
geo_bbox(min_lat, min_lon, max_lat, max_lon)軸並行緯度・経度範囲に含まれる格納済み座標を返す

例:

# 東京から 10 km(= 10 000 m)以内(35.6895, 139.6917)
location:geo_distance(35.6895, 139.6917, 10000)

# 軸並行緯度・経度範囲
location:geo_bbox(35.0, 139.0, 36.0, 140.0)

クエリ対象フィールドはスキーマで Geo フィールドとして宣言されている必要が あります。緯度は [-90, 90]、経度は [-180, 180] の範囲外を拒否します。

3D 地理クエリ(geo3d_*

3 種類の関数形式を Geo3d(3D ECEF 直交座標)フィールドに対して使えます。 k 以外の引数はすべてメートル単位の符号付き浮動小数点数、k のみ符号なし整数:

position:geo3d_distance(x, y, z, distance_m)
position:geo3d_bbox(min_x, min_y, min_z, max_x, max_y, max_z)
position:geo3d_nearest(x, y, z, k)
形式動作
geo3d_distance(x, y, z, distance_m)(x, y, z) から distance_m メートル以内の格納済みポイントを返す
geo3d_bbox(min_x, min_y, min_z, max_x, max_y, max_z)軸並行 3D ボックスに含まれる格納済みポイントを返す
geo3d_nearest(x, y, z, k)(x, y, z) に最も近い k 件をユークリッド距離順で返す

例:

# 東京タワーから 5 km 以内(ECEF 座標)
position:geo3d_distance(-3955182, 3350553, 3700276, 5000)

# 軸並行 ECEF バウンディングボックス内
position:geo3d_bbox(-4000000, 3300000, 3650000, -3900000, 3400000, 3750000)

# 最も近い 10 件
position:geo3d_nearest(-3955182, 3350553, 3700276, 10)

クエリ対象フィールドはスキーマで Geo3d として宣言されている必要があります。 座標系・WGS84 変換ヘルパー・詳細な意味論については 3D 地理検索 を参照してください。

ブースト

^ で句のウェイトを増加させます。

title:hello^2
"important phrase"^1.5

グルーピング

括弧でサブ式を囲みます。

(title:hello OR title:hi) AND body:world

Lexical PEG 文法

完全な Lexical 文法(parser.pest):

query          = { SOI ~ boolean_query ~ EOI }
boolean_query  = { clause ~ (boolean_op ~ clause | clause)* }
clause         = { required_clause | prohibited_clause | sub_clause }
required_clause   = { "+" ~ sub_clause }
prohibited_clause = { "-" ~ sub_clause }
sub_clause     = { grouped_query | field_query | term_query }
grouped_query  = { "(" ~ boolean_query ~ ")" ~ boost? }
boolean_op     = { ^"AND" | ^"OR" }
field_query    = { field ~ ":" ~ field_value }
field_value    = { geo3d_query | geo_query | range_query | phrase_query
                 | fuzzy_term | wildcard_term | simple_term }
geo3d_query    = { geo3d_distance | geo3d_bbox | geo3d_nearest }
geo3d_distance = { ^"geo3d_distance" ~ "(" ~ signed_float ~ "," ~ signed_float
                 ~ "," ~ signed_float ~ "," ~ signed_float ~ ")" }
geo3d_bbox     = { ^"geo3d_bbox" ~ "(" ~ signed_float ~ "," ~ signed_float
                 ~ "," ~ signed_float ~ "," ~ signed_float ~ ","
                 ~ signed_float ~ "," ~ signed_float ~ ")" }
geo3d_nearest  = { ^"geo3d_nearest" ~ "(" ~ signed_float ~ "," ~ signed_float
                 ~ "," ~ signed_float ~ "," ~ unsigned_int ~ ")" }
geo_query      = { geo_distance | geo_bbox }
geo_distance   = { ^"geo_distance" ~ "(" ~ signed_float ~ "," ~ signed_float
                 ~ "," ~ signed_float ~ ")" }
geo_bbox       = { ^"geo_bbox" ~ "(" ~ signed_float ~ "," ~ signed_float
                 ~ "," ~ signed_float ~ "," ~ signed_float ~ ")" }
phrase_query   = { "\"" ~ phrase_content ~ "\"" ~ proximity? ~ boost? }
proximity      = { "~" ~ number }
fuzzy_term     = { term ~ "~" ~ fuzziness? ~ boost? }
wildcard_term  = { wildcard_pattern ~ boost? }
simple_term    = { term ~ boost? }
boost          = { "^" ~ boost_value }

Vector クエリ構文

Vector クエリは、解析時にテキストをベクトルにエンベディングし、類似性検索を実行します。

基本構文

field:"text"
field:text
field:"text"^weight
要素必須説明
field:はい対象のベクトルフィールド名(スキーマでベクトルフィールドとして定義されている必要があります)content:
"text" または textはいエンベディングするテキスト(クォート付きまたはクォートなし)"cute kitten"python
^weightいいえスコアウェイト(デフォルト: 1.0)^0.8

Vector クエリの例

# Single field (quoted text)
content:"cute kitten"

# Single field (unquoted text)
content:python

# With boost weight
content:"cute kitten"^0.8

# Multiple clauses
content:"cats" image:"dogs"^0.5

# Nested field name (dot notation)
metadata.embedding:"text"

複数句

複数の Vector 句はスペースで区切ります。すべての句が実行され、スコアは score_mode(デフォルト: WeightedSum)を使用して結合されます。

content:"cats" image:"dogs"^0.5

この場合のスコア計算:

score = similarity("cats", content) * 1.0
      + similarity("dogs", image)   * 0.5

Vector DSL には AND/OR 演算子はありません。Vector 検索は本質的にランキング操作であり、ウェイト(^)が各句の寄与度を制御します。

スコアモード

モード説明
WeightedSum(デフォルト)すべてのクエリ句にわたる(類似度 * ウェイト)の合計
MaxSimクエリ句間の最大類似度スコア
LateInteractionLate Interaction スコアリング

スコアモードは DSL 構文からは設定できません。Rust API を使用してオーバーライドします。

#![allow(unused)]
fn main() {
let mut request = parser.parse(r#"content:"cats" image:"dogs""#).await?;
request.score_mode = VectorScoreMode::MaxSim;
}

Vector PEG 文法

完全な Vector 文法(parser.pest):

query          = { SOI ~ vector_clause+ ~ EOI }
vector_clause  = { field_prefix ~ (quoted_text | unquoted_text) ~ boost? }
field_prefix   = { field_name ~ ":" }
field_name     = @{ (ASCII_ALPHA | "_") ~ (ASCII_ALPHANUMERIC | "_" | ".")* }
quoted_text    = ${ "\"" ~ inner_text ~ "\"" }
unquoted_text  = @{ (!(WHITE_SPACE | "^" | "\"") ~ ANY)+ }
inner_text     = @{ (!("\"") ~ ANY)* }
boost          = { "^" ~ float_value }
float_value    = @{ ASCII_DIGIT+ ~ ("." ~ ASCII_DIGIT+)? }

統合(ハイブリッド)クエリ構文

UnifiedQueryParser を使用すると、単一のクエリ文字列内で Lexical 句と Vector 句を自由に混在させることができます。

title:hello content:"cute kitten"^0.8

仕組み

  1. 分割(Split): スキーマのフィールド型に基づいて、各句が Lexical か Vector かを判定する。ベクトルフィールドとして定義されたフィールド名を持つ句は Vector 句として抽出される
  2. 委譲(Delegate): Vector 部分は VectorQueryParser に、残りは Lexical の QueryParser に渡される
  3. フュージョン(Fuse): Lexical と Vector の両方の結果が存在する場合、フュージョンアルゴリズムで結合される

曖昧性の解消

Vector 句と Lexical 句の区別は、スキーマのフィールド型に基づいて行われます。フィールド名がスキーマ上でベクトルフィールド(HNSW、Flat、IVF など)として定義されている場合、その句は Vector クエリとして扱われます。Lexical 構文の ~(例: roam~2"hello world"~10)はファジークエリや近接度クエリとして引き続き正しく解析されます。

フュージョンアルゴリズム

クエリに Lexical 句と Vector 句の両方が含まれる場合、結果はフュージョンされます。

アルゴリズム計算式説明
RRF(デフォルト)score = sum(1 / (k + rank))Reciprocal Rank Fusion。異なるスコア分布に対してロバスト。デフォルト k=60。
WeightedSumscore = lexical * a + vector * b設定可能なウェイトによる線形結合。

注意: フュージョンアルゴリズムは DSL 構文では指定できません。UnifiedQueryParser の構築時に .with_fusion() で設定します。デフォルトは RRF(k=60)です。コード例はカスタムフュージョンを参照してください。

ハイブリッド AND/OR セマンティクス(+ プレフィックス)

デフォルトでは、ハイブリッドクエリは union(OR) を使用します。Lexical 結果または Vector 結果のいずれかに含まれるドキュメントが返されます。Vector 句に + プレフィックスを付けると intersection(AND) に切り替わり、両方の結果セットに存在するドキュメントのみが返されます。

構文モード動作
title:Rust content:"system process"OR(union)Lexical クエリまたは Vector クエリにマッチするドキュメントが返される
title:Rust +content:"system process"AND(intersection)Lexical と Vector の両方にマッチするドキュメントのみが返される
+title:Rust +content:"system process"AND(intersection)両方の句が必須。Lexical フィールドの + は既存の required clause として処理される

ルール:

  • Vector 句に + プレフィックスがない場合、フュージョンは Lexical と Vector の結果を union(OR) で結合します
  • 1 つでも Vector 句に + プレフィックスがある場合、フュージョンは intersection(AND) に切り替わり、Lexical と Vector の両方の結果セットに存在するドキュメントのみが返されます
  • Lexical フィールドの +(例: +title:Rust)は、Lexical クエリパーサーによって required clause(必須句)として解釈されます。これは既存の Tantivy/Lucene スタイルの動作であり、それ自体ではハイブリッドフュージョンの intersection モードをトリガーしません

統合クエリの例

# Lexical only — no fusion
title:hello AND body:world

# Vector only — no fusion
content:"cute kitten"

# Vector only — unquoted text
content:python

# Hybrid — fusion applied automatically (OR / union)
title:hello content:"cute kitten"

# Hybrid with AND / intersection — both result sets required
title:hello +content:"cute kitten"

# Hybrid with boolean operators
title:hello AND category:animal content:"cute kitten"^0.8

# Multiple vector clauses + lexical
category:animal content:"cats" image:"dogs"^0.5

コード例

DSL による Lexical 検索

#![allow(unused)]
fn main() {
use std::sync::Arc;
use laurus::analysis::analyzer::standard::StandardAnalyzer;
use laurus::lexical::query::QueryParser;

let analyzer = Arc::new(StandardAnalyzer::new()?);
let parser = QueryParser::new(analyzer)
    .with_default_field("title");

let query = parser.parse("title:hello AND body:world")?;
}

DSL による Vector 検索

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

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

let request = parser.parse(r#"content:"cute kitten"^0.8"#).await?;
}

統合 DSL によるハイブリッド検索

#![allow(unused)]
fn main() {
use laurus::engine::query::UnifiedQueryParser;

let unified = UnifiedQueryParser::new(lexical_parser, vector_parser);

let request = unified.parse(
    r#"title:hello content:"cute kitten"^0.8"#
).await?;
// request.query  -> SearchQuery::Hybrid { lexical: ..., vector: ... }
// request.fusion_algorithm  -> Some(RRF)  — fusion algorithm
}

カスタムフュージョン

#![allow(unused)]
fn main() {
use laurus::engine::search::FusionAlgorithm;

let unified = UnifiedQueryParser::new(lexical_parser, vector_parser)
    .with_fusion(FusionAlgorithm::WeightedSum {
        lexical_weight: 0.3,
        vector_weight: 0.7,
    });
}

BKD-Tree

Laurus は数値・日時・地理ポイントなどのデータを BKD-Tree (Block KD-Tree) に格納する。BKD-Tree はディスク常駐の多次元インデックスで、レンジ・バウンディング ボックス・距離・k 近傍 (k-NN) の各クエリを単一のファイル形式で扱える。

「空間的な形」を持つあらゆるフィールド型はこの BKD プリミティブを共有する:

フィールド型次元数座標空間
Integer / Float(単一値・多値)1スカラー
DateTime1Unix マイクロ秒(UTC)
Geo2緯度・経度(度)
Geo3d3ECEF 直交座標(メートル)

新しい空間フィールド型を追加する作業は、次元数を選んでクエリ側の IntersectVisitor を書くだけに帰着する。 ライタ・リーダ・オンディスクレイアウトはそのまま再利用される。

ファイルフォーマット (Version 2)

.bkd セグメントファイルは自己完結型のバイナリで、3 つの領域から成る:

+----------------------------------------+
| File Header                            |   固定長・バージョンタグ付き
+----------------------------------------+
| Leaf Blocks                            |   row-major points + doc_ids
|   leaf 0                               |
|   leaf 1                               |
|   ...                                  |
+----------------------------------------+
| Index Nodes                            |   内部ナビゲーションノード
|   node N-1                             |
|   ...                                  |
|   node 0  (root, written last)         |
+----------------------------------------+

ヘッダ (BKDFileHeader) は magicversion(現在は 2)、num_dimsbytes_per_dim、総ポイント数、リーフブロック数、全体の軸ごと min/max、 インデックス領域とルートノードへのオフセットを保持する。

Leaf Block レイアウト

各リーフブロックは、その部分木に属するポイントを格納する:

count               u32                       — リーフ内のポイント数
leaf_min            [f64; num_dims]           — リーフレベルの AABB 下端
leaf_max            [f64; num_dims]           — リーフレベルの AABB 上端
point_values        [f64; count * num_dims]   — row-major のポイント座標
doc_ids             [u64; count]              — ドキュメント ID の並列配列

リーフごとに AABB を持たせることで、クエリ領域がリーフの外側にある場合 (Outside) や全内包される場合 (Inside) には、ポイントを 1 つも読まずに リーフ全体を判定できる。

内部 Index Node レイアウト

内部ノードは分割情報に加え、子ごとの AABB も保持する:

split_dim           u32                       — 分割する軸
split_value         f64                       — 分割しきい値
left_min            [f64; num_dims]           — 左部分木の AABB 下端
left_max            [f64; num_dims]           — 左部分木の AABB 上端
right_min           [f64; num_dims]           — 右部分木の AABB 下端
right_max           [f64; num_dims]           — 右部分木の AABB 上端
left_offset         u64                       — 左子ノードのファイルオフセット
right_offset        u64                       — 右子ノードのファイルオフセット

ノードごとの AABB(v2 で追加)は、分割値だけを持っていた v1 レイアウトを 置き換える。これにより Inside / Outside の枝刈りが、再帰的な 探索ではなく定数時間の矩形判定で済むようになった。

ビルドアルゴリズム

BKDWriter::write は、平坦な row-major のポイントバッファと並列の doc_ids バッファからツリーを構築する。構築は 最も広い軸で分割する (widest-axis split) ヒューリスティクスで進む:

  1. 入力部分集合の AABB を計算する。
  2. (max - min) レンジが最も広い軸を選ぶ(同点時は次元番号の小さい方を 採用して決定的にする)。
  3. インデックスの並びをその軸でソートし、中央値で分割する。
  4. 部分木が block_size(既定 512)以下のポイント数になるまで再帰し、 そうなったらリーフとして書き出す。
  5. 子が flush された後、各親の left_offset / right_offset を後埋めする。

ビルダはポイント/doc_id バッファ自体ではなくインデックスの並び (permutation)をソートするため、ポイント数に関わらずポイント単位の ヒープ確保を一切おこなわない。

数値ロバスト性

座標は全順序で比較できる必要がある。BKDWriter::writeNaN を明示的に 拒否する。NaN には順序が定義されておらず、分割判定とノードごとの AABB 不変条件を破壊するためである。±INFINITY は両方とも受理され、クエリでは 「無限大」を表す自然なセンチネルとして機能する。

IntersectVisitor プロトコル

BKD インデックスへのクエリは IntersectVisitor の実装として表現する。リーダはツリーを辿りながら、ビジタに 3 種類の 情報を尋ねる:

#![allow(unused)]
fn main() {
pub enum CellRelation {
    Inside,   // 部分木全体がヒット — ポイント単位の照合不要
    Outside,  // 部分木全体をスキップできる
    Crosses,  // 再帰、もしくはリーフをポイント単位で照合
}

pub trait IntersectVisitor {
    fn compare(&self, cell: &AABB) -> CellRelation;
    fn visit_inside(&mut self, doc_id: u64);
    fn visit(&mut self, doc_id: u64, point: &[f64]);
}
}

リーダのトラバーサルは次のように進む:

graph TD
    A["compare(node.aabb)"]
    A -->|Inside| B["部分木の各 doc_id を visit_inside(doc_id) で報告<br/>(座標は読まない)"]
    A -->|Outside| C["部分木をスキップ"]
    A -->|Crosses, 内部ノード| D["子ノードへ再帰"]
    A -->|Crosses, リーフ| E["各ポイントについて visit(doc_id, point)<br/>ヒット判定はビジタに委ねる"]

この 3 値分類こそが枝刈りを実現する鍵である。常に Crosses を返すビジタを 書いても結果は正しい — 単にリーフ全件走査に退化するだけだ。

レンジクエリ

レガシーの BKDTree::range_search API は intersect の薄いラッパに なっている。RangeQueryVisitor を半開区間/閉区間のパラメータから組み立て、 無限大の None スロットを ±INFINITY に変換する。境界の包含・排他は ビジタ自身が処理する。

3D 地理クエリ

3 つのビジタが laurus::lexical::query::geo3d に存在し、 Geo3d (3D ECEF) フィールドをターゲットにする:

クエリcompare の判定領域visit の点ごとの判定
Geo3dDistanceQuery(centre, radius) と AABBユークリッド距離 ≤ radius
Geo3dBoundingBoxQueryクエリ AABB と セル AABB点がクエリ AABB に内包
Geo3dNearestQuery (k-NN)クエリ点を中心に拡大していく球距離 ≤ 現在の k 番目最良値

同じプリミティブで将来のあらゆる空間クエリ(ポリゴンクエリや 2D Geo の大円距離クエリなど)を新しいビジタとして実装できる。

リーダの内部実装

BKDReader::intersect は 1 クエリにつき 1 つのスクラッチバッファ (IntersectScratch) を使う。バッファは出会った最大のリーフサイズまで 拡大されたあと、後続のリーフでも再利用される。結果として、何枚のリーフを 辿っても、1 クエリの間にアロケータに触れる回数はごく少数で済む。

単一リーフだけのツリー(非常に小さなフィールド)は特別扱いされる: 「ルートオフセット」がそのまま唯一のリーフを指すため、内部ノードの 降下処理は完全にスキップされる。

関連項目

  • 3D 地理検索 (ECEF) — ECEF 距離・バウンディング ボックス・k-NN を実装した具体的な BKD ベースのビジタ。
  • Lexical インデクシング.bkd セグメントファイルがセグメント全体のレイアウト内のどこに位置するか。
  • Lexical 検索NumericRangeQueryGeoQueryGeo3dDistanceQuery の Rust API エントリポイント。

3D 地理検索 (ECEF)

Laurus は用途の異なる 2 種類の地理フィールド型を提供する:

フィールド型バックエンド構造座標系こんなときに
Geo2D BKD-TreeWGS84 緯度・経度(度)データが地表のみで、高度を考慮しなくてよい場合
Geo3d3D BKD-TreeECEF 直交座標 (x, y, z)(メートル)高度が一級の次元になる用途(ドローン・衛星・屋内測位・複数フロア建物)

両者は同じ BKD-Tree プリミティブを共有しており、Geo3d は単に第 3 の次元と異なるクエリ語彙を加えただけである。

なぜ ECEF なのか

(緯度, 経度, 高度) の組は人間にとっては便利だが、ユークリッド距離が そのままでは使えない:経度 1 度は赤道で約 111 km だが極では 0 km、 そして「高度」は地球表面に沿って曲がっている。

ECEF (Earth-Centered Earth-Fixed) はこれを直交座標系にフラット化する:

  • 原点は地球の質量中心。
  • +X 軸は赤道上の経度 0° を貫く。
  • +Y 軸は赤道上の経度 90° E を貫く。
  • +Z 軸は地理北極を貫く。
  • 3 軸とも単位はメートル

この座標系では 2 点間の直線距離が単なるユークリッドノルムになる — 球面三角法も極特異点も経度のラップアラウンドも要らない。これこそが、 Geo3d クエリを 3D BKD-Tree 上で(特殊な空間コードなしで)使える 理由である。

Geo3d フィールドの定義

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

let schema = Schema::builder()
    .add_geo3d_field("position", Geo3dOption::default())
    .build();
}

Geo3dOption は他の lexical フィールドオプションと同じく indexed / stored を提供する。インデックス済みの値はフィールドの 3D BKD-Tree に格納され、stored な値は get_documents で返ってくる。

TOML 形式(laurus-cli で使用):

[fields.position.Geo3d]
indexed = true
stored = true

3D ポイントのインデクシング

DocumentBuilder::add_geo_ecef を使って、メートル単位の生の直交座標で ポイントを追加する:

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

// (lat=35.6586°, lon=139.7454°, height=250m) のポイント
// 既に ECEF へ変換済み(後述「座標変換」参照)。
let doc = Document::builder()
    .add_geo_ecef("position", -3_955_182.0, 3_350_553.0, 3_700_276.0)
    .build();

engine.put_document("tokyo-tower", doc).await?;
engine.commit().await?;
}

値が既に GeoEcefPoint として手元にある場合は、統一 DataValue API を 使う:

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

let p = GeoEcefPoint::new(-3_955_182.0, 3_350_553.0, 3_700_276.0);
let doc = Document::builder()
    .add_field("position", DataValue::GeoEcef(p))
    .build();
}

座標変換 (WGS84 ↔ ECEF)

入力は通常 (緯度°, 経度°, 高度 m) でやって来る。Laurus は laurus::util::ecef で標準的な相互変換を提供する:

#![allow(unused)]
fn main() {
use laurus::util::ecef::{wgs84_to_ecef, ecef_to_wgs84};

// 順変換: lat/lon/height → ECEF
let p = wgs84_to_ecef(35.6586, 139.7454, 250.0);

// 逆変換: ECEF → lat/lon/height
let (lat, lon, height) = ecef_to_wgs84(&p);
}
関数方向アルゴリズム
wgs84_to_ecef(lat°, lon°, h_m)地理 → 直交卯酉線(prime vertical)の閉形式公式
ecef_to_wgs84(&GeoEcefPoint)直交 → 地理Bowring 1985 の閉形式を初期値にした Newton-Raphson 3 反復

逆変換は地表下から LEO 軌道高度をはるかに超える領域まで、各軸で サブ µm の精度で往復する。極付近では、cos(lat) → 0 で発散する 標準形 h = p / cos(lat) - N の代わりに、子午線形 h = z / sin(lat) - N(1 - e²) に切り替える。

内部で使う WGS84 楕円体定数は pub const として公開されている (WGS84_A, WGS84_F, WGS84_B, WGS84_E2, WGS84_E_PRIME_SQ)ので、 外部コードからも laurus が使っているのと同じ値を参照できる。

クエリ種別

Geo3d フィールドを対象とするクエリは 3 種類ある。いずれも 3D BKD-Tree を共有する IntersectVisitor の実装である。

球(半径) — Geo3dDistanceQuery

中心点から distance_m メートル以内に格納済みポイントが入っているドキュメントを すべて返す。スコアは 1 - distance / radius[0, 1] にクランプした 値。

#![allow(unused)]
fn main() {
use laurus::lexical::query::geo3d::Geo3dDistanceQuery;
use laurus::GeoEcefPoint;

let centre = GeoEcefPoint::new(-3_955_182.0, 3_350_553.0, 3_700_276.0);
let query = Geo3dDistanceQuery::new("position", centre, 5_000.0); // 5 km 半径
}

ビジタは Outside 判定に AABB::min_distance_sq_to_pointInside 判定に AABB::max_distance_sq_to_point を使う。どちらも 2 乗距離空間で比較するため、sqrt を回避できる。

バウンディングボックス — Geo3dBoundingBoxQuery

(min_x, min_y, min_z)(max_x, max_y, max_z) で定義された軸並行 3D ボックスにポイントが入っているドキュメントを返す。

#![allow(unused)]
fn main() {
use laurus::lexical::query::geo3d::Geo3dBoundingBoxQuery;
use laurus::GeoEcefPoint;

let min = GeoEcefPoint::new(-4_000_000.0, 3_300_000.0, 3_650_000.0);
let max = GeoEcefPoint::new(-3_900_000.0, 3_400_000.0, 3_750_000.0);
let query = Geo3dBoundingBoxQuery::new("position", min, max)?;
}

コンストラクタが Result を返すのは、すべての軸で min[i] ≤ max[i] を 満たす必要があるため。境界がおかしい場合は構築時点で拒否され、 「黙って空結果」を防げる。

注意点。 クエリボックスは ECEF 直交座標で軸並行になっている。 (lat, lon, height) の 2 つの隅をそれぞれ ECEF に変換して作った ボックスは、ユーザーが直感的に思い描く球殻領域とは違う形をしている。 地表上の領域クエリを書くなら、Geo3dDistanceQuery(真の 3D 球)の方が 多くの場合ユーザー直感に近い。

k 近傍 — Geo3dNearestQuery

クエリ点に最も近い k 件のドキュメントを返す。クエリは小さなプローブ球 (既定 1 km)から始め、以下のいずれかを満たすまで半径を倍加していく:

  1. 重複なしで k 件以上の候補を集めた、
  2. 倍加しても何も新しいヒットが見つからなくなった、
  3. 半径が max_radius_m(既定 1.0e10 m=10⁷ km)に達した。
#![allow(unused)]
fn main() {
use laurus::lexical::query::geo3d::Geo3dNearestQuery;
use laurus::GeoEcefPoint;

let centre = GeoEcefPoint::new(-3_955_182.0, 3_350_553.0, 3_700_276.0);
let query = Geo3dNearestQuery::new("position", centre, 10) // 上位 10 件
    .with_initial_radius(500.0)         // 500 m から開始
    .with_max_radius(1_000_000.0);      // 1 000 km で打ち切り
}

結果が k 件未満になるのは、単に max_radius_m 以内に k 件のポイントが 存在しないことを意味する。スコアは「最も近いヒットが 1.0、返却された 集合内で最も遠いヒットが 0.0」となるよう正規化される。

k-NN ビジタは compare から CellRelation::Inside を返さない — OutsideCrosses のみ — ので、すべての候補がその座標と一緒に visit へ届けられ、k-NN の順序付けに真の距離を使える。

Query DSL

Geo3d クエリは統一 Query DSL からも使える(Query DSL → Geo3d 関数 を参照):

position:geo3d_distance(-3955182, 3350553, 3700276, 5000)
position:geo3d_bbox(-4000000, 3300000, 3650000, -3900000, 3400000, 3750000)
position:geo3d_nearest(-3955182, 3350553, 3700276, 10)
形式引数
geo3d_distance(x, y, z, distance_m)中心 (x, y, z) と最大距離(m)
geo3d_bbox(min_x, min_y, min_z, max_x, max_y, max_z)AABB の対角の 2 隅
geo3d_nearest(x, y, z, k)中心 (x, y, z) と整数 k

数値引数はすべて符号付き浮動小数。geo3d_nearestk のみ符号なし整数。

ワイヤーフォーマット

gRPC

Protocol Buffers では、3D 地理を専用の Value バリアント、Geo3dPoint メッセージ、Geo3dOption スキーマオプションとして公開している:

message Geo3dPoint {
  double x = 1;
  double y = 2;
  double z = 3;
}

message Value {
  oneof kind {
    // ...
    Geo3dPoint geo3d_value = 12;
  }
}

message Geo3dOption {
  bool indexed = 1;
  bool stored = 2;
}

完全な定義は common.protoindex.proto を参照。

HTTP gateway / MCP

JSON ドキュメントでは Geo3d 値を { "x": …, "y": …, "z": … } の オブジェクトとして受け付ける:

{
  "position": { "x": -3955182.0, "y": 3350553.0, "z": 3700276.0 }
}

HTTP gateway はこれをエンジンへ転送する前に DataValue::GeoEcef に変換する。 MCP サーバーも同じ規約に従う。

Geo3d を使うべきでないケース

  • 純粋な 2D 地図クエリ(地表上の座標からの半径検索、タイル地図上の 単純なバウンディングボックスなど) — 引き続き Geo を使うこと。 2D BKD はひとつ次元が軽く、WGS84 入力もユーザー直感に近い。
  • 学習済みロケーション埋め込みの近似的な意味類似度 — それはベクトル フィールド(Hnsw, Flat, Ivf)の領分であって、Geo3d ではない。

関連項目

  • BKD-TreeInteger, Float, DateTime, 2D Geo も含めて支える、共通の多次元インデックス。
  • スキーマとフィールドGeo3d を含む フィールド型の一覧。
  • Query DSL — geo3d DSL 文法の詳細。
  • Lexical 検索 — Lexical クエリ全般の Rust API エントリポイント。
  • laurus-wasm/examples/geo3d/ — ブラウザ向けデモ。CesiumJS の 3D 地球儀上にリアルタイム航空機位置を プロットし、geo3d_bboxgeo3d_nearest を実演します。

ライブラリ概要

laurus クレートは検索エンジンのコアライブラリです。Lexical検索(転置インデックス(Inverted Index)によるキーワードマッチング)、Vector検索(Embeddingによるセマンティック類似度検索)、およびハイブリッド検索(両者の組み合わせ)を統一的なAPIで提供します。

モジュール構成

graph TB
    LIB["laurus (lib.rs)"]

    LIB --> engine["engine\nEngine, EngineBuilder\nSearchRequest, FusionAlgorithm"]
    LIB --> analysis["analysis\nAnalyzer, Tokenizer\nToken Filters, Char Filters"]
    LIB --> lexical["lexical\nInverted Index, BM25\nQuery Types, Faceting, Highlighting"]
    LIB --> vector["vector\nFlat, HNSW, IVF\nDistance Metrics, Quantization"]
    LIB --> embedding["embedding\nCandle BERT, OpenAI\nCLIP, Precomputed"]
    LIB --> storage["storage\nMemory, File, Mmap\nColumnStorage"]
    LIB --> store["store\nDocumentLog (WAL)"]
    LIB --> spelling["spelling\nSpelling Correction\nSuggestion Engine"]
    LIB --> util["util\n共通ヘルパとマクロ"]
    LIB --> data["data\nDataValue, Document"]
    LIB --> error["error\nLaurusError, Result"]

主要な型

モジュール説明
EngineengineLexical検索とVector検索を統合する検索エンジン
EngineBuilderengineEngineの設定・構築を行うBuilderパターン
Schemaengineフィールド定義とルーティング設定
SearchRequestengine統一的な検索リクエスト(Lexical、Vector、またはハイブリッド)
FusionAlgorithmengine結果マージ戦略(RRFまたはWeightedSum)
Documentdata名前付きフィールド値のコレクション
DataValuedataすべてのフィールド型に対応する統一的な値のenum
LaurusErrorerror各サブシステムのバリアントを含む包括的なエラー型

Feature Flag

laurus クレートはデフォルトではFeatureが有効化されていません。必要に応じてEmbeddingサポートを有効にしてください。

Feature説明依存クレート
embeddings-candleHugging Face CandleによるローカルBERT Embeddingcandle-core, candle-nn, candle-transformers, hf-hub, tokenizers
embeddings-openaiOpenAI API Embeddingreqwest
embeddings-multimodalCLIPマルチモーダルEmbedding(テキスト + 画像)image, embeddings-candle
embeddings-allすべてのEmbedding Featureを含む上記すべて
# Lexical検索のみ(Embeddingなし)
[dependencies]
laurus = "0.9"

# ローカルBERT Embeddingを使用
[dependencies]
laurus = { version = "0.9", features = ["embeddings-candle"] }

# すべてのFeatureを有効化
[dependencies]
laurus = { version = "0.9", features = ["embeddings-all"] }

セクション

Engine

Engine はLaurusの中心的な型です。Lexicalインデックス、Vectorインデックス、およびドキュメントログを単一の非同期APIで統合します。

Engine構造体

#![allow(unused)]
fn main() {
pub struct Engine {
    schema: Schema,
    lexical: LexicalStore,
    vector: VectorStore,
    log: Arc<DocumentLog>,
}
}
フィールド説明
schemaSchemaフィールド定義とルーティングルール
lexicalLexicalStoreキーワード検索用の転置インデックス(Inverted Index)
vectorVectorStore類似度検索用のベクトルインデックス
logArc<DocumentLog>クラッシュリカバリとドキュメント保存のためのWrite-Ahead Log

EngineBuilder

EngineBuilder を使用してEngineを設定・構築します。

#![allow(unused)]
fn main() {
use std::sync::Arc;
use laurus::{Engine, Schema};
use laurus::lexical::TextOption;
use laurus::storage::memory::MemoryStorage;

let storage = Arc::new(MemoryStorage::new(Default::default()));
let schema = Schema::builder()
    .add_text_field("title", TextOption::default())
    .add_text_field("body", TextOption::default())
    .add_default_field("body")
    .build();

let engine = Engine::builder(storage, schema)
    .analyzer(my_analyzer)            // オプション: カスタムテキストAnalyzer
    .embedder(my_embedder)            // オプション: ベクトルEmbedder
    .embedding_cache_capacity(1024)   // オプション: クエリEmbeddingをキャッシュ
    .build()
    .await?;
}

Builderメソッド

メソッドパラメータデフォルト説明
analyzer()Arc<dyn Analyzer>StandardAnalyzerLexicalフィールド用のテキスト解析パイプライン
embedder()Arc<dyn Embedder>NoneVectorフィールド用のEmbeddingモデル
embedding_cache_capacity()usizeNone(無効)最大 N 件のクエリEmbeddingをLRUキャッシュする
build()Engineを構築(非同期)

クエリEmbeddingキャッシュ

embedding_cache_capacity(n)クエリ時 のEmbeddingに対するLRU キャッシュを有効化します(ドキュメント取り込み時のEmbeddingは対象外)。 Vector / hybrid 検索でクエリペイロードをEmbeddingする際、結果を (field, embedder 名, payload ハッシュ) をキーにキャッシュし、以降の同一 クエリで再利用します。DSL パス(例: content:"cute kitten")と事前 Embedding 済みの Payloads パスは同じキャッシュを共有します。

これにより、繰り返しクエリのワークロード(オートコンプリート、ダッシュ ボード更新、A/B 評価など)で、ローカル Embedder のモデル推論やリモート Embedder のネットワークラウンドトリップを回避できます。デフォルトでは 無効です。識別可能なクエリの working set に応じてメモリを抑える容量を 指定してください。

1 つのクエリ内では、その全 payload を 1 回の Embedder::embed_batch 呼び出しでEmbedding します(キャッシュミスのみ、PerFieldEmbedder では field ごとにグルーピング)。これにより、バッチ対応 Embedder ではマルチ ベクトルクエリでも payload ごとではなく 1 往復で済みます(Issue #671)。

Buildライフサイクル

build() が呼び出されると、以下の処理が実行されます。

sequenceDiagram
    participant User
    participant EngineBuilder
    participant Engine

    User->>EngineBuilder: .build().await
    EngineBuilder->>EngineBuilder: split_schema()
    Note over EngineBuilder: Separate fields into<br/>LexicalIndexConfig<br/>+ VectorIndexConfig
    EngineBuilder->>Engine: Create PrefixedStorage (lexical/, vector/, documents/)
    EngineBuilder->>Engine: Create LexicalStore
    EngineBuilder->>Engine: Create VectorStore
    EngineBuilder->>Engine: Create DocumentLog
    EngineBuilder->>Engine: Recover from WAL
    EngineBuilder-->>User: Engine ready
  1. スキーマの分割 – Lexicalフィールド(Text、Integer、Floatなど)は LexicalIndexConfig に、Vectorフィールド(HNSW、Flat、IVF)は VectorIndexConfig に分割されます
  2. プレフィックス付きストレージの作成 – 各コンポーネントに独立した名前空間が割り当てられます(lexical/vector/documents/
  3. ストアの初期化LexicalStoreVectorStore がそれぞれの設定で作成されます
  4. WALからのリカバリ – 前回のセッションでコミットされなかった操作がリプレイされます

スキーマの分割

Schemaにはlexicalフィールドとvectorフィールドの両方が含まれています。ビルド時に split_schema() がこれらを分離します。

graph LR
    S["Schema<br/>title: Text<br/>body: Text<br/>page: Integer<br/>content_vec: HNSW"]

    S --> LC["LexicalIndexConfig<br/>title: TextOption<br/>body: TextOption<br/>page: IntegerOption<br/>_id: KeywordAnalyzer"]

    S --> VC["VectorIndexConfig<br/>content_vec: HnswOption<br/>(dim=384, m=16, ef=200)"]

予約フィールド _id は、完全一致検索のために KeywordAnalyzer を用いて常にLexical設定に追加されます。

フィールドごとのディスパッチ

PerFieldAnalyzer

PerFieldAnalyzer が指定された場合、テキスト解析はフィールドごとのAnalyzerにディスパッチされます。

graph LR
    PFA["PerFieldAnalyzer"]
    PFA -->|"title"| KA["KeywordAnalyzer"]
    PFA -->|"body"| SA["StandardAnalyzer"]
    PFA -->|"description"| JA["JapaneseAnalyzer"]
    PFA -->|"_id"| KA2["KeywordAnalyzer<br/>(always)"]
    PFA -->|other fields| DEF["Default Analyzer"]

PerFieldEmbedder

同様に、PerFieldEmbedder はフィールドごとのEmbedderにEmbedding処理をルーティングします。

graph LR
    PFE["PerFieldEmbedder"]
    PFE -->|"text_vec"| BERT["CandleBertEmbedder<br/>(384 dim)"]
    PFE -->|"image_vec"| CLIP["CandleClipEmbedder<br/>(512 dim)"]
    PFE -->|other fields| DEF["Default Embedder"]

Engineメソッド

ドキュメント操作

メソッド説明
put_document(id, doc)Upsert – 同じIDのドキュメントが既存の場合は置き換え
add_document(id, doc)追加 – 新しいチャンクとして追加(複数のチャンクが同一IDを共有可能)
get_documents(id)外部IDによるすべてのドキュメント/チャンクの取得
delete_documents(id)外部IDによるすべてのドキュメント/チャンクの削除
commit()保留中の変更をストレージにフラッシュ(ドキュメントが検索可能になる)
recover()クラッシュ後にWALをリプレイして未コミット状態を復元
add_field(name, field_option)稼働中のエンジンにフィールドを動的に追加し、更新後の Schema を返す
delete_field(name)稼働中のエンジンからフィールドを動的に削除し、更新後の Schema を返す
schema()現在の Schema への参照を返す

検索

メソッド説明
search(request)統一検索の実行(Lexical、Vector、またはハイブリッド)

search() メソッドは SearchRequest を受け取ります。SearchRequestにはLexicalクエリ、Vectorクエリ、またはその両方を含めることができます。両方が指定された場合、結果は指定された FusionAlgorithm で統合されます。

#![allow(unused)]
fn main() {
use laurus::{SearchRequestBuilder, FusionAlgorithm};
use laurus::lexical::TermQuery;
use laurus::lexical::search::searcher::LexicalSearchQuery;

// Lexicalのみの検索
let request = SearchRequestBuilder::new()
    .lexical_query(LexicalSearchQuery::Obj(Box::new(TermQuery::new("body", "rust"))))
    .limit(10)
    .build();

// RRFフュージョンによるハイブリッド検索
let request = SearchRequestBuilder::new()
    .lexical_query(lexical_query)
    .vector_query(vector_query)
    .fusion_algorithm(FusionAlgorithm::RRF { k: 60.0 })
    .limit(10)
    .build();

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

ウォームアップ

engine.warmup()(Issue #677)は Vector searcher を事前に準備し、初回の Vector / ハイブリッドクエリが one-time のセットアップコストを払わないようにします。 Engine 構築後、トラフィック処理を始める前に一度呼び出します:

let engine = builder.build()?;
engine.warmup()?;   // オプション: 初回クエリの latency を起動時に移す

Vector searcher を先行構築・キャッシュし(reader をロード — InMemory は ファイル→メモリ、Mmap はオフセット表)、HNSW の Mmap モードではディスク上の Vector データを OS page cache に pre-fault します。HNSW グラフは常に eager に ロードされるため、warming が必要なのは Vector データのみです。warmup() は 複数回呼んでも安全で、lexical のみのワークロードでは no-op です。

SearchRequest

フィールドデフォルト説明
querySearchQueryDsl("")検索クエリ仕様(Dsl / Lexical / Vector / Hybrid)
limitusize10返却する最大結果数
offsetusize0ページネーションのオフセット
fusion_algorithmOption<FusionAlgorithm>None(ハイブリッド時はRRF k=60)LexicalとVectorの結果を統合する方法
filter_queryOption<Box<dyn Query>>None両方の検索タイプに適用されるフィルタ
lexical_optionsLexicalSearchOptionsデフォルトLexical検索の動作パラメータ(ブースト、タイムアウト等)
vector_optionsVectorSearchOptionsデフォルトVector検索の動作パラメータ(スコアモード等)

FusionAlgorithm

バリアント説明
RRF { k: f64 }Reciprocal Rank Fusion – ランクベースの結合。スコア = sum(1 / (k + rank))。比較不可能なスコアの大きさを処理します。
WeightedSum { lexical_weight, vector_weight }min-maxスコア正規化を用いた加重結合。重みは[0.0, 1.0]にクランプされます。

関連項目: アーキテクチャ – 高レベルのデータフロー図

スコアリングとランキング

Laurus は Lexical 検索に BM25、Vector 検索に距離ベースの類似度、そしてハイブリッド検索ではこの 2 つを統合する設定可能なフュージョンアルゴリズムを使用します。本ページでは各スコアリング経路と、公開 API から介入する方法を説明します。

Lexical スコアリング

BM25(デフォルト)

BM25 は Lexical 検索のスコアリング関数です。単語頻度(term frequency)とドキュメント長正規化のバランスを取ります。

score = IDF * (tf * (k1 + 1)) / (tf + k1 * (1 - b + b * (doc_len / avg_doc_len)))

各パラメータ:

  • tf — ドキュメント内の単語頻度。
  • IDF — 逆文書頻度(全ドキュメントに対する単語の希少度)。
  • k1 — 単語頻度の飽和パラメータ。Laurus は 1.2 を使用。
  • b — ドキュメント長正規化の係数。Laurus は 0.75 を使用。
  • doc_len / avg_doc_len — ドキュメント長と平均ドキュメント長の比率。

(k1, b) の値は現在実装デフォルトに固定です。Lucene / Elasticsearch のデフォルトと同じため、BM25 スコアはチューニングの直感に関してそれらのエンジンと直接比較できます。

フィールドブースト

フィールドごとのスコア乗数は専用のスコアリング構造体ではなく、検索リクエスト上で設定します。

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

let request = SearchRequestBuilder::new()
    .query_dsl("rust programming")
    .add_field_boost("title", 2.0) // title のマッチはスコア 2 倍
    .add_field_boost("body", 1.0)  // body のマッチはスコア 1 倍(デフォルト)
    .limit(10)
    .build();
}

ブーストはそのフィールドにマッチした BM25 スコア寄与に乗算されます。1.0 は無効化と同じです。クエリで指定されたフィールド(またはスキーマの既定検索フィールド)にのみ適用されます。

gRPC / HTTP 経由では同じ設定が SearchRequest.field_boostsmap<string, float>)として公開されます。gRPC API → SearchRequest を参照してください。

Vector スコアリング

Vector 検索は距離ベースの類似度で結果をランク付けします。距離メトリックはベクトルインデックス(HNSW / Flat / IVF)のフィールドごとに設定します。

メトリック説明適した用途
Cosine1 − コサイン類似度(デフォルト)正規化済みテキスト埋め込み
EuclideanL2 距離空間データ・事前正規化済みデータ
ManhattanL1 距離疎な特徴ベクトル
DotProduct符号反転した内積高いほど良い事前正規化済みベクトル
Angular角度距離方向の類似度

距離は類似度スコア(「高いほど良い」)に変換され、Lexical 結果と Vector 結果のいずれにおいてもこの規約が保たれます。下記のフュージョンアルゴリズムはこの前提に依存します。

ハイブリッド検索フュージョン

検索リクエストが Lexical 句と Vector 句の両方を持つ場合、2 つの結果リストをマージする必要があります。Laurus は FusionAlgorithm で 2 種類のフュージョンアルゴリズムを公開しています。

RRF(Reciprocal Rank Fusion)

RRF は生のスコアではなくランクを統合することでスコア正規化を完全に回避します。

rrf_score(doc) = Σ 1 / (k + rank_i(doc))

合計はドキュメントが含まれる各結果リストにわたって取ります。k パラメータ(デフォルト 60.0)は分布を平滑化します — 値が大きいほど上位ランクの結果の貢献が薄まります。

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

let request = SearchRequestBuilder::new()
    .query_dsl("title:rust ~\"systems programming\"")
    .fusion_algorithm(FusionAlgorithm::Rrf { k: 60.0 })
    .build();
}

WeightedSum

WeightedSum は各リストのスコアを個別に min-max 正規化したうえで、重み付き線形結合を取ります。

norm(score)  = (score - min) / (max - min)
final(doc)   = lexical_weight * norm(lexical_score(doc))
             + vector_weight  * norm(vector_score(doc))
#![allow(unused)]
fn main() {
use laurus::{FusionAlgorithm, SearchRequestBuilder};

let request = SearchRequestBuilder::new()
    .query_dsl("title:rust ~\"systems programming\"")
    .fusion_algorithm(FusionAlgorithm::WeightedSum {
        lexical_weight: 0.6,
        vector_weight: 0.4,
    })
    .build();
}

両方の重みは [0.0, 1.0] にクランプされます。特定の重みを設定する理由がない場合は RRF を選んでください — パラメータが少なく、リスト間のスケール差にも頑健です。

関連項目

ファセット

ファセット(Faceting)は、フィールド値によって検索結果をカウント・分類する機能です。検索UIでナビゲーションフィルタを構築するために一般的に使用されます(例: 「エレクトロニクス (42)」「書籍 (18)」)。

概念

FacetPath

FacetPath は階層的なファセット値を表します。例えば、商品カテゴリ「Electronics > Computers > Laptops」は3階層のFacetPathです。

#![allow(unused)]
fn main() {
use laurus::lexical::search::features::facet::FacetPath;

// 単一レベルのファセット
let facet = FacetPath::from_value("category", "Electronics");

// コンポーネントからの階層的ファセット
let facet = FacetPath::new("category", vec![
    "Electronics".to_string(),
    "Computers".to_string(),
    "Laptops".to_string(),
]);

// 区切り文字付き文字列から
let facet = FacetPath::from_delimited("category", "Electronics/Computers/Laptops", "/");
}

FacetPathメソッド

メソッド説明
new(field, path)フィールド名とパスコンポーネントからFacetPathを作成
from_value(field, value)単一レベルのファセットを作成
from_delimited(field, path_str, delimiter)区切り文字付きのパス文字列をパース
depth()パスの階層数
is_parent_of(other)このパスが他のパスの親であるか確認
parent()親パスを取得(1階層上)
child(component)コンポーネントを追加して子パスを作成
to_string_with_delimiter(delimiter)区切り文字付き文字列に変換

FacetCount

FacetCount はファセット集計の結果を表します。

#![allow(unused)]
fn main() {
pub struct FacetCount {
    pub path: FacetPath,
    pub count: u64,
    pub children: Vec<FacetCount>,
}
}
フィールド説明
pathFacetPathファセット値
countu64マッチするドキュメント数
childrenVec<FacetCount>階層的なドリルダウン用の子ファセット

例: 階層的ファセット

Category
├── Electronics (42)
│   ├── Computers (18)
│   │   ├── Laptops (12)
│   │   └── Desktops (6)
│   └── Phones (24)
└── Books (35)
    ├── Fiction (20)
    └── Non-Fiction (15)

このツリーの各ノードは、ドリルダウンナビゲーション用に children が設定された FacetCount に対応します。

ユースケース

  • EC(電子商取引): カテゴリ、ブランド、価格帯、評価によるフィルタリング
  • ドキュメント検索: 著者、部門、日付範囲、ドキュメントタイプによるフィルタリング
  • コンテンツ管理: タグ、トピック、コンテンツステータスによるフィルタリング

パフォーマンス

ファセットカウントは stored document ではなく、各フィールドの DocValues 列から読み取られます。 収集された各ヒットについて、コレクターはファセットフィールドの値だけを per-field の DocValues ルックアップで読むため、ファセット対象の全フィールドが DocValues 列を持つ場合(既定では、index 時に 全 stored field が DocValues に書かれるため常に成立)、stored fields blob 全体を decode / clone しません。 DocValues を持たないフィールドは透過的に stored document へフォールバックするため、結果はどちらの経路でも 同一で、変わるのは読み取り経路だけです。

ハイライト

ハイライト(Highlighting)は検索結果内のマッチした単語をマークアップし、ドキュメントがクエリにマッチした理由をユーザーに視覚的に提示します。Laurusは設定可能なHTMLタグでハイライトされたテキストフラグメントを生成します。

HighlightConfig

HighlightConfig はハイライトの生成方法を制御します。

#![allow(unused)]
fn main() {
use laurus::lexical::search::features::highlight::HighlightConfig;

let config = HighlightConfig::default()
    .tag("mark")
    .css_class("highlight")
    .max_fragments(3)
    .fragment_size(200);
}

設定オプション

オプションデフォルト説明
tagString"mark"ハイライトに使用するHTMLタグ
css_classOption<String>Noneタグに追加するオプションのCSSクラス
max_fragmentsusize5返却するフラグメントの最大数
fragment_sizeusize150フラグメントの目標文字数
fragment_overlapusize20隣接するフラグメント間のオーバーラップ文字数
fragment_separatorString" ... "フラグメント間の区切り文字
return_entire_field_if_no_highlightboolfalseマッチがない場合にフィールド全体の値を返却する
max_analyzed_charsusize1,000,000ハイライト解析対象の最大文字数

Builderメソッド

メソッド説明
tag(tag)HTMLタグを設定(例: "em""strong""mark"
css_class(class)タグのCSSクラスを設定
max_fragments(count)フラグメントの最大数を設定
fragment_size(size)フラグメントの目標文字数を設定
opening_tag()開始HTMLタグ文字列を取得(例: <mark class="highlight">
closing_tag()終了HTMLタグ文字列を取得(例: </mark>

HighlightFragment

各ハイライト結果は HighlightFragment です。

#![allow(unused)]
fn main() {
pub struct HighlightFragment {
    pub text: String,
}
}

text フィールドには、マッチした単語が設定されたHTMLタグで囲まれたフラグメントが含まれます。

出力例

body = "Rust is a systems programming language focused on safety and performance." というドキュメントに対して “rust programming” で検索した場合:

<mark>Rust</mark> is a systems <mark>programming</mark> language focused on safety and performance.

css_class("highlight") を指定した場合:

<mark class="highlight">Rust</mark> is a systems <mark class="highlight">programming</mark> language focused on safety and performance.

フラグメント選択

フィールドが長い場合、Laurusは最も関連性の高いフラグメントを選択します。

  1. テキストが fragment_size 文字のオーバーラップするウィンドウに分割されます
  2. 各フラグメントは含まれるクエリ単語の数でスコアリングされます
  3. 上位 max_fragments 個のフラグメントが fragment_separator で結合されて返却されます

マッチを含むフラグメントがなく、return_entire_field_if_no_highlight が true の場合、フィールド全体の値が代わりに返却されます。

スペル修正

Laurusにはスペル修正システムが組み込まれており、誤入力されたクエリ単語の修正候補を提案し、「もしかして?(Did you mean?)」機能を提供します。

概要

スペル修正器は、編集距離(Levenshtein距離(Levenshtein Distance))と単語頻度データを組み合わせて修正候補を提案します。以下の機能をサポートしています。

  • 単語レベルの候補提案 – 個々の誤入力単語を修正
  • 自動修正 – 高信頼度の修正を自動的に適用
  • 「もしかして?」 – ユーザーに代替クエリを提案
  • クエリ学習 – ユーザーのクエリから学習して候補を改善
  • カスタム辞書 – 独自の単語リストを使用

基本的な使い方

SpellingCorrector

#![allow(unused)]
fn main() {
use laurus::spelling::corrector::SpellingCorrector;

// 組み込みの英語辞書で修正器を作成
let mut corrector = SpellingCorrector::new();

// クエリを修正
let result = corrector.correct("programing langauge");

// 候補が利用可能か確認
if result.has_suggestions() {
    for (word, suggestions) in &result.word_suggestions {
        println!("'{}' -> {:?}", word, suggestions);
    }
}

// 最良の修正済みクエリを取得
if let Some(corrected) = result.query() {
    println!("Corrected: {}", corrected);
}
}

「もしかして?」

DidYouMean ラッパーは検索UIに適した高レベルのインターフェースを提供します。

#![allow(unused)]
fn main() {
use laurus::spelling::corrector::{SpellingCorrector, DidYouMean};

let corrector = SpellingCorrector::new();
let mut did_you_mean = DidYouMean::new(corrector);

if let Some(suggestion) = did_you_mean.suggest("programing") {
    println!("Did you mean: {}?", suggestion);
}
}

設定

CorrectorConfig を使用して動作をカスタマイズできます。

#![allow(unused)]
fn main() {
use laurus::spelling::corrector::{CorrectorConfig, SpellingCorrector};

let config = CorrectorConfig {
    max_distance: 2,              // 最大編集距離(デフォルト: 2)
    max_suggestions: 5,           // 単語あたりの最大候補数(デフォルト: 5)
    min_frequency: 1,             // 最小単語頻度しきい値(デフォルト: 1)
    auto_correct: false,          // 自動修正を有効化(デフォルト: false)
    auto_correct_threshold: 0.8,  // 自動修正の信頼度しきい値(デフォルト: 0.8)
    use_index_terms: true,        // インデックスの単語を辞書として使用(デフォルト: true)
    learn_from_queries: true,     // ユーザーのクエリから学習(デフォルト: true)
};
}

設定オプション

オプションデフォルト説明
max_distanceusize2候補提案のための最大Levenshtein編集距離
max_suggestionsusize5単語あたりの最大候補数
min_frequencyu321候補として提案されるために必要な辞書内の最小頻度
auto_correctboolfalsetrueの場合、しきい値を超える修正を自動的に適用
auto_correct_thresholdf640.8自動修正に必要な信頼度スコア(0.0–1.0)
use_index_termsbooltrue検索インデックスの単語を辞書として使用
learn_from_queriesbooltrueユーザーの検索クエリから新しい単語を学習

CorrectionResult

correct() メソッドは詳細な情報を含む CorrectionResult を返します。

フィールド説明
originalString元のクエリ文字列
correctedOption<String>修正済みクエリ(自動修正が適用された場合)
word_suggestionsHashMap<String, Vec<Suggestion>>誤入力単語ごとにグループ化された候補
confidencef64全体の信頼度スコア(0.0–1.0)
auto_correctedbool自動修正が適用されたかどうか

ヘルパーメソッド

メソッド戻り値説明
has_suggestions()boolいずれかの単語に候補がある場合true
best_suggestion()Option<&Suggestion>最もスコアの高い単一の候補
query()Option<String>修正が行われた場合の修正済みクエリ文字列
should_show_did_you_mean()bool「もしかして?」プロンプトを表示すべきかどうか

カスタム辞書

組み込みの英語辞書の代わりに独自の辞書を提供できます。

#![allow(unused)]
fn main() {
use laurus::spelling::corrector::SpellingCorrector;
use laurus::spelling::dictionary::SpellingDictionary;

// カスタム辞書を構築
let mut dictionary = SpellingDictionary::new();
dictionary.add_word("elasticsearch", 100);
dictionary.add_word("lucene", 80);
dictionary.add_word("laurus", 90);

let corrector = SpellingCorrector::with_dictionary(dictionary);
}

インデックス単語からの学習

use_index_terms が有効な場合、修正器は検索インデックスの単語から学習できます。

#![allow(unused)]
fn main() {
let mut corrector = SpellingCorrector::new();

// インデックスの単語を修正器に提供
let index_terms = vec!["rust", "programming", "search", "engine"];
corrector.learn_from_terms(&index_terms);
}

これにより、ドメイン固有の語彙が組み込まれ、候補の品質が向上します。

統計情報

stats() で修正器の状態を監視できます。

#![allow(unused)]
fn main() {
let stats = corrector.stats();
println!("Dictionary words: {}", stats.dictionary_words);
println!("Total frequency: {}", stats.dictionary_total_frequency);
println!("Learned queries: {}", stats.queries_learned);
}

次のステップ

  • Lexical検索 – クエリタイプを使用した全文検索
  • Query DSL – 人間が読みやすいクエリ構文

ID管理

Laurusは、分散環境における効率的なドキュメントの検索、更新、集約を実現するために、二層構造のID管理戦略を採用しています。

1. 外部ID(String)

外部ID(External ID)は、ユーザーやアプリケーションがドキュメントを一意に識別するための論理的な識別子です。

  • : String
  • 役割: UUID、URL、データベースの主キーなど、任意の一意な値を使用できます。
  • 保存: Lexicalインデックス内の予約システムフィールド名 _id として透過的に永続化されます。
  • 一意性: システム全体でユニークであることが期待されます。
  • 更新: 既存の external_id でドキュメントをインデックスすると、自動的に「削除してから挿入(Delete-then-Insert)」(Upsert)操作がトリガーされ、古いバージョンが最新のものに置き換わります。

2. 内部ID(u64 / Stable ID)

内部ID(Internal ID)は、Laurusのエンジン(LexicalおよびVector)が高性能な操作のために内部的に使用する物理的なハンドルです。

  • : 符号なし64ビット整数(u64
  • 役割: ビットマップ操作、ポイント参照、分散ノード間のルーティングに使用されます。
  • 不変性(Stable): 一度割り当てられた内部IDは、インデックスのマージ(セグメントコンパクション)や再起動によって変更されることはありません。これにより、削除ログやキャッシュの不整合を防止します。

ID構造(Shard-Prefixed)

Laurusはマルチノード分散環境向けに設計されたShard-Prefixed Stable ID方式を採用しています。

ビット範囲名称説明
48-63ビットShard IDノードまたはパーティションを識別するプレフィックス(最大65,535シャード)
0-47ビットLocal IDシャード内で単調増加するドキュメント番号(最大約281兆ドキュメント)

この構造を採用する理由

  1. ゼロコスト集約: u64 IDがグローバルにユニークであるため、アグリゲータはノード間のID衝突を気にせずに高速なソートと重複排除を実行できます。
  2. 高速ルーティング: アグリゲータは上位ビットを見るだけで、ドキュメントの担当物理ノードを即座に特定でき、コストの高いハッシュ検索を回避できます。
  3. 高性能フェッチ: 内部IDは物理データ構造に直接マッピングされます。これにより、Laurusは検索時に「外部IDから内部IDへの変換」ステップをスキップし、O(1) のアクセス速度を実現します。

IDライフサイクル

  1. 登録(engine.put_document() / engine.add_document(): ユーザーが外部IDを持つドキュメントを提供します。
  2. ID割り当て: Engine が現在の shard_id と新しいLocal IDを組み合わせて、Shard-Prefixed内部IDを発行します。
  3. マッピング: エンジンが外部IDと新しい内部IDの対応関係を維持します。
  4. 検索: 検索結果は内部IDから解決された外部ID(String)を返します。
  5. 取得/削除: ユーザー向けAPIは利便性のために外部IDを受け付けますが、エンジンは内部的に内部IDに変換してほぼ即座に処理を行います。

永続化とWAL

Laurusはデータの耐久性を確保するために**Write-Ahead Log(WAL)**を使用します。すべての書き込み操作はインメモリ構造を変更する前にWALに永続化され、プロセスがクラッシュした場合でもデータが失われないことを保証します。

書き込みパス

sequenceDiagram
    participant App as Application
    participant Engine
    participant WAL as DocumentLog (WAL)
    participant Mem as In-Memory Buffers
    participant Disk as Storage (segments)

    App->>Engine: add_document() / delete_documents()
    Engine->>WAL: 1. Append operation to WAL
    Engine->>Mem: 2. Update in-memory buffers

    Note over Mem: Document is buffered but\nNOT yet searchable

    App->>Engine: commit()
    Engine->>Disk: 3. Flush segments to storage
    Engine->>WAL: 4. Truncate WAL
    Note over Disk: Documents are now\nsearchable and durable

主要な原則

  1. WALファースト: すべての書き込み(追加または削除)はインメモリ構造を更新する前にWALに追記されます
  2. バッファリング書き込み: インメモリバッファが commit() が呼ばれるまで変更を蓄積します
  3. アトミックコミット: commit() はすべてのバッファリングされた変更をセグメントファイルにフラッシュし、WALを切り捨てます
  4. クラッシュセーフティ: 書き込みとコミットの間にプロセスがクラッシュした場合、次回起動時にWALがリプレイされます
  5. アトミックなファイル書き込み: セグメントファイル(HNSW の .hnsw グラフ・そのメタデータ・削除ビットマップなど)は一時ファイルへ書き込んでからアトミックにリネームして配置されるため、書き込み途中のクラッシュでも切り詰められたファイルではなく直前にコミット済みのファイルがそのまま残ります
  6. チェックサム検証: これらのファイルは CRC-32(.hnsw.hnsw.f32 rerank sidecar は footer、metadata.json と削除ビットマップは framing)を持ち、ロード時に検証されるため、ディスク上の静かな破損を正常データとして読まずに検出できます。チェックサム導入前に書かれたファイルもそのままロードできます(ファイル単位で任意)。また、ローダーはヘッダーを信頼する前にバッファ確保サイズを実ファイルサイズで上限を縛るため、サイズフィールドが破損していても巨大なメモリ確保(OOM)を引き起こさずに破損として拒否します

Write-Ahead Log(WAL)

WALは DocumentLog コンポーネントによって管理され、ストレージバックエンドのルートレベル(engine.wal)に保存されます。

WALエントリタイプ

エントリタイプ説明
Upsertドキュメント内容 + 外部ID + 割り当てられた内部ID
Delete削除するドキュメントの外部ID

WALファイル

WALファイル(engine.wal)は追記専用のバイナリログです。各エントリは以下を含む自己完結型です。

  • 操作タイプ(add/delete)
  • シーケンス番号
  • ペイロード(ドキュメントデータまたはID)

リカバリ

エンジンがビルドされる際(Engine::builder(...).build().await)、残っているWALエントリが自動的にチェックされ、リプレイされます(WALはコミット時に切り捨てられるため、残っているエントリはクラッシュしたセッションのものです)。

graph TD
    Start["Engine::build()"] --> Check["Check WAL for\nuncommitted entries"]
    Check -->|"Entries found"| Replay["Replay operations\ninto in-memory buffers"]
    Replay --> Ready["Engine ready"]
    Check -->|"No entries"| Ready

リカバリは透過的に行われるため、手動で処理する必要はありません。

コミットライフサイクル

#![allow(unused)]
fn main() {
// 1. ドキュメントを追加(バッファリングされ、まだ検索不可)
engine.add_document("doc-1", doc1).await?;
engine.add_document("doc-2", doc2).await?;

// 2. コミット — 永続ストレージにフラッシュ
engine.commit().await?;
// ドキュメントが検索可能に

// 3. さらにドキュメントを追加
engine.add_document("doc-3", doc3).await?;

// 4. ここでプロセスがクラッシュした場合、doc-3はWAL内にあり
//    次回起動時にリカバリされます
}

コミットのタイミング

戦略説明ユースケース
ドキュメントごと最大の耐久性、最小の検索遅延書き込みが少ないリアルタイム検索
バッチごとスループットと遅延の良いバランスバルクインデキシング
定期的最大の書き込みスループット大量データの取り込み

ヒント: コミットはセグメントをストレージにフラッシュするため比較的コストが高い操作です。バルクインデキシングでは、commit() を呼び出す前に多数のドキュメントをバッチ処理してください。

ストレージレイアウト

エンジンは PrefixedStorage を使用してデータを整理します。

<storage root>/
├── lexical/          # 転置インデックスセグメント
│   ├── seg-000/
│   │   ├── terms.dict
│   │   ├── postings.post
│   │   └── ...
│   └── metadata.json
├── vector/           # ベクトルインデックスセグメント
│   ├── seg-000/
│   │   ├── graph.hnsw
│   │   ├── vectors.vecs
│   │   └── ...
│   └── metadata.json
├── documents/        # ドキュメントストレージ
│   └── ...
└── engine.wal        # Write-Ahead Log

次のステップ

削除とコンパクション

Laurusは二段階の削除戦略を採用しています。高速な**論理削除(Logical Deletion)と、それに続く定期的な物理コンパクション(Physical Compaction)**です。

ドキュメントの削除

#![allow(unused)]
fn main() {
// 外部IDでドキュメントを削除
engine.delete_documents("doc-1").await?;
engine.commit().await?;
}

論理削除

ドキュメントが削除された場合、インデックスファイルから即座に削除されるわけではありません。代わりに以下の処理が行われます。

graph LR
    Del["delete_documents('doc-1')"] --> Bitmap["Add internal ID\nto Deletion Bitmap"]
    Bitmap --> Search["Search skips\ndeleted IDs"]
  1. ドキュメントの内部IDが**削除ビットマップ(Deletion Bitmap)**に追加されます
  2. 検索時にビットマップがチェックされ、削除されたドキュメントが結果からフィルタリングされます
  3. 元のデータはセグメントファイルに残ったままです

これはレキシカルインデックスと**ベクトル(HNSW)**インデックスの両方に一様に適用されます。HNSW では 削除されたノードはグラフに残り(グラフの連結性を保つためそのベクトルは引き続き利用されます)、 削除認識トラバーサルが結果から除外します。したがって削除で グラフが再構築されることはなく、コストはインデックスサイズに依らず O(1) のビットマップマークだけです。 物理的な回収は後段のコンパクションで行われます。

論理削除を採用する理由

メリット説明
速度O(1) – ビットの反転は即座に完了
不変セグメントセグメントファイルはインプレースで変更されないため、並行性の管理が簡素化
安全なリカバリクラッシュが発生しても、削除ビットマップはWALから再構築可能

Upsert(更新 = 削除 + 挿入)

既存の外部IDでドキュメントをインデックスすると、Laurusは自動的にUpsertを実行します。

  1. 古いドキュメントが論理削除されます(そのIDが削除ビットマップに追加)
  2. 新しい内部IDで新しいドキュメントが挿入されます
  3. 外部IDから内部IDへのマッピングが更新されます
#![allow(unused)]
fn main() {
// 最初の挿入
engine.put_document("doc-1", doc_v1).await?;
engine.commit().await?;

// 更新: 古いバージョンが論理削除され、新しいバージョンが挿入される
engine.put_document("doc-1", doc_v2).await?;
engine.commit().await?;
}

物理コンパクション

時間の経過とともに、論理削除されたドキュメントが蓄積されスペースを浪費します。コンパクションは、削除済みエントリを含まないセグメントファイルを再書き込みすることでスペースを回収します。

graph LR
    subgraph "Before Compaction"
        S1["Segment 0\ndoc-1 (deleted)\ndoc-2\ndoc-3 (deleted)"]
        S2["Segment 1\ndoc-4\ndoc-5"]
    end

    Compact["Compaction"]

    subgraph "After Compaction"
        S3["Segment 0\ndoc-2\ndoc-4\ndoc-5"]
    end

    S1 --> Compact
    S2 --> Compact
    Compact --> S3

コンパクションの処理内容

  1. 既存セグメントからすべての生存(未削除)ドキュメントを読み取ります
  2. 削除済みエントリを含まない転置インデックスやベクトルインデックスを再構築します
  3. 新しいクリーンなセグメントファイルを書き込みます
  4. 古いセグメントファイルを削除します
  5. 削除ビットマップをリセットします

コストと頻度

側面詳細
CPUコスト高い – インデックス構造をゼロから再構築
I/Oコスト高い – すべてのデータを読み取り、新しいセグメントを書き込み
ブロッキングコンパクション中も検索は継続可能(新しいセグメントが準備できるまで古いセグメントが参照される)
頻度削除済みドキュメントがしきい値を超えた場合に実行(例: 全体の10-20%)

コンパクションのタイミング

  • 書き込みが少ないワークロード: 定期的にコンパクション(例: 毎日または毎週)
  • 書き込みが多いワークロード: 削除率がしきい値を超えた場合にコンパクション
  • バルク更新後: 大量のUpsertバッチの後にコンパクション

自動コンパクション

HNSW ベクトルインデックスではコンパクションを自動実行できます。DeletionConfig::auto_compaction が有効な場合(既定)、commit() が削除率(削除ノード数 / コミット済み総ノード数)を確認し、 DeletionConfig::compaction_threshold(既定 0.3)に達するとコンパクションを起動します。 コンパクション後は削除率が 0 に戻るため、削除が再度蓄積するまで再発火せず、手動 optimize() なしに tombstone の増加を抑えられます。自分で制御したい場合は auto_compactionfalse にします。

削除ビットマップ

削除ビットマップは、どの内部IDが削除されたかを追跡します。

  • 保存: 削除済みドキュメントIDの Roaring ビットマップ。 セグメント寿命で累積する密な削除集合では、生のID列より劇的に小さくなります。例えば 10M ドキュメント・10% 削除のセグメントは on-disk で ~8MB ではなく ~125KB です。
  • 検索: 分岐の少ないビットテスト。削除集合が大きくても CPU キャッシュに常駐しやすく、 is_deleted は lexical の per-document・vector の per-neighbour 検索ホットパスで呼ばれます。

ビットマップはインデックスセグメントと一緒に(.delmap ファイルとして)永続化され、リカバリ時に WALから再構築されます。on-disk 形式はバージョン管理されており、現在の writer は v4(Roaring)を 書き出し、reader は後方互換のため旧 v1〜v3(生ID列)形式も読み込めます。

次のステップ

エラーハンドリング

Laurusはすべての操作に統一的なエラー型を使用します。エラーシステムを理解することで、障害を適切に処理する堅牢なアプリケーションを作成できます。

LaurusError

Laurusのすべての操作は Result<T> を返します。これは std::result::Result<T, LaurusError> のエイリアスです。

LaurusError は、各カテゴリの障害に対応するバリアントを持つenumです。

バリアント説明一般的な原因
IoI/Oエラーファイルが見つからない、権限拒否、ディスク容量不足
Indexインデックス操作エラーインデックスの破損、セグメント読み取り失敗
Schemaスキーマ関連のエラー不明なフィールド名、型の不一致
Analysisテキスト解析エラートークナイザーの失敗、無効なフィルタ設定
Queryクエリの解析/実行エラー不正なQuery DSL、クエリ内の不明なフィールド
Storageストレージバックエンドエラーストレージのオープン失敗、書き込み失敗
Fieldフィールド定義エラー無効なフィールドオプション、重複するフィールド名
BenchmarkFailedベンチマークエラーベンチマーク実行失敗
ThreadJoinErrorスレッド join エラーワーカースレッドでのパニック
JsonJSONシリアライズエラー不正なドキュメントJSON
Anyhowanyhow ラップエラーanyhow 経由のサードパーティクレートエラー
InvalidOperation無効な操作コミット前の検索、二重クローズ
ResourceExhaustedリソース制限超過メモリ不足、オープンファイル数超過
SerializationErrorバイナリシリアライズエラーディスク上のデータ破損
OperationCancelled操作がキャンセルされたタイムアウト、ユーザーによるキャンセル
NotImplemented機能が利用不可未実装の操作
Other汎用エラータイムアウト、無効な設定、無効な引数

基本的なエラーハンドリング

? 演算子の使用

最もシンプルなアプローチ – エラーを呼び出し元に伝播します。

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

async fn index_documents(engine: &Engine) -> Result<()> {
    let doc = laurus::Document::builder()
        .add_text("title", "Rust Programming")
        .build();

    engine.put_document("doc1", doc).await?;
    engine.commit().await?;
    Ok(())
}
}

エラーバリアントのマッチング

エラータイプごとに異なる動作が必要な場合:

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

async fn safe_search(engine: &Engine, query: &str) {
    match engine.search(/* request */).await {
        Ok(results) => {
            for result in results {
                println!("{}: {}", result.id, result.score);
            }
        }
        Err(LaurusError::Query(msg)) => {
            eprintln!("Invalid query syntax: {}", msg);
        }
        Err(LaurusError::Io(e)) => {
            eprintln!("Storage I/O error: {}", e);
        }
        Err(e) => {
            eprintln!("Unexpected error: {}", e);
        }
    }
}
}

downcast によるエラータイプの確認

LaurusErrorstd::error::Error を実装しているため、標準的なエラーハンドリングパターンを使用できます。

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

fn is_retriable(error: &LaurusError) -> bool {
    matches!(error, LaurusError::Io(_) | LaurusError::ResourceExhausted(_))
}
}

よくあるエラーシナリオ

スキーマの不一致

スキーマに一致しないフィールドを持つドキュメントの追加:

#![allow(unused)]
fn main() {
// スキーマには "title"(Text)と "year"(Integer)がある
let doc = Document::builder()
    .add_text("title", "Hello")
    .add_text("unknown_field", "this field is not in schema")
    .build();

// スキーマにないフィールドはインデキシング時に黙って無視されます。
// エラーは発生しません -- スキーマで定義されたフィールドのみが処理されます。
}

クエリ解析エラー

無効なQuery DSL構文は Query エラーを返します。

#![allow(unused)]
fn main() {
use laurus::engine::query::UnifiedQueryParser;

let parser = UnifiedQueryParser::new();
match parser.parse("title:\"unclosed phrase") {
    Ok(request) => { /* ... */ }
    Err(LaurusError::Query(msg)) => {
        // msgには解析失敗の詳細が含まれます
        eprintln!("Bad query: {}", msg);
    }
    Err(e) => { /* その他のエラー */ }
}
}

ストレージI/Oエラー

ファイルベースのストレージではI/Oエラーが発生する可能性があります。

#![allow(unused)]
fn main() {
use laurus::storage::{StorageConfig, StorageFactory};

match StorageFactory::open(StorageConfig::File {
    path: "/nonexistent/path".into(),
    loading_mode: Default::default(),
}) {
    Ok(storage) => { /* ... */ }
    Err(LaurusError::Io(e)) => {
        eprintln!("Cannot open storage: {}", e);
    }
    Err(e) => { /* その他のエラー */ }
}
}

便利なコンストラクタ

LaurusError はカスタム実装でエラーを作成するためのファクトリメソッドを提供しています。

メソッド作成されるバリアント
LaurusError::index(msg)Index バリアント
LaurusError::schema(msg)Schema バリアント
LaurusError::analysis(msg)Analysis バリアント
LaurusError::query(msg)Query バリアント
LaurusError::storage(msg)Storage バリアント
LaurusError::field(msg)Field バリアント
LaurusError::other(msg)Other バリアント
LaurusError::cancelled(msg)OperationCancelled バリアント
LaurusError::invalid_argument(msg)“Invalid argument” プレフィックス付き Other
LaurusError::invalid_config(msg)“Invalid configuration” プレフィックス付き Other
LaurusError::not_found(msg)“Not found” プレフィックス付き Other
LaurusError::timeout(msg)“Timeout” プレフィックス付き Other

これらはカスタム Analyzer、Embedder、またはStorage トレイトを実装する際に有用です。

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

fn validate_dimension(dim: usize) -> Result<()> {
    if dim == 0 {
        return Err(LaurusError::invalid_argument("dimension must be > 0"));
    }
    Ok(())
}
}

自動変換

LaurusError は一般的なエラー型に対して From を実装しているため、? で自動変換されます。

ソース型ターゲットバリアント
std::io::ErrorLaurusError::Io
serde_json::ErrorLaurusError::Json
anyhow::ErrorLaurusError::Anyhow

次のステップ

  • 拡張性 – 適切なエラーハンドリングでカスタムトレイトを実装
  • APIリファレンス – 完全なメソッドシグネチャと戻り値の型

拡張性

Laurusはコアコンポーネントにトレイトベースの抽象化を採用しています。これらのトレイトを実装することで、カスタムAnalyzer、Embedder、およびStorageバックエンドを提供できます。

カスタムAnalyzer

Analyzer トレイトを実装して、カスタムテキスト解析パイプラインを作成します。

#![allow(unused)]
fn main() {
use laurus::analysis::analyzer::analyzer::Analyzer;
use laurus::analysis::token::{Token, TokenStream};
use laurus::Result;

#[derive(Debug)]
struct ReverseAnalyzer;

impl Analyzer for ReverseAnalyzer {
    fn analyze(&self, text: &str) -> Result<TokenStream> {
        let tokens: Vec<Token> = text
            .split_whitespace()
            .enumerate()
            .map(|(i, word)| Token {
                text: word.chars().rev().collect(),
                position: i,
                ..Default::default()
            })
            .collect();
        Ok(Box::new(tokens.into_iter()))
    }

    fn name(&self) -> &str {
        "reverse"
    }

    fn as_any(&self) -> &dyn std::any::Any {
        self
    }
}
}

必須メソッド

メソッド説明
analyze(&self, text: &str) -> Result<TokenStream>テキストをトークンストリームに変換
name(&self) -> &strこのAnalyzerの一意な識別子を返す
as_any(&self) -> &dyn Any具象型へのダウンキャストを可能にする

カスタムAnalyzerの使用

Analyzerを EngineBuilder に渡します。

#![allow(unused)]
fn main() {
use std::sync::Arc;

let analyzer = Arc::new(ReverseAnalyzer);
let engine = Engine::builder(storage, schema)
    .analyzer(analyzer)
    .build()
    .await?;
}

フィールドごとのAnalyzerには PerFieldAnalyzer でラップします。

#![allow(unused)]
fn main() {
use laurus::analysis::analyzer::per_field::PerFieldAnalyzer;
use laurus::analysis::analyzer::standard::StandardAnalyzer;

let per_field = PerFieldAnalyzer::new(Arc::new(StandardAnalyzer::new()?));
per_field.add_analyzer("custom_field", Arc::new(ReverseAnalyzer));

let engine = Engine::builder(storage, schema)
    .analyzer(Arc::new(per_field))
    .build()
    .await?;
}

カスタムEmbedder

Embedder トレイトを実装して、独自のベクトルEmbeddingモデルを統合します。

#![allow(unused)]
fn main() {
use async_trait::async_trait;
use laurus::embedding::embedder::{Embedder, EmbedInput, EmbedInputType};
use laurus::vector::core::vector::Vector;
use laurus::{LaurusError, Result};

#[derive(Debug)]
struct MyEmbedder {
    dimension: usize,
}

#[async_trait]
impl Embedder for MyEmbedder {
    async fn embed(&self, input: &EmbedInput<'_>) -> Result<Vector> {
        match input {
            EmbedInput::Text(text) => {
                // Embeddingロジックをここに記述
                let vector = vec![0.0f32; self.dimension];
                Ok(Vector::new(vector))
            }
            _ => Err(LaurusError::invalid_argument(
                "this embedder only supports text input",
            )),
        }
    }

    fn supported_input_types(&self) -> Vec<EmbedInputType> {
        vec![EmbedInputType::Text]
    }

    fn name(&self) -> &str {
        "my-embedder"
    }

    fn as_any(&self) -> &dyn std::any::Any {
        self
    }
}
}

必須メソッド

メソッド説明
async embed(&self, input: &EmbedInput) -> Result<Vector>指定された入力に対するEmbeddingベクトルを生成
supported_input_types(&self) -> Vec<EmbedInputType>サポートする入力タイプを宣言(TextImage
as_any(&self) -> &dyn Anyダウンキャストを可能にする

オプションメソッド

メソッドデフォルト説明
async embed_batch(&self, inputs) -> Result<Vec<Vector>>embed への逐次呼び出しバッチ最適化のためにオーバーライド
name(&self) -> &str"unknown"ログ出力用の識別子
supports(&self, input_type) -> boolsupported_input_types をチェック入力タイプのサポート確認
supports_text() -> boolText を確認テキストサポートの簡略確認
supports_image() -> boolImage を確認画像サポートの簡略確認
is_multimodal() -> boolテキストと画像の両方マルチモーダル確認

カスタムEmbedderの使用

#![allow(unused)]
fn main() {
let embedder = Arc::new(MyEmbedder { dimension: 384 });
let engine = Engine::builder(storage, schema)
    .embedder(embedder)
    .build()
    .await?;
}

フィールドごとのEmbedderには PerFieldEmbedder でラップします。

#![allow(unused)]
fn main() {
use laurus::embedding::per_field::PerFieldEmbedder;

let per_field = PerFieldEmbedder::new(Arc::new(MyEmbedder { dimension: 384 }));
per_field.add_embedder("image_vec", Arc::new(ClipEmbedder::new()?));

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

カスタムStorage

Storage トレイトを実装して、新しいストレージバックエンドを追加します。

#![allow(unused)]
fn main() {
use laurus::storage::{Storage, StorageInput, StorageOutput, LoadingMode, FileMetadata};
use laurus::Result;

#[derive(Debug)]
struct S3Storage {
    bucket: String,
    prefix: String,
}

impl Storage for S3Storage {
    fn loading_mode(&self) -> LoadingMode {
        LoadingMode::Eager  // S3は完全なダウンロードが必要
    }

    fn open_input(&self, name: &str) -> Result<Box<dyn StorageInput>> {
        // S3からダウンロードしてリーダーを返す
        todo!()
    }

    fn create_output(&self, name: &str) -> Result<Box<dyn StorageOutput>> {
        // S3へのアップロードストリームを作成
        todo!()
    }

    fn create_output_append(&self, name: &str) -> Result<Box<dyn StorageOutput>> {
        todo!()
    }

    fn file_exists(&self, name: &str) -> bool {
        todo!()
    }

    fn delete_file(&self, name: &str) -> Result<()> {
        todo!()
    }

    fn list_files(&self) -> Result<Vec<String>> {
        todo!()
    }

    fn file_size(&self, name: &str) -> Result<u64> {
        todo!()
    }

    fn metadata(&self, name: &str) -> Result<FileMetadata> {
        todo!()
    }

    fn rename_file(&self, old_name: &str, new_name: &str) -> Result<()> {
        todo!()
    }

    fn create_temp_output(&self, prefix: &str) -> Result<(String, Box<dyn StorageOutput>)> {
        todo!()
    }

    fn sync(&self) -> Result<()> {
        todo!()
    }

    fn close(&mut self) -> Result<()> {
        todo!()
    }
}
}

必須メソッド

メソッド説明
open_input(name) -> Result<Box<dyn StorageInput>>ファイルを読み取り用にオープン
create_output(name) -> Result<Box<dyn StorageOutput>>ファイルを書き込み用に作成
create_output_append(name) -> Result<Box<dyn StorageOutput>>ファイルを追記用にオープン
file_exists(name) -> boolファイルの存在を確認
delete_file(name) -> Result<()>ファイルを削除
list_files() -> Result<Vec<String>>すべてのファイルを一覧表示
file_size(name) -> Result<u64>ファイルサイズをバイト単位で取得
metadata(name) -> Result<FileMetadata>ファイルのメタデータを取得
rename_file(old, new) -> Result<()>ファイル名を変更
create_temp_output(prefix) -> Result<(String, Box<dyn StorageOutput>)>一時ファイルを作成
sync() -> Result<()>保留中の書き込みをすべてフラッシュ
close(&mut self) -> Result<()>ストレージを閉じてリソースを解放

オプションメソッド

メソッドデフォルト説明
loading_mode() -> LoadingModeLoadingMode::Eager推奨されるデータロードモード

スレッドセーフティ

3つのトレイトすべてが Send + Sync を要求します。つまり、実装はスレッド間で安全に共有できる必要があります。共有可能な可変状態には Arc<Mutex<_>> またはロックフリーデータ構造を使用してください。

次のステップ

APIリファレンス

このページでは、Laurusの最も重要な型とメソッドのクイックリファレンスを提供します。完全な詳細については、Rustdocを生成してください。

cargo doc --open

Engine

すべてのインデキシングと検索操作を統合する中心的なコーディネーターです。

メソッド説明
Engine::builder(storage, schema)EngineBuilder を作成
engine.put_document(id, doc).await?ドキュメントのUpsert(IDが存在する場合は置き換え)
engine.add_document(id, doc).await?ドキュメントをチャンクとして追加(複数のチャンクが同一IDを共有可能)
engine.delete_documents(id).await?外部IDによるすべてのドキュメント/チャンクの削除
engine.get_documents(id).await?外部IDによるすべてのドキュメント/チャンクの取得
engine.search(request).await?検索リクエストの実行
engine.commit().await?保留中のすべての変更をストレージにフラッシュ
engine.add_field(name, field_option).await?稼働中のエンジンにフィールドを動的に追加
engine.delete_field(name).await?稼働中のエンジンからフィールドを動的に削除
engine.schema()現在のスキーマへの参照を取得
engine.stats()?インデックス統計の取得

put_documentadd_document の違い: put_document はUpsertを実行します。同じ外部IDのドキュメントが既に存在する場合、削除して置き換えます。add_document は常に追加し、複数のドキュメントチャンクが同じ外部IDを共有できます。詳細は Schema & Fields – ドキュメントのインデキシング を参照してください。

EngineBuilder

メソッド説明
EngineBuilder::new(storage, schema)StorageとSchemaでBuilderを作成
.analyzer(Arc<dyn Analyzer>)テキストAnalyzerを設定(デフォルト: StandardAnalyzer
.embedder(Arc<dyn Embedder>)ベクトルEmbedderを設定(オプション)
.build().await?Engine を構築

Schema

ドキュメント構造を定義します。

メソッド説明
Schema::builder()SchemaBuilder を作成

SchemaBuilder

メソッド説明
.add_text_field(name, TextOption)全文検索フィールドを追加
.add_integer_field(name, IntegerOption)整数フィールドを追加(IntegerOption::multi_valued = true で多値配列対応)
.add_float_field(name, FloatOption)浮動小数点フィールドを追加(FloatOption::multi_valued = true で多値配列対応)
.add_boolean_field(name, BooleanOption)真偽値フィールドを追加
.add_datetime_field(name, DateTimeOption)日時フィールドを追加
.add_geo_field(name, GeoOption)2D 地理(緯度/経度)フィールドを追加
.add_geo3d_field(name, Geo3dOption)3D ECEF 直交座標系の点フィールド(x, y, z メートル単位)を追加
.add_bytes_field(name, BytesOption)バイナリフィールドを追加
.add_hnsw_field(name, HnswOption)HNSWベクトルフィールドを追加
.add_flat_field(name, FlatOption)Flatベクトルフィールドを追加
.add_ivf_field(name, IvfOption)IVFベクトルフィールドを追加
.add_default_field(name)デフォルト検索フィールドを設定
.add_analyzer(name, AnalyzerDefinition)カスタム Analyzer パイプラインを登録
.add_embedder(name, EmbedderDefinition)Embedder 定義を登録
.dynamic_field_policy(DynamicFieldPolicy)未宣言フィールドのポリシー(Strict / Dynamic / Ignore)を設定
.build()Schema を構築

Document

名前付きフィールド値のコレクションです。

メソッド説明
Document::builder()DocumentBuilder を作成
doc.get(name)名前でフィールド値を取得
doc.has_field(name)フィールドが存在するか確認
doc.field_names()すべてのフィールド名を取得

DocumentBuilder

メソッド説明
.add_field(name, DataValue)任意の DataValue を追加
.add_text(name, value)テキストフィールドを追加
.add_integer(name, value)単一値の整数フィールドを追加
.add_float(name, value)単一値の浮動小数点フィールドを追加
.add_boolean(name, value)真偽値フィールドを追加
.add_datetime(name, value)日時フィールドを追加
.add_vector(name, vec)事前計算済みベクトルを追加
.add_geo(name, lat, lon)2D 地理ポイントを追加
.add_geo_ecef(name, x, y, z)3D ECEF 直交座標系の点(メートル単位)を追加
.add_int64_array(name, values)多値整数フィールドを追加
.add_float64_array(name, values)多値浮動小数点フィールドを追加
.add_bytes(name, data)バイナリデータを追加
.build()Document を構築

SearchRequestBuilder

メソッド説明
SearchRequestBuilder::new()新しいBuilderを作成
.query_dsl(dsl)統合DSLクエリ文字列を設定
.lexical_query(query)Lexical検索クエリを設定(LexicalSearchQuery
.vector_query(query)Vector検索クエリを設定(VectorSearchQuery
.filter_query(query)プレフィルタクエリを設定
.fusion_algorithm(algo)フュージョンアルゴリズムを設定(デフォルト: RRF)
.limit(n)最大結果数(デフォルト: 10)
.offset(n)N件スキップ(デフォルト: 0)
.add_field_boost(field, boost)Lexical検索のフィールドブーストを追加
.lexical_min_score(f32)Lexical検索の最小スコアしきい値
.lexical_timeout_ms(u64)Lexical検索のタイムアウト(ミリ秒)
.lexical_parallel(bool)Lexical検索の並列実行を有効化
.sort_by(SortField)Lexical検索のソート順を設定
.vector_score_mode(VectorScoreMode)Vector検索のスコア結合モードを設定
.vector_min_score(f32)Vector検索の最小スコアしきい値
.build()SearchRequest を構築

VectorSearchRequestBuilder

メソッド説明
VectorSearchRequestBuilder::new()新しいBuilderを作成
.add_text(field, text)フィールドのテキストクエリを追加
.add_vector(field, vector)事前計算済みクエリベクトルを追加
.add_bytes(field, bytes, mime)バイナリペイロードを追加(マルチモーダル用)
.limit(n)最大結果数
.score_mode(VectorScoreMode)スコア結合モード(WeightedSum、MaxSim)
.min_score(f32)最小スコアしきい値
.field(name)検索を特定のフィールドに制限
.build()リクエストを構築

SearchResult

フィールド説明
idString外部ドキュメントID
scoref32関連度スコア
documentOption<Document>ドキュメント内容(ロードされた場合)

FusionAlgorithm

バリアント説明
RRF { k: f64 }Reciprocal Rank Fusion(デフォルト k=60.0)
WeightedSum { lexical_weight, vector_weight }スコアの線形結合

クエリタイプ(Lexical)

クエリ説明
TermQuery::new(field, term)完全一致TermQuery::new("body", "rust")
PhraseQuery::new(field, terms)フレーズ一致PhraseQuery::new("body", vec!["machine".into(), "learning".into()])
BooleanQueryBuilder::new()ブール結合.must(q1).should(q2).must_not(q3).build()
FuzzyQuery::new(field, term)あいまい一致(デフォルト max_edits=2)FuzzyQuery::new("body", "programing").max_edits(1)
WildcardQuery::new(field, pattern)ワイルドカードWildcardQuery::new("file", "*.pdf")
NumericRangeQuery::new(...)数値範囲Lexical Search を参照
GeoDistanceQuery::within_radius(...)2D 地理半径Lexical Search を参照
GeoBoundingBoxQuery::within_bounding_box(...)2D 地理バウンディングボックスLexical Search を参照
Geo3dDistanceQuery::within_sphere(...)3D ECEF 球3D 地理検索 を参照
Geo3dBoundingBoxQuery::within_box(...)3D ECEF 軸並行バウンディングボックス3D 地理検索 を参照
Geo3dNearestQuery::k_nearest(...)3D ECEF k-NN3D 地理検索 を参照
SpanNearQuery::new(...)近接Lexical Search を参照
PrefixQuery::new(field, prefix)前方一致PrefixQuery::new("body", "pro")
RegexpQuery::new(field, pattern)?正規表現一致RegexpQuery::new("body", "^pro.*ing$")?

クエリパーサー

パーサー説明
LexicalQueryParser::new(analyzer)Lexical DSL クエリをパース
VectorQueryParser::new(embedder)Vector DSL クエリをパース
UnifiedQueryParser::new(lexical, vector)Lexical / Vector 両方を含むハイブリッド DSL クエリをパース

Analyzer

説明
StandardAnalyzerRegexTokenizer + 小文字化 + ストップワード
SimpleAnalyzerトークン化のみ(フィルタリングなし)
EnglishAnalyzerRegexTokenizer + 小文字化 + 英語ストップワード
JapaneseAnalyzer日本語形態素解析
KeywordAnalyzerトークン化なし(完全一致)
PipelineAnalyzerカスタムTokenizer + フィルタチェーン
PerFieldAnalyzerフィールドごとのAnalyzerディスパッチ

Embedder

Feature Flag説明
CandleBertEmbedderembeddings-candleローカルBERTモデル
OpenAIEmbedderembeddings-openaiOpenAI API
CandleClipEmbedderembeddings-multimodalローカルCLIPモデル
PrecomputedEmbedder(デフォルト)事前計算済みベクトル
PerFieldEmbedder(デフォルト)フィールドごとのEmbedderディスパッチ

Storage

説明
MemoryStorageインメモリ(非永続)
FileStorageファイルシステムベース(メモリマップドI/O用の use_mmap をサポート)
StorageFactory::create(config)設定から作成

DataValue

バリアントRust型
DataValue::Null
DataValue::Bool(bool)bool
DataValue::Int64(i64)i64
DataValue::Float64(f64)f64
DataValue::Text(String)String
DataValue::Bytes(Vec<u8>, Option<String>)(data, mime_type)
DataValue::Vector(Vec<f32>)事前計算済みベクトル
DataValue::DateTime(DateTime<Utc>)chrono::DateTime<Utc>
DataValue::Geo(GeoPoint)(latitude, longitude)(WGS84)
DataValue::GeoEcef(GeoEcefPoint)(x, y, z) ECEF 直交座標系(メートル単位)
DataValue::Int64Array(Vec<i64>)多値整数(multi_valued フィールドオプションが必要)
DataValue::Float64Array(Vec<f64>)多値浮動小数点数(multi_valued フィールドオプションが必要)

CLI 概要

Laurus はコマンドラインツール laurus を提供しており、コードを書かずにインデックスの作成、ドキュメントの管理、検索クエリの実行が可能です。

機能

  • インデックス管理 – TOML スキーマファイルからインデックスを作成・検査。対話式スキーマジェネレーター付き
  • ドキュメント CRUD – JSON によるドキュメントの追加、取得、削除
  • 検索Query DSL を使用したクエリ実行
  • デュアル出力 – 人間が読みやすいテーブル形式または機械処理向け JSON 形式
  • 対話型 REPL – ライブセッションでインデックスを操作
  • gRPC サーバーlaurus servegRPC サーバーを起動

はじめに

# インストール
cargo install laurus-cli

# スキーマを対話的に生成
laurus create schema

# スキーマからインデックスを作成
laurus --index-dir ./my_index create index --schema schema.toml

# ドキュメントを追加
laurus --index-dir ./my_index add doc --id doc1 --data '{"title":"Hello","body":"World"}'

# 変更をコミット
laurus --index-dir ./my_index commit

# 検索
laurus --index-dir ./my_index search "body:world"

詳細はサブセクションを参照してください:

インストール

crates.io からインストール

cargo install laurus-cli

これにより laurus バイナリが ~/.cargo/bin/ にインストールされます。

ソースからインストール

git clone https://github.com/mosuka/laurus.git
cd laurus
cargo install --path laurus-cli

確認

laurus --version

ハンズオンチュートリアル

このチュートリアルでは、laurus CLI を使った一連のワークフローを体験します。スキーマの作成、インデックスの構築、ドキュメントの登録、検索、更新、削除、そしてインタラクティブ REPL の使い方を順を追って説明します。

前提条件

Step 1: スキーマの作成

まず、インデックスの構造を定義するスキーマファイルを作成します。対話形式で生成することもできます:

laurus create schema

対話ウィザードがフィールドの定義をガイドします。このチュートリアルでは、手動でスキーマファイルを作成します:

cat > schema.toml << 'EOF'
default_fields = ["title", "body"]

[fields.title.Text]
indexed = true
stored = true
term_vectors = false

[fields.body.Text]
indexed = true
stored = true
term_vectors = false

[fields.category.Text]
indexed = true
stored = true
term_vectors = false
EOF

3 つのテキストフィールドを定義しています。default_fields を設定することで、フィールド指定なしのクエリは titlebody の両方を検索します。

Step 2: インデックスの作成

スキーマを使ってインデックスを作成します:

laurus --index-dir ./tutorial_data create index --schema schema.toml

インデックスが作成されたことを確認します:

laurus --index-dir ./tutorial_data get stats

ドキュメント数が 0 であることが表示されます。

Step 3: ドキュメントの登録

ドキュメントをインデックスに追加します。各ドキュメントには ID と JSON 形式のフィールド値が必要です:

laurus --index-dir ./tutorial_data add doc \
  --id doc001 \
  --data '{"title":"Introduction to Rust Programming","body":"Rust is a modern systems programming language that focuses on safety, speed, and concurrency.","category":"programming"}'
laurus --index-dir ./tutorial_data add doc \
  --id doc002 \
  --data '{"title":"Web Development with Rust","body":"Building web applications with Rust has become increasingly popular. Frameworks like Actix and Rocket make it easy to create fast and secure web services.","category":"web-development"}'
laurus --index-dir ./tutorial_data add doc \
  --id doc003 \
  --data '{"title":"Python for Data Science","body":"Python is the most popular language for data science and machine learning. Libraries like NumPy and Pandas provide powerful tools for data analysis.","category":"data-science"}'

Step 4: 変更のコミット

ドキュメントはコミットするまで検索対象になりません:

laurus --index-dir ./tutorial_data commit

Step 5: ドキュメントの検索

基本的な検索

“rust” を含むドキュメントを検索します:

laurus --index-dir ./tutorial_data search "rust"

デフォルトフィールド(titlebody)が検索されます。doc001doc002 が返されます。

フィールド指定検索

title フィールドのみを検索します:

laurus --index-dir ./tutorial_data search "title:python"

doc003 のみが返されます。

カテゴリ検索

laurus --index-dir ./tutorial_data search "category:programming"

doc001 のみが返されます。

ブーリアンクエリ

+(必須)と -(除外)で条件を組み合わせます:

laurus --index-dir ./tutorial_data search "+body:rust -body:web"

“rust” を含み “web” を含まない doc001 のみが返されます。

フレーズ検索

完全一致するフレーズを検索します:

laurus --index-dir ./tutorial_data search 'body:"data science"'

doc003 のみが返されます。

あいまい検索

~ を使ってタイプミスに対応した検索を行います:

laurus --index-dir ./tutorial_data search "body:programing~1"

タイプミスがあっても “programming” にマッチします。

JSON 出力

プログラムでの利用に向けて JSON 形式で結果を取得します:

laurus --index-dir ./tutorial_data --format json search "rust"

Step 6: ドキュメントの取得

ID を指定して特定のドキュメントを取得します:

laurus --index-dir ./tutorial_data get docs --id doc001

Step 7: ドキュメントの削除

ドキュメントを削除してコミットします:

laurus --index-dir ./tutorial_data delete docs --id doc003
laurus --index-dir ./tutorial_data commit

削除されたことを確認します:

laurus --index-dir ./tutorial_data search "python"

結果は返されません。

Step 8: REPL を使う

REPL はインデックスを対話的に操作するためのインタラクティブセッションです:

laurus --index-dir ./tutorial_data repl

REPL で以下のコマンドを試してみてください:

> get stats
> search rust
> add doc doc004 {"title":"Go Programming","body":"Go is a statically typed language designed for simplicity and efficiency.","category":"programming"}
> commit
> search programming
> get docs doc004
> delete docs doc004
> commit
> quit

REPL はコマンド履歴(上下キー)や行編集に対応しています。

Step 9: クリーンアップ

チュートリアルで作成したデータを削除します:

rm -rf ./tutorial_data schema.toml

次のステップ

コマンドリファレンス

グローバルオプション

すべてのコマンドで以下のオプションが使用できます:

オプション環境変数デフォルト説明
--index-dir <PATH>LAURUS_INDEX_DIR./laurus_indexインデックスデータディレクトリのパス
--format <FORMAT>table出力形式: table または json
# 例: カスタムデータディレクトリで JSON 出力を使用
laurus --index-dir /var/data/my_index --format json search "title:rust"

create — リソースの作成

create index

新しいインデックスを作成します。--schema が指定された場合はその TOML ファイルを使用し、省略された場合は対話型スキーマウィザードが起動します。

laurus create index [--schema <FILE>]

引数:

フラグ必須説明
--schema <FILE>いいえインデックススキーマを定義する TOML ファイルのパス。省略時はインデックスディレクトリに既存の schema.toml があればそれを使用し、なければ対話型ウィザードが起動します。

スキーマファイルの形式:

スキーマファイルは Laurus ライブラリの Schema 型と同じ構造に従います。詳細はスキーマフォーマットリファレンスを参照してください。例:

default_fields = ["title", "body"]

[fields.title.Text]
stored = true
indexed = true

[fields.body.Text]
stored = true
indexed = true

[fields.category.Text]
stored = true
indexed = true

例:

# スキーマファイルから作成
laurus --index-dir ./my_index create index --schema schema.toml
# Index created at ./my_index.

# 対話型ウィザード(--schema フラグなし)
laurus --index-dir ./my_index create index
# === Laurus Schema Generator ===
# Field name: title
# ...
# Index created at ./my_index.

注意: schema.tomlstore/ の両方が存在する場合はエラーが返されます。再作成するにはインデックスディレクトリを削除してください。schema.toml のみ存在する場合(作成が中断された場合など)は、--schema なしで create index を実行すると既存スキーマからストレージが復旧されます。

create schema

対話式ウィザードを通じてスキーマ TOML ファイルを生成します。

laurus create schema [--output <FILE>]

引数:

フラグ必須デフォルト説明
--output <FILE>いいえschema.toml生成されるスキーマの出力ファイルパス

ウィザードは以下の手順で進みます:

  1. フィールド定義 — フィールド名を入力し、型を選択し、型固有のオプションを設定
  2. 繰り返し — 必要な数だけフィールドを追加
  3. デフォルトフィールド — デフォルトの検索対象とする Lexical フィールドを選択
  4. プレビュー — 保存前に生成された TOML を確認
  5. 保存 — スキーマファイルを書き出し

サポートされるフィールド型:

カテゴリオプション
TextLexicalindexed, stored, term_vectors
IntegerLexicalindexed, stored
FloatLexicalindexed, stored
BooleanLexicalindexed, stored
DateTimeLexicalindexed, stored
GeoLexicalindexed, stored
Geo3dLexicalindexed, stored
BytesLexicalstored
HnswVectordimension, distance, m, ef_construction
FlatVectordimension, distance
IvfVectordimension, distance, n_clusters, n_probe

例:

# schema.toml を対話的に生成
laurus create schema

# 出力パスを指定
laurus create schema --output my_schema.toml

# 生成されたスキーマからインデックスを作成
laurus create index --schema schema.toml

get — リソースの取得

get stats

インデックスの統計情報を表示します。

laurus get stats

テーブル出力の例:

Document count: 42

Vector fields:
╭──────────┬─────────┬───────────╮
│ Field    │ Vectors │ Dimension │
├──────────┼─────────┼───────────┤
│ text_vec │ 42      │ 384       │
╰──────────┴─────────┴───────────╯

JSON 出力の例:

laurus --format json get stats
{
  "document_count": 42,
  "fields": {
    "text_vec": {
      "vector_count": 42,
      "dimension": 384
    }
  }
}

get schema

現在のインデックスのスキーマを JSON 形式で表示します。

laurus get schema

例:

laurus get schema
# {
#   "fields": { ... },
#   "default_fields": ["title", "body"],
#   ...
# }

get docs

外部 ID で全ドキュメント(チャンクを含む)を取得します。

laurus get docs --id <ID>

テーブル出力の例:

╭──────┬─────────────────────────────────────────╮
│ ID   │ Fields                                  │
├──────┼─────────────────────────────────────────┤
│ doc1 │ body: This is a test, title: Hello World │
╰──────┴─────────────────────────────────────────╯

JSON 出力の例:

laurus --format json get docs --id doc1
[
  {
    "id": "doc1",
    "document": {
      "title": "Hello World",
      "body": "This is a test document."
    }
  }
]

add — リソースの追加

add doc

インデックスにドキュメントを追加します。ドキュメントは commit を実行するまで検索対象になりません。

laurus add doc --id <ID> --data <JSON>

引数:

フラグ必須説明
--id <ID>はい外部ドキュメント ID(文字列)
--data <JSON>はいJSON 文字列としてのドキュメントフィールド

JSON フォーマットはフィールド名と値を対応付けたフラットなオブジェクトです:

{
  "title": "Introduction to Rust",
  "body": "Rust is a systems programming language.",
  "category": "programming"
}

例:

laurus add doc --id doc1 --data '{"title":"Hello World","body":"This is a test document."}'
# Document 'doc1' added. Run 'commit' to persist changes.

ヒント: 複数のドキュメントが同じ外部 ID を共有できます(チャンキングパターン)。各チャンクに対して add doc を使用してください。


put — リソースの上書き(Upsert)

put doc

インデックスにドキュメントを上書き(upsert)します。同じ ID のドキュメントが既に存在する場合、全チャンクが削除されてから新しいドキュメントがインデックスされます。ドキュメントは commit を実行するまで検索対象になりません。

laurus put doc --id <ID> --data <JSON>

引数:

フラグ必須説明
--id <ID>はい外部ドキュメント ID(文字列)
--data <JSON>はいJSON 文字列としてのドキュメントフィールド

例:

laurus put doc --id doc1 --data '{"title":"Updated Title","body":"This replaces the existing document."}'
# Document 'doc1' put (upserted). Run 'commit' to persist changes.

注意: add doc とは異なり、put doc は指定 ID の既存チャンクをすべて置き換えます。チャンクを追記したい場合は add doc を、ドキュメント全体を置き換えたい場合は put doc を使用してください。


add field

既存のインデックスにフィールドを動的に追加します。

laurus add field --index-dir ./data \
    --name category \
    --field-option '{"Text": {"indexed": true, "stored": true}}'

--field-option 引数はスキーマファイルと同じ外部タグ付き JSON 形式を受け付けます。 フィールド追加後、スキーマは自動的に永続化されます。


delete — リソースの削除

delete field

スキーマからフィールドを動的に削除します。既にインデックスされたデータは残りますが、削除されたフィールドにはアクセスできなくなります。

laurus delete field --name <FIELD_NAME>

例:

laurus delete field --name category
# Field 'category' deleted.

delete docs

外部 ID で全ドキュメント(チャンクを含む)を削除します。

laurus delete docs --id <ID>

例:

laurus delete docs --id doc1
# Documents 'doc1' deleted. Run 'commit' to persist changes.

commit

保留中の変更(追加と削除)をインデックスにコミットします。コミットするまで、変更は検索に反映されません。

laurus commit

例:

laurus --index-dir ./my_index commit
# Changes committed successfully.

search

Query DSL を使用して検索クエリを実行します。

laurus search <QUERY> [--limit <N>] [--offset <N>]

引数:

引数 / フラグ必須デフォルト説明
<QUERY>はいLaurus Query DSL によるクエリ文字列
--limit <N>いいえ10最大結果件数
--offset <N>いいえ0スキップする結果件数

クエリ構文の例:

# Term クエリ
laurus search "body:rust"

# Phrase クエリ
laurus search 'body:"machine learning"'

# Boolean クエリ
laurus search "+body:programming -body:python"

# Fuzzy クエリ(タイポ許容)
laurus search "body:programing~2"

# Wildcard クエリ
laurus search "title:intro*"

# Range クエリ
laurus search "price:[10 TO 50]"

# 3D 地理クエリ(球 / バウンディングボックス / k-NN)
laurus search "position:geo3d_distance(-3955182, 3350553, 3700276, 5000)"
laurus search "position:geo3d_bbox(-4000000, 3300000, 3650000, -3900000, 3400000, 3750000)"
laurus search "position:geo3d_nearest(-3955182, 3350553, 3700276, 10)"

テーブル出力の例:

╭──────┬────────┬─────────────────────────────────────────╮
│ ID   │ Score  │ Fields                                  │
├──────┼────────┼─────────────────────────────────────────┤
│ doc1 │ 0.8532 │ body: Rust is a systems..., title: Intr │
│ doc3 │ 0.4210 │ body: JavaScript powers..., title: Web  │
╰──────┴────────┴─────────────────────────────────────────╯

JSON 出力の例:

laurus --format json search "body:rust" --limit 5
[
  {
    "id": "doc1",
    "score": 0.8532,
    "document": {
      "title": "Introduction to Rust",
      "body": "Rust is a systems programming language."
    }
  }
]

repl

対話型 REPL セッションを開始します。詳細は REPL を参照してください。

laurus repl

serve

gRPC サーバー(およびオプションで HTTP Gateway)を起動します。

laurus serve [OPTIONS]

起動オプション、設定、使用例については laurus-server のドキュメントを参照してください:


mcp

Model Context Protocol(MCP)サーバーを stdio 上で起動します。MCP サーバーを介して、Claude Code や Claude Desktop のような AI アシスタントが標準化されたツール群(create_indexadd_documentsearch など)で稼働中の laurus-server を操作できます。

laurus mcp [--endpoint <URL>]

引数:

フラグ環境変数必須説明
--endpoint <URL>LAURUS_ENDPOINTいいえ稼働中の laurus-server の gRPC エンドポイント(例: http://localhost:50051)。省略すると未接続で起動し、クライアントから後で connect MCP ツールを呼び出して接続できます。

使用例:

# ローカルの laurus-server に事前接続して MCP サーバーを起動
laurus mcp --endpoint http://localhost:50051

# 未接続で起動し、クライアントが最初に `connect` を呼ぶ運用
laurus mcp

MCP サーバーが公開する全ツールの一覧、および Claude Code や Claude Desktop と連携する設定方法については laurus-mcp のドキュメントを参照してください。

スキーマフォーマットリファレンス

スキーマファイルはインデックスの構造を定義します。どのフィールドが存在するか、その型、およびインデックスの方法を指定します。Laurus はスキーマファイルに TOML 形式を使用します。

概要

スキーマは 3 つのトップレベル要素で構成されます:

# スキーマに宣言されていないフィールドの扱い。省略時は "dynamic"。
dynamic_field_policy = "dynamic"

# クエリでフィールドが指定されていない場合にデフォルトで検索するフィールド。
default_fields = ["title", "body"]

# フィールド定義。各フィールドには名前と型付き設定があります。
[fields.<field_name>.<FieldType>]
# ... 型固有のオプション
  • dynamic_field_policy — スキーマに宣言されていないフィールドがドキュメントに含まれる場合の挙動を制御します。値は "strict" / "dynamic" / "ignore"。デフォルトは "dynamic"。詳細および「dynamic では情報損失が起きうる」という警告は 動的スキーマ を参照してください。
  • default_fieldsQuery DSL でデフォルトの検索対象として使用されるフィールド名のリストです。Lexical フィールド(Text、Integer、Float など)のみデフォルトフィールドに指定できます。このキーはオプションで、デフォルトは空のリストです。
  • fields — フィールド名とその型付き設定のマップです。各フィールドにはフィールド型を1つだけ指定する必要があります。

フィールド命名規則

  • フィールド名は任意の文字列です(例: titlebody_veccreated_at)。
  • アンダースコア(_)で始まるフィールド名はエンジンの予約領域です。例外として _id(自動管理)のみ許可されます。それ以外の _ プレフィックス名を宣言しようとするとエラーになります。
  • フィールド名はスキーマ内で一意である必要があります。

フィールド型

フィールドは Lexical(キーワード/全文検索用)と Vector(類似検索用)の2つのカテゴリに分類されます。1つのフィールドが両方を兼ねることはできません。

Lexical フィールド

Text

全文検索可能なフィールドです。テキストは解析パイプライン(トークン化、正規化、ステミングなど)によって処理されます。

[fields.title.Text]
indexed = true       # このフィールドを検索用にインデックスするかどうか
stored = true        # 取得用に元の値を保存するかどうか
term_vectors = false # タームの位置を保存するかどうか(フレーズクエリ、ハイライト用)
オプションデフォルト説明
indexedbooltrueこのフィールドの検索を有効にする
storedbooltrue結果に返せるよう元の値を保存する
term_vectorsbooltrueフレーズクエリ、ハイライト、More-Like-This 用にタームの位置を保存する

Integer

64ビット符号付き整数フィールド。範囲クエリと完全一致をサポートします。

[fields.year.Integer]
indexed = true
stored = true
multi_valued = false
オプションデフォルト説明
indexedbooltrue範囲クエリおよび完全一致クエリを有効にする
storedbooltrue元の値を保存する
multi_valuedboolfalse整数の配列を受け付け、範囲クエリはいずれかの値が条件を満たせばマッチ(Lucene 流の “any match”、constant スコア)

Float

64ビット浮動小数点フィールド。範囲クエリをサポートします。

[fields.rating.Float]
indexed = true
stored = true
multi_valued = false
オプションデフォルト説明
indexedbooltrue範囲クエリを有効にする
storedbooltrue元の値を保存する
multi_valuedboolfalse浮動小数点の配列を受け付け、範囲クエリはいずれかの値が条件を満たせばマッチ(Lucene 流の “any match”、constant スコア)

Boolean

ブーリアンフィールド(true / false)。

[fields.published.Boolean]
indexed = true
stored = true
オプションデフォルト説明
indexedbooltrueブーリアン値によるフィルタリングを有効にする
storedbooltrue元の値を保存する

DateTime

UTC タイムスタンプフィールド。範囲クエリをサポートします。

[fields.created_at.DateTime]
indexed = true
stored = true
オプションデフォルト説明
indexedbooltrue日時の範囲クエリを有効にする
storedbooltrue元の値を保存する

Geo

地理座標フィールド(緯度/経度)。半径クエリおよびバウンディングボックスクエリをサポートします。

[fields.location.Geo]
indexed = true
stored = true
オプションデフォルト説明
indexedbooltrueGeo クエリ(半径、バウンディングボックス)を有効にする
storedbooltrue元の値を保存する

Geo3d

3D Earth-Centered Earth-Fixed (ECEF) 直交座標系の点フィールド(x / y / z はメートル単位)。geo3d_distance(球)、geo3d_bbox(3D AABB)、geo3d_nearest(k-NN)クエリをサポートします。座標系および wgs84_to_ecef / ecef_to_wgs84 の変換ユーティリティについては 3D 地理検索 (ECEF) を参照してください。

[fields.position.Geo3d]
indexed = true
stored = true
オプションデフォルト説明
indexedbooltrue3D 地理クエリ(geo3d_distancegeo3d_bboxgeo3d_nearest)を有効にする
storedbooltrue元の (x, y, z) 値を保存する

Bytes

生バイナリデータフィールド。インデックスされず、保存のみです。

[fields.thumbnail.Bytes]
stored = true
オプションデフォルト説明
storedbooltrueバイナリデータを保存する

Vector フィールド

Vector フィールドは近似最近傍探索(ANN: Approximate Nearest Neighbor)用にインデックスされます。dimension(各ベクトルの長さ)と distance メトリクスの指定が必要です。

Hnsw

HNSW(Hierarchical Navigable Small World)グラフインデックス。ほとんどのユースケースに最適で、速度と再現率(Recall)のバランスに優れています。

[fields.body_vec.Hnsw]
dimension = 384
distance = "Cosine"
m = 16
ef_construction = 200
base_weight = 1.0
オプションデフォルト説明
dimensioninteger128ベクトルの次元数(Embedding モデルの出力と一致させる必要あり)
distancestring"Cosine"距離メトリクス(距離メトリクスを参照)
minteger16ノードあたりの最大双方向接続数。大きいほど再現率が向上するがメモリ使用量が増加
ef_constructioninteger200インデックス構築時の探索幅。大きいほど品質が向上するが構築が遅くなる
base_weightfloat1.0ハイブリッド検索のスコア融合における重み
quantizerobject"Scalar8Bit"量子化方式(量子化を参照)。必須。デフォルトは Issue #481 Stage 1 で導入された int8 形式を保つ。
rerank_storagestring(省略)Stage 2 rerank sidecar(Rerank Storage)。"F32" でフィールド単位の f32 sidecar を有効化し、検索時に int8 候補を元のベクトルで再スコアできるようにする。省略すると Stage 1 int8-only の挙動を維持。

チューニングガイドライン:

  • m: 12〜48 が一般的です。高次元ベクトルには大きい値を使用してください。
  • ef_construction: 100〜500。大きい値ほどグラフの品質が向上しますが、構築時間が増加します。
  • dimension: Embedding モデルの出力次元と正確に一致させる必要があります(例: all-MiniLM-L6-v2 は 384、BERT-base は 768、text-embedding-3-small は 1536)。

Flat

ブルートフォース線形スキャンインデックス。近似を行わず正確な結果を返します。小規模データセット(10,000 ベクトル未満)に最適です。

[fields.embedding.Flat]
dimension = 384
distance = "Cosine"
base_weight = 1.0
オプションデフォルト説明
dimensioninteger128ベクトルの次元数
distancestring"Cosine"距離メトリクス(距離メトリクスを参照)
base_weightfloat1.0ハイブリッド検索のスコア融合における重み
quantizerobject"Scalar8Bit"量子化方式(量子化を参照)。必須。デフォルトは Issue #481 Stage 1 で導入された int8 形式を保つ。
rerank_storagestring(省略)Rerank Storage 用に予約。現状 sidecar を書き出すのは HNSW writer のみで、Flat / IVF はスキーマの対称性のためにフィールドを受け付けるが sidecar の書き出し・読み込みは行わない。

Ivf

IVF(Inverted File Index)。ベクトルをクラスタリングし、クラスタのサブセットのみを検索します。大規模データセットに適しています。

[fields.embedding.Ivf]
dimension = 384
distance = "Cosine"
n_clusters = 100
n_probe = 1
base_weight = 1.0
オプションデフォルト説明
dimensioninteger(必須)ベクトルの次元数
distancestring"Cosine"距離メトリクス(距離メトリクスを参照)
n_clustersinteger100クラスタ数。多いほど細かい分割が可能
n_probeinteger1クエリ時に検索するクラスタ数。大きいほど再現率が向上するが遅くなる
base_weightfloat1.0ハイブリッド検索のスコア融合における重み
quantizerobject"Scalar8Bit"量子化方式(量子化を参照)。必須。デフォルトは Issue #481 Stage 1 で導入された int8 形式を保つ。
rerank_storagestring(省略)Rerank Storage 用に予約。現状 sidecar を書き出すのは HNSW writer のみで、Flat / IVF はスキーマの対称性のためにフィールドを受け付けるが sidecar の書き出し・読み込みは行わない。

注意: Hnsw および Flat とは異なり、Ivf の dimension フィールドは必須であり、デフォルト値はありません。

チューニングガイドライン:

  • n_clusters: 一般的な経験則は sqrt(N)(N はベクトルの総数)です。
  • n_probe: 1 から始めて、再現率が許容範囲になるまで増やしてください。一般的な範囲は 1〜20 です。

距離メトリクス

Vector フィールドの distance オプションは以下の値を受け付けます:

説明使用場面
"Cosine"コサイン距離(1 - コサイン類似度)。デフォルト。正規化されたテキスト/画像 Embedding
"Euclidean"L2(ユークリッド)距離空間データ、正規化されていないベクトル
"Manhattan"L1(マンハッタン)距離スパースな特徴ベクトル
"DotProduct"内積(大きいほど類似度が高い)大きさが重要な正規化済みベクトル
"Angular"角度距離コサインに似ているが角度に基づく

ほとんどの Embedding モデル(BERT、Sentence Transformers、OpenAI など)では "Cosine" が適切な選択です。

量子化

Vector フィールドはディスク上で 8 ビットスカラー量子化された整数 として保存されます(Issue #481 Stage 1)。量子化は必須となり、以前 の「量子化なし」モードは廃止されました。quantizer オプションは Scalar8Bit がデフォルトで、TOML から省略可能です。

Scalar 8-bit(デフォルト)

per-segment global affine による u8 量子化。各 f32 コンポーネント を 1 バイトに圧縮(約 4 倍のメモリ削減)し、recall 損失は実用上ほぼ 無視できる範囲。

[fields.embedding.Hnsw]
dimension = 384
distance = "Cosine"
# quantizer = "Scalar8Bit"  # デフォルトのため省略可

Product Quantization(予約)

Issue #481 Stage 3 で実装予定。現状 writer / searcher が選択時に NotImplemented を返します。Stage 3 着地後に追加の TOML 変更なしで オプトインできるよう、変種は予約してあります。

[fields.embedding.Hnsw]
dimension = 384
distance = "Cosine"

[fields.embedding.Hnsw.quantizer.ProductQuantization]
subvector_count = 48
オプション説明
subvector_countintegerサブベクトルの数。dimension を均等に割り切れる必要があります。

破壊的変更(Issue #481 Stage 1): quantizer を「なし」に 設定するスキーマはもはや有効ではありません。Stage 1 より前の laurus でビルドした既存 vector index は読み取れないため、アップ グレード後にソースデータから再構築してください。

Rerank Storage

任意の Stage 2 sidecar(Issue #481)。元の完全精度ベクトルを int8 セグメントの隣に保持し、HNSW searcher が int8 で広めに候補を取得 (高速)してから上位 top_k * rerank_factor 件を完全な f32 値で 再スコア(高精度)できるようにします。

sidecar はフィールド単位で rerank_storage で設定します:

[fields.embedding.Hnsw]
dimension = 384
distance = "Cosine"
rerank_storage = "F32"  # opt-in。省略すると Stage 1 int8-only の挙動を維持
ディスク追加コスト説明
"F32"+4 bytes/dim/vectorIEEE-754 単精度 sidecar(Lucene 99 / FAISS 互換)。

省略した場合 sidecar は書かれず、フィールドは Stage 1 int8-only の検索パスを維持します。rerank_storage を持たないフィールドに 対して rerank_factor を渡したクエリは silent に Stage 1 ランキングへフォールバックします — Stage 1 セグメントから index 作成時に捨てられた f32 情報を復元することはできません。

スコープ: Stage 2 は HNSW のみで実装しています。Flat / IVF は スキーマの対称性のためにフィールドを受け付けますが、現状 sidecar の書き出し・読み込みは行いません。

完全な例

全文検索のみ

Lexical 検索のみのシンプルなブログ記事インデックス:

default_fields = ["title", "body"]

[fields.title.Text]
indexed = true
stored = true
term_vectors = false

[fields.body.Text]
indexed = true
stored = true
term_vectors = false

[fields.category.Text]
indexed = true
stored = true
term_vectors = false

[fields.published_at.DateTime]
indexed = true
stored = true

Vector 検索のみ

セマンティック類似検索用の Vector のみのインデックス:

[fields.embedding.Hnsw]
dimension = 768
distance = "Cosine"
m = 16
ef_construction = 200

ハイブリッド検索(Lexical + Vector)

Lexical 検索と Vector 検索を組み合わせた両方の長所を活かす検索:

default_fields = ["title", "body"]

[fields.title.Text]
indexed = true
stored = true
term_vectors = false

[fields.body.Text]
indexed = true
stored = true
term_vectors = true

[fields.category.Text]
indexed = true
stored = true
term_vectors = false

[fields.body_vec.Hnsw]
dimension = 384
distance = "Cosine"
m = 16
ef_construction = 200

ヒント: 1つのフィールドが Lexical と Vector の両方を兼ねることはできません。別々のフィールド(例: テキスト用の body、Embedding 用の body_vec)を使用し、どちらも同じソースコンテンツにマッピングしてください。

E コマースの商品インデックス

複数のフィールド型を組み合わせたより複雑なスキーマ:

default_fields = ["name", "description"]

[fields.name.Text]
indexed = true
stored = true
term_vectors = false

[fields.description.Text]
indexed = true
stored = true
term_vectors = true

[fields.price.Float]
indexed = true
stored = true

[fields.in_stock.Boolean]
indexed = true
stored = true

[fields.created_at.DateTime]
indexed = true
stored = true

[fields.location.Geo]
indexed = true
stored = true

[fields.description_vec.Hnsw]
dimension = 384
distance = "Cosine"

スキーマの生成

CLI を使用して対話的にスキーマ TOML ファイルを生成できます:

laurus create schema
laurus create schema --output my_schema.toml

詳細は create schema を参照してください。

スキーマの使用

スキーマファイルが用意できたら、そこからインデックスを作成します:

laurus create index --schema schema.toml

または Rust でプログラム的に読み込みます:

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

let toml_str = std::fs::read_to_string("schema.toml")?;
let schema: Schema = toml::from_str(&toml_str)?;
}

REPL(対話モード)

REPL は、毎回 laurus コマンドをフルで入力することなく、インデックスを操作できる対話型セッションを提供します。

REPL の起動

laurus --index-dir ./my_index repl

指定されたディレクトリにインデックスが存在する場合、自動的に開かれます:

Laurus REPL (type 'help' for commands, 'quit' to exit)
laurus>

インデックスがまだ存在しない場合、インデックスなしで REPL が起動し、作成を案内します:

Laurus REPL — no index found at ./my_index.
Use 'create index <schema_path>' to create one, or 'help' for commands.
laurus>

利用可能なコマンド

コマンドは CLI と同じ <操作> <リソース> の順序に従います。

コマンド説明
create index [schema_path]インデックスを作成(パス省略時は対話型ウィザード)
create schema <output_path>対話型スキーマ生成ウィザード
search <query>インデックスを検索
add field <name> <json>スキーマにフィールドを追加
add doc <id> <json>ドキュメントを追加(追記、同一 ID で複数チャンク可)
put doc <id> <json>ドキュメントを上書き(同一 ID の既存チャンクを置換)
get statsインデックスの統計情報を表示
get schema現在のスキーマを表示
get docs <id>ID で全ドキュメント(チャンクを含む)を取得
delete field <name>スキーマからフィールドを削除
delete docs <id>ID で全ドキュメント(チャンクを含む)を削除
commit保留中の変更をコミット
help利用可能なコマンドを表示
quit / exitREPL を終了

注意: createhelpquit 以外のコマンドはインデックスがロードされている必要があります。インデックスがロードされていない場合、まず create index を実行するようメッセージが表示されます。

使用例

インデックスの作成

laurus> create index ./schema.toml
Index created at ./my_index.
laurus> add doc doc1 {"title":"Hello","body":"World"}
Document 'doc1' added.

検索

laurus> search body:rust
╭──────┬────────┬────────────────────────────────────╮
│ ID   │ Score  │ Fields                             │
├──────┼────────┼────────────────────────────────────┤
│ doc1 │ 0.8532 │ body: Rust is a systems..., title… │
╰──────┴────────┴────────────────────────────────────╯

フィールドの管理

laurus> add field category {"Text": {"indexed": true, "stored": true}}
Field 'category' added.
laurus> delete field category
Field 'category' deleted.

ドキュメントの追加とコミット

laurus> add doc doc4 {"title":"New Document","body":"Some content here."}
Document 'doc4' added.
laurus> commit
Changes committed.

情報の取得

laurus> get stats
Document count: 3

laurus> get schema
{
  "fields": { ... },
  "default_fields": ["title", "body"]
}

laurus> get docs doc4
╭──────┬───────────────────────────────────────────────╮
│ ID   │ Fields                                        │
├──────┼───────────────────────────────────────────────┤
│ doc4 │ body: Some content here., title: New Document │
╰──────┴───────────────────────────────────────────────╯

ドキュメントの削除

laurus> delete docs doc4
Documents 'doc4' deleted.
laurus> commit
Changes committed.

機能

  • 行編集 — 矢印キー、Home/End キー、および標準的な readline ショートカット
  • 履歴 — 上下矢印キーで以前のコマンドを呼び出し
  • Ctrl+C / Ctrl+D — REPL を正常に終了

サーバー概要

laurus-server クレートは、Laurus 検索エンジン用の gRPC サーバーとオプションの HTTP/JSON ゲートウェイを提供します。エンジンをメモリに常駐させることで、コマンド実行ごとの起動オーバーヘッドを排除します。

機能

  • 永続エンジン – インデックスはリクエスト間で開いたまま維持され、呼び出しごとの WAL リプレイが不要
  • フル gRPC API – インデックス管理、ドキュメント CRUD、コミット、検索(単発 + ストリーミング)
  • HTTP ゲートウェイ – gRPC と併用可能なオプションの HTTP/JSON ゲートウェイで REST スタイルのアクセスを提供
  • ヘルスチェック – ロードバランサーやオーケストレーター向けの標準ヘルスチェックエンドポイント
  • グレースフルシャットダウン – Ctrl+C / SIGINT で保留中の変更を自動的にコミット
  • TOML 設定 – オプションの設定ファイルと CLI・環境変数によるオーバーライド

アーキテクチャ

graph LR
    subgraph "laurus-server"
        GW["HTTP Gateway\n(axum)"]
        GRPC["gRPC Server\n(tonic)"]
        ENG["Engine\n(Arc&lt;RwLock&gt;)"]
    end

    Client1["HTTP Client"] --> GW
    Client2["gRPC Client"] --> GRPC
    GW --> GRPC
    GRPC --> ENG

gRPC サーバーは常に起動します。HTTP ゲートウェイはオプションで、HTTP/JSON リクエストを内部的に gRPC サーバーへプロキシします。

クイックスタート

# デフォルト設定で起動(gRPC ポート 50051)
laurus serve

# HTTP ゲートウェイ付きで起動
laurus serve --http-port 8080

# 設定ファイルを指定して起動
laurus serve --config config.toml

セクション

gRPC サーバーをはじめる

サーバーの起動

gRPC サーバーは laurus CLI の serve サブコマンドで起動します。

laurus serve [OPTIONS]

オプション

オプション短縮形環境変数デフォルト説明
--config <PATH>-cLAURUS_CONFIGTOML 設定ファイルのパス
--host <HOST>-HLAURUS_HOST0.0.0.0リッスンアドレス
--port <PORT>-pLAURUS_PORT50051リッスンポート
--http-port <PORT>LAURUS_HTTP_PORTHTTP ゲートウェイポート(設定すると HTTP ゲートウェイが有効化)

ログの詳細度は標準の RUST_LOG 環境変数で制御します(デフォルト: info)。 RUST_LOG=laurus=debug,tonic=warn のようなフィルタディレクティブの詳細は env_logger の構文を参照してください。

グローバルオプション --index-dir(環境変数: LAURUS_INDEX_DIR)でインデックスデータのディレクトリを指定します。

# CLI 引数を使用
laurus --index-dir ./my_index serve --port 8080

# 環境変数を使用
export LAURUS_INDEX_DIR=./my_index
export LAURUS_PORT=8080
export RUST_LOG=debug
laurus serve

起動時の動作

起動時、サーバーは設定されたデータディレクトリにある既存のインデックスを開こうとします。インデックスが存在しない場合、サーバーはインデックスなしで起動します。後から CreateIndex RPC でインデックスを作成できます。

設定

コマンドラインオプションの代わりに(または併用して)TOML 設定ファイルを使用できます。詳細は設定を参照してください。

laurus serve --config config.toml

HTTP ゲートウェイ

--http-port を設定すると、gRPC サーバーと並行して HTTP/JSON ゲートウェイが起動します。エンドポイントの詳細と使用例は HTTP ゲートウェイを参照してください。

laurus serve --http-port 8080

グレースフルシャットダウン

サーバーがシャットダウンシグナル(Ctrl+C / SIGINT)を受信すると、自動的に以下を実行します。

  1. 新しい接続の受け付けを停止
  2. インデックスへの保留中の変更をコミット
  3. 正常に終了

gRPC での接続

任意の gRPC クライアントでサーバーに接続できます。簡易テストには grpcurl が便利です。

# ヘルスチェック
grpcurl -plaintext localhost:50051 laurus.v1.HealthService/Check

# インデックスの作成
grpcurl -plaintext -d '{
  "schema": {
    "fields": {
      "title": {"text": {"indexed": true, "stored": true, "term_vectors": true}},
      "body": {"text": {"indexed": true, "stored": true, "term_vectors": true}}
    },
    "default_fields": ["title", "body"]
  }
}' localhost:50051 laurus.v1.IndexService/CreateIndex

# ドキュメントの追加
grpcurl -plaintext -d '{
  "id": "doc1",
  "document": {
    "fields": {
      "title": {"text_value": "Hello World"},
      "body": {"text_value": "This is a test document."}
    }
  }
}' localhost:50051 laurus.v1.DocumentService/AddDocument

# コミット
grpcurl -plaintext localhost:50051 laurus.v1.DocumentService/Commit

# 検索
grpcurl -plaintext -d '{"query": "body:test", "limit": 10}' \
  localhost:50051 laurus.v1.SearchService/Search

詳細は gRPC API リファレンスを参照してください。HTTP Gateway を使ったステップバイステップの操作ガイドはハンズオンチュートリアルを参照してください。

ハンズオンチュートリアル

このチュートリアルでは、laurus-server を使った一連のワークフローを体験します。サーバーの起動、インデックスの作成、ドキュメントの登録、検索、更新、削除を順を追って説明します。すべての操作は HTTP Gateway 経由の curl コマンドで行います。

前提条件

  • laurus CLI がインストール済み(インストール を参照)
  • curl が利用可能

Step 1: サーバーの起動

HTTP Gateway を有効にして laurus-server を起動します:

laurus --index-dir /tmp/laurus/tutorial serve --port 50051 --http-port 8080

gRPC サーバー(ポート 50051)と HTTP Gateway(ポート 8080)が起動したことを示すログが表示されます。

サーバーが正常に動作しているか確認します:

curl http://localhost:8080/v1/health

期待されるレスポンス:

{"status":"SERVING_STATUS_SERVING"}

Step 2: インデックスの作成

Lexical 検索用のテキストフィールドと Vector 検索用のベクトルフィールドを含むスキーマでインデックスを作成します。この例ではカスタムアナライザーエンベッダー定義、フィールドごとの設定を示しています:

curl -X POST http://localhost:8080/v1/index \
  -H 'Content-Type: application/json' \
  -d '{
    "schema": {
      "analyzers": {
        "body_analyzer": {
          "char_filters": [{"type": "unicode_normalization", "form": "nfkc"}],
          "tokenizer": {"type": "regex"},
          "token_filters": [
            {"type": "lowercase"},
            {"type": "stop", "words": ["the", "a", "an", "is", "it"]}
          ]
        }
      },
      "embedders": {
        "my_embedder": {"type": "precomputed"}
      },
      "fields": {
        "title": {"text": {"indexed": true, "stored": true, "term_vectors": false, "analyzer": "standard"}},
        "body": {"text": {"indexed": true, "stored": true, "term_vectors": false, "analyzer": "body_analyzer"}},
        "category": {"text": {"indexed": true, "stored": true, "term_vectors": false, "analyzer": "keyword"}},
        "embedding": {"hnsw": {"dimension": 4, "distance": "DISTANCE_METRIC_COSINE", "m": 16, "ef_construction": 200, "embedder": "my_embedder"}}
      },
      "default_fields": ["title", "body"]
    }
  }'

3 つのテキストフィールドと 1 つのベクトルフィールドを持つインデックスが作成されます:

  • title — 組み込みの standard アナライザー(トークン化+小文字化)を使用。
  • bodyanalyzers セクションで定義したカスタム body_analyzer(NFKC 正規化+正規表現トークナイザー+小文字化+カスタムストップワード)を使用。
  • categorykeyword アナライザー(値全体を単一トークンとして扱い、完全一致用)を使用。
  • embedding — HNSW ベクトルインデックス、4 次元、コサイン距離。embedders で定義した my_embedder を使用。このチュートリアルでは precomputed(外部で事前計算したベクトル)を使用。本番環境では、使用する埋め込みモデルに合わせた次元数(例: 384 や 768)を指定してください。

default_fields を設定することで、フィールド指定なしのクエリは titlebody の両方を検索します。

組み込みアナライザー

standard, keyword, english, japanese, simple, noop。省略時はエンジンのデフォルト(standard)が使用されます。

カスタムアナライザーのコンポーネント

以下のコンポーネントを組み合わせてカスタムアナライザーを構成できます:

  • トークナイザー: whitespace, unicode_word, regex, ngram, lindera, whole
  • 文字フィルター: unicode_normalization, pattern_replace, mapping, japanese_iteration_mark
  • トークンフィルター: lowercase, stop, stem, boost, limit, strip, remove_empty, flatten_graph

エンベッダー

embedders セクションでベクトルの生成方法を定義します。各ベクトルフィールドは embedder オプションでエンベッダーを名前で参照できます。利用可能なタイプ:

  • precomputed — ベクトルは外部で事前計算して供給(自動埋め込みなし)。
  • candle_bert — Candle によるローカル BERT モデル。パラメータ: model(HuggingFace モデルID)。embeddings-candle フィーチャが必要。
  • candle_clip — ローカル CLIP マルチモーダルモデル。パラメータ: model(HuggingFace モデルID)。embeddings-multimodal フィーチャが必要。
  • openai — OpenAI API。パラメータ: model(例: "text-embedding-3-small")。embeddings-openai フィーチャと OPENAI_API_KEY 環境変数が必要。

BERT エンベッダーの例(embeddings-candle フィーチャが必要):

{
  "embedders": {
    "bert": {"type": "candle_bert", "model": "sentence-transformers/all-MiniLM-L6-v2"}
  },
  "fields": {
    "embedding": {"hnsw": {"dimension": 384, "embedder": "bert"}}
  }
}

インデックスが作成されたことを確認します:

curl http://localhost:8080/v1/index

期待されるレスポンス:

{"document_count":0,"vector_fields":{}}

Step 3: ドキュメントの登録

ドキュメントをインデックスに追加します。PUT を使って ID 指定でドキュメントを登録します。各ドキュメントにはテキストフィールドと embedding ベクトルが含まれます(本番環境では、これらのベクトルは埋め込みモデルから生成されます):

curl -X PUT http://localhost:8080/v1/documents/doc001 \
  -H 'Content-Type: application/json' \
  -d '{
    "document": {
      "fields": {
        "title": "Introduction to Rust Programming",
        "body": "Rust is a modern systems programming language that focuses on safety, speed, and concurrency.",
        "category": "programming",
        "embedding": [0.9, 0.1, 0.2, 0.0]
      }
    }
  }'
curl -X PUT http://localhost:8080/v1/documents/doc002 \
  -H 'Content-Type: application/json' \
  -d '{
    "document": {
      "fields": {
        "title": "Web Development with Rust",
        "body": "Building web applications with Rust has become increasingly popular. Frameworks like Actix and Rocket make it easy to create fast and secure web services.",
        "category": "web-development",
        "embedding": [0.7, 0.3, 0.5, 0.1]
      }
    }
  }'
curl -X PUT http://localhost:8080/v1/documents/doc003 \
  -H 'Content-Type: application/json' \
  -d '{
    "document": {
      "fields": {
        "title": "Python for Data Science",
        "body": "Python is the most popular language for data science and machine learning. Libraries like NumPy and Pandas provide powerful tools for data analysis.",
        "category": "data-science",
        "embedding": [0.1, 0.8, 0.1, 0.9]
      }
    }
  }'

ベクトルフィールドは数値の JSON 配列として指定します。配列の長さはスキーマで設定した dimension(このチュートリアルでは 4)と一致する必要があります。

Step 4: 変更のコミット

ドキュメントはコミットするまで検索対象になりません。変更をコミットします:

curl -X POST http://localhost:8080/v1/commit

Step 5: ドキュメントの検索

基本的な検索

“rust” を含むドキュメントを検索します:

curl -X POST http://localhost:8080/v1/search \
  -H 'Content-Type: application/json' \
  -d '{"query": "rust", "limit": 10}'

デフォルトフィールド(titlebody)が検索されます。doc001doc002 が返されます。

フィールド指定検索

title フィールドのみを検索します:

curl -X POST http://localhost:8080/v1/search \
  -H 'Content-Type: application/json' \
  -d '{"query": "title:python", "limit": 10}'

doc003 のみが返されます。

カテゴリ検索

curl -X POST http://localhost:8080/v1/search \
  -H 'Content-Type: application/json' \
  -d '{"query": "category:programming", "limit": 10}'

doc001 のみが返されます。

ブーリアンクエリ

ANDORNOT で条件を組み合わせます:

curl -X POST http://localhost:8080/v1/search \
  -H 'Content-Type: application/json' \
  -d '{"query": "rust AND web", "limit": 10}'

“rust” と “web” の両方を含む doc002 のみが返されます。

フィールドブースト

title フィールドのスコアを引き上げて、タイトルの一致を優先します:

curl -X POST http://localhost:8080/v1/search \
  -H 'Content-Type: application/json' \
  -d '{
    "query": "rust",
    "limit": 10,
    "field_boosts": {"title": 2.0}
  }'

ベクトル検索

ベクトルの類似度で検索します。query_vectors にクエリベクトルを指定し、検索対象のフィールドを指定します:

curl -X POST http://localhost:8080/v1/search \
  -H 'Content-Type: application/json' \
  -d '{
    "query_vectors": [
      {
        "vector": [0.85, 0.15, 0.2, 0.05],
        "fields": ["embedding"]
      }
    ],
    "limit": 10
  }'

embedding ベクトルがクエリベクトルに最も近いドキュメントが返されます。doc001 が最上位にランクされます(最も類似したベクトル)。

ハイブリッド検索

Lexical 検索と Vector 検索を組み合わせて、より良い結果を得ます。fusion パラメータで両方のスコアの統合方法を制御します:

curl -X POST http://localhost:8080/v1/search \
  -H 'Content-Type: application/json' \
  -d '{
    "query": "rust",
    "query_vectors": [
      {
        "vector": [0.85, 0.15, 0.2, 0.05],
        "fields": ["embedding"]
      }
    ],
    "fusion": {"rrf": {"k": 60.0}},
    "limit": 10
  }'

Reciprocal Rank Fusion(RRF)を使って Lexical 検索と Vector 検索の結果を統合します。重み付き和による統合も可能です:

curl -X POST http://localhost:8080/v1/search \
  -H 'Content-Type: application/json' \
  -d '{
    "query": "programming",
    "query_vectors": [
      {
        "vector": [0.85, 0.15, 0.2, 0.05],
        "fields": ["embedding"]
      }
    ],
    "fusion": {"weighted_sum": {"lexical_weight": 0.3, "vector_weight": 0.7}},
    "limit": 10
  }'

Step 6: ドキュメントの取得

ID を指定して特定のドキュメントを取得します:

curl http://localhost:8080/v1/documents/doc001

期待されるレスポンス(ベクトルフィールドも含まれます):

{
  "documents": [
    {
      "fields": {
        "title": "Introduction to Rust Programming",
        "body": "Rust is a modern systems programming language that focuses on safety, speed, and concurrency.",
        "category": "programming",
        "embedding": [0.9, 0.1, 0.2, 0.0]
      }
    }
  ]
}

Step 7: ドキュメントの更新

同じ ID で PUT を実行するとドキュメント全体が置き換わります:

curl -X PUT http://localhost:8080/v1/documents/doc001 \
  -H 'Content-Type: application/json' \
  -d '{
    "document": {
      "fields": {
        "title": "Introduction to Rust Programming",
        "body": "Rust is a modern systems programming language that focuses on safety, speed, and concurrency. It provides memory safety without garbage collection.",
        "category": "programming",
        "embedding": [0.9, 0.1, 0.2, 0.0]
      }
    }
  }'

コミットして確認します:

curl -X POST http://localhost:8080/v1/commit
curl http://localhost:8080/v1/documents/doc001

更新された body の内容が反映されています。

Step 8: ドキュメントの削除

ID を指定してドキュメントを削除します:

curl -X DELETE http://localhost:8080/v1/documents/doc003

コミットして反映させます:

curl -X POST http://localhost:8080/v1/commit

ドキュメントが削除されたことを確認します:

curl http://localhost:8080/v1/documents/doc003

期待されるレスポンス:

{"documents":[]}

検索結果にも表示されなくなります:

curl -X POST http://localhost:8080/v1/search \
  -H 'Content-Type: application/json' \
  -d '{"query": "python", "limit": 10}'

結果は返されません。

期待されるレスポンス:

{"results":[]}

Step 9: インデックス統計の確認

現在のインデックス統計を確認します:

curl http://localhost:8080/v1/index

document_count は削除後の残りのドキュメント数を反映しています。

Step 10: クリーンアップ

Ctrl+C でサーバーを停止します。サーバーはグレースフルシャットダウンを行い、保留中の変更をコミットしてから終了します。

チュートリアルで作成したデータを削除します:

rm -rf /tmp/laurus/tutorial

さらに進んだ使い方: 実際の埋め込みモデルの利用

上記のチュートリアルでは簡略化のために precomputed ベクトルを使用しました。本番環境では、埋め込みモデルを使ってテキストを自動的にベクトルに変換するのが一般的です。ここでは BERT ベースのエンベッダーの設定方法を示します。

前提条件

embeddings-candle フィーチャを有効にして laurus をビルドします:

cargo build --release --features embeddings-candle

BERT エンベッダーを使ったスキーマ

インデックスを作成します:

curl -X POST http://localhost:8080/v1/index \
  -H 'Content-Type: application/json' \
  -d '{
    "schema": {
      "embedders": {
        "bert": {
          "type": "candle_bert",
          "model": "sentence-transformers/all-MiniLM-L6-v2"
        }
      },
      "fields": {
        "title": {"text": {"indexed": true, "stored": true, "analyzer": "standard"}},
        "body": {"text": {"indexed": true, "stored": true, "analyzer": "standard"}},
        "embedding": {"hnsw": {"dimension": 384, "distance": "DISTANCE_METRIC_COSINE", "m": 16, "ef_construction": 200, "embedder": "bert"}}
      },
      "default_fields": ["title", "body"]
    }
  }'

モデルは初回使用時に HuggingFace Hub から自動ダウンロードされます。dimension(384)はモデルの出力次元数と一致させる必要があります。

ドキュメントを追加します。embedding フィールドにはテキストを渡すだけで、エンベッダーが自動的にベクトルに変換します:

curl -X PUT http://localhost:8080/v1/documents/doc001 \
  -H 'Content-Type: application/json' \
  -d '{
    "document": {
      "fields": {
        "title": "Introduction to Rust Programming",
        "body": "Rust is a modern systems programming language.",
        "embedding": "Rust is a modern systems programming language."
      }
    }
  }'
curl -X PUT http://localhost:8080/v1/documents/doc002 \
  -H 'Content-Type: application/json' \
  -d '{
    "document": {
      "fields": {
        "title": "Web Development with Rust",
        "body": "Building web applications with Rust using Actix and Rocket.",
        "embedding": "Building web applications with Rust using Actix and Rocket."
      }
    }
  }'

コミットします:

curl -X POST http://localhost:8080/v1/commit

テキストクエリで lexical 検索、ベクトルクエリでセマンティック検索を同時に行います。検索時もテキストからベクトルへの変換が自動的に行われます:

curl -X POST http://localhost:8080/v1/search \
  -H 'Content-Type: application/json' \
  -d '{
    "query": "systems programming",
    "query_vectors": [
      {
        "vector": "systems programming language",
        "fields": ["embedding"]
      }
    ],
    "fusion": {"rrf": {"k": 60.0}},
    "limit": 10
  }'

precomputed エンベッダーではベクトルを直接渡す必要がありますが、candle_bert のようなテキスト対応エンベッダーを使うと、インデックス時も検索時もテキストを直接渡せます。

OpenAI Embeddings の利用

OpenAI の Embedding API を使う場合は、OPENAI_API_KEY 環境変数を設定し、embeddings-openai フィーチャでビルドします:

cargo build --release --features embeddings-openai
export OPENAI_API_KEY="sk-..."

インデックスを作成します:

curl -X POST http://localhost:8080/v1/index \
  -H 'Content-Type: application/json' \
  -d '{
    "schema": {
      "embedders": {
        "openai": {
          "type": "openai",
          "model": "text-embedding-3-small"
        }
      },
      "fields": {
        "title": {"text": {"indexed": true, "stored": true}},
        "embedding": {"hnsw": {"dimension": 1536, "distance": "DISTANCE_METRIC_COSINE", "embedder": "openai"}}
      },
      "default_fields": ["title"]
    }
  }'

text-embedding-3-small モデルは 1536 次元のベクトルを出力します。

利用可能な埋め込みモデル

タイプフィーチャフラグモデル例次元数
candle_bertembeddings-candlesentence-transformers/all-MiniLM-L6-v2384
candle_clipembeddings-multimodalopenai/clip-vit-base-patch32512
openaiembeddings-openaitext-embedding-3-small1536

次のステップ

設定

laurus-server は CLI 引数、環境変数、TOML 設定ファイルで設定できます。

設定の優先順位

サーバーとインデックスの設定は以下の順序で解決されます(優先度が高い順)。

CLI 引数 > 環境変数 > 設定ファイル > デフォルト値

ログの詳細度は RUST_LOG 環境変数でのみ制御します(デフォルト: info)。

例:

# CLI 引数が環境変数と設定ファイルより優先される
LAURUS_PORT=4567 laurus serve --config config.toml --port 1234
# -> ポート 1234 でリッスン

# 環境変数が設定ファイルより優先される
LAURUS_PORT=4567 laurus serve --config config.toml
# -> ポート 4567 でリッスン

# CLI 引数も環境変数も未設定の場合、設定ファイルの値が使用される
laurus serve --config config.toml
# -> config.toml のポートを使用(未設定の場合はデフォルト 50051)

TOML 設定ファイル

フォーマット

[server]
host = "0.0.0.0"
port = 50051
http_port = 8080  # オプション: HTTP ゲートウェイを有効化

[index]
data_dir = "./laurus_data"

ログの詳細度は設定ファイルではなく、RUST_LOG 環境変数で制御します(デフォルト: info)。

フィールドリファレンス

[server] セクション

フィールドデフォルト説明
hostString"0.0.0.0"gRPC サーバーのリッスンアドレス
portInteger50051gRPC サーバーのリッスンポート
http_portIntegerHTTP ゲートウェイポート。設定すると gRPC と並行して HTTP/JSON ゲートウェイが起動

[index] セクション

フィールドデフォルト説明
data_dirString"./laurus_data"インデックスデータディレクトリのパス

環境変数

変数対応する設定説明
LAURUS_HOSTserver.hostリッスンアドレス
LAURUS_PORTserver.portgRPC リッスンポート
LAURUS_HTTP_PORTserver.http_portHTTP ゲートウェイポート
LAURUS_INDEX_DIRindex.data_dirインデックスデータディレクトリ
RUST_LOGログフィルタディレクティブ(例: info, debug, laurus=debug,tonic=warn
LAURUS_CONFIGTOML 設定ファイルのパス

CLI 引数

オプション短縮形デフォルト説明
--config <PATH>-cTOML 設定ファイルのパス
--host <HOST>-H0.0.0.0リッスンアドレス
--port <PORT>-p50051gRPC リッスンポート
--http-port <PORT>HTTP ゲートウェイポート
--index-dir <PATH>./laurus_indexインデックスデータディレクトリ(グローバルオプション)

よくある設定例

開発環境(gRPC のみ)

[server]
host = "127.0.0.1"
port = 50051

[index]
data_dir = "./dev_data"
RUST_LOG=debug laurus serve --config config.toml

本番環境(gRPC + HTTP ゲートウェイ)

[server]
host = "0.0.0.0"
port = 50051
http_port = 8080

[index]
data_dir = "/var/lib/laurus/data"

最小構成(環境変数のみ)

export LAURUS_INDEX_DIR=/var/lib/laurus/data
export LAURUS_PORT=50051
export LAURUS_HTTP_PORT=8080
export RUST_LOG=info
laurus serve

gRPC API リファレンス

すべてのサービスは laurus.v1 protobuf パッケージで定義されています。

サービス一覧

サービスRPC説明
HealthServiceCheckヘルスチェック
IndexServiceCreateIndex, GetIndex, GetSchema, AddField, DeleteFieldインデックスのライフサイクルとスキーマ
DocumentServicePutDocument, AddDocument, GetDocuments, DeleteDocuments, Commitドキュメント CRUD とコミット
SearchServiceSearch, SearchStream単発検索とストリーミング検索

HealthService

Check

サーバーの現在のサービング状態を返します。

rpc Check(HealthCheckRequest) returns (HealthCheckResponse);

レスポンスフィールド:

フィールド説明
statusServingStatusサーバーの準備が完了している場合は SERVING_STATUS_SERVING

IndexService

CreateIndex

指定されたスキーマで新しいインデックスを作成します。インデックスが既に開いている場合は ALREADY_EXISTS エラーを返します。

rpc CreateIndex(CreateIndexRequest) returns (CreateIndexResponse);

リクエストフィールド:

フィールド必須説明
schemaSchemaはいインデックスのスキーマ定義

Schema 構造:

message Schema {
  map<string, FieldOption> fields = 1;
  repeated string default_fields = 2;
  map<string, AnalyzerDefinition> analyzers = 3;
  map<string, EmbedderConfig> embedders = 4;
  DynamicFieldPolicy dynamic_field_policy = 5;
}

enum DynamicFieldPolicy {
  DYNAMIC_FIELD_POLICY_UNSPECIFIED = 0;
  DYNAMIC_FIELD_POLICY_STRICT = 1;
  DYNAMIC_FIELD_POLICY_DYNAMIC = 2;
  DYNAMIC_FIELD_POLICY_IGNORE = 3;
}
  • fields — フィールド名をキーとしたフィールド定義。
  • default_fields — クエリでフィールドを指定しない場合のデフォルト検索対象フィールド名。
  • analyzers — 名前をキーとしたカスタムアナライザーパイプライン。TextOption.analyzer で参照。
  • embedders — 名前をキーとしたエンベッダー設定。ベクトルフィールドオプション(HnswOption.embedder など)で参照。
  • dynamic_field_policy — 投入されたドキュメントに含まれるが fields宣言されていないフィールドの扱い。UNSPECIFIED(値 0)は後方互換のため DYNAMIC として解釈されます。挙動マトリクスおよび DYNAMIC での情報損失警告は スキーマとフィールド を参照してください。

AnalyzerDefinition:

message AnalyzerDefinition {
  repeated ComponentConfig char_filters = 1;
  ComponentConfig tokenizer = 2;
  repeated ComponentConfig token_filters = 3;
}

ComponentConfig(文字フィルター、トークナイザー、トークンフィルターに使用):

フィールド説明
typestringコンポーネントタイプ名(例: "whitespace", "lowercase", "unicode_normalization"
paramsmap<string, string>タイプ固有のパラメータ(文字列のキーと値のペア)

EmbedderConfig:

フィールド説明
typestringエンベッダータイプ名(例: "precomputed", "candle_bert", "openai"
paramsmap<string, string>タイプ固有のパラメータ(例: "model""sentence-transformers/all-MiniLM-L6-v2"

FieldOption は以下のフィールドタイプのいずれかを持つ oneof です。

Lexical フィールドVector フィールド
TextOption (indexed, stored, term_vectors, analyzer)HnswOption (dimension, distance, m, ef_construction, base_weight, quantizer, embedder, rerank_storage)
IntegerOption (indexed, stored, multi_valued)FlatOption (dimension, distance, base_weight, quantizer, embedder, rerank_storage)
FloatOption (indexed, stored, multi_valued)IvfOption (dimension, distance, n_clusters, n_probe, base_weight, quantizer, embedder, rerank_storage)
BooleanOption (indexed, stored)
DateTimeOption (indexed, stored)
GeoOption (indexed, stored)
Geo3dOption (indexed, stored)
BytesOption (stored)

ベクトルフィールドオプションの embedder フィールドには、Schema.embedders で定義したエンベッダー名を指定します。設定すると、インデックス時にドキュメントのテキストフィールドからベクトルを自動生成します。事前計算済みのベクトルを直接供給する場合は空のままにします。

距離メトリクス: COSINE, EUCLIDEAN, MANHATTAN, DOT_PRODUCT, ANGULAR

量子化手法: SCALAR_8BIT(デフォルト), PRODUCT_QUANTIZATION(Issue #481 Stage 3 で予約、現状 Unimplemented

NONE(量子化なし)は Issue #481 Stage 1 で廃止されました。proto enum 値 0(QUANTIZATION_METHOD_NONE)は wire 互換のため予約されていますが、サーバ側で受信すると Default::default()SCALAR_8BIT)にフォールバックします。

Rerank storage: オプションの rerank_storage フィールド(enum RerankStorageKind: UNSPECIFIED = サイドカーなし、F32)は Stage-2 rerank サイドカー(Issue #481 / #793)を有効化します。HNSW フィールドで F32 を設定すると、commit 時に完全精度の .hnsw.f32 サイドカーを追加で書き出し、rerank_factor を指定した検索が int8 候補を元のベクトルで再スコアします。フィールドを省略(または UNSPECIFIED)すると Stage-1 の int8 のみのランキングになります。スキーマの round-trip 整合のため FlatOption / IvfOption にも保持されますが、これらのインデックスはまだサイドカーを出力しません。

QuantizationConfig 構造:

フィールド説明
methodQuantizationMethod量子化手法(QUANTIZATION_METHOD_SCALAR_8BIT または QUANTIZATION_METHOD_PRODUCT_QUANTIZATION)。0(NONE)は予約、サーバ側で SCALAR_8BIT にフォールバック。
subvector_countuint32サブベクトルの数(methodPRODUCT_QUANTIZATION の場合のみ使用。dimension を均等に割り切れる値を指定)。

例:

{
  "schema": {
    "fields": {
      "title": {"text": {"indexed": true, "stored": true, "term_vectors": true}},
      "embedding": {"hnsw": {"dimension": 384, "distance": "DISTANCE_METRIC_COSINE", "m": 16, "ef_construction": 200}}
    },
    "default_fields": ["title"]
  }
}

GetIndex

インデックスの統計情報を取得します。

rpc GetIndex(GetIndexRequest) returns (GetIndexResponse);

レスポンスフィールド:

フィールド説明
document_countuint64インデックス内のドキュメント総数
vector_fieldsmap<string, VectorFieldStats>フィールドごとのベクトル統計情報

VectorFieldStats には vector_countdimension が含まれます。

GetSchema

現在のインデックススキーマを取得します。

rpc GetSchema(GetSchemaRequest) returns (GetSchemaResponse);

レスポンスフィールド:

フィールド説明
schemaSchemaインデックスのスキーマ

AddField

稼働中のインデックスにフィールドを動的に追加します。

rpc AddField(AddFieldRequest) returns (AddFieldResponse);

リクエストフィールド:

フィールド説明
namestringフィールド名
field_optionFieldOptionフィールド設定

レスポンスフィールド:

フィールド説明
schemaSchemaフィールド追加後の更新済みスキーマ

HTTP ゲートウェイ: POST /v1/schema/fields

DeleteField

稼働中のインデックスからフィールドを動的に削除します。既にインデックスされたデータは残りますが、削除されたフィールドにはアクセスできなくなります。

rpc DeleteField(DeleteFieldRequest) returns (DeleteFieldResponse);

message DeleteFieldRequest {
  string name = 1;
}

message DeleteFieldResponse {
  Schema schema = 1;
}

リクエストフィールド:

フィールド必須説明
namestringはい削除するフィールド名

レスポンス: 更新後の Schema を返します。


DocumentService

PutDocument

ID を指定してドキュメントを挿入または置換します。同じ ID のドキュメントが既に存在する場合は置換されます。

rpc PutDocument(PutDocumentRequest) returns (PutDocumentResponse);

リクエストフィールド:

フィールド必須説明
idstringはい外部ドキュメント ID
documentDocumentはいドキュメントの内容

Document 構造:

message Document {
  map<string, Value> fields = 1;
}

Value は以下の型のいずれかを持つ oneof です。

Proto フィールド説明
Nullnull_valueNull 値
Booleanbool_valueブール値
Integerint64_value64 ビット符号付き整数
Floatfloat64_value64 ビット浮動小数点数
Texttext_valueUTF-8 文字列
Bytesbytes_valueバイト列
Vectorvector_valueVectorValue(浮動小数点数のリスト)
DateTimedatetime_valueUnix マイクロ秒(UTC)
Geogeo_valueGeoPoint(緯度、経度)
Int64Arrayint64_array_valueInt64ArrayValue(多値整数。IntegerOption.multi_valued = true を要求)
Float64Arrayfloat64_array_valueFloat64ArrayValue(多値浮動小数点数。FloatOption.multi_valued = true を要求)
Geo3dgeo3d_valueGeo3dPoint(x, y, z メートル単位、ECEF 直交座標系)

Geo3dPoint:

フィールド説明
xdoubleX 座標(メートル単位、ECEF: 赤道面、+X 方向は経度 0°)
ydoubleY 座標(メートル単位、ECEF: 赤道面、+Y 方向は東経 90°)
zdoubleZ 座標(メートル単位、ECEF: +Z 方向は北極)

座標系の詳細および wgs84_to_ecef / ecef_to_wgs84 の変換ユーティリティについては 3D 地理検索 (ECEF) を参照してください。

AddDocument

ドキュメントを追加します。PutDocument と異なり、同じ ID の既存ドキュメントを置換しません。複数のドキュメントが同じ ID を共有できます(チャンキングパターン)。

rpc AddDocument(AddDocumentRequest) returns (AddDocumentResponse);

リクエストフィールドは PutDocument と同じです。

GetDocuments

指定された外部 ID に一致するすべてのドキュメントを取得します。

rpc GetDocuments(GetDocumentsRequest) returns (GetDocumentsResponse);

リクエストフィールド:

フィールド必須説明
idstringはい外部ドキュメント ID

レスポンスフィールド:

フィールド説明
documentsrepeated Document一致するドキュメント

DeleteDocuments

指定された外部 ID に一致するすべてのドキュメントを削除します。

rpc DeleteDocuments(DeleteDocumentsRequest) returns (DeleteDocumentsResponse);

Commit

保留中の変更(追加および削除)をインデックスにコミットします。コミットされるまで、変更は検索に反映されません。

rpc Commit(CommitRequest) returns (CommitResponse);

SearchService

Search

検索クエリを実行し、結果を単一のレスポンスとして返します。

rpc Search(SearchRequest) returns (SearchResponse);

レスポンスフィールド:

フィールド説明
resultsrepeated SearchResult関連度順の検索結果
total_hitsuint64マッチするドキュメントの総数(limit/offset 適用前)

SearchStream

検索クエリを実行し、結果を 1 件ずつストリーミングで返します。

rpc SearchStream(SearchRequest) returns (stream SearchResult);

SearchRequest フィールド

フィールド必須説明
querystringいいえQuery DSL による Lexical 検索クエリ
query_vectorsrepeated QueryVectorいいえベクトル検索クエリ
limituint32いいえ最大結果件数(デフォルト: エンジンのデフォルト値)
offsetuint32いいえスキップする結果件数
fusionFusionAlgorithmいいえハイブリッド検索の Fusion アルゴリズム
lexical_paramsLexicalParamsいいえLexical 検索パラメータ
vector_paramsVectorParamsいいえベクトル検索パラメータ
field_boostsmap<string, float>いいえフィールドごとのスコアブースト

query または query_vectors のいずれか 1 つ以上を指定する必要があります。

3D 地理クエリ

3D ECEF の地理クエリは SearchRequest.query に渡す Lexical DSL 文字列で表現します。専用のメッセージ型はなく、コアライブラリで使用される DSL 形式がそのまま gRPC 経由でも動作します。3 種類の形式があります(構文の詳細は Query DSL → 3D 地理クエリ を参照):

  • position:geo3d_distance(x, y, z, distance_m)(x, y, z) を中心とした最大距離(メートル単位)の球
  • position:geo3d_bbox(min_x, min_y, min_z, max_x, max_y, max_z) — 3D 軸並行バウンディングボックス
  • position:geo3d_nearest(x, y, z, k)(x, y, z) に最も近い k 個の近傍点

position はフィールド名で、スキーマで宣言した実際の Geo3d 型フィールドに置き換えてください。すべての数値引数は符号付きの double 値で、k は符号なし整数です。

QueryVector

フィールド説明
vectorrepeated floatクエリベクトル
weightfloatこのベクトルの重み(デフォルト: 1.0)
fieldsrepeated string対象のベクトルフィールド(空の場合は全フィールド)

FusionAlgorithm

以下の 2 つのオプションを持つ oneof です。

  • RRF (Reciprocal Rank Fusion): k パラメータ(デフォルト: 60)
  • WeightedSum: lexical_weightvector_weight

LexicalParams

フィールド説明
min_scorefloat最小スコア閾値
timeout_msuint64検索タイムアウト(ミリ秒)
parallelbool並列検索を有効化
sort_bySortSpecスコアの代わりにフィールドでソート

SortSpec

フィールド説明
fieldstringソート対象のフィールド名。空文字列はスコアでソートすることを意味する
orderSortOrderSORT_ORDER_ASC(昇順)または SORT_ORDER_DESC(降順)

VectorParams

フィールド説明
fieldsrepeated string対象のベクトルフィールド
score_modeVectorScoreModeWEIGHTED_SUM, MAX_SIM, または LATE_INTERACTION
overfetchfloatオーバーフェッチ係数(デフォルト: 2.0)
min_scorefloat最小スコア閾値
rerank_factoroptional uint32Stage 2 rerank の widening 係数(Issue #481)。rerank_storage を有効にした HNSW フィールドに対してこの値を設定すると、サーバは int8 候補取得を top_k * rerank_factor まで広げ、元の完全精度ベクトルで再スコアしてから上位 top_k を返します。rerank_storage = "F32" を指定した HNSW フィールドのみで反映され、それ以外(Stage 1 セグメント、Flat、IVF)では silent に int8 ランキングへフォールバックします — Stage 1 セグメントから f32 情報を復元することはできません。0 または省略で rerank 無効。

SearchResult

フィールド説明
idstring外部ドキュメント ID
scorefloat関連度スコア
documentDocumentドキュメントの内容

{
  "query": "body:rust",
  "query_vectors": [
    {"vector": [0.1, 0.2, 0.3], "weight": 1.0}
  ],
  "limit": 10,
  "fusion": {
    "rrf": {"k": 60}
  },
  "field_boosts": {
    "title": 2.0
  }
}

エラーハンドリング

gRPC エラーは標準の Status コードとして返されます。

Laurus エラーgRPC ステータス発生条件
Schema / Query / Field / JSONINVALID_ARGUMENT不正なリクエストまたはスキーマ
インデックス未オープンFAILED_PRECONDITIONCreateIndex の前に RPC が呼び出された場合
インデックスが既に存在ALREADY_EXISTSCreateIndex が 2 回呼び出された場合
未実装UNIMPLEMENTEDまだサポートされていない機能
内部エラーINTERNALI/O、ストレージ、または予期しないエラー

HTTP ゲートウェイ

HTTP ゲートウェイは Laurus 検索エンジンへの RESTful HTTP/JSON インターフェースを提供します。gRPC サーバーと並行して動作し、リクエストを内部的にプロキシします。

Client (HTTP/JSON) --> HTTP Gateway (axum) --> gRPC Server (tonic) --> Engine

HTTP ゲートウェイの有効化

http_port を設定するとゲートウェイが起動します。

# CLI 引数で指定
laurus serve --http-port 8080

# 環境変数で指定
LAURUS_HTTP_PORT=8080 laurus serve

# 設定ファイルで指定
laurus serve --config config.toml
# ([server] セクションで http_port を設定)

http_port が未設定の場合、gRPC サーバーのみが起動します。

エンドポイント

メソッドパスgRPC メソッド説明
GET/v1/healthHealthService/Checkヘルスチェック
POST/v1/indexIndexService/CreateIndex新しいインデックスを作成
GET/v1/indexIndexService/GetIndexインデックスの統計情報を取得
GET/v1/schemaIndexService/GetSchemaインデックスのスキーマを取得
POST/v1/schema/fieldsIndexService/AddFieldフィールドを動的に追加
DELETE/v1/schema/fields/{name}IndexService/DeleteFieldスキーマからフィールドを削除
PUT/v1/documents/{id}DocumentService/PutDocumentドキュメントの Upsert
POST/v1/documents/{id}DocumentService/AddDocumentドキュメントの追加(チャンク)
GET/v1/documents/{id}DocumentService/GetDocumentsID でドキュメントを取得
DELETE/v1/documents/{id}DocumentService/DeleteDocumentsID でドキュメントを削除
POST/v1/commitDocumentService/Commit保留中の変更をコミット
POST/v1/searchSearchService/Search検索(単発)
POST/v1/search/streamSearchService/SearchStream検索(Server-Sent Events)

API の使用例

ヘルスチェック

curl http://localhost:8080/v1/health

インデックスの作成

curl -X POST http://localhost:8080/v1/index \
  -H 'Content-Type: application/json' \
  -d '{
    "schema": {
      "dynamic_field_policy": "dynamic",
      "fields": {
        "title": {"text": {"indexed": true, "stored": true, "term_vectors": true}},
        "body": {"text": {"indexed": true, "stored": true, "term_vectors": true}}
      },
      "default_fields": ["title", "body"]
    }
  }'

dynamic_field_policy は省略可能なキーで、スキーマに宣言されていないフィールドの扱いを制御します。指定できる値は "strict" / "dynamic"(デフォルト)/ "ignore" の 3 種類です。詳細および "dynamic" での情報損失に関する警告は スキーマとフィールド を参照してください。

インデックス統計情報の取得

curl http://localhost:8080/v1/index

スキーマの取得

curl http://localhost:8080/v1/schema

フィールドの動的追加

稼働中のインデックスにフィールドを追加します。リクエストボディは POST /v1/index と同じ FieldOption JSON 形式を使います:

curl -X POST http://localhost:8080/v1/schema/fields \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "category",
    "field_option": {"text": {"indexed": true, "stored": true}}
  }'

レスポンスでは更新後のスキーマが返されます。

フィールドの削除

スキーマからフィールドを削除します。フィールド名はパスで指定します:

curl -X DELETE http://localhost:8080/v1/schema/fields/category

既にインデックスされたデータはストレージに残りますが、アクセスできなくなります。フィールド固有のアナライザとエンベッダーは解除されます。

ドキュメントの Upsert(PUT)

ドキュメントが既に存在する場合は置換します。

curl -X PUT http://localhost:8080/v1/documents/doc1 \
  -H 'Content-Type: application/json' \
  -d '{
    "document": {
      "fields": {
        "title": "Hello World",
        "body": "This is a test document."
      }
    }
  }'

ドキュメントの追加(POST)

同じ ID の既存ドキュメントを置換せずに新しいチャンクを追加します。

curl -X POST http://localhost:8080/v1/documents/doc1 \
  -H 'Content-Type: application/json' \
  -d '{
    "document": {
      "fields": {
        "title": "Hello World",
        "body": "This is a test document."
      }
    }
  }'

ドキュメントの取得

curl http://localhost:8080/v1/documents/doc1

ドキュメントの削除

curl -X DELETE http://localhost:8080/v1/documents/doc1

コミット

curl -X POST http://localhost:8080/v1/commit

検索

curl -X POST http://localhost:8080/v1/search \
  -H 'Content-Type: application/json' \
  -d '{"query": "body:test", "limit": 10}'

フィールドブースト付き検索

curl -X POST http://localhost:8080/v1/search \
  -H 'Content-Type: application/json' \
  -d '{
    "query": "rust programming",
    "limit": 10,
    "field_boosts": {"title": 2.0}
  }'

ハイブリッド検索

curl -X POST http://localhost:8080/v1/search \
  -H 'Content-Type: application/json' \
  -d '{
    "query": "body:rust",
    "query_vectors": [{"vector": [0.1, 0.2, 0.3], "weight": 1.0}],
    "limit": 10,
    "fusion": {"rrf": {"k": 60}}
  }'

ストリーミング検索(SSE)

/v1/search/stream エンドポイントは Server-Sent Events(SSE)として結果を返します。各結果は個別のイベントとして送信されます。

curl -N -X POST http://localhost:8080/v1/search/stream \
  -H 'Content-Type: application/json' \
  -d '{"query": "body:test", "limit": 10}'

レスポンスは SSE イベントのストリームです。

data: {"id":"doc1","score":0.8532,"document":{...}}

data: {"id":"doc2","score":0.4210,"document":{...}}

JSON フィールド値の型推論

ドキュメント投入リクエスト(PUT /v1/documents/{id} または POST /v1/documents/{id})のボディに含まれる document.fields の各値は、 スキーマレス取り込みと同じ推論ルールでエンジンの DataValue に変換されます。これにより HTTP 経路と gRPC 経路で挙動が一致します。

JSON 値推論されるフィールド型備考
null(スキップ)NullValue として送出され、取り込み時に破棄されます。
true / falseboolean
整数(i64 に収まる)integer
浮動小数点 / 巨大整数float
"text"text
[1, 2, 3](全要素 integer)integermulti_valued: true多値数値フィールド。
[1.0, 2.5](非整数を含む数値配列)floatmulti_valued: true
[](空配列)(スキップ)要素型を決定できないためフィールドはスキップされます。
{"latitude": ..., "longitude": ...}geo
{"lat": ..., "lon": ...} / {"lat": ..., "lng": ...}geolatitude / longitude の短縮別名を受け付けます。
{"x": ..., "y": ..., "z": ...}geo3d3 キーすべて必須、有限な数値、ECEF メートル単位。lat/lon キーとの混在は拒否されます。

以下の場合、ゲートウェイは HTTP 400(Bad Request)を返します:

  • 配列が混在型もしくは非数値要素を含む(例: [1, "x"]
  • オブジェクトが有効な地理座標ではない(2D は latitude/longitude キーが、 3D は x/y/z のいずれかが欠けている)
  • 緯度が [-90, 90] の範囲外、または経度が [-180, 180] の範囲外
  • 3D ECEF の座標が有限値でない(NaN / Inf
  • 同一オブジェクトに 2D(lat/lon)と 3D(x/y/z)のキーが混在

ベクトルおよびバイト列フィールドは JSON だけからは推論できないため、 スキーマで明示的に宣言する必要があります。宣言済みのベクトルフィールドに 数値配列が送られた場合は自動的に f32 ベクトルへキャストされるので、 REST クライアントは埋め込みベクトルを通常の JSON 配列として送信できます。

3D 地理クエリ

3D ECEF クエリは query に渡す Lexical DSL 文字列をそのまま再利用します。ゲートウェイは文字列を変更せずエンジンへ転送するため、gRPC 経由と同じ DSL 形式が HTTP 経由でも動作します:

curl -X POST http://localhost:8080/v1/search \
  -H 'Content-Type: application/json' \
  -d '{
    "query": "position:geo3d_distance(-3955182, 3350553, 3700276, 5000)",
    "limit": 10
  }'

geo3d_bbox および geo3d_nearest の構文は Query DSL → 3D 地理クエリ を参照してください。

リクエスト/レスポンス形式

すべてのリクエストおよびレスポンスボディは JSON を使用します。JSON の構造は gRPC の protobuf メッセージに対応しています。メッセージ定義の詳細は gRPC API リファレンスを参照してください。

MCP サーバー概要

laurus-mcp クレートは、Laurus 検索エンジン用の Model Context Protocol (MCP) サーバーを提供します。実行中の laurus-server インスタンスへの gRPC クライアントとして動作し、Claude などの AI アシスタントが標準 MCP stdio トランスポートを通じてドキュメントのインデックス登録や検索を行えるようにします。

機能

  • MCP stdio トランスポート — サブプロセスとして起動し、stdin/stdout 経由で AI クライアントと通信
  • gRPC クライアント — すべてのツール呼び出しを実行中の laurus-server インスタンスにプロキシ
  • 全 laurus 検索モード — Lexical(BM25)、Vector(HNSW/Flat/IVF)、ハイブリッド検索
  • 動的接続connect ツールで任意の laurus-server エンドポイントに接続可能
  • ドキュメントライフサイクル — MCP ツールを通じてドキュメントの追加・更新・削除・取得が可能

アーキテクチャ

graph LR
    subgraph "laurus-mcp"
        MCP["MCP Server\n(stdio)"]
    end

    AI["AI クライアント\n(Claude など)"] -->|"stdio (JSON-RPC)"| MCP
    MCP -->|"gRPC"| SRV["laurus-server\n(常駐)"]
    SRV --> Disk["ディスク上のインデックス"]

MCP サーバーは AI クライアントによって起動される子プロセスとして動作します。すべてのツール呼び出しを gRPC 経由で laurus-server インスタンスにプロキシします。laurus-server は MCP サーバーとは別途、事前に起動しておく必要があります。

クイックスタート

# ステップ 1: laurus-server を起動
laurus serve --port 50051

# ステップ 2: Claude Code で MCP サーバーを設定
claude mcp add laurus -- laurus mcp --endpoint http://localhost:50051

または手動で設定ファイルを編集:

{
  "mcpServers": {
    "laurus": {
      "command": "laurus",
      "args": ["mcp", "--endpoint", "http://localhost:50051"]
    }
  }
}

セクション

laurus-mcp をはじめる

前提条件

  • laurus CLI バイナリがインストール済み(cargo install laurus-cli
  • 実行中の laurus-server インスタンス(laurus-server はじめにを参照)
  • MCP をサポートする AI クライアント(Claude Desktop、Claude Code など)

設定

ステップ 1: laurus-server を起動

laurus serve --port 50051

ステップ 2: MCP クライアントの設定

Claude Code

CLI コマンドで追加する方法(推奨):

claude mcp add laurus -- laurus mcp --endpoint http://localhost:50051

または ~/.claude/settings.json を直接編集:

{
  "mcpServers": {
    "laurus": {
      "command": "laurus",
      "args": ["mcp", "--endpoint", "http://localhost:50051"]
    }
  }
}

Claude Desktop

以下の設定ファイルを編集:

  • macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
  • Windows: %APPDATA%\Claude\claude_desktop_config.json
{
  "mcpServers": {
    "laurus": {
      "command": "laurus",
      "args": ["mcp", "--endpoint", "http://localhost:50051"]
    }
  }
}

使用ワークフロー

ワークフロー 1: 既存のインデックスを使用する

CLI でインデックスを事前に作成してから MCP サーバーで検索します:

# ステップ 1: スキーマファイルを作成
cat > schema.toml << 'EOF'
[fields.title]
Text = { indexed = true, stored = true }

[fields.body]
Text = { indexed = true, stored = true }
EOF

# ステップ 2: サーバーを起動してインデックスを作成
laurus serve --port 50051 &
laurus create index --schema schema.toml

# ステップ 3: MCP サーバーを Claude Code に登録
claude mcp add laurus -- laurus mcp --endpoint http://localhost:50051

ワークフロー 2: AI 主導のインデックス作成

laurus-server を起動してから MCP サーバーを登録し、AI にインデックスを作成させます:

# ステップ 1: laurus-server を起動(インデックス不要)
laurus serve --port 50051

# ステップ 2: MCP サーバーを Claude Code に登録
claude mcp add laurus -- laurus mcp --endpoint http://localhost:50051

次に Claude に依頼します:

「ブログ記事用の検索インデックスを作成してください。タイトルと本文テキストで検索できるようにして、著者と公開日も保存したいです。」

Claude はスキーマを設計して create_index を自動的に呼び出します。

ワークフロー 3: 実行時に接続する

エンドポイントを指定せずに MCP サーバーを登録します:

claude mcp add laurus -- laurus mcp

または設定ファイルを直接編集:

{
  "mcpServers": {
    "laurus": {
      "command": "laurus",
      "args": ["mcp"]
    }
  }
}

次に Claude に接続を依頼します:

http://localhost:50051 の laurus サーバーに接続してください」

Claude は他のツールを使用する前に connect を呼び出します。

環境変数

--endpoint フラグを省略し、代わりに LAURUS_ENDPOINT を渡すこともできます。エンドポイントがマシンごとに固定で、各 MCP クライアントの設定にハードコードしたくない場合に便利です:

export LAURUS_ENDPOINT=http://localhost:50051
claude mcp add laurus -- laurus mcp

クライアント設定内で指定する場合:

{
  "mcpServers": {
    "laurus": {
      "command": "laurus",
      "args": ["mcp"],
      "env": {
        "LAURUS_ENDPOINT": "http://localhost:50051"
      }
    }
  }
}

両方が指定された場合は、明示的な --endpoint フラグが優先されます(clap の #[arg(long, env = "...")] の標準動作)。

MCP サーバーの削除

Claude Code から登録済みの MCP サーバーを削除するには:

claude mcp remove laurus

Claude Desktop の場合は、設定ファイルから laurus エントリを削除してアプリケーションを再起動してください。

ライフサイクル

laurus-server 起動(別プロセス)
  └─ gRPC ポート 50051 でリッスン

Claude 起動
  └─ 起動: laurus mcp --endpoint http://localhost:50051
       └─ stdio イベントループに入る
            ├─ stdin 経由でツール呼び出しを受信
            ├─ gRPC 経由で laurus-server にプロキシ
            └─ stdout 経由で結果を送信
Claude 終了
  └─ laurus-mcp プロセスが終了
  └─ laurus-server は継続して動作

MCP ツールリファレンス

laurus MCP サーバーは以下のツールを公開しています。

connect

実行中の laurus-server gRPC エンドポイントに接続します。--endpoint フラグなしでサーバーを起動した場合や、実行時に別の laurus-server に切り替える場合に、他のツールを使用する前にこのツールを呼び出してください。

パラメーター

名前必須説明
endpointstringはいgRPC エンドポイント URL(例: http://localhost:50051

Tool: connect
endpoint: "http://localhost:50051"

結果: Connected to laurus-server at http://localhost:50051.


create_index

指定されたスキーマで新しい検索インデックスを作成します。

パラメーター

名前必須説明
schema_jsonstringはいJSON 文字列としてのスキーマ定義

スキーマ JSON フォーマット

FieldOption は serde の externally-tagged 表現を使用します(バリアント名がキーになります):

{
  "dynamic_field_policy": "Dynamic",
  "fields": {
    "title":     { "Text":    { "indexed": true, "stored": true } },
    "body":      { "Text":    {} },
    "score":     { "Float":   {} },
    "count":     { "Integer": {} },
    "active":    { "Boolean": {} },
    "created":   { "DateTime": {} },
    "embedding": { "Hnsw":    { "dimension": 384 } }
  }
}

オプションの dynamic_field_policy キーは、スキーマに宣言されていないフィールドが投入ドキュメントに含まれる場合の挙動を制御します。指定可能な値は "Strict" / "Dynamic"(デフォルト)/ "Ignore"警告: "Dynamic" では integer フィールドに入ってきた float 値が静かに切り捨てられます(3.143)。厳密さが必要なら "Strict" を使用してください。詳細な挙動マトリクスは スキーマとフィールド を参照してください。

Tool: create_index
schema_json: {"fields": {"title": {"Text": {}}, "body": {"Text": {}}}}

結果: Index created successfully at /path/to/index.

3D ECEF 座標を扱う Geo3d フィールドを含むスキーマ:

{
  "fields": {
    "title":    { "Text":  { "indexed": true, "stored": true } },
    "position": { "Geo3d": { "indexed": true, "stored": true } }
  }
}

座標系については 3D 地理検索 (ECEF) を参照してください。Geo3d フィールドは geo3d_distance / geo3d_bbox / geo3d_nearest の DSL 形式で検索できます(後述の search ツールを参照)。


get_stats

現在の検索インデックスの統計情報(ドキュメント数、ベクトルフィールド情報など)を取得します。

パラメーター

なし。

結果

{
  "document_count": 42,
  "vector_fields": {
    "embedding": {
      "vector_count": 42,
      "dimension": 384
    }
  }
}

vector_fields はフィールド名をキーとするマップで、各エントリにはインデックス済みベクトル数とフィールドに設定された次元数が含まれます。


get_schema

現在のインデックスのスキーマ(全フィールド定義と設定)を取得します。

パラメーター

なし。

結果

{
  "fields": {
    "title": { "Text": { "indexed": true, "stored": true } },
    "body": { "Text": {} },
    "embedding": { "Hnsw": { "dimension": 384 } }
  },
  "default_fields": ["title", "body"]
}

put_document

インデックスにドキュメントを上書き(upsert)します。同じ ID のドキュメントが既に存在する場合、全チャンクが削除されてから新しいドキュメントがインデックスされます。ドキュメント追加後は commit を呼び出してください。

パラメーター

名前必須説明
idstringはい外部ドキュメント識別子
documentobjectはいJSON オブジェクトとしてのドキュメントフィールド

Tool: put_document
id: "doc-1"
document: {"title": "Hello World", "body": "これはテストドキュメントです。"}

結果: Document 'doc-1' put (upserted). Call commit to persist changes.

Geo3d 値を含む例:

Tool: put_document
id: "drone-1"
document: {"title": "東京上空のドローン", "position": {"x": -3955182.0, "y": 3350553.0, "z": 3700276.0}}

MCP サーバーは 3D ECEF 点を xyz キーを持つ JSON オブジェクト(メートル単位)として受け付けます。これは HTTP ゲートウェイの挙動とは異なり、HTTP ゲートウェイでは現在 JSON から Geo3d を推論しません。MCP では書き込み・読み出しともに完全対応しています。


add_document

インデックスにドキュメントを新しいチャンクとして追加します。put_document とは異なり、同じ ID の既存ドキュメントを削除せずに追記します。大きなドキュメントをチャンクに分割する際に便利です。ドキュメント追加後は commit を呼び出してください。

パラメーター

名前必須説明
idstringはい外部ドキュメント識別子
documentobjectはいJSON オブジェクトとしてのドキュメントフィールド

Tool: add_document
id: "doc-1"
document: {"title": "Hello World - Part 2", "body": "これは続きです。"}

結果: Document 'doc-1' added as chunk. Call commit to persist changes.


get_documents

外部 ID で全ドキュメント(チャンクを含む)を取得します。

パラメーター

名前必須説明
idstringはい外部ドキュメント識別子

結果

{
  "id": "doc-1",
  "documents": [
    { "title": "Hello World", "body": "これはテストドキュメントです。" }
  ]
}

delete_documents

外部 ID で全ドキュメント(チャンクを含む)を削除します。削除後は commit を呼び出してください。

パラメーター

名前必須説明
idstringはい外部ドキュメント識別子

結果: Documents 'doc-1' deleted. Call commit to persist changes.


commit

保留中の変更をディスクにコミットします。変更を検索可能かつ永続的にするため、put_documentadd_document、または delete_documents の後に必ず呼び出してください。

パラメーター

なし。

結果: Changes committed successfully.


add_field

インデックスにフィールドを追加します。

パラメーター

名前必須説明
namestringはいフィールド名
field_option_jsonstringはいJSON 形式のフィールド設定

{
  "name": "category",
  "field_option_json": "{\"Text\": {\"indexed\": true, \"stored\": true}}"
}

delete_field

インデックスからフィールドを削除します。既にインデックスされたデータは残りますが、削除されたフィールドにはアクセスできなくなります。

パラメーター

名前必須説明
namestringはい削除するフィールド名

Tool: delete_field
name: "category"

結果: Field 'category' deleted.


search

laurus 統一クエリ DSL を使用してドキュメントを検索します。Lexical 検索、Vector 検索、ハイブリッド検索を単一のクエリ文字列でサポートします。

パラメーター

名前必須説明
querystringはいlaurus 統一クエリ DSL による検索クエリ
limitintegerいいえ最大結果数(デフォルト: 10)
offsetintegerいいえページネーション用スキップ数(デフォルト: 0)
fusionstringいいえハイブリッド検索用の融合アルゴリズム(JSON)
field_boostsstringいいえフィールド毎のブースト係数(JSON)

クエリ DSL の例

Lexical 検索

クエリ説明
helloデフォルトフィールド全体のターム検索
title:helloフィールド指定のターム検索
title:hello AND body:worldブール AND
"exact phrase"フレーズ検索
roam~2ファジー検索(編集距離 2)
count:[1 TO 10]範囲検索
title:helo~1フィールド指定のファジー検索

3D 地理検索

クエリ説明
position:geo3d_distance(x, y, z, distance_m)(x, y, z) を中心とした最大距離(メートル単位)の球
position:geo3d_bbox(min_x, min_y, min_z, max_x, max_y, max_z)3D 軸並行バウンディングボックス
position:geo3d_nearest(x, y, z, k)(x, y, z) に最も近い k 個の近傍点

position はフィールド名で、スキーマで宣言した実際の Geo3d 型フィールドに置き換えてください。完全な DSL 構文は Query DSL → 3D 地理クエリ を参照してください。

Vector 検索

クエリ説明
content:"cute kitten"特定フィールドでの Vector 検索(クォート付き)
content:python特定フィールドでの Vector 検索(クォートなし)
content:"cute kitten"^0.8重み付き Vector 検索
a:"cats" b:"dogs"^0.5複数の Vector クエリ

ハイブリッド検索

クエリ説明
title:hello content:"cute kitten"Lexical + Vector(OR/union — いずれかの結果を返す)
title:hello +content:"cute kitten"Lexical + Vector(AND/intersection — 両方にマッチした結果のみ)
+title:hello +content:"cute kitten"両方必須(AND)。Lexical フィールドの + は required clause
title:hello AND body:world content:"cats"^0.8ブール Lexical + 重み付き Vector

融合アルゴリズムの例

{"rrf": {"k": 60.0}}
{"weighted_sum": {"lexical_weight": 0.7, "vector_weight": 0.3}}

フィールドブーストの例

{"title": 2.0, "body": 1.0}

結果

{
  "total": 2,
  "results": [
    {
      "id": "doc-1",
      "score": 3.14,
      "document": { "title": "Hello World", "body": "..." }
    },
    {
      "id": "doc-2",
      "score": 1.57,
      "document": { "title": "Hello Again", "body": "..." }
    }
  ]
}

search_batch

独立した複数の検索を 1 回のラウンドトリップで実行します。すべてのクエリは サーバー上で並列に実行され、limitoffset はすべてのクエリに共通で 適用されます。エージェントが 1 ターンで複数のサブクエリを発行する場合に 有用です。

パラメータ

名前必須説明
queries文字列の配列はいlaurus 統一クエリ DSL のクエリ文字列(search と同じ構文)
limit整数いいえクエリごとの最大結果数(デフォルト: 10)
offset整数いいえクエリごとのページネーションオフセット(デフォルト: 0)

結果

batch 配列は入力順を保持します。batch[i]queries[i] の結果セットです。

{
  "batch": [
    {
      "total": 1,
      "results": [
        { "id": "doc-1", "score": 3.14, "document": { "title": "Hello World" } }
      ]
    },
    {
      "total": 1,
      "results": [
        { "id": "doc-2", "score": 2.71, "document": { "title": "Vector Search" } }
      ]
    }
  ]
}

典型的なワークフロー

1. connect          → 実行中の laurus-server に接続
2. create_index     → スキーマを定義(インデックスが存在しない場合)
3. add_field        → フィールドを追加(必要に応じて)
   delete_field     → フィールドを削除(必要に応じて)
4. put_document     → ドキュメントを上書き(必要に応じて繰り返し)
   add_document     → ドキュメントチャンクを追記(必要に応じて)
5. commit           → 変更をディスクに永続化
6. search           → インデックスを検索
7. get_documents    → ID でドキュメントを取得
8. delete_documents → ドキュメントを削除
9. commit           → 変更を永続化

Python バインディング概要

laurus-python パッケージは Laurus 検索エンジンの Python バインディングです。PyO3Maturin を使ってネイティブ Rust 拡張としてビルドされており、Python プログラムからネイティブに近いパフォーマンスで Laurus の Lexical 検索、Vector 検索、ハイブリッド検索機能を利用できます。

機能

  • Lexical 検索 – BM25 スコアリングを備えた転置インデックスによる全文検索
  • Vector 検索 – Flat、HNSW、IVF インデックスを使用した近似最近傍(ANN)検索
  • ハイブリッド検索 – フュージョンアルゴリズム(RRF、WeightedSum)で Lexical と Vector の結果を統合
  • 豊富なクエリ DSL – Term、Phrase、Fuzzy、Wildcard、NumericRange、Geo、Boolean、Span クエリ
  • テキスト解析 – トークナイザー、フィルター、ステマー、同義語展開
  • 柔軟なストレージ – インメモリ(一時的)またはファイルベース(永続的)インデックス
  • Python らしい API – 型情報を備えた直感的な Python クラス

アーキテクチャ

graph LR
    subgraph "laurus-python"
        PyIndex["Index\n(Python クラス)"]
        PyQuery["クエリクラス"]
        PySearch["SearchRequest\n/ SearchResult"]
    end

    Python["Python アプリケーション"] -->|"メソッド呼び出し"| PyIndex
    Python -->|"クエリオブジェクト"| PyQuery
    PyIndex -->|"PyO3 FFI"| Engine["laurus::Engine\n(Rust)"]
    PyQuery -->|"PyO3 FFI"| Engine
    Engine --> Storage["ストレージ\n(Memory / File)"]

Python クラスは Rust エンジンの薄いラッパーです。 各呼び出しは PyO3 の FFI 境界を一度だけ越え、その後 Rust エンジンが操作をネイティブコードで実行します。

Rust エンジン内部は非同期 I/O を使用していますが、 Python 側のメソッドはすべて同期関数として公開されています。 これは Python の GIL(Global Interpreter Lock)の制約により、 単一インタプリタ内での真の並行実行ができないためです。 非同期 API にすると asyncio.run() が常に必要になり、 API が煩雑になります。代わりに、各メソッドは内部で tokio::Runtime::block_on() を呼び出し、非同期 Rust を 同期 Python にブリッジしています。

注意: Node.js バインディング(laurus-nodejs)では、 同じ Rust エンジンのメソッドをネイティブな async / Promise API として公開しています。 Node.js のイベントループは非同期をネイティブにサポート しているためです。

クイックスタート

import laurus

# インメモリインデックスを作成
index = laurus.Index()

# ドキュメントをインデックス
index.put_document("doc1", {"title": "Rust 入門", "body": "システムプログラミング言語です。"})
index.put_document("doc2", {"title": "Python データサイエンス", "body": "Python によるデータ解析。"})
index.commit()

# 検索
results = index.search("title:rust", limit=5)
for r in results:
    print(f"[{r.id}] score={r.score:.4f}  {r.document['title']}")

セクション

インストール

PyPI からインストール

pip install laurus

ソースからビルド

ソースからビルドするには Rust ツールチェーン(1.85 以降。ルート Cargo.tomlworkspace.package.rust-version と一致)と Maturin が必要です。

# Maturin をインストール
pip install maturin

# リポジトリをクローン
git clone https://github.com/mosuka/laurus.git
cd laurus/laurus-python

# 開発モードでビルドとインストール
maturin develop

# またはリリースホイールをビルド
maturin build --release
pip install target/wheels/laurus-*.whl

動作確認

import laurus
index = laurus.Index()
print(index)  # Index()

動作要件

  • Python 3.8 以降
  • コンパイル済みネイティブ拡張以外のランタイム依存関係なし

クイックスタート

1. インデックスを作成する

import laurus

# インメモリインデックス(一時的、プロトタイピングに最適)
index = laurus.Index()

# ファイルベースインデックス(永続的)
schema = laurus.Schema()
schema.add_text_field("title")
schema.add_text_field("body")
index = laurus.Index(path="./myindex", schema=schema)

2. ドキュメントをインデックスする

index.put_document("doc1", {
    "title": "Rust 入門",
    "body": "Rust は安全性とパフォーマンスに重点を置いたシステムプログラミング言語です。",
})
index.put_document("doc2", {
    "title": "Python データサイエンス",
    "body": "Python はデータ解析と機械学習に広く使われています。",
})
index.commit()

3. Lexical 検索

# DSL 文字列
results = index.search("title:rust", limit=5)

# クエリオブジェクト
results = index.search(laurus.TermQuery("body", "python"), limit=5)

# 結果を表示
for r in results:
    print(f"[{r.id}] score={r.score:.4f}  {r.document['title']}")

4. Vector 検索

Vector 検索にはベクトルフィールドを含むスキーマと事前計算済みエンベディングが必要です。

import laurus

schema = laurus.Schema()
schema.add_text_field("title")
schema.add_hnsw_field("embedding", dimension=4)

index = laurus.Index(schema=schema)
index.put_document("doc1", {"title": "Rust", "embedding": [0.1, 0.2, 0.3, 0.4]})
index.put_document("doc2", {"title": "Python", "embedding": [0.9, 0.8, 0.7, 0.6]})
index.commit()

query_vec = [0.1, 0.2, 0.3, 0.4]
results = index.search(laurus.VectorQuery("embedding", query_vec), limit=3)

5. ハイブリッド検索

request = laurus.SearchRequest(
    lexical_query=laurus.TermQuery("title", "rust"),
    vector_query=laurus.VectorQuery("embedding", query_vec),
    fusion=laurus.RRF(k=60.0),
    limit=5,
)
results = index.search(request)

6. 更新と削除

# 更新: put_document は同じ ID の全バージョンを置換する
index.put_document("doc1", {"title": "更新されたタイトル", "body": "新しいコンテンツ。"})
index.commit()

# 既存バージョンを削除せずに新しいバージョンを追記(RAG チャンキングパターン)
index.add_document("doc1", {"title": "チャンク 2", "body": "追加のチャンク。"})
index.commit()

# 全バージョンを取得
docs = index.get_documents("doc1")

# 削除
index.delete_documents("doc1")
index.commit()

7. スキーマ管理

schema = laurus.Schema()
schema.add_text_field("title")
schema.add_text_field("body")
schema.add_integer_field("year")
schema.add_float_field("score")
schema.add_boolean_field("published")
schema.add_bytes_field("thumbnail")
schema.add_geo_field("location")
schema.add_datetime_field("created_at")
schema.add_hnsw_field("embedding", dimension=384)
schema.add_flat_field("small_vec", dimension=64)
schema.add_ivf_field("ivf_vec", dimension=128, n_clusters=100)

8. インデックス統計

stats = index.stats()
print(stats["document_count"])
print(stats["vector_fields"])

API リファレンス

Index

Laurus 検索エンジンをラップするメインクラスです。

class Index:
    def __init__(self, path: str | None = None, schema: Schema | None = None) -> None: ...

コンストラクタ

パラメータデフォルト説明
pathstr | NoneNone永続ストレージのディレクトリパス。None の場合はインメモリインデックスを作成します。
schemaSchema | NoneNoneスキーマ定義。省略時は空のスキーマが使用されます。

メソッド

メソッド説明
put_document(id, doc)ドキュメントをアップサート(upsert)します。同じ ID の既存バージョンをすべて置換します。
add_document(id, doc)既存バージョンを削除せずにドキュメントチャンクを追記します。
get_documents(id) -> list[dict]指定 ID の全保存バージョンを返します。
delete_documents(id)指定 ID の全バージョンを削除します。
commit()バッファリングされた書き込みをフラッシュし、すべての保留中の変更を検索可能にします。
search(query, *, limit=10, offset=0) -> list[SearchResult]検索クエリを実行します。
search_batch(queries, *, limit=10, offset=0) -> list[list[SearchResult]]独立した複数の検索を 1 回の呼び出しで実行します。各クエリは内部の tokio ランタイム上で並列に dispatch されます。results[i]queries[i] に対応し、入力が空のリストの場合は [] を返します。
stats() -> dictインデックス統計(document_countvector_fields)を返します。

search の query 引数

query パラメータは以下のいずれかを受け付けます:

  • DSL 文字列(例: "title:hello""content:\"memory safety\"")
  • Lexical クエリオブジェクトTermQueryPhraseQueryBooleanQuery など)
  • Vector クエリオブジェクトVectorQueryVectorTextQuery
  • SearchRequest(完全な制御が必要な場合)

search_batchqueries リストの各要素も同じ種類の値を受け付けます。DSL 文字列・クエリオブジェクト・SearchRequest を 1 つのバッチ内で混在させることもできます。


Schema

Index のフィールドとインデックスタイプを定義します。

class Schema:
    def __init__(self) -> None: ...

フィールドメソッド

メソッド説明
add_text_field(name, *, stored=True, indexed=True, term_vectors=False, analyzer=None)全文フィールド(転置インデックス、BM25)。analyzer には組込名("standard" / "english" / "keyword" / "simple" / "noop"、または add_analyzer で登録したカスタム名)か、{"language": "japanese", "mode": "normal", "dict": "/var/lib/lindera/ipadic"} のようなパラメータ付きプリセットの dict を渡せます。文字列単独の "japanese" は Lindera 辞書パスが必須なため拒否されます。
add_integer_field(name, *, stored=True, indexed=True, multi_valued=False)64 ビット整数フィールド。multi_valued=True で整数配列を受け付け(範囲クエリは “any match”)。
add_float_field(name, *, stored=True, indexed=True, multi_valued=False)64 ビット浮動小数点フィールド。multi_valued=True で浮動小数点配列を受け付け(範囲クエリは “any match”)。
add_boolean_field(name, *, stored=True, indexed=True)ブールフィールド。
add_bytes_field(name, *, stored=True)生バイトフィールド。
add_geo_field(name, *, stored=True, indexed=True)地理座標フィールド(緯度/経度)。
add_geo3d_field(name, *, stored=True, indexed=True)3D ECEF カルテシアン座標フィールド(x, y, z はメートル)。詳細は Geo3d の概念
add_datetime_field(name, *, stored=True, indexed=True)UTC 日時フィールド。
add_hnsw_field(name, dimension, *, distance="cosine", m=16, ef_construction=200, quantizer=None, subvector_count=None, rerank_storage=None, embedder=None)HNSW 近似最近傍ベクトルフィールド。
add_flat_field(name, dimension, *, distance="cosine", embedder=None)Flat(総当たり)ベクトルフィールド。
add_ivf_field(name, dimension, *, distance="cosine", n_clusters=100, n_probe=1, embedder=None)IVF 近似最近傍ベクトルフィールド。

ベクトル量子化とリランクストレージ(HNSW フィールド):

  • quantizer"scalar_8bit"(デフォルト、4 倍圧縮)または高圧縮率の "product_quantization"。Product quantization では subvector_countdimension を割り切れる値)が必須です。
  • rerank_storage"f32" を指定すると完全精度の *.hnsw.f32 サイドカーを書き出し、厳密な Stage-2 リランクを有効化します。省略すると int8 のみのセグメントを維持します。

その他のメソッド

メソッド説明
add_embedder(name, config)名前付きエンベダー定義を登録します。config"type" キーを持つ辞書です(下記参照)。
set_default_fields(fields)デフォルト検索フィールドを設定(文字列のリスト)。
set_dynamic_field_policy(policy)未宣言フィールドの扱いを設定。policy"strict" / "dynamic"(デフォルト)/ "ignore"。詳細は下記を参照。
dynamic_field_policy()現在のポリシーを小文字の文字列で返す。
field_names()全フィールド名を返す。

Dynamic field policy(動的フィールドポリシー)

ドキュメントに含まれるがスキーマに宣言されていないフィールドの扱いを制御します:

  • "strict" — ドキュメントを拒否
  • "dynamic"(デフォルト)— 各未宣言フィールドの型を推論してスキーマに追加。警告: integer フィールドに入ってきた float 値は静かに切り捨てられます(3.143)。厳密さが必要なら "strict" を使用してください
  • "ignore" — 未宣言フィールドを静かに破棄

詳細な挙動マトリクスは スキーマとフィールド を参照してください。

エンベダータイプ

"type"必須キーFeature Flag
"precomputed"(常に利用可能)
"candle_bert""model"embeddings-candle
"candle_clip""model"embeddings-multimodal
"openai""model"embeddings-openai

距離メトリクス

説明
"cosine"コサイン類似度(デフォルト)
"euclidean"ユークリッド距離
"dot_product"内積
"manhattan"マンハッタン距離
"angular"角度距離

クエリクラス

TermQuery

TermQuery(field: str, term: str)

指定フィールドに完全一致する語句を含むドキュメントを検索します。

PhraseQuery

PhraseQuery(field: str, terms: list[str])

指定した語句が順序どおりに含まれるドキュメントを検索します。

FuzzyQuery

FuzzyQuery(field: str, term: str, *, max_edits: int = 2)

編集距離が max_edits 以内の近似一致を検索します。max_edits はキーワード専用引数です。

WildcardQuery

WildcardQuery(field: str, pattern: str)

ワイルドカードパターン検索。* は任意の文字列、? は任意の1文字に一致します。

NumericRangeQuery

NumericRangeQuery(field: str, *, min: int | float | None = None, max: int | float | None = None)

[min, max] の範囲内の数値を検索します。開いた境界には None を指定する (または省略する)と開放されます。minmax はキーワード専用引数です。 数値型(整数または浮動小数点)は min/max の Python 型から推論されます。

GeoDistanceQuery

GeoDistanceQuery.within_radius(
    field: str, lat: float, lon: float, distance_m: float,
)

地理的距離検索(半径指定)。指定した地点から distance_m メートル以内の (lat, lon) 座標を持つドキュメントを返します。

GeoBoundingBoxQuery

GeoBoundingBoxQuery.within_bounding_box(
    field: str,
    min_lat: float, min_lon: float,
    max_lat: float, max_lon: float,
)

地理的範囲(バウンディングボックス)検索。軸並行 [min_lat, max_lat] × [min_lon, max_lon] 内の (lat, lon) 座標を持つドキュメントを返します。

Geo3dDistanceQuery

Geo3dDistanceQuery.within_sphere(
    field: str, x: float, y: float, z: float, distance_m: float,
)

3D ECEF 座標フィールドへの球距離検索。中心 (x, y, z) から distance_m メートル以内 の座標を持つドキュメントを返します。ECEF の理論については Geo3d の概念 を参照。

Geo3dBoundingBoxQuery

Geo3dBoundingBoxQuery.within_box(
    field: str,
    min_x: float, min_y: float, min_z: float,
    max_x: float, max_y: float, max_z: float,
)

軸並行 3D 範囲(AABB)検索。[min_x, max_x] × [min_y, max_y] × [min_z, max_z] 内 にある ECEF 座標を持つドキュメントを返します。

Geo3dNearestQuery

Geo3dNearestQuery.k_nearest(
    field: str,
    x: float, y: float, z: float,
    k: int,
    *,
    initial_radius_m: float | None = None,
    max_radius_m: float | None = None,
)

3D ECEF 座標フィールドへの k 最近傍検索。(x, y, z) から最も近い k 件のドキュ メントを返します。initial_radius_m / max_radius_m は反復拡張サーチの探索コーン を調整します。

BooleanQuery

bq = BooleanQuery()
bq.must(query)
bq.should(query)
bq.must_not(query)

複合ブールクエリ。引数なしでコンストラクタを呼び出し、must / should / must_not メソッドで節を一つずつ追加します。各メソッドは任意のクエリ オブジェクト(ネストされた BooleanQuery も含む)を受け付けます。

must 節はすべて一致する必要があり、must_not 節は一致してはなりません。 should 節はスコアリングに寄与し、must 節が無い場合は少なくとも1つが 一致する必要があります。

SpanQuery

# 単一語句
SpanQuery.term(field: str, term: str)

# Near: slop 位置以内の語句
SpanQuery.near(field: str, terms: list[str], *, slop: int = 0, ordered: bool = True)

# ネストされた SpanQuery 句を使った Near
SpanQuery.near_spans(field: str, clauses: list[SpanQuery], *, slop: int = 0, ordered: bool = True)

# Containing: big スパンが little スパンを含む
SpanQuery.containing(field: str, big: SpanQuery, little: SpanQuery)

# Within: 最大距離での include スパンと exclude スパン
SpanQuery.within(field: str, include: SpanQuery, exclude: SpanQuery, distance: int)

位置・近接スパンクエリ。静的ファクトリメソッドで構築します。near は語句 文字列のリストを受け取り、near_spans はネスト式のために SpanQuery オブジェクトのリストを受け取ります。slopordered はキーワード専用 引数です。

VectorQuery

VectorQuery(field: str, vector: list[float])

事前計算済みエンベディングベクトルを使った近似最近傍検索を行います。

VectorTextQuery

VectorTextQuery(field: str, text: str)

クエリ時に text をエンベディングに変換してベクトル検索を行います。インデックスにエンベダーの設定が必要です。


SearchRequest

高度な制御が必要な場合の完全なリクエストクラスです。

class SearchRequest:
    def __init__(
        self,
        *,
        query=None,
        lexical_query=None,
        vector_query=None,
        filter_query=None,
        fusion=None,
        limit: int = 10,
        offset: int = 0,
    ) -> None: ...
パラメータ説明
queryDSL 文字列または単一クエリオブジェクト。lexical_query / vector_query と排他的。
lexical_query明示的なハイブリッド検索の Lexical コンポーネント。
vector_query明示的なハイブリッド検索の Vector コンポーネント。
filter_queryスコアリング後に適用する Lexical フィルター。
fusionフュージョンアルゴリズム(RRF または WeightedSum)。両コンポーネント指定時のデフォルトは RRF(k=60)
limit最大結果件数(デフォルト 10)。
offsetページネーションオフセット(デフォルト 0)。

SearchResult

Index.search() が返すクラスです。

class SearchResult:
    id: str          # 外部ドキュメント識別子
    score: float     # 関連性スコア
    document: dict | None  # 取得されたフィールド値。stored=False の場合は None

フュージョンアルゴリズム

RRF

RRF(k: float = 60.0)

逆順位フュージョン(Reciprocal Rank Fusion)。Lexical と Vector の結果リストを順位位置によってマージします。k は平滑化定数で、値が大きいほど上位ランクの影響が小さくなります。

WeightedSum

WeightedSum(lexical_weight: float = 0.5, vector_weight: float = 0.5)

両スコアリストをそれぞれ正規化した後、lexical_weight * lexical_score + vector_weight * vector_score として結合します。


テキスト解析

SynonymDictionary

class SynonymDictionary:
    def __init__(self) -> None: ...
    def add_synonym_group(self, synonyms: list[str]) -> None: ...

WhitespaceTokenizer

class WhitespaceTokenizer:
    def __init__(self) -> None: ...
    def tokenize(self, text: str) -> list[Token]: ...

SynonymGraphFilter

class SynonymGraphFilter:
    def __init__(
        self,
        dictionary: SynonymDictionary,
        keep_original: bool = True,
        boost: float = 1.0,
    ) -> None: ...
    def apply(self, tokens: list[Token]) -> list[Token]: ...

Token

class Token:
    text: str
    position: int
    start_offset: int
    end_offset: int
    boost: float
    stopped: bool
    position_increment: int
    position_length: int

フィールド値の型マッピング

Python の値は自動的に Laurus の DataValue 型に変換されます:

Python 型Laurus 型備考
NoneNull
boolBoolint より先にチェック
intInt64
floatFloat64
strText
bytesBytes
list[float]Vector要素は f32 に変換
(lat, lon) タプルGeo2 つの float
(x, y, z) タプルGeo3d3 つの float 値(ECEF 直交座標系、メートル単位)
datetime.datetimeDateTimeisoformat() 経由で変換

開発環境のセットアップ

このページでは laurus-python バインディングのローカル開発環境のセットアップ、ビルド、テスト実行の手順を説明します。

前提条件

  • Rust 1.85 以降(Cargo 付属)
  • Python 3.8 以降
  • リポジトリのローカルクローン
git clone https://github.com/mosuka/laurus.git
cd laurus

Python 仮想環境

Python ツール(Maturin、pytest など)はすべて laurus-python/.venv に作成した専用の仮想環境で管理します。

# venv を作成して maturin と pytest をインストール
make venv

これは以下と同等です:

python3 -m venv laurus-python/.venv
laurus-python/.venv/bin/pip install maturin pytest

注意: venv を手動でアクティベートする必要はありません。 すべての make ターゲットは venv 内のバイナリを直接呼び出します。

ビルド

開発ビルド(編集可能インストール)

Rust 拡張をコンパイルして venv にインストールします。 Rust ソースを変更するたびに再実行してください。

cd laurus-python
VIRTUAL_ENV=$(pwd)/.venv .venv/bin/maturin develop

または Makefile のショートカットを使って配布用ホイールもまとめてビルドします:

make build-laurus-python

リリースホイールが target/wheels/ に生成されます:

target/wheels/laurus-0.x.y-cp312-cp312-manylinux_2_34_x86_64.whl

ビルドの確認

# venv の Python を直接指定して確認する場合:
laurus-python/.venv/bin/python -c "import laurus; print(laurus.Index())"
# Index()

テスト

make test-laurus-python は次の2つのテストスイートを順番に実行します:

  1. Rust ユニットテストcargo test -p laurus-python
  2. Python 統合テストmaturin develop 後に pytest を実行
make test-laurus-python

Python テストだけを実行する場合(Rust ステップをスキップ):

cd laurus-python
VIRTUAL_ENV=$(pwd)/.venv .venv/bin/maturin develop --quiet
.venv/bin/pytest tests/ -v

特定のテストだけを実行する場合:

.venv/bin/pytest tests/ -v -k test_vector_query

Lint とフォーマット

# Rust Lint(Clippy)
make lint-laurus-python

# Rust フォーマット
make format-laurus-python

クリーンアップ

# venv だけを削除
make venv-clean

# すべて削除(venv + Cargo ビルド成果物)
make clean

Makefile リファレンス

ターゲット説明
make venv.venv を作成して maturinpytest をインストール
make venv-clean.venv を削除
make build-laurus-pythonmaturin build でリリースホイールをビルド
make test-laurus-pythonRust ユニットテスト + Python pytest
make lint-laurus-python-D warnings 付きで Clippy を実行
make format-laurus-pythoncargo fmt -p laurus-python
make cleanvenv と Cargo ビルド成果物をすべて削除

プロジェクト構成

laurus-python/
├── Cargo.toml          # Rust クレートマニフェスト
├── pyproject.toml      # Python パッケージメタデータ(Maturin / PEP 517)
├── README.md           # 英語 README
├── README_ja.md        # 日本語 README
├── src/                # Rust ソース(PyO3 バインディング)
│   ├── lib.rs          # モジュール登録
│   ├── index.rs        # Index クラス
│   ├── schema.rs       # Schema クラス
│   ├── query.rs        # クエリクラス
│   ├── search.rs       # SearchRequest / SearchResult / Fusion
│   ├── analysis.rs     # Tokenizer / Filter / Token
│   ├── convert.rs      # Python ↔ DataValue 変換
│   └── errors.rs       # エラーマッピング
├── tests/              # Python pytest 統合テスト
│   └── test_index.py
└── examples/           # 実行可能な Python サンプル
    ├── quickstart.py
    ├── lexical_search.py
    ├── vector_search.py
    ├── hybrid_search.py
    ├── synonym_graph_filter.py
    ├── search_with_openai.py
    └── multimodal_search.py

Node.js バインディング概要

laurus-nodejs パッケージは、Laurus 検索エンジンの Node.js/TypeScript バインディングです。 napi-rs を使用したネイティブアドオンとして ビルドされており、Node.js プログラムから Laurus の Lexical 検索、 Vector 検索、ハイブリッド検索機能にネイティブに近い性能で アクセスできます。

特徴

  • Lexical 検索 – BM25 スコアリングによる転置インデックスベースの全文検索
  • Vector 検索 – Flat、HNSW、IVF インデックスによる近似最近傍(ANN)検索
  • ハイブリッド検索 – RRF、WeightedSum による Lexical と Vector の結果融合
  • 豊富なクエリ DSL – Term、Phrase、Fuzzy、Wildcard、 NumericRange、Geo、Boolean、Span クエリ
  • テキスト解析 – トークナイザー、フィルター、ステマー、同義語展開
  • 柔軟なストレージ – インメモリ(揮発性)またはファイルベース(永続)
  • TypeScript 型定義.d.ts ファイルの自動生成
  • 非同期 API – 全 I/O 操作が Promise を返す

アーキテクチャ

graph LR
    subgraph "laurus-nodejs"
        JsIndex["Index\n(JS クラス)"]
        JsQuery["Query クラス群"]
        JsSearch["SearchRequest\n/ SearchResult"]
    end

    Node["Node.js アプリケーション"] -->|"メソッド呼び出し"| JsIndex
    Node -->|"クエリオブジェクト"| JsQuery
    JsIndex -->|"napi-rs FFI"| Engine["laurus::Engine\n(Rust)"]
    JsQuery -->|"napi-rs FFI"| Engine
    Engine --> Storage["Storage\n(Memory / File)"]

JavaScript クラスは Rust エンジンの薄いラッパーです。 各呼び出しは napi-rs の FFI 境界を一度だけ越え、 Rust エンジンが完全にネイティブコードで処理を実行します。

全 I/O メソッド(searchcommitputDocument 等)は async で Promise を返します。napi-rs 内蔵の tokio ランタイムで実行され、Node.js のイベントループをブロック しません。Schema 構築、Query 作成、stats() は I/O を 伴わないため同期メソッドです。

注意: Python バインディング(laurus-python)では、 同じ Rust エンジンのメソッドを同期関数として公開 しています。Python の GIL(Global Interpreter Lock)の 制約により非同期 API が煩雑になるためです。Node.js には この制約がないため、非同期 Rust エンジンを直接 Promise として公開しています。

クイックスタート

import { Index, Schema } from "laurus-nodejs";

// インメモリインデックスを作成
const schema = new Schema();
schema.addTextField("name");
schema.addTextField("description");
schema.setDefaultFields(["name", "description"]);

const index = await Index.create(null, schema);

// ドキュメントをインデックス
await index.putDocument("express", {
  name: "Express",
  description: "Fast minimalist web framework for Node.js.",
});
await index.putDocument("fastify", {
  name: "Fastify",
  description: "Fast and low overhead web framework.",
});
await index.commit();

// 検索
const results = await index.search("framework", 5);
for (const r of results) {
  console.log(`[${r.id}] score=${r.score.toFixed(4)}  ${r.document.name}`);
}

セクション

インストール

npm から

npm install laurus-nodejs

ソースから

ソースからビルドするには Rust ツールチェーン(1.85 以降)と Node.js 24.15 以上が必要です。

# リポジトリをクローン
git clone https://github.com/mosuka/laurus.git
cd laurus/laurus-nodejs

# 依存パッケージのインストール
npm install

# ネイティブモジュールのビルド(リリース)
npm run build

# デバッグモード(ビルドが速い)
npm run build:debug

確認

import { Index } from "laurus-nodejs";
const index = await Index.create();
console.log(index.stats());
// { documentCount: 0, vectorFields: {} }

要件

  • Node.js 24.15 以上(package.jsonengines.node と一致)
  • コンパイル済みネイティブアドオン以外のランタイム依存なし

クイックスタート

1. インデックスの作成

import { Index, Schema } from "laurus-nodejs";

// インメモリインデックス(揮発性、プロトタイピング向け)
const index = await Index.create();

// ファイルベースインデックス(永続化)
const schema = new Schema();
schema.addTextField("name");
schema.addTextField("description");
const persistentIndex = await Index.create("./myindex", schema);

2. ドキュメントのインデックス

await index.putDocument("express", {
  name: "Express",
  description: "Fast minimalist web framework for Node.js.",
});
await index.putDocument("fastify", {
  name: "Fastify",
  description: "Fast and low overhead web framework.",
});
await index.commit();

3. Lexical 検索

// DSL 文字列
const results = await index.search("name:express", 5);

// Term クエリ
const results2 = await index.searchTerm(
  "description", "framework", 5,
);

// 結果の表示
for (const r of results) {
  console.log(`[${r.id}] score=${r.score.toFixed(4)}  ${r.document.name}`);
}

4. Vector 検索

Vector 検索にはベクトルフィールドを持つスキーマと 事前計算済みの埋め込みベクトルが必要です。

import { Index, Schema } from "laurus-nodejs";

const schema = new Schema();
schema.addTextField("name");
schema.addHnswField("embedding", 4);

const index = await Index.create(null, schema);
await index.putDocument("express", {
  name: "Express",
  embedding: [0.1, 0.2, 0.3, 0.4],
});
await index.putDocument("pg", {
  name: "pg",
  embedding: [0.9, 0.8, 0.7, 0.6],
});
await index.commit();

const results = await index.searchVector(
  "embedding", [0.1, 0.2, 0.3, 0.4], 3,
);

5. ハイブリッド検索

import {
  Index,
  RRF,
  SearchRequest,
  TermQuery,
  VectorQuery,
} from "laurus-nodejs";

const req = new SearchRequest({ limit: 5 });
req.setLexicalTerm(new TermQuery("name", "express"));
req.setVectorQuery(new VectorQuery("embedding", [0.1, 0.2, 0.3, 0.4]));
req.setRrfFusion(new RRF(60.0));

const results = await index.searchWithRequest(req);

6. 更新と削除

// 更新: putDocument は既存バージョンをすべて置換
await index.putDocument("express", {
  name: "Express v5",
  description: "Updated content.",
});
await index.commit();

// バージョン追記(RAG チャンキングパターン)
await index.addDocument("express", {
  name: "Express chunk 2",
  description: "Additional chunk.",
});
await index.commit();

// 全バージョンの取得
const docs = await index.getDocuments("express");

// 削除
await index.deleteDocuments("express");
await index.commit();

7. スキーマ管理

const schema = new Schema();
schema.addTextField("name");
schema.addTextField("description");
schema.addIntegerField("stars");
schema.addFloatField("score");
schema.addBooleanField("published");
schema.addBytesField("thumbnail");
schema.addGeoField("location");
schema.addDatetimeField("createdAt");
schema.addHnswField("embedding", 384);
schema.addFlatField("smallVec", 64);
schema.addIvfField("ivfVec", 128, "cosine", 100, 1);

8. インデックス統計

const stats = index.stats();
console.log(stats.documentCount);
console.log(stats.vectorFields);

API リファレンス

Index

主要なエントリポイント。Laurus 検索エンジンをラップします。

class Index {
  static create(
    path?: string | null,
    schema?: Schema,
  ): Promise<Index>;
}

ファクトリメソッド

パラメータデフォルト説明
pathstring | nullnull永続化ストレージのディレクトリ。null でインメモリ。
schemaSchemaスキーマ定義。

メソッド

メソッド説明
putDocument(id, doc)ドキュメントを上書き保存。
addDocument(id, doc)既存バージョンを残してチャンクを追記。
getDocuments(id)指定 ID の全バージョンを取得。
deleteDocuments(id)指定 ID の全バージョンを削除。
commit()書き込みをフラッシュし変更を検索可能にする。
search(query, limit?, offset?)DSL 文字列で検索。
searchTerm(field, term, limit?, offset?)完全一致 Term 検索。
searchVector(field, vector, limit?, offset?)事前計算ベクトルで検索。
searchVectorText(field, text, limit?, offset?)テキストを自動埋め込みして検索。
searchWithRequest(request)SearchRequest で検索。
searchBatch(queries, limit?, offset?)複数の DSL 文字列クエリを並列実行します。results[i]queries[i] に対応。戻り値は Promise<Array<Array<JsSearchResult>>>。空入力の場合は [] を返します。
stats()インデックス統計(documentCountvectorFields)を返す。

ドキュメント操作と検索メソッドはすべて非同期で Promise を返します。 stats() は同期メソッドです。

stats() は次の形のオブジェクトを返します:

interface IndexStats {
  documentCount: number;
  vectorFields: Record<string, { count: number; dimension: number }>;
}

Schema

Index のフィールドとインデックス型を定義します。

class Schema {
  constructor();
}

フィールドメソッド

メソッド説明
addTextField(name, stored?, indexed?, termVectors?, analyzer?)全文検索フィールド(転置インデックス、BM25)。analyzer にはパラメータ不要の組込名("standard" / "english" / "keyword" / "simple" / "noop"、または addAnalyzer で登録したカスタム名)を指定します。Lindera 辞書パスが必要な Japanese プリセットを使う場合は、lindera tokenizer を含むカスタム analyzer を登録して、その名前を参照してください。
addIntegerField(name, stored?, indexed?, multiValued?)64 ビット整数フィールド。multiValued: true で整数配列を受け付け(範囲クエリは “any match”)。
addFloatField(name, stored?, indexed?, multiValued?)64 ビット浮動小数点フィールド。multiValued: true で浮動小数点配列を受け付け(範囲クエリは “any match”)。
addBooleanField(name, stored?, indexed?)真偽値フィールド。
addBytesField(name, stored?)バイナリデータフィールド。
addGeoField(name, stored?, indexed?)地理座標フィールド。
addGeo3dField(name, stored?, indexed?)3D ECEF カルテシアン座標フィールド(x, y, z はメートル)。詳細は Geo3d の概念
addDatetimeField(name, stored?, indexed?)UTC 日時フィールド。
addHnswField(name, dimension, distance?, m?, efConstruction?, embedder?, quantizer?, subvectorCount?, rerankStorage?)HNSW ベクトルフィールド。
addFlatField(name, dimension, distance?, embedder?)Flat(全探索)ベクトルフィールド。
addIvfField(name, dimension, distance?, nClusters?, nProbe?, embedder?)IVF ベクトルフィールド。
addEmbedder(name, config)名前付き Embedder を登録。
setDefaultFields(fields)デフォルト検索フィールドを設定。
setDynamicFieldPolicy(policy)未宣言フィールドの扱いを設定。policy"strict" / "dynamic"(デフォルト)/ "ignore"。詳細は下記を参照。
dynamicFieldPolicy()現在のポリシーを小文字の文字列で返す。
fieldNames()全フィールド名を返す。
toString()スキーマの文字列表現("Schema(fields=[...])" 形式)を返す。

ベクトル量子化とリランクストレージ(HNSW フィールド):

  • quantizer"scalar_8bit"(デフォルト、4 倍圧縮)または高圧縮率の "product_quantization"。Product quantization では subvectorCountdimension を割り切れる値)が必須です。
  • rerankStorage"f32" を指定すると完全精度の *.hnsw.f32 サイドカーを書き出し、厳密な Stage-2 リランクを有効化します。省略すると int8 のみのセグメントを維持します。

Dynamic field policy(動的フィールドポリシー)

ドキュメントに含まれるがスキーマに宣言されていないフィールドの扱いを制御します:

  • "strict" — ドキュメントを拒否
  • "dynamic"(デフォルト)— 各未宣言フィールドの型を推論してスキーマに追加。警告: integer フィールドに入ってきた float 値は静かに切り捨てられます(3.143)。厳密さが必要なら "strict" を使用してください
  • "ignore" — 未宣言フィールドを静かに破棄

詳細な挙動マトリクスは スキーマとフィールド を参照してください。

距離指標

説明
"cosine"コサイン類似度(デフォルト)
"euclidean"ユークリッド距離
"dot_product"内積
"manhattan"マンハッタン距離
"angular"角度距離

クエリクラス

TermQuery

new TermQuery(field: string, term: string)

指定フィールドで完全一致する Term を含むドキュメントにマッチ。

PhraseQuery

new PhraseQuery(field: string, terms: string[])

指定順序で Term を含むドキュメントにマッチ。

FuzzyQuery

new FuzzyQuery(field: string, term: string, maxEdits?: number)

最大 maxEdits 編集距離までの近似マッチ(デフォルト 2)。

WildcardQuery

new WildcardQuery(field: string, pattern: string)

パターンマッチ。* は任意の文字列、? は任意の1文字。

NumericRangeQuery

new NumericRangeQuery(
  field: string,
  min?: number | null,
  max?: number | null,
  numericType?: "integer" | "float",
)

[min, max] 範囲の数値にマッチします。null(または省略)で開放端。 numericType は内部の範囲型を選択します("integer"(デフォルト)または "float")。それ以外の値は例外をスローします。

GeoDistanceQuery

GeoDistanceQuery.withinRadius(
  field: string, lat: number, lon: number, distanceM: number,
): GeoDistanceQuery

地理的距離検索(半径指定)。

GeoBoundingBoxQuery

GeoBoundingBoxQuery.withinBoundingBox(
  field: string,
  minLat: number, minLon: number,
  maxLat: number, maxLon: number,
): GeoBoundingBoxQuery

地理的バウンディングボックス検索。

Geo3dDistanceQuery

Geo3dDistanceQuery.withinSphere(
  field: string,
  x: number, y: number, z: number,
  distanceM: number,
): Geo3dDistanceQuery

3D ECEF 座標フィールドへの球距離検索。中心から distanceM メートル以内の (x, y, z) 座標を持つドキュメントを返します。ECEF の理論については Geo3d の概念 を参照。

Geo3dBoundingBoxQuery

Geo3dBoundingBoxQuery.withinBox(
  field: string,
  minX: number, minY: number, minZ: number,
  maxX: number, maxY: number, maxZ: number,
): Geo3dBoundingBoxQuery

軸並行 3D 範囲(AABB)検索。

Geo3dNearestQuery

Geo3dNearestQuery.kNearest(
  field: string,
  x: number, y: number, z: number,
  k: number,
  initialRadiusM?: number,
  maxRadiusM?: number,
): Geo3dNearestQuery

3D ECEF 座標フィールドへの k 最近傍検索。initialRadiusM / maxRadiusM は 反復拡張サーチの探索コーンを調整します。

BooleanQuery

class BooleanQuery {
  constructor();
  // 各クエリタイプ X について(X は次のいずれか):
  //   { Term, Phrase, Fuzzy, Wildcard, NumericRange,
  //     GeoDistance, GeoBoundingBox,
  //     Geo3dDistance, Geo3dBoundingBox, Geo3dNearest,
  //     Boolean, Span }
  mustX(query: X): void;
  shouldX(query: X): void;
  mustNotX(query: X): void;
}

MUST / SHOULD / MUST_NOT 句による複合ブーリアンクエリ。各句は対応するクエリ クラスのインスタンスを引数に取ります。例: mustTerm(new TermQuery("body", "rust"))shouldGeo3dNearest(Geo3dNearestQuery.kNearest(...))

Node.js バインディングは多態 must(query) ではなく 36 個の per-type メソッド (12 クエリタイプ × 3 極性)を公開しています。これは js_name を上書きした クラスに対する napi-deriveEither<&T, ...> 引数バリデーションの制限を 回避するためです。

must 節はすべて一致する必要があり、mustNot 節は一致してはなりません。 should 節はスコアリングに寄与し、must 節が無い場合は少なくとも1つが 一致する必要があります。

const bq = new BooleanQuery();
bq.mustTerm(new TermQuery("body", "programming"));
bq.mustNotTerm(new TermQuery("title", "python"));
bq.shouldFuzzy(new FuzzyQuery("body", "data", 1));

SpanQuery

SpanQuery.term(field: string, term: string): SpanQuery
SpanQuery.near(
  field: string, terms: string[],
  slop?: number, ordered?: boolean,
): SpanQuery
SpanQuery.nearSpans(
  field: string, clauses: SpanQuery[],
  slop?: number, ordered?: boolean,
): SpanQuery
SpanQuery.containing(
  field: string, big: SpanQuery, little: SpanQuery,
): SpanQuery
SpanQuery.within(
  field: string,
  include: SpanQuery, exclude: SpanQuery, distance: number,
): SpanQuery

位置・近接ベースのスパンクエリ。

VectorQuery

new VectorQuery(field: string, vector: number[])

事前計算済み埋め込みベクトルによる最近傍検索。

VectorTextQuery

new VectorTextQuery(field: string, text: string)

クエリ時にテキストを埋め込みに変換して検索。 インデックスに Embedder の設定が必要。


SearchRequest

高度な制御のための全機能検索リクエスト。

interface SearchRequestOptions {
  queryDsl?: string;
  limit?: number;   // デフォルト 10
  offset?: number;  // デフォルト 0
}

class SearchRequest {
  constructor(options?: SearchRequestOptions);
}

コンストラクタにはプリミティブな options を渡し、多態クエリ句は下記の per-type セッターで設定します。BooleanQuery 同様、napi-deriveEither<&T, ...> バリデーション制限を回避するため per-type 化されています。

DSL とフュージョンセッター

メソッド説明
setQueryDsl(dsl: string)DSL 文字列クエリを設定。
setRrfFusion(rrf: RRF)RRF フュージョンを使用。
setWeightedSumFusion(ws: WeightedSum)加重和フュージョンを使用。

ベクトルセッター

メソッド説明
setVectorQuery(query: VectorQuery)事前計算ベクトルクエリを設定。
setVectorTextQuery(query: VectorTextQuery)テキストベースのベクトルクエリを設定(登録 Embedder で自動埋め込み)。

Lexical セッター(per-type)

X{ Term, Phrase, Fuzzy, Wildcard, NumericRange, GeoDistance, GeoBoundingBox, Geo3dDistance, Geo3dBoundingBox, Geo3dNearest, Boolean, Span } の各クエリタイプとして、以下のメソッドが公開されています:

メソッド説明
setLexicalX(query: X)明示的なハイブリッドリクエストの Lexical コンポーネントを設定。
setFilterX(query: X)スコアリング後のフィルタコンポーネントを設定。

合計 24 個の per-type セッター(12 lexical + 12 filter)に加え、上記の DSL / ベクトル / フュージョンセッターが利用可能です。

const req = new SearchRequest({ limit: 5 });
req.setLexicalTerm(new TermQuery("title", "rust"));
req.setVectorQuery(new VectorQuery("embedding", [0.1, 0.2, 0.3, 0.4]));
req.setRrfFusion(new RRF(60.0));
const results = await index.searchWithRequest(req);

SearchResult

検索メソッドが配列として返す結果。

interface SearchResult {
  id: string;        // 外部ドキュメント識別子
  score: number;     // 関連度スコア
  document: object | null; // 取得フィールド、stored=false の場合は null
}

融合アルゴリズム

RRF

new RRF(k?: number)  // デフォルト 60.0

Reciprocal Rank Fusion。ランク位置で Lexical と Vector の 結果リストを統合。

WeightedSum

new WeightedSum(
  lexicalWeight?: number,  // デフォルト 0.5
  vectorWeight?: number,   // デフォルト 0.5
)

両スコアリストを個別に正規化し、加重和で結合。


テキスト解析

SynonymDictionary

class SynonymDictionary {
  constructor();
  addSynonymGroup(terms: string[]): void;
}

WhitespaceTokenizer

class WhitespaceTokenizer {
  constructor();
  tokenize(text: string): Token[];
}

SynonymGraphFilter

class SynonymGraphFilter {
  constructor(
    dictionary: SynonymDictionary,
    keepOriginal?: boolean,  // デフォルト true
    boost?: number,          // デフォルト 1.0
  );
  apply(tokens: Token[]): Token[];
}

Token

interface Token {
  text: string;
  position: number;
  startOffset: number;
  endOffset: number;
  boost: number;
  stopped: boolean;
  positionIncrement: number;
  positionLength: number;
}

フィールド値の型

JavaScript の値は自動的に Laurus の DataValue 型に変換されます:

JavaScript 型Laurus 型備考
nullNull
booleanBool
number(整数)Int64
number(浮動小数点)Float64
stringTextISO 8601 文字列は DateTime になる
number[]Vectorf32 に変換
{ lat, lon }Geo2 つの number
{ x, y, z }GeoEcef3 つの number 値(メートル単位、3D ECEF 直交座標)

開発環境のセットアップ

laurus-nodejs バインディングのローカル開発環境の構築、 ビルド、テスト実行について説明します。

前提条件

  • Rust 1.85 以降(Cargo 含む)
  • Node.js 24.15 以降(npm 含む。package.jsonengines.node と一致)
  • リポジトリがローカルにクローン済み
git clone https://github.com/mosuka/laurus.git
cd laurus

ビルド

開発ビルド

デバッグモードで Rust ネイティブアドオンをコンパイルします。 Rust ソースを変更した後は再実行してください。

cd laurus-nodejs
npm install
npm run build:debug

リリースビルド

npm run build

ビルドの確認

node -e "
const { Index } = require('./index.js');
Index.create().then(idx => console.log(idx.stats()));
"
// { documentCount: 0, vectorFields: {} }

テスト

テストには Vitest を使用し、 __tests__/ に配置されています。

# 全テスト実行
npm test

特定のテストを名前で実行:

npx vitest run -t "searches with DSL string"

リントとフォーマット

# Rust リント(Clippy)
cargo clippy -p laurus-nodejs -- -D warnings

# Rust フォーマットチェック
cargo fmt -p laurus-nodejs --check

# フォーマット適用
cargo fmt -p laurus-nodejs

クリーンアップ

# ビルド成果物の削除
rm -f *.node index.js index.d.ts

# node_modules の削除
rm -rf node_modules

プロジェクト構成

laurus-nodejs/
├── Cargo.toml          # Rust クレートマニフェスト
├── build.rs            # napi-build セットアップ
├── package.json        # npm パッケージメタデータ
├── README.md           # 英語 README
├── README_ja.md        # 日本語 README
├── src/                # Rust ソース(napi-rs バインディング)
│   ├── lib.rs          # モジュール登録
│   ├── index.rs        # Index クラス
│   ├── schema.rs       # Schema クラス
│   ├── query.rs        # Query クラス群
│   ├── search.rs       # SearchRequest / SearchResult / Fusion
│   ├── analysis.rs     # Tokenizer / Filter / Token
│   ├── convert.rs      # JS ↔ DataValue 変換
│   └── errors.rs       # エラーマッピング
├── __tests__/          # Vitest 統合テスト
│   └── index.spec.mjs
└── examples/           # 実行可能な Node.js サンプル
    ├── quickstart.mjs
    ├── lexical-search.mjs
    ├── vector-search.mjs
    └── hybrid-search.mjs

WASM バインディング概要

laurus-wasm パッケージは、Laurus 検索エンジンの WebAssembly バインディングです。 サーバーなしで、ブラウザやエッジランタイム(Cloudflare Workers、Vercel Edge Functions、Deno Deploy) 上で直接、レキシカル検索・ベクトル検索・ハイブリッド検索を実行できます。

機能

  • レキシカル検索 – BM25 スコアリングによる転置インデックスベースの全文検索
  • ベクトル検索 – Flat、HNSW、IVF インデックスによる近似最近傍探索
  • ハイブリッド検索 – RRF、WeightedSum による融合アルゴリズム
  • クエリ DSL – Term、Phrase、Fuzzy、Wildcard、NumericRange、Geo、Boolean、Span
  • テキスト分析 – トークナイザー、フィルター、同義語展開
  • インメモリストレージ – 高速な一時インデックス
  • OPFS 永続化 – Origin Private File System によるページリロード後のデータ保持
  • TypeScript 型定義 – 自動生成される .d.ts ファイル
  • 非同期 API – すべての I/O 操作は Promise を返す

アーキテクチャ

graph LR
    subgraph "laurus-wasm"
        WASM[wasm-bindgen API]
    end
    subgraph "laurus(コア)"
        Engine
        MemoryStorage
    end
    subgraph "ブラウザ"
        JS[JavaScript / TypeScript]
        OPFS[Origin Private File System]
    end
    JS --> WASM
    WASM --> Engine
    Engine --> MemoryStorage
    WASM -.->|永続化| OPFS

Embedding 戦略

ネイティブ環境では Laurus に複数の組み込み Embedder(Candle BERT、Candle CLIP、 OpenAI API)が用意されており、ドキュメントのインデックス時や searchVectorText("field", "query text") 実行時にエンジンが自動で呼び出します。 これらネイティブ Embedder は wasm32-unknown-unknown 上で動作しないため、 WASM ビルドでは無効化されています:

EmbedderDependencyWhy it cannot run in WASM
candle_bertcandle (GPU/SIMD)Requires native SIMD intrinsics and file system for models
candle_clipcandleSame as above
openaireqwest (HTTP)Requires a full async HTTP client (tokio + TLS)

(これらは embeddings-candle / embeddings-openai Feature Flags で管理されており、 wasm32-unknown-unknown で無効化される native feature に依存するため WASM ビルドから除外されます。)

laurus-wasm ではその代わりに以下 2 種類の addEmbedder タイプを公開しています:

  • "precomputed" — 呼び出し側が putDocument() / searchVector() 経由で ベクトルを直接渡します。エンジンは埋め込みを行いません。
  • "callback" — JavaScript コールバック embed: (text) => Promise<number[]> を登録し、エンジンがインジェスト時および searchVectorText() から呼び出します。Transformers.js 等のブラウザ内埋め込み ライブラリと組み合わせることでエンジン内自動埋め込みが実現でき、ネイティブ環境と 同じく searchVectorText("field", "query text") を呼び出すだけで利用できます。

Option A — 事前計算済みベクトル

JavaScript 側で埋め込みを計算し、事前計算済みベクトルを putDocument()searchVector() に渡します:

// Transformers.js を使用(all-MiniLM-L6-v2、384次元)
import { pipeline } from '@huggingface/transformers';

const embedder = await pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2');

async function embed(text) {
  const output = await embedder(text, { pooling: 'mean', normalize: true });
  return Array.from(output.data);
}

// 事前計算済み埋め込みでインデックス
const vec = await embed("Rust 入門");
await index.putDocument("doc1", { title: "Rust 入門", embedding: vec });
await index.commit();

// 事前計算済みクエリ埋め込みで検索
const queryVec = await embed("安全なシステムプログラミング");
const results = await index.searchVector("embedding", queryVec);

このアプローチにより、ネイティブ環境と同じ Sentence Transformer モデルを使った セマンティック検索がブラウザ内で実現できます。埋め込み計算は candle ではなく Transformers.js(ONNX Runtime Web)が担当します。

Option B — Callback Embedder

Transformers.js の同じパイプラインを "callback" Embedder として登録すれば、 エンジンが自動で呼び出してくれます。登録後は呼び出し側がベクトルを管理することなく、 インジェストおよび searchVectorText() が透過的に動作します:

import { pipeline } from '@huggingface/transformers';

const extractor = await pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2');

schema.addEmbedder("transformers", {
  type: "callback",
  embed: async (text) => {
    const output = await extractor(text, { pooling: 'mean', normalize: true });
    return Array.from(output.data);
  },
});
schema.addHnswField("embedding", 384, "cosine", undefined, undefined, "transformers");
const index = await Index.create(schema);

await index.putDocument("doc1", { title: "Rust 入門" });
await index.commit();

const results = await index.searchVectorText("embedding", "安全なシステムプログラミング");

Option A と比べると、Callback アプローチではエンジンがインジェスト時に埋め込みを キャッシュでき、書き込み側と読み出し側で埋め込みコードを重複させずに済みます。 ただし commit() のたびに JS コールバックの解決を待つため、大量バルク投入時には 事前計算ベクトルのほうが有利な場合があります。

laurus-wasm と laurus-nodejs の使い分け

基準laurus-wasmlaurus-nodejs
実行環境ブラウザ、エッジランタイムNode.js サーバー
パフォーマンス良好(シングルスレッド)最高(ネイティブ、マルチスレッド)
ストレージインメモリ + OPFSインメモリ + ファイルシステム
埋め込み事前計算 + JS コールバックCandle、OpenAI、事前計算
パッケージnpm install laurus-wasmnpm install laurus-nodejs
バイナリサイズ約 5-10 MB(WASM)プラットフォームネイティブ

インストール

npm / yarn / pnpm

npm install laurus-wasm
# または
yarn add laurus-wasm
# または
pnpm add laurus-wasm

CDN(ES Module)

<script type="module">
  import init, { Index, Schema } from 'https://unpkg.com/laurus-wasm/laurus_wasm.js';
  await init();
  // ...
</script>

ソースからビルド

前提条件:

git clone https://github.com/mosuka/laurus.git
cd laurus/laurus-wasm

# バンドラー向け(webpack、vite 等)
wasm-pack build --target bundler --release

# ブラウザ直接利用向け(<script type="module">)
wasm-pack build --target web --release

出力は pkg/ ディレクトリに生成されます。

ブラウザ対応状況

laurus-wasm は以下をサポートするブラウザが必要です:

  • WebAssembly(すべてのモダンブラウザ)
  • ES Modules

OPFS 永続化には以下のブラウザが対応しています:

ブラウザ最小バージョン
Chrome102+
Firefox111+
Safari15.2+
Edge102+

クイックスタート

基本的な使い方(インメモリ)

import init, { Index, Schema } from 'laurus-wasm';

// WASM モジュールを初期化
await init();

// スキーマを定義
const schema = new Schema();
schema.addTextField("title");
schema.addTextField("body");
schema.setDefaultFields(["title", "body"]);

// インメモリインデックスを作成
const index = await Index.create(schema);

// ドキュメントを追加
await index.putDocument("doc1", {
  title: "Rust 入門",
  body: "Rust はシステムプログラミング言語です"
});
await index.putDocument("doc2", {
  title: "WebAssembly ガイド",
  body: "WASM はブラウザでネイティブに近いパフォーマンスを実現します"
});
await index.commit();

// 検索
const results = await index.search("rust");
for (const result of results) {
  console.log(`${result.id}: ${result.score}`);
  console.log(result.document);
}

永続化ストレージ(OPFS)

import init, { Index, Schema } from 'laurus-wasm';

await init();

const schema = new Schema();
schema.addTextField("title");
schema.addTextField("body");

// 永続化インデックスを開く(ページリロード後もデータが保持される)
const index = await Index.open("my-index", schema);

// ドキュメントを追加
await index.putDocument("doc1", {
  title: "Hello",
  body: "World"
});

// commit() で自動的に OPFS に永続化される
await index.commit();

// 次のページロード時、Index.open("my-index") でデータが復元される

日本語形態素検索

ブラウザ WASM では Lindera 辞書をファイルシステムパスで指定できないため、 OPFS にロードした IPADIC のバイト列から analyzer を構築します。

import init, { Index, Schema, JapaneseAnalyzer } from 'laurus-wasm';
import {
  downloadDictionary,
  loadDictionaryFiles,
  hasDictionary,
} from 'laurus-wasm/opfs';

await init();

// 1. 初回訪問時に IPADIC アーカイブを OPFS にキャッシュする。zip は
//    アプリと同一オリジンで配信する必要がある(GitHub Releases は CORS
//    でブロックされる)。圧縮 ~16 MB / 展開後 ~58 MB。
if (!(await hasDictionary("ipadic"))) {
  await downloadDictionary("./dict/lindera-ipadic.zip", "ipadic", {
    onProgress: ({ phase, loaded, total }) => console.log(phase, loaded, total),
  });
}

// 2. 8 つのコンポーネントファイルを読み出して analyzer を構築する。
const f = await loadDictionaryFiles("ipadic");
const ja = JapaneseAnalyzer.fromBytes(
  f.metadata, f.dictDa, f.dictVals, f.dictWordsIdx,
  f.dictWords, f.matrixMtx, f.charDef, f.unk,
  "normal",
);

// 3. analyzer をスキーマに登録し、テキストフィールドから名前で参照する。
const schema = new Schema();
schema.addAnalyzer("ja-ipadic", ja);
schema.addTextField("title", undefined, undefined, undefined, "ja-ipadic");
schema.addTextField("body", undefined, undefined, undefined, "ja-ipadic");
schema.setDefaultFields(["title", "body"]);

const index = await Index.create(schema);

await index.putDocument("doc1", {
  title: "形態素解析",
  body: "Lindera は Rust 製の形態素解析ライブラリです。",
});
await index.commit();

const results = await index.search("形態素");
console.log(results[0].document.title); // "形態素解析"

JapaneseAnalyzer.fromBytes の完全なシグネチャと OPFS ヘルパ API は API リファレンス を参照してください。

ベクトル検索

import init, { Index, Schema } from 'laurus-wasm';

await init();

const schema = new Schema();
schema.addTextField("title");
schema.addHnswField("embedding", 3); // 3次元ベクトル

const index = await Index.create(schema);

await index.putDocument("doc1", {
  title: "Rust",
  embedding: [1.0, 0.0, 0.0]
});
await index.putDocument("doc2", {
  title: "Python",
  embedding: [0.0, 1.0, 0.0]
});
await index.commit();

// ベクトル類似度で検索
const results = await index.searchVector("embedding", [0.9, 0.1, 0.0]);
console.log(results[0].document.title); // "Rust"

バンドラーでの利用

Vite

// vite.config.js
import wasm from 'vite-plugin-wasm';

export default {
  plugins: [wasm()]
};

Webpack 5

Webpack 5 は asyncWebAssembly で WASM をネイティブサポートしています:

// webpack.config.js
module.exports = {
  experiments: {
    asyncWebAssembly: true
  }
};

API リファレンス

Index

検索インデックスの作成・クエリを行うメインエントリポイントです。

静的メソッド

Index.create(schema?)

新しいインメモリ(一時)インデックスを作成します。

  • 引数:
    • schema (Schema, 省略可) – スキーマ定義
  • 戻り値: Promise<Index>

Index.open(name, schema?)

OPFS で永続化されたインデックスを開くか、新規作成します。

  • 引数:
    • name (string) – インデックス名(OPFS サブディレクトリ)
    • schema (Schema, 省略可) – スキーマ定義
  • 戻り値: Promise<Index>

インスタンスメソッド

putDocument(id, document)

ドキュメントを置換(upsert)します。

  • 引数:
    • id (string) – ドキュメント識別子
    • document (object) – スキーマフィールドに対応するキーバリューペア
  • 戻り値: Promise<void>

addDocument(id, document)

ドキュメントバージョンを追加します(マルチバージョン RAG パターン)。

  • 引数・戻り値: putDocument と同じ

getDocuments(id)

ドキュメントの全バージョンを取得します。

  • 引数: id (string)
  • 戻り値: Promise<object[]>

deleteDocuments(id)

ドキュメントの全バージョンを削除します。

  • 引数: id (string)
  • 戻り値: Promise<void>

commit()

書き込みをフラッシュし、変更を検索可能にします。 Index.open() で作成したインデックスの場合、OPFS にも自動永続化されます。

  • 戻り値: Promise<void>

search(query, limit?, offset?)

DSL 文字列クエリで検索します。

  • 引数:
    • query (string) – クエリ DSL(例: "title:hello"
    • limit (number, デフォルト 10)
    • offset (number, デフォルト 0)
  • 戻り値: Promise<SearchResult[]>

searchTerm(field, term, limit?, offset?)

完全一致タームで検索します。

  • 引数:
    • field (string) – フィールド名
    • term (string) – 検索ターム
    • limit, offset (number, 省略可)
  • 戻り値: Promise<SearchResult[]>

searchVector(field, vector, limit?, offset?)

ベクトル類似度で検索します。

  • 引数:
    • field (string) – ベクトルフィールド名
    • vector (number[]) – クエリ埋め込みベクトル
    • limit, offset (number, 省略可)
  • 戻り値: Promise<SearchResult[]>

searchVectorText(field, text, limit?, offset?)

テキストで検索します(登録された埋め込み器で変換)。

  • 引数:
    • field (string) – ベクトルフィールド名
    • text (string) – 埋め込み対象テキスト
    • limit, offset (number, 省略可)
  • 戻り値: Promise<SearchResult[]>

searchGeo3dDistance(field, x, y, z, distanceM, limit?, offset?)

3D ECEF 座標フィールドへの球距離検索。中心 (x, y, z) から distanceM メートル以内 の座標を持つドキュメントを返します。ECEF の理論については Geo3d の概念 を参照。

  • 引数:
    • field (string) – Geo3d フィールド名
    • x, y, z (number) – 中心 ECEF 座標(メートル)
    • distanceM (number) – 中心からの最大距離(メートル)
    • limit, offset (number, 省略可)
  • 戻り値: Promise<SearchResult[]>

searchGeo3dBoundingBox(field, minX, minY, minZ, maxX, maxY, maxZ, limit?, offset?)

3D ECEF 座標フィールドへの軸並行範囲(AABB)検索。

  • 引数:
    • field (string) – Geo3d フィールド名
    • minX, minY, minZ, maxX, maxY, maxZ (number) – 範囲境界(メートル)
    • limit, offset (number, 省略可)
  • 戻り値: Promise<SearchResult[]>

searchGeo3dNearest(field, x, y, z, k, limit?, offset?, initialRadiusM?, maxRadiusM?)

3D ECEF 座標フィールドへの k 最近傍検索。(x, y, z) から最も近い k 件のドキュ メントを返します。initialRadiusM / maxRadiusM(オプション)で反復拡張サーチの 探索コーンを調整できます。

  • 引数:
    • field (string) – Geo3d フィールド名
    • x, y, z (number) – 中心 ECEF 座標(メートル)
    • k (number) – 返す近傍件数
    • limit, offset (number, 省略可)
    • initialRadiusM, maxRadiusM (number, 省略可)
  • 戻り値: Promise<SearchResult[]>

stats()

インデックス統計を返します。

  • 戻り値: { documentCount: number, vectorFields: { [name]: { count, dimension } } }

Schema

インデックスフィールドと埋め込み器を定義するビルダーです。

コンストラクタ

new Schema()

空のスキーマを作成します。

メソッド

addTextField(name, stored?, indexed?, termVectors?, analyzer?)

全文検索テキストフィールドを追加します。analyzer にはパラメータ不要の 組込名("standard" / "english" / "keyword" / "simple" / "noop")または addAnalyzer() で登録したランタイム analyzer 名を 指定します。

日本語の形態素解析を行う場合は、まず JapaneseAnalyzer を IPADIC の バイト列から構築し、addAnalyzer() で登録してください。 JapaneseAnalyzer.fromBytesaddAnalyzer を参照。

addIntegerField(name, stored?, indexed?, multiValued?)

64 ビット整数フィールドを追加します。multiValued: true を指定すると整数配列を受け付け、 範囲クエリはいずれかの値が条件を満たせばマッチ(Lucene 流の “any match”、constant スコア)します。

addFloatField(name, stored?, indexed?, multiValued?)

64 ビット浮動小数点フィールドを追加します。multiValued: true を指定すると浮動小数点配列を受け付け、 範囲クエリはいずれかの値が条件を満たせばマッチ(Lucene 流の “any match”、constant スコア)します。

addBooleanField(name, stored?, indexed?)

真偽値フィールドを追加します。

addDatetimeField(name, stored?, indexed?)

日時フィールドを追加します。

addGeoField(name, stored?, indexed?)

地理座標フィールドを追加します。

addGeo3dField(name, stored?, indexed?)

3D ECEF カルテシアン座標フィールド(x, y, z はメートル)を追加します。値は { x, y, z } オブジェクトで投入します。詳細は Geo3d の概念 を参照。

WASM バインディングは Geo3dDistanceQuery / Geo3dBoundingBoxQuery / Geo3dNearestQuery を JS クラスとして公開していません(wasm-bindgen は dyn Query トレイトオブジェクトを公開できないため)。代わりに上記の Index.searchGeo3dDistance / Index.searchGeo3dBoundingBox / Index.searchGeo3dNearest メソッドを使用してください。

addBytesField(name, stored?)

バイナリデータフィールドを追加します。

addHnswField(name, dimension, distance?, m?, efConstruction?, embedder?, quantizer?, subvectorCount?, rerankStorage?)

HNSW ベクトルインデックスフィールドを追加します。

  • distance: "cosine"(デフォルト)、"euclidean""dot_product""manhattan""angular"
  • m: 分岐係数(デフォルト 16)
  • efConstruction: 構築時の探索幅(デフォルト 200)
  • quantizer: "scalar_8bit"(デフォルト)または "product_quantization"subvectorCount が必須)
  • subvectorCount: PQ サブベクトル数。dimension を割り切れる値を指定します
  • rerankStorage: 省略(デフォルト)するか、"f32" を指定して完全精度のリランクサイドカーを保存します

addFlatField(name, dimension, distance?, embedder?)

全探索ベクトルインデックスフィールドを追加します。

addIvfField(name, dimension, distance?, nClusters?, nProbe?, embedder?)

IVF ベクトルインデックスフィールドを追加します。

  • nClusters: パーティショニングクラスタ数(デフォルト 100)
  • nProbe: 検索時にプローブするクラスタ数(デフォルト 1)

ベクトル量子化とリランクストレージ(HNSW フィールド):

  • quantizer"scalar_8bit"(デフォルト、4 倍圧縮)または高圧縮率の "product_quantization"。Product quantization では subvectorCountdimension を割り切れる値)が必須です。
  • rerankStorage"f32" を指定すると完全精度の *.hnsw.f32 サイドカーを書き出し、厳密な Stage-2 リランクを有効化します。省略すると int8 のみのセグメントを維持します。

addAnalyzer(name, analyzer)

事前に構築した analyzer インスタンスを name で登録します。テキスト フィールドが Named 形式で analyzer を参照するときに、組込名や schema.analyzers 定義よりも先に解決されます。

現状は JapaneseAnalyzer.fromBytes で構築した JapaneseAnalyzer のみ受け付けます。ブラウザ WASM では { "language": "japanese", "dict": ... } プリセットがファイルシステム パスを解決できないため、ランタイムレジストリ経由が日本語 analyzer を 利用する唯一の現実的な経路です。

import { JapaneseAnalyzer, Schema } from "laurus-wasm";
import { downloadDictionary, loadDictionaryFiles } from "laurus-wasm/opfs";

await downloadDictionary("./dict/lindera-ipadic.zip", "ipadic");
const f = await loadDictionaryFiles("ipadic");
const ja = JapaneseAnalyzer.fromBytes(
  f.metadata, f.dictDa, f.dictVals, f.dictWordsIdx,
  f.dictWords, f.matrixMtx, f.charDef, f.unk, "normal",
);

const schema = new Schema();
schema.addAnalyzer("ja-ipadic", ja);
schema.addTextField("body", undefined, undefined, undefined, "ja-ipadic");

addEmbedder(name, config)

名前付き埋め込み器を登録します。WASM では以下の 2 種類の type をサポートします:

  • "precomputed" — 埋め込みは行いません。ベクトルは putDocument() / searchVector() 経由で直接渡します。
  • "callback" — JavaScript コールバック embed: (text) => Promise<number[]> を 登録します。エンジンがインジェスト時および searchVectorText() で呼び出します。 Transformers.js などのブラウザ内埋め込みライブラリと組み合わせることで、 エンジン内自動埋め込みが可能になります。
// Precomputed embedder
schema.addEmbedder("precomputed-embedder", { type: "precomputed" });

// Callback embedder(例: Transformers.js)
schema.addEmbedder("callback-embedder", {
  type: "callback",
  embed: async (text) => {
    const output = await pipeline(text, { pooling: "mean", normalize: true });
    return Array.from(output.data);
  },
});

setDefaultFields(fields)

デフォルト検索フィールドを設定します。

setDynamicFieldPolicy(policy)

ドキュメントに含まれるがスキーマに宣言されていないフィールドの扱いを設定します。policy"strict" / "dynamic"(デフォルト)/ "ignore" のいずれか(大文字小文字を無視)。不正な値を渡すと例外をスローします。

  • "strict" — ドキュメントを拒否
  • "dynamic" — 各未宣言フィールドの型を推論してスキーマに追加。警告: integer フィールドに入ってきた float 値は静かに切り捨てられます(3.143
  • "ignore" — 未宣言フィールドを静かに破棄

詳細な挙動マトリクスは スキーマとフィールド を参照してください。

dynamicFieldPolicy()

現在のポリシーを小文字の文字列で返します。

fieldNames()

定義済みフィールド名の配列を返します。

toString()

スキーマの文字列表現("Schema(fields=[...])" 形式)を返します。

SearchResult

interface SearchResult {
  id: string;
  score: number;
  document: object | null;
}

Analysis

JapaneseAnalyzer

Lindera 辞書のバイト列から構築する日本語形態素解析 analyzer。 ブラウザ WASM には実ファイルシステムが無いため、標準の { "language": "japanese", "dict": "/path/to/ipadic" } プリセットは 利用できません。代わりに Lindera 辞書アーカイブ(典型的には lindera-ipadic-X.Y.Z.zip)を取得して OPFS ヘルパ で OPFS に保存し、8 つのコンポーネントバイト配列を JapaneseAnalyzer.fromBytes に渡してください。

JapaneseAnalyzer.fromBytes(metadata, dictDa, ..., mode?)

IPADIC のバイト列から analyzer を構築する static ファクトリ。

引数(mode 以外はすべて Uint8Array):

引数対応するファイル
metadatametadata.json
dictDadict.da(Double-Array Trie)
dictValsdict.vals
dictWordsIdxdict.wordsidx
dictWordsdict.words
matrixMtxmatrix.mtx
charDefchar_def.bin
unkunk.bin
mode"normal"(デフォルト)/ "search" / "decompose"

いずれかのコンポーネントの deserialization に失敗した場合、または mode 文字列が不正な場合は throw します。

import { JapaneseAnalyzer } from "laurus-wasm";
import { loadDictionaryFiles } from "laurus-wasm/opfs";

const f = await loadDictionaryFiles("ipadic");
const ja = JapaneseAnalyzer.fromBytes(
  f.metadata, f.dictDa, f.dictVals, f.dictWordsIdx,
  f.dictWords, f.matrixMtx, f.charDef, f.unk,
  "normal",
);

パイプラインは NFKC 正規化 → 日本語 iteration mark 正規化 → Lindera 形態素解析 → lowercase → 日本語 stop word フィルタ で、ネイティブ側の japanese プリセットと完全に一致します。

OPFS ヘルパ

laurus-wasm/opfs サブパスは、Lindera 辞書をブラウザの Origin Private File System にダウンロード・保存・読込するヘルパを提供します。 JapaneseAnalyzer.fromBytes と組み合わせて使用します。

import {
  downloadDictionary,
  loadDictionaryFiles,
  hasDictionary,
  listDictionaries,
  removeDictionary,
} from "laurus-wasm/opfs";
関数説明
downloadDictionary(url, name, options?).zip を fetch し、Web の DecompressionStream API で展開して、Lindera 8 ファイルを OPFS の laurus/dictionaries/<name>/ 配下に保存します。options.onProgress({ phase, loaded?, total? }) で進捗通知を受け取れます。
loadDictionaryFiles(name)8 ファイルを { metadata, dictDa, dictVals, dictWordsIdx, dictWords, matrixMtx, charDef, unk } オブジェクトとして読み出し、JapaneseAnalyzer.fromBytes にそのまま渡せる形にします。
hasDictionary(name)辞書ディレクトリが OPFS にあれば true
listDictionaries()保存済み辞書名の配列を返します。
removeDictionary(name)辞書ディレクトリを削除します。

ブラウザ CORS の制約により GitHub Releases から直接 fetch できないため、 zip はアプリと同一オリジンで配信してください(Laurus デモではデプロイ 時に ./dict/lindera-ipadic.zip を WASM と同じパスに同梱します)。

WhitespaceTokenizer

const tokenizer = new WhitespaceTokenizer();
const tokens = tokenizer.tokenize("hello world");
// [{ text, position, startOffset, endOffset, boost, stopped, positionIncrement, positionLength }]

空白を境界としてテキストを分割し、Token オブジェクトの配列を返します。

SynonymDictionary

const dict = new SynonymDictionary();
dict.addSynonymGroup(["ml", "machine learning"]);

同義語グループの辞書。グループ内のすべての語句が互いに同義語として扱われます。

SynonymGraphFilter

new SynonymGraphFilter(dictionary, keepOriginal = true, boost = 1.0)
  • dictionary (SynonymDictionary) — 同義語グループのソース。
  • keepOriginal (boolean, デフォルト true) — 元のトークンを挿入された同義語と 並べて保持します。
  • boost (number, デフォルト 1.0) — 挿入される同義語トークンに適用される スコアブースト。
const filter = new SynonymGraphFilter(dict, true, 0.8);
const expanded = filter.apply(tokens);

SynonymDictionary の同義語でトークンを展開するトークンフィルターです。

開発

前提条件

  • Rust(stable、wasm32-unknown-unknown ターゲット付き)
  • wasm-pack
  • Node.js(テストと npm publish 用)
rustup target add wasm32-unknown-unknown
cargo install wasm-pack

ビルド

cd laurus-wasm

# デバッグビルド(コンパイル高速)
wasm-pack build --target web --dev

# リリースビルド(最適化)
wasm-pack build --target web --release

# バンドラーターゲット(webpack、vite 等)
wasm-pack build --target bundler --release

プロジェクト構成

laurus-wasm/
├── Cargo.toml          # Rust 依存関係(wasm-bindgen、laurus コア)
├── package.json        # npm パッケージメタデータ
├── src/
│   ├── lib.rs          # モジュール宣言
│   ├── index.rs        # Index クラス(CRUD + 検索)
│   ├── schema.rs       # Schema ビルダー
│   ├── search.rs       # SearchRequest / SearchResult
│   ├── query.rs        # クエリ型定義
│   ├── convert.rs      # JsValue ↔ Document 変換
│   ├── analysis.rs     # トークナイザー / フィルターラッパー
│   ├── errors.rs       # LaurusError → JsValue 変換
│   └── storage.rs      # OPFS 永続化レイヤー
└── js/
    └── opfs_bridge.js  # Origin Private File System 用 JS グルーコード

アーキテクチャノート

ストレージ戦略

laurus-wasm は二層ストレージアプローチを採用しています:

  1. MemoryStorage(ランタイム) – すべての読み書き操作は Laurus の インメモリストレージを経由します。これは Storage トレイトの Send + Sync 要件を満たします。

  2. OPFS(永続化) – commit() 時に MemoryStorage の全状態が OPFS ファイルにシリアライズされます。Index.open() 時に OPFS ファイルが MemoryStorage にロードされます。

この設計により、JS ハンドルの Send + Sync 非互換性を回避しつつ、 コアエンジンを変更せずに永続化を実現しています。

Feature Flags

laurus コアは Feature Flags で WASM をサポートしています:

# laurus-wasm はデフォルト機能なしで laurus に依存
laurus = { workspace = true, default-features = false }

これにより、ネイティブ専用の依存関係(tokio/full、rayon、memmap2 等)が 除外され、#[cfg(target_arch = "wasm32")] フォールバックで並列処理が 逐次処理に切り替わります。

日本語形態素解析

ブラウザ WASM にはファイルシステムが無いため、{ "language": "japanese", "dict": "/path/to/ipadic" } の標準アナライザープリセットは利用できません。laurus-wasmsrc/analysis.rsJapaneseAnalyzer.fromBytes(...) を公開しており、Lindera IPADIC 辞書アーカイブを実行時に OPFS へ取得し、Lindera が必要とする 8 つの生バイト配列を読み出してアナライザーに渡せます:

import { JapaneseAnalyzer, Schema } from "laurus-wasm";
import { downloadDictionary, loadDictionaryFiles } from "laurus-wasm/opfs";

await downloadDictionary("./dict/lindera-ipadic.zip", "ipadic");
const f = await loadDictionaryFiles("ipadic");
const ja = JapaneseAnalyzer.fromBytes(
  f.metadata, f.dictDa, f.dictVals, f.dictWordsIdx,
  f.dictWords, f.matrixMtx, f.charDef, f.unk, "normal",
);

const schema = new Schema();
schema.addAnalyzer("ja-ipadic", ja);
schema.addTextField("body", undefined, undefined, undefined, "ja-ipadic");

OPFS ヘルパー(downloadDictionary / loadDictionaryFiles / hasDictionary / listDictionaries / removeDictionary)は js/opfs.js にあり、package.jsonlaurus-wasm/opfs サブパスとして再公開されています。引数表は API リファレンス → JapaneseAnalyzer を参照してください。

コールバック Embedder

事前計算済みベクトルを受け取る "precomputed" Embedder に加えて、laurus-wasm は JS 側から async embed: (text) => Promise<number[]> を渡せる "callback" Embedder をサポートします。エンジンはドキュメント投入時と searchVectorText() クエリ時にこのコールバックを呼び出すため、WASM モジュールを再ビルドすることなく任意のブラウザ向け埋め込みライブラリ(Transformers.js、ONNX Runtime Web 等)を統合できます:

import { pipeline } from "@xenova/transformers";

const embedder = await pipeline(
  "feature-extraction",
  "Xenova/all-MiniLM-L6-v2",
);

schema.addEmbedder("minilm", {
  type: "callback",
  embed: async (text) => {
    const output = await embedder(text, { pooling: "mean", normalize: true });
    return Array.from(output.data);
  },
});

schema.addHnswField(
  "embedding", 384, "cosine",
  undefined, undefined, "minilm",
);

wasm-bindgen のグルーコードが JS コールバックを Closure で保持するため、インデックスの寿命中ずっと有効です。コールバックは常にメインスレッドで実行されるため、Send + Sync 制約は付きません。

テスト

# ビルド確認
cargo build -p laurus-wasm --target wasm32-unknown-unknown

# Clippy
cargo clippy -p laurus-wasm --target wasm32-unknown-unknown -- -D warnings

ブラウザテストは wasm-pack test で実行できます:

wasm-pack test --headless --chrome

Ruby バインディング概要

laurus gem は Laurus 検索エンジンの Ruby バインディングです。Magnusrb_sys を使ってネイティブ Rust 拡張としてビルドされており、Ruby プログラムからネイティブに近いパフォーマンスで Laurus の Lexical 検索、Vector 検索、ハイブリッド検索機能を利用できます。

機能

  • Lexical 検索 – BM25 スコアリングを備えた転置インデックスによる全文検索
  • Vector 検索 – Flat、HNSW、IVF インデックスを使用した近似最近傍(ANN)検索
  • ハイブリッド検索 – フュージョンアルゴリズム(RRF、WeightedSum)で Lexical と Vector の結果を統合
  • 豊富なクエリ DSL – Term、Phrase、Fuzzy、Wildcard、NumericRange、Geo、Boolean、Span クエリ
  • テキスト解析 – トークナイザー、フィルター、ステマー、同義語展開
  • 柔軟なストレージ – インメモリ(一時的)またはファイルベース(永続的)インデックス
  • Ruby らしい APILaurus:: 名前空間の直感的な Ruby クラス

アーキテクチャ

graph LR
    subgraph "laurus-ruby (gem)"
        RbIndex["Index\n(Ruby クラス)"]
        RbQuery["クエリクラス"]
        RbSearch["SearchRequest\n/ SearchResult"]
    end

    Ruby["Ruby アプリケーション"] -->|"メソッド呼び出し"| RbIndex
    Ruby -->|"クエリオブジェクト"| RbQuery
    RbIndex -->|"Magnus FFI"| Engine["laurus::Engine\n(Rust)"]
    RbQuery -->|"Magnus FFI"| Engine
    Engine --> Storage["ストレージ\n(Memory / File)"]

Ruby クラスは Rust エンジンの薄いラッパーです。 各呼び出しは Magnus の FFI 境界を一度だけ越え、その後 Rust エンジンが操作をネイティブコードで実行します。

Rust エンジン内部は非同期 I/O を使用していますが、 Ruby 側のメソッドはすべて同期関数として公開されています。 各メソッドは内部で tokio::Runtime::block_on() を呼び出し、 非同期 Rust を同期 Ruby にブリッジしています。

クイックスタート

require "laurus"

# インメモリインデックスを作成
index = Laurus::Index.new

# ドキュメントをインデックス
index.put_document("doc1", { "title" => "Rust 入門", "body" => "システムプログラミング言語です。" })
index.put_document("doc2", { "title" => "Ruby Web 開発", "body" => "Ruby による Web アプリケーション。" })
index.commit

# 検索
results = index.search("title:rust", limit: 5)
results.each do |r|
  puts "[#{r.id}] score=#{format('%.4f', r.score)}  #{r.document['title']}"
end

セクション

インストール

RubyGems からインストール

gem install laurus

または Gemfile に追加します:

gem "laurus"

その後、以下を実行します:

bundle install

ソースからビルド

ソースからビルドするには Rust ツールチェーン(1.85 以降)と rb_sys が必要です。

# リポジトリをクローン
git clone https://github.com/mosuka/laurus.git
cd laurus/laurus-ruby

# 依存関係をインストール
bundle install

# ネイティブ拡張をコンパイル
bundle exec rake compile

# またはローカルに gem をインストール
gem build laurus.gemspec
gem install laurus-*.gem

動作確認

require "laurus"
index = Laurus::Index.new
puts index  # Index()

動作要件

  • Ruby 3.1 以降
  • Rust ツールチェーン(gem インストール時に rb_sys 経由で自動的に呼び出されます)
  • コンパイル済みネイティブ拡張以外のランタイム依存関係なし

クイックスタート

1. インデックスを作成する

require "laurus"

# インメモリインデックス(一時的、プロトタイピングに最適)
index = Laurus::Index.new

# ファイルベースインデックス(永続的)
schema = Laurus::Schema.new
schema.add_text_field("title")
schema.add_text_field("body")
index = Laurus::Index.new(path: "./myindex", schema: schema)

2. ドキュメントをインデックスする

index.put_document("doc1", {
  "title" => "Rust 入門",
  "body" => "Rust は安全性とパフォーマンスに重点を置いたシステムプログラミング言語です。",
})
index.put_document("doc2", {
  "title" => "Ruby Web 開発",
  "body" => "Ruby は Web アプリケーションと高速プロトタイピングに広く使われています。",
})
index.commit

3. Lexical 検索

# DSL 文字列
results = index.search("title:rust", limit: 5)

# クエリオブジェクト
results = index.search(Laurus::TermQuery.new("body", "ruby"), limit: 5)

# 結果を表示
results.each do |r|
  puts "[#{r.id}] score=#{format('%.4f', r.score)}  #{r.document['title']}"
end

4. Vector 検索

Vector 検索にはベクトルフィールドを含むスキーマと事前計算済みエンベディングが必要です。

require "laurus"

schema = Laurus::Schema.new
schema.add_text_field("title")
schema.add_hnsw_field("embedding", 4)

index = Laurus::Index.new(schema: schema)
index.put_document("doc1", { "title" => "Rust", "embedding" => [0.1, 0.2, 0.3, 0.4] })
index.put_document("doc2", { "title" => "Ruby", "embedding" => [0.9, 0.8, 0.7, 0.6] })
index.commit

query_vec = [0.1, 0.2, 0.3, 0.4]
results = index.search(Laurus::VectorQuery.new("embedding", query_vec), limit: 3)

5. ハイブリッド検索

request = Laurus::SearchRequest.new(
  lexical_query: Laurus::TermQuery.new("title", "rust"),
  vector_query: Laurus::VectorQuery.new("embedding", query_vec),
  fusion: Laurus::RRF.new(k: 60.0),
  limit: 5,
)
results = index.search(request)

6. 更新と削除

# 更新: put_document は同じ ID の全バージョンを置換する
index.put_document("doc1", { "title" => "更新されたタイトル", "body" => "新しいコンテンツ。" })
index.commit

# 既存バージョンを削除せずに新しいバージョンを追記(RAG チャンキングパターン)
index.add_document("doc1", { "title" => "チャンク 2", "body" => "追加のチャンク。" })
index.commit

# 全バージョンを取得
docs = index.get_documents("doc1")

# 削除
index.delete_documents("doc1")
index.commit

7. スキーマ管理

schema = Laurus::Schema.new
schema.add_text_field("title")
schema.add_text_field("body")
schema.add_integer_field("year")
schema.add_float_field("score")
schema.add_boolean_field("published")
schema.add_bytes_field("thumbnail")
schema.add_geo_field("location")
schema.add_datetime_field("created_at")
schema.add_hnsw_field("embedding", 384)
schema.add_flat_field("small_vec", 64)
schema.add_ivf_field("ivf_vec", 128, n_clusters: 100)

8. インデックス統計

stats = index.stats
puts stats["document_count"]
puts stats["vector_fields"]

API リファレンス

Index

Laurus 検索エンジンをラップするメインクラスです。

Laurus::Index.new(path: nil, schema: nil)

コンストラクタ

パラメータデフォルト説明
path:String | nilnil永続ストレージのディレクトリパス。nil の場合はインメモリインデックスを作成します。
schema:Schema | nilnilスキーマ定義。省略時は空のスキーマが使用されます。

メソッド

メソッド説明
put_document(id, doc)ドキュメントをアップサート(upsert)します。同じ ID の既存バージョンをすべて置換します。
add_document(id, doc)既存バージョンを削除せずにドキュメントチャンクを追記します。
get_documents(id) -> Array<Hash>指定 ID の全保存バージョンを返します。
delete_documents(id)指定 ID の全バージョンを削除します。
commitバッファリングされた書き込みをフラッシュし、すべての保留中の変更を検索可能にします。
search(query, limit: 10, offset: 0) -> Array<SearchResult>検索クエリを実行します。
search_batch(queries, limit: 10, offset: 0) -> Array<Array<SearchResult>>独立した複数の検索を 1 回の呼び出しで実行します。各クエリは内部の tokio ランタイム上で並列に dispatch されます。results[i]queries[i] に対応し、入力が空の配列の場合は [] を返します。
stats -> Hashインデックス統計("document_count""vector_fields")を返します。

search の query 引数

query パラメータは以下のいずれかを受け付けます:

  • DSL 文字列(例: "title:hello""content:\"memory safety\"")
  • Lexical クエリオブジェクトTermQueryPhraseQueryBooleanQuery など)
  • Vector クエリオブジェクトVectorQueryVectorTextQuery
  • SearchRequest(完全な制御が必要な場合)

search_batchqueries 配列の各要素も同じ種類の値を受け付けます。DSL 文字列・クエリオブジェクト・SearchRequest を 1 つのバッチ内で混在させることもできます。


Schema

Index のフィールドとインデックスタイプを定義します。

Laurus::Schema.new

フィールドメソッド

メソッド説明
add_text_field(name, stored: true, indexed: true, term_vectors: false, analyzer: nil)全文フィールド(転置インデックス、BM25)。analyzer: にはパラメータ不要の組込名("standard" / "english" / "keyword" / "simple" / "noop"、または add_analyzer で登録したカスタム名)を指定します。Lindera 辞書パスが必要な Japanese プリセットは、lindera tokenizer を含むカスタム analyzer として登録し、名前で参照してください。
add_integer_field(name, stored: true, indexed: true, multi_valued: false)64 ビット整数フィールド。multi_valued: true で整数配列を受け付け(範囲クエリは “any match”)。
add_float_field(name, stored: true, indexed: true, multi_valued: false)64 ビット浮動小数点フィールド。multi_valued: true で浮動小数点配列を受け付け(範囲クエリは “any match”)。
add_boolean_field(name, stored: true, indexed: true)ブールフィールド。
add_bytes_field(name, stored: true)生バイトフィールド。
add_geo_field(name, stored: true, indexed: true)地理座標フィールド(緯度/経度)。
add_geo3d_field(name, stored: true, indexed: true)3D ECEF カルテシアン座標フィールド(x, y, z はメートル)。詳細は Geo3d の概念
add_datetime_field(name, stored: true, indexed: true)UTC 日時フィールド。
add_hnsw_field(name, dimension, distance: "cosine", m: 16, ef_construction: 200, quantizer: nil, subvector_count: nil, rerank_storage: nil, embedder: nil)HNSW 近似最近傍ベクトルフィールド。
add_flat_field(name, dimension, distance: "cosine", embedder: nil)Flat(総当たり)ベクトルフィールド。
add_ivf_field(name, dimension, distance: "cosine", n_clusters: 100, n_probe: 1, embedder: nil)IVF 近似最近傍ベクトルフィールド。

ベクトル量子化とリランクストレージ(HNSW フィールド):

  • quantizer"scalar_8bit"(デフォルト、4 倍圧縮)または高圧縮率の "product_quantization"。Product quantization では subvector_countdimension を割り切れる値)が必須です。
  • rerank_storage"f32" を指定すると完全精度の *.hnsw.f32 サイドカーを書き出し、厳密な Stage-2 リランクを有効化します。省略すると int8 のみのセグメントを維持します。

その他のメソッド

メソッド説明
add_embedder(name, config)名前付きエンベダー定義を登録します。config"type" キーを持つ Hash です(下記参照)。
set_default_fields(fields)クエリでフィールドが指定されていない場合に使用するデフォルトフィールドを設定します。fields は文字列の配列です。
set_dynamic_field_policy(policy)未宣言フィールドの扱いを設定します。policy"strict" / "dynamic"(デフォルト)/ "ignore"。詳細は下記を参照。
dynamic_field_policy -> String現在のポリシーを小文字の文字列で返します。
field_names -> Array<String>このスキーマに定義されたフィールド名のリストを返します。

Dynamic field policy(動的フィールドポリシー)

ドキュメントに含まれるがスキーマに宣言されていないフィールドの扱いを制御します:

  • "strict" — ドキュメントを拒否
  • "dynamic"(デフォルト)— 各未宣言フィールドの型を推論してスキーマに追加。警告: integer フィールドに入ってきた float 値は静かに切り捨てられます(3.143)。厳密さが必要なら "strict" を使用してください
  • "ignore" — 未宣言フィールドを静かに破棄

詳細な挙動マトリクスは スキーマとフィールド を参照してください。

エンベダータイプ

"type"必須キーFeature Flag
"precomputed"(常に利用可能)
"candle_bert""model"embeddings-candle
"candle_clip""model"embeddings-multimodal
"openai""model"embeddings-openai

距離メトリクス

説明
"cosine"コサイン類似度(デフォルト)
"euclidean"ユークリッド距離
"dot_product"内積
"manhattan"マンハッタン距離
"angular"角度距離

クエリクラス

TermQuery

Laurus::TermQuery.new(field, term)

指定フィールドに完全一致する語句を含むドキュメントを検索します。

PhraseQuery

Laurus::PhraseQuery.new(field, terms)

指定した語句が順序どおりに含まれるドキュメントを検索します。terms は文字列の配列です。

FuzzyQuery

Laurus::FuzzyQuery.new(field, term, max_edits: 2)

編集距離が max_edits 以内の近似一致を検索します。

WildcardQuery

Laurus::WildcardQuery.new(field, pattern)

ワイルドカードパターン検索。* は任意の文字列、? は任意の1文字に一致します。

NumericRangeQuery

Laurus::NumericRangeQuery.new(field, min: nil, max: nil)

[min, max] の範囲内の数値を検索します。開いた境界には nil を指定します。型(整数または浮動小数点)は min/max の Ruby 型から推論されます。

GeoDistanceQuery

Laurus::GeoDistanceQuery.within_radius(field, lat, lon, distance_m)

地理的距離検索(半径指定)。指定した地点から distance_m メートル以内の (lat, lon) 座標を持つドキュメントを返します。

GeoBoundingBoxQuery

Laurus::GeoBoundingBoxQuery.within_bounding_box(
  field, min_lat, min_lon, max_lat, max_lon,
)

地理的範囲(バウンディングボックス)検索。軸並行 [min_lat, max_lat] × [min_lon, max_lon] 内の (lat, lon) 座標を持つドキュメントを返します。

Geo3dDistanceQuery

Laurus::Geo3dDistanceQuery.within_sphere(field, x, y, z, distance_m)

3D ECEF 座標フィールドへの球距離検索。中心 (x, y, z) から distance_m メートル以内 の座標を持つドキュメントを返します。ECEF の理論については Geo3d の概念 を参照。

Geo3dBoundingBoxQuery

Laurus::Geo3dBoundingBoxQuery.within_box(
  field,
  min_x, min_y, min_z,
  max_x, max_y, max_z,
)

軸並行 3D 範囲(AABB)検索。

Geo3dNearestQuery

Laurus::Geo3dNearestQuery.k_nearest(
  field, x, y, z, k,
  initial_radius_m: nil,
  max_radius_m: nil,
)

3D ECEF 座標フィールドへの k 最近傍検索。initial_radius_m: / max_radius_m: キーワード引数(オプション)で反復拡張サーチの探索コーンを調整できます。

BooleanQuery

bq = Laurus::BooleanQuery.new
bq.must(query)
bq.should(query)
bq.must_not(query)

複合ブールクエリ。must 節はすべて一致する必要があり、must_not 節は一致してはなりません。should 節はスコアリングに寄与し、must 節が無い場合は少なくとも1つが一致する必要があります。

SpanQuery

# 単一語句
Laurus::SpanQuery.term(field, term)

# Near: slop 位置以内の語句
Laurus::SpanQuery.near(field, terms, slop: 0, ordered: true)

# ネストされた SpanQuery 句を使った Near
Laurus::SpanQuery.near_spans(field, clauses, slop: 0, ordered: true)

# Containing: big スパンが little スパンを含む
Laurus::SpanQuery.containing(field, big, little)

# Within: 最大距離での include スパンと exclude スパン
Laurus::SpanQuery.within(field, include_span, exclude_span, distance)

位置・近接スパンクエリ。near は語句文字列の配列を受け取り、near_spans はネスト式のために SpanQuery オブジェクトの配列を受け取ります。

VectorQuery

Laurus::VectorQuery.new(field, vector)

事前計算済みエンベディングベクトルを使った近似最近傍検索を行います。vector は Float の配列です。

VectorTextQuery

Laurus::VectorTextQuery.new(field, text)

クエリ時に text をエンベディングに変換してベクトル検索を行います。インデックスにエンベダーの設定が必要です。


SearchRequest

高度な制御が必要な場合の完全なリクエストクラスです。

Laurus::SearchRequest.new(
  query: nil,
  lexical_query: nil,
  vector_query: nil,
  filter_query: nil,
  fusion: nil,
  limit: 10,
  offset: 0,
)
パラメータ説明
query:DSL 文字列または単一クエリオブジェクト。lexical_query: / vector_query: と排他的。
lexical_query:明示的なハイブリッド検索の Lexical コンポーネント。
vector_query:明示的なハイブリッド検索の Vector コンポーネント。
filter_query:スコアリング後に適用する Lexical フィルター。
fusion:フュージョンアルゴリズム(RRF または WeightedSum)。両コンポーネント指定時のデフォルトは RRF(k: 60)
limit:最大結果件数(デフォルト 10)。
offset:ページネーションオフセット(デフォルト 0)。

SearchResult

Index#search が返すクラスです。

result.id        # => String   -- 外部ドキュメント識別子
result.score     # => Float    -- 関連性スコア
result.document  # => Hash|nil -- 取得されたフィールド値。削除済みの場合は nil

フュージョンアルゴリズム

RRF

Laurus::RRF.new(k: 60.0)

逆順位フュージョン(Reciprocal Rank Fusion)。Lexical と Vector の結果リストを順位位置によってマージします。k は平滑化定数で、値が大きいほど上位ランクの影響が小さくなります。

WeightedSum

Laurus::WeightedSum.new(lexical_weight: 0.5, vector_weight: 0.5)

両スコアリストをそれぞれ正規化した後、lexical_weight * lexical_score + vector_weight * vector_score として結合します。


テキスト解析

SynonymDictionary

dict = Laurus::SynonymDictionary.new
dict.add_synonym_group(["fast", "quick", "rapid"])

同義語グループの辞書です。グループ内のすべての語句は互いの同義語として扱われます。

WhitespaceTokenizer

tokenizer = Laurus::WhitespaceTokenizer.new
tokens = tokenizer.tokenize("hello world")

空白で分割してテキストをトークン化し、Token オブジェクトの配列を返します。

SynonymGraphFilter

filter = Laurus::SynonymGraphFilter.new(dictionary, keep_original: true, boost: 1.0)
expanded = filter.apply(tokens)

SynonymDictionary の同義語でトークンを展開するトークンフィルターです。

Token

token.text                # => String  -- トークンテキスト
token.position            # => Integer -- トークンストリーム内の位置
token.start_offset        # => Integer -- 元テキスト内の文字開始オフセット
token.end_offset          # => Integer -- 元テキスト内の文字終了オフセット
token.boost               # => Float   -- スコアブースト係数(1.0 = 調整なし)
token.stopped             # => Boolean -- ストップフィルターによって除去されたかどうか
token.position_increment  # => Integer -- 前のトークンの位置との差分
token.position_length     # => Integer -- このトークンがカバーする位置数

フィールド値の型マッピング

Ruby の値は自動的に Laurus の DataValue 型に変換されます:

Ruby 型Laurus 型備考
nilNull
true / falseBool
IntegerInt64
FloatFloat64
StringText
Array(数値)Vector要素は f32 に変換
Hash"lat", "lon"Geo2 つの Float
Hash"x", "y", "z"GeoEcef3 つの Float 値(メートル単位、3D ECEF 直交座標)
Time / Stringiso8601 に応答)DateTimeiso8601 経由で変換

開発環境のセットアップ

このページでは laurus-ruby バインディングのローカル開発環境の構築、ビルド、テストスイートの実行方法について説明します。

前提条件

  • Rust 1.85 以降(Cargo 含む)
  • Ruby 3.1 以降(Bundler 含む)
  • リポジトリがローカルにクローンされていること
git clone https://github.com/mosuka/laurus.git
cd laurus

ビルド

開発ビルド

Rust ネイティブ拡張をデバッグモードでコンパイルします。Rust ソースを変更した場合は再実行してください。

cd laurus-ruby
bundle install
bundle exec rake compile

リリースビルド

gem build laurus.gemspec

ビルドの確認

ruby -e "
require 'laurus'
index = Laurus::Index.new
puts index.stats
"
# {"document_count"=>0, "vector_fields"=>{}}

テスト

テストは Minitest を使用しており、test/ ディレクトリにあります。

# 全テスト実行
bundle exec rake test

特定のテストファイルを実行する場合:

bundle exec ruby -Ilib -Itest test/test_index.rb

Lint とフォーマット

# Rust lint(Clippy)
cargo clippy -p laurus-ruby -- -D warnings

# Rust フォーマットチェック
cargo fmt -p laurus-ruby --check

# フォーマット適用
cargo fmt -p laurus-ruby

クリーンアップ

# ビルド成果物を削除
bundle exec rake clean

# インストールされた gem を削除
rm -rf vendor/bundle

Makefile リファレンス

ターゲット説明
make build-laurus-rubyBundler install + bundle exec rake compile(リリース gem)
make test-laurus-rubyRust 単体テスト + Ruby minitest
make lint-laurus-rubyClippy(-D warnings
make format-laurus-rubycargo fmt -p laurus-ruby

プロジェクト構成

laurus-ruby/
├── Cargo.toml          # Rust クレートマニフェスト
├── laurus.gemspec      # Gem 仕様
├── Gemfile             # Bundler 依存関係ファイル
├── Rakefile            # Rake タスク(compile、test、clean)
├── lib/
│   └── laurus.rb       # Ruby エントリポイント(ネイティブ拡張をロード)
├── ext/
│   └── laurus_ruby/    # ネイティブ拡張ビルド設定
│       └── extconf.rb  # rb_sys 拡張設定
├── src/                # Rust ソース(Magnus バインディング)
│   ├── lib.rs          # モジュール登録
│   ├── index.rs        # Index クラス
│   ├── schema.rs       # Schema クラス
│   ├── query.rs        # クエリクラス
│   ├── search.rs       # SearchRequest / SearchResult / Fusion
│   ├── analysis.rs     # Tokenizer / Filter / Token
│   ├── convert.rs      # Ruby ↔ DataValue 変換
│   └── errors.rs       # エラーマッピング
├── test/               # Minitest テスト
│   ├── test_helper.rb
│   └── test_index.rb
└── examples/           # 実行可能な Ruby サンプル

PHP バインディング概要

laurus PHP エクステンションは Laurus 検索エンジンの PHP バインディングです。ext-php-rs を使ってネイティブ Rust 拡張としてビルドされており、PHP プログラムからネイティブに近いパフォーマンスで Laurus の Lexical 検索、Vector 検索、ハイブリッド検索機能を利用できます。

機能

  • Lexical 検索 – BM25 スコアリングを備えた転置インデックスによる全文検索
  • Vector 検索 – Flat、HNSW、IVF インデックスを使用した近似最近傍(ANN)検索
  • ハイブリッド検索 – フュージョンアルゴリズム(RRF、WeightedSum)で Lexical と Vector の結果を統合
  • 豊富なクエリ DSL – Term、Phrase、Fuzzy、Wildcard、NumericRange、Geo、Boolean、Span クエリ
  • テキスト解析 – トークナイザー、フィルター、ステマー、同義語展開
  • 柔軟なストレージ – インメモリ(一時的)またはファイルベース(永続的)インデックス
  • PHP らしい APILaurus\ 名前空間の直感的な PHP クラス

アーキテクチャ

graph LR
    subgraph "laurus-php (extension)"
        PhpIndex["Index\n(PHP クラス)"]
        PhpQuery["クエリクラス"]
        PhpSearch["SearchRequest\n/ SearchResult"]
    end

    PHP["PHP アプリケーション"] -->|"メソッド呼び出し"| PhpIndex
    PHP -->|"クエリオブジェクト"| PhpQuery
    PhpIndex -->|"ext-php-rs FFI"| Engine["laurus::Engine\n(Rust)"]
    PhpQuery -->|"ext-php-rs FFI"| Engine
    Engine --> Storage["ストレージ\n(Memory / File)"]

PHP クラスは Rust エンジンの薄いラッパーです。 各呼び出しは ext-php-rs の FFI 境界を一度だけ越え、その後 Rust エンジンが操作をネイティブコードで実行します。

Rust エンジン内部は非同期 I/O を使用していますが、 PHP 側のメソッドはすべて同期関数として公開されています。 各メソッドは内部で tokio::Runtime::block_on() を呼び出し、 非同期 Rust を同期 PHP にブリッジしています。

クイックスタート

<?php

use Laurus\Index;

// インメモリインデックスを作成
$index = new Index();

// ドキュメントをインデックス
$index->putDocument("doc1", ["title" => "Introduction to Rust", "body" => "Systems programming language."]);
$index->putDocument("doc2", ["title" => "PHP for Web Development", "body" => "Web applications with PHP."]);
$index->commit();

// 検索
$results = $index->search("title:rust", 5);
foreach ($results as $r) {
    printf("[%s] score=%.4f  %s\n", $r->getId(), $r->getScore(), $r->getDocument()["title"]);
}

セクション

インストール

laurus-php は Rust で書かれた PHP 拡張です。PECL では配布されておらず、Composer はインストール経路として利用しません — リポジトリの composer.json は開発時のテスト依存(PHPUnit)のみを宣言しています。Cargo で共有ライブラリをソースからビルドし、PHP の拡張ディレクトリに配置して php.ini で有効化してください。

ソースからビルド

ソースからビルドするには Rust ツールチェーン(1.85 以降)と PHP 8.1 以降(開発ヘッダー付き)が必要です。

# リポジトリをクローン
git clone https://github.com/mosuka/laurus.git
cd laurus/laurus-php

# ネイティブ拡張をビルド
cargo build --release

# 共有ライブラリを PHP エクステンションディレクトリにコピー
# (正確なパスは OS と PHP バージョンによって異なります)
cp ../target/release/liblaurus_php.so $(php -r "echo ini_get('extension_dir');")

次に php.ini にエクステンションを追加します:

extension=laurus_php.so

または、コマンドラインでエクステンションをロードすることもできます:

php -d extension=liblaurus_php.so your_script.php

動作確認

<?php

use Laurus\Index;

$index = new Index();
echo $index;  // Index()

動作要件

  • PHP 8.1 以降(開発ヘッダー付き: php-dev / php-devel
  • Rust ツールチェーン 1.85 以降(Cargo 含む)
  • コンパイル済みネイティブ拡張以外のランタイム依存関係なし

クイックスタート

1. インデックスを作成する

<?php

use Laurus\Index;
use Laurus\Schema;

// インメモリインデックス(一時的、プロトタイピングに最適)
$index = new Index();

// ファイルベースインデックス(永続的)
$schema = new Schema();
$schema->addTextField("title");
$schema->addTextField("body");
$index = new Index("./myindex", $schema);

2. ドキュメントをインデックスする

$index->putDocument("doc1", [
    "title" => "Introduction to Rust",
    "body" => "Rust is a systems programming language focused on safety and performance.",
]);
$index->putDocument("doc2", [
    "title" => "PHP for Web Development",
    "body" => "PHP is widely used for web applications and rapid prototyping.",
]);
$index->commit();

3. Lexical 検索

// DSL 文字列
$results = $index->search("title:rust", 5);

// クエリオブジェクト
$results = $index->search(new \Laurus\TermQuery("body", "php"), 5);

// 結果を表示
foreach ($results as $r) {
    printf("[%s] score=%.4f  %s\n", $r->getId(), $r->getScore(), $r->getDocument()["title"]);
}

4. Vector 検索

Vector 検索にはベクトルフィールドを含むスキーマと事前計算済みエンベディングが必要です。

<?php

use Laurus\Index;
use Laurus\Schema;
use Laurus\VectorQuery;

$schema = new Schema();
$schema->addTextField("title");
$schema->addHnswField("embedding", 4);

$index = new Index(null, $schema);
$index->putDocument("doc1", ["title" => "Rust", "embedding" => [0.1, 0.2, 0.3, 0.4]]);
$index->putDocument("doc2", ["title" => "PHP", "embedding" => [0.9, 0.8, 0.7, 0.6]]);
$index->commit();

$queryVec = [0.1, 0.2, 0.3, 0.4];
$results = $index->search(new VectorQuery("embedding", $queryVec), 3);

5. ハイブリッド検索

use Laurus\SearchRequest;
use Laurus\TermQuery;
use Laurus\VectorQuery;
use Laurus\RRF;

$request = new SearchRequest(
    query: null,
    lexicalQuery: new TermQuery("title", "rust"),
    vectorQuery: new VectorQuery("embedding", $queryVec),
    filterQuery: null,
    fusion: new RRF(60.0),
    limit: 5,
);
$results = $index->search($request);

6. 更新と削除

// 更新: putDocument は同じ ID の全バージョンを置換する
$index->putDocument("doc1", ["title" => "Updated Title", "body" => "New content."]);
$index->commit();

// 既存バージョンを削除せずに新しいバージョンを追記(RAG チャンキングパターン)
$index->addDocument("doc1", ["title" => "Chunk 2", "body" => "Additional chunk."]);
$index->commit();

// 全バージョンを取得
$docs = $index->getDocuments("doc1");

// 削除
$index->deleteDocuments("doc1");
$index->commit();

7. スキーマ管理

$schema = new \Laurus\Schema();
$schema->addTextField("title");
$schema->addTextField("body");
$schema->addIntegerField("year");
$schema->addFloatField("score");
$schema->addBooleanField("published");
$schema->addBytesField("thumbnail");
$schema->addGeoField("location");
$schema->addDatetimeField("created_at");
$schema->addHnswField("embedding", 384);
$schema->addFlatField("small_vec", 64);
$schema->addIvfField("ivf_vec", 128, "cosine", 100, 1);

8. インデックス統計

$stats = $index->stats();
echo $stats["documentCount"];
echo $stats["vectorFields"];

API リファレンス

Index

Laurus 検索エンジンをラップするメインクラスです。

new \Laurus\Index(?string $path = null, ?Schema $schema = null)

コンストラクタ

パラメータデフォルト説明
$pathstring|nullnull永続ストレージのディレクトリパス。null の場合はインメモリインデックスを作成します。
$schemaSchema|nullnullスキーマ定義。省略時は空のスキーマが使用されます。

メソッド

メソッド説明
putDocument(string $id, array $doc): voidドキュメントをアップサート(upsert)します。同じ ID の既存バージョンをすべて置換します。
addDocument(string $id, array $doc): void既存バージョンを削除せずにドキュメントチャンクを追記します。
getDocuments(string $id): array指定 ID の全保存バージョンを返します。
deleteDocuments(string $id): void指定 ID の全バージョンを削除します。
commit(): voidバッファリングされた書き込みをフラッシュし、すべての保留中の変更を検索可能にします。
search(mixed $query, int $limit = 10, int $offset = 0): array検索クエリを実行します。SearchResult の配列を返します。
searchBatch(array $queries, int $limit = 10, int $offset = 0): array独立した複数の検索を 1 回の呼び出しで実行します。各クエリは内部の tokio ランタイム上で並列に dispatch されます。results[i]queries[i] に対応し、SearchResult の配列の配列を返します。入力が空の配列の場合は [] を返します。
stats(): arrayインデックス統計("documentCount""vectorFields")を返します。

search の query 引数

$query パラメータは以下のいずれかを受け付けます:

  • DSL 文字列(例: "title:hello""embedding:\"memory safety\"")
  • Lexical クエリオブジェクトTermQueryPhraseQueryBooleanQuery など)
  • Vector クエリオブジェクトVectorQueryVectorTextQuery
  • SearchRequest(完全な制御が必要な場合)

searchBatch$queries 配列の各要素も同じ種類の値を受け付けます。DSL 文字列・クエリオブジェクト・SearchRequest を 1 つのバッチ内で混在させることもできます。


Schema

Index のフィールドとインデックスタイプを定義します。

new \Laurus\Schema()

フィールドメソッド

メソッド説明
addTextField(string $name, bool $stored = true, bool $indexed = true, bool $termVectors = false, ?string $analyzer = null): void全文フィールド(転置インデックス、BM25)。$analyzer にはパラメータ不要の組込名("standard" / "english" / "keyword" / "simple" / "noop"、または addAnalyzer で登録したカスタム名)を指定します。Lindera 辞書パスが必要な Japanese プリセットは、lindera tokenizer を含むカスタム analyzer として登録し、名前で参照してください。
addIntegerField(string $name, bool $stored = true, bool $indexed = true, bool $multiValued = false): void64 ビット整数フィールド。$multiValued = true で整数配列を受け付け(範囲クエリは “any match”)。
addFloatField(string $name, bool $stored = true, bool $indexed = true, bool $multiValued = false): void64 ビット浮動小数点フィールド。$multiValued = true で浮動小数点配列を受け付け(範囲クエリは “any match”)。
addBooleanField(string $name, bool $stored = true, bool $indexed = true): voidブールフィールド。
addBytesField(string $name, bool $stored = true): void生バイトフィールド。
addGeoField(string $name, bool $stored = true, bool $indexed = true): void地理座標フィールド(緯度/経度)。
addGeo3dField(string $name, bool $stored = true, bool $indexed = true): void3D ECEF カルテシアン座標フィールド(x, y, z はメートル)。詳細は Geo3d の概念
addDatetimeField(string $name, bool $stored = true, bool $indexed = true): voidUTC 日時フィールド。
addHnswField(string $name, int $dimension, ?string $distance = "cosine", int $m = 16, int $efConstruction = 200, ?string $embedder = null, ?string $quantizer = null, ?int $subvectorCount = null, ?string $rerankStorage = null): voidHNSW 近似最近傍ベクトルフィールド。
addFlatField(string $name, int $dimension, ?string $distance = "cosine", ?string $embedder = null): voidFlat(総当たり)ベクトルフィールド。
addIvfField(string $name, int $dimension, ?string $distance = "cosine", int $nClusters = 100, int $nProbe = 1, ?string $embedder = null): voidIVF 近似最近傍ベクトルフィールド。

ベクトル量子化とリランクストレージ(HNSW フィールド):

  • quantizer"scalar_8bit"(デフォルト、4 倍圧縮)または高圧縮率の "product_quantization"。Product quantization では subvectorCountdimension を割り切れる値)が必須です。
  • rerankStorage"f32" を指定すると完全精度の *.hnsw.f32 サイドカーを書き出し、厳密な Stage-2 リランクを有効化します。省略すると int8 のみのセグメントを維持します。

その他のメソッド

メソッド説明
addEmbedder(string $name, array $config): void名前付きエンベダー定義を登録します。$config"type" キーを持つ連想配列です(下記参照)。
setDefaultFields(array $fieldNames): voidクエリでフィールドが指定されていない場合に使用するデフォルトフィールドを設定します。$fieldNames は文字列の配列です。
setDynamicFieldPolicy(string $policy): void未宣言フィールドの扱いを設定します。$policy"strict" / "dynamic"(デフォルト)/ "ignore"。詳細は下記を参照。
dynamicFieldPolicy(): string現在のポリシーを小文字の文字列で返します。
fieldNames(): arrayこのスキーマに定義されたフィールド名のリストを返します。

Dynamic field policy(動的フィールドポリシー)

ドキュメントに含まれるがスキーマに宣言されていないフィールドの扱いを制御します:

  • "strict" — ドキュメントを拒否
  • "dynamic"(デフォルト)— 各未宣言フィールドの型を推論してスキーマに追加。警告: integer フィールドに入ってきた float 値は静かに切り捨てられます(3.143)。厳密さが必要なら "strict" を使用してください
  • "ignore" — 未宣言フィールドを静かに破棄

詳細な挙動マトリクスは スキーマとフィールド を参照してください。

エンベダータイプ

"type"必須キーFeature Flag
"precomputed"(常に利用可能)
"candle_bert""model"embeddings-candle
"candle_clip""model"embeddings-multimodal
"openai""model"embeddings-openai

距離メトリクス

説明
"cosine"コサイン類似度(デフォルト)
"euclidean"ユークリッド距離
"dot_product"内積
"manhattan"マンハッタン距離
"angular"角度距離

クエリクラス

TermQuery

new \Laurus\TermQuery(string $field, string $term)

指定フィールドに完全一致する語句を含むドキュメントを検索します。

PhraseQuery

new \Laurus\PhraseQuery(string $field, array $terms)

指定した語句が順序どおりに含まれるドキュメントを検索します。$terms は文字列の配列です。

FuzzyQuery

new \Laurus\FuzzyQuery(string $field, string $term, int $maxEdits = 2)

編集距離が $maxEdits 以内の近似一致を検索します。

WildcardQuery

new \Laurus\WildcardQuery(string $field, string $pattern)

ワイルドカードパターン検索。* は任意の文字列、? は任意の1文字に一致します。

NumericRangeQuery

new \Laurus\NumericRangeQuery(string $field, mixed $min, mixed $max, ?string $numericType = "integer")

[$min, $max] の範囲内の数値を検索します。開いた境界には null を指定します。$numericType には "integer" または "float" を設定します。

GeoDistanceQuery

\Laurus\GeoDistanceQuery::withinRadius(
    string $field, float $lat, float $lon, float $distanceM,
): GeoDistanceQuery

地理的距離検索(半径指定)。指定した地点から $distanceM メートル以内の (lat, lon) 座標を持つドキュメントを返します。

GeoBoundingBoxQuery

\Laurus\GeoBoundingBoxQuery::withinBoundingBox(
    string $field,
    float $minLat, float $minLon,
    float $maxLat, float $maxLon,
): GeoBoundingBoxQuery

地理的範囲(バウンディングボックス)検索。軸並行 [$minLat, $maxLat] × [$minLon, $maxLon] 内の (lat, lon) 座標を持つドキュメントを返します。

Geo3dDistanceQuery

\Laurus\Geo3dDistanceQuery::withinSphere(
    string $field,
    float $x, float $y, float $z,
    float $distanceM,
): Geo3dDistanceQuery

3D ECEF 座標フィールドへの球距離検索。中心 (x, y, z) から $distanceM メートル以内 の座標を持つドキュメントを返します。ECEF の理論については Geo3d の概念 を参照。

Geo3dBoundingBoxQuery

\Laurus\Geo3dBoundingBoxQuery::withinBox(
    string $field,
    float $minX, float $minY, float $minZ,
    float $maxX, float $maxY, float $maxZ,
): Geo3dBoundingBoxQuery

軸並行 3D 範囲(AABB)検索。

Geo3dNearestQuery

\Laurus\Geo3dNearestQuery::kNearest(
    string $field,
    float $x, float $y, float $z,
    int $k,
    ?float $initialRadiusM = null,
    ?float $maxRadiusM = null,
): Geo3dNearestQuery

3D ECEF 座標フィールドへの k 最近傍検索。$initialRadiusM / $maxRadiusM (オプション)で反復拡張サーチの探索コーンを調整できます。

BooleanQuery

$bq = new \Laurus\BooleanQuery();
$bq->must($query);
$bq->should($query);
$bq->mustNot($query);

複合ブールクエリ。must 節はすべて一致する必要があり、mustNot 節は一致してはなりません。should 節はスコアリングに寄与し、must 節が無い場合は少なくとも1つが一致する必要があります。

SpanQuery

// 単一語句
\Laurus\SpanQuery::term(string $field, string $term): SpanQuery

// Near: slop 位置以内の語句
\Laurus\SpanQuery::near(string $field, array $terms, int $slop = 0, bool $ordered = true): SpanQuery

// NearSpans: slop 位置以内のネストされた SpanQuery 句
\Laurus\SpanQuery::nearSpans(string $field, array $clauses, int $slop = 0, bool $ordered = true): SpanQuery

// Containing: big スパンが little スパンを含む
\Laurus\SpanQuery::containing(string $field, SpanQuery $big, SpanQuery $little): SpanQuery

// Within: 最大距離での include スパンと exclude スパン
\Laurus\SpanQuery::within(string $field, SpanQuery $include, SpanQuery $exclude, int $distance): SpanQuery

位置・近接スパンクエリ。near は語句文字列の配列を受け取り、nearSpans は ネスト式のために SpanQuery オブジェクトの配列を受け取ります(各句のフィールド は外側の $field に再ルートされます)。

VectorQuery

new \Laurus\VectorQuery(string $field, array $vector)

事前計算済みエンベディングベクトルを使った近似最近傍検索を行います。$vector は Float の配列です。

VectorTextQuery

new \Laurus\VectorTextQuery(string $field, string $text)

クエリ時に $text をエンベディングに変換してベクトル検索を行います。インデックスにエンベダーの設定が必要です。


SearchRequest

高度な制御が必要な場合の完全なリクエストクラスです。

new \Laurus\SearchRequest(
    mixed $query = null,
    mixed $lexicalQuery = null,
    mixed $vectorQuery = null,
    mixed $filterQuery = null,
    mixed $fusion = null,
    int $limit = 10,
    int $offset = 0,
)
パラメータ説明
$queryDSL 文字列または単一クエリオブジェクト。$lexicalQuery / $vectorQuery と排他的。
$lexicalQuery明示的なハイブリッド検索の Lexical コンポーネント。
$vectorQuery明示的なハイブリッド検索の Vector コンポーネント。
$filterQueryスコアリング後に適用する Lexical フィルター。
$fusionフュージョンアルゴリズム(RRF または WeightedSum)。両コンポーネント指定時のデフォルトは RRF(k: 60)
$limit最大結果件数(デフォルト 10)。
$offsetページネーションオフセット(デフォルト 0)。

SearchResult

Index->search() が返すクラスです。

$result->getId()        // string   -- 外部ドキュメント識別子
$result->getScore()     // float    -- 関連性スコア
$result->getDocument()  // array|null -- 取得されたフィールド値。stored=false の場合は null

フュージョンアルゴリズム

RRF

new \Laurus\RRF(float $k = 60.0)

逆順位フュージョン(Reciprocal Rank Fusion)。Lexical と Vector の結果リストを順位位置によってマージします。$k は平滑化定数で、値が大きいほど上位ランクの影響が小さくなります。

WeightedSum

new \Laurus\WeightedSum(float $lexicalWeight = 0.5, float $vectorWeight = 0.5)

両スコアリストをそれぞれ正規化した後、$lexicalWeight * lexical_score + $vectorWeight * vector_score として結合します。


テキスト解析

SynonymDictionary

$dict = new \Laurus\SynonymDictionary();
$dict->addSynonymGroup(["fast", "quick", "rapid"]);

同義語グループの辞書です。グループ内のすべての語句は互いの同義語として扱われます。

WhitespaceTokenizer

$tokenizer = new \Laurus\WhitespaceTokenizer();
$tokens = $tokenizer->tokenize("hello world");

空白で分割してテキストをトークン化し、Token オブジェクトの配列を返します。

SynonymGraphFilter

new \Laurus\SynonymGraphFilter(SynonymDictionary $dictionary, bool $keepOriginal = true, float $boost = 1.0)
パラメータ説明
$dictionary同義語グループのソース。
$keepOriginaltrue(デフォルト)の場合は元のトークンも同義語と並べて保持します。
$boost挿入される同義語トークンに適用されるスコアブースト(デフォルト 1.0)。
$filter = new \Laurus\SynonymGraphFilter($dictionary, true, 1.0);
$expanded = $filter->apply($tokens);

SynonymDictionary の同義語でトークンを展開するトークンフィルターです。

Token

$token->getText()               // string  -- トークンテキスト
$token->getPosition()           // int     -- トークンストリーム内の位置
$token->getStartOffset()        // int     -- 元テキスト内の文字開始オフセット
$token->getEndOffset()          // int     -- 元テキスト内の文字終了オフセット
$token->getBoost()              // float   -- スコアブースト係数(1.0 = 調整なし)
$token->isStopped()             // bool    -- ストップフィルターによって除去されたかどうか
$token->getPositionIncrement()  // int     -- 前のトークンの位置との差分
$token->getPositionLength()     // int     -- このトークンがカバーする位置数

フィールド値の型マッピング

PHP の値は自動的に Laurus の DataValue 型に変換されます:

PHP 型Laurus 型備考
nullNull
true / falseBool
intInt64
floatFloat64
stringText
array(数値)Vector要素は f32 に変換
array"lat", "lon"Geo2 つの float
array"x", "y", "z"GeoEcef3 つの float 値(メートル単位、3D ECEF 直交座標)
string(ISO 8601)DateTimeISO 8601 形式からパース

開発環境のセットアップ

このページでは laurus-php バインディングのローカル開発環境の構築、ビルド、テストスイートの実行方法について説明します。

前提条件

  • Rust 1.85 以降(Cargo 含む)
  • PHP 8.1 以降(開発ヘッダー付き: php-dev / php-devel
  • Composer(依存関係管理用)
  • リポジトリがローカルにクローンされていること
git clone https://github.com/mosuka/laurus.git
cd laurus

ビルド

開発ビルド

Rust ネイティブ拡張をデバッグモードでコンパイルします。Rust ソースを変更した場合は再実行してください。

cd laurus-php
cargo build

ビルド成果物は ../target/debug/liblaurus_php.so に生成されます。

リリースビルド

cd laurus-php
cargo build --release

ビルド成果物は ../target/release/liblaurus_php.so に生成されます。

ビルドの確認

php -d extension=../target/release/liblaurus_php.so -r "
use Laurus\Index;
\$index = new Index();
print_r(\$index->stats());
"
# Array ( [documentCount] => 0 [vectorFields] => Array ( ) )

テスト

テストは PHPUnit を使用しており、tests/ ディレクトリにあります。 Composer は開発時の PHP 依存(PHPUnit)の取得のみに使用し、 ランタイム拡張本体は Cargo で直接ビルド・ロードします。

# テスト依存関係をインストール(PHPUnit のみ)
composer install

# 全テスト実行
php -d extension=../target/release/liblaurus_php.so vendor/bin/phpunit tests/

特定のテストファイルを実行する場合:

php -d extension=../target/release/liblaurus_php.so vendor/bin/phpunit tests/LaurusTest.php

Lint とフォーマット

# Rust lint(Clippy)
cargo clippy -p laurus-php -- -D warnings

# Rust フォーマットチェック
cargo fmt -p laurus-php --check

# フォーマット適用
cargo fmt -p laurus-php

クリーンアップ

# ビルド成果物を削除
cargo clean

# Composer 依存関係を削除
rm -rf vendor/

Workspace 統合と clang-sys パッチ

laurus-phpext-php-rs を使用しており、 ext-php-rs は ext-php-rs-clang-sysclang-sys のフォーク)に依存しています。 一方、laurus-rubymagnusrb-sysbindgenclang-sys(オリジナル)に依存しています。 両方のクレートが links = "clang" を宣言しており、Cargo は同一 workspace 内で同じ links 値を持つ パッケージを 2 つ許可しません。

laurus-phplaurus-ruby を workspace メンバーとして共存させるため、ルートの Cargo.tomlext-php-rs-clang-syslinks 宣言を除去したローカルコピーに patch しています:

# Cargo.toml(workspace ルート)
[patch.crates-io]
ext-php-rs-clang-sys = { path = "patches/ext-php-rs-clang-sys" }

パッチは patches/ext-php-rs-clang-sys/ にあります。上流クレートからの唯一の変更点は Cargo.tomllinks = "clang" の除去です。clang-sysext-php-rs-clang-sys は どちらも libclang をビルド時のみ使用し(bindgen によるヘッダー解析)、最終バイナリにはリンク されないため、この変更は安全です。

パッチが必要な条件

このパッチは laurus-phplaurus-ruby が同一の Cargo workspace のメンバーである場合にのみ 必要です。laurus-ruby を workspace から除外するか、laurus-php[workspace] exclude で 除外すれば、links = "clang" の競合は発生しないため、パッチとルート Cargo.toml[patch.crates-io] セクションを削除できます。

パッチの更新

ext-php-rs をアップグレードして新しいバージョンの ext-php-rs-clang-sys が 使われるようになった場合、パッチを更新してください:

# 1. laurus-php/Cargo.toml で ext-php-rs を更新した後:
cargo update -p ext-php-rs

# 2. 新しい ext-php-rs-clang-sys ソースをコピー
cp -r ~/.cargo/registry/src/index.crates.io-*/ext-php-rs-clang-sys-<NEW_VERSION>/* \
      patches/ext-php-rs-clang-sys/

# 3. links 宣言を除去
sed -i 's/^links = "clang"/# links = "clang"/' patches/ext-php-rs-clang-sys/Cargo.toml

# 4. ビルドを確認
cargo build -p laurus-php -p laurus-ruby

macOS リンカーフラグ (-undefined dynamic_lookup)

PHP 拡張は共有ライブラリ(.so / .dylib)であり、実行時に PHP インタプリタに ロードされます。PHP API シンボル(zend_*, php_* 等)は PHP バイナリ本体に 定義されており、拡張がリンクするライブラリには含まれません。Linux ではリンカーが 共有ライブラリ内の未定義シンボルをデフォルトで許容するため問題ありませんが、 macOS ではリンカーが未定義シンボルをエラーとして扱い、ビルドが失敗します:

ld: symbol(s) not found for architecture arm64

修正方法は -Wl,-undefined,dynamic_lookup をリンカーに渡すことです。これにより シンボル解決がロード時(PHP が拡張を dlopen する時点)まで延期されます。

このフラグは .cargo/config.toml には設定しません。設定すると workspace 内の 全クレートに適用され、PHP 以外のクレートでも未定義シンボルがエラーにならなくなる ためです。代わりに laurus-php のビルド時のみ適用します:

Makefile(ローカル開発):

build-laurus-php:
ifeq ($(shell uname -s),Darwin)
    RUSTFLAGS="-C link-args=-Wl,-undefined,dynamic_lookup" cargo build -p laurus-php --release
else
    cargo build -p laurus-php --release
endif

CI(GitHub Actions):

- name: Build PHP extension
  shell: bash
  run: |
    if [ "$RUNNER_OS" == "macOS" ]; then
      export RUSTFLAGS="-C link-args=-Wl,-undefined,dynamic_lookup"
    fi
    cargo build --release -p laurus-php

macOS でビルドする際は、cargo build -p laurus-php を直接実行するのではなく、 make build-laurus-php または make test-laurus-php を使用してください。

プロジェクト構成

laurus-php/
├── Cargo.toml          # Rust クレートマニフェスト
├── composer.json       # Composer パッケージ定義
├── composer.lock       # ロックされた依存関係バージョン
├── src/                # Rust ソース(ext-php-rs バインディング)
│   ├── lib.rs          # モジュール登録
│   ├── index.rs        # Index クラス
│   ├── schema.rs       # Schema クラス
│   ├── query.rs        # クエリクラス
│   ├── search.rs       # SearchRequest / SearchResult / Fusion
│   ├── analysis.rs     # Tokenizer / Filter / Token
│   ├── convert.rs      # PHP <-> DataValue 変換
│   └── errors.rs       # エラーマッピング
├── tests/              # PHPUnit テスト
│   └── LaurusTest.php
└── examples/           # 実行可能な PHP サンプル

ビルドとテスト

前提条件

  • Rust 1.85 以降(edition 2024)
  • Cargo(Rust に付属)
  • protobuf コンパイラprotoc)– laurus-server のビルドに必要

ビルド

# すべてのクレートをビルド
cargo build

# 特定の Feature を指定してビルド
cargo build --features embeddings-candle

# リリースモードでビルド
cargo build --release

テスト

# すべてのワークスペーステストを実行(デフォルト Feature)
cargo test

# 名前を指定して特定のテストを実行
cargo test <test_name>

# 特定のクレートのテストを実行
cargo test -p laurus
cargo test -p laurus-cli
cargo test -p laurus-server
cargo test -p laurus-mcp

言語バインディングのテスト

各言語バインディングは固有のツールチェーン(Python virtualenv、Node.js npm、Ruby Bundler、PHP Composer、wasm32-unknown-unknown ターゲット)を持ちます。 Makefile はこれらをラップし、各ターゲットがツールチェーンを準備したうえで テストを実行します。

make test-laurus-python   # cargo test -p laurus-python + Maturin 経由の pytest
make test-laurus-nodejs   # npm run build:debug + npm test
make test-laurus-wasm     # cargo build -p laurus-wasm --target wasm32-unknown-unknown
make test-laurus-ruby     # cargo test -p laurus-ruby + Ruby minitest
make test-laurus-php      # cargo build -p laurus-php --release + PHPUnit

laurus-phplaurus-ruby との links = "clang" 競合のため Cargo ワークスペースから 除外されており、上記の Makefile ターゲット経由でスタンドアロンクレートとしてビルド・テストします。 対応する format-laurus-* / lint-laurus-* / build-laurus-* のバリアントを含む全ターゲットは Makefile を参照してください。

Lint

# clippy を警告エラー扱いで実行
cargo clippy -- -D warnings

フォーマット

# フォーマットチェック
cargo fmt --check

# フォーマットを適用
cargo fmt

ドキュメント

API ドキュメント

# Rust API ドキュメントを生成して開く
cargo doc --no-deps --open

mdBook ドキュメント

# ドキュメントサイトをビルド
mdbook build docs

# ローカルプレビューサーバーを起動 (http://localhost:3000)
mdbook serve docs

# Markdown ファイルを Lint
markdownlint-cli2 "docs/src/**/*.md"

ベンチマーク

このガイドでは、laurus のベンチマークの実行方法、ベースライン(baseline)の保存と比較方法、プルリクエストでの結果報告方法について説明します。

ベンチマークスイートは laurus/benches/ 配下にあり、Criterion で構築されています。衛生ルール(決定的シード、ファイル冒頭ドキュメント、sanity assert、sample_size ポリシー)は laurus/benches/common.rs で一元管理されています。

スイート一覧

ファイルスコープ
bkd_bench.rsBKD ツリーの範囲検索(range search)、交差判定(intersect)、構築(1D / 2D / 3D、10k / 100k / 1M ポイント)
distance_bench.rsDistanceMetric::distance の cosine / Euclidean / Manhattan / dot product(現状は単一次元、次元スイープは #424 で対応予定)
lexical_search_bench.rsEngine::search 経由のエンドツーエンド lexical 検索(term / boolean / phrase / fuzzy / DSL)
search_perf.rsPosting iterator の skip_toBM25Scorer::score、SIMD バッチスコアリング、コンパクト posting 変換
spell_correction_bench.rsSpellingCorrector::correct、各イテレーションごとに fresh corrector を渡す cold-state 計測
synonym_bench.rsSynonymDictionary::get_synonyms のルックアップ(100 / 1k / 10k グループ)と構築コスト
text_analysis_bench.rsStandardAnalyzer::analyze のシングルドキュメントとバッチ(100 ドキュメント)解析
vector_search_bench.rsFlat / IVF / HNSW の構築と検索(1k / 5k ベクタ、dim 128、top-10)。加えてクラスタ選択パス用の大 K IVF ケース(512 / 2048 クラスタ、#668)

各ファイル冒頭の //! ドキュメントコメントにスコープ・シナリオ・フィルタ方法が書かれています。実行前に確認してください。

ベンチマークの実行

単一ベンチファイルを実行:

cargo bench -p laurus --bench distance_bench

criterion id でフィルタ(部分一致):

cargo bench -p laurus --bench distance_bench -- cosine
cargo bench -p laurus --bench vector_search_bench -- "HNSW Search/top10"

コンパイル確認のみ(CI やリファクタリング時に有用):

cargo bench -p laurus --bench distance_bench --no-run

ワークスペースの全ベンチを実行:

cargo bench -p laurus

ベースラインの保存と比較

Criterion は名前付きベースラインをサポートしており、フィーチャーブランチを main(または任意の参照状態)と比較できます。

現在の状態を main という名前のベースラインとして保存:

cargo bench -p laurus --bench distance_bench -- --save-baseline main

その後の実行結果をベースラインと比較:

cargo bench -p laurus --bench distance_bench -- --baseline main

出力には change: 行がベンチマーク単位で表示され、変化率と判定(No change in performance detectedPerformance has improvedPerformance has regressed)が示されます。Criterion はベースラインを target/criterion/<bench-id>/<baseline>/ 配下に保存します。

perf PR の推奨フロー:

  1. main(または変更前の状態)で — cargo bench --bench RELEVANT -- --save-baseline main
  2. ブランチで変更を実装
  3. ブランチで — cargo bench --bench RELEVANT -- --baseline main
  4. change: 行を PR 説明にコピーする

推奨環境

µs / ns 単位のマイクロベンチマークはシステムノイズに敏感です。意味のある数値を得るには:

  • CPU governor: performance に設定(Linux):

    sudo cpupower frequency-set -g performance
    
  • Turbo boost: 周波数スケーリングが結果を歪めないように無効化:

    echo 1 | sudo tee /sys/devices/system/cpu/intel_pstate/no_turbo   # Intel
    

    AMD システムや BIOS レベルの設定は異なるためベンダーのドキュメントを参照してください。

  • バックグラウンド負荷: ブラウザ・IDE・ビルドウォッチャー・Docker を停止する。CPU を共有するものは短時間ベンチを歪めます。

  • コア固定(任意): 利用可能なら固定コアにピン留め:

    taskset -c 2 cargo bench -p laurus --bench distance_bench
    

    あるいは同梱のラッパー scripts/bench-stable.sh を使えば、taskset による 単一コアへのピン留めと nice によるスケジューリング優先度引き上げを 1 行で実行できます:

    ./scripts/bench-stable.sh --bench distance_bench
    ./scripts/bench-stable.sh --bench distance_bench -- --baseline main
    

    ラッパーは Linux 限定です。CPU governor や turbo 状態には触りません(root が必要なため)。 さらに精度が必要なら別途設定してください。

  • 再実行: 2 回実行して比較する。チューニング済みマシンで ~5 % 以下の差はノイズ。共有ワークステーションでは ~5 % を超える差もノイズの可能性がある。1 回の実行結果を過大解釈しない。

環境を安定化できない場合は、不安定な数値を権威ある数値として提示せず、PR で明示的に断ること(例:「共有ノート PC で測定、~10 % のノイズを想定」)。

Make ターゲット

Makefile から共通エントリポイントを利用できます:

make bench             # cargo bench -p laurus
make bench-baseline    # cargo bench -p laurus -- --save-baseline main
make bench-compare     # cargo bench -p laurus -- --baseline main

単一ベンチを指定する場合は BENCH=name を渡します:

make bench BENCH=distance_bench
make bench-baseline BENCH=distance_bench
make bench-compare BENCH=distance_bench

PR 説明テンプレート

PR で計測可能なパフォーマンス変化を主張する場合、下記のようなテーブルを説明に貼り付けてください:

## Performance

Environment: <CPU モデル>, governor=performance, turbo disabled, dedicated machine.

Baseline: `main` at <commit-sha>. After: this branch at <commit-sha>.

| Bench | Before | After | Δ | Verdict |
| --- | --- | --- | --- | --- |
| `distance_metrics/cosine` | 4.20 µs | 3.10 µs | -26 % | improved |
| `distance_metrics/euclidean` | 2.18 µs | 2.16 µs | -1 % | no change |

Reproduce: `cargo bench -p laurus --bench distance_bench -- --baseline main`

比較が再現可能となるように、ベースラインと変更後の commit SHA を必ず含めてください。チューニング済みマシンで実行した場合でも環境を明示してください。

新しいベンチマークの追加

新規ベンチファイルを追加する際は、laurus/benches/common.rs のスイート全体衛生ルールに従ってください:

  1. common::DEFAULT_SEED(または lcg_* ヘルパ)で決定的シードを使う。rand::rng() は使わない。
  2. ファイル冒頭に //! ドキュメントコメントでスコープ・シナリオ・実行コマンド・フィルタ例を記載する。
  3. タイミング b.iter の外側で 1 度だけ assert! を実行し、空結果を出す regression を黙ってパスさせない。
  4. SAMPLE_SIZE_FAST(デフォルト、50 ms 以下の操作向け)または SAMPLE_SIZE_SLOW(構築パス向け)のいずれかを選ぶ。中間値は使わない。
  5. laurus/Cargo.toml[[bench]] name = "..." harness = false で登録する。クレートは autobenches = false を設定しているため、benches/ 配下のファイルは自動検出されない。

ファイル間でヘルパを共有する必要がある場合は、benches/common.rs を拡張してコード重複を避けてください。

CI 連携

現状、CI ではリグレッション検出のベンチジョブは実行していません。perf 系の PR は、推奨環境下で取得した baseline-vs-after 数値を手動で投稿することが想定されています。

将来的には大きなリグレッションで失敗するスモークセットのベンチジョブを追加する案があり、アンブレラ Issue #429 で追跡されています。

Feature Flags

laurus クレートはデフォルトでは Feature が無効の状態で提供されます。必要に応じて Embedding サポートを有効にしてください。

利用可能な Feature

Feature説明主な依存クレート
embeddings-candleHugging Face Candle によるローカル BERT Embeddingcandle-core, candle-nn, candle-transformers, hf-hub, tokenizers
embeddings-openaiOpenAI API Embeddingreqwest
embeddings-multimodalCLIP マルチモーダル Embedding(テキスト + 画像)image, embeddings-candle
embeddings-allすべての Embedding Feature を統合上記すべて

各 Feature の詳細

embeddings-candle

CandleBertEmbedder を有効にし、CPU 上でローカルに BERT モデルを実行できるようにします。モデルは初回使用時に Hugging Face Hub からダウンロードされます。

[dependencies]
laurus = { version = "0.9", features = ["embeddings-candle"] }

embeddings-openai

OpenAIEmbedder を有効にし、OpenAI Embeddings API を呼び出せるようにします。実行時に OPENAI_API_KEY 環境変数が必要です。

[dependencies]
laurus = { version = "0.9", features = ["embeddings-openai"] }

embeddings-multimodal

CandleClipEmbedder を有効にし、CLIP ベースのテキストおよび画像 Embedding を使用できるようにします。embeddings-candle を暗黙的に有効にします。

[dependencies]
laurus = { version = "0.9", features = ["embeddings-multimodal"] }

embeddings-all

すべての Embedding Feature を有効にする便利な Feature です。

[dependencies]
laurus = { version = "0.9", features = ["embeddings-all"] }

Feature Flag がバイナリサイズに与える影響

Embedding Feature を有効にすると、コンパイル時間とバイナリサイズが増加する依存クレートが追加されます。

構成おおよその影響
Feature なし(Lexical のみ)ベースライン
embeddings-candle+ Candle ML フレームワーク
embeddings-openai+ reqwest HTTP クライアント
embeddings-multimodal+ 画像処理 + Candle
embeddings-all上記すべて

Lexical(キーワード)検索のみが必要な場合は、Feature を有効にせずに Laurus を使用することで、最小のバイナリサイズと最速のコンパイル時間を実現できます。

プロジェクト構成

Laurus は 9 つのクレートで構成された Cargo ワークスペースです。コアライブラリ、3 つの自製バイナリ(CLI、gRPC サーバー、MCP サーバー)、および 5 つの言語バインディングから成ります。

ワークスペースレイアウト

laurus/                          # リポジトリルート
├── Cargo.toml                   # ワークスペース定義(members + workspace.package)
├── laurus/                      # コア検索エンジンライブラリ
│   ├── Cargo.toml
│   ├── src/
│   │   ├── lib.rs               # パブリック API とモジュール宣言
│   │   ├── engine.rs            # Engine, EngineBuilder, SearchRequest
│   │   ├── analysis/            # テキスト解析パイプライン
│   │   ├── lexical/             # 転置インデックス(Inverted Index)と Lexical 検索
│   │   ├── vector/              # ベクトルインデックス(Flat, HNSW, IVF)
│   │   ├── embedding/           # Embedder 実装
│   │   ├── storage/             # ストレージバックエンド(memory, file, mmap)
│   │   ├── store/               # ドキュメントログ(WAL)
│   │   ├── spelling/            # スペル修正
│   │   ├── data/                # DataValue, Document 型
│   │   └── error.rs             # LaurusError 型
│   └── examples/                # 実行可能なサンプル
├── laurus-cli/                  # コマンドラインインターフェース
│   ├── Cargo.toml
│   └── src/
│       ├── main.rs              # CLI エントリーポイント(clap)
│       ├── cli.rs               # サブコマンド定義
│       └── commands/            # サブコマンドごとの実装
├── laurus-server/               # gRPC サーバー + HTTP ゲートウェイ
│   ├── Cargo.toml
│   ├── proto/laurus/v1/         # Protobuf サービス定義
│   └── src/
│       ├── lib.rs               # サーバーライブラリ
│       ├── config.rs            # TOML 設定
│       ├── service/             # gRPC サービス実装(tonic)
│       └── gateway/             # HTTP/JSON ゲートウェイ(axum)
├── laurus-mcp/                  # MCP(Model Context Protocol)stdio サーバー
│   ├── Cargo.toml
│   └── src/
│       ├── lib.rs
│       ├── server.rs            # rmcp ツールルーター(12 ツール)
│       └── convert.rs           # JSON ↔ DataValue 変換ヘルパー
├── laurus-python/               # Python バインディング(PyO3 + Maturin)
├── laurus-nodejs/               # Node.js バインディング(NAPI-RS)
├── laurus-wasm/                 # WebAssembly バインディング(wasm-bindgen)
├── laurus-ruby/                 # Ruby バインディング(magnus + rb-sys)
├── laurus-php/                  # PHP バインディング(ext-php-rs)
└── docs/                        # mdBook ドキュメント
    ├── book.toml                # 英語版 mdBook 設定
    ├── src/                     # 英語版ソース
    │   └── SUMMARY.md           # 英語版目次
    └── ja/                      # 日本語版 mdBook(独立ビルド)
        ├── book.toml
        └── src/
            └── SUMMARY.md

クレートの役割

クレート種類説明
laurusライブラリLexical 検索、ベクトル検索、ハイブリッド検索を備えたコア検索エンジン
laurus-cliバイナリインデックス管理、ドキュメント CRUD、検索、REPL、および serve / mcp ランチャーを提供する CLI ツール
laurus-serverライブラリ + バイナリオプションの HTTP/JSON ゲートウェイ付き gRPC サーバー
laurus-mcpバイナリ稼働中の laurus-server へツール呼び出しをプロキシする MCP stdio サーバー
laurus-python動的ライブラリPyO3 / Maturin で構築する Python パッケージ(PyPI)
laurus-nodejs動的ライブラリNAPI-RS で構築する Node.js パッケージ(npm)
laurus-wasmWebAssemblywasm-bindgen で構築するブラウザ・エッジランタイム向け npm パッケージ
laurus-ruby動的ライブラリmagnus と rb-sys で構築する Ruby gem
laurus-phpPHP 拡張ext-php-rs で構築する PHP 拡張(スタンドアロン構成。ワークスペースからは除外。詳細は ビルドとテスト を参照)

すべてのバインディングクレートと 3 つの自製バイナリは laurus に依存します。

設計規約

  • モジュールスタイル: ファイルベースのモジュール(Rust 2018 edition スタイル)、mod.rs は使用しない
    • src/tokenizer.rs + src/tokenizer/dictionary.rs
    • 不可: src/tokenizer/mod.rs
  • エラーハンドリング: ライブラリのエラー型には thiserroranyhow はバイナリクレートのみ
  • unwrap() / expect() 禁止: 本番コードでは使用不可(テストでは使用可)
  • 非同期: すべてのパブリック API は Tokio ランタイムで async/await を使用
  • Unsafe: すべての unsafe ブロックに // SAFETY: ... コメントが必須
  • ドキュメント: すべてのパブリックな型、関数、列挙型にドキュメントコメント(///)が必須
  • ライセンス: 依存クレートは MIT または Apache-2.0 互換であること