Laurus
A fast, featureful hybrid search library for Rust.
Laurus is a pure-Rust library that combines lexical search (keyword matching via inverted index) and vector search (semantic similarity via embeddings) into a single, unified engine. It is designed to be embedded directly into your Rust application — no external server required.
Key Features
| Feature | Description |
|---|---|
| Lexical Search | Full-text search powered by an inverted index with BM25 scoring |
| Vector Search | Approximate nearest neighbor (ANN) search using Flat, HNSW, or IVF indexes |
| Hybrid Search | Combine lexical and vector results with fusion algorithms (RRF, WeightedSum) |
| Text Analysis | Pluggable analyzer pipeline — tokenizers, filters, stemmers, synonyms |
| Embeddings | Built-in support for Candle (local BERT/CLIP), OpenAI API, or custom embedders |
| Storage | Pluggable backends — in-memory, file-based, or memory-mapped |
| Query DSL | Human-readable query syntax for lexical, vector, and hybrid search |
| Pure Rust | No C/C++ dependencies in the core — safe, portable, easy to build |
How It Works
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"]
- Define a Schema — declare your fields and their types (text, integer, vector, etc.)
- Build an Engine — attach an analyzer for text and an embedder for vectors
- Index Documents — the engine routes each field to the correct index automatically
- Search — run lexical, vector, or hybrid queries and get ranked results
Document Map
| Section | What You Will Learn |
|---|---|
| Getting Started | Install Laurus and run your first search in minutes |
| Architecture | Understand the Engine, its components, and data flow |
| Core Concepts | Schema, text analysis, embeddings, and storage |
| Indexing | How inverted indexes and vector indexes work internally |
| Search | Query types, vector search, and hybrid fusion |
| Query DSL | Human-readable query syntax for all search types |
| Library (laurus) | Engine internals, scoring, faceting, and extensibility |
| CLI (laurus-cli) | Command-line tool for index management and search |
| Server (laurus-server) | gRPC server with HTTP Gateway |
| Development Guide | Build, test, and contribute to Laurus |
Quick Example
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_search_request(
laurus::LexicalSearchRequest::new(
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(())
}
License
Laurus is licensed under the MIT License.
Architecture
This page explains how Laurus is structured internally. Understanding the architecture will help you make better decisions about schema design, analyzer selection, and search strategies.
Project Structure
Laurus is organized as a Cargo workspace with five crates:
graph TB
CLI["laurus-cli\n(Binary Crate)\nCLI + REPL"]
SRV["laurus-server\n(Library + Binary)\ngRPC Server + HTTP Gateway"]
MCP["laurus-mcp\n(Library + Binary)\nMCP Server"]
PY["laurus-python\n(cdylib)\nPython Bindings"]
LIB["laurus\n(Library Crate)\nCore Search Engine"]
CLI --> LIB
CLI --> SRV
CLI --> MCP
SRV --> LIB
MCP --> SRV
MCP --> LIB
PY --> LIB
| Crate | Type | Description |
|---|---|---|
| laurus | Library | Core search engine – lexical, vector, and hybrid search |
| laurus-cli | Binary | Command-line interface for index management and search |
| laurus-server | Library + Binary | gRPC server with optional HTTP/JSON gateway |
| laurus-mcp | Binary | MCP (Model Context Protocol) stdio server that proxies to laurus-server |
| laurus-python | cdylib | Python bindings via PyO3 / Maturin |
| laurus-nodejs | cdylib | Node.js bindings via NAPI-RS |
| laurus-wasm | WebAssembly | Browser / edge bindings via wasm-bindgen |
| laurus-ruby | cdylib | Ruby bindings via magnus / rb-sys |
| laurus-php | PHP extension | PHP bindings via ext-php-rs (excluded from the workspace) |
For details on each crate, see:
- Library Overview
- CLI Overview
- Server Overview
- MCP Server Overview
- Python Bindings Overview
- Node.js Bindings Overview
- WASM Bindings Overview
- Ruby Bindings Overview
- PHP Bindings Overview
High-Level Overview
Laurus is organized around a single Engine that coordinates four internal components:
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
| Component | Responsibility |
|---|---|
| Schema | Declares fields and their types; determines how each field is routed |
| LexicalStore | Inverted index for keyword search (BM25 scoring) |
| VectorStore | Vector index for similarity search (Flat, HNSW, or IVF) |
| DocumentLog | Write-ahead log (WAL) for durability + raw document storage |
All three stores share a single Storage backend, isolated by key prefixes (lexical/, vector/, documents/).
Engine Lifecycle
Building an Engine
The EngineBuilder assembles the engine from its parts:
#![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
During build(), the engine:
- Splits the schema — lexical fields go to
LexicalIndexConfig, vector fields go toVectorIndexConfig - Creates prefixed storage — each component gets an isolated namespace (
lexical/,vector/,documents/) - Initializes stores —
LexicalStoreandVectorStoreare constructed with their configs - Recovers from WAL — replays any uncommitted operations from a previous session
Schema Splitting
The Schema contains both lexical and vector fields. At build time, split_schema() separates them:
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)"]
Key details:
- The reserved
_idfield is always added to the lexical config withKeywordAnalyzer(exact match) - A
PerFieldAnalyzerwraps per-field analyzer settings; if you pass a simpleStandardAnalyzer, it becomes the default for all text fields - A
PerFieldEmbedderworks the same way for vector fields
Indexing Data Flow
When you call 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
Key points:
- WAL-first: every write is logged before modifying in-memory structures
- Dual indexing: each field is routed to either the lexical or vector store based on the schema
- Commit required: documents become searchable only after
commit()
Search Data Flow
When you call 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
The search pipeline has three stages:
- Filter (optional) — execute a filter query on the lexical index to get a set of allowed document IDs
- Search — run lexical and/or vector queries in parallel
- Fusion — if both query types are present, merge results using RRF (default, k=60) or WeightedSum
Storage Architecture
All components share a single Storage trait implementation, but use key prefixes to isolate their data:
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
| Backend | Description | Best For |
|---|---|---|
MemoryStorage | All data in memory | Testing, small datasets, ephemeral use |
FileStorage | Standard file I/O | General production use |
FileStorage (mmap) | Memory-mapped files (use_mmap = true) | Large datasets, read-heavy workloads |
Per-Field Dispatch
When a PerFieldAnalyzer is provided, the engine dispatches analysis to field-specific analyzers. The same pattern applies to 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)"]
This allows different fields to use different analysis strategies within the same engine.
Summary
| Aspect | Detail |
|---|---|
| Core struct | Engine — coordinates all operations |
| Builder | EngineBuilder — assembles Engine from Storage + Schema + Analyzer + Embedder |
| Schema split | Lexical fields → LexicalIndexConfig, Vector fields → VectorIndexConfig |
| Write path | WAL → in-memory buffers → commit() → persistent storage |
| Read path | Query → parallel lexical/vector search → fusion → ranked results |
| Storage isolation | PrefixedStorage with lexical/, vector/, documents/ prefixes |
| Per-field dispatch | PerFieldAnalyzer and PerFieldEmbedder route to field-specific implementations |
Next Steps
- Understand field types and schema design: Schema & Fields
- Learn about text analysis: Text Analysis
- Learn about embeddings: Embeddings
Getting Started
Welcome to Laurus! This section will help you install the library and run your first search.
What You Will Build
By the end of this guide, you will have a working search engine that can:
- Index text documents
- Perform keyword (lexical) search
- Perform semantic (vector) search
- Combine both with hybrid search
Prerequisites
- Rust 1.85 or later (edition 2024)
- Cargo (included with Rust)
- Tokio runtime (Laurus uses async APIs)
Steps
- Installation — Add Laurus to your project and choose feature flags
- Quick Start — Build a complete search engine in 5 steps
Workflow Overview
Building a search application with Laurus follows a consistent pattern:
graph LR
A["1. Create\nStorage"] --> B["2. Define\nSchema"]
B --> C["3. Build\nEngine"]
C --> D["4. Index\nDocuments"]
D --> E["5. Search"]
| Step | What Happens |
|---|---|
| Create Storage | Choose where data lives — in memory, on disk, or memory-mapped |
| Define Schema | Declare fields and their types (text, integer, vector, etc.) |
| Build Engine | Attach an analyzer (for text) and an embedder (for vectors) |
| Index Documents | Add documents; the engine routes fields to the correct index |
| Search | Run lexical, vector, or hybrid queries and get ranked results |
Installation
Add Laurus to Your Project
Add laurus and tokio (async runtime) to your Cargo.toml:
[dependencies]
laurus = "0.9"
tokio = { version = "1", features = ["full"] }
Feature Flags
Laurus ships with a minimal default feature set. Enable additional features as needed:
| Feature | Description | Use Case |
|---|---|---|
| (default) | Core library (lexical search, storage, analyzers — no embedding) | Keyword search only |
embeddings-candle | Local BERT embeddings via Hugging Face Candle | Vector search without external API |
embeddings-openai | OpenAI API embeddings (text-embedding-3-small, etc.) | Cloud-based vector search |
embeddings-multimodal | CLIP embeddings for text + image via Candle | Multimodal (text-to-image) search |
embeddings-all | All embedding features above | Full embedding support |
Examples
Lexical search only (no embeddings needed):
[dependencies]
laurus = "0.9"
Vector search with local model (no API key required):
[dependencies]
laurus = { version = "0.9", features = ["embeddings-candle"] }
Vector search with OpenAI:
[dependencies]
laurus = { version = "0.9", features = ["embeddings-openai"] }
Everything:
[dependencies]
laurus = { version = "0.9", features = ["embeddings-all"] }
Verify Installation
Create a minimal program to verify that Laurus compiles:
use laurus::Result;
#[tokio::main]
async fn main() -> Result<()> {
println!("Laurus version: {}", laurus::VERSION);
Ok(())
}
cargo run
If you see the version printed, you are ready to proceed to the Quick Start.
Quick Start
This tutorial walks you through building a complete search engine in 5 steps. By the end, you will be able to index documents and search them by keyword.
Step 1 — Create Storage
Storage determines where Laurus persists index data. For development and testing, use 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())
);
}
Tip: For production, consider
FileStorage(with optionaluse_mmapfor memory-mapped I/O). See Storage for details.
Step 2 — Define a Schema
A Schema declares the fields in your documents and how each field should be indexed:
#![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();
}
Each field has a type. Common types include:
| Method | Field Type | Example Values |
|---|---|---|
add_text_field | Text (full-text searchable) | "Hello world" |
add_integer_field | 64-bit integer | 42 |
add_float_field | 64-bit float | 3.14 |
add_boolean_field | Boolean | true / false |
add_datetime_field | UTC datetime | 2024-01-15T10:30:00Z |
add_hnsw_field | Vector (HNSW index) | [0.1, 0.2, ...] |
add_flat_field | Vector (Flat index) | [0.1, 0.2, ...] |
See Schema & Fields for the full list.
Step 3 — Build an Engine
The Engine ties storage, schema, and runtime components together:
#![allow(unused)]
fn main() {
use laurus::Engine;
let engine = Engine::builder(storage, schema)
.build()
.await?;
}
When you only use text fields, the default StandardAnalyzer is used automatically. To customize analysis or add vector embeddings, see Architecture.
Step 4 — Index Documents
Create documents with the DocumentBuilder and add them to the 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?;
}
Important: Documents are not searchable until
commit()is called.
Step 5 — Search
Use SearchRequestBuilder with a query to search the index:
#![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);
}
}
}
}
Complete Example
Here is the full program that you can copy, paste, and run:
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(())
}
Next Steps
- Learn how the Engine works internally: Architecture
- Understand Schema and field types: Schema & Fields
- Add vector search: Vector Search
- Combine lexical + vector: Hybrid Search
Examples
The laurus/examples/ directory contains runnable examples demonstrating different features of the library.
Running Examples
# Run an example without feature flags
cargo run --example <name>
# Run an example with a feature flag
cargo run --example <name> --features <flag>
Available Examples
quickstart
A minimal example showing the basic workflow: create storage, define a schema, build an engine, index documents, and search.
cargo run --example quickstart
Demonstrates: In-memory storage, TextOption, TermQuery, LexicalSearchRequest.
lexical_search
Comprehensive example of all lexical query types, using both the Builder API and the QueryParser DSL.
cargo run --example lexical_search
Demonstrates: TermQuery, PhraseQuery, FuzzyQuery, WildcardQuery, NumericRangeQuery, GeoQuery, BooleanQuery, SpanQuery.
vector_search
Vector search with a mock embedder, including filtered vector search and DSL syntax.
cargo run --example vector_search
Demonstrates: PerFieldEmbedder, VectorSearchRequestBuilder, filtered search, DSL syntax (field:"query").
hybrid_search
Combining lexical and vector search with different fusion algorithms.
cargo run --example hybrid_search
Demonstrates: Lexical-only, vector-only, and hybrid search. Both RRF and WeightedSum fusion algorithms. Builder API and DSL.
geo3d_search
3D Earth-Centered Earth-Fixed (ECEF) geographic search. Indexes six landmarks (Tokyo Tower, Tokyo Skytree, Mt. Fuji summit, Statue of Liberty, Sydney Opera House, an ISS sample point) computed via wgs84_to_ecef.
cargo run --example geo3d_search
Demonstrates: Geo3dDistanceQuery (sphere), Geo3dBoundingBoxQuery (3D AABB), Geo3dNearestQuery (k-NN), and the wgs84_to_ecef conversion utility. The bounding-box query intentionally excludes the ISS sample to highlight that altitude is a first-class third axis.
search_with_candle
Vector search using real BERT embeddings via Hugging Face Candle. The model is downloaded automatically on first run (~80 MB).
cargo run --example search_with_candle --features embeddings-candle
Requires: embeddings-candle feature flag.
Demonstrates: CandleBertEmbedder with sentence-transformers/all-MiniLM-L6-v2 (384 dimensions).
search_with_openai
Vector search using the OpenAI Embeddings API.
export OPENAI_API_KEY=your-api-key
cargo run --example search_with_openai --features embeddings-openai
Requires: embeddings-openai feature flag, OPENAI_API_KEY environment variable.
Demonstrates: OpenAIEmbedder with text-embedding-3-small (1536 dimensions).
multimodal_search
Multimodal (text + image) search using a CLIP model.
cargo run --example multimodal_search --features embeddings-multimodal
Requires: embeddings-multimodal feature flag.
Demonstrates: CandleClipEmbedder, indexing images from the filesystem, text-to-image and image-to-image queries.
synonym_graph_filter
Demonstrates the SynonymGraphFilter for token expansion during analysis.
cargo run --example synonym_graph_filter
Demonstrates: Synonym dictionary creation, synonym-based token expansion, boost application, token position and position_length attributes.
Helper Module: common.rs
The common.rs file provides shared utilities used by the examples:
memory_storage()– Create an in-memory storage instanceper_field_analyzer()– Create aPerFieldAnalyzerwithKeywordAnalyzerfor specific fieldsMockEmbedder– A mockEmbedderimplementation for testing vector search without a real model
Schema & Fields
The Schema defines the structure of your documents — what fields exist and how each field is indexed. It is the single source of truth for the Engine.
For the TOML file format used by the CLI, see Schema Format Reference.
Schema
A Schema is a collection of named fields. Each field is either a lexical field (for keyword search) or a vector field (for similarity search).
#![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();
}
Default Fields
add_default_field() specifies which field(s) are searched when a query does not explicitly name a field. This is used by the Query DSL parser.
Field Types
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 Fields
Lexical fields are indexed using an inverted index and support keyword-based queries.
| Type | Rust Type | SchemaBuilder Method | Description |
|---|---|---|---|
| Text | TextOption | add_text_field() | Full-text searchable; tokenized by the analyzer |
| Integer | IntegerOption | add_integer_field() | 64-bit signed integer; supports range queries |
| Float | FloatOption | add_float_field() | 64-bit floating point; supports range queries |
| Boolean | BooleanOption | add_boolean_field() | true / false |
| DateTime | DateTimeOption | add_datetime_field() | UTC timestamp; supports range queries |
| Geo | GeoOption | add_geo_field() | Latitude/longitude pair; supports radius and bounding box queries |
| Geo3d | Geo3dOption | add_geo3d_field() | 3D ECEF Cartesian point (x, y, z in metres); supports 3D distance, bounding box, and k-NN queries. See 3D Geographic Search. |
| Bytes | BytesOption | add_bytes_field() | Raw binary data |
Text Field Options
TextOption controls how text is indexed:
#![allow(unused)]
fn main() {
use laurus::lexical::TextOption;
// Default: indexed + stored + term vectors (all true)
let opt = TextOption::default();
// Customize
let opt = TextOption::default()
.indexed(true)
.stored(true)
.term_vectors(true);
}
| Option | Default | Description |
|---|---|---|
indexed | true | Whether the field is searchable |
stored | true | Whether the original value is stored for retrieval |
term_vectors | true | Whether term positions are stored (needed for phrase queries and highlighting) |
Vector Fields
Vector fields are indexed using vector indexes for approximate nearest neighbor (ANN) search.
| Type | Rust Type | SchemaBuilder Method | Description |
|---|---|---|---|
| Flat | FlatOption | add_flat_field() | Brute-force linear scan; exact results |
| HNSW | HnswOption | add_hnsw_field() | Hierarchical Navigable Small World graph; fast approximate |
| IVF | IvfOption | add_ivf_field() | Inverted File Index; cluster-based approximate |
HNSW Field Options (most common)
#![allow(unused)]
fn main() {
use laurus::vector::HnswOption;
use laurus::vector::core::distance::DistanceMetric;
use laurus::vector::core::quantization::QuantizationMethod;
let opt = HnswOption {
dimension: 384, // vector dimensions
distance: DistanceMetric::Cosine, // distance metric
m: 16, // max connections per layer
ef_construction: 200, // construction search width
default_ef_search: Some(100), // schema-level ef_search default (issue #644)
base_weight: 1.0, // default scoring weight
quantizer: QuantizationMethod::Scalar8Bit, // mandatory; default Scalar8Bit
embedder: None, // optional named embedder
};
}
default_ef_search: the search-time recall knob
ef_search controls the dynamic candidate list size during query time
(distinct from ef_construction, which only affects index build). Higher
values explore more graph neighbours and yield higher recall at the cost of
latency.
- Schema-level default: set
HnswOption.default_ef_search = Some(ef)to raise the per-field default. WhenNone, the searcher falls back to its built-in50. - Per-query override: search requests honour
SearchRequestBuilder::vector_ef_search. The per-query value takes precedence over the schema default. - Auto-lifting: regardless of which source provides
ef_search, the searcher lifts the effective value to at leasttop_k(andtop_k * rerank_factorwhen both are set) so the candidate heap is never undersized for the requestedtop_k. - Tracked under Issue #644.
See Vector Indexing for detailed parameter guidance.
Document
A Document is a collection of named field values. Use DocumentBuilder to construct documents:
#![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();
}
Indexing Documents
The Engine provides two methods for adding documents, each with different semantics:
| Method | Behavior | Use Case |
|---|---|---|
put_document(id, doc) | Upsert — if a document with the same ID exists, it is replaced | Standard document indexing |
add_document(id, doc) | Append — adds the document as a new chunk; multiple chunks can share the same ID | Chunked/split documents (e.g., long articles split into paragraphs) |
#![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?;
}
Retrieving Documents
Use get_documents to retrieve all documents (including chunks) by external 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);
}
}
}
Deleting Documents
Delete all documents and chunks sharing an external ID:
#![allow(unused)]
fn main() {
engine.delete_documents("doc1").await?;
engine.commit().await?;
}
Document Lifecycle
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()"]
Important: Documents are not searchable until
commit()is called.
DocumentBuilder Methods
| Method | Value Type | Description |
|---|---|---|
add_text(name, value) | String | Add a text field |
add_integer(name, value) | i64 | Add an integer field |
add_float(name, value) | f64 | Add a float field |
add_boolean(name, value) | bool | Add a boolean field |
add_datetime(name, value) | DateTime<Utc> | Add a datetime field |
add_vector(name, value) | Vec<f32> | Add a pre-computed vector field |
add_geo(name, lat, lon) | (f64, f64) | Add a 2D geographic point (WGS84) |
add_geo_ecef(name, x, y, z) | (f64, f64, f64) | Add a 3D ECEF Cartesian point (metres) |
add_bytes(name, data) | Vec<u8> | Add binary data |
add_field(name, value) | DataValue | Add any value type |
DataValue
DataValue is the unified value enum that represents any field value in 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 point (latitude, longitude)
GeoEcef(GeoEcefPoint), // 3D ECEF Cartesian point (x, y, z) in metres
Int64Array(Vec<i64>), // multi-valued integer field
Float64Array(Vec<f64>), // multi-valued float field
}
}
DataValue implements From<T> for common types, so you can use .into() conversions:
#![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
}
Reserved Fields
Any field name starting with an underscore (_) is reserved for the
engine. User code cannot declare fields with such names, and documents that
carry user-supplied _-prefixed keys are rejected at ingest time.
The only _-prefixed name that is accepted is the allow-listed _id
system field described below.
_id — external document identifier
Stores the external document ID supplied to put_document / add_document.
It is injected automatically and indexed with KeywordAnalyzer (exact match).
You do not need to add it to your schema.
Dynamic Schema
Laurus can accept documents even when some of their fields have not been
declared in the schema. The behaviour is controlled by the
DynamicFieldPolicy attached to the schema:
| Policy | Behaviour on an undeclared field |
|---|---|
Strict | Reject the document with a descriptive error. |
Dynamic (default) | Infer the field’s type from the value and add it to the schema. |
Ignore | Silently drop the field and continue indexing the rest. |
Set the policy on the builder:
#![allow(unused)]
fn main() {
use laurus::{DynamicFieldPolicy, Schema};
let schema = Schema::builder()
.dynamic_field_policy(DynamicFieldPolicy::Dynamic)
.build();
}
Type inference rules (Dynamic policy)
| Incoming value | Inferred field type |
|---|---|
string | Text (BM25 via the inverted index) |
integer | Integer (BKD tree) |
float | Float (BKD tree) |
bool | Boolean |
array of integers (e.g. [1, 2, 3]) | Integer with multi_valued = true |
array of floats / mixed numeric (e.g. [1.5, 2.0, 3]) | Float with multi_valued = true |
object with a latitude key (lat or latitude) and a longitude key (lon, lng, or longitude), values in range | Geo |
object with all three numeric keys x, y, z (finite values, ECEF meters) | Geo3d |
Vector fields (Hnsw, Flat, Ivf) and Bytes are never inferred:
they must be declared in the schema explicitly. Mixing 2D (lat/lon)
and 3D (x/y/z) markers in a single object is rejected as ambiguous;
use either shape, not both.
Multi-valued numeric fields
Integer and Float fields can be declared with multi_valued = true to
hold multiple values per document. A range query matches a document if
any of its values satisfies the predicate (Lucene-style “any match”
semantics with constant scoring — there is no per-match BM25 weighting).
Single values sent to a multi-valued field are auto-wrapped into a one-element array; arrays sent to a single-valued field are rejected rather than silently truncating.
Type conflicts
When a value arrives for a field that is already declared, Laurus attempts to coerce the value to the declared type. The coercion rules are:
| Declared type | Incoming value | Result |
|---|---|---|
Integer | Int64 | stored as-is |
Integer | Float64(3.14) | truncated to 3 (information loss — see warning below) |
Integer | Text("42") | parsed as 42 |
Integer | Text("abc") | error |
Float | Int64 | widened to f64 |
Float | Text("3.14") | parsed |
Boolean | Int64(0) / Int64(1) | false / true |
Boolean | Text("true"/"false") | parsed (case-insensitive) |
Text | any scalar | stringified |
Geo / Geo3d / Bytes / vector | anything other than matching variant | error |
Coercion errors interact with the policy:
Strict: error is returned immediately.Dynamic: error is returned — the coercion layer already applied every conversion that is considered safe.Ignore: the offending field is dropped; the rest of the document is indexed.
⚠️ Warning: silent information loss is possible.
Several coercions throw away information without reporting an error:
- An
Integerfield truncates incomingFloatvalues (3.14→3,-3.9→-3). Ingest does not fail.- A
Floatfield may lose precision for very large integers that do not fit in anf64mantissa.- A
Textfield accepts any scalar by stringifying it, losing the original type.Ignoredrops incompatible fields quietly.If the correctness of your data matters more than the convenience of schema-less ingestion, use
DynamicFieldPolicy::Strict(or declare every field up-front). TheDynamicpolicy prioritises keeping the document ingestable over preserving every bit of incoming data.
Query DSL and undeclared fields
Once the schema is settled, the query parser validates that every
field:value clause references a declared field. Typos such as
titl:hello (for title:hello) produce a clear parse error instead of
returning silently-empty results.
Dynamic Field Management
Fields can be added to or removed from a running engine at runtime. Type changes are not supported—remove the field and re-add it with the new type instead.
Adding a Field
Use Engine::add_field() to add a new field to the schema.
Adding a Lexical Field
let updated_schema = engine.add_field(
"category",
FieldOption::Text(TextOption::default()),
).await?;
Adding a Vector Field
let updated_schema = engine.add_field(
"embedding",
FieldOption::Flat(FlatOption::default().dimension(384)),
).await?;
Existing documents are unaffected—they simply have no value for the new
field. The returned Schema should be persisted (e.g., to schema.toml)
by the caller.
Removing a Field
Use Engine::delete_field() to remove a field from the schema.
let updated_schema = engine.delete_field("category").await?;
When a field is deleted:
- The field definition is removed from the schema.
- Existing indexed data for the field remains in the index but becomes inaccessible through queries.
- If the field was listed in
default_fields, it is automatically removed. - Any per-field analyzer or embedder registered for the field is unregistered.
Schema Design Tips
-
Separate lexical and vector fields — a field is either lexical or vector, never both. For hybrid search, create separate fields (e.g.,
bodyfor text,body_vecfor vector). -
Use
KeywordAnalyzerfor exact-match fields — category, status, and tag fields should useKeywordAnalyzerviaPerFieldAnalyzerto avoid tokenization. -
Choose the right vector index — use HNSW for most cases, Flat for small datasets, IVF for very large datasets. See Vector Indexing.
-
Set default fields — if you use the Query DSL, set default fields so users can write
helloinstead ofbody:hello. -
Use the schema generator — run
laurus create schemato interactively build a schema TOML file instead of writing it by hand. See CLI Commands.
Text Analysis
Text analysis is the process of converting raw text into searchable tokens. When a document is indexed, the analyzer breaks text fields into individual terms; when a query is executed, the same analyzer processes the query text to ensure consistency.
The Analysis Pipeline
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
The analysis pipeline consists of:
- Char Filters — normalize raw text at the character level before tokenization
- Tokenizer — splits text into raw tokens (words, characters, n-grams)
- Token Filters — transform, remove, or expand tokens (lowercase, stop words, stemming, synonyms)
The Analyzer Trait
All analyzers implement the Analyzer trait:
#![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 is a Box<dyn Iterator<Item = Token> + Send> — a lazy iterator over tokens.
A Token contains:
| Field | Type | Description |
|---|---|---|
text | String | The token text |
position | usize | Position in the original text |
start_offset | usize | Start byte offset in original text |
end_offset | usize | End byte offset in original text |
position_increment | usize | Distance from previous token |
position_length | usize | Span of the token (>1 for synonyms) |
boost | f32 | Token-level scoring weight |
stopped | bool | Whether marked as a stop word |
metadata | Option<TokenMetadata> | Additional token metadata |
Built-in Analyzers
StandardAnalyzer
The default analyzer. Suitable for most Western languages.
Pipeline: RegexTokenizer (Unicode word boundaries) → LowercaseFilter → StopFilter (128 common English stop words)
#![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
Uses morphological analysis for Japanese text segmentation.
Pipeline: UnicodeNormalizationCharFilter (NFKC) → JapaneseIterationMarkCharFilter → LinderaTokenizer → LowercaseFilter → StopFilter (Japanese stop words)
JapaneseAnalyzer::new takes the same arguments as LinderaTokenizer::new:
the segmentation mode, a path to a Lindera dictionary directory, and an
optional user dictionary path. laurus does not enable Lindera’s
embed-* features by default, so a real filesystem path (typically an
IPADIC build) is required at runtime.
#![allow(unused)]
fn main() {
use laurus::analysis::analyzer::language::japanese::JapaneseAnalyzer;
// Pass the path where you have unpacked the Lindera dictionary.
let analyzer = JapaneseAnalyzer::new(
"normal",
"/var/lib/lindera/ipadic",
None,
)?;
// "東京都に住んでいる" → ["東京", "都", "住ん", "いる"]
}
When the analyzer is referenced from a Schema, supply the parameters
through the structured AnalyzerSpec form (see PerFieldAnalyzer below).
KeywordAnalyzer
Treats the entire input as a single token. No tokenization or normalization.
#![allow(unused)]
fn main() {
use laurus::analysis::analyzer::keyword::KeywordAnalyzer;
let analyzer = KeywordAnalyzer::new();
// "Hello World" → ["Hello World"]
}
Use this for fields that should match exactly (categories, tags, status codes).
SimpleAnalyzer
Tokenizes text without any filtering. The original case and all tokens are preserved. Useful when you need complete control over the analysis pipeline or want to test a tokenizer in isolation.
Pipeline: User-specified Tokenizer only (no char filters, no token filters)
#![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)
}
Use this for testing tokenizers, or when you want to apply token filters manually in a separate step.
EnglishAnalyzer
An English-specific analyzer. Tokenizes, lowercases, and removes common English stop words.
Pipeline: RegexTokenizer (Unicode word boundaries) → LowercaseFilter → StopFilter (128 common English stop words)
#![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
Build a custom pipeline by combining any char filters, a tokenizer, and any sequence of token filters:
#![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 lets you assign different analyzers to different fields within the same engine:
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?;
}
Note: The
_idfield is always analyzed withKeywordAnalyzerregardless of configuration.
Configuring per-field analyzers from a Schema
Most callers configure analyzers declaratively on the schema rather than
wiring them up by hand. The analyzer setting on a text field accepts
two shapes:
// 1. A bare name for a parameter-less built-in or a user-registered analyzer.
{ "analyzer": "standard" }
{ "analyzer": "english" }
{ "analyzer": "my_custom_pipeline" }
// 2. A structured object for a parameterised built-in preset. Today only
// the Japanese preset uses this form (it requires a Lindera dictionary
// path).
{
"analyzer": {
"language": "japanese",
"mode": "normal",
"dict": "/var/lib/lindera/ipadic"
}
}
The bare string "japanese" is rejected because the preset cannot be
constructed without a dictionary. Schemas that previously stored
"analyzer": "japanese" must migrate to the structured form above.
For full pipelines that do not fit a preset, register the pipeline under
schema.analyzers as an AnalyzerDefinition and reference it by name.
Char Filters
Char filters operate on the raw input text before it reaches the tokenizer. They perform character-level normalization such as Unicode normalization, character mapping, and pattern-based replacement. This ensures that the tokenizer receives clean, normalized text.
All char filters implement the CharFilter trait:
#![allow(unused)]
fn main() {
pub trait CharFilter: Send + Sync {
fn filter(&self, input: &str) -> (String, Vec<Transformation>);
fn name(&self) -> &'static str;
}
}
The Transformation records describe how character positions shifted, allowing the engine to map token positions back to the original text.
| Char Filter | Description |
|---|---|
UnicodeNormalizationCharFilter | Unicode normalization (NFC, NFD, NFKC, NFKD) |
MappingCharFilter | Replaces character sequences based on a mapping dictionary |
PatternReplaceCharFilter | Replaces characters matching a regex pattern |
JapaneseIterationMarkCharFilter | Expands Japanese iteration marks (踊り字) to their base characters |
UnicodeNormalizationCharFilter
Applies Unicode normalization to the input text. NFKC is recommended for search use cases because it normalizes both compatibility characters and composed forms.
#![allow(unused)]
fn main() {
use laurus::analysis::char_filter::unicode_normalize::{
NormalizationForm, UnicodeNormalizationCharFilter,
};
let filter = UnicodeNormalizationCharFilter::new(NormalizationForm::NFKC);
// "Sony" (fullwidth) → "Sony" (halfwidth)
// "㌂" → "アンペア"
}
| Form | Description |
|---|---|
| NFC | Canonical decomposition followed by canonical composition |
| NFD | Canonical decomposition |
| NFKC | Compatibility decomposition followed by canonical composition |
| NFKD | Compatibility decomposition |
MappingCharFilter
Replaces character sequences using a dictionary. Matches are found using the Aho-Corasick algorithm (leftmost-longest match).
#![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
Replaces all occurrences of a regex pattern with a fixed string.
#![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
Expands Japanese iteration marks (踊り字) to their base characters. Supports kanji (々), hiragana (ゝ, ゞ), and katakana (ヽ, ヾ) iteration marks.
#![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
);
// "佐々木" → "佐佐木"
// "いすゞ" → "いすず"
}
Using Char Filters in a Pipeline
Add char filters to a PipelineAnalyzer with add_char_filter(). Multiple char filters are applied in the order they are added, all before the tokenizer runs.
#![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"]
}
Tokenizers
| Tokenizer | Description |
|---|---|
RegexTokenizer | Unicode word boundaries; splits on whitespace and punctuation |
UnicodeWordTokenizer | Splits on Unicode word boundaries |
WhitespaceTokenizer | Splits on whitespace only |
WholeTokenizer | Returns the entire input as a single token |
LinderaTokenizer | Japanese morphological analysis (Lindera/MeCab) |
NgramTokenizer | Generates n-gram tokens of configurable size |
Token Filters
| Filter | Description |
|---|---|
LowercaseFilter | Converts tokens to lowercase |
StopFilter | Removes common words (“the”, “is”, “a”) |
StemFilter | Reduces words to their root form (“running” → “run”) |
SynonymGraphFilter | Expands tokens with synonyms from a dictionary |
BoostFilter | Adjusts token boost values |
LimitFilter | Limits the number of tokens |
StripFilter | Strips leading/trailing whitespace from tokens |
FlattenGraphFilter | Flattens token graphs (for synonym expansion) |
RemoveEmptyFilter | Removes empty tokens |
Synonym Expansion
The SynonymGraphFilter expands terms using a synonym dictionary:
#![allow(unused)]
fn main() {
use laurus::analysis::synonym::dictionary::SynonymDictionary;
use laurus::analysis::token_filter::synonym_graph::SynonymGraphFilter;
let mut dict = SynonymDictionary::new(None)?;
dict.add_synonym_group(vec!["ml".into(), "machine learning".into()]);
dict.add_synonym_group(vec!["ai".into(), "artificial intelligence".into()]);
// keep_original=true means original token is preserved alongside synonyms
let filter = SynonymGraphFilter::new(dict, true)
.with_boost(0.8); // synonyms get 80% weight
}
The boost parameter controls how much weight synonyms receive relative to original tokens. A value of 0.8 means synonym matches contribute 80% as much to the score as exact matches.
Embeddings
Embeddings convert text (or images) into dense numeric vectors that capture semantic meaning. Two texts with similar meanings produce vectors that are close together in vector space, enabling similarity-based search.
The Embedder Trait
All embedders implement the Embedder trait:
#![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;
}
}
The embed() method returns a Vector (a struct wrapping Vec<f32>).
EmbedInput supports two modalities:
| Variant | Description |
|---|---|
EmbedInput::Text(&str) | Text input |
EmbedInput::Bytes(&[u8], Option<&str>) | Binary input with optional MIME type (for images) |
Built-in Embedders
CandleBertEmbedder
Runs a BERT model locally using Hugging Face Candle. No API key required.
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
}
| Property | Value |
|---|---|
| Model | sentence-transformers/all-MiniLM-L6-v2 |
| Dimensions | 384 |
| Runtime | Local (CPU) |
| First-run download | ~80 MB |
OpenAIEmbedder
Calls the OpenAI Embeddings API. Requires an API key.
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
}
| Property | Value |
|---|---|
| Model | text-embedding-3-small (or any OpenAI model) |
| Dimensions | 1536 (for text-embedding-3-small) |
| Runtime | Remote API call |
| Requires | OPENAI_API_KEY environment variable |
CandleClipEmbedder
Runs a CLIP model locally for multimodal (text + image) embeddings.
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
}
| Property | Value |
|---|---|
| Model | openai/clip-vit-base-patch32 |
| Dimensions | 512 |
| Input types | Text AND images |
| Use case | Text-to-image search, image-to-image search |
PrecomputedEmbedder
Use pre-computed vectors directly without any embedding computation. Useful when vectors are generated externally.
#![allow(unused)]
fn main() {
use laurus::PrecomputedEmbedder;
let embedder = PrecomputedEmbedder::new(); // no parameters needed
}
When using PrecomputedEmbedder, you provide vectors directly in documents instead of text for embedding:
#![allow(unused)]
fn main() {
let doc = Document::builder()
.add_vector("embedding", vec![0.1, 0.2, 0.3, ...])
.build();
}
PerFieldEmbedder
PerFieldEmbedder routes embedding requests to field-specific embedders:
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?;
}
This is especially useful when:
- Different vector fields need different models (e.g., BERT for text, CLIP for images)
- Different fields have different vector dimensions
- You want to mix local and remote embedders
How Embeddings Are Used
At Index Time
When you add a text value to a vector field, the engine automatically embeds it:
#![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
}
At Search Time
When you search with text, the engine embeds the query text as well:
#![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?;
}
Both approaches embed the query text using the same embedder that was used at index time, ensuring consistent vector spaces.
Feature Flags Summary
Each embedder requires a specific feature flag to be enabled in Cargo.toml:
| Embedder | Feature Flag | Dependencies |
|---|---|---|
CandleBertEmbedder | embeddings-candle | candle-core, candle-nn, candle-transformers, hf-hub, tokenizers |
OpenAIEmbedder | embeddings-openai | reqwest |
CandleClipEmbedder | embeddings-multimodal | image + embeddings-candle |
PrecomputedEmbedder | (none – always available) | – |
The embeddings-all feature enables all embedding features at once. See Feature Flags for details.
Choosing an Embedder
| Scenario | Recommended Embedder |
|---|---|
| Quick prototyping, offline use | CandleBertEmbedder |
| Production with high accuracy | OpenAIEmbedder |
| Text + image search | CandleClipEmbedder |
| Pre-computed vectors from external pipeline | PrecomputedEmbedder |
| Multiple models per field | PerFieldEmbedder wrapping others |
Storage
Laurus uses a pluggable storage layer that abstracts how and where index data is persisted. All components — lexical index, vector index, and document log — share a single storage backend.
The Storage Trait
All backends implement the Storage trait:
#![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
}
}
This interface is file-oriented: all data (index segments, metadata, WAL entries, documents) is stored as named files accessed through streaming StorageInput / StorageOutput handles.
Storage Backends
MemoryStorage
All data lives in memory. Fast and simple, but not durable.
#![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())
);
}
| Property | Value |
|---|---|
| Durability | None (data lost on process exit) |
| Speed | Fastest |
| Use case | Testing, prototyping, ephemeral data |
FileStorage
Standard file-system based persistence. Each key maps to a file on disk.
#![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)?);
}
| Property | Value |
|---|---|
| Durability | Full (persisted to disk) |
| Speed | Moderate (disk I/O) |
| Use case | General production use |
FileStorage with Memory Mapping
FileStorage supports memory-mapped file access via the use_mmap
configuration flag. When enabled, the OS manages paging between memory
and disk; the lexical posting decoder (Issue #504) takes a zero-copy
path through StorageInput::as_slice, handing PFOR-bit-packed blocks
directly to bitpacking::decompress* instead of allocating an
intermediate Vec<u8> and copying through Read.
Default is platform-specific:
- *Unix (Linux / macOS / BSD):
trueas of Issue #504. Set theLAURUS_NO_MMAP=1environment variable when constructing the config (viaFileStorageConfig::new) to fall back to buffered file I/O for debug sessions or hosts where mmap misbehaves. - Windows:
falseas of Issue #508. Windows holds an exclusive lock on memory-mapped files (ERROR_USER_MAPPED_FILE, os error 1224) which prevents the writer from truncating / deleting a segment file while a reader still holds an mmap. The current segment-file lifecycle is incompatible with that lock. SetLAURUS_USE_MMAP=1to opt in for read-only / read-mostly workloads where commit frequency is low. Full Windows mmap support is tracked in Issue #508.
#![allow(unused)]
fn main() {
use std::sync::Arc;
use laurus::Storage;
use laurus::storage::file::{FileStorage, FileStorageConfig};
// mmap is on by default on Unix; on Windows it is off unless
// LAURUS_USE_MMAP=1 is set.
let config = FileStorageConfig::new("/tmp/laurus-data");
let storage: Arc<dyn Storage> = Arc::new(FileStorage::new("/tmp/laurus-data", config)?);
// Explicit opt-out without touching the env var (works on any OS).
let mut buffered_config = FileStorageConfig::new("/tmp/laurus-data");
buffered_config.use_mmap = false;
// Explicit opt-in (works on any OS, including Windows).
let mut mmap_config = FileStorageConfig::new("/tmp/laurus-data");
mmap_config.use_mmap = true;
}
| Property | Value |
|---|---|
| Durability | Full (persisted to disk) |
| Speed | Fast (OS-managed memory mapping; zero-copy posting decode) |
| Use case | Default for any production-scale workload |
StorageFactory
You can also create storage via configuration:
#![allow(unused)]
fn main() {
use laurus::storage::{StorageConfig, StorageFactory};
use laurus::storage::memory::MemoryStorageConfig;
let storage = StorageFactory::create(
StorageConfig::Memory(MemoryStorageConfig::default())
)?;
}
PrefixedStorage
The engine uses PrefixedStorage to isolate components within a single storage backend:
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
When the lexical store writes a key segments/seg-001.dict, it is actually stored as lexical/segments/seg-001.dict in the underlying backend. This ensures no key collisions between components.
You do not need to create PrefixedStorage yourself — the EngineBuilder handles this automatically.
ColumnStorage
In addition to the primary storage backends, Laurus provides a ColumnStorage layer for fast field-level access. This is used internally for operations like faceting, sorting, and aggregation, where accessing individual field values without deserializing entire documents is important.
ColumnValue
ColumnValue represents a single stored column value:
| Variant | Description |
|---|---|
String(String) | UTF-8 text |
I32(i32) | 32-bit signed integer |
I64(i64) | 64-bit signed integer |
U32(u32) | 32-bit unsigned integer |
U64(u64) | 64-bit unsigned integer |
F32(f32) | 32-bit floating point |
F64(f64) | 64-bit floating point |
Bool(bool) | Boolean |
DateTime(i64) | Unix timestamp (seconds) |
Null | Absent value |
ColumnStorage is managed internally by the Engine – you do not need to interact with it directly.
Choosing a Backend
| Factor | MemoryStorage | FileStorage | FileStorage (mmap) |
|---|---|---|---|
| Durability | None | Full | Full |
| Read speed | Fastest | Moderate | Fast |
| Write speed | Fastest | Moderate | Moderate |
| Memory usage | Proportional to data size | Low | OS-managed |
| Max data size | Limited by RAM | Limited by disk | Limited by disk + address space |
| Best for | Tests, small datasets | General use | Large read-heavy datasets |
Recommendations
- Development / Testing: Use
MemoryStoragefor fast iteration without file cleanup - Production (general): Use
FileStoragefor reliable persistence - Production (large scale): Use
FileStoragewithuse_mmap = truewhen you have large indexes and want to leverage OS page cache
Next Steps
- Learn how the lexical index works: Lexical Indexing
- Learn how the vector index works: Vector Indexing
Indexing
This section explains how Laurus stores and organizes data internally. Understanding the indexing layer will help you choose the right field types and tune performance.
Topics
Lexical Indexing
How text, numeric, and geographic fields are indexed using an inverted index. Covers:
- The inverted index structure (term dictionary, posting lists)
- BKD trees for numeric range queries
- Segment files and their formats
- BM25 scoring
Vector Indexing
How vector fields are indexed for approximate nearest neighbor search. Covers:
- Index types: Flat, HNSW, IVF
- Parameter tuning (m, ef_construction, n_clusters, n_probe)
- Distance metrics (Cosine, Euclidean, DotProduct)
- Quantization (SQ8, PQ)
Lexical Indexing
Lexical indexing powers keyword-based search. When a document’s text field is indexed, Laurus builds an inverted index — a data structure that maps terms to the documents containing them.
How Lexical Indexing Works
sequenceDiagram
participant Doc as Document
participant Analyzer
participant Writer as IndexWriter
participant Seg as Segment
Doc->>Analyzer: "The quick brown fox"
Analyzer->>Analyzer: Tokenize + Filter
Analyzer-->>Writer: ["quick", "brown", "fox"]
Writer->>Writer: Buffer in memory
Writer->>Seg: Flush to segment on commit()
Step by Step
- Analyze: The text passes through the configured analyzer (tokenizer + filters), producing a stream of normalized terms
- Buffer: Terms are stored in an in-memory write buffer, organized by field
- Commit: On
commit(), the buffer is flushed to a new segment on storage
The Inverted Index
An inverted index is essentially a map from terms to document lists:
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
| Component | Description |
|---|---|
| Term Dictionary | Sorted dictionary of unique terms in the index. Disk format is a Lucene BlockTreeTermsWriter-style block-tree (FST over per-block representative terms + front-coded term bytes inside each 128-term block + bit-packed TermInfo); in-memory is an AHashMap-indexed parallel-array query layer populated at load time. Disk format minimises file size, in-memory layer keeps get / iter / prefix-scan at parallel-array speed. Supports exact lookup, ordered iteration, and prefix scans. |
| Posting Lists | For each term, a list of document IDs and metadata (term frequency, positions) |
| Doc Values | Column-oriented storage for sort/filter operations on numeric and date fields |
Posting List Contents
Each entry in a posting list contains:
| Field | Description |
|---|---|
| Document ID | Internal u64 identifier (per-segment value must fit in u32) |
| Term Frequency | How many times the term appears in this document |
| Positions (optional) | Where in the document the term appears (needed for phrase queries) |
| Weight | Score weight for this posting |
On-Disk Posting Layout
Posting lists are stored in a structure-of-arrays layout with each field
written as its own contiguous section. Document IDs and term frequencies are
encoded in fixed-size 128-int blocks using bit-packing (Frame-of-Reference
plus sorted-delta for doc IDs), with any partial trailing block falling back
to varint. This matches the on-disk format used by Tantivy and Lucene 9 and
yields fast SIMD-accelerated decoding through the
bitpacking crate.
[term, total_frequency, doc_frequency, posting_count N, any_positions]
[Skip levels — v2 only: num_levels + per-level (len + u32 doc_ids)]
[Section 1: doc_ids — N/128 bit-packed blocks + varint tail]
[Section 2: frequencies — N/128 bit-packed blocks + varint tail]
[Section 3: weights — N raw f32 values]
[Section 4: positions — per-posting flag + varint deltas (only if any)]
Per-segment doc IDs must fit in u32. Encoding a value beyond u32::MAX
fails fast with a clear error to prevent silent corruption of the
bit-packed segment.
The decoder exposes a SoA-native fast path (PostingList::decode_soa) that
produces parallel Vec<u32> slices for doc_id and frequency directly
from disk without the intermediate Vec<Posting> reassembly. The query
iterator stores those slices, so next() advances a single u32 cursor
over a dense slice instead of striding across a 40-byte Posting struct.
Multi-Level Skip Table
A posting list of N ≥ 8 postings carries a Lucene-90-style
multi-level skip table immediately after the header (v2 format,
introduced for skip_to performance on seek-heavy workloads).
Each level stores the “last doc id” of a fixed-stride window over
doc_ids; level 0 has one entry per SKIP_INTERVAL = 8 postings,
level 1 covers 8² postings, and so on until the top level
collapses to a single entry.
PostingIterator::skip_to(target) walks the table top-down: at each
level a single partition_point narrows the search window by a factor
of SKIP_INTERVAL, descends one level, and finishes with a linear
scan over the bottom-level window of at most 8 postings. Total work
is O(log_8 N + SKIP_INTERVAL) per call — for N = 1 M that is
~25 comparisons, versus the linear O(N / block_size) walk the
single-level block_cache paid before.
The table is co-located with the posting list rather than stored in a
separate file, matching Lucene 9 / Tantivy. Segments written in the
legacy v1 format (without the on-disk skip table) remain readable —
the SoA decoder rebuilds the table from doc_ids at load time.
Term Dictionary Storage Layers
The dictionary uses a two-layer representation that decouples on-disk compactness from in-memory query speed:
- Disk layer — the
.dictfile uses a LuceneBlockTreeTermsWriter-style block-tree layout (magicLTDD, schema v1). This produces a compact file: at 100k unique 5-10 byte terms a.dictis ~12.5 bytes / term, roughly 70 % smaller than the prior parallel-array on-disk format. - In-memory query layer — at build / load time the dictionary
populates an
AHashMap<term, ordinal>index, an ordinal-indexed sortedVec<String>, and a single-copyArc<[TermInfo]>.get/iter/find_prefix/find_rangeoperate exclusively on these structures, so per-query cost matches the prior parallel-array implementation (no per-call FST traversal or in-block linear scan).
The disk-format scratch (FST + BlockSection bytes) is retained only so
[BlockTermDictionary::write_to_storage] can re-serialise the
dictionary on segment merge without re-encoding from scratch.
[Header ] magic "LTDD" + version
[FstSection ] fst::Map<u64> keyed by each block's last term,
valued by that block's start byte offset
[BlockSection ] concatenated 128-term blocks, each containing
front-coded term bytes, a bit-packed
fixed-size TermInfo block, and a
variable-length per-term Block-Max-WAND
metadata array
[Footer ] total term count + block count
Lookup walks the FST once (O(|term|)) to identify the block whose
last term is ≥ target, then performs a linear front-coded scan
inside the block (≤ 128 entries). Iteration walks the BlockSection
sequentially without consulting the FST, decoding each block’s
front-coded prefix once and reusing the buffer across terms. Prefix
scans combine the two: FST seek to the first matching block, then
sequential block walk until the prefix no longer matches.
Compared to a flat per-term FST the block-head FST is one to two
orders of magnitude smaller, and the front-coded block bytes plus
bit-packed TermInfo typically cut the on-disk size by 50-80 % at
production scale.
Numeric and Date Fields
Integer, float, and datetime fields are indexed using a BKD tree — a space-partitioning data structure optimized for range queries:
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 trees allow efficient evaluation of range queries like price:[10 TO 100] or date:[2024-01-01 TO 2024-12-31].
Geo Fields
Geographic fields come in two flavours, both backed by the same multi-dimensional BKD-Tree primitive:
| Field type | Dimensions | Coordinates | Supported queries |
|---|---|---|---|
Geo | 2 | WGS84 latitude / longitude (degrees) | radius, bounding box |
Geo3d | 3 | ECEF Cartesian (x, y, z) in metres | 3D distance (sphere), 3D bounding box, k-nearest neighbours |
Geo3d is the right choice when altitude is a first-class dimension —
drones, satellites, indoor 3D positioning, or anything else that a 2D
Geo field would lose or distort near the poles. See
3D Geographic Search (ECEF) for the coordinate system,
WGS84 conversion helpers, and DSL syntax.
Segments
The lexical index is organized into segments. Each segment is an immutable, self-contained mini-index:
graph TB
LI["Lexical Index"]
LI --> S1["Segment 0"]
LI --> S2["Segment 1"]
LI --> S3["Segment 2"]
S1 --- F1[".dict (terms)"]
S1 --- F2[".post (postings)"]
S1 --- F3[".bkd (numerics)"]
S1 --- F4[".docs (doc store)"]
S1 --- F5[".dv (doc values)"]
S1 --- F6[".meta (metadata)"]
S1 --- F7[".lens (field lengths)"]
| File Extension | Contents |
|---|---|
.dict | Term dictionary in the v1 LTDD block-tree layout (FST over per-block representative terms + 128-term blocks of front-coded term bytes + bit-packed TermInfo). Loaded into an AHashMap-backed in-memory query layer at segment open. |
.post | Posting lists (document IDs, term frequencies, positions) |
.bkd | BKD tree data for numeric, date, Geo (2D), and Geo3d (3D ECEF) fields |
.docs | Stored field values (the original document content) |
.dv | Doc values for sorting and filtering |
.meta | Segment metadata (doc count, term count, etc.) |
.lens | Field length norms (for BM25 scoring) |
Segment Lifecycle
- Create: A new segment is created each time
commit()is called - Search: All segments are searched in parallel and results are merged
- Merge: After each
commit(), an auto-merge merges the smallest segments once the count exceedsmax_segments, keeping the segment count bounded; a manualoptimize()force-merges everything into one segment - Delete: When a document is deleted, its ID is added to a deletion bitmap rather than physically removed (see Deletions & Compaction)
BM25 Scoring
Laurus uses the BM25 algorithm to score lexical search results. BM25 considers:
- Term Frequency (TF): how often the term appears in the document (more = better, with diminishing returns)
- Inverse Document Frequency (IDF): how rare the term is across all documents (rarer = more important)
- Field Length Normalization: shorter fields are boosted relative to longer ones
The formula:
score(q, d) = IDF(q) * (TF(q, d) * (k1 + 1)) / (TF(q, d) + k1 * (1 - b + b * |d| / avgdl))
Where k1 = 1.2 and b = 0.75 are the default tuning parameters.
SIMD Optimization
Vector distance calculations leverage SIMD (Single Instruction, Multiple Data) instructions when available, providing significant speedups for similarity computations in vector search.
Code Example
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(())
}
Next Steps
- Learn how vector indexes work: Vector Indexing
- Run queries against the lexical index: Lexical Search
Vector Indexing
Vector indexing powers similarity-based search. When a document’s vector field is indexed, Laurus stores the embedding vector in a specialized index structure that enables fast approximate nearest neighbor (ANN) retrieval.
How Vector Indexing Works
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
Step by Step
- Embed: The text (or image) is converted to a vector by the configured embedder
- Normalize: The vector is L2-normalized (for cosine similarity)
- Index: The vector is inserted into the configured index structure (Flat, HNSW, or IVF)
- Commit: On
commit(), the index is flushed to persistent storage
Index Types
Laurus supports three vector index types, each with different performance characteristics:
Comparison
| Property | Flat | HNSW | IVF |
|---|---|---|---|
| Accuracy | 100% (exact) | ~95-99% (approximate) | ~90-98% (approximate) |
| Search speed | O(n) linear scan | O(log n) graph walk | O(n/k) cluster scan |
| Memory usage | Low | Higher (graph edges) | Moderate (centroids) |
| Index build time | Fast | Moderate | Slower (clustering) |
| Best for | < 10K vectors | 10K - 10M vectors | > 1M vectors |
Flat Index
The simplest index. Compares the query vector against every stored vector (brute-force).
#![allow(unused)]
fn main() {
use laurus::vector::FlatOption;
use laurus::vector::core::distance::DistanceMetric;
let opt = FlatOption {
dimension: 384,
distance: DistanceMetric::Cosine,
..Default::default()
};
}
- Pros: 100% recall (exact results), simple, low memory
- Cons: Slow for large datasets (linear scan)
- Use when: You have fewer than ~10,000 vectors, or you need exact results
HNSW Index
Hierarchical Navigable Small World graph. The default and most commonly used index type.
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
The HNSW algorithm searches from the top (sparse) layer down to the bottom (dense) layer, narrowing the search space at each level.
#![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 Parameters
| Parameter | Default | Description | Impact |
|---|---|---|---|
m | 16 | Max bi-directional connections per layer | Higher = better recall, more memory |
ef_construction | 200 | Search width during index building | Higher = better recall, slower build |
dimension | 128 | Vector dimensions | Must match embedder output |
distance | Cosine | Distance metric | See Distance Metrics below |
Tuning tips:
- Increase
m(e.g., 32 or 64) for higher recall at the cost of memory - Increase
ef_construction(e.g., 400) for better index quality at the cost of build time - At search time, the
ef_searchparameter (set in the search request) controls the search width
IVF Index
Inverted File Index. Partitions vectors into clusters, then only searches relevant clusters.
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 Parameters
| Parameter | Default | Description | Impact |
|---|---|---|---|
n_clusters | 100 | Number of Voronoi cells | More clusters = faster search, lower recall |
n_probe | 1 | Clusters to search at query time | Higher = better recall, slower search |
dimension | (required) | Vector dimensions | Must match embedder output |
distance | Cosine | Distance metric | See Distance Metrics below |
Tuning tips:
- Set
n_clustersto roughlysqrt(n)wherenis the number of vectors - Set
n_probeto 5-20% ofn_clustersfor a good recall/speed trade-off - IVF requires a training phase — initial indexing may be slower
Distance Metrics
| Metric | Description | Range | Best For |
|---|---|---|---|
Cosine | 1 - cosine similarity | [0, 2] | Text embeddings (most common) |
Euclidean | L2 distance | [0, +inf) | Spatial data |
Manhattan | L1 distance | [0, +inf) | Feature vectors |
DotProduct | Negative inner product | (-inf, +inf) | Pre-normalized vectors |
Angular | Angular distance | [0, pi] | Directional similarity |
#![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
}
Note: For cosine similarity, vectors are automatically L2-normalized before indexing. Lower distance = more similar.
Quantization
Vectors are stored on disk as 8-bit scalar-quantized integers
(Issue #481 Stage 1). Compared to the previous 32-bit float storage
this is ~4x smaller with negligible recall loss in practice
(Recall@10 remains ≥ 0.95 against the f32 ground truth — see the
recall test at laurus/tests/vector_recall_test.rs).
| Method | Enum Variant | Description | Memory Reduction |
|---|---|---|---|
| Scalar 8-bit (default) | Scalar8Bit | Per-segment global affine quantization to u8 | ~4x |
| Product Quantization | ProductQuantization { subvector_count } | Stage 3 of #481 — per-segment codebook of M × 256 centroids, each vector stored as M bytes | ~16-64x (HNSW-only) |
#![allow(unused)]
fn main() {
use laurus::vector::HnswOption;
use laurus::vector::core::quantization::QuantizationMethod;
// `quantizer` defaults to `Scalar8Bit`; the explicit form below is
// equivalent to `HnswOption { dimension: 384, ..Default::default() }`.
let opt = HnswOption {
dimension: 384,
quantizer: QuantizationMethod::Scalar8Bit,
..Default::default()
};
}
Breaking change (Issue #481 Stage 1): the
quantizerfield is no longerOption<QuantizationMethod>; quantization is mandatory and defaults toScalar8Bit. There is no longer an unquantized (f32) on-disk format. Existing pre-Stage-1 vector indexes are intentionally not readable by this version — rebuild the index from source data.
How Scalar8Bit works
- Each segment trains a single global
(offset, scale)pair from its f32 vectors at flush time (offset = min,scale = (max - min) / 255). - Each
f32element is encoded asu8 = clamp(round((v - offset) / scale), 0, 255). - Per-vector metadata (
sum_q: u32,norm_q: f32) is precomputed and persisted alongside the int8 payload so the cosine search hot loop collapses to one int8 SIMD multiply-accumulate plus three scalar corrections — no per-element dequantization at search time. - Segment files start with the
LVS1magic + a 16-byte header so the reader can detect the format at load time.
Two-stage rerank (Issue #481 Stage 2)
Stage 1 stores vectors as int8 only. The graph search runs entirely against int8 distances, which is fast but introduces a small quantization error. Stage 2 adds an optional per-field f32 sidecar so the searcher can rescore the top candidates against the original full-precision vectors:
- The HNSW int8 graph search returns up to
ef_searchcandidates ranked by quantized cosine distance. - The top
top_k * rerank_factorcandidates are rescored against the f32 vectors loaded from the LRS1 sidecar (*.hnsw.f32). - The new ranking is truncated to
top_kand returned.
Stage 2 is opt-in per field via
HnswOption.rerank_storage:
#![allow(unused)]
fn main() {
use laurus::vector::HnswOption;
use laurus::vector::core::rerank::RerankStorageKind;
let opt = HnswOption {
rerank_storage: Some(RerankStorageKind::F32),
..HnswOption::default()
};
}
Queries pass the rerank factor through VectorIndexQuery::rerank_factor
(low-level), SearchRequestBuilder::vector_rerank_factor (engine), or
the gRPC / JSON VectorParams.rerank_factor field.
Fields without rerank_storage enabled silently fall back to the
Stage 1 int8 ranking even when rerank_factor is set — there is no
f32 information to recover from a Stage 1 segment.
LRS1 rerank sidecar
The sidecar is a separate file written next to the LVS1 segment when
rerank_storage is enabled:
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
Vectors are written in the same (doc_id, field_name) order as the
matching LVS1 segment, so a (sidecar position) → (LVS1 position)
mapping is the identity. The HNSW reader loads the sidecar into a
RerankStoragePool at init time when the storage loading mode is
Eager; Lazy mode skips the sidecar to honor its memory-savings
promise (Stage 2 segments opened in Lazy mode silently degrade to
Stage 1).
Recall vs speed trade-off
rerank_factor lets you exchange a small per-query rerank cost
(~top_k * rerank_factor exact-distance calls — a few µs at dim
128) for higher Recall@10. The gain depends on the corpus and the
graph search budget (ef_search):
- Real clustered embedding data (text-embedding-3, BERT, etc.)
reaches
Recall@10 ≥ 0.99at lowef_search; rerank polishes the ranking with negligible latency overhead. - Synthetic random unit-norm data (the worst case for HNSW recall
recovery) needs a higher
ef_searchfor the int8 graph to visit enough true top-10 candidates; rerank then re-orders the visited set but cannot retrieve candidates the graph never reached.
The recall acceptance is split into two CI gates so the rerank kernel and the full HNSW pipeline can fail independently:
stage2_brute_force_rerank_recall_at_10_meets_kernel_gateassertsRecall@10 ≥ 0.99. Bypasses the HNSW graph entirely (brute-force int8 over the corpus, widen totop_k * rerank_factor, rescore with f32) so any miss is a rerank-kernel regression.hnsw_quantized_recall_at_10_with_rerank_meets_stage2_recall_gateassertsRecall@10 ≥ 0.98. Adds the HNSW graph-construction non-determinism that an f32 HNSW baseline would also contribute; the looser gate matches the observed run-to-run variance band on this synthetic adversarial distribution. Real clustered embedding data and a stronger HNSW config (m=32, ef_construction=500) reach ≥ 0.99 on this path too — see the diagnostic sweep below.
The companion stage2_recall_sweep_diagnostic (opt-in via
LAURUS_STAGE2_SWEEP=1) sweeps (ef_search, rerank_factor) across
three corpus / query distributions and two HNSW configs so
production deployments can calibrate the budget for their actual
embedding distribution.
Real-data validation (Issue #498)
A third opt-in CI gate validates Stage 2 against a real ANN benchmark dataset (SIFT1M from TEXMEX) so the synthetic-data gates above do not become the only signal:
hnsw_quantized_recall_at_10_with_rerank_on_sift_meets_stage2_real_data_recall_gateassertsRecall@10 ≥ 0.99on a 50 000-vector SIFT1M subsample at(m=16, ef_construction=200, ef_search=200, rerank_factor=5).- The companion bench
bench_hnsw_graph_search_rerank_real_data(inlaurus/benches/vector_search_bench.rs) measures end-to-end Stage 2 latency on the same fixture. The accompanying examplelaurus/examples/sift_rerank_probe.rsruns a full(ef_search × rerank_factor × HNSW config)sweep with(Recall, latency)per cell so operators can pick the operating point for their own data shape.
Both are gated on LAURUS_REAL_BENCHMARK=1 AND the presence of the
SIFT1M .fvecs files under .cache/sift/sift/. Default CI runs are
unchanged. To enable locally:
./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"
The Issue #481 wording originally asked for “≥ 3× speedup vs the
pre-Stage-1 f32 baseline.” Cross-branch Criterion measurements on
SIFT1M-50k (median over 30 samples, same (m, ef_construction, ef_search)) gave 625 µs/query on the pre-Stage-1 f32 HNSW path
versus 323 µs/query on the Stage 2 int8 + rerank path — a 1.94×
speedup. Issue #498 reduces the real-data gate to ≥ 1.5×
accordingly, with the recall side held at the original 0.99. The
gap between the original 3× target and the measured 1.94× comes
from rerank only re-ordering candidates the int8 graph traversal
already visited — a lower ef_search does not pay back through
rerank when the candidate set itself becomes too narrow, which a
follow-up could address by widening the graph search budget
independently of ef_search (the Lucene 99 pattern).
Product Quantization with rerank (Issue #481 Stage 3)
Stage 3 adds an opt-in Product Quantization path for the HNSW
index. Each segment trains a per-field codebook of M sub-vectors
× K = 256 centroids using Lloyd k-means with k-means++
initialisation, and stores every vector as M bytes (one centroid
index per sub-vector). The search hot loop replaces the int8 SIMD
kernel with asymmetric distance computation (ADC): per-query
the searcher builds an M × K look-up table of squared distances
between the query’s sub-vectors and the codebook entries, then
scores each candidate as Σ_m lut[m][codes[m]] — M table
lookups + M − 1 adds per candidate.
PQ is enabled per field via 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 },
// PQ-only Recall@10 caps out around 0.78-0.92 on SIFT1M, so
// production deployments should pair PQ with the LRS1 rerank
// sidecar — same Stage 2 mechanism, just driven by PQ
// candidate generation instead of int8.
rerank_storage: Some(RerankStorageKind::F32),
..Default::default()
};
}
subvector_count must divide dimension. Common choices for
dim = 128: M ∈ {8, 16, 32} (sub_dim 16 / 8 / 4). Larger M
trades on-disk compression for higher recall — Issue #481 Stage 3
ships only the 8-bit (K = 256) variant; the on-disk format reserves
a 4-bit (K = 16) slot for a future PR.
On-disk format
PQ segments use the same LVS1 header as Scalar8Bit (quant_kind = 2) and carry the codebook inside the 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 ]
For dim = 128, M = 32, K = 256: codebook = 32 × 256 × 4 × 4 = 131 072 bytes (128 KB) per segment plus 32 bytes per vector.
Recall and speed gates (Issue #481 Stage 3)
- Kernel-level test — synthetic 5 000-vector / dim 128 / 100
queries at
(m=16, ef_construction=200, ef_search=200, rerank_factor=10, M=32):hnsw_pq_rerank_recall_at_10_meets_stage3_recall_gateasserts Recall@10 ≥ 0.95. Measured 0.9660. - Real-data test — SIFT1M-50k subsample (opt-in via
LAURUS_REAL_BENCHMARK=1, same config):hnsw_pq_rerank_recall_at_10_on_sift_meets_stage3_real_data_recall_gateasserts Recall@10 ≥ 0.95. Measured 0.9965. - Real-data speed bench —
bench_hnsw_graph_search_pq_rerank_real_data(opt-in, Criterion). Cross-branch measurement at the same SIFT1M-50k config: pre-Stage-1 f32 HNSW = 625.21 µs/query (PR #500); Stage 3 PQ + rerank = 299.54 µs/query = 2.09× speedup.
Issue #481 originally asked for ≥ 5× speedup at Recall ≥ 0.95. The PR established that target is not reachable on SIFT1M with the current implementation — both Stage 2 (#500) and this Stage 3 PR have measured speedups in the 1.9-2.1× band because rerank dominates the wall-clock once the candidate set is wide enough to recover recall. The gate was reduced to ≥ 1.5× accordingly. A follow-up could pursue the Lucene 99 pattern (independent graph search budget) and / or a 4-bit PQ variant to close the gap.
Segment Files
Each vector index type stores its data in a single segment file:
| Index Type | File Extension | Contents |
|---|---|---|
| HNSW | .hnsw | Graph structure, vectors, and metadata |
| Flat | .flat | Raw vectors and metadata |
| IVF | .ivf | Cluster centroids, assigned vectors, and metadata |
Code Example
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(())
}
Next Steps
- Search the vector index: Vector Search
- Combine with lexical search: Hybrid Search
Search
This section covers how to query your indexed data. Laurus supports three search modes that can be used independently or combined.
Topics
Lexical Search
Keyword-based search using an inverted index. Covers:
- All query types: Term, Phrase, Boolean, Fuzzy, Wildcard, Range, Geo, Span
- BM25 scoring and field boosts
- Using the Query DSL for text-based queries
Vector Search
Semantic similarity search using vector embeddings. Covers:
- VectorSearchRequestBuilder API
- Multi-field vector search and score modes
- Filtered vector search
Hybrid Search
Combining lexical and vector search for best-of-both-worlds results. Covers:
- SearchRequestBuilder API
- Fusion algorithms (RRF, WeightedSum)
- Filtered hybrid search
- Pagination with offset/limit
For spelling correction, see Spelling Correction in the Library section.
Lexical Search
Lexical search finds documents by matching keywords against an inverted index. Laurus provides a rich set of query types that cover exact matching, phrase matching, fuzzy matching, and more.
Basic Usage
#![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?;
}
Query Types
TermQuery
Matches documents containing an exact term in a specific field.
#![allow(unused)]
fn main() {
use laurus::lexical::TermQuery;
// Find documents where "body" contains the term "rust"
let query = TermQuery::new("body", "rust");
}
Note: Terms are matched after analysis. If the field uses
StandardAnalyzer, both the indexed text and the query term are lowercased, soTermQuery::new("body", "rust")will match “Rust” in the original text.
PhraseQuery
Matches documents containing an exact sequence of terms.
#![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");
}
Phrase queries require term positions to be stored (the default for TextOption).
BooleanQuery
Combines multiple queries with boolean logic.
#![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 | Meaning | DSL Equivalent |
|---|---|---|
Must | Document MUST match | +term or AND |
Should | Document SHOULD match (boosts score) | term or OR |
MustNot | Document MUST NOT match | -term or NOT |
Filter | MUST match, but does not affect score | (no DSL equivalent) |
FuzzyQuery
Matches terms within a specified edit distance (Levenshtein distance).
#![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
Matches terms using wildcard patterns.
#![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
Matches documents containing terms that start with a specific prefix.
#![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
Matches documents containing terms that match a regular expression pattern.
#![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+")?;
}
Note:
RegexpQuery::new()returnsResultbecause the regex pattern is validated at construction time. Invalid patterns will produce an error.
NumericRangeQuery
Matches documents with numeric field values within a range.
#![allow(unused)]
fn main() {
use laurus::lexical::NumericRangeQuery;
use laurus::lexical::core::field::NumericType;
// Find documents where "price" is between 10.0 and 100.0 (inclusive)
let query = NumericRangeQuery::new(
"price",
NumericType::Float,
Some(10.0), // min
Some(100.0), // max
true, // include min
true, // include max
);
// Open-ended range: price >= 50
let query = NumericRangeQuery::new(
"price",
NumericType::Float,
Some(50.0),
None, // no upper bound
true,
false,
);
}
GeoQuery
Matches documents by 2D geographic location (WGS84 latitude / longitude).
#![allow(unused)]
fn main() {
use laurus::lexical::query::geo::GeoQuery;
// Find documents within 10 km (= 10 000 m) of Tokyo Station (35.6812, 139.7671)
let query = GeoQuery::within_radius("location", 35.6812, 139.7671, 10_000.0)?; // distance in metres
// Find documents within a bounding box (min_lat, min_lon, max_lat, max_lon)
let query = GeoQuery::within_bounding_box(
"location",
35.0, 139.0, // min (lat, lon)
36.0, 140.0, // max (lat, lon)
)?;
}
Geo3dDistanceQuery / Geo3dBoundingBoxQuery / Geo3dNearestQuery
Three queries target 3D Geo3d fields backed by ECEF Cartesian coordinates
(metres). Use them when altitude matters or when a 2D Geo field would
introduce pole singularities. See 3D Geographic Search for
the coordinate system, WGS84 conversion helpers, and worked examples.
#![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);
// Sphere: docs within 5 km of `centre`
let q = Geo3dDistanceQuery::new("position", centre, 5_000.0);
// Axis-aligned 3D bounding box (constructor validates min ≤ max per axis)
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 nearest neighbours, with a custom radius schedule
let q = Geo3dNearestQuery::new("position", centre, 10)
.with_initial_radius(500.0)
.with_max_radius(1_000_000.0);
}
| Query | Score |
|---|---|
Geo3dDistanceQuery | 1 - distance / radius, clamped to [0, 1]. |
Geo3dBoundingBoxQuery | Constant 1.0 for every match. |
Geo3dNearestQuery | Normalised so the closest hit is 1.0, the farthest in the returned set is 0.0. |
SpanQuery
Matches terms based on their proximity within a document. Use SpanTermQuery and SpanNearQuery to build proximity queries:
#![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)
);
}
Scoring
Lexical search results are scored using BM25. The score reflects how relevant a document is to the query:
- Higher term frequency in the document increases the score
- Rarer terms across the index increase the score
- Shorter documents are boosted relative to longer ones
Field Boosts
You can boost specific fields to influence relevance using the SearchRequestBuilder:
#![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"))))
.add_field_boost("title", 2.0) // title matches count double
.add_field_boost("body", 1.0)
.build();
}
Lexical Search Options
Lexical search behavior is controlled via LexicalSearchOptions on the SearchRequest, or by using builder methods on SearchRequestBuilder:
| Option | Default | Description |
|---|---|---|
field_boosts | empty | Per-field score multipliers |
min_score | 0.0 | Minimum score threshold |
timeout_ms | None | Search timeout in milliseconds |
parallel | false | Enable parallel search across segments |
sort_by | Score | Sort by relevance score, or by a field (asc / desc) |
Builder Methods
SearchRequestBuilder provides convenience methods for lexical options:
#![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();
}
Using the Query DSL
Instead of building queries programmatically, you can use the text-based 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]")?;
}
See Query DSL for the complete syntax reference.
Filter Result Cache
Filter clauses — tenancy, category, status flags, and similar — are frequently reused across many requests. Re-evaluating them from scratch every time re-walks the same posting lists. Laurus memoises the set of document ids a filter matches so a repeated filter becomes a single lookup instead of a full posting walk.
- Snapshot-scoped, self-invalidating. The cache lives on the reader, which
is rebuilt on every
commit()/optimize()/refresh(). Each reader is a point-in-time snapshot, so the cache needs no manual invalidation: after the index changes, the next search starts from a fresh, empty cache and always reflects committed data. - Score-independent. A filter selects documents without affecting relevance,
so the cached value is a plain doc-id set (a Roaring bitmap). It is used for
the
filter_queryof a hybrid / filtered search and feeds both the lexical and vector sides. - Reused inside boolean queries. An
Occur::Filterclause within aBooleanQuery(e.g.must(user_query).filter(tenant_filter)) also draws its matched set from the cache instead of re-walking postings — including the per-segment fan-out path on multi-segment indexes. - Safe by construction. Only queries with a canonical key are cached. Term, phrase, prefix, wildcard, regexp, fuzzy, range, geo, and geo3d queries are cacheable, as are boolean queries composed entirely of cacheable clauses with at least one positive (Must / Should / Filter) clause. Boolean queries with no positive clause, span queries, and multi-field queries are evaluated fresh (never cached), so results are always correct.
The cache is enabled by default. Tune or disable it via the index config:
#![allow(unused)]
fn main() {
use laurus::lexical::store::config::LexicalIndexConfig;
let config = LexicalIndexConfig::builder()
.query_filter_cache_capacity(4096) // entries per snapshot; 0 disables the cache
.build();
}
Parsed Query Cache
Searching with a DSL string (SearchRequest::from_dsl, or a LexicalSearchQuery::Dsl)
parses the string with the pest grammar and re-tokenises its terms with the analyzer on
every call. Autocomplete and popular-query workloads repeat the same strings, so Laurus
memoises dsl string → parsed query: a repeated DSL string is parsed once and then reused
(a cheap clone of the parsed query tree).
Like the filter cache, it is snapshot-scoped: the cache lives on the searcher, which is
rebuilt on every commit() / optimize() / refresh(). The analyzer and default fields are
fixed for that searcher, so the DSL string alone is the key; a schema/analyzer change yields a
fresh, empty cache. Enabled by default; tune or disable via the index config:
#![allow(unused)]
fn main() {
use laurus::lexical::store::config::LexicalIndexConfig;
let config = LexicalIndexConfig::builder()
.parsed_query_cache_capacity(2048) // entries per snapshot; 0 disables the cache
.build();
}
Posting Cache
Evaluating a term reads its posting list from the segment’s .post file and decodes it
(varint doc-ids, deletion filtering, skip table). Without caching, every query for the same
term repeats that read + decode — and on cloud/remote storage the read dominates. Each segment
reader keeps a small cache of decoded, deletion-filtered posting lists, so a repeated
(field, term) lookup within a snapshot reuses the decoded list.
Because a segment is immutable for a reader snapshot, the cached list is always consistent with
its deletions; a commit builds new segment readers with empty caches. The cache is byte-budget
bounded (posting lists vary widely in size) — least-recently-used lists are evicted once the
budget is exceeded, and a single list larger than the whole budget is not cached. It is enabled
by default and shares the max_cache_memory budget; control it via the index config:
#![allow(unused)]
fn main() {
use laurus::lexical::store::config::LexicalIndexConfig;
use laurus::lexical::index::config::InvertedIndexConfig;
let mut inverted = InvertedIndexConfig::default();
inverted.enable_posting_cache = false; // disable entirely
inverted.max_cache_memory = 256 * 1024 * 1024; // or resize the cache budget (bytes)
let config = LexicalIndexConfig::Inverted(inverted);
}
Next Steps
- Semantic similarity search: Vector Search
- Combine lexical + vector: Hybrid Search
- Full DSL syntax reference: Query DSL
Vector Search
Vector search finds documents by semantic similarity. Instead of matching keywords, it compares the meaning of the query against document embeddings in vector space.
Basic Usage
Builder API
#![allow(unused)]
fn main() {
use laurus::SearchRequestBuilder;
use laurus::vector::search::searcher::VectorSearchQuery;
use laurus::vector::store::request::QueryPayload;
use laurus::data::DataValue;
let request = SearchRequestBuilder::new()
.vector_query(
VectorSearchQuery::Payloads(vec![
QueryPayload {
field: "embedding".to_string(),
payload: DataValue::Text("systems programming language".to_string()),
weight: 1.0,
},
])
)
.limit(10)
.build();
let results = engine.search(request).await?;
}
The QueryPayload stores raw data (text, bytes, etc.) that will be embedded at search time using the configured embedder.
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?;
}
VectorSearchQuery
The vector search query is specified as a VectorSearchQuery enum:
| Variant | Description |
|---|---|
Payloads(Vec<QueryPayload>) | Raw payloads (text, bytes, etc.) to be embedded at search time |
Vectors(Vec<QueryVector>) | Pre-embedded query vectors ready for nearest-neighbor search |
QueryPayload
| Field | Type | Description |
|---|---|---|
field | String | Target vector field name |
payload | DataValue | The payload to embed (e.g., DataValue::Text(...)) |
weight | f32 | Score weight (default: 1.0) |
QueryVector
| Field | Type | Description |
|---|---|---|
vector | Vector | Pre-computed dense vector embedding |
weight | f32 | Score weight (default: 1.0) |
fields | Option<Vec<String>> | Optional field restriction |
Examples
#![allow(unused)]
fn main() {
use laurus::vector::search::searcher::VectorSearchQuery;
use laurus::vector::store::request::{QueryPayload, QueryVector};
use laurus::vector::core::vector::Vector;
use laurus::data::DataValue;
// Text query (will be embedded at search time)
let query = VectorSearchQuery::Payloads(vec![
QueryPayload {
field: "text_vec".to_string(),
payload: DataValue::Text("machine learning".to_string()),
weight: 1.0,
},
]);
// Pre-computed vector
let query = VectorSearchQuery::Vectors(vec![
QueryVector {
vector: Vector::from(vec![0.1, 0.2, 0.3]),
weight: 1.0,
fields: Some(vec!["embedding".to_string()]),
},
]);
}
Multi-Field Vector Search
You can search across multiple vector fields in a single request:
#![allow(unused)]
fn main() {
use laurus::vector::search::searcher::VectorSearchQuery;
use laurus::vector::store::request::QueryPayload;
use laurus::data::DataValue;
let query = VectorSearchQuery::Payloads(vec![
QueryPayload {
field: "text_vec".to_string(),
payload: DataValue::Text("cute kitten".to_string()),
weight: 1.0,
},
QueryPayload {
field: "image_vec".to_string(),
payload: DataValue::Text("fluffy cat".to_string()),
weight: 1.0,
},
]);
}
Each clause produces a vector that is searched against its respective field. Results are combined using the configured score mode.
Score Modes
| Mode | Description |
|---|---|
WeightedSum (default) | Sum of (similarity * weight) across all clauses |
MaxSim | Maximum similarity score across clauses |
LateInteraction | ColBERT-style late interaction scoring |
Parallel Multi-Vector Execution
When a request carries multiple query vectors (e.g., ColBERT-style late interaction, multi-vector MaxSim, ensemble rerankers), Laurus dispatches the per-query similarity searches in parallel via rayon.
Behaviour:
- The parallelisation lives in
VectorIndexSearcher::search_batch’s default implementation. On native builds (defaultnativefeature), oncequeries.len()reaches the searcher’sparallel_threshold(default4, overridable per searcher), the per-query HNSW / Flat / IVF searches run on rayon’s global thread pool. Below that threshold the serial loop wins because rayon’s dispatch overhead (~1-2 µs) would otherwise dominate a single 50-200 µs query. - On
wasm32targets the serial path is always used because rayon is unavailable. - Aggregation (the score-mode merge) and the final sort run serially after
the per-query phase. Score ties are broken by ascending
doc_idso the results are deterministic regardless of rayon’s work-stealing schedule.
The external API surface (VectorStore::search, gRPC Search, REST
POST /v1/search, all language bindings) is unchanged; parallel execution
is purely an internal optimisation enabled by upgrading laurus. Speedups
scale with the host’s available cores — on a 4-core / 8-thread laptop CPU
the parallel path reaches roughly 2× throughput at B = 64 query vectors,
limited by physical core count and HyperThreading sharing.
Parallel Brute-Force Scan
Flat and IVF indexes rank candidates with an exhaustive distance scan rather
than a graph walk. When one query’s candidate count reaches an internal
threshold (2048), that scan is dispatched across rayon’s
global thread pool; below it the serial loop wins because rayon’s per-job
dispatch (~1-2 µs) would dominate a small scan.
This is orthogonal to the per-query parallelism above: a batch parallelises
across queries, and each large query further parallelises its own scan on the
same pool, with work-stealing bounding total parallelism to the pool size (no
OS-thread oversubscription). The distance kernel has no side effects, so
results are collected in arbitrary order and then sorted, keeping output
deterministic. On wasm32 (no rayon) the scan is always serial.
Speedup scales with the host’s physical cores and is largest for big Flat
indexes or a wide IVF n_probe; scans below the threshold are unaffected.
IVF Cluster Selection
Before the distance scan, an IVF query first chooses which clusters to scan:
it scores the query against every centroid and keeps the n_probe nearest.
Because the probed clusters are then merged and re-ranked by similarity, the
centroids’ relative order does not matter, so the nearest n_probe are taken
with an O(K) partial selection (select_nth_unstable_by) over the K
centroids rather than a full O(K log K) sort. The saving grows with the
cluster count K; at K = 2048 it cut the per-query coarse step by roughly
18% (Issue #668). The centroid scan itself stays serial — each centroid is a
single distance computation, so dispatching it to rayon costs more than it
saves at realistic cluster counts (K ≈ √N).
Weights
Use the ^ boost syntax in DSL or weight in QueryVector to adjust how much each field contributes:
text_vec:"cute kitten"^1.0 image_vec:"fluffy cat"^0.5
This means text similarity counts twice as much as image similarity.
Field Routing
In a multi-field schema, each vector field has its own HNSW graph. By default a query searches every vector field; restricting it to named fields skips the others’ graph work entirely.
Two routing inputs are honored, in priority order:
- Per-query —
QueryVector.fields. The DSL parser sets this from the field a clause names, soimage_vec:"fluffy cat"only searchesimage_vec. - Request-level —
VectorSearchParams.fields, a list of selectors:Exact("image_vec")— match a field by exact name.Prefix("image_")— match every field whose name starts with the prefix (resolved against the index’s field names).
When neither is set, all fields are searched (the default). A query routed to a field never returns documents that lack a vector in that field.
You can apply lexical filters to narrow the vector search results:
#![allow(unused)]
fn main() {
use laurus::SearchRequestBuilder;
use laurus::lexical::TermQuery;
use laurus::vector::search::searcher::VectorSearchQuery;
use laurus::vector::store::request::QueryPayload;
use laurus::data::DataValue;
// Vector search with a category filter
let request = SearchRequestBuilder::new()
.vector_query(
VectorSearchQuery::Payloads(vec![
QueryPayload {
field: "embedding".to_string(),
payload: DataValue::Text("machine learning".to_string()),
weight: 1.0,
},
])
)
.filter_query(Box::new(TermQuery::new("category", "tutorial")))
.limit(10)
.build();
let results = engine.search(request).await?;
}
The filter query runs first on the lexical index to identify allowed document IDs, then the vector search is restricted to those IDs.
Filter-Aware HNSW Traversal
For HNSW fields the allowed-ID set is pushed into the graph search itself, not just applied afterwards. During traversal the frontier still expands through every neighbour — including non-matching ones — so the search can cross non-matching regions of the graph, but only matching documents enter the result set.
This matters for selective filters. A plain post-filter inspects the fixed
ef_search window of nearest neighbours and keeps whichever happen to match;
when matches are rare they can fall entirely outside that window, so the query
returns far fewer hits than exist — sometimes none, even though matching
documents are reachable. Filter-aware traversal keeps searching until it has
collected enough matches or hits an internal visit cap (a multiple of
ef_search) that bounds latency for very selective filters.
The unfiltered path is unchanged: with no filter the traversal behaves exactly as before. Flat and IVF fields honour the allow-set inline — a candidate whose document ID is not in the set is skipped before the distance kernel runs, so a selective filter avoids the wasted distance computations a post-filter would incur. Their scan is exhaustive either way, so recall is unchanged; the store’s post-filter still runs afterwards as a redundant safety net.
When the allow-set is smaller than ef_search, the HNSW field skips the graph
walk entirely and scores the allowed documents directly. With so few candidates
there is nothing for the graph to find; the direct scan touches exactly the
allowed documents — never more than the walk would — and is exact, so a very
selective filter returns the true nearest matches rather than an approximation.
Larger allow-sets keep using the filter-aware traversal above.
Deletion-Aware HNSW Traversal
Logically deleted documents stay in the HNSW graph until compaction (see Deletions & Compaction), so the walk must skip them the same way it skips filtered-out documents. The graph traversal applies a single admission rule: a node enters the result set only if it matches the filter (when one is present) and is not deleted, while the frontier still expands through deleted nodes to preserve connectivity.
This is what keeps recall correct as deletions accumulate. If deleted nodes were
allowed into the result heap, they would consume the fixed ef_search slots and
push out live neighbours — in the worst case an ef_search window made entirely
of deleted documents would return nothing. Skipping them during the walk means
the same slots fill with live results instead, so a 10-hit page stays full even
after the nearest documents are deleted. The exact tiny-allow-set scan above and
the Flat/IVF inline paths apply the same deletion check.
The fast path is preserved: when a search has neither a filter nor any deletions, the traversal runs the original loop unchanged, paying nothing for the per-neighbour admission bookkeeping.
Allow-Set Representation
The allow-set is a typed structure chosen by shape: a Roaring bitmap for dense filters and a hash set for sparse ones. For a filtered hybrid search the lexical side already produced the matching document set as a bitmap (see the lexical Filter Result Cache); that bitmap is handed to the vector side as-is, so the set is materialised once for the whole query instead of being rebuilt for each side. This is an internal optimisation — the public filter API is unchanged.
Filter with Numeric Range
#![allow(unused)]
fn main() {
use laurus::lexical::NumericRangeQuery;
use laurus::lexical::core::field::NumericType;
let request = SearchRequestBuilder::new()
.vector_query(
VectorSearchQuery::Payloads(vec![
QueryPayload {
field: "embedding".to_string(),
payload: DataValue::Text("type systems".to_string()),
weight: 1.0,
},
])
)
.filter_query(Box::new(NumericRangeQuery::new(
"year", NumericType::Integer,
Some(2020.0), Some(2024.0), true, true
)))
.limit(10)
.build();
}
Distance Metrics
The distance metric is configured per field in the schema (see Vector Indexing):
| Metric | Description | Lower = More Similar |
|---|---|---|
| Cosine | 1 - cosine similarity | Yes |
| Euclidean | L2 distance | Yes |
| Manhattan | L1 distance | Yes |
| DotProduct | Negative inner product | Yes |
| Angular | Angular distance | Yes |
Code Example: Complete Vector Search
use std::sync::Arc;
use laurus::{Document, Engine, Schema, SearchRequestBuilder, PerFieldEmbedder};
use laurus::lexical::TextOption;
use laurus::vector::HnswOption;
use laurus::vector::search::searcher::VectorSearchQuery;
use laurus::vector::store::request::QueryPayload;
use laurus::data::DataValue;
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(
VectorSearchQuery::Payloads(vec![
QueryPayload {
field: "text_vec".to_string(),
payload: DataValue::Text("systems language".to_string()),
weight: 1.0,
},
])
)
.limit(5)
.build()
).await?;
for r in &results {
println!("{}: score={:.4}", r.id, r.score);
}
Ok(())
}
Next Steps
- Combine with keyword search: Hybrid Search
- DSL syntax for vector queries: Query DSL
Hybrid Search
Hybrid search combines lexical search (keyword matching) with vector search (semantic similarity) to deliver results that are both precise and semantically relevant. This is Laurus’s most powerful search mode.
Why Hybrid Search?
| Search Type | Strengths | Weaknesses |
|---|---|---|
| Lexical only | Exact keyword matching, handles rare terms well | Misses synonyms and paraphrases |
| Vector only | Understands meaning, handles synonyms | May miss exact keywords, less precise |
| Hybrid | Best of both worlds | Slightly more complex to configure |
How It Works
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
Basic Usage
Builder API
#![allow(unused)]
fn main() {
use laurus::{SearchRequestBuilder, FusionAlgorithm};
use laurus::lexical::TermQuery;
use laurus::lexical::search::searcher::LexicalSearchQuery;
use laurus::vector::search::searcher::VectorSearchQuery;
use laurus::vector::store::request::QueryPayload;
use laurus::data::DataValue;
let request = SearchRequestBuilder::new()
// Lexical component
.lexical_query(
LexicalSearchQuery::Obj(
Box::new(TermQuery::new("body", "rust"))
)
)
// Vector component
.vector_query(
VectorSearchQuery::Payloads(vec![
QueryPayload {
field: "text_vec".to_string(),
payload: DataValue::Text("systems programming".to_string()),
weight: 1.0,
},
])
)
// Fusion algorithm
.fusion_algorithm(FusionAlgorithm::RRF { k: 60.0 })
.limit(10)
.build();
let results = engine.search(request).await?;
}
Query DSL
Mix lexical and vector clauses in a single query string:
#![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?;
}
The parser uses the schema to identify vector clauses by field type. Fields defined as vector fields (e.g., HNSW) are parsed as vector queries; everything else is parsed as lexical.
Fusion Algorithms
When both lexical and vector results exist, they must be merged into a single ranked list. Laurus supports two fusion algorithms:
RRF (Reciprocal Rank Fusion)
The default algorithm. Combines results based on their rank positions rather than raw scores.
score(doc) = sum( 1 / (k + rank_i) )
Where rank_i is the position of the document in each result list, and k is a smoothing parameter (default 60).
#![allow(unused)]
fn main() {
use laurus::FusionAlgorithm;
let fusion = FusionAlgorithm::RRF { k: 60.0 };
}
Advantages:
- Robust to different score distributions between lexical and vector results
- No need to tune weights
- Works well out of the box
WeightedSum
Linearly combines normalized lexical and vector scores:
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,
};
}
When to use:
- When you want explicit control over the balance between lexical and vector relevance
- When you know one signal is more important than the other
SearchRequest Fields
| Field | Type | Default | Description |
|---|---|---|---|
query | SearchQuery | Dsl("") | Search query (Dsl, Lexical, Vector, or Hybrid) |
limit | usize | 10 | Maximum number of results to return |
offset | usize | 0 | Number of results to skip (for pagination) |
fusion_algorithm | Option<FusionAlgorithm> | None (uses RRF { k: 60.0 } when both results exist) | How to merge lexical and vector results |
filter_query | Option<Box<dyn Query>> | None | Pre-filter using a lexical query (restricts both lexical and vector results) |
lexical_options | LexicalSearchOptions | Default | Parameters controlling lexical search behavior (field boosts, min score, timeout, etc.) |
vector_options | VectorSearchOptions | Default | Parameters controlling vector search behavior (score mode, min score) |
SearchResult
Each result contains:
| Field | Type | Description |
|---|---|---|
id | String | External document ID |
score | f32 | Fused relevance score |
document | Option<Document> | Full document content (if loaded) |
Filtered Hybrid Search
Apply a filter to restrict both lexical and vector results:
#![allow(unused)]
fn main() {
let request = SearchRequestBuilder::new()
.lexical_query(
LexicalSearchQuery::Obj(Box::new(TermQuery::new("body", "rust")))
)
.vector_query(
VectorSearchQuery::Payloads(vec![
QueryPayload {
field: "text_vec".to_string(),
payload: DataValue::Text("systems programming".to_string()),
weight: 1.0,
},
])
)
// Only search within "tutorial" category
.filter_query(Box::new(TermQuery::new("category", "tutorial")))
.fusion_algorithm(FusionAlgorithm::RRF { k: 60.0 })
.limit(10)
.build();
}
How Filtering Works
- The filter query runs on the lexical index to produce a set of allowed document IDs
- For lexical search: the filter is combined with the user query as a boolean AND
- For vector search: the allowed IDs are passed to restrict the ANN search
Pagination
Use offset and limit for pagination:
#![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();
}
Complete Example
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;
use laurus::vector::search::searcher::VectorSearchQuery;
use laurus::vector::store::request::QueryPayload;
use laurus::data::DataValue;
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(
VectorSearchQuery::Payloads(vec![
QueryPayload {
field: "body_vec".to_string(),
payload: DataValue::Text("systems language".to_string()),
weight: 1.0,
},
])
)
.fusion_algorithm(FusionAlgorithm::RRF { k: 60.0 })
.limit(10)
.build()
).await?;
for r in &results {
println!("{}: score={:.4}", r.id, r.score);
}
Ok(())
}
Next Steps
- Full query syntax reference: Query DSL
- Understand ID resolution: ID Management
- Data durability: Persistence & WAL
Query DSL
Laurus provides a unified query DSL (Domain Specific Language) that allows lexical (keyword) and vector (semantic) search in a single query string. The UnifiedQueryParser splits the input into lexical and vector portions and delegates to the appropriate sub-parser.
Overview
title:hello AND content:"cute kitten"^0.8
|--- lexical --| |--- vector --------|
The field type in the schema determines whether a clause is lexical or vector. If the field is a vector field (e.g., HNSW), the clause is treated as a vector query. Everything else is treated as a lexical query.
Field validation
Every field:value clause is validated against the schema at parse time. A
query that references a field not declared in the schema is rejected with
an error rather than returning silently-empty results. This catches typos
early (e.g. titl:hello instead of title:hello).
If you want the engine to accept documents with previously-unknown fields,
set the schema’s dynamic_field_policy
so that the field gets added during ingestion. Once a field is part of the
schema, queries referencing it succeed.
Lexical Query Syntax
Lexical queries search the inverted index using exact or approximate keyword matching.
Term Query
Match a single term against a field (or the default field):
hello
title:hello
Boolean Operators
Combine clauses with AND and OR (case-insensitive):
title:hello AND body:world
title:hello OR title:goodbye
AND is symmetric — it makes the clauses on both sides required (Must). For example, title:hello AND body:world returns only documents that match both clauses. The same is true for chains: a AND b AND c requires all three. A clause that was already explicitly marked with + (required) or - (prohibited) keeps that intent — AND does not override an explicit prefix.
Space-separated clauses without an explicit operator use implicit boolean (behaves like OR with scoring), so a b AND c reads as “optionally match a, and require both b and c”.
Required / Prohibited Clauses
Use + (must match) and - (must not match):
+title:hello -title:goodbye
Phrase Query
Match an exact phrase using double quotes. Optional proximity (~N) allows N words between terms:
"hello world"
"hello world"~2
Fuzzy Query
Approximate matching with edit distance. Append ~ and optionally the maximum edit distance:
roam~
roam~2
Wildcard Query
Use ? (single character) and * (zero or more characters):
te?t
test*
Range Query
Inclusive [] or exclusive {} ranges, useful for numeric and date fields:
price:[100 TO 500]
date:{2024-01-01 TO 2024-12-31}
price:[* TO 100]
2D Geographic Queries (geo_*)
Two function-style forms target Geo (2D latitude / longitude) fields. All
arguments are signed floats; latitudes / longitudes are in degrees and the
distance is in metres:
location:geo_distance(lat, lon, distance_m)
location:geo_bbox(min_lat, min_lon, max_lat, max_lon)
| Form | Behaviour |
|---|---|
geo_distance(lat, lon, distance_m) | All docs whose stored (lat, lon) lies within distance_m metres of the given centre. |
geo_bbox(min_lat, min_lon, max_lat, max_lon) | All docs whose stored (lat, lon) lies inside the axis-aligned latitude / longitude rectangle. |
Examples:
# Within 10 km (= 10 000 m) of Tokyo (35.6895, 139.6917)
location:geo_distance(35.6895, 139.6917, 10000)
# Inside an axis-aligned lat/lon bounding box
location:geo_bbox(35.0, 139.0, 36.0, 140.0)
The query field must be declared as a
Geofield in the schema. Latitudes must lie in[-90, 90]and longitudes in[-180, 180]; the parser rejects out-of-range values.
3D Geographic Queries (geo3d_*)
Three function-style forms target Geo3d (3D ECEF Cartesian) fields. All
arguments are signed floats in metres, except k (an unsigned integer):
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)
| Form | Behaviour |
|---|---|
geo3d_distance(x, y, z, distance_m) | All docs whose stored point lies within distance_m metres of (x, y, z). |
geo3d_bbox(min_x, min_y, min_z, max_x, max_y, max_z) | All docs whose stored point lies inside the axis-aligned 3D box. |
geo3d_nearest(x, y, z, k) | The k nearest docs to (x, y, z) by Euclidean distance. |
Examples:
# Within 5 km of Tokyo Tower (ECEF coordinates)
position:geo3d_distance(-3955182, 3350553, 3700276, 5000)
# Inside an axis-aligned ECEF bounding box
position:geo3d_bbox(-4000000, 3300000, 3650000, -3900000, 3400000, 3750000)
# 10 nearest indexed points
position:geo3d_nearest(-3955182, 3350553, 3700276, 10)
The query field must be declared as a
Geo3dfield in the schema. See 3D Geographic Search for the underlying coordinate system, WGS84 conversion helpers, and detailed semantics.
Boost
Increase the weight of a clause with ^:
title:hello^2
"important phrase"^1.5
Grouping
Use parentheses for sub-expressions:
(title:hello OR title:hi) AND body:world
Lexical PEG Grammar
The full lexical grammar (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 Query Syntax
Vector queries embed text into vectors at parse time and perform similarity search.
Basic Syntax
field:"text"
field:text
field:"text"^weight
The field name must refer to a vector field defined in the schema. The parser uses the schema to determine whether a clause is a vector query.
| Element | Required | Description | Example |
|---|---|---|---|
field: | Yes | Target vector field name (must be a vector field in the schema) | content: |
"text" or text | Yes | Text to embed (quoted or unquoted) | "cute kitten", python |
^weight | No | Score weight (default: 1.0) | ^0.8 |
Vector Query Examples
# Single field (quoted text)
content:"cute kitten"
# 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"
Multiple Clauses
Multiple vector clauses are space-separated. All clauses are executed and their scores are combined using the score_mode (default: WeightedSum):
content:"cats" image:"dogs"^0.5
This produces:
score = similarity("cats", content) * 1.0
+ similarity("dogs", image) * 0.5
There are no AND/OR operators in the vector DSL. Vector search is inherently a ranking operation, and the weight (^) controls the contribution of each clause.
Score Modes
| Mode | Description |
|---|---|
WeightedSum (default) | Sum of (similarity * weight) across all clauses |
MaxSim | Maximum similarity score across clauses |
LateInteraction | Late interaction scoring |
Score mode cannot be set from DSL syntax. Use the Rust API to override:
#![allow(unused)]
fn main() {
let mut request = parser.parse(r#"content:"cats" image:"dogs""#).await?;
request.vector_options.score_mode = VectorScoreMode::MaxSim;
}
Vector PEG Grammar
The full vector grammar (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 ~ "\"" }
inner_text = @{ (!("\"") ~ ANY)* }
unquoted_text = @{ (!(" " | "^" | "\"") ~ ANY)+ }
boost = { "^" ~ float_value }
float_value = @{ ASCII_DIGIT+ ~ ("." ~ ASCII_DIGIT+)? }
Unified (Hybrid) Query Syntax
The UnifiedQueryParser allows mixing lexical and vector clauses freely in a single query string:
title:hello content:"cute kitten"^0.8
How It Works
- Split: The parser checks each field name against the schema. Fields defined as vector fields (e.g., HNSW, Flat, IVF) are routed to the vector parser; all other fields are routed to the lexical parser.
- Delegate: Vector portion goes to
VectorQueryParser, remainder goes to lexicalQueryParser. - Fuse: If both lexical and vector results exist, they are combined using a fusion algorithm.
Disambiguation
The parser uses the schema’s field type information to distinguish vector clauses from lexical clauses. A clause like content:"cute kitten" is a vector query if content is a vector field, or a phrase query if content is a text field. Lexical ~ syntax (e.g., roam~2 for fuzzy, "hello world"~10 for proximity) is unaffected.
Fusion Algorithms
When a query contains both lexical and vector clauses, results are fused:
| Algorithm | Formula | Description |
|---|---|---|
| RRF (default) | score = sum(1 / (k + rank)) | Reciprocal Rank Fusion. Robust to different score distributions. Default k=60. |
| WeightedSum | score = lexical * a + vector * b | Linear combination with configurable weights. |
Note: The fusion algorithm cannot be specified in the DSL syntax. It is configured when constructing the
UnifiedQueryParservia.with_fusion(). The default is RRF (k=60). See Custom Fusion for a code example.
Hybrid AND/OR Semantics (the + Prefix)
By default, hybrid queries use union (OR) — documents appearing in either the lexical results or the vector results are included. You can switch to intersection (AND) by prefixing a vector clause with +, which requires documents to appear in both result sets.
| Syntax | Mode | Behaviour |
|---|---|---|
title:Rust content:"system process" | OR (union) | Documents matching the lexical query or the vector query are returned. |
title:Rust +content:"system process" | AND (intersection) | Only documents matching both the lexical and vector results are returned. |
+title:Rust +content:"system process" | AND (intersection) | Both clauses required. + on the lexical field is handled by the lexical parser as a required clause. |
Rules:
- When no vector clause carries the
+prefix, the fusion produces a union (OR) of lexical and vector results. - When at least one vector clause carries the
+prefix, the fusion switches to intersection (AND) — only documents present in both the lexical and vector result sets are returned. +on a lexical field (e.g.,+title:Rust) is interpreted by the lexical query parser as a required clause, which is the existing Tantivy/Lucene-style behaviour. It does not by itself trigger intersection mode for the hybrid fusion.
Unified Query Examples
# Lexical only — no fusion
title:hello AND body:world
# Vector only — no fusion
content:"cute kitten"
# Hybrid — fusion applied automatically (OR / union)
title:hello content:"cute kitten"
# Hybrid with AND / intersection — only docs in both result sets
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
# Unquoted vector text
category:animal content:python
Code Examples
Lexical Search with DSL
#![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")?;
}
Vector Search with DSL
#![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?;
}
Hybrid Search with Unified 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
}
Custom Fusion
#![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 stores numeric, datetime, and geographic point data in a BKD-Tree (Block KD-Tree) — a disk-resident, multi-dimensional index that supports range, bounding-box, distance, and k-nearest-neighbour queries on the same underlying file format.
The BKD primitive is shared by every “spatial-shaped” field type:
| Field type | Dimensions | Coordinate space |
|---|---|---|
Integer / Float (single- or multi-valued) | 1 | scalar |
DateTime | 1 | Unix microseconds (UTC) |
Geo | 2 | latitude / longitude (degrees) |
Geo3d | 3 | ECEF Cartesian (metres) |
Adding a new spatial field type therefore boils down to picking a
dimensionality and writing a query-side
IntersectVisitor — the writer,
reader, and on-disk layout are reused unchanged.
File Format (Version 2)
A .bkd segment file is a self-contained binary blob made of three regions:
+----------------------------------------+
| File Header | fixed-size, version-tagged
+----------------------------------------+
| Leaf Blocks | row-major points + doc_ids
| leaf 0 |
| leaf 1 |
| ... |
+----------------------------------------+
| Index Nodes | internal navigation nodes
| node N-1 |
| ... |
| node 0 (root, written last) |
+----------------------------------------+
The header (BKDFileHeader) records magic, version (currently 2),
num_dims, bytes_per_dim, the total point count, the number of leaf
blocks, the per-axis global min/max for the whole tree, and offsets to
the index region and the root node.
Leaf Block Layout
Each leaf block stores the points that fall inside its subtree:
count u32 — number of points in the leaf
leaf_min [f64; num_dims] — leaf-level AABB minimum
leaf_max [f64; num_dims] — leaf-level AABB maximum
point_values [f64; count * num_dims] — row-major point coordinates
doc_ids [u64; count] — parallel doc-id buffer
The per-leaf AABB lets the reader prune the leaf without scanning any of its
points when the query region is fully outside (Outside) or fully inside
(Inside) the leaf.
Internal Index Node Layout
Internal nodes carry both the split decision and the per-child AABB:
split_dim u32 — axis to split on
split_value f64 — split threshold on that axis
left_min [f64; num_dims] — left subtree AABB min
left_max [f64; num_dims] — left subtree AABB max
right_min [f64; num_dims] — right subtree AABB min
right_max [f64; num_dims] — right subtree AABB max
left_offset u64 — file offset of left child
right_offset u64 — file offset of right child
Per-node AABBs (added in v2) replace the v1 layout that only carried the split value. They make
Inside/Outsidepruning a constant-time rectangle test instead of a recursive descent.
Build Algorithm
BKDWriter::write builds the tree from a flat row-major point buffer plus a
parallel doc_ids buffer. Construction is driven by the widest-axis split
heuristic:
- Compute the AABB of the input subset.
- Pick the axis whose
(max - min)range is the widest (ties broken by lower dimension index for determinism). - Sort the index permutation by that axis and split at the median.
- Recurse on the two halves until a subtree fits in
block_sizepoints (default512); emit it as a leaf. - Back-patch each parent’s
left_offset/right_offsetonce the children have been flushed.
The builder sorts an index permutation rather than the point/doc-id buffers
themselves, so it does not allocate per-point storage no matter how many
points are written.
Numeric Robustness
Coordinates must be totally orderable. BKDWriter::write rejects NaN
explicitly because NaN has no defined ordering and would corrupt the
split decisions and per-node AABB invariants. Both ±INFINITY are accepted
and act as natural sentinels for “unbounded” semantics in queries.
Query: The IntersectVisitor Protocol
Queries against a BKD index are expressed as an
IntersectVisitor
implementation. The reader walks the tree and asks the visitor three things:
#![allow(unused)]
fn main() {
pub enum CellRelation {
Inside, // entire subtree is a hit — collect without per-point checks
Outside, // entire subtree can be skipped
Crosses, // recurse, or filter the leaf per-point
}
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]);
}
}
The reader’s traversal is:
graph TD
A["compare(node.aabb)"]
A -->|Inside| B["visit_inside(doc_id) for every doc<br/>under the subtree — no point read"]
A -->|Outside| C["skip subtree"]
A -->|Crosses, internal| D["recurse into children"]
A -->|Crosses, leaf| E["visit(doc_id, point) per point<br/>visitor decides what is a hit"]
This three-valued classification is what unlocks pruning. A visitor that
always returns Crosses would still produce correct results — it would just
degrade to a full leaf scan.
Range Queries
The legacy BKDTree::range_search API is now a thin wrapper around
intersect: it constructs a RangeQueryVisitor from the half-open / closed
range parameters and converts unbounded None slots into ±INFINITY. The
visitor handles inclusive-vs-exclusive boundary checks itself.
3D Geographic Queries
Three additional visitors live in laurus::lexical::query::geo3d
and target Geo3d (3D ECEF) fields:
| Query | Region tested by compare | Per-point check in visit |
|---|---|---|
Geo3dDistanceQuery | sphere (centre, radius) vs. cell AABB | Euclidean distance ≤ radius |
Geo3dBoundingBoxQuery | query AABB vs. cell AABB | point inside query AABB |
Geo3dNearestQuery (k-NN) | expanding sphere around the query point | distance ≤ current k-th best |
The same primitive could host any future spatial query — for example, polygon
queries or great-circle 2D Geo queries — by writing a new visitor.
Reader Internals
BKDReader::intersect uses a single per-query scratch buffer
(IntersectScratch) that grows to the size of the largest leaf encountered
and is then reused for every subsequent leaf, so a query touches the
allocator at most a handful of times regardless of how many leaves it visits.
Single-leaf trees (very small fields) are handled as a special case: the “root offset” points directly at the only leaf and the reader skips the internal-node descent entirely.
See Also
- 3D Geographic Search — concrete BKD-backed visitors for ECEF distance, bounding-box, and k-NN queries.
- Lexical Indexing — where the
.bkdsegment file fits within the broader segment layout. - Lexical Search —
NumericRangeQuery,GeoQuery, andGeo3dDistanceQueryprogrammatic entry points.
3D Geographic Search (ECEF)
Laurus offers two geographic field types that target different problems:
| Field type | Backing structure | Coordinate system | Use it when … |
|---|---|---|---|
Geo | 2D BKD-Tree | WGS84 latitude / longitude (degrees) | All your data lives at the surface and altitude does not matter. |
Geo3d | 3D BKD-Tree | ECEF Cartesian (x, y, z) (metres) | Altitude is a first-class dimension — drones, satellites, indoor positioning, multi-floor buildings. |
Both share the same BKD-Tree primitive; Geo3d simply adds a
third dimension and a different query vocabulary.
Why ECEF?
(latitude, longitude, altitude) triples are convenient for humans but make
Euclidean distance unusable: a degree of longitude is ~111 km at the equator
and 0 km at the poles, and “altitude” is curved with the Earth’s surface.
ECEF (Earth-Centered Earth-Fixed) flattens this into a Cartesian frame:
- The origin is the Earth’s centre of mass.
- The +X axis points through the equator at 0° longitude.
- The +Y axis points through the equator at 90° E longitude.
- The +Z axis points through the geographic North Pole.
- All three axes are in metres.
In this frame the straight-line distance between two points is just the
Euclidean norm — no spherical trigonometry, no pole singularity, no
longitude-wrap. That property is what makes Geo3d queries usable from a
3D BKD-Tree without bespoke spatial code.
Defining a Geo3d Field
#![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 exposes the same indexed / stored switches as the other
lexical field options. Indexed values populate the field’s 3D BKD-Tree;
stored values are returned by get_documents.
The TOML form (used by laurus-cli):
[fields.position.Geo3d]
indexed = true
stored = true
Indexing 3D Points
Use DocumentBuilder::add_geo_ecef to add a point in raw Cartesian
metres:
#![allow(unused)]
fn main() {
use laurus::Document;
// A point at lat=35.6586°, lon=139.7454°, height=250 m
// (already converted to ECEF — see "Coordinate Conversion" below).
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?;
}
If the value already exists as a GeoEcefPoint, build it through the
unified DataValue API instead:
#![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();
}
Coordinate Conversion (WGS84 ↔ ECEF)
Most input arrives as (latitude°, longitude°, height_m). Laurus exposes
the canonical pair of conversions in laurus::util::ecef:
#![allow(unused)]
fn main() {
use laurus::util::ecef::{wgs84_to_ecef, ecef_to_wgs84};
// Forward: lat/lon/height → ECEF
let p = wgs84_to_ecef(35.6586, 139.7454, 250.0);
// Reverse: ECEF → lat/lon/height
let (lat, lon, height) = ecef_to_wgs84(&p);
}
| Function | Direction | Algorithm |
|---|---|---|
wgs84_to_ecef(lat°, lon°, h_m) | geographic → Cartesian | Closed-form prime-vertical formula. |
ecef_to_wgs84(&GeoEcefPoint) | Cartesian → geographic | Bowring 1985 closed-form seed + 3 Newton-Raphson iterations. |
The reverse conversion round-trips with sub-µm precision on every axis
from sub-surface up to far beyond LEO altitudes. Near the poles the
implementation switches from the standard h = p / cos(lat) - N formula
(which diverges at cos(lat) → 0) to the meridian form
h = z / sin(lat) - N(1 - e²).
The WGS84 ellipsoid constants used internally are also exposed as pub consts (WGS84_A, WGS84_F, WGS84_B, WGS84_E2, WGS84_E_PRIME_SQ)
so external code can reference the exact values laurus uses.
Query Types
Three query types target Geo3d fields. All three are
IntersectVisitor implementations
on top of the shared 3D BKD-Tree.
Sphere (radius) — Geo3dDistanceQuery
Find every document whose stored point lies within distance_m metres of a
centre point. Score is 1 - distance / radius, clamped to [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 radius
}
The visitor uses AABB::min_distance_sq_to_point for the Outside
test and AABB::max_distance_sq_to_point for the Inside test, so
both cell-vs-sphere checks run in squared-distance space and avoid sqrt.
Bounding Box — Geo3dBoundingBoxQuery
Find every document whose stored point lies inside an axis-aligned 3D box
defined by (min_x, min_y, min_z) and (max_x, max_y, max_z).
#![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)?;
}
The constructor returns Result because every min[i] must be ≤ max[i];
mismatched bounds are rejected at construction time rather than producing
silently-empty results.
Caveat. The query box is axis-aligned in ECEF Cartesian space. A box built from two
(lat, lon, height)corners by converting each corner separately is not the same shape as the spherical-shell region a user might intuitively expect. For region queries on the Earth’s surface,Geo3dDistanceQuery(a true 3D sphere) usually matches user intuition better.
k-Nearest Neighbours — Geo3dNearestQuery
Find the k nearest documents to a query point. The query starts from a
small probe sphere (default 1 km) and doubles the radius until either:
- it has collected at least
kdistinct candidates, - doubling makes no progress,
- the radius reaches
max_radius_m(default1.0e10m, i.e. 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 nearest
.with_initial_radius(500.0) // start at 500 m
.with_max_radius(1_000_000.0); // cap at 1 000 km
}
A smaller-than-k result simply means the index does not contain k
points within max_radius_m. Score is normalised so that the closest hit
gets 1.0 and the farthest hit in the returned set gets 0.0.
The k-NN visitor never returns CellRelation::Inside from compare —
only Outside or Crosses — so every candidate is delivered to visit
with its exact coordinates, ensuring k-NN ordering uses the true distance.
Query DSL
Geo3d queries are also exposed in the unified Query DSL (see
Query DSL → Geo3d Functions):
position:geo3d_distance(-3955182, 3350553, 3700276, 5000)
position:geo3d_bbox(-4000000, 3300000, 3650000, -3900000, 3400000, 3750000)
position:geo3d_nearest(-3955182, 3350553, 3700276, 10)
| Form | Arguments |
|---|---|
geo3d_distance(x, y, z, distance_m) | centre (x, y, z) and maximum distance in metres |
geo3d_bbox(min_x, min_y, min_z, max_x, max_y, max_z) | two opposite corners of the AABB |
geo3d_nearest(x, y, z, k) | centre (x, y, z) and the integer k |
All numeric arguments are signed floats; the k of geo3d_nearest is an
unsigned integer.
Wire Formats
gRPC
Protocol Buffers expose 3D geo as a dedicated Value variant alongside a
Geo3dPoint message and a Geo3dOption schema option:
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;
}
See common.proto and
index.proto for the full definitions.
HTTP gateway / MCP
JSON documents accept Geo3d values as a { "x": …, "y": …, "z": … }
object:
{
"position": { "x": -3955182.0, "y": 3350553.0, "z": 3700276.0 }
}
The HTTP gateway converts this to DataValue::GeoEcef before forwarding
to the engine; the MCP server uses the same convention.
When not to Use Geo3d
- Pure 2D map queries (radius from a coordinate on the surface, simple
bounding boxes on a tile map) — keep using
Geo. The 2D BKD is one dimension lighter and the WGS84 input matches user intuition. - Approximate semantic similarity of a learned location embedding —
that is a vector field (
Hnsw,Flat,Ivf), not aGeo3dfield.
See Also
- BKD-Tree — the underlying multi-dimensional index that
also backs
Integer,Float,DateTime, and 2DGeofields. - Schema & Fields —
Geo3dalongside the rest of the field type table. - Query DSL — full geo3d DSL grammar.
- Lexical Search — programmatic entry points for every lexical query type.
laurus-wasm/examples/geo3d/— browser-side demo: live aircraft positions on a CesiumJS 3D globe withgeo3d_bboxandgeo3d_nearestqueries.
Library Overview
The laurus crate is the core search engine library. It provides lexical search (keyword matching via inverted index), vector search (semantic similarity via embeddings), and hybrid search (combining both) through a unified API.
Module Structure
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\nShared helpers and macros"]
LIB --> data["data\nDataValue, Document"]
LIB --> error["error\nLaurusError, Result"]
Key Types
| Type | Module | Description |
|---|---|---|
Engine | engine | Unified search engine coordinating lexical and vector search |
EngineBuilder | engine | Builder pattern for configuring and creating an Engine |
Schema | engine | Field definitions and routing configuration |
SearchRequest | engine | Unified search request (lexical, vector, or hybrid) |
FusionAlgorithm | engine | Result merging strategy (RRF or WeightedSum) |
Document | data | Collection of named field values |
DataValue | data | Unified value enum for all field types |
LaurusError | error | Comprehensive error type with variants for each subsystem |
Feature Flags
The laurus crate has no default features enabled. Enable embedding support as needed:
| Feature | Description | Dependencies |
|---|---|---|
embeddings-candle | Local BERT embeddings via Hugging Face Candle | candle-core, candle-nn, candle-transformers, hf-hub, tokenizers |
embeddings-openai | OpenAI API embeddings | reqwest |
embeddings-multimodal | CLIP multimodal embeddings (text + image) | image, embeddings-candle |
embeddings-all | All embedding features combined | All of the above |
# Lexical search only (no embedding)
[dependencies]
laurus = "0.9"
# With local BERT embeddings
[dependencies]
laurus = { version = "0.9", features = ["embeddings-candle"] }
# All features
[dependencies]
laurus = { version = "0.9", features = ["embeddings-all"] }
Sections
- Engine – Engine and EngineBuilder internals
- Scoring & Ranking – BM25, TF-IDF, and vector similarity scoring
- Faceting – Hierarchical facet search
- Highlighting – Search result highlighting
- Spelling Correction – Spelling suggestions and auto-correction
- ID Management – Dual-tiered document identity
- Persistence & WAL – Write-ahead logging and durability
- Deletions & Compaction – Logical deletion and space reclamation
- Error Handling – LaurusError and Result types
- Extensibility – Custom analyzers, embedders, and storage backends
- API Reference – Key types and methods at a glance
Engine
The Engine is the central type in Laurus. It coordinates the lexical index, vector index, and document log behind a single async API.
Engine Struct
#![allow(unused)]
fn main() {
pub struct Engine {
schema: Schema,
lexical: LexicalStore,
vector: VectorStore,
log: Arc<DocumentLog>,
}
}
| Field | Type | Description |
|---|---|---|
schema | Schema | Field definitions and routing rules |
lexical | LexicalStore | Inverted index for keyword search |
vector | VectorStore | Vector index for similarity search |
log | Arc<DocumentLog> | Write-ahead log for crash recovery and document storage |
EngineBuilder
Use EngineBuilder to configure and create an 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) // optional: custom text analyzer
.embedder(my_embedder) // optional: vector embedder
.embedding_cache_capacity(1024) // optional: cache query embeddings
.build()
.await?;
}
Builder Methods
| Method | Parameter | Default | Description |
|---|---|---|---|
analyzer() | Arc<dyn Analyzer> | StandardAnalyzer | Text analysis pipeline for lexical fields |
embedder() | Arc<dyn Embedder> | None | Embedding model for vector fields |
embedding_cache_capacity() | usize | None (disabled) | Enable an LRU cache of up to N query embeddings |
build() | – | – | Create the Engine (async) |
Query embedding cache
embedding_cache_capacity(n) enables an LRU cache for query-time
embeddings (document-ingestion embedding is unaffected). When a vector or
hybrid search embeds a query payload, the result is cached keyed by
(field, embedder name, payload hash) and reused on subsequent identical
queries — both the DSL path (e.g. content:"cute kitten") and the
pre-embedded Payloads path share the same cache.
This avoids repeated model inference for local embedders, or network round trips for remote ones, on repeated-query workloads (autocomplete, dashboard refreshes, A/B evaluation). It is disabled by default; pick a capacity that bounds memory to your working set of distinct queries.
Within a single query, all of its payloads are embedded in one
Embedder::embed_batch call (cache misses only, grouped per field for
PerFieldEmbedder), so a batch-capable embedder pays one round trip for a
multi-vector query instead of one per payload (Issue #671).
Build Lifecycle
When build() is called, the following steps occur:
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
- Split schema – Lexical fields (Text, Integer, Float, etc.) go to
LexicalIndexConfig, vector fields (HNSW, Flat, IVF) go toVectorIndexConfig - Create prefixed storage – Each component gets an isolated namespace (
lexical/,vector/,documents/) - Initialize stores –
LexicalStoreandVectorStoreare created with their respective configs - Recover from WAL – Any uncommitted operations from a previous session are replayed
Schema Splitting
The Schema contains both lexical and vector fields. At build time, split_schema() separates them:
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)"]
The reserved _id field is always added to the lexical config with KeywordAnalyzer for exact match lookups.
Per-Field Dispatch
PerFieldAnalyzer
When a PerFieldAnalyzer is provided, text analysis is dispatched to field-specific analyzers:
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
Similarly, PerFieldEmbedder routes embedding to field-specific embedders:
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 Methods
Document Operations
| Method | Description |
|---|---|
put_document(id, doc) | Upsert – replaces any existing document with the same ID |
add_document(id, doc) | Append – adds as a new chunk (multiple chunks can share an ID) |
get_documents(id) | Retrieve all documents/chunks by external ID |
delete_documents(id) | Delete all documents/chunks by external ID |
commit() | Flush pending changes to storage (makes documents searchable) |
recover() | Replay WAL to restore uncommitted state after crash |
add_field(name, field_option) | Dynamically add a new field to the schema at runtime |
delete_field(name) | Remove a field from the schema at runtime |
schema() | Return the current Schema |
Search
| Method | Description |
|---|---|
search(request) | Execute a unified search (lexical, vector, or hybrid) |
The search() method accepts a SearchRequest which can contain a lexical query, a vector query, or both. When both are present, results are merged using the specified FusionAlgorithm.
#![allow(unused)]
fn main() {
use laurus::{SearchRequestBuilder, FusionAlgorithm};
use laurus::lexical::TermQuery;
use laurus::lexical::search::searcher::LexicalSearchQuery;
// Lexical-only search
let request = SearchRequestBuilder::new()
.lexical_query(
LexicalSearchQuery::Obj(Box::new(TermQuery::new("body", "rust")))
)
.limit(10)
.build();
// Hybrid search with RRF fusion
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?;
}
Warmup
engine.warmup() (Issue #677) primes the vector searcher so the first
vector / hybrid query does not pay one-time setup costs. Call it once after
building the engine, before serving traffic:
let engine = builder.build()?;
engine.warmup()?; // optional: move first-query latency to startup
It eagerly builds and caches the vector searcher — loading the reader (file →
memory for InMemory, the offset table for Mmap) — and, for HNSW indexes in
Mmap mode, pre-faults the on-disk vector data into the OS page cache. The HNSW
graph is always loaded eagerly, so only the vector data needs warming.
warmup() is safe to call multiple times and is a no-op for lexical-only
workloads.
SearchRequest
| Field | Type | Default | Description |
|---|---|---|---|
query | SearchQuery | Dsl("") | Search query specification (Dsl, Lexical, Vector, or Hybrid) |
limit | usize | 10 | Maximum results to return |
offset | usize | 0 | Pagination offset |
fusion_algorithm | Option<FusionAlgorithm> | RRF (k=60) | How to merge lexical + vector results |
filter_query | Option<Box<dyn Query>> | None | Filter applied to both search types |
lexical_options | LexicalSearchOptions | Default | Parameters controlling lexical search behavior |
vector_options | VectorSearchOptions | Default | Parameters controlling vector search behavior |
FusionAlgorithm
| Variant | Description |
|---|---|
RRF { k: f64 } | Reciprocal Rank Fusion – rank-based combining. Score = sum(1 / (k + rank)). Handles incomparable score magnitudes. |
WeightedSum { lexical_weight, vector_weight } | Weighted combination with min-max score normalization. Weights clamped to [0.0, 1.0]. |
See also: Architecture for the high-level data flow diagrams.
Scoring & Ranking
Laurus uses BM25 for lexical search, distance-based similarity for vector search, and a configurable fusion algorithm to combine the two for hybrid search. This page describes each scoring path and how to influence it from the public API.
Lexical Scoring
BM25 (Default)
BM25 is the lexical scoring function. It balances term frequency with document length normalization:
score = IDF * (tf * (k1 + 1)) / (tf + k1 * (1 - b + b * (doc_len / avg_doc_len)))
Where:
- tf — term frequency in the document.
- IDF — inverse document frequency (rarity of the term across all documents).
- k1 — term-frequency saturation parameter. Laurus uses 1.2.
- b — document-length normalization factor. Laurus uses 0.75.
- doc_len / avg_doc_len — ratio of document length to average document length.
The (k1, b) parameters are fixed at the implementation defaults today. The values match Lucene / Elasticsearch defaults, so BM25 scores from Laurus are directly comparable to those engines for tuning intuition.
Field Boosts
Per-field score multipliers are configured on the search request, not in a separate scoring struct:
#![allow(unused)]
fn main() {
use laurus::SearchRequestBuilder;
let request = SearchRequestBuilder::new()
.query_dsl("rust programming")
.add_field_boost("title", 2.0) // title matches score 2x
.add_field_boost("body", 1.0) // body matches score 1x (the default)
.limit(10)
.build();
}
The boost is multiplied into the BM25 score contribution of matches in that field. A boost of 1.0 is a no-op; boosts apply only to fields named in the query (or in the schema’s default-search fields).
Over gRPC and HTTP, the same setting is exposed as SearchRequest.field_boosts (map<string, float>). See gRPC API → SearchRequest.
Vector Scoring
Vector search ranks results by distance-based similarity. The distance metric is configured per field on the vector index (HNSW / Flat / IVF):
| Metric | Description | Best for |
|---|---|---|
Cosine | 1 − cosine similarity (default) | Normalised text embeddings |
Euclidean | L2 distance | Spatial / pre-normalised data |
Manhattan | L1 distance | Sparse feature vectors |
DotProduct | Negated dot product | Pre-normalised vectors where higher = better |
Angular | Angular distance | Directional similarity |
Distances are converted to similarity scores so that “higher is better” holds across both lexical and vector results, which is what the fusion algorithms below assume.
Hybrid Search Fusion
When a search request contains both lexical and vector clauses, the two result lists need to be merged. Laurus exposes two fusion algorithms via FusionAlgorithm.
RRF (Reciprocal Rank Fusion)
RRF avoids score normalisation entirely by combining ranks instead of raw scores:
rrf_score(doc) = Σ 1 / (k + rank_i(doc))
The sum runs over each result list the document appears in. The k parameter (default 60.0) smooths the distribution — higher k flattens the contribution of top-ranked results.
#![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 first min-max normalises each list of scores independently, then takes a weighted linear combination:
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();
}
Both weights are clamped to [0.0, 1.0]. Use RRF when you do not have a calibrated reason to pick specific weights — it is parameter-light and robust to scale differences between lists.
See Also
- API Reference →
FusionAlgorithm— variant signatures - Hybrid Search — when to pick which fusion algorithm
- Vector Search — distance metric trade-offs
Faceting
Faceting enables counting and categorizing search results by field values. It is commonly used to build navigation filters in search UIs (e.g., “Electronics (42)”, “Books (18)”).
Concepts
FacetPath
A FacetPath represents a hierarchical facet value. For example, a product category “Electronics > Computers > Laptops” is a facet path with three levels.
#![allow(unused)]
fn main() {
use laurus::lexical::search::features::facet::FacetPath;
// Single-level facet
let facet = FacetPath::from_value("category", "Electronics");
// Hierarchical facet from components
let facet = FacetPath::new("category", vec![
"Electronics".to_string(),
"Computers".to_string(),
"Laptops".to_string(),
]);
// From a delimited string
let facet = FacetPath::from_delimited("category", "Electronics/Computers/Laptops", "/");
}
FacetPath Methods
| Method | Description |
|---|---|
new(field, path) | Create a facet path from field name and path components |
from_value(field, value) | Create a single-level facet |
from_delimited(field, path_str, delimiter) | Parse a delimited path string |
depth() | Number of levels in the path |
is_parent_of(other) | Check if this path is a parent of another |
parent() | Get the parent path (one level up) |
child(component) | Create a child path by appending a component |
to_string_with_delimiter(delimiter) | Convert to a delimited string |
FacetCount
FacetCount represents the result of a facet aggregation:
#![allow(unused)]
fn main() {
pub struct FacetCount {
pub path: FacetPath,
pub count: u64,
pub children: Vec<FacetCount>,
}
}
| Field | Type | Description |
|---|---|---|
path | FacetPath | The facet value |
count | u64 | Number of matching documents |
children | Vec<FacetCount> | Child facets for hierarchical drill-down |
Example: Hierarchical Facets
Category
├── Electronics (42)
│ ├── Computers (18)
│ │ ├── Laptops (12)
│ │ └── Desktops (6)
│ └── Phones (24)
└── Books (35)
├── Fiction (20)
└── Non-Fiction (15)
Each node in this tree corresponds to a FacetCount with its children populated for drill-down navigation.
Use Cases
- E-commerce: Filter by category, brand, price range, rating
- Document search: Filter by author, department, date range, document type
- Content management: Filter by tags, topics, content status
Performance
Facet counts are read from each field’s DocValues column, not from the stored document. For every collected hit the collector reads only the facet field’s value via the per-field DocValues lookup, so it never decodes or clones the whole stored-fields blob when every faceted field has a DocValues column (which is the default — every stored field is written to DocValues at index time). A field that lacks DocValues transparently falls back to the stored document, so results are identical either way; only the read path changes.
Highlighting
Highlighting marks matching terms in search results, helping users see why a document matched their query. Laurus generates highlighted text fragments with configurable HTML tags.
HighlightConfig
HighlightConfig controls how highlights are generated:
#![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);
}
Configuration Options
| Option | Type | Default | Description |
|---|---|---|---|
tag | String | "mark" | HTML tag used for highlighting |
css_class | Option<String> | None | Optional CSS class added to the tag |
max_fragments | usize | 5 | Maximum number of fragments to return |
fragment_size | usize | 150 | Target fragment length in characters |
fragment_overlap | usize | 20 | Character overlap between adjacent fragments |
fragment_separator | String | " ... " | Separator between fragments |
return_entire_field_if_no_highlight | bool | false | Return the full field value if no matches found |
max_analyzed_chars | usize | 1,000,000 | Maximum characters to analyze for highlights |
Builder Methods
| Method | Description |
|---|---|
tag(tag) | Set the HTML tag (e.g., "em", "strong", "mark") |
css_class(class) | Set the CSS class for the tag |
max_fragments(count) | Set maximum fragment count |
fragment_size(size) | Set target fragment size in characters |
opening_tag() | Get the opening HTML tag string (e.g., <mark class="highlight">) |
closing_tag() | Get the closing HTML tag string (e.g., </mark>) |
HighlightFragment
Each highlight result is a HighlightFragment:
#![allow(unused)]
fn main() {
pub struct HighlightFragment {
pub text: String,
}
}
The text field contains the fragment with matching terms wrapped in the configured HTML tags.
Output Example
Given a document with body = "Rust is a systems programming language focused on safety and performance." and a search for “rust programming”:
<mark>Rust</mark> is a systems <mark>programming</mark> language focused on safety and performance.
With css_class("highlight"):
<mark class="highlight">Rust</mark> is a systems <mark class="highlight">programming</mark> language focused on safety and performance.
Fragment Selection
When a field is long, Laurus selects the most relevant fragments:
- The text is split into overlapping windows of
fragment_sizecharacters - Each fragment is scored by how many query terms it contains
- The top
max_fragmentsfragments are returned, joined byfragment_separator
If no fragments contain matches and return_entire_field_if_no_highlight is true, the full field value is returned instead.
Spelling Correction
Laurus includes a built-in spelling correction system that can suggest corrections for misspelled query terms and provide “Did you mean?” functionality.
Overview
The spelling corrector uses edit distance (Levenshtein distance) combined with word frequency data to suggest corrections. It supports:
- Word-level suggestions — correct individual misspelled words
- Auto-correction — automatically apply high-confidence corrections
- “Did you mean?” — suggest alternative queries to the user
- Query learning — improve suggestions by learning from user queries
- Custom dictionaries — use your own word lists
Basic Usage
SpellingCorrector
#![allow(unused)]
fn main() {
use laurus::spelling::corrector::SpellingCorrector;
// Create a corrector with the built-in English dictionary
let mut corrector = SpellingCorrector::new();
// Correct a query
let result = corrector.correct("programing langauge");
// Check if suggestions are available
if result.has_suggestions() {
for (word, suggestions) in &result.word_suggestions {
println!("'{}' -> {:?}", word, suggestions);
}
}
// Get the best corrected query
if let Some(corrected) = result.query() {
println!("Corrected: {}", corrected);
}
}
“Did You Mean?”
The DidYouMean wrapper provides a higher-level interface for search UIs:
#![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);
}
}
Configuration
Use CorrectorConfig to customize behavior:
#![allow(unused)]
fn main() {
use laurus::spelling::corrector::{CorrectorConfig, SpellingCorrector};
let config = CorrectorConfig {
max_distance: 2, // Maximum edit distance (default: 2)
max_suggestions: 5, // Max suggestions per word (default: 5)
min_frequency: 1, // Minimum word frequency threshold (default: 1)
auto_correct: false, // Enable auto-correction (default: false)
auto_correct_threshold: 0.8, // Confidence threshold for auto-correction (default: 0.8)
use_index_terms: true, // Use indexed terms as dictionary (default: true)
learn_from_queries: true, // Learn from user queries (default: true)
};
}
Configuration Options
| Option | Type | Default | Description |
|---|---|---|---|
max_distance | usize | 2 | Maximum Levenshtein edit distance for candidate suggestions |
max_suggestions | usize | 5 | Maximum number of suggestions returned per word |
min_frequency | u32 | 1 | Minimum frequency a word must have in the dictionary to be suggested |
auto_correct | bool | false | When true, automatically apply corrections above the threshold |
auto_correct_threshold | f64 | 0.8 | Confidence score (0.0–1.0) required for auto-correction |
use_index_terms | bool | true | Use terms from the search index as dictionary words |
learn_from_queries | bool | true | Learn new words from user search queries |
CorrectionResult
The correct() method returns a CorrectionResult with detailed information:
| Field | Type | Description |
|---|---|---|
original | String | The original query string |
corrected | Option<String> | The corrected query (if auto-correction was applied) |
word_suggestions | HashMap<String, Vec<Suggestion>> | Suggestions grouped by misspelled word |
confidence | f64 | Overall confidence score (0.0–1.0) |
auto_corrected | bool | Whether auto-correction was applied |
Helper Methods
| Method | Returns | Description |
|---|---|---|
has_suggestions() | bool | True if any word has suggestions |
best_suggestion() | Option<&Suggestion> | The single highest-scoring suggestion |
query() | Option<String> | The corrected query string, if corrections were made |
should_show_did_you_mean() | bool | Whether to display a “Did you mean?” prompt |
Custom Dictionaries
You can provide your own dictionary instead of using the built-in English one:
#![allow(unused)]
fn main() {
use laurus::spelling::corrector::SpellingCorrector;
use laurus::spelling::dictionary::SpellingDictionary;
// Build a custom dictionary
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);
}
Learning from Index Terms
When use_index_terms is enabled, the corrector can learn from terms in your search index:
#![allow(unused)]
fn main() {
let mut corrector = SpellingCorrector::new();
// Feed index terms to the corrector
let index_terms = vec!["rust", "programming", "search", "engine"];
corrector.learn_from_terms(&index_terms);
}
This improves suggestion quality by incorporating domain-specific vocabulary.
Statistics
Monitor the corrector’s state with 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);
}
Next Steps
- Lexical Search – full-text search with query types
- Query DSL – human-readable query syntax
ID Management
Laurus uses a dual-tiered ID management strategy to ensure efficient document retrieval, updates, and aggregation in distributed environments.
1. External ID (String)
The External ID is a logical identifier used by users and applications to uniquely identify a document.
- Type:
String - Role: You can use any unique value, such as UUIDs, URLs, or database primary keys.
- Storage: Persisted transparently as a reserved system field name
_idwithin the Lexical Index. - Uniqueness: Expected to be unique across the entire system.
- Updates: Indexing a document with an existing
external_idtriggers an automatic “Delete-then-Insert” (Upsert) operation, replacing the old version with the newest.
2. Internal ID (u64 / Stable ID)
The Internal ID is a physical handle used internally by Laurus’s engines (Lexical and Vector) for high-performance operations.
- Type: Unsigned 64-bit Integer (
u64) - Role: Used for bitmap operations, point references, and routing between distributed nodes.
- Immutability (Stable): Once assigned, an Internal ID never changes due to index merges (segment compaction) or restarts. This prevents inconsistencies in deletion logs and caches.
ID Structure (Shard-Prefixed)
Laurus employs a Shard-Prefixed Stable ID scheme designed for multi-node distributed environments.
| Bit Range | Name | Description |
|---|---|---|
| 48-63 bit | Shard ID | Prefix identifying the node or partition (up to 65,535 shards). |
| 0-47 bit | Local ID | Monotonically increasing document number within a shard (up to ~281 trillion documents). |
Why this structure?
- Zero-Cost Aggregation: Since
u64IDs are globally unique, the aggregator can perform fast sorting and deduplication without worrying about ID collisions between nodes. - Fast Routing: The aggregator can immediately identify the physical node responsible for a document just by looking at the upper bits, avoiding expensive hash lookups.
- High-Performance Fetching: Internal IDs map directly to physical data structures. This allows Laurus to skip the “External-to-Internal ID” conversion step during retrieval, achieving O(1) access speed.
ID Lifecycle
- Registration (
engine.put_document()/engine.add_document()): User provides a document with an External ID. - ID Assignment: The
Enginecombines the currentshard_idwith a new Local ID to issue a Shard-Prefixed Internal ID. - Mapping: The engine maintains the relationship between the External ID and the new Internal ID.
- Search: Search results return the External ID (
String), resolved from the Internal ID. - Retrieval/Deletion: While the user-facing API accepts External IDs for convenience, the engine internally converts them to Internal IDs for near-instant processing.
Persistence & WAL
Laurus uses a Write-Ahead Log (WAL) to ensure data durability. Every write operation is persisted to the WAL before modifying in-memory structures, guaranteeing that no data is lost even if the process crashes.
Write Path
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
Key Principles
- WAL-first: Every write (add or delete) is appended to the WAL before updating in-memory structures
- Buffered writes: In-memory buffers accumulate changes until
commit()is called - Atomic commit:
commit()flushes all buffered changes to segment files and truncates the WAL - Crash safety: If the process crashes between writes and commit, the WAL is replayed on the next startup
Write-Ahead Log (WAL)
The WAL is managed by the DocumentLog component and stored at the root level of the storage backend (engine.wal).
WAL Entry Types
| Entry Type | Description |
|---|---|
| Upsert | Document content + external ID + assigned internal ID |
| Delete | External ID of the document to remove |
WAL File
The WAL file (engine.wal) is an append-only binary log. Each entry is self-contained with:
- Operation type (add/delete)
- Sequence number
- Payload (document data or ID)
Recovery
When an engine is built (Engine::builder(...).build().await), it automatically checks for remaining WAL entries and replays them (the WAL is truncated on commit, so any remaining entries are from a crashed session):
graph TD
Start["Engine::build()"] --> Check["Check WAL for\nuncommitted entries"]
Check -->|"Entries found"| Replay["Replay operations\ninto in-memory buffers"]
Replay --> Ready["Engine ready"]
Check -->|"No entries"| Ready
Recovery is transparent — you do not need to handle it manually.
The Commit Lifecycle
#![allow(unused)]
fn main() {
// 1. Add documents (buffered, not yet searchable)
engine.add_document("doc-1", doc1).await?;
engine.add_document("doc-2", doc2).await?;
// 2. Commit — flush to persistent storage
engine.commit().await?;
// Documents are now searchable
// 3. Add more documents
engine.add_document("doc-3", doc3).await?;
// 4. If the process crashes here, doc-3 is in the WAL
// and will be recovered on next startup
}
When to Commit
| Strategy | Description | Use Case |
|---|---|---|
| After each document | Maximum durability, minimum search latency | Real-time search with few writes |
| After a batch | Good balance of throughput and latency | Bulk indexing |
| Periodically | Maximum write throughput | High-volume ingestion |
Tip: Commits are relatively expensive because they flush segments to storage. For bulk indexing, batch many documents before calling
commit().
Storage Layout
The engine uses PrefixedStorage to organize data:
<storage root>/
├── lexical/ # Inverted index segments
│ ├── seg-000/
│ │ ├── terms.dict
│ │ ├── postings.post
│ │ └── ...
│ └── metadata.json
├── vector/ # Vector index segments
│ ├── seg-000/
│ │ ├── graph.hnsw
│ │ ├── vectors.vecs
│ │ └── ...
│ └── metadata.json
├── documents/ # Document storage
│ └── ...
└── engine.wal # Write-ahead log
Next Steps
- How deletions are handled: Deletions & Compaction
- Storage backends: Storage
Deletions & Compaction
Laurus uses a two-phase deletion strategy: fast logical deletion followed by periodic physical compaction.
Deleting Documents
#![allow(unused)]
fn main() {
// Delete a document by its external ID
engine.delete_documents("doc-1").await?;
engine.commit().await?;
}
Logical Deletion
When a document is deleted, it is not immediately removed from the index files. Instead:
graph LR
Del["delete_documents('doc-1')"] --> Bitmap["Add internal ID\nto Deletion Bitmap"]
Bitmap --> Search["Search skips\ndeleted IDs"]
- The document’s internal ID is added to a deletion bitmap
- The bitmap is checked during every search, filtering out deleted documents from results
- The original data remains in the segment files
Why Logical Deletion?
| Benefit | Description |
|---|---|
| Speed | O(1) — flipping a bit is instant |
| Immutable segments | Segment files are never modified in place, simplifying concurrency |
| Safe recovery | If a crash occurs, the deletion bitmap can be reconstructed from the WAL |
Upserts (Update = Delete + Insert)
When you index a document with an existing external ID, Laurus performs an automatic upsert:
- The old document is logically deleted (its ID is added to the deletion bitmap)
- A new document is inserted with a new internal ID
- The external-to-internal ID mapping is updated
#![allow(unused)]
fn main() {
// First insert
engine.put_document("doc-1", doc_v1).await?;
engine.commit().await?;
// Update: old version is logically deleted, new version is inserted
engine.put_document("doc-1", doc_v2).await?;
engine.commit().await?;
}
Physical Compaction
Over time, logically deleted documents accumulate and waste space. Compaction reclaims this space by rewriting segment files without the deleted entries.
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
What Compaction Does
- Reads all live (non-deleted) documents from existing segments
- Rebuilds the inverted index and/or vector index without deleted entries
- Writes new, clean segment files
- Removes the old segment files
- Resets the deletion bitmap
Cost and Frequency
| Aspect | Detail |
|---|---|
| CPU cost | High — rebuilds index structures from scratch |
| I/O cost | High — reads all data, writes new segments |
| Blocking | Searches continue during compaction (reads see the old segments until the new ones are ready) |
| Frequency | Run when deleted documents exceed a threshold (e.g., 10-20% of total) |
When to Compact
- Low-write workloads: Compact periodically (e.g., daily or weekly)
- High-write workloads: Compact when the deletion ratio exceeds a threshold
- After bulk updates: Compact after a large batch of upserts
Deletion Bitmap
The deletion bitmap tracks which internal IDs have been deleted:
- Storage: a Roaring bitmap of deleted document IDs. For the dense deletion sets that accumulate over a segment’s life this is dramatically smaller than a plain ID list — e.g. a 10M-doc segment at 10% deletion is ~125 KB on disk instead of ~8 MB.
- Lookup: a branch-light bit test, which stays CPU-cache-resident even for large deletion
sets —
is_deletedis on the per-document (lexical) and per-neighbour (vector) search hot paths.
The bitmap is persisted alongside the index segments (the .delmap file) and is rebuilt from
the WAL during recovery. The on-disk format is versioned: the current writer emits v4 (Roaring),
and the reader still loads the older v1–v3 (raw ID list) layouts for backward compatibility.
Next Steps
- How data is persisted: Persistence & WAL
- ID management and internal/external ID mapping: ID Management
Error Handling
Laurus uses a unified error type for all operations. Understanding the error system helps you write robust applications that handle failures gracefully.
LaurusError
All Laurus operations return Result<T>, which is an alias for std::result::Result<T, LaurusError>.
LaurusError is an enum with variants for each category of failure:
| Variant | Description | Common Causes |
|---|---|---|
Io | I/O errors | File not found, permission denied, disk full |
Index | Index operation errors | Corrupt index, segment read failure |
Schema | Schema-related errors | Unknown field name, type mismatch |
Analysis | Text analysis errors | Tokenizer failure, invalid filter config |
Query | Query parsing/execution errors | Malformed Query DSL, unknown field in query |
Storage | Storage backend errors | Failed to open storage, write failure |
Field | Field definition errors | Invalid field options, duplicate field name |
BenchmarkFailed | Benchmark errors | Benchmark execution failure |
ThreadJoinError | Thread join errors | Panic in a worker thread |
Json | JSON serialization errors | Malformed document JSON |
Anyhow | Wrapped anyhow errors | Errors from third-party crates via anyhow |
InvalidOperation | Invalid operation | Searching before commit, double close |
ResourceExhausted | Resource limits exceeded | Out of memory, too many open files |
SerializationError | Binary serialization errors | Corrupt data on disk |
OperationCancelled | Operation was cancelled | Timeout, user cancellation |
NotImplemented | Feature not available | Unimplemented operation |
Other | Generic errors | Timeout, invalid config, invalid argument |
Basic Error Handling
Using the ? Operator
The simplest approach — propagate errors to the caller:
#![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(())
}
}
Matching on Error Variants
When you need different behavior for different error types:
#![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);
}
}
}
}
Checking Error Types with downcast
Since LaurusError implements std::error::Error, you can use standard error handling patterns:
#![allow(unused)]
fn main() {
use laurus::LaurusError;
fn is_retriable(error: &LaurusError) -> bool {
matches!(error, LaurusError::Io(_) | LaurusError::ResourceExhausted(_))
}
}
Common Error Scenarios
Schema Mismatch
Adding a document with fields that don’t match the schema:
#![allow(unused)]
fn main() {
// Schema has "title" (Text) and "year" (Integer)
let doc = Document::builder()
.add_text("title", "Hello")
.add_text("unknown_field", "this field is not in schema")
.build();
// Fields not in the schema are silently ignored during indexing.
// No error is raised — only schema-defined fields are processed.
}
Query Parsing Errors
Invalid Query DSL syntax returns a Query error:
#![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 contains details about the parse failure
eprintln!("Bad query: {}", msg);
}
Err(e) => { /* other errors */ }
}
}
Storage I/O Errors
File-based storage may encounter I/O errors:
#![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) => { /* other errors */ }
}
}
Convenience Constructors
LaurusError provides factory methods for creating errors in custom implementations:
| Method | Creates |
|---|---|
LaurusError::index(msg) | Index variant |
LaurusError::schema(msg) | Schema variant |
LaurusError::analysis(msg) | Analysis variant |
LaurusError::query(msg) | Query variant |
LaurusError::storage(msg) | Storage variant |
LaurusError::field(msg) | Field variant |
LaurusError::other(msg) | Other variant |
LaurusError::cancelled(msg) | OperationCancelled variant |
LaurusError::invalid_argument(msg) | Other with “Invalid argument” prefix |
LaurusError::invalid_config(msg) | Other with “Invalid configuration” prefix |
LaurusError::not_found(msg) | Other with “Not found” prefix |
LaurusError::timeout(msg) | Other with “Timeout” prefix |
These are useful when implementing custom Analyzer, Embedder, or Storage traits:
#![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(())
}
}
Automatic Conversions
LaurusError implements From for common error types, so they convert automatically with ?:
| Source Type | Target Variant |
|---|---|
std::io::Error | LaurusError::Io |
serde_json::Error | LaurusError::Json |
anyhow::Error | LaurusError::Anyhow |
Next Steps
- Extensibility — implement custom traits with proper error handling
- API Reference — full method signatures and return types
Extensibility
Laurus uses trait-based abstractions for its core components. You can implement these traits to provide custom analyzers, embedders, and storage backends.
Custom Analyzer
Implement the Analyzer trait to create a custom text analysis pipeline:
#![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
}
}
}
Required Methods
| Method | Description |
|---|---|
analyze(&self, text: &str) -> Result<TokenStream> | Process text into a stream of tokens |
name(&self) -> &str | Return a unique identifier for this analyzer |
as_any(&self) -> &dyn Any | Enable downcasting to the concrete type |
Using a Custom Analyzer
Pass your analyzer to EngineBuilder:
#![allow(unused)]
fn main() {
use std::sync::Arc;
let analyzer = Arc::new(ReverseAnalyzer);
let engine = Engine::builder(storage, schema)
.analyzer(analyzer)
.build()
.await?;
}
For per-field analyzers, wrap with 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?;
}
Custom Embedder
Implement the Embedder trait to integrate your own vector embedding model:
#![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) => {
// Your embedding logic here
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
}
}
}
Required Methods
| Method | Description |
|---|---|
async embed(&self, input: &EmbedInput) -> Result<Vector> | Generate an embedding vector for the given input |
supported_input_types(&self) -> Vec<EmbedInputType> | Declare supported input types (Text, Image) |
as_any(&self) -> &dyn Any | Enable downcasting |
Optional Methods
| Method | Default | Description |
|---|---|---|
async embed_batch(&self, inputs) -> Result<Vec<Vector>> | Sequential calls to embed | Override for batch optimization |
name(&self) -> &str | "unknown" | Identifier for logging |
supports(&self, input_type) -> bool | Checks supported_input_types | Input type support check |
supports_text() -> bool | Checks for Text | Text support shorthand |
supports_image() -> bool | Checks for Image | Image support shorthand |
is_multimodal() -> bool | Both text and image | Multimodal check |
Using a Custom Embedder
#![allow(unused)]
fn main() {
let embedder = Arc::new(MyEmbedder { dimension: 384 });
let engine = Engine::builder(storage, schema)
.embedder(embedder)
.build()
.await?;
}
For per-field embedders, wrap with 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?;
}
Custom Storage
Implement the Storage trait to add a new storage backend:
#![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 requires full download
}
fn open_input(&self, name: &str) -> Result<Box<dyn StorageInput>> {
// Download from S3 and return a reader
todo!()
}
fn create_output(&self, name: &str) -> Result<Box<dyn StorageOutput>> {
// Create an upload stream to 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!()
}
}
}
Required Methods
| Method | Description |
|---|---|
open_input(name) -> Result<Box<dyn StorageInput>> | Open a file for reading |
create_output(name) -> Result<Box<dyn StorageOutput>> | Create a file for writing |
create_output_append(name) -> Result<Box<dyn StorageOutput>> | Open a file for appending |
file_exists(name) -> bool | Check if a file exists |
delete_file(name) -> Result<()> | Delete a file |
list_files() -> Result<Vec<String>> | List all files |
file_size(name) -> Result<u64> | Get file size in bytes |
metadata(name) -> Result<FileMetadata> | Get file metadata |
rename_file(old, new) -> Result<()> | Rename a file |
create_temp_output(prefix) -> Result<(String, Box<dyn StorageOutput>)> | Create a temporary file |
sync() -> Result<()> | Flush all pending writes |
close(&mut self) -> Result<()> | Close storage and release resources |
Optional Methods
| Method | Default | Description |
|---|---|---|
loading_mode() -> LoadingMode | LoadingMode::Eager | Preferred data loading mode |
Thread Safety
All three traits require Send + Sync. This means your implementations must be safe to share across threads. Use Arc<Mutex<_>> or lock-free data structures for shared mutable state.
Next Steps
- Error Handling — handle errors in custom implementations
- Text Analysis — built-in analyzers and pipeline components
- Embeddings — built-in embedder options
- Storage — built-in storage backends
API Reference
This page provides a quick reference of the most important types and methods in Laurus. For full details, generate the Rustdoc:
cargo doc --open
Engine
The central coordinator for all indexing and search operations.
| Method | Description |
|---|---|
Engine::builder(storage, schema) | Create an EngineBuilder |
engine.put_document(id, doc).await? | Upsert a document (replace if ID exists) |
engine.add_document(id, doc).await? | Add a document as a chunk (multiple chunks can share an ID) |
engine.delete_documents(id).await? | Delete all documents/chunks by external ID |
engine.get_documents(id).await? | Get all documents/chunks by external ID |
engine.search(request).await? | Execute a search request |
engine.commit().await? | Flush all pending changes to storage |
engine.add_field(name, field_option).await? | Dynamically add a new field to the schema at runtime |
engine.delete_field(name).await? | Remove a field from the schema at runtime |
engine.schema() | Return the current Schema |
engine.stats()? | Get index statistics |
put_documentvsadd_document:put_documentperforms an upsert — if a document with the same external ID already exists, it is deleted and replaced.add_documentalways appends, allowing multiple document chunks to share the same external ID. See Schema & Fields — Indexing Documents for details.
EngineBuilder
| Method | Description |
|---|---|
EngineBuilder::new(storage, schema) | Create a builder with storage and schema |
.analyzer(Arc<dyn Analyzer>) | Set the text analyzer (default: StandardAnalyzer) |
.embedder(Arc<dyn Embedder>) | Set the vector embedder (optional) |
.build().await? | Build the Engine |
Schema
Defines document structure.
| Method | Description |
|---|---|
Schema::builder() | Create a SchemaBuilder |
SchemaBuilder
| Method | Description |
|---|---|
.add_text_field(name, TextOption) | Add a full-text field |
.add_integer_field(name, IntegerOption) | Add an integer field (set IntegerOption::multi_valued = true for arrays) |
.add_float_field(name, FloatOption) | Add a float field (set FloatOption::multi_valued = true for arrays) |
.add_boolean_field(name, BooleanOption) | Add a boolean field |
.add_datetime_field(name, DateTimeOption) | Add a datetime field |
.add_geo_field(name, GeoOption) | Add a 2D geographic (lat/lon) field |
.add_geo3d_field(name, Geo3dOption) | Add a 3D ECEF Cartesian point field (x, y, z in metres) |
.add_bytes_field(name, BytesOption) | Add a binary field |
.add_hnsw_field(name, HnswOption) | Add an HNSW vector field |
.add_flat_field(name, FlatOption) | Add a Flat vector field |
.add_ivf_field(name, IvfOption) | Add an IVF vector field |
.add_default_field(name) | Set a default search field |
.add_analyzer(name, AnalyzerDefinition) | Register a custom analyzer pipeline |
.add_embedder(name, EmbedderDefinition) | Register an embedder definition |
.dynamic_field_policy(DynamicFieldPolicy) | Set the policy for undeclared fields (Strict / Dynamic / Ignore) |
.build() | Build the Schema |
Document
A collection of named field values.
| Method | Description |
|---|---|
Document::builder() | Create a DocumentBuilder |
doc.get(name) | Get a field value by name |
doc.has_field(name) | Check if a field exists |
doc.field_names() | Get all field names |
DocumentBuilder
| Method | Description |
|---|---|
.add_field(name, DataValue) | Add an arbitrary DataValue |
.add_text(name, value) | Add a text field |
.add_integer(name, value) | Add a single integer field |
.add_float(name, value) | Add a single float field |
.add_boolean(name, value) | Add a boolean field |
.add_datetime(name, value) | Add a datetime field |
.add_vector(name, vec) | Add a pre-computed vector |
.add_geo(name, lat, lon) | Add a 2D geographic point |
.add_geo_ecef(name, x, y, z) | Add a 3D ECEF Cartesian point (metres) |
.add_int64_array(name, values) | Add a multi-valued integer field |
.add_float64_array(name, values) | Add a multi-valued float field |
.add_bytes(name, data) | Add binary data |
.build() | Build the Document |
Search
SearchRequestBuilder
| Method | Description |
|---|---|
SearchRequestBuilder::new() | Create a new builder |
.query_dsl(dsl) | Set a unified DSL string (parsed at search time) |
.lexical_query(query) | Set the lexical search query (LexicalSearchQuery) |
.vector_query(query) | Set the vector search query (VectorSearchQuery) |
.filter_query(query) | Set a pre-filter query |
.fusion_algorithm(algo) | Set the fusion algorithm (default: RRF) |
.limit(n) | Maximum results (default: 10) |
.offset(n) | Skip N results (default: 0) |
.add_field_boost(field, boost) | Add a field-level boost for lexical search |
.lexical_min_score(f32) | Set minimum score threshold for lexical search |
.lexical_timeout_ms(u64) | Set lexical search timeout in milliseconds |
.lexical_parallel(bool) | Enable parallel lexical search |
.sort_by(SortField) | Set sort order for lexical search results |
.vector_score_mode(VectorScoreMode) | Set score combination mode for vector search |
.vector_min_score(f32) | Set minimum score threshold for vector search |
.build() | Build the SearchRequest |
LexicalSearchQuery
| Variant | Description |
|---|---|
LexicalSearchQuery::Dsl(String) | Query specified as a DSL string (parsed at search time) |
LexicalSearchQuery::Obj(Box<dyn Query>) | Query specified as a pre-built Query object |
VectorSearchQuery
| Variant | Description |
|---|---|
VectorSearchQuery::Payloads(Vec<QueryPayload>) | Raw payloads (text, bytes, etc.) to be embedded at search time |
VectorSearchQuery::Vectors(Vec<QueryVector>) | Pre-embedded query vectors ready for nearest-neighbor search |
SearchResult
| Field | Type | Description |
|---|---|---|
id | String | External document ID |
score | f32 | Relevance score |
document | Option<Document> | Document content (if loaded) |
FusionAlgorithm
| Variant | Description |
|---|---|
RRF { k: f64 } | Reciprocal Rank Fusion (default k=60.0) |
WeightedSum { lexical_weight, vector_weight } | Linear combination of scores |
Query Types (Lexical)
| Query | Description | Example |
|---|---|---|
TermQuery::new(field, term) | Exact term match | TermQuery::new("body", "rust") |
PhraseQuery::new(field, terms) | Exact phrase | PhraseQuery::new("body", vec!["machine".into(), "learning".into()]) |
BooleanQueryBuilder::new() | Boolean combination | .must(q1).should(q2).must_not(q3).build() |
FuzzyQuery::new(field, term) | Fuzzy match (default max_edits=2) | FuzzyQuery::new("body", "programing").max_edits(1) |
WildcardQuery::new(field, pattern) | Wildcard | WildcardQuery::new("file", "*.pdf") |
NumericRangeQuery::new(...) | Numeric range | See Lexical Search |
GeoDistanceQuery::within_radius(...) | 2D geo radius | See Lexical Search |
GeoBoundingBoxQuery::within_bounding_box(...) | 2D geo bounding box | See Lexical Search |
Geo3dDistanceQuery::within_sphere(...) | 3D ECEF sphere | See 3D Geographic Search |
Geo3dBoundingBoxQuery::within_box(...) | 3D ECEF axis-aligned box | See 3D Geographic Search |
Geo3dNearestQuery::k_nearest(...) | 3D ECEF k-NN | See 3D Geographic Search |
SpanNearQuery::new(...) | Proximity | See Lexical Search |
PrefixQuery::new(field, prefix) | Prefix match | PrefixQuery::new("body", "pro") |
RegexpQuery::new(field, pattern)? | Regex match | RegexpQuery::new("body", "^pro.*ing$")? |
Query Parsers
| Parser | Description |
|---|---|
LexicalQueryParser::new(analyzer) | Parse lexical DSL queries |
VectorQueryParser::new(embedder) | Parse vector DSL queries |
UnifiedQueryParser::new(lexical, vector) | Parse hybrid DSL queries that mix lexical and vector clauses |
Analyzers
| Type | Description |
|---|---|
StandardAnalyzer | RegexTokenizer + lowercase + stop words |
SimpleAnalyzer | Tokenization only (no filtering) |
EnglishAnalyzer | RegexTokenizer + lowercase + English stop words |
JapaneseAnalyzer | Japanese morphological analysis |
KeywordAnalyzer | No tokenization (exact match) |
PipelineAnalyzer | Custom tokenizer + filter chain |
PerFieldAnalyzer | Per-field analyzer dispatch |
Embedders
| Type | Feature Flag | Description |
|---|---|---|
CandleBertEmbedder | embeddings-candle | Local BERT model |
OpenAIEmbedder | embeddings-openai | OpenAI API |
CandleClipEmbedder | embeddings-multimodal | Local CLIP model |
PrecomputedEmbedder | (default) | Pre-computed vectors |
PerFieldEmbedder | (default) | Per-field embedder dispatch |
Storage
| Type | Description |
|---|---|
MemoryStorage | In-memory (non-durable) |
FileStorage | File-system based (supports use_mmap for memory-mapped I/O) |
StorageFactory::create(config) | Create from config |
DataValue
| Variant | Rust Type |
|---|---|
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>) | Pre-computed vector |
DataValue::DateTime(DateTime<Utc>) | chrono::DateTime<Utc> |
DataValue::Geo(GeoPoint) | (latitude, longitude) (WGS84) |
DataValue::GeoEcef(GeoEcefPoint) | (x, y, z) ECEF Cartesian (metres) |
DataValue::Int64Array(Vec<i64>) | Multi-valued integers (requires multi_valued field option) |
DataValue::Float64Array(Vec<f64>) | Multi-valued floats (requires multi_valued field option) |
CLI Overview
Laurus provides a command-line tool laurus that lets you create indexes, manage documents, and run search queries without writing code.
Features
- Index management – Create and inspect indexes from TOML schema files, with an interactive schema generator
- Document CRUD – Add, retrieve, and delete documents via JSON
- Search – Execute queries using the Query DSL
- Dual output – Human-readable tables or machine-parseable JSON
- Interactive REPL – Explore your index in a live session
- gRPC server – Start a gRPC server with
laurus serve
Getting Started
# Install
cargo install laurus-cli
# Generate a schema interactively
laurus create schema
# Create an index from the schema
laurus --index-dir ./my_index create index --schema schema.toml
# Add a document
laurus --index-dir ./my_index add doc --id doc1 --data '{"title":"Hello","body":"World"}'
# Commit changes
laurus --index-dir ./my_index commit
# Search
laurus --index-dir ./my_index search "body:world"
See the sub-sections for detailed documentation:
- Installation – How to install the CLI
- Commands – Full command reference
- Schema Format – Schema TOML format reference
- REPL – Interactive mode
Installation
From crates.io
cargo install laurus-cli
This installs the laurus binary to ~/.cargo/bin/.
From source
git clone https://github.com/mosuka/laurus.git
cd laurus
cargo install --path laurus-cli
Verify
laurus --version
Hands-on Tutorial
This tutorial walks you through a complete workflow using the laurus CLI: creating a schema, building an index, adding documents, searching, updating, deleting, and using the interactive REPL.
Prerequisites
- laurus CLI installed (see Installation)
Step 1: Create a Schema
First, create a schema file that defines your index structure. You can generate one interactively:
laurus create schema
The interactive wizard guides you through defining fields, their types, and options. For this tutorial, create a schema file manually instead:
cat > schema.toml << 'EOF'
default_fields = ["title", "body"]
[fields.title.Text]
indexed = true
stored = true
term_vectors = false
[fields.body.Text]
indexed = true
stored = true
term_vectors = false
[fields.category.Text]
indexed = true
stored = true
term_vectors = false
EOF
This defines three text fields. The default_fields setting means queries without a field prefix will search title and body.
Step 2: Create an Index
Create an index using the schema:
laurus --index-dir ./tutorial_data create index --schema schema.toml
Verify the index was created:
laurus --index-dir ./tutorial_data get stats
The output shows the document count is 0.
Step 3: Add Documents
Add documents to the index. Each document needs an ID and a JSON object with field values:
laurus --index-dir ./tutorial_data add doc \
--id doc001 \
--data '{"title":"Introduction to Rust Programming","body":"Rust is a modern systems programming language that focuses on safety, speed, and concurrency.","category":"programming"}'
laurus --index-dir ./tutorial_data add doc \
--id doc002 \
--data '{"title":"Web Development with Rust","body":"Building web applications with Rust has become increasingly popular. Frameworks like Actix and Rocket make it easy to create fast and secure web services.","category":"web-development"}'
laurus --index-dir ./tutorial_data add doc \
--id doc003 \
--data '{"title":"Python for Data Science","body":"Python is the most popular language for data science and machine learning. Libraries like NumPy and Pandas provide powerful tools for data analysis.","category":"data-science"}'
Step 4: Commit Changes
Documents are not searchable until committed:
laurus --index-dir ./tutorial_data commit
Step 5: Search Documents
Basic Search
Search for documents containing “rust”:
laurus --index-dir ./tutorial_data search "rust"
This searches the default fields (title and body). Results show doc001 and doc002.
Field-Specific Search
Search only in the title field:
laurus --index-dir ./tutorial_data search "title:python"
Only doc003 is returned.
Category Search
laurus --index-dir ./tutorial_data search "category:programming"
Only doc001 is returned.
Boolean Queries
Combine conditions with + (must) and - (must not):
laurus --index-dir ./tutorial_data search "+body:rust -body:web"
Only doc001 is returned (contains “rust” but not “web”).
Phrase Search
Search for an exact phrase:
laurus --index-dir ./tutorial_data search 'body:"data science"'
Only doc003 is returned.
Fuzzy Search
Search with typo tolerance using ~:
laurus --index-dir ./tutorial_data search "body:programing~1"
Matches “programming” despite the typo.
JSON Output
Get results in JSON format for programmatic use:
laurus --index-dir ./tutorial_data --format json search "rust"
Step 6: Retrieve a Document
Fetch a specific document by ID:
laurus --index-dir ./tutorial_data get docs --id doc001
Step 7: Delete a Document
Delete a document and commit the change:
laurus --index-dir ./tutorial_data delete docs --id doc003
laurus --index-dir ./tutorial_data commit
Verify it was deleted:
laurus --index-dir ./tutorial_data search "python"
No results are returned.
Step 8: Use the REPL
The REPL provides an interactive session for exploring your index:
laurus --index-dir ./tutorial_data repl
Try these commands in the REPL:
> get stats
> search rust
> add doc doc004 {"title":"Go Programming","body":"Go is a statically typed language designed for simplicity and efficiency.","category":"programming"}
> commit
> search programming
> get docs doc004
> delete docs doc004
> commit
> quit
The REPL supports command history (Up/Down arrows) and line editing.
Step 9: Clean Up
Remove the tutorial data:
rm -rf ./tutorial_data schema.toml
Next Steps
- Learn about the Schema Format for advanced field configurations
- See the full Commands reference
- Explore the REPL for interactive usage
- Try the server tutorial for gRPC/HTTP access
Command Reference
Global Options
Every command accepts these options:
| Option | Environment Variable | Default | Description |
|---|---|---|---|
--index-dir <PATH> | LAURUS_INDEX_DIR | ./laurus_index | Path to the index data directory |
--format <FORMAT> | — | table | Output format: table or json |
# Example: use JSON output with a custom data directory
laurus --index-dir /var/data/my_index --format json search "title:rust"
create — Create a Resource
create index
Create a new index. If --schema is given, uses that TOML file; otherwise launches the interactive schema wizard.
laurus create index [--schema <FILE>]
Arguments:
| Flag | Required | Description |
|---|---|---|
--schema <FILE> | No | Path to a TOML file defining the index schema. When omitted, the command checks if a schema.toml already exists in the index directory and uses it; otherwise the interactive wizard is launched. |
Schema file format:
The schema file follows the same structure as the Schema type in the Laurus library. See Schema Format Reference for full details. Example:
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
Examples:
# From a schema file
laurus --index-dir ./my_index create index --schema schema.toml
# Index created at ./my_index.
# Interactive wizard (no --schema flag)
laurus --index-dir ./my_index create index
# === Laurus Schema Generator ===
# Field name: title
# ...
# Index created at ./my_index.
Note: If both
schema.tomlandstore/already exist, an error is returned. Delete the index directory to recreate. If onlyschema.tomlexists (e.g. after an interrupted creation), runningcreate indexwithout--schemarecovers the index by creating the missing storage from the existing schema.
create schema
Interactively generate a schema TOML file through a guided wizard.
laurus create schema [--output <FILE>]
Arguments:
| Flag | Required | Default | Description |
|---|---|---|---|
--output <FILE> | No | schema.toml | Output file path for the generated schema |
The wizard guides you through:
- Field definition — Enter a field name, select the type, and configure type-specific options
- Repeat — Add as many fields as needed
- Default fields — Select which lexical fields to use as default search fields
- Preview — Review the generated TOML before saving
- Save — Write the schema file
Supported field types:
| Type | Category | Options |
|---|---|---|
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 |
Example:
# Generate schema.toml interactively
laurus create schema
# Specify output path
laurus create schema --output my_schema.toml
# Then create an index from the generated schema
laurus create index --schema schema.toml
get — Get a Resource
get stats
Display statistics about the index.
laurus get stats
Table output example:
Document count: 42
Vector fields:
╭──────────┬─────────┬───────────╮
│ Field │ Vectors │ Dimension │
├──────────┼─────────┼───────────┤
│ text_vec │ 42 │ 384 │
╰──────────┴─────────┴───────────╯
JSON output example:
laurus --format json get stats
{
"document_count": 42,
"fields": {
"text_vec": {
"vector_count": 42,
"dimension": 384
}
}
}
get schema
Display the current index schema as JSON.
laurus get schema
Example:
laurus get schema
# {
# "fields": { ... },
# "default_fields": ["title", "body"],
# ...
# }
get docs
Retrieve all documents (including chunks) by external ID.
laurus get docs --id <ID>
Table output example:
╭──────┬─────────────────────────────────────────╮
│ ID │ Fields │
├──────┼─────────────────────────────────────────┤
│ doc1 │ body: This is a test, title: Hello World │
╰──────┴─────────────────────────────────────────╯
JSON output example:
laurus --format json get docs --id doc1
[
{
"id": "doc1",
"document": {
"title": "Hello World",
"body": "This is a test document."
}
}
]
add — Add a Resource
add doc
Add a document to the index. Documents are not searchable until commit is called.
laurus add doc --id <ID> --data <JSON>
Arguments:
| Flag | Required | Description |
|---|---|---|
--id <ID> | Yes | External document ID (string) |
--data <JSON> | Yes | Document fields as a JSON string |
The JSON format is a flat object mapping field names to values:
{
"title": "Introduction to Rust",
"body": "Rust is a systems programming language.",
"category": "programming"
}
Example:
laurus add doc --id doc1 --data '{"title":"Hello World","body":"This is a test document."}'
# Document 'doc1' added. Run 'commit' to persist changes.
Tip: Multiple documents can share the same external ID (chunking pattern). Use
add docfor each chunk.
put — Put (Upsert) a Resource
put doc
Put (upsert) a document into the index. If a document with the same ID already exists, all its chunks are deleted before the new document is indexed. Documents are not searchable until commit is called.
laurus put doc --id <ID> --data <JSON>
Arguments:
| Flag | Required | Description |
|---|---|---|
--id <ID> | Yes | External document ID (string) |
--data <JSON> | Yes | Document fields as a JSON string |
Example:
laurus put doc --id doc1 --data '{"title":"Updated Title","body":"This replaces the existing document."}'
# Document 'doc1' put (upserted). Run 'commit' to persist changes.
Note: Unlike
add doc,put docreplaces all existing chunks for the given ID. Useadd docwhen you want to append chunks, andput docwhen you want to replace the entire document.
add field
Dynamically add a new field to an existing index.
laurus add field --index-dir ./data \
--name category \
--field-option '{"Text": {"indexed": true, "stored": true}}'
The --field-option argument accepts a JSON string using the same
externally-tagged format as the schema file. The schema is automatically
persisted after the field is added.
delete — Delete a Resource
delete docs
Delete all documents (including chunks) by external ID.
laurus delete docs --id <ID>
Example:
laurus delete docs --id doc1
# Documents 'doc1' deleted. Run 'commit' to persist changes.
delete field
Remove a field from the index schema.
laurus delete field --name <FIELD_NAME>
Example:
laurus delete field --name category
# Field 'category' deleted.
Existing indexed data for the field remains in storage but becomes inaccessible. Per-field analyzers and embedders are unregistered.
commit
Commit pending changes (additions and deletions) to the index. Until committed, changes are not visible to search.
laurus commit
Example:
laurus --index-dir ./my_index commit
# Changes committed successfully.
search
Execute a search query using the Query DSL.
laurus search <QUERY> [--limit <N>] [--offset <N>]
Arguments:
| Argument / Flag | Required | Default | Description |
|---|---|---|---|
<QUERY> | Yes | — | Query string in Laurus Query DSL |
--limit <N> | No | 10 | Maximum number of results |
--offset <N> | No | 0 | Number of results to skip |
Query syntax examples:
# Term query
laurus search "body:rust"
# Phrase query
laurus search 'body:"machine learning"'
# Boolean query
laurus search "+body:programming -body:python"
# Fuzzy query (typo tolerance)
laurus search "body:programing~2"
# Wildcard query
laurus search "title:intro*"
# Range query
laurus search "price:[10 TO 50]"
# 3D geographic queries (sphere / bounding box / 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)"
Table output example:
╭──────┬────────┬─────────────────────────────────────────╮
│ ID │ Score │ Fields │
├──────┼────────┼─────────────────────────────────────────┤
│ doc1 │ 0.8532 │ body: Rust is a systems..., title: Intr │
│ doc3 │ 0.4210 │ body: JavaScript powers..., title: Web │
╰──────┴────────┴─────────────────────────────────────────╯
JSON output example:
laurus --format json search "body:rust" --limit 5
[
{
"id": "doc1",
"score": 0.8532,
"document": {
"title": "Introduction to Rust",
"body": "Rust is a systems programming language."
}
}
]
repl
Start an interactive REPL session. See REPL for details.
laurus repl
serve
Start the gRPC server (and optionally the HTTP Gateway).
laurus serve [OPTIONS]
For startup options, configuration, and usage examples, see the laurus-server documentation:
- Getting Started — startup options and gRPC connection examples
- Configuration — TOML config file, environment variables, and priority rules
- Hands-on Tutorial — step-by-step walkthrough
mcp
Start the Model Context Protocol (MCP) server on stdio. The MCP server lets AI assistants such as Claude Code or Claude Desktop drive a running laurus-server through a standard set of tools (create_index, add_document, search, etc.).
laurus mcp [--endpoint <URL>]
Arguments:
| Flag | Environment Variable | Required | Description |
|---|---|---|---|
--endpoint <URL> | LAURUS_ENDPOINT | No | gRPC endpoint of a running laurus-server (e.g. http://localhost:50051). If omitted, the server starts without a connection; clients can call the connect MCP tool later to attach. |
Examples:
# Start the MCP server pre-connected to a local laurus-server
laurus mcp --endpoint http://localhost:50051
# Start the MCP server without a connection; clients call `connect` first
laurus mcp
For the full list of MCP tools exposed by this server and how to wire it into Claude Code or Claude Desktop, see the laurus-mcp documentation.
Schema Format Reference
The schema file defines the structure of your index — what fields exist, their types, and how they are indexed. Laurus uses TOML format for schema files.
Overview
A schema consists of three top-level elements:
# Policy for fields not declared below. Optional — defaults to "dynamic".
dynamic_field_policy = "dynamic"
# Fields to search by default when a query does not specify a field.
default_fields = ["title", "body"]
# Field definitions. Each field has a name and a typed configuration.
[fields.<field_name>.<FieldType>]
# ... type-specific options
dynamic_field_policy— How the engine treats fields present in an ingested document but absent from this schema. Accepted values:"strict","dynamic","ignore". Defaults to"dynamic". See Dynamic Schema for the full semantics and the warning about silent truncation under"dynamic".default_fields— A list of field names used as default search targets by the Query DSL. Only lexical fields (Text, Integer, Float, etc.) can be default fields. This key is optional and defaults to an empty list.fields— A map of field names to their typed configuration. Each field must specify exactly one field type.
Field Naming
- Field names are arbitrary strings (e.g.,
title,body_vec,created_at). - Field names starting with
_are reserved for the engine. The only allow-listed name is_id(managed automatically). Attempting to declare any other_-prefixed field results in an error. - Field names must be unique within a schema.
Field Types
Fields fall into two categories: Lexical (for keyword/full-text search) and Vector (for similarity search). A single field cannot be both.
Lexical Fields
Text
Full-text searchable field. Text is processed by the analysis pipeline (tokenization, normalization, stemming, etc.).
[fields.title.Text]
indexed = true # Whether to index this field for search
stored = true # Whether to store the original value for retrieval
term_vectors = false # Whether to store term positions (for phrase queries, highlighting)
| Option | Type | Default | Description |
|---|---|---|---|
indexed | bool | true | Enables searching this field |
stored | bool | true | Stores the original value so it can be returned in results |
term_vectors | bool | true | Stores term positions for phrase queries, highlighting, and more-like-this |
Integer
64-bit signed integer field. Supports range queries and exact match.
[fields.year.Integer]
indexed = true
stored = true
multi_valued = false
| Option | Type | Default | Description |
|---|---|---|---|
indexed | bool | true | Enables range and exact-match queries |
stored | bool | true | Stores the original value |
multi_valued | bool | false | Accept arrays of integers; range queries match if any value satisfies the predicate (Lucene-style “any match” with constant scoring) |
Float
64-bit floating point field. Supports range queries.
[fields.rating.Float]
indexed = true
stored = true
multi_valued = false
| Option | Type | Default | Description |
|---|---|---|---|
indexed | bool | true | Enables range queries |
stored | bool | true | Stores the original value |
multi_valued | bool | false | Accept arrays of floats; range queries match if any value satisfies the predicate (Lucene-style “any match” with constant scoring) |
Boolean
Boolean field (true / false).
[fields.published.Boolean]
indexed = true
stored = true
| Option | Type | Default | Description |
|---|---|---|---|
indexed | bool | true | Enables filtering by boolean value |
stored | bool | true | Stores the original value |
DateTime
UTC timestamp field. Supports range queries.
[fields.created_at.DateTime]
indexed = true
stored = true
| Option | Type | Default | Description |
|---|---|---|---|
indexed | bool | true | Enables range queries on date/time |
stored | bool | true | Stores the original value |
Geo
Geographic point field (latitude/longitude). Supports radius and bounding box queries.
[fields.location.Geo]
indexed = true
stored = true
| Option | Type | Default | Description |
|---|---|---|---|
indexed | bool | true | Enables geo queries (radius, bounding box) |
stored | bool | true | Stores the original value |
Geo3d
3D Earth-Centered Earth-Fixed (ECEF) Cartesian point field (x / y / z in meters). Supports the geo3d_distance (sphere), geo3d_bbox (3D AABB), and geo3d_nearest (k-NN) queries. See 3D Geographic Search (ECEF) for the coordinate system and the wgs84_to_ecef / ecef_to_wgs84 conversion utilities.
[fields.position.Geo3d]
indexed = true
stored = true
| Option | Type | Default | Description |
|---|---|---|---|
indexed | bool | true | Enables 3D geo queries (geo3d_distance, geo3d_bbox, geo3d_nearest) |
stored | bool | true | Stores the original (x, y, z) value |
Bytes
Raw binary data field. Not indexed — stored only.
[fields.thumbnail.Bytes]
stored = true
| Option | Type | Default | Description |
|---|---|---|---|
stored | bool | true | Stores the binary data |
Vector Fields
Vector fields are indexed for approximate nearest neighbor (ANN) search. They require a dimension (the length of each vector) and a distance metric.
Hnsw
Hierarchical Navigable Small World graph index. Best for most use cases — offers a good balance of speed and recall.
[fields.body_vec.Hnsw]
dimension = 384
distance = "Cosine"
m = 16
ef_construction = 200
base_weight = 1.0
| Option | Type | Default | Description |
|---|---|---|---|
dimension | integer | 128 | Vector dimensionality (must match your embedding model) |
distance | string | "Cosine" | Distance metric (see Distance Metrics) |
m | integer | 16 | Max bi-directional connections per node. Higher = better recall, more memory |
ef_construction | integer | 200 | Search width during index construction. Higher = better quality, slower build |
base_weight | float | 1.0 | Scoring weight in hybrid search fusion |
quantizer | object | "Scalar8Bit" | Quantization method (see Quantization). Mandatory; default keeps the int8 format introduced in Issue #481 Stage 1. |
rerank_storage | string | (omit) | Optional Stage 2 rerank sidecar (see Rerank Storage). "F32" enables a per-field f32 sidecar so search can rescore int8 candidates against the original vectors. Omit to keep Stage 1 int8-only behavior. |
Tuning guidelines:
m: 12–48 is typical. Use higher values for higher-dimensional vectors.ef_construction: 100–500. Higher values produce a better graph but increase build time.dimension: Must exactly match the output dimension of your embedding model (e.g., 384 forall-MiniLM-L6-v2, 768 forBERT-base, 1536 fortext-embedding-3-small).
Flat
Brute-force linear scan index. Provides exact results with no approximation. Best for small datasets (< 10,000 vectors).
[fields.embedding.Flat]
dimension = 384
distance = "Cosine"
base_weight = 1.0
| Option | Type | Default | Description |
|---|---|---|---|
dimension | integer | 128 | Vector dimensionality |
distance | string | "Cosine" | Distance metric (see Distance Metrics) |
base_weight | float | 1.0 | Scoring weight in hybrid search fusion |
quantizer | object | "Scalar8Bit" | Quantization method (see Quantization). Mandatory; default keeps the int8 format introduced in Issue #481 Stage 1. |
rerank_storage | string | (omit) | Reserved for Rerank Storage. Currently emitted only by the HNSW writer; Flat / IVF accept the field for schema symmetry but do not yet write or consume the sidecar. |
Ivf
Inverted File Index. Clusters vectors and searches only a subset of clusters. Suitable for very large datasets.
[fields.embedding.Ivf]
dimension = 384
distance = "Cosine"
n_clusters = 100
n_probe = 1
base_weight = 1.0
| Option | Type | Default | Description |
|---|---|---|---|
dimension | integer | (required) | Vector dimensionality |
distance | string | "Cosine" | Distance metric (see Distance Metrics) |
n_clusters | integer | 100 | Number of clusters. More clusters = finer partitioning |
n_probe | integer | 1 | Number of clusters to search at query time. Higher = better recall, slower |
base_weight | float | 1.0 | Scoring weight in hybrid search fusion |
quantizer | object | "Scalar8Bit" | Quantization method (see Quantization). Mandatory; default keeps the int8 format introduced in Issue #481 Stage 1. |
rerank_storage | string | (omit) | Reserved for Rerank Storage. Currently emitted only by the HNSW writer; Flat / IVF accept the field for schema symmetry but do not yet write or consume the sidecar. |
Note: Unlike Hnsw and Flat, the
dimensionfield in Ivf is required and has no default value.
Tuning guidelines:
n_clusters: A common heuristic issqrt(N)where N is the total number of vectors.n_probe: Start with 1 and increase until recall is acceptable. Typical range is 1–20.
Distance Metrics
The distance option for vector fields accepts the following values:
| Value | Description | Use When |
|---|---|---|
"Cosine" | Cosine distance (1 - cosine similarity). Default. | Normalized text/image embeddings |
"Euclidean" | L2 (Euclidean) distance | Spatial data, non-normalized vectors |
"Manhattan" | L1 (Manhattan) distance | Sparse feature vectors |
"DotProduct" | Dot product (higher = more similar) | Pre-normalized vectors where magnitude matters |
"Angular" | Angular distance | Similar to cosine, but based on angle |
For most embedding models (BERT, Sentence Transformers, OpenAI, etc.), "Cosine" is the correct choice.
Quantization
Vector fields are stored on disk as 8-bit scalar-quantized integers
(Issue #481 Stage 1). Quantization is mandatory; the previous “no
quantization” mode no longer exists. The quantizer option defaults to
Scalar8Bit and can be omitted from TOML.
Scalar 8-bit (default)
Per-segment global affine quantization to u8. Compresses each f32
component to a single byte (~4x memory reduction) with negligible
recall loss in practice.
[fields.embedding.Hnsw]
dimension = 384
distance = "Cosine"
# quantizer = "Scalar8Bit" # implicit default; can be omitted
Product Quantization (reserved)
Reserved for Issue #481 Stage 3. Currently the writer / searcher
return NotImplemented if selected; the variant is kept here so
schemas can pre-declare without further TOML changes once Stage 3
lands.
[fields.embedding.Hnsw]
dimension = 384
distance = "Cosine"
[fields.embedding.Hnsw.quantizer.ProductQuantization]
subvector_count = 48
| Option | Type | Description |
|---|---|---|
subvector_count | integer | Number of subvectors. Must evenly divide dimension. |
Breaking change (Issue #481 Stage 1): schemas that explicitly set
quantizerto a “none” value are no longer valid. Existing vector indexes built with a pre-Stage-1 laurus build cannot be read; rebuild from source data after upgrading.
Rerank Storage
Optional Stage 2 sidecar (Issue #481) that keeps the original
full-precision vectors alongside the int8 segment so the HNSW
searcher can do a wide candidate fetch over int8 (cheap) and then
rescore the top top_k * rerank_factor candidates against the
exact f32 values (accurate).
The sidecar is configured per field with rerank_storage:
[fields.embedding.Hnsw]
dimension = 384
distance = "Cosine"
rerank_storage = "F32" # opt-in; omit for Stage 1 int8-only behavior
| Value | On-disk overhead | Description |
|---|---|---|
"F32" | +4 bytes/dim per vector | IEEE-754 single-precision sidecar (Lucene 99 / FAISS convention). |
When omitted, no sidecar is written and the field stays on the
Stage 1 int8-only search path. Queries that pass rerank_factor
against a field without rerank_storage silently fall back to
Stage 1 ranking — the searcher cannot recover f32 information that
was discarded at index time.
Scope: Stage 2 lands HNSW only. Flat / IVF accept the field for schema symmetry but currently neither emit nor consume the sidecar.
Complete Examples
Full-text search only
A simple blog post index with lexical search:
default_fields = ["title", "body"]
[fields.title.Text]
indexed = true
stored = true
term_vectors = false
[fields.body.Text]
indexed = true
stored = true
term_vectors = false
[fields.category.Text]
indexed = true
stored = true
term_vectors = false
[fields.published_at.DateTime]
indexed = true
stored = true
Vector search only
A vector-only index for semantic similarity:
[fields.embedding.Hnsw]
dimension = 768
distance = "Cosine"
m = 16
ef_construction = 200
Hybrid search (lexical + vector)
Combine lexical and vector search for best-of-both-worlds retrieval:
default_fields = ["title", "body"]
[fields.title.Text]
indexed = true
stored = true
term_vectors = false
[fields.body.Text]
indexed = true
stored = true
term_vectors = true
[fields.category.Text]
indexed = true
stored = true
term_vectors = false
[fields.body_vec.Hnsw]
dimension = 384
distance = "Cosine"
m = 16
ef_construction = 200
Tip: A single field cannot be both lexical and vector. Use separate fields (e.g.,
bodyfor text,body_vecfor embedding) and map them both to the same source content.
E-commerce product index
A more complex schema with mixed field types:
default_fields = ["name", "description"]
[fields.name.Text]
indexed = true
stored = true
term_vectors = false
[fields.description.Text]
indexed = true
stored = true
term_vectors = true
[fields.price.Float]
indexed = true
stored = true
[fields.in_stock.Boolean]
indexed = true
stored = true
[fields.created_at.DateTime]
indexed = true
stored = true
[fields.location.Geo]
indexed = true
stored = true
[fields.description_vec.Hnsw]
dimension = 384
distance = "Cosine"
Generating a Schema
You can generate a schema TOML file interactively using the CLI:
laurus create schema
laurus create schema --output my_schema.toml
See create schema for details.
Using a Schema
Once you have a schema file, create an index from it:
laurus create index --schema schema.toml
Or load it programmatically in 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 (Interactive Mode)
The REPL provides an interactive session for exploring your index without typing the full laurus command each time.
Starting the REPL
laurus --index-dir ./my_index repl
If an index already exists at the specified directory, it is opened automatically:
Laurus REPL (type 'help' for commands, 'quit' to exit)
laurus>
If no index exists yet, the REPL starts without a loaded index and guides you to create one:
Laurus REPL — no index found at ./my_index.
Use 'create index <schema_path>' to create one, or 'help' for commands.
laurus>
Available Commands
Commands follow the same <operation> <resource> ordering as the CLI.
| Command | Description |
|---|---|
create index [schema_path] | Create a new index (interactive wizard if no path given) |
create schema <output_path> | Interactive schema generation wizard |
search <query> | Search the index |
add field <name> <json> | Add a field to the schema |
add doc <id> <json> | Add a document (append, allows multiple chunks per ID) |
put doc <id> <json> | Put (upsert) a document (replaces existing with same ID) |
get stats | Show index statistics |
get schema | Show the current schema |
get docs <id> | Get all documents (including chunks) by ID |
delete field <name> | Remove a field from the schema |
delete docs <id> | Delete all documents (including chunks) by ID |
commit | Commit pending changes |
help | Show available commands |
quit / exit | Exit the REPL |
Note: Commands other than
create,help, andquitrequire a loaded index. If no index is loaded, the REPL displays a message asking you to runcreate indexfirst.
Usage Examples
Creating an Index
laurus> create index ./schema.toml
Index created at ./my_index.
laurus> add doc doc1 {"title":"Hello","body":"World"}
Document 'doc1' added.
Searching
laurus> search body:rust
╭──────┬────────┬────────────────────────────────────╮
│ ID │ Score │ Fields │
├──────┼────────┼────────────────────────────────────┤
│ doc1 │ 0.8532 │ body: Rust is a systems..., title… │
╰──────┴────────┴────────────────────────────────────╯
Managing Fields
laurus> add field category {"Text": {"indexed": true, "stored": true}}
Field 'category' added.
laurus> delete field category
Field 'category' deleted.
Adding and Committing Documents
laurus> add doc doc4 {"title":"New Document","body":"Some content here."}
Document 'doc4' added.
laurus> commit
Changes committed.
Retrieving Information
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 │
╰──────┴───────────────────────────────────────────────╯
Deleting Documents
laurus> delete docs doc4
Documents 'doc4' deleted.
laurus> commit
Changes committed.
Features
- Line editing — Arrow keys, Home/End, and standard readline shortcuts
- History — Use Up/Down arrows to recall previous commands
- Ctrl+C / Ctrl+D — Exit the REPL gracefully
Server Overview
The laurus-server crate provides a gRPC server with an optional HTTP/JSON gateway for the Laurus search engine. It keeps the engine resident in memory, eliminating per-command startup overhead.
Features
- Persistent engine – The index stays open across requests; no WAL replay on every call
- Full gRPC API – Index management, document CRUD, commit, and search (unary + streaming)
- HTTP Gateway – Optional HTTP/JSON gateway alongside gRPC for REST-style access
- Health checking – Standard health check endpoint for load balancers and orchestrators
- Graceful shutdown – Pending changes are committed automatically on Ctrl+C / SIGINT
- TOML configuration – Optional config file with CLI and environment variable overrides
Architecture
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
The gRPC server always runs. The HTTP Gateway is optional and proxies HTTP/JSON requests to the gRPC server internally.
Quick Start
# Start with default settings (gRPC on port 50051)
laurus serve
# Start with HTTP Gateway
laurus serve --http-port 8080
# Start with a configuration file
laurus serve --config config.toml
Sections
- Getting Started – Startup options and first steps
- Configuration – TOML configuration, environment variables, and priority
- gRPC API Reference – Full API documentation for all services and RPCs
- HTTP Gateway – HTTP/JSON endpoint reference
Getting Started with the gRPC Server
Starting the Server
The gRPC server is started via the serve subcommand of the laurus CLI:
laurus serve [OPTIONS]
Options
| Option | Short | Env Variable | Default | Description |
|---|---|---|---|---|
--config <PATH> | -c | LAURUS_CONFIG | – | Path to a TOML configuration file |
--host <HOST> | -H | LAURUS_HOST | 0.0.0.0 | Listen address |
--port <PORT> | -p | LAURUS_PORT | 50051 | Listen port |
--http-port <PORT> | – | LAURUS_HTTP_PORT | – | HTTP Gateway port (enables HTTP gateway when set) |
Log verbosity is controlled by the standard RUST_LOG environment variable (default: info).
See env_logger syntax for filter directives such as RUST_LOG=laurus=debug,tonic=warn.
The global --index-dir option (env: LAURUS_INDEX_DIR) specifies the index data directory:
# Using CLI arguments
laurus --index-dir ./my_index serve --port 8080
# Using environment variables
export LAURUS_INDEX_DIR=./my_index
export LAURUS_PORT=8080
export RUST_LOG=debug
laurus serve
Startup Behavior
On startup, the server attempts to open an existing index at the configured data directory. If no index exists, the server starts without one – you can create an index later via the CreateIndex RPC.
Configuration
You can use a TOML configuration file instead of (or in addition to) command-line options. See Configuration for the full reference.
laurus serve --config config.toml
HTTP Gateway
When --http-port is set, an HTTP/JSON gateway starts alongside the gRPC server. See HTTP Gateway for the full endpoint reference and examples.
laurus serve --http-port 8080
Graceful Shutdown
When the server receives a shutdown signal (Ctrl+C / SIGINT), it automatically:
- Stops accepting new connections
- Commits any pending changes to the index
- Exits cleanly
Connecting via gRPC
Any gRPC client can connect to the server. For quick testing, grpcurl is useful:
# Health check
grpcurl -plaintext localhost:50051 laurus.v1.HealthService/Check
# Create an index
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
# Add a document
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
# Commit
grpcurl -plaintext localhost:50051 laurus.v1.DocumentService/Commit
# Search
grpcurl -plaintext -d '{"query": "body:test", "limit": 10}' \
localhost:50051 laurus.v1.SearchService/Search
See gRPC API Reference for the full API documentation, or try the Hands-on Tutorial for a step-by-step walkthrough using the HTTP Gateway.
Hands-on Tutorial
This tutorial walks you through a complete workflow with laurus-server: starting the server, creating an index, adding documents, searching, updating, and deleting. All examples use curl via the HTTP Gateway.
Prerequisites
- laurus CLI installed (see Installation)
curlavailable on your system
Step 1: Start the Server
Start laurus-server with the HTTP Gateway enabled:
laurus --index-dir /tmp/laurus/tutorial serve --port 50051 --http-port 8080
You should see log output indicating the gRPC server (port 50051) and the HTTP Gateway (port 8080) have started.
Verify the server is running:
curl http://localhost:8080/v1/health
Expected response:
{"status":"SERVING_STATUS_SERVING"}
Step 2: Create an Index
Create an index with a schema that defines text fields for lexical search and a vector field for vector search. This example demonstrates custom analyzers, embedder definitions, and per-field configuration:
curl -X POST http://localhost:8080/v1/index \
-H 'Content-Type: application/json' \
-d '{
"schema": {
"analyzers": {
"body_analyzer": {
"char_filters": [{"type": "unicode_normalization", "form": "nfkc"}],
"tokenizer": {"type": "regex"},
"token_filters": [
{"type": "lowercase"},
{"type": "stop", "words": ["the", "a", "an", "is", "it"]}
]
}
},
"embedders": {
"my_embedder": {"type": "precomputed"}
},
"fields": {
"title": {"text": {"indexed": true, "stored": true, "term_vectors": false, "analyzer": "standard"}},
"body": {"text": {"indexed": true, "stored": true, "term_vectors": false, "analyzer": "body_analyzer"}},
"category": {"text": {"indexed": true, "stored": true, "term_vectors": false, "analyzer": "keyword"}},
"embedding": {"hnsw": {"dimension": 4, "distance": "DISTANCE_METRIC_COSINE", "m": 16, "ef_construction": 200, "embedder": "my_embedder"}}
},
"default_fields": ["title", "body"]
}
}'
This creates an index with three text fields and one vector field:
title— uses the built-instandardanalyzer (tokenizes and lowercases).body— uses the custombody_analyzerdefined in theanalyzerssection (NFKC normalization + regex tokenizer + lowercase + custom stop words).category— uses thekeywordanalyzer (treats the entire value as a single token for exact matching).embedding— HNSW vector index with 4 dimensions, cosine distance, using themy_embedderembedder defined inembedders. In this tutorial we useprecomputed(vectors supplied externally). In production, use a dimension matching your embedding model (e.g. 384 or 768).
The default_fields setting means that queries without a field prefix will search both title and body.
Built-in analyzers
standard, keyword, english, japanese, simple, noop. If omitted, the engine default (standard) is used.
Custom analyzer components
You can compose custom analyzers from the following components:
- Tokenizers:
whitespace,unicode_word,regex,ngram,lindera,whole - Char filters:
unicode_normalization,pattern_replace,mapping,japanese_iteration_mark - Token filters:
lowercase,stop,stem,boost,limit,strip,remove_empty,flatten_graph
Embedders
The embedders section defines how vectors are generated. Each vector field can reference an embedder by name via the embedder option. Available types:
precomputed— vectors are supplied externally (no automatic embedding).candle_bert— local BERT model via Candle. Params:model(HuggingFace model ID). Requiresembeddings-candlefeature.candle_clip— local CLIP multimodal model. Params:model(HuggingFace model ID). Requiresembeddings-multimodalfeature.openai— OpenAI API. Params:model(e.g."text-embedding-3-small"). Requiresembeddings-openaifeature andOPENAI_API_KEYenv var.
Example with a BERT embedder (requires the embeddings-candle feature):
{
"embedders": {
"bert": {"type": "candle_bert", "model": "sentence-transformers/all-MiniLM-L6-v2"}
},
"fields": {
"embedding": {"hnsw": {"dimension": 384, "embedder": "bert"}}
}
}
Verify the index was created:
curl http://localhost:8080/v1/index
Expected response:
{"document_count":0,"vector_fields":{}}
Step 3: Add Documents
Add a few documents to the index. Use PUT to upsert documents by ID. Each document includes text fields and an embedding vector (in production, these vectors would come from an embedding model):
curl -X PUT http://localhost:8080/v1/documents/doc001 \
-H 'Content-Type: application/json' \
-d '{
"document": {
"fields": {
"title": "Introduction to Rust Programming",
"body": "Rust is a modern systems programming language that focuses on safety, speed, and concurrency.",
"category": "programming",
"embedding": [0.9, 0.1, 0.2, 0.0]
}
}
}'
curl -X PUT http://localhost:8080/v1/documents/doc002 \
-H 'Content-Type: application/json' \
-d '{
"document": {
"fields": {
"title": "Web Development with Rust",
"body": "Building web applications with Rust has become increasingly popular. Frameworks like Actix and Rocket make it easy to create fast and secure web services.",
"category": "web-development",
"embedding": [0.7, 0.3, 0.5, 0.1]
}
}
}'
curl -X PUT http://localhost:8080/v1/documents/doc003 \
-H 'Content-Type: application/json' \
-d '{
"document": {
"fields": {
"title": "Python for Data Science",
"body": "Python is the most popular language for data science and machine learning. Libraries like NumPy and Pandas provide powerful tools for data analysis.",
"category": "data-science",
"embedding": [0.1, 0.8, 0.1, 0.9]
}
}
}'
Vector fields are specified as JSON arrays of numbers. The array length must match the dimension configured in the schema (4 in this tutorial).
Step 4: Commit Changes
Documents are not searchable until committed. Commit the pending changes:
curl -X POST http://localhost:8080/v1/commit
Step 5: Search Documents
Basic Search
Search for documents containing “rust”:
curl -X POST http://localhost:8080/v1/search \
-H 'Content-Type: application/json' \
-d '{"query": "rust", "limit": 10}'
This searches the default fields (title and body). Expected result: doc001 and doc002 are returned.
Field-Specific Search
Search only in the title field:
curl -X POST http://localhost:8080/v1/search \
-H 'Content-Type: application/json' \
-d '{"query": "title:python", "limit": 10}'
Expected result: only doc003 is returned.
Search by Category
curl -X POST http://localhost:8080/v1/search \
-H 'Content-Type: application/json' \
-d '{"query": "category:programming", "limit": 10}'
Expected result: only doc001 is returned.
Boolean Queries
Combine conditions with AND, OR, and NOT:
curl -X POST http://localhost:8080/v1/search \
-H 'Content-Type: application/json' \
-d '{"query": "rust AND web", "limit": 10}'
Expected result: only doc002 is returned (contains both “rust” and “web”).
Field Boosting
Boost the title field to prioritize title matches:
curl -X POST http://localhost:8080/v1/search \
-H 'Content-Type: application/json' \
-d '{
"query": "rust",
"limit": 10,
"field_boosts": {"title": 2.0}
}'
Vector Search
Search by vector similarity. Provide a query vector in query_vectors and specify which field to search:
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
}'
This finds documents whose embedding vectors are closest to the query vector. Expected result: doc001 ranks highest (most similar vector).
Hybrid Search
Combine lexical search and vector search for best results. The fusion parameter controls how scores from both searches are merged:
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
}'
This uses Reciprocal Rank Fusion (RRF) to merge lexical and vector search results. You can also use weighted sum fusion:
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: Retrieve a Document
Fetch a specific document by its ID:
curl http://localhost:8080/v1/documents/doc001
Expected response (includes the stored vector field):
{
"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: Update a Document
Update a document by PUT-ing with the same ID. This replaces the entire document:
curl -X PUT http://localhost:8080/v1/documents/doc001 \
-H 'Content-Type: application/json' \
-d '{
"document": {
"fields": {
"title": "Introduction to Rust Programming",
"body": "Rust is a modern systems programming language that focuses on safety, speed, and concurrency. It provides memory safety without garbage collection.",
"category": "programming",
"embedding": [0.9, 0.1, 0.2, 0.0]
}
}
}'
Commit and verify:
curl -X POST http://localhost:8080/v1/commit
curl http://localhost:8080/v1/documents/doc001
The updated body text is now stored.
Step 8: Delete a Document
Delete a document by its ID:
curl -X DELETE http://localhost:8080/v1/documents/doc003
Commit and verify:
curl -X POST http://localhost:8080/v1/commit
Confirm the document was deleted:
curl http://localhost:8080/v1/documents/doc003
Expected response:
{"documents":[]}
Search results will no longer include the deleted document:
curl -X POST http://localhost:8080/v1/search \
-H 'Content-Type: application/json' \
-d '{"query": "python", "limit": 10}'
Expected result: no results returned.
Step 9: Check Index Statistics
View the current index statistics:
curl http://localhost:8080/v1/index
The document_count should reflect the remaining documents after the deletion.
Step 10: Clean Up
Stop the server with Ctrl+C. The server performs a graceful shutdown, committing any pending changes before exiting.
To remove the tutorial data:
rm -rf /tmp/laurus/tutorial
Going Further: Using a Real Embedding Model
The tutorial above uses precomputed vectors for simplicity. In production, you typically use an embedding model to automatically convert text into vectors. Here is how to set up a BERT-based embedder.
Prerequisites
Build laurus with the embeddings-candle feature:
cargo build --release --features embeddings-candle
Schema with BERT Embedder
Create an index:
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"]
}
}'
The model is automatically downloaded from HuggingFace Hub on first use. The dimension (384) must match the model’s output dimension.
Add documents. Pass text to the embedding field — the embedder automatically converts it to a vector:
curl -X PUT http://localhost:8080/v1/documents/doc001 \
-H 'Content-Type: application/json' \
-d '{
"document": {
"fields": {
"title": "Introduction to Rust Programming",
"body": "Rust is a modern systems programming language.",
"embedding": "Rust is a modern systems programming language."
}
}
}'
curl -X PUT http://localhost:8080/v1/documents/doc002 \
-H 'Content-Type: application/json' \
-d '{
"document": {
"fields": {
"title": "Web Development with Rust",
"body": "Building web applications with Rust using Actix and Rocket.",
"embedding": "Building web applications with Rust using Actix and Rocket."
}
}
}'
Commit:
curl -X POST http://localhost:8080/v1/commit
Search with both lexical and semantic queries. The embedder also handles text-to-vector conversion at search time:
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
}'
With the precomputed embedder you must pass raw vectors, but text-capable embedders like candle_bert accept text directly for both indexing and searching.
Using OpenAI Embeddings
For OpenAI’s embedding API, set the OPENAI_API_KEY environment variable and build with the embeddings-openai feature:
cargo build --release --features embeddings-openai
export OPENAI_API_KEY="sk-..."
Create an index:
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"]
}
}'
The text-embedding-3-small model outputs 1536-dimensional vectors.
Available Embedding Models
| Type | Feature Flag | Example Model | Dimension |
|---|---|---|---|
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 |
Next Steps
- Learn about vector search and hybrid search for semantic similarity queries
- Explore the gRPC API Reference for the full API specification
- Configure the server for production using Configuration
- Use
grpcurlor a gRPC client library for programmatic access — see Getting Started
Configuration
The laurus-server can be configured through CLI arguments, environment variables, and a TOML configuration file.
Configuration Priority
Server and index settings are resolved in the following order (highest priority first):
CLI arguments > Environment variables > Config file > Defaults
Log verbosity is controlled exclusively by the RUST_LOG environment variable (default: info).
For example:
# CLI argument wins over environment variable and config file
LAURUS_PORT=4567 laurus serve --config config.toml --port 1234
# -> Listens on port 1234
# Environment variable wins over config file
LAURUS_PORT=4567 laurus serve --config config.toml
# -> Listens on port 4567
# Config file value is used when no CLI argument or env var is set
laurus serve --config config.toml
# -> Uses port from config.toml (or default 50051 if not set)
TOML Configuration File
Format
[server]
host = "0.0.0.0"
port = 50051
http_port = 8080 # Optional: enables HTTP Gateway
[index]
data_dir = "./laurus_data"
Log verbosity is controlled by the RUST_LOG environment variable (default: info), not through the config file.
Field Reference
[server] Section
| Field | Type | Default | Description |
|---|---|---|---|
host | String | "0.0.0.0" | Listen address for the gRPC server |
port | Integer | 50051 | Listen port for the gRPC server |
http_port | Integer | – | HTTP Gateway port. When set, the HTTP/JSON gateway starts alongside gRPC. |
[index] Section
| Field | Type | Default | Description |
|---|---|---|---|
data_dir | String | "./laurus_data" | Path to the index data directory |
Environment Variables
| Variable | Maps To | Description |
|---|---|---|
LAURUS_HOST | server.host | Listen address |
LAURUS_PORT | server.port | gRPC listen port |
LAURUS_HTTP_PORT | server.http_port | HTTP Gateway port |
LAURUS_INDEX_DIR | index.data_dir | Index data directory |
RUST_LOG | – | Log filter directive (e.g. info, debug, laurus=debug,tonic=warn) |
LAURUS_CONFIG | – | Path to TOML config file |
CLI Arguments
| Option | Short | Default | Description |
|---|---|---|---|
--config <PATH> | -c | – | Path to TOML configuration file |
--host <HOST> | -H | 0.0.0.0 | Listen address |
--port <PORT> | -p | 50051 | gRPC listen port |
--http-port <PORT> | – | – | HTTP Gateway port |
--index-dir <PATH> | – | ./laurus_index | Index data directory (global option) |
Common Configurations
Development (gRPC only)
[server]
host = "127.0.0.1"
port = 50051
[index]
data_dir = "./dev_data"
RUST_LOG=debug laurus serve --config config.toml
Production (gRPC + HTTP Gateway)
[server]
host = "0.0.0.0"
port = 50051
http_port = 8080
[index]
data_dir = "/var/lib/laurus/data"
Minimal (environment variables only)
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 Reference
All services are defined under the laurus.v1 protobuf package.
Services Overview
| Service | RPCs | Description |
|---|---|---|
HealthService | Check | Health checking |
IndexService | CreateIndex, GetIndex, GetSchema, AddField, DeleteField | Index lifecycle and schema |
DocumentService | PutDocument, AddDocument, GetDocuments, DeleteDocuments, Commit | Document CRUD and commit |
SearchService | Search, SearchStream | Unary and streaming search |
HealthService
Check
Returns the current serving status of the server.
rpc Check(HealthCheckRequest) returns (HealthCheckResponse);
Response fields:
| Field | Type | Description |
|---|---|---|
status | ServingStatus | SERVING_STATUS_SERVING when the server is ready |
IndexService
CreateIndex
Create a new index with the given schema. Fails with ALREADY_EXISTS if an index is already open.
rpc CreateIndex(CreateIndexRequest) returns (CreateIndexResponse);
Request fields:
| Field | Type | Required | Description |
|---|---|---|---|
schema | Schema | Yes | Index schema definition |
Schema structure:
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— Field definitions keyed by field name.default_fields— Field names used as default search targets when a query does not specify a field.analyzers— Custom analyzer pipelines keyed by name. Referenced byTextOption.analyzer.embedders— Embedder configurations keyed by name. Referenced by vector field options (HnswOption.embedder, etc.).dynamic_field_policy— How the engine treats fields that appear in an ingested document but are absent fromfields.UNSPECIFIEDis interpreted asDYNAMICfor forward compatibility. See Schema & Fields for the full behaviour matrix and the warning about silent truncation underDYNAMIC.
AnalyzerDefinition:
message AnalyzerDefinition {
repeated ComponentConfig char_filters = 1;
ComponentConfig tokenizer = 2;
repeated ComponentConfig token_filters = 3;
}
ComponentConfig (used for char filters, tokenizer, and token filters):
| Field | Type | Description |
|---|---|---|
type | string | Component type name (e.g. "whitespace", "lowercase", "unicode_normalization") |
params | map<string, string> | Type-specific parameters as string key-value pairs |
EmbedderConfig:
| Field | Type | Description |
|---|---|---|
type | string | Embedder type name (e.g. "precomputed", "candle_bert", "openai") |
params | map<string, string> | Type-specific parameters (e.g. "model" → "sentence-transformers/all-MiniLM-L6-v2") |
Each FieldOption is a oneof with one of the following field types:
| Lexical Fields | Vector Fields |
|---|---|
TextOption (indexed, stored, term_vectors, analyzer) | HnswOption (dimension, distance, m, ef_construction, base_weight, quantizer, embedder) |
IntegerOption (indexed, stored, multi_valued) | FlatOption (dimension, distance, base_weight, quantizer, embedder) |
FloatOption (indexed, stored, multi_valued) | IvfOption (dimension, distance, n_clusters, n_probe, base_weight, quantizer, embedder) |
BooleanOption (indexed, stored) | |
DateTimeOption (indexed, stored) | |
GeoOption (indexed, stored) | |
Geo3dOption (indexed, stored) | |
BytesOption (stored) |
The embedder field in vector options specifies the name of an embedder defined in Schema.embedders. When set, the server automatically generates vectors from document text fields at index time. Leave empty to supply pre-computed vectors directly.
Distance metrics: COSINE, EUCLIDEAN, MANHATTAN, DOT_PRODUCT, ANGULAR
Quantization methods: SCALAR_8BIT (default), PRODUCT_QUANTIZATION (reserved for Issue #481 Stage 3, currently Unimplemented).
NONE (no quantization) was removed in Issue #481 Stage 1. The proto enum value 0 (QUANTIZATION_METHOD_NONE) is kept as a wire-compat reservation; if the server receives it, it falls back to SCALAR_8BIT via Default::default().
QuantizationConfig structure:
| Field | Type | Description |
|---|---|---|
method | QuantizationMethod | Quantization method (QUANTIZATION_METHOD_SCALAR_8BIT or QUANTIZATION_METHOD_PRODUCT_QUANTIZATION). The reserved value 0 (NONE) is silently coerced to SCALAR_8BIT. |
subvector_count | uint32 | Number of subvectors (only used when method is PRODUCT_QUANTIZATION; must evenly divide dimension) |
Example:
{
"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
Get index statistics.
rpc GetIndex(GetIndexRequest) returns (GetIndexResponse);
Response fields:
| Field | Type | Description |
|---|---|---|
document_count | uint64 | Total number of documents in the index |
vector_fields | map<string, VectorFieldStats> | Per-field vector statistics |
Each VectorFieldStats contains vector_count and dimension.
AddField
Add a new field to the running index at runtime.
rpc AddField(AddFieldRequest) returns (AddFieldResponse);
Request fields:
| Field | Type | Description |
|---|---|---|
name | string | The field name |
field_option | FieldOption | The field configuration |
Response fields:
| Field | Type | Description |
|---|---|---|
schema | Schema | The updated schema after the field has been added |
HTTP gateway: POST /v1/schema/fields
DeleteField
Remove a field from the running index schema.
rpc DeleteField(DeleteFieldRequest) returns (DeleteFieldResponse);
Request fields:
| Field | Type | Description |
|---|---|---|
name | string | The field name to remove |
Response fields:
| Field | Type | Description |
|---|---|---|
schema | Schema | The updated schema after removal |
Existing indexed data for the field remains in storage but becomes inaccessible. Per-field analyzers and embedders are unregistered.
HTTP gateway: DELETE /v1/schema/fields/{name}
GetSchema
Retrieve the current index schema.
rpc GetSchema(GetSchemaRequest) returns (GetSchemaResponse);
Response fields:
| Field | Type | Description |
|---|---|---|
schema | Schema | The index schema |
DocumentService
PutDocument
Insert or replace a document by ID. If a document with the same ID already exists, it is replaced.
rpc PutDocument(PutDocumentRequest) returns (PutDocumentResponse);
Request fields:
| Field | Type | Required | Description |
|---|---|---|---|
id | string | Yes | External document ID |
document | Document | Yes | Document content |
Document structure:
message Document {
map<string, Value> fields = 1;
}
Each Value is a oneof with these types:
| Type | Proto Field | Description |
|---|---|---|
| Null | null_value | Null value |
| Boolean | bool_value | Boolean value |
| Integer | int64_value | 64-bit signed integer |
| Float | float64_value | 64-bit floating point |
| Text | text_value | UTF-8 string |
| Bytes | bytes_value | Raw bytes |
| Vector | vector_value | VectorValue (list of floats) |
| DateTime | datetime_value | Unix microseconds (UTC) |
| Geo | geo_value | GeoPoint (latitude, longitude) |
| Int64Array | int64_array_value | Int64ArrayValue (multi-valued integers; requires IntegerOption.multi_valued = true) |
| Float64Array | float64_array_value | Float64ArrayValue (multi-valued floats; requires FloatOption.multi_valued = true) |
| Geo3d | geo3d_value | Geo3dPoint (x, y, z meters; ECEF Cartesian) |
Geo3dPoint:
| Field | Type | Description |
|---|---|---|
x | double | X coordinate in meters (ECEF: equatorial plane, +X toward 0° longitude) |
y | double | Y coordinate in meters (ECEF: equatorial plane, +Y toward 90°E) |
z | double | Z coordinate in meters (ECEF: +Z toward the North Pole) |
See 3D Geographic Search (ECEF) for the full coordinate system description and the wgs84_to_ecef / ecef_to_wgs84 conversion utilities.
AddDocument
Add a document. Unlike PutDocument, this does not replace existing documents with the same ID — multiple documents can share an ID (chunking pattern).
rpc AddDocument(AddDocumentRequest) returns (AddDocumentResponse);
Request fields are the same as PutDocument.
GetDocuments
Retrieve all documents matching the given external ID.
rpc GetDocuments(GetDocumentsRequest) returns (GetDocumentsResponse);
Request fields:
| Field | Type | Required | Description |
|---|---|---|---|
id | string | Yes | External document ID |
Response fields:
| Field | Type | Description |
|---|---|---|
documents | repeated Document | Matching documents |
DeleteDocuments
Delete all documents matching the given external ID.
rpc DeleteDocuments(DeleteDocumentsRequest) returns (DeleteDocumentsResponse);
Commit
Commit pending changes (additions and deletions) to the index. Changes are not visible to search until committed.
rpc Commit(CommitRequest) returns (CommitResponse);
SearchService
Search
Execute a search query and return results as a single response.
rpc Search(SearchRequest) returns (SearchResponse);
Response fields:
| Field | Type | Description |
|---|---|---|
results | repeated SearchResult | Search results ordered by relevance |
total_hits | uint64 | Total number of matching documents (before limit/offset) |
SearchStream
Execute a search query and stream results back one at a time.
rpc SearchStream(SearchRequest) returns (stream SearchResult);
SearchRequest Fields
| Field | Type | Required | Description |
|---|---|---|---|
query | string | No | Lexical search query in Query DSL |
query_vectors | repeated QueryVector | No | Vector search queries |
limit | uint32 | No | Maximum number of results (default: engine default) |
offset | uint32 | No | Number of results to skip |
fusion | FusionAlgorithm | No | Fusion algorithm for hybrid search |
lexical_params | LexicalParams | No | Lexical search parameters |
vector_params | VectorParams | No | Vector search parameters |
field_boosts | map<string, float> | No | Per-field score boosting |
At least one of query or query_vectors must be provided.
3D Geographic Queries
3D ECEF geographic queries are expressed in the lexical DSL string passed via SearchRequest.query. There is no dedicated message type — the same DSL forms used by the core library work over gRPC. Three forms are available (see Query DSL → 3D Geographic Queries for full syntax):
position:geo3d_distance(x, y, z, distance_m)— sphere centered at(x, y, z)with maximum distance in metersposition:geo3d_bbox(min_x, min_y, min_z, max_x, max_y, max_z)— 3D axis-aligned bounding boxposition:geo3d_nearest(x, y, z, k)— k nearest neighbours to(x, y, z)
position is the field name; substitute the actual Geo3d-typed field declared in your schema. All numeric arguments are signed double values; k is an unsigned integer.
QueryVector
| Field | Type | Description |
|---|---|---|
vector | repeated float | Query vector |
weight | float | Weight for this vector (default: 1.0) |
fields | repeated string | Target vector fields (empty = all) |
FusionAlgorithm
A oneof with two options:
- RRF (Reciprocal Rank Fusion):
kparameter (default: 60) - WeightedSum:
lexical_weightandvector_weight
LexicalParams
| Field | Type | Description |
|---|---|---|
min_score | float | Minimum score threshold |
timeout_ms | uint64 | Search timeout in milliseconds |
parallel | bool | Enable parallel search |
sort_by | SortSpec | Sort by a field instead of score |
SortSpec
| Field | Type | Description |
|---|---|---|
field | string | Field name to sort by. Empty string means sort by relevance score |
order | SortOrder | SORT_ORDER_ASC (ascending) or SORT_ORDER_DESC (descending) |
VectorParams
| Field | Type | Description |
|---|---|---|
fields | repeated string | Target vector fields |
score_mode | VectorScoreMode | WEIGHTED_SUM, MAX_SIM, or LATE_INTERACTION |
overfetch | float | Overfetch factor (default: 2.0) |
min_score | float | Minimum score threshold |
rerank_factor | optional uint32 | Stage 2 rerank widening factor (Issue #481). When set on a HNSW field whose schema enabled rerank_storage, the server widens the int8 candidate fetch to top_k * rerank_factor and rescores the candidates against the original full-precision vectors before returning the top top_k. Honored only on HNSW fields with rerank_storage = "F32"; other configurations (Stage 1 segments, Flat, IVF) silently fall back to int8 ranking — there is no f32 information to recover. A value of 0 or omitting the field disables rerank. |
SearchResult
| Field | Type | Description |
|---|---|---|
id | string | External document ID |
score | float | Relevance score |
document | Document | Document content |
Example
{
"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
}
}
Error Handling
gRPC errors are returned as standard Status codes:
| Laurus Error | gRPC Status | When |
|---|---|---|
| Schema / Query / Field / JSON | INVALID_ARGUMENT | Malformed request or schema |
| No index open | FAILED_PRECONDITION | RPC called before CreateIndex |
| Index already exists | ALREADY_EXISTS | CreateIndex called twice |
| Not implemented | UNIMPLEMENTED | Feature not yet supported |
| Internal errors | INTERNAL | I/O, storage, or unexpected errors |
HTTP Gateway
The HTTP Gateway provides a RESTful HTTP/JSON interface to the Laurus search engine. It runs alongside the gRPC server and proxies requests internally:
Client (HTTP/JSON) --> HTTP Gateway (axum) --> gRPC Server (tonic) --> Engine
Enabling the HTTP Gateway
The gateway starts when http_port is configured:
# Via CLI argument
laurus serve --http-port 8080
# Via environment variable
LAURUS_HTTP_PORT=8080 laurus serve
# Via config file
laurus serve --config config.toml
# (set http_port in [server] section)
If http_port is not set, only the gRPC server starts.
Endpoints
| Method | Path | gRPC Method | Description |
|---|---|---|---|
| GET | /v1/health | HealthService/Check | Health check |
| POST | /v1/index | IndexService/CreateIndex | Create a new index |
| GET | /v1/index | IndexService/GetIndex | Get index statistics |
| GET | /v1/schema | IndexService/GetSchema | Get the index schema |
| POST | /v1/schema/fields | IndexService/AddField | Dynamically add a field |
| DELETE | /v1/schema/fields/{name} | IndexService/DeleteField | Remove a field from the schema |
| PUT | /v1/documents/{id} | DocumentService/PutDocument | Upsert a document |
| POST | /v1/documents/{id} | DocumentService/AddDocument | Add a document (chunk) |
| GET | /v1/documents/{id} | DocumentService/GetDocuments | Get documents by ID |
| DELETE | /v1/documents/{id} | DocumentService/DeleteDocuments | Delete documents by ID |
| POST | /v1/commit | DocumentService/Commit | Commit pending changes |
| POST | /v1/search | SearchService/Search | Search (unary) |
| POST | /v1/search/stream | SearchService/SearchStream | Search (Server-Sent Events) |
API Examples
Health Check
curl http://localhost:8080/v1/health
Create an Index
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"]
}
}'
The dynamic_field_policy key is optional. It controls how fields absent
from the schema are handled at ingest time. Accepted values: "strict",
"dynamic" (default), "ignore". See
Schema & Fields for the
full semantics and the warning about silent truncation under "dynamic".
Get Index Statistics
curl http://localhost:8080/v1/index
Get Schema
curl http://localhost:8080/v1/schema
Add a Field (Dynamic Schema)
Adds a new field to the running index. The request body uses the same FieldOption JSON shape as POST /v1/index:
curl -X POST http://localhost:8080/v1/schema/fields \
-H 'Content-Type: application/json' \
-d '{
"name": "category",
"field_option": {"text": {"indexed": true, "stored": true}}
}'
The response returns the updated schema.
Delete a Field
Removes a field from the schema. The field name is supplied in the path:
curl -X DELETE http://localhost:8080/v1/schema/fields/category
Existing indexed data for the field remains in storage but becomes inaccessible. Per-field analyzers and embedders are unregistered.
Upsert a Document (PUT)
Replaces the document if it already exists:
curl -X PUT http://localhost:8080/v1/documents/doc1 \
-H 'Content-Type: application/json' \
-d '{
"document": {
"fields": {
"title": "Hello World",
"body": "This is a test document."
}
}
}'
Add a Document (POST)
Adds a new chunk without replacing existing documents with the same ID:
curl -X POST http://localhost:8080/v1/documents/doc1 \
-H 'Content-Type: application/json' \
-d '{
"document": {
"fields": {
"title": "Hello World",
"body": "This is a test document."
}
}
}'
Get Documents
curl http://localhost:8080/v1/documents/doc1
Delete Documents
curl -X DELETE http://localhost:8080/v1/documents/doc1
Commit
curl -X POST http://localhost:8080/v1/commit
Search
curl -X POST http://localhost:8080/v1/search \
-H 'Content-Type: application/json' \
-d '{"query": "body:test", "limit": 10}'
Search with Field Boosts
curl -X POST http://localhost:8080/v1/search \
-H 'Content-Type: application/json' \
-d '{
"query": "rust programming",
"limit": 10,
"field_boosts": {"title": 2.0}
}'
Hybrid Search
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}}
}'
Streaming Search (SSE)
The /v1/search/stream endpoint returns results as Server-Sent Events (SSE). Each result is sent as a separate event:
curl -N -X POST http://localhost:8080/v1/search/stream \
-H 'Content-Type: application/json' \
-d '{"query": "body:test", "limit": 10}'
The response is a stream of SSE events:
data: {"id":"doc1","score":0.8532,"document":{...}}
data: {"id":"doc2","score":0.4210,"document":{...}}
JSON Field Value Inference
When the gateway accepts a document body (PUT /v1/documents/{id} or
POST /v1/documents/{id}), each value inside document.fields is converted
to the engine’s DataValue type using
the same inference rules as schema-less ingestion. This keeps the HTTP and
gRPC paths in sync.
| JSON value | Resulting field type | Notes |
|---|---|---|
null | (skipped) | The field is emitted as a NullValue and dropped during ingest. |
true / false | boolean | |
integer (fits in i64) | integer | |
| float / large integer | float | |
"text" | text | |
[1, 2, 3] (all integers) | integer with multi_valued: true | Multi-valued numeric field. |
[1.0, 2.5] (any non-integer number) | float with multi_valued: true | |
[] (empty array) | (skipped) | Element type cannot be determined, so the field is skipped. |
{"latitude": ..., "longitude": ...} | geo | |
{"lat": ..., "lon": ...} / {"lat": ..., "lng": ...} | geo | Short aliases for latitude / longitude are accepted. |
{"x": ..., "y": ..., "z": ...} | geo3d | All three keys required, finite numbers, ECEF meters. Mixing with lat/lon keys is rejected. |
The gateway returns an HTTP 400 (Bad Request) when:
- An array contains mixed types or non-numeric elements
(e.g.
[1, "x"]). - An object is not a valid geographic point (missing latitude / longitude
keys for 2D, or missing any of
x/y/zfor 3D). - A geographic latitude is outside
[-90, 90]or a longitude is outside[-180, 180]. - A 3D ECEF coordinate is non-finite (
NaN/Inf). - An object mixes 2D (
lat/lon) and 3D (x/y/z) keys.
Vector and bytes fields cannot be inferred from JSON alone and must be
declared in the schema. Numeric arrays sent against a declared vector
field are coerced to a vector of f32 values automatically, so REST
clients can post embeddings as plain JSON arrays.
3D Geographic Queries
3D ECEF queries reuse the lexical DSL string passed via query. The gateway forwards it unchanged to the engine, so the same forms work over HTTP as over gRPC:
curl -X POST http://localhost:8080/v1/search \
-H 'Content-Type: application/json' \
-d '{
"query": "position:geo3d_distance(-3955182, 3350553, 3700276, 5000)",
"limit": 10
}'
See Query DSL → 3D Geographic Queries for geo3d_bbox and geo3d_nearest syntax.
Request/Response Format
All request and response bodies use JSON. The JSON structure mirrors the gRPC protobuf messages. See gRPC API Reference for the full message definitions.
MCP Server Overview
The laurus-mcp crate provides a Model Context Protocol (MCP) server for the Laurus search engine. It acts as a gRPC client to a running laurus-server instance, enabling AI assistants such as Claude to index documents and perform searches through the standard MCP stdio transport.
Features
- MCP stdio transport — Runs as a subprocess; communicates with the AI client via stdin/stdout
- gRPC client — Proxies all tool calls to a running
laurus-serverinstance - All laurus search modes — Lexical (BM25), vector (HNSW/Flat/IVF), and hybrid search
- Dynamic connection — Connect to any laurus-server endpoint via the
connecttool - Document lifecycle — Add, update, delete, and retrieve documents through MCP tools
Architecture
graph LR
subgraph "laurus-mcp"
MCP["MCP Server\n(stdio)"]
end
AI["AI Client\n(Claude, etc.)"] -->|"stdio (JSON-RPC)"| MCP
MCP -->|"gRPC"| SRV["laurus-server\n(always running)"]
SRV --> Disk["Index on Disk"]
The MCP server runs as a child process launched by the AI client. It proxies all
tool calls to a laurus-server instance via gRPC. The laurus-server must be
started separately before (or after) the MCP server.
Quick Start
# Step 1: Start the laurus-server
laurus serve --port 50051
# Step 2: Configure Claude Code and start the MCP server
claude mcp add laurus -- laurus mcp --endpoint http://localhost:50051
Or with a manual configuration:
{
"mcpServers": {
"laurus": {
"command": "laurus",
"args": ["mcp", "--endpoint", "http://localhost:50051"]
}
}
}
Sections
- Getting Started — Installation, configuration, and first steps
- Tools Reference — Full reference for all MCP tools
Getting Started with laurus-mcp
Prerequisites
- The
laurusCLI binary installed (cargo install laurus-cli) - A running
laurus-serverinstance (see laurus-server getting started) - An AI client that supports MCP (Claude Desktop, Claude Code, etc.)
Configuration
Step 1: Start laurus-server
laurus serve --port 50051
Step 2: Configure the MCP client
Claude Code
Use the CLI command (recommended):
claude mcp add laurus -- laurus mcp --endpoint http://localhost:50051
Or edit ~/.claude/settings.json directly:
{
"mcpServers": {
"laurus": {
"command": "laurus",
"args": ["mcp", "--endpoint", "http://localhost:50051"]
}
}
}
Claude Desktop
Edit the configuration file for your platform:
- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Windows:
%APPDATA%\Claude\claude_desktop_config.json - Linux:
~/.config/Claude/claude_desktop_config.json
{
"mcpServers": {
"laurus": {
"command": "laurus",
"args": ["mcp", "--endpoint", "http://localhost:50051"]
}
}
}
Usage Workflows
Workflow 1: Pre-created index
Create the index using the CLI first, then use the MCP server to query it:
# Step 1: Create a schema file
cat > schema.toml << 'EOF'
[fields.title]
Text = { indexed = true, stored = true }
[fields.body]
Text = { indexed = true, stored = true }
EOF
# Step 2: Start the server and create the index
laurus serve --port 50051 &
laurus create index --schema schema.toml
# Step 3: Register the MCP server with Claude Code
claude mcp add laurus -- laurus mcp --endpoint http://localhost:50051
Workflow 2: AI-driven index creation
Start laurus-server first, then register the MCP server and let the AI create the index:
# Step 1: Start laurus-server (no index required)
laurus serve --port 50051
# Step 2: Register the MCP server with Claude Code
claude mcp add laurus -- laurus mcp --endpoint http://localhost:50051
Then ask Claude:
“Create a search index for blog posts. I need to search by title and body text, and I want to store the author and publication date.”
Claude will design the schema and call create_index automatically.
Workflow 3: Connect at runtime
Register the MCP server without specifying an endpoint:
claude mcp add laurus -- laurus mcp
Or edit the settings file directly:
{
"mcpServers": {
"laurus": {
"command": "laurus",
"args": ["mcp"]
}
}
}
Then ask Claude to connect:
“Connect to the laurus server at
http://localhost:50051”
Claude will call connect(endpoint: "http://localhost:50051") before using other tools.
Environment Variables
The --endpoint flag can be omitted and supplied through LAURUS_ENDPOINT instead — useful when the endpoint is fixed per machine and you do not want to hard-code it into every MCP client config:
export LAURUS_ENDPOINT=http://localhost:50051
claude mcp add laurus -- laurus mcp
Or inside the client config:
{
"mcpServers": {
"laurus": {
"command": "laurus",
"args": ["mcp"],
"env": {
"LAURUS_ENDPOINT": "http://localhost:50051"
}
}
}
}
When both are present, the explicit --endpoint flag wins (standard clap behaviour for #[arg(long, env = "...")]).
Removing the MCP Server
To remove the registered MCP server from Claude Code:
claude mcp remove laurus
For Claude Desktop, remove the laurus entry from the configuration file and restart the application.
Lifecycle
laurus-server starts (separate process)
└─ listens on gRPC port 50051
Claude starts
└─ spawns: laurus mcp --endpoint `http://localhost:50051`
└─ enters stdio event loop
├─ receives tool calls via stdin
├─ proxies calls to laurus-server via gRPC
└─ sends results via stdout
Claude exits
└─ laurus-mcp process terminates
└─ laurus-server continues running
MCP Tools Reference
The laurus MCP server exposes the following tools.
connect
Connect to a running laurus-server gRPC endpoint. Call this before using other
tools if the server was started without the --endpoint flag, or to switch to
a different laurus-server at runtime.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
endpoint | string | Yes | gRPC endpoint URL (e.g. http://localhost:50051) |
Example
Tool: connect
endpoint: "http://localhost:50051"
Result: Connected to laurus-server at http://localhost:50051.
create_index
Create a new search index with the provided schema.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
schema_json | string | Yes | Schema definition as a JSON string |
Schema JSON format
FieldOption uses serde’s externally-tagged representation where the variant name is the key:
{
"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 } }
}
}
The optional dynamic_field_policy key controls how fields that appear in
ingested documents but are absent from the schema are handled. Accepted
values: "Strict", "Dynamic" (default), "Ignore". Warning: under
"Dynamic", integer fields silently truncate incoming float values
(3.14 → 3); use "Strict" to reject such mismatches. See
Schema & Fields for the
full behaviour matrix.
Example
Tool: create_index
schema_json: {"fields": {"title": {"Text": {}}, "body": {"Text": {}}}}
Result: Index created successfully at /path/to/index.
A schema with a Geo3d field for 3D ECEF positions:
{
"fields": {
"title": { "Text": { "indexed": true, "stored": true } },
"position": { "Geo3d": { "indexed": true, "stored": true } }
}
}
See 3D Geographic Search (ECEF) for the coordinate system; Geo3d is queryable via the geo3d_distance / geo3d_bbox / geo3d_nearest DSL forms (see the search tool below).
add_field
Add a new field to the index.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
name | string | Yes | The field name |
field_option_json | string | Yes | Field configuration as JSON |
Example
{
"name": "category",
"field_option_json": "{\"Text\": {\"indexed\": true, \"stored\": true}}"
}
Result: Field 'category' added successfully.
delete_field
Remove a field from the index schema. Existing indexed data remains in storage but becomes inaccessible. Per-field analyzers and embedders are unregistered.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
name | string | Yes | The name of the field to remove |
Example
{
"name": "category"
}
Result: Field 'category' deleted successfully.
get_stats
Get statistics for the current search index, including document count and vector field information.
Parameters
None.
Result
{
"document_count": 42,
"vector_fields": {
"embedding": {
"vector_count": 42,
"dimension": 384
}
}
}
vector_fields is a map keyed by field name; each entry reports the number of indexed vectors and the field’s configured dimension.
get_schema
Get the current index schema, including all field definitions and their configurations.
Parameters
None.
Result
{
"fields": {
"title": { "Text": { "indexed": true, "stored": true } },
"body": { "Text": {} },
"embedding": { "Hnsw": { "dimension": 384 } }
},
"default_fields": ["title", "body"]
}
put_document
Put (upsert) a document into the index. If a document with the same ID already exists, all its chunks are deleted before the new document is indexed. Call commit after adding documents.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string | Yes | External document identifier |
document | object | Yes | Document fields as a JSON object |
Example
Tool: put_document
id: "doc-1"
document: {"title": "Hello World", "body": "This is a test document."}
Result: Document 'doc-1' put (upserted). Call commit to persist changes.
Example with a Geo3d value:
Tool: put_document
id: "drone-1"
document: {"title": "Drone over Tokyo", "position": {"x": -3955182.0, "y": 3350553.0, "z": 3700276.0}}
The MCP server accepts a 3D ECEF point as a JSON object with x, y, z keys (meters). This is distinct from the HTTP gateway, which currently does not infer Geo3d from JSON — the MCP path is fully supported for both writes and reads.
add_document
Add a document as a new chunk to the index. Unlike put_document, this appends without deleting existing documents with the same ID. Useful for splitting large documents into chunks. Call commit after adding documents.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string | Yes | External document identifier |
document | object | Yes | Document fields as a JSON object |
Example
Tool: add_document
id: "doc-1"
document: {"title": "Hello World - Part 2", "body": "This is a continuation."}
Result: Document 'doc-1' added as chunk. Call commit to persist changes.
get_documents
Retrieve all stored documents (including chunks) by external ID.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string | Yes | External document identifier |
Result
{
"id": "doc-1",
"documents": [
{ "title": "Hello World", "body": "This is a test document." }
]
}
delete_documents
Delete all documents and chunks sharing the given external ID from the index. Call commit after deletion.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string | Yes | External document identifier |
Result: Documents 'doc-1' deleted. Call commit to persist changes.
commit
Commit pending changes to disk. Must be called after put_document, add_document, or delete_documents to make changes searchable and durable.
Parameters
None.
Result: Changes committed successfully.
search
Search documents using the laurus unified query DSL. Supports lexical search, vector search, and hybrid search in a single query string.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
query | string | Yes | Search query in laurus unified query DSL |
limit | integer | No | Maximum results (default: 10) |
offset | integer | No | Results to skip for pagination (default: 0) |
fusion | string | No | Fusion algorithm as JSON (for hybrid search) |
field_boosts | string | No | Per-field boost factors as JSON |
Query DSL examples
Lexical search
| Query | Description |
|---|---|
hello | Term search across default fields |
title:hello | Field-scoped term search |
title:hello AND body:world | Boolean AND |
"exact phrase" | Phrase search |
roam~2 | Fuzzy search (edit distance 2) |
count:[1 TO 10] | Range search |
title:helo~1 | Fuzzy field search |
3D geographic search
| Query | Description |
|---|---|
position:geo3d_distance(x, y, z, distance_m) | Sphere with center (x, y, z) and maximum distance in meters |
position:geo3d_bbox(min_x, min_y, min_z, max_x, max_y, max_z) | 3D axis-aligned bounding box |
position:geo3d_nearest(x, y, z, k) | k nearest neighbors to (x, y, z) |
position is the field name; substitute the actual Geo3d-typed field declared in your schema. See Query DSL → 3D Geographic Queries for the full DSL syntax.
Vector search
| Query | Description |
|---|---|
content:"cute kitten" | Vector search on a field (field must be a vector field in schema) |
content:python | Vector search with unquoted text |
content:"cute kitten"^0.8 | Vector search with weight/boost |
a:"cats" b:"dogs"^0.5 | Multiple vector queries |
Hybrid search
| Query | Description |
|---|---|
title:hello content:"cute kitten" | Lexical + vector (OR/union — results from either) |
title:hello +content:"cute kitten" | Lexical + vector (AND/intersection — only results in both) |
+title:hello +content:"cute kitten" | Both required (AND); + on lexical field = required clause |
title:hello AND body:world content:"cats"^0.8 | Boolean lexical + weighted vector |
Fusion algorithm examples
{"rrf": {"k": 60.0}}
{"weighted_sum": {"lexical_weight": 0.7, "vector_weight": 0.3}}
Field boosts example
{"title": 2.0, "body": 1.0}
Result
{
"total": 2,
"results": [
{
"id": "doc-1",
"score": 3.14,
"document": { "title": "Hello World", "body": "..." }
},
{
"id": "doc-2",
"score": 1.57,
"document": { "title": "Hello Again", "body": "..." }
}
]
}
search_batch
Execute multiple independent searches in a single round trip. All queries
run in parallel on the server; the same limit and offset apply to every
query. Useful for agents issuing several sub-queries per turn.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
queries | array of string | Yes | Query strings, each in the laurus unified query DSL (same syntax as search) |
limit | integer | No | Maximum results per query (default: 10) |
offset | integer | No | Results to skip per query for pagination (default: 0) |
Result
The batch array preserves input order: batch[i] is the result set for
queries[i].
{
"batch": [
{
"total": 1,
"results": [
{ "id": "doc-1", "score": 3.14, "document": { "title": "Hello World" } }
]
},
{
"total": 1,
"results": [
{ "id": "doc-2", "score": 2.71, "document": { "title": "Vector Search" } }
]
}
]
}
Typical Workflow
1. connect → connect to a running laurus-server
2. create_index → define the schema (if index does not exist)
3. add_field → dynamically add fields (optional)
delete_field → remove fields (optional)
4. put_document → upsert documents (repeat as needed)
add_document → append document chunks (optional)
5. commit → persist changes to disk
6. search → query the index
7. get_documents → retrieve documents by ID
8. delete_documents → remove documents
9. commit → persist changes
Python Binding Overview
The laurus-python package provides Python bindings for the Laurus search engine. It is built as a native Rust extension using PyO3 and Maturin, giving Python programs direct access to Laurus’s lexical, vector, and hybrid search capabilities with near-native performance.
Features
- Lexical Search – Full-text search powered by an inverted index with BM25 scoring
- Vector Search – Approximate nearest neighbor (ANN) search using Flat, HNSW, or IVF indexes
- Hybrid Search – Combine lexical and vector results with fusion algorithms (RRF, WeightedSum)
- Rich Query DSL – Term, Phrase, Fuzzy, Wildcard, NumericRange, Geo, Boolean, Span queries
- Text Analysis – Tokenizers, filters, stemmers, and synonym expansion
- Flexible Storage – In-memory (ephemeral) or file-based (persistent) indexes
- Pythonic API – Clean, intuitive Python classes with full type information
Architecture
graph LR
subgraph "laurus-python"
PyIndex["Index\n(Python class)"]
PyQuery["Query classes"]
PySearch["SearchRequest\n/ SearchResult"]
end
Python["Python application"] -->|"method calls"| PyIndex
Python -->|"query objects"| PyQuery
PyIndex -->|"PyO3 FFI"| Engine["laurus::Engine\n(Rust)"]
PyQuery -->|"PyO3 FFI"| Engine
Engine --> Storage["Storage\n(Memory / File)"]
The Python classes are thin wrappers around the Rust engine. Each call crosses the PyO3 FFI boundary once; the Rust engine then executes the operation entirely in native code.
Although the Rust engine uses async I/O internally, all Python
methods are exposed as synchronous functions. This is because
Python’s GIL (Global Interpreter Lock) prevents true concurrent
execution within a single interpreter, making an async API
cumbersome (it would require asyncio.run() everywhere).
Instead, each method calls tokio::Runtime::block_on() under
the hood to bridge async Rust to synchronous Python.
Note: The Node.js binding (
laurus-nodejs) exposes the same Rust engine methods as nativeasync/PromiseAPIs, since Node.js’s event loop supports async natively.
Quick Start
import laurus
# Create an in-memory index
index = laurus.Index()
# Index documents
index.put_document("doc1", {"title": "Introduction to Rust", "body": "Systems programming language."})
index.put_document("doc2", {"title": "Python for Data Science", "body": "Data analysis with Python."})
index.commit()
# Search
results = index.search("title:rust", limit=5)
for r in results:
print(f"[{r.id}] score={r.score:.4f} {r.document['title']}")
Sections
- Installation – How to install the package
- Quick Start – Hands-on introduction with examples
- API Reference – Complete class and method reference
- Development – Building from source, testing, and project layout
Installation
From PyPI
pip install laurus
From source
Building from source requires a Rust toolchain (1.85 or later, matching workspace.package.rust-version in the root Cargo.toml) and Maturin.
# Install Maturin
pip install maturin
# Clone the repository
git clone https://github.com/mosuka/laurus.git
cd laurus/laurus-python
# Build and install in development mode
maturin develop
# Or build a release wheel
maturin build --release
pip install target/wheels/laurus-*.whl
Verify
import laurus
index = laurus.Index()
print(index) # Index()
Requirements
- Python 3.8 or later
- No runtime dependencies beyond the compiled native extension
Quick Start
1. Create an index
import laurus
# In-memory index (ephemeral, useful for prototyping)
index = laurus.Index()
# File-based index (persistent)
schema = laurus.Schema()
schema.add_text_field("title")
schema.add_text_field("body")
index = laurus.Index(path="./myindex", schema=schema)
2. Index documents
index.put_document("doc1", {
"title": "Introduction to Rust",
"body": "Rust is a systems programming language focused on safety and performance.",
})
index.put_document("doc2", {
"title": "Python for Data Science",
"body": "Python is widely used for data analysis and machine learning.",
})
index.commit()
3. Lexical search
# DSL string
results = index.search("title:rust", limit=5)
# Query object
results = index.search(laurus.TermQuery("body", "python"), limit=5)
# Print results
for r in results:
print(f"[{r.id}] score={r.score:.4f} {r.document['title']}")
4. Vector search
Vector search requires a schema with a vector field and pre-computed embeddings.
import laurus
import numpy as np
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. Hybrid search
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. Update and delete
# Update: put_document replaces all existing versions
index.put_document("doc1", {"title": "Updated Title", "body": "New content."})
index.commit()
# Append a new version without removing existing ones (RAG chunking pattern)
index.add_document("doc1", {"title": "Chunk 2", "body": "Additional chunk."})
index.commit()
# Retrieve all versions
docs = index.get_documents("doc1")
# Delete
index.delete_documents("doc1")
index.commit()
7. Schema management
schema = laurus.Schema()
schema.add_text_field("title")
schema.add_text_field("body")
schema.add_integer_field("year")
schema.add_float_field("score")
schema.add_boolean_field("published")
schema.add_bytes_field("thumbnail")
schema.add_geo_field("location")
schema.add_datetime_field("created_at")
schema.add_hnsw_field("embedding", dimension=384)
schema.add_flat_field("small_vec", dimension=64)
schema.add_ivf_field("ivf_vec", dimension=128, n_clusters=100)
8. Index statistics
stats = index.stats()
print(stats["document_count"])
print(stats["vector_fields"])
API Reference
Index
The primary entry point. Wraps the Laurus search engine.
class Index:
def __init__(self, path: str | None = None, schema: Schema | None = None) -> None: ...
Constructor
| Parameter | Type | Default | Description |
|---|---|---|---|
path | str | None | None | Directory path for persistent storage. None creates an in-memory index. |
schema | Schema | None | None | Schema definition. An empty schema is used when omitted. |
Methods
| Method | Description |
|---|---|
put_document(id, doc) | Upsert a document. Replaces all existing versions with the same ID. |
add_document(id, doc) | Append a document chunk without removing existing versions. |
get_documents(id) -> list[dict] | Return all stored versions for the given ID. |
delete_documents(id) | Delete all versions for the given ID. |
commit() | Flush buffered writes and make all pending changes searchable. |
search(query, *, limit=10, offset=0) -> list[SearchResult] | Execute a search query. |
search_batch(queries, *, limit=10, offset=0) -> list[list[SearchResult]] | Execute multiple independent searches in one call. Each query is dispatched in parallel on the underlying tokio runtime. results[i] corresponds to queries[i]. Empty input returns []. |
stats() -> dict | Return index statistics (document_count, vector_fields). |
search query argument
The query parameter accepts any of the following:
- A DSL string (e.g.
"title:hello","embedding:\"memory safety\"") - A lexical query object (
TermQuery,PhraseQuery,BooleanQuery, …) - A vector query object (
VectorQuery,VectorTextQuery) - A
SearchRequestfor full control
The same value kinds are accepted as the elements of search_batch’s queries list — DSL strings, query objects, and SearchRequest instances may be mixed within a single batch.
Schema
Defines the fields and index types for an Index.
class Schema:
def __init__(self) -> None: ...
Field methods
| Method | Description |
|---|---|
add_text_field(name, *, stored=True, indexed=True, term_vectors=False, analyzer=None) | Full-text field (inverted index, BM25). analyzer accepts a built-in name ("standard", "english", "keyword", "simple", "noop", or any custom name registered via add_analyzer) or a dict configuring a parameterised preset such as {"language": "japanese", "mode": "normal", "dict": "/var/lib/lindera/ipadic"}. The bare string "japanese" is rejected because the preset requires a Lindera dictionary path. |
add_integer_field(name, *, stored=True, indexed=True, multi_valued=False) | 64-bit integer field. Set multi_valued=True to accept arrays of integers (range queries match if any value satisfies the predicate). |
add_float_field(name, *, stored=True, indexed=True, multi_valued=False) | 64-bit float field. Set multi_valued=True to accept arrays of floats (range queries match if any value satisfies the predicate). |
add_boolean_field(name, *, stored=True, indexed=True) | Boolean field. |
add_bytes_field(name, *, stored=True) | Raw bytes field. |
add_geo_field(name, *, stored=True, indexed=True) | Geographic coordinate field (lat/lon). |
add_geo3d_field(name, *, stored=True, indexed=True) | 3D ECEF Cartesian point field (x, y, z in metres). See Geo3d concepts. |
add_datetime_field(name, *, stored=True, indexed=True) | UTC datetime field. |
add_hnsw_field(name, dimension, *, distance="cosine", m=16, ef_construction=200, embedder=None) | HNSW approximate nearest-neighbor vector field. |
add_flat_field(name, dimension, *, distance="cosine", embedder=None) | Flat (brute-force) vector field. |
add_ivf_field(name, dimension, *, distance="cosine", n_clusters=100, n_probe=1, embedder=None) | IVF approximate nearest-neighbor vector field. |
Other methods
| Method | Description |
|---|---|
add_embedder(name, config) | Register a named embedder definition. config is a dict with a "type" key (see below). |
set_default_fields(fields) | Set default search fields (list of strings). |
set_dynamic_field_policy(policy) | Set how undeclared fields are handled. policy is "strict", "dynamic" (default), or "ignore". See notes below. |
dynamic_field_policy() | Return the current policy as a lowercase string. |
field_names() | Return all field names. |
Dynamic field policy
Controls what happens when a document is ingested with field names that are not declared in the schema:
"strict"— Reject the document."dynamic"(default) — Infer a type for each undeclared field and add it to the schema. Warning: integer fields silently truncate incoming float values (3.14→3). Use"strict"if you need to reject such type mismatches."ignore"— Silently drop the undeclared fields.
See Schema & Fields for the full behaviour matrix.
Embedder types
"type" | Required keys | Feature flag |
|---|---|---|
"precomputed" | – | (always available) |
"candle_bert" | "model" | embeddings-candle |
"candle_clip" | "model" | embeddings-multimodal |
"openai" | "model" | embeddings-openai |
Distance metrics
| Value | Description |
|---|---|
"cosine" | Cosine similarity (default) |
"euclidean" | Euclidean distance |
"dot_product" | Dot product |
"manhattan" | Manhattan distance |
"angular" | Angular distance |
Query classes
TermQuery
TermQuery(field: str, term: str)
Matches documents containing the exact term in the given field.
PhraseQuery
PhraseQuery(field: str, terms: list[str])
Matches documents containing the terms in order.
FuzzyQuery
FuzzyQuery(field: str, term: str, *, max_edits: int = 2)
Approximate match allowing up to max_edits edit-distance errors. max_edits is keyword-only.
WildcardQuery
WildcardQuery(field: str, pattern: str)
Pattern match. * matches any sequence of characters, ? matches any single character.
NumericRangeQuery
NumericRangeQuery(field: str, *, min: int | float | None = None, max: int | float | None = None)
Matches numeric values in the range [min, max]. Pass None (or omit) for
an open bound. min and max are keyword-only. The numeric type (integer or
float) is inferred from the Python type of min/max.
GeoDistanceQuery
GeoDistanceQuery.within_radius(
field: str, lat: float, lon: float, distance_m: float,
)
Geo-distance (radius) search. Returns documents whose (lat, lon) coordinate
is within distance_m metres of the given point.
GeoBoundingBoxQuery
GeoBoundingBoxQuery.within_bounding_box(
field: str,
min_lat: float, min_lon: float,
max_lat: float, max_lon: float,
)
Geo bounding-box search. Returns documents whose (lat, lon) coordinate lies
inside the axis-aligned [min_lat, max_lat] × [min_lon, max_lon] rectangle.
Geo3dDistanceQuery
Geo3dDistanceQuery.within_sphere(
field: str, x: float, y: float, z: float, distance_m: float,
)
Sphere search over a 3D ECEF point field. Returns documents whose (x, y, z)
coordinate is within distance_m metres of the centre. See
Geo3d concepts for ECEF theory.
Geo3dBoundingBoxQuery
Geo3dBoundingBoxQuery.within_box(
field: str,
min_x: float, min_y: float, min_z: float,
max_x: float, max_y: float, max_z: float,
)
Axis-aligned 3D bounding-box search. Returns documents whose ECEF point lies
inside [min_x, max_x] × [min_y, max_y] × [min_z, max_z].
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,
)
k-nearest-neighbour search over a 3D ECEF point field. Returns the k
documents closest to (x, y, z). The optional initial_radius_m and
max_radius_m tune the iterative-expansion search cone.
BooleanQuery
bq = BooleanQuery()
bq.must(query)
bq.should(query)
bq.must_not(query)
Compound boolean query. Construct with no arguments and add clauses one at a
time via the must / should / must_not methods. Each method accepts any
query object (including a nested BooleanQuery).
must clauses all have to match; must_not clauses must not match.
should clauses contribute to scoring; at least one of them must match if
there are no must clauses.
SpanQuery
# Single term
SpanQuery.term(field: str, term: str)
# Near: terms appearing within `slop` positions of each other
SpanQuery.near(field: str, terms: list[str], *, slop: int = 0, ordered: bool = True)
# Near with nested SpanQuery clauses
SpanQuery.near_spans(field: str, clauses: list[SpanQuery], *, slop: int = 0, ordered: bool = True)
# Containing: big span contains little span
SpanQuery.containing(field: str, big: SpanQuery, little: SpanQuery)
# Within: include span within exclude span at max distance
SpanQuery.within(field: str, include: SpanQuery, exclude: SpanQuery, distance: int)
Positional / proximity span queries. Construct via the static factory
methods. near takes a list of term strings, while near_spans takes a
list of SpanQuery objects for nested expressions. slop and ordered
are keyword-only.
VectorQuery
VectorQuery(field: str, vector: list[float])
Approximate nearest-neighbor search using a pre-computed embedding vector.
VectorTextQuery
VectorTextQuery(field: str, text: str)
Converts text to an embedding at query time and runs vector search. Requires an embedder configured on the index.
SearchRequest
Full-featured search request for advanced control.
class SearchRequest:
def __init__(
self,
*,
query=None,
lexical_query=None,
vector_query=None,
filter_query=None,
fusion=None,
limit: int = 10,
offset: int = 0,
) -> None: ...
| Parameter | Description |
|---|---|
query | A DSL string or single query object. Mutually exclusive with lexical_query / vector_query. |
lexical_query | Lexical component for explicit hybrid search. |
vector_query | Vector component for explicit hybrid search. |
filter_query | Lexical filter applied after scoring. |
fusion | Fusion algorithm (RRF or WeightedSum). Defaults to RRF(k=60) when both components are set. |
limit | Maximum number of results (default 10). |
offset | Pagination offset (default 0). |
SearchResult
Returned by Index.search().
class SearchResult:
id: str # External document identifier
score: float # Relevance score
document: dict | None # Retrieved field values, or None if not stored
Fusion algorithms
RRF
RRF(k: float = 60.0)
Reciprocal Rank Fusion. Merges lexical and vector result lists by rank position. k is a smoothing constant; higher values reduce the influence of top-ranked results.
WeightedSum
WeightedSum(lexical_weight: float = 0.5, vector_weight: float = 0.5)
Normalises both score lists independently, then combines them as lexical_weight * lexical_score + vector_weight * vector_score.
Text analysis
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
Field value types
Python values are automatically converted to Laurus DataValue types:
| Python type | Laurus type | Notes |
|---|---|---|
None | Null | |
bool | Bool | Checked before int |
int | Int64 | |
float | Float64 | |
str | Text | |
bytes | Bytes | |
list[float] | Vector | Elements coerced to f32 |
(lat, lon) tuple | Geo | Two float values |
(x, y, z) tuple | Geo3d | Three float values (ECEF Cartesian, metres) |
datetime.datetime | DateTime | Converted via isoformat() |
Development Setup
This page covers how to set up a local development environment for the
laurus-python binding, build it, and run the test suite.
Prerequisites
- Rust 1.85 or later with Cargo
- Python 3.8 or later
- Repository cloned locally
git clone https://github.com/mosuka/laurus.git
cd laurus
Python virtual environment
All Python tooling (Maturin, pytest, …) is managed inside a dedicated virtual
environment located at laurus-python/.venv.
# Create the venv and install maturin + pytest
make venv
This is equivalent to:
python3 -m venv laurus-python/.venv
laurus-python/.venv/bin/pip install maturin pytest
Note: You do not need to activate the venv manually. All
maketargets invoke the venv binaries directly.
Build
Development build (editable install)
Compiles the Rust extension and installs it into the venv in one step. Re-run after any Rust source change.
cd laurus-python
VIRTUAL_ENV=$(pwd)/.venv .venv/bin/maturin develop
Or use the Makefile shortcut that also builds a distributable wheel:
make build-laurus-python
This produces a release wheel under target/wheels/:
target/wheels/laurus-0.x.y-cp312-cp312-manylinux_2_34_x86_64.whl
Verify the build
# With the venv activated, or using its Python directly:
laurus-python/.venv/bin/python -c "import laurus; print(laurus.Index())"
# Index()
Testing
make test-laurus-python runs two test suites in order:
- Rust unit tests via
cargo test -p laurus-python - Python integration tests via
pytest(after a freshmaturin develop)
make test-laurus-python
To run only the Python tests (skipping the Rust step):
cd laurus-python
VIRTUAL_ENV=$(pwd)/.venv .venv/bin/maturin develop --quiet
.venv/bin/pytest tests/ -v
To run a single test by name:
.venv/bin/pytest tests/ -v -k test_vector_query
Linting and formatting
# Rust lint (Clippy)
make lint-laurus-python
# Rust formatting
make format-laurus-python
Cleaning up
# Remove the venv only
make venv-clean
# Remove everything (venv + all Cargo build artifacts)
make clean
Makefile reference
| Target | Description |
|---|---|
make venv | Create .venv and install maturin + pytest |
make venv-clean | Remove .venv |
make build-laurus-python | Build a release wheel via maturin build |
make test-laurus-python | Rust unit tests + Python pytest |
make lint-laurus-python | Clippy with -D warnings |
make format-laurus-python | cargo fmt -p laurus-python |
make clean | Remove venv and all Cargo build artifacts |
Project layout
laurus-python/
├── Cargo.toml # Rust crate manifest
├── pyproject.toml # Python package metadata (Maturin / PEP 517)
├── README.md # English README
├── README_ja.md # Japanese README
├── src/ # Rust source (PyO3 binding)
│ ├── lib.rs # Module registration
│ ├── index.rs # Index class
│ ├── schema.rs # Schema class
│ ├── query.rs # Query classes
│ ├── search.rs # SearchRequest / SearchResult / Fusion
│ ├── analysis.rs # Tokenizer / Filter / Token
│ ├── convert.rs # Python ↔ DataValue conversion
│ └── errors.rs # Error mapping
├── tests/ # Python pytest integration tests
│ └── test_index.py
└── examples/ # Runnable Python examples
├── quickstart.py
├── lexical_search.py
├── vector_search.py
├── hybrid_search.py
├── synonym_graph_filter.py
├── search_with_openai.py
└── multimodal_search.py
Node.js Binding Overview
The laurus-nodejs package provides Node.js/TypeScript bindings
for the Laurus search engine. It is built as a native addon using
napi-rs, giving Node.js programs direct access
to Laurus’s lexical, vector, and hybrid search capabilities with
near-native performance.
Features
- Lexical Search – Full-text search powered by an inverted index with BM25 scoring
- Vector Search – Approximate nearest neighbor (ANN) search using Flat, HNSW, or IVF indexes
- Hybrid Search – Combine lexical and vector results with fusion algorithms (RRF, WeightedSum)
- Rich Query DSL – Term, Phrase, Fuzzy, Wildcard, NumericRange, Geo, Boolean, Span queries
- Text Analysis – Tokenizers, filters, stemmers, and synonym expansion
- Flexible Storage – In-memory (ephemeral) or file-based (persistent) indexes
- TypeScript Types – Auto-generated
.d.tstype definitions - Async API – All I/O operations return Promises
Architecture
graph LR
subgraph "laurus-nodejs"
JsIndex["Index\n(JS class)"]
JsQuery["Query classes"]
JsSearch["SearchRequest\n/ SearchResult"]
end
Node["Node.js application"] -->|"method calls"| JsIndex
Node -->|"query objects"| JsQuery
JsIndex -->|"napi-rs FFI"| Engine["laurus::Engine\n(Rust)"]
JsQuery -->|"napi-rs FFI"| Engine
Engine --> Storage["Storage\n(Memory / File)"]
The JavaScript classes are thin wrappers around the Rust engine. Each call crosses the napi-rs FFI boundary once; the Rust engine then executes the operation entirely in native code.
All I/O methods (search, commit, putDocument, etc.) are
async and return Promises. They run on napi-rs’s built-in
tokio runtime and return results to the Node.js event loop
without blocking it. Schema construction, query creation, and
stats() are synchronous since they involve no I/O.
Note: The Python binding (
laurus-python) exposes the same Rust engine methods as synchronous functions because Python’s GIL (Global Interpreter Lock) makes an async API cumbersome. Node.js has no such constraint, so the async Rust engine is exposed directly as Promises.
Quick Start
import { Index, Schema } from "laurus-nodejs";
// Create an in-memory index
const schema = new Schema();
schema.addTextField("name");
schema.addTextField("description");
schema.setDefaultFields(["name", "description"]);
const index = await Index.create(null, schema);
// Index documents
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();
// Search
const results = await index.search("framework", 5);
for (const r of results) {
console.log(`[${r.id}] score=${r.score.toFixed(4)} ${r.document.name}`);
}
Sections
- Installation – How to install the package
- Quick Start – Hands-on introduction with examples
- API Reference – Complete class and method reference
- Development – Building from source, testing, and project layout
Installation
From npm
npm install laurus-nodejs
From source
Building from source requires a Rust toolchain (1.85 or later) and Node.js 24.15 or later.
# Clone the repository
git clone https://github.com/mosuka/laurus.git
cd laurus/laurus-nodejs
# Install dependencies
npm install
# Build the native module (release)
npm run build
# Or build in debug mode (faster builds)
npm run build:debug
Verify
import { Index } from "laurus-nodejs";
const index = await Index.create();
console.log(index.stats());
// { documentCount: 0, vectorFields: {} }
Requirements
- Node.js 24.15 or later (matches the
engines.noderequirement inpackage.json) - No runtime dependencies beyond the compiled native addon
Quick Start
1. Create an index
import { Index, Schema } from "laurus-nodejs";
// In-memory index (ephemeral, useful for prototyping)
const index = await Index.create();
// File-based index (persistent)
const schema = new Schema();
schema.addTextField("name");
schema.addTextField("description");
const persistentIndex = await Index.create("./myindex", schema);
2. Index documents
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 search
// DSL string
const results = await index.search("name:express", 5);
// Term query
const results2 = await index.searchTerm(
"description", "framework", 5,
);
// Print results
for (const r of results) {
console.log(`[${r.id}] score=${r.score.toFixed(4)} ${r.document.name}`);
}
4. Vector search
Vector search requires a schema with a vector field and pre-computed embeddings.
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. Hybrid search
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. Update and delete
// Update: putDocument replaces all existing versions
await index.putDocument("express", {
name: "Express v5",
description: "Updated content.",
});
await index.commit();
// Append a new version (RAG chunking pattern)
await index.addDocument("express", {
name: "Express chunk 2",
description: "Additional chunk.",
});
await index.commit();
// Retrieve all versions
const docs = await index.getDocuments("express");
// Delete
await index.deleteDocuments("express");
await index.commit();
7. Schema management
const schema = new Schema();
schema.addTextField("name");
schema.addTextField("description");
schema.addIntegerField("stars");
schema.addFloatField("score");
schema.addBooleanField("published");
schema.addBytesField("thumbnail");
schema.addGeoField("location");
schema.addDatetimeField("createdAt");
schema.addHnswField("embedding", 384);
schema.addFlatField("smallVec", 64);
schema.addIvfField("ivfVec", 128, "cosine", 100, 1);
8. Index statistics
const stats = index.stats();
console.log(stats.documentCount);
console.log(stats.vectorFields);
API Reference
Index
The primary entry point. Wraps the Laurus search engine.
class Index {
static create(
path?: string | null,
schema?: Schema,
): Promise<Index>;
}
Factory method
| Parameter | Type | Default | Description |
|---|---|---|---|
path | string | null | null | Directory for persistent storage. null creates an in-memory index. |
schema | Schema | empty | Schema definition. |
Methods
| Method | Description |
|---|---|
putDocument(id, doc) | Upsert a document. Replaces all existing versions. |
addDocument(id, doc) | Append a document chunk without removing existing versions. |
getDocuments(id) | Return all stored versions for the given ID. |
deleteDocuments(id) | Delete all versions for the given ID. |
commit() | Flush writes and make pending changes searchable. |
search(query, limit?, offset?) | Search with a DSL string. |
searchTerm(field, term, limit?, offset?) | Search with an exact term match. |
searchVector(field, vector, limit?, offset?) | Search with a pre-computed vector. |
searchVectorText(field, text, limit?, offset?) | Search with text (auto-embedded). |
searchWithRequest(request) | Search with a SearchRequest. |
searchBatch(queries, limit?, offset?) | Execute multiple DSL string queries in parallel. results[i] corresponds to queries[i]. Returns Promise<Array<Array<JsSearchResult>>>. Empty input returns []. |
stats() | Return index statistics (documentCount, vectorFields). |
All document methods and search methods are async
and return Promises. stats() is synchronous.
stats() returns an object shaped like:
interface IndexStats {
documentCount: number;
vectorFields: Record<string, { count: number; dimension: number }>;
}
Schema
Defines the fields and index types for an Index.
class Schema {
constructor();
}
Field methods
| Method | Description |
|---|---|
addTextField(name, stored?, indexed?, termVectors?, analyzer?) | Full-text field (inverted index, BM25). analyzer is the name of a parameter-less built-in ("standard", "english", "keyword", "simple", "noop") or any custom name registered via addAnalyzer. For the parameterised Japanese preset (which requires a Lindera dictionary path), register a custom analyzer with a lindera tokenizer and reference it by name. |
addIntegerField(name, stored?, indexed?, multiValued?) | 64-bit integer field. Pass multiValued: true to accept arrays of integers (range queries match if any value satisfies the predicate). |
addFloatField(name, stored?, indexed?, multiValued?) | 64-bit float field. Pass multiValued: true to accept arrays of floats (range queries match if any value satisfies the predicate). |
addBooleanField(name, stored?, indexed?) | Boolean field. |
addBytesField(name, stored?) | Raw bytes field. |
addGeoField(name, stored?, indexed?) | Geographic coordinate field. |
addGeo3dField(name, stored?, indexed?) | 3D ECEF Cartesian point field (x, y, z in metres). See Geo3d concepts. |
addDatetimeField(name, stored?, indexed?) | UTC datetime field. |
addHnswField(name, dimension, distance?, m?, efConstruction?, embedder?) | HNSW vector field. |
addFlatField(name, dimension, distance?, embedder?) | Flat (brute-force) vector field. |
addIvfField(name, dimension, distance?, nClusters?, nProbe?, embedder?) | IVF vector field. |
addEmbedder(name, config) | Register a named embedder. |
setDefaultFields(fields) | Set default search fields. |
setDynamicFieldPolicy(policy) | Set how undeclared fields are handled. policy is "strict", "dynamic" (default), or "ignore". See notes below. |
dynamicFieldPolicy() | Return the current policy as a lowercase string. |
fieldNames() | Return all field names. |
toString() | Return a string representation of the schema ("Schema(fields=[...])"). |
Dynamic field policy
Controls what happens when a document is ingested with field names that are not declared in the schema:
"strict"— Reject the document."dynamic"(default) — Infer a type for each undeclared field and add it to the schema. Warning: integer fields silently truncate incoming float values (3.14→3). Use"strict"if you need to reject such type mismatches."ignore"— Silently drop the undeclared fields.
See Schema & Fields for the full behaviour matrix.
Distance metrics
| Value | Description |
|---|---|
"cosine" | Cosine similarity (default) |
"euclidean" | Euclidean distance |
"dot_product" | Dot product |
"manhattan" | Manhattan distance |
"angular" | Angular distance |
Query classes
TermQuery
new TermQuery(field: string, term: string)
Matches documents containing the exact term in the given field.
PhraseQuery
new PhraseQuery(field: string, terms: string[])
Matches documents containing the terms in order.
FuzzyQuery
new FuzzyQuery(field: string, term: string, maxEdits?: number)
Approximate match allowing up to maxEdits edit-distance
errors (default 2).
WildcardQuery
new WildcardQuery(field: string, pattern: string)
Pattern match. * matches any sequence, ? matches one
character.
NumericRangeQuery
new NumericRangeQuery(
field: string,
min?: number | null,
max?: number | null,
numericType?: "integer" | "float",
)
Matches numeric values in [min, max]. Pass null (or omit) for an
open bound. numericType selects the underlying range type
("integer" (default) or "float"); other values throw.
GeoDistanceQuery
GeoDistanceQuery.withinRadius(
field: string, lat: number, lon: number, distanceM: number,
): GeoDistanceQuery
Geographic distance (radius) search.
GeoBoundingBoxQuery
GeoBoundingBoxQuery.withinBoundingBox(
field: string,
minLat: number, minLon: number,
maxLat: number, maxLon: number,
): GeoBoundingBoxQuery
Geographic bounding-box search.
Geo3dDistanceQuery
Geo3dDistanceQuery.withinSphere(
field: string,
x: number, y: number, z: number,
distanceM: number,
): Geo3dDistanceQuery
Sphere search over a 3D ECEF point field. Returns documents whose (x, y, z)
coordinate is within distanceM metres of the centre. See
Geo3d concepts for ECEF theory.
Geo3dBoundingBoxQuery
Geo3dBoundingBoxQuery.withinBox(
field: string,
minX: number, minY: number, minZ: number,
maxX: number, maxY: number, maxZ: number,
): Geo3dBoundingBoxQuery
Axis-aligned 3D bounding-box search.
Geo3dNearestQuery
Geo3dNearestQuery.kNearest(
field: string,
x: number, y: number, z: number,
k: number,
initialRadiusM?: number,
maxRadiusM?: number,
): Geo3dNearestQuery
k-nearest-neighbour search over a 3D ECEF point field. The optional
initialRadiusM and maxRadiusM parameters tune the iterative-expansion
search cone.
BooleanQuery
class BooleanQuery {
constructor();
// For each query type X in
// { Term, Phrase, Fuzzy, Wildcard, NumericRange,
// GeoDistance, GeoBoundingBox,
// Geo3dDistance, Geo3dBoundingBox, Geo3dNearest,
// Boolean, Span }:
mustX(query: X): void;
shouldX(query: X): void;
mustNotX(query: X): void;
}
Compound boolean query with MUST / SHOULD / MUST_NOT clauses. Each clause
takes an instance of a specific query class — for example,
mustTerm(new TermQuery("body", "rust")) or
shouldGeo3dNearest(Geo3dNearestQuery.kNearest(...)).
The Node.js binding exposes 36 per-type methods (12 query types × 3
polarities) instead of a single polymorphic must(query) because of a
limitation in napi-derive’s validation of Either<&T, ...> arguments
for classes with js_name overrides.
must clauses all have to match; mustNot clauses must not match.
should clauses contribute to scoring; at least one of them must match if
there are no must clauses.
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
Positional/proximity span queries.
VectorQuery
new VectorQuery(field: string, vector: number[])
Nearest-neighbor search using a pre-computed embedding vector.
VectorTextQuery
new VectorTextQuery(field: string, text: string)
Converts text to an embedding at query time. Requires an
embedder configured on the index.
SearchRequest
Full-featured search request for advanced control.
interface SearchRequestOptions {
queryDsl?: string;
limit?: number; // default 10
offset?: number; // default 0
}
class SearchRequest {
constructor(options?: SearchRequestOptions);
}
Construct with primitive options first; attach polymorphic clauses with
the per-type setters below. As with BooleanQuery, the binding exposes
per-type setters because of napi-derive’s limitation on Either<&T, ...>
arguments.
DSL and fusion setters
| Method | Description |
|---|---|
setQueryDsl(dsl: string) | Set a DSL string query. |
setRrfFusion(rrf: RRF) | Use RRF fusion. |
setWeightedSumFusion(ws: WeightedSum) | Use weighted-sum fusion. |
Vector setters
| Method | Description |
|---|---|
setVectorQuery(query: VectorQuery) | Set a pre-computed vector query. |
setVectorTextQuery(query: VectorTextQuery) | Set a text-based vector query (auto-embedded by the configured embedder). |
Lexical setters (per type)
For each query type X in { Term, Phrase, Fuzzy, Wildcard, NumericRange, GeoDistance, GeoBoundingBox, Geo3dDistance, Geo3dBoundingBox, Geo3dNearest, Boolean, Span }, the request exposes:
| Method | Description |
|---|---|
setLexicalX(query: X) | Set the lexical component for an explicit hybrid request. |
setFilterX(query: X) | Set the post-scoring filter component. |
That is, 24 per-type setters in total (12 lexical + 12 filter), in addition to the DSL, vector, and fusion setters above.
const req = new SearchRequest({ limit: 5 });
req.setLexicalTerm(new TermQuery("title", "rust"));
req.setVectorQuery(new VectorQuery("embedding", [0.1, 0.2, 0.3, 0.4]));
req.setRrfFusion(new RRF(60.0));
const results = await index.searchWithRequest(req);
SearchResult
Returned by search methods as an array.
interface SearchResult {
id: string; // External document identifier
score: number; // Relevance score
document: object | null; // Retrieved fields, or null if not stored
}
Fusion algorithms
RRF
new RRF(k?: number) // default 60.0
Reciprocal Rank Fusion. Merges lexical and vector result lists by rank position.
WeightedSum
new WeightedSum(
lexicalWeight?: number, // default 0.5
vectorWeight?: number, // default 0.5
)
Normalises both score lists independently, then combines them.
Text analysis
SynonymDictionary
class SynonymDictionary {
constructor();
addSynonymGroup(terms: string[]): void;
}
WhitespaceTokenizer
class WhitespaceTokenizer {
constructor();
tokenize(text: string): Token[];
}
SynonymGraphFilter
class SynonymGraphFilter {
constructor(
dictionary: SynonymDictionary,
keepOriginal?: boolean, // default true
boost?: number, // default 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;
}
Field value types
JavaScript values are automatically converted to Laurus
DataValue types:
| JavaScript type | Laurus type | Notes |
|---|---|---|
null | Null | |
boolean | Bool | |
number (integer) | Int64 | |
number (float) | Float64 | |
string | Text | ISO 8601 strings become DateTime |
number[] | Vector | Coerced to f32 |
{ lat, lon } | Geo | Two number values |
{ x, y, z } | GeoEcef | Three number values, meters (3D ECEF Cartesian) |
Development Setup
This page covers how to set up a local development environment
for the laurus-nodejs binding, build it, and run the test
suite.
Prerequisites
- Rust 1.85 or later with Cargo
- Node.js 24.15 or later with npm (matches the
engines.noderequirement inpackage.json) - Repository cloned locally
git clone https://github.com/mosuka/laurus.git
cd laurus
Build
Development build
Compiles the Rust native addon in debug mode. Re-run after any Rust source change.
cd laurus-nodejs
npm install
npm run build:debug
Release build
npm run build
Verify the build
node -e "
const { Index } = require('./index.js');
Index.create().then(idx => console.log(idx.stats()));
"
// { documentCount: 0, vectorFields: {} }
Testing
Tests use Vitest and are located in
__tests__/.
# Run all tests
npm test
To run a specific test by name:
npx vitest run -t "searches with DSL string"
Linting and formatting
# Rust lint (Clippy)
cargo clippy -p laurus-nodejs -- -D warnings
# Rust formatting
cargo fmt -p laurus-nodejs --check
# Apply formatting
cargo fmt -p laurus-nodejs
Cleaning up
# Remove build artifacts
rm -f *.node index.js index.d.ts
# Remove node_modules
rm -rf node_modules
Project layout
laurus-nodejs/
├── Cargo.toml # Rust crate manifest
├── build.rs # napi-build setup
├── package.json # npm package metadata
├── README.md # English README
├── README_ja.md # Japanese README
├── src/ # Rust source (napi-rs binding)
│ ├── lib.rs # Module registration
│ ├── index.rs # Index class
│ ├── schema.rs # Schema class
│ ├── query.rs # Query classes
│ ├── search.rs # SearchRequest / SearchResult / Fusion
│ ├── analysis.rs # Tokenizer / Filter / Token
│ ├── convert.rs # JS ↔ DataValue conversion
│ └── errors.rs # Error mapping
├── __tests__/ # Vitest integration tests
│ └── index.spec.mjs
└── examples/ # Runnable Node.js examples
├── quickstart.mjs
├── lexical-search.mjs
├── vector-search.mjs
└── hybrid-search.mjs
WASM Binding Overview
The laurus-wasm package provides WebAssembly bindings for the
Laurus search engine. It enables lexical, vector, and hybrid search
directly in browsers and edge runtimes (Cloudflare Workers,
Vercel Edge Functions, Deno Deploy) without a server.
Features
- Lexical Search – Full-text search powered by an inverted index with BM25 scoring
- Vector Search – Approximate nearest neighbor (ANN) search using Flat, HNSW, or IVF indexes
- Hybrid Search – Combine lexical and vector results with fusion algorithms (RRF, WeightedSum)
- Rich Query DSL – Term, Phrase, Fuzzy, Wildcard, NumericRange, Geo, Boolean, Span queries
- Text Analysis – Tokenizers, filters, and synonym expansion
- In-memory Storage – Fast ephemeral indexes
- OPFS Persistence – Indexes survive page reloads via the Origin Private File System
- TypeScript Types – Auto-generated
.d.tstype definitions - Async API – All I/O operations return Promises
Architecture
graph LR
subgraph "laurus-wasm"
WASM[wasm-bindgen API]
end
subgraph "laurus (core)"
Engine
MemoryStorage
end
subgraph "Browser"
JS[JavaScript / TypeScript]
OPFS[Origin Private File System]
end
JS --> WASM
WASM --> Engine
Engine --> MemoryStorage
WASM -.->|persist| OPFS
Embedding Strategies
On native platforms Laurus supports several built-in embedders (Candle BERT,
Candle CLIP, OpenAI API) that the engine can invoke automatically when a
document is indexed or when searchVectorText("field", "query text") is
called. These native embedders cannot run inside wasm32-unknown-unknown and
are therefore disabled in the WASM build:
| 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 |
openai | reqwest (HTTP) | Requires a full async HTTP client (tokio + TLS) |
(They are excluded from the WASM build via the embeddings-candle /
embeddings-openai feature flags, which depend on the native feature that
is disabled for wasm32-unknown-unknown.)
laurus-wasm exposes two addEmbedder types instead:
"precomputed"— The caller supplies vectors directly viaputDocument()andsearchVector(). The engine performs no embedding."callback"— Register a JavaScript callbackembed: (text) => Promise<number[]>and the engine will invoke it during ingestion and fromsearchVectorText(). This enables in-engine auto-embedding using Transformers.js (or any other in-browser embedding library) so callers can use the samesearchVectorText("field", "query text")pattern as on native platforms.
Option A — Precomputed vectors
Compute embeddings on the JavaScript side and pass precomputed vectors
to putDocument() and searchVector():
// Using Transformers.js (all-MiniLM-L6-v2, 384-dim)
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);
}
// Index with precomputed embedding
const vec = await embed("Introduction to Rust");
await index.putDocument("doc1", { title: "Introduction to Rust", embedding: vec });
await index.commit();
// Search with precomputed query embedding
const queryVec = await embed("safe systems programming");
const results = await index.searchVector("embedding", queryVec);
This approach gives you real semantic search in the browser using the same sentence-transformer models available on native platforms, with the embedding computation handled by Transformers.js (ONNX Runtime Web) instead of candle.
Option B — Callback embedder
Register the same Transformers.js pipeline as a "callback" embedder so that
the engine can call it automatically. After registration, ingestion and
searchVectorText() work transparently without the caller managing vectors:
import { pipeline } from '@huggingface/transformers';
const extractor = await pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2');
schema.addEmbedder("transformers", {
type: "callback",
embed: async (text) => {
const output = await extractor(text, { pooling: 'mean', normalize: true });
return Array.from(output.data);
},
});
schema.addHnswField("embedding", 384, "cosine", undefined, undefined, "transformers");
const index = await Index.create(schema);
await index.putDocument("doc1", { title: "Introduction to Rust" });
await index.commit();
const results = await index.searchVectorText("embedding", "safe systems programming");
Compared to Option A, the callback approach lets the engine cache embeddings
during ingestion and avoids duplicating embedding code between writers and
readers. The trade-off is that every commit() waits for the JS callback to
resolve, so heavy bulk ingestion may benefit from precomputing vectors.
When to Use laurus-wasm vs laurus-nodejs
| Criterion | laurus-wasm | laurus-nodejs |
|---|---|---|
| Environment | Browser, Edge Runtime | Node.js server |
| Performance | Good (single-threaded) | Best (native, multi-threaded) |
| Storage | In-memory + OPFS | In-memory + File system |
| Embedding | Precomputed + JS callback | Candle, OpenAI, Precomputed |
| Package | npm install laurus-wasm | npm install laurus-nodejs |
| Binary size | ~5-10 MB (WASM) | Platform-native |
Installation
npm / yarn / pnpm
npm install laurus-wasm
# or
yarn add laurus-wasm
# or
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>
Build from Source
Prerequisites:
git clone https://github.com/mosuka/laurus.git
cd laurus/laurus-wasm
# For use with bundlers (webpack, vite, etc.)
wasm-pack build --target bundler --release
# For direct browser use (<script type="module">)
wasm-pack build --target web --release
The output will be in the pkg/ directory.
Browser Compatibility
laurus-wasm requires a browser that supports:
- WebAssembly (all modern browsers)
- ES Modules
For OPFS persistence, the following browsers are supported:
| Browser | Minimum Version |
|---|---|
| Chrome | 102+ |
| Firefox | 111+ |
| Safari | 15.2+ |
| Edge | 102+ |
Quick Start
Basic Usage (In-memory)
import init, { Index, Schema } from 'laurus-wasm';
// Initialize the WASM module
await init();
// Define a schema
const schema = new Schema();
schema.addTextField("title");
schema.addTextField("body");
schema.setDefaultFields(["title", "body"]);
// Create an in-memory index
const index = await Index.create(schema);
// Add documents
await index.putDocument("doc1", {
title: "Introduction to Rust",
body: "Rust is a systems programming language"
});
await index.putDocument("doc2", {
title: "WebAssembly Guide",
body: "WASM enables near-native performance in the browser"
});
await index.commit();
// Search
const results = await index.search("rust programming");
for (const result of results) {
console.log(`${result.id}: ${result.score}`);
console.log(result.document);
}
Persistent Storage (OPFS)
import init, { Index, Schema } from 'laurus-wasm';
await init();
const schema = new Schema();
schema.addTextField("title");
schema.addTextField("body");
// Open a persistent index (data survives page reloads)
const index = await Index.open("my-index", schema);
// Add documents
await index.putDocument("doc1", {
title: "Hello",
body: "World"
});
// commit() persists to OPFS automatically
await index.commit();
// On next page load, Index.open("my-index") will restore the data
Japanese Morphological Search
Browser WASM cannot use a filesystem path for the Lindera dictionary, so build the analyzer from raw IPADIC bytes loaded from OPFS.
import init, { Index, Schema, JapaneseAnalyzer } from 'laurus-wasm';
import {
downloadDictionary,
loadDictionaryFiles,
hasDictionary,
} from 'laurus-wasm/opfs';
await init();
// 1. Cache the IPADIC archive in OPFS on first visit. The zip must be
// served from the same origin as the page — GitHub Releases assets
// are blocked by CORS. (~16 MB compressed, ~58 MB extracted.)
if (!(await hasDictionary("ipadic"))) {
await downloadDictionary("./dict/lindera-ipadic.zip", "ipadic", {
onProgress: ({ phase, loaded, total }) => console.log(phase, loaded, total),
});
}
// 2. Read the eight component files back and construct the analyzer.
const f = await loadDictionaryFiles("ipadic");
const ja = JapaneseAnalyzer.fromBytes(
f.metadata, f.dictDa, f.dictVals, f.dictWordsIdx,
f.dictWords, f.matrixMtx, f.charDef, f.unk,
"normal",
);
// 3. Register the analyzer on the schema and reference it from text fields.
const schema = new Schema();
schema.addAnalyzer("ja-ipadic", ja);
schema.addTextField("title", undefined, undefined, undefined, "ja-ipadic");
schema.addTextField("body", undefined, undefined, undefined, "ja-ipadic");
schema.setDefaultFields(["title", "body"]);
const index = await Index.create(schema);
await index.putDocument("doc1", {
title: "形態素解析",
body: "Lindera は Rust 製の形態素解析ライブラリです。",
});
await index.commit();
const results = await index.search("形態素");
console.log(results[0].document.title); // "形態素解析"
See the API Reference for the full
JapaneseAnalyzer.fromBytes signature and the OPFS helper API.
Vector Search
import init, { Index, Schema } from 'laurus-wasm';
await init();
const schema = new Schema();
schema.addTextField("title");
schema.addHnswField("embedding", 3); // 3-dimensional vectors
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();
// Search by vector similarity
const results = await index.searchVector("embedding", [0.9, 0.1, 0.0]);
console.log(results[0].document.title); // "Rust"
Usage with Bundlers
Vite
// vite.config.js
import wasm from 'vite-plugin-wasm';
export default {
plugins: [wasm()]
};
Webpack 5
Webpack 5 supports WASM natively with asyncWebAssembly:
// webpack.config.js
module.exports = {
experiments: {
asyncWebAssembly: true
}
};
API Reference
Index
The main entry point for creating and querying search indexes.
Static Methods
Index.create(schema?)
Create a new in-memory (ephemeral) index.
- Parameters:
schema(Schema, optional) – Schema definition.
- Returns:
Promise<Index>
Index.open(name, schema?)
Open or create a persistent index backed by OPFS.
- Parameters:
name(string) – Index name (OPFS subdirectory).schema(Schema, optional) – Schema definition.
- Returns:
Promise<Index>
Instance Methods
putDocument(id, document)
Replace a document (upsert).
- Parameters:
id(string) – Document identifier.document(object) – Key-value pairs matching schema fields.
- Returns:
Promise<void>
addDocument(id, document)
Append a document version (multi-version RAG pattern).
- Parameters / Returns: Same as
putDocument.
getDocuments(id)
Retrieve all versions of a document.
- Parameters:
id(string)
- Returns:
Promise<object[]>
deleteDocuments(id)
Delete all versions of a document.
- Parameters:
id(string)
- Returns:
Promise<void>
commit()
Flush writes and make changes searchable. If opened with
Index.open(), data is also persisted to OPFS.
- Returns:
Promise<void>
search(query, limit?, offset?)
Search using a DSL string query.
- Parameters:
query(string) – Query DSL (e.g."title:hello").limit(number, default 10)offset(number, default 0)
- Returns:
Promise<SearchResult[]>
searchTerm(field, term, limit?, offset?)
Search for an exact term.
- Parameters:
field(string) – Field name.term(string) – Exact term.limit,offset(number, optional)
- Returns:
Promise<SearchResult[]>
searchVector(field, vector, limit?, offset?)
Search by vector similarity.
- Parameters:
field(string) – Vector field name.vector(number[]) – Query embedding.limit,offset(number, optional)
- Returns:
Promise<SearchResult[]>
searchVectorText(field, text, limit?, offset?)
Search by text (embedded by the registered embedder).
- Parameters:
field(string) – Vector field name.text(string) – Text to embed.limit,offset(number, optional)
- Returns:
Promise<SearchResult[]>
searchGeo3dDistance(field, x, y, z, distanceM, limit?, offset?)
Sphere search over a 3D ECEF point field. Returns documents whose (x, y, z)
coordinate is within distanceM metres of the centre. See
Geo3d concepts for ECEF theory.
- Parameters:
field(string) – Geo3d field name.x,y,z(number) – Centre ECEF coordinate (metres).distanceM(number) – Maximum distance from the centre (metres).limit,offset(number, optional)
- Returns:
Promise<SearchResult[]>
searchGeo3dBoundingBox(field, minX, minY, minZ, maxX, maxY, maxZ, limit?, offset?)
Axis-aligned 3D bounding-box search over a 3D ECEF point field.
- Parameters:
field(string) – Geo3d field name.minX,minY,minZ,maxX,maxY,maxZ(number) – Box bounds (metres).limit,offset(number, optional)
- Returns:
Promise<SearchResult[]>
searchGeo3dNearest(field, x, y, z, k, limit?, offset?, initialRadiusM?, maxRadiusM?)
k-nearest-neighbour search over a 3D ECEF point field. Returns the k
documents closest to (x, y, z). The optional initialRadiusM and
maxRadiusM parameters tune the iterative-expansion search cone.
- Parameters:
field(string) – Geo3d field name.x,y,z(number) – Centre ECEF coordinate (metres).k(number) – Number of nearest neighbours to return.limit,offset(number, optional)initialRadiusM,maxRadiusM(number, optional)
- Returns:
Promise<SearchResult[]>
stats()
Return index statistics.
- Returns:
{ documentCount: number, vectorFields: { [name]: { count, dimension } } }
Schema
Builder for defining index fields and embedders.
Constructor
new Schema()
Create an empty schema.
Methods
addTextField(name, stored?, indexed?, termVectors?, analyzer?)
Add a full-text field. analyzer is the name of a parameter-less
built-in ("standard", "english", "keyword", "simple", "noop")
or the name of a runtime analyzer registered via addAnalyzer().
For Japanese morphological analysis, build a JapaneseAnalyzer from
raw IPADIC bytes and register it with addAnalyzer() first; see
JapaneseAnalyzer.fromBytes
and addAnalyzer below.
addIntegerField(name, stored?, indexed?, multiValued?)
Add a 64-bit integer field. Pass multiValued: true to accept arrays of
integers; range queries then match if any value satisfies the predicate
(Lucene-style “any match” with constant scoring).
addFloatField(name, stored?, indexed?, multiValued?)
Add a 64-bit float field. Pass multiValued: true to accept arrays of
floats; range queries then match if any value satisfies the predicate
(Lucene-style “any match” with constant scoring).
addBooleanField(name, stored?, indexed?)
Add a boolean field.
addDatetimeField(name, stored?, indexed?)
Add a date/time field.
addGeoField(name, stored?, indexed?)
Add a geographic coordinate field.
addGeo3dField(name, stored?, indexed?)
Add a 3D ECEF Cartesian point field. Values are submitted as a { x, y, z }
object with metres units. See Geo3d concepts for
ECEF theory.
The WASM binding does not expose Geo3dDistanceQuery / Geo3dBoundingBoxQuery
/ Geo3dNearestQuery as JS classes (wasm-bindgen cannot expose dyn Query
trait objects). Instead, use the Index.searchGeo3dDistance /
Index.searchGeo3dBoundingBox / Index.searchGeo3dNearest methods documented
above.
addBytesField(name, stored?)
Add a binary data field.
addHnswField(name, dimension, distance?, m?, efConstruction?, embedder?)
Add an HNSW vector index field.
distance:"cosine"(default),"euclidean","dot_product","manhattan","angular"m: Branching factor (default 16)efConstruction: Build-time expansion (default 200)
addFlatField(name, dimension, distance?, embedder?)
Add a brute-force vector index field.
addIvfField(name, dimension, distance?, nClusters?, nProbe?, embedder?)
Add an IVF vector index field.
nClusters: Number of partitioning clusters (default 100)nProbe: Number of clusters to probe at query time (default 1)
addAnalyzer(name, analyzer)
Register a pre-built analyzer instance under name. Resolved before the
parameter-less built-in names and before schema.analyzers definitions
when text fields reference an analyzer by name.
Currently only JapaneseAnalyzer instances built via
JapaneseAnalyzer.fromBytes
are accepted here. The runtime registry is the only practical way to use
the Japanese analyzer in browser WASM, where the
{ "language": "japanese", "dict": ... } preset cannot resolve a
filesystem path.
import { JapaneseAnalyzer, Schema } from "laurus-wasm";
import { downloadDictionary, loadDictionaryFiles } from "laurus-wasm/opfs";
await downloadDictionary("./dict/lindera-ipadic.zip", "ipadic");
const f = await loadDictionaryFiles("ipadic");
const ja = JapaneseAnalyzer.fromBytes(
f.metadata, f.dictDa, f.dictVals, f.dictWordsIdx,
f.dictWords, f.matrixMtx, f.charDef, f.unk, "normal",
);
const schema = new Schema();
schema.addAnalyzer("ja-ipadic", ja);
schema.addTextField("body", undefined, undefined, undefined, "ja-ipadic");
addEmbedder(name, config)
Register a named embedder. WASM supports two type values:
"precomputed"— No embedding is performed; vectors are passed directly viaputDocument()/searchVector()."callback"— Provide a JavaScript callbackembed: (text) => Promise<number[]>that the engine will invoke during ingestion andsearchVectorText(). This enables in-engine auto-embedding using Transformers.js or any other in-browser embedding library.
// Precomputed embedder
schema.addEmbedder("precomputed-embedder", { type: "precomputed" });
// Callback embedder (e.g. backed by Transformers.js)
schema.addEmbedder("callback-embedder", {
type: "callback",
embed: async (text) => {
const output = await pipeline(text, { pooling: "mean", normalize: true });
return Array.from(output.data);
},
});
setDefaultFields(fields)
Set the default search fields.
setDynamicFieldPolicy(policy)
Set how the engine treats fields that appear in ingested documents but are
absent from the schema. policy is one of "strict", "dynamic"
(default), or "ignore" (case-insensitive). Throws on an invalid value.
"strict"— Reject the document."dynamic"— Infer a type for each undeclared field and add it to the schema. Warning: integer fields silently truncate incoming float values (3.14→3)."ignore"— Silently drop the undeclared fields.
See Schema & Fields for the full behaviour matrix.
dynamicFieldPolicy()
Returns the current policy as a lowercase string.
fieldNames()
Returns an array of defined field names.
toString()
Returns a string representation of the schema ("Schema(fields=[...])").
SearchResult
interface SearchResult {
id: string;
score: number;
document: object | null;
}
Analysis
JapaneseAnalyzer
Japanese morphological analyzer constructed from raw Lindera dictionary
bytes. Browser WASM has no real filesystem, so the standard
{ "language": "japanese", "dict": "/path/to/ipadic" } preset cannot
be used. Instead, fetch a Lindera dictionary archive (typically
lindera-ipadic-X.Y.Z.zip), store it in OPFS via the
OPFS helpers, and pass the eight component byte
arrays to JapaneseAnalyzer.fromBytes.
JapaneseAnalyzer.fromBytes(metadata, dictDa, ..., mode?)
Static factory that builds an analyzer from raw IPADIC bytes.
Arguments (all Uint8Array except mode):
| Argument | Source file |
|---|---|
metadata | metadata.json |
dictDa | dict.da (Double-Array Trie) |
dictVals | dict.vals |
dictWordsIdx | dict.wordsidx |
dictWords | dict.words |
matrixMtx | matrix.mtx |
charDef | char_def.bin |
unk | unk.bin |
mode | "normal" (default), "search", or "decompose" |
Throws if any component fails to deserialize or the mode string is invalid.
import { JapaneseAnalyzer } from "laurus-wasm";
import { loadDictionaryFiles } from "laurus-wasm/opfs";
const f = await loadDictionaryFiles("ipadic");
const ja = JapaneseAnalyzer.fromBytes(
f.metadata, f.dictDa, f.dictVals, f.dictWordsIdx,
f.dictWords, f.matrixMtx, f.charDef, f.unk,
"normal",
);
The pipeline is NFKC normalization → Japanese iteration mark normalization → Lindera morphological tokenization → lowercase → Japanese stop word filter — identical to the japanese preset on the
native side.
OPFS Helpers
The laurus-wasm/opfs subpath bundles helpers for downloading,
storing, and loading Lindera dictionaries from the browser’s Origin
Private File System. Used together with JapaneseAnalyzer.fromBytes.
import {
downloadDictionary,
loadDictionaryFiles,
hasDictionary,
listDictionaries,
removeDictionary,
} from "laurus-wasm/opfs";
| Function | Description |
|---|---|
downloadDictionary(url, name, options?) | Fetch a .zip, decompress with the Web DecompressionStream API, and store the eight Lindera files under laurus/dictionaries/<name>/ in OPFS. options.onProgress({ phase, loaded?, total? }) reports progress. |
loadDictionaryFiles(name) | Read the eight files back as a { metadata, dictDa, dictVals, dictWordsIdx, dictWords, matrixMtx, charDef, unk } object suitable for JapaneseAnalyzer.fromBytes. |
hasDictionary(name) | true if the dictionary directory exists in OPFS. |
listDictionaries() | Return an array of stored dictionary names. |
removeDictionary(name) | Delete the dictionary directory. |
Browser CORS prevents fetching directly from GitHub Releases, so host
the zip on the same origin as your app (the Laurus demo bundles
./dict/lindera-ipadic.zip alongside the WASM at deploy time).
WhitespaceTokenizer
const tokenizer = new WhitespaceTokenizer();
const tokens = tokenizer.tokenize("hello world");
// [{ text: "hello", position: 0, ... }, { text: "world", position: 1, ... }]
SynonymDictionary
const dict = new SynonymDictionary();
dict.addSynonymGroup(["ml", "machine learning"]);
SynonymGraphFilter
new SynonymGraphFilter(dictionary, keepOriginal = true, boost = 1.0)
dictionary(SynonymDictionary) — Source synonym groups.keepOriginal(boolean, defaulttrue) — Keep the original token alongside the inserted synonyms.boost(number, default1.0) — Score boost applied to inserted synonym tokens.
const filter = new SynonymGraphFilter(dict, true, 0.8);
const expanded = filter.apply(tokens);
Development
Prerequisites
rustup target add wasm32-unknown-unknown
cargo install wasm-pack
Build
cd laurus-wasm
# Debug build (faster compilation)
wasm-pack build --target web --dev
# Release build (optimized)
wasm-pack build --target web --release
# For bundler targets (webpack, vite, etc.)
wasm-pack build --target bundler --release
Project Structure
laurus-wasm/
├── Cargo.toml # Rust dependencies (wasm-bindgen, laurus core)
├── package.json # npm package metadata
├── src/
│ ├── lib.rs # Module declarations
│ ├── index.rs # Index class (CRUD + search)
│ ├── schema.rs # Schema builder
│ ├── search.rs # SearchRequest / SearchResult
│ ├── query.rs # Query type definitions
│ ├── convert.rs # JsValue ↔ Document conversion
│ ├── analysis.rs # Tokenizer / Filter wrappers
│ ├── errors.rs # LaurusError → JsValue conversion
│ └── storage.rs # OPFS persistence layer
└── js/
└── opfs_bridge.js # JS glue for Origin Private File System
Architecture Notes
Storage Strategy
laurus-wasm uses a two-layer storage approach:
-
MemoryStorage (runtime) – All read/write operations go through Laurus’s in-memory storage, which satisfies the
Storagetrait’sSend + Syncrequirement. -
OPFS (persistence) – On
commit(), the entire MemoryStorage state is serialized to OPFS files. OnIndex.open(), OPFS files are loaded back into MemoryStorage.
This avoids the Send + Sync incompatibility of JS handles
while keeping the core engine unchanged.
Feature Flags
The laurus core uses feature flags to support WASM:
# laurus-wasm depends on laurus without default features
laurus = { workspace = true, default-features = false }
This excludes native-only dependencies (tokio/full, rayon,
memmap2, etc.) and uses #[cfg(target_arch = "wasm32")]
fallbacks for parallelism.
Japanese Morphological Analysis
Browser WASM has no filesystem, so the standard { "language": "japanese", "dict": "/path/to/ipadic" } analyzer preset cannot be used. laurus-wasm exposes JapaneseAnalyzer.fromBytes(...) (defined in src/analysis.rs) so that a Lindera IPADIC dictionary archive can be fetched into OPFS at runtime, read back as the eight raw byte arrays Lindera needs, and handed to the analyzer:
import { JapaneseAnalyzer, Schema } from "laurus-wasm";
import { downloadDictionary, loadDictionaryFiles } from "laurus-wasm/opfs";
await downloadDictionary("./dict/lindera-ipadic.zip", "ipadic");
const f = await loadDictionaryFiles("ipadic");
const ja = JapaneseAnalyzer.fromBytes(
f.metadata, f.dictDa, f.dictVals, f.dictWordsIdx,
f.dictWords, f.matrixMtx, f.charDef, f.unk, "normal",
);
const schema = new Schema();
schema.addAnalyzer("ja-ipadic", ja);
schema.addTextField("body", undefined, undefined, undefined, "ja-ipadic");
The OPFS helpers (downloadDictionary, loadDictionaryFiles, hasDictionary, listDictionaries, removeDictionary) live in js/opfs.js and are re-exported as the laurus-wasm/opfs subpath in package.json. See API Reference → JapaneseAnalyzer for the argument table.
Callback Embedder
In addition to the "precomputed" embedder (vectors supplied directly by the caller), laurus-wasm accepts a "callback" embedder where the JS side provides an async embed: (text) => Promise<number[]> function. The engine invokes this callback during document ingestion and searchVectorText() queries, which lets you wire in any in-browser embedding library (Transformers.js, ONNX Runtime Web, etc.) without rebuilding the WASM module:
import { pipeline } from "@xenova/transformers";
const embedder = await pipeline(
"feature-extraction",
"Xenova/all-MiniLM-L6-v2",
);
schema.addEmbedder("minilm", {
type: "callback",
embed: async (text) => {
const output = await embedder(text, { pooling: "mean", normalize: true });
return Array.from(output.data);
},
});
schema.addHnswField(
"embedding", 384, "cosine",
undefined, undefined, "minilm",
);
The wasm-bindgen glue holds the JS callback via a Closure, so it stays alive for the lifetime of the index. There is no Send + Sync requirement on the callback because it only runs on the main thread.
Testing
# Build check
cargo build -p laurus-wasm --target wasm32-unknown-unknown
# Clippy
cargo clippy -p laurus-wasm --target wasm32-unknown-unknown -- -D warnings
Browser tests can be run with wasm-pack test:
wasm-pack test --headless --chrome
Ruby Binding Overview
The laurus gem provides Ruby bindings for the Laurus search engine. It is built as a native Rust extension using Magnus and rb_sys, giving Ruby programs direct access to Laurus’s lexical, vector, and hybrid search capabilities with near-native performance.
Features
- Lexical Search – Full-text search powered by an inverted index with BM25 scoring
- Vector Search – Approximate nearest neighbor (ANN) search using Flat, HNSW, or IVF indexes
- Hybrid Search – Combine lexical and vector results with fusion algorithms (RRF, WeightedSum)
- Rich Query DSL – Term, Phrase, Fuzzy, Wildcard, NumericRange, Geo, Boolean, Span queries
- Text Analysis – Tokenizers, filters, stemmers, and synonym expansion
- Flexible Storage – In-memory (ephemeral) or file-based (persistent) indexes
- Idiomatic Ruby API – Clean, intuitive Ruby classes under the
Laurus::namespace
Architecture
graph LR
subgraph "laurus-ruby (gem)"
RbIndex["Index\n(Ruby class)"]
RbQuery["Query classes"]
RbSearch["SearchRequest\n/ SearchResult"]
end
Ruby["Ruby application"] -->|"method calls"| RbIndex
Ruby -->|"query objects"| RbQuery
RbIndex -->|"Magnus FFI"| Engine["laurus::Engine\n(Rust)"]
RbQuery -->|"Magnus FFI"| Engine
Engine --> Storage["Storage\n(Memory / File)"]
The Ruby classes are thin wrappers around the Rust engine. Each call crosses the Magnus FFI boundary once; the Rust engine then executes the operation entirely in native code.
Although the Rust engine uses async I/O internally, all Ruby
methods are exposed as synchronous functions. Each method
calls tokio::Runtime::block_on() under the hood to bridge
async Rust to synchronous Ruby.
Quick Start
require "laurus"
# Create an in-memory index
index = Laurus::Index.new
# Index documents
index.put_document("doc1", { "title" => "Introduction to Rust", "body" => "Systems programming language." })
index.put_document("doc2", { "title" => "Ruby for Web Development", "body" => "Web applications with Ruby." })
index.commit
# Search
results = index.search("title:rust", limit: 5)
results.each do |r|
puts "[#{r.id}] score=#{format('%.4f', r.score)} #{r.document['title']}"
end
Sections
- Installation – How to install the gem
- Quick Start – Hands-on introduction with examples
- API Reference – Complete class and method reference
- Development – Building from source and running tests
Installation
From RubyGems
gem install laurus
Or add it to your Gemfile:
gem "laurus"
Then run:
bundle install
From source
Building from source requires a Rust toolchain (1.85 or later) and rb_sys.
# Clone the repository
git clone https://github.com/mosuka/laurus.git
cd laurus/laurus-ruby
# Install dependencies
bundle install
# Compile the native extension
bundle exec rake compile
# Or install the gem locally
gem build laurus.gemspec
gem install laurus-*.gem
Verify
require "laurus"
index = Laurus::Index.new
puts index # Index()
Requirements
- Ruby 3.1 or later
- Rust toolchain (automatically invoked during gem install via
rb_sys) - No runtime dependencies beyond the compiled native extension
Quick Start
1. Create an index
require "laurus"
# In-memory index (ephemeral, useful for prototyping)
index = Laurus::Index.new
# File-based index (persistent)
schema = Laurus::Schema.new
schema.add_text_field("title")
schema.add_text_field("body")
index = Laurus::Index.new(path: "./myindex", schema: schema)
2. Index documents
index.put_document("doc1", {
"title" => "Introduction to Rust",
"body" => "Rust is a systems programming language focused on safety and performance.",
})
index.put_document("doc2", {
"title" => "Ruby for Web Development",
"body" => "Ruby is widely used for web applications and rapid prototyping.",
})
index.commit
3. Lexical search
# DSL string
results = index.search("title:rust", limit: 5)
# Query object
results = index.search(Laurus::TermQuery.new("body", "ruby"), limit: 5)
# Print results
results.each do |r|
puts "[#{r.id}] score=#{format('%.4f', r.score)} #{r.document['title']}"
end
4. Vector search
Vector search requires a schema with a vector field and pre-computed embeddings.
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. Hybrid search
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. Update and delete
# Update: put_document replaces all existing versions
index.put_document("doc1", { "title" => "Updated Title", "body" => "New content." })
index.commit
# Append a new version without removing existing ones (RAG chunking pattern)
index.add_document("doc1", { "title" => "Chunk 2", "body" => "Additional chunk." })
index.commit
# Retrieve all versions
docs = index.get_documents("doc1")
# Delete
index.delete_documents("doc1")
index.commit
7. Schema management
schema = Laurus::Schema.new
schema.add_text_field("title")
schema.add_text_field("body")
schema.add_integer_field("year")
schema.add_float_field("score")
schema.add_boolean_field("published")
schema.add_bytes_field("thumbnail")
schema.add_geo_field("location")
schema.add_datetime_field("created_at")
schema.add_hnsw_field("embedding", 384)
schema.add_flat_field("small_vec", 64)
schema.add_ivf_field("ivf_vec", 128, n_clusters: 100)
8. Index statistics
stats = index.stats
puts stats["document_count"]
puts stats["vector_fields"]
API Reference
Index
The primary entry point. Wraps the Laurus search engine.
Laurus::Index.new(path: nil, schema: nil)
Constructor
| Parameter | Type | Default | Description |
|---|---|---|---|
path: | String | nil | nil | Directory path for persistent storage. nil creates an in-memory index. |
schema: | Schema | nil | nil | Schema definition. An empty schema is used when omitted. |
Methods
| Method | Description |
|---|---|
put_document(id, doc) | Upsert a document. Replaces all existing versions with the same ID. |
add_document(id, doc) | Append a document chunk without removing existing versions. |
get_documents(id) -> Array<Hash> | Return all stored versions for the given ID. |
delete_documents(id) | Delete all versions for the given ID. |
commit | Flush buffered writes and make all pending changes searchable. |
search(query, limit: 10, offset: 0) -> Array<SearchResult> | Execute a search query. |
search_batch(queries, limit: 10, offset: 0) -> Array<Array<SearchResult>> | Execute multiple independent searches in one call. Each query is dispatched in parallel on the underlying tokio runtime. results[i] corresponds to queries[i]. Empty input returns []. |
stats -> Hash | Return index statistics ("document_count", "vector_fields"). |
search query argument
The query parameter accepts any of the following:
- A DSL string (e.g.
"title:hello","embedding:\"memory safety\"") - A lexical query object (
TermQuery,PhraseQuery,BooleanQuery, …) - A vector query object (
VectorQuery,VectorTextQuery) - A
SearchRequestfor full control
The same value kinds are accepted as the elements of search_batch’s queries Array — DSL strings, query objects, and SearchRequest instances may be mixed within a single batch.
Schema
Defines the fields and index types for an Index.
Laurus::Schema.new
Field methods
| Method | Description |
|---|---|
add_text_field(name, stored: true, indexed: true, term_vectors: false, analyzer: nil) | Full-text field (inverted index, BM25). analyzer: is the name of a parameter-less built-in ("standard", "english", "keyword", "simple", "noop") or a custom name registered via add_analyzer. The Japanese preset requires a Lindera dictionary path, so register it as a custom analyzer with a lindera tokenizer and reference it by name. |
add_integer_field(name, stored: true, indexed: true, multi_valued: false) | 64-bit integer field. Pass multi_valued: true to accept arrays of integers (range queries match if any value satisfies the predicate). |
add_float_field(name, stored: true, indexed: true, multi_valued: false) | 64-bit float field. Pass multi_valued: true to accept arrays of floats (range queries match if any value satisfies the predicate). |
add_boolean_field(name, stored: true, indexed: true) | Boolean field. |
add_bytes_field(name, stored: true) | Raw bytes field. |
add_geo_field(name, stored: true, indexed: true) | Geographic coordinate field (lat/lon). |
add_geo3d_field(name, stored: true, indexed: true) | 3D ECEF Cartesian point field (x, y, z in metres). See Geo3d concepts. |
add_datetime_field(name, stored: true, indexed: true) | UTC datetime field. |
add_hnsw_field(name, dimension, distance: "cosine", m: 16, ef_construction: 200, embedder: nil) | HNSW approximate nearest-neighbor vector field. |
add_flat_field(name, dimension, distance: "cosine", embedder: nil) | Flat (brute-force) vector field. |
add_ivf_field(name, dimension, distance: "cosine", n_clusters: 100, n_probe: 1, embedder: nil) | IVF approximate nearest-neighbor vector field. |
Other methods
| Method | Description |
|---|---|
add_embedder(name, config) | Register a named embedder definition. config is a Hash with a "type" key (see below). |
set_default_fields(fields) | Set the default fields used when no field is specified in a query. fields is an Array of Strings. |
set_dynamic_field_policy(policy) | Set how undeclared fields are handled. policy is "strict", "dynamic" (default), or "ignore". See notes below. |
dynamic_field_policy -> String | Return the current policy as a lowercase string. |
field_names -> Array<String> | Return the list of field names defined in this schema. |
Dynamic field policy
Controls what happens when a document is ingested with field names that are not declared in the schema:
"strict"— Reject the document."dynamic"(default) — Infer a type for each undeclared field and add it to the schema. Warning: integer fields silently truncate incoming float values (3.14→3). Use"strict"if you need to reject such type mismatches."ignore"— Silently drop the undeclared fields.
See Schema & Fields for the full behaviour matrix.
Embedder types
"type" | Required keys | Feature flag |
|---|---|---|
"precomputed" | – | (always available) |
"candle_bert" | "model" | embeddings-candle |
"candle_clip" | "model" | embeddings-multimodal |
"openai" | "model" | embeddings-openai |
Distance metrics
| Value | Description |
|---|---|
"cosine" | Cosine similarity (default) |
"euclidean" | Euclidean distance |
"dot_product" | Dot product |
"manhattan" | Manhattan distance |
"angular" | Angular distance |
Query classes
TermQuery
Laurus::TermQuery.new(field, term)
Matches documents containing the exact term in the given field.
PhraseQuery
Laurus::PhraseQuery.new(field, terms)
Matches documents containing the terms in order. terms is an Array of Strings.
FuzzyQuery
Laurus::FuzzyQuery.new(field, term, max_edits: 2)
Approximate match allowing up to max_edits edit-distance errors.
WildcardQuery
Laurus::WildcardQuery.new(field, pattern)
Pattern match. * matches any sequence of characters, ? matches any single character.
NumericRangeQuery
Laurus::NumericRangeQuery.new(field, min: nil, max: nil)
Matches numeric values in the range [min, max]. Pass nil for an open bound. The type (integer or float) is inferred from the Ruby type of min/max.
GeoDistanceQuery
Laurus::GeoDistanceQuery.within_radius(field, lat, lon, distance_m)
Geo-distance (radius) search. Returns documents whose (lat, lon) coordinate
is within distance_m metres of the given point.
GeoBoundingBoxQuery
Laurus::GeoBoundingBoxQuery.within_bounding_box(
field, min_lat, min_lon, max_lat, max_lon,
)
Geo bounding-box search. Returns documents whose (lat, lon) coordinate lies
inside the axis-aligned [min_lat, max_lat] × [min_lon, max_lon] rectangle.
Geo3dDistanceQuery
Laurus::Geo3dDistanceQuery.within_sphere(field, x, y, z, distance_m)
Sphere search over a 3D ECEF point field. Returns documents whose (x, y, z)
coordinate is within distance_m metres of the centre. See
Geo3d concepts for ECEF theory.
Geo3dBoundingBoxQuery
Laurus::Geo3dBoundingBoxQuery.within_box(
field,
min_x, min_y, min_z,
max_x, max_y, max_z,
)
Axis-aligned 3D bounding-box search.
Geo3dNearestQuery
Laurus::Geo3dNearestQuery.k_nearest(
field, x, y, z, k,
initial_radius_m: nil,
max_radius_m: nil,
)
k-nearest-neighbour search over a 3D ECEF point field. The optional
initial_radius_m: and max_radius_m: keyword arguments tune the
iterative-expansion search cone.
BooleanQuery
bq = Laurus::BooleanQuery.new
bq.must(query)
bq.should(query)
bq.must_not(query)
Compound boolean query. must clauses all have to match; must_not clauses must not match. should clauses contribute to scoring; at least one of them must match if there are no must clauses.
SpanQuery
# Single term
Laurus::SpanQuery.term(field, term)
# Near: terms within slop positions
Laurus::SpanQuery.near(field, terms, slop: 0, ordered: true)
# Near with nested SpanQuery clauses
Laurus::SpanQuery.near_spans(field, clauses, slop: 0, ordered: true)
# Containing: big span contains little span
Laurus::SpanQuery.containing(field, big, little)
# Within: include span within exclude span at max distance
Laurus::SpanQuery.within(field, include_span, exclude_span, distance)
Positional / proximity span queries. near takes an Array of term Strings, while near_spans takes an Array of SpanQuery objects for nested expressions.
VectorQuery
Laurus::VectorQuery.new(field, vector)
Approximate nearest-neighbor search using a pre-computed embedding vector. vector is an Array of Floats.
VectorTextQuery
Laurus::VectorTextQuery.new(field, text)
Converts text to an embedding at query time and runs vector search. Requires an embedder configured on the index.
SearchRequest
Full-featured search request for advanced control.
Laurus::SearchRequest.new(
query: nil,
lexical_query: nil,
vector_query: nil,
filter_query: nil,
fusion: nil,
limit: 10,
offset: 0,
)
| Parameter | Description |
|---|---|
query: | A DSL string or single query object. Mutually exclusive with lexical_query: / vector_query:. |
lexical_query: | Lexical component for explicit hybrid search. |
vector_query: | Vector component for explicit hybrid search. |
filter_query: | Lexical filter applied after scoring. |
fusion: | Fusion algorithm (RRF or WeightedSum). Defaults to RRF(k: 60) when both components are set. |
limit: | Maximum number of results (default 10). |
offset: | Pagination offset (default 0). |
SearchResult
Returned by Index#search.
result.id # => String -- External document identifier
result.score # => Float -- Relevance score
result.document # => Hash|nil -- Retrieved field values, or nil if deleted
Fusion algorithms
RRF
Laurus::RRF.new(k: 60.0)
Reciprocal Rank Fusion. Merges lexical and vector result lists by rank position. k is a smoothing constant; higher values reduce the influence of top-ranked results.
WeightedSum
Laurus::WeightedSum.new(lexical_weight: 0.5, vector_weight: 0.5)
Normalises both score lists independently, then combines them as lexical_weight * lexical_score + vector_weight * vector_score.
Text analysis
SynonymDictionary
dict = Laurus::SynonymDictionary.new
dict.add_synonym_group(["fast", "quick", "rapid"])
A dictionary of synonym groups. All terms in a group are treated as synonyms of each other.
WhitespaceTokenizer
tokenizer = Laurus::WhitespaceTokenizer.new
tokens = tokenizer.tokenize("hello world")
Splits text on whitespace boundaries and returns an Array of Token objects.
SynonymGraphFilter
filter = Laurus::SynonymGraphFilter.new(dictionary, keep_original: true, boost: 1.0)
expanded = filter.apply(tokens)
Token filter that expands tokens with their synonyms from a SynonymDictionary.
Token
token.text # => String -- The token text
token.position # => Integer -- Position in the token stream
token.start_offset # => Integer -- Character start offset in the original text
token.end_offset # => Integer -- Character end offset in the original text
token.boost # => Float -- Score boost factor (1.0 = no adjustment)
token.stopped # => Boolean -- Whether removed by a stop filter
token.position_increment # => Integer -- Difference from the previous token's position
token.position_length # => Integer -- Number of positions spanned
Field value types
Ruby values are automatically converted to Laurus DataValue types:
| Ruby type | Laurus type | Notes |
|---|---|---|
nil | Null | |
true / false | Bool | |
Integer | Int64 | |
Float | Float64 | |
String | Text | |
Array of numerics | Vector | Elements coerced to f32 |
Hash with "lat", "lon" | Geo | Two Float values |
Hash with "x", "y", "z" | GeoEcef | Three Float values, meters (3D ECEF Cartesian) |
Time / String responding to iso8601 | DateTime | Converted via iso8601 |
Development Setup
This page covers how to set up a local development environment
for the laurus-ruby binding, build it, and run the test
suite.
Prerequisites
- Rust 1.85 or later with Cargo
- Ruby 3.1 or later with Bundler
- Repository cloned locally
git clone https://github.com/mosuka/laurus.git
cd laurus
Build
Development build
Compiles the Rust native extension in debug mode. Re-run after any Rust source change.
cd laurus-ruby
bundle install
bundle exec rake compile
Release build
gem build laurus.gemspec
Verify the build
ruby -e "
require 'laurus'
index = Laurus::Index.new
puts index.stats
"
# {"document_count"=>0, "vector_fields"=>{}}
Testing
Tests use Minitest and are located in
test/.
# Run all tests
bundle exec rake test
To run a specific test file:
bundle exec ruby -Ilib -Itest test/test_index.rb
Linting and formatting
# Rust lint (Clippy)
cargo clippy -p laurus-ruby -- -D warnings
# Rust formatting
cargo fmt -p laurus-ruby --check
# Apply formatting
cargo fmt -p laurus-ruby
Cleaning up
# Remove build artifacts
bundle exec rake clean
# Remove installed gems
rm -rf vendor/bundle
Makefile reference
| Target | Description |
|---|---|
make build-laurus-ruby | Bundler install + bundle exec rake compile (release gem) |
make test-laurus-ruby | Rust unit tests + Ruby minitest |
make lint-laurus-ruby | Clippy with -D warnings |
make format-laurus-ruby | cargo fmt -p laurus-ruby |
Project layout
laurus-ruby/
├── Cargo.toml # Rust crate manifest
├── laurus.gemspec # Gem specification
├── Gemfile # Bundler dependency file
├── Rakefile # Rake tasks (compile, test, clean)
├── lib/
│ └── laurus.rb # Ruby entrypoint (loads native extension)
├── ext/
│ └── laurus_ruby/ # Native extension build configuration
│ └── extconf.rb # rb_sys extension configuration
├── src/ # Rust source (Magnus binding)
│ ├── lib.rs # Module registration
│ ├── index.rs # Index class
│ ├── schema.rs # Schema class
│ ├── query.rs # Query classes
│ ├── search.rs # SearchRequest / SearchResult / Fusion
│ ├── analysis.rs # Tokenizer / Filter / Token
│ ├── convert.rs # Ruby ↔ DataValue conversion
│ └── errors.rs # Error mapping
├── test/ # Minitest tests
│ ├── test_helper.rb
│ └── test_index.rb
└── examples/ # Runnable Ruby examples
PHP Binding Overview
The laurus PHP extension provides PHP bindings for the Laurus search engine. It is built as a native Rust extension using ext-php-rs, giving PHP programs direct access to Laurus’s lexical, vector, and hybrid search capabilities with near-native performance.
Features
- Lexical Search – Full-text search powered by an inverted index with BM25 scoring
- Vector Search – Approximate nearest neighbor (ANN) search using Flat, HNSW, or IVF indexes
- Hybrid Search – Combine lexical and vector results with fusion algorithms (RRF, WeightedSum)
- Rich Query DSL – Term, Phrase, Fuzzy, Wildcard, NumericRange, Geo, Boolean, Span queries
- Text Analysis – Tokenizers, filters, stemmers, and synonym expansion
- Flexible Storage – In-memory (ephemeral) or file-based (persistent) indexes
- Idiomatic PHP API – Clean, intuitive PHP classes under the
Laurus\namespace
Architecture
graph LR
subgraph "laurus-php (extension)"
PhpIndex["Index\n(PHP class)"]
PhpQuery["Query classes"]
PhpSearch["SearchRequest\n/ SearchResult"]
end
PHP["PHP application"] -->|"method calls"| PhpIndex
PHP -->|"query objects"| PhpQuery
PhpIndex -->|"ext-php-rs FFI"| Engine["laurus::Engine\n(Rust)"]
PhpQuery -->|"ext-php-rs FFI"| Engine
Engine --> Storage["Storage\n(Memory / File)"]
The PHP classes are thin wrappers around the Rust engine. Each call crosses the ext-php-rs FFI boundary once; the Rust engine then executes the operation entirely in native code.
Although the Rust engine uses async I/O internally, all PHP
methods are exposed as synchronous functions. Each method
calls tokio::Runtime::block_on() under the hood to bridge
async Rust to synchronous PHP.
Quick Start
<?php
use Laurus\Index;
// Create an in-memory index
$index = new Index();
// Index documents
$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();
// Search
$results = $index->search("title:rust", 5);
foreach ($results as $r) {
printf("[%s] score=%.4f %s\n", $r->getId(), $r->getScore(), $r->getDocument()["title"]);
}
Sections
- Installation – How to install the extension
- Quick Start – Hands-on introduction with examples
- API Reference – Complete class and method reference
- Development – Building from source and running tests
Installation
laurus-php is a PHP extension written in Rust. It is not published to PECL and Composer is not used as the installation channel — the composer.json in the repository only declares dev-time test dependencies (PHPUnit). Build the shared library from source with Cargo, drop it next to your PHP installation’s other extensions, and enable it via php.ini.
From source
Building from source requires a Rust toolchain (1.85 or later) and PHP 8.1 or later with development headers.
# Clone the repository
git clone https://github.com/mosuka/laurus.git
cd laurus/laurus-php
# Build the native extension
cargo build --release
# Copy the shared library to the PHP extensions directory
# (the exact path depends on your OS and PHP version)
cp ../target/release/liblaurus_php.so $(php -r "echo ini_get('extension_dir');")
Then add the extension to your php.ini:
extension=laurus_php.so
Alternatively, you can load the extension on the command line:
php -d extension=liblaurus_php.so your_script.php
Verify
<?php
use Laurus\Index;
$index = new Index();
echo $index; // Index()
Requirements
- PHP 8.1 or later with development headers (
php-dev/php-devel) - Rust toolchain 1.85 or later with Cargo
- No runtime dependencies beyond the compiled native extension
Quick Start
1. Create an index
<?php
use Laurus\Index;
use Laurus\Schema;
// In-memory index (ephemeral, useful for prototyping)
$index = new Index();
// File-based index (persistent)
$schema = new Schema();
$schema->addTextField("title");
$schema->addTextField("body");
$index = new Index("./myindex", $schema);
2. Index documents
$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 search
// DSL string
$results = $index->search("title:rust", 5);
// Query object
$results = $index->search(new \Laurus\TermQuery("body", "php"), 5);
// Print results
foreach ($results as $r) {
printf("[%s] score=%.4f %s\n", $r->getId(), $r->getScore(), $r->getDocument()["title"]);
}
4. Vector search
Vector search requires a schema with a vector field and pre-computed embeddings.
<?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. Hybrid search
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. Update and delete
// Update: putDocument replaces all existing versions
$index->putDocument("doc1", ["title" => "Updated Title", "body" => "New content."]);
$index->commit();
// Append a new version without removing existing ones (RAG chunking pattern)
$index->addDocument("doc1", ["title" => "Chunk 2", "body" => "Additional chunk."]);
$index->commit();
// Retrieve all versions
$docs = $index->getDocuments("doc1");
// Delete
$index->deleteDocuments("doc1");
$index->commit();
7. Schema management
$schema = new \Laurus\Schema();
$schema->addTextField("title");
$schema->addTextField("body");
$schema->addIntegerField("year");
$schema->addFloatField("score");
$schema->addBooleanField("published");
$schema->addBytesField("thumbnail");
$schema->addGeoField("location");
$schema->addDatetimeField("created_at");
$schema->addHnswField("embedding", 384);
$schema->addFlatField("small_vec", 64);
$schema->addIvfField("ivf_vec", 128, "cosine", 100, 1);
8. Index statistics
$stats = $index->stats();
echo $stats["documentCount"];
echo $stats["vectorFields"];
API Reference
Index
The primary entry point. Wraps the Laurus search engine.
new \Laurus\Index(?string $path = null, ?Schema $schema = null)
Constructor
| Parameter | Type | Default | Description |
|---|---|---|---|
$path | string|null | null | Directory path for persistent storage. null creates an in-memory index. |
$schema | Schema|null | null | Schema definition. An empty schema is used when omitted. |
Methods
| Method | Description |
|---|---|
putDocument(string $id, array $doc): void | Upsert a document. Replaces all existing versions with the same ID. |
addDocument(string $id, array $doc): void | Append a document chunk without removing existing versions. |
getDocuments(string $id): array | Return all stored versions for the given ID. |
deleteDocuments(string $id): void | Delete all versions for the given ID. |
commit(): void | Flush buffered writes and make all pending changes searchable. |
search(mixed $query, int $limit = 10, int $offset = 0): array | Execute a search query. Returns an array of SearchResult. |
searchBatch(array $queries, int $limit = 10, int $offset = 0): array | Execute multiple independent searches in one call. Each query is dispatched in parallel on the underlying tokio runtime. results[i] corresponds to queries[i]. Returns an array of arrays of SearchResult. Empty input returns []. |
stats(): array | Return index statistics ("documentCount", "vectorFields"). |
search query argument
The $query parameter accepts any of the following:
- A DSL string (e.g.
"title:hello","embedding:\"memory safety\"") - A lexical query object (
TermQuery,PhraseQuery,BooleanQuery, …) - A vector query object (
VectorQuery,VectorTextQuery) - A
SearchRequestfor full control
The same value kinds are accepted as the elements of searchBatch’s $queries array — DSL strings, query objects, and SearchRequest instances may be mixed within a single batch.
Schema
Defines the fields and index types for an Index.
new \Laurus\Schema()
Field methods
| Method | Description |
|---|---|
addTextField(string $name, bool $stored = true, bool $indexed = true, bool $termVectors = false, ?string $analyzer = null): void | Full-text field (inverted index, BM25). $analyzer is the name of a parameter-less built-in ("standard", "english", "keyword", "simple", "noop") or a custom name registered via addAnalyzer. The Japanese preset requires a Lindera dictionary path, so register it as a custom analyzer with a lindera tokenizer and reference it by name. |
addIntegerField(string $name, bool $stored = true, bool $indexed = true, bool $multiValued = false): void | 64-bit integer field. Pass $multiValued = true to accept arrays of integers (range queries match if any value satisfies the predicate). |
addFloatField(string $name, bool $stored = true, bool $indexed = true, bool $multiValued = false): void | 64-bit float field. Pass $multiValued = true to accept arrays of floats (range queries match if any value satisfies the predicate). |
addBooleanField(string $name, bool $stored = true, bool $indexed = true): void | Boolean field. |
addBytesField(string $name, bool $stored = true): void | Raw bytes field. |
addGeoField(string $name, bool $stored = true, bool $indexed = true): void | Geographic coordinate field (lat/lon). |
addGeo3dField(string $name, bool $stored = true, bool $indexed = true): void | 3D ECEF Cartesian point field (x, y, z in metres). See Geo3d concepts. |
addDatetimeField(string $name, bool $stored = true, bool $indexed = true): void | UTC datetime field. |
addHnswField(string $name, int $dimension, ?string $distance = "cosine", int $m = 16, int $efConstruction = 200, ?string $embedder = null): void | HNSW approximate nearest-neighbor vector field. |
addFlatField(string $name, int $dimension, ?string $distance = "cosine", ?string $embedder = null): void | Flat (brute-force) vector field. |
addIvfField(string $name, int $dimension, ?string $distance = "cosine", int $nClusters = 100, int $nProbe = 1, ?string $embedder = null): void | IVF approximate nearest-neighbor vector field. |
Other methods
| Method | Description |
|---|---|
addEmbedder(string $name, array $config): void | Register a named embedder definition. $config is an associative array with a "type" key (see below). |
setDefaultFields(array $fields): void | Set the default fields used when no field is specified in a query. $fields is an array of strings. |
setDynamicFieldPolicy(string $policy): void | Set how undeclared fields are handled. $policy is "strict", "dynamic" (default), or "ignore". See notes below. |
dynamicFieldPolicy(): string | Return the current policy as a lowercase string. |
fieldNames(): array | Return the list of field names defined in this schema. |
Dynamic field policy
Controls what happens when a document is ingested with field names that are not declared in the schema:
"strict"— Reject the document."dynamic"(default) — Infer a type for each undeclared field and add it to the schema. Warning: integer fields silently truncate incoming float values (3.14→3). Use"strict"if you need to reject such type mismatches."ignore"— Silently drop the undeclared fields.
See Schema & Fields for the full behaviour matrix.
Embedder types
"type" | Required keys | Feature flag |
|---|---|---|
"precomputed" | – | (always available) |
"candle_bert" | "model" | embeddings-candle |
"candle_clip" | "model" | embeddings-multimodal |
"openai" | "model" | embeddings-openai |
Distance metrics
| Value | Description |
|---|---|
"cosine" | Cosine similarity (default) |
"euclidean" | Euclidean distance |
"dot_product" | Dot product |
"manhattan" | Manhattan distance |
"angular" | Angular distance |
Query classes
TermQuery
new \Laurus\TermQuery(string $field, string $term)
Matches documents containing the exact term in the given field.
PhraseQuery
new \Laurus\PhraseQuery(string $field, array $terms)
Matches documents containing the terms in order. $terms is an array of strings.
FuzzyQuery
new \Laurus\FuzzyQuery(string $field, string $term, int $maxEdits = 2)
Approximate match allowing up to $maxEdits edit-distance errors.
WildcardQuery
new \Laurus\WildcardQuery(string $field, string $pattern)
Pattern match. * matches any sequence of characters, ? matches any single character.
NumericRangeQuery
new \Laurus\NumericRangeQuery(string $field, mixed $min, mixed $max, ?string $numericType = "integer")
Matches numeric values in the range [$min, $max]. Pass null for an open bound. Set $numericType to "integer" or "float".
GeoDistanceQuery
\Laurus\GeoDistanceQuery::withinRadius(
string $field, float $lat, float $lon, float $distanceM,
): GeoDistanceQuery
Geo-distance (radius) search. Returns documents whose (lat, lon) coordinate
is within $distanceM metres of the given point.
GeoBoundingBoxQuery
\Laurus\GeoBoundingBoxQuery::withinBoundingBox(
string $field,
float $minLat, float $minLon,
float $maxLat, float $maxLon,
): GeoBoundingBoxQuery
Geo bounding-box search. Returns documents whose (lat, lon) coordinate lies
inside the axis-aligned [$minLat, $maxLat] × [$minLon, $maxLon] rectangle.
Geo3dDistanceQuery
\Laurus\Geo3dDistanceQuery::withinSphere(
string $field,
float $x, float $y, float $z,
float $distanceM,
): Geo3dDistanceQuery
Sphere search over a 3D ECEF point field. Returns documents whose (x, y, z)
coordinate is within $distanceM metres of the centre. See
Geo3d concepts for ECEF theory.
Geo3dBoundingBoxQuery
\Laurus\Geo3dBoundingBoxQuery::withinBox(
string $field,
float $minX, float $minY, float $minZ,
float $maxX, float $maxY, float $maxZ,
): Geo3dBoundingBoxQuery
Axis-aligned 3D bounding-box search.
Geo3dNearestQuery
\Laurus\Geo3dNearestQuery::kNearest(
string $field,
float $x, float $y, float $z,
int $k,
?float $initialRadiusM = null,
?float $maxRadiusM = null,
): Geo3dNearestQuery
k-nearest-neighbour search over a 3D ECEF point field. The optional
$initialRadiusM and $maxRadiusM parameters tune the iterative-expansion
search cone.
BooleanQuery
$bq = new \Laurus\BooleanQuery();
$bq->must($query);
$bq->should($query);
$bq->mustNot($query);
Compound boolean query. must clauses all have to match; mustNot clauses must not match. should clauses contribute to scoring; at least one of them must match if there are no must clauses.
SpanQuery
// Single term
\Laurus\SpanQuery::term(string $field, string $term): SpanQuery
// Near: terms within slop positions
\Laurus\SpanQuery::near(string $field, array $terms, int $slop = 0, bool $ordered = true): SpanQuery
// NearSpans: nested SpanQuery clauses within slop positions
\Laurus\SpanQuery::nearSpans(string $field, array $clauses, int $slop = 0, bool $ordered = true): SpanQuery
// Containing: big span contains little span
\Laurus\SpanQuery::containing(string $field, SpanQuery $big, SpanQuery $little): SpanQuery
// Within: include span within exclude span at max distance
\Laurus\SpanQuery::within(string $field, SpanQuery $include, SpanQuery $exclude, int $distance): SpanQuery
Positional / proximity span queries. near takes an array of term strings,
while nearSpans takes an array of SpanQuery objects for nested expressions
(each clause’s field is re-rooted to the outer $field).
VectorQuery
new \Laurus\VectorQuery(string $field, array $vector)
Approximate nearest-neighbor search using a pre-computed embedding vector. $vector is an array of floats.
VectorTextQuery
new \Laurus\VectorTextQuery(string $field, string $text)
Converts $text to an embedding at query time and runs vector search. Requires an embedder configured on the index.
SearchRequest
Full-featured search request for advanced control.
new \Laurus\SearchRequest(
mixed $query = null,
mixed $lexicalQuery = null,
mixed $vectorQuery = null,
mixed $filterQuery = null,
mixed $fusion = null,
int $limit = 10,
int $offset = 0,
)
| Parameter | Description |
|---|---|
$query | A DSL string or single query object. Mutually exclusive with $lexicalQuery / $vectorQuery. |
$lexicalQuery | Lexical component for explicit hybrid search. |
$vectorQuery | Vector component for explicit hybrid search. |
$filterQuery | Lexical filter applied after scoring. |
$fusion | Fusion algorithm (RRF or WeightedSum). Defaults to RRF(k: 60) when both components are set. |
$limit | Maximum number of results (default 10). |
$offset | Pagination offset (default 0). |
SearchResult
Returned by Index->search().
$result->getId() // string -- External document identifier
$result->getScore() // float -- Relevance score
$result->getDocument() // array|null -- Retrieved field values, or null if not stored
Fusion algorithms
RRF
new \Laurus\RRF(float $k = 60.0)
Reciprocal Rank Fusion. Merges lexical and vector result lists by rank position. $k is a smoothing constant; higher values reduce the influence of top-ranked results.
WeightedSum
new \Laurus\WeightedSum(float $lexicalWeight = 0.5, float $vectorWeight = 0.5)
Normalises both score lists independently, then combines them as $lexicalWeight * lexical_score + $vectorWeight * vector_score.
Text analysis
SynonymDictionary
$dict = new \Laurus\SynonymDictionary();
$dict->addSynonymGroup(["fast", "quick", "rapid"]);
A dictionary of synonym groups. All terms in a group are treated as synonyms of each other.
WhitespaceTokenizer
$tokenizer = new \Laurus\WhitespaceTokenizer();
$tokens = $tokenizer->tokenize("hello world");
Splits text on whitespace boundaries and returns an array of Token objects.
SynonymGraphFilter
new \Laurus\SynonymGraphFilter(SynonymDictionary $dictionary, bool $keepOriginal = true, float $boost = 1.0)
| Parameter | Description |
|---|---|
$dictionary | Source synonym groups. |
$keepOriginal | When true (default), keep the original token alongside the synonyms. |
$boost | Score boost applied to the inserted synonym tokens (default 1.0). |
$filter = new \Laurus\SynonymGraphFilter($dictionary, true, 1.0);
$expanded = $filter->apply($tokens);
Token filter that expands tokens with their synonyms from a SynonymDictionary.
Token
$token->getText() // string -- The token text
$token->getPosition() // int -- Position in the token stream
$token->getStartOffset() // int -- Character start offset in the original text
$token->getEndOffset() // int -- Character end offset in the original text
$token->getBoost() // float -- Score boost factor (1.0 = no adjustment)
$token->isStopped() // bool -- Whether removed by a stop filter
$token->getPositionIncrement() // int -- Difference from the previous token's position
$token->getPositionLength() // int -- Number of positions spanned
Field value types
PHP values are automatically converted to Laurus DataValue types:
| PHP type | Laurus type | Notes |
|---|---|---|
null | Null | |
true / false | Bool | |
int | Int64 | |
float | Float64 | |
string | Text | |
array of numerics | Vector | Elements coerced to f32 |
array with "lat", "lon" | Geo | Two float values |
array with "x", "y", "z" | GeoEcef | Three float values, meters (3D ECEF Cartesian) |
string (ISO 8601) | DateTime | Parsed from ISO 8601 format |
Development Setup
This page covers how to set up a local development environment
for the laurus-php binding, build it, and run the test
suite.
Prerequisites
- Rust 1.85 or later with Cargo
- PHP 8.1 or later with development headers (
php-dev/php-devel) - Composer for dependency management
- Repository cloned locally
git clone https://github.com/mosuka/laurus.git
cd laurus
Build
Development build
Compiles the Rust native extension in debug mode. Re-run after any Rust source change.
cd laurus-php
cargo build
The resulting shared library is located at ../target/debug/liblaurus_php.so.
Release build
cd laurus-php
cargo build --release
The resulting shared library is located at ../target/release/liblaurus_php.so.
Verify the build
php -d extension=../target/release/liblaurus_php.so -r "
use Laurus\Index;
\$index = new Index();
print_r(\$index->stats());
"
# Array ( [documentCount] => 0 [vectorFields] => Array ( ) )
Testing
Tests use PHPUnit and are located in
tests/. Composer is used only for these dev-time PHP dependencies —
the runtime extension itself is built and loaded directly by Cargo.
# Install test dependencies (PHPUnit only)
composer install
# Run all tests
php -d extension=../target/release/liblaurus_php.so vendor/bin/phpunit tests/
To run a specific test file:
php -d extension=../target/release/liblaurus_php.so vendor/bin/phpunit tests/LaurusTest.php
Linting and formatting
# Rust lint (Clippy)
cargo clippy -p laurus-php -- -D warnings
# Rust formatting
cargo fmt -p laurus-php --check
# Apply formatting
cargo fmt -p laurus-php
Cleaning up
# Remove build artifacts
cargo clean
# Remove Composer dependencies
rm -rf vendor/
Workspace integration and the clang-sys patch
laurus-php uses ext-php-rs, which
depends on ext-php-rs-clang-sys (a fork of clang-sys). The laurus-ruby
crate depends on magnus, which in turn depends on the original clang-sys.
Both crates declare links = "clang", and Cargo forbids two packages with the
same links value in a single workspace.
To allow laurus-php and laurus-ruby to coexist as workspace members, the
root Cargo.toml patches ext-php-rs-clang-sys with a local copy that has the
links declaration removed:
# Cargo.toml (workspace root)
[patch.crates-io]
ext-php-rs-clang-sys = { path = "patches/ext-php-rs-clang-sys" }
The patch lives in patches/ext-php-rs-clang-sys/. The only change from the
upstream crate is the removal of links = "clang" in its Cargo.toml. This is
safe because both clang-sys and ext-php-rs-clang-sys use libclang only at
build time (for bindgen header parsing) and do not link it into the final
binary.
When is the patch needed?
This patch is only required because laurus-php and laurus-ruby are both
members of the same Cargo workspace. If laurus-ruby were removed from the
workspace (or if laurus-php were excluded via [workspace] exclude), the
links = "clang" conflict would not occur and the patch could be removed along
with the [patch.crates-io] section in the root Cargo.toml.
Updating the patch
When ext-php-rs is upgraded and pulls in a new version of
ext-php-rs-clang-sys, update the patch:
# 1. Update ext-php-rs in laurus-php/Cargo.toml, then:
cargo update -p ext-php-rs
# 2. Copy the new ext-php-rs-clang-sys source
cp -r ~/.cargo/registry/src/index.crates.io-*/ext-php-rs-clang-sys-<NEW_VERSION>/* \
patches/ext-php-rs-clang-sys/
# 3. Remove the links declaration
sed -i 's/^links = "clang"/# links = "clang"/' patches/ext-php-rs-clang-sys/Cargo.toml
# 4. Verify the build
cargo build -p laurus-php -p laurus-ruby
macOS linker flag (-undefined dynamic_lookup)
PHP extensions are shared libraries (.so / .dylib) that are loaded by the
PHP interpreter at runtime. They reference PHP API symbols (zend_*,
php_*, etc.) that are defined in the PHP binary itself, not in any library
the extension links against. On Linux the linker allows undefined symbols in
shared libraries by default, so this works without extra flags. On macOS the
linker treats undefined symbols as errors, which causes the build to fail:
ld: symbol(s) not found for architecture arm64
The fix is to pass -Wl,-undefined,dynamic_lookup to the linker, which
tells it to defer symbol resolution to load time (when PHP dlopens the
extension).
This flag is not set in .cargo/config.toml because it would apply to
every crate in the workspace, including non-PHP crates where undefined
symbols should remain errors. Instead it is applied only when building
laurus-php:
Makefile (local development):
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
When building on macOS, always use make build-laurus-php or
make test-laurus-php instead of running cargo build -p laurus-php
directly.
Project layout
laurus-php/
├── Cargo.toml # Rust crate manifest
├── composer.json # Composer package definition
├── composer.lock # Locked dependency versions
├── src/ # Rust source (ext-php-rs binding)
│ ├── lib.rs # Module registration
│ ├── index.rs # Index class
│ ├── schema.rs # Schema class
│ ├── query.rs # Query classes
│ ├── search.rs # SearchRequest / SearchResult / Fusion
│ ├── analysis.rs # Tokenizer / Filter / Token
│ ├── convert.rs # PHP <-> DataValue conversion
│ └── errors.rs # Error mapping
├── tests/ # PHPUnit tests
│ └── LaurusTest.php
└── examples/ # Runnable PHP examples
Build & Test
Prerequisites
- Rust 1.85 or later (edition 2024)
- Cargo (included with Rust)
- protobuf compiler (
protoc) – required for buildinglaurus-server
Building
# Build all crates
cargo build
# Build with specific features
cargo build --features embeddings-candle
# Build in release mode
cargo build --release
Testing
# Run all workspace tests (default features)
cargo test
# Run a specific test by name
cargo test <test_name>
# Run tests for a specific crate
cargo test -p laurus
cargo test -p laurus-cli
cargo test -p laurus-server
cargo test -p laurus-mcp
Language binding tests
Each language binding has its own toolchain (Python virtualenv, Node.js
npm, Ruby Bundler, PHP Composer, wasm32-unknown-unknown target). The
Makefile wraps these so each target sets up the toolchain before
running the suite:
make test-laurus-python # cargo test -p laurus-python + pytest via Maturin
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 is excluded from the Cargo workspace because of links = "clang" conflicts with laurus-ruby; it builds and tests as a
standalone crate via the Makefile target above. See
Makefile for the full target list including the
matching format-laurus-* / lint-laurus-* / build-laurus-*
variants.
Linting
# Run clippy with warnings as errors
cargo clippy -- -D warnings
Formatting
# Check formatting
cargo fmt --check
# Apply formatting
cargo fmt
Documentation
API Documentation
# Generate and open Rust API docs
cargo doc --no-deps --open
mdBook Documentation
# Build the documentation site
mdbook build docs
# Start a local preview server (http://localhost:3000)
mdbook serve docs
# Lint markdown files
markdownlint-cli2 "docs/src/**/*.md"
Benchmarking
This guide describes how to run laurus benchmarks, how to capture and compare baselines, and how to report results in pull requests.
The benchmark suite lives in laurus/benches/ and is built on Criterion. Hygiene rules — deterministic seeds, file-level documentation, sanity asserts, and sample_size policy — are codified in laurus/benches/common.rs.
Suite overview
| File | Scope |
|---|---|
bkd_bench.rs | BKD tree range search, intersect, and build (1D / 2D / 3D, 10k / 100k / 1M points) |
distance_bench.rs | DistanceMetric::distance for cosine, Euclidean, Manhattan, dot product (single dimension today; sweep tracked in #424) |
lexical_search_bench.rs | End-to-end lexical search through Engine::search for term, boolean, phrase, fuzzy, and DSL queries |
search_perf.rs | Posting iterator skip_to, BM25Scorer::score, SIMD batch scoring, compact posting conversion |
spell_correction_bench.rs | SpellingCorrector::correct with a fresh corrector per iteration (cold-state measurement) |
synonym_bench.rs | SynonymDictionary::get_synonyms lookup at 100 / 1k / 10k groups, plus build cost |
text_analysis_bench.rs | StandardAnalyzer::analyze single-document and batch (100 docs) |
vector_search_bench.rs | Flat / IVF / HNSW construction and search at 1k / 5k vectors, dim 128, top-10; plus a large-K IVF case (512 / 2048 clusters) for the cluster-selection path (#668) |
Each file declares its scope, scenarios, and how to filter in its top-of-file //! doc comment. Read it before running.
Running benchmarks
Run a single bench file:
cargo bench -p laurus --bench distance_bench
Filter by criterion id (substring match):
cargo bench -p laurus --bench distance_bench -- cosine
cargo bench -p laurus --bench vector_search_bench -- "HNSW Search/top10"
Compile-only smoke check (useful in CI or during refactors):
cargo bench -p laurus --bench distance_bench --no-run
Run every bench file in the workspace:
cargo bench -p laurus
Saving and comparing baselines
Criterion supports named baselines so you can compare a feature branch against main (or any other reference run).
Save a baseline named main from your current state:
cargo bench -p laurus --bench distance_bench -- --save-baseline main
Compare a subsequent run against that baseline:
cargo bench -p laurus --bench distance_bench -- --baseline main
The output prints a change: line per benchmark with a percentage and a verdict (No change in performance detected, Performance has improved, Performance has regressed). Criterion stores baselines under target/criterion/<bench-id>/<baseline>/.
Recommended flow for a perf PR:
- On
main(or before any change) —cargo bench --bench RELEVANT -- --save-baseline main. - Make the change on a branch.
- On the branch —
cargo bench --bench RELEVANT -- --baseline main. - Copy the
change:lines into the PR description.
Recommended environment
Microbenchmarks at the µs / ns scale are sensitive to system noise. For meaningful numbers:
-
CPU governor: set to
performance(Linux):sudo cpupower frequency-set -g performance -
Turbo boost: disable so frequency scaling does not skew results:
echo 1 | sudo tee /sys/devices/system/cpu/intel_pstate/no_turbo # IntelAMD systems and BIOS-level switches differ; consult vendor docs.
-
Background load: close browsers, IDEs, build watchers, and Docker. Anything sharing the CPU skews short-running benches.
-
Pinning (optional): pin to a fixed core if available:
taskset -c 2 cargo bench -p laurus --bench distance_benchOr use the bundled wrapper that pins the bench process to one core via
tasksetand raises scheduling priority vianicein one step:./scripts/bench-stable.sh --bench distance_bench ./scripts/bench-stable.sh --bench distance_bench -- --baseline mainThe wrapper is Linux-only. It does not touch the CPU governor or turbo state (those need root); set those up separately if you need the extra precision.
-
Repeat: re-run twice and compare. Differences below ~5 % on a tuned machine are noise; differences above that on a shared workstation may also be noise. Do not over-interpret a single run.
If you cannot stabilise the environment, say so explicitly in the PR (e.g. “measured on a shared laptop, expect ~10 % noise”) rather than presenting unstable numbers as authoritative.
Make targets
The Makefile exposes the common entry points:
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
For a single bench, pass BENCH=name:
make bench BENCH=distance_bench
make bench-baseline BENCH=distance_bench
make bench-compare BENCH=distance_bench
PR description template
When a PR claims a measurable performance change, paste a table like the following into the description:
## Performance
Environment: <CPU model>, 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`
Always include the commit SHAs of the baseline and the after-state so the comparison is reproducible. State the environment explicitly even when running on a tuned machine.
Adding a new benchmark
When adding a new bench file, follow the suite-wide hygiene rules from laurus/benches/common.rs:
- Use a deterministic seed via
common::DEFAULT_SEED(or thelcg_*helpers). Never callrand::rng(). - Add a top-of-file
//!doc comment listing scope, scenarios, run command, and filter examples. - Add a one-time sanity
assert!outside the timedb.iterblock so a regression that produces empty output cannot pass silently. - Pick
SAMPLE_SIZE_FAST(default, for sub-50 ms operations) orSAMPLE_SIZE_SLOW(construction paths). Do not invent intermediate values. - Register the file in
laurus/Cargo.tomlwith[[bench]] name = "..." harness = false. The crate setsautobenches = false, so files inbenches/are not picked up automatically.
If your bench needs to share helpers across files, extend benches/common.rs rather than duplicating code.
Continuous integration
CI does not currently run a regression-detection bench job. Each perf-changing PR is expected to post baseline-vs-after numbers manually, captured under the recommended environment.
A future iteration may add a smoke-set bench job that fails on large regressions; this is tracked under the umbrella issue #429.
Feature Flags
The laurus crate ships with no default features. Enable embedding support as needed.
Available Flags
| Feature | Description | Key Dependencies |
|---|---|---|
embeddings-candle | Local BERT embeddings via Hugging Face Candle | candle-core, candle-nn, candle-transformers, hf-hub, tokenizers |
embeddings-openai | OpenAI API embeddings | reqwest |
embeddings-multimodal | CLIP multimodal embeddings (text + image) | image, embeddings-candle |
embeddings-all | All embedding features combined | All of the above |
What Each Flag Enables
embeddings-candle
Enables CandleBertEmbedder for running BERT models locally on the CPU. Models are downloaded from Hugging Face Hub on first use.
[dependencies]
laurus = { version = "0.9", features = ["embeddings-candle"] }
embeddings-openai
Enables OpenAIEmbedder for calling the OpenAI Embeddings API. Requires an OPENAI_API_KEY environment variable at runtime.
[dependencies]
laurus = { version = "0.9", features = ["embeddings-openai"] }
embeddings-multimodal
Enables CandleClipEmbedder for CLIP-based text and image embeddings. Implies embeddings-candle.
[dependencies]
laurus = { version = "0.9", features = ["embeddings-multimodal"] }
embeddings-all
Convenience flag that enables all embedding features.
[dependencies]
laurus = { version = "0.9", features = ["embeddings-all"] }
Feature Flag Impact on Binary Size
Enabling embedding features adds dependencies that increase compile time and binary size:
| Configuration | Approximate Impact |
|---|---|
| No features (lexical only) | Baseline |
embeddings-candle | + Candle ML framework |
embeddings-openai | + reqwest HTTP client |
embeddings-multimodal | + image processing + Candle |
embeddings-all | All of the above |
If you only need lexical (keyword) search, you can use Laurus with no features enabled for the smallest binary and fastest compile time.
Project Structure
Laurus is organized as a Cargo workspace with nine crates: the core library, three first-party binaries (CLI, gRPC server, MCP server), and five language bindings.
Workspace Layout
laurus/ # Repository root
├── Cargo.toml # Workspace definition (members + workspace.package)
├── laurus/ # Core search engine library
│ ├── Cargo.toml
│ ├── src/
│ │ ├── lib.rs # Public API and module declarations
│ │ ├── engine.rs # Engine, EngineBuilder, SearchRequest
│ │ ├── analysis/ # Text analysis pipeline
│ │ ├── lexical/ # Inverted index and lexical search
│ │ ├── vector/ # Vector indexes (Flat, HNSW, IVF)
│ │ ├── embedding/ # Embedder implementations
│ │ ├── storage/ # Storage backends (memory, file, mmap)
│ │ ├── store/ # Document log (WAL)
│ │ ├── spelling/ # Spelling correction
│ │ ├── data/ # DataValue, Document types
│ │ └── error.rs # LaurusError type
│ └── examples/ # Runnable examples
├── laurus-cli/ # Command-line interface
│ ├── Cargo.toml
│ └── src/
│ ├── main.rs # CLI entry point (clap)
│ ├── cli.rs # Subcommand definitions
│ └── commands/ # Per-subcommand implementations
├── laurus-server/ # gRPC server + HTTP gateway
│ ├── Cargo.toml
│ ├── proto/laurus/v1/ # Protobuf service definitions
│ └── src/
│ ├── lib.rs # Server library
│ ├── config.rs # TOML configuration
│ ├── service/ # gRPC service implementations (tonic)
│ └── gateway/ # HTTP/JSON gateway (axum)
├── laurus-mcp/ # MCP (Model Context Protocol) stdio server
│ ├── Cargo.toml
│ └── src/
│ ├── lib.rs
│ ├── server.rs # rmcp tool router (12 tools)
│ └── convert.rs # JSON ↔ DataValue helpers
├── laurus-python/ # Python bindings (PyO3 + Maturin)
├── laurus-nodejs/ # Node.js bindings (NAPI-RS)
├── laurus-wasm/ # WebAssembly bindings (wasm-bindgen)
├── laurus-ruby/ # Ruby bindings (magnus + rb-sys)
├── laurus-php/ # PHP bindings (ext-php-rs)
└── docs/ # mdBook documentation
├── book.toml # English mdBook config
├── src/ # English source
│ └── SUMMARY.md # English table of contents
└── ja/ # Japanese mdBook (independent build)
├── book.toml
└── src/
└── SUMMARY.md
Crate Responsibilities
| Crate | Type | Description |
|---|---|---|
laurus | Library | Core search engine with lexical, vector, and hybrid search |
laurus-cli | Binary | CLI tool for index management, document CRUD, search, REPL, and serve / mcp launchers |
laurus-server | Library + Binary | gRPC server with optional HTTP/JSON gateway |
laurus-mcp | Binary | MCP server on stdio that proxies tool calls to a running laurus-server |
laurus-python | Dynamic library | Python package (PyPI) built with PyO3 / Maturin |
laurus-nodejs | Dynamic library | Node.js package (npm) built with NAPI-RS |
laurus-wasm | WebAssembly | npm package for browser and edge runtimes, built with wasm-bindgen |
laurus-ruby | Dynamic library | Ruby gem built with magnus and rb-sys |
laurus-php | PHP extension | PHP extension built with ext-php-rs (standalone — excluded from the workspace; see Build & Test) |
All binding crates and the three first-party binaries depend on laurus.
Design Conventions
- Module style: File-based modules (Rust 2018 edition style), not
mod.rssrc/tokenizer.rs+src/tokenizer/dictionary.rs- Not:
src/tokenizer/mod.rs
- Error handling:
thiserrorfor library error types,anyhowonly in binary crates - No
unwrap()/expect()in production code (allowed in tests) - Async: All public APIs use async/await with Tokio runtime
- Unsafe: Every
unsafeblock must have a// SAFETY: ...comment - Documentation: All public types, functions, and enums must have doc comments (
///) - Licensing: Dependencies must be MIT or Apache-2.0 compatible