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 DSL | Lexical、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"]
- スキーマを定義する — フィールドとその型(text、integer、vector など)を宣言します
- Engine を構築する — テキスト用のアナライザと Vector 用のエンベッダを接続します
- ドキュメントをインデックスする — Engine が各フィールドを適切なインデックスに自動的に振り分けます
- 検索する — 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
| クレート | 種類 | 説明 |
|---|---|---|
| laurus | Library | コア検索エンジン – Lexical 検索、Vector 検索、ハイブリッド検索 |
| laurus-cli | Binary | インデックス管理と検索のためのコマンドラインインターフェース |
| laurus-server | Library + Binary | オプションの HTTP/JSON ゲートウェイ付き gRPC サーバー |
| laurus-mcp | Binary | laurus-server へプロキシする MCP(Model Context Protocol)stdio サーバー |
| laurus-python | cdylib | PyO3 / Maturin による Python バインディング |
| laurus-nodejs | cdylib | NAPI-RS による Node.js バインディング |
| laurus-wasm | WebAssembly | wasm-bindgen によるブラウザ・エッジバインディング |
| laurus-ruby | cdylib | magnus / rb-sys による Ruby バインディング |
| laurus-php | PHP 拡張 | ext-php-rs による PHP バインディング(ワークスペースからは除外) |
各クレートの詳細については以下を参照してください。
- ライブラリ概要
- CLI 概要
- サーバー概要
- MCP サーバー概要
- Python バインディング概要
- Node.js バインディング概要
- WASM バインディング概要
- Ruby バインディング概要
- 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 は以下の処理を行います。
- スキーマの分割 — Lexical フィールドは
LexicalIndexConfigへ、Vector フィールドはVectorIndexConfigへ振り分けられる - プレフィックス付きストレージの作成 — 各コンポーネントが分離された名前空間を取得する(
lexical/、vector/、documents/) - ストアの初期化 —
LexicalStoreとVectorStoreがそれぞれの設定で構築される - 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 つのステージで構成されています。
- フィルタ(オプション) — Lexical インデックスに対してフィルタクエリを実行し、許可されたドキュメント ID のセットを取得する
- 検索 — Lexical クエリと Vector クエリを並列に実行する
- フュージョン — 両方のクエリタイプが存在する場合、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/ プレフィックス |
| フィールドごとのディスパッチ | PerFieldAnalyzer と PerFieldEmbedder がフィールド固有の実装にルーティングする |
次のステップ
- フィールドタイプとスキーマ設計を理解する: スキーマとフィールド
- テキスト解析について学ぶ: テキスト解析
- Embedding について学ぶ: Embedding
はじめに
Laurus へようこそ! このセクションでは、ライブラリのインストールから最初の検索の実行までをガイドします。
作成するもの
このガイドを終えると、以下の機能を持つ検索エンジンが動作するようになります:
- テキストドキュメントのインデックス
- キーワード(Lexical)検索の実行
- セマンティック(Vector)検索の実行
- ハイブリッド検索による両者の統合
前提条件
- Rust 1.85 以降(edition 2024)
- Cargo(Rust に同梱)
- Tokio ランタイム(Laurus は非同期 API を使用します)
ステップ
ワークフロー概要
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.toml に laurus と tokio(非同期ランタイム)を追加します:
[dependencies]
laurus = "0.12"
tokio = { version = "1", features = ["full"] }
Feature Flags
Laurus はデフォルトで最小限の機能セットで提供されます。必要に応じて追加の機能を有効にしてください:
| Feature | 説明 | ユースケース |
|---|---|---|
| (default) | コアライブラリ(Lexical 検索、ストレージ、アナライザ — エンベディングなし) | キーワード検索のみ |
embeddings-candle | Hugging Face Candle によるローカル BERT エンベディング | 外部 API 不要の Vector 検索 |
embeddings-openai | OpenAI API エンベディング(text-embedding-3-small 等) | クラウドベースの Vector 検索 |
embeddings-multimodal | Candle による CLIP エンベディング(テキスト + 画像) | マルチモーダル(テキスト→画像)検索 |
embeddings-all | 上記すべてのエンベディング機能 | 全エンベディング対応 |
例
Lexical 検索のみ(エンベディング不要):
[dependencies]
laurus = "0.12"
ローカルモデルによる Vector 検索(API キー不要):
[dependencies]
laurus = { version = "0.12", features = ["embeddings-candle"] }
OpenAI による Vector 検索:
[dependencies]
laurus = { version = "0.12", features = ["embeddings-openai"] }
すべての機能:
[dependencies]
laurus = { version = "0.12", 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_field | Text(全文検索可能) | "Hello world" |
add_integer_field | 64 ビット整数 | 42 |
add_float_field | 64 ビット浮動小数点数 | 3.14 |
add_boolean_field | ブール値 | true / false |
add_datetime_field | UTC 日時 | 2024-01-15T10:30:00Z |
add_hnsw_field | Vector(HNSW インデックス) | [0.1, 0.2, ...] |
add_flat_field | Vector(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(())
}
次のステップ
- Engine の内部動作を学ぶ: アーキテクチャ
- Schema とフィールド型を理解する: スキーマとフィールド
- Vector 検索を追加する: Vector 検索
- Lexical と Vector を統合する: ハイブリッド検索
サンプル
laurus/examples/ ディレクトリには、ライブラリのさまざまな機能を示す実行可能なサンプルが含まれています。
サンプルの実行
# Feature Flags なしでサンプルを実行
cargo run --example <name>
# Feature Flags を指定してサンプルを実行
cargo run --example <name> --features <flag>
利用可能なサンプル
quickstart
基本的なワークフローを示す最小限のサンプルです: Storage の作成、Schema の定義、Engine の構築、ドキュメントのインデックス、検索を行います。
cargo run --example quickstart
デモ内容: インメモリストレージ、TextOption、TermQuery、LexicalSearchQuery
lexical_search
すべての Lexical クエリ型を示す包括的なサンプルです。Builder API と QueryParser DSL の両方を使用します。
cargo run --example lexical_search
デモ内容: TermQuery、PhraseQuery、FuzzyQuery、WildcardQuery、NumericRangeQuery、GeoDistanceQuery / GeoBoundingBoxQuery、BooleanQuery、SpanQuery
vector_search
モックエンベッダを使用した Vector 検索のサンプルです。フィルタ付き Vector 検索や DSL 構文も含みます。
cargo run --example vector_search
デモ内容: PerFieldEmbedder、VectorSearchRequestBuilder、フィルタ付き検索、DSL 構文(field:"query")
hybrid_search
異なる融合アルゴリズムを用いた Lexical 検索と Vector 検索の統合サンプルです。
cargo run --example hybrid_search
デモ内容: Lexical のみ、Vector のみ、ハイブリッド検索。RRF と WeightedSum の両方の融合アルゴリズム。Builder API と DSL。
geo3d_search
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
デモ内容: CandleBertEmbedder(sentence-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 環境変数
デモ内容: OpenAIEmbedder(text-embedding-3-small、1536 次元)
multimodal_search
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 メソッド | 説明 |
|---|---|---|---|
| Text | TextOption | add_text_field() | 全文検索可能。Analyzer によりトークン化される |
| Integer | IntegerOption | add_integer_field() | 64 ビット符号付き整数。範囲クエリをサポート |
| Float | FloatOption | add_float_field() | 64 ビット浮動小数点数。範囲クエリをサポート |
| Boolean | BooleanOption | add_boolean_field() | true / false |
| DateTime | DateTimeOption | add_datetime_field() | UTC タイムスタンプ。範囲クエリをサポート |
| Geo | GeoOption | add_geo_field() | 緯度/経度のペア。半径検索とバウンディングボックスクエリをサポート |
| Geo3d | Geo3dOption | add_geo3d_field() | 3D ECEF 直交座標ポイント(x, y, z、メートル)。3D 距離検索・バウンディングボックス・k-NN クエリをサポート。詳細は 3D 地理検索 を参照 |
| Bytes | BytesOption | add_bytes_field() | バイナリデータ |
Text フィールドオプション
TextOption はテキストのインデクシング方法を制御します。
#![allow(unused)]
fn main() {
use laurus::lexical::TextOption;
// Default: indexed + stored + term vectors + doc values (all true)
let opt = TextOption::default();
// Customize
let opt = TextOption::default()
.indexed(true)
.stored(true)
.multi_valued(false)
.position_increment_gap(100)
.term_vectors(true)
.doc_values(true);
}
| オプション | デフォルト | 説明 |
|---|---|---|
indexed | true | フィールドが検索可能かどうか |
stored | true | 元の値が取得用に保存されるかどうか |
multi_valued | false | 文字列の配列を受け付けるかどうか(Issue #1175)。term クエリはいずれかの要素がタームを含めばマッチ。詳細は後述の「多値(multi-valued)フィールド」を参照 |
position_increment_gap | 100 | 多値フィールドの要素間で読み飛ばす位置数(Lucene の positionIncrementGap)。slop がこの値に達しない限り、フレーズクエリが 2 つの要素をまたぐことはない。0 にすると要素を連結したものとして位置を付番する。multi_valued が true でなければ無視される |
term_vectors | true | ターム位置が保存されるかどうか。2 語以上のフレーズクエリとスパンクエリが使用し、false のフィールドにこれらを実行する検索はクエリエラーで拒否される。Term クエリ(1 トークンに解析される引用符付きの値を含む)は使用せず、ハイライトは常に保存済みテキストを再トークナイズする |
doc_values | true | 値を DocValues(ソート・ファセット・集計が読み取る列指向ストア)にもコピーするかどうか |
doc_values は TextOption 専用ではありません。BytesOption を除く全ての lexical
フィールドオプション(IntegerOption, FloatOption, BooleanOption, DateTimeOption,
GeoOption, Geo3dOption)が同じ設定を持ちます。BytesOption にはこの設定がありません
―― Bytes の値はソートにもファセットにも使えないため、設定にかかわらず DocValues には
一切書き込まれないからです。実効ルールは次のとおりです: DocValues 列が書き込まれるのは
stored と doc_values の両方が true(かつ値の型が Bytes でない)場合のみです。
ソートにもファセットにも使わないフィールドで doc_values: false を設定すると、二重目の
コピーを省略できるため、セグメントの使用容量が削減されます。
Vector フィールド
Vector フィールドは近似最近傍(ANN: Approximate Nearest Neighbor)検索のためのベクトルインデックスを使用してインデクシングされます。
| タイプ | Rust 型 | SchemaBuilder メソッド | 説明 |
|---|---|---|---|
| Flat | FlatOption | add_flat_field() | ブルートフォース線形スキャン。正確な結果 |
| HNSW | HnswOption | add_hnsw_field() | Hierarchical Navigable Small World グラフ。高速な近似検索 |
| IVF | IvfOption | add_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, // 他の vector フィールドに対する相対的な優先度(issue #1084)
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_k(rerank_factor併用時はtop_k * rerank_factor)まで持ち上げるため、top_k要求に対して候補ヒープが不足することはありません。 - Issue #644 で対応。
パラメータの詳細なガイダンスについては、Vector インデクシングを参照してください。
MultiVector フィールド
MultiVector フィールドは、文書のトークンベクトルをすべて保持します。たとえば
ColBERT 型の late interaction モデルが出力するトークンごとの埋め込み(1 パッセージあたり
128 次元のベクトルが 100〜300 本程度)です。ANN 索引は持ちません。ベクトル検索の対象には
ならず、別のクエリ(lexical・vector・hybrid)が見つけた候補を再採点するときにだけ読まれます。
| 型 | Rust 型 | SchemaBuilder メソッド | 説明 |
|---|---|---|---|
| MultiVector | MultiVectorOption | add_multi_vector_field() | late interaction の再採点に使う、文書ごとのトークンベクトル。検索対象ではない |
#![allow(unused)]
fn main() {
use laurus::{MultiVectorOption, Schema};
use laurus::vector::core::distance::DistanceMetric;
let schema = Schema::builder()
.add_multi_vector_field(
"body_colbert",
MultiVectorOption::new(128).distance(DistanceMetric::Cosine), // Cosine(既定)または DotProduct
)
.build();
}
- オプション:
dimension(各トークンベクトルの長さ)、distance、storage、embedder。distanceとして受け付けるのはCosine(書き込み時に L2 正規化 するので、類似度は内積になる。ゼロベクトルはそのまま保存する)とDotProductだけで、それ以外はインデックスの作成時に拒否されます。storageは各トークン ベクトルのディスク上での要素形式を選びます —F32(デフォルト、厳密)、F16、Int8のいずれかで、精度とインデックスサイズのトレードオフです。 サイズと誤差の数値は MultiVector ストレージを参照してください。embedderには、candle_colbertのようなトークン単位の Embedder の名前を指定します (MultiVectorOption::new(96).embedder("colbert"))。 - 値:
DataValue::VectorArray(Vec<Vec<f32>>)。1 本以上 8,192 本以下で、各ベクトルは フィールドの次元を持ち、有限値でなければなりません。JSON では、同じ長さの数値配列の 配列([[0.1, 0.2, ...], [0.3, 0.4, ...]])です。トークン単位の Embedder がこの フィールドを担当している場合(embedderで指定するか、EngineBuilder::embedderで登録する)は、テキストの値も使えます。テキストは文書のインデックス時にトークン ベクトルへ埋め込まれます(Issue #1349)。そのような Embedder がないフィールドに テキストを与えた文書は拒否されます。 - 保存しない: トークンベクトルはベクトル索引にだけ置きます(300 本 × 128 次元の
f32で 1 文書約 150 KB)。そのためget_documentsや検索結果には含まれません。 - 検索できない: フィールド指定なしのベクトル検索と prefix のフィールド指定は、この フィールドを対象にしません。ベクトル検索や DSL でこのフィールドを指定するとエラーです。
- スキーマの変更: 次元・距離・Embedder の変更、他のベクトルフィールド型との
相互変更は、破壊的な変更(Destructive)です。
storageの変更は Reindex 扱いです。既存の値から再エンコードできるため、Engine::update_field(.., UpdateFieldOptions { reindex: true, .. })でフィールドの既存セグメントを新しいディスク上の形式へ再構築します。
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_bytes_array(name, values) | Vec<(Vec<u8>, Option<String>)> | 多値バイナリフィールドを追加。各要素が独自の任意 MIME タイプを持つ |
add_vector_array(name, vectors) | Vec<Vec<f32>> | MultiVector フィールドのトークンベクトルを追加 |
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>), // 多値浮動小数点フィールド
GeoArray(Vec<GeoPoint>), // 多値 2D 地理フィールド
GeoEcefArray(Vec<GeoEcefPoint>), // 多値 3D ECEF フィールド
DateTimeArray(Vec<DateTime<Utc>>), // 多値日時フィールド
BoolArray(Vec<bool>), // 多値ブールフィールド
TextArray(Vec<String>), // 多値テキストフィールド
BytesArray(Vec<(Vec<u8>, Option<String>)>), // 多値バイトフィールド(要素ごとに MIME)
VectorArray(Vec<Vec<f32>>), // MultiVector フィールドのトークンベクトル
}
}
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
}
予約フィールド
アンダースコア(_)で始まるフィールド名はすべてエンジンの予約領域です。
そのような名前のフィールドを宣言すると、インデックス作成時に拒否されます。
_ で始まるキーを含むドキュメントも投入時にエラーとなるため、
いずれにせよそのフィールドが値を持つことはありません。
言語バインディングではさらに早く、フィールド追加メソッド(add_*_field / add*Field)が
そのような名前を渡した時点で拒否します。正確なエラー文と、
この検査より前に作成したインデックスの扱いは
フィールド命名規則
を参照してください。
唯一許可される _ プレフィックス名は、次に説明する _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 ポリシー)
| 投入される値 | 推論されるフィールド型 |
|---|---|
string | Text(転置インデックス、BM25) |
integer | Integer(BKD tree) |
float | Float(BKD tree) |
bool | Boolean |
整数の配列(例: [1, 2, 3]) | Integer(multi_valued = true) |
浮動小数点を含む数値配列(例: [1.5, 2.0, 3]) | Float(multi_valued = true) |
緯度キー(lat または latitude)と経度キー(lon、lng、longitude のいずれか)を持ち、値が範囲内の object | Geo |
数値の x、y、z の 3 キーをすべて持つ object(有限値、ECEF メートル単位) | Geo3d |
地理 object の配列(例: [{"lat": 35.6, "lon": 139.7}, ...]) | Geo(multi_valued = true) |
x/y/z object の配列 | Geo3d(multi_valued = true) |
全要素が RFC 3339 である文字列の配列(例: ["2024-01-01T00:00:00Z", "2024-06-15T21:00:00+09:00"]) | DateTime(multi_valued = true) |
それ以外の文字列の配列(例: ["rust", "search"]) | Text(multi_valued = true) |
ブール値の配列(例: [true, false]) | Boolean(multi_valued = true) |
data キー(base64 エンコードされた文字列)と任意の mime キーを持つ object | Bytes 値 |
同じ長さの空でない数値配列の配列(例: [[0.1, 0.2], [0.3, 0.4]]) | VectorArray 値(トークンベクトル) |
ベクトルフィールド(Hnsw / Flat / Ivf / MultiVector)は 自動推論の対象外です。
次元数・距離関数・embedder の設定は値だけからは復元できないため、
スキーマへ明示的に宣言してください。未宣言のフィールドにトークンベクトルが来た場合は
拒否されます。Bytes の値は上記の {data, mime}
オブジェクト形式から解析できるようになりましたが、未宣言の
フィールドに Bytes の値が来た場合は、ベクトルフィールドと同様に
自動登録されず拒否されます(Bytes フィールドは常に明示的な宣言が
必要です)。なお、同一 object 内で 2D 用キー(lat / lon)、
3D 用キー(x / y / z)、Bytes 用キー(data)のうち複数を
混在させた場合は曖昧と判定してエラーとなります。いずれか一方の
形式のみ使用してください。
多値(multi-valued)フィールド
Integer と Float フィールドは multi_valued = true を指定することで、
1 ドキュメントに複数の値を保持できます。範囲クエリはいずれかの値が条件を満たせばマッチ
する Lucene 流の挙動で、スコアは constant(マッチ件数による加点なし)です。
Geo と Geo3d フィールドも同様に multi_valued = true を指定できます。
多値地理フィールドの各ポイントは、それぞれ独立したエントリとしてフィールドの
BKD tree(Geo は 2 次元、Geo3d は 3 次元)に登録されます。そのため距離クエリと
バウンディングボックスクエリ(Geo3d では nearest クエリも)は、いずれかのポイントが
条件を満たせばドキュメントにマッチします。ドキュメントは 1 回だけ報告され、
スコアは条件を満たすポイントのうち最も近いもの(2D バウンディングボックスクエリでは
ボックス中心に最も近いポイント)で決まります。Dynamic ポリシーでは、2D object と
3D object を 1 つの配列に混在させた場合、および数値と object を混在させた場合は
エラーになります。各要素には単一ポイントと同じ範囲検証が適用されます。
DateTime フィールドも同様に multi_valued = true を指定できます(Issue #1184)。
多値日時フィールドの各時刻(instant)は、それぞれ独立した 1 次元のエントリとして
フィールドの BKD tree に登録されます。そのため DateTimeRangeQuery、このフィールドに対する
NumericRangeQuery、および seen_at:[2024-06-01 TO 2024-12-31] のような DSL の日付範囲は、
いずれかの時刻が範囲内にあればドキュメントにマッチします(Lucene 流の “any match”、
スコアは constant でマッチ件数による加点なし)。複数の時刻がマッチしてもドキュメントは
1 回だけ報告され、秒未満の時刻も尊重されます(境界は小数秒としてエンコードされます)。
Dynamic ポリシーでは、全要素が RFC 3339 文字列である配列は多値 DateTime と推論されます。
ここで日時として認識されるのは RFC 3339 のみで(クエリ DSL が受け付けるオフセットなし・日付のみの形式は
対象外)、RFC 3339 でない要素を含む文字列配列は代わりに多値 Text と推論されます(Issue #1175)——
エラーにはなりません。なお非対称性に注意してください:
単一の RFC 3339 文字列は従来どおり Text と推論されます(既存のテキストフィールドの
挙動を変えないため)が、RFC 3339 文字列の配列は多値 DateTime と推論されます。
単一値の DateTime フィールドが必要な場合はスキーマで明示的に宣言してください。
Boolean フィールドも同様に multi_valued = true を指定できます(Issue #1180)。
ただし仕組みは他の多値型とは異なり、Boolean フィールドは BKD ポイントを持ちません。
多値ブールフィールドの各要素は、それぞれ独立した "true" / "false" の term posting
としてインデックスされます。そのため TermQuery や flags:true のような DSL の term クエリは、
いずれかの要素がクエリの値と等しければドキュメントにマッチします(Lucene 流の “any match”)。
複数の要素が同じ値でも、その term についてドキュメントは 1 回だけ報告されます
(posting はドキュメント・term ごとに集約されます)。Boolean フィールドが範囲クエリの対象外である点は
従来どおりです。通常の term posting であるため、要素の重複は Lucene と同じように BM25 スコアに影響します:
[true, true] は term frequency 2・フィールド長 2 の posting 1 件になるため、flags:true に対して
[true] より高くスコアリングされ、[true, false] は長さ正規化(length normalization)のため
[true] よりわずかに低くスコアリングされます。重複排除は行わず、term クエリの constant スコアリングも
ありません(constant スコアの term クエリは Issue #580 で追跡しています)。
Dynamic ポリシーでは、全要素がブール値である JSON 配列(例: [true, false])は多値 Boolean と
推論されます。[true, 1] のような混在配列は、既存の「配列フィールドは数値のみ」という趣旨のエラーで
拒否されます。スカラーとの非対称性に注意してください: 単一の "true" 文字列は宣言済みの Boolean
フィールドに対して Bool に変換され、["true", "false"] のような文字列配列も宣言済みの多値 Boolean
フィールドに送れば同じ規則で要素ごとにパースされます。しかし未宣言のフィールドでは、この配列は Boolean
ではなく多値 Text と推論されます(Issue #1175)—— ブールとして推論させたい配列には JSON のブール値を使ってください。
既存の Boolean フィールドで multi_valued を有効にする変更は metadata-only です。無効にする変更は、
フィールドが stored なら再インデックス(Reindex)が必要で、stored: false なら Destructive
になります —— BKD ベースの型と異なり、再構築の元になるポイントツリーがなく、保存された値しかないためです。
Text フィールドも同様に multi_valued = true を指定できます(Issue #1175)。あわせて
position_increment_gap オプション(デフォルト 100。Lucene / Elasticsearch と同じ値)が追加されています。
多値テキストフィールドの各要素はフィールドの analyzer でそれぞれ独立に解析され、全要素のトークンは
**1 本の昇順の位置列(position sequence)**に追記されます: 要素 n + 1 の最初のトークンは、要素 n の
最後のトークンから position_increment_gap 個後ろの位置に置かれます。gap は要素ごとに加算され、
トークンを 1 つも生成しない要素に対しても加算されます。以下の挙動はすべてこの付番から導かれます:
TermQuery(またはtags:rustのような DSL の term)は、いずれかの要素がタームを含めばドキュメントにマッチします(Lucene 流の “any match”)。PhraseQueryは、slop がposition_increment_gap以上でない限り 2 つの要素をまたぐことができません —— 閾値はちょうどslop == gapです。デフォルトの gap では、["hello world", "foo bar"]はフレーズ"world foo"に slop 0〜99 ではマッチせず、slop 100 でマッチします。スパンクエリ(SpanNearQuery)も同じ保護を受けます。- gap を
0にすると要素を連結したものとして付番されるため、フレーズは要素境界をまたいでマッチします。gap が「0 から付番し直す」という意味になることはありません。 - 複数の要素にまたがるタームの重複は、他の多値型と同じくヒット数ではなく term frequency(したがって BM25 スコア)を増やします。フィールド長はトークン数ではなく position の数です —— 別のトークンにスタックされたシノニム(
position_increment = 0)はそのトークンの position を共有し、重ねて数えられません —— そのため gap 自体が BM25 の長さ正規化(length normalization)を膨らませることはありません。 - 2 語以上のフレーズクエリが機能するには位置が保存されている必要があります(
term_vectors: true。デフォルト)。位置がなければ検索はクエリエラーで拒否され、これは多値・単一値のどちらのフィールドでも同じです。keywordフィールドに対するtags:"lang/rust"のように 1 トークンに解析される引用符付きの値は Term の一致なので、位置を必要としません。
多値テキストフィールドのハイライトは要素ごとに行われます —— マッチした要素だけがフラグメントを生成し、
フラグメントが 2 つの要素をまたぐことはありません。詳細は ハイライト を参照してください。
Dynamic ポリシーでは、全要素が文字列である JSON 配列は、全要素が RFC 3339 としてパースできれば多値 DateTime、
そうでなければ多値 Text と推論されます(例: ["rust", "search"])。単一の文字列は従来どおり Text と推論され、
日時と判定されることはありません。既存の Text フィールドで multi_valued を有効にする変更は metadata-only です。
無効にする変更は、フィールドが stored なら再インデックス(Reindex)が必要で、stored: false なら
Destructive になります —— Boolean と同じく、再構築の元になる BKD tree がないためです。
position_increment_gap の変更は、フィールドが位置を保存している(term_vectors: true)場合に再インデックスが
必要です(stored なら Reindex、そうでなければ Destructive)。既存の posting は古い gap で付番されているためです。
term_vectors が false なら gap は観測できないため、変更は metadata-only です。
Bytes フィールドも同様に multi_valued = true を指定できます(Issue #1176)。ただし
仕組みは他のどの多値型とも異なり、Boolean よりもさらに徹底しています —— BytesOption には
indexed フラグ自体が存在せず、Bytes の値はそもそもレキシカルインデックスされません。
そのため BKD ポイントも term posting も存在せず、multi_valued には “any match” のクエリ
意味論が一切ありません。単に保存時の形と取り込み時の許容個数を変えるだけです。
DataValue::BytesArray は Vec<(Vec<u8>, Option<String>)> で、他の *Array variant が使う
素の Vec<T> とは異なり、各要素がスカラーの Bytes(Vec<u8>, Option<String>) と全く同じ形で
独自の MIME タイプを持ちます。Bytes の値はスカラーでも配列でも、Dynamic ポリシーの下で
未宣言のフィールドに対して推論されることは決してありません(前述の
型推論ルール(Dynamic ポリシー)を参照)。多値 Bytes
フィールドは常に明示的な宣言が必要です。宣言済みのフィールドでは、単一の Bytes 値や
base64 の Text 文字列は要素 1 個の配列に自動ラップされ(Text 要素はスカラーと同じ規則で
base64 デコードされます)、空の数値配列([])は空のバイト値リストとして受理されます。
既存の Bytes フィールドで multi_valued を有効にする変更は metadata-only です。無効にする
変更は、フィールドが stored なら再インデックス(Reindex)が必要で、stored: false なら
Destructive になります —— Boolean や Text と同じく、再構築の元になるポイントツリーも
posting もなく、保存された値しかないためです。
ファセット集計はすべての多値型を要素ごとに展開します(Issue #1187): 整数・浮動小数点・ブール・
日時・テキストの各要素がそれぞれ独立したファセット値になり(/ を含むテキスト要素は階層パスになります)、
要素が重複していてもドキュメントごとに 1 回だけ数えられます。地理座標とバイト列の配列はファセットの
対象外で何も寄与しません。要素の文字列形式は ファセット を参照してください。
多値フィールドに単一値を送った場合は要素 1 個の配列に自動ラップされます。
逆に単一値フィールドに配列を送ると、暗黙の切り捨てではなくエラーになります
(エラーメッセージは multi_valued = true でフィールドを宣言するよう案内します)。
多値地理・多値日時・多値ブール・多値テキストフィールドに空配列を送った場合は受理され、
ポイント・時刻・term を持たないフィールドになります(どの空間クエリ・範囲クエリ・term クエリ・フレーズクエリにもマッチしません)。
多値バイトフィールドに空配列を送った場合も同様に受理され、単に空リストとして保存されます ——
Bytes フィールドはそもそもクエリの対象にならないため、マッチしないクエリというもの自体が存在しません。
多値地理・多値日時・多値ブール・多値テキスト・多値バイトの値を含むセグメントは新しい stored-field 型タグを使用するため、
これらの機能より前のビルドでは読み込めません。フォーマットのバージョンは上げていないため、
古いリーダーはデータを誤読するのではなく、明示的なエラーで失敗します。
保存される多値日時はマイクロ秒精度(時刻ごとに 1 つの i64 Unix マイクロ秒。マイクロ秒未満の桁は
切り捨て)で保持され、単一値の DateTime は完全な精度を保ちます。
保存される多値ブールは要素ごとに 1 バイト(ビットパックなし)で書き込まれます。
保存される多値テキストは、要素数に続けて各文字列を長さプレフィックス付きで書き込みます
(本体は単一値のテキストと同じ形式です)。保存される多値バイトは、要素数に続けて各要素を
スカラーの Bytes 値と同じ(MIME、続けてデータ)の長さプレフィックス付き形式で書き込みます
(空の MIME 文字列は None を意味します)。
型衝突
既に宣言されているフィールドに別の型の値が到着した場合、Laurus は 宣言された型への変換を試みます。変換ルールは以下の通りです:
| 宣言型 | 受け取った値 | 結果 |
|---|---|---|
Integer | Int64 | そのまま格納 |
Integer | Float64(3.14) | 3 へ切り捨て(情報損失あり — 下の警告を参照) |
Integer | Text("42") | 42 としてパース |
Integer | Text("abc") | エラー |
Float | Int64 | f64 に拡張 |
Float | Text("3.14") | パース |
Boolean | Int64(0) / Int64(1) | false / true |
Boolean | Text("true"/"false") | 大文字小文字を無視してパース |
Text | 任意のスカラー値 | 文字列化 |
Bytes | Text(s) | base64 としてデコード(s が不正な base64 ならエラー) |
Geo / Geo3d | 対応 variant 以外 | エラー |
Geo / Geo3d(単一値) | GeoArray / GeoEcefArray | エラー(multi_valued = true を宣言する) |
Geo / Geo3d(multi_valued = true) | GeoArray / GeoEcefArray | そのまま格納 |
Geo / Geo3d(multi_valued = true) | 対応する単一ポイント | 要素 1 個の配列にラップ |
Geo / Geo3d(multi_valued = true) | 空の数値配列([]) | 空のポイントリスト |
DateTime(単一値) | DateTimeArray | エラー(multi_valued = true を宣言する) |
DateTime(multi_valued = true) | DateTimeArray | そのまま格納 |
DateTime(multi_valued = true) | 単一の DateTime または RFC 3339 の Text | 要素 1 個の配列にラップ |
DateTime(multi_valued = true) | 空の数値配列([]) | 空の時刻リスト |
DateTime(multi_valued = true) | 上記以外 | エラー |
Boolean(単一値) | BoolArray | エラー(multi_valued = true を宣言する) |
Boolean(multi_valued = true) | BoolArray | そのまま格納 |
Boolean(multi_valued = true) | 単一の Bool、Int64(0) / Int64(1)、または Text("true"/"false") | 要素 1 個の配列にラップ(スカラーと同じ規則) |
Boolean(multi_valued = true) | Int64Array | 同じ 0 / 1 の規則で要素ごとに変換([0, 1] → [false, true]。[0, 2] は「0 と 1 のみ受け付ける」エラー) |
Boolean(multi_valued = true) | 空の数値配列([]) | 空のブールリスト(どの term クエリにもマッチしない) |
Boolean(multi_valued = true) | 上記以外 | エラー |
Integer / Float(multi_valued = true) | BoolArray | 要素ごとに 0 / 1 へ拡張(単一値の Integer / Float は multi_valued = true を案内するエラーで拒否) |
Text(単一値) | TextArray | エラー(multi_valued = true を宣言する) |
Text(multi_valued = true) | TextArray | そのまま格納 |
Text(multi_valued = true) | 単一の Text、Int64、Float64、Bool、または DateTime | 文字列化して要素 1 個の配列にラップ(スカラーと同じ規則) |
Text(multi_valued = true) | Int64Array / Float64Array / BoolArray / DateTimeArray | 要素ごとに文字列化(日時は RFC 3339) |
Text(multi_valued = true) | Null または空の数値配列([]) | 空の文字列リスト(どの term クエリ・フレーズクエリにもマッチしない) |
Text(multi_valued = true) | 上記以外(地理配列・ベクトル・バイト列) | エラー |
Integer / Float / Boolean / DateTime(multi_valued = true) | TextArray | スカラーの Text と同じ規則で要素ごとにパース(["1", "2"] → [1, 2]。不正な要素はその要素を示すエラー)。単一値の Integer / Float / Boolean / DateTime は multi_valued = true を案内するエラーで拒否 |
Bytes(単一値) | BytesArray | エラー(multi_valued = true を宣言する) |
Bytes(multi_valued = true) | BytesArray | そのまま格納 |
Bytes(multi_valued = true) | 単一の Bytes または base64 の Text | 要素 1 個の配列にラップ(スカラーと同じ規則) |
Bytes(multi_valued = true) | TextArray | 各要素を base64 としてデコード(スカラーと同じ規則を要素ごとに適用。MIME は常に None) |
Bytes(multi_valued = true) | 空の数値配列([]) | 空のバイト値リスト |
Bytes(multi_valued = true) | 上記以外 | エラー |
ベクトル(Hnsw/Flat/Ivf) | Text または Bytes | フィールドの embedder にそのまま渡す |
ベクトル(Hnsw/Flat/Ivf) | 数値配列 | 要素ごとに f32 へキャスト |
ベクトル(Hnsw/Flat/Ivf) | VectorArray | エラー(トークンベクトルには MultiVector フィールドが必要) |
MultiVector | フィールドの次元を持つ有限値のベクトル 1〜8,192 本の VectorArray | そのまま格納 |
MultiVector | それ以外(単一のベクトル、平坦な数値配列、テキスト) | エラー |
変換エラーの扱いはポリシーに依存します:
Strict: ただちにエラーを返すDynamic: エラーを返す(安全とみなせる変換はこの層ですべて試し切っている)Ignore: 該当フィールドのみ破棄し、他のフィールドはインデックスする
⚠️ 警告: 静かな情報損失が発生しうる
いくつかの変換は、エラーを返さずに情報を失います:
Integerフィールドは受け取ったFloat値を切り捨てます (3.14→3、-3.9→-3)。投入は成功しますFloatフィールドはf64仮数部に収まらない巨大な整数で精度を失う可能性がありますTextフィールドはスカラーを文字列化して受け入れます(元の型情報は消えます)Ignoreは非互換なフィールドを静かに捨てますデータの正確性を優先したい場合は、
DynamicFieldPolicy::Strictを使う(あるいは必要なフィールドをすべて事前に宣言する)ことを推奨します。Dynamicポリシーは「ドキュメントを投入できる」ことを「入力データを 1 ビットも失わない」ことより優先します。
Query DSL と未宣言フィールド
スキーマが確定した後、クエリパーサは field:value 句で参照されるフィールドが
すべてスキーマに存在することを検証します。titl:hello(title: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に含まれている場合、そこからも削除されます。- フィールドに紐づくアナライザーおよびエンベッダーの登録が解除されます。
- 既にインデックスされたデータは物理的に残りますが、スキーマから削除されたフィールドにはアクセスできなくなります。
フィールドの変更
Engine::update_field() を使用すると、既存フィールドの型・オプションを稼働中のエンジンに対して変更できます。
let outcome = engine.update_field(
"title",
FieldOption::Text(TextOption::default().analyzer("english")),
UpdateFieldOptions { reindex: true, ..Default::default() },
).await?;
変更内容は次の3種類に分類され、outcome.classification で確認できます。
MetadataOnly(メタデータのみ): 既存データへの影響がなく、常に適用されます(例: HNSW のdefault_ef_search)。Reindex(再構築が必要): 保存済みの元データから再構築が可能です(例: text フィールドのanalyzer変更、term_vectorsが有効な text フィールドのposition_increment_gap変更、indexed: false → true、HNSW のm/ef_construction変更)。Destructive(破壊的変更): 元データから再構築できず、既存データを破棄します(例: ベクトルフィールドのdimension/embedder/distance変更、stored: falseフィールドの型変更、stored: falseなBoolean/Text/Bytesフィールドのmulti_valuedを無効にする変更)。
Reindex と Destructive は、明示的に UpdateFieldOptions { reindex: true, .. } を指定しない限り拒否されます(再構築に時間がかかる、あるいはデータを失うため、意図しない実行を防ぐオプトイン方式です)。
Destructive な変更を適用すると、対象フィールド名がスキーマの pending_reindex に記録されます。これは Solr のように「スキーマとインデックスが静かに食い違う」状態を避けるための可視化機構で、GetSchema / laurus get schema から確認できます。既存データを失ったフィールドが分かるので、必要に応じてドキュメントの再投入で解消してください。
UpdateFieldOptions { dry_run: true, .. } を指定すると、実際には何も適用せずに分類結果だけを確認できます。
共通の注意事項
Engine::builder().persist_schema_with(hook) でスキーマ永続化フックを設定していれば、
add_field/delete_field/update_field は返却前に自らそのフックを呼び出してスキーマを永続化します
(laurus-cli と laurus-server はどちらもこのフックで schema.toml への書き出しを
行っています)。フックを設定していない場合は、従来どおり返却された Schema を
呼び出し側で永続化する必要があります(例: schema.toml への書き出し)。
スキーマ設計のヒント
-
Lexical フィールドと Vector フィールドを分離する — フィールドは Lexical か Vector のいずれかであり、両方にはなりません。ハイブリッド検索には、別々のフィールドを作成してください(例: テキスト用に
body、ベクトル用にbody_vec)。 -
完全一致フィールドには
KeywordAnalyzerを使用する — カテゴリ、ステータス、タグフィールドはPerFieldAnalyzer経由でKeywordAnalyzerを使用し、トークン化を避けてください。 -
適切なベクトルインデックスを選択する — ほとんどの場合は HNSW、小規模データセットには Flat、非常に大規模なデータセットには IVF を使用してください。詳細は Vector インデクシングを参照。
-
デフォルトフィールドを設定する — Query DSL を使用する場合、デフォルトフィールドを設定することで、ユーザーは
body:helloの代わりにhelloと記述できます。 -
スキーマジェネレータを使用する —
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
解析パイプラインは以下で構成されます。
- Char Filter — トークン化の前に文字レベルで生テキストを正規化する
- Tokenizer — テキストを生トークン(単語、文字、n-gram)に分割する
- 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;
}
}
TokenStream は Box<dyn Iterator<Item = Token> + Send> であり、トークンの遅延イテレータです。
Token には以下のフィールドが含まれます。
| フィールド | 型 | 説明 |
|---|---|---|
text | String | トークンテキスト |
position | usize | 元テキスト内の位置 |
start_offset | usize | 元テキスト内の開始バイトオフセット |
end_offset | usize | 元テキスト内の終了バイトオフセット |
position_increment | usize | 前のトークンからの距離 |
position_length | usize | トークンのスパン(同義語の場合は 1 より大きい) |
boost | f32 | トークンレベルのスコアリング重み |
stopped | bool | ストップワードとしてマークされているかどうか |
metadata | Option<TokenMetadata> | 追加のトークンメタデータ |
組み込み Analyzer
StandardAnalyzer
デフォルトの Analyzer です。ほとんどの西洋言語に適しています。
パイプライン: RegexTokenizer(Unicode 単語境界) → LowercaseFilter → StopFilter(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) → JapaneseIterationMarkCharFilter → LinderaTokenizer → LowercaseFilter → StopFilter(日本語ストップワード)
JapaneseAnalyzer::new は LinderaTokenizer::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 単語境界) → LowercaseFilter → StopFilter(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.analyzers に AnalyzerDefinition として登録し、フィールドからは名前で参照します。パラメータ不要の組込 analyzer の名前は予約されているため、パイプラインには別の名前を付けてください(アナライザの参照 を参照)。
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 | 説明 |
|---|---|
UnicodeNormalizationCharFilter | Unicode 正規化(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 の使用
PipelineAnalyzer に add_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 | 説明 |
|---|---|
RegexTokenizer | Unicode 単語境界で分割。空白と句読点で区切る |
UnicodeWordTokenizer | Unicode 単語境界で分割 |
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); // 各同義語トークンに保存されるが、スコアリングは今のところ読まない(下記参照)
}
boost パラメータは各同義語 Token に保存されますが、インデクサもクエリパーサもこの値を今のところ読まないため、スコアリングやマッチングには影響しません —— 同義語のマッチは現在、完全一致と全く同じにスコア付けされます(下記同義語を使った検索参照)。独自のパイプラインステージで利用する予定がある場合は設定してください。同義語マッチの重みを下げる目的では使えません。
複数語の見出し語の一致
複数語の見出し語は、連続するトークンがすべて英数字(トークン種別 Alphanum または Num)のとき、またはオフセットが連続している(各トークンの end_offset が次のトークンの start_offset と等しい)ときに一致します。前者により空白で区切られた英単語が一致し、後者により形態素トークナイザが分割した CJK の語が一致します。種別は Token::metadata から読むため、種別のないトークンはオフセットが連続している必要があります。辞書に 東京大学 があるとき、東京 と 大学 に分割されたテキスト 東京大学 は一致しますが、東京 大学 は 2 つのトークンの間に空白があるため一致しません。
トークングラフ
このフィルタは、同義語を展開元の語と同じ位置に積みます(position_increment = 0)。そのため出力はグラフになり、トークンは「位置」から「位置 + position_length」への弧(arc)になります。一致した語とすべての同義語は、同じ開始ノードから同じ終了ノードまで続きます。短い代替語の最後のトークンが残りの位置をまたぎ、一致区間の次のトークンは終了ノードから始まります。ml と machine learning を同義語とした ml tutorial の出力は次のとおりです。
| トークン | 位置 | position_increment | position_length |
|---|---|---|---|
ml | 0 | 1 | 2 |
machine | 0 | 0 | 1 |
learning | 1 | 1 | 1 |
tutorial | 2 | 1 | 1 |
複数語のメンバーは、それぞれ専用の内側のノードを持ちます。そのため、2 つのメンバーの語が同じ弧に入ることはありません。ml と machine learning、statistical machine learning を同義語とした ml の出力は次のとおりです。
| トークン | 弧 |
|---|---|
ml | 0 → 4 |
machine | 0 → 1 |
statistical | 0 → 2 |
learning | 1 → 4 |
machine | 2 → 3 |
learning | 3 → 4 |
インデックスは position_length を保存しないため、グラフを最長の経路に沿って並べ直してから保存します。このとき、メンバーの語は位置を共有します。上の例では、ml・machine・statistical が位置 0、learning・machine が位置 1、learning が位置 2 に保存されます。積まれたトークンは展開元の語と同じ位置に保存されます。それ以外の位置は詰めて振られます。StopFilter が取り除いたストップワードの位置は空きません。この並べ直しはインデックスが行うため、アナライザーに FlattenGraphFilter は不要です。エンジンはクエリも同じアナライザーでパースするので、FlattenGraphFilter を入れると、引用符で囲んだ値をマッチさせるグラフまでフラット化されてしまいます。
同義語を使った検索
エンジンは、フィールドのアナライザーをインデックスとクエリのパースの両方に使います。そのため、同義語は両側で展開されます。引用符なしの語は、その同義語のどれにでもマッチします。引用符で囲んだ値はグラフに沿ってマッチします(フレーズクエリを参照)。big と large を同義語とすると、"big" はどちらの語にもマッチし、"a big dog" は「a large dog」にもマッチします。グループの各メンバーはそれぞれ 1 つのフレーズになります。上の例のメンバーなら、"ml" は ml、machine learning、statistical machine learning にマッチし、「statistical learning」にはマッチしません。同じ長さのメンバーも別々のフレーズになります。引用符で囲んだ値のフレーズはグラフに沿ってまとめてマッチするので、同義語ごとに掛け算で増えるその数に上限はありません。
1 つの位置に複数の代替語(同義語)が積まれている場合、それらは独立したタームのスコアを合算するのではなく、1 つのブレンドされたターム(SynonymQuery)としてスコア付けされます。big と large の両方を含む文書は "big" に対して big を 2 回含む文書と同じスコアになり、2 倍にはなりません。フィールド長もトークン数ではなく位置の数で数えるため、積まれた同義語が文書を長くすることもありません。詳細はBM25(デフォルト)を参照してください。
インデックスは位置を保存しますが position_length は保存しないため、複数語の同義語をインデックス時に展開すると、Lucene と同じく次のようになります。
- フレーズが同義語の途中から始まったり、途中で終わったりしてもマッチします。
"learning is"は「ml is fun」にマッチします。同義語を通すと「machine learning is fun」と読めるためです。 - 1 つのグループに複数語のメンバーが複数あると、インデックスではそれらの語が位置を共有します。そのため、1 つのメンバーを含む文書が、2 つのメンバーの語が混ざったフレーズにもマッチします。「ml is fun」は
"statistical learning"にマッチします。
そのほかの注意点は次のとおりです。
keep_original = falseは、一致した語をグループのほかのメンバーで置き換えます。そのため、その語自体はインデックスも検索もされません。検索にはkeep_original = trueを使ってください。StopFilter・RemoveEmptyFilter・LimitFilterのようにトークンを取り除くフィルターは、SynonymGraphFilterの後ろに置けます。グラフの中でも、取り除いた語の位置は空きません。statue of libertyとlady libertyを同義語とし、ofをストップワードとすると、"statue of liberty"は「statue of liberty」と「lady liberty」の両方にマッチします。StopFilterをSynonymGraphFilterより前に置くと、同義語フィルターが見る前にofが取り除かれるため、このメンバーはマッチしなくなります。- 取り除いた語と同じ位置にほかの選択肢(積まれた 1 語の同義語など)があれば、その位置は選択肢に引き継がれます。
theとaを同義語とすると、theを取り除いてもaがその位置に残り、その語を飛ばす経路はできません。LimitFilterは、すべての経路を同じトークンで切ります。 - Issue #1252 より前にインデックスした文書は、積まれたトークンに連続した位置を振っています。また Issue #1257 より前にインデックスした文書は、積まれたトークンもすべてフィールド長に数えてしまい、長さが水増しされています。フィールドが
SynonymGraphFilterを使う場合は、アップグレード後に文書を投入し直してください。セグメントのマージは保存済みの位置とフィールド長をどちらもそのままコピーするため、マージでは直りません。
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() メソッドは Vector(Vec<f32> をラップした構造体)を返します。
EmbedInput は 2 つのモダリティをサポートします。
| バリアント | 説明 |
|---|---|
EmbedInput::Text(&str) | テキスト入力 |
EmbedInput::Bytes(&[u8], Option<&str>) | バイナリ入力(オプションの MIME タイプ付き、画像用) |
トークン単位の Embedder
ColBERT のような late interaction のモデルは、入力全体で 1 本ではなく、
トークンごとに 1 本のベクトルを出し、クエリと文書を異なる方法で符号化します。
このような Embedder は TokenEmbedder も実装し、Embedder::as_token_embedder
で自分自身を返します(Issue #1349)。
#![allow(unused)]
fn main() {
#[async_trait]
pub trait TokenEmbedder: Send + Sync + Debug {
async fn embed_tokens(&self, inputs: &[EmbedInput<'_>], role: EmbedRole)
-> Result<Vec<Vec<Vector>>>;
fn token_dimension(&self) -> usize;
}
pub enum EmbedRole { Query, Document }
}
MultiVector フィールドは、テキストを この trait でだけ埋め込みます。この trait を持たない Embedder(1 入力 1 本の ベクトルを出すモデル)は、1 トークンだけの文書を黙って作るのではなく、 エラーになります。
組み込み 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 |
sentence-transformers と同じ方法で符号化し、all-MiniLM-L6-v2 と
paraphrase-multilingual-MiniLM-L12-v2 では、出力のベクトルが
sentence-transformers と要素ごとに 1e-6 以内で一致します。
- 入力は、
sentence_bert_config.jsonにあるモデルのmax_seq_length(all-MiniLM-L6-v2は 256 トークン、paraphrase-multilingual-MiniLM-L12-v2は 128) で切り詰めます。特殊トークンも長さに含みます。このファイルがないリポジトリでは、tokenizer.jsonの切り詰めの長さ、それもなければモデルのmax_position_embeddingsを使います。tokenizer.jsonの padding の設定は使いません。 - ベクトルは、トークンのベクトルの平均を L2 正規化したものです。sentence-transformers
では正規化しないモデル(
paraphrase-multilingual-MiniLM-L12-v2など)や、別の方法で pooling するモデルでも、同じようにこの処理を行います。
CandleBertEmbedder::with_options(model, CandleBertOptions::default().revision("<commit>"))
で、モデルをコミットに固定できます。
マイグレーション注記(Issue #1340): 以前の版は attention mask を token type id としてモデルに渡し、すべての入力を
tokenizer.jsonの長さまで PAD で埋めていたため、 ベクトルがずれていました(all-MiniLM-L6-v2では、短い文で sentence-transformers との cosine が 0.61〜0.70 しかありませんでした)。candle_bertのベクトルは変わり、長い入力はall-MiniLM-L6-v2で 128 ではなくmax_seq_lengthトークンまで使うようになりました。candle_bertで作った索引は埋め込み直してください。そうしないと、新しいクエリの ベクトルが古い文書のベクトルと比べられます。マイグレーション注記(Issue #1355): 以前の版は、ダウンロードしたモデルを hf-hub 本来のデフォルトである
~/.cache/huggingface/hub/models--*ではなく~/.cache/huggingface/models--*にキャッシュしており、HF_HUB_CACHE・XDG_CACHE_HOMEも無視していました。モデルは今後 hf-hub 本来のデフォルトの 場所にダウンロードされ、Python のhuggingface_hubライブラリとキャッシュを 共有します。古いパスに残っている既存のダウンロードは再利用されないため、 削除してかまいません。
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 |
| 入力タイプ | テキストおよび画像 |
| ユースケース | テキストから画像への検索、画像から画像への検索 |
マイグレーション注記(Issue #1355): 以前の版は、ダウンロードしたモデルを hf-hub 本来のデフォルトである
~/.cache/huggingface/hub/models--*ではなく~/.cache/huggingface/models--*にキャッシュしており、HF_HUB_CACHE・XDG_CACHE_HOMEも無視していました。モデルは今後 hf-hub 本来のデフォルトの 場所にダウンロードされ、Python のhuggingface_hubライブラリとキャッシュを 共有します。古いパスに残っている既存のダウンロードは再利用されないため、 削除してかまいません。
CandleColbertEmbedder
BERT ベースの ColBERT のチェックポイントをローカルで実行し、トークンごとに
1 本のベクトルを出します。MultiVector フィールドに対する
late interaction による再採点
に使います。トークン単位の Embedder 専用で、embed() はエラーを返します。
Feature flag: embeddings-candle
#![allow(unused)]
fn main() {
use laurus::{CandleColbertEmbedder, CandleColbertOptions};
// Uses the checkpoint's own settings (artifact.metadata).
let embedder = CandleColbertEmbedder::new("colbert-ir/colbertv2.0")?;
// Pin the model commit and override the lengths.
let embedder = CandleColbertEmbedder::with_options(
"answerdotai/answerai-colbert-small-v1",
CandleColbertOptions::default()
.revision("934fa8bb4ce2284f4c2baa232d81aca4d076fa5e")
.doc_maxlen(300),
)?;
}
| プロパティ | colbert-ir/colbertv2.0 | answerdotai/answerai-colbert-small-v1 |
|---|---|---|
| トークンベクトルの次元数 | 128 | 96 |
クエリの長さ(query_maxlen) | 32 | 32 |
文書の長さ(doc_maxlen) | 180 | 300 |
| ライセンス | MIT | Apache-2.0 |
| 実行環境 | ローカル(CPU) | ローカル(CPU) |
参照実装の colbert-ai と同じ方法で符号化します。
query: [CLS] [unused0] w1 … wn [SEP] [MASK] … [MASK] exactly query_maxlen tokens
document: [CLS] [unused1] w1 … wn [SEP] at most doc_maxlen tokens
- クエリの
[MASK]による埋め草には attention を向けませんが、そのベクトルは 残します。クエリを拡張する役割を持つためです。 - 文書では、句読点のトークンのベクトルを取り除きます。
- どのベクトルも、チェックポイントの
linear層で射影してから L2 正規化します。
長さ、マーカー、これらの切り替えは、チェックポイントの artifact.metadata
から読みます(ファイルがない場合は colbert-ai の既定値の 32 と 220)。
CandleColbertOptions で長さを上書きできます。上の 2 つのチェックポイントでは、
出力のベクトルが colbert-ai と要素ごとに 1e-6 以内で一致します。
推論は CPU 上のブロッキングタスクで、最大 32 件ずつのバッチで行います。
目安として、Apple M4 ではクエリ 1 件が colbertv2.0 で約 55 ms、
answerai-colbert-small-v1 で約 18 ms、約 110 トークンの文書 1 件が約 135 ms と
約 45 ms です。
revision はコミットに固定してください。write-ahead log はまだコミットされて
いない文書のテキストを保持し、復旧時に埋め込み直すため、モデルが変わると
異なるベクトルになります。対応するのは BERT ベースのチェックポイントだけです
(lightonai/GTE-ModernColBERT-v1 のような ModernBERT ベースのものは対象外)。
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
}
トークン単位の Embedder を持つ MultiVector フィールドも、同じようにテキストを 受け付けます。テキストは文書として、トークンごとに 1 本のベクトルへ埋め込まれます。
検索時
テキストで検索すると、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 化するため、一貫したベクトル空間が保証されます。
late interaction による再採点も、クエリをテキストで受け取れます
(RescoreOptions::late_interaction_text)。MultiVector フィールドのトークン単位の
Embedder が、それをクエリとして埋め込みます。
Feature Flag まとめ
各 Embedder は Cargo.toml で特定の Feature Flag を有効にする必要があります。
| Embedder | Feature Flag | 依存関係 |
|---|---|---|
CandleBertEmbedder | embeddings-candle | candle-core, candle-nn, candle-transformers, hf-hub, tokenizers |
CandleColbertEmbedder | embeddings-candle | CandleBertEmbedder と同じ |
OpenAIEmbedder | embeddings-openai | reqwest |
CandleClipEmbedder | embeddings-multimodal | image + embeddings-candle |
PrecomputedEmbedder | (なし – 常に利用可能) | – |
embeddings-all Feature ですべての Embedding 機能を一括で有効にできます。詳細は Feature Flags を参照してください。
Embedder の選択
| シナリオ | 推奨 Embedder |
|---|---|
| クイックプロトタイピング、オフライン利用 | CandleBertEmbedder |
| 高精度が求められる本番環境 | OpenAIEmbedder |
| テキスト + 画像検索 | CandleClipEmbedder |
| late interaction による再採点(MultiVector フィールド) | CandleColbertEmbedder |
| 外部パイプラインからの事前計算済みベクトル | 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
FileStorage は use_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 が内部的に管理するため、直接操作する必要はありません。
バックエンドの選択
| 要因 | MemoryStorage | FileStorage | FileStorage (mmap) |
|---|---|---|---|
| 耐久性 | なし | 完全 | 完全 |
| 読み取り速度 | 最速 | 中程度 | 高速 |
| 書き込み速度 | 最速 | 中程度 | 中程度 |
| メモリ使用量 | データサイズに比例 | 少ない | OS 管理 |
| 最大データサイズ | RAM による制限 | ディスクによる制限 | ディスク + アドレス空間による制限 |
| 最適な用途 | テスト、小規模データセット | 一般的な利用 | 大規模な読み取り負荷の高いデータセット |
推奨事項
- 開発 / テスト: ファイルクリーンアップなしで高速に反復するために
MemoryStorageを使用 - 本番(一般): 信頼性の高い永続化のために
FileStorageを使用 - 本番(大規模): 大規模なインデックスがあり OS のページキャッシュを活用したい場合は
FileStorageのuse_mmap = trueを使用
次のステップ
- Lexical インデックスの仕組みを学ぶ: Lexical インデクシング
- Vector インデックスの仕組みを学ぶ: Vector インデクシング
インデキシング(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 when the buffer fills, and on commit()
ステップごとの流れ
- 解析(Analyze): テキストが設定されたアナライザー(トークナイザー + フィルター)を通過し、正規化されたタームのストリームが生成される
- バッファリング(Buffer): タームはフィールドごとに整理され、インメモリの書き込みバッファに格納される
- フラッシュとコミット(Flush and commit): バッファが
max_buffered_docs(デフォルト 10,000 件)か推定メモリmax_buffer_memory(デフォルト 64 MiB)に達すると、まだ見えない新しいセグメントにフラッシュされる。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(オプション) | ドキュメント内でタームが出現する位置(フレーズクエリに必要)。多値テキストフィールドの各要素は、フィールドの position_increment_gap で区切られた 1 本の昇順の位置列を共有する |
| Weight | このポスティングのスコアウェイト。既定値は 1.0 で、Laurus が書き出すポスティングは常にこの値を持つ。リスト内のすべてのウェイトが 1.0 の場合、ウェイトセクションはディスクから完全に省かれる(v3) |
On-Disk Posting レイアウト
Posting list は structure-of-arrays レイアウトで保存され、各フィールドが
それぞれ連続したセクションに書かれます。ドキュメント ID とターム頻度は
固定長の 128 整数ブロック単位でビットパックされ(ドキュメント ID は
Frame-of-Reference + ソート済みデルタ)、端数の末尾ブロックは varint に
フォールバックします。これは Tantivy および Lucene 9 が採用する on-disk
形式と同じで、bitpacking クレートに
よる SIMD デコードが効きます。
[term, total_frequency, doc_frequency, posting_count N, any_positions, any_weights]
[Skip levels — v2 以降: num_levels + レベルごと (len + u32 doc_ids)]
[Section 1: doc_ids — N/128 ビットパックブロック + varint 末尾]
[Section 2: frequencies — N/128 ビットパックブロック + varint 末尾]
[Section 3: weights — N 個の生 f32(any_weights == 1 のときのみ)]
[Section 4: positions — ポスティングごとのフラグ + varint デルタ(存在する場合のみ)]
2 つのヘッダフラグが 2 つの省略可能セクションを制御します。any_positions は
従来から Section 4 を制御しており、any_weights は v3 で追加され、同じ形で
Section 3 を制御します。Laurus が書き出すポスティングは常に既定値 1.0 の
ウェイトを持つため、writer が生成するセグメントでは Section 3 が常に省かれ、
posting list は v2 と比べて 4N - 1 バイト小さくなります ── ポスティング
あたり 4 バイトの削減から、リストあたり 1 バイトのフラグを差し引いた値です。
10 億ポスティングでは、書き込み・読み出し・キャッシュのいずれもしなくて
済むバイト数が約 4 GB になります。
この削減によって形式が lossy になることはありません。1.0 以外のウェイトが
1 つでもあればフラグが立って Section 3 が復活し、すべての値が厳密に往復します。
セグメント内のドキュメント ID は u32 に収まる必要があります。u32::MAX を
超える値のエンコードは、ビットパックされたセグメントを黙って破損させないよう、
明確なエラーで即座に失敗します。
デコーダは SoA ネイティブの高速パス(PostingList::decode_soa)を提供し、
中間的な Vec<Posting> の再構築を挟まずに doc_id と frequency の
parallel な Vec<u32> スライスをディスクから直接生成します。クエリ
イテレータはこのスライスを保持するため、next() は 40 バイトの Posting
構造体をストライドせず、密なスライス上で u32 カーソルを 1 つ進めるだけで
済みます。
Multi-Level Skip Table
N ≥ 8 ポスティングを持つ posting list はヘッダ直後に
Lucene-90 互換のマルチレベルスキップテーブル(v2 フォーマットで導入され、
v3 でも変更なし)を持ちます。各レベルは doc_ids 上の固定ストライド窓の “末尾 doc id” を
保持し、レベル 0 は SKIP_INTERVAL = 8 ポスティングごとに 1 エントリ、
レベル 1 は 8²、トップレベルは 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 から再構築し、v2 形式は無条件のウェイトセクションを
そのまま読みます。どのデコーダを使うかは、マジックとバージョン番号を持つ
唯一のセグメント単位ファイルである .dict のバージョンで決まるため、
posting のペイロード自体を推測的に読む必要はありません。
ディスクから読んだスキップテーブルは、デコードした doc_ids から
writer が作るものと完全に一致しなければなりません。デコーダはその場で
両者を照合し、一致しない表は破損したセグメントとして拒否します。
skip_to はこの形に頼ってストライドを範囲内に保っており、誤った
エントリは一致する文書を飛ばしてしまうためです。
Term Dictionary の 2 層構造
ディクショナリは ディスク と メモリ で別の表現を持ち、 それぞれの最適化目標を分離しています。
- ディスク層 —
.dictファイルは LuceneBlockTreeTermsWriter風のブロックツリーレイアウト(マジックLTDD、スキーマ v3)。 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 ] 全タームカウント + ブロックカウント
[Checksum footer ] 上記すべての CRC-32 + マジック "LCRC"
- 検索: 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 プリミティブにバックアップされています:
| フィールド型 | 次元数 | 座標 | サポートされるクエリ |
|---|---|---|---|
Geo | 2 | WGS84 緯度・経度(度) | 半径検索、バウンディングボックス |
Geo3d | 3 | ECEF 直交座標 (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[".norms (field-length norms)"]
S1 --- F8[".ids (doc ids)"]
| ファイル拡張子 | 内容 |
|---|---|
.dict | Term Dictionary。v3 LTDD ブロックツリーレイアウト(FST + 128 ターム単位の front-coded ブロック + bit-packed TermInfo)。セグメント open 時に AHashMap バックの in-memory クエリ層へ展開 |
.post | Posting Lists(ドキュメント ID、ターム頻度、位置情報)。このファイルを失ったセグメントは、索引の analyzer で stored document をスキャンして term クエリに答え(ヒットのスコアは 0)、警告ログを出す |
.bkd | 数値・日付・Geo(2D)・Geo3d(3D ECEF)フィールドの BKD ツリー データ |
.docs | 格納されたフィールド値(元のドキュメント内容)。チャンク単位(未圧縮で約16KiBまたは128ドキュメントのいずれか早い方)でLZ4圧縮され、圧縮による効果がないチャンクは無圧縮のままフォールバックする(SDOC v1、Issue #548) |
.dv | ソートおよびフィルタリング用の Doc Values |
.meta | セグメントメタデータ(ドキュメント数、ターム数など) |
.norms | 1バイトに量子化されたフィールド長の正規化値(BM25 スコアリング用、ドキュメント×フィールドごとに1バイト)。長さはトークン数ではなく position の数 —— 別のトークンにスタックされた同義語はその position を共有し、重ねて数えられない |
.ids | セグメントが実際に持つドキュメント ID の集合(Roaring bitmap)。削除はグローバルなドキュメント ID で行われるが、セグメントの ID 範囲には、そのセグメントが持たない ID が含まれうる(隣り合わないセグメントのマージのあとなど)。そのため削除は、セグメントに印を付ける前にこの集合を確かめる。reader も同じくこの集合を使い、セグメントの生きているドキュメント数(BM25 のドキュメント数)を「持っているドキュメント − その削除数」で求め、削除はそのドキュメントを持つセグメントでだけドキュメントを隠す。この形式より前に書かれたセグメントは .norms から答える |
セグメントのライフサイクル
- 作成(Create): writer のバッファが
max_buffered_docs(デフォルト 10,000 件)かmax_buffer_memory(デフォルト 64 MiB)に達するたびに新しいセグメントがフラッシュされ、commit()が残りをもう 1 つフラッシュする。commit()はそれらを一度に公開するので、1 回のコミットで複数のセグメントが増えることがある。両方の閾値はインデックス設定で指定する(LexicalIndexConfig::builder().max_buffered_docs(..)/.max_buffer_memory(..)) - 検索(Search): すべてのセグメントが並列に検索され、結果がマージされる
- マージ(Merge): 各
commit()後に自動マージが走り、セグメント数がmax_segmentsを超えると最小のセグメント群がマージされて数が有界に保たれる。手動optimize()は全セグメントを 1 つに強制マージする - 削除(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.2 と b = 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 インデキシング
- Lexical インデックスに対してクエリを実行する: Lexical 検索
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
ステップごとの流れ
- エンベディング(Embed): テキスト(または画像)が、設定されたエンベッダーによってベクトルに変換される
- 正規化(Normalize): ベクトルは Cosine メトリックの場合のみ L2 正規化される(大きさ不変なメトリック)。Euclidean / DotProduct / Manhattan のフィールドは距離を保つため元のベクトルのまま保存される
- インデキシング(Index): ベクトルが設定されたインデックス構造(Flat、HNSW、または IVF)に挿入される
- コミット(Commit):
commit()の呼び出し時に、インデックスが永続ストレージにフラッシュされる
インデックスタイプ
Laurus は 3 種類のベクトルインデックスタイプをサポートしており、それぞれ異なるパフォーマンス特性を持ちます。
比較
| 特性 | Flat | HNSW | IVF |
|---|---|---|---|
| 精度 | 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 パラメータ
| パラメータ | デフォルト | 説明 | 影響 |
|---|---|---|---|
m | 16 | レイヤーごとのノードあたりの最大双方向接続数 | 大きいほど再現率が向上するが、メモリ消費が増加 |
ef_construction | 200 | インデックス構築時の探索幅 | 大きいほど再現率が向上するが、構築が低速に |
dimension | 128 | ベクトルの次元数 | エンベッダーの出力と一致させる必要あり |
distance | Cosine | 距離メトリクス | 下記の距離メトリクスを参照 |
チューニングのヒント:
- 再現率を向上させるには
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_clusters | 100 | ボロノイセル(Voronoi Cell)の数 | クラスタ数が多いほど検索は高速になるが、再現率は低下 |
n_probe | 1 | クエリ時に検索するクラスタ数 | 大きいほど再現率が向上するが、検索が低速に |
dimension | (必須) | ベクトルの次元数 | エンベッダーの出力と一致させる必要あり |
distance | Cosine | 距離メトリクス | 下記の距離メトリクスを参照 |
チューニングのヒント:
n_clustersはベクトル数nに対してsqrt(n)程度に設定する- 再現率と速度のバランスを取るため、
n_probeをn_clustersの 5-20% に設定する - IVF はトレーニングフェーズが必要なため、初回のインデキシングが遅くなる場合がある
距離メトリクス(Distance Metrics)
| メトリクス | 説明 | 値の範囲 | 最適な用途 |
|---|---|---|---|
Cosine | 1 - コサイン類似度 | [0, 2] | テキストエンベディング(最も一般的) |
Euclidean | L2 距離 | [0, +inf) | 空間データ |
Manhattan | L1 距離 | [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 ビット (デフォルト) | Scalar8Bit | per-segment global affine による u8 量子化 | 約 4 倍 |
| プロダクト量子化 | ProductQuantization { subvector_count } | Issue #481 Stage 3 — M × 256 centroid の codebook(segment ごとに学習、または pq_codebook_path で一度だけ学習して共有)、各ベクトルを M バイトで保存 | 約 16-64 倍(HNSW のみ) |
#![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 ファイルは
LVS1magic + 16 byte header で始まり、reader はロード時にフォーマットを判定。header はバージョン(機能のはしご: v2 = HNSW グラフブロックの ordinal エンコード、v3 = セグメントごとの フィールド名辞書)を持ち、v3 以降は辞書そのものも header に載る — 各レコードはフィールド名をインラインで繰り返す代わりに 16 ビット ID で参照する。
Two-stage rerank(Issue #481 Stage 2)
Stage 1 ではベクトルを int8 のみで保持します。グラフ検索は完全に int8 距離に対して行われ、高速ですがわずかな量子化誤差が入ります。Stage 2 ではフィールド単位で 任意の f32 sidecar を追加し、上位候補を元の 完全精度ベクトルで再スコアできるようにします:
- HNSW int8 グラフ検索が量子化コサイン距離で最大
ef_search件の 候補を返す。 - 上位
top_k * rerank_factor件を LRS1 sidecar(*.hnsw.f32)から読み込んだ f32 ベクトルで再スコアする。 - 新しいランキングを
top_kに切り詰めて返す。
Issue #932 以降、同じ sidecar 機構は共有 RerankPipeline(#650)を
通じて Flat / IVF でも機能します(以下の説明は Stage 2 の元ホストである
HNSW を例にしています)。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_searchでRecall@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_gateはRecall@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_gateはRecall@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_diagnostic(LAURUS_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_data(laurus/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()
};
}
最小学習サイズ: PQ はサブ量子化器ごとに K = 256 個の k-means
セントロイドを学習するため、256 ベクトル未満のセグメントでは意味のある
codebook を学習できません。そのようなセグメントは Scalar8Bit で
書き出されます(LVS1 ヘッダは自己記述型のため、reader は保存された
種別で透過的にディスパッチします)。セグメントが成長するか、より大きな
セグメントへマージされれば、次の書き込みで設定どおり PQ が学習されます。
subvector_count が dimension を割り切る検証はセグメントサイズに
かかわらず常に適用されます。なお、
共有 codebook を設定した場合はこの
フォールバックも最小サイズも適用されません — セグメントごとの学習が
発生しないため、小さな per-commit セグメントもそのまま PQ を維持します。
subvector_count は dimension を割り切る値を選択。 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 用に枠を予約しています。
共有 PQ codebook(Issue #631)
デフォルトでは segment の書き込みごとに codebook をゼロから学習 します — segment-per-commit レイアウト(#634/#889)では、この数秒 オーダーの k-means コストが commit と merge のたびに繰り返され ます。 FAISS / Lucene99 の前例に倣い、 codebook を代表サンプルで 一度だけ学習して以後のすべての segment 書き込みで再利用でき、 encode はテーブル参照だけになります(2,048〜8,192 ベクトルで インライン学習比 約 91-92% 高速の実測値)。
有効化はフィールドに codebook ファイル名を設定して学習するだけです:
[fields.embedding.Hnsw]
dimension = 128
pq_codebook_path = "embedding.pqcb"
[fields.embedding.Hnsw.quantizer.ProductQuantization]
subvector_count = 32
laurus train pq-codebook --field embedding --input vectors.jsonl --update-schema
(--input の代わりに --from-index でインデックスにコミット済みの
ベクトルを直接サンプリングすることも、laurus create index --train-pq-codebook <jsonl> で学習をインデックス作成に畳み込んで
train-before-first-commit の順序ハザードを完全に解消することもできます
— いずれも Issue #920)、または
プログラムから
Engine::train_pq_codebook
(engine.train_pq_codebook("embedding", &vectors, None)。from-index
フローには engine.sample_committed_vectors("embedding", Some(n)) を
組み合わせます)を使います。学習は同期・CPU-bound で、代表的な
ベクトル数千件で十分です(全コーパスは不要)。
セマンティクス:
- segment format は不変。 共有 codebook も従来どおり各 segment header にインライン埋め込みされるため、共有 codebook の segment と インライン学習の segment はマイグレーション無しで共存でき、生成 される code は同一サンプルでインライン学習した場合と byte-identical です。
- codebook ファイルは index open 時に一度だけ解決されます。 エンジンを開いたまま再学習しても mid-session で hot-swap されません (意図的な仕様 — 1 セッションの commit 間で codebook が変わっては ならないため)。新しいファイルは再オープンで反映されます。
- 失敗ポリシー — 無言のフォールバック無し。
pq_codebook_pathが 設定済みで未学習の場合、 open は lenient(学習前にスキーマを作成 できる)ですが、 encode が必要な commit は実行すべき学習コマンドを 示すエラーで失敗します。ファイルは存在するが破損・ジオメトリ不一致の 場合は open 時にエラーです。 laurus が per-segment 学習へ無言で フォールバックすることはありません — 数秒オーダーの学習コストへの 見えない回帰は本機能の意義を損なうためです。 - Cosine メトリックのフィールドでは、 writer がインデックス時に行う L2 正規化と同じ正規化を学習サンプルにも適用し、 codebook の基底を 常に一致させます(#794 の罠への対処)。
- FastScan フィールドにも対応(Issue #920、
pq-fastscanfeature):ProductQuantizationFastScanフィールドは同じコマンド・同じファイル フォーマットで k=16 の共有 codebook を学習・エンコードします —.pqcbheader はkをそのまま保存するため 2 つの variant は 保存された値で区別され、k 不一致の codebook(例: k=256 のファイルを FastScan フィールドに設定)は両方の値を明示するエラーで commit が 失敗します。
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 bench —
bench_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 を埋める方向が 考えられます。
PQ → SQ → f32 の3段 rerank(Issue #673)
Stage 3 の rerank は PQ ADC の候補集合を top_k * rerank_factor まで
広げ、その先頭部分だけを exact な f32 サイドカーに対して rescore
します — グラフ探索がその予算を超えて見つけた候補は捨てられます。
以前は PQ フィールドで ef_search をこの予算より広く設定しても、
予算を超えた分には何の効果もありませんでした。#673 以降は、その
ef_search サイズの候補集合全体を、狭い exact-stage 予算で
切り出す前に安価な int8 kernel で再ランキングする int8(SQ)段
が追加で有効化されます — グラフがすでに計算済みのサープラス分が、
到着時点の PQ ADC 順序よりもずっと良い代理指標(int8)で競争する
機会を得られるようになり、単純に捨てられることがなくなります。
この int8 ビューは exact 段が読む 同じ LRS1 f32 サイドカーから 導出されます(初回利用時に一度だけ遅延学習し、セグメントの生存期間 中キャッシュされます)— 新たなディスク上サイドカーも、新しい フィールド設定も、新しいクエリパラメータも不要です。3段チェーンは 以下すべてを満たすときにクエリごと自動的に有効化されます:
- フィールドが PQ 量子化されている(
ProductQuantizationまたはProductQuantizationFastScan) - rerank サイドカー(
rerank_storage: Some(F32))がロード済み - クエリに
rerank_factorが指定されている effective_ef_search > top_k * rerank_factor— つまりグラフ探索が 実際に exact 段単体が消費する量より多くの候補を計算している場合。 そうでない場合(ef_searchをデフォルトのままにしている一般的な ケース)、SQ 段は何も絞り込まないため skip され、クエリはこれまでと 同一の2段(PQ → f32)経路をたどります。
score_basis の契約(Issue #927)はどちらの場合でも影響を受けません
— パイプラインは常に exact な f32 段で終わるため、SQ 段が事前に
走ったかどうかに関わらず、マルチセグメント fan-out はそのスコアを
引き続き exact でセグメント間比較可能な basis として扱います。
MultiVector ストレージ
上記の量子化方式は ANN インデックスタイプ(Flat、HNSW、IVF)に適用され
ます。MultiVector フィールド
は ANN 索引を持たないため quantizer は適用されません。代わりに独自の
storage オプション(Issue #1346)で、各トークンベクトルのディスク上
での要素形式を選びます。
| 種類 | Rust Enum バリアント | バイト数/要素 | 誤差 | 1 文書あたりのサイズ(300 × 128 トークン) |
|---|---|---|---|---|
| F32 (デフォルト) | MultiVectorStorage::F32 | 4 | 厳密 | 約 150 KB |
| F16 | MultiVectorStorage::F16 | 2 | 要素あたり約 2⁻¹¹ の相対誤差 | 約 75 KB |
| Int8 | MultiVectorStorage::Int8 | 約 1(1 行あたり dimension + 2 バイト) | ベクトルごとのスケール max(abs(vector)) / 127 で上界が決まる | 約 38 KB |
#![allow(unused)]
fn main() {
use laurus::{MultiVectorOption, MultiVectorStorage};
let opt = MultiVectorOption::new(128).storage(MultiVectorStorage::Int8);
}
quantizer の per-segment グローバルスケールとは異なり、Int8 のスケー
ルはベクトルごとです: scale = max(abs(vector)) / 127 を各トークン
ベクトルごとに個別に計算し、corpus や segment にまたがる学習済み状態は
持ちません。これにより segment のマージ時に行を再量子化せずバイト単位
でコピーできます。
既存フィールドの storage を変更するのは Reindex 扱いの変更です。
Engine::update_field を reindex: true で呼び出すと、フィールドの
既存セグメントを新しいディスク上の形式で再構築します。storage キーを
持たないスキーマ(このオプションが存在する前に書かれたもの)はデフォルト
の F32 として扱われ、マイグレーションは不要です。
セグメントファイル
| インデックスタイプ | ファイル拡張子 | 内容 |
|---|---|---|
| HNSW | .hnsw | グラフ構造、ベクトル、メタデータ |
| Flat | .flat | 生ベクトルとメタデータ |
| IVF | .ivf | クラスタセントロイド、割り当て済みベクトル、メタデータ |
3つのインデックスタイプはすべてデフォルトで segment-per-commit レイアウトを
使用します(下記参照)。単一ファイルの monolithic レイアウトは、インデックスを
直接利用する場合に {Flat,Hnsw,Ivf}IndexConfig { segmented: false, .. } で
引き続き選択できます。
segment-per-commit レイアウト(3タイプ共通のデフォルト)
#634(HNSW)および
#889(Flat・IVF)以降、各コミットは
新規追加ベクトルのみを不変の segment_NNNNNN.{hnsw,flat,ivf} ファイルとして
seal し、原子的・チェックサム付きの segments.json manifest に登録します —
コミットあたりのコストは O(インデックス) から O(新規ドキュメント) に下がり、
既存セグメントの全面的な作り直しも発生しません。
- 検索は sealed セグメント群を(新しい世代から順に)fan-out し、 同一ドキュメントの複製を newest-wins マスキングで重複排除します。 再インデックスされたドキュメントは常に最新の埋め込みで解決されます。 マージ待ちの stale 複製が深く積もっている場合も、セグメントごとの over-fetch 予算を幾何級数的に拡張し、マスキング後も十分な生存 hit が 残るまで再取得する適応的 over-fetch により、結果品質は安定します。
- マージ: 世代連続・サイズ類似の tiered マージポリシーがインジェストの
進行に応じてセグメント列を集約し(各ベクトルの生涯書き直し回数は
O(log N))、
optimize()は全セグメントを 1 つに force-merge します。 IVF のマージはセグメントごとの cluster ID を引き継ごうとしません (セグメントをまたいだ意味を持たないため)— 重複排除済みの生存ベクトルを 平坦化し、union サイズに応じて適応的に再計算した cluster 数で centroid を ゼロから再学習します。Flat には f32 rerank サイドカーによるフォールバックが ないため、マージのたびに dequantize → scalar quantization パラメータの 再学習 → requantize が発生し、HNSW/IVF のほぼロスレスな再量子化経路とは 異なり、マージを重ねるごとにわずかな量子化ドリフトが蓄積します。 - セグメントごとの学習: HNSW のグラフと IVF の centroid はいずれも
セグメントごとに独立して構築されます(IVF はスキーマの
n_clustersを 上限としてセグメント自身のサイズに適応した cluster 数を学習します)。 検索はセグメントごとに fan-out して top-k をマージするため、 どちらのインデックスタイプもセグメント間の合意は不要です。 - 削除は論理削除です。インデックスレベルの bitmap(
{name}.delmap)を すべてのセグメントリーダーが参照し、マージ時に物理回収されます。 - 移行はゼロコピーです。既存の monolithic セグメントファイルは初回 オープン時にそのまま最初のセグメントとして登録されます — manifest への 1書き込みのみで、データ移動はありません。
- 耐久性: セグメントファイルと manifest は temp ファイル + fsync + 原子的 rename で書かれ、manifest はカバー対象の状態がすべて durable に なった後にのみ公開される WAL checkpoint を保持します — 永続化を参照。
コミット跨ぎの writer 保持(monolithic レイアウトのみ)
writer の保持は monolithic レイアウト(segmented: false)にのみ
適用されます。segmented な writer は常に空のバッファから始まるため、
保持すべき状態がそもそも存在しません。monolithic レイアウトの HNSW では、
ストアは writer をコミットを跨いでキャッシュし続けます(Issue #864)。
コミット直後の writer のメモリ内状態は、たった今書き出したファイルと等価で
あるため、コミット後最初の upsert はストレージから .hnsw 全体をリロード
(+ 全 dequantize)する代わりに、その場で writer を拡張します —
コミットの多いインジェストでは、このリロードがサイクルあたり O(インデックス
サイズ) のコストでした。ただし、writer の裏でインデックスが書き換えられる
場合 — コミット中の auto-compaction 発火、または明示的な optimize() —
はキャッシュを破棄します。どちらも fresh writer 経由でファイルを再構築して
deletion bitmap をクリアするため、stale な保持 writer は回収済みベクトルを
書き戻してしまうからです。また、コミットが途中で失敗した場合も writer と
ディスクの一致が不明になるため破棄します。副次効果として、保留変更のない
保持 writer はインデックス書き出し自体をスキップするため、変更なしの
commit() は .hnsw への書き込みコストがゼロになります。monolithic の
Flat と IVF は従来のコミット時破棄の挙動を維持しており、同じ不変条件の
監査はまだ済んでいません — 現在この経路に到達するには segmented デフォルトを
明示的にオプトアウトする必要があります。
コード例
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 のデフォルト設定)。多値テキストフィールド(multi_valued = true)では、各要素がフィールドの position_increment_gap(デフォルト 100)で区切られた 1 本の位置列を共有するため、slop がその gap に達しない限りフレーズが 2 つの要素をまたぐことはありません —— 詳細は スキーマとフィールド を参照してください。
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,
);
}
数値範囲クエリは**定数スコア(constant scoring)**です。マッチしたすべての
ドキュメントは同じスコア(クエリの boost、デフォルト 1.0)を受け取ります。
これは Lucene の PointRangeQuery のセマンティクスに従うものです。マッチングには
セグメントに BKD tree があればそれを使用し、対象フィールドの BKD tree を持たない
セグメント(例: そのフィールドを持つドキュメントが 1 件もないセグメントや、
indexed = false, stored = true と設定されたフィールド)では、そのセグメントに
実際に存在する保存済みドキュメントのみを走査するフォールバックを使用します。
DateTimeRangeQuery
DateTime フィールドの値が指定された範囲内にあるドキュメントをマッチングします。
#![allow(unused)]
fn main() {
use chrono::{TimeZone, Utc};
use laurus::lexical::DateTimeRangeQuery;
// Find documents where "created_at" is within 2024 (both bounds inclusive)
let query = DateTimeRangeQuery::between(
"created_at",
Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(),
Utc.with_ymd_and_hms(2024, 12, 31, 23, 59, 59).unwrap(),
);
// Open-ended range: created_at > 2024-06-15T12:00:00Z (exclusive)
let query = DateTimeRangeQuery::after(
"created_at",
Utc.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap(),
);
// From textual bounds, using the same literal grammar as the query DSL
let query = DateTimeRangeQuery::from_literals(
"created_at",
Some("2024-01-01"), // min (date only = midnight UTC)
Some("2024-12-31T23:59:59Z"), // max
true, // include min
true, // include max
)?;
}
new(field, lower, upper, lower_inclusive, upper_inclusive) は境界を
Option<DateTime<Utc>> で受け取ります。片側だけのヘルパーとして
on_or_after・before・on_or_before もあり、.with_boost() で定数スコアを
設定します。クエリはフィールドの 1 次元 BKD ポイント(マイクロ秒の小数部を持つ秒。
秒未満の境界も正確に比較されます)に対して評価され、定数スコアと保存済み
ドキュメントへのフォールバックは NumericRangeQuery と同じです。from_literals は
RFC 3339、オフセットなしの YYYY-MM-DDTHH:MM:SS[.fff](UTC)、YYYY-MM-DD
(その日の 0 時 UTC)を受け付け、DSL の created_at:[2024-01-01 TO 2024-12-31] が
構築するクエリと同一です(Query DSL 参照)。
DateTime フィールドに対してエポック秒を境界とする NumericRangeQuery も
従来どおり動作します。
GeoDistanceQuery / GeoBoundingBoxQuery
2D 地理座標(WGS84 緯度・経度)に基づいてドキュメントをマッチングします。
#![allow(unused)]
fn main() {
use laurus::lexical::query::geo::{GeoBoundingBoxQuery, GeoDistanceQuery};
// Find documents within 10 km (= 10 000 m) of Tokyo Station (35.6812, 139.7671)
let query = GeoDistanceQuery::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 = GeoBoundingBoxQuery::within_bounding_box(
"location",
35.0, 139.0, // min (lat, lon)
36.0, 140.0, // max (lat, lon)
)?;
}
どちらのクエリも距離ベースでスコアリングします(近いドキュメントほど高スコア。
円の中心、またはボックスの中心からの線形減衰)。マッチングにはセグメントに
BKD tree があればそれを使用し、座標は tree 自体から直接読み取ります —
そのためインデックスのみのフィールド(indexed = true, stored = false)でも
保存済みドキュメントなしで動作します。対象フィールドの BKD tree を持たない
セグメント(例: そのフィールドを持つドキュメントが 1 件もないセグメントや、
indexed = false, stored = true と設定されたフィールド)では、そのセグメントに
実際に存在する保存済みドキュメントのみを走査するフォールバックを使用します。
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);
}
| クエリ | スコア |
|---|---|
Geo3dDistanceQuery | 1 - distance / radius を [0, 1] にクランプ |
Geo3dBoundingBoxQuery | マッチした全ドキュメントで定数 1.0 |
Geo3dNearestQuery | 最も近いヒットが 1.0、返却された集合内で最も遠いヒットが 0.0 となるよう正規化 |
geo3d クエリはフィールドがインデックスされていること(indexed = true、
デフォルト)を必要とします。これらは完全にフィールドの BKD tree 上で動作し、
BKD tree を持たないセグメントからはヒットを返しません — 3D クエリには
保存済みドキュメントへのフォールバックはありません。
SpanQuery
ドキュメント内のタームの近接度に基づいてマッチングします。SpanTermQuery と SpanNearQuery を使用して近接クエリを構築します。
#![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 のメソッドで設定します。これらは SearchRequest の lexical_options フィールドに格納されます。
| オプション | デフォルト | 説明 |
|---|---|---|
field_boosts | 空 | フィールドごとのスコア倍率 |
min_score | 0.0 | 最小スコア閾値 |
timeout_ms | None | 検索の時間予算(ミリ秒、下記の注記を参照) |
parallel | false | セグメント間の並列検索を有効にする |
sort_by | Score | 関連性スコアでソート、またはフィールドでソート(asc / desc) |
highlight | None | 各ヒットの SearchResult::highlights に含めるフィールドと設定(ハイライト参照) |
フィールドソート検索(sort_by: Field { .. })は常に全候補ドキュメントを走査します。
スコアソートで利用可能な block-max ベースの早期終了とは異なり、フィールドソートには早期終了が
ありません。これにより返るヒットは早期終了による近似ではなく真の top-K であることが保証され、
total_hits も走査打ち切りによる件数ではなく真のマッチ数を反映します。
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, SortOrder};
let request = SearchRequestBuilder::new()
.lexical_query(LexicalSearchQuery::Obj(Box::new(TermQuery::new("body", "rust"))))
.lexical_min_score(0.5)
.lexical_timeout_ms(5000)
.lexical_parallel(true)
.sort_by(SortField::Field { name: "date".to_string(), order: SortOrder::Desc })
.add_field_boost("title", 2.0)
.add_field_boost("body", 1.0)
.limit(20)
.build();
}
.highlight(fields) と .highlight_config(config) は各ヒットのハイライト済みフラグメントを要求します。詳細はハイライトを参照してください。
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]")?;
// DateTime range (date-only bounds are midnight UTC)
let query = parser.parse("created_at:[2024-01-01 TO 2024-12-31]")?;
}
完全な構文リファレンスは 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_dsl や LexicalSearchQuery::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 の予算(デフォルト
128 MiB。reader の term-info キャッシュの上限も兼ねる)を持つため、最悪の使用量はセグメント数に比例して
増えます。インデックス設定で制御できます。
#![allow(unused)]
fn main() {
use laurus::lexical::store::config::LexicalIndexConfig;
let config = LexicalIndexConfig::builder()
.enable_posting_cache(false) // 完全に無効化
.build();
let config = LexicalIndexConfig::builder()
.max_cache_memory(256 * 1024 * 1024) // またはセグメントごとの予算(バイト)を変更
.build();
}
次のステップ
Vector 検索
Vector 検索は、意味的類似性によってドキュメントを検索します。キーワードのマッチングではなく、ベクトル空間におけるクエリの意味とドキュメントエンベディングを比較します。
基本的な使い方
Builder API
#![allow(unused)]
fn main() {
use laurus::SearchRequestBuilder;
use laurus::vector::VectorSearchRequestBuilder;
let request = SearchRequestBuilder::new()
.vector_query(
VectorSearchRequestBuilder::new()
.add_text("embedding", "systems programming language")
.limit(10)
.build()
)
.build();
let results = engine.search(request).await?;
}
add_text() メソッドはテキストをクエリペイロードとして格納します。検索時に、エンジンが設定されたエンベッダーを使用してテキストをエンベディングし、ベクトルインデックスを検索します。
Query DSL
#![allow(unused)]
fn main() {
use laurus::vector::VectorQueryParser;
let parser = VectorQueryParser::new(embedder.clone())
.with_default_field("embedding");
let request = parser.parse(r#"embedding:"systems programming""#).await?;
}
VectorSearchRequestBuilder
Builder API により、きめ細かな制御が可能です。
#![allow(unused)]
fn main() {
use laurus::vector::VectorSearchRequestBuilder;
use laurus::vector::store::request::QueryVector;
let request = VectorSearchRequestBuilder::new()
// Text query (will be embedded at search time)
.add_text("text_vec", "machine learning")
// Or use a pre-computed vector directly
.add_vector("embedding", vec![0.1, 0.2, 0.3, /* ... */])
// Search parameters
.limit(20)
.build();
}
メソッド
| メソッド | 説明 |
|---|---|
add_text(field, text) | 特定のフィールドに対するテキストクエリを追加(検索時にエンベディング) |
add_vector(field, vector) | 特定のフィールドに対する事前計算済みクエリベクトルを追加 |
add_vector_with_weight(field, vector, weight) | 明示的なウェイトを持つ事前計算済みベクトルを追加 |
add_payload(field, payload) | エンベディング対象の汎用 DataValue ペイロードを追加 |
add_bytes(field, bytes, mime) | バイナリペイロードを追加(例: マルチモーダル用の画像バイト) |
field(name) | 検索を特定のフィールドに制限 |
fields(names) | 検索を複数のフィールドに制限 |
limit(n) | 結果の最大件数(デフォルト: 10) |
score_mode(VectorScoreMode) | スコア結合モード(WeightedSum、MaxSim、LateInteraction) |
min_score(f32) | 最小スコア閾値(デフォルト: 0.0) |
overfetch(f32) | オーバーフェッチ係数(デフォルト: 2.0、#675)。各クエリは ceil(limit × overfetch) 件の候補を取得し、融合後に limit へ切り詰める。<= 1.0 で無効化 |
build() | VectorSearchRequest を構築 |
マルチフィールド Vector 検索
単一のリクエストで複数のベクトルフィールドを横断して検索できます。
#![allow(unused)]
fn main() {
let request = VectorSearchRequestBuilder::new()
.add_text("text_vec", "cute kitten")
.add_text("image_vec", "fluffy cat")
.build();
}
各クエリ句はベクトルを生成し、対応するフィールドに対して検索されます。結果は設定されたスコアモードで結合されます。
スコアモード
| モード | 説明 |
|---|---|
WeightedSum(デフォルト) | すべてのクエリ句にわたる(類似度 * ウェイト)の合計 |
MaxSim | クエリ句間の最大類似度スコア |
LateInteraction | ベクトルフィールドは 1 文書につき 1 本のベクトルを持つため、現在は WeightedSum と同じ。トークン単位(ColBERT 型)の late interaction には late interaction による再採点を使う |
マルチベクトル検索の並列実行
複数のクエリベクトル(複数のフィールドや、スコアモードで結合する複数の クエリ埋め込みなど)を含むリクエストに対して、Laurus は rayon を使ってクエリごとの類似度検索を並列に実行します。
動作:
- 並列化は
VectorIndexSearcher::search_batchトレイトメソッドの デフォルト実装内に置かれています。ネイティブビルド(nativefeature が デフォルトで有効)では、queries.len()が searcher のparallel_threshold(デフォルト4、 searcher 単位で override 可能) に達した時点で、HNSW / Flat / IVF のクエリごとの検索が rayon のグローバル スレッドプール上で並列実行されます。これを下回るとシリアルループのほうが 速くなります(rayon のディスパッチオーバーヘッド ~1-2 µs が、単一クエリの 50-200 µs を上回りやすいため)。 wasm32ターゲットでは rayon が使用できないため、常にシリアルパスを 通ります。- 集約(スコアモードによるマージ)と最終ソートはクエリ並列フェーズの後に
シリアル実行されます。スコアが同点の場合は
doc_idの昇順でタイブレーク するため、rayon のワークスチール順序に依存しない決定的な結果になります。
外部 API(VectorStore::search、gRPC の Search、REST の
POST /v1/search、各言語バインディング)は一切変更されません。並列実行は
完全に内部最適化で、laurus をアップグレードするだけで有効になります。
speedup はホストの利用可能コア数に応じてスケールアップします(4 物理コア /
8 スレッドの HT 有効ラップトップ CPU では、B = 64 クエリ時にスループットが
およそ 2× 向上します。これは物理コア数と HT 共有による上限に近い値です)。
ブルートフォーススキャンの並列化
Flat と IVF インデックスは、グラフ探索ではなく総当たりの距離スキャンで候補を
ランク付けします。1 つのクエリの候補数が内部しきい値(2048)に達すると、
そのスキャンは rayon のグローバルスレッドプールに分散されます。
これを下回るとシリアルループのほうが速くなります(rayon のジョブごとの
ディスパッチ ~1-2 µs が小さなスキャンを上回るため)。
これは上記のクエリ単位の並列化とは直交します。バッチはクエリ間で並列化し、
さらに各大規模クエリは同じプール上で自身のスキャンを並列化します。ワーク
スチールにより全体の並列度はプールサイズに制限されます(OS スレッドの
oversubscription は発生しません)。距離カーネルは副作用を持たないため、結果は
任意の順序で収集された後にソートされ、出力は決定的に保たれます。wasm32
(rayon なし)では常にシリアルです。
speedup はホストの物理コア数に応じてスケールし、大きな Flat インデックスや
広い IVF n_probe で最大になります。しきい値未満のスキャンは影響を受けません。
IVF クラスタ選択
距離スキャンの前に、IVF クエリはまず どの クラスタをスキャンするかを選びます。
クエリを全セントロイドと照合し、最も近い n_probe 個を残します。プローブした
クラスタはこの後にマージされ類似度で再ランク付けされるため、セントロイドの
相対順序は無関係です。したがって最近傍 n_probe 個は、K 個全体の完全ソート
(O(K log K))ではなく O(K) の部分選択(select_nth_unstable_by)で取得
します。削減効果はクラスタ数 K が大きいほど増え、K = 2048 ではクエリ
あたりの粗選択ステップを約 18% 短縮しました(Issue #668)。セントロイド
スキャン自体はシリアルのままです。各セントロイドは単一の距離計算であり、
現実的なクラスタ数(K ≈ √N)では rayon へのディスパッチが節約分を上回る
ためです。
ウェイト
DSL では ^ ブースト構文を使用するか、QueryVector の weight で各フィールドの寄与度を調整します。
text_vec:"cute kitten"^1.0 image_vec:"fluffy cat"^0.5
これは、テキストの類似度が画像の類似度の 2 倍の重みを持つことを意味します。
vector フィールドのスキーマレベルの base_weight(Issue #1084)も同様に
機能しますが、クエリ単位の上書きではなくフィールド単位のデフォルト値
である点が異なります。上記のクエリ重みに乗算されるため、base_weight
と ^/weight は組み合わさります。どちらも vector 側自体のスコアリング
にしか影響しないため、ハイブリッド検索の lexical-vs-vector のバランス
を変えることはできません。FusionAlgorithm::RRF はランクのみを見て元の
スコアを完全に無視しますし、FusionAlgorithm::WeightedSum は
lexical_weight/vector_weight を適用する前に各側を min-max 正規化
するため、フィールド単位の一様なスカラー倍は打ち消されます。両方の
重み付け機構が実際に影響するのは、1 回のクエリで複数の vector フィール
ドを対象にしたときの、フィールド間の相対的な優先度です。
このフィールド単位の加算が行われるのは、クエリが対象フィールドを明示
した場合のみです。フィールド未指定のクエリには参照すべき単一フィール
ドが無いため base_weight を適用できず(Issue #1084)、同一次元を持つ
複数フィールドに文書がマッチした場合は、加算ではなくその文書の最も
良くマッチしたフィールドを採用します(Issue #1343。後述の
フィールドルーティング を参照)。
フィールドルーティング
マルチフィールドスキーマでは、各 vector フィールドが独自の HNSW graph を 持ちます。デフォルトではクエリは全 vector フィールドを検索しますが、指定し たフィールドのみに限定すると、それ以外のフィールドの graph 探索を完全に 省略できます。
2 つのルーティング入力が、以下の優先順位で尊重されます。
- per-query —
QueryVector.fields。DSL パーサがクエリ句の指定する フィールドからこれを設定するため、image_vec:"fluffy cat"はimage_vecのみを検索します。 - request-level —
VectorSearchParams.fields(セレクタのリスト):Exact("image_vec")— フィールド名の完全一致。Prefix("image_")— 指定 prefix で始まる全フィールドに一致 (インデックスのフィールド名から解決)。
どちらも未指定の場合は全フィールドを検索します(デフォルト)。フィールドに ルーティングされたクエリは、そのフィールドに vector を持たないドキュメントを 返しません。
検索対象の複数フィールドに vector を持つ文書も、返るのは 1 回だけです。
フィールド未指定のクエリの fan-out は、その文書の最も良くマッチした
フィールドに畳み込まれるため(Issue #1343)、limit/top_k は
(文書, フィールド) のペアではなく文書を数えます。これは、フィールドを
明示した複数フィールドクエリ(fields: [Exact("a"), Exact("b")])が
各フィールドのスコアを加算するのとは異なります。詳しくは前述の
ウェイト を参照してください。
フィルター付き Vector 検索
Lexical フィルターを適用して Vector 検索の結果を絞り込むことができます。
#![allow(unused)]
fn main() {
use laurus::SearchRequestBuilder;
use laurus::lexical::TermQuery;
use laurus::vector::VectorSearchRequestBuilder;
// Vector search with a category filter
let request = SearchRequestBuilder::new()
.vector_query(
VectorSearchRequestBuilder::new()
.add_text("embedding", "machine learning")
.build()
)
.filter_query(Box::new(TermQuery::new("category", "tutorial")))
.limit(10)
.build();
let results = engine.search(request).await?;
}
フィルタークエリはまず Lexical インデックス上で実行されて許可されるドキュメント ID のセットを特定し、その後 Vector 検索がそれらの ID に制限されます。
filter-aware HNSW トラバーサル
HNSW フィールドでは、許可 ID セットは検索後に適用するだけでなく、graph 探索 そのものに渡されます。探索中、フロンティアはすべての近傍(非マッチの 近傍も含む)を経由して展開されるため、graph 上の非マッチ領域を横断して マッチに到達できますが、結果セットに入るのはマッチするドキュメントのみです。
これは選択的フィルターで重要になります。単純な post-filter は最近傍の固定
ef_search ウィンドウだけを見てマッチしたものを残すため、マッチが希少だと
ウィンドウの外に完全に外れてしまい、到達可能なマッチが存在しても結果が実際
より大幅に少なく(時にはゼロに)なります。filter-aware トラバーサルは十分な
マッチを集めるまで探索を続け、非常に選択的なフィルターでは内部の訪問上限
(ef_search の定数倍)で latency を抑えます。
フィルターなしの経路は不変です(フィルターがなければ従来どおりの挙動)。 Flat / IVF フィールドは許可セットをインラインで適用します。ドキュメント ID が セットに含まれない候補は距離カーネルの実行前にスキップされるため、選択的フィルター では post-filter が払う無駄な距離計算を回避できます。いずれにせよスキャンは網羅的 なので recall は変わりません。store 側の post-filter は冗長な安全網として後段で 引き続き実行されます。
許可セットが ef_search より小さい場合、HNSW フィールドは graph 探索を完全に
スキップし、許可されたドキュメントを直接採点します。候補がこれほど少ないと
graph で「探す」ものは無く、直接スキャンは許可ドキュメントだけを(graph 探索が
触れる数を超えずに)採点し、しかも exact です。そのため非常に選択的な
フィルターでは、近似ではなく真の最近傍マッチが返ります。許可セットが大きい場合は
上記の filter-aware トラバーサルを引き続き使います。
deletion-aware HNSW トラバーサル
論理削除されたドキュメントはコンパクションまで HNSW graph に残るため (削除とコンパクション を参照)、トラバーサルは フィルター除外と同じ方法で削除ノードをスキップする必要があります。graph 探索は 単一の admission ルールを適用します。ノードが結果集合に入るのは、フィルターに マッチし(フィルターがある場合)かつ 削除されていない場合のみで、frontier は 連結性を保つために削除ノードも通過します。
これにより削除が蓄積しても recall が正しく保たれます。削除ノードを result heap に
入れてしまうと、固定の ef_search スロットを占有して生存ネイバーを押し出し、
最悪の場合は ef_search ウィンドウが削除ドキュメントだけで埋まって何も返らなく
なります。トラバーサル中にスキップすれば同じスロットが生存結果で埋まるため、
最近傍のドキュメントを削除しても 10 件のページは満杯のままです。上記の小さな
許可セットの exact スキャンや Flat / IVF のインライン経路も同じ削除チェックを
適用します。
高速経路は維持されます。フィルターも削除も無い検索では、トラバーサルは従来の ループをそのまま実行し、ネイバーごとの admission 用ブックキーピングのコストを 一切払いません。
許可セットの表現(Allow-Set Representation)
許可セットは形状に応じて型が選ばれます。密なフィルターには Roaring ビットマップ、 疎なフィルターにはハッシュセットを使います。フィルター付きハイブリッド検索では、 lexical 側がマッチ集合をビットマップとして既に構築している (lexical の フィルタ結果キャッシュ 参照)ため、そのビットマップを vector 側にそのまま渡します。これにより集合は クエリ全体で一度だけ実体化され、両側で再構築されません。これは内部最適化であり、 公開フィルター API は不変です。
数値範囲によるフィルター
#![allow(unused)]
fn main() {
use laurus::lexical::NumericRangeQuery;
use laurus::lexical::core::field::NumericType;
let request = SearchRequestBuilder::new()
.vector_query(
VectorSearchRequestBuilder::new()
.add_text("embedding", "type systems")
.build()
)
.filter_query(Box::new(NumericRangeQuery::new(
"year", NumericType::Integer,
Some(2020.0), Some(2024.0), true, true
)))
.limit(10)
.build();
}
Late Interaction による再採点(Rescore)
late interaction による再採点(Issue #1345)は、lexical・vector・ハイブリッドの どの検索でも、上位の候補を ColBERT 型の late interaction で並べ替えます。 各文書は MultiVector フィールド にトークンごとのベクトルを持ち、クエリも同じくトークンベクトルの集合です。 文書のスコアは、クエリの各ベクトルについて文書のベクトルの中で最もよく合う ものとの類似度を求め、それを合計した値(MaxSim)です。
score(q, d) = Σ_i max_j (q_i · d_j)
トークン単位で照合するため、文書全体を 1 本の埋め込みにすると平均化されて 失われる細部を拾えます。一方で 1 段目の検索が速度を保ちます。再採点するのは 上位の候補だけなので、MultiVector フィールドに ANN 索引は要りません。
クエリ ─→ 1 段目: lexical / vector / ハイブリッド(通常の検索)
│ 上位 window_size 件(既定 100)
▼
再採点: MultiVector フィールドに対する MaxSim
▼
offset / limit → 文書を取得
MultiVector フィールドにトークン単位の Embedder(candle_colbert
など。CandleColbertEmbedder を参照)が
あれば、文書をテキストのまま取り込め、クエリもテキストで渡せます。
#![allow(unused)]
fn main() {
use laurus::{RescoreOptions, SearchRequestBuilder};
let request = SearchRequestBuilder::new()
.query_dsl("body:lifetimes body_vec:\"how do lifetimes work\"")
.rescore(
RescoreOptions::late_interaction_text("body_colbert", "how do lifetimes work")
.window_size(100),
)
.limit(10)
.build();
let results = engine.search(request).await?;
}
Embedder がない場合は、文書のトークンベクトルを作ったのと同じモデルで、 クエリのトークンベクトルを計算して渡します。
#![allow(unused)]
fn main() {
use laurus::RescoreOptions;
use laurus::vector::Vector;
let query_tokens: Vec<Vector> = colbert_query_vectors("how do lifetimes work");
let rescore = RescoreOptions::late_interaction("body_colbert", query_tokens);
}
再採点は、Rust API のほか、gRPC、
HTTP gateway、
MCP の search ツール、
laurus search、
Python・
Ruby・
PHP・
Node.js・
WASM の各バインディングでも使えます。
オプション
| フィールド | 型 | デフォルト | 説明 |
|---|---|---|---|
window_size | usize | 100 | 再採点する 1 段目の上位候補の件数。1..=10,000 |
rescorer | Rescorer | — | Rescorer::LateInteraction { field, query }: 対象の MultiVector フィールドとクエリ。クエリはトークンベクトル(LateInteractionQuery::Vectors)かテキスト(LateInteractionQuery::Text) |
テキストのクエリは、1 段目の検索の前に、フィールドのトークン単位の Embedder が
クエリとして埋め込みます。
EngineBuilder::embedding_cache_capacity
を設定していれば、同じクエリ(次のページなど)はキャッシュから返ります。
クエリのベクトルは 1〜1,024 本で、どれもフィールドの次元と一致し、有限値で
なければなりません。オプションが不正な場合、対象が MultiVector フィールドで
ない場合、テキストのクエリが空かフィールドにトークン単位の Embedder がない場合、
フィールドによるソート(sort_by に SortField::Field)を同時に指定した場合は、
検索を始める前に LaurusError::InvalidArgument で失敗します。フィールドによる
ソートはスコアで並べないため、再採点する対象がないからです。
並び順とスコア
- window(1 段目の上位
window_size件)を MaxSim の降順に並べます。 同点は内部の文書 ID の順にするため、並び順は決定的です。 - フィールドにトークンベクトルを持たない window 内の候補を、1 段目の順で 続けます。
- window の外の候補を、元の順のまま最後に続けます。
再採点した結果の score は MaxSim の値になり、それ以外の結果は 1 段目の
スコアのままです。2 種類のスコアは比較できないため、グループはスコアでは
なく位置で並べます。
1 段目は max(window_size, offset + limit) 件の候補を取得するため、どの
ページも同じ再採点済みの順位から切り出されます。ページを 1 つずつ取得して
つなげると、1 回で大きなページを取得したときと同じ順位になります。ページが
window の外に及ぶ場合も同じです。window を広げると 1 段目で下位だった文書を
引き上げられますが、コストは window に比例して増えます。1 段目で取得されな
かった文書は、再採点でも拾えません。
類似度
クエリのベクトルと文書のベクトルの類似度は内積です。
DistanceMetric::Cosine(デフォルト)のフィールドでは、文書のベクトルを
書き込み時に、クエリのベクトルを検索時に L2 正規化するため、各ペアはコサイン
類似度になり、スコアはクエリのベクトルの本数を超えません。
DistanceMetric::DotProduct ではベクトルをそのまま使います。
フィールドに保存されたベクトルは圧縮されている場合もあります。
storage オプション
は、既定の厳密な F32 の代わりに F16 や Int8 でトークンベクトルを
符号化でき、インデックスを小さくする代わりにスコアの精度をわずかに
犠牲にします — F16 は要素あたり約 2⁻¹¹ の相対誤差、Int8 の誤差は
ベクトルごとのスケール(max(abs(vector)) / 127)で上界が決まります。
コスト
再採点の計算量は window_size × クエリのベクトル数 × 文書のベクトル数 回の
内積です。ネイティブビルドでは候補を rayon のグローバル
スレッドプールで並列に採点し、wasm32 では 1 件ずつ採点します。目安として、
128 次元のベクトルを 300 本持つ候補 100 件を 32 本のクエリで再採点すると
(約 1 億 2,300 万回の積和)、Apple M4 で 1 回の検索あたり約 2 ms、1 スレッド
では約 7 ms でした。
距離メトリクス(Distance Metrics)
距離メトリクスはスキーマでフィールドごとに設定されます(Vector インデキシング を参照)。
| メトリクス | 説明 | 小さい値 = より類似 |
|---|---|---|
| Cosine | 1 - コサイン類似度 | はい |
| Euclidean | L2 距離 | はい |
| Manhattan | L1 距離 | はい |
| 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 のフィールド
| フィールド | 型 | デフォルト | 説明 |
|---|---|---|---|
query | SearchQuery | Dsl("") | 検索クエリ仕様(Dsl / Lexical / Vector / Hybrid) |
limit | usize | 10 | 返される結果の最大件数 |
offset | usize | 0 | スキップする結果の数(ページネーション用) |
fusion_algorithm | Option<FusionAlgorithm> | None(ハイブリッド時は RRF { k: 60.0 } を使用) | Lexical と Vector の結果をマージする方法 |
filter_query | Option<Box<dyn Query>> | None | Lexical クエリによるプレフィルター(Lexical と Vector の両方の結果を制限) |
lexical_options | LexicalSearchOptions | デフォルト | Lexical 検索の動作パラメータ |
vector_options | VectorSearchOptions | デフォルト | Vector 検索の動作パラメータ |
rescore | Option<RescoreOptions> | None | フュージョン後の上位候補を late interaction で並べ替える(フュージョン結果の再採点を参照) |
SearchResult
各結果には以下が含まれます。
| フィールド | 型 | 説明 |
|---|---|---|
id | String | 外部ドキュメント ID |
score | f32 | フュージョン後の関連性スコア。再採点した結果では late interaction のスコア |
document | Option<Document> | ドキュメントの全内容(ロードされた場合) |
highlights | HashMap<String, Vec<String>> | lexical_options.highlight で要求した場合のハイライト済みフラグメント |
ハイブリッド検索でのハイライトはリクエストの lexical クエリによって駆動されるため、Vector 側だけでヒットした結果の highlights は空になります。詳細はハイライトを参照してください。
フィルター付きハイブリッド検索
フィルターを適用して、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();
}
フィルタリングの仕組み
- フィルタークエリが Lexical インデックス上で実行され、許可されるドキュメント ID のセットが生成される
- Lexical 検索: フィルターがユーザークエリとブーリアン AND で結合される
- Vector 検索: 許可された ID が ANN 検索の制限として渡される
ページネーション
offset と limit を使用してページネーションを実現します。
#![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();
}
フュージョン結果の再採点(Rescore)
ハイブリッド検索は、late interaction による再採点の 1 段目に適しています。
フュージョンがキーワードと意味のどちらかに合う候補を集め、再採点がその上位
window_size 件を MultiVector フィールドに対するトークン単位の MaxSim で
並べ替えます。
#![allow(unused)]
fn main() {
use laurus::RescoreOptions;
let request = SearchRequestBuilder::new()
.lexical_query(/* ... */)
.vector_query(/* ... */)
.fusion_algorithm(FusionAlgorithm::RRF { k: 60.0 })
.rescore(RescoreOptions::late_interaction("body_colbert", query_tokens))
.limit(10)
.build();
}
再採点はフュージョンの後、offset / limit の前に実行されるため、
ページネーションは再採点の有無にかかわらず同じように動作します。オプション、
並び順の規則、コストは
late interaction による再採点
を参照してください。
完全な例
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
- ID 解決の仕組み: ID Management
- データの永続性: Persistence & WAL
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
フィールドのアナライザーが引用符なしの語を複数のトークンに分割する場合
(例: 形態素解析(Lindera)アナライザーを通した日本語の文、あるいは
デフォルトの \w+ トークナイザーを通した rust-lang のようなハイフン
入り単語)、それらのトークンは正確な語順のフレーズとしてではなく
OR で結合されます(Lucene の match クエリと同様)。厳密な隣接一致
が必要な場合は、引用符付きのフレーズクエリを使用してください。
ブーリアン演算子
AND と OR(大文字小文字を区別しない)で句を結合します。
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 は任意、b と c の両方が必須」と解釈されます。
必須 / 禁止句
+(必ずマッチ)と -(マッチ禁止)を使用します。
+title:hello -title:goodbye
フレーズクエリ
ダブルクォートを使用して正確なフレーズをマッチングします。オプションの近接度(~N)で、ターム間に N 語を許可します。
"hello world"
"hello world"~2
多値テキストフィールドでは、フレーズ(および ~N の近接ウィンドウ)は、N がフィールドの position_increment_gap(デフォルト 100)に達しない限り、ある要素から次の要素へまたぐことはありません。
引用符で囲んだ値がフィールドのアナライザーで 1 トークンになる場合は、Term クエリと同じ完全一致になります(~N は無視されます)。引用符なしの形では書けない値、たとえば .、/、: を含むパス・URL・ID を keyword フィールドで検索するときは、この形を使います。
path:"file:///notes/a.md"
links:"https://example.com/a"
SynonymGraphFilter のように、フィールドのアナライザーが 1 つの位置に複数のトークンを積む場合、それらはその位置の代替語になります。big と large を同義語とすると、"big" はどちらかの語を含む文書にマッチし、"a big dog" は「a large dog」にもマッチします。1 つの位置の代替語は合算ではなく 1 つのブレンドされたターム(SynonymQuery)としてスコア付けされるため、big と large の両方を含む文書が big を 2 回含む文書より高いスコアになることはありません。複数語の同義語は代替語ごとに 1 つのフレーズになり、どれか 1 つがマッチすれば値がマッチします。ml と machine learning を同義語とすると、"ml" は ml か、フレーズ machine learning にマッチします。これらのフレーズは 1 つずつではなくグラフに沿ってまとめてマッチするので、値が生むフレーズの数に上限はなく、文書はいくつのフレーズを含んでも 1 つのフレーズとしてスコアが付きます。同義語展開を参照してください。
2 位置以上のフレーズはタームの位置情報(ポジション)を比較します。ポジションは term_vectors: true(デフォルト)のフィールドにだけ保存されます。term_vectors: false のフィールドに対するフレーズは、0 件を返すのではなくクエリエラーで拒否されます。フィールドを指定しないフレーズがデフォルトフィールドに展開され、その中に term_vectors: false のフィールドが含まれる場合も同様です。上の "ml" のように、複数語の同義語でフレーズになる引用符付きの語も同様です。
ファジークエリ
編集距離を使用した近似マッチング。~ に続けてオプションで最大編集距離を指定します。
roam~
roam~2
ワイルドカードクエリ
?(1 文字)と *(0 文字以上)を使用します。
te?t
test*
範囲クエリ
包含的な [] または排他的な {} の範囲指定。数値フィールドや日付フィールドに有用です。
price:[100 TO 500]
date:{2024-01-01 TO 2024-12-31}
price:[* TO 100]
日付・日時の境界
境界が日時リテラルである範囲は DateTimeRangeQuery を構築し、数値範囲と同様に定数スコアでフィールドの BKD tree に対して評価されます。数値でない境界は、次の順に解釈を試みます。
| 形式 | 例 | 解釈 |
|---|---|---|
| RFC 3339 | 2024-01-01T00:00:00Z、2024-01-01T09:00:00.5+09:00 | 小数秒可。オフセット付きは UTC に正規化 |
| オフセットなし日時 | 2024-01-01T09:00:00、2024-01-01T09:00:00.5 | YYYY-MM-DDTHH:MM:SS[.fff]。UTC として解釈 |
| 日付のみ | 2024-01-01 | YYYY-MM-DD。その日の 00:00:00 UTC(Lucene / Elasticsearch のデフォルト) |
# 日付のみの境界(上限は 2024-12-31T00:00:00Z。下記参照)
created_at:[2024-01-01 TO 2024-12-31]
# オフセット付き RFC 3339(09:00+09:00 は 00:00Z)
created_at:[2024-06-15T09:00:00+09:00 TO 2024-06-16T09:00:00+09:00]
# オフセットなし日時は UTC。* で上限を開放
created_at:[2024-06-15T12:34:56 TO *]
# 両端を含まない
created_at:{2024-01-01 TO 2025-01-01}
- 日付のみの境界はその日の 0 時(UTC)を意味するため、
[2024-01-01 TO 2024-12-31]は2024-12-31T00:00:00Zを含み、同日のそれ以降は含みません。年全体を対象にするには、上限に時刻を付ける([2024-01-01 TO 2024-12-31T23:59:59.999999])か、翌日を上限とする排他的範囲({2024-01-01 TO 2025-01-01}。{}は下限の瞬間2024-01-01T00:00:00Zも除外する点に注意)を使います。 *はその側を開放します。- 数値のみの境界は従来どおり数値範囲です。
DateTimeフィールドではエポック秒として扱われ、NumericRangeQueryを構築します。 - 数値の境界と日時の境界の混在(
[1704067200 TO 2024-12-31])や、日時の境界と日時リテラルでない文字列の混在([2024-01-01 TO tomorrow])は、黙って空の結果を返すのではなくパースエラーになります。数値でも日時リテラルでもない境界の範囲(title:[A TO Z])は従来どおりパースは通りますが、何にもマッチしません。
ドキュメント投入時のテキストから
DateTimeフィールドへの型変換は RFC 3339 のみを 受け付けます。オフセットなし日時と日付のみの形式はクエリリテラル専用です。
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 | クエリ句間の最大類似度スコア |
LateInteraction | 現在は WeightedSum と同じ。トークン単位の late 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
仕組み
- 分割(Split): スキーマのフィールド型に基づいて、各句が Lexical か Vector かを判定する。ベクトルフィールドとして定義されたフィールド名を持つ句は Vector 句として抽出される
- 委譲(Delegate): Vector 部分は
VectorQueryParserに、残りは Lexical のQueryParserに渡される - フュージョン(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。 |
| WeightedSum | score = 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 | スカラー |
DateTime(単一値・多値) | 1 | Unix マイクロ秒(UTC) |
Geo(単一値・多値) | 2 | 緯度・経度(度) |
Geo3d(単一値・多値) | 3 | ECEF 直交座標(メートル) |
新しい空間フィールド型を追加する作業は、次元数を選んでクエリ側の
IntersectVisitor を書くだけに帰着する。
ライタ・リーダ・オンディスクレイアウトはそのまま再利用される。
ファイルフォーマット (Version 4)
.bkd セグメントファイルは自己完結型のバイナリで、3 つの領域と、その後ろのチェックサム footer から成る:
+----------------------------------------+
| File Header | 固定長・バージョンタグ付き
+----------------------------------------+
| Leaf Blocks | bit-packed points + doc_ids
| leaf 0 |
| leaf 1 |
| ... |
+----------------------------------------+
| Index Nodes | 内部ナビゲーションノード
| node N-1 |
| ... |
| node 0 (root, written last) |
+----------------------------------------+
| Footer | 上記すべての CRC-32
| | + マジック "LCRC"(8 バイト)
+----------------------------------------+
ヘッダはリーフと索引の書き込み位置を記録するので、writer はヘッダの領域を先に確保し、後ろをすべて書いてから最後にヘッダを埋める。それでも footer の CRC は、ファイル上の並び順のバイト列を覆う(Issue #1214)。footer 導入前に書かれたファイルは、代わりに 4 バイトの trailer で終わる。
ヘッダ (BKDFileHeader) は magic、version(現在は 4)、num_dims、
bytes_per_dim、総ポイント数、リーフブロック数、block_size
(writerが設定したリーフあたりの最大ポイント数。読み込み時にリーフの
count を検証する上限として使う、Issue #1142)、全体の軸ごと
min/max、インデックス領域とルートノードへのオフセットを保持する。
Leaf Block レイアウト
各リーフブロックは、その部分木に属するポイントを bit-pack して格納する
(Issue #549、以前は生の f64/u64 だった)。Issue #1142以降は、
再帰的な構築が残す空間分割順ではなくdoc_id昇順で格納される:
count u32 — リーフ内のポイント数
leaf_min [f64; num_dims] — リーフレベルの AABB 下端
leaf_max [f64; num_dims] — リーフレベルの AABB 上端
doc_id_base u64 — リーフ内の doc_id の最小値
doc_id_bits u8 — 連続するdoc_id差分の最大値のbit幅
packed_dim[0] バイト整列 — `sortable(point) - sortable(leaf_min[d])` をbit-pack
packed_dim[1..] ... — 次元ごとに1セクション
packed_doc_ids バイト整列 — 連続差分をbit-pack(後述)
各次元のbit幅は、leaf_min[d]/leaf_max[d] を(f64::total_cmp と同じ
IEEE 754 の全順序を保つ)u64 に写像した値から導出され、ディスクには
保存しない。書き込み側と読み込み側が同じ式で計算するため、両者がずれる
心配がない。リーフ全体で定数の次元は幅0bitに導出され、1バイトも消費しない。
packed_doc_ids はdoc_id_baseからの独立差分ではなく連続差分を
格納する: 値iはdoc_id[i-1] + delta(doc_id[-1] := doc_id_base)
であり、先頭のdeltaは常に0になる(読み込み側は先頭deltaが非ゼロの
場合を破損として拒否する)。したがってdoc_id_bitsは
bits_needed(max-min)ではなくbits_needed(連続差分の最大値)となり、
連続差分の合計は常にリーフのmax-minと等しくなる(テレスコーピング和)
ため理論上これより大きくなることはなく、doc_idがリーフの全範囲に
均等に広がっているのではなく局所的に密集している場合に大幅に小さく
なりうる。doc_id_bitsだけは唯一ディスクに保存される幅で(doc_id側
には導出元となる「最大値」ヘッダフィールドが無いため)、読み込み時に
64 を超える値は破損として拒否する。doc_idによるソートは安定
ソートであり、同一doc_idを持つ複数の点(多値の数値・日時・地理フィールド)は元の
相対順序を保つ — これはGeoBoxPointsVisitorの「最初に見つかった点が
勝つ」という重複排除方式が依存している性質である。
削減率はデータの相関度に依存する。旧v2形式(生のリーフフォーマット)に
対する比率は、一様乱数の1次元/2次元/3次元データでおよそ2.16倍/1.67倍/
1.50倍、単調増加するフィールド(タイムスタンプや自動採番カウンタなど)
では2.66倍程度 — v3(Issue #1142以前)の1.96倍/1.59倍/1.45倍/2.28倍
から改善している。これは、連続差分によるdoc_id符号化が、単一アンカー
からの独立差分では捉えられなかった局所的なdoc_idの密集を活用できる
ためである。これは量子化ではなく可逆な差分符号化方式であり、±0.0 や
±Infinity を含め全てのポイントがビット完全に往復する。
リーフごとに AABB を持たせることで、クエリ領域がリーフの外側にある場合
(Outside) や全内包される場合 (Inside) には、ポイントを1つもデコードせずに
リーフ全体を判定できる。Inside の場合はpacked pointセクションを1回の
シークでスキップし、doc_ids のみをデコードする。
内部 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) ヒューリスティクスで進む:
- 入力部分集合の AABB を計算する。
(max - min)レンジが最も広い軸を選ぶ(同点時は次元番号の小さい方を 採用して決定的にする)。- インデックスの並びをその軸でソートし、中央値で分割する。
- 部分木が
block_size(既定512)以下のポイント数になるまで再帰し、 そうなったらリーフとして書き出す。 - 子が flush された後、各親の
left_offset/right_offsetを後埋めする。
ビルダはポイント/doc_id バッファ自体ではなくインデックスの並び (permutation)をソートするため、ポイント数に関わらずポイント単位の ヒープ確保を一切おこなわない。
数値ロバスト性
座標は全順序で比較できる必要がある。BKDWriter::write は NaN を明示的に
拒否する。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 検索 —
NumericRangeQuery、GeoDistanceQuery/GeoBoundingBoxQuery、Geo3dDistanceQueryの Rust API エントリポイント。
3D 地理検索 (ECEF)
Laurus は用途の異なる 2 種類の地理フィールド型を提供する:
| フィールド型 | バックエンド構造 | 座標系 | こんなときに |
|---|---|---|---|
Geo | 2D BKD-Tree | WGS84 緯度・経度(度) | データが地表のみで、高度を考慮しなくてよい場合 |
Geo3d | 3D BKD-Tree | ECEF 直交座標 (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_point、
Inside 判定に 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)から始め、以下のいずれかを満たすまで半径を倍加していく:
- 重複なしで
k件以上の候補を集めた、 - 倍加しても何も新しいヒットが見つからなくなった、
- 半径が
max_radius_m(既定1.0e10m=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 を返さない —
Outside か Crosses のみ — ので、すべての候補がその座標と一緒に 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_nearest の k のみ符号なし整数。
ワイヤーフォーマット
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.proto
と index.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-Tree —
Integer,Float,DateTime, 2DGeoも含めて支える、共通の多次元インデックス。 - スキーマとフィールド —
Geo3dを含む フィールド型の一覧。 - Query DSL — geo3d DSL 文法の詳細。
- Lexical 検索 — Lexical クエリ全般の Rust API エントリポイント。
laurus-wasm/examples/geo3d/— ブラウザ向けデモ。CesiumJS の 3D 地球儀上に人工衛星のライブ位置を プロットし、geo3d_bboxとgeo3d_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"]
主要な型
| 型 | モジュール | 説明 |
|---|---|---|
Engine | engine | Lexical検索とVector検索を統合する検索エンジン |
EngineBuilder | engine | Engineの設定・構築を行うBuilderパターン |
Schema | engine | フィールド定義とルーティング設定 |
SearchRequest | engine | 統一的な検索リクエスト(Lexical、Vector、またはハイブリッド) |
FusionAlgorithm | engine | 結果マージ戦略(RRFまたはWeightedSum) |
Document | data | 名前付きフィールド値のコレクション |
DataValue | data | すべてのフィールド型に対応する統一的な値のenum |
LaurusError | error | 各サブシステムのバリアントを含む包括的なエラー型 |
Feature Flag
laurus クレートはデフォルトではFeatureが有効化されていません。必要に応じてEmbeddingサポートを有効にしてください。
| Feature | 説明 | 依存クレート |
|---|---|---|
embeddings-candle | Hugging Face CandleによるローカルBERT Embedding | candle-core, candle-nn, candle-transformers, hf-hub, tokenizers |
embeddings-openai | OpenAI API Embedding | reqwest |
embeddings-multimodal | CLIPマルチモーダルEmbedding(テキスト + 画像) | image, embeddings-candle |
embeddings-all | すべてのEmbedding Featureを含む | 上記すべて |
# Lexical検索のみ(Embeddingなし)
[dependencies]
laurus = "0.12"
# ローカルBERT Embeddingを使用
[dependencies]
laurus = { version = "0.12", features = ["embeddings-candle"] }
# すべてのFeatureを有効化
[dependencies]
laurus = { version = "0.12", features = ["embeddings-all"] }
セクション
- Engine – EngineとEngineBuilderの内部構造
- スコアリングとランキング – BM25、TF-IDF、およびベクトル類似度スコアリング
- ファセット – 階層的なファセット検索
- ハイライト – 検索結果のハイライト表示
- スペル修正 – スペル候補の提案と自動修正
- ID管理 – 二層構造のドキュメントID管理
- 永続化とWAL – Write-Ahead Loggingとデータの耐久性
- 削除とコンパクション – 論理削除と領域の再利用
- エラーハンドリング – LaurusErrorとResult型
- 拡張性 – カスタムAnalyzer、Embedder、Storageバックエンド
- APIリファレンス – 主要な型とメソッドの一覧
Engine
Engine はLaurusの中心的な型です。Lexicalインデックス、Vectorインデックス、およびドキュメントログを単一の非同期APIで統合します。
Engine構造体
#![allow(unused)]
fn main() {
pub struct Engine {
schema: Schema,
lexical: LexicalStore,
vector: VectorStore,
log: Arc<DocumentLog>,
}
}
| フィールド | 型 | 説明 |
|---|---|---|
schema | Schema | フィールド定義とルーティングルール |
lexical | LexicalStore | キーワード検索用の転置インデックス(Inverted Index) |
vector | VectorStore | 類似度検索用のベクトルインデックス |
log | Arc<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> | StandardAnalyzer | Lexicalフィールド用のテキスト解析パイプライン |
embedder() | Arc<dyn Embedder> | None | Vectorフィールド用のEmbeddingモデル |
embedding_cache_capacity() | usize | None(無効) | 最大 N 件のクエリEmbeddingをLRUキャッシュする |
wal_sync_policy() | WalSyncPolicy | PerRecord | WAL fsync の耐久性(per-record か group commit か) |
commit_policy() | CommitPolicy | Manual | 自動コミットのタイミング — 例: EveryDocs(n) は n 件ごとにコミット |
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)。
late interaction による再採点のテキストのクエリから作ったトークンベクトルも、 同じ容量の別の LRU にキャッシュします。キーはフィールド、フィールドの Embedder、役割(クエリか文書か)、クエリのテキストです。別にしているのは、 大きなトークンのエントリが 1 本のベクトルのエントリを追い出さないためです (Issue #1349)。ベクトルフィールドを追加・削除・作り直すと、フィールド名の 裏の Embedder が変わりうるため、両方のキャッシュを空にします。
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
- スキーマの分割 – Lexicalフィールド(Text、Integer、Floatなど)は
LexicalIndexConfigに、Vectorフィールド(HNSW、Flat、IVF)はVectorIndexConfigに分割されます - プレフィックス付きストレージの作成 – 各コンポーネントに独立した名前空間が割り当てられます(
lexical/、vector/、documents/) - ストアの初期化 –
LexicalStoreとVectorStoreがそれぞれの設定で作成されます - 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を共有可能) |
put_documents(docs) | バッチ Upsert – (id, doc) ペアを順序どおり適用し、WAL fsync はバッチごとに 1 回 |
add_documents(docs) | バッチ追加 – put_documents と同様だが既存チャンクを削除しない |
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
| フィールド | 型 | デフォルト | 説明 |
|---|---|---|---|
query | SearchQuery | Dsl("") | 検索クエリ仕様(Dsl / Lexical / Vector / Hybrid) |
limit | usize | 10 | 返却する最大結果数 |
offset | usize | 0 | ページネーションのオフセット |
fusion_algorithm | Option<FusionAlgorithm> | None(ハイブリッド時はRRF k=60) | LexicalとVectorの結果を統合する方法 |
filter_query | Option<Box<dyn Query>> | None | 両方の検索タイプに適用されるフィルタ |
lexical_options | LexicalSearchOptions | デフォルト | Lexical検索の動作パラメータ(ブースト、タイムアウト等) |
vector_options | VectorSearchOptions | デフォルト | Vector検索の動作パラメータ(スコアモード等) |
LexicalSearchOptions
| フィールド | 型 | デフォルト | 説明 |
|---|---|---|---|
field_boosts | HashMap<String, f32> | 空 | フィールドごとのスコア倍率 |
min_score | f32 | 0.0 | 最小スコアしきい値 |
timeout_ms | Option<u64> | None | 検索タイムアウト(ミリ秒) |
parallel | bool | false | セグメント検索を並列実行するか |
sort_by | SortField | Score | Lexical結果のソート順 |
highlight | Option<HighlightOptions> | None | 検索結果ハイライトの対象フィールドと設定。None の場合、各ヒットの SearchResult::highlights は空のまま。SearchRequestBuilder::highlight / .highlight_config で設定する(ハイライト参照)。Vector-onlyのリクエストでは無視される |
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 — ドキュメント長と平均ドキュメント長の比率。
doc_lenはトークン数ではなく position の数です —— アナライザーが別のトークンにスタックする同義語(position_increment = 0)はそのトークンの position を共有し、長さに加算されません。Lucene のdiscountOverlapsと同じ挙動です。
(k1, b) の値は現在実装デフォルトに固定です。Lucene / Elasticsearch のデフォルトと同じため、BM25 スコアはチューニングの直感に関してそれらのエンジンと直接比較できます。
クオート済み・未クオートを問わず、アナライザーが 1 つの位置に複数の代替語(同義語)を積む値は、独立したタームのスコアを合算するのではなく、1 つのブレンドされたタームとしてスコア付けされます。そのため、すべての代替語を含んでいるからといって文書のスコアが不当に高くなることはありません。詳細は同義語を使った検索を参照してください。
フィールドブースト
フィールドごとのスコア乗数は専用のスコアリング構造体ではなく、検索リクエスト上で設定します。
#![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 は無効化と同じです。クエリで指定されたフィールド(またはスキーマの既定検索フィールド)にのみ適用されます。例外は CombinedFields のマルチフィールドクエリで、ブーストはスコアの乗数ではなく、BM25F の内側の重みになります。
gRPC / HTTP 経由では同じ設定が SearchRequest.field_boosts(map<string, float>)として公開されます。gRPC API → SearchRequest を参照してください。
マルチフィールドクエリ
MultiFieldQuery は、1 つのターム(TermQuery と同じく解析しない)を複数のフィールドから検索します。フィールドごとにブーストを指定できます。フィールドの組み合わせ方は MultiFieldQueryType で決まります。
| 種類 | マッチ | スコア |
|---|---|---|
BestFields(デフォルト) | いずれかのフィールド | 最良のフィールドの BM25 スコア + tie_breaker × 他にマッチしたフィールドのスコア(Elasticsearch の best_fields) |
MostFields | すべてのフィールド | 各フィールドの BM25 スコアの合計(Elasticsearch の most_fields) |
CombinedFields | いずれかのフィールド | BM25F: 複数のフィールドを連結した 1 つのフィールドとしてスコア付けする(Elasticsearch の combined_fields、Lucene の CombinedFieldQuery) |
BestFields と MostFields は、各フィールドをそのフィールド自身の統計でスコア付けします。あるフィールドで珍しいタームはそのフィールドで IDF が高くなるため、同じテキストでも、どのフィールドに入ったかでスコアが変わります。CombinedFields は統計をブレンドし、各ドキュメントを次の値の BM25 で 1 回だけスコア付けします。
tf = Σ w_f · tf_f (各フィールドでのドキュメントの単語頻度)
doc_len = Σ w_f · len_f (タームを含むかどうかに関係なく、すべてのフィールド)
avg_doc_len = Σ w_f · avg_len_f · doc_count_f / max(doc_count_f)
df = max(df_f) (IDF 用)
w_f はフィールドのブーストです。BM25F の内側の重みとして働き、2.0 は、フィールドのスコアを 2 倍にするのではなく、そのフィールドのテキストを 2 回索引したものとして数えます。ブーストは有限で 0.0 より大きい値でなければならず、それ以外の値では検索が InvalidArgument を返します。Lucene と Elasticsearch は 1.0 未満のブーストを拒否しますが、Laurus は受け付けます。
TermQuery と同じく、文書頻度はインデックス全体の値を使います。一方、複数セグメントのインデックスでは、平均長は各セグメント自身の値を使います。
#![allow(unused)]
fn main() {
use laurus::lexical::query::advanced_query::{MultiFieldQuery, MultiFieldQueryType};
let query = MultiFieldQuery::new("smith".to_string())
.add_field("first_name".to_string(), 1.0)
.add_field("last_name".to_string(), 1.0)
.query_type(MultiFieldQueryType::CombinedFields);
}
Vector スコアリング
Vector 検索は距離ベースの類似度で結果をランク付けします。距離メトリックはベクトルインデックス(HNSW / Flat / IVF)のフィールドごとに設定します。
| メトリック | 説明 | 適した用途 |
|---|---|---|
Cosine | 1 − コサイン類似度(デフォルト) | 正規化済みテキスト埋め込み |
Euclidean | L2 距離 | 空間データ・事前正規化済みデータ |
Manhattan | L1 距離 | 疎な特徴ベクトル |
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 を選んでください — パラメータが少なく、リスト間のスケール差にも頑健です。
関連項目
- API リファレンス →
FusionAlgorithm— バリアントのシグネチャ - ハイブリッド検索 — どのフュージョンを選ぶかの目安
- Vector 検索 — 距離メトリックのトレードオフ
ファセット
ファセット(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".into(), "Electronics".into());
// コンポーネントからの階層的ファセット
let facet = FacetPath::new("category".into(), vec![
"Electronics".to_string(),
"Computers".to_string(),
"Laptops".to_string(),
]);
// 区切り文字付き文字列から
let facet = FacetPath::from_delimited("category".into(), "Electronics/Computers/Laptops", "/");
}
from_delimited はコレクターと同じく空の成分を捨てます。そのため
"/Electronics//Computers" は ["Electronics", "Computers"] になります。
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>,
}
}
| フィールド | 型 | 説明 |
|---|---|---|
path | FacetPath | ファセット値。フィールドのトップレベルからの完全なパス |
count | u64 | 値がこのパス、またはその下にある、マッチしたドキュメントの数 |
children | Vec<FacetCount> | 1 階層下のファセット。階層的なドリルダウン用 |
FacetConfig
FacetConfig は、コレクターが何を数えて何を返すかを指定します。
| フィールド | 既定値 | 説明 |
|---|---|---|
max_facets_per_field | 100 | 1 階層あたりに残す値の最大数。各フィールドのトップレベルと、各ノードの子に、それぞれ別々に適用される。並べ替えの後に適用する |
max_depth | 10 | 集計時にパスを先頭 max_depth 個の成分で切り詰める。それより深い階層は数えない。0 は何も数えず、usize::MAX はすべての階層を残す |
min_count | 1 | 値を返すのに必要な最小ドキュメント数。これに満たない値は、その子と一緒に落とされる |
sort_by_count | true | 各階層を件数の降順に並べ、同数ならラベル順にする。false なら各階層をラベル順に並べる |
集計したドキュメントのどれも持たない値は返らないため、min_count の 0 は 1 と同じ動作になります。
ファセットの集計
一致した各ドキュメントを FacetCollector に渡し、最後に finalize を呼びます。
#![allow(unused)]
fn main() {
use laurus::lexical::search::features::facet::{FacetCollector, FacetConfig};
let mut collector = FacetCollector::new(FacetConfig::default(), vec!["category".to_string()]);
for doc_id in matching_doc_ids {
collector.collect_doc(doc_id, reader.as_ref())?;
}
let results = collector.finalize()?;
for facet in results.get_field_facets("category").into_iter().flatten() {
println!("{} ({})", facet.path.to_string_with_delimiter("/"), facet.count);
}
}
ファセットのフィールドを stored document から読む必要があり、その読み取りに失敗すると、
collect_doc はエラーを返します。エラーの後はコレクターの件数が不完全なので、そのコレクターは
破棄してください。
階層的ファセット
/ を含む Text 値は階層パスです。Electronics/Computers/Laptops は 3 階層です。コレクターは
パスとその各祖先を、ドキュメントごとに 1 回ずつ数えます。finalize はフィールドごとに 1 つの
ツリーを返します。トップレベルの値は get_field_facets(field) に、それより深い階層は親の
children に入ります。
Category
├── Electronics (42)
│ ├── Computers (18)
│ │ ├── Laptops (12)
│ │ └── Desktops (6)
│ └── Phones (24)
└── Books (35)
├── Fiction (20)
└── Non-Fiction (15)
ノードの count は、値がそのパス、またはその下にあるドキュメントの数です。したがって、子の件数が
親を上回ることはありません。フラットな値と、階層的な値の根は同じノードです。例えば cat = "a" の
ドキュメントと cat = "a/b" のドキュメントからは、子 b (1) を 1 つ持つ a (2) が 1 つだけ
できます。
各階層は、それぞれ独立に絞り込み・並べ替え・切り詰めが行われます。
min_countは、値をその部分木全体と一緒に落とします。max_facets_per_fieldは、トップレベルと各ノードの子に別々に適用されます。そのため、祖先が 子孫の枠を使い切ることはありません。- 件数が同じ値はラベル順に並ぶので、どの値が残るかがハッシュの順序に左右されません。これは Lucene・Tantivy と同じです。
空の成分は捨てられます。"/a/b" と "a//b" はどちらも a/b に、"a/" は a になり、"" と
"/" は何も数えません。Lucene は索引時にこのような成分を拒否します。laurus はファセットを検索時に
通常のテキスト値から作るので、検索を失敗させる代わりに捨てます。
ユースケース
- EC(電子商取引): カテゴリ、ブランド、価格帯、評価によるフィルタリング
- ドキュメント検索: 著者、部門、日付範囲、ドキュメントタイプによるフィルタリング
- コンテンツ管理: タグ、トピック、コンテンツステータスによるフィルタリング
多値フィールド
多値フィールド(multi_valued = true。多値フィールド
を参照)は 1 ドキュメントに配列を保持し、コレクターはそれを展開します: 各要素がそれぞれ
独立したファセットパスになります(Issue #1187)。/ を含む TextArray の要素は、スカラーの
Text 値とまったく同じ規則で階層パスに分割されます。そのため tags = ["rust", "search"] は
rust と search をそれぞれ 1 回ずつ数え、cat = ["a/b", "a/c"] は a/b・a/c と、両者が
共有する祖先 a を数えます。
カウントは Lucene の SortedSetDocValuesFacetCounts に倣ってドキュメント単位です:
1 ドキュメント内で 2 回現れる要素(["rust", "rust"])は 1 回だけ数えられ、2 つの要素から
到達する祖先も同様です(上の a は 2 ではなく 1)。したがって FacetCount::count は常に
「マッチしたドキュメント数」を意味します。空配列は何も寄与しません。
配列の要素は、同じ型のスカラー値とまったく同じ形式で文字列化されます:
| 値 | ファセット値 |
|---|---|
Text / TextArray | 文字列そのまま。/ は階層の成分に分割される |
Int64 / Int64Array | 10 進整数(例: 42) |
Float64 / Float64Array | 常に小数点付き(例: 2.0、2.5)。浮動小数点が整数と同じラベルになることはない(浮動小数点を Text フィールドへ 変換する経路では 2.0 は 2 になる。別のコードパス) |
Bool / BoolArray | true / false |
DateTime / DateTimeArray | UTC の RFC 3339、マイクロ秒精度(例: 2024-01-01T00:00:00+00:00)。マイクロ秒未満の桁は切り捨てられるため、DocValues から読んでも stored document から読んでもラベルは同じ |
Null、地理座標(Geo・GeoEcef とそれらの配列)、Vector、Bytes はファセットの対象外で、
何も寄与しません。DocValues がヒットしてファセット値が 0 個になった場合でも、stored document
へのフォールバックは行いません。
パフォーマンス
ファセットカウントは stored document ではなく、各フィールドの DocValues 列から読み取られます。
収集された各ヒットについて、コレクターはファセットフィールドの値だけを per-field の DocValues
ルックアップで読むため、ファセット対象の全フィールドが DocValues 列を持つ場合(stored: true な
フィールドは既定でこれに該当します。ただし後述のとおり型によって除外される場合や、doc_values
オプションが明示的に false に設定されている場合を除きます)、stored fields blob 全体を
decode / clone しません。DocValues を持たないフィールド ―― オプトアウトしている、stored
ではない、あるいは Bytes/Vector の値(DocValues には設定にかかわらず一切格納されません)
であるため ―― は透過的に stored document へフォールバックするため、結果はどちらの経路でも
同一で、変わるのは読み取り経路だけです。多値フィールドの配列値は DocValues に丸ごと
(1 ドキュメント 1 エントリ)格納され、ファセット時に要素へ分割されるため、展開によって
DocValues の読み取り回数が増えることはありません。
ソートにもファセットにも使わないフィールドで doc_values: false を設定すると、値が二重(stored
document と DocValues)ではなく一度(stored document のみ)しか書き込まれなくなるため、
セグメントの使用容量が削減されます。
ハイライト
ハイライト(Highlighting)は検索結果内のマッチした単語をマークアップし、ドキュメントがクエリにマッチした理由をユーザーに視覚的に提示します。Laurusは設定可能なHTMLタグでハイライトされたテキストフラグメントを生成します。
検索結果のハイライト
ハイライトを取得する最も簡単な方法は、検索 API 自体に要求することです。SearchRequest でハイライトを指定すると、各ヒットの SearchResult::highlights に結果が入って返ってきます。
#![allow(unused)]
fn main() {
use laurus::{HighlightConfig, SearchRequestBuilder};
let request = SearchRequestBuilder::new()
.lexical_query(query)
.highlight(vec!["body".to_string()])
.highlight_config(HighlightConfig::default().tag("em".to_string()))
.build();
let results = engine.search(request).await?;
for result in &results {
if let Some(fragments) = result.highlights.get("body") {
println!("{}", fragments.join(" ... "));
}
}
}
highlight(fields) と highlight_config(config) は独立した SearchRequestBuilder のメソッドで、どちらを先に呼んでも構いません。highlight を再度呼ぶとフィールド一覧は置き換わり、highlight_config も同様です。highlight を呼ばない(または空のフィールド一覧を渡す)場合、すべてのヒットの highlights は空のままになります。この場合エンジンはハイライト処理を一切行いません。
押さえておくべき挙動:
- フィールド選択:
highlight(fields)で指定したフィールドのうち、stored: trueのテキストフィールドのみが対象になります。ドキュメントに存在しない、storedでない、テキスト型でないフィールドは黙ってスキップされ、highlightsのキーには現れません。多値テキストフィールド(multi_valued: true、Issue #1175)は要素ごとにハイライトされます: マッチした要素だけがフラグメントを生成し、フラグメントが 2 つの要素をまたぐことはなく、連結後のフラグメント一覧はmax_fragmentsを守ります。 - アナライザ: 各フィールドはそのフィールド自身のインデックス時アナライザでトークナイズされます(フィールド別アナライザは自動的に適用されます)。そのため、ハイライトは汎用トークナイザではなく、インデックス時に実際にマッチした内容を反映します。
- どのクエリがハイライトされるか: ハイライトはリクエストの lexical クエリで駆動されます。これはハイブリッド検索でも同様で、vector-only のリクエストはハイライトを生成しません。リクエストレベルの
filter_queryだけが持ち込む語はハイライトされません。フィルタは検索対象への適合性を表すものであり、検索意図そのものではないためです。 - 結果が空の場合: マッチするフラグメントがないフィールド(または
return_entire_field_if_no_highlightを設定していない場合)は、空リストとしてではなくhighlightsから完全に除外されます。 - コスト: ハイライトはページネーション後に実行されるため、コストはマッチ総数ではなく
limit × フィールド数に比例します。
Engine::search を経由しないテキストをハイライトするなど、検索 API の外でハイライトを直接制御したい場合は、以下で説明する Highlighter を使ってください。
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);
}
設定オプション
| オプション | 型 | デフォルト | 説明 |
|---|---|---|---|
tag | String | "mark" | ハイライトに使用するHTMLタグ |
css_class | Option<String> | None | タグに追加するオプションのCSSクラス |
max_fragments | usize | 5 | 返却するフラグメントの最大数 |
fragment_size | usize | 150 | フラグメントの目標文字数 |
fragment_overlap | usize | 20 | 予約済み。現状フラグメント選択では参照されない |
fragment_separator | String | " ... " | 予約済み。現状参照されない — フラグメントは結合済み文字列ではなくリストとして返却される |
return_entire_field_if_no_highlight | bool | false | マッチがない場合にフィールド全体の値を返却する |
max_analyzed_chars | usize | 1,000,000 | ハイライト解析対象の最大文字数 |
require_field_match | bool | true | ハイライト対象フィールドを対象とするクエリ語のみを使う(false にするとクエリ内の全フィールドの語でハイライトする) |
Builderメソッド
| メソッド | 説明 |
|---|---|
tag(tag) | HTMLタグを設定(例: "em"、"strong"、"mark") |
css_class(class) | タグのCSSクラスを設定 |
max_fragments(count) | フラグメントの最大数を設定 |
fragment_size(size) | フラグメントの目標文字数を設定 |
require_field_match(flag) | ハイライト対象フィールドを対象とする語のみを使うかを設定 |
opening_tag() | 開始HTMLタグ文字列を取得(例: <mark class="highlight">) |
closing_tag() | 終了HTMLタグ文字列を取得(例: </mark>) |
対応クエリ
ハイライト対象の語はクエリの説明文字列ではなくクエリ木(Query::collect_highlight_terms)から取得され、ハイライタのアナライザ(既定は StandardAnalyzer。日本語テキストなどフィールドのアナライザに合わせるには Highlighter::with_analyzer を使う)が生成したトークンと照合されます。
| クエリ | ハイライトされるもの |
|---|---|
TermQuery | 語と一致するトークン |
PhraseQuery | フレーズを構成する連続トークン。検索と同じ「順序どおり・語間の隙間が slop 以内」の規則で、出現 1 回が 1 つのハイライト。位置はインデックスと同じくアナライザが出力したトークンの順番で数えるため、除去されたストップワードは隙間にならない |
GraphPhraseQuery | グラフを通るパスのうち出現したものそれぞれを、PhraseQuery と同じようにハイライト。引用符で囲んだ値の複数語の同義語なら、メンバーごとのフレーズ |
PrefixQuery、WildcardQuery、RegexpQuery、FuzzyQuery | パターン(または編集距離)に一致するすべてのトークン |
BooleanQuery | Must・Should・Filter 節の語。MustNot 節は除外 |
AdvancedQuery | コアクエリ・フィルタ・ポストフィルタの語。ネガティブフィルタは除外 |
MultiFieldQuery | 設定された各フィールドにおけるクエリ文字列 |
スパンクエリ(SpanQueryWrapper) | ラップされたクエリ内のすべてのスパン語 |
| Range・Numeric・DateTime・Geo クエリ | なし |
既定ではハイライト対象フィールドを対象とする語のみが使われます(require_field_match)。クエリ内の全フィールドの語でハイライトするには false に設定してください。
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は最も関連性の高いフラグメントを選択します。
- テキストが
fragment_size文字のウィンドウに分割されます - 各フラグメントは含まれるクエリ単語の数でスコアリングされます
- 上位
max_fragments個のフラグメントが、元の出現順のままVec<HighlightFragment>として返却されます(結合や整形は呼び出し側が行います)
多値テキストフィールドでは上記の手順が要素ごとに実行されるため、フラグメントが 2 つの要素をまたぐことはありません。max_fragments は全要素を連結した一覧に対する上限です。
マッチを含むフラグメントがなく、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_distance | usize | 2 | 候補提案のための最大Levenshtein編集距離 |
max_suggestions | usize | 5 | 単語あたりの最大候補数 |
min_frequency | u32 | 1 | 候補として提案されるために必要な辞書内の最小頻度 |
auto_correct | bool | false | trueの場合、しきい値を超える修正を自動的に適用 |
auto_correct_threshold | f64 | 0.8 | 自動修正に必要な信頼度スコア(0.0–1.0) |
use_index_terms | bool | true | 検索インデックスの単語を辞書として使用 |
learn_from_queries | bool | true | ユーザーの検索クエリから新しい単語を学習 |
CorrectionResult
correct() メソッドは詳細な情報を含む CorrectionResult を返します。
| フィールド | 型 | 説明 |
|---|---|---|
original | String | 元のクエリ文字列 |
corrected | Option<String> | 修正済みクエリ(自動修正が適用された場合) |
word_suggestions | HashMap<String, Vec<Suggestion>> | 誤入力単語ごとにグループ化された候補 |
confidence | f64 | 全体の信頼度スコア(0.0–1.0) |
auto_corrected | bool | 自動修正が適用されたかどうか |
ヘルパーメソッド
| メソッド | 戻り値 | 説明 |
|---|---|---|
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);
}
次のステップ
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兆ドキュメント) |
この構造を採用する理由
- ゼロコスト集約:
u64IDがグローバルにユニークであるため、アグリゲータはノード間のID衝突を気にせずに高速なソートと重複排除を実行できます。 - 高速ルーティング: アグリゲータは上位ビットを見るだけで、ドキュメントの担当物理ノードを即座に特定でき、コストの高いハッシュ検索を回避できます。
- 高性能フェッチ: 内部IDは物理データ構造に直接マッピングされます。これにより、Laurusは検索時に「外部IDから内部IDへの変換」ステップをスキップし、O(1) のアクセス速度を実現します。
IDライフサイクル
- 登録(
engine.put_document()/engine.add_document()): ユーザーが外部IDを持つドキュメントを提供します。 - ID割り当て:
Engineが現在のshard_idと新しいLocal IDを組み合わせて、Shard-Prefixed内部IDを発行します。 - マッピング: エンジンが外部IDと新しい内部IDの対応関係を維持します。
- 検索: 検索結果は内部IDから解決された外部ID(
String)を返します。 - 取得/削除: ユーザー向け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
主要な原則
- WALファースト: すべての書き込み(追加または削除)はインメモリ構造を更新する前にWALに追記されます
- バッファリング書き込み: インメモリバッファが
commit()が呼ばれるまで変更を蓄積します - アトミックコミット:
commit()はすべてのバッファリングされた変更をセグメントファイルにフラッシュし、WALを切り捨てます - コミットスコープの可視性: Lexical セグメントは writer のバッファが埋まった時点で書き出されます。これは
commit()よりずっと前に起こり得るため、セグメントファイルがストレージ上に存在することは、その文書が検索可能になったことを意味しません。公開とはcommit()だけが追加する manifest エントリのことであり、セグメントの探索は manifest しか読みません。したがって「文書が検索可能になるのはcommit()の後」という契約は、searcher がいつ構築されたかに関わらず成立します。削除も同じ手順の中で、ビットマップが永続化された後に公開されるため、セグメントを見られる reader は必ずその削除も適用できます - クラッシュセーフティ: 書き込みとコミットの間にプロセスがクラッシュした場合、次回起動時にWALがリプレイされます
- アトミックなファイル書き込み: セグメントファイル(HNSW の
.hnswグラフ・そのメタデータ・削除ビットマップなど)は一時ファイルへ書き込んでからアトミックにリネームして配置されるため、書き込み途中のクラッシュでも切り詰められたファイルではなく直前にコミット済みのファイルがそのまま残ります - チェックサム検証: これらのファイルは CRC-32(
.hnswと.hnsw.f32rerank sidecar は footer、metadata.jsonは framing)を持ち、ロード時に検証されるため、ディスク上の静かな破損を正常データとして読まずに検出できます。チェックサム導入前に書かれたファイルもそのままロードできます(ファイル単位で任意)。語彙インデックスのセグメントパート(.dict・.post・.docs・.norms・.ids・.bkd)、削除ビットマップ(.delmap)、チェックサム付き manifest は、末尾に 8 バイトの footer を持ちます。footer は、それより前の全バイトの CRC-32 と、マジックLCRCからなります。先頭から順に読むパートは、セグメントを開いて読み込むときに検証されます。.postと.bkdはランダムアクセスで読むので、マージがセグメントを読むときに検証されます。これで、破損したデータが新しいチェックサムを付けてマージ後のセグメントへ移ることを防ぎます。マージ後のセグメントは、全パートが検証されます。document store のセグメント(doc_segment_NNNNNN.docs)も同じ footer を持ちます。書き込みや再オープンの後に初めて読むとき、offset index を作る際に全体が検証されます。破損していれば、そのセグメントは読むたびにエラーになります。この footer より前に書かれたファイルは、最後の書き込みだけを覆う 4 バイトの trailer で終わります。これらもそのままロードできますが、検証はされません。例外は manifest と.idsで、最後の書き込みがペイロード全体なので、trailer で引き続き検証されます。また、ローダーはヘッダーを信頼する前にバッファ確保サイズを実ファイルサイズで上限を縛るため、サイズフィールドが破損していても巨大なメモリ確保(OOM)を引き起こさずに破損として拒否します。語彙インデックスの Term Dictionary と posting list のデコーダも同様に、各ブロック・FST ヘッダー・スキップテーブルを writer が書く内容と照合するため、破損していれば panic せずに破損エラーを返します。デコードはできても辻褄の合わないデータも、誤った検索結果を返さずに破損として拒否します。対象は、順序が乱れた key や UTF-8 として不正な key、昇順でない block-max や NaN・負の factor を持つ block-max、重複・逆行したりセグメントの範囲外にある doc id、そして term・長さ・文書数・ブロックの境界が辞書のエントリと食い違う posting list です - バージョン付きセグメントヘッダー: ベクトルセグメントは共有ヘッダーにフォーマットバージョンを持ち、機能のはしごを形成します。バージョン 2(HNSW のみ)はグラフブロックを 64 ビットのドキュメント ID の代わりにセグメントローカルな 32 ビット ordinal で格納します(ディスク上のグラフブロックはおよそ半分になります)。バージョン 3(全ベクトルインデックス型)はセグメントごとのフィールド名辞書を追加し、各レコードはフィールド名をインラインで繰り返す代わりに 16 ビットの ID で参照します(レコードごとに名前の長さ + 2 バイト分縮小されます)。新しいセグメントはバージョン 3 で書き込まれ、古いビルドが書いたセグメント(バージョン 1・2)もそのままロードでき、次の書き直し(コンパクションやマージ)で更新されます
Write-Ahead Log(WAL)
WALは DocumentLog コンポーネントによって管理され、ストレージバックエンドのルートレベル(engine.wal)に保存されます。
WALエントリタイプ
| エントリタイプ | 説明 |
|---|---|
| Upsert | ドキュメント内容 + 外部ID + 割り当てられた内部ID |
| Delete | 削除するドキュメントの外部ID |
WALファイル
WALファイル(engine.wal)は追記専用のバイナリログです。各エントリは以下を含む自己完結型です。
- 操作タイプ(add/delete)
- シーケンス番号
- ペイロード(ドキュメントデータまたはID)
オンディスクのフレーミング
ファイルは 5 バイトのヘッダ(b"LWAL" マジック + バージョンバイト)で始まり、長さプレフィックス付きレコードが続きます。フレーミングは 3 種類あり、各ファイルは生涯を通じて単一のフレーミングを保持します(古いファイルは次の commit/truncate 時にのみ現行フォーマットへ書き換えられ、1 ファイル内でフォーマットが混在することはありません)。
| バージョン | フレーミング | ペイロード |
|---|---|---|
| v3(現行) | [u32 len][u32 crc32][payload] | コンパクトな rkyv バイナリ レコード |
| v2 | [u32 len][u32 crc32][payload] | JSON レコード(読み取り専用・後方互換) |
| legacy(CRC 以前) | [u32 len][payload] | JSON レコード、チェックサムなし(読み取り専用) |
CRC-32(v2/v3)は len || payload に対して計算され、長さの破損と本体の破損の両方を検出します。reader は 3 形式すべてを復旧できるため、古いビルドで書かれた WAL もアップグレード後に replay できます。
v3 以降、各ペイロードは JSON ではなくコンパクトな rkyv バイナリレコードです。ベクトルは十進文字列ではなく生の f32(各 4 バイト)で格納されるため、ベクトルが多いドキュメントでは WAL がおよそ 2〜3 倍小さくなり、replay もそれに応じて高速化します。耐久性は変わりません(CRC フレーミングとリカバリのセマンティクスは不変)。
リカバリ
エンジンがビルドされる際(Engine::builder(...).build().await)、残っているWALエントリが自動的にチェックされ、リプレイされます(WALはコミット時に切り捨てられるため、残っているエントリはクラッシュしたセッションのものです)。リカバリの最後には自動コミットが実行されます。リプレイされた状態 —
グループコミットで永続化される削除を含む — は
永続化されて即座に検索可能になり、WALは切り捨てられるため、続けてクラッシュしても再リプレイは
発生しません。
graph TD
Start["Engine::build()"] --> Check["Check WAL for\nuncommitted entries"]
Check -->|"Entries found"| Replay["Replay operations\ninto in-memory buffers"]
Replay --> Commit["Auto-commit\n(persist + truncate WAL)"]
Commit --> Ready["Engine ready"]
Check -->|"No entries"| Ready
リカバリは透過的に行われるため、手動で処理する必要はありません。なお、クラッシュ後のオープンは
コミット相当の処理(セグメントフラッシュ、インデックス書き込み)を行うため、そのコミットが
失敗した場合(ディスクフルなど)は Engine::build がエラーを返します。原因解消後の再オープンは
安全です(リプレイは冪等であり、WALはコミット成功後にのみ切り捨てられます)。
呼び出し側の入力に起因して拒否された put/add(フィールドの embedder が対応しない入力型、
フィールドの次元と異なるベクトル、未設定のフィールドなど)は、そもそも WAL に書き込まれません
(ベクトルフィールドの埋め込みと検証は、何かを書き込んだり削除したりするより前に行われます)。
そのため、後続のリカバリが再試行するレコードを残しません。旧バージョンのビルドが書き込んだ
そのようなレコードを既に抱えている index を開いた場合、リカバリはそのレコードをもう一度
再生しますが、警告を記録してそのレコードを破棄し、open 自体は失敗しません。そのレコードを
書き込んだ呼び出しは、当時すでに同じエラーを呼び出し側へ返しているからです。それ以外の理由
(リモート embedder が一時的に到達不能であるなど)での拒否は、従来どおりリカバリ自体が失敗し、
次回のオープンで再試行されます。
コミットライフサイクル
#![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()を呼び出す前に多数のドキュメントをバッチ処理してください。
自動コミットポリシー(Auto-commit Policy)
自分で commit() を呼ぶ代わりに、ビルダーで CommitPolicy を設定して、インジェスト駆動のタイミングでエンジンにコミットラダーを自動実行させることができます。
#![allow(unused)]
fn main() {
use laurus::CommitPolicy;
let engine = Engine::builder(storage, schema)
// 適用ドキュメント 1,000 件ごとに自動コミット。
.commit_policy(CommitPolicy::EveryDocs(1000))
.build()
.await?;
// 明示的な commit() は不要 — 1,000 件目ごとにコミットが発火する。
engine.put_documents(one_thousand_docs).await?;
}
| ポリシー | 挙動 |
|---|---|
Manual(既定) | 自動コミットしない。すべての commit() を自分で駆動する。従来と同一の挙動。 |
EveryDocs(n) | 単数・バッチ両 API を通じて、適用ドキュメント n 件ごとにコミットラダーを実行。EveryDocs(0) は自動コミット無効(Manual と同じ)。 |
Interval(Duration) | 背景タイマーで少なくとも Duration ごとにコミットラダーを実行。インジェストが idle でも末尾の端数がコミットされる。native ターゲットのみ — wasm32(背景スレッドなし)では no-op(WalSyncPolicy::Group の max_interval と同様)。 |
主なセマンティクス:
- group-commit を維持: 各自動コミットは WAL フラッシュ 1 回 + materialization ラダー 1 回であり、ドキュメントごとのコミットには決してならない。1 回の
put_documents/add_documents呼び出し内でも自動コミットはn件ごと(チャンク単位)に発火するため、大きなバッチは最後にまとめて 1 回ではなく逐次 materialize される。末尾の< n件の端数は次の境界か明示commit()まで WAL durable のまま残る。 WalSyncPolicyと直交:CommitPolicyは ストアがいつ materialize するか を、WalSyncPolicyは WAL fsync の耐久性 を決める。commit()は必ず WAL フラッシュから始まるため、自動コミットは任意の WAL ポリシー下で機能する。- クラッシュ意味論は不変: 自動コミットは通常のコミットであり、クラッシュ時は未コミットの tail が手動コミットとまったく同じように再生される。
- 並行性: 正確なタイミングと「ack した書き込みは durable」という保証は、単一 writer のインジェスト(エンジンの書き込みパスが前提とするモデル。CLI・バインディングもこれに従う)で成立する。共有エンジン上の並行 writer 下では auto-commit は best-effort となる: commit ラダーは他スレッドの in-flight write に対してアトミックでないため、並行 auto-commit の実行中に ack された書き込みが次のコミットまで durable にならず、タイミングもドリフトしうる(並行の手動
commit()も同じ race を持つ — auto-commit はそれを ingest 経路から誘発するだけ)。並行下でこれらの保証が必要な場合は、明示コミットか単一インジェストタスクを使うこと。Intervalタイマーは専用スレッドでラダーを実行するため、同じ best-effort の注意が当てはまる。
バッチインジェスト
put_documents / add_documents は put_document / add_document のバッチ形式です。(id, doc) ペアを入力順に逐次適用し、既定の PerRecord ポリシー下ではレコードごとではなくバッチ末尾の 1 回の WAL fsync でバッチ全体を durable にします。
#![allow(unused)]
fn main() {
let docs: Vec<(String, Document)> = build_batch();
engine.put_documents(docs).await?; // fsync 1 回で、全ドキュメントが単発 put と同等に durable
engine.commit().await?; // バッチ全体を 1 回のコミットで公開
}
留意すべき意味論:
- 順序: 1 回の
put_documentsバッチ内で重複した外部 ID は、同じ put を逐次発行した場合とまったく同じようにデデュープされます(最後の出現が勝ち)。add_documentsでの ID の繰り返しは正当なマルチチャンク追加です。 - fail-fast・ロールバックなし: 適用できない最初のドキュメントでバッチは停止し、
LaurusError::BatchIngest { failed_index, failed_id, applied, .. }を返します。失敗前に適用されたapplied件はロールバックされません — WAL と NRT バッファに残り(_idによる解決は即座に可能、検索可能になるのは次のcommit()以降、永続化もそのコミット時、クラッシュ時はリカバリで再生)、エラー経路でもバッチ末尾の WAL フラッシュは実行されます。バッチ全体、またはfailed_indexからの suffix の再試行は put 意味論の下で冪等です。 - 耐久性: 呼び出しが
Okを返した時点で、バッチ内の全ドキュメントは成功した単発 put とまったく同等に durable です。呼び出し途中のクラッシュで失われるのは fsync 前の末尾のみで、リカバリは fsync 済み prefix をドキュメント単位で再生し、途中で切れた末尾レコードは通常どおり CRC フレーミングが破棄します。 - サイズ指針: エンジンは各ドキュメントを WAL へ順次クローンするため、バッチのメモリは呼び出し側の
Vecが支配的です。1 回の呼び出しあたり 1,000〜10,000 ドキュメントが良い既定値で、より大きなコーパスは複数回の呼び出しに分割してください(セグメントサイズを抑えるため定期的なコミットも推奨)。 Groupポリシー下: バッチ中もグループしきい値は発火し続けるため、同ポリシーの有界な損失ウィンドウは保たれます。バッチ末尾のフラッシュも実行されます。- 並行する単発書き込み: 別タスクのバッチ実行中に完了した
put_document/add_document/delete_documentsは per-record の耐久性を完全に維持します — 単発書き込みは ack 前に fsync を再アサートするため、バッチが他の呼び出し元の保証を弱めることはありません。
WAL 耐久性ポリシー
既定では、各 add/delete は返る前に WAL を fsync するため、成功した書き込みがクラッシュで失われることはありません。大量に取り込む場合、この書き込みごとの fsync がスループットのボトルネックになります。WalSyncPolicy により、書き込みごとの耐久性とスループットをトレードオフできます。
| ポリシー | 耐久性 | スループット | 相当 |
|---|---|---|---|
PerRecord(既定) | 成功した書き込みは必ず durable | 書き込みごとに 1 回 fsync で律速 | SQLite synchronous = FULL |
Group { max_records, max_bytes } | クラッシュ時に未 sync の最終バッチまで失う可能性 | fsync をバッチで償却 | SQLite synchronous = NORMAL |
Group では fsync が遅延され、前回の sync 以降に max_records 件または max_bytes バイトのいずれか(先に到達した方)が蓄積した時点で 1 回発行されます。ビルダーで設定します。
#![allow(unused)]
fn main() {
use laurus::WalSyncPolicy;
use std::time::Duration;
let engine = Engine::builder(storage, schema)
// 既定閾値(1024 件 / 1 MiB)でのグループコミット(timer なし)。
.wal_sync_policy(WalSyncPolicy::group_with_defaults())
// ...既定閾値 + 500 ms ごとの定期 flush:
// .wal_sync_policy(WalSyncPolicy::group_with_interval(Duration::from_millis(500)))
// ...または任意のバッチサイズと timer を指定:
// .wal_sync_policy(WalSyncPolicy::Group {
// max_records: 4096,
// max_bytes: 4 * 1024 * 1024,
// max_interval: Some(Duration::from_secs(1)),
// })
.build()
.await?;
}
定期 flush タイマー
Group.max_interval はサイズベースの閾値に時間上限を加えます。設定すると、エンジンは少なくともその間隔ごとに WAL を強制 durable 化する background timer を起動します。これにより、低い取り込みレート(record/byte 閾値に到達しない場合)でも末尾の partial batch が無期限に未 sync のまま残ることを防ぎます。保留中のものが無ければ flush は no-op なので、アイドルな timer のコストはゼロです。None で timer を無効化します。
WASM 注意: timer は native ターゲットのみで有効です。
wasm32には background thread が無いためmax_intervalは無視され、耐久性は record/byte 閾値・commit()・flush_wal()に依存します。
耐久性の保証
-
commit()は両ポリシーで hard barrier です。 いずれのストアを materialize する前に WAL を強制的に durable 化するため、WAL がコミット済みインデックスより durability で劣ることはありません。commit()成功後は、ポリシーに関わらず全データが durable です。 -
flush_wal()は full commit なしでオンデマンドに flush します。Groupにおけるクラッシュ時の損失窓を、アプリ任意の地点で抑えるための手段で、SQLite の WAL チェックポイントに相当します。#![allow(unused)] fn main() { engine.add_document("doc-1", doc1).await?; engine.flush_wal()?; // セグメントをコミットせずに WAL を durable 化 } -
途中で切れた末尾レコードは決して復活しません。 各レコードは CRC-32 でフレーミングされ、リカバリ時にチェックサムに失敗した(または切り詰められた)レコードはそれ以降もろとも破棄されます。よって復旧後のログは常にギャップのない有効な接頭辞であり、グループコミットが失うのは直近書き込みの 末尾(suffix) のみで、それ以前を破損させることはありません。
注意:
Groupはオプトインです。既定のPerRecordポリシーは変更されないため、既存コードは何も変えずに書き込みごとの耐久性を維持します。
コミット耐久性ラダーとクラッシュ安全性
commit() は固定された順序で状態を永続化します。この順序こそが、どの時点でクラッシュ
しても復旧可能であることを保証します。lexical/vector の各ストアはそれぞれ独自の
last_wal_seq チェックポイント(materialize 済みの最後の WAL レコードのシーケンス番号)
を持ち、リカバリで適用済みレコードをスキップできます。永続化される last_wal_seq は
そのストアのオンディスク metadata に保存され、ストアの commit 時にのみ書かれます。
lexical の制御ファイル(metadata.json)には単一の権威があります: index が
所有するメモリ上のコピーです。ストアが commit に使う writer はこれへの共有ハンドルを
持ち、コミットの状態(文書数・削除数・WAL チェックポイント)をそのロックの下で記録して
スナップショットを永続化します。そのため、どのコードパスも古いコピーから
このファイルを上書きできず、内部的な writer(マージエンジンのセグメント再生用 writer
など)はハンドルを持たないためこのファイルに一切触れられません。記録するものが何もない
パスは永続化をスキップし、永続化に失敗した場合はメモリ上のコピーを永続化済みの状態へ
巻き戻します — リトライはそのコミットをもう一度記録します(後述の manifest の
失敗時契約と同じ形です)。
lexical のセグメント発見も同じ権威モデルに従います: コミット済みセグメント集合の
記録である segments.json(原子的に置換されるチェックサム付き manifest)です。
公開は all-or-nothing で、コミットはフラッシュ済みセグメント全部を 1 回の manifest
書き込みで追加し、マージはソースの除去と merged セグメントの挿入を 1 回で行います。
メモリ上のコピーは永続化に成功した最後の manifest を鏡写しにするため(save 失敗時は
pending 状態がリトライ用に残る)、reader の構築は純粋なメモリ読みです — ディレクトリ
列挙もセグメントごとの metadata parse もありません。セグメントごとの .meta は
もう存在しません: manifest が唯一の記録であり、manifest に載っていないファイルは
次の open で回収されます。manifest 導入前に書かれたインデックスは、open 時に一度だけ
レガシー .meta を読んで移行されます。
インデックスの文書数と削除数は manifest から導きます。各エントリはセグメントの
文書数と、そのうち削除された文書の数を記録し(そのセグメントから削除する commit の
たびに、削除ビットマップから数え直します)、インデックスの件数はその合計です。
metadata.json の件数は直近の commit の時点の合計で、インデックスはこれを読み戻し
ません。そのため、manifest の保存と metadata の書き込みの間でのクラッシュ、同じ id の
upsert の繰り返し、削除済みの文書を落とすマージのどれも、件数をずらしません。
古いビルドが書いた manifest は削除数を記録していません。open はビットマップから
数え直すだけで何も書かず、次に manifest を書くときに永続化されます。
セグメントデータ本体はセグメントごとに 1 つのコンパウンドコンテナ
(segment_<N>.cfs — posting・term dictionary・stored documents・field
lengths/statistics・セグメントの doc id の集合・doc values・フィールドごとの
BKD tree を連結し、末尾に
パートテーブルを持つ)として書かれます: flush ごとの create と fsync が
パートごと 1 回ではなく合計 1 回になります。削除ビットマップ(.delmap)は
封印後も書き換えられる唯一のデータとして別ファイルのままです。書き込み自体は
コンテナ本体と同じく一時ファイル書き込み後にリネームする方式です(前述の
項目6)。そのため書き換え途中でクラッシュしても直前にコミット済みのビットマップ
がそのまま残ります。reader は
セグメントごとにレイアウトを検出するため、旧来の loose ファイル形式の
セグメントを持つインデックスもそのまま動き、マージが順次コンテナへ
書き換えます。LAURUS_NO_COMPOUND=1 で移行期のエスケープとして loose
形式に戻せます。セグメントファイルが存在するのに
segments.json が無いインデックスは、黙って空として提供するのではなく明示的に
オープンを拒否します。留意点が 3 つ: ディレクトリごとに書き込みを行うストア
インスタンスは最大 1 つ(並行インスタンスは互いの manifest を上書きします)。
単体の InvertedIndexWriter は ephemeral なツールであり、その commit はセグメントを
どこにも登録しません(manifest 所有ディレクトリ内のそうしたファイルは回収されます)。
そして manifest 以前のバイナリが manifest 以後のインデックスを開くと .meta が
見つからず空に見えます — リリースノートに値する一方向のフォーマット段差です。
コミットラダーは次のとおりです。
flush_wal()— WAL を強制 durable 化(ハードバリア)。Groupでは遅延バッチを fsync し、PerRecordでは no-op。lexical.commit()— lexical セグメントと metadata(last_wal_seqを含む)を書き、sync()。vector.commit()— vector セグメントを書き、sync()。commit_documents()— document store セグメントを書き、sync()。truncate_retaining_after(applied_before)— このコミットが対象とした WAL レコードを 破棄し、コミット開始時点で両ストアへの適用が完了していなかったレコードは保持する (Issue #876)。
この順序は次の 2 つの不変条件を保証します。
- WAL は永続化された index より durable でないことはない。
last_wal_seqはステップ 2 以降でのみ永続化され、必ずステップ 1 のバリアの後に実行されるため、コミット済み index が まだ durable でない WAL レコードを参照することはありません。 - すべてのストアは WAL が truncate される前に完全に durable 化される。 ステップ 2〜4 は
ステップ 5 が対象範囲を破棄する前にそれぞれ
sync()するため、WAL はそれが記述したデータが 安全に materialize された後にのみ破棄されます。
commit() は並行する put/add/delete 呼び出しと直列化されません(CommitPolicy::Interval
の背景タイマーも同じラダーを実行するため同様です)。ステップ 5 はこれを踏まえた設計になって
います: ステップ 1 の実行前にエンジンの ingest high-water mark をスナップショットし、その
スナップショットより後の WAL レコードはすべて保持します。これにより、commit と並行した
mutation はこの commit の materialize には含まれなくても WAL レコードを保持し続け、次回の
リカバリで replay されます。並行する mutation が無い通常ケースでは、スナップショットが WAL
全体をカバーするため、ステップ 5 は従来どおり WAL を空にします。
リカバリは次回の build() で WAL を replay し、各ストアの last_wal_seq 以下のレコードを
スキップします。replay は**冪等(idempotent)**です。各レコードを元々記録された doc_id
の下で再適用するため、再実行は重複ではなく上書きになります。各ストアが独自のチェックポイント
を持つため、途中で失敗した commit は各ストアを異なる last_wal_seq のまま残し、リカバリは
各ストアに不足している分だけを再適用します。(vector store は現状チェックポイントを 0 の
まま保持するため、毎回のリカバリで保持中の WAL を全件 replay します。正しく冪等ですが、
最適化はまだです。)
次の表は各ステップでクラッシュした場合の結果を示します(ステップ 1 のバリアが既に走っている
ため、PerRecord と Group で同一です)。
| クラッシュ地点 | ディスク上で durable | リカバリの結果 |
|---|---|---|
| ステップ 1 の後、2 の前 | WAL のみ | 保留中の全レコードを両ストアへ replay |
| ステップ 2 の後、3 の前 | WAL + lexical(last_wal_seq = N) | lexical は ≤ N をスキップ、vector は WAL から replay |
| ステップ 3 の後、4 の前 | WAL + lexical + vector | 両ストアがスキップ、documents は WAL から復元 |
| ステップ 4 の後、5 の前 | WAL + 全ストア | WAL は残存、両ストアがスキップ、重複なし |
| ステップ 5 の後 | 全ストア。並行 mutation が無ければ WAL は空 | replay 対象なし(並行 mutation があればそのレコードのみ) |
コミット済み index が失われた WAL レコードを参照する interleaving は存在しないため、group
commit は文書化された契約(flush_wal() や commit() でまだ durable 化されていない書き込みの
末尾 をクラッシュで失い得る)を超える新たな耐久性ギャップを生みません。
ストレージレイアウト
エンジンは 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
<storage root> はレイアウトに依存しない抽象概念であり、エンジン自体は
schema.toml の存在や、ストレージルートがより大きなディレクトリのどこに
配置されるかを一切関知しません。laurus-cli・laurus-server・各言語
バインディング(Python・Node.js・Ruby・PHP)はいずれも <storage root> を
<index_dir>/store/ に配置し、スキーマを保持する <index_dir>/schema.toml
と対にして扱います。これはエンジンではなく、これらのエントリポイントが
独自に採用している規約です。この規約のおかげで、いずれか1つで作成した
インデックスディレクトリを、ディスク上の構造を変更することなく他のもので
そのまま開けます。作成・再オープン時の正確な挙動は各バインディングの
Index/create index のドキュメントを参照してください(特に、既存
インデックスを再オープンする際はディレクトリパスのみで足り、永続化済みの
スキーマが自動的に読み込まれます)。
次のステップ
- 削除の処理方法: 削除とコンパクション
- ストレージバックエンド: Storage
削除とコンパクション
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"]
- ドキュメントの内部IDが**削除ビットマップ(Deletion Bitmap)**に追加されます
- 検索時にビットマップがチェックされ、削除されたドキュメントが結果からフィルタリングされます
- 元のデータはセグメントファイルに残ったままです
これはレキシカルインデックスと**ベクトル(HNSW)**インデックスの両方に一様に適用されます。HNSW では 削除されたノードはグラフに残り(グラフの連結性を保つためそのベクトルは引き続き利用されます)、 削除認識トラバーサルが結果から除外します。したがって削除で グラフが再構築されることはなく、コストはインデックスサイズに依らず O(1) のビットマップマークだけです。 物理的な回収は後段のコンパクションで行われます。
論理削除を採用する理由
| メリット | 説明 |
|---|---|
| 速度 | O(1) – ビットの反転は即座に完了 |
| 不変セグメント | セグメントファイルはインプレースで変更されないため、並行性の管理が簡素化 |
| 安全なリカバリ | クラッシュが発生しても、削除ビットマップはWALから再構築可能 |
Upsert(更新 = 削除 + 挿入)
既存の外部IDでドキュメントをインデックスすると、Laurusは自動的にUpsertを実行します。
- 古いドキュメントが論理削除されます(そのIDが削除ビットマップに追加)
- 新しい内部IDで新しいドキュメントが挿入されます
- 外部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
コンパクションの処理内容
- 既存セグメントからすべての生存(未削除)ドキュメントを読み取ります
- 削除済みエントリを含まない転置インデックスやベクトルインデックスを再構築します
- 新しいクリーンなセグメントファイルを書き込みます
- 古いセグメントファイルを削除します
- 削除ビットマップをリセットします
コストと頻度
| 側面 | 詳細 |
|---|---|
| CPUコスト | 高い – インデックス構造をゼロから再構築 |
| I/Oコスト | 高い – すべてのデータを読み取り、新しいセグメントを書き込み |
| ブロッキング | コンパクション中も検索は継続可能(新しいセグメントが準備できるまで古いセグメントが参照される) |
| 頻度 | 削除済みドキュメントがしきい値を超えた場合に実行(例: 全体の10-20%) |
コンパクションのタイミング
- 書き込みが少ないワークロード: 定期的にコンパクション(例: 毎日または毎週)
- 書き込みが多いワークロード: 削除率がしきい値を超えた場合にコンパクション
- バルク更新後: 大量のUpsertバッチの後にコンパクション
自動コンパクション
HNSW ベクトルインデックスではコンパクションを自動実行できます。DeletionConfig::auto_compaction
が有効な場合(既定)、commit() が削除率(削除ノード数 / コミット済み総ノード数)を確認し、
DeletionConfig::compaction_threshold(既定 0.3)に達するとコンパクションを起動します。
コンパクション後は削除率が 0 に戻るため、削除が再度蓄積するまで再発火せず、手動 optimize() なしに
tombstone の増加を抑えられます。自分で制御したい場合は auto_compaction を false にします。
削除ビットマップ
削除ビットマップは、どの内部IDが削除されたかを追跡します。
- 保存: 削除済みドキュメントIDの Roaring ビットマップ。 セグメント寿命で累積する密な削除集合では、生のID列より劇的に小さくなります。例えば 10M ドキュメント・10% 削除のセグメントは on-disk で ~8MB ではなく ~125KB です。
- 検索: 分岐の少ないビットテスト。削除集合が大きくても CPU キャッシュに常駐しやすく、
is_deletedは lexical の per-document・vector の per-neighbour 検索ホットパスで呼ばれます。 lexical のセグメント reader は、開くときに一度だけセグメントのビットマップを読み、以後は 変えないので、判定で lock を取りません。さらに削除をセグメントの ID 範囲の単純なビットセット (1 ID 1 ビット。Lucene のFixedBitSet・Tantivy のAliveBitSetと同じ形)に写すので、 判定は Roaring のコンテナを探索せず、ビットを 1 つ調べるだけです。範囲の幅が文書数の 8 倍を 超えるセグメント(ビットセットが 1 文書あたり 1 バイトを超える場合)は、Roaring ビットマップ だけを使います。
ビットマップはインデックスセグメントと一緒に(.delmap ファイルとして)永続化され、リカバリ時に
WALから再構築されます。on-disk 形式はバージョン管理されており、現在の writer は v4(Roaring)を
書き出し、reader は後方互換のため旧 v1〜v3(生ID列)形式も読み込めます。
セグメントの .delmap は、そのセグメントを開くときに読み込まれます。ファイルは存在するのに
読めない場合は破損エラーになります。検索のために開くとき、マージするとき、writer の削除管理が
読み込むときのいずれも同じです。「削除なし」として扱うことはありません。そう扱うと、削除済みの
ドキュメントが検索結果や件数に戻ったり、マージ後のセグメントに持ち込まれたりするためです。
なお、メタデータには削除があると記録されているのに .delmap ファイルが無いセグメントは、
従来どおり削除のないセグメントとして開きます。
グループコミットによる永続化
削除状態は削除のたびではなく、コミットごとに 1 回永続化されます。各削除
(upsert の delete-first ステップを含む)はメモリ内のビットマップだけを更新し、
.delmap ファイルとセグメントの has_deletions メタデータフラグは commit() 実行時に
まとめて書き出されます。これにより既存 ID の upsert ごとに発生していた複数の fsync が
なくなり、更新の多いインジェストで効果があります。
耐久性は変わりません。WAL はインデックス変更の前にすべての削除を記録するため、
コミット前にクラッシュしても次回起動時に削除が replay されます。さらにリカバリの最後に
自動コミットが実行されるため、replay された状態(削除を含む)は再オープン直後から
検索可能です。この結果、削除の可視性はコミットスコープになります。新規追加
ドキュメントと同様に、削除は次の commit() 後に検索へ反映されます(未コミットバッチ内の
upsert 重複排除は別経路で処理され、常に正しく動作します)。
次のステップ
エラーハンドリング
Laurusはすべての操作に統一的なエラー型を使用します。エラーシステムを理解することで、障害を適切に処理する堅牢なアプリケーションを作成できます。
LaurusError
Laurusのすべての操作は Result<T> を返します。これは std::result::Result<T, LaurusError> のエイリアスです。
LaurusError は、各カテゴリの障害に対応するバリアントを持つenumです。
| バリアント | 説明 | 一般的な原因 |
|---|---|---|
Io | I/Oエラー | ファイルが見つからない、権限拒否、ディスク容量不足 |
Index | インデックス操作エラー | インデックスの破損、セグメント読み取り失敗 |
Schema | スキーマ関連のエラー | 不明なフィールド名、型の不一致 |
Analysis | テキスト解析エラー | トークナイザーの失敗、無効なフィルタ設定 |
Query | クエリの解析/実行エラー | 不正なQuery DSL、クエリ内の不明なフィールド |
Storage | ストレージバックエンドエラー | ストレージのオープン失敗、書き込み失敗 |
Field | フィールド定義エラー | 無効なフィールドオプション、重複するフィールド名 |
InvalidArgument | 呼び出し側が渡した引数が不正 | DSL クエリ内の不明なフィールド、ドキュメントの値の型の誤り、範囲外の値 |
BenchmarkFailed | ベンチマークエラー | ベンチマーク実行失敗 |
ThreadJoinError | スレッド join エラー | ワーカースレッドでのパニック |
Json | JSONシリアライズエラー | 不正なドキュメントJSON |
Anyhow | anyhow ラップエラー | 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 によるエラータイプの確認
LaurusError は std::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) | InvalidArgument バリアント |
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::Error | LaurusError::Io |
serde_json::Error | LaurusError::Json |
anyhow::Error | LaurusError::Anyhow |
次のステップ
拡張性
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> | サポートする入力タイプを宣言(Text、Image) |
as_any(&self) -> &dyn Any | ダウンキャストを可能にする |
オプションメソッド
| メソッド | デフォルト | 説明 |
|---|---|---|
async embed_batch(&self, inputs) -> Result<Vec<Vector>> | embed への逐次呼び出し | バッチ最適化のためにオーバーライド |
name(&self) -> &str | "unknown" | ログ出力用の識別子 |
supports(&self, input_type) -> bool | supported_input_types をチェック | 入力タイプのサポート確認 |
supports_text() -> bool | Text を確認 | テキストサポートの簡略確認 |
supports_image() -> bool | Image を確認 | 画像サポートの簡略確認 |
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() -> LoadingMode | LoadingMode::Eager | 推奨されるデータロードモード |
スレッドセーフティ
3つのトレイトすべてが Send + Sync を要求します。つまり、実装はスレッド間で安全に共有できる必要があります。共有可能な可変状態には Arc<Mutex<_>> またはロックフリーデータ構造を使用してください。
次のステップ
- エラーハンドリング – カスタム実装でのエラー処理
- テキスト解析 – 組み込みのAnalyzerとパイプラインコンポーネント
- Embedding – 組み込みのEmbedderオプション
- Storage – 組み込みのStorageバックエンド
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.put_documents(docs).await? | (id, doc) ペアのバッチ Upsert — WAL fsync はバッチごとに 1 回(バッチインジェスト 参照) |
engine.add_documents(docs).await? | (id, doc) ペアのバッチチャンク追加 — 耐久性・エラー意味論は put_documents と同一 |
engine.delete_documents(id).await? | 外部IDによるすべてのドキュメント/チャンクの削除 |
engine.get_documents(id).await? | 外部IDによるすべてのドキュメント/チャンクの取得 |
engine.search(request).await? | 検索リクエストの実行 |
engine.commit().await? | 保留中のすべての変更をストレージにフラッシュ |
engine.flush_wal()? | full commit なしで WAL を durable 化(WAL 耐久性ポリシー参照) |
engine.add_field(name, field_option).await? | 稼働中のエンジンにフィールドを動的に追加 |
engine.delete_field(name).await? | 稼働中のエンジンからフィールドを動的に削除 |
engine.schema() | 現在のスキーマへの参照を取得 |
engine.stats()? | インデックス統計の取得 |
put_documentとadd_documentの違い:put_documentはUpsertを実行します。同じ外部IDのドキュメントが既に存在する場合、削除して置き換えます。add_documentは常に追加し、複数のドキュメントチャンクが同じ外部IDを共有できます。詳細は Schema & Fields – ドキュメントのインデキシング を参照してください。バッチ形式:
put_documents/add_documentsは(id, doc)ペアを入力順に逐次適用します(1 回のput_documentsバッチ内で重複した ID は逐次 put と同じくデデュープされ、最後の出現が勝ちます)。適用できないドキュメントに遭遇すると fail-fast でLaurusError::BatchIngest { failed_index, failed_id, applied, .. }を返します。バッチロールバックはありません: 適用済みドキュメントは WAL と NRT バッファに残るため、バッチ(またはその suffix)の再試行は冪等です。耐久性の詳細はバッチインジェストを参照してください。
EngineBuilder
| メソッド | 説明 |
|---|---|
EngineBuilder::new(storage, schema) | StorageとSchemaでBuilderを作成 |
.analyzer(Arc<dyn Analyzer>) | テキストAnalyzerを設定(デフォルト: StandardAnalyzer) |
.embedder(Arc<dyn Embedder>) | ベクトルEmbedderを設定(オプション) |
.wal_sync_policy(policy) | WAL 耐久性ポリシーを設定(デフォルト: WalSyncPolicy::PerRecord。WAL 耐久性ポリシー参照) |
.build().await? | Engine を構築 |
Schema
ドキュメント構造を定義します。
| メソッド | 説明 |
|---|---|
Schema::builder() | SchemaBuilder を作成 |
SchemaBuilder
| メソッド | 説明 |
|---|---|
.add_text_field(name, TextOption) | 全文検索フィールドを追加(TextOption::multi_valued = true で文字列配列対応。TextOption::position_increment_gap(デフォルト 100)はフレーズクエリが要素をまたがないように要素間で読み飛ばす位置数) |
.add_integer_field(name, IntegerOption) | 整数フィールドを追加(IntegerOption::multi_valued = true で多値配列対応) |
.add_float_field(name, FloatOption) | 浮動小数点フィールドを追加(FloatOption::multi_valued = true で多値配列対応) |
.add_boolean_field(name, BooleanOption) | 真偽値フィールドを追加(BooleanOption::multi_valued = true で真偽値配列対応) |
.add_datetime_field(name, DateTimeOption) | 日時フィールドを追加(DateTimeOption::multi_valued = true で時刻配列対応) |
.add_geo_field(name, GeoOption) | 2D 地理(緯度/経度)フィールドを追加(GeoOption::multi_valued = true でポイント配列対応) |
.add_geo3d_field(name, Geo3dOption) | 3D ECEF 直交座標系の点フィールド(x, y, z メートル単位)を追加(Geo3dOption::multi_valued = true でポイント配列対応) |
.add_bytes_field(name, BytesOption) | バイナリフィールドを追加(BytesOption::multi_valued = true でバイト列配列対応。Bytes はそもそもインデックスされないため、これは保存時の形と取り込み時の許容個数を変えるだけ) |
.add_hnsw_field(name, HnswOption) | HNSWベクトルフィールドを追加 |
.add_flat_field(name, FlatOption) | Flatベクトルフィールドを追加 |
.add_ivf_field(name, IvfOption) | IVFベクトルフィールドを追加 |
.add_multi_vector_field(name, MultiVectorOption) | MultiVector フィールドを追加。late interaction の再採点に使う文書ごとのトークンベクトルで、検索対象ではない。.storage(MultiVectorStorage) でトークンベクトルのディスク上の要素種別を指定できる(F32 がデフォルトで正確、F16 は2倍小さい、Int8 は約4倍小さいが非可逆) |
.add_default_field(name) | デフォルト検索フィールドを設定 |
.add_analyzer(name, AnalyzerDefinition) | カスタム Analyzer パイプラインを登録。組み込み Analyzer 用に予約された名前(standard、keyword、english、simple、noop)を使うと .build() は panic し、.try_build() はエラーを返す。ほかの方法で作った Schema(Schema::from_toml など)は Schema::validate_for_create で検査する |
.add_embedder(name, EmbedderDefinition) | Embedder 定義を登録 |
.dynamic_field_policy(DynamicFieldPolicy) | 未宣言フィールドのポリシー(Strict / Dynamic / Ignore)を設定 |
.build() | Schema を構築 |
上記のどの add_*_field メソッドも _(_id を除く)で始まるフィールド名をそのまま受け付けるが、実際に拒否されるのは Schema::validate_for_create による検査の時点のみ — ここでは .build() / .try_build()、Schema::from_toml など別経路で作った 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_geo_array(name, points) | 多値 2D 地理フィールドを追加(Vec<GeoPoint>) |
.add_geo_ecef_array(name, points) | 多値 3D ECEF フィールドを追加(Vec<GeoEcefPoint>) |
.add_datetime_array(name, values) | 多値日時フィールドを追加(Vec<DateTime<Utc>>) |
.add_bool_array(name, values) | 多値真偽値フィールドを追加(Vec<bool>) |
.add_text_array(name, values) | 多値テキストフィールドを追加(Vec<String>) |
.add_bytes(name, data) | バイナリデータを追加 |
.add_bytes_array(name, values) | 多値バイナリフィールドを追加(Vec<(Vec<u8>, Option<String>)>。各要素が独自の任意 MIME タイプを持つ) |
.add_vector_array(name, vectors) | MultiVector フィールドのトークンベクトルを追加(Vec<Vec<f32>>) |
.build() | Document を構築 |
Search
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検索のソート順を設定 |
.highlight(fields) | これらの保存済みテキストフィールドに対するハイライトを要求(ハイライト) |
.highlight_config(HighlightConfig) | ハイライトに使うタグ・フラグメント・require_field_match の設定を指定 |
.vector_score_mode(VectorScoreMode) | Vector検索のスコア結合モードを設定 |
.vector_min_score(f32) | Vector検索の最小スコアしきい値 |
.rescore(RescoreOptions) | 1段目の上位候補を late interaction で並べ替える(late interaction による再採点) |
.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
| フィールド | 型 | 説明 |
|---|---|---|
id | String | 外部ドキュメントID |
score | f32 | 関連度スコア(再採点した結果では late interaction のスコア) |
document | Option<Document> | ドキュメント内容(ロードされた場合) |
highlights | HashMap<String, Vec<String>> | .highlight() で要求したフィールドごとのハイライト済みフラグメント。未要求またはマッチなしの場合は空(ハイライト) |
FusionAlgorithm
| バリアント | 説明 |
|---|---|
RRF { k: f64 } | Reciprocal Rank Fusion(デフォルト k=60.0) |
WeightedSum { lexical_weight, vector_weight } | スコアの線形結合 |
RescoreOptions
| 項目 | 説明 |
|---|---|
RescoreOptions::late_interaction(field, query_vectors) | MultiVector フィールドに対して、クエリのトークンベクトル(Vec<Vector>)で late interaction(MaxSim)の再採点を行う |
RescoreOptions::late_interaction_text(field, text) | 同上。クエリをテキストで渡し、フィールドのトークン単位の Embedder が埋め込む |
.window_size(n) | 再採点する1段目の上位候補の件数(デフォルト 100、上限は RescoreOptions::MAX_WINDOW_SIZE = 10,000) |
Rescorer::LateInteraction { field, query } | コンストラクタが作る再採点方法。query は LateInteractionQuery::Vectors(Vec<Vector>) か LateInteractionQuery::Text(String)。どちらの enum も #[non_exhaustive] |
クエリタイプ(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 を参照 |
DateTimeRangeQuery::between(field, start, end) | 日時範囲(new(...) は Option<DateTime<Utc>> の境界、from_literals(...)? は DSL リテラルを受け取る) | 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-NN | 3D 地理検索 を参照 |
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
| 型 | 説明 |
|---|---|
StandardAnalyzer | RegexTokenizer + 小文字化 + ストップワード |
SimpleAnalyzer | トークン化のみ(フィルタリングなし) |
EnglishAnalyzer | RegexTokenizer + 小文字化 + 英語ストップワード |
JapaneseAnalyzer | 日本語形態素解析 |
KeywordAnalyzer | トークン化なし(完全一致) |
PipelineAnalyzer | カスタムTokenizer + フィルタチェーン |
PerFieldAnalyzer | フィールドごとのAnalyzerディスパッチ |
Embedder
| 型 | Feature Flag | 説明 |
|---|---|---|
CandleBertEmbedder | embeddings-candle | ローカルBERTモデル |
OpenAIEmbedder | embeddings-openai | OpenAI API |
CandleClipEmbedder | embeddings-multimodal | ローカルCLIPモデル |
CandleColbertEmbedder | embeddings-candle | MultiVector フィールド用のトークンベクトルを出すローカル ColBERT モデル(with_options(model, CandleColbertOptions) で revision と長さを指定) |
PrecomputedEmbedder | (デフォルト) | 事前計算済みベクトル |
PerFieldEmbedder | (デフォルト) | フィールドごとのEmbedderディスパッチ |
トークン単位の Embedder は TokenEmbedder(embed_tokens(inputs, EmbedRole)、token_dimension())も実装し、Embedder::as_token_embedder() でそれを返します。トークン単位の 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 フィールドオプションが必要) |
DataValue::GeoArray(Vec<GeoPoint>) | 多値 2D 地理ポイント(multi_valued フィールドオプションが必要) |
DataValue::GeoEcefArray(Vec<GeoEcefPoint>) | 多値 3D ECEF ポイント(multi_valued フィールドオプションが必要) |
DataValue::DateTimeArray(Vec<DateTime<Utc>>) | 多値の時刻(multi_valued フィールドオプションが必要) |
DataValue::BoolArray(Vec<bool>) | 多値の真偽値(multi_valued フィールドオプションが必要) |
DataValue::TextArray(Vec<String>) | 多値の文字列(multi_valued フィールドオプションが必要) |
DataValue::BytesArray(Vec<(Vec<u8>, Option<String>)>) | 多値のバイナリデータ。各要素が独自の任意 MIME タイプを持つ(multi_valued フィールドオプションが必要。インデックスされない) |
DataValue::VectorArray(Vec<Vec<f32>>) | MultiVector フィールドのトークンベクトル(フィールドの次元を持つベクトル 1〜8,192 本。文書ストアには保存されない) |
CLI 概要
Laurus はコマンドラインツール laurus を提供しており、コードを書かずにインデックスの作成、ドキュメントの管理、検索クエリの実行が可能です。
機能
- インデックス管理 – TOML スキーマファイルからインデックスを作成・検査。対話式スキーマジェネレーター付き
- ドキュメント CRUD – JSON によるドキュメントの追加、取得、削除
- 検索 – Query DSL を使用したクエリ実行
- デュアル出力 – 人間が読みやすいテーブル形式または機械処理向け JSON 形式
- 対話型 REPL – ライブセッションでインデックスを操作
- gRPC サーバー –
laurus serveで gRPC サーバーを起動
はじめに
# インストール
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 '{"fields":{"title":"Hello","body":"World"}}'
# 変更をコミット
laurus --index-dir ./my_index commit
# 検索
laurus --index-dir ./my_index search "body:world"
詳細はサブセクションを参照してください:
- インストール – CLI のインストール方法
- コマンドリファレンス – 全コマンドの詳細
- スキーマフォーマット – スキーマ TOML フォーマットのリファレンス
- REPL – 対話モード
インストール
ビルド済みバイナリ
GitHub のリリースページでは、
以下のターゲット向けにビルド済みの laurus バイナリを配布しています。
いずれも --features embeddings-all でビルドされています。
| ターゲットトリプル | プラットフォーム | libc | リンク方式 | アーカイブ |
|---|---|---|---|---|
x86_64-unknown-linux-gnu | Linux x86_64 | glibc | 動的 | .tar.gz |
aarch64-unknown-linux-gnu | Linux aarch64 | glibc | 動的 | .tar.gz |
x86_64-unknown-linux-musl | Linux x86_64 | musl | 静的 | .tar.gz |
aarch64-unknown-linux-musl | Linux aarch64 | musl | 静的 | .tar.gz |
x86_64-apple-darwin | macOS Intel | – | 動的 | .tar.gz |
aarch64-apple-darwin | macOS Apple Silicon | – | 動的 | .tar.gz |
x86_64-pc-windows-msvc | Windows x86_64 | MSVC | 動的 | .zip |
aarch64-pc-windows-msvc | Windows arm64 | MSVC | 動的 | .zip |
VERSION=v0.13.2
TARGET=x86_64-unknown-linux-musl
curl -fsSL -O "https://github.com/mosuka/laurus/releases/download/${VERSION}/laurus-${VERSION}-${TARGET}.tar.gz"
tar -xzf "laurus-${VERSION}-${TARGET}.tar.gz"
./laurus --version
どのビルドを使うべきか
- 一般的な Linux ディストリビューションでは gnu ビルドを使ってください。
musl のアロケータ(
mallocng)は、laurus の索引処理のようなマルチスレッド・ allocation-heavy なワークロードでは glibc のアロケータより明確に遅くなります。 - Alpine、distroless、
scratch、あるいはビルドランナーより古い glibc しか 持たないホストでは musl ビルドを使ってください。musl バイナリは 完全に静的リンクされており、動的ライブラリへの依存が一切ありません。
コンテナで musl バイナリを使う
FROM alpine:3.22
# embeddings-openai を使う場合のみ必要: reqwest の rustls backend は
# OS の信頼ストアを参照します。Hugging Face からのモデルダウンロード
# (embeddings-candle / embeddings-multimodal)はバイナリに埋め込まれた
# ルート証明書を使うため、これがなくても動作します。詳細は開発ガイドの
# 「Feature Flags」を参照してください。
RUN apk add --no-cache ca-certificates
COPY laurus /usr/local/bin/laurus
ENTRYPOINT ["laurus"]
パッケージマネージャすら無い環境でも動作します:
FROM scratch
COPY laurus /laurus
ENTRYPOINT ["/laurus"]
musl / scratch での運用における注意点:
- DNS 解決には musl 内蔵のリゾルバが使われ、
/etc/resolv.confを読み込み、 NSS プラグインは無視されます。コンテナランタイムは常に/etc/resolv.confを提供するため通常は問題になりませんが、手動でscratchイメージを 構築する場合はこれを省略しないよう注意してください。 - Rust の標準ライブラリは生成するスレッドに独自の既定スタックサイズ
(2 MiB)を使用するため、musl のより小さい
pthreadの既定値は 実質的に問題になりません。
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 の使い方を順を追って説明します。
前提条件
- laurus CLI がインストール済み(インストール を参照)
Step 1: スキーマの作成
まず、インデックスの構造を定義するスキーマファイルを作成します。対話形式で生成することもできます:
laurus create schema
対話ウィザードがフィールドの定義をガイドします。このチュートリアルでは、手動でスキーマファイルを作成します:
cat > schema.toml << 'EOF'
default_fields = ["title", "body"]
[fields.title.Text]
indexed = true
stored = true
term_vectors = true
[fields.body.Text]
indexed = true
stored = true
term_vectors = true
[fields.category.Text]
indexed = true
stored = true
term_vectors = false
EOF
3 つのテキストフィールドを定義しています。default_fields を設定することで、フィールド指定なしのクエリは title と body の両方を検索します。
Step 2: インデックスの作成
スキーマを使ってインデックスを作成します:
laurus --index-dir ./tutorial_data create index --schema schema.toml
インデックスが作成されたことを確認します:
laurus --index-dir ./tutorial_data get stats
ドキュメント数が 0 であることが表示されます。
Step 3: ドキュメントの登録
ドキュメントをインデックスに追加します。各ドキュメントには ID と {"fields": {...}} 形式の JSON オブジェクトが必要です:
laurus --index-dir ./tutorial_data add doc \
--id doc001 \
--data '{"fields":{"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 '{"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"}}'
laurus --index-dir ./tutorial_data add doc \
--id doc003 \
--data '{"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"}}'
Step 4: 変更のコミット
ドキュメントはコミットするまで検索対象になりません:
laurus --index-dir ./tutorial_data commit
Step 5: ドキュメントの検索
基本的な検索
“rust” を含むドキュメントを検索します:
laurus --index-dir ./tutorial_data search "rust"
デフォルトフィールド(title と body)が検索されます。doc001 と doc002 が返されます。
フィールド指定検索
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 {"fields":{"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
次のステップ
- スキーマフォーマットで高度なフィールド設定を学ぶ
- コマンドリファレンスで全コマンドを確認する
- REPLでインタラクティブな操作を深める
- サーバーチュートリアルで gRPC/HTTP アクセスを試す
コマンドリファレンス
グローバルオプション
すべてのコマンドで以下のオプションが使用できます:
| オプション | 環境変数 | デフォルト | 説明 |
|---|---|---|---|
--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>] [--train-pq-codebook <JSONL>]
引数:
| フラグ | 必須 | 説明 |
|---|---|---|
--schema <FILE> | いいえ | インデックススキーマを定義する TOML ファイルのパス。省略時はインデックスディレクトリに既存の schema.toml があればそれを使用し、なければ対話型ウィザードが起動します。 |
--train-pq-codebook <JSONL> | いいえ | インデックス作成の一部として共有 PQ codebook を学習します(Issue #920)。ProductQuantization(または pq-fastscan feature 有効時は ProductQuantizationFastScan)+ pq_codebook_path を設定したすべての HNSW フィールドを、作成直後にこの JSONL ファイル(put docs / add docs と同じ形式、フィールド値は素の数値配列)から学習します。最初の commit がすぐに codebook でエンコードできるため、create → train pq-codebook → ingest の順序を手動で守る必要がなくなります。ファイル不在または対象フィールドなしの場合は、何も作成する前にエラーになります。 |
スキーマファイルの形式:
スキーマファイルは 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.
# 作成と共有 PQ codebook の学習を1ステップで(Issue #920)
laurus --index-dir ./my_index create index --schema schema.toml \
--train-pq-codebook train.jsonl
# Index created at ./my_index.
# Training PQ codebook for field 'embedding' on 300 vectors...
# Trained codebook 'embedding.pqcb' (m = 4, k = 256, sub_dim = 8, dimension = 32) from 300 vectors.
注意:
schema.tomlとstore/の両方が存在する場合はエラーが返されます。再作成するにはインデックスディレクトリを削除してください。schema.tomlのみ存在する場合(作成が中断された場合など)は、--schemaなしでcreate indexを実行すると既存スキーマからストレージが復旧されます。
create schema
対話式ウィザードを通じてスキーマ TOML ファイルを生成します。
laurus create schema [--output <FILE>]
引数:
| フラグ | 必須 | デフォルト | 説明 |
|---|---|---|---|
--output <FILE> | いいえ | schema.toml | 生成されるスキーマの出力ファイルパス |
ウィザードは以下の手順で進みます:
- フィールド定義 — フィールド名を入力し、型を選択し、型固有のオプションを設定
- 繰り返し — 必要な数だけフィールドを追加
- デフォルトフィールド — デフォルトの検索対象とする Lexical フィールドを選択
- プレビュー — 保存前に生成された TOML を確認
- 保存 — スキーマファイルを書き出し
サポートされるフィールド型:
| 型 | カテゴリ | オプション |
|---|---|---|
Text | Lexical | indexed, stored, term_vectors |
Integer | Lexical | indexed, stored |
Float | Lexical | indexed, stored |
Boolean | Lexical | indexed, stored |
DateTime | Lexical | indexed, stored |
Geo | Lexical | indexed, stored |
Geo3d | Lexical | indexed, stored |
Bytes | Lexical | stored |
Hnsw | Vector | dimension, distance, m, ef_construction |
Flat | Vector | dimension, distance |
Ivf | Vector | dimension, 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 は {"fields": {...}} 形式です: 各フィールド名にその素の値を対応付けます。型タグはありません
— 値の宣言済みスキーマ型(未宣言フィールドの場合は推論された型)が曖昧さを解決します。
{
"fields": {
"title": "Introduction to Rust",
"body": "Rust is a systems programming language.",
"year": 2024
}
}
例:
laurus add doc --id doc1 --data '{"fields":{"title":"Hello World","body":"This is a test document."}}'
# Document 'doc1' added. Run 'commit' to persist changes.
ヒント: 複数のドキュメントが同じ外部 ID を共有できます(チャンキングパターン)。各チャンクに対して
add docを使用してください。
add docs
JSONL ファイルからドキュメントチャンクをバルク追加します — 1 行に 1 エントリの {"id": "...", "fields": {...}} 形式で、外部 ID は add doc --data と同じ fields 形式に並ぶトップレベルキーです。エントリはエンジンのバッチ API(バッチごとに WAL fsync 1 回)で適用され、add doc と異なり自動的にコミットします(--commit-every 件ごと + 最後に 1 回)。
laurus add docs --file <JSONL> [--batch-size 1000] [--commit-every 0]
引数:
| フラグ | 必須 | 説明 |
|---|---|---|
--file <JSONL> | はい | 取り込む JSONL ファイルのパス |
--batch-size <N> | いいえ | エンジンのバッチ呼び出しあたりのドキュメント数(既定 1000) |
--commit-every <N> | いいえ | N 件適用ごとにコミット。0 = 最後の 1 回のみ(既定) |
繰り返した ID はチャンクとして蓄積されます。途中で失敗した場合、エラーは該当行を示し、適用済みの prefix はコミットされるため、残りの行から再実行してインジェストを継続できます。
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 '{"fields":{"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を使用してください。
put docs
JSONL ファイルからドキュメントをバルク Upsert します — 1 行に 1 エントリの {"id": "...", "fields": {...}} 形式で、エンジンのバッチ API(バッチごとに WAL fsync 1 回)で適用されます。重複した ID は順にデデュープされます(最後の出現が勝ち)。add docs と同じく自動的にコミットします。
laurus put docs --file <JSONL> [--batch-size 1000] [--commit-every 0]
引数は add docs と同じです。途中で失敗した場合、エラーは該当行を示し、適用済みの prefix はコミットされます。put は冪等なので、ファイル全体(または残りの suffix)の再実行は安全です。
例:
cat > docs.jsonl <<'JSONL'
{"id": "doc1", "fields": {"title": "Hello"}}
{"id": "doc2", "fields": {"title": "World"}}
JSONL
laurus put docs --file docs.jsonl
# 2 documents put (upserted) and committed.
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.
train
train pq-codebook
HNSW ベクトルフィールド用の共有 PQ codebook を学習します (Issue #631)。codebook を代表サンプルで一度だけ学習し、以後の commit と merge のすべてで再利用します — segment ごとの k-means 再学習が無くなるため PQ フィールドの commit は大幅に高速化し、 小さな per-commit segment も PQ を維持します。
laurus train pq-codebook --field <FIELD> (--input <JSONL> | --from-index) \
[--sample-size <N>] [--output <NAME>] [--update-schema]
| 引数 | 説明 |
|---|---|
--field | 学習対象の HNSW ベクトルフィールド。ProductQuantization quantizer(または pq-fastscan feature 有効時は ProductQuantizationFastScan — その場合 codebook は k=16 で学習されます、Issue #920)が設定されている必要があります。 |
--input | JSONL 学習ファイル — put docs / add docs と同じ {"id": "...", "fields": {...}} 形式。フィールド値は素の数値配列(例: "embedding": [0.1, 0.2, ...])である必要があります(embedder 生成の入力は未対応)。--input と --from-index はどちらか一方のみ指定できます。 |
--from-index | ファイルの代わりに、このインデックスにコミット済みのベクトルを直接サンプリングします(Issue #920)— JSONL エクスポート不要。--input と --from-index はどちらか一方のみ指定できます。注意: 既に PQ エンコード済みのフィールドではサンプルは有損の再構成ベクトルになります。想定フローはフィールドの PQ 有効化前にコミットしたベクトルからの学習です。 |
--sample-size | 先頭 N 件のみを使用(決定的: --input はファイル順、--from-index は doc_id 昇順)。省略時は全件を使用。代表的なベクトル数千件で十分です。 |
--output | ストレージ相対の codebook ファイル名。デフォルトはフィールドの pq_codebook_path、未設定なら {field}.pqcb。稼働中の codebook の横に v2 を学習する場合に使用。 |
--update-schema | フィールドの pq_codebook_path が学習済みファイルを指すよう schema.toml を書き換えます。 |
commit が codebook を使うのは、スキーマの pq_codebook_path が
そのファイルを指している場合のみです(スキーマ形式
参照)— 学習と同時に設定するには --update-schema を渡してください。
pq_codebook_path が設定済みで codebook が未学習のまま commit
すると、本コマンドを示すエラーで失敗します(per-segment 学習への
無言のフォールバックはありません)。codebook は index open 時に
読み込まれるため、ingest する add / put / commit の前に
学習してください(CLI は呼び出しごとに index を開き直すため、
以後のコマンドはすべて反映済みです)。
例:
cat > train.jsonl <<'JSONL'
{"id": "t1", "fields": {"embedding": [0.1, 0.2, 0.3, 0.4]}}
{"id": "t2", "fields": {"embedding": [0.5, 0.6, 0.7, 0.8]}}
JSONL
laurus train pq-codebook --field embedding --input train.jsonl --update-schema
# Training PQ codebook for field 'embedding' on 2 vectors...
# Trained codebook 'embedding.pqcb' (m = 2, k = 256, sub_dim = 2, dimension = 4) from 2 vectors.
# Updated schema.toml: embedding.pq_codebook_path = "embedding.pqcb".
または、インデックスにコミット済みのベクトルから直接サンプリングします — JSONL エクスポートは不要です:
laurus train pq-codebook --field embedding --from-index --sample-size 5000 --update-schema
search
Query DSL を使用して検索クエリを実行します。
laurus search <QUERY> [--limit <N>] [--offset <N>] [--highlight <FIELD>]...
[--rescore-field <FIELD> (--rescore-text <TEXT> | --rescore-vectors <JSON>) [--rescore-window <N>]]
クエリ文字列は、各フィールドに設定されたアナライザー自身で解析されます。例えば schema.toml で日本語(Lindera)アナライザーを設定したフィールドは、インデックス時と同じ方法でクエリ時にも解析されます。スキーマに宣言されていないフィールドを参照すると、そのフィールド名を含むエラーで拒否されます(typo の検出に役立ちます)。予約済みの _id フィールドはスキーマに現れませんが、常に検索可能です。
引数:
| 引数 / フラグ | 必須 | デフォルト | 説明 |
|---|---|---|---|
<QUERY> | はい | — | Laurus Query DSL によるクエリ文字列 |
--limit <N> | いいえ | 10 | 最大結果件数 |
--offset <N> | いいえ | 0 | スキップする結果件数 |
--highlight <FIELD> | いいえ | (なし) | ハイライトする保存済みテキストフィールド(Issue #1134)。複数フィールドを指定する場合は繰り返す。既定の HighlightConfig を使用 — one-shot 専用で REPL では使用不可 |
--rescore-field <FIELD> | いいえ | (なし) | 上位の結果を late interaction で再採点するときに、トークンベクトルを読む MultiVector フィールド(Issue #1351)。--rescore-text か --rescore-vectors が必要 |
--rescore-text <TEXT> | いいえ | (なし) | 再採点のクエリのテキスト。フィールドのトークン単位のエンベッダー(candle_colbert)が埋め込む |
--rescore-vectors <JSON> | いいえ | (なし) | 再採点のクエリのトークンベクトル(JSON。例: '[[0.1, 0.2], [0.3, 0.4]]') |
--rescore-window <N> | いいえ | 100 | 再採点する上位の件数(最大 10,000) |
再採点した結果のスコアは late interaction(MaxSim)のスコアです。late interaction による再採点を参照してください。例えば body_colbert に candle_colbert のエンベッダーがある場合:
laurus search 'body:lifetimes' --rescore-field body_colbert --rescore-text 'how do lifetimes work'
クエリ構文の例:
# 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)"
# "body" フィールドをハイライト
laurus search "body:rust" --highlight body
# 複数フィールドをハイライト
laurus search "body:rust" --highlight title --highlight body
テーブル出力の例:
╭──────┬────────┬─────────────────────────────────────────╮
│ ID │ Score │ Fields │
├──────┼────────┼─────────────────────────────────────────┤
│ doc1 │ 0.8532 │ body: Rust is a systems..., title: Intr │
│ doc3 │ 0.4210 │ body: JavaScript powers..., title: Web │
╰──────┴────────┴─────────────────────────────────────────╯
--highlight を指定した場合のみ Highlights 列が追加されます。
╭──────┬────────┬─────────────────────────┬──────────────────────────────╮
│ ID │ Score │ Fields │ Highlights │
├──────┼────────┼─────────────────────────┼──────────────────────────────┤
│ doc1 │ 0.8532 │ body: Rust is a syst... │ body: <mark>Rust</mark> is a │
╰──────┴────────┴─────────────────────────┴──────────────────────────────╯
JSON 出力の例:
laurus --format json search "body:rust" --limit 5
[
{
"id": "doc1",
"score": 0.8532,
"fields": {
"title": "Introduction to Rust",
"body": "Rust is a systems programming language."
}
}
]
--highlight body を指定すると、マッチした結果には "highlights" キーが追加されます(少なくとも1つのフィールドが実際にハイライトされた場合のみ)。
[
{
"id": "doc1",
"score": 0.8532,
"fields": {
"title": "Introduction to Rust",
"body": "Rust is a systems programming language."
},
"highlights": {
"body": ["<mark>Rust</mark> is a systems programming language."]
}
}
]
完全な意味論(フィールド選択、フィールドごとのアナライザー、filter_query がハイライトされない理由など)はハイライトを参照してください。
repl
対話型 REPL セッションを開始します。詳細は REPL を参照してください。
laurus repl
serve
gRPC サーバー(およびオプションで HTTP Gateway)を起動します。
laurus serve [OPTIONS]
起動オプション、設定、使用例については laurus-server のドキュメントを参照してください:
- はじめに — 起動オプションと gRPC 接続例
- 設定 — TOML 設定ファイル、環境変数、優先順位
- ハンズオンチュートリアル — ステップバイステップの操作ガイド
mcp
Model Context Protocol(MCP)サーバーを stdio 上で起動します。MCP サーバーを介して、Claude Code や Claude Desktop のような AI アシスタントが標準化されたツール群(create_index、add_document、search など)で稼働中の 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 形式を使用します。
概要
スキーマは 5 つのトップレベル要素で構成されます:
# スキーマに宣言されていないフィールドの扱い。省略時は "dynamic"。
dynamic_field_policy = "dynamic"
# クエリでフィールドが指定されていない場合にデフォルトで検索するフィールド。
default_fields = ["title", "body"]
# カスタムアナライザの定義。Text フィールドから名前で参照する。省略可能。
[analyzers.<analyzer_name>]
# ... tokenizer、char_filters、token_filters
# エンベダーの定義。ベクトルフィールドから名前で参照する。省略可能。
[embedders.<embedder_name>]
# ... type と型固有のオプション
# フィールド定義。各フィールドには名前と型付き設定があります。
[fields.<field_name>.<FieldType>]
# ... 型固有のオプション
dynamic_field_policy— スキーマに宣言されていないフィールドがドキュメントに含まれる場合の挙動を制御します。値は"strict"/"dynamic"/"ignore"。デフォルトは"dynamic"。詳細および「dynamicでは情報損失が起きうる」という警告は 動的スキーマ を参照してください。default_fields— Query DSL でデフォルトの検索対象として使用されるフィールド名のリストです。Lexical フィールド(Text、Integer、Float など)のみデフォルトフィールドに指定できます。このキーはオプションで、デフォルトは空のリストです。analyzers— 名前とカスタムのテキスト解析パイプラインのマップです。Text フィールドはanalyzerオプションに名前を書いて使います。省略可能です。アナライザ を参照してください。embedders— 名前と埋め込みモデルのマップです。ベクトルフィールドはembedderオプションに名前を書いて使います。省略可能です。エンベダー を参照してください。fields— フィールド名とその型付き設定のマップです。各フィールドにはフィールド型を1つだけ指定する必要があります。
フィールド命名規則
- フィールド名は任意の文字列です(例:
title、body_vec、created_at)。 - アンダースコア(
_)で始まるフィールド名はエンジンの予約領域です。例外として_id(自動管理)のみ許可されます。それ以外の_プレフィックス名はインデックス作成時に拒否され、Field name '_score' is reserved: names starting with '_' are reserved for system fields (allowed: '_id')というエラーになります。このときcreate indexは何も作りません。この検査より前に作成したインデックスは引き続き開けますが、そのフィールドは使えないままです — 投入時には今までどおり拒否されます。 - フィールド名はスキーマ内で一意である必要があります。
フィールド型
フィールドは Lexical(キーワード/全文検索用)と Vector(類似検索用)の2つのカテゴリに分類されます。1つのフィールドが両方を兼ねることはできません。
Lexical フィールド
Text
全文検索可能なフィールドです。テキストは解析パイプライン(トークン化、正規化、ステミングなど)によって処理されます。
[fields.title.Text]
indexed = true # このフィールドを検索用にインデックスするかどうか
stored = true # 取得用に元の値を保存するかどうか
multi_valued = false # 文字列の配列を受け付けるかどうか(Issue #1175)
position_increment_gap = 100 # 多値フィールドの要素間で読み飛ばす位置数
term_vectors = true # タームの位置を保存するかどうか(フレーズクエリ・スパンクエリ用)
doc_values = true # 値を DocValues にもコピーするかどうか(ソート・ファセット用)
analyzer = "standard" # このフィールドのインデックス時とクエリ解析時に使うアナライザ
| オプション | 型 | デフォルト | 説明 |
|---|---|---|---|
indexed | bool | true | このフィールドの検索を有効にする |
stored | bool | true | 結果に返せるよう元の値を保存する |
multi_valued | bool | false | 文字列の配列を受け付け、term クエリはいずれかの要素がタームを含めばマッチ(Lucene 流の “any match”)。フレーズクエリは slop が position_increment_gap に達しない限り 2 つの要素をまたがない |
position_increment_gap | integer | 100 | 多値フィールドの要素間で読み飛ばす位置数(Lucene の positionIncrementGap)。0 にすると要素を連結したものとして付番する。multi_valued = true でなければ無視される |
term_vectors | bool | true | フレーズクエリ・スパンクエリが読み取るタームの位置を保存する。ハイライトは常に保存済みテキストを再トークナイズするため使用しない |
doc_values | bool | true | 値を DocValues(ソート・ファセット・集計が読み取る列指向ストア)にもコピーする。stored も true の場合のみ有効 —— 詳細は後述の 共通オプション: doc_values を参照 |
analyzer | string またはテーブル | (省略) | このフィールドのインデックス時とクエリ解析時の両方で使うアナライザ。文字列は組み込みアナライザ("standard"、"english"、"keyword"、"simple"、"noop")か [analyzers.*] の名前を指す。テーブルはパラメータ付きの組み込みプリセットを選び、現在は { language = "japanese", mode = "normal", dict = "<path>" } のみ(テキスト解析 を参照)。省略すると "standard" を使う |
対話的なスキーマジェネレータ(laurus create schema。スキーマの生成 を参照)は、Text フィールドについて多値にするかどうかを尋ね、多値にする場合は position increment gap も尋ねます。
Integer
64ビット符号付き整数フィールド。範囲クエリと完全一致をサポートします。
[fields.year.Integer]
indexed = true
stored = true
multi_valued = false
doc_values = true
| オプション | 型 | デフォルト | 説明 |
|---|---|---|---|
indexed | bool | true | 範囲クエリおよび完全一致クエリを有効にする |
stored | bool | true | 元の値を保存する |
multi_valued | bool | false | 整数の配列を受け付け、範囲クエリはいずれかの値が条件を満たせばマッチ(Lucene 流の “any match”、constant スコア) |
doc_values | bool | true | 詳細は後述の 共通オプション: doc_values を参照 |
Float
64ビット浮動小数点フィールド。範囲クエリをサポートします。
[fields.rating.Float]
indexed = true
stored = true
multi_valued = false
doc_values = true
| オプション | 型 | デフォルト | 説明 |
|---|---|---|---|
indexed | bool | true | 範囲クエリを有効にする |
stored | bool | true | 元の値を保存する |
multi_valued | bool | false | 浮動小数点の配列を受け付け、範囲クエリはいずれかの値が条件を満たせばマッチ(Lucene 流の “any match”、constant スコア) |
doc_values | bool | true | 詳細は後述の 共通オプション: doc_values を参照 |
Boolean
ブーリアンフィールド(true / false)。
[fields.published.Boolean]
indexed = true
stored = true
multi_valued = false
doc_values = true
| オプション | 型 | デフォルト | 説明 |
|---|---|---|---|
indexed | bool | true | ブーリアン値によるフィルタリングを有効にする |
stored | bool | true | 元の値を保存する |
multi_valued | bool | false | ブール値の配列を受け付け、term クエリ(flags:true)はいずれかの要素がクエリの値と等しければマッチ(Lucene 流の “any match”)。要素の重複はヒット数ではなく term frequency を増やす |
doc_values | bool | true | 詳細は後述の 共通オプション: doc_values を参照 |
DateTime
UTC タイムスタンプフィールド。範囲クエリをサポートします。
[fields.created_at.DateTime]
indexed = true
stored = true
multi_valued = false
doc_values = true
| オプション | 型 | デフォルト | 説明 |
|---|---|---|---|
indexed | bool | true | 日時の範囲クエリを有効にする |
stored | bool | true | 元の値を保存する |
multi_valued | bool | false | 時刻の配列を受け付け、範囲クエリはいずれかの時刻が条件を満たせばマッチ(Lucene 流の “any match”) |
doc_values | bool | true | 詳細は後述の 共通オプション: doc_values を参照 |
Geo
地理座標フィールド(緯度/経度)。半径クエリおよびバウンディングボックスクエリをサポートします。
[fields.location.Geo]
indexed = true
stored = true
multi_valued = false
doc_values = true
| オプション | 型 | デフォルト | 説明 |
|---|---|---|---|
indexed | bool | true | Geo クエリ(半径、バウンディングボックス)を有効にする |
stored | bool | true | 元の値を保存する |
multi_valued | bool | false | ポイントの配列を受け付け、距離 / バウンディングボックスクエリはいずれかのポイントが条件を満たせばマッチ(Lucene 流の “any match”)。スコアはドキュメント内で最も近いポイントで決まる |
doc_values | bool | true | 詳細は後述の 共通オプション: doc_values を参照 |
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
multi_valued = false
doc_values = true
| オプション | 型 | デフォルト | 説明 |
|---|---|---|---|
indexed | bool | true | 3D 地理クエリ(geo3d_distance、geo3d_bbox、geo3d_nearest)を有効にする |
stored | bool | true | 元の (x, y, z) 値を保存する |
multi_valued | bool | false | ポイントの配列を受け付け、geo3d_distance / geo3d_bbox / geo3d_nearest クエリはいずれかのポイントが条件を満たせばマッチ(Lucene 流の “any match”)。スコアはドキュメント内で最も近いポイントで決まる |
doc_values | bool | true | 詳細は後述の 共通オプション: doc_values を参照 |
Bytes
生バイナリデータフィールド。インデックスされず、保存のみです。
[fields.thumbnail.Bytes]
stored = true
multi_valued = false
| オプション | 型 | デフォルト | 説明 |
|---|---|---|---|
stored | bool | true | バイナリデータを保存する |
multi_valued | bool | false | バイト列の配列を受け付ける。Bytes フィールドはそもそもインデックスされないため、他の multi_valued オプションと異なり “any match” のクエリ意味論は存在せず、保存時の形と取り込み時の許容個数を変えるだけ |
BytesOption に doc_values 設定はありません。Bytes の値はソートにもファセットにも
使えないため、設定にかかわらず DocValues には一切書き込まれないからです。
共通オプション: doc_values
上記の lexical フィールドオプションのうち BytesOption を除く全てが doc_values オプションを
持ち、値を DocValues ―― ソート・ファセット・集計が読み取る列指向ストア
―― にもコピーするかどうかを制御します。実効ルールは次のとおりです: DocValues 列が書き込まれる
のは stored と doc_values の両方が true の場合のみです。doc_values: false と
stored: false の組み合わせは(エラーにせず)黙って無視されます。ソートにもファセットにも
使わないフィールドで doc_values を無効にすると、値が二重(stored document と DocValues)
ではなく一度(stored document のみ)しか書き込まれなくなるため、セグメントの使用容量が
削減されます。フィールド自体は引き続き完全に検索・取得可能で、ソートやファセットを行う際は
単に stored document へフォールバックします。
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
| オプション | 型 | デフォルト | 説明 |
|---|---|---|---|
dimension | integer | 128 | ベクトルの次元数(Embedding モデルの出力と一致させる必要あり) |
distance | string | "Cosine" | 距離メトリクス(距離メトリクスを参照) |
m | integer | 16 | ノードあたりの最大双方向接続数。大きいほど再現率が向上するがメモリ使用量が増加 |
ef_construction | integer | 200 | インデックス構築時の探索幅。大きいほど品質が向上するが構築が遅くなる |
base_weight | float | 1.0 | 同時に検索する他の vector フィールドに対する相対的な優先度。ハイブリッド検索の lexical-vs-vector 融合のバランスには影響しない(ウェイトを参照) |
quantizer | object | "Scalar8Bit" | 量子化方式(量子化を参照)。必須。デフォルトは Issue #481 Stage 1 で導入された int8 形式を保つ。 |
rerank_storage | string | (省略) | Stage 2 rerank sidecar(Rerank Storage)。"F32" でフィールド単位の f32 sidecar を有効化し、検索時に int8 候補を元のベクトルで再スコアできるようにする。省略すると Stage 1 int8-only の挙動を維持。 |
pq_codebook_path | string | (省略) | 共有 PQ codebook のストレージ相対ファイル名(Issue #631)。ProductQuantization quantizer との組み合わせでのみ意味を持つ。laurus train pq-codebook で学習すると、以後の commit は segment ごとの k-means 再学習の代わりにこの codebook で encode する。設定済みで未学習の場合、commit は明示的にエラーになる(無言のフォールバック無し)。省略すると segment ごとに学習。 |
embedder | string | (省略) | [embedders.*] のエントリ名。指定すると、このフィールドに与えたテキスト(または画像)をそのモデルでベクトルに変換する。インデックス時と検索時の両方で変換する。省略すると計算済みのベクトルだけを受け付ける |
チューニングガイドライン:
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
| オプション | 型 | デフォルト | 説明 |
|---|---|---|---|
dimension | integer | 128 | ベクトルの次元数 |
distance | string | "Cosine" | 距離メトリクス(距離メトリクスを参照) |
base_weight | float | 1.0 | 同時に検索する他の vector フィールドに対する相対的な優先度。ハイブリッド検索の lexical-vs-vector 融合のバランスには影響しない(ウェイトを参照) |
quantizer | object | "Scalar8Bit" | 量子化方式(量子化を参照)。必須。デフォルトは Issue #481 Stage 1 で導入された int8 形式を保つ。 |
rerank_storage | string | (省略) | Stage 2 rerank sidecar(Rerank Storage)。#932 以降、3 つのベクトルインデックスタイプすべてでサポート。"F32" でフィールド単位の f32 sidecar を有効化し、検索時に int8 候補を元のベクトルで再スコアできる。 |
embedder | string | (省略) | [embedders.*] のエントリ名。Hnsw を参照 |
Ivf
IVF(Inverted File Index)。ベクトルをクラスタリングし、クラスタのサブセットのみを検索します。大規模データセットに適しています。
[fields.embedding.Ivf]
dimension = 384
distance = "Cosine"
n_clusters = 100
n_probe = 1
base_weight = 1.0
| オプション | 型 | デフォルト | 説明 |
|---|---|---|---|
dimension | integer | (必須) | ベクトルの次元数 |
distance | string | "Cosine" | 距離メトリクス(距離メトリクスを参照) |
n_clusters | integer | 100 | クラスタ数。多いほど細かい分割が可能 |
n_probe | integer | 1 | クエリ時に検索するクラスタ数。大きいほど再現率が向上するが遅くなる |
base_weight | float | 1.0 | 同時に検索する他の vector フィールドに対する相対的な優先度。ハイブリッド検索の lexical-vs-vector 融合のバランスには影響しない(ウェイトを参照) |
quantizer | object | "Scalar8Bit" | 量子化方式(量子化を参照)。必須。デフォルトは Issue #481 Stage 1 で導入された int8 形式を保つ。 |
rerank_storage | string | (省略) | Stage 2 rerank sidecar(Rerank Storage)。#932 以降、3 つのベクトルインデックスタイプすべてでサポート。"F32" でフィールド単位の f32 sidecar を有効化し、検索時に int8 候補を元のベクトルで再スコアできる。 |
embedder | string | (省略) | [embedders.*] のエントリ名。Hnsw を参照 |
注意: Hnsw および Flat とは異なり、Ivf の
dimensionフィールドは必須であり、デフォルト値はありません。
チューニングガイドライン:
n_clusters: 一般的な経験則はsqrt(N)(N はベクトルの総数)です。n_probe: 1 から始めて、再現率が許容範囲になるまで増やしてください。一般的な範囲は 1〜20 です。
MultiVector
文書のトークンベクトル(ColBERT 型のトークンごとの埋め込みなど)をすべて保持し、late interaction の再採点に使います。ANN 索引は持たず、ベクトル検索の対象にはなりません。MultiVector フィールドを参照してください。
[fields.body_colbert.MultiVector]
dimension = 128
distance = "Cosine"
storage = "Int8" # 任意。省略時は F32(厳密、デフォルト)
| オプション | 型 | デフォルト | 説明 |
|---|---|---|---|
dimension | integer | 128 | 各トークンベクトルの次元数 |
distance | string | "Cosine" | トークン間の類似度。"Cosine"(書き込み時に L2 正規化する)または "DotProduct"。それ以外は拒否されます |
storage | string | "F32" | 各トークンベクトルのディスク上での要素形式。"F32"(厳密、4 バイト/要素)、"F16"(2 バイト/要素、要素あたり約 2⁻¹¹ の相対誤差)、"Int8"(約 1 バイト/要素 + ベクトルごとの小さなスケール、1 行あたり dimension + 2 バイト)のいずれか。詳細は MultiVector ストレージを参照。既存フィールドで変更すると reindex が必要 |
embedder | string | – | [embedders] で宣言したトークン単位のエンベダー(type = "candle_colbert")の名前。指定すると、文書はこのフィールドにテキストを与えられ、テキストはトークンベクトルに埋め込まれます(エンベダーを参照) |
文書の値は、同じ長さの数値配列の配列(1〜8,192 本)です。エンベダーを指定したフィールドではテキストも使えます。このフィールドは文書ストアに保存されません。
距離メトリクス
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(HNSW のみ)
Issue #481 Stage 3。各ベクトルを、sub-vector ごとに 256 centroid を
持つ codebook への 1 バイトの centroid index × subvector_count 個
として保存します(約 16-64 倍の圧縮)。HNSW index がサポートし、
Flat / IVF は書き込み時に拒否します。recall 回復のため
Rerank Storage との併用を推奨します。
[fields.embedding.Hnsw]
dimension = 384
distance = "Cosine"
# 任意(Issue #631): `laurus train pq-codebook` で codebook を一度
# だけ学習し、commit / merge ごとの k-means 再学習の代わりに
# segment 間で共有する。
pq_codebook_path = "embedding.pqcb"
[fields.embedding.Hnsw.quantizer.ProductQuantization]
subvector_count = 48
| オプション | 型 | 説明 |
|---|---|---|
subvector_count | integer | サブベクトルの数。dimension を均等に割り切れる必要があります。 |
デフォルトでは codebook は segment ごとに学習されます(256 ベクトル
未満の segment は Scalar8Bit にフォールバック)。pq_codebook_path
を設定すると segment は共有の学習済み codebook で encode されます:
commit は大幅に高速化し、小さな per-commit segment も PQ を維持
します — ただし codebook の学習前に commit すると、実行すべき
laurus train pq-codebook コマンドを示すエラーで失敗します
(per-segment 学習への無言のフォールバックはありません)。学習
ワークフローは train コマンド を参照して
ください。
破壊的変更(Issue #481 Stage 1):
quantizerを「なし」に 設定するスキーマはもはや有効ではありません。Stage 1 より前の laurus でビルドした既存 vector index は読み取れないため、アップ グレード後にソースデータから再構築してください。
Rerank Storage
任意の Stage 2 sidecar(Issue #481)。元の完全精度ベクトルを int8
セグメントの隣に保持し、searcher が int8 で広めに候補を取得
(高速)してから上位 top_k * rerank_factor 件を完全な f32 値で
再スコア(高精度)できるようにします。#932 以降、HNSW / Flat /
IVF の 3 タイプすべてでサポートされます(Flat / IVF の再スコアは
フィールド指定クエリに適用)。
sidecar はフィールド単位で rerank_storage で設定します:
[fields.embedding.Hnsw]
dimension = 384
distance = "Cosine"
rerank_storage = "F32" # opt-in。省略すると Stage 1 int8-only の挙動を維持
| 値 | ディスク追加コスト | 説明 |
|---|---|---|
"F32" | +4 bytes/dim/vector | IEEE-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 の書き出し・読み込みは行いません。
アナライザ
[analyzers.<name>] テーブルは、カスタムのテキスト解析パイプラインを定義します。Text フィールドは analyzer オプションにその名前を書いて使います。組み込みのアナライザでは足りないとき、たとえばステミングを加えたいときや、japanese プリセットのストップフィルタを使わずに日本語を解析したいときに定義します。パイプラインの仕組みは テキスト解析 を参照してください。
[analyzers.<name>]
char_filters = [{ type = "...", ... }, ...] # 省略可能
tokenizer = { type = "...", ... } # 必須
token_filters = [{ type = "...", ... }, ...] # 省略可能
| キー | 型 | デフォルト | 説明 |
|---|---|---|---|
tokenizer | テーブル | (必須) | テキストをトークンに分割する。必ず 1 つ |
char_filters | テーブルの配列 | [] | トークン化の前に生テキストへ、配列の順に適用する |
token_filters | テーブルの配列 | [] | トークン化の後にトークン列へ、配列の順に適用する |
各コンポーネントは、type キーで種類を選び、残りのキーで設定するテーブルです。TOML ではインラインテーブル({ type = "lowercase" })で書くのが一般的です。JSON 形式のスキーマ({"type": "lowercase"})や、各バインディングの addAnalyzer / add_analyzer も同じ形を使います。
トークナイザ
type | 必須キー | 省略可能キー | 説明 |
|---|---|---|---|
"whitespace" | – | – | 空白で分割する |
"unicode_word" | – | – | Unicode の単語境界で分割する |
"regex" | – | pattern(デフォルト \w+)、gaps(デフォルト false) | pattern に一致した部分をトークンにする。gaps = true のときは、pattern がトークン間の区切りに一致するものとして扱う |
"ngram" | min_gram、max_gram | – | min_gram 文字から max_gram 文字までのすべての n-gram を出力する |
"lindera" | mode、dict | user_dict | Lindera による形態素解析。mode は "normal" か "decompose"。dict は Lindera 辞書のディレクトリ、user_dict はユーザー辞書のパス。laurus は辞書を同梱しないため、dict は実在するパスでなければならない |
"whole" | – | – | 入力全体を 1 つのトークンにする |
文字フィルタ
type | 必須キー | 省略可能キー | 説明 |
|---|---|---|---|
"unicode_normalization" | form("nfc" / "nfd" / "nfkc" / "nfkd") | – | Unicode 正規化を適用する |
"pattern_replace" | pattern、replacement | – | 正規表現 pattern に一致した部分を replacement に置き換える |
"mapping" | mapping(置換用の文字列のテーブル) | – | mapping の各キーを対応する値に置き換える |
"japanese_iteration_mark" | – | kanji(デフォルト true)、kana(デフォルト true) | 踊り字を展開する |
トークンフィルタ
type | 必須キー | 省略可能キー | 説明 |
|---|---|---|---|
"lowercase" | – | – | 各トークンを小文字にする |
"stop" | – | words(デフォルト: 英語のストップワード) | ストップワードを取り除く |
"stem" | – | stem_type("porter"(デフォルト)/ "simple" / "identity") | 各トークンを語幹にする |
"boost" | boost | – | 各トークンのブーストに boost を掛ける |
"limit" | limit | – | 先頭の limit 個のトークンだけを残す |
"strip" | – | – | 各トークンの前後の空白を取り除く |
"remove_empty" | – | – | 空のトークンを取り除く |
"flatten_graph" | – | – | トークングラフを直線的なトークン列に平坦化する。インデックス時は常に平坦化されるうえ、アナライザはクエリの解析にも使われるため、これを加えるとクエリ時に引用符付きの複数語の同義語が厳密に一致しなくなる |
アナライザの参照
Text フィールドは analyzer オプションにアナライザの名前を書きます:
[fields.body.Text]
analyzer = "english_stemmed"
名前は次の順に解決されます:
- バインディングから実行時に登録したアナライザ(例: WASM バインディングの
addAnalyzer) - 組み込みのアナライザ:
standard、keyword、english、simple、noop [analyzers.*]のエントリ
組み込みが先に調べられるため、standard、keyword、english、simple、noop は予約された名前です。この名前の [analyzers.*] エントリは決して使われないので、エラーになります。japanese は予約されていません。組み込みの japanese は辞書が必要でテーブルで指定するため、[analyzers.japanese] は定義どおりに使われます。
エラーは次の 3 つの時点で起きます:
- 未知の
typeや必須キーの欠落は、スキーマの解析時にエラーになります。このときcreate indexは何も作りません。 - 組み込みと同じ名前のエントリは、インデックスの作成時に
Analyzer name 'standard' is reserved for a built-in analyzer; choose another nameというエラーになります。このときcreate indexは何も作りません。各バインディングのaddAnalyzer/add_analyzer(WASM ではaddAnalyzerDefinition)も同じエラーを返します。この検査より前に作ったインデックスは、これまでどおり開けます。エントリは使われないままで、開くときにlogクレートで警告を出します(laurus-serverはこれをログに表示します)。 - 不正な値(誤った正規表現、未知の
formやstem_type、存在しない Lindera 辞書)や、どこにも見つからないanalyzerの名前は、インデックスの構築時にFailed to resolve analyzer for field 'body': ...のようなエラーになります。このときcreate indexは何も作りません —schema.tomlとstore/は、呼び出し前の状態(何もなければ「何もない」状態)まで巻き戻されます。
例: ステミング付きの英語テキスト
default_fields = ["title", "body"]
[analyzers.english_stemmed]
char_filters = [{ type = "unicode_normalization", form = "nfkc" }]
tokenizer = { type = "unicode_word" }
token_filters = [
{ type = "lowercase" },
{ type = "stop" },
{ type = "stem", stem_type = "porter" },
]
[fields.title.Text]
analyzer = "english_stemmed"
[fields.body.Text]
analyzer = "english_stemmed"
[fields.tag.Text]
analyzer = "keyword"
body が "Dogs are RUNNING in the park." の文書は、body:dog(NFKC 正規化・小文字化・ステミングによる)と body:run に一致し、body:the には一致しません(ストップワードが取り除かれるため)。tag フィールドは組み込みの keyword アナライザのままなので、値そのものにだけ一致します。
例: Lindera による日本語テキスト
次の定義は examples/aozora/schema.toml から取ったものです。{ language = "japanese" } プリセットと違ってストップフィルタを含まないため、「の」「は」などの助詞もインデックスに残ります:
[analyzers.ja_ipadic]
tokenizer = { type = "lindera", mode = "normal", dict = "/var/lib/lindera/ipadic" }
char_filters = [
{ type = "unicode_normalization", form = "nfkc" },
{ type = "japanese_iteration_mark", kanji = true, kana = true },
]
token_filters = [{ type = "lowercase" }]
[fields.title.Text]
analyzer = "ja_ipadic"
dict には展開済みの Lindera 辞書(通常は IPADIC)を指定します。存在しないパスを指定すると、create index は Failed to load dictionary: ... Dictionary path does not exist で失敗します。
エンベダー
[embedders.<name>] テーブルは、埋め込みモデルを宣言します。ベクトルフィールド(Hnsw、Flat、Ivf、MultiVector)は embedder オプションにその名前を書いて使います。するとそのフィールドに与えたテキスト(CLIP の場合は画像も)が、文書のインデックス時と、そのフィールドを対象とするクエリの実行時の両方で、モデルによってベクトルに変換されます。1 つのエンベダーを複数のフィールドで共有できます。各モデルの仕組みと選び方は Embedding を参照してください。
[embedders.<name>]
type = "..." # 必須
model = "..." # "precomputed" 以外のすべての型で必須
type | 必須キー | Feature Flag | 説明 |
|---|---|---|---|
"precomputed" | – | (常に利用可能) | 埋め込みを行わない。文書がベクトルを直接与える |
"candle_bert" | model | embeddings-candle | Hugging Face Hub の BERT 系モデル(例: "sentence-transformers/all-MiniLM-L6-v2")によるローカルでのテキスト埋め込み |
"candle_clip" | model | embeddings-multimodal | Hugging Face Hub の CLIP モデル(例: "openai/clip-vit-base-patch32")によるローカルでのテキストと画像の埋め込み |
"openai" | model | embeddings-openai | OpenAI API(例: "text-embedding-3-small")によるテキスト埋め込み。API キーはエンジン起動時に環境変数 OPENAI_API_KEY から読み、スキーマには保存しない |
"candle_colbert" | model | embeddings-candle | BERT ベースの ColBERT のチェックポイント(例: "colbert-ir/colbertv2.0"、"answerdotai/answerai-colbert-small-v1")によるローカルでのトークン単位の埋め込み。MultiVector フィールドでだけ使える |
"candle_colbert" は、次のキーも任意で受け付けます。
| キー | 型 | デフォルト | 説明 |
|---|---|---|---|
revision | string | デフォルトブランチ | モデルのリポジトリのブランチ、タグ、またはコミット。コミットに固定してください。まだコミットされていない文書は、復旧時に write-ahead log から埋め込み直されるため、モデルが変わると異なるベクトルになります |
query_maxlen | integer | チェックポイントの値(32) | すべてのクエリを埋める、または切り詰めるトークン数 |
doc_maxlen | integer | チェックポイントの値(colbertv2.0 は 180、answerai-colbert-small-v1 は 300) | 文書の最大トークン数 |
Hugging Face のモデルは初回の使用時にダウンロードされます。candle_bert と candle_clip は $HF_HOME(デフォルトは ~/.cache/huggingface)にキャッシュします。candle_colbert は Hugging Face の標準のキャッシュ $HF_HOME/hub(デフォルトは ~/.cache/huggingface/hub)を使うため、Python のツールとキャッシュを共有できます。ベクトルフィールドの dimension は、モデルの出力次元と一致させる必要があります。candle_colbert では、エンジンの起動時にこれを検査します。
エンベダーは、それを指定したフィールドに合っている必要があります。MultiVector フィールドに置けるのは "candle_colbert" か "precomputed" だけで、Hnsw、Flat、Ivf のフィールドには "candle_colbert" を置けません。それ以外の組み合わせは、create index、add field、update field で拒否されます。
注意: リリースで配布しているビルド済みバイナリは
--features embeddings-all付きでビルドされています。一方、cargo install laurus-cliやソースからのビルドでは、feature を指定しない限り(例:cargo install laurus-cli --features embeddings-candle)埋め込みの feature がどれも有効にならず、使えるのは"precomputed"だけです。インストール と Feature Flags を参照してください。feature が無効な型を書いたスキーマも解析は通りますが、create indexが次のエラーで失敗します:Error: Not implemented: candle_bert embedder requires the 'embeddings-candle' feature to be enabled
ベクトルフィールドの embedder には、[embedders.*] のエントリの名前を書く必要があります。宣言していない名前は create index、add field、update field で拒否されます。schema.toml にそうした名前を含む既存のインデックスは開けません:
Error: Invalid argument: Unknown embedder 'missing' for field 'vec': not defined in schema.embedders
このインデックスを開くには、schema.toml を編集します。その名前を type = "precomputed" で宣言すれば、フィールドはそれまでどおり、文書が与えるベクトルで動きます。フィールドの embedder の行を削除しても同じです。
例: 1 つのエンベダーを 2 つのフィールドで共有する
[embedders.text_embedder]
type = "candle_bert"
model = "sentence-transformers/all-MiniLM-L6-v2"
[fields.title_vec.Hnsw]
dimension = 384
distance = "Cosine"
embedder = "text_embedder"
[fields.body_vec.Hnsw]
dimension = 384
distance = "Cosine"
embedder = "text_embedder"
例: late interaction の再採点に使う ColBERT のトークンベクトル
[embedders.colbert]
type = "candle_colbert"
model = "answerdotai/answerai-colbert-small-v1"
revision = "934fa8bb4ce2284f4c2baa232d81aca4d076fa5e"
[fields.body.Text]
indexed = true
stored = true
[fields.body_colbert.MultiVector]
dimension = 96
distance = "Cosine"
embedder = "colbert"
文書は body_colbert に body と同じテキストを与えます。
late interaction による再採点
では、クエリをテキストで渡せます。
完全な例
全文検索のみ
Lexical 検索のみのシンプルなブログ記事インデックス:
default_fields = ["title", "body"]
[fields.title.Text]
indexed = true
stored = true
term_vectors = true
[fields.body.Text]
indexed = true
stored = true
term_vectors = true
[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 = true
[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 = true
[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"
カスタム解析と自動埋め込み
Text フィールドにカスタムアナライザを使い、ベクトルフィールドではテキストをローカルのモデルで埋め込むハイブリッドインデックスです。embeddings-candle feature 付きの laurus バイナリが必要です(エンベダー を参照):
default_fields = ["title", "body"]
[analyzers.english_stemmed]
tokenizer = { type = "unicode_word" }
token_filters = [
{ type = "lowercase" },
{ type = "stop" },
{ type = "stem" },
]
[embedders.text_embedder]
type = "candle_bert"
model = "sentence-transformers/all-MiniLM-L6-v2"
[fields.title.Text]
analyzer = "english_stemmed"
[fields.body.Text]
analyzer = "english_stemmed"
[fields.body_vec.Hnsw]
dimension = 384
distance = "Cosine"
embedder = "text_embedder"
スキーマの生成
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> | インデックスを検索(Lexical / Vector / ハイブリッド DSL) |
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 / exit | REPL を終了 |
注意:
create、help、quit以外のコマンドはインデックスがロードされている必要があります。インデックスがロードされていない場合、まずcreate indexを実行するようメッセージが表示されます。
使用例
インデックスの作成
laurus> create index ./schema.toml
Index created at ./my_index.
laurus> add doc doc1 {"fields":{"title":"Hello","body":"World"}}
Document 'doc1' added.
検索
laurus> search body:rust
╭──────┬────────┬────────────────────────────────────╮
│ ID │ Score │ Fields │
├──────┼────────┼────────────────────────────────────┤
│ doc1 │ 0.8532 │ body: Rust is a systems..., title… │
╰──────┴────────┴────────────────────────────────────╯
search は Query DSL の全体(Lexical・Vector・ハイブリッド句)を受け付け、単発の laurus search コマンドと同様に、クエリを各フィールドに設定されたアナライザー自身で解析します。結果件数は常に 10 件に制限されます。異なる件数が必要な場合は、REPL の外で laurus search --limit N を使用してください。同様に REPL には --highlight(Issue #1134)に相当する機能もありません — ハイライト付きの結果が必要な場合は単発の laurus search --highlight <FIELD> コマンドを使用してください。
フィールドの管理
laurus> add field category {"Text": {"indexed": true, "stored": true}}
Field 'category' added.
laurus> delete field category
Field 'category' deleted.
ドキュメントの追加とコミット
laurus> add doc doc4 {"fields":{"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<RwLock>)"]
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
セクション
- はじめに – 起動オプションと最初のステップ
- 設定 – TOML 設定、環境変数、優先順位
- gRPC API リファレンス – 全サービスと RPC の完全な API ドキュメント
- HTTP ゲートウェイ – HTTP/JSON エンドポイントリファレンス
gRPC サーバーをはじめる
サーバーの起動
gRPC サーバーは laurus CLI の serve サブコマンドで起動します。
laurus serve [OPTIONS]
オプション
| オプション | 短縮形 | 環境変数 | デフォルト | 説明 |
|---|---|---|---|---|
--config <PATH> | -c | LAURUS_CONFIG | – | TOML 設定ファイルのパス |
--host <HOST> | -H | LAURUS_HOST | 0.0.0.0 | リッスンアドレス |
--port <PORT> | -p | LAURUS_PORT | 50051 | リッスンポート |
--http-port <PORT> | – | LAURUS_HTTP_PORT | – | HTTP ゲートウェイポート(設定すると 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)を受信すると、自動的に以下を実行します。
- 新しい接続の受け付けを停止
- インデックスへの保留中の変更をコミット
- 正常に終了
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": true, "analyzer": "standard"}},
"body": {"text": {"indexed": true, "stored": true, "term_vectors": true, "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アナライザー(トークン化+小文字化)を使用。body—analyzersセクションで定義したカスタムbody_analyzer(NFKC 正規化+正規表現トークナイザー+小文字化+カスタムストップワード)を使用。category—keywordアナライザー(値全体を単一トークンとして扱い、完全一致用)を使用。embedding— HNSW ベクトルインデックス、4 次元、コサイン距離。embeddersで定義したmy_embedderを使用。このチュートリアルではprecomputed(外部で事前計算したベクトル)を使用。本番環境では、使用する埋め込みモデルに合わせた次元数(例: 384 や 768)を指定してください。
default_fields を設定することで、フィールド指定なしのクエリは title と body の両方を検索します。
組み込みアナライザー
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 '{
"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 '{
"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 '{
"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}'
デフォルトフィールド(title と body)が検索されます。doc001 と doc002 が返されます。
フィールド指定検索
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 のみが返されます。
ブーリアンクエリ
AND、OR、NOT で条件を組み合わせます:
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 '{
"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 '{
"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 '{
"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_bert | embeddings-candle | sentence-transformers/all-MiniLM-L6-v2 | 384 |
candle_clip | embeddings-multimodal | openai/clip-vit-base-patch32 | 512 |
openai | embeddings-openai | text-embedding-3-small | 1536 |
次のステップ
- ベクトル検索とハイブリッド検索で意味的な類似検索を試す
- gRPC API リファレンスで API 仕様の詳細を確認する
- 設定で本番環境向けの設定を行う
grpcurlや gRPC クライアントライブラリを使ったプログラムからのアクセスについてははじめにを参照
設定
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"
[index.wal]
sync_policy = "group" # "per_record"(デフォルト) | "group"
group_max_records = 1024 # オプション; デフォルト 1024
group_max_bytes = 1048576 # オプション; デフォルト 1 MiB
group_max_interval_ms = 1000 # オプション; 未設定時は background timer なし(native のみ)
[index.commit]
policy = "every_docs" # "manual"(デフォルト) | "every_docs" | "interval"
every_docs = 1000 # オプション; N 件ごとに commit(0/未設定で無効)
interval_ms = 1000 # "interval" のみ; 最低 N ミリ秒ごとに commit(native のみ)
ログの詳細度は設定ファイルではなく、RUST_LOG 環境変数で制御します(デフォルト: info)。
フィールドリファレンス
[server] セクション
| フィールド | 型 | デフォルト | 説明 |
|---|---|---|---|
host | String | "0.0.0.0" | gRPC サーバーのリッスンアドレス |
port | Integer | 50051 | gRPC サーバーのリッスンポート |
http_port | Integer | – | HTTP ゲートウェイポート。設定すると gRPC と並行して HTTP/JSON ゲートウェイが起動 |
[index] セクション
| フィールド | 型 | デフォルト | 説明 |
|---|---|---|---|
data_dir | String | "./laurus_data" | インデックスデータディレクトリのパス |
[index.wal] セクション
Write-Ahead Log(WAL)の耐久性ポリシーを制御します。セクション全体を省略した場合、
WAL は per-record fsync を使用します(各書き込みは返る前に durable 化されます)。
このポリシーは、起動時に開かれるインデックスと、後から CreateIndex で作成される
インデックスの両方に適用されます。耐久性のトレードオフについては
永続化と WAL → WAL 耐久性ポリシー
を参照してください。
| フィールド | 型 | デフォルト | 説明 |
|---|---|---|---|
sync_policy | String | "per_record" | 耐久性ポリシー: "per_record"(書き込みごとに fsync)または "group"(fsync をバッチ化) |
group_max_records | Integer | 1024 | グループコミットのみ。前回 sync 以降にこの件数のレコードが蓄積したら flush |
group_max_bytes | Integer | 1048576 | グループコミットのみ。前回 sync 以降にこのバイト数が蓄積したら flush(デフォルト 1 MiB) |
group_max_interval_ms | Integer | – | グループコミットのみ。定期 background flush の間隔(ミリ秒)。未設定時は timer なし。native ターゲットのみ — wasm32 では無視される |
sync_policy = "group" の場合、WAL は前回 sync 以降に group_max_records 件または
group_max_bytes バイトのいずれか(先に到達した方)が蓄積した時点、および commit 時に
無条件で flush します。クラッシュ時には未 sync の最終バッチまで失う可能性があります
(SQLite synchronous = NORMAL に相当)。途中で切れた末尾レコードはリカバリ時に
破棄されるため、復旧後のログにはギャップが生じません。
[index.commit] セクション
自動コミットポリシーを制御します。セクション全体を省略した場合、エンジンは
manual — サーバーがインデックスを materialize するときのみ commit します
(インジェスト中の自動コミットはありません)。このポリシーは、起動時に開かれる
インデックスと、後から CreateIndex で作成されるインデックスの両方に適用されます。
セマンティクスは
永続化と WAL → 自動コミットポリシー
を参照してください。"interval" は "every_docs" の時間ベース版であり、native のみ —
WebAssembly では background thread がないため no-op になります。
| フィールド | 型 | デフォルト | 説明 |
|---|---|---|---|
policy | String | "manual" | 自動コミットポリシー: "manual"(呼び出し側が commit を駆動)、"every_docs"(every_docs 件ごとに commit)、または "interval"(最低 interval_ms ミリ秒ごとに commit) |
every_docs | Integer | – | every_docs ポリシーのみ。適用ドキュメント数がこの値に達するごとに commit。未設定(または 0)で自動コミット無効("manual" と同等) |
interval_ms | Integer | – | interval ポリシーのみ。background timer により最低この間隔(ミリ秒)ごとに自動コミットするため、インジェストがアイドルでも末尾の部分バッチが commit されます — every_docs の時間ベース版。native ターゲットのみ — wasm32 では no-op として扱われます(background thread なし) |
CommitPolicy は [index.wal] と直交します。WAL セクションは append がいつ fsync
されるか を、このセクションは ストアがいつ materialize するか を制御します。commit は
必ず WAL flush から始まるため、自動コミットは任意の WAL ポリシー下で機能します。
環境変数
| 変数 | 対応する設定 | 説明 |
|---|---|---|
LAURUS_HOST | server.host | リッスンアドレス |
LAURUS_PORT | server.port | gRPC リッスンポート |
LAURUS_HTTP_PORT | server.http_port | HTTP ゲートウェイポート |
LAURUS_INDEX_DIR | index.data_dir | インデックスデータディレクトリ |
RUST_LOG | – | ログフィルタディレクティブ(例: info, debug, laurus=debug,tonic=warn) |
LAURUS_CONFIG | – | TOML 設定ファイルのパス |
CLI 引数
| オプション | 短縮形 | デフォルト | 説明 |
|---|---|---|---|
--config <PATH> | -c | – | TOML 設定ファイルのパス |
--host <HOST> | -H | 0.0.0.0 | リッスンアドレス |
--port <PORT> | -p | 50051 | gRPC リッスンポート |
--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 | 説明 |
|---|---|---|
HealthService | Check | ヘルスチェック |
IndexService | CreateIndex, GetIndex, GetSchema, AddField, DeleteField | インデックスのライフサイクルとスキーマ |
DocumentService | PutDocument, AddDocument, PutDocuments, AddDocuments, GetDocuments, DeleteDocuments, Commit, FlushWal | ドキュメント CRUD・バルクインジェスト・コミット・WAL flush |
SearchService | Search, SearchStream | 単発検索とストリーミング検索 |
HealthService
Check
サーバーの現在のサービング状態を返します。
rpc Check(HealthCheckRequest) returns (HealthCheckResponse);
レスポンスフィールド:
| フィールド | 型 | 説明 |
|---|---|---|
status | ServingStatus | サーバーの準備が完了している場合は SERVING_STATUS_SERVING |
IndexService
CreateIndex
指定されたスキーマで新しいインデックスを作成します。インデックスが既に開いている場合は ALREADY_EXISTS エラーを返します。
rpc CreateIndex(CreateIndexRequest) returns (CreateIndexResponse);
リクエストフィールド:
| フィールド | 型 | 必須 | 説明 |
|---|---|---|---|
schema | Schema | はい | インデックスのスキーマ定義 |
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(文字フィルター、トークナイザー、トークンフィルターに使用):
| フィールド | 型 | 説明 |
|---|---|---|
type | string | コンポーネントタイプ名(例: "whitespace", "lowercase", "unicode_normalization") |
params | map<string, string> | タイプ固有のパラメータ(文字列のキーと値のペア) |
EmbedderConfig:
| フィールド | 型 | 説明 |
|---|---|---|
type | string | エンベッダータイプ名(例: "precomputed", "candle_bert", "candle_colbert", "openai") |
params | map<string, string> | タイプ固有のパラメータ(例: "model" → "sentence-transformers/all-MiniLM-L6-v2")。"candle_colbert"(Issue #1349)は、任意で "revision"、"query_maxlen"、"doc_maxlen" も受け付けます。長さは "32" のような 10 進の文字列で、それ以外は拒否されます |
各 FieldOption は以下のフィールドタイプのいずれかを持つ oneof です。
| Lexical フィールド | Vector フィールド |
|---|---|
TextOption (indexed, stored, multi_valued, position_increment_gap, term_vectors, doc_values, analyzer) | HnswOption (dimension, distance, m, ef_construction, base_weight, quantizer, embedder, rerank_storage, pq_codebook_path) |
IntegerOption (indexed, stored, multi_valued, doc_values) | FlatOption (dimension, distance, base_weight, quantizer, embedder, rerank_storage) |
FloatOption (indexed, stored, multi_valued, doc_values) | IvfOption (dimension, distance, n_clusters, n_probe, base_weight, quantizer, embedder, rerank_storage) |
BooleanOption (indexed, stored, multi_valued, doc_values) | MultiVectorOption (dimension, distance, embedder, storage) |
DateTimeOption (indexed, stored, multi_valued, doc_values) | |
GeoOption (indexed, stored, multi_valued, doc_values) | |
Geo3dOption (indexed, stored, multi_valued, doc_values) | |
BytesOption (stored, multi_valued) |
ベクトルフィールドオプションの embedder フィールドには、Schema.embedders で定義したエンベッダー名を指定します。設定すると、インデックス時にドキュメントのテキストフィールドからベクトルを自動生成します。事前計算済みのベクトルを直接供給する場合は空のままにします。
Doc values: doc_values(Issue #1047)は、上記の BytesOption を除く全ての lexical オプションで optional bool であり、term_vectors と同じ tri-state の契約に従います。クライアントが省略するとエンジンのデフォルト(true)になり、明示的な false とは区別されます。フィールドの値を DocValues(ソート・ファセット・集計が読み取る列指向ストア)にもコピーするかどうかを制御し、DocValues 列が書き込まれるのは stored と doc_values の両方が true の場合のみです。BytesOption にはこのフィールドがありません —— Bytes の値は設定にかかわらず DocValues に一切書き込まれないためです。ソートにもファセットにも使わないフィールドで doc_values を無効にするとセグメントの使用容量が削減されます。フィールド自体は引き続き完全に検索・取得可能です。
多値地理・多値日時・多値ブール・多値テキスト・多値バイト(multi-valued geo, datetime, boolean, text and bytes): GeoOption / Geo3dOption(Issue #1174)、DateTimeOption(Issue #1184)、および BooleanOption(Issue #1180)の multi_valued は proto のフィールド番号 4 です(IntegerOption / FloatOption と異なり、3 は既に doc_values が使用しているため)。TextOption(Issue #1175)ではフィールド番号 6 です(Text は 3〜5 を既に term_vectors・analyzer・doc_values に使用しているため)。BytesOption(Issue #1176)ではフィールド番号 2 です(1 は既に stored が使用しており、BytesOption には indexed も doc_values もありません)。true にすると地理フィールドは GeoArrayValue / Geo3dArrayValue(後述の Value 表を参照)を受け付け、距離 / バウンディングボックスクエリ(および geo3d_nearest)はいずれかのポイントが条件を満たせばドキュメントにマッチし、最も近いポイントでスコアリングされます。日時フィールドは DatetimeArrayValue(repeated int64。datetime_value と同じ UTC の Unix マイクロ秒)を受け付け、範囲クエリはいずれかの時刻が範囲内にあればドキュメントにマッチします(スコアは constant。複数の時刻がマッチしてもドキュメントは 1 回だけ報告されます)。ブールフィールドは BoolArrayValue(repeated bool)を受け付け、term クエリはいずれかの要素がクエリの値と等しければドキュメントにマッチします。各要素はそれぞれ独立した true / false の term posting としてインデックスされ(ブールに BKD ポイントはありません)、要素の重複はヒット数ではなく term frequency(したがって BM25 スコア)を増やします。テキストフィールドは TextArrayValue(repeated string)を受け付け、term クエリはいずれかの要素がタームを含めばドキュメントにマッチします。position_increment_gap(フィールド 7、optional uint32)は連続する要素の間に挿入される位置数で、slop がこの値に達しない限りフレーズクエリが 2 つの要素をまたがないようにします。term_vectors / doc_values と同じ理由で optional になっており、省略時は 0 ではなくエンジンのデフォルト(100)を意味します。バイトフィールドは BytesArrayValue(repeated bytes)を受け付けますが、Bytes の値はそもそもレキシカルインデックスされないため “any match” のクエリ意味論は一切なく、保存時・ワイヤ上の形と取り込み時の許容個数を変えるだけです。また、スカラーの bytes_value と同じく、DataValue::BytesArray が持つ要素ごとの MIME タイプはワイヤ上に表現されません。
MultiVector フィールド: MultiVectorOption(FieldOption のフィールド番号 12、Issue #1177)は、文書ごとのトークンベクトル(ColBERT 型のトークンごとの埋め込みなど)を late interaction の再採点のために保持するフィールドを宣言します。ANN 索引は持たず、ベクトル検索の対象にはなりません。query_vectors でこのフィールドを指定すると拒否されます。distance は COSINE(書き込み時に L2 正規化する)か DOT_PRODUCT でなければなりません。値は VectorArrayValue(下の Value の表を参照)です。このフィールドは保存されないため、サーバーが返す文書には含まれません。embedder(フィールド番号 3、Issue #1349)には、Schema.embedders で宣言したトークン単位のエンベッダー("candle_colbert")の名前を指定します。指定すると、文書はこのフィールドに text_value を与えられ、テキストはトークンベクトルに埋め込まれます。MultiVector フィールドに置けるのは "candle_colbert" か "precomputed" のエンベッダーだけで、他のベクトルフィールドには "candle_colbert" を置けません。
MultiVector のストレージ: オプションの storage フィールド(フィールド番号 4、enum MultiVectorStorage、Issue #1346)は、各トークンベクトルのディスク上の要素形式を選びます。UNSPECIFIED は F32 と同じ、F32(デフォルト。4 バイト/要素、誤差なし)、F16(2 バイト/要素、要素あたり相対誤差 ~2⁻¹¹)、INT8(約 1 バイト/要素に加えてベクトルごとの小さなスケールのオーバーヘッド — 1 行あたり dimension + 2 バイト — ベクトルごとのスケール max(abs(vector)) / 127 を使用し、セグメント単位やコーパス全体で学習するものではありません)のいずれかです。300 トークン × 128 次元(F32 で約 150 KB/文書)の場合、F16 は約 75 KB/文書、INT8 は約 38 KB/文書になります。未設定時は F32 のままです。
距離メトリクス: COSINE, EUCLIDEAN, MANHATTAN, DOT_PRODUCT, ANGULAR
量子化手法: SCALAR_8BIT(デフォルト), PRODUCT_QUANTIZATION(Issue #481 Stage 3。HNSW インデックスがサポート — Flat / IVF は書き込み時に拒否)
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 のみのランキングになります。#932 以降、サイドカーは 3 つのベクトルインデックスタイプ(HNSW / Flat / IVF)すべてで出力・利用されます(Flat / IVF の再スコアはフィールド指定クエリに適用)。
共有 PQ codebook: HnswOption のオプションフィールド pq_codebook_path(Issue #631)は、laurus train pq-codebook CLI コマンドで一度だけ学習するストレージ相対の共有 PQ codebook ファイルを指定します。設定すると segment は commit / merge のたびに k-means を再学習する代わりに、学習済み codebook で encode されます。PRODUCT_QUANTIZATION quantizer との組み合わせでのみ意味を持ち、設定済みで未学習の場合、commit は学習コマンドを示すエラーで失敗します(per-segment 学習への無言のフォールバック無し)。未設定なら per-segment 学習のままです。
Base weight: HnswOption/FlatOption/IvfOption の base_weight は optional float(Issue #1084)です。クライアントが省略するとエンジンのデフォルト(1.0)が使われ、これは明示的な 0.0 とは区別されます。他の vector フィールドと同時にクエリ対象になったときの、そのフィールドの相対的なスコアリング優先度を設定します。何に効いて何に効かないかはウェイトを参照してください。
QuantizationConfig 構造:
| フィールド | 型 | 説明 |
|---|---|---|
method | QuantizationMethod | 量子化手法(QUANTIZATION_METHOD_SCALAR_8BIT または QUANTIZATION_METHOD_PRODUCT_QUANTIZATION)。0(NONE)は予約、サーバ側で SCALAR_8BIT にフォールバック。 |
subvector_count | uint32 | サブベクトルの数(method が PRODUCT_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_count | uint64 | インデックス内のドキュメント総数 |
vector_fields | map<string, VectorFieldStats> | フィールドごとのベクトル統計情報 |
各 VectorFieldStats には vector_count と dimension が含まれます。
GetSchema
現在のインデックススキーマを取得します。
rpc GetSchema(GetSchemaRequest) returns (GetSchemaResponse);
レスポンスフィールド:
| フィールド | 型 | 説明 |
|---|---|---|
schema | Schema | インデックスのスキーマ |
AddField
稼働中のインデックスにフィールドを動的に追加します。
rpc AddField(AddFieldRequest) returns (AddFieldResponse);
リクエストフィールド:
| フィールド | 型 | 説明 |
|---|---|---|
name | string | フィールド名 |
field_option | FieldOption | フィールド設定 |
レスポンスフィールド:
| フィールド | 型 | 説明 |
|---|---|---|
schema | Schema | フィールド追加後の更新済みスキーマ |
HTTP ゲートウェイ: POST /v1/schema/fields
DeleteField
稼働中のインデックスからフィールドを動的に削除します。既にインデックスされたデータは残りますが、削除されたフィールドにはアクセスできなくなります。
rpc DeleteField(DeleteFieldRequest) returns (DeleteFieldResponse);
message DeleteFieldRequest {
string name = 1;
}
message DeleteFieldResponse {
Schema schema = 1;
}
リクエストフィールド:
| フィールド | 型 | 必須 | 説明 |
|---|---|---|---|
name | string | はい | 削除するフィールド名 |
レスポンス: 更新後の Schema を返します。
DocumentService
PutDocument
ID を指定してドキュメントを挿入または置換します。同じ ID のドキュメントが既に存在する場合は置換されます。
rpc PutDocument(PutDocumentRequest) returns (PutDocumentResponse);
リクエストフィールド:
| フィールド | 型 | 必須 | 説明 |
|---|---|---|---|
id | string | はい | 外部ドキュメント ID |
document | Document | はい | ドキュメントの内容 |
Document 構造:
message Document {
map<string, Value> fields = 1;
}
各 Value は以下の型のいずれかを持つ oneof です。
| 型 | Proto フィールド | 説明 |
|---|---|---|
| Null | null_value | Null 値 |
| Boolean | bool_value | ブール値 |
| Integer | int64_value | 64 ビット符号付き整数 |
| Float | float64_value | 64 ビット浮動小数点数 |
| Text | text_value | UTF-8 文字列 |
| Bytes | bytes_value | バイト列 |
| Vector | vector_value | VectorValue(浮動小数点数のリスト) |
| DateTime | datetime_value | Unix マイクロ秒(UTC) |
| Geo | geo_value | GeoPoint(緯度、経度) |
| Int64Array | int64_array_value | Int64ArrayValue(多値整数。IntegerOption.multi_valued = true を要求) |
| Float64Array | float64_array_value | Float64ArrayValue(多値浮動小数点数。FloatOption.multi_valued = true を要求) |
| Geo3d | geo3d_value | Geo3dPoint(x, y, z メートル単位、ECEF 直交座標系) |
| GeoArray | geo_array_value | GeoArrayValue(repeated GeoPoint。多値 2D ポイント。GeoOption.multi_valued = true を要求) |
| Geo3dArray | geo3d_array_value | Geo3dArrayValue(repeated Geo3dPoint。多値 3D ポイント。Geo3dOption.multi_valued = true を要求) |
| DateTimeArray | datetime_array_value | DatetimeArrayValue(repeated int64 Unix マイクロ秒。多値の時刻。DateTimeOption.multi_valued = true を要求) |
| BoolArray | bool_array_value | BoolArrayValue(repeated bool。多値ブール。BooleanOption.multi_valued = true を要求) |
| TextArray | text_array_value | TextArrayValue(repeated string。多値テキスト。TextOption.multi_valued = true を要求) |
| BytesArray | bytes_array_value | BytesArrayValue(repeated bytes。多値バイト列。MIME はワイヤ上に含まれない。BytesOption.multi_valued = true を要求) |
| VectorArray | vector_array_value | VectorArrayValue(uint32 dimension と repeated float values。トークンベクトルを行優先で詰めたもので、values.len() / dimension 本のベクトル。MultiVectorOption のフィールドを要求) |
Geo3dPoint:
| フィールド | 型 | 説明 |
|---|---|---|
x | double | X 座標(メートル単位、ECEF: 赤道面、+X 方向は経度 0°) |
y | double | Y 座標(メートル単位、ECEF: 赤道面、+Y 方向は東経 90°) |
z | double | Z 座標(メートル単位、ECEF: +Z 方向は北極) |
座標系の詳細および wgs84_to_ecef / ecef_to_wgs84 の変換ユーティリティについては 3D 地理検索 (ECEF) を参照してください。
AddDocument
ドキュメントを追加します。PutDocument と異なり、同じ ID の既存ドキュメントを置換しません。複数のドキュメントが同じ ID を共有できます(チャンキングパターン)。
rpc AddDocument(AddDocumentRequest) returns (AddDocumentResponse);
リクエストフィールドは PutDocument と同じです。
PutDocuments
バッチ Upsert。エントリは入力順に逐次適用され、バッチ全体で WAL fsync は 1 回です — ドキュメントごとに PutDocument を呼ぶよりはるかに高速です。1 バッチ内で重複した ID は、同じ put を 1 件ずつ発行した場合とまったく同じようにデデュープされます(最後の出現が勝ち)。
rpc PutDocuments(PutDocumentsRequest) returns (PutDocumentsResponse);
message DocumentEntry {
string id = 1;
Document document = 2;
}
message PutDocumentsRequest {
repeated DocumentEntry documents = 1;
}
message PutDocumentsResponse {
uint32 applied = 1; // 成功時はリクエストサイズと一致
}
適用できない最初のエントリで fail-fast します。適用済みエントリはロールバックされず(次のコミットで永続化)、エラーステータスのメッセージに失敗位置・その ID・適用済み件数が含まれるため、バッチ(またはその suffix)の再試行は冪等です。呼び出し側の誤り(スキーマ違反など)で失敗したバッチは INVALID_ARGUMENT、ストレージ障害は INTERNAL を返します。
AddDocuments
バッチチャンク追加。PutDocuments と同様ですが既存ドキュメントを削除しないため、同一論理ドキュメントの複数チャンクを追加する目的で ID をバッチ内で繰り返せます。
rpc AddDocuments(AddDocumentsRequest) returns (AddDocumentsResponse);
リクエスト/レスポンスのフィールドは PutDocuments と対になります。
GetDocuments
指定された外部 ID に一致するすべてのドキュメントを取得します。
rpc GetDocuments(GetDocumentsRequest) returns (GetDocumentsResponse);
リクエストフィールド:
| フィールド | 型 | 必須 | 説明 |
|---|---|---|---|
id | string | はい | 外部ドキュメント ID |
レスポンスフィールド:
| フィールド | 型 | 説明 |
|---|---|---|
documents | repeated Document | 一致するドキュメント |
DeleteDocuments
指定された外部 ID に一致するすべてのドキュメントを削除します。
rpc DeleteDocuments(DeleteDocumentsRequest) returns (DeleteDocumentsResponse);
Commit
保留中の変更(追加および削除)をインデックスにコミットします。コミットされるまで、変更は検索に反映されません。
rpc Commit(CommitRequest) returns (CommitResponse);
FlushWal
バッファされた WAL レコードを full commit なしで durable 化します。両メッセージとも空です。デフォルトの per-record sync ポリシーでは near no-op です(各書き込みは既に fsync 済み)。グループコミットポリシーでは、現在の partial batch をオンデマンドで flush し、クラッシュ時の損失窓を抑えます。Commit と異なりセグメントを materialize しないため、バッファされた変更は後続の Commit まで検索に反映されません。
rpc FlushWal(FlushWalRequest) returns (FlushWalResponse);
message FlushWalRequest {}
message FlushWalResponse {}
WAL の耐久性ポリシーはサーバ側の [index.wal] 設定セクションで構成します。設定 → [index.wal] セクション および 永続化と WAL → WAL 耐久性ポリシー を参照してください。
HTTP ゲートウェイ: POST /v1/flush_wal
SearchService
Search
検索クエリを実行し、結果を単一のレスポンスとして返します。
rpc Search(SearchRequest) returns (SearchResponse);
レスポンスフィールド:
| フィールド | 型 | 説明 |
|---|---|---|
results | repeated SearchResult | 関連度順の検索結果 |
total_hits | uint64 | マッチするドキュメントの総数(limit/offset 適用前) |
SearchStream
検索クエリを実行し、結果を 1 件ずつストリーミングで返します。
rpc SearchStream(SearchRequest) returns (stream SearchResult);
SearchRequest フィールド
| フィールド | 型 | 必須 | 説明 |
|---|---|---|---|
query | string | いいえ | Query DSL による Lexical 検索クエリ |
query_vectors | repeated QueryVector | いいえ | ベクトル検索クエリ |
limit | uint32 | いいえ | 最大結果件数(デフォルト: エンジンのデフォルト値) |
offset | uint32 | いいえ | スキップする結果件数 |
fusion | FusionAlgorithm | いいえ | ハイブリッド検索の Fusion アルゴリズム |
lexical_params | LexicalParams | いいえ | Lexical 検索パラメータ |
vector_params | VectorParams | いいえ | ベクトル検索パラメータ |
field_boosts | map<string, float> | いいえ | フィールドごとのスコアブースト |
highlight | HighlightParams | いいえ | フィールドごとのハイライト済みフラグメントを要求する(Issue #1134) |
rescore | RescoreParams | いいえ | 1 段目の上位の結果を late interaction で再採点する(Issue #1351) |
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
| フィールド | 型 | 説明 |
|---|---|---|
vector | repeated float | クエリベクトル |
weight | float | このベクトルの重み(デフォルト: 1.0) |
fields | repeated string | 対象のベクトルフィールド(空の場合は全フィールド) |
FusionAlgorithm
以下の 2 つのオプションを持つ oneof です。
- RRF (Reciprocal Rank Fusion):
kパラメータ(デフォルト: 60) - WeightedSum:
lexical_weightとvector_weight
LexicalParams
| フィールド | 型 | 説明 |
|---|---|---|
min_score | float | 最小スコア閾値 |
timeout_ms | uint64 | 検索タイムアウト(ミリ秒) |
parallel | bool | 並列検索を有効化 |
sort_by | SortSpec | スコアの代わりにフィールドでソート |
SortSpec
| フィールド | 型 | 説明 |
|---|---|---|
field | string | ソート対象のフィールド名。空文字列はスコアでソートすることを意味する |
order | SortOrder | SORT_ORDER_ASC(昇順)または SORT_ORDER_DESC(降順) |
VectorParams
| フィールド | 型 | 説明 |
|---|---|---|
fields | repeated string | 対象のベクトルフィールド |
score_mode | VectorScoreMode | WEIGHTED_SUM, MAX_SIM, または LATE_INTERACTION |
overfetch | float | オーバーフェッチ係数(デフォルト: 2.0) |
min_score | float | 最小スコア閾値 |
rerank_factor | optional uint32 | Stage 2 rerank の widening 係数(Issue #481)。rerank_storage を有効にしたフィールドに対してこの値を設定すると、サーバは int8/PQ 候補取得を top_k * rerank_factor まで広げ、元の完全精度ベクトルで再スコアしてから上位 top_k を返します。#932 以降 3 つのベクトルインデックスタイプ(HNSW・Flat・IVF)すべてで反映されます(Flat/IVF はフィールド指定クエリに適用)。rerank_storage = "F32" を設定していないフィールドでは silent に int8 ランキングへフォールバックします — f32 情報を復元することはできません。0 または省略で rerank 無効。 |
ef_search | optional uint32 | HNSW の ef_search 候補リストサイズをクエリ単位で上書き(Issue #644)。PQ → SQ → f32 の3段 rerank チェーン(Issue #673)も、この値がゲートとなります。rerank_storage を有効にした PQ フィールドで ef_search を top_k * rerank_factor より広く設定すると、グラフが計算した候補集合全体を安価な int8 段で再ランキングしてから exact 段の狭い予算を切り出すようになります(rerank_storage サイドカーから導出、追加設定不要)。HNSW 以外のフィールドでは無視されます。 |
HighlightParams
指定したフィールドのハイライト済みフラグメントを要求します(Issue #1134)。ハイライトは SearchRequest.query(DSL クエリの lexical 節)— つまり lexical スコアリングを駆動するのと同じクエリ — によって行われます。filter_query はハイライト対象の語を提供せず、lexical クエリを持たない Vector-only のリクエストはハイライトを生成しません。stored: true のテキストフィールドのみハイライト可能で、それ以外のフィールド名は黙ってスキップされます。詳細な意味論はハイライトを参照してください。
| フィールド | 型 | 説明 |
|---|---|---|
fields | repeated string | ハイライト対象の保存済みテキストフィールド。必須 — 未設定または空の場合は INVALID_ARGUMENT で拒否される |
max_fragments | optional uint32 | フィールドあたりの最大フラグメント数(デフォルト: 5)。0 は拒否される |
fragment_size | optional uint32 | フラグメントの目標文字数(デフォルト: 150)。0 は拒否される |
tag | optional string | マッチを囲む HTML タグ(デフォルト: "mark")。空文字列は未設定として扱われる |
css_class | optional string | タグに追加する CSS クラス。空文字列は未設定として扱われる |
require_field_match | optional bool | ハイライト対象フィールドを対象とするクエリ語のみを使う(デフォルト: true) |
max_analyzed_chars | optional uint64 | フィールドのテキストのうち解析する最大文字数(デフォルト: 1,000,000) |
return_entire_field_if_no_highlight | optional bool | マッチがない場合にフィールド全体を1つのフラグメントとして返す(デフォルト: false) |
Highlights
| フィールド | 型 | 説明 |
|---|---|---|
fragments | repeated string | 1 フィールド分のハイライト済みフラグメント(最も良いフラグメントが先頭) |
RescoreParams
1 段目(lexical・vector・ハイブリッド)の上位 window_size 件を、MultiVector
フィールドに対する ColBERT 型の late interaction で並べ替えます(Issue #1351)。
採点と並び順の規則は
late interaction による再採点
を参照してください。
| フィールド | 型 | 説明 |
|---|---|---|
window_size | optional uint32 | 再採点する 1 段目の上位の件数。1〜10,000。未設定なら 100 |
late_interaction | LateInteractionRescore | 再採点の方法(oneof、必須) |
LateInteractionRescore:
| フィールド | 型 | 説明 |
|---|---|---|
field | string | MultiVector フィールド |
vectors | VectorArrayValue | クエリのトークンベクトル。文書の値と同じく行ごとに詰める(フィールドの次元のベクトルを 1〜1,024 本)。vectors と text のどちらか一方が必須 |
text | string | クエリのテキスト。フィールドのトークン単位のエンベッダー(candle_colbert)が埋め込む |
値はエンジンが検査します。範囲外の window、MultiVector でないフィールド、
ベクトルの本数や次元の誤り、トークン単位のエンベッダーがないフィールドへの
テキスト、lexical_params.sort_by との併用は、INVALID_ARGUMENT で拒否されます。
late_interaction やクエリのない RescoreParams も同じです。
SearchResult
| フィールド | 型 | 説明 |
|---|---|---|
id | string | 外部ドキュメント ID |
score | float | 関連度スコア。再採点した結果では late interaction(MaxSim)のスコア |
document | Document | ドキュメントの内容 |
highlights | map<string, Highlights> | SearchRequest.highlight.fields で指定したフィールドごとのハイライト済みフラグメント。ハイライトがないフィールドはこのマップに現れない |
例
{
"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
}
}
例: ハイライト
{
"query": "body:rust",
"limit": 10,
"highlight": {"fields": ["body"], "max_fragments": 2, "tag": "em"}
}
マッチしたヒットの SearchResult は以下のようになります。
{
"id": "doc1",
"score": 1.2,
"document": {"fields": {"body": {"text_value": "Rust is great"}}},
"highlights": {"body": {"fragments": ["<em>Rust</em> is great"]}}
}
例: 再採点
ハイブリッドの 1 段目の上位 100 件を、body_colbert の ColBERT エンベッダーで
再採点します。
{
"query": "body:lifetimes body_vec:\"how do lifetimes work\"",
"limit": 10,
"rescore": {
"window_size": 100,
"late_interaction": {"field": "body_colbert", "text": "how do lifetimes work"}
}
}
テキストの代わりに、計算済みのクエリのトークンベクトルを渡す場合:
{
"late_interaction": {
"field": "body_colbert",
"vectors": {"dimension": 2, "values": [1.0, 0.0, 0.0, 1.0]}
}
}
エラーハンドリング
gRPC エラーは標準の Status コードとして返されます。
| Laurus エラー | gRPC ステータス | 発生条件 |
|---|---|---|
| Schema / Query / Field / Invalid argument / JSON | INVALID_ARGUMENT | 不正なリクエストまたはスキーマ、クエリ内の不明なフィールド |
| インデックス未オープン | FAILED_PRECONDITION | CreateIndex の前に RPC が呼び出された場合 |
| インデックスが既に存在 | ALREADY_EXISTS | CreateIndex が 2 回呼び出された場合 |
| 未実装 | UNIMPLEMENTED | まだサポートされていない機能 |
| 内部エラー | INTERNAL | I/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/health | HealthService/Check | ヘルスチェック |
| POST | /v1/index | IndexService/CreateIndex | 新しいインデックスを作成 |
| GET | /v1/index | IndexService/GetIndex | インデックスの統計情報を取得 |
| GET | /v1/schema | IndexService/GetSchema | インデックスのスキーマを取得 |
| POST | /v1/schema/fields | IndexService/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/GetDocuments | ID でドキュメントを取得 |
| DELETE | /v1/documents/{id} | DocumentService/DeleteDocuments | ID でドキュメントを削除 |
| POST | /v1/documents:bulk | DocumentService/PutDocuments / AddDocuments | ドキュメントのバルクインジェスト(?mode=put|add、既定は put) |
| POST | /v1/commit | DocumentService/Commit | 保留中の変更をコミット |
| POST | /v1/flush_wal | DocumentService/FlushWal | full commit なしでバッファされた WAL レコードを durable 化 |
| POST | /v1/search | SearchService/Search | 検索(単発) |
| POST | /v1/search/stream | SearchService/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
レスポンスの text / integer / float / boolean / date_time / geo / geo3d / bytes オプションには常に multi_valued が含まれます(例: "location": {"geo": {"indexed": true, "stored": true, "multi_valued": true, "doc_values": true}}、"seen_at": {"date_time": {"indexed": true, "stored": true, "multi_valued": true, "doc_values": true}}、"flags": {"boolean": {"indexed": true, "stored": true, "multi_valued": true, "doc_values": true}}、"notes": {"text": {"indexed": true, "stored": true, "multi_valued": true, "position_increment_gap": 100}}、または "thumbnail": {"bytes": {"stored": true, "multi_valued": true}})。同じキーは POST /v1/index および POST /v1/schema/fields でも受け付けます。text オプションの position_increment_gap(Issue #1175)は入力では省略でき(省略時は 0 ではなくエンジンのデフォルト 100 を意味します)、レスポンスには常に含まれます。bytes オプションには indexed も doc_values もありません —— Bytes の値は multi_valued にかかわらずインデックスされず、DocValues にも書き込まれません(Issue #1176)。
MultiVector フィールド(Issue #1177)は "body_colbert": {"multi_vector": {"dimension": 128, "distance": "cosine"}} のように宣言します(distance は "cosine" または "dot_product"、省略時は "cosine")。文書での値は、トークンベクトルごとの同じ長さの数値配列の配列です("body_colbert": [[0.1, 0.2, ...], [0.3, 0.4, ...]])。このフィールドは late interaction の再採点のためにトークンベクトルを保持するもので、ベクトル検索の対象にはならず、文書とともに返されることもありません。
MultiVector フィールドには、トークン単位のエンベッダーも指定できます(Issue #1349)。エンベッダーはスキーマの embedders で宣言し、数値のオプションは JSON の数値で書きます。例: "embedders": {"colbert": {"type": "candle_colbert", "model": "answerdotai/answerai-colbert-small-v1", "revision": "934fa8bb4ce2284f4c2baa232d81aca4d076fa5e", "doc_maxlen": 300}} と "body_colbert": {"multi_vector": {"dimension": 96, "embedder": "colbert"}}。こうすると、文書はこのフィールドにテキスト("body_colbert": "how lifetimes work in rust")を与えられ、テキストはトークンベクトルに埋め込まれます。エンベッダーのパラメータのうち数値と真偽値は文字列として渡され、配列とオブジェクトは拒否されます。
MultiVector フィールドには storage(Issue #1346)も指定でき、各トークンベクトルのディスク上の要素形式を選べます。"f32"(デフォルト、誤差なし)、"f16"("f32" の半分のサイズ、要素あたり相対誤差 ~2⁻¹¹)、"int8"(典型的な次元数で "f32" の約 1/4 のサイズ、ベクトルごとのスケールを使用)のいずれかです。値は小文字の文字列で、入力時は大文字小文字を区別せずに受け付けます。例: "body_colbert": {"multi_vector": {"dimension": 128, "storage": "int8"}}。認識できない値は拒否されます。storage がデフォルトの "f32" の場合、このキーはレスポンスから省略されます。
フィールドの動的追加
稼働中のインデックスにフィールドを追加します。リクエストボディは 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 '{
"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 '{
"fields": {
"title": "Hello World",
"body": "This is a test document."
}
}'
ドキュメントのバルクインジェスト(POST)
1 回の呼び出しで多数のドキュメントを適用します — エントリは入力順に逐次処理され、バッチ全体で WAL fsync は 1 回です。?mode=put(既定)は Upsert(重複 ID はデデュープ、最後が勝ち)、?mode=add はチャンク追加(繰り返した ID は蓄積)です。
curl -X POST 'http://localhost:8080/v1/documents:bulk?mode=put' \
-H 'Content-Type: application/json' \
-d '{
"documents": [
{"id": "doc1", "fields": {"title": "Hello"}},
{"id": "doc2", "fields": {"title": "World"}}
]
}'
# => {"applied": 2}
適用できない最初のエントリで fail-fast します。適用済みエントリはロールバックされず(次のコミットで永続化)、エラーには失敗位置が含まれるため、バッチまたはその suffix の再試行は冪等です。
ドキュメントの取得
curl http://localhost:8080/v1/documents/doc1
ドキュメントの削除
curl -X DELETE http://localhost:8080/v1/documents/doc1
コミット
curl -X POST http://localhost:8080/v1/commit
WAL のフラッシュ
バッファされた WAL レコードを full commit なしで durable 化します。成功時は {} を返します。デフォルトの per-record sync ポリシーでは near no-op です。グループコミットポリシーでは、現在の partial batch をオンデマンドで flush します。バッファされた変更は後続の POST /v1/commit まで検索に反映されません。
curl -X POST http://localhost:8080/v1/flush_wal
検索
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}}
}'
ハイライト付き検索
highlight を指定すると、フィールドごとのハイライト済みフラグメントを要求できます(Issue #1134)。省略形はフィールド名の配列だけです。
curl -X POST http://localhost:8080/v1/search \
-H 'Content-Type: application/json' \
-d '{"query": "body:rust", "limit": 10, "highlight": ["body"]}'
オブジェクト形式では HighlightConfig の各設定(max_fragments、fragment_size、tag、css_class、require_field_match、max_analyzed_chars、return_entire_field_if_no_highlight)を追加できます。
curl -X POST http://localhost:8080/v1/search \
-H 'Content-Type: application/json' \
-d '{
"query": "body:rust",
"limit": 10,
"highlight": {"fields": ["body"], "max_fragments": 2, "tag": "em"}
}'
各結果には、少なくとも1つのフィールドが実際にハイライトされた場合にのみ "highlights" オブジェクトが追加されます。
{"id": "doc1", "score": 1.2, "fields": {...}, "highlights": {"body": ["<em>Rust</em> is a systems programming language"]}}
highlight はスキーマ上で stored: true のテキストフィールドにのみ作用し、ハイライトは常にリクエストの lexical クエリに従います。filter_query はハイライト対象の語を提供せず、Vector-only のリクエストは highlights を一切生成しません。詳細な意味論はハイライトを参照してください。
再採点付き検索
rescore を指定すると、上位 window_size 件(既定 100)を MultiVector フィールドに対する late interaction で並べ替えます(Issue #1351)。クエリは、フィールドのトークン単位のエンベッダー(candle_colbert)が埋め込むテキストか、クエリのトークンベクトル(同じ長さの数値配列の配列)です。
curl -X POST http://localhost:8080/v1/search \
-H 'Content-Type: application/json' \
-d '{
"query": "body:lifetimes",
"limit": 10,
"rescore": {
"window_size": 100,
"late_interaction": {"field": "body_colbert", "text": "how do lifetimes work"}
}
}'
"rescore": {"late_interaction": {"field": "body_colbert", "vectors": [[0.1, 0.2], [0.3, 0.4]]}}
再採点した結果の score は late interaction(MaxSim)のスコアです。ほかの検索オプションと違い、不正な rescore(field がない、vectors と text の両方またはどちらもない、ベクトルの長さがそろわない)は無視されず 400 で拒否されます。エンジンが拒否する値も同じです(gRPC の RescoreParams と late interaction による再採点を参照)。
ストリーミング検索(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,"fields":{...}}
data: {"id":"doc2","score":0.4210,"fields":{...}}
JSON フィールド値の型推論
ドキュメント投入リクエスト(PUT /v1/documents/{id} または
POST /v1/documents/{id})のボディに含まれる fields の各値は、
laurus-cli や laurus-mcp と共通の正典コンバータ json_to_document により、
スキーマレス取り込みと同じ推論ルールでエンジンの
DataValue に変換されます。これにより
HTTP・gRPC・CLI・MCP のすべての経路で挙動が一致します。
| JSON 値 | 推論されるフィールド型 | 備考 |
|---|---|---|
null | (スキップ) | フィールドごと省略されます(明示的な null としてもエンジンには送られません)。 |
true / false | boolean | |
整数(i64 に収まる) | integer | |
| 浮動小数点 / 巨大整数 | float | |
"text" | text | |
[1, 2, 3](全要素 integer) | integer(multi_valued: true) | 多値数値フィールド。 |
[1.0, 2.5](非整数を含む数値配列) | float(multi_valued: true) | |
[](空配列) | (スキップ) | 要素型を決定できないためフィールドはスキップされます。 |
{"latitude": ..., "longitude": ...} | geo | |
{"lat": ..., "lon": ...} / {"lat": ..., "lng": ...} | geo | latitude / longitude の短縮別名を受け付けます。 |
{"x": ..., "y": ..., "z": ...} | geo3d | 3 キーすべて必須、有限な数値、ECEF メートル単位。lat/lon キーとの混在は拒否されます。 |
[{"latitude": 35.6, "longitude": 139.7}, ...](全要素が地理 object) | geo(multi_valued: true) | 多値地理フィールド。lat / lon / lng の別名も受け付けます。ドキュメント取得時は {"latitude", "longitude"} object の配列として返されます。 |
[{"x": ..., "y": ..., "z": ...}, ...](全要素が 3D object) | geo3d(multi_valued: true) | 多値 3D 地理フィールド。取得時は {"x", "y", "z"} object の配列として返されます。1 つの配列に 2D と 3D の object を混在させると拒否されます。 |
["2024-01-01T00:00:00Z", "2024-06-15T21:00:00+09:00"](全要素が RFC 3339 文字列) | date_time(multi_valued: true) | 多値日時フィールド(Issue #1184)。ここで日時として認識されるのは RFC 3339 文字列のみです。ドキュメント取得時は UTC に正規化した RFC 3339 文字列の配列(例: "2024-06-15T12:00:00+00:00")として返されます。 |
["a", "b"](全要素が文字列だが、全要素が RFC 3339 ではない) | text(multi_valued: true) | 多値テキストフィールド(Issue #1175)。各要素は独立に解析されます。term クエリはいずれかの要素がタームを含めばマッチし、フレーズクエリは slop がフィールドの position_increment_gap(デフォルト 100)に達しない限り 2 つの要素をまたぎません。ドキュメント取得時は文字列の配列として返されます。 |
[true, false](全要素がブール値) | boolean(multi_valued: true) | 多値ブールフィールド(Issue #1180)。flags:true のような term クエリはいずれかの要素が値と等しければマッチします。ドキュメント取得時はブール値の配列として返されます。 |
{"data": "<base64>", "mime": "..."} | bytes | mime は省略可能。マルチモーダルなベクトルフィールド(Text と Bytes の両方を受け付ける embedder)に対して、素の文字列と画像バイト列を区別するために使います — 詳細は スキーマとフィールド を参照してください。 |
以下の場合、ゲートウェイは HTTP 400(Bad Request)を返します:
- 配列が混在型もしくは非数値要素を含む(例:
[1, "x"]や[true, 1])、または 2D と 3D の地理 object を混在させている(例:[{"lat": ...}, {"x": ...}]) - オブジェクトが上記のいずれの形にも一致しない(2D は latitude/longitude
キーが、3D は
x/y/zのいずれかが欠けている、またはdataが 文字列でない、など) - 緯度が
[-90, 90]の範囲外、または経度が[-180, 180]の範囲外 - 3D ECEF の座標が有限値でない(
NaN/Inf) - 同一オブジェクトに複数の形(2D の
lat/lon、3D のx/y/z、 Bytes のdata)のキーが混在
ベクトルフィールドは JSON だけからは推論できません(次元数・距離関数・
embedder の設定を値だけから復元できないため)。スキーマで明示的に
宣言する必要があります。宣言済みのベクトルフィールドに数値配列が送られた
場合は自動的に f32 ベクトルへキャストされるので、REST クライアントは
埋め込みベクトルを通常の JSON 配列として送信できます。バイト列フィールドは
上表の {"data", "mime"} オブジェクト形式、または宣言済み Bytes
フィールドであれば素の base64 文字列でも投入できます。
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 をはじめる
前提条件
laurusCLI バイナリがインストール済み(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 に切り替える場合に、他のツールを使用する前にこのツールを呼び出してください。
パラメーター
| 名前 | 型 | 必須 | 説明 |
|---|---|---|---|
endpoint | string | はい | gRPC エンドポイント URL(例: http://localhost:50051) |
例
Tool: connect
endpoint: "http://localhost:50051"
結果: Connected to laurus-server at http://localhost:50051.
create_index
指定されたスキーマで新しい検索インデックスを作成します。
パラメーター
| 名前 | 型 | 必須 | 説明 |
|---|---|---|---|
schema_json | string | はい | 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.14 → 3)。厳密さが必要なら "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 を呼び出してください。
パラメーター
| 名前 | 型 | 必須 | 説明 |
|---|---|---|---|
id | string | はい | 外部ドキュメント識別子 |
fields | object | はい | JSON オブジェクトとしてのドキュメントフィールド |
例
Tool: put_document
id: "doc-1"
fields: {"title": "Hello World", "body": "これはテストドキュメントです。"}
結果: Document 'doc-1' put (upserted). Call commit to persist changes.
Geo3d 値を含む例:
Tool: put_document
id: "drone-1"
fields: {"title": "東京上空のドローン", "position": {"x": -3955182.0, "y": 3350553.0, "z": 3700276.0}}
MCP サーバーは 3D ECEF 点を x、y、z キーを持つ JSON オブジェクト(メートル単位)として受け付けます。これは HTTP ゲートウェイや laurus-cli と同じ形式です(HTTP Gateway の「JSON フィールド値の型推論」セクション参照)。
add_document
インデックスにドキュメントを新しいチャンクとして追加します。put_document とは異なり、同じ ID の既存ドキュメントを削除せずに追記します。大きなドキュメントをチャンクに分割する際に便利です。ドキュメント追加後は commit を呼び出してください。
パラメーター
| 名前 | 型 | 必須 | 説明 |
|---|---|---|---|
id | string | はい | 外部ドキュメント識別子 |
fields | object | はい | JSON オブジェクトとしてのドキュメントフィールド |
例
Tool: add_document
id: "doc-1"
fields: {"title": "Hello World - Part 2", "body": "これは続きです。"}
結果: Document 'doc-1' added as chunk. Call commit to persist changes.
put_documents
1 回の呼び出しで多数のドキュメントを Put(Upsert)します。エントリは入力順に逐次適用され、バッチ全体で WAL fsync は 1 回です — ドキュメントごとに put_document を呼ぶよりはるかに高速です。1 バッチ内で重複した ID はデデュープされます(最後の出現が勝ち)。実行後に commit を呼び出してください。
パラメータ
| 名前 | 型 | 必須 | 説明 |
|---|---|---|---|
documents | array | はい | {"id": "...", "fields": {...}} エントリの配列。各エントリの fields は put_document と同じ形式 |
例
Tool: put_documents
documents: [
{"id": "doc-1", "fields": {"title": "Hello"}},
{"id": "doc-2", "fields": {"title": "World"}}
]
結果: 2 documents put (upserted). Call commit to persist changes.
失敗した場合は該当エントリで中断し、適用済みの prefix はロールバックされないため、バッチ(またはその suffix)の再試行は冪等です。
add_documents
1 回の呼び出しで多数のドキュメントを新しいチャンクとして追加します。put_documents と異なり既存ドキュメントは削除されないため、ID を繰り返すと同一論理ドキュメントの複数チャンクになります。バッチ全体で WAL fsync は 1 回です。実行後に commit を呼び出してください。
パラメータ
put_documents と同じです。
例
Tool: add_documents
documents: [
{"id": "doc-1", "fields": {"title": "Part 1"}},
{"id": "doc-1", "fields": {"title": "Part 2"}}
]
結果: 2 documents added as chunks. Call commit to persist changes.
get_documents
外部 ID で全ドキュメント(チャンクを含む)を取得します。
パラメーター
| 名前 | 型 | 必須 | 説明 |
|---|---|---|---|
id | string | はい | 外部ドキュメント識別子 |
結果
{
"id": "doc-1",
"documents": [
{ "fields": { "title": "Hello World", "body": "これはテストドキュメントです。" } }
]
}
delete_documents
外部 ID で全ドキュメント(チャンクを含む)を削除します。削除後は commit を呼び出してください。
パラメーター
| 名前 | 型 | 必須 | 説明 |
|---|---|---|---|
id | string | はい | 外部ドキュメント識別子 |
結果: Documents 'doc-1' deleted. Call commit to persist changes.
commit
保留中の変更をディスクにコミットします。変更を検索可能かつ永続的にするため、put_document、add_document、または delete_documents の後に必ず呼び出してください。
パラメーター
なし。
結果: Changes committed successfully.
add_field
インデックスにフィールドを追加します。
パラメーター
| 名前 | 型 | 必須 | 説明 |
|---|---|---|---|
name | string | はい | フィールド名 |
field_option_json | string | はい | JSON 形式のフィールド設定 |
例
{
"name": "category",
"field_option_json": "{\"Text\": {\"indexed\": true, \"stored\": true}}"
}
delete_field
インデックスからフィールドを削除します。既にインデックスされたデータは残りますが、削除されたフィールドにはアクセスできなくなります。
パラメーター
| 名前 | 型 | 必須 | 説明 |
|---|---|---|---|
name | string | はい | 削除するフィールド名 |
例
Tool: delete_field
name: "category"
結果: Field 'category' deleted.
search
laurus 統一クエリ DSL を使用してドキュメントを検索します。Lexical 検索、Vector 検索、ハイブリッド検索を単一のクエリ文字列でサポートします。
パラメーター
| 名前 | 型 | 必須 | 説明 |
|---|---|---|---|
query | string | はい | laurus 統一クエリ DSL による検索クエリ |
limit | integer | いいえ | 最大結果数(デフォルト: 10) |
offset | integer | いいえ | ページネーション用スキップ数(デフォルト: 0) |
fusion | string | いいえ | ハイブリッド検索用の融合アルゴリズム(JSON) |
field_boosts | string | いいえ | フィールド毎のブースト係数(JSON) |
highlight | string | いいえ | フィールドごとのハイライト済みフラグメント(JSON、ハイライトの例を参照) |
rescore | string | いいえ | 上位の結果の late interaction による再採点(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}
ハイライトの例
highlight はフィールドごとのハイライト済みフラグメントを要求します(Issue #1134)。ハイライトはこのツールの lexical クエリに従うため、Vector-only のクエリはハイライトを生成しません。また stored: true のテキストフィールドのみハイライト可能です。省略形はフィールド名の配列だけです。
["body"]
オブジェクト形式では HighlightConfig の各設定(max_fragments、fragment_size、tag、css_class、require_field_match、max_analyzed_chars、return_entire_field_if_no_highlight)を追加できます。
{"fields": ["body"], "max_fragments": 2, "tag": "em"}
再採点の例
rescore は、上位 window_size 件(既定 100)を MultiVector フィールドに対する late interaction で並べ替えます(Issue #1351)。再採点した結果の score は late interaction(MaxSim)のスコアです。クエリは、フィールドのトークン単位のエンベッダー(candle_colbert)が埋め込むテキスト:
{"late_interaction": {"field": "body_colbert", "text": "how do lifetimes work"}}
か、クエリのトークンベクトルです。
{"window_size": 50, "late_interaction": {"field": "body_colbert", "vectors": [[0.1, 0.2], [0.3, 0.4]]}}
不正な rescore はツールのエラーになります。search_batch には rescore パラメーターはありません。late interaction による再採点を参照してください。
結果
highlight を要求し、少なくとも1つのフィールドが実際にハイライトされた場合、各結果に "highlights" オブジェクトが追加されます。
{
"total": 2,
"results": [
{
"id": "doc-1",
"score": 3.14,
"fields": { "title": "Hello World", "body": "..." },
"highlights": { "body": ["<em>Hello</em> World"] }
},
{
"id": "doc-2",
"score": 1.57,
"fields": { "title": "Hello Again", "body": "..." }
}
]
}
search_batch
独立した複数の検索を 1 回のラウンドトリップで実行します。すべてのクエリは
サーバー上で並列に実行され、limit と offset はすべてのクエリに共通で
適用されます。エージェントが 1 ターンで複数のサブクエリを発行する場合に
有用です。
パラメータ
| 名前 | 型 | 必須 | 説明 |
|---|---|---|---|
queries | 文字列の配列 | はい | laurus 統一クエリ DSL のクエリ文字列(search と同じ構文) |
limit | 整数 | いいえ | クエリごとの最大結果数(デフォルト: 10) |
offset | 整数 | いいえ | クエリごとのページネーションオフセット(デフォルト: 0) |
highlight | 文字列 | いいえ | フィールドごとのハイライト済みフラグメント(JSON)。search の highlight と同じ形式で、バッチ内のすべてのクエリに同一に適用される |
結果
batch 配列は入力順を保持します。batch[i] は queries[i] の結果セットです。
{
"batch": [
{
"total": 1,
"results": [
{ "id": "doc-1", "score": 3.14, "fields": { "title": "Hello World" } }
]
},
{
"total": 1,
"results": [
{ "id": "doc-2", "score": 2.71, "fields": { "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 バインディングです。PyO3 と Maturin を使ってネイティブ 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() が常に必要になる)
ためです。代わりに、各メソッドは内部で
tokio::Runtime::block_on() を呼び出し、非同期 Rust を
同期 Python にブリッジしていますが、その呼び出しの間は
GIL を解放します(Python::detach、Issue #1103)。これにより、
呼び出し中も他の Python スレッドは動き続けられます。以前は
すべての呼び出しが GIL 上で直列化されていましたが、現在は
マルチスレッドサーバーがワーカースレッドを増やすことで
実際にスループットの恩恵を受けられます。
Python スレッドが初めて真の並行ライターになれるため、
エンジン側の既存の並行性に関する制約が Python からも
到達可能になります: commit() は並行する
put/add/delete 呼び出しと直列化されず、
CommitPolicy の自動コミット保証は単一ライターでの
取り込みを前提としており、同一 Index への並行ライター
下では best-effort(ベストエフォート)になります。
並行実行下でこれらの保証が必要な場合は、明示的な
commit() 呼び出し、または単一の取り込みスレッドを
使用してください。
注意: Node.js バインディング(
laurus-nodejs)では、 同じ Rust エンジンのメソッドをネイティブなasync/PromiseAPI として公開しています。 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']}")
セクション
- インストール – パッケージのインストール方法
- クイックスタート – サンプルによるハンズオン入門
- API リファレンス – クラスとメソッドの完全リファレンス
- 開発 – ソースからのビルド、テスト、プロジェクト構成
インストール
PyPI からインストール
pip install laurus
ソースからビルド
ソースからビルドするには Rust ツールチェーン(1.85 以降。ルート Cargo.toml の workspace.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.10 以降
- コンパイル済みネイティブ拡張以外のランタイム依存関係なし
クイックスタート
1. インデックスを作成する
import laurus
# インメモリインデックス(一時的、プロトタイピングに最適)
index = laurus.Index()
# ファイルベースインデックス(永続的)
# `./myindex/schema.toml` と `./myindex/store/` を書き込む -- これは
# `laurus-cli create index --schema` と同じレイアウトなので、このディレクトリは
# CLI からも開ける(逆も同様)。
schema = laurus.Schema()
schema.add_text_field("title")
schema.add_text_field("body")
index = laurus.Index(path="./myindex", schema=schema)
# 後で再オープンする際はパスだけで済む -- schema を再度渡すとエラーになる
# (スキーマは既に永続化されているため)。
index = laurus.Index(path="./myindex")
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. Late interaction による再採点
late interaction による再採点は、どの検索の上位の結果も ColBERT 型の MaxSim で並べ替えます。MaxSim は、文書ごとのトークンベクトルを保持する MultiVector フィールドに対して計算します。
import laurus
schema = laurus.Schema()
schema.add_text_field("title")
schema.add_multi_vector_field("tokens", dimension=2, distance="dot_product")
index = laurus.Index(schema=schema)
index.put_document("doc1", {"title": "Rust", "tokens": [[0.1, 0.0]]})
index.put_document("doc2", {"title": "Rust language", "tokens": [[0.9, 0.2], [0.0, 0.3]]})
index.commit()
results = index.search(
"title:rust",
rescore=laurus.LateInteractionRescore("tokens", [[1.0, 0.0], [0.0, 1.0]]),
)
トークンベクトルの代わりにテキストを渡すには、"candle_colbert" のエンベダーを登録し(schema.add_embedder("colbert", {"type": "candle_colbert", "model": "colbert-ir/colbertv2.0"}))、MultiVector フィールドに embedder="colbert" を指定します。すると文書はフィールドにテキストを与えられ、再採点のクエリも文字列で渡せます。詳細は API リファレンス → LateInteractionRescore を参照してください。
7. 更新と削除
# 更新: 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()
8. スキーマ管理
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)
schema.add_multi_vector_field("tokens", dimension=128)
9. インデックス統計
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,
wal_sync_policy: WalSyncPolicy | None = None,
commit_policy: CommitPolicy | None = None,
) -> None: ...
コンストラクタ
| パラメータ | 型 | デフォルト | 説明 |
|---|---|---|---|
path | str | None | None | 永続ストレージのディレクトリパス。None の場合はインメモリインデックスを作成します。指定した場合、そのディレクトリは laurus-cli create index/--index-dir と同じ <path>/schema.toml + <path>/store/ というレイアウトに従うため、ここで作成したインデックスは CLI からも開けます(逆も同様)。詳細は下記を参照してください。 |
schema | Schema | None | None | スキーマ定義。新規にファイルベース(またはインメモリ)インデックスを作成する場合のみ意味を持ちます。既存のファイルベースインデックスを再オープンする場合は省略(None)する必要があり、永続化済みのスキーマが代わりに読み込まれます。新規インデックスで省略した場合は空のスキーマが使用されます。 |
wal_sync_policy | WalSyncPolicy | None | None | WAL の永続性ポリシー。None の場合はデフォルトのレコードごとの fsync を使用します。WAL 同期ポリシー / 永続性を参照してください。 |
commit_policy | CommitPolicy | None | None | 自動コミットポリシー。None の場合はデフォルトの手動コミット(自動コミットなし)を使用します。コミットポリシー / 自動コミットを参照してください。 |
ファイルベースインデックスの作成 vs 再オープン(path を指定した場合): <path>/schema.toml がまだ存在しない場合、この呼び出しは新規インデックスを作成し、schema(省略時は空のスキーマ)をそこに永続化します。<path>/schema.toml が既に存在する場合、この呼び出しは既存インデックスを再オープンします – schema は省略しなければならず、指定すると ValueError になります(どちらのスキーマを優先すべきか曖昧になるため)。path がこの規約導入以前のレイアウト(schema.toml が無く、セグメントファイルが path 直下にある)のインデックスを含んでいる場合も ValueError になります。
メソッド
| メソッド | 説明 |
|---|---|
put_document(id, doc) | ドキュメントをアップサート(upsert)します。同じ ID の既存バージョンをすべて置換します。 |
add_document(id, doc) | 既存バージョンを削除せずにドキュメントチャンクを追記します。 |
put_documents(docs) | バッチ upsert。docs は (id, dict) ペアのイテラブルで、バッチごとに WAL fsync 1 回で順に適用します(重複 ID はデデュープ、最後が勝ち)。最初の不正エントリで fail-fast し、適用済みの prefix はロールバックされません。 |
add_documents(docs) | バッチチャンク追記。put_documents と同様ですが、繰り返した ID は別バージョンとして蓄積されます。 |
get_documents(id) -> list[dict] | 指定 ID の全保存バージョンを返します。 |
delete_documents(id) | 指定 ID の全バージョンを削除します。 |
commit() | バッファリングされた書き込みをフラッシュし、すべての保留中の変更を検索可能にします。 |
flush_wal() | WAL の永続性バリアを強制します。WAL 同期ポリシー / 永続性を参照してください。 |
search(query, *, limit=10, offset=0, highlight=None, rescore=None) -> list[SearchResult] | 検索クエリを実行します。rescore には、MultiVector フィールドに対する late interaction で上位の結果を並べ替える LateInteractionRescore を渡します(Issue #1351)。query が SearchRequest の場合はリクエスト自身の rescore を使い、このキーワードは無視されます。 |
search_batch(queries, *, limit=10, offset=0, highlight=None) -> list[list[SearchResult]] | 独立した複数の検索を 1 回の呼び出しで実行します。各クエリは内部の tokio ランタイム上で並列に dispatch されます。results[i] は queries[i] に対応し、入力が空のリストの場合は [] を返します。highlight はバッチ内のすべてのクエリに同一に適用されます。rescore キーワードはありません。再採点が必要なクエリには、rescore= を指定した SearchRequest を渡してください。 |
stats() -> dict | インデックス統計(document_count、vector_fields)を返します。 |
search の query 引数
query パラメータは以下のいずれかを受け付けます:
- DSL 文字列(例:
"title:hello"、"content:\"memory safety\"") - Lexical クエリオブジェクト(
TermQuery、PhraseQuery、BooleanQueryなど) - Vector クエリオブジェクト(
VectorQuery、VectorTextQuery) SearchRequest(完全な制御が必要な場合)
search_batch の queries リストの各要素も同じ種類の値を受け付けます。DSL 文字列・クエリオブジェクト・SearchRequest を 1 つのバッチ内で混在させることもできます。
ハイライト
search/search_batch の highlight パラメータ(Issue #1134)は、各ヒットの SearchResult.highlights にフィールドごとのハイライト済みフラグメントを要求します。以下のいずれかを受け付けます。
- フィールド名のリスト:
highlight=["body"] - 辞書: 必須の
"fields"キーに加えて、HighlightConfigの任意の設定(max_fragments、fragment_size、tag、css_class、require_field_match、max_analyzed_chars、return_entire_field_if_no_highlight)を指定 — 例:highlight={"fields": ["body"], "tag": "em", "max_fragments": 2}
results = index.search("body:rust", highlight=["body"])
print(results[0].highlights) # {"body": ["<mark>Rust</mark> is a systems programming language."]}
ハイライトは search/search_batch に渡したクエリ(または SearchRequest.query/lexical_query、下記参照)に従い、stored: true のテキストフィールドのみハイライト可能です。存在しない、保存されていない、テキスト型でないフィールドは黙ってスキップされます。highlight を省略すると、すべての結果の highlights は空のままになります。同じ highlight= キーワードは SearchRequest でも使用できます。
WAL 同期ポリシー / 永続性
永続インデックスでは、すべての書き込みが先行書き込みログ(WAL)に追記されます。
デフォルトでは WAL はすべてのレコードごとに fsync されるため、呼び出しが
返った時点で各書き込みは完全に永続化されます。コンストラクタはオプションの
wal_sync_policy を受け付け、永続性を一部犠牲にして書き込みスループットを
向上させることができます。また flush_wal() で必要なときに永続性バリアを
強制できます。
class WalSyncPolicy:
@staticmethod
def per_record() -> WalSyncPolicy: ...
@staticmethod
def group(
max_records: int | None = None,
max_bytes: int | None = None,
max_interval_ms: int | None = None,
) -> WalSyncPolicy: ...
| コンストラクタ | 説明 |
|---|---|
WalSyncPolicy.per_record() | デフォルト。WAL レコードごとに fsync し、書き込みごとに完全に永続化します。 |
WalSyncPolicy.group(...) | グループコミット。複数の書き込みにまたがって fsync をまとめます。 |
group() のパラメータ(いずれもキーワード指定可。None はデフォルトを維持):
| パラメータ | デフォルト | 説明 |
|---|---|---|
max_records | 1024 | この件数のレコードが蓄積されたらフラッシュします。 |
max_bytes | 1048576(1 MiB) | この量の未同期バイトが蓄積されたらフラッシュします。 |
max_interval_ms | None | 任意の定期フラッシュタイマー(ミリ秒)。None でタイマー無効。 |
グループコミットでは、max_records または max_bytes のいずれかに達した
時点で WAL がフラッシュされ、commit() 時にも必ずフラッシュされます。
クラッシュ時には最後の未同期バッチまでを失う可能性があります — これは
SQLite の synchronous = NORMAL と同じトレードオフです。完全な commit() を
行わずにこれまで書き込んだ内容をディスクへ強制するには flush_wal() を
呼び出します。
| メソッド | 説明 |
|---|---|
flush_wal() | 今すぐ WAL の永続性バリアを強制します。同期メソッドで None を返します。 |
import laurus
# 1 秒の定期フラッシュタイマー付きでグループコミットを有効化します。
policy = laurus.WalSyncPolicy.group(max_records=4096, max_interval_ms=1000)
index = laurus.Index(path="./myindex", wal_sync_policy=policy)
for i in range(10_000):
index.put_document(f"doc{i}", {"title": f"Document {i}"})
# まだコミットせずに永続性バリアを強制します。
index.flush_wal()
index.commit() # WAL もフラッシュされます
wal_sync_policy を省略する(または WalSyncPolicy.per_record() を渡す)と、
デフォルトの完全に永続的な動作が維持されます。
コミットポリシー / 自動コミット
コミットはバッファリングされた書き込みをストアへ materialize し、すべての
保留中の変更を検索可能にします。デフォルトでは Index は自動コミットを
行わないため、呼び出し側がすべての commit() を明示的に実行します。
コンストラクタはオプションの commit_policy を受け付け、一定件数の
ドキュメントを適用するごと、または一定時間ごとにエンジンが自動的に
コミットするようにできます。
class CommitPolicy:
@staticmethod
def manual() -> CommitPolicy: ...
@staticmethod
def every_docs(n: int) -> CommitPolicy: ...
@staticmethod
def interval_ms(ms: int) -> CommitPolicy: ...
| コンストラクタ | 説明 |
|---|---|
CommitPolicy.manual() | デフォルト。自動コミットなし。呼び出し側がすべての commit() を実行します。 |
CommitPolicy.every_docs(n) | n 件のドキュメントを適用するごとに自動コミットします。 |
CommitPolicy.interval_ms(ms) | バックグラウンドタイマーにより、少なくとも ms ミリ秒ごとに自動コミットします(デフォルト: なし)。ネイティブ専用で、wasm では no-op です。 |
every_docs(n) は、単体(put_document、add_document)とバッチ
(put_documents、add_documents)の両方の取り込みにまたがって適用済み
ドキュメントを数え、n 件ごとに自動コミットします — 1 つのバッチの
内部でも同様です。every_docs(0) も有効で、自動コミットを無効化するため
manual() と等価になります。
interval_ms(ms) は every_docs(n) の時間ベースの対応版です。
バックグラウンドタイマーが少なくとも ms ミリ秒ごとにコミットするため、
取り込みがアイドル状態でも末尾の部分バッチがコミットされます。この
ファクトリは ネイティブ専用 です — wasm にはバックグラウンドスレッドが
存在しないため、エンジンはこれを no-op として扱います。値の構築は
できますが、WebAssembly 上ではタイマーによるコミットは発生しません。
commit_policy は wal_sync_policy と直交します。wal_sync_policy は WAL の
fsync 永続性を制御するのに対し、commit_policy はストアが保留中の変更を
検索可能な状態へ materialize するタイミングを制御します。一方を設定しても
他方には影響しません。
import laurus
# 100 件のドキュメントを適用するごとに自動コミットします。
index = laurus.Index(
path="./myindex",
commit_policy=laurus.CommitPolicy.every_docs(100),
)
for i in range(1_000):
index.put_document(f"doc{i}", {"title": f"Document {i}"})
# エンジンはすでに 10 回コミットしています(100 件ごとに 1 回)。
commit_policy を省略する(または CommitPolicy.manual() を渡す)と、
デフォルトの手動コミットの動作が維持されます。
Schema
Index のフィールドとインデックスタイプを定義します。
class Schema:
def __init__(self) -> None: ...
フィールドメソッド
| メソッド | 説明 |
|---|---|
add_text_field(name, *, stored=True, indexed=True, term_vectors=True, doc_values=True, multi_valued=False, position_increment_gap=100, analyzer=None) | 全文フィールド(転置インデックス、BM25)。term_vectors はタームの位置を保存するかどうかを制御し、フレーズクエリ・スパンクエリが読み取ります。doc_values は値を DocValues(ソート・ファセット・集計が読み取る列指向ストア)にもコピーするかどうかを制御します(Issue #1047)。stored=True の場合のみ有効です。multi_valued=True で list[str] を受け付けます(Issue #1175): term クエリはいずれかの要素がタームを含めばマッチし、フレーズクエリは slop が position_increment_gap(デフォルト 100。0 にすると要素を連結したものとして付番)に達しない限り 2 つの要素をまたぎません。値は list[str] として読み戻されます。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, doc_values=True) | 64 ビット整数フィールド。multi_valued=True で整数配列を受け付け(範囲クエリは “any match”)。doc_values は上記を参照。 |
add_float_field(name, *, stored=True, indexed=True, multi_valued=False, doc_values=True) | 64 ビット浮動小数点フィールド。multi_valued=True で浮動小数点配列を受け付け(範囲クエリは “any match”)。doc_values は上記を参照。 |
add_boolean_field(name, *, stored=True, indexed=True, multi_valued=False, doc_values=True) | ブールフィールド。multi_valued=True で list[bool] を受け付け(flags:true のような term クエリはいずれかの要素が値と等しければマッチ。値は list[bool] として読み戻されます)。doc_values は上記を参照。 |
add_bytes_field(name, *, stored=True, multi_valued=False) | 生バイトフィールド。doc_values オプションはありません —— Bytes の値は設定にかかわらず DocValues に一切書き込まれないためです。multi_valued=True で list[bytes] を受け付けます(Issue #1176)。Bytes はそもそもインデックスされないため、他の multi_valued オプションと異なりクエリ一致の意味論はなく、保存時の形と取り込み時の許容個数を変えるだけです。値は list[bytes] として読み戻されます(MIME は保持されません)。 |
add_geo_field(name, *, stored=True, indexed=True, multi_valued=False, doc_values=True) | 地理座標フィールド(緯度/経度)。multi_valued=True で (lat, lon) タプルのリストを受け付け(距離 / バウンディングボックスクエリはいずれかのポイントが条件を満たせばマッチ。値はタプルのリストとして読み戻されます)。doc_values は上記を参照。 |
add_geo3d_field(name, *, stored=True, indexed=True, multi_valued=False, doc_values=True) | 3D ECEF カルテシアン座標フィールド(x, y, z はメートル)。multi_valued=True で (x, y, z) タプルのリストを受け付け(距離 / バウンディングボックス / nearest クエリはいずれかのポイントが条件を満たせばマッチ。値はタプルのリストとして読み戻されます)。詳細は Geo3d の概念。doc_values は上記を参照。 |
add_datetime_field(name, *, stored=True, indexed=True, multi_valued=False, doc_values=True) | UTC 日時フィールド。multi_valued=True で datetime.datetime / str のリストを受け付け(範囲クエリはいずれかの時刻が条件を満たせばマッチ。値は UTC の RFC 3339 文字列の list[str] として読み戻されます)。doc_values は上記を参照。 |
add_hnsw_field(name, dimension, *, distance="cosine", m=16, ef_construction=200, quantizer=None, subvector_count=None, rerank_storage=None, embedder=None, pq_codebook_path=None, base_weight=1.0) | HNSW 近似最近傍ベクトルフィールド。base_weight は他の vector フィールドと同時に検索されたときの相対的なスコアリング優先度(Issue #1084)。ウェイトを参照。 |
add_flat_field(name, dimension, *, distance="cosine", embedder=None, base_weight=1.0) | Flat(総当たり)ベクトルフィールド。 |
add_ivf_field(name, dimension, *, distance="cosine", n_clusters=100, n_probe=1, embedder=None, base_weight=1.0) | IVF 近似最近傍ベクトルフィールド。 |
add_multi_vector_field(name, dimension, *, distance="cosine", storage="f32", embedder=None) | 文書ごとに可変個のトークンベクトル(ColBERT の埋め込みなど)を保持する MultiVector フィールド。late interaction による再採点だけが読み取ります(Issue #1351)。ANN 索引は持たず、ベクトル検索の対象にはなりません。distance は "cosine"(デフォルト。書き込み時にベクトルを L2 正規化)か "dot_product" のどちらかで、dimension は 0 より大きい必要があります。どちらもこのメソッドの呼び出し時に検査し、不正なら ValueError を送出します。storage は各トークンベクトルのディスク上の要素種別を指定します — "f32"(デフォルト、正確)、"f16"(2倍小さい)、"int8"(約4倍小さいが非可逆圧縮)のいずれかで、認識できない値も ValueError を送出します。embedder にはトークン単位のエンベダー("candle_colbert"。エンベダータイプを参照)の名前を指定し、テキストの値と再採点のテキストクエリを埋め込みます。文書はこのフィールドに float のリストのリスト(フィールド値の型マッピングを参照)か、embedder がある場合はテキストを与えます。トークンベクトルはベクトルストアにだけ保持されるため、get_documents や検索結果には含まれません。詳細は MultiVector フィールドを参照。 |
ベクトル量子化とリランクストレージ(HNSW フィールド):
quantizer—"scalar_8bit"(デフォルト、4 倍圧縮)または高圧縮率の"product_quantization"。Product quantization ではsubvector_count(dimensionを割り切れる値)が必須です。rerank_storage—"f32"を指定すると完全精度の*.hnsw.f32サイドカーを書き出し、厳密な Stage-2 リランクを有効化します。省略すると int8 のみのセグメントを維持します。pq_codebook_path— 共有 PQ codebook のストレージ相対ファイル名(Issue #631)。laurus train pq-codebookCLI コマンドで一度だけ学習します。quantizer="product_quantization"との組み合わせでのみ意味を持ち、以後の commit は segment ごとの k-means 再学習の代わりに学習済み codebook で encode します。省略すると segment ごとの学習を維持します。
上記のどの add_*_field メソッドも、name が _(_id を除く)で始まる場合は ValueError を送出し、フィールドを追加しない。from_toml / from_toml_file で読み込むスキーマは引き続きそのようなフィールドを受け付けるため、永続化済みのスキーマは読み込めるが、そのスキーマから新しい Index を作成すると ValueError になる。詳細はフィールド命名規則を参照。
その他のメソッド
| メソッド | 説明 |
|---|---|
add_embedder(name, config) | 名前付きエンベダー定義を登録します。config は "type" キーを持つ辞書で(下記参照)、スキーマ TOML の [embedders] テーブルと同じ規則で読み取ります。config が辞書でない場合、"type" が無いか未知の場合、必須キーが無い場合は ValueError(invalid embedder config: ...)を送出します。 |
add_analyzer(name, tokenizer, *, char_filters=None, token_filters=None) | カスタムアナライザ定義を登録します。tokenizer は必須、char_filters/token_filters は省略可能な辞書のリストです。各辞書はスキーマ TOML/JSON 形式と同じ {"type": "..."} 形式です(下記参照)。組み込みアナライザ用に予約された名前(standard、keyword、english、simple、noop)は ValueError になり、その名前を定義したスキーマ(from_toml で読み込んだものなど)から新しい Index を作る場合も同じです。正規表現の妥当性など意味的な検証は、このメソッド呼び出し時点ではなく Index 構築時に行われます。 |
analyzer_names() | add_analyzer で登録された、または TOML から読み込まれたカスタムアナライザの名前一覧を返します。 |
Schema.from_toml(toml_str) (静的メソッド) | laurus-cli create index --schema と同じ形式の TOML 文字列からスキーマを読み込みます。 |
Schema.from_toml_file(path) (静的メソッド) | TOML ファイルからスキーマを読み込みます(path は str または os.PathLike を受け付けます)。 |
to_toml() | このスキーマを laurus-cli と同じ形式の TOML 文字列にシリアライズします。 |
to_toml_file(path) | このスキーマを TOML ファイルに書き込みます。 |
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.14→3)。厳密さが必要なら"strict"を使用してください"ignore"— 未宣言フィールドを静かに破棄
詳細な挙動マトリクスは スキーマとフィールド を参照してください。
エンベダータイプ
各型の説明を含む正規のリファレンスは スキーマフォーマットリファレンス → エンベダー を参照してください。
"type" | 必須キー | Feature Flag |
|---|---|---|
"precomputed" | – | (常に利用可能) |
"candle_bert" | "model" | embeddings-candle |
"candle_clip" | "model" | embeddings-multimodal |
"openai" | "model" | embeddings-openai |
"candle_colbert" | "model" | embeddings-candle |
"candle_colbert" はトークン単位のエンベダーです。トークンごとに 1 本のベクトルを生成し、MultiVector フィールド(add_multi_vector_field)でだけ使えます。省略可能なキーとして "revision"(モデルリポジトリのブランチ・タグ・コミット)、"query_maxlen"、"doc_maxlen" も受け付けます。各キーのデフォルトは スキーマフォーマットリファレンス → エンベダー を参照してください。
schema = laurus.Schema()
schema.add_text_field("body")
schema.add_embedder("colbert", {"type": "candle_colbert", "model": "colbert-ir/colbertv2.0"})
schema.add_multi_vector_field("body_colbert", dimension=128, embedder="colbert")
アナライザコンポーネント
add_analyzer(name, tokenizer, *, char_filters=None, token_filters=None) と
[analyzers.<name>] TOML セクションで使用します。tokenizer は単一の辞書、
char_filters/token_filters は辞書のリストで、リストの順序どおりに適用されます。
各コンポーネントの説明を含む正規のリファレンスは スキーマフォーマットリファレンス → アナライザ を参照してください。
トークナイザ(tokenizer、必ず1つ):
"type" | 必須キー | 省略可能キー |
|---|---|---|
"whitespace" | – | – |
"unicode_word" | – | – |
"regex" | – | "pattern"(デフォルト \w+)、"gaps"(デフォルト false) |
"ngram" | "min_gram"、"max_gram" | – |
"lindera" | "mode"、"dict" | "user_dict" |
"whole" | – | – |
文字フィルタ(char_filters、トークン化前の生テキストに適用):
"type" | 必須キー | 省略可能キー |
|---|---|---|
"unicode_normalization" | "form"("nfc"/"nfd"/"nfkc"/"nfkd") | – |
"pattern_replace" | "pattern"、"replacement" | – |
"mapping" | "mapping"(文字列置換の辞書) | – |
"japanese_iteration_mark" | – | "kanji"(デフォルト true)、"kana"(デフォルト true) |
トークンフィルタ(token_filters、トークン化後のトークン列に適用):
"type" | 必須キー | 省略可能キー |
|---|---|---|
"lowercase" | – | – |
"stop" | – | "words"(デフォルト: 英語のストップワード) |
"stem" | – | "stem_type"("porter"/"simple"/"identity") |
"boost" | "boost" | – |
"limit" | "limit" | – |
"strip" | – | – |
"remove_empty" | – | – |
"flatten_graph" | – | – |
schema = laurus.Schema()
schema.add_analyzer(
"ja_ipadic",
{"type": "lindera", "mode": "normal", "dict": "/var/lib/lindera/ipadic"},
char_filters=[
{"type": "unicode_normalization", "form": "nfkc"},
{"type": "japanese_iteration_mark"},
],
token_filters=[{"type": "lowercase"}],
)
schema.add_text_field("title", analyzer="ja_ipadic")
距離メトリクス
| 値 | 説明 |
|---|---|
"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 を指定する
(または省略する)と開放されます。min と max はキーワード専用引数です。
数値型(整数または浮動小数点)は min/max の Python 型から推論されます。
DateTimeRangeQuery
DateTimeRangeQuery(
field: str, *,
min: str | datetime.datetime | datetime.date | None = None,
max: str | datetime.datetime | datetime.date | None = None,
)
[min, max] の範囲内(両端を含む)の DateTime 値を検索します。開いた境界には
None を指定する(または省略する)と開放されます。min と max はキーワード
専用引数です。境界は Query DSL が受け付ける任意の形式の str リテラル(RFC 3339 の
"2024-01-01T09:00:00+09:00"(UTC に正規化)、オフセットなしの
"YYYY-MM-DDTHH:MM:SS[.fff]"(UTC)、"YYYY-MM-DD"(その日の 0 時 UTC))、
または datetime.datetime / datetime.date(isoformat() で変換。naive な
datetime は UTC として扱われます)です。不正な境界は構築時に ValueError を
送出します。クエリオブジェクトを受け付ける場所(Index.search、BooleanQuery、
SearchRequest)ならどこでも使用できます。例:
index.search(laurus.DateTimeRangeQuery("created_at", min="2024-01-01", max="2024-12-31"))
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
オブジェクトのリストを受け取ります。slop と ordered はキーワード専用
引数です。
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,
highlight=None,
rescore: LateInteractionRescore | None = None,
) -> None: ...
| パラメータ | 説明 |
|---|---|
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)。 |
highlight | Index.search の highlight パラメータと同じリストまたは辞書の形式(Issue #1134)。ハイライトを参照。 |
rescore | このリクエストの上位の結果に適用する LateInteractionRescore(Issue #1351)。どのクエリの形を指定した場合でも適用されます。SearchRequest を渡すと Index.search の rescore キーワードは無視され、こちらが使われます。 |
LateInteractionRescore
検索の上位の結果を late interaction(ColBERT の MaxSim)で再採点します(Issue #1351)。Index.search または SearchRequest の rescore= に渡します。
class LateInteractionRescore:
def __init__(
self,
field: str,
query: str | list[list[float]],
*,
window_size: int | None = None,
) -> None: ...
@property
def window_size(self) -> int: ...
| パラメータ | 型 | デフォルト | 説明 |
|---|---|---|---|
field | str | – | MultiVector フィールド(add_multi_vector_field を参照)。 |
query | str | list[list[float]] | – | クエリのテキスト、またはクエリのトークンベクトル。テキストはフィールドのトークン単位のエンベダー("candle_colbert")が埋め込みます。トークンベクトルは、文書のトークンベクトルと同じモデルで計算したものを渡します。ベクトルの要素には整数も使えます。 |
window_size | int | None | None(100) | 再採点する 1 段目の上位の結果の件数。1..=10,000。キーワード専用引数です。 |
window_size プロパティは、再採点で使う window の件数を返します(省略時は 100)。
1 段目(lexical・vector・ハイブリッド)の上位 window_size 件を、フィールドに対する MaxSim の降順に並べ替えます。再採点した結果の score は MaxSim の値です。フィールドにトークンベクトルを持たない window 内の結果は 1 段目の順で続き、window の外の結果は 1 段目の順とスコアのまま最後に続きます。offset と limit は、この再採点済みの順位から切り出されます。並び順・類似度・コストの詳細は ベクトル検索 → Late Interaction による再採点 を参照してください。
エラー:
queryがstrでもリストのリストでもない場合、またはトークンベクトルにboolかstrが含まれる場合は、構築時にTypeErrorを送出します。- それ以外の不正な値は、
Index.searchが検索を始める前に検査し、ValueError(Invalid argument: rescore: ...)を送出します。対象は、fieldが MultiVector フィールドでない場合、クエリがフィールドの次元で有限値のベクトルを 1〜1,024 本持たない場合、window_sizeが1..=10,000の範囲外の場合、テキストのクエリが空かフィールドにトークン単位のエンベダーがない場合です。
import laurus
schema = laurus.Schema()
schema.add_text_field("title")
schema.add_multi_vector_field("tokens", dimension=2, distance="dot_product")
index = laurus.Index(schema=schema)
index.put_document("a", {"title": "rust", "tokens": [[0.1, 0.0]]})
index.put_document("b", {"title": "rust language", "tokens": [[0.9, 0.2]]})
index.commit()
rescore = laurus.LateInteractionRescore("tokens", [[1.0, 0.0], [0.0, 1.0]], window_size=50)
results = index.search("title:rust", rescore=rescore) # "b"(MaxSim 1.1)が "a"(0.1)より先
# 同じ再採点を SearchRequest で指定する(search_batch でも使える)
request = laurus.SearchRequest(query="title:rust", rescore=rescore, limit=5)
results = index.search(request)
フィールドに "candle_colbert" のエンベダーがあれば、文書はフィールドにテキストを与えられ、クエリもテキストで渡せます: laurus.LateInteractionRescore("body_colbert", "how do lifetimes work")。
SearchResult
Index.search() が返すクラスです。
class SearchResult:
id: str # 外部ドキュメント識別子
score: float # 関連性スコア
document: dict | None # 取得されたフィールド値。stored=False の場合は None
highlights: dict[str, list[str]] # 要求したフィールドごとのハイライト済みフラグメント
highlights は highlight で指定した各フィールドをハイライト済みフラグメント(最も良いものが先頭)にマッピングします。ハイライトされなかったフィールドは辞書に現れず、highlight を要求しなかった場合 highlights は {} になります。詳細はハイライトを参照してください。
rescore を指定した場合、再採点した結果の score は MaxSim の値になり、再採点の window の外の結果は 1 段目のスコアのままです。詳細は LateInteractionRescore を参照してください。
フュージョンアルゴリズム
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
token_type: str | None
start_offset と end_offset は、元テキストの UTF-8 バイトオフセットです。非 ASCII のテキストでは str の添字と一致しないため、エンコードしたバイト列を切り出します: text.encode()[tok.start_offset:tok.end_offset].decode()。
token_type は "alphanum"、"num"、"cjk"、"katakana"、"hiragana"、"hangul"、"punctuation"、"whitespace"、"synonym"、"email"、"url"、"other" のいずれか、または None です。SynonymGraphFilter.apply は各トークンのオフセットと種別を保ち、挿入する同義語には種別 "synonym" と、置き換える語のオフセットを付けます。
フィールド値の型マッピング
Python の値は自動的に Laurus の DataValue 型に変換されます:
| Python 型 | Laurus 型 | 備考 |
|---|---|---|
None | Null | |
bool | Bool | int より先にチェック |
int | Int64 | |
float | Float64 | |
str | Text | |
bytes | Bytes | |
list[bool] | BoolArray | 多値ブールフィールド。bool は int のサブクラスのため list[int] の規則より先にチェック。フィールドに multi_valued=True が必要 |
list[bytes] | BytesArray | 多値バイトフィールド(Issue #1176)。各要素は単一の bytes 値と同じ直接バイト経路をたどるため、MIME は常に None。Bytes はそもそもインデックスされないため、クエリ一致の意味論はなく保存時の形を変えるだけ。フィールドに multi_valued=True が必要 |
list[int] | Int64Array | 多値整数フィールド(bool のリストは代わりに BoolArray になり、多値の Float / Integer フィールドではコアが 0/1 に拡張する)。ベクトルフィールドではリストを f32 にキャスト。空リストは空の Int64Array |
list[float | int] | Float64Array | 多値浮動小数点フィールド(整数は拡張)。ベクトルフィールドではリストを f32 にキャスト |
list[list[float | int]] | VectorArray | MultiVector フィールドのトークンベクトル(Issue #1351)。内側のリストが 1 トークンに対応する(例: {"tokens": [[0.1, 0.2], [0.3, 0.4]]})。整数は拡張し、bool や str の要素は TypeError。ベクトルの本数と次元はドキュメントの書き込み時にフィールドと照合する(長さの揃わないリストなどは ValueError)。get_documents や検索結果には含まれない |
(lat, lon) タプル | Geo | 2 つの float 値 |
(x, y, z) タプル | Geo3d | 3 つの float 値(ECEF 直交座標系、メートル単位) |
list[(lat, lon)] | GeoArray | (lat, lon) タプルのリスト。フィールドに multi_valued=True が必要 |
list[(x, y, z)] | GeoEcefArray | (x, y, z) タプルのリスト。フィールドに multi_valued=True が必要 |
datetime.datetime | DateTime | isoformat() 経由で変換 |
list[datetime.datetime | str](datetime を 1 つ以上含む) | DateTimeArray | 各要素を単一の日時と同じ規則でパース(オフセット付き RFC 3339 / ISO 8601、または naive な YYYY-MM-DDTHH:MM:SS を UTC として扱う。isoformat() を持つオブジェクトは先に変換)。日時でない要素は ValueError。フィールドに multi_valued=True が必要 |
list[str] | DateTimeArray または TextArray | 全要素が日時としてパースできれば(上記と同じ形式)DateTimeArray、そうでなければ TextArray(Issue #1175。list[str] として読み戻される)。フィールドに multi_valued=True が必要 |
開発環境のセットアップ
このページでは 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つのテストスイートを順番に実行します:
- Rust ユニットテスト —
cargo test -p laurus-python - 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 を作成して maturin と pytest をインストール |
make venv-clean | .venv を削除 |
make build-laurus-python | maturin build でリリースホイールをビルド |
make test-laurus-python | Rust ユニットテスト + Python pytest |
make lint-laurus-python | -D warnings 付きで Clippy を実行 |
make format-laurus-python | cargo fmt -p laurus-python |
make clean | venv と 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 メソッド(search、commit、putDocument 等)は
async で Promise を返します。napi-rs 内蔵の tokio
ランタイムで実行され、Node.js のイベントループをブロック
しません。Schema 構築、Query 作成、stats() は I/O を
伴わないため同期メソッドです。
注意: Python バインディング(
laurus-python)では、 同じ Rust エンジンのメソッドを同期関数として公開 しています。Python の GIL(Global Interpreter Lock)の 制約により非同期 API が煩雑になるためです(各呼び出しは Rust 側の処理中 GIL を解放するため、シングルスレッド 動作にはなりません。詳細は Python バインディングの ドキュメントを参照)。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}`);
}
セクション
- インストール – パッケージのインストール方法
- クイックスタート – サンプルを使ったハンズオン入門
- API リファレンス – クラスとメソッドの完全なリファレンス
- 開発 – ソースからのビルド、テスト、プロジェクト構成
インストール
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.jsonのengines.nodeと一致) - コンパイル済みネイティブアドオン以外のランタイム依存なし
クイックスタート
1. インデックスの作成
import { Index, Schema } from "laurus-nodejs";
// インメモリインデックス(揮発性、プロトタイピング向け)
const index = await Index.create();
// ファイルベースインデックス(永続化)
// `./myindex/schema.toml` と `./myindex/store/` を書き込む -- これは
// `laurus-cli create index --schema` と同じレイアウトなので、このディレクトリは
// CLI からも開ける(逆も同様)。
const schema = new Schema();
schema.addTextField("name");
schema.addTextField("description");
const persistentIndex = await Index.create("./myindex", schema);
// 後で再オープンする際はパスだけで済む -- schema を再度渡すとエラーになる
// (スキーマは既に永続化されているため)。
const reopened = await Index.create("./myindex");
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. Late interaction による再採点
MultiVector フィールドは文書ごとに複数のトークンベクトル(たとえば ColBERT の
トークン埋め込み)を保持します。rescore オブジェクトを渡すと、どの検索でも
上位の結果をそのフィールドに対する late interaction(MaxSim)で並べ替えます。
import { Index, Schema } from "laurus-nodejs";
const schema = new Schema();
schema.addTextField("title");
schema.addMultiVectorField("tokens", 2, "dot_product");
const index = await Index.create(null, schema);
await index.putDocument("a", {
title: "rust",
tokens: [[0.1, 0.0]],
});
await index.putDocument("b", {
title: "rust language",
tokens: [[0.9, 0.2], [0.0, 0.5]],
});
await index.commit();
// Lexical 検索の上位を MaxSim で並べ替える:
// "b"(スコア 1.4)が "a"(スコア 0.1)より上位になる。
const results = await index.search("title:rust", 10, 0, undefined, {
field: "tokens",
vectors: [[1, 0], [0, 1]],
});
トークンベクトルは保存されないため、getDocuments や検索結果には含まれません。
addEmbedder で candle_colbert の Embedder を登録してフィールドの第 4 引数に
指定すると、文書はフィールドにテキストを渡せ、再採点も vectors の代わりに
text を受け付けます。詳細は
Late interaction による再採点(Rescore)
を参照してください。
7. 更新と削除
// 更新: 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();
8. スキーマ管理
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);
schema.addMultiVectorField("tokens", 128);
9. インデックス統計
const stats = index.stats();
console.log(stats.documentCount);
console.log(stats.vectorFields);
API リファレンス
Index
主要なエントリポイント。Laurus 検索エンジンをラップします。
class Index {
static create(
path?: string | null,
schema?: Schema,
walSyncPolicy?: WalSyncPolicy,
commitPolicy?: CommitPolicy,
): Promise<Index>;
}
ファクトリメソッド
| パラメータ | 型 | デフォルト | 説明 |
|---|---|---|---|
path | string | null | null | 永続化ストレージのディレクトリ。null でインメモリ。指定した場合、そのディレクトリは laurus-cli create index/--index-dir と同じ <path>/schema.toml + <path>/store/ というレイアウトに従うため、ここで作成したインデックスは CLI からも開けます(逆も同様)。詳細は下記を参照。 |
schema | Schema | 空 | スキーマ定義。新規にファイルベース(またはインメモリ)インデックスを作成する場合のみ意味を持ちます。既存のファイルベースインデックスを再オープンする場合は省略する必要があり、永続化済みのスキーマが代わりに読み込まれます。新規インデックスで省略した場合は空のスキーマが使用されます。 |
walSyncPolicy | WalSyncPolicy | レコードごと | WAL の永続性ポリシー。省略するとデフォルトのレコードごとの fsync を使用します。WAL 同期ポリシー / 永続性を参照。 |
commitPolicy | CommitPolicy | 手動 | 自動コミットポリシー。省略するとデフォルトの手動ポリシー(呼び出し側がすべての commit() を駆動)を使用します。コミットポリシー / 自動コミットを参照。 |
ファイルベースインデックスの作成 vs 再オープン(path を指定した場合): <path>/schema.toml がまだ存在しない場合、この呼び出しは新規インデックスを作成し、schema(省略時は空のスキーマ)をそこに永続化します。<path>/schema.toml が既に存在する場合、この呼び出しは既存インデックスを再オープンします – schema は省略しなければならず、指定すると例外が投げられます(どちらのスキーマを優先すべきか曖昧になるため)。path がこの規約導入以前のレイアウト(schema.toml が無く、セグメントファイルが path 直下にある)のインデックスを含んでいる場合も例外になります。
メソッド
| メソッド | 説明 |
|---|---|
putDocument(id, doc) | ドキュメントを上書き保存。 |
addDocument(id, doc) | 既存バージョンを残してチャンクを追記。 |
putDocuments(docs) | バッチ upsert。docs は Array<[id, doc]> で、バッチごとに WAL fsync 1 回で順に適用(重複 ID はデデュープ、最後が勝ち)。最初の不正エントリで fail-fast し、適用済みの prefix はロールバックされません。 |
addDocuments(docs) | バッチチャンク追記。putDocuments と同様ですが、繰り返した ID は別バージョンとして蓄積されます。 |
getDocuments(id) | 指定 ID の全バージョンを取得。 |
deleteDocuments(id) | 指定 ID の全バージョンを削除。 |
commit() | 書き込みをフラッシュし変更を検索可能にする。 |
flushWal() | WAL の永続性バリアを強制する。WAL 同期ポリシー / 永続性を参照。 |
search(query, limit?, offset?, highlight?, rescore?) | DSL 文字列で検索。rescore は上位の結果を late interaction で並べ替えます。Late interaction による再採点(Rescore)を参照。 |
searchTerm(field, term, limit?, offset?, highlight?) | 完全一致 Term 検索。 |
searchVector(field, vector, limit?, offset?) | 事前計算ベクトルで検索。 |
searchVectorText(field, text, limit?, offset?) | テキストを自動埋め込みして検索。 |
searchWithRequest(request) | SearchRequest で検索。 |
searchBatch(queries, limit?, offset?, highlight?) | 複数の DSL 文字列クエリを並列実行します。results[i] は queries[i] に対応。戻り値は Promise<Array<Array<JsSearchResult>>>。空入力の場合は [] を返します。highlight はすべてのクエリに同一に適用されます。 |
stats() | インデックス統計(documentCount、vectorFields)を返す。 |
ドキュメント操作と検索メソッドはすべて非同期で Promise を返します。
stats() は同期メソッドです。
stats() は次の形のオブジェクトを返します:
interface IndexStats {
documentCount: number;
vectorFields: Record<string, { count: number; dimension: number }>;
}
WAL 同期ポリシー / 永続性
永続インデックスでは、すべての書き込みが先行書き込みログ(WAL)に追記されます。
デフォルトでは WAL はすべてのレコードごとに fsync されるため、Promise が
解決した時点で各書き込みは完全に永続化されます。Index.create はオプションの
walSyncPolicy を受け付け、永続性を一部犠牲にして書き込みスループットを
向上させることができます。また flushWal() で必要なときに永続性バリアを
強制できます。
class WalSyncPolicy {
static perRecord(): WalSyncPolicy;
static group(
maxRecords?: number,
maxBytes?: number,
maxIntervalMs?: number,
): WalSyncPolicy;
}
| コンストラクタ | 説明 |
|---|---|
WalSyncPolicy.perRecord() | デフォルト。WAL レコードごとに fsync し、書き込みごとに完全に永続化します。 |
WalSyncPolicy.group(...) | グループコミット。複数の書き込みにまたがって fsync をまとめます。 |
group(...) のパラメータ(引数を省略するとそのデフォルトを維持):
| パラメータ | デフォルト | 説明 |
|---|---|---|
maxRecords | 1024 | この件数のレコードが蓄積されたらフラッシュします。 |
maxBytes | 1048576(1 MiB) | この量の未同期バイトが蓄積されたらフラッシュします。 |
maxIntervalMs | なし | 任意の定期フラッシュタイマー(ミリ秒)。省略するとタイマー無効。 |
グループコミットでは、maxRecords または maxBytes のいずれかに達した
時点で WAL がフラッシュされ、commit() 時にも必ずフラッシュされます。
クラッシュ時には最後の未同期バッチまでを失う可能性があります — これは
SQLite の synchronous = NORMAL と同じトレードオフです。完全な commit() を
行わずにこれまで書き込んだ内容をディスクへ強制するには flushWal() を
呼び出します。
| メソッド | 説明 |
|---|---|
flushWal() | 今すぐ WAL の永続性バリアを強制します。Promise<void> を返します。 |
import { Index, WalSyncPolicy } from "laurus-nodejs";
// 1 秒の定期フラッシュタイマー付きでグループコミットを有効化します。
const policy = WalSyncPolicy.group(4096, undefined, 1000);
const index = await Index.create("./myindex", schema, policy);
for (let i = 0; i < 10000; i++) {
await index.putDocument(`doc${i}`, { title: `Document ${i}` });
}
// まだコミットせずに永続性バリアを強制します。
await index.flushWal();
await index.commit(); // WAL もフラッシュされます
walSyncPolicy を省略する(または WalSyncPolicy.perRecord() を渡す)と、
デフォルトの完全に永続的な動作が維持されます。
コミットポリシー / 自動コミット
デフォルトでは呼び出し側がすべての commit() を駆動するため、保留中の変更は
明示的に呼び出したときにのみ検索可能になります。Index.create はオプションの
commitPolicy を受け付け、代わりに取り込み駆動のタイミングでエンジンに自動
コミットさせることができます。これにより明示的な commit() なしで書き込みが
実体化されます。
class CommitPolicy {
static manual(): CommitPolicy;
static everyDocs(n: number): CommitPolicy;
static intervalMs(ms: number): CommitPolicy;
}
| コンストラクタ | 説明 |
|---|---|
CommitPolicy.manual() | デフォルト。自動コミットなし。呼び出し側がすべての commit() を駆動します。 |
CommitPolicy.everyDocs(n) | 適用されたドキュメント n 件ごとに自動コミットします。 |
CommitPolicy.intervalMs(ms) | バックグラウンドタイマーにより少なくとも ms ミリ秒ごとに自動コミットします(デフォルト: なし)。ネイティブ専用。wasm では no-op(バックグラウンドスレッドがないため)。 |
everyDocs(n) では、エンジンは適用されたドキュメント n 件ごとにコミットし、
その件数は単発・バッチ両方の取り込みにまたがって数えられます — 1 つのバッチ
内部でもドキュメント n 件ごとにコミットされます。everyDocs(0) は有効で
自動コミットを無効化し、CommitPolicy.manual() と等価です。
intervalMs(ms) は everyDocs(n) の時間ベース版です。バックグラウンドタイマー
が少なくとも ms ミリ秒ごとに自動コミットするため、取り込みがアイドル状態でも
末尾の部分的なバッチが実体化されます。これはネイティブ専用です — wasm には
バックグラウンドスレッドがないため、エンジンは intervalMs を no-op として扱い
ます(ファクトリは値を構築しますが、WebAssembly ではタイマーによるコミットは
発生しません)。
commitPolicy は walSyncPolicy と直交します。walSyncPolicy は WAL の
fsync 永続性を制御するのに対し、commitPolicy は保留中の変更を検索可能な
コミットへいつ実体化するかを制御します。両者は自由に組み合わせられます。
import { Index, CommitPolicy } from "laurus-nodejs";
// 適用されたドキュメント 1000 件ごとに自動コミットします。
const index = await Index.create(
null,
schema,
undefined,
CommitPolicy.everyDocs(1000),
);
for (let i = 0; i < 10000; i++) {
await index.putDocument(`doc${i}`, { title: `Document ${i}` });
}
// エンジンはすでに 10 回コミット済みです。明示的な commit() は不要です。
commitPolicy を省略する(または CommitPolicy.manual() を渡す)と、
デフォルトの呼び出し側駆動のコミット動作が維持されます。
Schema
Index のフィールドとインデックス型を定義します。
class Schema {
constructor();
}
フィールドメソッド
| メソッド | 説明 |
|---|---|
addTextField(name, stored?, indexed?, termVectors?, docValues?, analyzer?, multiValued?, positionIncrementGap?) | 全文検索フィールド(転置インデックス、BM25)。docValues は値を DocValues(ソート・ファセット・集計が読み取る列指向ストア)にもコピーするかどうかを制御します(Issue #1047、デフォルト true)。stored も true の場合のみ有効です。multiValued: true で文字列の配列を受け付けます(Issue #1175): term クエリはいずれかの要素がタームを含めばマッチし、フレーズクエリは slop が positionIncrementGap(デフォルト 100。0 にすると要素を連結したものとして付番)に達しない限り 2 つの要素をまたぎません。値は文字列の配列として読み戻されます。analyzer にはパラメータ不要の組込名("standard" / "english" / "keyword" / "simple" / "noop"、または addAnalyzer で登録したカスタム名)を指定します。Lindera 辞書パスが必要な Japanese プリセットを使う場合は、lindera tokenizer を含むカスタム analyzer を登録して、その名前を参照してください。 |
addIntegerField(name, stored?, indexed?, multiValued?, docValues?) | 64 ビット整数フィールド。multiValued: true で整数配列を受け付け(範囲クエリは “any match”)。docValues は上記を参照。 |
addFloatField(name, stored?, indexed?, multiValued?, docValues?) | 64 ビット浮動小数点フィールド。multiValued: true で浮動小数点配列を受け付け(範囲クエリは “any match”)。docValues は上記を参照。 |
addBooleanField(name, stored?, indexed?, multiValued?, docValues?) | 真偽値フィールド。multiValued: true で真偽値の配列を受け付け(flags:true のような term クエリはいずれかの要素が値と等しければマッチ。値は真偽値の配列として読み戻されます)。docValues は上記を参照。 |
addBytesField(name, stored?, multiValued?) | バイナリデータフィールド。docValues オプションはありません —— Bytes の値は設定にかかわらず DocValues に一切書き込まれないためです。multiValued: true を渡すと base64 文字列の配列を受け付け(Issue #1176)、単一の base64 文字列と同じ方法でスキーマ対応の変換が要素ごとにデコードします。Bytes はそもそもインデックスされないため、他の multiValued オプションと異なりクエリ一致の意味論はなく、保存時の形と取り込み時の許容個数を変えるだけです。値は MIME を落としたバイト整数配列の配列として読み戻され、スカラーフィールドと同じ入出力の非対称性を持ちます。 |
addGeoField(name, stored?, indexed?, multiValued?, docValues?) | 地理座標フィールド。multiValued: true で { lat, lon } オブジェクトの配列を受け付け(距離 / バウンディングボックスクエリはいずれかのポイントが条件を満たせばマッチ)。docValues は上記を参照。 |
addGeo3dField(name, stored?, indexed?, multiValued?, docValues?) | 3D ECEF カルテシアン座標フィールド(x, y, z はメートル)。multiValued: true で { x, y, z } オブジェクトの配列を受け付け(距離 / バウンディングボックス / nearest クエリはいずれかのポイントが条件を満たせばマッチ)。詳細は Geo3d の概念。docValues は上記を参照。 |
addDatetimeField(name, stored?, indexed?, multiValued?, docValues?) | UTC 日時フィールド。multiValued: true で RFC 3339 文字列の配列を受け付け(範囲クエリはいずれかの時刻が条件を満たせばマッチ。値は UTC に正規化した RFC 3339 文字列の配列として読み戻されます)。docValues は上記を参照。 |
addHnswField(name, dimension, distance?, m?, efConstruction?, defaultEfSearch?, embedder?, quantizer?, subvectorCount?, rerankStorage?, pqCodebookPath?, baseWeight?) | HNSW ベクトルフィールド。baseWeight は他の vector フィールドと同時に検索されたときの相対的なスコアリング優先度(Issue #1084)。ウェイトを参照。 |
addFlatField(name, dimension, distance?, embedder?, baseWeight?) | Flat(全探索)ベクトルフィールド。 |
addIvfField(name, dimension, distance?, nClusters?, nProbe?, embedder?, baseWeight?) | IVF ベクトルフィールド。 |
addMultiVectorField(name, dimension, distance?, embedder?, storage?) | 文書ごとに可変個のトークンベクトルを保持する MultiVector フィールド。late interaction による再採点に使います(Issue #1351)。MultiVector フィールドを参照。dimension は各トークンベクトルの次元で、0 より大きくなければなりません。distance は "cosine"(デフォルト)または "dot_product" です。どちらもフィールド追加時に検証され、不正なら code InvalidArg の例外を投げます。storage は各トークンベクトルのディスク上の要素種別を指定します(Issue #1346)— "f32"(デフォルト、正確)、"f16"(2倍小さい)、"int8"(約4倍小さい)のいずれかで、認識できない値も InvalidArg の例外を投げます。embedder にはトークン単位の Embedder("candle_colbert")の名前を指定し、テキスト値と再採点のクエリテキストを埋め込みます。このフィールド単体では検索できず、トークンベクトルは保存されないため、getDocuments や検索結果には含まれません。 |
addEmbedder(name, config) | 名前付き Embedder を登録。Embedderを参照。 |
addAnalyzer(name, tokenizer, charFilters?, tokenFilters?) | カスタムアナライザ定義を登録します。tokenizer は必須、charFilters/tokenFilters は省略可能なオブジェクトの配列です。各オブジェクトはスキーマ TOML/JSON 形式と同じ { type: "...", ... } 形式で、キーは snake_case のままです(下記参照)。組み込みアナライザ用に予約された名前(standard、keyword、english、simple、noop)は例外になり、その名前を定義したスキーマ(fromToml で読み込んだものなど)で新しいインデックスを Index.create する場合も同じです。正規表現の妥当性など意味的な検証は、このメソッド呼び出し時点ではなく Index 構築時に行われます。 |
analyzerNames() | addAnalyzer で登録された、または TOML から読み込まれたカスタムアナライザの名前一覧を返します。 |
Schema.fromToml(tomlStr) (静的メソッド) | laurus-cli create index --schema と同じ形式の TOML 文字列からスキーマを読み込みます。 |
Schema.fromTomlFile(path) (静的メソッド) | TOML ファイルからスキーマを読み込みます。 |
toToml() | このスキーマを laurus-cli と同じ形式の TOML 文字列にシリアライズします。 |
toTomlFile(path) | このスキーマを TOML ファイルに書き込みます。 |
setDefaultFields(fields) | デフォルト検索フィールドを設定。 |
setDynamicFieldPolicy(policy) | 未宣言フィールドの扱いを設定。policy は "strict" / "dynamic"(デフォルト)/ "ignore"。詳細は下記を参照。 |
dynamicFieldPolicy() | 現在のポリシーを小文字の文字列で返す。 |
fieldNames() | 全フィールド名を返す。 |
toString() | スキーマの文字列表現("Schema(fields=[...])" 形式)を返す。 |
ベクトル量子化とリランクストレージ(HNSW フィールド):
quantizer—"scalar_8bit"(デフォルト、4 倍圧縮)または高圧縮率の"product_quantization"。Product quantization ではsubvectorCount(dimensionを割り切れる値)が必須です。rerankStorage—"f32"を指定すると完全精度の*.hnsw.f32サイドカーを書き出し、厳密な Stage-2 リランクを有効化します。省略すると int8 のみのセグメントを維持します。pqCodebookPath— 共有 PQ codebook のストレージ相対ファイル名(Issue #631)。laurus train pq-codebookCLI コマンドで一度だけ学習します。quantizer: "product_quantization"との組み合わせでのみ意味を持ち、以後の commit は segment ごとの k-means 再学習の代わりに学習済み codebook で encode します。省略すると segment ごとの学習を維持します。
上記のどの add*Field メソッドも、name が _(_id を除く)で始まる場合は例外を投げ、フィールドを追加しない。fromToml / fromTomlFile で読み込んだスキーマはそのようなフィールドを引き続き受け付けるため、永続化済みのスキーマも読み込めるが、そこから新しい Index を作成すると例外を投げる。詳細はフィールド命名規則を参照。
Dynamic field policy(動的フィールドポリシー)
ドキュメントに含まれるがスキーマに宣言されていないフィールドの扱いを制御します:
"strict"— ドキュメントを拒否"dynamic"(デフォルト)— 各未宣言フィールドの型を推論してスキーマに追加。警告: integer フィールドに入ってきた float 値は静かに切り捨てられます(3.14→3)。厳密さが必要なら"strict"を使用してください"ignore"— 未宣言フィールドを静かに破棄
詳細な挙動マトリクスは スキーマとフィールド を参照してください。
アナライザコンポーネント
addAnalyzer(name, tokenizer, charFilters?, tokenFilters?) と
[analyzers.<name>] TOML セクションで使用します。tokenizer は単一のオブジェクト、
charFilters/tokenFilters はオブジェクトの配列で、配列の順序どおりに適用されます。
各コンポーネントの説明を含む正規のリファレンスは スキーマフォーマットリファレンス → アナライザ を参照してください。
トークナイザ(tokenizer、必ず1つ):
type | 必須キー | 省略可能キー |
|---|---|---|
"whitespace" | – | – |
"unicode_word" | – | – |
"regex" | – | pattern(デフォルト \w+)、gaps(デフォルト false) |
"ngram" | min_gram、max_gram | – |
"lindera" | mode、dict | user_dict |
"whole" | – | – |
文字フィルタ(charFilters、トークン化前の生テキストに適用):
type | 必須キー | 省略可能キー |
|---|---|---|
"unicode_normalization" | form("nfc"/"nfd"/"nfkc"/"nfkd") | – |
"pattern_replace" | pattern、replacement | – |
"mapping" | mapping(置換用のオブジェクト) | – |
"japanese_iteration_mark" | – | kanji(デフォルト true)、kana(デフォルト true) |
トークンフィルタ(tokenFilters、トークン化後のトークン列に適用):
type | 必須キー | 省略可能キー |
|---|---|---|
"lowercase" | – | – |
"stop" | – | words(デフォルト: 英語のストップワード) |
"stem" | – | stem_type("porter"/"simple"/"identity") |
"boost" | boost | – |
"limit" | limit | – |
"strip" | – | – |
"remove_empty" | – | – |
"flatten_graph" | – | – |
const schema = new Schema();
schema.addAnalyzer(
"ja_ipadic",
{ type: "lindera", mode: "normal", dict: "/var/lib/lindera/ipadic" },
[
{ type: "unicode_normalization", form: "nfkc" },
{ type: "japanese_iteration_mark" },
],
[{ type: "lowercase" }],
);
schema.addTextField("title", true, true, true, true, "ja_ipadic");
Embedder
addEmbedder(name, config) と [embedders.<name>] TOML セクションで使用します。
config は type キーでバックエンドを選ぶオブジェクトです。アナライザ
コンポーネントと同じく、キーはスキーマ TOML/JSON 形式に合わせて snake_case の
ままです。
各タイプの正規の説明は スキーマフォーマットリファレンス → エンベダー を参照してください。
type | 必須キー | 省略可能キー | フィーチャーフラグ |
|---|---|---|---|
"precomputed" | – | – | (常に利用可能) |
"candle_bert" | model | – | embeddings-candle |
"candle_clip" | model | – | embeddings-multimodal |
"openai" | model | – | embeddings-openai |
"candle_colbert" | model | revision、query_maxlen、doc_maxlen | embeddings-candle |
"candle_colbert" は BERT ベースの ColBERT チェックポイント("colbert-ir/colbertv2.0"
など)を実行してトークンごとに 1 本のベクトルを出力するため、MultiVector
フィールド(addMultiVectorField)でしか使えません。逆に MultiVector フィールドが
受け付けるのは "candle_colbert" か "precomputed" だけで、それ以外の組み合わせでは
Index.create が例外を投げます。revision はモデルリポジトリのブランチ・タグ・
コミットを固定します(再埋め込みで同じベクトルを再現できるよう、コミットを
固定してください)。query_maxlen と doc_maxlen はチェックポイントのクエリ長と
文書長(トークン数)を上書きします。
addEmbedder は、config がオブジェクトでない場合は embedder config must be an object、
type が無いか未知の値である場合や必須キーが無い場合は invalid embedder config: ...
の例外を投げます。バインディングのビルドで有効にしていないフィーチャーフラグの
タイプも登録はできますが、Index.create が例外を投げます。
const schema = new Schema();
schema.addEmbedder("colbert", {
type: "candle_colbert",
model: "answerdotai/answerai-colbert-small-v1",
revision: "934fa8bb4ce2284f4c2baa232d81aca4d076fa5e",
});
schema.addTextField("body");
schema.addMultiVectorField("body_colbert", 96, "cosine", "colbert");
距離指標
| 値 | 説明 |
|---|---|
"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")。それ以外の値は例外をスローします。
DateTimeRangeQuery
new DateTimeRangeQuery(
field: string,
min?: string | null,
max?: string | null,
)
[min, max] 範囲(両端を含む)の DateTime 値にマッチします。null(または省略)で
開放端。境界は Query DSL が受け付ける任意の形式の文字列リテラルです: RFC 3339
("2024-01-01T09:00:00+09:00"、UTC に正規化)、オフセットなしの
"YYYY-MM-DDTHH:MM:SS[.fff]"(UTC)、または "YYYY-MM-DD"(その日の 0 時 UTC)。
Date は date.toISOString() で渡します。不正な境界は構築時に Error を
スローします。BooleanQuery.mustDateTimeRange / shouldDateTimeRange /
mustNotDateTimeRange および SearchRequest.setLexicalDateTimeRange /
setFilterDateTimeRange で句として設定します。
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, DateTimeRange,
// 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) ではなく 39 個の per-type メソッド
(13 クエリタイプ × 3 極性)を公開しています。これは js_name を上書きした
クラスに対する napi-derive の Either<&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
highlight?: HighlightOptions;
rescore?: RescoreOptions;
}
class SearchRequest {
constructor(options?: SearchRequestOptions);
}
コンストラクタにはプリミティブな options を渡し、多態クエリ句は下記の
per-type セッターで設定します。BooleanQuery 同様、napi-derive の
Either<&T, ...> バリデーション制限を回避するため per-type 化されています。
highlight と rescore はプレーンなデータ(クラスインスタンスのユニオンではない)
なので、セッターを介さず SearchRequestOptions に直接持たせています。
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, DateTimeRange, GeoDistance, GeoBoundingBox, Geo3dDistance, Geo3dBoundingBox, Geo3dNearest, Boolean, Span } の各クエリタイプとして、以下のメソッドが公開されています:
| メソッド | 説明 |
|---|---|
setLexicalX(query: X) | 明示的なハイブリッドリクエストの Lexical コンポーネントを設定。 |
setFilterX(query: X) | スコアリング後のフィルタコンポーネントを設定。 |
合計 26 個の per-type セッター(13 lexical + 13 filter)に加え、上記の DSL /
ベクトル / フュージョンセッターが利用可能です。例:
setLexicalNumericRange(q) / setFilterNumericRange(q)、
setLexicalDateTimeRange(q) / setFilterDateTimeRange(q)。
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);
ハイライト
search、searchTerm、searchBatch、SearchRequestOptions は省略可能な highlight オブジェクトを受け付けます(Issue #1134)。
interface HighlightOptions {
fields: string[];
fragmentSize?: number; // デフォルト 150
maxFragments?: number; // デフォルト 5
tag?: string; // デフォルト "mark"
cssClass?: string;
requireFieldMatch?: boolean; // デフォルト true
}
必須なのは fields のみです。ハイライトは同じ呼び出しに渡したクエリに従い、stored: true のテキストフィールドのみハイライト可能です — 保存されていない、テキスト型でない、またはマッチしなかったフィールドは結果の highlights オブジェクトに現れません。
const results = await index.search("body:rust", 10, 0, { fields: ["body"], tag: "em" });
// results[0].highlights => { body: ["<em>Rust</em> is a systems programming language."] }
Late interaction による再採点(Rescore)
search(末尾の rescore 引数)と SearchRequestOptions は省略可能な rescore
オブジェクトを受け付けます(Issue #1351)。lexical・vector・ハイブリッドのどの
第 1 段階の検索でも、上位の結果を
MultiVector フィールド
に対する ColBERT 型の late interaction(MaxSim)で並べ替えます。仕組みは
ベクトル検索 → Late Interaction による再採点(Rescore)
を参照してください。
// index.d.ts では JsRescoreOptions としてエクスポートされます。
interface RescoreOptions {
field: string; // MultiVector フィールド
vectors?: number[][]; // クエリのトークンベクトル
text?: string; // フィールドの Embedder で埋め込むクエリテキスト
windowSize?: number; // デフォルト 100、最大 10,000
}
| フィールド | 型 | デフォルト | 説明 |
|---|---|---|---|
field | string | – | 採点に使う MultiVector フィールド。 |
vectors | number[][] | – | クエリのトークンベクトル。1〜1,024 本で、どれもフィールドの次元を持ち、値は有限でなければなりません。文書のトークンベクトルを作ったのと同じモデルで計算してください。 |
text | string | – | クエリテキスト。フィールドのトークン単位の Embedder("candle_colbert")がクエリとして埋め込みます。空白のみは不可。 |
windowSize | number | 100 | 再採点する第 1 段階の上位件数。1〜10,000。 |
vectors と text はちょうど一方だけを指定します。そうでない場合、検索は
code InvalidArg、メッセージ rescore needs exactly one of vectors or text
で reject されます。それ以外の値はエンジンが検索時に検証し、field が未知または
MultiVector フィールドでない、text が空白のみまたはフィールドにトークン単位の
Embedder が無い、クエリのベクトル数が範囲外または次元が異なる、windowSize が
範囲外、のいずれかの場合に code InvalidArg、rescore: ... を含むメッセージで
reject されます。
並び順とスコア:
- 第 1 段階の上位
windowSize件を MaxSim の高い順に並べ替え、再採点された結果のscoreはその MaxSim になります。 - ウィンドウ内でフィールドにトークンベクトルを持たない結果が第 1 段階の順で続き、 最後にウィンドウ外の結果がそのまま続きます。どちらも第 1 段階のスコアを保ち、 そのスコアは MaxSim と比較できません。
searchTerm、searchVector、searchVectorText、searchBatch は rescore
引数を取りません。代わりに SearchRequest を組み立ててください。SearchRequest
はハイブリッドを含むどの第 1 段階でも再採点できます。
// DSL 検索を、クエリのトークンベクトルで再採点します。
const results = await index.search("title:rust", 10, 0, undefined, {
field: "tokens",
vectors: [[1, 0], [0, 1]],
});
// ハイブリッド検索を、クエリテキストで再採点します
// (フィールドに "candle_colbert" の Embedder が必要)。
const req = new SearchRequest({
limit: 10,
rescore: { field: "body_colbert", text: "how do lifetimes work", windowSize: 50 },
});
req.setLexicalTerm(new TermQuery("body", "lifetimes"));
req.setVectorQuery(new VectorQuery("body_vec", queryEmbedding));
req.setRrfFusion(new RRF(60.0));
const reranked = await index.searchWithRequest(req);
SearchResult
検索メソッドが配列として返す結果。
interface SearchResult {
id: string; // 外部ドキュメント識別子
score: number; // 関連度スコア
document: object | null; // 取得フィールド、stored=false の場合は null
highlights: Record<string, string[]>; // 要求したフィールドごとのハイライト済みフラグメント
}
highlights は highlight.fields で指定した各フィールドをハイライト済みフラグメント(最も良いものが先頭)にマッピングします。ハイライトされなかったフィールドはオブジェクトに現れず、highlight を要求しなかった場合 highlights は {} になります。詳細はハイライトを参照してください。
融合アルゴリズム
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;
tokenType?: string;
}
startOffset と endOffset は、元テキストの UTF-8 バイトオフセットです。非 ASCII のテキストでは JavaScript の文字列(UTF-16)の添字と一致しないため、エンコードしたバイト列を切り出します: new TextDecoder().decode(new TextEncoder().encode(text).slice(tok.startOffset, tok.endOffset))。
tokenType は "alphanum"、"num"、"cjk"、"katakana"、"hiragana"、"hangul"、"punctuation"、"whitespace"、"synonym"、"email"、"url"、"other" のいずれかです。SynonymGraphFilter.apply は各トークンのオフセットと種別を保ち、挿入する同義語には種別 "synonym" と、置き換える語のオフセットを付けます。これ以外の tokenType を渡すと例外を投げます。
手で組み立てたトークンでは tokenType を省略できます。その場合、複数語の同義語は語のオフセットが連続しているときだけ一致するため、空白で区切られた語には tokenType: "alphanum" を付けてください。
フィールド値の型
JavaScript の値は自動的に Laurus の DataValue 型に変換されます:
| JavaScript 型 | Laurus 型 | 備考 |
|---|---|---|
null | Null | |
boolean | Bool | |
number(整数) | Int64 | |
number(浮動小数点) | Float64 | |
string | Text | ISO 8601 文字列は DateTime になる |
number[](すべて整数) | Int64Array | 多値整数フィールド。ベクトルフィールドでは配列を f32 にキャスト。空配列は空の Int64Array |
number[] | Float64Array | 多値浮動小数点フィールド(整数は拡張)。ベクトルフィールドでは配列を f32 にキャスト |
{ lat, lon } | Geo | 2 つの number 値 |
{ x, y, z } | GeoEcef | 3 つの number 値(メートル単位、3D ECEF 直交座標) |
{ lat, lon }[] | GeoArray | { lat, lon } オブジェクトの配列。フィールドに multiValued: true が必要 |
{ x, y, z }[] | GeoEcefArray | { x, y, z } オブジェクトの配列。フィールドに multiValued: true が必要 |
number[][] | VectorArray | MultiVector フィールド(addMultiVectorField)のトークンベクトル。フィールドの次元を持つ配列 1〜8,192 個。内側の配列の長さがそろっていないと、取り込みは token vectors must share one dimension で拒否される。トークン単位の Embedder を持つフィールドは string も受け付け、トークンベクトルに埋め込む。保存されないため、getDocuments や検索結果には含まれない |
string[](すべて RFC 3339) | DateTimeArray | RFC 3339 日時文字列の配列(HTTP ゲートウェイと同じ infer_from_json の規則)。フィールドに multiValued: true が必要 |
string[](すべてが RFC 3339 ではない) | TextArray | 文字列の配列(Issue #1175)。フィールドに multiValued: true が必要。string[] として読み戻される。宣言済みの多値 Bytes フィールドでは、同じ base64 文字列の配列がスキーマ対応の変換によって要素ごとに BytesArray にデコードされる(Issue #1176) |
boolean[] | BoolArray | 真偽値の配列(HTTP ゲートウェイと同じ infer_from_json の規則)。フィールドに multiValued: true が必要。[true, 1] のような混在配列は拒否される |
開発環境のセットアップ
laurus-nodejs バインディングのローカル開発環境の構築、
ビルド、テスト実行について説明します。
前提条件
- Rust 1.85 以降(Cargo 含む)
- Node.js 24.15 以降(npm 含む。
package.jsonのengines.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 による融合アルゴリズム
- Late interaction による再採点 – MultiVector フィールドのトークンごとのベクトルを使い、ColBERT 型の MaxSim で上位の結果を並べ替え
- クエリ 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、
Candle ColBERT、OpenAI API)が用意されており、ドキュメントのインデックス時や
searchVectorText("field", "query text") 実行時にエンジンが自動で呼び出します。
これらネイティブ Embedder は wasm32-unknown-unknown 上で動作しないため、
WASM ビルドでは無効化されています:
| Embedder | Dependency | Why it cannot run in WASM |
|---|---|---|
candle_bert | candle (GPU/SIMD) | Requires native SIMD intrinsics and file system for models |
candle_clip | candle | Same as above |
candle_colbert | candle | Same as above |
openai | reqwest (HTTP) | Requires a full async HTTP client (tokio + TLS) |
(これらは embeddings-candle / embeddings-openai Feature Flags で管理されており、
wasm32-unknown-unknown で無効化される native feature に依存するため
WASM ビルドから除外されます。)
laurus-wasm ではその代わりに以下 3 種類の addEmbedder タイプを公開しています:
"precomputed"— 呼び出し側がputDocument()/searchVector()経由で ベクトルを直接渡します。エンジンは埋め込みを行いません。"callback"— JavaScript コールバックembed: (text) => Promise<number[]>を登録し、エンジンがインジェスト時およびsearchVectorText()から呼び出します。Transformers.js 等のブラウザ内埋め込み ライブラリと組み合わせることでエンジン内自動埋め込みが実現でき、ネイティブ環境と 同じくsearchVectorText("field", "query text")を呼び出すだけで利用できます。"token_callback"— MultiVector フィールド用に、JavaScript コールバックembed: (text, role) => number[][] | Promise<number[][]>とトークンベクトルのdimensionを登録します。エンジンはネイティブのcandle_colbertEmbedder の 代わりに、フィールドのテキストの値と再採点のクエリテキストに対してこれを 呼び出します。Option C を参照してください。
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, 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 コールバックの解決を待つため、大量バルク投入時には
事前計算ベクトルのほうが有利な場合があります。
Option C — Token Callback Embedder(late interaction による再採点)
ColBERT のような late interaction モデルは、テキストをトークンごとに 1 本の ベクトルで表します。そのトークンベクトルを MultiVector フィールドに保持すれば、 lexical・vector・ハイブリッドのどの検索でも、上位の結果を MaxSim で再採点できます (Issue #1351)。詳細は Late Interaction による再採点 を参照してください。
モデルは "token_callback" Embedder として登録します。コールバックはテキストと
role("query" または "document"。late interaction モデルはクエリと文書を
別々にエンコードするため)を受け取り、トークンごとに長さ dimension のベクトルを
1 本ずつ、number[][] またはその Promise として返します:
// myColbert はブラウザで動く ColBERT モデルのラッパー(例: ONNX Runtime Web を使用)。
schema.addEmbedder("colbert", {
type: "token_callback",
embed: async (text, role) => myColbert.encode(text, role), // number[][]
dimension: 128,
});
schema.addTextField("body");
schema.addMultiVectorField("body_colbert", 128, "cosine", "colbert");
const index = await Index.create(schema);
// テキストの値はコールバックが role "document" で埋め込む。
const body = "Lifetimes let the Rust compiler check that references stay valid";
await index.putDocument("doc1", { body, body_colbert: body });
await index.commit();
// lexical 検索の上位を再採点する。クエリのテキストはコールバックが role "query" で埋め込む。
const results = await index.search("body:rust", 10, 0, undefined, {
field: "body_colbert",
text: "how do lifetimes work",
});
Embedder を使わない場合は、事前計算したトークンベクトルを渡します。インデックス時は
入れ子の配列(body_colbert: [[0.1, ...], [0.3, ...]])、検索時は
{ field: "body_colbert", vectors: [[...], ...] } です。トークンベクトルは
getDocuments() や検索結果には含まれません。search() のほか、searchVector() と
searchVectorText() も同じ末尾の rescore 引数を受け付けます。詳細は
API リファレンスを参照してください。
laurus-wasm と laurus-nodejs の使い分け
| 基準 | laurus-wasm | laurus-nodejs |
|---|---|---|
| 実行環境 | ブラウザ、エッジランタイム | Node.js サーバー |
| パフォーマンス | 良好(シングルスレッド) | 最高(ネイティブ、マルチスレッド) |
| ストレージ | インメモリ + OPFS | インメモリ + ファイルシステム |
| 埋め込み | 事前計算 + JS コールバック | Candle、OpenAI、事前計算 |
| パッケージ | npm install laurus-wasm | npm 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 永続化には以下のブラウザが対応しています:
| ブラウザ | 最小バージョン |
|---|---|
| Chrome | 102+ |
| Firefox | 111+ |
| Safari | 15.2+ |
| Edge | 102+ |
クイックスタート
基本的な使い方(インメモリ)
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();
// 次のページロード時は、schema 引数を省略した Index.open("my-index") で
// データと永続化済みスキーマの両方が復元される。
const reopened = await Index.open("my-index");
日本語形態素検索
ブラウザ WASM では Lindera 辞書をファイルシステムパスで指定できないため、 OPFS にロードした IPADIC のバイト列から analyzer を構築します。
import init, { Index, Schema, JapaneseAnalyzer } from 'laurus-wasm';
import {
downloadDictionary,
getDictionaryVersion,
loadDictionaryFiles,
hasDictionary,
} from 'laurus-wasm/opfs';
await init();
// 1. 初回訪問時に IPADIC アーカイブを OPFS にキャッシュする。zip は
// アプリと同一オリジンで配信する必要がある(GitHub Releases は CORS
// でブロックされる)。圧縮 ~16 MB / 展開後 ~58 MB。
// バイナリ形式は WASM にコンパイルされた Lindera バージョンに紐づく
// ため、`version` でキャッシュにスタンプを付け、ビルドが期待する
// バージョンと一致しなくなったら再ダウンロードする。
const LINDERA_VERSION = "5.0.2"; // Cargo.lock の lindera バージョンと揃える
if (
!(await hasDictionary("ipadic"))
|| (await getDictionaryVersion("ipadic")) !== LINDERA_VERSION
) {
await downloadDictionary("./dict/lindera-ipadic.zip", "ipadic", {
version: LINDERA_VERSION,
onProgress: ({ phase, loaded, total }) => console.log(phase, loaded, total),
});
}
// 2. 9 つのコンポーネントファイルを読み出して analyzer を構築する。
const f = await loadDictionaryFiles("ipadic");
const ja = JapaneseAnalyzer.fromBytes(
f.metadata, f.dictTrie, f.dictValsIdx, 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, undefined, "ja-ipadic");
schema.addTextField("body", undefined, 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"
Late interaction による再採点
トークンごとのベクトル(ColBERT モデルの出力など)を MultiVector フィールドに保持し、 検索の上位の結果を MaxSim で並べ替えます。ここではトークンベクトルを事前に計算し、 入れ子の配列として渡します:
import init, { Index, Schema } from 'laurus-wasm';
await init();
const schema = new Schema();
schema.addTextField("title");
schema.addMultiVectorField("tokens", 2, "dot_product"); // 2次元のトークンベクトル
const index = await Index.create(schema);
await index.putDocument("doc1", {
title: "Rust",
tokens: [[0.1, 0.0]]
});
await index.putDocument("doc2", {
title: "The Rust language",
tokens: [[0.9, 0.2]]
});
await index.commit();
// lexical 検索の上位を、クエリのトークンベクトルで再採点
const results = await index.search("title:rust", 10, 0, undefined, {
field: "tokens",
vectors: [[1, 0], [0, 1]]
});
console.log(results[0].id, results[0].score); // "doc2" 約 1.1(MaxSim: 0.9 + 0.2)
再採点した結果の score は MaxSim の値です。トークンベクトルは
results[i].document には含まれません。テキストでインデックス・検索するには、
フィールドに "token_callback" Embedder を登録します。詳細は
API リファレンスを参照してください。
バンドラーでの利用
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 リファレンス
モジュール関数
version()
laurus-wasm のビルドバージョン文字列(例: "0.12.1")を返します。
laurus の状態を OPFS に永続化するアプリケーションは、この値を
スタンプとして保存しておくことで、オンディスクフォーマットが
変わった可能性のある別ビルド由来の状態を検出できます(デモの
サンプルが実際にこの方式を使っています)。
Index
検索インデックスの作成・クエリを行うメインエントリポイントです。
静的メソッド
Index.create(schema?, walSyncPolicy?, commitPolicy?)
新しいインメモリ(一時)インデックスを作成します。
- 引数:
schema(Schema, 省略可) – スキーマ定義。省略時は空のスキーマが使用されますwalSyncPolicy(WalSyncPolicy, 省略可) – WAL の永続性ポリシー。省略すると デフォルトのレコードごとの同期を使用します。 WAL 同期ポリシー / 永続性を参照してください。commitPolicy(CommitPolicy, 省略可) – 自動コミットポリシー。省略すると デフォルト(manual: 呼び出し側がコミットを駆動)を使用します。 コミットポリシー / 自動コミットを参照してください。
- 戻り値:
Promise<Index>
Index.open(name, schema?, walSyncPolicy?, commitPolicy?)
OPFS で永続化されたインデックスを開くか、新規作成します。
- 引数:
name(string) – インデックス名(OPFS サブディレクトリ)schema(Schema, 省略可) – スキーマ定義、およびそのセッションで必要な embedder コールバック・ランタイムアナライザー。インデックスを初めて作成する とき、またはスキーマ永続化に対応する前に永続化されたインデックスを開くとき (後述)は必須。それ以降は省略可能です — フィールドスキーマ部分は インデックスのデータと一緒に永続化され、自動的に再読み込みされるためです。 スキーマが既に永続化された状態でschemaを渡した場合、そのフィールド定義 は無視され永続化済みのものが使われます。embedder コールバック・ランタイム アナライザーだけは永続化できないため、必要なセッションでは毎回渡す必要が ありますwalSyncPolicy(WalSyncPolicy, 省略可) – WAL の永続性ポリシー。省略すると デフォルトのレコードごとの同期を使用します。 WAL 同期ポリシー / 永続性を参照してください。commitPolicy(CommitPolicy, 省略可) – 自動コミットポリシー。省略すると デフォルト(manual: 呼び出し側がコミットを駆動)を使用します。 コミットポリシー / 自動コミットを参照してください。
- 戻り値:
Promise<Index> - 例外: この OPFS インデックスにスキーマ永続化対応前のデータが既に存在し、
かつ一度きりの移行を完了するための
schemaが渡されなかった場合に発生します (スキーマはその場で永続化され、以降の再オープンには不要になります)。
インスタンスメソッド
putDocument(id, document)
ドキュメントを置換(upsert)します。
- 引数:
id(string) – ドキュメント識別子document(object) – スキーマフィールドに対応するキーバリューペア
- 戻り値:
Promise<void>
addDocument(id, document)
ドキュメントバージョンを追加します(マルチバージョン RAG パターン)。
- 引数・戻り値:
putDocumentと同じ
putDocuments(docs)
バッチ upsert。ペアをバッチ全体で WAL fsync 1 回で順に適用します。1 バッチ内で重複した ID はデデュープされます(最後が勝ち)。最初の不正エントリで fail-fast し、適用済みの prefix はロールバックされません(再試行は冪等)。
- 引数:
docs(Array<[string, object]>) –[id, document]ペアの配列
- 戻り値:
Promise<void>
addDocuments(docs)
バッチチャンク追記。putDocuments と同様ですが、繰り返した ID は別バージョンとして蓄積されます。
- 引数・戻り値:
putDocumentsと同じ
getDocuments(id)
ドキュメントの全バージョンを取得します。
- 引数:
id(string) - 戻り値:
Promise<object[]>
deleteDocuments(id)
ドキュメントの全バージョンを削除します。
- 引数:
id(string) - 戻り値:
Promise<void>
commit()
書き込みをフラッシュし、変更を検索可能にします。
Index.open() で作成したインデックスの場合、OPFS にも自動永続化されます。
- 戻り値:
Promise<void>
flushWal()
インメモリエンジンの WAL に対して永続性バリアを強制します。wasm 固有の
注意点については WAL 同期ポリシー / 永続性 を
参照してください — 特にこれは OPFS への永続化を行いません。永続的な
永続化には commit() を呼び出してください。
- 戻り値:
Promise<void>
search(query, limit?, offset?, highlight?, rescore?)
DSL 文字列クエリで検索します。
- 引数:
query(string) – クエリ DSL(例:"title:hello")limit(number, デフォルト 10)offset(number, デフォルト 0)highlight(HighlightOptions, 省略可) – フィールドごとのハイライト済みフラグメントを要求する(Issue #1134)。詳細は下記のハイライトを参照。rescoreだけを使う場合はundefinedを渡すrescore(RescoreOptions, 省略可) – 上位の結果を、MultiVector フィールドに対する late interaction で並べ替える(Issue #1351)。詳細は下記の Late interaction による再採点を参照
- 戻り値:
Promise<SearchResult[]>
searchTerm(field, term, limit?, offset?, highlight?)
完全一致タームで検索します。
- 引数:
field(string) – フィールド名term(string) – 検索タームlimit,offset(number, 省略可)highlight(HighlightOptions, 省略可) –searchのhighlight引数と同じ
- 戻り値:
Promise<SearchResult[]>
searchDateTimeRange(field, min?, max?, limit?, offset?, highlight?)
DateTime フィールドを両端を含む範囲で検索します(Issue #1179)。クエリクラスは JS に公開されていないため、これは created_at:[2024-01-01 TO 2024-12-31] のような DSL の範囲指定(search() でも使用可能)に対応する、クエリオブジェクト不要の検索メソッドです。
- 引数:
field(string) – DateTime フィールド名min,max(string, 省略可) – 両端を含む境界。省略(またはnull/undefined)でその側を開放。DSL の日時リテラルのいずれか: RFC 3339("2024-01-01T09:00:00+09:00"、UTC に正規化)、オフセットなしの"YYYY-MM-DDTHH:MM:SS[.fff]"(UTC)、または"YYYY-MM-DD"(その日の 0 時 UTC)。Dateはdate.toISOString()で渡すlimit,offset(number, 省略可)highlight(HighlightOptions, 省略可) –searchのhighlight引数と同じ
- 戻り値:
Promise<SearchResult[]> - 例外: 境界が認識できる日時リテラルでない場合、エラーで reject されます
ハイライト
search、searchTerm、searchDateTimeRange は省略可能な highlight 引数を受け付けます。以下の形の単純なオブジェクトです。
interface HighlightOptions {
fields: string[];
fragmentSize?: number;
maxFragments?: number;
tag?: string;
cssClass?: string;
requireFieldMatch?: boolean;
}
必須なのは fields のみで、それ以外はエンジンの既定 HighlightConfig(タグ "mark"、約150文字のフラグメントを最大5件、requireFieldMatch: true)にフォールバックします。ハイライトは search/searchTerm に渡したクエリに従い、stored: true のテキストフィールドのみハイライト可能です — 保存されていない、テキスト型でない、またはマッチしなかったフィールドは結果の highlights オブジェクトに現れません。highlight を省略する(または undefined を渡す)と、すべての結果の highlights は空のままになります。
const results = await index.search("body:rust", 10, 0, {
fields: ["body"],
tag: "em",
});
// results[0].highlights => { body: ["<em>Rust</em> is a systems programming language"] }
searchVector(field, vector, limit?, offset?, rescore?)
ベクトル類似度で検索します。
- 引数:
field(string) – ベクトルフィールド名vector(number[]) – クエリ埋め込みベクトルlimit,offset(number, 省略可)rescore(RescoreOptions, 省略可) –searchのrescore引数と同じ
- 戻り値:
Promise<SearchResult[]>
searchVectorText(field, text, limit?, offset?, rescore?)
テキストで検索します(登録された埋め込み器で変換)。
- 引数:
field(string) – ベクトルフィールド名text(string) – 埋め込み対象テキストlimit,offset(number, 省略可)rescore(RescoreOptions, 省略可) –searchのrescore引数と同じ
- 戻り値:
Promise<SearchResult[]>
Late interaction による再採点
search、searchVector、searchVectorText は、末尾に省略可能な rescore 引数を受け付けます(Issue #1351)。1 段目(lexical・vector・ハイブリッド)の上位の結果を、MultiVector フィールドのトークンベクトルに対する ColBERT 型の late interaction(MaxSim)で並べ替えます。ほかの検索メソッドには rescore 引数はありません。引数は以下の形の単純なオブジェクトです。
interface RescoreOptions {
field: string;
vectors?: number[][];
text?: string;
windowSize?: number;
}
| キー | 型 | デフォルト | 説明 |
|---|---|---|---|
field | string | – | 採点に使う MultiVector フィールド |
vectors | number[][] | – | クエリのトークンベクトル。文書のトークンベクトルを作ったのと同じモデルで計算する |
text | string | – | クエリのテキスト。フィールドの "token_callback" Embedder が role "query" で埋め込む |
windowSize | number | 100 | 再採点する 1 段目の上位件数。1〜10,000 |
vectors と text はどちらか一方だけを指定します。そうでない場合は Invalid rescore options: set exactly one of vectors or text で例外になります。型の合わない値(vectors の数値でない要素など)も Invalid rescore options: ... で例外になり、未知のキーは無視されます。それ以外の値はエンジンが検索の実行時に検査し、不正な値は rescore: ... を含むエラーになります。たとえば、トークン単位の Embedder がないフィールドへの text、MultiVector ではないフィールド、範囲外の windowSize、次元の合わないクエリベクトルです。
上位 windowSize 件の結果は MaxSim の降順に並び、再採点した結果の score は MaxSim の値になります。window の外の結果(および window 内でフィールドにトークンベクトルを持たない結果)は、1 段目の順序とスコアのまま、再採点した結果の後に続きます。採点・並び順・ページングの詳細は Vector 検索 → Late Interaction による再採点 を参照してください。
// 事前計算したクエリのトークンベクトルで、lexical 検索の上位を再採点する。
const results = await index.search("title:rust", 10, 0, undefined, {
field: "tokens",
vectors: [[1, 0], [0, 1]],
});
// フィールドの token_callback Embedder にクエリのテキストを埋め込ませることもできる。
const reranked = await index.searchVectorText("embedding", "how do lifetimes work", 10, 0, {
field: "body_colbert",
text: "how do lifetimes work",
windowSize: 50,
});
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 } } }
WAL 同期ポリシー / 永続性
各書き込みは、エンジンのインメモリ先行書き込みログ(WAL)に追記されます。
Index.create と Index.open はオプションの walSyncPolicy を受け付け、
WAL をどの頻度でフラッシュするかを制御します。デフォルト(引数を省略)は
レコードごとの同期です。
class WalSyncPolicy {
static perRecord(): WalSyncPolicy;
static group(
maxRecords?: number,
maxBytes?: number,
maxIntervalMs?: number,
): WalSyncPolicy;
}
| コンストラクタ | 説明 |
|---|---|
WalSyncPolicy.perRecord() | デフォルト。WAL レコードごとにフラッシュします。 |
WalSyncPolicy.group(...) | グループコミット。複数の書き込みにまたがってフラッシュをまとめます。 |
group(...) のパラメータ(引数を省略するとそのデフォルトを維持):
| パラメータ | デフォルト | 説明 |
|---|---|---|
maxRecords | 1024 | この件数のレコードが蓄積されたらフラッシュします。 |
maxBytes | 1048576(1 MiB) | この量の未同期バイトが蓄積されたらフラッシュします。 |
maxIntervalMs | なし | 定期フラッシュタイマー(ミリ秒)。wasm では no-op(注意点を参照)。 |
グループコミットでは、maxRecords または maxBytes のいずれかに達した
時点でエンジン WAL がフラッシュされ、commit() 時にも必ずフラッシュされます。
クラッシュ時には最後の未同期バッチまでを失う可能性があります — これは
SQLite の synchronous = NORMAL と同じトレードオフです。
flushWal()(永続性バリア)
flushWal() はインメモリエンジンの WAL を必要なときにフラッシュします。
- 戻り値:
Promise<void>
WASM の注意点
WebAssembly にはバックグラウンドスレッドや直接のファイルシステムがないため、 ネイティブバインディングとは 2 点で動作が異なります:
maxIntervalMsは no-op です。 定期フラッシュタイマーには バックグラウンドスレッドが必要ですが、wasm では利用できません。 グループコミットはmaxRecords/maxBytesのしきい値到達時とcommit()時にはフラッシュされます。flushWal()はインメモリエンジンの WAL のみをフラッシュします。 OPFS への永続化は引き続きcommit()で行われます。wasm で永続的に 永続化するにはcommit()を呼び出してください。
import { Index, Schema, WalSyncPolicy } from "./pkg/laurus_wasm.js";
const schema = new Schema();
schema.addTextField("title");
// グループコミットを有効化。maxIntervalMs は受け付けられますが wasm では無視されます。
const policy = WalSyncPolicy.group(4096, undefined, 1000);
const index = await Index.open("my-index", schema, policy);
for (let i = 0; i < 10000; i++) {
await index.putDocument(`doc${i}`, { title: `Document ${i}` });
}
await index.flushWal(); // エンジン WAL をフラッシュ(OPFS ではない)
await index.commit(); // 変更を検索可能にし、かつ OPFS に永続化する
コミットポリシー / 自動コミット
コミットはバッファされた書き込みを検索可能なストアに反映します。
Index.create と Index.open はオプションの commitPolicy を受け付け、
エンジンが代わりにコミットするかどうかを制御します。デフォルト(引数を省略)は
manual で、すべての commit() を自分で駆動します。
class CommitPolicy {
static manual(): CommitPolicy;
static everyDocs(n: number): CommitPolicy;
static intervalMs(ms: number): CommitPolicy;
}
| コンストラクタ | 説明 |
|---|---|
CommitPolicy.manual() | デフォルト。自動コミットなし。呼び出し側がすべての commit() を駆動します。 |
CommitPolicy.everyDocs(n) | n 件のドキュメントを適用するたびに自動コミットします。 |
CommitPolicy.intervalMs(ms) | バックグラウンドタイマーで少なくとも ms ミリ秒ごとに自動コミットします(デフォルト: なし)。ネイティブ専用 — wasm では no-op。 |
everyDocs(n) では、エンジンは n 件のドキュメントを適用するたびに 1 回
コミットします。カウンタは単発とバッチの両方の取り込みにまたがり、バッチの
内部でも発火します — n より大きい putDocuments 呼び出しはバッチの
途中で 1 回以上のコミットを引き起こします。everyDocs(0) は有効で、自動
コミットを無効化します。これは CommitPolicy.manual() と等価です。
intervalMs(ms) は everyDocs の時間ベース版です: バックグラウンドタイマーが
少なくとも ms ミリ秒ごとにコミットするため、取り込みがアイドル状態でも
末尾の部分的なバッチがコミットされます。これはネイティブ専用です — 下記の
WASM の注意点を参照してください。
commitPolicy は walSyncPolicy と直交します: walSyncPolicy は永続性の
ために WAL をどの頻度で fsync するかを制御し、commitPolicy はバッファされた
書き込みをいつ検索可能な状態へ反映するかを制御します。両者は独立して設定
できます。
WASM の注意点
walSyncPolicy の maxIntervalMs バックグラウンドタイマー(wasm では no-op)
とは異なり、everyDocs はバックグラウンドスレッドを必要としません —
ドキュメントカウンタは取り込み中にインラインでチェックされます — そのため
自動コミットは WebAssembly 上でも完全に動作します。
一方 intervalMs は walSyncPolicy の maxIntervalMs と同じくバックグラウンド
タイマーに依存しますが、wasm にはバックグラウンドスレッドがありません。
ファクトリはポータブルなポリシーコードがコンパイルできるよう値を構築します
が、タイマーは WebAssembly 上では決して動作せず、intervalMs は wasm では
効果がありません — タイマーによるコミットは一切発火しません。wasm での自動
コミットには everyDocs を使用してください。
import { Index, Schema, CommitPolicy } from "./pkg/laurus_wasm.js";
const schema = new Schema();
schema.addTextField("title");
// 1000 件のドキュメントを適用するたびに自動コミット。
const index = await Index.open(
"my-index",
schema,
undefined,
CommitPolicy.everyDocs(1000),
);
for (let i = 0; i < 10000; i++) {
await index.putDocument(`doc${i}`, { title: `Document ${i}` });
}
// エンジンは 10 回自動コミット済み。明示的な commit() は不要。
Schema
インデックスフィールドと埋め込み器を定義するビルダーです。
コンストラクタ
new Schema()
空のスキーマを作成します。
メソッド
addTextField(name, stored?, indexed?, termVectors?, docValues?, analyzer?, multiValued?, positionIncrementGap?)
全文検索テキストフィールドを追加します。docValues は値を DocValues
(ソート・ファセット・集計が読み取る列指向ストア)にもコピーするかどうかを
制御します(Issue #1047、デフォルト true)。stored も true の場合のみ
有効です。analyzer にはパラメータ不要の組込名("standard" /
"english" / "keyword" / "simple" / "noop")または addAnalyzer()
で登録したランタイム analyzer 名を指定します。multiValued: true を指定すると
文字列の配列を受け付け(Issue #1175)、term クエリはいずれかの要素がタームを含めばマッチし、
フレーズクエリは slop が positionIncrementGap(デフォルト 100。0 にすると要素を連結したものとして付番)
に達しない限り 2 つの要素をまたぎません。値は文字列の配列として読み戻されます。
日本語の形態素解析を行う場合は、まず JapaneseAnalyzer を IPADIC の
バイト列から構築し、addAnalyzer() で登録してください。
JapaneseAnalyzer.fromBytes
と addAnalyzer を参照。
addIntegerField(name, stored?, indexed?, multiValued?, docValues?)
64 ビット整数フィールドを追加します。multiValued: true を指定すると整数配列を受け付け、
範囲クエリはいずれかの値が条件を満たせばマッチ(Lucene 流の “any match”、constant スコア)します。
docValues は上記を参照。
addFloatField(name, stored?, indexed?, multiValued?, docValues?)
64 ビット浮動小数点フィールドを追加します。multiValued: true を指定すると浮動小数点配列を受け付け、
範囲クエリはいずれかの値が条件を満たせばマッチ(Lucene 流の “any match”、constant スコア)します。
docValues は上記を参照。
addBooleanField(name, stored?, indexed?, multiValued?, docValues?)
真偽値フィールドを追加します。multiValued: true を指定すると真偽値の配列を受け付け、
flags:true のような term クエリはいずれかの要素がクエリの値と等しければマッチ
(Lucene 流の “any match”。各要素が独立した term posting になるため、要素の重複はヒット数ではなく
term frequency を増やします)します。値は真偽値の配列として読み戻されます。docValues は上記を参照。
addDatetimeField(name, stored?, indexed?, multiValued?, docValues?)
日時フィールドを追加します。multiValued: true を指定すると RFC 3339 文字列の配列を受け付け、
範囲クエリ(searchDateTimeRange および DSL の日付範囲)はいずれかの時刻が条件を満たせばマッチ
(Lucene 流の “any match”、constant スコア)します。値は UTC に正規化した RFC 3339 文字列の配列として
読み戻されます。docValues は上記を参照。
addGeoField(name, stored?, indexed?, multiValued?, docValues?)
地理座標フィールドを追加します。multiValued: true を指定すると { lat, lon } オブジェクトの配列を受け付け、
距離 / バウンディングボックスクエリはいずれかのポイントが条件を満たせばマッチ(Lucene 流の “any match”)し、
スコアはドキュメント内で最も近いポイントで決まります。docValues は上記を参照。
addGeo3dField(name, stored?, indexed?, multiValued?, docValues?)
3D ECEF カルテシアン座標フィールド(x, y, z はメートル)を追加します。値は
{ x, y, z } オブジェクトで投入します。multiValued: true を指定すると { x, y, z } オブジェクトの配列を受け付け、
3D クエリはいずれかのポイントが条件を満たせばマッチ(Lucene 流の “any match”)し、
スコアはドキュメント内で最も近いポイントで決まります。詳細は
Geo3d の概念 を参照。docValues は上記を参照。
WASM バインディングは Geo3dDistanceQuery / Geo3dBoundingBoxQuery /
Geo3dNearestQuery を JS クラスとして公開していません(wasm-bindgen は
dyn Query トレイトオブジェクトを公開できないため)。代わりに上記の
Index.searchGeo3dDistance / Index.searchGeo3dBoundingBox /
Index.searchGeo3dNearest メソッドを使用してください。
addBytesField(name, stored?, multiValued?)
バイナリデータフィールドを追加します。docValues オプションはありません
—— Bytes の値は設定にかかわらず DocValues に一切書き込まれないためです。
multiValued: true を指定すると base64 文字列の配列を受け付け(Issue #1176)、
単一の base64 文字列と同じ方法でスキーマ対応の変換が要素ごとにデコードします。
Bytes はそもそもインデックスされないため、他の multiValued オプションと異なり
クエリ一致の意味論はなく、保存時の形と取り込み時の許容個数を変えるだけです。
値は MIME を落としたバイト整数配列の配列として読み戻され、スカラーフィールドと
同じ入出力の非対称性を持ちます。
addHnswField(name, dimension, distance?, m?, efConstruction?, defaultEfSearch?, embedder?, quantizer?, subvectorCount?, rerankStorage?, pqCodebookPath?, baseWeight?)
HNSW ベクトルインデックスフィールドを追加します。
distance:"cosine"(デフォルト)、"euclidean"、"dot_product"、"manhattan"、"angular"m: 分岐係数(デフォルト 16)efConstruction: 構築時の探索幅(デフォルト 200)defaultEfSearch: クエリ時のef_search(候補リストサイズ)のスキーマレベルデフォルト。省略時は内部フォールバックの 50 を使用しますquantizer:"scalar_8bit"(デフォルト)または"product_quantization"(subvectorCountが必須)subvectorCount: PQ サブベクトル数。dimensionを割り切れる値を指定しますrerankStorage: 省略(デフォルト)するか、"f32"を指定して完全精度のリランクサイドカーを保存しますpqCodebookPath: 省略(デフォルト)するか、segment 間で再利用する共有 PQ codebook のストレージ相対ファイル名(Issue #631)を指定します(segment ごとの学習の代替)baseWeight: 他の vector フィールドと同時に検索されたときの、このフィールドの相対的なスコアリング優先度(デフォルト1.0、Issue #1084)。ウェイトを参照してください
addFlatField(name, dimension, distance?, embedder?, baseWeight?)
全探索ベクトルインデックスフィールドを追加します。
addIvfField(name, dimension, distance?, nClusters?, nProbe?, embedder?, baseWeight?)
IVF ベクトルインデックスフィールドを追加します。
nClusters: パーティショニングクラスタ数(デフォルト 100)nProbe: 検索時にプローブするクラスタ数(デフォルト 1)
ベクトル量子化とリランクストレージ(HNSW フィールド):
quantizer—"scalar_8bit"(デフォルト、4 倍圧縮)または高圧縮率の"product_quantization"。Product quantization ではsubvectorCount(dimensionを割り切れる値)が必須です。rerankStorage—"f32"を指定すると完全精度の*.hnsw.f32サイドカーを書き出し、厳密な Stage-2 リランクを有効化します。省略すると int8 のみのセグメントを維持します。pqCodebookPath— 共有 PQ codebook のストレージ相対ファイル名(Issue #631)。laurus train pq-codebookCLI コマンドで一度だけ学習します。quantizer: "product_quantization"との組み合わせでのみ意味を持ち、以後の commit は segment ごとの k-means 再学習の代わりに学習済み codebook で encode します。省略すると segment ごとの学習を維持します。
addMultiVectorField(name, dimension, distance?, embedder?, storage?)
MultiVector フィールドを追加します(Issue #1351)。ColBERT 型モデルのトークンごとの埋め込みのように、文書ごとに可変本数のトークンベクトルを保持します。ANN 索引は持たず、検索の対象にもなりません。読むのは late interaction による再採点だけです。スキーマとフィールド → MultiVector フィールドを参照してください。
dimension: 各トークンベクトルの長さ。0 より大きい値distance:"cosine"(デフォルト。書き込み時に L2 正規化)または"dot_product"embedder:addEmbedderで登録した"token_callback"Embedder の名前(省略可)。テキストの値と再採点のクエリテキストを埋め込みますstorage: トークンベクトルのディスク上の要素種別(Issue #1346)—"f32"(デフォルト、正確)、"f16"(2倍小さい)、"int8"(約4倍小さい)
オプションはフィールドの追加時に検証され、dimension が 0 の場合、上記以外の distance、または認識できない storage は例外になります。
値は、トークンごとに 1 つの配列を持つ入れ子の数値配列(tokens: [[0.1, 0.2], [0.3, 0.4]])で、フィールドの次元のベクトルを 1〜8,192 本保持します。フィールドに "token_callback" Embedder がある場合は文字列の値も使え、文書のインデックス時にコールバックが role "document" で埋め込みます。トークンベクトルは保存されないため、getDocuments や検索結果にこのフィールドは含まれません。
schema.addMultiVectorField("tokens", 2, "dot_product");
// ...
await index.putDocument("doc1", { title: "Rust", tokens: [[0.9, 0.2], [0.1, 0.8]] });
上記のどの add*Field メソッドも、name が _(_id を除く)で始まる場合は例外を投げ、何も追加しない。fromToml で読み込んだスキーマはそのようなフィールドを引き続き受け付けるため、永続化済みのスキーマも読み込めるが、そのスキーマから新しい Index を作成すると例外を投げる。詳細はフィールド命名規則を参照。
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.dictTrie, f.dictValsIdx, 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, undefined, "ja-ipadic");
addEmbedder(name, config)
名前付き埋め込み器を登録します。WASM では以下の 3 種類の type をサポートします:
"precomputed"— 埋め込みは行いません。ベクトルはputDocument()/searchVector()経由で直接渡します。"callback"— JavaScript コールバックembed: (text) => Promise<number[]>を 登録します。エンジンがインジェスト時およびsearchVectorText()で呼び出します。 Transformers.js などのブラウザ内埋め込みライブラリと組み合わせることで、 エンジン内自動埋め込みが可能になります。"token_callback"— MultiVector フィールド 用に、JavaScript コールバックembed: (text, role) => number[][] | Promise<number[][]>とdimension(正の整数)を登録します(Issue #1351)。late interaction モデルはクエリと 文書を別々にエンコードするため、roleには"query"または"document"が 渡されます。コールバックはトークンごとに長さdimensionのベクトルを 1 本ずつ 返します。値をそのまま返しても Promise で返しても構いません。エンジンは フィールドのテキストの値に対して role"document"で、再採点のtextに 対して role"query"で呼び出します。dimensionはインデックスの作成時・ オープン時に MultiVector フィールドと照合され、一致しない場合はIndex.create/Index.openが例外を投げます (... produces N-dimensional token vectors)。返された配列に数値でない要素が あるとエラーになります(0 として黙って扱われることはありません)。
関数はシリアライズできないため、どちらのコールバック型もスキーマ TOML
(toToml() や Index.open が永続化するスキーマ)には "precomputed" として
記録されます。コールバックが必要なセッションでは、Index.open に渡すスキーマで
毎回登録し直してください。
"token_callback" は、WASM にはないネイティブの candle_colbert Embedder の
代わりになります(Embedding 戦略を参照)。
// 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);
},
});
// MultiVector フィールド用の Token callback embedder(例: ブラウザで動く ColBERT モデル)
schema.addEmbedder("colbert", {
type: "token_callback",
embed: async (text, role) => myColbert.encode(text, role), // number[][]
dimension: 128,
});
schema.addMultiVectorField("body_colbert", 128, "cosine", "colbert");
addAnalyzerDefinition(name, definition)
カスタムアナライザ定義を登録します。必須のトークナイザに加え、省略可能な文字フィルタ・
トークンフィルタのチェーンで構成されます。上記の
addAnalyzer とは別概念です。あちらは事前構築済みの
ランタイムアナライザオブジェクト(現状 JapaneseAnalyzer のみ)を登録するのに対し、
こちらはシリアライズ可能な JSON 形式のコンポーネント(laurus-cli create index --schema
や他の言語バインディングと同じ形式)からアナライザを宣言します。
definition.tokenizer は必須、definition.charFilters と
definition.tokenFilters は省略可能な配列です。各コンポーネントはスキーマ TOML/JSON
形式と同じ { type: "...", ... } 形式を使います(下記参照)。コンポーネント内部の
キーはこのワイヤ形式に合わせて snake_case のままです。外側のラッパーキー
(charFilters/tokenFilters)のみ、このバインディング独自の camelCase 規約に従います。
組み込みアナライザ用に予約された名前(standard、keyword、english、simple、
noop)は例外になり、その名前を定義したスキーマ(fromToml で読み込んだものなど)で
Index.create や初回の Index.open をする場合も同じです。addAnalyzer は対象外です。
ランタイムアナライザは組み込みより先に解決されるため、組み込みと同じ名前で登録しても効きます。
const schema = new Schema();
schema.addAnalyzerDefinition("ngram3", {
tokenizer: { type: "ngram", min_gram: 3, max_gram: 3 },
});
schema.addTextField("title", undefined, undefined, undefined, undefined, "ngram3");
analyzerNames()
addAnalyzerDefinition で登録された、または TOML から読み込まれたカスタムアナライザの
名前一覧を返します。
Schema.fromToml(tomlStr) (静的メソッド)
laurus-cli create index --schema と同じ形式の TOML 文字列からスキーマを読み込みます。
ファイルパス版は提供しません(ブラウザ WASM ターゲットにはファイルシステムがないため)。
toToml()
このスキーマを laurus-cli と同じ形式の TOML 文字列にシリアライズします。
setDefaultFields(fields)
デフォルト検索フィールドを設定します。
setDynamicFieldPolicy(policy)
ドキュメントに含まれるがスキーマに宣言されていないフィールドの扱いを設定します。policy は "strict" / "dynamic"(デフォルト)/ "ignore" のいずれか(大文字小文字を無視)。不正な値を渡すと例外をスローします。
"strict"— ドキュメントを拒否"dynamic"— 各未宣言フィールドの型を推論してスキーマに追加。警告: integer フィールドに入ってきた float 値は静かに切り捨てられます(3.14→3)"ignore"— 未宣言フィールドを静かに破棄
詳細な挙動マトリクスは スキーマとフィールド を参照してください。
dynamicFieldPolicy()
現在のポリシーを小文字の文字列で返します。
fieldNames()
定義済みフィールド名の配列を返します。
toString()
スキーマの文字列表現("Schema(fields=[...])" 形式)を返します。
アナライザコンポーネント
addAnalyzerDefinition(name, definition) と [analyzers.<name>] TOML
セクションで使用します。definition.tokenizer は単一のオブジェクト、
definition.charFilters/definition.tokenFilters はオブジェクトの配列で、
配列の順序どおりに適用されます。
各コンポーネントの説明を含む正規のリファレンスは スキーマフォーマットリファレンス → アナライザ を参照してください。
トークナイザ(tokenizer、必ず1つ):
type | 必須キー | 省略可能キー |
|---|---|---|
"whitespace" | – | – |
"unicode_word" | – | – |
"regex" | – | pattern(デフォルト \w+)、gaps(デフォルト false) |
"ngram" | min_gram、max_gram | – |
"lindera" | mode、dict | user_dict |
"whole" | – | – |
文字フィルタ(charFilters、トークン化前の生テキストに適用):
type | 必須キー | 省略可能キー |
|---|---|---|
"unicode_normalization" | form("nfc"/"nfd"/"nfkc"/"nfkd") | – |
"pattern_replace" | pattern、replacement | – |
"mapping" | mapping(置換用のオブジェクト) | – |
"japanese_iteration_mark" | – | kanji(デフォルト true)、kana(デフォルト true) |
トークンフィルタ(tokenFilters、トークン化後のトークン列に適用):
type | 必須キー | 省略可能キー |
|---|---|---|
"lowercase" | – | – |
"stop" | – | words(デフォルト: 英語のストップワード) |
"stem" | – | stem_type("porter"/"simple"/"identity") |
"boost" | boost | – |
"limit" | limit | – |
"strip" | – | – |
"remove_empty" | – | – |
"flatten_graph" | – | – |
ここでの "lindera" トークナイザは、上記の JapaneseAnalyzer.fromBytes で構築する
日本語アナライザとは別の経路である点に注意してください。lindera トークナイザ定義の
dict はファイルシステムパスを指定するため、ブラウザでは解決できません。
laurus-wasm を実ファイルシステムを持つブラウザ以外の WASM ホストで動かす場合にのみ
有用です。ブラウザから呼び出す場合は、引き続き JapaneseAnalyzer.fromBytes +
addAnalyzer を使用してください。
SearchResult
interface SearchResult {
id: string;
score: number;
document: object | null;
highlights: Record<string, string[]>;
}
highlights はリクエストの highlight.fields で指定した各フィールドをハイライト済みフラグメント(最も良いものが先頭)にマッピングします。ハイライトされなかったフィールドはオブジェクトに現れず、highlight を要求しなかった場合 highlights は {} になります。詳細はハイライトを参照してください。
Analysis
JapaneseAnalyzer
Lindera 辞書のバイト列から構築する日本語形態素解析 analyzer。
ブラウザ WASM には実ファイルシステムが無いため、標準の
{ "language": "japanese", "dict": "/path/to/ipadic" } プリセットは
利用できません。代わりに Lindera 辞書アーカイブ(典型的には
lindera-ipadic-X.Y.Z.zip)を取得して OPFS ヘルパ で
OPFS に保存し、9 つのコンポーネントバイト配列を
JapaneseAnalyzer.fromBytes に渡してください。
JapaneseAnalyzer.fromBytes(metadata, dictTrie, ..., mode?)
IPADIC のバイト列から analyzer を構築する static ファクトリ。
引数(mode 以外はすべて Uint8Array):
| 引数 | 対応するファイル |
|---|---|
metadata | metadata.json |
dictTrie | dict.trie(prefix trie) |
dictValsIdx | dict.valsidx |
dictVals | dict.vals |
dictWordsIdx | dict.wordsidx |
dictWords | dict.words |
matrixMtx | matrix.mtx |
charDef | char_def.bin |
unk | unk.bin |
mode | "normal"(デフォルト)/ "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.dictTrie, f.dictValsIdx, 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,
getDictionaryVersion,
loadDictionaryFiles,
hasDictionary,
listDictionaries,
removeDictionary,
} from "laurus-wasm/opfs";
| 関数 | 説明 |
|---|---|
downloadDictionary(url, name, options?) | .zip を fetch し、Web の DecompressionStream API で展開して、Lindera 9 ファイルを OPFS の laurus/dictionaries/<name>/ 配下に保存します。options.onProgress({ phase, loaded?, total? }) で進捗通知を受け取れます。options.version を渡すとファイルと並べてバージョンスタンプを保存します(下記参照)。 |
getDictionaryVersion(name) | downloadDictionary が保存したバージョンスタンプを返します。辞書またはスタンプが存在しない場合は null を返します。 |
loadDictionaryFiles(name) | 9 ファイルを { metadata, dictTrie, dictValsIdx, dictVals, dictWordsIdx, dictWords, matrixMtx, charDef, unk } オブジェクトとして読み出し、JapaneseAnalyzer.fromBytes にそのまま渡せる形にします。 |
hasDictionary(name) | 辞書ディレクトリが OPFS にあれば true。 |
listDictionaries() | 保存済み辞書名の配列を返します。 |
removeDictionary(name) | 辞書ディレクトリを削除します。 |
辞書のバイナリ形式は WASM バイナリにコンパイルされた Lindera
(およびその依存 daachorse)のバージョンに紐づいており、アプリが
Lindera を更新すると OPFS にキャッシュ済みの辞書は読めなくなります
(InvalidAutomatonError でデシリアライズに失敗します)。ダウンロード時に
zip の対象 Lindera バージョンを options.version として渡し、起動時に
getDictionaryVersion(name) を現在のビルドが期待するバージョンと比較して、
不一致なら再ダウンロードしてください。スタンプが null の場合も
不一致として扱います。
ブラウザ 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, tokenType }]
空白を境界としてテキストを分割し、Token オブジェクトの配列を返します。
startOffset と endOffset は、元テキストの UTF-8 バイトオフセットです。非 ASCII のテキストでは JavaScript の文字列(UTF-16)の添字と一致しないため、エンコードしたバイト列を切り出します: new TextDecoder().decode(new TextEncoder().encode(text).slice(tok.startOffset, tok.endOffset))。
tokenType は "alphanum"、"num"、"cjk"、"katakana"、"hiragana"、"hangul"、"punctuation"、"whitespace"、"synonym"、"email"、"url"、"other" のいずれかです。
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 の同義語でトークンを展開するトークンフィルターです。
apply は各トークンのオフセットと種別を保ち、挿入する同義語には種別 "synonym" と、置き換える語のオフセットを付けます。未知の tokenType を渡すと例外を投げます。手で組み立てたトークンでは tokenType を省略できますが、その場合、複数語の同義語は語のオフセットが連続しているときだけ一致するため、空白で区切られた語には tokenType: "alphanum" を付けてください。
開発
前提条件
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 は二層ストレージアプローチを採用しています:
-
MemoryStorage(ランタイム) – すべての読み書き操作は Laurus の インメモリストレージを経由します。これは
StorageトレイトのSend + Sync要件を満たします。 -
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-wasm は src/analysis.rs で JapaneseAnalyzer.fromBytes(...) を公開しており、Lindera IPADIC 辞書アーカイブを実行時に OPFS へ取得し、Lindera が必要とする 9 つの生バイト配列を読み出してアナライザーに渡せます:
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.dictTrie, f.dictValsIdx, 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, undefined, "ja-ipadic");
OPFS ヘルパー(downloadDictionary / getDictionaryVersion / loadDictionaryFiles / hasDictionary / listDictionaries / removeDictionary)は js/opfs.js にあり、package.json で laurus-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, undefined, "minilm",
);
wasm-bindgen のグルーコードが JS コールバックを Closure で保持するため、インデックスの寿命中ずっと有効です。コールバックは常にメインスレッドで実行されるため、Send + Sync 制約は付きません。
MultiVector フィールドと late interaction による再採点には、"token_callback" Embedder({ type: "token_callback", embed: (text, role) => number[][] | Promise<number[][]>, dimension })を使います。JsTokenCallbackEmbedder(src/embedder.rs)は Embedder と TokenEmbedder の両方を手書きで実装しています。理由は callback Embedder と同じで、JsFuture が Send でないため、#[async_trait] を使わずに future を包んでいます。戻り値は Promise.resolve で包むので同期の戻り値でも動き、dimension はインデックスの作成時にフィールドと照合します。オプションは API リファレンス を参照してください。
テスト
# ビルド確認
cargo build -p laurus-wasm --target wasm32-unknown-unknown
# Clippy
cargo clippy -p laurus-wasm --target wasm32-unknown-unknown -- -D warnings
#[wasm_bindgen_test] によるユニットテストは Node 上で実行します(CI でもこのコマンドを実行しています):
wasm-pack test --node laurus-wasm
ブラウザが必要なテストがある場合は、ヘッドレス Chrome で実行できます:
wasm-pack test --headless --chrome
Ruby バインディング概要
laurus gem は Laurus 検索エンジンの Ruby バインディングです。Magnus と rb_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 らしい API –
Laurus::名前空間の直感的な 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 にブリッジしていますが、その呼び出しの間は
GVL(Global VM Lock)を解放します(Issue #1103)。これにより、
呼び出し中も他の Ruby スレッドは動き続けられます。マルチスレッド
サーバーがワーカースレッドを増やすことで、すべての呼び出しが
GVL 上で直列化される以前とは異なり、実際にスループットの恩恵を
受けられます。
Ruby スレッドが初めて真の並行ライターになれるため、
エンジン側の既存の並行性に関する制約が Ruby からも
到達可能になります: commit は並行する
put/add/delete 呼び出しと直列化されず、
CommitPolicy の自動コミット保証は単一ライターでの
取り込みを前提としており、同一 Index への並行ライター
下では best-effort(ベストエフォート)になります。
並行実行下でこれらの保証が必要な場合は、明示的な
commit 呼び出し、または単一の取り込みスレッドを
使用してください。
Python バインディングとは異なり、close は他のスレッドの
実行中の呼び出しの完了を待たずに返ります。Magnus は
#[magnus::wrap] メソッドに常に素の &self しか渡さないため、
close を排他的にする借用チェッカーの仕組みがここには
存在しません。close の実行中に別スレッドがまだ呼び出しの
途中である場合、その呼び出し自身が保持する参照によって、
呼び出しが完了するまで内部のエンジン(およびそのストレージ
ロック)は生き続けます。
クイックスタート
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
セクション
- インストール – gem のインストール方法
- クイックスタート – サンプルによるハンズオン入門
- API リファレンス – クラスとメソッドの完全リファレンス
- 開発 – ソースからのビルドとテスト実行
インストール
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
# ファイルベースインデックス(永続的)
# `./myindex/schema.toml` と `./myindex/store/` を書き込む -- これは
# `laurus-cli create index --schema` と同じレイアウトなので、このディレクトリは
# CLI からも開ける(逆も同様)。
schema = Laurus::Schema.new
schema.add_text_field("title")
schema.add_text_field("body")
index = Laurus::Index.new(path: "./myindex", schema: schema)
# 後で再オープンする際はパスだけで済む -- schema: を再度渡すとエラーになる
# (スキーマは既に永続化されているため)。
index = Laurus::Index.new(path: "./myindex")
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. Late interaction による再採点
MultiVector フィールドは、文書ごとに可変個のトークンベクトル(ColBERT のトークンごとの埋め込みなど)を保持します。LateInteractionRescore は、どの検索でも上位の結果をそのフィールドに対する MaxSim で並べ替え、再採点された結果のスコアはその MaxSim になります。
require "laurus"
schema = Laurus::Schema.new
schema.add_text_field("title")
schema.add_multi_vector_field("tokens", 2, distance: "dot_product")
index = Laurus::Index.new(schema: schema)
index.put_document("doc1", { "title" => "rust", "tokens" => [[0.1, 0.0]] })
index.put_document("doc2", { "title" => "rust language", "tokens" => [[0.9, 0.2], [0.1, 0.8]] })
index.commit
results = index.search("title:rust", rescore: Laurus::LateInteractionRescore.new("tokens", [[1.0, 0.0], [0.0, 1.0]]))
# doc2(MaxSim 1.7)が doc1(MaxSim 0.1)より上位になる
フィールドに candle_colbert のエンベダーを設定すると(schema.add_embedder("colbert", { type: "candle_colbert", model: "colbert-ir/colbertv2.0" }) と add_multi_vector_field("tokens", 128, embedder: "colbert"))、文書はこのフィールドにテキストを与えられ、クエリも Laurus::LateInteractionRescore.new("tokens", "how do lifetimes work") のようにテキストで渡せます。LateInteractionRescore を参照してください。
7. 更新と削除
# 更新: 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
8. スキーマ管理
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)
schema.add_multi_vector_field("tokens", 128)
9. インデックス統計
stats = index.stats
puts stats["document_count"]
puts stats["vector_fields"]
API リファレンス
Index
Laurus 検索エンジンをラップするメインクラスです。
Laurus::Index.new(path: nil, schema: nil, wal_sync_policy: nil, commit_policy: nil)
コンストラクタ
| パラメータ | 型 | デフォルト | 説明 |
|---|---|---|---|
path: | String | nil | nil | 永続ストレージのディレクトリパス。nil の場合はインメモリインデックスを作成します。指定した場合、そのディレクトリは laurus-cli create index/--index-dir と同じ <path>/schema.toml + <path>/store/ というレイアウトに従うため、ここで作成したインデックスは CLI からも開けます(逆も同様)。詳細は下記を参照。 |
schema: | Schema | nil | nil | スキーマ定義。新規にファイルベース(またはインメモリ)インデックスを作成する場合のみ意味を持ちます。既存のファイルベースインデックスを再オープンする場合は省略する必要があり、永続化済みのスキーマが代わりに読み込まれます。新規インデックスで省略した場合は空のスキーマが使用されます。 |
wal_sync_policy: | WalSyncPolicy | nil | nil | 先行書き込みログ(WAL)の耐久性ポリシー。nil の場合はデフォルトのレコードごと fsync を維持します。WAL 同期ポリシーと耐久性 を参照。 |
commit_policy: | CommitPolicy | nil | nil | 自動コミットポリシー。nil の場合はデフォルトの manual モード(呼び出し側がすべての commit を駆動)を維持します。コミットポリシーと自動コミット を参照。 |
ファイルベースインデックスの作成 vs 再オープン(path: を指定した場合): <path>/schema.toml がまだ存在しない場合、この呼び出しは新規インデックスを作成し、schema:(省略時は空のスキーマ)をそこに永続化します。<path>/schema.toml が既に存在する場合、この呼び出しは既存インデックスを再オープンします – schema: は省略しなければならず、指定すると ArgumentError が発生します(どちらのスキーマを優先すべきか曖昧になるため)。path: がこの規約導入以前のレイアウト(schema.toml が無く、セグメントファイルが path: 直下にある)のインデックスを含んでいる場合も ArgumentError になります。
メソッド
| メソッド | 説明 |
|---|---|
put_document(id, doc) | ドキュメントをアップサート(upsert)します。同じ ID の既存バージョンをすべて置換します。 |
add_document(id, doc) | 既存バージョンを削除せずにドキュメントチャンクを追記します。 |
put_documents(docs) | バッチ upsert。docs は [id, hash] ペアの Array で、バッチごとに WAL fsync 1 回で順に適用します(重複 ID はデデュープ、最後が勝ち)。最初の不正エントリで fail-fast し、適用済みの prefix はロールバックされません。 |
add_documents(docs) | バッチチャンク追記。put_documents と同様ですが、繰り返した ID は別バージョンとして蓄積されます。 |
get_documents(id) -> Array<Hash> | 指定 ID の全保存バージョンを返します。 |
delete_documents(id) | 指定 ID の全バージョンを削除します。 |
commit | バッファリングされた書き込みをフラッシュし、すべての保留中の変更を検索可能にします。 |
flush_wal | WAL の耐久バリアをオンデマンドで強制します。未同期の WAL レコードを同期的に fsync し、nil を返します。group-commit ポリシー下で実行する場合に有用です(下記参照)。 |
search(query, limit: 10, offset: 0, highlight: nil, rescore: nil) -> Array<SearchResult> | 検索クエリを実行します。rescore: には上位の結果を並べ替える LateInteractionRescore を渡します(Issue #1351)。それ以外の値は TypeError になります。LateInteractionRescore を参照。 |
search_batch(queries, limit: 10, offset: 0, highlight: nil) -> Array<Array<SearchResult>> | 独立した複数の検索を 1 回の呼び出しで実行します。各クエリは内部の tokio ランタイム上で並列に dispatch されます。results[i] は queries[i] に対応し、入力が空の配列の場合は [] を返します。highlight: はバッチ内のすべてのクエリに同一に適用されます。rescore: キーワードはありません。要素の SearchRequest は、その rescore: で再採点されます。 |
stats -> Hash | インデックス統計("document_count"、"vector_fields")を返します。 |
search の query 引数
query パラメータは以下のいずれかを受け付けます:
- DSL 文字列(例:
"title:hello"、"content:\"memory safety\"") - Lexical クエリオブジェクト(
TermQuery、PhraseQuery、BooleanQueryなど) - Vector クエリオブジェクト(
VectorQuery、VectorTextQuery) SearchRequest(完全な制御が必要な場合)
query が SearchRequest の場合は、その limit:・offset:・highlight:・rescore: が使われ、search のキーワード引数は無視されます。
search_batch の queries 配列の各要素も同じ種類の値を受け付けます。DSL 文字列・クエリオブジェクト・SearchRequest を 1 つのバッチ内で混在させることもできます。
ハイライト
search/search_batch の highlight: キーワード(Issue #1134)は、各ヒットの SearchResult#highlights にフィールドごとのハイライト済みフラグメントを要求します。以下のいずれかを受け付けます。
- フィールド名の配列:
highlight: ["body"] - Hash: 必須の
fieldsキー(String または Symbol)に加えて、HighlightConfigの任意の設定(max_fragments、fragment_size、tag、css_class、require_field_match、max_analyzed_chars、return_entire_field_if_no_highlight。キーは String・Symbol どちらも可)を指定 — 例:highlight: {fields: ["body"], tag: "em", max_fragments: 2}
results = index.search("body:rust", highlight: ["body"])
results[0].highlights # => {"body" => ["<mark>Rust</mark> is a systems programming language."]}
ハイライトは search/search_batch に渡したクエリ(または SearchRequest の query/lexical_query、下記参照)に従い、stored: true のテキストフィールドのみハイライト可能です。存在しない、保存されていない、テキスト型でないフィールドは黙ってスキップされます。highlight: を省略すると、すべての結果の highlights は空のままになります。同じ highlight: キーワードは SearchRequest.new でも使用できます。
WAL 同期ポリシーと耐久性
先行書き込みログ(WAL: Write-Ahead Log)は、コミット済みデータをクラッシュ
から保護します。デフォルトでは WAL は完全に耐久的で、すべてのレコードは
書き込みが返る前に fsync されます。group commit(グループコミット)
を有効にすると、fsync 呼び出しをまとめることで、耐久性をいくらか引き換えに
書き込みスループットを向上させられます。
WalSyncPolicy
Laurus::WalSyncPolicy は WAL のフラッシュ方法を記述するイミュータブルな
値オブジェクトです。Index.new(wal_sync_policy:) に渡します。
# デフォルト: 書き込みごとに耐久(各レコードを個別に fsync)。
Laurus::WalSyncPolicy.per_record
# Group commit: fsync をまとめてコストを償却。
Laurus::WalSyncPolicy.group(
max_records: nil, # このレコード数でフラッシュ(デフォルト 1024)
max_bytes: nil, # このバイト数でフラッシュ(デフォルト 1 MiB)
max_interval_ms: nil, # このミリ秒ごとに定期的にもフラッシュ
)
| コンストラクタ | 説明 |
|---|---|
WalSyncPolicy.per_record | デフォルト。すべてのレコードは書き込みが返る前に fsync されます。書き込みごとに完全に耐久的です。 |
WalSyncPolicy.group(max_records:, max_bytes:, max_interval_ms:) | fsync をまとめます。引数なしの場合はデフォルト(max_records: 1024、max_bytes: 1 MiB、タイマーなし)を使用します。WAL は max_records または max_bytes のいずれかが蓄積したとき、および毎回の commit 時にフラッシュされます。max_interval_ms: を指定すると、定期タイマーでもフラッシュします。 |
Group commit は SQLite の synchronous = NORMAL に相当します。クラッシュ時に
失われるのは最後の未同期バッチのレコードまでで、インデックスが破損する
ことはありません。レコードは常に commit 時に耐久化されるため、成功した
commit はポリシーに関わらず耐久バリアとなります。
フラッシュの強制
コミットの合間に耐久バリアを強制するには flush_wal を呼び出します。
例えば、バッチが安全に永続化されたことを通知する前などです。未同期の
レコードを同期的に fsync し、nil を返します。デフォルトのレコードごと
ポリシーでは実質的に no-op です。
# group commit を有効にし、必要に応じて耐久性を強制する。
policy = Laurus::WalSyncPolicy.group(max_records: 4096, max_bytes: 4 * 1024 * 1024)
index = Laurus::Index.new(path: "./myindex", wal_sync_policy: policy)
index.put_document("doc1", { "title" => "Hello" })
index.flush_wal # group バッチが満杯でなくてもレコードが永続化される
コミットポリシーと自動コミット
デフォルトでは、すべてのコミットは呼び出し側が駆動します。バッファリング
された書き込みは、明示的に commit を呼び出したときにのみ検索可能になり
ます。自動コミットポリシー(auto-commit policy) を使うと、その責務を
エンジンに委ねられ、一定数のドキュメントを適用するたび、または定期タイマー
で自動的にコミットされます。
CommitPolicy
Laurus::CommitPolicy は、エンジンがバッファリングされた書き込みをいつ
ストアへ実体化するかを記述するイミュータブルな値オブジェクトです。
Index.new(commit_policy:) に渡します。
# デフォルト: manual — すべてのコミットは呼び出し側が駆動。
Laurus::CommitPolicy.manual
# 自動コミット: N ドキュメント適用ごとにコミット。
Laurus::CommitPolicy.every_docs(1000)
# 自動コミット: 少なくとも N ミリ秒ごとにコミット(ネイティブのみ)。
Laurus::CommitPolicy.interval_ms(5000)
| コンストラクタ | 説明 |
|---|---|
CommitPolicy.manual | デフォルト。自動コミットなし。すべての commit は呼び出し側が駆動します。 |
CommitPolicy.every_docs(n) | n ドキュメント適用ごとに自動コミットします。単発・バッチ両方の取り込みを通してカウントされ、単一バッチ 内 でも n ドキュメントごとにコミットされます。 |
CommitPolicy.interval_ms(ms) | バックグラウンドタイマーで少なくとも ms ミリ秒ごとに自動コミットします。取り込みがアイドル状態でも、末尾の部分バッチがコミットされます。every_docs の時間ベース版です。デフォルト: なし。ネイティブのみ — WebAssembly(wasm32)ではバックグラウンドスレッドが存在しないため、エンジンはこれを no-op として扱います(値は構築されますが、タイマーによるコミットは発生しません)。 |
every_docs(0) は有効で、自動コミットを無効化します(manual と等価)。
コミットポリシーは WalSyncPolicy と直交して
います。WalSyncPolicy が WAL の fsync 耐久性を制御するのに対し、
CommitPolicy はストアがバッファリングされた書き込みをいつ実体化するかを
制御します。両者は独立して設定します。
# 1000 ドキュメント適用ごとに自動コミット。
policy = Laurus::CommitPolicy.every_docs(1000)
index = Laurus::Index.new(path: "./myindex", commit_policy: policy)
Schema
Index のフィールドとインデックスタイプを定義します。
Laurus::Schema.new
フィールドメソッド
| メソッド | 説明 |
|---|---|
add_text_field(name, stored: true, indexed: true, term_vectors: true, doc_values: true, analyzer: nil, multi_valued: false, position_increment_gap: 100) | 全文フィールド(転置インデックス、BM25)。term_vectors: はタームの位置を保存するかどうかを制御し、フレーズクエリ・スパンクエリが読み取ります。doc_values: は値を DocValues(ソート・ファセット・集計が読み取る列指向ストア)にもコピーするかどうかを制御します(Issue #1047)。stored: true の場合のみ有効です。multi_valued: true で String の Array を受け付けます(Issue #1175): term クエリはいずれかの要素がタームを含めばマッチし、フレーズクエリは slop が position_increment_gap:(デフォルト 100。0 にすると要素を連結したものとして付番)に達しない限り 2 つの要素をまたぎません。値は String の Array として読み戻されます。analyzer: にはパラメータ不要の組込名("standard" / "english" / "keyword" / "simple" / "noop"、または add_analyzer で登録したカスタム名)を指定します。Lindera 辞書パスが必要な Japanese プリセットは、lindera tokenizer を含むカスタム analyzer として登録し、名前で参照してください。 |
add_integer_field(name, stored: true, indexed: true, multi_valued: false, doc_values: true) | 64 ビット整数フィールド。multi_valued: true で整数配列を受け付け(範囲クエリは “any match”)。doc_values: は上記を参照。 |
add_float_field(name, stored: true, indexed: true, multi_valued: false, doc_values: true) | 64 ビット浮動小数点フィールド。multi_valued: true で浮動小数点配列を受け付け(範囲クエリは “any match”)。doc_values: は上記を参照。 |
add_boolean_field(name, stored: true, indexed: true, multi_valued: false, doc_values: true) | ブールフィールド。multi_valued: true で true / false の Array を受け付け(flags:true のような term クエリはいずれかの要素が値と等しければマッチ。値は true / false の Array として読み戻されます)。doc_values: は上記を参照。 |
add_bytes_field(name, stored: true, multi_valued: false) | 生バイトフィールド。doc_values: オプションはありません —— Bytes の値は設定にかかわらず DocValues に一切書き込まれないためです。multi_valued: true を渡すと base64 String の Array を受け付け(Issue #1176)、単一の base64 String と同じ方法で要素ごとにデコードされます。Bytes はそもそもインデックスされないため、他の multi_valued: オプションと異なりクエリ一致の意味論はなく、保存時の形と取り込み時の許容個数を変えるだけです。値はスカラーフィールドと同様、(バイナリ)String の Array として読み戻されます。 |
add_geo_field(name, stored: true, indexed: true, multi_valued: false, doc_values: true) | 地理座標フィールド(緯度/経度)。multi_valued: true で { "lat" => .., "lon" => .. } Hash の Array を受け付け(距離 / バウンディングボックスクエリはいずれかのポイントが条件を満たせばマッチ)。doc_values: は上記を参照。 |
add_geo3d_field(name, stored: true, indexed: true, multi_valued: false, doc_values: true) | 3D ECEF カルテシアン座標フィールド(x, y, z はメートル)。multi_valued: true で { "x" => .., "y" => .., "z" => .. } Hash の Array を受け付け(距離 / バウンディングボックス / nearest クエリはいずれかのポイントが条件を満たせばマッチ)。詳細は Geo3d の概念。doc_values: は上記を参照。 |
add_datetime_field(name, stored: true, indexed: true, multi_valued: false, doc_values: true) | UTC 日時フィールド。multi_valued: true で Time / DateTime / RFC 3339 String の Array を受け付け(範囲クエリはいずれかの時刻が条件を満たせばマッチ。値は UTC の RFC 3339 String の Array として読み戻されます)。doc_values: は上記を参照。 |
add_hnsw_field(name, dimension, distance: "cosine", m: 16, ef_construction: 200, quantizer: nil, subvector_count: nil, rerank_storage: nil, embedder: nil, pq_codebook_path: nil, base_weight: 1.0) | HNSW 近似最近傍ベクトルフィールド。base_weight は他の vector フィールドと同時に検索されたときの相対的なスコアリング優先度(Issue #1084)。ウェイトを参照。 |
add_flat_field(name, dimension, distance: "cosine", embedder: nil, base_weight: 1.0) | Flat(総当たり)ベクトルフィールド。 |
add_ivf_field(name, dimension, distance: "cosine", n_clusters: 100, n_probe: 1, embedder: nil, base_weight: 1.0) | IVF 近似最近傍ベクトルフィールド。 |
add_multi_vector_field(name, dimension, distance: "cosine", storage: "f32", embedder: nil) | 文書ごとに可変個のトークンベクトル(ColBERT のトークンごとの埋め込みなど)を保持する MultiVector フィールド(Issue #1351)。値は数値の Array の Array で、トークンごとに要素数 dimension の Array を 1 つ持ちます。ANN 索引は持たず、late interaction による再採点だけが読み取ります(LateInteractionRescore を参照)。トークンベクトルは保存されないため、get_documents や検索結果にこのフィールドは含まれません。distance: は "cosine" か "dot_product"、dimension は 0 より大きい値でなければならず、どちらもフィールド追加時に検査されます(ArgumentError)。storage: は各トークンベクトルのディスク上の要素種別を指定します — "f32"(デフォルト、正確)、"f16"(2倍小さい)、"int8"(約4倍小さいが非可逆圧縮)のいずれかで、こちらもフィールド追加時に検査されます(ArgumentError)。embedder: にはトークン単位のエンベダー("candle_colbert" のもの。エンベダータイプを参照)の名前を指定し、テキストの値と再採点のクエリテキストを埋め込みます。MultiVector フィールドを参照。 |
ベクトル量子化とリランクストレージ(HNSW フィールド):
quantizer—"scalar_8bit"(デフォルト、4 倍圧縮)または高圧縮率の"product_quantization"。Product quantization ではsubvector_count(dimensionを割り切れる値)が必須です。rerank_storage—"f32"を指定すると完全精度の*.hnsw.f32サイドカーを書き出し、厳密な Stage-2 リランクを有効化します。省略すると int8 のみのセグメントを維持します。pq_codebook_path— 共有 PQ codebook のストレージ相対ファイル名(Issue #631)。laurus train pq-codebookCLI コマンドで一度だけ学習します。quantizer: "product_quantization"との組み合わせでのみ意味を持ち、以後の commit は segment ごとの k-means 再学習の代わりに学習済み codebook で encode します。省略すると segment ごとの学習を維持します。
上記のどの add_*_field メソッドも、name が _(_id を除く)で始まる場合は ArgumentError を発生させ、何も追加しない。from_toml / from_toml_file で読み込むスキーマはそのようなフィールドを引き続き受け付けるため、永続化済みのスキーマはそのまま読み込める。ただし、そのスキーマから新しい Index を作成すると ArgumentError になる。詳細はフィールド命名規則を参照。
その他のメソッド
| メソッド | 説明 |
|---|---|
add_embedder(name, config) | 名前付きエンベダー定義を登録します。config は "type" キーを持つ Hash で、キーは String / Symbol どちらでも構いません(下記参照)。type が無い・未知である、または必須キーが無い場合は ArgumentError(invalid embedder config: ...)になります。 |
add_analyzer(name, tokenizer, char_filters: nil, token_filters: nil) | カスタムアナライザ定義を登録します。tokenizer は必須、char_filters:/token_filters: は省略可能な Hash の配列です。各 Hash はスキーマ TOML/JSON 形式と同じ {type: "..."} 形式で、キーは String / Symbol どちらでも構いません(下記参照)。組み込みアナライザ用に予約された名前(standard、keyword、english、simple、noop)は ArgumentError になり、その名前を定義したスキーマ(from_toml で読み込んだものなど)から新しい Index を作る場合も同じです。正規表現の妥当性など意味的な検証は、このメソッド呼び出し時点ではなく Index 構築時に行われます。 |
analyzer_names -> Array<String> | add_analyzer で登録された、または TOML から読み込まれたカスタムアナライザの名前一覧を返します。 |
Laurus::Schema.from_toml(toml_str) -> Schema (クラスメソッド) | laurus-cli create index --schema と同じ形式の TOML 文字列からスキーマを読み込みます。 |
Laurus::Schema.from_toml_file(path) -> Schema (クラスメソッド) | TOML ファイルからスキーマを読み込みます。 |
to_toml -> String | このスキーマを laurus-cli と同じ形式の TOML 文字列にシリアライズします。 |
to_toml_file(path) | このスキーマを TOML ファイルに書き込みます。 |
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.14→3)。厳密さが必要なら"strict"を使用してください"ignore"— 未宣言フィールドを静かに破棄
詳細な挙動マトリクスは スキーマとフィールド を参照してください。
エンベダータイプ
各型の説明を含む正規のリファレンスは スキーマフォーマットリファレンス → エンベダー を参照してください。
"type" | 必須キー | Feature Flag |
|---|---|---|
"precomputed" | – | (常に利用可能) |
"candle_bert" | "model" | embeddings-candle |
"candle_clip" | "model" | embeddings-multimodal |
"openai" | "model" | embeddings-openai |
"candle_colbert" | "model" | embeddings-candle |
"candle_colbert" は、省略可能なキー "revision"、"query_maxlen"、"doc_maxlen" も受け付けます。トークンベクトルを生成するため、使えるのは MultiVector フィールド(add_multi_vector_field)だけです。キーは String / Symbol どちらでも構いません。
schema = Laurus::Schema.new
schema.add_embedder(
"colbert",
{ type: "candle_colbert", model: "colbert-ir/colbertv2.0", query_maxlen: 32 },
)
schema.add_multi_vector_field("body_colbert", 128, embedder: "colbert")
アナライザコンポーネント
add_analyzer(name, tokenizer, char_filters: nil, token_filters: nil) と
[analyzers.<name>] TOML セクションで使用します。tokenizer は単一の Hash、
char_filters:/token_filters: は Hash の配列で、配列の順序どおりに適用されます。
各コンポーネントの説明を含む正規のリファレンスは スキーマフォーマットリファレンス → アナライザ を参照してください。
トークナイザ(tokenizer、必ず1つ):
type | 必須キー | 省略可能キー |
|---|---|---|
"whitespace" | – | – |
"unicode_word" | – | – |
"regex" | – | pattern(デフォルト \w+)、gaps(デフォルト false) |
"ngram" | min_gram、max_gram | – |
"lindera" | mode、dict | user_dict |
"whole" | – | – |
文字フィルタ(char_filters、トークン化前の生テキストに適用):
type | 必須キー | 省略可能キー |
|---|---|---|
"unicode_normalization" | form("nfc"/"nfd"/"nfkc"/"nfkd") | – |
"pattern_replace" | pattern、replacement | – |
"mapping" | mapping(置換用の Hash) | – |
"japanese_iteration_mark" | – | kanji(デフォルト true)、kana(デフォルト true) |
トークンフィルタ(token_filters、トークン化後のトークン列に適用):
type | 必須キー | 省略可能キー |
|---|---|---|
"lowercase" | – | – |
"stop" | – | words(デフォルト: 英語のストップワード) |
"stem" | – | stem_type("porter"/"simple"/"identity") |
"boost" | boost | – |
"limit" | limit | – |
"strip" | – | – |
"remove_empty" | – | – |
"flatten_graph" | – | – |
schema = Laurus::Schema.new
schema.add_analyzer(
"ja_ipadic",
{ type: "lindera", mode: "normal", dict: "/var/lib/lindera/ipadic" },
char_filters: [
{ type: "unicode_normalization", form: "nfkc" },
{ type: "japanese_iteration_mark" },
],
token_filters: [{ type: "lowercase" }],
)
schema.add_text_field("title", analyzer: "ja_ipadic")
距離メトリクス
| 値 | 説明 |
|---|---|
"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 型から推論されます。
DateTimeRangeQuery
Laurus::DateTimeRangeQuery.new(field, min: nil, max: nil)
[min, max] の範囲内(両端を含む)の DateTime 値を検索します。開いた境界には nil を指定する(またはキーワードを省略する)と開放されます。境界は Query DSL が受け付ける任意の形式の String リテラル(RFC 3339 の "2024-01-01T09:00:00+09:00"(UTC に正規化)、オフセットなしの "YYYY-MM-DDTHH:MM:SS[.fff]"(UTC)、"YYYY-MM-DD"(その日の 0 時 UTC))、または iso8601 に応答する任意のオブジェクト(Time、DateTime)です。不正な境界は構築時に ArgumentError を発生させます。クエリオブジェクトを受け付ける場所(Index#search、BooleanQuery、SearchRequest)ならどこでも使用できます。
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,
highlight: nil,
rescore: nil,
)
| パラメータ | 説明 |
|---|---|
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)。 |
highlight: | Index#search の highlight: と同じ配列または Hash の形式(Issue #1134)。ハイライトを参照。 |
rescore: | リクエスト(lexical・vector・ハイブリッドのいずれも可)の上位の結果を並べ替える LateInteractionRescore(Issue #1351)。それ以外の値は TypeError になります。LateInteractionRescore を参照。 |
LateInteractionRescore
検索結果の上位を late interaction(ColBERT の MaxSim)で再採点します(Issue #1351)。Index#search または SearchRequest.new の rescore: に渡します。
Laurus::LateInteractionRescore.new(field, query, window_size: nil)
| パラメータ | 型 | デフォルト | 説明 |
|---|---|---|---|
field | String | – | MultiVector フィールド(add_multi_vector_field)。 |
query | String | Array<Array<Numeric>> | – | クエリテキスト(フィールドのトークン単位のエンベダー、つまり "candle_colbert" のものが埋め込みます)、またはクエリのトークンベクトル(文書のトークンベクトルと同じモデルで計算したもの)。要素には Integer も使えます。 |
window_size: | Integer | nil | nil(100) | 再採点する 1 段目の上位結果の件数。最大 10,000。nil のときは既定値の 100 になります。 |
| メソッド | 説明 |
|---|---|
window_size -> Integer | 再採点する上位結果の件数を返します。 |
inspect -> String | LateInteractionRescore(field="tokens", window_size=100) のような要約を返します。 |
1 段目の上位 window_size 件の結果を、フィールドに対する MaxSim で並べ替えます。再採点された結果の score はその MaxSim です。ウィンドウ外の結果は 1 段目の順序とスコアのまま、再採点された結果の後に続きます。順序の規則と類似度の詳細は ベクトル検索 → late interaction による再採点 を参照してください。
エラー: query が String でも数値の Array の Array でもない場合、コンストラクタが TypeError を発生させます。それ以外の値は検索時にエンジンが検査し、フィールドが存在しない・MultiVector フィールドでない、テキストクエリが空・フィールドにトークン単位のエンベダーが無い、クエリがフィールドの次元のトークンベクトルを 1〜1,024 個持たない、window_size が 1〜10,000 の範囲外、のいずれかの場合に、メッセージに rescore: ... を含む ArgumentError を発生させます。
schema = Laurus::Schema.new
schema.add_text_field("title")
schema.add_multi_vector_field("tokens", 2, distance: "dot_product")
index = Laurus::Index.new(schema: schema)
index.put_document("a", { "title" => "rust", "tokens" => [[0.1, 0.0]] })
index.put_document("b", { "title" => "rust language", "tokens" => [[0.9, 0.2]] })
index.commit
rescore = Laurus::LateInteractionRescore.new("tokens", [[1.0, 0.0], [0.0, 1.0]], window_size: 50)
results = index.search("title:rust", rescore: rescore)
results.map(&:id) # => ["b", "a"] -- "b" の MaxSim は 1.1、"a" は 0.1
# フィールドに "candle_colbert" のエンベダーがあれば、クエリをテキストで渡せる。
rescore = Laurus::LateInteractionRescore.new("body_colbert", "how do lifetimes work")
SearchResult
Index#search が返すクラスです。
result.id # => String -- 外部ドキュメント識別子
result.score # => Float -- 関連性スコア
result.document # => Hash|nil -- 取得されたフィールド値。削除済みの場合は nil
result.highlights # => Hash -- 要求したフィールドごとのハイライト済みフラグメント
highlights は highlight: で指定した各フィールドをハイライト済みフラグメント(最も良いものが先頭)にマッピングします。ハイライトされなかったフィールドは Hash に現れず、highlight: を要求しなかった場合 highlights は {} になります。詳細はハイライトを参照してください。
フュージョンアルゴリズム
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 -- 元テキスト内の UTF-8 バイト開始オフセット
token.end_offset # => Integer -- 元テキスト内の UTF-8 バイト終了オフセット
token.boost # => Float -- スコアブースト係数(1.0 = 調整なし)
token.stopped # => Boolean -- ストップフィルターによって除去されたかどうか
token.position_increment # => Integer -- 前のトークンの位置との差分
token.position_length # => Integer -- このトークンがカバーする位置数
token.token_type # => String または nil -- トークン種別(例: "alphanum")
オフセットは文字数ではなくバイト数です。非 ASCII のテキストではバイト単位で切り出します: text.byteslice(token.start_offset...token.end_offset)。
token_type は "alphanum"、"num"、"cjk"、"katakana"、"hiragana"、"hangul"、"punctuation"、"whitespace"、"synonym"、"email"、"url"、"other" のいずれか、または nil です。SynonymGraphFilter#apply は各トークンのオフセットと種別を保ち、挿入する同義語には種別 "synonym" と、置き換える語のオフセットを付けます。
フィールド値の型マッピング
Ruby の値は自動的に Laurus の DataValue 型に変換されます:
| Ruby 型 | Laurus 型 | 備考 |
|---|---|---|
nil | Null | |
true / false | Bool | |
Integer | Int64 | |
Float | Float64 | |
String | Text | |
Array(Integer) | Int64Array | 多値整数フィールド。ベクトルフィールドでは配列を f32 にキャスト。空の Array は空の Int64Array |
Array(数値) | Float64Array | 多値浮動小数点フィールド(整数は拡張)。ベクトルフィールドでは配列を f32 にキャスト |
Array(数値の Array の配列) | VectorArray | MultiVector フィールド(add_multi_vector_field)のトークンベクトル。トークンごとにフィールドの次元の Array を 1 つ持つ(Issue #1351)。Integer の要素も可。true や String などそれ以外の要素は TypeError。get_documents や検索結果には返されない |
Hash("lat", "lon") | Geo | 2 つの Float 値 |
Hash("x", "y", "z") | GeoEcef | 3 つの Float 値(メートル単位、3D ECEF 直交座標) |
Array("lat", "lon" を持つ Hash の配列) | GeoArray | フィールドに multi_valued: true が必要 |
Array("x", "y", "z" を持つ Hash の配列) | GeoEcefArray | フィールドに multi_valued: true が必要 |
Time / String(iso8601 に応答) | DateTime | iso8601 経由で変換 |
Array(Time、または iso8601 に応答する他のオブジェクトを含む) | DateTimeArray | 各要素を RFC 3339 としてパース(iso8601 に応答するオブジェクトは先に変換)。日時でない要素は ArgumentError。フィールドに multi_valued: true が必要 |
Array(String) | DateTimeArray または TextArray | 全要素が RFC 3339 としてパースできれば DateTimeArray、そうでなければ TextArray(Issue #1175。String の Array として読み戻される)。フィールドに multi_valued: true が必要。宣言済みの多値 Bytes フィールドでは、同じ base64 String の Array が要素ごとにデコードされる(Issue #1176) |
Array(true / false) | BoolArray | 全要素が true または false であること。[true, 1] のような混在 Array は TypeError。フィールドに multi_valued: true が必要 |
開発環境のセットアップ
このページでは 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-ruby | Bundler install + bundle exec rake compile(リリース gem) |
make test-laurus-ruby | Rust 単体テスト + Ruby minitest |
make lint-laurus-ruby | Clippy(-D warnings) |
make format-laurus-ruby | cargo 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 らしい API –
Laurus\名前空間の直感的な 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"]);
}
セクション
- インストール – エクステンションのインストール方法
- クイックスタート – サンプルによるハンズオン入門
- API リファレンス – クラスとメソッドの完全リファレンス
- 開発 – ソースからのビルドとテスト実行
インストール
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();
// ファイルベースインデックス(永続的)
// `./myindex/schema.toml` と `./myindex/store/` を書き込む -- これは
// `laurus-cli create index --schema` と同じレイアウトなので、このディレクトリは
// CLI からも開ける(逆も同様)。
$schema = new Schema();
$schema->addTextField("title");
$schema->addTextField("body");
$index = new Index("./myindex", $schema);
// 後で再オープンする際はパスだけで済む -- $schema を再度渡すとエラーになる
// (スキーマは既に永続化されているため)。
$index = new Index("./myindex");
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. Late interaction による再採点
late interaction による再採点は、どの検索でも上位の結果を、MultiVector フィールドに対する ColBERT 型の MaxSim で並べ替えます。MultiVector フィールドは、文書ごとに可変本数のトークンベクトルを保持します。
<?php
use Laurus\Index;
use Laurus\Schema;
$schema = new Schema();
$schema->addTextField("title");
$schema->addMultiVectorField("tokens", 2, "dot_product");
$index = new Index(null, $schema);
$index->putDocument("doc1", ["title" => "Rust", "tokens" => [[0.1, 0.0]]]);
$index->putDocument("doc2", ["title" => "Rust language", "tokens" => [[0.9, 0.2], [0.0, 0.3]]]);
$index->commit();
// "title:rust" の上位 100 件(デフォルト)を MaxSim で並べ替える
$results = $index->search("title:rust", 10, 0, null, new Laurus\LateInteractionRescore("tokens", [[1.0, 0.0], [0.0, 1.0]]));
再採点した結果のスコアは MaxSim の値です。トークンベクトルの代わりにテキストでクエリを渡すには、candle_colbert エンベダー(Cargo の feature embeddings-candle)を addEmbedder で登録し、addMultiVectorField の $embedder に指定します。詳細は LateInteractionRescore を参照してください。
7. 更新と削除
// 更新: 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();
8. スキーマ管理
$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);
$schema->addMultiVectorField("colbert", 128);
9. インデックス統計
$stats = $index->stats();
echo $stats["documentCount"];
echo $stats["vectorFields"];
API リファレンス
Index
Laurus 検索エンジンをラップするメインクラスです。
new \Laurus\Index(?string $path = null, ?Schema $schema = null, ?WalSyncPolicy $wal_sync_policy = null, ?CommitPolicy $commit_policy = null)
コンストラクタ
| パラメータ | 型 | デフォルト | 説明 |
|---|---|---|---|
$path | string|null | null | 永続ストレージのディレクトリパス。null の場合はインメモリインデックスを作成します。指定した場合、そのディレクトリは laurus-cli create index/--index-dir と同じ <path>/schema.toml + <path>/store/ というレイアウトに従うため、ここで作成したインデックスは CLI からも開けます(逆も同様)。詳細は下記を参照。 |
$schema | Schema|null | null | スキーマ定義。新規にファイルベース(またはインメモリ)インデックスを作成する場合のみ意味を持ちます。既存のファイルベースインデックスを再オープンする場合は省略(null)する必要があり、永続化済みのスキーマが代わりに読み込まれます。新規インデックスで省略した場合は空のスキーマが使用されます。 |
$wal_sync_policy | WalSyncPolicy|null | null | 先行書き込みログ(WAL)の耐久性ポリシー。null の場合はデフォルトのレコードごと fsync を維持します。WAL 同期ポリシーと耐久性 を参照。 |
$commit_policy | CommitPolicy|null | null | 自動コミットポリシー。null の場合はデフォルトの manual ポリシー(呼び出し側がすべての commit() を駆動)を維持します。コミットポリシーと自動コミット を参照。 |
ファイルベースインデックスの作成 vs 再オープン($path を指定した場合): <path>/schema.toml がまだ存在しない場合、この呼び出しは新規インデックスを作成し、$schema(省略時は空のスキーマ)をそこに永続化します。<path>/schema.toml が既に存在する場合、この呼び出しは既存インデックスを再オープンします – $schema は省略しなければならず、指定すると ValueError が投げられます(どちらのスキーマを優先すべきか曖昧になるため)。$path がこの規約導入以前のレイアウト(schema.toml が無く、セグメントファイルが $path 直下にある)のインデックスを含んでいる場合も ValueError になります。
メソッド
| メソッド | 説明 |
|---|---|
putDocument(string $id, array $doc): void | ドキュメントをアップサート(upsert)します。同じ ID の既存バージョンをすべて置換します。 |
addDocument(string $id, array $doc): void | 既存バージョンを削除せずにドキュメントチャンクを追記します。 |
putDocuments(array $docs): void | バッチ upsert。$docs は [$id, $doc] ペアの配列で、バッチごとに WAL fsync 1 回で順に適用します(重複 ID はデデュープ、最後が勝ち)。最初の不正エントリで fail-fast し、適用済みの prefix はロールバックされません。 |
addDocuments(array $docs): void | バッチチャンク追記。putDocuments と同様ですが、繰り返した ID は別バージョンとして蓄積されます。 |
getDocuments(string $id): array | 指定 ID の全保存バージョンを返します。 |
deleteDocuments(string $id): void | 指定 ID の全バージョンを削除します。 |
commit(): void | バッファリングされた書き込みをフラッシュし、すべての保留中の変更を検索可能にします。 |
flushWal(): void | WAL の耐久バリアをオンデマンドで強制します。未同期の WAL レコードを同期的に fsync します。group-commit ポリシー下で実行する場合に有用です(下記参照)。 |
search(mixed $query, int $limit = 10, int $offset = 0, ?array $highlight = null, ?LateInteractionRescore $rescore = null): array | 検索クエリを実行します。SearchResult の配列を返します。$rescore は上位の結果を MultiVector フィールドに対する late interaction の MaxSim で並べ替えます(Issue #1351)。LateInteractionRescore を参照。$rescore にそれ以外のオブジェクトを渡すと \TypeError になります。$query が SearchRequest の場合は、リクエスト自身の $limit/$offset/$highlight/$rescore を使い、他の引数は無視されます。 |
searchBatch(array $queries, int $limit = 10, int $offset = 0, ?array $highlight = null): array | 独立した複数の検索を 1 回の呼び出しで実行します。各クエリは内部の tokio ランタイム上で並列に dispatch されます。results[i] は queries[i] に対応し、SearchResult の配列の配列を返します。入力が空の配列の場合は [] を返します。$highlight はバッチ内のすべてのクエリに同一に適用されます。$rescore パラメータはありません。SearchRequest の要素は、その要素自身の $rescore で再採点されます。 |
stats(): array | インデックス統計("documentCount"、"vectorFields")を返します。 |
search の query 引数
$query パラメータは以下のいずれかを受け付けます:
- DSL 文字列(例:
"title:hello"、"embedding:\"memory safety\"") - Lexical クエリオブジェクト(
TermQuery、PhraseQuery、BooleanQueryなど) - Vector クエリオブジェクト(
VectorQuery、VectorTextQuery) SearchRequest(完全な制御が必要な場合)
searchBatch の $queries 配列の各要素も同じ種類の値を受け付けます。DSL 文字列・クエリオブジェクト・SearchRequest を 1 つのバッチ内で混在させることもできます。
ハイライト
search/searchBatch の $highlight パラメータ(Issue #1134)は、各ヒットの SearchResult::getHighlights() にフィールドごとのハイライト済みフラグメントを要求します。以下のいずれかを受け付けます。
- フィールド名のリスト:
["body"] - 連想配列: 必須の
"fields"キーに加えて、HighlightConfigの任意の設定(max_fragments、fragment_size、tag、css_class、require_field_match、max_analyzed_chars、return_entire_field_if_no_highlight)を指定 — 例:["fields" => ["body"], "tag" => "em", "max_fragments" => 2]
$results = $index->search("body:rust", 10, 0, ["body"]);
$results[0]->getHighlights(); // ["body" => ["<mark>Rust</mark> is a systems programming language."]]
ハイライトは search/searchBatch に渡したクエリ(または SearchRequest の $query/$lexicalQuery、下記参照)に従い、stored: true のテキストフィールドのみハイライト可能です。存在しない、保存されていない、テキスト型でないフィールドは黙ってスキップされます。$highlight を省略すると、すべての結果のハイライトは空のままになります。同じ highlight 引数は SearchRequest のコンストラクタでも使用できます。
WAL 同期ポリシーと耐久性
先行書き込みログ(WAL: Write-Ahead Log)は、コミット済みデータをクラッシュ
から保護します。デフォルトでは WAL は完全に耐久的で、すべてのレコードは
書き込みが返る前に fsync されます。group commit(グループコミット)
を有効にすると、fsync 呼び出しをまとめることで、耐久性をいくらか引き換えに
書き込みスループットを向上させられます。
WalSyncPolicy
Laurus\WalSyncPolicy は WAL のフラッシュ方法を記述するイミュータブルな
値オブジェクトです。Index コンストラクタの $wal_sync_policy 引数に渡します。
// デフォルト: 書き込みごとに耐久(各レコードを個別に fsync)。
\Laurus\WalSyncPolicy::perRecord(): WalSyncPolicy
// Group commit: fsync をまとめてコストを償却。
\Laurus\WalSyncPolicy::group(
?int $max_records = null, // このレコード数でフラッシュ(デフォルト 1024)
?int $max_bytes = null, // このバイト数でフラッシュ(デフォルト 1 MiB)
?int $max_interval_ms = null, // このミリ秒ごとに定期的にもフラッシュ
): WalSyncPolicy
| コンストラクタ | 説明 |
|---|---|
WalSyncPolicy::perRecord() | デフォルト。すべてのレコードは書き込みが返る前に fsync されます。書き込みごとに完全に耐久的です。 |
WalSyncPolicy::group($max_records, $max_bytes, $max_interval_ms) | fsync をまとめます。すべての引数が null の場合はデフォルト(max_records = 1024、max_bytes = 1 MiB、タイマーなし)を使用します。WAL は $max_records または $max_bytes のいずれかが蓄積したとき、および毎回の commit() 時にフラッシュされます。$max_interval_ms を指定すると、定期タイマーでもフラッシュします。 |
Group commit は SQLite の synchronous = NORMAL に相当します。クラッシュ時に
失われるのは最後の未同期バッチのレコードまでで、インデックスが破損する
ことはありません。レコードは常に commit() 時に耐久化されるため、成功した
commit() はポリシーに関わらず耐久バリアとなります。
フラッシュの強制
コミットの合間に耐久バリアを強制するには flushWal() を呼び出します。
例えば、バッチが安全に永続化されたことを通知する前などです。未同期の
レコードを同期的に fsync します。デフォルトのレコードごとポリシーでは
実質的に no-op です。
// group commit を有効にし、必要に応じて耐久性を強制する。
$policy = \Laurus\WalSyncPolicy::group(4096, 4 * 1024 * 1024);
$index = new \Laurus\Index("./myindex", null, $policy);
$index->putDocument("doc1", ["title" => "Hello"]);
$index->flushWal(); // group バッチが満杯でなくてもレコードが永続化される
コミットポリシーと自動コミット
コミットは、バッファリングされた書き込みを Lexical ストアと Vector ストアに
実体化(materialise)し、保留中の変更を検索可能にします。デフォルトでは
Laurus が自動でコミットすることはなく、呼び出し側がすべての commit() を
駆動します。代わりに、適用したドキュメント数が一定に達するたびに、あるいは
一定の時間間隔ごとに、エンジンに **自動コミット(auto-commit)**させることも
できます。
CommitPolicy
Laurus\CommitPolicy はエンジンがいつコミットするかを記述するイミュータブルな
値オブジェクトです。Index コンストラクタの $commit_policy 引数に渡します。
// デフォルト: 自動コミットなし — 呼び出し側がすべての commit() を駆動。
\Laurus\CommitPolicy::manual(): CommitPolicy
// 適用したドキュメント N 件ごとに自動コミット。
\Laurus\CommitPolicy::everyDocs(
int $n, // このドキュメント数を適用するたびにコミット
): CommitPolicy
// 少なくとも N ミリ秒ごとに自動コミット(ネイティブ専用。wasm では no-op)。
\Laurus\CommitPolicy::intervalMs(
int $ms, // 少なくともこの間隔(ミリ秒)でコミット
): CommitPolicy
| コンストラクタ | 説明 |
|---|---|
CommitPolicy::manual() | デフォルト。エンジンは自動でコミットせず、呼び出し側がすべての commit() を駆動します。 |
CommitPolicy::everyDocs($n) | 適用したドキュメント $n 件ごとに自動コミットします。カウントは単一 ingest とバッチ ingest の両方にまたがり、バッチ 内 でも $n 件ごとにトリガーされます。 |
CommitPolicy::intervalMs($ms) | バックグラウンドタイマーにより、少なくとも $ms ミリ秒ごとに自動コミットします。ingest がアイドル状態でも、末尾の部分バッチがコミットされます。everyDocs の時間ベース版です。デフォルト: なし。ネイティブ専用 — wasm では no-op です(WebAssembly にはバックグラウンドスレッドがありません)。値は構築されますが、タイマーによるコミットは発生しません。 |
CommitPolicy::everyDocs(0) は有効で、自動コミットを無効化します。
CommitPolicy::manual() と等価です。
コミットポリシーは WAL 同期ポリシーと**直交(orthogonal)**しています。
WalSyncPolicy は耐久性のために WAL をいつ fsync するかを制御するのに対し、
CommitPolicy はストアをいつ実体化し、保留中の変更をいつ検索可能にするかを
制御します。両者は独立して設定します。
// 適用したドキュメント 1000 件ごとに自動コミットし、WAL ポリシーはデフォルトを維持。
$index = new \Laurus\Index(null, $schema, null, \Laurus\CommitPolicy::everyDocs(1000));
foreach ($docs as $id => $doc) {
$index->putDocument($id, $doc); // エンジンが 1000 件ごとに自動でコミットする
}
Schema
Index のフィールドとインデックスタイプを定義します。
new \Laurus\Schema()
フィールドメソッド
| メソッド | 説明 |
|---|---|
addTextField(string $name, bool $stored = true, bool $indexed = true, bool $termVectors = true, bool $docValues = true, ?string $analyzer = null, bool $multiValued = false, int $positionIncrementGap = 100): void | 全文フィールド(転置インデックス、BM25)。$docValues は値を DocValues(ソート・ファセット・集計が読み取る列指向ストア)にもコピーするかどうかを制御します(Issue #1047)。$stored も true の場合のみ有効です。$multiValued = true で文字列のシーケンシャル配列を受け付けます(Issue #1175): term クエリはいずれかの要素がタームを含めばマッチし、フレーズクエリは slop が $positionIncrementGap(デフォルト 100。0 にすると要素を連結したものとして付番)に達しない限り 2 つの要素をまたぎません。値は文字列の配列として読み戻されます。$analyzer にはパラメータ不要の組込名("standard" / "english" / "keyword" / "simple" / "noop")、または addAnalyzer で登録済みの任意のカスタム名(Japanese/Lindera アナライザーなど)を指定できます。 |
addIntegerField(string $name, bool $stored = true, bool $indexed = true, bool $multiValued = false, bool $docValues = true): void | 64 ビット整数フィールド。$multiValued = true で整数配列を受け付け(範囲クエリは “any match”)。$docValues は上記を参照。 |
addFloatField(string $name, bool $stored = true, bool $indexed = true, bool $multiValued = false, bool $docValues = true): void | 64 ビット浮動小数点フィールド。$multiValued = true で浮動小数点配列を受け付け(範囲クエリは “any match”)。$docValues は上記を参照。 |
addBooleanField(string $name, bool $stored = true, bool $indexed = true, bool $multiValued = false, bool $docValues = true): void | ブールフィールド。$multiValued = true で bool のシーケンシャル配列を受け付け(flags:true のような term クエリはいずれかの要素が値と等しければマッチ。値は bool の配列として読み戻されます)。$docValues は上記を参照。 |
addBytesField(string $name, bool $stored = true, bool $multiValued = false): void | 生バイトフィールド。$docValues オプションはありません —— Bytes の値は設定にかかわらず DocValues に一切書き込まれないためです。$multiValued = true を渡すと base64 文字列のシーケンシャル配列を受け付け(Issue #1176)、単一の base64 文字列と同じ方法で要素ごとにデコードされます。Bytes はそもそもインデックスされないため、他の $multiValued オプションと異なりクエリ一致の意味論はなく、保存時の形と取り込み時の許容個数を変えるだけです。値はスカラーフィールドと同様、バイナリ文字列の配列として読み戻されます。 |
addGeoField(string $name, bool $stored = true, bool $indexed = true, bool $multiValued = false, bool $docValues = true): void | 地理座標フィールド(緯度/経度)。$multiValued = true で ["lat" => .., "lon" => ..] 配列の配列を受け付け(距離 / バウンディングボックスクエリはいずれかのポイントが条件を満たせばマッチ)。$docValues は上記を参照。 |
addGeo3dField(string $name, bool $stored = true, bool $indexed = true, bool $multiValued = false, bool $docValues = true): void | 3D ECEF カルテシアン座標フィールド(x, y, z はメートル)。$multiValued = true で ["x" => .., "y" => .., "z" => ..] 配列の配列を受け付け(距離 / バウンディングボックス / nearest クエリはいずれかのポイントが条件を満たせばマッチ)。詳細は Geo3d の概念。$docValues は上記を参照。 |
addDatetimeField(string $name, bool $stored = true, bool $indexed = true, bool $multiValued = false, bool $docValues = true): void | UTC 日時フィールド。$multiValued = true で RFC 3339 文字列のシーケンシャル配列を受け付け(範囲クエリはいずれかの時刻が条件を満たせばマッチ。値は UTC に正規化した RFC 3339 文字列の配列として読み戻されます)。$docValues は上記を参照。 |
addHnswField(string $name, int $dimension, ?string $distance = "cosine", int $m = 16, int $efConstruction = 200, ?int $defaultEfSearch = null, ?string $embedder = null, ?string $quantizer = null, ?int $subvectorCount = null, ?string $rerankStorage = null, ?string $pqCodebookPath = null, float $baseWeight = 1.0): void | HNSW 近似最近傍ベクトルフィールド。$baseWeight は他の vector フィールドと同時に検索されたときの相対的なスコアリング優先度(Issue #1084)。ウェイトを参照。 |
addFlatField(string $name, int $dimension, ?string $distance = "cosine", ?string $embedder = null, float $baseWeight = 1.0): void | Flat(総当たり)ベクトルフィールド。 |
addIvfField(string $name, int $dimension, ?string $distance = "cosine", int $nClusters = 100, int $nProbe = 1, ?string $embedder = null, float $baseWeight = 1.0): void | IVF 近似最近傍ベクトルフィールド。 |
addMultiVectorField(string $name, int $dimension, ?string $distance = null, ?string $embedder = null, ?string $storage = null): void | 文書ごとに可変本数のトークンベクトルを保持する MultiVector フィールド。late interaction による再採点が読み取ります(Issue #1351)。MultiVector フィールドを参照。$dimension は各トークンベクトルの長さで、正の値でなければなりません。$distance は "cosine"(デフォルト)または "dot_product" です。どちらもフィールドの追加時にチェックされます(\ValueError)。$storage は各トークンベクトルのディスク上の要素種別を指定します(Issue #1346)— "f32"(デフォルト、正確)、"f16"(2倍小さい)、"int8"(約4倍小さい)のいずれかで、こちらもフィールドの追加時にチェックされます(\ValueError)。$embedder にはトークン単位のエンベダー("candle_colbert" のもの)の名前を指定し、フィールドのテキスト値と再採点のクエリテキストを埋め込みます。このフィールドはベクトル検索の対象にならず、トークンベクトルは保存されません。getDocuments や検索結果には含まれません。 |
ベクトル量子化とリランクストレージ(HNSW フィールド):
quantizer—"scalar_8bit"(デフォルト、4 倍圧縮)または高圧縮率の"product_quantization"。Product quantization ではsubvectorCount(dimensionを割り切れる値)が必須です。rerankStorage—"f32"を指定すると完全精度の*.hnsw.f32サイドカーを書き出し、厳密な Stage-2 リランクを有効化します。省略すると int8 のみのセグメントを維持します。pqCodebookPath— 共有 PQ codebook のストレージ相対ファイル名(Issue #631)。laurus train pq-codebookCLI コマンドで一度だけ学習します。$quantizer = "product_quantization"との組み合わせでのみ意味を持ち、以後の commit は segment ごとの k-means 再学習の代わりに学習済み codebook で encode します。省略すると segment ごとの学習を維持します。
上記のどの add*Field メソッドも、name が _(_id を除く)で始まる場合は \ValueError を投げ、フィールドを追加しない。fromToml / fromTomlFile で読み込んだスキーマはそのようなフィールドを引き続き受け付けるため、永続化済みのスキーマも読み込めるが、そこから新しい Index を作成すると \ValueError になる。詳細はフィールド命名規則を参照。
その他のメソッド
| メソッド | 説明 |
|---|---|
addEmbedder(string $name, array $config): void | 名前付きエンベダー定義を登録します。$config は "type" キーを持つ連想配列で(下記参照)、スキーマ TOML 形式と同じ規則でデコードされます。型が無い・未知の型である・必須キーが無い場合は \Exception(invalid embedder config: ...)を投げます。 |
addAnalyzer(string $name, array $tokenizer, ?array $charFilters = null, ?array $tokenFilters = null): void | カスタムアナライザー定義を登録します。$tokenizer は必須、$charFilters/$tokenFilters は連想配列の配列で省略可能です。各要素はスキーマ TOML/JSON 形式と同じ {"type": "...", ...} の形を使います(下記参照)。組み込みアナライザー用に予約された名前(standard、keyword、english、simple、noop)は \ValueError になり、その名前を定義したスキーマ(fromToml で読み込んだものなど)から新しい Index を作る場合も同じです。正規表現の構文誤りなどの意味的な妥当性は、このメソッド呼び出し時ではなく、スキーマから Index を構築する際にチェックされます。 |
analyzerNames(): array | addAnalyzer で登録済み、または TOML から読み込んだカスタムアナライザー名の一覧を返します。 |
Schema::fromToml(string $tomlStr): Schema | laurus-cli create index --schema と同じ形式の TOML 文字列からスキーマを読み込みます。TOML がスキーマとして正しくない場合は ValueError を投げます。 |
Schema::fromTomlFile(string $path): Schema | TOML ファイルからスキーマを読み込みます。ファイルを読めない場合はパスで始まるメッセージの Exception を、内容がスキーマとして正しくない場合は ValueError を投げます。 |
toToml(): string | このスキーマを laurus-cli と同じ形式の TOML 文字列にシリアライズします。テーブルはキーの昇順で出力されるため、往復させたスキーマはテキストではなく内容で比較してください。 |
toTomlFile(string $path): void | このスキーマを TOML ファイルに書き込みます。既存のファイルは上書きします。書き込めない場合はパスで始まるメッセージの Exception を投げます。 |
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.14→3)。厳密さが必要なら"strict"を使用してください"ignore"— 未宣言フィールドを静かに破棄
詳細な挙動マトリクスは スキーマとフィールド を参照してください。
エンベダータイプ
各型の説明を含む正規のリファレンスは スキーマフォーマットリファレンス → エンベダー を参照してください。
"type" | 必須キー | 任意キー | Feature Flag |
|---|---|---|---|
"precomputed" | – | – | (常に利用可能) |
"candle_bert" | "model" | – | embeddings-candle |
"candle_clip" | "model" | – | embeddings-multimodal |
"openai" | "model" | – | embeddings-openai |
"candle_colbert" | "model" | "revision"、"query_maxlen"、"doc_maxlen" | embeddings-candle |
"candle_colbert" はトークンごとに 1 本のベクトルを出力するため、MultiVector フィールド(addMultiVectorField)専用です。
$schema->addEmbedder("colbert", ["type" => "candle_colbert", "model" => "colbert-ir/colbertv2.0"]);
$schema->addMultiVectorField("body_colbert", 128, null, "colbert");
アナライザーコンポーネント
addAnalyzer(string $name, array $tokenizer, ?array $charFilters = null, ?array $tokenFilters = null)
および [analyzers.<name>] TOML セクションで使用します。$tokenizer は単一の
連想配列、$charFilters/$tokenFilters は連想配列の配列で、配列の順序で
適用されます。
各コンポーネントの説明を含む正規のリファレンスは スキーマフォーマットリファレンス → アナライザ を参照してください。
トークナイザー($tokenizer、必ず1つ):
"type" | 必須キー | 任意キー |
|---|---|---|
"whitespace" | – | – |
"unicode_word" | – | – |
"regex" | – | "pattern"(デフォルト \w+)、"gaps"(デフォルト false) |
"ngram" | "min_gram", "max_gram" | – |
"lindera" | "mode", "dict" | "user_dict" |
"whole" | – | – |
Char filter($charFilters、トークン化前の生テキストに適用):
"type" | 必須キー | 任意キー |
|---|---|---|
"unicode_normalization" | "form"("nfc"/"nfd"/"nfkc"/"nfkd") | – |
"pattern_replace" | "pattern", "replacement" | – |
"mapping" | "mapping"(文字列置換の連想配列) | – |
"japanese_iteration_mark" | – | "kanji"(デフォルト true)、"kana"(デフォルト true) |
Token filter($tokenFilters、トークン化後のトークン列に適用):
"type" | 必須キー | 任意キー |
|---|---|---|
"lowercase" | – | – |
"stop" | – | "words"(デフォルト: 英語のストップワード) |
"stem" | – | "stem_type"("porter"/"simple"/"identity") |
"boost" | "boost" | – |
"limit" | "limit" | – |
"strip" | – | – |
"remove_empty" | – | – |
"flatten_graph" | – | – |
$schema = new Laurus\Schema();
$schema->addAnalyzer(
"ja_ipadic",
["type" => "lindera", "mode" => "normal", "dict" => "/var/lib/lindera/ipadic"],
[
["type" => "unicode_normalization", "form" => "nfkc"],
["type" => "japanese_iteration_mark"],
],
[["type" => "lowercase"]],
);
$schema->addTextField("title", analyzer: "ja_ipadic");
距離メトリクス
| 値 | 説明 |
|---|---|
"cosine" | コサイン類似度(デフォルト) |
"euclidean" | ユークリッド距離 |
"dot_product" | 内積 |
"manhattan" | マンハッタン距離 |
"angular" | 角度距離 |
addMultiVectorField が受け付けるのは "cosine" と "dot_product" だけです。
クエリクラス
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" を設定します。
DateTimeRangeQuery
new \Laurus\DateTimeRangeQuery(string $field, ?string $min = null, ?string $max = null)
[$min, $max] の範囲内(両端を含む)の DateTime 値を検索します。開いた境界には null を指定します。境界は Query DSL が受け付ける任意の形式の文字列リテラルです: RFC 3339("2024-01-01T09:00:00+09:00"、UTC に正規化)、オフセットなしの "YYYY-MM-DDTHH:MM:SS[.fff]"(UTC)、または "YYYY-MM-DD"(その日の 0 時 UTC)。DateTimeInterface は $dt->format(DATE_RFC3339) で渡します。ext-php-rs のコンストラクタは失敗できないため、不正な境界はクエリの使用時(Index::search、BooleanQuery、SearchRequest)に \Throwable として報告されます。
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,
?array $highlight = null,
?LateInteractionRescore $rescore = null,
)
| パラメータ | 説明 |
|---|---|
$query | DSL 文字列または単一クエリオブジェクト。$lexicalQuery / $vectorQuery と排他的。 |
$lexicalQuery | 明示的なハイブリッド検索の Lexical コンポーネント。 |
$vectorQuery | 明示的なハイブリッド検索の Vector コンポーネント。 |
$filterQuery | スコアリング後に適用する Lexical フィルター。 |
$fusion | フュージョンアルゴリズム(RRF または WeightedSum)。両コンポーネント指定時のデフォルトは RRF(k: 60)。 |
$limit | 最大結果件数(デフォルト 10)。 |
$offset | ページネーションオフセット(デフォルト 0)。 |
$highlight | Index->search() の $highlight と同じリストまたは連想配列の形式(Issue #1134)。ハイライトを参照。$limit/$offset 以外は PHP レベルのデフォルトを持たないため、$highlight を名前付き引数で渡す場合もそれ以前の引数はすべて位置または名前で渡す必要があります。 |
$rescore | 上位の結果を並べ替える LateInteractionRescore(Issue #1351)。それ以外のオブジェクトは \TypeError になります。LateInteractionRescore を参照。$highlight と同じく、それ以前の引数もすべて渡す必要があります。 |
SearchRequest を Index->search() に渡すと、リクエスト自身の $limit・$offset・$highlight・$rescore が使われ、search の他の引数は無視されます。
LateInteractionRescore
上位の検索結果を late interaction(ColBERT の MaxSim)で再採点します(Issue #1351)。Index->search() の 5 番目の引数、または SearchRequest の $rescore に渡します。仕組みは Vector 検索 → Late Interaction による再採点 を参照してください。
new \Laurus\LateInteractionRescore(string $field, string|array $query, ?int $windowSize = null)
| パラメータ | 型 | デフォルト | 説明 |
|---|---|---|---|
$field | string | – | MultiVector フィールド(addMultiVectorField)。 |
$query | string|array | – | フィールドのトークン単位のエンベダー("candle_colbert" のもの)が埋め込むクエリテキスト、またはクエリのトークンベクトルを数値リストのリストで渡したもの(例: [[1.0, 0.0], [0.0, 1.0]]。整数は拡張されます)。 |
$windowSize | int|null | null(100) | 再採点する 1 段目の上位結果の件数。最大 10,000。 |
メソッド
| メソッド | 説明 |
|---|---|
getWindowSize(): int | 再採点する上位結果の件数を返します($windowSize を指定しなければ 100)。 |
__toString(): string | LateInteractionRescore(field="tokens", window_size=100) のような文字列表現を返します。 |
並び順とスコア
1 段目(lexical・vector・ハイブリッド)の上位 $windowSize 件を、フィールドに対する MaxSim で並べ替えます。再採点した結果の getScore() は MaxSim の値です。window の外の結果は 1 段目の順位とスコアのまま、再採点した結果の後に続きます(フィールドにトークンベクトルを持たない window 内の結果は、両者の間に 1 段目の順で並びます)。2 種類のスコアは比較できません。
エラー
- コンストラクタは、
$queryが文字列でも数値リストのリストでもない場合に\TypeError(query must be a string or a list of numeric lists)を、トークンベクトルがboolやstringなど数値以外を含む場合に\Exception(token vector N must hold only numbers)を投げます。 Index->search()とSearchRequestは、$rescoreにそれ以外のオブジェクトを渡すと\TypeError(rescore must be a Laurus\LateInteractionRescore)を投げます。- それ以外の値は検索時、検索を始める前にエンジンがチェックし、
rescore: ...を含むメッセージの\ValueErrorを投げます。フィールドが存在しないか MultiVector フィールドでない場合、クエリがフィールドの次元を持つ有限値のベクトルを 1〜1,024 本含まない場合、テキストのクエリが空かフィールドにトークン単位のエンベダーがない場合、$windowSizeが1..=10,000の範囲外の場合です。
例
$schema = new Laurus\Schema();
$schema->addTextField("title");
$schema->addMultiVectorField("tokens", 2, "dot_product");
$index = new Laurus\Index(null, $schema);
$index->putDocument("a", ["title" => "rust", "tokens" => [[0.1, 0.0]]]);
$index->putDocument("b", ["title" => "rust language", "tokens" => [[0.9, 0.2]]]);
$index->commit();
$rescore = new Laurus\LateInteractionRescore("tokens", [[1.0, 0.0], [0.0, 1.0]], 50);
$results = $index->search("title:rust", 10, 0, null, $rescore);
$results[0]->getId(); // "b"
$results[0]->getScore(); // ≈ 1.1 = 0.9 + 0.2(MaxSim)
// SearchRequest で同じ再採点を行う(9 番目の引数)
$results = $index->search(new Laurus\SearchRequest("title:rust", null, null, null, null, 10, 0, null, $rescore));
// テキストのクエリには、フィールドにトークン単位のエンベダーが必要(「エンベダータイプ」を参照)
$rescore = new Laurus\LateInteractionRescore("body_colbert", "how do lifetimes work");
SearchResult
Index->search() が返すクラスです。
$result->getId() // string -- 外部ドキュメント識別子
$result->getScore() // float -- 関連性スコア
$result->getDocument() // array|null -- 取得されたフィールド値。stored=false の場合は null
$result->getHighlights() // array -- 要求したフィールドごとのハイライト済みフラグメント
getHighlights() は $highlight で指定した各フィールドをハイライト済みフラグメント(最も良いものが先頭)にマッピングします。ハイライトされなかったフィールドは配列に現れず、$highlight を要求しなかった場合は [] を返します。詳細はハイライトを参照してください。
フュージョンアルゴリズム
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 | 同義語グループのソース。 |
$keepOriginal | true(デフォルト)の場合は元のトークンも同義語と並べて保持します。 |
$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 -- 元テキスト内の UTF-8 バイト開始オフセット
$token->getEndOffset() // int -- 元テキスト内の UTF-8 バイト終了オフセット
$token->getBoost() // float -- スコアブースト係数(1.0 = 調整なし)
$token->isStopped() // bool -- ストップフィルターによって除去されたかどうか
$token->getPositionIncrement() // int -- 前のトークンの位置との差分
$token->getPositionLength() // int -- このトークンがカバーする位置数
$token->getTokenType() // ?string -- トークン種別(例: "alphanum")
オフセットは PHP の文字列と同じくバイト数なので、substr($text, $start, $end - $start) でトークンのテキストを取り出せます。
getTokenType() は "alphanum"、"num"、"cjk"、"katakana"、"hiragana"、"hangul"、"punctuation"、"whitespace"、"synonym"、"email"、"url"、"other" のいずれか、または null を返します。SynonymGraphFilter::apply() は各トークンのオフセットと種別を保ち、挿入する同義語には種別 "synonym" と、置き換える語のオフセットを付けます。
フィールド値の型マッピング
PHP の値は自動的に Laurus の DataValue 型に変換されます:
| PHP 型 | Laurus 型 | 備考 |
|---|---|---|
null | Null | |
true / false | Bool | |
int | Int64 | |
float | Float64 | |
string | Text | |
array(int、シーケンシャル) | Int64Array | 多値整数フィールド。ベクトルフィールドでは配列を f32 にキャスト。空の array は空の Int64Array |
array(数値、シーケンシャル) | Float64Array | 多値浮動小数点フィールド(整数は拡張)。ベクトルフィールドでは配列を f32 にキャスト |
array("lat", "lon") | Geo | 2 つの float 値 |
array("x", "y", "z") | GeoEcef | 3 つの float 値(メートル単位、3D ECEF 直交座標) |
array(["lat" => .., "lon" => ..] 配列の配列) | GeoArray | シーケンシャル配列。フィールドに $multiValued = true が必要 |
array(["x" => .., "y" => .., "z" => ..] 配列の配列) | GeoEcefArray | シーケンシャル配列。フィールドに $multiValued = true が必要 |
array(数値リストのリスト、シーケンシャル。例: [[0.1, 0.2], [0.3, 0.4]]) | VectorArray | MultiVector フィールドのトークンベクトル(Issue #1351)。内側の配列はキー付きの地理座標ではなくリストなので、両者が衝突することはない。整数は拡張される。bool や string の要素はエラー(“token vector N must hold only numbers”)。ベクトルの本数と次元はフィールドに対してチェックされる(\ValueError)。保存されないため、getDocuments や検索結果には含まれない |
string(ISO 8601) | DateTime | ISO 8601 形式からパース |
array(ISO 8601 文字列、シーケンシャル) | DateTimeArray | 全要素が ISO 8601 としてパースできる場合のみ選ばれる。フィールドに $multiValued = true が必要 |
array(string、シーケンシャル。すべてが ISO 8601 ではない) | TextArray | 多値テキストフィールド(Issue #1175)。文字列の配列として読み戻される。フィールドに $multiValued = true が必要。宣言済みの多値 Bytes フィールドでは、同じ base64 文字列の配列が要素ごとにデコードされる(Issue #1176) |
array(bool、シーケンシャル) | BoolArray | 全要素が bool であること。[true, 1] のような混在配列はエラー(“numeric array elements must be numeric”)。フィールドに $multiValued = true が必要 |
開発環境のセットアップ
このページでは 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-php は ext-php-rs を使用しており、
そのバインディング生成(ext-php-rs-bindgen)は bindgen で PHP ヘッダーを解析し、
clang-sys 経由で libclang をロードします。一方、laurus-ruby は
magnus → rb-sys のビルドで同じく bindgen + clang-sys を使用します。
Cargo は同一 workspace 内で同じ links 値を持つパッケージを 2 つ許可しないため、
PHP と Ruby のバインディングが workspace メンバーとして共存できるのは、両者が
同一の clang-sys パッケージに解決される場合のみです。
かつての ext-php-rs は ext-php-rs-clang-sys(links = "clang" を宣言する
clang-sys のフォーク)に依存しており、laurus-ruby 側のオリジナル clang-sys と
衝突していました。当時は links 宣言を除去したローカルコピー(patches/ 配下)への
[patch.crates-io] オーバーライドが必要でした。ext-php-rs-bindgen 0.72.1-extphprs.2(ext-php-rs 0.15.15 が使用)以降はフォークが廃止され、
ext-php-rs は通常の clang-sys に戻ったため、両バインディングは 1 つの
clang-sys パッケージを共有し、パッチは削除済みです。
将来の ext-php-rs アップグレードでフォーク版 clang-sys が再導入された場合
(cargo build -p laurus-php -p laurus-ruby で links = "clang" の衝突エラーが
出たら要注意)、vendored パッチを復活させてください: フォーククレートのソースを
patches/ にコピーし、その links = "clang" 行をコメントアウトし、ルート
Cargo.toml の [patch.crates-io] からそこを指します。clang-sys は
libclang をビルド時のみ使用し(bindgen によるヘッダー解析)、最終バイナリには
リンクされないため、この変更は安全です。
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-zigbuild – 任意。静的リンクの musl バイナリをローカルでクロスビルドする場合のみ必要 (静的リンクの musl バイナリをクロスビルドするを参照)
ビルド
# すべてのクレートをビルド
cargo build
# 特定の Feature を指定してビルド
cargo build --features embeddings-candle
# リリースモードでビルド
cargo build --release
静的リンクの musl バイナリをクロスビルドする
laurus-cli のリリースワークフローは、動的リンクの glibc バイナリに加えて、
完全に静的リンクされた x86_64-unknown-linux-musl /
aarch64-unknown-linux-musl バイナリもビルドします
(ビルド済みバイナリを参照)。
前提条件:
rustup target add x86_64-unknown-linux-musl aarch64-unknown-linux-musl
pip install cargo-zigbuild
cargo build の代わりに cargo zigbuild でビルドします:
cargo zigbuild --release --target x86_64-unknown-linux-musl \
-p laurus-cli --features embeddings-all
(x86_64 ターゲットについては make build-laurus-cli-musl と同等です)
なぜ apt install musl-tools ではなく cargo-zigbuild を使うのか?
Ubuntu の musl-tools パッケージは musl-gcc(C)を提供しますが
musl-g++(C++)は提供しません。laurus の --features embeddings-all の
依存関係の大部分は C のみか純 Rust です(aws-lc-sys、onig_sys)が、
musl-tools だけの構成は依存 Feature を 1 つ切り替えるだけで再び C++ を
要求しかねません(tokenizers の esaxx_fast。laurus では意図的に無効化
しています。Feature Flags を参照)。cargo-zigbuild は
クロス C/C++ ツールチェーンとして Zig を使用し、
各ターゲット向けの musl ヘッダとライブラリを同梱しているため、Docker が
不要です。
ビルド結果が本当に静的であることを確認する:
file target/x86_64-unknown-linux-musl/release/laurus
# -> ELF 64-bit LSB executable, ..., statically linked
readelf -d target/x86_64-unknown-linux-musl/release/laurus | grep NEEDED
# -> (出力なし)
この手順が対応する CI 設定については
.github/workflows/release.yml
の build-binary を参照してください。
テスト
# すべてのワークスペーステストを実行(デフォルト 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-php は laurus-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"
# バインディングの README / docs / examples のスニペットが Node.js / WASM / PHP の
# 現行シグネチャと一致しているか確認(`make lint` と CI でも実行)
python3 scripts/check-binding-snippets.py
ベンチマーク
このガイドでは、laurus のベンチマークの実行方法、ベースライン(baseline)の保存と比較方法、プルリクエストでの結果報告方法について説明します。
ベンチマークスイートは laurus/benches/ 配下にあり、Criterion で構築されています。衛生ルール(決定的シード、ファイル冒頭ドキュメント、sanity assert、sample_size ポリシー)は laurus/benches/common.rs で一元管理されています。
スイート一覧
| ファイル | スコープ |
|---|---|
bkd_bench.rs | BKD ツリーの範囲検索(range search)、交差判定(intersect)、構築(1D / 2D / 3D、10k / 100k / 1M ポイント) |
distance_bench.rs | DistanceMetric::distance の cosine / Euclidean / Manhattan / dot product(現状は単一次元、次元スイープは #424 で対応予定) |
lexical_search_bench.rs | Engine::search 経由のエンドツーエンド lexical 検索(term / boolean / phrase / fuzzy / DSL) |
search_perf.rs | Posting iterator の skip_to、BM25Scorer::score、SIMD バッチスコアリング、コンパクト posting 変換 |
spell_correction_bench.rs | SpellingCorrector::correct、各イテレーションごとに fresh corrector を渡す cold-state 計測 |
synonym_bench.rs | SynonymDictionary::get_synonyms のルックアップ(100 / 1k / 10k グループ)と構築コスト |
text_analysis_bench.rs | StandardAnalyzer::analyze のシングルドキュメントとバッチ(100 ドキュメント)解析 |
vector_search_bench.rs | Flat / 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 detected、Performance has improved、Performance has regressed)が示されます。Criterion はベースラインを target/criterion/<bench-id>/<baseline>/ 配下に保存します。
perf PR の推奨フロー:
main(または変更前の状態)で —cargo bench --bench RELEVANT -- --save-baseline main- ブランチで変更を実装
- ブランチで —
cargo bench --bench RELEVANT -- --baseline main 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 # IntelAMD システムや 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 のスイート全体衛生ルールに従ってください:
common::DEFAULT_SEED(またはlcg_*ヘルパ)で決定的シードを使う。rand::rng()は使わない。- ファイル冒頭に
//!ドキュメントコメントでスコープ・シナリオ・実行コマンド・フィルタ例を記載する。 - タイミング
b.iterの外側で 1 度だけassert!を実行し、空結果を出す regression を黙ってパスさせない。 SAMPLE_SIZE_FAST(デフォルト、50 ms 以下の操作向け)またはSAMPLE_SIZE_SLOW(構築パス向け)のいずれかを選ぶ。中間値は使わない。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-candle | Hugging Face Candle によるローカル BERT Embedding と ColBERT のトークンベクトル | candle-core, candle-nn, candle-transformers, hf-hub, tokenizers |
embeddings-openai | OpenAI API Embedding | reqwest |
embeddings-multimodal | CLIP マルチモーダル Embedding(テキスト + 画像) | image, embeddings-candle |
embeddings-all | すべての Embedding Feature を統合 | 上記すべて |
各 Feature の詳細
embeddings-candle
CandleBertEmbedder を有効にし、CPU 上でローカルに BERT モデルを実行できるようにします。あわせて、late interaction の再採点に使う ColBERT のトークンベクトルを作る CandleColbertEmbedder(スキーマのエンベダー candle_colbert)も有効にします。モデルは初回使用時に Hugging Face Hub からダウンロードされます。
[dependencies]
laurus = { version = "0.12", features = ["embeddings-candle"] }
embeddings-openai
OpenAIEmbedder を有効にし、OpenAI Embeddings API を呼び出せるようにします。実行時に OPENAI_API_KEY 環境変数が必要です。
[dependencies]
laurus = { version = "0.12", features = ["embeddings-openai"] }
embeddings-multimodal
CandleClipEmbedder を有効にし、CLIP ベースのテキストおよび画像 Embedding を使用できるようにします。embeddings-candle を暗黙的に有効にします。
[dependencies]
laurus = { version = "0.12", features = ["embeddings-multimodal"] }
embeddings-all
すべての Embedding Feature を有効にする便利な Feature です。
[dependencies]
laurus = { version = "0.12", features = ["embeddings-all"] }
TLS とネットワークの挙動
Embedding Feature は、信頼するルート証明書のソースが異なる 2 系統の TLS スタックを使用します。
| Feature | HTTP クライアント | TLS backend | 信頼するルート証明書のソース |
|---|---|---|---|
embeddings-candle, embeddings-multimodal | hf-hub(ureq) | rustls | バイナリに埋め込まれた Mozilla ルート証明書(webpki-roots) |
embeddings-openai | reqwest | rustls | OS の信頼ストア(rustls-platform-verifier 経由) |
Hugging Face Hub からのモデルダウンロード(embeddings-candle /
embeddings-multimodal)は、OS の信頼ストアではなくバイナリに埋め込まれた
証明書を使用します。これは意図的な設計です。ca-certificates パッケージが
入っていない scratch や distroless コンテナ内でも、完全静的リンクの musl
バイナリがモデルをダウンロードできるようにするためです。トレードオフとして、
このパスでは SSL_CERT_FILE / SSL_CERT_DIR は尊重されず、OS の信頼ストア
にのみ導入された独自 CA(例: 社内の TLS インスペクションプロキシ配下)は
信頼されません。そのようなプロキシ経由で Hugging Face へのダウンロードを
行う必要がある場合は、キャッシュを事前に用意して HF_HOME でそれを指すか、
信頼された内部ミラーを HF_ENDPOINT で指定してください。
embeddings-openai は OS の信頼ストアを参照するため、これを使用する
コンテナには引き続き ca-certificates のインストールが必要です。
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-wasm | WebAssembly | wasm-bindgen で構築するブラウザ・エッジランタイム向け npm パッケージ |
laurus-ruby | 動的ライブラリ | magnus と rb-sys で構築する Ruby gem |
laurus-php | PHP 拡張 | ext-php-rs で構築する PHP 拡張(スタンドアロン構成。ワークスペースからは除外。詳細は ビルドとテスト を参照) |
すべてのバインディングクレートと 3 つの自製バイナリは laurus に依存します。
設計規約
- モジュールスタイル: ファイルベースのモジュール(Rust 2018 edition スタイル)、
mod.rsは使用しないsrc/tokenizer.rs+src/tokenizer/dictionary.rs- 不可:
src/tokenizer/mod.rs
- エラーハンドリング: ライブラリのエラー型には
thiserror、anyhowはバイナリクレートのみ unwrap()/expect()禁止: 本番コードでは使用不可(テストでは使用可)- 非同期: すべてのパブリック API は Tokio ランタイムで async/await を使用
- Unsafe: すべての
unsafeブロックに// SAFETY: ...コメントが必須 - ドキュメント: すべてのパブリックな型、関数、列挙型にドキュメントコメント(
///)が必須 - ライセンス: 依存クレートは MIT または Apache-2.0 互換であること