searchez 1.0.0

A searchable-model layer for Rust: make a type searchable, keep the index in sync, and search with real relevance ranking — over a pluggable backend, with a batteries-included in-memory engine that needs no external service.
Documentation

searchez

crates.io Documentation License

A searchable-model layer for Rust. Make a type searchable, keep its index in sync as records change, and search with real relevance ranking — over a pluggable backend, with a batteries-included in-memory engine that needs no external service.

Rust has excellent search engines (tantivy, meilisearch, opensearch) and, until now, nothing above them. Every project rewrites the same three things: the to_document() mapping, the "call the search client after every save", and a one-off backfill binary. searchez is that missing layer — the Rust analog of Ruby's Searchkick.


Install

[dependencies]
searchez = "1.0.0"

Quick start

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

# async fn demo() -> searchez::Result<()> {
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");
# Ok(()) }

The three pieces

Searchable your model declares its index, id, and to_document()
SearchEngine the spine index · remove · reindex · search
Backend the engine pluggable; ships with a BM25 MemoryBackend

Keeping the index in sync

Rust has no universal ORM callback, so sync points are explicit — and in exchange the boilerplate 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
engine.search::<Product>("dark roast").await?;

// or build it up
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?;

Each [Hit] carries id, a BM25 score, and the stored document.

Relevance is real

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, and rare terms count for more than common ones. Ties break by id, so paging is stable.

Hydration — search → database records

A hit carries the indexed document, which is enough to display results. When you need the full database record (columns or associations 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.

Meilisearch (feature meilisearch)

A production backend backed by a Meilisearch server:

searchez = { version = "1.0.0", features = ["meilisearch"] }
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);
// ...everything else is identical to the in-memory example.

Two Meilisearch specifics searchez handles or surfaces:

  • Primary key — searchez injects its id into each document under searchez_id and declares it the primary key. Ids should therefore match Meilisearch's ^[a-zA-Z0-9_-]+$ (avoid /, ., spaces).
  • Filterable fields — call set_filterable_attributes once per index for any field you filter on, or filtered searches error (a Meilisearch requirement).

Verification status: the Meilisearch backend compiles against meilisearch-sdk 0.28 and is written to its documented API, but has not yet been exercised against a live Meilisearch server in this repo's CI. Treat it as beta until you've run it against your instance. The in-memory backend is fully tested.

Bring your own

Implement [Backend] (four async methods: upsert · delete · search · clear, plus count) over OpenSearch, Tantivy, or anything else. Nothing above the trait changes — your Searchable models, index/search calls, and hydration code all stay put.

Status & roadmap

1.0.0 — the abstraction, a BM25-ranked in-memory backend (fully tested), and a Meilisearch backend (compile-verified, live-verification pending).

Not yet built (contributions welcome):

  • More server backends — OpenSearch / Tantivy behind feature flags.
  • Zero-downtime reindex — alias-swap, for backends that support it (today's reindex is clear-then-fill).
  • Aggregations / facets, misspelling tolerance, field boosting.
  • Framework glue — e.g. an adminx integration mirroring how adminx-audit hooks resource writes, so indexing stays in sync automatically.

License

MIT


Made with ❤️ by Srotas Space


👥 Contributors


GitHub stars