open·source logoopen·source
All crates
Open source · Rust · MIT

searchez

v1.0.0

Searchable models for Rust — Searchkick-style

A searchable-model layer for Rust: make a type searchable, keep its index in sync as records change, and search with real BM25 relevance — over a pluggable backend, with a batteries-included in-memory engine that needs no external service. Rust has excellent search engines (tantivy, meilisearch); searchez is the missing layer above them — the analog of Ruby’s Searchkick.

$cargo add searchez
searchfulltextbm25indexsearchkick
~/searchez
searchez
v1.0.0 · MIT · Rust Rust
$ cargo add searchez
Declare a Searchable model — index name, id, and to_document()
Index stays in sync: index / remove / reindex on every write
Real BM25 relevance (the Lucene / Elasticsearch algorithm)
Batteries-included in-memory backend — no external service
Pluggable Backend — Meilisearch behind a feature flag
Hydration: search returns ranked ids to load full DB records

// documentation

searchez docs

Overview

Rust has excellent search engines (tantivy, meilisearch, opensearch) and, until now, nothing above them. Every project rewrites the same to_document() mapping, the same post-save index call, and a one-off backfill binary. searchez is that missing layer.

  • Searchable — a type declares its index, its id, and its document
  • SearchEngine — index / remove / reindex / search, the lifecycle spine
  • Backend — pluggable; ships with a BM25-ranked in-memory engine
Note — The in-memory backend ranks with BM25 — the same algorithm Lucene, Elasticsearch and Tantivy use — not a naive term count. A short, dense match outranks a passing mention in a long body; ties break by id, so paging is stable.

Installation

toml
[dependencies]
searchez = "1"
# for the Meilisearch backend:
# searchez = { version = "1", features = ["meilisearch"] }

Quick start

Implement Searchable for your type, then index and search through a SearchEngine.

rust
use searchez::{Searchable, SearchEngine, MemoryBackend, Document, Query};

struct Product { id: u64, name: String, in_stock: bool }

impl Searchable for Product {
    fn index_name() -> &'static str { "products" }
    fn search_id(&self) -> String { self.id.to_string() }
    fn to_document(&self) -> Document {
        Document::new()
            .field("name", self.name.clone())
            .field("in_stock", self.in_stock)
    }
}

let engine = SearchEngine::new(MemoryBackend::new());
engine.index(&Product { id: 1, name: "Dark Roast Coffee".into(), in_stock: true }).await?;

// full-text + filter, ranked by BM25
let hits = engine
    .search::<Product>(Query::text("coffee").filter("in_stock", true))
    .await?;
assert_eq!(hits[0].id, "1");

Keeping the index in sync

Rust has no universal ORM callback, so sync points are explicit — and in exchange the boilerplate (document mapping, backend call, bulk backfill) is done for you.

rust
// after creating or updating a record
engine.index(&product).await?;

// after deleting one
engine.remove(&product).await?;          // or remove_id::<Product>("42")

// the backfill job: rebuild an index from every record
let n = engine.reindex(&all_products).await?;

Hydration — search → database records

A hit carries the indexed document, which is enough to display results. When you need the full database record (columns you didn’t index), search for the ids and load the rows, preserving rank order.

rust
let ids = engine.search_ids::<Product>("dark roast").await?;   // ranked
let mut rows = db.load_products(&ids).await?;                  // your DB call

// keep the search order (databases return rows in their own order)
let rank: std::collections::HashMap<_, _> =
    ids.iter().enumerate().map(|(i, id)| (id.clone(), i)).collect();
rows.sort_by_key(|p| rank[&p.id.to_string()]);

Backends

MemoryBackend (default) is in-memory and non-persistent — perfect for tests, development, and small single-process datasets. For production, enable the meilisearch feature and register a MeilisearchBackend; nothing above the Backend trait changes.

rust
use searchez::{SearchEngine, MeilisearchBackend};

let backend = MeilisearchBackend::new("http://localhost:7700", Some("masterKey"))?;
// Meilisearch only filters on fields declared filterable — do this once at setup:
backend.set_filterable_attributes("products", &["in_stock"]).await?;
let engine = SearchEngine::new(backend);
Note — Bring your own engine by implementing the Backend trait (upsert / delete / search / clear / count) over OpenSearch, Tantivy, or anything else — your Searchable models, index/search calls, and hydration code all stay put.

Ready to try searchez?

cargo add searchez
View on crates.io

// more

searchez — help & policies