searchez
v1.0.0Searchable 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// 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
Installation
[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.
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.
// 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?;Search
A bare string is a full-text query with sensible defaults; build a Query for filters and paging. Each Hit carries id, a BM25 score, and the stored document.
engine.search::<Product>("dark roast").await?;
let q = Query::text("dark roast")
.filter("in_stock", true) // exact-match filters, ANDed
.limit(10)
.offset(0);
engine.search::<Product>(q).await?;
// a pure filter query — everything matching, no text
engine.search::<Product>(Query::all().filter("in_stock", true)).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.
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.
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);Ready to try searchez?
cargo add searchez// more