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
<div align="center">
<img src="https://srotasspace.s3.ap-south-1.amazonaws.com/srotas.svg" alt="Searchez" width="100" />
</div>

# searchez

[![crates.io](https://img.shields.io/crates/v/searchez)](https://crates.io/crates/searchez)
[![Documentation](https://docs.rs/searchez/badge.svg)](https://docs.rs/searchez)
[![License](https://img.shields.io/crates/l/searchez)](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

```toml
[dependencies]
searchez = "1.0.0"
```

## Quick start

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

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

```rust,ignore
// 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

```rust,ignore
// 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:

```rust,ignore
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](https://www.meilisearch.com/)
server:

```toml
searchez = { version = "1.0.0", features = ["meilisearch"] }
```

```rust,ignore
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](https://open-source.srotas.space)

---

## 👥 Contributors

- **[Sandeep Maurya]https://github.com/srotas-space** - Creator & Lead Developer
  <img src="https://srotasspace.s3.ap-south-1.amazonaws.com/snm.png" alt="Sandeep Maurya" width="80" height="80" style="border-radius: 50%;">
  [LinkedIn]https://www.linkedin.com/in/snmmaurya/


---


[![GitHub stars](https://img.shields.io/github/stars/srotas-space/searchez?style=social)](https://github.com/srotas-space/searchez)