Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Laurus

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

FeatureDescription
Lexical SearchFull-text search powered by an inverted index with BM25 scoring
Vector SearchApproximate nearest neighbor (ANN) search using Flat, HNSW, or IVF indexes
Hybrid SearchCombine lexical and vector results with fusion algorithms (RRF, WeightedSum)
Text AnalysisPluggable analyzer pipeline — tokenizers, filters, stemmers, synonyms
EmbeddingsBuilt-in support for Candle (local BERT/CLIP), OpenAI API, or custom embedders
StoragePluggable backends — in-memory, file-based, or memory-mapped
Query DSLHuman-readable query syntax for lexical, vector, and hybrid search
Pure RustNo 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"]
  1. Define a Schema — declare your fields and their types (text, integer, vector, etc.)
  2. Build an Engine — attach an analyzer for text and an embedder for vectors
  3. Index Documents — the engine routes each field to the correct index automatically
  4. Search — run lexical, vector, or hybrid queries and get ranked results

Document Map

SectionWhat You Will Learn
Getting StartedInstall Laurus and run your first search in minutes
ArchitectureUnderstand the Engine, its components, and data flow
Core ConceptsSchema, text analysis, embeddings, and storage
IndexingHow inverted indexes and vector indexes work internally
SearchQuery types, vector search, and hybrid fusion
Query DSLHuman-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 GuideBuild, 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
CrateTypeDescription
laurusLibraryCore search engine – lexical, vector, and hybrid search
laurus-cliBinaryCommand-line interface for index management and search
laurus-serverLibrary + BinarygRPC server with optional HTTP/JSON gateway
laurus-mcpBinaryMCP (Model Context Protocol) stdio server that proxies to laurus-server
laurus-pythoncdylibPython bindings via PyO3 / Maturin
laurus-nodejscdylibNode.js bindings via NAPI-RS
laurus-wasmWebAssemblyBrowser / edge bindings via wasm-bindgen
laurus-rubycdylibRuby bindings via magnus / rb-sys
laurus-phpPHP extensionPHP bindings via ext-php-rs (excluded from the workspace)

For details on each crate, see:

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
ComponentResponsibility
SchemaDeclares fields and their types; determines how each field is routed
LexicalStoreInverted index for keyword search (BM25 scoring)
VectorStoreVector index for similarity search (Flat, HNSW, or IVF)
DocumentLogWrite-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:

  1. Splits the schema — lexical fields go to LexicalIndexConfig, vector fields go to VectorIndexConfig
  2. Creates prefixed storage — each component gets an isolated namespace (lexical/, vector/, documents/)
  3. Initializes stores — LexicalStore and VectorStore are constructed with their configs
  4. 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 _id field is always added to the lexical config with KeywordAnalyzer (exact match)
  • A PerFieldAnalyzer wraps per-field analyzer settings; if you pass a simple StandardAnalyzer, it becomes the default for all text fields
  • A PerFieldEmbedder works 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:

  1. Filter (optional) — execute a filter query on the lexical index to get a set of allowed document IDs
  2. Search — run lexical and/or vector queries in parallel
  3. 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
BackendDescriptionBest For
MemoryStorageAll data in memoryTesting, small datasets, ephemeral use
FileStorageStandard file I/OGeneral 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

AspectDetail
Core structEngine — coordinates all operations
BuilderEngineBuilder — assembles Engine from Storage + Schema + Analyzer + Embedder
Schema splitLexical fields → LexicalIndexConfig, Vector fields → VectorIndexConfig
Write pathWAL → in-memory buffers → commit() → persistent storage
Read pathQuery → parallel lexical/vector search → fusion → ranked results
Storage isolationPrefixedStorage with lexical/, vector/, documents/ prefixes
Per-field dispatchPerFieldAnalyzer and PerFieldEmbedder route to field-specific implementations

Next Steps

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

  1. Installation — Add Laurus to your project and choose feature flags
  2. 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"]
StepWhat Happens
Create StorageChoose where data lives — in memory, on disk, or memory-mapped
Define SchemaDeclare fields and their types (text, integer, vector, etc.)
Build EngineAttach an analyzer (for text) and an embedder (for vectors)
Index DocumentsAdd documents; the engine routes fields to the correct index
SearchRun 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.13"
tokio = { version = "1", features = ["full"] }

Feature Flags

Laurus ships with a minimal default feature set. Enable additional features as needed:

FeatureDescriptionUse Case
native (default)Core library (lexical search, storage, analyzers — no embedding)Keyword search only
embeddings-candleLocal BERT embeddings via Hugging Face CandleVector search without external API
embeddings-openaiOpenAI API embeddings (text-embedding-3-small, etc.)Cloud-based vector search
embeddings-multimodalCLIP embeddings for text + image via CandleMultimodal (text-to-image) search
embeddings-allAll embedding features aboveFull embedding support

Examples

Lexical search only (no embeddings needed):

[dependencies]
laurus = "0.13"

Vector search with local model (no API key required):

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

Vector search with OpenAI:

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

Everything:

[dependencies]
laurus = { version = "0.13", 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 optional use_mmap for 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:

MethodField TypeExample Values
add_text_fieldText (full-text searchable)"Hello world"
add_integer_field64-bit integer42
add_float_field64-bit float3.14
add_boolean_fieldBooleantrue / false
add_datetime_fieldUTC datetime2024-01-15T10:30:00Z
add_hnsw_fieldVector (HNSW index)[0.1, 0.2, ...]
add_flat_fieldVector (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.

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

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.

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, GeoDistanceQuery / GeoBoundingBoxQuery, BooleanQuery, SpanQuery.

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").

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.

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 (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 instance
  • per_field_analyzer() – Create a PerFieldAnalyzer with KeywordAnalyzer for specific fields
  • MockEmbedder – A mock Embedder implementation 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.

TypeRust TypeSchemaBuilder MethodDescription
TextTextOptionadd_text_field()Full-text searchable; tokenized by the analyzer
IntegerIntegerOptionadd_integer_field()64-bit signed integer; supports range queries
FloatFloatOptionadd_float_field()64-bit floating point; supports range queries
BooleanBooleanOptionadd_boolean_field()true / false
DateTimeDateTimeOptionadd_datetime_field()UTC timestamp; supports range queries
GeoGeoOptionadd_geo_field()Latitude/longitude pair; supports radius and bounding box queries
Geo3dGeo3dOptionadd_geo3d_field()3D ECEF Cartesian point (x, y, z in metres); supports 3D distance, bounding box, and k-NN queries. See 3D Geographic Search.
BytesBytesOptionadd_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 + doc values (all true)
let opt = TextOption::default();

// Customize
let opt = TextOption::default()
    .indexed(true)
    .stored(true)
    .multi_valued(false)
    .position_increment_gap(100)
    .term_vectors(true)
    .doc_values(true);
}
OptionDefaultDescription
indexedtrueWhether the field is searchable
storedtrueWhether the original value is stored for retrieval
multi_valuedfalseWhether the field accepts arrays of strings (Issue #1175); a term query matches if any element contains the term. See Multi-valued fields
position_increment_gap100Positions skipped between the elements of a multi-valued field (Lucene positionIncrementGap), so that a phrase query never spans two elements unless its slop reaches the gap; 0 numbers the elements as if concatenated. Ignored unless multi_valued is true
term_vectorstrueWhether term positions are stored. Phrase queries of two or more terms and span queries need them; with false, a search that runs them on this field is rejected with a query error. Term queries, including a quoted value that analyzes to one token, do not need them, and highlighting always re-tokenizes the stored text
doc_valuestrueWhether the value is also copied into DocValues, the column-oriented store sorting and faceting/aggregation read from

doc_values is not unique to TextOption — every lexical field option except BytesOption carries it (IntegerOption, FloatOption, BooleanOption, DateTimeOption, GeoOption, Geo3dOption). BytesOption has no such setting: a Bytes value is never written to DocValues regardless, since neither sorting nor faceting can do anything with it. The effective rule is: a DocValues column is written only when stored and doc_values are both true (and the value’s type isn’t Bytes); setting doc_values: false on a field that is never sorted or faceted on shrinks its segment footprint by skipping the second copy.

Vector Fields

Vector fields are indexed using vector indexes for approximate nearest neighbor (ANN) search.

TypeRust TypeSchemaBuilder MethodDescription
FlatFlatOptionadd_flat_field()Brute-force linear scan; exact results
HNSWHnswOptionadd_hnsw_field()Hierarchical Navigable Small World graph; fast approximate
IVFIvfOptionadd_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,                                // relative priority vs. other vector fields (issue #1084)
    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. When None, the searcher falls back to its built-in 50.
  • 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 least top_k (and top_k * rerank_factor when both are set) so the candidate heap is never undersized for the requested top_k.
  • Tracked under Issue #644.

See Vector Indexing for detailed parameter guidance.

Multi-Vector Fields

A MultiVector field holds all the token vectors of a document, for example the per-token embeddings of a ColBERT-style late-interaction model (typically 100–300 vectors of 128 dimensions per passage). It has no ANN index: it is never a vector-search target, and its vectors are read only to rescore candidates that another query (lexical, vector, or hybrid) already found.

TypeRust TypeSchemaBuilder MethodDescription
MultiVectorMultiVectorOptionadd_multi_vector_field()Per-document token vectors for late-interaction rescoring; not searchable
#![allow(unused)]
fn main() {
use laurus::{MultiVectorOption, Schema};
use laurus::vector::core::distance::DistanceMetric;

let schema = Schema::builder()
    .add_multi_vector_field(
        "body_colbert",
        MultiVectorOption::new(128).distance(DistanceMetric::Cosine), // Cosine (default) or DotProduct
    )
    .build();
}
  • Options: dimension (every token vector’s length), distance, storage, pool_factor and embedder. Only Cosine (vectors are L2-normalized when written, so the similarity is their dot product; a zero vector is kept as-is) and DotProduct are accepted for distance; other metrics are rejected when the index is created. storage picks the on-disk element kind of the token vectors — F32 (default, exact), F16, Int8, or Binary (1 bit per element; requires Cosine) — trading some precision for a smaller index; see MultiVector Storage for the size and quality figures. pool_factor (default 1, no pooling) keeps about 1 / pool_factor of each document’s token vectors by averaging similar ones at index time; see Token pooling. embedder names a token-level embedder, such as a candle_colbert one (MultiVectorOption::new(96).embedder("colbert")).
  • Values: a DataValue::VectorArray(Vec<Vec<f32>>) holding at least one and at most 8,192 vectors, each of the field’s dimension and with finite values. In JSON it is an array of equal-length numeric arrays ([[0.1, 0.2, ...], [0.3, 0.4, ...]]). When a token-level embedder serves the field (named in embedder, or registered through EngineBuilder::embedder), a text value works too: it is embedded into token vectors when the document is indexed (Issue #1349), and a document is rejected if the field has no such embedder.
  • Not stored: the token vectors are kept only in the vector index (about 150 KB per document for 300 × 128 f32), so get_documents and search results never include the field.
  • Not queryable: a field-less vector query and a prefix field selector skip it, naming it in a vector query is an error, and so is naming it in a DSL query.
  • Schema changes: changing its dimension, distance or embedder, or changing it to or from another vector field type, is a destructive change. Changing storage is a Reindex change instead — the stored values are decoded and re-encoded, so Engine::update_field(.., UpdateFieldOptions { reindex: true, .. }) rebuilds the field’s existing segments under the new on-disk kind (precision a lossy kind already dropped does not come back). Turning pooling on (pool_factor from 1 to more) is a Reindex change too: the stored token vectors are pooled. Changing or turning off a pool_factor already in use is destructive, because pooled token vectors cannot be restored. An option the field’s index would reject, such as Binary with DotProduct or a pool_factor of 0, is rejected before any data is touched.

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:

MethodBehaviorUse Case
put_document(id, doc)Upsert — if a document with the same ID exists, it is replacedStandard document indexing
add_document(id, doc)Append — adds the document as a new chunk; multiple chunks can share the same IDChunked/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

MethodValue TypeDescription
add_text(name, value)StringAdd a text field
add_integer(name, value)i64Add an integer field
add_float(name, value)f64Add a float field
add_boolean(name, value)boolAdd 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_bytes_array(name, values)Vec<(Vec<u8>, Option<String>)>Add a multi-valued binary field, each element carrying its own optional MIME type
add_vector_array(name, vectors)Vec<Vec<f32>>Add the token vectors of a MultiVector field
add_field(name, value)DataValueAdd 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
    GeoArray(Vec<GeoPoint>),         // multi-valued 2D geo field
    GeoEcefArray(Vec<GeoEcefPoint>), // multi-valued 3D ECEF field
    DateTimeArray(Vec<DateTime<Utc>>), // multi-valued datetime field
    BoolArray(Vec<bool>),            // multi-valued boolean field
    TextArray(Vec<String>),          // multi-valued text field
    BytesArray(Vec<(Vec<u8>, Option<String>)>), // multi-valued bytes field, per-element MIME
    VectorArray(Vec<Vec<f32>>),      // token vectors of a MultiVector 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. Declaring such a field is rejected when the index is created, and documents that carry user-supplied _-prefixed keys are rejected at ingest time — so the field can never hold a value either way. The language bindings check even earlier: their field-adding methods (add_*_field / add*Field) reject such a name as soon as it is passed. See Field Naming for the exact error and how an index created before this check is handled.

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:

PolicyBehaviour on an undeclared field
StrictReject the document with a descriptive error.
Dynamic (default)Infer the field’s type from the value and add it to the schema.
IgnoreSilently 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 valueInferred field type
stringText (BM25 via the inverted index)
integerInteger (BKD tree)
floatFloat (BKD tree)
boolBoolean
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 rangeGeo
object with all three numeric keys x, y, z (finite values, ECEF meters)Geo3d
array of geo objects (e.g. [{"lat": 35.6, "lon": 139.7}, ...])Geo with multi_valued = true
array of x/y/z objectsGeo3d with multi_valued = true
array of strings that are all RFC 3339 (e.g. ["2024-01-01T00:00:00Z", "2024-06-15T21:00:00+09:00"])DateTime with multi_valued = true
any other array of strings (e.g. ["rust", "search"])Text with multi_valued = true
array of booleans (e.g. [true, false])Boolean with multi_valued = true
object with a data key (base64-encoded string) and an optional mime string keyBytes value
array of equal-length, non-empty numeric arrays (e.g. [[0.1, 0.2], [0.3, 0.4]])VectorArray value (token vectors)

Vector fields (Hnsw, Flat, Ivf, MultiVector) are never inferred: they must be declared in the schema explicitly, since dimension, distance metric, and embedder configuration cannot be recovered from a value alone. An undeclared field carrying token vectors is rejected. Bytes values can be parsed from the {data, mime} object shape above, but an undeclared field carrying a Bytes value is still rejected rather than auto-registered — bytes fields must always be declared explicitly, same as vector fields. Mixing markers from more than one shape (2D lat/lon, 3D x/y/z, or bytes data) in a single object is rejected as ambiguous; use exactly one shape per object.

Multi-valued 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).

Geo and Geo3d fields accept multi_valued = true as well. Every point of a multi-valued geo field becomes its own entry in the field’s BKD tree (2 dimensions for Geo, 3 for Geo3d), so distance and bounding-box queries — and, for Geo3d, nearest queries — match a document if any of its points satisfies the predicate. A document is reported once, scored by its closest matching point (for the 2D bounding-box query, the point closest to the box centre). Under the Dynamic policy an array that mixes 2D and 3D objects, or numbers and objects, is rejected; each element gets the same range validation as a single point.

DateTime fields accept multi_valued = true as well (Issue #1184). Every instant of a multi-valued datetime field becomes its own 1-dimensional entry in the field’s BKD tree, so DateTimeRangeQuery, a NumericRangeQuery on the field, and DSL date ranges such as seen_at:[2024-06-01 TO 2024-12-31] match a document if any of its instants is in range (Lucene-style “any match” with constant scoring — there is no per-match BM25 weighting). A document is reported once even when several of its instants match, and sub-second instants are honored (the bounds are encoded as fractional seconds). Under the Dynamic policy an array whose elements are all RFC 3339 strings infers a multi-valued DateTime; only RFC 3339 is recognised here (not the naive or date-only forms the query DSL accepts), and an array of strings where any element is not RFC 3339 infers a multi-valued Text instead (Issue #1175) — it is not an error. Note the asymmetry: a single RFC 3339 string still infers Text (unchanged, so existing text fields keep their behaviour), while an array of RFC 3339 strings infers a multi-valued DateTime — declare the field in the schema if you want a single-valued DateTime.

Boolean fields accept multi_valued = true as well (Issue #1180), but the mechanism differs from every other multi-valued type: a Boolean field has no BKD points. Every element of a multi-valued boolean field is indexed as its own "true" / "false" term posting, so TermQuery and DSL term queries such as flags:true match a document if any element equals the queried value (Lucene-style “any match”). A document is reported once for a given term even when several of its elements equal it (postings are aggregated per document and term), and Boolean fields remain not range-queryable. Because these are ordinary term postings, repeated elements affect BM25 scoring the way they do in Lucene: [true, true] yields one posting with term frequency 2 and field length 2, so it scores higher than [true] for flags:true, while [true, false] scores slightly lower than [true] because of length normalization. There is no dedupe and no constant scoring for term queries (constant-scored term queries are tracked in Issue #580). Under the Dynamic policy a JSON array whose elements are all booleans (e.g. [true, false]) infers a multi-valued Boolean; a mixed array such as [true, 1] is rejected with the existing error that array fields must contain only numeric values. Note the asymmetry with scalars: a single "true" string coerces to a Bool on a declared Boolean field, and a string array such as ["true", "false"] sent to a declared multi-valued Boolean field is parsed element-wise under the same rule; on an undeclared field, however, that array infers a multi-valued Text (Issue #1175), not a Boolean — use JSON booleans for arrays you want inferred as booleans. Turning multi_valued on for an existing Boolean field is a metadata-only change; turning it off requires a reindex when the field is stored, and is Destructive when the field is stored: false — unlike the BKD-backed types there is no point tree to rebuild from, only the stored value.

Text fields accept multi_valued = true as well (Issue #1175), together with a second option, position_increment_gap (default 100, matching Lucene and Elasticsearch). Every element of a multi-valued text field is analyzed on its own by the field’s analyzer, and the tokens of all elements are appended to one ascending position sequence: the first token of element n + 1 is placed position_increment_gap positions after the last token of element n. The gap is charged per element, even for an element that analyzes to no tokens. Everything else follows from that numbering:

  • A TermQuery (or a DSL term such as tags:rust) matches a document if any element contains the term (Lucene-style “any match”).
  • A PhraseQuery cannot span two elements unless its slop is at least position_increment_gap — the threshold is exactly slop == gap. With the default gap, ["hello world", "foo bar"] does not match the phrase "world foo" at slop 0 through 99 and does match at slop 100. Span queries (SpanNearQuery) inherit the same protection.
  • A gap of 0 numbers the elements as if they had been concatenated, so phrases then match across element boundaries. The gap is never a “restart at 0”.
  • Repeated terms across elements raise the term frequency (and hence the BM25 score) but not the hit count, like the other multi-valued types. The field length is the number of positions, not the number of tokens — a synonym stacked on another token (position_increment = 0) shares its position and is not counted again — so the gap itself does not inflate BM25 length normalization.
  • Positions must be stored for phrase queries of two or more terms to work at all (term_vectors: true, the default); without them the search is rejected with a query error, for multi-valued and single-valued fields alike. A quoted value that analyzes to one token, such as tags:"lang/rust" on a keyword field, is a term match and does not need positions.

A multi-valued text field is highlighted element by element — only the elements that match contribute fragments, and a fragment never straddles two elements; see Highlighting. Under the Dynamic policy a JSON array whose elements are all strings infers a multi-valued DateTime when every element parses as RFC 3339 and a multi-valued Text otherwise (e.g. ["rust", "search"]); a single string still infers Text, never a datetime. Turning multi_valued on for an existing Text field is a metadata-only change; turning it off requires a reindex when the field is stored and is Destructive when it is stored: false — like Boolean, there is no BKD tree to rebuild from. Changing position_increment_gap requires a reindex when the field stores positions (term_vectors: true) — Reindex when stored, Destructive otherwise — because the existing postings were numbered under the old gap; when term_vectors is false the gap is unobservable and the change is metadata-only.

Bytes fields accept multi_valued = true as well (Issue #1176), but the mechanism differs from every other multi-valued type, Boolean included: BytesOption has no indexed flag at all — a Bytes value is never lexically indexed, so there are no BKD points and no term postings, and multi_valued has no “any match” query semantics to speak of. It only governs the stored shape and the ingestion arity. DataValue::BytesArray is Vec<(Vec<u8>, Option<String>)>: unlike the bare Vec<T> every other *Array variant uses, each element mirrors the scalar Bytes(Vec<u8>, Option<String>) shape exactly, carrying its own MIME type. A Bytes value — scalar or array — can never be inferred for an undeclared field under the Dynamic policy (see Type inference rules above); a multi-valued bytes field must always be declared explicitly. Once declared, a single Bytes value or a base64 Text string is auto-wrapped into a one-element array (a Text element is decoded as base64, the same rule the scalar field uses), and an empty numeric array ([]) is accepted as an empty byte-value list. Turning multi_valued on for an existing Bytes field is a metadata-only change; turning it off requires a reindex when the field is stored, and is Destructive when the field is stored: false — like Boolean and Text, there is no point tree or postings to rebuild from, only the stored value.

Faceting expands every multi-valued type element by element (Issue #1187): each integer, float, boolean, datetime or text element becomes its own facet value (a text element containing / becomes a hierarchical path), counted once per document even when an element repeats; geo and bytes arrays are not facetable and contribute nothing. See Faceting for the element formats.

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 (the error tells you to declare the field with multi_valued = true). An empty array sent to a multi-valued geo, datetime, boolean or text field is accepted and simply has no points, instants or terms, so it matches no spatial, range, term or phrase query; an empty array on a multi-valued bytes field is accepted the same way and is simply stored as an empty list — a Bytes field is never queried, so there is no query for it to not match.

Segments that contain multi-valued geo, datetime, boolean, text or bytes values use new stored-field type tags and cannot be read by builds that predate these features; there is no format version bump, so an older reader fails loudly instead of misreading the data. Stored multi-valued datetimes are kept at microsecond precision (one i64 Unix microsecond per instant, so sub-microsecond digits are truncated), whereas a single-valued DateTime keeps its full precision. Stored multi-valued booleans are written one byte per element (not bit-packed). Stored multi-valued text values are written as an element count followed by each string with its length prefix — the same body as a single-valued text value. Stored multi-valued bytes values are written as an element count followed by, for each element, the same (MIME, then data) length-prefixed layout the scalar Bytes value uses (an empty MIME string means None).

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 typeIncoming valueResult
IntegerInt64stored as-is
IntegerFloat64(3.14)truncated to 3 (information loss — see warning below)
IntegerText("42")parsed as 42
IntegerText("abc")error
FloatInt64widened to f64
FloatText("3.14")parsed
BooleanInt64(0) / Int64(1)false / true
BooleanText("true"/"false")parsed (case-insensitive)
Textany scalarstringified
BytesText(s)decoded as base64 (error if s is not valid base64)
Geo / Geo3danything other than matching varianterror
Geo / Geo3d (single-valued)GeoArray / GeoEcefArrayerror (declare multi_valued = true)
Geo / Geo3d with multi_valued = trueGeoArray / GeoEcefArraystored as-is
Geo / Geo3d with multi_valued = truematching single pointwrapped into a one-element array
Geo / Geo3d with multi_valued = trueempty numeric array ([])empty point list
DateTime (single-valued)DateTimeArrayerror (declare multi_valued = true)
DateTime with multi_valued = trueDateTimeArraystored as-is
DateTime with multi_valued = truesingle DateTime or RFC 3339 Textwrapped into a one-element array
DateTime with multi_valued = trueempty numeric array ([])empty instant list
DateTime with multi_valued = trueanything elseerror
Boolean (single-valued)BoolArrayerror (declare multi_valued = true)
Boolean with multi_valued = trueBoolArraystored as-is
Boolean with multi_valued = truesingle Bool, Int64(0) / Int64(1) or Text("true"/"false")wrapped into a one-element array (same scalar rule as above)
Boolean with multi_valued = trueInt64Arraywidened element-wise under the same 0 / 1 rule ([0, 1] → [false, true]; [0, 2] is an error: only 0 and 1 are accepted)
Boolean with multi_valued = trueempty numeric array ([])empty boolean list (matches no term query)
Boolean with multi_valued = trueanything elseerror
Integer / Float with multi_valued = trueBoolArraywidened element-wise to 0 / 1 (a single-valued Integer / Float rejects it, asking for multi_valued = true)
Text (single-valued)TextArrayerror (declare multi_valued = true)
Text with multi_valued = trueTextArraystored as-is
Text with multi_valued = truesingle Text, Int64, Float64, Bool or DateTimestringified and wrapped into a one-element array (same scalar rule as above)
Text with multi_valued = trueInt64Array / Float64Array / BoolArray / DateTimeArraystringified element-wise (datetimes as RFC 3339)
Text with multi_valued = trueNull or empty numeric array ([])empty string list (matches no term or phrase query)
Text with multi_valued = trueanything else (geo arrays, vectors, bytes)error
Integer / Float / Boolean / DateTime with multi_valued = trueTextArrayparsed element-wise under the same rule as a scalar Text (["1", "2"] → [1, 2]; a bad element is an error naming it). A single-valued Integer / Float / Boolean / DateTime rejects it, asking for multi_valued = true
Bytes (single-valued)BytesArrayerror (declare multi_valued = true)
Bytes with multi_valued = trueBytesArraystored as-is
Bytes with multi_valued = truesingle Bytes or base64 Textwrapped into a one-element array (same scalar rule as above)
Bytes with multi_valued = trueTextArrayeach element decoded as base64 (same scalar rule as above, applied element-wise; MIME is always None)
Bytes with multi_valued = trueempty numeric array ([])empty byte-value list
Bytes with multi_valued = trueanything elseerror
vector (Hnsw/Flat/Ivf)Text or Bytespassed through unchanged for the field’s embedder
vector (Hnsw/Flat/Ivf)numeric arraycast element-wise to f32
vector (Hnsw/Flat/Ivf)VectorArrayerror (token vectors need a MultiVector field)
MultiVectorVectorArray with 1–8,192 vectors of the field’s dimension, all finitestored as-is
MultiVectoranything else (a single vector, a flat numeric array, text)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 Integer field truncates incoming Float values (3.14 → 3, -3.9 → -3). Ingest does not fail.
  • A Float field may lose precision for very large integers that do not fit in an f64 mantissa.
  • A Text field accepts any scalar by stringifying it, losing the original type.
  • Ignore drops 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). The Dynamic policy 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, removed from, or changed on a running engine at runtime.

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.

If a schema-persist hook was configured via Engine::builder().persist_schema_with(hook), add_field/delete_field/ update_field call it themselves before returning, persisting the schema on the caller’s behalf (both laurus-cli and laurus-server configure this hook to write schema.toml). Otherwise, as before, 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.

Changing a Field

Use Engine::update_field() to change an existing field’s type/options on a running engine.

let outcome = engine.update_field(
    "title",
    FieldOption::Text(TextOption::default().analyzer("english")),
    UpdateFieldOptions { reindex: true, ..Default::default() },
).await?;

The requested change is classified into one of three kinds, reported as outcome.classification:

  • MetadataOnly — no existing data is affected; always applied (e.g. an HNSW field’s default_ef_search).
  • Reindex — can be rebuilt from the field’s already-stored values (e.g. a text field’s analyzer or — while term_vectors is on — its position_increment_gap, indexed: false → true, or an HNSW field’s m/ef_construction).
  • Destructive — cannot be rebuilt from existing data; applying it discards the field’s data (e.g. a vector field’s dimension/embedder/ distance, a type change on a stored: false field, or turning multi_valued off on a stored: false Boolean, Text or Bytes field).

Reindex and Destructive changes are rejected unless you explicitly pass UpdateFieldOptions { reindex: true, .. } — an opt-in gate against accidentally triggering an expensive rebuild or a data-losing change.

Applying a Destructive change records the field’s name in the schema’s pending_reindex set. This is a visibility mechanism against the known Solr failure mode where the schema and the index silently drift apart — check it via GetSchema / laurus get schema to find fields whose data no longer matches the schema, and re-ingest their documents as needed.

A vector field’s registered embedder follows its new embedder option (Issue #1354): naming one registers it, and removing the name unregisters the previous one, so the field falls back to the default embedder (text sent to it is rejected when that default is PrecomputedEmbedder). A change to a field that names no embedder, before or after, leaves an embedder registered directly through EngineBuilder::embedder in place.

Pass UpdateFieldOptions { dry_run: true, .. } to see how a change would be classified without applying anything.

Schema Design Tips

  1. Separate lexical and vector fields — a field is either lexical or vector, never both. For hybrid search, create separate fields (e.g., body for text, body_vec for vector).

  2. Use KeywordAnalyzer for exact-match fields — category, status, and tag fields should use KeywordAnalyzer via PerFieldAnalyzer to avoid tokenization.

  3. Choose the right vector index — use HNSW for most cases, Flat for small datasets, IVF for very large datasets. See Vector Indexing.

  4. Set default fields — if you use the Query DSL, set default fields so users can write hello instead of body:hello.

  5. Use the schema generator — run laurus create schema to 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:

  1. Char Filters — normalize raw text at the character level before tokenization
  2. Tokenizer — splits text into raw tokens (words, characters, n-grams)
  3. 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:

FieldTypeDescription
textStringThe token text
positionusizePosition in the original text
start_offsetusizeStart byte offset in original text
end_offsetusizeEnd byte offset in original text
position_incrementusizeDistance from previous token
position_lengthusizeSpan of the token (>1 for synonyms)
boostf32Token-level scoring weight
stoppedboolWhether marked as a stop word
metadataOption<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 _id field is always analyzed with KeywordAnalyzer regardless 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. The names of the parameter-less built-ins are reserved, so give the pipeline a name of its own (see Referencing an analyzer).

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 FilterDescription
UnicodeNormalizationCharFilterUnicode normalization (NFC, NFD, NFKC, NFKD)
MappingCharFilterReplaces character sequences based on a mapping dictionary
PatternReplaceCharFilterReplaces characters matching a regex pattern
JapaneseIterationMarkCharFilterExpands 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)
// "㌂" → "アンペア"
}
FormDescription
NFCCanonical decomposition followed by canonical composition
NFDCanonical decomposition
NFKCCompatibility decomposition followed by canonical composition
NFKDCompatibility 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

TokenizerDescription
RegexTokenizerUnicode word boundaries; splits on whitespace and punctuation
UnicodeWordTokenizerSplits on Unicode word boundaries
WhitespaceTokenizerSplits on whitespace only
WholeTokenizerReturns the entire input as a single token
LinderaTokenizerJapanese morphological analysis (Lindera/MeCab)
NgramTokenizerGenerates n-gram tokens of configurable size

Token Filters

FilterDescription
LowercaseFilterConverts tokens to lowercase
StopFilterRemoves common words (“the”, “is”, “a”)
StemFilterReduces words to their root form (“running” → “run”)
SynonymGraphFilterExpands tokens with synonyms from a dictionary
BoostFilterAdjusts token boost values
LimitFilterLimits the number of tokens
StripFilterStrips leading/trailing whitespace from tokens
FlattenGraphFilterFlattens token graphs. Indexing already does this, so leave it out of an analyzer that also parses queries (see The token graph)
RemoveEmptyFilterRemoves 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);  // stored on each synonym token, but not yet read by
                        // scoring (see below)
}

The boost parameter is stored on each synonym Token, but neither the indexer nor the query parser reads it today, so it has no effect on scoring or matching — a synonym match currently scores exactly like an exact match (see Searching with synonyms below). Set it if you plan to consume it from a custom pipeline stage; do not rely on it to weight synonym matches lower.

Matching entries of several words

An entry of several words matches consecutive tokens when all of them are alphanumeric (token type Alphanum or Num), so English words separated by spaces match, or when their offsets touch (each token’s end_offset is the next one’s start_offset), as the CJK words a morphological tokenizer splits do. The type comes from Token::metadata, so a token without one needs touching offsets. With 東京大学 in the dictionary, the text 東京大学 split into 東京 and 大学 matches it, but 東京 大学 does not: a space separates the two tokens.

The token graph

The filter stacks each synonym on the word it expands (position_increment = 0), so the output is a graph: a token is an arc from its position to its position + position_length. The matched words and every synonym run from the same start node to the same end node. The last token of a shorter alternative spans the remaining positions, and the token after the match starts at the end node. For ml tutorial with ml and machine learning as synonyms:

TokenPositionposition_incrementposition_length
ml012
machine001
learning111
tutorial211

A member of several words has inner nodes of its own, so no arc holds words of two members. For ml with machine learning and statistical machine learning as synonyms:

TokenArc
ml0 → 4
machine0 → 1
statistical0 → 2
learning1 → 4
machine2 → 3
learning3 → 4

The index stores no position_length, so it lays the graph out on its longest path: the members’ words share positions there. Above, ml, machine and statistical are stored at 0, learning and machine at 1, and learning at 2. A stacked token is stored at its word’s position. Positions otherwise stay dense: a stop word removed by StopFilter leaves no gap. The index does this itself, so an analyzer needs no FlattenGraphFilter. Adding one would also flatten the graph that quoted values are matched through, because the engine parses queries with the same analyzer.

Searching with synonyms

An engine uses a field’s analyzer both to index and to parse queries, so synonyms are expanded on both sides. An unquoted word matches any of its synonyms. A quoted value is matched through the graph (see Phrase Query): "big" with big and large as synonyms matches either word, and "a big dog" also matches “a large dog”. Each member of a group is its own phrase, so with the members above "ml" matches ml, machine learning or statistical machine learning, and not “statistical learning”. Members of the same length are separate phrases too. The phrases of a quoted value are matched together through the graph, so their number, which multiplies with each synonym in the value, sets no limit.

A position with several stacked alternatives is scored as one blended term (a SynonymQuery), not as a sum of each alternative’s independent score: a document holding both big and large scores the same for "big" as one that repeats big twice, not twice as much. Field length also counts positions, not tokens, so a stacked synonym does not lengthen a document either. See BM25.

Because the index keeps positions but not position_length, index-time expansion of multi-word synonyms behaves as in Lucene:

  • A phrase may start or end inside a synonym. "learning is" matches “ml is fun”, which reads “machine learning is fun” with the synonym.
  • When a group has several multi-word members, their words share positions in the index, so a document with one member matches a phrase that mixes two members’ words: “ml is fun” matches "statistical learning".

Other points to keep in mind:

  • keep_original = false replaces the matched word with the other members of its group, so the word itself is neither indexed nor searched. Use keep_original = true for search.
  • Filters that remove tokens, such as StopFilter, RemoveEmptyFilter and LimitFilter, can follow SynonymGraphFilter. A removed word leaves no position inside the graph either: with statue of liberty and lady liberty as synonyms and of as a stop word, "statue of liberty" matches both “statue of liberty” and “lady liberty”. A StopFilter before SynonymGraphFilter removes of before the synonym filter sees it, so that member never matches.
  • A removed word with an alternative at its position, such as a stacked one-word synonym, leaves its position to it: with the and a as synonyms, removing the leaves a there, and no path skips the word. LimitFilter cuts every path at the same token.
  • Documents indexed before Issue #1252 numbered stacked tokens one after another, and documents indexed before Issue #1257 counted every stacked token toward the field length (inflating it). Put them again after upgrading if their fields use SynonymGraphFilter: merging segments copies both the stored positions and the field length unchanged.

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:

VariantDescription
EmbedInput::Text(&str)Text input
EmbedInput::Bytes(&[u8], Option<&str>)Binary input with optional MIME type (for images)

Token-Level Embedders

Late-interaction models such as ColBERT turn an input into one vector per token instead of one vector in total, and encode queries and documents differently. Such an embedder also implements TokenEmbedder and returns itself from Embedder::as_token_embedder (Issue #1349):

#![allow(unused)]
fn main() {
#[async_trait]
pub trait TokenEmbedder: Send + Sync + Debug {
    async fn embed_tokens(&self, inputs: &[EmbedInput<'_>], role: EmbedRole)
        -> Result<Vec<Vec<Vector>>>;
    fn token_dimension(&self) -> usize;
}

pub enum EmbedRole { Query, Document }
}

A multi-vector field embeds text only through this trait. An embedder without it (any single-vector model) is rejected rather than silently producing a one-token document.

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
}
PropertyValue
Modelsentence-transformers/all-MiniLM-L6-v2
Dimensions384
RuntimeLocal (CPU)
First-run download~80 MB

It encodes like sentence-transformers, and its vectors match sentence-transformers’ within 1e-6 per element for all-MiniLM-L6-v2 and paraphrase-multilingual-MiniLM-L12-v2:

  • An input is truncated to the model’s max_seq_length from sentence_bert_config.json (256 tokens for all-MiniLM-L6-v2, 128 for paraphrase-multilingual-MiniLM-L12-v2), special tokens included. A repository without that file falls back to the truncation length in tokenizer.json, then to the model’s max_position_embeddings. The padding configured in tokenizer.json is ignored.
  • The vector is the mean of the token vectors, L2-normalized. This is done for every model, also for one whose sentence-transformers pipeline does not normalize (such as paraphrase-multilingual-MiniLM-L12-v2) or pools differently.

CandleBertEmbedder::with_options(model, CandleBertOptions::default().revision("<commit>")) pins the model to a commit.

Migration note (Issue #1340): earlier versions passed the attention mask to the model as token type ids and padded every input to the tokenizer.json length, so their vectors were off (for all-MiniLM-L6-v2, a cosine of only 0.61–0.70 with sentence-transformers’ on short texts). candle_bert vectors are now different, and long inputs keep up to max_seq_length tokens instead of 128 for all-MiniLM-L6-v2. Re-embed indexes built with candle_bert: otherwise new query vectors are compared with old document vectors.

Migration note (Issue #1355): earlier versions cached downloaded models under ~/.cache/huggingface/models--* instead of hf-hub’s own default ~/.cache/huggingface/hub/models--* (also ignoring HF_HUB_CACHE/XDG_CACHE_HOME). Models now download to hf-hub’s default location, shared with the Python huggingface_hub library. Existing downloads under the old path are not reused and can be deleted.

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
}
PropertyValue
Modeltext-embedding-3-small (or any OpenAI model)
Dimensions1536 (for text-embedding-3-small)
RuntimeRemote API call
RequiresOPENAI_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
}
PropertyValue
Modelopenai/clip-vit-base-patch32
Dimensions512
Input typesText AND images
Use caseText-to-image search, image-to-image search

Migration note (Issue #1355): earlier versions cached downloaded models under ~/.cache/huggingface/models--* instead of hf-hub’s own default ~/.cache/huggingface/hub/models--* (also ignoring HF_HUB_CACHE/XDG_CACHE_HOME). Models now download to hf-hub’s default location, shared with the Python huggingface_hub library. Existing downloads under the old path are not reused and can be deleted.

CandleColbertEmbedder

Runs a BERT-based ColBERT checkpoint locally and produces one vector per token, for late-interaction rescoring over a multi-vector field. It is a token-level embedder only: embed() returns an error.

Feature flag: embeddings-candle

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

// Uses the checkpoint's own settings (artifact.metadata).
let embedder = CandleColbertEmbedder::new("colbert-ir/colbertv2.0")?;

// Pin the model commit and override the lengths.
let embedder = CandleColbertEmbedder::with_options(
    "answerdotai/answerai-colbert-small-v1",
    CandleColbertOptions::default()
        .revision("934fa8bb4ce2284f4c2baa232d81aca4d076fa5e")
        .doc_maxlen(300),
)?;
}
Propertycolbert-ir/colbertv2.0answerdotai/answerai-colbert-small-v1
Token dimension12896
Query length (query_maxlen)3232
Document length (doc_maxlen)180300
LicenseMITApache-2.0
RuntimeLocal (CPU)Local (CPU)

It encodes like colbert-ai, the reference implementation:

query:    [CLS] [unused0] w1 … wn [SEP] [MASK] … [MASK]   exactly query_maxlen tokens
document: [CLS] [unused1] w1 … wn [SEP]                   at most doc_maxlen tokens
  • The query’s [MASK] padding is not attended to, but its vectors are kept: they expand the query.
  • Document vectors of punctuation tokens are dropped.
  • Every vector goes through the checkpoint’s linear projection and is L2-normalized.

The lengths, the markers and these switches come from the checkpoint’s artifact.metadata (colbert-ai’s defaults, 32 and 220, apply when the file is missing); CandleColbertOptions overrides the lengths. For both checkpoints above, its vectors match colbert-ai’s within 1e-6 per element.

Inference runs on the CPU in a blocking task, in batches of up to 32 inputs. As a guide, on an Apple M4 one query takes about 55 ms with colbertv2.0 and 18 ms with answerai-colbert-small-v1, and a document of about 110 tokens about 135 ms and 45 ms.

Pin revision to a commit: the write-ahead log keeps the text of documents that are not committed yet and embeds it again on recovery, and a changed model would produce different vectors. Only BERT-based checkpoints are supported (ModernBERT-based ones such as lightonai/GTE-ModernColBERT-v1 are not).

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
}

A multi-vector field whose embedder is a token-level one takes text the same way: the text is embedded into one vector per token, as a document.

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.

A late-interaction rescore takes its query as text too (RescoreOptions::late_interaction_text); the multi-vector field’s token-level embedder embeds it as a query.

Feature Flags Summary

Each embedder requires a specific feature flag to be enabled in Cargo.toml:

EmbedderFeature FlagDependencies
CandleBertEmbedderembeddings-candlecandle-core, candle-nn, candle-transformers, hf-hub, tokenizers
CandleColbertEmbedderembeddings-candlesame as CandleBertEmbedder
OpenAIEmbedderembeddings-openaireqwest
CandleClipEmbedderembeddings-multimodalcandle-core, candle-nn, candle-transformers, hf-hub, tokenizers, image
PrecomputedEmbedder(none – always available)–

The embeddings-all feature enables all embedding features at once. See Feature Flags for details.

Choosing an Embedder

ScenarioRecommended Embedder
Quick prototyping, offline useCandleBertEmbedder
Production with high accuracyOpenAIEmbedder
Text + image searchCandleClipEmbedder
Late-interaction rescoring (multi-vector fields)CandleColbertEmbedder
Pre-computed vectors from external pipelinePrecomputedEmbedder
Multiple models per fieldPerFieldEmbedder 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())
);
}
PropertyValue
DurabilityNone (data lost on process exit)
SpeedFastest
Use caseTesting, 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)?);
}
PropertyValue
DurabilityFull (persisted to disk)
SpeedModerate (disk I/O)
Use caseGeneral 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): true as of Issue #504. Set the LAURUS_NO_MMAP=1 environment variable when constructing the config (via FileStorageConfig::new) to fall back to buffered file I/O for debug sessions or hosts where mmap misbehaves.
  • Windows: false as 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. Set LAURUS_USE_MMAP=1 to 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;
}
PropertyValue
DurabilityFull (persisted to disk)
SpeedFast (OS-managed memory mapping; zero-copy posting decode)
Use caseDefault 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:

VariantDescription
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)
NullAbsent value

ColumnStorage is managed internally by the Engine – you do not need to interact with it directly.

Choosing a Backend

FactorMemoryStorageFileStorageFileStorage (mmap)
DurabilityNoneFullFull
Read speedFastestModerateFast
Write speedFastestModerateModerate
Memory usageProportional to data sizeLowOS-managed
Max data sizeLimited by RAMLimited by diskLimited by disk + address space
Best forTests, small datasetsGeneral useLarge read-heavy datasets

Recommendations

  • Development / Testing: Use MemoryStorage for fast iteration without file cleanup
  • Production (general): Use FileStorage for reliable persistence
  • Production (large scale): Use FileStorage with use_mmap = true when you have large indexes and want to leverage OS page cache

Next Steps

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 when the buffer fills, and on commit()

Step by Step

  1. Analyze: The text passes through the configured analyzer (tokenizer + filters), producing a stream of normalized terms
  2. Buffer: Each term goes straight into in-memory posting lists, organized by field; the write buffer keeps only what the flush still needs from the document — its stored values, field lengths, and numeric points
  3. Flush and commit: When the buffer reaches max_buffered_docs (default 10,000) or max_buffer_memory (default 64 MiB) of estimated memory (the buffered documents plus the posting lists), it is flushed to a new, not yet visible segment; commit() flushes what remains and publishes those segments together

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
ComponentDescription
Term DictionarySorted 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 ListsFor each term, a list of document IDs and metadata (term frequency, positions)
Doc ValuesColumn-oriented storage for sort/filter operations on numeric and date fields

Posting List Contents

Each entry in a posting list contains:

FieldDescription
Document IDInternal u64 identifier (per-segment value must fit in u32)
Term FrequencyHow many times the term appears in this document
Positions (optional)Where in the document the term appears (needed for phrase queries). The elements of a multi-valued text field share one ascending sequence, separated by the field’s position_increment_gap
WeightScore weight for this posting. Defaults to 1.0, which is what every posting written by Laurus carries; a list in which every weight is 1.0 omits the weights section from disk entirely (v3)

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, any_weights]
[Skip levels              — v2 onward: 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 (only when any_weights == 1)]
[Section 4: positions     — per-posting flag + varint deltas (only if any)]

Two header flags gate the two optional sections. any_positions has always gated Section 4; any_weights was added in v3 and gates Section 3 the same way. Because every posting Laurus writes carries the default weight of 1.0, Section 3 is absent from every segment the writer produces, and a posting list is 4N - 1 bytes smaller than its v2 equivalent — four bytes recovered per posting, less the one flag byte per list. At a billion postings that is roughly 4 GB that no longer has to be written, read, or cached.

The saving does not make the format lossy: a single weight other than 1.0 sets the flag, restores Section 3, and every value round-trips exactly.

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 (introduced in the v2 format for skip_to performance on seek-heavy workloads, and unchanged in v3). 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. Older segments remain readable: v1 segments (without the on-disk skip table) have it rebuilt from doc_ids at load time, and v2 segments are read with their unconditional weights section intact. Which decoder applies is determined by the sibling .dict file’s version — the only segment-level file carrying a magic and a version number — so the posting payload itself never has to be probed.

A table read from disk must be exactly the one the writer builds from the decoded doc_ids. The decoder compares the two in place and rejects any other table as a corrupt segment: skip_to relies on that shape to keep its strides in range, and a wrong entry would skip matching documents.

Term Dictionary Storage Layers

The dictionary uses a two-layer representation that decouples on-disk compactness from in-memory query speed:

  • Disk layer — the .dict file uses a Lucene BlockTreeTermsWriter-style block-tree layout (magic LTDD, schema v3). This produces a compact file: at 100k unique 5-10 byte terms a .dict is ~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 sorted Vec<String>, and a single-copy Arc<[TermInfo]>. get / iter / find_prefix / find_range operate 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
[Checksum footer       ]  CRC-32 of all of the above + magic "LCRC"

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 typeDimensionsCoordinatesSupported queries
Geo2WGS84 latitude / longitude (degrees)radius, bounding box
Geo3d3ECEF Cartesian (x, y, z) in metres3D 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[".norms (field-length norms)"]
    S1 --- F8[".ids (doc ids)"]
File ExtensionContents
.dictTerm dictionary in the v3 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 the first time the segment is read, and kept for the segment’s life (see Reading After a Commit).
.postPosting lists (document IDs, term frequencies, positions). A segment missing this file answers term queries by scanning its stored documents with the index analyzer (such hits score 0) and logs a warning
.bkdBKD tree data for numeric, date, Geo (2D), and Geo3d (3D ECEF) fields
.docsStored field values (the original document content), chunked (~16 KiB uncompressed, or 128 documents, whichever comes first) and LZ4-compressed per chunk, with a per-chunk raw fallback when compression doesn’t help (SDOC v1, Issue #548)
.dvDoc values for sorting and filtering
.metaSegment metadata (doc count, term count, etc.)
.norms1-byte-quantised field-length norms (for BM25 scoring), one byte per (document, field). The length is a count of positions, not tokens — a synonym stacked on another token shares its position and is not counted again. Segments written by v0.13.2 or earlier keep their field lengths in .lens/.fstats instead; a query that scores by field length (a term or phrase query, for example) or a merge over such a segment fails with an error asking you to rebuild the index
.idsThe exact set of document IDs the segment holds (a Roaring bitmap). Deletions address global document IDs, and a segment’s ID range can contain IDs it does not hold (after a merge of non-adjacent segments, for example), so a deletion checks this set before marking the segment. Readers use it the same way: a segment’s live-document count (BM25’s document count) is the documents it holds minus their deletions, and a deletion only hides a document in the segment that holds it. Segments written before it existed answer from .norms

Segment Lifecycle

  1. Create: A new segment is flushed each time the writer’s buffer reaches max_buffered_docs (default 10,000) or max_buffer_memory (default 64 MiB), and once more by commit() for what remains; commit() publishes them all at once, so one commit can add several segments. Both thresholds are set on the index config (LexicalIndexConfig::builder().max_buffered_docs(..) / .max_buffer_memory(..))
  2. Search: All segments are searched in parallel and results are merged
  3. Merge: After each commit(), once the count exceeds max_segments, an auto-merge merges runs of up to merge_factor segments of similar size (at most 8 times apart), smallest first, keeping the segment count bounded; a manual optimize() force-merges everything into one segment. Merging only similar sizes keeps a large segment from being rewritten every time it absorbs a small one, so each byte is rewritten a logarithmic number of times as the index grows. One commit’s auto-merge keeps merging until it has removed merge_factor - 1 segments, the count is back at max_segments, or no run of similar size is left. The count can therefore stay above max_segments while small segments wait for partners of similar size: as long as each commit publishes fewer than merge_factor segments, it stays within max(max_segments, 1 + log_8(index size / smallest segment)). A large segment with many deleted documents is rewritten, and its deleted documents reclaimed, only when it merges with segments of similar size or on optimize(). max_merged_segment_bytes (default: unbounded) caps the combined estimated size of the segments a single merge will take, so a segment at or over the cap is left unmerged and the count can stabilize above max_segments once the survivors no longer fit together
  4. Delete: When a document is deleted, its ID is added to a deletion bitmap rather than physically removed (see Deletions & Compaction)

Resolving Document IDs

Every put_document first looks up the earlier versions of its _id so it can replace them. The committed segments are checked through a Bloom filter over every committed _id key, which the index keeps in memory and shares among its readers:

  • An _id the filter has never seen is in no segment, so a put of a new ID costs the same however many segments the index holds.
  • A “maybe” (an _id that exists, or about 1% of new IDs at most) probes each segment’s term dictionary to find the exact copies.
  • The filter is built on the first lookup after the index is opened, and each lookup after a commit adds only the new segments’ keys. When it fills up, it is rebuilt from the current segments, which also drops the IDs that merges removed.
  • It takes 2 bytes per committed document right after a rebuild, down to about 1.3 bytes just before the next one.

Reading After a Commit

A commit’s documents become visible through a new reader, built by the next search or put. Building it does not read again the segments the commit left alone. The index keeps the per-segment readers of the last reader it built, and the next reader takes them over:

  • A segment whose metadata and deletions did not change keeps its reader. It keeps everything that reader has loaded: the term dictionary, norms, doc values, stored documents, BKD trees and the posting cache.
  • A segment that only gained deletions gets a new reader. The new reader reads the segment’s .delmap and shares everything else with the old one.
  • A new segment, such as one the commit flushed or a merge wrote, is opened. Its parts load the first time they are used.

The first search or _id lookup after a commit therefore reads only what the commit changed, however many documents the other segments hold. A reader built before the commit keeps the segment readers it was built with, so it still sees its own snapshot. A merge or a field rebuild releases the readers of the segments it replaced as soon as it publishes, and closing the index releases them all.

What the segments have loaded stays in memory from one commit to the next. Before, it was freed at each commit and loaded again by the next search or update. A search loads every segment’s term dictionary anyway, so the peak does not change. The memory is no longer released between a commit and the next read.

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

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

  1. Embed: The text (or image) is converted to a vector by the configured embedder
  2. Normalize: The vector is L2-normalized only for the Cosine metric (a magnitude-invariant metric). Euclidean, DotProduct, and Manhattan fields keep their original vectors so their distances are preserved
  3. Index: The vector is inserted into the configured index structure (Flat, HNSW, or IVF)
  4. Commit: On commit(), the index is flushed to persistent storage

Index Types

Laurus supports three vector index types, each with different performance characteristics:

Comparison

PropertyFlatHNSWIVF
Accuracy100% (exact)~95-99% (approximate)~90-98% (approximate)
Search speedO(n) linear scanO(log n) graph walkO(n/k) cluster scan
Memory usageLowHigher (graph edges)Moderate (centroids)
Index build timeFastModerateSlower (clustering)
Best for< 10K vectors10K - 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

ParameterDefaultDescriptionImpact
m16Max bi-directional connections per layerHigher = better recall, more memory
ef_construction200Search width during index buildingHigher = better recall, slower build
dimension128Vector dimensionsMust match embedder output
distanceCosineDistance metricSee 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_search parameter (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

ParameterDefaultDescriptionImpact
n_clusters100Number of Voronoi cellsMore clusters = faster search, lower recall
n_probe1Clusters to search at query timeHigher = better recall, slower search
dimension(required)Vector dimensionsMust match embedder output
distanceCosineDistance metricSee Distance Metrics below

Tuning tips:

  • Set n_clusters to roughly sqrt(n) where n is the number of vectors
  • Set n_probe to 5-20% of n_clusters for a good recall/speed trade-off
  • IVF requires a training phase — initial indexing may be slower

Distance Metrics

MetricDescriptionRangeBest For
Cosine1 - cosine similarity[0, 2]Text embeddings (most common)
EuclideanL2 distance[0, +inf)Spatial data
ManhattanL1 distance[0, +inf)Feature vectors
DotProductNegative inner product(-inf, +inf)Pre-normalized vectors
AngularAngular 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: Vectors are automatically L2-normalized before indexing only for the Cosine metric. Normalizing is magnitude-invariant, so it is safe for Cosine (and additionally tightens the int8 quantization range), but it would change the distances of magnitude-sensitive metrics — so Euclidean, DotProduct, and Manhattan fields are stored without normalization. Lower distance = more similar.

Migration note: non-Cosine fields created before this behavior was corrected have L2-normalized vectors on disk (the original magnitudes are not recoverable). Rebuild such an index to get correct distances.

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).

MethodEnum VariantDescriptionMemory Reduction
Scalar 8-bit (default)Scalar8BitPer-segment global affine quantization to u8~4x
Product QuantizationProductQuantization { subvector_count }Stage 3 of #481 — codebook of M × 256 centroids (trained per segment, or trained once and shared via pq_codebook_path), 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 quantizer field is no longer Option<QuantizationMethod>; quantization is mandatory and defaults to Scalar8Bit. 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 f32 element is encoded as u8 = 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 LVS1 magic + a 16-byte header so the reader can detect the format at load time. The header carries a version (a feature ladder: v2 = ordinal-encoded HNSW graph block, v3 = per-segment field-name dictionary) and, from v3 on, the dictionary itself — records then reference field names by a 16-bit id instead of repeating the full name inline.

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:

  1. The HNSW int8 graph search returns up to ef_search candidates ranked by quantized cosine distance.
  2. The top top_k * rerank_factor candidates are rescored against the f32 vectors loaded from the LRS1 sidecar (*.hnsw.f32).
  3. The new ranking is truncated to top_k and returned.

Since #932 the same sidecar mechanism serves Flat and IVF too (the shared RerankPipeline, #650); the description below uses HNSW, the original Stage-2 host. 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
   end     8  footer        magic "LRC1" u32 LE + CRC-32 u32 LE
                             over header + payload

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).

The sidecar is enabled per field by rerank_storage in the schema’s HNSW options, and that setting is honored on every write path: direct commits, the active write segment, and segment merges all re-emit the sidecar for the segment they produce. A merged segment’s sidecar is rebuilt from the source segments’ original f32 sidecars (not from the int8-dequantized segment data), so a merge stays lossless for the rerank vectors instead of compounding one round of quantization error per merge.

New sidecars end with an 8-byte CRC-32 footer over the header and payload that is verified whenever the sidecar is read (both the searcher load and the writer reload), so silent on-disk corruption is rejected instead of skewing rerank scores. Because the header fully determines the content length, the footer is detected by the bytes remaining after the payload — sidecars written before the footer was introduced have zero trailing bytes and still load, with verification skipped.

Bounded allocations from on-disk header counts

Every vector segment header carries element counts (num_vectors, n_clusters, the HNSW graph’s node_count / layer_count / neighbor_count) and per-record byte lengths (the v1/v2 inline field_name_len, PQ codes) that the reader uses to size Vec / HashMap capacities and read buffers. The HNSW CRC footer is verified before the structural parse, but legacy footer-less segments — and the writer reload paths, which run no footer verification — reach those counts unverified. A single flipped byte could otherwise turn a small count into a multi-GiB allocation request that aborts the process through handle_alloc_error (out of memory) before any corruption check runs.

To prevent that, the HNSW, Flat, and IVF loaders bound each such allocation against ground truth — the true file length from StorageInput::size(). Because the bytes a count or buffer describes must physically exist in the file, a header that declares more elements or bytes than the file can hold is rejected as a corrupted segment before the allocation. This generalizes the rerank sidecar’s file-size bound to the main segment readers and covers legacy footer-less segments, so a corrupt or hostile header surfaces a clean “corrupted segment” error rather than an OOM abort.

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.99 at low ef_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_search for 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_gate asserts Recall@10 ≥ 0.99. Bypasses the HNSW graph entirely (brute-force int8 over the corpus, widen to top_k * rerank_factor, rescore with f32) so any miss is a rerank-kernel regression.
  • hnsw_quantized_recall_at_10_with_rerank_meets_stage2_recall_gate asserts Recall@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_gate asserts Recall@10 ≥ 0.99 on 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 (in laurus/benches/vector_search_bench.rs) measures end-to-end Stage 2 latency on the same fixture. The accompanying example laurus/examples/sift_rerank_probe.rs runs 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.

Minimum training size: PQ trains K = 256 k-means centroids per sub-quantizer, so a segment with fewer than 256 vectors cannot train a meaningful codebook. Such segments are written as Scalar8Bit instead (the LVS1 header is self-describing, so readers dispatch on the stored kind transparently); once the segment grows — or is merged into a larger one — the next write trains PQ as configured. The subvector_count-divides-dimension validation still applies regardless of segment size. Neither the fallback nor the minimum applies when a shared codebook is configured: nothing is trained per segment in that case, so even tiny per-commit segments stay on PQ.

Shared PQ codebook (Issue #631)

By default every segment write trains its own codebook from scratch — a multi-second k-means cost that recurs on every commit and every merge under the segment-per-commit layout (#634/#889). Following the FAISS / Lucene99 precedent, a codebook can instead be trained once on a representative sample and reused by every subsequent segment write, reducing the encode step to a table lookup (measured ~91-92% faster than inline training at 2 048-8 192 vectors).

Enable it by naming a codebook file on the field and training it:

[fields.embedding.Hnsw]
dimension = 128
pq_codebook_path = "embedding.pqcb"

[fields.embedding.Hnsw.quantizer.ProductQuantization]
subvector_count = 32
laurus train pq-codebook --field embedding --input vectors.jsonl --update-schema

(or --from-index in place of --input to sample vectors already committed to the index; or fold training into index creation with laurus create index --train-pq-codebook <jsonl>, which removes the train-before-first-commit ordering hazard entirely — both Issue #920), or programmatically via Engine::train_pq_codebook (engine.train_pq_codebook("embedding", &vectors, None); pair with engine.sample_committed_vectors("embedding", Some(n)) for the from-index flow). Training is CPU-bound and synchronous; thousands of representative vectors are enough — the full corpus is not required.

Semantics:

  • The segment format is unchanged. The shared codebook is still embedded inline in each segment header, so shared-codebook and inline-trained segments coexist with no migration, and the codes are byte-identical to what inline training on the same sample would produce.
  • The codebook file is resolved once, at index open. Retraining while an engine is open does not hot-swap the codebook mid-session (intentional — a codebook must not change between the commits of one session); reopen the engine to pick up a new file.
  • Failure policy — no silent fallback. A configured but not-yet-trained pq_codebook_path is lenient at open (so a schema can be created before training), but a commit that needs to encode hard-errors with the training command to run. A present but corrupt or geometry-mismatched file errors at open. Laurus never silently falls back to per-segment training — an invisible regression to the multi-second training cost would defeat the feature.
  • Cosine-metric fields L2-normalize the training sample the same way the writer normalizes indexed vectors, so the codebook basis always matches (the #794 trap).
  • FastScan fields are supported too (Issue #920, pq-fastscan feature): a ProductQuantizationFastScan field trains and encodes against a k=16 shared codebook through the exact same commands and file format — the .pqcb header stores k verbatim, so the two variants are distinguished by the stored value, and a k-mismatched codebook (e.g. a k=256 file configured on a FastScan field) fails the commit loudly with both values named.

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_gate asserts 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_gate asserts 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.

PQ → SQ → f32 three-stage rerank (Issue #673)

Stage 3’s rerank widens the PQ ADC candidate set to top_k * rerank_factor and rescores only that leading slice against the exact f32 sidecar — anything the graph traversal found beyond that budget is discarded. Setting ef_search wider than top_k * rerank_factor used to buy nothing beyond that budget on a PQ field; since #673 it activates an extra int8 (SQ) stage that re-ranks the entire ef_search-sized candidate set by a cheap int8 kernel before the narrow exact-stage budget is carved out of it — so the surplus the graph already computed gets a chance to compete on a much better proxy (int8) than the PQ ADC order it arrived in, instead of being thrown away unscored.

The int8 view is derived from the same LRS1 f32 sidecar the exact stage reads (trained once, lazily, on first use, and cached for the segment’s lifetime) — no second on-disk sidecar, no new per-field configuration, and no new query parameter. The chain activates automatically, per query, whenever all of the following hold:

  • the field is PQ-quantized (ProductQuantization or ProductQuantizationFastScan);
  • a rerank sidecar (rerank_storage: Some(F32)) is loaded;
  • rerank_factor is set on the query; and
  • effective_ef_search > top_k * rerank_factor — i.e. the graph traversal actually computed more candidates than the exact stage alone would consume. When it doesn’t (the common case where ef_search is left at its default), the SQ stage would narrow nothing and is skipped, so the query takes the identical two-stage (PQ → f32) path it always has.

The score_basis contract (Issue #927) is unaffected either way: the pipeline still ends in the exact f32 stage, so multi-segment fan-out keeps treating those scores as the exact, cross-segment- comparable basis regardless of whether an SQ stage ran ahead of it.

MultiVector Storage

The quantization methods above apply to the ANN index types (Flat, HNSW, IVF). A MultiVector field has no ANN index, so quantizer does not apply to it — instead its own storage option (Issue #1346) picks the on-disk element kind of its token vectors:

KindRust Enum VariantBytes/elementErrorSize per document (300 × 128 tokens)
F32 (default)MultiVectorStorage::F324Exact~150 KB
F16MultiVectorStorage::F162~2⁻¹¹ relative error per element~75 KB
Int8MultiVectorStorage::Int8~1 (dimension + 2 bytes/row total)Bounded by the per-vector scale max(abs(vector)) / 127~38 KB
BinaryMultiVectorStorage::Binary1/8 (ceil(dimension / 8) bytes/row)Lossy: one bit per element against a field-level center (see Binary); requires Cosine~4.7 KB
#![allow(unused)]
fn main() {
use laurus::{MultiVectorOption, MultiVectorStorage};

let opt = MultiVectorOption::new(128).storage(MultiVectorStorage::Int8);
}

Unlike quantizer’s per-segment global scale, Int8’s scale is per-vector: scale = max(abs(vector)) / 127, computed independently for each token vector, with no corpus- or segment-trained state — which is what lets a segment merge copy rows byte-for-byte instead of re-quantizing on every merge.

Changing storage on an existing field is a Reindex change: Engine::update_field with reindex: true rebuilds the field’s existing segments under the new on-disk kind. It decodes what is stored, so it cannot restore precision a lossy kind already dropped. A schema with no storage key (written before this option existed) defaults to F32 and needs no migration.

Binary

Binary stores one bit per element: 12 bytes per token vector at 96 dimensions, 32 times smaller than F32. Token vectors of a late-interaction model are highly anisotropic — they share one dominant direction — so the plain sign of each element would come out nearly the same for every token. Binary stores the sign of each element’s offset from a center instead, and decodes a row as

x_i     = w_i * (bit_i ? +1 : -1)
decoded = c + x - ((x · c) / (c · c)) * c

where c is the mean token vector and w_i the mean of |v_i - c_i|, both stored as f16. The decoded offset is projected off c: without that, the bits’ spurious component along c acts as a query-independent bias in MaxSim and ranking quality collapses. Queries are not binarized: the float query vectors are scored against the stored bits directly, without decoding the rows (see Cost).

  • The field learns c and w once, from the first commit or merge that holds at least 2,048 token vectors (a sample of at most 16,384 of them, spread over the documents). Every later segment is encoded with them, and every Binary segment stores its own copy (4 bytes per dimension), so each segment decodes on its own and merges copy rows verbatim. They are never re-learned, so they do not follow a corpus that drifts afterwards.
  • Until then, the field writes its segments as F16. A merge that brings at least 2,048 token vectors together learns the parameters and converts them; converting an existing field to Binary with update_field learns them from the stored vectors. An F16 segment that no merge reaches only costs space.
  • Binary requires DistanceMetric::Cosine: it keeps each token vector’s direction, not its magnitude. Under Cosine a decoded vector has a norm of at most 1 (up to f16 rounding), so a score stays at most the number of query vectors.
  • It is clearly lossy (see Measured quality), and changing storage away from Binary does not bring the dropped precision back.

Token pooling

pool_factor (Issue #1346) makes a field smaller by keeping fewer token vectors per document. At index time, a document’s token vectors after the first are clustered into max(r / pool_factor, 1) clusters (r is how many there are) and each cluster is replaced by its mean. The stored size and MaxSim’s cost, which grows with the stored vectors, both drop by about the factor, and pooling combines with every storage kind.

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

let opt = MultiVectorOption::new(128)
    .storage(MultiVectorStorage::Int8)
    .pool_factor(2);
}
  • Method. The hierarchical pooling of Clavié et al. (arXiv 2409.14683) as Sentence Transformers and PyLate implement it: the first token vector ([CLS] for a ColBERT model) is kept as is, and the others are grouped by Ward agglomerative clustering on the cosine distance. laurus reproduces SciPy’s clustering; a test checks it against SciPy’s output. In a Cosine field the cluster means are re-normalized, which ranked better on BEIR than the raw means those implementations keep.
  • Every write is pooled once. A document is pooled after it is embedded and before the field’s writer is taken, so fresh documents, WAL recovery and text embedded by the field’s embedder are all pooled; queries are not. Each stored document records the factor it was pooled with, so merges never pool it again.
  • Turning pooling on for an existing field is a Reindex. Its stored vectors are still the original ones, so Engine::update_field with reindex: true pools them exactly as ingest would have (for F32 storage; other kinds pool their decoded values, and a Binary field keeps the parameters it already learned). The merge pools the stored documents in parallel (one after another on WebAssembly), and ingestion waits until it is done.
  • Changing or turning off a factor in use is Destructive. The original token vectors are gone once a document is pooled (multi-vector values are not kept in the document store), so the field’s data is discarded and its documents must be put again.
  • Cost. Pooling a document of n token vectors takes O(n²) time and memory: about 3 ms for 300 vectors of 128 dimensions, and about 2 s and 290 MB for the 8,192-vector maximum (Apple M4, release build). Puts made at the same time pool their documents at the same time, outside the field’s writer. Documents pooled at once hold at most 256 MiB together (one document at the maximum, which is then pooled alone), and no more of them run at once than there are cores. A Reindex pools within its own 256 MiB, so documents put through VectorStore directly while VectorStore::rebuild_field runs can bring the total to 512 MiB; Engine::update_field holds puts back until it is done.
  • A Binary field learns its parameters from the pooled vectors, and its 2,048-vector threshold counts pooled vectors.

Measured quality

Measured on two BEIR datasets with the BEIR probe: answerdotai/answerai-colbert-small-v1 (96-dimension token vectors, about 236 tokens per document), every storage kind ingesting the same vectors. Exhaustive ranks every document by MaxSim; BM25 → rescore rescores the BM25 top 100. Each cell is nDCG@10, then its paired difference from F32 with a 95% bootstrap confidence interval. The probe commits every 500 documents, so the Binary field learns its parameters in the first commit.

KindBytes/token (96 dims)Size vs F32SciFact exhaustiveSciFact BM25 → rescoreNFCorpus exhaustiveNFCorpus BM25 → rescore
F3238410.74560.72840.36900.3458
F161921/20.7446 (−0.0010 [−0.0029, 0.0000])0.7284 (−0.0001 [−0.0002, 0.0000])0.3689 (−0.0001 [−0.0008, +0.0006])0.3457 (−0.0001 [−0.0009, +0.0007])
Int8981/3.920.7457 (+0.0002 [−0.0038, +0.0044])0.7264 (−0.0021 [−0.0052, +0.0000])0.3686 (−0.0004 [−0.0043, +0.0034])0.3460 (+0.0002 [−0.0034, +0.0037])
Binary121/320.7176 (−0.0280 [−0.0481, −0.0087])0.7139 (−0.0145 [−0.0297, +0.0008])0.3192 (−0.0498 [−0.0626, −0.0373])0.3264 (−0.0194 [−0.0279, −0.0116])
  • Every F16 and Int8 interval contains 0, so neither shows a measurable nDCG@10 loss on these datasets. The largest point estimate is Int8’s −0.0021 on SciFact BM25 → rescore.
  • Binary loses 1.5 to 5.0 points of nDCG@10. It loses less under BM25 → rescore, where BM25 has already narrowed the candidates, than when MaxSim alone ranks the whole corpus.
  • The plain sign of each element (what Vespa’s ColBERT binarization stores, also 12 bytes per token) scored 0.7018 / 0.6978 / 0.3113 / 0.3202 in the same columns. Pooled over both datasets’ 623 queries, Binary is ahead by +0.0117 [−0.0014, +0.0254] exhaustive and +0.0110 [−0.0001, +0.0225] BM25 → rescore. The gain depends on the tokens the parameters are learned from: learning them from the leading 5, 20, 50, 100 or 500 documents instead gave +0.016 to +0.023 exhaustive, and no sample came out behind the plain sign (Issue #1346).
  • F16 changes the nDCG@10 of at most 1 of SciFact’s 300 queries and 18 of NFCorpus’s 323. Int8 reorders the top 10 of most queries (237 of 300 on SciFact exhaustive), but the gains and losses cancel out (6 queries up, 7 down).
  • F32’s exhaustive nDCG@10 is within 0.005 of the model card’s published numbers (SciFact 0.7477, NFCorpus 0.373), which do not state how they were measured.
  • On SciFact the payload is 469 MB (F32), 235 MB (F16), 120 MB (Int8) and 14.7 MB (Binary) for 1.22 M token vectors; segment files add 24 bytes per document and 80 bytes per segment, plus 4 bytes per dimension (384 here) per Binary segment.
  • 25 NFCorpus queries have no BM25 hit (single words such as okra that never occur in the corpus; the default analyzer does not stem), so BM25 → rescore scores them 0 under every kind.

With token pooling, measured the same way (the size is that of the segment files, relative to unpooled F32):

KindBytes/token (96 dims)Size vs F32SciFact exhaustiveSciFact BM25 → rescoreNFCorpus exhaustiveNFCorpus BM25 → rescore
F32, pool_factor 23841/2.000.7434 (−0.0021 [−0.0106, +0.0064])0.7292 (+0.0008 [−0.0069, +0.0090])0.3683 (−0.0007 [−0.0034, +0.0020])0.3456 (−0.0002 [−0.0026, +0.0020])
F32, pool_factor 33841/2.990.7450 (−0.0005 [−0.0095, +0.0084])0.7288 (+0.0004 [−0.0085, +0.0093])0.3650 (−0.0040 [−0.0083, +0.0001])0.3433 (−0.0025 [−0.0066, +0.0013])
F32, pool_factor 43841/3.970.7483 (+0.0028 [−0.0096, +0.0149])0.7292 (+0.0008 [−0.0107, +0.0121])0.3642 (−0.0048 [−0.0109, +0.0008])0.3415 (−0.0043 [−0.0095, +0.0008])
Int8, pool_factor 2981/7.810.7428 (−0.0028 [−0.0097, +0.0042])0.7277 (−0.0008 [−0.0077, +0.0064])0.3662 (−0.0028 [−0.0072, +0.0014])0.3447 (−0.0011 [−0.0051, +0.0028])
Int8, pool_factor 3981/11.670.7458 (+0.0002 [−0.0093, +0.0095])0.7296 (+0.0012 [−0.0076, +0.0100])0.3647 (−0.0043 [−0.0097, +0.0009])0.3431 (−0.0027 [−0.0079, +0.0020])
Binary, pool_factor 2121/62.810.7088 (−0.0368 [−0.0555, −0.0188])0.7038 (−0.0246 [−0.0404, −0.0093])0.3184 (−0.0506 [−0.0640, −0.0383])0.3217 (−0.0241 [−0.0328, −0.0158])
Binary, pool_factor 3121/93.230.7091 (−0.0365 [−0.0564, −0.0176])0.7006 (−0.0278 [−0.0456, −0.0106])0.3113 (−0.0577 [−0.0732, −0.0431])0.3202 (−0.0256 [−0.0365, −0.0155])
  • Every interval of pooled F32 and pooled Int8 contains 0. Pooled over both datasets’ 623 queries, the difference from unpooled F32 under exhaustive ranking is −0.0014 [−0.0057, +0.0031] at factor 2, −0.0023 [−0.0071, +0.0026] at factor 3 and −0.0012 [−0.0078, +0.0056] at factor 4, and −0.0028 [−0.0069, +0.0014] for Int8 at factor 2; under BM25 → rescore it is closer to 0. NFCorpus is where the point estimates drop first (about −0.004 at factors 3 and 4).
  • Pooling and Int8 multiply: factor 2 with Int8 is 1/7.8 the size of F32 with no measurable loss.
  • Binary loses more with pooling: −0.0047 [−0.0120, +0.0024] under exhaustive ranking and −0.0074 [−0.0139, −0.0009] under BM25 → rescore, on top of its own loss.
  • Re-normalizing the cluster means matters. With the raw means that Sentence Transformers and PyLate keep, F32 at factors 2, 3 and 4 ranked 0.0033, 0.0045 and 0.0098 lower under exhaustive ranking (pooled over both datasets, every interval above 0; Issue #1346).
  • Rescoring time fell about in proportion to the factor: on SciFact exhaustive, 104 ms per query unpooled and 50, 37 and 27 ms at factors 2, 3 and 4 (wall clock on a loaded machine; only the trend is meaningful).

Reproduce with make beir-storage-eval BEIR_DATASET=scifact (or nfcorpus).

Segment Files

Index TypeFile ExtensionContents
HNSW.hnswGraph structure, vectors, and metadata
Flat.flatRaw vectors and metadata
IVF.ivfCluster centroids, assigned vectors, and metadata

All three index types default to the segment-per-commit layout (see below); the monolithic single-file layout remains available via {Flat,Hnsw,Ivf}IndexConfig { segmented: false, .. } for direct index users.

Segment-per-commit layout (default for all three index types)

Since #634 (HNSW) and #889 (Flat and IVF), each commit seals only the newly added vectors as an immutable segment_NNNNNN.{hnsw,flat,ivf} file and registers it in an atomic, checksummed segments.json manifest — the per-commit cost drops from O(index) to O(new documents), with no full-corpus rework for the existing segments:

  • Search fans out over the sealed segments (newest generation first) and deduplicates same-document copies with newest-wins masking, so a re-indexed document always resolves to its latest embedding. An adaptive over-fetch — expanding the per-segment budget geometrically until enough live hits survive masking — keeps result quality stable even when a deep band of stale copies awaits a merge.
  • Merging: a tiered, generation-contiguous merge policy collapses size-similar segment runs as ingest proceeds (each vector is rewritten O(log N) times over its lifetime), and optimize() force-merges everything into one segment. IVF’s merge does not try to reconcile per-segment cluster ids (they carry no cross-segment meaning): it flattens the deduplicated survivors and retrains centroids from scratch over the merged union, with the cluster count re-derived adaptively for the union’s size. Flat has no f32 rerank sidecar to fall back on, so each merge dequantizes, retrains scalar-quantization parameters, and requantizes — a small amount of quantization drift accumulates across merges, unlike HNSW’s/IVF’s lossless-ish requantization path.
  • Per-segment training: HNSW’s graphs and IVF’s centroids are both built independently per segment — IVF adapts its cluster count to each segment’s own size (capped by the schema’s n_clusters) rather than training a fixed count regardless of how many vectors landed in that commit. No cross-segment agreement is needed for either index type, since search fans out per segment and merges top-k results.
  • Deletions are logical: an index-level bitmap ({name}.delmap) is consulted by every segment reader and physically reclaimed by merges.
  • Migration is zero-copy: an existing monolithic segment file is registered verbatim as the first segment on first open — one manifest write, no data movement.
  • Durability: segment files and the manifest are written via temp-file + fsync + atomic rename, and the manifest carries the WAL checkpoint published only after all covered state is durable — see Persistence.

Writer retention across commits (monolithic layout only)

Retention only applies to the monolithic layout (segmented: false); a segmented writer always starts from an empty buffer, so there is nothing to retain. For the monolithic HNSW layout, the store keeps its writer cached across commits (Issue #864): after a commit the writer’s in-memory state is equivalent to the file it just wrote, so the first upsert after a commit extends it in place instead of reloading (and dequantizing) the whole .hnsw from storage — under commit-heavy ingest that reload was O(index size) per cycle. The cache is dropped whenever the index is rewritten behind the writer — auto-compaction firing inside a commit, or an explicit optimize() — because both rebuild the file through a fresh writer and clear the deletion bitmap, and a stale retained writer would write the reclaimed vectors back — and whenever a commit fails partway, where the writer/disk agreement is unknown. As a bonus, a retained writer with no pending changes skips the index rewrite entirely, so a no-change commit() costs no .hnsw write at all. The monolithic Flat and IVF layouts keep the previous drop-on-commit behavior, unaudited for the same invariants — reaching for that code path today requires opting out of the segmented default explicitly.

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

This section covers how to query your indexed data. Laurus supports three search modes that can be used independently or combined.

Topics

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

Semantic similarity search using vector embeddings. Covers:

  • VectorSearchRequestBuilder API
  • Multi-field vector search and score modes
  • Filtered vector 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, so TermQuery::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). On a multi-valued text field (multi_valued = true) the elements share one position sequence separated by the field’s position_increment_gap (default 100), so a phrase never spans two elements unless its slop reaches that gap — see Schema and Fields.

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();
}
OccurMeaningDSL Equivalent
MustDocument MUST match+term or AND
ShouldDocument SHOULD match (boosts score)term or OR
MustNotDocument MUST NOT match-term or NOT
FilterMUST 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() returns Result because 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,
);
}

Numeric range queries are constant-scored: every matching document receives the same score (the query’s boost, 1.0 by default), following Lucene’s PointRangeQuery semantics. Matching uses the field’s BKD tree when the segment has one; segments without a BKD tree for the field (for example, segments none of whose documents carry the field, or a field configured indexed = false, stored = true) fall back to scanning only the stored documents actually present in that segment.

DateTimeRangeQuery

Matches documents whose DateTime field value falls within a range.

#![allow(unused)]
fn main() {
use chrono::{TimeZone, Utc};
use laurus::lexical::DateTimeRangeQuery;

// Find documents where "created_at" is within 2024 (both bounds inclusive)
let query = DateTimeRangeQuery::between(
    "created_at",
    Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(),
    Utc.with_ymd_and_hms(2024, 12, 31, 23, 59, 59).unwrap(),
);

// Open-ended range: created_at > 2024-06-15T12:00:00Z (exclusive)
let query = DateTimeRangeQuery::after(
    "created_at",
    Utc.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap(),
);

// From textual bounds, using the same literal grammar as the query DSL
let query = DateTimeRangeQuery::from_literals(
    "created_at",
    Some("2024-01-01"),            // min (date only = midnight UTC)
    Some("2024-12-31T23:59:59Z"),  // max
    true,                          // include min
    true,                          // include max
)?;
}

new(field, lower, upper, lower_inclusive, upper_inclusive) takes Option<DateTime<Utc>> bounds; on_or_after, before and on_or_before complete the one-sided helpers, and .with_boost() sets the constant score. The query is evaluated against the field’s one-dimensional BKD point (seconds with a microsecond fraction, so sub-second bounds compare exactly) with the same constant scoring and stored-document fallback as NumericRangeQuery. from_literals accepts RFC 3339, naive YYYY-MM-DDTHH:MM:SS[.fff] (UTC) and YYYY-MM-DD (midnight UTC) — the query the DSL builds for created_at:[2024-01-01 TO 2024-12-31] (see Query DSL). A NumericRangeQuery over a DateTime field with epoch-second bounds keeps working.

GeoDistanceQuery / GeoBoundingBoxQuery

Match documents by 2D geographic location (WGS84 latitude / longitude).

#![allow(unused)]
fn main() {
use laurus::lexical::query::geo::{GeoBoundingBoxQuery, GeoDistanceQuery};

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

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

Both queries score by distance (closer documents rank higher: linear decay from the circle’s centre, or from the box’s centre). Matching uses the field’s BKD tree when the segment has one, reading the coordinates directly from the tree — so an index-only field (indexed = true, stored = false) works without stored documents. Segments without a BKD tree for the field (for example, segments none of whose documents carry the field, or a field configured indexed = false, stored = true) fall back to scanning only the stored documents actually present in that segment.

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);
}
QueryScore
Geo3dDistanceQuery1 - distance / radius, clamped to [0, 1].
Geo3dBoundingBoxQueryConstant 1.0 for every match.
Geo3dNearestQueryNormalised so the closest hit is 1.0, the farthest in the returned set is 0.0.

Geo3d queries require the field to be indexed (indexed = true, the default): they run entirely on the field’s BKD tree and return no hits from segments that lack one — there is no stored-document fallback for 3D queries.

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:

OptionDefaultDescription
field_boostsemptyPer-field score multipliers
min_score0.0Minimum score threshold
timeout_msNoneSearch time budget in milliseconds (see note below)
parallelfalseEnable parallel search across segments
sort_byScoreSort by relevance score, or by a field (asc / desc)
highlightNoneFields (and configuration) to highlight in each hit’s SearchResult::highlights; see Highlighting

Field-sorted searches (sort_by: Field { .. }) always scan every candidate document — there is no early termination for field sorts, unlike the block-max-driven early termination available for score sorts. This guarantees the returned hits are the true top-K by field value rather than an early-terminated approximation, and it means total_hits reflects the true number of matches rather than a scan-truncated count.

When timeout_ms is set, the time budget is enforced cooperatively during the search: the scan loops (including each segment of a multi-segment fanout) check the deadline periodically and abort as soon as the budget is used up, returning a timeout error rather than running the query to completion first. The check is batched (every few thousand scanned documents), so an unset timeout_ms and the common in-budget case pay no measurable overhead.

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();
}

.highlight(fields) and .highlight_config(config) request highlighted fragments per hit — see Highlighting.

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]")?;

// DateTime range (date-only bounds are midnight UTC)
let query = parser.parse("created_at:[2024-01-01 TO 2024-12-31]")?;
}

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_query of a hybrid / filtered search and feeds both the lexical and vector sides.
  • Reused inside boolean queries. An Occur::Filter clause within a BooleanQuery (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. The per-term iterator shares the cached list directly (a reference-count bump, not a copy), so evaluating a term never duplicates its posting arrays — this removed the dominant allocation cost on the multi-segment scoring path.

A segment reader’s deletions are fixed for its life, so a cached list is always consistent with them. A commit that leaves a segment unchanged keeps its reader, and the cache with it; a segment whose deletions changed gets a new reader with an empty cache (see Reading After a Commit). 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 each segment’s cache gets its own max_cache_memory budget (default 128 MiB, which also bounds the reader’s term-info cache), so the worst case grows with the segment count. Control it via the index config:

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

let config = LexicalIndexConfig::builder()
    .enable_posting_cache(false) // disable entirely
    .build();

let config = LexicalIndexConfig::builder()
    .max_cache_memory(256 * 1024 * 1024) // or resize the per-segment budget (bytes)
    .build();
}

Next Steps

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:

VariantDescription
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

FieldTypeDescription
fieldStringTarget vector field name
payloadDataValueThe payload to embed (e.g., DataValue::Text(...))
weightf32Score weight (default: 1.0)

QueryVector

FieldTypeDescription
vectorVectorPre-computed dense vector embedding
weightf32Score weight (default: 1.0)
fieldsOption<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()]),
    },
]);
}

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

ModeDescription
WeightedSum (default)Sum of (similarity * weight) across all clauses
MaxSimMaximum similarity score across clauses
LateInteractionCurrently the same as WeightedSum, because a vector field holds one vector per document. For token-level (ColBERT-style) late interaction, use a late-interaction rescore

Parallel Multi-Vector Execution

When a request carries multiple query vectors (e.g., several fields, or several query embeddings combined by a score mode), 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 (default native feature), once queries.len() reaches the searcher’s parallel_threshold (default 4, 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 wasm32 targets 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_id so 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.

A vector field’s schema-level base_weight (Issue #1084) works the same way but is a per-field default rather than a per-query override: it is multiplied into the query weight above, so base_weight and ^/weight compose. Because both only affect the vector side’s own scoring, neither can rebalance a hybrid search’s lexical-vs-vector split — FusionAlgorithm::RRF is rank-only and ignores the underlying score entirely, and FusionAlgorithm::WeightedSum min-max normalizes each side before applying lexical_weight/vector_weight, which cancels out a uniform per-field scalar. What both weighting mechanisms DO affect is the relative priority among vector fields when a query targets two or more at once.

This per-field summing only happens when the query names its target fields. A field-less query cannot apply base_weight (it has no single field to look it up against — Issue #1084), so when it reaches a document through more than one same-dimension field it takes that document’s best-scoring field instead of summing across them (Issue #1343; see Field Routing below).

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:

  1. Per-query — QueryVector.fields. The DSL parser sets this from the field a clause names, so image_vec:"fluffy cat" only searches image_vec.
  2. 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.

A document that holds vectors in two or more of the searched fields still comes back only once: the field-less fanout collapses to that document’s best-scoring field (Issue #1343), so limit/top_k counts documents, not (document, field) pairs. This is unlike an explicit multi-field query (fields: [Exact("a"), Exact("b")]), which sums the fields’ scores — see Weights above.

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();
}

Late-Interaction Rescore

A late-interaction rescore (Issue #1345) reorders the top candidates of any search — lexical, vector or hybrid — with ColBERT-style late interaction. Each document keeps one vector per token in a multi-vector field, the query is likewise a set of token vectors, and a document scores the sum, over the query vectors, of each one’s best match among the document’s vectors (MaxSim):

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

Matching token by token keeps the details that a single document embedding averages away, while the first stage keeps the search fast: only the top candidates are rescored, so the multi-vector field needs no ANN index.

query ─→ first stage: lexical / vector / hybrid (as usual)
              │ top window_size candidates (default 100)
              ▼
         rescore: MaxSim against the multi-vector field
              ▼
         offset / limit → documents

When the multi-vector field has a token-level embedder (a candle_colbert one, see CandleColbertEmbedder), documents can be indexed as text and the query given as text:

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

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

Without one, pass the query’s token vectors, computed with the same model that produced the documents’ token vectors:

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

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

Besides the Rust API, the rescore stage is available over gRPC and the HTTP gateway, in the MCP search tool and in laurus search, and in the Python, Ruby, PHP, Node.js and WASM bindings.

Options

FieldTypeDefaultDescription
window_sizeusize100How many top first-stage candidates to rescore, 1..=10,000
rescorerRescorer—Rescorer::LateInteraction { field, query }: the multi-vector field and the query, either token vectors (LateInteractionQuery::Vectors) or text (LateInteractionQuery::Text)

A text query is embedded by the field’s token-level embedder, as a query, before the first stage runs; with EngineBuilder::embedding_cache_capacity set, a repeated query (for example the next page) is served from the cache.

The query must hold between 1 and 1,024 vectors, each of the field’s dimension and with finite values. The request fails with LaurusError::InvalidArgument before any search runs when the options are invalid, when the field is not a multi-vector field, when a text query is blank or the field has no token-level embedder, or when the request also sorts by a field (sort_by with SortField::Field): a field sort does not rank by score, so there is nothing to rescore.

Ordering and Scores

  1. The window — the top window_size first-stage candidates — is sorted by MaxSim, highest first. Equal scores are ordered by internal document ID, so the order is deterministic.
  2. Window candidates without token vectors in the field follow, in their first-stage order.
  3. Candidates beyond the window follow last, unchanged.

A rescored hit’s score is its MaxSim; the other hits keep their first-stage score. The two kinds of score are not comparable, which is why the groups are ordered by position rather than by score.

The first stage fetches max(window_size, offset + limit) candidates, so every page is cut from the same rescored ranking: pages fetched one by one concatenate to the ranking a single large page returns, even when a page reaches past the window. A larger window lets the rescore promote documents the first stage ranked lower, at a cost linear in the window; it cannot recover a document the first stage did not retrieve at all.

Similarity

The similarity of a query vector and a document vector is their dot product. In a field with DistanceMetric::Cosine (the default), document vectors are L2-normalized when written and query vectors when searched, so each pair contributes its cosine similarity and a score is at most the number of query vectors (up to rounding in a compressed kind). With DistanceMetric::DotProduct the vectors are used as given.

The field’s stored vectors may also be compressed: its storage option can encode them as F16, Int8 or Binary instead of the exact F32 default, trading some score precision for a smaller index — F16 carries ~2⁻¹¹ relative error per element, Int8’s error is bounded by its per-vector scale (max(abs(vector)) / 127), and Binary keeps one bit per element against a center the field learns, at a measurable cost in nDCG@10. The query vectors are never compressed.

Cost

Rescoring takes window_size × query vectors × document vectors dot products, so a field’s pool_factor cuts it by about that factor. On native builds the candidates are scored in parallel on rayon’s global thread pool; on wasm32 they are scored one by one. As a guide, 100 candidates of 300 vectors with 128 dimensions and a 32-vector query (about 123 million multiply-adds) took about 1.4 ms per search on an Apple M4, and about 5.5 ms on one thread.

Vectors of 64, 96 or 128 dimensions, the widths of common ColBERT-style models, are scored by a copy of the MaxSim loop made for that width. Other dimensions score four document vectors per pass over a query vector, which costs more per pair than the fixed-width copies but 31 to 36% less than scoring one document vector at a time: on one thread of an Apple M4, a pair retired about 54, 73 and 100 instructions at 64, 96 and 128 dimensions, and about 114 at 100 dimensions. The scores are the same through every loop.

A Binary field is scored straight from its bits. For each query vector, the search first builds one 256-entry table per byte of a row, holding the byte’s contribution to the dot product for each of its bit patterns. A query vector and a document vector then cost ceil(dimension / 8) table lookups instead of a decode and dimension multiply-adds. The tables take ceil(dimension / 8) KiB per query vector (384 KiB for 32 vectors of 96 dimensions) and are built once per search. A query that would need more than 4 MiB of tables decodes its Binary candidates instead. On the SciFact and NFCorpus token vectors of answerai-colbert-small-v1 (96 dimensions, Apple M4), a pair retires about 50 instructions, against about 74 for F32 and about 224 when the rows are decoded first.

Distance Metrics

The distance metric is configured per field in the schema (see Vector Indexing):

MetricDescriptionLower = More Similar
Cosine1 - cosine similarityYes
EuclideanL2 distanceYes
ManhattanL1 distanceYes
DotProductNegative inner productYes
AngularAngular distanceYes
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

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.

Search TypeStrengthsWeaknesses
Lexical onlyExact keyword matching, handles rare terms wellMisses synonyms and paraphrases
Vector onlyUnderstands meaning, handles synonymsMay miss exact keywords, less precise
HybridBest of both worldsSlightly 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

The lexical and vector searches are independent, so the engine runs them concurrently rather than one after the other. Any query embedding is computed first (the vector search depends on it), then both searches execute in parallel. As a result the hybrid latency is bounded by the slower of the two searches (max(lexical, vector)) instead of their sum, which matters most when both sides do substantial work. Fusion is order-independent, so concurrent execution returns exactly the same ranked list as a sequential run.

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

FieldTypeDefaultDescription
querySearchQueryDsl("")Search query (Dsl, Lexical, Vector, or Hybrid)
limitusize10Maximum number of results to return
offsetusize0Number of results to skip (for pagination)
fusion_algorithmOption<FusionAlgorithm>None (uses RRF { k: 60.0 } when both results exist)How to merge lexical and vector results
filter_queryOption<Box<dyn Query>>NonePre-filter using a lexical query (restricts both lexical and vector results)
lexical_optionsLexicalSearchOptionsDefaultParameters controlling lexical search behavior (field boosts, min score, timeout, etc.)
vector_optionsVectorSearchOptionsDefaultParameters controlling vector search behavior (score mode, min score)
rescoreOption<RescoreOptions>NoneReorder the top fused candidates with late interaction (see Rescoring the Fused Results)

SearchResult

Each result contains:

FieldTypeDescription
idStringExternal document ID
scoref32Fused relevance score, or the late-interaction score of a rescored hit
documentOption<Document>Full document content (if loaded)
highlightsHashMap<String, Vec<String>>Highlighted fragments, if requested via lexical_options.highlight

Highlighting in hybrid search is driven by the request’s lexical query, so a hit that came only from the vector side has an empty highlights map. See Highlighting.

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

  1. The filter query runs on the lexical index to produce a set of allowed document IDs
  2. For lexical search: the filter is combined with the user query as a boolean AND
  3. 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();
}

Rescoring the Fused Results

Hybrid search makes a good first stage for a late-interaction rescore: fusion gathers candidates that match either the keywords or the meaning, and the rescore then reorders the top window_size of them by token-level MaxSim against a multi-vector field:

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

let request = SearchRequestBuilder::new()
    .lexical_query(/* ... */)
    .vector_query(/* ... */)
    .fusion_algorithm(FusionAlgorithm::RRF { k: 60.0 })
    .rescore(RescoreOptions::late_interaction("body_colbert", query_tokens))
    .limit(10)
    .build();
}

The rescore runs after fusion and before offset / limit, so pagination works the same with or without it. See Late-Interaction Rescore for the options, the ordering rules and the cost.

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

Result Grouping

When a long document is indexed as several chunks, the chunks of one document often fill the top of the ranking and push other documents off the page. Result grouping (Issue #1297) folds the hits that share a field value into one group and keeps the best few hits of each group, like Elasticsearch’s collapse or Qdrant’s group_by. It works the same way for lexical, vector and hybrid searches.

Basic Usage

Chunks added with Engine::add_document share their document’s _id, so grouping by _id returns each document once, represented by its best chunk:

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

engine.add_document("manual", chapter_1).await?;
engine.add_document("manual", chapter_2).await?;
engine.add_document("faq", faq).await?;
engine.commit().await?;

let request = SearchRequestBuilder::new()
    .query_dsl("body:lifetimes")
    .group_by(GroupBy::new("_id").group_size(3))
    .limit(10)
    .build();
for result in engine.search(request).await? {
    // The group's best chunk, then up to two more of the same document.
    println!("{} {}", result.id, result.score);
    for hit in &result.inner_hits {
        println!("  {}", hit.score);
    }
}
}

Besides the Rust API, grouping is available over gRPC and the HTTP gateway, in the MCP search and search_batch tools, in laurus search --group-by, and in the Python, Node.js, Ruby, PHP and WASM bindings.

How It Works

query ─→ first stage: lexical / vector / hybrid (as usual)
              ▼
         rescore (when requested)
              ▼
         group the ranked hits by the field's value
              ▼
         offset / limit over groups → documents

Grouping runs on the final ranking, after fusion and rescore and before the page is cut:

  1. Groups are ordered by the position of their best hit in the ranking.
  2. Within a group, hits keep their ranking order, up to group_size hits.
  3. The first hit of a group is the returned result; the others are its inner_hits.

Grouping follows positions in the ranking and never compares scores, so it also follows a lexical sort_by and a rescore, whose rescored and unrescored hits have scores of different kinds.

Options

FieldTypeDefaultDescription
fieldString—The field whose value groups the hits (see below)
group_sizeusize1Hits kept per group, 1..=100: the returned result plus up to group_size - 1 inner hits

Grouping Fields

A grouping field is _id, or a schema field that is:

  • of type Text, Integer, Float, Boolean or DateTime;
  • single-valued (multi_valued: false);
  • stored, with doc_values enabled (both are on by default).

Values are compared as stored: a Text value is compared as the whole original string, not as analyzed terms. Float values that compare equal (0.0 and -0.0) share a group, and so do NaN values. A document with no value for the field forms a group of its own, with no group_key, so no hit is dropped and unrelated documents are never merged.

The search fails with LaurusError::InvalidArgument before any search runs when the field is unknown, of another type, multi-valued, not stored or without doc_values, when group_size is outside 1..=100, or when offset + limit exceeds 10,000. A limit of 0 returns no results without searching.

Results

limit and offset count groups, not hits. Each returned SearchResult is a group’s best hit, with two fields filled:

FieldTypeDescription
group_keyOption<DataValue>The value the group’s hits share; None for a hit without a value
inner_hitsVec<SearchResult>The group’s next best hits, in ranking order

Each inner hit carries the same group_key and has no inner hits of its own. Highlights are filled for inner hits too. An ungrouped search leaves both fields empty.

Candidate Pool

The first stage of a grouped search ranks (offset + limit) × group_size × 2 candidates, or the rescore window when that is larger. When those candidates hold fewer than offset + limit groups, the engine doubles the pool and runs the first stage again, until the pool holds enough groups, the first stage has no more hits, or the pool reaches 10,000 candidates. Query embedding runs once; a rescore scores each candidate once across rounds. Doubling keeps the total work within about twice that of the last round.

The number of groups is complete within that bound. The number of inner hits is not: a group’s further hits are taken from the same pool, so a group can hold fewer than group_size hits even when more of its documents match.

lexical_timeout_ms limits the lexical time of all rounds together: when widening runs past it, the search fails as an ungrouped search would.

Group values are read from the field’s DocValues column. The first grouped search on a field loads that column of each segment it reaches into memory, where it stays for later searches, as for sorting by the field. For 1,000,000 documents with 12-character _id values, the _id columns of every segment took about 111 MB (about 111 bytes per document).

Next Steps

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

If the field’s analyzer splits a bare term into multiple tokens — for example, a Japanese sentence under a morphological (Lindera) analyzer, or a hyphenated word like rust-lang under the default \w+ tokenizer — the resulting tokens are combined with OR (matching Lucene’s match query), not required to appear as an exact phrase. Use a quoted Phrase Query when exact adjacency is required.

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

On a multi-valued text field the phrase (or its ~N proximity window) never crosses from one element into the next unless N reaches the field’s position_increment_gap (default 100).

A quoted value that the field’s analyzer turns into a single token is an exact term match, the same as a Term Query (any ~N is ignored). This is how to search a keyword field for a value the unquoted form does not accept, such as a path, a URL, or an ID containing ., /, or ::

path:"file:///notes/a.md"
links:"https://example.com/a"

When the field’s analyzer stacks several tokens at one position, as SynonymGraphFilter does, they are alternatives at that position. With big and large as synonyms, "big" matches documents that contain either word, and "a big dog" also matches “a large dog”. A single position’s alternatives are scored as one blended term (a SynonymQuery), not summed, so a document containing both big and large does not score higher than one containing big twice. A multi-word synonym makes one phrase per alternative, and the value matches when any of them does: with ml and machine learning as synonyms, "ml" matches ml or the phrase machine learning. These phrases are matched together through the graph, not one by one, so there is no limit on how many a value makes, and a document gets one phrase score however many of them it contains. See Synonym Expansion.

A phrase of two or more positions compares term positions, which a field stores only with term_vectors: true (the default). On a field with term_vectors: false the search is rejected with a query error instead of returning no hits. This includes an unfielded phrase that expands over the default fields when one of them has term_vectors: false, and a quoted word whose multi-word synonym makes it a phrase, such as "ml" above.

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]

Date and datetime bounds

A range whose bounds are datetime literals builds a DateTimeRangeQuery, evaluated against the field’s BKD tree with constant scoring like a numeric range. A bound that is not a bare number is tried as, in order:

FormExampleInterpretation
RFC 33392024-01-01T00:00:00Z, 2024-01-01T09:00:00.5+09:00Fractional seconds allowed; an offset is normalized to UTC
Naive datetime2024-01-01T09:00:00, 2024-01-01T09:00:00.5YYYY-MM-DDTHH:MM:SS[.fff], interpreted as UTC
Date only2024-01-01YYYY-MM-DD, meaning 00:00:00 UTC of that day (the Lucene / Elasticsearch default)
# Date-only bounds (upper bound is 2024-12-31T00:00:00Z, see below)
created_at:[2024-01-01 TO 2024-12-31]

# RFC 3339 with an offset (09:00+09:00 is 00:00Z)
created_at:[2024-06-15T09:00:00+09:00 TO 2024-06-16T09:00:00+09:00]

# Naive datetime is UTC; * leaves the upper side open
created_at:[2024-06-15T12:34:56 TO *]

# Exclusive on both sides
created_at:{2024-01-01 TO 2025-01-01}
  • A date-only bound is midnight UTC, so [2024-01-01 TO 2024-12-31] includes 2024-12-31T00:00:00Z and nothing later that day. To cover the whole year, give the upper bound a time ([2024-01-01 TO 2024-12-31T23:59:59.999999]) or end an exclusive range on the next day ({2024-01-01 TO 2025-01-01} — note that {} also excludes the exact instant 2024-01-01T00:00:00Z).
  • * leaves a side open.
  • Bare numbers are still numeric bounds: on a DateTime field they are epoch seconds and build a NumericRangeQuery.
  • Mixing a numeric bound with a datetime bound ([1704067200 TO 2024-12-31]), or a datetime bound with a string that is not a datetime literal ([2024-01-01 TO tomorrow]), is a parse error rather than a silent empty result. A range whose bounds are neither numbers nor datetime literals (title:[A TO Z]) still parses but matches nothing, as before.

Document ingestion coerces text into a DateTime field only from RFC 3339; the naive and date-only forms are accepted for query literals only.

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)
FormBehaviour
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 Geo field 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)
FormBehaviour
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 Geo3d field 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.

ElementRequiredDescriptionExample
field:YesTarget vector field name (must be a vector field in the schema)content:
"text" or textYesText to embed (quoted or unquoted). In the unified DSL an unquoted value ends at whitespace or a parenthesis, so (content:cats) closes its group; quote the text to include them (Issue #1377)"cute kitten", python
^weightNoScore 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

ModeDescription
WeightedSum (default)Sum of (similarity * weight) across all clauses
MaxSimMaximum similarity score across clauses
LateInteractionCurrently the same as WeightedSum; token-level late interaction is a rescore

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

  1. 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. The whole field name must match: with a vector field content, subcontent:x or my.content:x stays a lexical clause (Issue #1373).
  2. Delegate: Vector portion goes to VectorQueryParser, remainder goes to lexical QueryParser.
  3. 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:

AlgorithmFormulaDescription
RRF (default)score = sum(1 / (k + rank))Reciprocal Rank Fusion. Robust to different score distributions. Default k=60.
WeightedSumscore = lexical * a + vector * bLinear combination with configurable weights.

Note: The fusion algorithm cannot be specified in the DSL syntax. It is configured when constructing the UnifiedQueryParser via .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.

SyntaxModeBehaviour
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 typeDimensionsCoordinate space
Integer / Float (single- or multi-valued)1scalar
DateTime (single- or multi-valued)1Unix microseconds (UTC)
Geo (single- or multi-valued)2latitude / longitude (degrees)
Geo3d (single- or multi-valued)3ECEF 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 4)

A .bkd segment file is a self-contained binary blob made of three regions, followed by a checksum footer:

+----------------------------------------+
| File Header                            |   fixed-size, version-tagged
+----------------------------------------+
| Leaf Blocks                            |   bit-packed points + doc_ids
|   leaf 0                               |
|   leaf 1                               |
|   ...                                  |
+----------------------------------------+
| Index Nodes                            |   internal navigation nodes
|   node N-1                             |
|   ...                                  |
|   node 0  (root, written last)         |
+----------------------------------------+
| Footer                                 |   CRC-32 of all of the above
|                                        |   + magic "LCRC" (8 bytes)
+----------------------------------------+

The header records where the leaves and the index ended up, so the writer reserves it, writes everything after it, and fills it in last. The footer’s CRC still covers the bytes in file order (Issue #1214). A file written before the footer existed ends in a 4-byte trailer instead.

The header (BKDFileHeader) records magic, version (currently 4), num_dims, bytes_per_dim, the total point count, the number of leaf blocks, block_size (the writer’s configured max points per leaf, used to bound a leaf’s count on read — Issue #1142), 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, bit-packed (Issue #549) instead of raw, and — since Issue #1142 — in doc_id-ascending order rather than the spatial split order the recursive build otherwise leaves them in:

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
doc_id_base         u64               — the leaf's minimum doc_id
doc_id_bits         u8                — bit width of the largest consecutive doc_id gap
packed_dim[0]       byte-aligned      — bit-packed `sortable(point) - sortable(leaf_min[d])`
packed_dim[1..]     ...               — one section per dimension
packed_doc_ids      byte-aligned      — bit-packed consecutive deltas (see below)

Each dimension’s bit width is derived from leaf_min[d]/leaf_max[d] (mapped into an IEEE-754 total-order-preserving u64, the same order f64::total_cmp computes) rather than stored — the writer and reader compute it with the same formula, so it cannot drift between the two sides. A dimension that is constant across the whole leaf derives a 0-bit width and contributes no bytes at all.

packed_doc_ids stores consecutive deltas, not independent deltas from doc_id_base: value i is doc_id[i-1] + delta, with doc_id[-1] := doc_id_base, so the very first packed delta is always 0 (the reader treats a nonzero leading delta as corruption). doc_id_bits is therefore bits_needed(max consecutive gap) rather than bits_needed(max - min) — never larger (consecutive gaps always telescope-sum to exactly max - min), and often much smaller when a leaf’s doc_ids are locally clustered rather than spread evenly across the leaf’s full range. doc_id_bits is the one width actually stored on disk, since there is no equivalent “doc_id_max” header field to derive it from; the reader rejects a value greater than 64 as corruption. Sorting by doc_id before packing is a stable sort, so multiple points sharing a doc_id (a multi-valued numeric, datetime or geo field) keep their original relative order — this is depended on by GeoBoxPointsVisitor’s “first point seen wins” deduplication.

Measured space savings depend on how correlated the data is. Relative to the raw (pre-#549) leaf format: roughly 2.16x/1.67x/1.50x smaller for uniformly random 1D/2D/3D points, and around 2.66x for a monotonically increasing field (timestamps, auto-incrementing counters) — improved from the v3 (pre-#1142) figures of 1.96x/1.59x/1.45x/2.28x for the same data, because consecutive-delta doc_id packing captures locally-clustered doc_id distributions that independent-delta-from-a-single-anchor packing could not. This is a lossless, delta-based packing scheme, not quantization — every point round-trips bit-for-bit, including ±0.0 and ±Infinity.

The per-leaf AABB lets the reader prune the leaf without decoding any of its points when the query region is fully outside (Outside) or fully inside (Inside) the leaf; on Inside, the packed point sections are skipped with a single seek and only doc_ids are decoded.

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 / Outside pruning 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:

  1. Compute the AABB of the input subset.
  2. Pick the axis whose (max - min) range is the widest (ties broken by lower dimension index for determinism).
  3. Sort the index permutation by that axis and split at the median.
  4. Recurse on the two halves until a subtree fits in block_size points (default 512); emit it as a leaf.
  5. Back-patch each parent’s left_offset / right_offset once 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:

QueryRegion tested by comparePer-point check in visit
Geo3dDistanceQuerysphere (centre, radius) vs. cell AABBEuclidean distance ≤ radius
Geo3dBoundingBoxQueryquery AABB vs. cell AABBpoint inside query AABB
Geo3dNearestQuery (k-NN)expanding sphere around the query pointdistance ≤ 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 .bkd segment file fits within the broader segment layout.
  • Lexical Search — NumericRangeQuery, GeoDistanceQuery / GeoBoundingBoxQuery, and Geo3dDistanceQuery programmatic entry points.

3D Geographic Search (ECEF)

Laurus offers two geographic field types that target different problems:

Field typeBacking structureCoordinate systemUse it when …
Geo2D BKD-TreeWGS84 latitude / longitude (degrees)All your data lives at the surface and altitude does not matter.
Geo3d3D BKD-TreeECEF 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);
}
FunctionDirectionAlgorithm
wgs84_to_ecef(lat°, lon°, h_m)geographic → CartesianClosed-form prime-vertical formula.
ecef_to_wgs84(&GeoEcefPoint)Cartesian → geographicBowring 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:

  1. it has collected at least k distinct candidates,
  2. doubling makes no progress,
  3. the radius reaches max_radius_m (default 1.0e10 m, 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)
FormArguments
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 a Geo3d field.

See Also

  • BKD-Tree — the underlying multi-dimensional index that also backs Integer, Float, DateTime, and 2D Geo fields.
  • Schema & Fields — Geo3d alongside 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 satellite positions on a CesiumJS 3D globe with geo3d_bbox and geo3d_nearest queries.

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

TypeModuleDescription
EngineengineUnified search engine coordinating lexical and vector search
EngineBuilderengineBuilder pattern for configuring and creating an Engine
SchemaengineField definitions and routing configuration
SearchRequestengineUnified search request (lexical, vector, or hybrid)
FusionAlgorithmengineResult merging strategy (RRF or WeightedSum)
DocumentdataCollection of named field values
DataValuedataUnified value enum for all field types
LaurusErrorerrorComprehensive error type with variants for each subsystem

Feature Flags

The laurus crate enables the native feature by default. Enable embedding support as needed:

FeatureDescriptionDependencies
native (default)File-based storage, rayon parallelism, multi-threaded tokiocrossbeam-channel, crossbeam-deque, memmap2, num_cpus, rayon, tempfile
embeddings-candleLocal BERT embeddings via Hugging Face Candlecandle-core, candle-nn, candle-transformers, hf-hub, tokenizers
embeddings-openaiOpenAI API embeddingsreqwest
embeddings-multimodalCLIP multimodal embeddings (text + image)candle-core, candle-nn, candle-transformers, hf-hub, tokenizers, image
embeddings-allAll embedding features combinedAll of the above
# Lexical search only (no embedding)
[dependencies]
laurus = "0.13"

# With local BERT embeddings
[dependencies]
laurus = { version = "0.13", features = ["embeddings-candle"] }

# All features
[dependencies]
laurus = { version = "0.13", features = ["embeddings-all"] }

Sections

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>,
}
}
FieldTypeDescription
schemaSchemaField definitions and routing rules
lexicalLexicalStoreInverted index for keyword search
vectorVectorStoreVector index for similarity search
logArc<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

MethodParameterDefaultDescription
analyzer()Arc<dyn Analyzer>StandardAnalyzerText analysis pipeline for lexical fields
embedder()Arc<dyn Embedder>NoneEmbedding model for vector fields
embedding_cache_capacity()usizeNone (disabled)Enable an LRU cache of up to N query embeddings
wal_sync_policy()WalSyncPolicyPerRecordWAL fsync durability (per-record vs group commit)
commit_policy()CommitPolicyManualAuto-commit cadence — e.g. EveryDocs(n) commits every n documents
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).

The token vectors of a late-interaction rescore’s text query are cached too, in a separate LRU of the same capacity, keyed by field, the field’s own embedder, role and query text, so their larger entries never evict single-vector ones (Issue #1349). Adding, deleting or rebuilding a vector field empties both, since the embedder behind a field name may have changed.

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
  1. Split schema – Lexical fields (Text, Integer, Float, etc.) go to LexicalIndexConfig, vector fields (HNSW, Flat, IVF) go to VectorIndexConfig
  2. Create prefixed storage – Each component gets an isolated namespace (lexical/, vector/, documents/)
  3. Initialize stores – LexicalStore and VectorStore are created with their respective configs
  4. 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

MethodDescription
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)
put_documents(docs)Batched upsert – applies (id, doc) pairs in order with one WAL fsync per batch
add_documents(docs)Batched append – like put_documents but never deletes existing chunks
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

MethodDescription
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

FieldTypeDefaultDescription
querySearchQueryDsl("")Search query specification (Dsl, Lexical, Vector, or Hybrid)
limitusize10Maximum results to return
offsetusize0Pagination offset
fusion_algorithmOption<FusionAlgorithm>RRF (k=60)How to merge lexical + vector results
filter_queryOption<Box<dyn Query>>NoneFilter applied to both search types
lexical_optionsLexicalSearchOptionsDefaultParameters controlling lexical search behavior
vector_optionsVectorSearchOptionsDefaultParameters controlling vector search behavior
rescoreOption<RescoreOptions>NoneSecond stage that reorders the top first-stage candidates (Late-Interaction Rescore)
group_byOption<GroupBy>NoneGroup the ranked hits by a field’s value; when set, limit and offset count groups (Result Grouping)

LexicalSearchOptions

FieldTypeDefaultDescription
field_boostsHashMap<String, f32>EmptyPer-field score multipliers
min_scoref320.0Minimum score threshold
timeout_msOption<u64>NoneSearch timeout in milliseconds
parallelboolfalseRun segment search in parallel
sort_bySortFieldScoreSort order for lexical results
highlightOption<HighlightOptions>NoneFields and configuration for search-result highlighting; None leaves every hit’s SearchResult::highlights empty. Set via SearchRequestBuilder::highlight / .highlight_config, see Highlighting. Ignored by vector-only requests.

FusionAlgorithm

VariantDescription
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. doc_len counts field positions, not tokens: a synonym the analyzer stacks on another token (position_increment = 0) shares its position and does not add to the length, matching Lucene’s discountOverlaps.

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.

A quoted or bare value whose analyzer stacks several alternatives at one position (synonyms) is scored as one blended term rather than as a sum of independent term scores, so a document is not rewarded for holding every alternative — see Searching with synonyms.

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). The exception is a CombinedFields multi-field query, where a boost is a BM25F weight inside the score instead.

Over gRPC and HTTP, the same setting is exposed as SearchRequest.field_boosts (map<string, float>). See gRPC API → SearchRequest.

Multi-field Queries

MultiFieldQuery searches one term, unanalyzed like a TermQuery, across several fields, each with its own boost. Its MultiFieldQueryType decides how the fields combine:

TypeMatchesScore
BestFields (default)Any fieldThe best field’s BM25 score plus tie_breaker × the other matching fields’ scores (Elasticsearch best_fields)
MostFieldsEvery fieldThe sum of the fields’ BM25 scores (Elasticsearch most_fields)
CombinedFieldsAny fieldBM25F: the fields are scored as one concatenated field (Elasticsearch combined_fields, Lucene CombinedFieldQuery)

BestFields and MostFields score each field with that field’s own statistics. A term that is rare in one field gets a high IDF there, so the same text scores differently depending on which field it lands in. CombinedFields blends the statistics and scores each document once, with BM25 over:

tf          = Σ w_f · tf_f                     (the document's term frequency in each field)
doc_len     = Σ w_f · len_f                    (every field, whether the term occurs in it or not)
avg_doc_len = Σ w_f · avg_len_f · doc_count_f / max(doc_count_f)
df          = max(df_f)                        (for the IDF)

Here w_f is the field’s boost. It acts as a weight inside BM25F: 2.0 counts the field as if its text were indexed twice, rather than multiplying the field’s score. Every boost must be finite and greater than 0.0; any other value makes the search return InvalidArgument. Boosts below 1.0 are accepted, although Lucene and Elasticsearch reject them.

As with TermQuery, the document frequency covers the whole index, while on a multi-segment index the average length is each segment’s own.

#![allow(unused)]
fn main() {
use laurus::lexical::query::advanced_query::{MultiFieldQuery, MultiFieldQueryType};

let query = MultiFieldQuery::new("smith".to_string())
    .add_field("first_name".to_string(), 1.0)
    .add_field("last_name".to_string(), 1.0)
    .query_type(MultiFieldQueryType::CombinedFields);
}

Vector Scoring

Vector search ranks results by distance-based similarity. The distance metric is configured per field on the vector index (HNSW / Flat / IVF):

MetricDescriptionBest for
Cosine1 − cosine similarity (default)Normalised text embeddings
EuclideanL2 distanceSpatial / pre-normalised data
ManhattanL1 distanceSparse feature vectors
DotProductNegated dot productPre-normalised vectors where higher = better
AngularAngular distanceDirectional 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

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".into(), "Electronics".into());

// Hierarchical facet from components
let facet = FacetPath::new("category".into(), vec![
    "Electronics".to_string(),
    "Computers".to_string(),
    "Laptops".to_string(),
]);

// From a delimited string
let facet = FacetPath::from_delimited("category".into(), "Electronics/Computers/Laptops", "/");
}

from_delimited drops empty components, as the collector does, so "/Electronics//Computers" gives ["Electronics", "Computers"].

FacetPath Methods

MethodDescription
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>,
}
}
FieldTypeDescription
pathFacetPathThe facet value, as the full path from the field’s top level
countu64Number of matching documents whose value is this path or lies under it
childrenVec<FacetCount>Facets one level deeper, for hierarchical drill-down

FacetConfig

FacetConfig controls what the collector counts and returns:

FieldDefaultDescription
max_facets_per_field100Maximum number of values kept per level: at the top level of each field and, separately, among the children of each node. Applied after sorting
max_depth10Paths are cut to their first max_depth components while collecting, so deeper levels are never counted. 0 counts nothing; usize::MAX keeps every level
min_count1Minimum document count for a value to be returned. A value below it is dropped together with its children
sort_by_counttrueSort each level by count, descending, with ties broken by label. false sorts each level by label

Values that no collected document has are never returned, so a min_count of 0 behaves like 1.

Collecting facets

Feed each matching document to a FacetCollector, then call finalize:

#![allow(unused)]
fn main() {
use laurus::lexical::search::features::facet::{FacetCollector, FacetConfig};

let mut collector = FacetCollector::new(FacetConfig::default(), vec!["category".to_string()]);
for doc_id in matching_doc_ids {
    collector.collect_doc(doc_id, reader.as_ref())?;
}
let results = collector.finalize()?;

for facet in results.get_field_facets("category").into_iter().flatten() {
    println!("{} ({})", facet.path.to_string_with_delimiter("/"), facet.count);
}
}

collect_doc returns an error when a facet field has to be read from the stored document and that read fails. The collector’s counts are incomplete after such an error, so discard it.

Hierarchical facets

A Text value containing / is a hierarchical path: Electronics/Computers/Laptops has three levels. The collector counts the path and each of its ancestors, once per document, and finalize returns one tree per field: the top-level values in get_field_facets(field), and each deeper level in the children of its parent.

Category
├── Electronics (42)
│   ├── Computers (18)
│   │   ├── Laptops (12)
│   │   └── Desktops (6)
│   └── Phones (24)
└── Books (35)
    ├── Fiction (20)
    └── Non-Fiction (15)

A node’s count is the number of documents whose value is that path or lies under it, so a child never outnumbers its parent. A flat value and the root of a hierarchical one are the same node. For example, one document with cat = "a" and another with cat = "a/b" give a single a (2) with one child b (1).

Each level is filtered, sorted and truncated on its own:

  • min_count drops a value together with its whole subtree.
  • max_facets_per_field applies to the top level and to the children of each node separately, so an ancestor never uses up its descendants’ budget.
  • Values with equal counts are ordered by label, so which values are kept never depends on hash order. This matches Lucene and Tantivy.

Empty components are dropped: "/a/b" and "a//b" both give a/b, "a/" gives a, and "" or "/" count nothing. Lucene rejects such components when indexing; here facets are derived at search time from ordinary text values, so they are dropped instead of failing the search.

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

Multi-valued fields

A multi-valued field (multi_valued = true, see Multi-valued fields) stores an array per document, and the collector expands it: every element becomes its own facet path (Issue #1187). A TextArray element containing / is split into a hierarchical path exactly like a scalar Text value, so tags = ["rust", "search"] counts rust and search once each, and cat = ["a/b", "a/c"] counts a/b, a/c and their shared ancestor a.

Counts are per document, following Lucene’s SortedSetDocValuesFacetCounts: an element that appears twice in one document (["rust", "rust"]) counts once, and so does an ancestor reached from two elements (a above is 1, not 2). FacetCount::count is therefore always the number of matching documents. An empty array contributes nothing.

Array elements are rendered exactly like the scalar of the same type:

ValueFacet value
Text / TextArrayThe string itself; / splits it into hierarchical components
Int64 / Int64ArrayDecimal integer, e.g. 42
Float64 / Float64ArrayAlways with a fraction, e.g. 2.0 or 2.5, so a float never shares a label with an integer (coercing a float into a Text field renders 2.0 as 2, which is a different code path)
Bool / BoolArraytrue / false
DateTime / DateTimeArrayRFC 3339 in UTC with microsecond precision, e.g. 2024-01-01T00:00:00+00:00; sub-microsecond digits are dropped so the label is the same whether it was read from DocValues or from the stored document

Null, geo points (Geo, GeoEcef and their arrays), Vector and Bytes are not facetable and contribute nothing. A DocValues hit that yields no facet value does not fall back to the stored document.

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 (the default for any stored: true field, unless its type is excluded — see below — or its doc_values option is explicitly set to false). A field that lacks DocValues — because it opted out, isn’t stored, or is a Bytes/Vector value, which DocValues never carries regardless of the setting — transparently falls back to the stored document, so results are identical either way; only the read path changes. Array values of multi-valued fields are stored whole in DocValues (one entry per document) and split into elements at facet time, so expansion adds no DocValues reads.

Setting doc_values: false on a field that is never sorted or faceted on shrinks its segment footprint, since the value is then written once (to the stored document) instead of twice.

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.

Highlighting search results

The easiest way to get highlights is through the search API itself: ask for them on a SearchRequest, and each hit’s SearchResult::highlights comes back filled in.

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

let request = SearchRequestBuilder::new()
    .lexical_query(query)
    .highlight(vec!["body".to_string()])
    .highlight_config(HighlightConfig::default().tag("em".to_string()))
    .build();

let results = engine.search(request).await?;
for result in &results {
    if let Some(fragments) = result.highlights.get("body") {
        println!("{}", fragments.join(" ... "));
    }
}
}

highlight(fields) and highlight_config(config) are independent SearchRequestBuilder methods and compose in either order; calling highlight again replaces the field list, and the same holds for highlight_config. Omitting highlight (or requesting no fields) leaves every hit’s highlights map empty — the engine does no highlighting work in that case.

Semantics to keep in mind:

  • Field selection. Only fields named in highlight(fields) are considered, and only when they are stored: true text fields. A field that is absent from the document, not stored, or not a text field is silently skipped — it never appears as a key in highlights. A multi-valued text field (multi_valued: true, Issue #1175) is highlighted element by element: only the elements that match contribute fragments, a fragment never straddles two elements, and the concatenated fragment list honours max_fragments.
  • Analyzer. Each field is tokenized with its own index-time analyzer (per-field analyzers apply automatically), so highlighting reflects what actually matched at index time — not a generic tokenizer.
  • Which query is highlighted. The request’s lexical query drives highlighting, including in hybrid search — a vector-only request produces no highlights. Terms contributed only by the request-level filter_query are never highlighted, since they describe eligibility, not relevance to what was searched for.
  • Empty result. A field with no highlight fragments (no match, or return_entire_field_if_no_highlight not set) is omitted from highlights entirely rather than mapped to an empty list.
  • Cost. Highlighting runs after pagination, so its cost scales with limit × len(fields), not with the total number of matches.

For direct control over highlighting outside the search API — for example to highlight text that never went through Engine::search — use Highlighter as described below.

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

OptionTypeDefaultDescription
tagString"mark"HTML tag used for highlighting
css_classOption<String>NoneOptional CSS class added to the tag
max_fragmentsusize5Maximum number of fragments to return
fragment_sizeusize150Target fragment length in characters
fragment_overlapusize20Reserved; not currently read by fragment selection
fragment_separatorString" ... "Reserved; not currently read — fragments are returned as a list, not pre-joined
return_entire_field_if_no_highlightboolfalseReturn the full field value if no matches found
max_analyzed_charsusize1,000,000Maximum characters to analyze for highlights
require_field_matchbooltrueUse only query terms that target the highlighted field (set to false to highlight terms from any field in the query)

Builder Methods

MethodDescription
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
require_field_match(flag)Set whether only terms targeting the highlighted field are used
opening_tag()Get the opening HTML tag string (e.g., <mark class="highlight">)
closing_tag()Get the closing HTML tag string (e.g., </mark>)

Supported Queries

Highlight terms come from the query tree (Query::collect_highlight_terms), not from the query’s description string, and are matched against the tokens produced by the highlighter’s analyzer (StandardAnalyzer by default; use Highlighter::with_analyzer to match the field’s analyzer, for example for Japanese text).

QueryWhat is highlighted
TermQueryTokens equal to the term
PhraseQueryConsecutive tokens forming the phrase, with the same in-order, per-gap slop rule as search; each occurrence is one highlight. As in the index, positions count the tokens the analyzer emits, so a stop word it drops leaves no gap
GraphPhraseQueryAs PhraseQuery, for each path through the graph that occurs, such as each member of a multi-word synonym in a quoted value
PrefixQuery, WildcardQuery, RegexpQuery, FuzzyQueryEvery token the pattern (or edit distance) matches
BooleanQueryTerms of Must, Should and Filter clauses; MustNot clauses are skipped
AdvancedQueryTerms of the core query, filters and post filters; negative filters are skipped
MultiFieldQueryIts text, in each configured field
Span queries (SpanQueryWrapper)Every span term in the wrapped query
Range, numeric, date-time and geo queriesNothing

By default only terms that target the highlighted field are used (require_field_match). Set it to false to highlight terms from every field in the query.

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:

  1. The text is split into windows of fragment_size characters
  2. Each fragment is scored by how many query terms it contains
  3. The top max_fragments fragments are returned, in original order, as a Vec<HighlightFragment> (callers join or render the list themselves)

For a multi-valued text field these steps run per element, so a fragment never straddles two elements; max_fragments caps the list concatenated across all elements.

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

OptionTypeDefaultDescription
max_distanceusize2Maximum Levenshtein edit distance for candidate suggestions
max_suggestionsusize5Maximum number of suggestions returned per word
min_frequencyu321Minimum frequency a word must have in the dictionary to be suggested
auto_correctboolfalseWhen true, automatically apply corrections above the threshold
auto_correct_thresholdf640.8Confidence score (0.0–1.0) required for auto-correction
use_index_termsbooltrueUse terms from the search index as dictionary words
learn_from_queriesbooltrueLearn new words from user search queries

CorrectionResult

The correct() method returns a CorrectionResult with detailed information:

FieldTypeDescription
originalStringThe original query string
correctedOption<String>The corrected query (if auto-correction was applied)
word_suggestionsHashMap<String, Vec<Suggestion>>Suggestions grouped by misspelled word
confidencef64Overall confidence score (0.0–1.0)
auto_correctedboolWhether auto-correction was applied

Helper Methods

MethodReturnsDescription
has_suggestions()boolTrue 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()boolWhether 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

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 _id within the Lexical Index.
  • Uniqueness: Expected to be unique across the entire system.
  • Updates: Indexing a document with an existing external_id triggers 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 RangeNameDescription
48-63 bitShard IDPrefix identifying the node or partition (up to 65,535 shards).
0-47 bitLocal IDMonotonically increasing document number within a shard (up to ~281 trillion documents).

Why this structure?

  1. Zero-Cost Aggregation: Since u64 IDs are globally unique, the aggregator can perform fast sorting and deduplication without worrying about ID collisions between nodes.
  2. 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.
  3. 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

  1. Registration (engine.put_document() / engine.add_document()): User provides a document with an External ID.
  2. ID Assignment: The Engine combines the current shard_id with a new Local ID to issue a Shard-Prefixed Internal ID.
  3. Mapping: The engine maintains the relationship between the External ID and the new Internal ID.
  4. Search: Search results return the External ID (String), resolved from the Internal ID.
  5. 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

  1. WAL-first: Every write (add or delete) is appended to the WAL before updating in-memory structures
  2. Buffered writes: In-memory buffers accumulate changes until commit() is called
  3. Atomic commit: commit() flushes all buffered changes to segment files and truncates the WAL
  4. Commit-scoped visibility: a lexical segment is written as soon as the writer’s buffer fills, which can happen long before commit(), so the segment files existing on storage is not what makes its documents searchable. Publication is a manifest entry that only commit() adds, and segment discovery reads only the manifest — so “documents become searchable only after commit()” holds regardless of when a searcher happens to be built. Deletions are published in the same step, after their bitmaps are durable, so a reader that can see a segment can always filter it
  5. Crash safety: If the process crashes between writes and commit, the WAL is replayed on the next startup
  6. Atomic file writes: Segment files (e.g. the HNSW .hnsw graph, its metadata, and the deletion bitmap) are written to a temporary file and atomically renamed into place, so a crash mid-write leaves the previously committed file intact rather than a truncated one
  7. Checksum verification: Those files carry a CRC-32 (a footer on .hnsw and the .hnsw.f32 rerank sidecar, framing on metadata.json) that is verified on load, so silent on-disk corruption is detected instead of being read as valid data. Files written before checksums were added still load (the checksum is optional per file). Lexical segment parts (.dict, .post, .docs, .norms, .ids, .bkd), deletion bitmaps (.delmap) and the checksummed manifests end in an 8-byte footer: a CRC-32 of every byte before it, then the magic LCRC. Parts read front to back are verified as they load, when a segment opens. .post and .bkd are read by random access, so they are verified when a merge reads a segment, which keeps corruption from being copied into the merged segment under a fresh checksum. The merged segment is then verified in full. Document store segments (doc_segment_NNNNNN.docs) carry the same footer and are verified in full when a segment is first read after it is written or the store is reopened, as its offset index is built; a corrupted one fails every read of it. Files written before this footer existed end in a 4-byte trailer that covered only their last write. They still load, unverified; manifests and .ids are the exception, because their last write is their whole payload, so their trailer is still checked. Loaders also bound buffer allocations against the real file size before trusting a header, so a corrupt size field is rejected as corruption rather than triggering a huge out-of-memory allocation. The lexical term dictionary and posting-list decoders likewise check each block, the FST header and each skip table against what the writer produces, so a corrupt one fails with a corruption error instead of a panic. They also reject data that decodes but is inconsistent, rather than serving wrong results: keys that are out of order or not valid UTF-8, block-max entries that do not ascend or carry a NaN or negative factor, doc ids that repeat, descend or fall outside the segment, and a posting list whose term, length, document count or block boundaries differ from its dictionary entry
  8. Versioned segment headers: Vector segments carry a format version in their shared header, forming a feature ladder. Version 2 (HNSW only) stores the graph block as segment-local 32-bit ordinals instead of 64-bit document ids (roughly halving the graph block on disk). Version 3 (all vector index types) adds a per-segment field-name dictionary: records reference field names by a 16-bit id instead of repeating the full name inline, shrinking every record by the name’s length plus two bytes. New segments are written at version 3; segments written by older builds (versions 1 and 2) still load and are upgraded on the next rewrite (compaction or merge)

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 TypeDescription
UpsertDocument content + external ID + assigned internal ID
DeleteExternal 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)

On-disk framing

The file begins with a 5-byte header (b"LWAL" magic + a version byte) followed by length-prefixed records. There are three framings, and a file keeps a single framing for its whole life (an older file is rewritten to the current format only on the next commit/truncate — formats are never mixed within one file):

VersionFramingPayload
v3 (current)[u32 len][u32 crc32][payload]compact rkyv binary record
v2[u32 len][u32 crc32][payload]JSON record (read-only, back-compat)
legacy (pre-CRC)[u32 len][payload]JSON record, no checksum (read-only)

The CRC-32 (v2/v3) is computed over len || payload, detecting both a corrupted length and a corrupted body. The reader recovers all three formats, so a WAL written by an older build still replays after an upgrade.

Since v3, each payload is a compact rkyv binary record rather than JSON. Vectors store as raw f32 (4 bytes each) instead of decimal strings, so for vector-heavy documents the WAL is roughly 2-3x smaller and replays correspondingly faster — without changing durability (the CRC framing and recovery semantics are unchanged).

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). Recovery finishes with an automatic commit: the replayed state — including deletions, whose persistence is group-committed — is persisted and immediately searchable, and the WAL is truncated, so a subsequent crash has nothing to re-replay:

graph TD
    Start["Engine::build()"] --> Check["Check WAL for\nuncommitted entries"]
    Check -->|"Entries found"| Replay["Replay operations\ninto in-memory buffers"]
    Replay --> Commit["Auto-commit\n(persist + truncate WAL)"]
    Commit --> Ready["Engine ready"]
    Check -->|"No entries"| Ready

Recovery is transparent — you do not need to handle it manually. Note that a post-crash open therefore does commit-scale work (segment flush, index writes), and Engine::build returns an error if that commit fails (e.g. the disk is full); reopening after the condition clears is safe, since replay is idempotent and the WAL is only truncated after the commit succeeds.

A put/add call rejected for a caller-input reason — an unsupported input type for a field’s embedder, a vector of the wrong dimension, an unconfigured field — never reaches the WAL (the document’s vector fields are embedded and validated before anything is written or deleted), so it leaves no replayable record for a later recovery to retry. An index that an older build already left with such a record still in its WAL replays it once more: recovery logs a warning and discards the record instead of failing to open, since the call that originally wrote it already returned that same error to its caller. A record rejected for any other reason (e.g. a remote embedder temporarily unreachable) still fails recovery and is retried on the next open, as before.

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

StrategyDescriptionUse Case
After each documentMaximum durability, minimum search latencyReal-time search with few writes
After a batchGood balance of throughput and latencyBulk indexing
PeriodicallyMaximum write throughputHigh-volume ingestion

Tip: Commits are relatively expensive because they flush segments to storage. For bulk indexing, batch many documents before calling commit().

Auto-commit Policy

Instead of calling commit() yourself, you can let the engine run the commit ladder automatically at an ingestion-driven cadence via CommitPolicy, configured on the builder:

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

let engine = Engine::builder(storage, schema)
    // Commit automatically after every 1,000 applied documents.
    .commit_policy(CommitPolicy::EveryDocs(1000))
    .build()
    .await?;

// No explicit commit() needed — every 1,000th document triggers one.
engine.put_documents(one_thousand_docs).await?;
}
PolicyBehavior
Manual (default)Never auto-commits; you drive every commit(). Identical to the historical behavior.
EveryDocs(n)Runs the commit ladder after every n applied documents, across the singular and batch APIs. EveryDocs(0) disables auto-commit (same as Manual).
Interval(Duration)Runs the commit ladder at least every Duration via a background timer, so a trailing partial batch is committed even while ingestion is idle. Native targets only — a no-op on wasm32 (no background threads), like WalSyncPolicy::Group’s max_interval.

Key semantics:

  • Group-commit preserved. Each auto-commit is one WAL flush plus one materialization ladder — never one commit per document. Within a single put_documents / add_documents call the auto-commit fires every n documents (chunked), so a large batch materializes incrementally rather than in one final ladder; the trailing < n remainder stays WAL-durable until the next boundary or an explicit commit().
  • Orthogonal to WalSyncPolicy. CommitPolicy decides when the stores materialize; WalSyncPolicy decides WAL fsync durability. Auto-commit works under any WAL policy because commit() always begins with a WAL flush.
  • Crash semantics unchanged. An auto-commit is a normal commit; a crash replays the uncommitted tail exactly as it would under manual commits.
  • Concurrency. The exact cadence and the usual “acknowledged write is durable” guarantee hold for single-writer ingestion (the model the engine’s write path — and the CLI/bindings — are built around). Under concurrent writers on a shared engine, auto-commit is best-effort: because the commit ladder is not atomic with respect to another thread’s in-flight write, a write acknowledged while a concurrent auto-commit runs may only become durable at the following commit, and the cadence may drift. (A concurrent manual commit() races the same way — auto-commit merely triggers it from the ingest path.) The Interval timer runs the ladder on its own thread, so the same best-effort caveat applies to it. Use explicit commits, or a single ingest task, if you need these guarantees under concurrency.

Batch Ingestion

put_documents / add_documents are the batched forms of put_document / add_document. They apply their (id, doc) pairs sequentially, in input order, and under the default PerRecord policy they make the whole batch durable with a single WAL fsync at batch end instead of one per record:

#![allow(unused)]
fn main() {
let docs: Vec<(String, Document)> = build_batch();
engine.put_documents(docs).await?; // one fsync, all docs as durable as singular puts
engine.commit().await?;            // one commit publishes the whole batch
}

Semantics to be aware of:

  • Ordering: duplicate external IDs within one put_documents batch dedup exactly like the same puts issued sequentially — the last occurrence wins. Repeating an ID in add_documents legitimately appends multiple chunks.
  • Fail-fast, no rollback: the batch stops at the first document that cannot be applied and returns LaurusError::BatchIngest { failed_index, failed_id, applied, .. }. The applied documents before the failure are not rolled back — they stay in the WAL and NRT buffers (resolvable by _id immediately, searchable after the next commit(), durable at that commit, replayed on crash recovery), and the batch-end WAL flush runs on the error path too. Retrying the batch, or its suffix starting at failed_index, is idempotent under put semantics.
  • Durability: when the call returns Ok, every document in the batch is exactly as durable as a successful singular put. A crash mid-call loses at most the un-fsync’d tail; recovery replays the fsync’d prefix per document, and a torn trailing record is dropped by the CRC framing as usual.
  • Sizing: the engine clones each document into the WAL as it goes, so batch memory is dominated by the caller’s Vec. Batches of 1,000-10,000 documents per call are a good default; chunk larger corpora into multiple calls (and commit periodically to bound segment sizes).
  • Under Group policy: the group thresholds keep firing mid-batch, so that policy’s bounded loss window is preserved; the batch-end flush still runs.
  • Concurrent singular writes: a put_document / add_document / delete_documents call that completes while another task’s batch is in flight keeps its full per-record durability — singular writes re-assert the fsync before acknowledging, so a batch never weakens anyone else’s guarantee.

WAL Durability Policy

By default, each add/delete fsyncs the WAL before returning, so a successful write can never be lost to a crash. When ingesting at high volume, that per-write fsync becomes the throughput bottleneck. The WalSyncPolicy lets you trade per-write durability for throughput:

PolicyDurabilityThroughputAnalogue
PerRecord (default)Every successful write is durableBounded by one fsync per writeSQLite synchronous = FULL
Group { max_records, max_bytes }A crash may lose up to the last unsynced batchfsync amortized over a batchSQLite synchronous = NORMAL

Under Group, the fsync is deferred and issued once either max_records records or max_bytes bytes have accumulated since the last sync (whichever comes first). Configure it on the builder:

#![allow(unused)]
fn main() {
use laurus::WalSyncPolicy;
use std::time::Duration;

let engine = Engine::builder(storage, schema)
    // Group commit with the default thresholds (1024 records / 1 MiB), no timer.
    .wal_sync_policy(WalSyncPolicy::group_with_defaults())
    // ...the default thresholds plus a periodic flush every 500 ms:
    // .wal_sync_policy(WalSyncPolicy::group_with_interval(Duration::from_millis(500)))
    // ...or choose your own batch size and timer:
    // .wal_sync_policy(WalSyncPolicy::Group {
    //     max_records: 4096,
    //     max_bytes: 4 * 1024 * 1024,
    //     max_interval: Some(Duration::from_secs(1)),
    // })
    .build()
    .await?;
}

Periodic Flush Timer

Group.max_interval adds a time bound to the size-based thresholds. When set, the engine runs a background timer that forces the WAL durable at least that often, so a trailing partial batch under a low ingest rate — where the record/byte thresholds may never be reached — is not left unsynced indefinitely. The flush is a no-op when nothing is pending, so an idle timer costs nothing. None disables the timer.

WASM note: the timer is honored on native targets only. On wasm32 there are no background threads, so max_interval is ignored; durability there relies on the record/byte thresholds, commit(), and flush_wal().

Durability Guarantees

  • commit() is a hard barrier under both policies. It forces the WAL durable before materializing any store, so the WAL is never less durable than the committed indexes. After a successful commit(), all data is durable regardless of policy.

  • flush_wal() forces a flush on demand without a full commit — the way to bound the crash-loss window under Group at an application-chosen point, analogous to a SQLite WAL checkpoint:

    #![allow(unused)]
    fn main() {
    engine.add_document("doc-1", doc1).await?;
    engine.flush_wal()?; // the WAL is now durable, without committing segments
    }
  • A torn trailing record is never resurrected. Each record is CRC-32 framed; on recovery a record that fails its checksum (or is truncated) is dropped along with everything after it, so the recovered log is always a gap-free valid prefix — group commit only ever risks losing a suffix of recent writes, never corrupting earlier ones.

Note: Group is opt-in; the default PerRecord policy is unchanged, so existing code keeps its per-write durability with no changes.

Commit Durability Ladder & Crash Safety

commit() persists state in a fixed order, and the order is what makes a crash at any point recoverable. Each lexical/vector store tracks its own last_wal_seq checkpoint — the sequence number of the last WAL record it has materialized — so recovery can skip already-applied records. The persisted last_wal_seq lives in the store’s on-disk metadata and is written only during the store’s commit.

The lexical control file (metadata.json) has a single authority: the in-memory copy owned by the index. The writer the store commits through holds a shared handle to it, records the commit’s state (the document and deletion counts, the WAL checkpoint) under that lock, and persists a snapshot — so no code path can overwrite the file from a stale copy, and internal writers (such as the merge engine’s segment-replay writer) have no handle and cannot touch the file at all. A pass with nothing to record skips the persist, and a failed persist rolls the in-memory copy back to the persisted state — the retry records the commit again, mirroring the manifest’s failure contract below.

Lexical segment discovery follows the same authority model through segments.json, an atomically replaced, checksummed manifest of the committed segment set. Publication is all-or-nothing: a commit adds every flushed segment in one manifest write, and a merge drops its sources and inserts the merged segment in one write. The in-memory copy mirrors the last successfully persisted manifest (a failed save leaves the pending state for the retry), so reader construction is a pure in-memory read — no directory listing, no per-segment metadata parse. There are no per-segment .meta files any more: the manifest is the only record, files it does not list are reclaimed at the next open, and an index written before the manifest existed is migrated by a one-time read of its legacy .meta files when opened.

The index’s document and deletion counts are derived from the manifest. Each entry records its segment’s document count and how many of those documents are deleted — recounted from the segment’s deletion bitmap by every commit that deletes from it — and the index’s counts are the sums. The counts in metadata.json are those sums as of the last commit; the index never reads them back, so a crash between the manifest save and the metadata write, a repeated upsert of one id, or a merge that drops deleted documents cannot skew them. A manifest written by an older build records no deletion counts: opening it recounts them from the bitmaps without writing anything, and the next manifest write persists them.

Segment data itself is written as one compound container per segment (segment_<N>.cfs — postings, term dictionary, stored documents, field lengths and statistics, the segment’s doc-id set, doc values and per-field BKD trees, concatenated with a trailing part table): one file create and one fsync per flush instead of one per part. The deletion bitmap (.delmap) stays a separate file, as the only per-segment data rewritten after sealing — written the same temp-file-then-rename way as the container itself (item 6 above), so a crash mid-rewrite leaves the previously committed bitmap intact. Readers detect the layout per segment, so indexes with older loose-file segments keep working unchanged, and merges rewrite them into containers over time. LAURUS_NO_COMPOUND=1 restores the loose layout as a transitional escape hatch. Opening an index whose segments.json is missing while segment files are present is refused loudly rather than served as silently empty. Three consequences worth knowing: at most one writing store instance per directory is supported (concurrent instances would overwrite each other’s manifest); a standalone InvertedIndexWriter is an ephemeral tool — its commits register segments nowhere, and such files in a manifest-owned directory are reclaimed; and a pre-manifest binary opening a post-manifest index finds no .meta files and sees it as empty — a one-way format step worth a release note.

The commit ladder is:

  1. flush_wal() — force the WAL durable (the hard barrier). Under Group this fsyncs any deferred batch; under PerRecord it is a no-op.
  2. lexical.commit() — write the lexical segment and metadata (including last_wal_seq), then sync().
  3. vector.commit() — write the vector segment, then sync().
  4. commit_documents() — write the document store segment, then sync().
  5. truncate_retaining_after(applied_before) — discard every WAL record this commit covered; retain any record whose mutation had not finished applying to both stores when the commit started (Issue #876).

This order guarantees two invariants:

  • The WAL is never less durable than any persisted index. last_wal_seq is only persisted in step 2+, which always runs after the step-1 barrier, so a committed index can never reference a WAL record that is not yet durable.
  • Every store is fully durable before the WAL is truncated. Steps 2–4 each sync() before step 5 discards the covered portion of the WAL, so nothing is discarded before the data it described is safely materialized.

commit() is not serialized against concurrent put/add/delete calls (nor against CommitPolicy::Interval’s background timer, which runs this same ladder). Step 5 accounts for this: it snapshots the engine’s ingest high-water mark before step 1 runs, and retains every WAL record past that snapshot instead of unconditionally emptying the file. A mutation racing the commit therefore keeps its WAL record — and is replayed on the next recovery — even though it was not included in this commit’s materialization. When nothing races the commit (the common case), the snapshot covers the whole WAL and step 5 empties the file exactly as before.

Recovery replays the WAL on the next build(), skipping records at or below each store’s last_wal_seq. Replay is idempotent — it re-applies each record under its originally recorded doc_id, so re-running it overwrites rather than duplicates. Because each store keeps its own checkpoint, a commit that fails partway leaves the stores at different last_wal_seq values and recovery simply re-applies what each store is missing. (The vector store currently keeps its checkpoint at 0, so it replays the full retained WAL on every recovery — correct and idempotent, just not yet optimized.)

The table below shows the outcome of a crash at each step (identical for PerRecord and Group, because the step-1 barrier has already run):

Crash pointDurable on diskRecovery outcome
After step 1, before step 2WAL onlyReplay all pending records into both stores
After step 2, before step 3WAL + lexical (last_wal_seq = N)Lexical skips ≤ N; vector replays from WAL
After step 3, before step 4WAL + lexical + vectorBoth stores skip; documents restored from WAL
After step 4, before step 5WAL + all storesWAL still present; both stores skip, no duplicates
After step 5All stores; WAL empty unless a mutation raced this commitNothing to replay (or just the racing mutation’s record)

No interleaving lets a committed index reference a lost WAL record, so group commit introduces no new durability gap beyond its documented contract (a crash can lose a suffix of writes not yet made durable by flush_wal() or 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

<storage root> is a layout-agnostic abstraction: the engine itself has no concept of schema.toml or where its storage root sits within a larger directory. laurus-cli, laurus-server, and the language bindings (Python, Node.js, Ruby, PHP) all place <storage root> at <index_dir>/store/, alongside a <index_dir>/schema.toml holding the schema — a convention owned by those entry points, not the engine. This means an index directory created by one of them can be opened by any of the others without restructuring anything on disk; see each binding’s Index/create index documentation for the exact create-vs-reopen semantics (in particular, reopening an existing index only needs the directory path — the persisted schema is loaded automatically).

Next Steps

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"]
  1. The document’s internal ID is added to a deletion bitmap
  2. The bitmap is checked during every search, filtering out deleted documents from results
  3. The original data remains in the segment files

This applies uniformly to the lexical and vector (HNSW) indexes. For HNSW the deleted node stays in the graph — its vector is still used to keep the graph connected — but the deletion-aware traversal excludes it from results. A delete therefore never rebuilds the graph; the cost is the same O(1) bitmap mark regardless of index size. Physical reclamation happens later, during compaction.

Why Logical Deletion?

BenefitDescription
SpeedO(1) — flipping a bit is instant
Immutable segmentsSegment files are never modified in place, simplifying concurrency
Safe recoveryIf 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:

  1. The old document is logically deleted (its ID is added to the deletion bitmap)
  2. A new document is inserted with a new internal ID
  3. 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

  1. Reads all live (non-deleted) documents from existing segments
  2. Rebuilds the inverted index and/or vector index without deleted entries
  3. Writes new, clean segment files
  4. Removes the old segment files
  5. Resets the deletion bitmap

Cost and Frequency

AspectDetail
CPU costHigh — rebuilds index structures from scratch
I/O costHigh — reads all data, writes new segments
BlockingSearches continue during compaction (reads see the old segments until the new ones are ready)
FrequencyRun 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

Automatic Compaction

For the HNSW vector index, compaction can run automatically. When DeletionConfig::auto_compaction is enabled (the default), commit() checks the deletion ratio (deleted nodes / total committed nodes) and triggers compaction once it reaches DeletionConfig::compaction_threshold (default 0.3). Compaction resets the ratio to zero, so it does not re-fire until deletions accumulate again — bounding tombstone growth without a manual optimize(). Set auto_compaction to false to manage compaction yourself.

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_deleted is on the per-document (lexical) and per-neighbour (vector) search hot paths. A lexical segment reader reads its segment’s bitmap once, when it opens, and keeps it fixed, so its checks take no lock. It also copies the deletions into a plain bitset over the segment’s ID range (one bit per ID, like Lucene’s FixedBitSet and Tantivy’s AliveBitSet), so a check tests a single bit instead of searching the Roaring containers. A segment whose range is more than 8× wider than its document count (the bitset would then take more than one byte per document) keeps only the Roaring bitmap.

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.

A segment’s .delmap is read when the segment is opened. One that exists but cannot be read is reported as a corruption error, whether the segment is being opened for search, merged, or loaded by the writer’s deletion tracking. It is never read as “no deletions”, which would bring the segment’s deleted documents back into results and counts, or carry them into a merged segment. A segment whose metadata records deletions but has no .delmap file still opens as a segment with none.

The index keeps a segment’s reader from one commit to the next (see Reading After a Commit). The writer rewrites a segment’s .delmap in place, under the same name, so each time the index builds a reader it reads the .delmap of every segment with deletions again and compares its contents with the deletions the kept reader holds. The kept reader is used only when they match; otherwise the segment gets a new reader with the new deletions. A reader built earlier keeps the deletions it read.

Group-Committed Persistence

Deletion state is persisted once per commit, not once per delete. Each delete (including the delete-first step of an upsert) only updates the in-memory bitmap; the .delmap files and the segments’ has_deletions metadata flags are written together when commit() runs. This removes several fsyncs from every existing-ID upsert, which matters for update-heavy ingest.

Durability is unchanged: the WAL records every delete before the index mutation, so a crash before the commit replays the deletions on the next startup — and recovery finishes with an automatic commit, so the replayed state (including deletions) is immediately searchable on the reopened engine. Consequently, deletion visibility is commit-scoped: like newly added documents, a deletion becomes visible to searches after the next commit() (upsert deduplication within an uncommitted batch is handled separately and is always correct).

Next Steps

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:

VariantDescriptionCommon Causes
IoI/O errorsFile not found, permission denied, disk full
IndexIndex operation errorsCorrupt index, segment read failure
SchemaSchema-related errorsUnknown field name, type mismatch
AnalysisText analysis errorsTokenizer failure, invalid filter config
QueryQuery parsing/execution errorsMalformed Query DSL, unknown field in query
StorageStorage backend errorsFailed to open storage, write failure
FieldField definition errorsInvalid field options, duplicate field name
InvalidArgumentThe caller passed an invalid argumentUnknown field in a DSL query, wrong value type in a document, out-of-range value
BenchmarkFailedBenchmark errorsBenchmark execution failure
ThreadJoinErrorThread join errorsPanic in a worker thread
JsonJSON serialization errorsMalformed document JSON
AnyhowWrapped anyhow errorsErrors from third-party crates via anyhow
InvalidOperationInvalid operationSearching before commit, double close
ResourceExhaustedResource limits exceededOut of memory, too many open files
SerializationErrorBinary serialization errorsCorrupt data on disk
OperationCancelledOperation was cancelledTimeout, user cancellation
NotImplementedFeature not availableUnimplemented operation
OtherGeneric errorsTimeout, invalid config, not found

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:

MethodCreates
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)InvalidArgument variant
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 TypeTarget Variant
std::io::ErrorLaurusError::Io
serde_json::ErrorLaurusError::Json
anyhow::ErrorLaurusError::Anyhow

Next Steps

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

MethodDescription
analyze(&self, text: &str) -> Result<TokenStream>Process text into a stream of tokens
name(&self) -> &strReturn a unique identifier for this analyzer
as_any(&self) -> &dyn AnyEnable 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

MethodDescription
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 AnyEnable downcasting

Optional Methods

MethodDefaultDescription
async embed_batch(&self, inputs) -> Result<Vec<Vector>>Sequential calls to embedOverride for batch optimization
name(&self) -> &str"unknown"Identifier for logging
supports(&self, input_type) -> boolChecks supported_input_typesInput type support check
supports_text() -> boolChecks for TextText support shorthand
supports_image() -> boolChecks for ImageImage support shorthand
is_multimodal() -> boolBoth text and imageMultimodal 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

MethodDescription
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) -> boolCheck 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

MethodDefaultDescription
loading_mode() -> LoadingModeLoadingMode::EagerPreferred 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

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.

MethodDescription
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.put_documents(docs).await?Batched upsert of (id, doc) pairs — one WAL fsync per batch (see Batch Ingestion)
engine.add_documents(docs).await?Batched chunk append of (id, doc) pairs — same durability and error semantics as put_documents
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.flush_wal()?Force the WAL durable without a full commit (see WAL Durability Policy)
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_document vs add_document: put_document performs an upsert — if a document with the same external ID already exists, it is deleted and replaced. add_document always appends, allowing multiple document chunks to share the same external ID. See Schema & Fields — Indexing Documents for details.

Batch forms: put_documents / add_documents apply their (id, doc) pairs sequentially in input order (duplicate IDs within one put_documents batch dedup exactly like sequential puts — the last occurrence wins) and fail fast with LaurusError::BatchIngest { failed_index, failed_id, applied, .. } on the first document that cannot be applied. There is no batch rollback: applied documents stay in the WAL and NRT buffers, so retrying the batch (or its suffix) is idempotent. See Batch Ingestion for the durability details.

EngineBuilder

MethodDescription
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)
.wal_sync_policy(policy)Set the WAL durability policy (default: WalSyncPolicy::PerRecord; see WAL Durability Policy)
.build().await?Build the Engine

Schema

Defines document structure.

MethodDescription
Schema::builder()Create a SchemaBuilder

SchemaBuilder

MethodDescription
.add_text_field(name, TextOption)Add a full-text field (set TextOption::multi_valued = true for arrays of strings; TextOption::position_increment_gap, default 100, is the number of positions skipped between elements so that phrase queries do not span them)
.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 (set BooleanOption::multi_valued = true for arrays of booleans)
.add_datetime_field(name, DateTimeOption)Add a datetime field (set DateTimeOption::multi_valued = true for arrays of instants)
.add_geo_field(name, GeoOption)Add a 2D geographic (lat/lon) field (set GeoOption::multi_valued = true for arrays of points)
.add_geo3d_field(name, Geo3dOption)Add a 3D ECEF Cartesian point field (x, y, z in metres; set Geo3dOption::multi_valued = true for arrays of points)
.add_bytes_field(name, BytesOption)Add a binary field (set BytesOption::multi_valued = true for arrays of byte strings; Bytes is never indexed, so this only governs the stored shape and ingestion arity)
.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_multi_vector_field(name, MultiVectorOption)Add a multi-vector field: per-document token vectors for late-interaction rescoring, not searchable; .storage(MultiVectorStorage) picks the on-disk element kind (F32 default and exact, F16 2x smaller, Int8 ~4x smaller and lossy, Binary 32x smaller, lossier, Cosine only), and .pool_factor(u32) keeps about 1 / pool_factor of each document’s token vectors (default 1, all)
.add_default_field(name)Set a default search field
.add_analyzer(name, AnalyzerDefinition)Register a custom analyzer pipeline. A name reserved for a built-in analyzer (standard, keyword, english, simple, noop) makes .build() panic and .try_build() return an error; a Schema built another way (e.g. Schema::from_toml) is checked with Schema::validate_for_create
.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

A field name starting with _ (other than _id) is accepted by every add_*_field method above; it is rejected only when the schema is checked by Schema::validate_for_create — .build()/.try_build() here, or index creation for a schema built another way (e.g. Schema::from_toml). See Field Naming.

Document

A collection of named field values.

MethodDescription
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

MethodDescription
.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_geo_array(name, points)Add a multi-valued 2D geographic field (Vec<GeoPoint>)
.add_geo_ecef_array(name, points)Add a multi-valued 3D ECEF field (Vec<GeoEcefPoint>)
.add_datetime_array(name, values)Add a multi-valued datetime field (Vec<DateTime<Utc>>)
.add_bool_array(name, values)Add a multi-valued boolean field (Vec<bool>)
.add_text_array(name, values)Add a multi-valued text field (Vec<String>)
.add_bytes(name, data)Add binary data
.add_bytes_array(name, values)Add a multi-valued binary field (Vec<(Vec<u8>, Option<String>)>; each element carries its own optional MIME type)
.add_vector_array(name, vectors)Add the token vectors of a multi-vector field (Vec<Vec<f32>>)
.build()Build the Document

Search

SearchRequestBuilder

MethodDescription
SearchRequestBuilder::new()Create a new builder
.query_dsl(dsl)Set a unified DSL string (parsed at search time). A .vector_query(..) set with it is added to the DSL’s vector part; a .lexical_query(..) is ignored
.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. The search fails with InvalidArgument if field is not a lexical field of the schema (or _id)
.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
.highlight(fields)Request highlights on these stored text fields (Highlighting)
.highlight_config(HighlightConfig)Set the tag/fragment/require_field_match settings used for highlighting
.vector_score_mode(VectorScoreMode)Set score combination mode for vector search
.vector_min_score(f32)Set minimum score threshold for vector search
.vector_rerank_factor(usize)Set the Stage 2 rerank widening factor for vector search
.vector_ef_search(usize)Override the HNSW ef_search candidate-list size for this search
.vector_fields(Vec<FieldSelector>)Restrict query vectors that name no field to these fields (a query vector’s own fields take precedence)
.vector_overfetch(f32)Set the overfetch factor for per-field vector candidates (default 2.0; <= 1.0 disables it)
.rescore(RescoreOptions)Reorder the top first-stage candidates with late interaction (Late-Interaction Rescore)
.group_by(GroupBy)Group the ranked hits by a field’s value; limit and offset then count groups (Result Grouping)
.build()Build the SearchRequest

LexicalSearchQuery

VariantDescription
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

VariantDescription
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

FieldTypeDescription
idStringExternal document ID
scoref32Relevance score (the late-interaction score for a rescored hit)
documentOption<Document>Document content (if loaded)
highlightsHashMap<String, Vec<String>>Highlighted fragments per field requested via .highlight(); empty when not requested or nothing matched (Highlighting)
group_keyOption<DataValue>With .group_by(): the value this result’s group shares; None for an ungrouped search or a hit without a value (Result Grouping)
inner_hitsVec<SearchResult>With .group_by(): the group’s next best hits, in ranking order; empty for an ungrouped search

FusionAlgorithm

VariantDescription
RRF { k: f64 }Reciprocal Rank Fusion (default k=60.0)
WeightedSum { lexical_weight, vector_weight }Linear combination of scores

RescoreOptions

ItemDescription
RescoreOptions::late_interaction(field, query_vectors)Rescore by late interaction (MaxSim) against a multi-vector field, with the query’s token vectors (Vec<Vector>)
RescoreOptions::late_interaction_text(field, text)Same, with query text that the field’s token-level embedder embeds
.window_size(n)How many top first-stage candidates to rescore (default 100, at most RescoreOptions::MAX_WINDOW_SIZE = 10,000)
Rescorer::LateInteraction { field, query }The rescorer the constructors build; query is LateInteractionQuery::Vectors(Vec<Vector>) or LateInteractionQuery::Text(String). Both enums are #[non_exhaustive]

GroupBy

ItemDescription
GroupBy::new(field)Group by field: _id, or a single-valued, stored Text, Integer, Float, Boolean or DateTime field with doc_values (Result Grouping)
.group_size(n)Hits kept per group, the returned result included (default 1, at most GroupBy::MAX_GROUP_SIZE = 100)
GroupBy::MAX_CANDIDATES10,000: the largest candidate pool a grouped search ranks, and the largest offset + limit it accepts

Query Types (Lexical)

QueryDescriptionExample
TermQuery::new(field, term)Exact term matchTermQuery::new("body", "rust")
PhraseQuery::new(field, terms)Exact phrasePhraseQuery::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)WildcardWildcardQuery::new("file", "*.pdf")
NumericRangeQuery::new(...)Numeric rangeSee Lexical Search
DateTimeRangeQuery::between(field, start, end)DateTime range (new(...) takes Option<DateTime<Utc>> bounds; from_literals(...)? takes DSL literals)See Lexical Search
GeoDistanceQuery::within_radius(...)2D geo radiusSee Lexical Search
GeoBoundingBoxQuery::within_bounding_box(...)2D geo bounding boxSee Lexical Search
Geo3dDistanceQuery::within_sphere(...)3D ECEF sphereSee 3D Geographic Search
Geo3dBoundingBoxQuery::within_box(...)3D ECEF axis-aligned boxSee 3D Geographic Search
Geo3dNearestQuery::k_nearest(...)3D ECEF k-NNSee 3D Geographic Search
SpanNearQuery::new(...)ProximitySee Lexical Search
PrefixQuery::new(field, prefix)Prefix matchPrefixQuery::new("body", "pro")
RegexpQuery::new(field, pattern)?Regex matchRegexpQuery::new("body", "^pro.*ing$")?

Query Parsers

ParserDescription
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

TypeDescription
StandardAnalyzerRegexTokenizer + lowercase + stop words
SimpleAnalyzerTokenization only (no filtering)
EnglishAnalyzerRegexTokenizer + lowercase + English stop words
JapaneseAnalyzerJapanese morphological analysis
KeywordAnalyzerNo tokenization (exact match)
PipelineAnalyzerCustom tokenizer + filter chain
PerFieldAnalyzerPer-field analyzer dispatch

Embedders

TypeFeature FlagDescription
CandleBertEmbedderembeddings-candleLocal BERT model
OpenAIEmbedderembeddings-openaiOpenAI API
CandleClipEmbedderembeddings-multimodalLocal CLIP model
CandleColbertEmbedderembeddings-candleLocal ColBERT model producing token vectors for multi-vector fields (with_options(model, CandleColbertOptions) pins a revision and lengths)
PrecomputedEmbedder(default)Pre-computed vectors
PerFieldEmbedder(default)Per-field embedder dispatch

A token-level embedder also implements TokenEmbedder (embed_tokens(inputs, EmbedRole), token_dimension()) and returns it from Embedder::as_token_embedder(); see Token-Level Embedders.

Storage

TypeDescription
MemoryStorageIn-memory (non-durable)
FileStorageFile-system based (supports use_mmap for memory-mapped I/O)
StorageFactory::create(config)Create from config

DataValue

VariantRust 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)
DataValue::GeoArray(Vec<GeoPoint>)Multi-valued 2D geographic points (requires multi_valued field option)
DataValue::GeoEcefArray(Vec<GeoEcefPoint>)Multi-valued 3D ECEF points (requires multi_valued field option)
DataValue::DateTimeArray(Vec<DateTime<Utc>>)Multi-valued instants (requires multi_valued field option)
DataValue::BoolArray(Vec<bool>)Multi-valued booleans (requires multi_valued field option)
DataValue::TextArray(Vec<String>)Multi-valued strings (requires multi_valued field option)
DataValue::BytesArray(Vec<(Vec<u8>, Option<String>)>)Multi-valued binary content, each element carrying its own optional MIME type (requires multi_valued field option; never indexed)
DataValue::VectorArray(Vec<Vec<f32>>)Token vectors of a multi-vector field (1 to 8,192 vectors of the field’s dimension; not stored in the document store)

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 '{"fields":{"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

Prebuilt binaries

Each GitHub release ships prebuilt laurus binaries for the following targets, all built with --features embeddings-all:

Target triplePlatformlibcLinkingArchive
x86_64-unknown-linux-gnuLinux x86_64glibcdynamic.tar.gz
aarch64-unknown-linux-gnuLinux aarch64glibcdynamic.tar.gz
x86_64-unknown-linux-muslLinux x86_64muslstatic.tar.gz
aarch64-unknown-linux-muslLinux aarch64muslstatic.tar.gz
x86_64-apple-darwinmacOS Intel–dynamic.tar.gz
aarch64-apple-darwinmacOS Apple Silicon–dynamic.tar.gz
x86_64-pc-windows-msvcWindows x86_64MSVCdynamic.zip
aarch64-pc-windows-msvcWindows arm64MSVCdynamic.zip
VERSION=v0.13.2
TARGET=x86_64-unknown-linux-musl
curl -fsSL -O "https://github.com/mosuka/laurus/releases/download/${VERSION}/laurus-${VERSION}-${TARGET}.tar.gz"
tar -xzf "laurus-${VERSION}-${TARGET}.tar.gz"
./laurus --version

Which build should I use?

  • Use a gnu build on an ordinary Linux distribution. musl’s allocator (mallocng) is measurably slower than glibc’s under multi-threaded, allocation-heavy workloads, which describes laurus’s indexing path.
  • Use a musl build for Alpine, distroless, scratch, or any host whose glibc is older than the build runner’s – the musl binaries are fully statically linked and have no dynamic library dependencies at all.

Using the musl binary in a container

FROM alpine:3.22
# Needed whenever an embedding feature makes an HTTPS call: reqwest's
# rustls backend verifies against the OS trust store for Hugging Face
# model downloads (`embeddings-candle` / `embeddings-multimodal`) as well
# as `embeddings-openai`. The prebuilt binary is built with
# `--features embeddings-all`, so install this even for a lexical-only
# workload. See "Feature Flags" in the development guide for details.
RUN apk add --no-cache ca-certificates
COPY laurus /usr/local/bin/laurus
ENTRYPOINT ["laurus"]

or, with no package manager at all (only safe if you never call an embedding feature, since there is no CA bundle to verify HTTPS downloads):

FROM scratch
COPY laurus /laurus
ENTRYPOINT ["/laurus"]

A couple of runtime notes for musl/scratch deployments:

  • DNS resolution uses musl’s built-in resolver, which reads /etc/resolv.conf and ignores NSS plugins. Container runtimes always provide /etc/resolv.conf, so this works out of the box; a hand-built scratch image must not omit it.
  • Rust’s standard library uses its own default stack size for spawned threads (2 MiB), not musl’s smaller pthread default, so this is not usually a concern in practice.

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

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 = true

[fields.body.Text]
indexed = true
stored = true
term_vectors = true

[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 of the shape {"fields": {...}} with field values:

laurus --index-dir ./tutorial_data add doc \
  --id doc001 \
  --data '{"fields":{"title":"Introduction to Rust Programming","body":"Rust is a modern systems programming language that focuses on safety, speed, and concurrency.","category":"programming"}}'
laurus --index-dir ./tutorial_data add doc \
  --id doc002 \
  --data '{"fields":{"title":"Web Development with Rust","body":"Building web applications with Rust has become increasingly popular. Frameworks like Actix and Rocket make it easy to create fast and secure web services.","category":"web-development"}}'
laurus --index-dir ./tutorial_data add doc \
  --id doc003 \
  --data '{"fields":{"title":"Python for Data Science","body":"Python is the most popular language for data science and machine learning. Libraries like NumPy and Pandas provide powerful tools for data analysis.","category":"data-science"}}'

Step 4: Commit Changes

Documents are not searchable until committed:

laurus --index-dir ./tutorial_data commit

Step 5: Search Documents

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.

Search only in the title field:

laurus --index-dir ./tutorial_data search "title:python"

Only doc003 is returned.

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”).

Search for an exact phrase:

laurus --index-dir ./tutorial_data search 'body:"data science"'

Only doc003 is returned.

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 {"fields":{"title":"Go Programming","body":"Go is a statically typed language designed for simplicity and efficiency.","category":"programming"}}
> commit
> search programming
> get docs doc004
> delete docs doc004
> commit
> quit

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

Command Reference

Global Options

Every command accepts these options:

OptionEnvironment VariableDefaultDescription
--index-dir <PATH>LAURUS_INDEX_DIR./laurus_indexPath to the index data directory
--format <FORMAT>—tableOutput 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>] [--train-pq-codebook <JSONL>]

Arguments:

FlagRequiredDescription
--schema <FILE>NoPath 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.
--train-pq-codebook <JSONL>NoTrain shared PQ codebooks as part of creation (Issue #920). Every HNSW field configuring ProductQuantization (or, with the pq-fastscan feature, ProductQuantizationFastScan) + pq_codebook_path is trained from this JSONL file (the put docs / add docs shape; each field value a plain numeric array) immediately after the index is created, so the very first commit can already encode against the codebook — removing the create → train pq-codebook → ingest ordering the failure policy otherwise requires you to manage manually. Errors before creating anything if the file is missing or no field is eligible.

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.

# Create and train the shared PQ codebook in one step (Issue #920)
laurus --index-dir ./my_index create index --schema schema.toml \
    --train-pq-codebook train.jsonl
# Index created at ./my_index.
# Training PQ codebook for field 'embedding' on 300 vectors...
# Trained codebook 'embedding.pqcb' (m = 4, k = 256, sub_dim = 8, dimension = 32) from 300 vectors.

Note: If both schema.toml and store/ already exist, an error is returned. Delete the index directory to recreate. If only schema.toml exists (e.g. after an interrupted creation), running create index without --schema recovers 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:

FlagRequiredDefaultDescription
--output <FILE>Noschema.tomlOutput file path for the generated schema

The wizard guides you through:

  1. Field definition — Enter a field name, select the type, and configure type-specific options
  2. Repeat — Add as many fields as needed
  3. Default fields — Select which lexical fields to use as default search fields
  4. Preview — Review the generated TOML before saving
  5. Save — Write the schema file

Supported field types:

TypeCategoryOptions
TextLexicalindexed, stored, term_vectors
IntegerLexicalindexed, stored
FloatLexicalindexed, stored
BooleanLexicalindexed, stored
DateTimeLexicalindexed, stored
GeoLexicalindexed, stored
Geo3dLexicalindexed, stored
BytesLexicalstored
HnswVectordimension, distance, m, ef_construction
FlatVectordimension, distance
IvfVectordimension, distance, n_clusters, n_probe

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:

FlagRequiredDescription
--id <ID>YesExternal document ID (string)
--data <JSON>YesDocument fields as a JSON string

The JSON is {"fields": {...}}: an object mapping each field name to its plain value. There are no type tags — the value’s declared schema type (or, for an undeclared field, its inferred type) resolves any ambiguity:

{
  "fields": {
    "title": "Introduction to Rust",
    "body": "Rust is a systems programming language.",
    "year": 2024
  }
}

Example:

laurus add doc --id doc1 --data '{"fields":{"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 doc for each chunk.

add docs

Bulk-add document chunks from a JSONL file — one {"id": "...", "fields": {...}} entry per line, with the external ID as a sibling top-level key alongside the same fields shape as add doc --data. Entries are applied through the engine’s batch API (one WAL fsync per batch) and, unlike add doc, the command commits automatically: every --commit-every applied documents and once at the end.

laurus add docs --file <JSONL> [--batch-size 1000] [--commit-every 0]

Arguments:

FlagRequiredDescription
--file <JSONL>YesPath to the JSONL file to ingest
--batch-size <N>NoDocuments per engine batch call (default 1000)
--commit-every <N>NoCommit every N applied documents; 0 = only the final commit (default)

Repeated IDs accumulate as chunks. On a mid-file failure the error names the offending line, the applied prefix is committed, and re-running the remaining lines continues the ingest.


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:

FlagRequiredDescription
--id <ID>YesExternal document ID (string)
--data <JSON>YesDocument fields as a JSON string

Example:

laurus put doc --id doc1 --data '{"fields":{"title":"Updated Title","body":"This replaces the existing document."}}'
# Document 'doc1' put (upserted). Run 'commit' to persist changes.

Note: Unlike add doc, put doc replaces all existing chunks for the given ID. Use add doc when you want to append chunks, and put doc when you want to replace the entire document.

put docs

Bulk-upsert documents from a JSONL file — one {"id": "...", "fields": {...}} entry per line, applied through the engine’s batch API (one WAL fsync per batch). Duplicate IDs dedup in order (the last occurrence wins). Like add docs, the command commits automatically.

laurus put docs --file <JSONL> [--batch-size 1000] [--commit-every 0]

Arguments are the same as add docs. On a mid-file failure the error names the offending line and the applied prefix is committed; because puts are idempotent, re-running the whole file (or its remaining suffix) is safe.

Example:

cat > docs.jsonl <<'JSONL'
{"id": "doc1", "fields": {"title": "Hello"}}
{"id": "doc2", "fields": {"title": "World"}}
JSONL
laurus put docs --file docs.jsonl
# 2 documents put (upserted) and committed.

add field

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.

train

train pq-codebook

Train a shared PQ codebook for an HNSW vector field (Issue #631). The codebook is trained once on a representative sample and then reused by every subsequent commit and merge, instead of re-training k-means per segment — commits on PQ fields get dramatically faster, and even tiny per-commit segments stay on PQ.

laurus train pq-codebook --field <FIELD> (--input <JSONL> | --from-index) \
    [--sample-size <N>] [--output <NAME>] [--update-schema]
ArgumentDescription
--fieldThe HNSW vector field to train for. Must be configured with a ProductQuantization quantizer (or ProductQuantizationFastScan when the pq-fastscan feature is enabled — the codebook is then trained with k=16, Issue #920).
--inputJSONL training file — the same {"id": "...", "fields": {...}} shape as put docs / add docs. The field value must be a plain numeric array, e.g. "embedding": [0.1, 0.2, ...] (embedder-generated input is not supported). Exactly one of --input and --from-index must be given.
--from-indexSample the vectors already committed to this index instead of reading a file (Issue #920) — no JSONL export needed. Exactly one of --input and --from-index must be given. Note: on a field that is already PQ-encoded, the sampled vectors are lossy reconstructions; the intended flow is to train from vectors committed before enabling PQ on the field.
--sample-sizeUse only the first N vectors (deterministic: file order for --input, ascending doc_id for --from-index). Omit to use all of them; thousands of representative vectors are enough.
--outputStorage-relative codebook file name. Defaults to the field’s configured pq_codebook_path, else {field}.pqcb. Use to train a v2 codebook alongside a live one.
--update-schemaRewrite schema.toml so the field’s pq_codebook_path names the trained file.

Commits use the codebook only when the schema’s pq_codebook_path names it (see Schema Format) — pass --update-schema to set it as part of training. A commit made while pq_codebook_path is set but the codebook has not been trained yet fails with an error naming this command; there is no silent fallback to per-segment training. The codebook is picked up when the index is opened, so train before the ingesting add / put / commit invocation (each CLI invocation opens the index fresh, so any subsequent command sees it).

Example:

cat > train.jsonl <<'JSONL'
{"id": "t1", "fields": {"embedding": [0.1, 0.2, 0.3, 0.4]}}
{"id": "t2", "fields": {"embedding": [0.5, 0.6, 0.7, 0.8]}}
JSONL
laurus train pq-codebook --field embedding --input train.jsonl --update-schema
# Training PQ codebook for field 'embedding' on 2 vectors...
# Trained codebook 'embedding.pqcb' (m = 2, k = 256, sub_dim = 2, dimension = 4) from 2 vectors.
# Updated schema.toml: embedding.pq_codebook_path = "embedding.pqcb".

Or sample directly from the vectors already committed to the index — no JSONL export needed:

laurus train pq-codebook --field embedding --from-index --sample-size 5000 --update-schema

search

Execute a search query using the Query DSL.

laurus search <QUERY> [--limit <N>] [--offset <N>] [--highlight <FIELD>]...
              [--rescore-field <FIELD> (--rescore-text <TEXT> | --rescore-vectors <JSON>) [--rescore-window <N>]]
              [--group-by <FIELD> [--group-size <N>]]

The query string is analyzed with each field’s own configured analyzer — a field declared with a Japanese (Lindera) analyzer in schema.toml, for example, is analyzed the same way at query time as it was at index time. Referencing a field that is not declared in the schema is rejected with an error naming the field (helpful for catching typos); the reserved _id field is always queryable even though it does not appear in the schema.

Arguments:

Argument / FlagRequiredDefaultDescription
<QUERY>Yes—Query string in Laurus Query DSL
--limit <N>No10Maximum number of results
--offset <N>No0Number of results to skip
--highlight <FIELD>No(none)Stored text field to highlight (Issue #1134); repeat for multiple fields. Uses the default HighlightConfig — one-shot only, not available in the REPL
--rescore-field <FIELD>No(none)MultiVector field whose token vectors rescore the top results with late interaction (Issue #1351). Needs --rescore-text or --rescore-vectors
--rescore-text <TEXT>No(none)Rescore query text, embedded by the field’s token-level embedder (a candle_colbert one)
--rescore-vectors <JSON>No(none)Rescore query token vectors as JSON, e.g. '[[0.1, 0.2], [0.3, 0.4]]'
--rescore-window <N>No100How many top results to rescore (at most 10,000)
--group-by <FIELD>No(none)Group the results by this field’s value (Issue #1297): _id, or a single-valued, stored Text, Integer, Float, Boolean or DateTime field. --limit and --offset then count groups
--group-size <N>No1How many results each group keeps, the shown one included (at most 100). Needs --group-by

A rescored result’s score is its late-interaction (MaxSim) score; see Late-Interaction Rescore. For example, with a candle_colbert embedder on body_colbert:

laurus search 'body:lifetimes' --rescore-field body_colbert --rescore-text 'how do lifetimes work'

--group-by _id shows each document added in chunks (laurus add doc) once, by its best chunk; see Result Grouping. With --group-size 2, a group’s next best chunk follows it, indented, and a Group column shows each group’s value:

laurus search "body:rust" --group-by _id --group-size 2
╭────────────┬────────┬────────┬──────────────────────────────────────────────╮
│ ID         │ Score  │ Group  │ Fields                                       │
├────────────┼────────┼────────┼──────────────────────────────────────────────┤
│ manual     │ 0.0126 │ manual │ body: Rust ownership rules for Rust programs │
│   ↳ manual │ 0.0124 │        │ body: Borrowing in Rust                      │
│ faq        │ 0.0109 │ faq    │ body: Why Rust has no garbage collector      │
╰────────────┴────────┴────────┴──────────────────────────────────────────────╯

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)"

# Highlighted fragments on the "body" field
laurus search "body:rust" --highlight body

# Highlight multiple fields
laurus search "body:rust" --highlight title --highlight body

Table output example:

╭──────┬────────┬─────────────────────────────────────────╮
│ ID   │ Score  │ Fields                                  │
├──────┼────────┼─────────────────────────────────────────┤
│ doc1 │ 0.8532 │ body: Rust is a systems..., title: Intr │
│ doc3 │ 0.4210 │ body: JavaScript powers..., title: Web  │
╰──────┴────────┴─────────────────────────────────────────╯

A Highlights column appears only when --highlight was passed:

╭──────┬────────┬─────────────────────────┬──────────────────────────────╮
│ ID   │ Score  │ Fields                  │ Highlights                   │
├──────┼────────┼─────────────────────────┼──────────────────────────────┤
│ doc1 │ 0.8532 │ body: Rust is a syst... │ body: <mark>Rust</mark> is a │
╰──────┴────────┴─────────────────────────┴──────────────────────────────╯

JSON output example:

laurus --format json search "body:rust" --limit 5
[
  {
    "id": "doc1",
    "score": 0.8532,
    "fields": {
      "title": "Introduction to Rust",
      "body": "Rust is a systems programming language."
    }
  }
]

With --highlight body, each matching result also gains a "highlights" key (present only when at least one field actually highlighted):

[
  {
    "id": "doc1",
    "score": 0.8532,
    "fields": {
      "title": "Introduction to Rust",
      "body": "Rust is a systems programming language."
    },
    "highlights": {
      "body": ["<mark>Rust</mark> is a systems programming language."]
    }
  }
]

See Highlighting for the full semantics — field selection, per-field analyzers, and why a filter_query never highlights (not reachable from this one-shot form of search, but relevant to any client using the same underlying option).

With --group-by, a result gains "group_key" (when it has a value) and "inner_hits" (when the group has more results, each in the same shape):

laurus --format json search "body:rust" --group-by _id --group-size 2
[
  {
    "fields": {
      "body": "Rust ownership rules for Rust programs"
    },
    "group_key": "manual",
    "id": "manual",
    "inner_hits": [
      {
        "fields": {
          "body": "Borrowing in Rust"
        },
        "group_key": "manual",
        "id": "manual",
        "score": 0.012359549291431904
      }
    ],
    "score": 0.012571428902447224
  },
  {
    "fields": {
      "body": "Why Rust has no garbage collector"
    },
    "group_key": "faq",
    "id": "faq",
    "score": 0.010891089215874672
  }
]

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:


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:

FlagEnvironment VariableRequiredDescription
--endpoint <URL>LAURUS_ENDPOINTNogRPC 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 five 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"]

# Custom analyzer definitions, referenced by name from Text fields. Optional.
[analyzers.<analyzer_name>]
# ... tokenizer, char_filters, token_filters

# Embedder definitions, referenced by name from vector fields. Optional.
[embedders.<embedder_name>]
# ... type and type-specific options

# 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.
  • analyzers — A map of names to custom text-analysis pipelines. A Text field uses one by naming it in its analyzer option. Optional. See Analyzers.
  • embedders — A map of names to embedding models. A vector field uses one by naming it in its embedder option. Optional. See Embedders.
  • 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). Declaring any other _-prefixed field is rejected when the index is created, with Field name '_score' is reserved: names starting with '_' are reserved for system fields (allowed: '_id'); create index creates nothing. An index created before this check keeps opening, and the field stays unusable — a document that sets it is still rejected at ingestion, exactly as it is today.
  • 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
multi_valued = false         # Whether to accept arrays of strings (Issue #1175)
position_increment_gap = 100 # Positions skipped between the elements of a multi-valued field
term_vectors = true          # Whether to store term positions (for phrase and span queries)
doc_values = true            # Whether to also copy the value into DocValues (for sorting/faceting)
analyzer = "standard"        # Analyzer for indexing and querying this field
OptionTypeDefaultDescription
indexedbooltrueEnables searching this field
storedbooltrueStores the original value so it can be returned in results
multi_valuedboolfalseAccept arrays of strings; a term query matches if any element contains the term (Lucene-style “any match”); a phrase query never spans two elements unless its slop reaches position_increment_gap
position_increment_gapinteger100Positions skipped between the elements of a multi-valued field (Lucene positionIncrementGap); 0 numbers the elements as if concatenated. Ignored unless multi_valued = true
term_vectorsbooltrueStores term positions, read by phrase and span queries; highlighting always re-tokenizes the stored text and does not use them
doc_valuesbooltrueCopies the value into DocValues, the column-oriented store sorting and faceting/aggregation read from. Takes effect only when stored is also true — see Common option: doc_values below
analyzerstring or table(omit)Analyzer used both when indexing and when parsing queries against this field. A string names a built-in analyzer ("standard", "english", "keyword", "simple", "noop") or an entry in [analyzers.*]. A table selects a parameterized built-in preset; today only { language = "japanese", mode = "normal", dict = "<path>" } (see Text Analysis). When omitted, "standard" is used

The interactive generator (laurus create schema, see Generating a Schema) asks whether a Text field is multi-valued and, when it is, for its position increment gap.

Integer

64-bit signed integer field. Supports range queries and exact match.

[fields.year.Integer]
indexed = true
stored = true
multi_valued = false
doc_values = true
OptionTypeDefaultDescription
indexedbooltrueEnables range and exact-match queries
storedbooltrueStores the original value
multi_valuedboolfalseAccept arrays of integers; range queries match if any value satisfies the predicate (Lucene-style “any match” with constant scoring)
doc_valuesbooltrueSee Common option: doc_values below

Float

64-bit floating point field. Supports range queries.

[fields.rating.Float]
indexed = true
stored = true
multi_valued = false
doc_values = true
OptionTypeDefaultDescription
indexedbooltrueEnables range queries
storedbooltrueStores the original value
multi_valuedboolfalseAccept arrays of floats; range queries match if any value satisfies the predicate (Lucene-style “any match” with constant scoring)
doc_valuesbooltrueSee Common option: doc_values below

Boolean

Boolean field (true / false).

[fields.published.Boolean]
indexed = true
stored = true
multi_valued = false
doc_values = true
OptionTypeDefaultDescription
indexedbooltrueEnables filtering by boolean value
storedbooltrueStores the original value
multi_valuedboolfalseAccept arrays of booleans; a term query (flags:true) matches if any element equals the queried value (Lucene-style “any match”); repeated elements raise the term frequency, not the hit count
doc_valuesbooltrueSee Common option: doc_values below

DateTime

UTC timestamp field. Supports range queries.

[fields.created_at.DateTime]
indexed = true
stored = true
multi_valued = false
doc_values = true
OptionTypeDefaultDescription
indexedbooltrueEnables range queries on date/time
storedbooltrueStores the original value
multi_valuedboolfalseAccept arrays of instants; range queries match if any instant satisfies the predicate (Lucene-style “any match”)
doc_valuesbooltrueSee Common option: doc_values below

Geo

Geographic point field (latitude/longitude). Supports radius and bounding box queries.

[fields.location.Geo]
indexed = true
stored = true
multi_valued = false
doc_values = true
OptionTypeDefaultDescription
indexedbooltrueEnables geo queries (radius, bounding box)
storedbooltrueStores the original value
multi_valuedboolfalseAccept arrays of points; distance / bounding-box queries match if any point satisfies the predicate (Lucene-style “any match”), scoring the document by its closest point
doc_valuesbooltrueSee Common option: doc_values below

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
multi_valued = false
doc_values = true
OptionTypeDefaultDescription
indexedbooltrueEnables 3D geo queries (geo3d_distance, geo3d_bbox, geo3d_nearest)
storedbooltrueStores the original (x, y, z) value
multi_valuedboolfalseAccept arrays of points; geo3d_distance / geo3d_bbox / geo3d_nearest queries match if any point satisfies the predicate (Lucene-style “any match”), scoring the document by its closest point
doc_valuesbooltrueSee Common option: doc_values below

Bytes

Raw binary data field. Not indexed — stored only.

[fields.thumbnail.Bytes]
stored = true
multi_valued = false
OptionTypeDefaultDescription
storedbooltrueStores the binary data
multi_valuedboolfalseAccept arrays of byte strings; a Bytes field is never indexed, so unlike every other multi_valued option this has no “any match” query semantics — it only governs the stored shape and ingestion arity

BytesOption has no doc_values setting: a Bytes value is never written to DocValues regardless, since neither sorting nor faceting can do anything with it.

Common option: doc_values

Every lexical field option above except BytesOption carries a doc_values option, controlling whether the value is also copied into DocValues — the column-oriented store sorting and faceting/aggregation read from. The effective rule: a DocValues column is written only when stored and doc_values are both true. Setting doc_values: false with stored: false is silently ignored (not an error). Turning doc_values off for a field that is never sorted or faceted on shrinks its segment footprint, since the value is then written once (to the stored document) instead of twice; the field remains fully searchable and retrievable either way — sorting and faceting on it simply fall back to the stored document.

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
OptionTypeDefaultDescription
dimensioninteger128Vector dimensionality (must match your embedding model)
distancestring"Cosine"Distance metric (see Distance Metrics)
minteger16Max bi-directional connections per node. Higher = better recall, more memory
ef_constructioninteger200Search width during index construction. Higher = better quality, slower build
base_weightfloat1.0Relative priority vs. other vector fields searched together; no effect on lexical-vs-vector fusion balance (see Vector Search → Weights)
quantizerobject"Scalar8Bit"Quantization method (see Quantization). Mandatory; default keeps the int8 format introduced in Issue #481 Stage 1.
rerank_storagestring(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.
pq_codebook_pathstring(omit)Storage-relative file name of a shared PQ codebook (Issue #631); only meaningful with a ProductQuantization quantizer. Train it with laurus train pq-codebook; commits then encode against it instead of re-training k-means per segment. When set but not yet trained, commits fail loudly (no silent fallback). Omit to train per segment.
embedderstring(omit)Name of an entry in [embedders.*]. Text (or image) values given for this field are then embedded with that model, both when indexing and when searching. Omit to supply precomputed vectors only

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 for all-MiniLM-L6-v2, 768 for BERT-base, 1536 for text-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
OptionTypeDefaultDescription
dimensioninteger128Vector dimensionality
distancestring"Cosine"Distance metric (see Distance Metrics)
base_weightfloat1.0Relative priority vs. other vector fields searched together; no effect on lexical-vs-vector fusion balance (see Vector Search → Weights)
quantizerobject"Scalar8Bit"Quantization method (see Quantization). Mandatory; default keeps the int8 format introduced in Issue #481 Stage 1.
rerank_storagestring(omit)Optional Stage 2 rerank sidecar (see Rerank Storage); supported by all three vector index types since #932. "F32" enables the per-field f32 sidecar so search can rescore int8 candidates against the original vectors.
embedderstring(omit)Name of an entry in [embedders.*]; see Hnsw

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
OptionTypeDefaultDescription
dimensioninteger(required)Vector dimensionality
distancestring"Cosine"Distance metric (see Distance Metrics)
n_clustersinteger100Number of clusters. More clusters = finer partitioning
n_probeinteger1Number of clusters to search at query time. Higher = better recall, slower
base_weightfloat1.0Relative priority vs. other vector fields searched together; no effect on lexical-vs-vector fusion balance (see Vector Search → Weights)
quantizerobject"Scalar8Bit"Quantization method (see Quantization). Mandatory; default keeps the int8 format introduced in Issue #481 Stage 1.
rerank_storagestring(omit)Optional Stage 2 rerank sidecar (see Rerank Storage); supported by all three vector index types since #932. "F32" enables the per-field f32 sidecar so search can rescore int8 candidates against the original vectors.
embedderstring(omit)Name of an entry in [embedders.*]; see Hnsw

Note: Unlike Hnsw and Flat, the dimension field in Ivf is required and has no default value.

Tuning guidelines:

  • n_clusters: A common heuristic is sqrt(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.

MultiVector

Every token vector of a document (for example ColBERT-style per-token embeddings), kept for late-interaction rescoring. It has no ANN index and is not a vector-search target; see Multi-Vector Fields.

[fields.body_colbert.MultiVector]
dimension = 128
distance = "Cosine"
storage = "Int8"  # optional; omit for F32 (exact, default)
pool_factor = 2   # optional; omit (or 1) to keep every token vector
OptionTypeDefaultDescription
dimensioninteger128Dimensionality of every token vector
distancestring"Cosine"Token similarity: "Cosine" (vectors are L2-normalized when written) or "DotProduct". Other metrics are rejected, and "Binary" storage requires "Cosine"
storagestring"F32"On-disk element kind of the token vectors: "F32" (exact, 4 bytes/element), "F16" (2 bytes/element, ~2⁻¹¹ relative error/element), "Int8" (~1 byte/element plus a small per-vector scale, dimension + 2 bytes/row total), or "Binary" (1 bit/element, ceil(dimension / 8) bytes/row, against a center the field learns once it holds 2,048 token vectors; segments written before that are F16). See MultiVector Storage. Changing it on an existing field requires a reindex
pool_factorinteger1Token pooling: each document keeps about 1 / pool_factor of its token vectors (the first one, then cluster means). 1 keeps them all; 0 is rejected. Turning it on for an existing field requires a reindex; changing or turning off a factor in use discards the field’s data. See Token pooling
embedderstring–Name of a token-level embedder (type = "candle_colbert") declared under [embedders]. When set, documents may give the field text, which is embedded into token vectors (see Embedders)

A document’s value is an array of equal-length numeric arrays, between 1 and 8,192 of them, or text when the field has an embedder. The field is not stored in the document store.

Distance Metrics

The distance option for vector fields accepts the following values:

ValueDescriptionUse When
"Cosine"Cosine distance (1 - cosine similarity). Default.Normalized text/image embeddings
"Euclidean"L2 (Euclidean) distanceSpatial data, non-normalized vectors
"Manhattan"L1 (Manhattan) distanceSparse feature vectors
"DotProduct"Dot product (higher = more similar)Pre-normalized vectors where magnitude matters
"Angular"Angular distanceSimilar 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 (HNSW-only)

Issue #481 Stage 3. Stores each vector as subvector_count one-byte centroid indexes against a codebook of 256 centroids per sub-vector (~16-64x compression). Supported by the HNSW index; Flat / IVF reject it at write time. Usually paired with Rerank Storage to recover recall.

[fields.embedding.Hnsw]
dimension = 384
distance = "Cosine"
# Optional (Issue #631): train the codebook once with
# `laurus train pq-codebook` and share it across segments instead of
# re-training k-means on every commit and merge.
pq_codebook_path = "embedding.pqcb"

[fields.embedding.Hnsw.quantizer.ProductQuantization]
subvector_count = 48
OptionTypeDescription
subvector_countintegerNumber of subvectors. Must evenly divide dimension.

By default the codebook is trained per segment (segments with fewer than 256 vectors fall back to Scalar8Bit). With pq_codebook_path set, segments encode against the shared pre-trained codebook instead: commits get dramatically faster, and even tiny per-commit segments stay on PQ — but a commit before the codebook has been trained fails with an error naming the laurus train pq-codebook command to run (never a silent fallback to per-segment training). See the train command for the training workflow.

Breaking change (Issue #481 Stage 1): schemas that explicitly set quantizer to 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 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). Supported by all three vector index types — HNSW, Flat, and IVF (#932) — including a field-less query when the segment holds only one field (#1388).

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
ValueOn-disk overheadDescription
"F32"+4 bytes/dim per vectorIEEE-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.

Analyzers

An [analyzers.<name>] table defines a custom text-analysis pipeline. A Text field uses it by naming it in its analyzer option. Define one when no built-in analyzer fits — for example, to add stemming, or to analyze Japanese text without the stop filter of the japanese preset. See Text Analysis for how the pipeline works.

[analyzers.<name>]
char_filters = [{ type = "...", ... }, ...]   # optional
tokenizer = { type = "...", ... }             # required
token_filters = [{ type = "...", ... }, ...]  # optional
KeyTypeDefaultDescription
tokenizertable(required)Splits the text into tokens. Exactly one
char_filtersarray of tables[]Applied to the raw text before tokenization, in array order
token_filtersarray of tables[]Applied to the token stream after tokenization, in array order

Each component is a table whose type key selects the component; its other keys configure it. Inline tables ({ type = "lowercase" }) are the usual TOML spelling. The same shape is used in JSON schemas ({"type": "lowercase"}) and by every binding’s addAnalyzer / add_analyzer.

Tokenizers

typeRequired keysOptional keysDescription
"whitespace"––Splits on whitespace
"unicode_word"––Splits on Unicode word boundaries
"regex"–pattern (default \w+), gaps (default false)Emits each match of pattern as a token. With gaps = true, pattern matches the separators between tokens instead
"ngram"min_gram, max_gram–Emits every n-gram from min_gram to max_gram characters long
"lindera"mode, dictuser_dictMorphological analysis with Lindera. mode is "normal" or "decompose". dict is the path to a Lindera dictionary directory and user_dict the path to a user dictionary; laurus does not embed a dictionary, so dict must exist on disk
"whole"––Emits the whole input as a single token

Char filters

typeRequired keysOptional keysDescription
"unicode_normalization"form ("nfc" / "nfd" / "nfkc" / "nfkd")–Applies Unicode normalization
"pattern_replace"pattern, replacement–Replaces each match of the regular expression pattern with replacement
"mapping"mapping (a table of string replacements)–Replaces each key of mapping with its value
"japanese_iteration_mark"–kanji (default true), kana (default true)Expands Japanese iteration marks (踊り字)

Token filters

typeRequired keysOptional keysDescription
"lowercase"––Lowercases each token
"stop"–words (default: English stop words)Removes stop words
"stem"–stem_type ("porter" (default) / "simple" / "identity")Reduces each token to its stem
"boost"boost–Multiplies each token’s boost by boost
"limit"limit–Keeps at most limit tokens
"strip"––Trims leading and trailing whitespace from each token
"remove_empty"––Removes empty tokens
"flatten_graph"––Flattens a token graph into a linear stream. Indexing already does this; because the analyzer also parses queries, adding it makes quoted multi-word synonyms inexact at query time

Referencing an analyzer

A Text field names an analyzer in its analyzer option:

[fields.body.Text]
analyzer = "english_stemmed"

The name is resolved in this order:

  1. An analyzer registered at runtime through a binding (for example the WASM binding’s addAnalyzer)
  2. A built-in analyzer: standard, keyword, english, simple, or noop
  3. An entry in [analyzers.*]

Because built-ins are checked first, the names standard, keyword, english, simple and noop are reserved: an [analyzers.*] entry under one of them could never be used, so it is rejected. japanese is not reserved, because its built-in needs a dictionary and is selected with a table, so [analyzers.japanese] is used as defined.

Errors surface at three points:

  • An unknown type or a missing required key is rejected when the schema is parsed; create index creates nothing.
  • An entry named after a built-in is rejected when the index is created, with Analyzer name 'standard' is reserved for a built-in analyzer; choose another name; create index creates nothing. Every binding’s addAnalyzer / add_analyzer (WASM: addAnalyzerDefinition) raises the same error. An index created before this check keeps opening and the entry stays unused; opening it emits a warning through the log crate, which laurus-server prints in its log.
  • An invalid value (a malformed regular expression, an unknown form or stem_type, a missing Lindera dictionary) or an analyzer name that resolves to nothing is rejected when the index is built, with an error such as Failed to resolve analyzer for field 'body': ...; create index creates nothing — schema.toml and store/ are rolled back to whatever state (if any) existed before the call.

Example: English text with stemming

default_fields = ["title", "body"]

[analyzers.english_stemmed]
char_filters = [{ type = "unicode_normalization", form = "nfkc" }]
tokenizer = { type = "unicode_word" }
token_filters = [
    { type = "lowercase" },
    { type = "stop" },
    { type = "stem", stem_type = "porter" },
]

[fields.title.Text]
analyzer = "english_stemmed"

[fields.body.Text]
analyzer = "english_stemmed"

[fields.tag.Text]
analyzer = "keyword"

A document whose body is "Dogs are RUNNING in the park." then matches body:dog (NFKC normalization, lowercasing, and stemming) and body:run, while body:the matches nothing (stop words are removed). The tag field keeps the built-in keyword analyzer, so it matches only its exact value.

Example: Japanese text with Lindera

This definition, taken from examples/aozora/schema.toml, differs from the { language = "japanese" } preset in that it has no stop filter, so particles such as の and は stay in the index:

[analyzers.ja_ipadic]
tokenizer = { type = "lindera", mode = "normal", dict = "/var/lib/lindera/ipadic" }
char_filters = [
    { type = "unicode_normalization", form = "nfkc" },
    { type = "japanese_iteration_mark", kanji = true, kana = true },
]
token_filters = [{ type = "lowercase" }]

[fields.title.Text]
analyzer = "ja_ipadic"

dict must point to an unpacked Lindera dictionary (typically IPADIC). If it does not exist, create index fails with Failed to load dictionary: ... Dictionary path does not exist.

Embedders

An [embedders.<name>] table declares an embedding model. A vector field (Hnsw, Flat, Ivf, or MultiVector) uses it by naming it in its embedder option. Text (or, for CLIP, image) values given for that field are then converted to vectors with the model, both when documents are indexed and when a query targets the field. Several fields can share one embedder. See Embeddings for how each model works and how to choose one.

[embedders.<name>]
type = "..."   # required
model = "..."  # required for every type except "precomputed"
typeRequired keysFeature flagDescription
"precomputed"–(always available)Performs no embedding; documents supply the vectors directly
"candle_bert"modelembeddings-candleLocal text embedding with a BERT-family model from Hugging Face Hub, such as "sentence-transformers/all-MiniLM-L6-v2"
"candle_clip"modelembeddings-multimodalLocal text and image embedding with a CLIP model from Hugging Face Hub, such as "openai/clip-vit-base-patch32"
"openai"modelembeddings-openaiText embedding through the OpenAI API, such as "text-embedding-3-small". The API key is read from the OPENAI_API_KEY environment variable when the engine starts and is never stored in the schema
"candle_colbert"modelembeddings-candleLocal token-level embedding with a BERT-based ColBERT checkpoint, such as "colbert-ir/colbertv2.0" or "answerdotai/answerai-colbert-small-v1". Only a MultiVector field can use it

"candle_colbert" also takes these optional keys:

KeyTypeDefaultDescription
revisionstringdefault branchBranch, tag or commit of the model repository. Pin a commit: documents not yet committed are embedded again from the write-ahead log on recovery, and a changed model would produce different vectors
query_maxlenintegerfrom the checkpoint (32)Number of tokens every query is padded or truncated to
doc_maxlenintegerfrom the checkpoint (180 for colbertv2.0, 300 for answerai-colbert-small-v1)Maximum number of tokens of a document

Hugging Face models are downloaded on first use. candle_bert and candle_clip cache them under $HF_HOME (default ~/.cache/huggingface); candle_colbert uses the Hugging Face default cache, $HF_HOME/hub (default ~/.cache/huggingface/hub), which Python tools share. The vector field’s dimension must equal the model’s output dimension; for candle_colbert this is checked when the engine starts.

An embedder must fit the field that names it: a MultiVector field accepts only "candle_colbert" or "precomputed", and an Hnsw, Flat or Ivf field does not accept "candle_colbert". Any other combination is rejected by create index, add field and update field.

Note: The prebuilt release binaries are built with --features embeddings-all. A laurus binary installed with cargo install laurus-cli or built from source enables none of the embedding features unless you pass them (for example cargo install laurus-cli --features embeddings-candle), so only "precomputed" works. See Installation and Feature Flags. A schema that names a type whose feature is missing still parses, but create index fails:

Error: Not implemented: candle_bert embedder requires the 'embeddings-candle' feature to be enabled

A vector field’s embedder must name an [embedders.*] entry. An undeclared name is rejected by create index, add field, and update field, and an existing index whose schema.toml holds one fails to open:

Error: Invalid argument: Unknown embedder 'missing' for field 'vec': not defined in schema.embedders

To open such an index, edit its schema.toml. Declaring the name with type = "precomputed" keeps the field working as it did, with documents supplying its vectors. Deleting the field’s embedder line does the same.

Example: one embedder shared by two fields

[embedders.text_embedder]
type = "candle_bert"
model = "sentence-transformers/all-MiniLM-L6-v2"

[fields.title_vec.Hnsw]
dimension = 384
distance = "Cosine"
embedder = "text_embedder"

[fields.body_vec.Hnsw]
dimension = 384
distance = "Cosine"
embedder = "text_embedder"

Example: ColBERT token vectors for late-interaction rescoring

[embedders.colbert]
type = "candle_colbert"
model = "answerdotai/answerai-colbert-small-v1"
revision = "934fa8bb4ce2284f4c2baa232d81aca4d076fa5e"

[fields.body.Text]
indexed = true
stored = true

[fields.body_colbert.MultiVector]
dimension = 96
distance = "Cosine"
embedder = "colbert"

Documents then give body_colbert the same text as body, and a late-interaction rescore can take its query as text.

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 = true

[fields.body.Text]
indexed = true
stored = true
term_vectors = true

[fields.category.Text]
indexed = true
stored = true
term_vectors = false

[fields.published_at.DateTime]
indexed = true
stored = true

Vector 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 = true

[fields.body.Text]
indexed = true
stored = true
term_vectors = true

[fields.category.Text]
indexed = true
stored = true
term_vectors = false

[fields.body_vec.Hnsw]
dimension = 384
distance = "Cosine"
m = 16
ef_construction = 200

Tip: A single field cannot be both lexical and vector. Use separate fields (e.g., body for text, body_vec for 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 = true

[fields.description.Text]
indexed = true
stored = true
term_vectors = true

[fields.price.Float]
indexed = true
stored = true

[fields.in_stock.Boolean]
indexed = true
stored = true

[fields.created_at.DateTime]
indexed = true
stored = true

[fields.location.Geo]
indexed = true
stored = true

[fields.description_vec.Hnsw]
dimension = 384
distance = "Cosine"

Custom analysis and automatic embedding

A hybrid index whose Text fields use a custom analyzer and whose vector field embeds its text with a local model. It needs a laurus binary with the embeddings-candle feature (see Embedders):

default_fields = ["title", "body"]

[analyzers.english_stemmed]
tokenizer = { type = "unicode_word" }
token_filters = [
    { type = "lowercase" },
    { type = "stop" },
    { type = "stem" },
]

[embedders.text_embedder]
type = "candle_bert"
model = "sentence-transformers/all-MiniLM-L6-v2"

[fields.title.Text]
analyzer = "english_stemmed"

[fields.body.Text]
analyzer = "english_stemmed"

[fields.body_vec.Hnsw]
dimension = 384
distance = "Cosine"
embedder = "text_embedder"

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.

CommandDescription
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 (lexical / vector / hybrid DSL)
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 statsShow index statistics
get schemaShow 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
commitCommit pending changes
helpShow available commands
quit / exitExit the REPL

Note: Commands other than create, help, and quit require a loaded index. If no index is loaded, the REPL displays a message asking you to run create index first.

Usage Examples

Creating an Index

laurus> create index ./schema.toml
Index created at ./my_index.
laurus> add doc doc1 {"fields":{"title":"Hello","body":"World"}}
Document 'doc1' added.

Searching

laurus> search body:rust
╭──────┬────────┬────────────────────────────────────╮
│ ID   │ Score  │ Fields                             │
├──────┼────────┼────────────────────────────────────┤
│ doc1 │ 0.8532 │ body: Rust is a systems..., title… │
╰──────┴────────┴────────────────────────────────────╯

search accepts the full Query DSL — lexical, vector, and hybrid clauses — and analyzes the query with each field’s own configured analyzer, the same as the one-shot laurus search command. Results are always limited to 10; use laurus search --limit N outside the REPL for a different page size. The REPL has no equivalent of --highlight (Issue #1134) either — use the one-shot laurus search --highlight <FIELD> command for highlighted results.

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 {"fields":{"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&lt;RwLock&gt;)"]
    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 with the gRPC Server

Starting the Server

The gRPC server is started via the serve subcommand of the laurus CLI:

laurus serve [OPTIONS]

Options

OptionShortEnv VariableDefaultDescription
--config <PATH>-cLAURUS_CONFIG–Path to a TOML configuration file
--host <HOST>-HLAURUS_HOST0.0.0.0Listen address
--port <PORT>-pLAURUS_PORT50051Listen port
--http-port <PORT>–LAURUS_HTTP_PORT–HTTP Gateway port (enables HTTP gateway when set)
--max-result-window <N>–LAURUS_MAX_RESULT_WINDOW10000Largest offset + limit per search; larger searches are rejected

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:

  1. Stops accepting new connections
  2. Commits any pending changes to the index
  3. 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)
  • curl available 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": true, "analyzer": "standard"}},
        "body": {"text": {"indexed": true, "stored": true, "term_vectors": true, "analyzer": "body_analyzer"}},
        "category": {"text": {"indexed": true, "stored": true, "term_vectors": false, "analyzer": "keyword"}},
        "embedding": {"hnsw": {"dimension": 4, "distance": "DISTANCE_METRIC_COSINE", "m": 16, "ef_construction": 200, "embedder": "my_embedder"}}
      },
      "default_fields": ["title", "body"]
    }
  }'

This creates an index with three text fields and one vector field:

  • title — uses the built-in standard analyzer (tokenizes and lowercases).
  • body — uses the custom body_analyzer defined in the analyzers section (NFKC normalization + regex tokenizer + lowercase + custom stop words).
  • category — uses the keyword analyzer (treats the entire value as a single token for exact matching).
  • embedding — HNSW vector index with 4 dimensions, cosine distance, using the my_embedder embedder defined in embedders. In this tutorial we use precomputed (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). Requires embeddings-candle feature.
  • candle_clip — local CLIP multimodal model. Params: model (HuggingFace model ID). Requires embeddings-multimodal feature.
  • openai — OpenAI API. Params: model (e.g. "text-embedding-3-small"). Requires embeddings-openai feature and OPENAI_API_KEY env 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 '{
    "fields": {
      "title": "Introduction to Rust Programming",
      "body": "Rust is a modern systems programming language that focuses on safety, speed, and concurrency.",
      "category": "programming",
      "embedding": [0.9, 0.1, 0.2, 0.0]
    }
  }'
curl -X PUT http://localhost:8080/v1/documents/doc002 \
  -H 'Content-Type: application/json' \
  -d '{
    "fields": {
      "title": "Web Development with Rust",
      "body": "Building web applications with Rust has become increasingly popular. Frameworks like Actix and Rocket make it easy to create fast and secure web services.",
      "category": "web-development",
      "embedding": [0.7, 0.3, 0.5, 0.1]
    }
  }'
curl -X PUT http://localhost:8080/v1/documents/doc003 \
  -H 'Content-Type: application/json' \
  -d '{
    "fields": {
      "title": "Python for Data Science",
      "body": "Python is the most popular language for data science and machine learning. Libraries like NumPy and Pandas provide powerful tools for data analysis.",
      "category": "data-science",
      "embedding": [0.1, 0.8, 0.1, 0.9]
    }
  }'

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 '{
    "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 '{
    "fields": {
      "title": "Introduction to Rust Programming",
      "body": "Rust is a modern systems programming language.",
      "embedding": "Rust is a modern systems programming language."
    }
  }'
curl -X PUT http://localhost:8080/v1/documents/doc002 \
  -H 'Content-Type: application/json' \
  -d '{
    "fields": {
      "title": "Web Development with Rust",
      "body": "Building web applications with Rust using Actix and Rocket.",
      "embedding": "Building web applications with Rust using Actix and Rocket."
    }
  }'

Commit:

curl -X POST http://localhost:8080/v1/commit

Search with both lexical and semantic queries in one query: systems programming is a lexical clause, and embedding:"..." is a vector clause whose text the embedder converts to a vector at search time:

curl -X POST http://localhost:8080/v1/search \
  -H 'Content-Type: application/json' \
  -d '{
    "query": "systems programming embedding:\"systems programming language\"",
    "fusion": {"rrf": {"k": 60.0}},
    "limit": 10
  }'

With the precomputed embedder you must pass raw vectors (in query_vectors, whose vector must be an array of numbers), 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

TypeFeature FlagExample ModelDimension
candle_bertembeddings-candlesentence-transformers/all-MiniLM-L6-v2384
candle_clipembeddings-multimodalopenai/clip-vit-base-patch32512
openaiembeddings-openaitext-embedding-3-small1536

Next Steps

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
max_result_window = 10000  # Optional; largest offset + limit per search

[index]
data_dir = "./laurus_data"

[index.wal]
sync_policy = "group"          # "per_record" (default) | "group"
group_max_records = 1024       # optional; default 1024
group_max_bytes = 1048576      # optional; default 1 MiB
group_max_interval_ms = 1000   # optional; no background timer when unset (native only)

[index.commit]
policy = "every_docs"          # "manual" (default) | "every_docs" | "interval"
every_docs = 1000              # optional; commit every N docs (0/unset disables)
interval_ms = 1000             # "interval" only; commit at least every N ms (native only)

Log verbosity is controlled by the RUST_LOG environment variable (default: info), not through the config file.

Field Reference

[server] Section

FieldTypeDefaultDescription
hostString"0.0.0.0"Listen address for the gRPC server
portInteger50051Listen port for the gRPC server
http_portInteger–HTTP Gateway port. When set, the HTTP/JSON gateway starts alongside gRPC.
max_result_windowInteger10000Largest offset + limit a search may request (the default limit of 10 counts when limit is unset). Larger searches (gRPC, HTTP and MCP) are rejected with INVALID_ARGUMENT / HTTP 400. Must be at least 1. Like Elasticsearch’s index.max_result_window (Issue #1367).

[index] Section

FieldTypeDefaultDescription
data_dirString"./laurus_data"Path to the index data directory

[index.wal] Section

Controls the Write-Ahead Log (WAL) durability policy. When the whole section is omitted, the WAL uses per-record fsync (every write is durable before it returns). The policy applies to both an index opened at boot and any index created later through CreateIndex. See Persistence & WAL → WAL Durability Policy for the durability trade-off.

FieldTypeDefaultDescription
sync_policyString"per_record"Durability policy: "per_record" (fsync every write) or "group" (batch fsyncs)
group_max_recordsInteger1024Group commit only. Flush once this many records accumulate since the last sync
group_max_bytesInteger1048576Group commit only. Flush once this many bytes accumulate since the last sync (default 1 MiB)
group_max_interval_msInteger–Group commit only. Periodic background flush interval in milliseconds. No timer runs when unset. Native targets only — ignored on wasm32

Under sync_policy = "group", the WAL flushes when either group_max_records records or group_max_bytes bytes have accumulated since the last sync (whichever comes first), and unconditionally on commit. A crash can lose up to the last unsynced batch (comparable to SQLite synchronous = NORMAL); a torn trailing record is dropped on recovery, so the recovered log is gap-free.

[index.commit] Section

Controls the auto-commit policy. When the whole section is omitted, the engine is manual — it commits only when the server materializes an index (there is no automatic commit during ingestion). The policy applies to both an index opened at boot and any index created later through CreateIndex. See Persistence & WAL → Auto-commit Policy for the semantics. "interval" is the time-based counterpart of "every_docs" and is native only — under WebAssembly it is a no-op because there are no background threads.

FieldTypeDefaultDescription
policyString"manual"Auto-commit policy: "manual" (caller-driven commits), "every_docs" (commit every every_docs applied documents), or "interval" (commit at least every interval_ms milliseconds)
every_docsInteger–every_docs policy only. Commit after this many applied documents. Unset (or 0) disables auto-commit, equivalent to "manual"
interval_msInteger–interval policy only. Auto-commit at least this often (milliseconds) via a background timer, so a trailing partial batch commits even while ingestion is idle — the time-based counterpart of every_docs. Native targets only — treated as a no-op on wasm32 (no background threads)

CommitPolicy is orthogonal to [index.wal]: the WAL section governs when appends are fsync’d, while this section governs when the stores materialize. An auto-commit works under any WAL policy because a commit always begins with a WAL flush.

Environment Variables

VariableMaps ToDescription
LAURUS_HOSTserver.hostListen address
LAURUS_PORTserver.portgRPC listen port
LAURUS_HTTP_PORTserver.http_portHTTP Gateway port
LAURUS_MAX_RESULT_WINDOWserver.max_result_windowLargest offset + limit per search
LAURUS_INDEX_DIRindex.data_dirIndex data directory
RUST_LOG–Log filter directive (e.g. info, debug, laurus=debug,tonic=warn)
LAURUS_CONFIG–Path to TOML config file

CLI Arguments

OptionShortDefaultDescription
--config <PATH>-c–Path to TOML configuration file
--host <HOST>-H0.0.0.0Listen address
--port <PORT>-p50051gRPC listen port
--http-port <PORT>––HTTP Gateway port
--max-result-window <N>–10000Largest offset + limit per search
--index-dir <PATH>–./laurus_indexIndex 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

ServiceRPCsDescription
HealthServiceCheckHealth checking
IndexServiceCreateIndex, GetIndex, GetSchema, AddField, DeleteFieldIndex lifecycle and schema
DocumentServicePutDocument, AddDocument, PutDocuments, AddDocuments, GetDocuments, DeleteDocuments, Commit, FlushWalDocument CRUD, bulk ingestion, commit, and WAL flush
SearchServiceSearch, SearchStreamUnary and streaming search

HealthService

Check

Returns the current serving status of the server.

rpc Check(HealthCheckRequest) returns (HealthCheckResponse);

Response fields:

FieldTypeDescription
statusServingStatusSERVING_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:

FieldTypeRequiredDescription
schemaSchemaYesIndex 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 by TextOption.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 from fields. UNSPECIFIED is interpreted as DYNAMIC for forward compatibility. See Schema & Fields for the full behaviour matrix and the warning about silent truncation under DYNAMIC.

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):

FieldTypeDescription
typestringComponent type name (e.g. "whitespace", "lowercase", "unicode_normalization")
paramsmap<string, string>Type-specific parameters as string key-value pairs

EmbedderConfig:

FieldTypeDescription
typestringEmbedder type name (e.g. "precomputed", "candle_bert", "candle_colbert", "openai")
paramsmap<string, string>Type-specific parameters (e.g. "model" → "sentence-transformers/all-MiniLM-L6-v2"). "candle_colbert" (Issue #1349) also takes the optional "revision", "query_maxlen" and "doc_maxlen"; the lengths are decimal strings such as "32", and anything else is rejected

Each FieldOption is a oneof with one of the following field types:

Lexical FieldsVector Fields
TextOption (indexed, stored, multi_valued, position_increment_gap, term_vectors, doc_values, analyzer)HnswOption (dimension, distance, m, ef_construction, base_weight, quantizer, embedder, rerank_storage, pq_codebook_path)
IntegerOption (indexed, stored, multi_valued, doc_values)FlatOption (dimension, distance, base_weight, quantizer, embedder, rerank_storage)
FloatOption (indexed, stored, multi_valued, doc_values)IvfOption (dimension, distance, n_clusters, n_probe, base_weight, quantizer, embedder, rerank_storage)
BooleanOption (indexed, stored, multi_valued, doc_values)MultiVectorOption (dimension, distance, embedder, storage)
DateTimeOption (indexed, stored, multi_valued, doc_values)
GeoOption (indexed, stored, multi_valued, doc_values)
Geo3dOption (indexed, stored, multi_valued, doc_values)
BytesOption (stored, multi_valued)

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.

Doc values: doc_values (Issue #1047) is optional bool on every lexical option above except BytesOption, following the same tri-state contract as term_vectors: a client that omits it gets the engine’s default (true), distinguishable from an explicit false. It controls whether the field’s value is also copied into DocValues, the column-oriented store sorting and faceting/aggregation read from — a DocValues column is written only when stored and doc_values are both true. BytesOption carries no such field: a Bytes value is never written to DocValues regardless. Turning doc_values off for a field that is never sorted or faceted on shrinks its segment footprint; the field remains fully searchable and retrievable either way.

Multi-valued geo, datetime, boolean, text and bytes: multi_valued on GeoOption / Geo3dOption (Issue #1174), on DateTimeOption (Issue #1184) and on BooleanOption (Issue #1180) is proto field number 4 — 3 is already taken by doc_values there, unlike IntegerOption / FloatOption; on TextOption (Issue #1175) it is field number 6, because Text already used 3–5 for term_vectors, analyzer and doc_values; on BytesOption (Issue #1176) it is field number 2 — 1 is already taken by stored, and BytesOption has neither an indexed nor a doc_values field. When true, a geo field accepts GeoArrayValue / Geo3dArrayValue (see the Value table below) and distance / bounding-box queries (plus geo3d_nearest) match a document if any of its points satisfies the predicate, scoring it by its closest point. A datetime field accepts DatetimeArrayValue (repeated int64 Unix microseconds UTC, the same encoding as datetime_value) and range queries match a document if any of its instants is in range, with constant scoring; a document is reported once even when several instants match. A boolean field accepts BoolArrayValue (repeated bool) and a term query matches a document if any element equals the queried value; each element is indexed as its own true / false term posting — there are no BKD points for booleans — so repeated elements raise the term frequency (and hence the BM25 score), not the hit count. A text field accepts TextArrayValue (repeated string) and a term query matches a document if any element contains the term; position_increment_gap (field 7, optional uint32) is the number of positions inserted between consecutive elements, which keeps a phrase query from spanning two elements unless its slop reaches the gap. It is optional for the same reason term_vectors / doc_values are: an omitted value means the engine default (100), not 0. A bytes field accepts BytesArrayValue (repeated bytes); since a Bytes value is never lexically indexed, this has no “any match” query semantics at all — it only governs the stored/wire shape and ingestion arity — and, like the scalar bytes_value, the per-element MIME type carried by DataValue::BytesArray is not represented on the wire.

Multi-vector fields: MultiVectorOption (FieldOption field 12, Issue #1177) declares a field that holds each document’s token vectors (for example ColBERT-style per-token embeddings) for late-interaction rescoring. It has no ANN index and is not a vector-search target: a query_vectors entry naming it is rejected. distance must be COSINE (vectors are L2-normalized when written) or DOT_PRODUCT. Values are VectorArrayValues (see the Value table below), and the field is not stored, so documents returned by the server never include it. embedder (field 3, Issue #1349) names a token-level embedder ("candle_colbert") from Schema.embedders; when set, a document may give the field a text_value, which is embedded into token vectors. A MultiVector field accepts only a "candle_colbert" or "precomputed" embedder, and other vector fields do not accept "candle_colbert".

Multi-vector storage: the optional storage field (field 4, enum MultiVectorStorage, Issue #1346) picks the on-disk element kind of the token vectors: UNSPECIFIED = same as F32, F32 (default, 4 bytes/element, exact), F16 (2 bytes/element, ~2⁻¹¹ relative error per element), INT8 (~1 byte/element plus a small per-vector scale overhead — dimension + 2 bytes/row total — using a per-vector scale max(abs(vector)) / 127, not trained per-segment or over the corpus), or BINARY (1 bit/element — ceil(dimension / 8) bytes/row — against a center the field learns once; requires distance COSINE). At 300 tokens × 128 dimensions (~150 KB/document in F32), F16 is ~75 KB/document, INT8 ~38 KB/document and BINARY ~4.7 KB/document. Unset keeps F32. A server that predates a value maps it to F32 without an error, so check the returned schema when talking to an older server.

Multi-vector token pooling: the optional pool_factor field (field 5, uint32, Issue #1346) makes each document keep about 1 / pool_factor of its token vectors: the first one, then the means of Ward clusters of the others. Unset means 1, no pooling; 0 is rejected. Turning pooling on for an existing field is a reindex; changing or turning off a factor in use is destructive. See Token pooling.

Distance metrics: COSINE, EUCLIDEAN, MANHATTAN, DOT_PRODUCT, ANGULAR

Quantization methods: SCALAR_8BIT (default), PRODUCT_QUANTIZATION (Issue #481 Stage 3; supported by the HNSW index — Flat / IVF reject it at write time).

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().

Rerank storage: the optional rerank_storage field (enum RerankStorageKind: UNSPECIFIED = no sidecar, F32) enables the Stage-2 rerank sidecar (Issue #481 / #793). When set to F32 on an HNSW field, commit writes an extra full-precision .hnsw.f32 sidecar so searches that set rerank_factor rescore int8 candidates against the original vectors. Omitting the field (or UNSPECIFIED) keeps Stage-1 int8-only ranking. Since #932 the sidecar is emitted and consumed by all three vector index types (HNSW / Flat / IVF), including a field-less query when the segment holds only one field (Issue #1388).

Shared PQ codebook: the optional pq_codebook_path field on HnswOption (Issue #631) names a storage-relative shared PQ codebook file, trained once via the laurus train pq-codebook CLI command. Segments are then encoded against the pre-trained codebook instead of re-training k-means on every commit and merge. Only meaningful with a PRODUCT_QUANTIZATION quantizer; when set but not yet trained, commits fail with an error naming the training command (no silent fallback to per-segment training). Unset keeps per-segment training.

Base weight: base_weight on HnswOption/FlatOption/IvfOption is optional float (Issue #1084): a client that omits it gets the engine’s default (1.0), distinguishable from an explicit 0.0. It sets the field’s relative scoring priority when a query targets it alongside other vector fields — see Vector Search → Weights for what it does and does not affect.

QuantizationConfig structure:

FieldTypeDescription
methodQuantizationMethodQuantization method (QUANTIZATION_METHOD_SCALAR_8BIT or QUANTIZATION_METHOD_PRODUCT_QUANTIZATION). The reserved value 0 (NONE) is silently coerced to SCALAR_8BIT.
subvector_countuint32Number 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:

FieldTypeDescription
document_countuint64Total number of documents in the index
vector_fieldsmap<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:

FieldTypeDescription
namestringThe field name
field_optionFieldOptionThe field configuration

Response fields:

FieldTypeDescription
schemaSchemaThe 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:

FieldTypeDescription
namestringThe field name to remove

Response fields:

FieldTypeDescription
schemaSchemaThe 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:

FieldTypeDescription
schemaSchemaThe 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:

FieldTypeRequiredDescription
idstringYesExternal document ID
documentDocumentYesDocument content

Document structure:

message Document {
  map<string, Value> fields = 1;
}

Each Value is a oneof with these types:

TypeProto FieldDescription
Nullnull_valueNull value
Booleanbool_valueBoolean value
Integerint64_value64-bit signed integer
Floatfloat64_value64-bit floating point
Texttext_valueUTF-8 string
Bytesbytes_valueRaw bytes
Vectorvector_valueVectorValue (list of floats)
DateTimedatetime_valueUnix microseconds (UTC)
Geogeo_valueGeoPoint (latitude, longitude)
Int64Arrayint64_array_valueInt64ArrayValue (multi-valued integers; requires IntegerOption.multi_valued = true)
Float64Arrayfloat64_array_valueFloat64ArrayValue (multi-valued floats; requires FloatOption.multi_valued = true)
Geo3dgeo3d_valueGeo3dPoint (x, y, z meters; ECEF Cartesian)
GeoArraygeo_array_valueGeoArrayValue (repeated GeoPoint; multi-valued 2D points; requires GeoOption.multi_valued = true)
Geo3dArraygeo3d_array_valueGeo3dArrayValue (repeated Geo3dPoint; multi-valued 3D points; requires Geo3dOption.multi_valued = true)
DateTimeArraydatetime_array_valueDatetimeArrayValue (repeated int64 Unix microseconds; multi-valued instants; requires DateTimeOption.multi_valued = true)
BoolArraybool_array_valueBoolArrayValue (repeated bool; multi-valued booleans; requires BooleanOption.multi_valued = true)
TextArraytext_array_valueTextArrayValue (repeated string; multi-valued text; requires TextOption.multi_valued = true)
BytesArraybytes_array_valueBytesArrayValue (repeated bytes; multi-valued bytes, MIME not carried on the wire; requires BytesOption.multi_valued = true)
VectorArrayvector_array_valueVectorArrayValue (uint32 dimension, repeated float values; token vectors packed row-major, values.len() / dimension vectors; requires a MultiVectorOption field)

Geo3dPoint:

FieldTypeDescription
xdoubleX coordinate in meters (ECEF: equatorial plane, +X toward 0° longitude)
ydoubleY coordinate in meters (ECEF: equatorial plane, +Y toward 90°E)
zdoubleZ 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.

PutDocuments

Batched upsert. Entries are applied sequentially, in input order, with one WAL fsync for the whole batch — much faster than one PutDocument call per document. Duplicate IDs within one batch dedup exactly like the same puts issued one by one (the last occurrence wins).

rpc PutDocuments(PutDocumentsRequest) returns (PutDocumentsResponse);

message DocumentEntry {
  string id = 1;
  Document document = 2;
}

message PutDocumentsRequest {
  repeated DocumentEntry documents = 1;
}

message PutDocumentsResponse {
  uint32 applied = 1; // equals the request size on success
}

The call fails fast at the first entry that cannot be applied. Already-applied entries are not rolled back — they are durable at the next commit — and the error status message carries the failing position, its ID, and the applied count, so retrying the batch (or its suffix) is idempotent. A batch that fails on a caller mistake (e.g. a schema violation) returns INVALID_ARGUMENT; storage failures return INTERNAL.

AddDocuments

Batched chunk append. Like PutDocuments but never deletes existing documents, so a batch may legitimately repeat an ID to add multiple chunks of the same logical document.

rpc AddDocuments(AddDocumentsRequest) returns (AddDocumentsResponse);

Request/response fields mirror PutDocuments.

GetDocuments

Retrieve all documents matching the given external ID.

rpc GetDocuments(GetDocumentsRequest) returns (GetDocumentsResponse);

Request fields:

FieldTypeRequiredDescription
idstringYesExternal document ID

Response fields:

FieldTypeDescription
documentsrepeated DocumentMatching 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);

FlushWal

Force buffered WAL records durable without a full commit. Both messages are empty. This is a near no-op under the default per-record sync policy (every write is already fsync’d); under the group-commit policy it flushes the current partial batch on demand, bounding the crash-loss window. Unlike Commit, it does not materialize segments, so buffered changes remain invisible to search until a subsequent Commit.

rpc FlushWal(FlushWalRequest) returns (FlushWalResponse);

message FlushWalRequest {}

message FlushWalResponse {}

The WAL durability policy is configured server-side via the [index.wal] config section. See Configuration → [index.wal] Section and Persistence & WAL → WAL Durability Policy.

HTTP gateway: POST /v1/flush_wal


SearchService

Search

Execute a search query and return results as a single response.

rpc Search(SearchRequest) returns (SearchResponse);

Response fields:

FieldTypeDescription
resultsrepeated SearchResultSearch results ordered by relevance
total_hitsuint64Total 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);

With group_by, each message is one group: its best hit, with the group’s next best hits in inner_hits.

SearchRequest Fields

FieldTypeRequiredDescription
querystringNoUnified query in Query DSL: lexical clauses and vector clauses. An unknown field is rejected with INVALID_ARGUMENT. lexical_params and field_boosts apply to its lexical part
query_vectorsrepeated QueryVectorNoPre-embedded query vectors. Sent with query, they are added to its vector part; a query with only lexical clauses becomes a hybrid search (Issue #1366)
limituint32NoMaximum number of results (0 means the engine default, 10). offset + limit must not exceed the server’s max_result_window (default 10,000), or the search is rejected with INVALID_ARGUMENT (Issue #1367)
offsetuint32NoNumber of results to skip; counts toward max_result_window with limit
fusionFusionAlgorithmNoFusion algorithm for hybrid search
lexical_paramsLexicalParamsNoLexical search parameters
vector_paramsVectorParamsNoVector search parameters
field_boostsmap<string, float>NoPer-field score boosting for the lexical part. A key must name a lexical field of the schema (or _id); an unknown or vector field is rejected with INVALID_ARGUMENT (Issue #1374). A lexical field the query does not use is accepted and has no effect
highlightHighlightParamsNoRequest highlighted fragments per field (Issue #1134)
rescoreRescoreParamsNoRescore the top first-stage results with late interaction (Issue #1351)
group_byGroupByParamsNoGroup the results by a field’s value; limit and offset then count groups (Issue #1297)

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 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 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

FieldTypeDescription
vectorrepeated floatQuery vector
weightfloatWeight for this vector (default: 1.0; 0 also means 1.0, since proto3 cannot tell it from unset)
fieldsrepeated stringTarget vector fields (empty = all)

FusionAlgorithm

A oneof with two options:

  • RRF (Reciprocal Rank Fusion): k parameter (default: 60)
  • WeightedSum: lexical_weight and vector_weight

LexicalParams

FieldTypeDescription
min_scorefloatMinimum score threshold
timeout_msuint64Search timeout in milliseconds
parallelboolEnable parallel search
sort_bySortSpecSort by a field instead of score

SortSpec

FieldTypeDescription
fieldstringField name to sort by. Empty string means sort by relevance score
orderSortOrderSORT_ORDER_ASC (ascending) or SORT_ORDER_DESC (descending)

VectorParams

vector_params applies to the request’s vector part: query_vectors and the vector clauses of query (Issues #1342, #1366). A request with neither query_vectors nor query is rejected with INVALID_ARGUMENT. The server cannot tell whether query has vector clauses without parsing it; on a request whose only possible vector part is a query without vector clauses, vector_params has no effect. Every other value either takes effect or is rejected with INVALID_ARGUMENT.

FieldTypeDescription
fieldsrepeated stringVector fields (exact names) to search with query vectors that name no field. A query vector’s own fields take precedence, and every DSL vector clause names its field, so fields is rejected when no searched query_vectors entry names no field. An unknown name is rejected. Empty means every vector field whose dimension matches.
score_modeVectorScoreModeWEIGHTED_SUM, MAX_SIM, or LATE_INTERACTION. Any other value is rejected.
overfetchfloatEach per-field index query fetches ceil(limit * overfetch) candidates before scores are combined. 0 (unset) means the default, 2.0; a value in (0, 1] turns overfetch off. Negative or non-finite values are rejected.
min_scorefloatMinimum per-field similarity, compared before query and field weights are applied. 0 (unset) means no threshold. A non-finite value is rejected.
rerank_factoroptional uint32Stage 2 rerank widening factor (Issue #481). When set on a field whose schema enabled rerank_storage, the server widens the int8/PQ candidate fetch to top_k * rerank_factor and rescores the candidates against the original full-precision vectors before returning the top top_k. Honored on all three vector index types since #932 (HNSW, Flat, IVF), including a field-less query when the segment holds only one field (Issue #1388); fields without rerank_storage = "F32" silently fall back to int8 ranking — there is no f32 information to recover. A value of 0 or omitting the field disables rerank.
ef_searchoptional uint32Per-query override for the HNSW ef_search candidate-list size (Issue #644). Also gates the PQ → SQ → f32 three-stage rerank chain (Issue #673): on a PQ field with rerank_storage enabled, setting ef_search wider than top_k * rerank_factor activates an extra int8 stage that rescores the graph’s full candidate set — derived from the same rerank_storage sidecar, no extra configuration needed — before the exact stage’s narrower budget is carved out of it. 0 is rejected. Ignored on non-HNSW fields.

HighlightParams

Requests highlighted fragments for specific fields (Issue #1134). Highlighting uses SearchRequest.query (or the lexical clause of a DSL query), the same query that drives lexical scoring — filter_query never contributes highlighted terms, and a vector-only request (no lexical query) produces no highlights. Only stored: true text fields can be highlighted; other names are silently skipped. See Highlighting for full semantics.

FieldTypeDescription
fieldsrepeated stringStored text fields to highlight. Required — a request with this unset or empty is rejected with INVALID_ARGUMENT
max_fragmentsoptional uint32Maximum fragments per field (default: 5). 0 is rejected
fragment_sizeoptional uint32Target fragment length in characters (default: 150). 0 is rejected
tagoptional stringHTML tag to wrap matches (default: "mark"). An empty string is treated as unset
css_classoptional stringCSS class added to the tag. An empty string is treated as unset
require_field_matchoptional boolOnly use query terms that target the highlighted field (default: true)
max_analyzed_charsoptional uint64Maximum characters of a field’s text to analyze (default: 1,000,000)
return_entire_field_if_no_highlightoptional boolReturn the full field value as one fragment when nothing matched (default: false)

Highlights

FieldTypeDescription
fragmentsrepeated stringHighlighted fragments for one field, best fragment first

RescoreParams

Reorders the top window_size first-stage results (lexical, vector or hybrid) with ColBERT-style late interaction over a MultiVector field (Issue #1351). See Late-Interaction Rescore for the scoring and ordering rules.

FieldTypeDescription
window_sizeoptional uint32How many top first-stage results to rescore, 1 to 10,000. Unset means 100
late_interactionLateInteractionRescoreThe rescorer (a oneof; required)

LateInteractionRescore:

FieldTypeDescription
fieldstringA MultiVector field
vectorsVectorArrayValueThe query’s token vectors, packed row-major like a document value (1 to 1,024 vectors of the field’s dimension). One of vectors and text is required
textstringQuery text, embedded by the field’s token-level embedder (a candle_colbert one)

The engine validates the values: an out-of-range window, a field that is not a MultiVector field, the wrong number or dimension of vectors, text for a field without a token-level embedder, or a rescore combined with lexical_params.sort_by is rejected with INVALID_ARGUMENT, as is a RescoreParams without late_interaction or a query.

GroupByParams

Folds the ranked results (after fusion and rescore) that share a field’s value into groups, ordered by each group’s best result (Issue #1297). See Result Grouping for the rules.

FieldTypeDescription
fieldstring_id, or a single-valued, stored Text, Integer, Float, Boolean or DateTime field with doc_values. A result with no value forms a group of its own
group_sizeoptional uint32Results kept per group, the returned one included, 1 to 100. Unset means 1

The engine validates the values: a field that cannot group, a group_size outside 1 to 100, or an offset + limit above 10,000 is rejected with INVALID_ARGUMENT.

SearchResult

FieldTypeDescription
idstringExternal document ID
scorefloatRelevance score; for a rescored result, its late-interaction (MaxSim) score
documentDocumentDocument content
highlightsmap<string, Highlights>Highlighted fragments per field named in SearchRequest.highlight.fields. A field with no highlight is absent from this map
group_keyValueWith group_by: the value this result’s group shares. Unset for an ungrouped search and for a result without a value
inner_hitsrepeated SearchResultWith group_by: the group’s next best results, in ranking order. Each carries the same group_key and has no inner_hits of its own

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
  }
}

Example: Highlighting

{
  "query": "body:rust",
  "limit": 10,
  "highlight": {"fields": ["body"], "max_fragments": 2, "tag": "em"}
}

A matching hit’s SearchResult then carries:

{
  "id": "doc1",
  "score": 1.2,
  "document": {"fields": {"body": {"text_value": "Rust is great"}}},
  "highlights": {"body": {"fragments": ["<em>Rust</em> is great"]}}
}

Example: Rescore

A hybrid first stage whose top 100 results are rescored by a ColBERT embedder on body_colbert:

{
  "query": "body:lifetimes body_vec:\"how do lifetimes work\"",
  "limit": 10,
  "rescore": {
    "window_size": 100,
    "late_interaction": {"field": "body_colbert", "text": "how do lifetimes work"}
  }
}

With pre-computed query token vectors instead of text:

{
  "late_interaction": {
    "field": "body_colbert",
    "vectors": {"dimension": 2, "values": [1.0, 0.0, 0.0, 1.0]}
  }
}

Example: Grouping

Documents added as chunks with AddDocuments share their _id; grouping by it returns each document once, with up to one more of its chunks:

{
  "query": "body:rust",
  "limit": 10,
  "group_by": {"field": "_id", "group_size": 2}
}

Each SearchResult is then a group’s best chunk:

{
  "id": "manual",
  "score": 0.012571429,
  "document": {"fields": {"body": {"text_value": "Rust ownership rules for Rust programs"}}},
  "group_key": {"text_value": "manual"},
  "inner_hits": [
    {
      "id": "manual",
      "score": 0.012359549,
      "document": {"fields": {"body": {"text_value": "Borrowing in Rust"}}},
      "group_key": {"text_value": "manual"}
    }
  ]
}

Error Handling

gRPC errors are returned as standard Status codes:

Laurus ErrorgRPC StatusWhen
Schema / Query / Field / Invalid argument / JSONINVALID_ARGUMENTMalformed request or schema, unknown field in a query, a search whose offset + limit exceeds max_result_window
No index openFAILED_PRECONDITIONRPC called before CreateIndex
Index already existsALREADY_EXISTSCreateIndex called twice
Not implementedUNIMPLEMENTEDFeature not yet supported
Internal errorsINTERNALI/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

MethodPathgRPC MethodDescription
GET/v1/healthHealthService/CheckHealth check
POST/v1/indexIndexService/CreateIndexCreate a new index
GET/v1/indexIndexService/GetIndexGet index statistics
GET/v1/schemaIndexService/GetSchemaGet the index schema
POST/v1/schema/fieldsIndexService/AddFieldDynamically add a field
DELETE/v1/schema/fields/{name}IndexService/DeleteFieldRemove a field from the schema
PUT/v1/documents/{id}DocumentService/PutDocumentUpsert a document
POST/v1/documents/{id}DocumentService/AddDocumentAdd a document (chunk)
GET/v1/documents/{id}DocumentService/GetDocumentsGet documents by ID
DELETE/v1/documents/{id}DocumentService/DeleteDocumentsDelete documents by ID
POST/v1/documents:bulkDocumentService/PutDocuments / AddDocumentsBulk-ingest documents (?mode=put|add, default put)
POST/v1/commitDocumentService/CommitCommit pending changes
POST/v1/flush_walDocumentService/FlushWalForce buffered WAL records durable without a full commit
POST/v1/searchSearchService/SearchSearch (unary)
POST/v1/search/streamSearchService/SearchStreamSearch (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

The response always includes multi_valued for text, integer, float, boolean, date_time, geo, geo3d, and bytes options (e.g. "location": {"geo": {"indexed": true, "stored": true, "multi_valued": true, "doc_values": true}}, "seen_at": {"date_time": {"indexed": true, "stored": true, "multi_valued": true, "doc_values": true}}, "flags": {"boolean": {"indexed": true, "stored": true, "multi_valued": true, "doc_values": true}}, "notes": {"text": {"indexed": true, "stored": true, "multi_valued": true, "position_increment_gap": 100}} or "thumbnail": {"bytes": {"stored": true, "multi_valued": true}}); the same keys are accepted on POST /v1/index and POST /v1/schema/fields. A text option’s position_increment_gap (Issue #1175) may be omitted on input — an omitted value means the engine default (100), not 0 — and is always present in the response. A bytes option has no indexed or doc_values key: a Bytes value is never indexed or written to DocValues regardless of multi_valued (Issue #1176).

A multi-vector field (Issue #1177) is declared as "body_colbert": {"multi_vector": {"dimension": 128, "distance": "cosine"}} (distance is "cosine" or "dot_product", defaulting to "cosine"). Its value in a document is an array of equal-length numeric arrays, one per token vector ("body_colbert": [[0.1, 0.2, ...], [0.3, 0.4, ...]]). The field holds token vectors for late-interaction rescoring: it is not a vector-search target and is not returned with documents.

A multi-vector field may also name a token-level embedder (Issue #1349), declared in the schema’s embedders with its numeric options as JSON numbers: "embedders": {"colbert": {"type": "candle_colbert", "model": "answerdotai/answerai-colbert-small-v1", "revision": "934fa8bb4ce2284f4c2baa232d81aca4d076fa5e", "doc_maxlen": 300}} and "body_colbert": {"multi_vector": {"dimension": 96, "embedder": "colbert"}}. A document can then give the field text ("body_colbert": "how lifetimes work in rust"), which is embedded into token vectors. Embedder parameters that are numbers or booleans are passed on as strings, and arrays or objects are rejected.

A multi-vector field may also set storage (Issue #1346) to pick the on-disk element kind of its token vectors: "f32" (default, exact), "f16" (2x smaller than "f32", ~2⁻¹¹ relative error per element), "int8" (~4x smaller than "f32" at typical dimensions, using a per-vector scale), or "binary" (32x smaller, 1 bit per element; requires "distance": "cosine"). It may also set pool_factor, an integer: each document keeps about 1 / pool_factor of its token vectors (1, the default, keeps them all; 0 is rejected by the engine, and a value that is not a non-negative integer is a 400). The key is omitted from the response when it is 1. The value is a lowercase string, accepted case-insensitively on input, e.g. "body_colbert": {"multi_vector": {"dimension": 128, "storage": "int8"}}; an unrecognized value is rejected. The key is omitted from the response when storage is "f32", the default.

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 '{
    "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 '{
    "fields": {
      "title": "Hello World",
      "body": "This is a test document."
    }
  }'

Bulk-Ingest Documents (POST)

Applies many documents in one call — entries are processed sequentially, in input order, with one WAL fsync for the whole batch. ?mode=put (the default) upserts (duplicate ids dedup, last wins); ?mode=add appends chunks, so repeated ids accumulate:

curl -X POST 'http://localhost:8080/v1/documents:bulk?mode=put' \
  -H 'Content-Type: application/json' \
  -d '{
    "documents": [
      {"id": "doc1", "fields": {"title": "Hello"}},
      {"id": "doc2", "fields": {"title": "World"}}
    ]
  }'
# => {"applied": 2}

The call fails fast at the first entry that cannot be applied; already-applied entries are not rolled back (they become durable at the next commit), and the error names the failing position, so retrying the batch or its suffix is idempotent.

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

Flush WAL

Forces buffered WAL records durable without a full commit. Returns {} on success. This is a near no-op under the default per-record sync policy; under the group-commit policy it flushes the current partial batch on demand. Buffered changes stay invisible to search until a subsequent POST /v1/commit.

curl -X POST http://localhost:8080/v1/flush_wal

Search

curl -X POST http://localhost:8080/v1/search \
  -H 'Content-Type: application/json' \
  -d '{"query": "body:test", "limit": 10}'

limit defaults to 10 and offset to 0 when absent or null. Since Issue #1367 a value that is not a non-negative integer, or that does not fit a 32-bit unsigned integer, is rejected with 400 Bad Request; it used to fall back to the default or wrap around. A search whose offset + limit exceeds the server’s max_result_window (default 10,000) is rejected with 400 Bad Request as well.

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}
  }'

Each key must name a lexical field of the schema (or _id). An unknown field, such as a typo, or a vector field is rejected with 400 Bad Request instead of being silently ignored (Issue #1374).

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}}
  }'

query_vectors are added to the vector part of query, so a query with only lexical clauses becomes a hybrid search, with or without lexical_params / field_boosts (Issue #1366). Each entry needs a non-empty numeric vector; weight (a number) and fields (an array of strings) are optional, and null keeps their defaults. Any other shape is rejected with 400 Bad Request instead of being dropped.

Search with Vector Parameters

vector_params takes the keys of the gRPC VectorParams message in snake_case: fields, score_mode ("weighted_sum", "max_sim" or "late_interaction"), overfetch, min_score, rerank_factor and ef_search. A key that is null or absent keeps its default. A key with the wrong type, an unknown score_mode, or a rerank_factor / ef_search that does not fit a 32-bit unsigned integer is rejected with 400 Bad Request, as is any value the gRPC API rejects (Issue #1342).

curl -X POST http://localhost:8080/v1/search \
  -H 'Content-Type: application/json' \
  -d '{
    "query_vectors": [{"vector": [0.1, 0.2, 0.3]}],
    "vector_params": {"fields": ["title_vec"], "min_score": 0.5, "ef_search": 128},
    "limit": 10
  }'

Search with Highlighting

highlight requests highlighted fragments per field (Issue #1134). The shorthand form is just a field list:

curl -X POST http://localhost:8080/v1/search \
  -H 'Content-Type: application/json' \
  -d '{"query": "body:rust", "limit": 10, "highlight": ["body"]}'

The full object form adds HighlightConfig knobs — max_fragments, fragment_size, tag, css_class, require_field_match, max_analyzed_chars, return_entire_field_if_no_highlight:

curl -X POST http://localhost:8080/v1/search \
  -H 'Content-Type: application/json' \
  -d '{
    "query": "body:rust",
    "limit": 10,
    "highlight": {"fields": ["body"], "max_fragments": 2, "tag": "em"}
  }'

Each result gains a "highlights" object, present only when at least one field actually highlighted:

{"id": "doc1", "score": 1.2, "fields": {...}, "highlights": {"body": ["<em>Rust</em> is a systems programming language"]}}

highlight only affects fields that are stored: true text fields in the schema, and highlighting always follows the request’s lexical query — a filter_query never contributes highlighted terms and a vector-only request produces no highlights at all. See Highlighting for the full semantics.

Search with Rescore

rescore reorders the top window_size results (default 100) with late interaction over a MultiVector field (Issue #1351). The query is text, embedded by the field’s token-level embedder (a candle_colbert one), or the query’s token vectors as an array of equal-length numeric arrays:

curl -X POST http://localhost:8080/v1/search \
  -H 'Content-Type: application/json' \
  -d '{
    "query": "body:lifetimes",
    "limit": 10,
    "rescore": {
      "window_size": 100,
      "late_interaction": {"field": "body_colbert", "text": "how do lifetimes work"}
    }
  }'
"rescore": {"late_interaction": {"field": "body_colbert", "vectors": [[0.1, 0.2], [0.3, 0.4]]}}

A rescored result’s score is its late-interaction (MaxSim) score. Unlike the other search options, a malformed rescore (missing field, both or neither of vectors and text, unequal vector lengths) is rejected with 400 instead of being ignored; so are the values the engine rejects (see gRPC RescoreParams and Late-Interaction Rescore).

Search with Grouping

group_by groups the results by a field’s value (Issue #1297); limit and offset then count groups. Chunks added with POST /v1/documents/:id share their _id, so grouping by it returns each document once, by its best chunk, with up to group_size - 1 more of its chunks (group_size defaults to 1, at most 100):

curl -X POST http://localhost:8080/v1/search \
  -H 'Content-Type: application/json' \
  -d '{"query": "body:rust", "limit": 10, "group_by": {"field": "_id", "group_size": 2}}'

With three chunks added as manual and one as faq, the response is:

{"results":[{"fields":{"body":"Rust ownership rules for Rust programs"},"group_key":"manual","id":"manual","inner_hits":[{"fields":{"body":"Borrowing in Rust"},"group_key":"manual","id":"manual","score":0.012359549291431904}],"score":0.012571428902447224},{"fields":{"body":"Why Rust has no garbage collector"},"group_key":"faq","id":"faq","score":0.010891089215874672}],"total_hits":2}

A result gains "group_key" when it has a value and "inner_hits" when its group has more results, each in the same shape. Like rescore, a malformed group_by is rejected with 400 instead of being ignored:

{"error":{"code":400,"message":"group_by.field must be a string"}}

So are the values the engine rejects (see gRPC GroupByParams and Result Grouping).

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,"fields":{...}}

data: {"id":"doc2","score":0.4210,"fields":{...}}

With group_by, each event is one group: its best result, with its inner_hits when group_size is above 1. For "group_by": {"field": "_id"}:

data: {"fields":{"body":"Rust ownership rules for Rust programs"},"group_key":"manual","id":"manual","score":0.012571428902447224}

data: {"fields":{"body":"Why Rust has no garbage collector"},"group_key":"faq","id":"faq","score":0.010891089215874672}

JSON Field Value Inference

When the gateway accepts a document body (PUT /v1/documents/{id} or POST /v1/documents/{id}), each value inside fields is converted to the engine’s DataValue type using the same canonical json_to_document converter laurus-cli and laurus-mcp use, so all three JSON-accepting transports agree on one document shape and the same inference rules as schema-less ingestion. This keeps the HTTP and gRPC paths in sync.

JSON valueResulting field typeNotes
null(skipped)The field is omitted entirely — it is not sent to the engine at all, not even as an explicit null.
true / falseboolean
integer (fits in i64)integer
float / large integerfloat
"text"text
[1, 2, 3] (all integers)integer with multi_valued: trueMulti-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": ...}geoShort aliases for latitude / longitude are accepted.
{"x": ..., "y": ..., "z": ...}geo3dAll three keys required, finite numbers, ECEF meters. Mixing with lat/lon keys is rejected.
[{"latitude": 35.6, "longitude": 139.7}, ...] (all geo objects)geo with multi_valued: trueMulti-valued geo field; the lat / lon / lng aliases are accepted. Documents are returned with the field rendered as an array of {"latitude", "longitude"} objects.
[{"x": ..., "y": ..., "z": ...}, ...] (all 3D objects)geo3d with multi_valued: trueMulti-valued 3D geo field, returned as an array of {"x", "y", "z"} objects. Mixing 2D and 3D objects in one array is rejected.
["2024-01-01T00:00:00Z", "2024-06-15T21:00:00+09:00"] (all RFC 3339 strings)date_time with multi_valued: trueMulti-valued datetime field (Issue #1184). Only RFC 3339 strings are recognized as datetimes here. Documents are returned with the field rendered as an array of RFC 3339 strings normalized to UTC (e.g. "2024-06-15T12:00:00+00:00").
["a", "b"] (all strings, not all RFC 3339)text with multi_valued: trueMulti-valued text field (Issue #1175). Each element is analyzed on its own; a term query matches if any element contains the term, and a phrase query does not span two elements unless its slop reaches the field’s position_increment_gap (default 100). Documents are returned with the field rendered as an array of strings.
[true, false] (all booleans)boolean with multi_valued: trueMulti-valued boolean field (Issue #1180). A term query such as flags:true matches if any element equals the value. Documents are returned with the field rendered as an array of booleans.
{"data": "<base64>", "mime": "..."}bytesmime is optional. Disambiguates a bytes payload from a plain string on a multimodal vector field’s Text-or-Bytes embedder input — see Schema and Fields.

The gateway returns an HTTP 400 (Bad Request) when:

  • An array contains mixed types or non-numeric elements (e.g. [1, "x"] or [true, 1]), or mixes 2D and 3D geo objects (e.g. [{"lat": ...}, {"x": ...}]).
  • An object does not match any of the supported shapes above (e.g. missing latitude / longitude keys for 2D geo, missing any of x / y / z for 3D geo, or a non-string data key).
  • A geographic latitude is outside [-90, 90] or a longitude is outside [-180, 180].
  • An object mixes markers from more than one supported shape (e.g. lat together with x, or data together with lat).
  • 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-server instance
  • All laurus search modes — Lexical (BM25), vector (HNSW/Flat/IVF), and hybrid search
  • Dynamic connection — Connect to any laurus-server endpoint via the connect tool
  • 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 with laurus-mcp

Prerequisites

  • The laurus CLI binary installed (cargo install laurus-cli)
  • A running laurus-server instance (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

NameTypeRequiredDescription
endpointstringYesgRPC 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

NameTypeRequiredDescription
schema_jsonstringYesSchema 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

NameTypeRequiredDescription
namestringYesThe field name
field_option_jsonstringYesField 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

NameTypeRequiredDescription
namestringYesThe 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

NameTypeRequiredDescription
idstringYesExternal document identifier
fieldsobjectYesDocument fields as a JSON object

Example

Tool: put_document
id: "doc-1"
fields: {"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"
fields: {"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) — the same shape the HTTP gateway and laurus-cli accept (see JSON Field Value Inference).


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

NameTypeRequiredDescription
idstringYesExternal document identifier
fieldsobjectYesDocument fields as a JSON object

Example

Tool: add_document
id: "doc-1"
fields: {"title": "Hello World - Part 2", "body": "This is a continuation."}

Result: Document 'doc-1' added as chunk. Call commit to persist changes.


put_documents

Put (upsert) many documents in one call. Entries are applied sequentially, in input order, with one WAL fsync for the whole batch — much faster than calling put_document per document. Duplicate IDs within one batch dedup (the last occurrence wins). Call commit afterwards.

Parameters

NameTypeRequiredDescription
documentsarrayYesArray of {"id": "...", "fields": {...}} entries; each entry’s fields has the same shape as put_document’s

Example

Tool: put_documents
documents: [
  {"id": "doc-1", "fields": {"title": "Hello"}},
  {"id": "doc-2", "fields": {"title": "World"}}
]

Result: 2 documents put (upserted). Call commit to persist changes.

A failure aborts at the offending entry without rolling back the already-applied prefix, so retrying the batch (or its suffix) is idempotent.


add_documents

Add many documents as new chunks in one call. Unlike put_documents, existing documents are never deleted, so repeating an ID adds multiple chunks of the same logical document. One WAL fsync covers the whole batch. Call commit afterwards.

Parameters

Same as put_documents.

Example

Tool: add_documents
documents: [
  {"id": "doc-1", "fields": {"title": "Part 1"}},
  {"id": "doc-1", "fields": {"title": "Part 2"}}
]

Result: 2 documents added as chunks. Call commit to persist changes.


get_documents

Retrieve all stored documents (including chunks) by external ID.

Parameters

NameTypeRequiredDescription
idstringYesExternal document identifier

Result

{
  "id": "doc-1",
  "documents": [
    { "fields": { "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

NameTypeRequiredDescription
idstringYesExternal 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

NameTypeRequiredDescription
querystringYesSearch query in laurus unified query DSL
limitintegerNoMaximum results (default: 10). offset + limit must not exceed the server’s max_result_window (default 10,000)
offsetintegerNoResults to skip for pagination (default: 0)
fusionstringNoFusion algorithm as JSON (for hybrid search)
field_boostsstringNoPer-field boost factors as JSON
highlightstringNoHighlighted fragments per field, as JSON (see Highlight example)
rescorestringNoLate-interaction rescore of the top results, as JSON (see Rescore example)
group_bystringNoGroup the results by a field’s value, as JSON; limit and offset then count groups (see Group by example)

Query DSL examples

Lexical search

QueryDescription
helloTerm search across default fields
title:helloField-scoped term search
title:hello AND body:worldBoolean AND
"exact phrase"Phrase search
roam~2Fuzzy search (edit distance 2)
count:[1 TO 10]Range search
title:helo~1Fuzzy field search
QueryDescription
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

QueryDescription
content:"cute kitten"Vector search on a field (field must be a vector field in schema)
content:pythonVector search with unquoted text
content:"cute kitten"^0.8Vector search with weight/boost
a:"cats" b:"dogs"^0.5Multiple vector queries

Hybrid search

QueryDescription
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.8Boolean 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}

Highlight example

highlight requests highlighted fragments per field (Issue #1134), following this tool’s lexical query — a vector-only query produces no highlights, and only stored: true text fields can be highlighted. The shorthand form is just a field list:

["body"]

The full object form adds HighlightConfig knobs — max_fragments, fragment_size, tag, css_class, require_field_match, max_analyzed_chars, return_entire_field_if_no_highlight:

{"fields": ["body"], "max_fragments": 2, "tag": "em"}

Rescore example

rescore reorders the top window_size results (default 100) with late interaction over a MultiVector field (Issue #1351). A rescored result’s score is its late-interaction (MaxSim) score. The query is text, embedded by the field’s token-level embedder (a candle_colbert one):

{"late_interaction": {"field": "body_colbert", "text": "how do lifetimes work"}}

or the query’s token vectors:

{"window_size": 50, "late_interaction": {"field": "body_colbert", "vectors": [[0.1, 0.2], [0.3, 0.4]]}}

A malformed rescore is a tool error. search_batch has no rescore parameter. See Late-Interaction Rescore.

Group by example

group_by groups the results by a field’s value (Issue #1297): each result is a group’s best hit, its inner_hits hold the group’s next best hits, and its group_key the shared value. Chunks added under one id share _id, so grouping by it returns each document once, with up to group_size - 1 more of its chunks (group_size defaults to 1, at most 100):

{"field": "_id", "group_size": 2}

With three chunks added as manual and one as faq, the query body:rust returns:

{"results":[{"fields":{"body":"Rust ownership rules for Rust programs"},"group_key":"manual","id":"manual","inner_hits":[{"fields":{"body":"Borrowing in Rust"},"group_key":"manual","id":"manual","score":0.012359549291431904}],"score":0.012571428902447224},{"fields":{"body":"Why Rust has no garbage collector"},"group_key":"faq","id":"faq","score":0.010891089215874672}],"total":2}

The field may also be a single-valued, stored Text, Integer, Float, Boolean or DateTime field. A malformed group_by is a tool error. See Result Grouping.

Result

Each result gains a "highlights" object when highlight was requested and at least one field actually highlighted:

{
  "total": 2,
  "results": [
    {
      "id": "doc-1",
      "score": 3.14,
      "fields": { "title": "Hello World", "body": "..." },
      "highlights": { "body": ["<em>Hello</em> World"] }
    },
    {
      "id": "doc-2",
      "score": 1.57,
      "fields": { "title": "Hello Again", "body": "..." }
    }
  ]
}

search_batch

Execute multiple independent searches in a single round trip. All queries run in parallel on the server; the same limit, offset, highlight and group_by apply to every query. Useful for agents issuing several sub-queries per turn.

Parameters

NameTypeRequiredDescription
queriesarray of stringYesQuery strings, each in the laurus unified query DSL (same syntax as search)
limitintegerNoMaximum results per query (default: 10). offset + limit must not exceed the server’s max_result_window (default 10,000)
offsetintegerNoResults to skip per query for pagination (default: 0)
highlightstringNoHighlighted fragments per field, as JSON — same format as search’s highlight, applied identically to every query
group_bystringNoGroup each query’s results by a field’s value, as JSON — same format as search’s group_by, applied identically to every query

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, "fields": { "title": "Hello World" } }
      ]
    },
    {
      "total": 1,
      "results": [
        { "id": "doc-2", "score": 2.71, "fields": { "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) would make 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, releasing the GIL for the duration of that call (Python::detach, Issue #1103) so other Python threads keep running while it’s in flight – a multi-threaded server genuinely benefits from more worker threads, rather than every call serializing on the GIL as before.

Because Python threads can now be concurrent writers for the first time, the engine’s existing concurrency caveats become reachable from Python: commit() is not serialized against concurrent put/add/delete calls, and CommitPolicy auto-commit guarantees hold for single-writer ingestion – best-effort under concurrent writers on a shared Index. Use explicit commit() calls, or a single ingest thread, when you need those guarantees under concurrency.

Note: The Node.js binding (laurus-nodejs) exposes the same Rust engine methods as native async / Promise APIs, 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

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.10 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)
# Writes `./myindex/schema.toml` and `./myindex/store/` -- the same layout
# `laurus-cli create index --schema` uses, so this directory can also be
# opened with the CLI (or vice versa).
schema = laurus.Schema()
schema.add_text_field("title")
schema.add_text_field("body")
index = laurus.Index(path="./myindex", schema=schema)

# Reopening it later only needs the path -- passing `schema` again would
# raise, since the schema is already persisted.
index = laurus.Index(path="./myindex")

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()
# 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']}")

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)
request = laurus.SearchRequest(
    lexical_query=laurus.TermQuery("title", "rust"),
    vector_query=laurus.VectorQuery("embedding", query_vec),
    fusion=laurus.RRF(k=60.0),
    limit=5,
)
results = index.search(request)

6. Late-interaction rescore

A late-interaction rescore reorders the top results of any search by ColBERT-style MaxSim against a multi-vector field, which holds each document’s token vectors.

import laurus

schema = laurus.Schema()
schema.add_text_field("title")
schema.add_multi_vector_field("tokens", dimension=2, distance="dot_product")

index = laurus.Index(schema=schema)
index.put_document("doc1", {"title": "Rust", "tokens": [[0.1, 0.0]]})
index.put_document("doc2", {"title": "Rust language", "tokens": [[0.9, 0.2], [0.0, 0.3]]})
index.commit()

results = index.search(
    "title:rust",
    rescore=laurus.LateInteractionRescore("tokens", [[1.0, 0.0], [0.0, 1.0]]),
)

To pass text instead of token vectors, register a "candle_colbert" embedder (schema.add_embedder("colbert", {"type": "candle_colbert", "model": "colbert-ir/colbertv2.0"})) and set embedder="colbert" on the multi-vector field; documents then give the field text, and the rescore query can be a string. See API Reference → LateInteractionRescore.

7. 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()

8. 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)
schema.add_multi_vector_field("tokens", dimension=128)

9. 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,
        wal_sync_policy: WalSyncPolicy | None = None,
        commit_policy: CommitPolicy | None = None,
    ) -> None: ...

Constructor

ParameterTypeDefaultDescription
pathstr | NoneNoneDirectory path for persistent storage. None creates an in-memory index. When given, the directory follows the <path>/schema.toml + <path>/store/ layout laurus-cli create index/--index-dir uses, so an index built here can also be opened by the CLI (and vice versa). See below.
schemaSchema | NoneNoneSchema definition. Only meaningful when creating a new file-backed index (or an in-memory one); must be omitted (None) when reopening an existing file-backed index — the persisted schema is loaded instead. An empty schema is used when omitted for a new index.
wal_sync_policyWalSyncPolicy | NoneNoneWAL durability policy. None keeps the default per-record fsync. See WAL sync policy / durability.
commit_policyCommitPolicy | NoneNoneAuto-commit policy. None keeps the default manual commit (no auto-commit). See Commit policy / auto-commit.

Creating vs. reopening a file-backed index (path given): if <path>/schema.toml does not yet exist, this call creates a new index and persists schema (or an empty schema, if omitted) to it. If <path>/schema.toml already exists, this call reopens the index — schema must be omitted, or a ValueError is raised (it would be ambiguous which schema should win). A ValueError is also raised if path contains an index in the layout used before this convention was introduced (segment files directly under path, no schema.toml).

Methods

MethodDescription
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.
put_documents(docs)Batched upsert. docs is an iterable of (id, dict) pairs, applied in order with one WAL fsync per batch (duplicate ids dedup, last wins). Fails fast at the first bad entry; the applied prefix is not rolled back.
add_documents(docs)Batched chunk append. Like put_documents but repeated ids accumulate as separate 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.
flush_wal()Force a durable WAL barrier. See WAL sync policy / durability.
search(query, *, limit=10, offset=0, highlight=None, rescore=None, group_by=None) -> list[SearchResult]Execute a search query. rescore takes a LateInteractionRescore that reorders the top results by late interaction over a multi-vector field (Issue #1351). group_by takes a GroupBy that groups the results by a field’s value (Issue #1297); limit and offset then count groups. When query is a SearchRequest, the request’s own rescore and group_by are used and these keywords are ignored.
search_batch(queries, *, limit=10, offset=0, highlight=None, group_by=None) -> 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 []. highlight and group_by apply identically to every query in the batch, except that a SearchRequest element uses its own. There is no rescore keyword; pass a SearchRequest with rescore= for each query that needs one.
stats() -> dictReturn 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 SearchRequest for 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.

Highlighting

search/search_batch’s highlight parameter (Issue #1134) requests highlighted fragments per field on each hit’s SearchResult.highlights. It accepts:

  • A list of field names: highlight=["body"]
  • A dict with a required "fields" key plus any of the optional HighlightConfig knobs: max_fragments, fragment_size, tag, css_class, require_field_match, max_analyzed_chars, return_entire_field_if_no_highlight — e.g. highlight={"fields": ["body"], "tag": "em", "max_fragments": 2}
results = index.search("body:rust", highlight=["body"])
print(results[0].highlights)  # {"body": ["<mark>Rust</mark> is a systems programming language."]}

Highlighting follows the query passed to search/search_batch (or SearchRequest.query/lexical_query — see below), and only stored: true text fields can be highlighted; a field that is absent, not stored, or not text is silently skipped. Omitting highlight leaves every result’s highlights empty. The same highlight= keyword is also accepted by SearchRequest.

WAL sync policy / durability

For a persistent index, every write is appended to a write-ahead log (WAL). By default the WAL is fsync-ed on every record, so each write is fully durable as soon as the call returns. The constructor accepts an optional wal_sync_policy to trade some durability for higher write throughput, and flush_wal() forces a durable barrier on demand.

class WalSyncPolicy:
    @staticmethod
    def per_record() -> WalSyncPolicy: ...
    @staticmethod
    def group(
        max_records: int | None = None,
        max_bytes: int | None = None,
        max_interval_ms: int | None = None,
    ) -> WalSyncPolicy: ...
ConstructorDescription
WalSyncPolicy.per_record()Default. fsync after every WAL record; fully durable per write.
WalSyncPolicy.group(...)Group commit. Batches fsync across writes.

group() parameters (all keyword-friendly, None keeps the default):

ParameterDefaultDescription
max_records1024Flush once this many records have accumulated.
max_bytes1048576 (1 MiB)Flush once this many unsynced bytes have accumulated.
max_interval_msNoneOptional periodic flush timer (milliseconds). None disables the timer.

With group commit the WAL is flushed when either max_records or max_bytes is reached, and always at commit(). A crash can lose up to the last unsynced batch — the same trade-off as SQLite’s synchronous = NORMAL. Call flush_wal() to force everything written so far to disk without a full commit().

MethodDescription
flush_wal()Force a durable WAL barrier now. Synchronous; returns None.
import laurus

# Opt into group commit with a 1-second periodic flush timer.
policy = laurus.WalSyncPolicy.group(max_records=4096, max_interval_ms=1000)
index = laurus.Index(path="./myindex", wal_sync_policy=policy)

for i in range(10_000):
    index.put_document(f"doc{i}", {"title": f"Document {i}"})

# Force a durable barrier without committing yet.
index.flush_wal()

index.commit()  # also flushes the WAL

Omit wal_sync_policy (or pass WalSyncPolicy.per_record()) to keep the default, fully durable behaviour.

Commit policy / auto-commit

A commit materializes buffered writes into the stores and makes all pending changes searchable. By default an Index never auto-commits, so the caller drives every commit() explicitly. The constructor accepts an optional commit_policy to instead let the engine auto-commit after a fixed number of applied documents, or at least once every fixed time interval.

class CommitPolicy:
    @staticmethod
    def manual() -> CommitPolicy: ...
    @staticmethod
    def every_docs(n: int) -> CommitPolicy: ...
    @staticmethod
    def interval_ms(ms: int) -> CommitPolicy: ...
ConstructorDescription
CommitPolicy.manual()Default. No auto-commit; the caller drives every commit().
CommitPolicy.every_docs(n)Auto-commit after every n applied documents.
CommitPolicy.interval_ms(ms)Auto-commit at least every ms milliseconds via a background timer (default: none). Native-only; a no-op on wasm.

every_docs(n) counts applied documents across both singular (put_document, add_document) and batch (put_documents, add_documents) ingest, and auto-commits after every n documents — including within a single batch. every_docs(0) is valid and disables auto-commit, making it equivalent to manual().

interval_ms(ms) is the time-based counterpart of every_docs(n): a background timer commits at least every ms milliseconds, so a trailing partial batch is committed even while ingestion is idle. This factory is native-only — on wasm there are no background threads, so the engine treats it as a no-op: the value still constructs, but no timed commit happens under WebAssembly.

commit_policy is orthogonal to wal_sync_policy: wal_sync_policy governs WAL fsync durability, whereas commit_policy governs when the stores materialize pending changes into searchable state. Setting one does not affect the other.

import laurus

# Auto-commit after every 100 applied documents.
index = laurus.Index(
    path="./myindex",
    commit_policy=laurus.CommitPolicy.every_docs(100),
)

for i in range(1_000):
    index.put_document(f"doc{i}", {"title": f"Document {i}"})
# The engine has already committed 10 times (once per 100 documents).

Omit commit_policy (or pass CommitPolicy.manual()) to keep the default, manual-commit behaviour.


Schema

Defines the fields and index types for an Index.

class Schema:
    def __init__(self) -> None: ...

Field methods

MethodDescription
add_text_field(name, *, stored=True, indexed=True, term_vectors=True, doc_values=True, multi_valued=False, position_increment_gap=100, analyzer=None)Full-text field (inverted index, BM25). term_vectors controls whether term positions are stored, read by phrase and span queries. doc_values controls whether the value is also copied into DocValues, the column-oriented store sort/facet/aggregation read from (Issue #1047); takes effect only when stored=True. Set multi_valued=True to accept a list[str] (Issue #1175): a term query matches if any element contains the term, a phrase query never spans two elements unless its slop reaches position_increment_gap (default 100; 0 numbers the elements as if concatenated), and values are read back as a list[str]. 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, doc_values=True)64-bit integer field. Set multi_valued=True to accept arrays of integers (range queries match if any value satisfies the predicate). See doc_values above.
add_float_field(name, *, stored=True, indexed=True, multi_valued=False, doc_values=True)64-bit float field. Set multi_valued=True to accept arrays of floats (range queries match if any value satisfies the predicate). See doc_values above.
add_boolean_field(name, *, stored=True, indexed=True, multi_valued=False, doc_values=True)Boolean field. Set multi_valued=True to accept a list[bool] (a term query such as flags:true matches if any element equals the value; values are read back as a list[bool]). See doc_values above.
add_bytes_field(name, *, stored=True, multi_valued=False)Raw bytes field. No doc_values option: a Bytes value is never written to DocValues regardless. Set multi_valued=True to accept a list[bytes] (Issue #1176); Bytes is never indexed, so unlike every other multi_valued option this has no query-matching semantics — it only governs the stored shape and ingestion arity. Values are read back as a list[bytes] (MIME is not preserved).
add_geo_field(name, *, stored=True, indexed=True, multi_valued=False, doc_values=True)Geographic coordinate field (lat/lon). Set multi_valued=True to accept a list of (lat, lon) tuples (distance / bounding-box queries match if any point satisfies the predicate; values are read back as a list of tuples). See doc_values above.
add_geo3d_field(name, *, stored=True, indexed=True, multi_valued=False, doc_values=True)3D ECEF Cartesian point field (x, y, z in metres). Set multi_valued=True to accept a list of (x, y, z) tuples (distance / bounding-box / nearest queries match if any point satisfies the predicate; values are read back as a list of tuples). See Geo3d concepts and doc_values above.
add_datetime_field(name, *, stored=True, indexed=True, multi_valued=False, doc_values=True)UTC datetime field. Set multi_valued=True to accept a list of datetime.datetime / str values (range queries match if any instant satisfies the predicate; values are read back as a list[str] of RFC 3339 strings in UTC). See doc_values above.
add_hnsw_field(name, dimension, *, distance="cosine", m=16, ef_construction=200, quantizer=None, subvector_count=None, rerank_storage=None, embedder=None, pq_codebook_path=None, base_weight=1.0)HNSW approximate nearest-neighbor vector field. base_weight sets this field’s relative scoring priority when searched alongside other vector fields (Issue #1084); see Vector Search → Weights.
add_flat_field(name, dimension, *, distance="cosine", embedder=None, base_weight=1.0)Flat (brute-force) vector field.
add_ivf_field(name, dimension, *, distance="cosine", n_clusters=100, n_probe=1, embedder=None, base_weight=1.0)IVF approximate nearest-neighbor vector field.
add_multi_vector_field(name, dimension, *, distance="cosine", storage="f32", embedder=None, pool_factor=1)Multi-vector field holding a variable number of token vectors per document (e.g. ColBERT embeddings), read only by the late-interaction rescore (Issue #1351). It has no ANN index and is not a vector-search target. distance must be "cosine" (default; vectors are L2-normalized when written) or "dot_product", and dimension must be greater than 0; both are checked here and raise ValueError. storage sets the on-disk element kind of the token vectors — "f32" (default; exact), "f16" (2x smaller), "int8" (~4x smaller, lossy quantization), or "binary" (32x smaller, 1 bit per element; requires "cosine"); an unrecognized value, or "binary" with "dot_product", also raises ValueError. pool_factor keeps about 1 / pool_factor of each document’s token vectors by averaging similar ones at index time (1, the default, keeps them all; 0 raises ValueError); see Token pooling. embedder names a token-level embedder ("candle_colbert", see Embedder types) that embeds text values and the text query of a rescore. A document gives the field a list of float lists (see Field value types) or, with embedder, text. The token vectors live only in the vector store: get_documents and search results do not return them. See Multi-Vector Fields.

Vector quantization & rerank storage (HNSW fields):

  • quantizer — "scalar_8bit" (default, 4× compression) or "product_quantization" for higher compression. Product quantization requires subvector_count (must divide dimension).
  • rerank_storage — set to "f32" to write a full-precision *.hnsw.f32 sidecar enabling exact Stage-2 rerank; omit to keep the int8-only segment.
  • pq_codebook_path — storage-relative file name of a shared PQ codebook (Issue #631), trained once via the laurus train pq-codebook CLI command. Only meaningful with quantizer="product_quantization"; commits then encode against the pre-trained codebook instead of re-training k-means per segment. Omit to keep per-segment training.

Every add_*_field method above raises ValueError when name starts with _ (other than _id), and adds nothing. A schema loaded with from_toml / from_toml_file keeps accepting such a field, so a persisted schema still loads; creating a new Index from it raises ValueError. See Field Naming.

Other methods

MethodDescription
add_embedder(name, config)Register a named embedder definition. config is a dict with a "type" key (see below), read with the same rules as the schema TOML’s [embedders] table. A config that is not a dict, or has a missing or unknown "type" or a missing required key, raises ValueError (invalid embedder config: ...).
add_analyzer(name, tokenizer, *, char_filters=None, token_filters=None)Register a custom analyzer definition. tokenizer is required; char_filters/token_filters are optional lists of dicts. Each dict uses the same {"type": "..."} shape as the schema TOML/JSON format (see below). A name reserved for a built-in analyzer (standard, keyword, english, simple, noop) raises ValueError, and so does creating a new Index from a schema that defines one (e.g. loaded with from_toml). Semantic validity (e.g. a malformed regex) is checked when the schema is used to build an Index, not here.
analyzer_names()Return the names of custom analyzers registered via add_analyzer or loaded from TOML.
Schema.from_toml(toml_str) (static)Parse a schema from a TOML string, in the same format laurus-cli create index --schema accepts.
Schema.from_toml_file(path) (static)Load a schema from a TOML file (path accepts str or os.PathLike).
to_toml()Serialize this schema to a TOML string in the same format laurus-cli accepts.
to_toml_file(path)Write this schema to a TOML file.
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

See Schema Format Reference → Embedders for the canonical description of each type.

"type"Required keysFeature flag
"precomputed"–(always available)
"candle_bert""model"embeddings-candle
"candle_clip""model"embeddings-multimodal
"openai""model"embeddings-openai
"candle_colbert""model"embeddings-candle

"candle_colbert" is a token-level embedder: it produces one vector per token and serves only a multi-vector field (add_multi_vector_field). It also takes the optional keys "revision" (branch, tag, or commit of the model repository), "query_maxlen", and "doc_maxlen"; see Schema Format Reference → Embedders for their defaults.

schema = laurus.Schema()
schema.add_text_field("body")
schema.add_embedder("colbert", {"type": "candle_colbert", "model": "colbert-ir/colbertv2.0"})
schema.add_multi_vector_field("body_colbert", dimension=128, embedder="colbert")

Analyzer components

Used by add_analyzer(name, tokenizer, *, char_filters=None, token_filters=None) and by the [analyzers.<name>] TOML section. tokenizer is a single dict; char_filters/token_filters are lists of dicts, applied in list order.

See Schema Format Reference → Analyzers for the canonical description of each component.

Tokenizers (tokenizer, exactly one):

"type"Required keysOptional keys
"whitespace"––
"unicode_word"––
"regex"–"pattern" (default \w+), "gaps" (default false)
"ngram""min_gram", "max_gram"–
"lindera""mode", "dict""user_dict"
"whole"––

Char filters (char_filters, applied to raw text before tokenization):

"type"Required keysOptional keys
"unicode_normalization""form" ("nfc"/"nfd"/"nfkc"/"nfkd")–
"pattern_replace""pattern", "replacement"–
"mapping""mapping" (dict of string replacements)–
"japanese_iteration_mark"–"kanji" (default true), "kana" (default true)

Token filters (token_filters, applied to the token stream after tokenization):

"type"Required keysOptional keys
"lowercase"––
"stop"–"words" (default: English stop words)
"stem"–"stem_type" ("porter"/"simple"/"identity")
"boost""boost"–
"limit""limit"–
"strip"––
"remove_empty"––
"flatten_graph"––
schema = laurus.Schema()
schema.add_analyzer(
    "ja_ipadic",
    {"type": "lindera", "mode": "normal", "dict": "/var/lib/lindera/ipadic"},
    char_filters=[
        {"type": "unicode_normalization", "form": "nfkc"},
        {"type": "japanese_iteration_mark"},
    ],
    token_filters=[{"type": "lowercase"}],
)
schema.add_text_field("title", analyzer="ja_ipadic")

Distance metrics

ValueDescription
"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.

DateTimeRangeQuery

DateTimeRangeQuery(
    field: str, *,
    min: str | datetime.datetime | datetime.date | None = None,
    max: str | datetime.datetime | datetime.date | None = None,
)

Matches DateTime values in the range [min, max] (both bounds inclusive). Pass None (or omit) for an open bound; min and max are keyword-only. A bound is a str literal in any form the query DSL accepts — RFC 3339 ("2024-01-01T09:00:00+09:00", normalized to UTC), naive "YYYY-MM-DDTHH:MM:SS[.fff]" (UTC), or "YYYY-MM-DD" (midnight UTC) — or a datetime.datetime / datetime.date, converted via isoformat() (a naive datetime is UTC). A malformed bound raises ValueError at construction. Usable anywhere a query object is accepted (Index.search, BooleanQuery, SearchRequest), e.g. index.search(laurus.DateTimeRangeQuery("created_at", min="2024-01-01", max="2024-12-31")).

GeoDistanceQuery

GeoDistanceQuery.within_radius(
    field: str, lat: float, lon: float, distance_m: float,
)

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,
        highlight=None,
        rescore: LateInteractionRescore | None = None,
        group_by: GroupBy | None = None,
    ) -> None: ...
ParameterDescription
queryA DSL string or single query object. A DSL string can be combined with vector_query: both are searched, and vector_query is added to the DSL’s vector part (fused by fusion, RRF(k=60) by default). Any other combination with lexical_query / vector_query raises ValueError (Issue #1372): write the lexical clauses in the DSL, or pass a query object as lexical_query / vector_query instead.
lexical_queryLexical component for explicit hybrid search.
vector_queryVector component for explicit hybrid search.
filter_queryLexical filter applied after scoring.
fusionFusion algorithm (RRF or WeightedSum). Defaults to RRF(k=60) when both components are set.
limitMaximum number of results (default 10).
offsetPagination offset (default 0).
highlightSame list-or-dict shape as Index.search’s highlight parameter (Issue #1134). See Highlighting.
rescoreA LateInteractionRescore applied to the top results of this request, whichever query shape is set (Issue #1351). It takes the place of Index.search’s rescore keyword, which is ignored when a SearchRequest is passed.
group_byA GroupBy grouping the results of this request (Issue #1297). It takes the place of the group_by keyword of Index.search and Index.search_batch, which is ignored when a SearchRequest is passed.

LateInteractionRescore

Late-interaction (ColBERT MaxSim) rescore of the top search results (Issue #1351). Pass it as rescore= to Index.search or SearchRequest.

class LateInteractionRescore:
    def __init__(
        self,
        field: str,
        query: str | list[list[float]],
        *,
        window_size: int | None = None,
    ) -> None: ...

    @property
    def window_size(self) -> int: ...
ParameterTypeDefaultDescription
fieldstr–A multi-vector field (see add_multi_vector_field).
querystr | list[list[float]]–The query text, embedded by the field’s token-level embedder (a "candle_colbert" one), or the query’s token vectors, computed with the same model as the documents’ token vectors. Integers are accepted as vector elements.
window_sizeint | NoneNone (100)How many top first-stage results to rescore, 1..=10,000. Keyword-only.

The window_size property returns the window the rescore uses (100 when omitted).

The top window_size first-stage results — lexical, vector, or hybrid — are reordered by their MaxSim against the field, highest first, and a rescored result’s score is its MaxSim. Window results without token vectors in the field follow in their first-stage order, and results beyond the window follow last, keeping their first-stage order and score. offset and limit are cut from this rescored ranking. See Vector Search → Late-Interaction Rescore for the ordering, similarity, and cost details.

Errors:

  • TypeError at construction when query is neither a str nor a list of lists, or a token vector holds a bool or str.
  • ValueError (Invalid argument: rescore: ...) from Index.search for every other invalid value, checked before the search runs: field is not a multi-vector field, the query does not hold 1 to 1,024 vectors of the field’s dimension with finite values, window_size is outside 1..=10,000, or a text query is blank or the field has no token-level embedder.
import laurus

schema = laurus.Schema()
schema.add_text_field("title")
schema.add_multi_vector_field("tokens", dimension=2, distance="dot_product")

index = laurus.Index(schema=schema)
index.put_document("a", {"title": "rust", "tokens": [[0.1, 0.0]]})
index.put_document("b", {"title": "rust language", "tokens": [[0.9, 0.2]]})
index.commit()

rescore = laurus.LateInteractionRescore("tokens", [[1.0, 0.0], [0.0, 1.0]], window_size=50)
results = index.search("title:rust", rescore=rescore)  # "b" (MaxSim 1.1) before "a" (0.1)

# The same rescore inside a SearchRequest (also usable in search_batch)
request = laurus.SearchRequest(query="title:rust", rescore=rescore, limit=5)
results = index.search(request)

With a "candle_colbert" embedder on the field, documents can give the field text and the query can be text too: laurus.LateInteractionRescore("body_colbert", "how do lifetimes work").


GroupBy

Groups the search results by a field’s value (Issue #1297), so that the chunks of one document do not fill the page. Pass it as group_by= to Index.search, Index.search_batch or SearchRequest.

class GroupBy:
    def __init__(self, field: str, *, group_size: int = 1) -> None: ...

    @property
    def field(self) -> str: ...
    @property
    def group_size(self) -> int: ...
ParameterTypeDefaultDescription
fieldstr–_id (chunks added with add_document share their document’s _id), or a single-valued, stored text, integer, float, boolean or datetime field with doc_values enabled (the defaults).
group_sizeint1How many hits each group keeps, the returned result included, 1..=100. Keyword-only.

Each returned SearchResult is then the best hit of a group: its group_key is the value the group shares and its inner_hits hold up to group_size - 1 further hits of the group, in ranking order. limit and offset count groups. Grouping runs on the final ranking, after fusion and rescore, for lexical, vector and hybrid searches alike. See Result Grouping for the ordering, the candidate pool and why a group can hold fewer than group_size hits.

Errors:

  • TypeError from Index.search, Index.search_batch and SearchRequest when group_by is not a GroupBy, and OverflowError at construction for a negative group_size.
  • ValueError (Invalid argument: group_by: ...) from Index.search and Index.search_batch, checked before the search runs: field is unknown, of another type, multi-valued, not stored or without doc_values, group_size is outside 1..=100, or offset + limit exceeds 10,000.
import laurus

schema = laurus.Schema()
schema.add_text_field("body")

index = laurus.Index(schema=schema)
index.add_document("manual", {"body": "Lifetimes tie a reference to its owner."})
index.add_document("manual", {"body": "Elided lifetimes follow three rules."})
index.add_document("faq", {"body": "Why do lifetimes appear in signatures?"})
index.commit()

for result in index.search("body:lifetimes", group_by=laurus.GroupBy("_id", group_size=2)):
    print(result.group_key, [hit.document["body"] for hit in result.inner_hits])
# faq []
# manual ['Elided lifetimes follow three rules.']

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
    highlights: dict[str, list[str]]  # Highlighted fragments per requested field
    group_key: str | int | float | bool | None  # With group_by, the group's value
    inner_hits: list[SearchResult]  # With group_by, the group's next best hits

highlights maps each field named in highlight to its highlighted fragments (best first); a field that did not highlight is absent from the dict, and highlights is {} when highlight was not requested. See Highlighting.

With rescore, a rescored result’s score is its MaxSim, while results outside the rescore window keep their first-stage score. See LateInteractionRescore.

With group_by, group_key is the value the result’s group shares (a datetime value as an RFC 3339 string; None for a hit without a value) and inner_hits holds the group’s next best hits in ranking order, each with the same group_key and no inner hits of its own. An ungrouped search leaves group_key as None and inner_hits empty. See GroupBy.


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
    token_type: str | None

start_offset and end_offset are UTF-8 byte offsets into the original text. For non-ASCII text they are not str indices; slice the encoded text instead: text.encode()[tok.start_offset:tok.end_offset].decode().

token_type is one of "alphanum", "num", "cjk", "katakana", "hiragana", "hangul", "punctuation", "whitespace", "synonym", "email", "url" and "other", or None. SynonymGraphFilter.apply keeps each token’s offsets and type, and gives each synonym it inserts the type "synonym" and the offsets of the words it replaces.


Field value types

Python values are automatically converted to Laurus DataValue types:

Python typeLaurus typeNotes
NoneNull
boolBoolChecked before int
intInt64
floatFloat64
strText
bytesBytes
list[bool]BoolArrayMulti-valued boolean field; checked before the list[int] rule because bool is a subclass of int. Requires multi_valued=True on the field
list[bytes]BytesArrayMulti-valued bytes field (Issue #1176); each element takes the same direct-bytes path as a single bytes value, so MIME is always None. Bytes is never indexed, so this has no query-matching semantics — it only governs the stored shape. Requires multi_valued=True on the field
list[int]Int64ArrayMulti-valued integer field (a list of bool becomes a BoolArray instead, which a multi-valued Float / Integer field widens to 0/1 in the core); vector fields cast the list to f32. An empty list is an empty Int64Array
list[float | int]Float64ArrayMulti-valued float field (integers widened); vector fields cast the list to f32
list[list[float | int]]VectorArrayToken vectors of a multi-vector field (Issue #1351), one inner list per token, e.g. {"tokens": [[0.1, 0.2], [0.3, 0.4]]}. Integers are widened; a bool or str element raises TypeError. The vectors’ count and dimension are checked against the field when the document is written (ValueError, e.g. for ragged lists). Not returned by get_documents or search results
(lat, lon) tupleGeoTwo float values
(x, y, z) tupleGeo3dThree float values (ECEF Cartesian, metres)
list[(lat, lon)]GeoArrayList of (lat, lon) tuples; requires multi_valued=True on the field
list[(x, y, z)]GeoEcefArrayList of (x, y, z) tuples; requires multi_valued=True on the field
datetime.datetimeDateTimeConverted via isoformat()
list[datetime.datetime | str] (containing at least one datetime)DateTimeArrayEach element parsed like a single datetime (RFC 3339 / ISO 8601 with offset, or naive YYYY-MM-DDTHH:MM:SS as UTC; objects with isoformat() are converted first); a non-datetime element raises ValueError. Requires multi_valued=True on the field
list[str]DateTimeArray or TextArrayA DateTimeArray when every element parses as a datetime (same forms as above), a TextArray otherwise (Issue #1175; read back as a list[str]). Requires multi_valued=True on the field

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 make targets 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:

  1. Rust unit tests via cargo test -p laurus-python
  2. Python integration tests via pytest (after a fresh maturin 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

TargetDescription
make venvCreate .venv and install maturin + pytest
make venv-cleanRemove .venv
make build-laurus-pythonBuild a release wheel via maturin build
make test-laurus-pythonRust unit tests + Python pytest
make lint-laurus-pythonClippy with -D warnings
make format-laurus-pythoncargo fmt -p laurus-python
make cleanRemove 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.ts type 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 (each call still releases the GIL while its Rust work runs, so it isn’t single-threaded – see the Python binding’s docs). 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

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.node requirement in package.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)
// Writes `./myindex/schema.toml` and `./myindex/store/` -- the same layout
// `laurus-cli create index --schema` uses, so this directory can also be
// opened with the CLI (or vice versa).
const schema = new Schema();
schema.addTextField("name");
schema.addTextField("description");
const persistentIndex = await Index.create("./myindex", schema);

// Reopening it later only needs the path -- passing `schema` again would
// throw, since the schema is already persisted.
const reopened = await Index.create("./myindex");

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. Late-interaction rescore

A multi-vector field holds several token vectors per document (for example ColBERT token embeddings). Passing a rescore object reorders the top results of any search by late interaction (MaxSim) against that field.

import { Index, Schema } from "laurus-nodejs";

const schema = new Schema();
schema.addTextField("title");
schema.addMultiVectorField("tokens", 2, "dot_product");

const index = await Index.create(null, schema);
await index.putDocument("a", {
  title: "rust",
  tokens: [[0.1, 0.0]],
});
await index.putDocument("b", {
  title: "rust language",
  tokens: [[0.9, 0.2], [0.0, 0.5]],
});
await index.commit();

// Lexical first stage, then reorder the top results by MaxSim:
// "b" (score 1.4) now ranks above "a" (score 0.1).
const results = await index.search("title:rust", 10, 0, undefined, {
  field: "tokens",
  vectors: [[1, 0], [0, 1]],
});

Token vectors are not stored, so getDocuments and search results do not return them. With a candle_colbert embedder registered via addEmbedder and named as the field’s 4th argument, documents can give the field text and the rescore can take text instead of vectors; see Late-interaction rescore.

7. 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();

8. 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);
schema.addMultiVectorField("tokens", 128);

9. 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,
    walSyncPolicy?: WalSyncPolicy,
    commitPolicy?: CommitPolicy,
  ): Promise<Index>;
}

Factory method

ParameterTypeDefaultDescription
pathstring | nullnullDirectory for persistent storage. null creates an in-memory index. When given, the directory follows the <path>/schema.toml + <path>/store/ layout laurus-cli create index/--index-dir uses, so an index built here can also be opened by the CLI (and vice versa). See below.
schemaSchemaemptySchema definition. Only meaningful when creating a new file-backed index (or an in-memory one); must be omitted when reopening an existing file-backed index – the persisted schema is loaded instead. An empty schema is used when omitted for a new index.
walSyncPolicyWalSyncPolicyper-recordWAL durability policy. Omit to keep the default per-record fsync. See WAL sync policy / durability.
commitPolicyCommitPolicymanualAuto-commit policy. Omit to keep the default manual policy (the caller drives every commit()). See Commit policy / auto-commit.

Creating vs. reopening a file-backed index (path given): if <path>/schema.toml does not yet exist, this call creates a new index and persists schema (or an empty schema, if omitted) to it. If <path>/schema.toml already exists, this call reopens the index – schema must be omitted, or the call throws (it would be ambiguous which schema should win). It also throws if path contains an index in the layout used before this convention was introduced (segment files directly under path, no schema.toml).

Methods

MethodDescription
putDocument(id, doc)Upsert a document. Replaces all existing versions.
addDocument(id, doc)Append a document chunk without removing existing versions.
putDocuments(docs)Batched upsert. docs is an Array<[id, doc]> applied in order with one WAL fsync per batch (duplicate ids dedup, last wins). Fails fast at the first bad entry; the applied prefix is not rolled back.
addDocuments(docs)Batched chunk append. Like putDocuments but repeated ids accumulate as separate 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.
flushWal()Force a durable WAL barrier. See WAL sync policy / durability.
search(query, limit?, offset?, highlight?, rescore?, groupBy?)Search with a DSL string. rescore reorders the top results by late interaction; see Late-interaction rescore. groupBy groups the results by a field’s value; see Result grouping.
searchTerm(field, term, limit?, offset?, highlight?)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?, highlight?, groupBy?)Execute multiple DSL string queries in parallel. results[i] corresponds to queries[i]. Returns Promise<Array<Array<JsSearchResult>>>. Empty input returns []. highlight and groupBy apply identically to every query.
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 }>;
}

WAL sync policy / durability

For a persistent index, every write is appended to a write-ahead log (WAL). By default the WAL is fsync-ed on every record, so each write is fully durable as soon as its Promise resolves. Index.create accepts an optional walSyncPolicy to trade some durability for higher write throughput, and flushWal() forces a durable barrier on demand.

class WalSyncPolicy {
  static perRecord(): WalSyncPolicy;
  static group(
    maxRecords?: number,
    maxBytes?: number,
    maxIntervalMs?: number,
  ): WalSyncPolicy;
}
ConstructorDescription
WalSyncPolicy.perRecord()Default. fsync after every WAL record; fully durable per write.
WalSyncPolicy.group(...)Group commit. Batches fsync across writes.

group(...) parameters (omit any argument to keep its default):

ParameterDefaultDescription
maxRecords1024Flush once this many records have accumulated.
maxBytes1048576 (1 MiB)Flush once this many unsynced bytes have accumulated.
maxIntervalMsnoneOptional periodic flush timer (milliseconds). Omit to disable the timer.

With group commit the WAL is flushed when either maxRecords or maxBytes is reached, and always at commit(). A crash can lose up to the last unsynced batch — the same trade-off as SQLite’s synchronous = NORMAL. Call flushWal() to force everything written so far to disk without a full commit().

MethodDescription
flushWal()Force a durable WAL barrier now. Returns Promise<void>.
import { Index, WalSyncPolicy } from "laurus-nodejs";

// Opt into group commit with a 1-second periodic flush timer.
const policy = WalSyncPolicy.group(4096, undefined, 1000);
const index = await Index.create("./myindex", schema, policy);

for (let i = 0; i < 10000; i++) {
  await index.putDocument(`doc${i}`, { title: `Document ${i}` });
}

// Force a durable barrier without committing yet.
await index.flushWal();

await index.commit(); // also flushes the WAL

Omit walSyncPolicy (or pass WalSyncPolicy.perRecord()) to keep the default, fully durable behaviour.

Commit policy / auto-commit

By default the caller drives every commit(), so pending changes become searchable only when you call it explicitly. Index.create accepts an optional commitPolicy to let the engine auto-commit on an ingestion-driven cadence instead, so writes are materialized without an explicit commit().

class CommitPolicy {
  static manual(): CommitPolicy;
  static everyDocs(n: number): CommitPolicy;
  static intervalMs(ms: number): CommitPolicy;
}
ConstructorDescription
CommitPolicy.manual()Default. No auto-commit; the caller drives every commit().
CommitPolicy.everyDocs(n)Auto-commit after every n applied documents.
CommitPolicy.intervalMs(ms)Auto-commit at least every ms milliseconds via a background timer (default: none). Native-only; a no-op on wasm (no background threads).

With everyDocs(n) the engine commits after every n applied documents, counted across both singular and batch ingest — including every n documents within a single batch. Passing everyDocs(0) is valid and disables auto-commit, which is equivalent to CommitPolicy.manual().

intervalMs(ms) is the time-based counterpart of everyDocs(n): a background timer auto-commits at least every ms milliseconds, so a trailing partial batch is materialized even while ingestion is idle. It is native-only — on wasm there are no background threads, so the engine treats intervalMs as a no-op (the factory still constructs the value, but no timed commit happens under WebAssembly).

commitPolicy is orthogonal to walSyncPolicy: walSyncPolicy governs WAL fsync durability, while commitPolicy governs when the stores materialize pending changes into a searchable commit. You can combine them freely.

import { Index, CommitPolicy } from "laurus-nodejs";

// Auto-commit after every 1000 applied documents.
const index = await Index.create(
  null,
  schema,
  undefined,
  CommitPolicy.everyDocs(1000),
);

for (let i = 0; i < 10000; i++) {
  await index.putDocument(`doc${i}`, { title: `Document ${i}` });
}
// The engine has already committed ten times; no explicit commit() needed.

Omit commitPolicy (or pass CommitPolicy.manual()) to keep the default, caller-driven commit behaviour.


Schema

Defines the fields and index types for an Index.

class Schema {
  constructor();
}

Field methods

MethodDescription
addTextField(name, stored?, indexed?, termVectors?, docValues?, analyzer?, multiValued?, positionIncrementGap?)Full-text field (inverted index, BM25). docValues controls whether the value is also copied into DocValues, the column-oriented store sort/facet/aggregation read from (Issue #1047, default true); takes effect only when stored is also true. Pass multiValued: true to accept an array of strings (Issue #1175): a term query matches if any element contains the term, a phrase query never spans two elements unless its slop reaches positionIncrementGap (default 100; 0 numbers the elements as if concatenated), and values are read back as an array of strings. 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?, docValues?)64-bit integer field. Pass multiValued: true to accept arrays of integers (range queries match if any value satisfies the predicate). See docValues above.
addFloatField(name, stored?, indexed?, multiValued?, docValues?)64-bit float field. Pass multiValued: true to accept arrays of floats (range queries match if any value satisfies the predicate). See docValues above.
addBooleanField(name, stored?, indexed?, multiValued?, docValues?)Boolean field. Pass multiValued: true to accept an array of booleans (a term query such as flags:true matches if any element equals the value; values are read back as an array of booleans). See docValues above.
addBytesField(name, stored?, multiValued?)Raw bytes field. No docValues option: a Bytes value is never written to DocValues regardless. Pass multiValued: true to accept an array of base64 strings (Issue #1176), decoded element-wise by the schema-aware coercion the same way a single base64 string is; Bytes is never indexed, so unlike every other multiValued option this has no query-matching semantics — it only governs the stored shape and ingestion arity. Values are read back as an array of byte-integer arrays with MIME dropped, the same input/output asymmetry the scalar field already has.
addGeoField(name, stored?, indexed?, multiValued?, docValues?)Geographic coordinate field. Pass multiValued: true to accept an array of { lat, lon } objects (distance / bounding-box queries match if any point satisfies the predicate). See docValues above.
addGeo3dField(name, stored?, indexed?, multiValued?, docValues?)3D ECEF Cartesian point field (x, y, z in metres). Pass multiValued: true to accept an array of { x, y, z } objects (distance / bounding-box / nearest queries match if any point satisfies the predicate). See Geo3d concepts and docValues above.
addDatetimeField(name, stored?, indexed?, multiValued?, docValues?)UTC datetime field. Pass multiValued: true to accept an array of RFC 3339 strings (range queries match if any instant satisfies the predicate; values are read back as an array of RFC 3339 strings normalized to UTC). See docValues above.
addHnswField(name, dimension, distance?, m?, efConstruction?, defaultEfSearch?, embedder?, quantizer?, subvectorCount?, rerankStorage?, pqCodebookPath?, baseWeight?)HNSW vector field. baseWeight sets this field’s relative scoring priority when searched alongside other vector fields (Issue #1084); see Vector Search → Weights.
addFlatField(name, dimension, distance?, embedder?, baseWeight?)Flat (brute-force) vector field.
addIvfField(name, dimension, distance?, nClusters?, nProbe?, embedder?, baseWeight?)IVF vector field.
addMultiVectorField(name, dimension, distance?, embedder?, storage?, poolFactor?)Multi-vector field holding a variable number of token vectors per document, for the late-interaction rescore (Issue #1351); see Multi-Vector Fields. dimension is the dimension of each token vector and must be greater than 0; distance is "cosine" (default) or "dot_product". Both are checked when the field is added, which throws with code InvalidArg. storage sets the on-disk element kind of the token vectors (Issue #1346): "f32" (default, exact), "f16" (2x smaller), "int8" (~4x smaller), or "binary" (32x smaller, 1 bit per element; requires "cosine"); an unrecognized value, or "binary" with "dot_product", also throws. poolFactor keeps about 1 / poolFactor of each document’s token vectors by averaging similar ones at index time (default 1, all; 0 throws InvalidArg); see Token pooling. embedder names a token-level ("candle_colbert") embedder, which embeds text values and rescore query text. The field is not searchable on its own, and its token vectors are not stored, so getDocuments and search results do not return them.
addEmbedder(name, config)Register a named embedder. See Embedders.
addAnalyzer(name, tokenizer, charFilters?, tokenFilters?)Register a custom analyzer definition. tokenizer is required; charFilters/tokenFilters are optional arrays of objects. Each object uses the same { type: "...", ... } shape as the schema TOML/JSON format (see below) — keys stay snake_case, matching that wire format. A name reserved for a built-in analyzer (standard, keyword, english, simple, noop) throws, and so does Index.create with a new index’s schema that defines one (e.g. loaded with fromToml). Semantic validity (e.g. a malformed regex) is checked when the schema is used to build an Index, not here.
analyzerNames()Return the names of custom analyzers registered via addAnalyzer or loaded from TOML.
Schema.fromToml(tomlStr) (static)Parse a schema from a TOML string, in the same format laurus-cli create index --schema accepts.
Schema.fromTomlFile(path) (static)Load a schema from a TOML file.
toToml()Serialize this schema to a TOML string in the same format laurus-cli accepts.
toTomlFile(path)Write this schema to a TOML file.
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=[...])").

Vector quantization & rerank storage (HNSW fields):

  • quantizer — "scalar_8bit" (default, 4× compression) or "product_quantization" for higher compression. Product quantization requires subvectorCount (must divide dimension).
  • rerankStorage — set to "f32" to write a full-precision *.hnsw.f32 sidecar enabling exact Stage-2 rerank; omit to keep the int8-only segment.
  • pqCodebookPath — storage-relative file name of a shared PQ codebook (Issue #631), trained once via the laurus train pq-codebook CLI command. Only meaningful with quantizer: "product_quantization"; commits then encode against the pre-trained codebook instead of re-training k-means per segment. Omit to keep per-segment training.

Every add*Field method above throws when name starts with _ (other than _id), and adds nothing. A schema loaded with fromToml / fromTomlFile keeps accepting such a field, so a persisted schema still loads; creating a new Index from it throws. See Field Naming.

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.

Analyzer components

Used by addAnalyzer(name, tokenizer, charFilters?, tokenFilters?) and by the [analyzers.<name>] TOML section. tokenizer is a single object; charFilters/tokenFilters are arrays of objects, applied in array order.

See Schema Format Reference → Analyzers for the canonical description of each component.

Tokenizers (tokenizer, exactly one):

typeRequired keysOptional keys
"whitespace"––
"unicode_word"––
"regex"–pattern (default \w+), gaps (default false)
"ngram"min_gram, max_gram–
"lindera"mode, dictuser_dict
"whole"––

Char filters (charFilters, applied to raw text before tokenization):

typeRequired keysOptional keys
"unicode_normalization"form ("nfc"/"nfd"/"nfkc"/"nfkd")–
"pattern_replace"pattern, replacement–
"mapping"mapping (object of string replacements)–
"japanese_iteration_mark"–kanji (default true), kana (default true)

Token filters (tokenFilters, applied to the token stream after tokenization):

typeRequired keysOptional keys
"lowercase"––
"stop"–words (default: English stop words)
"stem"–stem_type ("porter"/"simple"/"identity")
"boost"boost–
"limit"limit–
"strip"––
"remove_empty"––
"flatten_graph"––
const schema = new Schema();
schema.addAnalyzer(
  "ja_ipadic",
  { type: "lindera", mode: "normal", dict: "/var/lib/lindera/ipadic" },
  [
    { type: "unicode_normalization", form: "nfkc" },
    { type: "japanese_iteration_mark" },
  ],
  [{ type: "lowercase" }],
);
schema.addTextField("title", true, true, true, true, "ja_ipadic");

Embedders

Used by addEmbedder(name, config) and by the [embedders.<name>] TOML section. config is an object whose type key selects the backend; like the analyzer components, its keys stay snake_case, matching the schema TOML/JSON format.

See Schema Format Reference → Embedders for the canonical description of each type.

typeRequired keysOptional keysFeature flag
"precomputed"––(always available)
"candle_bert"model–embeddings-candle
"candle_clip"model–embeddings-multimodal
"openai"model–embeddings-openai
"candle_colbert"modelrevision, query_maxlen, doc_maxlenembeddings-candle

"candle_colbert" runs a BERT-based ColBERT checkpoint (such as "colbert-ir/colbertv2.0") and produces one vector per token, so only a multi-vector field (addMultiVectorField) can use it; a multi-vector field in turn accepts only "candle_colbert" or "precomputed", and any other combination makes Index.create throw. revision pins a branch, tag or commit of the model repository (pin a commit, so that re-embedding reproduces the same vectors); query_maxlen and doc_maxlen override the checkpoint’s query and document token lengths.

addEmbedder throws embedder config must be an object when config is not an object, and invalid embedder config: ... when type is missing or unknown or a required key is missing. A type whose feature flag the binding was not built with still registers, but Index.create throws.

const schema = new Schema();
schema.addEmbedder("colbert", {
  type: "candle_colbert",
  model: "answerdotai/answerai-colbert-small-v1",
  revision: "934fa8bb4ce2284f4c2baa232d81aca4d076fa5e",
});
schema.addTextField("body");
schema.addMultiVectorField("body_colbert", 96, "cosine", "colbert");

Distance metrics

ValueDescription
"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.

DateTimeRangeQuery

new DateTimeRangeQuery(
  field: string,
  min?: string | null,
  max?: string | null,
)

Matches DateTime values in [min, max] (both bounds inclusive). Pass null (or omit) for an open bound. Bounds are string literals in any form the query DSL accepts: RFC 3339 ("2024-01-01T09:00:00+09:00", normalized to UTC), naive "YYYY-MM-DDTHH:MM:SS[.fff]" (UTC), or "YYYY-MM-DD" (midnight UTC); pass date.toISOString() for a Date. A malformed bound throws an Error at construction. Attach it with BooleanQuery.mustDateTimeRange / shouldDateTimeRange / mustNotDateTimeRange and SearchRequest.setLexicalDateTimeRange / setFilterDateTimeRange.

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, DateTimeRange,
  //     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 39 per-type methods (13 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
  highlight?: HighlightOptions;
  rescore?: RescoreOptions;
  groupBy?: GroupByOptions;
}

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. highlight, rescore and groupBy are plain data (not class-instance unions), so they live directly on SearchRequestOptions rather than behind a setter.

DSL and fusion setters

MethodDescription
setQueryDsl(dsl: string)Set a DSL string query (same as the queryDsl option). It can be combined with a vector setter: both are searched, and the vector query is added to the DSL’s vector part (fused by the fusion setter, RRF(60) by default). Combining it with a setLexicalX setter makes searchWithRequest throw (Issue #1372): write the lexical clauses in the DSL, or use setLexicalX without a DSL.
setRrfFusion(rrf: RRF)Use RRF fusion.
setWeightedSumFusion(ws: WeightedSum)Use weighted-sum fusion.

Vector setters

MethodDescription
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, DateTimeRange, GeoDistance, GeoBoundingBox, Geo3dDistance, Geo3dBoundingBox, Geo3dNearest, Boolean, Span }, the request exposes:

MethodDescription
setLexicalX(query: X)Set the lexical component for an explicit hybrid request.
setFilterX(query: X)Set the post-scoring filter component.

That is, 26 per-type setters in total (13 lexical + 13 filter), in addition to the DSL, vector, and fusion setters above — for example setLexicalNumericRange(q) / setFilterNumericRange(q) and setLexicalDateTimeRange(q) / setFilterDateTimeRange(q).

const req = new SearchRequest({ limit: 5 });
req.setLexicalTerm(new TermQuery("title", "rust"));
req.setVectorQuery(new VectorQuery("embedding", [0.1, 0.2, 0.3, 0.4]));
req.setRrfFusion(new RRF(60.0));
const results = await index.searchWithRequest(req);

Highlighting

search, searchTerm, searchBatch and SearchRequestOptions accept an optional highlight object (Issue #1134):

interface HighlightOptions {
  fields: string[];
  fragmentSize?: number;    // default 150
  maxFragments?: number;    // default 5
  tag?: string;             // default "mark"
  cssClass?: string;
  requireFieldMatch?: boolean; // default true
}

Only fields is required. Highlighting follows the query passed to the same call, and only stored: true text fields can be highlighted — a field that isn’t stored, isn’t a text field, or had no match is simply absent from the result’s highlights object.

const results = await index.search("body:rust", 10, 0, { fields: ["body"], tag: "em" });
// results[0].highlights => { body: ["<em>Rust</em> is a systems programming language."] }

Late-interaction rescore

search (its trailing rescore argument) and SearchRequestOptions accept an optional rescore object (Issue #1351). It reorders the top results of the first stage — lexical, vector or hybrid — by ColBERT-style late interaction (MaxSim) against a multi-vector field. See Vector Search → Late-Interaction Rescore for how it works.

// Exported from index.d.ts as JsRescoreOptions.
interface RescoreOptions {
  field: string;          // a multi-vector field
  vectors?: number[][];   // the query's token vectors
  text?: string;          // query text, embedded by the field's embedder
  windowSize?: number;    // default 100, at most 10,000
}
FieldTypeDefaultDescription
fieldstring–The multi-vector field to score against.
vectorsnumber[][]–The query’s token vectors: 1 to 1,024 of them, each of the field’s dimension, with finite values. Compute them with the model that produced the documents’ token vectors.
textstring–Query text, embedded as a query by the field’s token-level ("candle_colbert") embedder. Must not be blank.
windowSizenumber100How many top first-stage results to rescore, 1 to 10,000.

Set exactly one of vectors and text; otherwise the search rejects with code InvalidArg and the message rescore needs exactly one of vectors or text. The engine checks the other values when the search runs, and rejects with code InvalidArg and a message containing rescore: ... when field is unknown or not a multi-vector field, a text query is blank or the field has no token-level embedder, the query holds too few or too many vectors or one of the wrong dimension, or windowSize is out of range.

Ordering and scores:

  • The top windowSize first-stage results are sorted by MaxSim, highest first, and a rescored result’s score is its MaxSim.
  • Window results without token vectors in the field follow in their first-stage order, then the results beyond the window, unchanged. Both keep their first-stage score, which is not comparable with a MaxSim.

searchTerm, searchVector, searchVectorText and searchBatch take no rescore argument; build a SearchRequest instead, which rescores any first stage, including a hybrid one.

// A DSL search, rescored with the query's token vectors.
const results = await index.search("title:rust", 10, 0, undefined, {
  field: "tokens",
  vectors: [[1, 0], [0, 1]],
});

// A hybrid search, rescored with query text (the field needs a
// "candle_colbert" embedder).
const req = new SearchRequest({
  limit: 10,
  rescore: { field: "body_colbert", text: "how do lifetimes work", windowSize: 50 },
});
req.setLexicalTerm(new TermQuery("body", "lifetimes"));
req.setVectorQuery(new VectorQuery("body_vec", queryEmbedding));
req.setRrfFusion(new RRF(60.0));
const reranked = await index.searchWithRequest(req);

Result grouping

search and searchBatch (their trailing groupBy argument) and SearchRequestOptions accept an optional groupBy object (Issue #1297). It groups the results by a field’s value, so that the chunks of one document do not fill the page. Grouping runs on the final ranking, after fusion and rescore, for lexical, vector and hybrid searches alike. See Result Grouping for the ordering, the candidate pool and why a group can hold fewer than groupSize hits.

// Exported from index.d.ts as JsGroupByOptions.
interface GroupByOptions {
  field: string;       // "_id", or a single-valued stored field
  groupSize?: number;  // default 1, at most 100
}
FieldTypeDefaultDescription
fieldstring–_id (chunks added with addDocument share their document’s _id), or a single-valued, stored Text, Integer, Float, Boolean or DateTime field with docValues enabled (the defaults).
groupSizenumber1How many hits each group keeps, the returned result included, 1 to 100.

Each returned result is then the best hit of a group: its groupKey is the value the group shares and its innerHits hold up to groupSize - 1 further hits of the group, in ranking order (see SearchResult). limit and offset count groups.

An object without field rejects with code InvalidArg and the message Missing field `field` . The engine checks the values when the search runs, and rejects with code InvalidArg and a message containing group_by: ... when field is unknown, of another type, multi-valued, not stored or without docValues, when groupSize is out of range, or when offset + limit exceeds 10,000.

searchTerm, searchVector and searchVectorText take no groupBy argument; build a SearchRequest instead.

const schema = new Schema();
schema.addTextField("body");
const index = await Index.create(null, schema);
await index.addDocument("manual", { body: "Lifetimes tie a reference to its owner." });
await index.addDocument("manual", { body: "Elided lifetimes follow three rules." });
await index.addDocument("faq", { body: "Why do lifetimes appear in signatures?" });
await index.commit();

const results = await index.search("body:lifetimes", 10, 0, undefined, undefined, {
  field: "_id",
  groupSize: 2,
});
for (const r of results) {
  console.log(r.groupKey, r.innerHits.map((hit) => hit.document.body));
}
// faq []
// manual [ 'Elided lifetimes follow three rules.' ]

// The same grouping through a SearchRequest.
const req = new SearchRequest({ limit: 10, groupBy: { field: "_id", groupSize: 2 } });
req.setLexicalTerm(new TermQuery("body", "lifetimes"));
const grouped = 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
  highlights: Record<string, string[]>; // Highlighted fragments per requested field
  groupKey?: any;    // With groupBy, the group's value
  innerHits: SearchResult[]; // With groupBy, the group's next best hits
}

highlights maps each field named in highlight.fields to its highlighted fragments (best first); a field that did not highlight is absent from the object, and highlights is {} when highlight was not requested. See Highlighting.

With groupBy, groupKey is the value the result’s group shares (a DateTime value as an RFC 3339 string; absent for a hit without a value) and innerHits holds the group’s next best hits in ranking order, each with the same groupKey and no inner hits of its own. An ungrouped search leaves groupKey absent and innerHits empty. See Result grouping.


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;
  tokenType?: string;
}

startOffset and endOffset are UTF-8 byte offsets into the original text. For non-ASCII text they are not JavaScript string (UTF-16) indices; slice the encoded text instead: new TextDecoder().decode(new TextEncoder().encode(text).slice(tok.startOffset, tok.endOffset)).

tokenType is one of "alphanum", "num", "cjk", "katakana", "hiragana", "hangul", "punctuation", "whitespace", "synonym", "email", "url" and "other". SynonymGraphFilter.apply keeps each token’s offsets and type, and gives each synonym it inserts the type "synonym" and the offsets of the words it replaces. It throws on any other tokenType.

A token built by hand may leave tokenType out. A multi-word synonym then matches only if the words’ offsets touch, so set tokenType: "alphanum" on words separated by spaces.


Field value types

JavaScript values are automatically converted to Laurus DataValue types:

JavaScript typeLaurus typeNotes
nullNull
booleanBool
number (integer)Int64
number (float)Float64
stringTextISO 8601 strings become DateTime
number[] (all integers)Int64ArrayMulti-valued integer field; vector fields cast the array to f32. An empty array is an empty Int64Array
number[]Float64ArrayMulti-valued float field (integers widened); vector fields cast the array to f32
{ lat, lon }GeoTwo number values
{ x, y, z }GeoEcefThree number values, meters (3D ECEF Cartesian)
{ lat, lon }[]GeoArrayArray of { lat, lon } objects; requires multiValued: true on the field
{ x, y, z }[]GeoEcefArrayArray of { x, y, z } objects; requires multiValued: true on the field
number[][]VectorArrayToken vectors of a multi-vector field (addMultiVectorField): 1 to 8,192 arrays of the field’s dimension. All inner arrays must have the same length, otherwise ingestion rejects with token vectors must share one dimension. A field with a token-level embedder also accepts a string, which is embedded into token vectors. Not stored, so absent from getDocuments and search results
string[] (all RFC 3339)DateTimeArrayArray of RFC 3339 datetime strings (same infer_from_json rules as the HTTP gateway); requires multiValued: true on the field
string[] (not all RFC 3339)TextArrayArray of strings (Issue #1175); requires multiValued: true on the field. Read back as string[]. On a declared multi-valued Bytes field, the same array of base64 strings is instead decoded element-wise into BytesArray by the schema-aware coercion (Issue #1176)
boolean[]BoolArrayArray of booleans (same infer_from_json rules as the HTTP gateway); requires multiValued: true on the field. A mixed array such as [true, 1] is rejected

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.node requirement in package.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)
  • Late-Interaction Rescore – Reorder the top results by ColBERT-style MaxSim over per-token vectors in a multi-vector field
  • 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.ts type 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, Candle ColBERT, 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:

EmbedderDependencyWhy it cannot run in WASM
candle_bertcandle (GPU/SIMD)Requires native SIMD intrinsics and file system for models
candle_clipcandleSame as above
candle_colbertcandleSame as above
openaireqwest (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 three addEmbedder types instead:

  • "precomputed" — The caller supplies vectors directly via putDocument() and searchVector(). The engine performs no embedding.
  • "callback" — Register a JavaScript callback embed: (text) => Promise<number[]> and the engine will invoke it during ingestion and from searchVectorText(). This enables in-engine auto-embedding using Transformers.js (or any other in-browser embedding library) so callers can use the same searchVectorText("field", "query text") pattern as on native platforms.
  • "token_callback" — Register a JavaScript callback embed: (text, role) => number[][] | Promise<number[][]> plus its token vector dimension for a multi-vector field. The engine invokes it for text values of the field and for a rescore’s query text, in place of the native candle_colbert embedder. See Option C.

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, 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.

Option C — Token callback embedder (late-interaction rescore)

A late-interaction model such as ColBERT represents a text as one vector per token. Keep those token vectors in a multi-vector field and use them to rescore the top results of any search — lexical, vector or hybrid — by MaxSim (Issue #1351); see Late-Interaction Rescore.

Register the model as a "token_callback" embedder. The callback receives the text and a role ("query" or "document", since late-interaction models encode the two differently) and returns one dimension-long vector per token, as a number[][] or a Promise of one:

// myColbert wraps a ColBERT model that runs in the browser (e.g. via ONNX Runtime Web).
schema.addEmbedder("colbert", {
  type: "token_callback",
  embed: async (text, role) => myColbert.encode(text, role), // number[][]
  dimension: 128,
});
schema.addTextField("body");
schema.addMultiVectorField("body_colbert", 128, "cosine", "colbert");
const index = await Index.create(schema);

// The callback embeds the text value with role "document".
const body = "Lifetimes let the Rust compiler check that references stay valid";
await index.putDocument("doc1", { body, body_colbert: body });
await index.commit();

// Rescore the top lexical hits; the callback embeds the query text with role "query".
const results = await index.search("body:rust", 10, 0, undefined, {
  field: "body_colbert",
  text: "how do lifetimes work",
});

Without an embedder, pass precomputed token vectors instead: nested arrays (body_colbert: [[0.1, ...], [0.3, ...]]) when indexing, and { field: "body_colbert", vectors: [[...], ...] } when searching. The token vectors are never returned by getDocuments() or in search results. Besides search(), searchVector() and searchVectorText() take the same trailing rescore argument; see the API Reference.

When to Use laurus-wasm vs laurus-nodejs

Criterionlaurus-wasmlaurus-nodejs
EnvironmentBrowser, Edge RuntimeNode.js server
PerformanceGood (single-threaded)Best (native, multi-threaded)
StorageIn-memory + OPFSIn-memory + File system
EmbeddingPrecomputed + JS callbackCandle, OpenAI, Precomputed
Packagenpm install laurus-wasmnpm 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:

BrowserMinimum Version
Chrome102+
Firefox111+
Safari15.2+
Edge102+

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).
// The first time, the schema is persisted alongside the data.
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 the next page load, Index.open("my-index") -- with no schema
// argument -- restores both the data and the persisted schema.
const reopened = await Index.open("my-index");

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,
  getDictionaryVersion,
  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.)
//    The binary format is tied to the Lindera version compiled into
//    the WASM, so stamp the cache with `version` and re-download when
//    it no longer matches the version your build expects.
const LINDERA_VERSION = "5.0.2"; // keep in sync with your Cargo.lock
if (
  !(await hasDictionary("ipadic"))
  || (await getDictionaryVersion("ipadic")) !== LINDERA_VERSION
) {
  await downloadDictionary("./dict/lindera-ipadic.zip", "ipadic", {
    version: LINDERA_VERSION,
    onProgress: ({ phase, loaded, total }) => console.log(phase, loaded, total),
  });
}

// 2. Read the nine component files back and construct the analyzer.
const f = await loadDictionaryFiles("ipadic");
const ja = JapaneseAnalyzer.fromBytes(
  f.metadata, f.dictTrie, f.dictValsIdx, f.dictVals,
  f.dictWordsIdx, f.dictWords, f.matrixMtx, f.charDef, f.unk,
  "normal",
);

// 3. 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, undefined, "ja-ipadic");
schema.addTextField("body", undefined, undefined, undefined, undefined, "ja-ipadic");
schema.setDefaultFields(["title", "body"]);

const index = await Index.create(schema);

await index.putDocument("doc1", {
  title: "形態素解析",
  body: "Lindera は Rust 製の形態素解析ライブラリです。",
});
await index.commit();

const results = await index.search("形態素");
console.log(results[0].document.title); // "形態素解析"

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"

Late-Interaction Rescore

Keep per-token vectors (for example from a ColBERT model) in a multi-vector field, and use them to reorder the top results of a search by MaxSim. Here the token vectors are precomputed and passed as nested arrays:

import init, { Index, Schema } from 'laurus-wasm';

await init();

const schema = new Schema();
schema.addTextField("title");
schema.addMultiVectorField("tokens", 2, "dot_product"); // 2-dimensional token vectors

const index = await Index.create(schema);

await index.putDocument("doc1", {
  title: "Rust",
  tokens: [[0.1, 0.0]]
});
await index.putDocument("doc2", {
  title: "The Rust language",
  tokens: [[0.9, 0.2]]
});
await index.commit();

// Rescore the top lexical hits with the query's token vectors
const results = await index.search("title:rust", 10, 0, undefined, {
  field: "tokens",
  vectors: [[1, 0], [0, 1]]
});
console.log(results[0].id, results[0].score); // "doc2" ~1.1 (MaxSim: 0.9 + 0.2)

A rescored result’s score is its MaxSim. The token vectors are not returned in results[i].document. To index and query with text instead, register a "token_callback" embedder for the field; see the API Reference.

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

Module Functions

version()

Return the laurus-wasm build version string (e.g. "0.12.1"). Applications persisting laurus state in OPFS can stamp it with this value to detect state written by a different build whose on-disk format may have changed (the demo samples do exactly this).

Index

The main entry point for creating and querying search indexes.

Static Methods

Index.create(schema?, walSyncPolicy?, commitPolicy?)

Create a new in-memory (ephemeral) index.

  • Parameters:
    • schema (Schema, optional) – Schema definition. An empty schema is used when omitted.
    • walSyncPolicy (WalSyncPolicy, optional) – WAL durability policy. Omit to keep the default per-record sync. See WAL sync policy / durability.
    • commitPolicy (CommitPolicy, optional) – Auto-commit policy. Omit to keep the default (manual; caller-driven commits). See Commit policy / auto-commit.
  • Returns: Promise<Index>

Index.open(name, schema?, walSyncPolicy?, commitPolicy?)

Open or create a persistent index backed by OPFS.

  • Parameters:
    • name (string) – Index name (OPFS subdirectory).
    • schema (Schema, optional) – Schema definition, plus any embedder callbacks / runtime analyzers this session needs. Required the first time an index is created, or when opening one persisted before schema tracking was added (see below); optional afterwards, since the field-schema part is persisted alongside the index data and reloaded automatically. If schema is still passed once a schema is already persisted, its field definitions are ignored in favor of the persisted ones – only its embedder callbacks / runtime analyzers are used, since those can never be persisted and must be re-supplied every time a session needs them.
    • walSyncPolicy (WalSyncPolicy, optional) – WAL durability policy. Omit to keep the default per-record sync. See WAL sync policy / durability.
    • commitPolicy (CommitPolicy, optional) – Auto-commit policy. Omit to keep the default (manual; caller-driven commits). See Commit policy / auto-commit.
  • Returns: Promise<Index>
  • Throws: if this OPFS index already has data persisted from before schema tracking was added, and schema was not supplied to complete the one-time migration (the schema is then persisted for future opens).

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.

putDocuments(docs)

Batched upsert. Applies the pairs in order with one WAL fsync for the whole batch; duplicate ids within one batch dedup (last occurrence wins). Fails fast at the first bad entry, and the applied prefix is not rolled back (retrying is idempotent).

  • Parameters:
    • docs (Array<[string, object]>) – An array of [id, document] pairs.
  • Returns: Promise<void>

addDocuments(docs)

Batched chunk append. Like putDocuments but repeated ids accumulate as separate versions.

  • Parameters / Returns: Same as putDocuments.

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>

flushWal()

Force a durable WAL barrier on the in-memory engine WAL. See WAL sync policy / durability for the wasm caveats — notably, this does not persist to OPFS; call commit() for durable persistence.

  • Returns: Promise<void>

search(query, limit?, offset?, highlight?, rescore?, groupBy?)

Search using a DSL string query.

  • Parameters:
    • query (string) – Query DSL (e.g. "title:hello").
    • limit (number, default 10)
    • offset (number, default 0)
    • highlight (HighlightOptions, optional) – Request highlighted fragments per field (Issue #1134). See Highlighting below. Pass undefined to skip it when you only need rescore or groupBy.
    • rescore (RescoreOptions, optional) – Reorder the top results by late interaction against a multi-vector field (Issue #1351). See Late-interaction rescore below.
    • groupBy (GroupByOptions, optional) – Group the results by a field’s value (Issue #1297). See Result grouping below.
  • Returns: Promise<SearchResult[]>

searchTerm(field, term, limit?, offset?, highlight?)

Search for an exact term.

  • Parameters:
    • field (string) – Field name.
    • term (string) – Exact term.
    • limit, offset (number, optional)
    • highlight (HighlightOptions, optional) – Same as search’s highlight argument.
  • Returns: Promise<SearchResult[]>

searchDateTimeRange(field, min?, max?, limit?, offset?, highlight?)

Search a DateTime field for values within an inclusive range (Issue #1179). Query classes are not exposed to JS, so this is the object-free counterpart of a DSL range such as created_at:[2024-01-01 TO 2024-12-31], which search() also accepts.

  • Parameters:
    • field (string) – DateTime field name.
    • min, max (string, optional) – Inclusive bounds; omit (or pass null/undefined) to leave a side open. Any DSL datetime literal: RFC 3339 ("2024-01-01T09:00:00+09:00", normalized to UTC), naive "YYYY-MM-DDTHH:MM:SS[.fff]" (UTC), or "YYYY-MM-DD" (midnight UTC). Pass date.toISOString() for a Date.
    • limit, offset (number, optional)
    • highlight (HighlightOptions, optional) – Same as search’s highlight argument.
  • Returns: Promise<SearchResult[]>
  • Throws: rejects with an error when a bound is not a recognized datetime literal.

Highlighting

search, searchTerm and searchDateTimeRange accept an optional highlight argument — a plain object shaped like:

interface HighlightOptions {
  fields: string[];
  fragmentSize?: number;
  maxFragments?: number;
  tag?: string;
  cssClass?: string;
  requireFieldMatch?: boolean;
}

Only fields is required; everything else falls back to the engine’s default HighlightConfig (tag "mark", up to 5 fragments of ~150 characters, requireFieldMatch: true). Highlighting follows the query passed to search/searchTerm, and only stored: true text fields can be highlighted — a field that isn’t stored, isn’t a text field, or had no match is simply absent from the result’s highlights object. Omitting highlight (or passing undefined) leaves every result’s highlights empty.

const results = await index.search("body:rust", 10, 0, {
  fields: ["body"],
  tag: "em",
});
// results[0].highlights => { body: ["<em>Rust</em> is a systems programming language"] }

searchVector(field, vector, limit?, offset?, rescore?, groupBy?)

Search by vector similarity.

  • Parameters:
    • field (string) – Vector field name.
    • vector (number[]) – Query embedding.
    • limit, offset (number, optional)
    • rescore (RescoreOptions, optional) – Same as search’s rescore argument.
    • groupBy (GroupByOptions, optional) – Same as search’s groupBy argument.
  • Returns: Promise<SearchResult[]>

searchVectorText(field, text, limit?, offset?, rescore?, groupBy?)

Search by text (embedded by the registered embedder).

  • Parameters:
    • field (string) – Vector field name.
    • text (string) – Text to embed.
    • limit, offset (number, optional)
    • rescore (RescoreOptions, optional) – Same as search’s rescore argument.
    • groupBy (GroupByOptions, optional) – Same as search’s groupBy argument.
  • Returns: Promise<SearchResult[]>

Late-interaction rescore

search, searchVector and searchVectorText accept an optional trailing rescore argument (Issue #1351). It reorders the top first-stage results — lexical, vector or hybrid — by ColBERT-style late interaction (MaxSim) against the token vectors of a multi-vector field. The other search methods have no rescore argument. The argument is a plain object shaped like:

interface RescoreOptions {
  field: string;
  vectors?: number[][];
  text?: string;
  windowSize?: number;
}
KeyTypeDefaultDescription
fieldstring–The multi-vector field to score against.
vectorsnumber[][]–The query’s token vectors, computed with the model that produced the documents’ token vectors.
textstring–The query text, embedded with role "query" by the field’s "token_callback" embedder.
windowSizenumber100How many top first-stage results to rescore, 1–10,000.

Set exactly one of vectors and text; otherwise the call throws Invalid rescore options: set exactly one of vectors or text. A value of the wrong type (for example a non-numeric element in vectors) also throws Invalid rescore options: ..., while unknown keys are ignored. The engine checks the other values when the search runs and throws an error containing rescore: ... for a bad one — for example text on a field without a token-level embedder, a field that is not a multi-vector field, a windowSize out of range, or query vectors of the wrong dimension.

The top windowSize results are sorted by MaxSim, and a rescored result’s score is its MaxSim. Results beyond the window (and results in it that have no token vectors in the field) keep their first-stage order and score, after the rescored ones. See Vector Search → Late-Interaction Rescore for the scoring, ordering and pagination details.

// Rescore the top lexical hits with precomputed query token vectors.
const results = await index.search("title:rust", 10, 0, undefined, {
  field: "tokens",
  vectors: [[1, 0], [0, 1]],
});

// Or let the field's token_callback embedder embed the query text.
const reranked = await index.searchVectorText("embedding", "how do lifetimes work", 10, 0, {
  field: "body_colbert",
  text: "how do lifetimes work",
  windowSize: 50,
});

Result grouping

search, searchVector and searchVectorText accept an optional trailing groupBy argument (Issue #1297). It groups the results by a field’s value, so that the chunks of one document do not fill the page. Grouping runs on the final ranking, after fusion and rescore, for lexical, vector and hybrid searches alike. The other search methods have no groupBy argument. The argument is a plain object shaped like:

interface GroupByOptions {
  field: string;
  groupSize?: number;
}
KeyTypeDefaultDescription
fieldstring–_id (chunks added with addDocument share their document’s _id), or a single-valued, stored Text, Integer, Float, Boolean or DateTime field with docValues enabled (the defaults).
groupSizenumber1How many hits each group keeps, the returned result included, 1–100.

Each returned result is then the best hit of a group: its groupKey is the value the group shares and its innerHits hold up to groupSize - 1 further hits of the group, in ranking order (see SearchResult). limit and offset count groups. See Result Grouping for the ordering, the candidate pool and why a group can hold fewer than groupSize hits.

An object without field, or with a value of the wrong type, throws Invalid groupBy options: ..., while unknown keys are ignored. The engine checks the values when the search runs and throws an error containing group_by: ... when field is unknown, of another type, multi-valued, not stored or without docValues, when groupSize is out of range, or when offset + limit exceeds 10,000.

// Chunks added under one id are grouped by that id.
await index.addDocument("manual", { body: "Lifetimes tie a reference to its owner." });
await index.addDocument("manual", { body: "Elided lifetimes follow three rules." });
await index.addDocument("faq", { body: "Why do lifetimes appear in signatures?" });
await index.commit();

const results = await index.search("body:lifetimes", 10, 0, undefined, undefined, {
  field: "_id",
  groupSize: 2,
});
results.map((r) => [r.groupKey, r.innerHits.map((hit) => hit.document.body)]);
// => [["faq", []], ["manual", ["Elided lifetimes follow three rules."]]]

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 } } }

WAL sync policy / durability

Each write is appended to the engine’s in-memory write-ahead log (WAL). Index.create and Index.open accept an optional walSyncPolicy that controls how often that WAL is flushed. The default (omit the argument) is per-record sync.

class WalSyncPolicy {
  static perRecord(): WalSyncPolicy;
  static group(
    maxRecords?: number,
    maxBytes?: number,
    maxIntervalMs?: number,
  ): WalSyncPolicy;
}
ConstructorDescription
WalSyncPolicy.perRecord()Default. Flush after every WAL record.
WalSyncPolicy.group(...)Group commit. Batch the flush across writes.

group(...) parameters (omit any argument to keep its default):

ParameterDefaultDescription
maxRecords1024Flush once this many records have accumulated.
maxBytes1048576 (1 MiB)Flush once this many unsynced bytes have accumulated.
maxIntervalMsnonePeriodic flush timer (milliseconds). No-op on wasm (see caveats).

With group commit the engine WAL is flushed when either maxRecords or maxBytes is reached, and always at commit(). A crash can lose up to the last unsynced batch — the same trade-off as SQLite’s synchronous = NORMAL.

flushWal() (durable barrier)

flushWal() forces a flush of the in-memory engine WAL on demand.

  • Returns: Promise<void>

WASM caveats

WebAssembly has no background threads or direct filesystem, so two behaviours differ from the native bindings:

  • maxIntervalMs is a no-op. The periodic flush timer requires a background thread, which is unavailable on wasm. Group commit still flushes on the maxRecords / maxBytes thresholds and at commit().
  • flushWal() flushes the in-memory engine WAL only. OPFS persistence still happens at commit(). For durable persistence on wasm, call commit().
import { Index, Schema, WalSyncPolicy } from "./pkg/laurus_wasm.js";

const schema = new Schema();
schema.addTextField("title");

// Opt into group commit. maxIntervalMs is accepted but ignored on wasm.
const policy = WalSyncPolicy.group(4096, undefined, 1000);
const index = await Index.open("my-index", schema, policy);

for (let i = 0; i < 10000; i++) {
  await index.putDocument(`doc${i}`, { title: `Document ${i}` });
}

await index.flushWal(); // flushes the engine WAL (not OPFS)
await index.commit();   // makes changes searchable AND persists to OPFS

Commit policy / auto-commit

A commit materializes buffered writes into the searchable stores. Index.create and Index.open accept an optional commitPolicy that controls whether the engine commits on your behalf. The default (omit the argument) is manual: you drive every commit() yourself.

class CommitPolicy {
  static manual(): CommitPolicy;
  static everyDocs(n: number): CommitPolicy;
  static intervalMs(ms: number): CommitPolicy;
}
ConstructorDescription
CommitPolicy.manual()Default. No auto-commit; the caller drives every commit().
CommitPolicy.everyDocs(n)Auto-commit after every n applied documents.
CommitPolicy.intervalMs(ms)Auto-commit at least every ms milliseconds via a background timer (default: none). Native only — no-op on wasm.

With everyDocs(n) the engine commits once every n applied documents. The counter spans both singular and batch ingest, and it also fires within a batch — a putDocuments call larger than n triggers one or more commits mid batch. everyDocs(0) is valid and disables auto-commit, which is equivalent to CommitPolicy.manual().

intervalMs(ms) is the time-based counterpart of everyDocs: a background timer commits at least every ms milliseconds, so a trailing partial batch is committed even while ingestion is idle. It is native only — see the WASM note below.

commitPolicy is orthogonal to walSyncPolicy: walSyncPolicy governs how often the WAL is fsynced for durability, while commitPolicy governs when the stores materialize buffered writes into searchable state. They are configured independently.

WASM note

Unlike walSyncPolicy’s maxIntervalMs background timer (a no-op on wasm), everyDocs needs no background thread — the document counter is checked inline during ingestion — so auto-commit works fully under WebAssembly.

intervalMs, by contrast, relies on a background timer just like walSyncPolicy’s maxIntervalMs, and wasm has no background threads. The factory still constructs a value so portable policy code keeps compiling, but the timer never runs under WebAssembly and intervalMs has no effect on wasm — no timed commit ever fires. Use everyDocs for auto-commit on wasm.

import { Index, Schema, CommitPolicy } from "./pkg/laurus_wasm.js";

const schema = new Schema();
schema.addTextField("title");

// Auto-commit after every 1000 applied documents.
const index = await Index.open(
  "my-index",
  schema,
  undefined,
  CommitPolicy.everyDocs(1000),
);

for (let i = 0; i < 10000; i++) {
  await index.putDocument(`doc${i}`, { title: `Document ${i}` });
}
// The engine has auto-committed 10 times; no explicit commit() required.

Schema

Builder for defining index fields and embedders.

Constructor

new Schema()

Create an empty schema.

Methods

addTextField(name, stored?, indexed?, termVectors?, docValues?, analyzer?, multiValued?, positionIncrementGap?)

Add a full-text field. docValues controls whether the value is also copied into DocValues, the column-oriented store sort/facet/aggregation read from (Issue #1047, default true); takes effect only when stored is also true. 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(). Pass multiValued: true to accept arrays of strings (Issue #1175); a term query then matches if any element contains the term, and a phrase query never spans two elements unless its slop reaches positionIncrementGap (default 100; 0 numbers the elements as if concatenated). Values are read back as an array of strings.

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?, docValues?)

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). See docValues above.

addFloatField(name, stored?, indexed?, multiValued?, docValues?)

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). See docValues above.

addBooleanField(name, stored?, indexed?, multiValued?, docValues?)

Add a boolean field. Pass multiValued: true to accept arrays of booleans; a term query such as flags:true then matches if any element equals the queried value (Lucene-style “any match” — each element is its own term posting, so repeated elements raise the term frequency, not the hit count). Values are read back as an array of booleans. See docValues above.

addDatetimeField(name, stored?, indexed?, multiValued?, docValues?)

Add a date/time field. Pass multiValued: true to accept arrays of RFC 3339 strings; range queries (searchDateTimeRange and DSL date ranges) then match if any instant satisfies the predicate (Lucene-style “any match” with constant scoring). Values are read back as an array of RFC 3339 strings normalized to UTC. See docValues above.

addGeoField(name, stored?, indexed?, multiValued?, docValues?)

Add a geographic coordinate field. Pass multiValued: true to accept arrays of { lat, lon } objects; distance and bounding-box queries then match if any point satisfies the predicate (Lucene-style “any match”), scoring the document by its closest point. See docValues above.

addGeo3dField(name, stored?, indexed?, multiValued?, docValues?)

Add a 3D ECEF Cartesian point field. Values are submitted as a { x, y, z } object with metres units. Pass multiValued: true to accept arrays of { x, y, z } objects; the 3D queries then match if any point satisfies the predicate (Lucene-style “any match”), scoring the document by its closest point. See Geo3d concepts for ECEF theory, and docValues above.

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?, multiValued?)

Add a binary data field. No docValues option: a Bytes value is never written to DocValues regardless. Pass multiValued: true to accept an array of base64 strings (Issue #1176), decoded element-wise by the schema-aware coercion the same way a single base64 string is; Bytes is never indexed, so unlike every other multiValued option this has no query-matching semantics — it only governs the stored shape and ingestion arity. Values are read back as an array of byte-integer arrays with MIME dropped, the same input/output asymmetry the scalar field already has.

addHnswField(name, dimension, distance?, m?, efConstruction?, defaultEfSearch?, embedder?, quantizer?, subvectorCount?, rerankStorage?, pqCodebookPath?, baseWeight?)

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)
  • defaultEfSearch: Schema-level default for the query-time ef_search candidate-list size; omit to use the internal fallback of 50
  • quantizer: "scalar_8bit" (default) or "product_quantization" (requires subvectorCount)
  • subvectorCount: number of PQ sub-vectors; must divide dimension
  • rerankStorage: omit (default) or "f32" to store a full-precision rerank sidecar
  • pqCodebookPath: omit (default) or the storage-relative file name of a shared PQ codebook (Issue #631) to reuse across segments instead of per-segment training
  • baseWeight: this field’s relative scoring priority when searched alongside other vector fields (default 1.0, Issue #1084); see Vector Search → Weights

addFlatField(name, dimension, distance?, embedder?, baseWeight?)

Add a brute-force vector index field.

addIvfField(name, dimension, distance?, nClusters?, nProbe?, embedder?, baseWeight?)

Add an IVF vector index field.

  • nClusters: Number of partitioning clusters (default 100)
  • nProbe: Number of clusters to probe at query time (default 1)

Vector quantization & rerank storage (HNSW fields):

  • quantizer — "scalar_8bit" (default, 4× compression) or "product_quantization" for higher compression. Product quantization requires subvectorCount (must divide dimension).
  • rerankStorage — set to "f32" to write a full-precision *.hnsw.f32 sidecar enabling exact Stage-2 rerank; omit to keep the int8-only segment.
  • pqCodebookPath — storage-relative file name of a shared PQ codebook (Issue #631), trained once via the laurus train pq-codebook CLI command. Only meaningful with quantizer: "product_quantization"; commits then encode against the pre-trained codebook instead of re-training k-means per segment. Omit to keep per-segment training.

addMultiVectorField(name, dimension, distance?, embedder?, storage?, poolFactor?)

Add a multi-vector field (Issue #1351): a variable number of token vectors per document, such as the per-token embeddings of a ColBERT-style model. It has no ANN index and is never a search target; only the late-interaction rescore reads it. See Schema & Fields → Multi-Vector Fields.

  • dimension: length of every token vector; must be greater than 0
  • distance: "cosine" (default; vectors are L2-normalized when written) or "dot_product"
  • embedder: optional name of a "token_callback" embedder registered via addEmbedder, which embeds text values and rescore query text
  • storage: on-disk element kind of the token vectors (Issue #1346) — "f32" (default, exact), "f16" (2x smaller), "int8" (~4x smaller), or "binary" (32x smaller, 1 bit per element; requires "cosine")
  • poolFactor: token pooling (Issue #1346) — each document keeps about 1 / poolFactor of its token vectors by averaging similar ones at index time (default 1, all; 0 throws)

The options are validated when the field is added: a dimension of 0, any other distance, or an unrecognized storage throws.

A value is a nested number array with one array per token (tokens: [[0.1, 0.2], [0.3, 0.4]]), holding 1 to 8,192 vectors of the field’s dimension. With a "token_callback" embedder on the field, a string value works too: the callback embeds it with role "document" when the document is indexed. The token vectors are not stored, so getDocuments and search results never include the field.

schema.addMultiVectorField("tokens", 2, "dot_product");
// ...
await index.putDocument("doc1", { title: "Rust", tokens: [[0.9, 0.2], [0.1, 0.8]] });

Every add*Field method above throws when name starts with _ (other than _id), and adds nothing. A schema loaded with fromToml keeps accepting such a field, so a persisted schema still loads; creating a new Index from it throws. See Field Naming.

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.dictTrie, f.dictValsIdx, f.dictVals,
  f.dictWordsIdx, f.dictWords, f.matrixMtx, f.charDef, f.unk, "normal",
);

const schema = new Schema();
schema.addAnalyzer("ja-ipadic", ja);
schema.addTextField("body", undefined, undefined, undefined, undefined, "ja-ipadic");

addEmbedder(name, config)

Register a named embedder. WASM supports three type values:

  • "precomputed" — No embedding is performed; vectors are passed directly via putDocument() / searchVector().
  • "callback" — Provide a JavaScript callback embed: (text) => Promise<number[]> that the engine will invoke during ingestion and searchVectorText(). This enables in-engine auto-embedding using Transformers.js or any other in-browser embedding library.
  • "token_callback" — Provide a JavaScript callback embed: (text, role) => number[][] | Promise<number[][]> and a dimension (positive integer) for a multi-vector field (Issue #1351). role is "query" or "document", because late-interaction models encode the two differently. The callback returns one dimension-long vector per token, either directly or as a Promise. The engine calls it with role "document" for a text value of the field and with role "query" for a rescore’s text. dimension is checked against the multi-vector field when the index is created or opened: on a mismatch Index.create / Index.open throws (... produces N-dimensional token vectors). A non-numeric element in the returned arrays is an error, not a silent 0.

Both callback types are recorded as "precomputed" in the schema TOML (toToml() and the schema persisted by Index.open), because a function cannot be serialized. Register the callbacks again in the schema passed to Index.open whenever a session needs them.

"token_callback" takes the place of the native candle_colbert embedder, which WASM does not have (see Embedding Strategies).

// 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);
  },
});

// Token callback embedder for a multi-vector field (e.g. a browser ColBERT model)
schema.addEmbedder("colbert", {
  type: "token_callback",
  embed: async (text, role) => myColbert.encode(text, role), // number[][]
  dimension: 128,
});
schema.addMultiVectorField("body_colbert", 128, "cosine", "colbert");

addAnalyzerDefinition(name, definition)

Register a custom analyzer definition, composed of a required tokenizer plus optional char/token filter chains. This is a distinct concept from addAnalyzer above: that method registers a pre-built runtime analyzer object (currently only JapaneseAnalyzer), while this one declares an analyzer from serializable JSON-shaped components — the same format laurus-cli create index --schema and the other language bindings use.

definition.tokenizer is required; definition.charFilters and definition.tokenFilters are optional arrays. Each component uses the same { type: "...", ... } shape as the schema TOML/JSON format (see below) — keys inside a component stay snake_case, matching that wire format. Only the two outer wrapper keys (charFilters/tokenFilters) follow this binding’s own camelCase convention.

A name reserved for a built-in analyzer (standard, keyword, english, simple, noop) throws, and so does Index.create or a first Index.open with a schema that defines one (e.g. loaded with fromToml). addAnalyzer is not affected: a runtime analyzer is resolved before the built-ins, so registering one under a built-in name does take effect.

const schema = new Schema();
schema.addAnalyzerDefinition("ngram3", {
  tokenizer: { type: "ngram", min_gram: 3, max_gram: 3 },
});
schema.addTextField("title", undefined, undefined, undefined, undefined, "ngram3");

analyzerNames()

Returns the names of custom analyzers registered via addAnalyzerDefinition or loaded from TOML.

Schema.fromToml(tomlStr) (static)

Parse a schema from a TOML string, in the same format laurus-cli create index --schema accepts. No file-path variant is provided: the browser WASM target has no filesystem.

toToml()

Serialize this schema to a TOML string in the same format laurus-cli accepts.

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=[...])").

Analyzer components

Used by addAnalyzerDefinition(name, definition) and by the [analyzers.<name>] TOML section. definition.tokenizer is a single object; definition.charFilters/definition.tokenFilters are arrays of objects, applied in array order.

See Schema Format Reference → Analyzers for the canonical description of each component.

Tokenizers (tokenizer, exactly one):

typeRequired keysOptional keys
"whitespace"––
"unicode_word"––
"regex"–pattern (default \w+), gaps (default false)
"ngram"min_gram, max_gram–
"lindera"mode, dictuser_dict
"whole"––

Char filters (charFilters, applied to raw text before tokenization):

typeRequired keysOptional keys
"unicode_normalization"form ("nfc"/"nfd"/"nfkc"/"nfkd")–
"pattern_replace"pattern, replacement–
"mapping"mapping (object of string replacements)–
"japanese_iteration_mark"–kanji (default true), kana (default true)

Token filters (tokenFilters, applied to the token stream after tokenization):

typeRequired keysOptional keys
"lowercase"––
"stop"–words (default: English stop words)
"stem"–stem_type ("porter"/"simple"/"identity")
"boost"boost–
"limit"limit–
"strip"––
"remove_empty"––
"flatten_graph"––

Note that the "lindera" tokenizer here is a distinct path from the Japanese analyzer built via JapaneseAnalyzer.fromBytes (above): a lindera tokenizer definition sets dict to a filesystem path, which cannot resolve in a browser. It is only useful when laurus-wasm runs in a non-browser WASM host with a real filesystem; browser callers should continue to use JapaneseAnalyzer.fromBytes + addAnalyzer.

SearchResult

interface SearchResult {
  id: string;
  score: number;
  document: object | null;
  highlights: Record<string, string[]>;
  groupKey: string | number | boolean | null;
  innerHits: SearchResult[];
}

highlights maps each field named in the request’s highlight.fields to its highlighted fragments (best first); a field that did not highlight is absent from the object, and highlights is {} when highlight was not requested. See Highlighting.

With groupBy, groupKey is the value the result’s group shares (a DateTime value as an RFC 3339 string; null for a hit without a value) and innerHits holds the group’s next best hits in ranking order, each with the same groupKey and no inner hits of its own. An ungrouped search returns groupKey: null and an empty innerHits. See Result grouping.

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 nine component byte arrays to JapaneseAnalyzer.fromBytes.

JapaneseAnalyzer.fromBytes(metadata, dictTrie, ..., mode?)

Static factory that builds an analyzer from raw IPADIC bytes.

Arguments (all Uint8Array except mode):

ArgumentSource file
metadatametadata.json
dictTriedict.trie (prefix trie)
dictValsIdxdict.valsidx
dictValsdict.vals
dictWordsIdxdict.wordsidx
dictWordsdict.words
matrixMtxmatrix.mtx
charDefchar_def.bin
unkunk.bin
mode"normal" (default) 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.dictTrie, f.dictValsIdx, 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,
  getDictionaryVersion,
  loadDictionaryFiles,
  hasDictionary,
  listDictionaries,
  removeDictionary,
} from "laurus-wasm/opfs";
FunctionDescription
downloadDictionary(url, name, options?)Fetch a .zip, decompress with the Web DecompressionStream API, and store the nine Lindera files under laurus/dictionaries/<name>/ in OPFS. options.onProgress({ phase, loaded?, total? }) reports progress. options.version stores a version stamp next to the files (see below).
getDictionaryVersion(name)Return the version stamp stored by downloadDictionary, or null if the dictionary or its stamp does not exist.
loadDictionaryFiles(name)Read the nine files back as a { metadata, dictTrie, dictValsIdx, 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.

The binary dictionary format is tied to the Lindera (and daachorse) version compiled into the WASM binary, so a dictionary cached in OPFS becomes unreadable after your app updates its Lindera version (deserialization fails with an InvalidAutomatonError). Pass the Lindera version your zip was built for as options.version when downloading, then compare getDictionaryVersion(name) against the version your current build expects on startup and re-download on mismatch. A null stamp should be treated as a mismatch.

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, ... }]
// Each token: { text, position, startOffset, endOffset, boost, stopped,
//               positionIncrement, positionLength, tokenType }

startOffset and endOffset are UTF-8 byte offsets into the original text. For non-ASCII text they are not JavaScript string (UTF-16) indices; slice the encoded text instead: new TextDecoder().decode(new TextEncoder().encode(text).slice(tok.startOffset, tok.endOffset)).

tokenType is one of "alphanum", "num", "cjk", "katakana", "hiragana", "hangul", "punctuation", "whitespace", "synonym", "email", "url" and "other".

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, default true) — Keep the original token alongside the inserted synonyms.
  • boost (number, default 1.0) — Score boost applied to inserted synonym tokens.
const filter = new SynonymGraphFilter(dict, true, 0.8);
const expanded = filter.apply(tokens);

apply keeps each token’s offsets and type, and gives each synonym it inserts the type "synonym" and the offsets of the words it replaces. It throws on an unknown tokenType. A token built by hand may leave tokenType out; a multi-word synonym then matches only if the words’ offsets touch, so set tokenType: "alphanum" on words separated by spaces.

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:

  1. MemoryStorage (runtime) – All read/write operations go through Laurus’s in-memory storage, which satisfies the Storage trait’s Send + Sync requirement.

  2. OPFS (persistence) – On commit(), the entire MemoryStorage state is serialized to OPFS files. On Index.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 nine 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.dictTrie, f.dictValsIdx, f.dictVals,
  f.dictWordsIdx, f.dictWords, f.matrixMtx, f.charDef, f.unk, "normal",
);

const schema = new Schema();
schema.addAnalyzer("ja-ipadic", ja);
schema.addTextField("body", undefined, undefined, undefined, undefined, "ja-ipadic");

The OPFS helpers (downloadDictionary, getDictionaryVersion, 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, 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.

A "token_callback" embedder ({ type: "token_callback", embed: (text, role) => number[][] | Promise<number[][]>, dimension }) backs a multi-vector field and the late-interaction rescore instead. JsTokenCallbackEmbedder (src/embedder.rs) implements both Embedder and TokenEmbedder by hand, for the same reason as the callback embedder: JsFuture is not Send, so the futures are wrapped instead of going through #[async_trait]. It wraps the return value in Promise.resolve, so a synchronous return works too, and its dimension is checked against the field when the index is created. See API Reference for the options.

Testing

# Build check
cargo build -p laurus-wasm --target wasm32-unknown-unknown

# Clippy
cargo clippy -p laurus-wasm --target wasm32-unknown-unknown -- -D warnings

The #[wasm_bindgen_test] unit tests run in Node (this is also what CI runs):

wasm-pack test --node laurus-wasm

If a test needs a browser instead, run it headless in Chrome:

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, releasing the GVL (Global VM Lock) for the duration of that call (Issue #1103) so other Ruby threads keep running while it’s in flight – a multi-threaded server genuinely benefits from more worker threads, rather than every call serializing on the GVL.

Because Ruby threads can now be concurrent writers for the first time, the engine’s existing concurrency caveats become reachable from Ruby: commit is not serialized against concurrent put/add/delete calls, and CommitPolicy auto-commit guarantees hold for single-writer ingestion – best-effort under concurrent writers on a shared Index. Use explicit commit calls, or a single ingest thread, when you need those guarantees under concurrency.

Unlike the Python binding, close does not wait for any in-flight call on another thread before returning: Magnus only ever hands a plain &self to #[magnus::wrap] methods, so there is no borrow-checker mechanism here to make close exclusive. If another thread is still mid-call when close runs, that call’s own reference keeps the underlying engine (and its storage lock) alive until the call returns.

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

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)
# Writes `./myindex/schema.toml` and `./myindex/store/` -- the same layout
# `laurus-cli create index --schema` uses, so this directory can also be
# opened with the CLI (or vice versa).
schema = Laurus::Schema.new
schema.add_text_field("title")
schema.add_text_field("body")
index = Laurus::Index.new(path: "./myindex", schema: schema)

# Reopening it later only needs the path -- passing `schema:` again would
# raise, since the schema is already persisted.
index = Laurus::Index.new(path: "./myindex")

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. Late-interaction rescore

A multi-vector field keeps a variable number of token vectors per document, for example ColBERT per-token embeddings. A LateInteractionRescore reorders the top results of any search by their MaxSim against that field, and a rescored result’s score is its MaxSim.

require "laurus"

schema = Laurus::Schema.new
schema.add_text_field("title")
schema.add_multi_vector_field("tokens", 2, distance: "dot_product")

index = Laurus::Index.new(schema: schema)
index.put_document("doc1", { "title" => "rust", "tokens" => [[0.1, 0.0]] })
index.put_document("doc2", { "title" => "rust language", "tokens" => [[0.9, 0.2], [0.1, 0.8]] })
index.commit

results = index.search("title:rust", rescore: Laurus::LateInteractionRescore.new("tokens", [[1.0, 0.0], [0.0, 1.0]]))
# doc2 (MaxSim 1.7) now ranks above doc1 (MaxSim 0.1)

With a candle_colbert embedder on the field (schema.add_embedder("colbert", { type: "candle_colbert", model: "colbert-ir/colbertv2.0" }) and add_multi_vector_field("tokens", 128, embedder: "colbert")), documents can give the field text and the query can be text, as in Laurus::LateInteractionRescore.new("tokens", "how do lifetimes work"). See LateInteractionRescore.

7. 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

8. 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)
schema.add_multi_vector_field("tokens", 128)

9. 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, wal_sync_policy: nil, commit_policy: nil)

Constructor

ParameterTypeDefaultDescription
path:String | nilnilDirectory path for persistent storage. nil creates an in-memory index. When given, the directory follows the <path>/schema.toml + <path>/store/ layout laurus-cli create index/--index-dir uses, so an index built here can also be opened by the CLI (and vice versa). See below.
schema:Schema | nilnilSchema definition. Only meaningful when creating a new file-backed index (or an in-memory one); must be omitted when reopening an existing file-backed index – the persisted schema is loaded instead. An empty schema is used when omitted for a new index.
wal_sync_policy:WalSyncPolicy | nilnilWrite-ahead log (WAL) durability policy. nil keeps the default per-record fsync. See WAL sync policy & durability.
commit_policy:CommitPolicy | nilnilAuto-commit policy. nil keeps the default manual mode (the caller drives every commit). See Commit policy & auto-commit.

Creating vs. reopening a file-backed index (path: given): if <path>/schema.toml does not yet exist, this call creates a new index and persists schema: (or an empty schema, if omitted) to it. If <path>/schema.toml already exists, this call reopens the index – schema: must be omitted, or an ArgumentError is raised (it would be ambiguous which schema should win). An ArgumentError is also raised if path: contains an index in the layout used before this convention was introduced (segment files directly under path:, no schema.toml).

Methods

MethodDescription
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.
put_documents(docs)Batched upsert. docs is an Array of [id, hash] pairs, applied in order with one WAL fsync per batch (duplicate ids dedup, last wins). Fails fast at the first bad entry; the applied prefix is not rolled back.
add_documents(docs)Batched chunk append. Like put_documents but repeated ids accumulate as separate versions.
get_documents(id) -> Array<Hash>Return all stored versions for the given ID.
delete_documents(id)Delete all versions for the given ID.
commitFlush buffered writes and make all pending changes searchable.
flush_walForce a durable WAL barrier on demand. Synchronously fsyncs any unsynced WAL records and returns nil. Useful when running under a group-commit policy (see below).
search(query, limit: 10, offset: 0, highlight: nil, rescore: nil, group_by: nil) -> Array<SearchResult>Execute a search query. rescore: takes a LateInteractionRescore that reorders the top results (Issue #1351); any other value raises TypeError. See LateInteractionRescore. group_by: takes a GroupBy that groups the results by a field’s value (Issue #1297); limit: and offset: then count groups. Any other value raises TypeError. See GroupBy.
search_batch(queries, limit: 10, offset: 0, highlight: nil, group_by: nil) -> 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 []. highlight: and group_by: apply identically to every query in the batch, except that a SearchRequest element uses its own. There is no rescore: keyword; a SearchRequest element is rescored by its own rescore:.
stats -> HashReturn 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 SearchRequest for full control

When query is a SearchRequest, its own limit:, offset:, highlight:, rescore: and group_by: are used and the keyword arguments of search are ignored.

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.

Highlighting

search/search_batch’s highlight: keyword (Issue #1134) requests highlighted fragments per field on each hit’s SearchResult#highlights. It accepts:

  • An Array of field names: highlight: ["body"]
  • A Hash with a required fields key (String or Symbol) plus any of the optional HighlightConfig knobs — max_fragments, fragment_size, tag, css_class, require_field_match, max_analyzed_chars, return_entire_field_if_no_highlight (String or Symbol keys both work) — e.g. highlight: {fields: ["body"], tag: "em", max_fragments: 2}
results = index.search("body:rust", highlight: ["body"])
results[0].highlights # => {"body" => ["<mark>Rust</mark> is a systems programming language."]}

Highlighting follows the query passed to search/search_batch (or SearchRequest’s query/lexical_query — see below), and only stored: true text fields can be highlighted; a field that is absent, not stored, or not text is silently skipped. Omitting highlight: leaves every result’s highlights empty. The same highlight: keyword is also accepted by SearchRequest.new.

WAL sync policy & durability

The write-ahead log (WAL) protects committed data against crashes. By default the WAL is fully durable: every record is fsynced before the write returns. You can trade some durability for higher write throughput by opting into group commit, which batches fsync calls.

WalSyncPolicy

Laurus::WalSyncPolicy is an immutable value object describing how the WAL is flushed. Pass it to Index.new(wal_sync_policy:).

# Default: durable per write (each record is fsynced individually).
Laurus::WalSyncPolicy.per_record

# Group commit: batch fsyncs to amortise their cost.
Laurus::WalSyncPolicy.group(
  max_records: nil,      # flush after this many records (default 1024)
  max_bytes: nil,        # flush after this many bytes (default 1 MiB)
  max_interval_ms: nil,  # also flush periodically after this many ms
)
ConstructorDescription
WalSyncPolicy.per_recordDefault. Every record is fsynced before the write returns — fully durable per write.
WalSyncPolicy.group(max_records:, max_bytes:, max_interval_ms:)Batch fsyncs. With no arguments uses the defaults (max_records: 1024, max_bytes: 1 MiB, no timer). The WAL is flushed when either max_records or max_bytes accumulate, and on every commit. Pass max_interval_ms: to also flush on a periodic timer.

Group commit is analogous to SQLite’s synchronous = NORMAL: a crash can lose at most the last unsynced batch of records, but the index never corrupts. Records are always made durable at commit, so a successful commit is a durability barrier regardless of policy.

Forcing a flush

Call flush_wal to force a durable barrier between commits — for example before signalling that a batch has been safely persisted. It synchronously fsyncs any unsynced records and returns nil. Under the default per-record policy it is effectively a no-op.

# Opt into group commit, then force durability on demand.
policy = Laurus::WalSyncPolicy.group(max_records: 4096, max_bytes: 4 * 1024 * 1024)
index = Laurus::Index.new(path: "./myindex", wal_sync_policy: policy)

index.put_document("doc1", { "title" => "Hello" })
index.flush_wal  # records persisted even though the group batch is not full

Commit policy & auto-commit

By default the caller drives every commit: buffered writes only become searchable once you call commit explicitly. You can hand that responsibility to the engine with an auto-commit policy, which commits automatically after a fixed number of applied documents or on a periodic timer.

CommitPolicy

Laurus::CommitPolicy is an immutable value object describing when the engine materialises buffered writes into the stores. Pass it to Index.new(commit_policy:).

# Default: manual — the caller drives every commit.
Laurus::CommitPolicy.manual

# Auto-commit: commit after every N applied documents.
Laurus::CommitPolicy.every_docs(1000)

# Auto-commit: commit at least every N milliseconds (native only).
Laurus::CommitPolicy.interval_ms(5000)
ConstructorDescription
CommitPolicy.manualDefault. No auto-commit — the caller drives every commit.
CommitPolicy.every_docs(n)Auto-commit after every n applied documents. Counted across both singular and batch ingest, including every n documents within a single batch.
CommitPolicy.interval_ms(ms)Auto-commit at least every ms milliseconds via a background timer, so a trailing partial batch is committed even while ingestion is idle. The time-based counterpart of every_docs. Default: none. Native only — under WebAssembly (wasm32) there are no background threads, so the engine treats it as a no-op (the value still constructs, but no timed commit happens).

every_docs(0) is valid and disables auto-commit, making it equivalent to manual.

Commit policy is orthogonal to WalSyncPolicy: WalSyncPolicy governs WAL fsync durability, whereas CommitPolicy governs when the stores materialise buffered writes. Set them independently.

# Auto-commit after every 1000 applied documents.
policy = Laurus::CommitPolicy.every_docs(1000)
index = Laurus::Index.new(path: "./myindex", commit_policy: policy)

Schema

Defines the fields and index types for an Index.

Laurus::Schema.new

Field methods

MethodDescription
add_text_field(name, stored: true, indexed: true, term_vectors: true, doc_values: true, analyzer: nil, multi_valued: false, position_increment_gap: 100)Full-text field (inverted index, BM25). term_vectors: controls whether term positions are stored, read by phrase and span queries. doc_values: controls whether the value is also copied into DocValues, the column-oriented store sort/facet/aggregation read from (Issue #1047); takes effect only when stored: true. Pass multi_valued: true to accept an Array of Strings (Issue #1175): a term query matches if any element contains the term, a phrase query never spans two elements unless its slop reaches position_increment_gap: (default 100; 0 numbers the elements as if concatenated), and values are read back as an Array of Strings. 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, doc_values: true)64-bit integer field. Pass multi_valued: true to accept arrays of integers (range queries match if any value satisfies the predicate). See doc_values: above.
add_float_field(name, stored: true, indexed: true, multi_valued: false, doc_values: true)64-bit float field. Pass multi_valued: true to accept arrays of floats (range queries match if any value satisfies the predicate). See doc_values: above.
add_boolean_field(name, stored: true, indexed: true, multi_valued: false, doc_values: true)Boolean field. Pass multi_valued: true to accept an Array of true / false values (a term query such as flags:true matches if any element equals the value; values are read back as an Array of true / false). See doc_values: above.
add_bytes_field(name, stored: true, multi_valued: false)Raw bytes field. No doc_values: option: a Bytes value is never written to DocValues regardless. Pass multi_valued: true to accept an Array of base64 Strings (Issue #1176), decoded element-wise the same way a single base64 String is; Bytes is never indexed, so unlike every other multi_valued: option this has no query-matching semantics — it only governs the stored shape and ingestion arity. Values are read back as an Array of (binary) Strings, mirroring the scalar field.
add_geo_field(name, stored: true, indexed: true, multi_valued: false, doc_values: true)Geographic coordinate field (lat/lon). Pass multi_valued: true to accept an Array of { "lat" => .., "lon" => .. } Hashes (distance / bounding-box queries match if any point satisfies the predicate). See doc_values: above.
add_geo3d_field(name, stored: true, indexed: true, multi_valued: false, doc_values: true)3D ECEF Cartesian point field (x, y, z in metres). Pass multi_valued: true to accept an Array of { "x" => .., "y" => .., "z" => .. } Hashes (distance / bounding-box / nearest queries match if any point satisfies the predicate). See Geo3d concepts and doc_values: above.
add_datetime_field(name, stored: true, indexed: true, multi_valued: false, doc_values: true)UTC datetime field. Pass multi_valued: true to accept an Array of Time / DateTime / RFC 3339 String values (range queries match if any instant satisfies the predicate; values are read back as an Array of RFC 3339 Strings in UTC). See doc_values: above.
add_hnsw_field(name, dimension, distance: "cosine", m: 16, ef_construction: 200, quantizer: nil, subvector_count: nil, rerank_storage: nil, embedder: nil, pq_codebook_path: nil, base_weight: 1.0)HNSW approximate nearest-neighbor vector field. base_weight sets this field’s relative scoring priority when searched alongside other vector fields (Issue #1084); see Vector Search → Weights.
add_flat_field(name, dimension, distance: "cosine", embedder: nil, base_weight: 1.0)Flat (brute-force) vector field.
add_ivf_field(name, dimension, distance: "cosine", n_clusters: 100, n_probe: 1, embedder: nil, base_weight: 1.0)IVF approximate nearest-neighbor vector field.
add_multi_vector_field(name, dimension, distance: "cosine", storage: "f32", embedder: nil, pool_factor: 1)Multi-vector field holding a variable number of token vectors per document, for example ColBERT per-token embeddings (Issue #1351). Its value is an Array of numeric Arrays, one per token, each of dimension elements. It has no ANN index and is read only by the late-interaction rescore (see LateInteractionRescore); its token vectors are not stored, so get_documents and search results never include the field. distance: must be "cosine" or "dot_product" and dimension greater than 0, both checked when the field is added (ArgumentError). storage: sets the on-disk element kind of the token vectors — "f32" (default, exact), "f16" (2x smaller), "int8" (~4x smaller, lossy quantization), or "binary" (32x smaller, 1 bit per element; requires "cosine") — also checked when the field is added (ArgumentError). pool_factor: keeps about 1 / pool_factor of each document’s token vectors by averaging similar ones at index time (1, the default, keeps them all; 0 raises ArgumentError); see Token pooling. embedder: names a token-level embedder (a "candle_colbert" one, see Embedder types), which embeds text values and rescore query text. See Multi-Vector Fields.

Vector quantization & rerank storage (HNSW fields):

  • quantizer — "scalar_8bit" (default, 4× compression) or "product_quantization" for higher compression. Product quantization requires subvector_count (must divide dimension).
  • rerank_storage — set to "f32" to write a full-precision *.hnsw.f32 sidecar enabling exact Stage-2 rerank; omit to keep the int8-only segment.
  • pq_codebook_path — storage-relative file name of a shared PQ codebook (Issue #631), trained once via the laurus train pq-codebook CLI command. Only meaningful with quantizer: "product_quantization"; commits then encode against the pre-trained codebook instead of re-training k-means per segment. Omit to keep per-segment training.

Every add_*_field method above raises ArgumentError when name starts with _ (other than _id), and adds nothing. A schema loaded with from_toml / from_toml_file keeps accepting such a field, so a persisted schema still loads; creating a new Index from it raises ArgumentError. See Field Naming.

Other methods

MethodDescription
add_embedder(name, config)Register a named embedder definition. config is a Hash with a "type" key, with either String or Symbol keys (see below). A missing or unknown type, or a missing required key, raises ArgumentError (invalid embedder config: ...).
add_analyzer(name, tokenizer, char_filters: nil, token_filters: nil)Register a custom analyzer definition. tokenizer is required; char_filters:/token_filters: are optional Arrays of Hashes. Each Hash uses the same {type: "..."} shape as the schema TOML/JSON format (see below), with either String or Symbol keys. A name reserved for a built-in analyzer (standard, keyword, english, simple, noop) raises ArgumentError, and so does creating a new Index from a schema that defines one (e.g. loaded with from_toml). Semantic validity (e.g. a malformed regex) is checked when the schema is used to build an Index, not here.
analyzer_names -> Array<String>Return the names of custom analyzers registered via add_analyzer or loaded from TOML.
Laurus::Schema.from_toml(toml_str) -> Schema (class method)Parse a schema from a TOML string, in the same format laurus-cli create index --schema accepts.
Laurus::Schema.from_toml_file(path) -> Schema (class method)Load a schema from a TOML file.
to_toml -> StringSerialize this schema to a TOML string in the same format laurus-cli accepts.
to_toml_file(path)Write this schema to a TOML file.
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 -> StringReturn 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

See Schema Format Reference → Embedders for the canonical description of each type.

"type"Required keysFeature flag
"precomputed"–(always available)
"candle_bert""model"embeddings-candle
"candle_clip""model"embeddings-multimodal
"openai""model"embeddings-openai
"candle_colbert""model"embeddings-candle

"candle_colbert" also takes the optional keys "revision", "query_maxlen" and "doc_maxlen". It produces token vectors, so only a multi-vector field (add_multi_vector_field) can use it. Keys may be Strings or Symbols:

schema = Laurus::Schema.new
schema.add_embedder(
  "colbert",
  { type: "candle_colbert", model: "colbert-ir/colbertv2.0", query_maxlen: 32 },
)
schema.add_multi_vector_field("body_colbert", 128, embedder: "colbert")

Analyzer components

Used by add_analyzer(name, tokenizer, char_filters: nil, token_filters: nil) and by the [analyzers.<name>] TOML section. tokenizer is a single Hash; char_filters:/token_filters: are Arrays of Hashes, applied in Array order.

See Schema Format Reference → Analyzers for the canonical description of each component.

Tokenizers (tokenizer, exactly one):

typeRequired keysOptional keys
"whitespace"––
"unicode_word"––
"regex"–pattern (default \w+), gaps (default false)
"ngram"min_gram, max_gram–
"lindera"mode, dictuser_dict
"whole"––

Char filters (char_filters, applied to raw text before tokenization):

typeRequired keysOptional keys
"unicode_normalization"form ("nfc"/"nfd"/"nfkc"/"nfkd")–
"pattern_replace"pattern, replacement–
"mapping"mapping (Hash of string replacements)–
"japanese_iteration_mark"–kanji (default true), kana (default true)

Token filters (token_filters, applied to the token stream after tokenization):

typeRequired keysOptional keys
"lowercase"––
"stop"–words (default: English stop words)
"stem"–stem_type ("porter"/"simple"/"identity")
"boost"boost–
"limit"limit–
"strip"––
"remove_empty"––
"flatten_graph"––
schema = Laurus::Schema.new
schema.add_analyzer(
  "ja_ipadic",
  { type: "lindera", mode: "normal", dict: "/var/lib/lindera/ipadic" },
  char_filters: [
    { type: "unicode_normalization", form: "nfkc" },
    { type: "japanese_iteration_mark" },
  ],
  token_filters: [{ type: "lowercase" }],
)
schema.add_text_field("title", analyzer: "ja_ipadic")

Distance metrics

ValueDescription
"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.

DateTimeRangeQuery

Laurus::DateTimeRangeQuery.new(field, min: nil, max: nil)

Matches DateTime values in the range [min, max] (both bounds inclusive). Pass nil (or omit the keyword) for an open bound. A bound is a String literal in any form the query DSL accepts — RFC 3339 ("2024-01-01T09:00:00+09:00", normalized to UTC), naive "YYYY-MM-DDTHH:MM:SS[.fff]" (UTC), or "YYYY-MM-DD" (midnight UTC) — or any object responding to iso8601 (Time, DateTime). A malformed bound raises ArgumentError at construction. Usable wherever a query object is accepted (Index#search, BooleanQuery, SearchRequest).

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,
  highlight: nil,
  rescore: nil,
  group_by: nil,
)
ParameterDescription
query:A DSL string or single query object. A DSL string can be combined with vector_query:: both are searched, and vector_query: is added to the DSL’s vector part (fused by fusion:, RRF(k: 60) by default). Any other combination with lexical_query: / vector_query: raises ArgumentError (Issue #1372): write the lexical clauses in the DSL, or pass a query object as lexical_query: / vector_query: instead.
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).
highlight:Same Array-or-Hash shape as Index#search’s highlight: (Issue #1134). See Highlighting.
rescore:A LateInteractionRescore that reorders the top results of the request — lexical, vector, or hybrid (Issue #1351); any other value raises TypeError. See LateInteractionRescore.
group_by:A GroupBy that groups the results of the request (Issue #1297); any other value raises TypeError. See GroupBy.

LateInteractionRescore

Late-interaction (ColBERT MaxSim) rescore of the top search results (Issue #1351). Pass it as rescore: to Index#search or SearchRequest.new.

Laurus::LateInteractionRescore.new(field, query, window_size: nil)
ParameterTypeDefaultDescription
fieldString–A multi-vector field (add_multi_vector_field).
queryString | Array<Array<Numeric>>–Query text, embedded by the field’s token-level embedder (a "candle_colbert" one), or the query’s token vectors, computed with the same model as the documents’ token vectors. Integer elements are accepted.
window_size:Integer | nilnil (100)How many top first-stage results to rescore, at most 10,000. nil keeps the default of 100.
MethodDescription
window_size -> IntegerReturn how many top results are rescored.
inspect -> StringReturn a summary such as LateInteractionRescore(field="tokens", window_size=100).

The top window_size first-stage results are reordered by their MaxSim against the field, and a rescored result’s score is its MaxSim. Results beyond the window keep their first-stage order and score, after the rescored ones. See Vector Search → Late-Interaction Rescore for the ordering rules and the similarity.

Errors: the constructor raises TypeError when query is neither a String nor an Array of numeric Arrays. The other values are checked by the engine when searching, which raises ArgumentError with a message containing rescore: ... when the field is unknown or not a multi-vector field, a text query is blank or the field has no token-level embedder, the query does not hold between 1 and 1,024 token vectors of the field’s dimension, or window_size is not between 1 and 10,000.

schema = Laurus::Schema.new
schema.add_text_field("title")
schema.add_multi_vector_field("tokens", 2, distance: "dot_product")

index = Laurus::Index.new(schema: schema)
index.put_document("a", { "title" => "rust", "tokens" => [[0.1, 0.0]] })
index.put_document("b", { "title" => "rust language", "tokens" => [[0.9, 0.2]] })
index.commit

rescore = Laurus::LateInteractionRescore.new("tokens", [[1.0, 0.0], [0.0, 1.0]], window_size: 50)
results = index.search("title:rust", rescore: rescore)
results.map(&:id) # => ["b", "a"] -- "b" has MaxSim 1.1, "a" 0.1

# With a "candle_colbert" embedder on the field, the query can be text.
rescore = Laurus::LateInteractionRescore.new("body_colbert", "how do lifetimes work")

GroupBy

Groups the search results by a field’s value (Issue #1297), so that the chunks of one document do not fill the page. Pass it as group_by: to Index#search, Index#search_batch or SearchRequest.new.

Laurus::GroupBy.new(field, group_size: 1)
ParameterTypeDefaultDescription
fieldString–_id (chunks added with add_document share their document’s _id), or a single-valued, stored text, integer, float, boolean or datetime field with doc_values enabled (the defaults).
group_size:Integer1How many hits each group keeps, the returned result included, 1..=100.
MethodDescription
field -> StringReturn the grouping field.
group_size -> IntegerReturn how many hits each group keeps.
inspect -> StringReturn a summary such as GroupBy(field="_id", group_size=2).

Each returned SearchResult is then the best hit of a group: its group_key is the value the group shares and its inner_hits hold up to group_size - 1 further hits of the group, in ranking order. limit: and offset: count groups. Grouping runs on the final ranking, after fusion and rescore, for lexical, vector and hybrid searches alike. See Result Grouping for the ordering, the candidate pool and why a group can hold fewer than group_size hits.

Errors: the constructor raises RangeError for a negative group_size:. The other values are checked by the engine when searching, before any search runs, which raises ArgumentError with a message containing group_by: ... when the field is unknown, of another type, multi-valued, not stored or without doc_values, when group_size is not between 1 and 100, or when offset + limit exceeds 10,000.

schema = Laurus::Schema.new
schema.add_text_field("body")

index = Laurus::Index.new(schema: schema)
index.add_document("manual", { "body" => "Lifetimes tie a reference to its owner." })
index.add_document("manual", { "body" => "Elided lifetimes follow three rules." })
index.add_document("faq", { "body" => "Why do lifetimes appear in signatures?" })
index.commit

results = index.search("body:lifetimes", group_by: Laurus::GroupBy.new("_id", group_size: 2))
results.map { |r| [r.group_key, r.inner_hits.map { |hit| hit.document["body"] }] }
# => [["faq", []], ["manual", ["Elided lifetimes follow three rules."]]]

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
result.highlights # => Hash     -- Highlighted fragments per requested field
result.group_key  # => Object   -- With group_by:, the group's value
result.inner_hits # => Array    -- With group_by:, the group's next best hits

highlights maps each field named in highlight: to its highlighted fragments (best first); a field that did not highlight is absent from the Hash, and highlights is {} when highlight: was not requested. See Highlighting.

With group_by:, group_key is the value the result’s group shares (a String, Integer, Float, true/false, or a datetime as an RFC 3339 String; nil for a hit without a value) and inner_hits is an Array of the group’s next best SearchResults in ranking order, each with the same group_key and no inner hits of its own. An ungrouped search returns nil and []. See GroupBy.


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 -- UTF-8 byte start offset in the original text
token.end_offset          # => Integer -- UTF-8 byte 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
token.token_type          # => String or nil -- Token type, e.g. "alphanum"

The offsets count bytes, not characters; for non-ASCII text slice the bytes: text.byteslice(token.start_offset...token.end_offset).

token_type is one of "alphanum", "num", "cjk", "katakana", "hiragana", "hangul", "punctuation", "whitespace", "synonym", "email", "url" and "other", or nil. SynonymGraphFilter#apply keeps each token’s offsets and type, and gives each synonym it inserts the type "synonym" and the offsets of the words it replaces.


Field value types

Ruby values are automatically converted to Laurus DataValue types:

Ruby typeLaurus typeNotes
nilNull
true / falseBool
IntegerInt64
FloatFloat64
StringText
Array of IntegerInt64ArrayMulti-valued integer field; vector fields cast the array to f32. An empty Array is an empty Int64Array
Array of numericsFloat64ArrayMulti-valued float field (integers widened); vector fields cast the array to f32
Array of numeric ArraysVectorArrayToken vectors of a multi-vector field (add_multi_vector_field), one Array per token, each of the field’s dimension (Issue #1351). Integer elements are accepted; any other element, such as true or a String, raises TypeError. Not returned by get_documents or search results
Hash with "lat", "lon"GeoTwo Float values
Hash with "x", "y", "z"GeoEcefThree Float values, meters (3D ECEF Cartesian)
Array of Hash with "lat", "lon"GeoArrayRequires multi_valued: true on the field
Array of Hash with "x", "y", "z"GeoEcefArrayRequires multi_valued: true on the field
Time / String responding to iso8601DateTimeConverted via iso8601
Array containing Time (or other objects responding to iso8601)DateTimeArrayEach element parsed as RFC 3339 (objects responding to iso8601 are converted first); a non-datetime element raises ArgumentError. Requires multi_valued: true on the field
Array of StringDateTimeArray or TextArrayA DateTimeArray when every element parses as RFC 3339, a TextArray otherwise (Issue #1175; read back as an Array of Strings). Requires multi_valued: true on the field. On a declared multi-valued Bytes field, the same Array of base64 Strings is instead decoded element-wise (Issue #1176)
Array of true / falseBoolArrayAll elements must be true or false; a mixed Array such as [true, 1] raises TypeError. Requires multi_valued: true on the field

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

TargetDescription
make build-laurus-rubyBundler install + bundle exec rake compile (release gem)
make test-laurus-rubyRust unit tests + Ruby minitest
make lint-laurus-rubyClippy with -D warnings
make format-laurus-rubycargo 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

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)
// Writes `./myindex/schema.toml` and `./myindex/store/` -- the same layout
// `laurus-cli create index --schema` uses, so this directory can also be
// opened with the CLI (or vice versa).
$schema = new Schema();
$schema->addTextField("title");
$schema->addTextField("body");
$index = new Index("./myindex", $schema);

// Reopening it later only needs the path -- passing `$schema` again would
// throw, since the schema is already persisted.
$index = new Index("./myindex");

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. Late-interaction rescore

A late-interaction rescore reorders the top results of any search by ColBERT-style MaxSim against a multi-vector field, which holds a variable number of token vectors per document.

<?php

use Laurus\Index;
use Laurus\Schema;

$schema = new Schema();
$schema->addTextField("title");
$schema->addMultiVectorField("tokens", 2, "dot_product");

$index = new Index(null, $schema);
$index->putDocument("doc1", ["title" => "Rust", "tokens" => [[0.1, 0.0]]]);
$index->putDocument("doc2", ["title" => "Rust language", "tokens" => [[0.9, 0.2], [0.0, 0.3]]]);
$index->commit();

// Reorder the top 100 (default) results of "title:rust" by MaxSim
$results = $index->search("title:rust", 10, 0, null, new Laurus\LateInteractionRescore("tokens", [[1.0, 0.0], [0.0, 1.0]]));

A rescored result’s score is its MaxSim. To pass the query as text instead of token vectors, register a candle_colbert embedder (Cargo feature embeddings-candle) with addEmbedder and name it in addMultiVectorField’s $embedder; see LateInteractionRescore.

7. 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();

8. 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);
$schema->addMultiVectorField("colbert", 128);

9. 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, ?WalSyncPolicy $wal_sync_policy = null, ?CommitPolicy $commit_policy = null)

Constructor

ParameterTypeDefaultDescription
$pathstring|nullnullDirectory path for persistent storage. null creates an in-memory index. When given, the directory follows the <path>/schema.toml + <path>/store/ layout laurus-cli create index/--index-dir uses, so an index built here can also be opened by the CLI (and vice versa). See below.
$schemaSchema|nullnullSchema definition. Only meaningful when creating a new file-backed index (or an in-memory one); must be omitted (null) when reopening an existing file-backed index – the persisted schema is loaded instead. An empty schema is used when omitted for a new index.
$wal_sync_policyWalSyncPolicy|nullnullWrite-ahead log (WAL) durability policy. null keeps the default per-record fsync. See WAL sync policy & durability.
$commit_policyCommitPolicy|nullnullAuto-commit policy. null keeps the default manual policy (the caller drives every commit()). See Commit policy & auto-commit.

Creating vs. reopening a file-backed index ($path given): if <path>/schema.toml does not yet exist, this call creates a new index and persists $schema (or an empty schema, if omitted) to it. If <path>/schema.toml already exists, this call reopens the index – $schema must be omitted, or a ValueError is thrown (it would be ambiguous which schema should win). A ValueError is also thrown if $path contains an index in the layout used before this convention was introduced (segment files directly under $path, no schema.toml).

Methods

MethodDescription
putDocument(string $id, array $doc): voidUpsert a document. Replaces all existing versions with the same ID.
addDocument(string $id, array $doc): voidAppend a document chunk without removing existing versions.
putDocuments(array $docs): voidBatched upsert. $docs is an array of [$id, $doc] pairs, applied in order with one WAL fsync per batch (duplicate ids dedup, last wins). Fails fast at the first bad entry; the applied prefix is not rolled back.
addDocuments(array $docs): voidBatched chunk append. Like putDocuments but repeated ids accumulate as separate versions.
getDocuments(string $id): arrayReturn all stored versions for the given ID.
deleteDocuments(string $id): voidDelete all versions for the given ID.
commit(): voidFlush buffered writes and make all pending changes searchable.
flushWal(): voidForce a durable WAL barrier on demand. Synchronously fsyncs any unsynced WAL records. Useful when running under a group-commit policy (see below).
search(mixed $query, int $limit = 10, int $offset = 0, ?array $highlight = null, ?LateInteractionRescore $rescore = null, ?GroupBy $groupBy = null): arrayExecute a search query. Returns an array of SearchResult. $rescore reorders the top results by late-interaction MaxSim against a multi-vector field (Issue #1351); see LateInteractionRescore. Any other object as $rescore throws \TypeError. $groupBy groups the results by a field’s value (Issue #1297), and $limit/$offset then count groups; see GroupBy. When $query is a SearchRequest, the request’s own $limit/$offset/$highlight/$rescore/$groupBy are used and the other arguments are ignored.
searchBatch(array $queries, int $limit = 10, int $offset = 0, ?array $highlight = null, ?GroupBy $groupBy = null): arrayExecute 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 []. $highlight and $groupBy apply identically to every query in the batch, except that a SearchRequest element uses its own. There is no $rescore parameter; a SearchRequest element is rescored by its own $rescore.
stats(): arrayReturn 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 SearchRequest for 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.

Highlighting

search/searchBatch’s $highlight parameter (Issue #1134) requests highlighted fragments per field on each hit’s SearchResult::getHighlights(). It accepts:

  • A list of field names: ["body"]
  • An associative array with a required "fields" key plus any of the optional HighlightConfig knobs — max_fragments, fragment_size, tag, css_class, require_field_match, max_analyzed_chars, return_entire_field_if_no_highlight — e.g. ["fields" => ["body"], "tag" => "em", "max_fragments" => 2]
$results = $index->search("body:rust", 10, 0, ["body"]);
$results[0]->getHighlights(); // ["body" => ["<mark>Rust</mark> is a systems programming language."]]

Highlighting follows the query passed to search/searchBatch (or SearchRequest’s $query/$lexicalQuery — see below), and only stored: true text fields can be highlighted; a field that is absent, not stored, or not text is silently skipped. Omitting $highlight leaves every result’s highlights empty. The same highlight argument is also accepted by SearchRequest’s constructor.

WAL sync policy & durability

The write-ahead log (WAL) protects committed data against crashes. By default the WAL is fully durable: every record is fsynced before the write returns. You can trade some durability for higher write throughput by opting into group commit, which batches fsync calls.

WalSyncPolicy

Laurus\WalSyncPolicy is an immutable value object describing how the WAL is flushed. Pass it to the Index constructor’s $wal_sync_policy argument.

// Default: durable per write (each record is fsynced individually).
\Laurus\WalSyncPolicy::perRecord(): WalSyncPolicy

// Group commit: batch fsyncs to amortise their cost.
\Laurus\WalSyncPolicy::group(
    ?int $max_records = null,      // flush after this many records (default 1024)
    ?int $max_bytes = null,        // flush after this many bytes (default 1 MiB)
    ?int $max_interval_ms = null,  // also flush periodically after this many ms
): WalSyncPolicy
ConstructorDescription
WalSyncPolicy::perRecord()Default. Every record is fsynced before the write returns — fully durable per write.
WalSyncPolicy::group($max_records, $max_bytes, $max_interval_ms)Batch fsyncs. With all arguments null uses the defaults (max_records = 1024, max_bytes = 1 MiB, no timer). The WAL is flushed when either $max_records or $max_bytes accumulate, and on every commit(). Pass $max_interval_ms to also flush on a periodic timer.

Group commit is analogous to SQLite’s synchronous = NORMAL: a crash can lose at most the last unsynced batch of records, but the index never corrupts. Records are always made durable at commit(), so a successful commit() is a durability barrier regardless of policy.

Forcing a flush

Call flushWal() to force a durable barrier between commits — for example before signalling that a batch has been safely persisted. It synchronously fsyncs any unsynced records. Under the default per-record policy it is effectively a no-op.

// Opt into group commit, then force durability on demand.
$policy = \Laurus\WalSyncPolicy::group(4096, 4 * 1024 * 1024);
$index = new \Laurus\Index("./myindex", null, $policy);

$index->putDocument("doc1", ["title" => "Hello"]);
$index->flushWal(); // records persisted even though the group batch is not full

Commit policy & auto-commit

A commit materialises buffered writes into the lexical and vector stores and makes pending changes searchable. By default Laurus never commits on your behalf — the caller drives every commit(). You can instead let the engine auto-commit after a fixed number of applied documents, or at least once per fixed time interval.

CommitPolicy

Laurus\CommitPolicy is an immutable value object describing when the engine commits. Pass it to the Index constructor’s $commit_policy argument.

// Default: no auto-commit — the caller drives every commit().
\Laurus\CommitPolicy::manual(): CommitPolicy

// Auto-commit after every N applied documents.
\Laurus\CommitPolicy::everyDocs(
    int $n,   // commit after this many applied documents
): CommitPolicy

// Auto-commit at least every N milliseconds (native only; no-op on wasm).
\Laurus\CommitPolicy::intervalMs(
    int $ms,   // commit at least this often, in milliseconds
): CommitPolicy
ConstructorDescription
CommitPolicy::manual()Default. The engine never commits on its own; the caller drives every commit().
CommitPolicy::everyDocs($n)Auto-commit after every $n applied documents. Counting spans both singular and batch ingest, and triggers every $n documents within a batch.
CommitPolicy::intervalMs($ms)Auto-commit at least every $ms milliseconds via a background timer, so a trailing partial batch is committed even while ingestion is idle. The time-based counterpart of everyDocs. Default: none. Native only — a no-op on wasm (WebAssembly has no background threads); the value still constructs, but no timed commit happens.

CommitPolicy::everyDocs(0) is valid and disables auto-commit — it is equivalent to CommitPolicy::manual().

The commit policy is orthogonal to the WAL sync policy: WalSyncPolicy governs when the WAL is fsynced for durability, whereas CommitPolicy governs when the stores materialise and pending changes become searchable. The two are configured independently.

// Auto-commit every 1000 applied documents; keep the default WAL policy.
$index = new \Laurus\Index(null, $schema, null, \Laurus\CommitPolicy::everyDocs(1000));

foreach ($docs as $id => $doc) {
    $index->putDocument($id, $doc); // engine commits automatically every 1000 docs
}

Schema

Defines the fields and index types for an Index.

new \Laurus\Schema()

Field methods

MethodDescription
addTextField(string $name, bool $stored = true, bool $indexed = true, bool $termVectors = true, bool $docValues = true, ?string $analyzer = null, bool $multiValued = false, int $positionIncrementGap = 100): voidFull-text field (inverted index, BM25). $docValues controls whether the value is also copied into DocValues, the column-oriented store sort/facet/aggregation read from (Issue #1047); takes effect only when $stored is also true. Pass $multiValued = true to accept a sequential array of strings (Issue #1175): a term query matches if any element contains the term, a phrase query never spans two elements unless its slop reaches $positionIncrementGap (default 100; 0 numbers the elements as if concatenated), and values are read back as an array of strings. $analyzer accepts a parameter-less built-in name ("standard", "english", "keyword", "simple", "noop") or any custom name registered via addAnalyzer (e.g. a Japanese/Lindera analyzer).
addIntegerField(string $name, bool $stored = true, bool $indexed = true, bool $multiValued = false, bool $docValues = true): void64-bit integer field. Pass $multiValued = true to accept arrays of integers (range queries match if any value satisfies the predicate). See $docValues above.
addFloatField(string $name, bool $stored = true, bool $indexed = true, bool $multiValued = false, bool $docValues = true): void64-bit float field. Pass $multiValued = true to accept arrays of floats (range queries match if any value satisfies the predicate). See $docValues above.
addBooleanField(string $name, bool $stored = true, bool $indexed = true, bool $multiValued = false, bool $docValues = true): voidBoolean field. Pass $multiValued = true to accept a sequential array of bool (a term query such as flags:true matches if any element equals the value; values are read back as an array of bool). See $docValues above.
addBytesField(string $name, bool $stored = true, bool $multiValued = false): voidRaw bytes field. No $docValues option: a Bytes value is never written to DocValues regardless. Pass $multiValued = true to accept a sequential array of base64 strings (Issue #1176), decoded element-wise the same way a single base64 string is; Bytes is never indexed, so unlike every other $multiValued option this has no query-matching semantics — it only governs the stored shape and ingestion arity. Values are read back as an array of binary strings, mirroring the scalar field.
addGeoField(string $name, bool $stored = true, bool $indexed = true, bool $multiValued = false, bool $docValues = true): voidGeographic coordinate field (lat/lon). Pass $multiValued = true to accept an array of ["lat" => .., "lon" => ..] arrays (distance / bounding-box queries match if any point satisfies the predicate). See $docValues above.
addGeo3dField(string $name, bool $stored = true, bool $indexed = true, bool $multiValued = false, bool $docValues = true): void3D ECEF Cartesian point field (x, y, z in metres). Pass $multiValued = true to accept an array of ["x" => .., "y" => .., "z" => ..] arrays (distance / bounding-box / nearest queries match if any point satisfies the predicate). See Geo3d concepts and $docValues above.
addDatetimeField(string $name, bool $stored = true, bool $indexed = true, bool $multiValued = false, bool $docValues = true): voidUTC datetime field. Pass $multiValued = true to accept a sequential array of RFC 3339 strings (range queries match if any instant satisfies the predicate; values are read back as an array of RFC 3339 strings normalized to UTC). See $docValues above.
addHnswField(string $name, int $dimension, ?string $distance = "cosine", int $m = 16, int $efConstruction = 200, ?int $defaultEfSearch = null, ?string $embedder = null, ?string $quantizer = null, ?int $subvectorCount = null, ?string $rerankStorage = null, ?string $pqCodebookPath = null, float $baseWeight = 1.0): voidHNSW approximate nearest-neighbor vector field. $baseWeight sets this field’s relative scoring priority when searched alongside other vector fields (Issue #1084); see Vector Search → Weights.
addFlatField(string $name, int $dimension, ?string $distance = "cosine", ?string $embedder = null, float $baseWeight = 1.0): voidFlat (brute-force) vector field.
addIvfField(string $name, int $dimension, ?string $distance = "cosine", int $nClusters = 100, int $nProbe = 1, ?string $embedder = null, float $baseWeight = 1.0): voidIVF approximate nearest-neighbor vector field.
addMultiVectorField(string $name, int $dimension, ?string $distance = null, ?string $embedder = null, ?string $storage = null, ?int $poolFactor = null): voidMulti-vector field holding a variable number of token vectors per document, read by the late-interaction rescore (Issue #1351); see Multi-Vector Fields. $dimension is each token vector’s length and must be positive; $distance is "cosine" (default) or "dot_product". Both are checked when the field is added (\ValueError). $storage sets the on-disk element kind of the token vectors (Issue #1346): "f32" (default, exact), "f16" (2x smaller), "int8" (~4x smaller), or "binary" (32x smaller, 1 bit per element; requires "cosine"); also checked when the field is added (\ValueError). $poolFactor keeps about 1 / $poolFactor of each document’s token vectors by averaging similar ones at index time (default 1, all; a value below 1 throws \ValueError); see Token pooling. $embedder names a token-level embedder (a "candle_colbert" one) that embeds the field’s text values and the rescore’s query text. The field is not a vector-search target, and its token vectors are not stored: getDocuments and search results omit it.

Vector quantization & rerank storage (HNSW fields):

  • quantizer — "scalar_8bit" (default, 4× compression) or "product_quantization" for higher compression. Product quantization requires subvectorCount (must divide dimension).
  • rerankStorage — set to "f32" to write a full-precision *.hnsw.f32 sidecar enabling exact Stage-2 rerank; omit to keep the int8-only segment.
  • pqCodebookPath — storage-relative file name of a shared PQ codebook (Issue #631), trained once via the laurus train pq-codebook CLI command. Only meaningful with $quantizer = "product_quantization"; commits then encode against the pre-trained codebook instead of re-training k-means per segment. Omit to keep per-segment training.

Every add*Field method above throws \ValueError when name starts with _ (other than _id), and adds nothing. A schema loaded with fromToml / fromTomlFile keeps accepting such a field, so a persisted schema still loads; creating a new Index from it throws \ValueError. See Field Naming.

Other methods

MethodDescription
addEmbedder(string $name, array $config): voidRegister a named embedder definition. $config is an associative array with a "type" key (see below), decoded with the same rules as the schema TOML format. A missing or unknown type, or a missing required key, throws \Exception (invalid embedder config: ...).
addAnalyzer(string $name, array $tokenizer, ?array $charFilters = null, ?array $tokenFilters = null): voidRegister a custom analyzer definition. $tokenizer is required; $charFilters/$tokenFilters are optional arrays of associative arrays. Each uses the same {"type": "...", ...} shape as the schema TOML/JSON format (see below). A name reserved for a built-in analyzer (standard, keyword, english, simple, noop) throws \ValueError, and so does creating a new Index from a schema that defines one (e.g. loaded with fromToml). Semantic validity (e.g. a malformed regex) is checked when the schema is used to build an Index, not here.
analyzerNames(): arrayReturn the names of custom analyzers registered via addAnalyzer or loaded from TOML.
Schema::fromToml(string $tomlStr): SchemaParse a schema from a TOML string, in the same format laurus-cli create index --schema accepts. Throws ValueError if the TOML is not a valid schema.
Schema::fromTomlFile(string $path): SchemaLoad a schema from a TOML file. Throws Exception (message prefixed with the path) if the file cannot be read, or ValueError if its content is not a valid schema.
toToml(): stringSerialize this schema to a TOML string in the same format laurus-cli accepts. Tables come out in sorted key order, so compare round-tripped schemas by content, not raw text.
toTomlFile(string $path): voidWrite this schema to a TOML file, overwriting any existing file. Throws Exception (message prefixed with the path) if the file cannot be written.
setDefaultFields(array $fields): voidSet the default fields used when no field is specified in a query. $fields is an array of strings.
setDynamicFieldPolicy(string $policy): voidSet how undeclared fields are handled. $policy is "strict", "dynamic" (default), or "ignore". See notes below.
dynamicFieldPolicy(): stringReturn the current policy as a lowercase string.
fieldNames(): arrayReturn 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

See Schema Format Reference → Embedders for the canonical description of each type.

"type"Required keysOptional keysFeature flag
"precomputed"––(always available)
"candle_bert""model"–embeddings-candle
"candle_clip""model"–embeddings-multimodal
"openai""model"–embeddings-openai
"candle_colbert""model""revision", "query_maxlen", "doc_maxlen"embeddings-candle

"candle_colbert" produces one vector per token, so it serves only a multi-vector field (addMultiVectorField):

$schema->addEmbedder("colbert", ["type" => "candle_colbert", "model" => "colbert-ir/colbertv2.0"]);
$schema->addMultiVectorField("body_colbert", 128, null, "colbert");

Analyzer components

Used by addAnalyzer(string $name, array $tokenizer, ?array $charFilters = null, ?array $tokenFilters = null) and by the [analyzers.<name>] TOML section. $tokenizer is a single associative array; $charFilters/$tokenFilters are arrays of associative arrays, applied in array order.

See Schema Format Reference → Analyzers for the canonical description of each component.

Tokenizers ($tokenizer, exactly one):

"type"Required keysOptional keys
"whitespace"––
"unicode_word"––
"regex"–"pattern" (default \w+), "gaps" (default false)
"ngram""min_gram", "max_gram"–
"lindera""mode", "dict""user_dict"
"whole"––

Char filters ($charFilters, applied to raw text before tokenization):

"type"Required keysOptional keys
"unicode_normalization""form" ("nfc"/"nfd"/"nfkc"/"nfkd")–
"pattern_replace""pattern", "replacement"–
"mapping""mapping" (associative array of string replacements)–
"japanese_iteration_mark"–"kanji" (default true), "kana" (default true)

Token filters ($tokenFilters, applied to the token stream after tokenization):

"type"Required keysOptional keys
"lowercase"––
"stop"–"words" (default: English stop words)
"stem"–"stem_type" ("porter"/"simple"/"identity")
"boost""boost"–
"limit""limit"–
"strip"––
"remove_empty"––
"flatten_graph"––
$schema = new Laurus\Schema();
$schema->addAnalyzer(
    "ja_ipadic",
    ["type" => "lindera", "mode" => "normal", "dict" => "/var/lib/lindera/ipadic"],
    [
        ["type" => "unicode_normalization", "form" => "nfkc"],
        ["type" => "japanese_iteration_mark"],
    ],
    [["type" => "lowercase"]],
);
$schema->addTextField("title", analyzer: "ja_ipadic");

Distance metrics

ValueDescription
"cosine"Cosine similarity (default)
"euclidean"Euclidean distance
"dot_product"Dot product
"manhattan"Manhattan distance
"angular"Angular distance

addMultiVectorField accepts only "cosine" and "dot_product".


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".

DateTimeRangeQuery

new \Laurus\DateTimeRangeQuery(string $field, ?string $min = null, ?string $max = null)

Matches DateTime values in the range [$min, $max] (both bounds inclusive). Pass null for an open bound. Bounds are string literals in any form the query DSL accepts: RFC 3339 ("2024-01-01T09:00:00+09:00", normalized to UTC), naive "YYYY-MM-DDTHH:MM:SS[.fff]" (UTC), or "YYYY-MM-DD" (midnight UTC); pass $dt->format(DATE_RFC3339) for a DateTimeInterface. Because ext-php-rs constructors cannot fail, a malformed bound is reported as a \Throwable when the query is used (Index::search, BooleanQuery, SearchRequest).

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,
    ?array $highlight = null,
    ?LateInteractionRescore $rescore = null,
    ?GroupBy $groupBy = null,
)
ParameterDescription
$queryA DSL string or single query object. A DSL string can be combined with $vectorQuery: both are searched, and $vectorQuery is added to the DSL’s vector part (fused by $fusion, RRF(k: 60) by default). Any other combination with $lexicalQuery / $vectorQuery throws \ValueError (Issue #1372): write the lexical clauses in the DSL, or pass a query object as $lexicalQuery / $vectorQuery instead.
$lexicalQueryLexical component for explicit hybrid search.
$vectorQueryVector component for explicit hybrid search.
$filterQueryLexical filter applied after scoring.
$fusionFusion algorithm (RRF or WeightedSum). Defaults to RRF(k: 60) when both components are set.
$limitMaximum number of results (default 10).
$offsetPagination offset (default 0).
$highlightSame list-or-associative-array shape as Index->search()’s $highlight (Issue #1134). See Highlighting. Only $limit/$offset have PHP-level defaults today, so a call that names $highlight must still pass every earlier parameter positionally or by name.
$rescoreA LateInteractionRescore that reorders the top results (Issue #1351); any other object throws \TypeError. See LateInteractionRescore. As with $highlight, pass every earlier parameter too.
$groupByA GroupBy that groups the results (Issue #1297); any other value throws \TypeError. See GroupBy. As with $highlight, pass every earlier parameter too.

When a SearchRequest is passed to Index->search(), its own $limit, $offset, $highlight, $rescore and $groupBy are used; search’s other arguments are ignored.


LateInteractionRescore

Late-interaction (ColBERT MaxSim) rescore of the top search results (Issue #1351). Pass it as Index->search()’s 5th argument or as SearchRequest’s $rescore. See Vector Search → Late-Interaction Rescore for how it works.

new \Laurus\LateInteractionRescore(string $field, string|array $query, ?int $windowSize = null)
ParameterTypeDefaultDescription
$fieldstring–A multi-vector field (addMultiVectorField).
$querystring|array–Query text, embedded by the field’s token-level embedder (a "candle_colbert" one), or the query’s token vectors as a list of numeric lists, e.g. [[1.0, 0.0], [0.0, 1.0]] (integers are widened).
$windowSizeint|nullnull (100)How many top first-stage results to rescore, at most 10,000.

Methods

MethodDescription
getWindowSize(): intReturn how many top results are rescored (100 unless $windowSize was given).
__toString(): stringReturn a representation such as LateInteractionRescore(field="tokens", window_size=100).

Ordering and scores

The top $windowSize first-stage results – lexical, vector, or hybrid – are reordered by their MaxSim against the field, and a rescored result’s getScore() is its MaxSim. Results beyond the window keep their first-stage order and score, after the rescored ones (window results without token vectors in the field come between the two, in first-stage order). The two kinds of score are not comparable.

Errors

  • The constructor throws \TypeError (query must be a string or a list of numeric lists) when $query is neither, and \Exception (token vector N must hold only numbers) when a token vector holds a non-number such as a bool or string.
  • Index->search() and SearchRequest throw \TypeError (rescore must be a Laurus\LateInteractionRescore) for any other $rescore object.
  • Everything else is checked by the engine when searching, before any search runs, and throws \ValueError with a message containing rescore: ...: the field is unknown or not a multi-vector field, the query does not hold between 1 and 1,024 finite vectors of the field’s dimension, a text query is blank or the field has no token-level embedder, or $windowSize is outside 1..=10,000.

Example

$schema = new Laurus\Schema();
$schema->addTextField("title");
$schema->addMultiVectorField("tokens", 2, "dot_product");

$index = new Laurus\Index(null, $schema);
$index->putDocument("a", ["title" => "rust", "tokens" => [[0.1, 0.0]]]);
$index->putDocument("b", ["title" => "rust language", "tokens" => [[0.9, 0.2]]]);
$index->commit();

$rescore = new Laurus\LateInteractionRescore("tokens", [[1.0, 0.0], [0.0, 1.0]], 50);
$results = $index->search("title:rust", 10, 0, null, $rescore);
$results[0]->getId();    // "b"
$results[0]->getScore(); // ≈ 1.1 = 0.9 + 0.2 (MaxSim)

// The same rescore through a SearchRequest (9th argument)
$results = $index->search(new Laurus\SearchRequest("title:rust", null, null, null, null, 10, 0, null, $rescore));

// A text query needs a token-level embedder on the field (see "Embedder types")
$rescore = new Laurus\LateInteractionRescore("body_colbert", "how do lifetimes work");

GroupBy

Groups the search results by a field’s value (Issue #1297), so that the chunks of one document do not fill the page. Pass it as the $groupBy argument of Index->search() or Index->searchBatch(), or as SearchRequest’s $groupBy.

new \Laurus\GroupBy(string $field, ?int $groupSize = null)
ParameterTypeDefaultDescription
$fieldstring–_id (chunks added with addDocument share their document’s _id), or a single-valued, stored text, integer, float, boolean or datetime field with docValues enabled (the defaults).
$groupSizeint|nullnull (1)How many hits each group keeps, the returned result included, 1..=100.

Methods

MethodDescription
getField(): stringReturn the grouping field.
getGroupSize(): intReturn how many hits each group keeps (1 unless $groupSize was given).
__toString(): stringReturn a representation such as GroupBy(field="_id", group_size=2).

Results and errors

Each returned SearchResult is then the best hit of a group: its getGroupKey() is the value the group shares and its getInnerHits() hold up to $groupSize - 1 further hits of the group, in ranking order. $limit and $offset count groups. Grouping runs on the final ranking, after fusion and rescore, for lexical, vector and hybrid searches alike. See Result Grouping for the ordering, the candidate pool and why a group can hold fewer than $groupSize hits.

  • Index->search(), Index->searchBatch() and SearchRequest throw \TypeError (groupBy must be a Laurus\GroupBy) for any other $groupBy value.
  • Everything else is checked by the engine when searching, before any search runs, and throws \ValueError with a message containing group_by: ...: the field is unknown, of another type, multi-valued, not stored or without docValues, $groupSize is outside 1..=100 (a negative size is reported as 0), or $offset + $limit exceeds 10,000.

Example

$schema = new Laurus\Schema();
$schema->addTextField("body");

$index = new Laurus\Index(null, $schema);
$index->addDocument("manual", ["body" => "Lifetimes tie a reference to its owner."]);
$index->addDocument("manual", ["body" => "Elided lifetimes follow three rules."]);
$index->addDocument("faq", ["body" => "Why do lifetimes appear in signatures?"]);
$index->commit();

$results = $index->search("body:lifetimes", groupBy: new Laurus\GroupBy("_id", groupSize: 2));
$results[0]->getGroupKey();                            // "faq"
$results[1]->getGroupKey();                            // "manual"
$results[1]->getInnerHits()[0]->getDocument()["body"]; // "Elided lifetimes follow three rules."

// The same grouping through a SearchRequest (10th argument)
$request = new Laurus\SearchRequest("body:lifetimes", null, null, null, null, 10, 0, null, null, new Laurus\GroupBy("_id", groupSize: 2));
$results = $index->search($request);

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
$result->getHighlights()  // array    -- Highlighted fragments per requested field
$result->getGroupKey()    // mixed    -- With $groupBy, the group's value
$result->getInnerHits()   // array    -- With $groupBy, the group's next best hits

getHighlights() maps each field named in $highlight to its highlighted fragments (best first); a field that did not highlight is absent from the array, and it returns [] when $highlight was not requested. See Highlighting.

With $groupBy, getGroupKey() returns the value the result’s group shares (a string, int, float or bool, or a datetime as an RFC 3339 string; null for a hit without a value) and getInnerHits() returns the group’s next best SearchResults in ranking order, each with the same group key and no inner hits of its own. An ungrouped search returns null and []. See GroupBy.


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)
ParameterDescription
$dictionarySource synonym groups.
$keepOriginalWhen true (default), keep the original token alongside the synonyms.
$boostScore 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     -- UTF-8 byte start offset in the original text
$token->getEndOffset()          // int     -- UTF-8 byte 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
$token->getTokenType()          // ?string -- Token type, e.g. "alphanum"

The offsets count bytes, as PHP strings do, so substr($text, $start, $end - $start) gives the token’s text.

getTokenType() returns one of "alphanum", "num", "cjk", "katakana", "hiragana", "hangul", "punctuation", "whitespace", "synonym", "email", "url" and "other", or null. SynonymGraphFilter::apply() keeps each token’s offsets and type, and gives each synonym it inserts the type "synonym" and the offsets of the words it replaces.


Field value types

PHP values are automatically converted to Laurus DataValue types:

PHP typeLaurus typeNotes
nullNull
true / falseBool
intInt64
floatFloat64
stringText
array of int (sequential)Int64ArrayMulti-valued integer field; vector fields cast the array to f32. An empty array is an empty Int64Array
array of numerics (sequential)Float64ArrayMulti-valued float field (integers widened); vector fields cast the array to f32
array with "lat", "lon"GeoTwo float values
array with "x", "y", "z"GeoEcefThree float values, meters (3D ECEF Cartesian)
array of ["lat" => .., "lon" => ..] arraysGeoArraySequential array; requires $multiValued = true on the field
array of ["x" => .., "y" => .., "z" => ..] arraysGeoEcefArraySequential array; requires $multiValued = true on the field
array of numeric lists (sequential, e.g. [[0.1, 0.2], [0.3, 0.4]])VectorArrayToken vectors of a multi-vector field (Issue #1351). The inner arrays are lists, not keyed geo points, so the two never collide. Integers are widened; a bool or string element throws (“token vector N must hold only numbers”). The vector count and dimension are checked against the field (\ValueError). Not stored: getDocuments and search results omit the field
string (ISO 8601)DateTimeParsed from ISO 8601 format
array of ISO 8601 strings (sequential)DateTimeArrayChosen only when every element parses as ISO 8601. Requires $multiValued = true on the field
array of string (sequential, not all ISO 8601)TextArrayMulti-valued text field (Issue #1175); read back as an array of strings. Requires $multiValued = true on the field. On a declared multi-valued Bytes field, the same array of base64 strings is instead decoded element-wise (Issue #1176)
array of bool (sequential)BoolArrayAll elements must be bool; a mixed array such as [true, 1] is an error (“numeric array elements must be numeric”). Requires $multiValued = true on the field

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 clang-sys

laurus-php uses ext-php-rs, whose bindings generator (ext-php-rs-bindgen) parses PHP headers with bindgen, which loads libclang through clang-sys. The laurus-ruby crate depends on magnus, whose rb-sys build also uses bindgen + clang-sys. Cargo forbids two packages with the same links value in a single workspace, so both PHP and Ruby bindings can only coexist as workspace members while they resolve to the same clang-sys package.

Older ext-php-rs releases depended on ext-php-rs-clang-sys (a fork of clang-sys that also declared links = "clang"), which conflicted with the original clang-sys pulled in by laurus-ruby. That era required a local [patch.crates-io] override (a vendored copy under patches/ with the links declaration removed). Since ext-php-rs-bindgen 0.72.1-extphprs.2 (pulled in by ext-php-rs 0.15.15), the fork is gone — ext-php-rs depends on the regular clang-sys again, both bindings share one clang-sys package, and the patch has been removed.

If a future ext-php-rs upgrade reintroduces a forked clang-sys (watch for a links = "clang" conflict error from cargo build -p laurus-php -p laurus-ruby), reintroduce the vendored patch: copy the forked crate’s source into patches/, comment out its links = "clang" line, and point [patch.crates-io] in the root Cargo.toml at it. This is safe because clang-sys uses libclang only at build time (for bindgen header parsing) and does not link it into the final binary.

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 building laurus-server
  • cargo-zigbuild – optional, only needed to cross-compile static musl binaries locally (see Cross-compiling static musl binaries)

Building

# Build all crates
cargo build

# Build with specific features
cargo build --features embeddings-candle

# Build in release mode
cargo build --release

Cross-compiling static musl binaries

laurus-cli’s release workflow builds fully static x86_64-unknown-linux-musl / aarch64-unknown-linux-musl binaries in addition to the dynamically-linked glibc ones (see Prebuilt binaries).

Prerequisites:

rustup target add x86_64-unknown-linux-musl aarch64-unknown-linux-musl
pip install cargo-zigbuild

Then build with cargo zigbuild in place of cargo build:

cargo zigbuild --release --target x86_64-unknown-linux-musl \
  -p laurus-cli --features embeddings-all

(equivalent to make build-laurus-cli-musl for the x86_64 target).

Why cargo-zigbuild and not apt install musl-tools? Ubuntu’s musl-tools package provides musl-gcc (C) but no musl-g++ (C++). Most of laurus’s --features embeddings-all dependency graph is C-only or pure Rust (aws-lc-sys, onig_sys), but a plain musl-tools setup is one dependency feature flip away from needing C++ again (tokenizers’ esaxx_fast, which laurus deliberately disables – see Feature Flags). cargo-zigbuild uses Zig as the cross C/C++ toolchain, which bundles musl headers and libraries for every target and needs no Docker.

Verify a build is genuinely static:

file target/x86_64-unknown-linux-musl/release/laurus
# -> ELF 64-bit LSB executable, ..., statically linked
readelf -d target/x86_64-unknown-linux-musl/release/laurus | grep NEEDED
# -> (no output)

See build-binary in .github/workflows/release.yml for the CI configuration this mirrors.

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"

# Check binding README / docs / example snippets against the current
# Node.js / WASM / PHP signatures (also run by `make lint` and CI)
python3 scripts/check-binding-snippets.py

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

FileScope
bkd_bench.rsBKD tree range search, intersect, and build (1D / 2D / 3D, 10k / 100k / 1M points)
distance_bench.rsDistanceMetric::distance for cosine, Euclidean, Manhattan, dot product (single dimension today; sweep tracked in #424)
lexical_search_bench.rsEnd-to-end lexical search through Engine::search for term, boolean, phrase, fuzzy, and DSL queries
search_perf.rsPosting iterator skip_to, BM25Scorer::score, SIMD batch scoring, compact posting conversion
spell_correction_bench.rsSpellingCorrector::correct with a fresh corrector per iteration (cold-state measurement)
synonym_bench.rsSynonymDictionary::get_synonyms lookup at 100 / 1k / 10k groups, plus build cost
text_analysis_bench.rsStandardAnalyzer::analyze single-document and batch (100 docs)
vector_search_bench.rsFlat / 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:

  1. On main (or before any change) — cargo bench --bench RELEVANT -- --save-baseline main.
  2. Make the change on a branch.
  3. On the branch — cargo bench --bench RELEVANT -- --baseline main.
  4. Copy the change: lines into the PR description.

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   # Intel
    

    AMD 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_bench
    

    Or use the bundled wrapper that pins the bench process to one core via taskset and raises scheduling priority via nice in one step:

    ./scripts/bench-stable.sh --bench distance_bench
    ./scripts/bench-stable.sh --bench distance_bench -- --baseline main
    

    The 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:

  1. Use a deterministic seed via common::DEFAULT_SEED (or the lcg_* helpers). Never call rand::rng().
  2. Add a top-of-file //! doc comment listing scope, scenarios, run command, and filter examples.
  3. Add a one-time sanity assert! outside the timed b.iter block so a regression that produces empty output cannot pass silently.
  4. Pick SAMPLE_SIZE_FAST (default, for sub-50 ms operations) or SAMPLE_SIZE_SLOW (construction paths). Do not invent intermediate values.
  5. Register the file in laurus/Cargo.toml with [[bench]] name = "..." harness = false. The crate sets autobenches = false, so files in benches/ are not picked up automatically.

If your bench needs to share helpers across files, extend benches/common.rs rather than duplicating code.

Retrieval-quality probes

Criterion measures speed; ranking quality is measured on BEIR datasets with laurus/examples/beir_multi_vector_probe.rs. It reports nDCG@10 of ColBERT MaxSim rescoring for every MultiVector storage kind and token-pooling factor it is configured with, along with each configuration’s segment size.

./scripts/fetch-beir.sh scifact          # download and MD5-check the BEIR zip into .cache/beir/
cargo run --release --features embeddings-candle \
    --example beir_multi_vector_probe -- --dataset scifact
make beir-storage-eval BEIR_DATASET=nfcorpus   # both steps in one target
  • The corpus is encoded once with answerdotai/answerai-colbert-small-v1 (pinned revision) and the f32 token vectors are cached under .cache/beir/<dataset>/; later runs skip the encoding.
  • Every storage kind ingests the same vectors into its own field of one engine, so the first stage is identical and only the on-disk element kind differs.
  • Two conditions: an exhaustive MaxSim over the whole corpus (isolates the storage effect; the corpus must fit in RescoreOptions::MAX_WINDOW_SIZE), and a BM25 top-100 first stage rescored by MaxSim.
  • nDCG@10 follows trec_eval’s ndcg_cut_10 as BEIR runs it. --trec-run <dir> writes TREC run files for an independent check with trec_eval or pytrec_eval.
  • Differences from F32 come with a paired-bootstrap 95% confidence interval (10,000 resamples, fixed seed). The reported search times are informational only.

The probe’s metric and cache code is unit-tested under cargo test (no download needed). The probe itself is not run in CI.

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 enables the native feature by default, which provides file-based storage (mmap), multi-threaded parallelism (rayon), and a multi-threaded tokio runtime. Disabling it (default-features = false) is the wasm32-unknown-unknown configuration used by laurus-wasm; it does not build on a native target, since native-only modules such as mmap-backed file storage are gated on it. Enable embedding support as needed.

Available Flags

FeatureDescriptionKey Dependencies
native (default)File-based storage, rayon parallelism, multi-threaded tokiocrossbeam-channel, crossbeam-deque, memmap2, num_cpus, rayon, tempfile
embeddings-candleLocal BERT embeddings and ColBERT token vectors via Hugging Face Candlecandle-core, candle-nn, candle-transformers, hf-hub, tokenizers
embeddings-openaiOpenAI API embeddingsreqwest
embeddings-multimodalCLIP multimodal embeddings (text + image)candle-core, candle-nn, candle-transformers, hf-hub, tokenizers, image
embeddings-allAll embedding features combinedAll of the above
pq-fastscanExperimental SIMD-accelerated PQ FastScan path for HNSW indexes–

What Each Flag Enables

embeddings-candle

Enables CandleBertEmbedder for running BERT models locally on the CPU, and CandleColbertEmbedder (the candle_colbert schema embedder) for ColBERT token vectors used by late-interaction rescoring. Models are downloaded from Hugging Face Hub on first use.

[dependencies]
laurus = { version = "0.13", 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.13", features = ["embeddings-openai"] }

embeddings-multimodal

Enables CandleClipEmbedder for CLIP-based text and image embeddings. This is independent of embeddings-candle: it does not enable CandleBertEmbedder or CandleColbertEmbedder, so combine it with embeddings-candle if you need both BERT/ColBERT and CLIP.

[dependencies]
laurus = { version = "0.13", features = ["embeddings-multimodal"] }

embeddings-all

Convenience flag that enables all embedding features.

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

TLS and Network Behavior

All three HTTP-calling embedding features now share a single TLS stack:

FeatureHTTP clientTLS backendTrust source
embeddings-candle, embeddings-multimodalhf-hub (reqwest, via its blocking feature)rustlsOS trust store (via rustls-platform-verifier)
embeddings-openaireqwestrustlsOS trust store (via rustls-platform-verifier)

All of them resolve trust through rustls-platform-verifier: on Linux this reads the system’s CA bundle via rustls-native-certs (honoring SSL_CERT_FILE / SSL_CERT_DIR), on macOS it uses Keychain, and on Windows the system certificate store. A container running any of these features needs a populated trust store – ca-certificates on Debian/Alpine-based images, or the equivalent for the target OS. A scratch or distroless image with no CA bundle will fail the TLS handshake for both Hugging Face Hub downloads and the OpenAI API call; see the laurus-cli installation guide for a working Dockerfile. A custom CA installed only in the OS trust store (for example behind a corporate TLS-inspecting proxy) is trusted on all three features.

Feature Flag Impact on Binary Size

Enabling embedding features adds dependencies that increase compile time and binary size:

ConfigurationApproximate Impact
Default (native, no embedding)Baseline
embeddings-candle+ Candle ML framework
embeddings-openai+ reqwest HTTP client
embeddings-multimodal+ image processing + Candle
embeddings-allAll of the above

If you only need lexical (keyword) search, the default feature set (native, no embedding features) gives 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

CrateTypeDescription
laurusLibraryCore search engine with lexical, vector, and hybrid search
laurus-cliBinaryCLI tool for index management, document CRUD, search, REPL, and serve / mcp launchers
laurus-serverLibrary + BinarygRPC server with optional HTTP/JSON gateway
laurus-mcpBinaryMCP server on stdio that proxies tool calls to a running laurus-server
laurus-pythonDynamic libraryPython package (PyPI) built with PyO3 / Maturin
laurus-nodejsDynamic libraryNode.js package (npm) built with NAPI-RS
laurus-wasmWebAssemblynpm package for browser and edge runtimes, built with wasm-bindgen
laurus-rubyDynamic libraryRuby gem built with magnus and rb-sys
laurus-phpPHP extensionPHP 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.rs
    • src/tokenizer.rs + src/tokenizer/dictionary.rs
    • Not: src/tokenizer/mod.rs
  • Error handling: thiserror for library error types, anyhow only in binary crates
  • No unwrap() / expect() in production code (allowed in tests)
  • Async: All public APIs use async/await with Tokio runtime
  • Unsafe: Every unsafe block 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